@beyondwork/docx-react-component 1.0.39 → 1.0.41

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.
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@beyondwork/docx-react-component",
3
3
  "publisher": "beyondwork",
4
- "version": "1.0.39",
4
+ "version": "1.0.41",
5
5
  "description": "Embeddable React Word (docx) editor with review, comments, tracked changes, and round-trip OOXML fidelity.",
6
6
  "packageManager": "pnpm@10.30.3",
7
7
  "type": "module",
@@ -1,6 +1,8 @@
1
1
  import type { PersistedEditorSnapshot as RuntimePersistedEditorSnapshot } from "../core/state/editor-state.ts";
2
2
  import type { CanonicalParagraphFormatting, CanonicalRunFormatting } from "../model/canonical-document.ts";
3
3
  import type { WordReviewEditorLayoutFacet } from "../runtime/layout/public-facet.ts";
4
+ import type { RenderFrameRect } from "../runtime/render/index.ts";
5
+ import type { ScopeRailPosture } from "../runtime/workflow-rail-segments.ts";
4
6
 
5
7
  export type { CanonicalParagraphFormatting, CanonicalRunFormatting };
6
8
 
@@ -1675,6 +1677,102 @@ export interface WorkflowMetadataSnapshot {
1675
1677
  entries: WorkflowMetadataEntry[];
1676
1678
  }
1677
1679
 
1680
+ // ---------------------------------------------------------------------------
1681
+ // R2 — issue metadata (scope-card-overlay P1)
1682
+ // ---------------------------------------------------------------------------
1683
+
1684
+ /**
1685
+ * Canonical metadata id for playbook-raised issues. Hosts register a
1686
+ * `WorkflowMetadataDefinition` with this id and push one
1687
+ * `WorkflowMetadataEntry` per issue; the scope card reads the entries
1688
+ * whose `workItemId` or `scopeId` matches a rendered scope.
1689
+ *
1690
+ * Field names line up 1:1 with the CCEP Playbook Engine rule shape so
1691
+ * hosts can push rule output without remapping.
1692
+ */
1693
+ export const ISSUE_METADATA_ID = "workflow.metadata.issue" as const;
1694
+
1695
+ export type IssueSeverity = "low" | "medium" | "high" | "blocker";
1696
+
1697
+ /**
1698
+ * Playbook rule modes. `guidance` is an advisory preference; `fallback`
1699
+ * is a tiered alternative; `mandatory` cannot be silently removed;
1700
+ * `escalate` requires named-owner sign-off; `block` refuses the edit
1701
+ * outright.
1702
+ */
1703
+ export type IssueMode =
1704
+ | "guidance"
1705
+ | "fallback"
1706
+ | "mandatory"
1707
+ | "escalate"
1708
+ | "block";
1709
+
1710
+ /**
1711
+ * Named escalation / ownership targets for playbook issues. Hosts can
1712
+ * extend the literal via a widened `string` alias if a tenant needs a
1713
+ * custom owner, but the canonical set matches CCEP's playbook rule
1714
+ * shape.
1715
+ */
1716
+ export type IssueOwner =
1717
+ | "procurement"
1718
+ | "legal"
1719
+ | "risk"
1720
+ | "finance"
1721
+ | "sustainability";
1722
+
1723
+ export interface IssueMetadataValue {
1724
+ issueId: string;
1725
+ ruleId?: string;
1726
+ topic: string;
1727
+ severity: IssueSeverity;
1728
+ mode: IssueMode;
1729
+ owner?: IssueOwner;
1730
+ escalateTo?: IssueOwner;
1731
+ checklistState: "open" | "acknowledged" | "resolved" | "waived";
1732
+ escalationState?: "none" | "requested" | "approved" | "rejected";
1733
+ suggestionIds?: string[];
1734
+ rationale?: string;
1735
+ title: string;
1736
+ summary?: string;
1737
+ }
1738
+
1739
+ /**
1740
+ * Per-scope action the scope card's issue row exposes. Dispatched as a
1741
+ * `scope-issue-action-requested` event — host decides what the action
1742
+ * means and updates the `IssueMetadataValue.checklistState` /
1743
+ * `escalationState` accordingly. The card never mutates runtime state.
1744
+ */
1745
+ export type ScopeIssueAction =
1746
+ | "resolve"
1747
+ | "waive"
1748
+ | "escalate"
1749
+ | "acknowledge";
1750
+
1751
+ /**
1752
+ * Scope card projection consumed by the chrome overlay's card layer.
1753
+ * Joins a `WorkflowScope` with its attached issue metadata (R2),
1754
+ * suggestion groups (R3 — P2 populates), review-action count (K1 —
1755
+ * P2 populates), and agent-pending flag (K2 — P2 populates).
1756
+ *
1757
+ * `primaryAnchorRect` is the position the card should hover next to —
1758
+ * resolved via `RenderAnchorIndex.bySelection(fromOffset, toOffset)`
1759
+ * on the active page, or `null` when the scope is off-screen.
1760
+ */
1761
+ export interface ScopeCardModel {
1762
+ scopeId: string;
1763
+ workItemId?: string;
1764
+ posture: ScopeRailPosture;
1765
+ label: string;
1766
+ primaryAnchorRect: RenderFrameRect | null;
1767
+ issue?: IssueMetadataValue;
1768
+ /** R3 suggestion groups attached to the scope. P2 populates; P1 = []. */
1769
+ suggestionGroupIds: readonly string[];
1770
+ /** K1 review-action count for the scope's issue. P2 populates; P1 = 0. */
1771
+ reviewActionCount: number;
1772
+ /** K2 agent-pending flag (overlapping WorkflowCandidateRange source:"ai"). P2 populates; P1 = false. */
1773
+ agentPending: boolean;
1774
+ }
1775
+
1678
1776
  export interface WorkflowBlockedCommandReason {
1679
1777
  code:
1680
1778
  | "outside_workflow_scope"
@@ -2192,6 +2290,30 @@ export type WordReviewEditorEvent =
2192
2290
  documentId: string;
2193
2291
  command: string;
2194
2292
  reasons: WorkflowBlockedCommandReason[];
2293
+ }
2294
+ | {
2295
+ /**
2296
+ * Scope card mode selector fired a mode change. Host relays to the
2297
+ * existing `setWorkflowOverlay` path (or the CCEP workflow
2298
+ * endpoint) — the card never mutates runtime state directly.
2299
+ */
2300
+ type: "scope-mode-change-requested";
2301
+ documentId: string;
2302
+ scopeId: string;
2303
+ mode: WorkflowScopeMode;
2304
+ }
2305
+ | {
2306
+ /**
2307
+ * Scope card issue row fired an action (resolve / waive /
2308
+ * escalate / acknowledge). Host updates the attached
2309
+ * `IssueMetadataValue.checklistState` / `escalationState` and
2310
+ * re-pushes via `setWorkflowMetadataEntries`.
2311
+ */
2312
+ type: "scope-issue-action-requested";
2313
+ documentId: string;
2314
+ scopeId: string;
2315
+ issueId: string;
2316
+ action: ScopeIssueAction;
2195
2317
  };
2196
2318
 
2197
2319
  export interface LoadResult {
package/src/index.ts CHANGED
@@ -7,6 +7,8 @@ export {
7
7
  validateEditorSessionState,
8
8
  EDITOR_SESSION_STATE_VERSION,
9
9
  } from "./api/session-state.ts";
10
+ // R2 — issue metadata id for scope-card-overlay P1.
11
+ export { ISSUE_METADATA_ID } from "./api/public-types.ts";
10
12
  export type {
11
13
  LoadRequest,
12
14
  LoadSourcePolicy,
@@ -104,6 +106,13 @@ export type {
104
106
  WorkflowMetadataDefinition,
105
107
  WorkflowMetadataEntry,
106
108
  WorkflowMetadataSnapshot,
109
+ // R2 — issue metadata (scope-card-overlay P1)
110
+ IssueSeverity,
111
+ IssueMode,
112
+ IssueOwner,
113
+ IssueMetadataValue,
114
+ ScopeIssueAction,
115
+ ScopeCardModel,
107
116
  WorkflowBlockedCommandReason,
108
117
  WorkflowScopeSnapshot,
109
118
  InteractionGuardSnapshot,
@@ -496,6 +496,13 @@ export function createDocumentRuntime(
496
496
  activeStory,
497
497
  };
498
498
  },
499
+ // R2 / scope-card-overlay P1 — surface metadata markup so
500
+ // `facet.getAllScopeCardModels()` can attach `IssueMetadataValue`
501
+ // to its scope without the chrome overlay having to re-fetch a
502
+ // separate snapshot. Reads through the same cached snapshot the
503
+ // runtime already builds for comment/revision/search consumers.
504
+ getWorkflowMarkupMetadata: () =>
505
+ getCachedWorkflowMarkupSnapshot().metadata,
499
506
  });
500
507
  renderKernelRef = createRenderKernel({
501
508
  facet: layoutFacet,
@@ -45,12 +45,35 @@ export interface DocxFontLoader {
45
45
  refresh(input: FontLoaderInput): void;
46
46
  }
47
47
 
48
+ interface MinimalFontFace {
49
+ load(): Promise<MinimalFontFace>;
50
+ }
51
+
52
+ interface MinimalFontFaceDescriptors {
53
+ weight?: string;
54
+ style?: string;
55
+ }
56
+
57
+ interface MinimalFontFaceConstructor {
58
+ new (
59
+ family: string,
60
+ source: ArrayBuffer | ArrayBufferView | string,
61
+ descriptors?: MinimalFontFaceDescriptors,
62
+ ): MinimalFontFace;
63
+ }
64
+
65
+ interface MinimalFontFaceSet {
66
+ add(face: MinimalFontFace): void;
67
+ check(font: string): boolean;
68
+ ready: Promise<MinimalFontFaceSet>;
69
+ }
70
+
48
71
  export function createDocxFontLoader(initial: FontLoaderInput): DocxFontLoader {
72
+ const globalDocument = (globalThis as unknown as { document?: { fonts?: unknown } }).document;
49
73
  const supported =
50
- typeof document !== "undefined" &&
74
+ globalDocument !== undefined &&
51
75
  typeof (globalThis as { FontFace?: unknown }).FontFace !== "undefined" &&
52
- // Guard against jsdom which exposes FontFace but not document.fonts
53
- Boolean((document as Document & { fonts?: FontFaceSet }).fonts);
76
+ Boolean(globalDocument.fonts);
54
77
 
55
78
  let current: FontLoaderInput = initial;
56
79
  let readyPromise: Promise<void>;
@@ -58,7 +81,7 @@ export function createDocxFontLoader(initial: FontLoaderInput): DocxFontLoader {
58
81
 
59
82
  function run(input: FontLoaderInput): Promise<void> {
60
83
  if (!supported) return Promise.resolve();
61
- const fontSet = (document as Document & { fonts?: FontFaceSet }).fonts;
84
+ const fontSet = globalDocument?.fonts as MinimalFontFaceSet | undefined;
62
85
  if (!fontSet) return Promise.resolve();
63
86
 
64
87
  const pending: Array<Promise<unknown>> = [];
@@ -70,10 +93,8 @@ export function createDocxFontLoader(initial: FontLoaderInput): DocxFontLoader {
70
93
 
71
94
  for (const [descriptor, data] of variantsOf(variants)) {
72
95
  try {
73
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
74
- const FontFaceCtor = (globalThis as any).FontFace as {
75
- new (family: string, source: BufferSource, descriptors?: FontFaceDescriptors): FontFace;
76
- };
96
+ const FontFaceCtor = (globalThis as { FontFace?: MinimalFontFaceConstructor }).FontFace;
97
+ if (!FontFaceCtor) continue;
77
98
  const face = new FontFaceCtor(family, data, descriptor);
78
99
  pending.push(
79
100
  face.load().then((loaded) => {
@@ -88,8 +109,6 @@ export function createDocxFontLoader(initial: FontLoaderInput): DocxFontLoader {
88
109
  }
89
110
  }
90
111
 
91
- // Mark declared families as registered if the browser already resolves
92
- // them (e.g. system fonts like Calibri, Arial).
93
112
  for (const family of input.families) {
94
113
  try {
95
114
  const probe = `12px "${family.replace(/"/g, "'")}", serif`;
@@ -127,7 +146,7 @@ export function createDocxFontLoader(initial: FontLoaderInput): DocxFontLoader {
127
146
 
128
147
  function* variantsOf(
129
148
  variants: EmbeddedFontBytes,
130
- ): IterableIterator<[FontFaceDescriptors, ArrayBuffer]> {
149
+ ): IterableIterator<[MinimalFontFaceDescriptors, ArrayBuffer]> {
131
150
  if (variants.regular) {
132
151
  yield [{ weight: "400", style: "normal" }, variants.regular];
133
152
  }
@@ -46,6 +46,7 @@ export function createInertLayoutFacet(): WordReviewEditorLayoutFacet {
46
46
  getAnchorRects: () => [],
47
47
  getScopeRailSegments: () => [],
48
48
  getAllScopeRailSegments: () => [],
49
+ getAllScopeCardModels: () => [],
49
50
  getResolvedFormatting: () => null,
50
51
  getResolvedRunFormatting: () => null,
51
52
  getMeasurement: () => null,
@@ -57,6 +57,7 @@ import {
57
57
  type ActiveMarginPreset,
58
58
  } from "./margin-preset-catalog.ts";
59
59
  import {
60
+ attachScopeCardModel,
60
61
  collectScopeRailSegments,
61
62
  type ScopeRailSegment,
62
63
  } from "../workflow-rail-segments.ts";
@@ -392,6 +393,20 @@ export interface WordReviewEditorLayoutFacet {
392
393
  /** Return every scope rail segment across the document. */
393
394
  getAllScopeRailSegments(): readonly import("../workflow-rail-segments.ts").ScopeRailSegment[];
394
395
 
396
+ /**
397
+ * Scope-card projection consumed by the chrome overlay's card layer
398
+ * (scope-card-overlay P1). Joins every unique scope segment with
399
+ * its attached issue metadata (R2 `ISSUE_METADATA_ID`), its
400
+ * work-item id, and its `primaryAnchorRect` via the render kernel's
401
+ * anchor index. P2 fields (`suggestionGroupIds`,
402
+ * `reviewActionCount`, `agentPending`) are defaulted to empty /
403
+ * zero / false in P1 and populated by later phases.
404
+ *
405
+ * Returns an empty list when no workflow rail input has been wired
406
+ * or when no scopes are currently on the active story.
407
+ */
408
+ getAllScopeCardModels(): readonly import("../../api/public-types.ts").ScopeCardModel[];
409
+
395
410
  // Measurement exposure -------------------------------------------------
396
411
  getResolvedFormatting(blockId: string): PublicResolvedParagraphFormatting | null;
397
412
  getResolvedRunFormatting(runId: string): PublicResolvedRunFormatting | null;
@@ -471,6 +486,17 @@ export interface CreateLayoutFacetInput {
471
486
  >
472
487
  | null
473
488
  | undefined;
489
+ /**
490
+ * Optional workflow-metadata accessor for scope-card issue resolution
491
+ * (R2 / scope-card-overlay P1). Returns the
492
+ * `WorkflowMarkupSnapshot.metadata` array so `getAllScopeCardModels`
493
+ * can attach issue values to their scopes. Omit when the host does
494
+ * not push metadata entries.
495
+ */
496
+ getWorkflowMarkupMetadata?: () =>
497
+ | readonly import("../../api/public-types.ts").WorkflowMetadataMarkup[]
498
+ | null
499
+ | undefined;
474
500
  }
475
501
 
476
502
  export function createLayoutFacet(
@@ -711,6 +737,21 @@ export function createLayoutFacet(
711
737
  );
712
738
  },
713
739
 
740
+ getAllScopeCardModels() {
741
+ const railInput = input.getWorkflowRailInput?.();
742
+ const segments = collectScopeRailSegmentsForQuery(railInput, currentGraph());
743
+ if (segments.length === 0) return [];
744
+ const kernel = input.renderKernel?.();
745
+ const anchorIndex = kernel?.getRenderFrame?.()?.anchorIndex ?? null;
746
+ const metadata = input.getWorkflowMarkupMetadata?.();
747
+ return attachScopeCardModel({
748
+ segments,
749
+ scopes: railInput?.scopes ?? [],
750
+ metadata: metadata ?? undefined,
751
+ anchorIndex,
752
+ });
753
+ },
754
+
714
755
  getFragmentsForPage(pageIndex) {
715
756
  const graph = currentGraph();
716
757
  const node = graph.pages[pageIndex];
@@ -15,14 +15,21 @@
15
15
  import type {
16
16
  EditorAnchorProjection,
17
17
  EditorStoryTarget,
18
+ IssueMetadataValue,
19
+ ScopeCardModel,
18
20
  WorkflowBlockedCommandReason,
19
21
  WorkflowCandidateRange,
20
22
  WorkflowLockedZone,
23
+ WorkflowMetadataMarkup,
21
24
  WorkflowScope,
22
25
  } from "../api/public-types";
26
+ import { ISSUE_METADATA_ID } from "../api/public-types";
23
27
  import { MAIN_STORY_TARGET, storyTargetsEqual } from "../core/selection/mapping.ts";
24
28
  import type { RuntimePageGraph } from "./layout/page-graph.ts";
25
- import type { RenderFrameRect } from "./render/render-frame-types.ts";
29
+ import type {
30
+ RenderAnchorIndex,
31
+ RenderFrameRect,
32
+ } from "./render/render-frame-types.ts";
26
33
 
27
34
  // ---------------------------------------------------------------------------
28
35
  // Public shape
@@ -278,3 +285,144 @@ function resolveScopePosture(scope: WorkflowScope): ScopeRailPosture {
278
285
  return "view";
279
286
  }
280
287
  }
288
+
289
+ // ---------------------------------------------------------------------------
290
+ // Scope card projection (P1b — consumed by ref.layout.getAllScopeCardModels)
291
+ // ---------------------------------------------------------------------------
292
+
293
+ export interface AttachScopeCardModelInput {
294
+ /**
295
+ * Segments produced by `collectScopeRailSegments`. The card model
296
+ * keys off segments so per-scope card state stays aligned with the
297
+ * rail stripe rendering.
298
+ */
299
+ segments: readonly ScopeRailSegment[];
300
+ /**
301
+ * Original workflow scopes — used to resolve `workItemId` for each
302
+ * segment (the segment shape predates workItemId passthrough).
303
+ * Optional; when absent, cards drop `workItemId` and issue matching
304
+ * falls back to scope-id only.
305
+ */
306
+ scopes?: readonly WorkflowScope[];
307
+ /**
308
+ * Workflow metadata markup from `WorkflowMarkupSnapshot.metadata`.
309
+ * Entries with `metadataId === ISSUE_METADATA_ID` that match a
310
+ * segment's `workItemId` or `scopeId` attach as the card's issue.
311
+ */
312
+ metadata?: readonly WorkflowMetadataMarkup[];
313
+ /**
314
+ * Optional anchor index used to resolve each card's
315
+ * `primaryAnchorRect`. When omitted, cards are produced without
316
+ * rects — chrome consumers fall back to on-render positioning.
317
+ */
318
+ anchorIndex?: RenderAnchorIndex | null;
319
+ }
320
+
321
+ /**
322
+ * Project rail segments into per-scope `ScopeCardModel` values.
323
+ *
324
+ * For each unique `scopeId` in the segment list:
325
+ * - use the first segment (document order) as the card's anchor
326
+ * - look up an `ISSUE_METADATA_ID` metadata entry whose
327
+ * `workItemId` (preferred) or `scopeId` matches the segment
328
+ * - coerce that entry's `value` into `IssueMetadataValue` when the
329
+ * required shape is present; drop the issue silently otherwise
330
+ * so malformed host input never breaks chrome rendering
331
+ *
332
+ * P2 fields (`suggestionGroupIds`, `reviewActionCount`,
333
+ * `agentPending`) are populated as empty defaults and wired in a
334
+ * later phase.
335
+ */
336
+ export function attachScopeCardModel(
337
+ input: AttachScopeCardModelInput,
338
+ ): ScopeCardModel[] {
339
+ if (input.segments.length === 0) return [];
340
+
341
+ // Take the first segment per scopeId (document order preserved by
342
+ // the segment collector).
343
+ const firstByScope = new Map<string, ScopeRailSegment>();
344
+ for (const segment of input.segments) {
345
+ if (!firstByScope.has(segment.scopeId)) {
346
+ firstByScope.set(segment.scopeId, segment);
347
+ }
348
+ }
349
+
350
+ const workItemByScope = new Map<string, string>();
351
+ for (const scope of input.scopes ?? []) {
352
+ if (scope.workItemId) {
353
+ workItemByScope.set(scope.scopeId, scope.workItemId);
354
+ }
355
+ }
356
+
357
+ const models: ScopeCardModel[] = [];
358
+ for (const segment of firstByScope.values()) {
359
+ const workItemId = workItemByScope.get(segment.scopeId);
360
+ const issue = resolveIssueForScope(
361
+ segment.scopeId,
362
+ workItemId,
363
+ input.metadata,
364
+ );
365
+ const primaryAnchorRect = input.anchorIndex
366
+ ? input.anchorIndex.bySelection(segment.fromOffset, segment.toOffset)
367
+ : null;
368
+
369
+ models.push({
370
+ scopeId: segment.scopeId,
371
+ ...(workItemId ? { workItemId } : {}),
372
+ label: segment.label ?? "",
373
+ posture: segment.posture,
374
+ primaryAnchorRect,
375
+ ...(issue ? { issue } : {}),
376
+ suggestionGroupIds: [],
377
+ reviewActionCount: 0,
378
+ agentPending: false,
379
+ });
380
+ }
381
+
382
+ return models;
383
+ }
384
+
385
+ function resolveIssueForScope(
386
+ scopeId: string,
387
+ workItemId: string | undefined,
388
+ metadata: readonly WorkflowMetadataMarkup[] | undefined,
389
+ ): IssueMetadataValue | undefined {
390
+ if (!metadata || metadata.length === 0) return undefined;
391
+ for (const entry of metadata) {
392
+ if (entry.metadataId !== ISSUE_METADATA_ID) continue;
393
+ const matchesWorkItem =
394
+ workItemId !== undefined &&
395
+ entry.workItemId !== undefined &&
396
+ entry.workItemId === workItemId;
397
+ const matchesScope =
398
+ entry.scopeId !== undefined && entry.scopeId === scopeId;
399
+ if (!matchesWorkItem && !matchesScope) continue;
400
+ const coerced = coerceIssueValue(entry.value);
401
+ if (coerced) return coerced;
402
+ }
403
+ return undefined;
404
+ }
405
+
406
+ /**
407
+ * Validate that a host-supplied metadata value looks like an
408
+ * `IssueMetadataValue`. Guards against malformed input — the card
409
+ * reads strongly-typed fields, so a missing severity or title must
410
+ * drop the issue rather than render `undefined` in the UI.
411
+ */
412
+ function coerceIssueValue(
413
+ value: unknown,
414
+ ): IssueMetadataValue | undefined {
415
+ if (!value || typeof value !== "object") return undefined;
416
+ const candidate = value as Partial<IssueMetadataValue>;
417
+ if (
418
+ typeof candidate.issueId !== "string" ||
419
+ typeof candidate.topic !== "string" ||
420
+ typeof candidate.severity !== "string" ||
421
+ typeof candidate.mode !== "string" ||
422
+ typeof candidate.checklistState !== "string" ||
423
+ typeof candidate.title !== "string"
424
+ ) {
425
+ return undefined;
426
+ }
427
+ return candidate as IssueMetadataValue;
428
+ }
@@ -2433,6 +2433,23 @@ export const WordReviewEditor = forwardRef<WordReviewEditorRef, WordReviewEditor
2433
2433
  document={documentElement}
2434
2434
  onReviewSidebarTrackedChanges={onReviewSidebarTrackedChanges}
2435
2435
  onReviewSidebarComments={onReviewSidebarComments}
2436
+ onScopeModeChangeRequested={(payload) => {
2437
+ onEventRef.current?.({
2438
+ type: "scope-mode-change-requested",
2439
+ documentId,
2440
+ scopeId: payload.scopeId,
2441
+ mode: payload.mode,
2442
+ });
2443
+ }}
2444
+ onScopeIssueActionRequested={(payload) => {
2445
+ onEventRef.current?.({
2446
+ type: "scope-issue-action-requested",
2447
+ documentId,
2448
+ scopeId: payload.scopeId,
2449
+ issueId: payload.issueId,
2450
+ action: payload.action,
2451
+ });
2452
+ }}
2436
2453
  />
2437
2454
  );
2438
2455
  },
@@ -87,6 +87,24 @@ export interface EditorShellViewProps {
87
87
  onReviewSidebarTrackedChanges?: () => void;
88
88
  /** Review-role sidebar panel: open sidebar to comments panel. */
89
89
  onReviewSidebarComments?: () => void;
90
+ /**
91
+ * Scope card mode selector fired a mode change (forwarded from the
92
+ * workspace). The editor turns this into a
93
+ * `scope-mode-change-requested` event.
94
+ */
95
+ onScopeModeChangeRequested?: (payload: {
96
+ scopeId: string;
97
+ mode: import("../api/public-types.ts").WorkflowScopeMode;
98
+ }) => void;
99
+ /**
100
+ * Scope card issue action fired (forwarded from the workspace).
101
+ * The editor turns this into a `scope-issue-action-requested` event.
102
+ */
103
+ onScopeIssueActionRequested?: (payload: {
104
+ scopeId: string;
105
+ issueId: string;
106
+ action: import("../api/public-types.ts").ScopeIssueAction;
107
+ }) => void;
90
108
  }
91
109
 
92
110
  export function EditorShellView(props: EditorShellViewProps) {
@@ -0,0 +1,80 @@
1
+ import React, { type ReactNode } from "react";
2
+
3
+ export interface TwModeDockAction {
4
+ id: string;
5
+ label: string;
6
+ icon: ReactNode;
7
+ onClick: () => void;
8
+ isActive?: boolean;
9
+ }
10
+
11
+ export interface TwModeDockProps {
12
+ label: string;
13
+ icon?: ReactNode;
14
+ actions?: readonly TwModeDockAction[];
15
+ className?: string;
16
+ }
17
+
18
+ const focusRingClass =
19
+ "focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-1 focus-visible:ring-offset-canvas";
20
+
21
+ export function TwModeDock(props: TwModeDockProps) {
22
+ const actions = (props.actions ?? []).slice(0, 3);
23
+
24
+ const className = [
25
+ "pointer-events-auto fixed bottom-4 left-1/2 z-40 -translate-x-1/2",
26
+ "flex h-9 items-center gap-2 rounded-[var(--radius-pill)] border border-border bg-canvas px-2 py-1",
27
+ "shadow-[var(--shadow-float)] backdrop-blur-sm",
28
+ "transition-opacity duration-[var(--motion-fast)]",
29
+ props.className,
30
+ ]
31
+ .filter(Boolean)
32
+ .join(" ");
33
+
34
+ return (
35
+ <div
36
+ role="toolbar"
37
+ aria-label="Mode dock"
38
+ className={className}
39
+ data-testid="tw-mode-dock"
40
+ >
41
+ <div className="flex items-center gap-1.5 pl-1 pr-1.5">
42
+ {props.icon ? (
43
+ <span aria-hidden="true" className="flex h-3.5 w-3.5 items-center text-tertiary">
44
+ {props.icon}
45
+ </span>
46
+ ) : null}
47
+ <span
48
+ className="text-[10px] font-semibold uppercase tracking-[0.14em] text-secondary"
49
+ data-testid="tw-mode-dock__label"
50
+ >
51
+ {props.label}
52
+ </span>
53
+ </div>
54
+ {actions.length > 0 ? (
55
+ <div className="flex items-center gap-0.5 border-l border-border/70 pl-1.5">
56
+ {actions.map((action) => (
57
+ <button
58
+ key={action.id}
59
+ type="button"
60
+ onClick={action.onClick}
61
+ aria-label={action.label}
62
+ aria-pressed={action.isActive ?? false}
63
+ title={action.label}
64
+ className={[
65
+ "inline-flex h-7 w-7 items-center justify-center rounded-[var(--radius-control)] transition-colors",
66
+ action.isActive
67
+ ? "bg-accent-soft text-accent"
68
+ : "text-tertiary hover:bg-surface-hover hover:text-primary",
69
+ focusRingClass,
70
+ ].join(" ")}
71
+ data-testid={`tw-mode-dock__action-${action.id}`}
72
+ >
73
+ <span aria-hidden="true">{action.icon}</span>
74
+ </button>
75
+ ))}
76
+ </div>
77
+ ) : null}
78
+ </div>
79
+ );
80
+ }