@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/src/recipes.ts CHANGED
@@ -4,8 +4,9 @@
4
4
  * package: a design system is data, and "looks like daisy" is one possible
5
5
  * value of that data.
6
6
  */
7
- import type { CssProps, PartStyles, RecipeInput, RoleDecl } from '@sigx/zero-kit';
8
- import { roles } from './tokens.js';
7
+ import type { CssProps, PartStyles, RecipeInput } from '@sigx/zero-kit';
8
+ import { axisRoles, tableStackAt } from '@sigx/zero-kit/define';
9
+ import { roles, tokens } from './tokens.js';
9
10
 
10
11
  /**
11
12
  * Every role a consumer can pass as `color`, derived from the declaration
@@ -14,9 +15,7 @@ import { roles } from './tokens.js';
14
15
  * `-content` or `-soft` are fills or hairlines, not action colours; this
15
16
  * design system declares none.
16
17
  */
17
- const ROLES = Object.entries(roles as Record<string, RoleDecl>)
18
- .filter(([, decl]) => decl.content !== false && decl.soft !== false)
19
- .map(([name]) => name);
18
+ const ROLES = axisRoles(roles);
20
19
 
21
20
  /**
22
21
  * "…and the reading direction is right-to-left" — appended to a selector, never
@@ -537,6 +536,13 @@ const disclosureSizes: Record<string, Record<string, PartStyles>> = {
537
536
  // daisy "collapse collapse-arrow" flavor.
538
537
  export const collapsible: RecipeInput = {
539
538
  component: 'collapsible',
539
+ // Public to a design system derived from this one (#73).
540
+ hooks: {
541
+ properties: {
542
+ '--collapsible-accent': 'The accent of the open trigger.',
543
+ },
544
+ pseudo: { trigger: ['::after'] },
545
+ },
540
546
  // The accent is the open heading's INK, so the default is base-content —
541
547
  // the un-attributed render stays exactly daisy's neutral collapse and a
542
548
  // role only arrives through `data-color`.
@@ -611,6 +617,14 @@ export const collapsible: RecipeInput = {
611
617
  // daisy "toggle" flavor.
612
618
  export const switchRecipe: RecipeInput = {
613
619
  component: 'switch',
620
+ // Public to a design system derived from this one (#73).
621
+ hooks: {
622
+ properties: {
623
+ '--switch-size': 'The height; width, padding and knob travel derive from it.',
624
+ '--switch-accent': 'The checked track and knob accent.',
625
+ '--switch-ink': 'The unchecked border, knob and ring ink (daisy --input-color).',
626
+ },
627
+ },
614
628
  /**
615
629
  * daisy drives the whole toggle off ONE size, exactly as it drives the
616
630
  * checkbox: `--size` is the height, the padding is a fixed eighth of it,
@@ -919,8 +933,32 @@ const btnSizes: Record<string, Record<string, PartStyles>> = {
919
933
  xl: { trigger: { base: { height: fieldHeight('xl'), paddingInline: 'calc(var(--size-field) * 6)', fontSize: 'var(--text-lg)' } } },
920
934
  };
921
935
 
936
+ /**
937
+ * `btn`'s inline padding restated physically, for the `targets.lynx`
938
+ * sections of every btn-wearing recipe: logical spellings resolve on iOS but
939
+ * not on Android (measured, signalxjs/lynx#1084), so the lynx emitter
940
+ * refuses `padding-inline`. Physical is that target's norm — lynx has no
941
+ * RTL flow for the logical spelling to flip with.
942
+ */
943
+ const lynxBtnPad = (mult: number): NonNullable<PartStyles['base']> => ({
944
+ paddingLeft: `calc(var(--size-field) * ${mult})`,
945
+ paddingRight: `calc(var(--size-field) * ${mult})`,
946
+ });
947
+
948
+ /** The btn size ramp's physical inline padding, for `targets.lynx.variants`. */
949
+ const lynxBtnSizes = (part: string): Record<string, Record<string, PartStyles>> => ({
950
+ xs: { [part]: { base: lynxBtnPad(2) } },
951
+ sm: { [part]: { base: lynxBtnPad(3) } },
952
+ lg: { [part]: { base: lynxBtnPad(5) } },
953
+ xl: { [part]: { base: lynxBtnPad(6) } },
954
+ });
955
+
922
956
  export const dialog: RecipeInput = {
923
957
  component: 'dialog',
958
+ // Public to a design system derived from this one (#73).
959
+ hooks: {
960
+ keyframes: ['zero-daisy-pop'],
961
+ },
924
962
  keyframes: {
925
963
  'zero-daisy-pop': 'from { opacity: 0; transform: scale(0.95); } to { opacity: 1; transform: scale(1); }',
926
964
  },
@@ -937,7 +975,12 @@ export const dialog: RecipeInput = {
937
975
  },
938
976
  popup: withPresence(popupPresence('translateY(8px) scale(0.97)'), {
939
977
  base: {
940
- padding: '1.5rem',
978
+ // A <dialog> keeps the UA's `content-box`, and zero ships no
979
+ // reset — so a width or max-width meant to leave a gutter
980
+ // grew by the padding, and at phone width the popup ran past
981
+ // both edges (#101). Its box is the border box.
982
+ boxSizing: 'border-box',
983
+ padding: 'var(--space-2xl)',
941
984
  maxWidth: '32rem',
942
985
  width: 'calc(100% - 2rem)',
943
986
  background: 'var(--color-base-100)',
@@ -967,14 +1010,15 @@ export const dialog: RecipeInput = {
967
1010
  },
968
1011
  // daisy's `modal-action`: the actions sit at the trailing edge, one
969
1012
  // step of the button gap apart, pushed down by the modal's own
970
- // padding value rather than by a scale step (daisy writes 1.5rem for
971
- // both, as `popup` above does).
1013
+ // padding — daisy writes 1.5rem for both, and both now read the top
1014
+ // of the ramp (`--space-2xl`, 1.25rem) so a density override moves
1015
+ // the popup's inset and the footer's push-down together.
972
1016
  footer: {
973
1017
  base: {
974
1018
  display: 'flex',
975
1019
  justifyContent: 'flex-end',
976
1020
  gap: 'var(--space-md)',
977
- marginBlockStart: '1.5rem',
1021
+ marginBlockStart: 'var(--space-2xl)',
978
1022
  },
979
1023
  },
980
1024
  close: {
@@ -999,6 +1043,19 @@ export const dialog: RecipeInput = {
999
1043
  // Trigger-carried axes — see `btnColors` for why the popup is out of
1000
1044
  // reach and the trigger is the whole story here.
1001
1045
  variants: { color: btnColors(), size: btnSizes },
1046
+ // The btn paddings and the footer's push-down, restated physically —
1047
+ // see `lynxBtnPad` for the #1084 verdict this answers.
1048
+ targets: {
1049
+ lynx: {
1050
+ parts: {
1051
+ trigger: { base: lynxBtnPad(4) },
1052
+ close: { base: lynxBtnPad(4) },
1053
+ cancel: { base: lynxBtnPad(4) },
1054
+ footer: { base: { marginTop: 'var(--space-2xl)' } },
1055
+ },
1056
+ variants: { size: lynxBtnSizes('trigger') },
1057
+ },
1058
+ },
1002
1059
  };
1003
1060
 
1004
1061
  // daisy "dropdown-content"/card look for floating panels.
@@ -1041,6 +1098,16 @@ export const popover: RecipeInput = {
1041
1098
  },
1042
1099
  // Trigger-carried axes — same wiring as dialog, same reason.
1043
1100
  variants: { color: btnColors(), size: btnSizes },
1101
+ // The btn paddings, restated physically — see `lynxBtnPad` (#1084).
1102
+ targets: {
1103
+ lynx: {
1104
+ parts: {
1105
+ trigger: { base: lynxBtnPad(4) },
1106
+ close: { base: lynxBtnPad(3) },
1107
+ },
1108
+ variants: { size: lynxBtnSizes('trigger') },
1109
+ },
1110
+ },
1044
1111
  };
1045
1112
 
1046
1113
  export const tooltip: RecipeInput = {
@@ -1077,6 +1144,13 @@ export const tooltip: RecipeInput = {
1077
1144
  // Trigger-carried axes — same wiring as dialog, same reason. The bubble
1078
1145
  // stays daisy's neutral tooltip whatever the trigger's colour.
1079
1146
  variants: { color: btnColors(), size: btnSizes },
1147
+ // The btn paddings, restated physically — see `lynxBtnPad` (#1084).
1148
+ targets: {
1149
+ lynx: {
1150
+ parts: { trigger: { base: lynxBtnPad(4) } },
1151
+ variants: { size: lynxBtnSizes('trigger') },
1152
+ },
1153
+ },
1080
1154
  };
1081
1155
 
1082
1156
  // daisy "menu in a dropdown" look.
@@ -1101,7 +1175,7 @@ export const menu: RecipeInput = {
1101
1175
  base: {
1102
1176
  display: 'flex',
1103
1177
  alignItems: 'center',
1104
- gap: '0.625rem',
1178
+ gap: 'var(--space-md)',
1105
1179
  padding: 'var(--space-md) var(--space-lg)',
1106
1180
  fontSize: 'var(--text-sm)',
1107
1181
  fontWeight: 'var(--weight-medium)',
@@ -1121,7 +1195,7 @@ export const menu: RecipeInput = {
1121
1195
  base: {
1122
1196
  display: 'flex',
1123
1197
  alignItems: 'center',
1124
- gap: '0.625rem',
1198
+ gap: 'var(--space-md)',
1125
1199
  padding: 'var(--space-md) var(--space-lg)',
1126
1200
  fontSize: 'var(--text-sm)',
1127
1201
  fontWeight: 'var(--weight-medium)',
@@ -1140,7 +1214,7 @@ export const menu: RecipeInput = {
1140
1214
  base: {
1141
1215
  display: 'flex',
1142
1216
  alignItems: 'center',
1143
- gap: '0.625rem',
1217
+ gap: 'var(--space-md)',
1144
1218
  padding: 'var(--space-md) var(--space-lg)',
1145
1219
  fontSize: 'var(--text-sm)',
1146
1220
  fontWeight: 'var(--weight-medium)',
@@ -1175,7 +1249,7 @@ export const menu: RecipeInput = {
1175
1249
  base: {
1176
1250
  display: 'flex',
1177
1251
  alignItems: 'center',
1178
- gap: '0.625rem',
1252
+ gap: 'var(--space-md)',
1179
1253
  padding: 'var(--space-md) var(--space-lg)',
1180
1254
  fontSize: 'var(--text-sm)',
1181
1255
  fontWeight: 'var(--weight-medium)',
@@ -1223,6 +1297,13 @@ export const menu: RecipeInput = {
1223
1297
  // Trigger-carried axes — same wiring as dialog, same reason. The dropdown
1224
1298
  // and its items are top-layer siblings the donut cannot reach.
1225
1299
  variants: { color: btnColors(), size: btnSizes },
1300
+ // The btn paddings, restated physically — see `lynxBtnPad` (#1084).
1301
+ targets: {
1302
+ lynx: {
1303
+ parts: { trigger: { base: lynxBtnPad(4) } },
1304
+ variants: { size: lynxBtnSizes('trigger') },
1305
+ },
1306
+ },
1226
1307
  };
1227
1308
 
1228
1309
  export const field: RecipeInput = {
@@ -1349,7 +1430,7 @@ export const checkbox: RecipeInput = {
1349
1430
  },
1350
1431
  parts: {
1351
1432
  root: {
1352
- base: { display: 'inline-flex', alignItems: 'center', gap: '0.625rem', cursor: 'pointer' },
1433
+ base: { display: 'inline-flex', alignItems: 'center', gap: 'var(--space-md)', cursor: 'pointer' },
1353
1434
  states: {
1354
1435
  disabled: { opacity: 'var(--disabled-opacity)', cursor: 'not-allowed' },
1355
1436
  checked: {}, unchecked: {}, indeterminate: {},
@@ -1510,7 +1591,23 @@ export const checkbox: RecipeInput = {
1510
1591
  // The tick's fill: the control's `color:` names
1511
1592
  // `--checkbox-on-accent`, and `currentColor` was that
1512
1593
  // ink by inheritance — spend it directly.
1513
- base: { backgroundColor: 'var(--checkbox-on-accent)' },
1594
+ //
1595
+ // The tick's motion, restated as `transform`: the
1596
+ // standalone `rotate`/`translate` properties resolve on
1597
+ // iOS but not on Android (measured, signalxjs/lynx#1084),
1598
+ // so the emitter refuses them. `transform` functions are
1599
+ // proven on both platforms — and the transition follows
1600
+ // the property that now moves.
1601
+ base: {
1602
+ backgroundColor: 'var(--checkbox-on-accent)',
1603
+ transform: 'rotate(45deg)',
1604
+ transition: 'clip-path var(--duration-slow) var(--ease-standard) var(--duration-instant), '
1605
+ + 'opacity var(--duration-instant) var(--ease-standard) var(--duration-instant), '
1606
+ + 'transform var(--duration-slow) var(--ease-standard) var(--duration-instant)',
1607
+ },
1608
+ // The order matches the standalone properties' fixed
1609
+ // apply order (translate, then rotate).
1610
+ states: { indeterminate: { transform: 'translateY(-35%) rotate(0deg)' } },
1514
1611
  },
1515
1612
  },
1516
1613
  },
@@ -1532,7 +1629,7 @@ export const radioGroup: RecipeInput = {
1532
1629
  },
1533
1630
  parts: {
1534
1631
  root: {
1535
- base: { display: 'flex', flexDirection: 'column', gap: '0.625rem' },
1632
+ base: { display: 'flex', flexDirection: 'column', gap: 'var(--space-md)' },
1536
1633
  states: { invalid: {}, required: {} },
1537
1634
  selectors: {
1538
1635
  // `invalid` is a fact about the GROUP — `item-control` carries
@@ -1546,7 +1643,7 @@ export const radioGroup: RecipeInput = {
1546
1643
  states: { disabled: { opacity: 'var(--disabled-opacity)' } },
1547
1644
  },
1548
1645
  item: {
1549
- base: { display: 'inline-flex', alignItems: 'center', gap: '0.625rem', cursor: 'pointer' },
1646
+ base: { display: 'inline-flex', alignItems: 'center', gap: 'var(--space-md)', cursor: 'pointer' },
1550
1647
  states: {
1551
1648
  disabled: { opacity: 'var(--disabled-opacity)', cursor: 'not-allowed' },
1552
1649
  checked: {}, unchecked: {},
@@ -1762,6 +1859,18 @@ export const progress: RecipeInput = {
1762
1859
  // other way.
1763
1860
  'zero-daisy-indeterminate': 'from { margin-inline-start: -40%; } to { margin-inline-start: 100%; }',
1764
1861
  },
1862
+ // The same sweep with a physical margin: logical spellings resolve on
1863
+ // iOS but not on Android (measured, signalxjs/lynx#1084), so the emitter
1864
+ // refuses the shared keyframes. Physical is the lynx target's norm — no
1865
+ // RTL flow there for the sweep to mirror with. Keyframes merge per name,
1866
+ // so this REPLACES the shared animation on lynx.
1867
+ targets: {
1868
+ lynx: {
1869
+ keyframes: {
1870
+ 'zero-daisy-indeterminate': 'from { margin-left: -40%; } to { margin-left: 100%; }',
1871
+ },
1872
+ },
1873
+ },
1765
1874
  };
1766
1875
 
1767
1876
  export const slider: RecipeInput = {
@@ -1887,6 +1996,22 @@ export const slider: RecipeInput = {
1887
1996
  targets: {
1888
1997
  lynx: {
1889
1998
  parts: {
1999
+ track: {
2000
+ // The web track's `margin-block` breathing room,
2001
+ // restated physically: logical spellings resolve on iOS
2002
+ // but not on Android (measured, signalxjs/lynx#1084), so
2003
+ // the emitter refuses them. Physical is the lynx
2004
+ // target's norm — no RTL flow there.
2005
+ base: {
2006
+ marginTop: 'calc(var(--size-selector) * 1.5)',
2007
+ marginBottom: 'calc(var(--size-selector) * 1.5)',
2008
+ },
2009
+ },
2010
+ mark: {
2011
+ // Same #1084 verdict: the tick labels hang below the
2012
+ // track off a physical padding.
2013
+ base: { paddingTop: 'calc(var(--size-selector) * 2 + var(--space-2xs))' },
2014
+ },
1890
2015
  range: {
1891
2016
  // The web fill deepens the accent 90/10 toward
1892
2017
  // base-content (`progressFill`'s recipe); the plain
@@ -1910,9 +2035,20 @@ export const slider: RecipeInput = {
1910
2035
  // thumb — a native-input trick with no lynx equivalent;
1911
2036
  // the composed `.zx-slider__range` box above IS that
1912
2037
  // fill here, so the knob only needs its own two paints.
2038
+ //
2039
+ // The centering geometry is restated physically beside
2040
+ // the paint: `inset-block-start`/`translate`/
2041
+ // `margin-inline-start` resolve on iOS but not on
2042
+ // Android (measured, signalxjs/lynx#1084 — the knob sat
2043
+ // off-center there), so the emitter refuses them.
2044
+ // `top` + `transform: translateY()` + `margin-left` are
2045
+ // proven on both platforms.
1913
2046
  base: {
1914
2047
  background: 'var(--color-base-100)',
1915
2048
  border: '0.25rem solid var(--slider-accent)',
2049
+ top: '50%',
2050
+ transform: 'translateY(-50%)',
2051
+ marginLeft: 'calc(var(--size-selector) * -2.5)',
1916
2052
  },
1917
2053
  },
1918
2054
  },
@@ -2097,7 +2233,7 @@ export const select: RecipeInput = {
2097
2233
  display: 'flex',
2098
2234
  alignItems: 'center',
2099
2235
  justifyContent: 'space-between',
2100
- gap: '0.625rem',
2236
+ gap: 'var(--space-md)',
2101
2237
  padding: 'var(--space-md) var(--space-lg)',
2102
2238
  fontSize: 'var(--text-sm)',
2103
2239
  fontWeight: 'var(--weight-medium)',
@@ -2133,6 +2269,17 @@ export const select: RecipeInput = {
2133
2269
  // Base equals the default (`tokens:` accent, `fieldControl`'s md height),
2134
2270
  // so the twins restate it; declared for the manifest (signalxjs/lynx#1070).
2135
2271
  defaultVariants: { color: 'primary', size: 'md' },
2272
+ // The btn paddings, restated physically — see `lynxBtnPad` (#1084).
2273
+ // The trigger ramp restates `md` too (unlike `btnSizes`), so its lynx
2274
+ // counterpart does as well.
2275
+ targets: {
2276
+ lynx: {
2277
+ parts: { trigger: { base: lynxBtnPad(4) } },
2278
+ variants: {
2279
+ size: { ...lynxBtnSizes('trigger'), md: { trigger: { base: lynxBtnPad(4) } } },
2280
+ },
2281
+ },
2282
+ },
2136
2283
  };
2137
2284
 
2138
2285
 
@@ -2155,6 +2302,15 @@ const btnHeight = (step: keyof typeof BTN_STEPS): string =>
2155
2302
 
2156
2303
  export const button: RecipeInput = {
2157
2304
  component: 'button',
2305
+ // Public to a design system derived from this one (#73).
2306
+ hooks: {
2307
+ properties: {
2308
+ '--btn-accent': 'The fill of the solid variant.',
2309
+ '--btn-on-accent': 'The ink on --btn-accent.',
2310
+ '--btn-soft': 'The soft fill (soft variant, unfilled hover).',
2311
+ '--btn-ink': 'The ink of the unfilled variants.',
2312
+ },
2313
+ },
2158
2314
  // The two axes meet here instead of multiplying. `color` sets the accent
2159
2315
  // pair; `variant` decides how the accent is used. 8 + 4 + 5 rules rather
2160
2316
  // than 8 × 4.
@@ -2165,9 +2321,34 @@ export const button: RecipeInput = {
2165
2321
  '--btn-ink': roleInk('primary'),
2166
2322
  },
2167
2323
  parts: {
2324
+ // The loading spinner (#50): a real part zero renders before the
2325
+ // label while `loading`, drawn as a ring in `currentColor` with one
2326
+ // transparent quadrant — it takes whatever ink the variant chose.
2327
+ // A literal duration and an explicit reduced-motion `none`: the kit
2328
+ // collapses `--duration-*` under reduced motion, and an infinite
2329
+ // loop at ~0s strobes rather than stops.
2330
+ // daisy marks a loading button with a `loading loading-spinner` span;
2331
+ // this is that span, zero's own now (it was a `loading` modifier's
2332
+ // `::before` until #50 made loading a Button state).
2333
+ spinner: {
2334
+ base: {
2335
+ boxSizing: 'border-box',
2336
+ inlineSize: '1em',
2337
+ blockSize: '1em',
2338
+ flex: 'none',
2339
+ borderRadius: '9999px',
2340
+ border: 'calc(var(--border) * 2) solid currentColor',
2341
+ borderBlockStartColor: 'transparent',
2342
+ animation: 'zero-daisyui-btn-spin 0.7s linear infinite',
2343
+ },
2344
+ at: { 'reduced-motion': { base: { animation: 'none' } } },
2345
+ },
2168
2346
  root: {
2169
2347
  base: {
2170
2348
  appearance: 'none',
2349
+ // An asChild `<a>` gets no UA underline (see the README's
2350
+ // link-button note for the unlayered `a { color }` case).
2351
+ textDecoration: 'none',
2171
2352
  display: 'inline-flex',
2172
2353
  alignItems: 'center',
2173
2354
  justifyContent: 'center',
@@ -2183,6 +2364,9 @@ export const button: RecipeInput = {
2183
2364
  + 'border-color var(--duration-fast) var(--ease-standard)',
2184
2365
  },
2185
2366
  states: {
2367
+ // Work in flight (`loading`, #50): still focusable, still legible —
2368
+ // the label is what the reader is waiting on — so no fade here.
2369
+ loading: { cursor: 'progress' },
2186
2370
  disabled: { opacity: 'var(--disabled-opacity)', cursor: 'not-allowed' },
2187
2371
  // The ring is drawn on the page, offset clear of the button —
2188
2372
  // ink, not fill, and the same 3:1 floor. On `--btn-accent` it
@@ -2328,40 +2512,20 @@ export const button: RecipeInput = {
2328
2512
  active: {
2329
2513
  root: { base: { transform: 'translateY(1px)', boxShadow: 'none', filter: 'brightness(0.92)' } },
2330
2514
  },
2331
- /**
2332
- * daisy's loading spinner, drawn by the recipe (#332): daisy marks
2333
- * the button with a `loading loading-spinner` span; zero changes no
2334
- * DOM, so the ring is a `::before` in `currentColor` with one
2335
- * transparent quadrant — it inherits whatever ink the variant chose,
2336
- * and the root's existing `gap` spaces it from the label. Literal
2337
- * duration + explicit reduced-motion `none`, like the spinner
2338
- * recipe: the kit collapses `--duration-*` under reduced motion, and
2339
- * an infinite loop at ~0s strobes rather than stops.
2340
- */
2341
- loading: {
2342
- root: {
2343
- base: { cursor: 'progress' },
2344
- selectors: {
2345
- '&::before': {
2346
- content: '""',
2347
- boxSizing: 'border-box',
2348
- inlineSize: '1em',
2349
- blockSize: '1em',
2350
- flex: 'none',
2351
- borderRadius: '9999px',
2352
- border: 'calc(var(--border) * 2) solid currentColor',
2353
- borderBlockStartColor: 'transparent',
2354
- animation: 'zero-daisyui-btn-spin 0.7s linear infinite',
2355
- },
2356
- },
2357
- at: {
2358
- 'reduced-motion': { selectors: { '&::before': { animation: 'none' } } },
2359
- },
2360
- },
2361
- },
2362
2515
  },
2363
2516
  defaultVariants: { color: 'primary', variant: 'solid', size: 'md' },
2364
2517
  keyframes: { 'zero-daisyui-btn-spin': 'to { transform: rotate(360deg) }' },
2518
+ // The icon chips' zeroed inline padding, restated physically: logical
2519
+ // spellings resolve on iOS but not on Android (measured,
2520
+ // signalxjs/lynx#1084), so the emitter refuses them.
2521
+ targets: {
2522
+ lynx: {
2523
+ modifiers: {
2524
+ square: { root: { base: { paddingLeft: '0', paddingRight: '0' } } },
2525
+ circle: { root: { base: { paddingLeft: '0', paddingRight: '0' } } },
2526
+ },
2527
+ },
2528
+ },
2365
2529
  };
2366
2530
 
2367
2531
  export const avatar: RecipeInput = {
@@ -2415,6 +2579,13 @@ export const avatar: RecipeInput = {
2415
2579
  },
2416
2580
  },
2417
2581
  variants: {
2582
+ // The shape axis (zero#129) — one declaration on the root, whose
2583
+ // `overflow: hidden` clips the image and the fallback alike. `rounded` is daisy's selector radius; the default is the circle.
2584
+ shape: {
2585
+ circle: { root: { base: { borderRadius: '9999px' } } },
2586
+ square: { root: { base: { borderRadius: '0' } } },
2587
+ rounded: { root: { base: { borderRadius: 'var(--radius-selector)' } } },
2588
+ },
2418
2589
  // daisy colours the RING, not the fallback — that is what its avatar
2419
2590
  // does, and the fallback stays neutral so initials read the same
2420
2591
  // whichever role is chosen.
@@ -2589,6 +2760,24 @@ export const toast: RecipeInput = {
2589
2760
  // signalxjs/lynx#1070. No `color` default: the un-attributed toast is a
2590
2761
  // neutral base-200 surface, outside the color vocabulary.
2591
2762
  defaultVariants: { size: 'md' },
2763
+ // The corner placements' physical anchors: logical inset spellings
2764
+ // resolve on iOS but not on Android (measured, signalxjs/lynx#1084), so
2765
+ // the emitter refuses them. Physical is the lynx target's norm — no RTL
2766
+ // flow there, so `start` IS left and `end` IS right.
2767
+ targets: {
2768
+ lynx: {
2769
+ parts: {
2770
+ viewport: {
2771
+ selectors: {
2772
+ '&[data-placement="top-start"]': { left: '0' },
2773
+ '&[data-placement="top-end"]': { right: '0' },
2774
+ '&[data-placement="bottom-start"]': { left: '0' },
2775
+ '&[data-placement="bottom-end"]': { right: '0' },
2776
+ },
2777
+ },
2778
+ },
2779
+ },
2780
+ },
2592
2781
  };
2593
2782
 
2594
2783
  export const combobox: RecipeInput = {
@@ -2608,6 +2797,7 @@ export const combobox: RecipeInput = {
2608
2797
  base: {
2609
2798
  display: 'inline-flex',
2610
2799
  alignItems: 'stretch',
2800
+ flexWrap: 'wrap',
2611
2801
  minWidth: '13rem',
2612
2802
  ...fieldControl,
2613
2803
  transition: 'border-color var(--duration-fast) var(--ease-standard)',
@@ -2653,6 +2843,52 @@ export const combobox: RecipeInput = {
2653
2843
  '&::placeholder': { color: 'color-mix(in oklab, var(--color-base-content) 60%, transparent)' },
2654
2844
  },
2655
2845
  },
2846
+ // A chosen value under `multiple` (#39): a chip in the control, before
2847
+ // the input. Symmetric metrics only — the physical-direction lint and
2848
+ // the lynx emitter both refuse a one-sided inset.
2849
+ // daisy's badge: a base-200 pill, centred in the stretched row.
2850
+ tag: {
2851
+ base: {
2852
+ display: 'inline-flex',
2853
+ alignItems: 'center',
2854
+ alignSelf: 'center',
2855
+ gap: 'var(--space-2xs)',
2856
+ margin: 'var(--space-2xs)',
2857
+ padding: 'var(--space-2xs) var(--space-sm)',
2858
+ background: 'var(--color-base-200)',
2859
+ color: 'var(--color-base-content)',
2860
+ borderRadius: 'var(--radius-selector)',
2861
+ fontSize: 'var(--text-xs)',
2862
+ fontWeight: 'var(--weight-medium)',
2863
+ lineHeight: 'var(--leading-none)',
2864
+ },
2865
+ states: { disabled: {} },
2866
+ },
2867
+ 'tag-label': { base: { whiteSpace: 'nowrap' } },
2868
+ 'tag-remove': {
2869
+ base: {
2870
+ appearance: 'none',
2871
+ border: 'none',
2872
+ background: 'transparent',
2873
+ color: 'inherit',
2874
+ font: 'inherit',
2875
+ borderRadius: 'var(--radius-selector)',
2876
+ padding: '0 var(--space-2xs)',
2877
+ lineHeight: 'var(--leading-none)',
2878
+ cursor: 'pointer',
2879
+ transition: 'background var(--duration-fast) var(--ease-standard)',
2880
+ },
2881
+ states: {
2882
+ hover: { background: 'color-mix(in oklab, var(--color-base-content) 10%, transparent)' },
2883
+ disabled: { opacity: 'var(--disabled-opacity)', cursor: 'not-allowed' },
2884
+ ...focusRing,
2885
+ },
2886
+ selectors: {
2887
+ '&[data-pressed]:not([data-disabled])': {
2888
+ background: 'color-mix(in oklab, var(--color-base-content) 18%, transparent)',
2889
+ },
2890
+ },
2891
+ },
2656
2892
  trigger: {
2657
2893
  base: {
2658
2894
  appearance: 'none',
@@ -2689,7 +2925,7 @@ export const combobox: RecipeInput = {
2689
2925
  display: 'flex',
2690
2926
  alignItems: 'center',
2691
2927
  justifyContent: 'space-between',
2692
- gap: '0.625rem',
2928
+ gap: 'var(--space-md)',
2693
2929
  padding: 'var(--space-md) var(--space-lg)',
2694
2930
  fontSize: 'var(--text-sm)',
2695
2931
  fontWeight: 'var(--weight-medium)',
@@ -2721,15 +2957,15 @@ export const combobox: RecipeInput = {
2721
2957
  '--combobox-accent': `var(--color-${c})`,
2722
2958
  } } }])),
2723
2959
  // The control carries the height — the shared field ramp, the same one
2724
- // `select`'s trigger steps on — and the input carries the inset and the
2725
- // type. `md` restates exactly the resting values so the union stays
2960
+ // `select`'s trigger steps on, as a floor so wrapped tags can grow it
2961
+ // — and the input carries the inset and the type. `md` restates exactly the resting values so the union stays
2726
2962
  // complete.
2727
2963
  size: {
2728
- xs: { control: { base: { height: fieldHeight('xs') } }, input: { base: { padding: 'var(--space-2xs) var(--space-md)', fontSize: 'var(--text-xs)' } } },
2729
- sm: { control: { base: { height: fieldHeight('sm') } }, input: { base: { padding: 'var(--space-xs) var(--space-md)', fontSize: 'var(--text-sm)' } } },
2730
- md: { control: { base: { height: fieldHeight('md') } }, input: { base: { padding: 'var(--space-sm) var(--space-lg)', fontSize: 'var(--text-sm)' } } },
2731
- lg: { control: { base: { height: fieldHeight('lg') } }, input: { base: { padding: 'var(--space-md) var(--space-xl)', fontSize: 'var(--text-md)' } } },
2732
- xl: { control: { base: { height: fieldHeight('xl') } }, input: { base: { padding: 'var(--space-lg) var(--space-2xl)', fontSize: 'var(--text-lg)' } } },
2964
+ xs: { control: { base: { minHeight: fieldHeight('xs') } }, input: { base: { padding: 'var(--space-2xs) var(--space-md)', fontSize: 'var(--text-xs)' } } },
2965
+ sm: { control: { base: { minHeight: fieldHeight('sm') } }, input: { base: { padding: 'var(--space-xs) var(--space-md)', fontSize: 'var(--text-sm)' } } },
2966
+ md: { control: { base: { minHeight: fieldHeight('md') } }, input: { base: { padding: 'var(--space-sm) var(--space-lg)', fontSize: 'var(--text-sm)' } } },
2967
+ lg: { control: { base: { minHeight: fieldHeight('lg') } }, input: { base: { padding: 'var(--space-md) var(--space-xl)', fontSize: 'var(--text-md)' } } },
2968
+ xl: { control: { base: { minHeight: fieldHeight('xl') } }, input: { base: { padding: 'var(--space-lg) var(--space-2xl)', fontSize: 'var(--text-lg)' } } },
2733
2969
  },
2734
2970
  },
2735
2971
  // The visible ring lives on `control`; input and trigger delegate.
@@ -2829,6 +3065,13 @@ export const toggle: RecipeInput = {
2829
3065
  // the on item filled with the accent.
2830
3066
  export const toggleGroup: RecipeInput = {
2831
3067
  component: 'toggle-group',
3068
+ // Public to a design system derived from this one (#73).
3069
+ hooks: {
3070
+ properties: {
3071
+ '--toggle-group-accent': 'The fill of an on item.',
3072
+ '--toggle-group-on-accent': 'The ink on --toggle-group-accent.',
3073
+ },
3074
+ },
2832
3075
  tokens: {
2833
3076
  '--toggle-group-accent': 'var(--color-primary)',
2834
3077
  '--toggle-group-on-accent': 'var(--color-primary-content)',
@@ -3071,6 +3314,17 @@ export const numberInput: RecipeInput = {
3071
3314
  xl: { control: { base: { height: fieldHeight('xl') } }, input: { base: { fontSize: 'var(--text-lg)', padding: 'var(--space-lg) var(--space-xl)' } } },
3072
3315
  },
3073
3316
  },
3317
+ // The steppers' inline padding, restated physically: logical spellings
3318
+ // resolve on iOS but not on Android (measured, signalxjs/lynx#1084), so
3319
+ // the emitter refuses them.
3320
+ targets: {
3321
+ lynx: {
3322
+ parts: {
3323
+ 'increment-trigger': { base: { paddingLeft: 'var(--space-lg)', paddingRight: 'var(--space-lg)' } },
3324
+ 'decrement-trigger': { base: { paddingLeft: 'var(--space-lg)', paddingRight: 'var(--space-lg)' } },
3325
+ },
3326
+ },
3327
+ },
3074
3328
  };
3075
3329
 
3076
3330
  /**
@@ -3254,7 +3508,7 @@ export const ratingGroup: RecipeInput = {
3254
3508
  const treeRow: NonNullable<PartStyles['base']> = {
3255
3509
  display: 'flex',
3256
3510
  alignItems: 'center',
3257
- gap: '0.625rem',
3511
+ gap: 'var(--space-md)',
3258
3512
  padding: 'var(--space-sm) var(--space-md)',
3259
3513
  fontSize: 'var(--tree-text)',
3260
3514
  fontWeight: 'var(--weight-medium)',
@@ -3368,6 +3622,16 @@ export const treeView: RecipeInput = {
3368
3622
  xl: { root: { base: { '--tree-text': 'var(--text-lg)' } } },
3369
3623
  },
3370
3624
  },
3625
+ // The indent, restated physically: logical spellings resolve on iOS but
3626
+ // not on Android (measured, signalxjs/lynx#1084), so the emitter refuses
3627
+ // them. Physical is the lynx target's norm — no RTL flow there.
3628
+ targets: {
3629
+ lynx: {
3630
+ parts: {
3631
+ 'branch-content': { base: { paddingLeft: 'var(--space-lg)' } },
3632
+ },
3633
+ },
3634
+ },
3371
3635
  };
3372
3636
 
3373
3637
  /**
@@ -3523,75 +3787,6 @@ export const textarea: RecipeInput = {
3523
3787
  },
3524
3788
  };
3525
3789
 
3526
- /**
3527
- * NativeSelect (#333) — daisy "select" flavor on the platform's own picker:
3528
- * the `fieldControl` box (same metrics as every field, so mixed rows line
3529
- * up), `appearance: none`, and the recipe-drawn chevron at the inline end.
3530
- * No open state — the platform owns the popup — so colour surfaces on the
3531
- * focus ring, exactly like input's.
3532
- */
3533
- export const nativeSelect: RecipeInput = {
3534
- component: 'native-select',
3535
- tokens: { '--native-select-accent': 'var(--color-base-content)' },
3536
- parts: {
3537
- root: {
3538
- base: { position: 'relative', display: 'inline-flex', alignItems: 'center' },
3539
- states: { disabled: {}, invalid: {}, required: {}, placeholder: {} },
3540
- },
3541
- control: {
3542
- base: {
3543
- appearance: 'none',
3544
- width: '100%',
3545
- minWidth: '12rem',
3546
- ...fieldControl,
3547
- color: 'var(--color-base-content)',
3548
- font: 'inherit',
3549
- fontSize: 'var(--text-sm)',
3550
- padding: 'var(--space-sm) var(--space-md)',
3551
- // Room for the chevron the platform no longer draws.
3552
- paddingInlineEnd: 'calc(var(--space-md) + 1.25em)',
3553
- cursor: 'pointer',
3554
- transition: 'border-color var(--duration-fast) var(--ease-standard)',
3555
- },
3556
- states: {
3557
- hover: { borderColor: 'var(--color-base-content)' },
3558
- invalid: { borderColor: 'var(--color-error)' },
3559
- disabled: { opacity: 'var(--disabled-opacity)', cursor: 'not-allowed' },
3560
- required: {},
3561
- // Same muting as the fields' placeholders (#264).
3562
- placeholder: { color: 'color-mix(in oklab, var(--color-base-content) 60%, transparent)' },
3563
- 'focus-visible': { ...focusRing['focus-visible'], outline: '2px solid var(--native-select-accent)' },
3564
- },
3565
- },
3566
- indicator: {
3567
- base: {
3568
- position: 'absolute',
3569
- insetInlineEnd: 'var(--space-md)',
3570
- pointerEvents: 'none',
3571
- opacity: '0.6',
3572
- fontSize: 'var(--text-sm)',
3573
- },
3574
- },
3575
- },
3576
- // The visible ring lives on the <select> itself; the wrapper delegates.
3577
- skipStates: { root: ['focus-visible'] },
3578
- variants: {
3579
- // The ring carries the role — daisy's field chrome is neutral, so
3580
- // focus is the only place colour surfaces (input's exact move).
3581
- color: Object.fromEntries(ROLES.map((c) => [c, { root: { base: {
3582
- '--native-select-accent': `var(--color-${c})`,
3583
- } } }])),
3584
- // The shared field-height ramp, on the control itself.
3585
- size: {
3586
- xs: { control: { base: { height: fieldHeight('xs'), fontSize: 'var(--text-xs)', padding: 'var(--space-2xs) var(--space-xs)', paddingInlineEnd: 'calc(var(--space-xs) + 1.25em)' } } },
3587
- sm: { control: { base: { height: fieldHeight('sm'), fontSize: 'var(--text-xs)', padding: 'var(--space-xs) var(--space-sm)', paddingInlineEnd: 'calc(var(--space-sm) + 1.25em)' } } },
3588
- md: {},
3589
- lg: { control: { base: { height: fieldHeight('lg'), fontSize: 'var(--text-md)', padding: 'var(--space-md) var(--space-lg)', paddingInlineEnd: 'calc(var(--space-lg) + 1.25em)' } } },
3590
- xl: { control: { base: { height: fieldHeight('xl'), fontSize: 'var(--text-lg)', padding: 'var(--space-lg) var(--space-xl)', paddingInlineEnd: 'calc(var(--space-xl) + 1.25em)' } } },
3591
- },
3592
- },
3593
- };
3594
-
3595
3790
  // ── Content tier (#311) ───────────────────────────────────────────────────
3596
3791
  /**
3597
3792
  * daisy "card" flavor: `bg-base-100` on a `rounded-box` with a real shadow —
@@ -3600,6 +3795,12 @@ export const nativeSelect: RecipeInput = {
3600
3795
  */
3601
3796
  export const card: RecipeInput = {
3602
3797
  component: 'card',
3798
+ // Public to a design system derived from this one (#73).
3799
+ hooks: {
3800
+ properties: {
3801
+ '--card-pad': 'The card padding.',
3802
+ },
3803
+ },
3603
3804
  tokens: { '--card-pad': 'var(--space-xl)' },
3604
3805
  parts: {
3605
3806
  root: {
@@ -3756,6 +3957,88 @@ export const alert: RecipeInput = {
3756
3957
  },
3757
3958
  };
3758
3959
 
3960
+ /**
3961
+ * EmptyState (zero#131) — daisy has no empty-state component; this is the
3962
+ * `hero`-content idiom at card scale: a centred column on the base-200
3963
+ * surface with the box radius, the icon in the role's ink and the surface
3964
+ * in its soft tint when a tone is given.
3965
+ */
3966
+ export const emptyState: RecipeInput = {
3967
+ component: 'empty-state',
3968
+ hooks: {
3969
+ properties: {
3970
+ '--empty-accent': 'The icon ink.',
3971
+ '--empty-tint': 'The surface fill.',
3972
+ },
3973
+ },
3974
+ tokens: {
3975
+ '--empty-accent': 'color-mix(in oklab, var(--color-base-content) 60%, transparent)',
3976
+ '--empty-tint': 'var(--color-base-200)',
3977
+ },
3978
+ parts: {
3979
+ root: {
3980
+ base: {
3981
+ display: 'flex',
3982
+ flexDirection: 'column',
3983
+ alignItems: 'center',
3984
+ textAlign: 'center',
3985
+ gap: 'var(--space-sm)',
3986
+ padding: 'var(--space-2xl) var(--space-xl)',
3987
+ background: 'var(--empty-tint)',
3988
+ color: 'var(--color-base-content)',
3989
+ borderRadius: 'var(--radius-box)',
3990
+ },
3991
+ },
3992
+ icon: {
3993
+ base: {
3994
+ display: 'inline-flex',
3995
+ color: 'var(--empty-accent)',
3996
+ fontSize: 'var(--text-2xl)',
3997
+ lineHeight: 'var(--leading-none)',
3998
+ marginBlockEnd: 'var(--space-xs)',
3999
+ },
4000
+ },
4001
+ title: {
4002
+ base: {
4003
+ margin: '0',
4004
+ fontSize: 'var(--text-lg)',
4005
+ fontWeight: 'var(--weight-bold)',
4006
+ lineHeight: 'var(--leading-tight)',
4007
+ },
4008
+ },
4009
+ description: {
4010
+ base: {
4011
+ maxInlineSize: '36ch',
4012
+ fontSize: 'var(--text-sm)',
4013
+ lineHeight: 'var(--leading-normal)',
4014
+ color: 'color-mix(in oklab, var(--color-base-content) 70%, transparent)',
4015
+ },
4016
+ },
4017
+ actions: {
4018
+ base: {
4019
+ display: 'flex',
4020
+ flexWrap: 'wrap',
4021
+ justifyContent: 'center',
4022
+ gap: 'var(--space-sm)',
4023
+ marginBlockStart: 'var(--space-sm)',
4024
+ },
4025
+ },
4026
+ },
4027
+ variants: {
4028
+ color: Object.fromEntries(ROLES.map((c) => [c, { root: { base: {
4029
+ '--empty-accent': roleInk(c),
4030
+ '--empty-tint': `var(--color-${c}-soft)`,
4031
+ } } }])),
4032
+ size: {
4033
+ xs: { root: { base: { padding: 'var(--space-md)', gap: 'var(--space-2xs)' } }, icon: { base: { fontSize: 'var(--text-xl)' } }, title: { base: { fontSize: 'var(--text-sm)' } }, description: { base: { fontSize: 'var(--text-xs)' } } },
4034
+ sm: { root: { base: { padding: 'var(--space-lg)', gap: 'var(--space-xs)' } }, icon: { base: { fontSize: 'var(--text-2xl)' } }, title: { base: { fontSize: 'var(--text-md)' } }, description: { base: { fontSize: 'var(--text-xs)' } } },
4035
+ md: {},
4036
+ lg: { root: { base: { padding: 'calc(var(--space-2xl) * 1.5) var(--space-2xl)', gap: 'var(--space-md)' } }, icon: { base: { fontSize: 'var(--text-2xl)' } }, title: { base: { fontSize: 'var(--text-xl)' } }, description: { base: { fontSize: 'var(--text-md)' } } },
4037
+ xl: { root: { base: { padding: 'calc(var(--space-2xl) * 2) var(--space-2xl)', gap: 'var(--space-lg)' } }, icon: { base: { fontSize: 'var(--text-2xl)' } }, title: { base: { fontSize: 'var(--text-2xl)' } }, description: { base: { fontSize: 'var(--text-md)' } } },
4038
+ },
4039
+ },
4040
+ };
4041
+
3759
4042
  /**
3760
4043
  * daisy "badge" flavor: a fully-rounded pill with a hairline, filled by the
3761
4044
  * role. No `variant` — the repo-wide decision (#175) holds everywhere except
@@ -3764,7 +4047,18 @@ export const alert: RecipeInput = {
3764
4047
  */
3765
4048
  export const badge: RecipeInput = {
3766
4049
  component: 'badge',
4050
+ // Public to a design system derived from this one (#73).
4051
+ hooks: {
4052
+ properties: {
4053
+ '--badge-dot': 'The status dot\'s fill (zero#130); the pill\'s ink at rest.',
4054
+ '--badge-dot-ring': 'The ring around a coloured status dot.',
4055
+ '--badge-fill': 'The badge fill.',
4056
+ '--badge-ink': 'The badge ink.',
4057
+ },
4058
+ },
3767
4059
  tokens: {
4060
+ '--badge-dot': 'currentColor',
4061
+ '--badge-dot-ring': 'currentColor',
3768
4062
  '--badge-fill': 'var(--color-base-200)',
3769
4063
  '--badge-ink': 'var(--color-base-content)',
3770
4064
  },
@@ -3780,7 +4074,7 @@ export const badge: RecipeInput = {
3780
4074
  // daisy badges are pills; a large radius rather than 9999px so
3781
4075
  // a square-cornered theme can still say so through the token.
3782
4076
  borderRadius: 'var(--radius-selector)',
3783
- padding: '0.125rem 0.625rem',
4077
+ padding: 'var(--space-2xs) var(--space-lg)',
3784
4078
  fontSize: 'var(--text-xs)',
3785
4079
  fontWeight: 'var(--weight-semibold)',
3786
4080
  lineHeight: 'var(--leading-normal)',
@@ -3788,12 +4082,53 @@ export const badge: RecipeInput = {
3788
4082
  textDecoration: 'none',
3789
4083
  },
3790
4084
  },
4085
+ // The status dot (zero#130). On an uncoloured pill it is the pill's
4086
+ // INK — a dot in `currentColor` is legible on whatever fill the pill
4087
+ // has. Coloured — by its own `data-color`, or by the pill's, since the
4088
+ // nearest carrier wins (#94) — it is the role's fill inside a ring in
4089
+ // the role's `-content` ink, the timeline marker's answer: a dot that
4090
+ // follows a solid pill of the same role still has an edge. `running` is a
4091
+ // static halo the pulse breathes; under reduced motion the halo
4092
+ // stays and the breathing stops, so the state never vanishes.
4093
+ dot: {
4094
+ base: {
4095
+ display: 'inline-block',
4096
+ flex: 'none',
4097
+ inlineSize: '0.5em',
4098
+ blockSize: '0.5em',
4099
+ boxSizing: 'border-box',
4100
+ borderRadius: '50%',
4101
+ background: 'var(--badge-dot)',
4102
+ border: '0.1em solid var(--badge-dot-ring)',
4103
+ },
4104
+ states: {
4105
+ running: {
4106
+ boxShadow: '0 0 0 0.2em color-mix(in oklch, var(--badge-dot) 35%, transparent)',
4107
+ animation: 'zero-daisy-badge-pulse 1.6s ease-out infinite',
4108
+ },
4109
+ },
4110
+ at: {
4111
+ 'reduced-motion': { states: { running: { animation: 'none' } } },
4112
+ // Forced colours strip the fill and keep the border, so the
4113
+ // border draws the whole dot there.
4114
+ 'forced-colors': { base: { borderWidth: '0.25em' } },
4115
+ },
4116
+ },
3791
4117
  },
3792
4118
  variants: {
3793
- color: Object.fromEntries(ROLES.map((c) => [c, { root: { base: {
3794
- '--badge-fill': `var(--color-${c})`,
3795
- '--badge-ink': `var(--color-${c}-content)`,
3796
- } } }])),
4119
+ color: Object.fromEntries(ROLES.map((c) => [c, {
4120
+ root: { base: {
4121
+ '--badge-fill': `var(--color-${c})`,
4122
+ '--badge-ink': `var(--color-${c}-content)`,
4123
+ } },
4124
+ // The dot's colour (zero#130): keyed under `variants.color` so the
4125
+ // compiler anchors it on the pill's `data-color` AND on the dot's
4126
+ // own re-carried one — the nearest carrier wins (#94).
4127
+ dot: { base: {
4128
+ '--badge-dot': `var(--color-${c})`,
4129
+ '--badge-dot-ring': `var(--color-${c}-content)`,
4130
+ } },
4131
+ }])),
3797
4132
  size: {
3798
4133
  xs: { root: { base: { fontSize: 'var(--text-xs)', padding: '0 var(--space-sm)' } } },
3799
4134
  sm: { root: { base: { fontSize: 'var(--text-xs)', padding: '0 var(--space-md)' } } },
@@ -3801,6 +4136,8 @@ export const badge: RecipeInput = {
3801
4136
  lg: { root: { base: { fontSize: 'var(--text-sm)', padding: 'var(--space-2xs) var(--space-lg)' } } },
3802
4137
  xl: { root: { base: { fontSize: 'var(--text-md)', padding: 'var(--space-xs) var(--space-xl)' } } },
3803
4138
  },
4139
+ }, keyframes: {
4140
+ 'zero-daisy-badge-pulse': 'from { box-shadow: 0 0 0 0 color-mix(in oklch, var(--badge-dot) 45%, transparent); } to { box-shadow: 0 0 0 0.5em transparent; }',
3804
4141
  },
3805
4142
  };
3806
4143
 
@@ -3846,6 +4183,12 @@ export const divider: RecipeInput = {
3846
4183
  */
3847
4184
  export const skeleton: RecipeInput = {
3848
4185
  component: 'skeleton',
4186
+ // Public to a design system derived from this one (#73).
4187
+ hooks: {
4188
+ properties: {
4189
+ '--skeleton-fill': 'The placeholder fill.',
4190
+ },
4191
+ },
3849
4192
  tokens: { '--skeleton-fill': 'var(--color-base-300)' },
3850
4193
  parts: {
3851
4194
  root: {
@@ -3935,7 +4278,11 @@ export const kbd: RecipeInput = {
3935
4278
  alignItems: 'center',
3936
4279
  justifyContent: 'center',
3937
4280
  minInlineSize: '1.75em',
3938
- padding: '0.0625rem 0.4375rem',
4281
+ // daisy's .0625rem/.4375rem, restated as the ramp steps they
4282
+ // already are once the cap's own border is absorbed: the OUTER
4283
+ // box lands on 2xs/md, so the cap rides the ramp under a density
4284
+ // override while keeping daisy's painted size exactly at 1px.
4285
+ padding: 'calc(var(--space-2xs) - var(--border)) calc(var(--space-md) - var(--border))',
3939
4286
  background: 'var(--kbd-fill)',
3940
4287
  color: 'var(--kbd-ink)',
3941
4288
  border: 'var(--border) solid var(--color-base-300)',
@@ -4063,6 +4410,30 @@ export const indicator: RecipeInput = {
4063
4410
  xl: { item: { base: { fontSize: 'var(--text-lg)' } } },
4064
4411
  },
4065
4412
  },
4413
+ // The corner slots, restated physically: logical inset spellings and the
4414
+ // standalone `translate` property resolve on iOS but not on Android
4415
+ // (measured, signalxjs/lynx#1084), so the emitter refuses them. Physical
4416
+ // anchors plus `transform: translate()` are proven on both platforms,
4417
+ // and physical is the lynx target's norm — no RTL flow there, so the
4418
+ // hand-flipped RTL rules above have nothing to engage with either.
4419
+ targets: {
4420
+ lynx: {
4421
+ parts: {
4422
+ item: {
4423
+ selectors: {
4424
+ '&[data-placement="top-start"]': { top: '0', left: '0', transform: 'translate(-50%, -50%)' },
4425
+ '&[data-placement="top"]': { top: '0', left: '50%', transform: 'translate(-50%, -50%)' },
4426
+ '&[data-placement="top-end"]': { top: '0', right: '0', transform: 'translate(50%, -50%)' },
4427
+ '&[data-placement="start"]': { top: '50%', left: '0', transform: 'translate(-50%, -50%)' },
4428
+ '&[data-placement="end"]': { top: '50%', right: '0', transform: 'translate(50%, -50%)' },
4429
+ '&[data-placement="bottom-start"]': { bottom: '0', left: '0', transform: 'translate(-50%, 50%)' },
4430
+ '&[data-placement="bottom"]': { bottom: '0', left: '50%', transform: 'translate(-50%, 50%)' },
4431
+ '&[data-placement="bottom-end"]': { bottom: '0', right: '0', transform: 'translate(50%, 50%)' },
4432
+ },
4433
+ },
4434
+ },
4435
+ },
4436
+ },
4066
4437
  };
4067
4438
 
4068
4439
  /** daisy stats: the rounded base-100 panel with rule-thin item joins. */
@@ -4144,10 +4515,32 @@ export const stats: RecipeInput = {
4144
4515
  },
4145
4516
  };
4146
4517
 
4518
+ /**
4519
+ * A timeline none of whose content sits on the start side: the root carries
4520
+ * no start-placed content among its own items. Child combinators all the way
4521
+ * down, so a timeline nested inside another's content answers only for
4522
+ * itself.
4523
+ */
4524
+ const TIMELINE_NO_START = '[data-scope="timeline"][data-part="root"]'
4525
+ + ':not(:has(> [data-scope="timeline"][data-part="item"] > [data-scope="timeline"][data-part="content"][data-placement="start"])) > &';
4526
+
4147
4527
  /** daisy timeline: the boxed content beside a dotted axis. */
4148
4528
  export const timeline: RecipeInput = {
4149
4529
  component: 'timeline',
4150
- tokens: { '--timeline-accent': 'var(--color-base-content)', '--timeline-marker-size': 'calc(var(--size-selector) * 3)' },
4530
+ // Public to a design system derived from this one (#73).
4531
+ hooks: {
4532
+ properties: {
4533
+ '--timeline-accent': 'The marker and connector accent.',
4534
+ '--timeline-ring': 'The ring around a coloured marker — the role\'s -content ink (#94).',
4535
+ '--timeline-marker-size': 'The marker diameter.',
4536
+ },
4537
+ },
4538
+ tokens: {
4539
+ '--timeline-accent': 'var(--color-base-content)',
4540
+ // The coloured dot's ring (#94); uncoloured, it IS the dot.
4541
+ '--timeline-ring': 'var(--timeline-accent)',
4542
+ '--timeline-marker-size': 'calc(var(--size-selector) * 3)',
4543
+ },
4151
4544
  parts: {
4152
4545
  root: {
4153
4546
  base: {
@@ -4183,6 +4576,11 @@ export const timeline: RecipeInput = {
4183
4576
  gridTemplateColumns: 'auto 1fr',
4184
4577
  flex: '1 1 0%',
4185
4578
  },
4579
+ // No content on the start side anywhere in this timeline →
4580
+ // the start track collapses instead of holding half the item
4581
+ // empty (#57). Automatic, so there is no prop to forget.
4582
+ [`${TIMELINE_NO_START}[data-orientation="vertical"]`]: { gridTemplateColumns: '0 auto 1fr' },
4583
+ [`${TIMELINE_NO_START}[data-orientation="horizontal"]`]: { gridTemplateRows: '0 auto 1fr' },
4186
4584
  },
4187
4585
  },
4188
4586
  marker: {
@@ -4192,12 +4590,24 @@ export const timeline: RecipeInput = {
4192
4590
  boxSizing: 'border-box',
4193
4591
  borderRadius: '9999px',
4194
4592
  background: 'var(--timeline-accent)',
4195
- border: 'calc(var(--timeline-marker-size) / 2) solid var(--timeline-accent)',
4593
+ // A coloured dot is the role fill inside a RING in the role's
4594
+ // `-content` ink (#94): a light role on a light page, or a dark
4595
+ // one on a dark page, has no edge of its own, and a per-entry
4596
+ // tone is exactly the mark a reader has to find. One half of
4597
+ // the role's own pair clears 3:1 against the page (the contrast
4598
+ // audit measures every colour). With no colour the ring is the
4599
+ // fill — the same solid dot as before.
4600
+ border: 'calc(var(--timeline-marker-size) / 6) solid var(--timeline-ring)',
4196
4601
  display: 'flex',
4197
4602
  alignItems: 'center',
4198
4603
  justifyContent: 'center',
4199
4604
  fontSize: '0',
4200
4605
  },
4606
+ at: {
4607
+ // Forced colours strip the fill and keep the border, so the
4608
+ // border draws the whole dot there, as it always did.
4609
+ 'forced-colors': { base: { borderWidth: 'calc(var(--timeline-marker-size) / 2)' } },
4610
+ },
4201
4611
  selectors: {
4202
4612
  // The axis cell, both orientations. `place-self` centres the
4203
4613
  // dot on the line in the cross axis.
@@ -4275,6 +4685,7 @@ export const timeline: RecipeInput = {
4275
4685
  variants: {
4276
4686
  color: Object.fromEntries(ROLES.map((c) => [c, { marker: { base: {
4277
4687
  '--timeline-accent': `var(--color-${c})`,
4688
+ '--timeline-ring': `var(--color-${c}-content)`,
4278
4689
  } } }])),
4279
4690
  size: {
4280
4691
  xs: { marker: { base: { '--timeline-marker-size': 'calc(var(--size-selector) * 2)' } }, content: { base: { fontSize: 'var(--text-xs)' } } },
@@ -4383,6 +4794,16 @@ export const chat: RecipeInput = {
4383
4794
  xl: { bubble: { base: { fontSize: 'var(--text-lg)', padding: 'var(--space-md) var(--space-xl)' } } },
4384
4795
  },
4385
4796
  },
4797
+ // The row's vertical padding, restated physically: logical spellings
4798
+ // resolve on iOS but not on Android (measured, signalxjs/lynx#1084), so
4799
+ // the emitter refuses them.
4800
+ targets: {
4801
+ lynx: {
4802
+ parts: {
4803
+ root: { base: { paddingTop: 'var(--space-2xs)', paddingBottom: 'var(--space-2xs)' } },
4804
+ },
4805
+ },
4806
+ },
4386
4807
  };
4387
4808
 
4388
4809
  /** daisy radial-progress: --value became --progress-percent; same ring. */
@@ -4636,6 +5057,112 @@ export const navbar: RecipeInput = {
4636
5057
  },
4637
5058
  };
4638
5059
 
5060
+ /**
5061
+ * NavList (zero#132) — daisy's `menu` idiom for a sidebar: rounded rows, a
5062
+ * base-200 wash on hover, and the current page as the role's soft fill in
5063
+ * the role's ink. `meta` is where daisy's menu puts its badge.
5064
+ */
5065
+ export const navList: RecipeInput = {
5066
+ component: 'nav-list',
5067
+ hooks: {
5068
+ properties: {
5069
+ '--nav-accent': 'The current link\'s ink.',
5070
+ '--nav-tint': 'The current link\'s fill.',
5071
+ '--nav-ink': 'The resting link ink.',
5072
+ },
5073
+ },
5074
+ tokens: {
5075
+ '--nav-accent': 'var(--color-base-content)',
5076
+ '--nav-tint': 'var(--color-base-300)',
5077
+ '--nav-ink': 'var(--color-base-content)',
5078
+ },
5079
+ parts: {
5080
+ root: {
5081
+ base: {
5082
+ display: 'flex',
5083
+ flexDirection: 'column',
5084
+ gap: 'var(--space-lg)',
5085
+ fontSize: 'var(--text-sm)',
5086
+ color: 'var(--color-base-content)',
5087
+ },
5088
+ },
5089
+ group: {
5090
+ base: { display: 'flex', flexDirection: 'column', gap: 'var(--space-xs)' },
5091
+ },
5092
+ heading: {
5093
+ base: {
5094
+ padding: '0 var(--space-lg)',
5095
+ fontSize: 'var(--text-xs)',
5096
+ fontWeight: 'var(--weight-bold)',
5097
+ opacity: '0.6',
5098
+ },
5099
+ },
5100
+ list: {
5101
+ base: {
5102
+ display: 'flex',
5103
+ flexDirection: 'column',
5104
+ gap: 'var(--space-2xs)',
5105
+ listStyle: 'none',
5106
+ margin: '0',
5107
+ padding: '0',
5108
+ },
5109
+ },
5110
+ item: { base: { display: 'block' } },
5111
+ link: {
5112
+ base: {
5113
+ display: 'flex',
5114
+ alignItems: 'center',
5115
+ gap: 'var(--space-sm)',
5116
+ textDecoration: 'none',
5117
+ color: 'var(--nav-ink)',
5118
+ padding: 'var(--space-sm) var(--space-lg)',
5119
+ borderRadius: 'var(--radius-field)',
5120
+ fontWeight: 'var(--weight-medium)',
5121
+ lineHeight: 'var(--leading-tight)',
5122
+ transition: 'background var(--duration-fast) var(--ease-standard)',
5123
+ },
5124
+ states: {
5125
+ hover: { background: 'var(--color-base-200)' },
5126
+ active: { background: 'var(--nav-tint)', color: 'var(--nav-accent)' },
5127
+ inactive: {},
5128
+ ...focusRing,
5129
+ },
5130
+ },
5131
+ icon: {
5132
+ base: {
5133
+ display: 'inline-flex',
5134
+ inlineSize: '1.25em',
5135
+ justifyContent: 'center',
5136
+ flex: 'none',
5137
+ lineHeight: 'var(--leading-none)',
5138
+ },
5139
+ },
5140
+ meta: {
5141
+ base: {
5142
+ marginInlineStart: 'auto',
5143
+ display: 'inline-flex',
5144
+ alignItems: 'center',
5145
+ fontSize: 'var(--text-xs)',
5146
+ fontVariantNumeric: 'tabular-nums',
5147
+ opacity: '0.7',
5148
+ },
5149
+ },
5150
+ },
5151
+ variants: {
5152
+ color: Object.fromEntries(ROLES.map((c) => [c, { root: { base: {
5153
+ '--nav-accent': roleInk(c),
5154
+ '--nav-tint': `var(--color-${c}-soft)`,
5155
+ } } }])),
5156
+ size: {
5157
+ xs: { root: { base: { fontSize: 'var(--text-xs)' } }, link: { base: { padding: 'var(--space-2xs) var(--space-md)' } } },
5158
+ sm: { root: { base: { fontSize: 'var(--text-xs)' } }, link: { base: { padding: 'var(--space-xs) var(--space-md)' } } },
5159
+ md: {},
5160
+ lg: { root: { base: { fontSize: 'var(--text-md)' } }, link: { base: { padding: 'var(--space-md) var(--space-lg)' } } },
5161
+ xl: { root: { base: { fontSize: 'var(--text-lg)' } }, link: { base: { padding: 'var(--space-lg) var(--space-xl)' } } },
5162
+ },
5163
+ },
5164
+ };
5165
+
4639
5166
  /**
4640
5167
  * Breadcrumbs — daisy's breadcrumbs: a compact text-sm trail, links
4641
5168
  * underlining on hover, the current crumb plain full ink. Colour rebinds
@@ -4839,8 +5366,32 @@ export const pagination: RecipeInput = {
4839
5366
  xl: { root: { base: { '--pg-size': 'calc(var(--size-field) * 14)', '--pg-font': 'var(--text-lg)' } } },
4840
5367
  },
4841
5368
  },
5369
+ // The cell's inline padding, restated physically: logical spellings
5370
+ // resolve on iOS but not on Android (measured, signalxjs/lynx#1084), so
5371
+ // the emitter refuses them.
5372
+ targets: {
5373
+ lynx: {
5374
+ parts: {
5375
+ item: { base: { paddingLeft: 'var(--space-2xs)', paddingRight: 'var(--space-2xs)' } },
5376
+ },
5377
+ },
5378
+ },
4842
5379
  };
4843
5380
 
5381
+ /**
5382
+ * The complete step's digit: the role hue deepened toward base-content, on
5383
+ * the disc's 20% tint of that role — `roleInk`'s per-role keep, capped at
5384
+ * 70%. The cap is the primary story: 95% on the tint measured 2.96:1 under
5385
+ * nord's muted primary (the static contrast matrix, #403). The per-role part
5386
+ * arrived with per-step colour (#112), when the matrix first measured every
5387
+ * role here: a flat 70% put nord's muted accent/success/warning at 2.2–2.6:1
5388
+ * and the dark themes' neutral (darker than the page) at 1.7–2.0:1, and
5389
+ * `ROLE_INK_KEEP` already knew how much hue each of those can keep.
5390
+ */
5391
+ const stepInk = (accent: string, role: string): string =>
5392
+ `color-mix(in oklab, ${accent} `
5393
+ + `${Math.min(ROLE_INK_KEEP[role as keyof typeof roles] ?? 55, 70)}%, var(--color-base-content))`;
5394
+
4844
5395
  /**
4845
5396
  * Steps — daisy's steps translated to the richer rail: bold numbered discs
4846
5397
  * on base-200, the walked disc and line refilled with the accent pair
@@ -4853,6 +5404,9 @@ export const steps: RecipeInput = {
4853
5404
  '--steps-accent': 'var(--color-primary)',
4854
5405
  '--steps-accent-content': 'var(--color-primary-content)',
4855
5406
  '--steps-accent-ink': roleInk('primary'),
5407
+ // Read through the accent, so it resolves on the element that sets
5408
+ // it; a colour on the item restates it for that role.
5409
+ '--steps-complete-ink': stepInk('var(--steps-accent)', 'primary'),
4856
5410
  '--steps-ind': 'calc(var(--size-selector) * 7)',
4857
5411
  '--steps-font': 'var(--text-sm)',
4858
5412
  },
@@ -4924,7 +5478,14 @@ export const steps: RecipeInput = {
4924
5478
  },
4925
5479
  states: {
4926
5480
  active: { background: 'var(--steps-accent)', color: 'var(--steps-accent-content)' },
4927
- complete: { background: 'color-mix(in oklch, var(--steps-accent) 20%, var(--color-base-100))', color: 'var(--steps-accent-ink)' },
5481
+ // Ink deepened toward base-content, per role (`stepInk`). The
5482
+ // tint mixes in OKLAB: daisy's light base-100 is
5483
+ // `oklch(100% 0 0)`, whose 0 hue the browser interpolates as
5484
+ // written, so an OKLCH mix swung every role's tint toward red
5485
+ // (warning's yellow came out pink) — which the browser parity
5486
+ // check caught once per-step colour put all eight roles on
5487
+ // the disc (#112).
5488
+ complete: { background: 'color-mix(in oklab, var(--steps-accent) 20%, var(--color-base-100))', color: 'var(--steps-complete-ink)' },
4928
5489
  inactive: { background: 'var(--color-base-200)', color: 'var(--color-base-content)' },
4929
5490
  },
4930
5491
  },
@@ -4973,10 +5534,15 @@ export const steps: RecipeInput = {
4973
5534
  },
4974
5535
  },
4975
5536
  variants: {
4976
- color: Object.fromEntries(ROLES.map((c) => [c, { root: { base: {
5537
+ // Keyed on the ITEM, not the root (#112): the item re-carries
5538
+ // `color`, so a step's own value outranks the rail's, and the
5539
+ // disc, bridge and title inside it inherit whichever won. A root
5540
+ // colour still reaches every item through the carrier's donut.
5541
+ color: Object.fromEntries(ROLES.map((c) => [c, { item: { base: {
4977
5542
  '--steps-accent': `var(--color-${c})`,
4978
5543
  '--steps-accent-content': `var(--color-${c}-content)`,
4979
5544
  '--steps-accent-ink': roleInk(c),
5545
+ '--steps-complete-ink': stepInk(`var(--color-${c})`, c),
4980
5546
  } } }])),
4981
5547
  size: {
4982
5548
  xs: { root: { base: { '--steps-ind': 'calc(var(--size-selector) * 5)', '--steps-font': 'var(--text-xs)' } } },
@@ -4986,12 +5552,76 @@ export const steps: RecipeInput = {
4986
5552
  xl: { root: { base: { '--steps-ind': 'calc(var(--size-selector) * 9)', '--steps-font': 'var(--text-md)' } } },
4987
5553
  },
4988
5554
  },
5555
+ // The bridge's anchors and the vertical item's tail padding, restated
5556
+ // physically: logical spellings resolve on iOS but not on Android
5557
+ // (measured, signalxjs/lynx#1084), so the emitter refuses them. Physical
5558
+ // is the lynx target's norm — no RTL flow there to mirror with.
5559
+ targets: {
5560
+ lynx: {
5561
+ parts: {
5562
+ item: {
5563
+ selectors: {
5564
+ '&[data-orientation="vertical"]': { paddingBottom: 'var(--space-lg)' },
5565
+ },
5566
+ },
5567
+ separator: {
5568
+ selectors: {
5569
+ '&[data-orientation="horizontal"]': {
5570
+ top: 'calc(var(--space-xs) + var(--steps-ind) / 2)',
5571
+ left: '50%',
5572
+ },
5573
+ '&[data-orientation="vertical"]': {
5574
+ left: 'calc(var(--space-xs) + (var(--steps-ind) - var(--border)) / 2)',
5575
+ top: 'calc(var(--space-xs) + var(--steps-ind))',
5576
+ bottom: 'calc(var(--space-xs) * -1)',
5577
+ },
5578
+ },
5579
+ },
5580
+ },
5581
+ },
5582
+ },
5583
+ };
5584
+
5585
+ /**
5586
+ * The modal sheet's slide (#83): in from its edge, back out to it.
5587
+ *
5588
+ * `translate` is physical, so the travel is a custom property that flips with
5589
+ * the placement AND the direction — off the reading start is leftward in LTR
5590
+ * and rightward in RTL (the `rtl` hedge, as everywhere here). Keyed on
5591
+ * `data-l-dock="sheet"`, the regime, which holds through the exit where
5592
+ * `:modal` does not, so the slide-out leaves from the sheet's own box.
5593
+ * Opacity stays at 1: the travel is the transition. Each direction rides the
5594
+ * transition declared at its destination; reduced motion drops both.
5595
+ */
5596
+ const sheetSlide = (enter: string, exit: string): PartStyles => {
5597
+ const sheet = '&[data-l-dock="sheet"]';
5598
+ const open = `${sheet}[data-state="open"]`;
5599
+ const travel = (tempo: string): string => {
5600
+ const duration = tempo.split(' ')[0];
5601
+ return `translate ${tempo}, display ${duration} allow-discrete, overlay ${duration} allow-discrete`;
5602
+ };
5603
+ return {
5604
+ base: { '--drawer-travel': '-100%' },
5605
+ selectors: {
5606
+ '&[data-placement="end"]': { '--drawer-travel': '100%' },
5607
+ [`&[data-placement="start"]${rtl}`]: { '--drawer-travel': '100%' },
5608
+ [`&[data-placement="end"]${rtl}`]: { '--drawer-travel': '-100%' },
5609
+ [sheet]: { opacity: '1', translate: 'var(--drawer-travel) 0', transition: travel(exit) },
5610
+ [open]: { translate: 'none', transition: travel(enter) },
5611
+ },
5612
+ at: {
5613
+ 'starting-style': { selectors: { [open]: { opacity: '1', translate: 'var(--drawer-travel) 0' } } },
5614
+ 'reduced-motion': { selectors: { [sheet]: { transition: 'none' }, [open]: { transition: 'none' } } },
5615
+ },
5616
+ };
4989
5617
  };
4990
5618
 
4991
5619
  /**
4992
5620
  * Drawer — daisy's drawer-side as a native <dialog> edge sheet: base-100
4993
- * paper, no border (daisy separates the drawer with the scrim), faded in.
4994
- * Base render is the inline mode; `:modal` is the top-layer edge sheet.
5621
+ * paper, no border (daisy separates the drawer with the scrim), sliding in
5622
+ * from its edge the way daisy's drawer-side does (`translate` over 0.3s,
5623
+ * #83). Base render is the inline mode; `data-l-dock="sheet"` is the
5624
+ * top-layer edge sheet.
4995
5625
  */
4996
5626
  export const drawer: RecipeInput = {
4997
5627
  component: 'drawer',
@@ -5006,7 +5636,10 @@ export const drawer: RecipeInput = {
5006
5636
  ...focusRing,
5007
5637
  },
5008
5638
  },
5009
- panel: withPresence(popupPresence('none'), {
5639
+ panel: withPresence(withPresence(popupPresence('none'), sheetSlide(
5640
+ 'var(--duration-slow) var(--ease-standard)',
5641
+ 'var(--duration-slow) var(--ease-standard)',
5642
+ )), {
5010
5643
  base: {
5011
5644
  padding: 'var(--space-lg)',
5012
5645
  background: 'var(--color-base-100)',
@@ -5014,30 +5647,39 @@ export const drawer: RecipeInput = {
5014
5647
  border: 'none',
5015
5648
  borderRadius: 'var(--radius-box)',
5016
5649
  boxShadow: 'var(--shadow-xl)',
5017
- inlineSize: 'min(20rem, 85vw)',
5650
+ // The width is capped by `--l-measure` (Drawer.Panel's
5651
+ // `measure`, via the layout step table); this is the default
5652
+ // an unset `measure` leaves. A cap, not a width, because the
5653
+ // table spells `full` as `none` — valid only for a max. In
5654
+ // flow and as a sheet alike, the panel fills what it is given
5655
+ // up to the measure, as its border box, so `full` never
5656
+ // overflows by its own padding.
5657
+ boxSizing: 'border-box',
5658
+ '--l-measure': 'min(20rem, 85vw)',
5659
+ inlineSize: '100%',
5660
+ maxInlineSize: 'var(--l-measure)',
5018
5661
  },
5019
5662
  states: { open: {}, closed: {} },
5020
5663
  selectors: {
5021
5664
  /**
5022
- * The platform's own spelling of "this open is the modal
5023
- * one": `:modal`. The base styles above are the INLINE
5024
- * render (`show()` keeps the panel in flow); this block is
5025
- * the top-layer edge sheet. Logical insets pin the edge, so
5026
- * RTL mirrors free.
5665
+ * The top-layer edge sheet, keyed on the regime
5666
+ * (`data-l-dock="sheet"`) rather than `:modal`, which stops
5667
+ * matching the moment `close()` runs — so the sheet keeps its
5668
+ * box through the exit (#83). The base styles above are the
5669
+ * INLINE render. Logical insets pin the edge, so RTL mirrors
5670
+ * free.
5027
5671
  */
5028
- '&:modal': {
5672
+ '&[data-l-dock="sheet"]': {
5029
5673
  position: 'fixed',
5030
5674
  insetBlockStart: '0',
5031
5675
  insetBlockEnd: '0',
5032
5676
  blockSize: '100dvh',
5033
5677
  maxBlockSize: '100dvh',
5034
- inlineSize: 'min(20rem, 85vw)',
5035
- maxInlineSize: 'none',
5036
5678
  margin: '0',
5037
5679
  borderRadius: '0',
5038
5680
  },
5039
- '&[data-placement="start"]:modal': { insetInlineStart: '0', insetInlineEnd: 'auto' },
5040
- '&[data-placement="end"]:modal': { insetInlineStart: 'auto', insetInlineEnd: '0' },
5681
+ '&[data-placement="start"][data-l-dock="sheet"]': { insetInlineStart: '0', insetInlineEnd: 'auto' },
5682
+ '&[data-placement="end"][data-l-dock="sheet"]': { insetInlineStart: 'auto', insetInlineEnd: '0' },
5041
5683
  },
5042
5684
  }),
5043
5685
  backdrop: {
@@ -5059,6 +5701,16 @@ export const drawer: RecipeInput = {
5059
5701
  // Trigger-carried axes — see `btnColors` for why the panel is out of
5060
5702
  // reach and the trigger is the whole story here.
5061
5703
  variants: { color: btnColors(), size: btnSizes },
5704
+ // The btn paddings, restated physically — see `lynxBtnPad` (#1084).
5705
+ targets: {
5706
+ lynx: {
5707
+ parts: {
5708
+ trigger: { base: lynxBtnPad(4) },
5709
+ close: { base: lynxBtnPad(4) },
5710
+ },
5711
+ variants: { size: lynxBtnSizes('trigger') },
5712
+ },
5713
+ },
5062
5714
  };
5063
5715
 
5064
5716
  /**
@@ -5069,7 +5721,20 @@ export const drawer: RecipeInput = {
5069
5721
  */
5070
5722
  export const table: RecipeInput = {
5071
5723
  component: 'table',
5724
+ // Public to a design system derived from this one (#73).
5725
+ hooks: {
5726
+ properties: {
5727
+ '--table-cell-align': 'Cell text alignment; zero writes it inline from the column spec.',
5728
+ '--table-accent': 'The header ink accent.',
5729
+ '--table-pad-block': 'Cell block padding.',
5730
+ '--table-pad-inline': 'Cell inline padding.',
5731
+ '--table-font': 'The table font size.',
5732
+ },
5733
+ },
5072
5734
  tokens: {
5735
+ // The column spec's alignment (#55): a cell that names an aligned
5736
+ // column overrides it inline; everything else reads `start`.
5737
+ '--table-cell-align': 'start',
5073
5738
  '--table-accent': 'var(--color-base-content)',
5074
5739
  '--table-pad-block': 'var(--space-sm)',
5075
5740
  '--table-pad-inline': 'var(--space-md)',
@@ -5083,6 +5748,9 @@ export const table: RecipeInput = {
5083
5748
  borderRadius: 'var(--radius-box)',
5084
5749
  background: 'var(--color-base-100)',
5085
5750
  },
5751
+ // Stacked (Table.Root stack, #55): zero lays each row out as a block
5752
+ // below the table's breakpoint; the card it becomes is drawn here.
5753
+ at: tableStackAt(tokens, 'root', { border: '0', borderRadius: '0', background: 'transparent' }),
5086
5754
  },
5087
5755
  table: {
5088
5756
  base: {
@@ -5100,34 +5768,52 @@ export const table: RecipeInput = {
5100
5768
  fontSize: 'var(--text-xs)',
5101
5769
  color: 'color-mix(in oklab, var(--color-base-content) 60%, transparent)',
5102
5770
  },
5771
+ at: tableStackAt(tokens, 'caption', { paddingInline: '0' }),
5103
5772
  },
5104
5773
  head: {},
5105
- body: {},
5774
+ body: { at: tableStackAt(tokens, 'body', { rowGap: 'var(--space-md)' }) },
5106
5775
  foot: {
5107
5776
  base: {
5108
5777
  fontSize: 'var(--text-xs)',
5109
5778
  color: 'color-mix(in oklab, var(--color-base-content) 60%, transparent)',
5110
5779
  },
5780
+ at: tableStackAt(tokens, 'foot', { rowGap: 'var(--space-md)', marginBlockStart: 'var(--space-md)' }),
5111
5781
  },
5112
5782
  row: {
5113
5783
  base: { borderBlockEnd: 'var(--border) solid var(--color-base-200)' },
5114
5784
  states: {
5115
5785
  selected: { background: 'var(--color-base-300)' },
5116
5786
  },
5787
+ at: tableStackAt(tokens, 'row', {
5788
+ border: 'var(--border) solid var(--color-base-200)',
5789
+ borderRadius: 'var(--radius-box)',
5790
+ background: 'var(--color-base-100)',
5791
+ padding: 'var(--table-pad-block) var(--table-pad-inline)',
5792
+ }),
5117
5793
  },
5118
5794
  'header-cell': {
5119
5795
  base: {
5120
5796
  padding: 'var(--table-pad-block) var(--table-pad-inline)',
5121
- textAlign: 'start',
5797
+ textAlign: 'var(--table-cell-align)',
5122
5798
  fontWeight: 'var(--weight-semibold)',
5123
5799
  fontSize: 'var(--text-xs)',
5124
5800
  color: 'color-mix(in oklab, var(--table-accent) 60%, transparent)',
5125
5801
  },
5802
+ at: tableStackAt(tokens, 'header-cell', { paddingInline: '0' }),
5126
5803
  },
5127
5804
  cell: {
5128
5805
  base: {
5129
5806
  padding: 'var(--table-pad-block) var(--table-pad-inline)',
5130
- textAlign: 'start',
5807
+ textAlign: 'var(--table-cell-align)',
5808
+ },
5809
+ at: tableStackAt(tokens, 'cell', { paddingInline: '0', paddingBlock: 'var(--space-xs)' }),
5810
+ },
5811
+ // A stacked cell's column label, in the header's muted semibold.
5812
+ 'cell-label': {
5813
+ base: {
5814
+ fontWeight: 'var(--weight-semibold)',
5815
+ fontSize: 'var(--text-xs)',
5816
+ color: 'color-mix(in oklab, var(--table-accent) 60%, transparent)',
5131
5817
  },
5132
5818
  },
5133
5819
  },
@@ -5449,6 +6135,22 @@ export const carousel: RecipeInput = {
5449
6135
  xl: { root: { base: { '--carousel-dot': '0.875rem', '--carousel-nav': '3rem' } } },
5450
6136
  },
5451
6137
  },
6138
+ // The nav buttons' anchors, restated physically: logical inset
6139
+ // spellings resolve on iOS but not on Android (measured,
6140
+ // signalxjs/lynx#1084), so the emitter refuses them. Physical is the
6141
+ // lynx target's norm — no RTL flow there, so `start` IS left.
6142
+ targets: {
6143
+ lynx: {
6144
+ parts: {
6145
+ 'prev-trigger': {
6146
+ base: { top: 'calc(50% - var(--carousel-nav) / 2)', left: 'var(--space-sm)' },
6147
+ },
6148
+ 'next-trigger': {
6149
+ base: { top: 'calc(50% - var(--carousel-nav) / 2)', right: 'var(--space-sm)' },
6150
+ },
6151
+ },
6152
+ },
6153
+ },
5452
6154
  };
5453
6155
 
5454
6156
  /**
@@ -5588,6 +6290,12 @@ export const countdown: RecipeInput = {
5588
6290
  xl: { root: { base: { '--countdown-font': 'var(--text-3xl)' } } },
5589
6291
  },
5590
6292
  },
6293
+ // One line inside a sentence (#57): the surrounding text's size and
6294
+ // weight instead of the display step. What stays is what makes
6295
+ // it a countdown — tabular digits, its ink, the per-tick entry.
6296
+ modifiers: {
6297
+ inline: { root: { base: { fontSize: 'inherit', fontWeight: 'inherit' } } },
6298
+ },
5591
6299
  keyframes: { 'zero-daisyui-countdown-in': 'from { transform: translateY(0.6em); opacity: 0; }' },
5592
6300
  };
5593
6301
 
@@ -5693,16 +6401,31 @@ export const diff: RecipeInput = {
5693
6401
  },
5694
6402
  },
5695
6403
  },
6404
+ // The reveal pane's pin and the handle's centering margin, restated
6405
+ // physically: logical spellings resolve on iOS but not on Android
6406
+ // (measured, signalxjs/lynx#1084), so the emitter refuses them.
6407
+ // Physical is the lynx target's norm — no RTL flow there to mirror
6408
+ // with.
6409
+ lynx: {
6410
+ parts: {
6411
+ after: {
6412
+ base: { top: '0', bottom: '0', left: '0' },
6413
+ },
6414
+ handle: {
6415
+ base: { top: '0', bottom: '0', marginLeft: 'calc(var(--diff-hit) / -2)' },
6416
+ },
6417
+ },
6418
+ },
5696
6419
  },
5697
6420
  };
5698
6421
 
5699
6422
  export const recipes: RecipeInput[] = [
5700
6423
  tabs, collapsible, switchRecipe, dialog, popover, tooltip, menu,
5701
6424
  field, checkbox, radioGroup, progress, slider, accordion, select, button, avatar, toast, combobox,
5702
- toggle, toggleGroup, numberInput, ratingGroup, treeView, input, textarea, nativeSelect,
5703
- card, alert, badge, divider, skeleton, spinner,
6425
+ toggle, toggleGroup, numberInput, ratingGroup, treeView, input, textarea,
6426
+ card, alert, emptyState, badge, divider, skeleton, spinner,
5704
6427
  kbd, status, indicator, stats, timeline, chat, radialProgress, join,
5705
- navbar, breadcrumbs, pagination, steps, drawer,
6428
+ navbar, navList, breadcrumbs, pagination, steps, drawer,
5706
6429
  table,
5707
6430
  fileUpload,
5708
6431
  carousel,