zabi-components 7.0.2 → 8.1.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 (433) hide show
  1. package/CHANGELOG.md +1924 -0
  2. package/README.md +287 -11
  3. package/THEME.md +149 -332
  4. package/THEMING.md +1329 -163
  5. package/dist/atoms/ActionPanel.svelte +24 -9
  6. package/dist/atoms/ActionPanel.svelte.d.ts +2 -0
  7. package/dist/atoms/Avatar.svelte +104 -0
  8. package/dist/atoms/Avatar.svelte.d.ts +34 -0
  9. package/dist/atoms/Badge.svelte +124 -58
  10. package/dist/atoms/Badge.svelte.d.ts +17 -5
  11. package/dist/atoms/Button.svelte +212 -62
  12. package/dist/atoms/Button.svelte.d.ts +33 -2
  13. package/dist/atoms/Card.svelte +31 -28
  14. package/dist/atoms/Card.svelte.d.ts +5 -2
  15. package/dist/atoms/CardContent.svelte +10 -2
  16. package/dist/atoms/CardContent.svelte.d.ts +2 -0
  17. package/dist/atoms/CardFooter.svelte +10 -2
  18. package/dist/atoms/CardFooter.svelte.d.ts +2 -0
  19. package/dist/atoms/CardHeader.svelte +26 -6
  20. package/dist/atoms/CardHeader.svelte.d.ts +2 -0
  21. package/dist/atoms/Checkbox.svelte +52 -21
  22. package/dist/atoms/Checkbox.svelte.d.ts +6 -2
  23. package/dist/atoms/CodeBlock.svelte +35 -9
  24. package/dist/atoms/CodeBlock.svelte.d.ts +13 -3
  25. package/dist/atoms/ColorPicker.svelte +272 -52
  26. package/dist/atoms/ColorPicker.svelte.d.ts +6 -1
  27. package/dist/atoms/Container.svelte +17 -10
  28. package/dist/atoms/DateField.svelte +107 -0
  29. package/dist/atoms/DateField.svelte.d.ts +53 -0
  30. package/dist/atoms/Divider.svelte +4 -4
  31. package/dist/atoms/FeatureCard.svelte +4 -3
  32. package/dist/atoms/FieldMessages.svelte +69 -0
  33. package/dist/atoms/FieldMessages.svelte.d.ts +23 -0
  34. package/dist/atoms/FloatingActionButton.svelte +174 -0
  35. package/dist/atoms/FloatingActionButton.svelte.d.ts +51 -0
  36. package/dist/atoms/Heading.svelte +93 -32
  37. package/dist/atoms/Heading.svelte.d.ts +24 -4
  38. package/dist/atoms/IconButton.svelte +242 -53
  39. package/dist/atoms/IconButton.svelte.d.ts +49 -4
  40. package/dist/atoms/Input.svelte +267 -74
  41. package/dist/atoms/Input.svelte.d.ts +32 -2
  42. package/dist/atoms/List.svelte +13 -2
  43. package/dist/atoms/List.svelte.d.ts +2 -0
  44. package/dist/atoms/ListItem.svelte +44 -11
  45. package/dist/atoms/ListItem.svelte.d.ts +5 -0
  46. package/dist/atoms/ListItemLeading.svelte +4 -2
  47. package/dist/atoms/ListItemLeading.svelte.d.ts +1 -1
  48. package/dist/atoms/OptimizedImage.svelte +11 -2
  49. package/dist/atoms/OptimizedImage.svelte.d.ts +2 -0
  50. package/dist/atoms/Progress.svelte +31 -9
  51. package/dist/atoms/Progress.svelte.d.ts +6 -0
  52. package/dist/atoms/Radio.svelte +23 -3
  53. package/dist/atoms/Radio.svelte.d.ts +6 -2
  54. package/dist/atoms/Rating.svelte +494 -0
  55. package/dist/atoms/Rating.svelte.d.ts +56 -0
  56. package/dist/atoms/Select.svelte +475 -135
  57. package/dist/atoms/Select.svelte.d.ts +52 -2
  58. package/dist/atoms/SelectionControl.svelte +99 -18
  59. package/dist/atoms/SelectionControl.svelte.d.ts +6 -3
  60. package/dist/atoms/Skeleton.svelte +5 -3
  61. package/dist/atoms/Slider.svelte +309 -0
  62. package/dist/atoms/Slider.svelte.d.ts +40 -0
  63. package/dist/atoms/Spinner.svelte +57 -0
  64. package/dist/atoms/Spinner.svelte.d.ts +19 -0
  65. package/dist/atoms/Table.svelte +152 -6
  66. package/dist/atoms/Table.svelte.d.ts +15 -3
  67. package/dist/atoms/TemporalField.svelte +140 -0
  68. package/dist/atoms/TemporalField.svelte.d.ts +32 -0
  69. package/dist/atoms/Text.svelte +56 -15
  70. package/dist/atoms/Text.svelte.d.ts +13 -4
  71. package/dist/atoms/Textarea.svelte +57 -45
  72. package/dist/atoms/Textarea.svelte.d.ts +14 -2
  73. package/dist/atoms/ThemeToggle.svelte +238 -56
  74. package/dist/atoms/ThemeToggle.svelte.d.ts +28 -3
  75. package/dist/atoms/TimeField.svelte +108 -0
  76. package/dist/atoms/TimeField.svelte.d.ts +54 -0
  77. package/dist/atoms/Toast.svelte +25 -10
  78. package/dist/atoms/Toast.svelte.d.ts +2 -0
  79. package/dist/atoms/Toggle.svelte +60 -19
  80. package/dist/atoms/Toggle.svelte.d.ts +18 -2
  81. package/dist/atoms/Tooltip.svelte +583 -40
  82. package/dist/atoms/Tooltip.svelte.d.ts +26 -0
  83. package/dist/atoms/ZabiStringsProvider.svelte +45 -0
  84. package/dist/atoms/ZabiStringsProvider.svelte.d.ts +27 -0
  85. package/dist/atoms/index.d.ts +17 -0
  86. package/dist/atoms/index.js +12 -0
  87. package/dist/atoms/selection-control.styles.d.ts +36 -5
  88. package/dist/atoms/selection-control.styles.js +40 -9
  89. package/dist/components/atoms/index.d.ts +17 -0
  90. package/dist/components/atoms/index.d.ts.map +1 -1
  91. package/dist/components/atoms/selection-control.styles.d.ts +36 -5
  92. package/dist/components/atoms/selection-control.styles.d.ts.map +1 -1
  93. package/dist/components/index.d.ts +83 -3
  94. package/dist/components/index.d.ts.map +1 -1
  95. package/dist/components/molecules/index.d.ts +46 -0
  96. package/dist/components/molecules/index.d.ts.map +1 -1
  97. package/dist/components/molecules/toast-store.d.ts +53 -7
  98. package/dist/components/molecules/toast-store.d.ts.map +1 -1
  99. package/dist/components/organisms/index.d.ts +6 -0
  100. package/dist/components/organisms/index.d.ts.map +1 -1
  101. package/dist/components/types/variants.d.ts +15 -1
  102. package/dist/components/types/variants.d.ts.map +1 -1
  103. package/dist/components/util/app-shell.d.ts +28 -0
  104. package/dist/components/util/app-shell.d.ts.map +1 -0
  105. package/dist/components/util/avatar.d.ts +36 -0
  106. package/dist/components/util/avatar.d.ts.map +1 -0
  107. package/dist/components/util/bottom-sheet.d.ts +30 -0
  108. package/dist/components/util/bottom-sheet.d.ts.map +1 -0
  109. package/dist/components/util/bottom-tab-bar.d.ts +35 -0
  110. package/dist/components/util/bottom-tab-bar.d.ts.map +1 -0
  111. package/dist/components/util/calendar.d.ts +56 -0
  112. package/dist/components/util/calendar.d.ts.map +1 -0
  113. package/dist/components/util/cn.d.ts +8 -0
  114. package/dist/components/util/cn.d.ts.map +1 -0
  115. package/dist/components/util/collapsible.d.ts +58 -0
  116. package/dist/components/util/collapsible.d.ts.map +1 -0
  117. package/dist/components/util/confirm-dialog.d.ts +12 -0
  118. package/dist/components/util/confirm-dialog.d.ts.map +1 -0
  119. package/dist/components/util/date.d.ts +96 -0
  120. package/dist/components/util/date.d.ts.map +1 -0
  121. package/dist/components/util/drawer.d.ts +13 -0
  122. package/dist/components/util/drawer.d.ts.map +1 -0
  123. package/dist/components/util/dropdown.d.ts +47 -0
  124. package/dist/components/util/dropdown.d.ts.map +1 -0
  125. package/dist/components/util/field.d.ts +39 -0
  126. package/dist/components/util/field.d.ts.map +1 -0
  127. package/dist/components/util/fit-in-viewport.d.ts +201 -0
  128. package/dist/components/util/fit-in-viewport.d.ts.map +1 -0
  129. package/dist/components/util/focus-utils.d.ts +75 -2
  130. package/dist/components/util/focus-utils.d.ts.map +1 -1
  131. package/dist/components/util/hydration.d.ts +45 -0
  132. package/dist/components/util/hydration.d.ts.map +1 -0
  133. package/dist/components/util/keyboard-inset.d.ts +18 -0
  134. package/dist/components/util/keyboard-inset.d.ts.map +1 -0
  135. package/dist/components/util/media-grid.d.ts +51 -0
  136. package/dist/components/util/media-grid.d.ts.map +1 -0
  137. package/dist/components/util/media.d.ts +6 -0
  138. package/dist/components/util/media.d.ts.map +1 -0
  139. package/dist/components/util/overlay-chrome.d.ts +53 -0
  140. package/dist/components/util/overlay-chrome.d.ts.map +1 -0
  141. package/dist/components/util/overlay.d.ts +72 -0
  142. package/dist/components/util/overlay.d.ts.map +1 -0
  143. package/dist/components/util/photo.d.ts +215 -0
  144. package/dist/components/util/photo.d.ts.map +1 -0
  145. package/dist/components/util/portal.d.ts +23 -0
  146. package/dist/components/util/portal.d.ts.map +1 -0
  147. package/dist/components/util/pull-to-refresh.d.ts +27 -0
  148. package/dist/components/util/pull-to-refresh.d.ts.map +1 -0
  149. package/dist/components/util/radio-keys.d.ts +25 -0
  150. package/dist/components/util/radio-keys.d.ts.map +1 -0
  151. package/dist/components/util/rating.d.ts +23 -0
  152. package/dist/components/util/rating.d.ts.map +1 -0
  153. package/dist/components/util/ready-made-strings.d.ts +77 -0
  154. package/dist/components/util/ready-made-strings.d.ts.map +1 -0
  155. package/dist/components/util/scroll-padding.d.ts +20 -0
  156. package/dist/components/util/scroll-padding.d.ts.map +1 -0
  157. package/dist/components/util/segmented-control.d.ts +16 -0
  158. package/dist/components/util/segmented-control.d.ts.map +1 -0
  159. package/dist/components/util/select.d.ts +41 -0
  160. package/dist/components/util/select.d.ts.map +1 -0
  161. package/dist/components/util/sheet-drag.d.ts +60 -0
  162. package/dist/components/util/sheet-drag.d.ts.map +1 -0
  163. package/dist/components/util/sidebar.d.ts +63 -0
  164. package/dist/components/util/sidebar.d.ts.map +1 -0
  165. package/dist/components/util/sortable-list.d.ts +65 -0
  166. package/dist/components/util/sortable-list.d.ts.map +1 -0
  167. package/dist/components/util/ssr-safe.d.ts +4 -4
  168. package/dist/components/util/ssr-safe.d.ts.map +1 -1
  169. package/dist/components/util/stepper.d.ts +40 -0
  170. package/dist/components/util/stepper.d.ts.map +1 -0
  171. package/dist/components/util/swipeable-list-item.d.ts +36 -0
  172. package/dist/components/util/swipeable-list-item.d.ts.map +1 -0
  173. package/dist/components/util/theme-mode.d.ts +80 -0
  174. package/dist/components/util/theme-mode.d.ts.map +1 -0
  175. package/dist/components/util/toaster.d.ts +93 -0
  176. package/dist/components/util/toaster.d.ts.map +1 -0
  177. package/dist/components/util/tooltip.d.ts +10 -0
  178. package/dist/components/util/tooltip.d.ts.map +1 -0
  179. package/dist/components/util/top-navbar.d.ts +11 -0
  180. package/dist/components/util/top-navbar.d.ts.map +1 -0
  181. package/dist/components/util/touch-target.d.ts +31 -0
  182. package/dist/components/util/touch-target.d.ts.map +1 -0
  183. package/dist/components/util/zabi-strings.d.ts +133 -0
  184. package/dist/components/util/zabi-strings.d.ts.map +1 -0
  185. package/dist/create-theme/cli.js +169 -0
  186. package/dist/create-theme/index.d.ts +176 -0
  187. package/dist/create-theme/index.js +817 -0
  188. package/dist/create-theme/lib/ramp-math.js +128 -0
  189. package/dist/create-theme/lib/resolve.js +141 -0
  190. package/dist/create-theme/theme-data.js +1116 -0
  191. package/dist/index.d.ts +83 -3
  192. package/dist/index.js +60 -8
  193. package/dist/molecules/Alert.svelte +73 -66
  194. package/dist/molecules/Alert.svelte.d.ts +9 -1
  195. package/dist/molecules/AppBar.svelte +415 -0
  196. package/dist/molecules/AppBar.svelte.d.ts +69 -0
  197. package/dist/molecules/AvatarGroup.svelte +116 -0
  198. package/dist/molecules/AvatarGroup.svelte.d.ts +40 -0
  199. package/dist/molecules/BottomSheet.svelte +540 -0
  200. package/dist/molecules/BottomSheet.svelte.d.ts +93 -0
  201. package/dist/molecules/BottomTabBar.svelte +297 -0
  202. package/dist/molecules/BottomTabBar.svelte.d.ts +64 -0
  203. package/dist/molecules/Calendar.svelte +597 -0
  204. package/dist/molecules/Calendar.svelte.d.ts +72 -0
  205. package/dist/molecules/Collapsible.svelte +243 -0
  206. package/dist/molecules/Collapsible.svelte.d.ts +44 -0
  207. package/dist/molecules/CollapsibleGroup.svelte +126 -0
  208. package/dist/molecules/CollapsibleGroup.svelte.d.ts +14 -0
  209. package/dist/molecules/ComponentDemo.svelte +25 -5
  210. package/dist/molecules/ComponentDemo.svelte.d.ts +5 -0
  211. package/dist/molecules/ConfirmDialog.svelte +253 -0
  212. package/dist/molecules/ConfirmDialog.svelte.d.ts +55 -0
  213. package/dist/molecules/ContactForm.svelte +46 -19
  214. package/dist/molecules/ContactForm.svelte.d.ts +10 -0
  215. package/dist/molecules/Drawer.svelte +394 -0
  216. package/dist/molecules/Drawer.svelte.d.ts +55 -0
  217. package/dist/molecules/Dropdown.svelte +540 -50
  218. package/dist/molecules/Dropdown.svelte.d.ts +49 -6
  219. package/dist/molecules/DropdownItem.svelte +122 -0
  220. package/dist/molecules/DropdownItem.svelte.d.ts +28 -0
  221. package/dist/molecules/EmptyState.svelte +56 -10
  222. package/dist/molecules/EmptyState.svelte.d.ts +8 -0
  223. package/dist/molecules/Form.svelte +25 -14
  224. package/dist/molecules/Form.svelte.d.ts +13 -5
  225. package/dist/molecules/FormField.svelte +11 -2
  226. package/dist/molecules/FormField.svelte.d.ts +2 -0
  227. package/dist/molecules/Header.svelte +13 -3
  228. package/dist/molecules/Header.svelte.d.ts +2 -0
  229. package/dist/molecules/ImageUpload.svelte +422 -70
  230. package/dist/molecules/ImageUpload.svelte.d.ts +61 -5
  231. package/dist/molecules/MediaGrid.svelte +527 -0
  232. package/dist/molecules/MediaGrid.svelte.d.ts +77 -0
  233. package/dist/molecules/Modal.svelte +340 -45
  234. package/dist/molecules/Modal.svelte.d.ts +59 -7
  235. package/dist/molecules/NavigationMenu.svelte +15 -3
  236. package/dist/molecules/NavigationMenu.svelte.d.ts +4 -0
  237. package/dist/molecules/NavigationMenuContent.svelte +91 -36
  238. package/dist/molecules/NavigationMenuContent.svelte.d.ts +2 -0
  239. package/dist/molecules/NavigationMenuItem.svelte +10 -3
  240. package/dist/molecules/NavigationMenuItem.svelte.d.ts +2 -0
  241. package/dist/molecules/NavigationMenuLink.svelte +10 -2
  242. package/dist/molecules/NavigationMenuLink.svelte.d.ts +2 -0
  243. package/dist/molecules/NavigationMenuList.svelte +19 -4
  244. package/dist/molecules/NavigationMenuList.svelte.d.ts +2 -0
  245. package/dist/molecules/NavigationMenuTrigger.svelte +16 -3
  246. package/dist/molecules/NavigationMenuTrigger.svelte.d.ts +2 -0
  247. package/dist/molecules/Page.svelte +38 -2
  248. package/dist/molecules/Page.svelte.d.ts +13 -0
  249. package/dist/molecules/PhotoGrid.svelte +433 -0
  250. package/dist/molecules/PhotoGrid.svelte.d.ts +62 -0
  251. package/dist/molecules/PhotoViewer.svelte +1116 -0
  252. package/dist/molecules/PhotoViewer.svelte.d.ts +56 -0
  253. package/dist/molecules/PropsTable.svelte +26 -11
  254. package/dist/molecules/PropsTable.svelte.d.ts +5 -0
  255. package/dist/molecules/PullToRefresh.svelte +263 -0
  256. package/dist/molecules/PullToRefresh.svelte.d.ts +52 -0
  257. package/dist/molecules/RadioGroup.svelte +57 -15
  258. package/dist/molecules/RadioGroup.svelte.d.ts +16 -3
  259. package/dist/molecules/Section.svelte +13 -5
  260. package/dist/molecules/Section.svelte.d.ts +3 -1
  261. package/dist/molecules/SegmentedControl.svelte +323 -0
  262. package/dist/molecules/SegmentedControl.svelte.d.ts +34 -0
  263. package/dist/molecules/SidebarBrandHeader.svelte +24 -5
  264. package/dist/molecules/SidebarBrandHeader.svelte.d.ts +5 -0
  265. package/dist/molecules/SidebarFooter.svelte +39 -8
  266. package/dist/molecules/SidebarFooter.svelte.d.ts +5 -0
  267. package/dist/molecules/SidebarNavSection.svelte +12 -4
  268. package/dist/molecules/SidebarNavSection.svelte.d.ts +2 -0
  269. package/dist/molecules/SlideUp.svelte +253 -30
  270. package/dist/molecules/SlideUp.svelte.d.ts +31 -4
  271. package/dist/molecules/SortableList.svelte +632 -0
  272. package/dist/molecules/SortableList.svelte.d.ts +69 -0
  273. package/dist/molecules/Stepper.svelte +548 -0
  274. package/dist/molecules/Stepper.svelte.d.ts +39 -0
  275. package/dist/molecules/StickyActionBar.svelte +191 -0
  276. package/dist/molecules/StickyActionBar.svelte.d.ts +65 -0
  277. package/dist/molecules/SwipeableListItem.svelte +334 -0
  278. package/dist/molecules/SwipeableListItem.svelte.d.ts +54 -0
  279. package/dist/molecules/Tabs.svelte +254 -24
  280. package/dist/molecules/Tabs.svelte.d.ts +15 -4
  281. package/dist/molecules/Toaster.svelte +352 -6
  282. package/dist/molecules/Toaster.svelte.d.ts +95 -2
  283. package/dist/molecules/ToasterToast.svelte +279 -62
  284. package/dist/molecules/ToasterToast.svelte.d.ts +11 -0
  285. package/dist/molecules/UnsavedChangesBar.svelte +212 -0
  286. package/dist/molecules/UnsavedChangesBar.svelte.d.ts +43 -0
  287. package/dist/molecules/index.d.ts +46 -0
  288. package/dist/molecules/index.js +27 -0
  289. package/dist/molecules/toast-store.d.ts +53 -7
  290. package/dist/molecules/toast-store.js +21 -0
  291. package/dist/organisms/AppShell.svelte +196 -0
  292. package/dist/organisms/AppShell.svelte.d.ts +61 -0
  293. package/dist/organisms/SidebarAccountPanel.svelte +111 -12
  294. package/dist/organisms/SidebarAccountPanel.svelte.d.ts +21 -0
  295. package/dist/organisms/SidebarNavigation.svelte +193 -69
  296. package/dist/organisms/SidebarNavigation.svelte.d.ts +31 -1
  297. package/dist/organisms/SidebarPanel.svelte +45 -16
  298. package/dist/organisms/SidebarPanel.svelte.d.ts +2 -0
  299. package/dist/organisms/SidebarShell.svelte +263 -0
  300. package/dist/organisms/SidebarShell.svelte.d.ts +106 -0
  301. package/dist/organisms/TopNavbar.svelte +329 -33
  302. package/dist/organisms/TopNavbar.svelte.d.ts +28 -0
  303. package/dist/organisms/index.d.ts +6 -0
  304. package/dist/organisms/index.js +4 -0
  305. package/dist/routes/lib/variant-utils.d.ts +26 -0
  306. package/dist/routes/lib/{variant-utils.ts → variant-utils.js} +15 -46
  307. package/dist/types/components.d.ts +4 -0
  308. package/dist/types/components.d.ts.map +1 -1
  309. package/dist/types/events.js +1 -0
  310. package/dist/types/index.d.ts +605 -25
  311. package/dist/types/index.d.ts.map +1 -1
  312. package/dist/types/index.js +3 -0
  313. package/dist/types/variants.ts +17 -1
  314. package/dist/util/app-shell.d.ts +27 -0
  315. package/dist/util/app-shell.js +72 -0
  316. package/dist/util/avatar.d.ts +35 -0
  317. package/dist/util/avatar.js +52 -0
  318. package/dist/util/bottom-sheet.d.ts +29 -0
  319. package/dist/util/bottom-sheet.js +43 -0
  320. package/dist/util/bottom-tab-bar.d.ts +34 -0
  321. package/dist/util/bottom-tab-bar.js +54 -0
  322. package/dist/util/calendar.d.ts +55 -0
  323. package/dist/util/calendar.js +74 -0
  324. package/dist/util/cn.d.ts +7 -0
  325. package/dist/util/cn.js +48 -0
  326. package/dist/util/collapsible.d.ts +57 -0
  327. package/dist/util/collapsible.js +23 -0
  328. package/dist/util/confirm-dialog.d.ts +11 -0
  329. package/dist/util/confirm-dialog.js +2 -0
  330. package/dist/util/date.d.ts +95 -0
  331. package/dist/util/date.js +237 -0
  332. package/dist/util/drawer.d.ts +12 -0
  333. package/dist/util/drawer.js +7 -0
  334. package/dist/util/dropdown.d.ts +46 -0
  335. package/dist/util/dropdown.js +21 -0
  336. package/dist/util/field.d.ts +38 -0
  337. package/dist/util/field.js +38 -0
  338. package/dist/util/fit-in-viewport.d.ts +200 -0
  339. package/dist/util/fit-in-viewport.js +399 -0
  340. package/dist/util/focus-utils.d.ts +75 -2
  341. package/dist/util/focus-utils.js +182 -9
  342. package/dist/util/hydration.d.ts +44 -0
  343. package/dist/util/hydration.js +58 -0
  344. package/dist/util/keyboard-inset.d.ts +17 -0
  345. package/dist/util/keyboard-inset.js +53 -0
  346. package/dist/util/media-grid.d.ts +50 -0
  347. package/dist/util/media-grid.js +81 -0
  348. package/dist/util/media.d.ts +5 -0
  349. package/dist/util/media.js +10 -0
  350. package/dist/util/overlay-chrome.d.ts +52 -0
  351. package/dist/util/overlay-chrome.js +56 -0
  352. package/dist/util/overlay.d.ts +71 -0
  353. package/dist/util/overlay.js +240 -0
  354. package/dist/util/photo.d.ts +214 -0
  355. package/dist/util/photo.js +206 -0
  356. package/dist/util/portal.d.ts +22 -0
  357. package/dist/util/portal.js +50 -0
  358. package/dist/util/pull-to-refresh.d.ts +26 -0
  359. package/dist/util/pull-to-refresh.js +30 -0
  360. package/dist/util/radio-keys.d.ts +24 -0
  361. package/dist/util/radio-keys.js +49 -0
  362. package/dist/util/rating.d.ts +22 -0
  363. package/dist/util/rating.js +26 -0
  364. package/dist/util/ready-made-strings.d.ts +76 -0
  365. package/dist/util/ready-made-strings.js +67 -0
  366. package/dist/util/scroll-padding.d.ts +19 -0
  367. package/dist/util/scroll-padding.js +44 -0
  368. package/dist/util/segmented-control.d.ts +15 -0
  369. package/dist/util/segmented-control.js +1 -0
  370. package/dist/util/select.d.ts +40 -0
  371. package/dist/util/select.js +63 -0
  372. package/dist/util/sheet-drag.d.ts +59 -0
  373. package/dist/util/sheet-drag.js +241 -0
  374. package/dist/util/sidebar.d.ts +62 -0
  375. package/dist/util/sidebar.js +38 -0
  376. package/dist/util/sortable-list.d.ts +64 -0
  377. package/dist/util/sortable-list.js +53 -0
  378. package/dist/util/ssr-safe.d.ts +4 -4
  379. package/dist/util/stepper.d.ts +39 -0
  380. package/dist/util/stepper.js +38 -0
  381. package/dist/util/swipeable-list-item.d.ts +35 -0
  382. package/dist/util/swipeable-list-item.js +32 -0
  383. package/dist/util/theme-mode.d.ts +79 -0
  384. package/dist/util/theme-mode.js +121 -0
  385. package/dist/util/toaster.d.ts +92 -0
  386. package/dist/util/toaster.js +75 -0
  387. package/dist/util/tooltip.d.ts +9 -0
  388. package/dist/util/tooltip.js +18 -0
  389. package/dist/util/top-navbar.d.ts +10 -0
  390. package/dist/util/top-navbar.js +5 -0
  391. package/dist/util/touch-target.d.ts +30 -0
  392. package/dist/util/touch-target.js +30 -0
  393. package/dist/util/zabi-strings.d.ts +132 -0
  394. package/dist/util/zabi-strings.js +105 -0
  395. package/dist/zabi-components-colors.css +5 -2
  396. package/dist/zabi-components-theme-dark-only.css +574 -225
  397. package/dist/zabi-components-theme-dark.css +574 -225
  398. package/dist/zabi-components-theme-only.css +1680 -317
  399. package/dist/zabi-components-theme.css +1680 -317
  400. package/dist/zabi-components.css +4473 -1158
  401. package/docs/lucide-icons.md +41 -16
  402. package/docs/theme-imports.md +81 -11
  403. package/package.json +51 -29
  404. package/dist/lib/layout-width-tokens.d.ts +0 -17
  405. package/dist/lib/layout-width-tokens.d.ts.map +0 -1
  406. package/dist/lib/showcase/component-docs/Button.d.ts +0 -3
  407. package/dist/lib/showcase/component-docs/Button.d.ts.map +0 -1
  408. package/dist/lib/showcase/component-docs/Input.d.ts +0 -3
  409. package/dist/lib/showcase/component-docs/Input.d.ts.map +0 -1
  410. package/dist/lib/showcase/component-docs/List.d.ts +0 -3
  411. package/dist/lib/showcase/component-docs/List.d.ts.map +0 -1
  412. package/dist/lib/showcase/component-docs/Modal.d.ts +0 -3
  413. package/dist/lib/showcase/component-docs/Modal.d.ts.map +0 -1
  414. package/dist/lib/showcase/component-docs/Radio.d.ts +0 -3
  415. package/dist/lib/showcase/component-docs/Radio.d.ts.map +0 -1
  416. package/dist/lib/showcase/component-docs/SidebarNavigation.d.ts +0 -3
  417. package/dist/lib/showcase/component-docs/SidebarNavigation.d.ts.map +0 -1
  418. package/dist/lib/showcase/component-docs/Skeleton.d.ts +0 -3
  419. package/dist/lib/showcase/component-docs/Skeleton.d.ts.map +0 -1
  420. package/dist/lib/showcase/component-docs/_shared.d.ts +0 -12
  421. package/dist/lib/showcase/component-docs/_shared.d.ts.map +0 -1
  422. package/dist/lib/showcase/component-docs/index.d.ts +0 -4
  423. package/dist/lib/showcase/component-docs/index.d.ts.map +0 -1
  424. package/dist/lib/showcase/components-catalog.d.ts +0 -3
  425. package/dist/lib/showcase/components-catalog.d.ts.map +0 -1
  426. package/dist/lib/showcase/components-showcase-constants.d.ts +0 -89
  427. package/dist/lib/showcase/components-showcase-constants.d.ts.map +0 -1
  428. package/dist/lib/showcase/docs-sidebar-helpers.d.ts +0 -15
  429. package/dist/lib/showcase/docs-sidebar-helpers.d.ts.map +0 -1
  430. package/dist/routes/lib/focus-utils.d.ts +0 -12
  431. package/dist/routes/lib/focus-utils.ts +0 -110
  432. package/dist/routes/lib/ssr-safe.ts +0 -58
  433. package/scripts/fix-lucide-svelte-icon-dts.js +0 -35
package/THEMING.md CHANGED
@@ -1,238 +1,1404 @@
1
- # Theming Guide
1
+ # Theming guide
2
2
 
3
- ## 🎨 Single Source of Truth
3
+ An app gives the whole library its own brand from one generated file of token
4
+ overrides. There is no component CSS to write and nothing to repeat for dark
5
+ mode.
4
6
 
5
- **All theming is done in one file: `src/app.css`**
7
+ This guide is the token API. The tokens under
8
+ [Tokens an app may set](#tokens-an-app-may-set) and
9
+ [Roles: read them, do not set them](#roles-read-them-do-not-set-them) are
10
+ public: renaming or removing one is a breaking change (see
11
+ [Stability](#stability)).
6
12
 
7
- This file contains:
8
- - All color scales (brand, citron, pine, iris, base)
9
- - Semantic color tokens (primary, secondary, success, error, etc.)
10
- - Action colors (for buttons and interactive elements)
11
- - Dark mode overrides
12
- - Typography settings
13
- - Spacing, border radius, and z-index values
13
+ The same guide, with a brand switcher to try it on real components, is on the
14
+ docs site at [/theming](https://zabi-components.vercel.app/theming).
14
15
 
15
- ## 📍 Where to Make Changes
16
+ - [The first thing to know: your colour is not pinned to a step](#the-first-thing-to-know-your-colour-is-not-pinned-to-a-step)
17
+ - [Quick start](#quick-start)
18
+ - [Import order](#import-order)
19
+ - [Tokens an app may set](#tokens-an-app-may-set)
20
+ - [Roles: read them, do not set them](#roles-read-them-do-not-set-them)
21
+ - [Light, dark and auto](#light-dark-and-auto)
22
+ - [Text and controls on a brand or accent block](#text-and-controls-on-a-brand-or-accent-block)
23
+ - [Component-level properties](#component-level-properties)
24
+ - [The generator](#the-generator)
25
+ - [What does not follow an override](#what-does-not-follow-an-override)
26
+ - [Stability](#stability)
27
+ - [Reference: how the theme is built](#reference-how-the-theme-is-built)
28
+ - [For maintainers](#for-maintainers)
16
29
 
17
- ### Main Theme (Light Mode)
18
- Edit the `@theme` block in `src/app.css`.
30
+ ## The first thing to know: your colour is not pinned to a step
19
31
 
20
- ### Dark Mode Theme
21
- Edit the `.dark` block in `src/app.css`.
32
+ **Need the exact brand colour on buttons? Use `--pin`.**
33
+ `npx zabi-theme --brand "#0026EA" --pin` makes the primary button `#0026ea`
34
+ itself in light, with a checked label colour. The rest of this section is what
35
+ happens without it, and [`--pin`](#--pin-the-exact-brand-colour-on-buttons)
36
+ has the trade-off.
22
37
 
23
- ## ⚠️ Important: Don't Edit These Files
38
+ `zabi-theme` takes one brand colour and builds an 11-step ramp from it. The
39
+ colour supplies the **hue and chroma**. The lightness of each step comes from
40
+ the library's own curve (see [Calibrated colour ramps](#calibrated-colour-ramps)),
41
+ so step 600 is as dark as every built-in 600 and every role keeps the contrast
42
+ it was designed with.
24
43
 
25
- ### Generated Files (in `dist/` folder)
26
- These files are **automatically generated** from `src/app.css` by the build script:
27
- - ❌ `dist/zabi-components.css` - Full compiled CSS (generated)
28
- - ❌ `dist/zabi-components-theme.css` - Theme with Tailwind import (generated)
29
- - ❌ `dist/zabi-components-theme-only.css` - Theme only, no Tailwind (generated)
30
- - ❌ `dist/zabi-components-theme-dark.css` - Dark mode theme (generated)
31
- - ❌ `dist/zabi-components-theme-dark-only.css` - Dark mode theme only (generated)
32
- - ❌ `dist/zabi-components-colors.css` - Standalone colors (generated from app.css)
44
+ So the exact hex you give may not be in the ramp, and it is usually not the
45
+ colour of the primary button:
33
46
 
34
- **These files are overwritten every time you run `npm run build:css`**
47
+ | You give | Nearest step | Primary button, light (step 600) | Primary button, dark (step 400) |
48
+ |---|---|---|---|
49
+ | `#0026EA` | 800, `#0024e1` (ΔE 3.5) | `#2b65ff` | `#84acff` |
50
+ | `#C17B00` | 500, exact | `#9b6200` | `#d99f58` |
35
51
 
36
- ### Example Files
37
- - ❌ `examples/theme-extensions/02-custom-brand-colors.css` - Just an example, not your actual theme
38
- - ❌ `examples/theme-extensions/03-custom-semantic-colors.css` - Just an example, not your actual theme
52
+ `#0026EA` is darker than a 600, so it lands near step 800 and the button is the
53
+ lighter `#2b65ff`. The header of the generated file, the command's summary line
54
+ and `closest` from `createTheme()` all name the nearest step.
39
55
 
40
- ### Source Files (Edit These)
41
- - ✅ `src/app.css` - **THIS IS THE ONLY FILE YOU NEED TO EDIT**
42
- - ✅ Removed deprecated files (`src/styles/colors.css`, `src/styles/base.css`, `src/styles/simple.css`, `src/app-simple.css`)
56
+ A very bright colour has no step that bright at its chroma. `#FFE600` lands
57
+ nearest step 300 at ΔE 14.8, and the generator says so:
43
58
 
44
- ## 📦 What Are the Dist Files For?
59
+ ```
60
+ zabi-theme: warning: brand #ffe600 is not in the ramp built from it: the nearest step is 300 (#dcc600, ΔE 14.8). The ramp keeps its hue on the library's lightness curve; fills use step 600 in light and 400 in dark.
61
+ ```
62
+
63
+ If the brand colour itself has to be the button, use
64
+ [`--pin`](#--pin-the-exact-brand-colour-on-buttons). If it only has to appear
65
+ somewhere (a logo plate, a brand screen), use the step the generator names
66
+ (`var(--zabi-brand-500)` for the amber above), not the hex.
67
+
68
+ ## Quick start
69
+
70
+ 1. Generate the brand file. Commit it; the output has no timestamp, and the
71
+ same input gives the same bytes.
72
+
73
+ ```bash
74
+ npx zabi-theme --brand "#C17B00" --neutral "#78716c" --out src/lib/brand.generated.css
75
+ ```
76
+
77
+ 2. Import it after the theme files.
78
+
79
+ ```css
80
+ /* src/lib/theme.css — the app's brand */
81
+ @import "zabi-components/theme-only";
82
+ @import "zabi-components/theme-dark-only";
83
+ @import "./brand.generated.css"; /* --zabi-brand-*, --zabi-accent-*, --zabi-base-* */
84
+
85
+ :root {
86
+ --font-family-heading: "Your Display Font", var(--font-family-sans);
87
+ }
88
+ ```
89
+
90
+ ```css
91
+ /* src/app.css */
92
+ @import "tailwindcss";
93
+ @import "./lib/theme.css";
94
+ ```
95
+
96
+ 3. Choose how dark mode is selected, on `<html>`.
97
+
98
+ ```html
99
+ <html lang="en" data-theme="auto">
100
+ ```
101
+
102
+ That is the whole rebrand: primary actions, focus rings, links, tints, text,
103
+ borders, the page, cards and the dark surface levels follow, in light and dark.
104
+
105
+ ## Import order
106
+
107
+ | Order | Import | Why here |
108
+ |---|---|---|
109
+ | 1 | `tailwindcss` | The theme files are Tailwind v4 `@theme` blocks. |
110
+ | 2 | `zabi-components/theme-only` | Declares every token, light values, and the raw ramps. |
111
+ | 3 | `zabi-components/theme-dark-only` | Only remaps roles for dark. It declares no ramp, so it does nothing without the light theme. |
112
+ | 4 | your generated file | `:root` overrides. After both theme files, so they win in dark as well. |
113
+ | 5 | your own `:root { … }` | Fonts, radius and anything else from the tables below. |
114
+
115
+ Keep the brand file last. `:root`, `.dark` and `[data-theme="dark"]` have the
116
+ same specificity, so between them the later rule wins: a brand file imported
117
+ before the dark theme loses, in dark, every token the dark file restates (a
118
+ role moved with `--set`, `--shadow-color`). The ramps happen to apply from
119
+ either position in a Tailwind build, because Tailwind puts the theme's own
120
+ values in a cascade layer and your `:root` rule is outside it. With
121
+ `zabi-components/colors`, which is plain CSS, an override placed before the
122
+ import does nothing at all.
123
+
124
+ If the app has no Tailwind, `zabi-components/css` replaces steps 1 to 3 (see
125
+ [docs/theme-imports.md](./docs/theme-imports.md)); the brand file still goes
126
+ after it.
127
+
128
+ Set the overrides on `:root`, not on an element further down. The roles are
129
+ resolved on the root element, so `--zabi-brand-600` set on a `<section>`
130
+ changes nothing inside it.
131
+
132
+ ## Tokens an app may set
133
+
134
+ Set these on `:root`, after the imports. One declaration covers light and dark
135
+ unless the table says otherwise. All of them were checked in a browser against
136
+ the built theme files.
137
+
138
+ ### Colour
139
+
140
+ `zabi-theme` writes every token in this table. Write them by hand only if you
141
+ have your own ramps.
142
+
143
+ | Token | Steps | What follows |
144
+ |---|---|---|
145
+ | `--zabi-brand-50` … `--zabi-brand-950` | 50, 100, 200, 300, 400, 500, 600, 700, 800, 900, 950 | Primary actions, focus rings, links, brand tints, `--color-primary*` |
146
+ | `--zabi-accent-50` … `--zabi-accent-950` | the same 11 | `--color-accent` and its roles: `bg-accent`, `text-accent`, `border-accent`. Defaults to the citron ramp. |
147
+ | `--zabi-base-50` … `--zabi-base-950` | 50, 75, 100, 150, 200, 250, 300, 350, 400, 450, 500, 550, 600, 650, 700, 750, 800, 850, 900, 925, 950 | Text, borders, inputs, the page, cards and the four dark surface levels |
148
+ | `--zabi-on-brand` | | Label on a primary fill in light. Default `#ffffff`. |
149
+ | `--zabi-on-brand-dark` | | The same in dark. Default `var(--zabi-brand-950)`. |
150
+ | `--zabi-on-accent` | | Label on a solid accent fill in light. Default `#ffffff`. |
151
+ | `--zabi-on-accent-dark` | | The same in dark. Default `var(--zabi-accent-950)`. |
152
+
153
+ Each ramp runs light (50) to dark (950). Light mode puts fills on step 600;
154
+ dark mode mirrors the ramp, so the fill is step 400.
155
+
156
+ A ramp you write by hand should keep the library's lightness per step, or the
157
+ contrast the roles were designed with is gone. Run it through
158
+ `zabi-theme --set` to have it checked (see [`--set`](#--set-and-overrides)).
159
+
160
+ ### Type
161
+
162
+ | Token | Default | What follows |
163
+ |---|---|---|
164
+ | `--font-family-sans` | `'Nunito Sans', ui-sans-serif, system-ui, sans-serif` | Body text and every component |
165
+ | `--font-family-heading` | `var(--font-family-sans)` | `h1` to `h6` and the Heading component |
166
+ | `--font-family-mono` | `"Monaco", "Menlo", "Ubuntu Mono", monospace` | CodeBlock |
167
+ | `--font-weight-regular` | `400` | `font-normal` |
168
+ | `--font-weight-medium` | `500` | `font-medium` |
169
+ | `--font-weight-semibold` | `600` | `font-semibold` |
170
+ | `--font-weight-bold` | `700` | `font-bold` |
171
+
172
+ Loading the font files is up to the app. The heading face is applied in the
173
+ base layer, so a `font-*` utility on a heading still wins.
174
+
175
+ Tailwind's own font utilities follow these tokens: `font-sans` is
176
+ `--font-family-sans`, `font-mono` is `--font-family-mono`, and `font-heading`
177
+ is `--font-family-heading`. The theme maps Tailwind's `--font-sans` and
178
+ `--font-mono` (and a `--font-heading` of its own) onto them. Before 8.1
179
+ `font-sans` was Tailwind's system stack, so `class="font-sans"` on a heading
180
+ dropped it out of the brand face. Set the `--font-family-*` tokens, not the
181
+ short names: the short ones follow.
182
+
183
+ ### Shape, depth and stacking
184
+
185
+ | Token | Default | What follows |
186
+ |---|---|---|
187
+ | `--radius-control` | `0.5rem` | Buttons, inputs, selects, toggles |
188
+ | `--radius-container` | `0.75rem` | Cards, alerts, panels, list items, code blocks |
189
+ | `--radius-overlay` | `1rem` | Modals, sheets, menus, popovers, toasts |
190
+ | `--radius-pill` | `9999px` | Badges, avatars, status dots |
191
+ | `--shadow-color` | `24 24 27` (dark: `0 0 0`) | The colour of `shadow-sm` and `shadow-lg`, as three space-separated RGB channels |
192
+ | `--shadow-opacity` | `0.14` (dark: `0.3`) | Their strength |
193
+ | `--z-dropdown`, `--z-sticky`, `--z-fixed`, `--z-modal-backdrop`, `--z-modal`, `--z-popover`, `--z-tooltip`, `--z-toast` | 1000 to 1080 | The layer of each floating component, when the app has its own stacking order to fit into |
194
+
195
+ The two shadow tokens are the only ones here with a separate dark value. A
196
+ `:root` override after the imports replaces both; to keep two values, declare
197
+ the dark one under the dark selectors (see
198
+ [One role in dark only](#one-role-in-dark-only)).
199
+
200
+ ### Not on the list
45
201
 
46
- The `dist/` files are **for consumers** of your library who want to import the theme separately.
202
+ These tokens exist, and their names are kept, but setting them does not restyle
203
+ the components:
47
204
 
48
- **Full import matrix, export paths, and examples:** [docs/theme-imports.md](./docs/theme-imports.md)
205
+ - `--spacing-xs` … `--spacing-2xl` feed the `p-md`, `gap-lg` utilities for your
206
+ own markup. Components use Tailwind's numeric scale.
207
+ - `--control-height-sm`, `-md`, `-lg` record the 32, 40 and 48px control
208
+ heights. Components set those heights with `h-8`, `h-10` and `h-12`.
209
+ - `--radius-sm`, `-md`, `-lg`, `-xl` are legacy aliases. No component uses them.
210
+ - `--shadow-sm`, `--shadow-md`, `--shadow-lg`, `--shadow-xl` are built from the
211
+ two shadow tokens above. Set those instead.
49
212
 
50
- ### For Library Developers (You)
51
- - **Edit**: `src/app.css` only
52
- - **Build**: Run `npm run build:css` to regenerate all dist files (or `npm run build:lib` before publish)
53
- - **After token edits**: run `node scripts/verify-build.js` to confirm outputs (also runs as part of `build:lib`)
54
- - **Base scale source**: use `tokens/base-scale.js`; run `npm run sync:tokens` (or `npm run build:css`) to render token blocks back into `src/app.css`
213
+ ## Roles: read them, do not set them
55
214
 
56
- ### For Library Consumers
215
+ A role says what a colour is for. Components paint with roles, and your own CSS
216
+ should too: `var(--color-action-primary)` or the `bg-action-primary` class, not
217
+ `var(--zabi-brand-600)`. A role is already the right step in each mode.
57
218
 
58
- Use **package exports** (preferred):
219
+ | Roles | For |
220
+ |---|---|
221
+ | `--color-action-primary`, `-hover`, `-active`, `-text`, `-subtle`, `-subtle-hover` | Primary buttons and controls. The same set exists for `secondary` and `danger`; `--color-action-disabled`, `-disabled-text`, `-disabled-border` for disabled ones. |
222
+ | `--color-on-brand`, `--color-on-accent` | Text on a primary fill and on a solid accent fill |
223
+ | `--color-accent`, `-hover`, `-active`, `-subtle`, `-border`, `-text` | The second brand colour |
224
+ | `--color-focus-ring`, `--color-focus-ring-offset`, `--color-focus` | The focus ring and the gap around it |
225
+ | `--color-focus-ring-muted` | The neutral ring of ghost and link controls (`.focus-ring--muted`); 3:1 or more on every surface level |
226
+ | `--color-focus-ring-danger` | The ring of a destructive control (`.focus-ring--danger`). It is `--color-error`; a role of its own so that a brand or accent block can re-point it |
227
+ | `--color-control-border` | The edge of a control that has nothing else to be seen by, such as an empty Rating star; 3:1 or more on every surface level |
228
+ | `--color-link`, `--color-link-hover` | Links |
229
+ | `--color-headline`, `--color-body`, `--color-description`, `--color-caption`, `--color-label` | Text |
230
+ | `--color-surface-base`, `-raised`, `-elevated`, `-overlay`, `-inset` | The page, cards, nested cards, floating panels, wells |
231
+ | `--color-surface-hover`, `--color-surface-active`, `--color-surface-overlay-hover` | Hover and pressed fills |
232
+ | `--color-border`, `-weak`, `-medium`, `-strong`, `--color-border-overlay` | Borders and dividers |
233
+ | `--color-input`, `-border`, `-border-hover`, `-hover`, `-active`, `-focus`, `-disabled`, `-placeholder` | Form fields; `-active` is the pressed Select trigger; `--color-input-border` is the field edge, 3:1 or more on the field fill, the page and the card in both themes |
234
+ | `--color-<family>`, `-weak`, `-medium`, `-strong`, `-subtle`, `-border`, `-text` | `success`, `warning`, `error`, `info`, `energetic`, `neutral` (see [Semantic families](#semantic-families)) |
59
235
 
60
- | Need | Import |
61
- |------|--------|
62
- | Tailwind + full `@theme` (app has no Tailwind yet) | `zabi-components/theme` |
63
- | Tailwind already in app — merge `@theme` only | `zabi-components/theme-only` |
64
- | Dark overrides (+ Tailwind import in file) | `zabi-components/theme-dark` |
65
- | Dark overrides only (Tailwind already loaded) | `zabi-components/theme-dark-only` |
66
- | CSS variables only, no Tailwind | `zabi-components/colors` |
67
- | Full compiled CSS (utilities + everything) | `zabi-components/css` |
236
+ Aliases kept from earlier versions resolve to the same values: `--color-background`,
237
+ `--color-card`, `--color-surface-1` … `-4`, `--color-primary*`,
238
+ `--color-secondary*`, `--color-page`.
68
239
 
69
- Examples:
240
+ **Why not set them.** A role set on `:root` after the imports is one value for
241
+ both modes: it replaces the dark value as well, and nothing checks its
242
+ contrast. If one role has to move, do it through the generator, which checks
243
+ it: see [`--set`](#--set-and-overrides).
244
+
245
+ **Do not set `--color-brand-*`, `--color-accent-<step>` or `--color-base-*`
246
+ either.** Those are the mirrored steps: `--color-brand-600` is
247
+ `--zabi-brand-600` in light and `--zabi-brand-400` in dark. Set on `:root`
248
+ after the imports, one of them becomes the same colour in both modes and stops
249
+ mirroring. Override the `--zabi-*` ramp instead.
250
+
251
+ ### One role in dark only
252
+
253
+ Declare it under all three dark selectors, after the imports:
70
254
 
71
255
  ```css
72
- @import "zabi-components/theme-only";
73
- @import "zabi-components/theme-dark-only";
256
+ .dark,
257
+ [data-theme="dark"] {
258
+ --color-link: var(--zabi-brand-200);
259
+ }
260
+
261
+ @media (prefers-color-scheme: dark) {
262
+ [data-theme="auto"] {
263
+ --color-link: var(--zabi-brand-200);
264
+ }
265
+ }
74
266
  ```
75
267
 
76
- ```css
77
- @import "zabi-components/colors";
268
+ ## Light, dark and auto
269
+
270
+ The mode is chosen on the `<html>` element, by an attribute or a class:
271
+
272
+ | On `<html>` | Result |
273
+ |---|---|
274
+ | nothing | light, also on a dark system |
275
+ | `data-theme="light"` | light |
276
+ | `data-theme="dark"` | dark |
277
+ | `data-theme="auto"` | follows the system (`prefers-color-scheme`) |
278
+ | `class="dark"` | dark, the same as `data-theme="dark"` |
279
+
280
+ - **Following the system is opt-in.** A page with no attribute and no class
281
+ stays light on a dark system, as it always has. `data-theme="auto"` needs no
282
+ script.
283
+ - **`<html>` only.** A `.dark` or `data-theme` on an element further down is
284
+ not supported. It re-themes the roles the dark file restates (the card
285
+ surface, the label on a primary button) and leaves the rest (text, borders,
286
+ the primary fill) light, so the subtree comes out half dark.
287
+ - `color-scheme` comes with the theme, so what the browser draws itself (date
288
+ and time pickers, scrollbars, autofill) matches: dark under `.dark`,
289
+ `data-theme="dark"` and `data-theme="auto"` on a dark system, and light
290
+ everywhere else, a page with no class and no attribute included. That
291
+ overrides a `<meta name="color-scheme">` tag, so the page needs neither the
292
+ tag nor an inline `color-scheme` on `<html>`; `ThemeToggle` writes none and
293
+ removes a stale `light` or `dark` one. Before 8.1 only the attributes set
294
+ it.
295
+ - The dark file must be imported together with a light one. It only remaps
296
+ roles onto ramps the light theme declares.
297
+ - Tailwind's own `dark:` variant follows the system unless you redefine it, and
298
+ does not read `data-theme`.
299
+
300
+ ### A theme control: system, light, dark
301
+
302
+ `ThemeToggle` reads the mode from `<html>` and follows whatever else changes
303
+ it.
304
+
305
+ | | Press | Writes |
306
+ |---|---|---|
307
+ | `<ThemeToggle />` (`modes="two"`) | light ↔ dark | `data-theme` when `<html>` has the attribute, the `dark` class otherwise. It is a switch named "Dark mode" (`labels.darkMode`), with `aria-pressed` for its state |
308
+ | `<ThemeToggle modes="three" />` | system → light → dark → system | always `data-theme` (`auto`, `light` or `dark`); it adds the attribute and removes a leftover `dark` class |
309
+
310
+ With two modes, a press on a page that is on `auto` sets `light` or `dark`, and
311
+ there is no way back to `auto` from that button. An app whose default is
312
+ `data-theme="auto"` wants three modes.
313
+
314
+ The three-mode button shows a monitor, a sun or a moon, and its accessible name
315
+ states the mode and what a press does: "Theme: system. Switch to light". The
316
+ words are replaceable:
317
+
318
+ ```svelte
319
+ <ThemeToggle
320
+ modes="three"
321
+ labels={{
322
+ auto: "följ telefonen",
323
+ light: "ljust",
324
+ dark: "mörkt",
325
+ describe: (current, next) => `Tema: ${current}. Byt till ${next}`,
326
+ }}
327
+ />
78
328
  ```
79
329
 
80
- ```css
81
- @import "zabi-components/css";
330
+ **Remembering the choice.** The choice is kept in `localStorage` under
331
+ `storageKey` (default `"theme"`) and applied again when the button mounts, with
332
+ two modes and with three, on a `data-theme` page and on a class page: the
333
+ two-way button on a `data-theme="auto"` page that finds `"dark"` stored sets
334
+ `data-theme="dark"`.
335
+ `storageKey={null}` keeps nothing. To store it in your own backend, bind the
336
+ mode or listen for it:
337
+
338
+ ```svelte
339
+ <ThemeToggle modes="three" storageKey={null} bind:mode onmodechange={(mode) => saveToProfile(mode)} />
82
340
  ```
83
341
 
84
- Canonical recommendation for most apps remains:
342
+ `mode` is `"auto" | "light" | "dark"`. It follows the page, and assigning it
343
+ switches the page.
85
344
 
86
- ```css
87
- @import "tailwindcss";
88
- @import "zabi-components/theme-only";
89
- @import "zabi-components/theme-dark-only";
345
+ **No flash on load.** A component mounts after the first paint, so a visitor
346
+ who chose dark would see light first. `themeInitScript(storageKey?)` returns a
347
+ small script that applies the stored mode from `<head>`. It is a plain string
348
+ with no dependencies. In SvelteKit, print it once and paste it into
349
+ `src/app.html`, or inject it from `hooks.server.ts`:
350
+
351
+ ```html
352
+ <!-- src/app.html -->
353
+ <html lang="sv" data-theme="auto">
354
+ <head>
355
+ <script>
356
+ (function(){try{var m=localStorage.getItem("theme");if(m!=="auto"&&m!=="light"&&m!=="dark")return;var r=document.documentElement;if(r.hasAttribute("data-theme")||m==="auto"){r.setAttribute("data-theme",m);r.classList.remove("dark")}else{r.classList.toggle("dark",m==="dark")}}catch(e){}})();
357
+ </script>
358
+ %sveltekit.head%
90
359
  ```
91
360
 
92
- Dark mapping rule reference:
93
- - Physical ramp tokens are `--zabi-base-50 ... --zabi-base-950`.
94
- - Semantic dark mapping is generated with `S -> 1000 - S` (for example `50 <-> 950`, `75 <-> 925`, `500` unchanged).
361
+ ```ts
362
+ // src/hooks.server.ts: the same, generated, so it follows the package.
363
+ // Put %theme-init% in the <head> of src/app.html where the script should go.
364
+ import { themeInitScript } from "zabi-components";
95
365
 
96
- ## 🎯 Common Customizations
366
+ export const handle = ({ event, resolve }) =>
367
+ resolve(event, {
368
+ transformPageChunk: ({ html }) => html.replace("%theme-init%", `<script>${themeInitScript()}</script>`),
369
+ });
370
+ ```
97
371
 
98
- ### Change Brand Colors
372
+ With nothing stored the script does nothing, and the `data-theme` in your
373
+ markup stands.
99
374
 
100
- ```css
101
- @theme {
102
- /* Replace the brand color scale */
103
- --color-brand-50: #f0f9ff;
104
- --color-brand-100: #e0f2fe;
105
- --color-brand-200: #bae6fd;
106
- --color-brand-300: #7dd3fc;
107
- --color-brand-400: #38bdf8;
108
- --color-brand-500: #0ea5e9;
109
- --color-brand-600: #0284c7;
110
- --color-brand-700: #0369a1;
111
- --color-brand-800: #075985;
112
- --color-brand-900: #0c4a6e;
113
- --color-brand-950: #082f49;
114
- }
375
+ **Your own control.** A settings screen usually wants three labelled options,
376
+ not a cycling button. Drive the theme with the same helpers and any
377
+ `ThemeToggle` on the page follows:
378
+
379
+ ```svelte
380
+ <script lang="ts">
381
+ import { onMount } from "svelte";
382
+ import { SegmentedControl, getThemeMode, setThemeMode, type ThemeMode } from "zabi-components";
383
+
384
+ let mode = $state<ThemeMode>("auto");
385
+ onMount(() => (mode = getThemeMode()));
386
+ </script>
387
+
388
+ <SegmentedControl
389
+ label="Tema"
390
+ options={[
391
+ { value: "auto", label: "Följ telefonen" },
392
+ { value: "light", label: "Ljust" },
393
+ { value: "dark", label: "Mörkt" },
394
+ ]}
395
+ value={mode}
396
+ onchange={(value) => {
397
+ mode = value as ThemeMode;
398
+ setThemeMode(mode); // or setThemeMode(mode, { storageKey: null }) and save it yourself
399
+ }}
400
+ />
401
+ ```
402
+
403
+ | Helper | |
404
+ |---|---|
405
+ | `getThemeMode()` | `"auto"`, `"light"` or `"dark"`, from `data-theme`; without the attribute, `"dark"` with the `dark` class and `"light"` without. |
406
+ | `setThemeMode(mode, { storageKey? })` | Writes `data-theme`, removes a leftover `dark` class and inline `color-scheme`, stores the choice (`storageKey: null` stores nothing). |
407
+ | `isThemeDark()` | Whether the page is dark right now; on `auto` that is the system's answer. |
408
+ | `getStoredThemeMode(storageKey?)`, `storeThemeMode(mode, storageKey?)` | The stored choice. |
409
+ | `themeInitScript(storageKey?)` | The `<head>` script above, as a string. |
410
+
411
+ All of them are safe to call on the server, where they read `"light"` and
412
+ write nothing.
413
+
414
+ ```js
415
+ document.documentElement.dataset.theme = "dark"; // "light" | "auto"
416
+ // or
417
+ document.documentElement.classList.toggle("dark");
115
418
  ```
116
419
 
117
- ### Change Primary Button Color
420
+ ## Text and controls on a brand or accent block
118
421
 
119
- The primary button uses `--color-action-primary` which defaults to `brand-800`. To change it:
422
+ A block filled with the brand colour (`bg-action-primary`) or the accent
423
+ (`bg-accent`) is not a surface level: the text roles (`--color-headline`,
424
+ `--color-body`) are chosen for the page and for cards, and on a brand fill
425
+ they are about 2:1 to 3.7:1. Three things make a block work.
426
+
427
+ **1. Say what the block is.** Add `on-brand` beside `bg-action-primary`, or
428
+ `on-accent` beside `bg-accent`:
429
+
430
+ ```svelte
431
+ <section class="bg-action-primary on-brand rounded-container p-6">
432
+ <Heading level={2} tone="inherit">Dagens quiz</Heading>
433
+ <Text tone="inherit">Tio frågor om Sverige.</Text>
434
+ <Button variant="accent">Spela</Button>
435
+ </section>
436
+ ```
437
+
438
+ The class sets the block's text colour to the fill's label colour
439
+ (`--color-on-brand` or `--color-on-accent`). For everything inside the block
440
+ it re-points the focus rings, and the roles a control writes with when it has
441
+ no fill of its own (see 3).
442
+
443
+ **2. Give text a tone that belongs on a fill.** `Heading` and `Text` take:
444
+
445
+ | `tone` | Colour | Use |
446
+ |---|---|---|
447
+ | `inherit` | the block's own text colour | inside `on-brand` / `on-accent`, or any block that sets a colour |
448
+ | `on-brand` | `--color-on-brand` | on `bg-action-primary`, wherever the element is |
449
+ | `on-accent` | `--color-on-accent` | on `bg-accent` |
450
+
451
+ `Heading` is `tone="headline"` by default and `Text` `tone="body"`. Inside a
452
+ block both roles are the block's label colour, so the defaults are legible
453
+ there too; `inherit` says it outright, and is what works on a block of a
454
+ colour of your own.
455
+
456
+ **3. Controls on the block.** A control with no fill of its own writes with
457
+ the page's roles: a Checkbox's label is `--color-label`, an outline Button's
458
+ `--color-headline` with a `--color-border` edge, help text
459
+ `--color-description`. On a brand fill those were 1.5:1 to 3.7:1. Inside
460
+ `on-brand` and `on-accent` they are re-pointed at the block's label colour:
461
+
462
+ - the text roles: `--color-headline`, `--color-body`, `--color-label`,
463
+ `--color-description`, `--color-caption`, `--color-link`,
464
+ `--color-link-hover`
465
+ - the edges: `--color-border`, `--color-border-medium`,
466
+ `--color-border-strong`, `--color-control-border`
467
+ - the rings: `--color-focus-ring`, `--color-focus-ring-muted`,
468
+ `--color-focus-ring-danger`, `--color-nav-menu-focus`, with the gap
469
+ (`--color-focus-ring-offset`) in the block's own colour
470
+
471
+ So every one of them measures what the label colour measures against the
472
+ fill, which the theme holds to 4.5:1 (`scripts/check-contrast.js` fails when a
473
+ block stops re-pointing one, or points it at something that does not read):
474
+
475
+ | Inside | Default theme, light | Default theme, dark | `#0026EA` pinned + `#FDD715` pinned accent, light | The same, dark |
476
+ |---|---|---|---|---|
477
+ | `on-brand` | 4.86 | 7.21 | 8.52 | 7.07 |
478
+ | `on-accent` | 5.02 | 7.11 | 11.66 | 11.66 |
479
+
480
+ That is the label of a Checkbox, Radio, Toggle, Input, Select, Textarea and
481
+ Rating, a field's help text, `Text` and `Heading` in their default tones, a
482
+ plain link, the label of an `outline`, `ghost` and `link` Button and
483
+ IconButton, the border of an `outline` one, and the focus ring of all of
484
+ them, the danger ring included. They were, on a brand block in the default
485
+ theme: labels 2.15 (light) and 1.52 (dark), `outline` and `ghost` 3.65 and
486
+ 2.05, `link` 1.39 and 1.34, the danger ring 1.06 and 1.04.
487
+
488
+ The Button variants, on a brand block (`bg-action-primary`). "Label" is
489
+ against the button's own fill, or against the block where the button has
490
+ none; "fill" is the button's fill against the block (3:1 for the button to be
491
+ seen as a shape):
492
+
493
+ | `Button variant` | Default theme, light | Default theme, dark | `#0026EA` + `#FDD715` pinned, light | The same, dark |
494
+ |---|---|---|---|---|
495
+ | `outline`, `ghost`, `link` | label 4.86 | label 7.21 | label 8.52 | label 7.07 |
496
+ | `secondary` | label 5.65 | label 7.75 | label 9.58 | label 7.63 |
497
+ | `accent` | label 5.02, fill 1.03 | label 7.11, fill 1.03 | label 11.66, fill 6.05 | label 11.66, fill 1.60 |
498
+ | `danger` | label 4.78, fill 1.06 | label 7.10, fill 1.04 | label 4.78, fill 1.65 | label 7.10, fill 1.03 |
499
+ | `primary` | the block's own colour: invisible | invisible | invisible | invisible |
500
+
501
+ On an accent block (`bg-accent`):
502
+
503
+ | `Button variant` | Default theme, light | Default theme, dark | `#0026EA` + `#FDD715` pinned, light | The same, dark |
504
+ |---|---|---|---|---|
505
+ | `outline`, `ghost`, `link` | label 5.02 | label 7.11 | label 11.66 | label 11.66 |
506
+ | `secondary` | label 5.86 | label 7.73 | label 9.43 | label 11.92 |
507
+ | `primary` | label 4.86, fill 1.03 | label 7.21, fill 1.03 | label 8.52, fill 6.05 | label 7.07, fill 1.60 |
508
+ | `danger` | label 4.78, fill 1.03 | label 7.10, fill 1.01 | label 4.78, fill 3.66 | label 7.10, fill 1.65 |
509
+ | `accent` | invisible | invisible | invisible | invisible |
510
+
511
+ So the quiet variants (`outline`, `ghost`, `link`, `secondary`) are legible on
512
+ either block in any theme. A solid one keeps its own label; whether it also
513
+ stands out as a shape depends on how far apart the two brand colours are: an
514
+ ultramarine and a yellow are 6:1 apart in light, the library's own blue and
515
+ citron 1.03:1. Where they are close, use a quiet variant for a second action,
516
+ or put the action on a card.
517
+
518
+ **What a block cannot make legible: put it on a card.** Some parts of a
519
+ control are not written with a role that a block can re-point. They are drawn
520
+ with a neutral step, with the primary fill itself, or with a status colour.
521
+ Against the block:
522
+
523
+ | Part | On a brand block (default light, dark; pinned light, dark) | On an accent block |
524
+ |---|---|---|
525
+ | Checkbox or Radio box, and the Toggle track, empty | 1.89, 3.44; 3.32, 3.43 | 1.96, 3.35; 1.82, 5.49 |
526
+ | the same, ticked or on, and a filled Rating star (the primary fill) | 1.00 in all four | 1.03, 1.03; 6.05, 1.60 |
527
+ | the text of an error or other status message | 1.47, 1.31; 1.19, 1.31 | 1.42, 1.34; 5.07, 1.22 |
528
+ | the fill of a field (Input, Select, Textarea) | 4.86, 7.31; 8.52, 7.30 | 5.02, 7.12; 1.41, 11.67 |
529
+
530
+ The label of such a control is legible on the block and the control itself
531
+ may not be: a ticked Checkbox on a brand block is a tick with no box around
532
+ it. Put Checkbox, Radio, Toggle, Calendar and anything that shows a validation
533
+ message on a card inside the block. A field is a surface of its own and
534
+ carries its text; it only disappears on an accent as light as the field (the
535
+ pinned yellow, 1.41). Rating has properties for its colours
536
+ (`--zabi-rating-on`, `--zabi-rating-off`, below).
537
+
538
+ **A surface inside a block is a surface again.** Anything inside the block
539
+ that paints a surface (a `Card`, a field, an `Alert`, a `list-group`: the
540
+ `bg-card`, `bg-surface-*`, `bg-input` and `bg-*-subtle` fills) gets the
541
+ theme's own roles back, and its own text colour, so a card needs nothing: a
542
+ Checkbox on it has the page's label colour and the theme's focus ring. For a
543
+ background the library cannot recognise (an inline style, a class of your
544
+ own), add `on-surface`:
545
+
546
+ ```svelte
547
+ <section class="bg-action-primary on-brand p-6">
548
+ <Button variant="outline">Hoppa över</Button>
549
+ <div class="on-surface rounded-container p-4" style="background: #fffbeb">
550
+ <Checkbox label="Kom ihåg mig" />
551
+ </div>
552
+ </section>
553
+ ```
554
+
555
+ A block inside that surface is a block again.
556
+
557
+ **The focus ring.** The default ring (`--color-focus-ring`) is the primary
558
+ fill, so on a brand block it measures 1.00:1 against the background and only
559
+ its 2px gap shows; the danger ring is the error colour, 1.0:1 to 1.7:1 there.
560
+ Inside a block every ring is the block's label colour and the gap is the
561
+ block's colour: the numbers of the first table above.
562
+
563
+ To do the same on a block of another colour, set the colour and the ring on
564
+ it yourself, and give text `tone="inherit"`:
120
565
 
121
566
  ```css
122
- @theme {
123
- /* Use a different brand shade */
124
- --color-action-primary: theme(colors.brand.600);
125
-
126
- /* Or use a completely custom color */
127
- --color-action-primary: #your-color-here;
567
+ .my-block {
568
+ color: #ffffff;
569
+ --color-focus-ring: #ffffff;
570
+ --color-focus-ring-offset: #7a1f5c; /* the block's own colour */
128
571
  }
129
572
  ```
130
573
 
131
- ### Brand Scale Usage
574
+ The roles of 3 are not re-pointed there: a quiet Button or a Checkbox on such
575
+ a block keeps the page's colours. Re-point the ones you need the same way
576
+ (`--color-label: #ffffff`, `--color-headline: #ffffff`, `--color-border:
577
+ #ffffff`), and then mark any card inside it `on-surface` and set them back
578
+ there.
579
+
580
+ ## Component-level properties
581
+
582
+ A few components read custom properties of their own, for colours that are not
583
+ roles of the theme. They are not tokens: the theme does not declare them, and
584
+ they do not appear in the generated files. Set them on the component, or on
585
+ anything around it; they inherit.
586
+
587
+ | Component | Property | What it colours | Default |
588
+ |---|---|---|---|
589
+ | `Rating` | `--zabi-rating-on` | a filled star | `--color-action-primary`; `--color-accent` with `tone="accent"` |
590
+ | `Rating` | `--zabi-rating-on-hover`, `--zabi-rating-on-active` | the same under the pointer and while pressed | `--zabi-rating-on` if you set it, else the tone's hover and pressed roles |
591
+ | `Rating` | `--zabi-rating-on-edge` | the outline of a filled star | the fill; `--color-accent-text` with `tone="accent"` |
592
+ | `Rating` | `--zabi-rating-off` | the outline of an empty star | `--color-control-border` |
593
+
594
+ ```svelte
595
+ <Rating label="Betyg" tone="accent" bind:value />
596
+
597
+ <!-- or a colour of your own, here for every rating in a block -->
598
+ <section class="bg-action-primary on-brand" style="--zabi-rating-on: var(--color-accent); --zabi-rating-off: var(--color-on-brand)">
599
+ <Rating label="Betyg" bind:value />
600
+ </section>
601
+ ```
602
+
603
+ A star is a control whose shape has to be seen (3:1 against what it sits on).
604
+ `tone="accent"` fills the star with the accent and outlines it in the accent's
605
+ text step, because an accent can be a colour no fill can show on white:
606
+
607
+ | Filled star, `tone="accent"` | page | card | inset |
608
+ |---|---|---|---|
609
+ | Default accent, light: fill / outline | 4.25 / 5.93 | 5.02 / 7.00 | 4.56 / 6.37 |
610
+ | Default accent, dark: fill / outline | 7.68 / 10.34 | 6.54 / 8.81 | 7.12 / 9.60 |
611
+ | `#FDD715` pinned, light: fill / outline | 1.19 / 5.94 | 1.41 / 7.01 | 1.28 / 6.38 |
612
+ | `#FDD715` pinned, dark: fill / outline | 12.58 / 10.27 | 10.71 / 8.75 | 11.67 / 9.53 |
613
+
614
+ A yellow star cannot reach 3:1 on a white card: its fill is 1.41:1 there. The
615
+ outline keeps the star's shape at 7:1, so the stars are always there to see.
616
+ What tells a filled star from an empty one on white is then the yellow inside
617
+ the outline and the outline's own colour, which is less than 3:1; the rating
618
+ also has its value as text (`showValue`, on by default when `readonly`) and in
619
+ its accessible name. On a dark or a brand block the yellow fill itself is
620
+ 6:1 or more. If you set `--zabi-rating-on` to a light colour yourself, set
621
+ `--zabi-rating-on-edge` too.
622
+
623
+ ## The generator
624
+
625
+ Exact brand colour on buttons: use `--pin`.
626
+
627
+ ```bash
628
+ npx zabi-theme --brand "#0026EA" --pin --out src/lib/brand.generated.css
629
+ ```
630
+
631
+ Without it, every fill is a step of a ramp built from the colour:
632
+
633
+ ```bash
634
+ npx zabi-theme --brand "#0026EA" --out src/lib/brand.generated.css
635
+ npx zabi-theme --brand "#C17B00" --accent "#ff3366" --neutral "#78716c" --out src/lib/brand.generated.css
636
+ ```
637
+
638
+ | Option | |
639
+ |---|---|
640
+ | `--brand <hex>` | Required. Builds `--zabi-brand-50 … 950`. |
641
+ | `--pin` | The primary action is the exact brand colour in light. See [`--pin`](#--pin-the-exact-brand-colour-on-buttons). |
642
+ | `--accent <hex>` | Builds `--zabi-accent-50 … 950`. Omitted: the library's citron. |
643
+ | `--pin-accent` | The same for the solid accent fill. Needs `--accent`. |
644
+ | `--neutral <hex>` | Tints the 21-step `--zabi-base-50 … 950`. Omitted: the library's greys. Takes the hue only, at no more than 0.03 chroma. |
645
+ | `--neutral-chroma <n>` | OKLCH chroma at the neutral ramp's peak, 0 to 0.1; needs `--neutral`. Lifts the 0.03 cap (0.04 to 0.06 gives a clearly tinted neutral such as a blue-slate), and makes the ink roles follow the neutral ramp. See [`--neutral-chroma`](#neutral-chroma-a-strongly-tinted-neutral). |
646
+ | `--out <file>` | Where to write. Omitted: stdout. |
647
+ | `--strict` | Exit 1 when a role pair is below WCAG AA. |
648
+ | `--set <token>=<value>` | Also write this declaration, in light and dark; repeatable. |
649
+ | `--set-light <token>=<value>` | The same for light only: dark keeps the library's value. Repeatable. See [`--set` and overrides](#--set-and-overrides). |
650
+ | `--set-dark <token>=<value>` | The same for dark only. Repeatable. |
651
+ | `--help` | Usage. |
652
+
653
+ Colours are hex, `#rgb` or `#rrggbb`. Warnings and the one-line summary go to
654
+ stderr. Exit codes: 0 done, 1 `--strict` with a failed pair, 2 bad usage
655
+ (missing `--brand`, not a hex colour).
656
+
657
+ ### `--neutral-chroma`: a strongly tinted neutral
658
+
659
+ `--neutral` gives the neutral ramp its hue and never more chroma than 0.03, so
660
+ it stays a tinted grey. For a UI that reads as one hue with the brand, say a
661
+ blue-slate under an ultramarine brand, give the chroma yourself:
662
+
663
+ ```bash
664
+ npx zabi-theme --brand "#0026EA" --neutral "#607296" --neutral-chroma 0.05 --out src/lib/brand.generated.css
665
+ ```
666
+
667
+ The ramp has the same 21 lightness steps and the same shape; only its peak
668
+ chroma is the number you gave (`0` to `0.1`). The contrast check runs on the
669
+ result as always. Without `--neutral-chroma` the output is exactly what it was.
670
+
671
+ With it, the translucent ink roles stop being grey and follow the ramp, as
672
+ `color-mix()` over `--zabi-base-900` (light) or `--zabi-base-50` (dark) at the
673
+ alpha the library gives each role:
674
+ `--color-action-secondary`, `-hover`, `-active`, `--color-surface-hover`,
675
+ `--color-surface-active` and, in light, `--color-border-overlay`.
676
+ `--shadow-color` becomes the RGB triplet of step 900 in light and stays `0 0 0`
677
+ in dark. The modal scrim `--color-overlay` stays black. Because a role on
678
+ `:root` is one value in both modes, the file restates the dark values under the
679
+ dark selectors, as `--pin` does. A role you set with `--set`, `--set-light` or
680
+ `--set-dark` keeps your value.
681
+ The contrast check does not evaluate these translucent roles, with or without
682
+ the option; the library's own guard (`npm run check:design`) does for the
683
+ defaults.
684
+
685
+ ### `--pin`: the exact brand colour on buttons
686
+
687
+ Unpinned, the primary button is step 600 of the ramp: for `#0026EA` that is
688
+ `#2b65ff`, visibly lighter than the brand. With `--pin` the ramp is the same
689
+ ramp, and the roles that *are* the brand colour are written as role overrides:
690
+
691
+ | | Unpinned | `--pin` |
692
+ |---|---|---|
693
+ | Primary button, light | step 600, `#2b65ff` | the colour itself, `#0026ea` |
694
+ | Its label | white, 4.76:1 | white, 8.52:1 |
695
+ | Hover, pressed | steps 700, 800 | the same hue, darker by the same distances: `#0100c4`, `#000e78` |
696
+ | Focus ring, links, checked boxes | steps 600 and 700 | the colour itself, because every guarded pair still passes |
697
+ | Tints and borders (`-subtle`, `-border`), `--color-brand-*` | on the ramp | on the ramp, unchanged |
698
+ | Dark | step 400, `#84acff` | step 400, `#84acff`: not pinned |
699
+
700
+ What it decides, and how:
701
+
702
+ - **The fill is never moved.** If the colour fails a pair, the pair is a
703
+ warning (and `--strict` exits 1). `#C17B00` pinned is 2.92:1 against the
704
+ light page, where a button needs 3:1, and the generator says so.
705
+ - **The label** is white or the ramp's 950 step, whichever reaches 4.5:1 on the
706
+ fill and both its states. An amber gets the dark label, and its hover and
707
+ pressed states go lighter, since darker states would lose the label.
708
+ - **Focus ring and links follow only if they pass.** The ring needs 3:1 on
709
+ every surface and a link 4.5:1 on the page and on a card. A yellow passes
710
+ neither, so both stay on the ramp. The header lists which followed.
711
+ - **Dark is not pinned unless the colour passes there.** `#0026EA` is 2.08:1
712
+ against a dark page and 1.77:1 against a dark card, where a fill needs 3:1:
713
+ no label makes that a button. Dark keeps the
714
+ mirrored step 400, exactly as without `--pin`, and the header says so. A
715
+ colour that does pass in dark (the amber, the yellow) is pinned there too,
716
+ with lighter states.
717
+
718
+ The trade-off: a ramp step is guaranteed to pass, in both modes, for any
719
+ colour. A pinned colour is exact and passes only if the colour can. Unpinned,
720
+ a brand rarely warns; pinned, read the warnings.
721
+
722
+ The file then has three rules, not one: `:root` with the ramp and the light
723
+ roles, and the dark values of those roles under `.dark, [data-theme="dark"]`
724
+ and under `[data-theme="auto"]` in the dark media query. A role set on `:root`
725
+ alone would be one value in both modes. To force a dark value the generator
726
+ declined, write the role in your own dark rule after the file (see
727
+ [One role in dark only](#one-role-in-dark-only)).
728
+
729
+ A role you pass with `--set` (or `--set-light`, `--set-dark`) is left to you:
730
+ `--pin` does not write it in either mode. With `--set` it is your value in both
731
+ modes; with `--set-light` or `--set-dark`, only in that mode.
732
+
733
+ ### What it does
734
+
735
+ - **Brand and accent ramps** on the library's lightness curve, from the hue and
736
+ chroma of the colour you give. See
737
+ [the first section](#the-first-thing-to-know-your-colour-is-not-pinned-to-a-step).
738
+ - **Neutral ramp.** It keeps the base scale's own 21 lightness steps and takes
739
+ only the hue, at low chroma (0.03 at most). A saturated colour gives a tinted
740
+ grey, not a coloured page. `neutralChroma` lifts that cap.
741
+ - **"On" colours.** It sets `--zabi-on-brand` and `--zabi-on-brand-dark` (and
742
+ the accent pair when `--accent` is given) to white or the ramp's 950 step,
743
+ whichever reaches 4.5:1 on the fill, its hover and its active step.
744
+ - **Contrast check.** Every pair the library holds itself to is resolved in
745
+ light and dark with the new ramps in place: 4.5:1 for text and
746
+ 3:1 for focus rings and UI parts.
747
+
748
+ ### What it does not do
749
+
750
+ - It does not put your exact hex in the ramp. `--pin` puts it on the primary
751
+ action; the ramp steps stay on the curve.
752
+ - It does not write fonts, radius or shadow unless you pass them with `--set`.
753
+ - It does not check the hover, pressed and secondary-button alpha tints. They
754
+ are not part of the contrast list.
755
+ - It does not check that a `--set` token exists. A misspelt name is written as
756
+ given.
757
+ - It does not load or check fonts.
758
+
759
+ ### Warnings
760
+
761
+ A plain brand passes, because the ramps share the library's curve. Warnings
762
+ come from two places.
763
+
764
+ An input that will not give the ramp its author probably expects:
765
+
766
+ ```
767
+ zabi-theme: warning: brand #808080 has almost no colour in it, so the ramp is a grey scale. Give a more saturated colour for a visible hue.
768
+ ```
769
+
770
+ A role pair below AA, which only happens when `--set` moves a role or `--pin`
771
+ pins a colour that cannot carry it:
772
+
773
+ ```
774
+ zabi-theme: warning: light · on-brand label on primary: #2f1b00 on #9b6200 is 3.24:1, needs 4.5:1 (--color-on-brand on --color-action-primary)
775
+ ```
776
+
777
+ ### `--strict` in CI
778
+
779
+ Without `--strict` a failed pair is a warning and the file is still written.
780
+ With it the command exits 1. Because the output is deterministic, one line
781
+ checks both that the brand passes and that the committed file is current:
782
+
783
+ ```bash
784
+ npx zabi-theme --brand "#C17B00" --neutral "#78716c" --strict --out src/lib/brand.generated.css && git diff --exit-code src/lib/brand.generated.css
785
+ ```
786
+
787
+ ### `--set` and overrides
788
+
789
+ `--set` writes one more declaration into the file. It applies in light and in
790
+ dark, and it takes part in the contrast check and in the choice of the "on"
791
+ colours. `--set-light` and `--set-dark` do the same for one mode.
792
+
793
+ ```bash
794
+ npx zabi-theme --brand "#C17B00" --set "--color-link=var(--color-brand-800)" --out src/lib/brand.generated.css
795
+ npx zabi-theme --brand "#C17B00" --set '--font-family-heading="Fraunces", var(--font-family-sans)' --out src/lib/brand.generated.css
796
+ ```
797
+
798
+ Point a role at a **semantic** step, `var(--color-brand-800)`, not at a
799
+ physical one. The semantic step mirrors in dark; `var(--zabi-brand-800)` is
800
+ the same dark brown in both modes, and as a link colour it fails in dark:
801
+
802
+ ```
803
+ zabi-theme: warning: dark · link on page: #613b00 on #18181b is 1.8:1, needs 4.5:1 (--color-link on --color-surface-base)
804
+ ```
805
+
806
+ #### One mode only: `--set-light` and `--set-dark`
807
+
808
+ Dark is derived from light, so `--set "--color-surface-raised=#f8faff"` also
809
+ paints the dark cards `#f8faff`. To tint the light card and keep the dark mix
810
+ the library gives it:
811
+
812
+ ```bash
813
+ npx zabi-theme --brand "#0026EA" \
814
+ --set-light "--color-surface-raised=#f8faff" --set-light "--color-surface-overlay=#f8faff" \
815
+ --out src/lib/brand.generated.css
816
+ ```
817
+
818
+ The value goes on `:root`. A `:root` value would win in dark too, so the file
819
+ restates, under both dark selectors, the library's own dark value of every token
820
+ you set in light only (the ramp the file writes, for a ramp step; the light
821
+ default if the library has no dark value for it). `--set-dark` writes only under
822
+ the dark selectors. A token given to `--set` and to a mode flag takes the mode's
823
+ value in that mode. The contrast check evaluates each mode with that mode's own
824
+ values, and a warning names the mode as before.
825
+
826
+ A token the library has no dark value for, or one you made up
827
+ (`--set-light "--my-gap=4px"`), cannot be put back in dark: it keeps the light
828
+ value there unless you also pass `--set-dark`.
829
+
830
+ ### `createTheme()`
831
+
832
+ The same thing as a function, for a build script:
833
+
834
+ ```js
835
+ import { writeFileSync } from "node:fs";
836
+ import { createTheme } from "zabi-components/create-theme";
837
+
838
+ const { css, tokens, warnings, closest } = createTheme({
839
+ brand: "#C17B00",
840
+ accent: "#ff3366", // optional
841
+ neutral: "#78716c", // optional
842
+ neutralChroma: 0.05, // optional, 0 to 0.1, needs neutral: a clearly tinted neutral; the ink roles follow it
843
+ overrides: { "--color-link": "var(--color-brand-800)" }, // optional; both modes. Or { light, dark, both }
844
+ pin: true, // optional: the exact brand on the primary action; or { brand: true, accent: true }
845
+ });
846
+
847
+ for (const warning of warnings) console.warn(warning.message);
848
+ console.log(`Brand is closest to step ${closest.brand.step} (${closest.brand.hex}).`);
849
+
850
+ if (warnings.some((warning) => warning.type === "contrast")) process.exit(1);
851
+ writeFileSync("src/lib/brand.generated.css", css);
852
+ ```
853
+
854
+ | Returned | |
855
+ |---|---|
856
+ | `css` | The stylesheet: a header comment and one `:root { … }` rule. With `pin`, `neutralChroma` or `light` / `dark` overrides, two dark rules follow it. |
857
+ | `tokens` | Every declaration of the `:root` rule, name to value. |
858
+ | `darkTokens` | Only with `pin`, `neutralChroma` or `light` / `dark` overrides: every declaration of the dark rules. |
859
+ | `pinned` | Only with `pin`: `{ brand?, accent? }`, each `{ hex, light: { followed, onRamp, states }, dark: { pinned, followed, onRamp, states?, failed? } }`. |
860
+ | `warnings` | `{ type: "contrast", pair, mode, ratio, required, foreground, background, message }` for each role pair below AA, and `{ type: "input", option, message }` notes about the colours given. Empty when everything passes. |
861
+ | `closest` | `{ brand, accent?, neutral? }`, each `{ step, hex, deltaE, exact }`: where the input landed in its ramp. |
862
+
863
+ `overrides` is either the flat map above, one value for both modes, or an object
864
+ with any of `light`, `dark` and `both`, each a flat map:
865
+
866
+ ```js
867
+ createTheme({
868
+ brand: "#0026EA",
869
+ overrides: {
870
+ light: { "--color-surface-raised": "#f8faff", "--color-surface-overlay": "#f8faff" }, // dark keeps its mix
871
+ dark: { "--color-link": "var(--color-brand-300)" }, // dark only
872
+ both: { "--color-focus": "var(--color-brand-700)" }, // same as the flat form
873
+ },
874
+ });
875
+ ```
876
+
877
+ Flat `--token` keys beside the three count as `both`. A token in `both` and in
878
+ `light` or `dark` takes the mode's value in that mode. `tokens` holds the `:root`
879
+ rule (`both`, then `light`); `darkTokens` holds what the dark rules say: the
880
+ `dark` map, and for every token set in `light`, the library's dark value for it
881
+ (or the `both` value), so `:root` does not reach dark. See
882
+ [One mode only](#one-mode-only---set-light-and---set-dark).
883
+
884
+ It throws a `TypeError` when `brand` is missing, a colour is not hex,
885
+ `pin.accent` is set without `accent`, `neutralChroma` is not a number from 0
886
+ to 0.1 or is given without `neutral`, or an override is not a custom property
887
+ name with a non-empty string value (the message names the key path, for example
888
+ `overrides.light["--color-link"]`). Without these options the result and the
889
+ file are exactly what they were before the option existed.
890
+
891
+ ## What does not follow an override
892
+
893
+ - **Hover, pressed and secondary-button fills.** `--color-surface-hover`,
894
+ `--color-surface-active` and `--color-action-secondary` (and `-hover`,
895
+ `-active`) are fixed near-black (light) and near-white (dark) alpha tints.
896
+ They do not take the hue of `--zabi-base-*`, unless you give `neutralChroma`
897
+ (`--neutral-chroma`): then they are mixes of the neutral ramp's ink at the
898
+ same alphas.
899
+ - **The overlay edge and the modal backdrop.** `--color-border-overlay` in
900
+ light is a fixed alpha tint too, and follows the ramp with `neutralChroma`
901
+ (in dark it is already a ramp step). `--color-overlay`, the scrim, stays black.
902
+ - **The shadow colour.** `--shadow-color` is the default neutral's ink, not
903
+ your neutral's. With `neutralChroma` it is the ramp's step 900 in light;
904
+ otherwise set it yourself, it is on the list above.
905
+ - **The `energetic` family.** It points at the citron ramp, not at the accent,
906
+ so it stays yellow when `--zabi-accent-*` changes. `success`, `warning`,
907
+ `error` and `info` keep their own ramps as well.
908
+ - **The size of the dark surface steps.** The four dark levels are mixed from
909
+ your neutral ramp, so they take its hue. The mix amounts were solved for the
910
+ default greys; a neutral whose 900 or 50 step is far from `#18181b` /
911
+ `#fafafa` gives steps of a different size.
912
+ - **Browsers without `color-mix()`.** Where Tailwind compiles the dark file
913
+ (your build, or the `zabi-components/css` bundle) it adds the default hex as
914
+ a fallback before each mixed surface. Those browsers get the default dark
915
+ surfaces whatever the app overrides.
916
+ - **Tokens set below `<html>`.** See [Import order](#import-order).
917
+ - **Semantic steps.** `--color-brand-*` and friends do not rebrand both modes;
918
+ see [Roles](#roles-read-them-do-not-set-them).
919
+
920
+ ## Stability
921
+
922
+ Every token named in [Tokens an app may set](#tokens-an-app-may-set) and
923
+ [Roles](#roles-read-them-do-not-set-them) is public API, and so is every other
924
+ token the theme files publish. **Renaming or removing one is a breaking
925
+ change.** Adding a token is not. Regenerating the library's own ramps is
926
+ breaking too, because it changes every colour an app has not overridden.
927
+ `RELEASING.md` says the same from the release side.
928
+
929
+ This is enforced, not just promised.
930
+ `tests/__snapshots__/theme-token-names.snap.json` lists every published token
931
+ name. `npm run test:themes` reads the names out of the built
932
+ `zabi-components/colors` file and fails if one on the list is missing. The
933
+ list only grows: refreshing the snapshots adds new names and never drops one,
934
+ so taking a name off is a hand edit of that file in a commit, which is where a
935
+ breaking change gets noticed.
936
+
937
+ The CSS export paths (`zabi-components/theme-only` and the rest) are public in
938
+ the same way: removing one is breaking.
939
+
940
+ ---
941
+
942
+ ## Reference: how the theme is built
943
+
944
+ Background for the tables above. An app does not need any of it to rebrand.
945
+
946
+ ### Which step does what
132
947
 
133
948
  The brand scale is used throughout the system:
134
949
 
135
950
  - **brand-50 to brand-100**: Subtle backgrounds, hover states
136
951
  - **brand-300 to brand-400**: Light accents, disabled states
137
- - **brand-500**: Focus rings, medium emphasis
138
- - **brand-600**: Primary color, links
139
- - **brand-700**: Link hover, primary hover
140
- - **brand-800**: **Primary buttons** (`action-primary`)
141
- - **brand-900**: Primary button hover
142
- - **brand-950**: Darkest brand shade
952
+ - **brand-500**: Medium emphasis
953
+ - **brand-600**: **Primary buttons** (`action-primary`) and the focus ring, in both modes
954
+ - **brand-700**: Primary hover, links
955
+ - **brand-800**: Primary active, link hover
956
+ - **brand-900 / 950**: Darkest brand shades
143
957
 
144
- ### Dark Mode
958
+ Hover and active always move *toward* the dark end in light mode. The `.dark`
959
+ mirror flips that automatically, so "more pressed" means "more contrast against
960
+ the page" in both themes. Never point `-active` at a step on the far side of the
961
+ ramp — that is how the pressed primary label ended up at 1.66:1.
145
962
 
146
- Dark mode automatically inverts the brand scale:
147
- - `brand-50` becomes `brand-950` (darkest)
148
- - `brand-950` becomes `brand-50` (lightest)
963
+ ### Calibrated colour ramps
149
964
 
150
- This happens automatically in the `.dark` block, so you typically don't need to override action colors in dark mode.
965
+ Every chromatic ramp (`brand`, `citron`, `pine`, `iris`, `warning`, `error`) is
966
+ **generated against one shared lightness curve**, so `<ramp>-600` means the same
967
+ perceptual lightness in every ramp. That is what lets the semantic families read
968
+ as one family instead of six unrelated colours.
151
969
 
152
- ## 🌙 Dark Mode Action Colors
970
+ | step | 50 | 100 | 200 | 300 | 400 | 500 | 600 | 700 | 800 | 900 | 950 |
971
+ |---|---|---|---|---|---|---|---|---|---|---|---|
972
+ | CIE L\* | 97 | 94 | 88 | 80 | 70 | 58 | **47** | 38 | 29 | 20 | 12 |
153
973
 
154
- Dark mode action colors are handled in `src/app.css` in the `.dark` block (lines 393-404):
974
+ Step **600** is the solid-fill step: dark enough to clear 4.5:1 against white,
975
+ light enough to still read as a colour. Every semantic token points at it.
155
976
 
156
- ### Primary Actions
157
- - **No explicit dark mode override needed** - Brand colors are automatically inverted
158
- - `brand-800` (light mode) → `brand-200` (dark mode) automatically
977
+ - **Source of truth:** `tokens/chromatic-scales.js` — hue and peak chroma per
978
+ ramp. The target curve, the chroma envelope and the solver are in
979
+ `create-theme/lib/ramp-math.js`, which the theme generator ships, so a
980
+ generated brand sits on the same curve.
981
+ - **Regenerate:** `node scripts/generate-ramps.js` (runs inside `npm run sync:tokens`).
982
+ - **Verify:** `node scripts/check-ramp-lightness.js` — fails if any step drifts
983
+ more than ±1.5 L\* off the curve, or if two adjacent steps differ by more than
984
+ 15 L\* (a cliff rather than a ramp).
159
985
 
160
- ### Secondary Actions
161
- - **No explicit dark mode override needed** - Base colors are automatically inverted
162
- - `base-600` (light mode) → `base-400` (dark mode) automatically
986
+ To change the library's own ramps, edit `hue` / `peakChroma` for a ramp in
987
+ `tokens/chromatic-scales.js` and re-run the generator. **Do not hand-edit
988
+ `--zabi-<ramp>-*` in `src/app.css`** — the generator overwrites them. They live
989
+ in `@theme` only; `.dark` no longer holds a copy. (An app rebrands by overriding
990
+ them on `:root` instead; see [Tokens an app may set](#tokens-an-app-may-set).)
163
991
 
164
- ### Danger Actions
165
- - **Has explicit dark mode overrides** (lines 399-404) because red colors aren't automatically inverted:
166
- ```css
167
- .dark {
168
- --color-action-danger: theme(colors.red.500);
169
- --color-action-danger-hover: theme(colors.red.600);
170
- --color-action-danger-active: theme(colors.red.700);
171
- /* ... */
172
- }
173
- ```
992
+ `--zabi-accent-*` is not a generated ramp: each step aliases the citron step of
993
+ the same number.
174
994
 
175
- ### In Generated Files
995
+ The `base` (neutral) ramp is deliberately **not** on this curve: it is wider on
996
+ purpose because it also drives text, borders and the surface levels. Semantic
997
+ `neutral` aliases the base step closest to the chromatic 600s.
176
998
 
177
- - **`dist/zabi-components.css`**: Has dark mode at line 2178, danger actions at lines 2264-2269
178
- - **`dist/zabi-components-theme-dark-only.css`**: Has dark mode action colors at lines 119-130 (only danger has explicit overrides)
179
- - **`dist/zabi-components-theme-only.css`**: Only has light mode (@theme block), no dark mode
180
- - **`dist/zabi-components-theme.css`**: Only has light mode (@theme block), no dark mode
999
+ ### Semantic families
181
1000
 
182
- ## 📁 File Structure
1001
+ Each family exposes the same roles, built from the same steps:
183
1002
 
184
- ```
185
- src/
186
- └── app.css ← 🎯 EDIT THIS FILE FOR ALL THEMING (single source of truth)
1003
+ | token | step | use |
1004
+ |---|---|---|
1005
+ | `--color-<family>` | 600 | solid fill — badge `emphasis="solid"`, progress bars |
1006
+ | `--color-<family>-weak` | 700 | pressed / emphasis |
1007
+ | `--color-<family>-medium` | 800 | strongest fill |
1008
+ | `--color-<family>-strong` | 900 | darkest |
1009
+ | `--color-<family>-subtle` | 200 (dark: 100) | tinted fill — badges, alerts |
1010
+ | `--color-<family>-border` | 300 (dark: 200) | tinted edge |
1011
+ | `--color-<family>-text` | 700 | text on a subtle fill or a page surface |
1012
+
1013
+ Families: `success`, `warning`, `error`, `info`, `energetic`, `neutral`.
1014
+
1015
+ `accent` has the subtle trio on the same steps, and action-style `-hover` (700)
1016
+ and `-active` (800) instead of `-weak` / `-medium` / `-strong`.
1017
+
1018
+ The tints are the one role that does not simply mirror. A step-100 fill is
1019
+ 1.16:1 on a white card, too pale to read as a fill, so light uses steps 200 and
1020
+ 300 (1.36:1 and about 1.7:1). Mirrored, those would be too heavy on a dark
1021
+ surface, so `.dark` pins 100 and 200. `neutral` follows the same shape on the
1022
+ base ramp: `base-250` fill and `base-300` edge in light, `base-300` and
1023
+ `base-200` in dark.
1024
+
1025
+ Prefer the **subtle** trio (`-subtle` fill + `-border` edge + `-text` label) for
1026
+ anything informational. Solid fills are for the one element on a screen that has
1027
+ to shout.
1028
+
1029
+ ### Surface elevation levels
1030
+
1031
+ Every surface uses one of four semantic levels. Use these tokens instead of raw `base-*` steps for backgrounds:
1032
+
1033
+ | Level | Token / utility | Use for | Light (OKLCH L) | Dark (OKLCH L) |
1034
+ |---|---|---|---|---|
1035
+ | Base | `--color-surface-base` · `bg-surface-base` | Page / app shell (the darkest level in both themes) | `base-150` · 94 | `--zabi-base-900` `#18181b` · 21 |
1036
+ | Raised | `--color-surface-raised` · `bg-surface-raised` | Cards, panels, sidebars | `#ffffff` · 100 | 6.4% wash `#262629` · 27 |
1037
+ | Elevated | `--color-surface-elevated` · `bg-surface-elevated` | Nested cards, hover and active fills | `base-100` · 97 | 13.1% wash `#363638` · 33 |
1038
+ | Overlay | `--color-surface-overlay` · `bg-surface-overlay` | Modals, sheets, dropdown/select menus, navigation panels, toasts | `#ffffff` · 100 | 19.7% wash `#454547` · 39 |
1039
+ | Inset | `--color-surface-inset` · `bg-surface-inset` | A recessed area on a raised surface: a well, a stat strip, a code sample inside a card | `base-100` · 97 | `base-150` `#1f1f22` · 24 |
1040
+
1041
+ **Inset is not a fifth rung.** The four levels say how far a surface is lifted;
1042
+ inset says it is cut into the card it sits on. It is the ramp step between the
1043
+ page and the raised surface in both themes, about 3 lightness points below
1044
+ `raised`, and every text token passes AA on it. In dark it is the step the input
1045
+ field has always used, and `--color-input` now points at it. In light it shares
1046
+ `base-100` with `elevated`: under a white card the only direction left is down,
1047
+ so a nested card and a well are the same colour there and differ only in dark.
1048
+ Do not use `--color-input` for a well. It is white in light mode, because a
1049
+ light field is a raised surface.
1050
+
1051
+ Supporting tokens:
1052
+ - `--color-surface-overlay-hover` (`bg-surface-overlay-hover`): row and icon-button hover **inside** an overlay. It is lighter than the overlay in dark mode.
1053
+ - `--color-border-overlay` (`border-border-overlay`): 1px edge on overlays. A 10% ink tint in light mode, so a white menu on a white card has an outline; a solid step in dark mode.
1054
+
1055
+ **The light ladder runs downwards.** Light cannot go lighter than white, so
1056
+ below the card it is ordered page → elevated → raised, with overlay equal to
1057
+ raised and separated from it by `shadow-lg` and the overlay edge. The page is
1058
+ `base-150`, not `base-100`: at `base-100` a card was 1.10:1 on the page and a
1059
+ nested card 1.04:1 on its parent.
1060
+
1061
+ **The dark levels are generated, not hand-picked.** The page is
1062
+ `--zabi-base-900`, and each level above it is `--zabi-base-50` — the same light
1063
+ as the dark hover tint `--color-surface-hover` — washed over the page at
1064
+ 6.4% / 13.1% / 19.7%, so every level is literally the level below it with more
1065
+ light on it. The ladder lives in `tokens/surface-ladder.js`; edit the alphas
1066
+ there and run `npm run sync:tokens`, never the values in `app.css`.
187
1067
 
188
- dist/ ← ⚠️ GENERATED FILES - DON'T EDIT
189
- ├── zabi-components.css (full CSS with dark mode)
190
- ├── zabi-components-theme.css (light mode only)
191
- ├── zabi-components-theme-only.css (light mode only)
192
- ├── zabi-components-theme-dark.css (dark mode only)
193
- ├── zabi-components-theme-dark-only.css (dark mode only)
194
- └── zabi-components-colors.css (standalone, both modes)
1068
+ The levels ship as `color-mix()` over the neutral ramp, not as baked hex:
195
1069
 
196
- examples/ ← ⚠️ EXAMPLES ONLY - DON'T EDIT
197
- └── theme-extensions/
198
- ├── 02-custom-brand-colors.css
199
- └── 03-custom-semantic-colors.css
1070
+ ```css
1071
+ --color-surface-base: var(--zabi-base-900);
1072
+ --color-surface-raised: color-mix(in srgb, var(--zabi-base-50) 6.4%, var(--zabi-base-900));
200
1073
  ```
201
1074
 
202
- ## ⚠️ Important Notes
1075
+ That is how the ladder follows an app's `--zabi-base-*` override: a warm grey
1076
+ gets four warm dark levels, with no surface tokens of its own. Mixing two
1077
+ opaque colours in sRGB is the same arithmetic as the old build-time composite,
1078
+ so the default theme resolves to exactly the hex in the table, and an overlay
1079
+ is still opaque. The static checks evaluate the mix themselves
1080
+ (`scripts/resolve-tokens.js`), so `check-contrast.js` still resolves every AA
1081
+ pair against a dark surface and `check-surface-elevation.js` still measures the
1082
+ steps. The trade is unchanged: nesting past the four levels is not automatic. A
1083
+ card inside a card does not self-lighten, it names the next level up.
1084
+
1085
+ Two things to know. The step size depends on the app's neutrals: the alphas are
1086
+ solved for the default greys, and a ramp whose 900 or 50 step is far from
1087
+ `#18181b` / `#fafafa` can land outside the 5–8 point window the guard holds the
1088
+ default to. And wherever Tailwind compiles the dark file (the
1089
+ `zabi-components/css` bundle, or an app's own build) it adds a static fallback,
1090
+ the default hex, before each mix for browsers without `color-mix()`; those get
1091
+ the default dark surfaces whatever the app overrides.
1092
+
1093
+ **Why dark mode steps lightness instead of using shadows:** a drop shadow simulates light blocked by a raised object, which reads on a light page. On a dark page the shadow is as dark as the background, so the signal disappears. In dark mode each level is therefore **lighter** than the one below it, by +6 OKLCH lightness points on the neutral base hue. Light mode keeps white surfaces and uses shadows for elevation.
1094
+
1095
+ **Rules:**
1096
+ - Anything that floats above content must use a lighter level than what it floats over. Floating components (Modal, SlideUp, Dropdown/Select menu, NavigationMenu panel, Toaster/Toast) use `bg-surface-overlay`.
1097
+ - Hover fills on overlays use `bg-surface-overlay-hover`, not `bg-base-*`, which would go darker in dark mode.
1098
+ - Existing tokens remain as aliases: `background` → base, `card` / `surface-1` → raised, `surface-2` / `card-hover` → elevated, `surface-3` → overlay (dark), `card-elevated` → overlay (light) / elevated (dark).
1099
+
1100
+ **Enforced by** `scripts/check-surface-elevation.js`, which `validate-theme.js` runs during `npm run build:css`. It fails when:
1101
+ - dark levels aren't strictly increasing;
1102
+ - any dark step is outside 5–8 OKLCH L points;
1103
+ - the light levels are not ordered base < elevated < raised, either step is under 2 points, or the overlay is darker than raised;
1104
+ - the inset surface is less than 2 points below raised, or darker than the page, in either theme;
1105
+ - a text token falls below AA on any level or on the inset surface;
1106
+ - overlay hover or tooltip fills are darker than the overlay;
1107
+ - a floating component paints a surface class below overlay.
203
1108
 
204
- 1. **Never edit files in `dist/`** - These are automatically generated from `src/app.css` by `scripts/build-css.js`
205
- 2. **All theming is in `src/app.css`** - This is the single source of truth
206
- 3. **Always edit `src/app.css`** - This is your single source of truth
207
- 4. The `examples/` folder contains examples, not your actual theme
208
- 5. **Dist files are overwritten** every time you run `npm run build:css`
1109
+ To retune the dark levels, edit `SURFACE_ALPHA` in `tokens/surface-ladder.js`, run `npm run sync:tokens`, and then `node scripts/check-surface-elevation.js`.
209
1110
 
210
- ## 🔄 Build Process
1111
+ > The light surface tokens must stay **below** the `/* Background Colors */` marker in `@theme`. `scripts/sync-theme-tokens.js` regenerates everything between the base-scale aliases and that marker.
211
1112
 
212
- When you edit `src/app.css`:
1113
+ ### Interaction fills
1114
+
1115
+ Hover and active fills are **surface-relative alpha tints**, not fixed ramp steps:
1116
+
1117
+ ```css
1118
+ /* light */
1119
+ --color-surface-hover: rgba(9, 9, 11, 0.09);
1120
+ --color-surface-active: rgba(9, 9, 11, 0.15);
1121
+ /* dark */
1122
+ --color-surface-hover: rgba(250, 250, 250, 0.08);
1123
+ --color-surface-active: rgba(250, 250, 250, 0.14);
1124
+ ```
213
1125
 
214
- 1. **Development**: Changes are automatically reflected (no build needed)
215
- 2. **Production**: Run `npm run build:css` to regenerate dist files
216
- 3. The build script (`scripts/build-css.js`) reads `src/app.css` and generates:
217
- - `zabi-components.css` - Full compiled CSS with Tailwind processed (includes dark mode)
218
- - `zabi-components-theme.css` - Theme block with Tailwind import (light mode only)
219
- - `zabi-components-theme-only.css` - Theme block only (light mode only)
220
- - `zabi-components-theme-dark.css` - Dark mode theme with Tailwind import
221
- - `zabi-components-theme-dark-only.css` - Dark mode theme only
222
- - `zabi-components-colors.css` - Standalone CSS custom properties (both light and dark)
1126
+ A fixed step can coincide exactly with the surface it sits on — `base-100` in
1127
+ dark mode *is* `--color-surface-base`, which is how ghost-button hover became
1128
+ invisible. `check-token-violations.js` now fails on `hover:bg-base-*` /
1129
+ `active:bg-base-*` in component source, in `src/routes` and in the `@apply`
1130
+ lines of `src/app.css`.
223
1131
 
224
- ## 🔄 After Making Changes
1132
+ Being one step away is not the same as being visible. `check-contrast.js`
1133
+ composites each tint over every surface level and requires 1.2:1 for the
1134
+ resting hover and the secondary button, and a further 1.08:1 for each state
1135
+ after it. Light hover was 6% ink, 1.14:1, and passed every check until then.
225
1136
 
226
- ### For Development
227
- After editing `src/app.css`, changes are automatically reflected in your app. No build needed!
1137
+ Use `hover:bg-surface-hover` / `active:bg-surface-active` for quiet controls, and
1138
+ the `action-*-hover` / `action-*-active` tokens for filled ones.
1139
+
1140
+ **State variants next to a hand-written colour class.** `text-description`,
1141
+ `border-border`, `bg-card` and the other semantic colour classes are written by
1142
+ hand in `src/app.css`, outside every cascade layer, so they beat any generated
1143
+ utility on the same element, including the state variant meant to replace
1144
+ them. `text-description hover:text-headline` compiled and never changed on
1145
+ hover. Each such variant a component uses is restated by hand in the block
1146
+ headed "STATE VARIANTS OF THE HAND-WRITTEN COLOUR CLASSES". When a component
1147
+ needs a new one, add the rule there; `tests/state-variants.test.ts`
1148
+ (`scripts/check-state-variants.js`) fails until it exists.
1149
+
1150
+ ### Contrast
1151
+
1152
+ `scripts/check-contrast.js` resolves every fill/foreground pair a component can
1153
+ render — through the same token chain the CSS uses — in **both** themes, and
1154
+ fails below WCAG AA. It also holds the focus ring to 3:1 against the page, a
1155
+ card, and the offset gap that separates it from a primary button. The muted
1156
+ ring (`--color-focus-ring-muted`), the danger ring (`--color-focus-ring-danger`) and the control boundary
1157
+ (`--color-control-border`), and the brand and nav rings, are held to 3:1 on
1158
+ all five surfaces: page, card, inset, elevated and overlay. The colour a focus-ring rule
1159
+ uses must be a token in that list (`scripts/contrast-pairs.js`), so a ring
1160
+ cannot read a raw ramp step.
1161
+ `--color-input-border`, the edge of a form field, is held to 3:1 on the field
1162
+ fill (`--color-input`), the page and the card (WCAG 1.4.11). The same pairs are
1163
+ in the list `createTheme` checks, so a generated theme that lets a field's edge
1164
+ fall below 3:1 is reported. Run it after
1165
+ re-pointing any token:
228
1166
 
229
- ### For Production/Library Build
230
- If you're building the library for distribution:
231
1167
  ```bash
232
- npm run build:css # Regenerates all dist CSS files from src/app.css
1168
+ npm run check:contrast # or: npm run check:design (all five guards)
233
1169
  ```
234
1170
 
235
- ## Optional perceptual midpoint generation (OKLCH)
1171
+ ### Border radius
1172
+
1173
+ Four radii, chosen by **role**, never by size. A large button is a bigger box
1174
+ with the same corner as a small one.
1175
+
1176
+ | token | value | use |
1177
+ |---|---|---|
1178
+ | `--radius-control` | 8px | buttons, inputs, selects, toggles |
1179
+ | `--radius-container` | 12px | cards, alerts, panels, list items, code blocks |
1180
+ | `--radius-overlay` | 16px | modals, sheets, menus, popovers, toasts |
1181
+ | `--radius-pill` | full | badges, avatars, status dots |
1182
+
1183
+ `--radius-sm/md/lg/xl` remain as legacy aliases so that existing consumer
1184
+ stylesheets keep compiling, but components must not use them — `check-control-geometry.js` fails the build if a
1185
+ component reaches for a t-shirt radius.
1186
+
1187
+ ### Control geometry
1188
+
1189
+ One height scale is shared by **every** form control, so a Button, Input, Select,
1190
+ IconButton and Slider of the same size line up in a row:
1191
+
1192
+ | size | height | token |
1193
+ |---|---|---|
1194
+ | `sm` | 32px | `--control-height-sm` |
1195
+ | `md` | 40px | `--control-height-md` |
1196
+ | `lg` | 48px | `--control-height-lg` |
1197
+
1198
+ IconButton also has `xs`, a 24px box for dense pointer-first layouts. No text
1199
+ control is that small, so it sits outside the shared scale, and the check pins
1200
+ it separately.
1201
+
1202
+ Enforced by `scripts/check-control-geometry.js`.
1203
+
1204
+ ### Shadow scale
1205
+
1206
+ Elevation in light mode is **two steps**, not a ramp. A shadow says "this is
1207
+ above the page" or "this floats over it" — there is no third meaning, and an
1208
+ in-between value just makes two neighbouring surfaces look accidentally
1209
+ different.
1210
+
1211
+ | Utility | Reads as | Use for |
1212
+ |---|---|---|
1213
+ | `shadow-sm` | Raised | Cards, tables, sidebars, toggle knobs, colour-picker thumbs |
1214
+ | `shadow-lg` | Floating | Modals, sheets, dropdown/select menus, toasts, popovers |
1215
+
1216
+ `shadow-none` is the explicit *absence* of a shadow (outlined and flat cards,
1217
+ disabled buttons), not a third step.
1218
+
1219
+ Rules:
1220
+ - A raised element that becomes floating on interaction may go `sm` → `lg` on
1221
+ hover. Something already at `lg` does not deepen further — it changes its
1222
+ background instead.
1223
+ - `shadow-md`, `shadow-xl` and a bare `shadow` are build failures. The bare
1224
+ `shadow` utility is the sneaky one: it resolves to a Tailwind default that
1225
+ `app.css` never defines, so no token controls it.
1226
+ - Shadows carry elevation in **light mode only**. In dark mode the surface
1227
+ levels above do the work — see [Surface elevation levels](#surface-elevation-levels).
1228
+ - The light shadow is the ramp's own ink (`--shadow-color: 24 24 27`) at
1229
+ `--shadow-opacity: 0.14`. Dark keeps black at 0.3.
1230
+
1231
+ Enforced by `scripts/check-token-violations.js`.
1232
+
1233
+ ### Spacing rhythm
1234
+
1235
+ Layout spacing rides a **4px grid**. Half-steps (`gap-1.5`, `px-2.5`, `py-2.5`)
1236
+ set up a second rhythm competing with the first: a 6px gap beside an 8px gap is
1237
+ a 2px wobble nobody chose, and it accumulates down a dense form.
1238
+
1239
+ - `gap-*`, `space-x/y-*` and every padding utility must land on a whole step.
1240
+ - **Margins are exempt**, because they carry optical nudges. `mt-0.5` on an
1241
+ icon sitting beside a first line of text is an alignment correction, not
1242
+ rhythm — Alert, ListItem, ToasterToast and the radio control all rely on it.
1243
+ Keep those to 0.5 and keep them on margins so the distinction stays visible.
1244
+ - Icon sizes are their own scale (`size-3.5` = 14px, `size-4`, `size-5`), not
1245
+ spacing, and are unaffected.
1246
+
1247
+ Enforced by `scripts/check-token-violations.js`.
1248
+
1249
+ ### Type scale
1250
+
1251
+ `Heading` and `Text` sit on **one** ramp rather than each carrying its own, so a
1252
+ heading and the copy beneath it share line boxes instead of nearly matching.
1253
+
1254
+ | Step | `Heading` | `Text` |
1255
+ |---|---|---|
1256
+ | 36px | `level={1}` | — |
1257
+ | 30px | `level={2}` | — |
1258
+ | 24px | `level={3}` | — |
1259
+ | 20px | `level={4}` | — |
1260
+ | 18px | `level={5}` | `size="lg"` |
1261
+ | 16px | `level={6}` | `size="md"` (default) |
1262
+ | 14px | — | `size="sm"` |
1263
+ | 12px | — | `size="xs"` |
1264
+
1265
+ Headings use `--font-family-heading` (the sans family unless an app sets it),
1266
+ tighten tracking as they grow and are `semibold`/`bold`; `Text` takes a
1267
+ `weight` prop (`normal` · `medium` · `semibold` · `bold`) so body copy can carry
1268
+ emphasis without being promoted to a heading. `tone="label"` defaults to
1269
+ `medium`; every other tone defaults to `normal`.
1270
+
1271
+ ### Overriding a component's classes
1272
+
1273
+ `class` is the public prop on every component and is merged **last**, so a
1274
+ call-site utility wins. That promise needs `cn()` (`src/components/util/cn.ts`,
1275
+ tailwind-merge) to be true: `rounded-control` and `rounded-container` are
1276
+ equal-specificity utilities, so plain concatenation leaves the winner to
1277
+ whichever one Tailwind emits later in the stylesheet, not to the caller.
1278
+
1279
+ ```svelte
1280
+ <Card class="rounded-pill" /> <!-- rounded-container is dropped, not fought -->
1281
+ ```
1282
+
1283
+ Most semantic utilities work with stock tailwind-merge — `bg-card` vs
1284
+ `bg-surface-overlay`, `text-headline` vs `text-description` all resolve. The
1285
+ role-based radii are the exception and are declared explicitly in `cn.ts`,
1286
+ because `control`/`container`/`overlay`/`pill` are role names rather than scale
1287
+ values. **Add any future custom scale to that config**, or tailwind-merge keeps
1288
+ both classes and stylesheet order silently decides again.
1289
+
1290
+ Covered by `tests/class-merge.test.ts`.
1291
+
1292
+ ---
1293
+
1294
+ ## For maintainers
1295
+
1296
+ This part is about changing the library's own theme, not an app's.
1297
+
1298
+ ### One source file
1299
+
1300
+ All theming is in `src/app.css`:
1301
+
1302
+ - the `@theme` block holds every token and the light values, including the raw
1303
+ `--zabi-*` ramps, declared once;
1304
+ - one `.dark { … }` rule remaps roles for dark. No `--zabi-*` token and no hex
1305
+ literal belongs in it; `validate-theme.js` fails the build on either.
1306
+
1307
+ The files under `dist/` are generated from it by `scripts/build-css.js` and are
1308
+ overwritten on every build. Never edit them. Which file is which is in
1309
+ [docs/theme-imports.md](./docs/theme-imports.md). `examples/theme-extensions/`
1310
+ holds examples, not the theme.
1311
+
1312
+ Parts of `src/app.css` are generated too, so edit the source instead:
1313
+
1314
+ | To change | Edit | Then run |
1315
+ |---|---|---|
1316
+ | A chromatic ramp (`brand`, `citron`, `pine`, `iris`, `warning`, `error`) | `hue` / `peakChroma` in `tokens/chromatic-scales.js` | `npm run sync:tokens` |
1317
+ | The neutral ramp | `tokens/base-scale.js` | `npm run sync:tokens` |
1318
+ | The dark surface levels | `SURFACE_ALPHA` in `tokens/surface-ladder.js` | `npm run sync:tokens` |
1319
+ | A role, a font, a radius | `src/app.css` | nothing in development |
1320
+
1321
+ Before a release, `npm run build:css` regenerates the `dist/` files and
1322
+ `npm run build:lib` does that and verifies them (`scripts/verify-build.js`).
1323
+
1324
+ ### Dark mode selectors
1325
+
1326
+ `src/app.css` holds one `.dark { … }` rule. `scripts/build-css.js` publishes it,
1327
+ in every file that carries dark tokens, as:
1328
+
1329
+ ```css
1330
+ :root, [data-theme="light"] { color-scheme: light; } /* in the light files */
1331
+ [data-theme="light"] { color-scheme: light; }
1332
+ [data-theme="dark"] { color-scheme: dark; }
1333
+ [data-theme="auto"] { color-scheme: light dark; }
1334
+ .dark, [data-theme="dark"] { …; color-scheme: dark; }
1335
+ @media (prefers-color-scheme: dark) { [data-theme="auto"] { …; color-scheme: dark; } }
1336
+ ```
1337
+
1338
+ - The light rule is written by hand in `src/app.css`, above `.dark`, and has
1339
+ to stay above it: all of these have the specificity of one class, so the
1340
+ later one wins. `validate-theme.js` fails the build if a light file lacks
1341
+ the rule or has it after a dark one.
1342
+ - `color-scheme: dark` is the last declaration of the source `.dark` rule, so
1343
+ every dark selector carries it. The three attribute rules come first: with
1344
+ `class="dark" data-theme="light"` the class brings the dark tokens, and so
1345
+ the dark scheme with them.
1346
+ - All selectors have the specificity of `.dark`.
1347
+ - Keep it as one `.dark` rule in the source and do not write the others by
1348
+ hand; `scripts/dark-selectors.js` generates them.
1349
+ - The dev site and Storybook read `src/app.css` directly. `postcss.config.cjs`
1350
+ runs the same expansion on it, so `data-theme` works there as it does in the
1351
+ published files.
1352
+
1353
+ `validate-theme.js` fails the build if a dark-carrying file lacks any of the
1354
+ three, or if the `auto` copy differs from `.dark`.
1355
+
1356
+ ### Dark mode action colours
1357
+
1358
+ - **Primary.** No dark override: `--color-brand-*` mirrors, so `brand-800` in
1359
+ light is `brand-200` in dark. The label is `--color-on-brand`, which `.dark`
1360
+ points at `--zabi-on-brand-dark`.
1361
+ - **Secondary.** No dark override: `--color-base-*` mirrors the same way.
1362
+ - **Danger.** Resolves through `--color-error-*`, which mirrors like every
1363
+ other ramp. The `.dark` block restates the aliases only so that
1364
+ `zabi-components/theme-dark-only` remains a complete standalone import
1365
+ (`validate-theme.js` enforces that).
1366
+
1367
+ Hover and active always move toward the dark end in light mode, and the mirror
1368
+ flips that, so "more pressed" means "more contrast against the page" in both
1369
+ themes. Never point `-active` at a step on the far side of the ramp; that is
1370
+ how the pressed primary label once ended up at 1.66:1.
1371
+
1372
+ ### Building the generator
1373
+
1374
+ The source is `create-theme/`. It ships as plain ESM in `dist/create-theme/`
1375
+ and needs `culori` at runtime, so `culori` is a dependency.
1376
+ `scripts/build-create-theme.js` copies it and writes `theme-data.js`, the
1377
+ default theme and the pair list, from `src/app.css` and
1378
+ `scripts/contrast-pairs.js` on every build. `verify-build.js` fails if that
1379
+ file differs from its sources, and runs the bin from `dist/`. Tests:
1380
+ `tests/create-theme.test.js`, part of `npm run test:themes`.
1381
+
1382
+ ### Where the two-brand proof lives
1383
+
1384
+ - `tests/theme-output.test.js` (`npm run test:themes`) proves from the built
1385
+ CSS that one ramp override rebrands light and dark, and keeps the list of
1386
+ published token names.
1387
+ - The docs site runs under two brands: the default, and Amber, which
1388
+ `vite-plugin-brand-themes.js` builds with `createTheme` from the inputs in
1389
+ `src/lib/marketing/brand-inputs.ts` each time the site starts or builds. The
1390
+ switcher is in the top bar and on `/theming`; `?brand=amber` opens any page
1391
+ in it.
1392
+ - `playwright/theme-brands.spec.ts` (`npm run test:e2e`) loads component pages
1393
+ under both brands, in light and dark, at desktop width and at 375px, and
1394
+ checks from computed styles that the primary fill, its label, the focus ring,
1395
+ the card surface and the heading face change with the brand and that the
1396
+ label keeps 4.5:1.
1397
+ - `tests/theming-guide.test.ts` (`npm test`) fails if this guide, `THEME.md`,
1398
+ `docs/theme-imports.md` or the site's token tables name a token the theme
1399
+ does not publish.
1400
+
1401
+ ### Optional perceptual midpoint generation (OKLCH)
236
1402
 
237
1403
  Base midpoints are frozen hex by default (`fixed` mode).
238
1404
  For experimentation, you can generate midpoint steps in OKLCH interpolation and commit the resulting frozen values: