@sigx/zero-daisyui 0.2.0-beta.6 → 0.4.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 (89) hide show
  1. package/README.md +78 -5
  2. package/dist/audit.json +271915 -0
  3. package/dist/components.d.ts +75 -18
  4. package/dist/components.js +15 -3
  5. package/dist/css/breakpoints.css +7 -0
  6. package/dist/css/components/avatar.css +12 -0
  7. package/dist/css/components/badge.css +130 -1
  8. package/dist/css/components/box.css +58 -0
  9. package/dist/css/components/button.css +17 -17
  10. package/dist/css/components/center.css +17 -0
  11. package/dist/css/components/checkbox.css +1 -1
  12. package/dist/css/components/combobox.css +57 -6
  13. package/dist/css/components/container.css +16 -0
  14. package/dist/css/components/countdown.css +5 -0
  15. package/dist/css/components/dialog.css +3 -2
  16. package/dist/css/components/drawer.css +102 -6
  17. package/dist/css/components/empty-state.css +164 -0
  18. package/dist/css/components/grid.css +32 -0
  19. package/dist/css/components/kbd.css +1 -1
  20. package/dist/css/components/menu.css +4 -4
  21. package/dist/css/components/nav-list.css +165 -0
  22. package/dist/css/components/radio-group.css +2 -2
  23. package/dist/css/components/select.css +1 -1
  24. package/dist/css/components/spacer.css +13 -0
  25. package/dist/css/components/stack.css +39 -0
  26. package/dist/css/components/steps.css +109 -28
  27. package/dist/css/components/table.css +276 -2
  28. package/dist/css/components/timeline.css +64 -1
  29. package/dist/css/components/tree-view.css +2 -2
  30. package/dist/css/index.css +2220 -279
  31. package/dist/css/index.d.ts +2 -0
  32. package/dist/css/tokens.css +9 -0
  33. package/dist/design-system.d.ts.map +1 -1
  34. package/dist/design-system.js +15 -8
  35. package/dist/design-system.js.map +1 -1
  36. package/dist/lynx/components/avatar.css +12 -0
  37. package/dist/lynx/components/badge.css +56 -1
  38. package/dist/lynx/components/box.css +54 -0
  39. package/dist/lynx/components/breadcrumbs.css +10 -10
  40. package/dist/lynx/components/button.css +15 -4
  41. package/dist/lynx/components/card.css +5 -5
  42. package/dist/lynx/components/center.css +13 -0
  43. package/dist/lynx/components/chat.css +10 -10
  44. package/dist/lynx/components/checkbox.css +1 -1
  45. package/dist/lynx/components/combobox.css +49 -6
  46. package/dist/lynx/components/container.css +11 -0
  47. package/dist/lynx/components/countdown.css +5 -0
  48. package/dist/lynx/components/dialog.css +8 -7
  49. package/dist/lynx/components/drawer.css +18 -0
  50. package/dist/lynx/components/empty-state.css +342 -0
  51. package/dist/lynx/components/file-upload.css +10 -10
  52. package/dist/lynx/components/grid.css +28 -0
  53. package/dist/lynx/components/kbd.css +1 -1
  54. package/dist/lynx/components/menu.css +4 -4
  55. package/dist/lynx/components/nav-list.css +302 -0
  56. package/dist/lynx/components/pagination.css +5 -5
  57. package/dist/lynx/components/radial-progress.css +5 -5
  58. package/dist/lynx/components/radio-group.css +2 -2
  59. package/dist/lynx/components/rating-group.css +5 -5
  60. package/dist/lynx/components/select.css +6 -6
  61. package/dist/lynx/components/spacer.css +11 -0
  62. package/dist/lynx/components/stack.css +35 -0
  63. package/dist/lynx/components/stats.css +10 -10
  64. package/dist/lynx/components/steps.css +99 -59
  65. package/dist/lynx/components/switch.css +5 -5
  66. package/dist/lynx/components/table.css +18 -12
  67. package/dist/lynx/components/tabs.css +5 -5
  68. package/dist/lynx/components/timeline.css +14 -5
  69. package/dist/lynx/components/tree-view.css +2 -2
  70. package/dist/lynx/index.css +1254 -345
  71. package/dist/lynx/manifest.json +354 -37
  72. package/dist/lynx/tokens.css +36 -0
  73. package/dist/manifest.json +351 -34
  74. package/dist/recipes.d.ts +17 -10
  75. package/dist/recipes.d.ts.map +1 -1
  76. package/dist/recipes.js +582 -173
  77. package/dist/recipes.js.map +1 -1
  78. package/dist/register.d.ts +89 -14
  79. package/dist/report.json +2476 -564
  80. package/dist/tokens.d.ts +22 -11
  81. package/dist/tokens.d.ts.map +1 -1
  82. package/dist/tokens.js +57 -7
  83. package/dist/tokens.js.map +1 -1
  84. package/package.json +28 -10
  85. package/src/design-system.ts +15 -8
  86. package/src/recipes.ts +594 -177
  87. package/src/tokens.ts +54 -7
  88. package/dist/css/components/native-select.css +0 -128
  89. package/dist/lynx/components/native-select.css +0 -130
package/src/recipes.ts CHANGED
@@ -4,8 +4,9 @@
4
4
  * package: a design system is data, and "looks like daisy" is one possible
5
5
  * value of that data.
6
6
  */
7
- import type { CssProps, PartStyles, RecipeInput, RoleDecl } from '@sigx/zero-kit';
8
- import { roles } from './tokens.js';
7
+ import type { CssProps, PartStyles, RecipeInput } from '@sigx/zero-kit';
8
+ import { axisRoles, tableStackAt } from '@sigx/zero-kit/define';
9
+ import { roles, tokens } from './tokens.js';
9
10
 
10
11
  /**
11
12
  * Every role a consumer can pass as `color`, derived from the declaration
@@ -14,9 +15,7 @@ import { roles } from './tokens.js';
14
15
  * `-content` or `-soft` are fills or hairlines, not action colours; this
15
16
  * design system declares none.
16
17
  */
17
- const ROLES = Object.entries(roles as Record<string, RoleDecl>)
18
- .filter(([, decl]) => decl.content !== false && decl.soft !== false)
19
- .map(([name]) => name);
18
+ const ROLES = axisRoles(roles);
20
19
 
21
20
  /**
22
21
  * "…and the reading direction is right-to-left" — appended to a selector, never
@@ -537,6 +536,13 @@ const disclosureSizes: Record<string, Record<string, PartStyles>> = {
537
536
  // daisy "collapse collapse-arrow" flavor.
538
537
  export const collapsible: RecipeInput = {
539
538
  component: 'collapsible',
539
+ // Public to a design system derived from this one (#73).
540
+ hooks: {
541
+ properties: {
542
+ '--collapsible-accent': 'The accent of the open trigger.',
543
+ },
544
+ pseudo: { trigger: ['::after'] },
545
+ },
540
546
  // The accent is the open heading's INK, so the default is base-content —
541
547
  // the un-attributed render stays exactly daisy's neutral collapse and a
542
548
  // role only arrives through `data-color`.
@@ -611,6 +617,14 @@ export const collapsible: RecipeInput = {
611
617
  // daisy "toggle" flavor.
612
618
  export const switchRecipe: RecipeInput = {
613
619
  component: 'switch',
620
+ // Public to a design system derived from this one (#73).
621
+ hooks: {
622
+ properties: {
623
+ '--switch-size': 'The height; width, padding and knob travel derive from it.',
624
+ '--switch-accent': 'The checked track and knob accent.',
625
+ '--switch-ink': 'The unchecked border, knob and ring ink (daisy --input-color).',
626
+ },
627
+ },
614
628
  /**
615
629
  * daisy drives the whole toggle off ONE size, exactly as it drives the
616
630
  * checkbox: `--size` is the height, the padding is a fixed eighth of it,
@@ -941,6 +955,10 @@ const lynxBtnSizes = (part: string): Record<string, Record<string, PartStyles>>
941
955
 
942
956
  export const dialog: RecipeInput = {
943
957
  component: 'dialog',
958
+ // Public to a design system derived from this one (#73).
959
+ hooks: {
960
+ keyframes: ['zero-daisy-pop'],
961
+ },
944
962
  keyframes: {
945
963
  'zero-daisy-pop': 'from { opacity: 0; transform: scale(0.95); } to { opacity: 1; transform: scale(1); }',
946
964
  },
@@ -957,7 +975,12 @@ export const dialog: RecipeInput = {
957
975
  },
958
976
  popup: withPresence(popupPresence('translateY(8px) scale(0.97)'), {
959
977
  base: {
960
- padding: '1.5rem',
978
+ // A <dialog> keeps the UA's `content-box`, and zero ships no
979
+ // reset — so a width or max-width meant to leave a gutter
980
+ // grew by the padding, and at phone width the popup ran past
981
+ // both edges (#101). Its box is the border box.
982
+ boxSizing: 'border-box',
983
+ padding: 'var(--space-2xl)',
961
984
  maxWidth: '32rem',
962
985
  width: 'calc(100% - 2rem)',
963
986
  background: 'var(--color-base-100)',
@@ -987,14 +1010,15 @@ export const dialog: RecipeInput = {
987
1010
  },
988
1011
  // daisy's `modal-action`: the actions sit at the trailing edge, one
989
1012
  // step of the button gap apart, pushed down by the modal's own
990
- // padding value rather than by a scale step (daisy writes 1.5rem for
991
- // both, as `popup` above does).
1013
+ // padding — daisy writes 1.5rem for both, and both now read the top
1014
+ // of the ramp (`--space-2xl`, 1.25rem) so a density override moves
1015
+ // the popup's inset and the footer's push-down together.
992
1016
  footer: {
993
1017
  base: {
994
1018
  display: 'flex',
995
1019
  justifyContent: 'flex-end',
996
1020
  gap: 'var(--space-md)',
997
- marginBlockStart: '1.5rem',
1021
+ marginBlockStart: 'var(--space-2xl)',
998
1022
  },
999
1023
  },
1000
1024
  close: {
@@ -1027,7 +1051,7 @@ export const dialog: RecipeInput = {
1027
1051
  trigger: { base: lynxBtnPad(4) },
1028
1052
  close: { base: lynxBtnPad(4) },
1029
1053
  cancel: { base: lynxBtnPad(4) },
1030
- footer: { base: { marginTop: '1.5rem' } },
1054
+ footer: { base: { marginTop: 'var(--space-2xl)' } },
1031
1055
  },
1032
1056
  variants: { size: lynxBtnSizes('trigger') },
1033
1057
  },
@@ -1151,7 +1175,7 @@ export const menu: RecipeInput = {
1151
1175
  base: {
1152
1176
  display: 'flex',
1153
1177
  alignItems: 'center',
1154
- gap: '0.625rem',
1178
+ gap: 'var(--space-md)',
1155
1179
  padding: 'var(--space-md) var(--space-lg)',
1156
1180
  fontSize: 'var(--text-sm)',
1157
1181
  fontWeight: 'var(--weight-medium)',
@@ -1171,7 +1195,7 @@ export const menu: RecipeInput = {
1171
1195
  base: {
1172
1196
  display: 'flex',
1173
1197
  alignItems: 'center',
1174
- gap: '0.625rem',
1198
+ gap: 'var(--space-md)',
1175
1199
  padding: 'var(--space-md) var(--space-lg)',
1176
1200
  fontSize: 'var(--text-sm)',
1177
1201
  fontWeight: 'var(--weight-medium)',
@@ -1190,7 +1214,7 @@ export const menu: RecipeInput = {
1190
1214
  base: {
1191
1215
  display: 'flex',
1192
1216
  alignItems: 'center',
1193
- gap: '0.625rem',
1217
+ gap: 'var(--space-md)',
1194
1218
  padding: 'var(--space-md) var(--space-lg)',
1195
1219
  fontSize: 'var(--text-sm)',
1196
1220
  fontWeight: 'var(--weight-medium)',
@@ -1225,7 +1249,7 @@ export const menu: RecipeInput = {
1225
1249
  base: {
1226
1250
  display: 'flex',
1227
1251
  alignItems: 'center',
1228
- gap: '0.625rem',
1252
+ gap: 'var(--space-md)',
1229
1253
  padding: 'var(--space-md) var(--space-lg)',
1230
1254
  fontSize: 'var(--text-sm)',
1231
1255
  fontWeight: 'var(--weight-medium)',
@@ -1406,7 +1430,7 @@ export const checkbox: RecipeInput = {
1406
1430
  },
1407
1431
  parts: {
1408
1432
  root: {
1409
- base: { display: 'inline-flex', alignItems: 'center', gap: '0.625rem', cursor: 'pointer' },
1433
+ base: { display: 'inline-flex', alignItems: 'center', gap: 'var(--space-md)', cursor: 'pointer' },
1410
1434
  states: {
1411
1435
  disabled: { opacity: 'var(--disabled-opacity)', cursor: 'not-allowed' },
1412
1436
  checked: {}, unchecked: {}, indeterminate: {},
@@ -1605,7 +1629,7 @@ export const radioGroup: RecipeInput = {
1605
1629
  },
1606
1630
  parts: {
1607
1631
  root: {
1608
- base: { display: 'flex', flexDirection: 'column', gap: '0.625rem' },
1632
+ base: { display: 'flex', flexDirection: 'column', gap: 'var(--space-md)' },
1609
1633
  states: { invalid: {}, required: {} },
1610
1634
  selectors: {
1611
1635
  // `invalid` is a fact about the GROUP — `item-control` carries
@@ -1619,7 +1643,7 @@ export const radioGroup: RecipeInput = {
1619
1643
  states: { disabled: { opacity: 'var(--disabled-opacity)' } },
1620
1644
  },
1621
1645
  item: {
1622
- base: { display: 'inline-flex', alignItems: 'center', gap: '0.625rem', cursor: 'pointer' },
1646
+ base: { display: 'inline-flex', alignItems: 'center', gap: 'var(--space-md)', cursor: 'pointer' },
1623
1647
  states: {
1624
1648
  disabled: { opacity: 'var(--disabled-opacity)', cursor: 'not-allowed' },
1625
1649
  checked: {}, unchecked: {},
@@ -2209,7 +2233,7 @@ export const select: RecipeInput = {
2209
2233
  display: 'flex',
2210
2234
  alignItems: 'center',
2211
2235
  justifyContent: 'space-between',
2212
- gap: '0.625rem',
2236
+ gap: 'var(--space-md)',
2213
2237
  padding: 'var(--space-md) var(--space-lg)',
2214
2238
  fontSize: 'var(--text-sm)',
2215
2239
  fontWeight: 'var(--weight-medium)',
@@ -2278,6 +2302,15 @@ const btnHeight = (step: keyof typeof BTN_STEPS): string =>
2278
2302
 
2279
2303
  export const button: RecipeInput = {
2280
2304
  component: 'button',
2305
+ // Public to a design system derived from this one (#73).
2306
+ hooks: {
2307
+ properties: {
2308
+ '--btn-accent': 'The fill of the solid variant.',
2309
+ '--btn-on-accent': 'The ink on --btn-accent.',
2310
+ '--btn-soft': 'The soft fill (soft variant, unfilled hover).',
2311
+ '--btn-ink': 'The ink of the unfilled variants.',
2312
+ },
2313
+ },
2281
2314
  // The two axes meet here instead of multiplying. `color` sets the accent
2282
2315
  // pair; `variant` decides how the accent is used. 8 + 4 + 5 rules rather
2283
2316
  // than 8 × 4.
@@ -2288,9 +2321,34 @@ export const button: RecipeInput = {
2288
2321
  '--btn-ink': roleInk('primary'),
2289
2322
  },
2290
2323
  parts: {
2324
+ // The loading spinner (#50): a real part zero renders before the
2325
+ // label while `loading`, drawn as a ring in `currentColor` with one
2326
+ // transparent quadrant — it takes whatever ink the variant chose.
2327
+ // A literal duration and an explicit reduced-motion `none`: the kit
2328
+ // collapses `--duration-*` under reduced motion, and an infinite
2329
+ // loop at ~0s strobes rather than stops.
2330
+ // daisy marks a loading button with a `loading loading-spinner` span;
2331
+ // this is that span, zero's own now (it was a `loading` modifier's
2332
+ // `::before` until #50 made loading a Button state).
2333
+ spinner: {
2334
+ base: {
2335
+ boxSizing: 'border-box',
2336
+ inlineSize: '1em',
2337
+ blockSize: '1em',
2338
+ flex: 'none',
2339
+ borderRadius: '9999px',
2340
+ border: 'calc(var(--border) * 2) solid currentColor',
2341
+ borderBlockStartColor: 'transparent',
2342
+ animation: 'zero-daisyui-btn-spin 0.7s linear infinite',
2343
+ },
2344
+ at: { 'reduced-motion': { base: { animation: 'none' } } },
2345
+ },
2291
2346
  root: {
2292
2347
  base: {
2293
2348
  appearance: 'none',
2349
+ // An asChild `<a>` gets no UA underline (see the README's
2350
+ // link-button note for the unlayered `a { color }` case).
2351
+ textDecoration: 'none',
2294
2352
  display: 'inline-flex',
2295
2353
  alignItems: 'center',
2296
2354
  justifyContent: 'center',
@@ -2306,6 +2364,9 @@ export const button: RecipeInput = {
2306
2364
  + 'border-color var(--duration-fast) var(--ease-standard)',
2307
2365
  },
2308
2366
  states: {
2367
+ // Work in flight (`loading`, #50): still focusable, still legible —
2368
+ // the label is what the reader is waiting on — so no fade here.
2369
+ loading: { cursor: 'progress' },
2309
2370
  disabled: { opacity: 'var(--disabled-opacity)', cursor: 'not-allowed' },
2310
2371
  // The ring is drawn on the page, offset clear of the button —
2311
2372
  // ink, not fill, and the same 3:1 floor. On `--btn-accent` it
@@ -2451,37 +2512,6 @@ export const button: RecipeInput = {
2451
2512
  active: {
2452
2513
  root: { base: { transform: 'translateY(1px)', boxShadow: 'none', filter: 'brightness(0.92)' } },
2453
2514
  },
2454
- /**
2455
- * daisy's loading spinner, drawn by the recipe (#332): daisy marks
2456
- * the button with a `loading loading-spinner` span; zero changes no
2457
- * DOM, so the ring is a `::before` in `currentColor` with one
2458
- * transparent quadrant — it inherits whatever ink the variant chose,
2459
- * and the root's existing `gap` spaces it from the label. Literal
2460
- * duration + explicit reduced-motion `none`, like the spinner
2461
- * recipe: the kit collapses `--duration-*` under reduced motion, and
2462
- * an infinite loop at ~0s strobes rather than stops.
2463
- */
2464
- loading: {
2465
- root: {
2466
- base: { cursor: 'progress' },
2467
- selectors: {
2468
- '&::before': {
2469
- content: '""',
2470
- boxSizing: 'border-box',
2471
- inlineSize: '1em',
2472
- blockSize: '1em',
2473
- flex: 'none',
2474
- borderRadius: '9999px',
2475
- border: 'calc(var(--border) * 2) solid currentColor',
2476
- borderBlockStartColor: 'transparent',
2477
- animation: 'zero-daisyui-btn-spin 0.7s linear infinite',
2478
- },
2479
- },
2480
- at: {
2481
- 'reduced-motion': { selectors: { '&::before': { animation: 'none' } } },
2482
- },
2483
- },
2484
- },
2485
2515
  },
2486
2516
  defaultVariants: { color: 'primary', variant: 'solid', size: 'md' },
2487
2517
  keyframes: { 'zero-daisyui-btn-spin': 'to { transform: rotate(360deg) }' },
@@ -2549,6 +2579,13 @@ export const avatar: RecipeInput = {
2549
2579
  },
2550
2580
  },
2551
2581
  variants: {
2582
+ // The shape axis (zero#129) — one declaration on the root, whose
2583
+ // `overflow: hidden` clips the image and the fallback alike. `rounded` is daisy's selector radius; the default is the circle.
2584
+ shape: {
2585
+ circle: { root: { base: { borderRadius: '9999px' } } },
2586
+ square: { root: { base: { borderRadius: '0' } } },
2587
+ rounded: { root: { base: { borderRadius: 'var(--radius-selector)' } } },
2588
+ },
2552
2589
  // daisy colours the RING, not the fallback — that is what its avatar
2553
2590
  // does, and the fallback stays neutral so initials read the same
2554
2591
  // whichever role is chosen.
@@ -2760,6 +2797,7 @@ export const combobox: RecipeInput = {
2760
2797
  base: {
2761
2798
  display: 'inline-flex',
2762
2799
  alignItems: 'stretch',
2800
+ flexWrap: 'wrap',
2763
2801
  minWidth: '13rem',
2764
2802
  ...fieldControl,
2765
2803
  transition: 'border-color var(--duration-fast) var(--ease-standard)',
@@ -2805,6 +2843,52 @@ export const combobox: RecipeInput = {
2805
2843
  '&::placeholder': { color: 'color-mix(in oklab, var(--color-base-content) 60%, transparent)' },
2806
2844
  },
2807
2845
  },
2846
+ // A chosen value under `multiple` (#39): a chip in the control, before
2847
+ // the input. Symmetric metrics only — the physical-direction lint and
2848
+ // the lynx emitter both refuse a one-sided inset.
2849
+ // daisy's badge: a base-200 pill, centred in the stretched row.
2850
+ tag: {
2851
+ base: {
2852
+ display: 'inline-flex',
2853
+ alignItems: 'center',
2854
+ alignSelf: 'center',
2855
+ gap: 'var(--space-2xs)',
2856
+ margin: 'var(--space-2xs)',
2857
+ padding: 'var(--space-2xs) var(--space-sm)',
2858
+ background: 'var(--color-base-200)',
2859
+ color: 'var(--color-base-content)',
2860
+ borderRadius: 'var(--radius-selector)',
2861
+ fontSize: 'var(--text-xs)',
2862
+ fontWeight: 'var(--weight-medium)',
2863
+ lineHeight: 'var(--leading-none)',
2864
+ },
2865
+ states: { disabled: {} },
2866
+ },
2867
+ 'tag-label': { base: { whiteSpace: 'nowrap' } },
2868
+ 'tag-remove': {
2869
+ base: {
2870
+ appearance: 'none',
2871
+ border: 'none',
2872
+ background: 'transparent',
2873
+ color: 'inherit',
2874
+ font: 'inherit',
2875
+ borderRadius: 'var(--radius-selector)',
2876
+ padding: '0 var(--space-2xs)',
2877
+ lineHeight: 'var(--leading-none)',
2878
+ cursor: 'pointer',
2879
+ transition: 'background var(--duration-fast) var(--ease-standard)',
2880
+ },
2881
+ states: {
2882
+ hover: { background: 'color-mix(in oklab, var(--color-base-content) 10%, transparent)' },
2883
+ disabled: { opacity: 'var(--disabled-opacity)', cursor: 'not-allowed' },
2884
+ ...focusRing,
2885
+ },
2886
+ selectors: {
2887
+ '&[data-pressed]:not([data-disabled])': {
2888
+ background: 'color-mix(in oklab, var(--color-base-content) 18%, transparent)',
2889
+ },
2890
+ },
2891
+ },
2808
2892
  trigger: {
2809
2893
  base: {
2810
2894
  appearance: 'none',
@@ -2841,7 +2925,7 @@ export const combobox: RecipeInput = {
2841
2925
  display: 'flex',
2842
2926
  alignItems: 'center',
2843
2927
  justifyContent: 'space-between',
2844
- gap: '0.625rem',
2928
+ gap: 'var(--space-md)',
2845
2929
  padding: 'var(--space-md) var(--space-lg)',
2846
2930
  fontSize: 'var(--text-sm)',
2847
2931
  fontWeight: 'var(--weight-medium)',
@@ -2873,15 +2957,15 @@ export const combobox: RecipeInput = {
2873
2957
  '--combobox-accent': `var(--color-${c})`,
2874
2958
  } } }])),
2875
2959
  // The control carries the height — the shared field ramp, the same one
2876
- // `select`'s trigger steps on — and the input carries the inset and the
2877
- // type. `md` restates exactly the resting values so the union stays
2960
+ // `select`'s trigger steps on, as a floor so wrapped tags can grow it
2961
+ // — and the input carries the inset and the type. `md` restates exactly the resting values so the union stays
2878
2962
  // complete.
2879
2963
  size: {
2880
- xs: { control: { base: { height: fieldHeight('xs') } }, input: { base: { padding: 'var(--space-2xs) var(--space-md)', fontSize: 'var(--text-xs)' } } },
2881
- sm: { control: { base: { height: fieldHeight('sm') } }, input: { base: { padding: 'var(--space-xs) var(--space-md)', fontSize: 'var(--text-sm)' } } },
2882
- md: { control: { base: { height: fieldHeight('md') } }, input: { base: { padding: 'var(--space-sm) var(--space-lg)', fontSize: 'var(--text-sm)' } } },
2883
- lg: { control: { base: { height: fieldHeight('lg') } }, input: { base: { padding: 'var(--space-md) var(--space-xl)', fontSize: 'var(--text-md)' } } },
2884
- xl: { control: { base: { height: fieldHeight('xl') } }, input: { base: { padding: 'var(--space-lg) var(--space-2xl)', fontSize: 'var(--text-lg)' } } },
2964
+ xs: { control: { base: { minHeight: fieldHeight('xs') } }, input: { base: { padding: 'var(--space-2xs) var(--space-md)', fontSize: 'var(--text-xs)' } } },
2965
+ sm: { control: { base: { minHeight: fieldHeight('sm') } }, input: { base: { padding: 'var(--space-xs) var(--space-md)', fontSize: 'var(--text-sm)' } } },
2966
+ md: { control: { base: { minHeight: fieldHeight('md') } }, input: { base: { padding: 'var(--space-sm) var(--space-lg)', fontSize: 'var(--text-sm)' } } },
2967
+ lg: { control: { base: { minHeight: fieldHeight('lg') } }, input: { base: { padding: 'var(--space-md) var(--space-xl)', fontSize: 'var(--text-md)' } } },
2968
+ xl: { control: { base: { minHeight: fieldHeight('xl') } }, input: { base: { padding: 'var(--space-lg) var(--space-2xl)', fontSize: 'var(--text-lg)' } } },
2885
2969
  },
2886
2970
  },
2887
2971
  // The visible ring lives on `control`; input and trigger delegate.
@@ -2981,6 +3065,13 @@ export const toggle: RecipeInput = {
2981
3065
  // the on item filled with the accent.
2982
3066
  export const toggleGroup: RecipeInput = {
2983
3067
  component: 'toggle-group',
3068
+ // Public to a design system derived from this one (#73).
3069
+ hooks: {
3070
+ properties: {
3071
+ '--toggle-group-accent': 'The fill of an on item.',
3072
+ '--toggle-group-on-accent': 'The ink on --toggle-group-accent.',
3073
+ },
3074
+ },
2984
3075
  tokens: {
2985
3076
  '--toggle-group-accent': 'var(--color-primary)',
2986
3077
  '--toggle-group-on-accent': 'var(--color-primary-content)',
@@ -3417,7 +3508,7 @@ export const ratingGroup: RecipeInput = {
3417
3508
  const treeRow: NonNullable<PartStyles['base']> = {
3418
3509
  display: 'flex',
3419
3510
  alignItems: 'center',
3420
- gap: '0.625rem',
3511
+ gap: 'var(--space-md)',
3421
3512
  padding: 'var(--space-sm) var(--space-md)',
3422
3513
  fontSize: 'var(--tree-text)',
3423
3514
  fontWeight: 'var(--weight-medium)',
@@ -3696,95 +3787,6 @@ export const textarea: RecipeInput = {
3696
3787
  },
3697
3788
  };
3698
3789
 
3699
- /**
3700
- * NativeSelect (#333) — daisy "select" flavor on the platform's own picker:
3701
- * the `fieldControl` box (same metrics as every field, so mixed rows line
3702
- * up), `appearance: none`, and the recipe-drawn chevron at the inline end.
3703
- * No open state — the platform owns the popup — so colour surfaces on the
3704
- * focus ring, exactly like input's.
3705
- */
3706
- export const nativeSelect: RecipeInput = {
3707
- component: 'native-select',
3708
- tokens: { '--native-select-accent': 'var(--color-base-content)' },
3709
- parts: {
3710
- root: {
3711
- base: { position: 'relative', display: 'inline-flex', alignItems: 'center' },
3712
- states: { disabled: {}, invalid: {}, required: {}, placeholder: {} },
3713
- },
3714
- control: {
3715
- base: {
3716
- appearance: 'none',
3717
- width: '100%',
3718
- minWidth: '12rem',
3719
- ...fieldControl,
3720
- color: 'var(--color-base-content)',
3721
- font: 'inherit',
3722
- fontSize: 'var(--text-sm)',
3723
- padding: 'var(--space-sm) var(--space-md)',
3724
- // Room for the chevron the platform no longer draws.
3725
- paddingInlineEnd: 'calc(var(--space-md) + 1.25em)',
3726
- cursor: 'pointer',
3727
- transition: 'border-color var(--duration-fast) var(--ease-standard)',
3728
- },
3729
- states: {
3730
- hover: { borderColor: 'var(--color-base-content)' },
3731
- invalid: { borderColor: 'var(--color-error)' },
3732
- disabled: { opacity: 'var(--disabled-opacity)', cursor: 'not-allowed' },
3733
- required: {},
3734
- // Same muting as the fields' placeholders (#264).
3735
- placeholder: { color: 'color-mix(in oklab, var(--color-base-content) 60%, transparent)' },
3736
- 'focus-visible': { ...focusRing['focus-visible'], outline: '2px solid var(--native-select-accent)' },
3737
- },
3738
- },
3739
- indicator: {
3740
- base: {
3741
- position: 'absolute',
3742
- insetInlineEnd: 'var(--space-md)',
3743
- pointerEvents: 'none',
3744
- opacity: '0.6',
3745
- fontSize: 'var(--text-sm)',
3746
- },
3747
- },
3748
- },
3749
- // The visible ring lives on the <select> itself; the wrapper delegates.
3750
- skipStates: { root: ['focus-visible'] },
3751
- variants: {
3752
- // The ring carries the role — daisy's field chrome is neutral, so
3753
- // focus is the only place colour surfaces (input's exact move).
3754
- color: Object.fromEntries(ROLES.map((c) => [c, { root: { base: {
3755
- '--native-select-accent': `var(--color-${c})`,
3756
- } } }])),
3757
- // The shared field-height ramp, on the control itself.
3758
- size: {
3759
- xs: { control: { base: { height: fieldHeight('xs'), fontSize: 'var(--text-xs)', padding: 'var(--space-2xs) var(--space-xs)', paddingInlineEnd: 'calc(var(--space-xs) + 1.25em)' } } },
3760
- sm: { control: { base: { height: fieldHeight('sm'), fontSize: 'var(--text-xs)', padding: 'var(--space-xs) var(--space-sm)', paddingInlineEnd: 'calc(var(--space-sm) + 1.25em)' } } },
3761
- md: {},
3762
- lg: { control: { base: { height: fieldHeight('lg'), fontSize: 'var(--text-md)', padding: 'var(--space-md) var(--space-lg)', paddingInlineEnd: 'calc(var(--space-lg) + 1.25em)' } } },
3763
- xl: { control: { base: { height: fieldHeight('xl'), fontSize: 'var(--text-lg)', padding: 'var(--space-lg) var(--space-xl)', paddingInlineEnd: 'calc(var(--space-xl) + 1.25em)' } } },
3764
- },
3765
- },
3766
- // The chevron gutter and the indicator anchor, restated physically:
3767
- // logical spellings resolve on iOS but not on Android (measured,
3768
- // signalxjs/lynx#1084), so the emitter refuses them. Physical is the
3769
- // lynx target's norm — no RTL flow there, so `end` IS right.
3770
- targets: {
3771
- lynx: {
3772
- parts: {
3773
- control: { base: { paddingRight: 'calc(var(--space-md) + 1.25em)' } },
3774
- indicator: { base: { right: 'var(--space-md)' } },
3775
- },
3776
- variants: {
3777
- size: {
3778
- xs: { control: { base: { paddingRight: 'calc(var(--space-xs) + 1.25em)' } } },
3779
- sm: { control: { base: { paddingRight: 'calc(var(--space-sm) + 1.25em)' } } },
3780
- lg: { control: { base: { paddingRight: 'calc(var(--space-lg) + 1.25em)' } } },
3781
- xl: { control: { base: { paddingRight: 'calc(var(--space-xl) + 1.25em)' } } },
3782
- },
3783
- },
3784
- },
3785
- },
3786
- };
3787
-
3788
3790
  // ── Content tier (#311) ───────────────────────────────────────────────────
3789
3791
  /**
3790
3792
  * daisy "card" flavor: `bg-base-100` on a `rounded-box` with a real shadow —
@@ -3793,6 +3795,12 @@ export const nativeSelect: RecipeInput = {
3793
3795
  */
3794
3796
  export const card: RecipeInput = {
3795
3797
  component: 'card',
3798
+ // Public to a design system derived from this one (#73).
3799
+ hooks: {
3800
+ properties: {
3801
+ '--card-pad': 'The card padding.',
3802
+ },
3803
+ },
3796
3804
  tokens: { '--card-pad': 'var(--space-xl)' },
3797
3805
  parts: {
3798
3806
  root: {
@@ -3949,6 +3957,88 @@ export const alert: RecipeInput = {
3949
3957
  },
3950
3958
  };
3951
3959
 
3960
+ /**
3961
+ * EmptyState (zero#131) — daisy has no empty-state component; this is the
3962
+ * `hero`-content idiom at card scale: a centred column on the base-200
3963
+ * surface with the box radius, the icon in the role's ink and the surface
3964
+ * in its soft tint when a tone is given.
3965
+ */
3966
+ export const emptyState: RecipeInput = {
3967
+ component: 'empty-state',
3968
+ hooks: {
3969
+ properties: {
3970
+ '--empty-accent': 'The icon ink.',
3971
+ '--empty-tint': 'The surface fill.',
3972
+ },
3973
+ },
3974
+ tokens: {
3975
+ '--empty-accent': 'color-mix(in oklab, var(--color-base-content) 60%, transparent)',
3976
+ '--empty-tint': 'var(--color-base-200)',
3977
+ },
3978
+ parts: {
3979
+ root: {
3980
+ base: {
3981
+ display: 'flex',
3982
+ flexDirection: 'column',
3983
+ alignItems: 'center',
3984
+ textAlign: 'center',
3985
+ gap: 'var(--space-sm)',
3986
+ padding: 'var(--space-2xl) var(--space-xl)',
3987
+ background: 'var(--empty-tint)',
3988
+ color: 'var(--color-base-content)',
3989
+ borderRadius: 'var(--radius-box)',
3990
+ },
3991
+ },
3992
+ icon: {
3993
+ base: {
3994
+ display: 'inline-flex',
3995
+ color: 'var(--empty-accent)',
3996
+ fontSize: 'var(--text-2xl)',
3997
+ lineHeight: 'var(--leading-none)',
3998
+ marginBlockEnd: 'var(--space-xs)',
3999
+ },
4000
+ },
4001
+ title: {
4002
+ base: {
4003
+ margin: '0',
4004
+ fontSize: 'var(--text-lg)',
4005
+ fontWeight: 'var(--weight-bold)',
4006
+ lineHeight: 'var(--leading-tight)',
4007
+ },
4008
+ },
4009
+ description: {
4010
+ base: {
4011
+ maxInlineSize: '36ch',
4012
+ fontSize: 'var(--text-sm)',
4013
+ lineHeight: 'var(--leading-normal)',
4014
+ color: 'color-mix(in oklab, var(--color-base-content) 70%, transparent)',
4015
+ },
4016
+ },
4017
+ actions: {
4018
+ base: {
4019
+ display: 'flex',
4020
+ flexWrap: 'wrap',
4021
+ justifyContent: 'center',
4022
+ gap: 'var(--space-sm)',
4023
+ marginBlockStart: 'var(--space-sm)',
4024
+ },
4025
+ },
4026
+ },
4027
+ variants: {
4028
+ color: Object.fromEntries(ROLES.map((c) => [c, { root: { base: {
4029
+ '--empty-accent': roleInk(c),
4030
+ '--empty-tint': `var(--color-${c}-soft)`,
4031
+ } } }])),
4032
+ size: {
4033
+ xs: { root: { base: { padding: 'var(--space-md)', gap: 'var(--space-2xs)' } }, icon: { base: { fontSize: 'var(--text-xl)' } }, title: { base: { fontSize: 'var(--text-sm)' } }, description: { base: { fontSize: 'var(--text-xs)' } } },
4034
+ sm: { root: { base: { padding: 'var(--space-lg)', gap: 'var(--space-xs)' } }, icon: { base: { fontSize: 'var(--text-2xl)' } }, title: { base: { fontSize: 'var(--text-md)' } }, description: { base: { fontSize: 'var(--text-xs)' } } },
4035
+ md: {},
4036
+ lg: { root: { base: { padding: 'calc(var(--space-2xl) * 1.5) var(--space-2xl)', gap: 'var(--space-md)' } }, icon: { base: { fontSize: 'var(--text-2xl)' } }, title: { base: { fontSize: 'var(--text-xl)' } }, description: { base: { fontSize: 'var(--text-md)' } } },
4037
+ xl: { root: { base: { padding: 'calc(var(--space-2xl) * 2) var(--space-2xl)', gap: 'var(--space-lg)' } }, icon: { base: { fontSize: 'var(--text-2xl)' } }, title: { base: { fontSize: 'var(--text-2xl)' } }, description: { base: { fontSize: 'var(--text-md)' } } },
4038
+ },
4039
+ },
4040
+ };
4041
+
3952
4042
  /**
3953
4043
  * daisy "badge" flavor: a fully-rounded pill with a hairline, filled by the
3954
4044
  * role. No `variant` — the repo-wide decision (#175) holds everywhere except
@@ -3957,7 +4047,18 @@ export const alert: RecipeInput = {
3957
4047
  */
3958
4048
  export const badge: RecipeInput = {
3959
4049
  component: 'badge',
4050
+ // Public to a design system derived from this one (#73).
4051
+ hooks: {
4052
+ properties: {
4053
+ '--badge-dot': 'The status dot\'s fill (zero#130); the pill\'s ink at rest.',
4054
+ '--badge-dot-ring': 'The ring around a coloured status dot.',
4055
+ '--badge-fill': 'The badge fill.',
4056
+ '--badge-ink': 'The badge ink.',
4057
+ },
4058
+ },
3960
4059
  tokens: {
4060
+ '--badge-dot': 'currentColor',
4061
+ '--badge-dot-ring': 'currentColor',
3961
4062
  '--badge-fill': 'var(--color-base-200)',
3962
4063
  '--badge-ink': 'var(--color-base-content)',
3963
4064
  },
@@ -3973,7 +4074,7 @@ export const badge: RecipeInput = {
3973
4074
  // daisy badges are pills; a large radius rather than 9999px so
3974
4075
  // a square-cornered theme can still say so through the token.
3975
4076
  borderRadius: 'var(--radius-selector)',
3976
- padding: '0.125rem 0.625rem',
4077
+ padding: 'var(--space-2xs) var(--space-lg)',
3977
4078
  fontSize: 'var(--text-xs)',
3978
4079
  fontWeight: 'var(--weight-semibold)',
3979
4080
  lineHeight: 'var(--leading-normal)',
@@ -3981,12 +4082,53 @@ export const badge: RecipeInput = {
3981
4082
  textDecoration: 'none',
3982
4083
  },
3983
4084
  },
4085
+ // The status dot (zero#130). On an uncoloured pill it is the pill's
4086
+ // INK — a dot in `currentColor` is legible on whatever fill the pill
4087
+ // has. Coloured — by its own `data-color`, or by the pill's, since the
4088
+ // nearest carrier wins (#94) — it is the role's fill inside a ring in
4089
+ // the role's `-content` ink, the timeline marker's answer: a dot that
4090
+ // follows a solid pill of the same role still has an edge. `running` is a
4091
+ // static halo the pulse breathes; under reduced motion the halo
4092
+ // stays and the breathing stops, so the state never vanishes.
4093
+ dot: {
4094
+ base: {
4095
+ display: 'inline-block',
4096
+ flex: 'none',
4097
+ inlineSize: '0.5em',
4098
+ blockSize: '0.5em',
4099
+ boxSizing: 'border-box',
4100
+ borderRadius: '50%',
4101
+ background: 'var(--badge-dot)',
4102
+ border: '0.1em solid var(--badge-dot-ring)',
4103
+ },
4104
+ states: {
4105
+ running: {
4106
+ boxShadow: '0 0 0 0.2em color-mix(in oklch, var(--badge-dot) 35%, transparent)',
4107
+ animation: 'zero-daisy-badge-pulse 1.6s ease-out infinite',
4108
+ },
4109
+ },
4110
+ at: {
4111
+ 'reduced-motion': { states: { running: { animation: 'none' } } },
4112
+ // Forced colours strip the fill and keep the border, so the
4113
+ // border draws the whole dot there.
4114
+ 'forced-colors': { base: { borderWidth: '0.25em' } },
4115
+ },
4116
+ },
3984
4117
  },
3985
4118
  variants: {
3986
- color: Object.fromEntries(ROLES.map((c) => [c, { root: { base: {
3987
- '--badge-fill': `var(--color-${c})`,
3988
- '--badge-ink': `var(--color-${c}-content)`,
3989
- } } }])),
4119
+ color: Object.fromEntries(ROLES.map((c) => [c, {
4120
+ root: { base: {
4121
+ '--badge-fill': `var(--color-${c})`,
4122
+ '--badge-ink': `var(--color-${c}-content)`,
4123
+ } },
4124
+ // The dot's colour (zero#130): keyed under `variants.color` so the
4125
+ // compiler anchors it on the pill's `data-color` AND on the dot's
4126
+ // own re-carried one — the nearest carrier wins (#94).
4127
+ dot: { base: {
4128
+ '--badge-dot': `var(--color-${c})`,
4129
+ '--badge-dot-ring': `var(--color-${c}-content)`,
4130
+ } },
4131
+ }])),
3990
4132
  size: {
3991
4133
  xs: { root: { base: { fontSize: 'var(--text-xs)', padding: '0 var(--space-sm)' } } },
3992
4134
  sm: { root: { base: { fontSize: 'var(--text-xs)', padding: '0 var(--space-md)' } } },
@@ -3994,6 +4136,8 @@ export const badge: RecipeInput = {
3994
4136
  lg: { root: { base: { fontSize: 'var(--text-sm)', padding: 'var(--space-2xs) var(--space-lg)' } } },
3995
4137
  xl: { root: { base: { fontSize: 'var(--text-md)', padding: 'var(--space-xs) var(--space-xl)' } } },
3996
4138
  },
4139
+ }, keyframes: {
4140
+ 'zero-daisy-badge-pulse': 'from { box-shadow: 0 0 0 0 color-mix(in oklch, var(--badge-dot) 45%, transparent); } to { box-shadow: 0 0 0 0.5em transparent; }',
3997
4141
  },
3998
4142
  };
3999
4143
 
@@ -4039,6 +4183,12 @@ export const divider: RecipeInput = {
4039
4183
  */
4040
4184
  export const skeleton: RecipeInput = {
4041
4185
  component: 'skeleton',
4186
+ // Public to a design system derived from this one (#73).
4187
+ hooks: {
4188
+ properties: {
4189
+ '--skeleton-fill': 'The placeholder fill.',
4190
+ },
4191
+ },
4042
4192
  tokens: { '--skeleton-fill': 'var(--color-base-300)' },
4043
4193
  parts: {
4044
4194
  root: {
@@ -4128,7 +4278,11 @@ export const kbd: RecipeInput = {
4128
4278
  alignItems: 'center',
4129
4279
  justifyContent: 'center',
4130
4280
  minInlineSize: '1.75em',
4131
- padding: '0.0625rem 0.4375rem',
4281
+ // daisy's .0625rem/.4375rem, restated as the ramp steps they
4282
+ // already are once the cap's own border is absorbed: the OUTER
4283
+ // box lands on 2xs/md, so the cap rides the ramp under a density
4284
+ // override while keeping daisy's painted size exactly at 1px.
4285
+ padding: 'calc(var(--space-2xs) - var(--border)) calc(var(--space-md) - var(--border))',
4132
4286
  background: 'var(--kbd-fill)',
4133
4287
  color: 'var(--kbd-ink)',
4134
4288
  border: 'var(--border) solid var(--color-base-300)',
@@ -4361,10 +4515,32 @@ export const stats: RecipeInput = {
4361
4515
  },
4362
4516
  };
4363
4517
 
4518
+ /**
4519
+ * A timeline none of whose content sits on the start side: the root carries
4520
+ * no start-placed content among its own items. Child combinators all the way
4521
+ * down, so a timeline nested inside another's content answers only for
4522
+ * itself.
4523
+ */
4524
+ const TIMELINE_NO_START = '[data-scope="timeline"][data-part="root"]'
4525
+ + ':not(:has(> [data-scope="timeline"][data-part="item"] > [data-scope="timeline"][data-part="content"][data-placement="start"])) > &';
4526
+
4364
4527
  /** daisy timeline: the boxed content beside a dotted axis. */
4365
4528
  export const timeline: RecipeInput = {
4366
4529
  component: 'timeline',
4367
- tokens: { '--timeline-accent': 'var(--color-base-content)', '--timeline-marker-size': 'calc(var(--size-selector) * 3)' },
4530
+ // Public to a design system derived from this one (#73).
4531
+ hooks: {
4532
+ properties: {
4533
+ '--timeline-accent': 'The marker and connector accent.',
4534
+ '--timeline-ring': 'The ring around a coloured marker — the role\'s -content ink (#94).',
4535
+ '--timeline-marker-size': 'The marker diameter.',
4536
+ },
4537
+ },
4538
+ tokens: {
4539
+ '--timeline-accent': 'var(--color-base-content)',
4540
+ // The coloured dot's ring (#94); uncoloured, it IS the dot.
4541
+ '--timeline-ring': 'var(--timeline-accent)',
4542
+ '--timeline-marker-size': 'calc(var(--size-selector) * 3)',
4543
+ },
4368
4544
  parts: {
4369
4545
  root: {
4370
4546
  base: {
@@ -4400,6 +4576,11 @@ export const timeline: RecipeInput = {
4400
4576
  gridTemplateColumns: 'auto 1fr',
4401
4577
  flex: '1 1 0%',
4402
4578
  },
4579
+ // No content on the start side anywhere in this timeline →
4580
+ // the start track collapses instead of holding half the item
4581
+ // empty (#57). Automatic, so there is no prop to forget.
4582
+ [`${TIMELINE_NO_START}[data-orientation="vertical"]`]: { gridTemplateColumns: '0 auto 1fr' },
4583
+ [`${TIMELINE_NO_START}[data-orientation="horizontal"]`]: { gridTemplateRows: '0 auto 1fr' },
4403
4584
  },
4404
4585
  },
4405
4586
  marker: {
@@ -4409,12 +4590,24 @@ export const timeline: RecipeInput = {
4409
4590
  boxSizing: 'border-box',
4410
4591
  borderRadius: '9999px',
4411
4592
  background: 'var(--timeline-accent)',
4412
- border: 'calc(var(--timeline-marker-size) / 2) solid var(--timeline-accent)',
4593
+ // A coloured dot is the role fill inside a RING in the role's
4594
+ // `-content` ink (#94): a light role on a light page, or a dark
4595
+ // one on a dark page, has no edge of its own, and a per-entry
4596
+ // tone is exactly the mark a reader has to find. One half of
4597
+ // the role's own pair clears 3:1 against the page (the contrast
4598
+ // audit measures every colour). With no colour the ring is the
4599
+ // fill — the same solid dot as before.
4600
+ border: 'calc(var(--timeline-marker-size) / 6) solid var(--timeline-ring)',
4413
4601
  display: 'flex',
4414
4602
  alignItems: 'center',
4415
4603
  justifyContent: 'center',
4416
4604
  fontSize: '0',
4417
4605
  },
4606
+ at: {
4607
+ // Forced colours strip the fill and keep the border, so the
4608
+ // border draws the whole dot there, as it always did.
4609
+ 'forced-colors': { base: { borderWidth: 'calc(var(--timeline-marker-size) / 2)' } },
4610
+ },
4418
4611
  selectors: {
4419
4612
  // The axis cell, both orientations. `place-self` centres the
4420
4613
  // dot on the line in the cross axis.
@@ -4492,6 +4685,7 @@ export const timeline: RecipeInput = {
4492
4685
  variants: {
4493
4686
  color: Object.fromEntries(ROLES.map((c) => [c, { marker: { base: {
4494
4687
  '--timeline-accent': `var(--color-${c})`,
4688
+ '--timeline-ring': `var(--color-${c}-content)`,
4495
4689
  } } }])),
4496
4690
  size: {
4497
4691
  xs: { marker: { base: { '--timeline-marker-size': 'calc(var(--size-selector) * 2)' } }, content: { base: { fontSize: 'var(--text-xs)' } } },
@@ -4863,6 +5057,112 @@ export const navbar: RecipeInput = {
4863
5057
  },
4864
5058
  };
4865
5059
 
5060
+ /**
5061
+ * NavList (zero#132) — daisy's `menu` idiom for a sidebar: rounded rows, a
5062
+ * base-200 wash on hover, and the current page as the role's soft fill in
5063
+ * the role's ink. `meta` is where daisy's menu puts its badge.
5064
+ */
5065
+ export const navList: RecipeInput = {
5066
+ component: 'nav-list',
5067
+ hooks: {
5068
+ properties: {
5069
+ '--nav-accent': 'The current link\'s ink.',
5070
+ '--nav-tint': 'The current link\'s fill.',
5071
+ '--nav-ink': 'The resting link ink.',
5072
+ },
5073
+ },
5074
+ tokens: {
5075
+ '--nav-accent': 'var(--color-base-content)',
5076
+ '--nav-tint': 'var(--color-base-300)',
5077
+ '--nav-ink': 'var(--color-base-content)',
5078
+ },
5079
+ parts: {
5080
+ root: {
5081
+ base: {
5082
+ display: 'flex',
5083
+ flexDirection: 'column',
5084
+ gap: 'var(--space-lg)',
5085
+ fontSize: 'var(--text-sm)',
5086
+ color: 'var(--color-base-content)',
5087
+ },
5088
+ },
5089
+ group: {
5090
+ base: { display: 'flex', flexDirection: 'column', gap: 'var(--space-xs)' },
5091
+ },
5092
+ heading: {
5093
+ base: {
5094
+ padding: '0 var(--space-lg)',
5095
+ fontSize: 'var(--text-xs)',
5096
+ fontWeight: 'var(--weight-bold)',
5097
+ opacity: '0.6',
5098
+ },
5099
+ },
5100
+ list: {
5101
+ base: {
5102
+ display: 'flex',
5103
+ flexDirection: 'column',
5104
+ gap: 'var(--space-2xs)',
5105
+ listStyle: 'none',
5106
+ margin: '0',
5107
+ padding: '0',
5108
+ },
5109
+ },
5110
+ item: { base: { display: 'block' } },
5111
+ link: {
5112
+ base: {
5113
+ display: 'flex',
5114
+ alignItems: 'center',
5115
+ gap: 'var(--space-sm)',
5116
+ textDecoration: 'none',
5117
+ color: 'var(--nav-ink)',
5118
+ padding: 'var(--space-sm) var(--space-lg)',
5119
+ borderRadius: 'var(--radius-field)',
5120
+ fontWeight: 'var(--weight-medium)',
5121
+ lineHeight: 'var(--leading-tight)',
5122
+ transition: 'background var(--duration-fast) var(--ease-standard)',
5123
+ },
5124
+ states: {
5125
+ hover: { background: 'var(--color-base-200)' },
5126
+ active: { background: 'var(--nav-tint)', color: 'var(--nav-accent)' },
5127
+ inactive: {},
5128
+ ...focusRing,
5129
+ },
5130
+ },
5131
+ icon: {
5132
+ base: {
5133
+ display: 'inline-flex',
5134
+ inlineSize: '1.25em',
5135
+ justifyContent: 'center',
5136
+ flex: 'none',
5137
+ lineHeight: 'var(--leading-none)',
5138
+ },
5139
+ },
5140
+ meta: {
5141
+ base: {
5142
+ marginInlineStart: 'auto',
5143
+ display: 'inline-flex',
5144
+ alignItems: 'center',
5145
+ fontSize: 'var(--text-xs)',
5146
+ fontVariantNumeric: 'tabular-nums',
5147
+ opacity: '0.7',
5148
+ },
5149
+ },
5150
+ },
5151
+ variants: {
5152
+ color: Object.fromEntries(ROLES.map((c) => [c, { root: { base: {
5153
+ '--nav-accent': roleInk(c),
5154
+ '--nav-tint': `var(--color-${c}-soft)`,
5155
+ } } }])),
5156
+ size: {
5157
+ xs: { root: { base: { fontSize: 'var(--text-xs)' } }, link: { base: { padding: 'var(--space-2xs) var(--space-md)' } } },
5158
+ sm: { root: { base: { fontSize: 'var(--text-xs)' } }, link: { base: { padding: 'var(--space-xs) var(--space-md)' } } },
5159
+ md: {},
5160
+ lg: { root: { base: { fontSize: 'var(--text-md)' } }, link: { base: { padding: 'var(--space-md) var(--space-lg)' } } },
5161
+ xl: { root: { base: { fontSize: 'var(--text-lg)' } }, link: { base: { padding: 'var(--space-lg) var(--space-xl)' } } },
5162
+ },
5163
+ },
5164
+ };
5165
+
4866
5166
  /**
4867
5167
  * Breadcrumbs — daisy's breadcrumbs: a compact text-sm trail, links
4868
5168
  * underlining on hover, the current crumb plain full ink. Colour rebinds
@@ -5078,6 +5378,20 @@ export const pagination: RecipeInput = {
5078
5378
  },
5079
5379
  };
5080
5380
 
5381
+ /**
5382
+ * The complete step's digit: the role hue deepened toward base-content, on
5383
+ * the disc's 20% tint of that role — `roleInk`'s per-role keep, capped at
5384
+ * 70%. The cap is the primary story: 95% on the tint measured 2.96:1 under
5385
+ * nord's muted primary (the static contrast matrix, #403). The per-role part
5386
+ * arrived with per-step colour (#112), when the matrix first measured every
5387
+ * role here: a flat 70% put nord's muted accent/success/warning at 2.2–2.6:1
5388
+ * and the dark themes' neutral (darker than the page) at 1.7–2.0:1, and
5389
+ * `ROLE_INK_KEEP` already knew how much hue each of those can keep.
5390
+ */
5391
+ const stepInk = (accent: string, role: string): string =>
5392
+ `color-mix(in oklab, ${accent} `
5393
+ + `${Math.min(ROLE_INK_KEEP[role as keyof typeof roles] ?? 55, 70)}%, var(--color-base-content))`;
5394
+
5081
5395
  /**
5082
5396
  * Steps — daisy's steps translated to the richer rail: bold numbered discs
5083
5397
  * on base-200, the walked disc and line refilled with the accent pair
@@ -5090,6 +5404,9 @@ export const steps: RecipeInput = {
5090
5404
  '--steps-accent': 'var(--color-primary)',
5091
5405
  '--steps-accent-content': 'var(--color-primary-content)',
5092
5406
  '--steps-accent-ink': roleInk('primary'),
5407
+ // Read through the accent, so it resolves on the element that sets
5408
+ // it; a colour on the item restates it for that role.
5409
+ '--steps-complete-ink': stepInk('var(--steps-accent)', 'primary'),
5093
5410
  '--steps-ind': 'calc(var(--size-selector) * 7)',
5094
5411
  '--steps-font': 'var(--text-sm)',
5095
5412
  },
@@ -5161,7 +5478,14 @@ export const steps: RecipeInput = {
5161
5478
  },
5162
5479
  states: {
5163
5480
  active: { background: 'var(--steps-accent)', color: 'var(--steps-accent-content)' },
5164
- complete: { background: 'color-mix(in oklch, var(--steps-accent) 20%, var(--color-base-100))', color: 'var(--steps-accent-ink)' },
5481
+ // Ink deepened toward base-content, per role (`stepInk`). The
5482
+ // tint mixes in OKLAB: daisy's light base-100 is
5483
+ // `oklch(100% 0 0)`, whose 0 hue the browser interpolates as
5484
+ // written, so an OKLCH mix swung every role's tint toward red
5485
+ // (warning's yellow came out pink) — which the browser parity
5486
+ // check caught once per-step colour put all eight roles on
5487
+ // the disc (#112).
5488
+ complete: { background: 'color-mix(in oklab, var(--steps-accent) 20%, var(--color-base-100))', color: 'var(--steps-complete-ink)' },
5165
5489
  inactive: { background: 'var(--color-base-200)', color: 'var(--color-base-content)' },
5166
5490
  },
5167
5491
  },
@@ -5210,10 +5534,15 @@ export const steps: RecipeInput = {
5210
5534
  },
5211
5535
  },
5212
5536
  variants: {
5213
- color: Object.fromEntries(ROLES.map((c) => [c, { root: { base: {
5537
+ // Keyed on the ITEM, not the root (#112): the item re-carries
5538
+ // `color`, so a step's own value outranks the rail's, and the
5539
+ // disc, bridge and title inside it inherit whichever won. A root
5540
+ // colour still reaches every item through the carrier's donut.
5541
+ color: Object.fromEntries(ROLES.map((c) => [c, { item: { base: {
5214
5542
  '--steps-accent': `var(--color-${c})`,
5215
5543
  '--steps-accent-content': `var(--color-${c}-content)`,
5216
5544
  '--steps-accent-ink': roleInk(c),
5545
+ '--steps-complete-ink': stepInk(`var(--color-${c})`, c),
5217
5546
  } } }])),
5218
5547
  size: {
5219
5548
  xs: { root: { base: { '--steps-ind': 'calc(var(--size-selector) * 5)', '--steps-font': 'var(--text-xs)' } } },
@@ -5253,10 +5582,46 @@ export const steps: RecipeInput = {
5253
5582
  },
5254
5583
  };
5255
5584
 
5585
+ /**
5586
+ * The modal sheet's slide (#83): in from its edge, back out to it.
5587
+ *
5588
+ * `translate` is physical, so the travel is a custom property that flips with
5589
+ * the placement AND the direction — off the reading start is leftward in LTR
5590
+ * and rightward in RTL (the `rtl` hedge, as everywhere here). Keyed on
5591
+ * `data-l-dock="sheet"`, the regime, which holds through the exit where
5592
+ * `:modal` does not, so the slide-out leaves from the sheet's own box.
5593
+ * Opacity stays at 1: the travel is the transition. Each direction rides the
5594
+ * transition declared at its destination; reduced motion drops both.
5595
+ */
5596
+ const sheetSlide = (enter: string, exit: string): PartStyles => {
5597
+ const sheet = '&[data-l-dock="sheet"]';
5598
+ const open = `${sheet}[data-state="open"]`;
5599
+ const travel = (tempo: string): string => {
5600
+ const duration = tempo.split(' ')[0];
5601
+ return `translate ${tempo}, display ${duration} allow-discrete, overlay ${duration} allow-discrete`;
5602
+ };
5603
+ return {
5604
+ base: { '--drawer-travel': '-100%' },
5605
+ selectors: {
5606
+ '&[data-placement="end"]': { '--drawer-travel': '100%' },
5607
+ [`&[data-placement="start"]${rtl}`]: { '--drawer-travel': '100%' },
5608
+ [`&[data-placement="end"]${rtl}`]: { '--drawer-travel': '-100%' },
5609
+ [sheet]: { opacity: '1', translate: 'var(--drawer-travel) 0', transition: travel(exit) },
5610
+ [open]: { translate: 'none', transition: travel(enter) },
5611
+ },
5612
+ at: {
5613
+ 'starting-style': { selectors: { [open]: { opacity: '1', translate: 'var(--drawer-travel) 0' } } },
5614
+ 'reduced-motion': { selectors: { [sheet]: { transition: 'none' }, [open]: { transition: 'none' } } },
5615
+ },
5616
+ };
5617
+ };
5618
+
5256
5619
  /**
5257
5620
  * Drawer — daisy's drawer-side as a native <dialog> edge sheet: base-100
5258
- * paper, no border (daisy separates the drawer with the scrim), faded in.
5259
- * Base render is the inline mode; `:modal` is the top-layer edge sheet.
5621
+ * paper, no border (daisy separates the drawer with the scrim), sliding in
5622
+ * from its edge the way daisy's drawer-side does (`translate` over 0.3s,
5623
+ * #83). Base render is the inline mode; `data-l-dock="sheet"` is the
5624
+ * top-layer edge sheet.
5260
5625
  */
5261
5626
  export const drawer: RecipeInput = {
5262
5627
  component: 'drawer',
@@ -5271,7 +5636,10 @@ export const drawer: RecipeInput = {
5271
5636
  ...focusRing,
5272
5637
  },
5273
5638
  },
5274
- panel: withPresence(popupPresence('none'), {
5639
+ panel: withPresence(withPresence(popupPresence('none'), sheetSlide(
5640
+ 'var(--duration-slow) var(--ease-standard)',
5641
+ 'var(--duration-slow) var(--ease-standard)',
5642
+ )), {
5275
5643
  base: {
5276
5644
  padding: 'var(--space-lg)',
5277
5645
  background: 'var(--color-base-100)',
@@ -5279,30 +5647,39 @@ export const drawer: RecipeInput = {
5279
5647
  border: 'none',
5280
5648
  borderRadius: 'var(--radius-box)',
5281
5649
  boxShadow: 'var(--shadow-xl)',
5282
- inlineSize: 'min(20rem, 85vw)',
5650
+ // The width is capped by `--l-measure` (Drawer.Panel's
5651
+ // `measure`, via the layout step table); this is the default
5652
+ // an unset `measure` leaves. A cap, not a width, because the
5653
+ // table spells `full` as `none` — valid only for a max. In
5654
+ // flow and as a sheet alike, the panel fills what it is given
5655
+ // up to the measure, as its border box, so `full` never
5656
+ // overflows by its own padding.
5657
+ boxSizing: 'border-box',
5658
+ '--l-measure': 'min(20rem, 85vw)',
5659
+ inlineSize: '100%',
5660
+ maxInlineSize: 'var(--l-measure)',
5283
5661
  },
5284
5662
  states: { open: {}, closed: {} },
5285
5663
  selectors: {
5286
5664
  /**
5287
- * The platform's own spelling of "this open is the modal
5288
- * one": `:modal`. The base styles above are the INLINE
5289
- * render (`show()` keeps the panel in flow); this block is
5290
- * the top-layer edge sheet. Logical insets pin the edge, so
5291
- * RTL mirrors free.
5665
+ * The top-layer edge sheet, keyed on the regime
5666
+ * (`data-l-dock="sheet"`) rather than `:modal`, which stops
5667
+ * matching the moment `close()` runs — so the sheet keeps its
5668
+ * box through the exit (#83). The base styles above are the
5669
+ * INLINE render. Logical insets pin the edge, so RTL mirrors
5670
+ * free.
5292
5671
  */
5293
- '&:modal': {
5672
+ '&[data-l-dock="sheet"]': {
5294
5673
  position: 'fixed',
5295
5674
  insetBlockStart: '0',
5296
5675
  insetBlockEnd: '0',
5297
5676
  blockSize: '100dvh',
5298
5677
  maxBlockSize: '100dvh',
5299
- inlineSize: 'min(20rem, 85vw)',
5300
- maxInlineSize: 'none',
5301
5678
  margin: '0',
5302
5679
  borderRadius: '0',
5303
5680
  },
5304
- '&[data-placement="start"]:modal': { insetInlineStart: '0', insetInlineEnd: 'auto' },
5305
- '&[data-placement="end"]:modal': { insetInlineStart: 'auto', insetInlineEnd: '0' },
5681
+ '&[data-placement="start"][data-l-dock="sheet"]': { insetInlineStart: '0', insetInlineEnd: 'auto' },
5682
+ '&[data-placement="end"][data-l-dock="sheet"]': { insetInlineStart: 'auto', insetInlineEnd: '0' },
5306
5683
  },
5307
5684
  }),
5308
5685
  backdrop: {
@@ -5344,7 +5721,20 @@ export const drawer: RecipeInput = {
5344
5721
  */
5345
5722
  export const table: RecipeInput = {
5346
5723
  component: 'table',
5724
+ // Public to a design system derived from this one (#73).
5725
+ hooks: {
5726
+ properties: {
5727
+ '--table-cell-align': 'Cell text alignment; zero writes it inline from the column spec.',
5728
+ '--table-accent': 'The header ink accent.',
5729
+ '--table-pad-block': 'Cell block padding.',
5730
+ '--table-pad-inline': 'Cell inline padding.',
5731
+ '--table-font': 'The table font size.',
5732
+ },
5733
+ },
5347
5734
  tokens: {
5735
+ // The column spec's alignment (#55): a cell that names an aligned
5736
+ // column overrides it inline; everything else reads `start`.
5737
+ '--table-cell-align': 'start',
5348
5738
  '--table-accent': 'var(--color-base-content)',
5349
5739
  '--table-pad-block': 'var(--space-sm)',
5350
5740
  '--table-pad-inline': 'var(--space-md)',
@@ -5358,6 +5748,9 @@ export const table: RecipeInput = {
5358
5748
  borderRadius: 'var(--radius-box)',
5359
5749
  background: 'var(--color-base-100)',
5360
5750
  },
5751
+ // Stacked (Table.Root stack, #55): zero lays each row out as a block
5752
+ // below the table's breakpoint; the card it becomes is drawn here.
5753
+ at: tableStackAt(tokens, 'root', { border: '0', borderRadius: '0', background: 'transparent' }),
5361
5754
  },
5362
5755
  table: {
5363
5756
  base: {
@@ -5375,34 +5768,52 @@ export const table: RecipeInput = {
5375
5768
  fontSize: 'var(--text-xs)',
5376
5769
  color: 'color-mix(in oklab, var(--color-base-content) 60%, transparent)',
5377
5770
  },
5771
+ at: tableStackAt(tokens, 'caption', { paddingInline: '0' }),
5378
5772
  },
5379
5773
  head: {},
5380
- body: {},
5774
+ body: { at: tableStackAt(tokens, 'body', { rowGap: 'var(--space-md)' }) },
5381
5775
  foot: {
5382
5776
  base: {
5383
5777
  fontSize: 'var(--text-xs)',
5384
5778
  color: 'color-mix(in oklab, var(--color-base-content) 60%, transparent)',
5385
5779
  },
5780
+ at: tableStackAt(tokens, 'foot', { rowGap: 'var(--space-md)', marginBlockStart: 'var(--space-md)' }),
5386
5781
  },
5387
5782
  row: {
5388
5783
  base: { borderBlockEnd: 'var(--border) solid var(--color-base-200)' },
5389
5784
  states: {
5390
5785
  selected: { background: 'var(--color-base-300)' },
5391
5786
  },
5787
+ at: tableStackAt(tokens, 'row', {
5788
+ border: 'var(--border) solid var(--color-base-200)',
5789
+ borderRadius: 'var(--radius-box)',
5790
+ background: 'var(--color-base-100)',
5791
+ padding: 'var(--table-pad-block) var(--table-pad-inline)',
5792
+ }),
5392
5793
  },
5393
5794
  'header-cell': {
5394
5795
  base: {
5395
5796
  padding: 'var(--table-pad-block) var(--table-pad-inline)',
5396
- textAlign: 'start',
5797
+ textAlign: 'var(--table-cell-align)',
5397
5798
  fontWeight: 'var(--weight-semibold)',
5398
5799
  fontSize: 'var(--text-xs)',
5399
5800
  color: 'color-mix(in oklab, var(--table-accent) 60%, transparent)',
5400
5801
  },
5802
+ at: tableStackAt(tokens, 'header-cell', { paddingInline: '0' }),
5401
5803
  },
5402
5804
  cell: {
5403
5805
  base: {
5404
5806
  padding: 'var(--table-pad-block) var(--table-pad-inline)',
5405
- textAlign: 'start',
5807
+ textAlign: 'var(--table-cell-align)',
5808
+ },
5809
+ at: tableStackAt(tokens, 'cell', { paddingInline: '0', paddingBlock: 'var(--space-xs)' }),
5810
+ },
5811
+ // A stacked cell's column label, in the header's muted semibold.
5812
+ 'cell-label': {
5813
+ base: {
5814
+ fontWeight: 'var(--weight-semibold)',
5815
+ fontSize: 'var(--text-xs)',
5816
+ color: 'color-mix(in oklab, var(--table-accent) 60%, transparent)',
5406
5817
  },
5407
5818
  },
5408
5819
  },
@@ -5879,6 +6290,12 @@ export const countdown: RecipeInput = {
5879
6290
  xl: { root: { base: { '--countdown-font': 'var(--text-3xl)' } } },
5880
6291
  },
5881
6292
  },
6293
+ // One line inside a sentence (#57): the surrounding text's size and
6294
+ // weight instead of the display step. What stays is what makes
6295
+ // it a countdown — tabular digits, its ink, the per-tick entry.
6296
+ modifiers: {
6297
+ inline: { root: { base: { fontSize: 'inherit', fontWeight: 'inherit' } } },
6298
+ },
5882
6299
  keyframes: { 'zero-daisyui-countdown-in': 'from { transform: translateY(0.6em); opacity: 0; }' },
5883
6300
  };
5884
6301
 
@@ -6005,10 +6422,10 @@ export const diff: RecipeInput = {
6005
6422
  export const recipes: RecipeInput[] = [
6006
6423
  tabs, collapsible, switchRecipe, dialog, popover, tooltip, menu,
6007
6424
  field, checkbox, radioGroup, progress, slider, accordion, select, button, avatar, toast, combobox,
6008
- toggle, toggleGroup, numberInput, ratingGroup, treeView, input, textarea, nativeSelect,
6009
- card, alert, badge, divider, skeleton, spinner,
6425
+ toggle, toggleGroup, numberInput, ratingGroup, treeView, input, textarea,
6426
+ card, alert, emptyState, badge, divider, skeleton, spinner,
6010
6427
  kbd, status, indicator, stats, timeline, chat, radialProgress, join,
6011
- navbar, breadcrumbs, pagination, steps, drawer,
6428
+ navbar, navList, breadcrumbs, pagination, steps, drawer,
6012
6429
  table,
6013
6430
  fileUpload,
6014
6431
  carousel,