@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/src/recipes.ts CHANGED
@@ -155,14 +155,48 @@ const disclosurePresence: PartStyles = {
155
155
  '&::details-content': {
156
156
  blockSize: '0',
157
157
  overflow: 'hidden',
158
+ },
159
+ // Open only: a close removes `open`, which hides the content before
160
+ // any transition could run — the panel animates that half (#276).
161
+ '&[open]::details-content': {
162
+ blockSize: 'auto',
158
163
  transition: 'block-size var(--duration-normal) var(--ease-standard), '
159
- + 'content-visibility var(--duration-normal) allow-discrete',
164
+ + 'content-visibility var(--duration-normal) allow-discrete',
160
165
  },
161
- '&[open]::details-content': { blockSize: 'auto' },
162
166
  },
163
- at: { 'reduced-motion': { selectors: { '&::details-content': { transition: 'none' } } } },
167
+ at: { 'reduced-motion': { selectors: { '&[open]::details-content': { transition: 'none' } } } },
164
168
  };
165
169
 
170
+ /**
171
+ * The panel's close (#276). Removing `open` hides a `<details>` at once, so
172
+ * the close cannot live on `::details-content` the way the open does: zero
173
+ * flips the panel to `closed` first and holds the element open until the
174
+ * panel's own animation has played. The runtime publishes the panel's
175
+ * measured height as `--<scope>-panel-height`, which is the one value this
176
+ * needs — `auto` is not an endpoint any engine but Chromium can animate.
177
+ * `border-box` so the measured height (content + padding) is the whole box.
178
+ * Web-only: the property is written by the DOM runtime (`RUNTIME_PROPERTIES`).
179
+ */
180
+ const disclosureExit = (scope: 'collapsible' | 'accordion'): RecipeInput['targets'] => ({
181
+ web: {
182
+ parts: {
183
+ panel: {
184
+ states: {
185
+ closed: {
186
+ boxSizing: 'border-box',
187
+ animation: `${scope}-panel-exit var(--duration-normal) var(--ease-standard)`,
188
+ },
189
+ },
190
+ at: { 'reduced-motion': { states: { closed: { animation: 'none' } } } },
191
+ },
192
+ },
193
+ keyframes: {
194
+ [`${scope}-panel-exit`]:
195
+ `from { block-size: var(--${scope}-panel-height); } to { block-size: 0; padding-block: 0; }`,
196
+ },
197
+ },
198
+ });
199
+
166
200
  /**
167
201
  * Merge presence into a part's own styles per KEY, not per block: a recipe
168
202
  * that already writes `states: { open: {} }` — the "deliberately unstyled"
@@ -216,14 +250,33 @@ const withPresence = (presence: PartStyles, styles: PartStyles): PartStyles => (
216
250
  */
217
251
  export const tabs: RecipeInput = {
218
252
  component: 'tabs',
253
+ tokens: {
254
+ // How far the focus ring reaches outside a tab (2px + 2px offset):
255
+ // the list's scroll box pads by it so the ring is never clipped (#45).
256
+ '--tabs-ring-room': '4px',
257
+ },
219
258
  parts: {
220
259
  root: {
221
260
  base: { display: 'flex', flexDirection: 'column', gap: 'var(--space-lg)' },
222
261
  },
223
262
  list: {
263
+ // The row's own scroll box (#45), Pagination's and Stats' answer:
264
+ // a row of labels wider than its column (a phone, a wide fallback
265
+ // face) scrolls instead of pushing the page sideways.
266
+ // `max-inline-size` (border-box, so it includes the padding)
267
+ // gives the shrink-to-fit list a width to scroll in. The padding is room for the focus ring, which the
268
+ // scroll box would otherwise clip at the row's edges; the
269
+ // matching scroll padding keeps it clear when focus scrolls a
270
+ // tab into view. The box variant's own padding (space-xs, the
271
+ // same 4px) replaces it there.
224
272
  base: {
225
273
  display: 'inline-flex',
226
274
  alignSelf: 'flex-start',
275
+ boxSizing: 'border-box',
276
+ maxInlineSize: '100%',
277
+ overflowX: 'auto',
278
+ padding: 'var(--tabs-ring-room)',
279
+ scrollPaddingInline: 'var(--tabs-ring-room)',
227
280
  },
228
281
  },
229
282
  tab: {
@@ -261,6 +314,10 @@ export const tabs: RecipeInput = {
261
314
  ...focusRing,
262
315
  },
263
316
  },
317
+ // daisy draws each flavor's active mark on the tab itself (the
318
+ // underline, the lift, the pill) and none of them slides, so a
319
+ // `Tabs.Indicator` renders nothing here (#283).
320
+ indicator: { base: { display: 'none' } },
264
321
  panel: {
265
322
  base: { fontSize: 'var(--text-md)' },
266
323
  states: { active: {}, inactive: {} },
@@ -536,6 +593,7 @@ const disclosureSizes: Record<string, Record<string, PartStyles>> = {
536
593
  // daisy "collapse collapse-arrow" flavor.
537
594
  export const collapsible: RecipeInput = {
538
595
  component: 'collapsible',
596
+ targets: disclosureExit('collapsible'),
539
597
  // Public to a design system derived from this one (#73).
540
598
  hooks: {
541
599
  properties: {
@@ -653,6 +711,8 @@ export const switchRecipe: RecipeInput = {
653
711
  root: {
654
712
  base: { display: 'inline-flex', alignItems: 'center', gap: 'var(--space-lg)', cursor: 'pointer' },
655
713
  states: {
714
+ // Readonly answers to nothing, so it does not invite a click.
715
+ readonly: { cursor: 'default' },
656
716
  disabled: { opacity: 'var(--disabled-opacity)', cursor: 'not-allowed' },
657
717
  checked: {},
658
718
  unchecked: {},
@@ -787,6 +847,29 @@ export const switchRecipe: RecipeInput = {
787
847
  + '0 1px color-mix(in oklab, currentColor calc(var(--depth) * 10%), #0000)',
788
848
  transition: 'background-color var(--duration-instant) var(--ease-standard)',
789
849
  },
850
+ at: {
851
+ // The knob is `currentColor` background plus shadows:
852
+ // forced colours revalue the one to Canvas and drop the
853
+ // others, leaving two empty pill outlines that read the
854
+ // same on and off (#189). Opt the knob out of forcing
855
+ // and paint it in system colours — CanvasText at rest,
856
+ // Highlight once checked. The noise and the shadows go
857
+ // too: unforced, they would mix the forced ink back in.
858
+ 'forced-colors': {
859
+ base: {
860
+ forcedColorAdjust: 'none',
861
+ backgroundColor: 'CanvasText',
862
+ backgroundImage: 'none',
863
+ boxShadow: 'none',
864
+ },
865
+ states: { checked: { backgroundColor: 'Highlight' } },
866
+ // A disabled switch draws in GrayText, the forced palette's
867
+ // own disabled ink, not full-strength ink faded by an
868
+ // author opacity the user's theme cannot style. Keyed off
869
+ // the control's flag, which outranks `checked`.
870
+ selectors: { '[data-scope="switch"][data-part="control"][data-disabled] &': { backgroundColor: 'GrayText' } },
871
+ },
872
+ },
790
873
  },
791
874
  },
792
875
  },
@@ -1067,6 +1150,23 @@ const floatingPanel: NonNullable<PartStyles['base']> = {
1067
1150
  boxShadow: 'var(--shadow-lg)',
1068
1151
  };
1069
1152
 
1153
+ /**
1154
+ * daisy's dropdown menu for a listbox, sized by the geometry the
1155
+ * anchored-position strategy publishes (#278): at least the width of the
1156
+ * control that opened it (`--anchor-width`, daisy's 13rem floor otherwise)
1157
+ * and never taller than the room on the side it opened to
1158
+ * (`--available-height`), scrolling past a 20rem cap. Border-box, so both
1159
+ * bounds measure the box the strategy positions. Runtime-published
1160
+ * properties are web-only, so this is each listbox recipe's `targets.web`;
1161
+ * lynx keeps the shared 13rem.
1162
+ */
1163
+ const anchoredListbox: CssProps = {
1164
+ boxSizing: 'border-box',
1165
+ minWidth: 'var(--anchor-width, 13rem)',
1166
+ maxHeight: 'min(20rem, var(--available-height, 20rem))',
1167
+ overflowY: 'auto',
1168
+ };
1169
+
1070
1170
  export const popover: RecipeInput = {
1071
1171
  component: 'popover',
1072
1172
  parts: {
@@ -1432,6 +1532,8 @@ export const checkbox: RecipeInput = {
1432
1532
  root: {
1433
1533
  base: { display: 'inline-flex', alignItems: 'center', gap: 'var(--space-md)', cursor: 'pointer' },
1434
1534
  states: {
1535
+ // Readonly answers to nothing, so it does not invite a click.
1536
+ readonly: { cursor: 'default' },
1435
1537
  disabled: { opacity: 'var(--disabled-opacity)', cursor: 'not-allowed' },
1436
1538
  checked: {}, unchecked: {}, indeterminate: {},
1437
1539
  },
@@ -1504,6 +1606,8 @@ export const checkbox: RecipeInput = {
1504
1606
  // `invalid` is semantic, not an accent: it stays error under
1505
1607
  // every colour variant, on purpose.
1506
1608
  invalid: { borderColor: 'var(--color-error)' },
1609
+ // Readonly answers to nothing, so it does not invite a click.
1610
+ readonly: { cursor: 'default' },
1507
1611
  disabled: {},
1508
1612
  },
1509
1613
  },
@@ -1630,13 +1734,9 @@ export const radioGroup: RecipeInput = {
1630
1734
  parts: {
1631
1735
  root: {
1632
1736
  base: { display: 'flex', flexDirection: 'column', gap: 'var(--space-md)' },
1737
+ // `invalid` paints on each `item-control`, which carries the flag
1738
+ // itself (#267) — the root only groups the items.
1633
1739
  states: { invalid: {}, required: {} },
1634
- selectors: {
1635
- // `invalid` is a fact about the GROUP — `item-control` carries
1636
- // no flag of its own — and it is semantic: it stays error
1637
- // under every colour variant.
1638
- '&[data-invalid] [data-part="item-control"]': { borderColor: 'var(--color-error)' },
1639
- },
1640
1740
  },
1641
1741
  label: {
1642
1742
  base: { fontSize: 'var(--text-sm)', fontWeight: 'var(--weight-semibold)' },
@@ -1645,6 +1745,8 @@ export const radioGroup: RecipeInput = {
1645
1745
  item: {
1646
1746
  base: { display: 'inline-flex', alignItems: 'center', gap: 'var(--space-md)', cursor: 'pointer' },
1647
1747
  states: {
1748
+ // Readonly answers to nothing, so it does not invite a click.
1749
+ readonly: { cursor: 'default' },
1648
1750
  disabled: { opacity: 'var(--disabled-opacity)', cursor: 'not-allowed' },
1649
1751
  checked: {}, unchecked: {},
1650
1752
  },
@@ -1679,6 +1781,10 @@ export const radioGroup: RecipeInput = {
1679
1781
  animation: 'var(--duration-fast) var(--ease-standard) zero-daisy-radio',
1680
1782
  },
1681
1783
  unchecked: {},
1784
+ // `invalid` is semantic: it stays error under every colour
1785
+ // variant, and after `checked` so it holds whichever radio is
1786
+ // chosen.
1787
+ invalid: { borderColor: 'var(--color-error)' },
1682
1788
  'focus-visible': { outline: '2px solid var(--radio-accent)', outlineOffset: '2px' },
1683
1789
  disabled: {},
1684
1790
  },
@@ -1750,6 +1856,48 @@ export const radioGroup: RecipeInput = {
1750
1856
  },
1751
1857
  };
1752
1858
 
1859
+ /**
1860
+ * CheckboxGroup (#282) — daisy has no group component; its forms stack
1861
+ * `.checkbox` rows under a `fieldset-legend`. The boxes keep the checkbox
1862
+ * recipe; the group owns the stack and the legend, which speaks Field's
1863
+ * label (colour through `roleInk`, #210).
1864
+ */
1865
+ export const checkboxGroup: RecipeInput = {
1866
+ component: 'checkbox-group',
1867
+ tokens: { '--checkbox-group-accent': 'var(--color-base-content)' },
1868
+ parts: {
1869
+ root: {
1870
+ base: { display: 'flex', flexDirection: 'column', gap: 'var(--space-md)' },
1871
+ // `invalid` and `readonly` paint on each box — the root only
1872
+ // lays the boxes out.
1873
+ states: { invalid: {}, required: {}, readonly: {} },
1874
+ selectors: {
1875
+ '&[data-orientation="horizontal"]': { flexDirection: 'row', flexWrap: 'wrap', columnGap: 'var(--space-lg)' },
1876
+ },
1877
+ },
1878
+ label: {
1879
+ base: { fontSize: 'var(--text-sm)', fontWeight: 'var(--weight-semibold)', color: 'var(--checkbox-group-accent)' },
1880
+ states: { disabled: { opacity: 'var(--disabled-opacity)' } },
1881
+ selectors: {
1882
+ '&[data-required]::after': { content: '" *"', color: roleInk('error') },
1883
+ },
1884
+ },
1885
+ },
1886
+ variants: {
1887
+ color: Object.fromEntries(ROLES.map((c) => [c, { root: { base: {
1888
+ '--checkbox-group-accent': roleInk(c),
1889
+ } } }])),
1890
+ size: {
1891
+ xs: { root: { base: { gap: 'var(--space-sm)' } }, label: { base: { fontSize: 'var(--text-xs)' } } },
1892
+ sm: { label: { base: { fontSize: 'var(--text-xs)' } } },
1893
+ md: {},
1894
+ lg: { label: { base: { fontSize: 'var(--text-md)' } } },
1895
+ xl: { root: { base: { gap: 'var(--space-lg)' } }, label: { base: { fontSize: 'var(--text-lg)' } } },
1896
+ },
1897
+ },
1898
+ skipStates: { label: ['invalid'] },
1899
+ };
1900
+
1753
1901
  // --------------------------------------------------------------------------
1754
1902
  // 4. progress — replace the whole export (only the two radius lines move)
1755
1903
  // --------------------------------------------------------------------------
@@ -1875,7 +2023,15 @@ export const progress: RecipeInput = {
1875
2023
 
1876
2024
  export const slider: RecipeInput = {
1877
2025
  component: 'slider',
1878
- tokens: { '--slider-accent': 'var(--color-primary)' },
2026
+ // daisy's `.range` metrics: the thumb is `--range-thumb-size`
2027
+ // (×6 of `--size-selector` at md, ×4…×8 across the ramp — the same
2028
+ // selector ramp checkbox rides) and the track is half of it. Both the
2029
+ // native control and the composed parts read these.
2030
+ tokens: {
2031
+ '--slider-accent': 'var(--color-primary)',
2032
+ '--slider-track-size': 'calc(var(--size-selector) * 3)',
2033
+ '--slider-thumb-size': 'calc(var(--size-selector) * 6)',
2034
+ },
1879
2035
  parts: {
1880
2036
  root: {
1881
2037
  base: { display: 'flex', flexDirection: 'column', gap: 'var(--space-sm)', width: '100%' },
@@ -1885,30 +2041,106 @@ export const slider: RecipeInput = {
1885
2041
  base: { fontSize: 'var(--text-sm)', fontWeight: 'var(--weight-semibold)' },
1886
2042
  states: { disabled: {} },
1887
2043
  },
2044
+ // A custom skin (`appearance: none`), ported from daisyUI 5's
2045
+ // `range.css`: a `--radius-selector` track half the thumb's height,
2046
+ // and daisy's real range thumb — a `base-100` knob inside a
2047
+ // `.25rem` ring of the fill ink (daisy's `--range-p`), carrying the
2048
+ // same `--depth` inset shading the checkbox and radio wear. daisy
2049
+ // throws the elapsed fill as a 100cqw box-shadow off the thumb and
2050
+ // clips it with `overflow: hidden`; here the track reads the
2051
+ // runtime-published `--slider-percent` as a gradient stop instead,
2052
+ // as every other skin does. The fill is deepened toward
2053
+ // `base-content` the way `progressFill` deepens daisy's bar — a raw
2054
+ // role on the base-300 rail is exactly the #210/#228 bug class.
2055
+ //
2056
+ // The rebuild is also the correctness move (see the kit skill): Blink
2057
+ // treats range inputs as ALWAYS `:focus-visible`, even on mouse
2058
+ // focus, so the base kills the native outline and the
2059
+ // `focus-visible` state — compiled to the runtime's keyboard-only
2060
+ // `[data-focus-visible]` flag — draws the ring only when it means it.
1888
2061
  control: {
1889
- base: { width: '100%', accentColor: 'var(--slider-accent)', cursor: 'pointer' },
2062
+ base: {
2063
+ appearance: 'none',
2064
+ width: '100%',
2065
+ height: 'var(--slider-thumb-size)',
2066
+ margin: '0',
2067
+ background: 'transparent',
2068
+ cursor: 'pointer',
2069
+ outline: 'none',
2070
+ // Kept for the forced-colors fallback below, where native
2071
+ // rendering takes over and still honours the accent.
2072
+ accentColor: 'var(--slider-accent)',
2073
+ // `--slider-fill` and `--slider-track` live in targets.web
2074
+ // below: the fill is a color-mix() the lynx emitter drops,
2075
+ // and the track reads the runtime-published
2076
+ // `--slider-percent`, a web-only mechanism.
2077
+ },
1890
2078
  states: {
2079
+ // Readonly answers to nothing, so it does not invite a click.
2080
+ readonly: { cursor: 'default' },
1891
2081
  disabled: { cursor: 'not-allowed' },
1892
2082
  'focus-visible': { outline: '2px solid var(--slider-accent)', outlineOffset: '2px' },
1893
2083
  // `invalid` is semantic: it stays error under every colour
1894
- // variant, on purpose.
1895
- invalid: { accentColor: 'var(--color-error)' },
2084
+ // variant, on purpose — the accent indirection swaps the
2085
+ // fill, the thumb ring, the focus ring and the forced-colors
2086
+ // fallback together.
2087
+ invalid: { '--slider-accent': 'var(--color-error)' },
2088
+ },
2089
+ selectors: {
2090
+ // Vendor pseudos cannot share a selector list — one unknown
2091
+ // selector invalidates the whole rule — so each engine gets
2092
+ // its own copy reading the shared custom properties.
2093
+ '&::-webkit-slider-runnable-track': {
2094
+ height: 'var(--slider-track-size)',
2095
+ borderRadius: 'var(--radius-selector)',
2096
+ background: 'var(--slider-track)',
2097
+ },
2098
+ '&::-webkit-slider-thumb': {
2099
+ appearance: 'none',
2100
+ boxSizing: 'border-box',
2101
+ width: 'var(--slider-thumb-size)',
2102
+ height: 'var(--slider-thumb-size)',
2103
+ marginTop: 'calc((var(--slider-track-size) - var(--slider-thumb-size)) / 2)',
2104
+ borderRadius: 'calc(var(--radius-selector) + min(0.25rem, var(--radius-selector) * 3))',
2105
+ border: '0.25rem solid var(--slider-fill)',
2106
+ background: 'var(--color-base-100)',
2107
+ boxShadow: '0 -1px var(--depth-shade) inset, 0 8px 0 -4px var(--depth-sheen) inset',
2108
+ },
2109
+ '&::-moz-range-track': {
2110
+ height: 'var(--slider-track-size)',
2111
+ borderRadius: 'var(--radius-selector)',
2112
+ background: 'var(--slider-track)',
2113
+ },
2114
+ '&::-moz-range-thumb': {
2115
+ boxSizing: 'border-box',
2116
+ width: 'var(--slider-thumb-size)',
2117
+ height: 'var(--slider-thumb-size)',
2118
+ borderRadius: 'calc(var(--radius-selector) + min(0.25rem, var(--radius-selector) * 3))',
2119
+ border: '0.25rem solid var(--slider-fill)',
2120
+ background: 'var(--color-base-100)',
2121
+ boxShadow: '0 -1px var(--depth-shade) inset, 0 8px 0 -4px var(--depth-sheen) inset',
2122
+ },
2123
+ },
2124
+ at: {
2125
+ // Native rendering knows forced colors better than a custom
2126
+ // skin; the retained accentColor keeps the fallback branded.
2127
+ 'forced-colors': { base: { appearance: 'auto' } },
1896
2128
  },
1897
2129
  },
1898
- // The composed range projection (#325). The native control above
1899
- // stays UA-drawn behind `accentColor`; the composed parts restate the
1900
- // same accent as real boxes. The fill is deepened toward
1901
- // `base-content` the way `progressFill` deepens daisy's bar — a raw
1902
- // role on the base-300 rail is exactly the #210/#228 bug class.
2130
+ // The composed range projection (#325): the same metrics as the
2131
+ // native control above, restating its accent as real boxes. The
2132
+ // fill is the same deepened ink the native track paints.
1903
2133
  track: {
1904
2134
  base: {
1905
- height: 'calc(var(--size-selector) * 2)',
1906
- marginBlock: 'calc(var(--size-selector) * 1.5)',
2135
+ height: 'var(--slider-track-size)',
2136
+ // Reserve the thumb's overhang so the row's box matches the
2137
+ // native control's.
2138
+ marginBlock: 'calc((var(--slider-thumb-size) - var(--slider-track-size)) / 2)',
1907
2139
  borderRadius: '9999px',
1908
2140
  background: 'var(--color-base-300)',
1909
2141
  cursor: 'pointer',
1910
2142
  },
1911
- states: { disabled: { cursor: 'not-allowed' } },
2143
+ states: { readonly: { cursor: 'default' }, disabled: { cursor: 'not-allowed' } },
1912
2144
  },
1913
2145
  range: {
1914
2146
  base: {
@@ -1921,11 +2153,11 @@ export const slider: RecipeInput = {
1921
2153
  thumb: {
1922
2154
  base: {
1923
2155
  boxSizing: 'border-box',
1924
- width: 'calc(var(--size-selector) * 5)',
1925
- height: 'calc(var(--size-selector) * 5)',
2156
+ width: 'var(--slider-thumb-size)',
2157
+ height: 'var(--slider-thumb-size)',
1926
2158
  insetBlockStart: '50%',
1927
2159
  translate: '0 -50%',
1928
- marginInlineStart: 'calc(var(--size-selector) * -2.5)',
2160
+ marginInlineStart: 'calc(var(--slider-thumb-size) / -2)',
1929
2161
  borderRadius: '9999px',
1930
2162
  background: 'color-mix(in oklab, var(--slider-accent) 90%, var(--color-base-content))',
1931
2163
  cursor: 'pointer',
@@ -1935,12 +2167,14 @@ export const slider: RecipeInput = {
1935
2167
  states: {
1936
2168
  'focus-visible': { outline: '2px solid var(--slider-accent)', outlineOffset: '2px' },
1937
2169
  pressed: {},
2170
+ // Readonly answers to nothing, so it does not invite a click.
2171
+ readonly: { cursor: 'default' },
1938
2172
  disabled: { cursor: 'not-allowed' },
1939
2173
  },
1940
2174
  },
1941
2175
  mark: {
1942
2176
  base: {
1943
- paddingBlockStart: 'calc(var(--size-selector) * 2 + var(--space-2xs))',
2177
+ paddingBlockStart: 'calc(var(--slider-track-size) + var(--space-2xs))',
1944
2178
  fontSize: 'var(--text-xs)',
1945
2179
  lineHeight: '1',
1946
2180
  whiteSpace: 'nowrap',
@@ -1954,7 +2188,7 @@ export const slider: RecipeInput = {
1954
2188
  insetBlockStart: '0',
1955
2189
  insetInlineStart: '-1px',
1956
2190
  width: '2px',
1957
- height: 'calc(var(--size-selector) * 2)',
2191
+ height: 'var(--slider-track-size)',
1958
2192
  background: 'var(--color-base-content)',
1959
2193
  },
1960
2194
  },
@@ -1967,33 +2201,135 @@ export const slider: RecipeInput = {
1967
2201
  color: Object.fromEntries(ROLES.map((c) => [c, { root: { base: {
1968
2202
  '--slider-accent': `var(--color-${c})`,
1969
2203
  } } }])),
1970
- // A native range widget: only its box height is size-able without an
1971
- // appearance:none rebuild, so the ramp moves the box and the text.
2204
+ // daisy's `.range-{xs…xl}` ramp: `--range-thumb-size` at ×4…×8 of
2205
+ // `--size-selector` (1 / 1.25 / 1.5 / 1.75 / 2rem), the track half
2206
+ // of it — so every step moves the whole widget; the un-attributed
2207
+ // (and `md`) render takes the middle step from `tokens:`.
1972
2208
  size: {
1973
- xs: { control: { base: { height: 'calc(var(--size-selector) * 3)' } }, label: { base: { fontSize: 'var(--text-xs)' } } },
1974
- sm: { control: { base: { height: 'calc(var(--size-selector) * 4)' } }, label: { base: { fontSize: 'var(--text-sm)' } } },
2209
+ 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)' } } },
2210
+ 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)' } } },
2211
+ // `md`'s metrics ARE the `tokens:` defaults — one source of truth.
1975
2212
  md: { label: { base: { fontSize: 'var(--text-sm)' } } },
1976
- lg: { control: { base: { height: 'calc(var(--size-selector) * 7)' } }, label: { base: { fontSize: 'var(--text-md)' } } },
1977
- xl: { control: { base: { height: 'calc(var(--size-selector) * 8)' } }, label: { base: { fontSize: 'var(--text-lg)' } } },
2213
+ 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)' } } },
2214
+ 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)' } } },
1978
2215
  },
1979
2216
  },
1980
- // Base equals the default — `tokens:` binds the primary accent, and `md`
1981
- // only restates the label font-size the base already declares (the
1982
- // control's base height IS the middle step) — so the twins restate it;
2217
+ // Base equals the default — `tokens:` binds the primary accent and the
2218
+ // middle-step metrics, and `md` only restates the label font-size the
2219
+ // base already declares — so the twins restate it;
1983
2220
  // declared for the manifest (signalxjs/lynx#1070).
1984
2221
  defaultVariants: { color: 'primary', size: 'md' },
1985
2222
  skipStates: { root: ['invalid', 'focus-visible'] },
1986
2223
  // ONE design decision, two paints. On the web the accent reaches the
1987
- // screen twice: the native control is UA-drawn behind `accent-color`, and
1988
- // the composed range/thumb deepen the accent with `color-mix()`. Lynx
1989
- // renders neither spelling — `accent-color` styles a native
1990
- // `<input type=range>` lynx does not have, and the emitter drops
1991
- // `color-mix()` as unresolvable — so the compiled range and thumb shipped
1992
- // with no paint at all: grey track, invisible fill, invisible knob
1993
- // (measured on device, signalxjs/lynx#1075). The geometry all survives
1994
- // (the sizes are direct calc(), proven working); paint is the only gap,
1995
- // so this section only restates paint, in lynx-expressible spellings.
2224
+ // screen twice, both times deepened with `color-mix()`: the native
2225
+ // control's gradient track and thumb ring (via `--slider-fill`, below),
2226
+ // and the composed range/thumb. Lynx renders neither — it has no native
2227
+ // `<input type=range>`, and the emitter drops `color-mix()` as
2228
+ // unresolvable — so the compiled range and thumb once shipped with no
2229
+ // paint at all: grey track, invisible fill, invisible knob (measured on
2230
+ // device, signalxjs/lynx#1075). The geometry all survives (the sizes are
2231
+ // direct calc() over the metric tokens); paint is the only gap, so the
2232
+ // lynx section only restates paint, in lynx-expressible spellings, plus
2233
+ // the physical restatement of the logical geometry.
1996
2234
  targets: {
2235
+ web: {
2236
+ parts: {
2237
+ control: {
2238
+ base: {
2239
+ // The fill ink, deepened like the composed range.
2240
+ '--slider-fill': 'color-mix(in oklab, var(--slider-accent) 90%, var(--color-base-content))',
2241
+ // The filled track reads the runtime-published
2242
+ // `--slider-percent` — web-only (`RUNTIME_PROPERTIES`);
2243
+ // the lynx runtime paints its own track/range parts.
2244
+ '--slider-track':
2245
+ 'linear-gradient(to right, var(--slider-fill) var(--slider-percent, 50%), var(--color-base-300) 0)',
2246
+ },
2247
+ selectors: {
2248
+ // A vertical native range is spelled through writing
2249
+ // mode by the runtime (#170), so the long axis is the
2250
+ // box's HEIGHT and the channel's WIDTH; the fill
2251
+ // grows from the foot.
2252
+ '&[data-orientation="vertical"]': {
2253
+ width: 'var(--slider-thumb-size)',
2254
+ height: 'var(--slider-length)',
2255
+ '--slider-track':
2256
+ 'linear-gradient(to top, var(--slider-fill) var(--slider-percent, 50%), var(--color-base-300) 0)',
2257
+ },
2258
+ '&[data-orientation="vertical"]::-webkit-slider-runnable-track': {
2259
+ width: 'var(--slider-track-size)',
2260
+ height: 'auto',
2261
+ },
2262
+ // Blink lays the thumb against the channel's block
2263
+ // axis — in vertical-lr that is its left edge.
2264
+ '&[data-orientation="vertical"]::-webkit-slider-thumb': {
2265
+ marginTop: '0',
2266
+ marginBlockStart: 'calc((var(--slider-track-size) - var(--slider-thumb-size)) / 2)',
2267
+ },
2268
+ '&[data-orientation="vertical"]::-moz-range-track': {
2269
+ width: 'var(--slider-track-size)',
2270
+ height: '100%',
2271
+ },
2272
+ },
2273
+ },
2274
+ // Vertical (#170): the runtime positions the moving parts by
2275
+ // physical `bottom` percents; these turn the channel upright and
2276
+ // re-centre the knob on the other axis. Web-only — the lynx
2277
+ // runtime ships no vertical slider.
2278
+ root: {
2279
+ selectors: {
2280
+ '&[data-orientation="vertical"]': {
2281
+ width: 'auto',
2282
+ alignItems: 'center',
2283
+ '--slider-length': 'calc(var(--size-selector) * 40)',
2284
+ },
2285
+ },
2286
+ },
2287
+ track: {
2288
+ selectors: {
2289
+ '&[data-orientation="vertical"]': {
2290
+ width: 'var(--slider-track-size)',
2291
+ height: 'var(--slider-length)',
2292
+ marginBlock: '0',
2293
+ marginInline: 'calc((var(--slider-thumb-size) - var(--slider-track-size)) / 2)',
2294
+ },
2295
+ },
2296
+ },
2297
+ range: {
2298
+ selectors: {
2299
+ '&[data-orientation="vertical"]': { insetInlineStart: '0', width: '100%' },
2300
+ },
2301
+ },
2302
+ thumb: {
2303
+ selectors: {
2304
+ '&[data-orientation="vertical"]': {
2305
+ insetBlockStart: 'auto',
2306
+ insetInlineStart: '50%',
2307
+ translate: 'none',
2308
+ marginInlineStart: 'calc(var(--slider-thumb-size) / -2)',
2309
+ marginBlockEnd: 'calc(var(--slider-thumb-size) / -2)',
2310
+ },
2311
+ },
2312
+ },
2313
+ // The label sits beside the channel, centred on its tick.
2314
+ mark: {
2315
+ selectors: {
2316
+ '&[data-orientation="vertical"]': {
2317
+ insetInlineStart: '0',
2318
+ paddingBlockStart: '0',
2319
+ paddingInlineStart: 'calc(var(--slider-track-size) + var(--space-2xs))',
2320
+ translate: '0 50%',
2321
+ },
2322
+ '&[data-orientation="vertical"]::before': {
2323
+ insetBlockStart: '50%',
2324
+ insetInlineStart: '0',
2325
+ width: 'var(--slider-track-size)',
2326
+ height: '2px',
2327
+ translate: '0 -50%',
2328
+ },
2329
+ },
2330
+ },
2331
+ },
2332
+ },
1997
2333
  lynx: {
1998
2334
  parts: {
1999
2335
  track: {
@@ -2003,14 +2339,14 @@ export const slider: RecipeInput = {
2003
2339
  // the emitter refuses them. Physical is the lynx
2004
2340
  // target's norm — no RTL flow there.
2005
2341
  base: {
2006
- marginTop: 'calc(var(--size-selector) * 1.5)',
2007
- marginBottom: 'calc(var(--size-selector) * 1.5)',
2342
+ marginTop: 'calc((var(--slider-thumb-size) - var(--slider-track-size)) / 2)',
2343
+ marginBottom: 'calc((var(--slider-thumb-size) - var(--slider-track-size)) / 2)',
2008
2344
  },
2009
2345
  },
2010
2346
  mark: {
2011
2347
  // Same #1084 verdict: the tick labels hang below the
2012
2348
  // track off a physical padding.
2013
- base: { paddingTop: 'calc(var(--size-selector) * 2 + var(--space-2xs))' },
2349
+ base: { paddingTop: 'calc(var(--slider-track-size) + var(--space-2xs))' },
2014
2350
  },
2015
2351
  range: {
2016
2352
  // The web fill deepens the accent 90/10 toward
@@ -2048,7 +2384,7 @@ export const slider: RecipeInput = {
2048
2384
  border: '0.25rem solid var(--slider-accent)',
2049
2385
  top: '50%',
2050
2386
  transform: 'translateY(-50%)',
2051
- marginLeft: 'calc(var(--size-selector) * -2.5)',
2387
+ marginLeft: 'calc(var(--slider-thumb-size) / -2)',
2052
2388
  },
2053
2389
  },
2054
2390
  },
@@ -2058,12 +2394,15 @@ export const slider: RecipeInput = {
2058
2394
 
2059
2395
  export const accordion: RecipeInput = {
2060
2396
  component: 'accordion',
2397
+ targets: disclosureExit('accordion'),
2061
2398
  // Same accent grammar as collapsible: the open heading's ink, defaulting
2062
2399
  // to base-content so the un-attributed render is unchanged.
2063
2400
  tokens: { '--accordion-accent': 'var(--color-base-content)' },
2064
2401
  parts: {
2065
2402
  root: {
2066
2403
  base: { display: 'flex', flexDirection: 'column', gap: 'var(--space-md)' },
2404
+ // `orientation="horizontal"` (#276): the cards sit in a row.
2405
+ selectors: { '&[data-orientation="horizontal"]': { flexDirection: 'row', alignItems: 'start' } },
2067
2406
  },
2068
2407
  item: withPresence(disclosurePresence, {
2069
2408
  base: {
@@ -2073,6 +2412,7 @@ export const accordion: RecipeInput = {
2073
2412
  overflow: 'hidden',
2074
2413
  },
2075
2414
  states: { open: {}, closed: {} },
2415
+ selectors: { '[data-scope="accordion"][data-part="root"][data-orientation="horizontal"] > &': { flex: '1 1 0', minInlineSize: '0' } },
2076
2416
  }),
2077
2417
  trigger: {
2078
2418
  base: {
@@ -2157,14 +2497,21 @@ const fieldControl: NonNullable<PartStyles['base']> = {
2157
2497
  boxShadow: 'var(--shadow-xs)',
2158
2498
  };
2159
2499
 
2500
+ /**
2501
+ * A select whose clear-trigger is rendered (#280) — anchored on the root, so
2502
+ * a part inside the trigger can make room for the button laid over it.
2503
+ */
2504
+ const SELECT_CLEARABLE = '[data-scope="select"][data-part="root"]:has(> [data-scope="select"][data-part="clear-trigger"]) &';
2505
+
2160
2506
  export const select: RecipeInput = {
2161
2507
  component: 'select',
2162
2508
  // Accent as text/border ink only — daisy's highlighted item is a neutral
2163
2509
  // base-200 wash, so no `-on-accent` content colour is consumed here.
2164
2510
  tokens: { '--select-accent': 'var(--color-primary)' },
2165
2511
  parts: {
2512
+ // Positioned: the clear-trigger sits over the trigger's inline end.
2166
2513
  root: {
2167
- base: { display: 'inline-flex', flexDirection: 'column' },
2514
+ base: { display: 'inline-flex', flexDirection: 'column', position: 'relative' },
2168
2515
  },
2169
2516
  trigger: {
2170
2517
  base: {
@@ -2177,6 +2524,8 @@ export const select: RecipeInput = {
2177
2524
  },
2178
2525
  states: {
2179
2526
  hover: { borderColor: 'var(--color-base-content)' },
2527
+ // Readonly answers to nothing, so it does not invite a click.
2528
+ readonly: { cursor: 'default' },
2180
2529
  disabled: { opacity: 'var(--disabled-opacity)', cursor: 'not-allowed' },
2181
2530
  open: { borderColor: 'var(--select-accent)' },
2182
2531
  closed: {},
@@ -2213,6 +2562,41 @@ export const select: RecipeInput = {
2213
2562
  indicator: {
2214
2563
  base: { opacity: '0.6', transition: 'transform var(--duration-normal) var(--ease-standard)' },
2215
2564
  states: { open: { transform: 'rotate(180deg)' }, closed: {} },
2565
+ // Room for the clear-trigger laid over the field (#280).
2566
+ selectors: { [SELECT_CLEARABLE]: { marginInlineStart: 'calc(var(--space-2xl) + var(--space-sm))' } },
2567
+ },
2568
+ // Clears the selection (#280): daisy's ghost-btn over the field's
2569
+ // inline end, just before the chevron — muted like it, the base-200
2570
+ // wash on hover, the base-content ring on focus.
2571
+ 'clear-trigger': {
2572
+ base: {
2573
+ appearance: 'none',
2574
+ position: 'absolute',
2575
+ insetBlock: '0',
2576
+ // Centred in the field at WCAG 2.5.8's 24px target, shrinking
2577
+ // only with a field too short to hold it.
2578
+ marginBlock: 'auto',
2579
+ blockSize: 'min(1.5rem, 100%)',
2580
+ minInlineSize: '1.5rem',
2581
+ insetInlineEnd: 'calc(var(--size-field) * 4 + 1em + var(--space-xs))',
2582
+ display: 'inline-flex',
2583
+ alignItems: 'center',
2584
+ justifyContent: 'center',
2585
+ padding: '0 var(--space-xs)',
2586
+ border: 'none',
2587
+ background: 'transparent',
2588
+ color: 'color-mix(in oklab, var(--color-base-content) 70%, transparent)',
2589
+ font: 'inherit',
2590
+ fontSize: 'var(--text-sm)',
2591
+ lineHeight: 'var(--leading-none)',
2592
+ borderRadius: 'var(--radius-field)',
2593
+ cursor: 'pointer',
2594
+ transition: 'background var(--duration-fast) var(--ease-standard)',
2595
+ },
2596
+ states: {
2597
+ hover: { background: 'var(--color-base-200)', color: 'var(--color-base-content)' },
2598
+ ...focusRing,
2599
+ },
2216
2600
  },
2217
2601
  popup: withPresence(popupPresence('translateY(-4px)'), {
2218
2602
  base: { ...floatingPanel, padding: 'var(--space-md)', minWidth: '13rem' },
@@ -2261,6 +2645,14 @@ export const select: RecipeInput = {
2261
2645
  base: { fontSize: 'var(--text-xs)', color: 'var(--select-accent)' },
2262
2646
  states: { selected: {} },
2263
2647
  },
2648
+ // The menu's rule between runs of options (#280).
2649
+ separator: {
2650
+ base: {
2651
+ height: 'var(--border)',
2652
+ margin: 'var(--space-sm) var(--space-md)',
2653
+ background: 'var(--color-base-300)',
2654
+ },
2655
+ },
2264
2656
  },
2265
2657
  variants: {
2266
2658
  color: Object.fromEntries(ROLES.map((c) => [c, { root: { base: {
@@ -2283,8 +2675,12 @@ export const select: RecipeInput = {
2283
2675
  // The trigger ramp restates `md` too (unlike `btnSizes`), so its lynx
2284
2676
  // counterpart does as well.
2285
2677
  targets: {
2678
+ web: { parts: { popup: { base: anchoredListbox } } },
2286
2679
  lynx: {
2287
- parts: { trigger: { base: lynxBtnPad(4) } },
2680
+ parts: {
2681
+ trigger: { base: lynxBtnPad(4) },
2682
+ 'clear-trigger': { base: { top: 'var(--space-xs)', bottom: 'var(--space-xs)', right: 'calc(var(--size-field) * 4 + 1em + var(--space-xs))', minWidth: '1.5rem' } },
2683
+ },
2288
2684
  variants: {
2289
2685
  size: { ...lynxBtnSizes('trigger'), md: { trigger: { base: lynxBtnPad(4) } } },
2290
2686
  },
@@ -2916,6 +3312,23 @@ export const combobox: RecipeInput = {
2916
3312
  disabled: { cursor: 'not-allowed' },
2917
3313
  },
2918
3314
  },
3315
+ // Clears the value and the text (#280): the chevron's ghost, one
3316
+ // step before it — muted, full ink on hover.
3317
+ 'clear-trigger': {
3318
+ base: {
3319
+ appearance: 'none',
3320
+ border: 'none',
3321
+ background: 'transparent',
3322
+ color: 'inherit',
3323
+ font: 'inherit',
3324
+ opacity: '0.6',
3325
+ padding: '0 var(--space-xs)',
3326
+ lineHeight: 'var(--leading-none)',
3327
+ cursor: 'pointer',
3328
+ transition: 'opacity var(--duration-fast) var(--ease-standard)',
3329
+ },
3330
+ states: { hover: { opacity: '1' } },
3331
+ },
2919
3332
  popup: withPresence(popupPresence('translateY(-4px)'), {
2920
3333
  base: { ...floatingPanel, padding: 'var(--space-md)', minWidth: '13rem' },
2921
3334
  states: { open: {}, closed: {} },
@@ -2971,6 +3384,23 @@ export const combobox: RecipeInput = {
2971
3384
  opacity: '0.6',
2972
3385
  },
2973
3386
  },
3387
+ // The list still arriving (#280): the empty row's muted grammar.
3388
+ loading: {
3389
+ base: {
3390
+ padding: 'var(--space-lg)',
3391
+ fontSize: 'var(--text-sm)',
3392
+ textAlign: 'center',
3393
+ opacity: '0.6',
3394
+ },
3395
+ },
3396
+ // The menu's rule between runs of options (#280).
3397
+ separator: {
3398
+ base: {
3399
+ height: 'var(--border)',
3400
+ margin: 'var(--space-sm) var(--space-md)',
3401
+ background: 'var(--color-base-300)',
3402
+ },
3403
+ },
2974
3404
  },
2975
3405
  variants: {
2976
3406
  color: Object.fromEntries(ROLES.map((c) => [c, { root: { base: {
@@ -2993,6 +3423,9 @@ export const combobox: RecipeInput = {
2993
3423
  input: ['focus-visible'],
2994
3424
  trigger: ['focus-visible'],
2995
3425
  },
3426
+ targets: {
3427
+ web: { parts: { popup: { base: anchoredListbox } } },
3428
+ },
2996
3429
  };
2997
3430
 
2998
3431
  // daisy "btn" that stays pressed: the off state is the neutral btn, the on
@@ -3606,7 +4039,9 @@ export const treeView: RecipeInput = {
3606
4039
  opacity: '0.6',
3607
4040
  transition: 'transform var(--duration-fast) var(--ease-standard)',
3608
4041
  },
3609
- states: { open: { transform: 'rotate(90deg)' }, closed: {} },
4042
+ // Loading: the chevron halts at daisy's own collapse-arrow angle —
4043
+ // static (no loop), ink unchanged, so contrast is untouched.
4044
+ states: { open: { transform: 'rotate(90deg)' }, closed: {}, loading: { transform: 'rotate(45deg)' } },
3610
4045
  // The glyph is element text the runtime renders (`TreeView.tsx`), not
3611
4046
  // `content:`, so the `:dir(rtl)` swap the submenu chevron uses is not
3612
4047
  // available here — a mirror is its equivalent. `scale` composes
@@ -3625,6 +4060,9 @@ export const treeView: RecipeInput = {
3625
4060
  states: { open: {}, closed: {} },
3626
4061
  },
3627
4062
  },
4063
+ // Open-and-loading content lays out exactly as open content does — the
4064
+ // indicator and the treeitem's aria-busy carry the state.
4065
+ sameAs: { 'branch-content': { loading: 'open' } },
3628
4066
  variants: {
3629
4067
  // A tree colours one thing: the selected row. Everything else is
3630
4068
  // structure, and tinting it would fight the content.
@@ -3654,6 +4092,33 @@ export const treeView: RecipeInput = {
3654
4092
  },
3655
4093
  };
3656
4094
 
4095
+ /**
4096
+ * The affordances inside a text field (#281): daisy's in-field label ink and
4097
+ * the ghost circle button, both following the size step.
4098
+ */
4099
+ const affixInk = 'color-mix(in oklab, var(--color-base-content) 70%, transparent)';
4100
+ const fieldButton: NonNullable<PartStyles['base']> = {
4101
+ appearance: 'none',
4102
+ border: 'none',
4103
+ background: 'transparent',
4104
+ color: affixInk,
4105
+ borderRadius: '9999px',
4106
+ alignSelf: 'center',
4107
+ flex: 'none',
4108
+ order: '1',
4109
+ marginInlineEnd: 'var(--space-xs)',
4110
+ padding: 'var(--space-2xs) var(--space-xs)',
4111
+ fontSize: 'var(--text-sm)',
4112
+ fontWeight: 'var(--weight-semibold)',
4113
+ lineHeight: 'var(--leading-none)',
4114
+ cursor: 'pointer',
4115
+ };
4116
+ const affixSize = (fontSize: string) => ({
4117
+ adornment: { base: { fontSize } },
4118
+ 'clear-trigger': { base: { fontSize } },
4119
+ 'visibility-trigger': { base: { fontSize } },
4120
+ });
4121
+
3657
4122
  /**
3658
4123
  * daisy "input" flavor: the shared field box with nothing in it but the text.
3659
4124
  * Same `fieldControl` metrics as select's trigger, combobox's control and the
@@ -3680,6 +4145,7 @@ export const input: RecipeInput = {
3680
4145
  base: {
3681
4146
  display: 'inline-flex',
3682
4147
  alignItems: 'stretch',
4148
+ gap: 'var(--space-2xs)',
3683
4149
  ...fieldControl,
3684
4150
  overflow: 'hidden',
3685
4151
  transition: 'border-color var(--duration-fast) var(--ease-standard)',
@@ -3716,6 +4182,43 @@ export const input: RecipeInput = {
3716
4182
  '&::placeholder': { color: 'color-mix(in oklab, var(--color-base-content) 60%, transparent)' },
3717
4183
  },
3718
4184
  },
4185
+ // daisy's `label` inside `.input`: a quiet prefix/suffix at one edge,
4186
+ // ordered logically so it flips with the reading direction.
4187
+ adornment: {
4188
+ base: {
4189
+ display: 'inline-flex',
4190
+ alignItems: 'center',
4191
+ flex: 'none',
4192
+ color: affixInk,
4193
+ fontSize: 'var(--text-sm)',
4194
+ lineHeight: 'var(--leading-none)',
4195
+ },
4196
+ states: { disabled: {} },
4197
+ selectors: {
4198
+ '&[data-placement="start"]': { order: '-1', paddingInlineStart: 'var(--space-md)' },
4199
+ '&[data-placement="end"]': { order: '1', paddingInlineEnd: 'var(--space-md)' },
4200
+ },
4201
+ },
4202
+ // `btn btn-ghost btn-circle btn-xs` inside the field.
4203
+ 'clear-trigger': {
4204
+ base: fieldButton,
4205
+ states: {
4206
+ hover: { color: 'var(--color-base-content)', background: 'var(--color-base-200)' },
4207
+ disabled: { cursor: 'not-allowed' },
4208
+ ...focusRing,
4209
+ },
4210
+ },
4211
+ // Shown: the ghost button's active fill and full ink.
4212
+ 'visibility-trigger': {
4213
+ base: fieldButton,
4214
+ states: {
4215
+ on: { color: 'var(--color-base-content)', background: 'var(--color-base-200)' },
4216
+ off: {},
4217
+ hover: { color: 'var(--color-base-content)', background: 'var(--color-base-200)' },
4218
+ disabled: { cursor: 'not-allowed' },
4219
+ ...focusRing,
4220
+ },
4221
+ },
3719
4222
  },
3720
4223
  // The visible ring lives on `control`; the input delegates.
3721
4224
  skipStates: { input: ['focus-visible'] },
@@ -3728,13 +4231,13 @@ export const input: RecipeInput = {
3728
4231
  // The control carries the height off the shared field ramp; the text
3729
4232
  // follows so the box stays proportional.
3730
4233
  size: {
3731
- xs: { control: { base: { height: fieldHeight('xs') } }, input: { base: { fontSize: 'var(--text-xs)', padding: 'var(--space-2xs) var(--space-xs)' } } },
3732
- sm: { control: { base: { height: fieldHeight('sm') } }, input: { base: { fontSize: 'var(--text-xs)', padding: 'var(--space-xs) var(--space-sm)' } } },
4234
+ xs: { control: { base: { height: fieldHeight('xs') } }, input: { base: { fontSize: 'var(--text-xs)', padding: 'var(--space-2xs) var(--space-xs)' } }, ...affixSize('var(--text-xs)') },
4235
+ sm: { control: { base: { height: fieldHeight('sm') } }, input: { base: { fontSize: 'var(--text-xs)', padding: 'var(--space-xs) var(--space-sm)' } }, ...affixSize('var(--text-xs)') },
3733
4236
  // `md` is the un-attributed render: the base already IS the
3734
4237
  // middle step.
3735
4238
  md: {},
3736
- lg: { control: { base: { height: fieldHeight('lg') } }, input: { base: { fontSize: 'var(--text-md)', padding: 'var(--space-md) var(--space-lg)' } } },
3737
- xl: { control: { base: { height: fieldHeight('xl') } }, input: { base: { fontSize: 'var(--text-lg)', padding: 'var(--space-lg) var(--space-xl)' } } },
4239
+ lg: { control: { base: { height: fieldHeight('lg') } }, input: { base: { fontSize: 'var(--text-md)', padding: 'var(--space-md) var(--space-lg)' } }, ...affixSize('var(--text-md)') },
4240
+ xl: { control: { base: { height: fieldHeight('xl') } }, input: { base: { fontSize: 'var(--text-lg)', padding: 'var(--space-lg) var(--space-xl)' } }, ...affixSize('var(--text-lg)') },
3738
4241
  },
3739
4242
  },
3740
4243
  };
@@ -4462,8 +4965,17 @@ export const stats: RecipeInput = {
4462
4965
  tokens: { '--stats-accent': 'var(--color-base-content)' },
4463
4966
  parts: {
4464
4967
  root: {
4968
+ // The row's own scroll box (#43): a value never wraps or shrinks
4969
+ // (`nowrap` below), so a row too wide for its container scrolls
4970
+ // inside the root — daisyUI's own `.stats` behaviour, and Table's
4971
+ // and Pagination's answer — instead of pushing the page sideways.
4972
+ // Items keep their flex automatic minimum — their min-content
4973
+ // width — so the overflow lands here rather than in the next
4974
+ // item. No ring room: a stat is not focusable, and anything a
4975
+ // consumer puts in one sits inside the item's own padding.
4465
4976
  base: {
4466
4977
  display: 'flex',
4978
+ overflowX: 'auto',
4467
4979
  border: 'var(--border) solid var(--color-base-200)',
4468
4980
  borderRadius: 'var(--radius-box)',
4469
4981
  background: 'var(--color-base-100)',
@@ -4500,6 +5012,8 @@ export const stats: RecipeInput = {
4500
5012
  value: {
4501
5013
  base: {
4502
5014
  gridColumn: '1',
5015
+ // A figure is read whole: `$12 930` never breaks at its space.
5016
+ whiteSpace: 'nowrap',
4503
5017
  fontSize: 'var(--text-2xl)',
4504
5018
  fontWeight: 'var(--weight-bold)',
4505
5019
  fontVariantNumeric: 'tabular-nums',
@@ -4522,8 +5036,19 @@ export const stats: RecipeInput = {
4522
5036
  },
4523
5037
  },
4524
5038
  variants: {
4525
- color: Object.fromEntries(ROLES.map((c) => [c, { root: { base: {
4526
- '--stats-accent': `var(--color-${c})`,
5039
+ // Keyed on the ITEM, not the root (#161): the item re-carries
5040
+ // `color`, so a stat's own value outranks the row's, and the value
5041
+ // inside it inherits whichever won. A root colour still reaches
5042
+ // every item through the carrier's donut.
5043
+ //
5044
+ // The value is TEXT on the page, so the raw role is not always a
5045
+ // readable ink: measured by the text matrix once the item re-carried
5046
+ // the axis (#161), light warning is 1.76:1 and dark neutral 1.26:1
5047
+ // on base-100. `roleInk` is the shared answer for a role used as ink
5048
+ // (a per-role share of the role over base-content, which flips with
5049
+ // the scheme), the same one the field and button text use.
5050
+ color: Object.fromEntries(ROLES.map((c) => [c, { item: { base: {
5051
+ '--stats-accent': roleInk(c),
4527
5052
  } } }])),
4528
5053
  size: {
4529
5054
  xs: { value: { base: { fontSize: 'var(--text-lg)' } } },
@@ -4571,7 +5096,11 @@ export const timeline: RecipeInput = {
4571
5096
  padding: '0',
4572
5097
  },
4573
5098
  selectors: {
4574
- '&[data-orientation="horizontal"]': { flexDirection: 'row' },
5099
+ // A horizontal timeline is a row of steps whose width follows
5100
+ // the step count, not the container: at phone width it scrolls
5101
+ // inside its own box, the answer Table and Pagination give
5102
+ // (#45), rather than widening the page.
5103
+ '&[data-orientation="horizontal"]': { flexDirection: 'row', overflowX: 'auto' },
4575
5104
  },
4576
5105
  },
4577
5106
  /**
@@ -5272,9 +5801,27 @@ export const pagination: RecipeInput = {
5272
5801
  '--pg-accent-content': 'var(--color-primary-content)',
5273
5802
  '--pg-size': 'calc(var(--size-field) * 10)',
5274
5803
  '--pg-font': 'var(--text-sm)',
5804
+ // How far the focus ring reaches outside a control: the root's
5805
+ // scroll box pads by it so the ring is never clipped (#44).
5806
+ '--pg-ring-room': '4px',
5275
5807
  },
5276
5808
  parts: {
5277
- root: { base: { display: 'flex', alignItems: 'center', gap: 'var(--space-2xs)' } },
5809
+ root: {
5810
+ // The row's own scroll box (#44): the window is constant-width by
5811
+ // design, so a narrow container scrolls it rather than clipping
5812
+ // it — Table's answer. The padding is room for the focus ring's
5813
+ // 2px + 2px offset, which the scroll box would otherwise clip at
5814
+ // the row's edges; the matching scroll padding keeps it clear
5815
+ // when focus scrolls a page into view.
5816
+ base: {
5817
+ display: 'flex',
5818
+ alignItems: 'center',
5819
+ gap: 'var(--space-2xs)',
5820
+ overflowX: 'auto',
5821
+ padding: 'var(--pg-ring-room)',
5822
+ scrollPaddingInline: 'var(--pg-ring-room)',
5823
+ },
5824
+ },
5278
5825
  item: {
5279
5826
  base: {
5280
5827
  display: 'inline-flex',
@@ -5768,6 +6315,11 @@ export const table: RecipeInput = {
5768
6315
  borderRadius: 'var(--radius-box)',
5769
6316
  background: 'var(--color-base-100)',
5770
6317
  },
6318
+ // The scroll box is a keyboard stop (#270): daisy's ink ring,
6319
+ // drawn inside the box (negative offset) — the root is as wide
6320
+ // as its column, so an outward ring would be clipped by
6321
+ // whatever scrolls around it.
6322
+ states: { 'focus-visible': { outline: '2px solid var(--color-base-content)', outlineOffset: '-2px' } },
5771
6323
  // Stacked (Table.Root stack, #55): zero lays each row out as a block
5772
6324
  // below the table's breakpoint; the card it becomes is drawn here.
5773
6325
  at: tableStackAt(tokens, 'root', { border: '0', borderRadius: '0', background: 'transparent' }),
@@ -5958,7 +6510,15 @@ export const fileUpload: RecipeInput = {
5958
6510
  borderRadius: 'var(--radius-field)',
5959
6511
  background: 'var(--color-base-100)',
5960
6512
  },
5961
- states: { disabled: { opacity: 'var(--disabled-opacity)' } },
6513
+ states: {
6514
+ disabled: { opacity: 'var(--disabled-opacity)' },
6515
+ // A rejected file the app renders through Item: daisy's
6516
+ // error border over a faint error wash.
6517
+ invalid: {
6518
+ borderColor: 'var(--color-error)',
6519
+ background: 'color-mix(in oklab, var(--color-error) 8%, var(--color-base-100))',
6520
+ },
6521
+ },
5962
6522
  },
5963
6523
  'item-name': {
5964
6524
  base: {
@@ -5994,6 +6554,26 @@ export const fileUpload: RecipeInput = {
5994
6554
  ...focusRing,
5995
6555
  },
5996
6556
  },
6557
+ // daisy's btn-ghost btn-xs: the remove button's pill, at text size.
6558
+ 'clear-trigger': {
6559
+ base: {
6560
+ appearance: 'none',
6561
+ border: 'none',
6562
+ background: 'transparent',
6563
+ color: 'inherit',
6564
+ borderRadius: '9999px',
6565
+ padding: 'var(--space-2xs) var(--space-sm)',
6566
+ fontSize: 'var(--fu-font)',
6567
+ fontWeight: 'var(--weight-semibold)',
6568
+ lineHeight: 'var(--leading-none)',
6569
+ cursor: 'pointer',
6570
+ },
6571
+ states: {
6572
+ hover: { background: 'var(--color-base-200)' },
6573
+ disabled: { opacity: 'var(--disabled-opacity)', cursor: 'not-allowed' },
6574
+ ...focusRing,
6575
+ },
6576
+ },
5997
6577
  },
5998
6578
  variants: {
5999
6579
  color: Object.fromEntries(ROLES.map((c) => [c, { root: { base: {
@@ -6338,6 +6918,9 @@ export const diff: RecipeInput = {
6338
6918
  background: 'var(--color-base-100)',
6339
6919
  borderRadius: 'var(--radius-box)',
6340
6920
  },
6921
+ // The root holds the images, which are content: disabled is the
6922
+ // handle's to show (#272).
6923
+ states: { disabled: {} },
6341
6924
  },
6342
6925
  before: {
6343
6926
  base: { gridArea: '1 / 1', minWidth: '0' },
@@ -6367,6 +6950,9 @@ export const diff: RecipeInput = {
6367
6950
  },
6368
6951
  states: {
6369
6952
  ...focusRing,
6953
+ // daisy's disabled look (#272): the uniform fade, and no grab
6954
+ // cursor — the images are content and stay unfaded.
6955
+ disabled: { opacity: 'var(--disabled-opacity)', cursor: 'not-allowed' },
6370
6956
  pressed: { '--diff-accent': 'color-mix(in oklab, var(--color-base-content) 85%, var(--color-base-100))' },
6371
6957
  },
6372
6958
  selectors: {
@@ -6441,7 +7027,7 @@ export const diff: RecipeInput = {
6441
7027
 
6442
7028
  export const recipes: RecipeInput[] = [
6443
7029
  tabs, collapsible, switchRecipe, dialog, popover, tooltip, menu,
6444
- field, checkbox, radioGroup, progress, slider, accordion, select, button, avatar, toast, combobox,
7030
+ field, checkbox, checkboxGroup, radioGroup, progress, slider, accordion, select, button, avatar, toast, combobox,
6445
7031
  toggle, toggleGroup, numberInput, ratingGroup, treeView, input, textarea,
6446
7032
  card, alert, emptyState, badge, divider, skeleton, spinner,
6447
7033
  kbd, status, indicator, stats, timeline, chat, radialProgress, join,