@elabs-ai/components-ui 4.0.0 → 4.1.0

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 (223) hide show
  1. package/README.md +10 -8
  2. package/dist/chunk-JKPPTL63.js +48 -0
  3. package/dist/chunk-JKPPTL63.js.map +1 -0
  4. package/dist/form.d.ts +28 -0
  5. package/dist/form.js +123 -0
  6. package/dist/form.js.map +1 -0
  7. package/dist/index.d.ts +2485 -167
  8. package/dist/index.js +10716 -2706
  9. package/dist/index.js.map +1 -1
  10. package/package.json +21 -7
  11. package/src/_peer-deps.test.ts +59 -0
  12. package/src/blocks-comparison-table.stories.tsx +2 -2
  13. package/src/components/accordion/accordion.tsx +1 -1
  14. package/src/components/advanced-group/advanced-group.tsx +1 -1
  15. package/src/components/alert/alert.tsx +4 -1
  16. package/src/components/app-shell/app-shell.stories.tsx +44 -2
  17. package/src/components/app-shell/app-shell.tsx +35 -2
  18. package/src/components/app-sidebar/app-sidebar.stories.tsx +9 -1
  19. package/src/components/attribution-panel/attribution-panel.tsx +1 -1
  20. package/src/components/attribution-panel/attributions.generated.ts +81 -77
  21. package/src/components/badge/badge.stories.tsx +8 -0
  22. package/src/components/badge/badge.tsx +1 -1
  23. package/src/components/bento-grid/bento-grid.test.tsx +1 -1
  24. package/src/components/bento-grid/bento-grid.tsx +1 -1
  25. package/src/components/breadcrumb/breadcrumb.stories.tsx +38 -0
  26. package/src/components/breadcrumb/breadcrumb.test.tsx +24 -1
  27. package/src/components/breadcrumb/breadcrumb.tsx +7 -2
  28. package/src/components/button/button.stories.tsx +116 -8
  29. package/src/components/button/button.tsx +1 -1
  30. package/src/components/card/card.stories.tsx +11 -6
  31. package/src/components/card/card.tsx +1 -1
  32. package/src/components/change-review/change-review.stories.tsx +145 -1
  33. package/src/components/change-review/change-review.test.tsx +143 -0
  34. package/src/components/change-review/change-review.tsx +185 -4
  35. package/src/components/checkbox/checkbox.tsx +1 -1
  36. package/src/components/color-picker/color-picker.tsx +6 -5
  37. package/src/components/command/command.stories.tsx +16 -6
  38. package/src/components/command/command.test.tsx +73 -0
  39. package/src/components/command/command.tsx +30 -7
  40. package/src/components/command/index.ts +1 -0
  41. package/src/components/command-trigger/command-trigger.stories.tsx +39 -0
  42. package/src/components/command-trigger/command-trigger.test.tsx +18 -0
  43. package/src/components/command-trigger/command-trigger.tsx +70 -0
  44. package/src/components/command-trigger/index.ts +1 -0
  45. package/src/components/context-rail/context-rail.stories.tsx +285 -0
  46. package/src/components/context-rail/context-rail.test.tsx +263 -0
  47. package/src/components/context-rail/context-rail.tsx +678 -0
  48. package/src/components/context-rail/index.ts +1 -0
  49. package/src/components/copyable-value/copyable-value.tsx +1 -1
  50. package/src/components/dialog/dialog.tsx +2 -2
  51. package/src/components/dropdown-menu/dropdown-menu.stories.tsx +1 -1
  52. package/src/components/empty-state/empty-state.test.tsx +26 -0
  53. package/src/components/empty-state/empty-state.tsx +6 -1
  54. package/src/components/expand-dialog/expand-dialog.tsx +1 -2
  55. package/src/components/field/field-context.ts +59 -0
  56. package/src/components/field/field.stories.tsx +142 -0
  57. package/src/components/field/field.test.tsx +415 -0
  58. package/src/components/field/field.tsx +340 -0
  59. package/src/components/field/index.ts +9 -0
  60. package/src/components/field-row/field-row.stories.tsx +85 -1
  61. package/src/components/field-row/field-row.test.tsx +145 -0
  62. package/src/components/field-row/field-row.tsx +48 -6
  63. package/src/components/file-upload/file-upload.stories.tsx +16 -0
  64. package/src/components/file-upload/file-upload.test.tsx +7 -2
  65. package/src/components/file-upload/file-upload.tsx +15 -4
  66. package/src/components/form/form.tsx +5 -2
  67. package/src/components/icon-button/icon-button.test.tsx +1 -1
  68. package/src/components/icon-button/icon-button.tsx +2 -2
  69. package/src/components/input/input.stories.tsx +121 -0
  70. package/src/components/input/input.test.tsx +13 -0
  71. package/src/components/input/input.tsx +2 -2
  72. package/src/components/input-group/input-group.stories.tsx +74 -0
  73. package/src/components/input-group/input-group.tsx +13 -4
  74. package/src/components/input-otp/input-otp.tsx +5 -1
  75. package/src/components/keyboard-shortcuts/index.ts +6 -0
  76. package/src/components/keyboard-shortcuts/keyboard-shortcuts.stories.tsx +177 -0
  77. package/src/components/keyboard-shortcuts/keyboard-shortcuts.test.tsx +73 -0
  78. package/src/components/keyboard-shortcuts/keyboard-shortcuts.tsx +203 -0
  79. package/src/components/link-preview/link-preview.tsx +1 -1
  80. package/src/components/locale-provider/index.ts +6 -1
  81. package/src/components/locale-provider/locale-provider.stories.tsx +89 -1
  82. package/src/components/locale-provider/locale-provider.test.tsx +257 -1
  83. package/src/components/locale-provider/locale-provider.tsx +63 -8
  84. package/src/components/locale-provider/messages.ts +501 -2
  85. package/src/components/match-highlight/match-highlight.stories.tsx +1 -1
  86. package/src/components/mention-input/mention-input-mirror.stories.tsx +9 -1
  87. package/src/components/mention-input/mention-input.tsx +8 -38
  88. package/src/components/mention-input/mention-value.ts +8 -2
  89. package/src/components/metric-card/metric-card.stories.tsx +5 -3
  90. package/src/components/metric-card/metric-card.tsx +21 -2
  91. package/src/components/model-picker/model-picker.stories.tsx +63 -3
  92. package/src/components/model-picker/model-picker.test.tsx +63 -0
  93. package/src/components/model-picker/model-picker.tsx +62 -36
  94. package/src/components/nav-main/nav-main.tsx +1 -1
  95. package/src/components/nav-notifications/nav-notifications.stories.tsx +110 -0
  96. package/src/components/nav-notifications/nav-notifications.tsx +57 -5
  97. package/src/components/navigation-menu/navigation-menu.stories.tsx +22 -0
  98. package/src/components/navigation-menu/navigation-menu.test.tsx +174 -0
  99. package/src/components/navigation-menu/navigation-menu.tsx +189 -12
  100. package/src/components/page-shell/page-shell.stories.tsx +163 -0
  101. package/src/components/page-shell/page-shell.test.tsx +229 -0
  102. package/src/components/page-shell/page-shell.tsx +108 -25
  103. package/src/components/pagination/pagination.test.tsx +90 -0
  104. package/src/components/pagination/pagination.tsx +9 -5
  105. package/src/components/radio-group/radio-group.tsx +1 -1
  106. package/src/components/rating/rating.tsx +1 -1
  107. package/src/components/resizable/resizable.stories.tsx +114 -2
  108. package/src/components/resizable/resizable.tsx +1 -1
  109. package/src/components/revision-timeline/revision-timeline.stories.tsx +8 -1
  110. package/src/components/revision-timeline/revision-timeline.test.tsx +26 -0
  111. package/src/components/revision-timeline/revision-timeline.tsx +46 -3
  112. package/src/components/schema-form/from-json-schema.test.ts +390 -0
  113. package/src/components/schema-form/from-json-schema.ts +375 -0
  114. package/src/components/schema-form/index.ts +81 -0
  115. package/src/components/schema-form/schema-form-spec.ts +698 -0
  116. package/src/components/schema-form/schema-form.stories.tsx +566 -0
  117. package/src/components/schema-form/schema-form.test.tsx +900 -0
  118. package/src/components/schema-form/schema-form.tsx +1545 -0
  119. package/src/components/section-header/section-header.stories.tsx +37 -0
  120. package/src/components/section-header/section-header.tsx +12 -1
  121. package/src/components/select/select.tsx +6 -2
  122. package/src/components/sheet/sheet.tsx +1 -1
  123. package/src/components/side-dock/index.ts +1 -0
  124. package/src/components/side-dock/side-dock.stories.tsx +137 -0
  125. package/src/components/side-dock/side-dock.test.tsx +273 -0
  126. package/src/components/side-dock/side-dock.tsx +502 -0
  127. package/src/components/sidebar/index.ts +1 -0
  128. package/src/components/sidebar/sidebar-frame.test.tsx +162 -0
  129. package/src/components/sidebar/sidebar.stories.tsx +421 -1
  130. package/src/components/sidebar/sidebar.test.tsx +36 -1
  131. package/src/components/sidebar/sidebar.tsx +245 -33
  132. package/src/components/skip-link/index.ts +1 -0
  133. package/src/components/skip-link/skip-link.stories.tsx +106 -0
  134. package/src/components/skip-link/skip-link.test.tsx +20 -0
  135. package/src/components/skip-link/skip-link.tsx +34 -0
  136. package/src/components/slider/slider.tsx +1 -1
  137. package/src/components/split-panel/index.ts +6 -1
  138. package/src/components/split-panel/split-panel.stories.tsx +18 -2
  139. package/src/components/split-panel/split-panel.test.tsx +20 -0
  140. package/src/components/split-panel/split-panel.tsx +28 -8
  141. package/src/components/state-panel/state-panel.stories.tsx +52 -0
  142. package/src/components/state-panel/state-panel.test.tsx +125 -0
  143. package/src/components/state-panel/state-panel.tsx +96 -17
  144. package/src/components/status-badge/status-badge.stories.tsx +8 -0
  145. package/src/components/switch/switch.tsx +1 -1
  146. package/src/components/table/table.stories.tsx +62 -0
  147. package/src/components/table/table.test.tsx +142 -1
  148. package/src/components/table/table.tsx +60 -1
  149. package/src/components/tabs/tabs.tsx +8 -2
  150. package/src/components/tag-input/tag-input.tsx +3 -2
  151. package/src/components/team-switcher/team-switcher.tsx +34 -5
  152. package/src/components/textarea/textarea.tsx +1 -1
  153. package/src/components/theme-switcher/theme-switcher.tsx +6 -2
  154. package/src/components/theme-switcher/use-theme-transition.ts +2 -2
  155. package/src/components/timeline/timeline.stories.tsx +27 -0
  156. package/src/components/timeline/timeline.tsx +11 -1
  157. package/src/components/toggle/toggle.tsx +1 -1
  158. package/src/components/toolbar/toolbar.stories.tsx +10 -2
  159. package/src/components/tree/tree.stories.tsx +2 -2
  160. package/src/components/tree/tree.tsx +14 -9
  161. package/src/components/typography/prose.tsx +1 -1
  162. package/src/components/typography/typography.stories.tsx +228 -0
  163. package/src/components/view-toolbar/view-toolbar.stories.tsx +41 -1
  164. package/src/components/view-toolbar/view-toolbar.test.tsx +33 -0
  165. package/src/components/view-toolbar/view-toolbar.tsx +37 -4
  166. package/src/components/virtual-select/virtual-select.test.tsx +1 -1
  167. package/src/components/wizard/wizard.tsx +1 -1
  168. package/src/components/workspace-picker/index.ts +3 -0
  169. package/src/components/workspace-picker/workspace-picker-state.ts +50 -0
  170. package/src/components/workspace-picker/workspace-picker.stories.tsx +126 -0
  171. package/src/components/workspace-picker/workspace-picker.test.tsx +207 -0
  172. package/src/components/workspace-picker/workspace-picker.tsx +172 -0
  173. package/src/illustrations/empty-list-illustration.tsx +26 -0
  174. package/src/illustrations/error-illustration.tsx +60 -0
  175. package/src/illustrations/first-run-illustration.tsx +27 -0
  176. package/src/illustrations/illustration-base.tsx +108 -0
  177. package/src/illustrations/illustrations.stories.tsx +94 -0
  178. package/src/illustrations/illustrations.test.tsx +98 -0
  179. package/src/illustrations/index.ts +16 -0
  180. package/src/illustrations/no-access-illustration.tsx +45 -0
  181. package/src/illustrations/no-results-illustration.tsx +46 -0
  182. package/src/illustrations/offline-illustration.tsx +37 -0
  183. package/src/illustrations/success-illustration.tsx +40 -0
  184. package/src/index.ts +75 -2
  185. package/src/lib/agent-event-model.test.ts +12 -0
  186. package/src/lib/agent-event-model.ts +42 -0
  187. package/src/lib/approval-option.test.ts +17 -0
  188. package/src/lib/approval-option.ts +53 -0
  189. package/src/lib/check-result.ts +33 -0
  190. package/src/lib/diff-line.test.ts +36 -0
  191. package/src/lib/diff-line.ts +63 -0
  192. package/src/lib/diff-rows.test.ts +76 -0
  193. package/src/lib/diff-rows.ts +92 -0
  194. package/src/lib/format-duration.test.ts +22 -0
  195. package/src/lib/format-duration.ts +30 -0
  196. package/src/lib/has-renderable-content.test.ts +49 -0
  197. package/src/lib/has-renderable-content.ts +32 -0
  198. package/src/lib/operating-mode.test.ts +36 -0
  199. package/src/lib/operating-mode.ts +81 -0
  200. package/src/lib/optional-peer.ts +59 -0
  201. package/src/lib/session-launch.ts +37 -0
  202. package/src/lib/slash-command.test.ts +44 -0
  203. package/src/lib/slash-command.ts +45 -0
  204. package/src/lib/trigger-query.test.ts +94 -0
  205. package/src/lib/trigger-query.ts +88 -0
  206. package/src/lib/use-mobile.ts +12 -6
  207. package/src/motion.stories.tsx +6 -0
  208. package/src/templates-object-detail-hub.stories.tsx +2 -2
  209. package/src/templates-screen-states.stories.tsx +5 -5
  210. package/src/templates-settings.stories.tsx +1 -1
  211. package/src/blocks/sidebar-02/app-sidebar.tsx +0 -160
  212. package/src/blocks/sidebar-02/logo.tsx +0 -18
  213. package/src/blocks/sidebar-02/nav-main.tsx +0 -13
  214. package/src/blocks/sidebar-02/nav-notifications.tsx +0 -7
  215. package/src/blocks/sidebar-02/sidebar-02.stories.tsx +0 -23
  216. package/src/blocks/sidebar-02/team-switcher.tsx +0 -7
  217. package/src/blocks/sidebar-04/app-sidebar.tsx +0 -236
  218. package/src/blocks/sidebar-04/mail-context.tsx +0 -29
  219. package/src/blocks/sidebar-04/nav-user.tsx +0 -6
  220. package/src/blocks/sidebar-04/sidebar-04.stories.tsx +0 -150
  221. package/src/blocks/sidebar-05/app-sidebar.tsx +0 -375
  222. package/src/blocks/sidebar-05/sidebar-05.stories.tsx +0 -18
  223. package/src/blocks/sidebar-05/team-switcher.tsx +0 -6
@@ -11,6 +11,27 @@
11
11
  * Design references: GitHub PR file-review (per-hunk viewed/comment),
12
12
  * Cursor/Copilot "review changes" panel, Graphite/Linear review UIs.
13
13
  *
14
+ * NOT a diff-line renderer, and deliberately never becoming one. A hunk's
15
+ * `before`/`after` are ordinary `ReactNode` prose, drawn as a quiet no-fill
16
+ * comparison; the `−`/`+` markers below mean "previous content"/"proposed
17
+ * content", which is not the same idea as `DiffLine`'s "removed line"/"added
18
+ * line" even though the glyphs coincide. Do NOT reach for `diffLineMarker` here:
19
+ * a coincidence stays a literal (theming.md, intentional-mirror rule).
20
+ *
21
+ * Structured `DiffLine[]` hunks reach this component by INJECTION, which is a
22
+ * binding decision, not an unfinished merge — see
23
+ * `docs/decisions/2026-09-01-brainless-adoption-architecture.md` § 3:
24
+ * - `ChangeHunk` gains NO `lines` field and `DiffLine` never moves here.
25
+ * - The app that depends on both packages passes a `<DiffView>`
26
+ * (`@elabs-ai/components-ai`, Shiki-backed) through `renderHunk` or a hunk's
27
+ * `after` slot. Neither package imports the other, in either direction.
28
+ * - Re-litigated and re-ratified 2026-09-03: moving this component into
29
+ * `@elabs-ai/components-ai` was rejected because it would push a
30
+ * dependency-free component into a layer-2 leaf that `terminal`, `editor`
31
+ * and `viewer` could then never reach.
32
+ * See the `InjectedHunkRenderer` story here, and `ChangeReviewComposition` in
33
+ * `packages/ai/src/diff-view.stories.tsx` for the real two-package wiring.
34
+ *
14
35
  * State grid: empty (no hunks → StatePanel), single hunk, mixed, all-approved,
15
36
  * all-rejected, very long hunk content. Approval state is signalled with color
16
37
  * AND icon/label (colorblind + high-decoration/high-contrast safe).
@@ -36,12 +57,16 @@ import {
36
57
  type HTMLAttributes,
37
58
  type ReactNode,
38
59
  } from "react";
39
- import { Check, X, CheckCheck, User, Bot, Clock } from "lucide-react";
60
+ import { Check, X, CheckCheck, User, Bot, Clock, ChevronDown } from "lucide-react";
40
61
  import { cva, type VariantProps } from "class-variance-authority";
41
62
  import { cn } from "../../lib/cn";
63
+ import { type CheckResult } from "../../lib/check-result";
64
+ import { formatElapsed } from "../../lib/format-duration";
42
65
  import { Button } from "../button/button";
43
66
  import { Badge } from "../badge/badge";
44
67
  import { StatePanel } from "../state-panel/state-panel";
68
+ import { StatusIcon } from "../status-badge/status-badge";
69
+ import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "../collapsible/collapsible";
45
70
 
46
71
  // ─── Public types ─────────────────────────────────────────────────────────────
47
72
 
@@ -74,6 +99,13 @@ export interface ChangeHunk {
74
99
  status?: ChangeHunkStatus;
75
100
  /** Per-hunk provenance override (overrides the review-level provenance). */
76
101
  provenance?: ChangeProvenance;
102
+ /**
103
+ * Verification results for this hunk (lint / types / tests / policy hooks).
104
+ * Purely informational — a failing check never blocks approval, it only
105
+ * informs the reviewer's decision. Grouped into "Before" / "After" sections
106
+ * when both phases are present.
107
+ */
108
+ checks?: CheckResult[];
77
109
  }
78
110
 
79
111
  /** State surface exposed by the ChangeReview context. */
@@ -334,6 +366,7 @@ export const ChangeReviewProvenance = forwardRef<HTMLDivElement, ChangeReviewPro
334
366
  "flex flex-wrap items-center gap-x-3 gap-y-1 text-caption text-muted-foreground pb-3",
335
367
  className,
336
368
  )}
369
+ role="group"
337
370
  aria-label="Change provenance"
338
371
  {...props}
339
372
  >
@@ -365,6 +398,7 @@ export const ChangeReviewProvenance = forwardRef<HTMLDivElement, ChangeReviewPro
365
398
  <div
366
399
  ref={ref}
367
400
  className={cn("pb-3 text-caption text-muted-foreground", className)}
401
+ role="group"
368
402
  aria-label="Change provenance"
369
403
  {...props}
370
404
  >
@@ -419,7 +453,11 @@ const HUNK_STATUS_ICONS: Record<ChangeHunkStatus, ReactNode> = {
419
453
  </span>
420
454
  ),
421
455
  removed: (
422
- <span className="text-destructive font-bold text-meta" aria-hidden="true">
456
+ // #124: sibling of `added`/`modified` above — this glyph is a decorative
457
+ // aria-hidden marker beside a labelled span (see HUNK_STATUS_LABELS), and
458
+ // reads as running text at the type scale, so it takes the ink rung the
459
+ // other two already use.
460
+ <span className="text-destructive-text font-bold text-meta" aria-hidden="true">
423
461
 
424
462
  </span>
425
463
  ),
@@ -436,6 +474,145 @@ const HUNK_STATUS_LABELS: Record<ChangeHunkStatus, string> = {
436
474
  modified: "modified",
437
475
  };
438
476
 
477
+ // ─── Checks (verification evidence) ────────────────────────────────────────────
478
+
479
+ /**
480
+ * A check's `detail` collapses behind a disclosure once it's long enough that
481
+ * showing it inline would dominate the hunk row. Short details always render
482
+ * in place.
483
+ */
484
+ const CHECK_DETAIL_COLLAPSE_THRESHOLD = 80;
485
+
486
+ function isLongCheckDetail(detail: string): boolean {
487
+ return detail.length > CHECK_DETAIL_COLLAPSE_THRESHOLD || detail.includes("\n");
488
+ }
489
+
490
+ const CHECK_PHASE_LABELS: Record<"before" | "after", string> = {
491
+ before: "Before",
492
+ after: "After",
493
+ };
494
+
495
+ /**
496
+ * Group checks for display. Phase headers ("Before" / "After") appear ONLY
497
+ * when both phases are present in the same hunk — a single-phase or
498
+ * unphased list of checks renders flat, with no header noise.
499
+ */
500
+ function groupChecksByPhase(
501
+ checks: CheckResult[],
502
+ ): { phase?: "before" | "after"; items: CheckResult[] }[] {
503
+ const before = checks.filter((c) => c.phase === "before");
504
+ const after = checks.filter((c) => c.phase === "after");
505
+ const showPhaseLabels = before.length > 0 && after.length > 0;
506
+
507
+ if (!showPhaseLabels) {
508
+ return [{ items: checks }];
509
+ }
510
+
511
+ const rest = checks.filter((c) => c.phase !== "before" && c.phase !== "after");
512
+ const groups: { phase?: "before" | "after"; items: CheckResult[] }[] = [];
513
+ if (rest.length > 0) groups.push({ items: rest });
514
+ groups.push({ phase: "before", items: before });
515
+ groups.push({ phase: "after", items: after });
516
+ return groups;
517
+ }
518
+
519
+ /**
520
+ * A single verification result row: icon + label + accessible status word +
521
+ * optional duration, with an optional collapsed detail line below.
522
+ *
523
+ * The pass/fail signal is carried by TWO non-color cues — the icon (distinct
524
+ * glyph per `StatusIcon`'s canonical vocabulary) and the visible "Passed"/
525
+ * "Failed" text — so it survives in greyscale (WCAG 1.4.1). `StatusIcon` is
526
+ * decorative (`aria-hidden`); the status word is what reaches assistive tech.
527
+ */
528
+ function ChangeCheckRow({ check }: { check: CheckResult }) {
529
+ const [detailOpen, setDetailOpen] = useState(false);
530
+ const { label, ok, detail, durationMs } = check;
531
+ const statusWord = ok ? "Passed" : "Failed";
532
+ const hasDetail = detail !== undefined && detail !== "";
533
+ const longDetail = hasDetail && isLongCheckDetail(detail);
534
+
535
+ return (
536
+ <div data-slot="change-review-hunk-check" data-ok={ok} className="flex flex-col gap-1">
537
+ <div className="flex flex-wrap items-center gap-x-1.5 gap-y-0.5 min-w-0">
538
+ <StatusIcon status={ok ? "complete" : "failed"} className="size-3.5 shrink-0" />
539
+ <span className="text-caption font-medium text-foreground truncate">{label}</span>
540
+ <span className={cn("text-caption", ok ? "text-success-text" : "text-destructive-text")}>
541
+ {statusWord}
542
+ </span>
543
+ {durationMs !== undefined && (
544
+ <span className="text-caption text-muted-foreground tabular-nums shrink-0">
545
+ {formatElapsed(durationMs)}
546
+ </span>
547
+ )}
548
+ </div>
549
+
550
+ {hasDetail &&
551
+ (longDetail ? (
552
+ <Collapsible open={detailOpen} onOpenChange={setDetailOpen}>
553
+ <CollapsibleTrigger
554
+ className={cn(
555
+ "flex items-center gap-1 self-start rounded-sm pl-5 text-caption text-muted-foreground",
556
+ "hover:text-foreground focus-ring",
557
+ )}
558
+ >
559
+ <ChevronDown
560
+ aria-hidden="true"
561
+ className={cn(
562
+ "size-3 transition-transform duration-fast motion-reduce:transition-none",
563
+ detailOpen ? "rotate-180" : "rotate-0",
564
+ )}
565
+ />
566
+ {detailOpen ? "Hide detail" : "Show detail"}
567
+ </CollapsibleTrigger>
568
+ <CollapsibleContent data-slot="change-review-hunk-check-detail">
569
+ <p className="pl-5 pt-1 text-caption text-muted-foreground break-words">{detail}</p>
570
+ </CollapsibleContent>
571
+ </Collapsible>
572
+ ) : (
573
+ <p
574
+ data-slot="change-review-hunk-check-detail"
575
+ className="pl-5 text-caption text-muted-foreground break-words"
576
+ >
577
+ {detail}
578
+ </p>
579
+ ))}
580
+ </div>
581
+ );
582
+ }
583
+
584
+ /** The full checks section for one hunk — grouped by phase when applicable. */
585
+ function ChangeReviewChecks({ checks }: { checks: CheckResult[] }) {
586
+ const groups = groupChecksByPhase(checks);
587
+ const showPhaseLabels = groups.some((g) => g.phase !== undefined);
588
+
589
+ return (
590
+ <div
591
+ data-slot="change-review-hunk-checks"
592
+ className="flex flex-col gap-2 border-t border-border pt-3"
593
+ >
594
+ {groups.map((group, index) => (
595
+ <div
596
+ key={group.phase ?? `_${index}`}
597
+ data-slot="change-review-hunk-checks-phase"
598
+ className="flex flex-col gap-1.5"
599
+ >
600
+ {showPhaseLabels && group.phase && (
601
+ <span className="text-meta font-medium text-muted-foreground">
602
+ {CHECK_PHASE_LABELS[group.phase]}
603
+ </span>
604
+ )}
605
+ <div className="flex flex-col gap-1.5">
606
+ {group.items.map((check, itemIndex) => (
607
+ <ChangeCheckRow key={`${check.label}-${itemIndex}`} check={check} />
608
+ ))}
609
+ </div>
610
+ </div>
611
+ ))}
612
+ </div>
613
+ );
614
+ }
615
+
439
616
  export interface ChangeReviewHunkProps
440
617
  extends
441
618
  Omit<HTMLAttributes<HTMLLIElement>, "children">,
@@ -454,13 +631,13 @@ export interface ChangeReviewHunkProps
454
631
  * Approval state is signalled with TWO non-color cues:
455
632
  * - icon: Check (approved) vs plain toggle (pending)
456
633
  * - label: "Approved" badge vs "Approve" button text
457
- * This ensures the state is clear in all three themes including the low-chroma
634
+ * This ensures the state is clear in every theme including the low-chroma
458
635
  * monochrome surfaces (colorblind-safe).
459
636
  */
460
637
  export const ChangeReviewHunk = forwardRef<HTMLLIElement, ChangeReviewHunkProps>(
461
638
  function ChangeReviewHunk({ hunk, renderHunk, className, ...props }, ref) {
462
639
  const { state, actions } = useChangeReview();
463
- const { id, title, before, after, status = "modified", provenance } = hunk;
640
+ const { id, title, before, after, status = "modified", provenance, checks } = hunk;
464
641
  const approved = state.approved.has(id);
465
642
 
466
643
  const statusLabel = HUNK_STATUS_LABELS[status];
@@ -513,6 +690,7 @@ export const ChangeReviewHunk = forwardRef<HTMLLIElement, ChangeReviewHunkProps>
513
690
  <div className="flex items-center gap-2 min-w-0">
514
691
  {/* Status indicator: NON-color cue via symbol + color together */}
515
692
  <span
693
+ role="img"
516
694
  aria-label={`Change type: ${statusLabel}`}
517
695
  className="shrink-0 inline-flex items-center justify-center size-5"
518
696
  >
@@ -561,6 +739,9 @@ export const ChangeReviewHunk = forwardRef<HTMLLIElement, ChangeReviewHunkProps>
561
739
 
562
740
  {/* Content area: before / after or custom renderHunk */}
563
741
  {defaultContent && <div>{defaultContent}</div>}
742
+
743
+ {/* Verification evidence — informational only, never blocks approval */}
744
+ {checks && checks.length > 0 && <ChangeReviewChecks checks={checks} />}
564
745
  </li>
565
746
  );
566
747
  },
@@ -12,7 +12,7 @@ export const Checkbox = forwardRef<
12
12
  ref={ref}
13
13
  className={cn(
14
14
  "peer size-4 shrink-0 rounded-sm border border-input shadow-sm transition-colors duration-fast ease-standard",
15
- "focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background",
15
+ "focus-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background",
16
16
  "disabled:cursor-not-allowed disabled:opacity-50",
17
17
  "data-[state=checked]:border-primary data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground",
18
18
  className,
@@ -120,7 +120,7 @@ const Swatch = forwardRef<HTMLButtonElement, SwatchProps>(function Swatch(
120
120
  selected
121
121
  ? "border-foreground ring-2 ring-ring ring-offset-1 ring-offset-background"
122
122
  : "border-transparent hover:border-foreground/40",
123
- "focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-1 focus-visible:ring-offset-background",
123
+ "focus-ring focus-visible:ring-offset-1 focus-visible:ring-offset-background",
124
124
  className,
125
125
  )}
126
126
  {...props}
@@ -266,7 +266,7 @@ export const ColorPicker = forwardRef<HTMLDivElement, ColorPickerProps>(function
266
266
  "inline-flex items-center gap-2 rounded-md border border-input bg-background px-3 py-1.5 text-sm shadow-sm",
267
267
  "transition-[color,box-shadow] duration-fast",
268
268
  "hover:bg-accent hover:text-accent-foreground",
269
- "focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-1 focus-visible:ring-offset-background",
269
+ "focus-ring focus-visible:ring-offset-1 focus-visible:ring-offset-background",
270
270
  "disabled:pointer-events-none disabled:opacity-50",
271
271
  )}
272
272
  >
@@ -297,7 +297,8 @@ export const ColorPicker = forwardRef<HTMLDivElement, ColorPickerProps>(function
297
297
  <div className="mt-3 border-t border-border pt-3">
298
298
  {/* Custom hex is NOT theme-aware — it will not adapt to theme changes. */}
299
299
  <p className="mb-1.5 text-xs text-muted-foreground">
300
- Custom hex <span className="text-warning">(not theme-aware)</span>
300
+ {/* #124: inline running text — ink rung, not the 3:1 mark rung. */}
301
+ Custom hex <span className="text-warning-text">(not theme-aware)</span>
301
302
  </p>
302
303
  <div className="flex gap-2">
303
304
  <input
@@ -316,7 +317,7 @@ export const ColorPicker = forwardRef<HTMLDivElement, ColorPickerProps>(function
316
317
  }}
317
318
  className={cn(
318
319
  "h-8 w-28 rounded-md border border-input bg-background px-2 text-sm",
319
- "focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-1 focus-visible:ring-offset-background",
320
+ "focus-ring focus-visible:ring-offset-1 focus-visible:ring-offset-background",
320
321
  "placeholder:text-muted-foreground",
321
322
  )}
322
323
  />
@@ -326,7 +327,7 @@ export const ColorPicker = forwardRef<HTMLDivElement, ColorPickerProps>(function
326
327
  className={cn(
327
328
  "h-8 rounded-md border border-input bg-background px-2 text-sm",
328
329
  "hover:bg-accent hover:text-accent-foreground",
329
- "focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-1 focus-visible:ring-offset-background",
330
+ "focus-ring focus-visible:ring-offset-1 focus-visible:ring-offset-background",
330
331
  )}
331
332
  >
332
333
  Apply
@@ -3,7 +3,6 @@ import { useEffect, useRef, useState, type KeyboardEvent as ReactKeyboardEvent }
3
3
  import { expect, waitFor, within } from "storybook/test";
4
4
  import { Calculator, Calendar, CreditCard, Settings, Smile, User } from "lucide-react";
5
5
  import { Button } from "../button";
6
- import { DialogTitle } from "../dialog";
7
6
  import {
8
7
  Command,
9
8
  CommandDialog,
@@ -19,6 +18,20 @@ const meta = {
19
18
  title: "Overlays/Command",
20
19
  component: Command,
21
20
  tags: ["autodocs"],
21
+ parameters: {
22
+ docs: {
23
+ description: {
24
+ component:
25
+ "A grouped, searchable, keyboard-driven list (cmdk). Render it inline, or wrap it " +
26
+ "in `CommandDialog` for a full ⌘K palette — pass `CommandDialog`'s optional `title` " +
27
+ "for the dialog's `sr-only` accessible name.\n\n" +
28
+ "**Choosing between the two model/target pickers.** A full ⌘K palette → compose " +
29
+ "`CommandDialog` + `Command*` from `@elabs-ai/components-ui` directly; there is no " +
30
+ "separate selector component. An inline pill in a composer footer that opens the " +
31
+ "same grouped list anchored under itself → `ModelPicker`.",
32
+ },
33
+ },
34
+ },
22
35
  argTypes: {
23
36
  label: {
24
37
  description: "Accessible label for the command palette (cmdk `label` prop).",
@@ -113,10 +126,7 @@ function CommandPaletteDemo() {
113
126
  Open command palette
114
127
  </Button>
115
128
 
116
- <CommandDialog open={open} onOpenChange={setOpen}>
117
- {/* CommandDialog doesn't ship a built-in title, so supply an sr-only one
118
- for the dialog's accessible name. */}
119
- <DialogTitle className="sr-only">Command palette</DialogTitle>
129
+ <CommandDialog open={open} onOpenChange={setOpen} title="Command palette">
120
130
  <CommandInput placeholder="Type a command or search…" />
121
131
  <CommandList>
122
132
  <CommandEmpty>No results found.</CommandEmpty>
@@ -291,7 +301,7 @@ function ExternalInputComboboxDemo() {
291
301
  role="combobox"
292
302
  onKeyDown={forwardKey}
293
303
  placeholder="Type @ to mention…"
294
- className="flex h-10 w-full rounded-md border border-input bg-background px-3 text-body outline-none focus-visible:ring-2 focus-visible:ring-ring"
304
+ className="flex h-10 w-full rounded-md border border-input bg-background px-3 text-body focus-ring"
295
305
  />
296
306
  <Command
297
307
  id="mention-list"
@@ -4,12 +4,15 @@ import { render, screen, waitFor } from "@testing-library/react";
4
4
  import userEvent from "@testing-library/user-event";
5
5
  import {
6
6
  Command,
7
+ CommandDialog,
7
8
  CommandGroup,
8
9
  CommandInput,
9
10
  CommandItem,
10
11
  CommandList,
12
+ CommandSeparator,
11
13
  useCommandActiveItemId,
12
14
  } from "./command";
15
+ import { DialogTitle } from "../dialog";
13
16
 
14
17
  // jsdom does not implement `Element.prototype.scrollIntoView`, and `cmdk`
15
18
  // calls it unconditionally (not feature-detected — it's a third-party
@@ -278,3 +281,73 @@ describe("Command — external combobox input wiring (integration)", () => {
278
281
  });
279
282
  });
280
283
  });
284
+
285
+ /**
286
+ * RM-010 — `CommandDialog` grew a `title` prop when the `ModelSelector` alias
287
+ * shell in `@elabs-ai/components-ai` was deleted; the `sr-only` `DialogTitle`
288
+ * was the one capability that shell had and `ui` lacked.
289
+ *
290
+ * It MUST stay optional. `@elabs-ai/components-ai`'s `VoiceSelectorDialog`
291
+ * spreads `ComponentProps<typeof CommandDialog>` straight through, and callers
292
+ * that already render their own `DialogTitle` in `children` must not be forced
293
+ * to move it. Both branches are asserted so a later "make it required" change
294
+ * fails here rather than in a downstream package.
295
+ */
296
+ describe("CommandDialog — optional title (RM-010)", () => {
297
+ it("names the dialog from `title`, rendered sr-only", () => {
298
+ render(
299
+ <CommandDialog open title="Command palette">
300
+ <CommandList>
301
+ <CommandItem>Calendar</CommandItem>
302
+ </CommandList>
303
+ </CommandDialog>,
304
+ );
305
+
306
+ const dialog = screen.getByRole("dialog");
307
+ expect(dialog).toHaveAccessibleName("Command palette");
308
+ expect(screen.getByText("Command palette")).toHaveClass("sr-only");
309
+ });
310
+
311
+ it("renders no title of its own when `title` is omitted — a caller's own DialogTitle still wins", () => {
312
+ render(
313
+ <CommandDialog open>
314
+ <DialogTitle>Caller-owned title</DialogTitle>
315
+ <CommandList>
316
+ <CommandItem>Calendar</CommandItem>
317
+ </CommandList>
318
+ </CommandDialog>,
319
+ );
320
+
321
+ expect(screen.getByRole("dialog")).toHaveAccessibleName("Caller-owned title");
322
+ // Exactly one heading — CommandDialog did not add a second, competing one.
323
+ expect(screen.getAllByText(/title/i)).toHaveLength(1);
324
+ });
325
+ });
326
+
327
+ /** #157 — `CommandSeparator` is `cmdk`'s divider between groups, rendered as a
328
+ * direct child of `CommandList`'s `role="listbox"`. A listbox may only own
329
+ * `option`/`group` children (WCAG 1.3.1); a bare `role="separator"` there is
330
+ * neither. */
331
+ describe("Command — CommandSeparator a11y (#157)", () => {
332
+ it("stays in the DOM for sighted users but is removed from the accessibility tree", () => {
333
+ render(
334
+ <Command>
335
+ <CommandList>
336
+ <CommandGroup heading="Fruit">
337
+ <CommandItem value="apple">Apple</CommandItem>
338
+ </CommandGroup>
339
+ <CommandSeparator />
340
+ <CommandGroup heading="Veg">
341
+ <CommandItem value="carrot">Carrot</CommandItem>
342
+ </CommandGroup>
343
+ </CommandList>
344
+ </Command>,
345
+ );
346
+
347
+ // Still rendered — cmdk owns the visual divider.
348
+ expect(document.querySelector("[cmdk-separator]")).not.toBeNull();
349
+ // Never exposed with its `role="separator"` — a listbox can't own one.
350
+ expect(screen.queryByRole("separator")).toBeNull();
351
+ expect(screen.getByRole("listbox")).toBeInTheDocument();
352
+ });
353
+ });
@@ -16,7 +16,7 @@ import { Command as CommandPrimitive, useCommandState } from "cmdk";
16
16
  import { Search } from "lucide-react";
17
17
  import { cn } from "../../lib/cn";
18
18
  import { mergeRefs } from "../../lib/merge-refs";
19
- import { Dialog, DialogContent } from "../dialog";
19
+ import { Dialog, DialogContent, DialogTitle } from "../dialog";
20
20
 
21
21
  /** cmdk's own marker attribute on an item row — the scope for the id lookup. */
22
22
  const CMDK_ITEM_SELECTOR = '[cmdk-item=""][aria-selected="true"]';
@@ -149,17 +149,34 @@ export function useCommandActiveItemId(): string | undefined {
149
149
  return id === NO_COMMAND_PROVIDER ? undefined : id;
150
150
  }
151
151
 
152
- export function CommandDialog({
153
- children,
154
- filter,
155
- ...props
156
- }: ComponentPropsWithoutRef<typeof Dialog> & {
152
+ export type CommandDialogProps = ComponentPropsWithoutRef<typeof Dialog> & {
157
153
  /** Custom cmdk ranking (e.g. strict substring instead of fuzzy). */
158
154
  filter?: ComponentPropsWithoutRef<typeof CommandPrimitive>["filter"];
159
- }) {
155
+ /**
156
+ * The dialog's accessible name, rendered `sr-only`.
157
+ *
158
+ * OPTIONAL, and deliberately so: callers that already render their own
159
+ * `DialogTitle` in `children` must not be forced to move it, and
160
+ * `@elabs-ai/components-ai`'s `VoiceSelectorDialog` passes this whole prop
161
+ * type straight through. Supply it when you have no visible title — a
162
+ * `Dialog` with no accessible name is a Radix warning and an axe violation.
163
+ */
164
+ title?: ReactNode;
165
+ };
166
+
167
+ /**
168
+ * A ⌘K command palette in a modal `Dialog`.
169
+ *
170
+ * This IS the palette presentation of `Command` — there is no separate
171
+ * "selector" component. For an inline pill that opens the same grouped,
172
+ * searchable list anchored under itself (a composer footer), reach for
173
+ * `ModelPicker` instead.
174
+ */
175
+ export function CommandDialog({ children, filter, title, ...props }: CommandDialogProps) {
160
176
  return (
161
177
  <Dialog {...props}>
162
178
  <DialogContent className="overflow-hidden p-0">
179
+ {title === undefined ? null : <DialogTitle className="sr-only">{title}</DialogTitle>}
163
180
  <Command
164
181
  filter={filter}
165
182
  className="[&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:text-muted-foreground [&_[cmdk-input-wrapper]_svg]:size-5 [&_[cmdk-item]]:px-2 [&_[cmdk-item]]:py-3"
@@ -244,6 +261,12 @@ export const CommandSeparator = forwardRef<
244
261
  <CommandPrimitive.Separator
245
262
  ref={ref}
246
263
  data-slot="command-separator"
264
+ // `cmdk` renders this as a direct child of its `role="listbox"`
265
+ // (`CommandList`), which may only own `option`/`group` (WCAG 1.3.1,
266
+ // #157) — a purely visual divider between groups, so `aria-hidden`
267
+ // removes it from the accessibility tree instead of asking the listbox
268
+ // to own a role it can't. `{...props}` still lets a caller override it.
269
+ aria-hidden="true"
247
270
  className={cn("-mx-1 h-px bg-border", className)}
248
271
  {...props}
249
272
  />
@@ -10,4 +10,5 @@ export {
10
10
  CommandShortcut,
11
11
  useCommandActiveItemId,
12
12
  type CommandProps,
13
+ type CommandDialogProps,
13
14
  } from "./command";
@@ -0,0 +1,39 @@
1
+ import type { Meta, StoryObj } from "@storybook/react-vite";
2
+ import { expect, within } from "storybook/test";
3
+ import { CommandTrigger } from "./command-trigger";
4
+
5
+ const meta = {
6
+ title: "Navigation/CommandTrigger",
7
+ component: CommandTrigger,
8
+ parameters: {
9
+ docs: {
10
+ description: {
11
+ component:
12
+ "The command-palette opener a top bar reaches for: shaped like a search field, " +
13
+ "collapsing to just the icon under `sm`. The visible label and the shortcut `Kbd` are " +
14
+ "both decorative — the accessible name is authored with `aria-label` so the shortcut " +
15
+ "glyph never concatenates into it (#117).",
16
+ },
17
+ },
18
+ },
19
+ tags: ["autodocs"],
20
+ } satisfies Meta<typeof CommandTrigger>;
21
+ export default meta;
22
+ type Story = StoryObj<typeof meta>;
23
+
24
+ /** Default label and platform-detected shortcut. */
25
+ export const Default: Story = {
26
+ play: async ({ canvasElement }) => {
27
+ const button = within(canvasElement).getByRole("button");
28
+ // #117: the Kbd's shortcut glyph must not pollute the computed name.
29
+ await expect(button).toHaveAccessibleName("Search");
30
+ },
31
+ };
32
+
33
+ /** A caller-supplied label and shortcut — useful when the platform guess is wrong. */
34
+ export const WithCustomShortcut: Story = {
35
+ args: {
36
+ label: "Find anything",
37
+ shortcut: "Ctrl K",
38
+ },
39
+ };
@@ -0,0 +1,18 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { render, screen } from "@testing-library/react";
3
+ import { CommandTrigger } from "./command-trigger";
4
+
5
+ describe("CommandTrigger", () => {
6
+ it("announces only its label — the shortcut glyph must not pollute the accessible name", () => {
7
+ render(<CommandTrigger />);
8
+ // #117: a Kbd inside a control concatenates into the computed name. Assert the
9
+ // EXACT name; a regex match would happily pass on the polluted string.
10
+ expect(screen.getByRole("button")).toHaveAccessibleName("Search");
11
+ });
12
+
13
+ it("takes a custom label and shortcut", () => {
14
+ render(<CommandTrigger label="Find anything" shortcut="Ctrl K" />);
15
+ expect(screen.getByRole("button")).toHaveAccessibleName("Find anything");
16
+ expect(screen.getByText("Ctrl K")).toBeInTheDocument();
17
+ });
18
+ });
@@ -0,0 +1,70 @@
1
+ import { forwardRef, type ComponentProps } from "react";
2
+ import { Search } from "lucide-react";
3
+ import { cn } from "../../lib/cn";
4
+ import { Kbd } from "../kbd";
5
+
6
+ export interface CommandTriggerProps extends ComponentProps<"button"> {
7
+ /** The visible and announced label. Defaults to "Search". */
8
+ label?: string;
9
+ /** The shortcut hint. Defaults to the platform-correct ⌘ K / Ctrl K. */
10
+ shortcut?: string;
11
+ }
12
+
13
+ function platformShortcut(): string {
14
+ if (typeof navigator === "undefined") return "Ctrl K";
15
+ // `userAgentData.platform` where available, else the legacy string. Both are
16
+ // hints, not guarantees — the trigger is a hint too, so a wrong guess is cosmetic.
17
+ const platform =
18
+ (navigator as { userAgentData?: { platform?: string } }).userAgentData?.platform ??
19
+ navigator.platform ??
20
+ "";
21
+ return /mac|iphone|ipad/i.test(platform) ? "⌘ K" : "Ctrl K";
22
+ }
23
+
24
+ /**
25
+ * The command-palette opener, shaped like a search field: label left, shortcut
26
+ * pinned right, collapsing to the icon alone under `sm`.
27
+ *
28
+ * The button’s accessible name comes entirely from `aria-label` — that is what
29
+ * stops the `Kbd`’s shortcut glyph from concatenating into it (#117); a
30
+ * non-empty `aria-label` short-circuits accessible-name computation before the
31
+ * DOM is ever consulted. The visible label and the `Kbd` are ALSO marked
32
+ * `aria-hidden`, per `accessibility.md`, as defence in depth: if `aria-label`
33
+ * is ever dropped, that turns a silent regression into a loud one — an empty
34
+ * accessible name rather than a plausible wrong one. They do not do the work
35
+ * themselves.
36
+ *
37
+ * `shortcut` reads `navigator` at render time when omitted, which can differ
38
+ * between server and client — a consumer that server-renders this component
39
+ * should pass `shortcut` explicitly to avoid a hydration mismatch.
40
+ */
41
+ export const CommandTrigger = forwardRef<HTMLButtonElement, CommandTriggerProps>(
42
+ function CommandTrigger({ label = "Search", shortcut, className, ...props }, ref) {
43
+ const hint = shortcut ?? platformShortcut();
44
+ return (
45
+ <button
46
+ ref={ref}
47
+ type="button"
48
+ data-slot="command-trigger"
49
+ aria-label={label}
50
+ className={cn(
51
+ "inline-flex h-8 items-center gap-2 rounded-md border border-input bg-background px-2 text-body text-muted-foreground",
52
+ "hover:bg-accent hover:text-accent-foreground focus-ring",
53
+ "sm:w-56 sm:justify-between sm:ps-2 sm:pe-1.5",
54
+ className,
55
+ )}
56
+ {...props}
57
+ >
58
+ <span className="flex min-w-0 items-center gap-2">
59
+ <Search aria-hidden="true" className="size-4 shrink-0" />
60
+ <span aria-hidden="true" className="hidden truncate sm:inline">
61
+ {label}
62
+ </span>
63
+ </span>
64
+ <Kbd aria-hidden="true" className="hidden sm:inline-flex">
65
+ {hint}
66
+ </Kbd>
67
+ </button>
68
+ );
69
+ },
70
+ );
@@ -0,0 +1 @@
1
+ export * from "./command-trigger";