@beyondwork/docx-react-component 1.0.83 → 1.0.85

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 (55) hide show
  1. package/package.json +1 -1
  2. package/src/api/internal/build-ref-projections.ts +3 -0
  3. package/src/api/public-types.ts +86 -4
  4. package/src/api/v3/_runtime-handle.ts +15 -0
  5. package/src/api/v3/runtime/content.ts +148 -1
  6. package/src/api/v3/runtime/formatting.ts +41 -0
  7. package/src/api/v3/runtime/review.ts +98 -0
  8. package/src/api/v3/runtime/workflow.ts +154 -6
  9. package/src/core/commands/index.ts +81 -25
  10. package/src/core/state/editor-state.ts +15 -0
  11. package/src/io/export/serialize-main-document.ts +72 -6
  12. package/src/io/ooxml/header-footer-reference.ts +38 -0
  13. package/src/io/ooxml/parse-headers-footers.ts +11 -23
  14. package/src/io/ooxml/parse-main-document.ts +7 -10
  15. package/src/io/ooxml/workflow-payload-validator.ts +24 -0
  16. package/src/io/ooxml/workflow-payload.ts +12 -0
  17. package/src/model/canonical-document.ts +9 -0
  18. package/src/model/review/comment-types.ts +2 -0
  19. package/src/runtime/document-runtime.ts +718 -68
  20. package/src/runtime/formatting/field/resolver.ts +73 -8
  21. package/src/runtime/layout/layout-engine-version.ts +31 -12
  22. package/src/runtime/layout/paginated-layout-engine.ts +18 -11
  23. package/src/runtime/layout/public-facet.ts +119 -16
  24. package/src/runtime/layout/resolve-page-fields.ts +68 -6
  25. package/src/runtime/layout/resolve-page-previews.ts +1 -1
  26. package/src/runtime/scopes/_scope-dependencies.ts +1 -0
  27. package/src/runtime/scopes/action-validation.ts +54 -45
  28. package/src/runtime/scopes/workflow-overlap.ts +41 -9
  29. package/src/runtime/suggestions-snapshot.ts +24 -0
  30. package/src/runtime/surface-projection.ts +59 -2
  31. package/src/runtime/workflow/coordinator.ts +66 -14
  32. package/src/runtime/workflow/scope-writer.ts +83 -5
  33. package/src/shell/ref-commands.ts +3 -354
  34. package/src/shell/session-bootstrap.ts +10 -0
  35. package/src/ui/WordReviewEditor.tsx +99 -9
  36. package/src/ui/editor-command-bag.ts +3 -1
  37. package/src/ui/headless/revision-decoration-model.ts +13 -0
  38. package/src/ui/headless/selection-tool-types.ts +2 -0
  39. package/src/ui-tailwind/chrome/tw-suggestion-card.tsx +7 -3
  40. package/src/ui-tailwind/chrome/tw-table-context-toolbar.tsx +175 -25
  41. package/src/ui-tailwind/chrome-overlay/tw-chrome-overlay.tsx +1 -1
  42. package/src/ui-tailwind/editor-surface/pm-decorations.ts +12 -0
  43. package/src/ui-tailwind/editor-surface/pm-page-break-decorations.ts +18 -30
  44. package/src/ui-tailwind/editor-surface/pm-state-from-snapshot.ts +1 -1
  45. package/src/ui-tailwind/editor-surface/tw-page-block-view.tsx +1 -1
  46. package/src/ui-tailwind/page-stack/tw-page-chrome-entry.tsx +20 -11
  47. package/src/ui-tailwind/page-stack/tw-page-footer-band.tsx +9 -4
  48. package/src/ui-tailwind/page-stack/tw-page-header-band.tsx +12 -7
  49. package/src/ui-tailwind/page-stack/tw-page-stack-chrome-layer.tsx +29 -10
  50. package/src/ui-tailwind/page-stack/tw-region-block-renderer.tsx +1 -1
  51. package/src/ui-tailwind/review-workspace/types.ts +3 -2
  52. package/src/ui-tailwind/review-workspace/use-page-markers.ts +11 -1
  53. package/src/ui-tailwind/toolbar/tw-role-action-region.tsx +2 -1
  54. package/src/ui-tailwind/toolbar/tw-toolbar.tsx +2 -1
  55. package/src/ui-tailwind/tw-review-workspace.tsx +18 -2
@@ -3,11 +3,13 @@
3
3
  *
4
4
  * Given a scope's canonical range and the workflow overlay, returns the
5
5
  * `SemanticScopeWorkflow` projection: the ids of overlay scopes overlapping
6
- * the range + the most-restrictive `effectiveMode` across them +
7
- * `blockedReasons` when a `view`-mode overlap blocks the scope.
6
+ * the range + the most-restrictive enforced `effectiveMode` across scopes
7
+ * whose `guardPolicy` participates in editing + `blockedReasons` when a
8
+ * read-only overlap blocks the scope.
8
9
  *
9
10
  * The most-restrictive rule matches layer 06's `InteractionGuardSnapshot`
10
- * composition (see `docs/architecture/06-workflow-review.md` §W3):
11
+ * composition for guard-participating scopes (see
12
+ * `docs/architecture/06-workflow-review.md` §W3):
11
13
  * view > comment > suggest > edit
12
14
  *
13
15
  * When no overlay is threaded, or no overlap exists, the returned shape
@@ -19,7 +21,11 @@
19
21
  * overlap on top.
20
22
  */
21
23
 
22
- import type { WorkflowOverlay, WorkflowScope } from "./_scope-dependencies.ts";
24
+ import type {
25
+ WorkflowOverlay,
26
+ WorkflowScope,
27
+ WorkflowScopeGuardPolicy,
28
+ } from "./_scope-dependencies.ts";
23
29
 
24
30
  import type { ScopePositionMap, ScopePositionRange } from "./position-map.ts";
25
31
  import { rangesOverlap } from "./scope-range.ts";
@@ -27,6 +33,14 @@ import type { SemanticScopeWorkflow } from "./semantic-scope-types.ts";
27
33
 
28
34
  type WorkflowMode = "edit" | "suggest" | "comment" | "view";
29
35
 
36
+ function getScopeGuardPolicy(scope: WorkflowScope): WorkflowScopeGuardPolicy {
37
+ return scope.guardPolicy ?? (scope.mode === "view" ? "read-only" : "none");
38
+ }
39
+
40
+ function getScopeGuardMode(scope: WorkflowScope): WorkflowMode {
41
+ return getScopeGuardPolicy(scope) === "read-only" ? "view" : scope.mode;
42
+ }
43
+
30
44
  function modeRank(mode: WorkflowMode): number {
31
45
  // Higher = more restrictive (wins the merge).
32
46
  switch (mode) {
@@ -45,6 +59,21 @@ function mergeModes(a: WorkflowMode, b: WorkflowMode): WorkflowMode {
45
59
  return modeRank(a) >= modeRank(b) ? a : b;
46
60
  }
47
61
 
62
+ function rangeForWorkflowScope(
63
+ scope: WorkflowScope,
64
+ positionMap: ScopePositionMap,
65
+ ): ScopePositionRange | null {
66
+ const markerRange = positionMap.markerScopes.get(scope.scopeId);
67
+ if (markerRange) return markerRange;
68
+ if (scope.anchor.kind === "range") {
69
+ return { from: scope.anchor.from, to: scope.anchor.to };
70
+ }
71
+ if (scope.anchor.kind === "node") {
72
+ return { from: scope.anchor.at, to: scope.anchor.at };
73
+ }
74
+ return null;
75
+ }
76
+
48
77
  export interface WorkflowOverlapInputs {
49
78
  readonly overlay: WorkflowOverlay | null | undefined;
50
79
  readonly positionMap: ScopePositionMap;
@@ -73,12 +102,15 @@ export function resolveWorkflowOverlap(
73
102
  let mode: WorkflowMode = "edit";
74
103
  for (const scope of overlay.scopes as readonly WorkflowScope[]) {
75
104
  if (selfScopeIds && selfScopeIds.has(scope.scopeId)) continue;
76
- const markerRange = positionMap.markerScopes.get(scope.scopeId);
77
- if (!markerRange) continue;
78
- if (!rangesOverlap(range, markerRange)) continue;
105
+ const scopeRange = rangeForWorkflowScope(scope, positionMap);
106
+ if (!scopeRange) continue;
107
+ if (!rangesOverlap(range, scopeRange)) continue;
79
108
  overlappingIds.push(scope.scopeId);
80
- mode = mergeModes(mode, scope.mode);
81
- if (scope.mode === "view") {
109
+ const guardPolicy = getScopeGuardPolicy(scope);
110
+ if (guardPolicy === "none") continue;
111
+ const guardMode = getScopeGuardMode(scope);
112
+ mode = mergeModes(mode, guardMode);
113
+ if (guardMode === "view") {
82
114
  blockedReasons.push(`workflow-scope-view:${scope.scopeId}`);
83
115
  }
84
116
  }
@@ -68,6 +68,26 @@ function summarizeActionability(
68
68
  : "actionable";
69
69
  }
70
70
 
71
+ function collectLinkedCommentThreadIds(
72
+ revisions: TrackedChangeEntrySnapshot[],
73
+ ): string[] | undefined {
74
+ const ids = new Set<string>();
75
+ for (const revision of revisions) {
76
+ for (const commentThreadId of revision.commentThreadIds ?? []) {
77
+ ids.add(commentThreadId);
78
+ }
79
+ }
80
+ return ids.size > 0 ? [...ids] : undefined;
81
+ }
82
+
83
+ function countLinkedReplies(revisions: TrackedChangeEntrySnapshot[]): number {
84
+ let count = 0;
85
+ for (const revision of revisions) {
86
+ count += revision.replyCount ?? 0;
87
+ }
88
+ return count;
89
+ }
90
+
71
91
  export function createSuggestionsSnapshot(
72
92
  trackedChanges: TrackedChangesSnapshot,
73
93
  ): SuggestionsSnapshot {
@@ -89,6 +109,8 @@ export function createSuggestionsSnapshot(
89
109
  const kind = toSemanticKind(primary);
90
110
  const status = summarizeStatus(revisions);
91
111
  const actionability = summarizeActionability(revisions);
112
+ const commentThreadIds = collectLinkedCommentThreadIds(revisions);
113
+ const replyCount = countLinkedReplies(revisions);
92
114
  return {
93
115
  suggestionId,
94
116
  kind,
@@ -108,6 +130,8 @@ export function createSuggestionsSnapshot(
108
130
  preserveOnlyReason: primary.preserveOnlyReason,
109
131
  excerpt: primary.excerpt,
110
132
  detail: primary.detail,
133
+ ...(commentThreadIds ? { commentThreadIds } : {}),
134
+ ...(replyCount > 0 ? { replyCount } : {}),
111
135
  };
112
136
  })
113
137
  .sort(compareSuggestions);
@@ -1536,7 +1536,34 @@ function appendInlineSegments(
1536
1536
  node.fieldFamily === "NOTEREF" ||
1537
1537
  node.fieldFamily === "TOC" ||
1538
1538
  node.fieldFamily === "PAGE" ||
1539
- node.fieldFamily === "NUMPAGES";
1539
+ node.fieldFamily === "NUMPAGES" ||
1540
+ node.fieldFamily === "SECTIONPAGES";
1541
+ const isPageScopedField =
1542
+ node.fieldFamily === "PAGE" ||
1543
+ node.fieldFamily === "NUMPAGES" ||
1544
+ node.fieldFamily === "SECTIONPAGES";
1545
+ if (isPageScopedField) {
1546
+ const fieldLabel =
1547
+ node.fieldFamily === "PAGE"
1548
+ ? "Current page number"
1549
+ : node.fieldFamily === "NUMPAGES"
1550
+ ? "Total pages"
1551
+ : "Section pages";
1552
+ const displayText = flattenSurfaceFieldDisplayText(node.children);
1553
+ paragraph.segments.push({
1554
+ segmentId: `${paragraph.blockId}-segment-${paragraph.segments.length}`,
1555
+ kind: "field_ref",
1556
+ from: start,
1557
+ to: start + 1,
1558
+ fieldFamily: node.fieldFamily,
1559
+ fieldTarget: node.fieldTarget,
1560
+ instruction: node.instruction,
1561
+ refreshStatus: node.refreshStatus ?? "stale",
1562
+ label: fieldLabel,
1563
+ ...(displayText ? { displayText } : {}),
1564
+ } as SurfaceInlineSegment);
1565
+ return { nextCursor: start + 1, lockedFragmentIds: [] };
1566
+ }
1540
1567
  if (node.children && node.children.length > 0) {
1541
1568
  // For REF \h, pass the bookmark as a hyperlink href so child text gets hyperlink styling
1542
1569
  const refHyperlinkHref =
@@ -1574,7 +1601,9 @@ function appendInlineSegments(
1574
1601
  ? "Current page number"
1575
1602
  : node.fieldFamily === "NUMPAGES"
1576
1603
  ? "Total pages"
1577
- : `${node.fieldFamily ?? "Field"}: ${node.fieldTarget ?? node.instruction.trim()}`;
1604
+ : node.fieldFamily === "SECTIONPAGES"
1605
+ ? "Section pages"
1606
+ : `${node.fieldFamily ?? "Field"}: ${node.fieldTarget ?? node.instruction.trim()}`;
1578
1607
  paragraph.segments.push({
1579
1608
  segmentId: `${paragraph.blockId}-segment-${paragraph.segments.length}`,
1580
1609
  kind: "field_ref",
@@ -1743,6 +1772,34 @@ function normalizeSafeCssHexColor(value: string | undefined): string | undefined
1743
1772
  return trimmed.replace(/^#/, "").toUpperCase();
1744
1773
  }
1745
1774
 
1775
+ function flattenSurfaceFieldDisplayText(
1776
+ children: readonly InlineNode[] | undefined,
1777
+ ): string {
1778
+ if (!children || children.length === 0) return "";
1779
+ const parts: string[] = [];
1780
+ for (const child of children) {
1781
+ switch (child.type) {
1782
+ case "text":
1783
+ parts.push(child.text);
1784
+ break;
1785
+ case "tab":
1786
+ case "hard_break":
1787
+ parts.push(" ");
1788
+ break;
1789
+ case "symbol":
1790
+ parts.push(child.char ? String.fromCodePoint(parseInt(child.char, 16)) : "\uFFFD");
1791
+ break;
1792
+ case "field":
1793
+ case "hyperlink":
1794
+ parts.push(flattenSurfaceFieldDisplayText(child.children));
1795
+ break;
1796
+ default:
1797
+ break;
1798
+ }
1799
+ }
1800
+ return parts.join("");
1801
+ }
1802
+
1746
1803
  /**
1747
1804
  * V2c.5 — Extract the first paragraph's plain text from a parsed
1748
1805
  * `txbxBlocks` tree for the `txbxText` segment preview. The recursion
@@ -64,6 +64,7 @@ import type {
64
64
  WorkflowMetadataSnapshot,
65
65
  WorkflowOverlay,
66
66
  WorkflowScope,
67
+ WorkflowScopeGuardPolicy,
67
68
  WorkflowScopeMode,
68
69
  WorkflowScopeSnapshot,
69
70
  } from "../../api/public-types.ts";
@@ -227,6 +228,11 @@ export interface WorkflowCoordinator {
227
228
  addInvisibleScope(params: AddScopeParams): AddScopeResult;
228
229
  setScopeVisibility(scopeId: string, visibility: ScopeVisibility): void;
229
230
  getScopeVisibility(scopeId: string): ScopeVisibility;
231
+ setScopeGuardPolicy(
232
+ scopeId: string,
233
+ guardPolicy: WorkflowScopeGuardPolicy,
234
+ ): void;
235
+ getScopeGuardPolicy(scopeId: string): WorkflowScopeGuardPolicy;
230
236
  getScope(scopeId: string): WorkflowScope | null;
231
237
  getMarkerBackedScopeIds(): ReadonlySet<string>;
232
238
  /* --- scope chrome visibility (local view state) --- */
@@ -316,6 +322,18 @@ const MODE_RESTRICTIVENESS: Record<WorkflowScopeMode, number> = {
316
322
  view: 3,
317
323
  };
318
324
 
325
+ function resolveScopeGuardPolicy(scope: WorkflowScope): WorkflowScopeGuardPolicy {
326
+ return scope.guardPolicy ?? (scope.mode === "view" ? "read-only" : "none");
327
+ }
328
+
329
+ function getScopeGuardMode(scope: WorkflowScope): WorkflowScopeMode {
330
+ return resolveScopeGuardPolicy(scope) === "read-only" ? "view" : scope.mode;
331
+ }
332
+
333
+ function participatesInInteractionGuard(scope: WorkflowScope): boolean {
334
+ return resolveScopeGuardPolicy(scope) !== "none";
335
+ }
336
+
319
337
  export function createWorkflowCoordinator(deps: CoordinatorDeps): WorkflowCoordinator {
320
338
  const { overlayStore, clock } = deps;
321
339
 
@@ -413,8 +431,7 @@ export function createWorkflowCoordinator(deps: CoordinatorDeps): WorkflowCoordi
413
431
  };
414
432
  const activeScopes = getEffectiveWorkflowScopes(overlay);
415
433
  const matching = activeScopes.filter((scope) => {
416
- // §C8: invisible non-view scopes are transparent to the guard.
417
- if (scope.visibility === "invisible" && scope.mode !== "view") return false;
434
+ if (!participatesInInteractionGuard(scope)) return false;
418
435
  if (scope.anchor.kind === "detached") return false;
419
436
  const scopeFrom =
420
437
  scope.anchor.kind === "range" ? scope.anchor.from : scope.anchor.at;
@@ -450,8 +467,8 @@ export function createWorkflowCoordinator(deps: CoordinatorDeps): WorkflowCoordi
450
467
  if (stack.length === 0) return null;
451
468
  // §C6 — most-restrictive-wins across overlapping scopes.
452
469
  return stack.reduce((best, scope) =>
453
- (MODE_RESTRICTIVENESS[scope.mode] ?? 0) >
454
- (MODE_RESTRICTIVENESS[best.mode] ?? 0)
470
+ (MODE_RESTRICTIVENESS[getScopeGuardMode(scope)] ?? 0) >
471
+ (MODE_RESTRICTIVENESS[getScopeGuardMode(best)] ?? 0)
455
472
  ? scope
456
473
  : best,
457
474
  );
@@ -463,7 +480,9 @@ export function createWorkflowCoordinator(deps: CoordinatorDeps): WorkflowCoordi
463
480
  const mode = deps.getDocumentMode();
464
481
  if (mode === "viewing" || mode === "commenting") return mode;
465
482
  const matchingScope = getMatchingWorkflowScope(selection);
466
- if (matchingScope?.mode === "suggest") return "suggesting";
483
+ if (matchingScope && getScopeGuardMode(matchingScope) === "suggest") {
484
+ return "suggesting";
485
+ }
467
486
  return mode;
468
487
  }
469
488
 
@@ -536,17 +555,18 @@ export function createWorkflowCoordinator(deps: CoordinatorDeps): WorkflowCoordi
536
555
  if (normalized) {
537
556
  const matchingScope = getMatchingWorkflowScope(selection);
538
557
  const activeScopes = getEffectiveWorkflowScopes(normalized);
539
- const guardingScopes = activeScopes.filter(
540
- (s) => !(s.visibility === "invisible" && s.mode !== "view"),
558
+ const insertOnlyScopes = activeScopes.filter(
559
+ (s) => resolveScopeGuardPolicy(s) === "insert-only",
541
560
  );
542
561
 
543
- if (!matchingScope && guardingScopes.length > 0) {
562
+ if (!matchingScope && insertOnlyScopes.length > 0) {
544
563
  reasons.push({
545
564
  code: "outside_workflow_scope",
546
- message: "Selection is outside any active workflow scope.",
565
+ message: "Selection is outside any active insert-only workflow scope.",
547
566
  });
548
567
  } else if (matchingScope) {
549
- if (matchingScope.mode === "comment") {
568
+ const guardMode = getScopeGuardMode(matchingScope);
569
+ if (guardMode === "comment") {
550
570
  const isCommentCommand = commandType?.startsWith("comment.") ?? false;
551
571
  if (!isCommentCommand) {
552
572
  reasons.push({
@@ -556,7 +576,7 @@ export function createWorkflowCoordinator(deps: CoordinatorDeps): WorkflowCoordi
556
576
  workItemId: matchingScope.workItemId,
557
577
  });
558
578
  }
559
- } else if (matchingScope.mode === "view") {
579
+ } else if (guardMode === "view") {
560
580
  reasons.push({
561
581
  code: "workflow_view_only",
562
582
  message: `Scope "${matchingScope.label ?? matchingScope.scopeId}" is view-only.`,
@@ -618,6 +638,9 @@ export function createWorkflowCoordinator(deps: CoordinatorDeps): WorkflowCoordi
618
638
  const matchingScope = getMatchingWorkflowScope(state.selection);
619
639
  const scopeStack = buildMatchingScopeStack(state.selection);
620
640
  const primaryBlockedReason = blockedReasons[0];
641
+ const matchingGuardMode = matchingScope
642
+ ? getScopeGuardMode(matchingScope)
643
+ : undefined;
621
644
  const effectiveMode = primaryBlockedReason
622
645
  ? primaryBlockedReason.code === "workflow_comment_only"
623
646
  ? "comment"
@@ -626,19 +649,19 @@ export function createWorkflowCoordinator(deps: CoordinatorDeps): WorkflowCoordi
626
649
  : "blocked"
627
650
  : getEffectiveDocumentMode(state.selection) === "suggesting"
628
651
  ? "suggest"
629
- : matchingScope?.mode ?? "edit";
652
+ : matchingGuardMode ?? "edit";
630
653
  const matchedScopeStack: InteractionGuardSnapshot["matchedScopeStack"] =
631
654
  scopeStack.length > 0
632
655
  ? scopeStack.map((s) => ({
633
656
  scopeId: s.scopeId,
634
- mode: s.mode,
657
+ mode: getScopeGuardMode(s),
635
658
  visibility: s.visibility ?? "visible",
636
659
  }))
637
660
  : undefined;
638
661
  const snapshot: InteractionGuardSnapshot = {
639
662
  effectiveMode,
640
663
  ...(matchingScope?.scopeId ? { matchedScopeId: matchingScope.scopeId } : {}),
641
- ...(matchingScope?.mode ? { matchedScopeMode: matchingScope.mode } : {}),
664
+ ...(matchingGuardMode ? { matchedScopeMode: matchingGuardMode } : {}),
642
665
  ...(matchedScopeStack ? { matchedScopeStack } : {}),
643
666
  targetAccess:
644
667
  effectiveMode === "edit"
@@ -888,6 +911,8 @@ export function createWorkflowCoordinator(deps: CoordinatorDeps): WorkflowCoordi
888
911
  anchor: publicAnchor,
889
912
  ...(params.storyTarget ? { storyTarget: params.storyTarget } : {}),
890
913
  ...(params.label ? { label: params.label } : {}),
914
+ ...(params.visibility ? { visibility: params.visibility } : {}),
915
+ ...(params.guardPolicy ? { guardPolicy: params.guardPolicy } : {}),
891
916
  ...(params.scopeMetadataFields && params.scopeMetadataFields.length > 0
892
917
  ? { metadata: [...params.scopeMetadataFields] }
893
918
  : {}),
@@ -1004,6 +1029,31 @@ export function createWorkflowCoordinator(deps: CoordinatorDeps): WorkflowCoordi
1004
1029
  return scope?.visibility ?? "visible";
1005
1030
  }
1006
1031
 
1032
+ function setScopeGuardPolicy(
1033
+ scopeId: string,
1034
+ guardPolicy: WorkflowScopeGuardPolicy,
1035
+ ): void {
1036
+ const overlay = overlayStore.getOverlay();
1037
+ if (!overlay) return;
1038
+ const idx = overlay.scopes.findIndex((s) => s.scopeId === scopeId);
1039
+ if (idx === -1) return;
1040
+ const nextScopes = overlay.scopes.map((s) =>
1041
+ s.scopeId === scopeId ? { ...s, guardPolicy } : s,
1042
+ );
1043
+ deps.dispatch({
1044
+ type: "workflow.set-overlay",
1045
+ overlay: { ...overlay, scopes: nextScopes },
1046
+ origin: { source: "api", at: clock() },
1047
+ });
1048
+ }
1049
+
1050
+ function getScopeGuardPolicy(scopeId: string): WorkflowScopeGuardPolicy {
1051
+ const overlay = overlayStore.getOverlay();
1052
+ if (!overlay) return "none";
1053
+ const scope = overlay.scopes.find((s) => s.scopeId === scopeId);
1054
+ return scope ? resolveScopeGuardPolicy(scope) : "none";
1055
+ }
1056
+
1007
1057
  function getScope(scopeId: string): WorkflowScope | null {
1008
1058
  const normalized = getNormalizedOverlay();
1009
1059
  const fromOverlay = normalized?.scopes.find((s) => s.scopeId === scopeId);
@@ -1360,6 +1410,8 @@ export function createWorkflowCoordinator(deps: CoordinatorDeps): WorkflowCoordi
1360
1410
  addInvisibleScope,
1361
1411
  setScopeVisibility,
1362
1412
  getScopeVisibility,
1413
+ setScopeGuardPolicy,
1414
+ getScopeGuardPolicy,
1363
1415
  getScope,
1364
1416
  getMarkerBackedScopeIds: () => overlayStore.getMarkerBackedScopeIds(),
1365
1417
  setScopeChromeVisibility,
@@ -37,6 +37,8 @@ import type {
37
37
  EditorAnchorProjection,
38
38
  EditorStoryTarget,
39
39
  RuntimeRenderSnapshot,
40
+ ScopeVisibility,
41
+ WorkflowScopeGuardPolicy,
40
42
  WorkflowMetadataEntry,
41
43
  WorkflowMetadataPersistence,
42
44
  WorkflowScopeMetadataField,
@@ -62,6 +64,8 @@ export interface CreateScopeFromBlockIdInput {
62
64
  readonly persistence?: WorkflowMetadataPersistence;
63
65
  readonly metadata?: Partial<WorkflowMetadataEntry>;
64
66
  readonly storyTarget?: EditorStoryTarget;
67
+ readonly visibility?: ScopeVisibility;
68
+ readonly guardPolicy?: WorkflowScopeGuardPolicy;
65
69
  /**
66
70
  * Coord-06 §13d — per-scope edge stickiness for the range anchor.
67
71
  * Defaults to `{ start: 1, end: -1 }` (greedy — absorbs boundary
@@ -98,7 +102,23 @@ export interface CreateScopeFromBlockIdInput {
98
102
 
99
103
  export type CreateScopeFromBlockIdResult =
100
104
  | { readonly status: "created"; readonly scopeId: string; readonly anchor: EditorAnchorProjection }
101
- | { readonly status: "block-not-found"; readonly blockId: string };
105
+ | { readonly status: "block-not-found"; readonly blockId: string }
106
+ | {
107
+ readonly status: "range-invalid";
108
+ readonly scopeId: "";
109
+ readonly reason:
110
+ | "range-exceeds-story-length"
111
+ | "non-paragraph-target"
112
+ | "empty-document";
113
+ readonly from: number;
114
+ readonly to: number;
115
+ readonly storyLength: number;
116
+ /** Non-paragraph target only — the offending block's index and kind. */
117
+ readonly blockIndex?: number;
118
+ readonly blockKind?: string;
119
+ readonly message: string;
120
+ readonly nextStep: string;
121
+ };
102
122
 
103
123
  function inlineLength(node: InlineNode): number {
104
124
  switch (node.type) {
@@ -190,8 +210,9 @@ export function createScopeFromBlockId(
190
210
  runtime: ScopeWriterRuntime,
191
211
  input: CreateScopeFromBlockIdInput,
192
212
  ): CreateScopeFromBlockIdResult {
213
+ const document = runtime.getCanonicalDocument();
193
214
  const anchor = resolveBlockAnchorFromCanonical(
194
- runtime.getCanonicalDocument(),
215
+ document,
195
216
  input.blockId,
196
217
  input.assoc,
197
218
  );
@@ -216,10 +237,63 @@ export function createScopeFromBlockId(
216
237
  metadata: input.metadata,
217
238
  storyTarget: input.storyTarget,
218
239
  label: input.label,
240
+ ...(input.visibility ? { visibility: input.visibility } : {}),
241
+ ...(input.guardPolicy ? { guardPolicy: input.guardPolicy } : {}),
219
242
  ...(scopeMetadataFields.length > 0
220
243
  ? { scopeMetadataFields }
221
244
  : {}),
222
245
  });
246
+ if (result.plantStatus && result.plantStatus.planted === false) {
247
+ const ps = result.plantStatus;
248
+ const from = anchor.kind === "range" ? anchor.from : 0;
249
+ const to = anchor.kind === "range" ? anchor.to : from;
250
+ const storyLength = ps.storyLength ?? computeMainStoryLength(document);
251
+ if (ps.reason === "non-paragraph-target") {
252
+ return {
253
+ status: "range-invalid",
254
+ scopeId: "",
255
+ reason: "non-paragraph-target",
256
+ from,
257
+ to,
258
+ storyLength,
259
+ blockIndex: ps.blockIndex ?? -1,
260
+ blockKind: ps.blockKind ?? "unknown",
261
+ message:
262
+ `createScope refused blockId "${input.blockId}": it resolves to a ` +
263
+ `${ps.blockKind ?? "non-paragraph"} block (index ${ps.blockIndex}). ` +
264
+ `Marker-backed scopes only plant inside paragraphs today; choose a ` +
265
+ `paragraph block or create an overlay-only scope for table/SDT metadata.`,
266
+ nextStep: "pick-a-paragraph-block-or-use-overlay-only-scope",
267
+ };
268
+ }
269
+ if (ps.reason === "range-out-of-bounds") {
270
+ return {
271
+ status: "range-invalid",
272
+ scopeId: "",
273
+ reason: "range-exceeds-story-length",
274
+ from,
275
+ to,
276
+ storyLength,
277
+ message:
278
+ `createScope refused blockId "${input.blockId}": resolved range ` +
279
+ `[${from}, ${to}] exceeds the current story length (${storyLength}). ` +
280
+ `Re-query block ids from the current render snapshot before retrying.`,
281
+ nextStep: "re-query-current-block-id",
282
+ };
283
+ }
284
+ return {
285
+ status: "range-invalid",
286
+ scopeId: "",
287
+ reason: "empty-document",
288
+ from,
289
+ to,
290
+ storyLength,
291
+ message:
292
+ `createScope refused blockId "${input.blockId}": the target document ` +
293
+ `has no blocks, so scope markers cannot be planted.`,
294
+ nextStep: "initialize-document-before-creating-scopes",
295
+ };
296
+ }
223
297
  return { status: "created", scopeId: result.scopeId, anchor: result.anchor };
224
298
  }
225
299
 
@@ -251,6 +325,8 @@ export interface CreateScopeFromAnchorInput {
251
325
  readonly scopeId?: string;
252
326
  readonly persistence?: WorkflowMetadataPersistence;
253
327
  readonly metadata?: Partial<WorkflowMetadataEntry>;
328
+ readonly visibility?: ScopeVisibility;
329
+ readonly guardPolicy?: WorkflowScopeGuardPolicy;
254
330
  /**
255
331
  * Per-scope edge stickiness for the range anchor. Defaults to
256
332
  * `{ start: 1, end: -1 }` (greedy — absorbs boundary inserts). See
@@ -423,6 +499,8 @@ export function createScopeFromAnchor(
423
499
  metadata: input.metadata,
424
500
  storyTarget,
425
501
  label: input.label,
502
+ ...(input.visibility ? { visibility: input.visibility } : {}),
503
+ ...(input.guardPolicy ? { guardPolicy: input.guardPolicy } : {}),
426
504
  ...(scopeMetadataFields.length > 0 ? { scopeMetadataFields } : {}),
427
505
  });
428
506
 
@@ -448,9 +526,9 @@ export function createScopeFromAnchor(
448
526
  message:
449
527
  `createScopeFromAnchor refused: range [${from}, ${to}] targets a ` +
450
528
  `${ps.blockKind ?? "non-paragraph"} block (index ${ps.blockIndex}). ` +
451
- `Marker scopes only plant inside paragraphs today. Pick a paragraph ` +
452
- `target, or use runtime.workflow.createScope({blockId}) for ` +
453
- `whole-block scopes on the containing structure.`,
529
+ `Marker-backed scopes only plant inside paragraphs today. Pick a ` +
530
+ `paragraph target, or create an overlay-only scope for table/SDT ` +
531
+ `metadata until structural marker support lands.`,
454
532
  nextStep: "pick-a-paragraph-target",
455
533
  };
456
534
  }