@beyondwork/docx-react-component 1.0.78 → 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.
@@ -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,
@@ -65,6 +65,7 @@ export function createInertLayoutFacet(): WordReviewEditorLayoutFacet {
65
65
  invalidateMeasurementCache: () => undefined,
66
66
  getTableRenderPlan: () => null,
67
67
  getTableBodyYOffsetOnPage: () => null,
68
+ getBlockHeightsTwips: () => new Map(),
68
69
  getDirtyFieldFamilies: () => [],
69
70
  getFieldDirtinessReport: () => emptyReport,
70
71
  setVisibleBlockRange: () => undefined,
@@ -955,8 +955,34 @@
955
955
  * underlying layout algorithm is unchanged — persisted envelopes
956
956
  * remain shape-compatible. Bump is defensive so any consumer that
957
957
  * keyed on the facet contract refreshes the cache.
958
+ *
959
+ * 57 — viewport-cull flicker fix. The pre-v57 PM placeholder emitted
960
+ * for `placeholder-culled` opaque blocks rendered at
961
+ * `min-height: 20px` regardless of the real block's visual height
962
+ * (`src/ui-tailwind/editor-surface/pm-schema.ts`), because neither
963
+ * L03 surface-projection nor L11 PM state-build had access to
964
+ * layout-derived heights. Scrolling into a culled region inflated
965
+ * every block in turn, dragging content below the scroll pointer —
966
+ * the long-standing "paragraphs jump around pagination gaps"
967
+ * flicker.
968
+ *
969
+ * L04 now exposes `WordReviewEditorLayoutFacet.getBlockHeightsTwips():
970
+ * ReadonlyMap<string, number>` — one entry per blockId, value = sum
971
+ * of that block's fragments' `heightTwips`. Cached per
972
+ * `graph.revision`. `DocumentRuntime` reads the map after each
973
+ * surface-projection pass and enriches every
974
+ * `placeholder-culled` `opaque_block` with
975
+ * `placeholderHeightTwips`. The PM schema's paragraph node gained
976
+ * a `placeholderHeightTwips` attr; `toDOM` emits
977
+ * `height: ${twips/20}pt` instead of the `min-height: 20px`
978
+ * fallback when the attr is set.
979
+ *
980
+ * Pagination itself is untouched — this is purely a render-surface
981
+ * fix. Cache envelopes from v56 invalidate because the exposed
982
+ * facet surface grew one public method; any consumer relying on
983
+ * the prior interface shape re-derives its cache key under v57.
958
984
  */
959
- export const LAYOUT_ENGINE_VERSION = 56 as const;
985
+ export const LAYOUT_ENGINE_VERSION = 57 as const;
960
986
 
961
987
  /**
962
988
  * Serialization schema version for the LayCache payload (the cache envelope
@@ -569,6 +569,19 @@ export interface WordReviewEditorLayoutFacet {
569
569
  */
570
570
  getTableBodyYOffsetOnPage(blockId: string, pageIndex: number): number | null;
571
571
 
572
+ /**
573
+ * Viewport-cull height resolver — returns total rendered height (twips)
574
+ * for every block in the current page graph, computed as the sum of each
575
+ * fragment's `heightTwips` grouped by `blockId`. Consumers (the render
576
+ * surface builder in particular) use this to size `placeholder-culled`
577
+ * opaque stubs so the scrollable canvas does not change height when a
578
+ * block realizes during scroll.
579
+ *
580
+ * Returns an empty map on the inert facet or before the first successful
581
+ * pagination pass. Cached per `graph.revision`.
582
+ */
583
+ getBlockHeightsTwips(): ReadonlyMap<string, number>;
584
+
572
585
  // Fields ---------------------------------------------------------------
573
586
  getDirtyFieldFamilies(): readonly string[];
574
587
  getFieldDirtinessReport(): PublicFieldDirtinessReport;
@@ -707,6 +720,13 @@ export function createLayoutFacet(
707
720
  revision: number;
708
721
  blocks: readonly PublicRegionBlock[] | null;
709
722
  } = { revision: -1, blocks: null };
723
+ // Viewport-cull flicker fix — per-revision cache for getBlockHeightsTwips.
724
+ // One entry per blockId; value is the sum of that block's fragments'
725
+ // `heightTwips`. Busts on `graph.revision` change.
726
+ let blockHeightsCache: {
727
+ revision: number;
728
+ map: ReadonlyMap<string, number> | null;
729
+ } = { revision: -1, map: null };
710
730
 
711
731
  function resolveCanonicalDocument(): CanonicalDocumentEnvelope {
712
732
  if (input.canonicalDocument) {
@@ -1234,6 +1254,21 @@ export function createLayoutFacet(
1234
1254
  return null;
1235
1255
  },
1236
1256
 
1257
+ getBlockHeightsTwips() {
1258
+ const graph = currentGraph();
1259
+ if (blockHeightsCache.revision === graph.revision && blockHeightsCache.map) {
1260
+ return blockHeightsCache.map;
1261
+ }
1262
+ const map = new Map<string, number>();
1263
+ for (const frag of graph.fragments) {
1264
+ const prev = map.get(frag.blockId) ?? 0;
1265
+ map.set(frag.blockId, prev + frag.heightTwips);
1266
+ }
1267
+ const frozen: ReadonlyMap<string, number> = map;
1268
+ blockHeightsCache = { revision: graph.revision, map: frozen };
1269
+ return frozen;
1270
+ },
1271
+
1237
1272
  getDirtyFieldFamilies() {
1238
1273
  return engine.getDirtyFieldFamilies();
1239
1274
  },
@@ -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;
@@ -791,19 +791,69 @@ export function createWorkflowCoordinator(deps: CoordinatorDeps): WorkflowCoordi
791
791
  return { scopeId, anchor: params.anchor };
792
792
  }
793
793
 
794
- const { document: nextDocument } = insertScopeMarkers(
795
- deps.getDocument(),
796
- { scopeId, from: anchor.from, to: anchor.to },
797
- );
794
+ const plantResult = insertScopeMarkers(deps.getDocument(), {
795
+ scopeId,
796
+ from: anchor.from,
797
+ to: anchor.to,
798
+ });
798
799
 
799
- if (nextDocument !== deps.getDocument()) {
800
- deps.dispatch({
801
- type: "document.replace",
802
- document: nextDocument,
803
- origin: { source: "api", at: clock() },
804
- });
800
+ // Plant failed pre-2026-04-24 this returned silently with a dead
801
+ // scopeId; callers later saw `scope-not-resolvable`. Now surface
802
+ // the typed failure on the AddScopeResult so consumers can detect
803
+ // the plant-failed path without a round-trip through
804
+ // resolveReference.
805
+ if (plantResult.status !== "planted") {
806
+ const callerAssoc: { readonly start: -1 | 1; readonly end: -1 | 1 } =
807
+ params.anchor.kind === "range"
808
+ ? params.anchor.assoc
809
+ : { start: -1, end: 1 };
810
+ // Return the caller's input range as an informational range
811
+ // anchor. The authoritative failure signal is `scopeId: ""` +
812
+ // `plantStatus.planted === false`. The detached-anchor shape has
813
+ // a fixed reason enum (`deleted|invalidatedByStructureChange|
814
+ // importAmbiguity`) that doesn't cover plant-refused, so we keep
815
+ // the range kind and let callers discriminate via plantStatus.
816
+ return {
817
+ scopeId: "",
818
+ anchor: {
819
+ kind: "range",
820
+ from: anchor.from,
821
+ to: anchor.to,
822
+ assoc: callerAssoc,
823
+ },
824
+ plantStatus: {
825
+ planted: false,
826
+ reason: plantResult.status,
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:
830
+ ...(plantResult.status === "non-paragraph-target"
831
+ ? {
832
+ blockIndex: plantResult.blockIndex,
833
+ blockKind: plantResult.blockKind,
834
+ }
835
+ : {}),
836
+ ...(plantResult.status === "range-out-of-bounds"
837
+ ? { storyLength: plantResult.storyLength }
838
+ : {}),
839
+ requestedFrom: plantResult.from,
840
+ requestedTo: plantResult.to,
841
+ },
842
+ };
843
+ // Intentionally NOT dispatching document.replace or workflow.set-overlay —
844
+ // a failed plant must not leave a half-registered scope. Prevents the
845
+ // pre-fix "overlay carries scopeId but canonical tree has no markers"
846
+ // state that produced `scope-not-resolvable` on every follow-up call.
805
847
  }
806
848
 
849
+ const nextDocument = plantResult.document;
850
+
851
+ deps.dispatch({
852
+ type: "document.replace",
853
+ document: nextDocument,
854
+ origin: { source: "api", at: clock() },
855
+ });
856
+
807
857
  // Coord-06 §13d — preserve the caller's assoc on the public anchor.
808
858
  // resolveScope re-derives the range from the inserted markers but emits
809
859
  // a hardcoded { start: -1, end: 1 }; without this override the caller's