@eifi1/ui-kit 0.32.2 → 0.33.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 (363) hide show
  1. package/README.md +16 -8
  2. package/dist/account/delete-account-setting.d.ts +10 -10
  3. package/dist/account/delete-account-setting.js +2 -1
  4. package/dist/account/delete-account-setting.js.map +1 -1
  5. package/dist/account/email-change-setting.d.ts +5 -0
  6. package/dist/account/email-change-setting.js +4 -3
  7. package/dist/account/email-change-setting.js.map +1 -1
  8. package/dist/account/sessions-setting.d.ts +4 -0
  9. package/dist/account/sessions-setting.js +3 -2
  10. package/dist/account/sessions-setting.js.map +1 -1
  11. package/dist/admin/admin-action-confirm.d.ts +10 -10
  12. package/dist/admin/admin-action-confirm.js.map +1 -1
  13. package/dist/admin/invitations-panel.d.ts +10 -10
  14. package/dist/admin/invitations-panel.js +4 -3
  15. package/dist/admin/invitations-panel.js.map +1 -1
  16. package/dist/admin/plan-change.d.ts +100 -0
  17. package/dist/admin/plan-change.js +208 -0
  18. package/dist/admin/plan-change.js.map +1 -0
  19. package/dist/admin/reviewer-scope-editor.d.ts +10 -10
  20. package/dist/admin/reviewer-scope-editor.js.map +1 -1
  21. package/dist/admin/role-select.d.ts +7 -7
  22. package/dist/admin/role-select.js.map +1 -1
  23. package/dist/admin/transfer-ownership-dialog.d.ts +10 -10
  24. package/dist/admin/user-roster.d.ts +7 -7
  25. package/dist/admin/user-roster.js +2 -2
  26. package/dist/admin/user-roster.js.map +1 -1
  27. package/dist/auth/auth-errors.d.ts +13 -2
  28. package/dist/auth/auth-errors.js +5 -1
  29. package/dist/auth/auth-errors.js.map +1 -1
  30. package/dist/auth/complete-name-dialog.d.ts +4 -1
  31. package/dist/auth/complete-name-dialog.js +2 -1
  32. package/dist/auth/complete-name-dialog.js.map +1 -1
  33. package/dist/auth/register-form.d.ts +10 -10
  34. package/dist/billing/billing-banner.d.ts +13 -11
  35. package/dist/billing/billing-banner.js.map +1 -1
  36. package/dist/billing/billing-labels.d.ts +10 -10
  37. package/dist/billing/billing-labels.js +10 -1
  38. package/dist/billing/billing-labels.js.map +1 -1
  39. package/dist/billing/billing-lock.d.ts +39 -12
  40. package/dist/billing/billing-lock.js +9 -3
  41. package/dist/billing/billing-lock.js.map +1 -1
  42. package/dist/billing/billing-overview.d.ts +154 -0
  43. package/dist/billing/billing-overview.js +2 -0
  44. package/dist/billing/billing-overview.js.map +1 -0
  45. package/dist/billing/billing-standing.d.ts +10 -10
  46. package/dist/billing/checkout-processing.d.ts +217 -0
  47. package/dist/billing/checkout-processing.js +198 -0
  48. package/dist/billing/checkout-processing.js.map +1 -0
  49. package/dist/billing/checkout-return.d.ts +51 -0
  50. package/dist/billing/checkout-return.js +43 -0
  51. package/dist/billing/checkout-return.js.map +1 -0
  52. package/dist/billing/pay-page.d.ts +210 -0
  53. package/dist/billing/pay-page.js +113 -0
  54. package/dist/billing/pay-page.js.map +1 -0
  55. package/dist/billing/plan-card.d.ts +10 -10
  56. package/dist/billing/plan-limit-notice.d.ts +10 -10
  57. package/dist/billing/plan-limit-toast.d.ts +143 -0
  58. package/dist/billing/plan-limit-toast.js +40 -0
  59. package/dist/billing/plan-limit-toast.js.map +1 -0
  60. package/dist/billing/subscription-actions.d.ts +38 -15
  61. package/dist/billing/subscription-actions.js +9 -2
  62. package/dist/billing/subscription-actions.js.map +1 -1
  63. package/dist/billing/subscription-status-chip.d.ts +9 -9
  64. package/dist/bin/eifi1-pay-page.mjs +166 -0
  65. package/dist/chart.d.ts +10 -10
  66. package/dist/components/account-chips.d.ts +6 -1
  67. package/dist/components/account-settings.d.ts +4 -1
  68. package/dist/components/account-settings.js +2 -1
  69. package/dist/components/account-settings.js.map +1 -1
  70. package/dist/components/amount-input.d.ts +10 -10
  71. package/dist/components/autocomplete.d.ts +2 -1
  72. package/dist/components/autocomplete.js +3 -1
  73. package/dist/components/autocomplete.js.map +1 -1
  74. package/dist/components/button-group.d.ts +10 -10
  75. package/dist/components/button-group.js +1 -1
  76. package/dist/components/button-group.js.map +1 -1
  77. package/dist/components/calculator.d.ts +10 -10
  78. package/dist/components/calculator.js +3 -3
  79. package/dist/components/calculator.js.map +1 -1
  80. package/dist/components/checkbox-group.d.ts +2 -1
  81. package/dist/components/checkbox-group.js.map +1 -1
  82. package/dist/components/checkbox.d.ts +2 -1
  83. package/dist/components/checkbox.js.map +1 -1
  84. package/dist/components/chip.d.ts +2 -1
  85. package/dist/components/chip.js +19 -17
  86. package/dist/components/chip.js.map +1 -1
  87. package/dist/components/choice-card.d.ts +3 -1
  88. package/dist/components/choice-card.js +1 -1
  89. package/dist/components/choice-card.js.map +1 -1
  90. package/dist/components/combobox.d.ts +4 -3
  91. package/dist/components/combobox.js.map +1 -1
  92. package/dist/components/confirm-dialog.d.ts +10 -10
  93. package/dist/components/confirm-dialog.js +6 -3
  94. package/dist/components/confirm-dialog.js.map +1 -1
  95. package/dist/components/copy-button.d.ts +11 -11
  96. package/dist/components/country-select.d.ts +2 -1
  97. package/dist/components/country-select.js.map +1 -1
  98. package/dist/components/danger-confirm.d.ts +10 -10
  99. package/dist/components/danger-confirm.js.map +1 -1
  100. package/dist/components/data-table-cells.d.ts +12 -1
  101. package/dist/components/data-table-filter-popover.d.ts +12 -1
  102. package/dist/components/data-table-filters.d.ts +12 -1
  103. package/dist/components/data-table-labels.d.ts +12 -1
  104. package/dist/components/data-table-pagination.d.ts +12 -1
  105. package/dist/components/data-table.d.ts +12 -1
  106. package/dist/components/data-table.js +3 -3
  107. package/dist/components/data-table.js.map +1 -1
  108. package/dist/components/date-picker.js +6 -4
  109. package/dist/components/date-picker.js.map +1 -1
  110. package/dist/components/entity-combobox.d.ts +3 -2
  111. package/dist/components/entity-combobox.js.map +1 -1
  112. package/dist/components/facing-pair.d.ts +10 -10
  113. package/dist/components/field-parts.d.ts +32 -4
  114. package/dist/components/field-parts.js +54 -19
  115. package/dist/components/field-parts.js.map +1 -1
  116. package/dist/components/file-button.d.ts +10 -10
  117. package/dist/components/file-button.js +3 -2
  118. package/dist/components/file-button.js.map +1 -1
  119. package/dist/components/file-dropzone.d.ts +11 -11
  120. package/dist/components/file-dropzone.js.map +1 -1
  121. package/dist/components/floating-panel.js +5 -5
  122. package/dist/components/floating-panel.js.map +1 -1
  123. package/dist/components/form-actions.d.ts +9 -9
  124. package/dist/components/form-actions.js +3 -3
  125. package/dist/components/form-actions.js.map +1 -1
  126. package/dist/components/iban-input.d.ts +10 -10
  127. package/dist/components/inline-edit-field.d.ts +2 -1
  128. package/dist/components/inline-edit-field.js +6 -6
  129. package/dist/components/inline-edit-field.js.map +1 -1
  130. package/dist/components/language-select.d.ts +10 -10
  131. package/dist/components/legal-page.d.ts +10 -10
  132. package/dist/components/legal.d.ts +10 -10
  133. package/dist/components/list.d.ts +2 -1
  134. package/dist/components/list.js +4 -5
  135. package/dist/components/list.js.map +1 -1
  136. package/dist/components/money-field.d.ts +10 -10
  137. package/dist/components/month-picker.js +54 -44
  138. package/dist/components/month-picker.js.map +1 -1
  139. package/dist/components/multi-entity-combobox.d.ts +3 -2
  140. package/dist/components/multi-entity-combobox.js.map +1 -1
  141. package/dist/components/number-field.d.ts +10 -10
  142. package/dist/components/number-input.d.ts +10 -10
  143. package/dist/components/numpad-sheet.d.ts +10 -10
  144. package/dist/components/numpad-sheet.js +2 -2
  145. package/dist/components/numpad-sheet.js.map +1 -1
  146. package/dist/components/phone-input.d.ts +10 -10
  147. package/dist/components/row-actions.d.ts +5 -5
  148. package/dist/components/row-actions.js +130 -32
  149. package/dist/components/row-actions.js.map +1 -1
  150. package/dist/components/series-chart.d.ts +10 -10
  151. package/dist/components/settings-fields.d.ts +10 -10
  152. package/dist/components/share-card.d.ts +7 -7
  153. package/dist/components/share-card.js +26 -10
  154. package/dist/components/share-card.js.map +1 -1
  155. package/dist/components/status-dot.d.ts +1 -0
  156. package/dist/components/status-dot.js +2 -2
  157. package/dist/components/status-dot.js.map +1 -1
  158. package/dist/components/swatch-picker.d.ts +2 -1
  159. package/dist/components/swatch-picker.js.map +1 -1
  160. package/dist/components/swipeable-row.d.ts +88 -17
  161. package/dist/components/swipeable-row.js +46 -9
  162. package/dist/components/swipeable-row.js.map +1 -1
  163. package/dist/components/switch.d.ts +2 -1
  164. package/dist/components/switch.js.map +1 -1
  165. package/dist/components/table.d.ts +2 -1
  166. package/dist/components/table.js +6 -0
  167. package/dist/components/table.js.map +1 -1
  168. package/dist/components/text-link.d.ts +9 -9
  169. package/dist/components/time-input.d.ts +10 -10
  170. package/dist/components/toggle-group.d.ts +2 -1
  171. package/dist/components/toggle-group.js +1 -1
  172. package/dist/components/toggle-group.js.map +1 -1
  173. package/dist/components/translation-review-editor.d.ts +4 -0
  174. package/dist/components/translation-review-editor.js +4 -3
  175. package/dist/components/translation-review-editor.js.map +1 -1
  176. package/dist/components/translation-review-parts.d.ts +1 -0
  177. package/dist/components/translation-review.d.ts +1 -0
  178. package/dist/components/translation-review.js +15 -18
  179. package/dist/components/translation-review.js.map +1 -1
  180. package/dist/components/ui.d.ts +9 -9
  181. package/dist/components/ui.js +47 -27
  182. package/dist/components/ui.js.map +1 -1
  183. package/dist/components/use-table-state.d.ts +12 -1
  184. package/dist/components/user-avatar.d.ts +1 -0
  185. package/dist/components/write-lock.d.ts +101 -13
  186. package/dist/components/write-lock.js +37 -13
  187. package/dist/components/write-lock.js.map +1 -1
  188. package/dist/{data-table-labels-kpAAVi6g.d.ts → data-table-labels-BJ5JE_X2.d.ts} +17 -0
  189. package/dist/data-table.d.ts +12 -1
  190. package/dist/{delete-account-setting-Dy4kJgUm.d.ts → delete-account-setting-DiPX4Vue.d.ts} +355 -41
  191. package/dist/feedback/feedback-attachment.d.ts +10 -10
  192. package/dist/feedback/feedback-attachment.js +7 -7
  193. package/dist/feedback/feedback-attachment.js.map +1 -1
  194. package/dist/feedback/feedback-context.d.ts +10 -10
  195. package/dist/feedback/feedback-crash.d.ts +10 -10
  196. package/dist/feedback/feedback-dialog.d.ts +10 -10
  197. package/dist/feedback/feedback-inbox.d.ts +9 -9
  198. package/dist/feedback/feedback-inbox.js +7 -3
  199. package/dist/feedback/feedback-inbox.js.map +1 -1
  200. package/dist/feedback/feedback-labels.d.ts +10 -10
  201. package/dist/feedback/feedback-menu.d.ts +3 -3
  202. package/dist/feedback/feedback-record.d.ts +10 -10
  203. package/dist/feedback/feedback-row-detail.d.ts +10 -10
  204. package/dist/feedback/feedback-row-detail.js.map +1 -1
  205. package/dist/feedback/feedback-status-undo.d.ts +10 -10
  206. package/dist/feedback/feedback-submit.d.ts +10 -10
  207. package/dist/feedback/feedback-swipe.d.ts +10 -10
  208. package/dist/feedback/feedback-swipe.js +3 -6
  209. package/dist/feedback/feedback-swipe.js.map +1 -1
  210. package/dist/feedback/feedback-table.d.ts +10 -10
  211. package/dist/feedback/feedback-table.js.map +1 -1
  212. package/dist/feedback/feedback-thread.d.ts +10 -10
  213. package/dist/feedback.d.ts +10 -10
  214. package/dist/hooks/use-file-drop.d.ts +10 -10
  215. package/dist/i18n/defaults.d.ts +10 -10
  216. package/dist/i18n/defaults.js +2 -0
  217. package/dist/i18n/defaults.js.map +1 -1
  218. package/dist/i18n/german.d.ts +10 -10
  219. package/dist/i18n/german.js +34 -1
  220. package/dist/i18n/german.js.map +1 -1
  221. package/dist/i18n/kit-labels.d.ts +4 -4
  222. package/dist/i18n/kit-labels.js.map +1 -1
  223. package/dist/i18n/languages.d.ts +10 -10
  224. package/dist/i18n/locales/de-CH.d.ts +10 -10
  225. package/dist/i18n/locales/en.d.ts +10 -10
  226. package/dist/i18n/locales/es.d.ts +10 -10
  227. package/dist/i18n/locales/es.js +34 -1
  228. package/dist/i18n/locales/es.js.map +1 -1
  229. package/dist/i18n/locales/fr.d.ts +10 -10
  230. package/dist/i18n/locales/fr.js +35 -1
  231. package/dist/i18n/locales/fr.js.map +1 -1
  232. package/dist/i18n/locales/hu.d.ts +10 -10
  233. package/dist/i18n/locales/hu.js +36 -1
  234. package/dist/i18n/locales/hu.js.map +1 -1
  235. package/dist/i18n/locales/it.d.ts +10 -10
  236. package/dist/i18n/locales/it.js +34 -1
  237. package/dist/i18n/locales/it.js.map +1 -1
  238. package/dist/i18n/locales/zh.d.ts +10 -10
  239. package/dist/i18n/locales/zh.js +34 -1
  240. package/dist/i18n/locales/zh.js.map +1 -1
  241. package/dist/i18n/review.d.ts +10 -10
  242. package/dist/i18n/review.js +8 -1
  243. package/dist/i18n/review.js.map +1 -1
  244. package/dist/index.d.ts +10 -5
  245. package/dist/index.js +40 -2
  246. package/dist/index.js.map +1 -1
  247. package/dist/landing/landing-actions.d.ts +10 -10
  248. package/dist/landing/landing-sections.d.ts +10 -10
  249. package/dist/landing/public-header.d.ts +10 -10
  250. package/dist/landing/routing.d.ts +10 -10
  251. package/dist/pay/.well-known/apple-developer-merchantid-domain-association +1 -0
  252. package/dist/pay/index.html +30 -0
  253. package/dist/pay/pay.css +126 -0
  254. package/dist/pay/pay.js +198 -0
  255. package/dist/rhf/fields.d.ts +10 -10
  256. package/dist/rhf/form.d.ts +10 -10
  257. package/dist/rhf.d.ts +10 -10
  258. package/dist/settings/settings-section.d.ts +10 -10
  259. package/dist/shell/app-shell.d.ts +9 -9
  260. package/dist/shell/top-bar-brand.d.ts +10 -10
  261. package/dist/shell/topbar-action-menu.d.ts +1 -0
  262. package/dist/shell.d.ts +10 -10
  263. package/dist/testing/css-variables.d.ts +74 -0
  264. package/dist/testing/css-variables.generated.d.ts +7 -0
  265. package/dist/testing/css-variables.generated.js +8 -0
  266. package/dist/testing/css-variables.generated.js.map +1 -0
  267. package/dist/testing/css-variables.js +93 -0
  268. package/dist/testing/css-variables.js.map +1 -0
  269. package/dist/testing/index.d.ts +1 -0
  270. package/dist/testing/index.js +12 -0
  271. package/dist/testing/index.js.map +1 -0
  272. package/dist/theme/contrast-tokens.d.ts +44 -1
  273. package/dist/theme/contrast-tokens.js +21 -0
  274. package/dist/theme/contrast-tokens.js.map +1 -1
  275. package/dist/theme/contrast.d.ts +2 -2
  276. package/dist/theme/palette-derive.js +60 -11
  277. package/dist/theme/palette-derive.js.map +1 -1
  278. package/dist/theme/palette-presets.js +45 -43
  279. package/dist/theme/palette-presets.js.map +1 -1
  280. package/dist/wizard/stepper-nav.d.ts +11 -11
  281. package/dist/wizard/stepper-nav.js.map +1 -1
  282. package/dist/wizard.d.ts +10 -10
  283. package/package.json +10 -2
  284. package/src/account/delete-account-setting.tsx +6 -1
  285. package/src/account/email-change-setting.tsx +9 -3
  286. package/src/account/sessions-setting.tsx +7 -2
  287. package/src/admin/admin-action-confirm.tsx +2 -1
  288. package/src/admin/invitations-panel.tsx +9 -3
  289. package/src/admin/plan-change.tsx +459 -0
  290. package/src/admin/reviewer-scope-editor.tsx +2 -1
  291. package/src/admin/role-select.tsx +2 -1
  292. package/src/admin/user-roster.tsx +5 -3
  293. package/src/auth/auth-errors.ts +25 -2
  294. package/src/auth/complete-name-dialog.tsx +6 -2
  295. package/src/billing/billing-banner.tsx +3 -1
  296. package/src/billing/billing-labels.ts +45 -0
  297. package/src/billing/billing-lock.ts +36 -3
  298. package/src/billing/billing-overview.ts +58 -0
  299. package/src/billing/checkout-processing.ts +391 -0
  300. package/src/billing/checkout-return.ts +90 -0
  301. package/src/billing/pay-page.ts +242 -0
  302. package/src/billing/plan-limit-toast.tsx +91 -0
  303. package/src/billing/subscription-actions.tsx +41 -8
  304. package/src/components/account-settings.tsx +6 -2
  305. package/src/components/autocomplete.tsx +5 -2
  306. package/src/components/button-group.tsx +4 -1
  307. package/src/components/calculator.tsx +10 -6
  308. package/src/components/checkbox-group.tsx +2 -2
  309. package/src/components/checkbox.tsx +2 -1
  310. package/src/components/chip.tsx +37 -32
  311. package/src/components/choice-card.tsx +4 -2
  312. package/src/components/combobox.tsx +4 -4
  313. package/src/components/confirm-dialog.tsx +13 -6
  314. package/src/components/country-select.tsx +2 -2
  315. package/src/components/danger-confirm.tsx +2 -2
  316. package/src/components/data-table.tsx +20 -9
  317. package/src/components/date-picker.tsx +10 -7
  318. package/src/components/entity-combobox.tsx +3 -3
  319. package/src/components/field-parts.tsx +61 -14
  320. package/src/components/file-button.tsx +5 -4
  321. package/src/components/file-dropzone.tsx +2 -2
  322. package/src/components/floating-panel.tsx +7 -5
  323. package/src/components/form-actions.tsx +12 -5
  324. package/src/components/inline-edit-field.tsx +13 -13
  325. package/src/components/list.tsx +10 -9
  326. package/src/components/month-picker.tsx +8 -2
  327. package/src/components/multi-entity-combobox.tsx +3 -3
  328. package/src/components/numpad-sheet.tsx +8 -4
  329. package/src/components/row-actions.tsx +208 -58
  330. package/src/components/share-card.tsx +67 -13
  331. package/src/components/status-dot.tsx +7 -7
  332. package/src/components/swatch-picker.tsx +2 -2
  333. package/src/components/swipeable-row.tsx +138 -27
  334. package/src/components/switch.tsx +2 -1
  335. package/src/components/table.tsx +8 -1
  336. package/src/components/toggle-group.tsx +5 -3
  337. package/src/components/translation-review-editor.tsx +8 -3
  338. package/src/components/translation-review.tsx +18 -22
  339. package/src/components/ui.tsx +92 -43
  340. package/src/components/write-lock.tsx +150 -24
  341. package/src/feedback/feedback-attachment.tsx +13 -9
  342. package/src/feedback/feedback-inbox.tsx +10 -6
  343. package/src/feedback/feedback-row-detail.tsx +3 -2
  344. package/src/feedback/feedback-swipe.tsx +6 -11
  345. package/src/feedback/feedback-table.tsx +2 -1
  346. package/src/i18n/defaults.ts +2 -0
  347. package/src/i18n/german.ts +38 -0
  348. package/src/i18n/kit-labels.tsx +4 -0
  349. package/src/i18n/locales/es.ts +34 -0
  350. package/src/i18n/locales/fr.ts +39 -0
  351. package/src/i18n/locales/hu.ts +37 -0
  352. package/src/i18n/locales/it.ts +35 -0
  353. package/src/i18n/locales/zh.ts +33 -0
  354. package/src/i18n/review.ts +7 -0
  355. package/src/index.ts +48 -4
  356. package/src/testing/css-variables.generated.ts +112 -0
  357. package/src/testing/css-variables.ts +191 -0
  358. package/src/testing/index.ts +13 -0
  359. package/src/theme/contrast-tokens.ts +53 -2
  360. package/src/theme/palette-derive.ts +87 -10
  361. package/src/theme/palette-presets.ts +45 -43
  362. package/src/wizard/stepper-nav.tsx +2 -1
  363. package/tokens.css +306 -46
@@ -12,6 +12,7 @@ import { DEFAULT_COMMON_LABELS, useKitLabels, useKitLink } from "../i18n/kit-lab
12
12
  import type { KitLinkComponent, KitLinkProps } from "../i18n/kit-labels";
13
13
  import { pickLinkRenderer, replacingClick, routerLinkNavigation } from "./text-link";
14
14
  import { useCommitReason } from "./write-lock";
15
+ import type { CommitScope } from "./write-lock";
15
16
  import { mergeDescribedBy } from "./choice-parts";
16
17
  import {
17
18
  CompactControlsContext,
@@ -24,6 +25,8 @@ import {
24
25
  useFieldHint,
25
26
  useLockReason,
26
27
  FLOATING_LABEL_STATIC,
28
+ LABEL_ABOVE_AT_LARGE,
29
+ LABEL_ABOVE_BOX,
27
30
  STATIC_LABEL_TYPE,
28
31
  type FieldHintParts,
29
32
  } from "./field-parts";
@@ -88,6 +91,18 @@ const BUTTON_SIZES_LARGE: Record<ButtonSize, string> = {
88
91
  sm: "large:min-h-[48px] large:py-2.5",
89
92
  };
90
93
 
94
+ /**
95
+ * The hover of a control that may sit on the page, a card or a well (0.33, B′ —
96
+ * docs/colour-roles-harmonization.md §12.1, §12.10): the body ink at 7 %, TRANSLUCENT,
97
+ * so it darkens (in dark, lightens) whatever is under it by the same step — 1.11:1 or
98
+ * more on all three surfaces, both themes, with the label at 7:1 and up. It was
99
+ * `--bg-surface-2`, which since 0.33 is the page itself in light: a ghost button on the
100
+ * page would not have moved at all. Not `--bg-hover` either, which is mixed against the
101
+ * CARD and stands only 1.04:1 from the page. IconButton's `custom` tone uses the same
102
+ * recipe at 12 %.
103
+ */
104
+ const HOVER_INK = "hover:bg-[color-mix(in_srgb,var(--text-primary)_7%,transparent)]";
105
+
91
106
  // Warm, palette-token-driven so buttons blend with the fields + cards in every theme.
92
107
  // Actions default to a warm bordered look (primary = filled warm chip, secondary =
93
108
  // outline); `brand` stays the solid accent for the rare strong CTA; `danger` takes the
@@ -96,11 +111,11 @@ const BUTTON_SIZES_LARGE: Record<ButtonSize, string> = {
96
111
  // variant's own accent: brand for the four neutral ones, danger for `danger`.
97
112
  const buttonVariantClasses: Record<ButtonVariant, string> = {
98
113
  primary:
99
- "border border-[var(--border)] bg-[var(--bg-surface-2)] text-[var(--text-primary)] hover:bg-[var(--border)] focus-visible:ring-[var(--brand)]",
100
- secondary:
101
- "border border-[var(--border)] bg-transparent text-[var(--text-primary)] hover:bg-[var(--bg-surface-2)] focus-visible:ring-[var(--brand)]",
102
- ghost:
103
- "bg-transparent text-[var(--text-primary)] hover:bg-[var(--bg-surface-2)] focus-visible:ring-[var(--brand)]",
114
+ // Hovers onto `--bg-active`, not `--border` (0.33, §12.9): More contrast steps the
115
+ // border until it is a 3:1 LINE, and as a fill under the label that left 2.7:1.
116
+ "border border-[var(--border)] bg-[var(--bg-surface-2)] text-[var(--text-primary)] hover:bg-active focus-visible:ring-[var(--brand)]",
117
+ secondary: `border border-[var(--border)] bg-transparent text-[var(--text-primary)] ${HOVER_INK} focus-visible:ring-[var(--brand)]`,
118
+ ghost: `bg-transparent text-[var(--text-primary)] ${HOVER_INK} focus-visible:ring-[var(--brand)]`,
104
119
  danger:
105
120
  "bg-[var(--danger)] text-[var(--danger-contrast)] hover:bg-[var(--danger-hover)] focus-visible:ring-[var(--danger-border-strong)]",
106
121
  brand:
@@ -158,6 +173,10 @@ const TONED_VARIANTS = new Set<ButtonVariant>(["link", "ghost"]);
158
173
  // restates every colour the variant sets (border, fill, text, hover, ring) so none of
159
174
  // the neutral ones survives the merge.
160
175
  const BUTTON_BOXED_DANGER: Partial<Record<ButtonVariant, string>> = {
176
+ // Not boxed, but its hover is a fill: the danger wash under the danger text, as
177
+ // IconButton's danger tone (0.33). On B′'s translucent ink `--danger` fell to 4.2:1
178
+ // over a light page.
179
+ ghost: "hover:bg-[var(--danger-bg)]",
161
180
  secondary:
162
181
  "border-[var(--danger-border)] bg-transparent text-[var(--danger)] hover:bg-[var(--danger-bg)] focus-visible:ring-[var(--danger-border-strong)]",
163
182
  primary:
@@ -268,9 +287,10 @@ export interface ButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
268
287
  * This button COMMITS — it saves, creates, deletes. Under a locked
269
288
  * {@link WriteLockProvider} it is disabled the `disabledReason` way, with the lock's
270
289
  * reason (which wins over a `disabledReason` of its own). No provider, or an
271
- * unlocked one: no effect. See {@link WriteLockProvider}.
290
+ * unlocked one: no effect. See {@link WriteLockProvider}; a {@link CommitScope} since
291
+ * 0.33 (`COMMIT_EXCEPT_BILLING`: every lock but a lapsed plan's).
272
292
  */
273
- commit?: boolean;
293
+ commit?: CommitScope;
274
294
  /**
275
295
  * Busy — the save is in flight: a spinner, `aria-busy`, and no second submit.
276
296
  *
@@ -727,8 +747,10 @@ const ICON_BUTTON_TONES: Record<ColouredTone, { quiet: string; toned: string }>
727
747
  },
728
748
  warning: {
729
749
  quiet:
730
- "text-[var(--text-placeholder)] hover:bg-[var(--warning-bg)] hover:text-[var(--warning)] focus-visible:ring-[var(--warning-border)]",
731
- toned: "text-[var(--warning)] hover:bg-[var(--warning-bg)] focus-visible:ring-[var(--warning-border)]",
750
+ "text-[var(--text-placeholder)] hover:bg-[var(--warning-bg)] hover:text-[var(--warning)] focus-visible:ring-warning-strong",
751
+ // The ring is warning's loud line (0.33, k27): `--warning-border`, amber-300, was a
752
+ // focus ring at 1.1:1 on the light surfaces.
753
+ toned: "text-[var(--warning)] hover:bg-[var(--warning-bg)] focus-visible:ring-warning-strong",
732
754
  },
733
755
  info: {
734
756
  quiet:
@@ -751,8 +773,8 @@ const ICON_BUTTON_TONES: Record<ColouredTone, { quiet: string; toned: string }>
751
773
  },
752
774
  };
753
775
 
754
- const ICON_BUTTON_MUTED =
755
- "text-[var(--text-placeholder)] hover:bg-[var(--bg-surface-2)] hover:text-[var(--text-primary)]";
776
+ // The translucent ink on hover, as Button's secondary/ghost (B′, see HOVER_INK).
777
+ const ICON_BUTTON_MUTED = `text-[var(--text-placeholder)] ${HOVER_INK} hover:text-[var(--text-primary)]`;
756
778
 
757
779
  /** Which tones sit quiet at rest when `quiet` is left out. */
758
780
  const QUIET_BY_DEFAULT: Record<ColouredTone, boolean> = {
@@ -796,9 +818,11 @@ const ICON_BUTTON_QUIET_DISABLED_REST = "disabled:hover:text-[var(--text-placeho
796
818
 
797
819
  // `pressed`: a toggle that is on. The brand glyph on the quiet brand fill — the
798
820
  // "selected" look of a Chip or a SegmentedControl option, so an on toggle reads as on
799
- // beside them. After the tone, so a pressed `muted` button is brand, not grey.
821
+ // beside them. After the tone, so a pressed `muted` button is brand, not grey. On hover
822
+ // the glyph turns brand-muted: `--brand` on `--brand-bg-hover` is 2.69–2.80 in four dark
823
+ // presets, under an icon's 3:1 (colour-roles §12.12).
800
824
  const ICON_BUTTON_PRESSED =
801
- "bg-[var(--brand-bg)] text-[var(--brand)] hover:bg-[var(--brand-bg-hover)] hover:text-[var(--brand)] disabled:hover:bg-[var(--brand-bg)] disabled:hover:text-[var(--brand)]";
825
+ "bg-[var(--brand-bg)] text-[var(--brand)] hover:bg-[var(--brand-bg-hover)] hover:text-brand-muted disabled:hover:bg-[var(--brand-bg)] disabled:hover:text-[var(--brand)]";
802
826
 
803
827
  // `variant="overlay"`: a round, translucent disc for a control that sits ON a photo
804
828
  // (keksdose's receipt-scan preview: close, rotate, retake over the camera image).
@@ -1076,8 +1100,8 @@ export interface IconButtonProps extends ButtonHTMLAttributes<HTMLButtonElement>
1076
1100
  disabledReasonDisplay?: DisabledReasonDisplay;
1077
1101
  /** This action COMMITS — {@link Button}'s `commit`: under a locked
1078
1102
  * {@link WriteLockProvider} it takes the `disabledReason` path with the lock's
1079
- * reason. No effect without a lock. */
1080
- commit?: boolean;
1103
+ * reason. No effect without a lock. A {@link CommitScope} since 0.33. */
1104
+ commit?: CommitScope;
1081
1105
  /** The `<button>` element — a prop in React 19, as on {@link Button}. */
1082
1106
  ref?: Ref<HTMLButtonElement>;
1083
1107
  /** Only on the link form — see {@link IconButtonLinkProps}. */
@@ -1584,8 +1608,9 @@ export const FIELD_WRITABLE_LOOK =
1584
1608
  // Extra top padding leaves room for a label that floats INSIDE the field (the
1585
1609
  // "filled" pattern) — the label sits in the top strip, the value below it. Used by
1586
1610
  // every labelled field (native + custom-dropdown triggers). twMerge lets pt/pb win
1587
- // over FIELD_BASE's py-2.
1588
- export const FIELD_FLOATING_PAD = "pt-4 pb-1";
1611
+ // over FIELD_BASE's py-2. At Large the label stands above the field (0.33, §10.17,
1612
+ // LABEL_ABOVE_AT_LARGE), so the strip goes and the field is FIELD_BASE's `py-2` again.
1613
+ export const FIELD_FLOATING_PAD = "pt-4 pb-1 large:py-2";
1589
1614
 
1590
1615
  // Error/required highlight for a field that is missing a value — a `--danger`
1591
1616
  // border and matching focus ring so the control itself shows what's wrong, not just
@@ -1705,12 +1730,22 @@ export function FieldChevron({ className, ...rest }: FieldChevronProps) {
1705
1730
  // Animated label that starts centred (as a placeholder) in an empty field and
1706
1731
  // floats up INSIDE the top strip on focus or once the field has a value. Sits on
1707
1732
  // the field's own surface, so no background chip and nothing to mismatch the card.
1733
+ //
1734
+ // At Large it is a static label ABOVE the field instead (0.33, §10.17): in flow, so it
1735
+ // wraps; always in its floated look, since there is no strip to float into and no
1736
+ // placeholder to stand in for; no transition, since it never moves.
1737
+ const LABEL_FLOATED_AT_LARGE =
1738
+ "large:text-caption large:leading-tight large:text-secondary large:transition-none";
1739
+
1708
1740
  export const FLOATING_LABEL_CLASS = cn(
1709
1741
  "pointer-events-none absolute start-3 top-2.5 text-sm text-[var(--text-placeholder)] transition-all",
1710
- "max-w-[calc(100%-1.5rem)] truncate",
1742
+ "max-w-[calc(100%-1.5rem)] truncate-until-large",
1711
1743
  "peer-focus:top-1 peer-focus:text-caption peer-focus:leading-tight peer-focus:text-[var(--text-secondary)]",
1712
1744
  "peer-[:not(:placeholder-shown)]:top-1 peer-[:not(:placeholder-shown)]:text-caption peer-[:not(:placeholder-shown)]:leading-tight peer-[:not(:placeholder-shown)]:text-[var(--text-secondary)]",
1713
1745
  "peer-disabled:opacity-50",
1746
+ LABEL_ABOVE_AT_LARGE,
1747
+ "large:block",
1748
+ LABEL_FLOATED_AT_LARGE,
1714
1749
  );
1715
1750
 
1716
1751
  // The animated label again, as a ROW that the label and its "?" share — same
@@ -1723,15 +1758,22 @@ export const FLOATING_LABEL_CLASS = cn(
1723
1758
  // because the controls that carry an animated label are the ones with something
1724
1759
  // at the end edge of the field — NumberInput's calculator is the case this was
1725
1760
  // written for. The label truncates a little sooner; the alternative was the "?"
1726
- // sitting on top of a button (steering-design feedback #48).
1761
+ // sitting on top of a button (steering-design feedback #48). At Large the row stands
1762
+ // above the field, as the label alone does, and nothing at the end edge is under it.
1727
1763
  const FLOATING_ROW_CLASS = cn(
1728
1764
  "pointer-events-none absolute start-3 top-2.5 flex items-center gap-1 transition-all",
1729
1765
  "max-w-[calc(100%-3rem)] text-sm text-[var(--text-placeholder)]",
1730
1766
  "peer-focus:top-1 peer-focus:text-caption peer-focus:leading-tight peer-focus:text-[var(--text-secondary)]",
1731
1767
  "peer-[:not(:placeholder-shown)]:top-1 peer-[:not(:placeholder-shown)]:text-caption peer-[:not(:placeholder-shown)]:leading-tight peer-[:not(:placeholder-shown)]:text-[var(--text-secondary)]",
1732
1768
  "peer-disabled:opacity-50",
1769
+ LABEL_ABOVE_AT_LARGE,
1770
+ LABEL_FLOATED_AT_LARGE,
1733
1771
  );
1734
1772
 
1773
+ /** The static label's row with its "?" at the field's end, in the top strip — and at
1774
+ * Large above the field (LABEL_ABOVE_AT_LARGE). */
1775
+ const STATIC_ROW_CLASS = cn("pointer-events-none absolute inset-x-3 top-1 flex items-center gap-1", LABEL_ABOVE_AT_LARGE);
1776
+
1735
1777
  /**
1736
1778
  * The one place that assembles a labelled field: a `relative` wrapper around the
1737
1779
  * control (`children`) plus a floating label inside the top strip. Pass `staticLabel`
@@ -1775,16 +1817,22 @@ export function FloatingField({
1775
1817
  }: FloatingFieldProps) {
1776
1818
  const withHint = hint !== undefined && !srOnlyLabel;
1777
1819
  const atEnd = withHint && staticLabel;
1820
+ // A label drawn over the field: at Large it stands above it instead (0.33, §10.17),
1821
+ // in the first row of this box (LABEL_ABOVE_BOX), though it FOLLOWS the control in
1822
+ // the DOM — `peer-*` reads the input before it.
1823
+ const above = label !== undefined && !srOnlyLabel;
1778
1824
  const labelEl = label !== undefined && (
1779
1825
  <label
1780
1826
  htmlFor={htmlFor}
1827
+ // In the first row at Large, out of LABEL_ABOVE_BOX's rule for the field's parts.
1828
+ data-field-label={above && !withHint ? "" : undefined}
1781
1829
  className={
1782
1830
  srOnlyLabel
1783
1831
  ? "sr-only"
1784
1832
  : atEnd
1785
- ? cn("pointer-events-none min-w-0 truncate", STATIC_LABEL_TYPE)
1833
+ ? cn("pointer-events-none min-w-0 truncate-until-large", STATIC_LABEL_TYPE)
1786
1834
  : withHint
1787
- ? "pointer-events-none min-w-0 truncate"
1835
+ ? "pointer-events-none min-w-0 truncate-until-large"
1788
1836
  : staticLabel
1789
1837
  ? FLOATING_LABEL_STATIC
1790
1838
  : FLOATING_LABEL_CLASS
@@ -1797,8 +1845,9 @@ export function FloatingField({
1797
1845
  // `relative` is the whole contract of this wrapper — the floating label and every
1798
1846
  // control that hangs off it (a reveal toggle, a chevron) are positioned against
1799
1847
  // this box — so it is merged through `cn` after the spread rather than left where
1800
- // a caller's stray `className` or `style` could unset it.
1801
- <div {...rest} className={cn("relative", className)}>
1848
+ // a caller's stray `className` or `style` could unset it. At Large, with a label
1849
+ // above, a grid: the label's row over the field's, the field's parts in the latter.
1850
+ <div {...rest} className={cn("relative", above && LABEL_ABOVE_BOX, className)}>
1802
1851
  {children}
1803
1852
  {withHint ? (
1804
1853
  // Static: `inset-x-3` rather than `start-3`, so a long label truncates at
@@ -1806,13 +1855,7 @@ export function FloatingField({
1806
1855
  // Animated: the row floats with the label and is only as wide as it needs
1807
1856
  // to be. Either way the hint keeps its width (`shrink-0`) and the label
1808
1857
  // is the one that gives way.
1809
- <div
1810
- className={
1811
- atEnd
1812
- ? "pointer-events-none absolute inset-x-3 top-1 flex items-center gap-1"
1813
- : FLOATING_ROW_CLASS
1814
- }
1815
- >
1858
+ <div data-field-label="" className={atEnd ? STATIC_ROW_CLASS : FLOATING_ROW_CLASS}>
1816
1859
  {labelEl}
1817
1860
  <span className="pointer-events-auto flex shrink-0 items-center">{hint}</span>
1818
1861
  </div>
@@ -2460,9 +2503,10 @@ export interface SelectProps extends Omit<SelectHTMLAttributes<HTMLSelectElement
2460
2503
  /**
2461
2504
  * This select COMMITS — choosing saves. Under a locked {@link WriteLockProvider} it is
2462
2505
  * locked the `disabledReason` way with the lock's reason (which wins over its own).
2463
- * No provider, or an unlocked one: no effect. Button's `commit`, for keksdose K3.
2506
+ * No provider, or an unlocked one: no effect. Button's `commit`, for keksdose K3; a
2507
+ * {@link CommitScope} since 0.33.
2464
2508
  */
2465
- commit?: boolean;
2509
+ commit?: CommitScope;
2466
2510
  }
2467
2511
 
2468
2512
  // The compact select: the field's colours, a toolbar button's height. `py-0` and a
@@ -2668,7 +2712,8 @@ export const Select = forwardRef<HTMLSelectElement, SelectProps>(function Select
2668
2712
  // The floated label takes the top strip of a list box too, so its first
2669
2713
  // row starts under the label rather than behind it. No bottom padding — see
2670
2714
  // `dress`.
2671
- listBox ? "pt-5 pb-0" : FIELD_FLOATING_PAD,
2715
+ // At Large the label stands above the field (§10.17): no strip.
2716
+ listBox ? "pt-5 pb-0 large:pt-2" : FIELD_FLOATING_PAD,
2672
2717
  "peer",
2673
2718
  dress,
2674
2719
  locked && FIELD_LOCKED,
@@ -2801,7 +2846,9 @@ function labelEdge(scrollTop: number, lineHeight: number): { height: number; sol
2801
2846
  */
2802
2847
  const TEXTAREA_LABEL_STRIP = cn(
2803
2848
  "pointer-events-none absolute inset-x-px top-px hidden h-4 rounded-t-[calc(0.375rem-1px)] bg-[var(--bg-surface)]",
2804
- "peer-focus:block peer-[:not(:placeholder-shown)]:block",
2849
+ // Never at Large (0.33, §10.17): the label stands above the field, so there is no
2850
+ // strip to keep scrolled text out of, and a layer here would hide the first line.
2851
+ "not-large:peer-focus:block not-large:peer-[:not(:placeholder-shown)]:block",
2805
2852
  "peer-disabled:bg-[var(--bg-surface-2)] peer-[[readonly]]:bg-[var(--bg-surface-2)]",
2806
2853
  TEXTAREA_LABEL_EDGE,
2807
2854
  );
@@ -3040,14 +3087,16 @@ export interface CardProps extends ComponentPropsWithoutRef<"div"> {
3040
3087
  tone?: CardTone;
3041
3088
  /**
3042
3089
  * How loud the `tone` frame is. `"strong"`: a 2px border in the tone's strong
3043
- * colour — `--danger-border-strong` for `danger`, the tone's own text colour
3044
- * (`--warning`, `--info`, `--success`) for the others, which have no
3045
- * `-border-strong` token of their own. For the one tile on a page that guards
3046
- * something destructive or security-relevant: keksdose's password / delete-account
3047
- * tile (shared/components/password-danger-card.tsx) wrote `toneFrameClass(tone)` over
3048
- * the Card by hand for the 2px rule, and `p-[15px]` under it so the content did not
3049
- * move. Here the extra pixel comes off the padding the same way (`padding` or the
3050
- * variant's own), so a strong card's content sits exactly where a soft one's does.
3090
+ * colour — `--danger-border-strong` for `danger`, `--warning-border-strong` for
3091
+ * `warning` (0.33: amber-600 in dark, where `--warning` stood 13:1 off the card and
3092
+ * shouted; docs/colour-roles-harmonization.md §5.2), the tone's own text colour
3093
+ * (`--info`, `--success`) for the two with no `-border-strong` token of their own.
3094
+ * For the one tile on a page that guards something destructive or security-relevant:
3095
+ * keksdose's password / delete-account tile (shared/components/password-danger-card.tsx)
3096
+ * wrote `toneFrameClass(tone)` over the Card by hand for the 2px rule, and `p-[15px]`
3097
+ * under it so the content did not move. Here the extra pixel comes off the padding the
3098
+ * same way (`padding` or the variant's own), so a strong card's content sits exactly
3099
+ * where a soft one's does.
3051
3100
  *
3052
3101
  * Default `"soft"`: the 1px `-border` it has always been. No effect without `tone`,
3053
3102
  * nor on `inset`, which has no border to make louder (its tone is a fill).
@@ -3135,7 +3184,7 @@ const CARD_TONE_WASH: Record<CardTone, string> = {
3135
3184
  };
3136
3185
 
3137
3186
  const CARD_TONES_STRONG: Record<CardTone, string> = {
3138
- warning: "border-2 border-[var(--warning)]",
3187
+ warning: "border-2 border-warning-strong",
3139
3188
  danger: "border-2 border-[var(--danger-border-strong)]",
3140
3189
  info: "border-2 border-[var(--info)]",
3141
3190
  success: "border-2 border-[var(--success)]",
@@ -12,15 +12,67 @@ export const DEFAULT_WRITE_LOCK_LABELS: WriteLockLabels = {
12
12
  reason: "You can view this but not change it.",
13
13
  };
14
14
 
15
- /** What {@link useWriteLock} returns: whether commits are locked here, and why. */
15
+ /**
16
+ * What kind of lock a source is (docs/billing-harmonization.md §12.36, ui-kit 0.33).
17
+ * `"demo"` (the read-only demo, `demo.writeLocked`) and `"billing"` (a lapsed plan,
18
+ * `billing.lockReason`) are the kit's; an app names its own — Kurvenschmiede's
19
+ * `"access"` (a curve shared to view) and `"customer"` (an account that owns nothing).
20
+ * A control exempts itself from a kind with {@link CommitScope}; a source without a
21
+ * kind is never exempted.
22
+ */
23
+ export type WriteLockKind = "demo" | "billing" | (string & {});
24
+
25
+ /** One locked source, as the provider holds it: its kind and its one sentence. */
26
+ export interface WriteLockHold {
27
+ kind: WriteLockKind | undefined;
28
+ reason: ReactNode;
29
+ }
30
+
31
+ /**
32
+ * A control's opt-in to the lock — the type of every `commit` prop in the kit.
33
+ *
34
+ * - `true`: every lock refuses the control (a save, an add, an invite).
35
+ * - `{ except: [kinds] }`: every lock but those kinds. A billing lock leaves the
36
+ * reader able to remove access, revoke sessions, delete the account and change their
37
+ * own settings (§3.3, §12.13), so the kit's parts for those take
38
+ * {@link COMMIT_EXCEPT_BILLING} built in, and an app's own controls of that sort do
39
+ * too.
40
+ * - `false` or left out: no lock reaches it.
41
+ *
42
+ * Under a demo lock and a billing lock together, a control exempt from billing is
43
+ * still refused, with the demo's reason.
44
+ */
45
+ export type CommitScope = boolean | { readonly except: readonly WriteLockKind[] };
46
+
47
+ /** Every lock but billing's: the scope of an action a lapsed plan still allows — removing
48
+ * access, the account's own settings, an operator's admin route (§12.13). One constant,
49
+ * so the call sites read alike. */
50
+ export const COMMIT_EXCEPT_BILLING = { except: ["billing"] } as const;
51
+
52
+ /**
53
+ * What {@link useWriteLock} returns: whether commits are locked here as a control with
54
+ * the scope asked for sees it, and why.
55
+ */
16
56
  export interface WriteLock {
17
57
  locked: boolean;
18
- /** The one sentence a locked commit shows in its tooltip. Always set while
19
- * `locked`; `undefined` when not. */
58
+ /** The one sentence a locked commit shows in its tooltip: the first hold in scope's.
59
+ * Always set while `locked`; `undefined` when not. */
20
60
  reason: ReactNode | undefined;
61
+ /** The first hold in scope's kind; `undefined` while unlocked, or for a source that
62
+ * names none. */
63
+ kind: WriteLockKind | undefined;
64
+ /**
65
+ * EVERY locked source the provider holds, in the order the server refuses them
66
+ * (§12.3: the demo's 403 before billing's 402) — unfiltered by the scope, for code
67
+ * that must know what else locks (a toast that names the plan, a swipe plan). Empty
68
+ * while the provider is unlocked.
69
+ */
70
+ holds: readonly WriteLockHold[];
21
71
  }
22
72
 
23
- const UNLOCKED: WriteLock = { locked: false, reason: undefined };
73
+ const NO_HOLDS: readonly WriteLockHold[] = [];
74
+
75
+ const UNLOCKED: WriteLock = { locked: false, reason: undefined, kind: undefined, holds: NO_HOLDS };
24
76
 
25
77
  const WriteLockContext = createContext<WriteLock>(UNLOCKED);
26
78
 
@@ -33,10 +85,27 @@ export interface WriteLockProviderProps {
33
85
  * already translated. Left out: `labels.reason`.
34
86
  */
35
87
  reason?: ReactNode;
88
+ /** What kind of lock this is — `"demo"`, `"billing"` or the app's own (see
89
+ * {@link WriteLockKind}), for a provider of one source. Left out, no control can be
90
+ * exempt from it. */
91
+ kind?: WriteLockKind;
92
+ /**
93
+ * Every locked source, in refusal order — what `combineWriteLocks` answers, so
94
+ * `<WriteLockProvider {...combineWriteLocks([...])}>` carries them. Given while
95
+ * `locked`, it wins over `kind` and `reason`; a hold without a reason takes
96
+ * `labels.reason`.
97
+ */
98
+ holds?: readonly WriteLockHold[];
36
99
  labels?: Partial<WriteLockLabels>;
37
100
  children?: ReactNode;
38
101
  }
39
102
 
103
+ // `null`/`""`/`false` count as "no reason given": a lock whose tooltip is empty would be
104
+ // a disabled button that cannot say why, the thing this replaces.
105
+ function given(reason: ReactNode): boolean {
106
+ return reason !== undefined && reason !== null && reason !== false && reason !== "";
107
+ }
108
+
40
109
  /**
41
110
  * The read-only lock — **everything renders; only the commit is locked.**
42
111
  *
@@ -61,36 +130,93 @@ export interface WriteLockProviderProps {
61
130
  * {@link useWriteLock} and disables itself. The lock is a courtesy, not a guard: the
62
131
  * server still refuses the write.
63
132
  *
133
+ * **A lock says its source** (0.33, docs/billing-harmonization.md §12.36). A lapsed
134
+ * plan locks creating and changing, but not removing access or the account's own
135
+ * settings (§3.3, §12.13), and the kit's parts for those hard-coded `commit` — so
136
+ * kastlan's app-wide lock locked a member's own sign-out everywhere. Each source now
137
+ * has a kind, the provider holds every locked one in order (`holds`, from
138
+ * `combineWriteLocks`), and a control's `commit` may name the kinds it is exempt from
139
+ * ({@link CommitScope}). The kit's parts follow §3.3 and §12.13 by themselves.
140
+ *
64
141
  * Nested providers: the nearest wins, so an inner `locked={false}` reopens a section
65
- * the caller may write (a viewer's own notes on a shared curve), and an inner lock
66
- * with its own reason (Kurvenschmiede's owner lock beside the viewer lock) overrides
67
- * the outer one's sentence.
142
+ * the caller may write (a viewer's own notes on a shared curve) — every source at
143
+ * once — and an inner lock with its own reason (Kurvenschmiede's owner lock beside the
144
+ * viewer lock) overrides the outer one's sentence and its holds.
68
145
  */
69
- export function WriteLockProvider({ locked, reason, labels, children }: WriteLockProviderProps) {
146
+ export function WriteLockProvider({ locked, reason, kind, holds, labels, children }: WriteLockProviderProps) {
70
147
  // The `writeLock` namespace: prop > `<UiKitProvider labels>` > English.
71
148
  const fallback = useKitLabels("writeLock", DEFAULT_WRITE_LOCK_LABELS, labels).reason;
72
- // `null`/`""`/`false` count as "no reason given": a lock whose tooltip is empty
73
- // would be a disabled button that cannot say why, the thing this replaces.
74
- const given = reason !== undefined && reason !== null && reason !== false && reason !== "";
75
- const resolved = locked ? (given ? reason : fallback) : undefined;
76
- const value = useMemo<WriteLock>(
77
- () => (locked ? { locked: true, reason: resolved } : UNLOCKED),
78
- [locked, resolved],
79
- );
149
+ // `holds` wins while it has any; otherwise this provider is one source of its own.
150
+ const several = locked && holds !== undefined && holds.length > 0 ? holds : undefined;
151
+ const firstKind = several ? several[0].kind : kind;
152
+ const firstGiven = several ? several[0].reason : reason;
153
+ const firstReason = given(firstGiven) ? firstGiven : fallback;
154
+ // Memoised on the first hold's kind and sentence — the strings an app passes — so a
155
+ // one-source lock keeps one context value across renders; a list of several is
156
+ // memoised on its own identity.
157
+ const many = several && several.length > 1 ? several : undefined;
158
+ const value = useMemo<WriteLock>(() => {
159
+ if (!locked) return UNLOCKED;
160
+ const all = many
161
+ ? many.map((hold) => ({ kind: hold.kind, reason: given(hold.reason) ? hold.reason : fallback }))
162
+ : [{ kind: firstKind, reason: firstReason }];
163
+ return { locked: true, reason: firstReason, kind: firstKind, holds: all };
164
+ }, [locked, many, firstKind, firstReason, fallback]);
80
165
  return <WriteLockContext.Provider value={value}>{children}</WriteLockContext.Provider>;
81
166
  }
82
167
 
83
- /** The nearest {@link WriteLockProvider}'s lock; unlocked with no provider. */
84
- export function useWriteLock(): WriteLock {
85
- return useContext(WriteLockContext);
168
+ /**
169
+ * Whether a hold refuses a control with this scope: `true` refuses with every hold,
170
+ * `{ except }` with every hold whose kind it does not name. A hold with no kind is
171
+ * never exempted — an app that forgot to name its source must not reopen by accident.
172
+ */
173
+ function inScope(hold: WriteLockHold, scope: CommitScope): boolean {
174
+ if (scope === true) return true;
175
+ if (scope === false) return false;
176
+ return hold.kind === undefined || !scope.except.includes(hold.kind);
177
+ }
178
+
179
+ /**
180
+ * The lock as a control with `scope` sees it — {@link useWriteLock}'s answer, pure, for
181
+ * a scope known only at call time: `useConfirm` reads the CALLER's lock when it is
182
+ * called, and applies each `confirm({ commit })`'s own scope to it when it runs.
183
+ *
184
+ * `undefined` and `false` see no lock (a control without `commit`); `true` sees the
185
+ * lock as it is; `{ except }` sees the first hold of another kind, with its reason
186
+ * and kind. `holds` is passed on unfiltered.
187
+ */
188
+ export function writeLockFor(lock: WriteLock, scope: CommitScope | undefined): WriteLock {
189
+ if (!lock.locked || scope === true) return lock;
190
+ // A lock built by hand (a test's, an app's own context) may carry no holds: it is
191
+ // then the one source it describes.
192
+ const holds = lock.holds.length > 0 ? lock.holds : [{ kind: lock.kind, reason: lock.reason }];
193
+ const first = scope === undefined ? undefined : holds.find((hold) => inScope(hold, scope));
194
+ if (!first) return { ...UNLOCKED, holds: lock.holds };
195
+ // The first in scope is the provider's own first: the lock as it is, same object.
196
+ if (first === holds[0]) return lock;
197
+ return { locked: true, reason: first.reason, kind: first.kind, holds: lock.holds };
198
+ }
199
+
200
+ /**
201
+ * The nearest {@link WriteLockProvider}'s lock, as a control with `scope` sees it —
202
+ * unlocked with no provider. Left out, the scope is `true`: every lock.
203
+ *
204
+ * For code that must KNOW rather than a control that opts in: a toast that says why,
205
+ * a swipe plan that leaves an action out, a hand-rolled toggle that disables itself.
206
+ * `useWriteLock(COMMIT_EXCEPT_BILLING)` answers "would a lapsed plan's lock refuse me?"
207
+ * as a removal sees it — what keksdose's `useWriteLockKind() !== "billing"` asked.
208
+ */
209
+ export function useWriteLock(scope: CommitScope = true): WriteLock {
210
+ return writeLockFor(useContext(WriteLockContext), scope);
86
211
  }
87
212
 
88
213
  /**
89
214
  * The `disabledReason` a control with `commit` should use: the lock's reason while
90
- * locked — it wins over the control's own, since it is the answer to the question the
91
- * hover is asking — else the control's own. Internal to the kit's commit controls.
215
+ * the lock refuses this scope — it wins over the control's own, since it is the answer
216
+ * to the question the hover is asking — else the control's own. Internal to the kit's
217
+ * commit controls.
92
218
  */
93
- export function useCommitReason(commit: boolean | undefined, own: ReactNode): ReactNode {
94
- const lock = useWriteLock();
95
- return commit && lock.locked ? lock.reason : own;
219
+ export function useCommitReason(commit: CommitScope | undefined, own: ReactNode): ReactNode {
220
+ const lock = writeLockFor(useContext(WriteLockContext), commit);
221
+ return lock.locked ? lock.reason : own;
96
222
  }
@@ -12,7 +12,7 @@ import { Camera, FileText, Image as ImageIcon, Paperclip, X } from "lucide-react
12
12
  import { Button, IconButton, Spinner } from "../components/ui";
13
13
  import type { ButtonSize, ButtonVariant } from "../components/ui";
14
14
  import { DEFAULT_FILE_PICKER_LABELS, formatAccept } from "../components/file-button";
15
- import { useCommitReason } from "../components/write-lock";
15
+ import { useCommitReason, type CommitScope } from "../components/write-lock";
16
16
  import { useKitFileLabels, useKitLabels } from "../i18n/kit-labels";
17
17
  import type { FeedbackAttachmentLabels } from "./feedback-dialog";
18
18
 
@@ -231,6 +231,10 @@ interface FeedbackAttachmentFieldBaseProps {
231
231
  * 592px, the page 614px wide). keksdose passed `className="min-w-0 max-w-full"` at both
232
232
  * its callers to stop it; here once, for every host. In normal flow — a dialog, a note
233
233
  * editor — both are no-ops.
234
+ *
235
+ * The names and sizes under it are `truncate-until-large` (0.33, docs/text-size-harmonization.md
236
+ * §10.17): "…" at Normal, and from Large up they wrap, breaking a long file name
237
+ * anywhere, so the bound above still holds and nothing is cut.
234
238
  */
235
239
  const FIELD_ROOT = "relative min-w-0 max-w-full";
236
240
 
@@ -334,7 +338,7 @@ export interface FeedbackAttachmentFieldRefsProps extends FeedbackAttachmentFiel
334
338
  * with the lock's reason, which wins over one of its own. No provider, or an unlocked
335
339
  * one: no effect.
336
340
  */
337
- commit?: boolean;
341
+ commit?: CommitScope;
338
342
  /**
339
343
  * Why no file can be added or removed — the write lock's sentence, a quota, a thread
340
344
  * closed to replies. Unlike `disabled`, it SAYS so, the kit's commit-control way: the
@@ -478,7 +482,7 @@ function SingleField({
478
482
  </div>
479
483
  )}
480
484
  <div className="min-w-0 flex-1">
481
- <div className="truncate text-sm text-[var(--text-secondary)]">{value.name}</div>
485
+ <div className="truncate-until-large text-sm text-[var(--text-secondary)]">{value.name}</div>
482
486
  <div className="text-xs text-[var(--text-muted)]">
483
487
  {fileText.size(value.size)}
484
488
  </div>
@@ -662,8 +666,8 @@ function MultipleField({
662
666
  >
663
667
  <ChipPreview file={chip.file} screenshot={chip.screenshot} />
664
668
  <div className="min-w-0 flex-1">
665
- <div className="truncate text-sm text-[var(--text-secondary)]">{chip.name}</div>
666
- <div className="truncate text-xs text-[var(--text-muted)]">
669
+ <div className="truncate-until-large text-sm text-[var(--text-secondary)]">{chip.name}</div>
670
+ <div className="truncate-until-large text-xs text-[var(--text-muted)]">
667
671
  {/* The screenshot's file name moves down here: still there for the
668
672
  reporter who wants to know what will be sent, not its title. */}
669
673
  {chip.screenshot ? `${chip.file.name} · ${fileText.size(chip.file.size)}` : fileText.size(chip.file.size)}
@@ -899,9 +903,9 @@ function RefsField({
899
903
  >
900
904
  <RefGlyph type={ref.type} />
901
905
  <div className="min-w-0 flex-1">
902
- <div className="truncate text-sm text-[var(--text-secondary)]">{ref.name}</div>
906
+ <div className="truncate-until-large text-sm text-[var(--text-secondary)]">{ref.name}</div>
903
907
  {ref.size !== undefined && (
904
- <div className="truncate text-xs text-[var(--text-muted)]">{fileText.size(ref.size)}</div>
908
+ <div className="truncate-until-large text-xs text-[var(--text-muted)]">{fileText.size(ref.size)}</div>
905
909
  )}
906
910
  </div>
907
911
  <RemoveButton
@@ -923,8 +927,8 @@ function RefsField({
923
927
  <Spinner label={null} className="size-5" />
924
928
  </span>
925
929
  <div className="min-w-0 flex-1">
926
- <div className="truncate text-sm text-[var(--text-secondary)]">{upload.file.name}</div>
927
- <div className="truncate text-xs text-[var(--text-muted)]">{uploadingLabel}</div>
930
+ <div className="truncate-until-large text-sm text-[var(--text-secondary)]">{upload.file.name}</div>
931
+ <div className="truncate-until-large text-xs text-[var(--text-muted)]">{uploadingLabel}</div>
928
932
  </div>
929
933
  </li>
930
934
  ))}
@@ -20,7 +20,7 @@ import { cn } from "../lib/cn";
20
20
  import { Button, Textarea } from "../components/ui";
21
21
  import type { ButtonSize, ButtonVariant } from "../components/ui";
22
22
  import { Tooltip } from "../components/tooltip";
23
- import { useCommitReason, useWriteLock } from "../components/write-lock";
23
+ import { useCommitReason, useWriteLock, type CommitScope } from "../components/write-lock";
24
24
  import { FeedbackAttachmentField, type FeedbackAttachmentErrorInfo } from "./feedback-attachment";
25
25
  import type { FeedbackAttachmentLabels } from "./feedback-dialog";
26
26
  import { useFeedbackCategoryLabels, useFeedbackStatusLabels } from "./feedback-labels";
@@ -401,7 +401,7 @@ export function FeedbackStatusTransitions({
401
401
  * every app's lock is about (keksdose's shell lock is a read-only demo BUDGET, and a
402
402
  * report is not budget data).
403
403
  */
404
- commit?: boolean;
404
+ commit?: CommitScope;
405
405
  /** Why the statuses cannot be changed right now, the same way (a lock's own reason
406
406
  * wins over it). Only read while `canEdit`: a read-only row is a picture of the
407
407
  * workflow, not a refused action. */
@@ -461,7 +461,9 @@ export function FeedbackStatusTransitions({
461
461
  "inline-flex items-center gap-1.5 rounded-md border px-2 py-1 text-xs font-medium",
462
462
  active
463
463
  ? cn(meta.activeBg, meta.activeText, "border-transparent")
464
- : "border-[var(--border)] bg-transparent text-[var(--text-primary)] hover:bg-[var(--bg-surface-2)]",
464
+ : // The translucent ink on hover (0.33, B′), as Button's secondary:
465
+ // the well is the page in light, and these pills sit on either.
466
+ "border-[var(--border)] bg-transparent text-[var(--text-primary)] hover:bg-[color-mix(in_srgb,var(--text-primary)_7%,transparent)]",
465
467
  ),
466
468
  // Both stay unclickable, but only the read-only case is DIMMED: the
467
469
  // current status is the one thing in the row that has to be legible
@@ -587,7 +589,7 @@ export function FeedbackNoteEditor({
587
589
  /** Save COMMITS (0.27.0): under a locked `WriteLockProvider` it is disabled the
588
590
  * `disabledReason` way, with the lock's reason, and Ctrl/⌘+Enter saves nothing. The
589
591
  * text box stays editable — nothing in it reaches the server until the save. */
590
- commit?: boolean;
592
+ commit?: CommitScope;
591
593
  }) {
592
594
  const [draft, setDraft] = useState(initial);
593
595
  const [file, setFile] = useState<File | null>(null);
@@ -607,11 +609,13 @@ export function FeedbackNoteEditor({
607
609
  // (Steering Design feedback #140).
608
610
  const root = useRef<HTMLDivElement>(null);
609
611
  const hintId = useId();
610
- const lock = useWriteLock();
612
+ // The lock as the save button sees it, with its scope (§12.36): `commit &&
613
+ // lock.locked` read a `{ except }` scope as `true`.
614
+ const lock = useWriteLock(commit ?? false);
611
615
  const blank = required && !draft.trim();
612
616
  // The shortcut obeys what the button shows: no save while one is in flight, while the
613
617
  // note a `required` editor needs is missing, or while a `commit` save is locked.
614
- const canSubmit = !pending && !blank && !(commit && lock.locked);
618
+ const canSubmit = !pending && !blank && !lock.locked;
615
619
  const submit = () => onSave(draft, file);
616
620
  return (
617
621
  // Not a control: a delegated Ctrl/Cmd+Enter shortcut for the textarea inside,