@beyondwork/docx-react-component 1.0.81 → 1.0.82

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.
@@ -12,9 +12,10 @@
12
12
  *
13
13
  * Progressive-disclosure discipline (designsystem.md §2.1 principle 4):
14
14
  * - Actions without a wired callback are hidden, not rendered
15
- * disabled. The palette is the escape valve for low-frequency
16
- * actions; an empty surface is better than a wall of disabled
17
- * rows.
15
+ * disabled unless the registry marks them as important signposts
16
+ * by returning `"disabled"` from `when()`. Those rows stay visible
17
+ * with an explanation so users can see that the product recognizes
18
+ * the workflow even when the host has not wired it yet.
18
19
  * - Mode-filtered actions (e.g. `scope-open-card` is
19
20
  * workflow/review-only) only appear when the composition mode
20
21
  * allows them.
@@ -33,7 +34,6 @@ import type {
33
34
  CommandPaletteGroup,
34
35
  CommandPaletteItem,
35
36
  } from "./tw-command-palette";
36
- import type { ContextMenuGroupId } from "./tw-context-menu";
37
37
  import type { ShortcutKey } from "./tw-shortcut-hint";
38
38
 
39
39
  /**
@@ -90,43 +90,63 @@ export function formatShortcutForPalette(
90
90
  }
91
91
 
92
92
  /**
93
- * Human-friendly group labels. Kept here because the palette renders
94
- * section headings and the registry's group ids are terse internal
95
- * identifiers.
93
+ * Product-facing command palette buckets. These are intentionally not
94
+ * the context-menu `ContextMenuGroupId`s: right-click menus group by
95
+ * local action family, while the palette groups by user intent.
96
96
  */
97
- const GROUP_LABELS: Record<ContextMenuGroupId, string> = {
98
- clipboard: "Clipboard",
99
- suggestion: "Review",
100
- comment: "Comments",
101
- table: "Table",
102
- formatting: "Formatting",
103
- misc: "More",
97
+ type ProductPaletteGroupId =
98
+ | "commands"
99
+ | "search"
100
+ | "navigation"
101
+ | "mode"
102
+ | "diagnostics";
103
+
104
+ const GROUP_LABELS: Record<ProductPaletteGroupId, string> = {
105
+ commands: "Commands",
106
+ search: "Search",
107
+ navigation: "Navigation",
108
+ mode: "Mode",
109
+ diagnostics: "Diagnostics",
104
110
  };
105
111
 
106
112
  /**
107
- * Stable group order matches the progressive-disclosure priority in
108
- * designsystem.md §2.1 (clipboard first because it's always available,
109
- * review + comment next because they're the primary legal-review
110
- * actions, table + formatting after, misc last).
113
+ * Stable product group order per editor-product Slice 3.
111
114
  */
112
- const GROUP_ORDER: readonly ContextMenuGroupId[] = [
113
- "clipboard",
114
- "suggestion",
115
- "comment",
116
- "table",
117
- "formatting",
118
- "misc",
115
+ const GROUP_ORDER: readonly ProductPaletteGroupId[] = [
116
+ "commands",
117
+ "search",
118
+ "navigation",
119
+ "mode",
120
+ "diagnostics",
119
121
  ];
120
122
 
121
- function isActionAvailable(
123
+ function actionAvailability(
122
124
  action: EditorAction,
123
125
  ctx: EditorActionDispatchContext,
124
- ): boolean {
126
+ ): false | "enabled" | "disabled" {
125
127
  const verdict = action.when ? action.when(ctx) : true;
126
- // "disabled" actions are hidden from the palette. The palette is a
127
- // search-first surface; surfacing disabled rows dilutes the query
128
- // space and clutters the empty state.
129
- return verdict === true;
128
+ if (verdict === false) return false;
129
+ return verdict === "disabled" ? "disabled" : "enabled";
130
+ }
131
+
132
+ function paletteGroupForAction(action: EditorAction): ProductPaletteGroupId {
133
+ switch (action.id) {
134
+ case "find":
135
+ case "replace":
136
+ return "search";
137
+ case "go-to":
138
+ case "jump-to-comment-in-rail":
139
+ case "scope-jump-in-rail":
140
+ case "scope-open-card":
141
+ return "navigation";
142
+ case "scope-mark-resolved":
143
+ return "mode";
144
+ case "object-info":
145
+ case "print":
146
+ return "diagnostics";
147
+ default:
148
+ return "commands";
149
+ }
130
150
  }
131
151
 
132
152
  export interface BuildPaletteGroupsInput {
@@ -145,15 +165,18 @@ export function buildPaletteGroupsFromRegistry(
145
165
  dismiss: input.dismiss,
146
166
  };
147
167
 
148
- const byGroup = new Map<ContextMenuGroupId, CommandPaletteItem[]>();
168
+ const byGroup = new Map<ProductPaletteGroupId, CommandPaletteItem[]>();
149
169
 
150
170
  for (const action of EDITOR_ACTION_REGISTRY) {
151
171
  if (action.modes && !action.modes.has(input.mode)) continue;
152
- if (!isActionAvailable(action, ctx)) continue;
172
+ const availability = actionAvailability(action, ctx);
173
+ if (availability === false) continue;
153
174
 
154
175
  const item: CommandPaletteItem = {
155
176
  id: action.id,
156
177
  label: action.label,
178
+ ...(action.description ? { description: action.description } : {}),
179
+ ...(availability === "disabled" ? { disabled: true } : {}),
157
180
  // Chrome Closure Pass · Task 4 (designsystem.md §6.25) —
158
181
  // preserve the registry shortcut so the palette row shows
159
182
  // the same hint as the matching context-menu / tooltip.
@@ -163,9 +186,10 @@ export function buildPaletteGroupsFromRegistry(
163
186
  onInvoke: () => action.run(ctx),
164
187
  };
165
188
 
166
- const bucket = byGroup.get(action.group) ?? [];
189
+ const groupId = paletteGroupForAction(action);
190
+ const bucket = byGroup.get(groupId) ?? [];
167
191
  bucket.push(item);
168
- byGroup.set(action.group, bucket);
192
+ byGroup.set(groupId, bucket);
169
193
  }
170
194
 
171
195
  const groups: CommandPaletteGroup[] = [];
@@ -251,6 +251,7 @@ export const TwChromeOverlay: React.FC<TwChromeOverlayProps> = ({
251
251
  {pageStackScrollRoot !== undefined ? (
252
252
  <TwPageStackChromeLayer
253
253
  facet={facet}
254
+ geometryFacet={geometryFacet}
254
255
  scrollRoot={pageStackScrollRoot}
255
256
  renderFrameRevision={renderFrameRevision ?? 0}
256
257
  activeStory={activeStory ?? { kind: "main" }}
@@ -94,6 +94,99 @@ export interface VisiblePageIndexRange {
94
94
  end: number;
95
95
  }
96
96
 
97
+ function pageOverlayRectsEqual(
98
+ a: readonly PageOverlayRect[],
99
+ b: readonly PageOverlayRect[],
100
+ ): boolean {
101
+ if (a === b) return true;
102
+ if (a.length !== b.length) return false;
103
+ for (let i = 0; i < a.length; i += 1) {
104
+ const left = a[i]!;
105
+ const right = b[i]!;
106
+ if (
107
+ left.pageId !== right.pageId ||
108
+ left.pageIndex !== right.pageIndex ||
109
+ left.topPx !== right.topPx ||
110
+ left.bottomPx !== right.bottomPx ||
111
+ left.heightPx !== right.heightPx
112
+ ) {
113
+ return false;
114
+ }
115
+ }
116
+ return true;
117
+ }
118
+
119
+ function pageOverlayLastBottom(rects: readonly PageOverlayRect[]): number {
120
+ let bottom = 0;
121
+ for (const rect of rects) {
122
+ if (rect.bottomPx > bottom) bottom = rect.bottomPx;
123
+ }
124
+ return bottom;
125
+ }
126
+
127
+ /**
128
+ * Page mode currently keeps the editable ProseMirror body as one natural DOM
129
+ * flow and paints page cards behind it. Layout geometry gives us the intended
130
+ * page frames, but browser-rendered PM content can exceed the final geometry
131
+ * frame (wide tables, fallback font metrics, or deliberately-degraded
132
+ * over-tall blocks). The decorative paper must follow that visible flow so
133
+ * text never appears directly on the workspace canvas.
134
+ */
135
+ export function extendFinalPageOverlayRectToFlowHeight(
136
+ rects: readonly PageOverlayRect[],
137
+ flowHeightPx: number,
138
+ ): readonly PageOverlayRect[] {
139
+ if (rects.length === 0 || !Number.isFinite(flowHeightPx)) return rects;
140
+ const last = rects[rects.length - 1]!;
141
+ if (flowHeightPx <= last.bottomPx + 1) return rects;
142
+ return [
143
+ ...rects.slice(0, -1),
144
+ {
145
+ ...last,
146
+ bottomPx: flowHeightPx,
147
+ heightPx: Math.max(0, flowHeightPx - last.topPx),
148
+ },
149
+ ];
150
+ }
151
+
152
+ export function extendPageOverlayRectsAcrossTableBoundaryGaps(
153
+ rects: readonly PageOverlayRect[],
154
+ tableBoundaryIndices: readonly number[],
155
+ ): readonly PageOverlayRect[] {
156
+ if (rects.length === 0 || tableBoundaryIndices.length === 0) return rects;
157
+ const tableBoundaries = new Set(tableBoundaryIndices);
158
+ const byPageIndex = new Map<number, PageOverlayRect>();
159
+ for (const rect of rects) {
160
+ byPageIndex.set(rect.pageIndex, rect);
161
+ }
162
+
163
+ let changed = false;
164
+ const bridged = rects.map((rect) => {
165
+ if (!tableBoundaries.has(rect.pageIndex)) return rect;
166
+ const next = byPageIndex.get(rect.pageIndex + 1);
167
+ if (!next || next.topPx <= rect.bottomPx + 1) return rect;
168
+ changed = true;
169
+ return {
170
+ ...rect,
171
+ bottomPx: next.topPx,
172
+ heightPx: Math.max(0, next.topPx - rect.topPx),
173
+ };
174
+ });
175
+ return changed ? bridged : rects;
176
+ }
177
+
178
+ function mergePageOverlayRectsByPageIndex(
179
+ baseRects: readonly PageOverlayRect[],
180
+ flowRects: readonly PageOverlayRect[],
181
+ ): readonly PageOverlayRect[] {
182
+ if (baseRects.length === 0 || flowRects.length === 0) return baseRects;
183
+ const flowByIndex = new Map<number, PageOverlayRect>();
184
+ for (const rect of flowRects) {
185
+ flowByIndex.set(rect.pageIndex, rect);
186
+ }
187
+ return baseRects.map((rect) => flowByIndex.get(rect.pageIndex) ?? rect);
188
+ }
189
+
97
190
  function normalizeVisiblePageIndexRange(
98
191
  range: VisiblePageIndexRange | null | undefined,
99
192
  pageCount: number,
@@ -126,6 +219,26 @@ function parsePageBoundaryIndex(prevPageId: string): number | undefined {
126
219
  return Number.parseInt(match[1] ?? "", 10);
127
220
  }
128
221
 
222
+ function collectTableEmbeddedBoundaryIndices(
223
+ queryRoot: Pick<HTMLElement, "querySelectorAll"> | null,
224
+ ): number[] {
225
+ if (!queryRoot) return [];
226
+ const indices: number[] = [];
227
+ const widgets = Array.from(
228
+ queryRoot.querySelectorAll<HTMLElement>("[data-page-frame-end]"),
229
+ );
230
+ for (const widget of widgets) {
231
+ const prevPageId = widget.getAttribute("data-page-frame-end");
232
+ if (!prevPageId) continue;
233
+ const boundaryIndex = parsePageBoundaryIndex(prevPageId);
234
+ if (boundaryIndex === undefined) continue;
235
+ if (widget.closest("[data-pm-table-root='true'], table")) {
236
+ indices.push(boundaryIndex);
237
+ }
238
+ }
239
+ return indices;
240
+ }
241
+
129
242
  /**
130
243
  * Pure helper: turn pre-measured page-boundary widget positions into
131
244
  * one `PageOverlayRect` per page. No DOM access — the caller supplies
@@ -407,6 +520,39 @@ function resolveOffsetHeight(widget: HTMLElement): number {
407
520
  return widget.offsetHeight ?? 0;
408
521
  }
409
522
 
523
+ function readElementFlowHeight(
524
+ element: HTMLElement | null,
525
+ options: { includeScrollHeight?: boolean } = {},
526
+ ): number {
527
+ if (!element) return 0;
528
+ let height = 0;
529
+ // geometry:allow-dom-fallback
530
+ height = Math.max(height, element.clientHeight || 0);
531
+ if (options.includeScrollHeight !== false) {
532
+ // geometry:allow-dom-fallback
533
+ height = Math.max(height, element.scrollHeight || 0);
534
+ }
535
+ if (height <= 0) {
536
+ // geometry:allow-dom-fallback
537
+ const rect = element.getBoundingClientRect();
538
+ height = Math.max(height, rect.height || 0);
539
+ }
540
+ return height;
541
+ }
542
+
543
+ function readOverlayFlowHeight(origin: HTMLElement | null): number {
544
+ if (!origin) return 0;
545
+ const parent = origin.parentElement instanceof HTMLElement
546
+ ? origin.parentElement
547
+ : null;
548
+ const parentHeight = readElementFlowHeight(parent);
549
+ if (parentHeight > 0) return parentHeight;
550
+ // Fallback only: do not read origin.scrollHeight here. The overlay's own
551
+ // absolutely-positioned paper cards can otherwise preserve a stale extended
552
+ // height after editable content shrinks.
553
+ return readElementFlowHeight(origin, { includeScrollHeight: false });
554
+ }
555
+
410
556
  // ---------------------------------------------------------------------------
411
557
  // Component
412
558
  // ---------------------------------------------------------------------------
@@ -438,6 +584,10 @@ export interface TwPageStackOverlayLayerProps {
438
584
  * time this changes so the overlays stay aligned with content.
439
585
  */
440
586
  renderFrameRevision: number;
587
+ /**
588
+ * Kept for call-site compatibility. Paper-card backgrounds intentionally
589
+ * render every page; viewport culling belongs to heavier chrome layers.
590
+ */
441
591
  visiblePageIndexRange?: VisiblePageIndexRange | null;
442
592
  /** Optional test id applied to the overlay root. */
443
593
  "data-testid"?: string;
@@ -524,7 +674,6 @@ export const TwPageStackOverlayLayer: React.FC<TwPageStackOverlayLayerProps> = (
524
674
  geometryFacet,
525
675
  scrollRoot,
526
676
  renderFrameRevision,
527
- visiblePageIndexRange,
528
677
  "data-testid": testId,
529
678
  }) => {
530
679
  // DS-C2 — prefer `ui.overlays.getAnchor({ kind: "page", ... })` as
@@ -542,12 +691,21 @@ export const TwPageStackOverlayLayer: React.FC<TwPageStackOverlayLayerProps> = (
542
691
  if (!geometryFacet) return [];
543
692
  const pageCount = facet.getPageCount();
544
693
  if (pageCount <= 0) return [];
694
+ // Paper-card backgrounds are cheap and must stay ahead of scroll. Heavy
695
+ // header/footer/footnote chrome still consumes `visiblePageIndexRange`;
696
+ // this decorative white-paper layer renders every card so fast scrolls
697
+ // never expose the gray canvas while the page window catches up.
545
698
  const warm = resolvePageOverlayRectsFromGeometry(
546
699
  geometryFacet,
547
700
  pageCount,
548
- visiblePageIndexRange,
701
+ null,
549
702
  );
550
- return warm ?? [];
703
+ return warm
704
+ ? extendPageOverlayRectsAcrossTableBoundaryGaps(
705
+ warm,
706
+ collectTableEmbeddedBoundaryIndices(scrollRoot),
707
+ )
708
+ : [];
551
709
  });
552
710
  // P3.d fix: the overlay root acts as the **measurement origin** so
553
711
  // widget `topPx` / `bottomPx` are expressed in the exact coordinate
@@ -576,12 +734,60 @@ export const TwPageStackOverlayLayer: React.FC<TwPageStackOverlayLayerProps> = (
576
734
  // tears down.
577
735
  const rafHandleRef = React.useRef<number | null>(null);
578
736
 
737
+ const setRectsIfChanged = React.useCallback(
738
+ (next: readonly PageOverlayRect[]) => {
739
+ setRects((prev) => (pageOverlayRectsEqual(prev, next) ? prev : next));
740
+ },
741
+ [],
742
+ );
743
+
744
+ const reconcilePaperRectsWithFlow = React.useCallback(
745
+ (
746
+ baseRects: readonly PageOverlayRect[],
747
+ pageCount: number,
748
+ ): readonly PageOverlayRect[] => {
749
+ if (baseRects.length === 0 || pageCount <= 0) return baseRects;
750
+ const origin = overlayRootRef.current;
751
+ const flowHeight = readOverlayFlowHeight(origin);
752
+ const bridgedBase = extendPageOverlayRectsAcrossTableBoundaryGaps(
753
+ baseRects,
754
+ collectTableEmbeddedBoundaryIndices(scrollRoot),
755
+ );
756
+ if (flowHeight <= 0) return bridgedBase;
757
+
758
+ const extendedBase = extendFinalPageOverlayRectToFlowHeight(
759
+ bridgedBase,
760
+ flowHeight,
761
+ );
762
+ if (!origin || !scrollRoot) return extendedBase;
763
+
764
+ // The common warm path has geometry and DOM flow in agreement. Avoid the
765
+ // full boundary-widget scan unless the PM flow is visibly taller than the
766
+ // geometry stack. When it is taller, the paper-card layer must follow the
767
+ // in-flow boundaries so content remains on paper instead of canvas.
768
+ if (flowHeight <= pageOverlayLastBottom(bridgedBase) + 1) {
769
+ return extendedBase;
770
+ }
771
+
772
+ const widgets = measureWidgetsViaBoundingRect(scrollRoot, origin, {
773
+ pageCount,
774
+ visiblePageIndexRange: null,
775
+ });
776
+ if (widgets.length === 0) return extendedBase;
777
+
778
+ const flowRects = resolvePageOverlayRects({
779
+ widgets,
780
+ pageCount,
781
+ scrollHeight: flowHeight,
782
+ visiblePageIndexRange: null,
783
+ });
784
+ const merged = mergePageOverlayRectsByPageIndex(extendedBase, flowRects);
785
+ return extendFinalPageOverlayRectToFlowHeight(merged, flowHeight);
786
+ },
787
+ [scrollRoot],
788
+ );
789
+
579
790
  const refreshRectsNow = React.useCallback(() => {
580
- if (!scrollRoot) {
581
- setRects([]);
582
- return;
583
- }
584
- const origin = overlayRootRef.current;
585
791
  const pageCount = facet.getPageCount();
586
792
 
587
793
  // DS-C2 — first try the UI API seam so presentation code does not
@@ -608,12 +814,12 @@ export const TwPageStackOverlayLayer: React.FC<TwPageStackOverlayLayerProps> = (
608
814
  const uiRects = resolvePageOverlayRectsFromUiApi(
609
815
  ui,
610
816
  pageCount,
611
- visiblePageIndexRange,
817
+ null,
612
818
  pageIds,
613
819
  );
614
820
  if (uiRects !== null) {
615
821
  incrementInvalidationCounter("overlay.page.ui_api.hit");
616
- setRects(uiRects);
822
+ setRectsIfChanged(reconcilePaperRectsWithFlow(uiRects, pageCount));
617
823
  return;
618
824
  }
619
825
  incrementInvalidationCounter("overlay.page.ui_api.fallthrough");
@@ -630,54 +836,65 @@ export const TwPageStackOverlayLayer: React.FC<TwPageStackOverlayLayerProps> = (
630
836
  const geometryRects = resolvePageOverlayRectsFromGeometry(
631
837
  geometryFacet,
632
838
  pageCount,
633
- visiblePageIndexRange,
839
+ null,
634
840
  );
635
841
  if (geometryRects !== null) {
636
- setRects(geometryRects);
842
+ setRectsIfChanged(reconcilePaperRectsWithFlow(geometryRects, pageCount));
637
843
  return;
638
844
  }
639
845
  }
640
846
 
847
+ if (!scrollRoot) {
848
+ setRectsIfChanged([]);
849
+ return;
850
+ }
851
+ const origin = overlayRootRef.current;
852
+
641
853
  // Cold-open / pre-paint DOM fallback — warm path early-returned
642
854
  // above via `geometryFacet` or the UI-API resolver. Lines below
643
855
  // fire only before the first render frame.
644
856
  if (origin) {
645
857
  const widgets = measureWidgetsViaBoundingRect(scrollRoot, origin, {
646
858
  pageCount,
647
- visiblePageIndexRange,
859
+ visiblePageIndexRange: null,
648
860
  });
649
861
  // geometry:allow-dom-fallback
650
862
  const originRect = origin.getBoundingClientRect();
651
- setRects(
652
- resolvePageOverlayRects({
653
- widgets,
654
- pageCount,
655
- scrollHeight:
656
- // geometry:allow-dom-fallback
657
- origin.clientHeight > 0 ? origin.clientHeight : originRect.height,
658
- visiblePageIndexRange,
659
- }),
660
- );
863
+ const domRects = resolvePageOverlayRects({
864
+ widgets,
865
+ pageCount,
866
+ scrollHeight:
867
+ // geometry:allow-dom-fallback
868
+ origin.clientHeight > 0 ? origin.clientHeight : originRect.height,
869
+ visiblePageIndexRange: null,
870
+ });
871
+ setRectsIfChanged(reconcilePaperRectsWithFlow(domRects, pageCount));
661
872
  } else {
662
873
  const widgets = measureWidgetsViaOffsetChain(scrollRoot, {
663
874
  pageCount,
664
- visiblePageIndexRange,
875
+ visiblePageIndexRange: null,
665
876
  });
666
- setRects(
667
- resolvePageOverlayRects({
668
- widgets,
669
- pageCount,
670
- // geometry:allow-dom-fallback
671
- scrollHeight: scrollRoot.clientHeight,
672
- visiblePageIndexRange,
673
- }),
674
- );
877
+ const domRects = resolvePageOverlayRects({
878
+ widgets,
879
+ pageCount,
880
+ // geometry:allow-dom-fallback
881
+ scrollHeight: scrollRoot.clientHeight,
882
+ visiblePageIndexRange: null,
883
+ });
884
+ setRectsIfChanged(reconcilePaperRectsWithFlow(domRects, pageCount));
675
885
  }
676
- }, [facet, geometryFacet, scrollRoot, ui, visiblePageIndexRange]);
886
+ }, [
887
+ facet,
888
+ geometryFacet,
889
+ reconcilePaperRectsWithFlow,
890
+ scrollRoot,
891
+ setRectsIfChanged,
892
+ ui,
893
+ ]);
677
894
 
678
895
  const refreshRects = React.useCallback(() => {
679
896
  if (!scrollRoot) {
680
- setRects([]);
897
+ refreshRectsNow();
681
898
  return;
682
899
  }
683
900
  const runtime = scrollRoot.ownerDocument?.defaultView as
@@ -724,6 +941,7 @@ export const TwPageStackOverlayLayer: React.FC<TwPageStackOverlayLayerProps> = (
724
941
  // Observe scroll-root size changes so zoom, viewport resize, or font
725
942
  // loading re-trigger measurement without a full app re-render.
726
943
  React.useEffect(() => {
944
+ if (geometryFacet) return;
727
945
  if (!scrollRoot) return;
728
946
  const runtime = scrollRoot.ownerDocument?.defaultView as
729
947
  | (Window & { ResizeObserver?: typeof ResizeObserver })
@@ -732,7 +950,7 @@ export const TwPageStackOverlayLayer: React.FC<TwPageStackOverlayLayerProps> = (
732
950
  const observer = new runtime.ResizeObserver(() => refreshRects());
733
951
  observer.observe(scrollRoot);
734
952
  return () => observer.disconnect();
735
- }, [scrollRoot, refreshRects]);
953
+ }, [geometryFacet, scrollRoot, refreshRects]);
736
954
 
737
955
  // Observe DOM mutations so PM re-renders (page-break widgets added /
738
956
  // removed on relayout) re-trigger measurement. We filter to
@@ -750,6 +968,7 @@ export const TwPageStackOverlayLayer: React.FC<TwPageStackOverlayLayerProps> = (
750
968
  // times within one animation frame coalesces into one measurement
751
969
  // pass instead of a per-mutation re-render storm.
752
970
  React.useEffect(() => {
971
+ if (geometryFacet) return;
753
972
  if (!scrollRoot) return;
754
973
  const runtime = scrollRoot.ownerDocument?.defaultView as
755
974
  | (Window & { MutationObserver?: typeof MutationObserver })
@@ -775,7 +994,7 @@ export const TwPageStackOverlayLayer: React.FC<TwPageStackOverlayLayerProps> = (
775
994
  // actually matters for the overlay's rect math.
776
995
  observer.observe(scrollRoot, { childList: true, subtree: false });
777
996
  return () => observer.disconnect();
778
- }, [scrollRoot, refreshRects]);
997
+ }, [geometryFacet, scrollRoot, refreshRects]);
779
998
 
780
999
  // Always render the overlay root so the ref resolves on first paint.
781
1000
  // Without the root element we never get a `getBoundingClientRect()`
@@ -279,6 +279,28 @@ export const editorSchema = new Schema({
279
279
  const indentHanging = node.attrs.indentHanging as number | null;
280
280
  if (indentHanging) styles.push(`text-indent: -${indentHanging / 20}pt`);
281
281
  else if (indentFirstLine) styles.push(`text-indent: ${indentFirstLine / 20}pt`);
282
+ // Numbering marker-lane outdent (coord-04 §1.21 — "legal numbering
283
+ // touches the left margin / clips into page chrome"). Word/LO model:
284
+ // a numbered paragraph occupies `[markerLane.start, textColumn.right]`;
285
+ // the marker lane lives at `[textStart - hanging, textStart]`. When
286
+ // `hanging > indentLeft` the marker starts at negative x relative to
287
+ // the paragraph's content-frame origin — a legitimate Word scenario
288
+ // (w:ind w:left="360" w:hanging="720"). The marker span's
289
+ // `margin-left: -markerWidth` (emitted below) depends on the
290
+ // paragraph's content box actually reaching that far left; without
291
+ // this outdent the marker slides off the page frame and clips into
292
+ // surrounding chrome. `margin-left` expands the paragraph's content
293
+ // box leftward while keeping `padding-left` (body-text alignment)
294
+ // untouched.
295
+ const numberingMarkerStartTwips = node.attrs.numberingMarkerStart as
296
+ | number
297
+ | null;
298
+ if (
299
+ typeof numberingMarkerStartTwips === "number" &&
300
+ numberingMarkerStartTwips < 0
301
+ ) {
302
+ styles.push(`margin-left: ${numberingMarkerStartTwips / 20}pt`);
303
+ }
282
304
  const shadingColor = safeHexColor(node.attrs.shadingFill as string | null);
283
305
  if (shadingColor) styles.push(`background-color: ${shadingColor}`);
284
306
  // Paragraph borders. Reads the PublicBorderSpec shape
@@ -378,6 +400,27 @@ export const editorSchema = new Schema({
378
400
  if (contextualSpacingAfter) {
379
401
  attrs["data-contextual-spacing-after"] = "true";
380
402
  }
403
+ // Numbering geometry debug visibility (coord-04 §1.21 Deliverable D):
404
+ // expose the resolved marker lane on the paragraph element so parity
405
+ // investigators can inspect truth-vs-render without re-deriving the
406
+ // geometry. Values are twips; consumers convert at read time.
407
+ if (typeof numberingMarkerStartTwips === "number") {
408
+ attrs["data-numbering-marker-start-twips"] = String(numberingMarkerStartTwips);
409
+ }
410
+ const _numberingMarkerWidthTwips = node.attrs.numberingMarkerWidth as
411
+ | number
412
+ | null;
413
+ if (
414
+ typeof _numberingMarkerWidthTwips === "number" &&
415
+ _numberingMarkerWidthTwips > 0
416
+ ) {
417
+ attrs["data-numbering-marker-width-twips"] = String(
418
+ _numberingMarkerWidthTwips,
419
+ );
420
+ }
421
+ if (typeof indentLeft === "number") {
422
+ attrs["data-numbering-body-start-twips"] = String(indentLeft);
423
+ }
381
424
  if (styles.length > 0) attrs.style = styles.join("; ");
382
425
  const numberingPrefix = node.attrs.numberingPrefix as string | null;
383
426
  const numberingLevel = node.attrs.numberingLevel as number | null;
@@ -453,6 +496,17 @@ export const editorSchema = new Schema({
453
496
  if (hasResolvedMarkerWidth) {
454
497
  // P13.a: emit marker geometry in pt (twips/20 == pt) so it
455
498
  // self-scales under CSS `zoom` and matches Word's intent.
499
+ //
500
+ // Marker-lane contract (coord-04 §1.21 — Word/LO model):
501
+ // the marker occupies `[markerStart, markerStart + markerWidth]`
502
+ // where `markerStart = textStart - hanging`. The negative
503
+ // `margin-left` pulls the marker leftward by its own width into
504
+ // the paragraph's padding zone (`textStart - markerWidth`, since
505
+ // `padding-left = textStart`). When `markerStart < 0` the
506
+ // paragraph itself carries a compensating negative `margin-left`
507
+ // (emitted above at the paragraph-style branch) so its content
508
+ // box reaches far enough left to hold the marker without
509
+ // clipping into page chrome.
456
510
  const markerWidthPt = Math.max(1, numberingMarkerWidth / 20);
457
511
  prefixStyles.push(
458
512
  `width: ${markerWidthPt}pt`,
@@ -462,7 +516,6 @@ export const editorSchema = new Schema({
462
516
  `margin-right: 0`,
463
517
  `overflow: visible`,
464
518
  );
465
- void numberingMarkerStart; // consumed via paragraph padding-left geometry
466
519
  } else {
467
520
  prefixStyles.push(
468
521
  `min-width: ${fallbackMinWidth}ch`,