@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
@@ -1,4 +1,5 @@
1
1
  import type { Meta, StoryObj } from "@storybook/react-vite";
2
+ import { expect } from "storybook/test";
2
3
  import {
3
4
  Breadcrumb,
4
5
  BreadcrumbItem,
@@ -45,3 +46,40 @@ export const Default: Story = {
45
46
  </Breadcrumb>
46
47
  ),
47
48
  };
49
+
50
+ /**
51
+ * #310 — `BreadcrumbLink` had no focus-visible styling of its own, so keyboard
52
+ * focus fell straight through to the browser's own default ring instead of
53
+ * the library's token-driven indicator (`focus-ring`, ADR 0027) every other
54
+ * interactive control carries. A class-string assertion would be worthless
55
+ * (it wouldn't prove anything actually paints) so this locks the RENDERED
56
+ * indicator: Tab to the first link and assert a real box-shadow exists,
57
+ * mirroring `Button`'s `CompoundFocusIndicator` lock.
58
+ */
59
+ export const FocusIndicator: Story = {
60
+ name: "Focus indicator (#310)",
61
+ render: () => (
62
+ <Breadcrumb>
63
+ <BreadcrumbList>
64
+ <BreadcrumbItem>
65
+ <BreadcrumbLink href="#">Home</BreadcrumbLink>
66
+ </BreadcrumbItem>
67
+ <BreadcrumbSeparator />
68
+ <BreadcrumbItem>
69
+ <BreadcrumbPage>brand-ui</BreadcrumbPage>
70
+ </BreadcrumbItem>
71
+ </BreadcrumbList>
72
+ </Breadcrumb>
73
+ ),
74
+ play: async ({ canvas, userEvent }) => {
75
+ const link = canvas.getByRole("link", { name: "Home" });
76
+
77
+ // Tab rather than .focus(): `:focus-visible` is what `focus-ring` keys
78
+ // on, and a programmatic focus does not reliably match it.
79
+ await userEvent.tab();
80
+ await expect(link).toHaveFocus();
81
+
82
+ const focused = getComputedStyle(link);
83
+ await expect(focused.boxShadow).not.toBe("none");
84
+ },
85
+ };
@@ -1,6 +1,13 @@
1
1
  import { describe, expect, it } from "vitest";
2
2
  import { render, screen } from "@testing-library/react";
3
- import { Breadcrumb, BreadcrumbItem, BreadcrumbList, BreadcrumbPage } from "./breadcrumb";
3
+ import { LocaleProvider } from "../locale-provider";
4
+ import {
5
+ Breadcrumb,
6
+ BreadcrumbEllipsis,
7
+ BreadcrumbItem,
8
+ BreadcrumbList,
9
+ BreadcrumbPage,
10
+ } from "./breadcrumb";
4
11
  describe("Breadcrumb", () => {
5
12
  it("renders the current page", () => {
6
13
  render(
@@ -15,3 +22,19 @@ describe("Breadcrumb", () => {
15
22
  expect(screen.getByText("Here")).toHaveAttribute("aria-current", "page");
16
23
  });
17
24
  });
25
+
26
+ describe("BreadcrumbEllipsis — microcopy (#18)", () => {
27
+ it("keeps its shipped sr-only 'More' text with no LocaleProvider", () => {
28
+ render(<BreadcrumbEllipsis />);
29
+ expect(screen.getByText("More")).toHaveClass("sr-only");
30
+ });
31
+
32
+ it("translates via the reused generic `more` key under a LocaleProvider", () => {
33
+ render(
34
+ <LocaleProvider locale="de-DE" messages={{ more: "Mehr" }}>
35
+ <BreadcrumbEllipsis />
36
+ </LocaleProvider>,
37
+ );
38
+ expect(screen.getByText("Mehr")).toHaveClass("sr-only");
39
+ });
40
+ });
@@ -2,6 +2,7 @@ import { forwardRef, type ComponentProps, type ReactNode } from "react";
2
2
  import { Slot } from "@radix-ui/react-slot";
3
3
  import { ChevronRight, MoreHorizontal } from "lucide-react";
4
4
  import { cn } from "../../lib/cn";
5
+ import { useLocale } from "../locale-provider";
5
6
 
6
7
  export const Breadcrumb = forwardRef<HTMLElement, ComponentProps<"nav">>(
7
8
  function Breadcrumb(props, ref) {
@@ -37,7 +38,10 @@ export const BreadcrumbLink = forwardRef<
37
38
  return (
38
39
  <Comp
39
40
  ref={ref}
40
- className={cn("transition-colors duration-fast hover:text-foreground", className)}
41
+ className={cn(
42
+ "rounded-sm transition-colors duration-fast hover:text-foreground focus-ring",
43
+ className,
44
+ )}
41
45
  {...props}
42
46
  />
43
47
  );
@@ -69,6 +73,7 @@ export function BreadcrumbSeparator({ children, className, ...props }: Component
69
73
  );
70
74
  }
71
75
  export function BreadcrumbEllipsis({ className, ...props }: ComponentProps<"span">) {
76
+ const { t } = useLocale();
72
77
  return (
73
78
  <span
74
79
  role="presentation"
@@ -77,7 +82,7 @@ export function BreadcrumbEllipsis({ className, ...props }: ComponentProps<"span
77
82
  {...props}
78
83
  >
79
84
  <MoreHorizontal className="size-4" />
80
- <span className="sr-only">More</span>
85
+ <span className="sr-only">{t("more")}</span>
81
86
  </span>
82
87
  );
83
88
  }
@@ -41,17 +41,30 @@ export const Ghost: Story = { args: { variant: "ghost" } };
41
41
  // themes.css/Tailwind failed to load, this would be the transparent default
42
42
  // `rgba(0, 0, 0, 0)` and fail — where a plain `toBeVisible()` would still pass
43
43
  // on the unstyled element.
44
- //
45
- // The harness applies the shipped DEFAULT theme (`light`) to
46
- // `document.documentElement` (see `apps/docs/.storybook/preview.tsx`'s
47
- // `withTheme` decorator, #402), so `--primary` here is light's own
48
- // value (`oklch(0.553 0.143 153)` in themes.css) — not the unbranded `:root`
49
- // fallback this test asserted against before #402 fixed the harness to
50
- // actually apply a theme.
51
44
  export const CssCheck: Story = {
52
45
  play: async ({ canvas }) => {
53
46
  const btn = canvas.getByRole("button", { name: "Button" });
54
- await expect(getComputedStyle(btn).backgroundColor).toBe("oklch(0.553 0.143 153)");
47
+
48
+ // Create a hidden reference element styled with the same token
49
+ // to derive the expected value dynamically, avoiding hardcoded snapshots
50
+ const ref = document.createElement("div");
51
+ ref.className = "bg-primary";
52
+ ref.style.visibility = "hidden";
53
+ ref.style.position = "absolute";
54
+ document.body.appendChild(ref);
55
+
56
+ const buttonBgColor = getComputedStyle(btn).backgroundColor;
57
+ const referenceBgColor = getComputedStyle(ref).backgroundColor;
58
+
59
+ // Verify the token system loaded by comparing button color to reference
60
+ await expect(buttonBgColor).toBe(referenceBgColor);
61
+
62
+ // Verify the token system actually loaded a non-transparent color
63
+ // (catches the case where themes.css/Tailwind failed to load)
64
+ await expect(buttonBgColor).not.toBe("rgba(0, 0, 0, 0)");
65
+
66
+ // Clean up
67
+ ref.remove();
55
68
  },
56
69
  };
57
70
 
@@ -68,3 +81,98 @@ export const AllVariants: Story = {
68
81
  </div>
69
82
  ),
70
83
  };
84
+
85
+ /**
86
+ * #67 — the RENDERED lock on the compound focus indicator (ADR 0027 Amendment).
87
+ *
88
+ * A class-string assertion would be worthless here: `focus-ring` could name a
89
+ * single-layer ring and the class would still be present while the ratio stayed
90
+ * at 1.36:1, which is the exact failure this story exists to prevent. So this
91
+ * play function measures the SHIPPED pixels — it reads the computed indicator
92
+ * layers off a really-focused button, resolves the ground the indicator is
93
+ * actually drawn on by walking up to the first non-transparent ancestor, and
94
+ * asserts at least one layer clears WCAG 1.4.11 (3:1) against it.
95
+ *
96
+ * It is theme-agnostic on purpose: whichever theme the run resolves (the
97
+ * toolbar global, or `STORYBOOK_THEME=<slug>` headless — see
98
+ * @.claude/rules/storybook-mcp.md § Themes) the SAME assertion has to hold, so
99
+ * one story covers `light`, `dark` and any consumer theme a fork adds.
100
+ */
101
+ export const CompoundFocusIndicator: Story = {
102
+ name: "Compound focus indicator (WCAG 1.4.11)",
103
+ play: async ({ canvas, userEvent }) => {
104
+ const btn = canvas.getByRole("button", { name: "Button" });
105
+
106
+ // Tab rather than .focus(): `:focus-visible` is what the utility keys on,
107
+ // and a programmatic focus does not reliably match it.
108
+ await userEvent.tab();
109
+ await expect(btn).toHaveFocus();
110
+
111
+ /** Resolve a token to the rgb() the browser actually paints. */
112
+ const resolve = (token: string) => {
113
+ const probe = document.createElement("div");
114
+ probe.style.cssText = `position:absolute;visibility:hidden;background-color:var(${token})`;
115
+ btn.parentElement?.appendChild(probe);
116
+ const value = getComputedStyle(probe).backgroundColor;
117
+ probe.remove();
118
+ return value;
119
+ };
120
+
121
+ // Colours come back in whatever space the author wrote — Chromium echoes
122
+ // `oklch()` verbatim for an oklch token — so convert through a 1x1 canvas
123
+ // rather than a regex: the canvas does the same CSS Color 4 → sRGB
124
+ // conversion the compositor does, for any colour syntax a theme may use.
125
+ const ctx = document.createElement("canvas").getContext("2d");
126
+ if (ctx == null) throw new Error("no 2d context");
127
+ const srgb = (value: string): [number, number, number] => {
128
+ ctx.clearRect(0, 0, 1, 1);
129
+ ctx.fillStyle = value;
130
+ ctx.fillRect(0, 0, 1, 1);
131
+ const [r, g, b] = ctx.getImageData(0, 0, 1, 1).data;
132
+ return [r as number, g as number, b as number];
133
+ };
134
+ const luminance = (value: string) => {
135
+ const [r, g, b] = srgb(value).map((c) => {
136
+ const s = c / 255;
137
+ return s <= 0.03928 ? s / 12.92 : ((s + 0.055) / 1.055) ** 2.4;
138
+ }) as [number, number, number];
139
+ return 0.2126 * r + 0.7152 * g + 0.0722 * b;
140
+ };
141
+ const ratio = (a: string, b: string) => {
142
+ const [la, lb] = [luminance(a), luminance(b)];
143
+ return (Math.max(la, lb) + 0.05) / (Math.min(la, lb) + 0.05);
144
+ };
145
+
146
+ // The ground the indicator is drawn ON — walked, not assumed, and started at
147
+ // the PARENT: the indicator sits outside the button, so the button's own
148
+ // `--primary` fill is not the surface it has to be legible against.
149
+ let ground = "rgba(0, 0, 0, 0)";
150
+ for (let el: HTMLElement | null = btn.parentElement; el != null; el = el.parentElement) {
151
+ const bg = getComputedStyle(el).backgroundColor;
152
+ if (bg !== "rgba(0, 0, 0, 0)" && bg !== "transparent") {
153
+ ground = bg;
154
+ break;
155
+ }
156
+ }
157
+ await expect(ground).not.toBe("rgba(0, 0, 0, 0)");
158
+
159
+ // BOTH layers must actually be emitted — a compound indicator with one layer
160
+ // missing is the single-layer ring this issue was filed about.
161
+ const focused = getComputedStyle(btn);
162
+ await expect(focused.boxShadow).not.toBe("none");
163
+ await expect(focused.outlineStyle).toBe("solid");
164
+ await expect(parseFloat(focused.outlineWidth)).toBeGreaterThan(0);
165
+
166
+ const ringLayer = resolve("--ring");
167
+ const contourLayer = focused.outlineColor;
168
+ // The contour layer really is the token, not currentColor or a fallback.
169
+ await expect(contourLayer).toBe(resolve("--ring-contour"));
170
+
171
+ const best = Math.max(ratio(ringLayer, ground), ratio(contourLayer, ground));
172
+ await expect(
173
+ best,
174
+ `focus indicator on ${ground}: ring ${ringLayer} = ${ratio(ringLayer, ground).toFixed(2)}, ` +
175
+ `contour ${contourLayer} = ${ratio(contourLayer, ground).toFixed(2)}`,
176
+ ).toBeGreaterThanOrEqual(3);
177
+ },
178
+ };
@@ -4,7 +4,7 @@ import { cva, type VariantProps } from "class-variance-authority";
4
4
  import { cn } from "../../lib/cn";
5
5
 
6
6
  export const buttonVariants = cva(
7
- "inline-flex items-center justify-center gap-2 whitespace-nowrap touch-manipulation rounded-md text-body font-medium transition-[color,background-color,border-color,box-shadow,scale] duration-fast ease-standard active:scale-[0.98] motion-reduce:active:scale-100 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0",
7
+ "inline-flex items-center justify-center gap-2 whitespace-nowrap touch-manipulation rounded-md text-body font-medium transition-[color,background-color,border-color,box-shadow,scale] duration-fast ease-standard active:scale-[0.98] motion-reduce:active:scale-100 focus-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0",
8
8
  {
9
9
  variants: {
10
10
  variant: {
@@ -70,6 +70,11 @@ export const Default: Story = {
70
70
 
71
71
  /** Opt-in hover-lift for clickable cards (gated by the motion system). */
72
72
  export const Interactive: Story = {
73
+ // The suite runs under `prefers-reduced-motion: reduce`
74
+ // (`apps/docs/vitest.config.ts`, #125), so the gated `--t-base` this asserts
75
+ // would read as the floor. The lift is what this story exists to prove, so it
76
+ // opts back into full motion explicitly.
77
+ globals: { motionPref: "full" },
73
78
  render: () => (
74
79
  <Card interactive className="max-w-sm" tabIndex={0} role="button">
75
80
  <CardHeader>
@@ -153,17 +158,17 @@ export const DetailSideHover: Story = {
153
158
  </p>
154
159
  <ul className="text-body space-y-1">
155
160
  <li>
156
- <button className="text-foreground hover:underline focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring rounded-sm px-1">
161
+ <button className="text-foreground hover:underline focus-ring rounded-sm px-1">
157
162
  View report
158
163
  </button>
159
164
  </li>
160
165
  <li>
161
- <button className="text-foreground hover:underline focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring rounded-sm px-1">
166
+ <button className="text-foreground hover:underline focus-ring rounded-sm px-1">
162
167
  Export CSV
163
168
  </button>
164
169
  </li>
165
170
  <li>
166
- <button className="text-foreground hover:underline focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring rounded-sm px-1">
171
+ <button className="text-foreground hover:underline focus-ring rounded-sm px-1">
167
172
  Share
168
173
  </button>
169
174
  </li>
@@ -223,10 +228,10 @@ export const DetailBottomHover: Story = {
223
228
  className="h-full"
224
229
  detail={
225
230
  <div className="flex items-center gap-3">
226
- <button className="text-body text-foreground hover:underline focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring rounded-sm px-1">
231
+ <button className="text-body text-foreground hover:underline focus-ring rounded-sm px-1">
227
232
  Drill down
228
233
  </button>
229
- <button className="text-body text-foreground hover:underline focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring rounded-sm px-1">
234
+ <button className="text-body text-foreground hover:underline focus-ring rounded-sm px-1">
230
235
  Download
231
236
  </button>
232
237
  </div>
@@ -390,7 +395,7 @@ export const DetailHoverKeyboardReveal: Story = {
390
395
  <div className="flex flex-col gap-2 pt-1">
391
396
  <button
392
397
  data-testid="detail-action"
393
- className="text-body text-foreground hover:underline focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring rounded-sm px-1 text-start"
398
+ className="text-body text-foreground hover:underline focus-ring rounded-sm px-1 text-start"
394
399
  >
395
400
  Open details
396
401
  </button>
@@ -23,7 +23,7 @@ export const cardVariants = cva("rounded-lg border bg-card text-card-foreground
23
23
  // duration-* utility; the transform is neutralized under OS reduced-motion
24
24
  // (the shadow/border still respond — "reduced != none").
25
25
  interactive: {
26
- true: "cursor-pointer transition-[translate,box-shadow,border-color] duration-base ease-standard hover:-translate-y-1 hover:border-ring/40 hover:shadow-md focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring motion-reduce:hover:translate-y-0",
26
+ true: "cursor-pointer transition-[translate,box-shadow,border-color] duration-base ease-standard hover:-translate-y-1 hover:border-ring/40 hover:shadow-md focus-ring motion-reduce:hover:translate-y-0",
27
27
  false: "",
28
28
  },
29
29
  },
@@ -1,6 +1,7 @@
1
1
  import type { Meta, StoryObj } from "@storybook/react-vite";
2
2
  import { expect, userEvent, waitFor, within } from "storybook/test";
3
3
  import { useState } from "react";
4
+ import { type CheckResult } from "../../lib/check-result";
4
5
  import {
5
6
  ChangeReview,
6
7
  ChangeReviewHeader,
@@ -42,6 +43,41 @@ const PROVENANCE = {
42
43
  note: "run-abc123",
43
44
  };
44
45
 
46
+ // ─── Sample hunks with verification checks (#112) ─────────────────────────────
47
+
48
+ const HUNKS_WITH_CHECKS: ChangeHunk[] = [
49
+ {
50
+ id: "checked-1",
51
+ title: "src/api/handler.ts",
52
+ status: "modified",
53
+ before: `function handle(req) { return req.body; }`,
54
+ after: `function handle(req: Request) { return req.body; }`,
55
+ checks: [
56
+ { label: "eslint", ok: true, durationMs: 214 },
57
+ {
58
+ label: "tsc",
59
+ ok: false,
60
+ durationMs: 890,
61
+ detail:
62
+ "src/api/handler.ts:1:31 — Parameter 'req' implicitly has an 'any' type, and the " +
63
+ "surrounding function type annotation does not resolve it. This is a deliberately " +
64
+ "long detail line so it renders behind a collapsed disclosure by default.",
65
+ },
66
+ ],
67
+ },
68
+ {
69
+ id: "checked-2",
70
+ title: "src/api/handler.test.ts",
71
+ status: "added",
72
+ after: `it("handles a request", () => { ... });`,
73
+ checks: [
74
+ { label: "pre_tool_use", ok: true, phase: "before" },
75
+ { label: "eslint", ok: true, phase: "after", durationMs: 120 },
76
+ { label: "vitest", ok: true, phase: "after", detail: "3 passed", durationMs: 1420 },
77
+ ] satisfies CheckResult[],
78
+ },
79
+ ];
80
+
45
81
  // ─── Meta ─────────────────────────────────────────────────────────────────────
46
82
 
47
83
  const meta = {
@@ -53,10 +89,30 @@ const meta = {
53
89
  docs: {
54
90
  description: {
55
91
  component:
92
+ "The ACCEPT/REJECT diff surface; read-only patch lines are `AI/DiffView` and an " +
93
+ "editable side-by-side is `Editor/DiffEditor` — see " +
94
+ "[Choosing between similar components](?path=/docs/docs-choosing-between-similar-components--docs). " +
56
95
  "AI-edit trust gate. When an agent proposes edits, the human reviews them " +
57
96
  "hunk-by-hunk and accepts or rejects each before they apply. Compound " +
58
97
  "component with lifted state — compose parts for custom layouts, or use the " +
59
- "convenience `<ChangeReview>` root.",
98
+ "convenience `<ChangeReview>` root.\n\n" +
99
+ "**Ships from `@elabs-ai/components-ui`**, not from the package this story " +
100
+ "group names. Stories are grouped by what a component is *for*, which " +
101
+ "`docs/STORYBOOK_GUIDELINES.md` names as intentional and cites this component " +
102
+ "as the worked example.\n\n" +
103
+ "**A hunk’s `before` / `after` are prose, not diff lines.** They are ordinary " +
104
+ "`ReactNode`s, drawn as a deliberately quiet no-fill comparison — a struck " +
105
+ "muted “before” above a plain “after”, with the status rail carrying the " +
106
+ "polarity. This component is not a line renderer and is not going to become " +
107
+ "one.\n\n" +
108
+ "**Structured `DiffLine[]` hunks arrive by injection.** The app that depends " +
109
+ "on both packages passes a `<DiffView>` (`@elabs-ai/components-ai`) through " +
110
+ "the `renderHunk` render-prop or a hunk’s `after` slot; neither package " +
111
+ "imports the other. `ChangeHunk` gains no `lines` field and `DiffLine` " +
112
+ "never moves — a ratified seam, recorded in § 3 of the adoption-architecture " +
113
+ "decision. The *Injected hunk renderer* story below shows the shape with a " +
114
+ "stand-in node; the real wiring is *Composed into ChangeReview (ui)* on " +
115
+ "[AI/DiffView](?path=/docs/ai-diffview--docs).",
60
116
  },
61
117
  },
62
118
  },
@@ -239,6 +295,55 @@ export const CustomRenderer: Story = {
239
295
  },
240
296
  };
241
297
 
298
+ // ─── Injected hunk renderer (the DiffView seam) ───────────────────────────────
299
+ //
300
+ // BINDING SEAM — docs/decisions/2026-09-01-brainless-adoption-architecture.md § 3.
301
+ // `ChangeHunk` gains no `lines` field and `DiffLine` never moves into this
302
+ // package. An app that wants structured patch rows inside the trust gate injects
303
+ // a renderer through `renderHunk` (or a hunk's `after` slot); in a real app that
304
+ // node is `<DiffView lines={…} />` from `@elabs-ai/components-ai`.
305
+ //
306
+ // This package has no dependency on that one in EITHER direction — that is the
307
+ // whole point of the seam — so the story stands in for it with a plain node of
308
+ // the same shape. The real composition is the `ChangeReviewComposition` story in
309
+ // `packages/ai/src/diff-view.stories.tsx`, which can import both.
310
+
311
+ function StandInLineRenderer({ file }: { file: string }) {
312
+ return (
313
+ <div
314
+ data-testid={`injected-${file}`}
315
+ className="rounded-md bg-surface-muted p-3 text-meta text-muted-foreground"
316
+ >
317
+ <span className="font-mono">{`<DiffView lines={…} />`}</span> renders the patch rows for{" "}
318
+ <span className="font-medium text-foreground">{file}</span> here.
319
+ </div>
320
+ );
321
+ }
322
+
323
+ export const InjectedHunkRenderer: Story = {
324
+ args: {
325
+ hunks: HUNKS,
326
+ renderHunk: (hunk) => <StandInLineRenderer file={hunk.title ?? hunk.id} />,
327
+ },
328
+ play: async ({ canvasElement }) => {
329
+ const canvas = within(canvasElement);
330
+
331
+ // The injected node replaces the default before/after prose on every hunk…
332
+ await waitFor(() => expect(canvas.getByTestId("injected-config/settings.json")).toBeVisible());
333
+ expect(canvas.getAllByTestId(/^injected-/)).toHaveLength(3);
334
+
335
+ // …and the default prose comparison is gone, because the seam replaces it.
336
+ expect(canvas.queryByText('"timeout": 30')).not.toBeInTheDocument();
337
+
338
+ // Approve/reject still belongs to ChangeReview, not to the injected renderer.
339
+ await userEvent.click(
340
+ canvas.getByRole("button", { name: /approve hunk: config\/settings\.json/i }),
341
+ );
342
+ await waitFor(() => expect(canvas.getByText("1 of 3 approved")).toBeVisible());
343
+ expect(canvas.getByTestId("injected-config/settings.json")).toBeVisible();
344
+ },
345
+ };
346
+
242
347
  // ─── Compound (manual layout) ─────────────────────────────────────────────────
243
348
 
244
349
  export const Compound: Story = {
@@ -286,3 +391,42 @@ edge cases properly and includes proper error boundaries.`,
286
391
  ],
287
392
  },
288
393
  };
394
+
395
+ // ─── With verification checks (#112) ──────────────────────────────────────────
396
+ // Evidence a reviewer needs (lint / types / tests / policy hooks) rendered
397
+ // alongside the diff. A failing check informs the decision — it never blocks
398
+ // approval, so `checked-1` (which carries a failing `tsc` check) can still be
399
+ // approved below.
400
+
401
+ export const WithChecks: Story = {
402
+ args: {
403
+ hunks: HUNKS_WITH_CHECKS,
404
+ },
405
+ play: async ({ canvasElement }) => {
406
+ const canvas = within(canvasElement);
407
+ await waitFor(() => expect(canvas.getByText("src/api/handler.ts")).toBeVisible());
408
+
409
+ // Passing and failing checks are distinguishable by accessible text —
410
+ // never by colour alone.
411
+ await waitFor(() => expect(canvas.getAllByText("Passed").length).toBeGreaterThan(0));
412
+ await waitFor(() => expect(canvas.getAllByText("Failed").length).toBeGreaterThan(0));
413
+
414
+ // The long `tsc` detail is collapsed by default…
415
+ const showDetail = canvas.getByRole("button", { name: /show detail/i });
416
+ expect(canvas.queryByText(/implicitly has an 'any' type/)).not.toBeInTheDocument();
417
+ // …and expands on click.
418
+ await userEvent.click(showDetail);
419
+ await waitFor(() => expect(canvas.getByText(/implicitly has an 'any' type/)).toBeVisible());
420
+
421
+ // Both phases present on the second hunk → grouped "Before" / "After".
422
+ await waitFor(() => expect(canvas.getByText("Before")).toBeVisible());
423
+ await waitFor(() => expect(canvas.getByText("After")).toBeVisible());
424
+
425
+ // A failing check does not block approval — it only informs it.
426
+ const approveFirstHunk = canvas.getByRole("button", {
427
+ name: /approve hunk: src\/api\/handler\.ts/i,
428
+ });
429
+ await userEvent.click(approveFirstHunk);
430
+ await waitFor(() => expect(canvas.getByText("1 of 2 approved")).toBeVisible());
431
+ },
432
+ };
@@ -65,6 +65,26 @@ describe("ChangeReview — render", () => {
65
65
  );
66
66
  expect(screen.getByTestId("custom-prov")).toBeInTheDocument();
67
67
  });
68
+
69
+ it("exposes structured provenance as a named group reachable by assistive tech", () => {
70
+ // aria-label on a role-less <div> is prohibited and silently discarded (#159) —
71
+ // assert the accessible name actually resolves via the ARIA role tree, not just
72
+ // that the attribute is present in the DOM.
73
+ render(
74
+ <ChangeReview
75
+ hunks={HUNKS}
76
+ provenance={{ author: "Atlas Agent", model: "claude-sonnet-4-6", timestamp: "14:02" }}
77
+ />,
78
+ );
79
+ expect(screen.getByRole("group", { name: "Change provenance" })).toBeInTheDocument();
80
+ });
81
+
82
+ it("exposes ReactNode provenance as a named group reachable by assistive tech", () => {
83
+ render(
84
+ <ChangeReview hunks={HUNKS} provenance={<span data-testid="custom-prov">Custom</span>} />,
85
+ );
86
+ expect(screen.getByRole("group", { name: "Change provenance" })).toBeInTheDocument();
87
+ });
68
88
  });
69
89
 
70
90
  // ─── Initial state with controlled approved set ───────────────────────────────
@@ -232,6 +252,129 @@ describe("ChangeReview — accessibility", () => {
232
252
  });
233
253
  });
234
254
 
255
+ // ─── Checks (verification evidence, #112) ─────────────────────────────────────
256
+
257
+ describe("ChangeReview — checks", () => {
258
+ it("shows a failing check's status in accessible text with an icon distinct from a passing check's", () => {
259
+ const hunksWithChecks: ChangeHunk[] = [
260
+ {
261
+ id: "h1",
262
+ title: "config/a.json",
263
+ status: "modified",
264
+ before: "old",
265
+ after: "new",
266
+ checks: [
267
+ { label: "eslint", ok: true },
268
+ { label: "tsc", ok: false, detail: "Type 'string' is not assignable to type 'number'." },
269
+ ],
270
+ },
271
+ ];
272
+ render(<ChangeReview hunks={hunksWithChecks} />);
273
+
274
+ // Accessible text carries the pass/fail status word — not color alone.
275
+ const passed = screen.getByText("Passed");
276
+ const failed = screen.getByText("Failed");
277
+ expect(passed).toBeInTheDocument();
278
+ expect(failed).toBeInTheDocument();
279
+
280
+ // The icon differs between a passing and a failing check (StatusIcon's
281
+ // canonical `complete` vs `failed` glyphs — never colour alone).
282
+ const passRow = passed.closest('[data-slot="change-review-hunk-check"]')!;
283
+ const failRow = failed.closest('[data-slot="change-review-hunk-check"]')!;
284
+ const passIcon = passRow.querySelector("svg[data-status]");
285
+ const failIcon = failRow.querySelector("svg[data-status]");
286
+ expect(passIcon).toHaveAttribute("data-status", "complete");
287
+ expect(failIcon).toHaveAttribute("data-status", "failed");
288
+ expect(passIcon?.getAttribute("data-status")).not.toBe(failIcon?.getAttribute("data-status"));
289
+ });
290
+
291
+ it("allows approving a hunk that carries a failing check", async () => {
292
+ const user = userEvent.setup();
293
+ const hunksWithFailingCheck: ChangeHunk[] = [
294
+ {
295
+ id: "h1",
296
+ title: "config/a.json",
297
+ status: "modified",
298
+ before: "old",
299
+ after: "new",
300
+ checks: [{ label: "tsc", ok: false, detail: "Type error" }],
301
+ },
302
+ ];
303
+ render(<ChangeReview hunks={hunksWithFailingCheck} />);
304
+
305
+ expect(screen.getByText("Failed")).toBeInTheDocument();
306
+
307
+ const btn = screen.getByRole("button", { name: /approve hunk: config\/a\.json/i });
308
+ await user.click(btn);
309
+
310
+ expect(screen.getByText("1 of 1 approved")).toBeInTheDocument();
311
+ expect(screen.getByText("Approved")).toBeInTheDocument();
312
+ // The failing check is still shown — informational, not blocking.
313
+ expect(screen.getByText("Failed")).toBeInTheDocument();
314
+ });
315
+
316
+ it("does not render a checks section when a hunk has no checks (strictly additive)", () => {
317
+ render(<ChangeReview hunks={HUNKS} />);
318
+ expect(screen.queryByText("Passed")).not.toBeInTheDocument();
319
+ expect(screen.queryByText("Failed")).not.toBeInTheDocument();
320
+ });
321
+
322
+ it("groups checks into Before / After sections only when both phases are present", () => {
323
+ const bothPhases: ChangeHunk[] = [
324
+ {
325
+ id: "h1",
326
+ checks: [
327
+ { label: "pre_tool_use", ok: true, phase: "before" },
328
+ { label: "post_tool_use", ok: true, phase: "after" },
329
+ ],
330
+ },
331
+ ];
332
+ render(<ChangeReview hunks={bothPhases} />);
333
+ expect(screen.getByText("Before")).toBeInTheDocument();
334
+ expect(screen.getByText("After")).toBeInTheDocument();
335
+ });
336
+
337
+ it("does not render phase headers when only one phase is present", () => {
338
+ const onePhase: ChangeHunk[] = [
339
+ {
340
+ id: "h1",
341
+ checks: [{ label: "eslint", ok: true, phase: "before" }],
342
+ },
343
+ ];
344
+ render(<ChangeReview hunks={onePhase} />);
345
+ expect(screen.queryByText("Before")).not.toBeInTheDocument();
346
+ expect(screen.queryByText("After")).not.toBeInTheDocument();
347
+ expect(screen.getByText("eslint")).toBeInTheDocument();
348
+ });
349
+
350
+ it("renders a short detail line inline and shows the duration", () => {
351
+ const hunksWithDuration: ChangeHunk[] = [
352
+ { id: "h1", checks: [{ label: "eslint", ok: true, detail: "0 problems", durationMs: 128 }] },
353
+ ];
354
+ render(<ChangeReview hunks={hunksWithDuration} />);
355
+ expect(screen.getByText("0 problems")).toBeInTheDocument();
356
+ expect(screen.getByText("128ms")).toBeInTheDocument();
357
+ });
358
+
359
+ it("collapses a long detail line behind a disclosure, expandable on click", async () => {
360
+ const user = userEvent.setup();
361
+ const longDetail =
362
+ "This is a deliberately long detail line that exceeds the inline collapse " +
363
+ "threshold and should therefore render behind a disclosure toggle by default.";
364
+ const hunksWithLongDetail: ChangeHunk[] = [
365
+ { id: "h1", checks: [{ label: "tsc", ok: false, detail: longDetail }] },
366
+ ];
367
+ render(<ChangeReview hunks={hunksWithLongDetail} />);
368
+
369
+ // Collapsed by default: the detail text is not yet in the document.
370
+ expect(screen.queryByText(longDetail)).not.toBeInTheDocument();
371
+ const trigger = screen.getByRole("button", { name: /show detail/i });
372
+
373
+ await user.click(trigger);
374
+ expect(await screen.findByText(longDetail)).toBeInTheDocument();
375
+ });
376
+ });
377
+
235
378
  // ─── Compound usage ───────────────────────────────────────────────────────────
236
379
 
237
380
  describe("ChangeReview — compound component parts", () => {