@eifi1/ui-kit 0.48.1 → 0.49.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 (318) hide show
  1. package/README.md +28 -13
  2. package/dist/admin/admin-action-confirm.js +1 -1
  3. package/dist/admin/admin-action-confirm.js.map +1 -1
  4. package/dist/admin/invitations-panel.js +5 -1
  5. package/dist/admin/invitations-panel.js.map +1 -1
  6. package/dist/admin/user-roster.d.ts +11 -5
  7. package/dist/admin/user-roster.js +15 -4
  8. package/dist/admin/user-roster.js.map +1 -1
  9. package/dist/auth/accept-invitation.js +2 -2
  10. package/dist/auth/accept-invitation.js.map +1 -1
  11. package/dist/auth/not-found-page.js +1 -1
  12. package/dist/auth/not-found-page.js.map +1 -1
  13. package/dist/auth/reset-password-form.js +2 -2
  14. package/dist/auth/reset-password-form.js.map +1 -1
  15. package/dist/auth/verify-email.js +2 -2
  16. package/dist/auth/verify-email.js.map +1 -1
  17. package/dist/billing/plan-card.js +2 -2
  18. package/dist/billing/plan-card.js.map +1 -1
  19. package/dist/components/breadcrumbs.d.ts +19 -1
  20. package/dist/components/breadcrumbs.js +11 -4
  21. package/dist/components/breadcrumbs.js.map +1 -1
  22. package/dist/components/button-look.d.ts +37 -7
  23. package/dist/components/button-look.js +33 -6
  24. package/dist/components/button-look.js.map +1 -1
  25. package/dist/components/button.d.ts +4 -1
  26. package/dist/components/button.js +2 -2
  27. package/dist/components/button.js.map +1 -1
  28. package/dist/components/calendar-heatmap.js +2 -1
  29. package/dist/components/calendar-heatmap.js.map +1 -1
  30. package/dist/components/chart-edge.d.ts +10 -0
  31. package/dist/components/chart-edge.js +57 -0
  32. package/dist/components/chart-edge.js.map +1 -0
  33. package/dist/components/chart.js +2 -1
  34. package/dist/components/chart.js.map +1 -1
  35. package/dist/components/chip.d.ts +8 -1
  36. package/dist/components/chip.js +4 -1
  37. package/dist/components/chip.js.map +1 -1
  38. package/dist/components/confirm-context.d.ts +5 -0
  39. package/dist/components/confirm-context.js +7 -0
  40. package/dist/components/confirm-context.js.map +1 -0
  41. package/dist/components/confirm-dialog.js +3 -3
  42. package/dist/components/confirm-dialog.js.map +1 -1
  43. package/dist/components/danger-confirm.d.ts +2 -2
  44. package/dist/components/danger-confirm.js +1 -1
  45. package/dist/components/danger-confirm.js.map +1 -1
  46. package/dist/components/data-table-fit.d.ts +8 -1
  47. package/dist/components/data-table-fit.js +28 -4
  48. package/dist/components/data-table-fit.js.map +1 -1
  49. package/dist/components/data-table-groups.d.ts +3 -1
  50. package/dist/components/data-table-groups.js +11 -5
  51. package/dist/components/data-table-groups.js.map +1 -1
  52. package/dist/components/data-table-head.d.ts +8 -1
  53. package/dist/components/data-table-head.js +11 -3
  54. package/dist/components/data-table-head.js.map +1 -1
  55. package/dist/components/data-table-leading-row.d.ts +3 -1
  56. package/dist/components/data-table-leading-row.js +2 -1
  57. package/dist/components/data-table-leading-row.js.map +1 -1
  58. package/dist/components/data-table-mobile.d.ts +48 -2
  59. package/dist/components/data-table-mobile.js +87 -10
  60. package/dist/components/data-table-mobile.js.map +1 -1
  61. package/dist/components/data-table-row-actions.d.ts +2 -1
  62. package/dist/components/data-table-row-actions.js +9 -2
  63. package/dist/components/data-table-row-actions.js.map +1 -1
  64. package/dist/components/data-table-row.d.ts +84 -0
  65. package/dist/components/data-table-row.js +261 -0
  66. package/dist/components/data-table-row.js.map +1 -0
  67. package/dist/components/data-table-selection.d.ts +3 -0
  68. package/dist/components/data-table-selection.js +12 -1
  69. package/dist/components/data-table-selection.js.map +1 -1
  70. package/dist/components/data-table-virtual.d.ts +98 -0
  71. package/dist/components/data-table-virtual.js +369 -0
  72. package/dist/components/data-table-virtual.js.map +1 -0
  73. package/dist/components/data-table.d.ts +74 -2
  74. package/dist/components/data-table.js +241 -221
  75. package/dist/components/data-table.js.map +1 -1
  76. package/dist/components/error-boundary.js +2 -2
  77. package/dist/components/error-boundary.js.map +1 -1
  78. package/dist/components/floating-panel.d.ts +36 -13
  79. package/dist/components/floating-panel.js +27 -7
  80. package/dist/components/floating-panel.js.map +1 -1
  81. package/dist/components/form-actions.d.ts +1 -1
  82. package/dist/components/form-actions.js +1 -1
  83. package/dist/components/form-actions.js.map +1 -1
  84. package/dist/components/icon-button.d.ts +11 -0
  85. package/dist/components/icon-button.js +7 -5
  86. package/dist/components/icon-button.js.map +1 -1
  87. package/dist/components/nav-pills.d.ts +8 -1
  88. package/dist/components/nav-pills.js +61 -38
  89. package/dist/components/nav-pills.js.map +1 -1
  90. package/dist/components/page-header-context.d.ts +8 -0
  91. package/dist/components/page-header-context.js +7 -0
  92. package/dist/components/page-header-context.js.map +1 -0
  93. package/dist/components/page-header.d.ts +14 -2
  94. package/dist/components/page-header.js +10 -1
  95. package/dist/components/page-header.js.map +1 -1
  96. package/dist/components/pie-chart.js +2 -1
  97. package/dist/components/pie-chart.js.map +1 -1
  98. package/dist/components/progress-bar.js +5 -4
  99. package/dist/components/progress-bar.js.map +1 -1
  100. package/dist/components/reauth-dialog.js +1 -1
  101. package/dist/components/reauth-dialog.js.map +1 -1
  102. package/dist/components/row-actions-labels.js +3 -1
  103. package/dist/components/row-actions-labels.js.map +1 -1
  104. package/dist/components/row-actions.d.ts +43 -9
  105. package/dist/components/row-actions.js +78 -15
  106. package/dist/components/row-actions.js.map +1 -1
  107. package/dist/components/section-nav.d.ts +37 -0
  108. package/dist/components/section-nav.js +33 -0
  109. package/dist/components/section-nav.js.map +1 -0
  110. package/dist/components/series-chart.js +8 -3
  111. package/dist/components/series-chart.js.map +1 -1
  112. package/dist/components/share-card.js +1 -1
  113. package/dist/components/share-card.js.map +1 -1
  114. package/dist/components/signature-pad.js +1 -1
  115. package/dist/components/signature-pad.js.map +1 -1
  116. package/dist/components/sparkline.js +2 -1
  117. package/dist/components/sparkline.js.map +1 -1
  118. package/dist/components/stat-tile.js +14 -7
  119. package/dist/components/stat-tile.js.map +1 -1
  120. package/dist/components/status-dot.d.ts +5 -1
  121. package/dist/components/status-dot.js +4 -1
  122. package/dist/components/status-dot.js.map +1 -1
  123. package/dist/components/tooltip.js +1 -1
  124. package/dist/components/tooltip.js.map +1 -1
  125. package/dist/components/translation-review-editor.js +2 -2
  126. package/dist/components/translation-review-editor.js.map +1 -1
  127. package/dist/components/translation-review.js +2 -2
  128. package/dist/components/translation-review.js.map +1 -1
  129. package/dist/components/treemap.d.ts +5 -2
  130. package/dist/components/treemap.js +26 -6
  131. package/dist/components/treemap.js.map +1 -1
  132. package/dist/components/use-table-state.js +37 -6
  133. package/dist/components/use-table-state.js.map +1 -1
  134. package/dist/data-table.d.ts +1 -0
  135. package/dist/data-table.js.map +1 -1
  136. package/dist/demo/demo-banner.js +1 -1
  137. package/dist/demo/demo-banner.js.map +1 -1
  138. package/dist/demo/demo-ended.js +1 -1
  139. package/dist/demo/demo-ended.js.map +1 -1
  140. package/dist/demo/demo-start.js +1 -1
  141. package/dist/demo/demo-start.js.map +1 -1
  142. package/dist/eslint/index.js +1 -1
  143. package/dist/feedback/feedback-inbox.js +1 -1
  144. package/dist/feedback/feedback-inbox.js.map +1 -1
  145. package/dist/hooks/use-anchored-panel.js +2 -1
  146. package/dist/hooks/use-anchored-panel.js.map +1 -1
  147. package/dist/hooks/use-dismiss.d.ts +9 -1
  148. package/dist/hooks/use-dismiss.js +25 -5
  149. package/dist/hooks/use-dismiss.js.map +1 -1
  150. package/dist/hooks/use-document-title.d.ts +14 -0
  151. package/dist/hooks/use-document-title.js +22 -0
  152. package/dist/hooks/use-document-title.js.map +1 -0
  153. package/dist/i18n/german.js +6 -2
  154. package/dist/i18n/german.js.map +1 -1
  155. package/dist/i18n/kit-labels.d.ts +31 -1
  156. package/dist/i18n/kit-labels.js +43 -2
  157. package/dist/i18n/kit-labels.js.map +1 -1
  158. package/dist/i18n/locales/en.js +6 -2
  159. package/dist/i18n/locales/en.js.map +2 -2
  160. package/dist/i18n/locales/es.js +6 -2
  161. package/dist/i18n/locales/es.js.map +1 -1
  162. package/dist/i18n/locales/fr.js +6 -2
  163. package/dist/i18n/locales/fr.js.map +1 -1
  164. package/dist/i18n/locales/hu.js +6 -2
  165. package/dist/i18n/locales/hu.js.map +1 -1
  166. package/dist/i18n/locales/it.js +6 -2
  167. package/dist/i18n/locales/it.js.map +1 -1
  168. package/dist/i18n/locales/zh.js +6 -2
  169. package/dist/i18n/locales/zh.js.map +1 -1
  170. package/dist/i18n/review.js +2 -0
  171. package/dist/i18n/review.js.map +1 -1
  172. package/dist/i18n/shared-labels.js +1 -0
  173. package/dist/i18n/shared-labels.js.map +1 -1
  174. package/dist/index.d.ts +8 -1
  175. package/dist/index.js +11 -0
  176. package/dist/index.js.map +1 -1
  177. package/dist/landing/landing-actions.js +2 -2
  178. package/dist/landing/landing-actions.js.map +1 -1
  179. package/dist/landing/public-header.js +2 -2
  180. package/dist/landing/public-header.js.map +1 -1
  181. package/dist/lib/clipboard-minus.d.ts +27 -0
  182. package/dist/lib/clipboard-minus.js +63 -0
  183. package/dist/lib/clipboard-minus.js.map +1 -0
  184. package/dist/lib/document-title.d.ts +17 -0
  185. package/dist/lib/document-title.js +29 -0
  186. package/dist/lib/document-title.js.map +1 -0
  187. package/dist/lib/float-clearance.js +8 -2
  188. package/dist/lib/float-clearance.js.map +1 -1
  189. package/dist/lib/format.d.ts +35 -10
  190. package/dist/lib/format.js +55 -11
  191. package/dist/lib/format.js.map +1 -1
  192. package/dist/lib/minus.d.ts +22 -0
  193. package/dist/lib/minus.js +14 -0
  194. package/dist/lib/minus.js.map +1 -0
  195. package/dist/lib/status-tone.d.ts +30 -0
  196. package/dist/lib/status-tone.js +17 -0
  197. package/dist/lib/status-tone.js.map +1 -0
  198. package/dist/settings/settings-layout.js +3 -2
  199. package/dist/settings/settings-layout.js.map +1 -1
  200. package/dist/shell/account-menu-language.d.ts +1 -1
  201. package/dist/shell/account-menu-language.js +9 -1
  202. package/dist/shell/account-menu-language.js.map +1 -1
  203. package/dist/shell/app-shell-labels.js +2 -1
  204. package/dist/shell/app-shell-labels.js.map +1 -1
  205. package/dist/shell/app-shell.d.ts +58 -16
  206. package/dist/shell/app-shell.js +158 -44
  207. package/dist/shell/app-shell.js.map +1 -1
  208. package/dist/shell/language-code.d.ts +3 -0
  209. package/dist/shell/language-code.js +8 -0
  210. package/dist/shell/language-code.js.map +1 -0
  211. package/dist/shell/topbar-controls.d.ts +14 -6
  212. package/dist/shell/topbar-controls.js +14 -17
  213. package/dist/shell/topbar-controls.js.map +1 -1
  214. package/dist/testing/css-variables.generated.d.ts +1 -1
  215. package/dist/testing/css-variables.generated.js +1 -1
  216. package/dist/testing/css-variables.generated.js.map +1 -1
  217. package/dist/theme/chart-palette.d.ts +82 -15
  218. package/dist/theme/chart-palette.js +51 -24
  219. package/dist/theme/chart-palette.js.map +1 -1
  220. package/dist/theme/palette-presets.d.ts +9 -0
  221. package/dist/theme/palette-presets.js +77 -62
  222. package/dist/theme/palette-presets.js.map +1 -1
  223. package/dist/tour/tour.js +1 -1
  224. package/dist/tour/tour.js.map +1 -1
  225. package/dist/wizard/stepper-nav.d.ts +1 -1
  226. package/dist/wizard/stepper-nav.js +3 -3
  227. package/dist/wizard/stepper-nav.js.map +1 -1
  228. package/package.json +1 -1
  229. package/src/admin/admin-action-confirm.tsx +1 -1
  230. package/src/admin/invitations-panel.tsx +7 -3
  231. package/src/admin/user-roster.tsx +23 -6
  232. package/src/auth/accept-invitation.tsx +3 -3
  233. package/src/auth/not-found-page.tsx +1 -1
  234. package/src/auth/reset-password-form.tsx +3 -3
  235. package/src/auth/verify-email.tsx +3 -3
  236. package/src/billing/plan-card.tsx +2 -2
  237. package/src/components/breadcrumbs.tsx +32 -4
  238. package/src/components/button-look.ts +86 -15
  239. package/src/components/button.tsx +6 -3
  240. package/src/components/calendar-heatmap.tsx +2 -1
  241. package/src/components/chart-edge.ts +73 -0
  242. package/src/components/chart.tsx +3 -1
  243. package/src/components/chip.tsx +10 -1
  244. package/src/components/confirm-context.ts +7 -0
  245. package/src/components/confirm-dialog.tsx +3 -3
  246. package/src/components/danger-confirm.tsx +3 -3
  247. package/src/components/data-table-fit.ts +58 -8
  248. package/src/components/data-table-groups.tsx +15 -4
  249. package/src/components/data-table-head.tsx +25 -2
  250. package/src/components/data-table-leading-row.tsx +4 -1
  251. package/src/components/data-table-mobile.tsx +162 -11
  252. package/src/components/data-table-row-actions.tsx +12 -3
  253. package/src/components/data-table-row.tsx +485 -0
  254. package/src/components/data-table-selection.ts +17 -1
  255. package/src/components/data-table-virtual.ts +547 -0
  256. package/src/components/data-table.tsx +367 -326
  257. package/src/components/error-boundary.tsx +2 -2
  258. package/src/components/floating-panel.tsx +67 -16
  259. package/src/components/form-actions.tsx +2 -2
  260. package/src/components/icon-button.tsx +21 -8
  261. package/src/components/nav-pills.tsx +29 -2
  262. package/src/components/page-header-context.ts +8 -0
  263. package/src/components/page-header.tsx +34 -2
  264. package/src/components/pie-chart.tsx +2 -1
  265. package/src/components/progress-bar.tsx +6 -3
  266. package/src/components/reauth-dialog.tsx +1 -1
  267. package/src/components/row-actions-labels.ts +2 -0
  268. package/src/components/row-actions.tsx +154 -33
  269. package/src/components/section-nav.tsx +65 -0
  270. package/src/components/series-chart.tsx +12 -3
  271. package/src/components/share-card.tsx +1 -1
  272. package/src/components/signature-pad.tsx +1 -1
  273. package/src/components/sparkline.tsx +2 -1
  274. package/src/components/stat-tile.tsx +19 -7
  275. package/src/components/status-dot.tsx +7 -1
  276. package/src/components/tooltip.tsx +3 -1
  277. package/src/components/translation-review-editor.tsx +2 -2
  278. package/src/components/translation-review.tsx +2 -2
  279. package/src/components/treemap.tsx +34 -8
  280. package/src/components/use-table-state.ts +63 -8
  281. package/src/data-table.ts +5 -3
  282. package/src/demo/demo-banner.tsx +1 -1
  283. package/src/demo/demo-ended.tsx +1 -1
  284. package/src/demo/demo-start.tsx +1 -1
  285. package/src/feedback/feedback-inbox.tsx +1 -1
  286. package/src/hooks/use-anchored-panel.ts +9 -1
  287. package/src/hooks/use-dismiss.ts +43 -4
  288. package/src/hooks/use-document-title.ts +31 -0
  289. package/src/i18n/german.ts +4 -0
  290. package/src/i18n/kit-labels.tsx +76 -1
  291. package/src/i18n/locales/es.ts +4 -0
  292. package/src/i18n/locales/fr.ts +4 -0
  293. package/src/i18n/locales/hu.ts +4 -0
  294. package/src/i18n/locales/it.ts +4 -0
  295. package/src/i18n/locales/zh.ts +4 -0
  296. package/src/i18n/review.ts +2 -0
  297. package/src/i18n/shared-labels.ts +1 -0
  298. package/src/index.ts +13 -0
  299. package/src/landing/landing-actions.tsx +2 -2
  300. package/src/landing/public-header.tsx +2 -2
  301. package/src/lib/clipboard-minus.ts +94 -0
  302. package/src/lib/document-title.ts +41 -0
  303. package/src/lib/float-clearance.ts +19 -3
  304. package/src/lib/format.ts +115 -30
  305. package/src/lib/minus.ts +40 -0
  306. package/src/lib/status-tone.ts +43 -0
  307. package/src/settings/settings-layout.tsx +3 -2
  308. package/src/shell/account-menu-language.tsx +14 -2
  309. package/src/shell/app-shell-labels.ts +1 -0
  310. package/src/shell/app-shell.tsx +298 -69
  311. package/src/shell/language-code.ts +5 -0
  312. package/src/shell/topbar-controls.tsx +35 -15
  313. package/src/testing/css-variables.generated.ts +6 -6
  314. package/src/theme/chart-palette.ts +102 -20
  315. package/src/theme/palette-presets.ts +121 -84
  316. package/src/tour/tour.tsx +1 -1
  317. package/src/wizard/stepper-nav.tsx +4 -4
  318. package/tokens.css +28 -18
@@ -1 +1 @@
1
- {"version":3,"sources":["../../src/billing/plan-card.tsx"],"sourcesContent":["import { useId, useState } from \"react\";\nimport type { ReactNode } from \"react\";\nimport { Check, Gauge } from \"lucide-react\";\n\nimport { DEFAULT_COMMON_LABELS, useKitLabels, useKitLocale } from \"../i18n/kit-labels\";\nimport type { LabelOverride } from \"../i18n/kit-labels\";\nimport { formatNumber } from \"../lib/format\";\nimport { cn } from \"../lib/cn\";\nimport { ChoiceCard } from \"../components/choice-card\";\nimport type { ChoiceCardProps } from \"../components/choice-card\";\nimport { Chip } from \"../components/chip\";\nimport { ToggleGroup } from \"../components/toggle-group\";\nimport { Button } from \"../components/button\";\nimport { DialogFrame } from \"../components/dialog-frame\";\nimport { useBillingLabels } from \"./billing-labels\";\nimport type { BillingLabels } from \"./billing-labels\";\nimport { BILLING_INTERVALS, billingCurrencyFor, formatPlanPrice, planPrice } from \"./plan-price\";\nimport type { BillingCurrency, BillingInterval, PlanPrices } from \"./plan-price\";\n\n/**\n * One plan of the app's catalogue (docs/billing-harmonization.md §3.1) as a page shows it:\n * server-kit's `PlanSpec` from `GET /billing/plans`, plus the words, which are the app's\n * (display names and feature lines come from its i18n, never from the provider).\n */\nexport interface BillingPlan {\n /** The plan code, lowercase on the wire (§12.16); compared without case here. */\n code: string;\n /** The app's display name, in the reader's language: \"Pro\". */\n name: string;\n /** A line under the price. */\n description?: ReactNode;\n /** Gross prices in minor units, per currency and period (§3.1, §12.17). None at all —\n * or 0 — is a free plan. */\n prices?: PlanPrices | null;\n /** Dimension → limit; `null` is unlimited (§3.1). Shown in the object's order. */\n limits?: Readonly<Record<string, number | null>>;\n /** The app's feature lines. */\n features?: readonly ReactNode[];\n /** Not choosable — a plan being retired, say. */\n disabled?: boolean;\n}\n\n/**\n * How a plan stands to the one the payer pays for now: `current` is it, `upgrade` comes\n * after it in the catalogue's order (`PlanSpec.sort`), `downgrade` before it.\n */\nexport type PlanRelation = \"current\" | \"upgrade\" | \"downgrade\";\n\n/** The plan's place against `current`, by the order of `plans`; `undefined` without a\n * current plan (a trial, an ended plan) or for a code that is not in the list. */\nexport function planRelation(\n plans: readonly Pick<BillingPlan, \"code\">[],\n code: string,\n current: string | null | undefined,\n): PlanRelation | undefined {\n if (!current) return undefined;\n const index = (c: string) => plans.findIndex((p) => p.code.toLowerCase() === c.toLowerCase());\n const at = index(code);\n const now = index(current);\n if (at < 0 || now < 0) return undefined;\n return at === now ? \"current\" : at > now ? \"upgrade\" : \"downgrade\";\n}\n\n/** What both parts need to draw a plan. */\ninterface PlanDisplayProps {\n /** The currency the prices are shown in (§2.6). */\n currency: BillingCurrency;\n /** The period the prices are shown for. */\n interval: BillingInterval;\n /** The app's name of each limit's dimension (\"Budgets\"). A dimension without one is\n * shown by its key. */\n dimensionLabels?: Readonly<Record<string, string>>;\n /** How a limit's figure is written — kastlan's storage is bytes (`formatFileSize`).\n * Default: a number in the kit's locale. */\n formatLimit?: (dimension: string, value: number) => string;\n /** Over the provider's locale. */\n locale?: string;\n /** Prop > `<UiKitProvider labels={{ billing }}>` > English. */\n labels?: LabelOverride<BillingLabels>;\n}\n\nexport interface PlanCardProps\n extends Omit<ChoiceCardProps, \"type\" | \"title\" | \"description\" | \"icon\" | \"indeterminate\" | \"value\" | \"labels\">,\n PlanDisplayProps {\n plan: BillingPlan;\n /** See {@link PlanRelation}. `current` draws the \"Current plan\" chip — a word in the\n * card's name, never a colour alone. */\n relation?: PlanRelation;\n /**\n * The provider's own localised price in place of the catalogue's figure (§12.17) — a\n * Merchant of Record's price preview for the buyer's country, tax included. The period\n * and \"VAT included\" stay: the law asks for a gross price, and the card says so.\n */\n pricePreview?: ReactNode;\n}\n\n/** A list from spans (`role=\"list\"`): the card is a `<label>`, which may hold phrasing\n * content only, so a `<ul>` there would be invalid HTML. */\nfunction SpanList({ name, items, icon }: { name: string; items: ReactNode[]; icon: ReactNode }) {\n if (items.length === 0) return null;\n return (\n <span role=\"list\" aria-label={name} className=\"block space-y-1\">\n {items.map((item, i) => (\n <span role=\"listitem\" key={i} className=\"flex items-start gap-1.5 text-secondary\">\n <span aria-hidden className=\"mt-[0.0625rem] flex shrink-0 text-muted [&_svg]:size-3.5\">\n {icon}\n </span>\n <span className=\"min-w-0\">\n {item}\n {/* A space between the items' texts in the flattened description. */}{\" \"}\n </span>\n </span>\n ))}\n </span>\n );\n}\n\n/**\n * One plan as a {@link ChoiceCard} radio (§7): the name (and \"Current plan\"), the GROSS\n * price in the chosen currency and period through `formatMoney` (\"CHF 49 per year, VAT\n * included\", §12.17), the limits (\"Budgets: 3\", \"Seats: Unlimited\") and the app's feature\n * lines. A plan with prices but none for this currency and period is shown, \"Not offered\n * for this billing period\", and cannot be picked.\n *\n * A radio of a set: give the cards one `name`, or use {@link PlanPicker}, which does that\n * and owns the choice. Everything else a `ChoiceCard` takes goes to it — `checked`,\n * `onCheckedChange`, `name`, `className`. Carries `data-plan` and `data-relation`.\n */\nexport function PlanCard({\n // The radio `<input>` (ChoiceCard's): a React 19 ref prop since 0.34 (MOD-4, refactor\n // audit 2026-10), where this was `forwardRef`.\n ref,\n plan,\n relation,\n pricePreview,\n currency,\n interval,\n dimensionLabels,\n formatLimit,\n locale: localeProp,\n labels: labelsProp,\n disabled,\n ...rest\n}: PlanCardProps) {\n const labels = useBillingLabels(labelsProp);\n const common = useKitLabels(\"common\", DEFAULT_COMMON_LABELS);\n const locale = useKitLocale(localeProp);\n const price = planPrice(plan.prices, currency, interval);\n\n const priced = price.kind === \"price\";\n const figure = priced\n ? pricePreview !== undefined && pricePreview !== null\n ? pricePreview\n : formatPlanPrice(price.minor, currency, { locale })\n : price.kind === \"free\"\n ? labels.free\n : labels.notOffered;\n\n const limitLines = Object.entries(plan.limits ?? {}).map(([dimension, value]) => {\n const name = dimensionLabels?.[dimension] ?? dimension;\n const shown =\n value === null ? labels.unlimited : formatLimit ? formatLimit(dimension, value) : formatNumber(value, { locale });\n return common.fieldValue(name, shown);\n });\n\n const body = (\n <span className=\"mt-1 block space-y-2\">\n <span className=\"block\">\n <span\n data-part=\"price\"\n className={cn(\n \"tabular-nums\",\n price.kind === \"not-offered\"\n ? \"text-xs text-muted\"\n : \"text-lg font-semibold leading-tight text-primary\",\n )}\n >\n {figure}\n </span>\n {priced && (\n <>\n {\" \"}\n <span className=\"text-xs text-secondary\">\n {interval === \"year\" ? labels.perYear : labels.perMonth}\n </span>{\" \"}\n <span className=\"block text-xs text-muted\">{labels.vatIncluded}</span>\n </>\n )}{\" \"}\n </span>\n {plan.description !== undefined && plan.description !== null && (\n <span className=\"block text-secondary\">{plan.description} </span>\n )}\n <SpanList name={labels.limits} items={limitLines} icon={<Gauge />} />\n <SpanList name={labels.features} items={[...(plan.features ?? [])]} icon={<Check />} />\n </span>\n );\n\n return (\n <ChoiceCard\n ref={ref}\n {...rest}\n type=\"radio\"\n value={plan.code}\n disabled={disabled || plan.disabled || price.kind === \"not-offered\"}\n data-plan={plan.code}\n data-relation={relation}\n title={\n <>\n {plan.name}\n {relation === \"current\" && (\n <>\n {\" \"}\n <Chip size=\"xs\" tone=\"brand\" shape=\"square\" caps className=\"ms-1 align-[0.0625rem]\">\n {labels.current}\n </Chip>\n </>\n )}\n </>\n }\n description={body}\n />\n );\n}\n\n/** What {@link PlanPickerProps.onChoose} is handed: everything `POST /billing/checkout`\n * takes (§4). */\nexport interface PlanChoice {\n plan: string;\n interval: BillingInterval;\n currency: BillingCurrency;\n}\n\nexport interface PlanPickerProps extends Omit<PlanDisplayProps, \"currency\" | \"interval\"> {\n /** The catalogue, in its order (`PlanSpec.sort`): cheapest first. The order decides\n * upgrade and downgrade. */\n plans: readonly BillingPlan[];\n /**\n * The plan the payer pays for now (`plan` of the overview while `active` or\n * `past_due`). Leave it out during a trial and after a plan ended: then every plan is\n * \"Choose …\", the trial's plan included, since none is paid for yet.\n */\n current?: string | null;\n /** The picked plan, controlled. `null` while none is. */\n value?: string | null;\n /** The picked plan, uncontrolled. Default: `current`, else nothing. */\n defaultValue?: string | null;\n onValueChange?: (plan: string) => void;\n /** The period, controlled. */\n interval?: BillingInterval;\n /** The period, uncontrolled. Default `year` — lead with yearly (§12.17). */\n defaultInterval?: BillingInterval;\n onIntervalChange?: (interval: BillingInterval) => void;\n /** The currency, controlled. */\n currency?: BillingCurrency;\n /** The currency, uncontrolled. Default: the account's stored currency is the app's to\n * pass here (§12.18); without it, {@link billingCurrencyFor} the kit's locale. */\n defaultCurrency?: BillingCurrency;\n onCurrencyChange?: (currency: BillingCurrency) => void;\n /** The currencies the payer may switch between. Default: every currency a plan has a\n * price in. The switch shows only for more than one. */\n currencies?: readonly BillingCurrency[];\n /** The button: the app starts its checkout (`POST /billing/checkout`, then off to the\n * provider's page). The kit sends no request. */\n onChoose: (choice: PlanChoice) => void;\n /** The checkout is being opened: the button's spinner, no second press. */\n pending?: boolean;\n /**\n * 0.42 (§16.2 decision 5, §16.8 B): the payer's free grant runs without an end —\n * `isPermanentGrant(overview)`. A purchase under it changes nothing the payer gets\n * while the grant runs (§12.11), so the button first opens a confirm —\n * `grantWarningTitle`, `grantWarningBody`, \"Buy anyway\" (`grantWarningAction`) and\n * \"Back\" — and {@link onChoose} runs only on \"Buy anyway\". Warned, not refused.\n *\n * Not while {@link switching}: then the pick opens `PlanSwitchConfirm`, which takes\n * the same `permanentGrant` and shows the warning above its figures — one warning,\n * in the dialog where the payer agrees, rather than a confirm before a confirm.\n */\n permanentGrant?: boolean;\n /**\n * 0.42: the overview's `switching` — the app sends this pick to `PlanSwitchConfirm`\n * (§15.2.10), not the checkout. Only decides where {@link permanentGrant}'s warning\n * shows; the button and {@link onChoose} are the same either way.\n */\n switching?: boolean;\n /** The provider's localised price for a plan, or `undefined` for the catalogue's —\n * see {@link PlanCardProps.pricePreview}. */\n pricePreview?: (plan: BillingPlan, choice: Omit<PlanChoice, \"plan\">) => ReactNode | undefined;\n /** A visible question over the cards. Without one the set is named \"Plans\". */\n legend?: ReactNode;\n /** Shared by the radios. Generated if omitted. */\n name?: string;\n /** Classes for the whole picker. */\n className?: string;\n /** Classes for the card grid. Default: one column, two from `sm`, three from `lg`. */\n gridClassName?: string;\n /** Classes for every card. */\n cardClassName?: string;\n}\n\n/** Every currency some plan has a price in, CHF first. */\nfunction pricedCurrencies(plans: readonly BillingPlan[]): BillingCurrency[] {\n const found = new Set<BillingCurrency>();\n for (const p of plans) for (const c of Object.keys(p.prices ?? {})) found.add(c as BillingCurrency);\n return ([\"CHF\", \"EUR\"] as const).filter((c) => found.has(c));\n}\n\n/** Every period some plan has a price for, yearly first. */\nfunction pricedIntervals(plans: readonly BillingPlan[]): BillingInterval[] {\n const found = new Set<BillingInterval>();\n for (const p of plans)\n for (const byInterval of Object.values(p.prices ?? {}))\n for (const [k, v] of Object.entries(byInterval ?? {})) if (typeof v === \"number\") found.add(k as BillingInterval);\n return BILLING_INTERVALS.filter((i) => found.has(i));\n}\n\n/**\n * The plans to choose from (§7): the period switch — yearly first (§12.17) — and, where\n * the payer may pay in either, the currency switch (§2.6); a {@link PlanCard} per plan in\n * a radio set; and one button that says what pressing it does: \"Upgrade to Pro\", \"Switch\n * to Free\", or \"Choose Pro\" with no plan paid for yet.\n *\n * One button under the set rather than one per card: each card IS a radio — a `<label>`\n * round an `<input>` — and a button inside a label is interactive content inside\n * interactive content. Picking and committing are two steps anyway: the price under the\n * picked card is what checkout will charge.\n *\n * The button never takes the write lock: billing itself always works, a read-only payer\n * must be able to pay (§3.3). It is off, with the reason on hover and focus, while\n * nothing is picked or the current plan is.\n *\n * 0.42 (§16.8 B): under a grant without an end (`permanentGrant`) the payer is warned\n * before paying for a plan that won't apply — here, in a confirm before `onChoose`, on\n * the way to the checkout; on the way to a switch (`switching`) in `PlanSwitchConfirm`\n * instead, so the warning shows once on either path.\n */\nexport function PlanPicker({\n plans,\n current,\n value: valueProp,\n defaultValue,\n onValueChange,\n interval: intervalProp,\n defaultInterval = \"year\",\n onIntervalChange,\n currency: currencyProp,\n defaultCurrency,\n onCurrencyChange,\n currencies: currenciesProp,\n onChoose,\n pending,\n permanentGrant = false,\n switching = false,\n pricePreview,\n legend,\n name,\n className,\n gridClassName,\n cardClassName,\n dimensionLabels,\n formatLimit,\n locale: localeProp,\n labels: labelsProp,\n}: PlanPickerProps) {\n const labels = useBillingLabels(labelsProp);\n const locale = useKitLocale(localeProp);\n const generated = useId();\n /** The choice the grant warning holds back until \"Buy anyway\". */\n const [warned, setWarned] = useState<PlanChoice | null>(null);\n const groupName = name ?? generated;\n const legendId = `${generated}-legend`;\n\n const [ownValue, setOwnValue] = useState<string | null>(() => defaultValue ?? current ?? null);\n const [ownInterval, setOwnInterval] = useState<BillingInterval>(defaultInterval);\n const [ownCurrency, setOwnCurrency] = useState<BillingCurrency>(\n () => defaultCurrency ?? billingCurrencyFor(locale),\n );\n const value = valueProp !== undefined ? valueProp : ownValue;\n const interval = intervalProp ?? ownInterval;\n const currency = currencyProp ?? ownCurrency;\n\n const pick = (code: string) => {\n if (valueProp === undefined) setOwnValue(code);\n onValueChange?.(code);\n };\n const setInterval = (next: BillingInterval) => {\n if (intervalProp === undefined) setOwnInterval(next);\n onIntervalChange?.(next);\n };\n const setCurrency = (next: BillingCurrency) => {\n if (currencyProp === undefined) setOwnCurrency(next);\n onCurrencyChange?.(next);\n };\n\n const currencies = currenciesProp ?? pricedCurrencies(plans);\n const intervals = pricedIntervals(plans);\n const picked = value ? plans.find((p) => p.code.toLowerCase() === value.toLowerCase()) : undefined;\n const relation = picked ? planRelation(plans, picked.code, current) : undefined;\n const pickedPrice = picked ? planPrice(picked.prices, currency, interval) : undefined;\n\n const buttonText = !picked\n ? labels.choosePlan\n : relation === \"upgrade\"\n ? labels.upgrade(picked.name)\n : relation === \"downgrade\"\n ? labels.downgrade(picked.name)\n : labels.choose(picked.name);\n const blocked = !picked\n ? labels.pickFirst\n : relation === \"current\"\n ? labels.isCurrent\n : pickedPrice?.kind === \"not-offered\"\n ? labels.notOffered\n : undefined;\n\n return (\n <div className={cn(\"min-w-0 space-y-3\", className)} data-billing-interval={interval} data-billing-currency={currency}>\n {(intervals.length > 1 || currencies.length > 1) && (\n <div className=\"flex flex-wrap items-center gap-2\">\n {intervals.length > 1 && (\n <ToggleGroup\n aria-label={labels.interval}\n // Content-wide, so the two switches share a row and wrap only when narrow.\n className=\"w-auto\"\n options={intervals.map((i) => ({ value: i, label: i === \"year\" ? labels.yearly : labels.monthly }))}\n value={interval}\n onChange={setInterval}\n />\n )}\n {currencies.length > 1 && (\n <ToggleGroup\n aria-label={labels.currency}\n className=\"w-auto\"\n options={currencies.map((c) => ({ value: c, label: c }))}\n value={currency}\n onChange={setCurrency}\n />\n )}\n </div>\n )}\n <fieldset\n className=\"min-w-0\"\n aria-labelledby={legend !== undefined ? legendId : undefined}\n aria-label={legend === undefined ? labels.plans : undefined}\n >\n {legend !== undefined && (\n <legend id={legendId} className=\"mb-2 text-sm font-medium text-primary\">\n {legend}\n </legend>\n )}\n <div className={cn(\"grid grid-cols-1 gap-2 sm:grid-cols-2 lg:grid-cols-3\", gridClassName)}>\n {plans.map((plan) => (\n <PlanCard\n key={plan.code}\n plan={plan}\n name={groupName}\n currency={currency}\n interval={interval}\n relation={planRelation(plans, plan.code, current)}\n pricePreview={pricePreview?.(plan, { interval, currency })}\n checked={picked?.code === plan.code}\n onCheckedChange={(on) => on && pick(plan.code)}\n dimensionLabels={dimensionLabels}\n formatLimit={formatLimit}\n locale={locale}\n labels={labelsProp}\n className={cardClassName}\n />\n ))}\n </div>\n </fieldset>\n <div className=\"flex justify-end\">\n <Button\n type=\"button\"\n variant=\"primary\"\n className=\"max-sm:w-full\"\n disabledReason={blocked}\n pending={pending}\n onClick={() => {\n if (!picked) return;\n const choice = { plan: picked.code, interval, currency };\n if (permanentGrant && !switching) setWarned(choice);\n else onChoose(choice);\n }}\n >\n {buttonText}\n </Button>\n </div>\n {permanentGrant && !switching && (\n <GrantWarningConfirm\n open={warned !== null}\n onOpenChange={(open) => {\n if (!open) setWarned(null);\n }}\n onConfirm={() => {\n if (warned) onChoose(warned);\n }}\n labels={labelsProp}\n />\n )}\n </div>\n );\n}\n\nexport interface GrantWarningConfirmProps {\n /** Shown, controlled. */\n open: boolean;\n /** `false` on \"Back\", Escape, the backdrop — and after \"Buy anyway\". */\n onOpenChange: (open: boolean) => void;\n /** \"Buy anyway\": the app opens its checkout. The dialog closes itself after it. */\n onConfirm: () => void;\n /** Prop > `<UiKitProvider labels={{ billing }}>` > English: `grantWarningTitle`,\n * `grantWarningBody`, `grantWarningAction`. \"Back\" is `common.back`. */\n labels?: LabelOverride<BillingLabels>;\n}\n\n/**\n * 0.42 (§16.8 B): the permanent-grant warning before a checkout — `grantWarningTitle`,\n * `grantWarningBody`, \"Back\" and \"Buy anyway\" (`grantWarningAction`). {@link PlanPicker}\n * shows it itself under `permanentGrant` when not `switching`. Exported for the path the\n * picker cannot see: an app that meant to switch, learned there is no subscription to\n * switch (keksdose: `/billing/plan/preview` → 409 `billing_not_subscribed`) and falls back\n * to a checkout — show this before that checkout, so a permanent-grant holder is still\n * warned. Carries `data-grant-warning`.\n */\nexport function GrantWarningConfirm({ open, onOpenChange, onConfirm, labels: labelsProp }: GrantWarningConfirmProps) {\n const labels = useBillingLabels(labelsProp);\n const common = useKitLabels(\"common\", DEFAULT_COMMON_LABELS);\n return (\n <DialogFrame\n open={open}\n title={labels.grantWarningTitle}\n description={labels.grantWarningBody}\n onClose={() => onOpenChange(false)}\n data-grant-warning=\"\"\n actions={(close) => (\n <>\n <Button variant=\"secondary\" onClick={close}>\n {common.back}\n </Button>\n <Button\n variant=\"primary\"\n onClick={() => {\n onConfirm();\n onOpenChange(false);\n }}\n >\n {labels.grantWarningAction}\n </Button>\n </>\n )}\n />\n );\n}\n"],"mappings":"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","names":[]}
1
+ {"version":3,"sources":["../../src/billing/plan-card.tsx"],"sourcesContent":["import { useId, useState } from \"react\";\nimport type { ReactNode } from \"react\";\nimport { Check, Gauge } from \"lucide-react\";\n\nimport { DEFAULT_COMMON_LABELS, useKitLabels, useKitLocale } from \"../i18n/kit-labels\";\nimport type { LabelOverride } from \"../i18n/kit-labels\";\nimport { formatNumber } from \"../lib/format\";\nimport { cn } from \"../lib/cn\";\nimport { ChoiceCard } from \"../components/choice-card\";\nimport type { ChoiceCardProps } from \"../components/choice-card\";\nimport { Chip } from \"../components/chip\";\nimport { ToggleGroup } from \"../components/toggle-group\";\nimport { Button } from \"../components/button\";\nimport { DialogFrame } from \"../components/dialog-frame\";\nimport { useBillingLabels } from \"./billing-labels\";\nimport type { BillingLabels } from \"./billing-labels\";\nimport { BILLING_INTERVALS, billingCurrencyFor, formatPlanPrice, planPrice } from \"./plan-price\";\nimport type { BillingCurrency, BillingInterval, PlanPrices } from \"./plan-price\";\n\n/**\n * One plan of the app's catalogue (docs/billing-harmonization.md §3.1) as a page shows it:\n * server-kit's `PlanSpec` from `GET /billing/plans`, plus the words, which are the app's\n * (display names and feature lines come from its i18n, never from the provider).\n */\nexport interface BillingPlan {\n /** The plan code, lowercase on the wire (§12.16); compared without case here. */\n code: string;\n /** The app's display name, in the reader's language: \"Pro\". */\n name: string;\n /** A line under the price. */\n description?: ReactNode;\n /** Gross prices in minor units, per currency and period (§3.1, §12.17). None at all —\n * or 0 — is a free plan. */\n prices?: PlanPrices | null;\n /** Dimension → limit; `null` is unlimited (§3.1). Shown in the object's order. */\n limits?: Readonly<Record<string, number | null>>;\n /** The app's feature lines. */\n features?: readonly ReactNode[];\n /** Not choosable — a plan being retired, say. */\n disabled?: boolean;\n}\n\n/**\n * How a plan stands to the one the payer pays for now: `current` is it, `upgrade` comes\n * after it in the catalogue's order (`PlanSpec.sort`), `downgrade` before it.\n */\nexport type PlanRelation = \"current\" | \"upgrade\" | \"downgrade\";\n\n/** The plan's place against `current`, by the order of `plans`; `undefined` without a\n * current plan (a trial, an ended plan) or for a code that is not in the list. */\nexport function planRelation(\n plans: readonly Pick<BillingPlan, \"code\">[],\n code: string,\n current: string | null | undefined,\n): PlanRelation | undefined {\n if (!current) return undefined;\n const index = (c: string) => plans.findIndex((p) => p.code.toLowerCase() === c.toLowerCase());\n const at = index(code);\n const now = index(current);\n if (at < 0 || now < 0) return undefined;\n return at === now ? \"current\" : at > now ? \"upgrade\" : \"downgrade\";\n}\n\n/** What both parts need to draw a plan. */\ninterface PlanDisplayProps {\n /** The currency the prices are shown in (§2.6). */\n currency: BillingCurrency;\n /** The period the prices are shown for. */\n interval: BillingInterval;\n /** The app's name of each limit's dimension (\"Budgets\"). A dimension without one is\n * shown by its key. */\n dimensionLabels?: Readonly<Record<string, string>>;\n /** How a limit's figure is written — kastlan's storage is bytes (`formatFileSize`).\n * Default: a number in the kit's locale. */\n formatLimit?: (dimension: string, value: number) => string;\n /** Over the provider's locale. */\n locale?: string;\n /** Prop > `<UiKitProvider labels={{ billing }}>` > English. */\n labels?: LabelOverride<BillingLabels>;\n}\n\nexport interface PlanCardProps\n extends Omit<ChoiceCardProps, \"type\" | \"title\" | \"description\" | \"icon\" | \"indeterminate\" | \"value\" | \"labels\">,\n PlanDisplayProps {\n plan: BillingPlan;\n /** See {@link PlanRelation}. `current` draws the \"Current plan\" chip — a word in the\n * card's name, never a colour alone. */\n relation?: PlanRelation;\n /**\n * The provider's own localised price in place of the catalogue's figure (§12.17) — a\n * Merchant of Record's price preview for the buyer's country, tax included. The period\n * and \"VAT included\" stay: the law asks for a gross price, and the card says so.\n */\n pricePreview?: ReactNode;\n}\n\n/** A list from spans (`role=\"list\"`): the card is a `<label>`, which may hold phrasing\n * content only, so a `<ul>` there would be invalid HTML. */\nfunction SpanList({ name, items, icon }: { name: string; items: ReactNode[]; icon: ReactNode }) {\n if (items.length === 0) return null;\n return (\n <span role=\"list\" aria-label={name} className=\"block space-y-1\">\n {items.map((item, i) => (\n <span role=\"listitem\" key={i} className=\"flex items-start gap-1.5 text-secondary\">\n <span aria-hidden className=\"mt-[0.0625rem] flex shrink-0 text-muted [&_svg]:size-3.5\">\n {icon}\n </span>\n <span className=\"min-w-0\">\n {item}\n {/* A space between the items' texts in the flattened description. */}{\" \"}\n </span>\n </span>\n ))}\n </span>\n );\n}\n\n/**\n * One plan as a {@link ChoiceCard} radio (§7): the name (and \"Current plan\"), the GROSS\n * price in the chosen currency and period through `formatMoney` (\"CHF 49 per year, VAT\n * included\", §12.17), the limits (\"Budgets: 3\", \"Seats: Unlimited\") and the app's feature\n * lines. A plan with prices but none for this currency and period is shown, \"Not offered\n * for this billing period\", and cannot be picked.\n *\n * A radio of a set: give the cards one `name`, or use {@link PlanPicker}, which does that\n * and owns the choice. Everything else a `ChoiceCard` takes goes to it — `checked`,\n * `onCheckedChange`, `name`, `className`. Carries `data-plan` and `data-relation`.\n */\nexport function PlanCard({\n // The radio `<input>` (ChoiceCard's): a React 19 ref prop since 0.34 (MOD-4, refactor\n // audit 2026-10), where this was `forwardRef`.\n ref,\n plan,\n relation,\n pricePreview,\n currency,\n interval,\n dimensionLabels,\n formatLimit,\n locale: localeProp,\n labels: labelsProp,\n disabled,\n ...rest\n}: PlanCardProps) {\n const labels = useBillingLabels(labelsProp);\n const common = useKitLabels(\"common\", DEFAULT_COMMON_LABELS);\n const locale = useKitLocale(localeProp);\n const price = planPrice(plan.prices, currency, interval);\n\n const priced = price.kind === \"price\";\n const figure = priced\n ? pricePreview !== undefined && pricePreview !== null\n ? pricePreview\n : formatPlanPrice(price.minor, currency, { locale })\n : price.kind === \"free\"\n ? labels.free\n : labels.notOffered;\n\n const limitLines = Object.entries(plan.limits ?? {}).map(([dimension, value]) => {\n const name = dimensionLabels?.[dimension] ?? dimension;\n const shown =\n value === null ? labels.unlimited : formatLimit ? formatLimit(dimension, value) : formatNumber(value, { locale });\n return common.fieldValue(name, shown);\n });\n\n const body = (\n <span className=\"mt-1 block space-y-2\">\n <span className=\"block\">\n <span\n data-part=\"price\"\n className={cn(\n \"tabular-nums\",\n price.kind === \"not-offered\"\n ? \"text-xs text-muted\"\n : \"text-lg font-semibold leading-tight text-primary\",\n )}\n >\n {figure}\n </span>\n {priced && (\n <>\n {\" \"}\n <span className=\"text-xs text-secondary\">\n {interval === \"year\" ? labels.perYear : labels.perMonth}\n </span>{\" \"}\n <span className=\"block text-xs text-muted\">{labels.vatIncluded}</span>\n </>\n )}{\" \"}\n </span>\n {plan.description !== undefined && plan.description !== null && (\n <span className=\"block text-secondary\">{plan.description} </span>\n )}\n <SpanList name={labels.limits} items={limitLines} icon={<Gauge />} />\n <SpanList name={labels.features} items={[...(plan.features ?? [])]} icon={<Check />} />\n </span>\n );\n\n return (\n <ChoiceCard\n ref={ref}\n {...rest}\n type=\"radio\"\n value={plan.code}\n disabled={disabled || plan.disabled || price.kind === \"not-offered\"}\n data-plan={plan.code}\n data-relation={relation}\n title={\n <>\n {plan.name}\n {relation === \"current\" && (\n <>\n {\" \"}\n <Chip size=\"xs\" tone=\"brand\" shape=\"square\" caps className=\"ms-1 align-[0.0625rem]\">\n {labels.current}\n </Chip>\n </>\n )}\n </>\n }\n description={body}\n />\n );\n}\n\n/** What {@link PlanPickerProps.onChoose} is handed: everything `POST /billing/checkout`\n * takes (§4). */\nexport interface PlanChoice {\n plan: string;\n interval: BillingInterval;\n currency: BillingCurrency;\n}\n\nexport interface PlanPickerProps extends Omit<PlanDisplayProps, \"currency\" | \"interval\"> {\n /** The catalogue, in its order (`PlanSpec.sort`): cheapest first. The order decides\n * upgrade and downgrade. */\n plans: readonly BillingPlan[];\n /**\n * The plan the payer pays for now (`plan` of the overview while `active` or\n * `past_due`). Leave it out during a trial and after a plan ended: then every plan is\n * \"Choose …\", the trial's plan included, since none is paid for yet.\n */\n current?: string | null;\n /** The picked plan, controlled. `null` while none is. */\n value?: string | null;\n /** The picked plan, uncontrolled. Default: `current`, else nothing. */\n defaultValue?: string | null;\n onValueChange?: (plan: string) => void;\n /** The period, controlled. */\n interval?: BillingInterval;\n /** The period, uncontrolled. Default `year` — lead with yearly (§12.17). */\n defaultInterval?: BillingInterval;\n onIntervalChange?: (interval: BillingInterval) => void;\n /** The currency, controlled. */\n currency?: BillingCurrency;\n /** The currency, uncontrolled. Default: the account's stored currency is the app's to\n * pass here (§12.18); without it, {@link billingCurrencyFor} the kit's locale. */\n defaultCurrency?: BillingCurrency;\n onCurrencyChange?: (currency: BillingCurrency) => void;\n /** The currencies the payer may switch between. Default: every currency a plan has a\n * price in. The switch shows only for more than one. */\n currencies?: readonly BillingCurrency[];\n /** The button: the app starts its checkout (`POST /billing/checkout`, then off to the\n * provider's page). The kit sends no request. */\n onChoose: (choice: PlanChoice) => void;\n /** The checkout is being opened: the button's spinner, no second press. */\n pending?: boolean;\n /**\n * 0.42 (§16.2 decision 5, §16.8 B): the payer's free grant runs without an end —\n * `isPermanentGrant(overview)`. A purchase under it changes nothing the payer gets\n * while the grant runs (§12.11), so the button first opens a confirm —\n * `grantWarningTitle`, `grantWarningBody`, \"Buy anyway\" (`grantWarningAction`) and\n * \"Back\" — and {@link onChoose} runs only on \"Buy anyway\". Warned, not refused.\n *\n * Not while {@link switching}: then the pick opens `PlanSwitchConfirm`, which takes\n * the same `permanentGrant` and shows the warning above its figures — one warning,\n * in the dialog where the payer agrees, rather than a confirm before a confirm.\n */\n permanentGrant?: boolean;\n /**\n * 0.42: the overview's `switching` — the app sends this pick to `PlanSwitchConfirm`\n * (§15.2.10), not the checkout. Only decides where {@link permanentGrant}'s warning\n * shows; the button and {@link onChoose} are the same either way.\n */\n switching?: boolean;\n /** The provider's localised price for a plan, or `undefined` for the catalogue's —\n * see {@link PlanCardProps.pricePreview}. */\n pricePreview?: (plan: BillingPlan, choice: Omit<PlanChoice, \"plan\">) => ReactNode | undefined;\n /** A visible question over the cards. Without one the set is named \"Plans\". */\n legend?: ReactNode;\n /** Shared by the radios. Generated if omitted. */\n name?: string;\n /** Classes for the whole picker. */\n className?: string;\n /** Classes for the card grid. Default: one column, two from `sm`, three from `lg`. */\n gridClassName?: string;\n /** Classes for every card. */\n cardClassName?: string;\n}\n\n/** Every currency some plan has a price in, CHF first. */\nfunction pricedCurrencies(plans: readonly BillingPlan[]): BillingCurrency[] {\n const found = new Set<BillingCurrency>();\n for (const p of plans) for (const c of Object.keys(p.prices ?? {})) found.add(c as BillingCurrency);\n return ([\"CHF\", \"EUR\"] as const).filter((c) => found.has(c));\n}\n\n/** Every period some plan has a price for, yearly first. */\nfunction pricedIntervals(plans: readonly BillingPlan[]): BillingInterval[] {\n const found = new Set<BillingInterval>();\n for (const p of plans)\n for (const byInterval of Object.values(p.prices ?? {}))\n for (const [k, v] of Object.entries(byInterval ?? {})) if (typeof v === \"number\") found.add(k as BillingInterval);\n return BILLING_INTERVALS.filter((i) => found.has(i));\n}\n\n/**\n * The plans to choose from (§7): the period switch — yearly first (§12.17) — and, where\n * the payer may pay in either, the currency switch (§2.6); a {@link PlanCard} per plan in\n * a radio set; and one button that says what pressing it does: \"Upgrade to Pro\", \"Switch\n * to Free\", or \"Choose Pro\" with no plan paid for yet.\n *\n * One button under the set rather than one per card: each card IS a radio — a `<label>`\n * round an `<input>` — and a button inside a label is interactive content inside\n * interactive content. Picking and committing are two steps anyway: the price under the\n * picked card is what checkout will charge.\n *\n * The button never takes the write lock: billing itself always works, a read-only payer\n * must be able to pay (§3.3). It is off, with the reason on hover and focus, while\n * nothing is picked or the current plan is.\n *\n * 0.42 (§16.8 B): under a grant without an end (`permanentGrant`) the payer is warned\n * before paying for a plan that won't apply — here, in a confirm before `onChoose`, on\n * the way to the checkout; on the way to a switch (`switching`) in `PlanSwitchConfirm`\n * instead, so the warning shows once on either path.\n */\nexport function PlanPicker({\n plans,\n current,\n value: valueProp,\n defaultValue,\n onValueChange,\n interval: intervalProp,\n defaultInterval = \"year\",\n onIntervalChange,\n currency: currencyProp,\n defaultCurrency,\n onCurrencyChange,\n currencies: currenciesProp,\n onChoose,\n pending,\n permanentGrant = false,\n switching = false,\n pricePreview,\n legend,\n name,\n className,\n gridClassName,\n cardClassName,\n dimensionLabels,\n formatLimit,\n locale: localeProp,\n labels: labelsProp,\n}: PlanPickerProps) {\n const labels = useBillingLabels(labelsProp);\n const locale = useKitLocale(localeProp);\n const generated = useId();\n /** The choice the grant warning holds back until \"Buy anyway\". */\n const [warned, setWarned] = useState<PlanChoice | null>(null);\n const groupName = name ?? generated;\n const legendId = `${generated}-legend`;\n\n const [ownValue, setOwnValue] = useState<string | null>(() => defaultValue ?? current ?? null);\n const [ownInterval, setOwnInterval] = useState<BillingInterval>(defaultInterval);\n const [ownCurrency, setOwnCurrency] = useState<BillingCurrency>(\n () => defaultCurrency ?? billingCurrencyFor(locale),\n );\n const value = valueProp !== undefined ? valueProp : ownValue;\n const interval = intervalProp ?? ownInterval;\n const currency = currencyProp ?? ownCurrency;\n\n const pick = (code: string) => {\n if (valueProp === undefined) setOwnValue(code);\n onValueChange?.(code);\n };\n const setInterval = (next: BillingInterval) => {\n if (intervalProp === undefined) setOwnInterval(next);\n onIntervalChange?.(next);\n };\n const setCurrency = (next: BillingCurrency) => {\n if (currencyProp === undefined) setOwnCurrency(next);\n onCurrencyChange?.(next);\n };\n\n const currencies = currenciesProp ?? pricedCurrencies(plans);\n const intervals = pricedIntervals(plans);\n const picked = value ? plans.find((p) => p.code.toLowerCase() === value.toLowerCase()) : undefined;\n const relation = picked ? planRelation(plans, picked.code, current) : undefined;\n const pickedPrice = picked ? planPrice(picked.prices, currency, interval) : undefined;\n\n const buttonText = !picked\n ? labels.choosePlan\n : relation === \"upgrade\"\n ? labels.upgrade(picked.name)\n : relation === \"downgrade\"\n ? labels.downgrade(picked.name)\n : labels.choose(picked.name);\n const blocked = !picked\n ? labels.pickFirst\n : relation === \"current\"\n ? labels.isCurrent\n : pickedPrice?.kind === \"not-offered\"\n ? labels.notOffered\n : undefined;\n\n return (\n <div className={cn(\"min-w-0 space-y-3\", className)} data-billing-interval={interval} data-billing-currency={currency}>\n {(intervals.length > 1 || currencies.length > 1) && (\n <div className=\"flex flex-wrap items-center gap-2\">\n {intervals.length > 1 && (\n <ToggleGroup\n aria-label={labels.interval}\n // Content-wide, so the two switches share a row and wrap only when narrow.\n className=\"w-auto\"\n options={intervals.map((i) => ({ value: i, label: i === \"year\" ? labels.yearly : labels.monthly }))}\n value={interval}\n onChange={setInterval}\n />\n )}\n {currencies.length > 1 && (\n <ToggleGroup\n aria-label={labels.currency}\n className=\"w-auto\"\n options={currencies.map((c) => ({ value: c, label: c }))}\n value={currency}\n onChange={setCurrency}\n />\n )}\n </div>\n )}\n <fieldset\n className=\"min-w-0\"\n aria-labelledby={legend !== undefined ? legendId : undefined}\n aria-label={legend === undefined ? labels.plans : undefined}\n >\n {legend !== undefined && (\n <legend id={legendId} className=\"mb-2 text-sm font-medium text-primary\">\n {legend}\n </legend>\n )}\n <div className={cn(\"grid grid-cols-1 gap-2 sm:grid-cols-2 lg:grid-cols-3\", gridClassName)}>\n {plans.map((plan) => (\n <PlanCard\n key={plan.code}\n plan={plan}\n name={groupName}\n currency={currency}\n interval={interval}\n relation={planRelation(plans, plan.code, current)}\n pricePreview={pricePreview?.(plan, { interval, currency })}\n checked={picked?.code === plan.code}\n onCheckedChange={(on) => on && pick(plan.code)}\n dimensionLabels={dimensionLabels}\n formatLimit={formatLimit}\n locale={locale}\n labels={labelsProp}\n className={cardClassName}\n />\n ))}\n </div>\n </fieldset>\n <div className=\"flex justify-end\">\n <Button\n type=\"button\"\n variant=\"default\"\n className=\"max-sm:w-full\"\n disabledReason={blocked}\n pending={pending}\n onClick={() => {\n if (!picked) return;\n const choice = { plan: picked.code, interval, currency };\n if (permanentGrant && !switching) setWarned(choice);\n else onChoose(choice);\n }}\n >\n {buttonText}\n </Button>\n </div>\n {permanentGrant && !switching && (\n <GrantWarningConfirm\n open={warned !== null}\n onOpenChange={(open) => {\n if (!open) setWarned(null);\n }}\n onConfirm={() => {\n if (warned) onChoose(warned);\n }}\n labels={labelsProp}\n />\n )}\n </div>\n );\n}\n\nexport interface GrantWarningConfirmProps {\n /** Shown, controlled. */\n open: boolean;\n /** `false` on \"Back\", Escape, the backdrop — and after \"Buy anyway\". */\n onOpenChange: (open: boolean) => void;\n /** \"Buy anyway\": the app opens its checkout. The dialog closes itself after it. */\n onConfirm: () => void;\n /** Prop > `<UiKitProvider labels={{ billing }}>` > English: `grantWarningTitle`,\n * `grantWarningBody`, `grantWarningAction`. \"Back\" is `common.back`. */\n labels?: LabelOverride<BillingLabels>;\n}\n\n/**\n * 0.42 (§16.8 B): the permanent-grant warning before a checkout — `grantWarningTitle`,\n * `grantWarningBody`, \"Back\" and \"Buy anyway\" (`grantWarningAction`). {@link PlanPicker}\n * shows it itself under `permanentGrant` when not `switching`. Exported for the path the\n * picker cannot see: an app that meant to switch, learned there is no subscription to\n * switch (keksdose: `/billing/plan/preview` → 409 `billing_not_subscribed`) and falls back\n * to a checkout — show this before that checkout, so a permanent-grant holder is still\n * warned. Carries `data-grant-warning`.\n */\nexport function GrantWarningConfirm({ open, onOpenChange, onConfirm, labels: labelsProp }: GrantWarningConfirmProps) {\n const labels = useBillingLabels(labelsProp);\n const common = useKitLabels(\"common\", DEFAULT_COMMON_LABELS);\n return (\n <DialogFrame\n open={open}\n title={labels.grantWarningTitle}\n description={labels.grantWarningBody}\n onClose={() => onOpenChange(false)}\n data-grant-warning=\"\"\n actions={(close) => (\n <>\n <Button variant=\"secondary\" onClick={close}>\n {common.back}\n </Button>\n <Button\n variant=\"default\"\n onClick={() => {\n onConfirm();\n onOpenChange(false);\n }}\n >\n {labels.grantWarningAction}\n </Button>\n </>\n )}\n />\n );\n}\n"],"mappings":"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","names":[]}
@@ -15,6 +15,12 @@ export interface BreadcrumbItem {
15
15
  href?: string;
16
16
  /** A stable React key, when `label` is not a string. */
17
17
  key?: string;
18
+ /**
19
+ * The app's home entry (0.49 KIT-9). Default: an entry whose `href` is `"/"`. No trail
20
+ * is drawn when only home entries are left — inside a `PageHeader`, or wherever the
21
+ * current entry is hidden (`hideCurrent`): "Home" above a page's title says nothing.
22
+ */
23
+ home?: boolean;
18
24
  }
19
25
  /** What {@link BreadcrumbsProps.renderLink} is handed. Spread it onto your router's
20
26
  * link — `({ href, ...p }) => <Link to={href} {...p} />`. */
@@ -46,6 +52,18 @@ export interface BreadcrumbsProps extends Omit<ComponentPropsWithRef<"nav">, "ch
46
52
  keepEnd?: number;
47
53
  /** Overrides the {@link UiKitProvider}'s `breadcrumbs` labels. */
48
54
  labels?: LabelOverride<BreadcrumbsLabels>;
55
+ /**
56
+ * Leave out the last entry, the current page (0.49 KIT-9). The entry before it stays a
57
+ * link, and no entry carries `aria-current`: the page's title names where you are.
58
+ *
59
+ * Default `true` inside a `PageHeader`'s `breadcrumbs` (the title is right below, so
60
+ * the trail would repeat it), `false` elsewhere. With the current entry hidden — and
61
+ * inside a `PageHeader` either way — the trail is drawn only when at least one entry
62
+ * that is not home ({@link BreadcrumbItem.home}) is left, so a trail drawn by the
63
+ * layout above the page shows no lone "Home" link either. `false` keeps the current
64
+ * entry as text with `aria-current="page"`.
65
+ */
66
+ hideCurrent?: boolean;
49
67
  }
50
68
  /**
51
69
  * The trail from the app's root to this page.
@@ -56,4 +74,4 @@ export interface BreadcrumbsProps extends Omit<ComponentPropsWithRef<"nav">, "ch
56
74
  * are. The collapse is CSS (`hidden md:flex`), so it costs no media-query listener and
57
75
  * the server's first paint is already right.
58
76
  */
59
- export declare function Breadcrumbs({ items, renderLink, separator, collapse, keepEnd, labels: labelsProp, className, "aria-label": ariaLabel, ...rest }: BreadcrumbsProps): import("react").JSX.Element | null;
77
+ export declare function Breadcrumbs({ items: allItems, renderLink, separator, collapse, keepEnd, labels: labelsProp, hideCurrent, className, "aria-label": ariaLabel, ...rest }: BreadcrumbsProps): import("react").JSX.Element | null;
@@ -1,23 +1,26 @@
1
1
  "use client";
2
2
  import { jsx, jsxs } from "react/jsx-runtime";
3
- import { Fragment, useEffect, useRef, useState } from "react";
3
+ import { Fragment, useContext, useEffect, useRef, useState } from "react";
4
4
  import { ChevronRight, MoreHorizontal } from "lucide-react";
5
5
  import { cn } from "../lib/cn.js";
6
6
  import { FOCUS_RING } from "./focus-ring.js";
7
7
  import { useKitLabels, useKitLink } from "../i18n/kit-labels.js";
8
8
  import { pickLinkRenderer, RenderedKitLink } from "./text-link.js";
9
9
  import { DEFAULT_BREADCRUMBS_LABELS } from "./breadcrumbs-labels.js";
10
+ import { PageHeaderTrailContext } from "./page-header-context.js";
10
11
  function Breadcrumbs({
11
- items,
12
+ items: allItems,
12
13
  renderLink,
13
14
  separator,
14
15
  collapse = true,
15
16
  keepEnd = 2,
16
17
  labels: labelsProp,
18
+ hideCurrent,
17
19
  className,
18
20
  "aria-label": ariaLabel,
19
21
  ...rest
20
22
  }) {
23
+ const host = useContext(PageHeaderTrailContext);
21
24
  const labels = useKitLabels("breadcrumbs", DEFAULT_BREADCRUMBS_LABELS, labelsProp);
22
25
  const kitLink = useKitLink();
23
26
  const [expanded, setExpanded] = useState(false);
@@ -29,7 +32,11 @@ function Breadcrumbs({
29
32
  const li = revealedRef.current;
30
33
  (li?.querySelector("a[href]") ?? li)?.focus();
31
34
  }, [expanded]);
32
- if (items.length === 0) return null;
35
+ const hides = hideCurrent ?? host?.titled ?? false;
36
+ const shown = hides ? allItems.slice(0, -1) : allItems;
37
+ if (shown.length === 0) return null;
38
+ if ((host || hides) && !shown.some((item) => !(item.home ?? item.href === "/"))) return null;
39
+ const items = shown;
33
40
  const end = Math.max(1, keepEnd);
34
41
  const firstKept = Math.max(1, items.length - end);
35
42
  const collapsible = collapse && !expanded && firstKept - 1 >= 2;
@@ -37,7 +44,7 @@ function Breadcrumbs({
37
44
  const sep = /* @__PURE__ */ jsx("span", { "aria-hidden": true, className: "flex shrink-0 items-center text-placeholder", children: separator ?? /* @__PURE__ */ jsx(ChevronRight, { className: "size-3.5 rtl:-scale-x-100" }) });
38
45
  const linkClass = cn("block max-w-[14rem] truncate-until-large rounded-sm hover:text-primary", FOCUS_RING);
39
46
  return /* @__PURE__ */ jsx("nav", { ...rest, "aria-label": ariaLabel ?? labels.label, className: cn("min-w-0 text-sm", className), children: /* @__PURE__ */ jsx("ol", { className: "m-0 flex min-w-0 list-none flex-wrap items-center gap-x-1.5 gap-y-1 p-0 text-muted", children: items.map((item, i) => {
40
- const last = i === items.length - 1;
47
+ const last = !hides && i === items.length - 1;
41
48
  const key = item.key ?? (typeof item.label === "string" ? `${i}-${item.label}` : String(i));
42
49
  let content;
43
50
  if (last) {
@@ -1 +1 @@
1
- {"version":3,"sources":["../../src/components/breadcrumbs.tsx"],"sourcesContent":["import { Fragment, useEffect, useRef, useState } from \"react\";\nimport type { ComponentPropsWithRef, ReactElement, ReactNode } from \"react\";\nimport { ChevronRight, MoreHorizontal } from \"lucide-react\";\nimport { cn } from \"../lib/cn\";\nimport { FOCUS_RING } from \"./focus-ring\";\nimport { useKitLabels, useKitLink, type LabelOverride } from \"../i18n/kit-labels\";\nimport { pickLinkRenderer, RenderedKitLink } from \"./text-link\";\nimport { DEFAULT_BREADCRUMBS_LABELS } from \"./breadcrumbs-labels\";\n\nexport interface BreadcrumbsLabels {\n /** The `nav` landmark's name — what a screen reader lists it as. */\n label: string;\n /** The \"…\" button that reveals the levels collapsed away on a phone. */\n showAll: string;\n}\n\n// Defined in ./breadcrumbs-labels, which holds no component code (0.34, KS U8).\nexport { DEFAULT_BREADCRUMBS_LABELS };\n\nexport interface BreadcrumbItem {\n label: ReactNode;\n /** Where the crumb leads. A crumb without one is text (a level with no page of its\n * own). The LAST crumb is the current page and is text whether or not it has one. */\n href?: string;\n /** A stable React key, when `label` is not a string. */\n key?: string;\n}\n\n/** What {@link BreadcrumbsProps.renderLink} is handed. Spread it onto your router's\n * link — `({ href, ...p }) => <Link to={href} {...p} />`. */\nexport interface BreadcrumbLinkProps {\n href: string;\n className: string;\n children: ReactNode;\n}\n\nexport interface BreadcrumbsProps extends Omit<ComponentPropsWithRef<\"nav\">, \"children\"> {\n items: BreadcrumbItem[];\n /** Your router's link. Default: the `<UiKitProvider linkComponent>` (for an in-app\n * `href`), then `<a>`. The API `Chip`, `StatTile` and `ListItem` share. */\n renderLink?: (props: BreadcrumbLinkProps) => ReactElement;\n /**\n * The mark between crumbs. Default a chevron, mirrored in RTL — a chevron pointing\n * right in a right-to-left trail points back toward where you came from. Supply your\n * own and it is drawn as given (a \"/\" needs no mirroring).\n */\n separator?: ReactNode;\n /**\n * Collapse the middle on phones (below the `md` breakpoint) into a \"…\" button, keeping\n * the first crumb and the last {@link keepEnd}. Default `true`. kastlan's trail\n * scrolled sideways on a phone instead, which hides the current page — the one crumb\n * that must never be off-screen.\n */\n collapse?: boolean;\n /** How many crumbs at the end stay visible when collapsed — the current page and its\n * parent by default (2), because \"up one level\" is what a phone user reaches for. */\n keepEnd?: number;\n /** Overrides the {@link UiKitProvider}'s `breadcrumbs` labels. */\n labels?: LabelOverride<BreadcrumbsLabels>;\n}\n\n/**\n * The trail from the app's root to this page.\n *\n * `nav` + `ol` + `aria-current=\"page\"` on the last crumb: the WAI breadcrumb pattern,\n * which kastlan's (shared/components/layout/breadcrumbs.tsx) nearly was — a `nav` of\n * `span`s, so a screen reader heard no list, no count, and nothing marking where you\n * are. The collapse is CSS (`hidden md:flex`), so it costs no media-query listener and\n * the server's first paint is already right.\n */\nexport function Breadcrumbs({\n items,\n renderLink,\n separator,\n collapse = true,\n keepEnd = 2,\n labels: labelsProp,\n className,\n \"aria-label\": ariaLabel,\n ...rest\n}: BreadcrumbsProps) {\n const labels = useKitLabels(\"breadcrumbs\", DEFAULT_BREADCRUMBS_LABELS, labelsProp);\n const kitLink = useKitLink();\n const [expanded, setExpanded] = useState(false);\n // The \"…\" button vanishes when pressed; focus goes to the first crumb it revealed,\n // not back to the top of the document.\n const revealedRef = useRef<HTMLLIElement>(null);\n const focusRevealed = useRef(false);\n useEffect(() => {\n if (!expanded || !focusRevealed.current) return;\n focusRevealed.current = false;\n const li = revealedRef.current;\n (li?.querySelector<HTMLElement>(\"a[href]\") ?? li)?.focus();\n }, [expanded]);\n if (items.length === 0) return null;\n\n const end = Math.max(1, keepEnd);\n // Crumbs [1, firstKept) are the collapsible middle. Collapsing a single crumb into a\n // \"…\" saves nothing, so it takes two.\n const firstKept = Math.max(1, items.length - end);\n const collapsible = collapse && !expanded && firstKept - 1 >= 2;\n const isHidden = (i: number) => collapsible && i >= 1 && i < firstKept;\n\n const sep = (\n <span aria-hidden className=\"flex shrink-0 items-center text-placeholder\">\n {separator ?? <ChevronRight className=\"size-3.5 rtl:-scale-x-100\" />}\n </span>\n );\n // Each crumb is capped (in rem, so the cap grows with the text) and cut with an\n // ellipsis at Normal; from Large up it wraps inside its cap instead (text size §10.17,\n // round 0.34 T1): the list already wraps between crumbs, and a crumb cut to \"Inv…\" at\n // 150 % names no page.\n const linkClass = cn(\"block max-w-[14rem] truncate-until-large rounded-sm hover:text-primary\", FOCUS_RING);\n\n return (\n <nav {...rest} aria-label={ariaLabel ?? labels.label} className={cn(\"min-w-0 text-sm\", className)}>\n <ol className=\"m-0 flex min-w-0 list-none flex-wrap items-center gap-x-1.5 gap-y-1 p-0 text-muted\">\n {items.map((item, i) => {\n const last = i === items.length - 1;\n const key = item.key ?? (typeof item.label === \"string\" ? `${i}-${item.label}` : String(i));\n let content: ReactNode;\n if (last) {\n content = (\n <span aria-current=\"page\" className=\"block max-w-[16rem] truncate-until-large font-medium text-primary\">\n {item.label}\n </span>\n );\n } else if (item.href !== undefined) {\n const props: BreadcrumbLinkProps = { href: item.href, className: linkClass, children: item.label };\n const kit = pickLinkRenderer<BreadcrumbLinkProps>(undefined, kitLink, item.href);\n content = renderLink ? (\n renderLink(props)\n ) : kit ? (\n <RenderedKitLink render={kit} props={props} />\n ) : (\n <a href={props.href} className={props.className}>\n {props.children}\n </a>\n );\n } else {\n content = <span className=\"block max-w-[14rem] truncate-until-large\">{item.label}</span>;\n }\n return (\n <Fragment key={key}>\n {collapsible && i === 1 && (\n <li className=\"flex items-center gap-1.5 md:hidden\">\n {sep}\n <button\n type=\"button\"\n onClick={() => {\n focusRevealed.current = true;\n setExpanded(true);\n }}\n aria-label={labels.showAll}\n aria-expanded={false}\n className={cn(\"flex items-center rounded-sm px-0.5 hover:text-primary\", FOCUS_RING)}\n >\n <MoreHorizontal className=\"size-4\" aria-hidden />\n </button>\n </li>\n )}\n <li\n ref={i === 1 ? revealedRef : undefined}\n // Focusable only as the landing place for a revealed crumb with no link.\n tabIndex={i === 1 && expanded && item.href === undefined ? -1 : undefined}\n className={cn(\"flex min-w-0 items-center gap-1.5 outline-none\", isHidden(i) && \"hidden md:flex\")}\n >\n {i > 0 && sep}\n {content}\n </li>\n </Fragment>\n );\n })}\n </ol>\n </nav>\n );\n}\n"],"mappings":"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","names":[]}
1
+ {"version":3,"sources":["../../src/components/breadcrumbs.tsx"],"sourcesContent":["import { Fragment, useContext, useEffect, useRef, useState } from \"react\";\nimport type { ComponentPropsWithRef, ReactElement, ReactNode } from \"react\";\nimport { ChevronRight, MoreHorizontal } from \"lucide-react\";\nimport { cn } from \"../lib/cn\";\nimport { FOCUS_RING } from \"./focus-ring\";\nimport { useKitLabels, useKitLink, type LabelOverride } from \"../i18n/kit-labels\";\nimport { pickLinkRenderer, RenderedKitLink } from \"./text-link\";\nimport { DEFAULT_BREADCRUMBS_LABELS } from \"./breadcrumbs-labels\";\nimport { PageHeaderTrailContext } from \"./page-header-context\";\n\nexport interface BreadcrumbsLabels {\n /** The `nav` landmark's name — what a screen reader lists it as. */\n label: string;\n /** The \"…\" button that reveals the levels collapsed away on a phone. */\n showAll: string;\n}\n\n// Defined in ./breadcrumbs-labels, which holds no component code (0.34, KS U8).\nexport { DEFAULT_BREADCRUMBS_LABELS };\n\nexport interface BreadcrumbItem {\n label: ReactNode;\n /** Where the crumb leads. A crumb without one is text (a level with no page of its\n * own). The LAST crumb is the current page and is text whether or not it has one. */\n href?: string;\n /** A stable React key, when `label` is not a string. */\n key?: string;\n /**\n * The app's home entry (0.49 KIT-9). Default: an entry whose `href` is `\"/\"`. No trail\n * is drawn when only home entries are left — inside a `PageHeader`, or wherever the\n * current entry is hidden (`hideCurrent`): \"Home\" above a page's title says nothing.\n */\n home?: boolean;\n}\n\n/** What {@link BreadcrumbsProps.renderLink} is handed. Spread it onto your router's\n * link — `({ href, ...p }) => <Link to={href} {...p} />`. */\nexport interface BreadcrumbLinkProps {\n href: string;\n className: string;\n children: ReactNode;\n}\n\nexport interface BreadcrumbsProps extends Omit<ComponentPropsWithRef<\"nav\">, \"children\"> {\n items: BreadcrumbItem[];\n /** Your router's link. Default: the `<UiKitProvider linkComponent>` (for an in-app\n * `href`), then `<a>`. The API `Chip`, `StatTile` and `ListItem` share. */\n renderLink?: (props: BreadcrumbLinkProps) => ReactElement;\n /**\n * The mark between crumbs. Default a chevron, mirrored in RTL — a chevron pointing\n * right in a right-to-left trail points back toward where you came from. Supply your\n * own and it is drawn as given (a \"/\" needs no mirroring).\n */\n separator?: ReactNode;\n /**\n * Collapse the middle on phones (below the `md` breakpoint) into a \"…\" button, keeping\n * the first crumb and the last {@link keepEnd}. Default `true`. kastlan's trail\n * scrolled sideways on a phone instead, which hides the current page — the one crumb\n * that must never be off-screen.\n */\n collapse?: boolean;\n /** How many crumbs at the end stay visible when collapsed — the current page and its\n * parent by default (2), because \"up one level\" is what a phone user reaches for. */\n keepEnd?: number;\n /** Overrides the {@link UiKitProvider}'s `breadcrumbs` labels. */\n labels?: LabelOverride<BreadcrumbsLabels>;\n /**\n * Leave out the last entry, the current page (0.49 KIT-9). The entry before it stays a\n * link, and no entry carries `aria-current`: the page's title names where you are.\n *\n * Default `true` inside a `PageHeader`'s `breadcrumbs` (the title is right below, so\n * the trail would repeat it), `false` elsewhere. With the current entry hidden — and\n * inside a `PageHeader` either way — the trail is drawn only when at least one entry\n * that is not home ({@link BreadcrumbItem.home}) is left, so a trail drawn by the\n * layout above the page shows no lone \"Home\" link either. `false` keeps the current\n * entry as text with `aria-current=\"page\"`.\n */\n hideCurrent?: boolean;\n}\n\n/**\n * The trail from the app's root to this page.\n *\n * `nav` + `ol` + `aria-current=\"page\"` on the last crumb: the WAI breadcrumb pattern,\n * which kastlan's (shared/components/layout/breadcrumbs.tsx) nearly was — a `nav` of\n * `span`s, so a screen reader heard no list, no count, and nothing marking where you\n * are. The collapse is CSS (`hidden md:flex`), so it costs no media-query listener and\n * the server's first paint is already right.\n */\nexport function Breadcrumbs({\n items: allItems,\n renderLink,\n separator,\n collapse = true,\n keepEnd = 2,\n labels: labelsProp,\n hideCurrent,\n className,\n \"aria-label\": ariaLabel,\n ...rest\n}: BreadcrumbsProps) {\n const host = useContext(PageHeaderTrailContext);\n const labels = useKitLabels(\"breadcrumbs\", DEFAULT_BREADCRUMBS_LABELS, labelsProp);\n const kitLink = useKitLink();\n const [expanded, setExpanded] = useState(false);\n // The \"…\" button vanishes when pressed; focus goes to the first crumb it revealed,\n // not back to the top of the document.\n const revealedRef = useRef<HTMLLIElement>(null);\n const focusRevealed = useRef(false);\n useEffect(() => {\n if (!expanded || !focusRevealed.current) return;\n focusRevealed.current = false;\n const li = revealedRef.current;\n (li?.querySelector<HTMLElement>(\"a[href]\") ?? li)?.focus();\n }, [expanded]);\n const hides = hideCurrent ?? host?.titled ?? false;\n const shown = hides ? allItems.slice(0, -1) : allItems;\n if (shown.length === 0) return null;\n // A trail of home entries alone repeats nothing worth a line: in a PageHeader, and\n // wherever the current entry is hidden (a layout's trail above the page's title).\n if ((host || hides) && !shown.some((item) => !(item.home ?? item.href === \"/\"))) return null;\n const items = shown;\n\n const end = Math.max(1, keepEnd);\n // Crumbs [1, firstKept) are the collapsible middle. Collapsing a single crumb into a\n // \"…\" saves nothing, so it takes two.\n const firstKept = Math.max(1, items.length - end);\n const collapsible = collapse && !expanded && firstKept - 1 >= 2;\n const isHidden = (i: number) => collapsible && i >= 1 && i < firstKept;\n\n const sep = (\n <span aria-hidden className=\"flex shrink-0 items-center text-placeholder\">\n {separator ?? <ChevronRight className=\"size-3.5 rtl:-scale-x-100\" />}\n </span>\n );\n // Each crumb is capped (in rem, so the cap grows with the text) and cut with an\n // ellipsis at Normal; from Large up it wraps inside its cap instead (text size §10.17,\n // round 0.34 T1): the list already wraps between crumbs, and a crumb cut to \"Inv…\" at\n // 150 % names no page.\n const linkClass = cn(\"block max-w-[14rem] truncate-until-large rounded-sm hover:text-primary\", FOCUS_RING);\n\n return (\n <nav {...rest} aria-label={ariaLabel ?? labels.label} className={cn(\"min-w-0 text-sm\", className)}>\n <ol className=\"m-0 flex min-w-0 list-none flex-wrap items-center gap-x-1.5 gap-y-1 p-0 text-muted\">\n {items.map((item, i) => {\n // With the current entry hidden, the last one shown is a parent: a link.\n const last = !hides && i === items.length - 1;\n const key = item.key ?? (typeof item.label === \"string\" ? `${i}-${item.label}` : String(i));\n let content: ReactNode;\n if (last) {\n content = (\n <span aria-current=\"page\" className=\"block max-w-[16rem] truncate-until-large font-medium text-primary\">\n {item.label}\n </span>\n );\n } else if (item.href !== undefined) {\n const props: BreadcrumbLinkProps = { href: item.href, className: linkClass, children: item.label };\n const kit = pickLinkRenderer<BreadcrumbLinkProps>(undefined, kitLink, item.href);\n content = renderLink ? (\n renderLink(props)\n ) : kit ? (\n <RenderedKitLink render={kit} props={props} />\n ) : (\n <a href={props.href} className={props.className}>\n {props.children}\n </a>\n );\n } else {\n content = <span className=\"block max-w-[14rem] truncate-until-large\">{item.label}</span>;\n }\n return (\n <Fragment key={key}>\n {collapsible && i === 1 && (\n <li className=\"flex items-center gap-1.5 md:hidden\">\n {sep}\n <button\n type=\"button\"\n onClick={() => {\n focusRevealed.current = true;\n setExpanded(true);\n }}\n aria-label={labels.showAll}\n aria-expanded={false}\n className={cn(\"flex items-center rounded-sm px-0.5 hover:text-primary\", FOCUS_RING)}\n >\n <MoreHorizontal className=\"size-4\" aria-hidden />\n </button>\n </li>\n )}\n <li\n ref={i === 1 ? revealedRef : undefined}\n // Focusable only as the landing place for a revealed crumb with no link.\n tabIndex={i === 1 && expanded && item.href === undefined ? -1 : undefined}\n className={cn(\"flex min-w-0 items-center gap-1.5 outline-none\", isHidden(i) && \"hidden md:flex\")}\n >\n {i > 0 && sep}\n {content}\n </li>\n </Fragment>\n );\n })}\n </ol>\n </nav>\n );\n}\n"],"mappings":"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","names":[]}
@@ -9,7 +9,37 @@ import type { KitLinkComponent, KitLinkProps } from "../i18n/kit-labels.js";
9
9
  * Public through ./ui by name: `buttonClasses`, `TOUCH_TARGET_LARGE` and the four types.
10
10
  * The other exports are the two buttons' shared parts (`@internal`).
11
11
  */
12
- export type ButtonVariant = "primary" | "secondary" | "ghost" | "danger" | "brand" | "link";
12
+ /**
13
+ * A button's look.
14
+ *
15
+ * - `default`: the quiet action — a warm filled chip with a hairline border. What a
16
+ * Button with no `variant` draws.
17
+ * - `accent`: the strong one, solid `--brand` with `--brand-contrast` text — the one
18
+ * call to action a view leads with. "Accent" is the button's role here, painted in
19
+ * `--brand`; it is not the palette's second anchor colour, also called `accent`
20
+ * (theme/palette-derive.ts), which no button paints.
21
+ * - `secondary`: an outline. `ghost`: text or glyph only. `danger`: solid `--danger`,
22
+ * for an action that destroys something. `link`: a text link that acts (a `<button>`).
23
+ * - `primary` and `brand` are **deprecated** aliases of `default` and `accent` (0.49,
24
+ * same look; a development build warns once per name). They are removed in 0.51, and
25
+ * `primary` will not come back with another meaning.
26
+ */
27
+ export type ButtonVariant = "default" | "accent" | "secondary" | "ghost" | "danger" | "link"
28
+ /** @deprecated 0.49 — use `default` (same look); removed in 0.51. */
29
+ | "primary"
30
+ /** @deprecated 0.49 — use `accent` (same look); removed in 0.51. */
31
+ | "brand";
32
+ /** @internal A {@link ButtonVariant} without the deprecated aliases. */
33
+ export type CanonicalButtonVariant = Exclude<ButtonVariant, "primary" | "brand">;
34
+ /**
35
+ * @internal A deprecated variant name warns once (per `component` and name) in a
36
+ * development build, then reads as the name it stands for. `component` is the prop's
37
+ * owner in the message ("Button variant", "FormActions submitVariant", …).
38
+ */
39
+ export declare function warnDeprecatedVariant(component: string, name: string, replacement: string): void;
40
+ /** @internal `primary` → `default`, `brand` → `accent` (warning once in development);
41
+ * every other variant as it is. Runs before every lookup keyed by the variant. */
42
+ export declare function resolveButtonVariant(variant: ButtonVariant, component?: string): CanonicalButtonVariant;
13
43
  /**
14
44
  * The touch target at Large and Extra large (docs/text-size-harmonization.md §4): at
15
45
  * least 48 px, the size a finger needs whatever the type. In px on purpose, like the
@@ -34,7 +64,7 @@ export type ButtonSize = "sm" | "md";
34
64
  * recipe at 12 %.
35
65
  */
36
66
  export declare const HOVER_INK = "hover:bg-[color-mix(in_srgb,var(--text-primary)_7%,transparent)]";
37
- export declare const buttonVariantClasses: Record<ButtonVariant, string>;
67
+ export declare const buttonVariantClasses: Record<CanonicalButtonVariant, string>;
38
68
  /**
39
69
  * The text colour of a `link` or `ghost` button, over the variant's own.
40
70
  *
@@ -52,14 +82,14 @@ export declare const buttonVariantClasses: Record<ButtonVariant, string>;
52
82
  *
53
83
  * `danger` also reaches the two NEUTRAL boxed variants, where the box carries it:
54
84
  * `secondary` becomes an outlined destructive button (`--danger` text and a
55
- * `--danger-border` outline, the quiet danger fill on hover) and `primary` a soft one
85
+ * `--danger-border` outline, the quiet danger fill on hover) and `default` a soft one
56
86
  * (the quiet fill at rest, solid `--danger` on hover). kastlan's meeting-invitations-
57
87
  * tab.tsx "Remove all" is `variant="secondary" className="text-destructive"` — a
58
88
  * destructive action that must sit in a row of secondary buttons without shouting
59
89
  * like the solid `variant="danger"` would, and whose red text alone left the border
60
90
  * and hover saying "neutral". `muted` stays a text look and does nothing on a box, and
61
- * `danger`, `brand` and `link`'s own colours are left alone: a tone that recoloured a
62
- * solid danger or brand button would be a second variant under another name.
91
+ * `danger`, `accent` and `link`'s own colours are left alone: a tone that recoloured a
92
+ * solid danger or accent button would be a second variant under another name.
63
93
  */
64
94
  export type ButtonTone = "default" | "muted" | "danger";
65
95
  export declare const BUTTON_LINK_PRESSED = "font-medium text-brand hover:text-brand disabled:hover:text-brand contrast:text-brand-muted contrast:hover:text-brand-muted contrast:disabled:hover:text-brand-muted";
@@ -80,10 +110,10 @@ export interface ButtonClassesOptions {
80
110
  * `{ size, className }` — `buttonClasses("secondary", { size: "sm" })` for keksdose's
81
111
  * compact toolbar links.
82
112
  */
83
- export declare function buttonClasses(variant?: ButtonVariant, classNameOrOptions?: string | ButtonClassesOptions): string;
113
+ export declare function buttonClasses(given?: ButtonVariant, classNameOrOptions?: string | ButtonClassesOptions): string;
84
114
  export declare const ICON_BUTTON_PRESSED = "bg-brand-soft text-brand hover:bg-brand-soft-hover hover:text-brand-muted disabled:hover:bg-brand-soft disabled:hover:text-brand";
85
115
  /** @internal The button look shared by the `<button>` and the `<a>` form. */
86
- export declare function buttonLook(variant: ButtonVariant, size: ButtonSize, stretch: boolean | undefined, tone: ButtonTone): string;
116
+ export declare function buttonLook(given: ButtonVariant, size: ButtonSize, stretch: boolean | undefined, tone: ButtonTone): string;
87
117
  /** @internal Calls a `renderLink` / provider link as a function, as ListItem and Chip
88
118
  * do — an inline `renderLink` arrow would otherwise be a new component type every
89
119
  * render. Both buttons' link forms render through it. */
@@ -1,6 +1,29 @@
1
1
  "use client";
2
2
  import { cn } from "../lib/cn.js";
3
3
  import { FOCUS_RING_WIDTH } from "./focus-ring.js";
4
+ const DEPRECATED_BUTTON_VARIANTS = {
5
+ primary: "default",
6
+ brand: "accent"
7
+ };
8
+ function devWarnings() {
9
+ return Boolean(import.meta.env?.DEV) && !import.meta.env?.VITEST;
10
+ }
11
+ const warnedVariants = /* @__PURE__ */ new Set();
12
+ function warnDeprecatedVariant(component, name, replacement) {
13
+ if (!devWarnings()) return;
14
+ const key = `${component}|${name}`;
15
+ if (warnedVariants.has(key)) return;
16
+ warnedVariants.add(key);
17
+ console.warn(
18
+ `[ui-kit] ${component}="${name}" is deprecated: use "${replacement}" (same look). "${name}" is removed in 0.51.`
19
+ );
20
+ }
21
+ function resolveButtonVariant(variant, component = "Button variant") {
22
+ const replacement = DEPRECATED_BUTTON_VARIANTS[variant];
23
+ if (replacement === void 0) return variant;
24
+ warnDeprecatedVariant(component, variant, replacement);
25
+ return replacement;
26
+ }
4
27
  const BUTTON_BASE = cn(
5
28
  "inline-flex items-center justify-center rounded-md font-medium transition-colors focus:outline-none disabled:opacity-50 disabled:cursor-not-allowed [&_svg]:shrink-0",
6
29
  FOCUS_RING_WIDTH
@@ -17,7 +40,7 @@ const BUTTON_SIZES_LARGE = {
17
40
  };
18
41
  const HOVER_INK = "hover:bg-[color-mix(in_srgb,var(--text-primary)_7%,transparent)]";
19
42
  const buttonVariantClasses = {
20
- primary: (
43
+ default: (
21
44
  // Hovers onto `--bg-active`, not `--border` (0.33, §12.9): More contrast steps the
22
45
  // border until it is a 3:1 LINE, and as a fill under the label that left 2.7:1.
23
46
  "border border-subtle bg-surface-2 text-primary hover:bg-active focus-visible:ring-brand"
@@ -25,7 +48,7 @@ const buttonVariantClasses = {
25
48
  secondary: `border border-subtle bg-transparent text-primary ${HOVER_INK} focus-visible:ring-brand`,
26
49
  ghost: `bg-transparent text-primary ${HOVER_INK} focus-visible:ring-brand`,
27
50
  danger: "bg-danger text-danger-contrast hover:bg-danger-hover focus-visible:ring-danger-strong",
28
- brand: "bg-brand text-brand-contrast hover:bg-brand-hover focus-visible:ring-brand",
51
+ accent: "bg-brand text-brand-contrast hover:bg-brand-hover focus-visible:ring-brand",
29
52
  // A text link that is still a `<button>` — keksdose's six hand-rolled
30
53
  // `<button className="text-brand underline">` sites ("Resend code", "Show all",
31
54
  // "Undo" in a toast…), which act rather than navigate and so must not be anchors.
@@ -51,11 +74,12 @@ const BUTTON_BOXED_DANGER = {
51
74
  // over a light page.
52
75
  ghost: "hover:bg-danger-soft",
53
76
  secondary: "border-danger bg-transparent text-danger hover:bg-danger-soft focus-visible:ring-danger-strong",
54
- primary: "border-danger bg-danger-soft text-danger hover:border-[var(--danger)] hover:bg-danger hover:text-danger-contrast focus-visible:ring-danger-strong disabled:hover:border-danger disabled:hover:bg-danger-soft disabled:hover:text-danger"
77
+ default: "border-danger bg-danger-soft text-danger hover:border-[var(--danger)] hover:bg-danger hover:text-danger-contrast focus-visible:ring-danger-strong disabled:hover:border-danger disabled:hover:bg-danger-soft disabled:hover:text-danger"
55
78
  };
56
79
  const BUTTON_LINK_PRESSED = "font-medium text-brand hover:text-brand disabled:hover:text-brand contrast:text-brand-muted contrast:hover:text-brand-muted contrast:disabled:hover:text-brand-muted";
57
- function buttonClasses(variant = "primary", classNameOrOptions) {
80
+ function buttonClasses(given = "default", classNameOrOptions) {
58
81
  const { size = "md", className } = typeof classNameOrOptions === "object" ? classNameOrOptions : { className: classNameOrOptions };
82
+ const variant = resolveButtonVariant(given, "buttonClasses variant");
59
83
  return cn(
60
84
  BUTTON_BASE,
61
85
  BUTTON_SIZES[size],
@@ -65,7 +89,8 @@ function buttonClasses(variant = "primary", classNameOrOptions) {
65
89
  );
66
90
  }
67
91
  const ICON_BUTTON_PRESSED = "bg-brand-soft text-brand hover:bg-brand-soft-hover hover:text-brand-muted disabled:hover:bg-brand-soft disabled:hover:text-brand";
68
- function buttonLook(variant, size, stretch, tone) {
92
+ function buttonLook(given, size, stretch, tone) {
93
+ const variant = resolveButtonVariant(given);
69
94
  return cn(
70
95
  BUTTON_BASE,
71
96
  BUTTON_SIZES[size],
@@ -93,6 +118,8 @@ export {
93
118
  TOUCH_TARGET_LARGE,
94
119
  buttonClasses,
95
120
  buttonLook,
96
- buttonVariantClasses
121
+ buttonVariantClasses,
122
+ resolveButtonVariant,
123
+ warnDeprecatedVariant
97
124
  };
98
125
  //# sourceMappingURL=button-look.js.map
@@ -1 +1 @@
1
- {"version":3,"sources":["../../src/components/button-look.ts"],"sourcesContent":["import { cn } from \"../lib/cn\";\nimport { FOCUS_RING_WIDTH } from \"./focus-ring\";\nimport type { KitLinkComponent, KitLinkProps } from \"../i18n/kit-labels\";\n\n/**\n * The button look, which {@link Button} and {@link IconButton} share (0.34, MOD-2, refactor\n * audit 2026-10). In ui.tsx it was the one knot in an otherwise one-way graph: Button drew\n * IconButton's pressed look, and IconButton drew Button's variant colours, its hover ink\n * and its router link. Both now draw from this module, which renders nothing, so neither\n * imports the other.\n *\n * Public through ./ui by name: `buttonClasses`, `TOUCH_TARGET_LARGE` and the four types.\n * The other exports are the two buttons' shared parts (`@internal`).\n */\n\nexport type ButtonVariant = \"primary\" | \"secondary\" | \"ghost\" | \"danger\" | \"brand\" | \"link\";\n\n// Shared base ring for every button-styled element. Kept as a named const so the\n// <Button> component and the {@link buttonClasses} helper draw from one source and\n// can never drift apart.\n//\n// The ring is the kit's focus frame (§5): {@link FOCUS_RING_WIDTH} wide, on keyboard focus\n// (`focus-visible`) — a tap or a click no longer leaves a ring on the button — in each\n// variant's own colour below.\n//\n// `[&_svg]:shrink-0` (0.32.2, kastlan's 0.32.1 report): an icon beside a label keeps its\n// size when the label wraps at Large. Without it the svg was a flex item like the text,\n// and a two-line label squeezed it to a sliver (\"New invoice\" in a page header at 360 px\n// / Extra large).\nconst BUTTON_BASE = cn(\n \"inline-flex items-center justify-center rounded-md font-medium transition-colors focus:outline-none disabled:opacity-50 disabled:cursor-not-allowed [&_svg]:shrink-0\",\n FOCUS_RING_WIDTH,\n);\n\n/**\n * The touch target at Large and Extra large (docs/text-size-harmonization.md §4): at\n * least 48 px, the size a finger needs whatever the type. In px on purpose, like the\n * field floor ({@link FIELD_TOUCH_TEXT}): it is a physical threshold, not a size that\n * follows the text. Normal keeps the kit's own heights; a `large:` variant, so it costs\n * no render and is right on the first paint.\n */\nexport const TOUCH_TARGET_LARGE = \"large:min-h-[48px] large:min-w-[48px]\";\n\n/** The 48 px target at Large for a control whose BOX cannot grow (a chip's or a tab's\n * action, IconButton `2xs`; Tabs' overflow cues, 0.48 KS N3): an invisible `::after`\n * centred on it takes the taps a finger lands round the glyph. Not public. */\nexport const HIT_AREA_LARGE =\n \"relative large:after:absolute large:after:inset-[calc((100%-48px)/2)] large:after:content-['']\";\n\nexport type ButtonSize = \"sm\" | \"md\";\n\n// Box geometry per size, split out of the base so the two cannot be merged into one\n// string that a size then has to fight. `md` is the pre-0.8.0 look, unchanged. `sm` is\n// the compact secondary action keksdose repeats by hand as `px-2 py-1 text-xs` (and\n// `px-2 py-0.5 text-xs`) on the buttons in a toolbar or a table header; one rung is\n// enough, so the two spellings meet at `py-1`. There is no `lg`: no app has asked for\n// a bigger text button (IconButton's `lg` is a touch target, not a text size). The\n// variant map comes AFTER the size, so `link`'s `p-0` still wins at either size.\n//\n// At Large and above (§4) `sm` grows to `md`'s height — the padding that makes its\n// 1rem line as tall as `md`'s 1.25rem one — and both reach the 48 px touch target. The\n// type stays `sm`'s: the compact button is still the quieter one. Not on `link`, which\n// is a word in a sentence and has no box to grow (see `buttonLook`).\nconst BUTTON_SIZES: Record<ButtonSize, string> = {\n md: \"gap-2 px-3 py-2 text-sm\",\n sm: \"gap-1.5 px-2 py-1 text-xs\",\n};\nconst BUTTON_SIZES_LARGE: Record<ButtonSize, string> = {\n md: \"large:min-h-[48px]\",\n sm: \"large:min-h-[48px] large:py-2.5\",\n};\n\n/**\n * The hover of a control that may sit on the page, a card or a well (0.33, B′ —\n * docs/colour-roles-harmonization.md §12.1, §12.10): the body ink at 7 %, TRANSLUCENT,\n * so it darkens (in dark, lightens) whatever is under it by the same step — 1.11:1 or\n * more on all three surfaces, both themes, with the label at 7:1 and up. It was\n * `--bg-surface-2`, which since 0.33 is the page itself in light: a ghost button on the\n * page would not have moved at all. Not `--bg-hover` either, which is mixed against the\n * CARD and stands only 1.04:1 from the page. IconButton's `custom` tone uses the same\n * recipe at 12 %.\n */\nexport const HOVER_INK = \"hover:bg-[color-mix(in_srgb,var(--text-primary)_7%,transparent)]\";\n\n// Warm, palette-token-driven so buttons blend with the fields + cards in every theme.\n// Actions default to a warm bordered look (primary = filled warm chip, secondary =\n// outline); `brand` stays the solid accent for the rare strong CTA; `danger` takes the\n// semantic `--danger` family (destructive semantics), so a consumer can re-point the\n// destructive hue instead of inheriting a hard-coded red. Focus rings follow each\n// variant's own accent: brand for the four neutral ones, danger for `danger`.\nexport const buttonVariantClasses: Record<ButtonVariant, string> = {\n primary:\n // Hovers onto `--bg-active`, not `--border` (0.33, §12.9): More contrast steps the\n // border until it is a 3:1 LINE, and as a fill under the label that left 2.7:1.\n \"border border-subtle bg-surface-2 text-primary hover:bg-active focus-visible:ring-brand\",\n secondary: `border border-subtle bg-transparent text-primary ${HOVER_INK} focus-visible:ring-brand`,\n ghost: `bg-transparent text-primary ${HOVER_INK} focus-visible:ring-brand`,\n danger:\n \"bg-danger text-danger-contrast hover:bg-danger-hover focus-visible:ring-danger-strong\",\n brand:\n \"bg-brand text-brand-contrast hover:bg-brand-hover focus-visible:ring-brand\",\n // A text link that is still a `<button>` — keksdose's six hand-rolled\n // `<button className=\"text-brand underline\">` sites (\"Resend code\", \"Show all\",\n // \"Undo\" in a toast…), which act rather than navigate and so must not be anchors.\n // The box padding goes (`p-0`) so it sits in a sentence at the text's own size, but\n // the base's focus ring stays: those copies had `outline-none` and no ring, so\n // a keyboard user tabbing onto them saw nothing at all. `rounded-sm` keeps that\n // ring hugging the word instead of drawing a pill round it.\n //\n // Under More contrast the word reads `--brand-muted`, the brand AS TEXT (0.36,\n // colour-roles §13.3 decision 8): `--brand` is solved as a mark (3:1), and on More\n // contrast's darker light well Moss's read 3.68:1; the derived dark brands sit at 3.0\n // as text too. Both modes, since `contrast:` does not split them.\n link:\n \"rounded-sm p-0 bg-transparent text-brand underline-offset-4 hover:underline focus-visible:ring-brand contrast:text-brand-muted\",\n};\n\n/**\n * The text colour of a `link` or `ghost` button, over the variant's own.\n *\n * `muted` is the QUIET link keksdose hand-rolls in six places (tours-page:321's \"Mark\n * undone\", transaction-fields:176/215's \"Add line\" / \"Fill total\",\n * invoice-lines-table:376, invoice-review:464): secondary text that darkens to the\n * body colour under the pointer, for an action that must be findable but not compete\n * with the brand-coloured one beside it. `danger` is the same quiet look turning\n * `--danger` on hover — transaction-editor:401's \"Remove split\", which is a text\n * action that destroys something.\n *\n * A `tone` rather than a `link-muted` variant, because it is the axis IconButton\n * already has (`tone=\"muted\"`, `tone=\"danger\"`, same quiet-until-hover meaning), and\n * because the same two looks are wanted on `ghost`.\n *\n * `danger` also reaches the two NEUTRAL boxed variants, where the box carries it:\n * `secondary` becomes an outlined destructive button (`--danger` text and a\n * `--danger-border` outline, the quiet danger fill on hover) and `primary` a soft one\n * (the quiet fill at rest, solid `--danger` on hover). kastlan's meeting-invitations-\n * tab.tsx \"Remove all\" is `variant=\"secondary\" className=\"text-destructive\"` — a\n * destructive action that must sit in a row of secondary buttons without shouting\n * like the solid `variant=\"danger\"` would, and whose red text alone left the border\n * and hover saying \"neutral\". `muted` stays a text look and does nothing on a box, and\n * `danger`, `brand` and `link`'s own colours are left alone: a tone that recoloured a\n * solid danger or brand button would be a second variant under another name.\n */\nexport type ButtonTone = \"default\" | \"muted\" | \"danger\";\n\n// `contrast:text-secondary` restates the tone's own colour under the `contrast:` variant,\n// so tailwind-merge drops the link's `contrast:text-brand-muted` (0.36): a quiet link\n// stays quiet under More contrast. On `ghost` it reads the same either way.\nconst BUTTON_TONES: Record<Exclude<ButtonTone, \"default\">, string> = {\n muted:\n \"text-secondary hover:text-primary disabled:hover:text-secondary contrast:text-secondary\",\n danger:\n \"text-secondary hover:text-danger focus-visible:ring-danger-strong disabled:hover:text-secondary contrast:text-secondary\",\n};\n\n/** Only the two transparent variants take every tone; see {@link ButtonTone}. */\nconst TONED_VARIANTS = new Set<ButtonVariant>([\"link\", \"ghost\"]);\n\n// `tone=\"danger\"` on the neutral boxed variants — see {@link ButtonTone}. Each string\n// restates every colour the variant sets (border, fill, text, hover, ring) so none of\n// the neutral ones survives the merge.\nconst BUTTON_BOXED_DANGER: Partial<Record<ButtonVariant, string>> = {\n // Not boxed, but its hover is a fill: the danger wash under the danger text, as\n // IconButton's danger tone (0.33). On B′'s translucent ink `--danger` fell to 4.2:1\n // over a light page.\n ghost: \"hover:bg-danger-soft\",\n secondary:\n \"border-danger bg-transparent text-danger hover:bg-danger-soft focus-visible:ring-danger-strong\",\n primary:\n \"border-danger bg-danger-soft text-danger hover:border-[var(--danger)] hover:bg-danger hover:text-danger-contrast focus-visible:ring-danger-strong disabled:hover:border-danger disabled:hover:bg-danger-soft disabled:hover:text-danger\",\n};\n\n// `pressed` on a `link`: the brand colour and a heavier weight, which is exactly what\n// lenkbank's \"All speeds\" toggle paints by hand (gear/hysteresis-charts.tsx:482). After\n// the tone, so a pressed muted link is brand, and an unpressed one is quiet grey. Under\n// More contrast the brand as text, `--brand-muted`, at rest and hovered (0.36, as the\n// `link` variant), whatever the tone.\nexport const BUTTON_LINK_PRESSED =\n \"font-medium text-brand hover:text-brand disabled:hover:text-brand contrast:text-brand-muted contrast:hover:text-brand-muted contrast:disabled:hover:text-brand-muted\";\n\n/** The second argument of {@link buttonClasses} in its options form. */\nexport interface ButtonClassesOptions {\n /** See {@link ButtonProps.size}. */\n size?: ButtonSize;\n className?: string;\n}\n\n/**\n * Button classes for the rare case where the styling must land on a non-`<button>`\n * element that {@link Button} can't render — e.g. a router `<Link>` or a Radix\n * AlertDialog Action/Cancel (which must stay the Radix element). Everywhere a real\n * button works, prefer `<Button>`. Draws from the same base, size and variant maps as\n * `<Button>`, so the two stay in lockstep.\n *\n * The second argument is either the extra classes (the pre-0.8.0 form) or\n * `{ size, className }` — `buttonClasses(\"secondary\", { size: \"sm\" })` for keksdose's\n * compact toolbar links.\n */\nexport function buttonClasses(\n variant: ButtonVariant = \"primary\",\n classNameOrOptions?: string | ButtonClassesOptions,\n): string {\n const { size = \"md\", className } =\n typeof classNameOrOptions === \"object\" ? classNameOrOptions : { className: classNameOrOptions };\n return cn(\n BUTTON_BASE,\n BUTTON_SIZES[size],\n variant !== \"link\" && BUTTON_SIZES_LARGE[size],\n buttonVariantClasses[variant],\n className,\n );\n}\n\n// `pressed`: a toggle that is on. The brand glyph on the quiet brand fill — the\n// \"selected\" look of a Chip or a SegmentedControl option, so an on toggle reads as on\n// beside them. After the tone, so a pressed `muted` button is brand, not grey. On hover\n// the glyph turns brand-muted: `--brand` on `--brand-bg-hover` is 2.69–2.80 in four dark\n// presets, under an icon's 3:1 (colour-roles §12.12). Button's `pressed` draws it on its\n// boxed variants too, which is why it lives here and not in ./icon-button.\nexport const ICON_BUTTON_PRESSED =\n \"bg-brand-soft text-brand hover:bg-brand-soft-hover hover:text-brand-muted disabled:hover:bg-brand-soft disabled:hover:text-brand\";\n\n/** @internal The button look shared by the `<button>` and the `<a>` form. */\nexport function buttonLook(variant: ButtonVariant, size: ButtonSize, stretch: boolean | undefined, tone: ButtonTone) {\n return cn(\n BUTTON_BASE,\n BUTTON_SIZES[size],\n // §4: `sm` at `md`'s height and the 48 px target at Large — not on a `link`. In the\n // order `buttonClasses` has it, so the two strings stay identical.\n variant !== \"link\" && BUTTON_SIZES_LARGE[size],\n // In a flex row next to a taller labelled field, `stretch` makes the button\n // fill the field's height so the two line up (self-stretch overrides the row's\n // align-items). No effect outside a flex row / when it's already the tallest.\n stretch && \"self-stretch\",\n buttonVariantClasses[variant],\n tone !== \"default\" && TONED_VARIANTS.has(variant) && BUTTON_TONES[tone],\n tone === \"danger\" && BUTTON_BOXED_DANGER[variant],\n );\n}\n\n/** @internal Calls a `renderLink` / provider link as a function, as ListItem and Chip\n * do — an inline `renderLink` arrow would otherwise be a new component type every\n * render. Both buttons' link forms render through it. */\nexport function RenderedButtonLink({ render, ...props }: KitLinkProps & { render: KitLinkComponent }) {\n return render(props);\n}\n"],"mappings":";AAAA,SAAS,UAAU;AACnB,SAAS,wBAAwB;AA4BjC,MAAM,cAAc;AAAA,EAClB;AAAA,EACA;AACF;AASO,MAAM,qBAAqB;AAK3B,MAAM,iBACX;AAgBF,MAAM,eAA2C;AAAA,EAC/C,IAAI;AAAA,EACJ,IAAI;AACN;AACA,MAAM,qBAAiD;AAAA,EACrD,IAAI;AAAA,EACJ,IAAI;AACN;AAYO,MAAM,YAAY;AAQlB,MAAM,uBAAsD;AAAA,EACjE;AAAA;AAAA;AAAA,IAGE;AAAA;AAAA,EACF,WAAW,oDAAoD,SAAS;AAAA,EACxE,OAAO,+BAA+B,SAAS;AAAA,EAC/C,QACE;AAAA,EACF,OACE;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,EAaF,MACE;AACJ;AAiCA,MAAM,eAA+D;AAAA,EACnE,OACE;AAAA,EACF,QACE;AACJ;AAGA,MAAM,iBAAiB,oBAAI,IAAmB,CAAC,QAAQ,OAAO,CAAC;AAK/D,MAAM,sBAA8D;AAAA;AAAA;AAAA;AAAA,EAIlE,OAAO;AAAA,EACP,WACE;AAAA,EACF,SACE;AACJ;AAOO,MAAM,sBACX;AAoBK,SAAS,cACd,UAAyB,WACzB,oBACQ;AACR,QAAM,EAAE,OAAO,MAAM,UAAU,IAC7B,OAAO,uBAAuB,WAAW,qBAAqB,EAAE,WAAW,mBAAmB;AAChG,SAAO;AAAA,IACL;AAAA,IACA,aAAa,IAAI;AAAA,IACjB,YAAY,UAAU,mBAAmB,IAAI;AAAA,IAC7C,qBAAqB,OAAO;AAAA,IAC5B;AAAA,EACF;AACF;AAQO,MAAM,sBACX;AAGK,SAAS,WAAW,SAAwB,MAAkB,SAA8B,MAAkB;AACnH,SAAO;AAAA,IACL;AAAA,IACA,aAAa,IAAI;AAAA;AAAA;AAAA,IAGjB,YAAY,UAAU,mBAAmB,IAAI;AAAA;AAAA;AAAA;AAAA,IAI7C,WAAW;AAAA,IACX,qBAAqB,OAAO;AAAA,IAC5B,SAAS,aAAa,eAAe,IAAI,OAAO,KAAK,aAAa,IAAI;AAAA,IACtE,SAAS,YAAY,oBAAoB,OAAO;AAAA,EAClD;AACF;AAKO,SAAS,mBAAmB,EAAE,QAAQ,GAAG,MAAM,GAAgD;AACpG,SAAO,OAAO,KAAK;AACrB;","names":[]}
1
+ {"version":3,"sources":["../../src/components/button-look.ts"],"sourcesContent":["import { cn } from \"../lib/cn\";\nimport { FOCUS_RING_WIDTH } from \"./focus-ring\";\nimport type { KitLinkComponent, KitLinkProps } from \"../i18n/kit-labels\";\n\n/**\n * The button look, which {@link Button} and {@link IconButton} share (0.34, MOD-2, refactor\n * audit 2026-10). In ui.tsx it was the one knot in an otherwise one-way graph: Button drew\n * IconButton's pressed look, and IconButton drew Button's variant colours, its hover ink\n * and its router link. Both now draw from this module, which renders nothing, so neither\n * imports the other.\n *\n * Public through ./ui by name: `buttonClasses`, `TOUCH_TARGET_LARGE` and the four types.\n * The other exports are the two buttons' shared parts (`@internal`).\n */\n\n/**\n * A button's look.\n *\n * - `default`: the quiet action — a warm filled chip with a hairline border. What a\n * Button with no `variant` draws.\n * - `accent`: the strong one, solid `--brand` with `--brand-contrast` text — the one\n * call to action a view leads with. \"Accent\" is the button's role here, painted in\n * `--brand`; it is not the palette's second anchor colour, also called `accent`\n * (theme/palette-derive.ts), which no button paints.\n * - `secondary`: an outline. `ghost`: text or glyph only. `danger`: solid `--danger`,\n * for an action that destroys something. `link`: a text link that acts (a `<button>`).\n * - `primary` and `brand` are **deprecated** aliases of `default` and `accent` (0.49,\n * same look; a development build warns once per name). They are removed in 0.51, and\n * `primary` will not come back with another meaning.\n */\nexport type ButtonVariant =\n | \"default\"\n | \"accent\"\n | \"secondary\"\n | \"ghost\"\n | \"danger\"\n | \"link\"\n /** @deprecated 0.49 — use `default` (same look); removed in 0.51. */\n | \"primary\"\n /** @deprecated 0.49 — use `accent` (same look); removed in 0.51. */\n | \"brand\";\n\n/** @internal A {@link ButtonVariant} without the deprecated aliases. */\nexport type CanonicalButtonVariant = Exclude<ButtonVariant, \"primary\" | \"brand\">;\n\n/** The deprecated names and what they stand for — see {@link ButtonVariant}. */\nconst DEPRECATED_BUTTON_VARIANTS: Readonly<Record<string, CanonicalButtonVariant>> = {\n primary: \"default\",\n brand: \"accent\",\n};\n\n/** The kit's dev switch, as `lib/logger.ts` reads it: a development build, never a\n * production one (the consumer's bundler makes `import.meta.env.DEV` false) and never\n * under vitest. Read per call so a test can switch it on with `vi.stubEnv`. */\nfunction devWarnings(): boolean {\n return Boolean(import.meta.env?.DEV) && !import.meta.env?.VITEST;\n}\n\nconst warnedVariants = new Set<string>();\n\n/**\n * @internal A deprecated variant name warns once (per `component` and name) in a\n * development build, then reads as the name it stands for. `component` is the prop's\n * owner in the message (\"Button variant\", \"FormActions submitVariant\", …).\n */\nexport function warnDeprecatedVariant(component: string, name: string, replacement: string): void {\n if (!devWarnings()) return;\n const key = `${component}|${name}`;\n if (warnedVariants.has(key)) return;\n warnedVariants.add(key);\n console.warn(\n `[ui-kit] ${component}=\"${name}\" is deprecated: use \"${replacement}\" (same look). ` +\n `\"${name}\" is removed in 0.51.`,\n );\n}\n\n/** @internal `primary` → `default`, `brand` → `accent` (warning once in development);\n * every other variant as it is. Runs before every lookup keyed by the variant. */\nexport function resolveButtonVariant(variant: ButtonVariant, component = \"Button variant\"): CanonicalButtonVariant {\n const replacement = DEPRECATED_BUTTON_VARIANTS[variant];\n if (replacement === undefined) return variant as CanonicalButtonVariant;\n warnDeprecatedVariant(component, variant, replacement);\n return replacement;\n}\n\n// Shared base ring for every button-styled element. Kept as a named const so the\n// <Button> component and the {@link buttonClasses} helper draw from one source and\n// can never drift apart.\n//\n// The ring is the kit's focus frame (§5): {@link FOCUS_RING_WIDTH} wide, on keyboard focus\n// (`focus-visible`) — a tap or a click no longer leaves a ring on the button — in each\n// variant's own colour below.\n//\n// `[&_svg]:shrink-0` (0.32.2, kastlan's 0.32.1 report): an icon beside a label keeps its\n// size when the label wraps at Large. Without it the svg was a flex item like the text,\n// and a two-line label squeezed it to a sliver (\"New invoice\" in a page header at 360 px\n// / Extra large).\nconst BUTTON_BASE = cn(\n \"inline-flex items-center justify-center rounded-md font-medium transition-colors focus:outline-none disabled:opacity-50 disabled:cursor-not-allowed [&_svg]:shrink-0\",\n FOCUS_RING_WIDTH,\n);\n\n/**\n * The touch target at Large and Extra large (docs/text-size-harmonization.md §4): at\n * least 48 px, the size a finger needs whatever the type. In px on purpose, like the\n * field floor ({@link FIELD_TOUCH_TEXT}): it is a physical threshold, not a size that\n * follows the text. Normal keeps the kit's own heights; a `large:` variant, so it costs\n * no render and is right on the first paint.\n */\nexport const TOUCH_TARGET_LARGE = \"large:min-h-[48px] large:min-w-[48px]\";\n\n/** The 48 px target at Large for a control whose BOX cannot grow (a chip's or a tab's\n * action, IconButton `2xs`; Tabs' overflow cues, 0.48 KS N3): an invisible `::after`\n * centred on it takes the taps a finger lands round the glyph. Not public. */\nexport const HIT_AREA_LARGE =\n \"relative large:after:absolute large:after:inset-[calc((100%-48px)/2)] large:after:content-['']\";\n\nexport type ButtonSize = \"sm\" | \"md\";\n\n// Box geometry per size, split out of the base so the two cannot be merged into one\n// string that a size then has to fight. `md` is the pre-0.8.0 look, unchanged. `sm` is\n// the compact secondary action keksdose repeats by hand as `px-2 py-1 text-xs` (and\n// `px-2 py-0.5 text-xs`) on the buttons in a toolbar or a table header; one rung is\n// enough, so the two spellings meet at `py-1`. There is no `lg`: no app has asked for\n// a bigger text button (IconButton's `lg` is a touch target, not a text size). The\n// variant map comes AFTER the size, so `link`'s `p-0` still wins at either size.\n//\n// At Large and above (§4) `sm` grows to `md`'s height — the padding that makes its\n// 1rem line as tall as `md`'s 1.25rem one — and both reach the 48 px touch target. The\n// type stays `sm`'s: the compact button is still the quieter one. Not on `link`, which\n// is a word in a sentence and has no box to grow (see `buttonLook`).\nconst BUTTON_SIZES: Record<ButtonSize, string> = {\n md: \"gap-2 px-3 py-2 text-sm\",\n sm: \"gap-1.5 px-2 py-1 text-xs\",\n};\nconst BUTTON_SIZES_LARGE: Record<ButtonSize, string> = {\n md: \"large:min-h-[48px]\",\n sm: \"large:min-h-[48px] large:py-2.5\",\n};\n\n/**\n * The hover of a control that may sit on the page, a card or a well (0.33, B′ —\n * docs/colour-roles-harmonization.md §12.1, §12.10): the body ink at 7 %, TRANSLUCENT,\n * so it darkens (in dark, lightens) whatever is under it by the same step — 1.11:1 or\n * more on all three surfaces, both themes, with the label at 7:1 and up. It was\n * `--bg-surface-2`, which since 0.33 is the page itself in light: a ghost button on the\n * page would not have moved at all. Not `--bg-hover` either, which is mixed against the\n * CARD and stands only 1.04:1 from the page. IconButton's `custom` tone uses the same\n * recipe at 12 %.\n */\nexport const HOVER_INK = \"hover:bg-[color-mix(in_srgb,var(--text-primary)_7%,transparent)]\";\n\n// Warm, palette-token-driven so buttons blend with the fields + cards in every theme.\n// Actions default to a warm bordered look (`default` = filled warm chip, secondary =\n// outline); `accent` is the solid brand fill for the rare strong CTA; `danger` takes the\n// semantic `--danger` family (destructive semantics), so a consumer can re-point the\n// destructive hue instead of inheriting a hard-coded red. Focus rings follow each\n// variant's own accent: brand for the four neutral ones, danger for `danger`. Keyed by\n// the canonical names: {@link resolveButtonVariant} maps the deprecated ones first.\nexport const buttonVariantClasses: Record<CanonicalButtonVariant, string> = {\n default:\n // Hovers onto `--bg-active`, not `--border` (0.33, §12.9): More contrast steps the\n // border until it is a 3:1 LINE, and as a fill under the label that left 2.7:1.\n \"border border-subtle bg-surface-2 text-primary hover:bg-active focus-visible:ring-brand\",\n secondary: `border border-subtle bg-transparent text-primary ${HOVER_INK} focus-visible:ring-brand`,\n ghost: `bg-transparent text-primary ${HOVER_INK} focus-visible:ring-brand`,\n danger:\n \"bg-danger text-danger-contrast hover:bg-danger-hover focus-visible:ring-danger-strong\",\n accent:\n \"bg-brand text-brand-contrast hover:bg-brand-hover focus-visible:ring-brand\",\n // A text link that is still a `<button>` — keksdose's six hand-rolled\n // `<button className=\"text-brand underline\">` sites (\"Resend code\", \"Show all\",\n // \"Undo\" in a toast…), which act rather than navigate and so must not be anchors.\n // The box padding goes (`p-0`) so it sits in a sentence at the text's own size, but\n // the base's focus ring stays: those copies had `outline-none` and no ring, so\n // a keyboard user tabbing onto them saw nothing at all. `rounded-sm` keeps that\n // ring hugging the word instead of drawing a pill round it.\n //\n // Under More contrast the word reads `--brand-muted`, the brand AS TEXT (0.36,\n // colour-roles §13.3 decision 8): `--brand` is solved as a mark (3:1), and on More\n // contrast's darker light well Moss's read 3.68:1; the derived dark brands sit at 3.0\n // as text too. Both modes, since `contrast:` does not split them.\n link:\n \"rounded-sm p-0 bg-transparent text-brand underline-offset-4 hover:underline focus-visible:ring-brand contrast:text-brand-muted\",\n};\n\n/**\n * The text colour of a `link` or `ghost` button, over the variant's own.\n *\n * `muted` is the QUIET link keksdose hand-rolls in six places (tours-page:321's \"Mark\n * undone\", transaction-fields:176/215's \"Add line\" / \"Fill total\",\n * invoice-lines-table:376, invoice-review:464): secondary text that darkens to the\n * body colour under the pointer, for an action that must be findable but not compete\n * with the brand-coloured one beside it. `danger` is the same quiet look turning\n * `--danger` on hover — transaction-editor:401's \"Remove split\", which is a text\n * action that destroys something.\n *\n * A `tone` rather than a `link-muted` variant, because it is the axis IconButton\n * already has (`tone=\"muted\"`, `tone=\"danger\"`, same quiet-until-hover meaning), and\n * because the same two looks are wanted on `ghost`.\n *\n * `danger` also reaches the two NEUTRAL boxed variants, where the box carries it:\n * `secondary` becomes an outlined destructive button (`--danger` text and a\n * `--danger-border` outline, the quiet danger fill on hover) and `default` a soft one\n * (the quiet fill at rest, solid `--danger` on hover). kastlan's meeting-invitations-\n * tab.tsx \"Remove all\" is `variant=\"secondary\" className=\"text-destructive\"` — a\n * destructive action that must sit in a row of secondary buttons without shouting\n * like the solid `variant=\"danger\"` would, and whose red text alone left the border\n * and hover saying \"neutral\". `muted` stays a text look and does nothing on a box, and\n * `danger`, `accent` and `link`'s own colours are left alone: a tone that recoloured a\n * solid danger or accent button would be a second variant under another name.\n */\nexport type ButtonTone = \"default\" | \"muted\" | \"danger\";\n\n// `contrast:text-secondary` restates the tone's own colour under the `contrast:` variant,\n// so tailwind-merge drops the link's `contrast:text-brand-muted` (0.36): a quiet link\n// stays quiet under More contrast. On `ghost` it reads the same either way.\nconst BUTTON_TONES: Record<Exclude<ButtonTone, \"default\">, string> = {\n muted:\n \"text-secondary hover:text-primary disabled:hover:text-secondary contrast:text-secondary\",\n danger:\n \"text-secondary hover:text-danger focus-visible:ring-danger-strong disabled:hover:text-secondary contrast:text-secondary\",\n};\n\n/** Only the two transparent variants take every tone; see {@link ButtonTone}. */\nconst TONED_VARIANTS = new Set<CanonicalButtonVariant>([\"link\", \"ghost\"]);\n\n// `tone=\"danger\"` on the neutral boxed variants — see {@link ButtonTone}. Each string\n// restates every colour the variant sets (border, fill, text, hover, ring) so none of\n// the neutral ones survives the merge.\nconst BUTTON_BOXED_DANGER: Partial<Record<CanonicalButtonVariant, string>> = {\n // Not boxed, but its hover is a fill: the danger wash under the danger text, as\n // IconButton's danger tone (0.33). On B′'s translucent ink `--danger` fell to 4.2:1\n // over a light page.\n ghost: \"hover:bg-danger-soft\",\n secondary:\n \"border-danger bg-transparent text-danger hover:bg-danger-soft focus-visible:ring-danger-strong\",\n default:\n \"border-danger bg-danger-soft text-danger hover:border-[var(--danger)] hover:bg-danger hover:text-danger-contrast focus-visible:ring-danger-strong disabled:hover:border-danger disabled:hover:bg-danger-soft disabled:hover:text-danger\",\n};\n\n// `pressed` on a `link`: the brand colour and a heavier weight, which is exactly what\n// lenkbank's \"All speeds\" toggle paints by hand (gear/hysteresis-charts.tsx:482). After\n// the tone, so a pressed muted link is brand, and an unpressed one is quiet grey. Under\n// More contrast the brand as text, `--brand-muted`, at rest and hovered (0.36, as the\n// `link` variant), whatever the tone.\nexport const BUTTON_LINK_PRESSED =\n \"font-medium text-brand hover:text-brand disabled:hover:text-brand contrast:text-brand-muted contrast:hover:text-brand-muted contrast:disabled:hover:text-brand-muted\";\n\n/** The second argument of {@link buttonClasses} in its options form. */\nexport interface ButtonClassesOptions {\n /** See {@link ButtonProps.size}. */\n size?: ButtonSize;\n className?: string;\n}\n\n/**\n * Button classes for the rare case where the styling must land on a non-`<button>`\n * element that {@link Button} can't render — e.g. a router `<Link>` or a Radix\n * AlertDialog Action/Cancel (which must stay the Radix element). Everywhere a real\n * button works, prefer `<Button>`. Draws from the same base, size and variant maps as\n * `<Button>`, so the two stay in lockstep.\n *\n * The second argument is either the extra classes (the pre-0.8.0 form) or\n * `{ size, className }` — `buttonClasses(\"secondary\", { size: \"sm\" })` for keksdose's\n * compact toolbar links.\n */\nexport function buttonClasses(\n given: ButtonVariant = \"default\",\n classNameOrOptions?: string | ButtonClassesOptions,\n): string {\n const { size = \"md\", className } =\n typeof classNameOrOptions === \"object\" ? classNameOrOptions : { className: classNameOrOptions };\n const variant = resolveButtonVariant(given, \"buttonClasses variant\");\n return cn(\n BUTTON_BASE,\n BUTTON_SIZES[size],\n variant !== \"link\" && BUTTON_SIZES_LARGE[size],\n buttonVariantClasses[variant],\n className,\n );\n}\n\n// `pressed`: a toggle that is on. The brand glyph on the quiet brand fill — the\n// \"selected\" look of a Chip or a SegmentedControl option, so an on toggle reads as on\n// beside them. After the tone, so a pressed `muted` button is brand, not grey. On hover\n// the glyph turns brand-muted: `--brand` on `--brand-bg-hover` is 2.69–2.80 in four dark\n// presets, under an icon's 3:1 (colour-roles §12.12). Button's `pressed` draws it on its\n// boxed variants too, which is why it lives here and not in ./icon-button.\nexport const ICON_BUTTON_PRESSED =\n \"bg-brand-soft text-brand hover:bg-brand-soft-hover hover:text-brand-muted disabled:hover:bg-brand-soft disabled:hover:text-brand\";\n\n/** @internal The button look shared by the `<button>` and the `<a>` form. */\nexport function buttonLook(given: ButtonVariant, size: ButtonSize, stretch: boolean | undefined, tone: ButtonTone) {\n const variant = resolveButtonVariant(given);\n return cn(\n BUTTON_BASE,\n BUTTON_SIZES[size],\n // §4: `sm` at `md`'s height and the 48 px target at Large — not on a `link`. In the\n // order `buttonClasses` has it, so the two strings stay identical.\n variant !== \"link\" && BUTTON_SIZES_LARGE[size],\n // In a flex row next to a taller labelled field, `stretch` makes the button\n // fill the field's height so the two line up (self-stretch overrides the row's\n // align-items). No effect outside a flex row / when it's already the tallest.\n stretch && \"self-stretch\",\n buttonVariantClasses[variant],\n tone !== \"default\" && TONED_VARIANTS.has(variant) && BUTTON_TONES[tone],\n tone === \"danger\" && BUTTON_BOXED_DANGER[variant],\n );\n}\n\n/** @internal Calls a `renderLink` / provider link as a function, as ListItem and Chip\n * do — an inline `renderLink` arrow would otherwise be a new component type every\n * render. Both buttons' link forms render through it. */\nexport function RenderedButtonLink({ render, ...props }: KitLinkProps & { render: KitLinkComponent }) {\n return render(props);\n}\n"],"mappings":";AAAA,SAAS,UAAU;AACnB,SAAS,wBAAwB;AA6CjC,MAAM,6BAA+E;AAAA,EACnF,SAAS;AAAA,EACT,OAAO;AACT;AAKA,SAAS,cAAuB;AAC9B,SAAO,QAAQ,YAAY,KAAK,GAAG,KAAK,CAAC,YAAY,KAAK;AAC5D;AAEA,MAAM,iBAAiB,oBAAI,IAAY;AAOhC,SAAS,sBAAsB,WAAmB,MAAc,aAA2B;AAChG,MAAI,CAAC,YAAY,EAAG;AACpB,QAAM,MAAM,GAAG,SAAS,IAAI,IAAI;AAChC,MAAI,eAAe,IAAI,GAAG,EAAG;AAC7B,iBAAe,IAAI,GAAG;AACtB,UAAQ;AAAA,IACN,YAAY,SAAS,KAAK,IAAI,yBAAyB,WAAW,mBAC5D,IAAI;AAAA,EACZ;AACF;AAIO,SAAS,qBAAqB,SAAwB,YAAY,kBAA0C;AACjH,QAAM,cAAc,2BAA2B,OAAO;AACtD,MAAI,gBAAgB,OAAW,QAAO;AACtC,wBAAsB,WAAW,SAAS,WAAW;AACrD,SAAO;AACT;AAcA,MAAM,cAAc;AAAA,EAClB;AAAA,EACA;AACF;AASO,MAAM,qBAAqB;AAK3B,MAAM,iBACX;AAgBF,MAAM,eAA2C;AAAA,EAC/C,IAAI;AAAA,EACJ,IAAI;AACN;AACA,MAAM,qBAAiD;AAAA,EACrD,IAAI;AAAA,EACJ,IAAI;AACN;AAYO,MAAM,YAAY;AASlB,MAAM,uBAA+D;AAAA,EAC1E;AAAA;AAAA;AAAA,IAGE;AAAA;AAAA,EACF,WAAW,oDAAoD,SAAS;AAAA,EACxE,OAAO,+BAA+B,SAAS;AAAA,EAC/C,QACE;AAAA,EACF,QACE;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,EAaF,MACE;AACJ;AAiCA,MAAM,eAA+D;AAAA,EACnE,OACE;AAAA,EACF,QACE;AACJ;AAGA,MAAM,iBAAiB,oBAAI,IAA4B,CAAC,QAAQ,OAAO,CAAC;AAKxE,MAAM,sBAAuE;AAAA;AAAA;AAAA;AAAA,EAI3E,OAAO;AAAA,EACP,WACE;AAAA,EACF,SACE;AACJ;AAOO,MAAM,sBACX;AAoBK,SAAS,cACd,QAAuB,WACvB,oBACQ;AACR,QAAM,EAAE,OAAO,MAAM,UAAU,IAC7B,OAAO,uBAAuB,WAAW,qBAAqB,EAAE,WAAW,mBAAmB;AAChG,QAAM,UAAU,qBAAqB,OAAO,uBAAuB;AACnE,SAAO;AAAA,IACL;AAAA,IACA,aAAa,IAAI;AAAA,IACjB,YAAY,UAAU,mBAAmB,IAAI;AAAA,IAC7C,qBAAqB,OAAO;AAAA,IAC5B;AAAA,EACF;AACF;AAQO,MAAM,sBACX;AAGK,SAAS,WAAW,OAAsB,MAAkB,SAA8B,MAAkB;AACjH,QAAM,UAAU,qBAAqB,KAAK;AAC1C,SAAO;AAAA,IACL;AAAA,IACA,aAAa,IAAI;AAAA;AAAA;AAAA,IAGjB,YAAY,UAAU,mBAAmB,IAAI;AAAA;AAAA;AAAA;AAAA,IAI7C,WAAW;AAAA,IACX,qBAAqB,OAAO;AAAA,IAC5B,SAAS,aAAa,eAAe,IAAI,OAAO,KAAK,aAAa,IAAI;AAAA,IACtE,SAAS,YAAY,oBAAoB,OAAO;AAAA,EAClD;AACF;AAKO,SAAS,mBAAmB,EAAE,QAAQ,GAAG,MAAM,GAAgD;AACpG,SAAO,OAAO,KAAK;AACrB;","names":[]}
@@ -11,6 +11,9 @@ import type { ButtonSize, ButtonTone, ButtonVariant } from "./button-look.js";
11
11
  * link buttons submitting their form).
12
12
  */
13
13
  export interface ButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
14
+ /** `default` (the quiet action, what a Button without `variant` draws), `accent` (the
15
+ * strong call to action, solid brand), `secondary`, `ghost`, `danger` or `link`.
16
+ * `primary` / `brand` are deprecated aliases of `default` / `accent`. See {@link ButtonVariant}. */
14
17
  variant?: ButtonVariant;
15
18
  /** `md` (default) is the page's action button. `sm` is the compact one — 12px text
16
19
  * and `px-2 py-1` — for the secondary actions in a toolbar, a card header or a
@@ -26,7 +29,7 @@ export interface ButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
26
29
  ref?: Ref<HTMLButtonElement>;
27
30
  /** Text colour for `link` and `ghost`: `muted` (quiet, body colour on hover) or
28
31
  * `danger` (quiet, `--danger` on hover). `danger` also makes `secondary` an
29
- * outlined destructive button and `primary` a soft one; ignored by the other
32
+ * outlined destructive button and `default` a soft one; ignored by the other
30
33
  * variants. See {@link ButtonTone}. */
31
34
  tone?: ButtonTone;
32
35
  /**
@@ -20,7 +20,7 @@ function ButtonLink({
20
20
  replace,
21
21
  reloadDocument,
22
22
  disabled,
23
- variant = "primary",
23
+ variant = "default",
24
24
  size = "md",
25
25
  stretch,
26
26
  tone = "default",
@@ -86,7 +86,7 @@ function ButtonLink({
86
86
  );
87
87
  }
88
88
  function ButtonElement({
89
- variant = "primary",
89
+ variant = "default",
90
90
  size = "md",
91
91
  stretch,
92
92
  tone = "default",