zabi-components 8.0.0 → 9.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (456) hide show
  1. package/CHANGELOG.md +2935 -0
  2. package/README.md +243 -10
  3. package/THEME.md +162 -332
  4. package/THEMING.md +1419 -259
  5. package/dist/atoms/ActionPanel.svelte +15 -5
  6. package/dist/atoms/Avatar.svelte +120 -0
  7. package/dist/atoms/Avatar.svelte.d.ts +46 -0
  8. package/dist/atoms/Badge.svelte +71 -13
  9. package/dist/atoms/Badge.svelte.d.ts +8 -2
  10. package/dist/atoms/Block.svelte +74 -0
  11. package/dist/atoms/Block.svelte.d.ts +29 -0
  12. package/dist/atoms/Button.svelte +217 -30
  13. package/dist/atoms/Button.svelte.d.ts +33 -4
  14. package/dist/atoms/Card.svelte +182 -26
  15. package/dist/atoms/Card.svelte.d.ts +55 -3
  16. package/dist/atoms/CardHeader.svelte +16 -4
  17. package/dist/atoms/Checkbox.svelte +81 -21
  18. package/dist/atoms/Checkbox.svelte.d.ts +31 -2
  19. package/dist/atoms/Chip.svelte +351 -0
  20. package/dist/atoms/Chip.svelte.d.ts +57 -0
  21. package/dist/atoms/CodeBlock.svelte +20 -2
  22. package/dist/atoms/CodeBlock.svelte.d.ts +4 -0
  23. package/dist/atoms/CodeField.svelte +385 -0
  24. package/dist/atoms/CodeField.svelte.d.ts +61 -0
  25. package/dist/atoms/ColorPicker.svelte +260 -49
  26. package/dist/atoms/ColorPicker.svelte.d.ts +3 -0
  27. package/dist/atoms/Container.svelte +16 -8
  28. package/dist/atoms/DateField.svelte +145 -0
  29. package/dist/atoms/DateField.svelte.d.ts +81 -0
  30. package/dist/atoms/FieldMessages.svelte +69 -0
  31. package/dist/atoms/FieldMessages.svelte.d.ts +23 -0
  32. package/dist/atoms/FloatingActionButton.svelte +176 -0
  33. package/dist/atoms/FloatingActionButton.svelte.d.ts +51 -0
  34. package/dist/atoms/Heading.svelte +63 -5
  35. package/dist/atoms/Heading.svelte.d.ts +14 -0
  36. package/dist/atoms/IconButton.svelte +294 -26
  37. package/dist/atoms/IconButton.svelte.d.ts +62 -4
  38. package/dist/atoms/Input.svelte +259 -53
  39. package/dist/atoms/Input.svelte.d.ts +33 -2
  40. package/dist/atoms/List.svelte +5 -1
  41. package/dist/atoms/ListItem.svelte +70 -13
  42. package/dist/atoms/ListItem.svelte.d.ts +11 -0
  43. package/dist/atoms/PickerField.svelte +239 -0
  44. package/dist/atoms/PickerField.svelte.d.ts +67 -0
  45. package/dist/atoms/Progress.svelte +99 -19
  46. package/dist/atoms/Progress.svelte.d.ts +15 -1
  47. package/dist/atoms/Radio.svelte +51 -3
  48. package/dist/atoms/Radio.svelte.d.ts +30 -2
  49. package/dist/atoms/Rating.svelte +620 -0
  50. package/dist/atoms/Rating.svelte.d.ts +77 -0
  51. package/dist/atoms/Select.svelte +438 -98
  52. package/dist/atoms/Select.svelte.d.ts +61 -3
  53. package/dist/atoms/SelectionControl.svelte +193 -23
  54. package/dist/atoms/SelectionControl.svelte.d.ts +29 -3
  55. package/dist/atoms/Skeleton.svelte +114 -18
  56. package/dist/atoms/Skeleton.svelte.d.ts +30 -3
  57. package/dist/atoms/SkeletonGroup.svelte +43 -0
  58. package/dist/atoms/SkeletonGroup.svelte.d.ts +20 -0
  59. package/dist/atoms/Slider.svelte +344 -0
  60. package/dist/atoms/Slider.svelte.d.ts +40 -0
  61. package/dist/atoms/Sparkline.svelte +109 -0
  62. package/dist/atoms/Sparkline.svelte.d.ts +27 -0
  63. package/dist/atoms/SpeechBubble.svelte +187 -0
  64. package/dist/atoms/SpeechBubble.svelte.d.ts +33 -0
  65. package/dist/atoms/Spinner.svelte +57 -0
  66. package/dist/atoms/Spinner.svelte.d.ts +19 -0
  67. package/dist/atoms/Stat.svelte +72 -0
  68. package/dist/atoms/Stat.svelte.d.ts +19 -0
  69. package/dist/atoms/Table.svelte +151 -6
  70. package/dist/atoms/Table.svelte.d.ts +15 -3
  71. package/dist/atoms/TemporalField.svelte +472 -0
  72. package/dist/atoms/TemporalField.svelte.d.ts +48 -0
  73. package/dist/atoms/Text.svelte +23 -11
  74. package/dist/atoms/Text.svelte.d.ts +6 -3
  75. package/dist/atoms/Textarea.svelte +150 -44
  76. package/dist/atoms/Textarea.svelte.d.ts +35 -2
  77. package/dist/atoms/ThemeToggle.svelte +223 -44
  78. package/dist/atoms/ThemeToggle.svelte.d.ts +26 -3
  79. package/dist/atoms/TimeColumn.svelte +150 -0
  80. package/dist/atoms/TimeColumn.svelte.d.ts +25 -0
  81. package/dist/atoms/TimeField.svelte +151 -0
  82. package/dist/atoms/TimeField.svelte.d.ts +85 -0
  83. package/dist/atoms/Toast.svelte +22 -8
  84. package/dist/atoms/Toast.svelte.d.ts +2 -0
  85. package/dist/atoms/Toggle.svelte +98 -14
  86. package/dist/atoms/Toggle.svelte.d.ts +26 -2
  87. package/dist/atoms/Tooltip.svelte +528 -24
  88. package/dist/atoms/Tooltip.svelte.d.ts +18 -0
  89. package/dist/atoms/ZabiStringsProvider.svelte +45 -0
  90. package/dist/atoms/ZabiStringsProvider.svelte.d.ts +27 -0
  91. package/dist/atoms/index.d.ts +38 -0
  92. package/dist/atoms/index.js +29 -0
  93. package/dist/atoms/selection-control.styles.d.ts +47 -5
  94. package/dist/atoms/selection-control.styles.js +52 -10
  95. package/dist/components/atoms/index.d.ts +38 -0
  96. package/dist/components/atoms/index.d.ts.map +1 -1
  97. package/dist/components/atoms/selection-control.styles.d.ts +47 -5
  98. package/dist/components/atoms/selection-control.styles.d.ts.map +1 -1
  99. package/dist/components/index.d.ts +104 -2
  100. package/dist/components/index.d.ts.map +1 -1
  101. package/dist/components/map.d.ts +11 -0
  102. package/dist/components/map.d.ts.map +1 -0
  103. package/dist/components/molecules/index.d.ts +51 -0
  104. package/dist/components/molecules/index.d.ts.map +1 -1
  105. package/dist/components/molecules/toast-store.d.ts +83 -7
  106. package/dist/components/molecules/toast-store.d.ts.map +1 -1
  107. package/dist/components/organisms/index.d.ts +10 -0
  108. package/dist/components/organisms/index.d.ts.map +1 -1
  109. package/dist/components/types/variants.d.ts +24 -1
  110. package/dist/components/types/variants.d.ts.map +1 -1
  111. package/dist/components/util/app-shell.d.ts +79 -0
  112. package/dist/components/util/app-shell.d.ts.map +1 -0
  113. package/dist/components/util/avatar.d.ts +39 -0
  114. package/dist/components/util/avatar.d.ts.map +1 -0
  115. package/dist/components/util/bottom-sheet.d.ts +30 -0
  116. package/dist/components/util/bottom-sheet.d.ts.map +1 -0
  117. package/dist/components/util/bottom-tab-bar.d.ts +41 -0
  118. package/dist/components/util/bottom-tab-bar.d.ts.map +1 -0
  119. package/dist/components/util/calendar.d.ts +56 -0
  120. package/dist/components/util/calendar.d.ts.map +1 -0
  121. package/dist/components/util/chart.d.ts +140 -0
  122. package/dist/components/util/chart.d.ts.map +1 -0
  123. package/dist/components/util/chip.d.ts +66 -0
  124. package/dist/components/util/chip.d.ts.map +1 -0
  125. package/dist/components/util/cn.d.ts.map +1 -1
  126. package/dist/components/util/code-field.d.ts +63 -0
  127. package/dist/components/util/code-field.d.ts.map +1 -0
  128. package/dist/components/util/collapsible.d.ts +64 -0
  129. package/dist/components/util/collapsible.d.ts.map +1 -0
  130. package/dist/components/util/confirm-dialog.d.ts +12 -0
  131. package/dist/components/util/confirm-dialog.d.ts.map +1 -0
  132. package/dist/components/util/date.d.ts +96 -0
  133. package/dist/components/util/date.d.ts.map +1 -0
  134. package/dist/components/util/display-voice.d.ts +10 -0
  135. package/dist/components/util/display-voice.d.ts.map +1 -0
  136. package/dist/components/util/drawer.d.ts +13 -0
  137. package/dist/components/util/drawer.d.ts.map +1 -0
  138. package/dist/components/util/dropdown.d.ts +47 -0
  139. package/dist/components/util/dropdown.d.ts.map +1 -0
  140. package/dist/components/util/field-control.d.ts +30 -0
  141. package/dist/components/util/field-control.d.ts.map +1 -0
  142. package/dist/components/util/field.d.ts +45 -0
  143. package/dist/components/util/field.d.ts.map +1 -0
  144. package/dist/components/util/fit-in-viewport.d.ts +201 -0
  145. package/dist/components/util/fit-in-viewport.d.ts.map +1 -0
  146. package/dist/components/util/focus-utils.d.ts +77 -2
  147. package/dist/components/util/focus-utils.d.ts.map +1 -1
  148. package/dist/components/util/hydration.d.ts +45 -0
  149. package/dist/components/util/hydration.d.ts.map +1 -0
  150. package/dist/components/util/keyboard-inset.d.ts +18 -0
  151. package/dist/components/util/keyboard-inset.d.ts.map +1 -0
  152. package/dist/components/util/map-view.d.ts +144 -0
  153. package/dist/components/util/map-view.d.ts.map +1 -0
  154. package/dist/components/util/media-grid.d.ts +51 -0
  155. package/dist/components/util/media-grid.d.ts.map +1 -0
  156. package/dist/components/util/media.d.ts +6 -0
  157. package/dist/components/util/media.d.ts.map +1 -0
  158. package/dist/components/util/overlay-chrome.d.ts +53 -0
  159. package/dist/components/util/overlay-chrome.d.ts.map +1 -0
  160. package/dist/components/util/overlay.d.ts +72 -0
  161. package/dist/components/util/overlay.d.ts.map +1 -0
  162. package/dist/components/util/photo.d.ts +234 -0
  163. package/dist/components/util/photo.d.ts.map +1 -0
  164. package/dist/components/util/portal.d.ts +23 -0
  165. package/dist/components/util/portal.d.ts.map +1 -0
  166. package/dist/components/util/progress.d.ts +19 -0
  167. package/dist/components/util/progress.d.ts.map +1 -0
  168. package/dist/components/util/pull-to-refresh.d.ts +27 -0
  169. package/dist/components/util/pull-to-refresh.d.ts.map +1 -0
  170. package/dist/components/util/radio-keys.d.ts +25 -0
  171. package/dist/components/util/radio-keys.d.ts.map +1 -0
  172. package/dist/components/util/rating.d.ts +23 -0
  173. package/dist/components/util/rating.d.ts.map +1 -0
  174. package/dist/components/util/ready-made-strings.d.ts +77 -0
  175. package/dist/components/util/ready-made-strings.d.ts.map +1 -0
  176. package/dist/components/util/scroll-edge.d.ts +33 -0
  177. package/dist/components/util/scroll-edge.d.ts.map +1 -0
  178. package/dist/components/util/scroll-padding.d.ts +20 -0
  179. package/dist/components/util/scroll-padding.d.ts.map +1 -0
  180. package/dist/components/util/segmented-control.d.ts +16 -0
  181. package/dist/components/util/segmented-control.d.ts.map +1 -0
  182. package/dist/components/util/select.d.ts +41 -0
  183. package/dist/components/util/select.d.ts.map +1 -0
  184. package/dist/components/util/sheet-drag.d.ts +84 -0
  185. package/dist/components/util/sheet-drag.d.ts.map +1 -0
  186. package/dist/components/util/sidebar.d.ts +63 -0
  187. package/dist/components/util/sidebar.d.ts.map +1 -0
  188. package/dist/components/util/skeleton.d.ts +15 -0
  189. package/dist/components/util/skeleton.d.ts.map +1 -0
  190. package/dist/components/util/sliding-indicator.d.ts +25 -0
  191. package/dist/components/util/sliding-indicator.d.ts.map +1 -0
  192. package/dist/components/util/sortable-list.d.ts +65 -0
  193. package/dist/components/util/sortable-list.d.ts.map +1 -0
  194. package/dist/components/util/stepper.d.ts +40 -0
  195. package/dist/components/util/stepper.d.ts.map +1 -0
  196. package/dist/components/util/swipeable-list-item.d.ts +36 -0
  197. package/dist/components/util/swipeable-list-item.d.ts.map +1 -0
  198. package/dist/components/util/temporal-field.d.ts +89 -0
  199. package/dist/components/util/temporal-field.d.ts.map +1 -0
  200. package/dist/components/util/theme-mode.d.ts +80 -0
  201. package/dist/components/util/theme-mode.d.ts.map +1 -0
  202. package/dist/components/util/toaster.d.ts +133 -0
  203. package/dist/components/util/toaster.d.ts.map +1 -0
  204. package/dist/components/util/tone-surface.d.ts +34 -0
  205. package/dist/components/util/tone-surface.d.ts.map +1 -0
  206. package/dist/components/util/tooltip.d.ts +10 -0
  207. package/dist/components/util/tooltip.d.ts.map +1 -0
  208. package/dist/components/util/top-navbar.d.ts +35 -0
  209. package/dist/components/util/top-navbar.d.ts.map +1 -0
  210. package/dist/components/util/touch-target.d.ts +31 -0
  211. package/dist/components/util/touch-target.d.ts.map +1 -0
  212. package/dist/components/util/zabi-strings.d.ts +142 -0
  213. package/dist/components/util/zabi-strings.d.ts.map +1 -0
  214. package/dist/create-theme/cli.js +173 -0
  215. package/dist/create-theme/index.d.ts +190 -0
  216. package/dist/create-theme/index.js +1034 -0
  217. package/dist/create-theme/lib/ramp-math.js +128 -0
  218. package/dist/create-theme/lib/resolve.js +238 -0
  219. package/dist/create-theme/theme-data.js +2644 -0
  220. package/dist/index.d.ts +104 -2
  221. package/dist/index.js +75 -2
  222. package/dist/map.d.ts +10 -0
  223. package/dist/map.js +9 -0
  224. package/dist/molecules/Alert.svelte +63 -16
  225. package/dist/molecules/Alert.svelte.d.ts +14 -1
  226. package/dist/molecules/AppBar.svelte +674 -0
  227. package/dist/molecules/AppBar.svelte.d.ts +131 -0
  228. package/dist/molecules/AvatarGroup.svelte +116 -0
  229. package/dist/molecules/AvatarGroup.svelte.d.ts +40 -0
  230. package/dist/molecules/BarChart.svelte +381 -0
  231. package/dist/molecules/BarChart.svelte.d.ts +45 -0
  232. package/dist/molecules/BottomSheet.svelte +608 -0
  233. package/dist/molecules/BottomSheet.svelte.d.ts +109 -0
  234. package/dist/molecules/BottomTabBar.svelte +403 -0
  235. package/dist/molecules/BottomTabBar.svelte.d.ts +98 -0
  236. package/dist/molecules/Calendar.svelte +597 -0
  237. package/dist/molecules/Calendar.svelte.d.ts +72 -0
  238. package/dist/molecules/ChipGroup.svelte +325 -0
  239. package/dist/molecules/ChipGroup.svelte.d.ts +41 -0
  240. package/dist/molecules/Collapsible.svelte +347 -0
  241. package/dist/molecules/Collapsible.svelte.d.ts +61 -0
  242. package/dist/molecules/CollapsibleGroup.svelte +136 -0
  243. package/dist/molecules/CollapsibleGroup.svelte.d.ts +14 -0
  244. package/dist/molecules/ComponentDemo.svelte +16 -4
  245. package/dist/molecules/ComponentDemo.svelte.d.ts +3 -0
  246. package/dist/molecules/ConfirmDialog.svelte +257 -0
  247. package/dist/molecules/ConfirmDialog.svelte.d.ts +58 -0
  248. package/dist/molecules/ContactForm.svelte +34 -17
  249. package/dist/molecules/ContactForm.svelte.d.ts +8 -0
  250. package/dist/molecules/Drawer.svelte +415 -0
  251. package/dist/molecules/Drawer.svelte.d.ts +55 -0
  252. package/dist/molecules/Dropdown.svelte +517 -48
  253. package/dist/molecules/Dropdown.svelte.d.ts +47 -6
  254. package/dist/molecules/DropdownItem.svelte +122 -0
  255. package/dist/molecules/DropdownItem.svelte.d.ts +28 -0
  256. package/dist/molecules/EmptyState.svelte +55 -10
  257. package/dist/molecules/EmptyState.svelte.d.ts +8 -0
  258. package/dist/molecules/FormField.svelte +9 -1
  259. package/dist/molecules/FormField.svelte.d.ts +2 -0
  260. package/dist/molecules/Header.svelte +1 -1
  261. package/dist/molecules/ImageUpload.svelte +492 -69
  262. package/dist/molecules/ImageUpload.svelte.d.ts +82 -2
  263. package/dist/molecules/LineChart.svelte +459 -0
  264. package/dist/molecules/LineChart.svelte.d.ts +53 -0
  265. package/dist/molecules/MapView.svelte +565 -0
  266. package/dist/molecules/MapView.svelte.d.ts +88 -0
  267. package/dist/molecules/MediaGrid.svelte +527 -0
  268. package/dist/molecules/MediaGrid.svelte.d.ts +77 -0
  269. package/dist/molecules/Modal.svelte +293 -72
  270. package/dist/molecules/Modal.svelte.d.ts +51 -2
  271. package/dist/molecules/NavigationMenuContent.svelte +58 -1
  272. package/dist/molecules/NavigationMenuItem.svelte +0 -1
  273. package/dist/molecules/NavigationMenuLink.svelte +1 -1
  274. package/dist/molecules/NavigationMenuList.svelte +10 -3
  275. package/dist/molecules/NavigationMenuTrigger.svelte +2 -2
  276. package/dist/molecules/Page.svelte +32 -2
  277. package/dist/molecules/Page.svelte.d.ts +11 -0
  278. package/dist/molecules/PhotoGrid.svelte +523 -0
  279. package/dist/molecules/PhotoGrid.svelte.d.ts +73 -0
  280. package/dist/molecules/PhotoViewer.svelte +1213 -0
  281. package/dist/molecules/PhotoViewer.svelte.d.ts +74 -0
  282. package/dist/molecules/PieChart.svelte +421 -0
  283. package/dist/molecules/PieChart.svelte.d.ts +51 -0
  284. package/dist/molecules/PropsTable.svelte +22 -10
  285. package/dist/molecules/PropsTable.svelte.d.ts +3 -0
  286. package/dist/molecules/PullToRefresh.svelte +263 -0
  287. package/dist/molecules/PullToRefresh.svelte.d.ts +52 -0
  288. package/dist/molecules/RadioGroup.svelte +72 -18
  289. package/dist/molecules/RadioGroup.svelte.d.ts +19 -3
  290. package/dist/molecules/Section.svelte +1 -1
  291. package/dist/molecules/SegmentedControl.svelte +456 -0
  292. package/dist/molecules/SegmentedControl.svelte.d.ts +40 -0
  293. package/dist/molecules/SidebarBrandHeader.svelte +11 -1
  294. package/dist/molecules/SidebarBrandHeader.svelte.d.ts +3 -0
  295. package/dist/molecules/SidebarFooter.svelte +30 -7
  296. package/dist/molecules/SidebarFooter.svelte.d.ts +3 -0
  297. package/dist/molecules/SidebarNavSection.svelte +1 -1
  298. package/dist/molecules/SlideUp.svelte +220 -61
  299. package/dist/molecules/SlideUp.svelte.d.ts +26 -0
  300. package/dist/molecules/SortableList.svelte +633 -0
  301. package/dist/molecules/SortableList.svelte.d.ts +69 -0
  302. package/dist/molecules/Stepper.svelte +562 -0
  303. package/dist/molecules/Stepper.svelte.d.ts +39 -0
  304. package/dist/molecules/StickyActionBar.svelte +250 -0
  305. package/dist/molecules/StickyActionBar.svelte.d.ts +78 -0
  306. package/dist/molecules/SwipeableListItem.svelte +334 -0
  307. package/dist/molecules/SwipeableListItem.svelte.d.ts +54 -0
  308. package/dist/molecules/Tabs.svelte +310 -9
  309. package/dist/molecules/Tabs.svelte.d.ts +7 -0
  310. package/dist/molecules/Toaster.svelte +514 -5
  311. package/dist/molecules/Toaster.svelte.d.ts +153 -2
  312. package/dist/molecules/ToasterToast.svelte +495 -77
  313. package/dist/molecules/ToasterToast.svelte.d.ts +15 -0
  314. package/dist/molecules/UnsavedChangesBar.svelte +223 -0
  315. package/dist/molecules/UnsavedChangesBar.svelte.d.ts +43 -0
  316. package/dist/molecules/index.d.ts +51 -0
  317. package/dist/molecules/index.js +31 -0
  318. package/dist/molecules/toast-store.d.ts +83 -7
  319. package/dist/molecules/toast-store.js +25 -1
  320. package/dist/organisms/AppNavigation.svelte +440 -0
  321. package/dist/organisms/AppNavigation.svelte.d.ts +72 -0
  322. package/dist/organisms/AppShell.svelte +696 -0
  323. package/dist/organisms/AppShell.svelte.d.ts +157 -0
  324. package/dist/organisms/AppShellHeaderPart.svelte +163 -0
  325. package/dist/organisms/AppShellHeaderPart.svelte.d.ts +57 -0
  326. package/dist/organisms/SidebarAccountPanel.svelte +107 -12
  327. package/dist/organisms/SidebarAccountPanel.svelte.d.ts +19 -0
  328. package/dist/organisms/SidebarNavigation.svelte +111 -43
  329. package/dist/organisms/SidebarNavigation.svelte.d.ts +29 -1
  330. package/dist/organisms/SidebarPanel.svelte +34 -17
  331. package/dist/organisms/SidebarPanel.svelte.d.ts +1 -0
  332. package/dist/organisms/SidebarShell.svelte +164 -14
  333. package/dist/organisms/SidebarShell.svelte.d.ts +56 -2
  334. package/dist/organisms/TopNavbar.svelte +353 -39
  335. package/dist/organisms/TopNavbar.svelte.d.ts +47 -0
  336. package/dist/organisms/TopNavbarSheetContent.svelte +145 -0
  337. package/dist/organisms/TopNavbarSheetContent.svelte.d.ts +24 -0
  338. package/dist/organisms/index.d.ts +10 -0
  339. package/dist/organisms/index.js +7 -0
  340. package/dist/types/components.d.ts +21 -1
  341. package/dist/types/components.d.ts.map +1 -1
  342. package/dist/types/index.d.ts +1093 -26
  343. package/dist/types/index.d.ts.map +1 -1
  344. package/dist/types/variants.ts +28 -1
  345. package/dist/util/app-shell.d.ts +78 -0
  346. package/dist/util/app-shell.js +97 -0
  347. package/dist/util/avatar.d.ts +38 -0
  348. package/dist/util/avatar.js +52 -0
  349. package/dist/util/bottom-sheet.d.ts +29 -0
  350. package/dist/util/bottom-sheet.js +43 -0
  351. package/dist/util/bottom-tab-bar.d.ts +40 -0
  352. package/dist/util/bottom-tab-bar.js +54 -0
  353. package/dist/util/calendar.d.ts +55 -0
  354. package/dist/util/calendar.js +74 -0
  355. package/dist/util/chart.d.ts +139 -0
  356. package/dist/util/chart.js +305 -0
  357. package/dist/util/chip.d.ts +65 -0
  358. package/dist/util/chip.js +58 -0
  359. package/dist/util/cn.js +28 -2
  360. package/dist/util/code-field.d.ts +62 -0
  361. package/dist/util/code-field.js +112 -0
  362. package/dist/util/collapsible.d.ts +63 -0
  363. package/dist/util/collapsible.js +23 -0
  364. package/dist/util/confirm-dialog.d.ts +11 -0
  365. package/dist/util/confirm-dialog.js +2 -0
  366. package/dist/util/date.d.ts +95 -0
  367. package/dist/util/date.js +237 -0
  368. package/dist/util/display-voice.d.ts +9 -0
  369. package/dist/util/display-voice.js +9 -0
  370. package/dist/util/drawer.d.ts +12 -0
  371. package/dist/util/drawer.js +7 -0
  372. package/dist/util/dropdown.d.ts +46 -0
  373. package/dist/util/dropdown.js +21 -0
  374. package/dist/util/field-control.d.ts +29 -0
  375. package/dist/util/field-control.js +32 -0
  376. package/dist/util/field.d.ts +44 -0
  377. package/dist/util/field.js +38 -0
  378. package/dist/util/fit-in-viewport.d.ts +200 -0
  379. package/dist/util/fit-in-viewport.js +399 -0
  380. package/dist/util/focus-utils.d.ts +77 -2
  381. package/dist/util/focus-utils.js +186 -9
  382. package/dist/util/hydration.d.ts +44 -0
  383. package/dist/util/hydration.js +58 -0
  384. package/dist/util/keyboard-inset.d.ts +17 -0
  385. package/dist/util/keyboard-inset.js +53 -0
  386. package/dist/util/map-view.d.ts +143 -0
  387. package/dist/util/map-view.js +121 -0
  388. package/dist/util/media-grid.d.ts +50 -0
  389. package/dist/util/media-grid.js +81 -0
  390. package/dist/util/media.d.ts +5 -0
  391. package/dist/util/media.js +10 -0
  392. package/dist/util/overlay-chrome.d.ts +52 -0
  393. package/dist/util/overlay-chrome.js +56 -0
  394. package/dist/util/overlay.d.ts +71 -0
  395. package/dist/util/overlay.js +240 -0
  396. package/dist/util/photo.d.ts +233 -0
  397. package/dist/util/photo.js +209 -0
  398. package/dist/util/portal.d.ts +22 -0
  399. package/dist/util/portal.js +50 -0
  400. package/dist/util/progress.d.ts +18 -0
  401. package/dist/util/progress.js +17 -0
  402. package/dist/util/pull-to-refresh.d.ts +26 -0
  403. package/dist/util/pull-to-refresh.js +30 -0
  404. package/dist/util/radio-keys.d.ts +24 -0
  405. package/dist/util/radio-keys.js +49 -0
  406. package/dist/util/rating.d.ts +22 -0
  407. package/dist/util/rating.js +26 -0
  408. package/dist/util/ready-made-strings.d.ts +76 -0
  409. package/dist/util/ready-made-strings.js +67 -0
  410. package/dist/util/scroll-edge.d.ts +32 -0
  411. package/dist/util/scroll-edge.js +87 -0
  412. package/dist/util/scroll-padding.d.ts +19 -0
  413. package/dist/util/scroll-padding.js +44 -0
  414. package/dist/util/segmented-control.d.ts +15 -0
  415. package/dist/util/segmented-control.js +1 -0
  416. package/dist/util/select.d.ts +40 -0
  417. package/dist/util/select.js +63 -0
  418. package/dist/util/sheet-drag.d.ts +83 -0
  419. package/dist/util/sheet-drag.js +311 -0
  420. package/dist/util/sidebar.d.ts +62 -0
  421. package/dist/util/sidebar.js +38 -0
  422. package/dist/util/skeleton.d.ts +14 -0
  423. package/dist/util/skeleton.js +22 -0
  424. package/dist/util/sliding-indicator.d.ts +24 -0
  425. package/dist/util/sliding-indicator.js +44 -0
  426. package/dist/util/sortable-list.d.ts +64 -0
  427. package/dist/util/sortable-list.js +53 -0
  428. package/dist/util/stepper.d.ts +39 -0
  429. package/dist/util/stepper.js +38 -0
  430. package/dist/util/swipeable-list-item.d.ts +35 -0
  431. package/dist/util/swipeable-list-item.js +32 -0
  432. package/dist/util/temporal-field.d.ts +88 -0
  433. package/dist/util/temporal-field.js +136 -0
  434. package/dist/util/theme-mode.d.ts +79 -0
  435. package/dist/util/theme-mode.js +121 -0
  436. package/dist/util/toaster.d.ts +132 -0
  437. package/dist/util/toaster.js +130 -0
  438. package/dist/util/tone-surface.d.ts +33 -0
  439. package/dist/util/tone-surface.js +35 -0
  440. package/dist/util/tooltip.d.ts +9 -0
  441. package/dist/util/tooltip.js +18 -0
  442. package/dist/util/top-navbar.d.ts +34 -0
  443. package/dist/util/top-navbar.js +28 -0
  444. package/dist/util/touch-target.d.ts +30 -0
  445. package/dist/util/touch-target.js +30 -0
  446. package/dist/util/zabi-strings.d.ts +141 -0
  447. package/dist/util/zabi-strings.js +105 -0
  448. package/dist/zabi-components-colors.css +11 -2
  449. package/dist/zabi-components-theme-dark-only.css +870 -258
  450. package/dist/zabi-components-theme-dark.css +870 -258
  451. package/dist/zabi-components-theme-only.css +2419 -368
  452. package/dist/zabi-components-theme.css +2419 -368
  453. package/dist/zabi-components.css +6032 -711
  454. package/docs/lucide-icons.md +40 -17
  455. package/docs/theme-imports.md +81 -11
  456. package/package.json +45 -8
package/THEMING.md CHANGED
@@ -1,186 +1,1042 @@
1
- # Theming Guide
1
+ # Theming guide
2
+
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.
6
+
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)).
12
+
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).
15
+
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)
29
+
30
+ ## The first thing to know: your colour is not pinned to a step
31
+
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.
37
+
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.
43
+
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:
46
+
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` |
51
+
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.
55
+
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:
2
58
 
3
- ## 🎨 Single Source of Truth
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
+ ```
4
62
 
5
- **All theming is done in one file: `src/app.css`**
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.
6
67
 
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
68
+ ## Quick start
14
69
 
15
- ## 📍 Where to Make Changes
70
+ 1. Generate the brand file. Commit it; the output has no timestamp, and the
71
+ same input gives the same bytes.
16
72
 
17
- ### Main Theme (Light Mode)
18
- Edit the `@theme` block in `src/app.css`.
73
+ ```bash
74
+ npx zabi-theme --brand "#C17B00" --neutral "#78716c" --out src/lib/brand.generated.css
75
+ ```
19
76
 
20
- ### Dark Mode Theme
21
- Edit the `.dark` block in `src/app.css`.
77
+ 2. Import it after the theme files.
22
78
 
23
- ## ⚠️ Important: Don't Edit These Files
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-* */
24
84
 
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)
85
+ :root {
86
+ --font-family-heading: "Your Display Font", var(--font-family-sans);
87
+ }
88
+ ```
33
89
 
34
- **These files are overwritten every time you run `npm run build:css`**
90
+ ```css
91
+ /* src/app.css */
92
+ @import "tailwindcss";
93
+ @import "./lib/theme.css";
94
+ ```
35
95
 
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
96
+ 3. Choose how dark mode is selected, on `<html>`.
39
97
 
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`)
98
+ ```html
99
+ <html lang="en" data-theme="auto">
100
+ ```
43
101
 
44
- ## 📦 What Are the Dist Files For?
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.
45
104
 
46
- The `dist/` files are **for consumers** of your library who want to import the theme separately.
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. |
47
114
 
48
- **Full import matrix, export paths, and examples:** [docs/theme-imports.md](./docs/theme-imports.md)
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.
49
123
 
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`
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.
55
127
 
56
- ### For Library Consumers
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.
57
131
 
58
- Use **package exports** (preferred):
132
+ ## Tokens an app may set
59
133
 
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` |
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.
68
137
 
69
- Examples:
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-on-brand)` (white): the dark primary is the same step as light. Set a dark label here if the dark primary is a pale step. |
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
+ the primary and danger fills stay on step 600 in dark too (the physical
155
+ `--zabi-*` steps, with a white label), so the main action keeps its colour. The
156
+ other roles mirror the ramp in dark (the ring, links and tints are the light
157
+ steps there). `createTheme({ darkPrimary: "mirror" })` restores the 8.1 pale
158
+ dark fills.
159
+
160
+ A ramp you write by hand should keep the library's lightness per step, or the
161
+ contrast the roles were designed with is gone. Run it through
162
+ `zabi-theme --set` to have it checked (see [`--set`](#--set-and-overrides)).
163
+
164
+ ### Type
165
+
166
+ | Token | Default | What follows |
167
+ |---|---|---|
168
+ | `--font-family-sans` | the platform UI face: `ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji"` | Body text and every component |
169
+ | `--font-family-heading` | `var(--font-family-sans)` | `h1` to `h6` and the Heading component |
170
+ | `--font-family-display` | `var(--font-family-heading)` | `Heading variant="display"` and `font-display`: a second face, so it is a token and not a CSS rule |
171
+ | `--font-family-mono` | `ui-monospace, "SF Mono", SFMono-Regular, Menlo, Consolas, "Liberation Mono", monospace` | CodeBlock |
172
+ | `--font-weight-regular` | `400` | `font-normal` |
173
+ | `--font-weight-medium` | `500` | `font-medium` |
174
+ | `--font-weight-semibold` | `600` | `font-semibold` |
175
+ | `--font-weight-bold` | `700` | `font-bold` |
176
+
177
+ The default is the platform's own face (San Francisco on Apple devices, Segoe
178
+ UI on Windows, Roboto on Android), so a default install downloads no font. It
179
+ differs by platform, and text widths differ from 8.1's Nunito Sans. Loading any
180
+ other font files is up to the app. To get the 8.1 face back, load Nunito Sans
181
+ yourself and set
182
+ `--font-family-sans: 'Nunito Sans', ui-sans-serif, system-ui, sans-serif`
183
+ on `:root` after the imports. The heading face is applied in the
184
+ base layer, so a `font-*` utility on a heading still wins.
185
+
186
+ Tailwind's own font utilities follow these tokens: `font-sans` is
187
+ `--font-family-sans`, `font-mono` is `--font-family-mono`, and `font-heading`
188
+ is `--font-family-heading`, `font-display` is `--font-family-display`. The theme maps Tailwind's `--font-sans` and
189
+ `--font-mono` (and a `--font-heading` and `--font-display` of its own) onto them. Before 8.1
190
+ `font-sans` was Tailwind's system stack, so `class="font-sans"` on a heading
191
+ dropped it out of the brand face. Set the `--font-family-*` tokens, not the
192
+ short names: the short ones follow.
193
+
194
+ ### Shape, depth and stacking
195
+
196
+ | Token | Default | What follows |
197
+ |---|---|---|
198
+ | `--radius-control` | `0.5rem` | Buttons, inputs, selects, toggles |
199
+ | `--radius-container` | `0.75rem` | Cards, alerts, panels, list items, code blocks |
200
+ | `--radius-overlay` | `1rem` | Modals, sheets, menus, popovers, toasts |
201
+ | `--radius-pill` | `9999px` | Badges, avatars, status dots |
202
+ | `--shadow-color` | `24 24 27` (dark: `0 0 0`) | The colour of `shadow-sm` and `shadow-lg`, as three space-separated RGB channels |
203
+ | `--shadow-opacity` | `0.10` (dark: `0.24`) | The strength of the wide, soft layer of each shadow |
204
+ | `--shadow-opacity-contact` | `0.06` (dark: `0.3`) | The strength of the tight contact layer |
205
+ | `--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. Each has a class of the same name (`z-sticky`, `z-fixed`, `z-modal`, …) that sets `z-index` from it |
206
+
207
+ The shadow tokens are the only ones here with a separate dark value. A
208
+ `:root` override after the imports replaces both; to keep two values, declare
209
+ the dark one under the dark selectors (see
210
+ [One role in dark only](#one-role-in-dark-only)).
211
+
212
+ ### Motion
213
+
214
+ | Token | Default | What follows |
215
+ |---|---|---|
216
+ | `--duration-fast`, `--duration-base`, `--duration-moderate`, `--duration-slow` | 100, 150, 200, 300ms | `--duration-base` is Tailwind's default transition duration; use the others as `duration-(--duration-moderate)` |
217
+ | `--ease-standard` | `cubic-bezier(0.2, 0, 0, 1)` | Tailwind's default transition curve, and the `ease-standard` utility |
218
+ | `--ease-out` | `cubic-bezier(0, 0, 0.2, 1)` | The `ease-out` utility (Tailwind's own name and value) |
219
+ | `--ease-spring` | a `linear()` spring, one small overshoot | The `ease-spring` utility; browsers without `linear()` get a cubic-bezier with a similar overshoot |
220
+
221
+ Under `prefers-reduced-motion: reduce` the durations are `0s` and
222
+ `--ease-spring` is the standard curve. This re-points the tokens with
223
+ `:root:root`, so it also wins over an app's own value: to keep motion for
224
+ reduced-motion users, set the token inside your own
225
+ `@media (prefers-reduced-motion: reduce) { :root:root:root { … } }`. To get
226
+ Tailwind's old curve back, set `--default-transition-timing-function`.
227
+
228
+ ### Not on the list
229
+
230
+ These tokens exist, and their names are kept, but setting them does not restyle
231
+ the components:
232
+
233
+ - `--spacing-xs` … `--spacing-2xl` feed the `p-md`, `gap-lg` utilities for your
234
+ own markup. Components use Tailwind's numeric scale.
235
+ - `--control-height-sm`, `-md`, `-lg` record the 32, 40 and 48px control
236
+ heights. Components set those heights with `h-8`, `h-10` and `h-12`.
237
+ - `--radius-sm`, `-md`, `-lg`, `-xl` are legacy aliases. No component uses them.
238
+ - `--shadow-sm`, `--shadow-md`, `--shadow-lg`, `--shadow-xl` are built from the
239
+ two shadow tokens above. Set those instead.
240
+
241
+ ## Roles: read them, do not set them
242
+
243
+ A role says what a colour is for. Components paint with roles, and your own CSS
244
+ should too: `var(--color-action-primary)` or the `bg-action-primary` class, not
245
+ `var(--zabi-brand-600)`. A role is already the right step in each mode.
246
+
247
+ | Roles | For |
248
+ |---|---|
249
+ | `--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. |
250
+ | `--color-on-brand`, `--color-on-accent` | Text on a primary fill and on a solid accent fill |
251
+ | `--color-accent`, `-hover`, `-active`, `-subtle`, `-border`, `-text` | The second brand colour |
252
+ | `--color-focus-ring`, `--color-focus-ring-offset`, `--color-focus` | The focus ring and the gap around it |
253
+ | `--color-focus-ring-muted` | The neutral ring of ghost and link controls (`.focus-ring--muted`); 3:1 or more on every surface level |
254
+ | `--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 |
255
+ | `--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 |
256
+ | `--color-link`, `--color-link-hover` | Links |
257
+ | `--color-headline`, `--color-body`, `--color-description`, `--color-caption`, `--color-label` | Text |
258
+ | `--color-surface-base`, `-raised`, `-elevated`, `-overlay`, `-inset` | The page, cards, nested cards, floating panels, wells |
259
+ | `--color-surface-hover`, `--color-surface-active`, `--color-surface-overlay-hover` | Hover and pressed fills |
260
+ | `--color-border`, `-weak`, `-medium`, `-strong`, `--color-border-overlay` | Borders and dividers |
261
+ | `--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 |
262
+ | `--color-<family>`, `-weak`, `-medium`, `-strong`, `-subtle`, `-border`, `-text` | `success`, `warning`, `error`, `info`, `energetic`, `neutral` (see [Semantic families](#semantic-families)) |
263
+
264
+ Aliases kept from earlier versions resolve to the same values: `--color-background`,
265
+ `--color-card`, `--color-surface-1` … `-4`, `--color-primary*`,
266
+ `--color-secondary*`, `--color-page`.
267
+
268
+ **Why not set them.** A role set on `:root` after the imports is one value for
269
+ both modes: it replaces the dark value as well, and nothing checks its
270
+ contrast. If one role has to move, do it through the generator, which checks
271
+ it: see [`--set`](#--set-and-overrides).
272
+
273
+ **Do not set `--color-brand-*`, `--color-accent-<step>` or `--color-base-*`
274
+ either.** Those are the mirrored steps: `--color-brand-600` is
275
+ `--zabi-brand-600` in light and `--zabi-brand-400` in dark. Set on `:root`
276
+ after the imports, one of them becomes the same colour in both modes and stops
277
+ mirroring. Override the `--zabi-*` ramp instead.
278
+
279
+ ### One role in dark only
280
+
281
+ Declare it under all three dark selectors, after the imports:
70
282
 
71
283
  ```css
72
- @import "zabi-components/theme-only";
73
- @import "zabi-components/theme-dark-only";
284
+ .dark,
285
+ [data-theme="dark"] {
286
+ --color-link: var(--zabi-brand-200);
287
+ }
288
+
289
+ @media (prefers-color-scheme: dark) {
290
+ [data-theme="auto"] {
291
+ --color-link: var(--zabi-brand-200);
292
+ }
293
+ }
74
294
  ```
75
295
 
76
- ```css
77
- @import "zabi-components/colors";
296
+ ## Light, dark and auto
297
+
298
+ The mode is chosen on the `<html>` element, by an attribute or a class:
299
+
300
+ | On `<html>` | Result |
301
+ |---|---|
302
+ | nothing | light, also on a dark system |
303
+ | `data-theme="light"` | light |
304
+ | `data-theme="dark"` | dark |
305
+ | `data-theme="auto"` | follows the system (`prefers-color-scheme`) |
306
+ | `class="dark"` | dark, the same as `data-theme="dark"` |
307
+
308
+ - **Following the system is opt-in.** A page with no attribute and no class
309
+ stays light on a dark system, as it always has. `data-theme="auto"` needs no
310
+ script.
311
+ - **`<html>` only.** A `.dark` or `data-theme` on an element further down is
312
+ not supported. It re-themes the roles the dark file restates (the card
313
+ surface, the label on a primary button) and leaves the rest (text, borders,
314
+ the primary fill) light, so the subtree comes out half dark.
315
+ - `color-scheme` comes with the theme, so what the browser draws itself (date
316
+ and time pickers, scrollbars, autofill) matches: dark under `.dark`,
317
+ `data-theme="dark"` and `data-theme="auto"` on a dark system, and light
318
+ everywhere else, a page with no class and no attribute included. That
319
+ overrides a `<meta name="color-scheme">` tag, so the page needs neither the
320
+ tag nor an inline `color-scheme` on `<html>`; `ThemeToggle` writes none and
321
+ removes a stale `light` or `dark` one. Before 8.1 only the attributes set
322
+ it.
323
+ - The dark file must be imported together with a light one. It only remaps
324
+ roles onto ramps the light theme declares.
325
+ - Tailwind's own `dark:` variant follows the system unless you redefine it, and
326
+ does not read `data-theme`.
327
+
328
+ ### A theme control: system, light, dark
329
+
330
+ `ThemeToggle` reads the mode from `<html>` and follows whatever else changes
331
+ it.
332
+
333
+ | | Press | Writes |
334
+ |---|---|---|
335
+ | `<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 |
336
+ | `<ThemeToggle modes="three" />` | system → light → dark → system | always `data-theme` (`auto`, `light` or `dark`); it adds the attribute and removes a leftover `dark` class |
337
+
338
+ With two modes, a press on a page that is on `auto` sets `light` or `dark`, and
339
+ there is no way back to `auto` from that button. An app whose default is
340
+ `data-theme="auto"` wants three modes.
341
+
342
+ The three-mode button shows a monitor, a sun or a moon, and its accessible name
343
+ states the mode and what a press does: "Theme: system. Switch to light". The
344
+ words are replaceable:
345
+
346
+ ```svelte
347
+ <ThemeToggle
348
+ modes="three"
349
+ labels={{
350
+ auto: "följ telefonen",
351
+ light: "ljust",
352
+ dark: "mörkt",
353
+ describe: (current, next) => `Tema: ${current}. Byt till ${next}`,
354
+ }}
355
+ />
78
356
  ```
79
357
 
80
- ```css
81
- @import "zabi-components/css";
358
+ **Remembering the choice.** The choice is kept in `localStorage` under
359
+ `storageKey` (default `"theme"`) and applied again when the button mounts, with
360
+ two modes and with three, on a `data-theme` page and on a class page: the
361
+ two-way button on a `data-theme="auto"` page that finds `"dark"` stored sets
362
+ `data-theme="dark"`.
363
+ `storageKey={null}` keeps nothing. To store it in your own backend, bind the
364
+ mode or listen for it:
365
+
366
+ ```svelte
367
+ <ThemeToggle modes="three" storageKey={null} bind:mode onmodechange={(mode) => saveToProfile(mode)} />
368
+ ```
369
+
370
+ `mode` is `"auto" | "light" | "dark"`. It follows the page, and assigning it
371
+ switches the page.
372
+
373
+ **No flash on load.** A component mounts after the first paint, so a visitor
374
+ who chose dark would see light first. `themeInitScript(storageKey?)` returns a
375
+ small script that applies the stored mode from `<head>`. It is a plain string
376
+ with no dependencies. In SvelteKit, print it once and paste it into
377
+ `src/app.html`, or inject it from `hooks.server.ts`:
378
+
379
+ ```html
380
+ <!-- src/app.html -->
381
+ <html lang="sv" data-theme="auto">
382
+ <head>
383
+ <script>
384
+ (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){}})();
385
+ </script>
386
+ %sveltekit.head%
82
387
  ```
83
388
 
84
- Canonical recommendation for most apps remains:
389
+ ```ts
390
+ // src/hooks.server.ts: the same, generated, so it follows the package.
391
+ // Put %theme-init% in the <head> of src/app.html where the script should go.
392
+ import { themeInitScript } from "zabi-components";
85
393
 
86
- ```css
87
- @import "tailwindcss";
88
- @import "zabi-components/theme-only";
89
- @import "zabi-components/theme-dark-only";
394
+ export const handle = ({ event, resolve }) =>
395
+ resolve(event, {
396
+ transformPageChunk: ({ html }) => html.replace("%theme-init%", `<script>${themeInitScript()}</script>`),
397
+ });
90
398
  ```
91
399
 
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).
400
+ With nothing stored the script does nothing, and the `data-theme` in your
401
+ markup stands.
95
402
 
96
- ## 🎯 Common Customizations
403
+ **Your own control.** A settings screen usually wants three labelled options,
404
+ not a cycling button. Drive the theme with the same helpers and any
405
+ `ThemeToggle` on the page follows:
97
406
 
98
- ### Change Brand Colors
407
+ ```svelte
408
+ <script lang="ts">
409
+ import { onMount } from "svelte";
410
+ import { SegmentedControl, getThemeMode, setThemeMode, type ThemeMode } from "zabi-components";
411
+
412
+ let mode = $state<ThemeMode>("auto");
413
+ onMount(() => (mode = getThemeMode()));
414
+ </script>
415
+
416
+ <SegmentedControl
417
+ label="Tema"
418
+ options={[
419
+ { value: "auto", label: "Följ telefonen" },
420
+ { value: "light", label: "Ljust" },
421
+ { value: "dark", label: "Mörkt" },
422
+ ]}
423
+ value={mode}
424
+ onchange={(value) => {
425
+ mode = value as ThemeMode;
426
+ setThemeMode(mode); // or setThemeMode(mode, { storageKey: null }) and save it yourself
427
+ }}
428
+ />
429
+ ```
99
430
 
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
- }
431
+ | Helper | |
432
+ |---|---|
433
+ | `getThemeMode()` | `"auto"`, `"light"` or `"dark"`, from `data-theme`; without the attribute, `"dark"` with the `dark` class and `"light"` without. |
434
+ | `setThemeMode(mode, { storageKey? })` | Writes `data-theme`, removes a leftover `dark` class and inline `color-scheme`, stores the choice (`storageKey: null` stores nothing). |
435
+ | `isThemeDark()` | Whether the page is dark right now; on `auto` that is the system's answer. |
436
+ | `getStoredThemeMode(storageKey?)`, `storeThemeMode(mode, storageKey?)` | The stored choice. |
437
+ | `themeInitScript(storageKey?)` | The `<head>` script above, as a string. |
438
+
439
+ All of them are safe to call on the server, where they read `"light"` and
440
+ write nothing.
441
+
442
+ ```js
443
+ document.documentElement.dataset.theme = "dark"; // "light" | "auto"
444
+ // or
445
+ document.documentElement.classList.toggle("dark");
446
+ ```
447
+
448
+ ## Text and controls on a brand or accent block
449
+
450
+ A block filled with the brand colour (`bg-action-primary`) or the accent
451
+ (`bg-accent`) is not a surface level: the text roles (`--color-headline`,
452
+ `--color-body`) are chosen for the page and for cards, and on a brand fill
453
+ they are about 2:1 to 3.7:1. Three things make a block work.
454
+
455
+ **1. Say what the block is.** Add `on-brand` beside `bg-action-primary`, or
456
+ `on-accent` beside `bg-accent`:
457
+
458
+ ```svelte
459
+ <section class="bg-action-primary on-brand rounded-container p-6">
460
+ <Heading level={2} tone="inherit">Dagens quiz</Heading>
461
+ <Text tone="inherit">Tio frågor om Sverige.</Text>
462
+ <Button variant="accent">Spela</Button>
463
+ </section>
464
+ ```
465
+
466
+ The class sets the block's text colour to the fill's label colour
467
+ (`--color-on-brand` or `--color-on-accent`). For everything inside the block
468
+ it re-points the focus rings, and the roles a control writes with when it has
469
+ no fill of its own (see 3).
470
+
471
+ **2. Give text a tone that belongs on a fill.** `Heading` and `Text` take:
472
+
473
+ | `tone` | Colour | Use |
474
+ |---|---|---|
475
+ | `inherit` | the block's own text colour | inside `on-brand` / `on-accent`, or any block that sets a colour |
476
+ | `on-brand` | `--color-on-brand` | on `bg-action-primary`, wherever the element is |
477
+ | `on-accent` | `--color-on-accent` | on `bg-accent` |
478
+
479
+ `Heading` is `tone="headline"` by default and `Text` `tone="body"`. Inside a
480
+ block both roles are the block's label colour, so the defaults are legible
481
+ there too; `inherit` says it outright, and is what works on a block of a
482
+ colour of your own.
483
+
484
+ **3. Controls on the block.** A control with no fill of its own writes with
485
+ the page's roles: a Checkbox's label is `--color-label`, an outline Button's
486
+ `--color-headline` with a `--color-border` edge, help text
487
+ `--color-description`. On a brand fill those were 1.5:1 to 3.7:1. Inside
488
+ `on-brand` and `on-accent` they are re-pointed at the block's label colour:
489
+
490
+ - the text roles: `--color-headline`, `--color-body`, `--color-label`,
491
+ `--color-description`, `--color-caption`, `--color-link`,
492
+ `--color-link-hover`
493
+ - the edges: `--color-border`, `--color-border-medium`,
494
+ `--color-border-strong`, `--color-control-border`
495
+ - the rings: `--color-focus-ring`, `--color-focus-ring-muted`,
496
+ `--color-focus-ring-danger`, `--color-nav-menu-focus`, with the gap
497
+ (`--color-focus-ring-offset`) in the block's own colour
498
+
499
+ So every one of them measures what the label colour measures against the
500
+ fill, which the theme holds to 4.5:1 (`scripts/check-contrast.js` fails when a
501
+ block stops re-pointing one, or points it at something that does not read):
502
+
503
+ | Inside | Default theme, light | Default theme, dark | `#0026EA` pinned + `#FDD715` pinned accent, light | The same, dark |
504
+ |---|---|---|---|---|
505
+ | `on-brand` | 4.86 | 7.21 | 8.52 | 7.07 |
506
+ | `on-accent` | 5.02 | 7.11 | 11.66 | 11.66 |
507
+
508
+ That is the label of a Checkbox, Radio, Toggle, Input, Select, Textarea and
509
+ Rating, a field's help text, `Text` and `Heading` in their default tones, a
510
+ plain link, the label of an `outline`, `ghost` and `link` Button and
511
+ IconButton, the border of an `outline` one, and the focus ring of all of
512
+ them, the danger ring included. They were, on a brand block in the default
513
+ theme: labels 2.15 (light) and 1.52 (dark), `outline` and `ghost` 3.65 and
514
+ 2.05, `link` 1.39 and 1.34, the danger ring 1.06 and 1.04.
515
+
516
+ **A fill you choose.** `on-fill` is the same scope for a colour the library
517
+ does not know: `<div class="on-fill" style="background: #123; --zabi-on-fill: #fff">`.
518
+ Everything above applies with `--zabi-on-fill` as the label colour (without it
519
+ the label is `currentColor`, and the ring's gap is transparent, so the block
520
+ shows through). A surface inside (`bg-card`, `bg-card-tint`, `bg-chip`, `on-surface`
521
+ and the rest of the list) gets the theme's roles back. The library cannot hold
522
+ a colour the app picks at run time: the 4.5:1 of the label on your fill (3:1 for
523
+ edges and rings) is yours to check. A primary Button, a solid Badge and a
524
+ Rating keep their own fills and labels inside any of the three blocks; only the
525
+ text roles, the edges and the rings follow the block.
526
+
527
+ The Button variants, on a brand block (`bg-action-primary`). "Label" is
528
+ against the button's own fill, or against the block where the button has
529
+ none; "fill" is the button's fill against the block (3:1 for the button to be
530
+ seen as a shape):
531
+
532
+ | `Button variant` | Default theme, light | Default theme, dark | `#0026EA` + `#FDD715` pinned, light | The same, dark |
533
+ |---|---|---|---|---|
534
+ | `outline`, `ghost`, `link` | label 4.86 | label 7.21 | label 8.52 | label 7.07 |
535
+ | `secondary` | label 5.65 | label 7.75 | label 9.58 | label 7.63 |
536
+ | `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 |
537
+ | `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 |
538
+ | `primary` | the block's own colour: invisible | invisible | invisible | invisible |
539
+
540
+ On an accent block (`bg-accent`):
541
+
542
+ | `Button variant` | Default theme, light | Default theme, dark | `#0026EA` + `#FDD715` pinned, light | The same, dark |
543
+ |---|---|---|---|---|
544
+ | `outline`, `ghost`, `link` | label 5.02 | label 7.11 | label 11.66 | label 11.66 |
545
+ | `secondary` | label 5.86 | label 7.73 | label 9.43 | label 11.92 |
546
+ | `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 |
547
+ | `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 |
548
+ | `accent` | invisible | invisible | invisible | invisible |
549
+
550
+ So the quiet variants (`outline`, `ghost`, `link`, `secondary`) are legible on
551
+ either block in any theme. A solid one keeps its own label; whether it also
552
+ stands out as a shape depends on how far apart the two brand colours are: an
553
+ ultramarine and a yellow are 6:1 apart in light, the library's own blue and
554
+ citron 1.03:1. Where they are close, use a quiet variant for a second action,
555
+ or put the action on a card.
556
+
557
+ **What a block cannot make legible: put it on a card.** Some parts of a
558
+ control are not written with a role that a block can re-point. They are drawn
559
+ with a neutral step, with the primary fill itself, or with a status colour.
560
+ Against the block:
561
+
562
+ | Part | On a brand block (default light, dark; pinned light, dark) | On an accent block |
563
+ |---|---|---|
564
+ | 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 |
565
+ | 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 |
566
+ | 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 |
567
+ | the fill of a field (Input, Select, Textarea) | 4.86, 7.31; 8.52, 7.30 | 5.02, 7.12; 1.41, 11.67 |
568
+
569
+ The label of such a control is legible on the block and the control itself
570
+ may not be: a ticked Checkbox on a brand block is a tick with no box around
571
+ it. Put Checkbox, Radio, Toggle, Calendar and anything that shows a validation
572
+ message on a card inside the block. A field is a surface of its own and
573
+ carries its text; it only disappears on an accent as light as the field (the
574
+ pinned yellow, 1.41). Rating has properties for its colours
575
+ (`--zabi-rating-on`, `--zabi-rating-off`, below).
576
+
577
+ **A surface inside a block is a surface again.** Anything inside the block
578
+ that paints a surface (a `Card`, a field, an `Alert`, a `list-group`: the
579
+ `bg-card`, `bg-surface-*`, `bg-input` and `bg-*-subtle` fills) gets the
580
+ theme's own roles back, and its own text colour, so a card needs nothing: a
581
+ Checkbox on it has the page's label colour and the theme's focus ring. For a
582
+ background the library cannot recognise (an inline style, a class of your
583
+ own), add `on-surface`:
584
+
585
+ ```svelte
586
+ <section class="bg-action-primary on-brand p-6">
587
+ <Button variant="outline">Hoppa över</Button>
588
+ <div class="on-surface rounded-container p-4" style="background: #fffbeb">
589
+ <Checkbox label="Kom ihåg mig" />
590
+ </div>
591
+ </section>
115
592
  ```
116
593
 
117
- ### Change Primary Button Color
594
+ A block inside that surface is a block again.
595
+
596
+ **The focus ring.** The default ring (`--color-focus-ring`) is the primary
597
+ fill, so on a brand block it measures 1.00:1 against the background and only
598
+ its 2px gap shows; the danger ring is the error colour, 1.0:1 to 1.7:1 there.
599
+ Inside a block every ring is the block's label colour and the gap is the
600
+ block's colour: the numbers of the first table above.
118
601
 
119
- The primary button uses `--color-action-primary`, which defaults to `brand-600`
120
- (4.86:1 against a white label). To change it:
602
+ To do the same on a block of another colour, set the colour and the ring on
603
+ it yourself, and give text `tone="inherit"`:
121
604
 
122
605
  ```css
123
- @theme {
124
- /* Use a different brand shade */
125
- --color-action-primary: theme(colors.brand.600);
126
-
127
- /* Or use a completely custom color */
128
- --color-action-primary: #your-color-here;
606
+ .my-block {
607
+ color: #ffffff;
608
+ --color-focus-ring: #ffffff;
609
+ --color-focus-ring-offset: #7a1f5c; /* the block's own colour */
129
610
  }
130
611
  ```
131
612
 
132
- ### Brand Scale Usage
613
+ The roles of 3 are not re-pointed there: a quiet Button or a Checkbox on such
614
+ a block keeps the page's colours. Re-point the ones you need the same way
615
+ (`--color-label: #ffffff`, `--color-headline: #ffffff`, `--color-border:
616
+ #ffffff`), and then mark any card inside it `on-surface` and set them back
617
+ there.
618
+
619
+ ## Component-level properties
620
+
621
+ A few components read custom properties of their own, for colours that are not
622
+ roles of the theme. They are not tokens: the theme does not declare them, and
623
+ they do not appear in the generated files. Set them on the component, or on
624
+ anything around it; they inherit.
625
+
626
+ | Component | Property | What it colours | Default |
627
+ |---|---|---|---|
628
+ | `Rating` | `--zabi-rating-on` | a filled star | `--color-action-primary`; `--color-accent` with `tone="accent"` |
629
+ | `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 |
630
+ | `Rating` | `--zabi-rating-on-edge` | the outline of a filled star | the fill; `--color-accent-text` with `tone="accent"` |
631
+ | `Rating` | `--zabi-rating-off` | the outline of an empty star | `--color-control-border` |
632
+ | `Rating` | `--zabi-rating-off-fill` | an empty star with `empty="soft"` (below 3:1 on the page; pair it with a visible label and value) | `--color-control-track` |
633
+ | `SpeechBubble` | `--zabi-bubble-fill` | the bubble and its tail; wins over `tone` | the headline colour; the page colour with `tone="paper"` |
634
+ | `SpeechBubble` | `--zabi-bubble-text` | the quoted text; wins over `tone` (4.5:1 on your fill is yours to check) | the page colour; the headline colour with `tone="paper"` |
635
+ | `SpeechBubble` | `--zabi-bubble-radius` | not a colour: the bubble's corners, any `border-radius` value, the eight-value form included | `1.5rem` |
636
+ | `SpeechBubble` | `--zabi-bubble-tilt` | not a colour: turns the fill and the tail, never the text | `0deg` |
637
+ | `LineChart`, `BarChart`, `PieChart`, `Sparkline` | `--zabi-chart-1` | the first series: a line, the bars, the first slice (3:1 on the surface under it is yours to check) | `--color-action-primary`: the blue a `Progress` fills with |
638
+ | `LineChart`, `PieChart` | `--zabi-chart-2` | the second series | `--color-accent-500`: the accent at its middle step |
639
+ | `LineChart`, `PieChart` | `--zabi-chart-3` | the third series | `--color-brand-900`: the brand at its deepest step (its palest in dark) |
640
+ | `LineChart`, `PieChart` | `--zabi-chart-4` | the fourth series | `--color-accent-700`: the accent, deep (pale in dark) |
641
+ | `LineChart`, `PieChart` | `--zabi-chart-5` | the fifth series | `--color-base-500`: the neutral grey |
642
+ | `LineChart`, `PieChart` | `--zabi-chart-6` | the sixth series; a seventh starts again at the first | plum: `--color-brand-700` and `--color-error-700` mixed half and half in OKLCH, the one colour from outside the three ramps |
643
+ | `LineChart` | `--zabi-chart-surface` | paints the ring around each marker. Without it the ring is cut out of the lines, so it shows whatever the chart stands on (a card, a tint, glass) and there is nothing to set | none: the ring is see-through |
644
+
645
+ The series colours are the library's own: the brand, the accent and the neutral ramp, each at a solid step and a deep one, so a chart sits beside a `Progress` or a `Badge` without a colour of its own. They are categories, not states: none is `--color-success`, `--color-warning`, `--color-error` or `--color-info`. Because they are steps of your ramps they follow light and dark and a brand of your own; they were measured on the default theme only (each 3:1 or more on a card in both modes; any two apart for a reader who does not see red and green). When a series means a state (right, wrong, skipped) give it `color: 'success'`, `'error'`, `'neutral'`; a series without a `color` in the same chart then steps past the series colour nearest to a named one.
646
+
647
+ The rest of a chart is borrowed too, so it follows what you set for those: a `BarChart` with a `yMax` stands each bar in a track drawn with `--color-progress-track` and `--color-progress-track-border`; grid lines are `--color-border`; the rows of the `PieChart` key and of the narrow `LineChart` key are parted by the divider of a `List`; `showTable` shows a `Table`.
648
+
649
+ The slices of a `PieChart` and the bars of a `BarChart` are drawn as glass. An SVG shape takes no backdrop blur, so each keeps its series colour, opaque, and the depth is a soft gradient and a barely-there edge: the veil of a solid control (`--color-control-gradient-accent-start` to `--color-control-gradient-accent-end`: darken-only in light, light at the top in dark), one pixel of light on the top of the rim (the lightest neutral at 30%, the same in both modes), a blurred sheen under it at about a third of that, and a soft shadow under the drawing (the two shadows of `--shadow-md`; the contact shadow only under a bar). Retune the veil and shadow tokens and the charts follow. They are the flat fills wherever the materials are opaque: reduced transparency, more contrast, forced colours, and inside `data-materials="opaque"`, which is also how an app turns the look off for one chart or for all of them. On the default theme every fill stays 3:1 or more on a card under the veil in both modes. The key marks, line markers and the `Sparkline` dot stay flat: at 6 to 10px there is no room for it. The gaps of a full pie narrow to a point at the centre, so every slice runs to the middle; in a ring they have parallel sides.
650
+
651
+ The glass under a chart, as distinct from the marks, is the library's thick material and nothing of its own, so the material tokens and every fallback apply to it: reduced transparency, more contrast, forced colours, a browser without `backdrop-filter` and `data-materials="opaque"` all give the opaque surface. The pane of values that comes up over a `LineChart` when it is pointed at is always that material, as a `Tooltip` is. The `glass` prop on `LineChart`, `BarChart` and `PieChart` stands the whole chart on a panel of it; that is for a chart over the canvas wash, a tint card or a picture. On a plain card it shows only an edge and a shadow. Measured on the default theme, every series colour is 3:1 or more on the glass over the wash and over a tint card in both modes; over a solid brand or accent fill the second series falls to 2.9:1 in light and the first and fifth to 2.3:1 and 1.8:1 in dark, so put the chart on a `Card` there instead.
133
652
 
134
- The brand scale is used throughout the system:
653
+ ```svelte
654
+ <Rating label="Betyg" tone="accent" bind:value />
135
655
 
136
- - **brand-50 to brand-100**: Subtle backgrounds, hover states
137
- - **brand-300 to brand-400**: Light accents, disabled states
138
- - **brand-500**: Focus rings, medium emphasis
139
- - **brand-600**: **Primary buttons** (`action-primary`)
140
- - **brand-700**: Primary hover, links
141
- - **brand-800**: Primary active, link hover
142
- - **brand-900 / 950**: Darkest brand shades
656
+ <!-- or a colour of your own, here for every rating in a block -->
657
+ <section class="bg-action-primary on-brand" style="--zabi-rating-on: var(--color-accent); --zabi-rating-off: var(--color-on-brand)">
658
+ <Rating label="Betyg" bind:value />
659
+ </section>
660
+ ```
143
661
 
144
- Hover and active always move *toward* the dark end in light mode. The `.dark`
145
- mirror flips that automatically, so "more pressed" means "more contrast against
146
- the page" in both themes. Never point `-active` at a step on the far side of the
147
- ramp — that is how the pressed primary label ended up at 1.66:1.
662
+ A star is a control whose shape has to be seen (3:1 against what it sits on).
663
+ `tone="accent"` fills the star with the accent and outlines it in the accent's
664
+ text step, because an accent can be a colour no fill can show on white:
665
+
666
+ | Filled star, `tone="accent"` | page | card | inset |
667
+ |---|---|---|---|
668
+ | Default accent, light: fill / outline | 4.25 / 5.93 | 5.02 / 7.00 | 4.56 / 6.37 |
669
+ | Default accent, dark: fill / outline | 7.68 / 10.34 | 6.54 / 8.81 | 7.12 / 9.60 |
670
+ | `#FDD715` pinned, light: fill / outline | 1.19 / 5.94 | 1.41 / 7.01 | 1.28 / 6.38 |
671
+ | `#FDD715` pinned, dark: fill / outline | 12.58 / 10.27 | 10.71 / 8.75 | 11.67 / 9.53 |
148
672
 
149
- ### Dark Mode
673
+ A yellow star cannot reach 3:1 on a white card: its fill is 1.41:1 there. The
674
+ outline keeps the star's shape at 7:1, so the stars are always there to see.
675
+ What tells a filled star from an empty one on white is then the yellow inside
676
+ the outline and the outline's own colour, which is less than 3:1; the rating
677
+ also has its value as text (`showValue`, on by default when `readonly`) and in
678
+ its accessible name. On a dark or a brand block the yellow fill itself is
679
+ 6:1 or more. If you set `--zabi-rating-on` to a light colour yourself, set
680
+ `--zabi-rating-on-edge` too.
150
681
 
151
- Dark mode automatically inverts the brand scale:
152
- - `brand-50` becomes `brand-950` (darkest)
153
- - `brand-950` becomes `brand-50` (lightest)
682
+ ## The generator
154
683
 
155
- This happens automatically in the `.dark` block, so you typically don't need to override action colors in dark mode.
684
+ Exact brand colour on buttons: use `--pin`.
156
685
 
157
- ## 🌙 Dark Mode Action Colors
686
+ ```bash
687
+ npx zabi-theme --brand "#0026EA" --pin --out src/lib/brand.generated.css
688
+ ```
158
689
 
159
- Dark mode action colors are handled in `src/app.css` in the `.dark` block (lines 393-404):
690
+ Without it, every fill is a step of a ramp built from the colour:
160
691
 
161
- ### Primary Actions
162
- - **No explicit dark mode override needed** - Brand colors are automatically inverted
163
- - `brand-800` (light mode) → `brand-200` (dark mode) automatically
692
+ ```bash
693
+ npx zabi-theme --brand "#0026EA" --out src/lib/brand.generated.css
694
+ npx zabi-theme --brand "#C17B00" --accent "#ff3366" --neutral "#78716c" --out src/lib/brand.generated.css
695
+ ```
164
696
 
165
- ### Secondary Actions
166
- - **No explicit dark mode override needed** - Base colors are automatically inverted
167
- - `base-600` (light mode) → `base-400` (dark mode) automatically
697
+ | Option | |
698
+ |---|---|
699
+ | `--brand <hex>` | Required. Builds `--zabi-brand-50 … 950`. |
700
+ | `--pin` | The primary action is the exact brand colour in light. See [`--pin`](#--pin-the-exact-brand-colour-on-buttons). |
701
+ | `--accent <hex>` | Builds `--zabi-accent-50 … 950`. Omitted: the library's citron. |
702
+ | `--pin-accent` | The same for the solid accent fill. Needs `--accent`. |
703
+ | `--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. |
704
+ | `--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). |
705
+ | `--out <file>` | Where to write. Omitted: stdout. |
706
+ | `--strict` | Exit 1 when a role pair is below WCAG AA. |
707
+ | `--set <token>=<value>` | Also write this declaration, in light and dark; repeatable. |
708
+ | `--set-light <token>=<value>` | The same for light only: dark keeps the library's value. Repeatable. See [`--set` and overrides](#--set-and-overrides). |
709
+ | `--set-dark <token>=<value>` | The same for dark only. Repeatable. |
710
+ | `--help` | Usage. |
711
+
712
+ Colours are hex, `#rgb` or `#rrggbb`. Warnings and the one-line summary go to
713
+ stderr. Exit codes: 0 done, 1 `--strict` with a failed pair, 2 bad usage
714
+ (missing `--brand`, not a hex colour).
715
+
716
+ ### `--neutral-chroma`: a strongly tinted neutral
717
+
718
+ `--neutral` gives the neutral ramp its hue and never more chroma than 0.03, so
719
+ it stays a tinted grey. For a UI that reads as one hue with the brand, say a
720
+ blue-slate under an ultramarine brand, give the chroma yourself:
168
721
 
169
- ### Danger Actions
170
- - **No re-pointing needed.** Danger resolves through `--color-error-*`, which
171
- mirrors like every other ramp, so dark danger is the same step of the same
172
- ramp as light danger. The `.dark` block restates the aliases only so that
173
- `zabi-components/theme-dark-only` remains a complete standalone import
174
- (`validate-theme.js` enforces that).
722
+ ```bash
723
+ npx zabi-theme --brand "#0026EA" --neutral "#607296" --neutral-chroma 0.05 --out src/lib/brand.generated.css
724
+ ```
725
+
726
+ The ramp has the same 21 lightness steps and the same shape; only its peak
727
+ chroma is the number you gave (`0` to `0.1`). The contrast check runs on the
728
+ result as always. Without `--neutral-chroma` the output is exactly what it was.
729
+
730
+ With it, the translucent ink roles stop being grey and follow the ramp, as
731
+ `color-mix()` over `--zabi-base-900` (light) or `--zabi-base-50` (dark) at the
732
+ alpha the library gives each role:
733
+ `--color-action-secondary`, `-hover`, `-active`, `--color-surface-hover`,
734
+ `--color-surface-active` and, in light, `--color-border-overlay`.
735
+ `--shadow-color` becomes the RGB triplet of step 900 in light and stays `0 0 0`
736
+ in dark. The modal scrim `--color-overlay` stays black. Because a role on
737
+ `:root` is one value in both modes, the file restates the dark values under the
738
+ dark selectors, as `--pin` does. A role you set with `--set`, `--set-light` or
739
+ `--set-dark` keeps your value.
740
+ The contrast check does not evaluate these translucent roles, with or without
741
+ the option; the library's own guard (`npm run check:design`) does for the
742
+ defaults.
743
+
744
+ ### `--pin`: the exact brand colour on buttons
745
+
746
+ Unpinned, the primary button is step 600 of the ramp: for `#0026EA` that is
747
+ `#2b65ff`, visibly lighter than the brand. With `--pin` the ramp is the same
748
+ ramp, and the roles that *are* the brand colour are written as role overrides:
749
+
750
+ | | Unpinned | `--pin` |
751
+ |---|---|---|
752
+ | Primary button, light | step 600, `#2b65ff` | the colour itself, `#0026ea` |
753
+ | Its label | white, 4.76:1 | white, 8.52:1 |
754
+ | Hover, pressed | steps 700, 800 | the same hue, darker by the same distances: `#0100c4`, `#000e78` |
755
+ | Focus ring, links, checked boxes | steps 600 and 700 | the colour itself, because every guarded pair still passes |
756
+ | Tints and borders (`-subtle`, `-border`), `--color-brand-*` | on the ramp | on the ramp, unchanged |
757
+ | Dark | step 400, `#84acff` | step 400, `#84acff`: not pinned |
758
+
759
+ What it decides, and how:
760
+
761
+ - **The fill is never moved.** If the colour fails a pair, the pair is a
762
+ warning (and `--strict` exits 1). `#C17B00` pinned is 2.92:1 against the
763
+ light page, where a button needs 3:1, and the generator says so.
764
+ - **The label** is white or the ramp's 950 step, whichever reaches 4.5:1 on the
765
+ fill and both its states. An amber gets the dark label, and its hover and
766
+ pressed states go lighter, since darker states would lose the label.
767
+ - **Focus ring and links follow only if they pass.** The ring needs 3:1 on
768
+ every surface and a link 4.5:1 on the page and on a card. A yellow passes
769
+ neither, so both stay on the ramp. The header lists which followed.
770
+ - **Dark is not pinned unless the colour passes there.** `#0026EA` is 2.08:1
771
+ against a dark page and 1.77:1 against a dark card, where a fill needs 3:1:
772
+ no label makes that a button. Dark keeps
773
+ step 600 (the mirrored step 400 with `darkPrimary: "mirror"`), exactly as
774
+ without `--pin`, and the header says so. A
775
+ colour that does pass in dark (the amber, the yellow) is pinned there too,
776
+ with lighter states.
777
+
778
+ The trade-off: a ramp step is guaranteed to pass, in both modes, for any
779
+ colour. A pinned colour is exact and passes only if the colour can. Unpinned,
780
+ a brand rarely warns; pinned, read the warnings.
781
+
782
+ The file then has three rules, not one: `:root` with the ramp and the light
783
+ roles, and the dark values of those roles under `.dark, [data-theme="dark"]`
784
+ and under `[data-theme="auto"]` in the dark media query. A role set on `:root`
785
+ alone would be one value in both modes. To force a dark value the generator
786
+ declined, write the role in your own dark rule after the file (see
787
+ [One role in dark only](#one-role-in-dark-only)).
788
+
789
+ A role you pass with `--set` (or `--set-light`, `--set-dark`) is left to you:
790
+ `--pin` does not write it in either mode. With `--set` it is your value in both
791
+ modes; with `--set-light` or `--set-dark`, only in that mode.
792
+
793
+ ### What it does
794
+
795
+ - **Brand and accent ramps** on the library's lightness curve, from the hue and
796
+ chroma of the colour you give. See
797
+ [the first section](#the-first-thing-to-know-your-colour-is-not-pinned-to-a-step).
798
+ - **Neutral ramp.** It keeps the base scale's own 21 lightness steps and takes
799
+ only the hue, at low chroma (0.03 at most). A saturated colour gives a tinted
800
+ grey, not a coloured page. `neutralChroma` lifts that cap.
801
+ The chroma tapers towards the light end of the ramp, so the lightest steps
802
+ are paler than the mid steps. To match an owner's page colour exactly, do not
803
+ raise `neutralChroma` (that tints the mid steps and muted text too); set the
804
+ page per mode instead: `overrides: { light: { '--color-surface-base': '#e3edff' } }`.
805
+ - **Links follow an overridden primary.** If `overrides` set `--color-action-primary` (and not `--color-link`), links take that colour in each mode the override applies to, and `--color-link-hover` the primary hover if you set one, as long as no guarded pair fails that did not before; otherwise the ramp's link stays in that mode. An explicit `--color-link` always wins. To put the library's link back: `overrides: { '--color-link': 'var(--color-brand-700)', '--color-link-hover': 'var(--color-brand-800)' }`.
806
+ - **"On" colours.** It sets `--zabi-on-brand` and `--zabi-on-brand-dark` (and
807
+ the accent pair when `--accent` is given) to white or the ramp's 950 step,
808
+ whichever reaches 4.5:1 on the fill, its hover and its active step. In dark
809
+ the brand label tries white first (the dark primary is step 600), and falls
810
+ back to the 950 step when an app has moved the dark primary to a pale step.
811
+ - **`darkPrimary`.** `"brand"` (default) writes nothing: the library's dark
812
+ primary and danger are step 600 with a white label. `"mirror"` writes the 8.1
813
+ fills under the dark selectors: primary and danger on the mirrored 600, 700
814
+ and 800 (pale in dark), danger's label as it was, the label chosen by the
815
+ contrast check, and the control veil at 22% / 6%. An override of those roles
816
+ in `dark` or `both` always wins. CLI: `--dark-primary brand|mirror`. A
817
+ `--set --color-action-primary=...` that applies to both modes keeps its dark
818
+ value; move it under `--set-light` to take the dark default.
819
+ - **Contrast check.** Every pair the library holds itself to is resolved in
820
+ light and dark with the new ramps in place: 4.5:1 for text and
821
+ 3:1 for focus rings and UI parts.
822
+
823
+ ### What it does not do
824
+
825
+ - It does not put your exact hex in the ramp. `--pin` puts it on the primary
826
+ action; the ramp steps stay on the curve.
827
+ - It does not write fonts, radius or shadow unless you pass them with `--set`.
828
+ - It does not check the hover, pressed and secondary-button alpha tints. They
829
+ are not part of the contrast list.
830
+ - It does not check that a `--set` token exists. A misspelt name is written as
831
+ given.
832
+ - It does not load or check fonts.
833
+
834
+ ### Warnings
835
+
836
+ A plain brand passes, because the ramps share the library's curve. Warnings
837
+ come from two places.
838
+
839
+ An input that will not give the ramp its author probably expects:
840
+
841
+ ```
842
+ 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.
843
+ ```
844
+
845
+ A role pair below AA, which only happens when `--set` moves a role or `--pin`
846
+ pins a colour that cannot carry it:
847
+
848
+ ```
849
+ 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)
850
+ ```
851
+
852
+ ### `--strict` in CI
853
+
854
+ Without `--strict` a failed pair is a warning and the file is still written.
855
+ With it the command exits 1. Because the output is deterministic, one line
856
+ checks both that the brand passes and that the committed file is current:
857
+
858
+ ```bash
859
+ npx zabi-theme --brand "#C17B00" --neutral "#78716c" --strict --out src/lib/brand.generated.css && git diff --exit-code src/lib/brand.generated.css
860
+ ```
175
861
 
176
- ### In Generated Files
862
+ ### `--set` and overrides
177
863
 
178
- - **`dist/zabi-components.css`**: Has dark mode at line 2178, danger actions at lines 2264-2269
179
- - **`dist/zabi-components-theme-dark-only.css`**: Has dark mode action colors at lines 119-130 (only danger has explicit overrides)
180
- - **`dist/zabi-components-theme-only.css`**: Only has light mode (@theme block), no dark mode
181
- - **`dist/zabi-components-theme.css`**: Only has light mode (@theme block), no dark mode
864
+ `--set` writes one more declaration into the file. It applies in light and in
865
+ dark, and it takes part in the contrast check and in the choice of the "on"
866
+ colours. `--set-light` and `--set-dark` do the same for one mode.
867
+
868
+ ```bash
869
+ npx zabi-theme --brand "#C17B00" --set "--color-link=var(--color-brand-800)" --out src/lib/brand.generated.css
870
+ npx zabi-theme --brand "#C17B00" --set '--font-family-heading="Fraunces", var(--font-family-sans)' --out src/lib/brand.generated.css
871
+ ```
872
+
873
+ Point a role at a **semantic** step, `var(--color-brand-800)`, not at a
874
+ physical one. The semantic step mirrors in dark; `var(--zabi-brand-800)` is
875
+ the same dark brown in both modes, and as a link colour it fails in dark:
876
+
877
+ ```
878
+ zabi-theme: warning: dark · link on page: #613b00 on #18181b is 1.8:1, needs 4.5:1 (--color-link on --color-surface-base)
879
+ ```
880
+
881
+ #### One mode only: `--set-light` and `--set-dark`
882
+
883
+ Dark is derived from light, so `--set "--color-surface-raised=#f8faff"` also
884
+ paints the dark cards `#f8faff`. To tint the light card and keep the dark mix
885
+ the library gives it:
886
+
887
+ ```bash
888
+ npx zabi-theme --brand "#0026EA" \
889
+ --set-light "--color-surface-raised=#f8faff" --set-light "--color-surface-overlay=#f8faff" \
890
+ --out src/lib/brand.generated.css
891
+ ```
892
+
893
+ The value goes on `:root`. A `:root` value would win in dark too, so the file
894
+ restates, under both dark selectors, the library's own dark value of every token
895
+ you set in light only (the ramp the file writes, for a ramp step; the light
896
+ default if the library has no dark value for it). `--set-dark` writes only under
897
+ the dark selectors. A token given to `--set` and to a mode flag takes the mode's
898
+ value in that mode. The contrast check evaluates each mode with that mode's own
899
+ values, and a warning names the mode as before.
900
+
901
+ A token the library has no dark value for, or one you made up
902
+ (`--set-light "--my-gap=4px"`), cannot be put back in dark: it keeps the light
903
+ value there unless you also pass `--set-dark`.
904
+
905
+ ### `createTheme()`
906
+
907
+ The same thing as a function, for a build script:
908
+
909
+ ```js
910
+ import { writeFileSync } from "node:fs";
911
+ import { createTheme } from "zabi-components/create-theme";
912
+
913
+ const { css, tokens, warnings, closest } = createTheme({
914
+ brand: "#C17B00",
915
+ accent: "#ff3366", // optional
916
+ neutral: "#78716c", // optional
917
+ neutralChroma: 0.05, // optional, 0 to 0.1, needs neutral: a clearly tinted neutral; the ink roles follow it
918
+ overrides: { "--color-link": "var(--color-brand-800)" }, // optional; both modes. Or { light, dark, both }
919
+ pin: true, // optional: the exact brand on the primary action; or { brand: true, accent: true }
920
+ darkPrimary: "brand", // optional: "brand" (default, step 600 in dark too) or "mirror" (the 8.1 pale dark fills)
921
+ });
922
+
923
+ for (const warning of warnings) console.warn(warning.message);
924
+ console.log(`Brand is closest to step ${closest.brand.step} (${closest.brand.hex}).`);
925
+
926
+ if (warnings.some((warning) => warning.type === "contrast")) process.exit(1);
927
+ writeFileSync("src/lib/brand.generated.css", css);
928
+ ```
929
+
930
+ | Returned | |
931
+ |---|---|
932
+ | `css` | The stylesheet: a header comment and one `:root { … }` rule. With `pin`, `neutralChroma`, `darkPrimary: "mirror"` or `light` / `dark` overrides, two dark rules follow it. |
933
+ | `tokens` | Every declaration of the `:root` rule, name to value. |
934
+ | `darkTokens` | Only with `pin`, `neutralChroma`, `darkPrimary: "mirror"` or `light` / `dark` overrides: every declaration of the dark rules. |
935
+ | `pinned` | Only with `pin`: `{ brand?, accent? }`, each `{ hex, light: { followed, onRamp, states }, dark: { pinned, followed, onRamp, states?, failed? } }`. |
936
+ | `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. |
937
+ | `closest` | `{ brand, accent?, neutral? }`, each `{ step, hex, deltaE, exact }`: where the input landed in its ramp. |
938
+
939
+ `overrides` is either the flat map above, one value for both modes, or an object
940
+ with any of `light`, `dark` and `both`, each a flat map:
941
+
942
+ ```js
943
+ createTheme({
944
+ brand: "#0026EA",
945
+ overrides: {
946
+ light: { "--color-surface-raised": "#f8faff", "--color-surface-overlay": "#f8faff" }, // dark keeps its mix
947
+ dark: { "--color-link": "var(--color-brand-300)" }, // dark only
948
+ both: { "--color-focus": "var(--color-brand-700)" }, // same as the flat form
949
+ },
950
+ });
951
+ ```
952
+
953
+ Flat `--token` keys beside the three count as `both`. A token in `both` and in
954
+ `light` or `dark` takes the mode's value in that mode. `tokens` holds the `:root`
955
+ rule (`both`, then `light`); `darkTokens` holds what the dark rules say: the
956
+ `dark` map, and for every token set in `light`, the library's dark value for it
957
+ (or the `both` value), so `:root` does not reach dark. See
958
+ [One mode only](#one-mode-only---set-light-and---set-dark).
959
+
960
+ It throws a `TypeError` when `brand` is missing, a colour is not hex,
961
+ `pin.accent` is set without `accent`, `neutralChroma` is not a number from 0
962
+ to 0.1 or is given without `neutral`, `darkPrimary` is not `"brand"` or `"mirror"`, or an override is not a custom property
963
+ name with a non-empty string value (the message names the key path, for example
964
+ `overrides.light["--color-link"]`). Without these options the result and the
965
+ file are exactly what they were before the option existed.
966
+
967
+ ## What does not follow an override
968
+
969
+ - **Hover, pressed and secondary-button fills.** `--color-surface-hover`,
970
+ `--color-surface-active` and `--color-action-secondary` (and `-hover`,
971
+ `-active`) are fixed near-black (light) and near-white (dark) alpha tints.
972
+ They do not take the hue of `--zabi-base-*`, unless you give `neutralChroma`
973
+ (`--neutral-chroma`): then they are mixes of the neutral ramp's ink at the
974
+ same alphas.
975
+ - **The overlay edge and the modal backdrop.** `--color-border-overlay` in
976
+ light is a fixed alpha tint too, and follows the ramp with `neutralChroma`
977
+ (in dark it is already a ramp step). `--color-overlay`, the scrim, stays black.
978
+ - **The shadow colour.** `--shadow-color` is the default neutral's ink, not
979
+ your neutral's. With `neutralChroma` it is the ramp's step 900 in light;
980
+ otherwise set it yourself, it is on the list above.
981
+ - **The `energetic` family.** It points at the citron ramp, not at the accent,
982
+ so it stays yellow when `--zabi-accent-*` changes. `success`, `warning`,
983
+ `error` and `info` keep their own ramps as well.
984
+ - **The size of the dark surface steps.** The four dark levels are mixed from
985
+ your neutral ramp, so they take its hue. The mix amounts were solved for the
986
+ default greys; a neutral whose 900 or 50 step is far from `#18181b` /
987
+ `#fafafa` gives steps of a different size.
988
+ - **Browsers without `color-mix()`.** Where Tailwind compiles the dark file
989
+ (your build, or the `zabi-components/css` bundle) it adds the default hex as
990
+ a fallback before each mixed surface. Those browsers get the default dark
991
+ surfaces whatever the app overrides.
992
+ - **Tokens set below `<html>`.** See [Import order](#import-order).
993
+ - **Semantic steps.** `--color-brand-*` and friends do not rebrand both modes;
994
+ see [Roles](#roles-read-them-do-not-set-them).
995
+
996
+ ## Stability
997
+
998
+ Every token named in [Tokens an app may set](#tokens-an-app-may-set) and
999
+ [Roles](#roles-read-them-do-not-set-them) is public API, and so is every other
1000
+ token the theme files publish. **Renaming or removing one is a breaking
1001
+ change.** Adding a token is not. Regenerating the library's own ramps is
1002
+ breaking too, because it changes every colour an app has not overridden.
1003
+ `RELEASING.md` says the same from the release side.
1004
+
1005
+ This is enforced, not just promised.
1006
+ `tests/__snapshots__/theme-token-names.snap.json` lists every published token
1007
+ name. `npm run test:themes` reads the names out of the built
1008
+ `zabi-components/colors` file and fails if one on the list is missing. The
1009
+ list only grows: refreshing the snapshots adds new names and never drops one,
1010
+ so taking a name off is a hand edit of that file in a commit, which is where a
1011
+ breaking change gets noticed.
1012
+
1013
+ The CSS export paths (`zabi-components/theme-only` and the rest) are public in
1014
+ the same way: removing one is breaking.
1015
+
1016
+ ---
1017
+
1018
+ ## Reference: how the theme is built
1019
+
1020
+ Background for the tables above. An app does not need any of it to rebrand.
1021
+
1022
+ ### Which step does what
1023
+
1024
+ The brand scale is used throughout the system:
1025
+
1026
+ - **brand-50 to brand-100**: Subtle backgrounds, hover states
1027
+ - **brand-300 to brand-400**: Light accents, disabled states
1028
+ - **brand-500**: Medium emphasis
1029
+ - **brand-600**: **Primary buttons** (`action-primary`) and the focus ring, in both modes
1030
+ - **brand-700**: Primary hover, links
1031
+ - **brand-800**: Primary active, link hover
1032
+ - **brand-900 / 950**: Darkest brand shades
1033
+
1034
+ Hover and active always move *toward* the dark end in light mode. The `.dark`
1035
+ mirror flips that automatically, so "more pressed" means "more contrast against
1036
+ the page" in both themes. Never point `-active` at a step on the far side of the
1037
+ ramp — that is how the pressed primary label ended up at 1.66:1.
182
1038
 
183
- ## 🎨 Calibrated Colour Ramps
1039
+ ### Calibrated colour ramps
184
1040
 
185
1041
  Every chromatic ramp (`brand`, `citron`, `pine`, `iris`, `warning`, `error`) is
186
1042
  **generated against one shared lightness curve**, so `<ramp>-600` means the same
@@ -194,22 +1050,29 @@ as one family instead of six unrelated colours.
194
1050
  Step **600** is the solid-fill step: dark enough to clear 4.5:1 against white,
195
1051
  light enough to still read as a colour. Every semantic token points at it.
196
1052
 
197
- - **Source of truth:** `tokens/chromatic-scales.js` — hue, peak chroma, the
198
- chroma envelope, and the target curve.
1053
+ - **Source of truth:** `tokens/chromatic-scales.js` — hue and peak chroma per
1054
+ ramp. The target curve, the chroma envelope and the solver are in
1055
+ `create-theme/lib/ramp-math.js`, which the theme generator ships, so a
1056
+ generated brand sits on the same curve.
199
1057
  - **Regenerate:** `node scripts/generate-ramps.js` (runs inside `npm run sync:tokens`).
200
1058
  - **Verify:** `node scripts/check-ramp-lightness.js` — fails if any step drifts
201
1059
  more than ±1.5 L\* off the curve, or if two adjacent steps differ by more than
202
1060
  15 L\* (a cliff rather than a ramp).
203
1061
 
204
- To rebrand, edit `hue` / `peakChroma` for a ramp in `tokens/chromatic-scales.js`
205
- and re-run the generator. **Do not hand-edit `--zabi-<ramp>-*` in `src/app.css`** —
206
- the generator overwrites them, in both the `@theme` block and the `.dark` mirror.
1062
+ To change the library's own ramps, edit `hue` / `peakChroma` for a ramp in
1063
+ `tokens/chromatic-scales.js` and re-run the generator. **Do not hand-edit
1064
+ `--zabi-<ramp>-*` in `src/app.css`** — the generator overwrites them. They live
1065
+ in `@theme` only; `.dark` no longer holds a copy. (An app rebrands by overriding
1066
+ them on `:root` instead; see [Tokens an app may set](#tokens-an-app-may-set).)
1067
+
1068
+ `--zabi-accent-*` is not a generated ramp: each step aliases the citron step of
1069
+ the same number.
207
1070
 
208
1071
  The `base` (neutral) ramp is deliberately **not** on this curve: it is wider on
209
1072
  purpose because it also drives text, borders and the surface levels. Semantic
210
1073
  `neutral` aliases the base step closest to the chromatic 600s.
211
1074
 
212
- ## 🧩 Semantic Families
1075
+ ### Semantic families
213
1076
 
214
1077
  Each family exposes the same roles, built from the same steps:
215
1078
 
@@ -219,53 +1082,155 @@ Each family exposes the same roles, built from the same steps:
219
1082
  | `--color-<family>-weak` | 700 | pressed / emphasis |
220
1083
  | `--color-<family>-medium` | 800 | strongest fill |
221
1084
  | `--color-<family>-strong` | 900 | darkest |
222
- | `--color-<family>-subtle` | 100 | tinted fill — badges, alerts |
223
- | `--color-<family>-border` | 200 | tinted edge |
1085
+ | `--color-<family>-subtle` | 200 (dark: 16% of step 500 over the card surface) | tinted fill — badges, alerts |
1086
+ | `--color-<family>-border` | 300 (dark: 36% of step 500 over the card surface) | tinted edge |
224
1087
  | `--color-<family>-text` | 700 | text on a subtle fill or a page surface |
225
1088
 
226
1089
  Families: `success`, `warning`, `error`, `info`, `energetic`, `neutral`.
227
1090
 
1091
+ `accent` has the subtle trio on the same steps, and action-style `-hover` (700)
1092
+ and `-active` (800) instead of `-weak` / `-medium` / `-strong`.
1093
+
1094
+ The tints are the one role that does not simply mirror. A step-100 fill is
1095
+ 1.16:1 on a white card, which is enough on the near-white 9.0 page once the
1096
+ tint has its edge, so light uses steps 100 and 200 for the fill and the edge.
1097
+ `.dark` restates 100 and 200 too (the mirror of those would be too heavy on a
1098
+ dark surface). `neutral` follows the same shape on the base ramp: `base-200`
1099
+ fill and `base-250` edge in light, `base-300` and `base-200` in dark.
1100
+
228
1101
  Prefer the **subtle** trio (`-subtle` fill + `-border` edge + `-text` label) for
229
1102
  anything informational. Solid fills are for the one element on a screen that has
230
1103
  to shout.
231
1104
 
232
- ## 📐 Control Geometry
1105
+ ### Surface elevation levels
233
1106
 
234
- One height scale is shared by **every** form control, so a Button, Input, Select
235
- and IconButton of the same size line up in a row:
1107
+ Every surface uses one of four semantic levels. Use these tokens instead of raw `base-*` steps for backgrounds:
236
1108
 
237
- | size | height | token |
238
- |---|---|---|
239
- | `sm` | 32px | `--control-height-sm` |
240
- | `md` | 40px | `--control-height-md` |
241
- | `lg` | 48px | `--control-height-lg` |
1109
+ | Level | Token / utility | Use for | Light (OKLCH L) | Dark (OKLCH L) |
1110
+ |---|---|---|---|---|
1111
+ | Base | `--color-surface-base` · `bg-surface-base` | Page / app shell (the darkest level in both themes) | `base-150` · 94 | `--zabi-base-900` `#18181b` · 21 |
1112
+ | Raised | `--color-surface-raised` · `bg-surface-raised` | Cards, panels, sidebars | `#ffffff` · 100 | `--zabi-base-850` `#1f1f22` · 24 |
1113
+ | Elevated | `--color-surface-elevated` · `bg-surface-elevated` | Nested cards, hover and active fills | `base-100` · 97 | `--zabi-base-800` `#27272a` · 27 |
1114
+ | Overlay | `--color-surface-overlay` · `bg-surface-overlay` | Modals, sheets, dropdown/select menus, navigation panels, toasts | `#ffffff` · 100 | `--zabi-base-800` `#27272a` · 27 (same tone as elevated; set apart by rim and shadow) |
1115
+ | 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 | `--zabi-base-900` `#18181b` · 21 (the page step) |
1116
+
1117
+ **Inset is not a fifth rung.** The four levels say how far a surface is lifted;
1118
+ inset says it is cut into the card it sits on. In light it is the ramp step
1119
+ below the raised surface, about 3 lightness points under it, and every text token
1120
+ passes AA on it. In dark it is the page step (3 points below the card). In
1121
+ light it shares `base-100` with `elevated`: under a white card the only
1122
+ direction left is down, so a nested card and a well are the same colour there.
1123
+ In dark the inset and the page are the same colour, so an inset area on the page
1124
+ needs an edge. `--color-input` no longer points at it in dark (it did up to
1125
+ 9.0.0-alpha.5): a dark field is a translucent step of the neutral ramp, below.
1126
+ Do not use `--color-input` for a well. It is white in light mode, because a
1127
+ light field is a raised surface.
1128
+
1129
+ **The dark field fill.** `--color-input` is base-50 at 7% over transparent in dark,
1130
+ `-hover` 10%, `-focus` 7%, `-active` 15%: a well lighter than whatever it sits on,
1131
+ with no rule per surface, as the dark flat card is a lighter layer. Measured on the
1132
+ default theme: the resting well is 1.20:1 off the page (#28282b), 1.22:1 off a card
1133
+ (#2e2e31) and 1.23:1 off the overlay (#363639); the pressed fill is 1.30:1 past
1134
+ the resting one on all three, the value text 7.34:1 or more and the placeholder
1135
+ 4.08:1 or more on every fill (4.5:1 except pressed over the overlay, held to 4:1).
1136
+ `--color-input-placeholder` is 40% of base-350 into base-400 in dark (#ababb3, was
1137
+ base-600, #a1a1aa), because a lifted fill took the old one to 4.04:1 pressed on a
1138
+ card. The well is held at 1.15:1 or more off the page, a card and the overlay in
1139
+ dark, the hover step 1.08:1 and the pressed step 1.25:1 past rest, as pairs
1140
+ laid over what is behind the field (`behind`, `only: 'dark'`, `fgPaint`), also in
1141
+ `createTheme`'s check. The edge stays 3:1 against the surface outside the field
1142
+ (`--color-input-border`, unchanged); against the lifted fill it is 3.05:1 on the
1143
+ page, 2.79:1 on a card and 2.50:1 on the overlay (guarded at 3, 2.7 and 2.4).
1144
+ Inside a colour block (`.on-brand`, `.on-accent`, `.on-fill`) a field takes the
1145
+ opaque `--color-input-solid`, `-hover`, `-focus`, `-active` (in light the field's
1146
+ own values, in dark the old opaque field), as the flat card takes the opaque card.
1147
+ The progress track is unchanged (`--color-base-75` in dark). Restore the 9.0.0-alpha.5
1148
+ field: `overrides: { dark: { '--color-input': 'var(--color-surface-inset)',
1149
+ '--color-input-hover': 'var(--color-base-200)', '--color-input-focus':
1150
+ 'var(--color-base-150)', '--color-input-active': 'color-mix(in srgb,
1151
+ var(--zabi-base-50) 10%, var(--color-surface-inset))', '--color-input-placeholder':
1152
+ 'var(--color-base-600)' } }`.
242
1153
 
243
- Enforced by `scripts/check-control-geometry.js`.
1154
+ Supporting tokens:
1155
+ - `--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.
1156
+ - `--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.
244
1157
 
245
- ## 🔲 Border Radius
1158
+ **The light ladder runs downwards.** Light cannot go lighter than white, so
1159
+ below the card it is ordered page → elevated → raised, with overlay equal to
1160
+ raised and separated from it by `shadow-lg` and the overlay edge. The page is
1161
+ `base-150`, not `base-100`: at `base-100` a card was 1.10:1 on the page and a
1162
+ nested card 1.04:1 on its parent.
246
1163
 
247
- Four radii, chosen by **role**, never by size. A large button is a bigger box
248
- with the same corner as a small one.
1164
+ **The dark levels are steps of the neutral ramp** (9.0, decisions D133/D134).
1165
+ The page is `--zabi-base-900`, the card `--zabi-base-850`, elevated and overlay
1166
+ `--zabi-base-800` (default `#18181b` / `#1f1f22` / `#27272a`, OKLCH L 21.0 /
1167
+ 24.1 / 27.4): two tone steps in all. The overlay hover is one step lighter
1168
+ (`--zabi-base-750`, `#333338`) and the inset (a field, a well) is the page step.
1169
+ The ladder lives in `tokens/surface-ladder.js` (`SURFACE_STEP`); edit the steps
1170
+ there and run `npm run sync:tokens`, never the values in `app.css`.
249
1171
 
250
- | token | value | use |
251
- |---|---|---|
252
- | `--radius-control` | 8px | buttons, inputs, selects, toggles |
253
- | `--radius-container` | 12px | cards, alerts, panels, list items, code blocks |
254
- | `--radius-overlay` | 16px | modals, sheets, menus, popovers, toasts |
255
- | `--radius-pill` | full | badges, avatars, status dots |
1172
+ Each role is a plain alias of its step and nothing else:
256
1173
 
257
- `--radius-sm/md/lg/xl` remain as legacy aliases for consumer overrides, but
258
- components must not use them — `check-control-geometry.js` fails the build if a
259
- component reaches for a t-shirt radius.
1174
+ ```css
1175
+ --color-surface-base: var(--zabi-base-900);
1176
+ --color-surface-raised: var(--zabi-base-850);
1177
+ --color-surface-overlay: var(--zabi-base-800);
1178
+ ```
1179
+
1180
+ That keeps the ramp's own hue and chroma. Until 8.1 the levels were a white
1181
+ wash mixed into the darkest neutral, which lost chroma at every level on a
1182
+ tinted neutral (`createTheme({ neutral, neutralChroma })`): the higher the
1183
+ surface, the greyer. Now `neutral: '#607296', neutralChroma: 0.08` gives a
1184
+ `#111828` page, `#18202f` card and `#202837` overlay, and a `--zabi-base-*`
1185
+ override carries all of them.
1186
+
1187
+ **An overlay is told apart by its edge and its shadow, not by being greyer.**
1188
+ Its tone equals elevated. `--color-material-rim` (14% of `--zabi-base-50`) laid
1189
+ over the overlay is 1.85:1 on the page, 1.72:1 on the card and 1.56:1 on the
1190
+ overlay itself, and the dark `--shadow-opacity` / `--shadow-opacity-contact`
1191
+ (0.5 / 0.4) are much stronger than light's. The decorative hairline
1192
+ `--color-border` is `--color-base-250` (`#333338`) in dark: 1.41:1 on the page
1193
+ and 1.31:1 on the card. A field on the page is told apart by its 3:1 edge
1194
+ (`--color-input-border`), which is guarded on the field, page, card, elevated
1195
+ and overlay surfaces in both themes.
1196
+
1197
+ Two things to know. The step distances depend on the app's neutrals: a ramp
1198
+ whose 900, 850 and 800 steps are far from the defaults can land outside the
1199
+ 2 to 5 point window the guard holds. And wherever Tailwind compiles the dark
1200
+ file (the `zabi-components/css` bundle, or an app's own build) it may add a
1201
+ static fallback, the default hex, before an alias for browsers without
1202
+ `var()`-in-colour support.
1203
+
1204
+ **Why dark mode steps lightness, and so little of it:** 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 comes from tone: each level is **lighter** than the one below it, by about 3 OKLCH lightness points, with the overlay at the elevated tone and set apart by its rim and a stronger shadow. Light mode keeps white surfaces and uses shadows for elevation.
1205
+
1206
+ **Rules:**
1207
+ - 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`.
1208
+ - Hover fills on overlays use `bg-surface-overlay-hover`, not `bg-base-*`, which would go darker in dark mode.
1209
+ - 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).
1210
+
1211
+ **Enforced by** `scripts/check-surface-elevation.js`, which `validate-theme.js` runs during `npm run build:css`. It fails when:
1212
+ - a dark level, the overlay hover or the inset is not declared as one step of the ramp (`var(--zabi-base-N)` or `var(--color-base-N)`), for example a `color-mix()` with a wash;
1213
+ - dark lightness decreases base to raised to elevated to overlay, or raised is not lighter than the page;
1214
+ - raised, elevated and overlay use more than two distinct tones above the page, a tone step is outside 2 to 5 OKLCH L points, or the top is more than 8 points above the page;
1215
+ - the rim over the overlay is under 1.5:1 on the page or the card, or under 1.3:1 on the overlay; or the dark `--color-border` is under 1.2:1 on the page or the card;
1216
+ - the light levels are not ordered base < elevated < raised, either step is under 2 points, or the overlay is darker than raised;
1217
+ - the inset surface is less than 2 points below raised, or darker than the page, in either theme;
1218
+ - a text token falls below AA on any level or on the inset surface;
1219
+ - overlay hover or tooltip fills are darker than the overlay;
1220
+ - a floating component paints a surface class below overlay.
1221
+
1222
+ To retune the dark levels, edit `SURFACE_STEP` in `tokens/surface-ladder.js`, run `npm run sync:tokens`, and then `node scripts/check-surface-elevation.js`.
1223
+
1224
+ > 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.
260
1225
 
261
- ## ✋ Interaction Fills
1226
+ ### Interaction fills
262
1227
 
263
1228
  Hover and active fills are **surface-relative alpha tints**, not fixed ramp steps:
264
1229
 
265
1230
  ```css
266
1231
  /* light */
267
- --color-surface-hover: rgba(9, 9, 11, 0.06);
268
- --color-surface-active: rgba(9, 9, 11, 0.11);
1232
+ --color-surface-hover: rgba(9, 9, 11, 0.09);
1233
+ --color-surface-active: rgba(9, 9, 11, 0.15);
269
1234
  /* dark */
270
1235
  --color-surface-hover: rgba(250, 250, 250, 0.08);
271
1236
  --color-surface-active: rgba(250, 250, 250, 0.14);
@@ -274,88 +1239,186 @@ Hover and active fills are **surface-relative alpha tints**, not fixed ramp step
274
1239
  A fixed step can coincide exactly with the surface it sits on — `base-100` in
275
1240
  dark mode *is* `--color-surface-base`, which is how ghost-button hover became
276
1241
  invisible. `check-token-violations.js` now fails on `hover:bg-base-*` /
277
- `active:bg-base-*` in component source.
1242
+ `active:bg-base-*` in component source, in `src/routes` and in the `@apply`
1243
+ lines of `src/app.css`.
1244
+
1245
+ Being one step away is not the same as being visible. `check-contrast.js`
1246
+ composites each tint over every surface level and requires 1.2:1 for the
1247
+ resting hover and the secondary button, and a further 1.08:1 for each state
1248
+ after it. Light hover was 6% ink, 1.14:1, and passed every check until then.
278
1249
 
279
1250
  Use `hover:bg-surface-hover` / `active:bg-surface-active` for quiet controls, and
280
1251
  the `action-*-hover` / `action-*-active` tokens for filled ones.
281
1252
 
282
- ## ♿ Contrast
1253
+ **State variants next to a hand-written colour class.** `text-description`,
1254
+ `border-border`, `bg-card` and the other semantic colour classes are written by
1255
+ hand in `src/app.css`, outside every cascade layer, so they beat any generated
1256
+ utility on the same element, including the state variant meant to replace
1257
+ them. `text-description hover:text-headline` compiled and never changed on
1258
+ hover. Each such variant a component uses is restated by hand in the block
1259
+ headed "STATE VARIANTS OF THE HAND-WRITTEN COLOUR CLASSES". When a component
1260
+ needs a new one, add the rule there; `tests/state-variants.test.ts`
1261
+ (`scripts/check-state-variants.js`) fails until it exists.
1262
+
1263
+ ### Contrast
283
1264
 
284
1265
  `scripts/check-contrast.js` resolves every fill/foreground pair a component can
285
1266
  render — through the same token chain the CSS uses — in **both** themes, and
286
- fails below WCAG AA. Run it after re-pointing any token:
1267
+ fails below WCAG AA. It also holds the focus ring to 3:1 against the page, a
1268
+ card, and the offset gap that separates it from a primary button. The muted
1269
+ ring (`--color-focus-ring-muted`), the danger ring (`--color-focus-ring-danger`) and the control boundary
1270
+ (`--color-control-border`), and the brand and nav rings, are held to 3:1 on
1271
+ all five surfaces: page, card, inset, elevated and overlay. The colour a focus-ring rule
1272
+ uses must be a token in that list (`scripts/contrast-pairs.js`), so a ring
1273
+ cannot read a raw ramp step.
1274
+ `--color-input-border`, the edge of a form field, is held to 3:1 on the field
1275
+ fill (`--color-input`; over the page, a card and the overlay in dark, where the fill is translucent), the page and the card (WCAG 1.4.11). The same pairs are
1276
+ in the list `createTheme` checks, so a generated theme that lets a field's edge
1277
+ fall below 3:1 is reported. Run it after
1278
+ re-pointing any token:
287
1279
 
288
1280
  ```bash
289
- npm run check:contrast # or: npm run check:design (all four guards)
1281
+ npm run check:contrast # or: npm run check:design (all five guards)
290
1282
  ```
291
1283
 
292
- ## 🧱 Surface Elevation Levels
1284
+ ### Border radius
293
1285
 
294
- Every surface uses one of four semantic levels. Use these tokens instead of raw `base-*` steps for backgrounds:
1286
+ Four radii, chosen by **role**, never by size. A large button is a bigger box
1287
+ with the same corner as a small one.
295
1288
 
296
- | Level | Token / utility | Use for | Light | Dark (OKLCH L) |
297
- |---|---|---|---|---|
298
- | Base | `--color-surface-base` · `bg-surface-base` | Page / app shell (the darkest level in dark mode) | `base-100` | `#18181b` · 21 |
299
- | Raised | `--color-surface-raised` · `bg-surface-raised` | Cards, panels, sidebars | `#ffffff` | `#262629` · 27 |
300
- | Elevated | `--color-surface-elevated` · `bg-surface-elevated` | Nested cards, hover and active fills | `base-50` | `#35353a` · 33 |
301
- | Overlay | `--color-surface-overlay` · `bg-surface-overlay` | Modals, sheets, dropdown/select menus, navigation panels, toasts | `#ffffff` | `#44444c` · 39 |
1289
+ | token | value | use |
1290
+ |---|---|---|
1291
+ | `--radius-control` | 8px | buttons, inputs, selects, toggles |
1292
+ | `--radius-container` | 12px | cards, alerts, panels, list items, code blocks |
1293
+ | `--radius-overlay` | 16px | modals, sheets, menus, popovers, toasts |
1294
+ | `--radius-pill` | full | badges, avatars, status dots |
302
1295
 
303
- Supporting tokens:
304
- - `--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.
305
- - `--color-border-overlay` (`border-border-overlay`): 1px edge on overlays. Transparent in light mode, visible in dark mode.
1296
+ `--radius-sm/md/lg/xl` remain as legacy aliases so that existing consumer
1297
+ stylesheets keep compiling, but components must not use them — `check-control-geometry.js` fails the build if a
1298
+ component reaches for a t-shirt radius.
1299
+
1300
+ ### Control geometry
306
1301
 
307
- **The dark levels are generated, not hand-picked.** Each one is `#fafafa` — the
308
- same light as the dark hover tint `--color-surface-hover` — washed over the page
309
- at 6.4% / 13.1% / 19.7%, so every level is literally the level below it with more
310
- light on it. The ladder lives in `tokens/surface-ladder.js`; edit the alphas
311
- there and run `npm run sync:tokens`, never the hex in `app.css`.
1302
+ One height scale is shared by **every** form control, so a Button, Input, Select,
1303
+ IconButton and Slider of the same size line up in a row:
312
1304
 
313
- The composite is baked to an opaque hex at build time rather than shipped as
314
- `rgba()`. Flat colours keep the static toolchain working — `check-contrast.js`
315
- can resolve every AA pair against a surface, `check-surface-elevation.js` can
316
- measure the steps — and an opaque overlay means a modal never lets the page bleed
317
- through it. The trade: nesting past the four levels is not automatic. A card
318
- inside a card does not self-lighten, it names the next level up.
1305
+ | size | height | token |
1306
+ |---|---|---|
1307
+ | `sm` | 32px | `--control-height-sm` |
1308
+ | `md` | 40px | `--control-height-md` |
1309
+ | `lg` | 48px | `--control-height-lg` |
319
1310
 
320
- **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.
1311
+ IconButton also has `xs`, a 24px box for dense pointer-first layouts. No text
1312
+ control is that small, so it sits outside the shared scale, and the check pins
1313
+ it separately.
321
1314
 
322
- **Rules:**
323
- - 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`.
324
- - Hover fills on overlays use `bg-surface-overlay-hover`, not `bg-base-*`, which would go darker in dark mode.
325
- - 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).
1315
+ Enforced by `scripts/check-control-geometry.js`.
326
1316
 
327
- **Enforced by** `scripts/check-surface-elevation.js`, which `validate-theme.js` runs during `npm run build:css`. It fails when:
328
- - dark levels aren't strictly increasing;
329
- - any step is outside 5–8 OKLCH L points;
330
- - overlay hover or tooltip fills are darker than the overlay;
331
- - a floating component paints a surface class below overlay.
1317
+ ### Materials
1318
+
1319
+ Frosted glass is the default for the layer that **floats over content**: bars,
1320
+ sheets, menus. It is never for cards, lists, inputs or the page. A material is
1321
+ a surface at partial alpha with a backdrop blur, a light top edge, a 1px rim
1322
+ and a soft shadow, applied together by one class.
1323
+
1324
+ | Class | Fill | Use for | Text it may carry |
1325
+ |---|---|---|---|
1326
+ | `material-thin` | `--color-material-thin` (from `--color-surface-chrome`) | A single control floating on a bar | headline |
1327
+ | `material-regular` | `--color-material-regular` (from `--color-surface-chrome`) | Bars, the tab bar, a floating sidebar | headline, body, label |
1328
+ | `material-thick` | `--color-material-thick` (from `--color-surface-overlay`) | Sheets, menus, modals, toasts, tooltips | all five text roles |
1329
+
1330
+ Supporting tokens: `--material-alpha-thin|regular|thick`, `--material-backdrop-brightness`, `--material-blur-thin|regular|thick`
1331
+ (16, 24 and 32px), `--material-saturate`, `--color-material-highlight`,
1332
+ `--color-material-rim` and `--shadow-material`. The highlight and rim are mixes
1333
+ of the neutral ramp (`--zabi-base-50` and `--zabi-base-900`) and the shadow is
1334
+ built on `--shadow-color`, so a neutral override tints all of them. A class
1335
+ sets no position, radius or overflow: add `rounded-*` yourself. A component
1336
+ never writes `backdrop-blur-*` or `backdrop-filter` (`scripts/check-token-violations.js`
1337
+ fails it).
1338
+
1339
+ **The floor.** A material is judged over the worst backdrop of the mode, not
1340
+ alone: the fill is composited over `--zabi-base-950` (light) or `--zabi-base-50`
1341
+ (dark), the far ends of the neutral ramp, because a photo can be that dark or
1342
+ that bright. In dark that backdrop is first dimmed: the filters are
1343
+ `blur() saturate() brightness(var(--material-backdrop-brightness))`, the token is
1344
+ `1` in light (an identity) and `0.6` in dark, and `brightness()` multiplies the
1345
+ sRGB channels, so the brightest thing behind dark glass is step 50 times 0.6.
1346
+ This is what a browser paints, because every fallback below removes the filter
1347
+ and makes the fill opaque in the same breath. Only material pairs are dimmed. On
1348
+ that result the roles above need 4.5:1, and regular and thick need the focus ring
1349
+ at 3:1 (a thin control's ring is drawn around it, outside the material); the same
1350
+ roles are also held over the mode's own page. `npm run check:contrast` and
1351
+ `createTheme` both compute this, with the mode's own value of the token, so an
1352
+ app that sets `--material-backdrop-brightness` back to 1 in dark is warned if its
1353
+ alphas then fail. The alphas for thin, regular and thick are light 58, 84, 88
1354
+ and dark 60, 68, 72: each is the smallest whole percent that passes plus a
1355
+ margin (against the page-colour chrome the floors are thin 50 / 30, regular
1356
+ 82 / 47 and thick 84 / 68, light / dark).
1357
+
1358
+ **Fallbacks.** The three fills become the opaque surface they are made from and
1359
+ the blur is removed under `prefers-reduced-transparency: reduce`,
1360
+ `prefers-contrast: more`, `forced-colors: active`, where `backdrop-filter` is
1361
+ unsupported, and wherever the app sets `data-materials="opaque"` on the root or
1362
+ any ancestor (a way out for a slow phone). The fallback re-points the tokens,
1363
+ so every use of them follows, in both modes and over an app's own fill. Under
1364
+ forced colors the rim is a 1px `CanvasText` outline. To set a fill per mode:
1365
+ `overrides: { light: { '--color-material-regular': … }, dark: { … } }`.
1366
+
1367
+ **Layers.** `backdrop-filter` makes an element the containing block of its fixed
1368
+ descendants, so a container that holds a Dropdown or a Tooltip puts the glass on
1369
+ a `::before` instead. `material-layer-thick` and `material-layer-regular` are the
1370
+ layer forms of thick and regular (same tokens, so every fallback applies): the
1371
+ element is positioned by you, never scrolls its own content, and keeps only the
1372
+ outer shadow.
1373
+
1374
+ **Bars.** `--color-surface-chrome` is the page colour in both modes, so a bar is
1375
+ flush with the page. `material-bar` is the bar surface: put it on a positioned
1376
+ element (never a scroller). At rest its `::before` layer is `--color-bar`, with
1377
+ no line and no blur; with `data-scrolled-under="true"` it is the regular material
1378
+ and a 1px `--color-bar-border` hairline on the content side (the bottom edge, or
1379
+ the top edge with `data-bar-edge="bottom"`). The fill and line fade over
1380
+ `--duration-moderate`. Under the fallbacks a scrolled bar is page coloured with the
1381
+ hairline (`CanvasText` in forced colors). Scope `--color-bar` on the element
1382
+ (`transparent`, `var(--color-bar-brand)`) and the layer follows. Tokens, each
1383
+ settable per mode:
1384
+
1385
+ | Token | Default | Use |
1386
+ |---|---|---|
1387
+ | `--color-bar` (read by `material-bar`) | `var(--color-surface-chrome)` | A bar's fill at rest |
1388
+ | `--color-bar-border` (`border-bar`) | headline at 10% | The hairline once content is under the bar; decorative |
1389
+ | `--color-bar-brand` (`bg-bar-brand`) | `var(--color-action-primary)` | An on-brand AppBar; `--color-on-brand` is held at 4.5:1 on it |
1390
+ | `--color-tabbar-active` (`bg-tabbar-active`) | `var(--color-nav-menu-active)` | The active tab's pill; `--color-nav-menu-item-active` is held at 4.5:1 on it |
332
1391
 
333
- To retune the dark levels, edit the four `--color-surface-*` values in the `.dark` block of `src/app.css` and run `node scripts/check-surface-elevation.js`.
1392
+ `--color-bar` and headline are held at 4.5:1 as well. To get the raised bar fill
1393
+ back: `--color-surface-chrome: var(--color-surface-raised)`.
334
1394
 
335
- > 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.
1395
+ ### Gradients
336
1396
 
337
- ## 🎛️ Overriding a Component's Classes
1397
+ Two gradients, as tokens, so a rebrand carries through. Contrast is never measured against a gradient; the guard checks the worst stop of each.
338
1398
 
339
- `class` is the public prop on every component and is merged **last**, so a
340
- call-site utility wins. That promise needs `cn()` (`src/components/util/cn.ts`,
341
- tailwind-merge) to be true: `rounded-control` and `rounded-container` are
342
- equal-specificity utilities, so plain concatenation leaves the winner to
343
- whichever one Tailwind emits later in the stylesheet, not to the caller.
1399
+ - **Canvas.** `--gradient-canvas` is two soft radial washes from `--color-brand-300` and `--color-accent-300` (stops `--color-canvas-wash-brand|accent`, strength `--gradient-canvas-strength`, light 55%, dark 25%). Glass over a flat page shows nothing; put `bg-canvas` (page colour plus the wash) on a fixed-height shell or a scroll container, where the wash stays put as content moves. The library never applies it to `body`. Text roles, link, focus ring and field edge are held on each stop over the page.
1400
+ - **Solid controls.** `bg-control-gradient` (primary and danger; the veil is neutral) and `bg-control-gradient-accent` lay a veil over the control's own `background-color`: a light neutral at the top, a slight dark one at the bottom (`--color-control-gradient-start|end`, `--color-control-gradient-accent-start|end`), plus an inset top edge (`--shadow-control-highlight`). Hover and pressed fills still show through. The label is held at 4.5:1 on both ends over rest, hover and pressed, for primary, accent and danger. Progress does not take a gradient.
1401
+ - Light is darken-only: `--gradient-control-top-strength: 0%` (the top is the control's own colour, so the label keeps its full contrast and your brand colour is not altered) and `--gradient-control-bottom-strength: 14%`. Dark is 22% / 6%. The accent has strengths of its own, `--gradient-control-accent-top-strength` and `-bottom-strength`, which follow the shared ones.
1402
+ - The highlight is written to Tailwind's inset-shadow variable, so a `shadow-*` class and `focus-ring` on the same element still draw.
1403
+ - `createTheme` does not hand you a veil that fails: where the darkened bottom takes a dark label under 4.5:1 (a light accent), it flips that veil to lighten-only in that mode (top up to 14%, bottom 0); if no strength passes it writes both as 0%. A strength you set yourself is never moved, only reported.
1404
+ - Override per mode with `createTheme` `overrides` and restate dark; a strength too high is reported as a contrast warning.
344
1405
 
345
- ```svelte
346
- <Card class="rounded-pill" /> <!-- rounded-container is dropped, not fought -->
347
- ```
1406
+ ### Buttons and the Toggle
348
1407
 
349
- Most semantic utilities work with stock tailwind-merge — `bg-card` vs
350
- `bg-surface-overlay`, `text-headline` vs `text-description` all resolve. The
351
- role-based radii are the exception and are declared explicitly in `cn.ts`,
352
- because `control`/`container`/`overlay`/`pill` are role names rather than scale
353
- values. **Add any future custom scale to that config**, or tailwind-merge keeps
354
- both classes and stylesheet order silently decides again.
1408
+ Tokens and classes the 9.0 Button, Toggle and FloatingActionButton read. All settable like any role; dark is restated.
355
1409
 
356
- Covered by `tests/class-merge.test.ts`.
1410
+ - **Tonal** (`bg-action-tonal`, `text-action-tonal`): `--color-action-tonal` (the brand tint), `-hover`, `-active`, and `--color-action-tonal-text` (brand step 800, 4.5:1 or more on all three fills in both modes; step 700 fails the pressed fill on a saturated brand).
1411
+ - **Outline edge** (`border-action-outline`): `--color-action-outline-border` and `-hover`, the field edge by default (3:1 on the page and a card in both modes). Inside `.on-brand` and `.on-accent` it is the block's label colour.
1412
+ - **Button corner and label weight**, apart from fields: `rounded-button` reads `--zabi-button-radius` and falls back to `--radius-control`; `font-button` (500) and `font-button-strong` (600) read `--zabi-button-font-weight`. Declared nowhere, so set them on any ancestor (or in `createTheme` `overrides`).
1413
+ - **Off Toggle** (`bg-toggle-track`, `ring-toggle-track`): `--color-toggle-track`, `-hover`, `-active` and `--color-toggle-track-border`. The track is a light tint; its edge is what holds 3:1 on the page and a card (WCAG 1.4.11). `--color-control-track*` is unchanged and still the Slider's. **Toggle knob** (`bg-toggle-thumb`, `bg-toggle-thumb-checked`): the default knob is `--color-toggle-knob` off (light base-500, dark base-700) and `--color-toggle-knob-checked` on (the primary fill's label colour), each 3:1 or more on every track fill it sits on, at rest, hovered and pressed (WCAG 1.4.11; guarded, 3.27:1 at the least in light off, 4.86:1 on). A theme sets those two, light and dark each in their own block, and `scripts/check-contrast.js` holds the pairs. `--color-toggle-thumb` and `--color-toggle-thumb-checked` are declared nowhere, so any ancestor can set them and they win: the first is the knob in both states (the checked state reads it when its own is unset), the second the knob when on only. Contrast of an override is yours to check. In forced colours the knob is a system colour and none of the tokens is read.
1414
+ - **Neutral subtle Badge** (`bg-badge-neutral`, `text-badge-neutral`): `--zabi-badge-neutral-fill` and `--zabi-badge-neutral-text`, declared nowhere, read in front of `--color-neutral-subtle` and `--color-neutral-text`. Set them on any ancestor or in the theme (light and dark each in their own block) and the neutral subtle `Badge` moves; `Alert variant="neutral"`, `Skeleton`, `AvatarGroup`, `Header` and the other badge variants do not. Unset, the badge is as it was. Inside `.on-brand`, `.on-accent` and `.on-fill` the text is the theme's body colour again, as for any neutral surface; the fill follows the property. Contrast of the pair is yours to check (the default is 8.2:1 in light).
1415
+ - **Skeleton on a colour block** (`tone="on-color"`, `bg-skeleton-on-color`): the fill is the surface's label colour at 24% (`color-mix(in srgb, currentColor 24%, transparent)`), so inside `.on-brand`, `.on-accent`, `.on-fill` or a `Card` with `tone` it follows the block's label colour in light and dark. `--zabi-skeleton-fill`, declared nowhere, replaces it on any ancestor. The class is not in the lists that restore the theme's text on a surface inside a block, on purpose. The default `tone="neutral"` is `bg-neutral-subtle`, unchanged.
1416
+ - **Chip** (`bg-chip`, `text-chip-text`): `--color-chip`, `-hover`, `-active` (light base-200, 300, 350; dark base-750, 700, 650) and `--color-chip-text` (the headline, 4.5:1 on all three). Seen on the page and a card without an edge, 1.2:1 or more, guarded. **Selected chip** (`bg-chip-selected`, `text-chip-selected-text`): `--color-chip-selected`, `-hover`, `-active`, `-text`, the tonal role by default, as tokens of their own, with the label held on all three fills.
1417
+ - **Tint card** (`bg-card-tint`): `--color-card-tint`, brand-100 in light and the brand at 8% over the card in dark. Headline, body, label, description, caption and link are held to 4.5:1 on it, and the ring, the field edge and the outline Button's edge to 3:1, in both modes.
1418
+ - **Flat card** (`bg-card-flat`): `--color-card-flat`, `-hover`, `-active`. Light is the card, hover and pressed fills, opaque, as before. Dark is the neutral ramp's base-50 at 7%, 11% and 15% over transparent, so a flat card is a lighter layer of whatever is behind it: on the page (#18181b) one layer is #282829, on a card (#1f1f22) #2e2e31, on the overlay (#27272a) #363638, and each nested layer is another 1.2:1 to 1.25:1 step. It is derived from the ramp, so `neutralChroma` tints it. Restore the opaque fill with `overrides: { dark: { '--color-card-flat': 'var(--color-card)' } }` (and `-hover`, `-active` for an interactive flat card). Held in dark: the five text roles, the link and the ring on three layers over the card, two over the overlay and one over the page (the caption is 4.1:1 on three over the overlay, so that stack is not held), and the field edge and the outline Button's edge at 3:1 on one layer over the page (3.03:1; on a layer over a card or an overlay they are 2.8:1 or less, so a field there keeps its own fill). Inside a `tone` card (`.on-brand`, `.on-accent`, `.on-fill`) a flat card is the opaque card with the theme's text, in dark too, as `bg-card` was: a translucent step over the block's fill would carry the theme's text at about 2.3:1, and the label colour at 4.4:1.
1419
+ - **Progress track edge** (`border-progress-track-border`): `--color-progress-track-border`, the field edge by default (3:1 on the page and a card in both modes, because the default track is the field fill and does not reach it). `transparent` skips the guard. `createTheme` writes `transparent` for a mode in which you override `--color-progress-track` with a colour that itself reaches 3:1 on the page and the card, unless you set the edge; an edge you set, `transparent` included, is never moved.
357
1420
 
358
- ## 🌑 Shadow Scale
1421
+ ### Shadow scale
359
1422
 
360
1423
  Elevation in light mode is **two steps**, not a ramp. A shadow says "this is
361
1424
  above the page" or "this floats over it" — there is no third meaning, and an
@@ -378,11 +1441,15 @@ Rules:
378
1441
  `shadow` utility is the sneaky one: it resolves to a Tailwind default that
379
1442
  `app.css` never defines, so no token controls it.
380
1443
  - Shadows carry elevation in **light mode only**. In dark mode the surface
381
- levels above do the work — see Surface Elevation Levels.
1444
+ levels above do the work — see [Surface elevation levels](#surface-elevation-levels).
1445
+ - The light shadow is the ramp's own ink (`--shadow-color: 24 24 27`). Every
1446
+ step is two layers: a tight contact shadow at `--shadow-opacity-contact`
1447
+ (0.06; dark 0.3) and a wider, softer one at `--shadow-opacity` (0.10; dark
1448
+ 0.24). Until 8.1 both layers shared one flat 0.14 (dark 0.3).
382
1449
 
383
1450
  Enforced by `scripts/check-token-violations.js`.
384
1451
 
385
- ## 📏 Spacing Rhythm
1452
+ ### Spacing rhythm
386
1453
 
387
1454
  Layout spacing rides a **4px grid**. Half-steps (`gap-1.5`, `px-2.5`, `py-2.5`)
388
1455
  set up a second rhythm competing with the first: a 6px gap beside an 8px gap is
@@ -398,7 +1465,7 @@ a 2px wobble nobody chose, and it accumulates down a dense form.
398
1465
 
399
1466
  Enforced by `scripts/check-token-violations.js`.
400
1467
 
401
- ## 🔠 Type Scale
1468
+ ### Type scale
402
1469
 
403
1470
  `Heading` and `Text` sit on **one** ramp rather than each carrying its own, so a
404
1471
  heading and the copy beneath it share line boxes instead of nearly matching.
@@ -414,65 +1481,158 @@ heading and the copy beneath it share line boxes instead of nearly matching.
414
1481
  | 14px | — | `size="sm"` |
415
1482
  | 12px | — | `size="xs"` |
416
1483
 
417
- Headings tighten tracking as they grow and are `semibold`/`bold`; `Text` takes a
1484
+ Headings use `--font-family-heading` (the sans family unless an app sets it),
1485
+ tighten tracking as they grow and are `semibold`/`bold`; `Text` takes a
418
1486
  `weight` prop (`normal` · `medium` · `semibold` · `bold`) so body copy can carry
419
1487
  emphasis without being promoted to a heading. `tone="label"` defaults to
420
1488
  `medium`; every other tone defaults to `normal`.
421
1489
 
422
- ## 📁 File Structure
1490
+ ### Overriding a component's classes
1491
+
1492
+ `class` is the public prop on every component and is merged **last**, so a
1493
+ call-site utility wins. That promise needs `cn()` (`src/components/util/cn.ts`,
1494
+ tailwind-merge) to be true: `rounded-control` and `rounded-container` are
1495
+ equal-specificity utilities, so plain concatenation leaves the winner to
1496
+ whichever one Tailwind emits later in the stylesheet, not to the caller.
423
1497
 
1498
+ ```svelte
1499
+ <Card class="rounded-pill" /> <!-- rounded-container is dropped, not fought -->
424
1500
  ```
425
- src/
426
- └── app.css ← 🎯 EDIT THIS FILE FOR ALL THEMING (single source of truth)
427
1501
 
428
- dist/ ← ⚠️ GENERATED FILES - DON'T EDIT
429
- ├── zabi-components.css (full CSS with dark mode)
430
- ├── zabi-components-theme.css (light mode only)
431
- ├── zabi-components-theme-only.css (light mode only)
432
- ├── zabi-components-theme-dark.css (dark mode only)
433
- ├── zabi-components-theme-dark-only.css (dark mode only)
434
- └── zabi-components-colors.css (standalone, both modes)
1502
+ Most semantic utilities work with stock tailwind-merge — `bg-card` vs
1503
+ `bg-surface-overlay`, `text-headline` vs `text-description` all resolve. The
1504
+ role-based radii are the exception and are declared explicitly in `cn.ts`,
1505
+ because `control`/`container`/`overlay`/`pill` are role names rather than scale
1506
+ values. **Add any future custom scale to that config**, or tailwind-merge keeps
1507
+ both classes and stylesheet order silently decides again.
1508
+
1509
+ Covered by `tests/class-merge.test.ts`.
435
1510
 
436
- examples/ ← ⚠️ EXAMPLES ONLY - DON'T EDIT
437
- └── theme-extensions/
438
- ├── 02-custom-brand-colors.css
439
- └── 03-custom-semantic-colors.css
440
- ```
1511
+ ---
441
1512
 
442
- ## ⚠️ Important Notes
1513
+ ## For maintainers
443
1514
 
444
- 1. **Never edit files in `dist/`** - These are automatically generated from `src/app.css` by `scripts/build-css.js`
445
- 2. **All theming is in `src/app.css`** - This is the single source of truth
446
- 3. **Always edit `src/app.css`** - This is your single source of truth
447
- 4. The `examples/` folder contains examples, not your actual theme
448
- 5. **Dist files are overwritten** every time you run `npm run build:css`
1515
+ This part is about changing the library's own theme, not an app's.
449
1516
 
450
- ## 🔄 Build Process
1517
+ ### One source file
451
1518
 
452
- When you edit `src/app.css`:
1519
+ All theming is in `src/app.css`:
453
1520
 
454
- 1. **Development**: Changes are automatically reflected (no build needed)
455
- 2. **Production**: Run `npm run build:css` to regenerate dist files
456
- 3. The build script (`scripts/build-css.js`) reads `src/app.css` and generates:
457
- - `zabi-components.css` - Full compiled CSS with Tailwind processed (includes dark mode)
458
- - `zabi-components-theme.css` - Theme block with Tailwind import (light mode only)
459
- - `zabi-components-theme-only.css` - Theme block only (light mode only)
460
- - `zabi-components-theme-dark.css` - Dark mode theme with Tailwind import
461
- - `zabi-components-theme-dark-only.css` - Dark mode theme only
462
- - `zabi-components-colors.css` - Standalone CSS custom properties (both light and dark)
1521
+ - the `@theme` block holds every token and the light values, including the raw
1522
+ `--zabi-*` ramps, declared once;
1523
+ - one `.dark { … }` rule remaps roles for dark. No `--zabi-*` token and no hex
1524
+ literal belongs in it; `validate-theme.js` fails the build on either.
463
1525
 
464
- ## 🔄 After Making Changes
1526
+ The files under `dist/` are generated from it by `scripts/build-css.js` and are
1527
+ overwritten on every build. Never edit them. Which file is which is in
1528
+ [docs/theme-imports.md](./docs/theme-imports.md). `examples/theme-extensions/`
1529
+ holds examples, not the theme.
465
1530
 
466
- ### For Development
467
- After editing `src/app.css`, changes are automatically reflected in your app. No build needed!
1531
+ Parts of `src/app.css` are generated too, so edit the source instead:
468
1532
 
469
- ### For Production/Library Build
470
- If you're building the library for distribution:
471
- ```bash
472
- npm run build:css # Regenerates all dist CSS files from src/app.css
1533
+ | To change | Edit | Then run |
1534
+ |---|---|---|
1535
+ | A chromatic ramp (`brand`, `citron`, `pine`, `iris`, `warning`, `error`) | `hue` / `peakChroma` in `tokens/chromatic-scales.js` | `npm run sync:tokens` |
1536
+ | The neutral ramp | `tokens/base-scale.js` | `npm run sync:tokens` |
1537
+ | The dark surface levels | `SURFACE_STEP` in `tokens/surface-ladder.js` | `npm run sync:tokens` |
1538
+ | A role, a font, a radius | `src/app.css` | nothing in development |
1539
+
1540
+ Before a release, `npm run build:css` regenerates the `dist/` files and
1541
+ `npm run build:lib` does that and verifies them (`scripts/verify-build.js`).
1542
+
1543
+ ### Dark mode selectors
1544
+
1545
+ `src/app.css` holds one `.dark { … }` rule. `scripts/build-css.js` publishes it,
1546
+ in every file that carries dark tokens, as:
1547
+
1548
+ ```css
1549
+ :root, [data-theme="light"] { color-scheme: light; } /* in the light files */
1550
+ [data-theme="light"] { color-scheme: light; }
1551
+ [data-theme="dark"] { color-scheme: dark; }
1552
+ [data-theme="auto"] { color-scheme: light dark; }
1553
+ .dark, [data-theme="dark"] { …; color-scheme: dark; }
1554
+ @media (prefers-color-scheme: dark) { [data-theme="auto"] { …; color-scheme: dark; } }
473
1555
  ```
474
1556
 
475
- ## Optional perceptual midpoint generation (OKLCH)
1557
+ - The light rule is written by hand in `src/app.css`, above `.dark`, and has
1558
+ to stay above it: all of these have the specificity of one class, so the
1559
+ later one wins. `validate-theme.js` fails the build if a light file lacks
1560
+ the rule or has it after a dark one.
1561
+ - `color-scheme: dark` is the last declaration of the source `.dark` rule, so
1562
+ every dark selector carries it. The three attribute rules come first: with
1563
+ `class="dark" data-theme="light"` the class brings the dark tokens, and so
1564
+ the dark scheme with them.
1565
+ - All selectors have the specificity of `.dark`.
1566
+ - Keep it as one `.dark` rule in the source and do not write the others by
1567
+ hand; `scripts/dark-selectors.js` generates them.
1568
+ - The dev site and Storybook read `src/app.css` directly. `postcss.config.cjs`
1569
+ runs the same expansion on it, so `data-theme` works there as it does in the
1570
+ published files.
1571
+
1572
+ `validate-theme.js` fails the build if a dark-carrying file lacks any of the
1573
+ three, or if the `auto` copy differs from `.dark`.
1574
+
1575
+ ### Dark mode action colours
1576
+
1577
+ - **Primary.** The same physical steps as light: `.dark` points
1578
+ `--color-action-primary`, `-hover` and `-active` at `--zabi-brand-600`, `700`
1579
+ and `800` (not the mirrored `--color-brand-*`, which are the pale 400, 300
1580
+ and 200). The label is `--color-on-brand`, which `.dark` points at
1581
+ `--zabi-on-brand-dark` (white). The fill is 3.72:1 on the dark page, 3.43:1
1582
+ on a card and 3.10:1 on the overlay, held by guarded pairs. The ring, links,
1583
+ the tonal roles and the nav-active roles stay on the mirrored steps (they are
1584
+ text, rings and tints on a dark surface).
1585
+ `--color-primary*` is unchanged (still mirrored; no component reads it).
1586
+ - **Status tints.** In dark, `--color-<success|warning|error|info|energetic|accent>-subtle`
1587
+ is the hue thinned into the card surface: `color-mix(in srgb, var(--zabi-<ramp>-500) 16%, var(--color-surface-raised))`;
1588
+ `-border` is the same mix at 36%. A badge or alert is a low-chroma tint of its
1589
+ card (never darker than it) and the hue is carried by the icon and the text,
1590
+ which are unchanged. Because the base is a `var()`, an app's override of the
1591
+ card surface (or `neutralChroma`) carries through. `neutral` and the brand
1592
+ tints are unchanged.
1593
+ - **Secondary.** No dark override: `--color-base-*` mirrors the same way.
1594
+ - **Danger.** The same physical steps as light danger (`--zabi-error-600`,
1595
+ `700`, `800`, label `--zabi-error-50`), so a destructive button is one red in
1596
+ both modes. Its `-subtle` tint is the error tint (`--color-error-subtle`), with
1597
+ `-subtle-hover` (20%) and `-subtle-active` (32%) stronger mixes. Restated in `.dark` so that
1598
+ `zabi-components/theme-dark-only` remains a complete standalone import
1599
+ (`validate-theme.js` enforces that).
1600
+
1601
+ Hover and active move toward the dark end for the primary and danger fills in
1602
+ both modes (one recipe). For the mirrored roles the mirror flips that, so "more
1603
+ pressed" means "more contrast against the page" in both themes. Never point `-active` at a step on the far side of the ramp; that is
1604
+ how the pressed primary label once ended up at 1.66:1.
1605
+
1606
+ ### Building the generator
1607
+
1608
+ The source is `create-theme/`. It ships as plain ESM in `dist/create-theme/`
1609
+ and needs `culori` at runtime, so `culori` is a dependency.
1610
+ `scripts/build-create-theme.js` copies it and writes `theme-data.js`, the
1611
+ default theme and the pair list, from `src/app.css` and
1612
+ `scripts/contrast-pairs.js` on every build. `verify-build.js` fails if that
1613
+ file differs from its sources, and runs the bin from `dist/`. Tests:
1614
+ `tests/create-theme.test.js`, part of `npm run test:themes`.
1615
+
1616
+ ### Where the two-brand proof lives
1617
+
1618
+ - `tests/theme-output.test.js` (`npm run test:themes`) proves from the built
1619
+ CSS that one ramp override rebrands light and dark, and keeps the list of
1620
+ published token names.
1621
+ - The docs site runs under two brands: the default, and Amber, which
1622
+ `vite-plugin-brand-themes.js` builds with `createTheme` from the inputs in
1623
+ `src/lib/marketing/brand-inputs.ts` each time the site starts or builds. The
1624
+ switcher is in the top bar and on `/theming`; `?brand=amber` opens any page
1625
+ in it.
1626
+ - `playwright/theme-brands.spec.ts` (`npm run test:e2e`) loads component pages
1627
+ under both brands, in light and dark, at desktop width and at 375px, and
1628
+ checks from computed styles that the primary fill, its label, the focus ring,
1629
+ the card surface and the heading face change with the brand and that the
1630
+ label keeps 4.5:1.
1631
+ - `tests/theming-guide.test.ts` (`npm test`) fails if this guide, `THEME.md`,
1632
+ `docs/theme-imports.md` or the site's token tables name a token the theme
1633
+ does not publish.
1634
+
1635
+ ### Optional perceptual midpoint generation (OKLCH)
476
1636
 
477
1637
  Base midpoints are frozen hex by default (`fixed` mode).
478
1638
  For experimentation, you can generate midpoint steps in OKLCH interpolation and commit the resulting frozen values: