@sigx/zero-daisyui 0.2.0-beta.5 → 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 (98) 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 +19 -6
  41. package/dist/lynx/components/card.css +5 -5
  42. package/dist/lynx/components/carousel.css +4 -4
  43. package/dist/lynx/components/center.css +13 -0
  44. package/dist/lynx/components/chat.css +12 -11
  45. package/dist/lynx/components/checkbox.css +4 -5
  46. package/dist/lynx/components/combobox.css +49 -6
  47. package/dist/lynx/components/container.css +11 -0
  48. package/dist/lynx/components/countdown.css +5 -0
  49. package/dist/lynx/components/dialog.css +22 -14
  50. package/dist/lynx/components/diff.css +6 -4
  51. package/dist/lynx/components/drawer.css +30 -6
  52. package/dist/lynx/components/empty-state.css +342 -0
  53. package/dist/lynx/components/file-upload.css +10 -10
  54. package/dist/lynx/components/grid.css +28 -0
  55. package/dist/lynx/components/indicator.css +24 -24
  56. package/dist/lynx/components/kbd.css +1 -1
  57. package/dist/lynx/components/menu.css +14 -9
  58. package/dist/lynx/components/nav-list.css +302 -0
  59. package/dist/lynx/components/number-input.css +4 -2
  60. package/dist/lynx/components/pagination.css +7 -6
  61. package/dist/lynx/components/popover.css +12 -6
  62. package/dist/lynx/components/progress.css +1 -1
  63. package/dist/lynx/components/radial-progress.css +5 -5
  64. package/dist/lynx/components/radio-group.css +2 -2
  65. package/dist/lynx/components/rating-group.css +5 -5
  66. package/dist/lynx/components/select.css +18 -12
  67. package/dist/lynx/components/slider.css +6 -5
  68. package/dist/lynx/components/spacer.css +11 -0
  69. package/dist/lynx/components/stack.css +35 -0
  70. package/dist/lynx/components/stats.css +10 -10
  71. package/dist/lynx/components/steps.css +117 -77
  72. package/dist/lynx/components/switch.css +5 -5
  73. package/dist/lynx/components/table.css +18 -12
  74. package/dist/lynx/components/tabs.css +5 -5
  75. package/dist/lynx/components/timeline.css +14 -5
  76. package/dist/lynx/components/toast.css +4 -4
  77. package/dist/lynx/components/tooltip.css +10 -5
  78. package/dist/lynx/components/tree-view.css +3 -3
  79. package/dist/lynx/index.css +1402 -450
  80. package/dist/lynx/manifest.json +354 -37
  81. package/dist/lynx/tokens.css +36 -0
  82. package/dist/manifest.json +351 -34
  83. package/dist/recipes.d.ts +17 -10
  84. package/dist/recipes.d.ts.map +1 -1
  85. package/dist/recipes.js +866 -153
  86. package/dist/recipes.js.map +1 -1
  87. package/dist/register.d.ts +89 -14
  88. package/dist/report.json +2815 -408
  89. package/dist/tokens.d.ts +22 -11
  90. package/dist/tokens.d.ts.map +1 -1
  91. package/dist/tokens.js +57 -7
  92. package/dist/tokens.js.map +1 -1
  93. package/package.json +28 -10
  94. package/src/design-system.ts +15 -8
  95. package/src/recipes.ts +880 -157
  96. package/src/tokens.ts +54 -7
  97. package/dist/css/components/native-select.css +0 -128
  98. 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,
@@ -877,8 +891,30 @@ const btnSizes = {
877
891
  lg: { trigger: { base: { height: fieldHeight('lg'), paddingInline: 'calc(var(--size-field) * 5)', fontSize: 'var(--text-md)' } } },
878
892
  xl: { trigger: { base: { height: fieldHeight('xl'), paddingInline: 'calc(var(--size-field) * 6)', fontSize: 'var(--text-lg)' } } },
879
893
  };
894
+ /**
895
+ * `btn`'s inline padding restated physically, for the `targets.lynx`
896
+ * sections of every btn-wearing recipe: logical spellings resolve on iOS but
897
+ * not on Android (measured, signalxjs/lynx#1084), so the lynx emitter
898
+ * refuses `padding-inline`. Physical is that target's norm — lynx has no
899
+ * RTL flow for the logical spelling to flip with.
900
+ */
901
+ const lynxBtnPad = (mult) => ({
902
+ paddingLeft: `calc(var(--size-field) * ${mult})`,
903
+ paddingRight: `calc(var(--size-field) * ${mult})`,
904
+ });
905
+ /** The btn size ramp's physical inline padding, for `targets.lynx.variants`. */
906
+ const lynxBtnSizes = (part) => ({
907
+ xs: { [part]: { base: lynxBtnPad(2) } },
908
+ sm: { [part]: { base: lynxBtnPad(3) } },
909
+ lg: { [part]: { base: lynxBtnPad(5) } },
910
+ xl: { [part]: { base: lynxBtnPad(6) } },
911
+ });
880
912
  export const dialog = {
881
913
  component: 'dialog',
914
+ // Public to a design system derived from this one (#73).
915
+ hooks: {
916
+ keyframes: ['zero-daisy-pop'],
917
+ },
882
918
  keyframes: {
883
919
  'zero-daisy-pop': 'from { opacity: 0; transform: scale(0.95); } to { opacity: 1; transform: scale(1); }',
884
920
  },
@@ -895,7 +931,12 @@ export const dialog = {
895
931
  },
896
932
  popup: withPresence(popupPresence('translateY(8px) scale(0.97)'), {
897
933
  base: {
898
- 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)',
899
940
  maxWidth: '32rem',
900
941
  width: 'calc(100% - 2rem)',
901
942
  background: 'var(--color-base-100)',
@@ -925,14 +966,15 @@ export const dialog = {
925
966
  },
926
967
  // daisy's `modal-action`: the actions sit at the trailing edge, one
927
968
  // step of the button gap apart, pushed down by the modal's own
928
- // padding value rather than by a scale step (daisy writes 1.5rem for
929
- // 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.
930
972
  footer: {
931
973
  base: {
932
974
  display: 'flex',
933
975
  justifyContent: 'flex-end',
934
976
  gap: 'var(--space-md)',
935
- marginBlockStart: '1.5rem',
977
+ marginBlockStart: 'var(--space-2xl)',
936
978
  },
937
979
  },
938
980
  close: {
@@ -957,6 +999,19 @@ export const dialog = {
957
999
  // Trigger-carried axes — see `btnColors` for why the popup is out of
958
1000
  // reach and the trigger is the whole story here.
959
1001
  variants: { color: btnColors(), size: btnSizes },
1002
+ // The btn paddings and the footer's push-down, restated physically —
1003
+ // see `lynxBtnPad` for the #1084 verdict this answers.
1004
+ targets: {
1005
+ lynx: {
1006
+ parts: {
1007
+ trigger: { base: lynxBtnPad(4) },
1008
+ close: { base: lynxBtnPad(4) },
1009
+ cancel: { base: lynxBtnPad(4) },
1010
+ footer: { base: { marginTop: 'var(--space-2xl)' } },
1011
+ },
1012
+ variants: { size: lynxBtnSizes('trigger') },
1013
+ },
1014
+ },
960
1015
  };
961
1016
  // daisy "dropdown-content"/card look for floating panels.
962
1017
  const floatingPanel = {
@@ -997,6 +1052,16 @@ export const popover = {
997
1052
  },
998
1053
  // Trigger-carried axes — same wiring as dialog, same reason.
999
1054
  variants: { color: btnColors(), size: btnSizes },
1055
+ // The btn paddings, restated physically — see `lynxBtnPad` (#1084).
1056
+ targets: {
1057
+ lynx: {
1058
+ parts: {
1059
+ trigger: { base: lynxBtnPad(4) },
1060
+ close: { base: lynxBtnPad(3) },
1061
+ },
1062
+ variants: { size: lynxBtnSizes('trigger') },
1063
+ },
1064
+ },
1000
1065
  };
1001
1066
  export const tooltip = {
1002
1067
  component: 'tooltip',
@@ -1032,6 +1097,13 @@ export const tooltip = {
1032
1097
  // Trigger-carried axes — same wiring as dialog, same reason. The bubble
1033
1098
  // stays daisy's neutral tooltip whatever the trigger's colour.
1034
1099
  variants: { color: btnColors(), size: btnSizes },
1100
+ // The btn paddings, restated physically — see `lynxBtnPad` (#1084).
1101
+ targets: {
1102
+ lynx: {
1103
+ parts: { trigger: { base: lynxBtnPad(4) } },
1104
+ variants: { size: lynxBtnSizes('trigger') },
1105
+ },
1106
+ },
1035
1107
  };
1036
1108
  // daisy "menu in a dropdown" look.
1037
1109
  export const menu = {
@@ -1055,7 +1127,7 @@ export const menu = {
1055
1127
  base: {
1056
1128
  display: 'flex',
1057
1129
  alignItems: 'center',
1058
- gap: '0.625rem',
1130
+ gap: 'var(--space-md)',
1059
1131
  padding: 'var(--space-md) var(--space-lg)',
1060
1132
  fontSize: 'var(--text-sm)',
1061
1133
  fontWeight: 'var(--weight-medium)',
@@ -1075,7 +1147,7 @@ export const menu = {
1075
1147
  base: {
1076
1148
  display: 'flex',
1077
1149
  alignItems: 'center',
1078
- gap: '0.625rem',
1150
+ gap: 'var(--space-md)',
1079
1151
  padding: 'var(--space-md) var(--space-lg)',
1080
1152
  fontSize: 'var(--text-sm)',
1081
1153
  fontWeight: 'var(--weight-medium)',
@@ -1094,7 +1166,7 @@ export const menu = {
1094
1166
  base: {
1095
1167
  display: 'flex',
1096
1168
  alignItems: 'center',
1097
- gap: '0.625rem',
1169
+ gap: 'var(--space-md)',
1098
1170
  padding: 'var(--space-md) var(--space-lg)',
1099
1171
  fontSize: 'var(--text-sm)',
1100
1172
  fontWeight: 'var(--weight-medium)',
@@ -1129,7 +1201,7 @@ export const menu = {
1129
1201
  base: {
1130
1202
  display: 'flex',
1131
1203
  alignItems: 'center',
1132
- gap: '0.625rem',
1204
+ gap: 'var(--space-md)',
1133
1205
  padding: 'var(--space-md) var(--space-lg)',
1134
1206
  fontSize: 'var(--text-sm)',
1135
1207
  fontWeight: 'var(--weight-medium)',
@@ -1177,6 +1249,13 @@ export const menu = {
1177
1249
  // Trigger-carried axes — same wiring as dialog, same reason. The dropdown
1178
1250
  // and its items are top-layer siblings the donut cannot reach.
1179
1251
  variants: { color: btnColors(), size: btnSizes },
1252
+ // The btn paddings, restated physically — see `lynxBtnPad` (#1084).
1253
+ targets: {
1254
+ lynx: {
1255
+ parts: { trigger: { base: lynxBtnPad(4) } },
1256
+ variants: { size: lynxBtnSizes('trigger') },
1257
+ },
1258
+ },
1180
1259
  };
1181
1260
  export const field = {
1182
1261
  component: 'field',
@@ -1299,7 +1378,7 @@ export const checkbox = {
1299
1378
  },
1300
1379
  parts: {
1301
1380
  root: {
1302
- base: { display: 'inline-flex', alignItems: 'center', gap: '0.625rem', cursor: 'pointer' },
1381
+ base: { display: 'inline-flex', alignItems: 'center', gap: 'var(--space-md)', cursor: 'pointer' },
1303
1382
  states: {
1304
1383
  disabled: { opacity: 'var(--disabled-opacity)', cursor: 'not-allowed' },
1305
1384
  checked: {}, unchecked: {}, indeterminate: {},
@@ -1460,7 +1539,23 @@ export const checkbox = {
1460
1539
  // The tick's fill: the control's `color:` names
1461
1540
  // `--checkbox-on-accent`, and `currentColor` was that
1462
1541
  // ink by inheritance — spend it directly.
1463
- base: { backgroundColor: 'var(--checkbox-on-accent)' },
1542
+ //
1543
+ // The tick's motion, restated as `transform`: the
1544
+ // standalone `rotate`/`translate` properties resolve on
1545
+ // iOS but not on Android (measured, signalxjs/lynx#1084),
1546
+ // so the emitter refuses them. `transform` functions are
1547
+ // proven on both platforms — and the transition follows
1548
+ // the property that now moves.
1549
+ base: {
1550
+ backgroundColor: 'var(--checkbox-on-accent)',
1551
+ transform: 'rotate(45deg)',
1552
+ transition: 'clip-path var(--duration-slow) var(--ease-standard) var(--duration-instant), '
1553
+ + 'opacity var(--duration-instant) var(--ease-standard) var(--duration-instant), '
1554
+ + 'transform var(--duration-slow) var(--ease-standard) var(--duration-instant)',
1555
+ },
1556
+ // The order matches the standalone properties' fixed
1557
+ // apply order (translate, then rotate).
1558
+ states: { indeterminate: { transform: 'translateY(-35%) rotate(0deg)' } },
1464
1559
  },
1465
1560
  },
1466
1561
  },
@@ -1480,7 +1575,7 @@ export const radioGroup = {
1480
1575
  },
1481
1576
  parts: {
1482
1577
  root: {
1483
- base: { display: 'flex', flexDirection: 'column', gap: '0.625rem' },
1578
+ base: { display: 'flex', flexDirection: 'column', gap: 'var(--space-md)' },
1484
1579
  states: { invalid: {}, required: {} },
1485
1580
  selectors: {
1486
1581
  // `invalid` is a fact about the GROUP — `item-control` carries
@@ -1494,7 +1589,7 @@ export const radioGroup = {
1494
1589
  states: { disabled: { opacity: 'var(--disabled-opacity)' } },
1495
1590
  },
1496
1591
  item: {
1497
- base: { display: 'inline-flex', alignItems: 'center', gap: '0.625rem', cursor: 'pointer' },
1592
+ base: { display: 'inline-flex', alignItems: 'center', gap: 'var(--space-md)', cursor: 'pointer' },
1498
1593
  states: {
1499
1594
  disabled: { opacity: 'var(--disabled-opacity)', cursor: 'not-allowed' },
1500
1595
  checked: {}, unchecked: {},
@@ -1706,6 +1801,18 @@ export const progress = {
1706
1801
  // other way.
1707
1802
  'zero-daisy-indeterminate': 'from { margin-inline-start: -40%; } to { margin-inline-start: 100%; }',
1708
1803
  },
1804
+ // The same sweep with a physical margin: logical spellings resolve on
1805
+ // iOS but not on Android (measured, signalxjs/lynx#1084), so the emitter
1806
+ // refuses the shared keyframes. Physical is the lynx target's norm — no
1807
+ // RTL flow there for the sweep to mirror with. Keyframes merge per name,
1808
+ // so this REPLACES the shared animation on lynx.
1809
+ targets: {
1810
+ lynx: {
1811
+ keyframes: {
1812
+ 'zero-daisy-indeterminate': 'from { margin-left: -40%; } to { margin-left: 100%; }',
1813
+ },
1814
+ },
1815
+ },
1709
1816
  };
1710
1817
  export const slider = {
1711
1818
  component: 'slider',
@@ -1830,6 +1937,22 @@ export const slider = {
1830
1937
  targets: {
1831
1938
  lynx: {
1832
1939
  parts: {
1940
+ track: {
1941
+ // The web track's `margin-block` breathing room,
1942
+ // restated physically: logical spellings resolve on iOS
1943
+ // but not on Android (measured, signalxjs/lynx#1084), so
1944
+ // the emitter refuses them. Physical is the lynx
1945
+ // target's norm — no RTL flow there.
1946
+ base: {
1947
+ marginTop: 'calc(var(--size-selector) * 1.5)',
1948
+ marginBottom: 'calc(var(--size-selector) * 1.5)',
1949
+ },
1950
+ },
1951
+ mark: {
1952
+ // Same #1084 verdict: the tick labels hang below the
1953
+ // track off a physical padding.
1954
+ base: { paddingTop: 'calc(var(--size-selector) * 2 + var(--space-2xs))' },
1955
+ },
1833
1956
  range: {
1834
1957
  // The web fill deepens the accent 90/10 toward
1835
1958
  // base-content (`progressFill`'s recipe); the plain
@@ -1853,9 +1976,20 @@ export const slider = {
1853
1976
  // thumb — a native-input trick with no lynx equivalent;
1854
1977
  // the composed `.zx-slider__range` box above IS that
1855
1978
  // fill here, so the knob only needs its own two paints.
1979
+ //
1980
+ // The centering geometry is restated physically beside
1981
+ // the paint: `inset-block-start`/`translate`/
1982
+ // `margin-inline-start` resolve on iOS but not on
1983
+ // Android (measured, signalxjs/lynx#1084 — the knob sat
1984
+ // off-center there), so the emitter refuses them.
1985
+ // `top` + `transform: translateY()` + `margin-left` are
1986
+ // proven on both platforms.
1856
1987
  base: {
1857
1988
  background: 'var(--color-base-100)',
1858
1989
  border: '0.25rem solid var(--slider-accent)',
1990
+ top: '50%',
1991
+ transform: 'translateY(-50%)',
1992
+ marginLeft: 'calc(var(--size-selector) * -2.5)',
1859
1993
  },
1860
1994
  },
1861
1995
  },
@@ -2037,7 +2171,7 @@ export const select = {
2037
2171
  display: 'flex',
2038
2172
  alignItems: 'center',
2039
2173
  justifyContent: 'space-between',
2040
- gap: '0.625rem',
2174
+ gap: 'var(--space-md)',
2041
2175
  padding: 'var(--space-md) var(--space-lg)',
2042
2176
  fontSize: 'var(--text-sm)',
2043
2177
  fontWeight: 'var(--weight-medium)',
@@ -2073,6 +2207,17 @@ export const select = {
2073
2207
  // Base equals the default (`tokens:` accent, `fieldControl`'s md height),
2074
2208
  // so the twins restate it; declared for the manifest (signalxjs/lynx#1070).
2075
2209
  defaultVariants: { color: 'primary', size: 'md' },
2210
+ // The btn paddings, restated physically — see `lynxBtnPad` (#1084).
2211
+ // The trigger ramp restates `md` too (unlike `btnSizes`), so its lynx
2212
+ // counterpart does as well.
2213
+ targets: {
2214
+ lynx: {
2215
+ parts: { trigger: { base: lynxBtnPad(4) } },
2216
+ variants: {
2217
+ size: { ...lynxBtnSizes('trigger'), md: { trigger: { base: lynxBtnPad(4) } } },
2218
+ },
2219
+ },
2220
+ },
2076
2221
  };
2077
2222
  /**
2078
2223
  * daisy's BUTTON ramp — `.btn`'s fixed height per size step, in `--size-field`
@@ -2091,6 +2236,15 @@ const BTN_STEPS = { xs: 6, sm: 8, md: 10, lg: 12, xl: 14 };
2091
2236
  const btnHeight = (step) => `calc(var(--size-field) * ${BTN_STEPS[step]})`;
2092
2237
  export const button = {
2093
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
+ },
2094
2248
  // The two axes meet here instead of multiplying. `color` sets the accent
2095
2249
  // pair; `variant` decides how the accent is used. 8 + 4 + 5 rules rather
2096
2250
  // than 8 × 4.
@@ -2101,9 +2255,34 @@ export const button = {
2101
2255
  '--btn-ink': roleInk('primary'),
2102
2256
  },
2103
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
+ },
2104
2280
  root: {
2105
2281
  base: {
2106
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',
2107
2286
  display: 'inline-flex',
2108
2287
  alignItems: 'center',
2109
2288
  justifyContent: 'center',
@@ -2119,6 +2298,9 @@ export const button = {
2119
2298
  + 'border-color var(--duration-fast) var(--ease-standard)',
2120
2299
  },
2121
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' },
2122
2304
  disabled: { opacity: 'var(--disabled-opacity)', cursor: 'not-allowed' },
2123
2305
  // The ring is drawn on the page, offset clear of the button —
2124
2306
  // ink, not fill, and the same 3:1 floor. On `--btn-accent` it
@@ -2262,40 +2444,20 @@ export const button = {
2262
2444
  active: {
2263
2445
  root: { base: { transform: 'translateY(1px)', boxShadow: 'none', filter: 'brightness(0.92)' } },
2264
2446
  },
2265
- /**
2266
- * daisy's loading spinner, drawn by the recipe (#332): daisy marks
2267
- * the button with a `loading loading-spinner` span; zero changes no
2268
- * DOM, so the ring is a `::before` in `currentColor` with one
2269
- * transparent quadrant — it inherits whatever ink the variant chose,
2270
- * and the root's existing `gap` spaces it from the label. Literal
2271
- * duration + explicit reduced-motion `none`, like the spinner
2272
- * recipe: the kit collapses `--duration-*` under reduced motion, and
2273
- * an infinite loop at ~0s strobes rather than stops.
2274
- */
2275
- loading: {
2276
- root: {
2277
- base: { cursor: 'progress' },
2278
- selectors: {
2279
- '&::before': {
2280
- content: '""',
2281
- boxSizing: 'border-box',
2282
- inlineSize: '1em',
2283
- blockSize: '1em',
2284
- flex: 'none',
2285
- borderRadius: '9999px',
2286
- border: 'calc(var(--border) * 2) solid currentColor',
2287
- borderBlockStartColor: 'transparent',
2288
- animation: 'zero-daisyui-btn-spin 0.7s linear infinite',
2289
- },
2290
- },
2291
- at: {
2292
- 'reduced-motion': { selectors: { '&::before': { animation: 'none' } } },
2293
- },
2294
- },
2295
- },
2296
2447
  },
2297
2448
  defaultVariants: { color: 'primary', variant: 'solid', size: 'md' },
2298
2449
  keyframes: { 'zero-daisyui-btn-spin': 'to { transform: rotate(360deg) }' },
2450
+ // The icon chips' zeroed inline padding, restated physically: logical
2451
+ // spellings resolve on iOS but not on Android (measured,
2452
+ // signalxjs/lynx#1084), so the emitter refuses them.
2453
+ targets: {
2454
+ lynx: {
2455
+ modifiers: {
2456
+ square: { root: { base: { paddingLeft: '0', paddingRight: '0' } } },
2457
+ circle: { root: { base: { paddingLeft: '0', paddingRight: '0' } } },
2458
+ },
2459
+ },
2460
+ },
2299
2461
  };
2300
2462
  export const avatar = {
2301
2463
  component: 'avatar',
@@ -2348,6 +2510,13 @@ export const avatar = {
2348
2510
  },
2349
2511
  },
2350
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
+ },
2351
2520
  // daisy colours the RING, not the fallback — that is what its avatar
2352
2521
  // does, and the fallback stays neutral so initials read the same
2353
2522
  // whichever role is chosen.
@@ -2521,6 +2690,24 @@ export const toast = {
2521
2690
  // signalxjs/lynx#1070. No `color` default: the un-attributed toast is a
2522
2691
  // neutral base-200 surface, outside the color vocabulary.
2523
2692
  defaultVariants: { size: 'md' },
2693
+ // The corner placements' physical anchors: logical inset spellings
2694
+ // resolve on iOS but not on Android (measured, signalxjs/lynx#1084), so
2695
+ // the emitter refuses them. Physical is the lynx target's norm — no RTL
2696
+ // flow there, so `start` IS left and `end` IS right.
2697
+ targets: {
2698
+ lynx: {
2699
+ parts: {
2700
+ viewport: {
2701
+ selectors: {
2702
+ '&[data-placement="top-start"]': { left: '0' },
2703
+ '&[data-placement="top-end"]': { right: '0' },
2704
+ '&[data-placement="bottom-start"]': { left: '0' },
2705
+ '&[data-placement="bottom-end"]': { right: '0' },
2706
+ },
2707
+ },
2708
+ },
2709
+ },
2710
+ },
2524
2711
  };
2525
2712
  export const combobox = {
2526
2713
  component: 'combobox',
@@ -2539,6 +2726,7 @@ export const combobox = {
2539
2726
  base: {
2540
2727
  display: 'inline-flex',
2541
2728
  alignItems: 'stretch',
2729
+ flexWrap: 'wrap',
2542
2730
  minWidth: '13rem',
2543
2731
  ...fieldControl,
2544
2732
  transition: 'border-color var(--duration-fast) var(--ease-standard)',
@@ -2584,6 +2772,52 @@ export const combobox = {
2584
2772
  '&::placeholder': { color: 'color-mix(in oklab, var(--color-base-content) 60%, transparent)' },
2585
2773
  },
2586
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
+ },
2587
2821
  trigger: {
2588
2822
  base: {
2589
2823
  appearance: 'none',
@@ -2620,7 +2854,7 @@ export const combobox = {
2620
2854
  display: 'flex',
2621
2855
  alignItems: 'center',
2622
2856
  justifyContent: 'space-between',
2623
- gap: '0.625rem',
2857
+ gap: 'var(--space-md)',
2624
2858
  padding: 'var(--space-md) var(--space-lg)',
2625
2859
  fontSize: 'var(--text-sm)',
2626
2860
  fontWeight: 'var(--weight-medium)',
@@ -2652,15 +2886,15 @@ export const combobox = {
2652
2886
  '--combobox-accent': `var(--color-${c})`,
2653
2887
  } } }])),
2654
2888
  // The control carries the height — the shared field ramp, the same one
2655
- // `select`'s trigger steps on — and the input carries the inset and the
2656
- // 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
2657
2891
  // complete.
2658
2892
  size: {
2659
- xs: { control: { base: { height: fieldHeight('xs') } }, input: { base: { padding: 'var(--space-2xs) var(--space-md)', fontSize: 'var(--text-xs)' } } },
2660
- sm: { control: { base: { height: fieldHeight('sm') } }, input: { base: { padding: 'var(--space-xs) var(--space-md)', fontSize: 'var(--text-sm)' } } },
2661
- md: { control: { base: { height: fieldHeight('md') } }, input: { base: { padding: 'var(--space-sm) var(--space-lg)', fontSize: 'var(--text-sm)' } } },
2662
- lg: { control: { base: { height: fieldHeight('lg') } }, input: { base: { padding: 'var(--space-md) var(--space-xl)', fontSize: 'var(--text-md)' } } },
2663
- 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)' } } },
2664
2898
  },
2665
2899
  },
2666
2900
  // The visible ring lives on `control`; input and trigger delegate.
@@ -2756,6 +2990,13 @@ export const toggle = {
2756
2990
  // the on item filled with the accent.
2757
2991
  export const toggleGroup = {
2758
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
+ },
2759
3000
  tokens: {
2760
3001
  '--toggle-group-accent': 'var(--color-primary)',
2761
3002
  '--toggle-group-on-accent': 'var(--color-primary-content)',
@@ -2995,6 +3236,17 @@ export const numberInput = {
2995
3236
  xl: { control: { base: { height: fieldHeight('xl') } }, input: { base: { fontSize: 'var(--text-lg)', padding: 'var(--space-lg) var(--space-xl)' } } },
2996
3237
  },
2997
3238
  },
3239
+ // The steppers' inline padding, restated physically: logical spellings
3240
+ // resolve on iOS but not on Android (measured, signalxjs/lynx#1084), so
3241
+ // the emitter refuses them.
3242
+ targets: {
3243
+ lynx: {
3244
+ parts: {
3245
+ 'increment-trigger': { base: { paddingLeft: 'var(--space-lg)', paddingRight: 'var(--space-lg)' } },
3246
+ 'decrement-trigger': { base: { paddingLeft: 'var(--space-lg)', paddingRight: 'var(--space-lg)' } },
3247
+ },
3248
+ },
3249
+ },
2998
3250
  };
2999
3251
  /**
3000
3252
  * daisy "rating" flavor: a row of orange filled symbols. The default content
@@ -3173,7 +3425,7 @@ export const ratingGroup = {
3173
3425
  const treeRow = {
3174
3426
  display: 'flex',
3175
3427
  alignItems: 'center',
3176
- gap: '0.625rem',
3428
+ gap: 'var(--space-md)',
3177
3429
  padding: 'var(--space-sm) var(--space-md)',
3178
3430
  fontSize: 'var(--tree-text)',
3179
3431
  fontWeight: 'var(--weight-medium)',
@@ -3284,6 +3536,16 @@ export const treeView = {
3284
3536
  xl: { root: { base: { '--tree-text': 'var(--text-lg)' } } },
3285
3537
  },
3286
3538
  },
3539
+ // The indent, restated physically: logical spellings resolve on iOS but
3540
+ // not on Android (measured, signalxjs/lynx#1084), so the emitter refuses
3541
+ // them. Physical is the lynx target's norm — no RTL flow there.
3542
+ targets: {
3543
+ lynx: {
3544
+ parts: {
3545
+ 'branch-content': { base: { paddingLeft: 'var(--space-lg)' } },
3546
+ },
3547
+ },
3548
+ },
3287
3549
  };
3288
3550
  /**
3289
3551
  * daisy "input" flavor: the shared field box with nothing in it but the text.
@@ -3436,74 +3698,6 @@ export const textarea = {
3436
3698
  },
3437
3699
  },
3438
3700
  };
3439
- /**
3440
- * NativeSelect (#333) — daisy "select" flavor on the platform's own picker:
3441
- * the `fieldControl` box (same metrics as every field, so mixed rows line
3442
- * up), `appearance: none`, and the recipe-drawn chevron at the inline end.
3443
- * No open state — the platform owns the popup — so colour surfaces on the
3444
- * focus ring, exactly like input's.
3445
- */
3446
- export const nativeSelect = {
3447
- component: 'native-select',
3448
- tokens: { '--native-select-accent': 'var(--color-base-content)' },
3449
- parts: {
3450
- root: {
3451
- base: { position: 'relative', display: 'inline-flex', alignItems: 'center' },
3452
- states: { disabled: {}, invalid: {}, required: {}, placeholder: {} },
3453
- },
3454
- control: {
3455
- base: {
3456
- appearance: 'none',
3457
- width: '100%',
3458
- minWidth: '12rem',
3459
- ...fieldControl,
3460
- color: 'var(--color-base-content)',
3461
- font: 'inherit',
3462
- fontSize: 'var(--text-sm)',
3463
- padding: 'var(--space-sm) var(--space-md)',
3464
- // Room for the chevron the platform no longer draws.
3465
- paddingInlineEnd: 'calc(var(--space-md) + 1.25em)',
3466
- cursor: 'pointer',
3467
- transition: 'border-color var(--duration-fast) var(--ease-standard)',
3468
- },
3469
- states: {
3470
- hover: { borderColor: 'var(--color-base-content)' },
3471
- invalid: { borderColor: 'var(--color-error)' },
3472
- disabled: { opacity: 'var(--disabled-opacity)', cursor: 'not-allowed' },
3473
- required: {},
3474
- // Same muting as the fields' placeholders (#264).
3475
- placeholder: { color: 'color-mix(in oklab, var(--color-base-content) 60%, transparent)' },
3476
- 'focus-visible': { ...focusRing['focus-visible'], outline: '2px solid var(--native-select-accent)' },
3477
- },
3478
- },
3479
- indicator: {
3480
- base: {
3481
- position: 'absolute',
3482
- insetInlineEnd: 'var(--space-md)',
3483
- pointerEvents: 'none',
3484
- opacity: '0.6',
3485
- fontSize: 'var(--text-sm)',
3486
- },
3487
- },
3488
- },
3489
- // The visible ring lives on the <select> itself; the wrapper delegates.
3490
- skipStates: { root: ['focus-visible'] },
3491
- variants: {
3492
- // The ring carries the role — daisy's field chrome is neutral, so
3493
- // focus is the only place colour surfaces (input's exact move).
3494
- color: Object.fromEntries(ROLES.map((c) => [c, { root: { base: {
3495
- '--native-select-accent': `var(--color-${c})`,
3496
- } } }])),
3497
- // The shared field-height ramp, on the control itself.
3498
- size: {
3499
- xs: { control: { base: { height: fieldHeight('xs'), fontSize: 'var(--text-xs)', padding: 'var(--space-2xs) var(--space-xs)', paddingInlineEnd: 'calc(var(--space-xs) + 1.25em)' } } },
3500
- sm: { control: { base: { height: fieldHeight('sm'), fontSize: 'var(--text-xs)', padding: 'var(--space-xs) var(--space-sm)', paddingInlineEnd: 'calc(var(--space-sm) + 1.25em)' } } },
3501
- md: {},
3502
- lg: { control: { base: { height: fieldHeight('lg'), fontSize: 'var(--text-md)', padding: 'var(--space-md) var(--space-lg)', paddingInlineEnd: 'calc(var(--space-lg) + 1.25em)' } } },
3503
- xl: { control: { base: { height: fieldHeight('xl'), fontSize: 'var(--text-lg)', padding: 'var(--space-lg) var(--space-xl)', paddingInlineEnd: 'calc(var(--space-xl) + 1.25em)' } } },
3504
- },
3505
- },
3506
- };
3507
3701
  // ── Content tier (#311) ───────────────────────────────────────────────────
3508
3702
  /**
3509
3703
  * daisy "card" flavor: `bg-base-100` on a `rounded-box` with a real shadow —
@@ -3512,6 +3706,12 @@ export const nativeSelect = {
3512
3706
  */
3513
3707
  export const card = {
3514
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
+ },
3515
3715
  tokens: { '--card-pad': 'var(--space-xl)' },
3516
3716
  parts: {
3517
3717
  root: {
@@ -3666,6 +3866,87 @@ export const alert = {
3666
3866
  },
3667
3867
  },
3668
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
+ };
3669
3950
  /**
3670
3951
  * daisy "badge" flavor: a fully-rounded pill with a hairline, filled by the
3671
3952
  * role. No `variant` — the repo-wide decision (#175) holds everywhere except
@@ -3674,7 +3955,18 @@ export const alert = {
3674
3955
  */
3675
3956
  export const badge = {
3676
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
+ },
3677
3967
  tokens: {
3968
+ '--badge-dot': 'currentColor',
3969
+ '--badge-dot-ring': 'currentColor',
3678
3970
  '--badge-fill': 'var(--color-base-200)',
3679
3971
  '--badge-ink': 'var(--color-base-content)',
3680
3972
  },
@@ -3690,7 +3982,7 @@ export const badge = {
3690
3982
  // daisy badges are pills; a large radius rather than 9999px so
3691
3983
  // a square-cornered theme can still say so through the token.
3692
3984
  borderRadius: 'var(--radius-selector)',
3693
- padding: '0.125rem 0.625rem',
3985
+ padding: 'var(--space-2xs) var(--space-lg)',
3694
3986
  fontSize: 'var(--text-xs)',
3695
3987
  fontWeight: 'var(--weight-semibold)',
3696
3988
  lineHeight: 'var(--leading-normal)',
@@ -3698,12 +3990,53 @@ export const badge = {
3698
3990
  textDecoration: 'none',
3699
3991
  },
3700
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
+ },
3701
4025
  },
3702
4026
  variants: {
3703
- color: Object.fromEntries(ROLES.map((c) => [c, { root: { base: {
4027
+ color: Object.fromEntries(ROLES.map((c) => [c, {
4028
+ root: { base: {
3704
4029
  '--badge-fill': `var(--color-${c})`,
3705
4030
  '--badge-ink': `var(--color-${c}-content)`,
3706
- } } }])),
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
+ }])),
3707
4040
  size: {
3708
4041
  xs: { root: { base: { fontSize: 'var(--text-xs)', padding: '0 var(--space-sm)' } } },
3709
4042
  sm: { root: { base: { fontSize: 'var(--text-xs)', padding: '0 var(--space-md)' } } },
@@ -3711,6 +4044,8 @@ export const badge = {
3711
4044
  lg: { root: { base: { fontSize: 'var(--text-sm)', padding: 'var(--space-2xs) var(--space-lg)' } } },
3712
4045
  xl: { root: { base: { fontSize: 'var(--text-md)', padding: 'var(--space-xs) var(--space-xl)' } } },
3713
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; }',
3714
4049
  },
3715
4050
  };
3716
4051
  /** daisy "divider": the base-300 rule, thickening with the ramp. */
@@ -3754,6 +4089,12 @@ export const divider = {
3754
4089
  */
3755
4090
  export const skeleton = {
3756
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
+ },
3757
4098
  tokens: { '--skeleton-fill': 'var(--color-base-300)' },
3758
4099
  parts: {
3759
4100
  root: {
@@ -3841,7 +4182,11 @@ export const kbd = {
3841
4182
  alignItems: 'center',
3842
4183
  justifyContent: 'center',
3843
4184
  minInlineSize: '1.75em',
3844
- 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))',
3845
4190
  background: 'var(--kbd-fill)',
3846
4191
  color: 'var(--kbd-ink)',
3847
4192
  border: 'var(--border) solid var(--color-base-300)',
@@ -3967,6 +4312,30 @@ export const indicator = {
3967
4312
  xl: { item: { base: { fontSize: 'var(--text-lg)' } } },
3968
4313
  },
3969
4314
  },
4315
+ // The corner slots, restated physically: logical inset spellings and the
4316
+ // standalone `translate` property resolve on iOS but not on Android
4317
+ // (measured, signalxjs/lynx#1084), so the emitter refuses them. Physical
4318
+ // anchors plus `transform: translate()` are proven on both platforms,
4319
+ // and physical is the lynx target's norm — no RTL flow there, so the
4320
+ // hand-flipped RTL rules above have nothing to engage with either.
4321
+ targets: {
4322
+ lynx: {
4323
+ parts: {
4324
+ item: {
4325
+ selectors: {
4326
+ '&[data-placement="top-start"]': { top: '0', left: '0', transform: 'translate(-50%, -50%)' },
4327
+ '&[data-placement="top"]': { top: '0', left: '50%', transform: 'translate(-50%, -50%)' },
4328
+ '&[data-placement="top-end"]': { top: '0', right: '0', transform: 'translate(50%, -50%)' },
4329
+ '&[data-placement="start"]': { top: '50%', left: '0', transform: 'translate(-50%, -50%)' },
4330
+ '&[data-placement="end"]': { top: '50%', right: '0', transform: 'translate(50%, -50%)' },
4331
+ '&[data-placement="bottom-start"]': { bottom: '0', left: '0', transform: 'translate(-50%, 50%)' },
4332
+ '&[data-placement="bottom"]': { bottom: '0', left: '50%', transform: 'translate(-50%, 50%)' },
4333
+ '&[data-placement="bottom-end"]': { bottom: '0', right: '0', transform: 'translate(50%, 50%)' },
4334
+ },
4335
+ },
4336
+ },
4337
+ },
4338
+ },
3970
4339
  };
3971
4340
  /** daisy stats: the rounded base-100 panel with rule-thin item joins. */
3972
4341
  export const stats = {
@@ -4046,10 +4415,31 @@ export const stats = {
4046
4415
  },
4047
4416
  },
4048
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"])) > &';
4049
4426
  /** daisy timeline: the boxed content beside a dotted axis. */
4050
4427
  export const timeline = {
4051
4428
  component: 'timeline',
4052
- 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
+ },
4053
4443
  parts: {
4054
4444
  root: {
4055
4445
  base: {
@@ -4085,6 +4475,11 @@ export const timeline = {
4085
4475
  gridTemplateColumns: 'auto 1fr',
4086
4476
  flex: '1 1 0%',
4087
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' },
4088
4483
  },
4089
4484
  },
4090
4485
  marker: {
@@ -4094,12 +4489,24 @@ export const timeline = {
4094
4489
  boxSizing: 'border-box',
4095
4490
  borderRadius: '9999px',
4096
4491
  background: 'var(--timeline-accent)',
4097
- 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)',
4098
4500
  display: 'flex',
4099
4501
  alignItems: 'center',
4100
4502
  justifyContent: 'center',
4101
4503
  fontSize: '0',
4102
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
+ },
4103
4510
  selectors: {
4104
4511
  // The axis cell, both orientations. `place-self` centres the
4105
4512
  // dot on the line in the cross axis.
@@ -4177,6 +4584,7 @@ export const timeline = {
4177
4584
  variants: {
4178
4585
  color: Object.fromEntries(ROLES.map((c) => [c, { marker: { base: {
4179
4586
  '--timeline-accent': `var(--color-${c})`,
4587
+ '--timeline-ring': `var(--color-${c}-content)`,
4180
4588
  } } }])),
4181
4589
  size: {
4182
4590
  xs: { marker: { base: { '--timeline-marker-size': 'calc(var(--size-selector) * 2)' } }, content: { base: { fontSize: 'var(--text-xs)' } } },
@@ -4284,6 +4692,16 @@ export const chat = {
4284
4692
  xl: { bubble: { base: { fontSize: 'var(--text-lg)', padding: 'var(--space-md) var(--space-xl)' } } },
4285
4693
  },
4286
4694
  },
4695
+ // The row's vertical padding, restated physically: logical spellings
4696
+ // resolve on iOS but not on Android (measured, signalxjs/lynx#1084), so
4697
+ // the emitter refuses them.
4698
+ targets: {
4699
+ lynx: {
4700
+ parts: {
4701
+ root: { base: { paddingTop: 'var(--space-2xs)', paddingBottom: 'var(--space-2xs)' } },
4702
+ },
4703
+ },
4704
+ },
4287
4705
  };
4288
4706
  /** daisy radial-progress: --value became --progress-percent; same ring. */
4289
4707
  export const radialProgress = {
@@ -4533,6 +4951,111 @@ export const navbar = {
4533
4951
  },
4534
4952
  },
4535
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
+ };
4536
5059
  /**
4537
5060
  * Breadcrumbs — daisy's breadcrumbs: a compact text-sm trail, links
4538
5061
  * underlining on hover, the current crumb plain full ink. Colour rebinds
@@ -4735,7 +5258,29 @@ export const pagination = {
4735
5258
  xl: { root: { base: { '--pg-size': 'calc(var(--size-field) * 14)', '--pg-font': 'var(--text-lg)' } } },
4736
5259
  },
4737
5260
  },
5261
+ // The cell's inline padding, restated physically: logical spellings
5262
+ // resolve on iOS but not on Android (measured, signalxjs/lynx#1084), so
5263
+ // the emitter refuses them.
5264
+ targets: {
5265
+ lynx: {
5266
+ parts: {
5267
+ item: { base: { paddingLeft: 'var(--space-2xs)', paddingRight: 'var(--space-2xs)' } },
5268
+ },
5269
+ },
5270
+ },
4738
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))`;
4739
5284
  /**
4740
5285
  * Steps — daisy's steps translated to the richer rail: bold numbered discs
4741
5286
  * on base-200, the walked disc and line refilled with the accent pair
@@ -4748,6 +5293,9 @@ export const steps = {
4748
5293
  '--steps-accent': 'var(--color-primary)',
4749
5294
  '--steps-accent-content': 'var(--color-primary-content)',
4750
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'),
4751
5299
  '--steps-ind': 'calc(var(--size-selector) * 7)',
4752
5300
  '--steps-font': 'var(--text-sm)',
4753
5301
  },
@@ -4819,7 +5367,14 @@ export const steps = {
4819
5367
  },
4820
5368
  states: {
4821
5369
  active: { background: 'var(--steps-accent)', color: 'var(--steps-accent-content)' },
4822
- 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)' },
4823
5378
  inactive: { background: 'var(--color-base-200)', color: 'var(--color-base-content)' },
4824
5379
  },
4825
5380
  },
@@ -4868,10 +5423,15 @@ export const steps = {
4868
5423
  },
4869
5424
  },
4870
5425
  variants: {
4871
- 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: {
4872
5431
  '--steps-accent': `var(--color-${c})`,
4873
5432
  '--steps-accent-content': `var(--color-${c}-content)`,
4874
5433
  '--steps-accent-ink': roleInk(c),
5434
+ '--steps-complete-ink': stepInk(`var(--color-${c})`, c),
4875
5435
  } } }])),
4876
5436
  size: {
4877
5437
  xs: { root: { base: { '--steps-ind': 'calc(var(--size-selector) * 5)', '--steps-font': 'var(--text-xs)' } } },
@@ -4881,11 +5441,74 @@ export const steps = {
4881
5441
  xl: { root: { base: { '--steps-ind': 'calc(var(--size-selector) * 9)', '--steps-font': 'var(--text-md)' } } },
4882
5442
  },
4883
5443
  },
5444
+ // The bridge's anchors and the vertical item's tail padding, restated
5445
+ // physically: logical spellings resolve on iOS but not on Android
5446
+ // (measured, signalxjs/lynx#1084), so the emitter refuses them. Physical
5447
+ // is the lynx target's norm — no RTL flow there to mirror with.
5448
+ targets: {
5449
+ lynx: {
5450
+ parts: {
5451
+ item: {
5452
+ selectors: {
5453
+ '&[data-orientation="vertical"]': { paddingBottom: 'var(--space-lg)' },
5454
+ },
5455
+ },
5456
+ separator: {
5457
+ selectors: {
5458
+ '&[data-orientation="horizontal"]': {
5459
+ top: 'calc(var(--space-xs) + var(--steps-ind) / 2)',
5460
+ left: '50%',
5461
+ },
5462
+ '&[data-orientation="vertical"]': {
5463
+ left: 'calc(var(--space-xs) + (var(--steps-ind) - var(--border)) / 2)',
5464
+ top: 'calc(var(--space-xs) + var(--steps-ind))',
5465
+ bottom: 'calc(var(--space-xs) * -1)',
5466
+ },
5467
+ },
5468
+ },
5469
+ },
5470
+ },
5471
+ },
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
+ };
4884
5505
  };
4885
5506
  /**
4886
5507
  * Drawer — daisy's drawer-side as a native <dialog> edge sheet: base-100
4887
- * paper, no border (daisy separates the drawer with the scrim), faded in.
4888
- * 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.
4889
5512
  */
4890
5513
  export const drawer = {
4891
5514
  component: 'drawer',
@@ -4900,7 +5523,7 @@ export const drawer = {
4900
5523
  ...focusRing,
4901
5524
  },
4902
5525
  },
4903
- panel: withPresence(popupPresence('none'), {
5526
+ panel: withPresence(withPresence(popupPresence('none'), sheetSlide('var(--duration-slow) var(--ease-standard)', 'var(--duration-slow) var(--ease-standard)')), {
4904
5527
  base: {
4905
5528
  padding: 'var(--space-lg)',
4906
5529
  background: 'var(--color-base-100)',
@@ -4908,30 +5531,39 @@ export const drawer = {
4908
5531
  border: 'none',
4909
5532
  borderRadius: 'var(--radius-box)',
4910
5533
  boxShadow: 'var(--shadow-xl)',
4911
- 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)',
4912
5545
  },
4913
5546
  states: { open: {}, closed: {} },
4914
5547
  selectors: {
4915
5548
  /**
4916
- * The platform's own spelling of "this open is the modal
4917
- * one": `:modal`. The base styles above are the INLINE
4918
- * render (`show()` keeps the panel in flow); this block is
4919
- * the top-layer edge sheet. Logical insets pin the edge, so
4920
- * 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.
4921
5555
  */
4922
- '&:modal': {
5556
+ '&[data-l-dock="sheet"]': {
4923
5557
  position: 'fixed',
4924
5558
  insetBlockStart: '0',
4925
5559
  insetBlockEnd: '0',
4926
5560
  blockSize: '100dvh',
4927
5561
  maxBlockSize: '100dvh',
4928
- inlineSize: 'min(20rem, 85vw)',
4929
- maxInlineSize: 'none',
4930
5562
  margin: '0',
4931
5563
  borderRadius: '0',
4932
5564
  },
4933
- '&[data-placement="start"]:modal': { insetInlineStart: '0', insetInlineEnd: 'auto' },
4934
- '&[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' },
4935
5567
  },
4936
5568
  }),
4937
5569
  backdrop: {
@@ -4953,6 +5585,16 @@ export const drawer = {
4953
5585
  // Trigger-carried axes — see `btnColors` for why the panel is out of
4954
5586
  // reach and the trigger is the whole story here.
4955
5587
  variants: { color: btnColors(), size: btnSizes },
5588
+ // The btn paddings, restated physically — see `lynxBtnPad` (#1084).
5589
+ targets: {
5590
+ lynx: {
5591
+ parts: {
5592
+ trigger: { base: lynxBtnPad(4) },
5593
+ close: { base: lynxBtnPad(4) },
5594
+ },
5595
+ variants: { size: lynxBtnSizes('trigger') },
5596
+ },
5597
+ },
4956
5598
  };
4957
5599
  /**
4958
5600
  * Table — daisy's `table`: quiet base-200 row rules in a rounded box,
@@ -4962,7 +5604,20 @@ export const drawer = {
4962
5604
  */
4963
5605
  export const table = {
4964
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
+ },
4965
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',
4966
5621
  '--table-accent': 'var(--color-base-content)',
4967
5622
  '--table-pad-block': 'var(--space-sm)',
4968
5623
  '--table-pad-inline': 'var(--space-md)',
@@ -4976,6 +5631,9 @@ export const table = {
4976
5631
  borderRadius: 'var(--radius-box)',
4977
5632
  background: 'var(--color-base-100)',
4978
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' }),
4979
5637
  },
4980
5638
  table: {
4981
5639
  base: {
@@ -4993,34 +5651,52 @@ export const table = {
4993
5651
  fontSize: 'var(--text-xs)',
4994
5652
  color: 'color-mix(in oklab, var(--color-base-content) 60%, transparent)',
4995
5653
  },
5654
+ at: tableStackAt(tokens, 'caption', { paddingInline: '0' }),
4996
5655
  },
4997
5656
  head: {},
4998
- body: {},
5657
+ body: { at: tableStackAt(tokens, 'body', { rowGap: 'var(--space-md)' }) },
4999
5658
  foot: {
5000
5659
  base: {
5001
5660
  fontSize: 'var(--text-xs)',
5002
5661
  color: 'color-mix(in oklab, var(--color-base-content) 60%, transparent)',
5003
5662
  },
5663
+ at: tableStackAt(tokens, 'foot', { rowGap: 'var(--space-md)', marginBlockStart: 'var(--space-md)' }),
5004
5664
  },
5005
5665
  row: {
5006
5666
  base: { borderBlockEnd: 'var(--border) solid var(--color-base-200)' },
5007
5667
  states: {
5008
5668
  selected: { background: 'var(--color-base-300)' },
5009
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
+ }),
5010
5676
  },
5011
5677
  'header-cell': {
5012
5678
  base: {
5013
5679
  padding: 'var(--table-pad-block) var(--table-pad-inline)',
5014
- textAlign: 'start',
5680
+ textAlign: 'var(--table-cell-align)',
5015
5681
  fontWeight: 'var(--weight-semibold)',
5016
5682
  fontSize: 'var(--text-xs)',
5017
5683
  color: 'color-mix(in oklab, var(--table-accent) 60%, transparent)',
5018
5684
  },
5685
+ at: tableStackAt(tokens, 'header-cell', { paddingInline: '0' }),
5019
5686
  },
5020
5687
  cell: {
5021
5688
  base: {
5022
5689
  padding: 'var(--table-pad-block) var(--table-pad-inline)',
5023
- 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)',
5024
5700
  },
5025
5701
  },
5026
5702
  },
@@ -5340,6 +6016,22 @@ export const carousel = {
5340
6016
  xl: { root: { base: { '--carousel-dot': '0.875rem', '--carousel-nav': '3rem' } } },
5341
6017
  },
5342
6018
  },
6019
+ // The nav buttons' anchors, restated physically: logical inset
6020
+ // spellings resolve on iOS but not on Android (measured,
6021
+ // signalxjs/lynx#1084), so the emitter refuses them. Physical is the
6022
+ // lynx target's norm — no RTL flow there, so `start` IS left.
6023
+ targets: {
6024
+ lynx: {
6025
+ parts: {
6026
+ 'prev-trigger': {
6027
+ base: { top: 'calc(50% - var(--carousel-nav) / 2)', left: 'var(--space-sm)' },
6028
+ },
6029
+ 'next-trigger': {
6030
+ base: { top: 'calc(50% - var(--carousel-nav) / 2)', right: 'var(--space-sm)' },
6031
+ },
6032
+ },
6033
+ },
6034
+ },
5343
6035
  };
5344
6036
  /**
5345
6037
  * Swap — daisy's signature swap-rotate as the ONE look: the leaving face
@@ -5477,6 +6169,12 @@ export const countdown = {
5477
6169
  xl: { root: { base: { '--countdown-font': 'var(--text-3xl)' } } },
5478
6170
  },
5479
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
+ },
5480
6178
  keyframes: { 'zero-daisyui-countdown-in': 'from { transform: translateY(0.6em); opacity: 0; }' },
5481
6179
  };
5482
6180
  /**
@@ -5581,15 +6279,30 @@ export const diff = {
5581
6279
  },
5582
6280
  },
5583
6281
  },
6282
+ // The reveal pane's pin and the handle's centering margin, restated
6283
+ // physically: logical spellings resolve on iOS but not on Android
6284
+ // (measured, signalxjs/lynx#1084), so the emitter refuses them.
6285
+ // Physical is the lynx target's norm — no RTL flow there to mirror
6286
+ // with.
6287
+ lynx: {
6288
+ parts: {
6289
+ after: {
6290
+ base: { top: '0', bottom: '0', left: '0' },
6291
+ },
6292
+ handle: {
6293
+ base: { top: '0', bottom: '0', marginLeft: 'calc(var(--diff-hit) / -2)' },
6294
+ },
6295
+ },
6296
+ },
5584
6297
  },
5585
6298
  };
5586
6299
  export const recipes = [
5587
6300
  tabs, collapsible, switchRecipe, dialog, popover, tooltip, menu,
5588
6301
  field, checkbox, radioGroup, progress, slider, accordion, select, button, avatar, toast, combobox,
5589
- toggle, toggleGroup, numberInput, ratingGroup, treeView, input, textarea, nativeSelect,
5590
- card, alert, badge, divider, skeleton, spinner,
6302
+ toggle, toggleGroup, numberInput, ratingGroup, treeView, input, textarea,
6303
+ card, alert, emptyState, badge, divider, skeleton, spinner,
5591
6304
  kbd, status, indicator, stats, timeline, chat, radialProgress, join,
5592
- navbar, breadcrumbs, pagination, steps, drawer,
6305
+ navbar, navList, breadcrumbs, pagination, steps, drawer,
5593
6306
  table,
5594
6307
  fileUpload,
5595
6308
  carousel,