banhaten 0.3.0 → 0.5.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 (348) hide show
  1. package/CHANGELOG.md +139 -73
  2. package/CONTRIBUTING.md +46 -44
  3. package/README.md +192 -190
  4. package/SECURITY.md +15 -15
  5. package/banhaten.config.example.json +11 -11
  6. package/docs/design-spacing.md +13 -0
  7. package/docs/design-system/README.md +22 -20
  8. package/docs/design-system/adoption-quality-remediation.md +87 -87
  9. package/docs/design-system/agent-usage.md +13 -13
  10. package/docs/design-system/appearance-presets.md +189 -189
  11. package/docs/design-system/appearances/default.md +94 -94
  12. package/docs/design-system/appearances/rounded.md +95 -95
  13. package/docs/design-system/appearances/sharp.md +95 -95
  14. package/docs/design-system/component-docs-pattern.md +81 -81
  15. package/docs/design-system/component-implementation-ownership-audit.md +245 -244
  16. package/docs/design-system/component-quality-audit-2026-08-20.md +225 -223
  17. package/docs/design-system/component-showcase-consistency-report.md +219 -219
  18. package/docs/design-system/component-token-consistency-audit.md +173 -173
  19. package/docs/design-system/components/README.md +90 -76
  20. package/docs/design-system/components/accordion.md +53 -53
  21. package/docs/design-system/components/activity-feed.md +94 -94
  22. package/docs/design-system/components/activity-heatmap.md +57 -57
  23. package/docs/design-system/components/agent-input.md +85 -0
  24. package/docs/design-system/components/alert-dialog.md +72 -72
  25. package/docs/design-system/components/alert.md +98 -98
  26. package/docs/design-system/components/approval-card.md +134 -0
  27. package/docs/design-system/components/aspect-ratio.md +45 -45
  28. package/docs/design-system/components/attribute.md +89 -89
  29. package/docs/design-system/components/autocomplete.md +75 -75
  30. package/docs/design-system/components/avatar.md +54 -54
  31. package/docs/design-system/components/badge.md +54 -54
  32. package/docs/design-system/components/banner.md +87 -87
  33. package/docs/design-system/components/breadcrumbs.md +177 -176
  34. package/docs/design-system/components/button-group.md +98 -98
  35. package/docs/design-system/components/button.md +83 -80
  36. package/docs/design-system/components/card.md +88 -88
  37. package/docs/design-system/components/carousel.md +46 -46
  38. package/docs/design-system/components/catalog-components.md +47 -47
  39. package/docs/design-system/components/chart.md +95 -24
  40. package/docs/design-system/components/checkbox.md +54 -54
  41. package/docs/design-system/components/collapsible.md +67 -53
  42. package/docs/design-system/components/command.md +62 -62
  43. package/docs/design-system/components/context-menu.md +46 -46
  44. package/docs/design-system/components/date-picker.md +81 -79
  45. package/docs/design-system/components/divider.md +102 -102
  46. package/docs/design-system/components/empty-state.md +56 -56
  47. package/docs/design-system/components/field.md +70 -70
  48. package/docs/design-system/components/file-upload.md +187 -187
  49. package/docs/design-system/components/hover-card.md +48 -48
  50. package/docs/design-system/components/icons.md +48 -48
  51. package/docs/design-system/components/inline-citations.md +93 -0
  52. package/docs/design-system/components/input-group.md +58 -58
  53. package/docs/design-system/components/input-otp.md +56 -56
  54. package/docs/design-system/components/input.md +117 -61
  55. package/docs/design-system/components/item.md +59 -57
  56. package/docs/design-system/components/kbd.md +54 -49
  57. package/docs/design-system/components/label.md +49 -49
  58. package/docs/design-system/components/menu.md +71 -61
  59. package/docs/design-system/components/menubar.md +47 -47
  60. package/docs/design-system/components/modal.md +105 -105
  61. package/docs/design-system/components/native-select.md +54 -54
  62. package/docs/design-system/components/navigation-menu.md +50 -50
  63. package/docs/design-system/components/onboarding-step-list-item.md +82 -82
  64. package/docs/design-system/components/page-header.md +93 -86
  65. package/docs/design-system/components/pagination.md +57 -57
  66. package/docs/design-system/components/popover.md +60 -60
  67. package/docs/design-system/components/progress-slider.md +50 -50
  68. package/docs/design-system/components/progress.md +76 -76
  69. package/docs/design-system/components/radio-card.md +51 -51
  70. package/docs/design-system/components/radio-group.md +57 -57
  71. package/docs/design-system/components/resizable.md +44 -44
  72. package/docs/design-system/components/rolling-count.md +61 -0
  73. package/docs/design-system/components/scroll-area.md +51 -51
  74. package/docs/design-system/components/segmented-control.md +54 -54
  75. package/docs/design-system/components/select.md +68 -62
  76. package/docs/design-system/components/sheet.md +67 -67
  77. package/docs/design-system/components/shimmer-text.md +67 -0
  78. package/docs/design-system/components/sidebar.md +127 -76
  79. package/docs/design-system/components/skeleton.md +75 -75
  80. package/docs/design-system/components/slideout.md +65 -65
  81. package/docs/design-system/components/slider.md +63 -63
  82. package/docs/design-system/components/social-button.md +50 -50
  83. package/docs/design-system/components/spinner.md +62 -62
  84. package/docs/design-system/components/steps.md +65 -65
  85. package/docs/design-system/components/streaming-text.md +82 -0
  86. package/docs/design-system/components/table.md +419 -414
  87. package/docs/design-system/components/tabs.md +59 -55
  88. package/docs/design-system/components/tag.md +80 -80
  89. package/docs/design-system/components/task-list.md +95 -0
  90. package/docs/design-system/components/textarea.md +50 -50
  91. package/docs/design-system/components/thinking-reasoning.md +72 -0
  92. package/docs/design-system/components/thinking-state.md +67 -0
  93. package/docs/design-system/components/timeline.md +83 -83
  94. package/docs/design-system/components/toast.md +58 -58
  95. package/docs/design-system/components/toggle.md +81 -81
  96. package/docs/design-system/components/toolbar.md +92 -87
  97. package/docs/design-system/components/tooltip.md +94 -92
  98. package/docs/design-system/components/typography.md +18 -18
  99. package/docs/design-system/density-and-elevation.md +65 -61
  100. package/docs/design-system/design-system-test-missing-items.md +368 -368
  101. package/docs/design-system/direction-and-localization.md +23 -23
  102. package/docs/design-system/form-recipes.md +73 -73
  103. package/docs/design-system/gap-promotion-policy.md +21 -21
  104. package/docs/design-system/icons.md +74 -69
  105. package/docs/design-system/installation.md +70 -68
  106. package/docs/design-system/launch-comparison-shadcn.md +98 -98
  107. package/docs/design-system/registry-and-cli.md +64 -64
  108. package/docs/design-system/release-policy.md +30 -30
  109. package/docs/design-system/roadmap.md +14 -14
  110. package/docs/design-system/support-matrix.md +23 -23
  111. package/docs/design-system/tabs.md +54 -53
  112. package/docs/design-system/template-gap-audit.md +32 -0
  113. package/docs/design-system/templates.md +59 -0
  114. package/docs/design-system/token-governance.md +72 -60
  115. package/package.json +98 -98
  116. package/registry/component-contract.json +40 -40
  117. package/registry/components/accordion.tsx +155 -155
  118. package/registry/components/agent-input-parts.tsx +552 -0
  119. package/registry/components/agent-input-state.ts +156 -0
  120. package/registry/components/agent-input.css +307 -0
  121. package/registry/components/agent-input.tsx +382 -0
  122. package/registry/components/alert-dialog.tsx +297 -297
  123. package/registry/components/alert.tsx +293 -293
  124. package/registry/components/approval-card.css +409 -0
  125. package/registry/components/approval-card.tsx +401 -0
  126. package/registry/components/approval-frame.tsx +108 -0
  127. package/registry/components/approval-questions.tsx +559 -0
  128. package/registry/components/aspect-ratio.tsx +30 -30
  129. package/registry/components/attribute.tsx +454 -448
  130. package/registry/components/autocomplete.tsx +598 -651
  131. package/registry/components/avatar.tsx +385 -381
  132. package/registry/components/badge.tsx +629 -629
  133. package/registry/components/button-group.tsx +565 -565
  134. package/registry/components/button.tsx +127 -123
  135. package/registry/components/card.tsx +638 -638
  136. package/registry/components/carousel.tsx +296 -296
  137. package/registry/components/chart-block-data.ts +177 -0
  138. package/registry/components/chart-block.css +171 -0
  139. package/registry/components/chart-block.tsx +260 -0
  140. package/registry/components/chart-interactions.ts +123 -0
  141. package/registry/components/chart-options.cartesian.ts +162 -0
  142. package/registry/components/chart-options.paint.ts +99 -0
  143. package/registry/components/chart-options.parts.ts +72 -0
  144. package/registry/components/chart-options.radial.ts +109 -0
  145. package/registry/components/chart-options.ts +44 -0
  146. package/registry/components/chart-options.types.ts +67 -0
  147. package/registry/components/chart.tsx +203 -203
  148. package/registry/components/checkbox.tsx +247 -247
  149. package/registry/components/collapsible.tsx +109 -69
  150. package/registry/components/command.tsx +208 -208
  151. package/registry/components/context-menu.tsx +236 -236
  152. package/registry/components/data-chart.tsx +40 -0
  153. package/registry/components/date-picker.tsx +1289 -1277
  154. package/registry/components/echart-theme.ts +246 -0
  155. package/registry/components/echart.css +49 -0
  156. package/registry/components/echart.tsx +178 -0
  157. package/registry/components/expanded/ActivityFeed.tsx +227 -227
  158. package/registry/components/expanded/ActivityHeatmap.tsx +64 -64
  159. package/registry/components/expanded/Banner.tsx +179 -179
  160. package/registry/components/expanded/Breadcrumbs.tsx +131 -131
  161. package/registry/components/expanded/CatalogComponentsShowcase.tsx +205 -205
  162. package/registry/components/expanded/CatalogDivider.tsx +49 -49
  163. package/registry/components/expanded/EmptyState.tsx +155 -155
  164. package/registry/components/expanded/FileUpload.tsx +543 -543
  165. package/registry/components/expanded/OnboardingStepListItem.tsx +60 -59
  166. package/registry/components/expanded/PageHeader.tsx +179 -166
  167. package/registry/components/expanded/Slideout.tsx +108 -108
  168. package/registry/components/expanded/Steps.tsx +402 -402
  169. package/registry/components/expanded/Table.tsx +1092 -1071
  170. package/registry/components/expanded/Timeline.tsx +203 -203
  171. package/registry/components/expanded/activityFeed.css +248 -248
  172. package/registry/components/expanded/activityHeatmap.css +66 -66
  173. package/registry/components/expanded/banner.css +260 -255
  174. package/registry/components/expanded/breadcrumbs.css +140 -140
  175. package/registry/components/expanded/catalogComponentsShowcase.css +87 -87
  176. package/registry/components/expanded/divider.css +75 -75
  177. package/registry/components/expanded/emptyState.css +111 -111
  178. package/registry/components/expanded/fileUpload.css +457 -457
  179. package/registry/components/expanded/onboardingStepListItem.css +135 -126
  180. package/registry/components/expanded/pageHeader.css +248 -290
  181. package/registry/components/expanded/slideout.css +276 -276
  182. package/registry/components/expanded/steps.css +356 -356
  183. package/registry/components/expanded/table.css +606 -603
  184. package/registry/components/expanded/timeline.css +286 -286
  185. package/registry/components/field.tsx +283 -283
  186. package/registry/components/hover-card.tsx +48 -48
  187. package/registry/components/inline-citations.css +126 -0
  188. package/registry/components/inline-citations.tsx +292 -0
  189. package/registry/components/input-content.ts +107 -107
  190. package/registry/components/input-group.tsx +130 -130
  191. package/registry/components/input-otp.tsx +584 -584
  192. package/registry/components/input.tsx +990 -972
  193. package/registry/components/item.tsx +189 -189
  194. package/registry/components/kbd.tsx +246 -151
  195. package/registry/components/label.tsx +78 -78
  196. package/registry/components/menu.tsx +978 -986
  197. package/registry/components/menubar.tsx +226 -226
  198. package/registry/components/modal.tsx +872 -872
  199. package/registry/components/native-select.tsx +205 -205
  200. package/registry/components/navigation-menu.tsx +171 -171
  201. package/registry/components/pagination.css +36 -36
  202. package/registry/components/pagination.tsx +560 -555
  203. package/registry/components/popover.tsx +338 -338
  204. package/registry/components/progress-slider.tsx +216 -216
  205. package/registry/components/radio-card.tsx +676 -677
  206. package/registry/components/radio-group.tsx +570 -570
  207. package/registry/components/resizable.tsx +108 -108
  208. package/registry/components/rolling-count.css +37 -0
  209. package/registry/components/rolling-count.tsx +110 -0
  210. package/registry/components/scroll-area.tsx +91 -91
  211. package/registry/components/segmented-control.tsx +87 -87
  212. package/registry/components/select-content.tsx +218 -218
  213. package/registry/components/select.tsx +937 -932
  214. package/registry/components/sheet.tsx +305 -305
  215. package/registry/components/shimmer-text.css +69 -0
  216. package/registry/components/shimmer-text.tsx +43 -0
  217. package/registry/components/sidebar.tsx +1433 -358
  218. package/registry/components/skeleton.css +54 -54
  219. package/registry/components/skeleton.tsx +482 -482
  220. package/registry/components/slider.tsx +380 -380
  221. package/registry/components/social-button.tsx +387 -387
  222. package/registry/components/spinner.tsx +78 -78
  223. package/registry/components/streaming-text.css +45 -0
  224. package/registry/components/streaming-text.tsx +165 -0
  225. package/registry/components/table-elements.tsx +128 -128
  226. package/registry/components/tabs.css +222 -218
  227. package/registry/components/tabs.tsx +241 -241
  228. package/registry/components/tag.tsx +423 -423
  229. package/registry/components/task-list.css +328 -0
  230. package/registry/components/task-list.tsx +429 -0
  231. package/registry/components/textarea.tsx +610 -610
  232. package/registry/components/thinking-reasoning.css +178 -0
  233. package/registry/components/thinking-reasoning.tsx +253 -0
  234. package/registry/components/thinking-state.css +8 -0
  235. package/registry/components/thinking-state.tsx +46 -0
  236. package/registry/components/toast.tsx +136 -136
  237. package/registry/components/toggle.tsx +256 -256
  238. package/registry/components/toolbar.tsx +539 -539
  239. package/registry/components/tooltip.tsx +433 -431
  240. package/registry/examples/agent-input-demo.tsx +90 -0
  241. package/registry/examples/alert-demo.tsx +34 -34
  242. package/registry/examples/alert-dialog-demo.tsx +42 -42
  243. package/registry/examples/approval-card-demo.tsx +83 -0
  244. package/registry/examples/aspect-ratio-demo.tsx +11 -11
  245. package/registry/examples/attribute-demo.tsx +65 -65
  246. package/registry/examples/autocomplete-demo.tsx +109 -109
  247. package/registry/examples/avatar-demo.tsx +132 -132
  248. package/registry/examples/badge-demo.tsx +69 -69
  249. package/registry/examples/button-demo.tsx +86 -83
  250. package/registry/examples/button-group-demo.tsx +63 -63
  251. package/registry/examples/card-demo.tsx +86 -86
  252. package/registry/examples/carousel-demo.tsx +43 -43
  253. package/registry/examples/chart-block-demo.tsx +5 -0
  254. package/registry/examples/chart-demo.tsx +18 -33
  255. package/registry/examples/checkbox-demo.tsx +62 -62
  256. package/registry/examples/collapsible-demo.tsx +16 -16
  257. package/registry/examples/collection-filter-bar-demo.tsx +135 -135
  258. package/registry/examples/command-demo.tsx +42 -42
  259. package/registry/examples/command-palette-demo.tsx +235 -236
  260. package/registry/examples/context-menu-demo.tsx +29 -29
  261. package/registry/examples/control-density-contract-demo.tsx +65 -61
  262. package/registry/examples/date-picker-demo.tsx +127 -127
  263. package/registry/examples/divider-demo.tsx +17 -17
  264. package/registry/examples/expanded/activity-heatmap-demo.tsx +29 -29
  265. package/registry/examples/expanded/banner-demo.tsx +48 -48
  266. package/registry/examples/expanded/breadcrumbs-demo.tsx +59 -59
  267. package/registry/examples/expanded/empty-state-demo.tsx +39 -39
  268. package/registry/examples/expanded/file-upload-demo.tsx +60 -60
  269. package/registry/examples/expanded/onboarding-step-demo.tsx +11 -11
  270. package/registry/examples/expanded/slideout-demo.tsx +15 -15
  271. package/registry/examples/expanded/steps-demo.tsx +29 -29
  272. package/registry/examples/expanded/tabs-demo.tsx +13 -13
  273. package/registry/examples/expanded/timeline-demo.tsx +18 -18
  274. package/registry/examples/field-demo.tsx +51 -51
  275. package/registry/examples/hover-card-demo.tsx +23 -23
  276. package/registry/examples/inline-citations-demo.tsx +31 -0
  277. package/registry/examples/input-actions-demo.tsx +36 -0
  278. package/registry/examples/input-demo.tsx +87 -87
  279. package/registry/examples/input-group-demo.tsx +16 -16
  280. package/registry/examples/input-otp-demo.tsx +72 -72
  281. package/registry/examples/item-demo.tsx +46 -46
  282. package/registry/examples/kbd-demo.tsx +12 -11
  283. package/registry/examples/label-demo.tsx +20 -20
  284. package/registry/examples/menu-demo.tsx +122 -122
  285. package/registry/examples/menubar-demo.tsx +34 -34
  286. package/registry/examples/modal-demo.tsx +74 -74
  287. package/registry/examples/native-select-demo.tsx +16 -16
  288. package/registry/examples/navigation-menu-demo.tsx +29 -29
  289. package/registry/examples/pagination-demo.tsx +20 -20
  290. package/registry/examples/popover-demo.tsx +547 -546
  291. package/registry/examples/progress-demo.tsx +37 -37
  292. package/registry/examples/progress-slider-demo.tsx +29 -29
  293. package/registry/examples/radio-card-demo.tsx +51 -51
  294. package/registry/examples/radio-group-demo.tsx +62 -62
  295. package/registry/examples/resizable-demo.tsx +22 -22
  296. package/registry/examples/results-toolbar-demo.tsx +44 -44
  297. package/registry/examples/rolling-count-demo.tsx +39 -0
  298. package/registry/examples/scroll-area-demo.tsx +15 -15
  299. package/registry/examples/segmented-control-demo.tsx +39 -39
  300. package/registry/examples/select-demo.tsx +87 -87
  301. package/registry/examples/sheet-demo.tsx +47 -47
  302. package/registry/examples/shimmer-text-demo.tsx +20 -0
  303. package/registry/examples/sidebar-demo.tsx +101 -57
  304. package/registry/examples/slider-demo.tsx +31 -31
  305. package/registry/examples/social-button-demo.tsx +61 -61
  306. package/registry/examples/spinner-demo.tsx +59 -59
  307. package/registry/examples/streaming-text-demo.tsx +28 -0
  308. package/registry/examples/tabs-demo.tsx +13 -13
  309. package/registry/examples/tag-demo.tsx +29 -29
  310. package/registry/examples/task-list-demo.tsx +74 -0
  311. package/registry/examples/textarea-demo.tsx +79 -79
  312. package/registry/examples/thinking-reasoning-demo.tsx +74 -0
  313. package/registry/examples/thinking-state-demo.tsx +13 -0
  314. package/registry/examples/toast-demo.tsx +35 -35
  315. package/registry/examples/toggle-demo.tsx +59 -59
  316. package/registry/examples/tooltip-demo.tsx +115 -115
  317. package/registry/guidance/USAGE.md +27 -27
  318. package/registry/guidance/charts.LICENSE.txt +24 -0
  319. package/registry/hooks/use-controllable-state.ts +33 -0
  320. package/registry/hooks/use-direction.ts +207 -207
  321. package/registry/hooks/use-listbox-navigation.ts +73 -0
  322. package/registry/hooks/use-motion-end.ts +55 -0
  323. package/registry/hooks/use-reduced-motion.ts +28 -0
  324. package/registry/index.json +1634 -184
  325. package/registry/styles/globals.css +1462 -1570
  326. package/registry.json +364 -10
  327. package/schema/config.schema.json +51 -51
  328. package/schema/registry.schema.json +194 -190
  329. package/schema/tokens.schema.json +167 -163
  330. package/src/cli/command-line.js +194 -194
  331. package/src/cli/config-editors.js +9 -9
  332. package/src/cli/doctor.js +399 -399
  333. package/src/cli/file-transaction.js +302 -295
  334. package/src/cli/global-css.js +153 -153
  335. package/src/cli/index.js +1002 -1009
  336. package/src/cli/install-plan.js +592 -592
  337. package/src/cli/install-receipt.js +171 -171
  338. package/src/cli/jsonc.js +203 -203
  339. package/src/cli/package-install-state.js +254 -254
  340. package/src/cli/package-manager.js +238 -238
  341. package/src/cli/package-plan.js +185 -185
  342. package/src/cli/package-version.js +97 -97
  343. package/src/cli/registry-query.js +207 -207
  344. package/src/cli/reporting.js +55 -55
  345. package/src/cli/schema-validation.js +19 -19
  346. package/src/cli/source-scanner.js +686 -686
  347. package/src/cli/vite-config.js +478 -478
  348. package/tokens/banhaten.tokens.json +458 -224
@@ -0,0 +1,559 @@
1
+ "use client"
2
+
3
+ import * as React from "react"
4
+ import {
5
+ ChevronDownIcon,
6
+ ChevronUpIcon,
7
+ MessageCircleQuestionIcon,
8
+ } from "lucide-react"
9
+
10
+ import { Button } from "@/components/ui/button"
11
+ import { Kbd } from "@/components/ui/kbd"
12
+ import { RollingCount } from "@/components/ui/rolling-count"
13
+ import { useReducedMotion } from "@/hooks/use-reduced-motion"
14
+
15
+ import { ApprovalFrame, type ApprovalCardBaseProps } from "./approval-frame"
16
+
17
+ type ApprovalQuestion = {
18
+ id: string
19
+ options: readonly string[]
20
+ prompt: string
21
+ }
22
+
23
+ type ApprovalAnswers = Record<string, string>
24
+
25
+ type ApprovalQuestionsPayload = {
26
+ /** Answers keyed by question id. */
27
+ answers: ApprovalAnswers
28
+ }
29
+
30
+ type ApprovalQuestionsLabels = {
31
+ customAnswer: string
32
+ customAnswerFor: (prompt: string) => string
33
+ nextQuestion: string
34
+ previousQuestion: string
35
+ questionOf: (current: number, total: number) => string
36
+ }
37
+
38
+ type ApprovalQuestionsProps = ApprovalCardBaseProps & {
39
+ labels?: Partial<ApprovalQuestionsLabels>
40
+ /** Fires once every question has an answer. */
41
+ onApprove?: (payload: ApprovalQuestionsPayload) => void
42
+ onReject?: () => void
43
+ /** Single-choice questions, answered one at a time. */
44
+ questions: readonly ApprovalQuestion[]
45
+ rejectLabel?: string
46
+ }
47
+
48
+ const defaultLabels: ApprovalQuestionsLabels = {
49
+ customAnswer: "Something else…",
50
+ customAnswerFor: (prompt) => `Custom answer for: ${prompt}`,
51
+ nextQuestion: "Next question",
52
+ previousQuestion: "Previous question",
53
+ questionOf: (current, total) => `Question ${current} of ${total}`,
54
+ }
55
+
56
+ // Beat between picking an option and sliding to the next question.
57
+ const ADVANCE_MS = 320
58
+
59
+ type QuestionChoice = { kind: "option"; option: string } | { kind: "custom" }
60
+
61
+ /** A question's pick, plus its free-text draft kept while switching picks. */
62
+ type QuestionEntry = { choice?: QuestionChoice; draft: string }
63
+
64
+ const emptyEntry: QuestionEntry = { draft: "" }
65
+
66
+ function answerOf(entry: QuestionEntry | undefined) {
67
+ if (entry?.choice?.kind === "option") return entry.choice.option
68
+ if (entry?.choice?.kind === "custom") return entry.draft.trim()
69
+ return ""
70
+ }
71
+
72
+ function collectAnswers(
73
+ questions: readonly ApprovalQuestion[],
74
+ entries: Record<string, QuestionEntry>
75
+ ) {
76
+ const answers: ApprovalAnswers = {}
77
+ for (const question of questions) {
78
+ const answer = answerOf(entries[question.id])
79
+ if (answer) answers[question.id] = answer
80
+ }
81
+ return answers
82
+ }
83
+
84
+ function isComplete(
85
+ questions: readonly ApprovalQuestion[],
86
+ answers: ApprovalAnswers
87
+ ) {
88
+ return (
89
+ questions.length > 0 &&
90
+ questions.every((question) => Boolean(answers[question.id]?.trim()))
91
+ )
92
+ }
93
+
94
+ /**
95
+ * Sizes the viewport to the active question and slides the track to it. When
96
+ * a slide would leave keyboard focus on a hidden question, focus follows to
97
+ * the visible one.
98
+ */
99
+ function useQuestionTrack(
100
+ step: number,
101
+ questions: readonly ApprovalQuestion[],
102
+ entries: Record<string, QuestionEntry>
103
+ ) {
104
+ const viewportRef = React.useRef<HTMLDivElement>(null)
105
+ const questionRefs = React.useRef<Array<HTMLDivElement | null>>([])
106
+ const measured = React.useRef(false)
107
+ const [height, setHeight] = React.useState<number | null>(null)
108
+ const [offset, setOffset] = React.useState(0)
109
+ const [animate, setAnimate] = React.useState(false)
110
+ const reducedMotion = useReducedMotion()
111
+
112
+ const sync = React.useCallback(
113
+ (shouldAnimate: boolean) => {
114
+ const item = questionRefs.current[step]
115
+ const viewport = viewportRef.current
116
+ if (!item || !viewport) return
117
+ // The viewport pads the track so focus rings are not clipped.
118
+ const padding = Number.parseFloat(window.getComputedStyle(viewport).paddingTop) || 0
119
+ setHeight(item.offsetHeight + 2 * padding)
120
+ setOffset(item.offsetTop)
121
+ setAnimate(shouldAnimate && !reducedMotion)
122
+ },
123
+ [reducedMotion, step]
124
+ )
125
+
126
+ // Answers can change a question's height, so they re-measure too.
127
+ React.useLayoutEffect(() => {
128
+ const shouldAnimate = measured.current
129
+ measured.current = true
130
+ sync(shouldAnimate)
131
+ }, [entries, questions, sync])
132
+
133
+ React.useEffect(() => {
134
+ const frame = window.requestAnimationFrame(() => sync(measured.current))
135
+ return () => window.cancelAnimationFrame(frame)
136
+ }, [questions, sync])
137
+
138
+ React.useEffect(() => {
139
+ const viewport = viewportRef.current
140
+ const question = questionRefs.current[step]
141
+ const focused = document.activeElement
142
+ if (!viewport || !question || !focused) return
143
+ if (!viewport.contains(focused) || question.contains(focused)) return
144
+ const radio =
145
+ question.querySelector<HTMLInputElement>(
146
+ '[data-slot="approval-card-option-radio"]:checked'
147
+ ) ??
148
+ question.querySelector<HTMLInputElement>('[data-slot="approval-card-option-radio"]')
149
+ radio?.focus({ preventScroll: true })
150
+ }, [step])
151
+
152
+ return { animate, height, offset, questionRefs, viewportRef }
153
+ }
154
+
155
+ type QuestionStepNavProps = {
156
+ labels: ApprovalQuestionsLabels
157
+ onStep: (step: number) => void
158
+ step: number
159
+ total: number
160
+ }
161
+
162
+ /**
163
+ * Previous and next controls around a rolling "2 / 3" counter, grouped under
164
+ * "Question 2 of 3". A plain div cannot carry that name; a group can.
165
+ */
166
+ function QuestionStepNav({ labels, onStep, step, total }: QuestionStepNavProps) {
167
+ return (
168
+ <div
169
+ aria-label={labels.questionOf(step + 1, total)}
170
+ data-slot="approval-card-step-nav"
171
+ role="group"
172
+ >
173
+ <Button
174
+ aria-label={labels.previousQuestion}
175
+ disabled={step <= 0}
176
+ onClick={() => onStep(step - 1)}
177
+ size="icon-xs"
178
+ variant="ghost"
179
+ >
180
+ <ChevronUpIcon />
181
+ </Button>
182
+ <span aria-live="polite" data-slot="approval-card-step">
183
+ <RollingCount value={`${step + 1} / ${total}`} />
184
+ </span>
185
+ <Button
186
+ aria-label={labels.nextQuestion}
187
+ disabled={step >= total - 1}
188
+ onClick={() => onStep(step + 1)}
189
+ size="icon-xs"
190
+ variant="ghost"
191
+ >
192
+ <ChevronDownIcon />
193
+ </Button>
194
+ </div>
195
+ )
196
+ }
197
+
198
+ type QuestionOptionsProps = {
199
+ active: boolean
200
+ entry: QuestionEntry
201
+ labels: ApprovalQuestionsLabels
202
+ name: string
203
+ /** Selects "Something else"; the field is focused separately. */
204
+ onChooseCustom: () => void
205
+ onCommitCustom: (draft: string) => void
206
+ /** Enter on the choices: advance, or approve from the last question. */
207
+ onConfirm: () => void
208
+ onEditCustom: (draft: string) => void
209
+ onPick: (option: string, advance: boolean) => void
210
+ promptId: string
211
+ question: ApprovalQuestion
212
+ }
213
+
214
+ /**
215
+ * One question's choices as native radios: one tab stop, arrow keys, and a
216
+ * letter per option. "Something else" is its own radio next to a text field;
217
+ * focusing or typing in the field selects it.
218
+ */
219
+ function QuestionOptions({
220
+ active,
221
+ entry,
222
+ labels,
223
+ name,
224
+ onChooseCustom,
225
+ onCommitCustom,
226
+ onConfirm,
227
+ onEditCustom,
228
+ onPick,
229
+ promptId,
230
+ question,
231
+ }: QuestionOptionsProps) {
232
+ const inputRef = React.useRef<HTMLInputElement>(null)
233
+ // Set by an arrow-key press so the radio change it causes moves the choice
234
+ // without advancing, the way a native radio group browses.
235
+ const arrowKey = React.useRef(false)
236
+ const custom = entry.choice?.kind === "custom"
237
+ const picked = entry.choice?.kind === "option" ? entry.choice.option : null
238
+
239
+ const chooseCustom = (focusField: boolean) => {
240
+ onChooseCustom()
241
+ if (focusField) window.requestAnimationFrame(() => inputRef.current?.focus())
242
+ }
243
+
244
+ // Letters pick options by their key hint; Enter confirms the current answer.
245
+ const handleKeyDown = (event: React.KeyboardEvent<HTMLDivElement>) => {
246
+ if ((event.target as HTMLElement).dataset.slot === "approval-card-option-input") return
247
+ if (event.key.startsWith("Arrow")) {
248
+ arrowKey.current = true
249
+ return
250
+ }
251
+ if (event.key === "Enter") {
252
+ event.preventDefault()
253
+ onConfirm()
254
+ return
255
+ }
256
+ if (event.altKey || event.ctrlKey || event.metaKey || event.key.length !== 1) return
257
+ const index = event.key.toUpperCase().charCodeAt(0) - 65
258
+ if (index < 0 || index > question.options.length) return
259
+ event.preventDefault()
260
+ if (index === question.options.length) {
261
+ chooseCustom(true)
262
+ return
263
+ }
264
+ event.currentTarget
265
+ .querySelectorAll<HTMLInputElement>('[data-slot="approval-card-option-radio"]')
266
+ [index]?.focus()
267
+ onPick(question.options[index], true)
268
+ }
269
+
270
+ return (
271
+ <div
272
+ aria-labelledby={promptId}
273
+ data-slot="approval-card-options"
274
+ onKeyDown={handleKeyDown}
275
+ onPointerDown={() => {
276
+ arrowKey.current = false
277
+ }}
278
+ role="radiogroup"
279
+ >
280
+ {question.options.map((option, optionIndex) => {
281
+ const selected = picked === option
282
+ return (
283
+ <label
284
+ data-selected={selected ? "true" : "false"}
285
+ data-slot="approval-card-option"
286
+ key={option}
287
+ >
288
+ <input
289
+ checked={selected}
290
+ className="sr-only"
291
+ data-slot="approval-card-option-radio"
292
+ name={name}
293
+ onChange={() => {
294
+ if (active) onPick(option, !arrowKey.current)
295
+ arrowKey.current = false
296
+ }}
297
+ tabIndex={active ? undefined : -1}
298
+ type="radio"
299
+ value={option}
300
+ />
301
+ <Kbd aria-hidden="true" size="sm">
302
+ {String.fromCharCode(65 + optionIndex)}
303
+ </Kbd>
304
+ <span dir="auto">{option}</span>
305
+ </label>
306
+ )
307
+ })}
308
+ <div
309
+ data-other="true"
310
+ data-selected={custom ? "true" : "false"}
311
+ data-slot="approval-card-option"
312
+ onClick={(event) => {
313
+ if (event.target === event.currentTarget) inputRef.current?.focus()
314
+ }}
315
+ >
316
+ <label data-slot="approval-card-other-choice">
317
+ <input
318
+ aria-label={labels.customAnswer}
319
+ checked={custom}
320
+ className="sr-only"
321
+ data-slot="approval-card-option-radio"
322
+ name={name}
323
+ onChange={() => {
324
+ if (active) chooseCustom(!arrowKey.current)
325
+ arrowKey.current = false
326
+ }}
327
+ tabIndex={active ? undefined : -1}
328
+ type="radio"
329
+ value=""
330
+ />
331
+ <Kbd aria-hidden="true" size="sm">
332
+ {String.fromCharCode(65 + question.options.length)}
333
+ </Kbd>
334
+ </label>
335
+ <input
336
+ aria-label={labels.customAnswerFor(question.prompt)}
337
+ data-slot="approval-card-option-input"
338
+ onChange={(event) => {
339
+ if (active) onEditCustom(event.target.value)
340
+ }}
341
+ onFocus={() => {
342
+ if (active && !custom) chooseCustom(false)
343
+ }}
344
+ onKeyDown={(event) => {
345
+ event.stopPropagation()
346
+ if (!active) return
347
+ // Enter that confirms an IME candidate is part of the composition,
348
+ // not the answer (Safari reports it with keyCode 229).
349
+ if (event.nativeEvent.isComposing || event.keyCode === 229) return
350
+ if (event.key === "Enter") {
351
+ event.preventDefault()
352
+ onCommitCustom(event.currentTarget.value)
353
+ }
354
+ }}
355
+ placeholder={labels.customAnswer}
356
+ ref={inputRef}
357
+ tabIndex={active ? 0 : -1}
358
+ type="text"
359
+ value={entry.draft}
360
+ />
361
+ </div>
362
+ </div>
363
+ )
364
+ }
365
+
366
+ /**
367
+ * Pauses the agent for a few single-choice questions, one at a time, each with
368
+ * a free-text answer.
369
+ */
370
+ const ApprovalQuestions = React.forwardRef<HTMLDivElement, ApprovalQuestionsProps>(
371
+ function ApprovalQuestions(
372
+ {
373
+ approveLabel = "Continue",
374
+ labels: labelOverrides,
375
+ onApprove,
376
+ onKeyDown,
377
+ onReject,
378
+ questions,
379
+ rejectLabel = "Skip",
380
+ title = "Questions",
381
+ ...props
382
+ },
383
+ ref
384
+ ) {
385
+ const labels = { ...defaultLabels, ...labelOverrides }
386
+ const [entries, setEntries] = React.useState<Record<string, QuestionEntry>>({})
387
+ const [step, setStep] = React.useState(0)
388
+ const advanceTimer = React.useRef<number | null>(null)
389
+ const groupId = React.useId()
390
+
391
+ React.useEffect(
392
+ () => () => {
393
+ if (advanceTimer.current) window.clearTimeout(advanceTimer.current)
394
+ },
395
+ []
396
+ )
397
+
398
+ const lastStep = Math.max(questions.length - 1, 0)
399
+ const safeStep = Math.min(step, lastStep)
400
+ const answers = collectAnswers(questions, entries)
401
+ const complete = isComplete(questions, answers)
402
+ const track = useQuestionTrack(safeStep, questions, entries)
403
+
404
+ const clearAdvance = () => {
405
+ if (advanceTimer.current) window.clearTimeout(advanceTimer.current)
406
+ }
407
+
408
+ const advance = () => setStep((current) => Math.min(current + 1, lastStep))
409
+
410
+ const goToStep = (next: number) => {
411
+ clearAdvance()
412
+ setStep(Math.min(Math.max(next, 0), lastStep))
413
+ }
414
+
415
+ const updateEntry = (questionId: string, patch: Partial<QuestionEntry>) =>
416
+ setEntries((previous) => ({
417
+ ...previous,
418
+ [questionId]: { ...(previous[questionId] ?? emptyEntry), ...patch },
419
+ }))
420
+
421
+ const approve = (current: ApprovalAnswers) => {
422
+ if (isComplete(questions, current)) onApprove?.({ answers: current })
423
+ }
424
+
425
+ // A click, Space, or letter picks and advances after a beat.
426
+ const pickOption = (questionId: string, option: string, advanceAfter: boolean) => {
427
+ updateEntry(questionId, { choice: { kind: "option", option } })
428
+ if (advanceAfter && safeStep < lastStep) {
429
+ clearAdvance()
430
+ advanceTimer.current = window.setTimeout(advance, ADVANCE_MS)
431
+ }
432
+ }
433
+
434
+ const chooseCustom = (questionId: string) => {
435
+ clearAdvance()
436
+ updateEntry(questionId, { choice: { kind: "custom" } })
437
+ }
438
+
439
+ const editCustom = (questionId: string, draft: string) =>
440
+ updateEntry(questionId, { choice: { kind: "custom" }, draft })
441
+
442
+ // Enter in the free-text field answers the question: it advances, or
443
+ // submits from the last question.
444
+ const commitCustom = (questionId: string, draft: string) => {
445
+ if (!draft.trim()) return
446
+ editCustom(questionId, draft)
447
+ if (safeStep < lastStep) {
448
+ clearAdvance()
449
+ advance()
450
+ return
451
+ }
452
+ approve({ ...answers, [questionId]: draft.trim() })
453
+ }
454
+
455
+ const confirm = (questionId: string) => {
456
+ if (!answerOf(entries[questionId])) return
457
+ if (safeStep < lastStep) {
458
+ clearAdvance()
459
+ advance()
460
+ return
461
+ }
462
+ approve(answers)
463
+ }
464
+
465
+ return (
466
+ <ApprovalFrame
467
+ {...props}
468
+ approveDisabled={!complete}
469
+ approveLabel={approveLabel}
470
+ footerStart={
471
+ <QuestionStepNav
472
+ labels={labels}
473
+ onStep={goToStep}
474
+ step={safeStep}
475
+ total={questions.length}
476
+ />
477
+ }
478
+ icon={<MessageCircleQuestionIcon />}
479
+ onApprove={() => approve(answers)}
480
+ onKeyDown={(event) => {
481
+ onKeyDown?.(event)
482
+ if (event.defaultPrevented || event.key !== "Enter") return
483
+ if (safeStep !== lastStep || !complete) return
484
+ const target = event.target as HTMLElement
485
+ if (target.tagName === "INPUT" || target.tagName === "TEXTAREA") return
486
+ if (target.closest("[data-slot='approval-card-buttons']")) return
487
+ event.preventDefault()
488
+ approve(answers)
489
+ }}
490
+ onSecondary={() => onReject?.()}
491
+ ref={ref}
492
+ secondaryLabel={rejectLabel}
493
+ title={title}
494
+ tone="info"
495
+ >
496
+ {questions.length > 0 ? (
497
+ <div
498
+ aria-live="polite"
499
+ data-animate={track.animate ? "true" : "false"}
500
+ data-slot="approval-card-questions"
501
+ ref={track.viewportRef}
502
+ style={track.height !== null ? { height: `${track.height}px` } : undefined}
503
+ >
504
+ <div
505
+ data-animate={track.animate ? "true" : "false"}
506
+ data-slot="approval-card-questions-track"
507
+ style={{ transform: `translate3d(0, ${-track.offset}px, 0)` }}
508
+ >
509
+ {questions.map((question, questionIndex) => {
510
+ const active = questionIndex === safeStep
511
+ const promptId = `${groupId}-${question.id}-prompt`
512
+
513
+ return (
514
+ <div
515
+ aria-hidden={active ? undefined : "true"}
516
+ data-active={active ? "true" : "false"}
517
+ data-slot="approval-card-question"
518
+ key={question.id}
519
+ ref={(element) => {
520
+ track.questionRefs.current[questionIndex] = element
521
+ }}
522
+ >
523
+ <div data-slot="approval-card-prompt" dir="auto" id={promptId}>
524
+ {question.prompt}
525
+ </div>
526
+ <QuestionOptions
527
+ active={active}
528
+ entry={entries[question.id] ?? emptyEntry}
529
+ labels={labels}
530
+ name={`${groupId}-${question.id}`}
531
+ onChooseCustom={() => chooseCustom(question.id)}
532
+ onCommitCustom={(draft) => commitCustom(question.id, draft)}
533
+ onConfirm={() => confirm(question.id)}
534
+ onEditCustom={(draft) => editCustom(question.id, draft)}
535
+ onPick={(option, advanceAfter) =>
536
+ pickOption(question.id, option, advanceAfter)
537
+ }
538
+ promptId={promptId}
539
+ question={question}
540
+ />
541
+ </div>
542
+ )
543
+ })}
544
+ </div>
545
+ </div>
546
+ ) : null}
547
+ </ApprovalFrame>
548
+ )
549
+ }
550
+ )
551
+
552
+ export { ApprovalQuestions }
553
+ export type {
554
+ ApprovalAnswers,
555
+ ApprovalQuestion,
556
+ ApprovalQuestionsLabels,
557
+ ApprovalQuestionsPayload,
558
+ ApprovalQuestionsProps,
559
+ }
@@ -1,30 +1,30 @@
1
- "use client"
2
-
3
- import * as React from "react"
4
- import * as AspectRatioPrimitive from "@radix-ui/react-aspect-ratio"
5
-
6
- import { cn } from "@/lib/utils"
7
-
8
- type AspectRatioProps = React.ComponentPropsWithoutRef<
9
- typeof AspectRatioPrimitive.Root
10
- >
11
-
12
- const AspectRatio = React.forwardRef<
13
- React.ElementRef<typeof AspectRatioPrimitive.Root>,
14
- AspectRatioProps
15
- >(function AspectRatio({ className, ...props }, ref) {
16
- return (
17
- <AspectRatioPrimitive.Root
18
- data-slot="aspect-ratio"
19
- ref={ref}
20
- className={cn(
21
- "overflow-hidden rounded-[var(--bh-radius-xl-10)] bg-[var(--bh-bg-neutral-subtle)]",
22
- className
23
- )}
24
- {...props}
25
- />
26
- )
27
- })
28
-
29
- export { AspectRatio }
30
- export type { AspectRatioProps }
1
+ "use client"
2
+
3
+ import * as React from "react"
4
+ import * as AspectRatioPrimitive from "@radix-ui/react-aspect-ratio"
5
+
6
+ import { cn } from "@/lib/utils"
7
+
8
+ type AspectRatioProps = React.ComponentPropsWithoutRef<
9
+ typeof AspectRatioPrimitive.Root
10
+ >
11
+
12
+ const AspectRatio = React.forwardRef<
13
+ React.ElementRef<typeof AspectRatioPrimitive.Root>,
14
+ AspectRatioProps
15
+ >(function AspectRatio({ className, ...props }, ref) {
16
+ return (
17
+ <AspectRatioPrimitive.Root
18
+ data-slot="aspect-ratio"
19
+ ref={ref}
20
+ className={cn(
21
+ "overflow-hidden rounded-[var(--bh-radius-xl-10)] bg-[var(--bh-bg-neutral-subtle)]",
22
+ className
23
+ )}
24
+ {...props}
25
+ />
26
+ )
27
+ })
28
+
29
+ export { AspectRatio }
30
+ export type { AspectRatioProps }