@sigx/zero-daisyui 0.6.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.
package/dist/recipes.js CHANGED
@@ -1,4 +1,4 @@
1
- import { axisRoles, tableStackAt } from '@sigx/zero-kit/define';
1
+ import { axisRoles, popupArrow, popupArrowHost, tableStackAt } from '@sigx/zero-kit/define';
2
2
  import { roles, tokens } from './tokens.js';
3
3
  /**
4
4
  * Every role a consumer can pass as `color`, derived from the declaration
@@ -289,7 +289,9 @@ export const tabs = {
289
289
  },
290
290
  // daisy draws each flavor's active mark on the tab itself (the
291
291
  // underline, the lift, the pill) and none of them slides, so a
292
- // `Tabs.Indicator` renders nothing here (#283).
292
+ // `Tabs.Indicator` renders nothing here (#283). On lynx, which has
293
+ // no pseudo-elements, the `border` flavor's underline is drawn on
294
+ // this part instead (see `targets.lynx`).
293
295
  indicator: { base: { display: 'none' } },
294
296
  panel: {
295
297
  base: { fontSize: 'var(--text-md)' },
@@ -491,11 +493,17 @@ export const tabs = {
491
493
  targets: {
492
494
  lynx: {
493
495
  parts: {
494
- // The default active ink — base-content, exactly what the
495
- // shared `states.active.color` paints. One class, so any
496
+ // No hover on a touch platform: the held tab takes the ink
497
+ // daisy's hover gives it.
498
+ tab: { states: { pressed: { color: 'var(--color-base-content)' } } },
499
+ // The list is the indicator's containing block.
500
+ list: { base: { position: 'relative' } },
501
+ // The underline's ink (read by the `border` flavor below) —
502
+ // base-content by default, exactly what the shared
503
+ // `states.active.color` paints on the tab. One class, so any
496
504
  // color-axis definition below (host + two classes, baked per
497
505
  // theme) wins on specificity.
498
- tab: { base: { '--tab-active-ink': 'var(--color-base-content)' } },
506
+ indicator: { base: { '--tab-active-ink': 'var(--color-base-content)' } },
499
507
  },
500
508
  variants: {
501
509
  // `currentColor` never resolves on lynx (measured on device
@@ -505,18 +513,28 @@ export const tabs = {
505
513
  // theme colors through color-mix(), which the emitter bakes
506
514
  // once per theme — the same per-color/per-theme literal the
507
515
  // active `color:` rules already land as.
508
- color: Object.fromEntries(ROLES.map((c) => [c, { tab: { base: { '--tab-active-ink': roleInk(c) } } }])),
516
+ color: Object.fromEntries(ROLES.map((c) => [c, { indicator: { base: { '--tab-active-ink': roleInk(c) } } }])),
509
517
  variant: {
510
518
  // The underline is a ::before on the web, which lynx
511
- // drops — redrawn as a real bottom border on the tab
512
- // itself (transparent at rest so activation never moves
513
- // the row). The active ink rides `--tab-active-ink`
514
- // above — a plain var() chain, device-proven — instead
515
- // of `currentColor`, which lynx never resolves.
519
+ // drops. It is redrawn on the `indicator` part (zero#324),
520
+ // which lynx-zero renders in every list and positions
521
+ // over the active tab from measured geometry (the web's
522
+ // `--tabs-indicator-*` properties are web-only). The part
523
+ // IS the tab's box, so daisy's bar is its bottom edge:
524
+ // 3px, 80% wide and centred, like `left: 10%; width: 80%`
525
+ // on the web. The ink rides `--tab-active-ink` — a plain
526
+ // var() chain, device-proven — instead of `currentColor`,
527
+ // which lynx never resolves.
516
528
  border: {
517
- tab: {
518
- base: { borderBottom: '3px solid transparent' },
519
- states: { active: { borderBottomColor: 'var(--tab-active-ink)' } },
529
+ indicator: {
530
+ base: {
531
+ display: 'flex',
532
+ boxSizing: 'border-box',
533
+ borderBottomWidth: '3px',
534
+ borderBottomStyle: 'solid',
535
+ borderBottomColor: 'var(--tab-active-ink)',
536
+ transform: 'scaleX(0.8)',
537
+ },
520
538
  },
521
539
  },
522
540
  // `min()` does not exist on lynx (signalxjs/lynx#1066) and
@@ -864,6 +882,10 @@ export const switchRecipe = {
864
882
  // No currentColor on this target — the ink is named
865
883
  // directly, and `checked` moves border + fill below.
866
884
  border: 'var(--border) solid var(--switch-ink)',
885
+ // daisy's 1px inset depth line along the track's top
886
+ // edge — `currentColor` at `--depth` × 10% on the
887
+ // web, the theme's own depth shade here.
888
+ boxShadow: '0 1px var(--depth-shade) inset',
867
889
  userSelect: 'none',
868
890
  transition: 'border-color var(--duration-slow) var(--ease-standard), '
869
891
  + 'background-color var(--duration-normal) var(--ease-standard)',
@@ -884,6 +906,12 @@ export const switchRecipe = {
884
906
  height: 'calc(var(--switch-size) - (var(--border) + var(--switch-p)) * 2)',
885
907
  borderRadius: 'var(--radius-selector)',
886
908
  backgroundColor: 'var(--switch-ink)',
909
+ // daisy's knob relief (`.toggle:before`): a shade
910
+ // along the bottom edge, a sheen across the top, and
911
+ // a 1px drop — the web's `currentColor` drop spelled
912
+ // as the theme's depth shade. The noise layer has no
913
+ // lynx spelling and stays web-only.
914
+ boxShadow: '0 -1px var(--depth-shade) inset, 0 8px 0 -4px var(--depth-sheen) inset, 0 1px var(--depth-shade)',
887
915
  transition: 'transform var(--duration-normal) var(--ease-standard), '
888
916
  + 'background-color var(--duration-instant) var(--ease-standard)',
889
917
  },
@@ -990,6 +1018,21 @@ const lynxBtnSizes = (part) => ({
990
1018
  lg: { [part]: { base: lynxBtnPad(5) } },
991
1019
  xl: { [part]: { base: lynxBtnPad(6) } },
992
1020
  });
1021
+ /**
1022
+ * The press rendering of the overlay btns (dialog, popover), for their
1023
+ * `targets.lynx` sections. The web answers a pointer with `:hover`, which
1024
+ * the lynx emitter drops (a touch platform), and these parts' `pressed`
1025
+ * flag — stamped by the lynx runtime on every touch — had no rule, so a
1026
+ * tap showed nothing (signalxjs/lynx#1146). daisy's `:hover` wash plus the
1027
+ * btn's 1px sink, the same pair `button` paints for its own `pressed`. A
1028
+ * colour variant restates the background after the states at equal
1029
+ * specificity, so a coloured trigger keeps its fill and sinks.
1030
+ */
1031
+ const lynxBtnPressed = {
1032
+ background: 'var(--color-base-300)',
1033
+ transform: 'translateY(1px)',
1034
+ boxShadow: 'none',
1035
+ };
993
1036
  export const dialog = {
994
1037
  component: 'dialog',
995
1038
  // Public to a design system derived from this one (#73).
@@ -1081,14 +1124,16 @@ export const dialog = {
1081
1124
  // reach and the trigger is the whole story here.
1082
1125
  variants: { color: btnColors(), size: btnSizes },
1083
1126
  // The btn paddings and the footer's push-down, restated physically —
1084
- // see `lynxBtnPad` for the #1084 verdict this answers.
1127
+ // see `lynxBtnPad` for the #1084 verdict this answers — and the btns'
1128
+ // press rendering (`lynxBtnPressed`). The footer states its row: on lynx
1129
+ // a row container says so next to `display: flex`.
1085
1130
  targets: {
1086
1131
  lynx: {
1087
1132
  parts: {
1088
- trigger: { base: lynxBtnPad(4) },
1089
- close: { base: lynxBtnPad(4) },
1090
- cancel: { base: lynxBtnPad(4) },
1091
- footer: { base: { marginTop: 'var(--space-2xl)' } },
1133
+ trigger: { base: lynxBtnPad(4), states: { pressed: lynxBtnPressed } },
1134
+ close: { base: lynxBtnPad(4), states: { pressed: lynxBtnPressed } },
1135
+ cancel: { base: lynxBtnPad(4), states: { pressed: lynxBtnPressed } },
1136
+ footer: { base: { marginTop: 'var(--space-2xl)', flexDirection: 'row' } },
1092
1137
  },
1093
1138
  variants: { size: lynxBtnSizes('trigger') },
1094
1139
  },
@@ -1102,6 +1147,21 @@ const floatingPanel = {
1102
1147
  borderRadius: 'var(--radius-box)',
1103
1148
  boxShadow: 'var(--shadow-lg)',
1104
1149
  };
1150
+ /**
1151
+ * The arrow a floating surface grows toward its anchor (#279), in the
1152
+ * surface's own paint: the dropdown's base-100 and border for popover and
1153
+ * menu, the tooltip's borderless neutral for the tooltip — daisy's
1154
+ * `tooltip` draws exactly that tail under its bubble. Web-only (placed by
1155
+ * the runtime's `--arrow-x`/`--arrow-y`), so it rides `targets.web` with
1156
+ * the popup's permission to let it out.
1157
+ */
1158
+ const floatingArrow = (scope, paint, size = '0.625rem') => ({
1159
+ parts: {
1160
+ popup: { selectors: popupArrowHost(scope) },
1161
+ arrow: popupArrow(scope, { size, paint }),
1162
+ },
1163
+ });
1164
+ const panelArrowPaint = { background: 'var(--color-base-100)', border: 'var(--border) solid var(--color-base-300)' };
1105
1165
  /**
1106
1166
  * daisy's dropdown menu for a listbox, sized by the geometry the
1107
1167
  * anchored-position strategy publishes (#278): at least the width of the
@@ -1138,6 +1198,14 @@ export const popover = {
1138
1198
  title: {
1139
1199
  base: { margin: '0 0 var(--space-md)', fontSize: 'var(--text-md)', fontWeight: 'var(--weight-bold)' },
1140
1200
  },
1201
+ // The modal's muted body line, at the dropdown's tighter rhythm.
1202
+ description: {
1203
+ base: {
1204
+ margin: '0 0 var(--space-lg)',
1205
+ fontSize: 'var(--text-sm)',
1206
+ color: 'color-mix(in oklab, var(--color-base-content) 75%, transparent)',
1207
+ },
1208
+ },
1141
1209
  close: {
1142
1210
  base: { ...btn, height: 'calc(var(--size-field) * 8)', paddingInline: 'calc(var(--size-field) * 3)', fontSize: 'var(--text-xs)' },
1143
1211
  states: {
@@ -1149,12 +1217,14 @@ export const popover = {
1149
1217
  },
1150
1218
  // Trigger-carried axes — same wiring as dialog, same reason.
1151
1219
  variants: { color: btnColors(), size: btnSizes },
1152
- // The btn paddings, restated physically — see `lynxBtnPad` (#1084).
1220
+ // The btn paddings, restated physically — see `lynxBtnPad` (#1084) —
1221
+ // and the btns' press rendering (`lynxBtnPressed`).
1153
1222
  targets: {
1223
+ web: floatingArrow('popover', panelArrowPaint),
1154
1224
  lynx: {
1155
1225
  parts: {
1156
- trigger: { base: lynxBtnPad(4) },
1157
- close: { base: lynxBtnPad(3) },
1226
+ trigger: { base: lynxBtnPad(4), states: { pressed: lynxBtnPressed } },
1227
+ close: { base: lynxBtnPad(3), states: { pressed: lynxBtnPressed } },
1158
1228
  },
1159
1229
  variants: { size: lynxBtnSizes('trigger') },
1160
1230
  },
@@ -1196,6 +1266,7 @@ export const tooltip = {
1196
1266
  variants: { color: btnColors(), size: btnSizes },
1197
1267
  // The btn paddings, restated physically — see `lynxBtnPad` (#1084).
1198
1268
  targets: {
1269
+ web: floatingArrow('tooltip', { background: 'var(--color-neutral)' }, '0.5rem'),
1199
1270
  lynx: {
1200
1271
  parts: { trigger: { base: lynxBtnPad(4) } },
1201
1272
  variants: { size: lynxBtnSizes('trigger') },
@@ -1348,6 +1419,7 @@ export const menu = {
1348
1419
  variants: { color: btnColors(), size: btnSizes },
1349
1420
  // The btn paddings, restated physically — see `lynxBtnPad` (#1084).
1350
1421
  targets: {
1422
+ web: floatingArrow('menu', panelArrowPaint),
1351
1423
  lynx: {
1352
1424
  parts: { trigger: { base: lynxBtnPad(4) } },
1353
1425
  variants: { size: lynxBtnSizes('trigger') },
@@ -1403,6 +1475,60 @@ export const field = {
1403
1475
  },
1404
1476
  },
1405
1477
  };
1478
+ /**
1479
+ * Fieldset (#285), daisyUI 5's `.fieldset` / `.fieldset-legend`: a
1480
+ * one-column grid with a tight row gap and a little block padding, no frame
1481
+ * of its own (daisy draws one only when the app adds `border`), and a
1482
+ * semibold legend with block padding standing in for its margin. The UA
1483
+ * `min-inline-size: min-content` is undone so a wide control cannot push the
1484
+ * group past its column. Colour accents the legend through `roleInk`, the
1485
+ * Field way.
1486
+ */
1487
+ export const fieldset = {
1488
+ component: 'fieldset',
1489
+ tokens: { '--fieldset-accent': 'var(--color-base-content)' },
1490
+ parts: {
1491
+ root: {
1492
+ base: {
1493
+ display: 'grid',
1494
+ gridTemplateColumns: '1fr',
1495
+ gridAutoRows: 'max-content',
1496
+ gap: 'var(--space-sm)',
1497
+ minInlineSize: '0',
1498
+ margin: '0',
1499
+ paddingBlock: 'var(--space-xs)',
1500
+ paddingInline: '0',
1501
+ border: 'none',
1502
+ },
1503
+ },
1504
+ legend: {
1505
+ base: {
1506
+ paddingBlock: 'var(--space-sm)',
1507
+ paddingInline: '0',
1508
+ fontSize: 'var(--text-sm)',
1509
+ fontWeight: 'var(--weight-semibold)',
1510
+ color: 'var(--fieldset-accent)',
1511
+ },
1512
+ states: {
1513
+ disabled: { opacity: 'var(--disabled-opacity)' },
1514
+ invalid: { color: roleInk('error') },
1515
+ },
1516
+ },
1517
+ },
1518
+ variants: {
1519
+ color: Object.fromEntries(ROLES.map((c) => [c, { root: { base: {
1520
+ '--fieldset-accent': roleInk(c),
1521
+ } } }])),
1522
+ size: {
1523
+ xs: { legend: { base: { fontSize: 'var(--text-xs)' } } },
1524
+ sm: { legend: { base: { fontSize: 'var(--text-xs)' } } },
1525
+ // `md` is the un-attributed render.
1526
+ md: {},
1527
+ lg: { legend: { base: { fontSize: 'var(--text-md)' } } },
1528
+ xl: { legend: { base: { fontSize: 'var(--text-lg)' } } },
1529
+ },
1530
+ },
1531
+ };
1406
1532
  /**
1407
1533
  * daisy's tick, drawn by a clip-path that GROWS.
1408
1534
  *
@@ -1900,9 +2026,13 @@ export const progress = {
1900
2026
  transition: 'width var(--duration-slow) var(--ease-standard)',
1901
2027
  },
1902
2028
  states: {
1903
- // `complete` is a semantic state, not an accent: it stays
1904
- // success regardless of the colour variant, on purpose.
1905
- complete: { background: progressFill('success', 55) },
2029
+ // `complete` keeps the colour axis. daisyUI 5 has no complete
2030
+ // tint — a full `progress-primary` stays primary — and a
2031
+ // `color="secondary"` bar that turns green at 100% reads as a
2032
+ // bug, not as meaning (signalxjs/lynx#1144). A caller who wants
2033
+ // "done" to look like success says so: `color="success"`.
2034
+ // Declared equal to `loading` in `sameAs` below.
2035
+ complete: {},
1906
2036
  loading: {},
1907
2037
  indeterminate: { width: '40%', animation: 'zero-daisy-indeterminate 1.2s ease-in-out infinite' },
1908
2038
  },
@@ -1938,6 +2068,8 @@ export const progress = {
1938
2068
  // so the manifest states the default for runtimes with no `:not()`
1939
2069
  // fallback (the lynx target; signalxjs/lynx#1070).
1940
2070
  defaultVariants: { color: 'primary', size: 'md' },
2071
+ // A finished bar is a full bar of the same ink (see `complete` above).
2072
+ sameAs: { root: { complete: 'loading' }, range: { complete: 'loading' } },
1941
2073
  keyframes: {
1942
2074
  // Logical, so the sweep runs the way the bar fills — `margin-inline-start`
1943
2075
  // is animatable and direction-aware on its own. The determinate `width`,
@@ -1958,6 +2090,8 @@ export const progress = {
1958
2090
  },
1959
2091
  },
1960
2092
  };
2093
+ /** The slider's deepened fill — the web's `--slider-fill` mix, for a given accent. */
2094
+ const sliderFill = (accent) => `color-mix(in oklab, ${accent} 90%, var(--color-base-content))`;
1961
2095
  export const slider = {
1962
2096
  component: 'slider',
1963
2097
  // daisy's `.range` metrics: the thumb is `--range-thumb-size`
@@ -2266,29 +2400,94 @@ export const slider = {
2266
2400
  },
2267
2401
  },
2268
2402
  lynx: {
2403
+ // The web's deepened fill, theme-baked: `color-mix()` over theme
2404
+ // colours cannot resolve on device, but the emitter restates a
2405
+ // colour function per theme as literals (as `progress` does for
2406
+ // its accent), so each colour step gets the same 90/10 mix the
2407
+ // web track and knob ring wear. Bound on the root like
2408
+ // `--slider-accent`, rebound by `color` below and by `invalid`
2409
+ // on the control (which wraps the rail on lynx).
2410
+ tokens: { '--slider-fill': sliderFill('var(--color-primary)') },
2411
+ variants: {
2412
+ color: Object.fromEntries(ROLES.map((c) => [c, { root: { base: {
2413
+ '--slider-fill': sliderFill(`var(--color-${c})`),
2414
+ } } }])),
2415
+ },
2269
2416
  parts: {
2417
+ // Vertical (lynx#1144): the lynx runtime positions the moving
2418
+ // parts itself — `top` percentages, the knob centred by
2419
+ // `left: 50%` plus the base's `translateY(-50%)` and
2420
+ // negative `margin-left` — so the class rules only turn the
2421
+ // channel upright: the control is a thumb-wide column of
2422
+ // daisy's rail length, the track fills it.
2423
+ root: {
2424
+ selectors: {
2425
+ '&[data-orientation="vertical"]': { width: 'auto', alignItems: 'center' },
2426
+ },
2427
+ },
2428
+ control: {
2429
+ states: {
2430
+ invalid: { '--slider-fill': sliderFill('var(--color-error)') },
2431
+ },
2432
+ selectors: {
2433
+ '&[data-orientation="vertical"]': {
2434
+ display: 'flex',
2435
+ flexDirection: 'row',
2436
+ justifyContent: 'center',
2437
+ width: 'var(--slider-thumb-size)',
2438
+ height: 'calc(var(--size-selector) * 40)',
2439
+ },
2440
+ },
2441
+ },
2270
2442
  track: {
2271
2443
  // The web track's `margin-block` breathing room,
2272
2444
  // restated physically: logical spellings resolve on iOS
2273
2445
  // but not on Android (measured, signalxjs/lynx#1084), so
2274
2446
  // the emitter refuses them. Physical is the lynx
2275
2447
  // target's norm — no RTL flow there.
2448
+ //
2449
+ // daisy's channel radius is the selector radius (the
2450
+ // native control's runnable-track), not a pill.
2276
2451
  base: {
2277
2452
  marginTop: 'calc((var(--slider-thumb-size) - var(--slider-track-size)) / 2)',
2278
2453
  marginBottom: 'calc((var(--slider-thumb-size) - var(--slider-track-size)) / 2)',
2454
+ borderRadius: 'var(--radius-selector)',
2455
+ },
2456
+ selectors: {
2457
+ '&[data-orientation="vertical"]': {
2458
+ width: 'var(--slider-track-size)',
2459
+ height: '100%',
2460
+ marginTop: '0',
2461
+ marginBottom: '0',
2462
+ },
2279
2463
  },
2280
2464
  },
2281
2465
  mark: {
2282
- // Same #1084 verdict: the tick labels hang below the
2283
- // track off a physical padding.
2284
- base: { paddingTop: 'calc(var(--slider-track-size) + var(--space-2xs))' },
2466
+ // The web draws the tick with `::before`, which lynx has
2467
+ // no projection for — the mark was an empty, invisible
2468
+ // box. Here the mark IS the tick: a 2px rule across the
2469
+ // channel, centred on its runtime-set `left` percent.
2470
+ // (Lynx marks carry no label text.)
2471
+ base: {
2472
+ top: '0',
2473
+ width: '2px',
2474
+ height: 'var(--slider-track-size)',
2475
+ marginLeft: '-1px',
2476
+ paddingTop: '0',
2477
+ backgroundColor: 'var(--color-base-content)',
2478
+ },
2479
+ selectors: {
2480
+ '&[data-orientation="vertical"]': {
2481
+ left: '0',
2482
+ width: 'var(--slider-track-size)',
2483
+ height: '2px',
2484
+ marginLeft: '0',
2485
+ marginTop: '-1px',
2486
+ },
2487
+ },
2285
2488
  },
2286
2489
  range: {
2287
- // The web fill deepens the accent 90/10 toward
2288
- // base-content (`progressFill`'s recipe); the plain
2289
- // accent — a var() chain, proven working on device — is
2290
- // the closest lynx-expressible equivalent.
2291
- base: { background: 'var(--slider-accent)' },
2490
+ base: { background: 'var(--slider-fill)', borderRadius: 'var(--radius-selector)' },
2292
2491
  },
2293
2492
  thumb: {
2294
2493
  // daisy's real range thumb, ported from daisyUI 5
@@ -2299,13 +2498,16 @@ export const slider = {
2299
2498
  // `--range-thumb` defaults to `var(--color-base-100)`,
2300
2499
  // `--range-p` is daisy's literal `.25rem` (deliberately
2301
2500
  // not the theme's size token), and the border ink is the
2302
- // accent (currentColor on a daisy `.range-*` IS the
2303
- // accent role) — because on lynx a named value is a
2304
- // var-chain fewer to resolve. daisy paints the progress
2305
- // fill itself as a 100cqw box-shadow thrown off this
2306
- // thumb — a native-input trick with no lynx equivalent;
2307
- // the composed `.zx-slider__range` box above IS that
2308
- // fill here, so the knob only needs its own two paints.
2501
+ // deepened fill the web's native thumb ring reads. daisy
2502
+ // paints the progress fill itself as a 100cqw box-shadow
2503
+ // thrown off this thumb — a native-input trick with no
2504
+ // lynx equivalent; the composed `.zx-slider__range` box
2505
+ // above IS that fill here, so the knob only needs its own
2506
+ // paints. Its radius is daisy's formula minus the min()
2507
+ // clamp lynx cannot evaluate — exact for every shipped
2508
+ // theme (all set `--radius-selector` ≥ .25rem / 3) — and
2509
+ // it wears daisy's depth shading (`currentColor`'s 10%
2510
+ // outer shadow spelled as the theme's shade).
2309
2511
  //
2310
2512
  // The centering geometry is restated physically beside
2311
2513
  // the paint: `inset-block-start`/`translate`/
@@ -2316,7 +2518,9 @@ export const slider = {
2316
2518
  // proven on both platforms.
2317
2519
  base: {
2318
2520
  background: 'var(--color-base-100)',
2319
- border: '0.25rem solid var(--slider-accent)',
2521
+ border: '0.25rem solid var(--slider-fill)',
2522
+ borderRadius: 'calc(var(--radius-selector) + 0.25rem)',
2523
+ boxShadow: '0 -1px var(--depth-shade) inset, 0 8px 0 -4px var(--depth-sheen) inset, 0 1px var(--depth-shade)',
2320
2524
  top: '50%',
2321
2525
  transform: 'translateY(-50%)',
2322
2526
  marginLeft: 'calc(var(--slider-thumb-size) / -2)',
@@ -2328,7 +2532,18 @@ export const slider = {
2328
2532
  };
2329
2533
  export const accordion = {
2330
2534
  component: 'accordion',
2331
- targets: disclosureExit('accordion'),
2535
+ targets: {
2536
+ ...disclosureExit('accordion'),
2537
+ lynx: {
2538
+ parts: {
2539
+ // No hover on a touch platform: the held trigger takes daisy's
2540
+ // hover fill. The ::after chevron has no lynx counterpart (no
2541
+ // pseudo-elements, and the anatomy declares no part for it) —
2542
+ // an app draws its own glyph in the trigger.
2543
+ trigger: { states: { pressed: { background: 'var(--color-base-200)' } } },
2544
+ },
2545
+ },
2546
+ },
2332
2547
  // Same accent grammar as collapsible: the open heading's ink, defaulting
2333
2548
  // to base-content so the un-attributed render is unchanged.
2334
2549
  tokens: { '--accordion-accent': 'var(--color-base-content)' },
@@ -2609,7 +2824,23 @@ export const select = {
2609
2824
  web: { parts: { popup: { base: anchoredListbox } } },
2610
2825
  lynx: {
2611
2826
  parts: {
2612
- trigger: { base: lynxBtnPad(4) },
2827
+ // daisy's `.select` is `width: clamp(3rem, 20rem, 100%)`: 20rem,
2828
+ // never wider than its container. lynx has no `clamp()`, so the
2829
+ // same bounds are spelled out — and the base's 13rem floor
2830
+ // comes down to daisy's 3rem, or the trigger overflowed any
2831
+ // cell narrower than 208px (signalxjs/lynx#1146). The root
2832
+ // caps too, so a shrink-wrapped root cannot push it back out.
2833
+ root: { base: { maxWidth: '100%' } },
2834
+ trigger: {
2835
+ base: { ...lynxBtnPad(4), width: '20rem', maxWidth: '100%', minWidth: '3rem' },
2836
+ // The web's `:hover` border is dropped on a touch
2837
+ // platform; a press gets a wash instead — a background,
2838
+ // so it never fights the open/invalid border inks.
2839
+ states: { pressed: { background: 'var(--color-base-200)' } },
2840
+ },
2841
+ // A touch list has no highlight: the pressed item takes the
2842
+ // highlighted item's wash.
2843
+ item: { states: { pressed: { background: 'var(--color-base-200)' } } },
2613
2844
  'clear-trigger': { base: { top: 'var(--space-xs)', bottom: 'var(--space-xs)', right: 'calc(var(--size-field) * 4 + 1em + var(--space-xs))', minWidth: '1.5rem' } },
2614
2845
  },
2615
2846
  variants: {
@@ -2633,6 +2864,22 @@ export const select = {
2633
2864
  */
2634
2865
  const BTN_STEPS = { xs: 6, sm: 8, md: 10, lg: 12, xl: 14 };
2635
2866
  const btnHeight = (step) => `calc(var(--size-field) * ${BTN_STEPS[step]})`;
2867
+ /**
2868
+ * daisy 5's disabled btn, for the lynx target (signalxjs/lynx#1143). The web
2869
+ * fades the whole button (`opacity: var(--disabled-opacity)`, 0.3 on the
2870
+ * dark themes), which on device left a solid button's label barely legible.
2871
+ * daisy's own `.btn:disabled` paints instead: a base-content wash (not on
2872
+ * ghost/link), a transparent border, no shadow, and a muted base-content
2873
+ * label. daisy mutes that label to 20%; this keeps it at 50%, still plainly
2874
+ * inert but readable on a phone at arm's length.
2875
+ */
2876
+ const lynxBtnDisabled = {
2877
+ opacity: '1',
2878
+ background: 'color-mix(in oklab, var(--color-base-content) 10%, transparent)',
2879
+ color: 'color-mix(in oklab, var(--color-base-content) 50%, transparent)',
2880
+ borderColor: 'transparent',
2881
+ boxShadow: 'none',
2882
+ };
2636
2883
  export const button = {
2637
2884
  component: 'button',
2638
2885
  // Public to a design system derived from this one (#73).
@@ -2846,11 +3093,39 @@ export const button = {
2846
3093
  },
2847
3094
  defaultVariants: { color: 'primary', variant: 'solid', size: 'md' },
2848
3095
  keyframes: { 'zero-daisyui-btn-spin': 'to { transform: rotate(360deg) }' },
2849
- // The icon chips' zeroed inline padding, restated physically: logical
2850
- // spellings resolve on iOS but not on Android (measured,
2851
- // signalxjs/lynx#1084), so the emitter refuses them.
2852
3096
  targets: {
2853
3097
  lynx: {
3098
+ parts: {
3099
+ // The ring, restated: `currentColor` never resolves on lynx
3100
+ // (signalxjs/lynx#1079) and the logical sizes/edge are
3101
+ // unproven there, so the lynx ring is physical longhands in
3102
+ // the variant's own ink (the variants below pick it).
3103
+ spinner: {
3104
+ base: {
3105
+ width: '1em',
3106
+ height: '1em',
3107
+ border: 'calc(var(--border) * 2) solid var(--btn-ink)',
3108
+ borderTopColor: 'transparent',
3109
+ },
3110
+ },
3111
+ root: { states: { disabled: lynxBtnDisabled } },
3112
+ },
3113
+ variants: {
3114
+ // The baked disabled paint above outranks every variant's fill
3115
+ // (the theme host adds a class), but its plain declarations
3116
+ // do not: outline/dash restate the border, ghost/link drop
3117
+ // the wash (as in daisy).
3118
+ variant: {
3119
+ solid: { spinner: { base: { borderColor: 'var(--btn-on-accent)', borderTopColor: 'transparent' } } },
3120
+ outline: { root: { states: { disabled: { borderColor: 'transparent' } } } },
3121
+ dash: { root: { states: { disabled: { borderColor: 'transparent' } } } },
3122
+ ghost: { root: { states: { disabled: { background: 'transparent' } } } },
3123
+ link: { root: { states: { disabled: { background: 'transparent' } } } },
3124
+ },
3125
+ },
3126
+ // The icon chips' zeroed inline padding, restated physically: logical
3127
+ // spellings resolve on iOS but not on Android (measured,
3128
+ // signalxjs/lynx#1084), so the emitter refuses them.
2854
3129
  modifiers: {
2855
3130
  square: { root: { base: { paddingLeft: '0', paddingRight: '0' } } },
2856
3131
  circle: { root: { base: { paddingLeft: '0', paddingRight: '0' } } },
@@ -3089,21 +3364,66 @@ export const toast = {
3089
3364
  // signalxjs/lynx#1070. No `color` default: the un-attributed toast is a
3090
3365
  // neutral base-200 surface, outside the color vocabulary.
3091
3366
  defaultVariants: { size: 'md' },
3092
- // The corner placements' physical anchors: logical inset spellings
3093
- // resolve on iOS but not on Android (measured, signalxjs/lynx#1084), so
3094
- // the emitter refuses them. Physical is the lynx target's norm — no RTL
3095
- // flow there, so `start` IS left and `end` IS right.
3096
3367
  targets: {
3097
3368
  lynx: {
3098
3369
  parts: {
3370
+ // The viewport is a full-width strip on lynx (the runtime pins
3371
+ // it to the outlet's top or bottom edge). What the web gets
3372
+ // from `:popover-open` and `min(24rem, 100vw)` is restated:
3373
+ // flex (a lynx view defaults to linear layout, which ignores
3374
+ // flex-direction and gap), and the cards aligned to the
3375
+ // placement's edge. The centred pair's `left: 50%` +
3376
+ // `translateX(-50%)` pull-back is for a shrink-wrapped popover;
3377
+ // on the strip it shifted everything half a width left, so it
3378
+ // is cancelled. The corner anchors are physical: logical
3379
+ // insets resolve on iOS but not on Android (measured,
3380
+ // signalxjs/lynx#1084), and lynx has no RTL flow, so `start`
3381
+ // IS left and `end` IS right.
3099
3382
  viewport: {
3383
+ base: { display: 'flex', width: 'auto' },
3100
3384
  selectors: {
3101
- '&[data-placement="top-start"]': { left: '0' },
3102
- '&[data-placement="top-end"]': { right: '0' },
3103
- '&[data-placement="bottom-start"]': { left: '0' },
3104
- '&[data-placement="bottom-end"]': { right: '0' },
3385
+ '&[data-placement="top-start"]': { left: '0', alignItems: 'flex-start' },
3386
+ '&[data-placement="top"]': { left: '0', right: '0', transform: 'none', alignItems: 'center' },
3387
+ '&[data-placement="top-end"]': { right: '0', alignItems: 'flex-end' },
3388
+ '&[data-placement="bottom-start"]': { left: '0', alignItems: 'flex-start' },
3389
+ '&[data-placement="bottom"]': { left: '0', right: '0', transform: 'none', alignItems: 'center' },
3390
+ '&[data-placement="bottom-end"]': { right: '0', alignItems: 'flex-end' },
3105
3391
  },
3106
3392
  },
3393
+ // No grid on lynx (signalxjs/lynx#1075): the card is a flex
3394
+ // column (title, description, then the action), with the
3395
+ // close button pinned to the top-right corner and the title
3396
+ // kept clear of it. The web's 24rem viewport cap becomes the
3397
+ // card's own cap. The `^="top"` entry offset has no class
3398
+ // form, so each top placement restates it.
3399
+ root: {
3400
+ base: {
3401
+ display: 'flex',
3402
+ flexDirection: 'column',
3403
+ alignItems: 'flex-start',
3404
+ position: 'relative',
3405
+ boxSizing: 'border-box',
3406
+ width: '100%',
3407
+ maxWidth: '24rem',
3408
+ },
3409
+ selectors: {
3410
+ '&[data-placement="top-start"]': { '--toast-from': '-8px' },
3411
+ '&[data-placement="top"]': { '--toast-from': '-8px' },
3412
+ '&[data-placement="top-end"]': { '--toast-from': '-8px' },
3413
+ },
3414
+ },
3415
+ title: { base: { paddingRight: '1.75em' } },
3416
+ // The 75% ink mix over a recipe-local property cannot bake;
3417
+ // the same ink at 75% opacity reads the same on the soft fill.
3418
+ description: { base: { color: 'var(--toast-ink)', opacity: '0.75' } },
3419
+ // The 25% ink border cannot bake either: it takes the full ink.
3420
+ action: {
3421
+ base: {
3422
+ marginTop: 'var(--space-xs)',
3423
+ border: 'var(--border) solid var(--toast-ink)',
3424
+ },
3425
+ },
3426
+ close: { base: { position: 'absolute', top: 'var(--space-xs)', right: 'var(--space-xs)' } },
3107
3427
  },
3108
3428
  },
3109
3429
  },
@@ -5135,6 +5455,87 @@ export const timeline = {
5135
5455
  xl: { marker: { base: { '--timeline-marker-size': 'calc(var(--size-selector) * 4)' } }, content: { base: { fontSize: 'var(--text-md)' } } },
5136
5456
  },
5137
5457
  },
5458
+ /**
5459
+ * Lynx has no grid, and it does not resolve `inline-size`/`block-size`:
5460
+ * the item fell back to the engine's default layout and the marker,
5461
+ * sized only logically, stretched into a pill (signalxjs/lynx#1145).
5462
+ *
5463
+ * The item is a wrapping flex row instead. Vertical: line one is the
5464
+ * marker beside its content (start-side content ordered before the
5465
+ * marker), and the connector takes line two by itself (`flex-basis:
5466
+ * 100%`), drawn as a left border under the marker's centre, which is the
5467
+ * web's row-2 segment. Horizontal: line one is the marker with the
5468
+ * connector running on to the item's end, and the content wraps under it
5469
+ * (start-side content above). Content grows from a zero basis, so long
5470
+ * text never wraps onto its own line.
5471
+ *
5472
+ * The web collapses the start track when no item has start-side content
5473
+ * (`:has()`); lynx has no such test. The vertical connector sits under a
5474
+ * marker at the item's start edge, which is where the marker is unless
5475
+ * that item has start-side content: an alternating vertical timeline is
5476
+ * web-only for now.
5477
+ */
5478
+ targets: {
5479
+ lynx: {
5480
+ parts: {
5481
+ item: { base: { display: 'flex', flexDirection: 'row', flexWrap: 'wrap', alignItems: 'center' } },
5482
+ marker: {
5483
+ base: {
5484
+ width: 'var(--timeline-marker-size)',
5485
+ height: 'var(--timeline-marker-size)',
5486
+ flexGrow: '0',
5487
+ flexShrink: '0',
5488
+ },
5489
+ },
5490
+ connector: {
5491
+ selectors: {
5492
+ '&[data-orientation="vertical"]': {
5493
+ flexGrow: '0',
5494
+ flexShrink: '1',
5495
+ flexBasis: '100%',
5496
+ height: 'var(--space-lg)',
5497
+ background: 'transparent',
5498
+ borderLeftWidth: 'var(--border)',
5499
+ borderLeftStyle: 'solid',
5500
+ borderLeftColor: 'var(--color-base-300)',
5501
+ marginLeft: 'calc(var(--timeline-marker-size) / 2 - var(--border) / 2)',
5502
+ },
5503
+ '&[data-orientation="horizontal"]': {
5504
+ order: '1',
5505
+ flexGrow: '1',
5506
+ flexShrink: '1',
5507
+ flexBasis: '0',
5508
+ height: 'var(--border)',
5509
+ minWidth: 'var(--space-lg)',
5510
+ },
5511
+ },
5512
+ },
5513
+ content: {
5514
+ selectors: {
5515
+ '&[data-orientation="vertical"][data-placement="start"]': {
5516
+ order: '-1',
5517
+ flexGrow: '1',
5518
+ flexShrink: '1',
5519
+ flexBasis: '0',
5520
+ },
5521
+ '&[data-orientation="vertical"][data-placement="end"]': {
5522
+ flexGrow: '1',
5523
+ flexShrink: '1',
5524
+ flexBasis: '0',
5525
+ },
5526
+ '&[data-orientation="horizontal"][data-placement="start"]': {
5527
+ order: '-1',
5528
+ flexBasis: '100%',
5529
+ },
5530
+ '&[data-orientation="horizontal"][data-placement="end"]': {
5531
+ order: '2',
5532
+ flexBasis: '100%',
5533
+ },
5534
+ },
5535
+ },
5536
+ },
5537
+ },
5538
+ },
5138
5539
  };
5139
5540
  /** daisy chat: the classic bubble, colour-refilled per role. */
5140
5541
  export const chat = {
@@ -6163,6 +6564,9 @@ export const drawer = {
6163
6564
  */
6164
6565
  export const table = {
6165
6566
  component: 'table',
6567
+ // A sortable header cell carries the sort state for `aria-sort`'s sake;
6568
+ // its trigger and indicator paint it.
6569
+ skipStates: { 'header-cell': ['ascending', 'descending', 'none'] },
6166
6570
  // Public to a design system derived from this one (#73).
6167
6571
  hooks: {
6168
6572
  properties: {
@@ -6248,6 +6652,55 @@ export const table = {
6248
6652
  },
6249
6653
  at: tableStackAt(tokens, 'header-cell', { paddingInline: '0' }),
6250
6654
  },
6655
+ // Sorting (#286): daisy's header label made a button — the sorted
6656
+ // column's label takes the full accent ink, and the mark beside it
6657
+ // turns with the direction.
6658
+ 'sort-trigger': {
6659
+ base: {
6660
+ appearance: 'none',
6661
+ display: 'inline-flex',
6662
+ alignItems: 'center',
6663
+ margin: '0',
6664
+ border: '0',
6665
+ font: 'inherit',
6666
+ letterSpacing: 'inherit',
6667
+ textTransform: 'inherit',
6668
+ textAlign: 'inherit',
6669
+ cursor: 'pointer',
6670
+ gap: 'var(--space-2xs)',
6671
+ padding: '0',
6672
+ borderRadius: 'var(--radius-selector)',
6673
+ background: 'transparent',
6674
+ color: 'inherit',
6675
+ transition: 'color var(--duration-fast) var(--ease-standard)',
6676
+ },
6677
+ states: {
6678
+ ascending: { color: 'var(--table-accent)' },
6679
+ descending: { color: 'var(--table-accent)' },
6680
+ none: {},
6681
+ hover: { color: 'var(--table-accent)' },
6682
+ disabled: { cursor: 'not-allowed', opacity: 'var(--disabled-opacity)' },
6683
+ ...focusRing,
6684
+ },
6685
+ at: { 'reduced-motion': { base: { transition: 'none' } } },
6686
+ },
6687
+ // zero's ▲, turned for descending; an unsorted column shows it only
6688
+ // under a hovered or keyboard-focused trigger.
6689
+ 'sort-indicator': {
6690
+ base: {
6691
+ display: 'inline-block',
6692
+ fontSize: '0.75em',
6693
+ lineHeight: '1',
6694
+ transition: 'transform var(--duration-fast) var(--ease-standard), opacity var(--duration-fast) var(--ease-standard)',
6695
+ },
6696
+ states: {
6697
+ ascending: {},
6698
+ descending: { transform: 'rotate(180deg)' },
6699
+ none: { opacity: '0' },
6700
+ },
6701
+ selectors: { '[data-scope="table"][data-part="sort-trigger"]:is(:hover, [data-focus-visible]) > &[data-state="none"]': { opacity: '0.6' } },
6702
+ at: { 'reduced-motion': { base: { transition: 'none' } } },
6703
+ },
6251
6704
  cell: {
6252
6705
  base: {
6253
6706
  padding: 'var(--table-pad-block) var(--table-pad-inline)',
@@ -6896,7 +7349,7 @@ export const diff = {
6896
7349
  };
6897
7350
  export const recipes = [
6898
7351
  tabs, collapsible, switchRecipe, dialog, popover, tooltip, menu,
6899
- field, checkbox, checkboxGroup, radioGroup, progress, slider, accordion, select, button, avatar, toast, combobox,
7352
+ field, fieldset, checkbox, checkboxGroup, radioGroup, progress, slider, accordion, select, button, avatar, toast, combobox,
6900
7353
  toggle, toggleGroup, numberInput, ratingGroup, treeView, input, textarea,
6901
7354
  card, alert, emptyState, badge, divider, skeleton, spinner,
6902
7355
  kbd, status, indicator, stats, timeline, chat, radialProgress, join,