@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/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
  /**
@@ -316,7 +316,9 @@ export const tabs: RecipeInput = {
316
316
  },
317
317
  // daisy draws each flavor's active mark on the tab itself (the
318
318
  // underline, the lift, the pill) and none of them slides, so a
319
- // `Tabs.Indicator` renders nothing here (#283).
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`).
320
322
  indicator: { base: { display: 'none' } },
321
323
  panel: {
322
324
  base: { fontSize: 'var(--text-md)' },
@@ -520,11 +522,17 @@ export const tabs: RecipeInput = {
520
522
  targets: {
521
523
  lynx: {
522
524
  parts: {
523
- // The default active ink — base-content, exactly what the
524
- // 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
525
533
  // color-axis definition below (host + two classes, baked per
526
534
  // theme) wins on specificity.
527
- tab: { base: { '--tab-active-ink': 'var(--color-base-content)' } },
535
+ indicator: { base: { '--tab-active-ink': 'var(--color-base-content)' } },
528
536
  },
529
537
  variants: {
530
538
  // `currentColor` never resolves on lynx (measured on device
@@ -535,19 +543,29 @@ export const tabs: RecipeInput = {
535
543
  // once per theme — the same per-color/per-theme literal the
536
544
  // active `color:` rules already land as.
537
545
  color: Object.fromEntries(
538
- ROLES.map((c) => [c, { tab: { base: { '--tab-active-ink': roleInk(c) } } }]),
546
+ ROLES.map((c) => [c, { indicator: { base: { '--tab-active-ink': roleInk(c) } } }]),
539
547
  ),
540
548
  variant: {
541
549
  // The underline is a ::before on the web, which lynx
542
- // drops — redrawn as a real bottom border on the tab
543
- // itself (transparent at rest so activation never moves
544
- // the row). The active ink rides `--tab-active-ink`
545
- // above — a plain var() chain, device-proven — instead
546
- // 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.
547
559
  border: {
548
- tab: {
549
- base: { borderBottom: '3px solid transparent' },
550
- 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
+ },
551
569
  },
552
570
  },
553
571
  // `min()` does not exist on lynx (signalxjs/lynx#1066) and
@@ -900,6 +918,10 @@ export const switchRecipe: RecipeInput = {
900
918
  // No currentColor on this target — the ink is named
901
919
  // directly, and `checked` moves border + fill below.
902
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',
903
925
  userSelect: 'none',
904
926
  transition: 'border-color var(--duration-slow) var(--ease-standard), '
905
927
  + 'background-color var(--duration-normal) var(--ease-standard)',
@@ -920,6 +942,12 @@ export const switchRecipe: RecipeInput = {
920
942
  height: 'calc(var(--switch-size) - (var(--border) + var(--switch-p)) * 2)',
921
943
  borderRadius: 'var(--radius-selector)',
922
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)',
923
951
  transition: 'transform var(--duration-normal) var(--ease-standard), '
924
952
  + 'background-color var(--duration-instant) var(--ease-standard)',
925
953
  },
@@ -1036,6 +1064,22 @@ const lynxBtnSizes = (part: string): Record<string, Record<string, PartStyles>>
1036
1064
  xl: { [part]: { base: lynxBtnPad(6) } },
1037
1065
  });
1038
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
+
1039
1083
  export const dialog: RecipeInput = {
1040
1084
  component: 'dialog',
1041
1085
  // Public to a design system derived from this one (#73).
@@ -1127,14 +1171,16 @@ export const dialog: RecipeInput = {
1127
1171
  // reach and the trigger is the whole story here.
1128
1172
  variants: { color: btnColors(), size: btnSizes },
1129
1173
  // The btn paddings and the footer's push-down, restated physically —
1130
- // 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`.
1131
1177
  targets: {
1132
1178
  lynx: {
1133
1179
  parts: {
1134
- trigger: { base: lynxBtnPad(4) },
1135
- close: { base: lynxBtnPad(4) },
1136
- cancel: { base: lynxBtnPad(4) },
1137
- 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' } },
1138
1184
  },
1139
1185
  variants: { size: lynxBtnSizes('trigger') },
1140
1186
  },
@@ -1150,6 +1196,22 @@ const floatingPanel: NonNullable<PartStyles['base']> = {
1150
1196
  boxShadow: 'var(--shadow-lg)',
1151
1197
  };
1152
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
+
1153
1215
  /**
1154
1216
  * daisy's dropdown menu for a listbox, sized by the geometry the
1155
1217
  * anchored-position strategy publishes (#278): at least the width of the
@@ -1187,6 +1249,14 @@ export const popover: RecipeInput = {
1187
1249
  title: {
1188
1250
  base: { margin: '0 0 var(--space-md)', fontSize: 'var(--text-md)', fontWeight: 'var(--weight-bold)' },
1189
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
+ },
1190
1260
  close: {
1191
1261
  base: { ...btn, height: 'calc(var(--size-field) * 8)', paddingInline: 'calc(var(--size-field) * 3)', fontSize: 'var(--text-xs)' },
1192
1262
  states: {
@@ -1198,12 +1268,14 @@ export const popover: RecipeInput = {
1198
1268
  },
1199
1269
  // Trigger-carried axes — same wiring as dialog, same reason.
1200
1270
  variants: { color: btnColors(), size: btnSizes },
1201
- // The btn paddings, restated physically — see `lynxBtnPad` (#1084).
1271
+ // The btn paddings, restated physically — see `lynxBtnPad` (#1084) —
1272
+ // and the btns' press rendering (`lynxBtnPressed`).
1202
1273
  targets: {
1274
+ web: floatingArrow('popover', panelArrowPaint),
1203
1275
  lynx: {
1204
1276
  parts: {
1205
- trigger: { base: lynxBtnPad(4) },
1206
- close: { base: lynxBtnPad(3) },
1277
+ trigger: { base: lynxBtnPad(4), states: { pressed: lynxBtnPressed } },
1278
+ close: { base: lynxBtnPad(3), states: { pressed: lynxBtnPressed } },
1207
1279
  },
1208
1280
  variants: { size: lynxBtnSizes('trigger') },
1209
1281
  },
@@ -1246,6 +1318,7 @@ export const tooltip: RecipeInput = {
1246
1318
  variants: { color: btnColors(), size: btnSizes },
1247
1319
  // The btn paddings, restated physically — see `lynxBtnPad` (#1084).
1248
1320
  targets: {
1321
+ web: floatingArrow('tooltip', { background: 'var(--color-neutral)' }, '0.5rem'),
1249
1322
  lynx: {
1250
1323
  parts: { trigger: { base: lynxBtnPad(4) } },
1251
1324
  variants: { size: lynxBtnSizes('trigger') },
@@ -1399,6 +1472,7 @@ export const menu: RecipeInput = {
1399
1472
  variants: { color: btnColors(), size: btnSizes },
1400
1473
  // The btn paddings, restated physically — see `lynxBtnPad` (#1084).
1401
1474
  targets: {
1475
+ web: floatingArrow('menu', panelArrowPaint),
1402
1476
  lynx: {
1403
1477
  parts: { trigger: { base: lynxBtnPad(4) } },
1404
1478
  variants: { size: lynxBtnSizes('trigger') },
@@ -1456,6 +1530,61 @@ export const field: RecipeInput = {
1456
1530
  },
1457
1531
  };
1458
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
+
1459
1588
  /**
1460
1589
  * daisy's tick, drawn by a clip-path that GROWS.
1461
1590
  *
@@ -1962,9 +2091,13 @@ export const progress: RecipeInput = {
1962
2091
  transition: 'width var(--duration-slow) var(--ease-standard)',
1963
2092
  },
1964
2093
  states: {
1965
- // `complete` is a semantic state, not an accent: it stays
1966
- // success regardless of the colour variant, on purpose.
1967
- 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: {},
1968
2101
  loading: {},
1969
2102
  indeterminate: { width: '40%', animation: 'zero-daisy-indeterminate 1.2s ease-in-out infinite' },
1970
2103
  },
@@ -2000,6 +2133,8 @@ export const progress: RecipeInput = {
2000
2133
  // so the manifest states the default for runtimes with no `:not()`
2001
2134
  // fallback (the lynx target; signalxjs/lynx#1070).
2002
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' } },
2003
2138
  keyframes: {
2004
2139
  // Logical, so the sweep runs the way the bar fills — `margin-inline-start`
2005
2140
  // is animatable and direction-aware on its own. The determinate `width`,
@@ -2021,6 +2156,10 @@ export const progress: RecipeInput = {
2021
2156
  },
2022
2157
  };
2023
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
+
2024
2163
  export const slider: RecipeInput = {
2025
2164
  component: 'slider',
2026
2165
  // daisy's `.range` metrics: the thumb is `--range-thumb-size`
@@ -2331,29 +2470,94 @@ export const slider: RecipeInput = {
2331
2470
  },
2332
2471
  },
2333
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
+ },
2334
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
+ },
2335
2512
  track: {
2336
2513
  // The web track's `margin-block` breathing room,
2337
2514
  // restated physically: logical spellings resolve on iOS
2338
2515
  // but not on Android (measured, signalxjs/lynx#1084), so
2339
2516
  // the emitter refuses them. Physical is the lynx
2340
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.
2341
2521
  base: {
2342
2522
  marginTop: 'calc((var(--slider-thumb-size) - var(--slider-track-size)) / 2)',
2343
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
+ },
2344
2533
  },
2345
2534
  },
2346
2535
  mark: {
2347
- // Same #1084 verdict: the tick labels hang below the
2348
- // track off a physical padding.
2349
- base: { paddingTop: 'calc(var(--slider-track-size) + 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
+ },
2350
2558
  },
2351
2559
  range: {
2352
- // The web fill deepens the accent 90/10 toward
2353
- // base-content (`progressFill`'s recipe); the plain
2354
- // accent — a var() chain, proven working on device — is
2355
- // the closest lynx-expressible equivalent.
2356
- base: { background: 'var(--slider-accent)' },
2560
+ base: { background: 'var(--slider-fill)', borderRadius: 'var(--radius-selector)' },
2357
2561
  },
2358
2562
  thumb: {
2359
2563
  // daisy's real range thumb, ported from daisyUI 5
@@ -2364,13 +2568,16 @@ export const slider: RecipeInput = {
2364
2568
  // `--range-thumb` defaults to `var(--color-base-100)`,
2365
2569
  // `--range-p` is daisy's literal `.25rem` (deliberately
2366
2570
  // not the theme's size token), and the border ink is the
2367
- // accent (currentColor on a daisy `.range-*` IS the
2368
- // accent role) — because on lynx a named value is a
2369
- // var-chain fewer to resolve. daisy paints the progress
2370
- // fill itself as a 100cqw box-shadow thrown off this
2371
- // thumb — a native-input trick with no lynx equivalent;
2372
- // the composed `.zx-slider__range` box above IS that
2373
- // 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).
2374
2581
  //
2375
2582
  // The centering geometry is restated physically beside
2376
2583
  // the paint: `inset-block-start`/`translate`/
@@ -2381,7 +2588,9 @@ export const slider: RecipeInput = {
2381
2588
  // proven on both platforms.
2382
2589
  base: {
2383
2590
  background: 'var(--color-base-100)',
2384
- 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)',
2385
2594
  top: '50%',
2386
2595
  transform: 'translateY(-50%)',
2387
2596
  marginLeft: 'calc(var(--slider-thumb-size) / -2)',
@@ -2394,7 +2603,18 @@ export const slider: RecipeInput = {
2394
2603
 
2395
2604
  export const accordion: RecipeInput = {
2396
2605
  component: 'accordion',
2397
- targets: disclosureExit('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
+ },
2398
2618
  // Same accent grammar as collapsible: the open heading's ink, defaulting
2399
2619
  // to base-content so the un-attributed render is unchanged.
2400
2620
  tokens: { '--accordion-accent': 'var(--color-base-content)' },
@@ -2678,7 +2898,23 @@ export const select: RecipeInput = {
2678
2898
  web: { parts: { popup: { base: anchoredListbox } } },
2679
2899
  lynx: {
2680
2900
  parts: {
2681
- trigger: { base: lynxBtnPad(4) },
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)' } } },
2682
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' } },
2683
2919
  },
2684
2920
  variants: {
@@ -2706,6 +2942,23 @@ const BTN_STEPS = { xs: 6, sm: 8, md: 10, lg: 12, xl: 14 } as const;
2706
2942
  const btnHeight = (step: keyof typeof BTN_STEPS): string =>
2707
2943
  `calc(var(--size-field) * ${BTN_STEPS[step]})`;
2708
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
+
2709
2962
  export const button: RecipeInput = {
2710
2963
  component: 'button',
2711
2964
  // Public to a design system derived from this one (#73).
@@ -2921,11 +3174,39 @@ export const button: RecipeInput = {
2921
3174
  },
2922
3175
  defaultVariants: { color: 'primary', variant: 'solid', size: 'md' },
2923
3176
  keyframes: { 'zero-daisyui-btn-spin': 'to { transform: rotate(360deg) }' },
2924
- // The icon chips' zeroed inline padding, restated physically: logical
2925
- // spellings resolve on iOS but not on Android (measured,
2926
- // signalxjs/lynx#1084), so the emitter refuses them.
2927
3177
  targets: {
2928
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.
2929
3210
  modifiers: {
2930
3211
  square: { root: { base: { paddingLeft: '0', paddingRight: '0' } } },
2931
3212
  circle: { root: { base: { paddingLeft: '0', paddingRight: '0' } } },
@@ -3166,21 +3447,66 @@ export const toast: RecipeInput = {
3166
3447
  // signalxjs/lynx#1070. No `color` default: the un-attributed toast is a
3167
3448
  // neutral base-200 surface, outside the color vocabulary.
3168
3449
  defaultVariants: { size: 'md' },
3169
- // The corner placements' physical anchors: logical inset spellings
3170
- // resolve on iOS but not on Android (measured, signalxjs/lynx#1084), so
3171
- // the emitter refuses them. Physical is the lynx target's norm — no RTL
3172
- // flow there, so `start` IS left and `end` IS right.
3173
3450
  targets: {
3174
3451
  lynx: {
3175
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.
3176
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
+ },
3177
3492
  selectors: {
3178
- '&[data-placement="top-start"]': { left: '0' },
3179
- '&[data-placement="top-end"]': { right: '0' },
3180
- '&[data-placement="bottom-start"]': { left: '0' },
3181
- '&[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' },
3496
+ },
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)',
3182
3507
  },
3183
3508
  },
3509
+ close: { base: { position: 'absolute', top: 'var(--space-xs)', right: 'var(--space-xs)' } },
3184
3510
  },
3185
3511
  },
3186
3512
  },
@@ -5244,6 +5570,87 @@ export const timeline: RecipeInput = {
5244
5570
  xl: { marker: { base: { '--timeline-marker-size': 'calc(var(--size-selector) * 4)' } }, content: { base: { fontSize: 'var(--text-md)' } } },
5245
5571
  },
5246
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
+ },
5247
5654
  };
5248
5655
 
5249
5656
  /** daisy chat: the classic bubble, colour-refilled per role. */
@@ -6288,6 +6695,9 @@ export const drawer: RecipeInput = {
6288
6695
  */
6289
6696
  export const table: RecipeInput = {
6290
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'] },
6291
6701
  // Public to a design system derived from this one (#73).
6292
6702
  hooks: {
6293
6703
  properties: {
@@ -6373,6 +6783,55 @@ export const table: RecipeInput = {
6373
6783
  },
6374
6784
  at: tableStackAt(tokens, 'header-cell', { paddingInline: '0' }),
6375
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
+ },
6376
6835
  cell: {
6377
6836
  base: {
6378
6837
  padding: 'var(--table-pad-block) var(--table-pad-inline)',
@@ -7027,7 +7486,7 @@ export const diff: RecipeInput = {
7027
7486
 
7028
7487
  export const recipes: RecipeInput[] = [
7029
7488
  tabs, collapsible, switchRecipe, dialog, popover, tooltip, menu,
7030
- field, checkbox, checkboxGroup, radioGroup, progress, slider, accordion, select, button, avatar, toast, combobox,
7489
+ field, fieldset, checkbox, checkboxGroup, radioGroup, progress, slider, accordion, select, button, avatar, toast, combobox,
7031
7490
  toggle, toggleGroup, numberInput, ratingGroup, treeView, input, textarea,
7032
7491
  card, alert, emptyState, badge, divider, skeleton, spinner,
7033
7492
  kbd, status, indicator, stats, timeline, chat, radialProgress, join,