@eifi1/ui-kit 0.11.0 → 0.12.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 (342) hide show
  1. package/README.md +57 -32
  2. package/dist/chart.d.ts +3 -1
  3. package/dist/chart.js +2 -0
  4. package/dist/chart.js.map +1 -1
  5. package/dist/components/account-settings-labels.d.ts +105 -0
  6. package/dist/components/account-settings-labels.js +81 -0
  7. package/dist/components/account-settings-labels.js.map +1 -0
  8. package/dist/components/account-settings.d.ts +36 -52
  9. package/dist/components/account-settings.js +44 -8
  10. package/dist/components/account-settings.js.map +1 -1
  11. package/dist/components/amount-input.d.ts +24 -4
  12. package/dist/components/amount-input.js +5 -2
  13. package/dist/components/amount-input.js.map +1 -1
  14. package/dist/components/authed-image.d.ts +68 -0
  15. package/dist/components/authed-image.js +117 -0
  16. package/dist/components/authed-image.js.map +1 -0
  17. package/dist/components/breadcrumbs.d.ts +2 -1
  18. package/dist/components/breadcrumbs.js +5 -2
  19. package/dist/components/breadcrumbs.js.map +1 -1
  20. package/dist/components/button-group.d.ts +51 -2
  21. package/dist/components/button-group.js +5 -1
  22. package/dist/components/button-group.js.map +1 -1
  23. package/dist/components/calculator.d.ts +15 -4
  24. package/dist/components/calendar-heatmap.d.ts +41 -3
  25. package/dist/components/calendar-heatmap.js +48 -7
  26. package/dist/components/calendar-heatmap.js.map +1 -1
  27. package/dist/components/chip.d.ts +2 -1
  28. package/dist/components/chip.js +6 -3
  29. package/dist/components/chip.js.map +1 -1
  30. package/dist/components/choice-card.d.ts +35 -3
  31. package/dist/components/choice-card.js +83 -25
  32. package/dist/components/choice-card.js.map +1 -1
  33. package/dist/components/combobox-core.js +2 -0
  34. package/dist/components/combobox-core.js.map +1 -1
  35. package/dist/components/combobox.d.ts +2 -2
  36. package/dist/components/combobox.js +31 -9
  37. package/dist/components/combobox.js.map +1 -1
  38. package/dist/components/copy-button.d.ts +51 -70
  39. package/dist/components/copy-button.js +1 -1
  40. package/dist/components/copy-button.js.map +1 -1
  41. package/dist/components/data-table-cells.d.ts +63 -0
  42. package/dist/components/data-table-cells.js +64 -0
  43. package/dist/components/data-table-cells.js.map +1 -0
  44. package/dist/components/data-table-filter-popover.d.ts +1 -1
  45. package/dist/components/data-table-filters.d.ts +1 -1
  46. package/dist/components/data-table-filters.js +31 -1
  47. package/dist/components/data-table-filters.js.map +1 -1
  48. package/dist/components/data-table-labels.d.ts +13 -0
  49. package/dist/components/data-table-labels.js +8 -1
  50. package/dist/components/data-table-labels.js.map +1 -1
  51. package/dist/components/data-table.d.ts +1 -1
  52. package/dist/components/data-table.js +102 -12
  53. package/dist/components/data-table.js.map +1 -1
  54. package/dist/components/date-picker.d.ts +3 -1
  55. package/dist/components/date-picker.js +15 -2
  56. package/dist/components/date-picker.js.map +1 -1
  57. package/dist/components/description-list.d.ts +23 -3
  58. package/dist/components/description-list.js +23 -3
  59. package/dist/components/description-list.js.map +1 -1
  60. package/dist/components/dialog-frame.d.ts +1 -0
  61. package/dist/components/entity-combobox.d.ts +1 -1
  62. package/dist/components/entity-combobox.js +18 -3
  63. package/dist/components/entity-combobox.js.map +1 -1
  64. package/dist/components/error-boundary.d.ts +92 -0
  65. package/dist/components/error-boundary.js +111 -0
  66. package/dist/components/error-boundary.js.map +1 -0
  67. package/dist/components/field.d.ts +30 -4
  68. package/dist/components/field.js +40 -17
  69. package/dist/components/field.js.map +1 -1
  70. package/dist/components/file-button.d.ts +50 -208
  71. package/dist/components/file-dropzone.d.ts +49 -2
  72. package/dist/components/floating-panel.d.ts +13 -4
  73. package/dist/components/floating-panel.js +7 -3
  74. package/dist/components/floating-panel.js.map +1 -1
  75. package/dist/components/form-actions.d.ts +53 -0
  76. package/dist/components/form-actions.js +98 -0
  77. package/dist/components/form-actions.js.map +1 -0
  78. package/dist/components/image-grid.d.ts +67 -0
  79. package/dist/components/image-grid.js +112 -0
  80. package/dist/components/image-grid.js.map +1 -0
  81. package/dist/components/lightbox.d.ts +104 -0
  82. package/dist/components/lightbox.js +229 -0
  83. package/dist/components/lightbox.js.map +1 -0
  84. package/dist/components/line-items.d.ts +114 -0
  85. package/dist/components/line-items.js +238 -0
  86. package/dist/components/line-items.js.map +1 -0
  87. package/dist/components/list.d.ts +23 -2
  88. package/dist/components/list.js +32 -13
  89. package/dist/components/list.js.map +1 -1
  90. package/dist/components/loading-state.d.ts +24 -0
  91. package/dist/components/loading-state.js +32 -0
  92. package/dist/components/loading-state.js.map +1 -0
  93. package/dist/components/menu-item.d.ts +2 -1
  94. package/dist/components/menu-item.js +5 -1
  95. package/dist/components/menu-item.js.map +1 -1
  96. package/dist/components/mini-calendar.d.ts +13 -1
  97. package/dist/components/mini-calendar.js +46 -2
  98. package/dist/components/mini-calendar.js.map +1 -1
  99. package/dist/components/modal.d.ts +20 -1
  100. package/dist/components/modal.js +26 -2
  101. package/dist/components/modal.js.map +1 -1
  102. package/dist/components/month-picker.d.ts +21 -1
  103. package/dist/components/month-picker.js +139 -26
  104. package/dist/components/month-picker.js.map +1 -1
  105. package/dist/components/multi-entity-combobox.d.ts +1 -1
  106. package/dist/components/multi-entity-combobox.js +19 -3
  107. package/dist/components/multi-entity-combobox.js.map +1 -1
  108. package/dist/components/nav-pills.d.ts +2 -1
  109. package/dist/components/nav-pills.js +5 -1
  110. package/dist/components/nav-pills.js.map +1 -1
  111. package/dist/components/number-field.d.ts +24 -5
  112. package/dist/components/number-field.js +5 -7
  113. package/dist/components/number-field.js.map +1 -1
  114. package/dist/components/number-input.d.ts +25 -5
  115. package/dist/components/number-input.js +7 -1
  116. package/dist/components/number-input.js.map +1 -1
  117. package/dist/components/numpad-sheet.d.ts +15 -4
  118. package/dist/components/passkeys-setting.d.ts +67 -0
  119. package/dist/components/passkeys-setting.js +214 -0
  120. package/dist/components/passkeys-setting.js.map +1 -0
  121. package/dist/components/picker-sheet.js +2 -1
  122. package/dist/components/picker-sheet.js.map +1 -1
  123. package/dist/components/pie-chart-labels.d.ts +20 -0
  124. package/dist/components/pie-chart-labels.js +12 -0
  125. package/dist/components/pie-chart-labels.js.map +1 -0
  126. package/dist/components/pie-chart.d.ts +101 -0
  127. package/dist/components/pie-chart.js +283 -0
  128. package/dist/components/pie-chart.js.map +1 -0
  129. package/dist/components/progress-bar.d.ts +44 -3
  130. package/dist/components/progress-bar.js +58 -22
  131. package/dist/components/progress-bar.js.map +1 -1
  132. package/dist/components/qr-code.d.ts +34 -0
  133. package/dist/components/qr-code.js +57 -0
  134. package/dist/components/qr-code.js.map +1 -0
  135. package/dist/components/search-field.d.ts +15 -3
  136. package/dist/components/search-field.js +5 -1
  137. package/dist/components/search-field.js.map +1 -1
  138. package/dist/components/settings-fields.d.ts +49 -1
  139. package/dist/components/signed-amount.d.ts +121 -0
  140. package/dist/components/signed-amount.js +152 -0
  141. package/dist/components/signed-amount.js.map +1 -0
  142. package/dist/components/skeleton.d.ts +10 -3
  143. package/dist/components/skeleton.js +12 -2
  144. package/dist/components/skeleton.js.map +1 -1
  145. package/dist/components/stat-tile.d.ts +2 -1
  146. package/dist/components/stat-tile.js +5 -2
  147. package/dist/components/stat-tile.js.map +1 -1
  148. package/dist/components/table.d.ts +39 -5
  149. package/dist/components/table.js +45 -11
  150. package/dist/components/table.js.map +1 -1
  151. package/dist/components/text-link.d.ts +176 -0
  152. package/dist/components/text-link.js +109 -0
  153. package/dist/components/text-link.js.map +1 -0
  154. package/dist/components/time-input.d.ts +49 -1
  155. package/dist/components/toggle-group.d.ts +13 -0
  156. package/dist/components/toggle-group.js +35 -6
  157. package/dist/components/toggle-group.js.map +1 -1
  158. package/dist/components/toggle-legend.d.ts +36 -3
  159. package/dist/components/toggle-legend.js +49 -0
  160. package/dist/components/toggle-legend.js.map +1 -1
  161. package/dist/components/ui.d.ts +51 -779
  162. package/dist/components/ui.js +215 -23
  163. package/dist/components/ui.js.map +1 -1
  164. package/dist/components/use-table-state.d.ts +3 -52
  165. package/dist/components/use-table-state.js +128 -30
  166. package/dist/components/use-table-state.js.map +1 -1
  167. package/dist/{data-table-filters-Dh9uF_S-.d.ts → data-table-Drun8Fk9.d.ts} +305 -61
  168. package/dist/data-table.d.ts +2 -1
  169. package/dist/data-table.js +5 -1
  170. package/dist/data-table.js.map +1 -1
  171. package/dist/feedback/feedback-dialog.d.ts +26 -3
  172. package/dist/feedback/feedback-dialog.js +16 -2
  173. package/dist/feedback/feedback-dialog.js.map +1 -1
  174. package/dist/feedback/feedback-thread.d.ts +133 -0
  175. package/dist/feedback/feedback-thread.js +237 -0
  176. package/dist/feedback/feedback-thread.js.map +1 -0
  177. package/dist/feedback.d.ts +2 -1
  178. package/dist/feedback.js +1 -0
  179. package/dist/feedback.js.map +1 -1
  180. package/dist/hooks/use-authed-src.d.ts +71 -0
  181. package/dist/hooks/use-authed-src.js +63 -0
  182. package/dist/hooks/use-authed-src.js.map +1 -0
  183. package/dist/hooks/use-file-drop.d.ts +49 -2
  184. package/dist/hooks/use-hotkey.d.ts +59 -0
  185. package/dist/hooks/use-hotkey.js +105 -0
  186. package/dist/hooks/use-hotkey.js.map +1 -0
  187. package/dist/hooks/use-search-param-state.d.ts +83 -0
  188. package/dist/hooks/use-search-param-state.js +94 -0
  189. package/dist/hooks/use-search-param-state.js.map +1 -0
  190. package/dist/i18n/defaults.d.ts +15 -4
  191. package/dist/i18n/defaults.js +31 -1
  192. package/dist/i18n/defaults.js.map +1 -1
  193. package/dist/i18n/kit-labels.d.ts +46 -312
  194. package/dist/i18n/kit-labels.js +20 -5
  195. package/dist/i18n/kit-labels.js.map +1 -1
  196. package/dist/i18n/locales/de-CH-informal.d.ts +15 -4
  197. package/dist/i18n/locales/de-CH.d.ts +15 -4
  198. package/dist/i18n/locales/de-informal.d.ts +15 -4
  199. package/dist/i18n/locales/de.d.ts +15 -4
  200. package/dist/i18n/locales/de.js +156 -3
  201. package/dist/i18n/locales/de.js.map +1 -1
  202. package/dist/i18n/locales/es.d.ts +15 -4
  203. package/dist/i18n/locales/es.js +153 -3
  204. package/dist/i18n/locales/es.js.map +1 -1
  205. package/dist/i18n/locales/fr.d.ts +15 -4
  206. package/dist/i18n/locales/fr.js +153 -3
  207. package/dist/i18n/locales/fr.js.map +1 -1
  208. package/dist/i18n/locales/hu.d.ts +15 -4
  209. package/dist/i18n/locales/hu.js +153 -3
  210. package/dist/i18n/locales/hu.js.map +1 -1
  211. package/dist/i18n/locales/it.d.ts +15 -4
  212. package/dist/i18n/locales/it.js +153 -3
  213. package/dist/i18n/locales/it.js.map +1 -1
  214. package/dist/i18n/locales/zh.d.ts +15 -4
  215. package/dist/i18n/locales/zh.js +153 -3
  216. package/dist/i18n/locales/zh.js.map +1 -1
  217. package/dist/index.d.ts +37 -17
  218. package/dist/index.js +40 -1
  219. package/dist/index.js.map +1 -1
  220. package/dist/kit-labels-v3biUF1L.d.ts +1645 -0
  221. package/dist/lib/format.d.ts +144 -0
  222. package/dist/lib/format.js +149 -0
  223. package/dist/lib/format.js.map +1 -0
  224. package/dist/lib/qr-encode.d.ts +44 -0
  225. package/dist/lib/qr-encode.js +338 -0
  226. package/dist/lib/qr-encode.js.map +1 -0
  227. package/dist/rhf/fields.d.ts +241 -0
  228. package/dist/rhf/fields.js +601 -0
  229. package/dist/rhf/fields.js.map +1 -0
  230. package/dist/rhf/form.d.ts +49 -1
  231. package/dist/rhf/line-items.d.ts +31 -0
  232. package/dist/rhf/line-items.js +59 -0
  233. package/dist/rhf/line-items.js.map +1 -0
  234. package/dist/rhf.d.ts +61 -1
  235. package/dist/rhf.js +2 -0
  236. package/dist/rhf.js.map +1 -1
  237. package/dist/shell/app-shell.d.ts +99 -2
  238. package/dist/shell/app-shell.js +58 -3
  239. package/dist/shell/app-shell.js.map +1 -1
  240. package/dist/shell/auth-layout.d.ts +74 -0
  241. package/dist/shell/auth-layout.js +97 -0
  242. package/dist/shell/auth-layout.js.map +1 -0
  243. package/dist/shell/top-bar-brand.d.ts +81 -0
  244. package/dist/shell/top-bar-brand.js +47 -0
  245. package/dist/shell/top-bar-brand.js.map +1 -0
  246. package/dist/shell/topbar-action-menu.d.ts +12 -1
  247. package/dist/shell/topbar-action-menu.js +16 -1
  248. package/dist/shell/topbar-action-menu.js.map +1 -1
  249. package/dist/shell.d.ts +56 -1
  250. package/dist/shell.js +2 -0
  251. package/dist/shell.js.map +1 -1
  252. package/dist/wizard/stepper-nav.d.ts +48 -1
  253. package/dist/wizard/wizard-context.d.ts +24 -1
  254. package/dist/wizard/wizard-context.js +19 -2
  255. package/dist/wizard/wizard-context.js.map +1 -1
  256. package/dist/wizard/wizard-step.d.ts +20 -5
  257. package/dist/wizard/wizard-step.js +16 -3
  258. package/dist/wizard/wizard-step.js.map +1 -1
  259. package/dist/wizard.d.ts +50 -3
  260. package/package.json +1 -1
  261. package/src/chart.ts +4 -0
  262. package/src/components/account-settings-labels.ts +190 -0
  263. package/src/components/account-settings.tsx +110 -63
  264. package/src/components/amount-input.tsx +13 -1
  265. package/src/components/authed-image.tsx +190 -0
  266. package/src/components/breadcrumbs.tsx +8 -2
  267. package/src/components/button-group.tsx +7 -2
  268. package/src/components/calendar-heatmap.tsx +105 -10
  269. package/src/components/chip.tsx +8 -4
  270. package/src/components/choice-card.tsx +136 -25
  271. package/src/components/combobox-core.tsx +5 -0
  272. package/src/components/combobox.tsx +31 -9
  273. package/src/components/copy-button.tsx +3 -2
  274. package/src/components/data-table-cells.tsx +119 -0
  275. package/src/components/data-table-filters.ts +77 -0
  276. package/src/components/data-table-labels.ts +23 -0
  277. package/src/components/data-table.tsx +224 -13
  278. package/src/components/date-picker.tsx +19 -3
  279. package/src/components/description-list.tsx +52 -3
  280. package/src/components/entity-combobox.tsx +18 -3
  281. package/src/components/error-boundary.tsx +216 -0
  282. package/src/components/field.tsx +56 -5
  283. package/src/components/floating-panel.tsx +20 -7
  284. package/src/components/form-actions.tsx +191 -0
  285. package/src/components/image-grid.tsx +181 -0
  286. package/src/components/lightbox.tsx +374 -0
  287. package/src/components/line-items.tsx +385 -0
  288. package/src/components/list.tsx +65 -16
  289. package/src/components/loading-state.tsx +47 -0
  290. package/src/components/menu-item.tsx +7 -2
  291. package/src/components/mini-calendar.tsx +72 -3
  292. package/src/components/modal.tsx +61 -3
  293. package/src/components/month-picker.tsx +181 -26
  294. package/src/components/multi-entity-combobox.tsx +19 -3
  295. package/src/components/nav-pills.tsx +8 -1
  296. package/src/components/number-field.tsx +15 -10
  297. package/src/components/number-input.tsx +16 -1
  298. package/src/components/passkeys-setting.tsx +331 -0
  299. package/src/components/picker-sheet.tsx +7 -1
  300. package/src/components/pie-chart-labels.ts +32 -0
  301. package/src/components/pie-chart.tsx +479 -0
  302. package/src/components/progress-bar.tsx +129 -19
  303. package/src/components/qr-code.tsx +83 -0
  304. package/src/components/search-field.tsx +20 -4
  305. package/src/components/signed-amount.tsx +286 -0
  306. package/src/components/skeleton.tsx +21 -3
  307. package/src/components/stat-tile.tsx +8 -2
  308. package/src/components/table.tsx +102 -13
  309. package/src/components/text-link.tsx +248 -0
  310. package/src/components/toggle-group.tsx +58 -10
  311. package/src/components/toggle-legend.tsx +87 -2
  312. package/src/components/ui.tsx +426 -36
  313. package/src/components/use-table-state.ts +265 -33
  314. package/src/data-table.ts +10 -0
  315. package/src/feedback/feedback-dialog.tsx +42 -3
  316. package/src/feedback/feedback-thread.tsx +434 -0
  317. package/src/feedback.ts +1 -0
  318. package/src/hooks/use-authed-src.ts +161 -0
  319. package/src/hooks/use-hotkey.ts +172 -0
  320. package/src/hooks/use-search-param-state.ts +187 -0
  321. package/src/i18n/defaults.ts +30 -0
  322. package/src/i18n/kit-labels.tsx +102 -8
  323. package/src/i18n/locales/de.ts +153 -0
  324. package/src/i18n/locales/es.ts +150 -0
  325. package/src/i18n/locales/fr.ts +153 -0
  326. package/src/i18n/locales/hu.ts +151 -0
  327. package/src/i18n/locales/it.ts +151 -0
  328. package/src/i18n/locales/zh.ts +150 -0
  329. package/src/index.ts +59 -0
  330. package/src/lib/format.ts +325 -0
  331. package/src/lib/qr-encode.ts +420 -0
  332. package/src/rhf/fields.tsx +1054 -0
  333. package/src/rhf/line-items.tsx +130 -0
  334. package/src/rhf.ts +6 -0
  335. package/src/shell/app-shell.tsx +147 -2
  336. package/src/shell/auth-layout.tsx +190 -0
  337. package/src/shell/top-bar-brand.tsx +76 -0
  338. package/src/shell/topbar-action-menu.tsx +21 -0
  339. package/src/shell.ts +2 -0
  340. package/src/wizard/wizard-context.tsx +41 -1
  341. package/src/wizard/wizard-step.tsx +43 -6
  342. package/tokens.css +12 -0
@@ -1,11 +1,13 @@
1
1
  import { forwardRef, useId, useLayoutEffect, useRef, useState } from "react";
2
2
  import { ChevronDown, Eye, EyeOff, HelpCircle, Plus, X } from "lucide-react";
3
- import type { ButtonHTMLAttributes, ComponentPropsWithoutRef, InputHTMLAttributes, KeyboardEvent, MouseEvent, ReactNode, Ref, SelectHTMLAttributes, TextareaHTMLAttributes } from "react";
3
+ import type { AnchorHTMLAttributes, ButtonHTMLAttributes, ComponentPropsWithoutRef, InputHTMLAttributes, KeyboardEvent, MouseEvent, ReactElement, ReactNode, Ref, SelectHTMLAttributes, TextareaHTMLAttributes } from "react";
4
4
  import { cn } from "../lib/cn";
5
5
  import { horizontalStep } from "../lib/direction";
6
6
  import { useMediaQuery } from "../hooks/use-media-query";
7
7
  import { Tooltip, type TooltipSide } from "./tooltip";
8
- import { DEFAULT_COMMON_LABELS, useKitLabels } from "../i18n/kit-labels";
8
+ import { DEFAULT_COMMON_LABELS, useKitLabels, useKitLink } from "../i18n/kit-labels";
9
+ import type { KitLinkComponent, KitLinkProps } from "../i18n/kit-labels";
10
+ import { pickLinkRenderer } from "./text-link";
9
11
 
10
12
  export type ButtonVariant = "primary" | "secondary" | "ghost" | "danger" | "brand" | "link";
11
13
 
@@ -70,8 +72,18 @@ const buttonVariantClasses: Record<ButtonVariant, string> = {
70
72
  *
71
73
  * A `tone` rather than a `link-muted` variant, because it is the axis IconButton
72
74
  * already has (`tone="muted"`, `tone="danger"`, same quiet-until-hover meaning), and
73
- * because the same two looks are wanted on `ghost`. The filled and bordered variants
74
- * carry their meaning in the box, not the text, so a tone on them does nothing.
75
+ * because the same two looks are wanted on `ghost`.
76
+ *
77
+ * `danger` also reaches the two NEUTRAL boxed variants, where the box carries it:
78
+ * `secondary` becomes an outlined destructive button (`--danger` text and a
79
+ * `--danger-border` outline, the quiet danger fill on hover) and `primary` a soft one
80
+ * (the quiet fill at rest, solid `--danger` on hover). kastlan's meeting-invitations-
81
+ * tab.tsx "Remove all" is `variant="secondary" className="text-destructive"` — a
82
+ * destructive action that must sit in a row of secondary buttons without shouting
83
+ * like the solid `variant="danger"` would, and whose red text alone left the border
84
+ * and hover saying "neutral". `muted` stays a text look and does nothing on a box, and
85
+ * `danger`, `brand` and `link`'s own colours are left alone: a tone that recoloured a
86
+ * solid danger or brand button would be a second variant under another name.
75
87
  */
76
88
  export type ButtonTone = "default" | "muted" | "danger";
77
89
 
@@ -82,9 +94,19 @@ const BUTTON_TONES: Record<Exclude<ButtonTone, "default">, string> = {
82
94
  "text-[var(--text-secondary)] hover:text-[var(--danger)] focus:ring-[var(--danger-border)] disabled:hover:text-[var(--text-secondary)]",
83
95
  };
84
96
 
85
- /** Only the two transparent variants take a tone; see {@link ButtonTone}. */
97
+ /** Only the two transparent variants take every tone; see {@link ButtonTone}. */
86
98
  const TONED_VARIANTS = new Set<ButtonVariant>(["link", "ghost"]);
87
99
 
100
+ // `tone="danger"` on the neutral boxed variants — see {@link ButtonTone}. Each string
101
+ // restates every colour the variant sets (border, fill, text, hover, ring) so none of
102
+ // the neutral ones survives the merge.
103
+ const BUTTON_BOXED_DANGER: Partial<Record<ButtonVariant, string>> = {
104
+ secondary:
105
+ "border-[var(--danger-border)] bg-transparent text-[var(--danger)] hover:bg-[var(--danger-bg)] focus:ring-[var(--danger-border)]",
106
+ primary:
107
+ "border-[var(--danger-border)] bg-[var(--danger-bg)] text-[var(--danger)] hover:border-[var(--danger)] hover:bg-[var(--danger)] hover:text-[var(--danger-contrast)] focus:ring-[var(--danger-border)] disabled:hover:border-[var(--danger-border)] disabled:hover:bg-[var(--danger-bg)] disabled:hover:text-[var(--danger)]",
108
+ };
109
+
88
110
  // `pressed` on a `link`: the brand colour and a heavier weight, which is exactly what
89
111
  // lenkbank's "All speeds" toggle paints by hand (gear/hysteresis-charts.tsx:482). After
90
112
  // the tone, so a pressed muted link is brand, and an unpressed one is quiet grey.
@@ -140,8 +162,9 @@ export interface ButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
140
162
  * declared here only because `ButtonHTMLAttributes` does not carry it. */
141
163
  ref?: Ref<HTMLButtonElement>;
142
164
  /** Text colour for `link` and `ghost`: `muted` (quiet, body colour on hover) or
143
- * `danger` (quiet, `--danger` on hover). Ignored by the other variants. See
144
- * {@link ButtonTone}. */
165
+ * `danger` (quiet, `--danger` on hover). `danger` also makes `secondary` an
166
+ * outlined destructive button and `primary` a soft one; ignored by the other
167
+ * variants. See {@link ButtonTone}. */
145
168
  tone?: ButtonTone;
146
169
  /**
147
170
  * Make it a toggle button, as {@link IconButton}'s `pressed` does. `true` sets
@@ -172,57 +195,243 @@ export interface ButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
172
195
  * DangerConfirm keeps its own.
173
196
  */
174
197
  disabledReason?: ReactNode;
198
+ /**
199
+ * Busy — the save is in flight: a spinner, `aria-busy`, and no second submit.
200
+ *
201
+ * kastlan's FormActions (form-actions.tsx:50) disables its submit and swaps in a
202
+ * `submittingLabel`, so the button jumps width mid-click and a screen reader hears
203
+ * the name change instead of "busy". Here the label STAYS — it is still the
204
+ * accessible name and still holds the button's width — and the spinner is drawn over
205
+ * it in the text colour, so nothing beside the button moves.
206
+ *
207
+ * The same prop {@link FileButton} has (`pending`), with one difference: this one is
208
+ * `aria-disabled`, not `disabled`. The button being pressed is the one that has focus,
209
+ * and a native `disabled` drops that focus to `<body>` the moment the save starts;
210
+ * clicks, and the Enter that submits a form through it, are swallowed instead.
211
+ */
212
+ pending?: boolean;
213
+ /** Only on the link form — see {@link ButtonLinkProps}. */
214
+ href?: never;
215
+ renderLink?: never;
216
+ external?: never;
217
+ }
218
+
219
+ /**
220
+ * `<Button href>`: a link that looks like a button — for an action that NAVIGATES.
221
+ *
222
+ * kastlan paints `buttonClasses(…)` onto router Links by hand in six places (the
223
+ * invoice / lease / unit preview dialogs' "Open" at invoice-preview-dialog.tsx:36,
224
+ * platform-companies-page.tsx:111, verify-email-page.tsx:86's "Log in",
225
+ * billing-history-page.tsx:85's PDF download), and has 26 `<Button onClick={() =>
226
+ * navigate(…)}>` that are links in all but markup: no middle-click, no "open in new
227
+ * tab", no URL on hover, and a screen reader announces a button that then changes the
228
+ * page. With `href` it is an `<a>`: the provider's router link
229
+ * ({@link UiKitProvider}'s `linkComponent`), or `renderLink` if given, else a plain one.
230
+ *
231
+ * `type` and `form` are typed out: they are a `<button>`'s, and a link never submits.
232
+ */
233
+ export interface ButtonLinkProps extends Omit<AnchorHTMLAttributes<HTMLAnchorElement>, "type" | "href"> {
234
+ href: string;
235
+ variant?: ButtonVariant;
236
+ size?: ButtonSize;
237
+ stretch?: boolean;
238
+ tone?: ButtonTone;
239
+ /** The in-app router link, over the provider's `linkComponent` (see
240
+ * {@link KitLinkComponent}). Ignored when `external`. */
241
+ renderLink?: KitLinkComponent;
242
+ /** Leaves the app: a plain `<a target="_blank" rel="noopener noreferrer">`, never the
243
+ * router link, and "(opens in a new tab)" read after the label — billing-history's
244
+ * Stripe PDF. A `target="_blank"` of your own is announced the same way. */
245
+ external?: boolean;
246
+ /** A link cannot be `disabled`, so this renders an `<a>` with NO `href` —
247
+ * `role="link"` and `aria-disabled`, out of the tab order like a disabled button,
248
+ * with the disabled look. The router link is not used: it needs somewhere to go. */
249
+ disabled?: boolean;
250
+ ref?: Ref<HTMLAnchorElement>;
251
+ type?: never;
252
+ form?: never;
253
+ pending?: never;
254
+ pressed?: never;
255
+ disabledReason?: never;
175
256
  }
176
257
 
177
258
  function hasContent(node: ReactNode): boolean {
178
259
  return node !== undefined && node !== null && node !== false && node !== "";
179
260
  }
180
261
 
181
- export function Button({
262
+ /** The button look shared by the `<button>` and the `<a>` form. */
263
+ function buttonLook(variant: ButtonVariant, size: ButtonSize, stretch: boolean | undefined, tone: ButtonTone) {
264
+ return cn(
265
+ BUTTON_BASE,
266
+ BUTTON_SIZES[size],
267
+ // In a flex row next to a taller labelled field, `stretch` makes the button
268
+ // fill the field's height so the two line up (self-stretch overrides the row's
269
+ // align-items). No effect outside a flex row / when it's already the tallest.
270
+ stretch && "self-stretch",
271
+ buttonVariantClasses[variant],
272
+ tone !== "default" && TONED_VARIANTS.has(variant) && BUTTON_TONES[tone],
273
+ tone === "danger" && BUTTON_BOXED_DANGER[variant],
274
+ );
275
+ }
276
+
277
+ // Declared link-first so `<Button href>` picks the link props; the LAST signature is
278
+ // what `ComponentProps<typeof Button>` reads, so wrappers (FileButton) still see the
279
+ // `<button>` props they always did.
280
+ export function Button(props: ButtonLinkProps): ReactElement;
281
+ export function Button(props: ButtonProps): ReactElement;
282
+ export function Button(props: ButtonProps | ButtonLinkProps): ReactElement {
283
+ if (props.href !== undefined) return <ButtonLink {...(props as ButtonLinkProps)} />;
284
+ return <ButtonElement {...(props as ButtonProps)} />;
285
+ }
286
+
287
+ /** Calls a `renderLink` / provider link as a function, as ListItem and Chip do — an
288
+ * inline `renderLink` arrow would otherwise be a new component type every render. */
289
+ function RenderedButtonLink({ render, ...props }: KitLinkProps & { render: KitLinkComponent }) {
290
+ return render(props);
291
+ }
292
+
293
+ function ButtonLink({
294
+ href,
295
+ renderLink,
296
+ external,
297
+ disabled,
298
+ variant = "primary",
299
+ size = "md",
300
+ stretch,
301
+ tone = "default",
302
+ className,
303
+ children,
304
+ target,
305
+ rel,
306
+ onClick,
307
+ ...rest
308
+ }: ButtonLinkProps) {
309
+ const kitLink = useKitLink();
310
+ const common = useKitLabels("common", DEFAULT_COMMON_LABELS);
311
+ // The same rule as every kit link (`pickLinkRenderer`): an external `href` or an
312
+ // in-page `#anchor` is never handed to the provider's router link, even without
313
+ // `external`.
314
+ const Link = pickLinkRenderer<KitLinkProps>(renderLink, kitLink, href);
315
+ const newTab = !disabled && (external || target === "_blank");
316
+ const cls = cn(
317
+ buttonLook(variant, size, stretch, tone),
318
+ // `relative` holds the sr-only note (see sr-only-containment.test).
319
+ newTab && "relative",
320
+ // BUTTON_BASE's `disabled:` classes never match an `<a>`; no pointer events also
321
+ // keeps the hover fill from lighting up on something that refuses the click.
322
+ disabled && "pointer-events-none cursor-not-allowed opacity-50",
323
+ className,
324
+ );
325
+ const body = (
326
+ <>
327
+ {children}
328
+ {/* The space OUTSIDE the sr-only span: a name computation trims each child's
329
+ text, so one inside it was lost ("Download(opens…"). In a flex box a bare
330
+ space draws nothing. */}
331
+ {newTab && " "}
332
+ {newTab && <span className="sr-only">({common.opensInNewTab})</span>}
333
+ </>
334
+ );
335
+ if (disabled) {
336
+ return (
337
+ <a {...rest} role="link" aria-disabled="true" className={cls}>
338
+ {body}
339
+ </a>
340
+ );
341
+ }
342
+ if (external || !Link) {
343
+ return (
344
+ <a
345
+ {...rest}
346
+ href={href}
347
+ target={external ? "_blank" : target}
348
+ rel={external ? (rel ?? "noopener noreferrer") : rel}
349
+ onClick={onClick}
350
+ className={cls}
351
+ >
352
+ {body}
353
+ </a>
354
+ );
355
+ }
356
+ return (
357
+ <RenderedButtonLink
358
+ render={Link}
359
+ {...rest}
360
+ href={href}
361
+ target={target}
362
+ rel={rel}
363
+ onClick={onClick}
364
+ className={cls}
365
+ >
366
+ {body}
367
+ </RenderedButtonLink>
368
+ );
369
+ }
370
+
371
+ function ButtonElement({
182
372
  variant = "primary",
183
373
  size = "md",
184
374
  stretch,
185
375
  tone = "default",
186
376
  pressed,
187
377
  disabledReason,
378
+ pending,
188
379
  className,
189
380
  onClick,
381
+ children,
382
+ href: _href,
383
+ renderLink: _renderLink,
384
+ external: _external,
190
385
  ...rest
191
386
  }: ButtonProps) {
192
387
  const reasonId = useId();
193
388
  const locked = hasContent(disabledReason);
389
+ const inert = locked || Boolean(pending);
194
390
  const ownDescribedBy = rest["aria-describedby"];
195
391
  const button = (
196
392
  <button
197
393
  {...rest}
198
- disabled={locked ? undefined : rest.disabled}
199
- aria-disabled={locked || rest["aria-disabled"]}
394
+ disabled={inert ? undefined : rest.disabled}
395
+ aria-disabled={inert || rest["aria-disabled"]}
396
+ aria-busy={pending || rest["aria-busy"]}
200
397
  aria-describedby={locked ? (ownDescribedBy ? `${ownDescribedBy} ${reasonId}` : reasonId) : ownDescribedBy}
201
398
  aria-pressed={pressed ?? rest["aria-pressed"]}
202
399
  onClick={(e) => {
203
400
  // `preventDefault` as well as not calling through: a locked submit button must
204
401
  // not submit its form, and the browser does that after this handler returns.
205
- if (locked) {
402
+ if (inert) {
206
403
  e.preventDefault();
207
404
  return;
208
405
  }
209
406
  onClick?.(e);
210
407
  }}
211
408
  className={cn(
212
- BUTTON_BASE,
213
- BUTTON_SIZES[size],
214
- // In a flex row next to a taller labelled field, `stretch` makes the button
215
- // fill the field's height so the two line up (self-stretch overrides the row's
216
- // align-items). No effect outside a flex row / when it's already the tallest.
217
- stretch && "self-stretch",
218
- buttonVariantClasses[variant],
219
- tone !== "default" && TONED_VARIANTS.has(variant) && BUTTON_TONES[tone],
409
+ buttonLook(variant, size, stretch, tone),
220
410
  pressed && (variant === "link" ? BUTTON_LINK_PRESSED : ICON_BUTTON_PRESSED),
221
411
  // The disabled look for the focusable kind of disabled.
222
412
  locked && "cursor-not-allowed opacity-50",
413
+ // No dimming while pending: the spinner IS the state, and at half opacity it
414
+ // would be the faintest thing on the button. `relative` anchors it.
415
+ pending && "relative cursor-progress",
223
416
  className,
224
417
  )}
225
- />
418
+ >
419
+ {pending ? (
420
+ <>
421
+ {/* Transparent, not hidden: it keeps the width AND stays the accessible
422
+ name (`visibility: hidden` would drop it from the name). `gap-[inherit]`
423
+ keeps an icon + text spaced as the button spaces them. */}
424
+ <span className="inline-flex items-center justify-center gap-[inherit] opacity-0">{children}</span>
425
+ {/* Decorative: `aria-busy` says it, as on FileButton. */}
426
+ <Spinner
427
+ label={null}
428
+ className="absolute inset-0 m-auto size-4 border-current/30 border-t-current"
429
+ />
430
+ </>
431
+ ) : (
432
+ children
433
+ )}
434
+ </button>
226
435
  );
227
436
  if (!locked) return button;
228
437
  return (
@@ -254,12 +463,15 @@ export function Button({
254
463
  const ICON_BUTTON_BASE =
255
464
  "inline-flex items-center justify-center rounded-md transition-colors focus:outline-none focus:ring-2 disabled:opacity-50 disabled:cursor-not-allowed";
256
465
 
257
- export type IconButtonSize = "xl" | "lg" | "md" | "sm" | "xs" | "2xs";
466
+ export type IconButtonSize = "2xl" | "xl" | "lg" | "md" | "sm" | "xs" | "2xs";
258
467
 
259
468
  // Box and glyph together, so a 24px chip action cannot end up holding a 20px icon
260
469
  // that touches its edges. The two small steps are lenkbank's list-row (28px) and
261
470
  // chip (24px) actions, which it had hand-rolled beside the kit's 32/36px ones.
262
471
  const ICON_BUTTON_SIZES: Record<IconButtonSize, string> = {
472
+ // 64px with a 28px glyph: a camera's shutter (keksdose invoices/camera-capture.tsx:248,
473
+ // `size-16` by hand) — the one control of a full-screen capture view, thumb-sized.
474
+ "2xl": "size-16 [&_svg]:size-7",
263
475
  // 48px with a 24px glyph: the one primary action of a phone screen or sheet, a step
264
476
  // past the 44px minimum rather than on it.
265
477
  xl: "size-12 [&_svg]:size-6",
@@ -276,6 +488,7 @@ const ICON_BUTTON_SIZES: Record<IconButtonSize, string> = {
276
488
  // stated — `size-*` sets a height that `self-stretch` cannot override, so the box is
277
489
  // written as a width plus a minimum height (the square it would otherwise be).
278
490
  const ICON_BUTTON_STRETCH_SIZES: Record<IconButtonSize, string> = {
491
+ "2xl": "w-16 min-h-16 self-stretch [&_svg]:size-7",
279
492
  xl: "w-12 min-h-12 self-stretch [&_svg]:size-6",
280
493
  lg: "w-11 min-h-11 self-stretch [&_svg]:size-5",
281
494
  md: "w-9 min-h-9 self-stretch [&_svg]:size-5",
@@ -363,7 +576,7 @@ function iconButtonToneClass(tone: IconButtonTone, quiet: boolean | undefined):
363
576
  // and would out-rank a caller's plain `hover:` override), each variant pins its
364
577
  // RESTING look under `disabled:hover:`, which out-ranks any `hover:` by specificity
365
578
  // and only ever matches a disabled button.
366
- const ICON_BUTTON_DISABLED_REST: Record<ButtonVariant | "overlay", string> = {
579
+ const ICON_BUTTON_DISABLED_REST: Record<IconButtonVariant, string> = {
367
580
  primary: "disabled:hover:bg-[var(--bg-surface-2)]",
368
581
  secondary: "disabled:hover:bg-transparent",
369
582
  ghost: "disabled:hover:bg-transparent",
@@ -371,6 +584,7 @@ const ICON_BUTTON_DISABLED_REST: Record<ButtonVariant | "overlay", string> = {
371
584
  brand: "disabled:hover:bg-[var(--brand)]",
372
585
  link: "disabled:hover:bg-transparent disabled:hover:no-underline",
373
586
  overlay: "disabled:hover:bg-[color-mix(in_srgb,var(--bg-inverse)_60%,transparent)]",
587
+ shutter: "disabled:hover:bg-[var(--media-scrim)]",
374
588
  };
375
589
 
376
590
  // The quiet looks change the glyph on hover, so they pin their resting glyph the
@@ -393,15 +607,69 @@ const ICON_BUTTON_PRESSED =
393
607
  const ICON_BUTTON_OVERLAY =
394
608
  "rounded-full bg-[color-mix(in_srgb,var(--bg-inverse)_60%,transparent)] text-[var(--text-inverse)] backdrop-blur-sm hover:bg-[color-mix(in_srgb,var(--bg-inverse)_75%,transparent)] focus:ring-[var(--text-inverse)]";
395
609
 
610
+ // `variant="shutter"`: the camera's release — keksdose's camera-capture.tsx:248 draws
611
+ // it by hand as a 64px circle with a thick white ring round a translucent fill. It sits
612
+ // on a live camera picture, which is dark whatever the theme, so it uses the MEDIA
613
+ // tokens (tokens.css), white ink on a dark scrim in both themes — the inverse pair
614
+ // `overlay` uses would give a dark ring on a dark viewfinder in dark mode. The focus ring stands off by 2px, or it would
615
+ // merge into the ring that is part of the look.
616
+ const ICON_BUTTON_SHUTTER =
617
+ "rounded-full border-4 border-[var(--media-ink)] bg-[var(--media-scrim)] text-[var(--media-ink)] backdrop-blur-sm hover:bg-[var(--media-scrim-hover)] focus:ring-[var(--media-ink)] focus:ring-offset-2 focus:ring-offset-[var(--media-scrim-hover)]";
618
+
619
+ export type IconButtonVariant = ButtonVariant | "overlay" | "shutter";
620
+
621
+ /**
622
+ * The glyph's size in px, over the one the box size implies — see
623
+ * {@link IconButtonProps.glyphSize}.
624
+ */
625
+ export type IconButtonGlyphSize = 12 | 14 | 16 | 20 | 24 | 28;
626
+
627
+ const ICON_BUTTON_GLYPH_SIZES: Record<IconButtonGlyphSize, string> = {
628
+ 12: "[&_svg]:size-3",
629
+ 14: "[&_svg]:size-3.5",
630
+ 16: "[&_svg]:size-4",
631
+ 20: "[&_svg]:size-5",
632
+ 24: "[&_svg]:size-6",
633
+ 28: "[&_svg]:size-7",
634
+ };
635
+
396
636
  export interface IconButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
397
- /** Any {@link ButtonVariant}, or `overlay` — a round translucent disc for use over
398
- * an image (see `ICON_BUTTON_OVERLAY`). */
399
- variant?: ButtonVariant | "overlay";
400
- /** Box size: lg = 44px (a phone's touch target), md = 36px (matches the top bar),
637
+ /** Any {@link ButtonVariant}; `overlay` — a round translucent disc for use over
638
+ * an image (see `ICON_BUTTON_OVERLAY`); or `shutter` — a camera's release, a ringed
639
+ * disc over the viewfinder (see `ICON_BUTTON_SHUTTER`; pair it with `size="2xl"`). */
640
+ variant?: IconButtonVariant;
641
+ /** Box size: 2xl = 64px with a 28px icon (a camera shutter), xl = 48px with a 24px
642
+ * icon, lg = 44px (a phone's touch target), md = 36px (matches the top bar),
401
643
  * sm = 32px — all three with a 20px icon;
402
644
  * xs = 28px with a 16px icon (an action in a list row), 2xs = 24px with a 14px
403
- * icon (an action on a chip or a tab). */
645
+ * icon (an action on a chip or a tab). `glyphSize` overrides the icon. */
404
646
  size?: IconButtonSize;
647
+ /**
648
+ * The icon's size in px, when the box's own is not the one wanted. keksdose's sync
649
+ * chip (app/sync-status-indicator.tsx:142) is a 24px round chip round a 20px cloud —
650
+ * `size="2xs" glyphSize={20} shape="round"` — where `2xs` alone draws 14px. A number
651
+ * over a new size step because the box and the glyph are two independent choices; a
652
+ * step per combination (`"sm-lg"`) would have to be named for every one.
653
+ */
654
+ glyphSize?: IconButtonGlyphSize;
655
+ /**
656
+ * A small mark on the button's corner — the sync chip's 12px check over its cloud
657
+ * ("all good", feedback #67). Rendered OUTSIDE the `<button>`, in a wrapper beside
658
+ * it, so the box's `[&_svg]:size-*` rule that sizes the glyph does not blow the badge
659
+ * up to the glyph's size; a bare `<Check />` comes out 12px, on a
660
+ * `--bg-surface` disc that keeps it legible over the glyph. Decorative
661
+ * (`aria-hidden`): the name (`label` / `aria-label`) must already carry the state.
662
+ * With a badge, `className` still lands on the button; the wrapper is the flex item.
663
+ */
664
+ badge?: ReactNode;
665
+ /**
666
+ * `keep`: a disabled button keeps its full colour (no 50% drop) and an ordinary
667
+ * cursor. For a STATUS that is also an action — the sync chip shows "in sync" in
668
+ * green and refreshes on click, and is disabled while offline or syncing, when the
669
+ * state it shows matters most; dimmed it read as broken (keksdose wrote
670
+ * `disabled:cursor-default` by hand and left out the dimming). Default `dim`.
671
+ */
672
+ disabledStyle?: "dim" | "keep";
405
673
  /**
406
674
  * Fill the height of the flex row it stands in, keeping its width — the delete at
407
675
  * the end of keksdose's split line (transaction-fields:162), level with the labelled
@@ -546,6 +814,9 @@ export const IconButton = forwardRef<HTMLButtonElement, IconButtonProps>(functio
546
814
  tooltipSide,
547
815
  tooltipPortal,
548
816
  disabledReason,
817
+ glyphSize,
818
+ badge,
819
+ disabledStyle = "dim",
549
820
  className,
550
821
  style,
551
822
  onClick,
@@ -555,6 +826,7 @@ export const IconButton = forwardRef<HTMLButtonElement, IconButtonProps>(functio
555
826
  ref,
556
827
  ) {
557
828
  const tone = toneProp ?? (toneColor !== undefined ? "custom" : "default");
829
+ const keep = disabledStyle === "keep";
558
830
  const reasonId = useId();
559
831
  const locked = hasContent(disabledReason);
560
832
  const ownDescribedBy = rest["aria-describedby"];
@@ -586,18 +858,40 @@ export const IconButton = forwardRef<HTMLButtonElement, IconButtonProps>(functio
586
858
  className={cn(
587
859
  ICON_BUTTON_BASE,
588
860
  stretch ? ICON_BUTTON_STRETCH_SIZES[size] : ICON_BUTTON_SIZES[size],
861
+ glyphSize !== undefined && ICON_BUTTON_GLYPH_SIZES[glyphSize],
589
862
  // After the size, so the overlay's `rounded-full` beats the small sizes' `rounded`.
590
- variant === "overlay" ? ICON_BUTTON_OVERLAY : buttonVariantClasses[variant],
863
+ variant === "overlay"
864
+ ? ICON_BUTTON_OVERLAY
865
+ : variant === "shutter"
866
+ ? ICON_BUTTON_SHUTTER
867
+ : buttonVariantClasses[variant],
591
868
  shape === "round" && "rounded-full",
592
869
  ICON_BUTTON_DISABLED_REST[variant],
593
870
  iconButtonToneClass(tone, quiet),
594
871
  pressed && ICON_BUTTON_PRESSED,
595
872
  // The disabled look for the focusable kind of disabled, as on Button.
596
- locked && "cursor-not-allowed opacity-50",
873
+ locked && (keep ? "cursor-default" : "cursor-not-allowed opacity-50"),
874
+ keep && "disabled:cursor-default disabled:opacity-100",
597
875
  className,
598
876
  )}
599
877
  />
600
878
  );
879
+ // The badge beside the button, not in it — see `badge`. The wrapper is only a
880
+ // positioning box: the button inside it keeps its own focus ring and hit area.
881
+ const control = hasContent(badge) ? (
882
+ <span className={cn("relative inline-flex shrink-0", stretch && "self-stretch")}>
883
+ {button}
884
+ <span
885
+ aria-hidden
886
+ data-slot="icon-button-badge"
887
+ className="pointer-events-none absolute -end-0.5 -bottom-0.5 inline-flex rounded-full bg-[var(--bg-surface)] [&_svg]:size-3"
888
+ >
889
+ {badge}
890
+ </span>
891
+ </span>
892
+ ) : (
893
+ button
894
+ );
601
895
  if (locked) {
602
896
  return (
603
897
  // Button's shape: the button and the hidden reason in a FRAGMENT, so Tooltip does
@@ -609,7 +903,7 @@ export const IconButton = forwardRef<HTMLButtonElement, IconButtonProps>(functio
609
903
  className={stretch ? "self-stretch" : undefined}
610
904
  >
611
905
  <>
612
- {button}
906
+ {control}
613
907
  <span id={reasonId} hidden>
614
908
  {disabledReason}
615
909
  </span>
@@ -617,12 +911,12 @@ export const IconButton = forwardRef<HTMLButtonElement, IconButtonProps>(functio
617
911
  </Tooltip>
618
912
  );
619
913
  }
620
- if (!tooltip || label === undefined || label === "") return button;
914
+ if (!tooltip || label === undefined || label === "") return control;
621
915
  return (
622
916
  // A fragment, so Tooltip leaves the button's description alone: the bubble says
623
917
  // exactly what `aria-label` already does.
624
918
  <Tooltip label={label} side={tooltipSide} portal={tooltipPortal} className={stretch ? "self-stretch" : undefined}>
625
- <>{button}</>
919
+ <>{control}</>
626
920
  </Tooltip>
627
921
  );
628
922
  });
@@ -1503,17 +1797,82 @@ export interface CardProps extends ComponentPropsWithoutRef<"div"> {
1503
1797
  * those copies wanted the same one; a caller's `p-*` still wins. `flush` is ignored:
1504
1798
  * an inset panel never runs edge to edge.
1505
1799
  */
1506
- variant?: "default" | "inset";
1800
+ variant?: "default" | "inset" | "outline";
1801
+ /**
1802
+ * The card's own padding: `none`, `sm` (`p-3`) or `md` (`p-4`).
1803
+ *
1804
+ * `outline` is the bordered box with no shadow — a row or a block INSIDE a section
1805
+ * rather than a card on the page: kastlan's rent breakdown rows
1806
+ * (tenancy/components/breakdown-row.tsx, `rounded-lg border p-4`) and the meters
1807
+ * list's inline add row (meters/components/meter-add-row.tsx). It takes `md` by
1808
+ * default, as every one of those copies does; `sm` is the dense row.
1809
+ *
1810
+ * Left out, the default card keeps no padding (its CardHeader / CardContent own the
1811
+ * rhythm) and `inset` keeps its `p-3`; passing it sets theirs too. A caller's `p-*`
1812
+ * in `className` still wins.
1813
+ */
1814
+ padding?: "none" | "sm" | "md";
1815
+ /**
1816
+ * A card that is a WARNING (or other status) as a whole: the border in the tone's
1817
+ * `-border` colour and the {@link CardTitle} in the tone's text colour. kastlan's
1818
+ * dunning summary (invoice-detail-page.tsx:231-235) writes both by hand —
1819
+ * `border-[var(--warning-border)]` on the Card, `text-[var(--warning)]` on the title
1820
+ * — and the next status card would copy the pair (`outline` takes it the same way).
1821
+ * An `inset` panel has no border, so it takes the tone's quiet `-bg` fill instead.
1822
+ * `data-tone` is set for a caller's own
1823
+ * selectors.
1824
+ */
1825
+ tone?: CardTone;
1507
1826
  }
1508
1827
 
1509
- export function Card({ className, children, flush, variant = "default", ...rest }: CardProps) {
1828
+ export type CardTone = "warning" | "danger" | "info" | "success";
1829
+
1830
+ // The title is reached through its `data-slot`, so the tone needs no context and a
1831
+ // caller's `className` on CardTitle still wins (it is on the element itself).
1832
+ const CARD_TONES: Record<CardTone, { border: string; fill: string; title: string }> = {
1833
+ warning: {
1834
+ border: "border-[var(--warning-border)]",
1835
+ fill: "bg-[var(--warning-bg)]",
1836
+ title: "[&_[data-slot=card-title]]:text-[var(--warning)]",
1837
+ },
1838
+ danger: {
1839
+ border: "border-[var(--danger-border)]",
1840
+ fill: "bg-[var(--danger-bg)]",
1841
+ title: "[&_[data-slot=card-title]]:text-[var(--danger)]",
1842
+ },
1843
+ info: {
1844
+ border: "border-[var(--info-border)]",
1845
+ fill: "bg-[var(--info-bg)]",
1846
+ title: "[&_[data-slot=card-title]]:text-[var(--info)]",
1847
+ },
1848
+ success: {
1849
+ border: "border-[var(--success-border)]",
1850
+ fill: "bg-[var(--success-bg)]",
1851
+ title: "[&_[data-slot=card-title]]:text-[var(--success)]",
1852
+ },
1853
+ };
1854
+
1855
+ const CARD_PADDING: Record<NonNullable<CardProps["padding"]>, string> = {
1856
+ none: "p-0",
1857
+ sm: "p-3",
1858
+ md: "p-4",
1859
+ };
1860
+
1861
+ export function Card({ className, children, flush, variant = "default", tone, padding, ...rest }: CardProps) {
1862
+ const toned = tone ? CARD_TONES[tone] : undefined;
1863
+ // An outline card is bordered like the default one, so a tone recolours its border.
1864
+ const bordered = variant !== "inset";
1510
1865
  return (
1511
1866
  <div
1867
+ data-tone={tone}
1512
1868
  {...rest}
1513
1869
  className={cn(
1514
1870
  variant === "inset"
1515
1871
  ? "rounded-md bg-[var(--bg-surface-2)] p-3"
1516
- : cn(
1872
+ : variant === "outline"
1873
+ ? // No shadow and no `flush`: it sits on a surface that already has both.
1874
+ "rounded-lg border border-[var(--border)] bg-[var(--bg-surface)] p-4"
1875
+ : cn(
1517
1876
  // Surface + border are theme tokens so the palette switcher (feedback
1518
1877
  // #307) can re-skin every card; a caller's own bg-*/border-* override
1519
1878
  // still wins via tailwind-merge.
@@ -1522,6 +1881,9 @@ export function Card({ className, children, flush, variant = "default", ...rest
1522
1881
  ? "border-y border-[var(--border)] md:rounded-lg md:border md:shadow-sm"
1523
1882
  : "rounded-lg border border-[var(--border)] shadow-sm",
1524
1883
  ),
1884
+ toned && (bordered ? toned.border : toned.fill),
1885
+ toned?.title,
1886
+ padding && CARD_PADDING[padding],
1525
1887
  className,
1526
1888
  )}
1527
1889
  >
@@ -1623,6 +1985,19 @@ export interface SpinnerProps extends Omit<ComponentPropsWithoutRef<"span">, "ch
1623
1985
  * one both announce).
1624
1986
  */
1625
1987
  label?: string | null;
1988
+ /**
1989
+ * Show the label as TEXT beside the ring, not only to a screen reader. kastlan's
1990
+ * LoadingState (feedback/loading-state.tsx:14) builds exactly this by hand — a
1991
+ * `role="status"` wrapper, the spinner at `label={null}`, a `<p>` of text — so it
1992
+ * has to get the three-part aria arrangement right itself. Here the visible text IS
1993
+ * the live region's content (the label stays the accessible name, said once), and
1994
+ * the ring is decorative. `className` still sizes the ring; the other props go on
1995
+ * the wrapper. Ignored with `label={null}`: there are no words to show.
1996
+ */
1997
+ showLabel?: boolean;
1998
+ /** Where the shown label sits: `end` (default) — beside the ring, for a row or a
1999
+ * button; `below` — under it, centred, for a panel's loading state. */
2000
+ labelPosition?: "end" | "below";
1626
2001
  }
1627
2002
 
1628
2003
  /**
@@ -1640,7 +2015,7 @@ export interface SpinnerProps extends Omit<ComponentPropsWithoutRef<"span">, "ch
1640
2015
  * `label={null}`: announcing is right for a spinner standing alone and wrong for one
1641
2016
  * beside text, inside a button, or inside the app's own live region.
1642
2017
  */
1643
- export function Spinner({ className, label, ...rest }: SpinnerProps) {
2018
+ export function Spinner({ className, label, showLabel, labelPosition = "end", ...rest }: SpinnerProps) {
1644
2019
  const common = useKitLabels("common", DEFAULT_COMMON_LABELS, {
1645
2020
  loading: label ?? undefined,
1646
2021
  });
@@ -1649,6 +2024,21 @@ export function Spinner({ className, label, ...rest }: SpinnerProps) {
1649
2024
  className,
1650
2025
  );
1651
2026
  if (label === null) return <span aria-hidden {...rest} className={ring} />;
2027
+ if (showLabel) {
2028
+ return (
2029
+ <span
2030
+ role="status"
2031
+ {...rest}
2032
+ className={cn(
2033
+ "inline-flex items-center text-sm text-[var(--text-secondary)]",
2034
+ labelPosition === "below" ? "flex-col gap-2 text-center" : "gap-2",
2035
+ )}
2036
+ >
2037
+ <span aria-hidden className={ring} />
2038
+ <span>{common.loading}</span>
2039
+ </span>
2040
+ );
2041
+ }
1652
2042
  return (
1653
2043
  <span role="status" {...rest} className={ring}>
1654
2044
  {/* The trailing space is a separator for the case the caller forgot `label={null}`