@sigx/zero-daisyui 0.5.0 → 0.7.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 (111) hide show
  1. package/README.md +62 -3
  2. package/dist/audit.json +8018 -2748
  3. package/dist/components.d.ts +23 -2
  4. package/dist/components.js +2 -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/fieldset.css +91 -0
  29. package/dist/css/components/file-upload.css +34 -0
  30. package/dist/css/components/grid.css +2 -0
  31. package/dist/css/components/indicator.css +2 -0
  32. package/dist/css/components/input.css +137 -0
  33. package/dist/css/components/join.css +2 -0
  34. package/dist/css/components/kbd.css +2 -0
  35. package/dist/css/components/menu.css +68 -0
  36. package/dist/css/components/nav-list.css +2 -0
  37. package/dist/css/components/navbar.css +2 -0
  38. package/dist/css/components/number-input.css +2 -0
  39. package/dist/css/components/pagination.css +6 -0
  40. package/dist/css/components/popover.css +74 -0
  41. package/dist/css/components/progress.css +2 -4
  42. package/dist/css/components/radial-progress.css +2 -0
  43. package/dist/css/components/radio-group.css +10 -4
  44. package/dist/css/components/rating-group.css +2 -0
  45. package/dist/css/components/select.css +54 -1
  46. package/dist/css/components/skeleton.css +2 -0
  47. package/dist/css/components/slider.css +154 -25
  48. package/dist/css/components/spacer.css +2 -0
  49. package/dist/css/components/spinner.css +2 -0
  50. package/dist/css/components/stack.css +2 -0
  51. package/dist/css/components/stats.css +68 -16
  52. package/dist/css/components/status.css +2 -0
  53. package/dist/css/components/steps.css +2 -0
  54. package/dist/css/components/swap.css +2 -0
  55. package/dist/css/components/switch.css +23 -0
  56. package/dist/css/components/table.css +77 -0
  57. package/dist/css/components/tabs.css +15 -0
  58. package/dist/css/components/textarea.css +2 -0
  59. package/dist/css/components/timeline.css +3 -0
  60. package/dist/css/components/toast.css +2 -0
  61. package/dist/css/components/toggle-group.css +2 -0
  62. package/dist/css/components/toggle.css +2 -0
  63. package/dist/css/components/tooltip.css +67 -0
  64. package/dist/css/components/tree-view.css +6 -0
  65. package/dist/css/index.css +1053 -70
  66. package/dist/css/tokens.css +13 -13
  67. package/dist/lynx/components/accordion.css +9 -0
  68. package/dist/lynx/components/alert.css +20 -0
  69. package/dist/lynx/components/button.css +58 -1
  70. package/dist/lynx/components/checkbox-group.css +209 -0
  71. package/dist/lynx/components/checkbox.css +8 -0
  72. package/dist/lynx/components/combobox.css +46 -0
  73. package/dist/lynx/components/dialog.css +19 -0
  74. package/dist/lynx/components/diff.css +5 -0
  75. package/dist/lynx/components/drawer.css +4 -0
  76. package/dist/lynx/components/fieldset.css +219 -0
  77. package/dist/lynx/components/file-upload.css +47 -0
  78. package/dist/lynx/components/input.css +178 -0
  79. package/dist/lynx/components/number-input.css +8 -0
  80. package/dist/lynx/components/pagination.css +16 -0
  81. package/dist/lynx/components/popover.css +37 -0
  82. package/dist/lynx/components/progress.css +4 -24
  83. package/dist/lynx/components/radio-group.css +8 -0
  84. package/dist/lynx/components/rating-group.css +3 -3
  85. package/dist/lynx/components/select.css +70 -1
  86. package/dist/lynx/components/slider.css +391 -28
  87. package/dist/lynx/components/stats.css +146 -16
  88. package/dist/lynx/components/steps.css +4 -0
  89. package/dist/lynx/components/switch.css +6 -0
  90. package/dist/lynx/components/table.css +57 -0
  91. package/dist/lynx/components/tabs.css +115 -93
  92. package/dist/lynx/components/timeline.css +84 -1
  93. package/dist/lynx/components/toast.css +41 -6
  94. package/dist/lynx/components/toggle.css +5 -0
  95. package/dist/lynx/components/tree-view.css +4 -0
  96. package/dist/lynx/index.css +1912 -262
  97. package/dist/lynx/manifest.json +67 -5
  98. package/dist/lynx/tokens.css +4 -4
  99. package/dist/manifest.json +65 -3
  100. package/dist/recipes.d.ts +17 -0
  101. package/dist/recipes.d.ts.map +1 -1
  102. package/dist/recipes.js +1141 -110
  103. package/dist/recipes.js.map +1 -1
  104. package/dist/register.d.ts +18 -0
  105. package/dist/report.json +902 -270
  106. package/dist/tokens.d.ts.map +1 -1
  107. package/dist/tokens.js +18 -4
  108. package/dist/tokens.js.map +1 -1
  109. package/package.json +5 -5
  110. package/src/recipes.ts +1156 -111
  111. package/src/tokens.ts +18 -4
package/dist/recipes.js CHANGED
@@ -1,4 +1,4 @@
1
- import { axisRoles, tableStackAt } from '@sigx/zero-kit/define';
1
+ import { axisRoles, popupArrow, popupArrowHost, tableStackAt } from '@sigx/zero-kit/define';
2
2
  import { roles, tokens } from './tokens.js';
3
3
  /**
4
4
  * Every role a consumer can pass as `color`, derived from the declaration
@@ -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,12 @@ 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). On lynx, which has
293
+ // no pseudo-elements, the `border` flavor's underline is drawn on
294
+ // this part instead (see `targets.lynx`).
295
+ indicator: { base: { display: 'none' } },
239
296
  panel: {
240
297
  base: { fontSize: 'var(--text-md)' },
241
298
  states: { active: {}, inactive: {} },
@@ -436,11 +493,17 @@ export const tabs = {
436
493
  targets: {
437
494
  lynx: {
438
495
  parts: {
439
- // The default active ink — base-content, exactly what the
440
- // shared `states.active.color` paints. One class, so any
496
+ // No hover on a touch platform: the held tab takes the ink
497
+ // daisy's hover gives it.
498
+ tab: { states: { pressed: { color: 'var(--color-base-content)' } } },
499
+ // The list is the indicator's containing block.
500
+ list: { base: { position: 'relative' } },
501
+ // The underline's ink (read by the `border` flavor below) —
502
+ // base-content by default, exactly what the shared
503
+ // `states.active.color` paints on the tab. One class, so any
441
504
  // color-axis definition below (host + two classes, baked per
442
505
  // theme) wins on specificity.
443
- tab: { base: { '--tab-active-ink': 'var(--color-base-content)' } },
506
+ indicator: { base: { '--tab-active-ink': 'var(--color-base-content)' } },
444
507
  },
445
508
  variants: {
446
509
  // `currentColor` never resolves on lynx (measured on device
@@ -450,18 +513,28 @@ export const tabs = {
450
513
  // theme colors through color-mix(), which the emitter bakes
451
514
  // once per theme — the same per-color/per-theme literal the
452
515
  // active `color:` rules already land as.
453
- color: Object.fromEntries(ROLES.map((c) => [c, { tab: { base: { '--tab-active-ink': roleInk(c) } } }])),
516
+ color: Object.fromEntries(ROLES.map((c) => [c, { indicator: { base: { '--tab-active-ink': roleInk(c) } } }])),
454
517
  variant: {
455
518
  // The underline is a ::before on the web, which lynx
456
- // drops — redrawn as a real bottom border on the tab
457
- // itself (transparent at rest so activation never moves
458
- // the row). The active ink rides `--tab-active-ink`
459
- // above — a plain var() chain, device-proven — instead
460
- // of `currentColor`, which lynx never resolves.
519
+ // drops. It is redrawn on the `indicator` part (zero#324),
520
+ // which lynx-zero renders in every list and positions
521
+ // over the active tab from measured geometry (the web's
522
+ // `--tabs-indicator-*` properties are web-only). The part
523
+ // IS the tab's box, so daisy's bar is its bottom edge:
524
+ // 3px, 80% wide and centred, like `left: 10%; width: 80%`
525
+ // on the web. The ink rides `--tab-active-ink` — a plain
526
+ // var() chain, device-proven — instead of `currentColor`,
527
+ // which lynx never resolves.
461
528
  border: {
462
- tab: {
463
- base: { borderBottom: '3px solid transparent' },
464
- states: { active: { borderBottomColor: 'var(--tab-active-ink)' } },
529
+ indicator: {
530
+ base: {
531
+ display: 'flex',
532
+ boxSizing: 'border-box',
533
+ borderBottomWidth: '3px',
534
+ borderBottomStyle: 'solid',
535
+ borderBottomColor: 'var(--tab-active-ink)',
536
+ transform: 'scaleX(0.8)',
537
+ },
465
538
  },
466
539
  },
467
540
  // `min()` does not exist on lynx (signalxjs/lynx#1066) and
@@ -505,6 +578,7 @@ const disclosureSizes = {
505
578
  // daisy "collapse collapse-arrow" flavor.
506
579
  export const collapsible = {
507
580
  component: 'collapsible',
581
+ targets: disclosureExit('collapsible'),
508
582
  // Public to a design system derived from this one (#73).
509
583
  hooks: {
510
584
  properties: {
@@ -621,6 +695,8 @@ export const switchRecipe = {
621
695
  root: {
622
696
  base: { display: 'inline-flex', alignItems: 'center', gap: 'var(--space-lg)', cursor: 'pointer' },
623
697
  states: {
698
+ // Readonly answers to nothing, so it does not invite a click.
699
+ readonly: { cursor: 'default' },
624
700
  disabled: { opacity: 'var(--disabled-opacity)', cursor: 'not-allowed' },
625
701
  checked: {},
626
702
  unchecked: {},
@@ -753,6 +829,29 @@ export const switchRecipe = {
753
829
  + '0 1px color-mix(in oklab, currentColor calc(var(--depth) * 10%), #0000)',
754
830
  transition: 'background-color var(--duration-instant) var(--ease-standard)',
755
831
  },
832
+ at: {
833
+ // The knob is `currentColor` background plus shadows:
834
+ // forced colours revalue the one to Canvas and drop the
835
+ // others, leaving two empty pill outlines that read the
836
+ // same on and off (#189). Opt the knob out of forcing
837
+ // and paint it in system colours — CanvasText at rest,
838
+ // Highlight once checked. The noise and the shadows go
839
+ // too: unforced, they would mix the forced ink back in.
840
+ 'forced-colors': {
841
+ base: {
842
+ forcedColorAdjust: 'none',
843
+ backgroundColor: 'CanvasText',
844
+ backgroundImage: 'none',
845
+ boxShadow: 'none',
846
+ },
847
+ states: { checked: { backgroundColor: 'Highlight' } },
848
+ // A disabled switch draws in GrayText, the forced palette's
849
+ // own disabled ink, not full-strength ink faded by an
850
+ // author opacity the user's theme cannot style. Keyed off
851
+ // the control's flag, which outranks `checked`.
852
+ selectors: { '[data-scope="switch"][data-part="control"][data-disabled] &': { backgroundColor: 'GrayText' } },
853
+ },
854
+ },
756
855
  },
757
856
  },
758
857
  },
@@ -783,6 +882,10 @@ export const switchRecipe = {
783
882
  // No currentColor on this target — the ink is named
784
883
  // directly, and `checked` moves border + fill below.
785
884
  border: 'var(--border) solid var(--switch-ink)',
885
+ // daisy's 1px inset depth line along the track's top
886
+ // edge — `currentColor` at `--depth` × 10% on the
887
+ // web, the theme's own depth shade here.
888
+ boxShadow: '0 1px var(--depth-shade) inset',
786
889
  userSelect: 'none',
787
890
  transition: 'border-color var(--duration-slow) var(--ease-standard), '
788
891
  + 'background-color var(--duration-normal) var(--ease-standard)',
@@ -803,6 +906,12 @@ export const switchRecipe = {
803
906
  height: 'calc(var(--switch-size) - (var(--border) + var(--switch-p)) * 2)',
804
907
  borderRadius: 'var(--radius-selector)',
805
908
  backgroundColor: 'var(--switch-ink)',
909
+ // daisy's knob relief (`.toggle:before`): a shade
910
+ // along the bottom edge, a sheen across the top, and
911
+ // a 1px drop — the web's `currentColor` drop spelled
912
+ // as the theme's depth shade. The noise layer has no
913
+ // lynx spelling and stays web-only.
914
+ boxShadow: '0 -1px var(--depth-shade) inset, 0 8px 0 -4px var(--depth-sheen) inset, 0 1px var(--depth-shade)',
806
915
  transition: 'transform var(--duration-normal) var(--ease-standard), '
807
916
  + 'background-color var(--duration-instant) var(--ease-standard)',
808
917
  },
@@ -909,6 +1018,21 @@ const lynxBtnSizes = (part) => ({
909
1018
  lg: { [part]: { base: lynxBtnPad(5) } },
910
1019
  xl: { [part]: { base: lynxBtnPad(6) } },
911
1020
  });
1021
+ /**
1022
+ * The press rendering of the overlay btns (dialog, popover), for their
1023
+ * `targets.lynx` sections. The web answers a pointer with `:hover`, which
1024
+ * the lynx emitter drops (a touch platform), and these parts' `pressed`
1025
+ * flag — stamped by the lynx runtime on every touch — had no rule, so a
1026
+ * tap showed nothing (signalxjs/lynx#1146). daisy's `:hover` wash plus the
1027
+ * btn's 1px sink, the same pair `button` paints for its own `pressed`. A
1028
+ * colour variant restates the background after the states at equal
1029
+ * specificity, so a coloured trigger keeps its fill and sinks.
1030
+ */
1031
+ const lynxBtnPressed = {
1032
+ background: 'var(--color-base-300)',
1033
+ transform: 'translateY(1px)',
1034
+ boxShadow: 'none',
1035
+ };
912
1036
  export const dialog = {
913
1037
  component: 'dialog',
914
1038
  // Public to a design system derived from this one (#73).
@@ -1000,14 +1124,16 @@ export const dialog = {
1000
1124
  // reach and the trigger is the whole story here.
1001
1125
  variants: { color: btnColors(), size: btnSizes },
1002
1126
  // The btn paddings and the footer's push-down, restated physically —
1003
- // see `lynxBtnPad` for the #1084 verdict this answers.
1127
+ // see `lynxBtnPad` for the #1084 verdict this answers — and the btns'
1128
+ // press rendering (`lynxBtnPressed`). The footer states its row: on lynx
1129
+ // a row container says so next to `display: flex`.
1004
1130
  targets: {
1005
1131
  lynx: {
1006
1132
  parts: {
1007
- trigger: { base: lynxBtnPad(4) },
1008
- close: { base: lynxBtnPad(4) },
1009
- cancel: { base: lynxBtnPad(4) },
1010
- footer: { base: { marginTop: 'var(--space-2xl)' } },
1133
+ trigger: { base: lynxBtnPad(4), states: { pressed: lynxBtnPressed } },
1134
+ close: { base: lynxBtnPad(4), states: { pressed: lynxBtnPressed } },
1135
+ cancel: { base: lynxBtnPad(4), states: { pressed: lynxBtnPressed } },
1136
+ footer: { base: { marginTop: 'var(--space-2xl)', flexDirection: 'row' } },
1011
1137
  },
1012
1138
  variants: { size: lynxBtnSizes('trigger') },
1013
1139
  },
@@ -1021,6 +1147,37 @@ const floatingPanel = {
1021
1147
  borderRadius: 'var(--radius-box)',
1022
1148
  boxShadow: 'var(--shadow-lg)',
1023
1149
  };
1150
+ /**
1151
+ * The arrow a floating surface grows toward its anchor (#279), in the
1152
+ * surface's own paint: the dropdown's base-100 and border for popover and
1153
+ * menu, the tooltip's borderless neutral for the tooltip — daisy's
1154
+ * `tooltip` draws exactly that tail under its bubble. Web-only (placed by
1155
+ * the runtime's `--arrow-x`/`--arrow-y`), so it rides `targets.web` with
1156
+ * the popup's permission to let it out.
1157
+ */
1158
+ const floatingArrow = (scope, paint, size = '0.625rem') => ({
1159
+ parts: {
1160
+ popup: { selectors: popupArrowHost(scope) },
1161
+ arrow: popupArrow(scope, { size, paint }),
1162
+ },
1163
+ });
1164
+ const panelArrowPaint = { background: 'var(--color-base-100)', border: 'var(--border) solid var(--color-base-300)' };
1165
+ /**
1166
+ * daisy's dropdown menu for a listbox, sized by the geometry the
1167
+ * anchored-position strategy publishes (#278): at least the width of the
1168
+ * control that opened it (`--anchor-width`, daisy's 13rem floor otherwise)
1169
+ * and never taller than the room on the side it opened to
1170
+ * (`--available-height`), scrolling past a 20rem cap. Border-box, so both
1171
+ * bounds measure the box the strategy positions. Runtime-published
1172
+ * properties are web-only, so this is each listbox recipe's `targets.web`;
1173
+ * lynx keeps the shared 13rem.
1174
+ */
1175
+ const anchoredListbox = {
1176
+ boxSizing: 'border-box',
1177
+ minWidth: 'var(--anchor-width, 13rem)',
1178
+ maxHeight: 'min(20rem, var(--available-height, 20rem))',
1179
+ overflowY: 'auto',
1180
+ };
1024
1181
  export const popover = {
1025
1182
  component: 'popover',
1026
1183
  parts: {
@@ -1041,6 +1198,14 @@ export const popover = {
1041
1198
  title: {
1042
1199
  base: { margin: '0 0 var(--space-md)', fontSize: 'var(--text-md)', fontWeight: 'var(--weight-bold)' },
1043
1200
  },
1201
+ // The modal's muted body line, at the dropdown's tighter rhythm.
1202
+ description: {
1203
+ base: {
1204
+ margin: '0 0 var(--space-lg)',
1205
+ fontSize: 'var(--text-sm)',
1206
+ color: 'color-mix(in oklab, var(--color-base-content) 75%, transparent)',
1207
+ },
1208
+ },
1044
1209
  close: {
1045
1210
  base: { ...btn, height: 'calc(var(--size-field) * 8)', paddingInline: 'calc(var(--size-field) * 3)', fontSize: 'var(--text-xs)' },
1046
1211
  states: {
@@ -1052,12 +1217,14 @@ export const popover = {
1052
1217
  },
1053
1218
  // Trigger-carried axes — same wiring as dialog, same reason.
1054
1219
  variants: { color: btnColors(), size: btnSizes },
1055
- // The btn paddings, restated physically — see `lynxBtnPad` (#1084).
1220
+ // The btn paddings, restated physically — see `lynxBtnPad` (#1084) —
1221
+ // and the btns' press rendering (`lynxBtnPressed`).
1056
1222
  targets: {
1223
+ web: floatingArrow('popover', panelArrowPaint),
1057
1224
  lynx: {
1058
1225
  parts: {
1059
- trigger: { base: lynxBtnPad(4) },
1060
- close: { base: lynxBtnPad(3) },
1226
+ trigger: { base: lynxBtnPad(4), states: { pressed: lynxBtnPressed } },
1227
+ close: { base: lynxBtnPad(3), states: { pressed: lynxBtnPressed } },
1061
1228
  },
1062
1229
  variants: { size: lynxBtnSizes('trigger') },
1063
1230
  },
@@ -1099,6 +1266,7 @@ export const tooltip = {
1099
1266
  variants: { color: btnColors(), size: btnSizes },
1100
1267
  // The btn paddings, restated physically — see `lynxBtnPad` (#1084).
1101
1268
  targets: {
1269
+ web: floatingArrow('tooltip', { background: 'var(--color-neutral)' }, '0.5rem'),
1102
1270
  lynx: {
1103
1271
  parts: { trigger: { base: lynxBtnPad(4) } },
1104
1272
  variants: { size: lynxBtnSizes('trigger') },
@@ -1251,6 +1419,7 @@ export const menu = {
1251
1419
  variants: { color: btnColors(), size: btnSizes },
1252
1420
  // The btn paddings, restated physically — see `lynxBtnPad` (#1084).
1253
1421
  targets: {
1422
+ web: floatingArrow('menu', panelArrowPaint),
1254
1423
  lynx: {
1255
1424
  parts: { trigger: { base: lynxBtnPad(4) } },
1256
1425
  variants: { size: lynxBtnSizes('trigger') },
@@ -1306,6 +1475,60 @@ export const field = {
1306
1475
  },
1307
1476
  },
1308
1477
  };
1478
+ /**
1479
+ * Fieldset (#285), daisyUI 5's `.fieldset` / `.fieldset-legend`: a
1480
+ * one-column grid with a tight row gap and a little block padding, no frame
1481
+ * of its own (daisy draws one only when the app adds `border`), and a
1482
+ * semibold legend with block padding standing in for its margin. The UA
1483
+ * `min-inline-size: min-content` is undone so a wide control cannot push the
1484
+ * group past its column. Colour accents the legend through `roleInk`, the
1485
+ * Field way.
1486
+ */
1487
+ export const fieldset = {
1488
+ component: 'fieldset',
1489
+ tokens: { '--fieldset-accent': 'var(--color-base-content)' },
1490
+ parts: {
1491
+ root: {
1492
+ base: {
1493
+ display: 'grid',
1494
+ gridTemplateColumns: '1fr',
1495
+ gridAutoRows: 'max-content',
1496
+ gap: 'var(--space-sm)',
1497
+ minInlineSize: '0',
1498
+ margin: '0',
1499
+ paddingBlock: 'var(--space-xs)',
1500
+ paddingInline: '0',
1501
+ border: 'none',
1502
+ },
1503
+ },
1504
+ legend: {
1505
+ base: {
1506
+ paddingBlock: 'var(--space-sm)',
1507
+ paddingInline: '0',
1508
+ fontSize: 'var(--text-sm)',
1509
+ fontWeight: 'var(--weight-semibold)',
1510
+ color: 'var(--fieldset-accent)',
1511
+ },
1512
+ states: {
1513
+ disabled: { opacity: 'var(--disabled-opacity)' },
1514
+ invalid: { color: roleInk('error') },
1515
+ },
1516
+ },
1517
+ },
1518
+ variants: {
1519
+ color: Object.fromEntries(ROLES.map((c) => [c, { root: { base: {
1520
+ '--fieldset-accent': roleInk(c),
1521
+ } } }])),
1522
+ size: {
1523
+ xs: { legend: { base: { fontSize: 'var(--text-xs)' } } },
1524
+ sm: { legend: { base: { fontSize: 'var(--text-xs)' } } },
1525
+ // `md` is the un-attributed render.
1526
+ md: {},
1527
+ lg: { legend: { base: { fontSize: 'var(--text-md)' } } },
1528
+ xl: { legend: { base: { fontSize: 'var(--text-lg)' } } },
1529
+ },
1530
+ },
1531
+ };
1309
1532
  /**
1310
1533
  * daisy's tick, drawn by a clip-path that GROWS.
1311
1534
  *
@@ -1380,6 +1603,8 @@ export const checkbox = {
1380
1603
  root: {
1381
1604
  base: { display: 'inline-flex', alignItems: 'center', gap: 'var(--space-md)', cursor: 'pointer' },
1382
1605
  states: {
1606
+ // Readonly answers to nothing, so it does not invite a click.
1607
+ readonly: { cursor: 'default' },
1383
1608
  disabled: { opacity: 'var(--disabled-opacity)', cursor: 'not-allowed' },
1384
1609
  checked: {}, unchecked: {}, indeterminate: {},
1385
1610
  },
@@ -1452,6 +1677,8 @@ export const checkbox = {
1452
1677
  // `invalid` is semantic, not an accent: it stays error under
1453
1678
  // every colour variant, on purpose.
1454
1679
  invalid: { borderColor: 'var(--color-error)' },
1680
+ // Readonly answers to nothing, so it does not invite a click.
1681
+ readonly: { cursor: 'default' },
1455
1682
  disabled: {},
1456
1683
  },
1457
1684
  },
@@ -1576,13 +1803,9 @@ export const radioGroup = {
1576
1803
  parts: {
1577
1804
  root: {
1578
1805
  base: { display: 'flex', flexDirection: 'column', gap: 'var(--space-md)' },
1806
+ // `invalid` paints on each `item-control`, which carries the flag
1807
+ // itself (#267) — the root only groups the items.
1579
1808
  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
1809
  },
1587
1810
  label: {
1588
1811
  base: { fontSize: 'var(--text-sm)', fontWeight: 'var(--weight-semibold)' },
@@ -1591,6 +1814,8 @@ export const radioGroup = {
1591
1814
  item: {
1592
1815
  base: { display: 'inline-flex', alignItems: 'center', gap: 'var(--space-md)', cursor: 'pointer' },
1593
1816
  states: {
1817
+ // Readonly answers to nothing, so it does not invite a click.
1818
+ readonly: { cursor: 'default' },
1594
1819
  disabled: { opacity: 'var(--disabled-opacity)', cursor: 'not-allowed' },
1595
1820
  checked: {}, unchecked: {},
1596
1821
  },
@@ -1625,6 +1850,10 @@ export const radioGroup = {
1625
1850
  animation: 'var(--duration-fast) var(--ease-standard) zero-daisy-radio',
1626
1851
  },
1627
1852
  unchecked: {},
1853
+ // `invalid` is semantic: it stays error under every colour
1854
+ // variant, and after `checked` so it holds whichever radio is
1855
+ // chosen.
1856
+ invalid: { borderColor: 'var(--color-error)' },
1628
1857
  'focus-visible': { outline: '2px solid var(--radio-accent)', outlineOffset: '2px' },
1629
1858
  disabled: {},
1630
1859
  },
@@ -1695,6 +1924,47 @@ export const radioGroup = {
1695
1924
  },
1696
1925
  },
1697
1926
  };
1927
+ /**
1928
+ * CheckboxGroup (#282) — daisy has no group component; its forms stack
1929
+ * `.checkbox` rows under a `fieldset-legend`. The boxes keep the checkbox
1930
+ * recipe; the group owns the stack and the legend, which speaks Field's
1931
+ * label (colour through `roleInk`, #210).
1932
+ */
1933
+ export const checkboxGroup = {
1934
+ component: 'checkbox-group',
1935
+ tokens: { '--checkbox-group-accent': 'var(--color-base-content)' },
1936
+ parts: {
1937
+ root: {
1938
+ base: { display: 'flex', flexDirection: 'column', gap: 'var(--space-md)' },
1939
+ // `invalid` and `readonly` paint on each box — the root only
1940
+ // lays the boxes out.
1941
+ states: { invalid: {}, required: {}, readonly: {} },
1942
+ selectors: {
1943
+ '&[data-orientation="horizontal"]': { flexDirection: 'row', flexWrap: 'wrap', columnGap: 'var(--space-lg)' },
1944
+ },
1945
+ },
1946
+ label: {
1947
+ base: { fontSize: 'var(--text-sm)', fontWeight: 'var(--weight-semibold)', color: 'var(--checkbox-group-accent)' },
1948
+ states: { disabled: { opacity: 'var(--disabled-opacity)' } },
1949
+ selectors: {
1950
+ '&[data-required]::after': { content: '" *"', color: roleInk('error') },
1951
+ },
1952
+ },
1953
+ },
1954
+ variants: {
1955
+ color: Object.fromEntries(ROLES.map((c) => [c, { root: { base: {
1956
+ '--checkbox-group-accent': roleInk(c),
1957
+ } } }])),
1958
+ size: {
1959
+ xs: { root: { base: { gap: 'var(--space-sm)' } }, label: { base: { fontSize: 'var(--text-xs)' } } },
1960
+ sm: { label: { base: { fontSize: 'var(--text-xs)' } } },
1961
+ md: {},
1962
+ lg: { label: { base: { fontSize: 'var(--text-md)' } } },
1963
+ xl: { root: { base: { gap: 'var(--space-lg)' } }, label: { base: { fontSize: 'var(--text-lg)' } } },
1964
+ },
1965
+ },
1966
+ skipStates: { label: ['invalid'] },
1967
+ };
1698
1968
  // --------------------------------------------------------------------------
1699
1969
  // 4. progress — replace the whole export (only the two radius lines move)
1700
1970
  // --------------------------------------------------------------------------
@@ -1756,9 +2026,13 @@ export const progress = {
1756
2026
  transition: 'width var(--duration-slow) var(--ease-standard)',
1757
2027
  },
1758
2028
  states: {
1759
- // `complete` is a semantic state, not an accent: it stays
1760
- // success regardless of the colour variant, on purpose.
1761
- complete: { background: progressFill('success', 55) },
2029
+ // `complete` keeps the colour axis. daisyUI 5 has no complete
2030
+ // tint — a full `progress-primary` stays primary — and a
2031
+ // `color="secondary"` bar that turns green at 100% reads as a
2032
+ // bug, not as meaning (signalxjs/lynx#1144). A caller who wants
2033
+ // "done" to look like success says so: `color="success"`.
2034
+ // Declared equal to `loading` in `sameAs` below.
2035
+ complete: {},
1762
2036
  loading: {},
1763
2037
  indeterminate: { width: '40%', animation: 'zero-daisy-indeterminate 1.2s ease-in-out infinite' },
1764
2038
  },
@@ -1794,6 +2068,8 @@ export const progress = {
1794
2068
  // so the manifest states the default for runtimes with no `:not()`
1795
2069
  // fallback (the lynx target; signalxjs/lynx#1070).
1796
2070
  defaultVariants: { color: 'primary', size: 'md' },
2071
+ // A finished bar is a full bar of the same ink (see `complete` above).
2072
+ sameAs: { root: { complete: 'loading' }, range: { complete: 'loading' } },
1797
2073
  keyframes: {
1798
2074
  // Logical, so the sweep runs the way the bar fills — `margin-inline-start`
1799
2075
  // is animatable and direction-aware on its own. The determinate `width`,
@@ -1814,9 +2090,19 @@ export const progress = {
1814
2090
  },
1815
2091
  },
1816
2092
  };
2093
+ /** The slider's deepened fill — the web's `--slider-fill` mix, for a given accent. */
2094
+ const sliderFill = (accent) => `color-mix(in oklab, ${accent} 90%, var(--color-base-content))`;
1817
2095
  export const slider = {
1818
2096
  component: 'slider',
1819
- tokens: { '--slider-accent': 'var(--color-primary)' },
2097
+ // daisy's `.range` metrics: the thumb is `--range-thumb-size`
2098
+ // (×6 of `--size-selector` at md, ×4…×8 across the ramp — the same
2099
+ // selector ramp checkbox rides) and the track is half of it. Both the
2100
+ // native control and the composed parts read these.
2101
+ tokens: {
2102
+ '--slider-accent': 'var(--color-primary)',
2103
+ '--slider-track-size': 'calc(var(--size-selector) * 3)',
2104
+ '--slider-thumb-size': 'calc(var(--size-selector) * 6)',
2105
+ },
1820
2106
  parts: {
1821
2107
  root: {
1822
2108
  base: { display: 'flex', flexDirection: 'column', gap: 'var(--space-sm)', width: '100%' },
@@ -1826,30 +2112,106 @@ export const slider = {
1826
2112
  base: { fontSize: 'var(--text-sm)', fontWeight: 'var(--weight-semibold)' },
1827
2113
  states: { disabled: {} },
1828
2114
  },
2115
+ // A custom skin (`appearance: none`), ported from daisyUI 5's
2116
+ // `range.css`: a `--radius-selector` track half the thumb's height,
2117
+ // and daisy's real range thumb — a `base-100` knob inside a
2118
+ // `.25rem` ring of the fill ink (daisy's `--range-p`), carrying the
2119
+ // same `--depth` inset shading the checkbox and radio wear. daisy
2120
+ // throws the elapsed fill as a 100cqw box-shadow off the thumb and
2121
+ // clips it with `overflow: hidden`; here the track reads the
2122
+ // runtime-published `--slider-percent` as a gradient stop instead,
2123
+ // as every other skin does. The fill is deepened toward
2124
+ // `base-content` the way `progressFill` deepens daisy's bar — a raw
2125
+ // role on the base-300 rail is exactly the #210/#228 bug class.
2126
+ //
2127
+ // The rebuild is also the correctness move (see the kit skill): Blink
2128
+ // treats range inputs as ALWAYS `:focus-visible`, even on mouse
2129
+ // focus, so the base kills the native outline and the
2130
+ // `focus-visible` state — compiled to the runtime's keyboard-only
2131
+ // `[data-focus-visible]` flag — draws the ring only when it means it.
1829
2132
  control: {
1830
- base: { width: '100%', accentColor: 'var(--slider-accent)', cursor: 'pointer' },
2133
+ base: {
2134
+ appearance: 'none',
2135
+ width: '100%',
2136
+ height: 'var(--slider-thumb-size)',
2137
+ margin: '0',
2138
+ background: 'transparent',
2139
+ cursor: 'pointer',
2140
+ outline: 'none',
2141
+ // Kept for the forced-colors fallback below, where native
2142
+ // rendering takes over and still honours the accent.
2143
+ accentColor: 'var(--slider-accent)',
2144
+ // `--slider-fill` and `--slider-track` live in targets.web
2145
+ // below: the fill is a color-mix() the lynx emitter drops,
2146
+ // and the track reads the runtime-published
2147
+ // `--slider-percent`, a web-only mechanism.
2148
+ },
1831
2149
  states: {
2150
+ // Readonly answers to nothing, so it does not invite a click.
2151
+ readonly: { cursor: 'default' },
1832
2152
  disabled: { cursor: 'not-allowed' },
1833
2153
  'focus-visible': { outline: '2px solid var(--slider-accent)', outlineOffset: '2px' },
1834
2154
  // `invalid` is semantic: it stays error under every colour
1835
- // variant, on purpose.
1836
- invalid: { accentColor: 'var(--color-error)' },
2155
+ // variant, on purpose — the accent indirection swaps the
2156
+ // fill, the thumb ring, the focus ring and the forced-colors
2157
+ // fallback together.
2158
+ invalid: { '--slider-accent': 'var(--color-error)' },
2159
+ },
2160
+ selectors: {
2161
+ // Vendor pseudos cannot share a selector list — one unknown
2162
+ // selector invalidates the whole rule — so each engine gets
2163
+ // its own copy reading the shared custom properties.
2164
+ '&::-webkit-slider-runnable-track': {
2165
+ height: 'var(--slider-track-size)',
2166
+ borderRadius: 'var(--radius-selector)',
2167
+ background: 'var(--slider-track)',
2168
+ },
2169
+ '&::-webkit-slider-thumb': {
2170
+ appearance: 'none',
2171
+ boxSizing: 'border-box',
2172
+ width: 'var(--slider-thumb-size)',
2173
+ height: 'var(--slider-thumb-size)',
2174
+ marginTop: 'calc((var(--slider-track-size) - var(--slider-thumb-size)) / 2)',
2175
+ borderRadius: 'calc(var(--radius-selector) + min(0.25rem, var(--radius-selector) * 3))',
2176
+ border: '0.25rem solid var(--slider-fill)',
2177
+ background: 'var(--color-base-100)',
2178
+ boxShadow: '0 -1px var(--depth-shade) inset, 0 8px 0 -4px var(--depth-sheen) inset',
2179
+ },
2180
+ '&::-moz-range-track': {
2181
+ height: 'var(--slider-track-size)',
2182
+ borderRadius: 'var(--radius-selector)',
2183
+ background: 'var(--slider-track)',
2184
+ },
2185
+ '&::-moz-range-thumb': {
2186
+ boxSizing: 'border-box',
2187
+ width: 'var(--slider-thumb-size)',
2188
+ height: 'var(--slider-thumb-size)',
2189
+ borderRadius: 'calc(var(--radius-selector) + min(0.25rem, var(--radius-selector) * 3))',
2190
+ border: '0.25rem solid var(--slider-fill)',
2191
+ background: 'var(--color-base-100)',
2192
+ boxShadow: '0 -1px var(--depth-shade) inset, 0 8px 0 -4px var(--depth-sheen) inset',
2193
+ },
2194
+ },
2195
+ at: {
2196
+ // Native rendering knows forced colors better than a custom
2197
+ // skin; the retained accentColor keeps the fallback branded.
2198
+ 'forced-colors': { base: { appearance: 'auto' } },
1837
2199
  },
1838
2200
  },
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.
2201
+ // The composed range projection (#325): the same metrics as the
2202
+ // native control above, restating its accent as real boxes. The
2203
+ // fill is the same deepened ink the native track paints.
1844
2204
  track: {
1845
2205
  base: {
1846
- height: 'calc(var(--size-selector) * 2)',
1847
- marginBlock: 'calc(var(--size-selector) * 1.5)',
2206
+ height: 'var(--slider-track-size)',
2207
+ // Reserve the thumb's overhang so the row's box matches the
2208
+ // native control's.
2209
+ marginBlock: 'calc((var(--slider-thumb-size) - var(--slider-track-size)) / 2)',
1848
2210
  borderRadius: '9999px',
1849
2211
  background: 'var(--color-base-300)',
1850
2212
  cursor: 'pointer',
1851
2213
  },
1852
- states: { disabled: { cursor: 'not-allowed' } },
2214
+ states: { readonly: { cursor: 'default' }, disabled: { cursor: 'not-allowed' } },
1853
2215
  },
1854
2216
  range: {
1855
2217
  base: {
@@ -1862,11 +2224,11 @@ export const slider = {
1862
2224
  thumb: {
1863
2225
  base: {
1864
2226
  boxSizing: 'border-box',
1865
- width: 'calc(var(--size-selector) * 5)',
1866
- height: 'calc(var(--size-selector) * 5)',
2227
+ width: 'var(--slider-thumb-size)',
2228
+ height: 'var(--slider-thumb-size)',
1867
2229
  insetBlockStart: '50%',
1868
2230
  translate: '0 -50%',
1869
- marginInlineStart: 'calc(var(--size-selector) * -2.5)',
2231
+ marginInlineStart: 'calc(var(--slider-thumb-size) / -2)',
1870
2232
  borderRadius: '9999px',
1871
2233
  background: 'color-mix(in oklab, var(--slider-accent) 90%, var(--color-base-content))',
1872
2234
  cursor: 'pointer',
@@ -1876,12 +2238,14 @@ export const slider = {
1876
2238
  states: {
1877
2239
  'focus-visible': { outline: '2px solid var(--slider-accent)', outlineOffset: '2px' },
1878
2240
  pressed: {},
2241
+ // Readonly answers to nothing, so it does not invite a click.
2242
+ readonly: { cursor: 'default' },
1879
2243
  disabled: { cursor: 'not-allowed' },
1880
2244
  },
1881
2245
  },
1882
2246
  mark: {
1883
2247
  base: {
1884
- paddingBlockStart: 'calc(var(--size-selector) * 2 + var(--space-2xs))',
2248
+ paddingBlockStart: 'calc(var(--slider-track-size) + var(--space-2xs))',
1885
2249
  fontSize: 'var(--text-xs)',
1886
2250
  lineHeight: '1',
1887
2251
  whiteSpace: 'nowrap',
@@ -1895,7 +2259,7 @@ export const slider = {
1895
2259
  insetBlockStart: '0',
1896
2260
  insetInlineStart: '-1px',
1897
2261
  width: '2px',
1898
- height: 'calc(var(--size-selector) * 2)',
2262
+ height: 'var(--slider-track-size)',
1899
2263
  background: 'var(--color-base-content)',
1900
2264
  },
1901
2265
  },
@@ -1908,57 +2272,222 @@ export const slider = {
1908
2272
  color: Object.fromEntries(ROLES.map((c) => [c, { root: { base: {
1909
2273
  '--slider-accent': `var(--color-${c})`,
1910
2274
  } } }])),
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.
2275
+ // daisy's `.range-{xs…xl}` ramp: `--range-thumb-size` at ×4…×8 of
2276
+ // `--size-selector` (1 / 1.25 / 1.5 / 1.75 / 2rem), the track half
2277
+ // of it — so every step moves the whole widget; the un-attributed
2278
+ // (and `md`) render takes the middle step from `tokens:`.
1913
2279
  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)' } } },
2280
+ 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)' } } },
2281
+ 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)' } } },
2282
+ // `md`'s metrics ARE the `tokens:` defaults — one source of truth.
1916
2283
  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)' } } },
2284
+ 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)' } } },
2285
+ 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
2286
  },
1920
2287
  },
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;
2288
+ // Base equals the default — `tokens:` binds the primary accent and the
2289
+ // middle-step metrics, and `md` only restates the label font-size the
2290
+ // base already declares — so the twins restate it;
1924
2291
  // declared for the manifest (signalxjs/lynx#1070).
1925
2292
  defaultVariants: { color: 'primary', size: 'md' },
1926
2293
  skipStates: { root: ['invalid', 'focus-visible'] },
1927
2294
  // 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.
2295
+ // screen twice, both times deepened with `color-mix()`: the native
2296
+ // control's gradient track and thumb ring (via `--slider-fill`, below),
2297
+ // and the composed range/thumb. Lynx renders neither — it has no native
2298
+ // `<input type=range>`, and the emitter drops `color-mix()` as
2299
+ // unresolvable — so the compiled range and thumb once shipped with no
2300
+ // paint at all: grey track, invisible fill, invisible knob (measured on
2301
+ // device, signalxjs/lynx#1075). The geometry all survives (the sizes are
2302
+ // direct calc() over the metric tokens); paint is the only gap, so the
2303
+ // lynx section only restates paint, in lynx-expressible spellings, plus
2304
+ // the physical restatement of the logical geometry.
1937
2305
  targets: {
2306
+ web: {
2307
+ parts: {
2308
+ control: {
2309
+ base: {
2310
+ // The fill ink, deepened like the composed range.
2311
+ '--slider-fill': 'color-mix(in oklab, var(--slider-accent) 90%, var(--color-base-content))',
2312
+ // The filled track reads the runtime-published
2313
+ // `--slider-percent` — web-only (`RUNTIME_PROPERTIES`);
2314
+ // the lynx runtime paints its own track/range parts.
2315
+ '--slider-track': 'linear-gradient(to right, var(--slider-fill) var(--slider-percent, 50%), var(--color-base-300) 0)',
2316
+ },
2317
+ selectors: {
2318
+ // A vertical native range is spelled through writing
2319
+ // mode by the runtime (#170), so the long axis is the
2320
+ // box's HEIGHT and the channel's WIDTH; the fill
2321
+ // grows from the foot.
2322
+ '&[data-orientation="vertical"]': {
2323
+ width: 'var(--slider-thumb-size)',
2324
+ height: 'var(--slider-length)',
2325
+ '--slider-track': 'linear-gradient(to top, var(--slider-fill) var(--slider-percent, 50%), var(--color-base-300) 0)',
2326
+ },
2327
+ '&[data-orientation="vertical"]::-webkit-slider-runnable-track': {
2328
+ width: 'var(--slider-track-size)',
2329
+ height: 'auto',
2330
+ },
2331
+ // Blink lays the thumb against the channel's block
2332
+ // axis — in vertical-lr that is its left edge.
2333
+ '&[data-orientation="vertical"]::-webkit-slider-thumb': {
2334
+ marginTop: '0',
2335
+ marginBlockStart: 'calc((var(--slider-track-size) - var(--slider-thumb-size)) / 2)',
2336
+ },
2337
+ '&[data-orientation="vertical"]::-moz-range-track': {
2338
+ width: 'var(--slider-track-size)',
2339
+ height: '100%',
2340
+ },
2341
+ },
2342
+ },
2343
+ // Vertical (#170): the runtime positions the moving parts by
2344
+ // physical `bottom` percents; these turn the channel upright and
2345
+ // re-centre the knob on the other axis. Web-only — the lynx
2346
+ // runtime ships no vertical slider.
2347
+ root: {
2348
+ selectors: {
2349
+ '&[data-orientation="vertical"]': {
2350
+ width: 'auto',
2351
+ alignItems: 'center',
2352
+ '--slider-length': 'calc(var(--size-selector) * 40)',
2353
+ },
2354
+ },
2355
+ },
2356
+ track: {
2357
+ selectors: {
2358
+ '&[data-orientation="vertical"]': {
2359
+ width: 'var(--slider-track-size)',
2360
+ height: 'var(--slider-length)',
2361
+ marginBlock: '0',
2362
+ marginInline: 'calc((var(--slider-thumb-size) - var(--slider-track-size)) / 2)',
2363
+ },
2364
+ },
2365
+ },
2366
+ range: {
2367
+ selectors: {
2368
+ '&[data-orientation="vertical"]': { insetInlineStart: '0', width: '100%' },
2369
+ },
2370
+ },
2371
+ thumb: {
2372
+ selectors: {
2373
+ '&[data-orientation="vertical"]': {
2374
+ insetBlockStart: 'auto',
2375
+ insetInlineStart: '50%',
2376
+ translate: 'none',
2377
+ marginInlineStart: 'calc(var(--slider-thumb-size) / -2)',
2378
+ marginBlockEnd: 'calc(var(--slider-thumb-size) / -2)',
2379
+ },
2380
+ },
2381
+ },
2382
+ // The label sits beside the channel, centred on its tick.
2383
+ mark: {
2384
+ selectors: {
2385
+ '&[data-orientation="vertical"]': {
2386
+ insetInlineStart: '0',
2387
+ paddingBlockStart: '0',
2388
+ paddingInlineStart: 'calc(var(--slider-track-size) + var(--space-2xs))',
2389
+ translate: '0 50%',
2390
+ },
2391
+ '&[data-orientation="vertical"]::before': {
2392
+ insetBlockStart: '50%',
2393
+ insetInlineStart: '0',
2394
+ width: 'var(--slider-track-size)',
2395
+ height: '2px',
2396
+ translate: '0 -50%',
2397
+ },
2398
+ },
2399
+ },
2400
+ },
2401
+ },
1938
2402
  lynx: {
2403
+ // The web's deepened fill, theme-baked: `color-mix()` over theme
2404
+ // colours cannot resolve on device, but the emitter restates a
2405
+ // colour function per theme as literals (as `progress` does for
2406
+ // its accent), so each colour step gets the same 90/10 mix the
2407
+ // web track and knob ring wear. Bound on the root like
2408
+ // `--slider-accent`, rebound by `color` below and by `invalid`
2409
+ // on the control (which wraps the rail on lynx).
2410
+ tokens: { '--slider-fill': sliderFill('var(--color-primary)') },
2411
+ variants: {
2412
+ color: Object.fromEntries(ROLES.map((c) => [c, { root: { base: {
2413
+ '--slider-fill': sliderFill(`var(--color-${c})`),
2414
+ } } }])),
2415
+ },
1939
2416
  parts: {
2417
+ // Vertical (lynx#1144): the lynx runtime positions the moving
2418
+ // parts itself — `top` percentages, the knob centred by
2419
+ // `left: 50%` plus the base's `translateY(-50%)` and
2420
+ // negative `margin-left` — so the class rules only turn the
2421
+ // channel upright: the control is a thumb-wide column of
2422
+ // daisy's rail length, the track fills it.
2423
+ root: {
2424
+ selectors: {
2425
+ '&[data-orientation="vertical"]': { width: 'auto', alignItems: 'center' },
2426
+ },
2427
+ },
2428
+ control: {
2429
+ states: {
2430
+ invalid: { '--slider-fill': sliderFill('var(--color-error)') },
2431
+ },
2432
+ selectors: {
2433
+ '&[data-orientation="vertical"]': {
2434
+ display: 'flex',
2435
+ flexDirection: 'row',
2436
+ justifyContent: 'center',
2437
+ width: 'var(--slider-thumb-size)',
2438
+ height: 'calc(var(--size-selector) * 40)',
2439
+ },
2440
+ },
2441
+ },
1940
2442
  track: {
1941
2443
  // The web track's `margin-block` breathing room,
1942
2444
  // restated physically: logical spellings resolve on iOS
1943
2445
  // but not on Android (measured, signalxjs/lynx#1084), so
1944
2446
  // the emitter refuses them. Physical is the lynx
1945
2447
  // target's norm — no RTL flow there.
2448
+ //
2449
+ // daisy's channel radius is the selector radius (the
2450
+ // native control's runnable-track), not a pill.
1946
2451
  base: {
1947
- marginTop: 'calc(var(--size-selector) * 1.5)',
1948
- marginBottom: 'calc(var(--size-selector) * 1.5)',
2452
+ marginTop: 'calc((var(--slider-thumb-size) - var(--slider-track-size)) / 2)',
2453
+ marginBottom: 'calc((var(--slider-thumb-size) - var(--slider-track-size)) / 2)',
2454
+ borderRadius: 'var(--radius-selector)',
2455
+ },
2456
+ selectors: {
2457
+ '&[data-orientation="vertical"]': {
2458
+ width: 'var(--slider-track-size)',
2459
+ height: '100%',
2460
+ marginTop: '0',
2461
+ marginBottom: '0',
2462
+ },
1949
2463
  },
1950
2464
  },
1951
2465
  mark: {
1952
- // Same #1084 verdict: the tick labels hang below the
1953
- // track off a physical padding.
1954
- base: { paddingTop: 'calc(var(--size-selector) * 2 + var(--space-2xs))' },
2466
+ // The web draws the tick with `::before`, which lynx has
2467
+ // no projection for — the mark was an empty, invisible
2468
+ // box. Here the mark IS the tick: a 2px rule across the
2469
+ // channel, centred on its runtime-set `left` percent.
2470
+ // (Lynx marks carry no label text.)
2471
+ base: {
2472
+ top: '0',
2473
+ width: '2px',
2474
+ height: 'var(--slider-track-size)',
2475
+ marginLeft: '-1px',
2476
+ paddingTop: '0',
2477
+ backgroundColor: 'var(--color-base-content)',
2478
+ },
2479
+ selectors: {
2480
+ '&[data-orientation="vertical"]': {
2481
+ left: '0',
2482
+ width: 'var(--slider-track-size)',
2483
+ height: '2px',
2484
+ marginLeft: '0',
2485
+ marginTop: '-1px',
2486
+ },
2487
+ },
1955
2488
  },
1956
2489
  range: {
1957
- // The web fill deepens the accent 90/10 toward
1958
- // base-content (`progressFill`'s recipe); the plain
1959
- // accent — a var() chain, proven working on device — is
1960
- // the closest lynx-expressible equivalent.
1961
- base: { background: 'var(--slider-accent)' },
2490
+ base: { background: 'var(--slider-fill)', borderRadius: 'var(--radius-selector)' },
1962
2491
  },
1963
2492
  thumb: {
1964
2493
  // daisy's real range thumb, ported from daisyUI 5
@@ -1969,13 +2498,16 @@ export const slider = {
1969
2498
  // `--range-thumb` defaults to `var(--color-base-100)`,
1970
2499
  // `--range-p` is daisy's literal `.25rem` (deliberately
1971
2500
  // not the theme's size token), and the border ink is the
1972
- // accent (currentColor on a daisy `.range-*` IS the
1973
- // accent role) — because on lynx a named value is a
1974
- // var-chain fewer to resolve. daisy paints the progress
1975
- // fill itself as a 100cqw box-shadow thrown off this
1976
- // thumb — a native-input trick with no lynx equivalent;
1977
- // the composed `.zx-slider__range` box above IS that
1978
- // fill here, so the knob only needs its own two paints.
2501
+ // deepened fill the web's native thumb ring reads. daisy
2502
+ // paints the progress fill itself as a 100cqw box-shadow
2503
+ // thrown off this thumb — a native-input trick with no
2504
+ // lynx equivalent; the composed `.zx-slider__range` box
2505
+ // above IS that fill here, so the knob only needs its own
2506
+ // paints. Its radius is daisy's formula minus the min()
2507
+ // clamp lynx cannot evaluate — exact for every shipped
2508
+ // theme (all set `--radius-selector` ≥ .25rem / 3) — and
2509
+ // it wears daisy's depth shading (`currentColor`'s 10%
2510
+ // outer shadow spelled as the theme's shade).
1979
2511
  //
1980
2512
  // The centering geometry is restated physically beside
1981
2513
  // the paint: `inset-block-start`/`translate`/
@@ -1986,10 +2518,12 @@ export const slider = {
1986
2518
  // proven on both platforms.
1987
2519
  base: {
1988
2520
  background: 'var(--color-base-100)',
1989
- border: '0.25rem solid var(--slider-accent)',
2521
+ border: '0.25rem solid var(--slider-fill)',
2522
+ borderRadius: 'calc(var(--radius-selector) + 0.25rem)',
2523
+ boxShadow: '0 -1px var(--depth-shade) inset, 0 8px 0 -4px var(--depth-sheen) inset, 0 1px var(--depth-shade)',
1990
2524
  top: '50%',
1991
2525
  transform: 'translateY(-50%)',
1992
- marginLeft: 'calc(var(--size-selector) * -2.5)',
2526
+ marginLeft: 'calc(var(--slider-thumb-size) / -2)',
1993
2527
  },
1994
2528
  },
1995
2529
  },
@@ -1998,12 +2532,26 @@ export const slider = {
1998
2532
  };
1999
2533
  export const accordion = {
2000
2534
  component: 'accordion',
2535
+ targets: {
2536
+ ...disclosureExit('accordion'),
2537
+ lynx: {
2538
+ parts: {
2539
+ // No hover on a touch platform: the held trigger takes daisy's
2540
+ // hover fill. The ::after chevron has no lynx counterpart (no
2541
+ // pseudo-elements, and the anatomy declares no part for it) —
2542
+ // an app draws its own glyph in the trigger.
2543
+ trigger: { states: { pressed: { background: 'var(--color-base-200)' } } },
2544
+ },
2545
+ },
2546
+ },
2001
2547
  // Same accent grammar as collapsible: the open heading's ink, defaulting
2002
2548
  // to base-content so the un-attributed render is unchanged.
2003
2549
  tokens: { '--accordion-accent': 'var(--color-base-content)' },
2004
2550
  parts: {
2005
2551
  root: {
2006
2552
  base: { display: 'flex', flexDirection: 'column', gap: 'var(--space-md)' },
2553
+ // `orientation="horizontal"` (#276): the cards sit in a row.
2554
+ selectors: { '&[data-orientation="horizontal"]': { flexDirection: 'row', alignItems: 'start' } },
2007
2555
  },
2008
2556
  item: withPresence(disclosurePresence, {
2009
2557
  base: {
@@ -2013,6 +2561,7 @@ export const accordion = {
2013
2561
  overflow: 'hidden',
2014
2562
  },
2015
2563
  states: { open: {}, closed: {} },
2564
+ selectors: { '[data-scope="accordion"][data-part="root"][data-orientation="horizontal"] > &': { flex: '1 1 0', minInlineSize: '0' } },
2016
2565
  }),
2017
2566
  trigger: {
2018
2567
  base: {
@@ -2095,14 +2644,20 @@ const fieldControl = {
2095
2644
  borderRadius: 'var(--radius-field)',
2096
2645
  boxShadow: 'var(--shadow-xs)',
2097
2646
  };
2647
+ /**
2648
+ * A select whose clear-trigger is rendered (#280) — anchored on the root, so
2649
+ * a part inside the trigger can make room for the button laid over it.
2650
+ */
2651
+ const SELECT_CLEARABLE = '[data-scope="select"][data-part="root"]:has(> [data-scope="select"][data-part="clear-trigger"]) &';
2098
2652
  export const select = {
2099
2653
  component: 'select',
2100
2654
  // Accent as text/border ink only — daisy's highlighted item is a neutral
2101
2655
  // base-200 wash, so no `-on-accent` content colour is consumed here.
2102
2656
  tokens: { '--select-accent': 'var(--color-primary)' },
2103
2657
  parts: {
2658
+ // Positioned: the clear-trigger sits over the trigger's inline end.
2104
2659
  root: {
2105
- base: { display: 'inline-flex', flexDirection: 'column' },
2660
+ base: { display: 'inline-flex', flexDirection: 'column', position: 'relative' },
2106
2661
  },
2107
2662
  trigger: {
2108
2663
  base: {
@@ -2115,6 +2670,8 @@ export const select = {
2115
2670
  },
2116
2671
  states: {
2117
2672
  hover: { borderColor: 'var(--color-base-content)' },
2673
+ // Readonly answers to nothing, so it does not invite a click.
2674
+ readonly: { cursor: 'default' },
2118
2675
  disabled: { opacity: 'var(--disabled-opacity)', cursor: 'not-allowed' },
2119
2676
  open: { borderColor: 'var(--select-accent)' },
2120
2677
  closed: {},
@@ -2151,6 +2708,41 @@ export const select = {
2151
2708
  indicator: {
2152
2709
  base: { opacity: '0.6', transition: 'transform var(--duration-normal) var(--ease-standard)' },
2153
2710
  states: { open: { transform: 'rotate(180deg)' }, closed: {} },
2711
+ // Room for the clear-trigger laid over the field (#280).
2712
+ selectors: { [SELECT_CLEARABLE]: { marginInlineStart: 'calc(var(--space-2xl) + var(--space-sm))' } },
2713
+ },
2714
+ // Clears the selection (#280): daisy's ghost-btn over the field's
2715
+ // inline end, just before the chevron — muted like it, the base-200
2716
+ // wash on hover, the base-content ring on focus.
2717
+ 'clear-trigger': {
2718
+ base: {
2719
+ appearance: 'none',
2720
+ position: 'absolute',
2721
+ insetBlock: '0',
2722
+ // Centred in the field at WCAG 2.5.8's 24px target, shrinking
2723
+ // only with a field too short to hold it.
2724
+ marginBlock: 'auto',
2725
+ blockSize: 'min(1.5rem, 100%)',
2726
+ minInlineSize: '1.5rem',
2727
+ insetInlineEnd: 'calc(var(--size-field) * 4 + 1em + var(--space-xs))',
2728
+ display: 'inline-flex',
2729
+ alignItems: 'center',
2730
+ justifyContent: 'center',
2731
+ padding: '0 var(--space-xs)',
2732
+ border: 'none',
2733
+ background: 'transparent',
2734
+ color: 'color-mix(in oklab, var(--color-base-content) 70%, transparent)',
2735
+ font: 'inherit',
2736
+ fontSize: 'var(--text-sm)',
2737
+ lineHeight: 'var(--leading-none)',
2738
+ borderRadius: 'var(--radius-field)',
2739
+ cursor: 'pointer',
2740
+ transition: 'background var(--duration-fast) var(--ease-standard)',
2741
+ },
2742
+ states: {
2743
+ hover: { background: 'var(--color-base-200)', color: 'var(--color-base-content)' },
2744
+ ...focusRing,
2745
+ },
2154
2746
  },
2155
2747
  popup: withPresence(popupPresence('translateY(-4px)'), {
2156
2748
  base: { ...floatingPanel, padding: 'var(--space-md)', minWidth: '13rem' },
@@ -2199,6 +2791,14 @@ export const select = {
2199
2791
  base: { fontSize: 'var(--text-xs)', color: 'var(--select-accent)' },
2200
2792
  states: { selected: {} },
2201
2793
  },
2794
+ // The menu's rule between runs of options (#280).
2795
+ separator: {
2796
+ base: {
2797
+ height: 'var(--border)',
2798
+ margin: 'var(--space-sm) var(--space-md)',
2799
+ background: 'var(--color-base-300)',
2800
+ },
2801
+ },
2202
2802
  },
2203
2803
  variants: {
2204
2804
  color: Object.fromEntries(ROLES.map((c) => [c, { root: { base: {
@@ -2221,8 +2821,28 @@ export const select = {
2221
2821
  // The trigger ramp restates `md` too (unlike `btnSizes`), so its lynx
2222
2822
  // counterpart does as well.
2223
2823
  targets: {
2824
+ web: { parts: { popup: { base: anchoredListbox } } },
2224
2825
  lynx: {
2225
- parts: { trigger: { base: lynxBtnPad(4) } },
2826
+ parts: {
2827
+ // daisy's `.select` is `width: clamp(3rem, 20rem, 100%)`: 20rem,
2828
+ // never wider than its container. lynx has no `clamp()`, so the
2829
+ // same bounds are spelled out — and the base's 13rem floor
2830
+ // comes down to daisy's 3rem, or the trigger overflowed any
2831
+ // cell narrower than 208px (signalxjs/lynx#1146). The root
2832
+ // caps too, so a shrink-wrapped root cannot push it back out.
2833
+ root: { base: { maxWidth: '100%' } },
2834
+ trigger: {
2835
+ base: { ...lynxBtnPad(4), width: '20rem', maxWidth: '100%', minWidth: '3rem' },
2836
+ // The web's `:hover` border is dropped on a touch
2837
+ // platform; a press gets a wash instead — a background,
2838
+ // so it never fights the open/invalid border inks.
2839
+ states: { pressed: { background: 'var(--color-base-200)' } },
2840
+ },
2841
+ // A touch list has no highlight: the pressed item takes the
2842
+ // highlighted item's wash.
2843
+ item: { states: { pressed: { background: 'var(--color-base-200)' } } },
2844
+ 'clear-trigger': { base: { top: 'var(--space-xs)', bottom: 'var(--space-xs)', right: 'calc(var(--size-field) * 4 + 1em + var(--space-xs))', minWidth: '1.5rem' } },
2845
+ },
2226
2846
  variants: {
2227
2847
  size: { ...lynxBtnSizes('trigger'), md: { trigger: { base: lynxBtnPad(4) } } },
2228
2848
  },
@@ -2244,6 +2864,22 @@ export const select = {
2244
2864
  */
2245
2865
  const BTN_STEPS = { xs: 6, sm: 8, md: 10, lg: 12, xl: 14 };
2246
2866
  const btnHeight = (step) => `calc(var(--size-field) * ${BTN_STEPS[step]})`;
2867
+ /**
2868
+ * daisy 5's disabled btn, for the lynx target (signalxjs/lynx#1143). The web
2869
+ * fades the whole button (`opacity: var(--disabled-opacity)`, 0.3 on the
2870
+ * dark themes), which on device left a solid button's label barely legible.
2871
+ * daisy's own `.btn:disabled` paints instead: a base-content wash (not on
2872
+ * ghost/link), a transparent border, no shadow, and a muted base-content
2873
+ * label. daisy mutes that label to 20%; this keeps it at 50%, still plainly
2874
+ * inert but readable on a phone at arm's length.
2875
+ */
2876
+ const lynxBtnDisabled = {
2877
+ opacity: '1',
2878
+ background: 'color-mix(in oklab, var(--color-base-content) 10%, transparent)',
2879
+ color: 'color-mix(in oklab, var(--color-base-content) 50%, transparent)',
2880
+ borderColor: 'transparent',
2881
+ boxShadow: 'none',
2882
+ };
2247
2883
  export const button = {
2248
2884
  component: 'button',
2249
2885
  // Public to a design system derived from this one (#73).
@@ -2457,11 +3093,39 @@ export const button = {
2457
3093
  },
2458
3094
  defaultVariants: { color: 'primary', variant: 'solid', size: 'md' },
2459
3095
  keyframes: { 'zero-daisyui-btn-spin': 'to { transform: rotate(360deg) }' },
2460
- // The icon chips' zeroed inline padding, restated physically: logical
2461
- // spellings resolve on iOS but not on Android (measured,
2462
- // signalxjs/lynx#1084), so the emitter refuses them.
2463
3096
  targets: {
2464
3097
  lynx: {
3098
+ parts: {
3099
+ // The ring, restated: `currentColor` never resolves on lynx
3100
+ // (signalxjs/lynx#1079) and the logical sizes/edge are
3101
+ // unproven there, so the lynx ring is physical longhands in
3102
+ // the variant's own ink (the variants below pick it).
3103
+ spinner: {
3104
+ base: {
3105
+ width: '1em',
3106
+ height: '1em',
3107
+ border: 'calc(var(--border) * 2) solid var(--btn-ink)',
3108
+ borderTopColor: 'transparent',
3109
+ },
3110
+ },
3111
+ root: { states: { disabled: lynxBtnDisabled } },
3112
+ },
3113
+ variants: {
3114
+ // The baked disabled paint above outranks every variant's fill
3115
+ // (the theme host adds a class), but its plain declarations
3116
+ // do not: outline/dash restate the border, ghost/link drop
3117
+ // the wash (as in daisy).
3118
+ variant: {
3119
+ solid: { spinner: { base: { borderColor: 'var(--btn-on-accent)', borderTopColor: 'transparent' } } },
3120
+ outline: { root: { states: { disabled: { borderColor: 'transparent' } } } },
3121
+ dash: { root: { states: { disabled: { borderColor: 'transparent' } } } },
3122
+ ghost: { root: { states: { disabled: { background: 'transparent' } } } },
3123
+ link: { root: { states: { disabled: { background: 'transparent' } } } },
3124
+ },
3125
+ },
3126
+ // The icon chips' zeroed inline padding, restated physically: logical
3127
+ // spellings resolve on iOS but not on Android (measured,
3128
+ // signalxjs/lynx#1084), so the emitter refuses them.
2465
3129
  modifiers: {
2466
3130
  square: { root: { base: { paddingLeft: '0', paddingRight: '0' } } },
2467
3131
  circle: { root: { base: { paddingLeft: '0', paddingRight: '0' } } },
@@ -2700,21 +3364,66 @@ export const toast = {
2700
3364
  // signalxjs/lynx#1070. No `color` default: the un-attributed toast is a
2701
3365
  // neutral base-200 surface, outside the color vocabulary.
2702
3366
  defaultVariants: { size: 'md' },
2703
- // The corner placements' physical anchors: logical inset spellings
2704
- // resolve on iOS but not on Android (measured, signalxjs/lynx#1084), so
2705
- // the emitter refuses them. Physical is the lynx target's norm — no RTL
2706
- // flow there, so `start` IS left and `end` IS right.
2707
3367
  targets: {
2708
3368
  lynx: {
2709
3369
  parts: {
3370
+ // The viewport is a full-width strip on lynx (the runtime pins
3371
+ // it to the outlet's top or bottom edge). What the web gets
3372
+ // from `:popover-open` and `min(24rem, 100vw)` is restated:
3373
+ // flex (a lynx view defaults to linear layout, which ignores
3374
+ // flex-direction and gap), and the cards aligned to the
3375
+ // placement's edge. The centred pair's `left: 50%` +
3376
+ // `translateX(-50%)` pull-back is for a shrink-wrapped popover;
3377
+ // on the strip it shifted everything half a width left, so it
3378
+ // is cancelled. The corner anchors are physical: logical
3379
+ // insets resolve on iOS but not on Android (measured,
3380
+ // signalxjs/lynx#1084), and lynx has no RTL flow, so `start`
3381
+ // IS left and `end` IS right.
2710
3382
  viewport: {
3383
+ base: { display: 'flex', width: 'auto' },
2711
3384
  selectors: {
2712
- '&[data-placement="top-start"]': { left: '0' },
2713
- '&[data-placement="top-end"]': { right: '0' },
2714
- '&[data-placement="bottom-start"]': { left: '0' },
2715
- '&[data-placement="bottom-end"]': { right: '0' },
3385
+ '&[data-placement="top-start"]': { left: '0', alignItems: 'flex-start' },
3386
+ '&[data-placement="top"]': { left: '0', right: '0', transform: 'none', alignItems: 'center' },
3387
+ '&[data-placement="top-end"]': { right: '0', alignItems: 'flex-end' },
3388
+ '&[data-placement="bottom-start"]': { left: '0', alignItems: 'flex-start' },
3389
+ '&[data-placement="bottom"]': { left: '0', right: '0', transform: 'none', alignItems: 'center' },
3390
+ '&[data-placement="bottom-end"]': { right: '0', alignItems: 'flex-end' },
2716
3391
  },
2717
3392
  },
3393
+ // No grid on lynx (signalxjs/lynx#1075): the card is a flex
3394
+ // column (title, description, then the action), with the
3395
+ // close button pinned to the top-right corner and the title
3396
+ // kept clear of it. The web's 24rem viewport cap becomes the
3397
+ // card's own cap. The `^="top"` entry offset has no class
3398
+ // form, so each top placement restates it.
3399
+ root: {
3400
+ base: {
3401
+ display: 'flex',
3402
+ flexDirection: 'column',
3403
+ alignItems: 'flex-start',
3404
+ position: 'relative',
3405
+ boxSizing: 'border-box',
3406
+ width: '100%',
3407
+ maxWidth: '24rem',
3408
+ },
3409
+ selectors: {
3410
+ '&[data-placement="top-start"]': { '--toast-from': '-8px' },
3411
+ '&[data-placement="top"]': { '--toast-from': '-8px' },
3412
+ '&[data-placement="top-end"]': { '--toast-from': '-8px' },
3413
+ },
3414
+ },
3415
+ title: { base: { paddingRight: '1.75em' } },
3416
+ // The 75% ink mix over a recipe-local property cannot bake;
3417
+ // the same ink at 75% opacity reads the same on the soft fill.
3418
+ description: { base: { color: 'var(--toast-ink)', opacity: '0.75' } },
3419
+ // The 25% ink border cannot bake either: it takes the full ink.
3420
+ action: {
3421
+ base: {
3422
+ marginTop: 'var(--space-xs)',
3423
+ border: 'var(--border) solid var(--toast-ink)',
3424
+ },
3425
+ },
3426
+ close: { base: { position: 'absolute', top: 'var(--space-xs)', right: 'var(--space-xs)' } },
2718
3427
  },
2719
3428
  },
2720
3429
  },
@@ -2845,6 +3554,23 @@ export const combobox = {
2845
3554
  disabled: { cursor: 'not-allowed' },
2846
3555
  },
2847
3556
  },
3557
+ // Clears the value and the text (#280): the chevron's ghost, one
3558
+ // step before it — muted, full ink on hover.
3559
+ 'clear-trigger': {
3560
+ base: {
3561
+ appearance: 'none',
3562
+ border: 'none',
3563
+ background: 'transparent',
3564
+ color: 'inherit',
3565
+ font: 'inherit',
3566
+ opacity: '0.6',
3567
+ padding: '0 var(--space-xs)',
3568
+ lineHeight: 'var(--leading-none)',
3569
+ cursor: 'pointer',
3570
+ transition: 'opacity var(--duration-fast) var(--ease-standard)',
3571
+ },
3572
+ states: { hover: { opacity: '1' } },
3573
+ },
2848
3574
  popup: withPresence(popupPresence('translateY(-4px)'), {
2849
3575
  base: { ...floatingPanel, padding: 'var(--space-md)', minWidth: '13rem' },
2850
3576
  states: { open: {}, closed: {} },
@@ -2900,6 +3626,23 @@ export const combobox = {
2900
3626
  opacity: '0.6',
2901
3627
  },
2902
3628
  },
3629
+ // The list still arriving (#280): the empty row's muted grammar.
3630
+ loading: {
3631
+ base: {
3632
+ padding: 'var(--space-lg)',
3633
+ fontSize: 'var(--text-sm)',
3634
+ textAlign: 'center',
3635
+ opacity: '0.6',
3636
+ },
3637
+ },
3638
+ // The menu's rule between runs of options (#280).
3639
+ separator: {
3640
+ base: {
3641
+ height: 'var(--border)',
3642
+ margin: 'var(--space-sm) var(--space-md)',
3643
+ background: 'var(--color-base-300)',
3644
+ },
3645
+ },
2903
3646
  },
2904
3647
  variants: {
2905
3648
  color: Object.fromEntries(ROLES.map((c) => [c, { root: { base: {
@@ -2922,6 +3665,9 @@ export const combobox = {
2922
3665
  input: ['focus-visible'],
2923
3666
  trigger: ['focus-visible'],
2924
3667
  },
3668
+ targets: {
3669
+ web: { parts: { popup: { base: anchoredListbox } } },
3670
+ },
2925
3671
  };
2926
3672
  // daisy "btn" that stays pressed: the off state is the neutral btn, the on
2927
3673
  // state is the solid accent fill.
@@ -3520,7 +4266,9 @@ export const treeView = {
3520
4266
  opacity: '0.6',
3521
4267
  transition: 'transform var(--duration-fast) var(--ease-standard)',
3522
4268
  },
3523
- states: { open: { transform: 'rotate(90deg)' }, closed: {} },
4269
+ // Loading: the chevron halts at daisy's own collapse-arrow angle —
4270
+ // static (no loop), ink unchanged, so contrast is untouched.
4271
+ states: { open: { transform: 'rotate(90deg)' }, closed: {}, loading: { transform: 'rotate(45deg)' } },
3524
4272
  // The glyph is element text the runtime renders (`TreeView.tsx`), not
3525
4273
  // `content:`, so the `:dir(rtl)` swap the submenu chevron uses is not
3526
4274
  // available here — a mirror is its equivalent. `scale` composes
@@ -3539,6 +4287,9 @@ export const treeView = {
3539
4287
  states: { open: {}, closed: {} },
3540
4288
  },
3541
4289
  },
4290
+ // Open-and-loading content lays out exactly as open content does — the
4291
+ // indicator and the treeitem's aria-busy carry the state.
4292
+ sameAs: { 'branch-content': { loading: 'open' } },
3542
4293
  variants: {
3543
4294
  // A tree colours one thing: the selected row. Everything else is
3544
4295
  // structure, and tinting it would fight the content.
@@ -3567,6 +4318,32 @@ export const treeView = {
3567
4318
  },
3568
4319
  },
3569
4320
  };
4321
+ /**
4322
+ * The affordances inside a text field (#281): daisy's in-field label ink and
4323
+ * the ghost circle button, both following the size step.
4324
+ */
4325
+ const affixInk = 'color-mix(in oklab, var(--color-base-content) 70%, transparent)';
4326
+ const fieldButton = {
4327
+ appearance: 'none',
4328
+ border: 'none',
4329
+ background: 'transparent',
4330
+ color: affixInk,
4331
+ borderRadius: '9999px',
4332
+ alignSelf: 'center',
4333
+ flex: 'none',
4334
+ order: '1',
4335
+ marginInlineEnd: 'var(--space-xs)',
4336
+ padding: 'var(--space-2xs) var(--space-xs)',
4337
+ fontSize: 'var(--text-sm)',
4338
+ fontWeight: 'var(--weight-semibold)',
4339
+ lineHeight: 'var(--leading-none)',
4340
+ cursor: 'pointer',
4341
+ };
4342
+ const affixSize = (fontSize) => ({
4343
+ adornment: { base: { fontSize } },
4344
+ 'clear-trigger': { base: { fontSize } },
4345
+ 'visibility-trigger': { base: { fontSize } },
4346
+ });
3570
4347
  /**
3571
4348
  * daisy "input" flavor: the shared field box with nothing in it but the text.
3572
4349
  * Same `fieldControl` metrics as select's trigger, combobox's control and the
@@ -3593,6 +4370,7 @@ export const input = {
3593
4370
  base: {
3594
4371
  display: 'inline-flex',
3595
4372
  alignItems: 'stretch',
4373
+ gap: 'var(--space-2xs)',
3596
4374
  ...fieldControl,
3597
4375
  overflow: 'hidden',
3598
4376
  transition: 'border-color var(--duration-fast) var(--ease-standard)',
@@ -3629,6 +4407,43 @@ export const input = {
3629
4407
  '&::placeholder': { color: 'color-mix(in oklab, var(--color-base-content) 60%, transparent)' },
3630
4408
  },
3631
4409
  },
4410
+ // daisy's `label` inside `.input`: a quiet prefix/suffix at one edge,
4411
+ // ordered logically so it flips with the reading direction.
4412
+ adornment: {
4413
+ base: {
4414
+ display: 'inline-flex',
4415
+ alignItems: 'center',
4416
+ flex: 'none',
4417
+ color: affixInk,
4418
+ fontSize: 'var(--text-sm)',
4419
+ lineHeight: 'var(--leading-none)',
4420
+ },
4421
+ states: { disabled: {} },
4422
+ selectors: {
4423
+ '&[data-placement="start"]': { order: '-1', paddingInlineStart: 'var(--space-md)' },
4424
+ '&[data-placement="end"]': { order: '1', paddingInlineEnd: 'var(--space-md)' },
4425
+ },
4426
+ },
4427
+ // `btn btn-ghost btn-circle btn-xs` inside the field.
4428
+ 'clear-trigger': {
4429
+ base: fieldButton,
4430
+ states: {
4431
+ hover: { color: 'var(--color-base-content)', background: 'var(--color-base-200)' },
4432
+ disabled: { cursor: 'not-allowed' },
4433
+ ...focusRing,
4434
+ },
4435
+ },
4436
+ // Shown: the ghost button's active fill and full ink.
4437
+ 'visibility-trigger': {
4438
+ base: fieldButton,
4439
+ states: {
4440
+ on: { color: 'var(--color-base-content)', background: 'var(--color-base-200)' },
4441
+ off: {},
4442
+ hover: { color: 'var(--color-base-content)', background: 'var(--color-base-200)' },
4443
+ disabled: { cursor: 'not-allowed' },
4444
+ ...focusRing,
4445
+ },
4446
+ },
3632
4447
  },
3633
4448
  // The visible ring lives on `control`; the input delegates.
3634
4449
  skipStates: { input: ['focus-visible'] },
@@ -3641,13 +4456,13 @@ export const input = {
3641
4456
  // The control carries the height off the shared field ramp; the text
3642
4457
  // follows so the box stays proportional.
3643
4458
  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)' } } },
4459
+ xs: { control: { base: { height: fieldHeight('xs') } }, input: { base: { fontSize: 'var(--text-xs)', padding: 'var(--space-2xs) var(--space-xs)' } }, ...affixSize('var(--text-xs)') },
4460
+ sm: { control: { base: { height: fieldHeight('sm') } }, input: { base: { fontSize: 'var(--text-xs)', padding: 'var(--space-xs) var(--space-sm)' } }, ...affixSize('var(--text-xs)') },
3646
4461
  // `md` is the un-attributed render: the base already IS the
3647
4462
  // middle step.
3648
4463
  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)' } } },
4464
+ lg: { control: { base: { height: fieldHeight('lg') } }, input: { base: { fontSize: 'var(--text-md)', padding: 'var(--space-md) var(--space-lg)' } }, ...affixSize('var(--text-md)') },
4465
+ xl: { control: { base: { height: fieldHeight('xl') } }, input: { base: { fontSize: 'var(--text-lg)', padding: 'var(--space-lg) var(--space-xl)' } }, ...affixSize('var(--text-lg)') },
3651
4466
  },
3652
4467
  },
3653
4468
  };
@@ -4363,8 +5178,17 @@ export const stats = {
4363
5178
  tokens: { '--stats-accent': 'var(--color-base-content)' },
4364
5179
  parts: {
4365
5180
  root: {
5181
+ // The row's own scroll box (#43): a value never wraps or shrinks
5182
+ // (`nowrap` below), so a row too wide for its container scrolls
5183
+ // inside the root — daisyUI's own `.stats` behaviour, and Table's
5184
+ // and Pagination's answer — instead of pushing the page sideways.
5185
+ // Items keep their flex automatic minimum — their min-content
5186
+ // width — so the overflow lands here rather than in the next
5187
+ // item. No ring room: a stat is not focusable, and anything a
5188
+ // consumer puts in one sits inside the item's own padding.
4366
5189
  base: {
4367
5190
  display: 'flex',
5191
+ overflowX: 'auto',
4368
5192
  border: 'var(--border) solid var(--color-base-200)',
4369
5193
  borderRadius: 'var(--radius-box)',
4370
5194
  background: 'var(--color-base-100)',
@@ -4401,6 +5225,8 @@ export const stats = {
4401
5225
  value: {
4402
5226
  base: {
4403
5227
  gridColumn: '1',
5228
+ // A figure is read whole: `$12 930` never breaks at its space.
5229
+ whiteSpace: 'nowrap',
4404
5230
  fontSize: 'var(--text-2xl)',
4405
5231
  fontWeight: 'var(--weight-bold)',
4406
5232
  fontVariantNumeric: 'tabular-nums',
@@ -4423,8 +5249,19 @@ export const stats = {
4423
5249
  },
4424
5250
  },
4425
5251
  variants: {
4426
- color: Object.fromEntries(ROLES.map((c) => [c, { root: { base: {
4427
- '--stats-accent': `var(--color-${c})`,
5252
+ // Keyed on the ITEM, not the root (#161): the item re-carries
5253
+ // `color`, so a stat's own value outranks the row's, and the value
5254
+ // inside it inherits whichever won. A root colour still reaches
5255
+ // every item through the carrier's donut.
5256
+ //
5257
+ // The value is TEXT on the page, so the raw role is not always a
5258
+ // readable ink: measured by the text matrix once the item re-carried
5259
+ // the axis (#161), light warning is 1.76:1 and dark neutral 1.26:1
5260
+ // on base-100. `roleInk` is the shared answer for a role used as ink
5261
+ // (a per-role share of the role over base-content, which flips with
5262
+ // the scheme), the same one the field and button text use.
5263
+ color: Object.fromEntries(ROLES.map((c) => [c, { item: { base: {
5264
+ '--stats-accent': roleInk(c),
4428
5265
  } } }])),
4429
5266
  size: {
4430
5267
  xs: { value: { base: { fontSize: 'var(--text-lg)' } } },
@@ -4470,7 +5307,11 @@ export const timeline = {
4470
5307
  padding: '0',
4471
5308
  },
4472
5309
  selectors: {
4473
- '&[data-orientation="horizontal"]': { flexDirection: 'row' },
5310
+ // A horizontal timeline is a row of steps whose width follows
5311
+ // the step count, not the container: at phone width it scrolls
5312
+ // inside its own box, the answer Table and Pagination give
5313
+ // (#45), rather than widening the page.
5314
+ '&[data-orientation="horizontal"]': { flexDirection: 'row', overflowX: 'auto' },
4474
5315
  },
4475
5316
  },
4476
5317
  /**
@@ -4614,6 +5455,87 @@ export const timeline = {
4614
5455
  xl: { marker: { base: { '--timeline-marker-size': 'calc(var(--size-selector) * 4)' } }, content: { base: { fontSize: 'var(--text-md)' } } },
4615
5456
  },
4616
5457
  },
5458
+ /**
5459
+ * Lynx has no grid, and it does not resolve `inline-size`/`block-size`:
5460
+ * the item fell back to the engine's default layout and the marker,
5461
+ * sized only logically, stretched into a pill (signalxjs/lynx#1145).
5462
+ *
5463
+ * The item is a wrapping flex row instead. Vertical: line one is the
5464
+ * marker beside its content (start-side content ordered before the
5465
+ * marker), and the connector takes line two by itself (`flex-basis:
5466
+ * 100%`), drawn as a left border under the marker's centre, which is the
5467
+ * web's row-2 segment. Horizontal: line one is the marker with the
5468
+ * connector running on to the item's end, and the content wraps under it
5469
+ * (start-side content above). Content grows from a zero basis, so long
5470
+ * text never wraps onto its own line.
5471
+ *
5472
+ * The web collapses the start track when no item has start-side content
5473
+ * (`:has()`); lynx has no such test. The vertical connector sits under a
5474
+ * marker at the item's start edge, which is where the marker is unless
5475
+ * that item has start-side content: an alternating vertical timeline is
5476
+ * web-only for now.
5477
+ */
5478
+ targets: {
5479
+ lynx: {
5480
+ parts: {
5481
+ item: { base: { display: 'flex', flexDirection: 'row', flexWrap: 'wrap', alignItems: 'center' } },
5482
+ marker: {
5483
+ base: {
5484
+ width: 'var(--timeline-marker-size)',
5485
+ height: 'var(--timeline-marker-size)',
5486
+ flexGrow: '0',
5487
+ flexShrink: '0',
5488
+ },
5489
+ },
5490
+ connector: {
5491
+ selectors: {
5492
+ '&[data-orientation="vertical"]': {
5493
+ flexGrow: '0',
5494
+ flexShrink: '1',
5495
+ flexBasis: '100%',
5496
+ height: 'var(--space-lg)',
5497
+ background: 'transparent',
5498
+ borderLeftWidth: 'var(--border)',
5499
+ borderLeftStyle: 'solid',
5500
+ borderLeftColor: 'var(--color-base-300)',
5501
+ marginLeft: 'calc(var(--timeline-marker-size) / 2 - var(--border) / 2)',
5502
+ },
5503
+ '&[data-orientation="horizontal"]': {
5504
+ order: '1',
5505
+ flexGrow: '1',
5506
+ flexShrink: '1',
5507
+ flexBasis: '0',
5508
+ height: 'var(--border)',
5509
+ minWidth: 'var(--space-lg)',
5510
+ },
5511
+ },
5512
+ },
5513
+ content: {
5514
+ selectors: {
5515
+ '&[data-orientation="vertical"][data-placement="start"]': {
5516
+ order: '-1',
5517
+ flexGrow: '1',
5518
+ flexShrink: '1',
5519
+ flexBasis: '0',
5520
+ },
5521
+ '&[data-orientation="vertical"][data-placement="end"]': {
5522
+ flexGrow: '1',
5523
+ flexShrink: '1',
5524
+ flexBasis: '0',
5525
+ },
5526
+ '&[data-orientation="horizontal"][data-placement="start"]': {
5527
+ order: '-1',
5528
+ flexBasis: '100%',
5529
+ },
5530
+ '&[data-orientation="horizontal"][data-placement="end"]': {
5531
+ order: '2',
5532
+ flexBasis: '100%',
5533
+ },
5534
+ },
5535
+ },
5536
+ },
5537
+ },
5538
+ },
4617
5539
  };
4618
5540
  /** daisy chat: the classic bubble, colour-refilled per role. */
4619
5541
  export const chat = {
@@ -5164,9 +6086,27 @@ export const pagination = {
5164
6086
  '--pg-accent-content': 'var(--color-primary-content)',
5165
6087
  '--pg-size': 'calc(var(--size-field) * 10)',
5166
6088
  '--pg-font': 'var(--text-sm)',
6089
+ // How far the focus ring reaches outside a control: the root's
6090
+ // scroll box pads by it so the ring is never clipped (#44).
6091
+ '--pg-ring-room': '4px',
5167
6092
  },
5168
6093
  parts: {
5169
- root: { base: { display: 'flex', alignItems: 'center', gap: 'var(--space-2xs)' } },
6094
+ root: {
6095
+ // The row's own scroll box (#44): the window is constant-width by
6096
+ // design, so a narrow container scrolls it rather than clipping
6097
+ // it — Table's answer. The padding is room for the focus ring's
6098
+ // 2px + 2px offset, which the scroll box would otherwise clip at
6099
+ // the row's edges; the matching scroll padding keeps it clear
6100
+ // when focus scrolls a page into view.
6101
+ base: {
6102
+ display: 'flex',
6103
+ alignItems: 'center',
6104
+ gap: 'var(--space-2xs)',
6105
+ overflowX: 'auto',
6106
+ padding: 'var(--pg-ring-room)',
6107
+ scrollPaddingInline: 'var(--pg-ring-room)',
6108
+ },
6109
+ },
5170
6110
  item: {
5171
6111
  base: {
5172
6112
  display: 'inline-flex',
@@ -5624,6 +6564,9 @@ export const drawer = {
5624
6564
  */
5625
6565
  export const table = {
5626
6566
  component: 'table',
6567
+ // A sortable header cell carries the sort state for `aria-sort`'s sake;
6568
+ // its trigger and indicator paint it.
6569
+ skipStates: { 'header-cell': ['ascending', 'descending', 'none'] },
5627
6570
  // Public to a design system derived from this one (#73).
5628
6571
  hooks: {
5629
6572
  properties: {
@@ -5651,6 +6594,11 @@ export const table = {
5651
6594
  borderRadius: 'var(--radius-box)',
5652
6595
  background: 'var(--color-base-100)',
5653
6596
  },
6597
+ // The scroll box is a keyboard stop (#270): daisy's ink ring,
6598
+ // drawn inside the box (negative offset) — the root is as wide
6599
+ // as its column, so an outward ring would be clipped by
6600
+ // whatever scrolls around it.
6601
+ states: { 'focus-visible': { outline: '2px solid var(--color-base-content)', outlineOffset: '-2px' } },
5654
6602
  // Stacked (Table.Root stack, #55): zero lays each row out as a block
5655
6603
  // below the table's breakpoint; the card it becomes is drawn here.
5656
6604
  at: tableStackAt(tokens, 'root', { border: '0', borderRadius: '0', background: 'transparent' }),
@@ -5704,6 +6652,55 @@ export const table = {
5704
6652
  },
5705
6653
  at: tableStackAt(tokens, 'header-cell', { paddingInline: '0' }),
5706
6654
  },
6655
+ // Sorting (#286): daisy's header label made a button — the sorted
6656
+ // column's label takes the full accent ink, and the mark beside it
6657
+ // turns with the direction.
6658
+ 'sort-trigger': {
6659
+ base: {
6660
+ appearance: 'none',
6661
+ display: 'inline-flex',
6662
+ alignItems: 'center',
6663
+ margin: '0',
6664
+ border: '0',
6665
+ font: 'inherit',
6666
+ letterSpacing: 'inherit',
6667
+ textTransform: 'inherit',
6668
+ textAlign: 'inherit',
6669
+ cursor: 'pointer',
6670
+ gap: 'var(--space-2xs)',
6671
+ padding: '0',
6672
+ borderRadius: 'var(--radius-selector)',
6673
+ background: 'transparent',
6674
+ color: 'inherit',
6675
+ transition: 'color var(--duration-fast) var(--ease-standard)',
6676
+ },
6677
+ states: {
6678
+ ascending: { color: 'var(--table-accent)' },
6679
+ descending: { color: 'var(--table-accent)' },
6680
+ none: {},
6681
+ hover: { color: 'var(--table-accent)' },
6682
+ disabled: { cursor: 'not-allowed', opacity: 'var(--disabled-opacity)' },
6683
+ ...focusRing,
6684
+ },
6685
+ at: { 'reduced-motion': { base: { transition: 'none' } } },
6686
+ },
6687
+ // zero's ▲, turned for descending; an unsorted column shows it only
6688
+ // under a hovered or keyboard-focused trigger.
6689
+ 'sort-indicator': {
6690
+ base: {
6691
+ display: 'inline-block',
6692
+ fontSize: '0.75em',
6693
+ lineHeight: '1',
6694
+ transition: 'transform var(--duration-fast) var(--ease-standard), opacity var(--duration-fast) var(--ease-standard)',
6695
+ },
6696
+ states: {
6697
+ ascending: {},
6698
+ descending: { transform: 'rotate(180deg)' },
6699
+ none: { opacity: '0' },
6700
+ },
6701
+ selectors: { '[data-scope="table"][data-part="sort-trigger"]:is(:hover, [data-focus-visible]) > &[data-state="none"]': { opacity: '0.6' } },
6702
+ at: { 'reduced-motion': { base: { transition: 'none' } } },
6703
+ },
5707
6704
  cell: {
5708
6705
  base: {
5709
6706
  padding: 'var(--table-pad-block) var(--table-pad-inline)',
@@ -5840,7 +6837,15 @@ export const fileUpload = {
5840
6837
  borderRadius: 'var(--radius-field)',
5841
6838
  background: 'var(--color-base-100)',
5842
6839
  },
5843
- states: { disabled: { opacity: 'var(--disabled-opacity)' } },
6840
+ states: {
6841
+ disabled: { opacity: 'var(--disabled-opacity)' },
6842
+ // A rejected file the app renders through Item: daisy's
6843
+ // error border over a faint error wash.
6844
+ invalid: {
6845
+ borderColor: 'var(--color-error)',
6846
+ background: 'color-mix(in oklab, var(--color-error) 8%, var(--color-base-100))',
6847
+ },
6848
+ },
5844
6849
  },
5845
6850
  'item-name': {
5846
6851
  base: {
@@ -5876,6 +6881,26 @@ export const fileUpload = {
5876
6881
  ...focusRing,
5877
6882
  },
5878
6883
  },
6884
+ // daisy's btn-ghost btn-xs: the remove button's pill, at text size.
6885
+ 'clear-trigger': {
6886
+ base: {
6887
+ appearance: 'none',
6888
+ border: 'none',
6889
+ background: 'transparent',
6890
+ color: 'inherit',
6891
+ borderRadius: '9999px',
6892
+ padding: 'var(--space-2xs) var(--space-sm)',
6893
+ fontSize: 'var(--fu-font)',
6894
+ fontWeight: 'var(--weight-semibold)',
6895
+ lineHeight: 'var(--leading-none)',
6896
+ cursor: 'pointer',
6897
+ },
6898
+ states: {
6899
+ hover: { background: 'var(--color-base-200)' },
6900
+ disabled: { opacity: 'var(--disabled-opacity)', cursor: 'not-allowed' },
6901
+ ...focusRing,
6902
+ },
6903
+ },
5879
6904
  },
5880
6905
  variants: {
5881
6906
  color: Object.fromEntries(ROLES.map((c) => [c, { root: { base: {
@@ -6216,6 +7241,9 @@ export const diff = {
6216
7241
  background: 'var(--color-base-100)',
6217
7242
  borderRadius: 'var(--radius-box)',
6218
7243
  },
7244
+ // The root holds the images, which are content: disabled is the
7245
+ // handle's to show (#272).
7246
+ states: { disabled: {} },
6219
7247
  },
6220
7248
  before: {
6221
7249
  base: { gridArea: '1 / 1', minWidth: '0' },
@@ -6245,6 +7273,9 @@ export const diff = {
6245
7273
  },
6246
7274
  states: {
6247
7275
  ...focusRing,
7276
+ // daisy's disabled look (#272): the uniform fade, and no grab
7277
+ // cursor — the images are content and stay unfaded.
7278
+ disabled: { opacity: 'var(--disabled-opacity)', cursor: 'not-allowed' },
6248
7279
  pressed: { '--diff-accent': 'color-mix(in oklab, var(--color-base-content) 85%, var(--color-base-100))' },
6249
7280
  },
6250
7281
  selectors: {
@@ -6318,7 +7349,7 @@ export const diff = {
6318
7349
  };
6319
7350
  export const recipes = [
6320
7351
  tabs, collapsible, switchRecipe, dialog, popover, tooltip, menu,
6321
- field, checkbox, radioGroup, progress, slider, accordion, select, button, avatar, toast, combobox,
7352
+ field, fieldset, checkbox, checkboxGroup, radioGroup, progress, slider, accordion, select, button, avatar, toast, combobox,
6322
7353
  toggle, toggleGroup, numberInput, ratingGroup, treeView, input, textarea,
6323
7354
  card, alert, emptyState, badge, divider, skeleton, spinner,
6324
7355
  kbd, status, indicator, stats, timeline, chat, radialProgress, join,