@sigx/zero-daisyui 0.13.0 → 0.15.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 (54) hide show
  1. package/dist/audit.json +60 -0
  2. package/dist/css/components/combobox.css +5 -0
  3. package/dist/css/components/container.css +1 -0
  4. package/dist/css/components/input.css +9 -0
  5. package/dist/css/components/nav-list.css +7 -0
  6. package/dist/css/components/spinner.css +5 -5
  7. package/dist/css/index.css +27 -5
  8. package/dist/lynx/components/alert.css +49 -7
  9. package/dist/lynx/components/avatar-group.css +13 -1
  10. package/dist/lynx/components/avatar.css +56 -1
  11. package/dist/lynx/components/badge.css +209 -6
  12. package/dist/lynx/components/breadcrumbs.css +30 -21
  13. package/dist/lynx/components/button.css +2 -7
  14. package/dist/lynx/components/card.css +12 -2
  15. package/dist/lynx/components/carousel.css +4 -4
  16. package/dist/lynx/components/chat-log.css +5 -5
  17. package/dist/lynx/components/chat.css +1 -1
  18. package/dist/lynx/components/combobox.css +5 -0
  19. package/dist/lynx/components/container.css +3 -2
  20. package/dist/lynx/components/diff.css +1 -1
  21. package/dist/lynx/components/divider.css +53 -8
  22. package/dist/lynx/components/drawer.css +8 -8
  23. package/dist/lynx/components/empty-state.css +7 -1
  24. package/dist/lynx/components/fieldset.css +1 -1
  25. package/dist/lynx/components/grid.css +2 -2
  26. package/dist/lynx/components/kbd.css +16 -3
  27. package/dist/lynx/components/menu.css +20 -1
  28. package/dist/lynx/components/nav-list.css +15 -2
  29. package/dist/lynx/components/navbar.css +9 -5
  30. package/dist/lynx/components/pagination.css +97 -65
  31. package/dist/lynx/components/radial-progress.css +10 -10
  32. package/dist/lynx/components/select.css +1 -2
  33. package/dist/lynx/components/skeleton.css +1 -1
  34. package/dist/lynx/components/spinner.css +22 -12
  35. package/dist/lynx/components/stack.css +2 -2
  36. package/dist/lynx/components/stats.css +39 -3
  37. package/dist/lynx/components/status.css +197 -11
  38. package/dist/lynx/components/steps.css +114 -22
  39. package/dist/lynx/components/table.css +1 -1
  40. package/dist/lynx/components/tabs.css +1 -1
  41. package/dist/lynx/components/textarea.css +1 -1
  42. package/dist/lynx/components/timeline.css +12 -25
  43. package/dist/lynx/components/toast.css +331 -12
  44. package/dist/lynx/components/tree-view.css +31 -4
  45. package/dist/lynx/index.css +1382 -262
  46. package/dist/lynx/manifest.json +3 -3
  47. package/dist/manifest.json +1 -1
  48. package/dist/recipes.d.ts +6 -1
  49. package/dist/recipes.d.ts.map +1 -1
  50. package/dist/recipes.js +934 -25
  51. package/dist/recipes.js.map +1 -1
  52. package/dist/report.json +565 -89
  53. package/package.json +4 -4
  54. package/src/recipes.ts +939 -25
package/dist/recipes.js CHANGED
@@ -1558,12 +1558,34 @@ export const menu = {
1558
1558
  // Trigger-carried axes — same wiring as dialog, same reason. The dropdown
1559
1559
  // and its items are top-layer siblings the donut cannot reach.
1560
1560
  variants: { color: btnColors(), size: btnSizes },
1561
- // The btn paddings, restated physically — see `lynxBtnPad` (#1084).
1561
+ // The btn paddings, restated physically — see `lynxBtnPad` (#1084) —
1562
+ // and the trigger's press rendering, as popover's (`lynxBtnPressed`).
1562
1563
  targets: {
1563
1564
  web: floatingArrow('menu', panelArrowPaint),
1564
1565
  lynx: {
1565
1566
  parts: {
1566
- trigger: { base: lynxBtnPad(4) },
1567
+ // Content-sized like daisy's btn (signalxjs/lynx#1165), an
1568
+ // explicit row (lynx does not default the direction the way a
1569
+ // browser does), the held state in place of the dropped
1570
+ // `:hover`, and the box-shadow ring (signalxjs/lynx#1163).
1571
+ trigger: {
1572
+ base: { ...lynxBtnPad(4), ...lynxBtnFit, flexDirection: 'row' },
1573
+ states: { pressed: lynxBtnPressed, ...lynxFocus() },
1574
+ },
1575
+ // The popups' rows stack and span the panel: a flex column
1576
+ // (a lynx view's default is a shrink-wrapping linear box).
1577
+ popup: { base: { display: 'flex', flexDirection: 'column' } },
1578
+ 'sub-popup': { base: { display: 'flex', flexDirection: 'column' } },
1579
+ // Every row kind is a mark / label / shortcut / chevron row.
1580
+ // A touch list has no hover: lynx-zero stamps `highlighted` on
1581
+ // the row under the finger, so the web's highlight wash is the
1582
+ // press rendering as it stands. The ✓ (item-indicator) and ›
1583
+ // (sub-trigger) the web draws with `::after` render as
1584
+ // `<text>` glyphs in lynx-zero — there is no pseudo-element.
1585
+ item: { base: { flexDirection: 'row' } },
1586
+ 'checkbox-item': { base: { flexDirection: 'row' } },
1587
+ 'radio-item': { base: { flexDirection: 'row' } },
1588
+ 'sub-trigger': { base: { flexDirection: 'row' } },
1567
1589
  // The shortcut's reading-end push, restated physically (#1084).
1568
1590
  shortcut: { base: { marginLeft: 'auto', paddingLeft: 'var(--space-lg)' } },
1569
1591
  },
@@ -3674,6 +3696,80 @@ export const avatar = {
3674
3696
  xl: { root: { base: { '--avatar-size': 'calc(var(--size-selector) * 16)', '--avatar-text': 'var(--text-lg)' } } },
3675
3697
  },
3676
3698
  },
3699
+ // signalxjs/lynx#1237 — the lynx layout for what the web gets from grid.
3700
+ targets: {
3701
+ lynx: {
3702
+ parts: {
3703
+ // No grid on lynx (signalxjs/lynx#1075): the web stacks the
3704
+ // image and the fallback in one grid cell. Here the root is a
3705
+ // flex box that does not shrink in a row, and both faces are
3706
+ // laid over it absolutely — the fallback, later in the tree,
3707
+ // paints over a pending image exactly as the grid's second
3708
+ // item does. lynx-zero renders neither face while its state
3709
+ // hides it (`hiddenIn`), so the web's `&:not([hidden])`
3710
+ // display rule has nothing to replace.
3711
+ root: {
3712
+ base: {
3713
+ display: 'flex',
3714
+ flexDirection: 'row',
3715
+ alignItems: 'center',
3716
+ justifyContent: 'center',
3717
+ flexShrink: '0',
3718
+ },
3719
+ },
3720
+ // Each face also rounds itself to the root's shape (below),
3721
+ // rather than trusting the root's clip to round an image on
3722
+ // both engines.
3723
+ image: {
3724
+ base: { position: 'absolute', top: '0', left: '0', borderRadius: '9999px' },
3725
+ },
3726
+ fallback: {
3727
+ base: {
3728
+ position: 'absolute',
3729
+ top: '0',
3730
+ left: '0',
3731
+ borderRadius: '9999px',
3732
+ display: 'flex',
3733
+ alignItems: 'center',
3734
+ justifyContent: 'center',
3735
+ textAlign: 'center',
3736
+ },
3737
+ },
3738
+ },
3739
+ variants: {
3740
+ shape: Object.fromEntries([
3741
+ ['circle', '9999px'], ['square', '0'], ['rounded', 'var(--radius-selector)'],
3742
+ ].map(([shape, radius]) => [shape, {
3743
+ image: { base: { borderRadius: radius } },
3744
+ fallback: { base: { borderRadius: radius } },
3745
+ }])),
3746
+ },
3747
+ // Every avatar after the first in an AvatarGroup overlaps its
3748
+ // neighbour by a quarter of its own box — the web's
3749
+ // `avatar-group` compose, `&:not(:first-child)`. Lynx has neither
3750
+ // the nested-scope compose nor `:first-child`, so lynx-zero stamps
3751
+ // those avatars `stacked` itself (`zx-m-stacked`, in mount order).
3752
+ // A lynx-zero rendering detail, not a modifier an author sets, so
3753
+ // it is styled from raw lynx css, one rule per avatar size.
3754
+ css: `
3755
+ .zx-avatar__root.zx-m-stacked {
3756
+ margin-left: calc(var(--size-selector) * -2.5);
3757
+ }
3758
+ .zx-avatar__root.zx-m-stacked.zx-a-size-xs {
3759
+ margin-left: calc(var(--size-selector) * -1.5);
3760
+ }
3761
+ .zx-avatar__root.zx-m-stacked.zx-a-size-sm {
3762
+ margin-left: calc(var(--size-selector) * -2);
3763
+ }
3764
+ .zx-avatar__root.zx-m-stacked.zx-a-size-lg {
3765
+ margin-left: calc(var(--size-selector) * -3);
3766
+ }
3767
+ .zx-avatar__root.zx-m-stacked.zx-a-size-xl {
3768
+ margin-left: calc(var(--size-selector) * -4);
3769
+ }
3770
+ `,
3771
+ },
3772
+ },
3677
3773
  };
3678
3774
  /**
3679
3775
  * Avatar group (#297) — daisy's `avatar-group`: the faces overlap by a
@@ -3766,6 +3862,33 @@ export const avatarGroup = {
3766
3862
  md: {},
3767
3863
  },
3768
3864
  },
3865
+ // signalxjs/lynx#1237. The avatars inside take the group's size and
3866
+ // colour from lynx-zero (the `composes` above has no class form on this
3867
+ // target), and their overlap is the avatar recipe's `stacked` rule.
3868
+ targets: {
3869
+ lynx: {
3870
+ parts: {
3871
+ // A row that sizes to its faces (a lynx view stretches to its
3872
+ // column otherwise).
3873
+ root: { base: { display: 'flex', flexDirection: 'row', width: 'max-content' } },
3874
+ // `inline-grid` centring as flex, and the logical inline
3875
+ // padding and overlap as physical spellings — logical
3876
+ // margins and paddings resolve on iOS but not on Android
3877
+ // (signalxjs/lynx#1084); lynx has no RTL flow, so start IS left.
3878
+ overflow: {
3879
+ base: {
3880
+ display: 'flex',
3881
+ alignItems: 'center',
3882
+ justifyContent: 'center',
3883
+ flexShrink: '0',
3884
+ paddingLeft: 'var(--space-2xs)',
3885
+ paddingRight: 'var(--space-2xs)',
3886
+ marginLeft: 'calc(var(--avatar-group-size) * -0.25)',
3887
+ },
3888
+ },
3889
+ },
3890
+ },
3891
+ },
3769
3892
  };
3770
3893
  /**
3771
3894
  * A toast whose indicator is rendered (#292): the grid grows a leading column
@@ -4059,7 +4182,99 @@ export const toast = {
4059
4182
  base: { position: 'absolute', top: 'var(--space-xs)', right: 'var(--space-xs)' },
4060
4183
  states: lynxFocus({ gap: 'var(--toast-bg)' }),
4061
4184
  },
4185
+ // The promise toast's mark (signalxjs/lynx#1196). No grid and
4186
+ // no `:has()`: the mark is laid over the card's leading
4187
+ // padding, at the root's own padding offsets (per size,
4188
+ // below), and lynx-zero stamps `marked` on every part of a
4189
+ // toast that shows it, so the text steps aside (the raw css
4190
+ // below) — the web's leading grid column.
4191
+ indicator: {
4192
+ base: {
4193
+ position: 'absolute',
4194
+ left: 'var(--space-lg)',
4195
+ top: 'var(--space-md)',
4196
+ width: 'var(--toast-mark)',
4197
+ height: 'var(--toast-mark)',
4198
+ },
4199
+ states: {
4200
+ // daisy's ring in the toast's ink. The track is the
4201
+ // ink at 25%, which cannot bake over the recipe-local
4202
+ // `--toast-ink`: the neutral toast's track mixes the
4203
+ // theme's base-content, and each colour's twin (the
4204
+ // variants below) its role. Longhands, and the head as
4205
+ // `border-top-color`: logical spellings are not lynx's
4206
+ // (signalxjs/lynx#1084), and a var-bearing shorthand
4207
+ // would repaint the head after the cascade (#1161).
4208
+ loading: {
4209
+ // The shared shorthand is dropped on lynx (its
4210
+ // track cannot bake); a static one here keeps it
4211
+ // that way should the shared value ever bake.
4212
+ border: '0 solid transparent',
4213
+ borderStyle: 'solid',
4214
+ ...lynxSides('border{}Width', 'calc(var(--border) * 2)'),
4215
+ ...lynxSides('border{}Color', 'color-mix(in oklab, var(--color-base-content) 25%, transparent)', ['Right', 'Bottom', 'Left']),
4216
+ borderTopColor: 'var(--toast-ink)',
4217
+ },
4218
+ // The tick without clip-path, which lynx does not apply
4219
+ // (signalxjs/lynx#1216): the checkbox's two-border
4220
+ // check — a box 3/8 of the mark wide and 3/4 tall with
4221
+ // its right and bottom borders drawn, turned 45° —
4222
+ // pulled to the middle of the mark's slot by its
4223
+ // margins. The fill the web cuts the tick from is
4224
+ // cleared.
4225
+ complete: {
4226
+ background: 'transparent',
4227
+ boxSizing: 'border-box',
4228
+ width: 'calc(var(--toast-mark) * 0.375)',
4229
+ height: 'calc(var(--toast-mark) * 0.75)',
4230
+ marginLeft: 'calc(var(--toast-mark) * 0.3125)',
4231
+ borderStyle: 'solid',
4232
+ borderColor: TOAST_SUCCESS_INK,
4233
+ borderTopWidth: '0',
4234
+ borderLeftWidth: '0',
4235
+ borderRightWidth: 'calc(var(--border) * 2)',
4236
+ borderBottomWidth: 'calc(var(--border) * 2)',
4237
+ transform: 'rotate(45deg)',
4238
+ },
4239
+ // The cross without clip-path: its two diagonal bars
4240
+ // as hard-stop gradients over a clear fill.
4241
+ error: {
4242
+ background: `linear-gradient(45deg, transparent 41%, ${TOAST_ERROR_INK} 41%, ${TOAST_ERROR_INK} 59%, transparent 59%), `
4243
+ + `linear-gradient(-45deg, transparent 41%, ${TOAST_ERROR_INK} 41%, ${TOAST_ERROR_INK} 59%, transparent 59%)`,
4244
+ },
4245
+ },
4246
+ },
4247
+ },
4248
+ variants: {
4249
+ // Each colour's ring track: its role at 25% (the web's mix
4250
+ // over `--toast-ink`, which is that role).
4251
+ color: Object.fromEntries(ROLES.map((role) => [role, {
4252
+ indicator: {
4253
+ states: {
4254
+ loading: lynxSides('border{}Color', `color-mix(in oklab, var(--color-${role}) 25%, transparent)`, ['Right', 'Bottom', 'Left']),
4255
+ },
4256
+ },
4257
+ }])),
4258
+ // The mark sits at the root's padding offsets for its size.
4259
+ size: {
4260
+ xs: { indicator: { base: { left: 'var(--space-md)', top: 'var(--space-xs)' } } },
4261
+ sm: { indicator: { base: { left: 'var(--space-md)', top: 'var(--space-sm)' } } },
4262
+ lg: { indicator: { base: { left: 'var(--space-xl)', top: 'var(--space-lg)' } } },
4263
+ xl: { indicator: { base: { left: 'var(--space-2xl)', top: 'var(--space-xl)' } } },
4264
+ },
4062
4265
  },
4266
+ // The text of a marked toast steps past the mark and the web's
4267
+ // column gap. `marked` is a lynx-zero rendering detail (the
4268
+ // spelling of `:has(> indicator)`), not a modifier an author sets,
4269
+ // so it is styled from raw lynx css. The reduced-motion and
4270
+ // forced-colors stops cannot follow: lynx emits no `@media`.
4271
+ css: `
4272
+ .zx-toast__title.zx-m-marked,
4273
+ .zx-toast__description.zx-m-marked,
4274
+ .zx-toast__action.zx-m-marked {
4275
+ margin-left: calc(var(--toast-mark) + var(--space-md));
4276
+ }
4277
+ `,
4063
4278
  },
4064
4279
  },
4065
4280
  // The viewport's `open` (the stack expanded) is a fact this skin has no
@@ -4154,7 +4369,8 @@ export const combobox = {
4154
4369
  fontWeight: 'var(--weight-medium)',
4155
4370
  lineHeight: 'var(--leading-none)',
4156
4371
  },
4157
- states: { disabled: {} },
4372
+ // Arrowed onto from the input (#411).
4373
+ states: { disabled: {}, ...focusRing },
4158
4374
  },
4159
4375
  'tag-label': { base: { whiteSpace: 'nowrap' } },
4160
4376
  'tag-remove': {
@@ -5059,6 +5275,28 @@ const treeRowSelectors = {
5059
5275
  background: 'var(--color-base-300)',
5060
5276
  },
5061
5277
  };
5278
+ /**
5279
+ * The tree rows' lynx states (signalxjs/lynx#1256). The web's press wash is
5280
+ * `[data-pressed]:not([data-disabled]):not([data-selected])`; lynx has no
5281
+ * absence classes, so the wash is the plain `pressed` state and a selected
5282
+ * row restores its accent fill below. The web's descendant rule that swaps
5283
+ * the check box's pair on a selected row (`[data-selected] &` on the box)
5284
+ * becomes the ROW's own custom properties: they inherit to the box inside
5285
+ * it. The focus ring is inset (rows sit flush) and drawn as a box-shadow,
5286
+ * which follows the row's radius where lynx's `outline` does not.
5287
+ */
5288
+ const lynxTreeRowStates = {
5289
+ pressed: { background: 'var(--color-base-300)' },
5290
+ selected: {
5291
+ '--tree-check': 'var(--tree-on-accent)',
5292
+ '--tree-on-check': 'var(--tree-accent)',
5293
+ },
5294
+ ...lynxFocus({ inset: true }),
5295
+ };
5296
+ /** A held selected row keeps its accent fill (the web's `:not([data-selected])`). */
5297
+ const lynxTreeRowSelectors = {
5298
+ '&[data-selected][data-pressed]': { background: 'var(--tree-accent)' },
5299
+ };
5062
5300
  /**
5063
5301
  * `tickGlyphFallback` for the node check box, whose tick is its `::after`:
5064
5302
  * the geometry drops and the same pseudo carries the glyph, in the medium's
@@ -5208,12 +5446,37 @@ export const treeView = {
5208
5446
  xl: { root: { base: { '--tree-text': 'var(--text-lg)' } } },
5209
5447
  },
5210
5448
  },
5211
- // The indent, restated physically: logical spellings resolve on iOS but
5212
- // not on Android (measured, signalxjs/lynx#1084), so the emitter refuses
5213
- // them. Physical is the lynx target's norm — no RTL flow there.
5449
+ // lynx (signalxjs/lynx#1256, zero#478). No hover on a touch platform, no
5450
+ // `:not()`, no pseudo-elements, no descendant selectors: each web rule
5451
+ // those carry is restated on the part the runtime stamps. The skin's
5452
+ // `:dir(rtl)` mirror and the `@media` stops have no lynx form (no RTL
5453
+ // flow, no conditions) and stay dropped.
5214
5454
  targets: {
5215
5455
  lynx: {
5216
5456
  parts: {
5457
+ item: { states: lynxTreeRowStates, selectors: lynxTreeRowSelectors },
5458
+ 'branch-trigger': { states: lynxTreeRowStates, selectors: lynxTreeRowSelectors },
5459
+ // `inline-block` is not a lynx display value. The glyph is a
5460
+ // `<text>` part: a 1em box centred on it, so the quarter turn
5461
+ // pivots on the chevron instead of swinging it sideways.
5462
+ 'branch-indicator': { base: { display: 'flex', width: '1em', textAlign: 'center' } },
5463
+ // The mark is a `<text>` child lynx-zero renders (`✓` checked,
5464
+ // `−` indeterminate) in place of the web's clip-path `::after`
5465
+ // tick — clip-path and pseudo-elements are both dropped on
5466
+ // lynx. The glyph is the forced-colors fallback's shape
5467
+ // (`nodeTickFallback`), inked with the box's `-on` pair and
5468
+ // sized by inheritance from the row.
5469
+ 'node-checkbox': {
5470
+ base: {
5471
+ display: 'flex',
5472
+ alignItems: 'center',
5473
+ justifyContent: 'center',
5474
+ color: 'var(--tree-on-check)',
5475
+ },
5476
+ },
5477
+ // The indent, restated physically: logical spellings resolve
5478
+ // on iOS but not on Android (measured, signalxjs/lynx#1084),
5479
+ // so the emitter refuses them.
5217
5480
  'branch-content': { base: { paddingLeft: 'var(--space-lg)' } },
5218
5481
  },
5219
5482
  },
@@ -5314,6 +5577,10 @@ export const input = {
5314
5577
  selectors: {
5315
5578
  // Same muting, same reason as `number-input/input` (#264).
5316
5579
  '&::placeholder': { color: 'color-mix(in oklab, var(--color-base-content) 60%, transparent)' },
5580
+ // zero draws its own ClearTrigger and clears on Escape, and Firefox
5581
+ // draws no native clear: hide the engine's cancel button everywhere (#446).
5582
+ '&::-webkit-search-cancel-button': { appearance: 'none', display: 'none' },
5583
+ '&::-webkit-search-decoration': { appearance: 'none' },
5317
5584
  },
5318
5585
  },
5319
5586
  // daisy's `label` inside `.input`: a quiet prefix/suffix at one edge,
@@ -5510,11 +5777,18 @@ export const textarea = {
5510
5777
  // lands on top of the 2px gap and paints one solid band. A fixed-height
5511
5778
  // box never re-lays out; this auto-height one does, as its native field
5512
5779
  // measures and grows. So the ring is spelled with one outset shadow and
5513
- // one inset shadow, which paint disjoint areas and cannot swap: the ring
5780
+ // inset shadows, which paint areas the outset never reaches: the ring
5514
5781
  // just outside the box, the box's own border repainted as the base-100
5515
- // gap, and the hairline redrawn inside it by the inset spread, in the
5782
+ // gap, and the hairline redrawn inside it by the edge inset's spread, in the
5516
5783
  // edge colour (`--textarea-edge`, the error colour while invalid). The
5517
5784
  // gap is the border's width instead of the web's 2px.
5785
+ //
5786
+ // Android paints inset shadows OVER the border, where iOS paints them
5787
+ // under it (signalxjs/lynx#1230): the base-100 border vanished and the
5788
+ // whole 2px inset showed as a grey band against the ring. So the gap is
5789
+ // also drawn as a 1px base-100 inset stacked above the edge inset. On
5790
+ // Android that inset is the gap. On iOS the border already covers it,
5791
+ // whichever order the layers end up in, so iOS paints as before.
5518
5792
  targets: {
5519
5793
  lynx: {
5520
5794
  parts: {
@@ -5531,6 +5805,9 @@ export const textarea = {
5531
5805
  outline: 'none',
5532
5806
  borderColor: 'var(--color-base-100)',
5533
5807
  boxShadow: '0 0 0 2px var(--textarea-accent), '
5808
+ // The gap: the border's width in base-100,
5809
+ // for Android, which paints this over the border.
5810
+ + 'inset 0 0 0 1px var(--color-base-100), '
5534
5811
  // Twice the border (1px in every shipped
5535
5812
  // theme): the inner half shows as the hairline.
5536
5813
  + 'inset 0 0 0 2px var(--textarea-edge)',
@@ -5656,6 +5933,42 @@ export const card = {
5656
5933
  xl: { root: { base: { '--card-pad': 'var(--space-2xl)' } } },
5657
5934
  },
5658
5935
  },
5936
+ targets: {
5937
+ lynx: {
5938
+ parts: {
5939
+ // The root does not clip on lynx: a clipping layer takes its
5940
+ // own shadow with it on iOS, and the card's elevation is the
5941
+ // daisy look. The media band rounds its own corners instead
5942
+ // (below), which is all the clip was for.
5943
+ root: { base: { overflow: 'visible' } },
5944
+ // `inline-size` has no lynx spelling; the image selector and
5945
+ // the `:first-child` / `:last-child` corners are dropped (no
5946
+ // child or structural pseudo-classes). The band clips its
5947
+ // own content, so an `<image>` inside it takes the corners.
5948
+ media: { base: { width: '100%', overflow: 'hidden' } },
5949
+ // A lynx view is a column unless told otherwise.
5950
+ footer: { base: { flexDirection: 'row' } },
5951
+ },
5952
+ // The corners the media band shares with the card
5953
+ // (signalxjs/lynx#1235). Lynx has no `:first-child` or
5954
+ // `:last-child`, so lynx-zero stamps the band itself from its
5955
+ // mount order among the card's bands, as `data-mod-first` /
5956
+ // `data-mod-last` (the `zx-m-first` / `zx-m-last` classes). They
5957
+ // are a lynx-zero rendering detail, not modifiers an author sets,
5958
+ // so they are not declared in the vocabulary and are styled from
5959
+ // raw lynx css (toggle-group's join ends are the precedent).
5960
+ css: `
5961
+ .zx-card__media.zx-m-first {
5962
+ border-top-left-radius: var(--radius-box);
5963
+ border-top-right-radius: var(--radius-box);
5964
+ }
5965
+ .zx-card__media.zx-m-last {
5966
+ border-bottom-left-radius: var(--radius-box);
5967
+ border-bottom-right-radius: var(--radius-box);
5968
+ }
5969
+ `,
5970
+ },
5971
+ },
5659
5972
  };
5660
5973
  /**
5661
5974
  * daisy "alert" flavor: the role's soft tint edge to edge, its ink for the
@@ -5746,6 +6059,100 @@ export const alert = {
5746
6059
  xl: { root: { base: { padding: 'var(--space-xl) var(--space-2xl)' } } },
5747
6060
  },
5748
6061
  },
6062
+ targets: {
6063
+ // No grid on lynx (signalxjs/lynx#1075, #1235): the web's three
6064
+ // columns (the icon over both rows | the title over the description
6065
+ // | the close button) are rebuilt as a flex column with the icon and
6066
+ // the close button placed absolutely — the toast's close pin is the
6067
+ // precedent. The padding moves onto two properties so the icon and
6068
+ // the close button can follow it per size.
6069
+ lynx: {
6070
+ tokens: {
6071
+ '--alert-pad-y': 'var(--space-md)',
6072
+ '--alert-pad-x': 'var(--space-lg)',
6073
+ },
6074
+ parts: {
6075
+ root: {
6076
+ base: {
6077
+ display: 'flex',
6078
+ flexDirection: 'column',
6079
+ alignItems: 'stretch',
6080
+ gap: 'var(--space-sm)',
6081
+ position: 'relative',
6082
+ boxSizing: 'border-box',
6083
+ padding: 'var(--alert-pad-y) var(--alert-pad-x)',
6084
+ },
6085
+ },
6086
+ // The icon column: a --text-xl slot at the padding edge,
6087
+ // centred over the alert's height (the web's two-row span).
6088
+ icon: {
6089
+ base: {
6090
+ position: 'absolute',
6091
+ left: 'var(--alert-pad-x)',
6092
+ top: '0',
6093
+ bottom: '0',
6094
+ width: 'var(--text-xl)',
6095
+ display: 'flex',
6096
+ justifyContent: 'center',
6097
+ },
6098
+ },
6099
+ // Pinned top-right, its glyph on the padding edge. `inherit`
6100
+ // is spelled as the root's own ink. A square chip, the
6101
+ // glyph centred in it: the web's `line-height: 1` + 2xs
6102
+ // padding make an almost-square box, but lynx does not carry
6103
+ // the line-height into the glyph's `<text>`, so the padded
6104
+ // box was a narrow × in a tall line — a 14 × 20pt pill whose
6105
+ // press wash and focus ring drew as vertical ovals
6106
+ // (signalxjs/lynx#1253). The box is sized outright instead.
6107
+ close: {
6108
+ base: {
6109
+ position: 'absolute',
6110
+ top: 'calc(var(--alert-pad-y) - var(--space-2xs))',
6111
+ right: 'calc(var(--alert-pad-x) - var(--space-2xs))',
6112
+ color: 'var(--color-base-content)',
6113
+ fontSize: 'var(--text-md)',
6114
+ display: 'flex',
6115
+ alignItems: 'center',
6116
+ justifyContent: 'center',
6117
+ boxSizing: 'border-box',
6118
+ padding: '0',
6119
+ width: 'calc(var(--text-md) + var(--space-2xs) * 2)',
6120
+ height: 'calc(var(--text-md) + var(--space-2xs) * 2)',
6121
+ },
6122
+ states: lynxFocus({ gap: 'var(--alert-tint)' }),
6123
+ },
6124
+ },
6125
+ variants: {
6126
+ size: {
6127
+ xs: { root: { base: { '--alert-pad-y': 'var(--space-2xs)', '--alert-pad-x': 'var(--space-sm)' } } },
6128
+ sm: { root: { base: { '--alert-pad-y': 'var(--space-xs)', '--alert-pad-x': 'var(--space-md)' } } },
6129
+ lg: { root: { base: { '--alert-pad-y': 'var(--space-lg)', '--alert-pad-x': 'var(--space-xl)' } } },
6130
+ xl: { root: { base: { '--alert-pad-y': 'var(--space-xl)', '--alert-pad-x': 'var(--space-2xl)' } } },
6131
+ },
6132
+ },
6133
+ // The title and description keep clear of the icon column and
6134
+ // the close button's corner. Lynx has no `:has()`, so lynx-zero
6135
+ // stamps what the alert renders onto both text parts, as
6136
+ // `data-mod-with-icon` / `data-mod-with-close` (the
6137
+ // `zx-m-with-icon` / `zx-m-with-close` classes) — a lynx-zero
6138
+ // rendering detail like toggle-group's join ends, styled from raw
6139
+ // lynx css.
6140
+ css: `
6141
+ .zx-alert__title.zx-m-with-icon {
6142
+ margin-left: calc(var(--text-xl) + var(--space-md));
6143
+ }
6144
+ .zx-alert__description.zx-m-with-icon {
6145
+ margin-left: calc(var(--text-xl) + var(--space-md));
6146
+ }
6147
+ .zx-alert__title.zx-m-with-close {
6148
+ margin-right: calc(var(--text-md) + var(--space-md));
6149
+ }
6150
+ .zx-alert__description.zx-m-with-close {
6151
+ margin-right: calc(var(--text-md) + var(--space-md));
6152
+ }
6153
+ `,
6154
+ },
6155
+ },
5749
6156
  };
5750
6157
  /**
5751
6158
  * EmptyState (zero#131) — daisy has no empty-state component; this is the
@@ -5827,6 +6234,25 @@ export const emptyState = {
5827
6234
  xl: { root: { base: { padding: 'calc(var(--space-2xl) * 2) var(--space-2xl)', gap: 'var(--space-lg)' } }, icon: { base: { fontSize: 'var(--text-2xl)' } }, title: { base: { fontSize: 'var(--text-2xl)' } }, description: { base: { fontSize: 'var(--text-md)' } } },
5828
6235
  },
5829
6236
  },
6237
+ // signalxjs/lynx#1236 — the lynx replacements for what the shared
6238
+ // sections cannot say there.
6239
+ targets: {
6240
+ lynx: {
6241
+ parts: {
6242
+ // Physical margins: Android resolves no logical margin
6243
+ // (signalxjs/lynx#1084).
6244
+ icon: { base: { marginBottom: 'var(--space-xs)' } },
6245
+ // Title and description are lynx `<text>`: a view's
6246
+ // `text-align` and `color` do not reach one unless the app
6247
+ // turned CSS inheritance on, so each states its own ink and
6248
+ // centring. The web's 36ch measure becomes the root's width:
6249
+ // `ch` is unmeasured on lynx.
6250
+ title: { base: { color: 'var(--color-base-content)', textAlign: 'center' } },
6251
+ description: { base: { textAlign: 'center', maxInlineSize: '100%' } },
6252
+ actions: { base: { flexDirection: 'row', marginTop: 'var(--space-sm)' } },
6253
+ },
6254
+ },
6255
+ },
5830
6256
  };
5831
6257
  /**
5832
6258
  * daisy "badge" flavor: a fully-rounded pill with a hairline, filled by the
@@ -5928,6 +6354,62 @@ export const badge = {
5928
6354
  }, keyframes: {
5929
6355
  'zero-daisy-badge-pulse': 'from { box-shadow: 0 0 0 0 color-mix(in oklch, var(--badge-dot) 45%, transparent); } to { box-shadow: 0 0 0 0.5em transparent; }',
5930
6356
  },
6357
+ /**
6358
+ * Lynx (signalxjs/lynx#1234). The dot's `currentColor` never resolves
6359
+ * there (lynx#1079), so the uncoloured dot and its ring take the pill's
6360
+ * ink token, the value `currentColor` names on the web. The running halo
6361
+ * and pulse mix the recipe-local `--badge-dot`, which cannot bake, so the
6362
+ * halo is its own token mixed from each role's `--color-*` (baked per
6363
+ * theme) and the pulse breathes opacity instead of the halo's spread.
6364
+ * `inline-size`/`block-size` do not resolve on lynx (lynx#1145): the dot
6365
+ * is sized physically. A lynx view has no inline formatting context, so
6366
+ * the pill is content-sized with an explicit row (lynx#1165), and its
6367
+ * hairline and the dot's ring are longhands (lynx#1161/#1162).
6368
+ */
6369
+ targets: {
6370
+ lynx: {
6371
+ tokens: {
6372
+ '--badge-dot': 'var(--badge-ink)',
6373
+ '--badge-dot-ring': 'var(--badge-ink)',
6374
+ '--badge-dot-halo': 'color-mix(in oklch, var(--color-base-content) 35%, transparent)',
6375
+ },
6376
+ parts: {
6377
+ root: {
6378
+ base: {
6379
+ ...lynxBtnFit,
6380
+ flexDirection: 'row',
6381
+ border: '0 solid transparent',
6382
+ ...lynxSides('border{}Width', 'var(--border)'),
6383
+ },
6384
+ },
6385
+ dot: {
6386
+ base: {
6387
+ display: 'flex',
6388
+ width: '0.5em',
6389
+ height: '0.5em',
6390
+ flexShrink: '0',
6391
+ border: '0 solid transparent',
6392
+ ...lynxSides('border{}Width', '0.1em'),
6393
+ ...lynxSides('border{}Color', 'var(--badge-dot-ring)'),
6394
+ },
6395
+ states: {
6396
+ running: {
6397
+ boxShadow: '0 0 0 0.2em var(--badge-dot-halo)',
6398
+ animation: 'zero-daisy-badge-pulse 1.6s ease-in-out infinite',
6399
+ },
6400
+ },
6401
+ },
6402
+ },
6403
+ variants: {
6404
+ color: Object.fromEntries(ROLES.map((c) => [c, {
6405
+ dot: { base: { '--badge-dot-halo': `color-mix(in oklch, var(--color-${c}) 35%, transparent)` } },
6406
+ }])),
6407
+ },
6408
+ keyframes: {
6409
+ 'zero-daisy-badge-pulse': 'from, to { opacity: 1; } 50% { opacity: 0.45; }',
6410
+ },
6411
+ },
6412
+ },
5931
6413
  };
5932
6414
  /** daisy "divider": the base-300 rule, thickening with the ramp. */
5933
6415
  export const divider = {
@@ -5997,6 +6479,11 @@ export const divider = {
5997
6479
  },
5998
6480
  targets: {
5999
6481
  lynx: {
6482
+ // `auto` behind a custom property, so the labelled root's size
6483
+ // is var-bearing too and can beat the var-bearing line thickness
6484
+ // below (lynx expands var() declarations after the cascade,
6485
+ // signalxjs/lynx#1161).
6486
+ tokens: { '--divider-fit': 'auto' },
6000
6487
  parts: {
6001
6488
  // Lynx drops the ::before/::after segments the rule is drawn
6002
6489
  // with above, which left the lynx divider with no line and,
@@ -6011,7 +6498,64 @@ export const divider = {
6011
6498
  '&[data-orientation="vertical"]': { inlineSize: 'var(--divider-thickness)' },
6012
6499
  },
6013
6500
  },
6501
+ // The label's inset, physical (Android resolves no logical
6502
+ // padding, signalxjs/lynx#1084), and flush on the side its
6503
+ // placement gave up. Lynx has no descendant combinator from
6504
+ // the root, so lynx-zero stamps the root's orientation on the
6505
+ // label itself; vertical, the inset turns to the block axis.
6506
+ // Every zero is spelled through var() so it beats the
6507
+ // var-bearing inset (signalxjs/lynx#1161).
6508
+ label: {
6509
+ base: {
6510
+ paddingLeft: 'var(--space-md)',
6511
+ paddingRight: 'var(--space-md)',
6512
+ },
6513
+ selectors: {
6514
+ '&[data-placement="start"]': { paddingLeft: 'calc(var(--space-md) * 0)' },
6515
+ '&[data-placement="end"]': { paddingRight: 'calc(var(--space-md) * 0)' },
6516
+ '&[data-orientation="vertical"]': {
6517
+ paddingLeft: 'calc(var(--space-md) * 0)',
6518
+ paddingRight: 'calc(var(--space-md) * 0)',
6519
+ paddingTop: 'var(--space-sm)',
6520
+ paddingBottom: 'var(--space-sm)',
6521
+ },
6522
+ '&[data-orientation="vertical"][data-placement="start"]': { paddingTop: 'calc(var(--space-sm) * 0)' },
6523
+ '&[data-orientation="vertical"][data-placement="end"]': { paddingBottom: 'calc(var(--space-sm) * 0)' },
6524
+ },
6525
+ },
6014
6526
  },
6527
+ // A labelled divider (signalxjs/lynx#1236). With no pseudo-
6528
+ // elements, lynx-zero draws the rule's two segments as views
6529
+ // carrying the root's own line classes plus `zx-m-segment` —
6530
+ // so each paints this recipe's line, ink and thickness ramp — and
6531
+ // leaves out the one on the label's placed side (the web's
6532
+ // `:has(> [data-placement])::before { display: none }`). The root
6533
+ // holding them is stamped `zx-m-labelled` and stops being a line:
6534
+ // no fill, its cross size following the label, a row when
6535
+ // horizontal. Both are lynx-zero rendering details, not
6536
+ // modifiers an author sets, so they are styled from raw lynx css
6537
+ // (the toggle-group join ends' reasoning).
6538
+ css: `
6539
+ .zx-divider__root.zx-m-labelled {
6540
+ --divider-ink: transparent;
6541
+ }
6542
+ .zx-divider__root.zx-m-labelled.zx-o-horizontal {
6543
+ flex-direction: row;
6544
+ block-size: var(--divider-fit);
6545
+ }
6546
+ .zx-divider__root.zx-m-labelled.zx-o-vertical {
6547
+ inline-size: var(--divider-fit);
6548
+ }
6549
+ .zx-divider__root.zx-m-segment {
6550
+ flex: 1 1 0;
6551
+ }
6552
+ .zx-divider__root.zx-m-segment.zx-o-horizontal {
6553
+ min-width: 0;
6554
+ }
6555
+ .zx-divider__root.zx-m-segment.zx-o-vertical {
6556
+ min-height: 0;
6557
+ }
6558
+ `,
6015
6559
  },
6016
6560
  },
6017
6561
  };
@@ -6067,12 +6611,28 @@ export const skeleton = {
6067
6611
  keyframes: {
6068
6612
  'zero-daisyui-skeleton': 'from, to { opacity: 1; } 50% { opacity: 0.55; }',
6069
6613
  },
6614
+ // signalxjs/lynx#1237. The pulse is an opacity loop on the part itself —
6615
+ // no pseudo-element, so it carries as is; only its keyframes are
6616
+ // restated as three plain stops, not the `from, to` selector list. The
6617
+ // reduced-motion stop cannot follow: lynx emits no `@media`.
6618
+ targets: {
6619
+ lynx: {
6620
+ keyframes: {
6621
+ 'zero-daisyui-skeleton': '0% { opacity: 1; } 50% { opacity: 0.55; } 100% { opacity: 1; }',
6622
+ },
6623
+ },
6624
+ },
6070
6625
  };
6071
- /** daisy "loading-spinner": the ring, with one quadrant in the role's ink. */
6626
+ /**
6627
+ * daisy "loading-spinner": the ring, with one quadrant in the role's ink.
6628
+ * Sized on daisy's `loading-*` ramp — `--size-selector * 4…8` (16–32px), md
6629
+ * the `loading` default of `* 6` (signalxjs/lynx#1237: the ramp was
6630
+ * `--size-field * 0.35…0.9`, a 2px box that the 2px border swallowed).
6631
+ */
6072
6632
  export const spinner = {
6073
6633
  component: 'spinner',
6074
6634
  tokens: {
6075
- '--spinner-size': 'calc(var(--size-field) * 0.5)',
6635
+ '--spinner-size': 'calc(var(--size-selector) * 6)',
6076
6636
  '--spinner-ink': 'var(--color-primary)',
6077
6637
  '--spinner-track': 'var(--color-base-300)',
6078
6638
  },
@@ -6096,14 +6656,40 @@ export const spinner = {
6096
6656
  '--spinner-ink': `var(--color-${c})`,
6097
6657
  } } }])),
6098
6658
  size: {
6099
- xs: { root: { base: { '--spinner-size': 'calc(var(--size-field) * 0.35)' } } },
6100
- sm: { root: { base: { '--spinner-size': 'calc(var(--size-field) * 0.42)' } } },
6659
+ xs: { root: { base: { '--spinner-size': 'calc(var(--size-selector) * 4)' } } },
6660
+ sm: { root: { base: { '--spinner-size': 'calc(var(--size-selector) * 5)' } } },
6101
6661
  md: {},
6102
- lg: { root: { base: { '--spinner-size': 'calc(var(--size-field) * 0.7)' } } },
6103
- xl: { root: { base: { '--spinner-size': 'calc(var(--size-field) * 0.9)' } } },
6662
+ lg: { root: { base: { '--spinner-size': 'calc(var(--size-selector) * 7)' } } },
6663
+ xl: { root: { base: { '--spinner-size': 'calc(var(--size-selector) * 8)' } } },
6104
6664
  },
6105
6665
  },
6106
6666
  keyframes: { 'zero-daisyui-spin': 'to { transform: rotate(360deg); }' },
6667
+ // signalxjs/lynx#1237 — the ring in physical spellings. The logical box
6668
+ // size and `border-block-start-color` are not lynx's; the ring is
6669
+ // restated as longhands, because a var-bearing `border` shorthand
6670
+ // expands after the cascade and would repaint the head in the track
6671
+ // colour (signalxjs/lynx#1161). A flex box, since `inline-block` is not a
6672
+ // lynx display value, that keeps its size in a row. The reduced-motion
6673
+ // stop cannot follow: lynx emits no `@media`.
6674
+ targets: {
6675
+ lynx: {
6676
+ parts: {
6677
+ root: {
6678
+ base: {
6679
+ display: 'flex',
6680
+ flexShrink: '0',
6681
+ width: 'var(--spinner-size)',
6682
+ height: 'var(--spinner-size)',
6683
+ border: '0 solid transparent',
6684
+ borderStyle: 'solid',
6685
+ ...lynxSides('border{}Width', 'calc(var(--border) * 2)'),
6686
+ ...lynxSides('border{}Color', 'var(--spinner-track)', ['Right', 'Bottom', 'Left']),
6687
+ borderTopColor: 'var(--spinner-ink)',
6688
+ },
6689
+ },
6690
+ },
6691
+ },
6692
+ },
6107
6693
  };
6108
6694
  // ── The content-tier sweep (#334) ─────────────────────────────────────────
6109
6695
  /** daisy kbd: the raised base-200 cap with the doubled bottom edge. */
@@ -6149,6 +6735,44 @@ export const kbd = {
6149
6735
  xl: { root: { base: { fontSize: 'var(--text-md)', padding: 'var(--space-xs) var(--space-lg)' } } },
6150
6736
  },
6151
6737
  },
6738
+ /**
6739
+ * Lynx (signalxjs/lynx#1234). `min-inline-size` and the logical bottom
6740
+ * edge do not resolve on lynx (lynx#1145, #1084), so the cap's floor is
6741
+ * a physical `min-width` and its doubled bottom edge a physical
6742
+ * longhand; the whole hairline is longhands so a var-bearing shorthand
6743
+ * cannot expand over the thicker edge (lynx#1161/#1162). A lynx view has
6744
+ * no inline formatting context: the cap is content-sized with an
6745
+ * explicit row (lynx#1165).
6746
+ */
6747
+ targets: {
6748
+ lynx: {
6749
+ parts: {
6750
+ root: {
6751
+ base: {
6752
+ ...lynxBtnFit,
6753
+ flexDirection: 'row',
6754
+ minWidth: '1.75em',
6755
+ border: '0 solid transparent',
6756
+ ...lynxSides('border{}Width', 'var(--border)', ['Top', 'Right', 'Left']),
6757
+ borderBottomWidth: 'calc(var(--border) * 2)',
6758
+ ...lynxSides('border{}Color', 'var(--color-base-300)'),
6759
+ // The web's monospace cap comes from the browser's
6760
+ // UA style for `<kbd>`, not the recipe; lynx has no
6761
+ // UA sheet, so the face is spelled here
6762
+ // (signalxjs/lynx#1254). Concrete names, not a
6763
+ // token: iOS Lynx walks the list with
6764
+ // `UIFont fontWithName:` (Menlo ships on iOS, the
6765
+ // CSS generics do not resolve there), and
6766
+ // `monospace` serves the lynx web target.
6767
+ fontFamily: 'Menlo, monospace',
6768
+ },
6769
+ },
6770
+ },
6771
+ variants: {
6772
+ size: { xs: { root: { base: { minWidth: '1.5em' } } } },
6773
+ },
6774
+ },
6775
+ },
6152
6776
  };
6153
6777
  /**
6154
6778
  * daisy status: the dot with the soft halo ring daisy draws around it —
@@ -6189,6 +6813,37 @@ export const status = {
6189
6813
  xl: { root: { base: { '--status-size': 'calc(var(--size-selector) * 3.5)' } } },
6190
6814
  },
6191
6815
  },
6816
+ /**
6817
+ * Lynx (signalxjs/lynx#1234). The halo mixes the recipe-local
6818
+ * `--status-ink`, which cannot bake, so it is its own token mixed from
6819
+ * each role's `--color-*` (baked per theme). `inline-size`/`block-size`
6820
+ * do not resolve on lynx (lynx#1145): the dot is sized physically, and
6821
+ * it is a flex box rather than an inline-block the engine has no
6822
+ * formatting context for.
6823
+ */
6824
+ targets: {
6825
+ lynx: {
6826
+ tokens: {
6827
+ '--status-halo': 'color-mix(in oklch, var(--color-base-content) 25%, transparent)',
6828
+ },
6829
+ parts: {
6830
+ root: {
6831
+ base: {
6832
+ display: 'flex',
6833
+ flexShrink: '0',
6834
+ width: 'var(--status-size)',
6835
+ height: 'var(--status-size)',
6836
+ boxShadow: '0 0 0 calc(var(--status-size) / 4) var(--status-halo)',
6837
+ },
6838
+ },
6839
+ },
6840
+ variants: {
6841
+ color: Object.fromEntries(ROLES.map((c) => [c, { root: { base: {
6842
+ '--status-halo': `color-mix(in oklch, var(--color-${c}) 25%, transparent)`,
6843
+ } } }])),
6844
+ },
6845
+ },
6846
+ },
6192
6847
  };
6193
6848
  /**
6194
6849
  * Indicator — pure position; the item's content brings its own paint.
@@ -6371,6 +7026,90 @@ export const stats = {
6371
7026
  xl: { value: { base: { fontSize: 'var(--text-3xl)' } } },
6372
7027
  },
6373
7028
  },
7029
+ // signalxjs/lynx#1236 — the lynx replacements for what the shared
7030
+ // sections cannot say there.
7031
+ targets: {
7032
+ lynx: {
7033
+ parts: {
7034
+ // A row unless vertical: a lynx flex container does not
7035
+ // default to one.
7036
+ root: { base: { flexDirection: 'row' } },
7037
+ // Lynx has no grid, so `display: grid` left the item in the
7038
+ // engine's default linear layout. The bands stack in a
7039
+ // centred column instead, and the figure is pinned to the
7040
+ // item's end edge (below). The padding as longhands, so the
7041
+ // `figure` rule below can widen one side by specificity (a
7042
+ // var-bearing `padding` shorthand would expand over it,
7043
+ // signalxjs/lynx#1161).
7044
+ item: {
7045
+ base: {
7046
+ display: 'flex',
7047
+ flexDirection: 'column',
7048
+ justifyContent: 'center',
7049
+ position: 'relative',
7050
+ padding: '0',
7051
+ paddingTop: 'var(--space-lg)',
7052
+ paddingBottom: 'var(--space-lg)',
7053
+ paddingLeft: 'var(--space-xl)',
7054
+ paddingRight: 'var(--space-xl)',
7055
+ },
7056
+ selectors: {
7057
+ // The seams: lynx has no sibling combinator, so the
7058
+ // web's `item + item` edge cannot be selected. Every
7059
+ // item draws its leading seam and the `first`
7060
+ // modifier takes it off the first (raw css below).
7061
+ '&[data-orientation="horizontal"]': {
7062
+ borderLeftWidth: 'var(--border)',
7063
+ borderLeftStyle: 'solid',
7064
+ borderLeftColor: 'var(--color-base-200)',
7065
+ },
7066
+ '&[data-orientation="vertical"]': {
7067
+ borderTopWidth: 'var(--border)',
7068
+ borderTopStyle: 'solid',
7069
+ borderTopColor: 'var(--color-base-200)',
7070
+ },
7071
+ },
7072
+ },
7073
+ // The web's second grid column, spanning the three bands and
7074
+ // centred on them: pinned to the item's end edge, inside its
7075
+ // padding, and centred top to bottom.
7076
+ figure: {
7077
+ base: {
7078
+ position: 'absolute',
7079
+ top: '0',
7080
+ bottom: '0',
7081
+ right: 'var(--space-xl)',
7082
+ // The web centres the grid cell with align-self; the
7083
+ // insets place it here.
7084
+ alignSelf: 'auto',
7085
+ display: 'flex',
7086
+ flexDirection: 'column',
7087
+ justifyContent: 'center',
7088
+ },
7089
+ },
7090
+ },
7091
+ // lynx-zero stamps the row's first item `zx-m-first` (mount
7092
+ // order; no `:first-child`) and an item holding a figure
7093
+ // `zx-m-figure`. Rendering details, not author modifiers, so raw
7094
+ // lynx css (the toggle-group join ends' reasoning). The first
7095
+ // item drops the leading seam, its zero width spelled through
7096
+ // var() to beat the var-bearing seam (signalxjs/lynx#1161). An
7097
+ // item with a figure keeps the figure's column clear: the
7098
+ // padding, the web's `--space-md` column gap, and a 2rem figure
7099
+ // (daisy's `stat-figure` holds a `size-8` icon or avatar).
7100
+ css: `
7101
+ .zx-stats__item.zx-m-first.zx-o-horizontal {
7102
+ border-left-width: calc(var(--border) * 0);
7103
+ }
7104
+ .zx-stats__item.zx-m-first.zx-o-vertical {
7105
+ border-top-width: calc(var(--border) * 0);
7106
+ }
7107
+ .zx-stats__item.zx-m-figure {
7108
+ padding-right: calc(var(--space-xl) + var(--space-md) + 2rem);
7109
+ }
7110
+ `,
7111
+ },
7112
+ },
6374
7113
  };
6375
7114
  /**
6376
7115
  * A timeline none of whose content sits on the start side: the root carries
@@ -7085,6 +7824,28 @@ export const navbar = {
7085
7824
  xl: { root: { base: { minBlockSize: '6rem', fontSize: 'var(--text-lg)' } } },
7086
7825
  },
7087
7826
  },
7827
+ // Lynx (signalxjs/lynx#1257): `min-block-size` is not a lynx property,
7828
+ // so the bar's height ramp is restated as `min-height`; and a lynx flex
7829
+ // container is only a row when it says so, so the bar and its three
7830
+ // sections spell `flex-direction: row`.
7831
+ targets: {
7832
+ lynx: {
7833
+ parts: {
7834
+ root: { base: { flexDirection: 'row', minHeight: '4rem' } },
7835
+ start: { base: { flexDirection: 'row' } },
7836
+ center: { base: { flexDirection: 'row' } },
7837
+ end: { base: { flexDirection: 'row' } },
7838
+ },
7839
+ variants: {
7840
+ size: {
7841
+ xs: { root: { base: { minHeight: '2.5rem' } } },
7842
+ sm: { root: { base: { minHeight: '3rem' } } },
7843
+ lg: { root: { base: { minHeight: '5rem' } } },
7844
+ xl: { root: { base: { minHeight: '6rem' } } },
7845
+ },
7846
+ },
7847
+ },
7848
+ },
7088
7849
  };
7089
7850
  /**
7090
7851
  * NavList (zero#132) — daisy's `menu` idiom for a sidebar: rounded rows, a
@@ -7143,6 +7904,14 @@ export const navList = {
7143
7904
  alignItems: 'center',
7144
7905
  gap: 'var(--space-sm)',
7145
7906
  textDecoration: 'none',
7907
+ // Without an href the link is a <button> (#451): drop its chrome.
7908
+ appearance: 'none',
7909
+ background: 'none',
7910
+ border: '0',
7911
+ font: 'inherit',
7912
+ textAlign: 'start',
7913
+ inlineSize: '100%',
7914
+ cursor: 'pointer',
7146
7915
  color: 'var(--nav-ink)',
7147
7916
  padding: 'var(--space-sm) var(--space-lg)',
7148
7917
  borderRadius: 'var(--radius-field)',
@@ -7190,6 +7959,27 @@ export const navList = {
7190
7959
  xl: { root: { base: { fontSize: 'var(--text-lg)' } }, link: { base: { padding: 'var(--space-lg) var(--space-xl)' } } },
7191
7960
  },
7192
7961
  },
7962
+ // The web's block and inline boxes, restated for lynx (signalxjs/lynx#1259).
7963
+ // No `:hover` on a touch platform, and the link's anatomy declares no
7964
+ // `pressed` flag: a held link has lynx-zero's main-thread press scale.
7965
+ targets: {
7966
+ lynx: {
7967
+ parts: {
7968
+ // `display: block` has no lynx projection: a column, so the
7969
+ // link spans the list as a block `<li>` makes it.
7970
+ item: { base: { display: 'flex', flexDirection: 'column' } },
7971
+ // The icon / label / meta row, explicit (lynx does not
7972
+ // default the direction the way a browser does).
7973
+ link: { base: { flexDirection: 'row' } },
7974
+ // `inline-size` restated physically; the glyph centred in
7975
+ // its column on both axes.
7976
+ icon: { base: { width: '1.25em', flexDirection: 'row', alignItems: 'center' } },
7977
+ // The count's push to the far edge, restated physically —
7978
+ // `margin-inline-start` does not resolve on Android (#1084).
7979
+ meta: { base: { marginLeft: 'auto', flexDirection: 'row' } },
7980
+ },
7981
+ },
7982
+ },
7193
7983
  };
7194
7984
  /**
7195
7985
  * Breadcrumbs — daisy's breadcrumbs: a compact text-sm trail, links
@@ -7298,6 +8088,37 @@ export const breadcrumbs = {
7298
8088
  xl: { root: { base: { fontSize: 'var(--text-lg)' } } },
7299
8089
  },
7300
8090
  },
8091
+ // Lynx (signalxjs/lynx#1257):
8092
+ // - The list, the items and the ellipsis are explicit rows (a lynx flex
8093
+ // container is only a row when it says so).
8094
+ // - The trigger's `hover` ink is dropped on a touch platform; the
8095
+ // `pressed` flag carries it instead. Its resting ink moves onto a
8096
+ // lynx-only token so the base rule stays a plain var() read: the
8097
+ // emitter bakes a `color-mix` declaration per theme under the host
8098
+ // class, and that baked rule would outrank the pressed one.
8099
+ // - The focus ring is the lynx box-shadow ring (outline has no offset
8100
+ // and ignores the radius there).
8101
+ // - The link's `hover` stays dropped: the link anatomy declares no
8102
+ // pressed flag, so there is nothing on lynx to carry it.
8103
+ // - The separator is a real part on both targets (lynx-zero renders it
8104
+ // as a text glyph), so daisy's `::before` chevron needs no lynx form.
8105
+ targets: {
8106
+ lynx: {
8107
+ tokens: { '--bc-trigger-ink': 'color-mix(in oklch, var(--color-base-content) 75%, transparent)' },
8108
+ parts: {
8109
+ list: { base: { flexDirection: 'row' } },
8110
+ item: { base: { flexDirection: 'row' } },
8111
+ ellipsis: { base: { flexDirection: 'row' } },
8112
+ 'ellipsis-trigger': {
8113
+ base: { color: 'var(--bc-trigger-ink)' },
8114
+ states: {
8115
+ pressed: { color: 'var(--color-base-content)' },
8116
+ 'focus-visible': lynxFocusRing('var(--color-primary)'),
8117
+ },
8118
+ },
8119
+ },
8120
+ },
8121
+ },
7301
8122
  };
7302
8123
  /**
7303
8124
  * The four pagination triggers — prev/next and, with `withEdges`, the
@@ -7334,6 +8155,17 @@ const pageTrigger = {
7334
8155
  [`&${rtl}`]: { scale: '-1 1' },
7335
8156
  },
7336
8157
  };
8158
+ /** A pagination cell's box on lynx — see the recipe's lynx section. */
8159
+ const pageCellLynx = {
8160
+ minWidth: 'var(--pg-size)',
8161
+ height: 'var(--pg-size)',
8162
+ flexShrink: '0',
8163
+ };
8164
+ /** The four triggers on lynx: the cell box, the held wash + sink, the ring. */
8165
+ const pageTriggerLynx = {
8166
+ base: pageCellLynx,
8167
+ states: { pressed: { background: 'var(--color-base-300)' }, ...lynxFocus() },
8168
+ };
7337
8169
  /**
7338
8170
  * Pagination — daisy's join-of-buttons made of standalone btn cells: the
7339
8171
  * quiet base-200 fill, base-300 hover, the current page inverted into the
@@ -7430,13 +8262,35 @@ export const pagination = {
7430
8262
  xl: { root: { base: { '--pg-size': 'calc(var(--size-field) * 14)', '--pg-font': 'var(--text-lg)' } } },
7431
8263
  },
7432
8264
  },
7433
- // The cell's inline padding, restated physically: logical spellings
7434
- // resolve on iOS but not on Android (measured, signalxjs/lynx#1084), so
7435
- // the emitter refuses them.
8265
+ // Lynx (signalxjs/lynx#1258):
8266
+ // - the cells' size, restated physically: `min-inline-size` and
8267
+ // `block-size` do not resolve on lynx (lynx#1145), so every cell
8268
+ // collapsed to its glyph. Read through `--pg-size`, so the compile
8269
+ // inlines the size steps per part, as it does the web's;
8270
+ // - the item's inline padding, physical: logical spellings resolve on
8271
+ // iOS but not on Android (measured, lynx#1084);
8272
+ // - the press: the web answers a pointer with `:hover`, which a touch
8273
+ // platform drops, so the held cell paints daisy's hover wash with the
8274
+ // 1px sink (`lynxBtnPressed`'s pair) — the current page keeps its
8275
+ // accent fill and only sinks;
8276
+ // - the focus ring as `lynxFocus`: lynx's outline has no offset and
8277
+ // ignores the radius (lynx#1163);
8278
+ // - no row shrink: a cell never squeezes below its size in a narrow row.
8279
+ // The glyphs' rtl flip has no replacement: lynx has no RTL flow.
7436
8280
  targets: {
7437
8281
  lynx: {
7438
8282
  parts: {
7439
- item: { base: { paddingLeft: 'var(--space-2xs)', paddingRight: 'var(--space-2xs)' } },
8283
+ root: { base: { flexDirection: 'row' } },
8284
+ item: {
8285
+ base: { ...pageCellLynx, paddingLeft: 'var(--space-2xs)', paddingRight: 'var(--space-2xs)' },
8286
+ states: lynxFocus(),
8287
+ selectors: { '&[data-pressed][data-state="inactive"]': { background: 'var(--color-base-300)' } },
8288
+ },
8289
+ ellipsis: { base: pageCellLynx },
8290
+ 'first-trigger': pageTriggerLynx,
8291
+ 'prev-trigger': pageTriggerLynx,
8292
+ 'next-trigger': pageTriggerLynx,
8293
+ 'last-trigger': pageTriggerLynx,
7440
8294
  },
7441
8295
  },
7442
8296
  },
@@ -7453,6 +8307,11 @@ export const pagination = {
7453
8307
  */
7454
8308
  const stepInk = (accent, role) => `color-mix(in oklab, ${accent} `
7455
8309
  + `${Math.min(ROLE_INK_KEEP[role] ?? 55, 70)}%, var(--color-base-content))`;
8310
+ /**
8311
+ * The complete disc's fill: 20% of the accent over base-100, in OKLAB (see
8312
+ * the indicator's `complete` state for why not OKLCH).
8313
+ */
8314
+ const stepTint = (accent) => `color-mix(in oklab, ${accent} 20%, var(--color-base-100))`;
7456
8315
  /** A Steps root holding wizard parts (#296) — the panel or the triggers. */
7457
8316
  const STEPS_WIZARD = '&:has(> [data-scope="steps"]:is([data-part="content"], [data-part="prev-trigger"], [data-part="next-trigger"]))';
7458
8317
  /** Steps' Back/Next (#296): the neutral daisy btn, with its 1px sink. */
@@ -7568,7 +8427,7 @@ export const steps = {
7568
8427
  // (warning's yellow came out pink) — which the browser parity
7569
8428
  // check caught once per-step colour put all eight roles on
7570
8429
  // the disc (#112).
7571
- complete: { background: 'color-mix(in oklab, var(--steps-accent) 20%, var(--color-base-100))', color: 'var(--steps-complete-ink)' },
8430
+ complete: { background: stepTint('var(--steps-accent)'), color: 'var(--steps-complete-ink)' },
7572
8431
  inactive: { background: 'var(--color-base-200)', color: 'var(--color-base-content)' },
7573
8432
  invalid: { background: 'var(--color-error)', color: 'var(--color-error-content)' },
7574
8433
  },
@@ -7656,32 +8515,82 @@ export const steps = {
7656
8515
  xl: { root: { base: { '--steps-ind': 'calc(var(--size-selector) * 9)', '--steps-font': 'var(--text-md)' } } },
7657
8516
  },
7658
8517
  },
7659
- // The bridge's anchors and the vertical item's tail padding, restated
7660
- // physically: logical spellings resolve on iOS but not on Android
7661
- // (measured, signalxjs/lynx#1084), so the emitter refuses them. Physical
7662
- // is the lynx target's norm — no RTL flow there to mirror with.
8518
+ // Lynx. Physical spellings throughout: logical insets, margins and
8519
+ // paddings resolve on iOS but not on Android (measured,
8520
+ // signalxjs/lynx#1084), and `inline-size` / `block-size` do not resolve
8521
+ // at all (lynx#1145) — without the restatement the discs and lines did
8522
+ // not paint. Also (signalxjs/lynx#1258):
8523
+ // - the complete disc's tint and ink read `--steps-accent` through a
8524
+ // colour function, which cannot bake; the base pair is restated over
8525
+ // primary, and each colour already restates the ink on its item, so
8526
+ // the tint joins it there;
8527
+ // - the bridge runs from this disc's edge to the next disc's edge —
8528
+ // `50% + ind/2` for `100% - ind` — instead of centre to centre under
8529
+ // the disc, so it needs no z-index across sibling items; the item
8530
+ // stops clipping (a lynx view's `overflow` defaults to hidden) so the
8531
+ // bridge can reach past its box;
8532
+ // - the `:has()` wizard wrap becomes the `wizard` stamp lynx-zero puts on
8533
+ // the root while a panel or trigger is mounted (raw css below);
8534
+ // - the focus ring as `lynxFocus` (lynx#1163), and Back/Next's press as
8535
+ // daisy's hover wash + sink (`lynxBtnPressed`), `:hover` being dropped.
7663
8536
  targets: {
7664
8537
  lynx: {
8538
+ tokens: {
8539
+ '--steps-complete-ink': stepInk('var(--color-primary)', 'primary'),
8540
+ '--steps-complete-tint': stepTint('var(--color-primary)'),
8541
+ },
7665
8542
  parts: {
8543
+ root: { base: { flexDirection: 'row', overflow: 'visible' } },
7666
8544
  item: {
8545
+ base: { overflow: 'visible' },
8546
+ states: lynxFocus(),
7667
8547
  selectors: {
7668
8548
  '&[data-orientation="vertical"]': { paddingBottom: 'var(--space-lg)' },
7669
8549
  },
7670
8550
  },
8551
+ indicator: {
8552
+ base: { width: 'var(--steps-ind)', height: 'var(--steps-ind)' },
8553
+ states: { complete: { background: 'var(--steps-complete-tint)' } },
8554
+ },
7671
8555
  separator: {
7672
8556
  selectors: {
7673
8557
  '&[data-orientation="horizontal"]': {
7674
- top: 'calc(var(--space-xs) + var(--steps-ind) / 2)',
7675
- left: '50%',
8558
+ top: 'calc(var(--space-xs) + (var(--steps-ind) - var(--border)) / 2)',
8559
+ left: 'calc(50% + var(--steps-ind) / 2)',
8560
+ width: 'calc(100% - var(--steps-ind))',
8561
+ height: 'var(--border)',
7676
8562
  },
7677
8563
  '&[data-orientation="vertical"]': {
7678
8564
  left: 'calc(var(--space-xs) + (var(--steps-ind) - var(--border)) / 2)',
7679
8565
  top: 'calc(var(--space-xs) + var(--steps-ind))',
7680
8566
  bottom: 'calc(var(--space-xs) * -1)',
8567
+ width: 'var(--border)',
7681
8568
  },
7682
8569
  },
7683
8570
  },
8571
+ 'prev-trigger': {
8572
+ base: { ...lynxBtnPad(4), ...lynxBtnFit },
8573
+ states: { pressed: lynxBtnPressed, ...lynxFocus() },
8574
+ },
8575
+ 'next-trigger': {
8576
+ base: { ...lynxBtnPad(4), ...lynxBtnFit, marginLeft: 'auto' },
8577
+ states: { pressed: lynxBtnPressed, ...lynxFocus() },
8578
+ },
8579
+ },
8580
+ variants: {
8581
+ color: Object.fromEntries(ROLES.map((c) => [c, { item: { base: {
8582
+ '--steps-complete-tint': stepTint(`var(--color-${c})`),
8583
+ } } }])),
7684
8584
  },
8585
+ // lynx-zero stamps `wizard` on the root while a Content,
8586
+ // PrevTrigger or NextTrigger is mounted — the web's `:has()`.
8587
+ // A lynx-zero rendering detail, not a modifier an author sets.
8588
+ css: `
8589
+ .zx-steps__root.zx-m-wizard {
8590
+ flex-wrap: wrap;
8591
+ row-gap: var(--space-md);
8592
+ }
8593
+ `,
7685
8594
  },
7686
8595
  },
7687
8596
  };