@sigx/zero-daisyui 0.2.0-beta.6 → 0.3.0-beta.1

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/dist/recipes.js CHANGED
@@ -1,4 +1,5 @@
1
- import { roles } from './tokens.js';
1
+ import { axisRoles, tableStackAt } from '@sigx/zero-kit/define';
2
+ import { roles, tokens } from './tokens.js';
2
3
  /**
3
4
  * Every role a consumer can pass as `color`, derived from the declaration
4
5
  * rather than retyped — a role declared in `tokens.ts` but missing here would
@@ -6,9 +7,7 @@ import { roles } from './tokens.js';
6
7
  * `-content` or `-soft` are fills or hairlines, not action colours; this
7
8
  * design system declares none.
8
9
  */
9
- const ROLES = Object.entries(roles)
10
- .filter(([, decl]) => decl.content !== false && decl.soft !== false)
11
- .map(([name]) => name);
10
+ const ROLES = axisRoles(roles);
12
11
  /**
13
12
  * "…and the reading direction is right-to-left" — appended to a selector, never
14
13
  * written alone.
@@ -506,6 +505,13 @@ const disclosureSizes = {
506
505
  // daisy "collapse collapse-arrow" flavor.
507
506
  export const collapsible = {
508
507
  component: 'collapsible',
508
+ // Public to a design system derived from this one (#73).
509
+ hooks: {
510
+ properties: {
511
+ '--collapsible-accent': 'The accent of the open trigger.',
512
+ },
513
+ pseudo: { trigger: ['::after'] },
514
+ },
509
515
  // The accent is the open heading's INK, so the default is base-content —
510
516
  // the un-attributed render stays exactly daisy's neutral collapse and a
511
517
  // role only arrives through `data-color`.
@@ -579,6 +585,14 @@ export const collapsible = {
579
585
  // daisy "toggle" flavor.
580
586
  export const switchRecipe = {
581
587
  component: 'switch',
588
+ // Public to a design system derived from this one (#73).
589
+ hooks: {
590
+ properties: {
591
+ '--switch-size': 'The height; width, padding and knob travel derive from it.',
592
+ '--switch-accent': 'The checked track and knob accent.',
593
+ '--switch-ink': 'The unchecked border, knob and ring ink (daisy --input-color).',
594
+ },
595
+ },
582
596
  /**
583
597
  * daisy drives the whole toggle off ONE size, exactly as it drives the
584
598
  * checkbox: `--size` is the height, the padding is a fixed eighth of it,
@@ -897,6 +911,10 @@ const lynxBtnSizes = (part) => ({
897
911
  });
898
912
  export const dialog = {
899
913
  component: 'dialog',
914
+ // Public to a design system derived from this one (#73).
915
+ hooks: {
916
+ keyframes: ['zero-daisy-pop'],
917
+ },
900
918
  keyframes: {
901
919
  'zero-daisy-pop': 'from { opacity: 0; transform: scale(0.95); } to { opacity: 1; transform: scale(1); }',
902
920
  },
@@ -913,7 +931,12 @@ export const dialog = {
913
931
  },
914
932
  popup: withPresence(popupPresence('translateY(8px) scale(0.97)'), {
915
933
  base: {
916
- padding: '1.5rem',
934
+ // A <dialog> keeps the UA's `content-box`, and zero ships no
935
+ // reset — so a width or max-width meant to leave a gutter
936
+ // grew by the padding, and at phone width the popup ran past
937
+ // both edges (#101). Its box is the border box.
938
+ boxSizing: 'border-box',
939
+ padding: 'var(--space-2xl)',
917
940
  maxWidth: '32rem',
918
941
  width: 'calc(100% - 2rem)',
919
942
  background: 'var(--color-base-100)',
@@ -943,14 +966,15 @@ export const dialog = {
943
966
  },
944
967
  // daisy's `modal-action`: the actions sit at the trailing edge, one
945
968
  // step of the button gap apart, pushed down by the modal's own
946
- // padding value rather than by a scale step (daisy writes 1.5rem for
947
- // both, as `popup` above does).
969
+ // padding — daisy writes 1.5rem for both, and both now read the top
970
+ // of the ramp (`--space-2xl`, 1.25rem) so a density override moves
971
+ // the popup's inset and the footer's push-down together.
948
972
  footer: {
949
973
  base: {
950
974
  display: 'flex',
951
975
  justifyContent: 'flex-end',
952
976
  gap: 'var(--space-md)',
953
- marginBlockStart: '1.5rem',
977
+ marginBlockStart: 'var(--space-2xl)',
954
978
  },
955
979
  },
956
980
  close: {
@@ -983,7 +1007,7 @@ export const dialog = {
983
1007
  trigger: { base: lynxBtnPad(4) },
984
1008
  close: { base: lynxBtnPad(4) },
985
1009
  cancel: { base: lynxBtnPad(4) },
986
- footer: { base: { marginTop: '1.5rem' } },
1010
+ footer: { base: { marginTop: 'var(--space-2xl)' } },
987
1011
  },
988
1012
  variants: { size: lynxBtnSizes('trigger') },
989
1013
  },
@@ -1103,7 +1127,7 @@ export const menu = {
1103
1127
  base: {
1104
1128
  display: 'flex',
1105
1129
  alignItems: 'center',
1106
- gap: '0.625rem',
1130
+ gap: 'var(--space-md)',
1107
1131
  padding: 'var(--space-md) var(--space-lg)',
1108
1132
  fontSize: 'var(--text-sm)',
1109
1133
  fontWeight: 'var(--weight-medium)',
@@ -1123,7 +1147,7 @@ export const menu = {
1123
1147
  base: {
1124
1148
  display: 'flex',
1125
1149
  alignItems: 'center',
1126
- gap: '0.625rem',
1150
+ gap: 'var(--space-md)',
1127
1151
  padding: 'var(--space-md) var(--space-lg)',
1128
1152
  fontSize: 'var(--text-sm)',
1129
1153
  fontWeight: 'var(--weight-medium)',
@@ -1142,7 +1166,7 @@ export const menu = {
1142
1166
  base: {
1143
1167
  display: 'flex',
1144
1168
  alignItems: 'center',
1145
- gap: '0.625rem',
1169
+ gap: 'var(--space-md)',
1146
1170
  padding: 'var(--space-md) var(--space-lg)',
1147
1171
  fontSize: 'var(--text-sm)',
1148
1172
  fontWeight: 'var(--weight-medium)',
@@ -1177,7 +1201,7 @@ export const menu = {
1177
1201
  base: {
1178
1202
  display: 'flex',
1179
1203
  alignItems: 'center',
1180
- gap: '0.625rem',
1204
+ gap: 'var(--space-md)',
1181
1205
  padding: 'var(--space-md) var(--space-lg)',
1182
1206
  fontSize: 'var(--text-sm)',
1183
1207
  fontWeight: 'var(--weight-medium)',
@@ -1354,7 +1378,7 @@ export const checkbox = {
1354
1378
  },
1355
1379
  parts: {
1356
1380
  root: {
1357
- base: { display: 'inline-flex', alignItems: 'center', gap: '0.625rem', cursor: 'pointer' },
1381
+ base: { display: 'inline-flex', alignItems: 'center', gap: 'var(--space-md)', cursor: 'pointer' },
1358
1382
  states: {
1359
1383
  disabled: { opacity: 'var(--disabled-opacity)', cursor: 'not-allowed' },
1360
1384
  checked: {}, unchecked: {}, indeterminate: {},
@@ -1551,7 +1575,7 @@ export const radioGroup = {
1551
1575
  },
1552
1576
  parts: {
1553
1577
  root: {
1554
- base: { display: 'flex', flexDirection: 'column', gap: '0.625rem' },
1578
+ base: { display: 'flex', flexDirection: 'column', gap: 'var(--space-md)' },
1555
1579
  states: { invalid: {}, required: {} },
1556
1580
  selectors: {
1557
1581
  // `invalid` is a fact about the GROUP — `item-control` carries
@@ -1565,7 +1589,7 @@ export const radioGroup = {
1565
1589
  states: { disabled: { opacity: 'var(--disabled-opacity)' } },
1566
1590
  },
1567
1591
  item: {
1568
- base: { display: 'inline-flex', alignItems: 'center', gap: '0.625rem', cursor: 'pointer' },
1592
+ base: { display: 'inline-flex', alignItems: 'center', gap: 'var(--space-md)', cursor: 'pointer' },
1569
1593
  states: {
1570
1594
  disabled: { opacity: 'var(--disabled-opacity)', cursor: 'not-allowed' },
1571
1595
  checked: {}, unchecked: {},
@@ -2147,7 +2171,7 @@ export const select = {
2147
2171
  display: 'flex',
2148
2172
  alignItems: 'center',
2149
2173
  justifyContent: 'space-between',
2150
- gap: '0.625rem',
2174
+ gap: 'var(--space-md)',
2151
2175
  padding: 'var(--space-md) var(--space-lg)',
2152
2176
  fontSize: 'var(--text-sm)',
2153
2177
  fontWeight: 'var(--weight-medium)',
@@ -2212,6 +2236,15 @@ const BTN_STEPS = { xs: 6, sm: 8, md: 10, lg: 12, xl: 14 };
2212
2236
  const btnHeight = (step) => `calc(var(--size-field) * ${BTN_STEPS[step]})`;
2213
2237
  export const button = {
2214
2238
  component: 'button',
2239
+ // Public to a design system derived from this one (#73).
2240
+ hooks: {
2241
+ properties: {
2242
+ '--btn-accent': 'The fill of the solid variant.',
2243
+ '--btn-on-accent': 'The ink on --btn-accent.',
2244
+ '--btn-soft': 'The soft fill (soft variant, unfilled hover).',
2245
+ '--btn-ink': 'The ink of the unfilled variants.',
2246
+ },
2247
+ },
2215
2248
  // The two axes meet here instead of multiplying. `color` sets the accent
2216
2249
  // pair; `variant` decides how the accent is used. 8 + 4 + 5 rules rather
2217
2250
  // than 8 × 4.
@@ -2222,9 +2255,34 @@ export const button = {
2222
2255
  '--btn-ink': roleInk('primary'),
2223
2256
  },
2224
2257
  parts: {
2258
+ // The loading spinner (#50): a real part zero renders before the
2259
+ // label while `loading`, drawn as a ring in `currentColor` with one
2260
+ // transparent quadrant — it takes whatever ink the variant chose.
2261
+ // A literal duration and an explicit reduced-motion `none`: the kit
2262
+ // collapses `--duration-*` under reduced motion, and an infinite
2263
+ // loop at ~0s strobes rather than stops.
2264
+ // daisy marks a loading button with a `loading loading-spinner` span;
2265
+ // this is that span, zero's own now (it was a `loading` modifier's
2266
+ // `::before` until #50 made loading a Button state).
2267
+ spinner: {
2268
+ base: {
2269
+ boxSizing: 'border-box',
2270
+ inlineSize: '1em',
2271
+ blockSize: '1em',
2272
+ flex: 'none',
2273
+ borderRadius: '9999px',
2274
+ border: 'calc(var(--border) * 2) solid currentColor',
2275
+ borderBlockStartColor: 'transparent',
2276
+ animation: 'zero-daisyui-btn-spin 0.7s linear infinite',
2277
+ },
2278
+ at: { 'reduced-motion': { base: { animation: 'none' } } },
2279
+ },
2225
2280
  root: {
2226
2281
  base: {
2227
2282
  appearance: 'none',
2283
+ // An asChild `<a>` gets no UA underline (see the README's
2284
+ // link-button note for the unlayered `a { color }` case).
2285
+ textDecoration: 'none',
2228
2286
  display: 'inline-flex',
2229
2287
  alignItems: 'center',
2230
2288
  justifyContent: 'center',
@@ -2240,6 +2298,9 @@ export const button = {
2240
2298
  + 'border-color var(--duration-fast) var(--ease-standard)',
2241
2299
  },
2242
2300
  states: {
2301
+ // Work in flight (`loading`, #50): still focusable, still legible —
2302
+ // the label is what the reader is waiting on — so no fade here.
2303
+ loading: { cursor: 'progress' },
2243
2304
  disabled: { opacity: 'var(--disabled-opacity)', cursor: 'not-allowed' },
2244
2305
  // The ring is drawn on the page, offset clear of the button —
2245
2306
  // ink, not fill, and the same 3:1 floor. On `--btn-accent` it
@@ -2383,37 +2444,6 @@ export const button = {
2383
2444
  active: {
2384
2445
  root: { base: { transform: 'translateY(1px)', boxShadow: 'none', filter: 'brightness(0.92)' } },
2385
2446
  },
2386
- /**
2387
- * daisy's loading spinner, drawn by the recipe (#332): daisy marks
2388
- * the button with a `loading loading-spinner` span; zero changes no
2389
- * DOM, so the ring is a `::before` in `currentColor` with one
2390
- * transparent quadrant — it inherits whatever ink the variant chose,
2391
- * and the root's existing `gap` spaces it from the label. Literal
2392
- * duration + explicit reduced-motion `none`, like the spinner
2393
- * recipe: the kit collapses `--duration-*` under reduced motion, and
2394
- * an infinite loop at ~0s strobes rather than stops.
2395
- */
2396
- loading: {
2397
- root: {
2398
- base: { cursor: 'progress' },
2399
- selectors: {
2400
- '&::before': {
2401
- content: '""',
2402
- boxSizing: 'border-box',
2403
- inlineSize: '1em',
2404
- blockSize: '1em',
2405
- flex: 'none',
2406
- borderRadius: '9999px',
2407
- border: 'calc(var(--border) * 2) solid currentColor',
2408
- borderBlockStartColor: 'transparent',
2409
- animation: 'zero-daisyui-btn-spin 0.7s linear infinite',
2410
- },
2411
- },
2412
- at: {
2413
- 'reduced-motion': { selectors: { '&::before': { animation: 'none' } } },
2414
- },
2415
- },
2416
- },
2417
2447
  },
2418
2448
  defaultVariants: { color: 'primary', variant: 'solid', size: 'md' },
2419
2449
  keyframes: { 'zero-daisyui-btn-spin': 'to { transform: rotate(360deg) }' },
@@ -2480,6 +2510,13 @@ export const avatar = {
2480
2510
  },
2481
2511
  },
2482
2512
  variants: {
2513
+ // The shape axis (zero#129) — one declaration on the root, whose
2514
+ // `overflow: hidden` clips the image and the fallback alike. `rounded` is daisy's selector radius; the default is the circle.
2515
+ shape: {
2516
+ circle: { root: { base: { borderRadius: '9999px' } } },
2517
+ square: { root: { base: { borderRadius: '0' } } },
2518
+ rounded: { root: { base: { borderRadius: 'var(--radius-selector)' } } },
2519
+ },
2483
2520
  // daisy colours the RING, not the fallback — that is what its avatar
2484
2521
  // does, and the fallback stays neutral so initials read the same
2485
2522
  // whichever role is chosen.
@@ -2689,6 +2726,7 @@ export const combobox = {
2689
2726
  base: {
2690
2727
  display: 'inline-flex',
2691
2728
  alignItems: 'stretch',
2729
+ flexWrap: 'wrap',
2692
2730
  minWidth: '13rem',
2693
2731
  ...fieldControl,
2694
2732
  transition: 'border-color var(--duration-fast) var(--ease-standard)',
@@ -2734,6 +2772,52 @@ export const combobox = {
2734
2772
  '&::placeholder': { color: 'color-mix(in oklab, var(--color-base-content) 60%, transparent)' },
2735
2773
  },
2736
2774
  },
2775
+ // A chosen value under `multiple` (#39): a chip in the control, before
2776
+ // the input. Symmetric metrics only — the physical-direction lint and
2777
+ // the lynx emitter both refuse a one-sided inset.
2778
+ // daisy's badge: a base-200 pill, centred in the stretched row.
2779
+ tag: {
2780
+ base: {
2781
+ display: 'inline-flex',
2782
+ alignItems: 'center',
2783
+ alignSelf: 'center',
2784
+ gap: 'var(--space-2xs)',
2785
+ margin: 'var(--space-2xs)',
2786
+ padding: 'var(--space-2xs) var(--space-sm)',
2787
+ background: 'var(--color-base-200)',
2788
+ color: 'var(--color-base-content)',
2789
+ borderRadius: 'var(--radius-selector)',
2790
+ fontSize: 'var(--text-xs)',
2791
+ fontWeight: 'var(--weight-medium)',
2792
+ lineHeight: 'var(--leading-none)',
2793
+ },
2794
+ states: { disabled: {} },
2795
+ },
2796
+ 'tag-label': { base: { whiteSpace: 'nowrap' } },
2797
+ 'tag-remove': {
2798
+ base: {
2799
+ appearance: 'none',
2800
+ border: 'none',
2801
+ background: 'transparent',
2802
+ color: 'inherit',
2803
+ font: 'inherit',
2804
+ borderRadius: 'var(--radius-selector)',
2805
+ padding: '0 var(--space-2xs)',
2806
+ lineHeight: 'var(--leading-none)',
2807
+ cursor: 'pointer',
2808
+ transition: 'background var(--duration-fast) var(--ease-standard)',
2809
+ },
2810
+ states: {
2811
+ hover: { background: 'color-mix(in oklab, var(--color-base-content) 10%, transparent)' },
2812
+ disabled: { opacity: 'var(--disabled-opacity)', cursor: 'not-allowed' },
2813
+ ...focusRing,
2814
+ },
2815
+ selectors: {
2816
+ '&[data-pressed]:not([data-disabled])': {
2817
+ background: 'color-mix(in oklab, var(--color-base-content) 18%, transparent)',
2818
+ },
2819
+ },
2820
+ },
2737
2821
  trigger: {
2738
2822
  base: {
2739
2823
  appearance: 'none',
@@ -2770,7 +2854,7 @@ export const combobox = {
2770
2854
  display: 'flex',
2771
2855
  alignItems: 'center',
2772
2856
  justifyContent: 'space-between',
2773
- gap: '0.625rem',
2857
+ gap: 'var(--space-md)',
2774
2858
  padding: 'var(--space-md) var(--space-lg)',
2775
2859
  fontSize: 'var(--text-sm)',
2776
2860
  fontWeight: 'var(--weight-medium)',
@@ -2802,15 +2886,15 @@ export const combobox = {
2802
2886
  '--combobox-accent': `var(--color-${c})`,
2803
2887
  } } }])),
2804
2888
  // The control carries the height — the shared field ramp, the same one
2805
- // `select`'s trigger steps on — and the input carries the inset and the
2806
- // type. `md` restates exactly the resting values so the union stays
2889
+ // `select`'s trigger steps on, as a floor so wrapped tags can grow it
2890
+ // — and the input carries the inset and the type. `md` restates exactly the resting values so the union stays
2807
2891
  // complete.
2808
2892
  size: {
2809
- xs: { control: { base: { height: fieldHeight('xs') } }, input: { base: { padding: 'var(--space-2xs) var(--space-md)', fontSize: 'var(--text-xs)' } } },
2810
- sm: { control: { base: { height: fieldHeight('sm') } }, input: { base: { padding: 'var(--space-xs) var(--space-md)', fontSize: 'var(--text-sm)' } } },
2811
- md: { control: { base: { height: fieldHeight('md') } }, input: { base: { padding: 'var(--space-sm) var(--space-lg)', fontSize: 'var(--text-sm)' } } },
2812
- lg: { control: { base: { height: fieldHeight('lg') } }, input: { base: { padding: 'var(--space-md) var(--space-xl)', fontSize: 'var(--text-md)' } } },
2813
- xl: { control: { base: { height: fieldHeight('xl') } }, input: { base: { padding: 'var(--space-lg) var(--space-2xl)', fontSize: 'var(--text-lg)' } } },
2893
+ xs: { control: { base: { minHeight: fieldHeight('xs') } }, input: { base: { padding: 'var(--space-2xs) var(--space-md)', fontSize: 'var(--text-xs)' } } },
2894
+ sm: { control: { base: { minHeight: fieldHeight('sm') } }, input: { base: { padding: 'var(--space-xs) var(--space-md)', fontSize: 'var(--text-sm)' } } },
2895
+ md: { control: { base: { minHeight: fieldHeight('md') } }, input: { base: { padding: 'var(--space-sm) var(--space-lg)', fontSize: 'var(--text-sm)' } } },
2896
+ lg: { control: { base: { minHeight: fieldHeight('lg') } }, input: { base: { padding: 'var(--space-md) var(--space-xl)', fontSize: 'var(--text-md)' } } },
2897
+ xl: { control: { base: { minHeight: fieldHeight('xl') } }, input: { base: { padding: 'var(--space-lg) var(--space-2xl)', fontSize: 'var(--text-lg)' } } },
2814
2898
  },
2815
2899
  },
2816
2900
  // The visible ring lives on `control`; input and trigger delegate.
@@ -2906,6 +2990,13 @@ export const toggle = {
2906
2990
  // the on item filled with the accent.
2907
2991
  export const toggleGroup = {
2908
2992
  component: 'toggle-group',
2993
+ // Public to a design system derived from this one (#73).
2994
+ hooks: {
2995
+ properties: {
2996
+ '--toggle-group-accent': 'The fill of an on item.',
2997
+ '--toggle-group-on-accent': 'The ink on --toggle-group-accent.',
2998
+ },
2999
+ },
2909
3000
  tokens: {
2910
3001
  '--toggle-group-accent': 'var(--color-primary)',
2911
3002
  '--toggle-group-on-accent': 'var(--color-primary-content)',
@@ -3334,7 +3425,7 @@ export const ratingGroup = {
3334
3425
  const treeRow = {
3335
3426
  display: 'flex',
3336
3427
  alignItems: 'center',
3337
- gap: '0.625rem',
3428
+ gap: 'var(--space-md)',
3338
3429
  padding: 'var(--space-sm) var(--space-md)',
3339
3430
  fontSize: 'var(--tree-text)',
3340
3431
  fontWeight: 'var(--weight-medium)',
@@ -3607,94 +3698,6 @@ export const textarea = {
3607
3698
  },
3608
3699
  },
3609
3700
  };
3610
- /**
3611
- * NativeSelect (#333) — daisy "select" flavor on the platform's own picker:
3612
- * the `fieldControl` box (same metrics as every field, so mixed rows line
3613
- * up), `appearance: none`, and the recipe-drawn chevron at the inline end.
3614
- * No open state — the platform owns the popup — so colour surfaces on the
3615
- * focus ring, exactly like input's.
3616
- */
3617
- export const nativeSelect = {
3618
- component: 'native-select',
3619
- tokens: { '--native-select-accent': 'var(--color-base-content)' },
3620
- parts: {
3621
- root: {
3622
- base: { position: 'relative', display: 'inline-flex', alignItems: 'center' },
3623
- states: { disabled: {}, invalid: {}, required: {}, placeholder: {} },
3624
- },
3625
- control: {
3626
- base: {
3627
- appearance: 'none',
3628
- width: '100%',
3629
- minWidth: '12rem',
3630
- ...fieldControl,
3631
- color: 'var(--color-base-content)',
3632
- font: 'inherit',
3633
- fontSize: 'var(--text-sm)',
3634
- padding: 'var(--space-sm) var(--space-md)',
3635
- // Room for the chevron the platform no longer draws.
3636
- paddingInlineEnd: 'calc(var(--space-md) + 1.25em)',
3637
- cursor: 'pointer',
3638
- transition: 'border-color var(--duration-fast) var(--ease-standard)',
3639
- },
3640
- states: {
3641
- hover: { borderColor: 'var(--color-base-content)' },
3642
- invalid: { borderColor: 'var(--color-error)' },
3643
- disabled: { opacity: 'var(--disabled-opacity)', cursor: 'not-allowed' },
3644
- required: {},
3645
- // Same muting as the fields' placeholders (#264).
3646
- placeholder: { color: 'color-mix(in oklab, var(--color-base-content) 60%, transparent)' },
3647
- 'focus-visible': { ...focusRing['focus-visible'], outline: '2px solid var(--native-select-accent)' },
3648
- },
3649
- },
3650
- indicator: {
3651
- base: {
3652
- position: 'absolute',
3653
- insetInlineEnd: 'var(--space-md)',
3654
- pointerEvents: 'none',
3655
- opacity: '0.6',
3656
- fontSize: 'var(--text-sm)',
3657
- },
3658
- },
3659
- },
3660
- // The visible ring lives on the <select> itself; the wrapper delegates.
3661
- skipStates: { root: ['focus-visible'] },
3662
- variants: {
3663
- // The ring carries the role — daisy's field chrome is neutral, so
3664
- // focus is the only place colour surfaces (input's exact move).
3665
- color: Object.fromEntries(ROLES.map((c) => [c, { root: { base: {
3666
- '--native-select-accent': `var(--color-${c})`,
3667
- } } }])),
3668
- // The shared field-height ramp, on the control itself.
3669
- size: {
3670
- xs: { control: { base: { height: fieldHeight('xs'), fontSize: 'var(--text-xs)', padding: 'var(--space-2xs) var(--space-xs)', paddingInlineEnd: 'calc(var(--space-xs) + 1.25em)' } } },
3671
- sm: { control: { base: { height: fieldHeight('sm'), fontSize: 'var(--text-xs)', padding: 'var(--space-xs) var(--space-sm)', paddingInlineEnd: 'calc(var(--space-sm) + 1.25em)' } } },
3672
- md: {},
3673
- lg: { control: { base: { height: fieldHeight('lg'), fontSize: 'var(--text-md)', padding: 'var(--space-md) var(--space-lg)', paddingInlineEnd: 'calc(var(--space-lg) + 1.25em)' } } },
3674
- xl: { control: { base: { height: fieldHeight('xl'), fontSize: 'var(--text-lg)', padding: 'var(--space-lg) var(--space-xl)', paddingInlineEnd: 'calc(var(--space-xl) + 1.25em)' } } },
3675
- },
3676
- },
3677
- // The chevron gutter and the indicator anchor, restated physically:
3678
- // logical spellings resolve on iOS but not on Android (measured,
3679
- // signalxjs/lynx#1084), so the emitter refuses them. Physical is the
3680
- // lynx target's norm — no RTL flow there, so `end` IS right.
3681
- targets: {
3682
- lynx: {
3683
- parts: {
3684
- control: { base: { paddingRight: 'calc(var(--space-md) + 1.25em)' } },
3685
- indicator: { base: { right: 'var(--space-md)' } },
3686
- },
3687
- variants: {
3688
- size: {
3689
- xs: { control: { base: { paddingRight: 'calc(var(--space-xs) + 1.25em)' } } },
3690
- sm: { control: { base: { paddingRight: 'calc(var(--space-sm) + 1.25em)' } } },
3691
- lg: { control: { base: { paddingRight: 'calc(var(--space-lg) + 1.25em)' } } },
3692
- xl: { control: { base: { paddingRight: 'calc(var(--space-xl) + 1.25em)' } } },
3693
- },
3694
- },
3695
- },
3696
- },
3697
- };
3698
3701
  // ── Content tier (#311) ───────────────────────────────────────────────────
3699
3702
  /**
3700
3703
  * daisy "card" flavor: `bg-base-100` on a `rounded-box` with a real shadow —
@@ -3703,6 +3706,12 @@ export const nativeSelect = {
3703
3706
  */
3704
3707
  export const card = {
3705
3708
  component: 'card',
3709
+ // Public to a design system derived from this one (#73).
3710
+ hooks: {
3711
+ properties: {
3712
+ '--card-pad': 'The card padding.',
3713
+ },
3714
+ },
3706
3715
  tokens: { '--card-pad': 'var(--space-xl)' },
3707
3716
  parts: {
3708
3717
  root: {
@@ -3857,6 +3866,87 @@ export const alert = {
3857
3866
  },
3858
3867
  },
3859
3868
  };
3869
+ /**
3870
+ * EmptyState (zero#131) — daisy has no empty-state component; this is the
3871
+ * `hero`-content idiom at card scale: a centred column on the base-200
3872
+ * surface with the box radius, the icon in the role's ink and the surface
3873
+ * in its soft tint when a tone is given.
3874
+ */
3875
+ export const emptyState = {
3876
+ component: 'empty-state',
3877
+ hooks: {
3878
+ properties: {
3879
+ '--empty-accent': 'The icon ink.',
3880
+ '--empty-tint': 'The surface fill.',
3881
+ },
3882
+ },
3883
+ tokens: {
3884
+ '--empty-accent': 'color-mix(in oklab, var(--color-base-content) 60%, transparent)',
3885
+ '--empty-tint': 'var(--color-base-200)',
3886
+ },
3887
+ parts: {
3888
+ root: {
3889
+ base: {
3890
+ display: 'flex',
3891
+ flexDirection: 'column',
3892
+ alignItems: 'center',
3893
+ textAlign: 'center',
3894
+ gap: 'var(--space-sm)',
3895
+ padding: 'var(--space-2xl) var(--space-xl)',
3896
+ background: 'var(--empty-tint)',
3897
+ color: 'var(--color-base-content)',
3898
+ borderRadius: 'var(--radius-box)',
3899
+ },
3900
+ },
3901
+ icon: {
3902
+ base: {
3903
+ display: 'inline-flex',
3904
+ color: 'var(--empty-accent)',
3905
+ fontSize: 'var(--text-2xl)',
3906
+ lineHeight: 'var(--leading-none)',
3907
+ marginBlockEnd: 'var(--space-xs)',
3908
+ },
3909
+ },
3910
+ title: {
3911
+ base: {
3912
+ margin: '0',
3913
+ fontSize: 'var(--text-lg)',
3914
+ fontWeight: 'var(--weight-bold)',
3915
+ lineHeight: 'var(--leading-tight)',
3916
+ },
3917
+ },
3918
+ description: {
3919
+ base: {
3920
+ maxInlineSize: '36ch',
3921
+ fontSize: 'var(--text-sm)',
3922
+ lineHeight: 'var(--leading-normal)',
3923
+ color: 'color-mix(in oklab, var(--color-base-content) 70%, transparent)',
3924
+ },
3925
+ },
3926
+ actions: {
3927
+ base: {
3928
+ display: 'flex',
3929
+ flexWrap: 'wrap',
3930
+ justifyContent: 'center',
3931
+ gap: 'var(--space-sm)',
3932
+ marginBlockStart: 'var(--space-sm)',
3933
+ },
3934
+ },
3935
+ },
3936
+ variants: {
3937
+ color: Object.fromEntries(ROLES.map((c) => [c, { root: { base: {
3938
+ '--empty-accent': roleInk(c),
3939
+ '--empty-tint': `var(--color-${c}-soft)`,
3940
+ } } }])),
3941
+ size: {
3942
+ 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)' } } },
3943
+ 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)' } } },
3944
+ md: {},
3945
+ 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)' } } },
3946
+ 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)' } } },
3947
+ },
3948
+ },
3949
+ };
3860
3950
  /**
3861
3951
  * daisy "badge" flavor: a fully-rounded pill with a hairline, filled by the
3862
3952
  * role. No `variant` — the repo-wide decision (#175) holds everywhere except
@@ -3865,7 +3955,18 @@ export const alert = {
3865
3955
  */
3866
3956
  export const badge = {
3867
3957
  component: 'badge',
3958
+ // Public to a design system derived from this one (#73).
3959
+ hooks: {
3960
+ properties: {
3961
+ '--badge-dot': 'The status dot\'s fill (zero#130); the pill\'s ink at rest.',
3962
+ '--badge-dot-ring': 'The ring around a coloured status dot.',
3963
+ '--badge-fill': 'The badge fill.',
3964
+ '--badge-ink': 'The badge ink.',
3965
+ },
3966
+ },
3868
3967
  tokens: {
3968
+ '--badge-dot': 'currentColor',
3969
+ '--badge-dot-ring': 'currentColor',
3869
3970
  '--badge-fill': 'var(--color-base-200)',
3870
3971
  '--badge-ink': 'var(--color-base-content)',
3871
3972
  },
@@ -3881,7 +3982,7 @@ export const badge = {
3881
3982
  // daisy badges are pills; a large radius rather than 9999px so
3882
3983
  // a square-cornered theme can still say so through the token.
3883
3984
  borderRadius: 'var(--radius-selector)',
3884
- padding: '0.125rem 0.625rem',
3985
+ padding: 'var(--space-2xs) var(--space-lg)',
3885
3986
  fontSize: 'var(--text-xs)',
3886
3987
  fontWeight: 'var(--weight-semibold)',
3887
3988
  lineHeight: 'var(--leading-normal)',
@@ -3889,12 +3990,53 @@ export const badge = {
3889
3990
  textDecoration: 'none',
3890
3991
  },
3891
3992
  },
3993
+ // The status dot (zero#130). On an uncoloured pill it is the pill's
3994
+ // INK — a dot in `currentColor` is legible on whatever fill the pill
3995
+ // has. Coloured — by its own `data-color`, or by the pill's, since the
3996
+ // nearest carrier wins (#94) — it is the role's fill inside a ring in
3997
+ // the role's `-content` ink, the timeline marker's answer: a dot that
3998
+ // follows a solid pill of the same role still has an edge. `running` is a
3999
+ // static halo the pulse breathes; under reduced motion the halo
4000
+ // stays and the breathing stops, so the state never vanishes.
4001
+ dot: {
4002
+ base: {
4003
+ display: 'inline-block',
4004
+ flex: 'none',
4005
+ inlineSize: '0.5em',
4006
+ blockSize: '0.5em',
4007
+ boxSizing: 'border-box',
4008
+ borderRadius: '50%',
4009
+ background: 'var(--badge-dot)',
4010
+ border: '0.1em solid var(--badge-dot-ring)',
4011
+ },
4012
+ states: {
4013
+ running: {
4014
+ boxShadow: '0 0 0 0.2em color-mix(in oklch, var(--badge-dot) 35%, transparent)',
4015
+ animation: 'zero-daisy-badge-pulse 1.6s ease-out infinite',
4016
+ },
4017
+ },
4018
+ at: {
4019
+ 'reduced-motion': { states: { running: { animation: 'none' } } },
4020
+ // Forced colours strip the fill and keep the border, so the
4021
+ // border draws the whole dot there.
4022
+ 'forced-colors': { base: { borderWidth: '0.25em' } },
4023
+ },
4024
+ },
3892
4025
  },
3893
4026
  variants: {
3894
- color: Object.fromEntries(ROLES.map((c) => [c, { root: { base: {
4027
+ color: Object.fromEntries(ROLES.map((c) => [c, {
4028
+ root: { base: {
3895
4029
  '--badge-fill': `var(--color-${c})`,
3896
4030
  '--badge-ink': `var(--color-${c}-content)`,
3897
- } } }])),
4031
+ } },
4032
+ // The dot's colour (zero#130): keyed under `variants.color` so the
4033
+ // compiler anchors it on the pill's `data-color` AND on the dot's
4034
+ // own re-carried one — the nearest carrier wins (#94).
4035
+ dot: { base: {
4036
+ '--badge-dot': `var(--color-${c})`,
4037
+ '--badge-dot-ring': `var(--color-${c}-content)`,
4038
+ } },
4039
+ }])),
3898
4040
  size: {
3899
4041
  xs: { root: { base: { fontSize: 'var(--text-xs)', padding: '0 var(--space-sm)' } } },
3900
4042
  sm: { root: { base: { fontSize: 'var(--text-xs)', padding: '0 var(--space-md)' } } },
@@ -3902,6 +4044,8 @@ export const badge = {
3902
4044
  lg: { root: { base: { fontSize: 'var(--text-sm)', padding: 'var(--space-2xs) var(--space-lg)' } } },
3903
4045
  xl: { root: { base: { fontSize: 'var(--text-md)', padding: 'var(--space-xs) var(--space-xl)' } } },
3904
4046
  },
4047
+ }, keyframes: {
4048
+ '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; }',
3905
4049
  },
3906
4050
  };
3907
4051
  /** daisy "divider": the base-300 rule, thickening with the ramp. */
@@ -3945,6 +4089,12 @@ export const divider = {
3945
4089
  */
3946
4090
  export const skeleton = {
3947
4091
  component: 'skeleton',
4092
+ // Public to a design system derived from this one (#73).
4093
+ hooks: {
4094
+ properties: {
4095
+ '--skeleton-fill': 'The placeholder fill.',
4096
+ },
4097
+ },
3948
4098
  tokens: { '--skeleton-fill': 'var(--color-base-300)' },
3949
4099
  parts: {
3950
4100
  root: {
@@ -4032,7 +4182,11 @@ export const kbd = {
4032
4182
  alignItems: 'center',
4033
4183
  justifyContent: 'center',
4034
4184
  minInlineSize: '1.75em',
4035
- padding: '0.0625rem 0.4375rem',
4185
+ // daisy's .0625rem/.4375rem, restated as the ramp steps they
4186
+ // already are once the cap's own border is absorbed: the OUTER
4187
+ // box lands on 2xs/md, so the cap rides the ramp under a density
4188
+ // override while keeping daisy's painted size exactly at 1px.
4189
+ padding: 'calc(var(--space-2xs) - var(--border)) calc(var(--space-md) - var(--border))',
4036
4190
  background: 'var(--kbd-fill)',
4037
4191
  color: 'var(--kbd-ink)',
4038
4192
  border: 'var(--border) solid var(--color-base-300)',
@@ -4261,10 +4415,31 @@ export const stats = {
4261
4415
  },
4262
4416
  },
4263
4417
  };
4418
+ /**
4419
+ * A timeline none of whose content sits on the start side: the root carries
4420
+ * no start-placed content among its own items. Child combinators all the way
4421
+ * down, so a timeline nested inside another's content answers only for
4422
+ * itself.
4423
+ */
4424
+ const TIMELINE_NO_START = '[data-scope="timeline"][data-part="root"]'
4425
+ + ':not(:has(> [data-scope="timeline"][data-part="item"] > [data-scope="timeline"][data-part="content"][data-placement="start"])) > &';
4264
4426
  /** daisy timeline: the boxed content beside a dotted axis. */
4265
4427
  export const timeline = {
4266
4428
  component: 'timeline',
4267
- tokens: { '--timeline-accent': 'var(--color-base-content)', '--timeline-marker-size': 'calc(var(--size-selector) * 3)' },
4429
+ // Public to a design system derived from this one (#73).
4430
+ hooks: {
4431
+ properties: {
4432
+ '--timeline-accent': 'The marker and connector accent.',
4433
+ '--timeline-ring': 'The ring around a coloured marker — the role\'s -content ink (#94).',
4434
+ '--timeline-marker-size': 'The marker diameter.',
4435
+ },
4436
+ },
4437
+ tokens: {
4438
+ '--timeline-accent': 'var(--color-base-content)',
4439
+ // The coloured dot's ring (#94); uncoloured, it IS the dot.
4440
+ '--timeline-ring': 'var(--timeline-accent)',
4441
+ '--timeline-marker-size': 'calc(var(--size-selector) * 3)',
4442
+ },
4268
4443
  parts: {
4269
4444
  root: {
4270
4445
  base: {
@@ -4300,6 +4475,11 @@ export const timeline = {
4300
4475
  gridTemplateColumns: 'auto 1fr',
4301
4476
  flex: '1 1 0%',
4302
4477
  },
4478
+ // No content on the start side anywhere in this timeline →
4479
+ // the start track collapses instead of holding half the item
4480
+ // empty (#57). Automatic, so there is no prop to forget.
4481
+ [`${TIMELINE_NO_START}[data-orientation="vertical"]`]: { gridTemplateColumns: '0 auto 1fr' },
4482
+ [`${TIMELINE_NO_START}[data-orientation="horizontal"]`]: { gridTemplateRows: '0 auto 1fr' },
4303
4483
  },
4304
4484
  },
4305
4485
  marker: {
@@ -4309,12 +4489,24 @@ export const timeline = {
4309
4489
  boxSizing: 'border-box',
4310
4490
  borderRadius: '9999px',
4311
4491
  background: 'var(--timeline-accent)',
4312
- border: 'calc(var(--timeline-marker-size) / 2) solid var(--timeline-accent)',
4492
+ // A coloured dot is the role fill inside a RING in the role's
4493
+ // `-content` ink (#94): a light role on a light page, or a dark
4494
+ // one on a dark page, has no edge of its own, and a per-entry
4495
+ // tone is exactly the mark a reader has to find. One half of
4496
+ // the role's own pair clears 3:1 against the page (the contrast
4497
+ // audit measures every colour). With no colour the ring is the
4498
+ // fill — the same solid dot as before.
4499
+ border: 'calc(var(--timeline-marker-size) / 6) solid var(--timeline-ring)',
4313
4500
  display: 'flex',
4314
4501
  alignItems: 'center',
4315
4502
  justifyContent: 'center',
4316
4503
  fontSize: '0',
4317
4504
  },
4505
+ at: {
4506
+ // Forced colours strip the fill and keep the border, so the
4507
+ // border draws the whole dot there, as it always did.
4508
+ 'forced-colors': { base: { borderWidth: 'calc(var(--timeline-marker-size) / 2)' } },
4509
+ },
4318
4510
  selectors: {
4319
4511
  // The axis cell, both orientations. `place-self` centres the
4320
4512
  // dot on the line in the cross axis.
@@ -4392,6 +4584,7 @@ export const timeline = {
4392
4584
  variants: {
4393
4585
  color: Object.fromEntries(ROLES.map((c) => [c, { marker: { base: {
4394
4586
  '--timeline-accent': `var(--color-${c})`,
4587
+ '--timeline-ring': `var(--color-${c}-content)`,
4395
4588
  } } }])),
4396
4589
  size: {
4397
4590
  xs: { marker: { base: { '--timeline-marker-size': 'calc(var(--size-selector) * 2)' } }, content: { base: { fontSize: 'var(--text-xs)' } } },
@@ -4758,6 +4951,111 @@ export const navbar = {
4758
4951
  },
4759
4952
  },
4760
4953
  };
4954
+ /**
4955
+ * NavList (zero#132) — daisy's `menu` idiom for a sidebar: rounded rows, a
4956
+ * base-200 wash on hover, and the current page as the role's soft fill in
4957
+ * the role's ink. `meta` is where daisy's menu puts its badge.
4958
+ */
4959
+ export const navList = {
4960
+ component: 'nav-list',
4961
+ hooks: {
4962
+ properties: {
4963
+ '--nav-accent': 'The current link\'s ink.',
4964
+ '--nav-tint': 'The current link\'s fill.',
4965
+ '--nav-ink': 'The resting link ink.',
4966
+ },
4967
+ },
4968
+ tokens: {
4969
+ '--nav-accent': 'var(--color-base-content)',
4970
+ '--nav-tint': 'var(--color-base-300)',
4971
+ '--nav-ink': 'var(--color-base-content)',
4972
+ },
4973
+ parts: {
4974
+ root: {
4975
+ base: {
4976
+ display: 'flex',
4977
+ flexDirection: 'column',
4978
+ gap: 'var(--space-lg)',
4979
+ fontSize: 'var(--text-sm)',
4980
+ color: 'var(--color-base-content)',
4981
+ },
4982
+ },
4983
+ group: {
4984
+ base: { display: 'flex', flexDirection: 'column', gap: 'var(--space-xs)' },
4985
+ },
4986
+ heading: {
4987
+ base: {
4988
+ padding: '0 var(--space-lg)',
4989
+ fontSize: 'var(--text-xs)',
4990
+ fontWeight: 'var(--weight-bold)',
4991
+ opacity: '0.6',
4992
+ },
4993
+ },
4994
+ list: {
4995
+ base: {
4996
+ display: 'flex',
4997
+ flexDirection: 'column',
4998
+ gap: 'var(--space-2xs)',
4999
+ listStyle: 'none',
5000
+ margin: '0',
5001
+ padding: '0',
5002
+ },
5003
+ },
5004
+ item: { base: { display: 'block' } },
5005
+ link: {
5006
+ base: {
5007
+ display: 'flex',
5008
+ alignItems: 'center',
5009
+ gap: 'var(--space-sm)',
5010
+ textDecoration: 'none',
5011
+ color: 'var(--nav-ink)',
5012
+ padding: 'var(--space-sm) var(--space-lg)',
5013
+ borderRadius: 'var(--radius-field)',
5014
+ fontWeight: 'var(--weight-medium)',
5015
+ lineHeight: 'var(--leading-tight)',
5016
+ transition: 'background var(--duration-fast) var(--ease-standard)',
5017
+ },
5018
+ states: {
5019
+ hover: { background: 'var(--color-base-200)' },
5020
+ active: { background: 'var(--nav-tint)', color: 'var(--nav-accent)' },
5021
+ inactive: {},
5022
+ ...focusRing,
5023
+ },
5024
+ },
5025
+ icon: {
5026
+ base: {
5027
+ display: 'inline-flex',
5028
+ inlineSize: '1.25em',
5029
+ justifyContent: 'center',
5030
+ flex: 'none',
5031
+ lineHeight: 'var(--leading-none)',
5032
+ },
5033
+ },
5034
+ meta: {
5035
+ base: {
5036
+ marginInlineStart: 'auto',
5037
+ display: 'inline-flex',
5038
+ alignItems: 'center',
5039
+ fontSize: 'var(--text-xs)',
5040
+ fontVariantNumeric: 'tabular-nums',
5041
+ opacity: '0.7',
5042
+ },
5043
+ },
5044
+ },
5045
+ variants: {
5046
+ color: Object.fromEntries(ROLES.map((c) => [c, { root: { base: {
5047
+ '--nav-accent': roleInk(c),
5048
+ '--nav-tint': `var(--color-${c}-soft)`,
5049
+ } } }])),
5050
+ size: {
5051
+ xs: { root: { base: { fontSize: 'var(--text-xs)' } }, link: { base: { padding: 'var(--space-2xs) var(--space-md)' } } },
5052
+ sm: { root: { base: { fontSize: 'var(--text-xs)' } }, link: { base: { padding: 'var(--space-xs) var(--space-md)' } } },
5053
+ md: {},
5054
+ lg: { root: { base: { fontSize: 'var(--text-md)' } }, link: { base: { padding: 'var(--space-md) var(--space-lg)' } } },
5055
+ xl: { root: { base: { fontSize: 'var(--text-lg)' } }, link: { base: { padding: 'var(--space-lg) var(--space-xl)' } } },
5056
+ },
5057
+ },
5058
+ };
4761
5059
  /**
4762
5060
  * Breadcrumbs — daisy's breadcrumbs: a compact text-sm trail, links
4763
5061
  * underlining on hover, the current crumb plain full ink. Colour rebinds
@@ -4971,6 +5269,18 @@ export const pagination = {
4971
5269
  },
4972
5270
  },
4973
5271
  };
5272
+ /**
5273
+ * The complete step's digit: the role hue deepened toward base-content, on
5274
+ * the disc's 20% tint of that role — `roleInk`'s per-role keep, capped at
5275
+ * 70%. The cap is the primary story: 95% on the tint measured 2.96:1 under
5276
+ * nord's muted primary (the static contrast matrix, #403). The per-role part
5277
+ * arrived with per-step colour (#112), when the matrix first measured every
5278
+ * role here: a flat 70% put nord's muted accent/success/warning at 2.2–2.6:1
5279
+ * and the dark themes' neutral (darker than the page) at 1.7–2.0:1, and
5280
+ * `ROLE_INK_KEEP` already knew how much hue each of those can keep.
5281
+ */
5282
+ const stepInk = (accent, role) => `color-mix(in oklab, ${accent} `
5283
+ + `${Math.min(ROLE_INK_KEEP[role] ?? 55, 70)}%, var(--color-base-content))`;
4974
5284
  /**
4975
5285
  * Steps — daisy's steps translated to the richer rail: bold numbered discs
4976
5286
  * on base-200, the walked disc and line refilled with the accent pair
@@ -4983,6 +5293,9 @@ export const steps = {
4983
5293
  '--steps-accent': 'var(--color-primary)',
4984
5294
  '--steps-accent-content': 'var(--color-primary-content)',
4985
5295
  '--steps-accent-ink': roleInk('primary'),
5296
+ // Read through the accent, so it resolves on the element that sets
5297
+ // it; a colour on the item restates it for that role.
5298
+ '--steps-complete-ink': stepInk('var(--steps-accent)', 'primary'),
4986
5299
  '--steps-ind': 'calc(var(--size-selector) * 7)',
4987
5300
  '--steps-font': 'var(--text-sm)',
4988
5301
  },
@@ -5054,7 +5367,14 @@ export const steps = {
5054
5367
  },
5055
5368
  states: {
5056
5369
  active: { background: 'var(--steps-accent)', color: 'var(--steps-accent-content)' },
5057
- complete: { background: 'color-mix(in oklch, var(--steps-accent) 20%, var(--color-base-100))', color: 'var(--steps-accent-ink)' },
5370
+ // Ink deepened toward base-content, per role (`stepInk`). The
5371
+ // tint mixes in OKLAB: daisy's light base-100 is
5372
+ // `oklch(100% 0 0)`, whose 0 hue the browser interpolates as
5373
+ // written, so an OKLCH mix swung every role's tint toward red
5374
+ // (warning's yellow came out pink) — which the browser parity
5375
+ // check caught once per-step colour put all eight roles on
5376
+ // the disc (#112).
5377
+ complete: { background: 'color-mix(in oklab, var(--steps-accent) 20%, var(--color-base-100))', color: 'var(--steps-complete-ink)' },
5058
5378
  inactive: { background: 'var(--color-base-200)', color: 'var(--color-base-content)' },
5059
5379
  },
5060
5380
  },
@@ -5103,10 +5423,15 @@ export const steps = {
5103
5423
  },
5104
5424
  },
5105
5425
  variants: {
5106
- color: Object.fromEntries(ROLES.map((c) => [c, { root: { base: {
5426
+ // Keyed on the ITEM, not the root (#112): the item re-carries
5427
+ // `color`, so a step's own value outranks the rail's, and the
5428
+ // disc, bridge and title inside it inherit whichever won. A root
5429
+ // colour still reaches every item through the carrier's donut.
5430
+ color: Object.fromEntries(ROLES.map((c) => [c, { item: { base: {
5107
5431
  '--steps-accent': `var(--color-${c})`,
5108
5432
  '--steps-accent-content': `var(--color-${c}-content)`,
5109
5433
  '--steps-accent-ink': roleInk(c),
5434
+ '--steps-complete-ink': stepInk(`var(--color-${c})`, c),
5110
5435
  } } }])),
5111
5436
  size: {
5112
5437
  xs: { root: { base: { '--steps-ind': 'calc(var(--size-selector) * 5)', '--steps-font': 'var(--text-xs)' } } },
@@ -5145,10 +5470,45 @@ export const steps = {
5145
5470
  },
5146
5471
  },
5147
5472
  };
5473
+ /**
5474
+ * The modal sheet's slide (#83): in from its edge, back out to it.
5475
+ *
5476
+ * `translate` is physical, so the travel is a custom property that flips with
5477
+ * the placement AND the direction — off the reading start is leftward in LTR
5478
+ * and rightward in RTL (the `rtl` hedge, as everywhere here). Keyed on
5479
+ * `data-l-dock="sheet"`, the regime, which holds through the exit where
5480
+ * `:modal` does not, so the slide-out leaves from the sheet's own box.
5481
+ * Opacity stays at 1: the travel is the transition. Each direction rides the
5482
+ * transition declared at its destination; reduced motion drops both.
5483
+ */
5484
+ const sheetSlide = (enter, exit) => {
5485
+ const sheet = '&[data-l-dock="sheet"]';
5486
+ const open = `${sheet}[data-state="open"]`;
5487
+ const travel = (tempo) => {
5488
+ const duration = tempo.split(' ')[0];
5489
+ return `translate ${tempo}, display ${duration} allow-discrete, overlay ${duration} allow-discrete`;
5490
+ };
5491
+ return {
5492
+ base: { '--drawer-travel': '-100%' },
5493
+ selectors: {
5494
+ '&[data-placement="end"]': { '--drawer-travel': '100%' },
5495
+ [`&[data-placement="start"]${rtl}`]: { '--drawer-travel': '100%' },
5496
+ [`&[data-placement="end"]${rtl}`]: { '--drawer-travel': '-100%' },
5497
+ [sheet]: { opacity: '1', translate: 'var(--drawer-travel) 0', transition: travel(exit) },
5498
+ [open]: { translate: 'none', transition: travel(enter) },
5499
+ },
5500
+ at: {
5501
+ 'starting-style': { selectors: { [open]: { opacity: '1', translate: 'var(--drawer-travel) 0' } } },
5502
+ 'reduced-motion': { selectors: { [sheet]: { transition: 'none' }, [open]: { transition: 'none' } } },
5503
+ },
5504
+ };
5505
+ };
5148
5506
  /**
5149
5507
  * Drawer — daisy's drawer-side as a native <dialog> edge sheet: base-100
5150
- * paper, no border (daisy separates the drawer with the scrim), faded in.
5151
- * Base render is the inline mode; `:modal` is the top-layer edge sheet.
5508
+ * paper, no border (daisy separates the drawer with the scrim), sliding in
5509
+ * from its edge the way daisy's drawer-side does (`translate` over 0.3s,
5510
+ * #83). Base render is the inline mode; `data-l-dock="sheet"` is the
5511
+ * top-layer edge sheet.
5152
5512
  */
5153
5513
  export const drawer = {
5154
5514
  component: 'drawer',
@@ -5163,7 +5523,7 @@ export const drawer = {
5163
5523
  ...focusRing,
5164
5524
  },
5165
5525
  },
5166
- panel: withPresence(popupPresence('none'), {
5526
+ panel: withPresence(withPresence(popupPresence('none'), sheetSlide('var(--duration-slow) var(--ease-standard)', 'var(--duration-slow) var(--ease-standard)')), {
5167
5527
  base: {
5168
5528
  padding: 'var(--space-lg)',
5169
5529
  background: 'var(--color-base-100)',
@@ -5171,30 +5531,39 @@ export const drawer = {
5171
5531
  border: 'none',
5172
5532
  borderRadius: 'var(--radius-box)',
5173
5533
  boxShadow: 'var(--shadow-xl)',
5174
- inlineSize: 'min(20rem, 85vw)',
5534
+ // The width is capped by `--l-measure` (Drawer.Panel's
5535
+ // `measure`, via the layout step table); this is the default
5536
+ // an unset `measure` leaves. A cap, not a width, because the
5537
+ // table spells `full` as `none` — valid only for a max. In
5538
+ // flow and as a sheet alike, the panel fills what it is given
5539
+ // up to the measure, as its border box, so `full` never
5540
+ // overflows by its own padding.
5541
+ boxSizing: 'border-box',
5542
+ '--l-measure': 'min(20rem, 85vw)',
5543
+ inlineSize: '100%',
5544
+ maxInlineSize: 'var(--l-measure)',
5175
5545
  },
5176
5546
  states: { open: {}, closed: {} },
5177
5547
  selectors: {
5178
5548
  /**
5179
- * The platform's own spelling of "this open is the modal
5180
- * one": `:modal`. The base styles above are the INLINE
5181
- * render (`show()` keeps the panel in flow); this block is
5182
- * the top-layer edge sheet. Logical insets pin the edge, so
5183
- * RTL mirrors free.
5549
+ * The top-layer edge sheet, keyed on the regime
5550
+ * (`data-l-dock="sheet"`) rather than `:modal`, which stops
5551
+ * matching the moment `close()` runs — so the sheet keeps its
5552
+ * box through the exit (#83). The base styles above are the
5553
+ * INLINE render. Logical insets pin the edge, so RTL mirrors
5554
+ * free.
5184
5555
  */
5185
- '&:modal': {
5556
+ '&[data-l-dock="sheet"]': {
5186
5557
  position: 'fixed',
5187
5558
  insetBlockStart: '0',
5188
5559
  insetBlockEnd: '0',
5189
5560
  blockSize: '100dvh',
5190
5561
  maxBlockSize: '100dvh',
5191
- inlineSize: 'min(20rem, 85vw)',
5192
- maxInlineSize: 'none',
5193
5562
  margin: '0',
5194
5563
  borderRadius: '0',
5195
5564
  },
5196
- '&[data-placement="start"]:modal': { insetInlineStart: '0', insetInlineEnd: 'auto' },
5197
- '&[data-placement="end"]:modal': { insetInlineStart: 'auto', insetInlineEnd: '0' },
5565
+ '&[data-placement="start"][data-l-dock="sheet"]': { insetInlineStart: '0', insetInlineEnd: 'auto' },
5566
+ '&[data-placement="end"][data-l-dock="sheet"]': { insetInlineStart: 'auto', insetInlineEnd: '0' },
5198
5567
  },
5199
5568
  }),
5200
5569
  backdrop: {
@@ -5235,7 +5604,20 @@ export const drawer = {
5235
5604
  */
5236
5605
  export const table = {
5237
5606
  component: 'table',
5607
+ // Public to a design system derived from this one (#73).
5608
+ hooks: {
5609
+ properties: {
5610
+ '--table-cell-align': 'Cell text alignment; zero writes it inline from the column spec.',
5611
+ '--table-accent': 'The header ink accent.',
5612
+ '--table-pad-block': 'Cell block padding.',
5613
+ '--table-pad-inline': 'Cell inline padding.',
5614
+ '--table-font': 'The table font size.',
5615
+ },
5616
+ },
5238
5617
  tokens: {
5618
+ // The column spec's alignment (#55): a cell that names an aligned
5619
+ // column overrides it inline; everything else reads `start`.
5620
+ '--table-cell-align': 'start',
5239
5621
  '--table-accent': 'var(--color-base-content)',
5240
5622
  '--table-pad-block': 'var(--space-sm)',
5241
5623
  '--table-pad-inline': 'var(--space-md)',
@@ -5249,6 +5631,9 @@ export const table = {
5249
5631
  borderRadius: 'var(--radius-box)',
5250
5632
  background: 'var(--color-base-100)',
5251
5633
  },
5634
+ // Stacked (Table.Root stack, #55): zero lays each row out as a block
5635
+ // below the table's breakpoint; the card it becomes is drawn here.
5636
+ at: tableStackAt(tokens, 'root', { border: '0', borderRadius: '0', background: 'transparent' }),
5252
5637
  },
5253
5638
  table: {
5254
5639
  base: {
@@ -5266,34 +5651,52 @@ export const table = {
5266
5651
  fontSize: 'var(--text-xs)',
5267
5652
  color: 'color-mix(in oklab, var(--color-base-content) 60%, transparent)',
5268
5653
  },
5654
+ at: tableStackAt(tokens, 'caption', { paddingInline: '0' }),
5269
5655
  },
5270
5656
  head: {},
5271
- body: {},
5657
+ body: { at: tableStackAt(tokens, 'body', { rowGap: 'var(--space-md)' }) },
5272
5658
  foot: {
5273
5659
  base: {
5274
5660
  fontSize: 'var(--text-xs)',
5275
5661
  color: 'color-mix(in oklab, var(--color-base-content) 60%, transparent)',
5276
5662
  },
5663
+ at: tableStackAt(tokens, 'foot', { rowGap: 'var(--space-md)', marginBlockStart: 'var(--space-md)' }),
5277
5664
  },
5278
5665
  row: {
5279
5666
  base: { borderBlockEnd: 'var(--border) solid var(--color-base-200)' },
5280
5667
  states: {
5281
5668
  selected: { background: 'var(--color-base-300)' },
5282
5669
  },
5670
+ at: tableStackAt(tokens, 'row', {
5671
+ border: 'var(--border) solid var(--color-base-200)',
5672
+ borderRadius: 'var(--radius-box)',
5673
+ background: 'var(--color-base-100)',
5674
+ padding: 'var(--table-pad-block) var(--table-pad-inline)',
5675
+ }),
5283
5676
  },
5284
5677
  'header-cell': {
5285
5678
  base: {
5286
5679
  padding: 'var(--table-pad-block) var(--table-pad-inline)',
5287
- textAlign: 'start',
5680
+ textAlign: 'var(--table-cell-align)',
5288
5681
  fontWeight: 'var(--weight-semibold)',
5289
5682
  fontSize: 'var(--text-xs)',
5290
5683
  color: 'color-mix(in oklab, var(--table-accent) 60%, transparent)',
5291
5684
  },
5685
+ at: tableStackAt(tokens, 'header-cell', { paddingInline: '0' }),
5292
5686
  },
5293
5687
  cell: {
5294
5688
  base: {
5295
5689
  padding: 'var(--table-pad-block) var(--table-pad-inline)',
5296
- textAlign: 'start',
5690
+ textAlign: 'var(--table-cell-align)',
5691
+ },
5692
+ at: tableStackAt(tokens, 'cell', { paddingInline: '0', paddingBlock: 'var(--space-xs)' }),
5693
+ },
5694
+ // A stacked cell's column label, in the header's muted semibold.
5695
+ 'cell-label': {
5696
+ base: {
5697
+ fontWeight: 'var(--weight-semibold)',
5698
+ fontSize: 'var(--text-xs)',
5699
+ color: 'color-mix(in oklab, var(--table-accent) 60%, transparent)',
5297
5700
  },
5298
5701
  },
5299
5702
  },
@@ -5766,6 +6169,12 @@ export const countdown = {
5766
6169
  xl: { root: { base: { '--countdown-font': 'var(--text-3xl)' } } },
5767
6170
  },
5768
6171
  },
6172
+ // One line inside a sentence (#57): the surrounding text's size and
6173
+ // weight instead of the display step. What stays is what makes
6174
+ // it a countdown — tabular digits, its ink, the per-tick entry.
6175
+ modifiers: {
6176
+ inline: { root: { base: { fontSize: 'inherit', fontWeight: 'inherit' } } },
6177
+ },
5769
6178
  keyframes: { 'zero-daisyui-countdown-in': 'from { transform: translateY(0.6em); opacity: 0; }' },
5770
6179
  };
5771
6180
  /**
@@ -5890,10 +6299,10 @@ export const diff = {
5890
6299
  export const recipes = [
5891
6300
  tabs, collapsible, switchRecipe, dialog, popover, tooltip, menu,
5892
6301
  field, checkbox, radioGroup, progress, slider, accordion, select, button, avatar, toast, combobox,
5893
- toggle, toggleGroup, numberInput, ratingGroup, treeView, input, textarea, nativeSelect,
5894
- card, alert, badge, divider, skeleton, spinner,
6302
+ toggle, toggleGroup, numberInput, ratingGroup, treeView, input, textarea,
6303
+ card, alert, emptyState, badge, divider, skeleton, spinner,
5895
6304
  kbd, status, indicator, stats, timeline, chat, radialProgress, join,
5896
- navbar, breadcrumbs, pagination, steps, drawer,
6305
+ navbar, navList, breadcrumbs, pagination, steps, drawer,
5897
6306
  table,
5898
6307
  fileUpload,
5899
6308
  carousel,