@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/src/recipes.ts CHANGED
@@ -5,7 +5,7 @@
5
5
  * value of that data.
6
6
  */
7
7
  import type { CssProps, PartStyles, RecipeInput } from '@sigx/zero-kit';
8
- import { axisRoles, tableStackAt } from '@sigx/zero-kit/define';
8
+ import { axisRoles, popupArrow, popupArrowHost, tableStackAt } from '@sigx/zero-kit/define';
9
9
  import { roles, tokens } from './tokens.js';
10
10
 
11
11
  /**
@@ -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,12 @@ 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). On lynx, which has
320
+ // no pseudo-elements, the `border` flavor's underline is drawn on
321
+ // this part instead (see `targets.lynx`).
322
+ indicator: { base: { display: 'none' } },
264
323
  panel: {
265
324
  base: { fontSize: 'var(--text-md)' },
266
325
  states: { active: {}, inactive: {} },
@@ -463,11 +522,17 @@ export const tabs: RecipeInput = {
463
522
  targets: {
464
523
  lynx: {
465
524
  parts: {
466
- // The default active ink — base-content, exactly what the
467
- // shared `states.active.color` paints. One class, so any
525
+ // No hover on a touch platform: the held tab takes the ink
526
+ // daisy's hover gives it.
527
+ tab: { states: { pressed: { color: 'var(--color-base-content)' } } },
528
+ // The list is the indicator's containing block.
529
+ list: { base: { position: 'relative' } },
530
+ // The underline's ink (read by the `border` flavor below) —
531
+ // base-content by default, exactly what the shared
532
+ // `states.active.color` paints on the tab. One class, so any
468
533
  // color-axis definition below (host + two classes, baked per
469
534
  // theme) wins on specificity.
470
- tab: { base: { '--tab-active-ink': 'var(--color-base-content)' } },
535
+ indicator: { base: { '--tab-active-ink': 'var(--color-base-content)' } },
471
536
  },
472
537
  variants: {
473
538
  // `currentColor` never resolves on lynx (measured on device
@@ -478,19 +543,29 @@ export const tabs: RecipeInput = {
478
543
  // once per theme — the same per-color/per-theme literal the
479
544
  // active `color:` rules already land as.
480
545
  color: Object.fromEntries(
481
- ROLES.map((c) => [c, { tab: { base: { '--tab-active-ink': roleInk(c) } } }]),
546
+ ROLES.map((c) => [c, { indicator: { base: { '--tab-active-ink': roleInk(c) } } }]),
482
547
  ),
483
548
  variant: {
484
549
  // The underline is a ::before on the web, which lynx
485
- // drops — redrawn as a real bottom border on the tab
486
- // itself (transparent at rest so activation never moves
487
- // the row). The active ink rides `--tab-active-ink`
488
- // above — a plain var() chain, device-proven — instead
489
- // of `currentColor`, which lynx never resolves.
550
+ // drops. It is redrawn on the `indicator` part (zero#324),
551
+ // which lynx-zero renders in every list and positions
552
+ // over the active tab from measured geometry (the web's
553
+ // `--tabs-indicator-*` properties are web-only). The part
554
+ // IS the tab's box, so daisy's bar is its bottom edge:
555
+ // 3px, 80% wide and centred, like `left: 10%; width: 80%`
556
+ // on the web. The ink rides `--tab-active-ink` — a plain
557
+ // var() chain, device-proven — instead of `currentColor`,
558
+ // which lynx never resolves.
490
559
  border: {
491
- tab: {
492
- base: { borderBottom: '3px solid transparent' },
493
- states: { active: { borderBottomColor: 'var(--tab-active-ink)' } },
560
+ indicator: {
561
+ base: {
562
+ display: 'flex',
563
+ boxSizing: 'border-box',
564
+ borderBottomWidth: '3px',
565
+ borderBottomStyle: 'solid',
566
+ borderBottomColor: 'var(--tab-active-ink)',
567
+ transform: 'scaleX(0.8)',
568
+ },
494
569
  },
495
570
  },
496
571
  // `min()` does not exist on lynx (signalxjs/lynx#1066) and
@@ -536,6 +611,7 @@ const disclosureSizes: Record<string, Record<string, PartStyles>> = {
536
611
  // daisy "collapse collapse-arrow" flavor.
537
612
  export const collapsible: RecipeInput = {
538
613
  component: 'collapsible',
614
+ targets: disclosureExit('collapsible'),
539
615
  // Public to a design system derived from this one (#73).
540
616
  hooks: {
541
617
  properties: {
@@ -653,6 +729,8 @@ export const switchRecipe: RecipeInput = {
653
729
  root: {
654
730
  base: { display: 'inline-flex', alignItems: 'center', gap: 'var(--space-lg)', cursor: 'pointer' },
655
731
  states: {
732
+ // Readonly answers to nothing, so it does not invite a click.
733
+ readonly: { cursor: 'default' },
656
734
  disabled: { opacity: 'var(--disabled-opacity)', cursor: 'not-allowed' },
657
735
  checked: {},
658
736
  unchecked: {},
@@ -787,6 +865,29 @@ export const switchRecipe: RecipeInput = {
787
865
  + '0 1px color-mix(in oklab, currentColor calc(var(--depth) * 10%), #0000)',
788
866
  transition: 'background-color var(--duration-instant) var(--ease-standard)',
789
867
  },
868
+ at: {
869
+ // The knob is `currentColor` background plus shadows:
870
+ // forced colours revalue the one to Canvas and drop the
871
+ // others, leaving two empty pill outlines that read the
872
+ // same on and off (#189). Opt the knob out of forcing
873
+ // and paint it in system colours — CanvasText at rest,
874
+ // Highlight once checked. The noise and the shadows go
875
+ // too: unforced, they would mix the forced ink back in.
876
+ 'forced-colors': {
877
+ base: {
878
+ forcedColorAdjust: 'none',
879
+ backgroundColor: 'CanvasText',
880
+ backgroundImage: 'none',
881
+ boxShadow: 'none',
882
+ },
883
+ states: { checked: { backgroundColor: 'Highlight' } },
884
+ // A disabled switch draws in GrayText, the forced palette's
885
+ // own disabled ink, not full-strength ink faded by an
886
+ // author opacity the user's theme cannot style. Keyed off
887
+ // the control's flag, which outranks `checked`.
888
+ selectors: { '[data-scope="switch"][data-part="control"][data-disabled] &': { backgroundColor: 'GrayText' } },
889
+ },
890
+ },
790
891
  },
791
892
  },
792
893
  },
@@ -817,6 +918,10 @@ export const switchRecipe: RecipeInput = {
817
918
  // No currentColor on this target — the ink is named
818
919
  // directly, and `checked` moves border + fill below.
819
920
  border: 'var(--border) solid var(--switch-ink)',
921
+ // daisy's 1px inset depth line along the track's top
922
+ // edge — `currentColor` at `--depth` × 10% on the
923
+ // web, the theme's own depth shade here.
924
+ boxShadow: '0 1px var(--depth-shade) inset',
820
925
  userSelect: 'none',
821
926
  transition: 'border-color var(--duration-slow) var(--ease-standard), '
822
927
  + 'background-color var(--duration-normal) var(--ease-standard)',
@@ -837,6 +942,12 @@ export const switchRecipe: RecipeInput = {
837
942
  height: 'calc(var(--switch-size) - (var(--border) + var(--switch-p)) * 2)',
838
943
  borderRadius: 'var(--radius-selector)',
839
944
  backgroundColor: 'var(--switch-ink)',
945
+ // daisy's knob relief (`.toggle:before`): a shade
946
+ // along the bottom edge, a sheen across the top, and
947
+ // a 1px drop — the web's `currentColor` drop spelled
948
+ // as the theme's depth shade. The noise layer has no
949
+ // lynx spelling and stays web-only.
950
+ boxShadow: '0 -1px var(--depth-shade) inset, 0 8px 0 -4px var(--depth-sheen) inset, 0 1px var(--depth-shade)',
840
951
  transition: 'transform var(--duration-normal) var(--ease-standard), '
841
952
  + 'background-color var(--duration-instant) var(--ease-standard)',
842
953
  },
@@ -953,6 +1064,22 @@ const lynxBtnSizes = (part: string): Record<string, Record<string, PartStyles>>
953
1064
  xl: { [part]: { base: lynxBtnPad(6) } },
954
1065
  });
955
1066
 
1067
+ /**
1068
+ * The press rendering of the overlay btns (dialog, popover), for their
1069
+ * `targets.lynx` sections. The web answers a pointer with `:hover`, which
1070
+ * the lynx emitter drops (a touch platform), and these parts' `pressed`
1071
+ * flag — stamped by the lynx runtime on every touch — had no rule, so a
1072
+ * tap showed nothing (signalxjs/lynx#1146). daisy's `:hover` wash plus the
1073
+ * btn's 1px sink, the same pair `button` paints for its own `pressed`. A
1074
+ * colour variant restates the background after the states at equal
1075
+ * specificity, so a coloured trigger keeps its fill and sinks.
1076
+ */
1077
+ const lynxBtnPressed: NonNullable<PartStyles['base']> = {
1078
+ background: 'var(--color-base-300)',
1079
+ transform: 'translateY(1px)',
1080
+ boxShadow: 'none',
1081
+ };
1082
+
956
1083
  export const dialog: RecipeInput = {
957
1084
  component: 'dialog',
958
1085
  // Public to a design system derived from this one (#73).
@@ -1044,14 +1171,16 @@ export const dialog: RecipeInput = {
1044
1171
  // reach and the trigger is the whole story here.
1045
1172
  variants: { color: btnColors(), size: btnSizes },
1046
1173
  // The btn paddings and the footer's push-down, restated physically —
1047
- // see `lynxBtnPad` for the #1084 verdict this answers.
1174
+ // see `lynxBtnPad` for the #1084 verdict this answers — and the btns'
1175
+ // press rendering (`lynxBtnPressed`). The footer states its row: on lynx
1176
+ // a row container says so next to `display: flex`.
1048
1177
  targets: {
1049
1178
  lynx: {
1050
1179
  parts: {
1051
- trigger: { base: lynxBtnPad(4) },
1052
- close: { base: lynxBtnPad(4) },
1053
- cancel: { base: lynxBtnPad(4) },
1054
- footer: { base: { marginTop: 'var(--space-2xl)' } },
1180
+ trigger: { base: lynxBtnPad(4), states: { pressed: lynxBtnPressed } },
1181
+ close: { base: lynxBtnPad(4), states: { pressed: lynxBtnPressed } },
1182
+ cancel: { base: lynxBtnPad(4), states: { pressed: lynxBtnPressed } },
1183
+ footer: { base: { marginTop: 'var(--space-2xl)', flexDirection: 'row' } },
1055
1184
  },
1056
1185
  variants: { size: lynxBtnSizes('trigger') },
1057
1186
  },
@@ -1067,6 +1196,39 @@ const floatingPanel: NonNullable<PartStyles['base']> = {
1067
1196
  boxShadow: 'var(--shadow-lg)',
1068
1197
  };
1069
1198
 
1199
+ /**
1200
+ * The arrow a floating surface grows toward its anchor (#279), in the
1201
+ * surface's own paint: the dropdown's base-100 and border for popover and
1202
+ * menu, the tooltip's borderless neutral for the tooltip — daisy's
1203
+ * `tooltip` draws exactly that tail under its bubble. Web-only (placed by
1204
+ * the runtime's `--arrow-x`/`--arrow-y`), so it rides `targets.web` with
1205
+ * the popup's permission to let it out.
1206
+ */
1207
+ const floatingArrow = (scope: string, paint: CssProps, size = '0.625rem'): NonNullable<NonNullable<RecipeInput['targets']>['web']> => ({
1208
+ parts: {
1209
+ popup: { selectors: popupArrowHost(scope) },
1210
+ arrow: popupArrow(scope, { size, paint }),
1211
+ },
1212
+ });
1213
+ const panelArrowPaint: CssProps = { background: 'var(--color-base-100)', border: 'var(--border) solid var(--color-base-300)' };
1214
+
1215
+ /**
1216
+ * daisy's dropdown menu for a listbox, sized by the geometry the
1217
+ * anchored-position strategy publishes (#278): at least the width of the
1218
+ * control that opened it (`--anchor-width`, daisy's 13rem floor otherwise)
1219
+ * and never taller than the room on the side it opened to
1220
+ * (`--available-height`), scrolling past a 20rem cap. Border-box, so both
1221
+ * bounds measure the box the strategy positions. Runtime-published
1222
+ * properties are web-only, so this is each listbox recipe's `targets.web`;
1223
+ * lynx keeps the shared 13rem.
1224
+ */
1225
+ const anchoredListbox: CssProps = {
1226
+ boxSizing: 'border-box',
1227
+ minWidth: 'var(--anchor-width, 13rem)',
1228
+ maxHeight: 'min(20rem, var(--available-height, 20rem))',
1229
+ overflowY: 'auto',
1230
+ };
1231
+
1070
1232
  export const popover: RecipeInput = {
1071
1233
  component: 'popover',
1072
1234
  parts: {
@@ -1087,6 +1249,14 @@ export const popover: RecipeInput = {
1087
1249
  title: {
1088
1250
  base: { margin: '0 0 var(--space-md)', fontSize: 'var(--text-md)', fontWeight: 'var(--weight-bold)' },
1089
1251
  },
1252
+ // The modal's muted body line, at the dropdown's tighter rhythm.
1253
+ description: {
1254
+ base: {
1255
+ margin: '0 0 var(--space-lg)',
1256
+ fontSize: 'var(--text-sm)',
1257
+ color: 'color-mix(in oklab, var(--color-base-content) 75%, transparent)',
1258
+ },
1259
+ },
1090
1260
  close: {
1091
1261
  base: { ...btn, height: 'calc(var(--size-field) * 8)', paddingInline: 'calc(var(--size-field) * 3)', fontSize: 'var(--text-xs)' },
1092
1262
  states: {
@@ -1098,12 +1268,14 @@ export const popover: RecipeInput = {
1098
1268
  },
1099
1269
  // Trigger-carried axes — same wiring as dialog, same reason.
1100
1270
  variants: { color: btnColors(), size: btnSizes },
1101
- // The btn paddings, restated physically — see `lynxBtnPad` (#1084).
1271
+ // The btn paddings, restated physically — see `lynxBtnPad` (#1084) —
1272
+ // and the btns' press rendering (`lynxBtnPressed`).
1102
1273
  targets: {
1274
+ web: floatingArrow('popover', panelArrowPaint),
1103
1275
  lynx: {
1104
1276
  parts: {
1105
- trigger: { base: lynxBtnPad(4) },
1106
- close: { base: lynxBtnPad(3) },
1277
+ trigger: { base: lynxBtnPad(4), states: { pressed: lynxBtnPressed } },
1278
+ close: { base: lynxBtnPad(3), states: { pressed: lynxBtnPressed } },
1107
1279
  },
1108
1280
  variants: { size: lynxBtnSizes('trigger') },
1109
1281
  },
@@ -1146,6 +1318,7 @@ export const tooltip: RecipeInput = {
1146
1318
  variants: { color: btnColors(), size: btnSizes },
1147
1319
  // The btn paddings, restated physically — see `lynxBtnPad` (#1084).
1148
1320
  targets: {
1321
+ web: floatingArrow('tooltip', { background: 'var(--color-neutral)' }, '0.5rem'),
1149
1322
  lynx: {
1150
1323
  parts: { trigger: { base: lynxBtnPad(4) } },
1151
1324
  variants: { size: lynxBtnSizes('trigger') },
@@ -1299,6 +1472,7 @@ export const menu: RecipeInput = {
1299
1472
  variants: { color: btnColors(), size: btnSizes },
1300
1473
  // The btn paddings, restated physically — see `lynxBtnPad` (#1084).
1301
1474
  targets: {
1475
+ web: floatingArrow('menu', panelArrowPaint),
1302
1476
  lynx: {
1303
1477
  parts: { trigger: { base: lynxBtnPad(4) } },
1304
1478
  variants: { size: lynxBtnSizes('trigger') },
@@ -1356,6 +1530,61 @@ export const field: RecipeInput = {
1356
1530
  },
1357
1531
  };
1358
1532
 
1533
+ /**
1534
+ * Fieldset (#285), daisyUI 5's `.fieldset` / `.fieldset-legend`: a
1535
+ * one-column grid with a tight row gap and a little block padding, no frame
1536
+ * of its own (daisy draws one only when the app adds `border`), and a
1537
+ * semibold legend with block padding standing in for its margin. The UA
1538
+ * `min-inline-size: min-content` is undone so a wide control cannot push the
1539
+ * group past its column. Colour accents the legend through `roleInk`, the
1540
+ * Field way.
1541
+ */
1542
+ export const fieldset: RecipeInput = {
1543
+ component: 'fieldset',
1544
+ tokens: { '--fieldset-accent': 'var(--color-base-content)' },
1545
+ parts: {
1546
+ root: {
1547
+ base: {
1548
+ display: 'grid',
1549
+ gridTemplateColumns: '1fr',
1550
+ gridAutoRows: 'max-content',
1551
+ gap: 'var(--space-sm)',
1552
+ minInlineSize: '0',
1553
+ margin: '0',
1554
+ paddingBlock: 'var(--space-xs)',
1555
+ paddingInline: '0',
1556
+ border: 'none',
1557
+ },
1558
+ },
1559
+ legend: {
1560
+ base: {
1561
+ paddingBlock: 'var(--space-sm)',
1562
+ paddingInline: '0',
1563
+ fontSize: 'var(--text-sm)',
1564
+ fontWeight: 'var(--weight-semibold)',
1565
+ color: 'var(--fieldset-accent)',
1566
+ },
1567
+ states: {
1568
+ disabled: { opacity: 'var(--disabled-opacity)' },
1569
+ invalid: { color: roleInk('error') },
1570
+ },
1571
+ },
1572
+ },
1573
+ variants: {
1574
+ color: Object.fromEntries(ROLES.map((c) => [c, { root: { base: {
1575
+ '--fieldset-accent': roleInk(c),
1576
+ } } }])),
1577
+ size: {
1578
+ xs: { legend: { base: { fontSize: 'var(--text-xs)' } } },
1579
+ sm: { legend: { base: { fontSize: 'var(--text-xs)' } } },
1580
+ // `md` is the un-attributed render.
1581
+ md: {},
1582
+ lg: { legend: { base: { fontSize: 'var(--text-md)' } } },
1583
+ xl: { legend: { base: { fontSize: 'var(--text-lg)' } } },
1584
+ },
1585
+ },
1586
+ };
1587
+
1359
1588
  /**
1360
1589
  * daisy's tick, drawn by a clip-path that GROWS.
1361
1590
  *
@@ -1432,6 +1661,8 @@ export const checkbox: RecipeInput = {
1432
1661
  root: {
1433
1662
  base: { display: 'inline-flex', alignItems: 'center', gap: 'var(--space-md)', cursor: 'pointer' },
1434
1663
  states: {
1664
+ // Readonly answers to nothing, so it does not invite a click.
1665
+ readonly: { cursor: 'default' },
1435
1666
  disabled: { opacity: 'var(--disabled-opacity)', cursor: 'not-allowed' },
1436
1667
  checked: {}, unchecked: {}, indeterminate: {},
1437
1668
  },
@@ -1504,6 +1735,8 @@ export const checkbox: RecipeInput = {
1504
1735
  // `invalid` is semantic, not an accent: it stays error under
1505
1736
  // every colour variant, on purpose.
1506
1737
  invalid: { borderColor: 'var(--color-error)' },
1738
+ // Readonly answers to nothing, so it does not invite a click.
1739
+ readonly: { cursor: 'default' },
1507
1740
  disabled: {},
1508
1741
  },
1509
1742
  },
@@ -1630,13 +1863,9 @@ export const radioGroup: RecipeInput = {
1630
1863
  parts: {
1631
1864
  root: {
1632
1865
  base: { display: 'flex', flexDirection: 'column', gap: 'var(--space-md)' },
1866
+ // `invalid` paints on each `item-control`, which carries the flag
1867
+ // itself (#267) — the root only groups the items.
1633
1868
  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
1869
  },
1641
1870
  label: {
1642
1871
  base: { fontSize: 'var(--text-sm)', fontWeight: 'var(--weight-semibold)' },
@@ -1645,6 +1874,8 @@ export const radioGroup: RecipeInput = {
1645
1874
  item: {
1646
1875
  base: { display: 'inline-flex', alignItems: 'center', gap: 'var(--space-md)', cursor: 'pointer' },
1647
1876
  states: {
1877
+ // Readonly answers to nothing, so it does not invite a click.
1878
+ readonly: { cursor: 'default' },
1648
1879
  disabled: { opacity: 'var(--disabled-opacity)', cursor: 'not-allowed' },
1649
1880
  checked: {}, unchecked: {},
1650
1881
  },
@@ -1679,6 +1910,10 @@ export const radioGroup: RecipeInput = {
1679
1910
  animation: 'var(--duration-fast) var(--ease-standard) zero-daisy-radio',
1680
1911
  },
1681
1912
  unchecked: {},
1913
+ // `invalid` is semantic: it stays error under every colour
1914
+ // variant, and after `checked` so it holds whichever radio is
1915
+ // chosen.
1916
+ invalid: { borderColor: 'var(--color-error)' },
1682
1917
  'focus-visible': { outline: '2px solid var(--radio-accent)', outlineOffset: '2px' },
1683
1918
  disabled: {},
1684
1919
  },
@@ -1750,6 +1985,48 @@ export const radioGroup: RecipeInput = {
1750
1985
  },
1751
1986
  };
1752
1987
 
1988
+ /**
1989
+ * CheckboxGroup (#282) — daisy has no group component; its forms stack
1990
+ * `.checkbox` rows under a `fieldset-legend`. The boxes keep the checkbox
1991
+ * recipe; the group owns the stack and the legend, which speaks Field's
1992
+ * label (colour through `roleInk`, #210).
1993
+ */
1994
+ export const checkboxGroup: RecipeInput = {
1995
+ component: 'checkbox-group',
1996
+ tokens: { '--checkbox-group-accent': 'var(--color-base-content)' },
1997
+ parts: {
1998
+ root: {
1999
+ base: { display: 'flex', flexDirection: 'column', gap: 'var(--space-md)' },
2000
+ // `invalid` and `readonly` paint on each box — the root only
2001
+ // lays the boxes out.
2002
+ states: { invalid: {}, required: {}, readonly: {} },
2003
+ selectors: {
2004
+ '&[data-orientation="horizontal"]': { flexDirection: 'row', flexWrap: 'wrap', columnGap: 'var(--space-lg)' },
2005
+ },
2006
+ },
2007
+ label: {
2008
+ base: { fontSize: 'var(--text-sm)', fontWeight: 'var(--weight-semibold)', color: 'var(--checkbox-group-accent)' },
2009
+ states: { disabled: { opacity: 'var(--disabled-opacity)' } },
2010
+ selectors: {
2011
+ '&[data-required]::after': { content: '" *"', color: roleInk('error') },
2012
+ },
2013
+ },
2014
+ },
2015
+ variants: {
2016
+ color: Object.fromEntries(ROLES.map((c) => [c, { root: { base: {
2017
+ '--checkbox-group-accent': roleInk(c),
2018
+ } } }])),
2019
+ size: {
2020
+ xs: { root: { base: { gap: 'var(--space-sm)' } }, label: { base: { fontSize: 'var(--text-xs)' } } },
2021
+ sm: { label: { base: { fontSize: 'var(--text-xs)' } } },
2022
+ md: {},
2023
+ lg: { label: { base: { fontSize: 'var(--text-md)' } } },
2024
+ xl: { root: { base: { gap: 'var(--space-lg)' } }, label: { base: { fontSize: 'var(--text-lg)' } } },
2025
+ },
2026
+ },
2027
+ skipStates: { label: ['invalid'] },
2028
+ };
2029
+
1753
2030
  // --------------------------------------------------------------------------
1754
2031
  // 4. progress — replace the whole export (only the two radius lines move)
1755
2032
  // --------------------------------------------------------------------------
@@ -1814,9 +2091,13 @@ export const progress: RecipeInput = {
1814
2091
  transition: 'width var(--duration-slow) var(--ease-standard)',
1815
2092
  },
1816
2093
  states: {
1817
- // `complete` is a semantic state, not an accent: it stays
1818
- // success regardless of the colour variant, on purpose.
1819
- complete: { background: progressFill('success', 55) },
2094
+ // `complete` keeps the colour axis. daisyUI 5 has no complete
2095
+ // tint — a full `progress-primary` stays primary — and a
2096
+ // `color="secondary"` bar that turns green at 100% reads as a
2097
+ // bug, not as meaning (signalxjs/lynx#1144). A caller who wants
2098
+ // "done" to look like success says so: `color="success"`.
2099
+ // Declared equal to `loading` in `sameAs` below.
2100
+ complete: {},
1820
2101
  loading: {},
1821
2102
  indeterminate: { width: '40%', animation: 'zero-daisy-indeterminate 1.2s ease-in-out infinite' },
1822
2103
  },
@@ -1852,6 +2133,8 @@ export const progress: RecipeInput = {
1852
2133
  // so the manifest states the default for runtimes with no `:not()`
1853
2134
  // fallback (the lynx target; signalxjs/lynx#1070).
1854
2135
  defaultVariants: { color: 'primary', size: 'md' },
2136
+ // A finished bar is a full bar of the same ink (see `complete` above).
2137
+ sameAs: { root: { complete: 'loading' }, range: { complete: 'loading' } },
1855
2138
  keyframes: {
1856
2139
  // Logical, so the sweep runs the way the bar fills — `margin-inline-start`
1857
2140
  // is animatable and direction-aware on its own. The determinate `width`,
@@ -1873,9 +2156,21 @@ export const progress: RecipeInput = {
1873
2156
  },
1874
2157
  };
1875
2158
 
2159
+ /** The slider's deepened fill — the web's `--slider-fill` mix, for a given accent. */
2160
+ const sliderFill = (accent: string): string =>
2161
+ `color-mix(in oklab, ${accent} 90%, var(--color-base-content))`;
2162
+
1876
2163
  export const slider: RecipeInput = {
1877
2164
  component: 'slider',
1878
- tokens: { '--slider-accent': 'var(--color-primary)' },
2165
+ // daisy's `.range` metrics: the thumb is `--range-thumb-size`
2166
+ // (×6 of `--size-selector` at md, ×4…×8 across the ramp — the same
2167
+ // selector ramp checkbox rides) and the track is half of it. Both the
2168
+ // native control and the composed parts read these.
2169
+ tokens: {
2170
+ '--slider-accent': 'var(--color-primary)',
2171
+ '--slider-track-size': 'calc(var(--size-selector) * 3)',
2172
+ '--slider-thumb-size': 'calc(var(--size-selector) * 6)',
2173
+ },
1879
2174
  parts: {
1880
2175
  root: {
1881
2176
  base: { display: 'flex', flexDirection: 'column', gap: 'var(--space-sm)', width: '100%' },
@@ -1885,30 +2180,106 @@ export const slider: RecipeInput = {
1885
2180
  base: { fontSize: 'var(--text-sm)', fontWeight: 'var(--weight-semibold)' },
1886
2181
  states: { disabled: {} },
1887
2182
  },
2183
+ // A custom skin (`appearance: none`), ported from daisyUI 5's
2184
+ // `range.css`: a `--radius-selector` track half the thumb's height,
2185
+ // and daisy's real range thumb — a `base-100` knob inside a
2186
+ // `.25rem` ring of the fill ink (daisy's `--range-p`), carrying the
2187
+ // same `--depth` inset shading the checkbox and radio wear. daisy
2188
+ // throws the elapsed fill as a 100cqw box-shadow off the thumb and
2189
+ // clips it with `overflow: hidden`; here the track reads the
2190
+ // runtime-published `--slider-percent` as a gradient stop instead,
2191
+ // as every other skin does. The fill is deepened toward
2192
+ // `base-content` the way `progressFill` deepens daisy's bar — a raw
2193
+ // role on the base-300 rail is exactly the #210/#228 bug class.
2194
+ //
2195
+ // The rebuild is also the correctness move (see the kit skill): Blink
2196
+ // treats range inputs as ALWAYS `:focus-visible`, even on mouse
2197
+ // focus, so the base kills the native outline and the
2198
+ // `focus-visible` state — compiled to the runtime's keyboard-only
2199
+ // `[data-focus-visible]` flag — draws the ring only when it means it.
1888
2200
  control: {
1889
- base: { width: '100%', accentColor: 'var(--slider-accent)', cursor: 'pointer' },
2201
+ base: {
2202
+ appearance: 'none',
2203
+ width: '100%',
2204
+ height: 'var(--slider-thumb-size)',
2205
+ margin: '0',
2206
+ background: 'transparent',
2207
+ cursor: 'pointer',
2208
+ outline: 'none',
2209
+ // Kept for the forced-colors fallback below, where native
2210
+ // rendering takes over and still honours the accent.
2211
+ accentColor: 'var(--slider-accent)',
2212
+ // `--slider-fill` and `--slider-track` live in targets.web
2213
+ // below: the fill is a color-mix() the lynx emitter drops,
2214
+ // and the track reads the runtime-published
2215
+ // `--slider-percent`, a web-only mechanism.
2216
+ },
1890
2217
  states: {
2218
+ // Readonly answers to nothing, so it does not invite a click.
2219
+ readonly: { cursor: 'default' },
1891
2220
  disabled: { cursor: 'not-allowed' },
1892
2221
  'focus-visible': { outline: '2px solid var(--slider-accent)', outlineOffset: '2px' },
1893
2222
  // `invalid` is semantic: it stays error under every colour
1894
- // variant, on purpose.
1895
- invalid: { accentColor: 'var(--color-error)' },
2223
+ // variant, on purpose — the accent indirection swaps the
2224
+ // fill, the thumb ring, the focus ring and the forced-colors
2225
+ // fallback together.
2226
+ invalid: { '--slider-accent': 'var(--color-error)' },
2227
+ },
2228
+ selectors: {
2229
+ // Vendor pseudos cannot share a selector list — one unknown
2230
+ // selector invalidates the whole rule — so each engine gets
2231
+ // its own copy reading the shared custom properties.
2232
+ '&::-webkit-slider-runnable-track': {
2233
+ height: 'var(--slider-track-size)',
2234
+ borderRadius: 'var(--radius-selector)',
2235
+ background: 'var(--slider-track)',
2236
+ },
2237
+ '&::-webkit-slider-thumb': {
2238
+ appearance: 'none',
2239
+ boxSizing: 'border-box',
2240
+ width: 'var(--slider-thumb-size)',
2241
+ height: 'var(--slider-thumb-size)',
2242
+ marginTop: 'calc((var(--slider-track-size) - var(--slider-thumb-size)) / 2)',
2243
+ borderRadius: 'calc(var(--radius-selector) + min(0.25rem, var(--radius-selector) * 3))',
2244
+ border: '0.25rem solid var(--slider-fill)',
2245
+ background: 'var(--color-base-100)',
2246
+ boxShadow: '0 -1px var(--depth-shade) inset, 0 8px 0 -4px var(--depth-sheen) inset',
2247
+ },
2248
+ '&::-moz-range-track': {
2249
+ height: 'var(--slider-track-size)',
2250
+ borderRadius: 'var(--radius-selector)',
2251
+ background: 'var(--slider-track)',
2252
+ },
2253
+ '&::-moz-range-thumb': {
2254
+ boxSizing: 'border-box',
2255
+ width: 'var(--slider-thumb-size)',
2256
+ height: 'var(--slider-thumb-size)',
2257
+ borderRadius: 'calc(var(--radius-selector) + min(0.25rem, var(--radius-selector) * 3))',
2258
+ border: '0.25rem solid var(--slider-fill)',
2259
+ background: 'var(--color-base-100)',
2260
+ boxShadow: '0 -1px var(--depth-shade) inset, 0 8px 0 -4px var(--depth-sheen) inset',
2261
+ },
2262
+ },
2263
+ at: {
2264
+ // Native rendering knows forced colors better than a custom
2265
+ // skin; the retained accentColor keeps the fallback branded.
2266
+ 'forced-colors': { base: { appearance: 'auto' } },
1896
2267
  },
1897
2268
  },
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.
2269
+ // The composed range projection (#325): the same metrics as the
2270
+ // native control above, restating its accent as real boxes. The
2271
+ // fill is the same deepened ink the native track paints.
1903
2272
  track: {
1904
2273
  base: {
1905
- height: 'calc(var(--size-selector) * 2)',
1906
- marginBlock: 'calc(var(--size-selector) * 1.5)',
2274
+ height: 'var(--slider-track-size)',
2275
+ // Reserve the thumb's overhang so the row's box matches the
2276
+ // native control's.
2277
+ marginBlock: 'calc((var(--slider-thumb-size) - var(--slider-track-size)) / 2)',
1907
2278
  borderRadius: '9999px',
1908
2279
  background: 'var(--color-base-300)',
1909
2280
  cursor: 'pointer',
1910
2281
  },
1911
- states: { disabled: { cursor: 'not-allowed' } },
2282
+ states: { readonly: { cursor: 'default' }, disabled: { cursor: 'not-allowed' } },
1912
2283
  },
1913
2284
  range: {
1914
2285
  base: {
@@ -1921,11 +2292,11 @@ export const slider: RecipeInput = {
1921
2292
  thumb: {
1922
2293
  base: {
1923
2294
  boxSizing: 'border-box',
1924
- width: 'calc(var(--size-selector) * 5)',
1925
- height: 'calc(var(--size-selector) * 5)',
2295
+ width: 'var(--slider-thumb-size)',
2296
+ height: 'var(--slider-thumb-size)',
1926
2297
  insetBlockStart: '50%',
1927
2298
  translate: '0 -50%',
1928
- marginInlineStart: 'calc(var(--size-selector) * -2.5)',
2299
+ marginInlineStart: 'calc(var(--slider-thumb-size) / -2)',
1929
2300
  borderRadius: '9999px',
1930
2301
  background: 'color-mix(in oklab, var(--slider-accent) 90%, var(--color-base-content))',
1931
2302
  cursor: 'pointer',
@@ -1935,12 +2306,14 @@ export const slider: RecipeInput = {
1935
2306
  states: {
1936
2307
  'focus-visible': { outline: '2px solid var(--slider-accent)', outlineOffset: '2px' },
1937
2308
  pressed: {},
2309
+ // Readonly answers to nothing, so it does not invite a click.
2310
+ readonly: { cursor: 'default' },
1938
2311
  disabled: { cursor: 'not-allowed' },
1939
2312
  },
1940
2313
  },
1941
2314
  mark: {
1942
2315
  base: {
1943
- paddingBlockStart: 'calc(var(--size-selector) * 2 + var(--space-2xs))',
2316
+ paddingBlockStart: 'calc(var(--slider-track-size) + var(--space-2xs))',
1944
2317
  fontSize: 'var(--text-xs)',
1945
2318
  lineHeight: '1',
1946
2319
  whiteSpace: 'nowrap',
@@ -1954,7 +2327,7 @@ export const slider: RecipeInput = {
1954
2327
  insetBlockStart: '0',
1955
2328
  insetInlineStart: '-1px',
1956
2329
  width: '2px',
1957
- height: 'calc(var(--size-selector) * 2)',
2330
+ height: 'var(--slider-track-size)',
1958
2331
  background: 'var(--color-base-content)',
1959
2332
  },
1960
2333
  },
@@ -1967,57 +2340,224 @@ export const slider: RecipeInput = {
1967
2340
  color: Object.fromEntries(ROLES.map((c) => [c, { root: { base: {
1968
2341
  '--slider-accent': `var(--color-${c})`,
1969
2342
  } } }])),
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.
2343
+ // daisy's `.range-{xs…xl}` ramp: `--range-thumb-size` at ×4…×8 of
2344
+ // `--size-selector` (1 / 1.25 / 1.5 / 1.75 / 2rem), the track half
2345
+ // of it — so every step moves the whole widget; the un-attributed
2346
+ // (and `md`) render takes the middle step from `tokens:`.
1972
2347
  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)' } } },
2348
+ 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)' } } },
2349
+ 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)' } } },
2350
+ // `md`'s metrics ARE the `tokens:` defaults — one source of truth.
1975
2351
  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)' } } },
2352
+ 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)' } } },
2353
+ 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
2354
  },
1979
2355
  },
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;
2356
+ // Base equals the default — `tokens:` binds the primary accent and the
2357
+ // middle-step metrics, and `md` only restates the label font-size the
2358
+ // base already declares — so the twins restate it;
1983
2359
  // declared for the manifest (signalxjs/lynx#1070).
1984
2360
  defaultVariants: { color: 'primary', size: 'md' },
1985
2361
  skipStates: { root: ['invalid', 'focus-visible'] },
1986
2362
  // 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.
2363
+ // screen twice, both times deepened with `color-mix()`: the native
2364
+ // control's gradient track and thumb ring (via `--slider-fill`, below),
2365
+ // and the composed range/thumb. Lynx renders neither — it has no native
2366
+ // `<input type=range>`, and the emitter drops `color-mix()` as
2367
+ // unresolvable — so the compiled range and thumb once shipped with no
2368
+ // paint at all: grey track, invisible fill, invisible knob (measured on
2369
+ // device, signalxjs/lynx#1075). The geometry all survives (the sizes are
2370
+ // direct calc() over the metric tokens); paint is the only gap, so the
2371
+ // lynx section only restates paint, in lynx-expressible spellings, plus
2372
+ // the physical restatement of the logical geometry.
1996
2373
  targets: {
2374
+ web: {
2375
+ parts: {
2376
+ control: {
2377
+ base: {
2378
+ // The fill ink, deepened like the composed range.
2379
+ '--slider-fill': 'color-mix(in oklab, var(--slider-accent) 90%, var(--color-base-content))',
2380
+ // The filled track reads the runtime-published
2381
+ // `--slider-percent` — web-only (`RUNTIME_PROPERTIES`);
2382
+ // the lynx runtime paints its own track/range parts.
2383
+ '--slider-track':
2384
+ 'linear-gradient(to right, var(--slider-fill) var(--slider-percent, 50%), var(--color-base-300) 0)',
2385
+ },
2386
+ selectors: {
2387
+ // A vertical native range is spelled through writing
2388
+ // mode by the runtime (#170), so the long axis is the
2389
+ // box's HEIGHT and the channel's WIDTH; the fill
2390
+ // grows from the foot.
2391
+ '&[data-orientation="vertical"]': {
2392
+ width: 'var(--slider-thumb-size)',
2393
+ height: 'var(--slider-length)',
2394
+ '--slider-track':
2395
+ 'linear-gradient(to top, var(--slider-fill) var(--slider-percent, 50%), var(--color-base-300) 0)',
2396
+ },
2397
+ '&[data-orientation="vertical"]::-webkit-slider-runnable-track': {
2398
+ width: 'var(--slider-track-size)',
2399
+ height: 'auto',
2400
+ },
2401
+ // Blink lays the thumb against the channel's block
2402
+ // axis — in vertical-lr that is its left edge.
2403
+ '&[data-orientation="vertical"]::-webkit-slider-thumb': {
2404
+ marginTop: '0',
2405
+ marginBlockStart: 'calc((var(--slider-track-size) - var(--slider-thumb-size)) / 2)',
2406
+ },
2407
+ '&[data-orientation="vertical"]::-moz-range-track': {
2408
+ width: 'var(--slider-track-size)',
2409
+ height: '100%',
2410
+ },
2411
+ },
2412
+ },
2413
+ // Vertical (#170): the runtime positions the moving parts by
2414
+ // physical `bottom` percents; these turn the channel upright and
2415
+ // re-centre the knob on the other axis. Web-only — the lynx
2416
+ // runtime ships no vertical slider.
2417
+ root: {
2418
+ selectors: {
2419
+ '&[data-orientation="vertical"]': {
2420
+ width: 'auto',
2421
+ alignItems: 'center',
2422
+ '--slider-length': 'calc(var(--size-selector) * 40)',
2423
+ },
2424
+ },
2425
+ },
2426
+ track: {
2427
+ selectors: {
2428
+ '&[data-orientation="vertical"]': {
2429
+ width: 'var(--slider-track-size)',
2430
+ height: 'var(--slider-length)',
2431
+ marginBlock: '0',
2432
+ marginInline: 'calc((var(--slider-thumb-size) - var(--slider-track-size)) / 2)',
2433
+ },
2434
+ },
2435
+ },
2436
+ range: {
2437
+ selectors: {
2438
+ '&[data-orientation="vertical"]': { insetInlineStart: '0', width: '100%' },
2439
+ },
2440
+ },
2441
+ thumb: {
2442
+ selectors: {
2443
+ '&[data-orientation="vertical"]': {
2444
+ insetBlockStart: 'auto',
2445
+ insetInlineStart: '50%',
2446
+ translate: 'none',
2447
+ marginInlineStart: 'calc(var(--slider-thumb-size) / -2)',
2448
+ marginBlockEnd: 'calc(var(--slider-thumb-size) / -2)',
2449
+ },
2450
+ },
2451
+ },
2452
+ // The label sits beside the channel, centred on its tick.
2453
+ mark: {
2454
+ selectors: {
2455
+ '&[data-orientation="vertical"]': {
2456
+ insetInlineStart: '0',
2457
+ paddingBlockStart: '0',
2458
+ paddingInlineStart: 'calc(var(--slider-track-size) + var(--space-2xs))',
2459
+ translate: '0 50%',
2460
+ },
2461
+ '&[data-orientation="vertical"]::before': {
2462
+ insetBlockStart: '50%',
2463
+ insetInlineStart: '0',
2464
+ width: 'var(--slider-track-size)',
2465
+ height: '2px',
2466
+ translate: '0 -50%',
2467
+ },
2468
+ },
2469
+ },
2470
+ },
2471
+ },
1997
2472
  lynx: {
2473
+ // The web's deepened fill, theme-baked: `color-mix()` over theme
2474
+ // colours cannot resolve on device, but the emitter restates a
2475
+ // colour function per theme as literals (as `progress` does for
2476
+ // its accent), so each colour step gets the same 90/10 mix the
2477
+ // web track and knob ring wear. Bound on the root like
2478
+ // `--slider-accent`, rebound by `color` below and by `invalid`
2479
+ // on the control (which wraps the rail on lynx).
2480
+ tokens: { '--slider-fill': sliderFill('var(--color-primary)') },
2481
+ variants: {
2482
+ color: Object.fromEntries(ROLES.map((c) => [c, { root: { base: {
2483
+ '--slider-fill': sliderFill(`var(--color-${c})`),
2484
+ } } }])),
2485
+ },
1998
2486
  parts: {
2487
+ // Vertical (lynx#1144): the lynx runtime positions the moving
2488
+ // parts itself — `top` percentages, the knob centred by
2489
+ // `left: 50%` plus the base's `translateY(-50%)` and
2490
+ // negative `margin-left` — so the class rules only turn the
2491
+ // channel upright: the control is a thumb-wide column of
2492
+ // daisy's rail length, the track fills it.
2493
+ root: {
2494
+ selectors: {
2495
+ '&[data-orientation="vertical"]': { width: 'auto', alignItems: 'center' },
2496
+ },
2497
+ },
2498
+ control: {
2499
+ states: {
2500
+ invalid: { '--slider-fill': sliderFill('var(--color-error)') },
2501
+ },
2502
+ selectors: {
2503
+ '&[data-orientation="vertical"]': {
2504
+ display: 'flex',
2505
+ flexDirection: 'row',
2506
+ justifyContent: 'center',
2507
+ width: 'var(--slider-thumb-size)',
2508
+ height: 'calc(var(--size-selector) * 40)',
2509
+ },
2510
+ },
2511
+ },
1999
2512
  track: {
2000
2513
  // The web track's `margin-block` breathing room,
2001
2514
  // restated physically: logical spellings resolve on iOS
2002
2515
  // but not on Android (measured, signalxjs/lynx#1084), so
2003
2516
  // the emitter refuses them. Physical is the lynx
2004
2517
  // target's norm — no RTL flow there.
2518
+ //
2519
+ // daisy's channel radius is the selector radius (the
2520
+ // native control's runnable-track), not a pill.
2005
2521
  base: {
2006
- marginTop: 'calc(var(--size-selector) * 1.5)',
2007
- marginBottom: 'calc(var(--size-selector) * 1.5)',
2522
+ marginTop: 'calc((var(--slider-thumb-size) - var(--slider-track-size)) / 2)',
2523
+ marginBottom: 'calc((var(--slider-thumb-size) - var(--slider-track-size)) / 2)',
2524
+ borderRadius: 'var(--radius-selector)',
2525
+ },
2526
+ selectors: {
2527
+ '&[data-orientation="vertical"]': {
2528
+ width: 'var(--slider-track-size)',
2529
+ height: '100%',
2530
+ marginTop: '0',
2531
+ marginBottom: '0',
2532
+ },
2008
2533
  },
2009
2534
  },
2010
2535
  mark: {
2011
- // Same #1084 verdict: the tick labels hang below the
2012
- // track off a physical padding.
2013
- base: { paddingTop: 'calc(var(--size-selector) * 2 + var(--space-2xs))' },
2536
+ // The web draws the tick with `::before`, which lynx has
2537
+ // no projection for — the mark was an empty, invisible
2538
+ // box. Here the mark IS the tick: a 2px rule across the
2539
+ // channel, centred on its runtime-set `left` percent.
2540
+ // (Lynx marks carry no label text.)
2541
+ base: {
2542
+ top: '0',
2543
+ width: '2px',
2544
+ height: 'var(--slider-track-size)',
2545
+ marginLeft: '-1px',
2546
+ paddingTop: '0',
2547
+ backgroundColor: 'var(--color-base-content)',
2548
+ },
2549
+ selectors: {
2550
+ '&[data-orientation="vertical"]': {
2551
+ left: '0',
2552
+ width: 'var(--slider-track-size)',
2553
+ height: '2px',
2554
+ marginLeft: '0',
2555
+ marginTop: '-1px',
2556
+ },
2557
+ },
2014
2558
  },
2015
2559
  range: {
2016
- // The web fill deepens the accent 90/10 toward
2017
- // base-content (`progressFill`'s recipe); the plain
2018
- // accent — a var() chain, proven working on device — is
2019
- // the closest lynx-expressible equivalent.
2020
- base: { background: 'var(--slider-accent)' },
2560
+ base: { background: 'var(--slider-fill)', borderRadius: 'var(--radius-selector)' },
2021
2561
  },
2022
2562
  thumb: {
2023
2563
  // daisy's real range thumb, ported from daisyUI 5
@@ -2028,13 +2568,16 @@ export const slider: RecipeInput = {
2028
2568
  // `--range-thumb` defaults to `var(--color-base-100)`,
2029
2569
  // `--range-p` is daisy's literal `.25rem` (deliberately
2030
2570
  // not the theme's size token), and the border ink is the
2031
- // accent (currentColor on a daisy `.range-*` IS the
2032
- // accent role) — because on lynx a named value is a
2033
- // var-chain fewer to resolve. daisy paints the progress
2034
- // fill itself as a 100cqw box-shadow thrown off this
2035
- // thumb — a native-input trick with no lynx equivalent;
2036
- // the composed `.zx-slider__range` box above IS that
2037
- // fill here, so the knob only needs its own two paints.
2571
+ // deepened fill the web's native thumb ring reads. daisy
2572
+ // paints the progress fill itself as a 100cqw box-shadow
2573
+ // thrown off this thumb — a native-input trick with no
2574
+ // lynx equivalent; the composed `.zx-slider__range` box
2575
+ // above IS that fill here, so the knob only needs its own
2576
+ // paints. Its radius is daisy's formula minus the min()
2577
+ // clamp lynx cannot evaluate — exact for every shipped
2578
+ // theme (all set `--radius-selector` ≥ .25rem / 3) — and
2579
+ // it wears daisy's depth shading (`currentColor`'s 10%
2580
+ // outer shadow spelled as the theme's shade).
2038
2581
  //
2039
2582
  // The centering geometry is restated physically beside
2040
2583
  // the paint: `inset-block-start`/`translate`/
@@ -2045,10 +2588,12 @@ export const slider: RecipeInput = {
2045
2588
  // proven on both platforms.
2046
2589
  base: {
2047
2590
  background: 'var(--color-base-100)',
2048
- border: '0.25rem solid var(--slider-accent)',
2591
+ border: '0.25rem solid var(--slider-fill)',
2592
+ borderRadius: 'calc(var(--radius-selector) + 0.25rem)',
2593
+ boxShadow: '0 -1px var(--depth-shade) inset, 0 8px 0 -4px var(--depth-sheen) inset, 0 1px var(--depth-shade)',
2049
2594
  top: '50%',
2050
2595
  transform: 'translateY(-50%)',
2051
- marginLeft: 'calc(var(--size-selector) * -2.5)',
2596
+ marginLeft: 'calc(var(--slider-thumb-size) / -2)',
2052
2597
  },
2053
2598
  },
2054
2599
  },
@@ -2058,12 +2603,26 @@ export const slider: RecipeInput = {
2058
2603
 
2059
2604
  export const accordion: RecipeInput = {
2060
2605
  component: 'accordion',
2606
+ targets: {
2607
+ ...disclosureExit('accordion'),
2608
+ lynx: {
2609
+ parts: {
2610
+ // No hover on a touch platform: the held trigger takes daisy's
2611
+ // hover fill. The ::after chevron has no lynx counterpart (no
2612
+ // pseudo-elements, and the anatomy declares no part for it) —
2613
+ // an app draws its own glyph in the trigger.
2614
+ trigger: { states: { pressed: { background: 'var(--color-base-200)' } } },
2615
+ },
2616
+ },
2617
+ },
2061
2618
  // Same accent grammar as collapsible: the open heading's ink, defaulting
2062
2619
  // to base-content so the un-attributed render is unchanged.
2063
2620
  tokens: { '--accordion-accent': 'var(--color-base-content)' },
2064
2621
  parts: {
2065
2622
  root: {
2066
2623
  base: { display: 'flex', flexDirection: 'column', gap: 'var(--space-md)' },
2624
+ // `orientation="horizontal"` (#276): the cards sit in a row.
2625
+ selectors: { '&[data-orientation="horizontal"]': { flexDirection: 'row', alignItems: 'start' } },
2067
2626
  },
2068
2627
  item: withPresence(disclosurePresence, {
2069
2628
  base: {
@@ -2073,6 +2632,7 @@ export const accordion: RecipeInput = {
2073
2632
  overflow: 'hidden',
2074
2633
  },
2075
2634
  states: { open: {}, closed: {} },
2635
+ selectors: { '[data-scope="accordion"][data-part="root"][data-orientation="horizontal"] > &': { flex: '1 1 0', minInlineSize: '0' } },
2076
2636
  }),
2077
2637
  trigger: {
2078
2638
  base: {
@@ -2157,14 +2717,21 @@ const fieldControl: NonNullable<PartStyles['base']> = {
2157
2717
  boxShadow: 'var(--shadow-xs)',
2158
2718
  };
2159
2719
 
2720
+ /**
2721
+ * A select whose clear-trigger is rendered (#280) — anchored on the root, so
2722
+ * a part inside the trigger can make room for the button laid over it.
2723
+ */
2724
+ const SELECT_CLEARABLE = '[data-scope="select"][data-part="root"]:has(> [data-scope="select"][data-part="clear-trigger"]) &';
2725
+
2160
2726
  export const select: RecipeInput = {
2161
2727
  component: 'select',
2162
2728
  // Accent as text/border ink only — daisy's highlighted item is a neutral
2163
2729
  // base-200 wash, so no `-on-accent` content colour is consumed here.
2164
2730
  tokens: { '--select-accent': 'var(--color-primary)' },
2165
2731
  parts: {
2732
+ // Positioned: the clear-trigger sits over the trigger's inline end.
2166
2733
  root: {
2167
- base: { display: 'inline-flex', flexDirection: 'column' },
2734
+ base: { display: 'inline-flex', flexDirection: 'column', position: 'relative' },
2168
2735
  },
2169
2736
  trigger: {
2170
2737
  base: {
@@ -2177,6 +2744,8 @@ export const select: RecipeInput = {
2177
2744
  },
2178
2745
  states: {
2179
2746
  hover: { borderColor: 'var(--color-base-content)' },
2747
+ // Readonly answers to nothing, so it does not invite a click.
2748
+ readonly: { cursor: 'default' },
2180
2749
  disabled: { opacity: 'var(--disabled-opacity)', cursor: 'not-allowed' },
2181
2750
  open: { borderColor: 'var(--select-accent)' },
2182
2751
  closed: {},
@@ -2213,6 +2782,41 @@ export const select: RecipeInput = {
2213
2782
  indicator: {
2214
2783
  base: { opacity: '0.6', transition: 'transform var(--duration-normal) var(--ease-standard)' },
2215
2784
  states: { open: { transform: 'rotate(180deg)' }, closed: {} },
2785
+ // Room for the clear-trigger laid over the field (#280).
2786
+ selectors: { [SELECT_CLEARABLE]: { marginInlineStart: 'calc(var(--space-2xl) + var(--space-sm))' } },
2787
+ },
2788
+ // Clears the selection (#280): daisy's ghost-btn over the field's
2789
+ // inline end, just before the chevron — muted like it, the base-200
2790
+ // wash on hover, the base-content ring on focus.
2791
+ 'clear-trigger': {
2792
+ base: {
2793
+ appearance: 'none',
2794
+ position: 'absolute',
2795
+ insetBlock: '0',
2796
+ // Centred in the field at WCAG 2.5.8's 24px target, shrinking
2797
+ // only with a field too short to hold it.
2798
+ marginBlock: 'auto',
2799
+ blockSize: 'min(1.5rem, 100%)',
2800
+ minInlineSize: '1.5rem',
2801
+ insetInlineEnd: 'calc(var(--size-field) * 4 + 1em + var(--space-xs))',
2802
+ display: 'inline-flex',
2803
+ alignItems: 'center',
2804
+ justifyContent: 'center',
2805
+ padding: '0 var(--space-xs)',
2806
+ border: 'none',
2807
+ background: 'transparent',
2808
+ color: 'color-mix(in oklab, var(--color-base-content) 70%, transparent)',
2809
+ font: 'inherit',
2810
+ fontSize: 'var(--text-sm)',
2811
+ lineHeight: 'var(--leading-none)',
2812
+ borderRadius: 'var(--radius-field)',
2813
+ cursor: 'pointer',
2814
+ transition: 'background var(--duration-fast) var(--ease-standard)',
2815
+ },
2816
+ states: {
2817
+ hover: { background: 'var(--color-base-200)', color: 'var(--color-base-content)' },
2818
+ ...focusRing,
2819
+ },
2216
2820
  },
2217
2821
  popup: withPresence(popupPresence('translateY(-4px)'), {
2218
2822
  base: { ...floatingPanel, padding: 'var(--space-md)', minWidth: '13rem' },
@@ -2261,6 +2865,14 @@ export const select: RecipeInput = {
2261
2865
  base: { fontSize: 'var(--text-xs)', color: 'var(--select-accent)' },
2262
2866
  states: { selected: {} },
2263
2867
  },
2868
+ // The menu's rule between runs of options (#280).
2869
+ separator: {
2870
+ base: {
2871
+ height: 'var(--border)',
2872
+ margin: 'var(--space-sm) var(--space-md)',
2873
+ background: 'var(--color-base-300)',
2874
+ },
2875
+ },
2264
2876
  },
2265
2877
  variants: {
2266
2878
  color: Object.fromEntries(ROLES.map((c) => [c, { root: { base: {
@@ -2283,8 +2895,28 @@ export const select: RecipeInput = {
2283
2895
  // The trigger ramp restates `md` too (unlike `btnSizes`), so its lynx
2284
2896
  // counterpart does as well.
2285
2897
  targets: {
2898
+ web: { parts: { popup: { base: anchoredListbox } } },
2286
2899
  lynx: {
2287
- parts: { trigger: { base: lynxBtnPad(4) } },
2900
+ parts: {
2901
+ // daisy's `.select` is `width: clamp(3rem, 20rem, 100%)`: 20rem,
2902
+ // never wider than its container. lynx has no `clamp()`, so the
2903
+ // same bounds are spelled out — and the base's 13rem floor
2904
+ // comes down to daisy's 3rem, or the trigger overflowed any
2905
+ // cell narrower than 208px (signalxjs/lynx#1146). The root
2906
+ // caps too, so a shrink-wrapped root cannot push it back out.
2907
+ root: { base: { maxWidth: '100%' } },
2908
+ trigger: {
2909
+ base: { ...lynxBtnPad(4), width: '20rem', maxWidth: '100%', minWidth: '3rem' },
2910
+ // The web's `:hover` border is dropped on a touch
2911
+ // platform; a press gets a wash instead — a background,
2912
+ // so it never fights the open/invalid border inks.
2913
+ states: { pressed: { background: 'var(--color-base-200)' } },
2914
+ },
2915
+ // A touch list has no highlight: the pressed item takes the
2916
+ // highlighted item's wash.
2917
+ item: { states: { pressed: { background: 'var(--color-base-200)' } } },
2918
+ 'clear-trigger': { base: { top: 'var(--space-xs)', bottom: 'var(--space-xs)', right: 'calc(var(--size-field) * 4 + 1em + var(--space-xs))', minWidth: '1.5rem' } },
2919
+ },
2288
2920
  variants: {
2289
2921
  size: { ...lynxBtnSizes('trigger'), md: { trigger: { base: lynxBtnPad(4) } } },
2290
2922
  },
@@ -2310,6 +2942,23 @@ const BTN_STEPS = { xs: 6, sm: 8, md: 10, lg: 12, xl: 14 } as const;
2310
2942
  const btnHeight = (step: keyof typeof BTN_STEPS): string =>
2311
2943
  `calc(var(--size-field) * ${BTN_STEPS[step]})`;
2312
2944
 
2945
+ /**
2946
+ * daisy 5's disabled btn, for the lynx target (signalxjs/lynx#1143). The web
2947
+ * fades the whole button (`opacity: var(--disabled-opacity)`, 0.3 on the
2948
+ * dark themes), which on device left a solid button's label barely legible.
2949
+ * daisy's own `.btn:disabled` paints instead: a base-content wash (not on
2950
+ * ghost/link), a transparent border, no shadow, and a muted base-content
2951
+ * label. daisy mutes that label to 20%; this keeps it at 50%, still plainly
2952
+ * inert but readable on a phone at arm's length.
2953
+ */
2954
+ const lynxBtnDisabled: CssProps = {
2955
+ opacity: '1',
2956
+ background: 'color-mix(in oklab, var(--color-base-content) 10%, transparent)',
2957
+ color: 'color-mix(in oklab, var(--color-base-content) 50%, transparent)',
2958
+ borderColor: 'transparent',
2959
+ boxShadow: 'none',
2960
+ };
2961
+
2313
2962
  export const button: RecipeInput = {
2314
2963
  component: 'button',
2315
2964
  // Public to a design system derived from this one (#73).
@@ -2525,11 +3174,39 @@ export const button: RecipeInput = {
2525
3174
  },
2526
3175
  defaultVariants: { color: 'primary', variant: 'solid', size: 'md' },
2527
3176
  keyframes: { 'zero-daisyui-btn-spin': 'to { transform: rotate(360deg) }' },
2528
- // The icon chips' zeroed inline padding, restated physically: logical
2529
- // spellings resolve on iOS but not on Android (measured,
2530
- // signalxjs/lynx#1084), so the emitter refuses them.
2531
3177
  targets: {
2532
3178
  lynx: {
3179
+ parts: {
3180
+ // The ring, restated: `currentColor` never resolves on lynx
3181
+ // (signalxjs/lynx#1079) and the logical sizes/edge are
3182
+ // unproven there, so the lynx ring is physical longhands in
3183
+ // the variant's own ink (the variants below pick it).
3184
+ spinner: {
3185
+ base: {
3186
+ width: '1em',
3187
+ height: '1em',
3188
+ border: 'calc(var(--border) * 2) solid var(--btn-ink)',
3189
+ borderTopColor: 'transparent',
3190
+ },
3191
+ },
3192
+ root: { states: { disabled: lynxBtnDisabled } },
3193
+ },
3194
+ variants: {
3195
+ // The baked disabled paint above outranks every variant's fill
3196
+ // (the theme host adds a class), but its plain declarations
3197
+ // do not: outline/dash restate the border, ghost/link drop
3198
+ // the wash (as in daisy).
3199
+ variant: {
3200
+ solid: { spinner: { base: { borderColor: 'var(--btn-on-accent)', borderTopColor: 'transparent' } } },
3201
+ outline: { root: { states: { disabled: { borderColor: 'transparent' } } } },
3202
+ dash: { root: { states: { disabled: { borderColor: 'transparent' } } } },
3203
+ ghost: { root: { states: { disabled: { background: 'transparent' } } } },
3204
+ link: { root: { states: { disabled: { background: 'transparent' } } } },
3205
+ },
3206
+ },
3207
+ // The icon chips' zeroed inline padding, restated physically: logical
3208
+ // spellings resolve on iOS but not on Android (measured,
3209
+ // signalxjs/lynx#1084), so the emitter refuses them.
2533
3210
  modifiers: {
2534
3211
  square: { root: { base: { paddingLeft: '0', paddingRight: '0' } } },
2535
3212
  circle: { root: { base: { paddingLeft: '0', paddingRight: '0' } } },
@@ -2770,21 +3447,66 @@ export const toast: RecipeInput = {
2770
3447
  // signalxjs/lynx#1070. No `color` default: the un-attributed toast is a
2771
3448
  // neutral base-200 surface, outside the color vocabulary.
2772
3449
  defaultVariants: { size: 'md' },
2773
- // The corner placements' physical anchors: logical inset spellings
2774
- // resolve on iOS but not on Android (measured, signalxjs/lynx#1084), so
2775
- // the emitter refuses them. Physical is the lynx target's norm — no RTL
2776
- // flow there, so `start` IS left and `end` IS right.
2777
3450
  targets: {
2778
3451
  lynx: {
2779
3452
  parts: {
3453
+ // The viewport is a full-width strip on lynx (the runtime pins
3454
+ // it to the outlet's top or bottom edge). What the web gets
3455
+ // from `:popover-open` and `min(24rem, 100vw)` is restated:
3456
+ // flex (a lynx view defaults to linear layout, which ignores
3457
+ // flex-direction and gap), and the cards aligned to the
3458
+ // placement's edge. The centred pair's `left: 50%` +
3459
+ // `translateX(-50%)` pull-back is for a shrink-wrapped popover;
3460
+ // on the strip it shifted everything half a width left, so it
3461
+ // is cancelled. The corner anchors are physical: logical
3462
+ // insets resolve on iOS but not on Android (measured,
3463
+ // signalxjs/lynx#1084), and lynx has no RTL flow, so `start`
3464
+ // IS left and `end` IS right.
2780
3465
  viewport: {
3466
+ base: { display: 'flex', width: 'auto' },
3467
+ selectors: {
3468
+ '&[data-placement="top-start"]': { left: '0', alignItems: 'flex-start' },
3469
+ '&[data-placement="top"]': { left: '0', right: '0', transform: 'none', alignItems: 'center' },
3470
+ '&[data-placement="top-end"]': { right: '0', alignItems: 'flex-end' },
3471
+ '&[data-placement="bottom-start"]': { left: '0', alignItems: 'flex-start' },
3472
+ '&[data-placement="bottom"]': { left: '0', right: '0', transform: 'none', alignItems: 'center' },
3473
+ '&[data-placement="bottom-end"]': { right: '0', alignItems: 'flex-end' },
3474
+ },
3475
+ },
3476
+ // No grid on lynx (signalxjs/lynx#1075): the card is a flex
3477
+ // column (title, description, then the action), with the
3478
+ // close button pinned to the top-right corner and the title
3479
+ // kept clear of it. The web's 24rem viewport cap becomes the
3480
+ // card's own cap. The `^="top"` entry offset has no class
3481
+ // form, so each top placement restates it.
3482
+ root: {
3483
+ base: {
3484
+ display: 'flex',
3485
+ flexDirection: 'column',
3486
+ alignItems: 'flex-start',
3487
+ position: 'relative',
3488
+ boxSizing: 'border-box',
3489
+ width: '100%',
3490
+ maxWidth: '24rem',
3491
+ },
2781
3492
  selectors: {
2782
- '&[data-placement="top-start"]': { left: '0' },
2783
- '&[data-placement="top-end"]': { right: '0' },
2784
- '&[data-placement="bottom-start"]': { left: '0' },
2785
- '&[data-placement="bottom-end"]': { right: '0' },
3493
+ '&[data-placement="top-start"]': { '--toast-from': '-8px' },
3494
+ '&[data-placement="top"]': { '--toast-from': '-8px' },
3495
+ '&[data-placement="top-end"]': { '--toast-from': '-8px' },
2786
3496
  },
2787
3497
  },
3498
+ title: { base: { paddingRight: '1.75em' } },
3499
+ // The 75% ink mix over a recipe-local property cannot bake;
3500
+ // the same ink at 75% opacity reads the same on the soft fill.
3501
+ description: { base: { color: 'var(--toast-ink)', opacity: '0.75' } },
3502
+ // The 25% ink border cannot bake either: it takes the full ink.
3503
+ action: {
3504
+ base: {
3505
+ marginTop: 'var(--space-xs)',
3506
+ border: 'var(--border) solid var(--toast-ink)',
3507
+ },
3508
+ },
3509
+ close: { base: { position: 'absolute', top: 'var(--space-xs)', right: 'var(--space-xs)' } },
2788
3510
  },
2789
3511
  },
2790
3512
  },
@@ -2916,6 +3638,23 @@ export const combobox: RecipeInput = {
2916
3638
  disabled: { cursor: 'not-allowed' },
2917
3639
  },
2918
3640
  },
3641
+ // Clears the value and the text (#280): the chevron's ghost, one
3642
+ // step before it — muted, full ink on hover.
3643
+ 'clear-trigger': {
3644
+ base: {
3645
+ appearance: 'none',
3646
+ border: 'none',
3647
+ background: 'transparent',
3648
+ color: 'inherit',
3649
+ font: 'inherit',
3650
+ opacity: '0.6',
3651
+ padding: '0 var(--space-xs)',
3652
+ lineHeight: 'var(--leading-none)',
3653
+ cursor: 'pointer',
3654
+ transition: 'opacity var(--duration-fast) var(--ease-standard)',
3655
+ },
3656
+ states: { hover: { opacity: '1' } },
3657
+ },
2919
3658
  popup: withPresence(popupPresence('translateY(-4px)'), {
2920
3659
  base: { ...floatingPanel, padding: 'var(--space-md)', minWidth: '13rem' },
2921
3660
  states: { open: {}, closed: {} },
@@ -2971,6 +3710,23 @@ export const combobox: RecipeInput = {
2971
3710
  opacity: '0.6',
2972
3711
  },
2973
3712
  },
3713
+ // The list still arriving (#280): the empty row's muted grammar.
3714
+ loading: {
3715
+ base: {
3716
+ padding: 'var(--space-lg)',
3717
+ fontSize: 'var(--text-sm)',
3718
+ textAlign: 'center',
3719
+ opacity: '0.6',
3720
+ },
3721
+ },
3722
+ // The menu's rule between runs of options (#280).
3723
+ separator: {
3724
+ base: {
3725
+ height: 'var(--border)',
3726
+ margin: 'var(--space-sm) var(--space-md)',
3727
+ background: 'var(--color-base-300)',
3728
+ },
3729
+ },
2974
3730
  },
2975
3731
  variants: {
2976
3732
  color: Object.fromEntries(ROLES.map((c) => [c, { root: { base: {
@@ -2993,6 +3749,9 @@ export const combobox: RecipeInput = {
2993
3749
  input: ['focus-visible'],
2994
3750
  trigger: ['focus-visible'],
2995
3751
  },
3752
+ targets: {
3753
+ web: { parts: { popup: { base: anchoredListbox } } },
3754
+ },
2996
3755
  };
2997
3756
 
2998
3757
  // daisy "btn" that stays pressed: the off state is the neutral btn, the on
@@ -3606,7 +4365,9 @@ export const treeView: RecipeInput = {
3606
4365
  opacity: '0.6',
3607
4366
  transition: 'transform var(--duration-fast) var(--ease-standard)',
3608
4367
  },
3609
- states: { open: { transform: 'rotate(90deg)' }, closed: {} },
4368
+ // Loading: the chevron halts at daisy's own collapse-arrow angle —
4369
+ // static (no loop), ink unchanged, so contrast is untouched.
4370
+ states: { open: { transform: 'rotate(90deg)' }, closed: {}, loading: { transform: 'rotate(45deg)' } },
3610
4371
  // The glyph is element text the runtime renders (`TreeView.tsx`), not
3611
4372
  // `content:`, so the `:dir(rtl)` swap the submenu chevron uses is not
3612
4373
  // available here — a mirror is its equivalent. `scale` composes
@@ -3625,6 +4386,9 @@ export const treeView: RecipeInput = {
3625
4386
  states: { open: {}, closed: {} },
3626
4387
  },
3627
4388
  },
4389
+ // Open-and-loading content lays out exactly as open content does — the
4390
+ // indicator and the treeitem's aria-busy carry the state.
4391
+ sameAs: { 'branch-content': { loading: 'open' } },
3628
4392
  variants: {
3629
4393
  // A tree colours one thing: the selected row. Everything else is
3630
4394
  // structure, and tinting it would fight the content.
@@ -3654,6 +4418,33 @@ export const treeView: RecipeInput = {
3654
4418
  },
3655
4419
  };
3656
4420
 
4421
+ /**
4422
+ * The affordances inside a text field (#281): daisy's in-field label ink and
4423
+ * the ghost circle button, both following the size step.
4424
+ */
4425
+ const affixInk = 'color-mix(in oklab, var(--color-base-content) 70%, transparent)';
4426
+ const fieldButton: NonNullable<PartStyles['base']> = {
4427
+ appearance: 'none',
4428
+ border: 'none',
4429
+ background: 'transparent',
4430
+ color: affixInk,
4431
+ borderRadius: '9999px',
4432
+ alignSelf: 'center',
4433
+ flex: 'none',
4434
+ order: '1',
4435
+ marginInlineEnd: 'var(--space-xs)',
4436
+ padding: 'var(--space-2xs) var(--space-xs)',
4437
+ fontSize: 'var(--text-sm)',
4438
+ fontWeight: 'var(--weight-semibold)',
4439
+ lineHeight: 'var(--leading-none)',
4440
+ cursor: 'pointer',
4441
+ };
4442
+ const affixSize = (fontSize: string) => ({
4443
+ adornment: { base: { fontSize } },
4444
+ 'clear-trigger': { base: { fontSize } },
4445
+ 'visibility-trigger': { base: { fontSize } },
4446
+ });
4447
+
3657
4448
  /**
3658
4449
  * daisy "input" flavor: the shared field box with nothing in it but the text.
3659
4450
  * Same `fieldControl` metrics as select's trigger, combobox's control and the
@@ -3680,6 +4471,7 @@ export const input: RecipeInput = {
3680
4471
  base: {
3681
4472
  display: 'inline-flex',
3682
4473
  alignItems: 'stretch',
4474
+ gap: 'var(--space-2xs)',
3683
4475
  ...fieldControl,
3684
4476
  overflow: 'hidden',
3685
4477
  transition: 'border-color var(--duration-fast) var(--ease-standard)',
@@ -3716,6 +4508,43 @@ export const input: RecipeInput = {
3716
4508
  '&::placeholder': { color: 'color-mix(in oklab, var(--color-base-content) 60%, transparent)' },
3717
4509
  },
3718
4510
  },
4511
+ // daisy's `label` inside `.input`: a quiet prefix/suffix at one edge,
4512
+ // ordered logically so it flips with the reading direction.
4513
+ adornment: {
4514
+ base: {
4515
+ display: 'inline-flex',
4516
+ alignItems: 'center',
4517
+ flex: 'none',
4518
+ color: affixInk,
4519
+ fontSize: 'var(--text-sm)',
4520
+ lineHeight: 'var(--leading-none)',
4521
+ },
4522
+ states: { disabled: {} },
4523
+ selectors: {
4524
+ '&[data-placement="start"]': { order: '-1', paddingInlineStart: 'var(--space-md)' },
4525
+ '&[data-placement="end"]': { order: '1', paddingInlineEnd: 'var(--space-md)' },
4526
+ },
4527
+ },
4528
+ // `btn btn-ghost btn-circle btn-xs` inside the field.
4529
+ 'clear-trigger': {
4530
+ base: fieldButton,
4531
+ states: {
4532
+ hover: { color: 'var(--color-base-content)', background: 'var(--color-base-200)' },
4533
+ disabled: { cursor: 'not-allowed' },
4534
+ ...focusRing,
4535
+ },
4536
+ },
4537
+ // Shown: the ghost button's active fill and full ink.
4538
+ 'visibility-trigger': {
4539
+ base: fieldButton,
4540
+ states: {
4541
+ on: { color: 'var(--color-base-content)', background: 'var(--color-base-200)' },
4542
+ off: {},
4543
+ hover: { color: 'var(--color-base-content)', background: 'var(--color-base-200)' },
4544
+ disabled: { cursor: 'not-allowed' },
4545
+ ...focusRing,
4546
+ },
4547
+ },
3719
4548
  },
3720
4549
  // The visible ring lives on `control`; the input delegates.
3721
4550
  skipStates: { input: ['focus-visible'] },
@@ -3728,13 +4557,13 @@ export const input: RecipeInput = {
3728
4557
  // The control carries the height off the shared field ramp; the text
3729
4558
  // follows so the box stays proportional.
3730
4559
  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)' } } },
4560
+ xs: { control: { base: { height: fieldHeight('xs') } }, input: { base: { fontSize: 'var(--text-xs)', padding: 'var(--space-2xs) var(--space-xs)' } }, ...affixSize('var(--text-xs)') },
4561
+ sm: { control: { base: { height: fieldHeight('sm') } }, input: { base: { fontSize: 'var(--text-xs)', padding: 'var(--space-xs) var(--space-sm)' } }, ...affixSize('var(--text-xs)') },
3733
4562
  // `md` is the un-attributed render: the base already IS the
3734
4563
  // middle step.
3735
4564
  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)' } } },
4565
+ lg: { control: { base: { height: fieldHeight('lg') } }, input: { base: { fontSize: 'var(--text-md)', padding: 'var(--space-md) var(--space-lg)' } }, ...affixSize('var(--text-md)') },
4566
+ xl: { control: { base: { height: fieldHeight('xl') } }, input: { base: { fontSize: 'var(--text-lg)', padding: 'var(--space-lg) var(--space-xl)' } }, ...affixSize('var(--text-lg)') },
3738
4567
  },
3739
4568
  },
3740
4569
  };
@@ -4462,8 +5291,17 @@ export const stats: RecipeInput = {
4462
5291
  tokens: { '--stats-accent': 'var(--color-base-content)' },
4463
5292
  parts: {
4464
5293
  root: {
5294
+ // The row's own scroll box (#43): a value never wraps or shrinks
5295
+ // (`nowrap` below), so a row too wide for its container scrolls
5296
+ // inside the root — daisyUI's own `.stats` behaviour, and Table's
5297
+ // and Pagination's answer — instead of pushing the page sideways.
5298
+ // Items keep their flex automatic minimum — their min-content
5299
+ // width — so the overflow lands here rather than in the next
5300
+ // item. No ring room: a stat is not focusable, and anything a
5301
+ // consumer puts in one sits inside the item's own padding.
4465
5302
  base: {
4466
5303
  display: 'flex',
5304
+ overflowX: 'auto',
4467
5305
  border: 'var(--border) solid var(--color-base-200)',
4468
5306
  borderRadius: 'var(--radius-box)',
4469
5307
  background: 'var(--color-base-100)',
@@ -4500,6 +5338,8 @@ export const stats: RecipeInput = {
4500
5338
  value: {
4501
5339
  base: {
4502
5340
  gridColumn: '1',
5341
+ // A figure is read whole: `$12 930` never breaks at its space.
5342
+ whiteSpace: 'nowrap',
4503
5343
  fontSize: 'var(--text-2xl)',
4504
5344
  fontWeight: 'var(--weight-bold)',
4505
5345
  fontVariantNumeric: 'tabular-nums',
@@ -4522,8 +5362,19 @@ export const stats: RecipeInput = {
4522
5362
  },
4523
5363
  },
4524
5364
  variants: {
4525
- color: Object.fromEntries(ROLES.map((c) => [c, { root: { base: {
4526
- '--stats-accent': `var(--color-${c})`,
5365
+ // Keyed on the ITEM, not the root (#161): the item re-carries
5366
+ // `color`, so a stat's own value outranks the row's, and the value
5367
+ // inside it inherits whichever won. A root colour still reaches
5368
+ // every item through the carrier's donut.
5369
+ //
5370
+ // The value is TEXT on the page, so the raw role is not always a
5371
+ // readable ink: measured by the text matrix once the item re-carried
5372
+ // the axis (#161), light warning is 1.76:1 and dark neutral 1.26:1
5373
+ // on base-100. `roleInk` is the shared answer for a role used as ink
5374
+ // (a per-role share of the role over base-content, which flips with
5375
+ // the scheme), the same one the field and button text use.
5376
+ color: Object.fromEntries(ROLES.map((c) => [c, { item: { base: {
5377
+ '--stats-accent': roleInk(c),
4527
5378
  } } }])),
4528
5379
  size: {
4529
5380
  xs: { value: { base: { fontSize: 'var(--text-lg)' } } },
@@ -4571,7 +5422,11 @@ export const timeline: RecipeInput = {
4571
5422
  padding: '0',
4572
5423
  },
4573
5424
  selectors: {
4574
- '&[data-orientation="horizontal"]': { flexDirection: 'row' },
5425
+ // A horizontal timeline is a row of steps whose width follows
5426
+ // the step count, not the container: at phone width it scrolls
5427
+ // inside its own box, the answer Table and Pagination give
5428
+ // (#45), rather than widening the page.
5429
+ '&[data-orientation="horizontal"]': { flexDirection: 'row', overflowX: 'auto' },
4575
5430
  },
4576
5431
  },
4577
5432
  /**
@@ -4715,6 +5570,87 @@ export const timeline: RecipeInput = {
4715
5570
  xl: { marker: { base: { '--timeline-marker-size': 'calc(var(--size-selector) * 4)' } }, content: { base: { fontSize: 'var(--text-md)' } } },
4716
5571
  },
4717
5572
  },
5573
+ /**
5574
+ * Lynx has no grid, and it does not resolve `inline-size`/`block-size`:
5575
+ * the item fell back to the engine's default layout and the marker,
5576
+ * sized only logically, stretched into a pill (signalxjs/lynx#1145).
5577
+ *
5578
+ * The item is a wrapping flex row instead. Vertical: line one is the
5579
+ * marker beside its content (start-side content ordered before the
5580
+ * marker), and the connector takes line two by itself (`flex-basis:
5581
+ * 100%`), drawn as a left border under the marker's centre, which is the
5582
+ * web's row-2 segment. Horizontal: line one is the marker with the
5583
+ * connector running on to the item's end, and the content wraps under it
5584
+ * (start-side content above). Content grows from a zero basis, so long
5585
+ * text never wraps onto its own line.
5586
+ *
5587
+ * The web collapses the start track when no item has start-side content
5588
+ * (`:has()`); lynx has no such test. The vertical connector sits under a
5589
+ * marker at the item's start edge, which is where the marker is unless
5590
+ * that item has start-side content: an alternating vertical timeline is
5591
+ * web-only for now.
5592
+ */
5593
+ targets: {
5594
+ lynx: {
5595
+ parts: {
5596
+ item: { base: { display: 'flex', flexDirection: 'row', flexWrap: 'wrap', alignItems: 'center' } },
5597
+ marker: {
5598
+ base: {
5599
+ width: 'var(--timeline-marker-size)',
5600
+ height: 'var(--timeline-marker-size)',
5601
+ flexGrow: '0',
5602
+ flexShrink: '0',
5603
+ },
5604
+ },
5605
+ connector: {
5606
+ selectors: {
5607
+ '&[data-orientation="vertical"]': {
5608
+ flexGrow: '0',
5609
+ flexShrink: '1',
5610
+ flexBasis: '100%',
5611
+ height: 'var(--space-lg)',
5612
+ background: 'transparent',
5613
+ borderLeftWidth: 'var(--border)',
5614
+ borderLeftStyle: 'solid',
5615
+ borderLeftColor: 'var(--color-base-300)',
5616
+ marginLeft: 'calc(var(--timeline-marker-size) / 2 - var(--border) / 2)',
5617
+ },
5618
+ '&[data-orientation="horizontal"]': {
5619
+ order: '1',
5620
+ flexGrow: '1',
5621
+ flexShrink: '1',
5622
+ flexBasis: '0',
5623
+ height: 'var(--border)',
5624
+ minWidth: 'var(--space-lg)',
5625
+ },
5626
+ },
5627
+ },
5628
+ content: {
5629
+ selectors: {
5630
+ '&[data-orientation="vertical"][data-placement="start"]': {
5631
+ order: '-1',
5632
+ flexGrow: '1',
5633
+ flexShrink: '1',
5634
+ flexBasis: '0',
5635
+ },
5636
+ '&[data-orientation="vertical"][data-placement="end"]': {
5637
+ flexGrow: '1',
5638
+ flexShrink: '1',
5639
+ flexBasis: '0',
5640
+ },
5641
+ '&[data-orientation="horizontal"][data-placement="start"]': {
5642
+ order: '-1',
5643
+ flexBasis: '100%',
5644
+ },
5645
+ '&[data-orientation="horizontal"][data-placement="end"]': {
5646
+ order: '2',
5647
+ flexBasis: '100%',
5648
+ },
5649
+ },
5650
+ },
5651
+ },
5652
+ },
5653
+ },
4718
5654
  };
4719
5655
 
4720
5656
  /** daisy chat: the classic bubble, colour-refilled per role. */
@@ -5272,9 +6208,27 @@ export const pagination: RecipeInput = {
5272
6208
  '--pg-accent-content': 'var(--color-primary-content)',
5273
6209
  '--pg-size': 'calc(var(--size-field) * 10)',
5274
6210
  '--pg-font': 'var(--text-sm)',
6211
+ // How far the focus ring reaches outside a control: the root's
6212
+ // scroll box pads by it so the ring is never clipped (#44).
6213
+ '--pg-ring-room': '4px',
5275
6214
  },
5276
6215
  parts: {
5277
- root: { base: { display: 'flex', alignItems: 'center', gap: 'var(--space-2xs)' } },
6216
+ root: {
6217
+ // The row's own scroll box (#44): the window is constant-width by
6218
+ // design, so a narrow container scrolls it rather than clipping
6219
+ // it — Table's answer. The padding is room for the focus ring's
6220
+ // 2px + 2px offset, which the scroll box would otherwise clip at
6221
+ // the row's edges; the matching scroll padding keeps it clear
6222
+ // when focus scrolls a page into view.
6223
+ base: {
6224
+ display: 'flex',
6225
+ alignItems: 'center',
6226
+ gap: 'var(--space-2xs)',
6227
+ overflowX: 'auto',
6228
+ padding: 'var(--pg-ring-room)',
6229
+ scrollPaddingInline: 'var(--pg-ring-room)',
6230
+ },
6231
+ },
5278
6232
  item: {
5279
6233
  base: {
5280
6234
  display: 'inline-flex',
@@ -5741,6 +6695,9 @@ export const drawer: RecipeInput = {
5741
6695
  */
5742
6696
  export const table: RecipeInput = {
5743
6697
  component: 'table',
6698
+ // A sortable header cell carries the sort state for `aria-sort`'s sake;
6699
+ // its trigger and indicator paint it.
6700
+ skipStates: { 'header-cell': ['ascending', 'descending', 'none'] },
5744
6701
  // Public to a design system derived from this one (#73).
5745
6702
  hooks: {
5746
6703
  properties: {
@@ -5768,6 +6725,11 @@ export const table: RecipeInput = {
5768
6725
  borderRadius: 'var(--radius-box)',
5769
6726
  background: 'var(--color-base-100)',
5770
6727
  },
6728
+ // The scroll box is a keyboard stop (#270): daisy's ink ring,
6729
+ // drawn inside the box (negative offset) — the root is as wide
6730
+ // as its column, so an outward ring would be clipped by
6731
+ // whatever scrolls around it.
6732
+ states: { 'focus-visible': { outline: '2px solid var(--color-base-content)', outlineOffset: '-2px' } },
5771
6733
  // Stacked (Table.Root stack, #55): zero lays each row out as a block
5772
6734
  // below the table's breakpoint; the card it becomes is drawn here.
5773
6735
  at: tableStackAt(tokens, 'root', { border: '0', borderRadius: '0', background: 'transparent' }),
@@ -5821,6 +6783,55 @@ export const table: RecipeInput = {
5821
6783
  },
5822
6784
  at: tableStackAt(tokens, 'header-cell', { paddingInline: '0' }),
5823
6785
  },
6786
+ // Sorting (#286): daisy's header label made a button — the sorted
6787
+ // column's label takes the full accent ink, and the mark beside it
6788
+ // turns with the direction.
6789
+ 'sort-trigger': {
6790
+ base: {
6791
+ appearance: 'none',
6792
+ display: 'inline-flex',
6793
+ alignItems: 'center',
6794
+ margin: '0',
6795
+ border: '0',
6796
+ font: 'inherit',
6797
+ letterSpacing: 'inherit',
6798
+ textTransform: 'inherit',
6799
+ textAlign: 'inherit',
6800
+ cursor: 'pointer',
6801
+ gap: 'var(--space-2xs)',
6802
+ padding: '0',
6803
+ borderRadius: 'var(--radius-selector)',
6804
+ background: 'transparent',
6805
+ color: 'inherit',
6806
+ transition: 'color var(--duration-fast) var(--ease-standard)',
6807
+ },
6808
+ states: {
6809
+ ascending: { color: 'var(--table-accent)' },
6810
+ descending: { color: 'var(--table-accent)' },
6811
+ none: {},
6812
+ hover: { color: 'var(--table-accent)' },
6813
+ disabled: { cursor: 'not-allowed', opacity: 'var(--disabled-opacity)' },
6814
+ ...focusRing,
6815
+ },
6816
+ at: { 'reduced-motion': { base: { transition: 'none' } } },
6817
+ },
6818
+ // zero's ▲, turned for descending; an unsorted column shows it only
6819
+ // under a hovered or keyboard-focused trigger.
6820
+ 'sort-indicator': {
6821
+ base: {
6822
+ display: 'inline-block',
6823
+ fontSize: '0.75em',
6824
+ lineHeight: '1',
6825
+ transition: 'transform var(--duration-fast) var(--ease-standard), opacity var(--duration-fast) var(--ease-standard)',
6826
+ },
6827
+ states: {
6828
+ ascending: {},
6829
+ descending: { transform: 'rotate(180deg)' },
6830
+ none: { opacity: '0' },
6831
+ },
6832
+ selectors: { '[data-scope="table"][data-part="sort-trigger"]:is(:hover, [data-focus-visible]) > &[data-state="none"]': { opacity: '0.6' } },
6833
+ at: { 'reduced-motion': { base: { transition: 'none' } } },
6834
+ },
5824
6835
  cell: {
5825
6836
  base: {
5826
6837
  padding: 'var(--table-pad-block) var(--table-pad-inline)',
@@ -5958,7 +6969,15 @@ export const fileUpload: RecipeInput = {
5958
6969
  borderRadius: 'var(--radius-field)',
5959
6970
  background: 'var(--color-base-100)',
5960
6971
  },
5961
- states: { disabled: { opacity: 'var(--disabled-opacity)' } },
6972
+ states: {
6973
+ disabled: { opacity: 'var(--disabled-opacity)' },
6974
+ // A rejected file the app renders through Item: daisy's
6975
+ // error border over a faint error wash.
6976
+ invalid: {
6977
+ borderColor: 'var(--color-error)',
6978
+ background: 'color-mix(in oklab, var(--color-error) 8%, var(--color-base-100))',
6979
+ },
6980
+ },
5962
6981
  },
5963
6982
  'item-name': {
5964
6983
  base: {
@@ -5994,6 +7013,26 @@ export const fileUpload: RecipeInput = {
5994
7013
  ...focusRing,
5995
7014
  },
5996
7015
  },
7016
+ // daisy's btn-ghost btn-xs: the remove button's pill, at text size.
7017
+ 'clear-trigger': {
7018
+ base: {
7019
+ appearance: 'none',
7020
+ border: 'none',
7021
+ background: 'transparent',
7022
+ color: 'inherit',
7023
+ borderRadius: '9999px',
7024
+ padding: 'var(--space-2xs) var(--space-sm)',
7025
+ fontSize: 'var(--fu-font)',
7026
+ fontWeight: 'var(--weight-semibold)',
7027
+ lineHeight: 'var(--leading-none)',
7028
+ cursor: 'pointer',
7029
+ },
7030
+ states: {
7031
+ hover: { background: 'var(--color-base-200)' },
7032
+ disabled: { opacity: 'var(--disabled-opacity)', cursor: 'not-allowed' },
7033
+ ...focusRing,
7034
+ },
7035
+ },
5997
7036
  },
5998
7037
  variants: {
5999
7038
  color: Object.fromEntries(ROLES.map((c) => [c, { root: { base: {
@@ -6338,6 +7377,9 @@ export const diff: RecipeInput = {
6338
7377
  background: 'var(--color-base-100)',
6339
7378
  borderRadius: 'var(--radius-box)',
6340
7379
  },
7380
+ // The root holds the images, which are content: disabled is the
7381
+ // handle's to show (#272).
7382
+ states: { disabled: {} },
6341
7383
  },
6342
7384
  before: {
6343
7385
  base: { gridArea: '1 / 1', minWidth: '0' },
@@ -6367,6 +7409,9 @@ export const diff: RecipeInput = {
6367
7409
  },
6368
7410
  states: {
6369
7411
  ...focusRing,
7412
+ // daisy's disabled look (#272): the uniform fade, and no grab
7413
+ // cursor — the images are content and stay unfaded.
7414
+ disabled: { opacity: 'var(--disabled-opacity)', cursor: 'not-allowed' },
6370
7415
  pressed: { '--diff-accent': 'color-mix(in oklab, var(--color-base-content) 85%, var(--color-base-100))' },
6371
7416
  },
6372
7417
  selectors: {
@@ -6441,7 +7486,7 @@ export const diff: RecipeInput = {
6441
7486
 
6442
7487
  export const recipes: RecipeInput[] = [
6443
7488
  tabs, collapsible, switchRecipe, dialog, popover, tooltip, menu,
6444
- field, checkbox, radioGroup, progress, slider, accordion, select, button, avatar, toast, combobox,
7489
+ field, fieldset, checkbox, checkboxGroup, radioGroup, progress, slider, accordion, select, button, avatar, toast, combobox,
6445
7490
  toggle, toggleGroup, numberInput, ratingGroup, treeView, input, textarea,
6446
7491
  card, alert, emptyState, badge, divider, skeleton, spinner,
6447
7492
  kbd, status, indicator, stats, timeline, chat, radialProgress, join,