@eifi1/ui-kit 0.4.0 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (410) hide show
  1. package/LICENSE +32 -0
  2. package/README.md +498 -57
  3. package/dist/chart.d.ts +10 -0
  4. package/dist/chart.js +10 -0
  5. package/dist/chart.js.map +1 -0
  6. package/dist/components/account-settings.d.ts +11 -0
  7. package/dist/components/account-settings.js +19 -12
  8. package/dist/components/account-settings.js.map +1 -1
  9. package/dist/components/alert-banner.d.ts +10 -6
  10. package/dist/components/alert-banner.js +20 -16
  11. package/dist/components/alert-banner.js.map +1 -1
  12. package/dist/components/amount-input.d.ts +30 -4
  13. package/dist/components/amount-input.js +36 -14
  14. package/dist/components/amount-input.js.map +1 -1
  15. package/dist/components/calculator.d.ts +24 -7
  16. package/dist/components/calculator.js +37 -20
  17. package/dist/components/calculator.js.map +1 -1
  18. package/dist/components/chart-zoom.d.ts +156 -0
  19. package/dist/components/chart-zoom.js +262 -0
  20. package/dist/components/chart-zoom.js.map +1 -0
  21. package/dist/components/chart.d.ts +28 -3
  22. package/dist/components/chart.js +71 -32
  23. package/dist/components/chart.js.map +1 -1
  24. package/dist/components/checkbox.d.ts +51 -0
  25. package/dist/components/checkbox.js +123 -0
  26. package/dist/components/checkbox.js.map +1 -0
  27. package/dist/components/chip.d.ts +128 -0
  28. package/dist/components/chip.js +357 -0
  29. package/dist/components/chip.js.map +1 -0
  30. package/dist/components/choice-parts.d.ts +24 -0
  31. package/dist/components/choice-parts.js +18 -0
  32. package/dist/components/choice-parts.js.map +1 -0
  33. package/dist/components/combobox-core.d.ts +52 -9
  34. package/dist/components/combobox-core.js +91 -20
  35. package/dist/components/combobox-core.js.map +1 -1
  36. package/dist/components/combobox.d.ts +43 -33
  37. package/dist/components/combobox.js +409 -340
  38. package/dist/components/combobox.js.map +1 -1
  39. package/dist/components/currency-select.d.ts +48 -8
  40. package/dist/components/currency-select.js +107 -65
  41. package/dist/components/currency-select.js.map +1 -1
  42. package/dist/components/data-table-filter-popover.d.ts +15 -3
  43. package/dist/components/data-table-filter-popover.js +23 -17
  44. package/dist/components/data-table-filter-popover.js.map +1 -1
  45. package/dist/components/data-table-filters.d.ts +1 -1
  46. package/dist/components/data-table-filters.js +3 -2
  47. package/dist/components/data-table-filters.js.map +1 -1
  48. package/dist/components/data-table-labels.d.ts +53 -2
  49. package/dist/components/data-table-labels.js +31 -2
  50. package/dist/components/data-table-labels.js.map +1 -1
  51. package/dist/components/data-table-pagination.d.ts +3 -1
  52. package/dist/components/data-table-pagination.js +16 -10
  53. package/dist/components/data-table-pagination.js.map +1 -1
  54. package/dist/components/data-table-sort.js +1 -0
  55. package/dist/components/data-table-sort.js.map +1 -1
  56. package/dist/components/data-table.d.ts +1 -1
  57. package/dist/components/data-table.js +201 -85
  58. package/dist/components/data-table.js.map +1 -1
  59. package/dist/components/date-picker.d.ts +25 -9
  60. package/dist/components/date-picker.js +232 -94
  61. package/dist/components/date-picker.js.map +1 -1
  62. package/dist/components/dropdown.d.ts +96 -20
  63. package/dist/components/dropdown.js +77 -33
  64. package/dist/components/dropdown.js.map +1 -1
  65. package/dist/components/entity-combobox.d.ts +5 -4
  66. package/dist/components/entity-combobox.js +107 -76
  67. package/dist/components/entity-combobox.js.map +1 -1
  68. package/dist/components/facing-pair.d.ts +36 -0
  69. package/dist/components/facing-pair.js +40 -0
  70. package/dist/components/facing-pair.js.map +1 -0
  71. package/dist/components/field-sync.d.ts +164 -0
  72. package/dist/components/field-sync.js +264 -0
  73. package/dist/components/field-sync.js.map +1 -0
  74. package/dist/components/file-dropzone.d.ts +38 -6
  75. package/dist/components/file-dropzone.js +34 -9
  76. package/dist/components/file-dropzone.js.map +1 -1
  77. package/dist/components/full-bleed-dialog.d.ts +37 -26
  78. package/dist/components/full-bleed-dialog.js +20 -5
  79. package/dist/components/full-bleed-dialog.js.map +1 -1
  80. package/dist/components/grouped-picker.d.ts +21 -10
  81. package/dist/components/grouped-picker.js +66 -52
  82. package/dist/components/grouped-picker.js.map +1 -1
  83. package/dist/components/hover-menu.d.ts +20 -4
  84. package/dist/components/hover-menu.js +15 -4
  85. package/dist/components/hover-menu.js.map +1 -1
  86. package/dist/components/mini-calendar.d.ts +102 -14
  87. package/dist/components/mini-calendar.js +256 -70
  88. package/dist/components/mini-calendar.js.map +1 -1
  89. package/dist/components/modal.d.ts +9 -3
  90. package/dist/components/modal.js +34 -45
  91. package/dist/components/modal.js.map +1 -1
  92. package/dist/components/month-picker.d.ts +79 -0
  93. package/dist/components/month-picker.js +342 -0
  94. package/dist/components/month-picker.js.map +1 -0
  95. package/dist/components/multi-entity-combobox.d.ts +7 -6
  96. package/dist/components/multi-entity-combobox.js +112 -72
  97. package/dist/components/multi-entity-combobox.js.map +1 -1
  98. package/dist/components/multi-select.d.ts +13 -11
  99. package/dist/components/multi-select.js +161 -84
  100. package/dist/components/multi-select.js.map +1 -1
  101. package/dist/components/number-field.d.ts +92 -0
  102. package/dist/components/number-field.js +111 -0
  103. package/dist/components/number-field.js.map +1 -0
  104. package/dist/components/number-input.d.ts +28 -2
  105. package/dist/components/number-input.js +12 -2
  106. package/dist/components/number-input.js.map +1 -1
  107. package/dist/components/numpad-sheet.d.ts +32 -4
  108. package/dist/components/numpad-sheet.js +30 -13
  109. package/dist/components/numpad-sheet.js.map +1 -1
  110. package/dist/components/page-contents.d.ts +90 -0
  111. package/dist/components/page-contents.js +170 -0
  112. package/dist/components/page-contents.js.map +1 -0
  113. package/dist/components/password-strength.d.ts +106 -0
  114. package/dist/components/password-strength.js +132 -0
  115. package/dist/components/password-strength.js.map +1 -0
  116. package/dist/components/picker-sheet.d.ts +23 -15
  117. package/dist/components/picker-sheet.js +34 -14
  118. package/dist/components/picker-sheet.js.map +1 -1
  119. package/dist/components/popover.d.ts +62 -4
  120. package/dist/components/popover.js +52 -2
  121. package/dist/components/popover.js.map +1 -1
  122. package/dist/components/search-field.d.ts +25 -7
  123. package/dist/components/search-field.js +7 -5
  124. package/dist/components/search-field.js.map +1 -1
  125. package/dist/components/series-chart-labels.d.ts +14 -0
  126. package/dist/components/series-chart-labels.js +11 -0
  127. package/dist/components/series-chart-labels.js.map +1 -0
  128. package/dist/components/series-chart.d.ts +207 -0
  129. package/dist/components/series-chart.js +223 -0
  130. package/dist/components/series-chart.js.map +1 -0
  131. package/dist/components/settings-fields.d.ts +14 -4
  132. package/dist/components/settings-fields.js +19 -8
  133. package/dist/components/settings-fields.js.map +1 -1
  134. package/dist/components/signature-pad.d.ts +119 -0
  135. package/dist/components/signature-pad.js +405 -0
  136. package/dist/components/signature-pad.js.map +1 -0
  137. package/dist/components/slider.d.ts +80 -0
  138. package/dist/components/slider.js +155 -0
  139. package/dist/components/slider.js.map +1 -0
  140. package/dist/components/sparkline.d.ts +93 -0
  141. package/dist/components/sparkline.js +205 -0
  142. package/dist/components/sparkline.js.map +1 -0
  143. package/dist/components/stat-tile.d.ts +151 -0
  144. package/dist/components/stat-tile.js +236 -0
  145. package/dist/components/stat-tile.js.map +1 -0
  146. package/dist/components/swipeable-row.d.ts +25 -2
  147. package/dist/components/swipeable-row.js +31 -0
  148. package/dist/components/swipeable-row.js.map +1 -1
  149. package/dist/components/switch.d.ts +60 -0
  150. package/dist/components/switch.js +81 -0
  151. package/dist/components/switch.js.map +1 -0
  152. package/dist/components/time-input.d.ts +58 -0
  153. package/dist/components/time-input.js +50 -0
  154. package/dist/components/time-input.js.map +1 -0
  155. package/dist/components/toggle-group.d.ts +18 -3
  156. package/dist/components/toggle-group.js +10 -6
  157. package/dist/components/toggle-group.js.map +1 -1
  158. package/dist/components/toggle-legend.d.ts +115 -0
  159. package/dist/components/toggle-legend.js +114 -0
  160. package/dist/components/toggle-legend.js.map +1 -0
  161. package/dist/components/tooltip.d.ts +40 -11
  162. package/dist/components/tooltip.js +77 -20
  163. package/dist/components/tooltip.js.map +1 -1
  164. package/dist/components/treemap.d.ts +132 -0
  165. package/dist/components/treemap.js +179 -0
  166. package/dist/components/treemap.js.map +1 -0
  167. package/dist/components/ui.d.ts +159 -46
  168. package/dist/components/ui.js +220 -115
  169. package/dist/components/ui.js.map +1 -1
  170. package/dist/components/use-mobile-reveal.js +1 -0
  171. package/dist/components/use-mobile-reveal.js.map +1 -1
  172. package/dist/components/use-table-state.d.ts +1 -1
  173. package/dist/components/use-table-state.js +38 -17
  174. package/dist/components/use-table-state.js.map +1 -1
  175. package/dist/components/user-avatar.d.ts +15 -4
  176. package/dist/components/user-avatar.js +5 -4
  177. package/dist/components/user-avatar.js.map +1 -1
  178. package/dist/components/wizard-stepper.js +3 -2
  179. package/dist/components/wizard-stepper.js.map +1 -1
  180. package/dist/{data-table-filters-aXyf0Xub.d.ts → data-table-filters-DM1auUfH.d.ts} +27 -5
  181. package/dist/data-table.d.ts +7 -0
  182. package/dist/data-table.js +12 -0
  183. package/dist/data-table.js.map +1 -0
  184. package/dist/feedback/feedback-attachment.js +12 -11
  185. package/dist/feedback/feedback-attachment.js.map +1 -1
  186. package/dist/feedback/feedback-dialog.js +2 -1
  187. package/dist/feedback/feedback-dialog.js.map +1 -1
  188. package/dist/feedback/feedback-inbox.d.ts +33 -7
  189. package/dist/feedback/feedback-inbox.js +49 -37
  190. package/dist/feedback/feedback-inbox.js.map +1 -1
  191. package/dist/feedback.d.ts +5 -0
  192. package/dist/feedback.js +5 -0
  193. package/dist/feedback.js.map +1 -0
  194. package/dist/hooks/use-anchored-panel.js +1 -0
  195. package/dist/hooks/use-anchored-panel.js.map +1 -1
  196. package/dist/hooks/use-anchored-rect.js +1 -0
  197. package/dist/hooks/use-anchored-rect.js.map +1 -1
  198. package/dist/hooks/use-announce.d.ts +46 -0
  199. package/dist/hooks/use-announce.js +37 -0
  200. package/dist/hooks/use-announce.js.map +1 -0
  201. package/dist/hooks/use-body-scroll-lock.js +1 -0
  202. package/dist/hooks/use-body-scroll-lock.js.map +1 -1
  203. package/dist/hooks/use-close-transition.js +1 -0
  204. package/dist/hooks/use-close-transition.js.map +1 -1
  205. package/dist/hooks/use-dismiss.js +1 -0
  206. package/dist/hooks/use-dismiss.js.map +1 -1
  207. package/dist/hooks/use-focus-trap.d.ts +16 -0
  208. package/dist/hooks/use-focus-trap.js +67 -0
  209. package/dist/hooks/use-focus-trap.js.map +1 -0
  210. package/dist/hooks/use-media-query.js +1 -0
  211. package/dist/hooks/use-media-query.js.map +1 -1
  212. package/dist/hooks/use-overlay-history.js +27 -1
  213. package/dist/hooks/use-overlay-history.js.map +1 -1
  214. package/dist/hooks/use-row-swipe.js +1 -0
  215. package/dist/hooks/use-row-swipe.js.map +1 -1
  216. package/dist/i18n/defaults.d.ts +31 -0
  217. package/dist/i18n/defaults.js +63 -0
  218. package/dist/i18n/defaults.js.map +1 -0
  219. package/dist/i18n/kit-labels.d.ts +250 -0
  220. package/dist/i18n/kit-labels.js +184 -0
  221. package/dist/i18n/kit-labels.js.map +1 -0
  222. package/dist/index.d.ts +47 -22
  223. package/dist/index.js +28 -0
  224. package/dist/index.js.map +1 -1
  225. package/dist/lib/calc.js +1 -0
  226. package/dist/lib/calc.js.map +1 -1
  227. package/dist/lib/cn.js +1 -0
  228. package/dist/lib/cn.js.map +1 -1
  229. package/dist/lib/dates.js +1 -0
  230. package/dist/lib/dates.js.map +1 -1
  231. package/dist/lib/logger.js +1 -0
  232. package/dist/lib/logger.js.map +1 -1
  233. package/dist/lib/safe-storage.js +1 -0
  234. package/dist/lib/safe-storage.js.map +1 -1
  235. package/dist/search/command-palette.d.ts +11 -1
  236. package/dist/search/command-palette.js +21 -15
  237. package/dist/search/command-palette.js.map +1 -1
  238. package/dist/search.d.ts +2 -0
  239. package/dist/search.js +3 -0
  240. package/dist/search.js.map +1 -0
  241. package/dist/shell/app-shell.d.ts +33 -4
  242. package/dist/shell/app-shell.js +275 -78
  243. package/dist/shell/app-shell.js.map +1 -1
  244. package/dist/shell/option-switcher-menu.js +3 -2
  245. package/dist/shell/option-switcher-menu.js.map +1 -1
  246. package/dist/shell/role-switcher.d.ts +6 -0
  247. package/dist/shell/role-switcher.js +7 -4
  248. package/dist/shell/role-switcher.js.map +1 -1
  249. package/dist/shell/top-bar.d.ts +15 -7
  250. package/dist/shell/top-bar.js +3 -5
  251. package/dist/shell/top-bar.js.map +1 -1
  252. package/dist/shell/topbar-action-menu.js +3 -2
  253. package/dist/shell/topbar-action-menu.js.map +1 -1
  254. package/dist/shell/topbar-controls.d.ts +5 -2
  255. package/dist/shell/topbar-controls.js +20 -15
  256. package/dist/shell/topbar-controls.js.map +1 -1
  257. package/dist/shell.d.ts +13 -0
  258. package/dist/shell.js +8 -0
  259. package/dist/shell.js.map +1 -0
  260. package/dist/theme/chart-palette.d.ts +19 -12
  261. package/dist/theme/chart-palette.js +24 -30
  262. package/dist/theme/chart-palette.js.map +1 -1
  263. package/dist/theme/color.d.ts +78 -0
  264. package/dist/theme/color.js +148 -0
  265. package/dist/theme/color.js.map +1 -0
  266. package/dist/theme/palette-derive.d.ts +140 -0
  267. package/dist/theme/palette-derive.js +376 -0
  268. package/dist/theme/palette-derive.js.map +1 -0
  269. package/dist/theme/palette-presets.d.ts +27 -5
  270. package/dist/theme/palette-presets.js +231 -33
  271. package/dist/theme/palette-presets.js.map +1 -1
  272. package/dist/theme/palette-store.js +1 -0
  273. package/dist/theme/palette-store.js.map +1 -1
  274. package/dist/theme/theme-store.js +1 -0
  275. package/dist/theme/theme-store.js.map +1 -1
  276. package/dist/tour/tour.d.ts +2 -1
  277. package/dist/tour/tour.js +15 -9
  278. package/dist/tour/tour.js.map +1 -1
  279. package/dist/tour.d.ts +2 -0
  280. package/dist/tour.js +3 -0
  281. package/dist/tour.js.map +1 -0
  282. package/dist/wizard/stepper-nav.js +10 -8
  283. package/dist/wizard/stepper-nav.js.map +1 -1
  284. package/dist/wizard/types.d.ts +18 -2
  285. package/dist/wizard/types.js +4 -1
  286. package/dist/wizard/types.js.map +1 -1
  287. package/dist/wizard/use-wizard.js +12 -4
  288. package/dist/wizard/use-wizard.js.map +1 -1
  289. package/dist/wizard/validation.js +1 -0
  290. package/dist/wizard/validation.js.map +1 -1
  291. package/dist/wizard/wizard-context.js +1 -0
  292. package/dist/wizard/wizard-context.js.map +1 -1
  293. package/dist/wizard/wizard-step.js +1 -0
  294. package/dist/wizard/wizard-step.js.map +1 -1
  295. package/dist/wizard/wizard-summary.js +5 -3
  296. package/dist/wizard/wizard-summary.js.map +1 -1
  297. package/dist/wizard.d.ts +8 -0
  298. package/dist/wizard.js +9 -0
  299. package/dist/wizard.js.map +1 -0
  300. package/package.json +67 -14
  301. package/src/chart.ts +22 -0
  302. package/src/components/account-settings.tsx +275 -0
  303. package/src/components/alert-banner.tsx +75 -0
  304. package/src/components/amount-input.tsx +471 -0
  305. package/src/components/calculator.tsx +252 -0
  306. package/src/components/chart-zoom.tsx +499 -0
  307. package/src/components/chart.tsx +363 -0
  308. package/src/components/checkbox.tsx +222 -0
  309. package/src/components/chip.tsx +570 -0
  310. package/src/components/choice-parts.ts +33 -0
  311. package/src/components/combobox-core.tsx +540 -0
  312. package/src/components/combobox.tsx +1026 -0
  313. package/src/components/currency-select.tsx +269 -0
  314. package/src/components/data-table-filter-popover.tsx +252 -0
  315. package/src/components/data-table-filters.ts +176 -0
  316. package/src/components/data-table-labels.ts +190 -0
  317. package/src/components/data-table-pagination.tsx +126 -0
  318. package/src/components/data-table-sort.ts +70 -0
  319. package/src/components/data-table.tsx +1740 -0
  320. package/src/components/date-picker.tsx +631 -0
  321. package/src/components/dropdown.tsx +341 -0
  322. package/src/components/entity-combobox.tsx +215 -0
  323. package/src/components/facing-pair.ts +86 -0
  324. package/src/components/field-sync.tsx +472 -0
  325. package/src/components/file-dropzone.tsx +181 -0
  326. package/src/components/full-bleed-dialog.tsx +179 -0
  327. package/src/components/grouped-picker.tsx +157 -0
  328. package/src/components/hover-menu.tsx +183 -0
  329. package/src/components/mini-calendar.tsx +554 -0
  330. package/src/components/modal.tsx +287 -0
  331. package/src/components/month-picker.tsx +549 -0
  332. package/src/components/multi-entity-combobox.tsx +226 -0
  333. package/src/components/multi-select.tsx +303 -0
  334. package/src/components/number-field.tsx +225 -0
  335. package/src/components/number-input.tsx +211 -0
  336. package/src/components/numpad-sheet.tsx +264 -0
  337. package/src/components/page-contents.tsx +289 -0
  338. package/src/components/password-strength.tsx +258 -0
  339. package/src/components/picker-sheet.tsx +251 -0
  340. package/src/components/popover.tsx +195 -0
  341. package/src/components/search-field.tsx +146 -0
  342. package/src/components/series-chart-labels.ts +26 -0
  343. package/src/components/series-chart.tsx +481 -0
  344. package/src/components/settings-fields.tsx +68 -0
  345. package/src/components/signature-pad.tsx +630 -0
  346. package/src/components/slider.tsx +313 -0
  347. package/src/components/sparkline.tsx +329 -0
  348. package/src/components/stat-tile.tsx +448 -0
  349. package/src/components/swipeable-row.tsx +271 -0
  350. package/src/components/switch.tsx +170 -0
  351. package/src/components/time-input.tsx +107 -0
  352. package/src/components/toggle-group.tsx +153 -0
  353. package/src/components/toggle-legend.tsx +229 -0
  354. package/src/components/tooltip.tsx +477 -0
  355. package/src/components/treemap.tsx +390 -0
  356. package/src/components/ui.tsx +1284 -0
  357. package/src/components/use-mobile-reveal.ts +54 -0
  358. package/src/components/use-table-state.ts +295 -0
  359. package/src/components/user-avatar.tsx +61 -0
  360. package/src/components/wizard-stepper.tsx +37 -0
  361. package/src/data-table.ts +19 -0
  362. package/src/feedback/feedback-attachment.tsx +246 -0
  363. package/src/feedback/feedback-dialog.tsx +209 -0
  364. package/src/feedback/feedback-inbox.tsx +615 -0
  365. package/src/feedback.ts +8 -0
  366. package/src/hooks/use-anchored-panel.ts +169 -0
  367. package/src/hooks/use-anchored-rect.ts +78 -0
  368. package/src/hooks/use-announce.ts +85 -0
  369. package/src/hooks/use-body-scroll-lock.ts +94 -0
  370. package/src/hooks/use-close-transition.ts +99 -0
  371. package/src/hooks/use-dismiss.ts +77 -0
  372. package/src/hooks/use-focus-trap.ts +160 -0
  373. package/src/hooks/use-media-query.ts +23 -0
  374. package/src/hooks/use-overlay-history.ts +345 -0
  375. package/src/hooks/use-row-swipe.ts +212 -0
  376. package/src/i18n/defaults.ts +68 -0
  377. package/src/i18n/kit-labels.tsx +447 -0
  378. package/src/index.ts +169 -0
  379. package/src/lib/calc.ts +309 -0
  380. package/src/lib/cn.ts +5 -0
  381. package/src/lib/dates.ts +187 -0
  382. package/src/lib/logger.ts +73 -0
  383. package/src/lib/safe-storage.ts +37 -0
  384. package/src/search/command-palette.tsx +342 -0
  385. package/src/search.ts +3 -0
  386. package/src/shell/app-shell.tsx +772 -0
  387. package/src/shell/option-switcher-menu.tsx +72 -0
  388. package/src/shell/role-switcher.tsx +46 -0
  389. package/src/shell/top-bar.tsx +39 -0
  390. package/src/shell/topbar-action-menu.tsx +91 -0
  391. package/src/shell/topbar-controls.tsx +229 -0
  392. package/src/shell.ts +10 -0
  393. package/src/theme/chart-palette.ts +145 -0
  394. package/src/theme/color.ts +266 -0
  395. package/src/theme/palette-derive.ts +681 -0
  396. package/src/theme/palette-presets.ts +760 -0
  397. package/src/theme/palette-store.ts +94 -0
  398. package/src/theme/theme-store.ts +145 -0
  399. package/src/tour/tour.tsx +586 -0
  400. package/src/tour.ts +3 -0
  401. package/src/vite-env.d.ts +26 -0
  402. package/src/wizard/stepper-nav.tsx +221 -0
  403. package/src/wizard/types.ts +178 -0
  404. package/src/wizard/use-wizard.ts +345 -0
  405. package/src/wizard/validation.ts +37 -0
  406. package/src/wizard/wizard-context.tsx +29 -0
  407. package/src/wizard/wizard-step.tsx +14 -0
  408. package/src/wizard/wizard-summary.tsx +69 -0
  409. package/src/wizard.ts +14 -0
  410. package/tokens.css +222 -20
@@ -0,0 +1,681 @@
1
+ import type { ThemeMode } from "./theme-store";
2
+ import type { TokenSet } from "./palette-presets";
3
+ import type { HeatStops } from "./chart-palette";
4
+ import {
5
+ contrast,
6
+ deltaE,
7
+ toHex,
8
+ hexToOklch,
9
+ oklchToHex,
10
+ parseHex,
11
+ rgbToOklch,
12
+ simulateCvd,
13
+ solveLightness,
14
+ } from "./color";
15
+ import type { CvdType, Oklch } from "./color";
16
+
17
+ /**
18
+ * Build a whole palette from one brand colour, plus any number of colours pinned by
19
+ * hand, with every contrast ratio SOLVED rather than chosen and then hoped for.
20
+ *
21
+ * The presets this package shipped were assembled by eye and annotated with the ratios
22
+ * they were believed to hold — `tokens.css` records "7.0:1 and 4.8:1", and
23
+ * `palette-presets.ts` records "2.67:1 … lifted they clear it at 3.30:1". Nothing
24
+ * computed any of them, and the audit found four of the nine default light chart hues
25
+ * below the 3:1 that WCAG 1.4.11 requires of a graphical object. A comment is not a
26
+ * test. Everything here is derived to a target and then measured, and
27
+ * {@link auditPalette} returns the measurements so a caller can refuse a palette that
28
+ * does not hold up.
29
+ *
30
+ * WHAT IS DERIVED AND WHAT IS NOT. The UI palette and the CATEGORICAL CHART RAMP solve
31
+ * different problems and are deliberately kept apart. A UI colour needs contrast against
32
+ * one known surface and carries a fixed meaning; a chart colour needs to stay separable
33
+ * from eight unknown siblings, at roughly equal salience, with no implied order.
34
+ * Optimising a set for one makes it worse at the other, which is why `deriveTokenSet`
35
+ * leaves `chart` alone unless you explicitly ask for {@link deriveChartRamp}.
36
+ */
37
+
38
+ /* ── Inputs ───────────────────────────────────────────────────────────────── */
39
+
40
+ /** A role whose hue may be pinned by the caller instead of chosen by the deriver. */
41
+ export type AnchorRole = "brand" | "accent" | "danger" | "warning" | "success" | "info";
42
+
43
+ export type PaletteAnchors = { brand: string } & Partial<Record<Exclude<AnchorRole, "brand">, string>>;
44
+
45
+ export interface DeriveOptions {
46
+ anchors: PaletteAnchors;
47
+ mode: ThemeMode;
48
+ /**
49
+ * How strongly the surfaces carry the brand's hue, 0–1 of the maximum useful tint.
50
+ * 0 gives neutral greys; the shipped default is a visible warmth, because a page that
51
+ * is very slightly the brand's hue reads as considered where a pure grey reads as
52
+ * unstyled. Above ~0.5 the surfaces start competing with the content.
53
+ */
54
+ surfaceTint?: number;
55
+ /** Contrast targets for the three text roles against the WORST surface. */
56
+ textContrast?: { primary?: number; secondary?: number; muted?: number };
57
+ }
58
+
59
+ /* ── Outputs ──────────────────────────────────────────────────────────────── */
60
+
61
+ /** The semantic layer, which lives in tokens.css rather than in `TokenSet`. */
62
+ export interface SemanticTokens {
63
+ danger: string;
64
+ dangerHover: string;
65
+ dangerContrast: string;
66
+ dangerBorder: string;
67
+ dangerBg: string;
68
+ warning: string;
69
+ warningBorder: string;
70
+ warningBg: string;
71
+ info: string;
72
+ infoBorder: string;
73
+ infoBg: string;
74
+ success: string;
75
+ successBorder: string;
76
+ successBg: string;
77
+ /** Present only when a `accent` (secondary brand) anchor was supplied. */
78
+ accent?: string;
79
+ accentContrast?: string;
80
+ accentBg?: string;
81
+ }
82
+
83
+ export interface ContrastCheck {
84
+ /** e.g. "textMuted on bgPage". */
85
+ pair: string;
86
+ ratio: number;
87
+ /** What WCAG asks of this pair. */
88
+ required: number;
89
+ /** Which rule the requirement comes from, for a reader who wants to argue with it. */
90
+ rule: string;
91
+ passes: boolean;
92
+ }
93
+
94
+ export interface ContrastReport {
95
+ checks: ContrastCheck[];
96
+ failures: ContrastCheck[];
97
+ passes: boolean;
98
+ }
99
+
100
+ export interface DerivedPalette {
101
+ tokens: TokenSet;
102
+ semantic: SemanticTokens;
103
+ audit: ContrastReport;
104
+ /** Compromises the deriver had to make, in plain words. Never silent. */
105
+ warnings: string[];
106
+ }
107
+
108
+ /* ── The hue homes ────────────────────────────────────────────────────────── */
109
+
110
+ /**
111
+ * Where each semantic role wants to sit, and how far it may be pushed.
112
+ *
113
+ * A semantic colour is a convention before it is an aesthetic: danger is red because
114
+ * every other interface the user has ever used made it red, and a "danger" button in
115
+ * the brand's teal is a worse button however well it matches. So each role has a home
116
+ * hue and a narrow licence to move — enough to get out of the brand's way when the
117
+ * brand happens to live in the same neighbourhood, not enough to stop meaning what it
118
+ * means. If it cannot get far enough, that is a warning, not a silent compromise.
119
+ */
120
+ const HUE_HOME: Record<Exclude<AnchorRole, "brand" | "accent">, { hue: number; slack: number }> = {
121
+ danger: { hue: 27, slack: 14 },
122
+ warning: { hue: 75, slack: 16 },
123
+ success: { hue: 150, slack: 20 },
124
+ info: { hue: 240, slack: 22 },
125
+ };
126
+
127
+ /** Circular distance between two hue angles, 0–180. */
128
+ function hueGap(a: number, b: number): number {
129
+ const d = Math.abs(((a - b) % 360) + 360) % 360;
130
+ return d > 180 ? 360 - d : d;
131
+ }
132
+
133
+ /**
134
+ * Nudge `home` within `slack` to maximise the distance from every taken hue.
135
+ *
136
+ * Sampled rather than solved: the objective is a min-of-distances over a handful of
137
+ * points on a short arc, which is piecewise-linear with kinks wherever the nearest
138
+ * neighbour changes. Gradient methods stall on those kinks; 2° steps over a ±22° arc is
139
+ * 23 evaluations of some trigonometry and cannot stall.
140
+ */
141
+ function placeHue(home: number, slack: number, taken: number[]): number {
142
+ let best = home;
143
+ let bestGap = Math.min(...taken.map((t) => hueGap(home, t)), 360);
144
+ for (let d = -slack; d <= slack; d += 2) {
145
+ const h = (home + d + 360) % 360;
146
+ const gap = Math.min(...taken.map((t) => hueGap(h, t)), 360);
147
+ // Ties go to the hue nearest home: a role should move only as far as it must.
148
+ if (gap > bestGap + 1e-9) {
149
+ best = h;
150
+ bestGap = gap;
151
+ }
152
+ }
153
+ return best;
154
+ }
155
+
156
+ /* ── Derivation ───────────────────────────────────────────────────────────── */
157
+
158
+ const SURFACE_L = {
159
+ // Light: the PAGE is slightly darker than the cards on it, so a card reads as raised
160
+ // without needing a shadow to say so. Matches the shipped presets' relationship.
161
+ light: { page: 0.912, surface: 0.945, surface2: 0.929 },
162
+ dark: { page: 0.158, surface: 0.206, surface2: 0.249 },
163
+ } as const;
164
+
165
+ /**
166
+ * What a hairline has to clear against the surface it divides.
167
+ *
168
+ * WCAG has nothing to say here — a decorative border is not a graphical object that
169
+ * conveys information — so this is a house rule, and it is deliberately low. A border
170
+ * that clears 3:1 is a RULE, not a hairline: it draws the eye to the box instead of to
171
+ * what is in it. 1.5:1 is the point at which the edge is reliably findable without
172
+ * becoming part of the composition.
173
+ */
174
+ const BORDER_CONTRAST = 1.5;
175
+
176
+ /** The most tint a surface can carry before it stops reading as a neutral ground. */
177
+ const MAX_SURFACE_CHROMA = 0.034;
178
+
179
+ function surfaceSet(hue: number, mode: ThemeMode, tint: number) {
180
+ const c = MAX_SURFACE_CHROMA * Math.max(0, Math.min(1, tint));
181
+ const L = SURFACE_L[mode];
182
+ const bgPage = oklchToHex({ l: L.page, c, h: hue });
183
+ const bgSurface = oklchToHex({ l: L.surface, c, h: hue });
184
+ const bgSurface2 = oklchToHex({ l: L.surface2, c, h: hue });
185
+ // Solved, not guessed: a fixed lightness that looks right against a pale warm ground
186
+ // disappears against a deep cold one, and both are reachable from the same `hue`
187
+ // input. The border carries a little more chroma than the surfaces because at these
188
+ // lightnesses a perfectly neutral hairline on a tinted ground reads as a wrong grey.
189
+ const borderChroma = c * 1.6;
190
+ // Solved against the surface the hairline is HARDEST to see on, not against one of
191
+ // the three. Solving against `bgSurface` alone left it at 1.36:1 on `bgPage`, so the
192
+ // target and the audit disagreed about the same border.
193
+ const hardest = [bgPage, bgSurface, bgSurface2].reduce((a, b) =>
194
+ contrast(a, mode === "light" ? "#000000" : "#ffffff") <
195
+ contrast(b, mode === "light" ? "#000000" : "#ffffff")
196
+ ? a
197
+ : b,
198
+ );
199
+ const borderL = solveLightness(
200
+ BORDER_CONTRAST,
201
+ parseHex(hardest)!,
202
+ hue,
203
+ borderChroma,
204
+ mode === "light" ? "darker" : "lighter",
205
+ );
206
+ return {
207
+ bgPage,
208
+ bgSurface,
209
+ bgSurface2,
210
+ border: oklchToHex({ l: borderL, c: borderChroma, h: hue }),
211
+ };
212
+ }
213
+
214
+ /** Solve a colour at this hue that clears `target` against `against`, keeping chroma. */
215
+ function solveAgainst(hue: number, chroma: number, target: number, against: string, mode: ThemeMode): string {
216
+ const bg = parseHex(against)!;
217
+ const l = solveLightness(target, bg, hue, chroma, mode === "light" ? "darker" : "lighter");
218
+ return oklchToHex({ l, c: chroma, h: hue });
219
+ }
220
+
221
+ /** Whichever of near-white / near-black reads better ON `fill`. */
222
+ function contrastOn(fill: string): string {
223
+ const light = "#ffffff";
224
+ const dark = "#12100e";
225
+ return contrast(fill, light) >= contrast(fill, dark) ? light : dark;
226
+ }
227
+
228
+ /** A soft tint of `hue` sitting on the surface — a badge fill, not a button fill. */
229
+ function tintOn(hue: number, chroma: number, surface: string, mode: ThemeMode): string {
230
+ const s = hexToOklch(surface)!;
231
+ // Toward the colour, but only far enough to be seen: these back TEXT of the same hue,
232
+ // and a strong tint leaves the text nowhere to go.
233
+ const l = mode === "light" ? s.l - 0.055 : s.l + 0.055;
234
+ return oklchToHex({ l, c: Math.min(chroma * 0.45, 0.06), h: hue });
235
+ }
236
+
237
+ export function derivePalette({
238
+ anchors,
239
+ mode,
240
+ surfaceTint = 0.55,
241
+ textContrast,
242
+ }: DeriveOptions): DerivedPalette {
243
+ const warnings: string[] = [];
244
+ const brand = hexToOklch(anchors.brand);
245
+ if (!brand) {
246
+ throw new Error(
247
+ `derivePalette: brand "${anchors.brand}" is not a #rgb or #rrggbb colour. ` +
248
+ `Anchors must be hex — a named colour or an rgba() string cannot be converted.`,
249
+ );
250
+ }
251
+
252
+ const targets = { primary: 10, secondary: 7, muted: 4.6, ...textContrast };
253
+
254
+ // ── Surfaces, from the brand's hue.
255
+ const surfaces = surfaceSet(brand.h, mode, surfaceTint);
256
+ // Every text role is solved against the WORST of the three, so a token is legible
257
+ // wherever it lands. Solving against `bgSurface` alone is the mistake that makes a
258
+ // muted label fail on the page background it also sits on.
259
+ const worstSurface = [surfaces.bgPage, surfaces.bgSurface, surfaces.bgSurface2].reduce((a, b) =>
260
+ contrast(a, mode === "light" ? "#000000" : "#ffffff") <
261
+ contrast(b, mode === "light" ? "#000000" : "#ffffff")
262
+ ? a
263
+ : b,
264
+ );
265
+
266
+ // ── Text. A trace of the brand hue keeps the greys from reading cold on a warm page.
267
+ const textChroma = 0.014;
268
+ const textPrimary = solveAgainst(brand.h, textChroma, targets.primary, worstSurface, mode);
269
+ const textSecondary = solveAgainst(brand.h, textChroma, targets.secondary, worstSurface, mode);
270
+ const textMuted = solveAgainst(brand.h, textChroma, targets.muted, worstSurface, mode);
271
+
272
+ // ── Brand. Keep the hue the caller asked for; move only the lightness, and only as
273
+ // far as 3:1 against the worst surface — WCAG 1.4.11, since a brand-filled control is
274
+ // a graphical object whose boundary has to be perceivable.
275
+ const brandChroma = Math.max(brand.c, 0.06);
276
+ const brandRatio = contrast(anchors.brand, worstSurface);
277
+ // NORMALISED, never the caller's raw string. `parseHex` accepts "4f46e5" and "#abc",
278
+ // and passing either through unchanged put a value in the TokenSet that is not a CSS
279
+ // colour — it then fails silently at paint time, three layers from here.
280
+ const brandHex =
281
+ brandRatio >= 3
282
+ ? toHex(parseHex(anchors.brand)!)
283
+ : solveAgainst(brand.h, brandChroma, 3, worstSurface, mode);
284
+ if (brandRatio < 3) {
285
+ warnings.push(
286
+ `brand ${anchors.brand} sits at ${brandRatio.toFixed(2)}:1 against the ${mode} surfaces, ` +
287
+ `below the 3:1 WCAG 1.4.11 asks of a control boundary. Lightness was adjusted to ` +
288
+ `${brandHex}; hue and chroma are unchanged.`,
289
+ );
290
+ }
291
+ const brandLch = hexToOklch(brandHex)!;
292
+ const brandHover = oklchToHex({
293
+ ...brandLch,
294
+ // Hover moves AWAY from the page in both themes: darker on light, lighter on dark.
295
+ l: mode === "light" ? brandLch.l - 0.06 : brandLch.l + 0.06,
296
+ });
297
+ const brandContrast = contrastOn(brandHex);
298
+ const onBrand = contrast(brandHex, brandContrast);
299
+ if (onBrand < 4.5) {
300
+ warnings.push(
301
+ `text on the brand fill reaches only ${onBrand.toFixed(2)}:1 (needs 4.5:1). ` +
302
+ `A mid-lightness, high-chroma brand cannot carry legible text at any ink; ` +
303
+ `use it as an accent and keep primary buttons on a darker or lighter variant.`,
304
+ );
305
+ }
306
+
307
+ // ── Semantic roles. Anchors are honoured exactly; the rest are placed away from the
308
+ // brand and from each other, within each role's licence to move.
309
+ const taken: number[] = [brandLch.h];
310
+ const accentLch = anchors.accent ? hexToOklch(anchors.accent) : null;
311
+ if (anchors.accent && !accentLch) {
312
+ warnings.push(`accent "${anchors.accent}" is not a hex colour and was ignored.`);
313
+ }
314
+ if (accentLch) taken.push(accentLch.h);
315
+
316
+ const semanticHue = {} as Record<Exclude<AnchorRole, "brand" | "accent">, number>;
317
+ const semanticChroma = {} as Record<Exclude<AnchorRole, "brand" | "accent">, number>;
318
+ for (const role of ["danger", "warning", "success", "info"] as const) {
319
+ const pinned = anchors[role] ? hexToOklch(anchors[role]!) : null;
320
+ if (anchors[role] && !pinned) {
321
+ warnings.push(`${role} "${anchors[role]}" is not a hex colour and was ignored.`);
322
+ }
323
+ const home = HUE_HOME[role];
324
+ const hue = pinned ? pinned.h : placeHue(home.hue, home.slack, taken);
325
+ if (!pinned) {
326
+ const gap = Math.min(...taken.map((t) => hueGap(hue, t)));
327
+ if (gap < 20) {
328
+ warnings.push(
329
+ `${role} could only reach ${gap.toFixed(0)}° from the nearest pinned hue, which is ` +
330
+ `too close to tell apart at a glance. Pin ${role} explicitly, or move the brand.`,
331
+ );
332
+ }
333
+ }
334
+ semanticHue[role] = hue;
335
+ semanticChroma[role] = pinned ? Math.max(pinned.c, 0.08) : 0.13;
336
+ taken.push(hue);
337
+ }
338
+
339
+ /**
340
+ * A semantic colour has two jobs and therefore two backgrounds: plain text on the
341
+ * page, and the label inside its own tinted badge. The badge is the harder of the
342
+ * two — it is a step toward the colour — so solving only against the surface produced
343
+ * a badge at 4.1:1 while the loose text passed, which is the failure mode that is
344
+ * hardest to notice because nine tenths of the uses look fine.
345
+ */
346
+ const sem = (role: Exclude<AnchorRole, "brand" | "accent">) => {
347
+ const hue = semanticHue[role];
348
+ const chroma = semanticChroma[role];
349
+ const bg = tintOn(hue, chroma, surfaces.bgSurface, mode);
350
+ const onSurface = solveLightness(
351
+ 4.5,
352
+ parseHex(worstSurface)!,
353
+ hue,
354
+ chroma,
355
+ mode === "light" ? "darker" : "lighter",
356
+ );
357
+ const onTint = solveLightness(
358
+ 4.5,
359
+ parseHex(bg)!,
360
+ hue,
361
+ chroma,
362
+ mode === "light" ? "darker" : "lighter",
363
+ );
364
+ const l = mode === "light" ? Math.min(onSurface, onTint) : Math.max(onSurface, onTint);
365
+ return oklchToHex({ l, c: chroma, h: hue });
366
+ };
367
+
368
+ const danger = sem("danger");
369
+ const dangerLch = hexToOklch(danger)!;
370
+ const semantic: SemanticTokens = {
371
+ danger,
372
+ dangerHover: oklchToHex({
373
+ ...dangerLch,
374
+ l: mode === "light" ? dangerLch.l - 0.06 : dangerLch.l + 0.06,
375
+ }),
376
+ dangerContrast: contrastOn(danger),
377
+ dangerBorder: oklchToHex({
378
+ l: mode === "light" ? dangerLch.l + 0.22 : dangerLch.l - 0.22,
379
+ c: semanticChroma.danger,
380
+ h: semanticHue.danger,
381
+ }),
382
+ dangerBg: tintOn(semanticHue.danger, semanticChroma.danger, surfaces.bgSurface, mode),
383
+ warning: sem("warning"),
384
+ warningBorder: oklchToHex({
385
+ l: mode === "light" ? 0.82 : 0.44,
386
+ c: semanticChroma.warning,
387
+ h: semanticHue.warning,
388
+ }),
389
+ warningBg: tintOn(semanticHue.warning, semanticChroma.warning, surfaces.bgSurface, mode),
390
+ info: sem("info"),
391
+ infoBorder: oklchToHex({
392
+ l: mode === "light" ? 0.8 : 0.45,
393
+ c: semanticChroma.info,
394
+ h: semanticHue.info,
395
+ }),
396
+ infoBg: tintOn(semanticHue.info, semanticChroma.info, surfaces.bgSurface, mode),
397
+ success: sem("success"),
398
+ successBorder: oklchToHex({
399
+ l: mode === "light" ? 0.81 : 0.45,
400
+ c: semanticChroma.success,
401
+ h: semanticHue.success,
402
+ }),
403
+ successBg: tintOn(semanticHue.success, semanticChroma.success, surfaces.bgSurface, mode),
404
+ };
405
+
406
+ if (accentLch) {
407
+ const accent = solveAgainst(accentLch.h, Math.max(accentLch.c, 0.06), 3, worstSurface, mode);
408
+ semantic.accent = accent;
409
+ semantic.accentContrast = contrastOn(accent);
410
+ semantic.accentBg = tintOn(accentLch.h, Math.max(accentLch.c, 0.06), surfaces.bgSurface, mode);
411
+ }
412
+
413
+ // ── Money. A THIRD system again: these are data colours with fixed meanings, and the
414
+ // one rule that outranks everything is that income and expense must never be the
415
+ // red/green pair — which is why the shipped presets use teal/amber/violet. Derived
416
+ // here at fixed hues rather than from the brand, for the same reason `--danger` is:
417
+ // a consumer who rebrands does not thereby change what "money out" looks like.
418
+ const money = (hue: number, target: number) => solveAgainst(hue, 0.12, target, worstSurface, mode);
419
+ const moneyIncome = money(175, 4.5);
420
+ const moneyExpense = money(70, 4.5);
421
+ const moneyNet = money(300, 4.5);
422
+ const moneyNeutral = oklchToHex({ l: mode === "light" ? 0.52 : 0.72, c: 0.012, h: brand.h });
423
+
424
+ const tokens: TokenSet = {
425
+ ...surfaces,
426
+ textPrimary,
427
+ textSecondary,
428
+ textMuted,
429
+ brand: brandHex,
430
+ brandHover,
431
+ brandContrast,
432
+ moneyIncome,
433
+ moneyExpense,
434
+ moneyNet,
435
+ moneyNeutral,
436
+ // Left alone. See the note at the top of this file about why the categorical ramp is
437
+ // not derived from the brand unless the caller asks.
438
+ //
439
+ // ⚠️ EMPTY, not absent: a caller who hands this straight to `applyTokenSet` keeps
440
+ // whatever `--chart-1…9` were already on the element. That is deliberate — the ramp
441
+ // is a separate decision and clearing it would leave charts unpainted — but it means
442
+ // a derived palette applied over another preset is a MIX until you also supply a
443
+ // ramp. `deriveChartRamp` is how you supply one.
444
+ chart: [],
445
+ heat: deriveHeat(moneyIncome, moneyExpense, surfaces, mode),
446
+ };
447
+
448
+ return { tokens, semantic, audit: auditPalette(tokens, semantic), warnings };
449
+ }
450
+
451
+ /**
452
+ * Heatmap stops, reusing the money pair.
453
+ *
454
+ * `under` is the good direction and `over` the bad one, at the SAME hues as
455
+ * `moneyIncome`/`moneyExpense` — so a heatmap cell and the figure printed beside it
456
+ * agree about which way is which, rather than each inventing its own convention.
457
+ */
458
+ function deriveHeat(
459
+ income: string,
460
+ expense: string,
461
+ surfaces: { bgSurface2: string },
462
+ mode: ThemeMode,
463
+ ): HeatStops {
464
+ const exp = hexToOklch(expense)!;
465
+ return {
466
+ neutral: surfaces.bgSurface2,
467
+ under: income,
468
+ over: expense,
469
+ seqLow: oklchToHex({ l: mode === "light" ? 0.93 : 0.26, c: 0.05, h: exp.h }),
470
+ seqHigh: expense,
471
+ empty: mode === "light" ? "rgba(120,110,95,0.14)" : "rgba(160,170,190,0.10)",
472
+ };
473
+ }
474
+
475
+ /* ── Audit ────────────────────────────────────────────────────────────────── */
476
+
477
+ /**
478
+ * Measure every pair that has a requirement, and say which ones fail.
479
+ *
480
+ * This is the part the shipped presets were missing. `derivePalette` runs it on the way
481
+ * out, and the preset test runs it over everything the package ships, so a palette that
482
+ * does not hold up cannot be merged with a comment claiming that it does.
483
+ */
484
+ export function auditPalette(t: TokenSet, semantic?: SemanticTokens): ContrastReport {
485
+ const checks: ContrastCheck[] = [];
486
+ const surfaces: Array<[string, string]> = [
487
+ ["bgPage", t.bgPage],
488
+ ["bgSurface", t.bgSurface],
489
+ ["bgSurface2", t.bgSurface2],
490
+ ];
491
+
492
+ const add = (pair: string, fg: string, bg: string, required: number, rule: string) => {
493
+ const ratio = contrast(fg, bg);
494
+ checks.push({ pair, ratio, required, rule, passes: ratio >= required - 0.05 });
495
+ };
496
+
497
+ for (const [name, bg] of surfaces) {
498
+ add(`textPrimary on ${name}`, t.textPrimary, bg, 7, "WCAG 1.4.6 AAA body text");
499
+ add(`textSecondary on ${name}`, t.textSecondary, bg, 4.5, "WCAG 1.4.3 AA body text");
500
+ add(`textMuted on ${name}`, t.textMuted, bg, 4.5, "WCAG 1.4.3 AA body text");
501
+ add(`brand on ${name}`, t.brand, bg, 3, "WCAG 1.4.11 graphical object");
502
+ add(`border on ${name}`, t.border, bg, 1.1, "house rule: a hairline must be findable");
503
+ for (const [key, hex] of [
504
+ ["moneyIncome", t.moneyIncome],
505
+ ["moneyExpense", t.moneyExpense],
506
+ ["moneyNet", t.moneyNet],
507
+ ] as const) {
508
+ add(`${key} on ${name}`, hex, bg, 4.5, "WCAG 1.4.3 — these are rendered as TEXT");
509
+ }
510
+ }
511
+
512
+ add("brandContrast on brand", t.brandContrast, t.brand, 4.5, "WCAG 1.4.3 AA body text");
513
+
514
+ if (semantic) {
515
+ for (const [name, fg] of [
516
+ ["danger", semantic.danger],
517
+ ["warning", semantic.warning],
518
+ ["info", semantic.info],
519
+ ["success", semantic.success],
520
+ ] as const) {
521
+ add(`${name} on bgSurface`, fg, t.bgSurface, 4.5, "WCAG 1.4.3 — rendered as text");
522
+ }
523
+ add("danger on dangerBg", semantic.danger, semantic.dangerBg, 4.5, "WCAG 1.4.3 — badge text");
524
+ add("warning on warningBg", semantic.warning, semantic.warningBg, 4.5, "WCAG 1.4.3 — badge text");
525
+ add("info on infoBg", semantic.info, semantic.infoBg, 4.5, "WCAG 1.4.3 — badge text");
526
+ add("success on successBg", semantic.success, semantic.successBg, 4.5, "WCAG 1.4.3 — badge text");
527
+ add(
528
+ "dangerContrast on danger",
529
+ semantic.dangerContrast,
530
+ semantic.danger,
531
+ 4.5,
532
+ "WCAG 1.4.3 AA body text",
533
+ );
534
+ }
535
+
536
+ const failures = checks.filter((c) => !c.passes);
537
+ return { checks, failures, passes: failures.length === 0 };
538
+ }
539
+
540
+ /* ── Categorical chart ramp ───────────────────────────────────────────────── */
541
+
542
+ export interface ChartRampReport {
543
+ colors: string[];
544
+ /** Series pairs that are too close under normal vision or any simulated CVD. */
545
+ collisions: Array<{ a: number; b: number; under: "normal" | CvdType; distance: number }>;
546
+ /** Series that do not clear 3:1 against a surface they may be drawn on. */
547
+ lowContrast: Array<{ index: number; surface: string; ratio: number }>;
548
+ /** The worst pairwise separation, across normal vision and all three dichromacies. */
549
+ minSeparation: number;
550
+ /** The worst contrast any series reaches against any surface it was measured on. */
551
+ minContrast: number;
552
+ passes: boolean;
553
+ }
554
+
555
+ /**
556
+ * The floor for pairwise separation in OKLab, across normal vision and each dichromacy.
557
+ *
558
+ * CALIBRATED, not chosen. Paul Tol's "Muted" set — which this package ships and which is
559
+ * a well-regarded CVD-safe palette — bottoms out at 0.059 in its light variant and 0.032
560
+ * in the lightened dark one. A threshold above 0.059 would fail a palette that is known
561
+ * to work, which is worse than having no check at all: an audit that cries wolf gets
562
+ * switched off. 0.05 sits just under the known-good case.
563
+ */
564
+ const MIN_SEPARATION = 0.05;
565
+
566
+ /**
567
+ * **The categorical trade-off, measured.** At a SINGLE lightness, the most series that
568
+ * can satisfy both the 3:1 fill contrast of WCAG 1.4.11 and {@link MIN_SEPARATION} under
569
+ * dichromacy is FOUR. Searched exhaustively over starting hue and lightness:
570
+ *
571
+ * n=4 best separation 0.065 — both satisfiable
572
+ * n=5 best separation 0.041 — not satisfiable
573
+ * n=9 best separation 0.015 — not satisfiable
574
+ *
575
+ * The reason is structural: dichromacy collapses the red-green axis, so hue alone stops
576
+ * distinguishing colours once you need more than a handful, and the remaining channel is
577
+ * LIGHTNESS — the very thing a uniform-contrast ramp holds constant. Paul Tol's set
578
+ * varies lightness deliberately and pays for it in contrast; that is not an oversight in
579
+ * their palette, it is the only currency left.
580
+ *
581
+ * So above four series this deriver varies lightness too, and reports what it gave up
582
+ * rather than pretending both constraints were met.
583
+ *
584
+ * NOTE ON WHAT THIS BOUND IS. It describes the PROBLEM, not a guarantee about
585
+ * {@link deriveChartRamp}. That function fixes lightness and starts the hue wheel at the
586
+ * brand's own hue rather than searching for the best start, so it reaches the bound only
587
+ * where the brand happens to sit well: sweeping 72 brand hues against the default light
588
+ * surfaces, 7 of 72 produced a passing 4-series ramp (12 of 72 on dark), and none passed
589
+ * at 5 or 9. Read the report it returns; do not assume a pass.
590
+ */
591
+ const SINGLE_LIGHTNESS_MAX_SERIES = 4;
592
+
593
+ /**
594
+ * A categorical ramp of `count` hues, evenly spaced and equal in perceived lightness,
595
+ * then MEASURED for separability — including under each dichromacy.
596
+ *
597
+ * Offered but not used by default, and the distinction matters. Evenly spaced hues at
598
+ * one lightness are a reasonable generic answer; Paul Tol's "Muted" set, which this
599
+ * package ships, is a better specific one, because it was optimised against real
600
+ * confusion lines rather than derived from a formula. So this exists for a consumer who
601
+ * wants their charts to follow their brand and is willing to read the report — not as
602
+ * a silent upgrade to the default.
603
+ */
604
+ export function deriveChartRamp(
605
+ brandHex: string,
606
+ mode: ThemeMode,
607
+ surfaces: string[],
608
+ count = 9,
609
+ ): ChartRampReport {
610
+ const brand = hexToOklch(brandHex) ?? { l: 0.6, c: 0.12, h: 264 };
611
+ const base = mode === "light" ? 0.58 : 0.74;
612
+ const c = Math.max(0.09, Math.min(brand.c, 0.15));
613
+ // Up to four series, one lightness: a scale that varies lightness implies rank, and a
614
+ // categorical one must not. Beyond four, lightness is the only channel left that
615
+ // survives dichromacy — see SINGLE_LIGHTNESS_MAX_SERIES — so it alternates in a
616
+ // three-step cycle, which keeps ADJACENT series (the ones a legend puts side by side)
617
+ // at different lightnesses as well as different hues.
618
+ const vary = count > SINGLE_LIGHTNESS_MAX_SERIES;
619
+ const offsets = [0, mode === "light" ? -0.1 : 0.1, mode === "light" ? 0.08 : -0.08];
620
+ const colors = Array.from({ length: count }, (_, i) =>
621
+ oklchToHex({
622
+ l: vary ? base + offsets[i % offsets.length] : base,
623
+ c,
624
+ h: (brand.h + (360 / count) * i) % 360,
625
+ }),
626
+ );
627
+
628
+ return measureRamp(colors, surfaces);
629
+ }
630
+
631
+ /** Audit a ramp somebody else chose — the shipped Paul Tol set, say. */
632
+ export function auditChartRamp(colors: string[], surfaces: string[]): ChartRampReport {
633
+ return measureRamp(colors, surfaces);
634
+ }
635
+
636
+ function measureRamp(colors: string[], surfaces: string[]): ChartRampReport {
637
+ const collisions: ChartRampReport["collisions"] = [];
638
+ const rgbs = colors.map((hex) => parseHex(hex)).filter((v): v is NonNullable<typeof v> => !!v);
639
+ let minSeparation = Infinity;
640
+ for (let i = 0; i < rgbs.length; i++) {
641
+ for (let j = i + 1; j < rgbs.length; j++) {
642
+ const normal = deltaE(rgbs[i], rgbs[j]);
643
+ minSeparation = Math.min(minSeparation, normal);
644
+ if (normal < MIN_SEPARATION) {
645
+ collisions.push({ a: i, b: j, under: "normal", distance: normal });
646
+ }
647
+ for (const type of ["protanopia", "deuteranopia", "tritanopia"] as const) {
648
+ const d = deltaE(simulateCvd(rgbs[i], type), simulateCvd(rgbs[j], type));
649
+ minSeparation = Math.min(minSeparation, d);
650
+ if (d < MIN_SEPARATION) collisions.push({ a: i, b: j, under: type, distance: d });
651
+ }
652
+ }
653
+ }
654
+
655
+ const lowContrast: ChartRampReport["lowContrast"] = [];
656
+ let minContrast = Infinity;
657
+ colors.forEach((hex, index) => {
658
+ for (const surface of surfaces) {
659
+ const ratio = contrast(hex, surface);
660
+ minContrast = Math.min(minContrast, ratio);
661
+ // WCAG 1.4.11: a graphical object carrying meaning needs 3:1 against what is
662
+ // adjacent to it. A bar the same value as the card behind it has no boundary.
663
+ if (ratio < 3) lowContrast.push({ index, surface, ratio });
664
+ }
665
+ });
666
+
667
+ return {
668
+ colors,
669
+ collisions,
670
+ lowContrast,
671
+ minSeparation: Number.isFinite(minSeparation) ? minSeparation : 0,
672
+ minContrast: Number.isFinite(minContrast) ? minContrast : 0,
673
+ passes: collisions.length === 0 && lowContrast.length === 0,
674
+ };
675
+ }
676
+
677
+ /** Convenience: the OKLCH of a hex, for a caller inspecting an anchor. */
678
+ export function describe(hex: string): Oklch | null {
679
+ const rgb = parseHex(hex);
680
+ return rgb ? rgbToOklch(rgb) : null;
681
+ }