@beyondwork/docx-react-component 1.0.74 → 1.0.75

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.74",
4
+ "version": "1.0.75",
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": [
@@ -5625,6 +5625,13 @@ export interface ShortcutDelegationContext {
5625
5625
  selectionRange: SelectionSnapshot;
5626
5626
  }
5627
5627
 
5628
+ /**
5629
+ * Resolver contract (`resolveChromeVisibilityForPreset`): every field on
5630
+ * this interface is REQUIRED — no `| undefined`, no optional sugar. Each
5631
+ * preset's default-visibility block (`src/api/v3/ui/chrome-preset-model.ts`)
5632
+ * MUST set every field. If you add a new surface visibility flag, add it
5633
+ * here AND to all preset default blocks in the same commit.
5634
+ */
5628
5635
  export interface WordReviewEditorChromeVisibility {
5629
5636
  toolbar: boolean;
5630
5637
  alerts: boolean;
@@ -11,12 +11,16 @@
11
11
  import type { RuntimeApiHandle } from "../_runtime-handle.ts";
12
12
  import type { ApiV3FnMetadata } from "../_layer-metadata.ts";
13
13
  import type {
14
+ EditorStoryTarget,
14
15
  OverlayKind,
15
16
  OverlayVisibilityPolicy,
16
17
  WorkflowMarkupModePolicy,
17
18
  } from "../../public-types.ts";
18
19
  import { emitUxResponse } from "../_ux-response.ts";
19
- import { createScopeFromBlockId } from "../../../runtime/workflow/scope-writer.ts";
20
+ import {
21
+ createScopeFromAnchor,
22
+ createScopeFromBlockId,
23
+ } from "../../../runtime/workflow/scope-writer.ts";
20
24
  import { attachScopeMetadata } from "../../../runtime/workflow/metadata-writer.ts";
21
25
  import {
22
26
  DEFAULT_REGISTRY_ENTRIES,
@@ -113,6 +117,59 @@ export interface CreateScopeResult {
113
117
  readonly status: "created" | "block-not-found";
114
118
  }
115
119
 
120
+ /**
121
+ * Input for `createScopeFromAnchor` — sub-block marker-backed scope.
122
+ *
123
+ * Use this variant when the scope must bracket a range *within* a block
124
+ * (a phrase inside a paragraph) or across blocks (a selection that
125
+ * spans paragraph boundaries). For whole-block scopes, prefer
126
+ * `createScope({blockId})` — it stays block-aligned after edits at the
127
+ * block boundary.
128
+ *
129
+ * The `from`/`to` positions are consumed **once** at creation. After
130
+ * this call returns, use the returned `scopeId` as the reference. Do
131
+ * not cache, store, or reuse the positions — they are positional
132
+ * queries (KI-P9), valid only in the document state they were captured
133
+ * in. The scope itself is marker-backed and travels with its bracketed
134
+ * content through any number of subsequent edits.
135
+ */
136
+ export interface CreateScopeFromAnchorInput {
137
+ readonly anchor: {
138
+ /** Zero-based surface offset, inclusive. Must be `>= 0`. */
139
+ readonly from: number;
140
+ /** Zero-based surface offset, exclusive of the marker slot. Must be `>= from` and `<= storyLength`. */
141
+ readonly to: number;
142
+ /** Non-main-body story (footnote / header / endnote). Defaults to `{kind:"main"}`. */
143
+ readonly storyTarget?: EditorStoryTarget;
144
+ };
145
+ readonly mode?: "edit" | "suggest" | "comment" | "view";
146
+ readonly label?: string;
147
+ readonly assoc?: { readonly start: -1 | 1; readonly end: -1 | 1 };
148
+ readonly stableRefHint?:
149
+ | "scope-id"
150
+ | "bookmark"
151
+ | "semantic-path"
152
+ | "runtime-handle";
153
+ }
154
+
155
+ export type CreateScopeFromAnchorResult =
156
+ | { readonly scopeId: string; readonly status: "created" }
157
+ | {
158
+ readonly scopeId: "";
159
+ readonly status: "range-invalid";
160
+ readonly reason:
161
+ | "from-negative"
162
+ | "to-less-than-from"
163
+ | "range-exceeds-story-length";
164
+ readonly from: number;
165
+ readonly to: number;
166
+ readonly storyLength: number;
167
+ /** Agent-actionable single-sentence explanation. Safe to surface to LLM tool replies as-is. */
168
+ readonly message: string;
169
+ /** Machine-routable next-step hint (`"clamp-from-to-zero"` / `"swap-from-and-to"` / `"clamp-to-to-storyLength-or-pick-a-different-range"`). */
170
+ readonly nextStep: string;
171
+ };
172
+
116
173
  export const createScopeMetadata: ApiV3FnMetadata = {
117
174
  name: "runtime.workflow.createScope",
118
175
  status: "live-with-adapter",
@@ -129,6 +186,32 @@ export const createScopeMetadata: ApiV3FnMetadata = {
129
186
  rwdReference: "§Runtime API § runtime.workflow.createScope",
130
187
  };
131
188
 
189
+ export const createScopeFromAnchorMetadata: ApiV3FnMetadata = {
190
+ name: "runtime.workflow.createScopeFromAnchor",
191
+ status: "live-with-adapter",
192
+ sourceLayer: "workflow-review",
193
+ liveEvidence: {
194
+ runnerTest: "test/api/v3/workflow-create-scope-from-anchor-live.test.ts",
195
+ commit: "pending",
196
+ },
197
+ uxIntent: {
198
+ uiVisible: true,
199
+ expectsUxResponse: "scope-created",
200
+ expectedDelta:
201
+ "rail shows new scope chip for the sub-block range; editor surface refreshes with inline markers planted at from/to",
202
+ },
203
+ agentMetadata: {
204
+ readOrMutate: "mutate",
205
+ boundedScope: "selection",
206
+ auditCategory: "scope-creation",
207
+ },
208
+ stateClass: "A-canonical",
209
+ persistsTo: "customXml",
210
+ broadcastsVia: "crdt",
211
+ rwdReference:
212
+ "§Runtime API § runtime.workflow.createScopeFromAnchor. Companion to createScope({blockId}) for sub-block ranges. Consumes from/to once to plant inline scope_marker_start/end; the returned scopeId is the durable reference (KI-P9).",
213
+ };
214
+
132
215
  export interface AttachMetadataInput {
133
216
  readonly scopeId: string;
134
217
  readonly metadataId: string;
@@ -411,6 +494,52 @@ export function createWorkflowFamily(runtime: RuntimeApiHandle) {
411
494
  return { scopeId: adapterResult.scopeId, status: "created" };
412
495
  },
413
496
 
497
+ createScopeFromAnchor(
498
+ input: CreateScopeFromAnchorInput,
499
+ ): CreateScopeFromAnchorResult {
500
+ // @endStateApi — live-with-adapter. Delegates to the layer-06
501
+ // scope-writer with the anchor range passed through directly.
502
+ // Bounds are validated against the story length; out-of-bounds
503
+ // ranges return a typed `range-invalid` discriminator rather than
504
+ // inventing a scopeId.
505
+ const adapterResult = createScopeFromAnchor(runtime, {
506
+ anchor: input.anchor,
507
+ mode: input.mode,
508
+ label: input.label,
509
+ ...(input.assoc ? { assoc: input.assoc } : {}),
510
+ ...(input.stableRefHint
511
+ ? { stableRefHint: input.stableRefHint }
512
+ : {}),
513
+ });
514
+ emitUxResponse(runtime, {
515
+ apiFn: createScopeFromAnchorMetadata.name,
516
+ intent: createScopeFromAnchorMetadata.uxIntent.expectedDelta ?? "",
517
+ mockOrLive: "live",
518
+ uiVisible: true,
519
+ expectedDelta: createScopeFromAnchorMetadata.uxIntent.expectedDelta,
520
+ actualDelta:
521
+ adapterResult.status === "created"
522
+ ? {
523
+ kind: "inline-change",
524
+ payload: { scopeId: adapterResult.scopeId },
525
+ }
526
+ : undefined,
527
+ });
528
+ if (adapterResult.status !== "created") {
529
+ return {
530
+ scopeId: "",
531
+ status: "range-invalid",
532
+ reason: adapterResult.reason,
533
+ from: adapterResult.from,
534
+ to: adapterResult.to,
535
+ storyLength: adapterResult.storyLength,
536
+ message: adapterResult.message,
537
+ nextStep: adapterResult.nextStep,
538
+ };
539
+ }
540
+ return { scopeId: adapterResult.scopeId, status: "created" };
541
+ },
542
+
414
543
  getVisibilityPolicy(kind: OverlayKind): OverlayVisibilityPolicy | null {
415
544
  // @endStateApi — live. Class-A policy read; composition with
416
545
  // class-C local preference lives in L10 (`ui.overlays.getVisibility`).
@@ -17,6 +17,7 @@ import { resolveHighlightColor } from "./highlight-colors.ts";
17
17
  import type { ParseDrawingOpts } from "./parse-drawing.ts";
18
18
  import { parseFFDataFromFldChar } from "./parse-ffdata.ts";
19
19
  import { classifyFieldInstruction } from "./parse-fields.ts";
20
+ import { isSafeTableFieldInstruction } from "./table-opaque-preservation.ts";
20
21
  import { parseXmlWithOffsets as parseXml } from "./xml-parser.ts";
21
22
  import { localName, readStringAttr } from "./xml-attr-helpers.ts";
22
23
  import {
@@ -1018,8 +1019,12 @@ function containsRiskyElement(element: XmlElementNode): boolean {
1018
1019
  const instruction =
1019
1020
  readStringAttr(child, "w:instr") ??
1020
1021
  extractTextContent(child);
1021
- const classification = classifyFieldInstruction(instruction);
1022
- if (!isSafeSecondaryStoryFieldFamily(classification.family)) {
1022
+ // Coord-01 §11 unification (2026-04-24): the field-safety check
1023
+ // is delegated to the shared `isSafeTableFieldInstruction` helper
1024
+ // so body-direct + secondary-story tables apply the same
1025
+ // allowlist. Legacy form fields (FORMTEXT/FORMCHECKBOX/
1026
+ // FORMDROPDOWN) now pass here just like in main-story tables.
1027
+ if (!isSafeTableFieldInstruction(instruction)) {
1023
1028
  return true;
1024
1029
  }
1025
1030
  continue;
@@ -1041,17 +1046,6 @@ function containsRiskyElement(element: XmlElementNode): boolean {
1041
1046
  return false;
1042
1047
  }
1043
1048
 
1044
- function isSafeSecondaryStoryFieldFamily(family: string): boolean {
1045
- return (
1046
- family === "REF" ||
1047
- family === "PAGEREF" ||
1048
- family === "NOTEREF" ||
1049
- family === "TOC" ||
1050
- family === "PAGE" ||
1051
- family === "NUMPAGES"
1052
- );
1053
- }
1054
-
1055
1049
  function parseSimpleTableElement(
1056
1050
  tblElement: XmlElementNode,
1057
1051
  sourceXml: string,
@@ -41,6 +41,7 @@ import { parseShapeXml, parseVmlXml } from "./parse-shapes.ts";
41
41
  import { parseObject } from "./parse-object.ts";
42
42
  import { parseDrawingFrame } from "./parse-drawing.ts";
43
43
  import { readFrameProperties } from "./parse-paragraph-formatting.ts";
44
+ import { tableRequiresOpaquePreservation } from "./table-opaque-preservation.ts";
44
45
  import { classifyFieldInstruction } from "./parse-fields.ts";
45
46
  import { parseFFDataFromFldChar } from "./parse-ffdata.ts";
46
47
  import { resolveHighlightColor } from "./highlight-colors.ts";
@@ -2020,37 +2021,12 @@ function readCellCnfStyle(node: XmlElementNode): string | undefined {
2020
2021
  * Tables matching this check stay opaque until the respective features
2021
2022
  * are implemented in the table editing path.
2022
2023
  */
2023
- function tableRequiresOpaquePreservation(rawXml: string): boolean {
2024
- // Safe table-local content now includes hyperlinks, bookmarks, comments,
2025
- // nested tables, floating images, VML preview atoms, and bounded field
2026
- // families already owned by the current field slice. Risky table-local
2027
- // semantics still fail closed to preserve-only.
2028
- if (/<w:(ins|del|rPrChange|pPrChange|tblPrChange|trPrChange|tcPrChange|sectPrChange|cellIns|cellDel|cellMerge|smartTag)\b/.test(rawXml)) {
2029
- return true;
2030
- }
2031
-
2032
- const simpleInstructions = [...rawXml.matchAll(/\bw:instr="([^"]*)"/g)].map((match) => match[1] ?? "");
2033
- const complexInstructions = extractComplexFieldInstructions(rawXml);
2034
- for (const instruction of [...simpleInstructions, ...complexInstructions]) {
2035
- const classification = classifyFieldInstruction(instruction);
2036
- if (!isSafeMainStoryTableFieldFamily(classification.family)) {
2037
- return true;
2038
- }
2039
- }
2040
-
2041
- return false;
2042
- }
2043
-
2044
- function isSafeMainStoryTableFieldFamily(family: string): boolean {
2045
- return (
2046
- family === "REF" ||
2047
- family === "PAGEREF" ||
2048
- family === "NOTEREF" ||
2049
- family === "TOC" ||
2050
- family === "PAGE" ||
2051
- family === "NUMPAGES"
2052
- );
2053
- }
2024
+ // `tableRequiresOpaquePreservation` now lives in
2025
+ // `src/io/ooxml/table-opaque-preservation.ts` as a shared helper used by
2026
+ // `parse-main-document.ts`, `parse-headers-footers.ts`, and
2027
+ // `parse-footnotes.ts`. Unified 2026-04-24 per coord-01 §11 to stop the
2028
+ // three parsers from drifting out of alignment. The top-of-file import
2029
+ // `tableRequiresOpaquePreservation` points at that module.
2054
2030
 
2055
2031
  function extractComplexFieldInstructions(rawXml: string): string[] {
2056
2032
  const tokenPattern =
@@ -0,0 +1,171 @@
1
+ /**
2
+ * Unified table-opaque-preservation predicate — shared across
3
+ * `parse-main-document.ts`, `parse-headers-footers.ts`, and
4
+ * `parse-footnotes.ts` so the three parsers agree on which tables can
5
+ * be promoted to the typed `TableNode` shape and which must fall back
6
+ * to `opaque_block` for lossless rawXml round-trip.
7
+ *
8
+ * ## History
9
+ *
10
+ * Before this module existed, each of the three parsers maintained its
11
+ * own divergent allowlist. That divergence drove the coord-01 §11
12
+ * finding (2026-04-24): legacy form fields (`FORMTEXT`,
13
+ * `FORMCHECKBOX`, `FORMDROPDOWN`) classify as `UNKNOWN` under
14
+ * `FIELD_FAMILY_PATTERN` (which targets data-field families), so both
15
+ * the main-story and secondary-story table predicates rejected them
16
+ * even though the body-direct paragraph parser path (via
17
+ * `parseFFDataFromFldChar`) handles them correctly. The result was
18
+ * 58 cell paragraphs lost on APS Short Form because 2 body-direct
19
+ * tables flattened to opaque_block; 5 additional footer tables on
20
+ * the same doc also flatten (those carry `DOCPROPERTY` — a real data
21
+ * field, distinct from the legacy form-field case).
22
+ *
23
+ * ## Contract
24
+ *
25
+ * A `<w:tbl>` is "safe to parse as structured" iff NONE of the
26
+ * following are true:
27
+ *
28
+ * 1. **Revision markup.** `<w:ins>`, `<w:del>`, `<w:moveFrom>`,
29
+ * `<w:moveTo>`, `<w:rPrChange>`, `<w:pPrChange>`,
30
+ * `<w:tblPrChange>`, `<w:trPrChange>`, `<w:tcPrChange>`,
31
+ * `<w:sectPrChange>`, `<w:cellIns>`, `<w:cellDel>`,
32
+ * `<w:cellMerge>`, `<w:smartTag>`.
33
+ * Rationale: tracked-change-aware table editing isn't implemented;
34
+ * flattening preserves fidelity until it is.
35
+ *
36
+ * 2. **Field instruction outside the safe set.** Scan every
37
+ * `w:instr` simple-field attribute + every `<w:instrText>` inside
38
+ * a complex field (`<w:fldChar>begin</w:fldChar>...end`) and
39
+ * classify via `classifyFieldInstruction`. If the classified
40
+ * family isn't in `SAFE_TABLE_FIELD_FAMILIES` AND the instruction
41
+ * isn't a legacy form field (`isLegacyFormFieldInstruction`), the
42
+ * table flattens. The legacy-form-field short-circuit is the
43
+ * coord-01 §11 fix — those tokens are fully supported by the cell
44
+ * paragraph parser but classify as `UNKNOWN`.
45
+ *
46
+ * Extending this contract is a cross-layer architectural decision:
47
+ * widening `SAFE_TABLE_FIELD_FAMILIES` lets more tables parse as
48
+ * structured but commits the edit-command layer (L06/L08) to handle
49
+ * each field family's cell-level edit semantics. Keep this list
50
+ * aligned with the field families supported by the runtime
51
+ * formatting + scope-command pipeline.
52
+ */
53
+
54
+ import { classifyFieldInstruction } from "./parse-fields.ts";
55
+
56
+ /**
57
+ * Field families safe enough to leave a `<w:tbl>` in structured
58
+ * canonical form. Widening this set commits L06 / L08 to cell-level
59
+ * edit semantics for that family — don't expand opportunistically.
60
+ */
61
+ export const SAFE_TABLE_FIELD_FAMILIES: ReadonlySet<string> = new Set([
62
+ "REF",
63
+ "PAGEREF",
64
+ "NOTEREF",
65
+ "TOC",
66
+ "PAGE",
67
+ "NUMPAGES",
68
+ ]);
69
+
70
+ /**
71
+ * Revision + structural markup that forces table flattening.
72
+ */
73
+ const RISKY_TABLE_MARKUP_RE =
74
+ /<w:(ins|del|moveFrom|moveTo|rPrChange|pPrChange|tblPrChange|trPrChange|tcPrChange|sectPrChange|cellIns|cellDel|cellMerge|smartTag)\b/;
75
+
76
+ const SIMPLE_FIELD_INSTR_RE = /\bw:instr="([^"]*)"/g;
77
+
78
+ const COMPLEX_FIELD_TOKEN_RE =
79
+ /<(?:\w+:)?fldChar\b[^>]*?(?:\w+:)?fldCharType="(begin|separate|end)"[^>]*?(?:\/>|>[\s\S]*?<\/(?:\w+:)?fldChar>)|<(?:\w+:)?instrText\b[^>]*>([\s\S]*?)<\/(?:\w+:)?instrText>/gu;
80
+
81
+ /**
82
+ * Decode the minimal set of XML entities that can appear inside an
83
+ * `instrText` payload. `classifyFieldInstruction` consumes the raw
84
+ * instruction text, so we normalize `&amp;` / `&quot;` / `&lt;` /
85
+ * `&gt;` / `&apos;` before handing it off. Matches the pre-existing
86
+ * behavior in `parse-main-document.ts::extractComplexFieldInstructions`.
87
+ */
88
+ function decodeXmlEntities(text: string): string {
89
+ return text
90
+ .replace(/&lt;/g, "<")
91
+ .replace(/&gt;/g, ">")
92
+ .replace(/&quot;/g, '"')
93
+ .replace(/&apos;/g, "'")
94
+ .replace(/&amp;/g, "&");
95
+ }
96
+
97
+ /**
98
+ * Extract every complex-field instruction (`<w:fldChar
99
+ * fldCharType="begin">...<w:instrText>...</w:instrText>...</w:fldChar
100
+ * fldCharType="end">`) as a single concatenated instruction string.
101
+ * Mirrors `parse-main-document.ts::extractComplexFieldInstructions`.
102
+ */
103
+ export function extractComplexFieldInstructionsFromRaw(rawXml: string): string[] {
104
+ const instructions: string[] = [];
105
+ let active = "";
106
+ let capturing = false;
107
+ for (const match of rawXml.matchAll(COMPLEX_FIELD_TOKEN_RE)) {
108
+ const [, fldCharType, instrText] = match;
109
+ if (fldCharType === "begin") {
110
+ active = "";
111
+ capturing = true;
112
+ continue;
113
+ }
114
+ if (fldCharType === "separate" || fldCharType === "end") {
115
+ if (capturing && active.trim().length > 0) instructions.push(active);
116
+ active = "";
117
+ capturing = false;
118
+ continue;
119
+ }
120
+ if (capturing && instrText !== undefined) active += decodeXmlEntities(instrText);
121
+ }
122
+ return instructions;
123
+ }
124
+
125
+ /**
126
+ * Legacy form fields (ECMA-376 §17.16.21) — `FORMTEXT`, `FORMCHECKBOX`,
127
+ * `FORMDROPDOWN`. These are fully supported by the body-direct
128
+ * paragraph parser via `parseFFDataFromFldChar` but classify as
129
+ * `UNKNOWN` under `FIELD_FAMILY_PATTERN` (which targets data-field
130
+ * families like REF / TOC / MERGEFIELD). Short-circuiting them lets
131
+ * form-field cells stay in structured canonical tables instead of
132
+ * flattening the entire table to `opaque_block`. Coord-01 §11, 2026-04-24.
133
+ */
134
+ export function isLegacyFormFieldInstruction(instruction: string): boolean {
135
+ return /^\s*(FORMTEXT|FORMCHECKBOX|FORMDROPDOWN)\b/i.test(instruction);
136
+ }
137
+
138
+ /**
139
+ * Decides whether a single field instruction (either `w:instr`
140
+ * attribute value or concatenated `instrText` run) is safe for
141
+ * structured-table parsing. Used by the shared predicate below;
142
+ * exposed for direct callers (the debug diagnostics script runs
143
+ * this to classify source instructions alongside the canonical).
144
+ */
145
+ export function isSafeTableFieldInstruction(instruction: string): boolean {
146
+ if (isLegacyFormFieldInstruction(instruction)) return true;
147
+ const family = classifyFieldInstruction(instruction).family;
148
+ return SAFE_TABLE_FIELD_FAMILIES.has(family);
149
+ }
150
+
151
+ /**
152
+ * Shared `<w:tbl>` opaque-preservation predicate. Returns `true`
153
+ * when the table must fall back to `opaque_block` for lossless
154
+ * round-trip, `false` when `parseTable` / `parseSimpleTableElement`
155
+ * can promote it to a structured `TableNode`.
156
+ *
157
+ * Callers (`parse-main-document.ts`, `parse-headers-footers.ts`,
158
+ * `parse-footnotes.ts`) pass the raw XML of the `<w:tbl>` element.
159
+ */
160
+ export function tableRequiresOpaquePreservation(rawXml: string): boolean {
161
+ if (RISKY_TABLE_MARKUP_RE.test(rawXml)) return true;
162
+
163
+ const simpleInstructions = [...rawXml.matchAll(SIMPLE_FIELD_INSTR_RE)].map(
164
+ (match) => match[1] ?? "",
165
+ );
166
+ const complexInstructions = extractComplexFieldInstructionsFromRaw(rawXml);
167
+ for (const instruction of [...simpleInstructions, ...complexInstructions]) {
168
+ if (!isSafeTableFieldInstruction(instruction)) return true;
169
+ }
170
+ return false;
171
+ }
@@ -1036,6 +1036,9 @@ function normalizeViewportRanges(
1036
1036
  for (let i = 1; i < cleaned.length; i += 1) {
1037
1037
  const next = cleaned[i]!;
1038
1038
  const last = merged[merged.length - 1]!;
1039
+ // Mutation is safe: `last` is a fresh object from the `.map` step above,
1040
+ // not aliased with any caller-provided input. The `Object.freeze` at the
1041
+ // return site only runs after the merge loop finishes.
1039
1042
  if (next.start <= last.end) {
1040
1043
  if (next.end > last.end) last.end = next.end;
1041
1044
  } else {
@@ -1372,6 +1375,17 @@ export function createDocumentRuntime(
1372
1375
  | {
1373
1376
  revisionToken: string;
1374
1377
  activeStoryKey: string;
1378
+ /**
1379
+ * Serialized viewport ranges at build time. A surface snapshot is
1380
+ * only reusable for a subsequent `getCachedSurface` call if the
1381
+ * runtime's current `viewportRangesKey` matches — otherwise the
1382
+ * cached snapshot realizes a different set of blocks as real vs
1383
+ * placeholder-culled than the current runtime state demands.
1384
+ * Pre-refactor/11b this was a (revisionToken, activeStoryKey) key
1385
+ * only, which was silently stale-prone when callers applied new
1386
+ * viewport ranges without also calling `requestViewportRefresh()`.
1387
+ */
1388
+ viewportRangesKey: string;
1375
1389
  snapshot: RuntimeRenderSnapshot["surface"];
1376
1390
  }
1377
1391
  | undefined;
@@ -1533,7 +1547,8 @@ export function createDocumentRuntime(
1533
1547
  if (
1534
1548
  cachedSurface &&
1535
1549
  cachedSurface.revisionToken === state.revisionToken &&
1536
- cachedSurface.activeStoryKey === activeStoryKey
1550
+ cachedSurface.activeStoryKey === activeStoryKey &&
1551
+ cachedSurface.viewportRangesKey === viewportRangesKey
1537
1552
  ) {
1538
1553
  return cachedSurface.snapshot;
1539
1554
  }
@@ -1549,8 +1564,13 @@ export function createDocumentRuntime(
1549
1564
  cachedSurface = {
1550
1565
  revisionToken: state.revisionToken,
1551
1566
  activeStoryKey,
1567
+ viewportRangesKey,
1552
1568
  snapshot,
1553
1569
  };
1570
+ // Keep the scroll-path fingerprint in lockstep so a subsequent
1571
+ // `maybeRefreshSurfaceForViewport` sees the freshly-built snapshot
1572
+ // and short-circuits instead of paying a redundant projection.
1573
+ cachedSurfaceFingerprint = `${state.revisionToken}|${activeStoryKey}|${viewportRangesKey}|${String(state.selection.anchor)}:${String(state.selection.head)}`;
1554
1574
  return snapshot;
1555
1575
  }
1556
1576
 
@@ -2394,6 +2414,7 @@ export function createDocumentRuntime(
2394
2414
  cachedSurface = {
2395
2415
  revisionToken: state.revisionToken,
2396
2416
  activeStoryKey: storyTargetKey(activeStory),
2417
+ viewportRangesKey,
2397
2418
  snapshot: newSurface,
2398
2419
  };
2399
2420
  cachedRenderSnapshot = {
@@ -2427,6 +2448,12 @@ export function createDocumentRuntime(
2427
2448
 
2428
2449
  function invalidateDerivedRuntimeCaches(): void {
2429
2450
  cachedSurface = undefined;
2451
+ // Keep the scroll-path fingerprint in lockstep with `cachedSurface`.
2452
+ // Otherwise a post-commit `requestViewportRefresh` would compute a new
2453
+ // fingerprint, see it differs from the stale pre-commit value, and
2454
+ // rebuild + broadcast even when the freshly-hydrated `cachedSurface`
2455
+ // already matches — wasted projection + listener fan-out.
2456
+ cachedSurfaceFingerprint = null;
2430
2457
  cachedCompatibility = undefined;
2431
2458
  cachedComments = undefined;
2432
2459
  cachedTrackedChanges = undefined;
@@ -106,6 +106,15 @@ export interface SurfaceProjectionOptions {
106
106
  * block stays real without realizing the document-scale gap between the
107
107
  * two). Callers still supplying the legacy scalar `viewportBlockRange` get
108
108
  * wrapped into a single-element array internally.
109
+ *
110
+ * **Invariant (caller responsibility):** intervals must be sorted ascending
111
+ * by `start` and non-overlapping. The projection function itself only
112
+ * checks membership — it does not sort or merge — so an unsorted input
113
+ * produces a correct `isInViewport` answer but the snapshot's
114
+ * `viewportBlockRanges` field carries the unnormalized shape to downstream
115
+ * consumers (e.g. the surface-build-key serializer). Runtime callers
116
+ * route through `DocumentRuntime.applyViewportRanges` which normalizes;
117
+ * if you call `createEditorSurfaceSnapshot` directly, normalize first.
109
118
  */
110
119
  viewportBlockRanges?: readonly { start: number; end: number }[] | null;
111
120
  /** @deprecated use `viewportBlockRanges`. Kept for back-compat; wrapped into a 1-element array when supplied alone. */
@@ -1,21 +1,26 @@
1
1
  /**
2
- * Layer-06 Slice 3 `createScopeFromBlockId` adapter.
2
+ * Layer-06 marker-backed workflow-scope creators.
3
3
  *
4
- * Resolves a `blockId` (paragraph/table/sdt-index identifier minted by
5
- * `src/runtime/surface-projection.ts`) into the `EditorAnchorProjection`
6
- * that `runtime.addScope` expects, then delegates to the existing
7
- * marker-backed scope creator.
4
+ * Two adapters share `runtime.addScope` as the underlying creator:
5
+ *
6
+ * 1. `createScopeFromBlockId` resolves a block identifier
7
+ * (`paragraph-N`, `table-N`, `sdt-N`) minted by
8
+ * `src/runtime/surface-projection.ts` into the whole-block range.
9
+ * Use when the scope should cover an entire paragraph / table / SDT.
10
+ *
11
+ * 2. `createScopeFromAnchor` — takes a sub-block `{from, to}` range
12
+ * directly. Use when the scope should bracket a sub-span — a phrase
13
+ * inside a paragraph, a cross-paragraph selection, a span from a
14
+ * click + drag. Resolves the KI-P9 "positions-as-references" trap:
15
+ * positions are consumed **once** at creation to plant the markers;
16
+ * the returned `scopeId` is durable and survives subsequent editing.
17
+ * Callers must stop carrying the positions after the call returns.
8
18
  *
9
19
  * Resolution walks the canonical document directly, not the surface
10
20
  * snapshot. Going through `runtime.getRenderSnapshot().surface.blocks`
11
21
  * would miss blocks the surface replaces with `placeholder-culled-*`
12
22
  * entries under viewport culling — a real reproducibility failure on
13
23
  * CCEP-size documents.
14
- *
15
- * This adapter unblocks `v3 runtime.workflow.createScope` graduation
16
- * from `mock` → `live-with-adapter`. Consumers that already carry a
17
- * full `EditorAnchorProjection` should continue calling
18
- * `runtime.addScope` directly.
19
24
  */
20
25
 
21
26
  import type {
@@ -220,3 +225,200 @@ export function createScopeFromBlockId(
220
225
 
221
226
  // Exported for tests that need to verify the resolver independently.
222
227
  export { resolveBlockAnchorFromCanonical };
228
+
229
+ /**
230
+ * Input for `createScopeFromAnchor`. The `anchor` fields are a one-shot
231
+ * position query — the runtime plants inline `scope_marker_start` /
232
+ * `scope_marker_end` at these offsets and returns a marker-backed
233
+ * `scopeId`. From that moment the markers travel with their bracketed
234
+ * content through any number of edits; callers must re-resolve by
235
+ * `scopeId` afterward and MUST NOT store or reuse `from`/`to`.
236
+ */
237
+ export interface CreateScopeFromAnchorInput {
238
+ /**
239
+ * Range in the current document state. `from` and `to` are measured
240
+ * in the editor's surface-projection offset space (the same space
241
+ * `findText`, `replaceText`, and `addScope` ranges use). Must satisfy
242
+ * `0 <= from <= to <= storyLength`.
243
+ */
244
+ readonly anchor: {
245
+ readonly from: number;
246
+ readonly to: number;
247
+ readonly storyTarget?: EditorStoryTarget;
248
+ };
249
+ readonly mode?: WorkflowScopeMode;
250
+ readonly label?: string;
251
+ readonly scopeId?: string;
252
+ readonly persistence?: WorkflowMetadataPersistence;
253
+ readonly metadata?: Partial<WorkflowMetadataEntry>;
254
+ /**
255
+ * Per-scope edge stickiness for the range anchor. Defaults to
256
+ * `{ start: 1, end: -1 }` (greedy — absorbs boundary inserts). See
257
+ * `CreateScopeFromBlockIdInput.assoc` for the full matrix.
258
+ */
259
+ readonly assoc?: { readonly start: -1 | 1; readonly end: -1 | 1 };
260
+ /** Caller-steerable identity strategy; same semantics as on `CreateScopeFromBlockIdInput`. */
261
+ readonly stableRefHint?:
262
+ | "scope-id"
263
+ | "bookmark"
264
+ | "semantic-path"
265
+ | "runtime-handle";
266
+ }
267
+
268
+ export type CreateScopeFromAnchorResult =
269
+ | {
270
+ readonly status: "created";
271
+ readonly scopeId: string;
272
+ readonly anchor: EditorAnchorProjection;
273
+ }
274
+ | {
275
+ readonly status: "range-invalid";
276
+ readonly reason:
277
+ | "from-negative"
278
+ | "to-less-than-from"
279
+ | "range-exceeds-story-length";
280
+ readonly from: number;
281
+ readonly to: number;
282
+ readonly storyLength: number;
283
+ /**
284
+ * Single-sentence, agent-actionable explanation. Tells the caller
285
+ * what the failure was and the concrete next step — no guesswork
286
+ * required. Safe to surface directly to an LLM tool-use loop as
287
+ * the `error` content of the tool reply.
288
+ */
289
+ readonly message: string;
290
+ /**
291
+ * Short machine-routable next-step hint for thin consumers that
292
+ * don't want to pattern-match on `reason`. Examples:
293
+ * "clamp-from-to-zero", "swap-from-and-to",
294
+ * "clamp-to-to-storyLength-or-pick-a-different-range".
295
+ */
296
+ readonly nextStep: string;
297
+ };
298
+
299
+ /**
300
+ * Sum the total length of the main story by walking blocks with
301
+ * inter-block gap semantics matched to `resolveBlockAnchorFromCanonical`.
302
+ * Used as the bounds check for `createScopeFromAnchor`.
303
+ */
304
+ function computeMainStoryLength(document: CanonicalDocumentEnvelope): number {
305
+ const root = document.content as DocumentRootNode;
306
+ const blocks = root.children;
307
+ let total = 0;
308
+ for (let i = 0; i < blocks.length; i += 1) {
309
+ const block: BlockNode = blocks[i]!;
310
+ if (block.type === "paragraph") {
311
+ total += paragraphLength(block);
312
+ } else {
313
+ total += 1;
314
+ }
315
+ if (i < blocks.length - 1) total += 1;
316
+ }
317
+ return total;
318
+ }
319
+
320
+ /**
321
+ * Create a marker-backed workflow scope over a sub-block anchor range.
322
+ *
323
+ * The returned `scopeId` is the durable reference — after this call
324
+ * returns, `from`/`to` are not part of the scope's identity and must
325
+ * not be stored or reused. All later lookups use
326
+ * `resolveReference({kind:"scope-id", value: scopeId})` or `get_scope`,
327
+ * which walk to the markers' current positions regardless of
328
+ * intervening edits.
329
+ *
330
+ * Bounds: `0 <= from <= to <= storyLength`. Out-of-bounds or inverted
331
+ * ranges return `{status: "range-invalid", reason}` without mutating
332
+ * the document. `storyTarget` defaults to main; footnote / header /
333
+ * endnote stories are supported by passing the target through.
334
+ */
335
+ export function createScopeFromAnchor(
336
+ runtime: ScopeWriterRuntime,
337
+ input: CreateScopeFromAnchorInput,
338
+ ): CreateScopeFromAnchorResult {
339
+ const doc = runtime.getCanonicalDocument();
340
+ const storyTarget: EditorStoryTarget =
341
+ input.anchor.storyTarget ?? { kind: "main" };
342
+ // Bounds check only covers the main story today. Non-main stories
343
+ // (footnote / header / endnote) skip the numeric bound check —
344
+ // `runtime.addScope` is the authoritative bound enforcer for those
345
+ // secondary stories and returns an error anchor when the range is
346
+ // invalid. This matches the pre-existing `createScopeFromBlockId`
347
+ // contract where block resolution handles story-aware bounds.
348
+ const storyLength =
349
+ storyTarget.kind === "main" ? computeMainStoryLength(doc) : Number.MAX_SAFE_INTEGER;
350
+
351
+ const { from, to } = input.anchor;
352
+ if (from < 0) {
353
+ return {
354
+ status: "range-invalid",
355
+ reason: "from-negative",
356
+ from,
357
+ to,
358
+ storyLength,
359
+ message:
360
+ `createScopeFromAnchor requires from >= 0 (received from=${from}). ` +
361
+ `Offsets are zero-based absolute positions in the current document; ` +
362
+ `clamp negative values to 0 before calling.`,
363
+ nextStep: "clamp-from-to-zero",
364
+ };
365
+ }
366
+ if (to < from) {
367
+ return {
368
+ status: "range-invalid",
369
+ reason: "to-less-than-from",
370
+ from,
371
+ to,
372
+ storyLength,
373
+ message:
374
+ `createScopeFromAnchor requires to >= from (received from=${from}, to=${to}). ` +
375
+ `The range is inverted — swap the two values before calling.`,
376
+ nextStep: "swap-from-and-to",
377
+ };
378
+ }
379
+ if (to > storyLength) {
380
+ return {
381
+ status: "range-invalid",
382
+ reason: "range-exceeds-story-length",
383
+ from,
384
+ to,
385
+ storyLength,
386
+ message:
387
+ `createScopeFromAnchor requires to <= storyLength (received to=${to}, ` +
388
+ `storyLength=${storyLength}). This typically means the offset was ` +
389
+ `captured from a stale document state (see KI-P9) — do not carry ` +
390
+ `offsets across mutations. Re-derive from the current document, or ` +
391
+ `clamp to <= storyLength and verify the result targets the intended content.`,
392
+ nextStep: "clamp-to-to-storyLength-or-pick-a-different-range",
393
+ };
394
+ }
395
+
396
+ const anchor: EditorAnchorProjection = {
397
+ kind: "range",
398
+ from,
399
+ to,
400
+ assoc: input.assoc ?? { start: 1, end: -1 },
401
+ };
402
+
403
+ const scopeMetadataFields: WorkflowScopeMetadataField[] = [];
404
+ if (input.stableRefHint !== undefined) {
405
+ scopeMetadataFields.push({
406
+ key: "stableRefHint",
407
+ valueType: "string",
408
+ value: input.stableRefHint,
409
+ });
410
+ }
411
+
412
+ const result: AddScopeResult = runtime.addScope({
413
+ anchor,
414
+ mode: input.mode,
415
+ scopeId: input.scopeId,
416
+ persistence: input.persistence,
417
+ metadata: input.metadata,
418
+ storyTarget,
419
+ label: input.label,
420
+ ...(scopeMetadataFields.length > 0 ? { scopeMetadataFields } : {}),
421
+ });
422
+
423
+ return { status: "created", scopeId: result.scopeId, anchor: result.anchor };
424
+ }
@@ -289,7 +289,15 @@ export const editorSchema = new Schema({
289
289
  if (pageBreak) styles.push("border-top: 2px dashed rgba(0,0,0,0.1); padding-top: 8px; margin-top: 16px");
290
290
  // `<w:framePr>` out-of-flow frame — mirror the static-path branch in
291
291
  // tw-page-block-view.helpers.ts so PM-rendered page 1 absolutely
292
- // positions the frame identically to pages 2+.
292
+ // positions the frame identically to pages 2+. Gating rules
293
+ // match the static path: drop-cap stays in-flow; a framePr with
294
+ // zero positional fields stays in-flow (no out-of-flow without
295
+ // a declared position — M3 review finding).
296
+ //
297
+ // N1: `position: absolute` is pushed BEFORE `hiddenTextOnly`'s
298
+ // `display: none` by code order; `display: none` wins visually
299
+ // regardless of source order, so a hidden framed paragraph
300
+ // collapses as expected. Do not "fix" the order — it's correct.
293
301
  const framePr = node.attrs.frameProperties as
294
302
  | {
295
303
  xTwips?: number;
@@ -298,9 +306,21 @@ export const editorSchema = new Schema({
298
306
  heightTwips?: number;
299
307
  hRule?: "auto" | "atLeast" | "exact";
300
308
  dropCap?: "none" | "drop" | "margin";
309
+ xAlign?: string;
310
+ yAlign?: string;
301
311
  }
302
312
  | null;
303
- if (framePr && framePr.dropCap !== "drop" && framePr.dropCap !== "margin") {
313
+ const hasPosition =
314
+ typeof framePr?.xTwips === "number" ||
315
+ typeof framePr?.yTwips === "number" ||
316
+ typeof framePr?.xAlign === "string" ||
317
+ typeof framePr?.yAlign === "string";
318
+ if (
319
+ framePr &&
320
+ framePr.dropCap !== "drop" &&
321
+ framePr.dropCap !== "margin" &&
322
+ hasPosition
323
+ ) {
304
324
  styles.push("position: absolute");
305
325
  if (typeof framePr.xTwips === "number") styles.push(`left: ${framePr.xTwips / 20}pt`);
306
326
  if (typeof framePr.yTwips === "number") styles.push(`top: ${framePr.yTwips / 20}pt`);
@@ -124,6 +124,9 @@ export function computeTabWidthsInPoints(
124
124
  if (stop) {
125
125
  const prevPos = tabIndex > 0 ? readPos(rawStops[tabIndex - 1]) : 0;
126
126
  const widthTwips = readPos(stop) - prevPos;
127
+ // `> 0` (not `>= 0`) is intentional — a zero-width zone between
128
+ // two identical stops is meaningless; skip so the caller falls
129
+ // back to the 32 px default.
127
130
  if (widthTwips > 0) {
128
131
  widths.set(seg.segmentId, widthTwips / 20);
129
132
  }
@@ -254,10 +257,26 @@ export function buildParagraphStyle(
254
257
  // `<w:framePr>` out-of-flow frame (ECMA-376 §17.3.1.11). L04 returns 0
255
258
  // from measureBlockHeight for these paragraphs (a298391e) so the inline
256
259
  // flow doesn't double-count; L11 renders them absolutely positioned.
257
- // Drop-cap (dropCap="drop"|"margin") is in-flow — only the initial
258
- // letter is framed so skip the absolute switch there.
260
+ //
261
+ // Gating rules (M3 review finding):
262
+ // - Drop-cap (`dropCap` "drop" | "margin") is in-flow — only the
263
+ // initial letter is framed; paragraph body stays with its text.
264
+ // - A `<w:framePr/>` with ZERO positional fields (no xTwips / yTwips
265
+ // / xAlign / yAlign) is ambiguous; Word treats it as in-flow. If
266
+ // we emitted `position: absolute` we'd pin the paragraph at (0,0)
267
+ // of the nearest positioned ancestor — dramatically wrong.
259
268
  const framePr = block.frameProperties;
260
- if (framePr && framePr.dropCap !== "drop" && framePr.dropCap !== "margin") {
269
+ const hasPosition =
270
+ typeof framePr?.xTwips === "number" ||
271
+ typeof framePr?.yTwips === "number" ||
272
+ typeof framePr?.xAlign === "string" ||
273
+ typeof framePr?.yAlign === "string";
274
+ if (
275
+ framePr &&
276
+ framePr.dropCap !== "drop" &&
277
+ framePr.dropCap !== "margin" &&
278
+ hasPosition
279
+ ) {
261
280
  style.position = "absolute";
262
281
  if (typeof framePr.xTwips === "number") {
263
282
  style.left = `${framePr.xTwips / 20}pt`;
@@ -145,11 +145,15 @@ export function collectFloatingImageOverlayItems(input: {
145
145
  };
146
146
 
147
147
  // coord-01 §9 / §5.1 — CCEP logos live in header stories; collect from
148
- // the main story for the active-story case AND from every secondary
149
- // story so header/footer images reach the overlay regardless of which
150
- // story is active in the editor.
148
+ // the active story's surface.blocks (always) + every secondary story
149
+ // EXCEPT the one whose target matches activeStory (runtime fills
150
+ // surface.blocks with that same story's blocks, so walking secondary
151
+ // stories unconditionally would double-emit header/footer segments
152
+ // the moment the user enters a header for editing — M1).
151
153
  collectFromStory(surface.blocks, activeStory);
154
+ const activeKey = storyTargetKey(activeStory);
152
155
  for (const secondary of surface.secondaryStories ?? []) {
156
+ if (storyTargetKey(secondary.target) === activeKey) continue;
153
157
  collectFromStory(secondary.blocks, secondary.target);
154
158
  }
155
159
 
@@ -52,7 +52,11 @@ export const TwEndnoteArea: React.FC<TwEndnoteAreaProps> = ({
52
52
  marginBottom: "8pt",
53
53
  }}
54
54
  />
55
- <TwRegionBlockRenderer blocks={blocks} mediaPreviews={mediaPreviews} />
55
+ <TwRegionBlockRenderer
56
+ blocks={blocks}
57
+ mediaPreviews={mediaPreviews}
58
+ fallbackDisplay="hidden"
59
+ />
56
60
  </div>
57
61
  );
58
62
  };
@@ -66,7 +66,11 @@ export const TwFootnoteArea: React.FC<TwFootnoteAreaProps> = React.memo(({
66
66
  marginBottom: "4pt",
67
67
  }}
68
68
  />
69
- <TwRegionBlockRenderer blocks={blocks} mediaPreviews={mediaPreviews} />
69
+ <TwRegionBlockRenderer
70
+ blocks={blocks}
71
+ mediaPreviews={mediaPreviews}
72
+ fallbackDisplay="hidden"
73
+ />
70
74
  </div>
71
75
  );
72
76
  });
@@ -14,6 +14,7 @@ import {
14
14
  headingClassList,
15
15
  resolveHeadingLevel,
16
16
  } from "../editor-surface/tw-page-block-view.helpers.ts";
17
+ import { shouldRenderAbsoluteFloatingImageInPageOverlay } from "./floating-image-overlay-model.ts";
17
18
 
18
19
  const EMU_PER_PX = 9525;
19
20
 
@@ -94,11 +95,12 @@ function renderSegment(
94
95
  case "hard_break":
95
96
  return <br key={seg.segmentId} />;
96
97
  case "image": {
97
- // §5.1 gap 3 — floating-anchor images are owned by the absolute
98
- // floating-image overlay (`TwFloatingImageLayer`). Emitting them
99
- // inline here would double-paint the CCEP header logo on every
100
- // page. Skip entirely so only the overlay renders them.
101
- if (seg.anchor?.display === "floating") {
98
+ // §5.1 gap 3 — floating-anchor images the overlay can render
99
+ // (`TwFloatingImageLayer`) are owned by the overlay. Skip inline
100
+ // emission ONLY for anchors the overlay predicate accepts
101
+ // otherwise wrap-mode=square / column-relative / tight-wrapped
102
+ // floats get dropped from inline AND from the overlay (M2).
103
+ if (shouldRenderAbsoluteFloatingImageInPageOverlay(seg.anchor)) {
102
104
  return null;
103
105
  }
104
106
  // Mirror body-renderer behavior (`pm-state-from-snapshot.ts` :500+):
@@ -2,7 +2,6 @@ import { useCallback, useEffect } from "react";
2
2
  import type { Dispatch, SetStateAction } from "react";
3
3
 
4
4
  import { createCanvasBackend } from "../../api/public-types.ts";
5
- import type { RuntimeRenderSnapshot } from "../../api/public-types.ts";
6
5
  import {
7
6
  incrementInvalidationCounter,
8
7
  recordPerfSample,
@@ -17,8 +16,6 @@ export interface UseWorkspaceSideEffectsOptions {
17
16
  activeParagraphLayout: ActiveParagraphLayout | null;
18
17
  pageChromeModel: PageChromeModel;
19
18
  pageShellMetrics: PageShellMetrics;
20
- isPageWorkspace: boolean;
21
- activeStoryKind: RuntimeRenderSnapshot["activeStory"]["kind"];
22
19
  showDrawerReviewRail: boolean;
23
20
  setReviewRailOpen: Dispatch<SetStateAction<boolean>>;
24
21
  onOpenHeaderStory?: () => void;
@@ -58,8 +55,6 @@ export function useWorkspaceSideEffects(
58
55
  activeParagraphLayout,
59
56
  pageChromeModel,
60
57
  pageShellMetrics,
61
- isPageWorkspace,
62
- activeStoryKind,
63
58
  showDrawerReviewRail,
64
59
  setReviewRailOpen,
65
60
  onOpenHeaderStory,
@@ -67,14 +62,6 @@ export function useWorkspaceSideEffects(
67
62
  onDismissSelectionToolbar,
68
63
  } = options;
69
64
 
70
- // Slice A (designsystem §6.20 reshape, 2026-04-24): isPageWorkspace +
71
- // activeStoryKind referenced here so the prop sweep stays a no-op
72
- // type-checker pass even though the auto-open-layout-tools effect
73
- // they fed retired with the strip. Slice B mounts an active-band
74
- // ribbon that observes activeStoryKind directly.
75
- void isPageWorkspace;
76
- void activeStoryKind;
77
-
78
65
  useEffect(() => {
79
66
  recordPerfSample("workspace.chrome");
80
67
  incrementInvalidationCounter("workspace.chrome.recomputes");
@@ -10,10 +10,6 @@ import React, {
10
10
  import * as Tooltip from "@radix-ui/react-tooltip";
11
11
  import { ChevronRight } from "lucide-react";
12
12
 
13
- import {
14
- useVisibleBlockRange,
15
- useVisiblePageIndexRange,
16
- } from "./page-stack/use-visible-block-range.ts";
17
13
  import { sliceBlocksForPage } from "./editor-surface/page-slice-util.ts";
18
14
  import {
19
15
  findScrollAnchor,
@@ -330,8 +326,6 @@ export function TwReviewWorkspace(inputProps: TwReviewWorkspaceProps) {
330
326
  activeParagraphLayout,
331
327
  pageChromeModel,
332
328
  pageShellMetrics,
333
- isPageWorkspace,
334
- activeStoryKind: snapshot.activeStory.kind,
335
329
  showDrawerReviewRail: responsiveChrome.showDrawerReviewRail,
336
330
  setReviewRailOpen,
337
331
  onOpenHeaderStory: props.onOpenHeaderStory,