@elabs-ai/components-ui 4.0.0 → 4.2.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 (339) hide show
  1. package/README.md +10 -8
  2. package/dist/chunk-FCH4ZN6G.js +57 -0
  3. package/dist/chunk-FCH4ZN6G.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 +2597 -188
  8. package/dist/index.js +12855 -3578
  9. package/dist/index.js.map +1 -1
  10. package/dist/lib/cn.js +10 -1
  11. package/dist/lib/cn.js.map +1 -1
  12. package/package.json +21 -7
  13. package/src/__contract__/attribution-panel.contract.test.tsx +49 -0
  14. package/src/__contract__/badge.contract.test.tsx +49 -0
  15. package/src/__contract__/bounded-number.contract.test.tsx +49 -0
  16. package/src/__contract__/button.contract.test.tsx +49 -0
  17. package/src/__contract__/color-picker.contract.test.tsx +49 -0
  18. package/src/__contract__/combobox.contract.test.tsx +49 -0
  19. package/src/__contract__/command-trigger.contract.test.tsx +49 -0
  20. package/src/__contract__/copyable-value.contract.test.tsx +49 -0
  21. package/src/__contract__/date-picker.contract.test.tsx +49 -0
  22. package/src/__contract__/date-range-picker.contract.test.tsx +49 -0
  23. package/src/__contract__/empty-state.contract.test.tsx +49 -0
  24. package/src/__contract__/error-state.contract.test.tsx +49 -0
  25. package/src/__contract__/icon-button.contract.test.tsx +49 -0
  26. package/src/__contract__/input.contract.test.tsx +49 -0
  27. package/src/__contract__/key-value-editor.contract.test.tsx +49 -0
  28. package/src/__contract__/list-editor.contract.test.tsx +49 -0
  29. package/src/__contract__/loading-state.contract.test.tsx +49 -0
  30. package/src/__contract__/metric-card.contract.test.tsx +49 -0
  31. package/src/__contract__/model-picker.contract.test.tsx +49 -0
  32. package/src/__contract__/nav-notifications.contract.test.tsx +49 -0
  33. package/src/__contract__/number-input.contract.test.tsx +49 -0
  34. package/src/__contract__/rating.contract.test.tsx +49 -0
  35. package/src/__contract__/schema-form.contract.test.tsx +49 -0
  36. package/src/__contract__/section-header.contract.test.tsx +49 -0
  37. package/src/__contract__/segmented-field.contract.test.tsx +49 -0
  38. package/src/__contract__/slider-number.contract.test.tsx +49 -0
  39. package/src/__contract__/status-badge.contract.test.tsx +49 -0
  40. package/src/__contract__/tag-input.contract.test.tsx +49 -0
  41. package/src/__contract__/text.contract.test.tsx +49 -0
  42. package/src/__contract__/textarea.contract.test.tsx +49 -0
  43. package/src/__contract__/timeline.contract.test.tsx +49 -0
  44. package/src/__contract__/toggle.contract.test.tsx +49 -0
  45. package/src/__contract__/transfer.contract.test.tsx +49 -0
  46. package/src/__contract__/tree-select.contract.test.tsx +49 -0
  47. package/src/__contract__/tree.contract.test.tsx +49 -0
  48. package/src/__contract__/virtual-select.contract.test.tsx +49 -0
  49. package/src/__contract__/workspace-picker.contract.test.tsx +49 -0
  50. package/src/_peer-deps.test.ts +59 -0
  51. package/src/blocks-comparison-table.stories.tsx +2 -2
  52. package/src/components/accordion/accordion.tsx +13 -4
  53. package/src/components/advanced-group/advanced-group.tsx +1 -1
  54. package/src/components/alert/alert.tsx +15 -4
  55. package/src/components/alert-dialog/alert-dialog.test.tsx +32 -0
  56. package/src/components/alert-dialog/alert-dialog.tsx +28 -6
  57. package/src/components/app-shell/app-shell.stories.tsx +44 -2
  58. package/src/components/app-shell/app-shell.test.tsx +99 -0
  59. package/src/components/app-shell/app-shell.tsx +56 -4
  60. package/src/components/app-sidebar/app-sidebar.stories.tsx +9 -1
  61. package/src/components/attribution-panel/attribution-panel.tsx +3 -1
  62. package/src/components/attribution-panel/attributions.generated.ts +83 -79
  63. package/src/components/avatar/avatar.tsx +4 -1
  64. package/src/components/badge/badge.stories.tsx +8 -0
  65. package/src/components/badge/badge.test.tsx +8 -0
  66. package/src/components/badge/badge.tsx +15 -5
  67. package/src/components/bento-grid/bento-grid.test.tsx +24 -3
  68. package/src/components/bento-grid/bento-grid.tsx +11 -1
  69. package/src/components/bounded-number/bounded-number.test.tsx +1 -1
  70. package/src/components/bounded-number/bounded-number.tsx +78 -75
  71. package/src/components/breadcrumb/breadcrumb.stories.tsx +38 -0
  72. package/src/components/breadcrumb/breadcrumb.test.tsx +24 -1
  73. package/src/components/breadcrumb/breadcrumb.tsx +10 -4
  74. package/src/components/button/button.stories.tsx +116 -8
  75. package/src/components/button/button.tsx +18 -12
  76. package/src/components/button-group/button-group.tsx +1 -1
  77. package/src/components/calendar/calendar.tsx +3 -2
  78. package/src/components/card/card.stories.tsx +11 -6
  79. package/src/components/card/card.test.tsx +24 -2
  80. package/src/components/card/card.tsx +48 -7
  81. package/src/components/carousel/carousel.test.tsx +169 -0
  82. package/src/components/carousel/carousel.tsx +93 -32
  83. package/src/components/change-review/change-review.stories.tsx +145 -1
  84. package/src/components/change-review/change-review.test.tsx +143 -0
  85. package/src/components/change-review/change-review.tsx +214 -24
  86. package/src/components/checkbox/checkbox.tsx +2 -1
  87. package/src/components/collapsible-panel/use-collapsible-panel.ts +0 -3
  88. package/src/components/color-picker/color-picker.tsx +22 -15
  89. package/src/components/combobox/combobox.stories.tsx +10 -0
  90. package/src/components/combobox/combobox.test.tsx +54 -0
  91. package/src/components/combobox/combobox.tsx +25 -11
  92. package/src/components/command/command.stories.tsx +16 -6
  93. package/src/components/command/command.test.tsx +88 -0
  94. package/src/components/command/command.tsx +38 -13
  95. package/src/components/command/index.ts +1 -0
  96. package/src/components/command-trigger/command-trigger.stories.tsx +39 -0
  97. package/src/components/command-trigger/command-trigger.test.tsx +18 -0
  98. package/src/components/command-trigger/command-trigger.tsx +70 -0
  99. package/src/components/command-trigger/index.ts +1 -0
  100. package/src/components/context-menu/context-menu.tsx +16 -7
  101. package/src/components/context-rail/context-rail.stories.tsx +285 -0
  102. package/src/components/context-rail/context-rail.test.tsx +263 -0
  103. package/src/components/context-rail/context-rail.tsx +678 -0
  104. package/src/components/context-rail/index.ts +1 -0
  105. package/src/components/copyable-value/copyable-value.stories.tsx +10 -0
  106. package/src/components/copyable-value/copyable-value.test.tsx +28 -1
  107. package/src/components/copyable-value/copyable-value.tsx +13 -2
  108. package/src/components/date-picker/date-picker.stories.tsx +9 -0
  109. package/src/components/date-picker/date-picker.test.tsx +52 -0
  110. package/src/components/date-picker/date-picker.tsx +13 -15
  111. package/src/components/date-range-picker/date-range-picker.stories.tsx +9 -0
  112. package/src/components/date-range-picker/date-range-picker.test.tsx +76 -1
  113. package/src/components/date-range-picker/date-range-picker.tsx +39 -28
  114. package/src/components/descriptions/descriptions.tsx +1 -1
  115. package/src/components/dialog/dialog.test.tsx +8 -3
  116. package/src/components/dialog/dialog.tsx +14 -6
  117. package/src/components/drawer/drawer.tsx +23 -6
  118. package/src/components/dropdown-menu/dropdown-menu.stories.tsx +1 -1
  119. package/src/components/dropdown-menu/dropdown-menu.tsx +4 -4
  120. package/src/components/empty-state/empty-state.test.tsx +26 -0
  121. package/src/components/empty-state/empty-state.tsx +6 -1
  122. package/src/components/expand-dialog/expand-dialog.tsx +1 -2
  123. package/src/components/field/field-context.ts +59 -0
  124. package/src/components/field/field.stories.tsx +142 -0
  125. package/src/components/field/field.test.tsx +415 -0
  126. package/src/components/field/field.tsx +342 -0
  127. package/src/components/field/index.ts +9 -0
  128. package/src/components/field-row/field-row.stories.tsx +85 -1
  129. package/src/components/field-row/field-row.test.tsx +145 -0
  130. package/src/components/field-row/field-row.tsx +50 -6
  131. package/src/components/file-upload/file-upload.stories.tsx +16 -0
  132. package/src/components/file-upload/file-upload.test.tsx +90 -12
  133. package/src/components/file-upload/file-upload.tsx +164 -31
  134. package/src/components/form/form.tsx +8 -3
  135. package/src/components/hover-card/hover-card.tsx +1 -0
  136. package/src/components/icon-button/icon-button.test.tsx +1 -1
  137. package/src/components/icon-button/icon-button.tsx +3 -3
  138. package/src/components/input/input.stories.tsx +121 -0
  139. package/src/components/input/input.test.tsx +13 -0
  140. package/src/components/input/input.tsx +8 -4
  141. package/src/components/input-group/input-group.stories.tsx +74 -0
  142. package/src/components/input-group/input-group.tsx +18 -9
  143. package/src/components/input-otp/input-otp.tsx +8 -2
  144. package/src/components/keyboard-shortcuts/index.ts +6 -0
  145. package/src/components/keyboard-shortcuts/keyboard-shortcuts.stories.tsx +177 -0
  146. package/src/components/keyboard-shortcuts/keyboard-shortcuts.test.tsx +73 -0
  147. package/src/components/keyboard-shortcuts/keyboard-shortcuts.tsx +203 -0
  148. package/src/components/label/label.tsx +1 -1
  149. package/src/components/link-preview/link-preview.tsx +1 -1
  150. package/src/components/list-editor/list-editor.test.tsx +24 -0
  151. package/src/components/list-editor/list-editor.tsx +126 -52
  152. package/src/components/locale-provider/index.ts +6 -1
  153. package/src/components/locale-provider/locale-keys.test.ts +134 -0
  154. package/src/components/locale-provider/locale-provider.stories.tsx +89 -1
  155. package/src/components/locale-provider/locale-provider.test.tsx +257 -1
  156. package/src/components/locale-provider/locale-provider.tsx +63 -8
  157. package/src/components/locale-provider/messages.ts +822 -2
  158. package/src/components/match-highlight/match-highlight.stories.tsx +1 -1
  159. package/src/components/mention-input/mention-input-mirror.stories.tsx +9 -1
  160. package/src/components/mention-input/mention-input.stories.tsx +3 -3
  161. package/src/components/mention-input/mention-input.test.tsx +51 -1
  162. package/src/components/mention-input/mention-input.tsx +66 -46
  163. package/src/components/mention-input/mention-value.ts +8 -2
  164. package/src/components/menubar/menubar.tsx +16 -6
  165. package/src/components/metric-card/metric-card.stories.tsx +5 -3
  166. package/src/components/metric-card/metric-card.tsx +21 -2
  167. package/src/components/model-picker/model-picker.stories.tsx +63 -3
  168. package/src/components/model-picker/model-picker.test.tsx +63 -0
  169. package/src/components/model-picker/model-picker.tsx +62 -36
  170. package/src/components/nav-main/nav-main.tsx +8 -4
  171. package/src/components/nav-notifications/nav-notifications.stories.tsx +110 -0
  172. package/src/components/nav-notifications/nav-notifications.test.tsx +11 -0
  173. package/src/components/nav-notifications/nav-notifications.tsx +67 -9
  174. package/src/components/nav-user/nav-user.tsx +5 -5
  175. package/src/components/navigation-menu/navigation-menu.stories.tsx +22 -0
  176. package/src/components/navigation-menu/navigation-menu.test.tsx +174 -0
  177. package/src/components/navigation-menu/navigation-menu.tsx +204 -15
  178. package/src/components/number-input/number-input.stories.tsx +7 -3
  179. package/src/components/number-input/number-input.test.tsx +47 -0
  180. package/src/components/number-input/number-input.tsx +65 -24
  181. package/src/components/page-shell/page-shell.stories.tsx +163 -0
  182. package/src/components/page-shell/page-shell.test.tsx +229 -0
  183. package/src/components/page-shell/page-shell.tsx +108 -25
  184. package/src/components/pagination/pagination.test.tsx +109 -0
  185. package/src/components/pagination/pagination.tsx +27 -19
  186. package/src/components/popover/popover.tsx +1 -0
  187. package/src/components/progress/progress.tsx +2 -0
  188. package/src/components/radio-group/radio-group.tsx +1 -1
  189. package/src/components/rating/rating.tsx +3 -1
  190. package/src/components/resizable/resizable.stories.tsx +114 -2
  191. package/src/components/resizable/resizable.test.tsx +76 -0
  192. package/src/components/resizable/resizable.tsx +32 -6
  193. package/src/components/revision-timeline/revision-timeline.stories.tsx +8 -1
  194. package/src/components/revision-timeline/revision-timeline.test.tsx +26 -0
  195. package/src/components/revision-timeline/revision-timeline.tsx +53 -5
  196. package/src/components/schema-form/from-json-schema.test.ts +390 -0
  197. package/src/components/schema-form/from-json-schema.ts +375 -0
  198. package/src/components/schema-form/index.ts +81 -0
  199. package/src/components/schema-form/schema-form-spec.ts +698 -0
  200. package/src/components/schema-form/schema-form-store.ts +506 -0
  201. package/src/components/schema-form/schema-form.stories.tsx +566 -0
  202. package/src/components/schema-form/schema-form.test.tsx +1021 -0
  203. package/src/components/schema-form/schema-form.tsx +1381 -0
  204. package/src/components/section-header/section-header.stories.tsx +37 -0
  205. package/src/components/section-header/section-header.tsx +14 -3
  206. package/src/components/select/select.tsx +13 -7
  207. package/src/components/separator/separator.tsx +1 -0
  208. package/src/components/sheet/sheet.test.tsx +39 -0
  209. package/src/components/sheet/sheet.tsx +40 -9
  210. package/src/components/side-dock/index.ts +1 -0
  211. package/src/components/side-dock/side-dock.stories.tsx +137 -0
  212. package/src/components/side-dock/side-dock.test.tsx +299 -0
  213. package/src/components/side-dock/side-dock.tsx +515 -0
  214. package/src/components/sidebar/index.ts +1 -0
  215. package/src/components/sidebar/sidebar-frame.test.tsx +162 -0
  216. package/src/components/sidebar/sidebar.stories.tsx +421 -1
  217. package/src/components/sidebar/sidebar.test.tsx +71 -2
  218. package/src/components/sidebar/sidebar.tsx +264 -38
  219. package/src/components/skeleton/skeleton.tsx +1 -0
  220. package/src/components/skip-link/index.ts +1 -0
  221. package/src/components/skip-link/skip-link.stories.tsx +106 -0
  222. package/src/components/skip-link/skip-link.test.tsx +20 -0
  223. package/src/components/skip-link/skip-link.tsx +34 -0
  224. package/src/components/slider/slider.tsx +1 -1
  225. package/src/components/slider-number/slider-number.stories.tsx +4 -1
  226. package/src/components/slider-number/slider-number.test.tsx +1 -1
  227. package/src/components/sonner/sonner.tsx +2 -0
  228. package/src/components/spinner/spinner.test.tsx +18 -0
  229. package/src/components/spinner/spinner.tsx +14 -4
  230. package/src/components/split-panel/index.ts +6 -1
  231. package/src/components/split-panel/split-panel.stories.tsx +18 -2
  232. package/src/components/split-panel/split-panel.test.tsx +20 -0
  233. package/src/components/split-panel/split-panel.tsx +28 -8
  234. package/src/components/state-panel/state-panel.stories.tsx +52 -0
  235. package/src/components/state-panel/state-panel.test.tsx +125 -0
  236. package/src/components/state-panel/state-panel.tsx +110 -24
  237. package/src/components/status-badge/status-badge.stories.tsx +8 -0
  238. package/src/components/switch/switch.tsx +3 -1
  239. package/src/components/table/table.stories.tsx +64 -0
  240. package/src/components/table/table.test.tsx +149 -1
  241. package/src/components/table/table.tsx +71 -2
  242. package/src/components/tabs/index.ts +10 -1
  243. package/src/components/tabs/tabs.stories.tsx +48 -0
  244. package/src/components/tabs/tabs.test.tsx +67 -0
  245. package/src/components/tabs/tabs.tsx +176 -72
  246. package/src/components/tag-input/tag-input.tsx +6 -3
  247. package/src/components/team-switcher/team-switcher.tsx +35 -6
  248. package/src/components/textarea/textarea.tsx +2 -2
  249. package/src/components/theme-switcher/theme-switcher.stories.tsx +76 -0
  250. package/src/components/theme-switcher/theme-switcher.test.tsx +182 -0
  251. package/src/components/theme-switcher/theme-switcher.tsx +140 -12
  252. package/src/components/theme-switcher/use-theme-transition.ts +2 -2
  253. package/src/components/timeline/timeline.stories.tsx +27 -0
  254. package/src/components/timeline/timeline.tsx +10 -3
  255. package/src/components/toggle/toggle.tsx +7 -2
  256. package/src/components/toggle-group/toggle-group.tsx +2 -0
  257. package/src/components/toolbar/toolbar.stories.tsx +10 -2
  258. package/src/components/tooltip/tooltip.tsx +2 -1
  259. package/src/components/top-nav/top-nav.tsx +1 -1
  260. package/src/components/transfer/transfer.tsx +35 -30
  261. package/src/components/tree/tree.stories.tsx +2 -2
  262. package/src/components/tree/tree.test.tsx +41 -0
  263. package/src/components/tree/tree.tsx +91 -31
  264. package/src/components/tree/use-tree-keyboard.ts +37 -14
  265. package/src/components/tree-select/tree-select.stories.tsx +9 -0
  266. package/src/components/tree-select/tree-select.test.tsx +5 -0
  267. package/src/components/tree-select/tree-select.tsx +11 -14
  268. package/src/components/typography/prose.test.tsx +3 -2
  269. package/src/components/typography/prose.tsx +3 -3
  270. package/src/components/typography/typography.stories.tsx +228 -0
  271. package/src/components/view-toolbar/view-toolbar.stories.tsx +41 -1
  272. package/src/components/view-toolbar/view-toolbar.test.tsx +33 -0
  273. package/src/components/view-toolbar/view-toolbar.tsx +37 -4
  274. package/src/components/virtual-select/virtual-select.stories.tsx +9 -0
  275. package/src/components/virtual-select/virtual-select.test.tsx +29 -3
  276. package/src/components/virtual-select/virtual-select.tsx +56 -18
  277. package/src/components/wizard/wizard.test.tsx +9 -0
  278. package/src/components/wizard/wizard.tsx +13 -5
  279. package/src/components/workspace-picker/index.ts +3 -0
  280. package/src/components/workspace-picker/workspace-picker-state.ts +50 -0
  281. package/src/components/workspace-picker/workspace-picker.stories.tsx +126 -0
  282. package/src/components/workspace-picker/workspace-picker.test.tsx +207 -0
  283. package/src/components/workspace-picker/workspace-picker.tsx +172 -0
  284. package/src/illustrations/empty-list-illustration.tsx +26 -0
  285. package/src/illustrations/error-illustration.tsx +60 -0
  286. package/src/illustrations/first-run-illustration.tsx +27 -0
  287. package/src/illustrations/illustration-base.tsx +108 -0
  288. package/src/illustrations/illustrations.stories.tsx +94 -0
  289. package/src/illustrations/illustrations.test.tsx +98 -0
  290. package/src/illustrations/index.ts +16 -0
  291. package/src/illustrations/no-access-illustration.tsx +45 -0
  292. package/src/illustrations/no-results-illustration.tsx +46 -0
  293. package/src/illustrations/offline-illustration.tsx +37 -0
  294. package/src/illustrations/success-illustration.tsx +40 -0
  295. package/src/index.ts +80 -2
  296. package/src/lib/agent-event-model.test.ts +12 -0
  297. package/src/lib/agent-event-model.ts +42 -0
  298. package/src/lib/approval-option.test.ts +17 -0
  299. package/src/lib/approval-option.ts +53 -0
  300. package/src/lib/check-result.ts +33 -0
  301. package/src/lib/cn.ts +9 -0
  302. package/src/lib/csv.ts +48 -0
  303. package/src/lib/diff-line.test.ts +36 -0
  304. package/src/lib/diff-line.ts +63 -0
  305. package/src/lib/diff-rows.test.ts +76 -0
  306. package/src/lib/diff-rows.ts +94 -0
  307. package/src/lib/format-duration.test.ts +22 -0
  308. package/src/lib/format-duration.ts +30 -0
  309. package/src/lib/has-renderable-content.test.ts +49 -0
  310. package/src/lib/has-renderable-content.ts +32 -0
  311. package/src/lib/merge-refs.ts +2 -0
  312. package/src/lib/operating-mode.test.ts +36 -0
  313. package/src/lib/operating-mode.ts +81 -0
  314. package/src/lib/optional-peer.ts +59 -0
  315. package/src/lib/session-launch.ts +37 -0
  316. package/src/lib/slash-command.test.ts +44 -0
  317. package/src/lib/slash-command.ts +45 -0
  318. package/src/lib/trigger-query.test.ts +94 -0
  319. package/src/lib/trigger-query.ts +88 -0
  320. package/src/lib/use-controllable-state.ts +47 -0
  321. package/src/lib/use-mobile.test.tsx +47 -0
  322. package/src/lib/use-mobile.ts +34 -12
  323. package/src/motion.stories.tsx +6 -0
  324. package/src/templates-object-detail-hub.stories.tsx +2 -2
  325. package/src/templates-screen-states.stories.tsx +5 -5
  326. package/src/templates-settings.stories.tsx +1 -1
  327. package/src/blocks/sidebar-02/app-sidebar.tsx +0 -160
  328. package/src/blocks/sidebar-02/logo.tsx +0 -18
  329. package/src/blocks/sidebar-02/nav-main.tsx +0 -13
  330. package/src/blocks/sidebar-02/nav-notifications.tsx +0 -7
  331. package/src/blocks/sidebar-02/sidebar-02.stories.tsx +0 -23
  332. package/src/blocks/sidebar-02/team-switcher.tsx +0 -7
  333. package/src/blocks/sidebar-04/app-sidebar.tsx +0 -236
  334. package/src/blocks/sidebar-04/mail-context.tsx +0 -29
  335. package/src/blocks/sidebar-04/nav-user.tsx +0 -6
  336. package/src/blocks/sidebar-04/sidebar-04.stories.tsx +0 -150
  337. package/src/blocks/sidebar-05/app-sidebar.tsx +0 -375
  338. package/src/blocks/sidebar-05/sidebar-05.stories.tsx +0 -18
  339. package/src/blocks/sidebar-05/team-switcher.tsx +0 -6
@@ -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,10 +2,12 @@ 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) {
8
- return <nav ref={ref} aria-label="breadcrumb" {...props} />;
9
+ const { t } = useLocale();
10
+ return <nav ref={ref} aria-label={t("ui.breadcrumb.label")} {...props} />;
9
11
  },
10
12
  );
11
13
  export const BreadcrumbList = forwardRef<HTMLOListElement, ComponentProps<"ol">>(
@@ -14,7 +16,7 @@ export const BreadcrumbList = forwardRef<HTMLOListElement, ComponentProps<"ol">>
14
16
  <ol
15
17
  ref={ref}
16
18
  className={cn(
17
- "flex flex-wrap items-center gap-1.5 break-words text-sm text-muted-foreground",
19
+ "flex flex-wrap items-center gap-1.5 break-words text-body text-muted-foreground",
18
20
  className,
19
21
  )}
20
22
  {...props}
@@ -37,7 +39,10 @@ export const BreadcrumbLink = forwardRef<
37
39
  return (
38
40
  <Comp
39
41
  ref={ref}
40
- className={cn("transition-colors duration-fast hover:text-foreground", className)}
42
+ className={cn(
43
+ "rounded-sm transition-colors duration-fast hover:text-foreground focus-ring",
44
+ className,
45
+ )}
41
46
  {...props}
42
47
  />
43
48
  );
@@ -69,6 +74,7 @@ export function BreadcrumbSeparator({ children, className, ...props }: Component
69
74
  );
70
75
  }
71
76
  export function BreadcrumbEllipsis({ className, ...props }: ComponentProps<"span">) {
77
+ const { t } = useLocale();
72
78
  return (
73
79
  <span
74
80
  role="presentation"
@@ -77,7 +83,7 @@ export function BreadcrumbEllipsis({ className, ...props }: ComponentProps<"span
77
83
  {...props}
78
84
  >
79
85
  <MoreHorizontal className="size-4" />
80
- <span className="sr-only">More</span>
86
+ <span className="sr-only">{t("more")}</span>
81
87
  </span>
82
88
  );
83
89
  }
@@ -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,15 +4,21 @@ 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-control text-body font-control 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: {
11
+ // Disabled treatment is ONE rule for every variant, inherited from the
12
+ // base string above (`disabled:pointer-events-none disabled:opacity-50`)
13
+ // — no per-variant fill swap. A solid-fill variant fading via opacity
14
+ // still reads as "off" against `--muted`/`--background` in both themes;
15
+ // keeping one mechanism (vs. a bg-muted swap on some variants only) is
16
+ // what made Input's disabled state internally inconsistent (#286).
11
17
  default:
12
- "bg-primary text-primary-foreground hover:bg-primary/90 disabled:bg-muted disabled:text-muted-foreground disabled:opacity-100",
18
+ "bg-primary text-primary-foreground hover:bg-primary-hover active:bg-primary-active",
13
19
  secondary: "bg-secondary text-secondary-foreground hover:bg-secondary/80",
14
20
  destructive:
15
- "bg-destructive text-destructive-foreground hover:bg-destructive/90 disabled:bg-muted disabled:text-muted-foreground disabled:opacity-100",
21
+ "bg-destructive text-destructive-foreground hover:bg-destructive-hover active:bg-destructive-active",
16
22
  outline: "border border-input bg-background hover:bg-accent hover:text-accent-foreground",
17
23
  // The calm "outlined but quiet" rung (#194, research 02 §3a). `outline` uses
18
24
  // the form-field `border-input` token, `outline-subtle` uses `border-border`.
@@ -24,17 +30,17 @@ export const buttonVariants = cva(
24
30
  "outline-subtle":
25
31
  "border border-border bg-background hover:bg-accent hover:text-accent-foreground",
26
32
  ghost: "hover:bg-accent hover:text-accent-foreground",
27
- // #399 — a link button is TEXT on the page, so it takes the on-surface
28
- // `-text` rung; `bg-primary` above keeps the fill rung.
29
- link: "text-primary-text underline-offset-4 hover:underline",
33
+ // #399 — a link button is TEXT on the page: `--link` defaults to the
34
+ // on-surface `--primary-text` rung; `bg-primary` above keeps the fill rung.
35
+ link: "text-link underline-offset-4 hover:underline",
30
36
  },
31
37
  size: {
32
- sm: "h-8 rounded-md px-3 text-meta",
33
- default: "h-9 px-4 py-2",
34
- lg: "h-10 rounded-md px-6",
35
- icon: "size-9",
36
- "icon-sm": "size-8",
37
- "icon-lg": "size-10",
38
+ sm: "h-control-sm px-3 text-meta",
39
+ default: "h-control px-4 py-2",
40
+ lg: "h-control-lg px-6",
41
+ icon: "size-control",
42
+ "icon-sm": "size-control-sm",
43
+ "icon-lg": "size-control-lg",
38
44
  },
39
45
  },
40
46
  defaultVariants: { variant: "default", size: "default" },
@@ -44,7 +44,7 @@ export const ButtonGroupText = forwardRef<
44
44
  <Comp
45
45
  ref={ref}
46
46
  className={cn(
47
- "flex items-center gap-2 rounded-md border bg-muted px-4 text-sm font-medium shadow-sm [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4",
47
+ "flex items-center gap-2 rounded-md border bg-muted px-4 text-body font-medium shadow-sm [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4",
48
48
  className,
49
49
  )}
50
50
  {...props}
@@ -14,6 +14,7 @@ export function Calendar({
14
14
  }: CalendarProps) {
15
15
  return (
16
16
  <DayPicker
17
+ data-slot="calendar"
17
18
  showOutsideDays={showOutsideDays}
18
19
  className={cn("p-3", className)}
19
20
  classNames={{
@@ -26,7 +27,7 @@ export function Calendar({
26
27
  // for a taller caption (e.g. `captionLayout="dropdown"`), and `px-8` keeps
27
28
  // the label clear of the buttons overlaying either edge.
28
29
  month_caption: "flex min-h-7 items-center justify-center px-8",
29
- caption_label: "text-sm font-medium",
30
+ caption_label: "text-body font-medium",
30
31
  // Overlays the caption row and pushes the buttons to either edge; the
31
32
  // buttons themselves stay in normal flow inside it.
32
33
  nav: "absolute inset-x-0 top-0 flex h-7 items-center justify-between",
@@ -42,7 +43,7 @@ export function Calendar({
42
43
  weekdays: "flex",
43
44
  weekday: "w-8 rounded-md text-[0.8rem] font-normal text-muted-foreground",
44
45
  week: "mt-2 flex w-full",
45
- day: "relative size-8 p-0 text-center text-sm focus-within:relative focus-within:z-20 [&:has([aria-selected])]:bg-accent [&:has([aria-selected].day-range-end)]:rounded-e-md first:[&:has([aria-selected])]:rounded-s-md last:[&:has([aria-selected])]:rounded-e-md",
46
+ day: "relative size-8 p-0 text-center text-body focus-within:relative focus-within:z-20 [&:has([aria-selected])]:bg-accent [&:has([aria-selected].day-range-end)]:rounded-e-md first:[&:has([aria-selected])]:rounded-s-md last:[&:has([aria-selected])]:rounded-e-md",
46
47
  day_button: cn(
47
48
  buttonVariants({ variant: "ghost" }),
48
49
  "size-8 p-0 font-normal aria-selected:opacity-100",
@@ -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>
@@ -1,5 +1,5 @@
1
- import { describe, expect, it } from "vitest";
2
- import { render, screen } from "@testing-library/react";
1
+ import { describe, expect, it, vi } from "vitest";
2
+ import { fireEvent, render, screen } from "@testing-library/react";
3
3
  import { Card, CardDescription, CardTitle } from "./card";
4
4
 
5
5
  describe("Card", () => {
@@ -103,6 +103,28 @@ describe("Card", () => {
103
103
  expect(region).toHaveAttribute("aria-hidden", "true");
104
104
  });
105
105
 
106
+ it("with `detail` + hover, cancels its pending conceal rAF on unmount", () => {
107
+ const rafSpy = vi.spyOn(window, "requestAnimationFrame").mockReturnValue(42);
108
+ const cafSpy = vi.spyOn(window, "cancelAnimationFrame").mockImplementation(() => {});
109
+ const { unmount } = render(
110
+ <Card data-testid="card" detailReveal="hover" detail={<button>More</button>}>
111
+ main
112
+ </Card>,
113
+ );
114
+ const card = screen.getByTestId("card");
115
+ // focus-out schedules the conceal rAF this test is locking cleanup for.
116
+ fireEvent.focusOut(card);
117
+ expect(rafSpy).toHaveBeenCalled();
118
+
119
+ unmount();
120
+ // Without cancelling it, the scheduled frame is still pending after
121
+ // teardown and fires against a component that no longer exists.
122
+ expect(cafSpy).toHaveBeenCalledWith(42);
123
+
124
+ rafSpy.mockRestore();
125
+ cafSpy.mockRestore();
126
+ });
127
+
106
128
  it("with `detail` + hover, detail region is labelled (landmark navigation, #141)", () => {
107
129
  render(
108
130
  <Card detailReveal="hover" detail={<span>info</span>} detailLabel="Card details">
@@ -2,6 +2,7 @@
2
2
 
3
3
  import {
4
4
  forwardRef,
5
+ useEffect,
5
6
  useRef,
6
7
  useState,
7
8
  useCallback,
@@ -23,7 +24,7 @@ export const cardVariants = cva("rounded-lg border bg-card text-card-foreground
23
24
  // duration-* utility; the transform is neutralized under OS reduced-motion
24
25
  // (the shadow/border still respond — "reduced != none").
25
26
  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",
27
+ 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
28
  false: "",
28
29
  },
29
30
  },
@@ -110,12 +111,22 @@ function CardWithHoverDetail({
110
111
  }: CardProps) {
111
112
  const [isRevealed, setIsRevealed] = useState(false);
112
113
  const containerRef = useRef<HTMLDivElement>(null);
114
+ const concealRafRef = useRef<number | null>(null);
115
+
116
+ useEffect(() => {
117
+ return () => {
118
+ if (concealRafRef.current !== null) cancelAnimationFrame(concealRafRef.current);
119
+ };
120
+ }, []);
113
121
 
114
122
  const reveal = useCallback(() => setIsRevealed(true), []);
115
123
  const conceal = useCallback(() => {
116
124
  // Only conceal if focus has truly left the card (not moved to detail).
117
125
  // Use requestAnimationFrame so the new focusIn event fires first.
118
- requestAnimationFrame(() => {
126
+ // Tracked in a ref so it can be cancelled if the card unmounts before the
127
+ // frame fires (a focus-out right before navigation away, say).
128
+ concealRafRef.current = requestAnimationFrame(() => {
129
+ concealRafRef.current = null;
119
130
  if (containerRef.current && !containerRef.current.contains(document.activeElement)) {
120
131
  setIsRevealed(false);
121
132
  }
@@ -142,6 +153,7 @@ function CardWithHoverDetail({
142
153
  return (
143
154
  <div
144
155
  ref={containerRef}
156
+ data-slot="card"
145
157
  className={cn(
146
158
  cardVariants({ interactive }),
147
159
  cardDetailVariants({ detailPlacement, detailReveal: "hover" }),
@@ -161,6 +173,7 @@ function CardWithHoverDetail({
161
173
  role="region"
162
174
  aria-label={detailLabel}
163
175
  aria-hidden={!isRevealed}
176
+ data-slot="card-detail"
164
177
  className={cn("overflow-hidden bg-background p-6", dividerEdge, dividerColor)}
165
178
  >
166
179
  {detail}
@@ -189,6 +202,7 @@ export const Card = forwardRef<HTMLDivElement, CardProps>(function Card(
189
202
  return (
190
203
  <div
191
204
  ref={ref}
205
+ data-slot="card"
192
206
  className={cn(cardVariants({ interactive }), className)}
193
207
  style={style}
194
208
  {...props}
@@ -230,6 +244,7 @@ export const Card = forwardRef<HTMLDivElement, CardProps>(function Card(
230
244
  return (
231
245
  <div
232
246
  ref={ref}
247
+ data-slot="card"
233
248
  className={cn(
234
249
  cardVariants({ interactive }),
235
250
  cardDetailVariants({ detailPlacement, detailReveal }),
@@ -243,6 +258,7 @@ export const Card = forwardRef<HTMLDivElement, CardProps>(function Card(
243
258
  <div
244
259
  role="region"
245
260
  aria-label={detailLabel}
261
+ data-slot="card-detail"
246
262
  className={cn("overflow-hidden bg-background p-6", dividerEdge, dividerColor)}
247
263
  >
248
264
  {detail}
@@ -253,7 +269,14 @@ export const Card = forwardRef<HTMLDivElement, CardProps>(function Card(
253
269
 
254
270
  export const CardHeader = forwardRef<HTMLDivElement, HTMLAttributes<HTMLDivElement>>(
255
271
  function CardHeader({ className, ...props }, ref) {
256
- return <div ref={ref} className={cn("flex flex-col gap-1.5 p-6", className)} {...props} />;
272
+ return (
273
+ <div
274
+ ref={ref}
275
+ data-slot="card-header"
276
+ className={cn("flex flex-col gap-1.5 p-6", className)}
277
+ {...props}
278
+ />
279
+ );
257
280
  },
258
281
  );
259
282
 
@@ -276,7 +299,14 @@ export const CardTitle = forwardRef<HTMLDivElement, CardTitleProps>(function Car
276
299
  { className, as: Tag = "div", ...props },
277
300
  ref,
278
301
  ) {
279
- return <Tag ref={ref} className={cn("text-title leading-none", className)} {...props} />;
302
+ return (
303
+ <Tag
304
+ ref={ref}
305
+ data-slot="card-title"
306
+ className={cn("text-title leading-none", className)}
307
+ {...props}
308
+ />
309
+ );
280
310
  });
281
311
 
282
312
  export interface CardDescriptionProps extends HTMLAttributes<HTMLParagraphElement> {
@@ -293,11 +323,12 @@ export const CardDescription = forwardRef<HTMLParagraphElement, CardDescriptionP
293
323
  return (
294
324
  <p
295
325
  ref={ref}
326
+ data-slot="card-description"
296
327
  // `text-balance` sets `text-wrap: balance`. #336's real defect was an
297
328
  // invalid, misspelled utility name that emitted zero CSS (NOT a
298
329
  // tailwind-merge conflict) — this is the real, registered class.
299
330
  className={cn(
300
- "text-sm text-balance text-muted-foreground",
331
+ "text-body text-balance text-muted-foreground",
301
332
  measure && "max-w-prose",
302
333
  className,
303
334
  )}
@@ -312,6 +343,7 @@ export const CardAction = forwardRef<HTMLDivElement, HTMLAttributes<HTMLDivEleme
312
343
  return (
313
344
  <div
314
345
  ref={ref}
346
+ data-slot="card-action"
315
347
  className={cn("col-start-2 row-span-2 row-start-1 self-start justify-self-end", className)}
316
348
  {...props}
317
349
  />
@@ -321,12 +353,21 @@ export const CardAction = forwardRef<HTMLDivElement, HTMLAttributes<HTMLDivEleme
321
353
 
322
354
  export const CardContent = forwardRef<HTMLDivElement, HTMLAttributes<HTMLDivElement>>(
323
355
  function CardContent({ className, ...props }, ref) {
324
- return <div ref={ref} className={cn("p-6 pt-0", className)} {...props} />;
356
+ return (
357
+ <div ref={ref} data-slot="card-content" className={cn("p-6 pt-0", className)} {...props} />
358
+ );
325
359
  },
326
360
  );
327
361
 
328
362
  export const CardFooter = forwardRef<HTMLDivElement, HTMLAttributes<HTMLDivElement>>(
329
363
  function CardFooter({ className, ...props }, ref) {
330
- return <div ref={ref} className={cn("flex items-center p-6 pt-0", className)} {...props} />;
364
+ return (
365
+ <div
366
+ ref={ref}
367
+ data-slot="card-footer"
368
+ className={cn("flex items-center p-6 pt-0", className)}
369
+ {...props}
370
+ />
371
+ );
331
372
  },
332
373
  );