@sigx/zero-daisyui 0.6.0 → 0.8.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 (56) hide show
  1. package/README.md +1 -2
  2. package/dist/audit.json +5618 -72
  3. package/dist/components.d.ts +37 -1
  4. package/dist/components.js +4 -0
  5. package/dist/css/components/breadcrumbs.css +30 -0
  6. package/dist/css/components/card.css +26 -0
  7. package/dist/css/components/chat-log.css +172 -0
  8. package/dist/css/components/divider.css +68 -3
  9. package/dist/css/components/fieldset.css +91 -0
  10. package/dist/css/components/hover-card.css +174 -0
  11. package/dist/css/components/menu.css +73 -0
  12. package/dist/css/components/menubar.css +75 -0
  13. package/dist/css/components/pagination.css +83 -0
  14. package/dist/css/components/popover.css +72 -0
  15. package/dist/css/components/progress.css +0 -4
  16. package/dist/css/components/steps.css +103 -0
  17. package/dist/css/components/table.css +70 -0
  18. package/dist/css/components/timeline.css +14 -0
  19. package/dist/css/components/toast.css +65 -0
  20. package/dist/css/components/tooltip.css +65 -0
  21. package/dist/css/components/tree-view.css +118 -0
  22. package/dist/css/index.css +1330 -42
  23. package/dist/lynx/components/accordion.css +7 -1
  24. package/dist/lynx/components/breadcrumbs.css +44 -0
  25. package/dist/lynx/components/button.css +121 -10
  26. package/dist/lynx/components/card.css +9 -0
  27. package/dist/lynx/components/chat-log.css +147 -0
  28. package/dist/lynx/components/dialog.css +28 -3
  29. package/dist/lynx/components/divider.css +8 -17
  30. package/dist/lynx/components/fieldset.css +219 -0
  31. package/dist/lynx/components/hover-card.css +211 -0
  32. package/dist/lynx/components/menu.css +7 -0
  33. package/dist/lynx/components/menubar.css +199 -0
  34. package/dist/lynx/components/pagination.css +107 -0
  35. package/dist/lynx/components/popover.css +44 -2
  36. package/dist/lynx/components/progress.css +0 -20
  37. package/dist/lynx/components/select.css +51 -3
  38. package/dist/lynx/components/slider.css +295 -12
  39. package/dist/lynx/components/steps.css +103 -0
  40. package/dist/lynx/components/switch.css +4 -1
  41. package/dist/lynx/components/table.css +52 -0
  42. package/dist/lynx/components/tabs.css +109 -94
  43. package/dist/lynx/components/timeline.css +90 -1
  44. package/dist/lynx/components/toast.css +111 -8
  45. package/dist/lynx/components/tree-view.css +28 -0
  46. package/dist/lynx/index.css +2081 -255
  47. package/dist/lynx/manifest.json +112 -4
  48. package/dist/manifest.json +110 -2
  49. package/dist/recipes.d.ts +29 -1
  50. package/dist/recipes.d.ts.map +1 -1
  51. package/dist/recipes.js +1225 -129
  52. package/dist/recipes.js.map +1 -1
  53. package/dist/register.d.ts +36 -0
  54. package/dist/report.json +1353 -188
  55. package/package.json +4 -4
  56. package/src/recipes.ts +1254 -130
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
  /**
@@ -39,6 +39,41 @@ const focusRing: Record<string, NonNullable<PartStyles['base']>> = {
39
39
  },
40
40
  };
41
41
 
42
+ /**
43
+ * The focus ring on lynx, for `targets.lynx` (signalxjs/lynx#1163): the same
44
+ * 2px ring 2px clear of the part, spelled as spread box-shadows.
45
+ *
46
+ * The web ring is `outline` + `outline-offset`, and neither survives on
47
+ * lynx: its `outline` ignores `border-radius` (a square box around a
48
+ * rounded button, a rectangle around the switch pill) and it has no
49
+ * `outline-offset` property at all, so the ring sat flush on the fill. A
50
+ * zero-blur shadow's spread follows the element's radius. Shadows stack
51
+ * rather than mask, so the offset gap cannot be transparent: the inner
52
+ * shadow paints it in `gap` — the surface the part sits on, which is what
53
+ * the web's gap shows through to. `outline: none` retires the shared ring
54
+ * in the same merged declaration block.
55
+ *
56
+ * `under` appends the part's own lynx shadows (a knob's depth relief), which
57
+ * the focus rule would otherwise replace. `inset` draws the ring inside the
58
+ * part, flush with its edge, for a part whose parent clips an outside ring
59
+ * away (signalxjs/lynx#1164, the accordion trigger inside its card).
60
+ */
61
+ const lynxFocusRing = (
62
+ ink: string,
63
+ { gap = 'var(--color-base-100)', under, inset = false }: { gap?: string; under?: string; inset?: boolean } = {},
64
+ ): CssProps => ({
65
+ outline: 'none',
66
+ boxShadow: [
67
+ ...(inset ? [`inset 0 0 0 2px ${ink}`] : [`0 0 0 2px ${gap}`, `0 0 0 4px ${ink}`]),
68
+ ...(under ? [under] : []),
69
+ ].join(', '),
70
+ });
71
+
72
+ /** `focusRing`'s lynx spelling — the base-content ring, as a states entry. */
73
+ const lynxFocus = (opts?: Parameters<typeof lynxFocusRing>[1]): Record<string, CssProps> => ({
74
+ 'focus-visible': lynxFocusRing('var(--color-base-content)', opts),
75
+ });
76
+
42
77
  /**
43
78
  * How far each role survives in the button's INK — the colour a transparent or
44
79
  * tinted fill draws its label, border and focus ring with.
@@ -316,7 +351,9 @@ export const tabs: RecipeInput = {
316
351
  },
317
352
  // daisy draws each flavor's active mark on the tab itself (the
318
353
  // underline, the lift, the pill) and none of them slides, so a
319
- // `Tabs.Indicator` renders nothing here (#283).
354
+ // `Tabs.Indicator` renders nothing here (#283). On lynx, which has
355
+ // no pseudo-elements, the `border` flavor's underline is drawn on
356
+ // this part instead (see `targets.lynx`).
320
357
  indicator: { base: { display: 'none' } },
321
358
  panel: {
322
359
  base: { fontSize: 'var(--text-md)' },
@@ -520,11 +557,17 @@ export const tabs: RecipeInput = {
520
557
  targets: {
521
558
  lynx: {
522
559
  parts: {
523
- // The default active ink — base-content, exactly what the
524
- // shared `states.active.color` paints. One class, so any
560
+ // No hover on a touch platform: the held tab takes the ink
561
+ // daisy's hover gives it.
562
+ tab: { states: { pressed: { color: 'var(--color-base-content)' }, ...lynxFocus() } },
563
+ // The list is the indicator's containing block.
564
+ list: { base: { position: 'relative' } },
565
+ // The underline's ink (read by the `border` flavor below) —
566
+ // base-content by default, exactly what the shared
567
+ // `states.active.color` paints on the tab. One class, so any
525
568
  // color-axis definition below (host + two classes, baked per
526
569
  // theme) wins on specificity.
527
- tab: { base: { '--tab-active-ink': 'var(--color-base-content)' } },
570
+ indicator: { base: { '--tab-active-ink': 'var(--color-base-content)' } },
528
571
  },
529
572
  variants: {
530
573
  // `currentColor` never resolves on lynx (measured on device
@@ -535,19 +578,29 @@ export const tabs: RecipeInput = {
535
578
  // once per theme — the same per-color/per-theme literal the
536
579
  // active `color:` rules already land as.
537
580
  color: Object.fromEntries(
538
- ROLES.map((c) => [c, { tab: { base: { '--tab-active-ink': roleInk(c) } } }]),
581
+ ROLES.map((c) => [c, { indicator: { base: { '--tab-active-ink': roleInk(c) } } }]),
539
582
  ),
540
583
  variant: {
541
584
  // 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.
585
+ // drops. It is redrawn on the `indicator` part (zero#324),
586
+ // which lynx-zero renders in every list and positions
587
+ // over the active tab from measured geometry (the web's
588
+ // `--tabs-indicator-*` properties are web-only). The part
589
+ // IS the tab's box, so daisy's bar is its bottom edge:
590
+ // 3px, 80% wide and centred, like `left: 10%; width: 80%`
591
+ // on the web. The ink rides `--tab-active-ink` — a plain
592
+ // var() chain, device-proven — instead of `currentColor`,
593
+ // which lynx never resolves.
547
594
  border: {
548
- tab: {
549
- base: { borderBottom: '3px solid transparent' },
550
- states: { active: { borderBottomColor: 'var(--tab-active-ink)' } },
595
+ indicator: {
596
+ base: {
597
+ display: 'flex',
598
+ boxSizing: 'border-box',
599
+ borderBottomWidth: '3px',
600
+ borderBottomStyle: 'solid',
601
+ borderBottomColor: 'var(--tab-active-ink)',
602
+ transform: 'scaleX(0.8)',
603
+ },
551
604
  },
552
605
  },
553
606
  // `min()` does not exist on lynx (signalxjs/lynx#1066) and
@@ -559,6 +612,14 @@ export const tabs: RecipeInput = {
559
612
  base: { '--tab-radius-limit': 'var(--radius-field)' },
560
613
  },
561
614
  },
615
+ // The box flavor's tabs sit on the list's base-200
616
+ // surface, so the ring's gap is that surface. Stated on
617
+ // the variant (one class more than the part rule) so it
618
+ // also outranks the active pill's `--shadow-sm`, which
619
+ // is a box-shadow too.
620
+ box: {
621
+ tab: { states: lynxFocus({ gap: 'var(--color-base-200)' }) },
622
+ },
562
623
  },
563
624
  },
564
625
  },
@@ -900,6 +961,10 @@ export const switchRecipe: RecipeInput = {
900
961
  // No currentColor on this target — the ink is named
901
962
  // directly, and `checked` moves border + fill below.
902
963
  border: 'var(--border) solid var(--switch-ink)',
964
+ // daisy's 1px inset depth line along the track's top
965
+ // edge — `currentColor` at `--depth` × 10% on the
966
+ // web, the theme's own depth shade here.
967
+ boxShadow: '0 1px var(--depth-shade) inset',
903
968
  userSelect: 'none',
904
969
  transition: 'border-color var(--duration-slow) var(--ease-standard), '
905
970
  + 'background-color var(--duration-normal) var(--ease-standard)',
@@ -909,6 +974,9 @@ export const switchRecipe: RecipeInput = {
909
974
  borderColor: 'var(--switch-accent)',
910
975
  backgroundColor: 'var(--color-base-100)',
911
976
  },
977
+ // The pill ring (signalxjs/lynx#1163), keeping the
978
+ // track's inset depth line under it.
979
+ 'focus-visible': lynxFocusRing('var(--switch-accent)', { under: '0 1px var(--depth-shade) inset' }),
912
980
  },
913
981
  },
914
982
  thumb: {
@@ -920,6 +988,12 @@ export const switchRecipe: RecipeInput = {
920
988
  height: 'calc(var(--switch-size) - (var(--border) + var(--switch-p)) * 2)',
921
989
  borderRadius: 'var(--radius-selector)',
922
990
  backgroundColor: 'var(--switch-ink)',
991
+ // daisy's knob relief (`.toggle:before`): a shade
992
+ // along the bottom edge, a sheen across the top, and
993
+ // a 1px drop — the web's `currentColor` drop spelled
994
+ // as the theme's depth shade. The noise layer has no
995
+ // lynx spelling and stays web-only.
996
+ boxShadow: '0 -1px var(--depth-shade) inset, 0 8px 0 -4px var(--depth-sheen) inset, 0 1px var(--depth-shade)',
923
997
  transition: 'transform var(--duration-normal) var(--ease-standard), '
924
998
  + 'background-color var(--duration-instant) var(--ease-standard)',
925
999
  },
@@ -1028,6 +1102,43 @@ const lynxBtnPad = (mult: number): NonNullable<PartStyles['base']> => ({
1028
1102
  paddingRight: `calc(var(--size-field) * ${mult})`,
1029
1103
  });
1030
1104
 
1105
+ /**
1106
+ * A four-side property spelled as its physical longhands, for `targets.lynx`
1107
+ * sections that must not ship a `var()`-bearing shorthand.
1108
+ *
1109
+ * Lynx keeps a declaration holding `var()` under its own property id,
1110
+ * unexpanded, and expands it only after the cascade has merged every rule
1111
+ * into one insertion-ordered style map — in that map's order, writing over
1112
+ * the longhands already there (`CSSVariableHandler::HandleCSSVariables`). A
1113
+ * static shorthand, by contrast, is expanded into its longhands at parse
1114
+ * time. So a var-bearing `border-color` and a static `border-top-color` never
1115
+ * meet in the cascade at all: whichever key the map saw later wins, whatever
1116
+ * the specificity (signalxjs/lynx#1161, #1162). Longhands share one key per
1117
+ * side, and the cascade decides again.
1118
+ */
1119
+ const lynxSides = (
1120
+ prop: 'border{}Width' | 'border{}Color' | 'padding{}',
1121
+ value: string,
1122
+ sides: readonly ('Top' | 'Right' | 'Bottom' | 'Left')[] = ['Top', 'Right', 'Bottom', 'Left'],
1123
+ ): NonNullable<PartStyles['base']> =>
1124
+ Object.fromEntries(sides.map((side) => [prop.replace('{}', side), value]));
1125
+
1126
+ /**
1127
+ * daisy's `.btn` is content-sized and never squeezed: `display: inline-flex`
1128
+ * with `flex-shrink: 0; flex-wrap: nowrap`, so a narrow row makes the button
1129
+ * overflow, never break its label. A lynx view has no inline formatting
1130
+ * context: it stretches to a linear or column parent, and a squeezed label
1131
+ * `<text>` then wraps per character ("Bt / n", signalxjs/lynx#1165).
1132
+ * `width: max-content` is the intrinsic keyword starlight implements (an
1133
+ * intrinsic width is also exempt from the cross-axis stretch), so the box
1134
+ * sizes to its content as the web's does; the other two are daisy's own.
1135
+ */
1136
+ const lynxBtnFit: NonNullable<PartStyles['base']> = {
1137
+ width: 'max-content',
1138
+ flexShrink: '0',
1139
+ flexWrap: 'nowrap',
1140
+ };
1141
+
1031
1142
  /** The btn size ramp's physical inline padding, for `targets.lynx.variants`. */
1032
1143
  const lynxBtnSizes = (part: string): Record<string, Record<string, PartStyles>> => ({
1033
1144
  xs: { [part]: { base: lynxBtnPad(2) } },
@@ -1036,6 +1147,22 @@ const lynxBtnSizes = (part: string): Record<string, Record<string, PartStyles>>
1036
1147
  xl: { [part]: { base: lynxBtnPad(6) } },
1037
1148
  });
1038
1149
 
1150
+ /**
1151
+ * The press rendering of the overlay btns (dialog, popover), for their
1152
+ * `targets.lynx` sections. The web answers a pointer with `:hover`, which
1153
+ * the lynx emitter drops (a touch platform), and these parts' `pressed`
1154
+ * flag — stamped by the lynx runtime on every touch — had no rule, so a
1155
+ * tap showed nothing (signalxjs/lynx#1146). daisy's `:hover` wash plus the
1156
+ * btn's 1px sink, the same pair `button` paints for its own `pressed`. A
1157
+ * colour variant restates the background after the states at equal
1158
+ * specificity, so a coloured trigger keeps its fill and sinks.
1159
+ */
1160
+ const lynxBtnPressed: NonNullable<PartStyles['base']> = {
1161
+ background: 'var(--color-base-300)',
1162
+ transform: 'translateY(1px)',
1163
+ boxShadow: 'none',
1164
+ };
1165
+
1039
1166
  export const dialog: RecipeInput = {
1040
1167
  component: 'dialog',
1041
1168
  // Public to a design system derived from this one (#73).
@@ -1127,14 +1254,17 @@ export const dialog: RecipeInput = {
1127
1254
  // reach and the trigger is the whole story here.
1128
1255
  variants: { color: btnColors(), size: btnSizes },
1129
1256
  // The btn paddings and the footer's push-down, restated physically —
1130
- // see `lynxBtnPad` for the #1084 verdict this answers.
1257
+ // see `lynxBtnPad` for the #1084 verdict this answers — and the btns'
1258
+ // press rendering (`lynxBtnPressed`). The footer states its row: on lynx
1259
+ // a row container says so next to `display: flex`.
1131
1260
  targets: {
1132
1261
  lynx: {
1133
1262
  parts: {
1134
- trigger: { base: lynxBtnPad(4) },
1135
- close: { base: lynxBtnPad(4) },
1136
- cancel: { base: lynxBtnPad(4) },
1137
- footer: { base: { marginTop: 'var(--space-2xl)' } },
1263
+ // Content-sized like daisy's btn, never broken mid-word (signalxjs/lynx#1165).
1264
+ trigger: { base: { ...lynxBtnPad(4), ...lynxBtnFit }, states: { pressed: lynxBtnPressed, ...lynxFocus() } },
1265
+ close: { base: lynxBtnPad(4), states: { pressed: lynxBtnPressed, ...lynxFocus() } },
1266
+ cancel: { base: lynxBtnPad(4), states: { pressed: lynxBtnPressed, ...lynxFocus() } },
1267
+ footer: { base: { marginTop: 'var(--space-2xl)', flexDirection: 'row' } },
1138
1268
  },
1139
1269
  variants: { size: lynxBtnSizes('trigger') },
1140
1270
  },
@@ -1150,6 +1280,22 @@ const floatingPanel: NonNullable<PartStyles['base']> = {
1150
1280
  boxShadow: 'var(--shadow-lg)',
1151
1281
  };
1152
1282
 
1283
+ /**
1284
+ * The arrow a floating surface grows toward its anchor (#279), in the
1285
+ * surface's own paint: the dropdown's base-100 and border for popover and
1286
+ * menu, the tooltip's borderless neutral for the tooltip — daisy's
1287
+ * `tooltip` draws exactly that tail under its bubble. Web-only (placed by
1288
+ * the runtime's `--arrow-x`/`--arrow-y`), so it rides `targets.web` with
1289
+ * the popup's permission to let it out.
1290
+ */
1291
+ const floatingArrow = (scope: string, paint: CssProps, size = '0.625rem'): NonNullable<NonNullable<RecipeInput['targets']>['web']> => ({
1292
+ parts: {
1293
+ popup: { selectors: popupArrowHost(scope) },
1294
+ arrow: popupArrow(scope, { size, paint }),
1295
+ },
1296
+ });
1297
+ const panelArrowPaint: CssProps = { background: 'var(--color-base-100)', border: 'var(--border) solid var(--color-base-300)' };
1298
+
1153
1299
  /**
1154
1300
  * daisy's dropdown menu for a listbox, sized by the geometry the
1155
1301
  * anchored-position strategy publishes (#278): at least the width of the
@@ -1187,6 +1333,14 @@ export const popover: RecipeInput = {
1187
1333
  title: {
1188
1334
  base: { margin: '0 0 var(--space-md)', fontSize: 'var(--text-md)', fontWeight: 'var(--weight-bold)' },
1189
1335
  },
1336
+ // The modal's muted body line, at the dropdown's tighter rhythm.
1337
+ description: {
1338
+ base: {
1339
+ margin: '0 0 var(--space-lg)',
1340
+ fontSize: 'var(--text-sm)',
1341
+ color: 'color-mix(in oklab, var(--color-base-content) 75%, transparent)',
1342
+ },
1343
+ },
1190
1344
  close: {
1191
1345
  base: { ...btn, height: 'calc(var(--size-field) * 8)', paddingInline: 'calc(var(--size-field) * 3)', fontSize: 'var(--text-xs)' },
1192
1346
  states: {
@@ -1198,12 +1352,15 @@ export const popover: RecipeInput = {
1198
1352
  },
1199
1353
  // Trigger-carried axes — same wiring as dialog, same reason.
1200
1354
  variants: { color: btnColors(), size: btnSizes },
1201
- // The btn paddings, restated physically — see `lynxBtnPad` (#1084).
1355
+ // The btn paddings, restated physically — see `lynxBtnPad` (#1084) —
1356
+ // and the btns' press rendering (`lynxBtnPressed`).
1202
1357
  targets: {
1358
+ web: floatingArrow('popover', panelArrowPaint),
1203
1359
  lynx: {
1204
1360
  parts: {
1205
- trigger: { base: lynxBtnPad(4) },
1206
- close: { base: lynxBtnPad(3) },
1361
+ // Content-sized like daisy's btn, never broken mid-word (signalxjs/lynx#1165).
1362
+ trigger: { base: { ...lynxBtnPad(4), ...lynxBtnFit }, states: { pressed: lynxBtnPressed, ...lynxFocus() } },
1363
+ close: { base: lynxBtnPad(3), states: { pressed: lynxBtnPressed, ...lynxFocus() } },
1207
1364
  },
1208
1365
  variants: { size: lynxBtnSizes('trigger') },
1209
1366
  },
@@ -1246,6 +1403,7 @@ export const tooltip: RecipeInput = {
1246
1403
  variants: { color: btnColors(), size: btnSizes },
1247
1404
  // The btn paddings, restated physically — see `lynxBtnPad` (#1084).
1248
1405
  targets: {
1406
+ web: floatingArrow('tooltip', { background: 'var(--color-neutral)' }, '0.5rem'),
1249
1407
  lynx: {
1250
1408
  parts: { trigger: { base: lynxBtnPad(4) } },
1251
1409
  variants: { size: lynxBtnSizes('trigger') },
@@ -1253,6 +1411,57 @@ export const tooltip: RecipeInput = {
1253
1411
  },
1254
1412
  };
1255
1413
 
1414
+ /**
1415
+ * Hover card — daisy's `link` opening a `dropdown-content` card. The trigger
1416
+ * is running text, not a btn: underlined in the ink it sits in (daisy's bare
1417
+ * `link`), and a colour is `link-<role>` in the role's readable ink
1418
+ * (`roleInk`). The underline thickens on hover and while the card is up,
1419
+ * rather than the ink dimming — the `btn-link` reasoning. The card is the
1420
+ * floating panel popover and menu wear.
1421
+ */
1422
+ export const hoverCard: RecipeInput = {
1423
+ component: 'hover-card',
1424
+ parts: {
1425
+ trigger: {
1426
+ base: {
1427
+ color: 'inherit',
1428
+ textDecorationLine: 'underline',
1429
+ textUnderlineOffset: '2px',
1430
+ cursor: 'pointer',
1431
+ borderRadius: 'var(--radius-selector)',
1432
+ },
1433
+ states: {
1434
+ hover: { textDecorationThickness: '2px' },
1435
+ open: { textDecorationThickness: '2px' },
1436
+ closed: {},
1437
+ ...focusRing,
1438
+ },
1439
+ },
1440
+ popup: withPresence(popupPresence('translateY(-4px)'), {
1441
+ base: {
1442
+ ...floatingPanel,
1443
+ padding: 'var(--space-xl)',
1444
+ maxWidth: '20rem',
1445
+ fontSize: 'var(--text-sm)',
1446
+ },
1447
+ states: { open: {}, closed: {} },
1448
+ }),
1449
+ },
1450
+ variants: {
1451
+ color: Object.fromEntries(ROLES.map((c) => [c, { trigger: { base: { color: roleInk(c) } } }])),
1452
+ size: {
1453
+ xs: { trigger: { base: { fontSize: 'var(--text-xs)' } } },
1454
+ sm: { trigger: { base: { fontSize: 'var(--text-sm)' } } },
1455
+ md: {},
1456
+ lg: { trigger: { base: { fontSize: 'var(--text-lg)' } } },
1457
+ xl: { trigger: { base: { fontSize: 'var(--text-xl)' } } },
1458
+ },
1459
+ },
1460
+ // Web-only: the popover top layer and the runtime's arrow geometry have
1461
+ // no lynx counterpart.
1462
+ targets: { web: floatingArrow('hover-card', panelArrowPaint) },
1463
+ };
1464
+
1256
1465
  // daisy "menu in a dropdown" look.
1257
1466
  export const menu: RecipeInput = {
1258
1467
  component: 'menu',
@@ -1386,6 +1595,17 @@ export const menu: RecipeInput = {
1386
1595
  opacity: '0.6',
1387
1596
  },
1388
1597
  },
1598
+ // daisy's menu hint: the group label's faded ink, pushed to the
1599
+ // row's reading end — faded less, since 0.6 leaves nord's paper
1600
+ // under the 4.5:1 a line read by eye owes.
1601
+ shortcut: {
1602
+ base: {
1603
+ marginInlineStart: 'auto',
1604
+ paddingInlineStart: 'var(--space-lg)',
1605
+ fontSize: 'var(--text-xs)',
1606
+ opacity: '0.75',
1607
+ },
1608
+ },
1389
1609
  separator: {
1390
1610
  base: {
1391
1611
  height: 'var(--border)',
@@ -1399,13 +1619,57 @@ export const menu: RecipeInput = {
1399
1619
  variants: { color: btnColors(), size: btnSizes },
1400
1620
  // The btn paddings, restated physically — see `lynxBtnPad` (#1084).
1401
1621
  targets: {
1622
+ web: floatingArrow('menu', panelArrowPaint),
1402
1623
  lynx: {
1403
- parts: { trigger: { base: lynxBtnPad(4) } },
1624
+ parts: {
1625
+ trigger: { base: lynxBtnPad(4) },
1626
+ // The shortcut's reading-end push, restated physically (#1084).
1627
+ shortcut: { base: { marginLeft: 'auto', paddingLeft: 'var(--space-lg)' } },
1628
+ },
1404
1629
  variants: { size: lynxBtnSizes('trigger') },
1405
1630
  },
1406
1631
  },
1407
1632
  };
1408
1633
 
1634
+ // The menubar row: daisy's `menu-horizontal` strip — a base-200 box the
1635
+ // menu triggers sit in. The triggers are ordinary `menu` triggers.
1636
+ export const menubar: RecipeInput = {
1637
+ component: 'menubar',
1638
+ tokens: { '--menubar-surface': 'var(--color-base-200)' },
1639
+ parts: {
1640
+ root: {
1641
+ base: {
1642
+ display: 'flex',
1643
+ alignItems: 'center',
1644
+ gap: 'var(--space-xs)',
1645
+ padding: 'var(--space-xs)',
1646
+ width: 'max-content',
1647
+ maxWidth: '100%',
1648
+ flexWrap: 'wrap',
1649
+ background: 'var(--menubar-surface)',
1650
+ borderRadius: 'var(--radius-box)',
1651
+ },
1652
+ // The triggers fade themselves — see basic.
1653
+ states: { disabled: {} },
1654
+ selectors: {
1655
+ '&[data-orientation="vertical"]': { flexDirection: 'column', alignItems: 'stretch' },
1656
+ },
1657
+ },
1658
+ },
1659
+ variants: {
1660
+ color: Object.fromEntries(ROLES.map((c) => [c, { root: { base: {
1661
+ '--menubar-surface': `color-mix(in oklch, var(--color-${c}) 12%, var(--color-base-200))`,
1662
+ } } }])),
1663
+ size: {
1664
+ xs: { root: { base: { gap: '0', padding: '0' } } },
1665
+ sm: { root: { base: { padding: 'var(--space-2xs)' } } },
1666
+ md: {},
1667
+ lg: { root: { base: { gap: 'var(--space-sm)', padding: 'var(--space-sm)' } } },
1668
+ xl: { root: { base: { gap: 'var(--space-md)', padding: 'var(--space-sm)' } } },
1669
+ },
1670
+ },
1671
+ };
1672
+
1409
1673
  export const field: RecipeInput = {
1410
1674
  component: 'field',
1411
1675
  // The label's accent ink — base-content by default, so the un-attributed
@@ -1456,6 +1720,61 @@ export const field: RecipeInput = {
1456
1720
  },
1457
1721
  };
1458
1722
 
1723
+ /**
1724
+ * Fieldset (#285), daisyUI 5's `.fieldset` / `.fieldset-legend`: a
1725
+ * one-column grid with a tight row gap and a little block padding, no frame
1726
+ * of its own (daisy draws one only when the app adds `border`), and a
1727
+ * semibold legend with block padding standing in for its margin. The UA
1728
+ * `min-inline-size: min-content` is undone so a wide control cannot push the
1729
+ * group past its column. Colour accents the legend through `roleInk`, the
1730
+ * Field way.
1731
+ */
1732
+ export const fieldset: RecipeInput = {
1733
+ component: 'fieldset',
1734
+ tokens: { '--fieldset-accent': 'var(--color-base-content)' },
1735
+ parts: {
1736
+ root: {
1737
+ base: {
1738
+ display: 'grid',
1739
+ gridTemplateColumns: '1fr',
1740
+ gridAutoRows: 'max-content',
1741
+ gap: 'var(--space-sm)',
1742
+ minInlineSize: '0',
1743
+ margin: '0',
1744
+ paddingBlock: 'var(--space-xs)',
1745
+ paddingInline: '0',
1746
+ border: 'none',
1747
+ },
1748
+ },
1749
+ legend: {
1750
+ base: {
1751
+ paddingBlock: 'var(--space-sm)',
1752
+ paddingInline: '0',
1753
+ fontSize: 'var(--text-sm)',
1754
+ fontWeight: 'var(--weight-semibold)',
1755
+ color: 'var(--fieldset-accent)',
1756
+ },
1757
+ states: {
1758
+ disabled: { opacity: 'var(--disabled-opacity)' },
1759
+ invalid: { color: roleInk('error') },
1760
+ },
1761
+ },
1762
+ },
1763
+ variants: {
1764
+ color: Object.fromEntries(ROLES.map((c) => [c, { root: { base: {
1765
+ '--fieldset-accent': roleInk(c),
1766
+ } } }])),
1767
+ size: {
1768
+ xs: { legend: { base: { fontSize: 'var(--text-xs)' } } },
1769
+ sm: { legend: { base: { fontSize: 'var(--text-xs)' } } },
1770
+ // `md` is the un-attributed render.
1771
+ md: {},
1772
+ lg: { legend: { base: { fontSize: 'var(--text-md)' } } },
1773
+ xl: { legend: { base: { fontSize: 'var(--text-lg)' } } },
1774
+ },
1775
+ },
1776
+ };
1777
+
1459
1778
  /**
1460
1779
  * daisy's tick, drawn by a clip-path that GROWS.
1461
1780
  *
@@ -1962,9 +2281,13 @@ export const progress: RecipeInput = {
1962
2281
  transition: 'width var(--duration-slow) var(--ease-standard)',
1963
2282
  },
1964
2283
  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) },
2284
+ // `complete` keeps the colour axis. daisyUI 5 has no complete
2285
+ // tint — a full `progress-primary` stays primary — and a
2286
+ // `color="secondary"` bar that turns green at 100% reads as a
2287
+ // bug, not as meaning (signalxjs/lynx#1144). A caller who wants
2288
+ // "done" to look like success says so: `color="success"`.
2289
+ // Declared equal to `loading` in `sameAs` below.
2290
+ complete: {},
1968
2291
  loading: {},
1969
2292
  indeterminate: { width: '40%', animation: 'zero-daisy-indeterminate 1.2s ease-in-out infinite' },
1970
2293
  },
@@ -2000,6 +2323,8 @@ export const progress: RecipeInput = {
2000
2323
  // so the manifest states the default for runtimes with no `:not()`
2001
2324
  // fallback (the lynx target; signalxjs/lynx#1070).
2002
2325
  defaultVariants: { color: 'primary', size: 'md' },
2326
+ // A finished bar is a full bar of the same ink (see `complete` above).
2327
+ sameAs: { root: { complete: 'loading' }, range: { complete: 'loading' } },
2003
2328
  keyframes: {
2004
2329
  // Logical, so the sweep runs the way the bar fills — `margin-inline-start`
2005
2330
  // is animatable and direction-aware on its own. The determinate `width`,
@@ -2021,6 +2346,10 @@ export const progress: RecipeInput = {
2021
2346
  },
2022
2347
  };
2023
2348
 
2349
+ /** The slider's deepened fill — the web's `--slider-fill` mix, for a given accent. */
2350
+ const sliderFill = (accent: string): string =>
2351
+ `color-mix(in oklab, ${accent} 90%, var(--color-base-content))`;
2352
+
2024
2353
  export const slider: RecipeInput = {
2025
2354
  component: 'slider',
2026
2355
  // daisy's `.range` metrics: the thumb is `--range-thumb-size`
@@ -2331,29 +2660,100 @@ export const slider: RecipeInput = {
2331
2660
  },
2332
2661
  },
2333
2662
  lynx: {
2663
+ // The web's deepened fill, theme-baked: `color-mix()` over theme
2664
+ // colours cannot resolve on device, but the emitter restates a
2665
+ // colour function per theme as literals (as `progress` does for
2666
+ // its accent), so each colour step gets the same 90/10 mix the
2667
+ // web track and knob ring wear. Bound on the root like
2668
+ // `--slider-accent`, rebound by `color` below and by `invalid`
2669
+ // on the control (which wraps the rail on lynx).
2670
+ tokens: { '--slider-fill': sliderFill('var(--color-primary)') },
2671
+ variants: {
2672
+ color: Object.fromEntries(ROLES.map((c) => [c, { root: { base: {
2673
+ '--slider-fill': sliderFill(`var(--color-${c})`),
2674
+ } } }])),
2675
+ },
2334
2676
  parts: {
2677
+ // Vertical (lynx#1144): the lynx runtime positions the moving
2678
+ // parts itself — `top` percentages, the knob centred by
2679
+ // `left: 50%` plus the base's `translateY(-50%)` and
2680
+ // negative `margin-left` — so the class rules only turn the
2681
+ // channel upright: the control is a thumb-wide column of
2682
+ // daisy's rail length, the track fills it.
2683
+ root: {
2684
+ selectors: {
2685
+ '&[data-orientation="vertical"]': { width: 'auto', alignItems: 'center' },
2686
+ },
2687
+ },
2688
+ control: {
2689
+ states: {
2690
+ invalid: { '--slider-fill': sliderFill('var(--color-error)') },
2691
+ // The control's ring is the NATIVE input's on the web;
2692
+ // lynx always renders the composed parts, where the
2693
+ // thumb is what takes focus and wears the ring below
2694
+ // (signalxjs/lynx#1163 — both at once read as two
2695
+ // nested boxes).
2696
+ 'focus-visible': { outline: 'none' },
2697
+ },
2698
+ selectors: {
2699
+ '&[data-orientation="vertical"]': {
2700
+ display: 'flex',
2701
+ flexDirection: 'row',
2702
+ justifyContent: 'center',
2703
+ width: 'var(--slider-thumb-size)',
2704
+ height: 'calc(var(--size-selector) * 40)',
2705
+ },
2706
+ },
2707
+ },
2335
2708
  track: {
2336
2709
  // The web track's `margin-block` breathing room,
2337
2710
  // restated physically: logical spellings resolve on iOS
2338
2711
  // but not on Android (measured, signalxjs/lynx#1084), so
2339
2712
  // the emitter refuses them. Physical is the lynx
2340
2713
  // target's norm — no RTL flow there.
2714
+ //
2715
+ // daisy's channel radius is the selector radius (the
2716
+ // native control's runnable-track), not a pill.
2341
2717
  base: {
2342
2718
  marginTop: 'calc((var(--slider-thumb-size) - var(--slider-track-size)) / 2)',
2343
2719
  marginBottom: 'calc((var(--slider-thumb-size) - var(--slider-track-size)) / 2)',
2720
+ borderRadius: 'var(--radius-selector)',
2721
+ },
2722
+ selectors: {
2723
+ '&[data-orientation="vertical"]': {
2724
+ width: 'var(--slider-track-size)',
2725
+ height: '100%',
2726
+ marginTop: '0',
2727
+ marginBottom: '0',
2728
+ },
2344
2729
  },
2345
2730
  },
2346
2731
  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))' },
2732
+ // The web draws the tick with `::before`, which lynx has
2733
+ // no projection for — the mark was an empty, invisible
2734
+ // box. Here the mark IS the tick: a 2px rule across the
2735
+ // channel, centred on its runtime-set `left` percent.
2736
+ // (Lynx marks carry no label text.)
2737
+ base: {
2738
+ top: '0',
2739
+ width: '2px',
2740
+ height: 'var(--slider-track-size)',
2741
+ marginLeft: '-1px',
2742
+ paddingTop: '0',
2743
+ backgroundColor: 'var(--color-base-content)',
2744
+ },
2745
+ selectors: {
2746
+ '&[data-orientation="vertical"]': {
2747
+ left: '0',
2748
+ width: 'var(--slider-track-size)',
2749
+ height: '2px',
2750
+ marginLeft: '0',
2751
+ marginTop: '-1px',
2752
+ },
2753
+ },
2350
2754
  },
2351
2755
  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)' },
2756
+ base: { background: 'var(--slider-fill)', borderRadius: 'var(--radius-selector)' },
2357
2757
  },
2358
2758
  thumb: {
2359
2759
  // daisy's real range thumb, ported from daisyUI 5
@@ -2364,13 +2764,16 @@ export const slider: RecipeInput = {
2364
2764
  // `--range-thumb` defaults to `var(--color-base-100)`,
2365
2765
  // `--range-p` is daisy's literal `.25rem` (deliberately
2366
2766
  // 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.
2767
+ // deepened fill the web's native thumb ring reads. daisy
2768
+ // paints the progress fill itself as a 100cqw box-shadow
2769
+ // thrown off this thumb — a native-input trick with no
2770
+ // lynx equivalent; the composed `.zx-slider__range` box
2771
+ // above IS that fill here, so the knob only needs its own
2772
+ // paints. Its radius is daisy's formula minus the min()
2773
+ // clamp lynx cannot evaluate — exact for every shipped
2774
+ // theme (all set `--radius-selector` ≥ .25rem / 3) — and
2775
+ // it wears daisy's depth shading (`currentColor`'s 10%
2776
+ // outer shadow spelled as the theme's shade).
2374
2777
  //
2375
2778
  // The centering geometry is restated physically beside
2376
2779
  // the paint: `inset-block-start`/`translate`/
@@ -2381,11 +2784,20 @@ export const slider: RecipeInput = {
2381
2784
  // proven on both platforms.
2382
2785
  base: {
2383
2786
  background: 'var(--color-base-100)',
2384
- border: '0.25rem solid var(--slider-accent)',
2787
+ border: '0.25rem solid var(--slider-fill)',
2788
+ borderRadius: 'calc(var(--radius-selector) + 0.25rem)',
2789
+ boxShadow: '0 -1px var(--depth-shade) inset, 0 8px 0 -4px var(--depth-sheen) inset, 0 1px var(--depth-shade)',
2385
2790
  top: '50%',
2386
2791
  transform: 'translateY(-50%)',
2387
2792
  marginLeft: 'calc(var(--slider-thumb-size) / -2)',
2388
2793
  },
2794
+ // The round ring (signalxjs/lynx#1163), over the knob's
2795
+ // depth relief.
2796
+ states: {
2797
+ 'focus-visible': lynxFocusRing('var(--slider-accent)', {
2798
+ under: '0 -1px var(--depth-shade) inset, 0 8px 0 -4px var(--depth-sheen) inset, 0 1px var(--depth-shade)',
2799
+ }),
2800
+ },
2389
2801
  },
2390
2802
  },
2391
2803
  },
@@ -2394,7 +2806,31 @@ export const slider: RecipeInput = {
2394
2806
 
2395
2807
  export const accordion: RecipeInput = {
2396
2808
  component: 'accordion',
2397
- targets: disclosureExit('accordion'),
2809
+ targets: {
2810
+ ...disclosureExit('accordion'),
2811
+ lynx: {
2812
+ parts: {
2813
+ // No hover on a touch platform: the held trigger takes daisy's
2814
+ // hover fill. The ::after chevron has no lynx counterpart (no
2815
+ // pseudo-elements, and the anatomy declares no part for it) —
2816
+ // an app draws its own glyph in the trigger.
2817
+ //
2818
+ // The item card clips (`overflow: hidden`) and the trigger
2819
+ // fills it, so an outside ring is clipped away entirely
2820
+ // (signalxjs/lynx#1164): the ring is drawn inset, rounded to
2821
+ // the card's inner corners.
2822
+ trigger: {
2823
+ states: {
2824
+ pressed: { background: 'var(--color-base-200)' },
2825
+ 'focus-visible': {
2826
+ ...lynxFocusRing('var(--color-base-content)', { inset: true }),
2827
+ borderRadius: 'calc(var(--radius-box) - var(--border))',
2828
+ },
2829
+ },
2830
+ },
2831
+ },
2832
+ },
2833
+ },
2398
2834
  // Same accent grammar as collapsible: the open heading's ink, defaulting
2399
2835
  // to base-content so the un-attributed render is unchanged.
2400
2836
  tokens: { '--accordion-accent': 'var(--color-base-content)' },
@@ -2678,10 +3114,47 @@ export const select: RecipeInput = {
2678
3114
  web: { parts: { popup: { base: anchoredListbox } } },
2679
3115
  lynx: {
2680
3116
  parts: {
2681
- trigger: { base: lynxBtnPad(4) },
2682
- 'clear-trigger': { base: { top: 'var(--space-xs)', bottom: 'var(--space-xs)', right: 'calc(var(--size-field) * 4 + 1em + var(--space-xs))', minWidth: '1.5rem' } },
3117
+ // daisy's `.select` is `width: clamp(3rem, 20rem, 100%)`: 20rem,
3118
+ // never wider than its container. lynx has no `clamp()`, so the
3119
+ // same bounds are spelled out — and the base's 13rem floor
3120
+ // comes down to daisy's 3rem, or the trigger overflowed any
3121
+ // cell narrower than 208px (signalxjs/lynx#1146).
3122
+ //
3123
+ // The bounds sit on the ROOT, the box daisy's `.select` is,
3124
+ // and the trigger fills it. The web root is `inline-flex`, so
3125
+ // it shrink-wraps the trigger; lynx rewrites that to `flex`,
3126
+ // and a root stretched by its parent while the trigger stayed
3127
+ // capped left the clear-trigger — positioned against the root
3128
+ // — floating past the trigger's end (signalxjs/lynx#1166).
3129
+ root: { base: { width: '20rem', maxWidth: '100%', minWidth: '3rem' } },
3130
+ trigger: {
3131
+ base: { ...lynxBtnPad(4), width: '100%', maxWidth: '100%', minWidth: '3rem' },
3132
+ // The web's `:hover` border is dropped on a touch
3133
+ // platform; a press gets a wash instead — a background,
3134
+ // so it never fights the open/invalid border inks.
3135
+ states: { pressed: { background: 'var(--color-base-200)' }, ...lynxFocus() },
3136
+ },
3137
+ // The popup renders in the overlay outlet, outside the root,
3138
+ // so it cannot inherit the root's `--select-accent`, and an
3139
+ // unresolved `var()` on lynx has no fallback: the selected
3140
+ // item and its tick fell back to base-content
3141
+ // (signalxjs/lynx#1168). The popup restates the accent; its
3142
+ // colour-axis twins are in `variants.color` below.
3143
+ popup: { base: { '--select-accent': 'var(--color-primary)' } },
3144
+ // A touch list has no highlight: the pressed item takes the
3145
+ // highlighted item's wash.
3146
+ item: { states: { pressed: { background: 'var(--color-base-200)' } } },
3147
+ 'clear-trigger': {
3148
+ base: { top: 'var(--space-xs)', bottom: 'var(--space-xs)', right: 'calc(var(--size-field) * 4 + 1em + var(--space-xs))', minWidth: '1.5rem' },
3149
+ states: lynxFocus(),
3150
+ },
2683
3151
  },
2684
3152
  variants: {
3153
+ // The popup's accent follows the root's colour axis — the
3154
+ // popup carries the axis class itself (lynx stamps every part).
3155
+ color: Object.fromEntries(ROLES.map((c) => [c, { popup: { base: {
3156
+ '--select-accent': `var(--color-${c})`,
3157
+ } } }])),
2685
3158
  size: { ...lynxBtnSizes('trigger'), md: { trigger: { base: lynxBtnPad(4) } } },
2686
3159
  },
2687
3160
  },
@@ -2706,6 +3179,40 @@ const BTN_STEPS = { xs: 6, sm: 8, md: 10, lg: 12, xl: 14 } as const;
2706
3179
  const btnHeight = (step: keyof typeof BTN_STEPS): string =>
2707
3180
  `calc(var(--size-field) * ${BTN_STEPS[step]})`;
2708
3181
 
3182
+ /**
3183
+ * The btn padding ramp — `[size, block space, inline space]` per step. One
3184
+ * table for the web's `padding` shorthand and the lynx section's longhands,
3185
+ * so the two spellings cannot drift apart.
3186
+ */
3187
+ const BTN_PAD = [
3188
+ ['xs', '2xs', 'xs'],
3189
+ ['sm', 'xs', 'sm'],
3190
+ ['md', 'sm', 'lg'],
3191
+ ['lg', 'md', 'xl'],
3192
+ ['xl', 'lg', '2xl'],
3193
+ ] as const satisfies readonly (readonly [keyof typeof BTN_STEPS, string, string])[];
3194
+ const btnPadding = (step: keyof typeof BTN_STEPS): string => {
3195
+ const [, block, inline] = BTN_PAD.find(([size]) => size === step)!;
3196
+ return `var(--space-${block}) var(--space-${inline})`;
3197
+ };
3198
+
3199
+ /**
3200
+ * daisy 5's disabled btn, for the lynx target (signalxjs/lynx#1143). The web
3201
+ * fades the whole button (`opacity: var(--disabled-opacity)`, 0.3 on the
3202
+ * dark themes), which on device left a solid button's label barely legible.
3203
+ * daisy's own `.btn:disabled` paints instead: a base-content wash (not on
3204
+ * ghost/link), a transparent border, no shadow, and a muted base-content
3205
+ * label. daisy mutes that label to 20%; this keeps it at 50%, still plainly
3206
+ * inert but readable on a phone at arm's length.
3207
+ */
3208
+ const lynxBtnDisabled: CssProps = {
3209
+ opacity: '1',
3210
+ background: 'color-mix(in oklab, var(--color-base-content) 10%, transparent)',
3211
+ color: 'color-mix(in oklab, var(--color-base-content) 50%, transparent)',
3212
+ borderColor: 'transparent',
3213
+ boxShadow: 'none',
3214
+ };
3215
+
2709
3216
  export const button: RecipeInput = {
2710
3217
  component: 'button',
2711
3218
  // Public to a design system derived from this one (#73).
@@ -2886,11 +3393,11 @@ export const button: RecipeInput = {
2886
3393
  // The `<button>` element is UA `border-box`, so the stated height is
2887
3394
  // the rendered height, border included — as in daisy.
2888
3395
  size: {
2889
- xs: { root: { base: { height: btnHeight('xs'), padding: 'var(--space-2xs) var(--space-xs)', fontSize: 'var(--text-xs)' } } },
2890
- sm: { root: { base: { height: btnHeight('sm'), padding: 'var(--space-xs) var(--space-sm)', fontSize: 'var(--text-sm)' } } },
2891
- md: { root: { base: { height: btnHeight('md'), padding: 'var(--space-sm) var(--space-lg)', fontSize: 'var(--text-md)' } } },
2892
- lg: { root: { base: { height: btnHeight('lg'), padding: 'var(--space-md) var(--space-xl)', fontSize: 'var(--text-lg)' } } },
2893
- xl: { root: { base: { height: btnHeight('xl'), padding: 'var(--space-lg) var(--space-2xl)', fontSize: 'var(--text-xl)' } } },
3396
+ xs: { root: { base: { height: btnHeight('xs'), padding: btnPadding('xs'), fontSize: 'var(--text-xs)' } } },
3397
+ sm: { root: { base: { height: btnHeight('sm'), padding: btnPadding('sm'), fontSize: 'var(--text-sm)' } } },
3398
+ md: { root: { base: { height: btnHeight('md'), padding: btnPadding('md'), fontSize: 'var(--text-md)' } } },
3399
+ lg: { root: { base: { height: btnHeight('lg'), padding: btnPadding('lg'), fontSize: 'var(--text-lg)' } } },
3400
+ xl: { root: { base: { height: btnHeight('xl'), padding: btnPadding('xl'), fontSize: 'var(--text-xl)' } } },
2894
3401
  },
2895
3402
  },
2896
3403
  /**
@@ -2921,14 +3428,96 @@ export const button: RecipeInput = {
2921
3428
  },
2922
3429
  defaultVariants: { color: 'primary', variant: 'solid', size: 'md' },
2923
3430
  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
3431
  targets: {
2928
3432
  lynx: {
3433
+ parts: {
3434
+ // The ring, restated: `currentColor` never resolves on lynx
3435
+ // (signalxjs/lynx#1079) and the logical sizes/edge are
3436
+ // unproven there, so the lynx ring is physical longhands in
3437
+ // the variant's own ink (the variants below pick it). Every
3438
+ // var-bearing edge is a longhand (`lynxSides`): a var-bearing
3439
+ // `border` expanded after the transparent top and closed the
3440
+ // ring (signalxjs/lynx#1161). The shorthand stays, static, so
3441
+ // the shared `currentColor` spelling never ships.
3442
+ spinner: {
3443
+ base: {
3444
+ width: '1em',
3445
+ height: '1em',
3446
+ border: '0 solid transparent',
3447
+ ...lynxSides('border{}Width', 'calc(var(--border) * 2)'),
3448
+ ...lynxSides('border{}Color', 'var(--btn-ink)', ['Right', 'Bottom', 'Left']),
3449
+ borderTopColor: 'transparent',
3450
+ },
3451
+ },
3452
+ root: {
3453
+ // daisy's content-sized, never-squeezed btn (#1165), and
3454
+ // the hairline as longhands: a var-bearing `border`
3455
+ // shorthand would expand over the variants' and the
3456
+ // disabled state's edge colours (see `lynxSides`).
3457
+ base: {
3458
+ ...lynxBtnFit,
3459
+ border: '0 solid transparent',
3460
+ ...lynxSides('border{}Width', 'var(--border)'),
3461
+ },
3462
+ // The ring in the variant's own ink (signalxjs/lynx#1163).
3463
+ states: { disabled: lynxBtnDisabled, 'focus-visible': lynxFocusRing('var(--btn-ink)') },
3464
+ },
3465
+ },
3466
+ variants: {
3467
+ // The baked disabled paint above outranks every variant's fill
3468
+ // (the theme host adds a class), but its plain declarations
3469
+ // do not: outline/dash restate the border, ghost/link drop
3470
+ // the wash (as in daisy). outline/dash draw their ink edge as
3471
+ // longhands, so that transparent restatement wins by
3472
+ // specificity instead of losing to a var-bearing
3473
+ // `border-color` (signalxjs/lynx#1162); the shared shorthand
3474
+ // is neutralised to a static value.
3475
+ variant: {
3476
+ solid: { spinner: { base: lynxSides('border{}Color', 'var(--btn-on-accent)', ['Right', 'Bottom', 'Left']) } },
3477
+ outline: {
3478
+ root: {
3479
+ base: { borderColor: 'transparent', ...lynxSides('border{}Color', 'var(--btn-ink)') },
3480
+ states: { disabled: { borderColor: 'transparent' } },
3481
+ },
3482
+ },
3483
+ dash: {
3484
+ root: {
3485
+ base: { borderColor: 'transparent', ...lynxSides('border{}Color', 'var(--btn-ink)') },
3486
+ states: { disabled: { borderColor: 'transparent' } },
3487
+ },
3488
+ },
3489
+ ghost: { root: { states: { disabled: { background: 'transparent' } } } },
3490
+ // `link` zeroes the box-shadow in a variant rule that
3491
+ // lands after the ring, so it restates the ring.
3492
+ link: { root: { states: { disabled: { background: 'transparent' }, 'focus-visible': lynxFocusRing('var(--btn-ink)') } } },
3493
+ },
3494
+ // The size ramp's paddings as longhands, so square/circle's
3495
+ // zeroed sides below win by the cascade rather than by the
3496
+ // order lynx happens to expand a var-bearing `padding` in.
3497
+ size: Object.fromEntries(BTN_PAD.map(([size, block, inline]) => [size, {
3498
+ root: {
3499
+ base: {
3500
+ padding: '0',
3501
+ ...lynxSides('padding{}', `var(--space-${block})`, ['Top', 'Bottom']),
3502
+ ...lynxSides('padding{}', `var(--space-${inline})`, ['Right', 'Left']),
3503
+ },
3504
+ },
3505
+ }])),
3506
+ },
3507
+ // The icon chips' zeroed inline padding, restated physically: logical
3508
+ // spellings resolve on iOS but not on Android (measured,
3509
+ // signalxjs/lynx#1084), so the emitter refuses them. `width: auto`
3510
+ // undoes the root's `max-content`, which would otherwise switch
3511
+ // off the `aspect-ratio` that squares the chip. `wide`/`block`
3512
+ // restate their width physically: `inline-size` and
3513
+ // `max-inline-size` are not lynx properties at all.
2929
3514
  modifiers: {
2930
- square: { root: { base: { paddingLeft: '0', paddingRight: '0' } } },
2931
- circle: { root: { base: { paddingLeft: '0', paddingRight: '0' } } },
3515
+ wide: { root: { base: { width: '100%', maxWidth: '16rem' } } },
3516
+ block: { root: { base: { width: '100%' } } },
3517
+ square: { root: { base: { width: 'auto', paddingLeft: '0', paddingRight: '0' } } },
3518
+ circle: { root: { base: { width: 'auto', paddingLeft: '0', paddingRight: '0' } } },
3519
+ // `active` zeroes the box-shadow too (the held press look).
3520
+ active: { root: { states: { 'focus-visible': lynxFocusRing('var(--btn-ink)') } } },
2932
3521
  },
2933
3522
  },
2934
3523
  },
@@ -3010,6 +3599,24 @@ export const avatar: RecipeInput = {
3010
3599
  },
3011
3600
  };
3012
3601
 
3602
+ /**
3603
+ * A toast whose indicator is rendered (#292): the grid grows a leading column
3604
+ * for the mark, and the text, action and close step one column along.
3605
+ */
3606
+ const TOAST_MARKED = '[data-scope="toast"][data-part="root"]:has(> [data-scope="toast"][data-part="indicator"]) > &';
3607
+ /** The indicator's marks, cut from its fill — not mirrored in RTL (a tick is not a direction). */
3608
+ const TOAST_CHECK = 'polygon(15.1% 41.3%, 1% 55%, 37.6% 92.8%, 99% 19.8%, 83.9% 7.2%, 36.6% 63.5%)';
3609
+ const TOAST_CROSS = 'polygon(20% 8%, 50% 38%, 80% 8%, 92% 20%, 62% 50%, 92% 80%, 80% 92%, 50% 62%, 20% 92%, 8% 80%, 38% 50%, 8% 20%)';
3610
+
3611
+ /**
3612
+ * daisy's success and error are FILLS — light enough in `light` and `nord`
3613
+ * that a bare mark in them reads under 2:1 on the toast's base-200. Deepened
3614
+ * toward base-content (which flips with the theme), the hue survives and the
3615
+ * mark clears the 3:1 non-text floor in every theme.
3616
+ */
3617
+ const TOAST_SUCCESS_INK = 'color-mix(in oklab, var(--color-success) 55%, var(--color-base-content))';
3618
+ const TOAST_ERROR_INK = 'color-mix(in oklab, var(--color-error) 55%, var(--color-base-content))';
3619
+
3013
3620
  /**
3014
3621
  * Toast presence is runtime-managed (see the SKILL's Toast section): plain
3015
3622
  * two-state transitions only — no `@starting-style`, no `allow-discrete`.
@@ -3020,6 +3627,7 @@ export const toast: RecipeInput = {
3020
3627
  '--toast-bg': 'var(--color-base-200)',
3021
3628
  '--toast-ink': 'var(--color-base-content)',
3022
3629
  '--toast-from': '8px',
3630
+ '--toast-mark': '1rem',
3023
3631
  },
3024
3632
  parts: {
3025
3633
  viewport: {
@@ -3073,6 +3681,7 @@ export const toast: RecipeInput = {
3073
3681
  },
3074
3682
  selectors: {
3075
3683
  '&[data-placement^="top"]': { '--toast-from': '-8px' },
3684
+ '&:has(> [data-scope="toast"][data-part="indicator"])': { gridTemplateColumns: 'auto 1fr auto auto' },
3076
3685
  },
3077
3686
  states: {
3078
3687
  open: { opacity: '1', transform: 'none' },
@@ -3082,8 +3691,39 @@ export const toast: RecipeInput = {
3082
3691
  'reduced-motion': { base: { transition: 'none' }, states: { open: { transform: 'none' } } },
3083
3692
  },
3084
3693
  },
3694
+ // daisy's `loading-spinner` ring in the alert's own ink, then the
3695
+ // success tick or the error cross — the promise toast's status.
3696
+ indicator: {
3697
+ base: {
3698
+ gridColumn: '1',
3699
+ gridRow: '1 / span 2',
3700
+ inlineSize: 'var(--toast-mark)',
3701
+ blockSize: 'var(--toast-mark)',
3702
+ boxSizing: 'border-box',
3703
+ },
3704
+ states: {
3705
+ loading: {
3706
+ borderRadius: '50%',
3707
+ border: 'calc(var(--border) * 2) solid color-mix(in oklab, var(--toast-ink) 25%, transparent)',
3708
+ borderBlockStartColor: 'var(--toast-ink)',
3709
+ animation: 'zero-daisyui-toast-spin 0.75s linear infinite',
3710
+ },
3711
+ complete: { background: TOAST_SUCCESS_INK, clipPath: TOAST_CHECK },
3712
+ error: { background: TOAST_ERROR_INK, clipPath: TOAST_CROSS },
3713
+ },
3714
+ at: {
3715
+ 'reduced-motion': { states: { loading: { animation: 'none' } } },
3716
+ 'forced-colors': {
3717
+ states: {
3718
+ complete: { background: 'CanvasText', forcedColorAdjust: 'none' },
3719
+ error: { background: 'CanvasText', forcedColorAdjust: 'none' },
3720
+ },
3721
+ },
3722
+ },
3723
+ },
3085
3724
  title: {
3086
3725
  base: { gridColumn: '1', fontWeight: 'var(--weight-semibold)' },
3726
+ selectors: { [TOAST_MARKED]: { gridColumn: '2' } },
3087
3727
  },
3088
3728
  description: {
3089
3729
  base: {
@@ -3091,6 +3731,7 @@ export const toast: RecipeInput = {
3091
3731
  fontSize: 'var(--text-xs)',
3092
3732
  color: 'color-mix(in oklab, var(--toast-ink) 75%, transparent)',
3093
3733
  },
3734
+ selectors: { [TOAST_MARKED]: { gridColumn: '2' } },
3094
3735
  },
3095
3736
  action: {
3096
3737
  base: {
@@ -3111,6 +3752,7 @@ export const toast: RecipeInput = {
3111
3752
  disabled: { opacity: 'var(--disabled-opacity)', cursor: 'not-allowed' },
3112
3753
  ...focusRing,
3113
3754
  },
3755
+ selectors: { [TOAST_MARKED]: { gridColumn: '3' } },
3114
3756
  },
3115
3757
  close: {
3116
3758
  base: {
@@ -3130,6 +3772,7 @@ export const toast: RecipeInput = {
3130
3772
  disabled: { opacity: 'var(--disabled-opacity)' },
3131
3773
  ...focusRing,
3132
3774
  },
3775
+ selectors: { [TOAST_MARKED]: { gridColumn: '4' } },
3133
3776
  },
3134
3777
  },
3135
3778
  variants: {
@@ -3166,24 +3809,79 @@ export const toast: RecipeInput = {
3166
3809
  // signalxjs/lynx#1070. No `color` default: the un-attributed toast is a
3167
3810
  // neutral base-200 surface, outside the color vocabulary.
3168
3811
  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.
3812
+ keyframes: { 'zero-daisyui-toast-spin': 'to { transform: rotate(360deg); }' },
3173
3813
  targets: {
3174
3814
  lynx: {
3175
3815
  parts: {
3816
+ // The viewport is a full-width strip on lynx (the runtime pins
3817
+ // it to the outlet's top or bottom edge). What the web gets
3818
+ // from `:popover-open` and `min(24rem, 100vw)` is restated:
3819
+ // flex (a lynx view defaults to linear layout, which ignores
3820
+ // flex-direction and gap), and the cards aligned to the
3821
+ // placement's edge. The centred pair's `left: 50%` +
3822
+ // `translateX(-50%)` pull-back is for a shrink-wrapped popover;
3823
+ // on the strip it shifted everything half a width left, so it
3824
+ // is cancelled. The corner anchors are physical: logical
3825
+ // insets resolve on iOS but not on Android (measured,
3826
+ // signalxjs/lynx#1084), and lynx has no RTL flow, so `start`
3827
+ // IS left and `end` IS right.
3176
3828
  viewport: {
3829
+ base: { display: 'flex', width: 'auto' },
3830
+ selectors: {
3831
+ '&[data-placement="top-start"]': { left: '0', alignItems: 'flex-start' },
3832
+ '&[data-placement="top"]': { left: '0', right: '0', transform: 'none', alignItems: 'center' },
3833
+ '&[data-placement="top-end"]': { right: '0', alignItems: 'flex-end' },
3834
+ '&[data-placement="bottom-start"]': { left: '0', alignItems: 'flex-start' },
3835
+ '&[data-placement="bottom"]': { left: '0', right: '0', transform: 'none', alignItems: 'center' },
3836
+ '&[data-placement="bottom-end"]': { right: '0', alignItems: 'flex-end' },
3837
+ },
3838
+ },
3839
+ // No grid on lynx (signalxjs/lynx#1075): the card is a flex
3840
+ // column (title, description, then the action), with the
3841
+ // close button pinned to the top-right corner and the title
3842
+ // kept clear of it. The web's 24rem viewport cap becomes the
3843
+ // card's own cap. The `^="top"` entry offset has no class
3844
+ // form, so each top placement restates it.
3845
+ root: {
3846
+ base: {
3847
+ display: 'flex',
3848
+ flexDirection: 'column',
3849
+ alignItems: 'flex-start',
3850
+ position: 'relative',
3851
+ boxSizing: 'border-box',
3852
+ width: '100%',
3853
+ maxWidth: '24rem',
3854
+ },
3177
3855
  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' },
3856
+ '&[data-placement="top-start"]': { '--toast-from': '-8px' },
3857
+ '&[data-placement="top"]': { '--toast-from': '-8px' },
3858
+ '&[data-placement="top-end"]': { '--toast-from': '-8px' },
3859
+ },
3860
+ },
3861
+ title: { base: { paddingRight: '1.75em' } },
3862
+ // The 75% ink mix over a recipe-local property cannot bake;
3863
+ // the same ink at 75% opacity reads the same on the soft fill.
3864
+ description: { base: { color: 'var(--toast-ink)', opacity: '0.75' } },
3865
+ // The 25% ink border cannot bake either: it takes the full ink.
3866
+ // Their rings' gap is the card's own fill.
3867
+ action: {
3868
+ base: {
3869
+ marginTop: 'var(--space-xs)',
3870
+ border: 'var(--border) solid var(--toast-ink)',
3182
3871
  },
3872
+ states: lynxFocus({ gap: 'var(--toast-bg)' }),
3873
+ },
3874
+ close: {
3875
+ base: { position: 'absolute', top: 'var(--space-xs)', right: 'var(--space-xs)' },
3876
+ states: lynxFocus({ gap: 'var(--toast-bg)' }),
3183
3877
  },
3184
3878
  },
3185
3879
  },
3186
3880
  },
3881
+ // The viewport's `open` (the stack expanded) is a fact this skin has no
3882
+ // use for: its toasts are always a plain column, so both states look the
3883
+ // same by design (#292).
3884
+ skipStates: { viewport: ['open', 'closed'] },
3187
3885
  };
3188
3886
 
3189
3887
  export const combobox: RecipeInput = {
@@ -3999,12 +4697,29 @@ const treeRowSelectors: NonNullable<PartStyles['selectors']> = {
3999
4697
  },
4000
4698
  };
4001
4699
 
4700
+ /**
4701
+ * `tickGlyphFallback` for the node check box, whose tick is its `::after`:
4702
+ * the geometry drops and the same pseudo carries the glyph, in the medium's
4703
+ * own ink (the #233 divergence, restated).
4704
+ */
4705
+ const nodeTickFallback = (ink: string): PartStyles => ({
4706
+ selectors: {
4707
+ '&::after': { clipPath: 'none', backgroundColor: '#0000', rotate: '0deg', inset: '0', display: 'grid', placeItems: 'center', color: ink, fontSize: '0.85em', lineHeight: '1' },
4708
+ '&[data-state="checked"]::after': { content: '"✔︎"', clipPath: 'none', rotate: '0deg' },
4709
+ '&[data-state="indeterminate"]::after': { content: '"−"', clipPath: 'none', rotate: '0deg', translate: 'none' },
4710
+ },
4711
+ });
4712
+
4002
4713
  export const treeView: RecipeInput = {
4003
4714
  component: 'tree-view',
4004
4715
  tokens: {
4005
4716
  '--tree-accent': 'var(--color-primary)',
4006
4717
  '--tree-text': 'var(--text-sm)',
4007
4718
  '--tree-on-accent': 'var(--color-primary-content)',
4719
+ // The node check box's pair — the tree's own accent, so a colour
4720
+ // variant retints the box with the row.
4721
+ '--tree-check': 'var(--tree-accent)',
4722
+ '--tree-on-check': 'var(--tree-on-accent)',
4008
4723
  },
4009
4724
  parts: {
4010
4725
  root: {
@@ -4053,7 +4768,59 @@ export const treeView: RecipeInput = {
4053
4768
  'reduced-motion': { base: { transition: 'none' } },
4054
4769
  },
4055
4770
  },
4056
- // Indentation comes from this inline padding — depth is the DOM
4771
+ // daisy's `.checkbox` at row scale: the accent outline, the recessed
4772
+ // depth shade, the fill on check — and daisy's own tick, the six
4773
+ // clip-path points rotated 45°, drawn on the box's `::after` since
4774
+ // the part is one span. On a selected row (the accent fill) the pair
4775
+ // swaps, so the box never vanishes into the row it sits on.
4776
+ 'node-checkbox': {
4777
+ base: {
4778
+ display: 'inline-block',
4779
+ position: 'relative',
4780
+ flexShrink: '0',
4781
+ boxSizing: 'border-box',
4782
+ width: '1.25em',
4783
+ height: '1.25em',
4784
+ border: 'var(--border) solid var(--tree-check)',
4785
+ borderRadius: 'var(--radius-selector)',
4786
+ boxShadow: '0 1px var(--depth-shade) inset',
4787
+ cursor: 'pointer',
4788
+ transition: 'background-color var(--duration-normal) var(--ease-standard)',
4789
+ },
4790
+ states: {
4791
+ checked: { backgroundColor: 'var(--tree-check)' },
4792
+ indeterminate: { backgroundColor: 'var(--tree-check)' },
4793
+ unchecked: {},
4794
+ disabled: { cursor: 'not-allowed' },
4795
+ },
4796
+ selectors: {
4797
+ '&::after': {
4798
+ content: '""',
4799
+ position: 'absolute',
4800
+ inset: '15%',
4801
+ opacity: '0',
4802
+ rotate: '45deg',
4803
+ backgroundColor: 'var(--tree-on-check)',
4804
+ clipPath: TICK_COLLAPSED,
4805
+ transition: 'clip-path var(--duration-slow) var(--ease-standard) var(--duration-instant), '
4806
+ + 'opacity var(--duration-instant) var(--ease-standard) var(--duration-instant), '
4807
+ + 'rotate var(--duration-slow) var(--ease-standard) var(--duration-instant), '
4808
+ + 'translate var(--duration-slow) var(--ease-standard) var(--duration-instant)',
4809
+ },
4810
+ '&[data-state="checked"]::after': { clipPath: TICK_DRAWN, opacity: '1' },
4811
+ '&[data-state="indeterminate"]::after': { clipPath: DASH_DRAWN, opacity: '1', translate: '0 -35%', rotate: '0deg' },
4812
+ '[data-scope="tree-view"][data-selected] &': {
4813
+ '--tree-check': 'var(--tree-on-accent)',
4814
+ '--tree-on-check': 'var(--tree-accent)',
4815
+ },
4816
+ },
4817
+ at: {
4818
+ 'reduced-motion': { base: { transition: 'none' }, selectors: { '&::after': { transition: 'none' } } },
4819
+ 'forced-colors': nodeTickFallback('CanvasText'),
4820
+ print: nodeTickFallback('var(--print-ink)'),
4821
+ },
4822
+ },
4823
+ // Indentation comes from this inline padding — depth is the DOM
4057
4824
  // nesting, no per-level rules needed.
4058
4825
  'branch-content': {
4059
4826
  base: { display: 'flex', flexDirection: 'column', paddingInlineStart: 'var(--space-lg)' },
@@ -4337,6 +5104,36 @@ export const card: RecipeInput = {
4337
5104
  overflow: 'hidden',
4338
5105
  },
4339
5106
  },
5107
+ /**
5108
+ * Media (#302) — daisy's `card > figure`: a centred flex box, flush
5109
+ * to the card's edges, the image filling it.
5110
+ */
5111
+ media: {
5112
+ base: {
5113
+ display: 'flex',
5114
+ alignItems: 'center',
5115
+ justifyContent: 'center',
5116
+ margin: '0',
5117
+ inlineSize: '100%',
5118
+ objectFit: 'cover',
5119
+ },
5120
+ selectors: {
5121
+ // The media's own image, when the band wraps one (a div or a
5122
+ // figure): block-level, the band's full width, cropped rather
5123
+ // than stretched when the band is given a height.
5124
+ '& > :is(img, picture, video, svg, canvas)': {
5125
+ display: 'block',
5126
+ inlineSize: '100%',
5127
+ blockSize: '100%',
5128
+ objectFit: 'cover',
5129
+ },
5130
+ // The corners it shares with the card (#302): the root clips
5131
+ // too, but an asChild <img> is the band itself and rounds on
5132
+ // its own.
5133
+ '&:first-child': { borderStartStartRadius: 'var(--radius-box)', borderStartEndRadius: 'var(--radius-box)' },
5134
+ '&:last-child': { borderEndStartRadius: 'var(--radius-box)', borderEndEndRadius: 'var(--radius-box)' },
5135
+ },
5136
+ },
4340
5137
  header: {
4341
5138
  base: {
4342
5139
  display: 'flex',
@@ -4670,16 +5467,51 @@ export const divider: RecipeInput = {
4670
5467
  tokens: { '--divider-ink': 'var(--color-base-300)', '--divider-thickness': 'var(--border)' },
4671
5468
  parts: {
4672
5469
  root: {
4673
- base: { border: 'none', background: 'var(--divider-ink)', alignSelf: 'stretch' },
5470
+ // The rule is two flex segments (#298), drawn as borders: with no
5471
+ // label they meet and read as one line, and a label sits between
5472
+ // them. Borders rather than a background, so forced colours keep
5473
+ // the line instead of revaluing it to Canvas.
5474
+ base: {
5475
+ display: 'flex',
5476
+ alignItems: 'center',
5477
+ border: 'none',
5478
+ alignSelf: 'stretch',
5479
+ },
4674
5480
  selectors: {
4675
- '&[data-orientation="horizontal"]': {
4676
- inlineSize: '100%',
4677
- blockSize: 'var(--divider-thickness)',
4678
- },
4679
- '&[data-orientation="vertical"]': {
4680
- inlineSize: 'var(--divider-thickness)',
4681
- minBlockSize: '1em',
5481
+ '&::before': { content: '""', flex: '1 1 0', minInlineSize: '0' },
5482
+ '&::after': { content: '""', flex: '1 1 0', minInlineSize: '0' },
5483
+ '&[data-orientation="horizontal"]': { inlineSize: '100%' },
5484
+ '&[data-orientation="horizontal"]::before': { borderBlockStart: 'var(--divider-thickness) solid var(--divider-ink)' },
5485
+ '&[data-orientation="horizontal"]::after': { borderBlockStart: 'var(--divider-thickness) solid var(--divider-ink)' },
5486
+ '&[data-orientation="vertical"]': { flexDirection: 'column', minBlockSize: '1em' },
5487
+ '&[data-orientation="vertical"]::before': { borderInlineStart: 'var(--divider-thickness) solid var(--divider-ink)' },
5488
+ '&[data-orientation="vertical"]::after': { borderInlineStart: 'var(--divider-thickness) solid var(--divider-ink)' },
5489
+ // A placed label sits flush at its edge: the segment on
5490
+ // that side goes, and the other one takes the whole run.
5491
+ '&:has(> [data-scope="divider"][data-part="label"][data-placement="start"])::before': { display: 'none' },
5492
+ '&:has(> [data-scope="divider"][data-part="label"][data-placement="end"])::after': { display: 'none' },
5493
+ },
5494
+ },
5495
+ label: {
5496
+ base: {
5497
+ // daisy's `divider` text: the content colour, the rule
5498
+ // stopping a gap short of it on either side.
5499
+ flex: 'none',
5500
+ paddingInline: 'var(--space-md)',
5501
+ fontSize: 'var(--text-sm)',
5502
+ whiteSpace: 'nowrap',
5503
+ color: 'var(--color-base-content)',
5504
+ },
5505
+ selectors: {
5506
+ '[data-scope="divider"][data-part="root"][data-orientation="vertical"] > &': {
5507
+ paddingInline: '0',
5508
+ paddingBlock: 'var(--space-sm)',
4682
5509
  },
5510
+ // Flush means flush: no inset on the side the rule gave up.
5511
+ '&[data-placement="start"]': { paddingInlineStart: '0' },
5512
+ '&[data-placement="end"]': { paddingInlineEnd: '0' },
5513
+ '[data-scope="divider"][data-part="root"][data-orientation="vertical"] > &[data-placement="start"]': { paddingBlockStart: '0' },
5514
+ '[data-scope="divider"][data-part="root"][data-orientation="vertical"] > &[data-placement="end"]': { paddingBlockEnd: '0' },
4683
5515
  },
4684
5516
  },
4685
5517
  },
@@ -5230,6 +6062,10 @@ export const timeline: RecipeInput = {
5230
6062
  },
5231
6063
  },
5232
6064
  },
6065
+ // Title and description (#302): daisy's timeline-box leads with a
6066
+ // bold line; the detail under it in the card description's ink.
6067
+ title: { base: { display: 'block', margin: '0', fontWeight: 'var(--weight-semibold)', lineHeight: 'var(--leading-tight)' } },
6068
+ description: { base: { display: 'block', margin: '0', marginBlockStart: 'var(--space-2xs)', color: 'color-mix(in oklab, var(--color-base-content) 70%, transparent)' } },
5233
6069
  },
5234
6070
  variants: {
5235
6071
  color: Object.fromEntries(ROLES.map((c) => [c, { marker: { base: {
@@ -5244,6 +6080,87 @@ export const timeline: RecipeInput = {
5244
6080
  xl: { marker: { base: { '--timeline-marker-size': 'calc(var(--size-selector) * 4)' } }, content: { base: { fontSize: 'var(--text-md)' } } },
5245
6081
  },
5246
6082
  },
6083
+ /**
6084
+ * Lynx has no grid, and it does not resolve `inline-size`/`block-size`:
6085
+ * the item fell back to the engine's default layout and the marker,
6086
+ * sized only logically, stretched into a pill (signalxjs/lynx#1145).
6087
+ *
6088
+ * The item is a wrapping flex row instead. Vertical: line one is the
6089
+ * marker beside its content (start-side content ordered before the
6090
+ * marker), and the connector takes line two by itself (`flex-basis:
6091
+ * 100%`), drawn as a left border under the marker's centre, which is the
6092
+ * web's row-2 segment. Horizontal: line one is the marker with the
6093
+ * connector running on to the item's end, and the content wraps under it
6094
+ * (start-side content above). Content grows from a zero basis, so long
6095
+ * text never wraps onto its own line.
6096
+ *
6097
+ * The web collapses the start track when no item has start-side content
6098
+ * (`:has()`); lynx has no such test. The vertical connector sits under a
6099
+ * marker at the item's start edge, which is where the marker is unless
6100
+ * that item has start-side content: an alternating vertical timeline is
6101
+ * web-only for now.
6102
+ */
6103
+ targets: {
6104
+ lynx: {
6105
+ parts: {
6106
+ item: { base: { display: 'flex', flexDirection: 'row', flexWrap: 'wrap', alignItems: 'center' } },
6107
+ marker: {
6108
+ base: {
6109
+ width: 'var(--timeline-marker-size)',
6110
+ height: 'var(--timeline-marker-size)',
6111
+ flexGrow: '0',
6112
+ flexShrink: '0',
6113
+ },
6114
+ },
6115
+ connector: {
6116
+ selectors: {
6117
+ '&[data-orientation="vertical"]': {
6118
+ flexGrow: '0',
6119
+ flexShrink: '1',
6120
+ flexBasis: '100%',
6121
+ height: 'var(--space-lg)',
6122
+ background: 'transparent',
6123
+ borderLeftWidth: 'var(--border)',
6124
+ borderLeftStyle: 'solid',
6125
+ borderLeftColor: 'var(--color-base-300)',
6126
+ marginLeft: 'calc(var(--timeline-marker-size) / 2 - var(--border) / 2)',
6127
+ },
6128
+ '&[data-orientation="horizontal"]': {
6129
+ order: '1',
6130
+ flexGrow: '1',
6131
+ flexShrink: '1',
6132
+ flexBasis: '0',
6133
+ height: 'var(--border)',
6134
+ minWidth: 'var(--space-lg)',
6135
+ },
6136
+ },
6137
+ },
6138
+ content: {
6139
+ selectors: {
6140
+ '&[data-orientation="vertical"][data-placement="start"]': {
6141
+ order: '-1',
6142
+ flexGrow: '1',
6143
+ flexShrink: '1',
6144
+ flexBasis: '0',
6145
+ },
6146
+ '&[data-orientation="vertical"][data-placement="end"]': {
6147
+ flexGrow: '1',
6148
+ flexShrink: '1',
6149
+ flexBasis: '0',
6150
+ },
6151
+ '&[data-orientation="horizontal"][data-placement="start"]': {
6152
+ order: '-1',
6153
+ flexBasis: '100%',
6154
+ },
6155
+ '&[data-orientation="horizontal"][data-placement="end"]': {
6156
+ order: '2',
6157
+ flexBasis: '100%',
6158
+ },
6159
+ },
6160
+ },
6161
+ },
6162
+ },
6163
+ },
5247
6164
  };
5248
6165
 
5249
6166
  /** daisy chat: the classic bubble, colour-refilled per role. */
@@ -5355,6 +6272,96 @@ export const chat: RecipeInput = {
5355
6272
  },
5356
6273
  };
5357
6274
 
6275
+ /**
6276
+ * ChatLog — daisy's `bg-base-200 rounded-box` well that the chat bubbles
6277
+ * already sit in, scrolling; the jump trigger is a `btn btn-sm` pill in the
6278
+ * role's solid pair with daisy's `shadow-xs` and its 1px press sink, floated
6279
+ * over the rows by `position: sticky` at the box's foot. Its negative block
6280
+ * margin gives back the line it would take, so appearing moves no row.
6281
+ */
6282
+ export const chatLog: RecipeInput = {
6283
+ component: 'chat-log',
6284
+ tokens: {
6285
+ '--chat-log-fill': 'var(--color-primary)',
6286
+ '--chat-log-on-fill': 'var(--color-primary-content)',
6287
+ '--chat-log-jump-size': 'calc(var(--size-field) * 8)',
6288
+ },
6289
+ parts: {
6290
+ root: {
6291
+ base: {
6292
+ display: 'flex',
6293
+ flexDirection: 'column',
6294
+ overflowY: 'auto',
6295
+ overscrollBehaviorY: 'contain',
6296
+ borderRadius: 'var(--radius-box)',
6297
+ background: 'var(--color-base-100)',
6298
+ color: 'var(--color-base-content)',
6299
+ border: 'var(--border) solid var(--color-base-300)',
6300
+ padding: 'var(--space-sm) var(--space-md)',
6301
+ },
6302
+ // Drawn inside the box (negative offset): an outward ring would
6303
+ // be clipped by whatever scrolls around the transcript.
6304
+ states: { 'focus-visible': { outline: '2px solid var(--color-base-content)', outlineOffset: '-2px' } },
6305
+ },
6306
+ content: {
6307
+ base: {
6308
+ // `auto` gathers a short transcript at the foot; a long one
6309
+ // overflows and the margin resolves to nothing.
6310
+ marginBlockStart: 'auto',
6311
+ flex: 'none',
6312
+ display: 'grid',
6313
+ rowGap: 'var(--space-xs)',
6314
+ },
6315
+ },
6316
+ 'jump-trigger': {
6317
+ base: {
6318
+ position: 'sticky',
6319
+ insetBlockEnd: 'var(--space-sm)',
6320
+ alignSelf: 'center',
6321
+ flex: 'none',
6322
+ marginBlockStart: 'calc(var(--chat-log-jump-size) * -1)',
6323
+ blockSize: 'var(--chat-log-jump-size)',
6324
+ appearance: 'none',
6325
+ display: 'inline-flex',
6326
+ alignItems: 'center',
6327
+ gap: 'var(--space-2xs)',
6328
+ paddingInline: 'var(--space-md)',
6329
+ border: 'var(--border) solid transparent',
6330
+ borderRadius: '9999px',
6331
+ background: 'var(--chat-log-fill)',
6332
+ color: 'var(--chat-log-on-fill)',
6333
+ boxShadow: 'var(--shadow-xs)',
6334
+ fontFamily: 'inherit',
6335
+ fontSize: 'var(--text-xs)',
6336
+ fontWeight: 'var(--weight-semibold)',
6337
+ lineHeight: 'var(--leading-none)',
6338
+ cursor: 'pointer',
6339
+ transition: 'background var(--duration-fast) var(--ease-standard)',
6340
+ },
6341
+ states: {
6342
+ open: {},
6343
+ closed: {},
6344
+ hover: { background: 'color-mix(in oklab, var(--chat-log-fill) 90%, black)' },
6345
+ 'focus-visible': { outline: '2px solid var(--color-base-content)', outlineOffset: '2px' },
6346
+ },
6347
+ selectors: { '&[data-pressed]:not([data-disabled])': { transform: 'translateY(1px)', boxShadow: 'none' } },
6348
+ },
6349
+ },
6350
+ variants: {
6351
+ color: Object.fromEntries(ROLES.map((c) => [c, { 'jump-trigger': { base: {
6352
+ '--chat-log-fill': `var(--color-${c})`,
6353
+ '--chat-log-on-fill': `var(--color-${c}-content)`,
6354
+ } } }])),
6355
+ size: {
6356
+ xs: { content: { base: { rowGap: 'var(--space-2xs)' } }, 'jump-trigger': { base: { '--chat-log-jump-size': 'calc(var(--size-field) * 6)', fontSize: 'var(--text-xs)' } } },
6357
+ sm: { content: { base: { rowGap: 'var(--space-2xs)' } }, 'jump-trigger': { base: { '--chat-log-jump-size': 'calc(var(--size-field) * 7)', fontSize: 'var(--text-xs)' } } },
6358
+ md: {},
6359
+ lg: { content: { base: { rowGap: 'var(--space-sm)' } }, 'jump-trigger': { base: { '--chat-log-jump-size': 'calc(var(--size-field) * 9)', fontSize: 'var(--text-sm)' } } },
6360
+ xl: { content: { base: { rowGap: 'var(--space-md)' } }, 'jump-trigger': { base: { '--chat-log-jump-size': 'calc(var(--size-field) * 10)', fontSize: 'var(--text-sm)' } } },
6361
+ },
6362
+ },
6363
+ };
6364
+
5358
6365
  /** daisy radial-progress: --value became --progress-percent; same ring. */
5359
6366
  export const radialProgress: RecipeInput = {
5360
6367
  component: 'radial-progress',
@@ -5745,6 +6752,8 @@ export const breadcrumbs: RecipeInput = {
5745
6752
  alignItems: 'center',
5746
6753
  gap: 'var(--space-xs)',
5747
6754
  },
6755
+ // `closed` is presence: the runtime hides the part (#295).
6756
+ states: { open: {}, closed: {} },
5748
6757
  },
5749
6758
  link: {
5750
6759
  base: {
@@ -5773,6 +6782,37 @@ export const breadcrumbs: RecipeInput = {
5773
6782
  userSelect: 'none',
5774
6783
  },
5775
6784
  },
6785
+ ellipsis: {
6786
+ base: {
6787
+ display: 'flex',
6788
+ alignItems: 'center',
6789
+ gap: 'var(--space-xs)',
6790
+ },
6791
+ // `closed` is presence: the runtime hides the part (#295).
6792
+ states: { open: {}, closed: {} },
6793
+ },
6794
+ 'ellipsis-trigger': {
6795
+ base: {
6796
+ appearance: 'none',
6797
+ background: 'transparent',
6798
+ border: '0',
6799
+ padding: '0 var(--space-2xs)',
6800
+ margin: '0',
6801
+ font: 'inherit',
6802
+ lineHeight: 'inherit',
6803
+ color: 'color-mix(in oklch, var(--color-base-content) 75%, transparent)',
6804
+ borderRadius: 'var(--radius-selector)',
6805
+ cursor: 'pointer',
6806
+ transition: 'color var(--duration-fast) var(--ease-standard)',
6807
+ },
6808
+ states: {
6809
+ hover: { color: 'var(--color-base-content)', textDecoration: 'underline' },
6810
+ 'focus-visible': {
6811
+ outline: '2px solid var(--color-primary)',
6812
+ outlineOffset: '2px',
6813
+ },
6814
+ },
6815
+ },
5776
6816
  },
5777
6817
  variants: {
5778
6818
  color: Object.fromEntries(ROLES.map((c) => [c, { root: { base: {
@@ -5788,11 +6828,47 @@ export const breadcrumbs: RecipeInput = {
5788
6828
  },
5789
6829
  };
5790
6830
 
6831
+ /**
6832
+ * The four pagination triggers — prev/next and, with `withEdges`, the
6833
+ * first/last jumps (#294) — are one cell: the same box, the same
6834
+ * glyph-sized type, and the same flip under the rtl guard, since every
6835
+ * glyph (`‹ › « »`) is physical ink pointing at a reading edge.
6836
+ */
6837
+ const pageTrigger: PartStyles = {
6838
+ base: {
6839
+ display: 'inline-flex',
6840
+ alignItems: 'center',
6841
+ justifyContent: 'center',
6842
+ minInlineSize: 'var(--pg-size)',
6843
+ blockSize: 'var(--pg-size)',
6844
+ background: 'var(--color-base-200)',
6845
+ color: 'var(--color-base-content)',
6846
+ border: 'none',
6847
+ borderRadius: 'var(--radius-field)',
6848
+ fontSize: 'calc(var(--pg-font) * 1.2)',
6849
+ lineHeight: 'var(--leading-none)',
6850
+ appearance: 'none',
6851
+ // Link mode renders an <a> (#294): no UA underline.
6852
+ textDecoration: 'none',
6853
+ cursor: 'pointer',
6854
+ transition: 'background var(--duration-fast) var(--ease-standard)',
6855
+ },
6856
+ states: {
6857
+ hover: { background: 'var(--color-base-300)' },
6858
+ disabled: { opacity: 'var(--disabled-opacity)', cursor: 'not-allowed' },
6859
+ ...focusRing,
6860
+ },
6861
+ selectors: {
6862
+ '&[data-pressed]:not([data-disabled])': { transform: 'translateY(1px)' },
6863
+ [`&${rtl}`]: { scale: '-1 1' },
6864
+ },
6865
+ };
6866
+
5791
6867
  /**
5792
6868
  * Pagination — daisy's join-of-buttons made of standalone btn cells: the
5793
6869
  * quiet base-200 fill, base-300 hover, the current page inverted into the
5794
6870
  * accent pair (primary by default). Pressed is the daisy 1px sink. The
5795
- * `‹`/`›` glyphs flip under the shared rtl guard.
6871
+ * `‹ › « »` glyphs flip under the shared rtl guard.
5796
6872
  */
5797
6873
  export const pagination: RecipeInput = {
5798
6874
  component: 'pagination',
@@ -5838,6 +6914,7 @@ export const pagination: RecipeInput = {
5838
6914
  fontWeight: 'var(--weight-semibold)',
5839
6915
  fontVariantNumeric: 'tabular-nums',
5840
6916
  appearance: 'none',
6917
+ textDecoration: 'none',
5841
6918
  cursor: 'pointer',
5842
6919
  transition: 'background var(--duration-fast) var(--ease-standard), color var(--duration-fast) var(--ease-standard)',
5843
6920
  },
@@ -5865,60 +6942,10 @@ export const pagination: RecipeInput = {
5865
6942
  userSelect: 'none',
5866
6943
  },
5867
6944
  },
5868
- 'prev-trigger': {
5869
- base: {
5870
- display: 'inline-flex',
5871
- alignItems: 'center',
5872
- justifyContent: 'center',
5873
- minInlineSize: 'var(--pg-size)',
5874
- blockSize: 'var(--pg-size)',
5875
- background: 'var(--color-base-200)',
5876
- color: 'var(--color-base-content)',
5877
- border: 'none',
5878
- borderRadius: 'var(--radius-field)',
5879
- fontSize: 'calc(var(--pg-font) * 1.2)',
5880
- lineHeight: 'var(--leading-none)',
5881
- appearance: 'none',
5882
- cursor: 'pointer',
5883
- transition: 'background var(--duration-fast) var(--ease-standard)',
5884
- },
5885
- states: {
5886
- hover: { background: 'var(--color-base-300)' },
5887
- disabled: { opacity: 'var(--disabled-opacity)', cursor: 'not-allowed' },
5888
- ...focusRing,
5889
- },
5890
- selectors: {
5891
- '&[data-pressed]:not([data-disabled])': { transform: 'translateY(1px)' },
5892
- [`&${rtl}`]: { scale: '-1 1' },
5893
- },
5894
- },
5895
- 'next-trigger': {
5896
- base: {
5897
- display: 'inline-flex',
5898
- alignItems: 'center',
5899
- justifyContent: 'center',
5900
- minInlineSize: 'var(--pg-size)',
5901
- blockSize: 'var(--pg-size)',
5902
- background: 'var(--color-base-200)',
5903
- color: 'var(--color-base-content)',
5904
- border: 'none',
5905
- borderRadius: 'var(--radius-field)',
5906
- fontSize: 'calc(var(--pg-font) * 1.2)',
5907
- lineHeight: 'var(--leading-none)',
5908
- appearance: 'none',
5909
- cursor: 'pointer',
5910
- transition: 'background var(--duration-fast) var(--ease-standard)',
5911
- },
5912
- states: {
5913
- hover: { background: 'var(--color-base-300)' },
5914
- disabled: { opacity: 'var(--disabled-opacity)', cursor: 'not-allowed' },
5915
- ...focusRing,
5916
- },
5917
- selectors: {
5918
- '&[data-pressed]:not([data-disabled])': { transform: 'translateY(1px)' },
5919
- [`&${rtl}`]: { scale: '-1 1' },
5920
- },
5921
- },
6945
+ 'first-trigger': pageTrigger,
6946
+ 'prev-trigger': pageTrigger,
6947
+ 'next-trigger': pageTrigger,
6948
+ 'last-trigger': pageTrigger,
5922
6949
  },
5923
6950
  variants: {
5924
6951
  color: Object.fromEntries(ROLES.map((c) => [c, { root: { base: {
@@ -5959,6 +6986,22 @@ const stepInk = (accent: string, role: string): string =>
5959
6986
  `color-mix(in oklab, ${accent} `
5960
6987
  + `${Math.min(ROLE_INK_KEEP[role as keyof typeof roles] ?? 55, 70)}%, var(--color-base-content))`;
5961
6988
 
6989
+ /** A Steps root holding wizard parts (#296) — the panel or the triggers. */
6990
+ const STEPS_WIZARD = '&:has(> [data-scope="steps"]:is([data-part="content"], [data-part="prev-trigger"], [data-part="next-trigger"]))';
6991
+
6992
+ /** Steps' Back/Next (#296): the neutral daisy btn, with its 1px sink. */
6993
+ const stepsTrigger: PartStyles = {
6994
+ base: { ...btn, flex: '0 0 auto', alignSelf: 'flex-start' },
6995
+ states: {
6996
+ hover: { background: 'var(--color-base-300)' },
6997
+ disabled: { opacity: 'var(--disabled-opacity)', cursor: 'not-allowed' },
6998
+ ...focusRing,
6999
+ },
7000
+ selectors: {
7001
+ '&[data-pressed]:not([data-disabled])': { transform: 'translateY(1px)' },
7002
+ },
7003
+ };
7004
+
5962
7005
  /**
5963
7006
  * Steps — daisy's steps translated to the richer rail: bold numbered discs
5964
7007
  * on base-200, the walked disc and line refilled with the accent pair
@@ -5985,6 +7028,10 @@ export const steps: RecipeInput = {
5985
7028
  },
5986
7029
  selectors: {
5987
7030
  '&[data-orientation="vertical"]': { flexDirection: 'column' },
7031
+ // A wizard (#296): the rail keeps its own line and the panel
7032
+ // and triggers wrap below it. Only then — a bare rail never
7033
+ // wraps, so its steps stay one row at any width.
7034
+ [STEPS_WIZARD]: { flexWrap: 'wrap', rowGap: 'var(--space-md)' },
5988
7035
  },
5989
7036
  },
5990
7037
  /**
@@ -6023,6 +7070,10 @@ export const steps: RecipeInput = {
6023
7070
  active: { color: 'var(--steps-accent-ink)', fontWeight: 'var(--weight-semibold)' },
6024
7071
  complete: { color: 'var(--color-base-content)' },
6025
7072
  inactive: { color: 'color-mix(in oklch, var(--color-base-content) 65%, transparent)' },
7073
+ // daisy's `step-error`, whatever the phase: after the phases,
7074
+ // so it wins at equal weight. `stepInk`, not `roleInk`: the
7075
+ // 85% error ink measured 3.79:1 on light's page as text.
7076
+ invalid: { color: stepInk('var(--color-error)', 'error') },
6026
7077
  disabled: { opacity: 'var(--disabled-opacity)', cursor: 'not-allowed' },
6027
7078
  ...focusRing,
6028
7079
  },
@@ -6054,6 +7105,7 @@ export const steps: RecipeInput = {
6054
7105
  // the disc (#112).
6055
7106
  complete: { background: 'color-mix(in oklab, var(--steps-accent) 20%, var(--color-base-100))', color: 'var(--steps-complete-ink)' },
6056
7107
  inactive: { background: 'var(--color-base-200)', color: 'var(--color-base-content)' },
7108
+ invalid: { background: 'var(--color-error)', color: 'var(--color-error-content)' },
6057
7109
  },
6058
7110
  },
6059
7111
  /**
@@ -6085,6 +7137,7 @@ export const steps: RecipeInput = {
6085
7137
  states: {
6086
7138
  complete: { background: 'var(--steps-accent)' },
6087
7139
  inactive: { background: 'var(--color-base-300)' },
7140
+ invalid: { background: 'var(--color-error)' },
6088
7141
  },
6089
7142
  },
6090
7143
  title: {
@@ -6099,6 +7152,25 @@ export const steps: RecipeInput = {
6099
7152
  fontWeight: 'var(--weight-normal)',
6100
7153
  },
6101
7154
  },
7155
+ /**
7156
+ * The active step's panel: a full-width line under the rail (the
7157
+ * wizard wrap above). Inactive panels are `hidden`, so they need no
7158
+ * rule.
7159
+ */
7160
+ content: {
7161
+ base: {
7162
+ flex: '1 0 100%',
7163
+ fontSize: 'var(--text-md)',
7164
+ color: 'var(--color-base-content)',
7165
+ },
7166
+ states: { active: {}, inactive: {} },
7167
+ },
7168
+ // Back/Next are daisy btns; Next takes the far end of their line.
7169
+ 'prev-trigger': stepsTrigger,
7170
+ 'next-trigger': {
7171
+ ...stepsTrigger,
7172
+ base: { ...stepsTrigger.base, marginInlineStart: 'auto' },
7173
+ },
6102
7174
  },
6103
7175
  variants: {
6104
7176
  // Keyed on the ITEM, not the root (#112): the item re-carries
@@ -6288,6 +7360,9 @@ export const drawer: RecipeInput = {
6288
7360
  */
6289
7361
  export const table: RecipeInput = {
6290
7362
  component: 'table',
7363
+ // A sortable header cell carries the sort state for `aria-sort`'s sake;
7364
+ // its trigger and indicator paint it.
7365
+ skipStates: { 'header-cell': ['ascending', 'descending', 'none'] },
6291
7366
  // Public to a design system derived from this one (#73).
6292
7367
  hooks: {
6293
7368
  properties: {
@@ -6373,6 +7448,55 @@ export const table: RecipeInput = {
6373
7448
  },
6374
7449
  at: tableStackAt(tokens, 'header-cell', { paddingInline: '0' }),
6375
7450
  },
7451
+ // Sorting (#286): daisy's header label made a button — the sorted
7452
+ // column's label takes the full accent ink, and the mark beside it
7453
+ // turns with the direction.
7454
+ 'sort-trigger': {
7455
+ base: {
7456
+ appearance: 'none',
7457
+ display: 'inline-flex',
7458
+ alignItems: 'center',
7459
+ margin: '0',
7460
+ border: '0',
7461
+ font: 'inherit',
7462
+ letterSpacing: 'inherit',
7463
+ textTransform: 'inherit',
7464
+ textAlign: 'inherit',
7465
+ cursor: 'pointer',
7466
+ gap: 'var(--space-2xs)',
7467
+ padding: '0',
7468
+ borderRadius: 'var(--radius-selector)',
7469
+ background: 'transparent',
7470
+ color: 'inherit',
7471
+ transition: 'color var(--duration-fast) var(--ease-standard)',
7472
+ },
7473
+ states: {
7474
+ ascending: { color: 'var(--table-accent)' },
7475
+ descending: { color: 'var(--table-accent)' },
7476
+ none: {},
7477
+ hover: { color: 'var(--table-accent)' },
7478
+ disabled: { cursor: 'not-allowed', opacity: 'var(--disabled-opacity)' },
7479
+ ...focusRing,
7480
+ },
7481
+ at: { 'reduced-motion': { base: { transition: 'none' } } },
7482
+ },
7483
+ // zero's ▲, turned for descending; an unsorted column shows it only
7484
+ // under a hovered or keyboard-focused trigger.
7485
+ 'sort-indicator': {
7486
+ base: {
7487
+ display: 'inline-block',
7488
+ fontSize: '0.75em',
7489
+ lineHeight: '1',
7490
+ transition: 'transform var(--duration-fast) var(--ease-standard), opacity var(--duration-fast) var(--ease-standard)',
7491
+ },
7492
+ states: {
7493
+ ascending: {},
7494
+ descending: { transform: 'rotate(180deg)' },
7495
+ none: { opacity: '0' },
7496
+ },
7497
+ selectors: { '[data-scope="table"][data-part="sort-trigger"]:is(:hover, [data-focus-visible]) > &[data-state="none"]': { opacity: '0.6' } },
7498
+ at: { 'reduced-motion': { base: { transition: 'none' } } },
7499
+ },
6376
7500
  cell: {
6377
7501
  base: {
6378
7502
  padding: 'var(--table-pad-block) var(--table-pad-inline)',
@@ -7026,11 +8150,11 @@ export const diff: RecipeInput = {
7026
8150
  };
7027
8151
 
7028
8152
  export const recipes: RecipeInput[] = [
7029
- tabs, collapsible, switchRecipe, dialog, popover, tooltip, menu,
7030
- field, checkbox, checkboxGroup, radioGroup, progress, slider, accordion, select, button, avatar, toast, combobox,
8153
+ tabs, collapsible, switchRecipe, dialog, popover, tooltip, hoverCard, menu, menubar,
8154
+ field, fieldset, checkbox, checkboxGroup, radioGroup, progress, slider, accordion, select, button, avatar, toast, combobox,
7031
8155
  toggle, toggleGroup, numberInput, ratingGroup, treeView, input, textarea,
7032
8156
  card, alert, emptyState, badge, divider, skeleton, spinner,
7033
- kbd, status, indicator, stats, timeline, chat, radialProgress, join,
8157
+ kbd, status, indicator, stats, timeline, chat, chatLog, radialProgress, join,
7034
8158
  navbar, navList, breadcrumbs, pagination, steps, drawer,
7035
8159
  table,
7036
8160
  fileUpload,