zabi-components 8.1.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 (273) hide show
  1. package/CHANGELOG.md +1011 -0
  2. package/README.md +1 -1
  3. package/THEME.md +18 -5
  4. package/THEMING.md +308 -74
  5. package/dist/atoms/ActionPanel.svelte +1 -1
  6. package/dist/atoms/Avatar.svelte +19 -3
  7. package/dist/atoms/Avatar.svelte.d.ts +12 -0
  8. package/dist/atoms/Badge.svelte +37 -8
  9. package/dist/atoms/Badge.svelte.d.ts +6 -0
  10. package/dist/atoms/Block.svelte +74 -0
  11. package/dist/atoms/Block.svelte.d.ts +29 -0
  12. package/dist/atoms/Button.svelte +52 -15
  13. package/dist/atoms/Button.svelte.d.ts +3 -2
  14. package/dist/atoms/Card.svelte +181 -25
  15. package/dist/atoms/Card.svelte.d.ts +55 -3
  16. package/dist/atoms/Checkbox.svelte +33 -0
  17. package/dist/atoms/Checkbox.svelte.d.ts +27 -0
  18. package/dist/atoms/Chip.svelte +351 -0
  19. package/dist/atoms/Chip.svelte.d.ts +57 -0
  20. package/dist/atoms/CodeField.svelte +385 -0
  21. package/dist/atoms/CodeField.svelte.d.ts +61 -0
  22. package/dist/atoms/ColorPicker.svelte +1 -0
  23. package/dist/atoms/DateField.svelte +41 -3
  24. package/dist/atoms/DateField.svelte.d.ts +31 -3
  25. package/dist/atoms/FloatingActionButton.svelte +5 -3
  26. package/dist/atoms/FloatingActionButton.svelte.d.ts +1 -1
  27. package/dist/atoms/Heading.svelte +27 -1
  28. package/dist/atoms/Heading.svelte.d.ts +7 -0
  29. package/dist/atoms/IconButton.svelte +81 -9
  30. package/dist/atoms/IconButton.svelte.d.ts +13 -0
  31. package/dist/atoms/Input.svelte +10 -5
  32. package/dist/atoms/Input.svelte.d.ts +3 -2
  33. package/dist/atoms/List.svelte +3 -2
  34. package/dist/atoms/ListItem.svelte +31 -7
  35. package/dist/atoms/ListItem.svelte.d.ts +6 -0
  36. package/dist/atoms/PickerField.svelte +239 -0
  37. package/dist/atoms/PickerField.svelte.d.ts +67 -0
  38. package/dist/atoms/Progress.svelte +78 -17
  39. package/dist/atoms/Progress.svelte.d.ts +11 -1
  40. package/dist/atoms/Radio.svelte +32 -0
  41. package/dist/atoms/Radio.svelte.d.ts +26 -0
  42. package/dist/atoms/Rating.svelte +132 -6
  43. package/dist/atoms/Rating.svelte.d.ts +24 -3
  44. package/dist/atoms/Select.svelte +24 -26
  45. package/dist/atoms/Select.svelte.d.ts +12 -0
  46. package/dist/atoms/SelectionControl.svelte +99 -6
  47. package/dist/atoms/SelectionControl.svelte.d.ts +25 -0
  48. package/dist/atoms/Skeleton.svelte +114 -18
  49. package/dist/atoms/Skeleton.svelte.d.ts +30 -3
  50. package/dist/atoms/SkeletonGroup.svelte +43 -0
  51. package/dist/atoms/SkeletonGroup.svelte.d.ts +20 -0
  52. package/dist/atoms/Slider.svelte +35 -0
  53. package/dist/atoms/Sparkline.svelte +109 -0
  54. package/dist/atoms/Sparkline.svelte.d.ts +27 -0
  55. package/dist/atoms/SpeechBubble.svelte +187 -0
  56. package/dist/atoms/SpeechBubble.svelte.d.ts +33 -0
  57. package/dist/atoms/Stat.svelte +72 -0
  58. package/dist/atoms/Stat.svelte.d.ts +19 -0
  59. package/dist/atoms/TemporalField.svelte +342 -10
  60. package/dist/atoms/TemporalField.svelte.d.ts +20 -4
  61. package/dist/atoms/Textarea.svelte +100 -5
  62. package/dist/atoms/Textarea.svelte.d.ts +21 -0
  63. package/dist/atoms/TimeColumn.svelte +150 -0
  64. package/dist/atoms/TimeColumn.svelte.d.ts +25 -0
  65. package/dist/atoms/TimeField.svelte +46 -3
  66. package/dist/atoms/TimeField.svelte.d.ts +34 -3
  67. package/dist/atoms/Toast.svelte +6 -5
  68. package/dist/atoms/Toggle.svelte +61 -10
  69. package/dist/atoms/Toggle.svelte.d.ts +15 -0
  70. package/dist/atoms/Tooltip.svelte +20 -1
  71. package/dist/atoms/Tooltip.svelte.d.ts +7 -0
  72. package/dist/atoms/index.d.ts +21 -0
  73. package/dist/atoms/index.js +17 -0
  74. package/dist/atoms/selection-control.styles.d.ts +15 -4
  75. package/dist/atoms/selection-control.styles.js +16 -5
  76. package/dist/components/atoms/index.d.ts +21 -0
  77. package/dist/components/atoms/index.d.ts.map +1 -1
  78. package/dist/components/atoms/selection-control.styles.d.ts +15 -4
  79. package/dist/components/atoms/selection-control.styles.d.ts.map +1 -1
  80. package/dist/components/index.d.ts +27 -3
  81. package/dist/components/index.d.ts.map +1 -1
  82. package/dist/components/map.d.ts +11 -0
  83. package/dist/components/map.d.ts.map +1 -0
  84. package/dist/components/molecules/index.d.ts +7 -2
  85. package/dist/components/molecules/index.d.ts.map +1 -1
  86. package/dist/components/molecules/toast-store.d.ts +30 -0
  87. package/dist/components/molecules/toast-store.d.ts.map +1 -1
  88. package/dist/components/organisms/index.d.ts +6 -1
  89. package/dist/components/organisms/index.d.ts.map +1 -1
  90. package/dist/components/types/variants.d.ts +12 -3
  91. package/dist/components/types/variants.d.ts.map +1 -1
  92. package/dist/components/util/app-shell.d.ts +55 -4
  93. package/dist/components/util/app-shell.d.ts.map +1 -1
  94. package/dist/components/util/avatar.d.ts +3 -0
  95. package/dist/components/util/avatar.d.ts.map +1 -1
  96. package/dist/components/util/bottom-tab-bar.d.ts +6 -0
  97. package/dist/components/util/bottom-tab-bar.d.ts.map +1 -1
  98. package/dist/components/util/chart.d.ts +140 -0
  99. package/dist/components/util/chart.d.ts.map +1 -0
  100. package/dist/components/util/chip.d.ts +66 -0
  101. package/dist/components/util/chip.d.ts.map +1 -0
  102. package/dist/components/util/cn.d.ts.map +1 -1
  103. package/dist/components/util/code-field.d.ts +63 -0
  104. package/dist/components/util/code-field.d.ts.map +1 -0
  105. package/dist/components/util/collapsible.d.ts +6 -0
  106. package/dist/components/util/collapsible.d.ts.map +1 -1
  107. package/dist/components/util/display-voice.d.ts +10 -0
  108. package/dist/components/util/display-voice.d.ts.map +1 -0
  109. package/dist/components/util/field-control.d.ts +30 -0
  110. package/dist/components/util/field-control.d.ts.map +1 -0
  111. package/dist/components/util/field.d.ts +6 -0
  112. package/dist/components/util/field.d.ts.map +1 -1
  113. package/dist/components/util/focus-utils.d.ts +2 -0
  114. package/dist/components/util/focus-utils.d.ts.map +1 -1
  115. package/dist/components/util/map-view.d.ts +144 -0
  116. package/dist/components/util/map-view.d.ts.map +1 -0
  117. package/dist/components/util/photo.d.ts +19 -0
  118. package/dist/components/util/photo.d.ts.map +1 -1
  119. package/dist/components/util/progress.d.ts +19 -0
  120. package/dist/components/util/progress.d.ts.map +1 -0
  121. package/dist/components/util/scroll-edge.d.ts +33 -0
  122. package/dist/components/util/scroll-edge.d.ts.map +1 -0
  123. package/dist/components/util/sheet-drag.d.ts +25 -1
  124. package/dist/components/util/sheet-drag.d.ts.map +1 -1
  125. package/dist/components/util/skeleton.d.ts +15 -0
  126. package/dist/components/util/skeleton.d.ts.map +1 -0
  127. package/dist/components/util/sliding-indicator.d.ts +25 -0
  128. package/dist/components/util/sliding-indicator.d.ts.map +1 -0
  129. package/dist/components/util/temporal-field.d.ts +89 -0
  130. package/dist/components/util/temporal-field.d.ts.map +1 -0
  131. package/dist/components/util/toaster.d.ts +40 -0
  132. package/dist/components/util/toaster.d.ts.map +1 -1
  133. package/dist/components/util/tone-surface.d.ts +34 -0
  134. package/dist/components/util/tone-surface.d.ts.map +1 -0
  135. package/dist/components/util/top-navbar.d.ts +26 -2
  136. package/dist/components/util/top-navbar.d.ts.map +1 -1
  137. package/dist/components/util/zabi-strings.d.ts +9 -0
  138. package/dist/components/util/zabi-strings.d.ts.map +1 -1
  139. package/dist/create-theme/cli.js +6 -2
  140. package/dist/create-theme/index.d.ts +17 -3
  141. package/dist/create-theme/index.js +252 -35
  142. package/dist/create-theme/lib/resolve.js +97 -0
  143. package/dist/create-theme/theme-data.js +1618 -90
  144. package/dist/index.d.ts +27 -3
  145. package/dist/index.js +18 -0
  146. package/dist/map.d.ts +10 -0
  147. package/dist/map.js +9 -0
  148. package/dist/molecules/Alert.svelte +31 -12
  149. package/dist/molecules/Alert.svelte.d.ts +12 -1
  150. package/dist/molecules/AppBar.svelte +287 -28
  151. package/dist/molecules/AppBar.svelte.d.ts +62 -0
  152. package/dist/molecules/AvatarGroup.svelte +1 -1
  153. package/dist/molecules/BarChart.svelte +381 -0
  154. package/dist/molecules/BarChart.svelte.d.ts +45 -0
  155. package/dist/molecules/BottomSheet.svelte +88 -20
  156. package/dist/molecules/BottomSheet.svelte.d.ts +17 -1
  157. package/dist/molecules/BottomTabBar.svelte +134 -28
  158. package/dist/molecules/BottomTabBar.svelte.d.ts +36 -2
  159. package/dist/molecules/Calendar.svelte.d.ts +1 -1
  160. package/dist/molecules/ChipGroup.svelte +325 -0
  161. package/dist/molecules/ChipGroup.svelte.d.ts +41 -0
  162. package/dist/molecules/Collapsible.svelte +158 -54
  163. package/dist/molecules/Collapsible.svelte.d.ts +23 -6
  164. package/dist/molecules/CollapsibleGroup.svelte +16 -6
  165. package/dist/molecules/ConfirmDialog.svelte +5 -1
  166. package/dist/molecules/ConfirmDialog.svelte.d.ts +3 -0
  167. package/dist/molecules/Drawer.svelte +33 -12
  168. package/dist/molecules/Dropdown.svelte +9 -1
  169. package/dist/molecules/Dropdown.svelte.d.ts +2 -0
  170. package/dist/molecules/DropdownItem.svelte +1 -1
  171. package/dist/molecules/ImageUpload.svelte +230 -120
  172. package/dist/molecules/ImageUpload.svelte.d.ts +38 -3
  173. package/dist/molecules/LineChart.svelte +459 -0
  174. package/dist/molecules/LineChart.svelte.d.ts +53 -0
  175. package/dist/molecules/MapView.svelte +565 -0
  176. package/dist/molecules/MapView.svelte.d.ts +88 -0
  177. package/dist/molecules/Modal.svelte +11 -6
  178. package/dist/molecules/PhotoGrid.svelte +100 -10
  179. package/dist/molecules/PhotoGrid.svelte.d.ts +11 -0
  180. package/dist/molecules/PhotoViewer.svelte +121 -24
  181. package/dist/molecules/PhotoViewer.svelte.d.ts +18 -0
  182. package/dist/molecules/PieChart.svelte +421 -0
  183. package/dist/molecules/PieChart.svelte.d.ts +51 -0
  184. package/dist/molecules/RadioGroup.svelte +20 -4
  185. package/dist/molecules/RadioGroup.svelte.d.ts +5 -0
  186. package/dist/molecules/SegmentedControl.svelte +136 -3
  187. package/dist/molecules/SegmentedControl.svelte.d.ts +6 -0
  188. package/dist/molecules/SidebarFooter.svelte +1 -1
  189. package/dist/molecules/SidebarNavSection.svelte +1 -1
  190. package/dist/molecules/SortableList.svelte +1 -0
  191. package/dist/molecules/Stepper.svelte +21 -7
  192. package/dist/molecules/StickyActionBar.svelte +67 -8
  193. package/dist/molecules/StickyActionBar.svelte.d.ts +14 -1
  194. package/dist/molecules/Tabs.svelte +79 -2
  195. package/dist/molecules/Toaster.svelte +171 -7
  196. package/dist/molecules/Toaster.svelte.d.ts +60 -2
  197. package/dist/molecules/ToasterToast.svelte +292 -60
  198. package/dist/molecules/ToasterToast.svelte.d.ts +6 -0
  199. package/dist/molecules/UnsavedChangesBar.svelte +13 -2
  200. package/dist/molecules/index.d.ts +7 -2
  201. package/dist/molecules/index.js +4 -0
  202. package/dist/molecules/toast-store.d.ts +30 -0
  203. package/dist/molecules/toast-store.js +4 -1
  204. package/dist/organisms/AppNavigation.svelte +440 -0
  205. package/dist/organisms/AppNavigation.svelte.d.ts +72 -0
  206. package/dist/organisms/AppShell.svelte +539 -39
  207. package/dist/organisms/AppShell.svelte.d.ts +102 -6
  208. package/dist/organisms/AppShellHeaderPart.svelte +163 -0
  209. package/dist/organisms/AppShellHeaderPart.svelte.d.ts +57 -0
  210. package/dist/organisms/SidebarNavigation.svelte +23 -36
  211. package/dist/organisms/SidebarPanel.svelte +10 -14
  212. package/dist/organisms/SidebarPanel.svelte.d.ts +1 -0
  213. package/dist/organisms/SidebarShell.svelte +2 -3
  214. package/dist/organisms/TopNavbar.svelte +117 -28
  215. package/dist/organisms/TopNavbar.svelte.d.ts +22 -1
  216. package/dist/organisms/TopNavbarSheetContent.svelte +145 -0
  217. package/dist/organisms/TopNavbarSheetContent.svelte.d.ts +24 -0
  218. package/dist/organisms/index.d.ts +6 -1
  219. package/dist/organisms/index.js +4 -0
  220. package/dist/types/components.d.ts +17 -1
  221. package/dist/types/components.d.ts.map +1 -1
  222. package/dist/types/index.d.ts +497 -10
  223. package/dist/types/index.d.ts.map +1 -1
  224. package/dist/types/variants.ts +14 -3
  225. package/dist/util/app-shell.d.ts +55 -4
  226. package/dist/util/app-shell.js +32 -7
  227. package/dist/util/avatar.d.ts +3 -0
  228. package/dist/util/bottom-tab-bar.d.ts +6 -0
  229. package/dist/util/chart.d.ts +139 -0
  230. package/dist/util/chart.js +305 -0
  231. package/dist/util/chip.d.ts +65 -0
  232. package/dist/util/chip.js +58 -0
  233. package/dist/util/cn.js +28 -2
  234. package/dist/util/code-field.d.ts +62 -0
  235. package/dist/util/code-field.js +112 -0
  236. package/dist/util/collapsible.d.ts +6 -0
  237. package/dist/util/display-voice.d.ts +9 -0
  238. package/dist/util/display-voice.js +9 -0
  239. package/dist/util/field-control.d.ts +29 -0
  240. package/dist/util/field-control.js +32 -0
  241. package/dist/util/field.d.ts +6 -0
  242. package/dist/util/focus-utils.d.ts +2 -0
  243. package/dist/util/focus-utils.js +4 -0
  244. package/dist/util/map-view.d.ts +143 -0
  245. package/dist/util/map-view.js +121 -0
  246. package/dist/util/photo.d.ts +19 -0
  247. package/dist/util/photo.js +3 -0
  248. package/dist/util/progress.d.ts +18 -0
  249. package/dist/util/progress.js +17 -0
  250. package/dist/util/scroll-edge.d.ts +32 -0
  251. package/dist/util/scroll-edge.js +87 -0
  252. package/dist/util/sheet-drag.d.ts +25 -1
  253. package/dist/util/sheet-drag.js +71 -1
  254. package/dist/util/skeleton.d.ts +14 -0
  255. package/dist/util/skeleton.js +22 -0
  256. package/dist/util/sliding-indicator.d.ts +24 -0
  257. package/dist/util/sliding-indicator.js +44 -0
  258. package/dist/util/temporal-field.d.ts +88 -0
  259. package/dist/util/temporal-field.js +136 -0
  260. package/dist/util/toaster.d.ts +40 -0
  261. package/dist/util/toaster.js +55 -0
  262. package/dist/util/tone-surface.d.ts +33 -0
  263. package/dist/util/tone-surface.js +35 -0
  264. package/dist/util/top-navbar.d.ts +26 -2
  265. package/dist/util/top-navbar.js +23 -0
  266. package/dist/util/zabi-strings.d.ts +9 -0
  267. package/dist/zabi-components-colors.css +9 -3
  268. package/dist/zabi-components-theme-dark-only.css +474 -164
  269. package/dist/zabi-components-theme-dark.css +474 -164
  270. package/dist/zabi-components-theme-only.css +933 -99
  271. package/dist/zabi-components-theme.css +933 -99
  272. package/dist/zabi-components.css +2275 -321
  273. package/package.json +11 -1
@@ -46,7 +46,7 @@ import {
46
46
  toOklch,
47
47
  toLab,
48
48
  } from './lib/ramp-math.js';
49
- import { resolveTokenColor } from './lib/resolve.js';
49
+ import { resolveTokenColor, resolveTokenPaint, compositeOver, dimBackdrop, backdropBrightness } from './lib/resolve.js';
50
50
 
51
51
  /** The neutral ramp's 21 steps. */
52
52
  export const BASE_STEPS = [
@@ -252,28 +252,28 @@ function triplet(hex) {
252
252
  }
253
253
 
254
254
  /**
255
- * Light and dark declarations for the ink roles over a neutral ramp. Roles the
256
- * author overrides themselves are left out, so their override stays what both
257
- * modes show.
255
+ * Light and dark declarations for the ink roles over a neutral ramp. A role
256
+ * the author overrides in a mode (or in `both`) is left out for that mode, so
257
+ * their override is what it shows. The other mode keeps the ramp's ink: a role
258
+ * set in `light` alone is still the tinted mix in dark, not the library's grey.
258
259
  */
259
- function inkTokens(ramp, overrides) {
260
+ function inkTokens(ramp, modes) {
260
261
  const light = {};
261
262
  const dark = {};
263
+ const setIn = (mode, role) => role in modes[mode] || role in modes.both;
262
264
  const mix = (step, percent) => `color-mix(in srgb, var(--zabi-base-${step}) ${percent}%, transparent)`;
263
265
  for (const role of INK_ROLES) {
264
- if (role in overrides) continue;
265
266
  const lightPercent = alphaPercent(data.light[role]);
266
- if (lightPercent !== null) light[role] = mix(900, lightPercent);
267
+ if (lightPercent !== null && !setIn('light', role)) light[role] = mix(900, lightPercent);
268
+ if (setIn('dark', role)) continue;
267
269
  const darkPercent = alphaPercent(data.darkOnly[role]);
268
270
  // A role the dark theme already points at a ramp step (the overlay edge)
269
271
  // keeps that: the light value on `:root` would otherwise win there too.
270
272
  if (darkPercent !== null) dark[role] = mix(50, darkPercent);
271
273
  else if (data.darkOnly[role] !== undefined) dark[role] = data.darkOnly[role];
272
274
  }
273
- if (!('--shadow-color' in overrides)) {
274
- light['--shadow-color'] = triplet(ramp[900]);
275
- dark['--shadow-color'] = '0 0 0';
276
- }
275
+ if (!setIn('light', '--shadow-color')) light['--shadow-color'] = triplet(ramp[900]);
276
+ if (!setIn('dark', '--shadow-color')) dark['--shadow-color'] = '0 0 0';
277
277
  return { light, dark };
278
278
  }
279
279
 
@@ -324,14 +324,37 @@ function themeMaps(tokens, darkTokens = {}) {
324
324
  };
325
325
  }
326
326
 
327
- function checkPairs(tokens, darkTokens = {}, extraPairs = []) {
327
+ function checkPairs(tokens, darkTokens = {}, extraPairs = [], { gradients = true } = {}) {
328
328
  const maps = themeMaps(tokens, darkTokens);
329
329
  const warnings = [];
330
330
  let checked = 0;
331
331
  for (const mode of ['light', 'dark']) {
332
332
  for (const pair of [...data.pairs, ...extraPairs]) {
333
- const bg = resolveTokenColor(maps[mode], pair.bg);
334
- const fg = resolveTokenColor(maps[mode], pair.fg);
333
+ // `only`: a pair about one mode (the dark field is a translucent step; light's is opaque).
334
+ if (pair.only && pair.only !== mode) continue;
335
+ // The control veils are settled last (settleControlGradients), so a
336
+ // choice made before then must not be steered by them.
337
+ if (!gradients && pair.gradient) continue;
338
+ const behind = pair.behind ? (typeof pair.behind === 'string' ? pair.behind : pair.behind[mode]) : null;
339
+ let bg = resolveTokenColor(maps[mode], pair.bg);
340
+ if (behind) {
341
+ // A material: its fill (an alpha paint) laid over what is behind it.
342
+ const paint = resolveTokenPaint(maps[mode], pair.bg);
343
+ const flat = resolveTokenColor(maps[mode], behind);
344
+ // A material's filter dims what is behind it (D137); other `behind` pairs are not filtered.
345
+ const backdrop = flat && pair.material ? dimBackdrop(flat, backdropBrightness(maps[mode])) : flat;
346
+ bg = paint && backdrop ? backdrop : null;
347
+ // `layers`: the same paint stacked, each laid over the last (nested translucent cards).
348
+ for (let layer = 0; layer < (pair.layers ?? 1) && bg; layer += 1) bg = compositeOver(paint, bg);
349
+ }
350
+ let fg = resolveTokenColor(maps[mode], pair.fg);
351
+ if (behind && pair.fgPaint) {
352
+ // `fgPaint`: the foreground is a fill too (a pressed field against the resting one), laid over the same backdrop.
353
+ const fgPaint = resolveTokenPaint(maps[mode], pair.fg);
354
+ let under = resolveTokenColor(maps[mode], behind);
355
+ for (let layer = 0; layer < (pair.layers ?? 1) && under && fgPaint; layer += 1) under = compositeOver(fgPaint, under);
356
+ fg = fgPaint ? under : null;
357
+ }
335
358
  if (!bg || !fg) continue; // an alpha tint: not one flat colour
336
359
  checked += 1;
337
360
  const ratio = contrast(bg, fg);
@@ -347,7 +370,7 @@ function checkPairs(tokens, darkTokens = {}, extraPairs = []) {
347
370
  required: pair.min,
348
371
  foreground: { token: pair.fg, value: fg },
349
372
  background: { token: pair.bg, value: bg },
350
- message: `${mode} · ${pair.name}: ${fg} on ${bg} is ${ratio}:1, needs ${pair.min}:1 (${pair.fg} on ${pair.bg})`,
373
+ message: `${mode} · ${pair.name}: ${fg} on ${bg} is ${ratio}:1, needs ${pair.min}:1 (${pair.fg} on ${pair.bg}${behind ? ` over ${behind}` : ''})`,
351
374
  });
352
375
  }
353
376
  }
@@ -370,14 +393,18 @@ const ON_ROLES = {
370
393
  /**
371
394
  * The label for a solid fill: white or the ramp's dark end, whichever reaches
372
395
  * 4.5:1 on the fill, its hover and its active step. The library's own choice
373
- * for the mode is tried first; if neither passes, the better one is kept and
396
+ * for the role and mode is tried first (white, except the accent in dark); if neither passes, the better one is kept and
374
397
  * the pair check reports it.
375
398
  */
376
399
  function chooseOn(name, mode, tokens, darkTokens = {}) {
377
400
  const { role, fills } = ON_ROLES[name];
378
401
  const knob = mode === 'dark' ? `--zabi-on-${name}-dark` : `--zabi-on-${name}`;
379
402
  const darkEnd = `var(--zabi-${name}-950)`;
380
- const candidates = mode === 'dark' ? [darkEnd, WHITE] : [WHITE, darkEnd];
403
+ // White first for the brand in both modes: the library's dark primary is the
404
+ // same mid step as light. A pale dark primary (an app's own override, or
405
+ // darkPrimary: 'mirror') fails white and keeps the ramp's dark end. The
406
+ // accent's dark fill is the light step, so its dark end still goes first.
407
+ const candidates = mode === 'dark' && name === 'accent' ? [darkEnd, WHITE] : [WHITE, darkEnd];
381
408
  let best = null;
382
409
  for (const value of candidates) {
383
410
  const map = themeMaps({ ...tokens, [knob]: value }, darkTokens)[mode];
@@ -393,6 +420,36 @@ function chooseOn(name, mode, tokens, darkTokens = {}) {
393
420
  return [knob, best.value];
394
421
  }
395
422
 
423
+ /* ---------- dark primary ---------- */
424
+
425
+ /**
426
+ * What `darkPrimary: 'mirror'` writes under the dark selectors: the 8.1 dark
427
+ * fills, which are the mirrored semantic steps (pale in dark), the danger
428
+ * aliases with the label they had, and the lighten-heavy control veil made for
429
+ * a pale fill. The label knob is chosen by chooseOn like any other.
430
+ */
431
+ const MIRRORED_DARK = {
432
+ '--color-action-primary': 'var(--color-brand-600)',
433
+ '--color-action-primary-hover': 'var(--color-brand-700)',
434
+ '--color-action-primary-active': 'var(--color-brand-800)',
435
+ '--color-action-danger': 'var(--color-error-600)',
436
+ '--color-action-danger-hover': 'var(--color-error-700)',
437
+ '--color-action-danger-active': 'var(--color-error-800)',
438
+ '--color-action-danger-text': 'var(--color-error-50)',
439
+ '--gradient-control-top-strength': '22%',
440
+ '--gradient-control-bottom-strength': '6%',
441
+ };
442
+
443
+ function readDarkPrimary(value) {
444
+ if (value === undefined || value === null) return 'brand';
445
+ if (value !== 'brand' && value !== 'mirror') {
446
+ throw new TypeError(
447
+ `${TOOL}: darkPrimary must be "brand" or "mirror" (got ${typeof value === 'string' ? `"${value}"` : JSON.stringify(value)})`,
448
+ );
449
+ }
450
+ return value;
451
+ }
452
+
396
453
  /* ---------- pinning ---------- */
397
454
 
398
455
  /**
@@ -508,10 +565,15 @@ const failing = (warnings, mode) => new Set(warnings.filter((w) => w.mode === mo
508
565
  * 3:1, and no label makes that a button. So dark takes the colour only when nothing guarded fails with it,
509
566
  * and otherwise keeps what it has without `pin`: the mirrored ramp step.
510
567
  */
511
- function pinColour(name, colour, tokens, earlierDark, userOverrides, extraPairs, skip) {
568
+ function pinColour(name, colour, tokens, earlierDark, userOverrides, extraPairs, skip, darkPrimary = 'brand') {
512
569
  const roles = PIN_ROLES[name];
513
570
  const keep = (group) => Object.fromEntries(Object.entries(group).filter(([role]) => !skip.has(role)));
514
- const libraryDark = (role) => data.darkOnly[role] ?? data.light[role];
571
+ // What dark has for a role without the pin: what the file already says for
572
+ // dark (the mirrored fills under darkPrimary: 'mirror'), then the library.
573
+ const libraryDark = (role) => earlierDark[role] ?? data.darkOnly[role] ?? data.light[role];
574
+ // The brand's dark fill is the light recipe (the same steps, darker states)
575
+ // unless it is mirrored; the accent's dark fill is still the pale step.
576
+ const recipe = (mode) => (mode === 'dark' && name === 'brand' && darkPrimary === 'brand' ? 'light' : mode);
515
577
  const light = {};
516
578
  const dark = {};
517
579
  const knobs = {};
@@ -522,7 +584,7 @@ function pinColour(name, colour, tokens, earlierDark, userOverrides, extraPairs,
522
584
  const root = { ...tokens, ...knobs, ...lightRoles, ...userOverrides };
523
585
  const darkRule = { ...earlierDark, ...darkRoles };
524
586
  const [knob, value] = chooseOn(name, mode, root, darkRule);
525
- const result = checkPairs({ ...root, [knob]: value }, darkRule, extraPairs);
587
+ const result = checkPairs({ ...root, [knob]: value }, darkRule, extraPairs, { gradients: false });
526
588
  return { knob, value, failures: failing(result.warnings, mode) };
527
589
  };
528
590
  const noWorse = (after, before) => [...after].every((pair) => before.has(pair));
@@ -535,8 +597,8 @@ function pinColour(name, colour, tokens, earlierDark, userOverrides, extraPairs,
535
597
  */
536
598
  const bestFill = (mode, tryWith) => {
537
599
  let best = null;
538
- for (const direction of SHADE_DIRECTIONS[mode]) {
539
- const states = shades(colour, mode, direction);
600
+ for (const direction of SHADE_DIRECTIONS[recipe(mode)]) {
601
+ const states = shades(colour, recipe(mode), direction);
540
602
  if (!states) continue;
541
603
  const result = tryWith(keep(roles.fill(colour.hex, states)));
542
604
  if (!best || result.failures.size < best.result.failures.size) best = { direction, states, result };
@@ -600,13 +662,151 @@ function pinColour(name, colour, tokens, earlierDark, userOverrides, extraPairs,
600
662
  return { light, dark: { ...restated(), ...dark }, knobs, report };
601
663
  }
602
664
 
665
+ /* ---------- links that follow the primary ---------- */
666
+
667
+ /** What the app's overrides say a role is in `mode`, or undefined. */
668
+ const overrideIn = (modes, mode, role) => modes[mode][role] ?? modes.both[role];
669
+
670
+ /**
671
+ * Sets roles for one mode, answering a light value with the library's own
672
+ * dark one so that dark does not move (as overrideMaps does for `light`).
673
+ */
674
+ function setRoles(tokens, darkTokens, mode, roles) {
675
+ if (mode === 'light') {
676
+ const dark = { ...darkTokens };
677
+ for (const role of Object.keys(roles)) if (!(role in dark)) dark[role] = data.darkOnly[role] ?? data.light[role];
678
+ return { tokens: { ...tokens, ...roles }, darkTokens: dark };
679
+ }
680
+ return { tokens, darkTokens: { ...darkTokens, ...roles } };
681
+ }
682
+
683
+ /** The pairs failing in `mode` with these tokens. */
684
+ const failingIn = (tokens, darkTokens, mode) => failing(checkPairs(tokens, darkTokens).warnings, mode);
685
+
686
+ /**
687
+ * `--color-link` follows an overridden primary, as a pinned colour's link
688
+ * does (PIN_ROLES): the library's link is the brand's step 700 because step
689
+ * 600, its primary, does not hold 4.5:1 as text on tints. An app that sets
690
+ * its own primary (a deeper step, say) otherwise has links beside buttons in
691
+ * a different blue. In each mode where the app sets `--color-action-primary`
692
+ * and not `--color-link`, link is that primary and, if the app set a primary
693
+ * hover too, link-hover is that (otherwise the ramp's link-hover stays: the
694
+ * library's hover is a step the app's primary does not use), but only if no
695
+ * guarded pair fails that did not fail before; otherwise the ramp's link
696
+ * stays in that mode.
697
+ * An explicit `--color-link` always wins.
698
+ */
699
+ function followPrimaryWithLink(tokens, darkTokens, modes) {
700
+ for (const mode of ['light', 'dark']) {
701
+ const primary = overrideIn(modes, mode, '--color-action-primary');
702
+ if (primary === undefined || overrideIn(modes, mode, '--color-link') !== undefined) continue;
703
+ const hover = overrideIn(modes, mode, '--color-action-primary-hover');
704
+ const before = failingIn(tokens, darkTokens, mode);
705
+ for (const roles of [
706
+ ...(hover === undefined ? [] : [{ '--color-link': primary, '--color-link-hover': hover }]),
707
+ { '--color-link': primary },
708
+ ]) {
709
+ const next = setRoles(tokens, darkTokens, mode, roles);
710
+ const after = failingIn(next.tokens, next.darkTokens, mode);
711
+ if ([...after].every((pair) => before.has(pair))) {
712
+ ({ tokens, darkTokens } = next);
713
+ break;
714
+ }
715
+ }
716
+ }
717
+ return { tokens, darkTokens };
718
+ }
719
+
720
+ /**
721
+ * The edge of the Progress track (`--color-progress-track-border`) is the field
722
+ * edge by default, because the default track is the field fill and does not
723
+ * reach 3:1 against the page or a card (WCAG 1.4.11). An app that overrides
724
+ * the track so that it does reach 3:1 against both, in a mode, does not need
725
+ * an edge there: it is written as `transparent` for that mode, as the link
726
+ * follows an overridden primary. An edge the app sets itself, in either
727
+ * mode or both, is its decision, and is never moved (a `transparent` of its
728
+ * own draws no warning: a pair with no flat colour is skipped).
729
+ */
730
+ const PROGRESS_TRACK = '--color-progress-track';
731
+ const PROGRESS_TRACK_EDGE = '--color-progress-track-border';
732
+ const MIN_TRACK_AGAINST_SURFACE = 3;
733
+
734
+ function settleProgressTrackEdge(tokens, darkTokens, modes) {
735
+ for (const mode of ['light', 'dark']) {
736
+ if (overrideIn(modes, mode, PROGRESS_TRACK) === undefined) continue;
737
+ if (overrideIn(modes, mode, PROGRESS_TRACK_EDGE) !== undefined) continue;
738
+ const maps = themeMaps(tokens, darkTokens)[mode];
739
+ const track = resolveTokenColor(maps, PROGRESS_TRACK);
740
+ if (!track) continue; // an alpha tint: not one flat colour
741
+ const surfaces = ['--color-surface-base', '--color-surface-raised'].map((role) => resolveTokenColor(maps, role));
742
+ if (surfaces.some((surface) => !surface || contrast(track, surface) < MIN_TRACK_AGAINST_SURFACE)) continue;
743
+ ({ tokens, darkTokens } = setRoles(tokens, darkTokens, mode, { [PROGRESS_TRACK_EDGE]: 'transparent' }));
744
+ }
745
+ return { tokens, darkTokens };
746
+ }
747
+
748
+ /* ---------- control gradients ---------- */
749
+
750
+ /** The strongest veil tried: the library's own cap. */
751
+ const MAX_VEIL = 14;
752
+
753
+ /**
754
+ * The veil of a solid control is a light stop on top and a dark one at the
755
+ * bottom, over the control's own fill. The library's light default darkens
756
+ * only (top 0, bottom 14%): a darker fill can only help a light label. An app
757
+ * whose fill takes a DARK label (a light accent, an amber) loses contrast to
758
+ * that darkening, so for each veil and each mode: if every pair passes, nothing
759
+ * is written; if not, the veil is flipped to lighten-only (the top at the
760
+ * largest whole percent up to 14 that passes, the bottom 0), or the other way
761
+ * if the label is the light one; if neither direction passes, both are 0%,
762
+ * which is no veil. A strength the app set itself is never moved: the pair
763
+ * check reports it. Primary and danger share one veil, so they flip together.
764
+ */
765
+ function settleControlGradients(tokens, darkTokens, modes) {
766
+ const veils = new Map();
767
+ for (const control of data.controlGradients) {
768
+ const key = control.strengths.join('|');
769
+ if (!veils.has(key)) veils.set(key, { strengths: control.strengths, controls: [] });
770
+ veils.get(key).controls.push(control);
771
+ }
772
+ for (const mode of ['light', 'dark']) {
773
+ for (const { strengths, controls } of veils.values()) {
774
+ if (strengths.some((role) => overrideIn(modes, mode, role) !== undefined)) continue;
775
+ const failed = (t, d) =>
776
+ [...failingIn(t, d, mode)].filter((pair) => controls.some((control) => pair.startsWith(`${control.name} gradient `)));
777
+ if (failed(tokens, darkTokens).length === 0) continue;
778
+ const [top, bottom] = strengths;
779
+ const maps = themeMaps(tokens, darkTokens)[mode];
780
+ const labelIsDark = controls.some((control) => {
781
+ const label = resolveTokenColor(maps, control.on);
782
+ const fill = resolveTokenColor(maps, control.fills[0][1]);
783
+ return label && fill && luminance(label) < luminance(fill);
784
+ });
785
+ const directions = labelIsDark ? ['lighten', 'darken'] : ['darken', 'lighten'];
786
+ let chosen = { [top]: '0%', [bottom]: '0%' };
787
+ search: for (const direction of directions) {
788
+ for (let percent = MAX_VEIL; percent >= 1; percent -= 1) {
789
+ const roles = direction === 'lighten' ? { [top]: `${percent}%`, [bottom]: '0%' } : { [top]: '0%', [bottom]: `${percent}%` };
790
+ const next = setRoles(tokens, darkTokens, mode, roles);
791
+ if (failed(next.tokens, next.darkTokens).length === 0) {
792
+ chosen = roles;
793
+ break search;
794
+ }
795
+ }
796
+ }
797
+ ({ tokens, darkTokens } = setRoles(tokens, darkTokens, mode, chosen));
798
+ }
799
+ }
800
+ return { tokens, darkTokens };
801
+ }
802
+
603
803
  /* ---------- output ---------- */
604
804
 
605
805
  function rampTokens(name, ramp, steps) {
606
806
  return Object.fromEntries(steps.map((step) => [`--zabi-${name}-${step}`, ramp[step]]));
607
807
  }
608
808
 
609
- function render({ inputs, closest, tokens, darkTokens, pinned, neutralChroma, contrastWarnings, checked }) {
809
+ function render({ inputs, closest, tokens, darkTokens, pinned, neutralChroma, darkPrimary, contrastWarnings, checked }) {
610
810
  const describeInput = (name) =>
611
811
  inputs[name]
612
812
  ? `${inputs[name]} closest step ${closest[name].step} (${closest[name].hex}, ` +
@@ -621,6 +821,12 @@ function render({ inputs, closest, tokens, darkTokens, pinned, neutralChroma, co
621
821
  ` * brand ${describeInput('brand')}`,
622
822
  ` * accent ${describeInput('accent')}`,
623
823
  ` * neutral ${describeInput('neutral')}`,
824
+ ...(darkPrimary === 'mirror'
825
+ ? [
826
+ ' * dark primary: mirror. The primary and danger fills use the mirrored (pale) steps in',
827
+ ' * dark, as in 8.1, with the label and control veil chosen for them.',
828
+ ]
829
+ : []),
624
830
  ...(neutralChroma === undefined
625
831
  ? []
626
832
  : [
@@ -679,8 +885,8 @@ function pinnedNotes(pinned) {
679
885
  if (report.dark.followed.length) lines.push(` * dark, also this colour: ${list(report.dark.followed)}`);
680
886
  if (report.dark.onRamp.length) lines.push(` * dark, left on the ramp: ${list(report.dark.onRamp)}`);
681
887
  } else {
682
- lines.push(' * dark: NOT pinned. The colour fails there, so dark keeps the mirrored ramp step, as it');
683
- lines.push(' * does without pin. With the colour, these would fail:');
888
+ lines.push(' * dark: NOT pinned. The colour fails there, so dark keeps the ramp step it has');
889
+ lines.push(' * without pin. With the colour, these would fail:');
684
890
  for (const pair of report.dark.failed) lines.push(` * - ${pair}`);
685
891
  lines.push(' * To force a dark value all the same, write the role in your own dark rule after this file.');
686
892
  }
@@ -689,7 +895,7 @@ function pinnedNotes(pinned) {
689
895
  }
690
896
 
691
897
  /**
692
- * @param {{ brand: string, accent?: string, neutral?: string, neutralChroma?: number, overrides?: Record<string, string> | { light?: Record<string, string>, dark?: Record<string, string>, both?: Record<string, string> } & Record<string, unknown>, pin?: boolean | { brand?: boolean, accent?: boolean } }} options
898
+ * @param {{ brand: string, accent?: string, neutral?: string, neutralChroma?: number, overrides?: Record<string, string> | { light?: Record<string, string>, dark?: Record<string, string>, both?: Record<string, string> } & Record<string, unknown>, pin?: boolean | { brand?: boolean, accent?: boolean }, darkPrimary?: 'brand' | 'mirror' }} options
693
899
  */
694
900
  export function createTheme(options) {
695
901
  if (options === null || typeof options !== 'object') {
@@ -699,10 +905,6 @@ export function createTheme(options) {
699
905
  throw new TypeError(`${TOOL}: brand is required, for example { brand: "#0026EA" }`);
700
906
  }
701
907
  const modes = readOverrides(options.overrides ?? {});
702
- // Roles the app sets in any mode are left to it: no ink role or pin replaces them.
703
- const overridden = {};
704
- for (const map of [modes.both, modes.light, modes.dark]) Object.assign(overridden, map);
705
-
706
908
  const inputs = { brand: parseHex(options.brand, 'brand') };
707
909
  if (options.accent !== undefined && options.accent !== null) inputs.accent = parseHex(options.accent, 'accent');
708
910
  if (options.neutral !== undefined && options.neutral !== null) inputs.neutral = parseHex(options.neutral, 'neutral');
@@ -721,6 +923,7 @@ export function createTheme(options) {
721
923
  neutralChroma = value;
722
924
  }
723
925
 
926
+ const darkPrimary = readDarkPrimary(options.darkPrimary);
724
927
  const pin = readPin(options.pin);
725
928
  if (pin.accent && !inputs.accent) throw new TypeError(`${TOOL}: pin.accent needs an accent colour`);
726
929
  const pinning = pin.brand || pin.accent;
@@ -747,7 +950,7 @@ export function createTheme(options) {
747
950
  message:
748
951
  `${name} ${colour.hex} is not in the ramp built from it: the nearest step is ` +
749
952
  `${closest[name].step} (${closest[name].hex}, ΔE ${closest[name].deltaE.toFixed(1)}). ` +
750
- 'The ramp keeps its hue on the library\'s lightness curve; fills use step 600 in light and 400 in dark.',
953
+ 'The ramp keeps its hue on the library\'s lightness curve; fills use step 600 in light and in dark (400 in dark with darkPrimary: "mirror").',
751
954
  });
752
955
  }
753
956
  tokens = { ...tokens, ...rampTokens(name, ramp, RAMP_STEPS) };
@@ -766,12 +969,21 @@ export function createTheme(options) {
766
969
  tokens = { ...tokens, ...rampTokens('base', ramp, BASE_STEPS) };
767
970
  if (neutralChroma !== undefined) {
768
971
  // Opt-in: without it these roles are the library's grey tints, as before.
769
- const ink = inkTokens(ramp, overridden);
972
+ const ink = inkTokens(ramp, modes);
770
973
  tokens = { ...tokens, ...ink.light };
771
974
  darkTokens = ink.dark;
772
975
  }
773
976
  }
774
977
 
978
+ // darkPrimary: 'mirror' writes the 8.1 dark fills under the dark selectors,
979
+ // except for a role the app sets itself in dark or in both modes.
980
+ if (darkPrimary === 'mirror') {
981
+ for (const [role, value] of Object.entries(MIRRORED_DARK)) {
982
+ if (role in modes.dark || role in modes.both) continue;
983
+ darkTokens = { ...darkTokens, [role]: value };
984
+ }
985
+ }
986
+
775
987
  // Overrides take part in choosing the labels, and are written last. Each
776
988
  // mode sees its own values.
777
989
  for (const name of ['brand', 'accent']) {
@@ -790,7 +1002,7 @@ export function createTheme(options) {
790
1002
  for (const name of ['brand', 'accent']) {
791
1003
  if (!pin[name]) continue;
792
1004
  const own = overrideMaps(modes, tokens, darkTokens);
793
- const result = pinColour(name, described[name], tokens, { ...darkTokens, ...own.dark }, own.root, extraPairs, own.skip);
1005
+ const result = pinColour(name, described[name], tokens, { ...darkTokens, ...own.dark }, own.root, extraPairs, own.skip, darkPrimary);
794
1006
  tokens = { ...tokens, ...result.knobs, ...result.light };
795
1007
  darkTokens = { ...darkTokens, ...result.dark };
796
1008
  pinned[name] = result.report;
@@ -799,11 +1011,16 @@ export function createTheme(options) {
799
1011
  tokens = { ...tokens, ...own.root };
800
1012
  darkTokens = { ...darkTokens, ...own.dark };
801
1013
 
1014
+ // What the generator settles once the app's own values are in place.
1015
+ if (!pin.brand) ({ tokens, darkTokens } = followPrimaryWithLink(tokens, darkTokens, modes));
1016
+ ({ tokens, darkTokens } = settleProgressTrackEdge(tokens, darkTokens, modes));
1017
+ ({ tokens, darkTokens } = settleControlGradients(tokens, darkTokens, modes));
1018
+
802
1019
  const { warnings: contrastWarnings, checked } = checkPairs(tokens, darkTokens, extraPairs);
803
1020
  warnings.push(...contrastWarnings);
804
1021
 
805
- const css = render({ inputs, closest, tokens, darkTokens, pinned: pinning ? pinned : null, neutralChroma, contrastWarnings, checked });
806
- const hasDark = pinning || neutralChroma !== undefined || Object.keys(own.dark).length > 0;
1022
+ const css = render({ inputs, closest, tokens, darkTokens, pinned: pinning ? pinned : null, neutralChroma, darkPrimary, contrastWarnings, checked });
1023
+ const hasDark = pinning || neutralChroma !== undefined || Object.keys(darkTokens).length > 0;
807
1024
  return {
808
1025
  css,
809
1026
  tokens,
@@ -10,6 +10,8 @@
10
10
  * Supported, because it is all the stylesheet uses for a flat colour:
11
11
  * - `var(--token)` and `var(--token, fallback)`, anywhere in a value
12
12
  * - `color-mix(in srgb, <opaque> p%, <opaque>)` (either or both percentages)
13
+ * - `color-mix(in srgb, <colour> p%, transparent)` and `rgba()`, as a colour
14
+ * with an alpha (resolveTokenPaint), for the materials' fills
13
15
  * Anything else (`rgba()`, a mix with `transparent`) is returned as written,
14
16
  * for the caller to composite over a surface or to skip.
15
17
  */
@@ -139,3 +141,98 @@ export function resolveTokenColor(map, token) {
139
141
  const value = resolveTokenValue(map, token);
140
142
  return value && HEX.test(value) ? value.toLowerCase() : null;
141
143
  }
144
+
145
+ /* ---------- colours with an alpha ---------- */
146
+
147
+ /** `#rrggbb` + alpha, or null. `transparent` is alpha 0. */
148
+ function parsePaint(text) {
149
+ const value = text.trim();
150
+ if (value === 'transparent') return { hex: '#000000', alpha: 0 };
151
+ if (HEX.test(value)) return { hex: value.toLowerCase().length === 4 ? toHex(parseHex(value)) : value.toLowerCase(), alpha: 1 };
152
+ const rgba = /^rgba?\(\s*([\d.]+)[\s,]+([\d.]+)[\s,]+([\d.]+)(?:[\s,/]+([\d.]+%?))?\s*\)$/.exec(value);
153
+ if (rgba) {
154
+ const a = rgba[4] === undefined ? 1 : rgba[4].endsWith('%') ? Number.parseFloat(rgba[4]) / 100 : Number(rgba[4]);
155
+ return { hex: toHex([rgba[1], rgba[2], rgba[3]].map(Number)), alpha: a };
156
+ }
157
+ if (value.startsWith('color-mix(')) return mixPaint(value);
158
+ return null;
159
+ }
160
+
161
+ /**
162
+ * `color-mix(in srgb, A p%, B q%)` where either side may be `transparent` or
163
+ * carry an alpha. Premultiplied, as the browser mixes; a sum under 100% leaves
164
+ * the result that much more transparent. Null when it cannot be reduced.
165
+ */
166
+ function mixPaint(value) {
167
+ const match = /^color-mix\(([\s\S]*)\)$/.exec(value.trim());
168
+ if (!match) return null;
169
+ const [space, first, second] = splitTopLevel(match[1]);
170
+ if (!space || !first || !second || space.replace(/\s+/g, ' ') !== 'in srgb') return null;
171
+ const operand = (text) => {
172
+ const t = text.trim();
173
+ let percent = null;
174
+ let colourText = t;
175
+ const trailing = /^([\s\S]*\S)\s+(-?[\d.]+)%$/.exec(t);
176
+ const leading = /^(-?[\d.]+)%\s+([\s\S]*)$/.exec(t);
177
+ if (trailing) [, colourText, percent] = trailing;
178
+ else if (leading) [, percent, colourText] = leading;
179
+ const paint = parsePaint(colourText);
180
+ return paint ? { paint, percent: percent === null ? null : Number(percent) } : null;
181
+ };
182
+ const a = operand(first);
183
+ const b = operand(second);
184
+ if (!a || !b) return null;
185
+ let pa = a.percent;
186
+ let pb = b.percent;
187
+ if (pa === null && pb === null) { pa = 50; pb = 50; }
188
+ else if (pa === null) pa = 100 - pb;
189
+ else if (pb === null) pb = 100 - pa;
190
+ const total = pa + pb;
191
+ if (total <= 0) return null;
192
+ const wa = (pa / total) * a.paint.alpha;
193
+ const wb = (pb / total) * b.paint.alpha;
194
+ const alpha = wa + wb;
195
+ if (alpha === 0) return { hex: '#000000', alpha: 0 };
196
+ const ca = parseHex(a.paint.hex);
197
+ const cb = parseHex(b.paint.hex);
198
+ return {
199
+ hex: toHex(ca.map((channel, i) => (channel * wa + cb[i] * wb) / alpha)),
200
+ alpha: alpha * Math.min(1, total / 100),
201
+ };
202
+ }
203
+
204
+ /**
205
+ * What a token paints, as `{ hex, alpha }`: a flat colour (alpha 1), a
206
+ * `color-mix()` with `transparent`, or an `rgba()` tint. Null when the token
207
+ * is not declared or is not one of those.
208
+ */
209
+ export function resolveTokenPaint(map, token) {
210
+ if (map[token] === undefined) return null;
211
+ const substituted = substituteVars(map, map[token], new Set([token]));
212
+ return substituted === null ? null : parsePaint(substituted);
213
+ }
214
+
215
+ /** The colour `paint` makes laid source-over on the opaque `backdropHex`, as hex. */
216
+ export function compositeOver(paint, backdropHex) {
217
+ const under = parseHex(backdropHex);
218
+ const over = parseHex(paint.hex);
219
+ return toHex(over.map((channel, i) => channel * paint.alpha + under[i] * (1 - paint.alpha)));
220
+ }
221
+
222
+ /**
223
+ * The backdrop a material's filter hands to its fill: `brightness()` multiplies
224
+ * the sRGB-encoded channels, so each channel of `backdropHex` is scaled by
225
+ * `factor` (a number; 1 is the identity). Material pairs only.
226
+ */
227
+ export function dimBackdrop(backdropHex, factor) {
228
+ const f = Number(factor);
229
+ if (!Number.isFinite(f) || f === 1) return backdropHex;
230
+ return toHex(parseHex(backdropHex).map((channel) => channel * f));
231
+ }
232
+
233
+ /** The mode's `--material-backdrop-brightness` as a number; 1 when it is not declared or not a number. */
234
+ export function backdropBrightness(map) {
235
+ const value = resolveTokenValue(map, '--material-backdrop-brightness');
236
+ const n = value === null ? NaN : Number(value);
237
+ return Number.isFinite(n) ? n : 1;
238
+ }