@beyondwork/docx-react-component 1.0.93 → 1.0.95

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.
@@ -17,6 +17,7 @@ import {
17
17
  projectRectToOverlay,
18
18
  type OverlayCoordinateSpace,
19
19
  } from "./chrome-overlay-projector";
20
+ import { useUiApi } from "../ui-api-context";
20
21
  import type { RenderFrame, RenderFrameRect } from "../../api/public-types.ts";
21
22
  import type { ScopeRailSegment, ScopeRailPosture } from "../../api/public-types.ts";
22
23
  import type { WorkflowFacet } from "../../api/public-types.ts";
@@ -30,22 +31,29 @@ export interface TwScopeRailLayerProps {
30
31
  /**
31
32
  * Geometry facet — used for `getRenderFrame()`. Migrated from
32
33
  * `facet: WordReviewEditorLayoutFacet` in refactor/05 cross-lane-coord
33
- * §8.4 pass. Rail-segment + scope-card data flows through
34
- * `workflowFacet` per Layer-06 Slice 4's seam inversion.
34
+ * §8.4 pass. Mounted rail/card data flows through `api.ui.scope.*`.
35
35
  */
36
36
  geometryFacet: GeometryFacet;
37
37
  /**
38
- * Workflow facet — canonical source of scope rail segments + scope
39
- * card models (`runtime.workflow.*`). Required for the layer to
40
- * render anything; passing `null` makes the layer a no-op.
38
+ * Workflow facet — no-provider fallback for scope rail/card reads.
39
+ * Mounted editor paths prefer `api.ui.scope.*`; passing `null` makes
40
+ * fallback reads no-op.
41
41
  */
42
42
  workflowFacet: WorkflowFacet | null;
43
+ /**
44
+ * Optional pre-read rail snapshot from `ui.scope.rail()`. When omitted,
45
+ * the layer reads the mounted UI API itself, then falls back to the
46
+ * workflow facet for no-provider paths.
47
+ */
48
+ scopeRailSegments?: readonly ScopeRailSegment[];
43
49
  /** Overlay's coordinate space. Defaults to the overlay's own origin. */
44
50
  space?: OverlayCoordinateSpace;
45
51
  /** Horizontal pad (px) the rail gutter occupies to the left of body. */
46
52
  railLaneWidthPx?: number;
47
53
  /** Scope id that should render with the `active` emphasis. */
48
54
  activeScopeId?: string | null;
55
+ /** Posture filters shared with the Workflow rail. Omitted means all scopes render. */
56
+ visibleScopePostures?: ReadonlySet<ScopeRailPosture>;
49
57
  /**
50
58
  * Fires when the user clicks the rail stripe — opens the scope card.
51
59
  * P0 wires this directly; P1 replaces with card-layer-aware routing.
@@ -87,22 +95,32 @@ const POSTURE_STYLES: Record<ScopeRailPosture, PostureStyle> = {
87
95
  // ---------------------------------------------------------------------------
88
96
 
89
97
  const DEFAULT_RAIL_LANE_PX = 44;
90
- const STRIPE_WIDTH_PX = 4;
91
- const LABEL_WIDTH_PX = 40;
98
+ const STRIPE_WIDTH_PX = 6;
99
+ const LABEL_WIDTH_PX = 58;
92
100
  const STACK_OFFSET_PX = 6;
93
101
 
94
102
  export const TwScopeRailLayer: React.FC<TwScopeRailLayerProps> = ({
95
103
  geometryFacet,
96
104
  workflowFacet,
105
+ scopeRailSegments,
97
106
  space,
98
107
  railLaneWidthPx = DEFAULT_RAIL_LANE_PX,
99
108
  activeScopeId,
109
+ visibleScopePostures,
100
110
  onStripeClick,
101
111
  onSegmentClick,
102
112
  "data-testid": testId,
103
113
  }) => {
114
+ const ui = useUiApi();
104
115
  const frame = geometryFacet.getRenderFrame() ?? null;
105
- const segments = workflowFacet?.getAllRailSegments() ?? [];
116
+ const railSegments =
117
+ scopeRailSegments ??
118
+ ui?.scope.rail().segments ??
119
+ workflowFacet?.getAllRailSegments() ??
120
+ [];
121
+ const segments = railSegments.filter((segment) =>
122
+ visibleScopePostures ? visibleScopePostures.has(segment.posture) : true,
123
+ );
106
124
 
107
125
  if (!frame || segments.length === 0) {
108
126
  return null;
@@ -110,15 +128,23 @@ export const TwScopeRailLayer: React.FC<TwScopeRailLayerProps> = ({
110
128
 
111
129
  // P2: which scopes currently have a `source: "ai"` candidate
112
130
  // overlapping — drives the agent-pending shimmer class on their
113
- // tints. Reads from the workflow facet's card-model projection so the
114
- // shimmer logic lives in the Layer-06 runtime, not the overlay.
115
- const cardModels = workflowFacet?.getAllScopeCardModels() ?? [];
131
+ // tints. Mounted surfaces read card projections through ui.scope.card;
132
+ // no-provider paths fall back to the workflow facet.
133
+ const cardModels = ui ? [] : workflowFacet?.getAllScopeCardModels() ?? [];
116
134
  const agentPendingByScope = new Map<string, boolean>();
117
135
  for (const model of cardModels) {
118
136
  if (model.agentPending) {
119
137
  agentPendingByScope.set(model.scopeId, true);
120
138
  }
121
139
  }
140
+ if (ui) {
141
+ for (const segment of segments) {
142
+ const model = ui.scope.card(segment.scopeId);
143
+ if (model?.agentPending) {
144
+ agentPendingByScope.set(segment.scopeId, true);
145
+ }
146
+ }
147
+ }
122
148
 
123
149
  // P3c: stack offsets for overlapping scopes. Two scopes whose
124
150
  // offset ranges intersect on the same page render as stacked
@@ -223,16 +249,21 @@ export const TwScopeRailLayer: React.FC<TwScopeRailLayerProps> = ({
223
249
  style={projectRectToOverlay(stripeRect, projectorSpace)}
224
250
  />
225
251
  {/* Label pill — revealed on stripe hover via CSS. */}
226
- <div
227
- className={`wre-scope-rail-label wre-scope-rail-label-${style.railToken}`}
252
+ <button
253
+ type="button"
254
+ tabIndex={-1}
255
+ className={`wre-scope-rail-label wre-scope-rail-label-${style.railToken} ${
256
+ isActive ? "wre-scope-rail-label-active" : ""
257
+ }`}
228
258
  data-scope-id={segment.scopeId}
229
259
  data-posture={segment.posture}
230
- aria-hidden="true"
260
+ aria-label={`${style.labelText}${segment.label ? `: ${segment.label}` : ""}`}
261
+ onClick={handleActivate}
231
262
  style={projectRectToOverlay(labelRect, projectorSpace)}
232
263
  >
233
264
  <span aria-hidden="true" className={`wre-scope-rail-icon wre-scope-rail-icon-${style.icon}`} />
234
265
  <span className="wre-scope-rail-label-text">{style.labelText}</span>
235
- </div>
266
+ </button>
236
267
  </React.Fragment>
237
268
  );
238
269
  })}
@@ -402,13 +402,7 @@ function buildParagraph(
402
402
  indentRight: paragraphLayout.indentation?.right ?? null,
403
403
  indentFirstLine: paragraphLayout.indentation?.firstLine ?? null,
404
404
  indentHanging: paragraphLayout.indentation?.hanging ?? null,
405
- numberingMarkerWidth:
406
- paragraphLayout.markerLane?.width ??
407
- paragraphLayout.indentation?.hanging ??
408
- (paragraphLayout.indentation?.firstLine !== undefined &&
409
- paragraphLayout.indentation.firstLine < 0
410
- ? Math.abs(paragraphLayout.indentation.firstLine)
411
- : null),
405
+ numberingMarkerWidth: paragraphLayout.markerLane?.width ?? null,
412
406
  numberingMarkerStart: paragraphLayout.markerLane?.start ?? null,
413
407
  numberingMarkerJustification: paragraphLayout.markerJustification ?? null,
414
408
  numberingMarkerRunProperties: block.resolvedNumbering?.markerRunProperties ?? null,
@@ -9,13 +9,13 @@ import {
9
9
  cycleScopeIndex,
10
10
  shouldHandleScopeNavKey,
11
11
  } from "../chrome-overlay/scope-keyboard-cycle";
12
+ import { useUiApi } from "../ui-api-context.tsx";
12
13
 
13
14
  export interface UseScopeCardStateOptions {
14
15
  layoutFacet?: import("../../runtime/layout/index.ts").WordReviewEditorLayoutFacet;
15
16
  /**
16
- * Layer-06 workflow facet — canonical source of scope card models.
17
- * Required by the keyboard-nav loop + Ask-agent handler after Slice 4
18
- * rail-seam inversion removed those methods from `layoutFacet`.
17
+ * Layer-06 workflow facet — no-provider fallback for scope card models.
18
+ * Mounted paths prefer `api.ui.scope.rail/card`.
19
19
  */
20
20
  workflowFacet?: import("../../runtime/workflow/rail/types.ts").WorkflowFacet;
21
21
  onScopeModeChangeRequested?: (payload: {
@@ -79,6 +79,31 @@ export function useScopeCardState(options: UseScopeCardStateOptions): ScopeCardS
79
79
  void layoutFacet;
80
80
 
81
81
  const [activeScopeId, setActiveScopeId] = useState<string | null>(null);
82
+ const ui = useUiApi();
83
+
84
+ const readScopeIds = useCallback((): string[] => {
85
+ if (ui) {
86
+ const ids: string[] = [];
87
+ const seen = new Set<string>();
88
+ for (const segment of ui.scope.rail().segments) {
89
+ if (seen.has(segment.scopeId)) continue;
90
+ seen.add(segment.scopeId);
91
+ ids.push(segment.scopeId);
92
+ }
93
+ return ids;
94
+ }
95
+ return workflowFacet?.getAllScopeCardModels().map((model) => model.scopeId) ?? [];
96
+ }, [ui, workflowFacet]);
97
+
98
+ const readScopeCard = useCallback(
99
+ (scopeId: string) => {
100
+ if (ui) return ui.scope.card(scopeId);
101
+ return workflowFacet
102
+ ?.getAllScopeCardModels()
103
+ .find((m) => m.scopeId === scopeId) ?? null;
104
+ },
105
+ [ui, workflowFacet],
106
+ );
82
107
 
83
108
  const handleScopeStripeClick = useCallback(
84
109
  (segment: { scopeId: string }) => {
@@ -94,14 +119,13 @@ export function useScopeCardState(options: UseScopeCardStateOptions): ScopeCardS
94
119
  }, []);
95
120
 
96
121
  useEffect(() => {
97
- if (!workflowFacet) {
122
+ if (!ui && !workflowFacet) {
98
123
  return undefined;
99
124
  }
100
125
  const onKey = (event: KeyboardEvent) => {
101
126
  if (!shouldHandleScopeNavKey(event)) return;
102
- const models = workflowFacet.getAllScopeCardModels();
103
- if (models.length === 0) return;
104
- const ids = models.map((model) => model.scopeId);
127
+ const ids = readScopeIds();
128
+ if (ids.length === 0) return;
105
129
  const key = event.key.toLowerCase();
106
130
  if (key === "enter") {
107
131
  if (!activeScopeId) {
@@ -117,7 +141,7 @@ export function useScopeCardState(options: UseScopeCardStateOptions): ScopeCardS
117
141
  };
118
142
  window.addEventListener("keydown", onKey);
119
143
  return () => window.removeEventListener("keydown", onKey);
120
- }, [workflowFacet, activeScopeId]);
144
+ }, [ui, workflowFacet, activeScopeId, readScopeIds]);
121
145
 
122
146
  const handleScopeCardModeChange = useCallback(
123
147
  (scopeId: string, mode: WorkflowScopeMode) => {
@@ -149,12 +173,10 @@ export function useScopeCardState(options: UseScopeCardStateOptions): ScopeCardS
149
173
 
150
174
  const handleScopeCardAskAgent = useCallback(
151
175
  (scopeId: string) => {
152
- const cardModel = workflowFacet
153
- ?.getAllScopeCardModels()
154
- .find((m) => m.scopeId === scopeId);
176
+ const cardModel = readScopeCard(scopeId);
155
177
  onScopeAskAgent?.({ scopeId, anchor: cardModel?.anchor });
156
178
  },
157
- [onScopeAskAgent, workflowFacet],
179
+ [onScopeAskAgent, readScopeCard],
158
180
  );
159
181
 
160
182
  return {
@@ -13,8 +13,8 @@
13
13
  * traversal + claim/skip/complete.
14
14
  */
15
15
 
16
- import React, { useState } from "react";
17
- import * as Popover from "@radix-ui/react-popover";
16
+ import React, { useLayoutEffect, useRef, useState, type CSSProperties } from "react";
17
+ import { createPortal } from "react-dom";
18
18
  import * as Toggle from "@radix-ui/react-toggle";
19
19
  import * as Tooltip from "@radix-ui/react-tooltip";
20
20
  import {
@@ -433,38 +433,100 @@ function RoleActionOverflow({
433
433
  props,
434
434
  }: RoleActionOverflowProps): React.JSX.Element {
435
435
  const [open, setOpen] = useState(false);
436
+ const triggerRef = useRef<HTMLButtonElement>(null);
436
437
 
437
438
  return (
438
- <Popover.Root open={open} onOpenChange={setOpen}>
439
- <Popover.Trigger asChild>
439
+ <>
440
440
  <button
441
+ ref={triggerRef}
441
442
  type="button"
442
443
  aria-label="More role actions"
443
444
  aria-expanded={open}
445
+ aria-haspopup="menu"
444
446
  onMouseDown={preserveEditorSelectionMouseDown}
447
+ onClick={(event) => {
448
+ event.preventDefault();
449
+ setOpen((value) => !value);
450
+ }}
445
451
  title="More role actions"
446
- className="inline-flex h-6 w-6 items-center justify-center rounded-md border border-border bg-canvas text-primary transition-colors hover:bg-surface outline-none focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-2 focus-visible:ring-offset-canvas"
452
+ className={`inline-flex h-6 w-6 items-center justify-center rounded-md border border-border bg-canvas text-primary transition-colors hover:bg-surface outline-none focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-2 focus-visible:ring-offset-canvas ${
453
+ open ? "text-accent ring-1 ring-accent/30 shadow-sm" : ""
454
+ }`}
447
455
  data-testid="role-action-overflow-trigger"
448
456
  >
449
457
  <MoreHorizontal className="h-3.5 w-3.5 text-tertiary" aria-hidden="true" />
450
458
  </button>
451
- </Popover.Trigger>
452
- <Popover.Portal>
453
- <Popover.Content
454
- className="z-50 w-[220px] rounded-lg bg-canvas p-1 shadow-lg ring-1 ring-border"
455
- sideOffset={8}
456
- align="start"
457
- data-testid="role-action-overflow-content"
458
- >
459
+ <RoleActionPortalMenu anchorRef={triggerRef} open={open}>
459
460
  {ids.map((id) => (
460
461
  <OverflowAction key={id} id={id} props={props} onClose={() => setOpen(false)} />
461
462
  ))}
462
- </Popover.Content>
463
- </Popover.Portal>
464
- </Popover.Root>
463
+ </RoleActionPortalMenu>
464
+ </>
465
+ );
466
+ }
467
+
468
+ function RoleActionPortalMenu(props: {
469
+ anchorRef: React.RefObject<HTMLButtonElement | null>;
470
+ children: React.ReactNode;
471
+ open: boolean;
472
+ }): React.ReactPortal | null {
473
+ const style = useRoleActionPortalPosition(props.anchorRef, props.open);
474
+ const body = props.anchorRef.current?.ownerDocument?.body;
475
+ if (!props.open || !body) return null;
476
+ return createPortal(
477
+ <div
478
+ className="z-50 w-[220px] rounded-lg bg-canvas p-1 shadow-lg ring-1 ring-border"
479
+ data-testid="role-action-overflow-content"
480
+ style={style}
481
+ >
482
+ {props.children}
483
+ </div>,
484
+ body,
465
485
  );
466
486
  }
467
487
 
488
+ function useRoleActionPortalPosition(
489
+ anchorRef: React.RefObject<HTMLButtonElement | null>,
490
+ open: boolean,
491
+ ): CSSProperties {
492
+ const [style, setStyle] = useState<CSSProperties>({
493
+ left: 8,
494
+ position: "fixed",
495
+ top: 8,
496
+ zIndex: 50,
497
+ });
498
+
499
+ useLayoutEffect(() => {
500
+ if (!open) return;
501
+ const anchor = anchorRef.current;
502
+ const ownerWindow = anchor?.ownerDocument?.defaultView;
503
+ if (!anchor || !ownerWindow) return;
504
+ const update = () => {
505
+ const rect = anchor.getBoundingClientRect();
506
+ const width = 220;
507
+ const left = Math.min(
508
+ Math.max(8, rect.left),
509
+ Math.max(8, (ownerWindow.innerWidth || width + 16) - width - 8),
510
+ );
511
+ setStyle({
512
+ left,
513
+ position: "fixed",
514
+ top: Math.max(8, rect.bottom + 8),
515
+ zIndex: 50,
516
+ });
517
+ };
518
+ update();
519
+ ownerWindow.addEventListener("resize", update);
520
+ ownerWindow.addEventListener("scroll", update, true);
521
+ return () => {
522
+ ownerWindow.removeEventListener("resize", update);
523
+ ownerWindow.removeEventListener("scroll", update, true);
524
+ };
525
+ }, [anchorRef, open]);
526
+
527
+ return style;
528
+ }
529
+
468
530
  function OverflowAction(arg: {
469
531
  id: ToolbarChromeItemId;
470
532
  props: TwRoleActionRegionProps;
@@ -27,6 +27,8 @@ export interface TwToolbarIconButtonProps {
27
27
  * vs. Windows however they like.
28
28
  */
29
29
  shortcut?: string;
30
+ /** Explanation surfaced when the button is disabled by selection/mode state. */
31
+ disabledReason?: string;
30
32
  onClick?: () => void;
31
33
  }
32
34
 
@@ -42,7 +44,9 @@ export function TwToolbarIconButton(props: TwToolbarIconButtonProps) {
42
44
  aria-label={props.label}
43
45
  aria-pressed={props.active ?? undefined}
44
46
  data-active={props.active ? "true" : undefined}
47
+ data-disabled-reason={props.disabled && props.disabledReason ? props.disabledReason : undefined}
45
48
  disabled={props.disabled}
49
+ title={props.disabled && props.disabledReason ? `Not available: ${props.disabledReason}` : undefined}
46
50
  onMouseDown={preserveEditorSelectionMouseDown}
47
51
  onClick={props.onClick}
48
52
  className={[