@sigx/zero-daisyui 0.5.0 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (106) hide show
  1. package/README.md +61 -1
  2. package/dist/audit.json +6763 -3228
  3. package/dist/components.d.ts +14 -2
  4. package/dist/components.js +1 -0
  5. package/dist/css/components/accordion.css +26 -2
  6. package/dist/css/components/alert.css +2 -0
  7. package/dist/css/components/avatar.css +2 -0
  8. package/dist/css/components/badge.css +2 -0
  9. package/dist/css/components/box.css +2 -0
  10. package/dist/css/components/breadcrumbs.css +2 -0
  11. package/dist/css/components/button.css +2 -0
  12. package/dist/css/components/card.css +2 -0
  13. package/dist/css/components/carousel.css +2 -0
  14. package/dist/css/components/center.css +2 -0
  15. package/dist/css/components/chat.css +2 -0
  16. package/dist/css/components/checkbox-group.css +98 -0
  17. package/dist/css/components/checkbox.css +10 -0
  18. package/dist/css/components/collapsible.css +16 -2
  19. package/dist/css/components/combobox.css +36 -1
  20. package/dist/css/components/container.css +2 -0
  21. package/dist/css/components/countdown.css +2 -0
  22. package/dist/css/components/dialog.css +2 -0
  23. package/dist/css/components/diff.css +7 -0
  24. package/dist/css/components/divider.css +2 -0
  25. package/dist/css/components/drawer.css +2 -0
  26. package/dist/css/components/empty-state.css +2 -0
  27. package/dist/css/components/field.css +2 -0
  28. package/dist/css/components/file-upload.css +34 -0
  29. package/dist/css/components/grid.css +2 -0
  30. package/dist/css/components/indicator.css +2 -0
  31. package/dist/css/components/input.css +137 -0
  32. package/dist/css/components/join.css +2 -0
  33. package/dist/css/components/kbd.css +2 -0
  34. package/dist/css/components/menu.css +2 -0
  35. package/dist/css/components/nav-list.css +2 -0
  36. package/dist/css/components/navbar.css +2 -0
  37. package/dist/css/components/number-input.css +2 -0
  38. package/dist/css/components/pagination.css +6 -0
  39. package/dist/css/components/popover.css +2 -0
  40. package/dist/css/components/progress.css +2 -0
  41. package/dist/css/components/radial-progress.css +2 -0
  42. package/dist/css/components/radio-group.css +10 -4
  43. package/dist/css/components/rating-group.css +2 -0
  44. package/dist/css/components/select.css +54 -1
  45. package/dist/css/components/skeleton.css +2 -0
  46. package/dist/css/components/slider.css +154 -25
  47. package/dist/css/components/spacer.css +2 -0
  48. package/dist/css/components/spinner.css +2 -0
  49. package/dist/css/components/stack.css +2 -0
  50. package/dist/css/components/stats.css +68 -16
  51. package/dist/css/components/status.css +2 -0
  52. package/dist/css/components/steps.css +2 -0
  53. package/dist/css/components/swap.css +2 -0
  54. package/dist/css/components/switch.css +23 -0
  55. package/dist/css/components/table.css +7 -0
  56. package/dist/css/components/tabs.css +15 -0
  57. package/dist/css/components/textarea.css +2 -0
  58. package/dist/css/components/timeline.css +3 -0
  59. package/dist/css/components/toast.css +2 -0
  60. package/dist/css/components/toggle-group.css +2 -0
  61. package/dist/css/components/toggle.css +2 -0
  62. package/dist/css/components/tooltip.css +2 -0
  63. package/dist/css/components/tree-view.css +6 -0
  64. package/dist/css/index.css +688 -64
  65. package/dist/css/tokens.css +13 -13
  66. package/dist/lynx/components/accordion.css +5 -0
  67. package/dist/lynx/components/alert.css +20 -0
  68. package/dist/lynx/components/button.css +5 -0
  69. package/dist/lynx/components/checkbox-group.css +209 -0
  70. package/dist/lynx/components/checkbox.css +8 -0
  71. package/dist/lynx/components/combobox.css +46 -0
  72. package/dist/lynx/components/diff.css +5 -0
  73. package/dist/lynx/components/drawer.css +4 -0
  74. package/dist/lynx/components/file-upload.css +47 -0
  75. package/dist/lynx/components/input.css +178 -0
  76. package/dist/lynx/components/number-input.css +8 -0
  77. package/dist/lynx/components/pagination.css +16 -0
  78. package/dist/lynx/components/progress.css +4 -4
  79. package/dist/lynx/components/radio-group.css +8 -0
  80. package/dist/lynx/components/rating-group.css +3 -3
  81. package/dist/lynx/components/select.css +58 -0
  82. package/dist/lynx/components/slider.css +106 -25
  83. package/dist/lynx/components/stats.css +146 -16
  84. package/dist/lynx/components/steps.css +4 -0
  85. package/dist/lynx/components/switch.css +4 -0
  86. package/dist/lynx/components/table.css +5 -0
  87. package/dist/lynx/components/tabs.css +13 -0
  88. package/dist/lynx/components/timeline.css +26 -0
  89. package/dist/lynx/components/toggle.css +5 -0
  90. package/dist/lynx/components/tree-view.css +4 -0
  91. package/dist/lynx/index.css +984 -94
  92. package/dist/lynx/manifest.json +40 -5
  93. package/dist/lynx/tokens.css +4 -4
  94. package/dist/manifest.json +38 -3
  95. package/dist/recipes.d.ts +7 -0
  96. package/dist/recipes.d.ts.map +1 -1
  97. package/dist/recipes.js +638 -60
  98. package/dist/recipes.js.map +1 -1
  99. package/dist/register.d.ts +9 -0
  100. package/dist/report.json +597 -211
  101. package/dist/tokens.d.ts.map +1 -1
  102. package/dist/tokens.js +18 -4
  103. package/dist/tokens.js.map +1 -1
  104. package/package.json +5 -5
  105. package/src/recipes.ts +647 -61
  106. package/src/tokens.ts +18 -4
package/dist/recipes.js CHANGED
@@ -140,13 +140,45 @@ const disclosurePresence = {
140
140
  '&::details-content': {
141
141
  blockSize: '0',
142
142
  overflow: 'hidden',
143
+ },
144
+ // Open only: a close removes `open`, which hides the content before
145
+ // any transition could run — the panel animates that half (#276).
146
+ '&[open]::details-content': {
147
+ blockSize: 'auto',
143
148
  transition: 'block-size var(--duration-normal) var(--ease-standard), '
144
149
  + 'content-visibility var(--duration-normal) allow-discrete',
145
150
  },
146
- '&[open]::details-content': { blockSize: 'auto' },
147
151
  },
148
- at: { 'reduced-motion': { selectors: { '&::details-content': { transition: 'none' } } } },
152
+ at: { 'reduced-motion': { selectors: { '&[open]::details-content': { transition: 'none' } } } },
149
153
  };
154
+ /**
155
+ * The panel's close (#276). Removing `open` hides a `<details>` at once, so
156
+ * the close cannot live on `::details-content` the way the open does: zero
157
+ * flips the panel to `closed` first and holds the element open until the
158
+ * panel's own animation has played. The runtime publishes the panel's
159
+ * measured height as `--<scope>-panel-height`, which is the one value this
160
+ * needs — `auto` is not an endpoint any engine but Chromium can animate.
161
+ * `border-box` so the measured height (content + padding) is the whole box.
162
+ * Web-only: the property is written by the DOM runtime (`RUNTIME_PROPERTIES`).
163
+ */
164
+ const disclosureExit = (scope) => ({
165
+ web: {
166
+ parts: {
167
+ panel: {
168
+ states: {
169
+ closed: {
170
+ boxSizing: 'border-box',
171
+ animation: `${scope}-panel-exit var(--duration-normal) var(--ease-standard)`,
172
+ },
173
+ },
174
+ at: { 'reduced-motion': { states: { closed: { animation: 'none' } } } },
175
+ },
176
+ },
177
+ keyframes: {
178
+ [`${scope}-panel-exit`]: `from { block-size: var(--${scope}-panel-height); } to { block-size: 0; padding-block: 0; }`,
179
+ },
180
+ },
181
+ });
150
182
  /**
151
183
  * Merge presence into a part's own styles per KEY, not per block: a recipe
152
184
  * that already writes `states: { open: {} }` — the "deliberately unstyled"
@@ -191,14 +223,33 @@ const withPresence = (presence, styles) => ({
191
223
  */
192
224
  export const tabs = {
193
225
  component: 'tabs',
226
+ tokens: {
227
+ // How far the focus ring reaches outside a tab (2px + 2px offset):
228
+ // the list's scroll box pads by it so the ring is never clipped (#45).
229
+ '--tabs-ring-room': '4px',
230
+ },
194
231
  parts: {
195
232
  root: {
196
233
  base: { display: 'flex', flexDirection: 'column', gap: 'var(--space-lg)' },
197
234
  },
198
235
  list: {
236
+ // The row's own scroll box (#45), Pagination's and Stats' answer:
237
+ // a row of labels wider than its column (a phone, a wide fallback
238
+ // face) scrolls instead of pushing the page sideways.
239
+ // `max-inline-size` (border-box, so it includes the padding)
240
+ // gives the shrink-to-fit list a width to scroll in. The padding is room for the focus ring, which the
241
+ // scroll box would otherwise clip at the row's edges; the
242
+ // matching scroll padding keeps it clear when focus scrolls a
243
+ // tab into view. The box variant's own padding (space-xs, the
244
+ // same 4px) replaces it there.
199
245
  base: {
200
246
  display: 'inline-flex',
201
247
  alignSelf: 'flex-start',
248
+ boxSizing: 'border-box',
249
+ maxInlineSize: '100%',
250
+ overflowX: 'auto',
251
+ padding: 'var(--tabs-ring-room)',
252
+ scrollPaddingInline: 'var(--tabs-ring-room)',
202
253
  },
203
254
  },
204
255
  tab: {
@@ -236,6 +287,10 @@ export const tabs = {
236
287
  ...focusRing,
237
288
  },
238
289
  },
290
+ // daisy draws each flavor's active mark on the tab itself (the
291
+ // underline, the lift, the pill) and none of them slides, so a
292
+ // `Tabs.Indicator` renders nothing here (#283).
293
+ indicator: { base: { display: 'none' } },
239
294
  panel: {
240
295
  base: { fontSize: 'var(--text-md)' },
241
296
  states: { active: {}, inactive: {} },
@@ -505,6 +560,7 @@ const disclosureSizes = {
505
560
  // daisy "collapse collapse-arrow" flavor.
506
561
  export const collapsible = {
507
562
  component: 'collapsible',
563
+ targets: disclosureExit('collapsible'),
508
564
  // Public to a design system derived from this one (#73).
509
565
  hooks: {
510
566
  properties: {
@@ -621,6 +677,8 @@ export const switchRecipe = {
621
677
  root: {
622
678
  base: { display: 'inline-flex', alignItems: 'center', gap: 'var(--space-lg)', cursor: 'pointer' },
623
679
  states: {
680
+ // Readonly answers to nothing, so it does not invite a click.
681
+ readonly: { cursor: 'default' },
624
682
  disabled: { opacity: 'var(--disabled-opacity)', cursor: 'not-allowed' },
625
683
  checked: {},
626
684
  unchecked: {},
@@ -753,6 +811,29 @@ export const switchRecipe = {
753
811
  + '0 1px color-mix(in oklab, currentColor calc(var(--depth) * 10%), #0000)',
754
812
  transition: 'background-color var(--duration-instant) var(--ease-standard)',
755
813
  },
814
+ at: {
815
+ // The knob is `currentColor` background plus shadows:
816
+ // forced colours revalue the one to Canvas and drop the
817
+ // others, leaving two empty pill outlines that read the
818
+ // same on and off (#189). Opt the knob out of forcing
819
+ // and paint it in system colours — CanvasText at rest,
820
+ // Highlight once checked. The noise and the shadows go
821
+ // too: unforced, they would mix the forced ink back in.
822
+ 'forced-colors': {
823
+ base: {
824
+ forcedColorAdjust: 'none',
825
+ backgroundColor: 'CanvasText',
826
+ backgroundImage: 'none',
827
+ boxShadow: 'none',
828
+ },
829
+ states: { checked: { backgroundColor: 'Highlight' } },
830
+ // A disabled switch draws in GrayText, the forced palette's
831
+ // own disabled ink, not full-strength ink faded by an
832
+ // author opacity the user's theme cannot style. Keyed off
833
+ // the control's flag, which outranks `checked`.
834
+ selectors: { '[data-scope="switch"][data-part="control"][data-disabled] &': { backgroundColor: 'GrayText' } },
835
+ },
836
+ },
756
837
  },
757
838
  },
758
839
  },
@@ -1021,6 +1102,22 @@ const floatingPanel = {
1021
1102
  borderRadius: 'var(--radius-box)',
1022
1103
  boxShadow: 'var(--shadow-lg)',
1023
1104
  };
1105
+ /**
1106
+ * daisy's dropdown menu for a listbox, sized by the geometry the
1107
+ * anchored-position strategy publishes (#278): at least the width of the
1108
+ * control that opened it (`--anchor-width`, daisy's 13rem floor otherwise)
1109
+ * and never taller than the room on the side it opened to
1110
+ * (`--available-height`), scrolling past a 20rem cap. Border-box, so both
1111
+ * bounds measure the box the strategy positions. Runtime-published
1112
+ * properties are web-only, so this is each listbox recipe's `targets.web`;
1113
+ * lynx keeps the shared 13rem.
1114
+ */
1115
+ const anchoredListbox = {
1116
+ boxSizing: 'border-box',
1117
+ minWidth: 'var(--anchor-width, 13rem)',
1118
+ maxHeight: 'min(20rem, var(--available-height, 20rem))',
1119
+ overflowY: 'auto',
1120
+ };
1024
1121
  export const popover = {
1025
1122
  component: 'popover',
1026
1123
  parts: {
@@ -1380,6 +1477,8 @@ export const checkbox = {
1380
1477
  root: {
1381
1478
  base: { display: 'inline-flex', alignItems: 'center', gap: 'var(--space-md)', cursor: 'pointer' },
1382
1479
  states: {
1480
+ // Readonly answers to nothing, so it does not invite a click.
1481
+ readonly: { cursor: 'default' },
1383
1482
  disabled: { opacity: 'var(--disabled-opacity)', cursor: 'not-allowed' },
1384
1483
  checked: {}, unchecked: {}, indeterminate: {},
1385
1484
  },
@@ -1452,6 +1551,8 @@ export const checkbox = {
1452
1551
  // `invalid` is semantic, not an accent: it stays error under
1453
1552
  // every colour variant, on purpose.
1454
1553
  invalid: { borderColor: 'var(--color-error)' },
1554
+ // Readonly answers to nothing, so it does not invite a click.
1555
+ readonly: { cursor: 'default' },
1455
1556
  disabled: {},
1456
1557
  },
1457
1558
  },
@@ -1576,13 +1677,9 @@ export const radioGroup = {
1576
1677
  parts: {
1577
1678
  root: {
1578
1679
  base: { display: 'flex', flexDirection: 'column', gap: 'var(--space-md)' },
1680
+ // `invalid` paints on each `item-control`, which carries the flag
1681
+ // itself (#267) — the root only groups the items.
1579
1682
  states: { invalid: {}, required: {} },
1580
- selectors: {
1581
- // `invalid` is a fact about the GROUP — `item-control` carries
1582
- // no flag of its own — and it is semantic: it stays error
1583
- // under every colour variant.
1584
- '&[data-invalid] [data-part="item-control"]': { borderColor: 'var(--color-error)' },
1585
- },
1586
1683
  },
1587
1684
  label: {
1588
1685
  base: { fontSize: 'var(--text-sm)', fontWeight: 'var(--weight-semibold)' },
@@ -1591,6 +1688,8 @@ export const radioGroup = {
1591
1688
  item: {
1592
1689
  base: { display: 'inline-flex', alignItems: 'center', gap: 'var(--space-md)', cursor: 'pointer' },
1593
1690
  states: {
1691
+ // Readonly answers to nothing, so it does not invite a click.
1692
+ readonly: { cursor: 'default' },
1594
1693
  disabled: { opacity: 'var(--disabled-opacity)', cursor: 'not-allowed' },
1595
1694
  checked: {}, unchecked: {},
1596
1695
  },
@@ -1625,6 +1724,10 @@ export const radioGroup = {
1625
1724
  animation: 'var(--duration-fast) var(--ease-standard) zero-daisy-radio',
1626
1725
  },
1627
1726
  unchecked: {},
1727
+ // `invalid` is semantic: it stays error under every colour
1728
+ // variant, and after `checked` so it holds whichever radio is
1729
+ // chosen.
1730
+ invalid: { borderColor: 'var(--color-error)' },
1628
1731
  'focus-visible': { outline: '2px solid var(--radio-accent)', outlineOffset: '2px' },
1629
1732
  disabled: {},
1630
1733
  },
@@ -1695,6 +1798,47 @@ export const radioGroup = {
1695
1798
  },
1696
1799
  },
1697
1800
  };
1801
+ /**
1802
+ * CheckboxGroup (#282) — daisy has no group component; its forms stack
1803
+ * `.checkbox` rows under a `fieldset-legend`. The boxes keep the checkbox
1804
+ * recipe; the group owns the stack and the legend, which speaks Field's
1805
+ * label (colour through `roleInk`, #210).
1806
+ */
1807
+ export const checkboxGroup = {
1808
+ component: 'checkbox-group',
1809
+ tokens: { '--checkbox-group-accent': 'var(--color-base-content)' },
1810
+ parts: {
1811
+ root: {
1812
+ base: { display: 'flex', flexDirection: 'column', gap: 'var(--space-md)' },
1813
+ // `invalid` and `readonly` paint on each box — the root only
1814
+ // lays the boxes out.
1815
+ states: { invalid: {}, required: {}, readonly: {} },
1816
+ selectors: {
1817
+ '&[data-orientation="horizontal"]': { flexDirection: 'row', flexWrap: 'wrap', columnGap: 'var(--space-lg)' },
1818
+ },
1819
+ },
1820
+ label: {
1821
+ base: { fontSize: 'var(--text-sm)', fontWeight: 'var(--weight-semibold)', color: 'var(--checkbox-group-accent)' },
1822
+ states: { disabled: { opacity: 'var(--disabled-opacity)' } },
1823
+ selectors: {
1824
+ '&[data-required]::after': { content: '" *"', color: roleInk('error') },
1825
+ },
1826
+ },
1827
+ },
1828
+ variants: {
1829
+ color: Object.fromEntries(ROLES.map((c) => [c, { root: { base: {
1830
+ '--checkbox-group-accent': roleInk(c),
1831
+ } } }])),
1832
+ size: {
1833
+ xs: { root: { base: { gap: 'var(--space-sm)' } }, label: { base: { fontSize: 'var(--text-xs)' } } },
1834
+ sm: { label: { base: { fontSize: 'var(--text-xs)' } } },
1835
+ md: {},
1836
+ lg: { label: { base: { fontSize: 'var(--text-md)' } } },
1837
+ xl: { root: { base: { gap: 'var(--space-lg)' } }, label: { base: { fontSize: 'var(--text-lg)' } } },
1838
+ },
1839
+ },
1840
+ skipStates: { label: ['invalid'] },
1841
+ };
1698
1842
  // --------------------------------------------------------------------------
1699
1843
  // 4. progress — replace the whole export (only the two radius lines move)
1700
1844
  // --------------------------------------------------------------------------
@@ -1816,7 +1960,15 @@ export const progress = {
1816
1960
  };
1817
1961
  export const slider = {
1818
1962
  component: 'slider',
1819
- tokens: { '--slider-accent': 'var(--color-primary)' },
1963
+ // daisy's `.range` metrics: the thumb is `--range-thumb-size`
1964
+ // (×6 of `--size-selector` at md, ×4…×8 across the ramp — the same
1965
+ // selector ramp checkbox rides) and the track is half of it. Both the
1966
+ // native control and the composed parts read these.
1967
+ tokens: {
1968
+ '--slider-accent': 'var(--color-primary)',
1969
+ '--slider-track-size': 'calc(var(--size-selector) * 3)',
1970
+ '--slider-thumb-size': 'calc(var(--size-selector) * 6)',
1971
+ },
1820
1972
  parts: {
1821
1973
  root: {
1822
1974
  base: { display: 'flex', flexDirection: 'column', gap: 'var(--space-sm)', width: '100%' },
@@ -1826,30 +1978,106 @@ export const slider = {
1826
1978
  base: { fontSize: 'var(--text-sm)', fontWeight: 'var(--weight-semibold)' },
1827
1979
  states: { disabled: {} },
1828
1980
  },
1981
+ // A custom skin (`appearance: none`), ported from daisyUI 5's
1982
+ // `range.css`: a `--radius-selector` track half the thumb's height,
1983
+ // and daisy's real range thumb — a `base-100` knob inside a
1984
+ // `.25rem` ring of the fill ink (daisy's `--range-p`), carrying the
1985
+ // same `--depth` inset shading the checkbox and radio wear. daisy
1986
+ // throws the elapsed fill as a 100cqw box-shadow off the thumb and
1987
+ // clips it with `overflow: hidden`; here the track reads the
1988
+ // runtime-published `--slider-percent` as a gradient stop instead,
1989
+ // as every other skin does. The fill is deepened toward
1990
+ // `base-content` the way `progressFill` deepens daisy's bar — a raw
1991
+ // role on the base-300 rail is exactly the #210/#228 bug class.
1992
+ //
1993
+ // The rebuild is also the correctness move (see the kit skill): Blink
1994
+ // treats range inputs as ALWAYS `:focus-visible`, even on mouse
1995
+ // focus, so the base kills the native outline and the
1996
+ // `focus-visible` state — compiled to the runtime's keyboard-only
1997
+ // `[data-focus-visible]` flag — draws the ring only when it means it.
1829
1998
  control: {
1830
- base: { width: '100%', accentColor: 'var(--slider-accent)', cursor: 'pointer' },
1999
+ base: {
2000
+ appearance: 'none',
2001
+ width: '100%',
2002
+ height: 'var(--slider-thumb-size)',
2003
+ margin: '0',
2004
+ background: 'transparent',
2005
+ cursor: 'pointer',
2006
+ outline: 'none',
2007
+ // Kept for the forced-colors fallback below, where native
2008
+ // rendering takes over and still honours the accent.
2009
+ accentColor: 'var(--slider-accent)',
2010
+ // `--slider-fill` and `--slider-track` live in targets.web
2011
+ // below: the fill is a color-mix() the lynx emitter drops,
2012
+ // and the track reads the runtime-published
2013
+ // `--slider-percent`, a web-only mechanism.
2014
+ },
1831
2015
  states: {
2016
+ // Readonly answers to nothing, so it does not invite a click.
2017
+ readonly: { cursor: 'default' },
1832
2018
  disabled: { cursor: 'not-allowed' },
1833
2019
  'focus-visible': { outline: '2px solid var(--slider-accent)', outlineOffset: '2px' },
1834
2020
  // `invalid` is semantic: it stays error under every colour
1835
- // variant, on purpose.
1836
- invalid: { accentColor: 'var(--color-error)' },
2021
+ // variant, on purpose — the accent indirection swaps the
2022
+ // fill, the thumb ring, the focus ring and the forced-colors
2023
+ // fallback together.
2024
+ invalid: { '--slider-accent': 'var(--color-error)' },
2025
+ },
2026
+ selectors: {
2027
+ // Vendor pseudos cannot share a selector list — one unknown
2028
+ // selector invalidates the whole rule — so each engine gets
2029
+ // its own copy reading the shared custom properties.
2030
+ '&::-webkit-slider-runnable-track': {
2031
+ height: 'var(--slider-track-size)',
2032
+ borderRadius: 'var(--radius-selector)',
2033
+ background: 'var(--slider-track)',
2034
+ },
2035
+ '&::-webkit-slider-thumb': {
2036
+ appearance: 'none',
2037
+ boxSizing: 'border-box',
2038
+ width: 'var(--slider-thumb-size)',
2039
+ height: 'var(--slider-thumb-size)',
2040
+ marginTop: 'calc((var(--slider-track-size) - var(--slider-thumb-size)) / 2)',
2041
+ borderRadius: 'calc(var(--radius-selector) + min(0.25rem, var(--radius-selector) * 3))',
2042
+ border: '0.25rem solid var(--slider-fill)',
2043
+ background: 'var(--color-base-100)',
2044
+ boxShadow: '0 -1px var(--depth-shade) inset, 0 8px 0 -4px var(--depth-sheen) inset',
2045
+ },
2046
+ '&::-moz-range-track': {
2047
+ height: 'var(--slider-track-size)',
2048
+ borderRadius: 'var(--radius-selector)',
2049
+ background: 'var(--slider-track)',
2050
+ },
2051
+ '&::-moz-range-thumb': {
2052
+ boxSizing: 'border-box',
2053
+ width: 'var(--slider-thumb-size)',
2054
+ height: 'var(--slider-thumb-size)',
2055
+ borderRadius: 'calc(var(--radius-selector) + min(0.25rem, var(--radius-selector) * 3))',
2056
+ border: '0.25rem solid var(--slider-fill)',
2057
+ background: 'var(--color-base-100)',
2058
+ boxShadow: '0 -1px var(--depth-shade) inset, 0 8px 0 -4px var(--depth-sheen) inset',
2059
+ },
2060
+ },
2061
+ at: {
2062
+ // Native rendering knows forced colors better than a custom
2063
+ // skin; the retained accentColor keeps the fallback branded.
2064
+ 'forced-colors': { base: { appearance: 'auto' } },
1837
2065
  },
1838
2066
  },
1839
- // The composed range projection (#325). The native control above
1840
- // stays UA-drawn behind `accentColor`; the composed parts restate the
1841
- // same accent as real boxes. The fill is deepened toward
1842
- // `base-content` the way `progressFill` deepens daisy's bar — a raw
1843
- // role on the base-300 rail is exactly the #210/#228 bug class.
2067
+ // The composed range projection (#325): the same metrics as the
2068
+ // native control above, restating its accent as real boxes. The
2069
+ // fill is the same deepened ink the native track paints.
1844
2070
  track: {
1845
2071
  base: {
1846
- height: 'calc(var(--size-selector) * 2)',
1847
- marginBlock: 'calc(var(--size-selector) * 1.5)',
2072
+ height: 'var(--slider-track-size)',
2073
+ // Reserve the thumb's overhang so the row's box matches the
2074
+ // native control's.
2075
+ marginBlock: 'calc((var(--slider-thumb-size) - var(--slider-track-size)) / 2)',
1848
2076
  borderRadius: '9999px',
1849
2077
  background: 'var(--color-base-300)',
1850
2078
  cursor: 'pointer',
1851
2079
  },
1852
- states: { disabled: { cursor: 'not-allowed' } },
2080
+ states: { readonly: { cursor: 'default' }, disabled: { cursor: 'not-allowed' } },
1853
2081
  },
1854
2082
  range: {
1855
2083
  base: {
@@ -1862,11 +2090,11 @@ export const slider = {
1862
2090
  thumb: {
1863
2091
  base: {
1864
2092
  boxSizing: 'border-box',
1865
- width: 'calc(var(--size-selector) * 5)',
1866
- height: 'calc(var(--size-selector) * 5)',
2093
+ width: 'var(--slider-thumb-size)',
2094
+ height: 'var(--slider-thumb-size)',
1867
2095
  insetBlockStart: '50%',
1868
2096
  translate: '0 -50%',
1869
- marginInlineStart: 'calc(var(--size-selector) * -2.5)',
2097
+ marginInlineStart: 'calc(var(--slider-thumb-size) / -2)',
1870
2098
  borderRadius: '9999px',
1871
2099
  background: 'color-mix(in oklab, var(--slider-accent) 90%, var(--color-base-content))',
1872
2100
  cursor: 'pointer',
@@ -1876,12 +2104,14 @@ export const slider = {
1876
2104
  states: {
1877
2105
  'focus-visible': { outline: '2px solid var(--slider-accent)', outlineOffset: '2px' },
1878
2106
  pressed: {},
2107
+ // Readonly answers to nothing, so it does not invite a click.
2108
+ readonly: { cursor: 'default' },
1879
2109
  disabled: { cursor: 'not-allowed' },
1880
2110
  },
1881
2111
  },
1882
2112
  mark: {
1883
2113
  base: {
1884
- paddingBlockStart: 'calc(var(--size-selector) * 2 + var(--space-2xs))',
2114
+ paddingBlockStart: 'calc(var(--slider-track-size) + var(--space-2xs))',
1885
2115
  fontSize: 'var(--text-xs)',
1886
2116
  lineHeight: '1',
1887
2117
  whiteSpace: 'nowrap',
@@ -1895,7 +2125,7 @@ export const slider = {
1895
2125
  insetBlockStart: '0',
1896
2126
  insetInlineStart: '-1px',
1897
2127
  width: '2px',
1898
- height: 'calc(var(--size-selector) * 2)',
2128
+ height: 'var(--slider-track-size)',
1899
2129
  background: 'var(--color-base-content)',
1900
2130
  },
1901
2131
  },
@@ -1908,33 +2138,133 @@ export const slider = {
1908
2138
  color: Object.fromEntries(ROLES.map((c) => [c, { root: { base: {
1909
2139
  '--slider-accent': `var(--color-${c})`,
1910
2140
  } } }])),
1911
- // A native range widget: only its box height is size-able without an
1912
- // appearance:none rebuild, so the ramp moves the box and the text.
2141
+ // daisy's `.range-{xs…xl}` ramp: `--range-thumb-size` at ×4…×8 of
2142
+ // `--size-selector` (1 / 1.25 / 1.5 / 1.75 / 2rem), the track half
2143
+ // of it — so every step moves the whole widget; the un-attributed
2144
+ // (and `md`) render takes the middle step from `tokens:`.
1913
2145
  size: {
1914
- xs: { control: { base: { height: 'calc(var(--size-selector) * 3)' } }, label: { base: { fontSize: 'var(--text-xs)' } } },
1915
- sm: { control: { base: { height: 'calc(var(--size-selector) * 4)' } }, label: { base: { fontSize: 'var(--text-sm)' } } },
2146
+ xs: { root: { base: { '--slider-track-size': 'calc(var(--size-selector) * 2)', '--slider-thumb-size': 'calc(var(--size-selector) * 4)' } }, label: { base: { fontSize: 'var(--text-xs)' } } },
2147
+ sm: { root: { base: { '--slider-track-size': 'calc(var(--size-selector) * 2.5)', '--slider-thumb-size': 'calc(var(--size-selector) * 5)' } }, label: { base: { fontSize: 'var(--text-sm)' } } },
2148
+ // `md`'s metrics ARE the `tokens:` defaults — one source of truth.
1916
2149
  md: { label: { base: { fontSize: 'var(--text-sm)' } } },
1917
- lg: { control: { base: { height: 'calc(var(--size-selector) * 7)' } }, label: { base: { fontSize: 'var(--text-md)' } } },
1918
- xl: { control: { base: { height: 'calc(var(--size-selector) * 8)' } }, label: { base: { fontSize: 'var(--text-lg)' } } },
2150
+ lg: { root: { base: { '--slider-track-size': 'calc(var(--size-selector) * 3.5)', '--slider-thumb-size': 'calc(var(--size-selector) * 7)' } }, label: { base: { fontSize: 'var(--text-md)' } } },
2151
+ xl: { root: { base: { '--slider-track-size': 'calc(var(--size-selector) * 4)', '--slider-thumb-size': 'calc(var(--size-selector) * 8)' } }, label: { base: { fontSize: 'var(--text-lg)' } } },
1919
2152
  },
1920
2153
  },
1921
- // Base equals the default — `tokens:` binds the primary accent, and `md`
1922
- // only restates the label font-size the base already declares (the
1923
- // control's base height IS the middle step) — so the twins restate it;
2154
+ // Base equals the default — `tokens:` binds the primary accent and the
2155
+ // middle-step metrics, and `md` only restates the label font-size the
2156
+ // base already declares — so the twins restate it;
1924
2157
  // declared for the manifest (signalxjs/lynx#1070).
1925
2158
  defaultVariants: { color: 'primary', size: 'md' },
1926
2159
  skipStates: { root: ['invalid', 'focus-visible'] },
1927
2160
  // ONE design decision, two paints. On the web the accent reaches the
1928
- // screen twice: the native control is UA-drawn behind `accent-color`, and
1929
- // the composed range/thumb deepen the accent with `color-mix()`. Lynx
1930
- // renders neither spelling — `accent-color` styles a native
1931
- // `<input type=range>` lynx does not have, and the emitter drops
1932
- // `color-mix()` as unresolvable — so the compiled range and thumb shipped
1933
- // with no paint at all: grey track, invisible fill, invisible knob
1934
- // (measured on device, signalxjs/lynx#1075). The geometry all survives
1935
- // (the sizes are direct calc(), proven working); paint is the only gap,
1936
- // so this section only restates paint, in lynx-expressible spellings.
2161
+ // screen twice, both times deepened with `color-mix()`: the native
2162
+ // control's gradient track and thumb ring (via `--slider-fill`, below),
2163
+ // and the composed range/thumb. Lynx renders neither — it has no native
2164
+ // `<input type=range>`, and the emitter drops `color-mix()` as
2165
+ // unresolvable — so the compiled range and thumb once shipped with no
2166
+ // paint at all: grey track, invisible fill, invisible knob (measured on
2167
+ // device, signalxjs/lynx#1075). The geometry all survives (the sizes are
2168
+ // direct calc() over the metric tokens); paint is the only gap, so the
2169
+ // lynx section only restates paint, in lynx-expressible spellings, plus
2170
+ // the physical restatement of the logical geometry.
1937
2171
  targets: {
2172
+ web: {
2173
+ parts: {
2174
+ control: {
2175
+ base: {
2176
+ // The fill ink, deepened like the composed range.
2177
+ '--slider-fill': 'color-mix(in oklab, var(--slider-accent) 90%, var(--color-base-content))',
2178
+ // The filled track reads the runtime-published
2179
+ // `--slider-percent` — web-only (`RUNTIME_PROPERTIES`);
2180
+ // the lynx runtime paints its own track/range parts.
2181
+ '--slider-track': 'linear-gradient(to right, var(--slider-fill) var(--slider-percent, 50%), var(--color-base-300) 0)',
2182
+ },
2183
+ selectors: {
2184
+ // A vertical native range is spelled through writing
2185
+ // mode by the runtime (#170), so the long axis is the
2186
+ // box's HEIGHT and the channel's WIDTH; the fill
2187
+ // grows from the foot.
2188
+ '&[data-orientation="vertical"]': {
2189
+ width: 'var(--slider-thumb-size)',
2190
+ height: 'var(--slider-length)',
2191
+ '--slider-track': 'linear-gradient(to top, var(--slider-fill) var(--slider-percent, 50%), var(--color-base-300) 0)',
2192
+ },
2193
+ '&[data-orientation="vertical"]::-webkit-slider-runnable-track': {
2194
+ width: 'var(--slider-track-size)',
2195
+ height: 'auto',
2196
+ },
2197
+ // Blink lays the thumb against the channel's block
2198
+ // axis — in vertical-lr that is its left edge.
2199
+ '&[data-orientation="vertical"]::-webkit-slider-thumb': {
2200
+ marginTop: '0',
2201
+ marginBlockStart: 'calc((var(--slider-track-size) - var(--slider-thumb-size)) / 2)',
2202
+ },
2203
+ '&[data-orientation="vertical"]::-moz-range-track': {
2204
+ width: 'var(--slider-track-size)',
2205
+ height: '100%',
2206
+ },
2207
+ },
2208
+ },
2209
+ // Vertical (#170): the runtime positions the moving parts by
2210
+ // physical `bottom` percents; these turn the channel upright and
2211
+ // re-centre the knob on the other axis. Web-only — the lynx
2212
+ // runtime ships no vertical slider.
2213
+ root: {
2214
+ selectors: {
2215
+ '&[data-orientation="vertical"]': {
2216
+ width: 'auto',
2217
+ alignItems: 'center',
2218
+ '--slider-length': 'calc(var(--size-selector) * 40)',
2219
+ },
2220
+ },
2221
+ },
2222
+ track: {
2223
+ selectors: {
2224
+ '&[data-orientation="vertical"]': {
2225
+ width: 'var(--slider-track-size)',
2226
+ height: 'var(--slider-length)',
2227
+ marginBlock: '0',
2228
+ marginInline: 'calc((var(--slider-thumb-size) - var(--slider-track-size)) / 2)',
2229
+ },
2230
+ },
2231
+ },
2232
+ range: {
2233
+ selectors: {
2234
+ '&[data-orientation="vertical"]': { insetInlineStart: '0', width: '100%' },
2235
+ },
2236
+ },
2237
+ thumb: {
2238
+ selectors: {
2239
+ '&[data-orientation="vertical"]': {
2240
+ insetBlockStart: 'auto',
2241
+ insetInlineStart: '50%',
2242
+ translate: 'none',
2243
+ marginInlineStart: 'calc(var(--slider-thumb-size) / -2)',
2244
+ marginBlockEnd: 'calc(var(--slider-thumb-size) / -2)',
2245
+ },
2246
+ },
2247
+ },
2248
+ // The label sits beside the channel, centred on its tick.
2249
+ mark: {
2250
+ selectors: {
2251
+ '&[data-orientation="vertical"]': {
2252
+ insetInlineStart: '0',
2253
+ paddingBlockStart: '0',
2254
+ paddingInlineStart: 'calc(var(--slider-track-size) + var(--space-2xs))',
2255
+ translate: '0 50%',
2256
+ },
2257
+ '&[data-orientation="vertical"]::before': {
2258
+ insetBlockStart: '50%',
2259
+ insetInlineStart: '0',
2260
+ width: 'var(--slider-track-size)',
2261
+ height: '2px',
2262
+ translate: '0 -50%',
2263
+ },
2264
+ },
2265
+ },
2266
+ },
2267
+ },
1938
2268
  lynx: {
1939
2269
  parts: {
1940
2270
  track: {
@@ -1944,14 +2274,14 @@ export const slider = {
1944
2274
  // the emitter refuses them. Physical is the lynx
1945
2275
  // target's norm — no RTL flow there.
1946
2276
  base: {
1947
- marginTop: 'calc(var(--size-selector) * 1.5)',
1948
- marginBottom: 'calc(var(--size-selector) * 1.5)',
2277
+ marginTop: 'calc((var(--slider-thumb-size) - var(--slider-track-size)) / 2)',
2278
+ marginBottom: 'calc((var(--slider-thumb-size) - var(--slider-track-size)) / 2)',
1949
2279
  },
1950
2280
  },
1951
2281
  mark: {
1952
2282
  // Same #1084 verdict: the tick labels hang below the
1953
2283
  // track off a physical padding.
1954
- base: { paddingTop: 'calc(var(--size-selector) * 2 + var(--space-2xs))' },
2284
+ base: { paddingTop: 'calc(var(--slider-track-size) + var(--space-2xs))' },
1955
2285
  },
1956
2286
  range: {
1957
2287
  // The web fill deepens the accent 90/10 toward
@@ -1989,7 +2319,7 @@ export const slider = {
1989
2319
  border: '0.25rem solid var(--slider-accent)',
1990
2320
  top: '50%',
1991
2321
  transform: 'translateY(-50%)',
1992
- marginLeft: 'calc(var(--size-selector) * -2.5)',
2322
+ marginLeft: 'calc(var(--slider-thumb-size) / -2)',
1993
2323
  },
1994
2324
  },
1995
2325
  },
@@ -1998,12 +2328,15 @@ export const slider = {
1998
2328
  };
1999
2329
  export const accordion = {
2000
2330
  component: 'accordion',
2331
+ targets: disclosureExit('accordion'),
2001
2332
  // Same accent grammar as collapsible: the open heading's ink, defaulting
2002
2333
  // to base-content so the un-attributed render is unchanged.
2003
2334
  tokens: { '--accordion-accent': 'var(--color-base-content)' },
2004
2335
  parts: {
2005
2336
  root: {
2006
2337
  base: { display: 'flex', flexDirection: 'column', gap: 'var(--space-md)' },
2338
+ // `orientation="horizontal"` (#276): the cards sit in a row.
2339
+ selectors: { '&[data-orientation="horizontal"]': { flexDirection: 'row', alignItems: 'start' } },
2007
2340
  },
2008
2341
  item: withPresence(disclosurePresence, {
2009
2342
  base: {
@@ -2013,6 +2346,7 @@ export const accordion = {
2013
2346
  overflow: 'hidden',
2014
2347
  },
2015
2348
  states: { open: {}, closed: {} },
2349
+ selectors: { '[data-scope="accordion"][data-part="root"][data-orientation="horizontal"] > &': { flex: '1 1 0', minInlineSize: '0' } },
2016
2350
  }),
2017
2351
  trigger: {
2018
2352
  base: {
@@ -2095,14 +2429,20 @@ const fieldControl = {
2095
2429
  borderRadius: 'var(--radius-field)',
2096
2430
  boxShadow: 'var(--shadow-xs)',
2097
2431
  };
2432
+ /**
2433
+ * A select whose clear-trigger is rendered (#280) — anchored on the root, so
2434
+ * a part inside the trigger can make room for the button laid over it.
2435
+ */
2436
+ const SELECT_CLEARABLE = '[data-scope="select"][data-part="root"]:has(> [data-scope="select"][data-part="clear-trigger"]) &';
2098
2437
  export const select = {
2099
2438
  component: 'select',
2100
2439
  // Accent as text/border ink only — daisy's highlighted item is a neutral
2101
2440
  // base-200 wash, so no `-on-accent` content colour is consumed here.
2102
2441
  tokens: { '--select-accent': 'var(--color-primary)' },
2103
2442
  parts: {
2443
+ // Positioned: the clear-trigger sits over the trigger's inline end.
2104
2444
  root: {
2105
- base: { display: 'inline-flex', flexDirection: 'column' },
2445
+ base: { display: 'inline-flex', flexDirection: 'column', position: 'relative' },
2106
2446
  },
2107
2447
  trigger: {
2108
2448
  base: {
@@ -2115,6 +2455,8 @@ export const select = {
2115
2455
  },
2116
2456
  states: {
2117
2457
  hover: { borderColor: 'var(--color-base-content)' },
2458
+ // Readonly answers to nothing, so it does not invite a click.
2459
+ readonly: { cursor: 'default' },
2118
2460
  disabled: { opacity: 'var(--disabled-opacity)', cursor: 'not-allowed' },
2119
2461
  open: { borderColor: 'var(--select-accent)' },
2120
2462
  closed: {},
@@ -2151,6 +2493,41 @@ export const select = {
2151
2493
  indicator: {
2152
2494
  base: { opacity: '0.6', transition: 'transform var(--duration-normal) var(--ease-standard)' },
2153
2495
  states: { open: { transform: 'rotate(180deg)' }, closed: {} },
2496
+ // Room for the clear-trigger laid over the field (#280).
2497
+ selectors: { [SELECT_CLEARABLE]: { marginInlineStart: 'calc(var(--space-2xl) + var(--space-sm))' } },
2498
+ },
2499
+ // Clears the selection (#280): daisy's ghost-btn over the field's
2500
+ // inline end, just before the chevron — muted like it, the base-200
2501
+ // wash on hover, the base-content ring on focus.
2502
+ 'clear-trigger': {
2503
+ base: {
2504
+ appearance: 'none',
2505
+ position: 'absolute',
2506
+ insetBlock: '0',
2507
+ // Centred in the field at WCAG 2.5.8's 24px target, shrinking
2508
+ // only with a field too short to hold it.
2509
+ marginBlock: 'auto',
2510
+ blockSize: 'min(1.5rem, 100%)',
2511
+ minInlineSize: '1.5rem',
2512
+ insetInlineEnd: 'calc(var(--size-field) * 4 + 1em + var(--space-xs))',
2513
+ display: 'inline-flex',
2514
+ alignItems: 'center',
2515
+ justifyContent: 'center',
2516
+ padding: '0 var(--space-xs)',
2517
+ border: 'none',
2518
+ background: 'transparent',
2519
+ color: 'color-mix(in oklab, var(--color-base-content) 70%, transparent)',
2520
+ font: 'inherit',
2521
+ fontSize: 'var(--text-sm)',
2522
+ lineHeight: 'var(--leading-none)',
2523
+ borderRadius: 'var(--radius-field)',
2524
+ cursor: 'pointer',
2525
+ transition: 'background var(--duration-fast) var(--ease-standard)',
2526
+ },
2527
+ states: {
2528
+ hover: { background: 'var(--color-base-200)', color: 'var(--color-base-content)' },
2529
+ ...focusRing,
2530
+ },
2154
2531
  },
2155
2532
  popup: withPresence(popupPresence('translateY(-4px)'), {
2156
2533
  base: { ...floatingPanel, padding: 'var(--space-md)', minWidth: '13rem' },
@@ -2199,6 +2576,14 @@ export const select = {
2199
2576
  base: { fontSize: 'var(--text-xs)', color: 'var(--select-accent)' },
2200
2577
  states: { selected: {} },
2201
2578
  },
2579
+ // The menu's rule between runs of options (#280).
2580
+ separator: {
2581
+ base: {
2582
+ height: 'var(--border)',
2583
+ margin: 'var(--space-sm) var(--space-md)',
2584
+ background: 'var(--color-base-300)',
2585
+ },
2586
+ },
2202
2587
  },
2203
2588
  variants: {
2204
2589
  color: Object.fromEntries(ROLES.map((c) => [c, { root: { base: {
@@ -2221,8 +2606,12 @@ export const select = {
2221
2606
  // The trigger ramp restates `md` too (unlike `btnSizes`), so its lynx
2222
2607
  // counterpart does as well.
2223
2608
  targets: {
2609
+ web: { parts: { popup: { base: anchoredListbox } } },
2224
2610
  lynx: {
2225
- parts: { trigger: { base: lynxBtnPad(4) } },
2611
+ parts: {
2612
+ trigger: { base: lynxBtnPad(4) },
2613
+ 'clear-trigger': { base: { top: 'var(--space-xs)', bottom: 'var(--space-xs)', right: 'calc(var(--size-field) * 4 + 1em + var(--space-xs))', minWidth: '1.5rem' } },
2614
+ },
2226
2615
  variants: {
2227
2616
  size: { ...lynxBtnSizes('trigger'), md: { trigger: { base: lynxBtnPad(4) } } },
2228
2617
  },
@@ -2845,6 +3234,23 @@ export const combobox = {
2845
3234
  disabled: { cursor: 'not-allowed' },
2846
3235
  },
2847
3236
  },
3237
+ // Clears the value and the text (#280): the chevron's ghost, one
3238
+ // step before it — muted, full ink on hover.
3239
+ 'clear-trigger': {
3240
+ base: {
3241
+ appearance: 'none',
3242
+ border: 'none',
3243
+ background: 'transparent',
3244
+ color: 'inherit',
3245
+ font: 'inherit',
3246
+ opacity: '0.6',
3247
+ padding: '0 var(--space-xs)',
3248
+ lineHeight: 'var(--leading-none)',
3249
+ cursor: 'pointer',
3250
+ transition: 'opacity var(--duration-fast) var(--ease-standard)',
3251
+ },
3252
+ states: { hover: { opacity: '1' } },
3253
+ },
2848
3254
  popup: withPresence(popupPresence('translateY(-4px)'), {
2849
3255
  base: { ...floatingPanel, padding: 'var(--space-md)', minWidth: '13rem' },
2850
3256
  states: { open: {}, closed: {} },
@@ -2900,6 +3306,23 @@ export const combobox = {
2900
3306
  opacity: '0.6',
2901
3307
  },
2902
3308
  },
3309
+ // The list still arriving (#280): the empty row's muted grammar.
3310
+ loading: {
3311
+ base: {
3312
+ padding: 'var(--space-lg)',
3313
+ fontSize: 'var(--text-sm)',
3314
+ textAlign: 'center',
3315
+ opacity: '0.6',
3316
+ },
3317
+ },
3318
+ // The menu's rule between runs of options (#280).
3319
+ separator: {
3320
+ base: {
3321
+ height: 'var(--border)',
3322
+ margin: 'var(--space-sm) var(--space-md)',
3323
+ background: 'var(--color-base-300)',
3324
+ },
3325
+ },
2903
3326
  },
2904
3327
  variants: {
2905
3328
  color: Object.fromEntries(ROLES.map((c) => [c, { root: { base: {
@@ -2922,6 +3345,9 @@ export const combobox = {
2922
3345
  input: ['focus-visible'],
2923
3346
  trigger: ['focus-visible'],
2924
3347
  },
3348
+ targets: {
3349
+ web: { parts: { popup: { base: anchoredListbox } } },
3350
+ },
2925
3351
  };
2926
3352
  // daisy "btn" that stays pressed: the off state is the neutral btn, the on
2927
3353
  // state is the solid accent fill.
@@ -3520,7 +3946,9 @@ export const treeView = {
3520
3946
  opacity: '0.6',
3521
3947
  transition: 'transform var(--duration-fast) var(--ease-standard)',
3522
3948
  },
3523
- states: { open: { transform: 'rotate(90deg)' }, closed: {} },
3949
+ // Loading: the chevron halts at daisy's own collapse-arrow angle —
3950
+ // static (no loop), ink unchanged, so contrast is untouched.
3951
+ states: { open: { transform: 'rotate(90deg)' }, closed: {}, loading: { transform: 'rotate(45deg)' } },
3524
3952
  // The glyph is element text the runtime renders (`TreeView.tsx`), not
3525
3953
  // `content:`, so the `:dir(rtl)` swap the submenu chevron uses is not
3526
3954
  // available here — a mirror is its equivalent. `scale` composes
@@ -3539,6 +3967,9 @@ export const treeView = {
3539
3967
  states: { open: {}, closed: {} },
3540
3968
  },
3541
3969
  },
3970
+ // Open-and-loading content lays out exactly as open content does — the
3971
+ // indicator and the treeitem's aria-busy carry the state.
3972
+ sameAs: { 'branch-content': { loading: 'open' } },
3542
3973
  variants: {
3543
3974
  // A tree colours one thing: the selected row. Everything else is
3544
3975
  // structure, and tinting it would fight the content.
@@ -3567,6 +3998,32 @@ export const treeView = {
3567
3998
  },
3568
3999
  },
3569
4000
  };
4001
+ /**
4002
+ * The affordances inside a text field (#281): daisy's in-field label ink and
4003
+ * the ghost circle button, both following the size step.
4004
+ */
4005
+ const affixInk = 'color-mix(in oklab, var(--color-base-content) 70%, transparent)';
4006
+ const fieldButton = {
4007
+ appearance: 'none',
4008
+ border: 'none',
4009
+ background: 'transparent',
4010
+ color: affixInk,
4011
+ borderRadius: '9999px',
4012
+ alignSelf: 'center',
4013
+ flex: 'none',
4014
+ order: '1',
4015
+ marginInlineEnd: 'var(--space-xs)',
4016
+ padding: 'var(--space-2xs) var(--space-xs)',
4017
+ fontSize: 'var(--text-sm)',
4018
+ fontWeight: 'var(--weight-semibold)',
4019
+ lineHeight: 'var(--leading-none)',
4020
+ cursor: 'pointer',
4021
+ };
4022
+ const affixSize = (fontSize) => ({
4023
+ adornment: { base: { fontSize } },
4024
+ 'clear-trigger': { base: { fontSize } },
4025
+ 'visibility-trigger': { base: { fontSize } },
4026
+ });
3570
4027
  /**
3571
4028
  * daisy "input" flavor: the shared field box with nothing in it but the text.
3572
4029
  * Same `fieldControl` metrics as select's trigger, combobox's control and the
@@ -3593,6 +4050,7 @@ export const input = {
3593
4050
  base: {
3594
4051
  display: 'inline-flex',
3595
4052
  alignItems: 'stretch',
4053
+ gap: 'var(--space-2xs)',
3596
4054
  ...fieldControl,
3597
4055
  overflow: 'hidden',
3598
4056
  transition: 'border-color var(--duration-fast) var(--ease-standard)',
@@ -3629,6 +4087,43 @@ export const input = {
3629
4087
  '&::placeholder': { color: 'color-mix(in oklab, var(--color-base-content) 60%, transparent)' },
3630
4088
  },
3631
4089
  },
4090
+ // daisy's `label` inside `.input`: a quiet prefix/suffix at one edge,
4091
+ // ordered logically so it flips with the reading direction.
4092
+ adornment: {
4093
+ base: {
4094
+ display: 'inline-flex',
4095
+ alignItems: 'center',
4096
+ flex: 'none',
4097
+ color: affixInk,
4098
+ fontSize: 'var(--text-sm)',
4099
+ lineHeight: 'var(--leading-none)',
4100
+ },
4101
+ states: { disabled: {} },
4102
+ selectors: {
4103
+ '&[data-placement="start"]': { order: '-1', paddingInlineStart: 'var(--space-md)' },
4104
+ '&[data-placement="end"]': { order: '1', paddingInlineEnd: 'var(--space-md)' },
4105
+ },
4106
+ },
4107
+ // `btn btn-ghost btn-circle btn-xs` inside the field.
4108
+ 'clear-trigger': {
4109
+ base: fieldButton,
4110
+ states: {
4111
+ hover: { color: 'var(--color-base-content)', background: 'var(--color-base-200)' },
4112
+ disabled: { cursor: 'not-allowed' },
4113
+ ...focusRing,
4114
+ },
4115
+ },
4116
+ // Shown: the ghost button's active fill and full ink.
4117
+ 'visibility-trigger': {
4118
+ base: fieldButton,
4119
+ states: {
4120
+ on: { color: 'var(--color-base-content)', background: 'var(--color-base-200)' },
4121
+ off: {},
4122
+ hover: { color: 'var(--color-base-content)', background: 'var(--color-base-200)' },
4123
+ disabled: { cursor: 'not-allowed' },
4124
+ ...focusRing,
4125
+ },
4126
+ },
3632
4127
  },
3633
4128
  // The visible ring lives on `control`; the input delegates.
3634
4129
  skipStates: { input: ['focus-visible'] },
@@ -3641,13 +4136,13 @@ export const input = {
3641
4136
  // The control carries the height off the shared field ramp; the text
3642
4137
  // follows so the box stays proportional.
3643
4138
  size: {
3644
- xs: { control: { base: { height: fieldHeight('xs') } }, input: { base: { fontSize: 'var(--text-xs)', padding: 'var(--space-2xs) var(--space-xs)' } } },
3645
- sm: { control: { base: { height: fieldHeight('sm') } }, input: { base: { fontSize: 'var(--text-xs)', padding: 'var(--space-xs) var(--space-sm)' } } },
4139
+ xs: { control: { base: { height: fieldHeight('xs') } }, input: { base: { fontSize: 'var(--text-xs)', padding: 'var(--space-2xs) var(--space-xs)' } }, ...affixSize('var(--text-xs)') },
4140
+ sm: { control: { base: { height: fieldHeight('sm') } }, input: { base: { fontSize: 'var(--text-xs)', padding: 'var(--space-xs) var(--space-sm)' } }, ...affixSize('var(--text-xs)') },
3646
4141
  // `md` is the un-attributed render: the base already IS the
3647
4142
  // middle step.
3648
4143
  md: {},
3649
- lg: { control: { base: { height: fieldHeight('lg') } }, input: { base: { fontSize: 'var(--text-md)', padding: 'var(--space-md) var(--space-lg)' } } },
3650
- xl: { control: { base: { height: fieldHeight('xl') } }, input: { base: { fontSize: 'var(--text-lg)', padding: 'var(--space-lg) var(--space-xl)' } } },
4144
+ lg: { control: { base: { height: fieldHeight('lg') } }, input: { base: { fontSize: 'var(--text-md)', padding: 'var(--space-md) var(--space-lg)' } }, ...affixSize('var(--text-md)') },
4145
+ xl: { control: { base: { height: fieldHeight('xl') } }, input: { base: { fontSize: 'var(--text-lg)', padding: 'var(--space-lg) var(--space-xl)' } }, ...affixSize('var(--text-lg)') },
3651
4146
  },
3652
4147
  },
3653
4148
  };
@@ -4363,8 +4858,17 @@ export const stats = {
4363
4858
  tokens: { '--stats-accent': 'var(--color-base-content)' },
4364
4859
  parts: {
4365
4860
  root: {
4861
+ // The row's own scroll box (#43): a value never wraps or shrinks
4862
+ // (`nowrap` below), so a row too wide for its container scrolls
4863
+ // inside the root — daisyUI's own `.stats` behaviour, and Table's
4864
+ // and Pagination's answer — instead of pushing the page sideways.
4865
+ // Items keep their flex automatic minimum — their min-content
4866
+ // width — so the overflow lands here rather than in the next
4867
+ // item. No ring room: a stat is not focusable, and anything a
4868
+ // consumer puts in one sits inside the item's own padding.
4366
4869
  base: {
4367
4870
  display: 'flex',
4871
+ overflowX: 'auto',
4368
4872
  border: 'var(--border) solid var(--color-base-200)',
4369
4873
  borderRadius: 'var(--radius-box)',
4370
4874
  background: 'var(--color-base-100)',
@@ -4401,6 +4905,8 @@ export const stats = {
4401
4905
  value: {
4402
4906
  base: {
4403
4907
  gridColumn: '1',
4908
+ // A figure is read whole: `$12 930` never breaks at its space.
4909
+ whiteSpace: 'nowrap',
4404
4910
  fontSize: 'var(--text-2xl)',
4405
4911
  fontWeight: 'var(--weight-bold)',
4406
4912
  fontVariantNumeric: 'tabular-nums',
@@ -4423,8 +4929,19 @@ export const stats = {
4423
4929
  },
4424
4930
  },
4425
4931
  variants: {
4426
- color: Object.fromEntries(ROLES.map((c) => [c, { root: { base: {
4427
- '--stats-accent': `var(--color-${c})`,
4932
+ // Keyed on the ITEM, not the root (#161): the item re-carries
4933
+ // `color`, so a stat's own value outranks the row's, and the value
4934
+ // inside it inherits whichever won. A root colour still reaches
4935
+ // every item through the carrier's donut.
4936
+ //
4937
+ // The value is TEXT on the page, so the raw role is not always a
4938
+ // readable ink: measured by the text matrix once the item re-carried
4939
+ // the axis (#161), light warning is 1.76:1 and dark neutral 1.26:1
4940
+ // on base-100. `roleInk` is the shared answer for a role used as ink
4941
+ // (a per-role share of the role over base-content, which flips with
4942
+ // the scheme), the same one the field and button text use.
4943
+ color: Object.fromEntries(ROLES.map((c) => [c, { item: { base: {
4944
+ '--stats-accent': roleInk(c),
4428
4945
  } } }])),
4429
4946
  size: {
4430
4947
  xs: { value: { base: { fontSize: 'var(--text-lg)' } } },
@@ -4470,7 +4987,11 @@ export const timeline = {
4470
4987
  padding: '0',
4471
4988
  },
4472
4989
  selectors: {
4473
- '&[data-orientation="horizontal"]': { flexDirection: 'row' },
4990
+ // A horizontal timeline is a row of steps whose width follows
4991
+ // the step count, not the container: at phone width it scrolls
4992
+ // inside its own box, the answer Table and Pagination give
4993
+ // (#45), rather than widening the page.
4994
+ '&[data-orientation="horizontal"]': { flexDirection: 'row', overflowX: 'auto' },
4474
4995
  },
4475
4996
  },
4476
4997
  /**
@@ -5164,9 +5685,27 @@ export const pagination = {
5164
5685
  '--pg-accent-content': 'var(--color-primary-content)',
5165
5686
  '--pg-size': 'calc(var(--size-field) * 10)',
5166
5687
  '--pg-font': 'var(--text-sm)',
5688
+ // How far the focus ring reaches outside a control: the root's
5689
+ // scroll box pads by it so the ring is never clipped (#44).
5690
+ '--pg-ring-room': '4px',
5167
5691
  },
5168
5692
  parts: {
5169
- root: { base: { display: 'flex', alignItems: 'center', gap: 'var(--space-2xs)' } },
5693
+ root: {
5694
+ // The row's own scroll box (#44): the window is constant-width by
5695
+ // design, so a narrow container scrolls it rather than clipping
5696
+ // it — Table's answer. The padding is room for the focus ring's
5697
+ // 2px + 2px offset, which the scroll box would otherwise clip at
5698
+ // the row's edges; the matching scroll padding keeps it clear
5699
+ // when focus scrolls a page into view.
5700
+ base: {
5701
+ display: 'flex',
5702
+ alignItems: 'center',
5703
+ gap: 'var(--space-2xs)',
5704
+ overflowX: 'auto',
5705
+ padding: 'var(--pg-ring-room)',
5706
+ scrollPaddingInline: 'var(--pg-ring-room)',
5707
+ },
5708
+ },
5170
5709
  item: {
5171
5710
  base: {
5172
5711
  display: 'inline-flex',
@@ -5651,6 +6190,11 @@ export const table = {
5651
6190
  borderRadius: 'var(--radius-box)',
5652
6191
  background: 'var(--color-base-100)',
5653
6192
  },
6193
+ // The scroll box is a keyboard stop (#270): daisy's ink ring,
6194
+ // drawn inside the box (negative offset) — the root is as wide
6195
+ // as its column, so an outward ring would be clipped by
6196
+ // whatever scrolls around it.
6197
+ states: { 'focus-visible': { outline: '2px solid var(--color-base-content)', outlineOffset: '-2px' } },
5654
6198
  // Stacked (Table.Root stack, #55): zero lays each row out as a block
5655
6199
  // below the table's breakpoint; the card it becomes is drawn here.
5656
6200
  at: tableStackAt(tokens, 'root', { border: '0', borderRadius: '0', background: 'transparent' }),
@@ -5840,7 +6384,15 @@ export const fileUpload = {
5840
6384
  borderRadius: 'var(--radius-field)',
5841
6385
  background: 'var(--color-base-100)',
5842
6386
  },
5843
- states: { disabled: { opacity: 'var(--disabled-opacity)' } },
6387
+ states: {
6388
+ disabled: { opacity: 'var(--disabled-opacity)' },
6389
+ // A rejected file the app renders through Item: daisy's
6390
+ // error border over a faint error wash.
6391
+ invalid: {
6392
+ borderColor: 'var(--color-error)',
6393
+ background: 'color-mix(in oklab, var(--color-error) 8%, var(--color-base-100))',
6394
+ },
6395
+ },
5844
6396
  },
5845
6397
  'item-name': {
5846
6398
  base: {
@@ -5876,6 +6428,26 @@ export const fileUpload = {
5876
6428
  ...focusRing,
5877
6429
  },
5878
6430
  },
6431
+ // daisy's btn-ghost btn-xs: the remove button's pill, at text size.
6432
+ 'clear-trigger': {
6433
+ base: {
6434
+ appearance: 'none',
6435
+ border: 'none',
6436
+ background: 'transparent',
6437
+ color: 'inherit',
6438
+ borderRadius: '9999px',
6439
+ padding: 'var(--space-2xs) var(--space-sm)',
6440
+ fontSize: 'var(--fu-font)',
6441
+ fontWeight: 'var(--weight-semibold)',
6442
+ lineHeight: 'var(--leading-none)',
6443
+ cursor: 'pointer',
6444
+ },
6445
+ states: {
6446
+ hover: { background: 'var(--color-base-200)' },
6447
+ disabled: { opacity: 'var(--disabled-opacity)', cursor: 'not-allowed' },
6448
+ ...focusRing,
6449
+ },
6450
+ },
5879
6451
  },
5880
6452
  variants: {
5881
6453
  color: Object.fromEntries(ROLES.map((c) => [c, { root: { base: {
@@ -6216,6 +6788,9 @@ export const diff = {
6216
6788
  background: 'var(--color-base-100)',
6217
6789
  borderRadius: 'var(--radius-box)',
6218
6790
  },
6791
+ // The root holds the images, which are content: disabled is the
6792
+ // handle's to show (#272).
6793
+ states: { disabled: {} },
6219
6794
  },
6220
6795
  before: {
6221
6796
  base: { gridArea: '1 / 1', minWidth: '0' },
@@ -6245,6 +6820,9 @@ export const diff = {
6245
6820
  },
6246
6821
  states: {
6247
6822
  ...focusRing,
6823
+ // daisy's disabled look (#272): the uniform fade, and no grab
6824
+ // cursor — the images are content and stay unfaded.
6825
+ disabled: { opacity: 'var(--disabled-opacity)', cursor: 'not-allowed' },
6248
6826
  pressed: { '--diff-accent': 'color-mix(in oklab, var(--color-base-content) 85%, var(--color-base-100))' },
6249
6827
  },
6250
6828
  selectors: {
@@ -6318,7 +6896,7 @@ export const diff = {
6318
6896
  };
6319
6897
  export const recipes = [
6320
6898
  tabs, collapsible, switchRecipe, dialog, popover, tooltip, menu,
6321
- field, checkbox, radioGroup, progress, slider, accordion, select, button, avatar, toast, combobox,
6899
+ field, checkbox, checkboxGroup, radioGroup, progress, slider, accordion, select, button, avatar, toast, combobox,
6322
6900
  toggle, toggleGroup, numberInput, ratingGroup, treeView, input, textarea,
6323
6901
  card, alert, emptyState, badge, divider, skeleton, spinner,
6324
6902
  kbd, status, indicator, stats, timeline, chat, radialProgress, join,