@beyondwork/docx-react-component 1.0.39 → 1.0.40

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.
@@ -0,0 +1,386 @@
1
+ /**
2
+ * TwScopeCard — inline floating card shown above a scoped region when
3
+ * the user activates the rail stripe. Displays scope label + mode
4
+ * selector + (when an `IssueMetadataValue` is attached via
5
+ * `ScopeCardModel.issue`) the R2 issue severity, owner, title, and
6
+ * resolve/waive/escalate actions.
7
+ *
8
+ * Per docs/plans/scope-card-overlay.md P1, the card never mutates
9
+ * runtime state directly — it fires `onModeChange` / `onIssueAction`
10
+ * callbacks that bubble up to `scope-mode-change-requested` /
11
+ * `scope-issue-action-requested` events on WordReviewEditorEvent.
12
+ *
13
+ * A11y contract:
14
+ * - role="dialog", aria-modal="false" (not a hard-focus capture; the
15
+ * editor surface remains interactive while the card is open)
16
+ * - aria-labelledby points at the header id
17
+ * - Escape closes; focus-trap wraps Tab / Shift-Tab
18
+ * - An aria-live="polite" region announces the attached issue's
19
+ * severity when the card opens with an issue
20
+ */
21
+
22
+ import * as React from "react";
23
+ import type {
24
+ IssueMetadataValue,
25
+ IssueOwner,
26
+ IssueSeverity,
27
+ ScopeCardModel,
28
+ ScopeIssueAction,
29
+ WorkflowScopeMode,
30
+ } from "../../api/public-types";
31
+
32
+ // ---------------------------------------------------------------------------
33
+ // Types
34
+ // ---------------------------------------------------------------------------
35
+
36
+ export interface TwScopeCardProps {
37
+ model: ScopeCardModel;
38
+ onClose: () => void;
39
+ onModeChange: (mode: WorkflowScopeMode) => void;
40
+ onIssueAction: (action: ScopeIssueAction) => void;
41
+ /** Test id applied to the root node. */
42
+ "data-testid"?: string;
43
+ }
44
+
45
+ const MODE_OPTIONS: ReadonlyArray<{ mode: WorkflowScopeMode; label: string }> = [
46
+ { mode: "edit", label: "Edit" },
47
+ { mode: "suggest", label: "Suggest" },
48
+ { mode: "comment", label: "Comment" },
49
+ { mode: "view", label: "View" },
50
+ ];
51
+
52
+ const SEVERITY_COLOR: Record<IssueSeverity, string> = {
53
+ low: "var(--color-secondary)",
54
+ medium: "var(--color-warning)",
55
+ high: "var(--color-warning)",
56
+ blocker: "var(--color-danger)",
57
+ };
58
+
59
+ const SEVERITY_LABEL: Record<IssueSeverity, string> = {
60
+ low: "Low",
61
+ medium: "Medium",
62
+ high: "High",
63
+ blocker: "Blocker",
64
+ };
65
+
66
+ const OWNER_LABEL: Record<IssueOwner, string> = {
67
+ procurement: "Procurement",
68
+ legal: "Legal",
69
+ risk: "Risk",
70
+ finance: "Finance",
71
+ sustainability: "Sustainability",
72
+ };
73
+
74
+ // ---------------------------------------------------------------------------
75
+ // Component
76
+ // ---------------------------------------------------------------------------
77
+
78
+ export const TwScopeCard: React.FC<TwScopeCardProps> = ({
79
+ model,
80
+ onClose,
81
+ onModeChange,
82
+ onIssueAction,
83
+ "data-testid": testId,
84
+ }) => {
85
+ const rootRef = React.useRef<HTMLDivElement | null>(null);
86
+ const headerId = React.useId();
87
+ const liveRegionId = React.useId();
88
+
89
+ // --- Focus management ----------------------------------------------------
90
+ React.useEffect(() => {
91
+ const root = rootRef.current;
92
+ if (!root) return undefined;
93
+ const first = getFocusable(root)[0];
94
+ first?.focus();
95
+ return undefined;
96
+ }, []);
97
+
98
+ // --- Escape + click-outside ---------------------------------------------
99
+ React.useEffect(() => {
100
+ const onKey = (event: KeyboardEvent) => {
101
+ if (event.key === "Escape") {
102
+ event.stopPropagation();
103
+ onClose();
104
+ }
105
+ };
106
+ const onPointerDown = (event: PointerEvent) => {
107
+ const root = rootRef.current;
108
+ if (!root) return;
109
+ if (event.target instanceof Node && root.contains(event.target)) return;
110
+ onClose();
111
+ };
112
+ window.addEventListener("keydown", onKey, true);
113
+ window.addEventListener("pointerdown", onPointerDown, true);
114
+ return () => {
115
+ window.removeEventListener("keydown", onKey, true);
116
+ window.removeEventListener("pointerdown", onPointerDown, true);
117
+ };
118
+ }, [onClose]);
119
+
120
+ // --- Focus trap ----------------------------------------------------------
121
+ const onKeyDown = (event: React.KeyboardEvent<HTMLDivElement>) => {
122
+ if (event.key !== "Tab") return;
123
+ const root = rootRef.current;
124
+ if (!root) return;
125
+ const focusables = getFocusable(root);
126
+ if (focusables.length === 0) return;
127
+ const first = focusables[0];
128
+ const last = focusables[focusables.length - 1];
129
+ const active = document.activeElement;
130
+ if (event.shiftKey) {
131
+ if (active === first || !root.contains(active)) {
132
+ event.preventDefault();
133
+ last.focus();
134
+ }
135
+ } else {
136
+ if (active === last) {
137
+ event.preventDefault();
138
+ first.focus();
139
+ }
140
+ }
141
+ };
142
+
143
+ const issue = model.issue;
144
+ const postureLabel = posturePresentationLabel(model.posture);
145
+
146
+ return (
147
+ <div
148
+ ref={rootRef}
149
+ className="wre-scope-card pointer-events-auto absolute flex w-80 max-w-[22rem] flex-col gap-2 rounded-lg border border-border bg-canvas p-3 text-sm shadow-[var(--shadow-float)]"
150
+ role="dialog"
151
+ aria-modal="false"
152
+ aria-labelledby={headerId}
153
+ data-testid={testId ?? "scope-card"}
154
+ data-scope-id={model.scopeId}
155
+ data-posture={model.posture}
156
+ onKeyDown={onKeyDown}
157
+ >
158
+ {/* Header --------------------------------------------------------- */}
159
+ <div className="flex items-center justify-between gap-2">
160
+ <div
161
+ id={headerId}
162
+ className="flex min-w-0 flex-1 items-center gap-2 text-xs font-medium text-primary"
163
+ >
164
+ <span
165
+ className={`wre-scope-rail-icon wre-scope-rail-icon-${posturePresentationIcon(model.posture)}`}
166
+ aria-hidden="true"
167
+ style={{ color: postureTokenColor(model.posture) }}
168
+ />
169
+ <span className="truncate uppercase tracking-[0.06em]">
170
+ {postureLabel}
171
+ </span>
172
+ {model.label ? (
173
+ <span className="truncate text-tertiary">· {model.label}</span>
174
+ ) : null}
175
+ </div>
176
+ <button
177
+ type="button"
178
+ aria-label="Close scope card"
179
+ className="flex h-6 w-6 items-center justify-center rounded-sm text-tertiary transition-colors hover:bg-surface hover:text-primary"
180
+ onClick={onClose}
181
+ data-testid="scope-card-close"
182
+ >
183
+ ×
184
+ </button>
185
+ </div>
186
+
187
+ {/* Mode row ------------------------------------------------------- */}
188
+ <div
189
+ role="group"
190
+ aria-label="Scope mode"
191
+ className="flex gap-1 rounded-md border border-border bg-surface p-0.5"
192
+ >
193
+ {MODE_OPTIONS.map(({ mode, label }) => {
194
+ const active = model.posture === mode;
195
+ return (
196
+ <button
197
+ key={mode}
198
+ type="button"
199
+ aria-pressed={active ? "true" : "false"}
200
+ className={`flex-1 rounded-sm px-2 py-1 text-xs font-medium transition-colors ${
201
+ active
202
+ ? "bg-canvas text-primary shadow-sm"
203
+ : "text-secondary hover:bg-canvas hover:text-primary"
204
+ }`}
205
+ onClick={() => onModeChange(mode)}
206
+ data-testid={`scope-card-mode-${mode}`}
207
+ >
208
+ {label}
209
+ </button>
210
+ );
211
+ })}
212
+ </div>
213
+
214
+ {/* Issue row (R2) ------------------------------------------------- */}
215
+ {issue ? <IssueRow issue={issue} onAction={onIssueAction} /> : null}
216
+
217
+ {/* A11y live region ---------------------------------------------- */}
218
+ <span
219
+ id={liveRegionId}
220
+ role="status"
221
+ aria-live="polite"
222
+ className="sr-only"
223
+ >
224
+ {issue
225
+ ? `${postureLabel} scope, ${SEVERITY_LABEL[issue.severity]} severity issue attached: ${issue.title}`
226
+ : `${postureLabel} scope opened`}
227
+ </span>
228
+ </div>
229
+ );
230
+ };
231
+
232
+ // ---------------------------------------------------------------------------
233
+ // Issue row
234
+ // ---------------------------------------------------------------------------
235
+
236
+ const IssueRow: React.FC<{
237
+ issue: IssueMetadataValue;
238
+ onAction: (action: ScopeIssueAction) => void;
239
+ }> = ({ issue, onAction }) => {
240
+ const canResolve = issue.checklistState === "open" || issue.checklistState === "acknowledged";
241
+ const canWaive = issue.checklistState !== "waived";
242
+ const canEscalate = issue.escalationState !== "requested";
243
+
244
+ return (
245
+ <div
246
+ className="flex flex-col gap-1.5 rounded-md bg-surface p-2"
247
+ data-testid="scope-card-issue"
248
+ >
249
+ <div className="flex items-center gap-1.5 text-[11px]">
250
+ <span
251
+ aria-hidden="true"
252
+ className="inline-block h-2 w-2 rounded-full"
253
+ style={{ background: SEVERITY_COLOR[issue.severity] }}
254
+ />
255
+ <span className="font-semibold text-primary">
256
+ {SEVERITY_LABEL[issue.severity]}
257
+ </span>
258
+ {issue.owner ? (
259
+ <span
260
+ className="rounded-sm border border-border px-1 py-0.5 text-[10px] font-medium uppercase tracking-[0.06em] text-secondary"
261
+ data-testid="scope-card-issue-owner"
262
+ >
263
+ {OWNER_LABEL[issue.owner]}
264
+ </span>
265
+ ) : null}
266
+ </div>
267
+ <div
268
+ className="text-xs font-medium leading-snug text-primary"
269
+ data-testid="scope-card-issue-title"
270
+ >
271
+ {issue.title}
272
+ </div>
273
+ {issue.summary ? (
274
+ <div className="text-[11px] leading-snug text-secondary">{issue.summary}</div>
275
+ ) : null}
276
+ <div className="flex gap-1">
277
+ <IssueActionButton
278
+ label="Resolve"
279
+ testId="scope-card-issue-resolve"
280
+ disabled={!canResolve}
281
+ onClick={() => onAction("resolve")}
282
+ />
283
+ <IssueActionButton
284
+ label="Waive"
285
+ testId="scope-card-issue-waive"
286
+ disabled={!canWaive}
287
+ onClick={() => onAction("waive")}
288
+ />
289
+ <IssueActionButton
290
+ label="Escalate"
291
+ testId="scope-card-issue-escalate"
292
+ disabled={!canEscalate}
293
+ onClick={() => onAction("escalate")}
294
+ />
295
+ </div>
296
+ </div>
297
+ );
298
+ };
299
+
300
+ const IssueActionButton: React.FC<{
301
+ label: string;
302
+ testId: string;
303
+ disabled: boolean;
304
+ onClick: () => void;
305
+ }> = ({ label, testId, disabled, onClick }) => (
306
+ <button
307
+ type="button"
308
+ disabled={disabled}
309
+ className="flex-1 rounded-sm border border-border bg-canvas px-1.5 py-1 text-[11px] font-medium text-secondary transition-colors hover:bg-surface hover:text-primary disabled:cursor-not-allowed disabled:opacity-40"
310
+ onClick={onClick}
311
+ data-testid={testId}
312
+ >
313
+ {label}
314
+ </button>
315
+ );
316
+
317
+ // ---------------------------------------------------------------------------
318
+ // Helpers
319
+ // ---------------------------------------------------------------------------
320
+
321
+ function getFocusable(root: HTMLElement): HTMLElement[] {
322
+ const selector =
323
+ 'button:not([disabled]), a[href], [tabindex]:not([tabindex="-1"]), input:not([disabled]), select:not([disabled]), textarea:not([disabled])';
324
+ return Array.from(root.querySelectorAll<HTMLElement>(selector)).filter(
325
+ (el) => !el.hasAttribute("inert") && el.offsetParent !== null,
326
+ );
327
+ }
328
+
329
+ function posturePresentationLabel(posture: ScopeCardModel["posture"]): string {
330
+ switch (posture) {
331
+ case "edit":
332
+ return "Edit";
333
+ case "suggest":
334
+ return "Suggest";
335
+ case "comment":
336
+ return "Comment";
337
+ case "view":
338
+ return "View";
339
+ case "candidate":
340
+ return "Proposed";
341
+ case "preserve-only":
342
+ case "blocked-import":
343
+ return "Blocked";
344
+ default:
345
+ return "Scope";
346
+ }
347
+ }
348
+
349
+ function posturePresentationIcon(posture: ScopeCardModel["posture"]): string {
350
+ switch (posture) {
351
+ case "edit":
352
+ return "pencil";
353
+ case "suggest":
354
+ return "sparkles";
355
+ case "comment":
356
+ return "message";
357
+ case "view":
358
+ return "eye";
359
+ case "candidate":
360
+ return "flag";
361
+ case "preserve-only":
362
+ case "blocked-import":
363
+ return "lock";
364
+ default:
365
+ return "eye";
366
+ }
367
+ }
368
+
369
+ function postureTokenColor(posture: ScopeCardModel["posture"]): string {
370
+ switch (posture) {
371
+ case "edit":
372
+ return "var(--color-accent)";
373
+ case "suggest":
374
+ case "candidate":
375
+ return "var(--color-warning)";
376
+ case "comment":
377
+ return "var(--color-insert)";
378
+ case "preserve-only":
379
+ case "blocked-import":
380
+ return "var(--color-danger)";
381
+ default:
382
+ return "var(--color-secondary)";
383
+ }
384
+ }
385
+
386
+ export default TwScopeCard;
@@ -1,23 +1,23 @@
1
1
  /**
2
- * Scope rail layer — renders workflow scopes as a continuous zone with a
3
- * gutter icon + label column OUTSIDE the document flow plus a flat block-
4
- * level tint BEHIND the scoped paragraphs.
2
+ * Scope rail layer — renders workflow scopes as a thin color stripe in
3
+ * the reserved left-gutter lane plus a per-line flat tint behind the
4
+ * scoped text runs.
5
5
  *
6
- * Per runtime-rendering-and-chrome-phase.md §5, the rail is a projection
7
- * over canonical workflow scopes; it never lives inside the PM NodeView
8
- * tree. Positions come from the render kernel's anchor index not from
9
- * DOM rect math — so the rail stays aligned across scroll, zoom, resize,
10
- * and through predicted-text reconciliation.
6
+ * Per runtime-rendering-and-chrome-phase.md §5 and
7
+ * docs/plans/scope-card-overlay.md P0, the rail is a projection over
8
+ * canonical workflow scopes; it never lives inside the PM NodeView
9
+ * tree. Positions come from the render kernel's per-line block data
10
+ * (walked directly from `RenderFrame.pages[].regions.body.blocks[]`)
11
+ * so multi-line scopes produce one tight tint per line rather than a
12
+ * fat bounding-box union.
11
13
  */
12
14
 
13
15
  import * as React from "react";
14
16
  import {
15
- inflateRect,
16
17
  projectRectToOverlay,
17
- unionRect,
18
18
  type OverlayCoordinateSpace,
19
19
  } from "./chrome-overlay-projector";
20
- import type { RenderFrameRect } from "../../runtime/render";
20
+ import type { RenderFrame, RenderFrameRect } from "../../runtime/render";
21
21
  import type { ScopeRailSegment, ScopeRailPosture } from "../../runtime/layout";
22
22
  import type { WordReviewEditorLayoutFacet } from "../../runtime/layout";
23
23
 
@@ -26,16 +26,25 @@ import type { WordReviewEditorLayoutFacet } from "../../runtime/layout";
26
26
  // ---------------------------------------------------------------------------
27
27
 
28
28
  export interface TwScopeRailLayerProps {
29
- /** Layout facet that provides segments + anchor index. */
29
+ /** Layout facet that provides segments + render frame. */
30
30
  facet: WordReviewEditorLayoutFacet;
31
31
  /** Overlay's coordinate space. Defaults to the overlay's own origin. */
32
32
  space?: OverlayCoordinateSpace;
33
- /** Horizontal padding (px) the rail gutter occupies to the left of body. */
33
+ /** Horizontal pad (px) the rail gutter occupies to the left of body. */
34
34
  railLaneWidthPx?: number;
35
- /** Optional click handler for a segment label (open-scope drawer, etc). */
36
- onSegmentClick?: (segment: ScopeRailSegment) => void;
37
35
  /** Scope id that should render with the `active` emphasis. */
38
36
  activeScopeId?: string | null;
37
+ /**
38
+ * Fires when the user clicks the rail stripe — opens the scope card.
39
+ * P0 wires this directly; P1 replaces with card-layer-aware routing.
40
+ */
41
+ onStripeClick?: (segment: ScopeRailSegment) => void;
42
+ /**
43
+ * Legacy click handler kept for existing consumers. Called alongside
44
+ * `onStripeClick` so host apps that subscribed to the pre-stripe API
45
+ * continue to receive clicks.
46
+ */
47
+ onSegmentClick?: (segment: ScopeRailSegment) => void;
39
48
  /** Test id applied to the layer root. */
40
49
  "data-testid"?: string;
41
50
  }
@@ -46,7 +55,7 @@ export interface TwScopeRailLayerProps {
46
55
 
47
56
  interface PostureStyle {
48
57
  labelText: string;
49
- icon: string; // lucide-style key; CSS ::before handles glyph in production
58
+ icon: string;
50
59
  railToken: string;
51
60
  tintToken: string;
52
61
  }
@@ -65,20 +74,19 @@ const POSTURE_STYLES: Record<ScopeRailPosture, PostureStyle> = {
65
74
  // Component
66
75
  // ---------------------------------------------------------------------------
67
76
 
68
- const DEFAULT_RAIL_LANE_PX = 120;
69
- const LABEL_WIDTH_PX = 92;
77
+ const DEFAULT_RAIL_LANE_PX = 44;
78
+ const STRIPE_WIDTH_PX = 4;
79
+ const LABEL_WIDTH_PX = 40;
70
80
 
71
81
  export const TwScopeRailLayer: React.FC<TwScopeRailLayerProps> = ({
72
82
  facet,
73
83
  space,
74
84
  railLaneWidthPx = DEFAULT_RAIL_LANE_PX,
75
- onSegmentClick,
76
85
  activeScopeId,
86
+ onStripeClick,
87
+ onSegmentClick,
77
88
  "data-testid": testId,
78
89
  }) => {
79
- // Read the render frame once per paint cycle. The facet.subscribe path
80
- // already invalidates the caller's React state on layout changes, so we
81
- // just read on render.
82
90
  const frame = typeof facet.getRenderFrame === "function"
83
91
  ? facet.getRenderFrame() ?? null
84
92
  : null;
@@ -88,16 +96,6 @@ export const TwScopeRailLayer: React.FC<TwScopeRailLayerProps> = ({
88
96
  return null;
89
97
  }
90
98
 
91
- // Group segments by scopeId so multi-page scopes render one contiguous
92
- // tint per page range. (Per-page render happens below because each
93
- // scope may span pages.)
94
- const items = segments.map((segment) => {
95
- const rect = resolveSegmentRect(facet, frame, segment);
96
- if (!rect) return null;
97
- const style = POSTURE_STYLES[segment.posture];
98
- return { segment, rect, style };
99
- }).filter((item): item is NonNullable<typeof item> => item !== null);
100
-
101
99
  const projectorSpace: OverlayCoordinateSpace = space ?? { originLeftPx: 0, originTopPx: 0 };
102
100
 
103
101
  return (
@@ -108,43 +106,86 @@ export const TwScopeRailLayer: React.FC<TwScopeRailLayerProps> = ({
108
106
  role="group"
109
107
  aria-label="Workflow scope rail"
110
108
  >
111
- {items.map(({ segment, rect, style }) => {
112
- const isActive = activeScopeId === segment.scopeId || segment.isActiveWorkItem;
113
- const tintRect = inflateRect(rect, { leftPx: 4, rightPx: 4, topPx: 2, bottomPx: 2 });
109
+ {segments.map((segment) => {
110
+ const style = POSTURE_STYLES[segment.posture];
111
+ const lineRects = collectLineRectsForSegment(frame, segment);
112
+ if (lineRects.length === 0) return null;
113
+
114
+ const isActive =
115
+ activeScopeId === segment.scopeId || segment.isActiveWorkItem;
116
+
117
+ // Stripe + label span the vertical range of the scope's lines;
118
+ // they live in the gutter lane to the left of the first line.
119
+ const firstLine = lineRects[0];
120
+ const lastLine = lineRects[lineRects.length - 1];
121
+ const stripeTopPx = firstLine.topPx;
122
+ const stripeHeightPx =
123
+ lastLine.topPx + lastLine.heightPx - firstLine.topPx;
124
+ const stripeRect: RenderFrameRect = {
125
+ leftPx: firstLine.leftPx - railLaneWidthPx + (railLaneWidthPx - STRIPE_WIDTH_PX) / 2,
126
+ topPx: stripeTopPx,
127
+ widthPx: STRIPE_WIDTH_PX,
128
+ heightPx: Math.max(stripeHeightPx, 14),
129
+ };
114
130
  const labelRect: RenderFrameRect = {
115
- leftPx: rect.leftPx - railLaneWidthPx,
116
- topPx: rect.topPx,
131
+ leftPx: firstLine.leftPx - railLaneWidthPx,
132
+ topPx: stripeTopPx,
117
133
  widthPx: LABEL_WIDTH_PX,
118
- heightPx: Math.max(20, Math.min(rect.heightPx, 48)),
134
+ heightPx: 20,
135
+ };
136
+
137
+ const handleActivate = () => {
138
+ onStripeClick?.(segment);
139
+ onSegmentClick?.(segment);
140
+ };
141
+ const handleStripeKey = (event: React.KeyboardEvent<HTMLButtonElement>) => {
142
+ if (event.key === "Enter" || event.key === " ") {
143
+ event.preventDefault();
144
+ handleActivate();
145
+ }
119
146
  };
120
147
 
121
148
  return (
122
149
  <React.Fragment key={`${segment.scopeId}:${segment.pageIndex}:${segment.fromOffset}`}>
123
- {/* Flat tint behind the scoped block region */}
124
- <div
125
- className={`wre-scope-rail-tint wre-scope-rail-tint-${style.tintToken} absolute ${
126
- isActive ? "wre-scope-rail-tint-active" : ""
127
- }`}
128
- data-scope-id={segment.scopeId}
129
- data-posture={segment.posture}
130
- style={projectRectToOverlay(tintRect, projectorSpace)}
131
- />
132
- {/* Gutter label + icon outside the page frame */}
150
+ {/* Per-line tint behind the scoped text runs. */}
151
+ {lineRects.map((lineRect, index) => (
152
+ <div
153
+ key={`tint:${index}`}
154
+ className={`wre-scope-rail-tint wre-scope-rail-tint-${style.tintToken} ${
155
+ isActive ? "wre-scope-rail-tint-active" : ""
156
+ }`}
157
+ data-scope-id={segment.scopeId}
158
+ data-posture={segment.posture}
159
+ data-line-index={index}
160
+ style={projectRectToOverlay(lineRect, projectorSpace)}
161
+ />
162
+ ))}
163
+ {/* Rail stripe in the gutter. */}
133
164
  <button
134
165
  type="button"
135
- className={`wre-scope-rail-label wre-scope-rail-label-${style.railToken} pointer-events-auto absolute flex flex-col items-center justify-center gap-1 rounded-md text-[10px] font-semibold uppercase tracking-[0.08em] ${
136
- isActive ? "wre-scope-rail-label-active" : ""
166
+ className={`wre-scope-rail-stripe wre-scope-rail-label-${style.railToken} ${
167
+ isActive ? "wre-scope-rail-stripe-active" : ""
137
168
  }`}
138
169
  data-scope-id={segment.scopeId}
139
170
  data-posture={segment.posture}
140
- data-icon={style.icon}
171
+ data-testid="scope-rail-stripe"
141
172
  aria-label={`${style.labelText}${segment.label ? `: ${segment.label}` : ""}`}
142
- onClick={onSegmentClick ? () => onSegmentClick(segment) : undefined}
173
+ aria-expanded={isActive ? "true" : "false"}
174
+ onClick={handleActivate}
175
+ onKeyDown={handleStripeKey}
176
+ style={projectRectToOverlay(stripeRect, projectorSpace)}
177
+ />
178
+ {/* Label pill — revealed on stripe hover via CSS. */}
179
+ <div
180
+ className={`wre-scope-rail-label wre-scope-rail-label-${style.railToken}`}
181
+ data-scope-id={segment.scopeId}
182
+ data-posture={segment.posture}
183
+ aria-hidden="true"
143
184
  style={projectRectToOverlay(labelRect, projectorSpace)}
144
185
  >
145
186
  <span aria-hidden="true" className={`wre-scope-rail-icon wre-scope-rail-icon-${style.icon}`} />
146
187
  <span className="wre-scope-rail-label-text">{style.labelText}</span>
147
- </button>
188
+ </div>
148
189
  </React.Fragment>
149
190
  );
150
191
  })}
@@ -156,23 +197,53 @@ export const TwScopeRailLayer: React.FC<TwScopeRailLayerProps> = ({
156
197
  // Internals
157
198
  // ---------------------------------------------------------------------------
158
199
 
159
- function resolveSegmentRect(
160
- facet: WordReviewEditorLayoutFacet,
161
- _frame: { anchorIndex: { byRuntimeOffset: (offset: number) => RenderFrameRect | null } },
200
+ /**
201
+ * Walk the render frame and collect one rect per line whose owning
202
+ * fragment overlaps the segment's offset range. Line frames come from
203
+ * the kernel's per-line projection, which clamps widthPx to the line's
204
+ * text-actual width via `Math.min(regionFrame.widthPx, box.widthTwips
205
+ * * pxPerTwip)`. Multi-line scopes therefore produce one tight tint
206
+ * per line instead of a single bounding-box union.
207
+ *
208
+ * Exported for unit testing — not part of the public API.
209
+ */
210
+ export function collectLineRectsForSegment(
211
+ frame: RenderFrame,
162
212
  segment: ScopeRailSegment,
163
- ): RenderFrameRect | null {
164
- const fromRect = _frame.anchorIndex.byRuntimeOffset(segment.fromOffset);
165
- const toRect = _frame.anchorIndex.byRuntimeOffset(Math.max(segment.fromOffset, segment.toOffset - 1));
166
- const unioned = unionRect(fromRect, toRect);
167
- if (unioned) return unioned;
168
- // Fall back to the page rect so long scopes that can't resolve per-line
169
- // still render a posture in the gutter.
170
- const fallbackPage = facet.getPage(segment.pageIndex);
171
- if (!fallbackPage) return null;
172
- // Approximate: derive a rect from the page's body region by asking the
173
- // render frame for the page rect. Without a render kernel kernel this
174
- // function returns null and the segment is skipped.
175
- return null;
213
+ ): RenderFrameRect[] {
214
+ const rects: RenderFrameRect[] = [];
215
+ const lo = segment.fromOffset;
216
+ const hi = segment.toOffset;
217
+ if (lo >= hi) return rects;
218
+
219
+ const page = frame.pages[segment.pageIndex];
220
+ if (!page) {
221
+ // Fall back to scanning every page — protects against pageIndex
222
+ // drift during an in-flight layout update.
223
+ for (const p of frame.pages) {
224
+ pushRectsFromPage(rects, p, lo, hi);
225
+ }
226
+ return rects;
227
+ }
228
+ pushRectsFromPage(rects, page, lo, hi);
229
+ return rects;
230
+ }
231
+
232
+ function pushRectsFromPage(
233
+ sink: RenderFrameRect[],
234
+ page: RenderFrame["pages"][number],
235
+ lo: number,
236
+ hi: number,
237
+ ): void {
238
+ for (const block of page.regions.body.blocks) {
239
+ const from = block.fragment.from;
240
+ const to = block.fragment.to;
241
+ if (to <= lo || from >= hi) continue;
242
+ // Block overlaps the segment — emit one tint per line.
243
+ for (const line of block.lines) {
244
+ sink.push(line.frame);
245
+ }
246
+ }
176
247
  }
177
248
 
178
249
  export default TwScopeRailLayer;