@stigmer/react 3.1.2 → 3.1.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (285) hide show
  1. package/composer/SessionComposer.d.ts +37 -5
  2. package/composer/SessionComposer.d.ts.map +1 -1
  3. package/composer/SessionComposer.js +13 -5
  4. package/composer/SessionComposer.js.map +1 -1
  5. package/execution/ArtifactApplyButton.d.ts +25 -0
  6. package/execution/ArtifactApplyButton.d.ts.map +1 -0
  7. package/execution/ArtifactApplyButton.js +21 -0
  8. package/execution/ArtifactApplyButton.js.map +1 -0
  9. package/execution/ArtifactContentBody.d.ts +36 -0
  10. package/execution/ArtifactContentBody.d.ts.map +1 -0
  11. package/execution/ArtifactContentBody.js +62 -0
  12. package/execution/ArtifactContentBody.js.map +1 -0
  13. package/execution/ArtifactDocument.d.ts +48 -0
  14. package/execution/ArtifactDocument.d.ts.map +1 -0
  15. package/execution/ArtifactDocument.js +89 -0
  16. package/execution/ArtifactDocument.js.map +1 -0
  17. package/execution/ArtifactPreviewModal.d.ts +9 -9
  18. package/execution/ArtifactPreviewModal.d.ts.map +1 -1
  19. package/execution/ArtifactPreviewModal.js +18 -145
  20. package/execution/ArtifactPreviewModal.js.map +1 -1
  21. package/execution/ArtifactRow.d.ts +49 -0
  22. package/execution/ArtifactRow.d.ts.map +1 -0
  23. package/execution/ArtifactRow.js +56 -0
  24. package/execution/ArtifactRow.js.map +1 -0
  25. package/execution/ArtifactsWidget.d.ts +6 -9
  26. package/execution/ArtifactsWidget.d.ts.map +1 -1
  27. package/execution/ArtifactsWidget.js +15 -19
  28. package/execution/ArtifactsWidget.js.map +1 -1
  29. package/execution/FileChangeProgressBar.d.ts +47 -0
  30. package/execution/FileChangeProgressBar.d.ts.map +1 -0
  31. package/execution/FileChangeProgressBar.js +49 -0
  32. package/execution/FileChangeProgressBar.js.map +1 -0
  33. package/execution/FileChangesView.d.ts.map +1 -1
  34. package/execution/FileChangesView.js +19 -4
  35. package/execution/FileChangesView.js.map +1 -1
  36. package/execution/FileReviewAtoms.d.ts +27 -0
  37. package/execution/FileReviewAtoms.d.ts.map +1 -0
  38. package/execution/FileReviewAtoms.js +64 -0
  39. package/execution/FileReviewAtoms.js.map +1 -0
  40. package/execution/FileReviewCard.d.ts +2 -2
  41. package/execution/FileReviewCard.d.ts.map +1 -1
  42. package/execution/FileReviewCard.js +32 -62
  43. package/execution/FileReviewCard.js.map +1 -1
  44. package/execution/MessageEntry.d.ts +0 -9
  45. package/execution/MessageEntry.d.ts.map +1 -1
  46. package/execution/MessageEntry.js +1 -11
  47. package/execution/MessageEntry.js.map +1 -1
  48. package/execution/MessageThread.d.ts +3 -4
  49. package/execution/MessageThread.d.ts.map +1 -1
  50. package/execution/MessageThread.js +25 -11
  51. package/execution/MessageThread.js.map +1 -1
  52. package/execution/PlanArtifactCard.d.ts +16 -10
  53. package/execution/PlanArtifactCard.d.ts.map +1 -1
  54. package/execution/PlanArtifactCard.js +31 -7
  55. package/execution/PlanArtifactCard.js.map +1 -1
  56. package/execution/PlanCompletionCard.d.ts +4 -4
  57. package/execution/PlanCompletionCard.d.ts.map +1 -1
  58. package/execution/PlanCompletionCard.js +4 -4
  59. package/execution/PlanCompletionCard.js.map +1 -1
  60. package/execution/PlanStreamingCard.d.ts +7 -2
  61. package/execution/PlanStreamingCard.d.ts.map +1 -1
  62. package/execution/PlanStreamingCard.js +11 -6
  63. package/execution/PlanStreamingCard.js.map +1 -1
  64. package/execution/index.d.ts +12 -2
  65. package/execution/index.d.ts.map +1 -1
  66. package/execution/index.js +6 -1
  67. package/execution/index.js.map +1 -1
  68. package/execution/use-build-from-plan-hotkey.d.ts +1 -1
  69. package/execution/use-build-from-plan-hotkey.js +1 -1
  70. package/execution/useArtifactContent.d.ts +6 -1
  71. package/execution/useArtifactContent.d.ts.map +1 -1
  72. package/execution/useArtifactContent.js +7 -2
  73. package/execution/useArtifactContent.js.map +1 -1
  74. package/execution/useArtifactCopy.d.ts +51 -0
  75. package/execution/useArtifactCopy.d.ts.map +1 -0
  76. package/execution/useArtifactCopy.js +75 -0
  77. package/execution/useArtifactCopy.js.map +1 -0
  78. package/execution/useArtifactDownload.d.ts.map +1 -1
  79. package/execution/useArtifactDownload.js +17 -5
  80. package/execution/useArtifactDownload.js.map +1 -1
  81. package/execution/useArtifactInspection.d.ts +85 -0
  82. package/execution/useArtifactInspection.d.ts.map +1 -0
  83. package/execution/useArtifactInspection.js +143 -0
  84. package/execution/useArtifactInspection.js.map +1 -0
  85. package/execution/useCreateAgentExecution.d.ts +16 -3
  86. package/execution/useCreateAgentExecution.d.ts.map +1 -1
  87. package/execution/useCreateAgentExecution.js +1 -0
  88. package/execution/useCreateAgentExecution.js.map +1 -1
  89. package/index.d.ts +4 -4
  90. package/index.d.ts.map +1 -1
  91. package/index.js +3 -3
  92. package/index.js.map +1 -1
  93. package/internal/MermaidDiagram.d.ts +47 -0
  94. package/internal/MermaidDiagram.d.ts.map +1 -0
  95. package/internal/MermaidDiagram.js +118 -0
  96. package/internal/MermaidDiagram.js.map +1 -0
  97. package/internal/file-tree/FileTreeNode.d.ts.map +1 -1
  98. package/internal/file-tree/FileTreeNode.js +2 -2
  99. package/internal/file-tree/FileTreeNode.js.map +1 -1
  100. package/internal/markdown-components.d.ts.map +1 -1
  101. package/internal/markdown-components.js +34 -0
  102. package/internal/markdown-components.js.map +1 -1
  103. package/internal/mermaid-loader.d.ts +26 -0
  104. package/internal/mermaid-loader.d.ts.map +1 -0
  105. package/internal/mermaid-loader.js +34 -0
  106. package/internal/mermaid-loader.js.map +1 -0
  107. package/internal/mermaid-theme-css.d.ts +41 -0
  108. package/internal/mermaid-theme-css.d.ts.map +1 -0
  109. package/internal/mermaid-theme-css.js +179 -0
  110. package/internal/mermaid-theme-css.js.map +1 -0
  111. package/internal/slug.d.ts +9 -5
  112. package/internal/slug.d.ts.map +1 -1
  113. package/internal/slug.js +10 -6
  114. package/internal/slug.js.map +1 -1
  115. package/library/detect-plan-artifact.d.ts +27 -9
  116. package/library/detect-plan-artifact.d.ts.map +1 -1
  117. package/library/detect-plan-artifact.js +26 -6
  118. package/library/detect-plan-artifact.js.map +1 -1
  119. package/library/index.d.ts +1 -1
  120. package/library/index.d.ts.map +1 -1
  121. package/library/index.js +1 -1
  122. package/library/index.js.map +1 -1
  123. package/package.json +5 -4
  124. package/session/PlanEditor.js +1 -1
  125. package/session/PlanEditor.js.map +1 -1
  126. package/session/SessionViewer.d.ts.map +1 -1
  127. package/session/SessionViewer.js +122 -30
  128. package/session/SessionViewer.js.map +1 -1
  129. package/session/artifact-document.d.ts +13 -0
  130. package/session/artifact-document.d.ts.map +1 -0
  131. package/session/artifact-document.js +22 -0
  132. package/session/artifact-document.js.map +1 -0
  133. package/session/facets/ArtifactsTab.d.ts +30 -4
  134. package/session/facets/ArtifactsTab.d.ts.map +1 -1
  135. package/session/facets/ArtifactsTab.js +32 -10
  136. package/session/facets/ArtifactsTab.js.map +1 -1
  137. package/session/index.d.ts +2 -1
  138. package/session/index.d.ts.map +1 -1
  139. package/session/index.js +4 -1
  140. package/session/index.js.map +1 -1
  141. package/session/plan-document.d.ts +14 -2
  142. package/session/plan-document.d.ts.map +1 -1
  143. package/session/plan-document.js +14 -3
  144. package/session/plan-document.js.map +1 -1
  145. package/session/usePlanDraft.d.ts +1 -1
  146. package/session/useSessionArtifacts.d.ts +17 -0
  147. package/session/useSessionArtifacts.d.ts.map +1 -1
  148. package/session/useSessionArtifacts.js +16 -5
  149. package/session/useSessionArtifacts.js.map +1 -1
  150. package/session/useSessionConversation.d.ts +26 -3
  151. package/session/useSessionConversation.d.ts.map +1 -1
  152. package/session/useSessionConversation.js +51 -6
  153. package/session/useSessionConversation.js.map +1 -1
  154. package/session/useSessionPageFlow.d.ts.map +1 -1
  155. package/session/useSessionPageFlow.js +1 -0
  156. package/session/useSessionPageFlow.js.map +1 -1
  157. package/session/useSessionPanel.d.ts +20 -0
  158. package/session/useSessionPanel.d.ts.map +1 -1
  159. package/session/useSessionPanel.js +13 -0
  160. package/session/useSessionPanel.js.map +1 -1
  161. package/session/useSessionRailViews.d.ts +13 -1
  162. package/session/useSessionRailViews.d.ts.map +1 -1
  163. package/session/useSessionRailViews.js +5 -3
  164. package/session/useSessionRailViews.js.map +1 -1
  165. package/src/composer/SessionComposer.tsx +76 -7
  166. package/src/composer/__tests__/SessionComposer-editing.test.tsx +128 -0
  167. package/src/execution/ArtifactApplyButton.tsx +79 -0
  168. package/src/execution/ArtifactContentBody.tsx +255 -0
  169. package/src/execution/ArtifactDocument.tsx +344 -0
  170. package/src/execution/ArtifactPreviewModal.tsx +49 -415
  171. package/src/execution/ArtifactRow.tsx +165 -0
  172. package/src/execution/ArtifactsWidget.tsx +24 -33
  173. package/src/execution/FileChangeProgressBar.tsx +121 -0
  174. package/src/execution/FileChangesView.tsx +18 -4
  175. package/src/execution/FileReviewAtoms.tsx +89 -0
  176. package/src/execution/FileReviewCard.tsx +57 -90
  177. package/src/execution/MessageEntry.tsx +0 -62
  178. package/src/execution/MessageThread.tsx +29 -15
  179. package/src/execution/PlanArtifactCard.tsx +130 -52
  180. package/src/execution/PlanCompletionCard.tsx +5 -5
  181. package/src/execution/PlanStreamingCard.tsx +15 -11
  182. package/src/execution/__tests__/ArtifactContentBody.test.tsx +131 -0
  183. package/src/execution/__tests__/ArtifactDocument.test.tsx +100 -0
  184. package/src/execution/__tests__/ArtifactPreviewModal.test.tsx +6 -6
  185. package/src/execution/__tests__/ArtifactRow.test.tsx +139 -0
  186. package/src/execution/__tests__/ArtifactsWidget.test.tsx +78 -0
  187. package/src/execution/__tests__/FileChangeProgressBar.test.tsx +125 -0
  188. package/src/execution/__tests__/FileReviewCard.test.tsx +66 -7
  189. package/src/execution/__tests__/MessageThread.test.tsx +1 -1
  190. package/src/execution/__tests__/PlanArtifactCard.test.tsx +52 -18
  191. package/src/execution/__tests__/PlanCompletionCard.test.tsx +2 -2
  192. package/src/execution/__tests__/PlanStreamingCard.test.tsx +11 -4
  193. package/src/execution/__tests__/buildThreadItems-plan-completion.test.ts +104 -8
  194. package/src/execution/__tests__/message-entry.test.tsx +56 -39
  195. package/src/execution/__tests__/useArtifactCopy.test.tsx +90 -0
  196. package/src/execution/__tests__/useArtifactDownload.test.tsx +2 -0
  197. package/src/execution/__tests__/useArtifactInspection.test.tsx +151 -0
  198. package/src/execution/__tests__/useCreateAgentExecution.test.tsx +35 -0
  199. package/src/execution/index.ts +20 -2
  200. package/src/execution/use-build-from-plan-hotkey.ts +1 -1
  201. package/src/execution/useArtifactContent.ts +7 -0
  202. package/src/execution/useArtifactCopy.ts +109 -0
  203. package/src/execution/useArtifactDownload.ts +17 -5
  204. package/src/execution/useArtifactInspection.ts +252 -0
  205. package/src/execution/useCreateAgentExecution.ts +17 -3
  206. package/src/index.ts +17 -2
  207. package/src/internal/MermaidDiagram.tsx +164 -0
  208. package/src/internal/__tests__/a11y/mermaid-theme.a11y.test.tsx +122 -0
  209. package/src/internal/__tests__/markdown-components.test.tsx +93 -1
  210. package/src/internal/__tests__/mermaid-diagram.test.tsx +282 -0
  211. package/src/internal/file-tree/FileTreeNode.tsx +13 -2
  212. package/src/internal/markdown-components.tsx +36 -1
  213. package/src/internal/mermaid-loader.ts +44 -0
  214. package/src/internal/mermaid-theme-css.ts +178 -0
  215. package/src/internal/slug.ts +10 -6
  216. package/src/library/__tests__/detect-plan-artifact.test.ts +21 -0
  217. package/src/library/detect-plan-artifact.ts +32 -10
  218. package/src/library/index.ts +2 -0
  219. package/src/session/PlanEditor.tsx +1 -1
  220. package/src/session/SessionViewer.tsx +227 -51
  221. package/src/session/__tests__/PlanEditor.test.tsx +4 -4
  222. package/src/session/__tests__/PlanStreamingDocument.test.tsx +1 -1
  223. package/src/session/__tests__/SessionViewer-artifactTabs.test.tsx +210 -0
  224. package/src/session/__tests__/SessionViewer-editResubmit.test.tsx +233 -0
  225. package/src/session/__tests__/SessionViewer-fileReviewDock.test.tsx +57 -0
  226. package/src/session/__tests__/useSessionArtifacts.test.ts +57 -0
  227. package/src/session/__tests__/useSessionConversation.test.tsx +216 -1
  228. package/src/session/__tests__/useSessionPanel.test.ts +70 -1
  229. package/src/session/artifact-document.ts +23 -0
  230. package/src/session/facets/ArtifactsTab.tsx +66 -18
  231. package/src/session/facets/__tests__/ArtifactsTab.test.tsx +111 -47
  232. package/src/session/index.ts +5 -1
  233. package/src/session/plan-document.ts +14 -3
  234. package/src/session/usePlanDraft.ts +1 -1
  235. package/src/session/useSessionArtifacts.ts +16 -5
  236. package/src/session/useSessionConversation.ts +80 -10
  237. package/src/session/useSessionPageFlow.ts +1 -0
  238. package/src/session/useSessionPanel.ts +41 -0
  239. package/src/session/useSessionRailViews.tsx +21 -1
  240. package/src/workspace/EditorTabs.tsx +38 -9
  241. package/src/workspace/ExplorerTree.tsx +10 -4
  242. package/src/workspace/FileViewer.tsx +27 -6
  243. package/src/workspace/WorkspaceContentSearch.tsx +44 -20
  244. package/src/workspace/WorkspaceEditor.tsx +19 -16
  245. package/src/workspace/WorkspaceFileSearch.tsx +51 -28
  246. package/src/workspace/WorkspaceSurface.tsx +106 -48
  247. package/src/workspace/__tests__/EditorTabs.test.tsx +4 -2
  248. package/src/workspace/__tests__/ExplorerTree.test.tsx +52 -0
  249. package/src/workspace/__tests__/FileViewer.test.tsx +18 -0
  250. package/src/workspace/__tests__/WorkspaceContentSearch.test.tsx +38 -5
  251. package/src/workspace/__tests__/WorkspaceEditor-initialPanel.test.tsx +19 -0
  252. package/src/workspace/__tests__/WorkspaceFileSearch.test.tsx +27 -6
  253. package/src/workspace/__tests__/WorkspaceSurface.test.tsx +70 -0
  254. package/src/workspace/__tests__/a11y/harness.tsx +140 -0
  255. package/src/workspace/__tests__/a11y/mock-capabilities.ts +138 -0
  256. package/src/workspace/__tests__/a11y/workspace-editor.a11y.test.tsx +65 -0
  257. package/src/workspace/__tests__/a11y/workspace-explorer-search.a11y.test.tsx +112 -0
  258. package/src/workspace/__tests__/a11y/workspace-viewer-tabs.a11y.test.tsx +69 -0
  259. package/styles.css +1 -1
  260. package/workspace/EditorTabs.d.ts +21 -3
  261. package/workspace/EditorTabs.d.ts.map +1 -1
  262. package/workspace/EditorTabs.js +18 -6
  263. package/workspace/EditorTabs.js.map +1 -1
  264. package/workspace/ExplorerTree.js +8 -4
  265. package/workspace/ExplorerTree.js.map +1 -1
  266. package/workspace/FileViewer.d.ts.map +1 -1
  267. package/workspace/FileViewer.js +19 -8
  268. package/workspace/FileViewer.js.map +1 -1
  269. package/workspace/WorkspaceContentSearch.d.ts.map +1 -1
  270. package/workspace/WorkspaceContentSearch.js +23 -2
  271. package/workspace/WorkspaceContentSearch.js.map +1 -1
  272. package/workspace/WorkspaceEditor.d.ts.map +1 -1
  273. package/workspace/WorkspaceEditor.js +8 -10
  274. package/workspace/WorkspaceEditor.js.map +1 -1
  275. package/workspace/WorkspaceFileSearch.d.ts.map +1 -1
  276. package/workspace/WorkspaceFileSearch.js +23 -3
  277. package/workspace/WorkspaceFileSearch.js.map +1 -1
  278. package/workspace/WorkspaceSurface.d.ts.map +1 -1
  279. package/workspace/WorkspaceSurface.js +54 -18
  280. package/workspace/WorkspaceSurface.js.map +1 -1
  281. package/execution/ArtifactCard.d.ts +0 -81
  282. package/execution/ArtifactCard.d.ts.map +0 -1
  283. package/execution/ArtifactCard.js +0 -130
  284. package/execution/ArtifactCard.js.map +0 -1
  285. package/src/execution/ArtifactCard.tsx +0 -320
@@ -4,6 +4,17 @@ import { useCallback, type KeyboardEvent, type MouseEvent } from "react";
4
4
  import { cn } from "@stigmer/theme";
5
5
  import { editorKey, type OpenEditor } from "../internal/store/index.js";
6
6
 
7
+ /**
8
+ * Deterministic, space-free DOM id for a tab, built from an instance-scoped
9
+ * `prefix` (a `useId()` value) and the editor `key`. Shared by the tab (its
10
+ * `id`) and the editor body (its `aria-labelledby`) so the two associate; the
11
+ * key is percent-encoded because editor keys embed file paths that may contain
12
+ * characters (spaces) illegal in a space-separated IDREF.
13
+ */
14
+ export function editorTabDomId(prefix: string, key: string): string {
15
+ return `${prefix}${encodeURIComponent(key)}`;
16
+ }
17
+
7
18
  /** Props for {@link EditorTabs}. */
8
19
  export interface EditorTabsProps {
9
20
  /** Open editors in tab order. */
@@ -16,6 +27,14 @@ export interface EditorTabsProps {
16
27
  readonly onPin: (entryId: string, path: string) => void;
17
28
  /** Close an editor (close button or middle click). */
18
29
  readonly onClose: (entryId: string, path: string) => void;
30
+ /**
31
+ * DOM id of the editor body (`role="tabpanel"`). When provided together with
32
+ * {@link tabIdPrefix}, each tab gets a stable `id` and `aria-controls` linking
33
+ * it to that panel. Optional so the strip degrades gracefully without a panel.
34
+ */
35
+ readonly panelId?: string;
36
+ /** Instance-scoped prefix for per-tab ids (see {@link editorTabDomId}). */
37
+ readonly tabIdPrefix?: string;
19
38
  /** Additional CSS classes for the root element. */
20
39
  readonly className?: string;
21
40
  }
@@ -28,8 +47,10 @@ export interface EditorTabsProps {
28
47
  *
29
48
  * A `role="tablist"` of `role="tab"` items; the matching `tabpanel` is the
30
49
  * editor body rendered by the surface. Arrow keys move focus between tabs and
31
- * activate; the per-tab close button is a nested control. All visual properties
32
- * flow through `--stgm-*` tokens (DD-005).
50
+ * activate; Delete/Backspace closes the focused tab. The visual close "X" is a
51
+ * presentational (`aria-hidden`) mouse affordance — assistive tech closes via
52
+ * the keyboard, so the X is not a nested interactive control (WCAG 4.1.2). All
53
+ * visual properties flow through `--stgm-*` tokens (DD-005).
33
54
  */
34
55
  export function EditorTabs({
35
56
  editors,
@@ -37,6 +58,8 @@ export function EditorTabs({
37
58
  onActivate,
38
59
  onPin,
39
60
  onClose,
61
+ panelId,
62
+ tabIdPrefix,
40
63
  className,
41
64
  }: EditorTabsProps) {
42
65
  const move = useCallback(
@@ -94,7 +117,9 @@ export function EditorTabs({
94
117
  <div
95
118
  key={key}
96
119
  role="tab"
120
+ id={tabIdPrefix ? editorTabDomId(tabIdPrefix, key) : undefined}
97
121
  aria-selected={isActive}
122
+ aria-controls={panelId}
98
123
  tabIndex={isActive ? 0 : -1}
99
124
  title={editor.path}
100
125
  onClick={() => onActivate(editor.entryId, editor.path)}
@@ -112,22 +137,26 @@ export function EditorTabs({
112
137
  <span className={cn("truncate", editor.preview && "italic")}>
113
138
  {basename}
114
139
  </span>
115
- <button
116
- type="button"
140
+ {/* The visual close "X" is a presentational mouse affordance, NOT a
141
+ nested interactive control: a `<button>` inside `role="tab"` would
142
+ violate WCAG 4.1.2 (axe `nested-interactive`). Assistive tech and
143
+ keyboard users close the focused tab via Delete/Backspace (handled
144
+ above), so `aria-hidden` here removes the redundant nested control
145
+ without losing the close action for anyone. */}
146
+ <span
147
+ aria-hidden="true"
148
+ title={`Close ${basename}`}
117
149
  onClick={(e) => {
118
150
  e.stopPropagation();
119
151
  onClose(editor.entryId, editor.path);
120
152
  }}
121
- aria-label={`Close ${basename}`}
122
- tabIndex={-1}
123
153
  className={cn(
124
- "shrink-0 rounded p-0.5 text-muted-foreground transition-opacity hover:bg-accent-hover hover:text-foreground",
125
- "focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring",
154
+ "shrink-0 cursor-pointer rounded p-0.5 text-muted-foreground transition-opacity hover:bg-accent-hover hover:text-foreground",
126
155
  isActive ? "opacity-100" : "opacity-0 group-hover:opacity-100",
127
156
  )}
128
157
  >
129
158
  <CloseIcon />
130
- </button>
159
+ </span>
131
160
  </div>
132
161
  );
133
162
  })}
@@ -101,7 +101,10 @@ function ExplorerRoot({
101
101
  )}
102
102
  title={entry.type === "local" ? entry.localPath : entry.gitUrl}
103
103
  >
104
- <span className="text-[10px] text-muted-foreground-subtle">
104
+ <span
105
+ aria-hidden="true"
106
+ className="text-[10px] text-muted-foreground-subtle"
107
+ >
105
108
  {expanded ? "▼" : "▶"}
106
109
  </span>
107
110
  <span className="truncate">{entry.name}</span>
@@ -167,6 +170,10 @@ function ExplorerRootBody({
167
170
  if (isLoading) {
168
171
  return (
169
172
  <div
173
+ // role="status" both announces the loading state to assistive tech and
174
+ // makes aria-label permitted here (a role-less generic div cannot carry
175
+ // an accessible name — axe `aria-prohibited-attr`).
176
+ role="status"
170
177
  className="space-y-1.5 px-2 py-2"
171
178
  aria-busy="true"
172
179
  aria-label={`Loading files for ${entryName}`}
@@ -190,7 +197,7 @@ function ExplorerRootBody({
190
197
  <button
191
198
  type="button"
192
199
  onClick={onRetry}
193
- className="ml-2 underline transition-colors hover:text-destructive"
200
+ className="ml-2 rounded-sm underline transition-colors hover:text-destructive focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
194
201
  >
195
202
  Retry
196
203
  </button>
@@ -226,8 +233,7 @@ function ExplorerRootBody({
226
233
  </nav>
227
234
  {truncated && (
228
235
  <p className="px-3 py-1.5 text-[0.65rem] text-muted-foreground">
229
- Showing a partial listing — this repository has too many files to load
230
- in full.
236
+ Showing a partial listing — too many files to load in full.
231
237
  </p>
232
238
  )}
233
239
  </>
@@ -5,6 +5,7 @@ import {
5
5
  useCallback,
6
6
  useEffect,
7
7
  useImperativeHandle,
8
+ useRef,
8
9
  useState,
9
10
  type KeyboardEvent,
10
11
  } from "react";
@@ -194,7 +195,10 @@ export const FileViewer = forwardRef<FileViewerHandle, FileViewerProps>(
194
195
  <div
195
196
  role="region"
196
197
  aria-label="File viewer"
197
- className={cn("flex h-full flex-col", className)}
198
+ // Focusable (programmatically / on click) so the Escape-to-close handler
199
+ // fires even when the read-only body holds no natively focusable element.
200
+ tabIndex={-1}
201
+ className={cn("flex h-full flex-col focus:outline-none", className)}
198
202
  onKeyDown={handleKeyDown}
199
203
  >
200
204
  {showHeader && (
@@ -260,17 +264,26 @@ function ViewerModeToggle({
260
264
  { value: "file", label: "File" },
261
265
  ];
262
266
 
267
+ // Selection follows focus, matching ActivityRail / SearchModeToggle.
268
+ const buttonRefs = useRef<(HTMLButtonElement | null)[]>([]);
269
+ const selectValue = useCallback(
270
+ (next: "diff" | "file") => {
271
+ onChange(next);
272
+ buttonRefs.current[next === "diff" ? 0 : 1]?.focus();
273
+ },
274
+ [onChange],
275
+ );
263
276
  const handleKeyDown = useCallback(
264
277
  (e: KeyboardEvent<HTMLButtonElement>) => {
265
278
  if (e.key === "ArrowRight" || e.key === "ArrowDown") {
266
279
  e.preventDefault();
267
- onChange("file");
280
+ selectValue("file");
268
281
  } else if (e.key === "ArrowLeft" || e.key === "ArrowUp") {
269
282
  e.preventDefault();
270
- onChange("diff");
283
+ selectValue("diff");
271
284
  }
272
285
  },
273
- [onChange],
286
+ [selectValue],
274
287
  );
275
288
 
276
289
  return (
@@ -280,11 +293,14 @@ function ViewerModeToggle({
280
293
  aria-label="File view"
281
294
  className="inline-flex rounded-md bg-muted p-0.5"
282
295
  >
283
- {options.map((option) => {
296
+ {options.map((option, index) => {
284
297
  const isSelected = value === option.value;
285
298
  return (
286
299
  <button
287
300
  key={option.value}
301
+ ref={(el) => {
302
+ buttonRefs.current[index] = el;
303
+ }}
288
304
  type="button"
289
305
  role="radio"
290
306
  aria-checked={isSelected}
@@ -340,7 +356,12 @@ function FileViewerBody({
340
356
 
341
357
  if (isLoading) {
342
358
  return (
343
- <div className="space-y-2 p-4" aria-busy="true" aria-label="Loading file">
359
+ <div
360
+ role="status"
361
+ className="space-y-2 p-4"
362
+ aria-busy="true"
363
+ aria-label="Loading file"
364
+ >
344
365
  {SKELETON_LINE_WIDTHS.map((width, i) => (
345
366
  <div
346
367
  key={i}
@@ -161,8 +161,28 @@ export function WorkspaceContentSearch({
161
161
  const belowMinLength = trimmed.length > 0 && trimmed.length < MIN_QUERY_LENGTH;
162
162
  const showEntryHeaders = groups.length > 1;
163
163
 
164
+ // Screen-reader-only summary in a persistent polite live region: status swaps
165
+ // (searching → results/none) happen across separate unmounting branches, so a
166
+ // stable region is what actually gets announced. Terse by design.
167
+ const statusMessage = isUnsupported
168
+ ? ""
169
+ : trimmed.length === 0
170
+ ? ""
171
+ : belowMinLength
172
+ ? `Type at least ${MIN_QUERY_LENGTH} characters to search.`
173
+ : isLoading
174
+ ? "Searching file contents…"
175
+ : groups.length === 0
176
+ ? `No files containing ${trimmed}.`
177
+ : totalMatches > flatResults.length
178
+ ? `Showing the first ${flatResults.length} of ${totalMatches} matches.`
179
+ : `${totalMatches} ${totalMatches === 1 ? "match" : "matches"}.`;
180
+
164
181
  return (
165
182
  <div className={cn("flex h-full flex-col", className)}>
183
+ <div role="status" aria-live="polite" className="sr-only">
184
+ {statusMessage}
185
+ </div>
166
186
  <div className="flex items-center gap-1.5 border-b border-border px-2 py-1.5">
167
187
  <SearchIcon />
168
188
  <input
@@ -336,27 +356,31 @@ function LineRow({
336
356
  readonly isOpen: boolean;
337
357
  readonly onOpen: () => void;
338
358
  }) {
359
+ // The `role="option"` IS the interactive leaf of the listbox (keyboard
360
+ // activation flows through the combobox input's Enter handler +
361
+ // aria-activedescendant). It must not nest another interactive control — a
362
+ // button inside would violate WCAG 4.1.2 (axe `nested-interactive`). So the
363
+ // click handler and row styling live on the option itself.
339
364
  return (
340
- <li id={id} role="option" aria-selected={isFocused}>
341
- <button
342
- type="button"
343
- onClick={onOpen}
344
- tabIndex={-1}
345
- aria-current={isOpen ? "true" : undefined}
346
- className={cn(
347
- "flex w-full items-baseline gap-2 py-0.5 pl-6 pr-3 text-left text-xs transition-colors",
348
- isFocused && "bg-muted",
349
- isOpen && "bg-muted",
350
- !isFocused && !isOpen && "hover:bg-muted",
351
- )}
352
- >
353
- <span className="w-8 shrink-0 text-right font-mono text-[0.65rem] tabular-nums text-muted-foreground-subtle">
354
- {match.line}
355
- </span>
356
- <span className="min-w-0 flex-1 truncate font-mono text-muted-foreground">
357
- <HighlightedPreview text={match.preview} query={query} />
358
- </span>
359
- </button>
365
+ <li
366
+ id={id}
367
+ role="option"
368
+ aria-selected={isFocused}
369
+ aria-current={isOpen ? "true" : undefined}
370
+ onClick={onOpen}
371
+ className={cn(
372
+ "flex cursor-pointer items-baseline gap-2 py-0.5 pl-6 pr-3 text-xs transition-colors",
373
+ isFocused && "bg-muted",
374
+ isOpen && "bg-muted",
375
+ !isFocused && !isOpen && "hover:bg-muted",
376
+ )}
377
+ >
378
+ <span className="w-8 shrink-0 text-right font-mono text-[0.65rem] tabular-nums text-muted-foreground-subtle">
379
+ {match.line}
380
+ </span>
381
+ <span className="min-w-0 flex-1 truncate font-mono text-muted-foreground">
382
+ <HighlightedPreview text={match.preview} query={query} />
383
+ </span>
360
384
  </li>
361
385
  );
362
386
  }
@@ -1,6 +1,7 @@
1
1
  "use client";
2
2
 
3
3
  import { useState, useCallback, useEffect, type KeyboardEvent } from "react";
4
+ import { cn } from "@stigmer/theme";
4
5
  import type { UseWorkspaceEntriesReturn } from "./useWorkspaceEntries.js";
5
6
  import type { UseGitHubConnectionReturn } from "../github/useGitHubConnection.js";
6
7
  import { GitHubRepoPicker } from "../github/GitHubRepoPicker.js";
@@ -136,11 +137,11 @@ export function WorkspaceEditor({
136
137
 
137
138
  if (activePanel === "github" && enableGitHub) {
138
139
  return (
139
- <div className={["space-y-2", className].filter(Boolean).join(" ")}>
140
+ <div className={cn("space-y-2", className)}>
140
141
  <button
141
142
  type="button"
142
143
  onClick={goBack}
143
- className="inline-flex items-center gap-1 text-[0.65rem] text-muted-foreground hover:text-foreground transition-colors"
144
+ className="inline-flex items-center gap-1 rounded-sm text-[0.65rem] text-muted-foreground transition-colors hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
144
145
  >
145
146
  <ChevronLeftIcon />
146
147
  Back
@@ -180,7 +181,7 @@ export function WorkspaceEditor({
180
181
  // ---------------------------------------------------------------------------
181
182
 
182
183
  return (
183
- <div className={["space-y-2", className].filter(Boolean).join(" ")}>
184
+ <div className={cn("space-y-2", className)}>
184
185
  {/* Entry list */}
185
186
  {workspace.entries.length > 0 && (
186
187
  <div className="relative">
@@ -202,10 +203,10 @@ export function WorkspaceEditor({
202
203
  </span>
203
204
  )}
204
205
  <span
205
- className={[
206
+ className={cn(
206
207
  "min-w-0 flex-1 truncate text-foreground",
207
- entry.type === "local" ? "[direction:rtl] text-left" : "",
208
- ].join(" ")}
208
+ entry.type === "local" && "[direction:rtl] text-left",
209
+ )}
209
210
  title={entry.name}
210
211
  >
211
212
  <bdi>{entry.name}</bdi>
@@ -214,7 +215,7 @@ export function WorkspaceEditor({
214
215
  type="button"
215
216
  onClick={() => workspace.remove(entry.id)}
216
217
  disabled={disabled}
217
- className="shrink-0 text-muted-foreground hover:text-destructive disabled:pointer-events-none"
218
+ className="shrink-0 rounded-sm text-muted-foreground transition-colors hover:text-destructive focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:pointer-events-none"
218
219
  aria-label={`Remove ${entry.name}`}
219
220
  >
220
221
  <XIcon />
@@ -237,7 +238,7 @@ export function WorkspaceEditor({
237
238
  if (path) workspace.addLocalPath(path);
238
239
  }}
239
240
  disabled={disabled}
240
- className="flex w-full items-center gap-2 rounded-md px-2.5 py-2 text-xs text-foreground transition-colors hover:bg-accent-hover disabled:pointer-events-none disabled:opacity-40"
241
+ className="flex w-full items-center gap-2 rounded-md px-2.5 py-2 text-xs text-foreground transition-colors hover:bg-accent-hover focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-40"
241
242
  >
242
243
  <FolderIcon />
243
244
  <span className="flex-1 text-left">Browse Folder</span>
@@ -248,7 +249,7 @@ export function WorkspaceEditor({
248
249
  type="button"
249
250
  onClick={() => setActivePanel("github")}
250
251
  disabled={disabled}
251
- className="flex w-full items-center gap-2 rounded-md px-2.5 py-2 text-xs text-foreground transition-colors hover:bg-accent-hover disabled:pointer-events-none disabled:opacity-40"
252
+ className="flex w-full items-center gap-2 rounded-md px-2.5 py-2 text-xs text-foreground transition-colors hover:bg-accent-hover focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-40"
252
253
  >
253
254
  <GitHubIcon />
254
255
  <span className="flex-1 text-left">Connect GitHub</span>
@@ -307,14 +308,14 @@ function GitHubPanel({
307
308
  <button
308
309
  type="button"
309
310
  onClick={() => connection.connect(redirectUri, { popup: true })}
310
- className="rounded-md px-2.5 py-1 text-xs text-muted-foreground hover:text-foreground transition-colors"
311
+ className="rounded-md px-2.5 py-1 text-xs text-muted-foreground transition-colors hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
311
312
  >
312
313
  Try again
313
314
  </button>
314
315
  <button
315
316
  type="button"
316
317
  onClick={() => connection.connect(redirectUri)}
317
- className="inline-flex items-center gap-2 rounded-md bg-foreground px-3 py-1.5 text-xs text-background hover:bg-foreground-hover transition-colors"
318
+ className="inline-flex items-center gap-2 rounded-md bg-foreground px-3 py-1.5 text-xs text-background transition-colors hover:bg-foreground-hover focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
318
319
  >
319
320
  <GitHubIcon />
320
321
  <span>Continue with redirect</span>
@@ -325,7 +326,7 @@ function GitHubPanel({
325
326
  <button
326
327
  type="button"
327
328
  onClick={() => connection.connect(redirectUri, { popup: true })}
328
- className="inline-flex items-center gap-2 rounded-md bg-foreground px-3 py-1.5 text-xs text-background hover:bg-foreground-hover transition-colors"
329
+ className="inline-flex items-center gap-2 rounded-md bg-foreground px-3 py-1.5 text-xs text-background transition-colors hover:bg-foreground-hover focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
329
330
  >
330
331
  <GitHubIcon />
331
332
  <span>Connect GitHub</span>
@@ -353,7 +354,7 @@ function GitHubPanel({
353
354
  <button
354
355
  type="button"
355
356
  onClick={connection.disconnect}
356
- className="text-[0.6rem] text-muted-foreground hover:text-destructive transition-colors"
357
+ className="rounded-sm text-[0.6rem] text-muted-foreground transition-colors hover:text-destructive focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
357
358
  >
358
359
  Disconnect
359
360
  </button>
@@ -392,6 +393,7 @@ function ManualGitPanel({
392
393
  <div className="space-y-2">
393
394
  <input
394
395
  type="url"
396
+ aria-label="Git repository URL"
395
397
  placeholder="https://github.com/org/repo.git"
396
398
  value={url}
397
399
  onChange={(e) => onUrlChange(e.target.value)}
@@ -401,6 +403,7 @@ function ManualGitPanel({
401
403
  />
402
404
  <input
403
405
  type="text"
406
+ aria-label="Branch (optional)"
404
407
  placeholder="Branch (optional)"
405
408
  value={branch}
406
409
  onChange={(e) => onBranchChange(e.target.value)}
@@ -411,7 +414,7 @@ function ManualGitPanel({
411
414
  <button
412
415
  type="button"
413
416
  onClick={onCancel}
414
- className="rounded-md px-2.5 py-1 text-xs text-muted-foreground hover:text-foreground transition-colors"
417
+ className="rounded-md px-2.5 py-1 text-xs text-muted-foreground transition-colors hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
415
418
  >
416
419
  Cancel
417
420
  </button>
@@ -419,7 +422,7 @@ function ManualGitPanel({
419
422
  type="button"
420
423
  onClick={onAdd}
421
424
  disabled={!url.trim()}
422
- className="rounded-md bg-primary px-2.5 py-1 text-xs text-primary-foreground hover:bg-primary-hover transition-colors disabled:opacity-40"
425
+ className="rounded-md bg-primary px-2.5 py-1 text-xs text-primary-foreground transition-colors hover:bg-primary-hover focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:opacity-40"
423
426
  >
424
427
  Add
425
428
  </button>
@@ -453,7 +456,7 @@ function GitHubConnectingState({ onCancel }: { onCancel: () => void }) {
453
456
  <button
454
457
  type="button"
455
458
  onClick={onCancel}
456
- className="text-[0.65rem] text-muted-foreground hover:text-foreground transition-colors"
459
+ className="rounded-sm text-[0.65rem] text-muted-foreground transition-colors hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
457
460
  >
458
461
  Cancel
459
462
  </button>
@@ -132,8 +132,27 @@ export function WorkspaceFileSearch({
132
132
  const trimmed = query.trim();
133
133
  const showGroupHeaders = groups.length > 1;
134
134
 
135
+ // A concise, screen-reader-only summary of the current result state. It lives
136
+ // in a persistent polite live region (below) so status changes are announced
137
+ // even as the visible states — which live in separate, unmounting branches —
138
+ // swap. Deliberately terse: never the result list itself, only its shape.
139
+ const statusMessage = isUnsupported
140
+ ? ""
141
+ : trimmed.length === 0
142
+ ? ""
143
+ : isLoading && totalMatches === 0 && groups.length === 0
144
+ ? "Searching files…"
145
+ : groups.length === 0
146
+ ? `No files matching ${trimmed}.`
147
+ : totalMatches > flatResults.length
148
+ ? `Showing the first ${flatResults.length} of ${totalMatches} matching files.`
149
+ : `${flatResults.length} matching ${flatResults.length === 1 ? "file" : "files"}.`;
150
+
135
151
  return (
136
152
  <div className={cn("flex h-full flex-col", className)}>
153
+ <div role="status" aria-live="polite" className="sr-only">
154
+ {statusMessage}
155
+ </div>
137
156
  <div className="flex items-center gap-1.5 border-b border-border px-2 py-1.5">
138
157
  <SearchIcon />
139
158
  <input
@@ -264,7 +283,7 @@ function renderRows({
264
283
  if (group.truncated) {
265
284
  rows.push(
266
285
  <GroupNotice key={`t:${group.entry.id}`}>
267
- Partial listing — this repository has too many files to search in full.
286
+ Partial listing — too many files to search in full.
268
287
  </GroupNotice>,
269
288
  );
270
289
  }
@@ -299,39 +318,43 @@ function ResultRow({
299
318
  const basename = match.path.slice(lastSlash + 1);
300
319
  const dir = lastSlash >= 0 ? match.path.slice(0, lastSlash) : "";
301
320
 
321
+ // The `role="option"` IS the interactive leaf of the listbox (keyboard
322
+ // activation flows through the combobox input's Enter handler +
323
+ // aria-activedescendant). It must not nest another interactive control — a
324
+ // button inside would violate WCAG 4.1.2 (axe `nested-interactive`). So the
325
+ // click handler and row styling live on the option itself.
302
326
  return (
303
- <li id={id} role="option" aria-selected={isFocused}>
304
- <button
305
- type="button"
306
- onClick={onOpen}
307
- tabIndex={-1}
308
- aria-current={isOpen ? "true" : undefined}
309
- className={cn(
310
- "flex w-full items-baseline gap-1.5 px-3 py-1 text-left text-xs transition-colors",
311
- isFocused && "bg-muted",
312
- isOpen && "bg-muted font-medium text-foreground",
313
- !isFocused && !isOpen && "hover:bg-muted",
314
- )}
315
- >
316
- <span className="truncate text-foreground">
327
+ <li
328
+ id={id}
329
+ role="option"
330
+ aria-selected={isFocused}
331
+ aria-current={isOpen ? "true" : undefined}
332
+ onClick={onOpen}
333
+ className={cn(
334
+ "flex cursor-pointer items-baseline gap-1.5 px-3 py-1 text-xs transition-colors",
335
+ isFocused && "bg-muted",
336
+ isOpen && "bg-muted font-medium text-foreground",
337
+ !isFocused && !isOpen && "hover:bg-muted",
338
+ )}
339
+ >
340
+ <span className="truncate text-foreground">
341
+ <HighlightedPath
342
+ text={basename}
343
+ offset={lastSlash + 1}
344
+ matchStart={match.matchStart}
345
+ matchEnd={match.matchEnd}
346
+ />
347
+ </span>
348
+ {dir && (
349
+ <span className="truncate text-[0.65rem] text-muted-foreground">
317
350
  <HighlightedPath
318
- text={basename}
319
- offset={lastSlash + 1}
351
+ text={dir}
352
+ offset={0}
320
353
  matchStart={match.matchStart}
321
354
  matchEnd={match.matchEnd}
322
355
  />
323
356
  </span>
324
- {dir && (
325
- <span className="truncate text-[0.65rem] text-muted-foreground">
326
- <HighlightedPath
327
- text={dir}
328
- offset={0}
329
- matchStart={match.matchStart}
330
- matchEnd={match.matchEnd}
331
- />
332
- </span>
333
- )}
334
- </button>
357
+ )}
335
358
  </li>
336
359
  );
337
360
  }