zabi-components 8.0.0 → 9.0.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 (456) hide show
  1. package/CHANGELOG.md +2935 -0
  2. package/README.md +243 -10
  3. package/THEME.md +162 -332
  4. package/THEMING.md +1419 -259
  5. package/dist/atoms/ActionPanel.svelte +15 -5
  6. package/dist/atoms/Avatar.svelte +120 -0
  7. package/dist/atoms/Avatar.svelte.d.ts +46 -0
  8. package/dist/atoms/Badge.svelte +71 -13
  9. package/dist/atoms/Badge.svelte.d.ts +8 -2
  10. package/dist/atoms/Block.svelte +74 -0
  11. package/dist/atoms/Block.svelte.d.ts +29 -0
  12. package/dist/atoms/Button.svelte +217 -30
  13. package/dist/atoms/Button.svelte.d.ts +33 -4
  14. package/dist/atoms/Card.svelte +182 -26
  15. package/dist/atoms/Card.svelte.d.ts +55 -3
  16. package/dist/atoms/CardHeader.svelte +16 -4
  17. package/dist/atoms/Checkbox.svelte +81 -21
  18. package/dist/atoms/Checkbox.svelte.d.ts +31 -2
  19. package/dist/atoms/Chip.svelte +351 -0
  20. package/dist/atoms/Chip.svelte.d.ts +57 -0
  21. package/dist/atoms/CodeBlock.svelte +20 -2
  22. package/dist/atoms/CodeBlock.svelte.d.ts +4 -0
  23. package/dist/atoms/CodeField.svelte +385 -0
  24. package/dist/atoms/CodeField.svelte.d.ts +61 -0
  25. package/dist/atoms/ColorPicker.svelte +260 -49
  26. package/dist/atoms/ColorPicker.svelte.d.ts +3 -0
  27. package/dist/atoms/Container.svelte +16 -8
  28. package/dist/atoms/DateField.svelte +145 -0
  29. package/dist/atoms/DateField.svelte.d.ts +81 -0
  30. package/dist/atoms/FieldMessages.svelte +69 -0
  31. package/dist/atoms/FieldMessages.svelte.d.ts +23 -0
  32. package/dist/atoms/FloatingActionButton.svelte +176 -0
  33. package/dist/atoms/FloatingActionButton.svelte.d.ts +51 -0
  34. package/dist/atoms/Heading.svelte +63 -5
  35. package/dist/atoms/Heading.svelte.d.ts +14 -0
  36. package/dist/atoms/IconButton.svelte +294 -26
  37. package/dist/atoms/IconButton.svelte.d.ts +62 -4
  38. package/dist/atoms/Input.svelte +259 -53
  39. package/dist/atoms/Input.svelte.d.ts +33 -2
  40. package/dist/atoms/List.svelte +5 -1
  41. package/dist/atoms/ListItem.svelte +70 -13
  42. package/dist/atoms/ListItem.svelte.d.ts +11 -0
  43. package/dist/atoms/PickerField.svelte +239 -0
  44. package/dist/atoms/PickerField.svelte.d.ts +67 -0
  45. package/dist/atoms/Progress.svelte +99 -19
  46. package/dist/atoms/Progress.svelte.d.ts +15 -1
  47. package/dist/atoms/Radio.svelte +51 -3
  48. package/dist/atoms/Radio.svelte.d.ts +30 -2
  49. package/dist/atoms/Rating.svelte +620 -0
  50. package/dist/atoms/Rating.svelte.d.ts +77 -0
  51. package/dist/atoms/Select.svelte +438 -98
  52. package/dist/atoms/Select.svelte.d.ts +61 -3
  53. package/dist/atoms/SelectionControl.svelte +193 -23
  54. package/dist/atoms/SelectionControl.svelte.d.ts +29 -3
  55. package/dist/atoms/Skeleton.svelte +114 -18
  56. package/dist/atoms/Skeleton.svelte.d.ts +30 -3
  57. package/dist/atoms/SkeletonGroup.svelte +43 -0
  58. package/dist/atoms/SkeletonGroup.svelte.d.ts +20 -0
  59. package/dist/atoms/Slider.svelte +344 -0
  60. package/dist/atoms/Slider.svelte.d.ts +40 -0
  61. package/dist/atoms/Sparkline.svelte +109 -0
  62. package/dist/atoms/Sparkline.svelte.d.ts +27 -0
  63. package/dist/atoms/SpeechBubble.svelte +187 -0
  64. package/dist/atoms/SpeechBubble.svelte.d.ts +33 -0
  65. package/dist/atoms/Spinner.svelte +57 -0
  66. package/dist/atoms/Spinner.svelte.d.ts +19 -0
  67. package/dist/atoms/Stat.svelte +72 -0
  68. package/dist/atoms/Stat.svelte.d.ts +19 -0
  69. package/dist/atoms/Table.svelte +151 -6
  70. package/dist/atoms/Table.svelte.d.ts +15 -3
  71. package/dist/atoms/TemporalField.svelte +472 -0
  72. package/dist/atoms/TemporalField.svelte.d.ts +48 -0
  73. package/dist/atoms/Text.svelte +23 -11
  74. package/dist/atoms/Text.svelte.d.ts +6 -3
  75. package/dist/atoms/Textarea.svelte +150 -44
  76. package/dist/atoms/Textarea.svelte.d.ts +35 -2
  77. package/dist/atoms/ThemeToggle.svelte +223 -44
  78. package/dist/atoms/ThemeToggle.svelte.d.ts +26 -3
  79. package/dist/atoms/TimeColumn.svelte +150 -0
  80. package/dist/atoms/TimeColumn.svelte.d.ts +25 -0
  81. package/dist/atoms/TimeField.svelte +151 -0
  82. package/dist/atoms/TimeField.svelte.d.ts +85 -0
  83. package/dist/atoms/Toast.svelte +22 -8
  84. package/dist/atoms/Toast.svelte.d.ts +2 -0
  85. package/dist/atoms/Toggle.svelte +98 -14
  86. package/dist/atoms/Toggle.svelte.d.ts +26 -2
  87. package/dist/atoms/Tooltip.svelte +528 -24
  88. package/dist/atoms/Tooltip.svelte.d.ts +18 -0
  89. package/dist/atoms/ZabiStringsProvider.svelte +45 -0
  90. package/dist/atoms/ZabiStringsProvider.svelte.d.ts +27 -0
  91. package/dist/atoms/index.d.ts +38 -0
  92. package/dist/atoms/index.js +29 -0
  93. package/dist/atoms/selection-control.styles.d.ts +47 -5
  94. package/dist/atoms/selection-control.styles.js +52 -10
  95. package/dist/components/atoms/index.d.ts +38 -0
  96. package/dist/components/atoms/index.d.ts.map +1 -1
  97. package/dist/components/atoms/selection-control.styles.d.ts +47 -5
  98. package/dist/components/atoms/selection-control.styles.d.ts.map +1 -1
  99. package/dist/components/index.d.ts +104 -2
  100. package/dist/components/index.d.ts.map +1 -1
  101. package/dist/components/map.d.ts +11 -0
  102. package/dist/components/map.d.ts.map +1 -0
  103. package/dist/components/molecules/index.d.ts +51 -0
  104. package/dist/components/molecules/index.d.ts.map +1 -1
  105. package/dist/components/molecules/toast-store.d.ts +83 -7
  106. package/dist/components/molecules/toast-store.d.ts.map +1 -1
  107. package/dist/components/organisms/index.d.ts +10 -0
  108. package/dist/components/organisms/index.d.ts.map +1 -1
  109. package/dist/components/types/variants.d.ts +24 -1
  110. package/dist/components/types/variants.d.ts.map +1 -1
  111. package/dist/components/util/app-shell.d.ts +79 -0
  112. package/dist/components/util/app-shell.d.ts.map +1 -0
  113. package/dist/components/util/avatar.d.ts +39 -0
  114. package/dist/components/util/avatar.d.ts.map +1 -0
  115. package/dist/components/util/bottom-sheet.d.ts +30 -0
  116. package/dist/components/util/bottom-sheet.d.ts.map +1 -0
  117. package/dist/components/util/bottom-tab-bar.d.ts +41 -0
  118. package/dist/components/util/bottom-tab-bar.d.ts.map +1 -0
  119. package/dist/components/util/calendar.d.ts +56 -0
  120. package/dist/components/util/calendar.d.ts.map +1 -0
  121. package/dist/components/util/chart.d.ts +140 -0
  122. package/dist/components/util/chart.d.ts.map +1 -0
  123. package/dist/components/util/chip.d.ts +66 -0
  124. package/dist/components/util/chip.d.ts.map +1 -0
  125. package/dist/components/util/cn.d.ts.map +1 -1
  126. package/dist/components/util/code-field.d.ts +63 -0
  127. package/dist/components/util/code-field.d.ts.map +1 -0
  128. package/dist/components/util/collapsible.d.ts +64 -0
  129. package/dist/components/util/collapsible.d.ts.map +1 -0
  130. package/dist/components/util/confirm-dialog.d.ts +12 -0
  131. package/dist/components/util/confirm-dialog.d.ts.map +1 -0
  132. package/dist/components/util/date.d.ts +96 -0
  133. package/dist/components/util/date.d.ts.map +1 -0
  134. package/dist/components/util/display-voice.d.ts +10 -0
  135. package/dist/components/util/display-voice.d.ts.map +1 -0
  136. package/dist/components/util/drawer.d.ts +13 -0
  137. package/dist/components/util/drawer.d.ts.map +1 -0
  138. package/dist/components/util/dropdown.d.ts +47 -0
  139. package/dist/components/util/dropdown.d.ts.map +1 -0
  140. package/dist/components/util/field-control.d.ts +30 -0
  141. package/dist/components/util/field-control.d.ts.map +1 -0
  142. package/dist/components/util/field.d.ts +45 -0
  143. package/dist/components/util/field.d.ts.map +1 -0
  144. package/dist/components/util/fit-in-viewport.d.ts +201 -0
  145. package/dist/components/util/fit-in-viewport.d.ts.map +1 -0
  146. package/dist/components/util/focus-utils.d.ts +77 -2
  147. package/dist/components/util/focus-utils.d.ts.map +1 -1
  148. package/dist/components/util/hydration.d.ts +45 -0
  149. package/dist/components/util/hydration.d.ts.map +1 -0
  150. package/dist/components/util/keyboard-inset.d.ts +18 -0
  151. package/dist/components/util/keyboard-inset.d.ts.map +1 -0
  152. package/dist/components/util/map-view.d.ts +144 -0
  153. package/dist/components/util/map-view.d.ts.map +1 -0
  154. package/dist/components/util/media-grid.d.ts +51 -0
  155. package/dist/components/util/media-grid.d.ts.map +1 -0
  156. package/dist/components/util/media.d.ts +6 -0
  157. package/dist/components/util/media.d.ts.map +1 -0
  158. package/dist/components/util/overlay-chrome.d.ts +53 -0
  159. package/dist/components/util/overlay-chrome.d.ts.map +1 -0
  160. package/dist/components/util/overlay.d.ts +72 -0
  161. package/dist/components/util/overlay.d.ts.map +1 -0
  162. package/dist/components/util/photo.d.ts +234 -0
  163. package/dist/components/util/photo.d.ts.map +1 -0
  164. package/dist/components/util/portal.d.ts +23 -0
  165. package/dist/components/util/portal.d.ts.map +1 -0
  166. package/dist/components/util/progress.d.ts +19 -0
  167. package/dist/components/util/progress.d.ts.map +1 -0
  168. package/dist/components/util/pull-to-refresh.d.ts +27 -0
  169. package/dist/components/util/pull-to-refresh.d.ts.map +1 -0
  170. package/dist/components/util/radio-keys.d.ts +25 -0
  171. package/dist/components/util/radio-keys.d.ts.map +1 -0
  172. package/dist/components/util/rating.d.ts +23 -0
  173. package/dist/components/util/rating.d.ts.map +1 -0
  174. package/dist/components/util/ready-made-strings.d.ts +77 -0
  175. package/dist/components/util/ready-made-strings.d.ts.map +1 -0
  176. package/dist/components/util/scroll-edge.d.ts +33 -0
  177. package/dist/components/util/scroll-edge.d.ts.map +1 -0
  178. package/dist/components/util/scroll-padding.d.ts +20 -0
  179. package/dist/components/util/scroll-padding.d.ts.map +1 -0
  180. package/dist/components/util/segmented-control.d.ts +16 -0
  181. package/dist/components/util/segmented-control.d.ts.map +1 -0
  182. package/dist/components/util/select.d.ts +41 -0
  183. package/dist/components/util/select.d.ts.map +1 -0
  184. package/dist/components/util/sheet-drag.d.ts +84 -0
  185. package/dist/components/util/sheet-drag.d.ts.map +1 -0
  186. package/dist/components/util/sidebar.d.ts +63 -0
  187. package/dist/components/util/sidebar.d.ts.map +1 -0
  188. package/dist/components/util/skeleton.d.ts +15 -0
  189. package/dist/components/util/skeleton.d.ts.map +1 -0
  190. package/dist/components/util/sliding-indicator.d.ts +25 -0
  191. package/dist/components/util/sliding-indicator.d.ts.map +1 -0
  192. package/dist/components/util/sortable-list.d.ts +65 -0
  193. package/dist/components/util/sortable-list.d.ts.map +1 -0
  194. package/dist/components/util/stepper.d.ts +40 -0
  195. package/dist/components/util/stepper.d.ts.map +1 -0
  196. package/dist/components/util/swipeable-list-item.d.ts +36 -0
  197. package/dist/components/util/swipeable-list-item.d.ts.map +1 -0
  198. package/dist/components/util/temporal-field.d.ts +89 -0
  199. package/dist/components/util/temporal-field.d.ts.map +1 -0
  200. package/dist/components/util/theme-mode.d.ts +80 -0
  201. package/dist/components/util/theme-mode.d.ts.map +1 -0
  202. package/dist/components/util/toaster.d.ts +133 -0
  203. package/dist/components/util/toaster.d.ts.map +1 -0
  204. package/dist/components/util/tone-surface.d.ts +34 -0
  205. package/dist/components/util/tone-surface.d.ts.map +1 -0
  206. package/dist/components/util/tooltip.d.ts +10 -0
  207. package/dist/components/util/tooltip.d.ts.map +1 -0
  208. package/dist/components/util/top-navbar.d.ts +35 -0
  209. package/dist/components/util/top-navbar.d.ts.map +1 -0
  210. package/dist/components/util/touch-target.d.ts +31 -0
  211. package/dist/components/util/touch-target.d.ts.map +1 -0
  212. package/dist/components/util/zabi-strings.d.ts +142 -0
  213. package/dist/components/util/zabi-strings.d.ts.map +1 -0
  214. package/dist/create-theme/cli.js +173 -0
  215. package/dist/create-theme/index.d.ts +190 -0
  216. package/dist/create-theme/index.js +1034 -0
  217. package/dist/create-theme/lib/ramp-math.js +128 -0
  218. package/dist/create-theme/lib/resolve.js +238 -0
  219. package/dist/create-theme/theme-data.js +2644 -0
  220. package/dist/index.d.ts +104 -2
  221. package/dist/index.js +75 -2
  222. package/dist/map.d.ts +10 -0
  223. package/dist/map.js +9 -0
  224. package/dist/molecules/Alert.svelte +63 -16
  225. package/dist/molecules/Alert.svelte.d.ts +14 -1
  226. package/dist/molecules/AppBar.svelte +674 -0
  227. package/dist/molecules/AppBar.svelte.d.ts +131 -0
  228. package/dist/molecules/AvatarGroup.svelte +116 -0
  229. package/dist/molecules/AvatarGroup.svelte.d.ts +40 -0
  230. package/dist/molecules/BarChart.svelte +381 -0
  231. package/dist/molecules/BarChart.svelte.d.ts +45 -0
  232. package/dist/molecules/BottomSheet.svelte +608 -0
  233. package/dist/molecules/BottomSheet.svelte.d.ts +109 -0
  234. package/dist/molecules/BottomTabBar.svelte +403 -0
  235. package/dist/molecules/BottomTabBar.svelte.d.ts +98 -0
  236. package/dist/molecules/Calendar.svelte +597 -0
  237. package/dist/molecules/Calendar.svelte.d.ts +72 -0
  238. package/dist/molecules/ChipGroup.svelte +325 -0
  239. package/dist/molecules/ChipGroup.svelte.d.ts +41 -0
  240. package/dist/molecules/Collapsible.svelte +347 -0
  241. package/dist/molecules/Collapsible.svelte.d.ts +61 -0
  242. package/dist/molecules/CollapsibleGroup.svelte +136 -0
  243. package/dist/molecules/CollapsibleGroup.svelte.d.ts +14 -0
  244. package/dist/molecules/ComponentDemo.svelte +16 -4
  245. package/dist/molecules/ComponentDemo.svelte.d.ts +3 -0
  246. package/dist/molecules/ConfirmDialog.svelte +257 -0
  247. package/dist/molecules/ConfirmDialog.svelte.d.ts +58 -0
  248. package/dist/molecules/ContactForm.svelte +34 -17
  249. package/dist/molecules/ContactForm.svelte.d.ts +8 -0
  250. package/dist/molecules/Drawer.svelte +415 -0
  251. package/dist/molecules/Drawer.svelte.d.ts +55 -0
  252. package/dist/molecules/Dropdown.svelte +517 -48
  253. package/dist/molecules/Dropdown.svelte.d.ts +47 -6
  254. package/dist/molecules/DropdownItem.svelte +122 -0
  255. package/dist/molecules/DropdownItem.svelte.d.ts +28 -0
  256. package/dist/molecules/EmptyState.svelte +55 -10
  257. package/dist/molecules/EmptyState.svelte.d.ts +8 -0
  258. package/dist/molecules/FormField.svelte +9 -1
  259. package/dist/molecules/FormField.svelte.d.ts +2 -0
  260. package/dist/molecules/Header.svelte +1 -1
  261. package/dist/molecules/ImageUpload.svelte +492 -69
  262. package/dist/molecules/ImageUpload.svelte.d.ts +82 -2
  263. package/dist/molecules/LineChart.svelte +459 -0
  264. package/dist/molecules/LineChart.svelte.d.ts +53 -0
  265. package/dist/molecules/MapView.svelte +565 -0
  266. package/dist/molecules/MapView.svelte.d.ts +88 -0
  267. package/dist/molecules/MediaGrid.svelte +527 -0
  268. package/dist/molecules/MediaGrid.svelte.d.ts +77 -0
  269. package/dist/molecules/Modal.svelte +293 -72
  270. package/dist/molecules/Modal.svelte.d.ts +51 -2
  271. package/dist/molecules/NavigationMenuContent.svelte +58 -1
  272. package/dist/molecules/NavigationMenuItem.svelte +0 -1
  273. package/dist/molecules/NavigationMenuLink.svelte +1 -1
  274. package/dist/molecules/NavigationMenuList.svelte +10 -3
  275. package/dist/molecules/NavigationMenuTrigger.svelte +2 -2
  276. package/dist/molecules/Page.svelte +32 -2
  277. package/dist/molecules/Page.svelte.d.ts +11 -0
  278. package/dist/molecules/PhotoGrid.svelte +523 -0
  279. package/dist/molecules/PhotoGrid.svelte.d.ts +73 -0
  280. package/dist/molecules/PhotoViewer.svelte +1213 -0
  281. package/dist/molecules/PhotoViewer.svelte.d.ts +74 -0
  282. package/dist/molecules/PieChart.svelte +421 -0
  283. package/dist/molecules/PieChart.svelte.d.ts +51 -0
  284. package/dist/molecules/PropsTable.svelte +22 -10
  285. package/dist/molecules/PropsTable.svelte.d.ts +3 -0
  286. package/dist/molecules/PullToRefresh.svelte +263 -0
  287. package/dist/molecules/PullToRefresh.svelte.d.ts +52 -0
  288. package/dist/molecules/RadioGroup.svelte +72 -18
  289. package/dist/molecules/RadioGroup.svelte.d.ts +19 -3
  290. package/dist/molecules/Section.svelte +1 -1
  291. package/dist/molecules/SegmentedControl.svelte +456 -0
  292. package/dist/molecules/SegmentedControl.svelte.d.ts +40 -0
  293. package/dist/molecules/SidebarBrandHeader.svelte +11 -1
  294. package/dist/molecules/SidebarBrandHeader.svelte.d.ts +3 -0
  295. package/dist/molecules/SidebarFooter.svelte +30 -7
  296. package/dist/molecules/SidebarFooter.svelte.d.ts +3 -0
  297. package/dist/molecules/SidebarNavSection.svelte +1 -1
  298. package/dist/molecules/SlideUp.svelte +220 -61
  299. package/dist/molecules/SlideUp.svelte.d.ts +26 -0
  300. package/dist/molecules/SortableList.svelte +633 -0
  301. package/dist/molecules/SortableList.svelte.d.ts +69 -0
  302. package/dist/molecules/Stepper.svelte +562 -0
  303. package/dist/molecules/Stepper.svelte.d.ts +39 -0
  304. package/dist/molecules/StickyActionBar.svelte +250 -0
  305. package/dist/molecules/StickyActionBar.svelte.d.ts +78 -0
  306. package/dist/molecules/SwipeableListItem.svelte +334 -0
  307. package/dist/molecules/SwipeableListItem.svelte.d.ts +54 -0
  308. package/dist/molecules/Tabs.svelte +310 -9
  309. package/dist/molecules/Tabs.svelte.d.ts +7 -0
  310. package/dist/molecules/Toaster.svelte +514 -5
  311. package/dist/molecules/Toaster.svelte.d.ts +153 -2
  312. package/dist/molecules/ToasterToast.svelte +495 -77
  313. package/dist/molecules/ToasterToast.svelte.d.ts +15 -0
  314. package/dist/molecules/UnsavedChangesBar.svelte +223 -0
  315. package/dist/molecules/UnsavedChangesBar.svelte.d.ts +43 -0
  316. package/dist/molecules/index.d.ts +51 -0
  317. package/dist/molecules/index.js +31 -0
  318. package/dist/molecules/toast-store.d.ts +83 -7
  319. package/dist/molecules/toast-store.js +25 -1
  320. package/dist/organisms/AppNavigation.svelte +440 -0
  321. package/dist/organisms/AppNavigation.svelte.d.ts +72 -0
  322. package/dist/organisms/AppShell.svelte +696 -0
  323. package/dist/organisms/AppShell.svelte.d.ts +157 -0
  324. package/dist/organisms/AppShellHeaderPart.svelte +163 -0
  325. package/dist/organisms/AppShellHeaderPart.svelte.d.ts +57 -0
  326. package/dist/organisms/SidebarAccountPanel.svelte +107 -12
  327. package/dist/organisms/SidebarAccountPanel.svelte.d.ts +19 -0
  328. package/dist/organisms/SidebarNavigation.svelte +111 -43
  329. package/dist/organisms/SidebarNavigation.svelte.d.ts +29 -1
  330. package/dist/organisms/SidebarPanel.svelte +34 -17
  331. package/dist/organisms/SidebarPanel.svelte.d.ts +1 -0
  332. package/dist/organisms/SidebarShell.svelte +164 -14
  333. package/dist/organisms/SidebarShell.svelte.d.ts +56 -2
  334. package/dist/organisms/TopNavbar.svelte +353 -39
  335. package/dist/organisms/TopNavbar.svelte.d.ts +47 -0
  336. package/dist/organisms/TopNavbarSheetContent.svelte +145 -0
  337. package/dist/organisms/TopNavbarSheetContent.svelte.d.ts +24 -0
  338. package/dist/organisms/index.d.ts +10 -0
  339. package/dist/organisms/index.js +7 -0
  340. package/dist/types/components.d.ts +21 -1
  341. package/dist/types/components.d.ts.map +1 -1
  342. package/dist/types/index.d.ts +1093 -26
  343. package/dist/types/index.d.ts.map +1 -1
  344. package/dist/types/variants.ts +28 -1
  345. package/dist/util/app-shell.d.ts +78 -0
  346. package/dist/util/app-shell.js +97 -0
  347. package/dist/util/avatar.d.ts +38 -0
  348. package/dist/util/avatar.js +52 -0
  349. package/dist/util/bottom-sheet.d.ts +29 -0
  350. package/dist/util/bottom-sheet.js +43 -0
  351. package/dist/util/bottom-tab-bar.d.ts +40 -0
  352. package/dist/util/bottom-tab-bar.js +54 -0
  353. package/dist/util/calendar.d.ts +55 -0
  354. package/dist/util/calendar.js +74 -0
  355. package/dist/util/chart.d.ts +139 -0
  356. package/dist/util/chart.js +305 -0
  357. package/dist/util/chip.d.ts +65 -0
  358. package/dist/util/chip.js +58 -0
  359. package/dist/util/cn.js +28 -2
  360. package/dist/util/code-field.d.ts +62 -0
  361. package/dist/util/code-field.js +112 -0
  362. package/dist/util/collapsible.d.ts +63 -0
  363. package/dist/util/collapsible.js +23 -0
  364. package/dist/util/confirm-dialog.d.ts +11 -0
  365. package/dist/util/confirm-dialog.js +2 -0
  366. package/dist/util/date.d.ts +95 -0
  367. package/dist/util/date.js +237 -0
  368. package/dist/util/display-voice.d.ts +9 -0
  369. package/dist/util/display-voice.js +9 -0
  370. package/dist/util/drawer.d.ts +12 -0
  371. package/dist/util/drawer.js +7 -0
  372. package/dist/util/dropdown.d.ts +46 -0
  373. package/dist/util/dropdown.js +21 -0
  374. package/dist/util/field-control.d.ts +29 -0
  375. package/dist/util/field-control.js +32 -0
  376. package/dist/util/field.d.ts +44 -0
  377. package/dist/util/field.js +38 -0
  378. package/dist/util/fit-in-viewport.d.ts +200 -0
  379. package/dist/util/fit-in-viewport.js +399 -0
  380. package/dist/util/focus-utils.d.ts +77 -2
  381. package/dist/util/focus-utils.js +186 -9
  382. package/dist/util/hydration.d.ts +44 -0
  383. package/dist/util/hydration.js +58 -0
  384. package/dist/util/keyboard-inset.d.ts +17 -0
  385. package/dist/util/keyboard-inset.js +53 -0
  386. package/dist/util/map-view.d.ts +143 -0
  387. package/dist/util/map-view.js +121 -0
  388. package/dist/util/media-grid.d.ts +50 -0
  389. package/dist/util/media-grid.js +81 -0
  390. package/dist/util/media.d.ts +5 -0
  391. package/dist/util/media.js +10 -0
  392. package/dist/util/overlay-chrome.d.ts +52 -0
  393. package/dist/util/overlay-chrome.js +56 -0
  394. package/dist/util/overlay.d.ts +71 -0
  395. package/dist/util/overlay.js +240 -0
  396. package/dist/util/photo.d.ts +233 -0
  397. package/dist/util/photo.js +209 -0
  398. package/dist/util/portal.d.ts +22 -0
  399. package/dist/util/portal.js +50 -0
  400. package/dist/util/progress.d.ts +18 -0
  401. package/dist/util/progress.js +17 -0
  402. package/dist/util/pull-to-refresh.d.ts +26 -0
  403. package/dist/util/pull-to-refresh.js +30 -0
  404. package/dist/util/radio-keys.d.ts +24 -0
  405. package/dist/util/radio-keys.js +49 -0
  406. package/dist/util/rating.d.ts +22 -0
  407. package/dist/util/rating.js +26 -0
  408. package/dist/util/ready-made-strings.d.ts +76 -0
  409. package/dist/util/ready-made-strings.js +67 -0
  410. package/dist/util/scroll-edge.d.ts +32 -0
  411. package/dist/util/scroll-edge.js +87 -0
  412. package/dist/util/scroll-padding.d.ts +19 -0
  413. package/dist/util/scroll-padding.js +44 -0
  414. package/dist/util/segmented-control.d.ts +15 -0
  415. package/dist/util/segmented-control.js +1 -0
  416. package/dist/util/select.d.ts +40 -0
  417. package/dist/util/select.js +63 -0
  418. package/dist/util/sheet-drag.d.ts +83 -0
  419. package/dist/util/sheet-drag.js +311 -0
  420. package/dist/util/sidebar.d.ts +62 -0
  421. package/dist/util/sidebar.js +38 -0
  422. package/dist/util/skeleton.d.ts +14 -0
  423. package/dist/util/skeleton.js +22 -0
  424. package/dist/util/sliding-indicator.d.ts +24 -0
  425. package/dist/util/sliding-indicator.js +44 -0
  426. package/dist/util/sortable-list.d.ts +64 -0
  427. package/dist/util/sortable-list.js +53 -0
  428. package/dist/util/stepper.d.ts +39 -0
  429. package/dist/util/stepper.js +38 -0
  430. package/dist/util/swipeable-list-item.d.ts +35 -0
  431. package/dist/util/swipeable-list-item.js +32 -0
  432. package/dist/util/temporal-field.d.ts +88 -0
  433. package/dist/util/temporal-field.js +136 -0
  434. package/dist/util/theme-mode.d.ts +79 -0
  435. package/dist/util/theme-mode.js +121 -0
  436. package/dist/util/toaster.d.ts +132 -0
  437. package/dist/util/toaster.js +130 -0
  438. package/dist/util/tone-surface.d.ts +33 -0
  439. package/dist/util/tone-surface.js +35 -0
  440. package/dist/util/tooltip.d.ts +9 -0
  441. package/dist/util/tooltip.js +18 -0
  442. package/dist/util/top-navbar.d.ts +34 -0
  443. package/dist/util/top-navbar.js +28 -0
  444. package/dist/util/touch-target.d.ts +30 -0
  445. package/dist/util/touch-target.js +30 -0
  446. package/dist/util/zabi-strings.d.ts +141 -0
  447. package/dist/util/zabi-strings.js +105 -0
  448. package/dist/zabi-components-colors.css +11 -2
  449. package/dist/zabi-components-theme-dark-only.css +870 -258
  450. package/dist/zabi-components-theme-dark.css +870 -258
  451. package/dist/zabi-components-theme-only.css +2419 -368
  452. package/dist/zabi-components-theme.css +2419 -368
  453. package/dist/zabi-components.css +6032 -711
  454. package/docs/lucide-icons.md +40 -17
  455. package/docs/theme-imports.md +81 -11
  456. package/package.json +45 -8
@@ -0,0 +1,1034 @@
1
+ /**
2
+ * createTheme — one brand colour in, a file of token overrides out.
3
+ *
4
+ * import { createTheme } from 'zabi-components/create-theme';
5
+ * const { css, tokens, warnings } = createTheme({ brand: '#0026EA' });
6
+ *
7
+ * The CSS holds `--zabi-brand-50 … 950` (and `--zabi-accent-*`, `--zabi-base-*`
8
+ * when asked for) on `:root`. Imported after `zabi-components/theme-only` and
9
+ * `zabi-components/theme-dark-only` it restyles light and dark; nothing is
10
+ * repeated for dark, because the dark theme only remaps roles onto the ramps.
11
+ *
12
+ * How the ramps are built:
13
+ * - On the library's shared lightness curve (./lib/ramp-math.js), so
14
+ * `<ramp>-600` means the same lightness as in every built-in ramp and every
15
+ * role keeps the contrast it was designed with. The colour you give
16
+ * supplies hue and chroma; it is NOT pinned to a step, so the exact hex may
17
+ * not appear in the ramp. `closest` says which step it landed nearest.
18
+ * - The neutral ramp keeps the base scale's own 21 lightness steps and is
19
+ * tinted toward the hue you give, at low chroma, so it stays a neutral.
20
+ *
21
+ * `pin` puts the exact colour on the primary action. The ramp stays on the
22
+ * curve, and the roles that ARE the brand colour (the primary fill, and where
23
+ * they still pass, the focus ring and links) are written as role overrides:
24
+ * light on `:root`, dark under the selectors the dark theme is published
25
+ * under. See "pinning" below.
26
+ *
27
+ * Then every role pair the library guards (the same list as
28
+ * scripts/check-contrast.js) is resolved in light and in dark with the new
29
+ * ramps in place, and anything under WCAG AA comes back in `warnings`.
30
+ *
31
+ * `./theme-data.js` is not in the source tree: scripts/build-create-theme.js
32
+ * writes it next to this file in dist/, from src/app.css and the pair list, on
33
+ * every build. It is the default theme's token map and cannot go stale.
34
+ *
35
+ * Plain ESM, no build step. Runtime dependency: culori.
36
+ */
37
+
38
+ import data from './theme-data.js';
39
+ import {
40
+ RAMP_STEPS,
41
+ TARGET_LIGHTNESS,
42
+ CHROMA_ENVELOPE,
43
+ generateRampFromSeed,
44
+ hexAt,
45
+ cieL,
46
+ toOklch,
47
+ toLab,
48
+ } from './lib/ramp-math.js';
49
+ import { resolveTokenColor, resolveTokenPaint, compositeOver, dimBackdrop, backdropBrightness } from './lib/resolve.js';
50
+
51
+ /** The neutral ramp's 21 steps. */
52
+ export const BASE_STEPS = [
53
+ 50, 75, 100, 150, 200, 250, 300, 350, 400, 450, 500,
54
+ 550, 600, 650, 700, 750, 800, 850, 900, 925, 950,
55
+ ];
56
+
57
+ export { RAMP_STEPS };
58
+
59
+ const TOOL = 'zabi-theme';
60
+ const WHITE = '#ffffff';
61
+
62
+ /** Below this OKLCH chroma a colour has no hue worth building a ramp from. */
63
+ const MIN_USEFUL_CHROMA = 0.02;
64
+ /** A ramp never asks for more chroma than this; sRGB cannot show it anyway. */
65
+ const MAX_PEAK_CHROMA = 0.4;
66
+ /** The envelope is not trusted below this when dividing a pale input by it. */
67
+ const MIN_ENVELOPE = 0.2;
68
+ /** A neutral stays a neutral: this is the most chroma any of its steps gets. */
69
+ const MAX_NEUTRAL_CHROMA = 0.03;
70
+ /** `neutralChroma` may lift the cap, but not past this: beyond it the steps stop being neutrals. */
71
+ const MAX_NEUTRAL_CHROMA_OPTION = 0.1;
72
+ /** The least any neutral step keeps of that, so the lightest steps still tint. */
73
+ const MIN_NEUTRAL_PROFILE = 0.25;
74
+ /** CIELAB ΔE past which the input is visibly not one of the ramp's steps. */
75
+ const FAR_FROM_RAMP = 10;
76
+
77
+ /* ---------- input ---------- */
78
+
79
+ function parseHex(value, option) {
80
+ const shown = typeof value === 'string' ? `"${value}"` : String(value);
81
+ const fail = () =>
82
+ new TypeError(`${TOOL}: ${option} must be a hex colour such as "#0026EA" or "#06e" (got ${shown})`);
83
+ if (typeof value !== 'string') throw fail();
84
+ const text = value.trim().replace(/^#/, '');
85
+ if (!/^(?:[0-9a-fA-F]{3}|[0-9a-fA-F]{6})$/.test(text)) throw fail();
86
+ const full = text.length === 3 ? text.split('').map((c) => c + c).join('') : text;
87
+ return `#${full.toLowerCase()}`;
88
+ }
89
+
90
+ function describe(hex) {
91
+ const { c, h } = toOklch(hex);
92
+ return { hex, lightness: cieL(hex), chroma: c ?? 0, hue: Number.isFinite(h) ? h : 0 };
93
+ }
94
+
95
+ /** Notes about an input that will not give the ramp its author expects. */
96
+ function inputWarnings(option, colour) {
97
+ const out = [];
98
+ const add = (message) => out.push({ type: 'input', option, message: `${option} ${colour.hex} ${message}` });
99
+ if (colour.chroma < MIN_USEFUL_CHROMA) {
100
+ add('has almost no colour in it, so the ramp is a grey scale. Give a more saturated colour for a visible hue.');
101
+ } else if (colour.lightness > 96) {
102
+ add('is nearly white: only its hue is used, and the ramp is built at the chroma that hue can carry.');
103
+ } else if (colour.lightness < 6) {
104
+ add('is nearly black: only its hue is used, and the ramp is built at the chroma that hue can carry.');
105
+ }
106
+ return out;
107
+ }
108
+
109
+ /* ---------- overrides ---------- */
110
+
111
+ const MODE_KEYS = ['light', 'dark', 'both'];
112
+
113
+ function checkDeclarations(map, where, flat) {
114
+ for (const [name, value] of Object.entries(map)) {
115
+ if (!name.startsWith('--') || typeof value !== 'string' || !value.trim()) {
116
+ throw new TypeError(
117
+ flat
118
+ ? `${TOOL}: overrides must map custom property names ("--color-link") to values (got ${name}: ${value})`
119
+ : `${TOOL}: ${where}["${name}"] must map a custom property name ("--color-link") to a non-empty string value (got ${name}: ${typeof value === 'string' ? `"${value}"` : String(value)})`,
120
+ );
121
+ }
122
+ }
123
+ }
124
+
125
+ /**
126
+ * `overrides` is a flat map of `--token: value` (both modes), or an object with
127
+ * any of `light`, `dark` and `both`, each a flat map. Flat keys beside them
128
+ * count as `both`. Returns the maps by mode.
129
+ */
130
+ function readOverrides(overrides) {
131
+ if (overrides === null || typeof overrides !== 'object' || Array.isArray(overrides)) {
132
+ throw new TypeError(`${TOOL}: overrides must be an object of token names to values`);
133
+ }
134
+ const nested = MODE_KEYS.some((key) => key in overrides);
135
+ const out = { light: {}, dark: {}, both: {} };
136
+ if (!nested) {
137
+ checkDeclarations(overrides, 'overrides', true);
138
+ out.both = { ...overrides };
139
+ return out;
140
+ }
141
+ for (const [key, value] of Object.entries(overrides)) {
142
+ if (MODE_KEYS.includes(key)) {
143
+ if (value === null || typeof value !== 'object' || Array.isArray(value)) {
144
+ throw new TypeError(`${TOOL}: overrides.${key} must be an object of token names to values (got ${JSON.stringify(value)})`);
145
+ }
146
+ checkDeclarations(value, `overrides.${key}`, false);
147
+ out[key] = { ...out[key], ...value };
148
+ } else {
149
+ if (!key.startsWith('--')) {
150
+ throw new TypeError(`${TOOL}: overrides has no "${key}"; it takes light, dark, both, or custom property names ("--color-link")`);
151
+ }
152
+ checkDeclarations({ [key]: value }, 'overrides', false);
153
+ out.both[key] = value;
154
+ }
155
+ }
156
+ return out;
157
+ }
158
+
159
+ /**
160
+ * What the overrides write. `root` goes on `:root`: both, then light. `dark` is
161
+ * what the dark rules must say so that `:root` does not reach dark: the dark
162
+ * override; for a token set in `light` alone, dark's own value without it; for
163
+ * one also set in `both`, that value. `own` is that dark value, from what the
164
+ * file writes already, then the library's dark theme, then its light default.
165
+ */
166
+ function overrideMaps({ light, dark, both }, tokens, darkTokens) {
167
+ const root = { ...both, ...light };
168
+ const out = { ...dark };
169
+ for (const name of Object.keys(light)) {
170
+ if (name in dark) continue;
171
+ out[name] = both[name] ?? darkTokens[name] ?? data.darkOnly[name] ?? tokens[name] ?? data.light[name];
172
+ }
173
+ for (const name of Object.keys(out)) if (out[name] === undefined) delete out[name];
174
+ const skip = new Set([...Object.keys(root), ...Object.keys(dark)]);
175
+ return { root, dark: out, skip };
176
+ }
177
+
178
+ /* ---------- chromatic ramps ---------- */
179
+
180
+ /** The chroma envelope at any L*, by linear interpolation between the steps. */
181
+ function envelopeAt(lightness) {
182
+ const points = RAMP_STEPS.map((step) => [TARGET_LIGHTNESS[step], CHROMA_ENVELOPE[step]]).sort((a, b) => a[0] - b[0]);
183
+ if (lightness <= points[0][0]) return points[0][1];
184
+ if (lightness >= points[points.length - 1][0]) return points[points.length - 1][1];
185
+ for (let i = 0; i < points.length - 1; i += 1) {
186
+ const [l0, e0] = points[i];
187
+ const [l1, e1] = points[i + 1];
188
+ if (lightness >= l0 && lightness <= l1) return e0 + ((lightness - l0) / (l1 - l0)) * (e1 - e0);
189
+ }
190
+ return 1;
191
+ }
192
+
193
+ /**
194
+ * The input's own chroma, read back through the envelope: a colour at the
195
+ * lightness of step 500 is taken to be 92% of the ramp's peak, and so on. That
196
+ * is what lets the step nearest the input come out close to the input.
197
+ */
198
+ function chromaticRamp(colour) {
199
+ const envelope = Math.max(envelopeAt(colour.lightness), MIN_ENVELOPE);
200
+ const peakChroma = Math.min(colour.chroma / envelope, MAX_PEAK_CHROMA);
201
+ return generateRampFromSeed({ hue: colour.hue, peakChroma, hueShift: 0 });
202
+ }
203
+
204
+ /* ---------- neutral ramp ---------- */
205
+
206
+ function neutralRamp(colour, chromaOption) {
207
+ const defaults = BASE_STEPS.map((step) => {
208
+ const hex = data.light[`--zabi-base-${step}`];
209
+ return { step, lightness: cieL(hex), chroma: toOklch(hex).c ?? 0 };
210
+ });
211
+ const peak = Math.max(...defaults.map((d) => d.chroma)) || 1;
212
+ // The default greys carry most chroma mid-ramp and almost none at the ends.
213
+ // The tint keeps that shape, with a floor so the page and card still tint.
214
+ const profile = (d) => Math.max(d.chroma / peak, MIN_NEUTRAL_PROFILE);
215
+ const nearest = defaults.reduce((best, d) =>
216
+ Math.abs(d.lightness - colour.lightness) < Math.abs(best.lightness - colour.lightness) ? d : best,
217
+ );
218
+ // `neutralChroma` replaces the capped value; the profile across the steps stays.
219
+ const peakChroma = chromaOption ?? Math.min(colour.chroma / profile(nearest), MAX_NEUTRAL_CHROMA);
220
+ const out = {};
221
+ for (const d of defaults) out[d.step] = hexAt(d.lightness, peakChroma * profile(d), colour.hue);
222
+ return out;
223
+ }
224
+
225
+ /* ---------- ink roles ---------- */
226
+
227
+ /**
228
+ * The translucent roles that are fixed alpha tints of near-black (light) or
229
+ * near-white (dark) in the library, so grey over any ramp. With `neutralChroma`
230
+ * they are the neutral ramp's own ink at the same alpha instead. Each keeps the
231
+ * alpha the library gives it, read from the default theme's data. The dark
232
+ * `--color-border-overlay` is already a ramp step: it is restated as it is.
233
+ */
234
+ const INK_ROLES = [
235
+ '--color-action-secondary',
236
+ '--color-action-secondary-hover',
237
+ '--color-action-secondary-active',
238
+ '--color-surface-hover',
239
+ '--color-surface-active',
240
+ '--color-border-overlay',
241
+ ];
242
+
243
+ /** `rgba(9, 9, 11, 0.1)` -> 10. Null for anything that is not an rgba() tint. */
244
+ function alphaPercent(value) {
245
+ const match = /^rgba\(\s*[\d.]+\s*,\s*[\d.]+\s*,\s*[\d.]+\s*,\s*([\d.]+)\s*\)$/.exec(String(value ?? '').trim());
246
+ return match ? Math.round(Number(match[1]) * 1000) / 10 : null;
247
+ }
248
+
249
+ /** `#18181b` -> `24 24 27`, the form `--shadow-color` takes. */
250
+ function triplet(hex) {
251
+ return [1, 3, 5].map((i) => parseInt(hex.slice(i, i + 2), 16)).join(' ');
252
+ }
253
+
254
+ /**
255
+ * Light and dark declarations for the ink roles over a neutral ramp. A role
256
+ * the author overrides in a mode (or in `both`) is left out for that mode, so
257
+ * their override is what it shows. The other mode keeps the ramp's ink: a role
258
+ * set in `light` alone is still the tinted mix in dark, not the library's grey.
259
+ */
260
+ function inkTokens(ramp, modes) {
261
+ const light = {};
262
+ const dark = {};
263
+ const setIn = (mode, role) => role in modes[mode] || role in modes.both;
264
+ const mix = (step, percent) => `color-mix(in srgb, var(--zabi-base-${step}) ${percent}%, transparent)`;
265
+ for (const role of INK_ROLES) {
266
+ const lightPercent = alphaPercent(data.light[role]);
267
+ if (lightPercent !== null && !setIn('light', role)) light[role] = mix(900, lightPercent);
268
+ if (setIn('dark', role)) continue;
269
+ const darkPercent = alphaPercent(data.darkOnly[role]);
270
+ // A role the dark theme already points at a ramp step (the overlay edge)
271
+ // keeps that: the light value on `:root` would otherwise win there too.
272
+ if (darkPercent !== null) dark[role] = mix(50, darkPercent);
273
+ else if (data.darkOnly[role] !== undefined) dark[role] = data.darkOnly[role];
274
+ }
275
+ if (!setIn('light', '--shadow-color')) light['--shadow-color'] = triplet(ramp[900]);
276
+ if (!setIn('dark', '--shadow-color')) dark['--shadow-color'] = '0 0 0';
277
+ return { light, dark };
278
+ }
279
+
280
+ /* ---------- closest step ---------- */
281
+
282
+ function deltaE(a, b) {
283
+ const [x, y] = [toLab(a), toLab(b)];
284
+ return Math.hypot(x.l - y.l, x.a - y.a, x.b - y.b);
285
+ }
286
+
287
+ function closestStep(hex, ramp) {
288
+ let best = null;
289
+ for (const [step, value] of Object.entries(ramp)) {
290
+ const distance = deltaE(hex, value);
291
+ if (!best || distance < best.deltaE) best = { step: Number(step), hex: value, deltaE: distance };
292
+ }
293
+ return { ...best, deltaE: Math.round(best.deltaE * 10) / 10, exact: best.hex === hex };
294
+ }
295
+
296
+ /* ---------- contrast ---------- */
297
+
298
+ function channel(c) {
299
+ const v = c / 255;
300
+ return v <= 0.04045 ? v / 12.92 : ((v + 0.055) / 1.055) ** 2.4;
301
+ }
302
+
303
+ function luminance(hex) {
304
+ const [r, g, b] = [1, 3, 5].map((i) => parseInt(hex.slice(i, i + 2), 16));
305
+ return 0.2126 * channel(r) + 0.7152 * channel(g) + 0.0722 * channel(b);
306
+ }
307
+
308
+ /** WCAG contrast ratio, rounded to two decimals as the library's guard does. */
309
+ function contrast(a, b) {
310
+ const [hi, lo] = [luminance(a), luminance(b)].sort((x, y) => y - x);
311
+ return Math.round(((hi + 0.05) / (lo + 0.05)) * 100) / 100;
312
+ }
313
+
314
+ /**
315
+ * What the page computes once the generated file is imported after the theme
316
+ * files. The file is last in the cascade and `:root` has the specificity of
317
+ * `.dark`, so its declarations win in both modes.
318
+ */
319
+ function themeMaps(tokens, darkTokens = {}) {
320
+ return {
321
+ light: { ...data.light, ...tokens },
322
+ // The generated dark rule comes after the generated `:root` rule.
323
+ dark: { ...data.light, ...data.darkOnly, ...tokens, ...darkTokens },
324
+ };
325
+ }
326
+
327
+ function checkPairs(tokens, darkTokens = {}, extraPairs = [], { gradients = true } = {}) {
328
+ const maps = themeMaps(tokens, darkTokens);
329
+ const warnings = [];
330
+ let checked = 0;
331
+ for (const mode of ['light', 'dark']) {
332
+ for (const pair of [...data.pairs, ...extraPairs]) {
333
+ // `only`: a pair about one mode (the dark field is a translucent step; light's is opaque).
334
+ if (pair.only && pair.only !== mode) continue;
335
+ // The control veils are settled last (settleControlGradients), so a
336
+ // choice made before then must not be steered by them.
337
+ if (!gradients && pair.gradient) continue;
338
+ const behind = pair.behind ? (typeof pair.behind === 'string' ? pair.behind : pair.behind[mode]) : null;
339
+ let bg = resolveTokenColor(maps[mode], pair.bg);
340
+ if (behind) {
341
+ // A material: its fill (an alpha paint) laid over what is behind it.
342
+ const paint = resolveTokenPaint(maps[mode], pair.bg);
343
+ const flat = resolveTokenColor(maps[mode], behind);
344
+ // A material's filter dims what is behind it (D137); other `behind` pairs are not filtered.
345
+ const backdrop = flat && pair.material ? dimBackdrop(flat, backdropBrightness(maps[mode])) : flat;
346
+ bg = paint && backdrop ? backdrop : null;
347
+ // `layers`: the same paint stacked, each laid over the last (nested translucent cards).
348
+ for (let layer = 0; layer < (pair.layers ?? 1) && bg; layer += 1) bg = compositeOver(paint, bg);
349
+ }
350
+ let fg = resolveTokenColor(maps[mode], pair.fg);
351
+ if (behind && pair.fgPaint) {
352
+ // `fgPaint`: the foreground is a fill too (a pressed field against the resting one), laid over the same backdrop.
353
+ const fgPaint = resolveTokenPaint(maps[mode], pair.fg);
354
+ let under = resolveTokenColor(maps[mode], behind);
355
+ for (let layer = 0; layer < (pair.layers ?? 1) && under && fgPaint; layer += 1) under = compositeOver(fgPaint, under);
356
+ fg = fgPaint ? under : null;
357
+ }
358
+ if (!bg || !fg) continue; // an alpha tint: not one flat colour
359
+ checked += 1;
360
+ const ratio = contrast(bg, fg);
361
+ if (ratio >= pair.min) continue;
362
+ // A pair with a second foreground passes when either one does.
363
+ const other = pair.orFg ? resolveTokenColor(maps[mode], pair.orFg) : null;
364
+ if (other && contrast(bg, other) >= pair.min) continue;
365
+ warnings.push({
366
+ type: 'contrast',
367
+ pair: pair.name,
368
+ mode,
369
+ ratio,
370
+ required: pair.min,
371
+ foreground: { token: pair.fg, value: fg },
372
+ background: { token: pair.bg, value: bg },
373
+ message: `${mode} · ${pair.name}: ${fg} on ${bg} is ${ratio}:1, needs ${pair.min}:1 (${pair.fg} on ${pair.bg}${behind ? ` over ${behind}` : ''})`,
374
+ });
375
+ }
376
+ }
377
+ return { warnings, checked };
378
+ }
379
+
380
+ /* ---------- "on" colours ---------- */
381
+
382
+ const ON_ROLES = {
383
+ brand: {
384
+ role: '--color-on-brand',
385
+ fills: ['--color-action-primary', '--color-action-primary-hover', '--color-action-primary-active'],
386
+ },
387
+ accent: {
388
+ role: '--color-on-accent',
389
+ fills: ['--color-accent', '--color-accent-hover', '--color-accent-active'],
390
+ },
391
+ };
392
+
393
+ /**
394
+ * The label for a solid fill: white or the ramp's dark end, whichever reaches
395
+ * 4.5:1 on the fill, its hover and its active step. The library's own choice
396
+ * for the role and mode is tried first (white, except the accent in dark); if neither passes, the better one is kept and
397
+ * the pair check reports it.
398
+ */
399
+ function chooseOn(name, mode, tokens, darkTokens = {}) {
400
+ const { role, fills } = ON_ROLES[name];
401
+ const knob = mode === 'dark' ? `--zabi-on-${name}-dark` : `--zabi-on-${name}`;
402
+ const darkEnd = `var(--zabi-${name}-950)`;
403
+ // White first for the brand in both modes: the library's dark primary is the
404
+ // same mid step as light. A pale dark primary (an app's own override, or
405
+ // darkPrimary: 'mirror') fails white and keeps the ramp's dark end. The
406
+ // accent's dark fill is the light step, so its dark end still goes first.
407
+ const candidates = mode === 'dark' && name === 'accent' ? [darkEnd, WHITE] : [WHITE, darkEnd];
408
+ let best = null;
409
+ for (const value of candidates) {
410
+ const map = themeMaps({ ...tokens, [knob]: value }, darkTokens)[mode];
411
+ const label = resolveTokenColor(map, role);
412
+ const ratios = fills.map((fill) => {
413
+ const colour = resolveTokenColor(map, fill);
414
+ return label && colour ? contrast(label, colour) : 0;
415
+ });
416
+ const worst = Math.min(...ratios);
417
+ if (worst >= 4.5) return [knob, value];
418
+ if (!best || worst > best.worst) best = { value, worst };
419
+ }
420
+ return [knob, best.value];
421
+ }
422
+
423
+ /* ---------- dark primary ---------- */
424
+
425
+ /**
426
+ * What `darkPrimary: 'mirror'` writes under the dark selectors: the 8.1 dark
427
+ * fills, which are the mirrored semantic steps (pale in dark), the danger
428
+ * aliases with the label they had, and the lighten-heavy control veil made for
429
+ * a pale fill. The label knob is chosen by chooseOn like any other.
430
+ */
431
+ const MIRRORED_DARK = {
432
+ '--color-action-primary': 'var(--color-brand-600)',
433
+ '--color-action-primary-hover': 'var(--color-brand-700)',
434
+ '--color-action-primary-active': 'var(--color-brand-800)',
435
+ '--color-action-danger': 'var(--color-error-600)',
436
+ '--color-action-danger-hover': 'var(--color-error-700)',
437
+ '--color-action-danger-active': 'var(--color-error-800)',
438
+ '--color-action-danger-text': 'var(--color-error-50)',
439
+ '--gradient-control-top-strength': '22%',
440
+ '--gradient-control-bottom-strength': '6%',
441
+ };
442
+
443
+ function readDarkPrimary(value) {
444
+ if (value === undefined || value === null) return 'brand';
445
+ if (value !== 'brand' && value !== 'mirror') {
446
+ throw new TypeError(
447
+ `${TOOL}: darkPrimary must be "brand" or "mirror" (got ${typeof value === 'string' ? `"${value}"` : JSON.stringify(value)})`,
448
+ );
449
+ }
450
+ return value;
451
+ }
452
+
453
+ /* ---------- pinning ---------- */
454
+
455
+ /**
456
+ * A pinned colour is the fill; its hover and pressed states and one deeper
457
+ * step are the same hue and chroma moved along L* by the distances the curve
458
+ * puts between steps 600, 700, 800 and 900 in light (darker), and between
459
+ * 400, 300, 200 and 100 in dark (lighter).
460
+ */
461
+ const SHADE_STEPS = {
462
+ light: [700, 800, 900].map((step) => TARGET_LIGHTNESS[step] - TARGET_LIGHTNESS[600]),
463
+ dark: [300, 200, 100].map((step) => TARGET_LIGHTNESS[step] - TARGET_LIGHTNESS[400]),
464
+ };
465
+ /** No shade is asked to go past these: there is nothing darker or lighter to show. */
466
+ const MIN_SHADE_L = 4;
467
+ const MAX_SHADE_L = 98;
468
+
469
+ /**
470
+ * [hover, pressed, deeper] for a pinned fill, going `darker` or `lighter`, or
471
+ * null when the pressed step would leave the range: a colour that near black
472
+ * has nothing darker to show.
473
+ */
474
+ function shades(colour, mode, direction) {
475
+ const sign = direction === 'darker' ? -1 : 1;
476
+ const steps = SHADE_STEPS[mode].map((step) => sign * Math.abs(step));
477
+ const pressed = colour.lightness + steps[1];
478
+ if (pressed < MIN_SHADE_L || pressed > MAX_SHADE_L) return null;
479
+ return steps.map((step) =>
480
+ hexAt(Math.min(MAX_SHADE_L, Math.max(MIN_SHADE_L, colour.lightness + step)), colour.chroma, colour.hue),
481
+ );
482
+ }
483
+
484
+ /** The library's own direction first: darker in light, lighter in dark. */
485
+ const SHADE_DIRECTIONS = { light: ['darker', 'lighter'], dark: ['lighter', 'darker'] };
486
+
487
+ /**
488
+ * The roles a pinned colour can take over. `fill` always follows, since that
489
+ * is what pinning means. Each of `follow` is the brand colour by design in
490
+ * the library (the focus ring is the primary fill; a link is the brand as
491
+ * text), so it follows too, but only if every guarded pair still passes with
492
+ * it; otherwise it stays on the ramp, which was built to pass.
493
+ *
494
+ * What stays on the ramp whatever happens: the tints (`-subtle`, `-border`),
495
+ * because a tint is a lightness, not the brand colour, and the ramp steps
496
+ * themselves (`--color-brand-*`).
497
+ */
498
+ const PIN_ROLES = {
499
+ brand: {
500
+ fill: (c, [hover, pressed, deeper]) => ({
501
+ '--color-action-primary': c,
502
+ '--color-action-primary-hover': hover,
503
+ '--color-action-primary-active': pressed,
504
+ '--color-primary': c,
505
+ '--color-primary-weak': hover,
506
+ '--color-primary-medium': pressed,
507
+ '--color-primary-strong': deeper,
508
+ }),
509
+ follow: {
510
+ 'focus ring': (c, [hover, pressed, deeper]) => ({
511
+ '--color-focus': c,
512
+ '--color-focus-weak': hover,
513
+ '--color-focus-medium': pressed,
514
+ '--color-focus-strong': deeper,
515
+ }),
516
+ link: (c, [hover]) => ({ '--color-link': c, '--color-link-hover': hover }),
517
+ },
518
+ // A fill has to be seen against what it sits on (WCAG 1.4.11). On the
519
+ // ramp, step 600 always is; an arbitrary colour may not be.
520
+ pairs: [
521
+ { name: 'pinned primary fill against the page', bg: '--color-surface-base', fg: '--color-action-primary', min: 3 },
522
+ { name: 'pinned primary fill against a card', bg: '--color-surface-raised', fg: '--color-action-primary', min: 3 },
523
+ ],
524
+ },
525
+ accent: {
526
+ fill: (c, [hover, pressed]) => ({
527
+ '--color-accent': c,
528
+ '--color-accent-hover': hover,
529
+ '--color-accent-active': pressed,
530
+ }),
531
+ follow: {
532
+ 'accent text': (c) => ({ '--color-accent-text': c }),
533
+ },
534
+ pairs: [
535
+ { name: 'pinned accent fill against the page', bg: '--color-surface-base', fg: '--color-accent', min: 3 },
536
+ { name: 'pinned accent fill against a card', bg: '--color-surface-raised', fg: '--color-accent', min: 3 },
537
+ ],
538
+ },
539
+ };
540
+
541
+ /** Which of the two colours `pin` asks for. */
542
+ function readPin(pin) {
543
+ if (pin === undefined || pin === null || pin === false) return { brand: false, accent: false };
544
+ if (pin === true) return { brand: true, accent: false };
545
+ if (typeof pin !== 'object') {
546
+ throw new TypeError(`${TOOL}: pin must be true, false or { brand, accent } (got ${JSON.stringify(pin)})`);
547
+ }
548
+ for (const key of Object.keys(pin)) {
549
+ if (key !== 'brand' && key !== 'accent') throw new TypeError(`${TOOL}: pin has no "${key}"; it takes brand and accent`);
550
+ }
551
+ return { brand: pin.brand === true, accent: pin.accent === true };
552
+ }
553
+
554
+ const failing = (warnings, mode) => new Set(warnings.filter((w) => w.mode === mode).map((w) => w.pair));
555
+
556
+ /**
557
+ * Pins one colour. Returns the role overrides for `:root` and for the dark
558
+ * rule, the "on" knobs, and what was decided.
559
+ *
560
+ * Light: the fill is the colour, always. If that fails a pair, the pair is
561
+ * reported; the colour is not moved.
562
+ *
563
+ * Dark: the same hex is usually wrong there. A deep blue that carries white
564
+ * text on a white page is about 2:1 against a dark page, where a fill needs
565
+ * 3:1, and no label makes that a button. So dark takes the colour only when nothing guarded fails with it,
566
+ * and otherwise keeps what it has without `pin`: the mirrored ramp step.
567
+ */
568
+ function pinColour(name, colour, tokens, earlierDark, userOverrides, extraPairs, skip, darkPrimary = 'brand') {
569
+ const roles = PIN_ROLES[name];
570
+ const keep = (group) => Object.fromEntries(Object.entries(group).filter(([role]) => !skip.has(role)));
571
+ // What dark has for a role without the pin: what the file already says for
572
+ // dark (the mirrored fills under darkPrimary: 'mirror'), then the library.
573
+ const libraryDark = (role) => earlierDark[role] ?? data.darkOnly[role] ?? data.light[role];
574
+ // The brand's dark fill is the light recipe (the same steps, darker states)
575
+ // unless it is mirrored; the accent's dark fill is still the pale step.
576
+ const recipe = (mode) => (mode === 'dark' && name === 'brand' && darkPrimary === 'brand' ? 'light' : mode);
577
+ const light = {};
578
+ const dark = {};
579
+ const knobs = {};
580
+ const report = { hex: colour.hex, light: { followed: [], onRamp: [] }, dark: { pinned: false, followed: [], onRamp: [] } };
581
+
582
+ // What the page has when a set of roles is tried, with the label chosen for it.
583
+ const attempt = (mode, lightRoles, darkRoles) => {
584
+ const root = { ...tokens, ...knobs, ...lightRoles, ...userOverrides };
585
+ const darkRule = { ...earlierDark, ...darkRoles };
586
+ const [knob, value] = chooseOn(name, mode, root, darkRule);
587
+ const result = checkPairs({ ...root, [knob]: value }, darkRule, extraPairs, { gradients: false });
588
+ return { knob, value, failures: failing(result.warnings, mode) };
589
+ };
590
+ const noWorse = (after, before) => [...after].every((pair) => before.has(pair));
591
+
592
+ /**
593
+ * The fill with its states, in the direction that fails least. The states
594
+ * decide which label can be used: darker states under a dark label lose
595
+ * contrast with every step, so an amber that needs a dark label gets
596
+ * lighter states, as its mirrored ramp step has in dark.
597
+ */
598
+ const bestFill = (mode, tryWith) => {
599
+ let best = null;
600
+ for (const direction of SHADE_DIRECTIONS[recipe(mode)]) {
601
+ const states = shades(colour, recipe(mode), direction);
602
+ if (!states) continue;
603
+ const result = tryWith(keep(roles.fill(colour.hex, states)));
604
+ if (!best || result.failures.size < best.result.failures.size) best = { direction, states, result };
605
+ }
606
+ return best;
607
+ };
608
+
609
+ // Every role written on `:root` reaches dark as well, so dark restates it;
610
+ // until dark is decided below, with the library's own dark value.
611
+ const restatedFor = (lightRoles) =>
612
+ Object.fromEntries(Object.keys(lightRoles).map((role) => [role, dark[role] ?? libraryDark(role)]));
613
+ const restated = () => restatedFor(light);
614
+
615
+ // Light.
616
+ const lightFill = bestFill('light', (group) => attempt('light', group, restatedFor(group)));
617
+ Object.assign(light, keep(roles.fill(colour.hex, lightFill.states)));
618
+ report.light.states = lightFill.direction;
619
+ let current = attempt('light', light, restated());
620
+ for (const [label, build] of Object.entries(roles.follow)) {
621
+ const group = keep(build(colour.hex, lightFill.states));
622
+ const next = attempt('light', { ...light, ...group }, restated());
623
+ if (noWorse(next.failures, current.failures)) {
624
+ Object.assign(light, group);
625
+ current = next;
626
+ report.light.followed.push(label);
627
+ } else {
628
+ report.light.onRamp.push(label);
629
+ }
630
+ }
631
+ knobs[current.knob] = current.value;
632
+
633
+ // Dark.
634
+ const unpinned = attempt('dark', light, restated());
635
+ const darkBest = bestFill('dark', (group) => attempt('dark', light, { ...restated(), ...group }));
636
+ const darkFill = keep(roles.fill(colour.hex, darkBest.states));
637
+ let darkCurrent = darkBest.result;
638
+ if (noWorse(darkCurrent.failures, unpinned.failures)) {
639
+ Object.assign(dark, darkFill);
640
+ report.dark.pinned = true;
641
+ report.dark.states = darkBest.direction;
642
+ for (const [label, build] of Object.entries(roles.follow)) {
643
+ const group = keep(build(colour.hex, darkBest.states));
644
+ const next = attempt('dark', light, { ...restated(), ...dark, ...group });
645
+ // Only where light followed too: a role that is the brand colour in
646
+ // one mode and a ramp step in the other would be two brands.
647
+ if (report.light.followed.includes(label) && noWorse(next.failures, darkCurrent.failures)) {
648
+ Object.assign(dark, group);
649
+ darkCurrent = next;
650
+ report.dark.followed.push(label);
651
+ } else {
652
+ report.dark.onRamp.push(label);
653
+ }
654
+ }
655
+ } else {
656
+ darkCurrent = unpinned;
657
+ report.dark.onRamp.push(...Object.keys(roles.follow));
658
+ report.dark.failed = [...darkBest.result.failures].filter((pair) => !unpinned.failures.has(pair));
659
+ }
660
+ knobs[darkCurrent.knob] = darkCurrent.value;
661
+
662
+ return { light, dark: { ...restated(), ...dark }, knobs, report };
663
+ }
664
+
665
+ /* ---------- links that follow the primary ---------- */
666
+
667
+ /** What the app's overrides say a role is in `mode`, or undefined. */
668
+ const overrideIn = (modes, mode, role) => modes[mode][role] ?? modes.both[role];
669
+
670
+ /**
671
+ * Sets roles for one mode, answering a light value with the library's own
672
+ * dark one so that dark does not move (as overrideMaps does for `light`).
673
+ */
674
+ function setRoles(tokens, darkTokens, mode, roles) {
675
+ if (mode === 'light') {
676
+ const dark = { ...darkTokens };
677
+ for (const role of Object.keys(roles)) if (!(role in dark)) dark[role] = data.darkOnly[role] ?? data.light[role];
678
+ return { tokens: { ...tokens, ...roles }, darkTokens: dark };
679
+ }
680
+ return { tokens, darkTokens: { ...darkTokens, ...roles } };
681
+ }
682
+
683
+ /** The pairs failing in `mode` with these tokens. */
684
+ const failingIn = (tokens, darkTokens, mode) => failing(checkPairs(tokens, darkTokens).warnings, mode);
685
+
686
+ /**
687
+ * `--color-link` follows an overridden primary, as a pinned colour's link
688
+ * does (PIN_ROLES): the library's link is the brand's step 700 because step
689
+ * 600, its primary, does not hold 4.5:1 as text on tints. An app that sets
690
+ * its own primary (a deeper step, say) otherwise has links beside buttons in
691
+ * a different blue. In each mode where the app sets `--color-action-primary`
692
+ * and not `--color-link`, link is that primary and, if the app set a primary
693
+ * hover too, link-hover is that (otherwise the ramp's link-hover stays: the
694
+ * library's hover is a step the app's primary does not use), but only if no
695
+ * guarded pair fails that did not fail before; otherwise the ramp's link
696
+ * stays in that mode.
697
+ * An explicit `--color-link` always wins.
698
+ */
699
+ function followPrimaryWithLink(tokens, darkTokens, modes) {
700
+ for (const mode of ['light', 'dark']) {
701
+ const primary = overrideIn(modes, mode, '--color-action-primary');
702
+ if (primary === undefined || overrideIn(modes, mode, '--color-link') !== undefined) continue;
703
+ const hover = overrideIn(modes, mode, '--color-action-primary-hover');
704
+ const before = failingIn(tokens, darkTokens, mode);
705
+ for (const roles of [
706
+ ...(hover === undefined ? [] : [{ '--color-link': primary, '--color-link-hover': hover }]),
707
+ { '--color-link': primary },
708
+ ]) {
709
+ const next = setRoles(tokens, darkTokens, mode, roles);
710
+ const after = failingIn(next.tokens, next.darkTokens, mode);
711
+ if ([...after].every((pair) => before.has(pair))) {
712
+ ({ tokens, darkTokens } = next);
713
+ break;
714
+ }
715
+ }
716
+ }
717
+ return { tokens, darkTokens };
718
+ }
719
+
720
+ /**
721
+ * The edge of the Progress track (`--color-progress-track-border`) is the field
722
+ * edge by default, because the default track is the field fill and does not
723
+ * reach 3:1 against the page or a card (WCAG 1.4.11). An app that overrides
724
+ * the track so that it does reach 3:1 against both, in a mode, does not need
725
+ * an edge there: it is written as `transparent` for that mode, as the link
726
+ * follows an overridden primary. An edge the app sets itself, in either
727
+ * mode or both, is its decision, and is never moved (a `transparent` of its
728
+ * own draws no warning: a pair with no flat colour is skipped).
729
+ */
730
+ const PROGRESS_TRACK = '--color-progress-track';
731
+ const PROGRESS_TRACK_EDGE = '--color-progress-track-border';
732
+ const MIN_TRACK_AGAINST_SURFACE = 3;
733
+
734
+ function settleProgressTrackEdge(tokens, darkTokens, modes) {
735
+ for (const mode of ['light', 'dark']) {
736
+ if (overrideIn(modes, mode, PROGRESS_TRACK) === undefined) continue;
737
+ if (overrideIn(modes, mode, PROGRESS_TRACK_EDGE) !== undefined) continue;
738
+ const maps = themeMaps(tokens, darkTokens)[mode];
739
+ const track = resolveTokenColor(maps, PROGRESS_TRACK);
740
+ if (!track) continue; // an alpha tint: not one flat colour
741
+ const surfaces = ['--color-surface-base', '--color-surface-raised'].map((role) => resolveTokenColor(maps, role));
742
+ if (surfaces.some((surface) => !surface || contrast(track, surface) < MIN_TRACK_AGAINST_SURFACE)) continue;
743
+ ({ tokens, darkTokens } = setRoles(tokens, darkTokens, mode, { [PROGRESS_TRACK_EDGE]: 'transparent' }));
744
+ }
745
+ return { tokens, darkTokens };
746
+ }
747
+
748
+ /* ---------- control gradients ---------- */
749
+
750
+ /** The strongest veil tried: the library's own cap. */
751
+ const MAX_VEIL = 14;
752
+
753
+ /**
754
+ * The veil of a solid control is a light stop on top and a dark one at the
755
+ * bottom, over the control's own fill. The library's light default darkens
756
+ * only (top 0, bottom 14%): a darker fill can only help a light label. An app
757
+ * whose fill takes a DARK label (a light accent, an amber) loses contrast to
758
+ * that darkening, so for each veil and each mode: if every pair passes, nothing
759
+ * is written; if not, the veil is flipped to lighten-only (the top at the
760
+ * largest whole percent up to 14 that passes, the bottom 0), or the other way
761
+ * if the label is the light one; if neither direction passes, both are 0%,
762
+ * which is no veil. A strength the app set itself is never moved: the pair
763
+ * check reports it. Primary and danger share one veil, so they flip together.
764
+ */
765
+ function settleControlGradients(tokens, darkTokens, modes) {
766
+ const veils = new Map();
767
+ for (const control of data.controlGradients) {
768
+ const key = control.strengths.join('|');
769
+ if (!veils.has(key)) veils.set(key, { strengths: control.strengths, controls: [] });
770
+ veils.get(key).controls.push(control);
771
+ }
772
+ for (const mode of ['light', 'dark']) {
773
+ for (const { strengths, controls } of veils.values()) {
774
+ if (strengths.some((role) => overrideIn(modes, mode, role) !== undefined)) continue;
775
+ const failed = (t, d) =>
776
+ [...failingIn(t, d, mode)].filter((pair) => controls.some((control) => pair.startsWith(`${control.name} gradient `)));
777
+ if (failed(tokens, darkTokens).length === 0) continue;
778
+ const [top, bottom] = strengths;
779
+ const maps = themeMaps(tokens, darkTokens)[mode];
780
+ const labelIsDark = controls.some((control) => {
781
+ const label = resolveTokenColor(maps, control.on);
782
+ const fill = resolveTokenColor(maps, control.fills[0][1]);
783
+ return label && fill && luminance(label) < luminance(fill);
784
+ });
785
+ const directions = labelIsDark ? ['lighten', 'darken'] : ['darken', 'lighten'];
786
+ let chosen = { [top]: '0%', [bottom]: '0%' };
787
+ search: for (const direction of directions) {
788
+ for (let percent = MAX_VEIL; percent >= 1; percent -= 1) {
789
+ const roles = direction === 'lighten' ? { [top]: `${percent}%`, [bottom]: '0%' } : { [top]: '0%', [bottom]: `${percent}%` };
790
+ const next = setRoles(tokens, darkTokens, mode, roles);
791
+ if (failed(next.tokens, next.darkTokens).length === 0) {
792
+ chosen = roles;
793
+ break search;
794
+ }
795
+ }
796
+ }
797
+ ({ tokens, darkTokens } = setRoles(tokens, darkTokens, mode, chosen));
798
+ }
799
+ }
800
+ return { tokens, darkTokens };
801
+ }
802
+
803
+ /* ---------- output ---------- */
804
+
805
+ function rampTokens(name, ramp, steps) {
806
+ return Object.fromEntries(steps.map((step) => [`--zabi-${name}-${step}`, ramp[step]]));
807
+ }
808
+
809
+ function render({ inputs, closest, tokens, darkTokens, pinned, neutralChroma, darkPrimary, contrastWarnings, checked }) {
810
+ const describeInput = (name) =>
811
+ inputs[name]
812
+ ? `${inputs[name]} closest step ${closest[name].step} (${closest[name].hex}, ` +
813
+ `${closest[name].exact ? 'exact' : `ΔE ${closest[name].deltaE.toFixed(1)}`})`
814
+ : name === 'accent'
815
+ ? 'not set (library default: citron)'
816
+ : 'not set (library default)';
817
+ const header = [
818
+ '/*',
819
+ ` * Generated by ${TOOL} (zabi-components/create-theme). Do not edit; run it again.`,
820
+ ' *',
821
+ ` * brand ${describeInput('brand')}`,
822
+ ` * accent ${describeInput('accent')}`,
823
+ ` * neutral ${describeInput('neutral')}`,
824
+ ...(darkPrimary === 'mirror'
825
+ ? [
826
+ ' * dark primary: mirror. The primary and danger fills use the mirrored (pale) steps in',
827
+ ' * dark, as in 8.1, with the label and control veil chosen for them.',
828
+ ]
829
+ : []),
830
+ ...(neutralChroma === undefined
831
+ ? []
832
+ : [
833
+ ` * neutral chroma ${neutralChroma} (peak, OKLCH). The ink roles (secondary button,`,
834
+ ' * hover and pressed tints, overlay edge, shadow colour) follow the neutral ramp.',
835
+ ]),
836
+ ' *',
837
+ ...(pinned ? pinnedNotes(pinned) : [
838
+ ' * The ramps sit on the library\'s lightness curve, so step 600 means the same',
839
+ ' * lightness as in every built-in ramp. Your colour supplies hue and chroma and',
840
+ ' * is not pinned to a step: the exact hex may not appear below.',
841
+ ]),
842
+ ' *',
843
+ ' * Import after zabi-components/theme-only and zabi-components/theme-dark-only.',
844
+ ' * One file covers light and dark.',
845
+ ' *',
846
+ contrastWarnings.length === 0
847
+ ? ` * Contrast: all ${checked} role pairs pass WCAG AA in light and dark.`
848
+ : ` * Contrast: ${contrastWarnings.length} of ${checked} role pairs are below WCAG AA:`,
849
+ ...contrastWarnings.map((w) => ` * ${w.message}`),
850
+ ' */',
851
+ ];
852
+ const body = Object.entries(tokens).map(([name, value]) => ` ${name}: ${value};`);
853
+ const root = `${header.join('\n')}\n:root {\n${body.join('\n')}\n}\n`;
854
+ if (!pinned && Object.keys(darkTokens).length === 0) return root;
855
+ // A role on `:root` is one value in light and in dark, so dark says its own,
856
+ // under each selector the dark theme is published under.
857
+ const dark = Object.entries(darkTokens).map(([name, value]) => `${name}: ${value};`);
858
+ const { always, auto, media } = data.darkSelectors;
859
+ return (
860
+ root +
861
+ `\n${always} {\n${dark.map((line) => ` ${line}`).join('\n')}\n}\n` +
862
+ `\n@media ${media} {\n ${auto} {\n${dark.map((line) => ` ${line}`).join('\n')}\n }\n}\n`
863
+ );
864
+ }
865
+
866
+ function pinnedNotes(pinned) {
867
+ const lines = [
868
+ ' * The ramps sit on the library\'s lightness curve, so step 600 means the same',
869
+ ' * lightness as in every built-in ramp, and tints, borders and every role not',
870
+ ' * named here keep their place on it.',
871
+ ];
872
+ for (const [name, report] of Object.entries(pinned)) {
873
+ const what = name === 'brand' ? 'the primary action' : 'the solid accent fill';
874
+ const list = (items) => items.join(', ');
875
+ lines.push(' *');
876
+ lines.push(` * PINNED ${name} ${report.hex}: in light, ${what} is exactly this colour. Hover and`);
877
+ lines.push(` * pressed are the same hue and chroma, ${report.light.states} by the distance the curve puts`);
878
+ lines.push(' * between steps 600, 700 and 800.');
879
+ if (report.light.followed.length) lines.push(` * light, also this colour: ${list(report.light.followed)}`);
880
+ if (report.light.onRamp.length) {
881
+ lines.push(` * light, left on the ramp because the colour fails a guarded pair there: ${list(report.light.onRamp)}`);
882
+ }
883
+ if (report.dark.pinned) {
884
+ lines.push(` * dark: the same colour, since no guarded pair fails because of it; hover and pressed go ${report.dark.states}`);
885
+ if (report.dark.followed.length) lines.push(` * dark, also this colour: ${list(report.dark.followed)}`);
886
+ if (report.dark.onRamp.length) lines.push(` * dark, left on the ramp: ${list(report.dark.onRamp)}`);
887
+ } else {
888
+ lines.push(' * dark: NOT pinned. The colour fails there, so dark keeps the ramp step it has');
889
+ lines.push(' * without pin. With the colour, these would fail:');
890
+ for (const pair of report.dark.failed) lines.push(` * - ${pair}`);
891
+ lines.push(' * To force a dark value all the same, write the role in your own dark rule after this file.');
892
+ }
893
+ }
894
+ return lines;
895
+ }
896
+
897
+ /**
898
+ * @param {{ brand: string, accent?: string, neutral?: string, neutralChroma?: number, overrides?: Record<string, string> | { light?: Record<string, string>, dark?: Record<string, string>, both?: Record<string, string> } & Record<string, unknown>, pin?: boolean | { brand?: boolean, accent?: boolean }, darkPrimary?: 'brand' | 'mirror' }} options
899
+ */
900
+ export function createTheme(options) {
901
+ if (options === null || typeof options !== 'object') {
902
+ throw new TypeError(`${TOOL}: createTheme needs an options object with at least { brand }`);
903
+ }
904
+ if (options.brand === undefined || options.brand === null) {
905
+ throw new TypeError(`${TOOL}: brand is required, for example { brand: "#0026EA" }`);
906
+ }
907
+ const modes = readOverrides(options.overrides ?? {});
908
+ const inputs = { brand: parseHex(options.brand, 'brand') };
909
+ if (options.accent !== undefined && options.accent !== null) inputs.accent = parseHex(options.accent, 'accent');
910
+ if (options.neutral !== undefined && options.neutral !== null) inputs.neutral = parseHex(options.neutral, 'neutral');
911
+
912
+ let neutralChroma;
913
+ if (options.neutralChroma !== undefined && options.neutralChroma !== null) {
914
+ const value = options.neutralChroma;
915
+ if (typeof value !== 'number' || !Number.isFinite(value) || value < 0 || value > MAX_NEUTRAL_CHROMA_OPTION) {
916
+ throw new TypeError(
917
+ `${TOOL}: neutralChroma must be a number from 0 to ${MAX_NEUTRAL_CHROMA_OPTION}, the OKLCH chroma at the neutral ramp's peak (got ${
918
+ typeof value === 'string' ? `"${value}"` : String(value)
919
+ })`,
920
+ );
921
+ }
922
+ if (!inputs.neutral) throw new TypeError(`${TOOL}: neutralChroma needs a neutral colour, for example { neutral: "#607296", neutralChroma: 0.05 }`);
923
+ neutralChroma = value;
924
+ }
925
+
926
+ const darkPrimary = readDarkPrimary(options.darkPrimary);
927
+ const pin = readPin(options.pin);
928
+ if (pin.accent && !inputs.accent) throw new TypeError(`${TOOL}: pin.accent needs an accent colour`);
929
+ const pinning = pin.brand || pin.accent;
930
+
931
+ const warnings = [];
932
+ const closest = {};
933
+ const described = {};
934
+ let tokens = {};
935
+ let darkTokens = {};
936
+
937
+ for (const name of ['brand', 'accent']) {
938
+ if (!inputs[name]) continue;
939
+ const colour = describe(inputs[name]);
940
+ described[name] = colour;
941
+ warnings.push(...inputWarnings(name, colour));
942
+ const ramp = chromaticRamp(colour);
943
+ closest[name] = closestStep(inputs[name], ramp);
944
+ if (colour.chroma >= MIN_USEFUL_CHROMA && closest[name].deltaE > FAR_FROM_RAMP) {
945
+ // Typically a very bright colour: the curve fixes each step's
946
+ // lightness, and sRGB has no such chroma at that lightness.
947
+ warnings.push({
948
+ type: 'input',
949
+ option: name,
950
+ message:
951
+ `${name} ${colour.hex} is not in the ramp built from it: the nearest step is ` +
952
+ `${closest[name].step} (${closest[name].hex}, ΔE ${closest[name].deltaE.toFixed(1)}). ` +
953
+ 'The ramp keeps its hue on the library\'s lightness curve; fills use step 600 in light and in dark (400 in dark with darkPrimary: "mirror").',
954
+ });
955
+ }
956
+ tokens = { ...tokens, ...rampTokens(name, ramp, RAMP_STEPS) };
957
+ }
958
+ if (inputs.neutral) {
959
+ const colour = describe(inputs.neutral);
960
+ if (neutralChroma === undefined && colour.chroma > MAX_NEUTRAL_CHROMA * 2) {
961
+ warnings.push({
962
+ type: 'input',
963
+ option: 'neutral',
964
+ message: `neutral ${colour.hex} is a saturated colour: only its hue is used, at low chroma, so the result is a tinted grey.`,
965
+ });
966
+ }
967
+ const ramp = neutralRamp(colour, neutralChroma);
968
+ closest.neutral = closestStep(inputs.neutral, ramp);
969
+ tokens = { ...tokens, ...rampTokens('base', ramp, BASE_STEPS) };
970
+ if (neutralChroma !== undefined) {
971
+ // Opt-in: without it these roles are the library's grey tints, as before.
972
+ const ink = inkTokens(ramp, modes);
973
+ tokens = { ...tokens, ...ink.light };
974
+ darkTokens = ink.dark;
975
+ }
976
+ }
977
+
978
+ // darkPrimary: 'mirror' writes the 8.1 dark fills under the dark selectors,
979
+ // except for a role the app sets itself in dark or in both modes.
980
+ if (darkPrimary === 'mirror') {
981
+ for (const [role, value] of Object.entries(MIRRORED_DARK)) {
982
+ if (role in modes.dark || role in modes.both) continue;
983
+ darkTokens = { ...darkTokens, [role]: value };
984
+ }
985
+ }
986
+
987
+ // Overrides take part in choosing the labels, and are written last. Each
988
+ // mode sees its own values.
989
+ for (const name of ['brand', 'accent']) {
990
+ if (!inputs[name] || pin[name]) continue;
991
+ const own = overrideMaps(modes, tokens, darkTokens);
992
+ for (const mode of ['light', 'dark']) {
993
+ const [knob, value] = chooseOn(name, mode, { ...tokens, ...own.root }, { ...darkTokens, ...own.dark });
994
+ tokens[knob] = value;
995
+ }
996
+ }
997
+
998
+ // Pinned colours: role overrides for light, their dark counterparts, and
999
+ // the labels chosen with those in place.
1000
+ const pinned = {};
1001
+ const extraPairs = ['brand', 'accent'].flatMap((name) => (pin[name] ? PIN_ROLES[name].pairs : []));
1002
+ for (const name of ['brand', 'accent']) {
1003
+ if (!pin[name]) continue;
1004
+ const own = overrideMaps(modes, tokens, darkTokens);
1005
+ const result = pinColour(name, described[name], tokens, { ...darkTokens, ...own.dark }, own.root, extraPairs, own.skip, darkPrimary);
1006
+ tokens = { ...tokens, ...result.knobs, ...result.light };
1007
+ darkTokens = { ...darkTokens, ...result.dark };
1008
+ pinned[name] = result.report;
1009
+ }
1010
+ const own = overrideMaps(modes, tokens, darkTokens);
1011
+ tokens = { ...tokens, ...own.root };
1012
+ darkTokens = { ...darkTokens, ...own.dark };
1013
+
1014
+ // What the generator settles once the app's own values are in place.
1015
+ if (!pin.brand) ({ tokens, darkTokens } = followPrimaryWithLink(tokens, darkTokens, modes));
1016
+ ({ tokens, darkTokens } = settleProgressTrackEdge(tokens, darkTokens, modes));
1017
+ ({ tokens, darkTokens } = settleControlGradients(tokens, darkTokens, modes));
1018
+
1019
+ const { warnings: contrastWarnings, checked } = checkPairs(tokens, darkTokens, extraPairs);
1020
+ warnings.push(...contrastWarnings);
1021
+
1022
+ const css = render({ inputs, closest, tokens, darkTokens, pinned: pinning ? pinned : null, neutralChroma, darkPrimary, contrastWarnings, checked });
1023
+ const hasDark = pinning || neutralChroma !== undefined || Object.keys(darkTokens).length > 0;
1024
+ return {
1025
+ css,
1026
+ tokens,
1027
+ ...(hasDark ? { darkTokens } : {}),
1028
+ ...(pinning ? { pinned } : {}),
1029
+ warnings,
1030
+ closest,
1031
+ };
1032
+ }
1033
+
1034
+ export default createTheme;