@beyondwork/docx-react-component 1.0.40 → 1.0.42

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 (88) hide show
  1. package/package.json +13 -1
  2. package/src/api/awareness-identity-types.ts +35 -0
  3. package/src/api/comment-negotiation-types.ts +130 -0
  4. package/src/api/comment-presentation-types.ts +106 -0
  5. package/src/api/external-custody-types.ts +74 -0
  6. package/src/api/participants-types.ts +18 -0
  7. package/src/api/public-types.ts +347 -4
  8. package/src/api/scope-metadata-resolver-types.ts +88 -0
  9. package/src/core/commands/formatting-commands.ts +1 -1
  10. package/src/core/commands/index.ts +568 -1
  11. package/src/index.ts +118 -1
  12. package/src/io/export/escape-xml-attribute.ts +26 -0
  13. package/src/io/export/external-send.ts +188 -0
  14. package/src/io/export/serialize-comments.ts +13 -16
  15. package/src/io/export/serialize-footnotes.ts +17 -24
  16. package/src/io/export/serialize-headers-footers.ts +17 -24
  17. package/src/io/export/serialize-main-document.ts +59 -62
  18. package/src/io/export/serialize-numbering.ts +20 -27
  19. package/src/io/export/serialize-runtime-revisions.ts +2 -9
  20. package/src/io/export/serialize-tables.ts +8 -15
  21. package/src/io/export/table-properties-xml.ts +25 -32
  22. package/src/io/import/external-reimport.ts +40 -0
  23. package/src/io/ooxml/bw-xml.ts +244 -0
  24. package/src/io/ooxml/canonicalize-payload.ts +301 -0
  25. package/src/io/ooxml/comment-negotiation-payload.ts +288 -0
  26. package/src/io/ooxml/comment-presentation-payload.ts +311 -0
  27. package/src/io/ooxml/external-custody-payload.ts +102 -0
  28. package/src/io/ooxml/participants-payload.ts +97 -0
  29. package/src/io/ooxml/payload-signature.ts +112 -0
  30. package/src/io/ooxml/workflow-payload-validator.ts +271 -0
  31. package/src/io/ooxml/workflow-payload.ts +146 -7
  32. package/src/runtime/awareness-identity.ts +173 -0
  33. package/src/runtime/collab/event-types.ts +27 -0
  34. package/src/runtime/collab-session-bridge.ts +157 -0
  35. package/src/runtime/collab-session-facet.ts +193 -0
  36. package/src/runtime/collab-session.ts +273 -0
  37. package/src/runtime/comment-negotiation-sync.ts +91 -0
  38. package/src/runtime/comment-negotiation.ts +158 -0
  39. package/src/runtime/comment-presentation.ts +223 -0
  40. package/src/runtime/document-runtime.ts +280 -93
  41. package/src/runtime/external-send-runtime.ts +117 -0
  42. package/src/runtime/layout/docx-font-loader.ts +11 -30
  43. package/src/runtime/layout/inert-layout-facet.ts +2 -0
  44. package/src/runtime/layout/layout-engine-instance.ts +122 -12
  45. package/src/runtime/layout/page-graph.ts +79 -7
  46. package/src/runtime/layout/paginated-layout-engine.ts +230 -34
  47. package/src/runtime/layout/public-facet.ts +185 -13
  48. package/src/runtime/layout/table-row-split.ts +316 -0
  49. package/src/runtime/markdown-sanitizer.ts +132 -0
  50. package/src/runtime/participants.ts +134 -0
  51. package/src/runtime/resign-payload.ts +120 -0
  52. package/src/runtime/tamper-gate.ts +157 -0
  53. package/src/runtime/workflow-markup.ts +9 -0
  54. package/src/runtime/workflow-rail-segments.ts +244 -5
  55. package/src/ui/WordReviewEditor.tsx +587 -0
  56. package/src/ui/editor-runtime-boundary.ts +1 -0
  57. package/src/ui/editor-shell-view.tsx +11 -0
  58. package/src/ui-tailwind/chrome/chrome-preset-model.ts +28 -0
  59. package/src/ui-tailwind/chrome/collab-audience-chip.tsx +73 -0
  60. package/src/ui-tailwind/chrome/collab-negotiation-action-bar.tsx +244 -0
  61. package/src/ui-tailwind/chrome/collab-presence-strip.tsx +150 -0
  62. package/src/ui-tailwind/chrome/collab-role-chip.tsx +62 -0
  63. package/src/ui-tailwind/chrome/collab-send-to-supplier-button.tsx +68 -0
  64. package/src/ui-tailwind/chrome/collab-send-to-supplier-modal.tsx +149 -0
  65. package/src/ui-tailwind/chrome/collab-tamper-banner.tsx +68 -0
  66. package/src/ui-tailwind/chrome/forward-non-drag-click.ts +104 -0
  67. package/src/ui-tailwind/chrome/tw-mode-dock.tsx +1 -0
  68. package/src/ui-tailwind/chrome/tw-selection-tool-host.tsx +7 -1
  69. package/src/ui-tailwind/chrome/tw-table-grip-layer.tsx +1 -38
  70. package/src/ui-tailwind/chrome-overlay/index.ts +6 -0
  71. package/src/ui-tailwind/chrome-overlay/scope-card-role-model.ts +78 -0
  72. package/src/ui-tailwind/chrome-overlay/scope-keyboard-cycle.ts +49 -0
  73. package/src/ui-tailwind/chrome-overlay/tw-chrome-overlay.tsx +58 -0
  74. package/src/ui-tailwind/chrome-overlay/tw-page-stack-overlay-layer.tsx +527 -0
  75. package/src/ui-tailwind/chrome-overlay/tw-scope-card-layer.tsx +120 -22
  76. package/src/ui-tailwind/chrome-overlay/tw-scope-card.tsx +310 -32
  77. package/src/ui-tailwind/chrome-overlay/tw-scope-rail-layer.tsx +93 -14
  78. package/src/ui-tailwind/editor-surface/pm-decorations.ts +35 -1
  79. package/src/ui-tailwind/editor-surface/pm-page-break-decorations.ts +86 -3
  80. package/src/ui-tailwind/editor-surface/pm-schema.ts +15 -13
  81. package/src/ui-tailwind/editor-surface/remote-cursor-plugin.ts +20 -3
  82. package/src/ui-tailwind/editor-surface/tw-page-block-view.tsx +15 -14
  83. package/src/ui-tailwind/editor-surface/tw-table-node-view.tsx +66 -0
  84. package/src/ui-tailwind/index.ts +32 -0
  85. package/src/ui-tailwind/review/tw-review-rail-footer.tsx +29 -3
  86. package/src/ui-tailwind/status/tw-status-bar.tsx +52 -1
  87. package/src/ui-tailwind/theme/editor-theme.css +25 -0
  88. package/src/ui-tailwind/tw-review-workspace.tsx +293 -34
@@ -1,6 +1,9 @@
1
1
  import React from "react";
2
2
 
3
- import type { RuntimeContextAnalyticsSnapshot } from "../../api/public-types";
3
+ import type {
4
+ PublicMeasurementFidelity,
5
+ RuntimeContextAnalyticsSnapshot,
6
+ } from "../../api/public-types";
4
7
 
5
8
  export interface TwStatusBarProps {
6
9
  isDirty: boolean;
@@ -10,6 +13,26 @@ export interface TwStatusBarProps {
10
13
  changeCount: number;
11
14
  sessionId: string;
12
15
  contextAnalytics?: RuntimeContextAnalyticsSnapshot | null;
16
+ /**
17
+ * P5b: **displayed** page number for the selection head. When the
18
+ * active section restarts page numbering (roman front matter → arabic
19
+ * body), this reflects what Word would print on the page — not the
20
+ * raw 0-based document-order index. Pass `null` when the facet has
21
+ * no resolved page (e.g., during initial load).
22
+ */
23
+ displayPageNumber?: number | null;
24
+ /**
25
+ * P5b: total **content** page count (excludes evenPage / oddPage
26
+ * blank fillers). Matches `facet.getPageCount()` — same quantity a
27
+ * `NUMPAGES` field resolves to on every page.
28
+ */
29
+ pageCount?: number | null;
30
+ /**
31
+ * P5b: measurement fidelity the layout engine is computing against.
32
+ * Surfaced as a compact badge so harness operators can see when the
33
+ * canvas backend is live vs. the empirical fallback. Absent = skip.
34
+ */
35
+ measurementFidelity?: PublicMeasurementFidelity;
13
36
  }
14
37
 
15
38
  export function TwStatusBar(props: TwStatusBarProps) {
@@ -56,10 +79,38 @@ export function TwStatusBar(props: TwStatusBarProps) {
56
79
  {props.commentCount} comment{props.commentCount !== 1 ? "s" : ""} ·{" "}
57
80
  {props.changeCount} change{props.changeCount !== 1 ? "s" : ""}
58
81
  </span>
82
+ {props.displayPageNumber != null && props.pageCount != null ? (
83
+ <span data-testid="status-bar-page-count">
84
+ Page {props.displayPageNumber} of {props.pageCount}
85
+ </span>
86
+ ) : null}
59
87
  <span className="flex-1" />
88
+ {props.measurementFidelity ? (
89
+ <span
90
+ data-testid="status-bar-measurement-fidelity"
91
+ data-fidelity={props.measurementFidelity}
92
+ className="uppercase tracking-[0.12em] text-tertiary/70"
93
+ title={`Measurement fidelity: ${props.measurementFidelity}`}
94
+ >
95
+ {formatFidelityBadge(props.measurementFidelity)}
96
+ </span>
97
+ ) : null}
60
98
  <span className="truncate text-[10px] uppercase tracking-[0.12em] text-tertiary/80">
61
99
  Session active
62
100
  </span>
63
101
  </footer>
64
102
  );
65
103
  }
104
+
105
+ function formatFidelityBadge(fidelity: PublicMeasurementFidelity): string {
106
+ switch (fidelity) {
107
+ case "empirical":
108
+ return "E";
109
+ case "canvas":
110
+ return "C";
111
+ case "canvas-with-font-loading":
112
+ return "C+F";
113
+ default:
114
+ return String(fidelity);
115
+ }
116
+ }
@@ -488,6 +488,31 @@
488
488
  outline-offset: -1px;
489
489
  }
490
490
 
491
+ /*
492
+ * ─── Agent-pending shimmer (K2 / scope-card-overlay P2) ───
493
+ *
494
+ * Painted on every scope tint that overlaps a WorkflowCandidateRange
495
+ * with `source: "ai"`. A soft 1.8s pulse signals the agent is
496
+ * thinking without competing with the active outline. Reduced-
497
+ * motion disables the animation and holds a static 60% opacity
498
+ * border so the posture is still readable.
499
+ */
500
+ @keyframes wre-agent-pulse {
501
+ 0%, 100% { opacity: 0.4; }
502
+ 50% { opacity: 0.85; }
503
+ }
504
+
505
+ .wre-scope-rail-tint-agent-pending {
506
+ outline: 1px solid color-mix(in srgb, var(--color-workflow) 70%, transparent);
507
+ outline-offset: -1px;
508
+ animation: wre-agent-pulse 1.8s ease-in-out infinite;
509
+ }
510
+
511
+ [data-reduced-motion="true"] .wre-scope-rail-tint-agent-pending {
512
+ animation: none;
513
+ opacity: 0.6;
514
+ }
515
+
491
516
  /*
492
517
  * ─── Scope rail stripe ───
493
518
  *
@@ -88,6 +88,10 @@ import { TwReviewRail, type ReviewRailTab } from "./review/tw-review-rail";
88
88
  import { TwStatusBar } from "./status/tw-status-bar";
89
89
  import { type ToolbarInteractionPolicy } from "./toolbar/tw-toolbar";
90
90
  import { TwChromeOverlay } from "./chrome-overlay";
91
+ import {
92
+ cycleScopeIndex,
93
+ shouldHandleScopeNavKey,
94
+ } from "./chrome-overlay/scope-keyboard-cycle";
91
95
 
92
96
  export type ReviewWorkspaceChromeVisibility = WordReviewEditorChromeVisibility;
93
97
 
@@ -343,6 +347,34 @@ export interface TwReviewWorkspaceProps {
343
347
  issueId: string;
344
348
  action: import("../api/public-types.ts").ScopeIssueAction;
345
349
  }) => void;
350
+ /**
351
+ * R3 — scope card suggestion-group accept button fired. WordReview-
352
+ * Editor relays to `ref.acceptSuggestionGroup(groupId)` which fans
353
+ * out to individual `acceptChange` calls across the group members.
354
+ */
355
+ onScopeAcceptSuggestionGroup?: (payload: {
356
+ scopeId: string;
357
+ groupId: string;
358
+ }) => void;
359
+ /** R3 — scope card suggestion-group reject. */
360
+ onScopeRejectSuggestionGroup?: (payload: {
361
+ scopeId: string;
362
+ groupId: string;
363
+ }) => void;
364
+ /**
365
+ * K2 — scope card "Ask review agent" fired. WordReviewEditor emits
366
+ * `agent-on-selection-requested` via WordReviewEditorEvent.
367
+ */
368
+ onScopeAskAgent?: (payload: {
369
+ scopeId: string;
370
+ }) => void;
371
+ /**
372
+ * P3 — optional scope-tag editor slot rendered inside the scope
373
+ * card when `editorRole === "workflow"`. Hosts pass a chip picker,
374
+ * free-text input, or whatever authoring surface they want. Unset
375
+ * in editor/review roles.
376
+ */
377
+ scopeCardScopeTagEditor?: ReactNode;
346
378
  }
347
379
 
348
380
  export function TwReviewWorkspace(inputProps: TwReviewWorkspaceProps) {
@@ -373,6 +405,37 @@ export function TwReviewWorkspace(inputProps: TwReviewWorkspaceProps) {
373
405
  const handleScopeCardClose = useCallback(() => {
374
406
  setActiveScopeId(null);
375
407
  }, []);
408
+
409
+ // P3d: keyboard scope navigation. J / K cycle the active scope in
410
+ // document order; Enter opens the first scope when none is active.
411
+ // `shouldHandleScopeNavKey` + `cycleScopeIndex` are extracted pure
412
+ // helpers so the logic is unit-testable without a workspace mount.
413
+ useEffect(() => {
414
+ const layoutFacet = props.layoutFacet;
415
+ if (!layoutFacet || typeof layoutFacet.getAllScopeCardModels !== "function") {
416
+ return undefined;
417
+ }
418
+ const onKey = (event: KeyboardEvent) => {
419
+ if (!shouldHandleScopeNavKey(event)) return;
420
+ const models = layoutFacet.getAllScopeCardModels();
421
+ if (models.length === 0) return;
422
+ const ids = models.map((model) => model.scopeId);
423
+ const key = event.key.toLowerCase();
424
+ if (key === "enter") {
425
+ if (!activeScopeId) {
426
+ setActiveScopeId(ids[0] ?? null);
427
+ event.preventDefault();
428
+ }
429
+ return;
430
+ }
431
+ const direction: 1 | -1 = key === "j" ? 1 : -1;
432
+ const next = cycleScopeIndex(activeScopeId, ids, direction);
433
+ setActiveScopeId(next);
434
+ event.preventDefault();
435
+ };
436
+ window.addEventListener("keydown", onKey);
437
+ return () => window.removeEventListener("keydown", onKey);
438
+ }, [props.layoutFacet, activeScopeId]);
376
439
  const onScopeModeChangeRequested = props.onScopeModeChangeRequested;
377
440
  const handleScopeCardModeChange = useCallback(
378
441
  (scopeId: string, mode: import("../api/public-types.ts").WorkflowScopeMode) => {
@@ -391,10 +454,32 @@ export function TwReviewWorkspace(inputProps: TwReviewWorkspaceProps) {
391
454
  },
392
455
  [onScopeIssueActionRequested],
393
456
  );
457
+ const onScopeAcceptSuggestionGroup = props.onScopeAcceptSuggestionGroup;
458
+ const handleScopeCardAcceptSuggestionGroup = useCallback(
459
+ (scopeId: string, groupId: string) => {
460
+ onScopeAcceptSuggestionGroup?.({ scopeId, groupId });
461
+ },
462
+ [onScopeAcceptSuggestionGroup],
463
+ );
464
+ const onScopeRejectSuggestionGroup = props.onScopeRejectSuggestionGroup;
465
+ const handleScopeCardRejectSuggestionGroup = useCallback(
466
+ (scopeId: string, groupId: string) => {
467
+ onScopeRejectSuggestionGroup?.({ scopeId, groupId });
468
+ },
469
+ [onScopeRejectSuggestionGroup],
470
+ );
471
+ const onScopeAskAgent = props.onScopeAskAgent;
472
+ const handleScopeCardAskAgent = useCallback(
473
+ (scopeId: string) => {
474
+ onScopeAskAgent?.({ scopeId });
475
+ },
476
+ [onScopeAskAgent],
477
+ );
394
478
  const zoomLevel = props.zoomLevel ?? 100;
395
- const zoomScale = typeof zoomLevel === "number" ? zoomLevel / 100 : 1;
396
- const pageZoomBucket =
397
- !isPageWorkspace ? undefined : zoomScale < 1 ? "low" : zoomScale > 1 ? "high" : "base";
479
+ // Numeric zooms resolve immediately; "pageWidth" / "onePage" need the
480
+ // page-frame dimensions to fit against — they're resolved below once
481
+ // `pageShellMetrics` has been computed (P2.c).
482
+ const numericZoomScale = typeof zoomLevel === "number" ? zoomLevel / 100 : 1;
398
483
  const chromePreset = resolveChromePreset(props.chromePreset, props.reviewMode);
399
484
  const chromeOptions = resolveChromePresetOptions(chromePreset, props.chromeOptions);
400
485
  const preserveOnlyCount = caps?.preserveOnlyCount ??
@@ -412,6 +497,7 @@ export function TwReviewWorkspace(inputProps: TwReviewWorkspaceProps) {
412
497
  });
413
498
  const reviewRailAvailable = chromeVisibility.reviewRail && (caps?.reviewRailVisible ?? true);
414
499
  const [viewportWidth, setViewportWidth] = useState<number | undefined>(() => readViewportWidth());
500
+ const [viewportHeight, setViewportHeight] = useState<number | undefined>(() => readViewportHeight());
415
501
  const [reviewRailOpen, setReviewRailOpen] = useState(() =>
416
502
  getInitialReviewRailOpen({
417
503
  viewportWidth: readViewportWidth(),
@@ -461,6 +547,33 @@ export function TwReviewWorkspace(inputProps: TwReviewWorkspaceProps) {
461
547
  }
462
548
  return props.activeSelectionTool;
463
549
  }, [props.activeSelectionTool, chromeVisibility.contextToolbars]);
550
+ const pageShellMetrics = useMemo(
551
+ () => buildPageShellMetrics(snapshot.pageLayout),
552
+ [snapshot.pageLayout],
553
+ );
554
+ // P2.c — resolve "pageWidth" / "onePage" against the active section's
555
+ // real paper dimensions. Numeric zooms pass through. Falls back to
556
+ // `numericZoomScale` (1.0 for symbolic zooms when paper dims are
557
+ // unavailable, e.g., during initial load).
558
+ const zoomScale = useMemo(() => {
559
+ if (typeof zoomLevel === "number") return numericZoomScale;
560
+ return resolveZoomMultiplier(
561
+ zoomLevel,
562
+ pageShellMetrics.frameWidthPx ?? 0,
563
+ pageShellMetrics.frameHeightPx ?? 0,
564
+ viewportWidth,
565
+ viewportHeight,
566
+ );
567
+ }, [
568
+ zoomLevel,
569
+ numericZoomScale,
570
+ pageShellMetrics.frameWidthPx,
571
+ pageShellMetrics.frameHeightPx,
572
+ viewportWidth,
573
+ viewportHeight,
574
+ ]);
575
+ const pageZoomBucket =
576
+ !isPageWorkspace ? undefined : zoomScale < 1 ? "low" : zoomScale > 1 ? "high" : "base";
464
577
  const selectionToolbarPlacement = useMemo(() => {
465
578
  // Prefer render-frame anchors when the layout facet is available — this
466
579
  // keeps the tool glued to kernel coordinates across zoom, scroll, and
@@ -496,10 +609,37 @@ export function TwReviewWorkspace(inputProps: TwReviewWorkspaceProps) {
496
609
  renderFrameRevision,
497
610
  ]);
498
611
  const activePage = props.documentNavigation?.pages[props.documentNavigation.activePageIndex] ?? null;
499
- const pageShellMetrics = useMemo(
500
- () => buildPageShellMetrics(snapshot.pageLayout),
501
- [snapshot.pageLayout],
502
- );
612
+ // P5b status-bar facts derived from the layout facet so the
613
+ // Page-N-of-M display + measurement-fidelity badge ("E" / "C" / "C+F")
614
+ // refresh on every layout-affecting facet event. The subscription
615
+ // above bumps `renderFrameRevision` on the same kinds; including it
616
+ // in the dependency list re-runs this memo without a separate
617
+ // subscription.
618
+ const statusBarPageFacts = useMemo(() => {
619
+ const facet = props.layoutFacet;
620
+ if (!facet) {
621
+ return {
622
+ displayPageNumber: null as number | null,
623
+ pageCount: null as number | null,
624
+ measurementFidelity: undefined as
625
+ | import("../api/public-types.ts").PublicMeasurementFidelity
626
+ | undefined,
627
+ };
628
+ }
629
+ const head = selectionPosition;
630
+ const pageRef = facet.getPageForOffset(head, snapshot.activeStory);
631
+ const displayPageNumber =
632
+ pageRef !== null && typeof pageRef.pageIndex === "number"
633
+ ? facet.getDisplayPageNumber(pageRef.pageIndex) ?? pageRef.pageIndex + 1
634
+ : null;
635
+ const pageCount = facet.getPageCount();
636
+ return {
637
+ displayPageNumber,
638
+ pageCount,
639
+ measurementFidelity: facet.getMeasurementFidelity(),
640
+ };
641
+ // eslint-disable-next-line react-hooks/exhaustive-deps
642
+ }, [props.layoutFacet, selectionPosition, snapshot.activeStory, renderFrameRevision]);
503
643
  const headerBandLabel = resolvePageBandLabel("header", snapshot.activeStory);
504
644
  const footerBandLabel = resolvePageBandLabel("footer", snapshot.activeStory);
505
645
  const hidePageBorderForActiveEditing =
@@ -573,24 +713,44 @@ export function TwReviewWorkspace(inputProps: TwReviewWorkspaceProps) {
573
713
  return;
574
714
  }
575
715
 
576
- const updateViewportWidth = () => {
716
+ const updateViewport = () => {
577
717
  setViewportWidth(readViewportWidth());
718
+ setViewportHeight(readViewportHeight());
578
719
  };
579
720
 
580
- updateViewportWidth();
581
- window.addEventListener("resize", updateViewportWidth);
721
+ updateViewport();
722
+ window.addEventListener("resize", updateViewport);
582
723
  return () => {
583
- window.removeEventListener("resize", updateViewportWidth);
724
+ window.removeEventListener("resize", updateViewport);
584
725
  };
585
726
  }, []);
586
727
 
587
- // Subscribe to layout facet events so chrome re-projects on zoom changes
588
- // and after incremental relayout (R4).
728
+ // Subscribe to layout facet events so chrome re-projects whenever the
729
+ // engine produces new pagination state, fields dirty, or measurement
730
+ // fidelity changes. P5b broadened this beyond the original P3.b set
731
+ // ("zoom_changed" / "render_frame_ready") so the status-bar Page-N-of-M
732
+ // and fidelity badge transition in real time; the hardening commit
733
+ // added "measurement_backend_ready" so the canvas backend swap also
734
+ // refreshes the badge. P14.b adds "layout_committed" — a single
735
+ // coalesced event per applyPatch — so consumers that only care
736
+ // about "the engine just finished a build" can react once instead
737
+ // of N times.
589
738
  useEffect(() => {
590
739
  if (!props.layoutFacet) return;
591
740
  const unsub = props.layoutFacet.subscribe((event) => {
592
- if (event.kind === "zoom_changed" || event.kind === "render_frame_ready") {
593
- setRenderFrameRevision((n) => n + 1);
741
+ switch (event.kind) {
742
+ case "zoom_changed":
743
+ case "render_frame_ready":
744
+ case "layout_recomputed":
745
+ case "incremental_relayout":
746
+ case "page_count_changed":
747
+ case "page_field_dirtied":
748
+ case "measurement_backend_ready":
749
+ case "layout_committed":
750
+ setRenderFrameRevision((n) => n + 1);
751
+ break;
752
+ default:
753
+ break;
594
754
  }
595
755
  });
596
756
  return unsub;
@@ -923,11 +1083,32 @@ export function TwReviewWorkspace(inputProps: TwReviewWorkspaceProps) {
923
1083
  ref={selectionToolbarRootRef}
924
1084
  className={`mx-auto min-h-full w-full ${
925
1085
  isPageWorkspace
926
- ? "wre-page-chrome wre-page-surface relative max-w-[840px] my-8 overflow-hidden"
1086
+ ? "wre-page-chrome wre-page-surface relative my-8 overflow-hidden"
927
1087
  : "wre-canvas-surface relative my-8 overflow-hidden"
928
1088
  }`}
929
1089
  data-zoom-bucket={pageZoomBucket}
930
- style={isPageWorkspace && zoomScale !== 1 ? { transform: `scale(${zoomScale})`, transformOrigin: "top center" } : undefined}
1090
+ data-zoom-scale={isPageWorkspace ? zoomScale : undefined}
1091
+ style={
1092
+ isPageWorkspace
1093
+ ? {
1094
+ // P2.a — real-dim page frame: width/height from
1095
+ // `pageLayout.pageWidth/pageHeight × FRAME_PX_PER_TWIP_AT_96DPI`
1096
+ // so every paper size renders at its
1097
+ // Word-matching CSS px. `max-w-[840px]` retired.
1098
+ ...(pageShellMetrics.frameWidthPx
1099
+ ? { width: `${pageShellMetrics.frameWidthPx}px` }
1100
+ : {}),
1101
+ ...(pageShellMetrics.frameHeightPx
1102
+ ? { minHeight: `${pageShellMetrics.frameHeightPx}px` }
1103
+ : {}),
1104
+ // P2.b — browser-native CSS `zoom` rescales layout
1105
+ // so `getBoundingClientRect()` and hit-test offsets
1106
+ // stay truthful at any zoom — no inverse-projection
1107
+ // math needed downstream.
1108
+ ...(zoomScale !== 1 ? { zoom: zoomScale } : {}),
1109
+ }
1110
+ : undefined
1111
+ }
931
1112
  >
932
1113
  {isPageWorkspace && chromeVisibility.pageChrome && snapshot.pageLayout ? (
933
1114
  <div className="border-b border-border/70 bg-surface/65 px-5 py-3" data-testid="page-context-summary">
@@ -1208,10 +1389,27 @@ export function TwReviewWorkspace(inputProps: TwReviewWorkspaceProps) {
1208
1389
  onSetColumnWidth={props.onSetColumnWidth}
1209
1390
  onSetRowHeight={props.onSetRowHeight}
1210
1391
  activeScopeId={activeScopeId}
1392
+ editorRole={viewState.editorRole}
1393
+ scopeCardScopeTagEditor={props.scopeCardScopeTagEditor}
1211
1394
  onScopeStripeClick={handleScopeStripeClick}
1212
1395
  onScopeCardClose={handleScopeCardClose}
1213
1396
  onScopeCardModeChange={handleScopeCardModeChange}
1214
1397
  onScopeCardIssueAction={handleScopeCardIssueAction}
1398
+ onScopeCardAcceptSuggestionGroup={
1399
+ onScopeAcceptSuggestionGroup
1400
+ ? handleScopeCardAcceptSuggestionGroup
1401
+ : undefined
1402
+ }
1403
+ onScopeCardRejectSuggestionGroup={
1404
+ onScopeRejectSuggestionGroup
1405
+ ? handleScopeCardRejectSuggestionGroup
1406
+ : undefined
1407
+ }
1408
+ onScopeCardAskAgent={
1409
+ onScopeAskAgent
1410
+ ? handleScopeCardAskAgent
1411
+ : undefined
1412
+ }
1215
1413
  />
1216
1414
  ) : null}
1217
1415
  </div>
@@ -1257,6 +1455,9 @@ export function TwReviewWorkspace(inputProps: TwReviewWorkspaceProps) {
1257
1455
  ? props.documentContextAnalytics
1258
1456
  : null
1259
1457
  }
1458
+ displayPageNumber={statusBarPageFacts.displayPageNumber}
1459
+ pageCount={statusBarPageFacts.pageCount}
1460
+ measurementFidelity={statusBarPageFacts.measurementFidelity}
1260
1461
  />
1261
1462
  ) : null}
1262
1463
  </div>
@@ -1364,6 +1565,10 @@ function readViewportWidth(): number | undefined {
1364
1565
  return typeof window === "undefined" ? undefined : window.innerWidth;
1365
1566
  }
1366
1567
 
1568
+ function readViewportHeight(): number | undefined {
1569
+ return typeof window === "undefined" ? undefined : window.innerHeight;
1570
+ }
1571
+
1367
1572
  function shouldHidePageBorderForSelection(
1368
1573
  selection: EditorViewStateSnapshot["selection"],
1369
1574
  ): boolean {
@@ -1496,7 +1701,20 @@ const EMPTY_PAGE_CHROME_MODEL: PageChromeModel = {
1496
1701
 
1497
1702
  const DOCUMENT_CONTENT_TOP_PADDING_PX = 40;
1498
1703
 
1499
- interface PageShellMetrics {
1704
+ // P2.a — real-dimension page frame. Page frame width/height are
1705
+ // `pageLayout.pageWidth/pageHeight × FRAME_PX_PER_TWIP_AT_96DPI` so
1706
+ // every paper size renders at its Word-matching CSS px (Letter
1707
+ // 816×1056, A4 794×1123, Legal 816×1344, …). Constants are exported
1708
+ // so tests + harness panels can derive the same values.
1709
+ export const FRAME_PX_PER_TWIP_AT_96DPI = 96 / 1440;
1710
+ /** Floor on header/footer band heights so empty bands stay clickable. */
1711
+ export const MIN_BAND_HEIGHT_PX = 20;
1712
+
1713
+ export interface PageShellMetrics {
1714
+ /** P2.a — page frame CSS px width = `pageWidth × FRAME_PX_PER_TWIP_AT_96DPI`. */
1715
+ frameWidthPx?: number;
1716
+ /** P2.a — page frame CSS px height = `pageHeight × FRAME_PX_PER_TWIP_AT_96DPI`. */
1717
+ frameHeightPx?: number;
1500
1718
  contentInsetStyle: CSSProperties;
1501
1719
  pageFrameStyle: CSSProperties;
1502
1720
  headerBandStyle: CSSProperties;
@@ -1539,7 +1757,7 @@ function buildPageChromeModel(
1539
1757
  };
1540
1758
  }
1541
1759
 
1542
- function buildPageShellMetrics(
1760
+ export function buildPageShellMetrics(
1543
1761
  pageLayout: RuntimeRenderSnapshot["pageLayout"] | undefined,
1544
1762
  ): PageShellMetrics {
1545
1763
  if (!pageLayout) {
@@ -1548,32 +1766,35 @@ function buildPageShellMetrics(
1548
1766
  pageFrameStyle: {},
1549
1767
  headerBandStyle: {},
1550
1768
  footerBandStyle: {},
1769
+ frameWidthPx: 0,
1770
+ frameHeightPx: 0,
1551
1771
  };
1552
1772
  }
1553
1773
 
1554
- const horizontalInsetPx = Math.max(
1555
- 24,
1556
- Math.min(120, Math.round(pageLayout.marginLeft * DEFAULT_PAGE_ESTIMATE_PX_PER_TWIP)),
1557
- );
1558
- const verticalInsetPx = Math.max(
1559
- 24,
1560
- Math.min(140, Math.round(pageLayout.marginTop * DEFAULT_PAGE_ESTIMATE_PX_PER_TWIP)),
1561
- );
1774
+ // P2.a frame dimensions follow the section's real twip width/height
1775
+ // so every paper size in the catalog renders at its Word-matching CSS
1776
+ // px (Letter 816×1056, A4 794×1123, Legal 816×1344, …).
1777
+ const pxPerTwip = FRAME_PX_PER_TWIP_AT_96DPI;
1778
+ const frameWidthPx = Math.round(pageLayout.pageWidth * pxPerTwip);
1779
+ const frameHeightPx = Math.round(pageLayout.pageHeight * pxPerTwip);
1780
+ const horizontalInsetPx = Math.round(pageLayout.marginLeft * pxPerTwip);
1781
+ const horizontalInsetRightPx = Math.round(pageLayout.marginRight * pxPerTwip);
1782
+ // Header BAND height = margin space NOT consumed by header content.
1783
+ // When marginTop == headerMargin, the band has no headroom — floor to
1784
+ // MIN_BAND_HEIGHT_PX so empty bands stay clickable.
1562
1785
  const headerBandHeightPx = Math.max(
1563
- 40,
1564
- Math.min(96, Math.round(pageLayout.headerMargin * DEFAULT_PAGE_ESTIMATE_PX_PER_TWIP + 16)),
1786
+ MIN_BAND_HEIGHT_PX,
1787
+ Math.round(Math.max(0, pageLayout.marginTop - pageLayout.headerMargin) * pxPerTwip),
1565
1788
  );
1566
1789
  const footerBandHeightPx = Math.max(
1567
- 40,
1568
- Math.min(96, Math.round(pageLayout.footerMargin * DEFAULT_PAGE_ESTIMATE_PX_PER_TWIP + 16)),
1790
+ MIN_BAND_HEIGHT_PX,
1791
+ Math.round(Math.max(0, pageLayout.marginBottom - pageLayout.footerMargin) * pxPerTwip),
1569
1792
  );
1570
1793
 
1571
1794
  return {
1572
1795
  contentInsetStyle: {
1573
1796
  paddingLeft: `${horizontalInsetPx}px`,
1574
- paddingRight: `${horizontalInsetPx}px`,
1575
- paddingTop: `${Math.max(20, verticalInsetPx - 12)}px`,
1576
- paddingBottom: `${Math.max(20, Math.round(pageLayout.marginBottom * DEFAULT_PAGE_ESTIMATE_PX_PER_TWIP) - 12)}px`,
1797
+ paddingRight: `${horizontalInsetRightPx}px`,
1577
1798
  },
1578
1799
  pageFrameStyle: {
1579
1800
  backgroundColor: "var(--color-page-bg)",
@@ -1587,9 +1808,47 @@ function buildPageShellMetrics(
1587
1808
  footerBandStyle: {
1588
1809
  minHeight: `${footerBandHeightPx}px`,
1589
1810
  },
1811
+ frameWidthPx,
1812
+ frameHeightPx,
1590
1813
  };
1591
1814
  }
1592
1815
 
1816
+ // P2.c — fit-to-width / fit-to-page resolves against the active section's
1817
+ // real paper size (not a global constant), so Letter and A4 produce the
1818
+ // expected 1.029:1 fit-width ratio at the same viewport. Clamped so
1819
+ // extreme viewports don't pin the editor at unreadable zooms.
1820
+ const FIT_WIDTH_CHROME_RESERVATION_PX = 96;
1821
+ const FIT_HEIGHT_CHROME_RESERVATION_PX = 180;
1822
+ const MIN_FIT_ZOOM = 0.5;
1823
+ const MAX_FIT_ZOOM = 2.0;
1824
+
1825
+ export function resolveZoomMultiplier(
1826
+ zoomLevel: number | "pageWidth" | "onePage",
1827
+ frameWidthPx: number,
1828
+ frameHeightPx: number,
1829
+ viewportWidth: number | undefined,
1830
+ viewportHeight: number | undefined,
1831
+ ): number {
1832
+ if (typeof zoomLevel === "number") {
1833
+ return zoomLevel / 100;
1834
+ }
1835
+ if (!viewportWidth || frameWidthPx <= 0) return 1;
1836
+ const widthFit =
1837
+ (viewportWidth - FIT_WIDTH_CHROME_RESERVATION_PX) / frameWidthPx;
1838
+ if (zoomLevel === "pageWidth") {
1839
+ return Math.max(MIN_FIT_ZOOM, Math.min(MAX_FIT_ZOOM, widthFit));
1840
+ }
1841
+ if (!viewportHeight || frameHeightPx <= 0) {
1842
+ return Math.max(MIN_FIT_ZOOM, Math.min(MAX_FIT_ZOOM, widthFit));
1843
+ }
1844
+ const heightFit =
1845
+ (viewportHeight - FIT_HEIGHT_CHROME_RESERVATION_PX) / frameHeightPx;
1846
+ return Math.max(
1847
+ MIN_FIT_ZOOM,
1848
+ Math.min(MAX_FIT_ZOOM, Math.min(widthFit, heightFit)),
1849
+ );
1850
+ }
1851
+
1593
1852
  function resolvePageBandLabel(
1594
1853
  region: "header" | "footer",
1595
1854
  activeStory: RuntimeRenderSnapshot["activeStory"],