@beyondwork/docx-react-component 1.0.58 → 1.0.59

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 (134) hide show
  1. package/README.md +2 -2
  2. package/package.json +2 -1
  3. package/src/api/awareness-identity-types.ts +4 -2
  4. package/src/api/comment-negotiation-types.ts +4 -1
  5. package/src/api/external-custody-types.ts +16 -0
  6. package/src/api/internal/build-ref-projections.ts +108 -0
  7. package/src/api/package-version.ts +1 -1
  8. package/src/api/participants-types.ts +11 -1
  9. package/src/api/public-types.ts +978 -10
  10. package/src/api/scope-metadata-resolver-types.ts +6 -0
  11. package/src/compare/diff-engine.ts +3 -0
  12. package/src/core/commands/formatting-commands.ts +1 -0
  13. package/src/core/commands/index.ts +225 -16
  14. package/src/core/commands/legacy-form-field-commands.ts +181 -0
  15. package/src/core/commands/table-structure-commands.ts +149 -31
  16. package/src/core/selection/mapping.ts +20 -0
  17. package/src/core/state/editor-state.ts +2 -1
  18. package/src/index.ts +28 -0
  19. package/src/io/docx-session.ts +22 -3
  20. package/src/io/export/export-session.ts +11 -7
  21. package/src/io/export/ooxml-namespaces.ts +47 -0
  22. package/src/io/export/reattach-preserved-parts.ts +4 -16
  23. package/src/io/export/serialize-comments.ts +3 -131
  24. package/src/io/export/serialize-ffdata.ts +89 -0
  25. package/src/io/export/serialize-headers-footers.ts +5 -0
  26. package/src/io/export/serialize-main-document.ts +224 -34
  27. package/src/io/export/serialize-numbering.ts +22 -2
  28. package/src/io/export/serialize-revisions.ts +99 -0
  29. package/src/io/export/serialize-tables.ts +9 -0
  30. package/src/io/export/split-review-boundaries.ts +1 -0
  31. package/src/io/export/table-properties-xml.ts +14 -0
  32. package/src/io/load-scheduler.ts +70 -28
  33. package/src/io/normalize/normalize-text.ts +13 -0
  34. package/src/io/ooxml/_mini-xml.ts +198 -0
  35. package/src/io/ooxml/canonicalize-payload.ts +1 -4
  36. package/src/io/ooxml/chart/chart-style-table.ts +4 -3
  37. package/src/io/ooxml/chart/parse-chart-space.ts +2 -4
  38. package/src/io/ooxml/chart/parse-series.ts +2 -1
  39. package/src/io/ooxml/chart/resolve-color.ts +2 -2
  40. package/src/io/ooxml/chart/types.ts +6 -434
  41. package/src/io/ooxml/comment-presentation-payload.ts +6 -5
  42. package/src/io/ooxml/highlight-colors.ts +8 -5
  43. package/src/io/ooxml/parse-anchor.ts +68 -53
  44. package/src/io/ooxml/parse-comments.ts +14 -142
  45. package/src/io/ooxml/parse-complex-content.ts +3 -106
  46. package/src/io/ooxml/parse-drawing.ts +100 -195
  47. package/src/io/ooxml/parse-ffdata.ts +93 -0
  48. package/src/io/ooxml/parse-fields.ts +7 -146
  49. package/src/io/ooxml/parse-fill.ts +88 -8
  50. package/src/io/ooxml/parse-font-table.ts +5 -105
  51. package/src/io/ooxml/parse-footnotes.ts +28 -152
  52. package/src/io/ooxml/parse-headers-footers.ts +106 -212
  53. package/src/io/ooxml/parse-inline-media.ts +3 -200
  54. package/src/io/ooxml/parse-main-document.ts +180 -217
  55. package/src/io/ooxml/parse-numbering.ts +154 -335
  56. package/src/io/ooxml/parse-object.ts +147 -0
  57. package/src/io/ooxml/parse-ole-relationship.ts +82 -0
  58. package/src/io/ooxml/parse-paragraph-formatting.ts +7 -10
  59. package/src/io/ooxml/parse-picture-sdt.ts +85 -0
  60. package/src/io/ooxml/parse-picture.ts +72 -42
  61. package/src/io/ooxml/parse-revisions.ts +285 -51
  62. package/src/io/ooxml/parse-settings.ts +6 -99
  63. package/src/io/ooxml/parse-shapes.ts +25 -140
  64. package/src/io/ooxml/parse-styles.ts +3 -218
  65. package/src/io/ooxml/parse-tables.ts +76 -256
  66. package/src/io/ooxml/parse-theme.ts +1 -4
  67. package/src/io/ooxml/property-grab-bag.ts +5 -47
  68. package/src/io/ooxml/xml-element-serialize.ts +32 -0
  69. package/src/io/ooxml/xml-parser.ts +183 -0
  70. package/src/legal/bookmarks.ts +1 -1
  71. package/src/legal/cross-references.ts +1 -1
  72. package/src/legal/defined-terms.ts +1 -1
  73. package/src/legal/{_document-root.ts → document-root.ts} +8 -0
  74. package/src/legal/signature-blocks.ts +1 -1
  75. package/src/model/canonical-document.ts +159 -6
  76. package/src/model/chart-types.ts +439 -0
  77. package/src/model/snapshot.ts +3 -1
  78. package/src/review/store/comment-remapping.ts +24 -11
  79. package/src/review/store/revision-actions.ts +482 -2
  80. package/src/review/store/revision-store.ts +15 -0
  81. package/src/review/store/revision-types.ts +76 -0
  82. package/src/runtime/collab/remote-cursor-awareness.ts +24 -0
  83. package/src/runtime/collab/runtime-collab-sync.ts +33 -0
  84. package/src/runtime/diagnostics/build-diagnostic.ts +151 -0
  85. package/src/runtime/diagnostics/code-metadata-table.ts +221 -0
  86. package/src/runtime/document-runtime.ts +476 -34
  87. package/src/runtime/document-search.ts +115 -0
  88. package/src/runtime/edit-ops/index.ts +18 -2
  89. package/src/runtime/footnote-resolver.ts +130 -0
  90. package/src/runtime/layout/layout-engine-instance.ts +31 -4
  91. package/src/runtime/layout/layout-engine-version.ts +37 -1
  92. package/src/runtime/layout/page-graph.ts +14 -1
  93. package/src/runtime/layout/resolved-formatting-state.ts +21 -0
  94. package/src/runtime/numbering-prefix.ts +17 -0
  95. package/src/runtime/query-scopes.ts +5 -8
  96. package/src/runtime/resolved-numbering-geometry.ts +37 -6
  97. package/src/runtime/revision-runtime.ts +27 -1
  98. package/src/runtime/selection/post-edit-validator.ts +60 -6
  99. package/src/runtime/structure-ops/index.ts +20 -4
  100. package/src/runtime/surface-projection.ts +290 -21
  101. package/src/runtime/table-schema.ts +6 -0
  102. package/src/runtime/theme-color-resolver.ts +2 -2
  103. package/src/runtime/units.ts +9 -0
  104. package/src/runtime/workflow-rail-segments.ts +4 -0
  105. package/src/ui/WordReviewEditor.tsx +187 -43
  106. package/src/ui/editor-runtime-boundary.ts +10 -0
  107. package/src/ui/editor-shell-view.tsx +4 -1
  108. package/src/ui/headless/chrome-registry.ts +53 -0
  109. package/src/ui/headless/selection-tool-resolver.ts +11 -1
  110. package/src/ui-tailwind/chrome/chrome-preset-model.ts +13 -0
  111. package/src/ui-tailwind/chrome/tw-command-palette-mount.tsx +96 -0
  112. package/src/ui-tailwind/chrome/tw-context-menu.tsx +2 -1
  113. package/src/ui-tailwind/chrome/tw-image-context-toolbar.tsx +5 -4
  114. package/src/ui-tailwind/chrome/tw-mode-dock.tsx +6 -2
  115. package/src/ui-tailwind/chrome/use-container-breakpoint.ts +111 -0
  116. package/src/ui-tailwind/chrome-overlay/tw-chrome-overlay.tsx +0 -9
  117. package/src/ui-tailwind/chrome-overlay/tw-object-selection-overlay.tsx +1 -0
  118. package/src/ui-tailwind/chrome-overlay/tw-page-stack-overlay-layer.tsx +6 -7
  119. package/src/ui-tailwind/editor-surface/pm-schema.ts +87 -25
  120. package/src/ui-tailwind/editor-surface/pm-state-from-snapshot.ts +9 -0
  121. package/src/ui-tailwind/editor-surface/shape-renderer.ts +76 -14
  122. package/src/ui-tailwind/editor-surface/tw-page-block-view.helpers.ts +18 -1
  123. package/src/ui-tailwind/editor-surface/tw-page-block-view.tsx +2 -0
  124. package/src/ui-tailwind/editor-surface/tw-table-node-view.tsx +18 -2
  125. package/src/ui-tailwind/index.ts +9 -0
  126. package/src/ui-tailwind/page-chrome-model.ts +77 -5
  127. package/src/ui-tailwind/page-stack/tw-page-stack-chrome-layer.tsx +56 -1
  128. package/src/ui-tailwind/page-stack/tw-region-block-renderer.tsx +2 -0
  129. package/src/ui-tailwind/review/tw-comment-sidebar.tsx +116 -113
  130. package/src/ui-tailwind/review/tw-review-rail-footer.tsx +2 -2
  131. package/src/ui-tailwind/theme/tokens.ts +14 -0
  132. package/src/ui-tailwind/toolbar/tw-shell-header.tsx +5 -0
  133. package/src/ui-tailwind/tw-review-workspace.tsx +29 -87
  134. package/src/validation/diagnostics.ts +1 -0
@@ -157,6 +157,16 @@ function CommentThreadCard(props: {
157
157
  const canEdit = isOwnComment && thread.status === "open" && props.onEditBody != null;
158
158
  const hasNoBody = isEmptyCommentBody(leadEntry?.body);
159
159
  const showExcerpt = Boolean(thread.excerpt) && !isDraftThread && thread.excerpt !== "Empty thread";
160
+ const threadCardClassName = [
161
+ "rounded-lg bg-surface/90 transition-colors ring-1 ring-border",
162
+ isActive
163
+ ? "bg-accent-soft/40 ring-accent/25 shadow-[var(--shadow-soft)]"
164
+ : "hover:bg-surface",
165
+ thread.status === "detached"
166
+ ? "border-l-[3px] border-[var(--color-semantic-warning)] opacity-70"
167
+ : "",
168
+ ].join(" ");
169
+ const threadContentPaddingClass = thread.status === "detached" ? "pl-2.5 pr-3" : "px-3";
160
170
 
161
171
  const scrollRef = useCallback(
162
172
  (node: HTMLButtonElement | null) => {
@@ -168,130 +178,123 @@ function CommentThreadCard(props: {
168
178
  );
169
179
 
170
180
  return (
171
- <button
172
- type="button"
173
- ref={scrollRef}
174
- data-comment-thread-id={thread.commentId}
175
- data-comment-thread-status={thread.status}
176
- className={[
177
- "w-full text-left cursor-pointer rounded-lg bg-surface/90 px-3 py-2.5 transition-colors ring-1 ring-border",
178
- focusRingClass,
179
- isActive
180
- ? "bg-accent-soft/40 ring-accent/25 shadow-[var(--shadow-soft)]"
181
- : "hover:bg-surface",
182
- thread.status === "detached"
183
- ? "border-l-[3px] border-[var(--color-semantic-warning)] opacity-70 pl-2.5"
184
- : "",
185
- ].join(" ")}
186
- onClick={() => props.onOpenComment?.(thread)}
187
- >
188
- {/* Header row: avatar + author + date + status */}
189
- <div className="mb-1.5 flex items-center gap-1.5">
190
- <span className="inline-flex h-4 w-4 shrink-0 items-center justify-center rounded-full bg-subtle text-[8px] font-semibold text-secondary">
191
- {thread.createdBy.charAt(0).toUpperCase()}
192
- </span>
193
- <span className="truncate text-[10px] font-medium text-primary">{thread.createdBy}</span>
194
- {thread.status === "detached" && (
195
- <span
196
- data-comment-thread-detached-chip="true"
197
- className="inline-flex items-center rounded-full bg-[var(--color-semantic-warning-soft)] text-[var(--color-semantic-warning)] text-[9px] font-semibold uppercase tracking-[0.08em] px-1.5 py-0.5 ml-1.5"
198
- >
199
- Detached
181
+ <div data-comment-thread-card={thread.commentId} className={threadCardClassName}>
182
+ <button
183
+ type="button"
184
+ ref={scrollRef}
185
+ data-comment-thread-id={thread.commentId}
186
+ data-comment-thread-status={thread.status}
187
+ className={["w-full cursor-pointer pb-1 pt-2.5 text-left", threadContentPaddingClass, focusRingClass].join(" ")}
188
+ onClick={() => props.onOpenComment?.(thread)}
189
+ >
190
+ {/* Header row: avatar + author + date + status */}
191
+ <div className="mb-1.5 flex items-center gap-1.5">
192
+ <span className="inline-flex h-4 w-4 shrink-0 items-center justify-center rounded-full bg-subtle text-[8px] font-semibold text-secondary">
193
+ {thread.createdBy.charAt(0).toUpperCase()}
200
194
  </span>
201
- )}
202
- <span data-comment-thread-created-at="true" className="text-[9px] text-tertiary">
203
- {formatCommentDate(thread.createdAt)}
204
- </span>
205
- <span className="flex-1" />
206
- {isDraftThread ? <StatusBadge label="draft" tone="draft" /> : null}
207
- {thread.status === "resolved" ? <StatusBadge label="resolved" tone="resolved" /> : null}
208
- </div>
195
+ <span className="truncate text-[10px] font-medium text-primary">{thread.createdBy}</span>
196
+ {thread.status === "detached" && (
197
+ <span
198
+ data-comment-thread-detached-chip="true"
199
+ className="ml-1.5 inline-flex items-center rounded-full bg-[var(--color-semantic-warning-soft)] px-1.5 py-0.5 text-[9px] font-semibold uppercase tracking-[0.08em] text-[var(--color-semantic-warning)]"
200
+ >
201
+ Detached
202
+ </span>
203
+ )}
204
+ <span data-comment-thread-created-at="true" className="text-[9px] text-tertiary">
205
+ {formatCommentDate(thread.createdAt)}
206
+ </span>
207
+ <span className="flex-1" />
208
+ {isDraftThread ? <StatusBadge label="draft" tone="draft" /> : null}
209
+ {thread.status === "resolved" ? <StatusBadge label="resolved" tone="resolved" /> : null}
210
+ </div>
209
211
 
210
- {/* Excerpt — anchored text from document */}
211
- {showExcerpt ? (
212
- <p className="mb-1.5 rounded-md bg-comment-soft px-2 py-1 text-[9px] leading-4 text-secondary italic whitespace-pre-wrap break-words line-clamp-2">
213
- {thread.excerpt}
214
- </p>
215
- ) : null}
212
+ {/* Excerpt — anchored text from document */}
213
+ {showExcerpt ? (
214
+ <p className="mb-1.5 rounded-md bg-comment-soft px-2 py-1 text-[9px] italic leading-4 text-secondary whitespace-pre-wrap break-words line-clamp-2">
215
+ {thread.excerpt}
216
+ </p>
217
+ ) : null}
216
218
 
217
- {/* Comment body */}
218
- {canEdit && (isActive || hasNoBody) ? (
219
- <InlineEditableBody
220
- body={leadEntry?.body ?? ""}
221
- autoFocus={isActive && hasNoBody}
222
- onSave={(newBody) => props.onEditBody?.(thread.commentId, newBody)}
223
- label={isDraftThread ? "New comment" : undefined}
224
- />
225
- ) : presentation ? (
226
- <CommentMarkdownRenderer
227
- body={presentation.body}
228
- mentions={presentation.mentions}
229
- attachments={presentation.attachments}
230
- resolveAttachmentHref={resolveAttachmentHref}
231
- className="text-[10px] leading-[1.1rem] text-secondary break-words"
232
- />
233
- ) : leadEntry?.body ? (
234
- <p
235
- className="text-[10px] leading-[1.1rem] text-secondary whitespace-pre-wrap break-words line-clamp-4"
236
- data-comment-thread-body="true"
237
- >
238
- {leadEntry.body}
239
- </p>
240
- ) : canEdit ? (
241
- <p
242
- className="cursor-text text-[10px] italic text-tertiary"
243
- onClick={(e) => {
244
- e.stopPropagation();
245
- props.onOpenComment?.(thread);
246
- }}
247
- >
248
- New comment
249
- </p>
250
- ) : null}
219
+ {/* Comment body */}
220
+ {canEdit && (isActive || hasNoBody) ? (
221
+ <InlineEditableBody
222
+ body={leadEntry?.body ?? ""}
223
+ autoFocus={isActive && hasNoBody}
224
+ onSave={(newBody) => props.onEditBody?.(thread.commentId, newBody)}
225
+ label={isDraftThread ? "New comment" : undefined}
226
+ />
227
+ ) : presentation ? (
228
+ <CommentMarkdownRenderer
229
+ body={presentation.body}
230
+ mentions={presentation.mentions}
231
+ attachments={presentation.attachments}
232
+ resolveAttachmentHref={resolveAttachmentHref}
233
+ className="text-[10px] leading-[1.1rem] text-secondary break-words"
234
+ />
235
+ ) : leadEntry?.body ? (
236
+ <p
237
+ className="text-[10px] leading-[1.1rem] text-secondary whitespace-pre-wrap break-words line-clamp-4"
238
+ data-comment-thread-body="true"
239
+ >
240
+ {leadEntry.body}
241
+ </p>
242
+ ) : canEdit ? (
243
+ <p
244
+ className="cursor-text text-[10px] italic text-tertiary"
245
+ onClick={(e) => {
246
+ e.stopPropagation();
247
+ props.onOpenComment?.(thread);
248
+ }}
249
+ >
250
+ New comment
251
+ </p>
252
+ ) : null}
251
253
 
252
- {/* Reply entries (compact) */}
253
- {thread.entries.slice(1).map((entry) => {
254
- const replyPresentation = replyPresentationByEntryId.get(entry.entryId);
255
- return (
256
- <div key={entry.entryId} className="mt-2 ml-4 border-l border-border pl-2.5">
257
- <div className="mb-0.5 flex items-center gap-1">
258
- <span className="text-[9px] font-medium text-secondary">{entry.authorId}</span>
259
- <span className="text-[9px] text-tertiary">{formatCommentDate(entry.createdAt)}</span>
254
+ {/* Reply entries (compact) */}
255
+ {thread.entries.slice(1).map((entry) => {
256
+ const replyPresentation = replyPresentationByEntryId.get(entry.entryId);
257
+ return (
258
+ <div key={entry.entryId} className="mt-2 ml-4 border-l border-border pl-2.5">
259
+ <div className="mb-0.5 flex items-center gap-1">
260
+ <span className="text-[9px] font-medium text-secondary">{entry.authorId}</span>
261
+ <span className="text-[9px] text-tertiary">{formatCommentDate(entry.createdAt)}</span>
262
+ </div>
263
+ {replyPresentation ? (
264
+ <CommentMarkdownRenderer
265
+ body={replyPresentation.body}
266
+ mentions={presentation?.mentions}
267
+ attachments={presentation?.attachments}
268
+ resolveAttachmentHref={resolveAttachmentHref}
269
+ className="text-[10px] leading-4 text-secondary break-words"
270
+ />
271
+ ) : (
272
+ <p
273
+ className="text-[10px] leading-4 text-secondary whitespace-pre-wrap break-words line-clamp-3"
274
+ data-comment-reply-body="true"
275
+ >
276
+ {entry.body}
277
+ </p>
278
+ )}
260
279
  </div>
261
- {replyPresentation ? (
262
- <CommentMarkdownRenderer
263
- body={replyPresentation.body}
264
- mentions={presentation?.mentions}
265
- attachments={presentation?.attachments}
266
- resolveAttachmentHref={resolveAttachmentHref}
267
- className="text-[10px] leading-4 text-secondary break-words"
268
- />
269
- ) : (
270
- <p
271
- className="text-[10px] leading-4 text-secondary whitespace-pre-wrap break-words line-clamp-3"
272
- data-comment-reply-body="true"
273
- >
274
- {entry.body}
275
- </p>
276
- )}
277
- </div>
278
- );
279
- })}
280
+ );
281
+ })}
280
282
 
281
- {thread.entryCount > thread.entries.length ? (
282
- <p className="mt-1 text-[9px] text-tertiary">
283
- +{thread.entryCount - thread.entries.length} more
284
- </p>
285
- ) : null}
283
+ {thread.entryCount > thread.entries.length ? (
284
+ <p className="mt-1 text-[9px] text-tertiary">
285
+ +{thread.entryCount - thread.entries.length} more
286
+ </p>
287
+ ) : null}
288
+ </button>
286
289
 
287
290
  {/* Inline actions — compact, horizontal */}
288
- <div className="mt-2 flex items-center gap-1">
291
+ <div className={["mt-2 flex items-center gap-1 pb-2.5", threadContentPaddingClass].join(" ")}>
289
292
  {thread.status === "open" && (
290
293
  <>
291
294
  <button
292
295
  type="button"
293
296
  className="inline-flex items-center gap-0.5 rounded px-1 py-0.5 text-[9px] font-medium text-accent hover:bg-accent-soft transition-colors"
294
- onClick={(e) => { e.stopPropagation(); props.onResolveComment?.(thread.commentId); }}
297
+ onClick={() => props.onResolveComment?.(thread.commentId)}
295
298
  >
296
299
  <Check className="h-2 w-2" /> Resolve
297
300
  </button>
@@ -305,7 +308,7 @@ function CommentThreadCard(props: {
305
308
  type="button"
306
309
  className="inline-flex items-center gap-0.5 rounded px-1 py-0.5 text-[9px] font-medium text-secondary hover:bg-surface-hover transition-colors"
307
310
  data-comment-thread-action="reopen"
308
- onClick={(e) => { e.stopPropagation(); props.onReopenComment?.(thread.commentId); }}
311
+ onClick={() => props.onReopenComment?.(thread.commentId)}
309
312
  >
310
313
  <RotateCcw className="h-2 w-2" /> Reopen
311
314
  </button>
@@ -314,7 +317,7 @@ function CommentThreadCard(props: {
314
317
  <span className="text-[9px] text-comment">Detached</span>
315
318
  )}
316
319
  </div>
317
- </button>
320
+ </div>
318
321
  );
319
322
  }
320
323
 
@@ -1,5 +1,6 @@
1
1
  import React from "react";
2
2
  import { HelpCircle, Search } from "lucide-react";
3
+ import { FOCUS_RING_CLASSES } from "../theme/tokens";
3
4
 
4
5
  /**
5
6
  * Thin pinned footer rendered at the bottom of the review rail. The footer
@@ -14,8 +15,7 @@ export interface TwReviewRailFooterProps {
14
15
  searchLabel?: string;
15
16
  }
16
17
 
17
- const focusRingClass =
18
- "focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-1 focus-visible:ring-offset-canvas";
18
+ const focusRingClass = FOCUS_RING_CLASSES;
19
19
 
20
20
  /**
21
21
  * Accept only http(s) and mailto help links. Rejects javascript:, data:,
@@ -273,6 +273,20 @@ export const HOST_LOCKED_TOKENS = [
273
273
  export type HostOverridableToken = (typeof HOST_OVERRIDABLE_TOKENS)[number];
274
274
  export type HostLockedToken = (typeof HOST_LOCKED_TOKENS)[number];
275
275
 
276
+ /**
277
+ * Canonical focus-visible ring class string (designsystem §4.7 / §7.2).
278
+ *
279
+ * Every interactive chrome surface that renders a custom focus indicator
280
+ * MUST import this constant rather than inline the Tailwind utilities.
281
+ * The invariant is enforced by `test/ui-tailwind/focus-ring-canonical.test.ts`.
282
+ *
283
+ * Exceptions: input / textarea elements that use a 1-px border ring as the
284
+ * idle-state affordance (e.g. `tw-comment-sidebar` reply composer) are
285
+ * a distinct pattern and not covered by this utility.
286
+ */
287
+ export const FOCUS_RING_CLASSES =
288
+ "focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-2 focus-visible:ring-offset-canvas";
289
+
276
290
  /** Returns true if `path` is in the locked set (must not be overridden by hosts). */
277
291
  export function isTokenPathLocked(path: string): boolean {
278
292
  return (HOST_LOCKED_TOKENS as readonly string[]).includes(path);
@@ -131,6 +131,11 @@ export function TwShellHeader(props: TwShellHeaderProps): React.ReactElement {
131
131
  key={mode.id}
132
132
  value={mode.id}
133
133
  disabled={mode.disabled}
134
+ onClick={() => {
135
+ if (!mode.disabled) {
136
+ props.onModeChange?.(mode.id);
137
+ }
138
+ }}
134
139
  className={`wre-rail-tab ${focusRingClass}`}
135
140
  data-testid={`tw-shell-header__mode-${mode.id}`}
136
141
  >
@@ -17,6 +17,7 @@ import type {
17
17
  ActiveListContext,
18
18
  CommentSidebarThreadSnapshot,
19
19
  DocumentNavigationSnapshot,
20
+ EditorAnchorProjection,
20
21
  EditorStoryTarget,
21
22
  EditorViewStateSnapshot,
22
23
  FormattingStateSnapshot,
@@ -39,15 +40,8 @@ import type {
39
40
  WorkspaceMode,
40
41
  ZoomLevel,
41
42
  } from "../api/public-types";
42
- import { findPageForOffset } from "../runtime/document-navigation.ts";
43
43
  import { createCanvasBackend } from "../runtime/layout/index.ts";
44
- import {
45
- DEFAULT_PAGE_ESTIMATE_PX_PER_TWIP,
46
- estimateBlockHeight,
47
- estimateParagraphLineCount,
48
- estimateParagraphLineHeight,
49
- getUsableColumnWidth,
50
- } from "../runtime/page-layout-estimation.ts";
44
+ import { DEFAULT_PAGE_ESTIMATE_PX_PER_TWIP } from "../runtime/page-layout-estimation.ts";
51
45
  import {
52
46
  incrementInvalidationCounter,
53
47
  recordPerfSample,
@@ -96,7 +90,7 @@ import { resolveSelectionToolPlacement } from "./chrome/tw-selection-tool-placem
96
90
  import { TwReviewRail, type ReviewRailTab } from "./review/tw-review-rail";
97
91
  import { TwStatusBar } from "./status/tw-status-bar";
98
92
  import { type ToolbarInteractionPolicy } from "./toolbar/tw-toolbar";
99
- import { TwChromeOverlay } from "./chrome-overlay";
93
+ import { TwChromeOverlay, TwPageStackOverlayLayer } from "./chrome-overlay";
100
94
  import {
101
95
  cycleScopeIndex,
102
96
  shouldHandleScopeNavKey,
@@ -423,6 +417,7 @@ export interface TwReviewWorkspaceProps {
423
417
  */
424
418
  onScopeAskAgent?: (payload: {
425
419
  scopeId: string;
420
+ anchor?: EditorAnchorProjection;
426
421
  }) => void;
427
422
  /**
428
423
  * P3 — optional scope-tag editor slot rendered inside the scope
@@ -538,9 +533,12 @@ export function TwReviewWorkspace(inputProps: TwReviewWorkspaceProps) {
538
533
  const onScopeAskAgent = props.onScopeAskAgent;
539
534
  const handleScopeCardAskAgent = useCallback(
540
535
  (scopeId: string) => {
541
- onScopeAskAgent?.({ scopeId });
536
+ const cardModel = props.layoutFacet
537
+ ?.getAllScopeCardModels?.()
538
+ ?.find((m) => m.scopeId === scopeId);
539
+ onScopeAskAgent?.({ scopeId, anchor: cardModel?.anchor });
542
540
  },
543
- [onScopeAskAgent],
541
+ [onScopeAskAgent, props.layoutFacet],
544
542
  );
545
543
  const zoomLevel = props.zoomLevel ?? 100;
546
544
  // Numeric zooms resolve immediately; "pageWidth" / "onePage" need the
@@ -548,7 +546,7 @@ export function TwReviewWorkspace(inputProps: TwReviewWorkspaceProps) {
548
546
  // `pageShellMetrics` has been computed (P2.c).
549
547
  const numericZoomScale = typeof zoomLevel === "number" ? zoomLevel / 100 : 1;
550
548
  const chromePreset = resolveChromePreset(props.chromePreset, props.reviewMode);
551
- const chromeOptions = resolveChromePresetOptions(chromePreset, props.chromeOptions);
549
+ const chromeOptions = resolveChromePresetOptions(chromePreset, props.chromeOptions, viewState.editorRole);
552
550
  const preserveOnlyCount = caps?.preserveOnlyCount ??
553
551
  snapshot.compatibility.featureEntries.filter(
554
552
  (entry) => entry.featureClass === "preserve-only",
@@ -1606,12 +1604,12 @@ export function TwReviewWorkspace(inputProps: TwReviewWorkspaceProps) {
1606
1604
  style={
1607
1605
  isPageWorkspace
1608
1606
  ? {
1609
- // Phase A (L8 page-native layout): the paper frame
1610
- // owns paper width/height + browser-native CSS
1611
- // `zoom` so layout measurement stays truthful
1612
- // inside the card. `pageShellMetrics.pageFrameStyle`
1613
- // carries the paper chrome (bg + shadow + rounded +
1614
- // border) painted exactly once.
1607
+ // N1 (L8 Phase D): paper chrome (bg/border/shadow)
1608
+ // now lives on per-page overlay cards (z-0) painted
1609
+ // by TwPageStackOverlayLayer below. This wrapper
1610
+ // keeps width/height/zoom so layout measurement stays
1611
+ // truthful but is visually transparent so the canvas
1612
+ // background shows through inter-page gaps.
1615
1613
  ...(pageShellMetrics.frameWidthPx
1616
1614
  ? { width: `${pageShellMetrics.frameWidthPx}px` }
1617
1615
  : {}),
@@ -1619,11 +1617,23 @@ export function TwReviewWorkspace(inputProps: TwReviewWorkspaceProps) {
1619
1617
  ? { minHeight: `${pageShellMetrics.frameHeightPx}px` }
1620
1618
  : {}),
1621
1619
  ...(zoomScale !== 1 ? { zoom: zoomScale } : {}),
1622
- ...pageShellMetrics.pageFrameStyle,
1623
1620
  }
1624
1621
  : undefined
1625
1622
  }
1626
1623
  >
1624
+ {/* N1 (L8 Phase D): per-page paper card backgrounds at z-0,
1625
+ painted before the z-10 PM wrapper so white card areas
1626
+ sit behind PM text. Gaps between cards expose the gray
1627
+ workspace-canvas background, giving N discrete papers. */}
1628
+ {isPageWorkspace && chromeVisibility.pageChrome && props.layoutFacet ? (
1629
+ <TwPageStackOverlayLayer
1630
+ facet={props.layoutFacet}
1631
+ scrollRoot={pageStackScrollRoot}
1632
+ renderFrameRevision={renderFrameRevision}
1633
+ visiblePageIndexRange={visiblePageIndexRange}
1634
+ data-layer="page-card-backgrounds"
1635
+ />
1636
+ ) : null}
1627
1637
  {isPageWorkspace && chromeVisibility.pageChrome && pageChromeModel.lineNumberingEnabled ? (
1628
1638
  <div
1629
1639
  aria-hidden="true"
@@ -1990,7 +2000,6 @@ const EMPTY_PAGE_CHROME_MODEL: PageChromeModel = {
1990
2000
  documentGridStyle: undefined,
1991
2001
  };
1992
2002
 
1993
- const DOCUMENT_CONTENT_TOP_PADDING_PX = 40;
1994
2003
 
1995
2004
  // P2.a — real-dimension page frame. Page frame width/height are
1996
2005
  // `pageLayout.pageWidth/pageHeight × FRAME_PX_PER_TWIP_AT_96DPI` so
@@ -2024,7 +2033,6 @@ function buildPageChromeModel(
2024
2033
  pageLayout,
2025
2034
  surfaceBlocks: surface.blocks,
2026
2035
  pages: navigation.pages,
2027
- buildLineNumberMarkers,
2028
2036
  });
2029
2037
  const lineNumberingEnabled =
2030
2038
  Boolean(pageLayout.lineNumbering) && lineMarkers.length > 0;
@@ -2124,72 +2132,6 @@ export function resolveZoomMultiplier(
2124
2132
  );
2125
2133
  }
2126
2134
 
2127
- function buildLineNumberMarkers(
2128
- blocks: readonly SurfaceBlockSnapshot[],
2129
- pages: ReadonlyArray<DocumentNavigationSnapshot["pages"][number]>,
2130
- ): Array<{ id: string; label: string; topPx: number }> {
2131
- const markers: Array<{ id: string; label: string; topPx: number }> = [];
2132
- if (pages.length === 0) {
2133
- return markers;
2134
- }
2135
-
2136
- let currentTopTwips = 0;
2137
- let lineNumber = 1;
2138
- let lastPageIndex = -1;
2139
- let lastSectionIndex = -1;
2140
-
2141
- for (const block of blocks) {
2142
- const pageIndex = findPageForOffset(pages, block.from);
2143
- const page = pages[pageIndex];
2144
- if (!page) {
2145
- continue;
2146
- }
2147
-
2148
- const lineNumbering = page.layout.lineNumbering;
2149
- const restartMode = lineNumbering?.restart ?? "newPage";
2150
- const restartStart = lineNumbering?.start ?? 1;
2151
- const countBy = Math.max(1, lineNumbering?.countBy ?? 1);
2152
- const columnWidth = getUsableColumnWidth(page.layout);
2153
-
2154
- if (pageIndex !== lastPageIndex) {
2155
- if (restartMode === "newPage" || lastPageIndex === -1) {
2156
- lineNumber = restartStart;
2157
- }
2158
- lastPageIndex = pageIndex;
2159
- }
2160
- if (page.sectionIndex !== lastSectionIndex) {
2161
- if (restartMode === "newSection" || lastSectionIndex === -1) {
2162
- lineNumber = restartStart;
2163
- }
2164
- lastSectionIndex = page.sectionIndex;
2165
- }
2166
-
2167
- if (block.kind === "paragraph" && lineNumbering) {
2168
- const lineCount = estimateParagraphLineCount(block, columnWidth);
2169
- const lineHeight = estimateParagraphLineHeight(block);
2170
- const suppress = block.suppressLineNumbers === true;
2171
- for (let lineIndex = 0; lineIndex < lineCount; lineIndex += 1) {
2172
- if (!suppress && (lineNumber - restartStart) % countBy === 0) {
2173
- markers.push({
2174
- id: `${block.blockId}-${lineIndex}`,
2175
- label: String(lineNumber),
2176
- topPx:
2177
- DOCUMENT_CONTENT_TOP_PADDING_PX +
2178
- (currentTopTwips + lineIndex * lineHeight) * DEFAULT_PAGE_ESTIMATE_PX_PER_TWIP,
2179
- });
2180
- }
2181
- if (!suppress) {
2182
- lineNumber += 1;
2183
- }
2184
- }
2185
- }
2186
-
2187
- currentTopTwips += estimateBlockHeight(block, columnWidth);
2188
- }
2189
-
2190
- return markers;
2191
- }
2192
-
2193
2135
  function shouldRenderPageBorder(
2194
2136
  pageLayout: RuntimeRenderSnapshot["pageLayout"],
2195
2137
  pages: ReadonlyArray<DocumentNavigationSnapshot["pages"][number]>,
@@ -17,6 +17,7 @@ export const EDITOR_WARNING_CODES = [
17
17
  "large_document_degraded",
18
18
  "font_substitution",
19
19
  "image_missing",
20
+ "review_target_not_found",
20
21
  ] as const;
21
22
 
22
23
  export type EditorWarningCode = (typeof EDITOR_WARNING_CODES)[number];