banhaten 0.4.0 → 0.6.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 (214) hide show
  1. package/CHANGELOG.md +78 -1
  2. package/CONTRIBUTING.md +6 -4
  3. package/README.md +4 -2
  4. package/docs/design-spacing.md +13 -0
  5. package/docs/design-system/README.md +2 -0
  6. package/docs/design-system/appearances/default.md +1 -1
  7. package/docs/design-system/appearances/rounded.md +1 -1
  8. package/docs/design-system/appearances/sharp.md +1 -1
  9. package/docs/design-system/component-showcase-consistency-report.md +5 -5
  10. package/docs/design-system/components/README.md +15 -0
  11. package/docs/design-system/components/accordion.md +1 -1
  12. package/docs/design-system/components/activity-feed.md +1 -1
  13. package/docs/design-system/components/activity-heatmap.md +1 -1
  14. package/docs/design-system/components/agent-input.md +85 -0
  15. package/docs/design-system/components/alert-dialog.md +1 -1
  16. package/docs/design-system/components/alert.md +10 -4
  17. package/docs/design-system/components/app-header.md +97 -0
  18. package/docs/design-system/components/approval-card.md +134 -0
  19. package/docs/design-system/components/attribute.md +14 -2
  20. package/docs/design-system/components/avatar.md +1 -1
  21. package/docs/design-system/components/badge.md +1 -1
  22. package/docs/design-system/components/banner.md +1 -1
  23. package/docs/design-system/components/breadcrumbs.md +8 -3
  24. package/docs/design-system/components/button-group.md +1 -1
  25. package/docs/design-system/components/button.md +8 -5
  26. package/docs/design-system/components/card.md +15 -1
  27. package/docs/design-system/components/catalog-components.md +1 -1
  28. package/docs/design-system/components/chart.md +95 -24
  29. package/docs/design-system/components/checkbox.md +1 -1
  30. package/docs/design-system/components/collapsible.md +20 -6
  31. package/docs/design-system/components/command.md +1 -1
  32. package/docs/design-system/components/context-menu.md +1 -1
  33. package/docs/design-system/components/date-picker.md +1 -1
  34. package/docs/design-system/components/file-upload.md +1 -1
  35. package/docs/design-system/components/hover-card.md +1 -1
  36. package/docs/design-system/components/inline-citations.md +93 -0
  37. package/docs/design-system/components/input-group.md +1 -1
  38. package/docs/design-system/components/input.md +59 -3
  39. package/docs/design-system/components/item.md +1 -1
  40. package/docs/design-system/components/kbd.md +6 -4
  41. package/docs/design-system/components/menu.md +35 -4
  42. package/docs/design-system/components/menubar.md +1 -1
  43. package/docs/design-system/components/modal.md +2 -2
  44. package/docs/design-system/components/native-select.md +1 -1
  45. package/docs/design-system/components/navigation-menu.md +1 -1
  46. package/docs/design-system/components/onboarding-step-list-item.md +1 -1
  47. package/docs/design-system/components/page-header.md +58 -17
  48. package/docs/design-system/components/pagination.md +2 -2
  49. package/docs/design-system/components/popover.md +1 -1
  50. package/docs/design-system/components/progress-slider.md +1 -1
  51. package/docs/design-system/components/radio-card.md +1 -1
  52. package/docs/design-system/components/radio-group.md +1 -1
  53. package/docs/design-system/components/resizable.md +1 -1
  54. package/docs/design-system/components/rolling-count.md +61 -0
  55. package/docs/design-system/components/scroll-area.md +1 -1
  56. package/docs/design-system/components/select.md +12 -2
  57. package/docs/design-system/components/sheet.md +1 -1
  58. package/docs/design-system/components/shimmer-text.md +67 -0
  59. package/docs/design-system/components/sidebar.md +75 -8
  60. package/docs/design-system/components/skeleton.md +2 -2
  61. package/docs/design-system/components/slideout.md +1 -1
  62. package/docs/design-system/components/slider.md +2 -2
  63. package/docs/design-system/components/social-button.md +2 -2
  64. package/docs/design-system/components/streaming-text.md +82 -0
  65. package/docs/design-system/components/table.md +103 -21
  66. package/docs/design-system/components/tabs.md +25 -6
  67. package/docs/design-system/components/tag.md +1 -1
  68. package/docs/design-system/components/task-list.md +95 -0
  69. package/docs/design-system/components/textarea.md +1 -1
  70. package/docs/design-system/components/thinking-reasoning.md +72 -0
  71. package/docs/design-system/components/thinking-state.md +67 -0
  72. package/docs/design-system/components/timeline.md +1 -1
  73. package/docs/design-system/components/toast.md +1 -1
  74. package/docs/design-system/components/toggle.md +2 -2
  75. package/docs/design-system/components/toolbar.md +11 -6
  76. package/docs/design-system/components/tooltip.md +6 -4
  77. package/docs/design-system/density-and-elevation.md +16 -16
  78. package/docs/design-system/design-system-test-missing-items.md +8 -8
  79. package/docs/design-system/installation.md +2 -0
  80. package/docs/design-system/tabs.md +2 -1
  81. package/docs/design-system/template-gap-audit.md +32 -0
  82. package/docs/design-system/templates.md +59 -0
  83. package/docs/design-system/token-governance.md +15 -2
  84. package/package.json +2 -2
  85. package/registry/components/accordion.tsx +2 -2
  86. package/registry/components/agent-input-parts.tsx +552 -0
  87. package/registry/components/agent-input-state.ts +156 -0
  88. package/registry/components/agent-input.css +307 -0
  89. package/registry/components/agent-input.tsx +382 -0
  90. package/registry/components/alert-dialog.tsx +2 -2
  91. package/registry/components/alert.tsx +23 -7
  92. package/registry/components/app-header.tsx +151 -0
  93. package/registry/components/approval-card.css +409 -0
  94. package/registry/components/approval-card.tsx +401 -0
  95. package/registry/components/approval-frame.tsx +108 -0
  96. package/registry/components/approval-questions.tsx +559 -0
  97. package/registry/components/attribute.tsx +48 -13
  98. package/registry/components/autocomplete.tsx +15 -68
  99. package/registry/components/avatar.tsx +2 -2
  100. package/registry/components/badge.tsx +8 -7
  101. package/registry/components/button-group.tsx +2 -2
  102. package/registry/components/button.tsx +19 -14
  103. package/registry/components/card.tsx +18 -5
  104. package/registry/components/chart-block-data.ts +177 -0
  105. package/registry/components/chart-block.css +171 -0
  106. package/registry/components/chart-block.tsx +260 -0
  107. package/registry/components/chart-interactions.ts +123 -0
  108. package/registry/components/chart-options.cartesian.ts +162 -0
  109. package/registry/components/chart-options.paint.ts +99 -0
  110. package/registry/components/chart-options.parts.ts +72 -0
  111. package/registry/components/chart-options.radial.ts +109 -0
  112. package/registry/components/chart-options.ts +44 -0
  113. package/registry/components/chart-options.types.ts +67 -0
  114. package/registry/components/chart.tsx +1 -1
  115. package/registry/components/checkbox.tsx +6 -6
  116. package/registry/components/collapsible.tsx +43 -3
  117. package/registry/components/command.tsx +1 -1
  118. package/registry/components/context-menu.tsx +1 -1
  119. package/registry/components/data-chart.tsx +40 -0
  120. package/registry/components/date-picker.tsx +9 -9
  121. package/registry/components/echart-theme.ts +246 -0
  122. package/registry/components/echart.css +49 -0
  123. package/registry/components/echart.tsx +178 -0
  124. package/registry/components/expanded/Breadcrumbs.tsx +5 -1
  125. package/registry/components/expanded/PageHeader.tsx +67 -24
  126. package/registry/components/expanded/Table.tsx +60 -22
  127. package/registry/components/expanded/activityFeed.css +2 -2
  128. package/registry/components/expanded/activityHeatmap.css +1 -1
  129. package/registry/components/expanded/banner.css +2 -2
  130. package/registry/components/expanded/breadcrumbs.css +19 -2
  131. package/registry/components/expanded/catalogComponentsShowcase.css +1 -1
  132. package/registry/components/expanded/fileUpload.css +7 -7
  133. package/registry/components/expanded/onboardingStepListItem.css +2 -2
  134. package/registry/components/expanded/pageHeader.css +38 -44
  135. package/registry/components/expanded/slideout.css +4 -4
  136. package/registry/components/expanded/table.css +58 -9
  137. package/registry/components/expanded/timeline.css +1 -1
  138. package/registry/components/hover-card.tsx +1 -1
  139. package/registry/components/inline-citations.css +126 -0
  140. package/registry/components/inline-citations.tsx +292 -0
  141. package/registry/components/input-group.tsx +4 -4
  142. package/registry/components/input.tsx +150 -137
  143. package/registry/components/item.tsx +1 -1
  144. package/registry/components/kbd.tsx +3 -2
  145. package/registry/components/menu.tsx +117 -12
  146. package/registry/components/menubar.tsx +2 -2
  147. package/registry/components/modal.tsx +3 -3
  148. package/registry/components/native-select.tsx +4 -4
  149. package/registry/components/navigation-menu.tsx +2 -2
  150. package/registry/components/pagination.tsx +4 -4
  151. package/registry/components/popover.tsx +3 -3
  152. package/registry/components/progress-slider.tsx +1 -1
  153. package/registry/components/radio-card.tsx +7 -7
  154. package/registry/components/radio-group.tsx +2 -2
  155. package/registry/components/resizable.tsx +2 -2
  156. package/registry/components/rolling-count.css +37 -0
  157. package/registry/components/rolling-count.tsx +110 -0
  158. package/registry/components/scroll-area.tsx +1 -1
  159. package/registry/components/select.tsx +15 -10
  160. package/registry/components/sheet.tsx +2 -2
  161. package/registry/components/shimmer-text.css +69 -0
  162. package/registry/components/shimmer-text.tsx +43 -0
  163. package/registry/components/sidebar.tsx +528 -223
  164. package/registry/components/skeleton.tsx +1 -1
  165. package/registry/components/slider.tsx +2 -2
  166. package/registry/components/social-button.tsx +1 -1
  167. package/registry/components/streaming-text.css +45 -0
  168. package/registry/components/streaming-text.tsx +165 -0
  169. package/registry/components/table-elements.tsx +164 -5
  170. package/registry/components/tabs.css +32 -4
  171. package/registry/components/tabs.tsx +5 -1
  172. package/registry/components/tag.tsx +1 -1
  173. package/registry/components/task-list.css +328 -0
  174. package/registry/components/task-list.tsx +429 -0
  175. package/registry/components/textarea.tsx +4 -4
  176. package/registry/components/thinking-reasoning.css +178 -0
  177. package/registry/components/thinking-reasoning.tsx +253 -0
  178. package/registry/components/thinking-state.css +8 -0
  179. package/registry/components/thinking-state.tsx +46 -0
  180. package/registry/components/toast.tsx +1 -1
  181. package/registry/components/toggle.tsx +2 -2
  182. package/registry/components/toolbar.tsx +6 -6
  183. package/registry/components/tooltip.tsx +6 -4
  184. package/registry/examples/agent-input-demo.tsx +90 -0
  185. package/registry/examples/app-header-demo.tsx +40 -0
  186. package/registry/examples/approval-card-demo.tsx +83 -0
  187. package/registry/examples/button-demo.tsx +3 -0
  188. package/registry/examples/chart-block-demo.tsx +5 -0
  189. package/registry/examples/chart-demo.tsx +6 -21
  190. package/registry/examples/collection-filter-bar-demo.tsx +1 -1
  191. package/registry/examples/control-density-contract-demo.tsx +5 -1
  192. package/registry/examples/inline-citations-demo.tsx +31 -0
  193. package/registry/examples/input-actions-demo.tsx +36 -0
  194. package/registry/examples/menu-demo.tsx +1 -1
  195. package/registry/examples/popover-demo.tsx +1 -0
  196. package/registry/examples/rolling-count-demo.tsx +39 -0
  197. package/registry/examples/shimmer-text-demo.tsx +20 -0
  198. package/registry/examples/streaming-text-demo.tsx +28 -0
  199. package/registry/examples/task-list-demo.tsx +74 -0
  200. package/registry/examples/thinking-reasoning-demo.tsx +74 -0
  201. package/registry/examples/thinking-state-demo.tsx +13 -0
  202. package/registry/guidance/charts.LICENSE.txt +24 -0
  203. package/registry/hooks/use-controllable-state.ts +33 -0
  204. package/registry/hooks/use-listbox-navigation.ts +73 -0
  205. package/registry/hooks/use-motion-end.ts +55 -0
  206. package/registry/hooks/use-reduced-motion.ts +28 -0
  207. package/registry/index.json +1808 -190
  208. package/registry/styles/globals.css +992 -1088
  209. package/registry.json +385 -10
  210. package/schema/registry.schema.json +5 -1
  211. package/schema/tokens.schema.json +8 -4
  212. package/src/cli/file-transaction.js +15 -8
  213. package/src/cli/index.js +22 -29
  214. package/tokens/banhaten.tokens.json +490 -192
@@ -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
+ }
@@ -79,10 +79,17 @@ const attributeListVariants = cva("grid min-w-0", {
79
79
  "overflow-hidden rounded-[var(--bh-radius-lg-8)] border border-[var(--bh-border-subtle)] [&>[data-slot='attribute-item']]:px-[var(--bh-space-3xl-16)] sm:[&>[data-slot='attribute-item']]:px-[var(--bh-space-4xl-20)]",
80
80
  false: "",
81
81
  },
82
+ // comfortable spaces the rows with a gap instead of row padding, as in the app-shell detail cards.
83
+ density: {
84
+ compact: "",
85
+ default: "",
86
+ comfortable: "gap-y-[var(--bh-space-3xl-16)]",
87
+ },
82
88
  },
83
89
  defaultVariants: {
84
90
  layout: "inline",
85
91
  bordered: false,
92
+ density: "default",
86
93
  },
87
94
  })
88
95
 
@@ -99,7 +106,7 @@ const attributeItemVariants = cva("min-w-0", {
99
106
  density: {
100
107
  compact: "py-[var(--bh-space-md-8)]",
101
108
  default: "py-[var(--bh-space-3xl-16)]",
102
- comfortable: "py-[var(--bh-space-3xl-16)]",
109
+ comfortable: "py-[var(--bh-space-none)]",
103
110
  },
104
111
  divided: {
105
112
  true: "border-b border-[var(--bh-border-subtle)] last:border-b-0",
@@ -110,6 +117,13 @@ const attributeItemVariants = cva("min-w-0", {
110
117
  false: "",
111
118
  },
112
119
  },
120
+ compoundVariants: [
121
+ {
122
+ layout: "inline",
123
+ density: "comfortable",
124
+ class: "items-start gap-x-[var(--bh-space-3xl-16)]",
125
+ },
126
+ ],
113
127
  defaultVariants: {
114
128
  layout: "inline",
115
129
  density: "default",
@@ -119,21 +133,31 @@ const attributeItemVariants = cva("min-w-0", {
119
133
  })
120
134
 
121
135
  const attributeLabelVariants = cva(
122
- [
123
- "min-w-0 break-words text-[length:var(--bh-text-body-md-regular-font-size)]",
124
- "font-[var(--bh-text-body-md-regular-font-weight)]",
125
- "leading-[var(--bh-text-body-md-regular-line-height)]",
126
- "tracking-[var(--bh-text-body-md-regular-letter-spacing)]",
127
- "text-[var(--bh-content-subtle)]",
128
- ],
136
+ ["min-w-0 break-words text-[var(--bh-content-subtle)]"],
129
137
  {
130
138
  variants: {
139
+ // Comfortable lists set the label in body-sm, one step below the value.
140
+ size: {
141
+ default: [
142
+ "text-[length:var(--bh-text-body-md-regular-font-size)]",
143
+ "font-[var(--bh-text-body-md-regular-font-weight)]",
144
+ "leading-[var(--bh-text-body-md-regular-line-height)]",
145
+ "tracking-[var(--bh-text-body-md-regular-letter-spacing)]",
146
+ ],
147
+ sm: [
148
+ "text-[length:var(--bh-text-body-sm-regular-font-size)]",
149
+ "font-[var(--bh-text-body-sm-regular-font-weight)]",
150
+ "leading-[var(--bh-text-body-sm-regular-line-height)]",
151
+ "tracking-[var(--bh-text-body-sm-regular-letter-spacing)]",
152
+ ],
153
+ },
131
154
  truncate: {
132
155
  true: "truncate",
133
156
  false: "",
134
157
  },
135
158
  },
136
159
  defaultVariants: {
160
+ size: "default",
137
161
  truncate: false,
138
162
  },
139
163
  }
@@ -258,11 +282,16 @@ const AttributeList = React.forwardRef<HTMLDListElement, AttributeListProps>(
258
282
  className,
259
283
  density = "default",
260
284
  dividers = false,
261
- labelWidth = defaultAttributeContext.labelWidth,
285
+ labelWidth: labelWidthProp,
262
286
  layout = "inline",
263
287
  style,
264
288
  ...props
265
289
  }, ref) {
290
+ const labelWidth =
291
+ labelWidthProp ??
292
+ (density === "comfortable"
293
+ ? "var(--bh-attribute-comfortable-label-width)"
294
+ : defaultAttributeContext.labelWidth)
266
295
  const value = React.useMemo(
267
296
  () => ({
268
297
  density,
@@ -285,7 +314,7 @@ const AttributeList = React.forwardRef<HTMLDListElement, AttributeListProps>(
285
314
  data-slot="attribute-list"
286
315
  ref={ref}
287
316
  style={attributeStyle}
288
- className={cn(attributeListVariants({ layout, bordered, className }))}
317
+ className={cn(attributeListVariants({ layout, bordered, density, className }))}
289
318
  {...props}
290
319
  />
291
320
  </AttributeContext.Provider>
@@ -343,7 +372,11 @@ const AttributeItem = React.forwardRef<HTMLDivElement, AttributeItemProps>(
343
372
  )}
344
373
  {...props}
345
374
  >
346
- <AttributeLabel className={labelClassName} truncate={truncate}>
375
+ <AttributeLabel
376
+ className={labelClassName}
377
+ size={selectedDensity === "comfortable" ? "sm" : undefined}
378
+ truncate={truncate}
379
+ >
347
380
  {label}
348
381
  </AttributeLabel>
349
382
  <AttributeValue
@@ -363,12 +396,14 @@ type AttributeLabelProps = React.ComponentProps<"dt"> &
363
396
  VariantProps<typeof attributeLabelVariants>
364
397
 
365
398
  const AttributeLabel = React.forwardRef<HTMLElement, AttributeLabelProps>(
366
- function AttributeLabel({ children, className, truncate, ...props }, ref) {
399
+ function AttributeLabel({ children, className, size, truncate, ...props }, ref) {
400
+ const context = React.useContext(AttributeContext)
401
+ const selectedSize = size ?? (context.density === "comfortable" ? "sm" : "default")
367
402
  return (
368
403
  <dt
369
404
  data-slot="attribute-label"
370
405
  ref={ref}
371
- className={cn(attributeLabelVariants({ truncate, className }))}
406
+ className={cn(attributeLabelVariants({ size: selectedSize, truncate, className }))}
372
407
  {...props}
373
408
  >
374
409
  {renderAttributeText(children, "attribute-label-text")}