@sigx/zero-daisyui 0.7.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 (47) hide show
  1. package/dist/audit.json +3813 -2
  2. package/dist/components.d.ts +28 -1
  3. package/dist/components.js +3 -0
  4. package/dist/css/components/breadcrumbs.css +30 -0
  5. package/dist/css/components/card.css +26 -0
  6. package/dist/css/components/chat-log.css +172 -0
  7. package/dist/css/components/divider.css +68 -3
  8. package/dist/css/components/hover-card.css +174 -0
  9. package/dist/css/components/menu.css +7 -0
  10. package/dist/css/components/menubar.css +75 -0
  11. package/dist/css/components/pagination.css +83 -0
  12. package/dist/css/components/steps.css +103 -0
  13. package/dist/css/components/timeline.css +14 -0
  14. package/dist/css/components/toast.css +65 -0
  15. package/dist/css/components/tree-view.css +118 -0
  16. package/dist/css/index.css +951 -22
  17. package/dist/lynx/components/accordion.css +3 -1
  18. package/dist/lynx/components/breadcrumbs.css +44 -0
  19. package/dist/lynx/components/button.css +71 -12
  20. package/dist/lynx/components/card.css +9 -0
  21. package/dist/lynx/components/chat-log.css +147 -0
  22. package/dist/lynx/components/dialog.css +9 -3
  23. package/dist/lynx/components/divider.css +8 -17
  24. package/dist/lynx/components/hover-card.css +211 -0
  25. package/dist/lynx/components/menu.css +7 -0
  26. package/dist/lynx/components/menubar.css +199 -0
  27. package/dist/lynx/components/pagination.css +107 -0
  28. package/dist/lynx/components/popover.css +7 -2
  29. package/dist/lynx/components/select.css +40 -3
  30. package/dist/lynx/components/slider.css +3 -2
  31. package/dist/lynx/components/steps.css +103 -0
  32. package/dist/lynx/components/switch.css +2 -1
  33. package/dist/lynx/components/tabs.css +7 -1
  34. package/dist/lynx/components/timeline.css +32 -0
  35. package/dist/lynx/components/toast.css +70 -2
  36. package/dist/lynx/components/tree-view.css +28 -0
  37. package/dist/lynx/index.css +1194 -128
  38. package/dist/lynx/manifest.json +85 -4
  39. package/dist/manifest.json +83 -2
  40. package/dist/recipes.d.ts +19 -1
  41. package/dist/recipes.d.ts.map +1 -1
  42. package/dist/recipes.js +741 -98
  43. package/dist/recipes.js.map +1 -1
  44. package/dist/register.d.ts +27 -0
  45. package/dist/report.json +1078 -159
  46. package/package.json +4 -4
  47. package/src/recipes.ts +763 -98
package/dist/recipes.js CHANGED
@@ -28,6 +28,36 @@ const focusRing = {
28
28
  outlineOffset: '2px',
29
29
  },
30
30
  };
31
+ /**
32
+ * The focus ring on lynx, for `targets.lynx` (signalxjs/lynx#1163): the same
33
+ * 2px ring 2px clear of the part, spelled as spread box-shadows.
34
+ *
35
+ * The web ring is `outline` + `outline-offset`, and neither survives on
36
+ * lynx: its `outline` ignores `border-radius` (a square box around a
37
+ * rounded button, a rectangle around the switch pill) and it has no
38
+ * `outline-offset` property at all, so the ring sat flush on the fill. A
39
+ * zero-blur shadow's spread follows the element's radius. Shadows stack
40
+ * rather than mask, so the offset gap cannot be transparent: the inner
41
+ * shadow paints it in `gap` — the surface the part sits on, which is what
42
+ * the web's gap shows through to. `outline: none` retires the shared ring
43
+ * in the same merged declaration block.
44
+ *
45
+ * `under` appends the part's own lynx shadows (a knob's depth relief), which
46
+ * the focus rule would otherwise replace. `inset` draws the ring inside the
47
+ * part, flush with its edge, for a part whose parent clips an outside ring
48
+ * away (signalxjs/lynx#1164, the accordion trigger inside its card).
49
+ */
50
+ const lynxFocusRing = (ink, { gap = 'var(--color-base-100)', under, inset = false } = {}) => ({
51
+ outline: 'none',
52
+ boxShadow: [
53
+ ...(inset ? [`inset 0 0 0 2px ${ink}`] : [`0 0 0 2px ${gap}`, `0 0 0 4px ${ink}`]),
54
+ ...(under ? [under] : []),
55
+ ].join(', '),
56
+ });
57
+ /** `focusRing`'s lynx spelling — the base-content ring, as a states entry. */
58
+ const lynxFocus = (opts) => ({
59
+ 'focus-visible': lynxFocusRing('var(--color-base-content)', opts),
60
+ });
31
61
  /**
32
62
  * How far each role survives in the button's INK — the colour a transparent or
33
63
  * tinted fill draws its label, border and focus ring with.
@@ -495,7 +525,7 @@ export const tabs = {
495
525
  parts: {
496
526
  // No hover on a touch platform: the held tab takes the ink
497
527
  // daisy's hover gives it.
498
- tab: { states: { pressed: { color: 'var(--color-base-content)' } } },
528
+ tab: { states: { pressed: { color: 'var(--color-base-content)' }, ...lynxFocus() } },
499
529
  // The list is the indicator's containing block.
500
530
  list: { base: { position: 'relative' } },
501
531
  // The underline's ink (read by the `border` flavor below) —
@@ -546,6 +576,14 @@ export const tabs = {
546
576
  base: { '--tab-radius-limit': 'var(--radius-field)' },
547
577
  },
548
578
  },
579
+ // The box flavor's tabs sit on the list's base-200
580
+ // surface, so the ring's gap is that surface. Stated on
581
+ // the variant (one class more than the part rule) so it
582
+ // also outranks the active pill's `--shadow-sm`, which
583
+ // is a box-shadow too.
584
+ box: {
585
+ tab: { states: lynxFocus({ gap: 'var(--color-base-200)' }) },
586
+ },
549
587
  },
550
588
  },
551
589
  },
@@ -895,6 +933,9 @@ export const switchRecipe = {
895
933
  borderColor: 'var(--switch-accent)',
896
934
  backgroundColor: 'var(--color-base-100)',
897
935
  },
936
+ // The pill ring (signalxjs/lynx#1163), keeping the
937
+ // track's inset depth line under it.
938
+ 'focus-visible': lynxFocusRing('var(--switch-accent)', { under: '0 1px var(--depth-shade) inset' }),
898
939
  },
899
940
  },
900
941
  thumb: {
@@ -1011,6 +1052,36 @@ const lynxBtnPad = (mult) => ({
1011
1052
  paddingLeft: `calc(var(--size-field) * ${mult})`,
1012
1053
  paddingRight: `calc(var(--size-field) * ${mult})`,
1013
1054
  });
1055
+ /**
1056
+ * A four-side property spelled as its physical longhands, for `targets.lynx`
1057
+ * sections that must not ship a `var()`-bearing shorthand.
1058
+ *
1059
+ * Lynx keeps a declaration holding `var()` under its own property id,
1060
+ * unexpanded, and expands it only after the cascade has merged every rule
1061
+ * into one insertion-ordered style map — in that map's order, writing over
1062
+ * the longhands already there (`CSSVariableHandler::HandleCSSVariables`). A
1063
+ * static shorthand, by contrast, is expanded into its longhands at parse
1064
+ * time. So a var-bearing `border-color` and a static `border-top-color` never
1065
+ * meet in the cascade at all: whichever key the map saw later wins, whatever
1066
+ * the specificity (signalxjs/lynx#1161, #1162). Longhands share one key per
1067
+ * side, and the cascade decides again.
1068
+ */
1069
+ const lynxSides = (prop, value, sides = ['Top', 'Right', 'Bottom', 'Left']) => Object.fromEntries(sides.map((side) => [prop.replace('{}', side), value]));
1070
+ /**
1071
+ * daisy's `.btn` is content-sized and never squeezed: `display: inline-flex`
1072
+ * with `flex-shrink: 0; flex-wrap: nowrap`, so a narrow row makes the button
1073
+ * overflow, never break its label. A lynx view has no inline formatting
1074
+ * context: it stretches to a linear or column parent, and a squeezed label
1075
+ * `<text>` then wraps per character ("Bt / n", signalxjs/lynx#1165).
1076
+ * `width: max-content` is the intrinsic keyword starlight implements (an
1077
+ * intrinsic width is also exempt from the cross-axis stretch), so the box
1078
+ * sizes to its content as the web's does; the other two are daisy's own.
1079
+ */
1080
+ const lynxBtnFit = {
1081
+ width: 'max-content',
1082
+ flexShrink: '0',
1083
+ flexWrap: 'nowrap',
1084
+ };
1014
1085
  /** The btn size ramp's physical inline padding, for `targets.lynx.variants`. */
1015
1086
  const lynxBtnSizes = (part) => ({
1016
1087
  xs: { [part]: { base: lynxBtnPad(2) } },
@@ -1130,9 +1201,10 @@ export const dialog = {
1130
1201
  targets: {
1131
1202
  lynx: {
1132
1203
  parts: {
1133
- trigger: { base: lynxBtnPad(4), states: { pressed: lynxBtnPressed } },
1134
- close: { base: lynxBtnPad(4), states: { pressed: lynxBtnPressed } },
1135
- cancel: { base: lynxBtnPad(4), states: { pressed: lynxBtnPressed } },
1204
+ // Content-sized like daisy's btn, never broken mid-word (signalxjs/lynx#1165).
1205
+ trigger: { base: { ...lynxBtnPad(4), ...lynxBtnFit }, states: { pressed: lynxBtnPressed, ...lynxFocus() } },
1206
+ close: { base: lynxBtnPad(4), states: { pressed: lynxBtnPressed, ...lynxFocus() } },
1207
+ cancel: { base: lynxBtnPad(4), states: { pressed: lynxBtnPressed, ...lynxFocus() } },
1136
1208
  footer: { base: { marginTop: 'var(--space-2xl)', flexDirection: 'row' } },
1137
1209
  },
1138
1210
  variants: { size: lynxBtnSizes('trigger') },
@@ -1223,8 +1295,9 @@ export const popover = {
1223
1295
  web: floatingArrow('popover', panelArrowPaint),
1224
1296
  lynx: {
1225
1297
  parts: {
1226
- trigger: { base: lynxBtnPad(4), states: { pressed: lynxBtnPressed } },
1227
- close: { base: lynxBtnPad(3), states: { pressed: lynxBtnPressed } },
1298
+ // Content-sized like daisy's btn, never broken mid-word (signalxjs/lynx#1165).
1299
+ trigger: { base: { ...lynxBtnPad(4), ...lynxBtnFit }, states: { pressed: lynxBtnPressed, ...lynxFocus() } },
1300
+ close: { base: lynxBtnPad(3), states: { pressed: lynxBtnPressed, ...lynxFocus() } },
1228
1301
  },
1229
1302
  variants: { size: lynxBtnSizes('trigger') },
1230
1303
  },
@@ -1273,6 +1346,56 @@ export const tooltip = {
1273
1346
  },
1274
1347
  },
1275
1348
  };
1349
+ /**
1350
+ * Hover card — daisy's `link` opening a `dropdown-content` card. The trigger
1351
+ * is running text, not a btn: underlined in the ink it sits in (daisy's bare
1352
+ * `link`), and a colour is `link-<role>` in the role's readable ink
1353
+ * (`roleInk`). The underline thickens on hover and while the card is up,
1354
+ * rather than the ink dimming — the `btn-link` reasoning. The card is the
1355
+ * floating panel popover and menu wear.
1356
+ */
1357
+ export const hoverCard = {
1358
+ component: 'hover-card',
1359
+ parts: {
1360
+ trigger: {
1361
+ base: {
1362
+ color: 'inherit',
1363
+ textDecorationLine: 'underline',
1364
+ textUnderlineOffset: '2px',
1365
+ cursor: 'pointer',
1366
+ borderRadius: 'var(--radius-selector)',
1367
+ },
1368
+ states: {
1369
+ hover: { textDecorationThickness: '2px' },
1370
+ open: { textDecorationThickness: '2px' },
1371
+ closed: {},
1372
+ ...focusRing,
1373
+ },
1374
+ },
1375
+ popup: withPresence(popupPresence('translateY(-4px)'), {
1376
+ base: {
1377
+ ...floatingPanel,
1378
+ padding: 'var(--space-xl)',
1379
+ maxWidth: '20rem',
1380
+ fontSize: 'var(--text-sm)',
1381
+ },
1382
+ states: { open: {}, closed: {} },
1383
+ }),
1384
+ },
1385
+ variants: {
1386
+ color: Object.fromEntries(ROLES.map((c) => [c, { trigger: { base: { color: roleInk(c) } } }])),
1387
+ size: {
1388
+ xs: { trigger: { base: { fontSize: 'var(--text-xs)' } } },
1389
+ sm: { trigger: { base: { fontSize: 'var(--text-sm)' } } },
1390
+ md: {},
1391
+ lg: { trigger: { base: { fontSize: 'var(--text-lg)' } } },
1392
+ xl: { trigger: { base: { fontSize: 'var(--text-xl)' } } },
1393
+ },
1394
+ },
1395
+ // Web-only: the popover top layer and the runtime's arrow geometry have
1396
+ // no lynx counterpart.
1397
+ targets: { web: floatingArrow('hover-card', panelArrowPaint) },
1398
+ };
1276
1399
  // daisy "menu in a dropdown" look.
1277
1400
  export const menu = {
1278
1401
  component: 'menu',
@@ -1406,6 +1529,17 @@ export const menu = {
1406
1529
  opacity: '0.6',
1407
1530
  },
1408
1531
  },
1532
+ // daisy's menu hint: the group label's faded ink, pushed to the
1533
+ // row's reading end — faded less, since 0.6 leaves nord's paper
1534
+ // under the 4.5:1 a line read by eye owes.
1535
+ shortcut: {
1536
+ base: {
1537
+ marginInlineStart: 'auto',
1538
+ paddingInlineStart: 'var(--space-lg)',
1539
+ fontSize: 'var(--text-xs)',
1540
+ opacity: '0.75',
1541
+ },
1542
+ },
1409
1543
  separator: {
1410
1544
  base: {
1411
1545
  height: 'var(--border)',
@@ -1421,11 +1555,53 @@ export const menu = {
1421
1555
  targets: {
1422
1556
  web: floatingArrow('menu', panelArrowPaint),
1423
1557
  lynx: {
1424
- parts: { trigger: { base: lynxBtnPad(4) } },
1558
+ parts: {
1559
+ trigger: { base: lynxBtnPad(4) },
1560
+ // The shortcut's reading-end push, restated physically (#1084).
1561
+ shortcut: { base: { marginLeft: 'auto', paddingLeft: 'var(--space-lg)' } },
1562
+ },
1425
1563
  variants: { size: lynxBtnSizes('trigger') },
1426
1564
  },
1427
1565
  },
1428
1566
  };
1567
+ // The menubar row: daisy's `menu-horizontal` strip — a base-200 box the
1568
+ // menu triggers sit in. The triggers are ordinary `menu` triggers.
1569
+ export const menubar = {
1570
+ component: 'menubar',
1571
+ tokens: { '--menubar-surface': 'var(--color-base-200)' },
1572
+ parts: {
1573
+ root: {
1574
+ base: {
1575
+ display: 'flex',
1576
+ alignItems: 'center',
1577
+ gap: 'var(--space-xs)',
1578
+ padding: 'var(--space-xs)',
1579
+ width: 'max-content',
1580
+ maxWidth: '100%',
1581
+ flexWrap: 'wrap',
1582
+ background: 'var(--menubar-surface)',
1583
+ borderRadius: 'var(--radius-box)',
1584
+ },
1585
+ // The triggers fade themselves — see basic.
1586
+ states: { disabled: {} },
1587
+ selectors: {
1588
+ '&[data-orientation="vertical"]': { flexDirection: 'column', alignItems: 'stretch' },
1589
+ },
1590
+ },
1591
+ },
1592
+ variants: {
1593
+ color: Object.fromEntries(ROLES.map((c) => [c, { root: { base: {
1594
+ '--menubar-surface': `color-mix(in oklch, var(--color-${c}) 12%, var(--color-base-200))`,
1595
+ } } }])),
1596
+ size: {
1597
+ xs: { root: { base: { gap: '0', padding: '0' } } },
1598
+ sm: { root: { base: { padding: 'var(--space-2xs)' } } },
1599
+ md: {},
1600
+ lg: { root: { base: { gap: 'var(--space-sm)', padding: 'var(--space-sm)' } } },
1601
+ xl: { root: { base: { gap: 'var(--space-md)', padding: 'var(--space-sm)' } } },
1602
+ },
1603
+ },
1604
+ };
1429
1605
  export const field = {
1430
1606
  component: 'field',
1431
1607
  // The label's accent ink — base-content by default, so the un-attributed
@@ -2428,6 +2604,12 @@ export const slider = {
2428
2604
  control: {
2429
2605
  states: {
2430
2606
  invalid: { '--slider-fill': sliderFill('var(--color-error)') },
2607
+ // The control's ring is the NATIVE input's on the web;
2608
+ // lynx always renders the composed parts, where the
2609
+ // thumb is what takes focus and wears the ring below
2610
+ // (signalxjs/lynx#1163 — both at once read as two
2611
+ // nested boxes).
2612
+ 'focus-visible': { outline: 'none' },
2431
2613
  },
2432
2614
  selectors: {
2433
2615
  '&[data-orientation="vertical"]': {
@@ -2525,6 +2707,13 @@ export const slider = {
2525
2707
  transform: 'translateY(-50%)',
2526
2708
  marginLeft: 'calc(var(--slider-thumb-size) / -2)',
2527
2709
  },
2710
+ // The round ring (signalxjs/lynx#1163), over the knob's
2711
+ // depth relief.
2712
+ states: {
2713
+ 'focus-visible': lynxFocusRing('var(--slider-accent)', {
2714
+ under: '0 -1px var(--depth-shade) inset, 0 8px 0 -4px var(--depth-sheen) inset, 0 1px var(--depth-shade)',
2715
+ }),
2716
+ },
2528
2717
  },
2529
2718
  },
2530
2719
  },
@@ -2540,7 +2729,20 @@ export const accordion = {
2540
2729
  // hover fill. The ::after chevron has no lynx counterpart (no
2541
2730
  // pseudo-elements, and the anatomy declares no part for it) —
2542
2731
  // an app draws its own glyph in the trigger.
2543
- trigger: { states: { pressed: { background: 'var(--color-base-200)' } } },
2732
+ //
2733
+ // The item card clips (`overflow: hidden`) and the trigger
2734
+ // fills it, so an outside ring is clipped away entirely
2735
+ // (signalxjs/lynx#1164): the ring is drawn inset, rounded to
2736
+ // the card's inner corners.
2737
+ trigger: {
2738
+ states: {
2739
+ pressed: { background: 'var(--color-base-200)' },
2740
+ 'focus-visible': {
2741
+ ...lynxFocusRing('var(--color-base-content)', { inset: true }),
2742
+ borderRadius: 'calc(var(--radius-box) - var(--border))',
2743
+ },
2744
+ },
2745
+ },
2544
2746
  },
2545
2747
  },
2546
2748
  },
@@ -2828,22 +3030,43 @@ export const select = {
2828
3030
  // never wider than its container. lynx has no `clamp()`, so the
2829
3031
  // same bounds are spelled out — and the base's 13rem floor
2830
3032
  // comes down to daisy's 3rem, or the trigger overflowed any
2831
- // cell narrower than 208px (signalxjs/lynx#1146). The root
2832
- // caps too, so a shrink-wrapped root cannot push it back out.
2833
- root: { base: { maxWidth: '100%' } },
3033
+ // cell narrower than 208px (signalxjs/lynx#1146).
3034
+ //
3035
+ // The bounds sit on the ROOT, the box daisy's `.select` is,
3036
+ // and the trigger fills it. The web root is `inline-flex`, so
3037
+ // it shrink-wraps the trigger; lynx rewrites that to `flex`,
3038
+ // and a root stretched by its parent while the trigger stayed
3039
+ // capped left the clear-trigger — positioned against the root
3040
+ // — floating past the trigger's end (signalxjs/lynx#1166).
3041
+ root: { base: { width: '20rem', maxWidth: '100%', minWidth: '3rem' } },
2834
3042
  trigger: {
2835
- base: { ...lynxBtnPad(4), width: '20rem', maxWidth: '100%', minWidth: '3rem' },
3043
+ base: { ...lynxBtnPad(4), width: '100%', maxWidth: '100%', minWidth: '3rem' },
2836
3044
  // The web's `:hover` border is dropped on a touch
2837
3045
  // platform; a press gets a wash instead — a background,
2838
3046
  // so it never fights the open/invalid border inks.
2839
- states: { pressed: { background: 'var(--color-base-200)' } },
2840
- },
3047
+ states: { pressed: { background: 'var(--color-base-200)' }, ...lynxFocus() },
3048
+ },
3049
+ // The popup renders in the overlay outlet, outside the root,
3050
+ // so it cannot inherit the root's `--select-accent`, and an
3051
+ // unresolved `var()` on lynx has no fallback: the selected
3052
+ // item and its tick fell back to base-content
3053
+ // (signalxjs/lynx#1168). The popup restates the accent; its
3054
+ // colour-axis twins are in `variants.color` below.
3055
+ popup: { base: { '--select-accent': 'var(--color-primary)' } },
2841
3056
  // A touch list has no highlight: the pressed item takes the
2842
3057
  // highlighted item's wash.
2843
3058
  item: { states: { pressed: { background: 'var(--color-base-200)' } } },
2844
- 'clear-trigger': { base: { top: 'var(--space-xs)', bottom: 'var(--space-xs)', right: 'calc(var(--size-field) * 4 + 1em + var(--space-xs))', minWidth: '1.5rem' } },
3059
+ 'clear-trigger': {
3060
+ base: { top: 'var(--space-xs)', bottom: 'var(--space-xs)', right: 'calc(var(--size-field) * 4 + 1em + var(--space-xs))', minWidth: '1.5rem' },
3061
+ states: lynxFocus(),
3062
+ },
2845
3063
  },
2846
3064
  variants: {
3065
+ // The popup's accent follows the root's colour axis — the
3066
+ // popup carries the axis class itself (lynx stamps every part).
3067
+ color: Object.fromEntries(ROLES.map((c) => [c, { popup: { base: {
3068
+ '--select-accent': `var(--color-${c})`,
3069
+ } } }])),
2847
3070
  size: { ...lynxBtnSizes('trigger'), md: { trigger: { base: lynxBtnPad(4) } } },
2848
3071
  },
2849
3072
  },
@@ -2864,6 +3087,22 @@ export const select = {
2864
3087
  */
2865
3088
  const BTN_STEPS = { xs: 6, sm: 8, md: 10, lg: 12, xl: 14 };
2866
3089
  const btnHeight = (step) => `calc(var(--size-field) * ${BTN_STEPS[step]})`;
3090
+ /**
3091
+ * The btn padding ramp — `[size, block space, inline space]` per step. One
3092
+ * table for the web's `padding` shorthand and the lynx section's longhands,
3093
+ * so the two spellings cannot drift apart.
3094
+ */
3095
+ const BTN_PAD = [
3096
+ ['xs', '2xs', 'xs'],
3097
+ ['sm', 'xs', 'sm'],
3098
+ ['md', 'sm', 'lg'],
3099
+ ['lg', 'md', 'xl'],
3100
+ ['xl', 'lg', '2xl'],
3101
+ ];
3102
+ const btnPadding = (step) => {
3103
+ const [, block, inline] = BTN_PAD.find(([size]) => size === step);
3104
+ return `var(--space-${block}) var(--space-${inline})`;
3105
+ };
2867
3106
  /**
2868
3107
  * daisy 5's disabled btn, for the lynx target (signalxjs/lynx#1143). The web
2869
3108
  * fades the whole button (`opacity: var(--disabled-opacity)`, 0.3 on the
@@ -3058,11 +3297,11 @@ export const button = {
3058
3297
  // The `<button>` element is UA `border-box`, so the stated height is
3059
3298
  // the rendered height, border included — as in daisy.
3060
3299
  size: {
3061
- xs: { root: { base: { height: btnHeight('xs'), padding: 'var(--space-2xs) var(--space-xs)', fontSize: 'var(--text-xs)' } } },
3062
- sm: { root: { base: { height: btnHeight('sm'), padding: 'var(--space-xs) var(--space-sm)', fontSize: 'var(--text-sm)' } } },
3063
- md: { root: { base: { height: btnHeight('md'), padding: 'var(--space-sm) var(--space-lg)', fontSize: 'var(--text-md)' } } },
3064
- lg: { root: { base: { height: btnHeight('lg'), padding: 'var(--space-md) var(--space-xl)', fontSize: 'var(--text-lg)' } } },
3065
- xl: { root: { base: { height: btnHeight('xl'), padding: 'var(--space-lg) var(--space-2xl)', fontSize: 'var(--text-xl)' } } },
3300
+ xs: { root: { base: { height: btnHeight('xs'), padding: btnPadding('xs'), fontSize: 'var(--text-xs)' } } },
3301
+ sm: { root: { base: { height: btnHeight('sm'), padding: btnPadding('sm'), fontSize: 'var(--text-sm)' } } },
3302
+ md: { root: { base: { height: btnHeight('md'), padding: btnPadding('md'), fontSize: 'var(--text-md)' } } },
3303
+ lg: { root: { base: { height: btnHeight('lg'), padding: btnPadding('lg'), fontSize: 'var(--text-lg)' } } },
3304
+ xl: { root: { base: { height: btnHeight('xl'), padding: btnPadding('xl'), fontSize: 'var(--text-xl)' } } },
3066
3305
  },
3067
3306
  },
3068
3307
  /**
@@ -3099,36 +3338,90 @@ export const button = {
3099
3338
  // The ring, restated: `currentColor` never resolves on lynx
3100
3339
  // (signalxjs/lynx#1079) and the logical sizes/edge are
3101
3340
  // unproven there, so the lynx ring is physical longhands in
3102
- // the variant's own ink (the variants below pick it).
3341
+ // the variant's own ink (the variants below pick it). Every
3342
+ // var-bearing edge is a longhand (`lynxSides`): a var-bearing
3343
+ // `border` expanded after the transparent top and closed the
3344
+ // ring (signalxjs/lynx#1161). The shorthand stays, static, so
3345
+ // the shared `currentColor` spelling never ships.
3103
3346
  spinner: {
3104
3347
  base: {
3105
3348
  width: '1em',
3106
3349
  height: '1em',
3107
- border: 'calc(var(--border) * 2) solid var(--btn-ink)',
3350
+ border: '0 solid transparent',
3351
+ ...lynxSides('border{}Width', 'calc(var(--border) * 2)'),
3352
+ ...lynxSides('border{}Color', 'var(--btn-ink)', ['Right', 'Bottom', 'Left']),
3108
3353
  borderTopColor: 'transparent',
3109
3354
  },
3110
3355
  },
3111
- root: { states: { disabled: lynxBtnDisabled } },
3356
+ root: {
3357
+ // daisy's content-sized, never-squeezed btn (#1165), and
3358
+ // the hairline as longhands: a var-bearing `border`
3359
+ // shorthand would expand over the variants' and the
3360
+ // disabled state's edge colours (see `lynxSides`).
3361
+ base: {
3362
+ ...lynxBtnFit,
3363
+ border: '0 solid transparent',
3364
+ ...lynxSides('border{}Width', 'var(--border)'),
3365
+ },
3366
+ // The ring in the variant's own ink (signalxjs/lynx#1163).
3367
+ states: { disabled: lynxBtnDisabled, 'focus-visible': lynxFocusRing('var(--btn-ink)') },
3368
+ },
3112
3369
  },
3113
3370
  variants: {
3114
3371
  // The baked disabled paint above outranks every variant's fill
3115
3372
  // (the theme host adds a class), but its plain declarations
3116
3373
  // do not: outline/dash restate the border, ghost/link drop
3117
- // the wash (as in daisy).
3374
+ // the wash (as in daisy). outline/dash draw their ink edge as
3375
+ // longhands, so that transparent restatement wins by
3376
+ // specificity instead of losing to a var-bearing
3377
+ // `border-color` (signalxjs/lynx#1162); the shared shorthand
3378
+ // is neutralised to a static value.
3118
3379
  variant: {
3119
- solid: { spinner: { base: { borderColor: 'var(--btn-on-accent)', borderTopColor: 'transparent' } } },
3120
- outline: { root: { states: { disabled: { borderColor: 'transparent' } } } },
3121
- dash: { root: { states: { disabled: { borderColor: 'transparent' } } } },
3380
+ solid: { spinner: { base: lynxSides('border{}Color', 'var(--btn-on-accent)', ['Right', 'Bottom', 'Left']) } },
3381
+ outline: {
3382
+ root: {
3383
+ base: { borderColor: 'transparent', ...lynxSides('border{}Color', 'var(--btn-ink)') },
3384
+ states: { disabled: { borderColor: 'transparent' } },
3385
+ },
3386
+ },
3387
+ dash: {
3388
+ root: {
3389
+ base: { borderColor: 'transparent', ...lynxSides('border{}Color', 'var(--btn-ink)') },
3390
+ states: { disabled: { borderColor: 'transparent' } },
3391
+ },
3392
+ },
3122
3393
  ghost: { root: { states: { disabled: { background: 'transparent' } } } },
3123
- link: { root: { states: { disabled: { background: 'transparent' } } } },
3124
- },
3394
+ // `link` zeroes the box-shadow in a variant rule that
3395
+ // lands after the ring, so it restates the ring.
3396
+ link: { root: { states: { disabled: { background: 'transparent' }, 'focus-visible': lynxFocusRing('var(--btn-ink)') } } },
3397
+ },
3398
+ // The size ramp's paddings as longhands, so square/circle's
3399
+ // zeroed sides below win by the cascade rather than by the
3400
+ // order lynx happens to expand a var-bearing `padding` in.
3401
+ size: Object.fromEntries(BTN_PAD.map(([size, block, inline]) => [size, {
3402
+ root: {
3403
+ base: {
3404
+ padding: '0',
3405
+ ...lynxSides('padding{}', `var(--space-${block})`, ['Top', 'Bottom']),
3406
+ ...lynxSides('padding{}', `var(--space-${inline})`, ['Right', 'Left']),
3407
+ },
3408
+ },
3409
+ }])),
3125
3410
  },
3126
3411
  // The icon chips' zeroed inline padding, restated physically: logical
3127
3412
  // spellings resolve on iOS but not on Android (measured,
3128
- // signalxjs/lynx#1084), so the emitter refuses them.
3413
+ // signalxjs/lynx#1084), so the emitter refuses them. `width: auto`
3414
+ // undoes the root's `max-content`, which would otherwise switch
3415
+ // off the `aspect-ratio` that squares the chip. `wide`/`block`
3416
+ // restate their width physically: `inline-size` and
3417
+ // `max-inline-size` are not lynx properties at all.
3129
3418
  modifiers: {
3130
- square: { root: { base: { paddingLeft: '0', paddingRight: '0' } } },
3131
- circle: { root: { base: { paddingLeft: '0', paddingRight: '0' } } },
3419
+ wide: { root: { base: { width: '100%', maxWidth: '16rem' } } },
3420
+ block: { root: { base: { width: '100%' } } },
3421
+ square: { root: { base: { width: 'auto', paddingLeft: '0', paddingRight: '0' } } },
3422
+ circle: { root: { base: { width: 'auto', paddingLeft: '0', paddingRight: '0' } } },
3423
+ // `active` zeroes the box-shadow too (the held press look).
3424
+ active: { root: { states: { 'focus-visible': lynxFocusRing('var(--btn-ink)') } } },
3132
3425
  },
3133
3426
  },
3134
3427
  },
@@ -3208,6 +3501,22 @@ export const avatar = {
3208
3501
  },
3209
3502
  },
3210
3503
  };
3504
+ /**
3505
+ * A toast whose indicator is rendered (#292): the grid grows a leading column
3506
+ * for the mark, and the text, action and close step one column along.
3507
+ */
3508
+ const TOAST_MARKED = '[data-scope="toast"][data-part="root"]:has(> [data-scope="toast"][data-part="indicator"]) > &';
3509
+ /** The indicator's marks, cut from its fill — not mirrored in RTL (a tick is not a direction). */
3510
+ 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%)';
3511
+ 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%)';
3512
+ /**
3513
+ * daisy's success and error are FILLS — light enough in `light` and `nord`
3514
+ * that a bare mark in them reads under 2:1 on the toast's base-200. Deepened
3515
+ * toward base-content (which flips with the theme), the hue survives and the
3516
+ * mark clears the 3:1 non-text floor in every theme.
3517
+ */
3518
+ const TOAST_SUCCESS_INK = 'color-mix(in oklab, var(--color-success) 55%, var(--color-base-content))';
3519
+ const TOAST_ERROR_INK = 'color-mix(in oklab, var(--color-error) 55%, var(--color-base-content))';
3211
3520
  /**
3212
3521
  * Toast presence is runtime-managed (see the SKILL's Toast section): plain
3213
3522
  * two-state transitions only — no `@starting-style`, no `allow-discrete`.
@@ -3218,6 +3527,7 @@ export const toast = {
3218
3527
  '--toast-bg': 'var(--color-base-200)',
3219
3528
  '--toast-ink': 'var(--color-base-content)',
3220
3529
  '--toast-from': '8px',
3530
+ '--toast-mark': '1rem',
3221
3531
  },
3222
3532
  parts: {
3223
3533
  viewport: {
@@ -3271,6 +3581,7 @@ export const toast = {
3271
3581
  },
3272
3582
  selectors: {
3273
3583
  '&[data-placement^="top"]': { '--toast-from': '-8px' },
3584
+ '&:has(> [data-scope="toast"][data-part="indicator"])': { gridTemplateColumns: 'auto 1fr auto auto' },
3274
3585
  },
3275
3586
  states: {
3276
3587
  open: { opacity: '1', transform: 'none' },
@@ -3280,8 +3591,39 @@ export const toast = {
3280
3591
  'reduced-motion': { base: { transition: 'none' }, states: { open: { transform: 'none' } } },
3281
3592
  },
3282
3593
  },
3594
+ // daisy's `loading-spinner` ring in the alert's own ink, then the
3595
+ // success tick or the error cross — the promise toast's status.
3596
+ indicator: {
3597
+ base: {
3598
+ gridColumn: '1',
3599
+ gridRow: '1 / span 2',
3600
+ inlineSize: 'var(--toast-mark)',
3601
+ blockSize: 'var(--toast-mark)',
3602
+ boxSizing: 'border-box',
3603
+ },
3604
+ states: {
3605
+ loading: {
3606
+ borderRadius: '50%',
3607
+ border: 'calc(var(--border) * 2) solid color-mix(in oklab, var(--toast-ink) 25%, transparent)',
3608
+ borderBlockStartColor: 'var(--toast-ink)',
3609
+ animation: 'zero-daisyui-toast-spin 0.75s linear infinite',
3610
+ },
3611
+ complete: { background: TOAST_SUCCESS_INK, clipPath: TOAST_CHECK },
3612
+ error: { background: TOAST_ERROR_INK, clipPath: TOAST_CROSS },
3613
+ },
3614
+ at: {
3615
+ 'reduced-motion': { states: { loading: { animation: 'none' } } },
3616
+ 'forced-colors': {
3617
+ states: {
3618
+ complete: { background: 'CanvasText', forcedColorAdjust: 'none' },
3619
+ error: { background: 'CanvasText', forcedColorAdjust: 'none' },
3620
+ },
3621
+ },
3622
+ },
3623
+ },
3283
3624
  title: {
3284
3625
  base: { gridColumn: '1', fontWeight: 'var(--weight-semibold)' },
3626
+ selectors: { [TOAST_MARKED]: { gridColumn: '2' } },
3285
3627
  },
3286
3628
  description: {
3287
3629
  base: {
@@ -3289,6 +3631,7 @@ export const toast = {
3289
3631
  fontSize: 'var(--text-xs)',
3290
3632
  color: 'color-mix(in oklab, var(--toast-ink) 75%, transparent)',
3291
3633
  },
3634
+ selectors: { [TOAST_MARKED]: { gridColumn: '2' } },
3292
3635
  },
3293
3636
  action: {
3294
3637
  base: {
@@ -3309,6 +3652,7 @@ export const toast = {
3309
3652
  disabled: { opacity: 'var(--disabled-opacity)', cursor: 'not-allowed' },
3310
3653
  ...focusRing,
3311
3654
  },
3655
+ selectors: { [TOAST_MARKED]: { gridColumn: '3' } },
3312
3656
  },
3313
3657
  close: {
3314
3658
  base: {
@@ -3328,6 +3672,7 @@ export const toast = {
3328
3672
  disabled: { opacity: 'var(--disabled-opacity)' },
3329
3673
  ...focusRing,
3330
3674
  },
3675
+ selectors: { [TOAST_MARKED]: { gridColumn: '4' } },
3331
3676
  },
3332
3677
  },
3333
3678
  variants: {
@@ -3364,6 +3709,7 @@ export const toast = {
3364
3709
  // signalxjs/lynx#1070. No `color` default: the un-attributed toast is a
3365
3710
  // neutral base-200 surface, outside the color vocabulary.
3366
3711
  defaultVariants: { size: 'md' },
3712
+ keyframes: { 'zero-daisyui-toast-spin': 'to { transform: rotate(360deg); }' },
3367
3713
  targets: {
3368
3714
  lynx: {
3369
3715
  parts: {
@@ -3417,16 +3763,25 @@ export const toast = {
3417
3763
  // the same ink at 75% opacity reads the same on the soft fill.
3418
3764
  description: { base: { color: 'var(--toast-ink)', opacity: '0.75' } },
3419
3765
  // The 25% ink border cannot bake either: it takes the full ink.
3766
+ // Their rings' gap is the card's own fill.
3420
3767
  action: {
3421
3768
  base: {
3422
3769
  marginTop: 'var(--space-xs)',
3423
3770
  border: 'var(--border) solid var(--toast-ink)',
3424
3771
  },
3772
+ states: lynxFocus({ gap: 'var(--toast-bg)' }),
3773
+ },
3774
+ close: {
3775
+ base: { position: 'absolute', top: 'var(--space-xs)', right: 'var(--space-xs)' },
3776
+ states: lynxFocus({ gap: 'var(--toast-bg)' }),
3425
3777
  },
3426
- close: { base: { position: 'absolute', top: 'var(--space-xs)', right: 'var(--space-xs)' } },
3427
3778
  },
3428
3779
  },
3429
3780
  },
3781
+ // The viewport's `open` (the stack expanded) is a fact this skin has no
3782
+ // use for: its toasts are always a plain column, so both states look the
3783
+ // same by design (#292).
3784
+ skipStates: { viewport: ['open', 'closed'] },
3430
3785
  };
3431
3786
  export const combobox = {
3432
3787
  component: 'combobox',
@@ -4226,12 +4581,28 @@ const treeRowSelectors = {
4226
4581
  background: 'var(--color-base-300)',
4227
4582
  },
4228
4583
  };
4584
+ /**
4585
+ * `tickGlyphFallback` for the node check box, whose tick is its `::after`:
4586
+ * the geometry drops and the same pseudo carries the glyph, in the medium's
4587
+ * own ink (the #233 divergence, restated).
4588
+ */
4589
+ const nodeTickFallback = (ink) => ({
4590
+ selectors: {
4591
+ '&::after': { clipPath: 'none', backgroundColor: '#0000', rotate: '0deg', inset: '0', display: 'grid', placeItems: 'center', color: ink, fontSize: '0.85em', lineHeight: '1' },
4592
+ '&[data-state="checked"]::after': { content: '"✔︎"', clipPath: 'none', rotate: '0deg' },
4593
+ '&[data-state="indeterminate"]::after': { content: '"−"', clipPath: 'none', rotate: '0deg', translate: 'none' },
4594
+ },
4595
+ });
4229
4596
  export const treeView = {
4230
4597
  component: 'tree-view',
4231
4598
  tokens: {
4232
4599
  '--tree-accent': 'var(--color-primary)',
4233
4600
  '--tree-text': 'var(--text-sm)',
4234
4601
  '--tree-on-accent': 'var(--color-primary-content)',
4602
+ // The node check box's pair — the tree's own accent, so a colour
4603
+ // variant retints the box with the row.
4604
+ '--tree-check': 'var(--tree-accent)',
4605
+ '--tree-on-check': 'var(--tree-on-accent)',
4235
4606
  },
4236
4607
  parts: {
4237
4608
  root: {
@@ -4280,6 +4651,58 @@ export const treeView = {
4280
4651
  'reduced-motion': { base: { transition: 'none' } },
4281
4652
  },
4282
4653
  },
4654
+ // daisy's `.checkbox` at row scale: the accent outline, the recessed
4655
+ // depth shade, the fill on check — and daisy's own tick, the six
4656
+ // clip-path points rotated 45°, drawn on the box's `::after` since
4657
+ // the part is one span. On a selected row (the accent fill) the pair
4658
+ // swaps, so the box never vanishes into the row it sits on.
4659
+ 'node-checkbox': {
4660
+ base: {
4661
+ display: 'inline-block',
4662
+ position: 'relative',
4663
+ flexShrink: '0',
4664
+ boxSizing: 'border-box',
4665
+ width: '1.25em',
4666
+ height: '1.25em',
4667
+ border: 'var(--border) solid var(--tree-check)',
4668
+ borderRadius: 'var(--radius-selector)',
4669
+ boxShadow: '0 1px var(--depth-shade) inset',
4670
+ cursor: 'pointer',
4671
+ transition: 'background-color var(--duration-normal) var(--ease-standard)',
4672
+ },
4673
+ states: {
4674
+ checked: { backgroundColor: 'var(--tree-check)' },
4675
+ indeterminate: { backgroundColor: 'var(--tree-check)' },
4676
+ unchecked: {},
4677
+ disabled: { cursor: 'not-allowed' },
4678
+ },
4679
+ selectors: {
4680
+ '&::after': {
4681
+ content: '""',
4682
+ position: 'absolute',
4683
+ inset: '15%',
4684
+ opacity: '0',
4685
+ rotate: '45deg',
4686
+ backgroundColor: 'var(--tree-on-check)',
4687
+ clipPath: TICK_COLLAPSED,
4688
+ transition: 'clip-path var(--duration-slow) var(--ease-standard) var(--duration-instant), '
4689
+ + 'opacity var(--duration-instant) var(--ease-standard) var(--duration-instant), '
4690
+ + 'rotate var(--duration-slow) var(--ease-standard) var(--duration-instant), '
4691
+ + 'translate var(--duration-slow) var(--ease-standard) var(--duration-instant)',
4692
+ },
4693
+ '&[data-state="checked"]::after': { clipPath: TICK_DRAWN, opacity: '1' },
4694
+ '&[data-state="indeterminate"]::after': { clipPath: DASH_DRAWN, opacity: '1', translate: '0 -35%', rotate: '0deg' },
4695
+ '[data-scope="tree-view"][data-selected] &': {
4696
+ '--tree-check': 'var(--tree-on-accent)',
4697
+ '--tree-on-check': 'var(--tree-accent)',
4698
+ },
4699
+ },
4700
+ at: {
4701
+ 'reduced-motion': { base: { transition: 'none' }, selectors: { '&::after': { transition: 'none' } } },
4702
+ 'forced-colors': nodeTickFallback('CanvasText'),
4703
+ print: nodeTickFallback('var(--print-ink)'),
4704
+ },
4705
+ },
4283
4706
  // Indentation comes from this inline padding — depth is the DOM
4284
4707
  // nesting, no per-level rules needed.
4285
4708
  'branch-content': {
@@ -4560,6 +4983,36 @@ export const card = {
4560
4983
  overflow: 'hidden',
4561
4984
  },
4562
4985
  },
4986
+ /**
4987
+ * Media (#302) — daisy's `card > figure`: a centred flex box, flush
4988
+ * to the card's edges, the image filling it.
4989
+ */
4990
+ media: {
4991
+ base: {
4992
+ display: 'flex',
4993
+ alignItems: 'center',
4994
+ justifyContent: 'center',
4995
+ margin: '0',
4996
+ inlineSize: '100%',
4997
+ objectFit: 'cover',
4998
+ },
4999
+ selectors: {
5000
+ // The media's own image, when the band wraps one (a div or a
5001
+ // figure): block-level, the band's full width, cropped rather
5002
+ // than stretched when the band is given a height.
5003
+ '& > :is(img, picture, video, svg, canvas)': {
5004
+ display: 'block',
5005
+ inlineSize: '100%',
5006
+ blockSize: '100%',
5007
+ objectFit: 'cover',
5008
+ },
5009
+ // The corners it shares with the card (#302): the root clips
5010
+ // too, but an asChild <img> is the band itself and rounds on
5011
+ // its own.
5012
+ '&:first-child': { borderStartStartRadius: 'var(--radius-box)', borderStartEndRadius: 'var(--radius-box)' },
5013
+ '&:last-child': { borderEndStartRadius: 'var(--radius-box)', borderEndEndRadius: 'var(--radius-box)' },
5014
+ },
5015
+ },
4563
5016
  header: {
4564
5017
  base: {
4565
5018
  display: 'flex',
@@ -4889,16 +5342,51 @@ export const divider = {
4889
5342
  tokens: { '--divider-ink': 'var(--color-base-300)', '--divider-thickness': 'var(--border)' },
4890
5343
  parts: {
4891
5344
  root: {
4892
- base: { border: 'none', background: 'var(--divider-ink)', alignSelf: 'stretch' },
5345
+ // The rule is two flex segments (#298), drawn as borders: with no
5346
+ // label they meet and read as one line, and a label sits between
5347
+ // them. Borders rather than a background, so forced colours keep
5348
+ // the line instead of revaluing it to Canvas.
5349
+ base: {
5350
+ display: 'flex',
5351
+ alignItems: 'center',
5352
+ border: 'none',
5353
+ alignSelf: 'stretch',
5354
+ },
4893
5355
  selectors: {
4894
- '&[data-orientation="horizontal"]': {
4895
- inlineSize: '100%',
4896
- blockSize: 'var(--divider-thickness)',
4897
- },
4898
- '&[data-orientation="vertical"]': {
4899
- inlineSize: 'var(--divider-thickness)',
4900
- minBlockSize: '1em',
5356
+ '&::before': { content: '""', flex: '1 1 0', minInlineSize: '0' },
5357
+ '&::after': { content: '""', flex: '1 1 0', minInlineSize: '0' },
5358
+ '&[data-orientation="horizontal"]': { inlineSize: '100%' },
5359
+ '&[data-orientation="horizontal"]::before': { borderBlockStart: 'var(--divider-thickness) solid var(--divider-ink)' },
5360
+ '&[data-orientation="horizontal"]::after': { borderBlockStart: 'var(--divider-thickness) solid var(--divider-ink)' },
5361
+ '&[data-orientation="vertical"]': { flexDirection: 'column', minBlockSize: '1em' },
5362
+ '&[data-orientation="vertical"]::before': { borderInlineStart: 'var(--divider-thickness) solid var(--divider-ink)' },
5363
+ '&[data-orientation="vertical"]::after': { borderInlineStart: 'var(--divider-thickness) solid var(--divider-ink)' },
5364
+ // A placed label sits flush at its edge: the segment on
5365
+ // that side goes, and the other one takes the whole run.
5366
+ '&:has(> [data-scope="divider"][data-part="label"][data-placement="start"])::before': { display: 'none' },
5367
+ '&:has(> [data-scope="divider"][data-part="label"][data-placement="end"])::after': { display: 'none' },
5368
+ },
5369
+ },
5370
+ label: {
5371
+ base: {
5372
+ // daisy's `divider` text: the content colour, the rule
5373
+ // stopping a gap short of it on either side.
5374
+ flex: 'none',
5375
+ paddingInline: 'var(--space-md)',
5376
+ fontSize: 'var(--text-sm)',
5377
+ whiteSpace: 'nowrap',
5378
+ color: 'var(--color-base-content)',
5379
+ },
5380
+ selectors: {
5381
+ '[data-scope="divider"][data-part="root"][data-orientation="vertical"] > &': {
5382
+ paddingInline: '0',
5383
+ paddingBlock: 'var(--space-sm)',
4901
5384
  },
5385
+ // Flush means flush: no inset on the side the rule gave up.
5386
+ '&[data-placement="start"]': { paddingInlineStart: '0' },
5387
+ '&[data-placement="end"]': { paddingInlineEnd: '0' },
5388
+ '[data-scope="divider"][data-part="root"][data-orientation="vertical"] > &[data-placement="start"]': { paddingBlockStart: '0' },
5389
+ '[data-scope="divider"][data-part="root"][data-orientation="vertical"] > &[data-placement="end"]': { paddingBlockEnd: '0' },
4902
5390
  },
4903
5391
  },
4904
5392
  },
@@ -5441,6 +5929,10 @@ export const timeline = {
5441
5929
  },
5442
5930
  },
5443
5931
  },
5932
+ // Title and description (#302): daisy's timeline-box leads with a
5933
+ // bold line; the detail under it in the card description's ink.
5934
+ title: { base: { display: 'block', margin: '0', fontWeight: 'var(--weight-semibold)', lineHeight: 'var(--leading-tight)' } },
5935
+ description: { base: { display: 'block', margin: '0', marginBlockStart: 'var(--space-2xs)', color: 'color-mix(in oklab, var(--color-base-content) 70%, transparent)' } },
5444
5936
  },
5445
5937
  variants: {
5446
5938
  color: Object.fromEntries(ROLES.map((c) => [c, { marker: { base: {
@@ -5645,6 +6137,95 @@ export const chat = {
5645
6137
  },
5646
6138
  },
5647
6139
  };
6140
+ /**
6141
+ * ChatLog — daisy's `bg-base-200 rounded-box` well that the chat bubbles
6142
+ * already sit in, scrolling; the jump trigger is a `btn btn-sm` pill in the
6143
+ * role's solid pair with daisy's `shadow-xs` and its 1px press sink, floated
6144
+ * over the rows by `position: sticky` at the box's foot. Its negative block
6145
+ * margin gives back the line it would take, so appearing moves no row.
6146
+ */
6147
+ export const chatLog = {
6148
+ component: 'chat-log',
6149
+ tokens: {
6150
+ '--chat-log-fill': 'var(--color-primary)',
6151
+ '--chat-log-on-fill': 'var(--color-primary-content)',
6152
+ '--chat-log-jump-size': 'calc(var(--size-field) * 8)',
6153
+ },
6154
+ parts: {
6155
+ root: {
6156
+ base: {
6157
+ display: 'flex',
6158
+ flexDirection: 'column',
6159
+ overflowY: 'auto',
6160
+ overscrollBehaviorY: 'contain',
6161
+ borderRadius: 'var(--radius-box)',
6162
+ background: 'var(--color-base-100)',
6163
+ color: 'var(--color-base-content)',
6164
+ border: 'var(--border) solid var(--color-base-300)',
6165
+ padding: 'var(--space-sm) var(--space-md)',
6166
+ },
6167
+ // Drawn inside the box (negative offset): an outward ring would
6168
+ // be clipped by whatever scrolls around the transcript.
6169
+ states: { 'focus-visible': { outline: '2px solid var(--color-base-content)', outlineOffset: '-2px' } },
6170
+ },
6171
+ content: {
6172
+ base: {
6173
+ // `auto` gathers a short transcript at the foot; a long one
6174
+ // overflows and the margin resolves to nothing.
6175
+ marginBlockStart: 'auto',
6176
+ flex: 'none',
6177
+ display: 'grid',
6178
+ rowGap: 'var(--space-xs)',
6179
+ },
6180
+ },
6181
+ 'jump-trigger': {
6182
+ base: {
6183
+ position: 'sticky',
6184
+ insetBlockEnd: 'var(--space-sm)',
6185
+ alignSelf: 'center',
6186
+ flex: 'none',
6187
+ marginBlockStart: 'calc(var(--chat-log-jump-size) * -1)',
6188
+ blockSize: 'var(--chat-log-jump-size)',
6189
+ appearance: 'none',
6190
+ display: 'inline-flex',
6191
+ alignItems: 'center',
6192
+ gap: 'var(--space-2xs)',
6193
+ paddingInline: 'var(--space-md)',
6194
+ border: 'var(--border) solid transparent',
6195
+ borderRadius: '9999px',
6196
+ background: 'var(--chat-log-fill)',
6197
+ color: 'var(--chat-log-on-fill)',
6198
+ boxShadow: 'var(--shadow-xs)',
6199
+ fontFamily: 'inherit',
6200
+ fontSize: 'var(--text-xs)',
6201
+ fontWeight: 'var(--weight-semibold)',
6202
+ lineHeight: 'var(--leading-none)',
6203
+ cursor: 'pointer',
6204
+ transition: 'background var(--duration-fast) var(--ease-standard)',
6205
+ },
6206
+ states: {
6207
+ open: {},
6208
+ closed: {},
6209
+ hover: { background: 'color-mix(in oklab, var(--chat-log-fill) 90%, black)' },
6210
+ 'focus-visible': { outline: '2px solid var(--color-base-content)', outlineOffset: '2px' },
6211
+ },
6212
+ selectors: { '&[data-pressed]:not([data-disabled])': { transform: 'translateY(1px)', boxShadow: 'none' } },
6213
+ },
6214
+ },
6215
+ variants: {
6216
+ color: Object.fromEntries(ROLES.map((c) => [c, { 'jump-trigger': { base: {
6217
+ '--chat-log-fill': `var(--color-${c})`,
6218
+ '--chat-log-on-fill': `var(--color-${c}-content)`,
6219
+ } } }])),
6220
+ size: {
6221
+ xs: { content: { base: { rowGap: 'var(--space-2xs)' } }, 'jump-trigger': { base: { '--chat-log-jump-size': 'calc(var(--size-field) * 6)', fontSize: 'var(--text-xs)' } } },
6222
+ sm: { content: { base: { rowGap: 'var(--space-2xs)' } }, 'jump-trigger': { base: { '--chat-log-jump-size': 'calc(var(--size-field) * 7)', fontSize: 'var(--text-xs)' } } },
6223
+ md: {},
6224
+ lg: { content: { base: { rowGap: 'var(--space-sm)' } }, 'jump-trigger': { base: { '--chat-log-jump-size': 'calc(var(--size-field) * 9)', fontSize: 'var(--text-sm)' } } },
6225
+ xl: { content: { base: { rowGap: 'var(--space-md)' } }, 'jump-trigger': { base: { '--chat-log-jump-size': 'calc(var(--size-field) * 10)', fontSize: 'var(--text-sm)' } } },
6226
+ },
6227
+ },
6228
+ };
5648
6229
  /** daisy radial-progress: --value became --progress-percent; same ring. */
5649
6230
  export const radialProgress = {
5650
6231
  component: 'radial-progress',
@@ -6031,6 +6612,8 @@ export const breadcrumbs = {
6031
6612
  alignItems: 'center',
6032
6613
  gap: 'var(--space-xs)',
6033
6614
  },
6615
+ // `closed` is presence: the runtime hides the part (#295).
6616
+ states: { open: {}, closed: {} },
6034
6617
  },
6035
6618
  link: {
6036
6619
  base: {
@@ -6059,6 +6642,37 @@ export const breadcrumbs = {
6059
6642
  userSelect: 'none',
6060
6643
  },
6061
6644
  },
6645
+ ellipsis: {
6646
+ base: {
6647
+ display: 'flex',
6648
+ alignItems: 'center',
6649
+ gap: 'var(--space-xs)',
6650
+ },
6651
+ // `closed` is presence: the runtime hides the part (#295).
6652
+ states: { open: {}, closed: {} },
6653
+ },
6654
+ 'ellipsis-trigger': {
6655
+ base: {
6656
+ appearance: 'none',
6657
+ background: 'transparent',
6658
+ border: '0',
6659
+ padding: '0 var(--space-2xs)',
6660
+ margin: '0',
6661
+ font: 'inherit',
6662
+ lineHeight: 'inherit',
6663
+ color: 'color-mix(in oklch, var(--color-base-content) 75%, transparent)',
6664
+ borderRadius: 'var(--radius-selector)',
6665
+ cursor: 'pointer',
6666
+ transition: 'color var(--duration-fast) var(--ease-standard)',
6667
+ },
6668
+ states: {
6669
+ hover: { color: 'var(--color-base-content)', textDecoration: 'underline' },
6670
+ 'focus-visible': {
6671
+ outline: '2px solid var(--color-primary)',
6672
+ outlineOffset: '2px',
6673
+ },
6674
+ },
6675
+ },
6062
6676
  },
6063
6677
  variants: {
6064
6678
  color: Object.fromEntries(ROLES.map((c) => [c, { root: { base: {
@@ -6073,11 +6687,46 @@ export const breadcrumbs = {
6073
6687
  },
6074
6688
  },
6075
6689
  };
6690
+ /**
6691
+ * The four pagination triggers — prev/next and, with `withEdges`, the
6692
+ * first/last jumps (#294) — are one cell: the same box, the same
6693
+ * glyph-sized type, and the same flip under the rtl guard, since every
6694
+ * glyph (`‹ › « »`) is physical ink pointing at a reading edge.
6695
+ */
6696
+ const pageTrigger = {
6697
+ base: {
6698
+ display: 'inline-flex',
6699
+ alignItems: 'center',
6700
+ justifyContent: 'center',
6701
+ minInlineSize: 'var(--pg-size)',
6702
+ blockSize: 'var(--pg-size)',
6703
+ background: 'var(--color-base-200)',
6704
+ color: 'var(--color-base-content)',
6705
+ border: 'none',
6706
+ borderRadius: 'var(--radius-field)',
6707
+ fontSize: 'calc(var(--pg-font) * 1.2)',
6708
+ lineHeight: 'var(--leading-none)',
6709
+ appearance: 'none',
6710
+ // Link mode renders an <a> (#294): no UA underline.
6711
+ textDecoration: 'none',
6712
+ cursor: 'pointer',
6713
+ transition: 'background var(--duration-fast) var(--ease-standard)',
6714
+ },
6715
+ states: {
6716
+ hover: { background: 'var(--color-base-300)' },
6717
+ disabled: { opacity: 'var(--disabled-opacity)', cursor: 'not-allowed' },
6718
+ ...focusRing,
6719
+ },
6720
+ selectors: {
6721
+ '&[data-pressed]:not([data-disabled])': { transform: 'translateY(1px)' },
6722
+ [`&${rtl}`]: { scale: '-1 1' },
6723
+ },
6724
+ };
6076
6725
  /**
6077
6726
  * Pagination — daisy's join-of-buttons made of standalone btn cells: the
6078
6727
  * quiet base-200 fill, base-300 hover, the current page inverted into the
6079
6728
  * accent pair (primary by default). Pressed is the daisy 1px sink. The
6080
- * `‹`/`›` glyphs flip under the shared rtl guard.
6729
+ * `‹ › « »` glyphs flip under the shared rtl guard.
6081
6730
  */
6082
6731
  export const pagination = {
6083
6732
  component: 'pagination',
@@ -6123,6 +6772,7 @@ export const pagination = {
6123
6772
  fontWeight: 'var(--weight-semibold)',
6124
6773
  fontVariantNumeric: 'tabular-nums',
6125
6774
  appearance: 'none',
6775
+ textDecoration: 'none',
6126
6776
  cursor: 'pointer',
6127
6777
  transition: 'background var(--duration-fast) var(--ease-standard), color var(--duration-fast) var(--ease-standard)',
6128
6778
  },
@@ -6150,60 +6800,10 @@ export const pagination = {
6150
6800
  userSelect: 'none',
6151
6801
  },
6152
6802
  },
6153
- 'prev-trigger': {
6154
- base: {
6155
- display: 'inline-flex',
6156
- alignItems: 'center',
6157
- justifyContent: 'center',
6158
- minInlineSize: 'var(--pg-size)',
6159
- blockSize: 'var(--pg-size)',
6160
- background: 'var(--color-base-200)',
6161
- color: 'var(--color-base-content)',
6162
- border: 'none',
6163
- borderRadius: 'var(--radius-field)',
6164
- fontSize: 'calc(var(--pg-font) * 1.2)',
6165
- lineHeight: 'var(--leading-none)',
6166
- appearance: 'none',
6167
- cursor: 'pointer',
6168
- transition: 'background var(--duration-fast) var(--ease-standard)',
6169
- },
6170
- states: {
6171
- hover: { background: 'var(--color-base-300)' },
6172
- disabled: { opacity: 'var(--disabled-opacity)', cursor: 'not-allowed' },
6173
- ...focusRing,
6174
- },
6175
- selectors: {
6176
- '&[data-pressed]:not([data-disabled])': { transform: 'translateY(1px)' },
6177
- [`&${rtl}`]: { scale: '-1 1' },
6178
- },
6179
- },
6180
- 'next-trigger': {
6181
- base: {
6182
- display: 'inline-flex',
6183
- alignItems: 'center',
6184
- justifyContent: 'center',
6185
- minInlineSize: 'var(--pg-size)',
6186
- blockSize: 'var(--pg-size)',
6187
- background: 'var(--color-base-200)',
6188
- color: 'var(--color-base-content)',
6189
- border: 'none',
6190
- borderRadius: 'var(--radius-field)',
6191
- fontSize: 'calc(var(--pg-font) * 1.2)',
6192
- lineHeight: 'var(--leading-none)',
6193
- appearance: 'none',
6194
- cursor: 'pointer',
6195
- transition: 'background var(--duration-fast) var(--ease-standard)',
6196
- },
6197
- states: {
6198
- hover: { background: 'var(--color-base-300)' },
6199
- disabled: { opacity: 'var(--disabled-opacity)', cursor: 'not-allowed' },
6200
- ...focusRing,
6201
- },
6202
- selectors: {
6203
- '&[data-pressed]:not([data-disabled])': { transform: 'translateY(1px)' },
6204
- [`&${rtl}`]: { scale: '-1 1' },
6205
- },
6206
- },
6803
+ 'first-trigger': pageTrigger,
6804
+ 'prev-trigger': pageTrigger,
6805
+ 'next-trigger': pageTrigger,
6806
+ 'last-trigger': pageTrigger,
6207
6807
  },
6208
6808
  variants: {
6209
6809
  color: Object.fromEntries(ROLES.map((c) => [c, { root: { base: {
@@ -6241,6 +6841,20 @@ export const pagination = {
6241
6841
  */
6242
6842
  const stepInk = (accent, role) => `color-mix(in oklab, ${accent} `
6243
6843
  + `${Math.min(ROLE_INK_KEEP[role] ?? 55, 70)}%, var(--color-base-content))`;
6844
+ /** A Steps root holding wizard parts (#296) — the panel or the triggers. */
6845
+ const STEPS_WIZARD = '&:has(> [data-scope="steps"]:is([data-part="content"], [data-part="prev-trigger"], [data-part="next-trigger"]))';
6846
+ /** Steps' Back/Next (#296): the neutral daisy btn, with its 1px sink. */
6847
+ const stepsTrigger = {
6848
+ base: { ...btn, flex: '0 0 auto', alignSelf: 'flex-start' },
6849
+ states: {
6850
+ hover: { background: 'var(--color-base-300)' },
6851
+ disabled: { opacity: 'var(--disabled-opacity)', cursor: 'not-allowed' },
6852
+ ...focusRing,
6853
+ },
6854
+ selectors: {
6855
+ '&[data-pressed]:not([data-disabled])': { transform: 'translateY(1px)' },
6856
+ },
6857
+ };
6244
6858
  /**
6245
6859
  * Steps — daisy's steps translated to the richer rail: bold numbered discs
6246
6860
  * on base-200, the walked disc and line refilled with the accent pair
@@ -6267,6 +6881,10 @@ export const steps = {
6267
6881
  },
6268
6882
  selectors: {
6269
6883
  '&[data-orientation="vertical"]': { flexDirection: 'column' },
6884
+ // A wizard (#296): the rail keeps its own line and the panel
6885
+ // and triggers wrap below it. Only then — a bare rail never
6886
+ // wraps, so its steps stay one row at any width.
6887
+ [STEPS_WIZARD]: { flexWrap: 'wrap', rowGap: 'var(--space-md)' },
6270
6888
  },
6271
6889
  },
6272
6890
  /**
@@ -6305,6 +6923,10 @@ export const steps = {
6305
6923
  active: { color: 'var(--steps-accent-ink)', fontWeight: 'var(--weight-semibold)' },
6306
6924
  complete: { color: 'var(--color-base-content)' },
6307
6925
  inactive: { color: 'color-mix(in oklch, var(--color-base-content) 65%, transparent)' },
6926
+ // daisy's `step-error`, whatever the phase: after the phases,
6927
+ // so it wins at equal weight. `stepInk`, not `roleInk`: the
6928
+ // 85% error ink measured 3.79:1 on light's page as text.
6929
+ invalid: { color: stepInk('var(--color-error)', 'error') },
6308
6930
  disabled: { opacity: 'var(--disabled-opacity)', cursor: 'not-allowed' },
6309
6931
  ...focusRing,
6310
6932
  },
@@ -6336,6 +6958,7 @@ export const steps = {
6336
6958
  // the disc (#112).
6337
6959
  complete: { background: 'color-mix(in oklab, var(--steps-accent) 20%, var(--color-base-100))', color: 'var(--steps-complete-ink)' },
6338
6960
  inactive: { background: 'var(--color-base-200)', color: 'var(--color-base-content)' },
6961
+ invalid: { background: 'var(--color-error)', color: 'var(--color-error-content)' },
6339
6962
  },
6340
6963
  },
6341
6964
  /**
@@ -6367,6 +6990,7 @@ export const steps = {
6367
6990
  states: {
6368
6991
  complete: { background: 'var(--steps-accent)' },
6369
6992
  inactive: { background: 'var(--color-base-300)' },
6993
+ invalid: { background: 'var(--color-error)' },
6370
6994
  },
6371
6995
  },
6372
6996
  title: {
@@ -6381,6 +7005,25 @@ export const steps = {
6381
7005
  fontWeight: 'var(--weight-normal)',
6382
7006
  },
6383
7007
  },
7008
+ /**
7009
+ * The active step's panel: a full-width line under the rail (the
7010
+ * wizard wrap above). Inactive panels are `hidden`, so they need no
7011
+ * rule.
7012
+ */
7013
+ content: {
7014
+ base: {
7015
+ flex: '1 0 100%',
7016
+ fontSize: 'var(--text-md)',
7017
+ color: 'var(--color-base-content)',
7018
+ },
7019
+ states: { active: {}, inactive: {} },
7020
+ },
7021
+ // Back/Next are daisy btns; Next takes the far end of their line.
7022
+ 'prev-trigger': stepsTrigger,
7023
+ 'next-trigger': {
7024
+ ...stepsTrigger,
7025
+ base: { ...stepsTrigger.base, marginInlineStart: 'auto' },
7026
+ },
6384
7027
  },
6385
7028
  variants: {
6386
7029
  // Keyed on the ITEM, not the root (#112): the item re-carries
@@ -7348,11 +7991,11 @@ export const diff = {
7348
7991
  },
7349
7992
  };
7350
7993
  export const recipes = [
7351
- tabs, collapsible, switchRecipe, dialog, popover, tooltip, menu,
7994
+ tabs, collapsible, switchRecipe, dialog, popover, tooltip, hoverCard, menu, menubar,
7352
7995
  field, fieldset, checkbox, checkboxGroup, radioGroup, progress, slider, accordion, select, button, avatar, toast, combobox,
7353
7996
  toggle, toggleGroup, numberInput, ratingGroup, treeView, input, textarea,
7354
7997
  card, alert, emptyState, badge, divider, skeleton, spinner,
7355
- kbd, status, indicator, stats, timeline, chat, radialProgress, join,
7998
+ kbd, status, indicator, stats, timeline, chat, chatLog, radialProgress, join,
7356
7999
  navbar, navList, breadcrumbs, pagination, steps, drawer,
7357
8000
  table,
7358
8001
  fileUpload,