@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/src/recipes.ts CHANGED
@@ -1625,12 +1625,34 @@ export const menu: RecipeInput = {
1625
1625
  // Trigger-carried axes — same wiring as dialog, same reason. The dropdown
1626
1626
  // and its items are top-layer siblings the donut cannot reach.
1627
1627
  variants: { color: btnColors(), size: btnSizes },
1628
- // The btn paddings, restated physically — see `lynxBtnPad` (#1084).
1628
+ // The btn paddings, restated physically — see `lynxBtnPad` (#1084) —
1629
+ // and the trigger's press rendering, as popover's (`lynxBtnPressed`).
1629
1630
  targets: {
1630
1631
  web: floatingArrow('menu', panelArrowPaint),
1631
1632
  lynx: {
1632
1633
  parts: {
1633
- trigger: { base: lynxBtnPad(4) },
1634
+ // Content-sized like daisy's btn (signalxjs/lynx#1165), an
1635
+ // explicit row (lynx does not default the direction the way a
1636
+ // browser does), the held state in place of the dropped
1637
+ // `:hover`, and the box-shadow ring (signalxjs/lynx#1163).
1638
+ trigger: {
1639
+ base: { ...lynxBtnPad(4), ...lynxBtnFit, flexDirection: 'row' },
1640
+ states: { pressed: lynxBtnPressed, ...lynxFocus() },
1641
+ },
1642
+ // The popups' rows stack and span the panel: a flex column
1643
+ // (a lynx view's default is a shrink-wrapping linear box).
1644
+ popup: { base: { display: 'flex', flexDirection: 'column' } },
1645
+ 'sub-popup': { base: { display: 'flex', flexDirection: 'column' } },
1646
+ // Every row kind is a mark / label / shortcut / chevron row.
1647
+ // A touch list has no hover: lynx-zero stamps `highlighted` on
1648
+ // the row under the finger, so the web's highlight wash is the
1649
+ // press rendering as it stands. The ✓ (item-indicator) and ›
1650
+ // (sub-trigger) the web draws with `::after` render as
1651
+ // `<text>` glyphs in lynx-zero — there is no pseudo-element.
1652
+ item: { base: { flexDirection: 'row' } },
1653
+ 'checkbox-item': { base: { flexDirection: 'row' } },
1654
+ 'radio-item': { base: { flexDirection: 'row' } },
1655
+ 'sub-trigger': { base: { flexDirection: 'row' } },
1634
1656
  // The shortcut's reading-end push, restated physically (#1084).
1635
1657
  shortcut: { base: { marginLeft: 'auto', paddingLeft: 'var(--space-lg)' } },
1636
1658
  },
@@ -3773,6 +3795,80 @@ export const avatar: RecipeInput = {
3773
3795
  xl: { root: { base: { '--avatar-size': 'calc(var(--size-selector) * 16)', '--avatar-text': 'var(--text-lg)' } } },
3774
3796
  },
3775
3797
  },
3798
+ // signalxjs/lynx#1237 — the lynx layout for what the web gets from grid.
3799
+ targets: {
3800
+ lynx: {
3801
+ parts: {
3802
+ // No grid on lynx (signalxjs/lynx#1075): the web stacks the
3803
+ // image and the fallback in one grid cell. Here the root is a
3804
+ // flex box that does not shrink in a row, and both faces are
3805
+ // laid over it absolutely — the fallback, later in the tree,
3806
+ // paints over a pending image exactly as the grid's second
3807
+ // item does. lynx-zero renders neither face while its state
3808
+ // hides it (`hiddenIn`), so the web's `&:not([hidden])`
3809
+ // display rule has nothing to replace.
3810
+ root: {
3811
+ base: {
3812
+ display: 'flex',
3813
+ flexDirection: 'row',
3814
+ alignItems: 'center',
3815
+ justifyContent: 'center',
3816
+ flexShrink: '0',
3817
+ },
3818
+ },
3819
+ // Each face also rounds itself to the root's shape (below),
3820
+ // rather than trusting the root's clip to round an image on
3821
+ // both engines.
3822
+ image: {
3823
+ base: { position: 'absolute', top: '0', left: '0', borderRadius: '9999px' },
3824
+ },
3825
+ fallback: {
3826
+ base: {
3827
+ position: 'absolute',
3828
+ top: '0',
3829
+ left: '0',
3830
+ borderRadius: '9999px',
3831
+ display: 'flex',
3832
+ alignItems: 'center',
3833
+ justifyContent: 'center',
3834
+ textAlign: 'center',
3835
+ },
3836
+ },
3837
+ },
3838
+ variants: {
3839
+ shape: Object.fromEntries(([
3840
+ ['circle', '9999px'], ['square', '0'], ['rounded', 'var(--radius-selector)'],
3841
+ ] as const).map(([shape, radius]) => [shape, {
3842
+ image: { base: { borderRadius: radius } },
3843
+ fallback: { base: { borderRadius: radius } },
3844
+ }])),
3845
+ },
3846
+ // Every avatar after the first in an AvatarGroup overlaps its
3847
+ // neighbour by a quarter of its own box — the web's
3848
+ // `avatar-group` compose, `&:not(:first-child)`. Lynx has neither
3849
+ // the nested-scope compose nor `:first-child`, so lynx-zero stamps
3850
+ // those avatars `stacked` itself (`zx-m-stacked`, in mount order).
3851
+ // A lynx-zero rendering detail, not a modifier an author sets, so
3852
+ // it is styled from raw lynx css, one rule per avatar size.
3853
+ css: `
3854
+ .zx-avatar__root.zx-m-stacked {
3855
+ margin-left: calc(var(--size-selector) * -2.5);
3856
+ }
3857
+ .zx-avatar__root.zx-m-stacked.zx-a-size-xs {
3858
+ margin-left: calc(var(--size-selector) * -1.5);
3859
+ }
3860
+ .zx-avatar__root.zx-m-stacked.zx-a-size-sm {
3861
+ margin-left: calc(var(--size-selector) * -2);
3862
+ }
3863
+ .zx-avatar__root.zx-m-stacked.zx-a-size-lg {
3864
+ margin-left: calc(var(--size-selector) * -3);
3865
+ }
3866
+ .zx-avatar__root.zx-m-stacked.zx-a-size-xl {
3867
+ margin-left: calc(var(--size-selector) * -4);
3868
+ }
3869
+ `,
3870
+ },
3871
+ },
3776
3872
  };
3777
3873
 
3778
3874
  /**
@@ -3867,6 +3963,33 @@ export const avatarGroup: RecipeInput = {
3867
3963
  md: {},
3868
3964
  },
3869
3965
  },
3966
+ // signalxjs/lynx#1237. The avatars inside take the group's size and
3967
+ // colour from lynx-zero (the `composes` above has no class form on this
3968
+ // target), and their overlap is the avatar recipe's `stacked` rule.
3969
+ targets: {
3970
+ lynx: {
3971
+ parts: {
3972
+ // A row that sizes to its faces (a lynx view stretches to its
3973
+ // column otherwise).
3974
+ root: { base: { display: 'flex', flexDirection: 'row', width: 'max-content' } },
3975
+ // `inline-grid` centring as flex, and the logical inline
3976
+ // padding and overlap as physical spellings — logical
3977
+ // margins and paddings resolve on iOS but not on Android
3978
+ // (signalxjs/lynx#1084); lynx has no RTL flow, so start IS left.
3979
+ overflow: {
3980
+ base: {
3981
+ display: 'flex',
3982
+ alignItems: 'center',
3983
+ justifyContent: 'center',
3984
+ flexShrink: '0',
3985
+ paddingLeft: 'var(--space-2xs)',
3986
+ paddingRight: 'var(--space-2xs)',
3987
+ marginLeft: 'calc(var(--avatar-group-size) * -0.25)',
3988
+ },
3989
+ },
3990
+ },
3991
+ },
3992
+ },
3870
3993
  };
3871
3994
 
3872
3995
  /**
@@ -4163,7 +4286,99 @@ export const toast: RecipeInput = {
4163
4286
  base: { position: 'absolute', top: 'var(--space-xs)', right: 'var(--space-xs)' },
4164
4287
  states: lynxFocus({ gap: 'var(--toast-bg)' }),
4165
4288
  },
4289
+ // The promise toast's mark (signalxjs/lynx#1196). No grid and
4290
+ // no `:has()`: the mark is laid over the card's leading
4291
+ // padding, at the root's own padding offsets (per size,
4292
+ // below), and lynx-zero stamps `marked` on every part of a
4293
+ // toast that shows it, so the text steps aside (the raw css
4294
+ // below) — the web's leading grid column.
4295
+ indicator: {
4296
+ base: {
4297
+ position: 'absolute',
4298
+ left: 'var(--space-lg)',
4299
+ top: 'var(--space-md)',
4300
+ width: 'var(--toast-mark)',
4301
+ height: 'var(--toast-mark)',
4302
+ },
4303
+ states: {
4304
+ // daisy's ring in the toast's ink. The track is the
4305
+ // ink at 25%, which cannot bake over the recipe-local
4306
+ // `--toast-ink`: the neutral toast's track mixes the
4307
+ // theme's base-content, and each colour's twin (the
4308
+ // variants below) its role. Longhands, and the head as
4309
+ // `border-top-color`: logical spellings are not lynx's
4310
+ // (signalxjs/lynx#1084), and a var-bearing shorthand
4311
+ // would repaint the head after the cascade (#1161).
4312
+ loading: {
4313
+ // The shared shorthand is dropped on lynx (its
4314
+ // track cannot bake); a static one here keeps it
4315
+ // that way should the shared value ever bake.
4316
+ border: '0 solid transparent',
4317
+ borderStyle: 'solid',
4318
+ ...lynxSides('border{}Width', 'calc(var(--border) * 2)'),
4319
+ ...lynxSides('border{}Color', 'color-mix(in oklab, var(--color-base-content) 25%, transparent)', ['Right', 'Bottom', 'Left']),
4320
+ borderTopColor: 'var(--toast-ink)',
4321
+ },
4322
+ // The tick without clip-path, which lynx does not apply
4323
+ // (signalxjs/lynx#1216): the checkbox's two-border
4324
+ // check — a box 3/8 of the mark wide and 3/4 tall with
4325
+ // its right and bottom borders drawn, turned 45° —
4326
+ // pulled to the middle of the mark's slot by its
4327
+ // margins. The fill the web cuts the tick from is
4328
+ // cleared.
4329
+ complete: {
4330
+ background: 'transparent',
4331
+ boxSizing: 'border-box',
4332
+ width: 'calc(var(--toast-mark) * 0.375)',
4333
+ height: 'calc(var(--toast-mark) * 0.75)',
4334
+ marginLeft: 'calc(var(--toast-mark) * 0.3125)',
4335
+ borderStyle: 'solid',
4336
+ borderColor: TOAST_SUCCESS_INK,
4337
+ borderTopWidth: '0',
4338
+ borderLeftWidth: '0',
4339
+ borderRightWidth: 'calc(var(--border) * 2)',
4340
+ borderBottomWidth: 'calc(var(--border) * 2)',
4341
+ transform: 'rotate(45deg)',
4342
+ },
4343
+ // The cross without clip-path: its two diagonal bars
4344
+ // as hard-stop gradients over a clear fill.
4345
+ error: {
4346
+ background: `linear-gradient(45deg, transparent 41%, ${TOAST_ERROR_INK} 41%, ${TOAST_ERROR_INK} 59%, transparent 59%), `
4347
+ + `linear-gradient(-45deg, transparent 41%, ${TOAST_ERROR_INK} 41%, ${TOAST_ERROR_INK} 59%, transparent 59%)`,
4348
+ },
4349
+ },
4350
+ },
4166
4351
  },
4352
+ variants: {
4353
+ // Each colour's ring track: its role at 25% (the web's mix
4354
+ // over `--toast-ink`, which is that role).
4355
+ color: Object.fromEntries(ROLES.map((role) => [role, {
4356
+ indicator: {
4357
+ states: {
4358
+ loading: lynxSides('border{}Color', `color-mix(in oklab, var(--color-${role}) 25%, transparent)`, ['Right', 'Bottom', 'Left']),
4359
+ },
4360
+ },
4361
+ }])),
4362
+ // The mark sits at the root's padding offsets for its size.
4363
+ size: {
4364
+ xs: { indicator: { base: { left: 'var(--space-md)', top: 'var(--space-xs)' } } },
4365
+ sm: { indicator: { base: { left: 'var(--space-md)', top: 'var(--space-sm)' } } },
4366
+ lg: { indicator: { base: { left: 'var(--space-xl)', top: 'var(--space-lg)' } } },
4367
+ xl: { indicator: { base: { left: 'var(--space-2xl)', top: 'var(--space-xl)' } } },
4368
+ },
4369
+ },
4370
+ // The text of a marked toast steps past the mark and the web's
4371
+ // column gap. `marked` is a lynx-zero rendering detail (the
4372
+ // spelling of `:has(> indicator)`), not a modifier an author sets,
4373
+ // so it is styled from raw lynx css. The reduced-motion and
4374
+ // forced-colors stops cannot follow: lynx emits no `@media`.
4375
+ css: `
4376
+ .zx-toast__title.zx-m-marked,
4377
+ .zx-toast__description.zx-m-marked,
4378
+ .zx-toast__action.zx-m-marked {
4379
+ margin-left: calc(var(--toast-mark) + var(--space-md));
4380
+ }
4381
+ `,
4167
4382
  },
4168
4383
  },
4169
4384
  // The viewport's `open` (the stack expanded) is a fact this skin has no
@@ -4259,7 +4474,8 @@ export const combobox: RecipeInput = {
4259
4474
  fontWeight: 'var(--weight-medium)',
4260
4475
  lineHeight: 'var(--leading-none)',
4261
4476
  },
4262
- states: { disabled: {} },
4477
+ // Arrowed onto from the input (#411).
4478
+ states: { disabled: {}, ...focusRing },
4263
4479
  },
4264
4480
  'tag-label': { base: { whiteSpace: 'nowrap' } },
4265
4481
  'tag-remove': {
@@ -5180,6 +5396,30 @@ const treeRowSelectors: NonNullable<PartStyles['selectors']> = {
5180
5396
  },
5181
5397
  };
5182
5398
 
5399
+ /**
5400
+ * The tree rows' lynx states (signalxjs/lynx#1256). The web's press wash is
5401
+ * `[data-pressed]:not([data-disabled]):not([data-selected])`; lynx has no
5402
+ * absence classes, so the wash is the plain `pressed` state and a selected
5403
+ * row restores its accent fill below. The web's descendant rule that swaps
5404
+ * the check box's pair on a selected row (`[data-selected] &` on the box)
5405
+ * becomes the ROW's own custom properties: they inherit to the box inside
5406
+ * it. The focus ring is inset (rows sit flush) and drawn as a box-shadow,
5407
+ * which follows the row's radius where lynx's `outline` does not.
5408
+ */
5409
+ const lynxTreeRowStates: Record<string, CssProps> = {
5410
+ pressed: { background: 'var(--color-base-300)' },
5411
+ selected: {
5412
+ '--tree-check': 'var(--tree-on-accent)',
5413
+ '--tree-on-check': 'var(--tree-accent)',
5414
+ },
5415
+ ...lynxFocus({ inset: true }),
5416
+ };
5417
+
5418
+ /** A held selected row keeps its accent fill (the web's `:not([data-selected])`). */
5419
+ const lynxTreeRowSelectors: Record<string, CssProps> = {
5420
+ '&[data-selected][data-pressed]': { background: 'var(--tree-accent)' },
5421
+ };
5422
+
5183
5423
  /**
5184
5424
  * `tickGlyphFallback` for the node check box, whose tick is its `::after`:
5185
5425
  * the geometry drops and the same pseudo carries the glyph, in the medium's
@@ -5330,12 +5570,37 @@ export const treeView: RecipeInput = {
5330
5570
  xl: { root: { base: { '--tree-text': 'var(--text-lg)' } } },
5331
5571
  },
5332
5572
  },
5333
- // The indent, restated physically: logical spellings resolve on iOS but
5334
- // not on Android (measured, signalxjs/lynx#1084), so the emitter refuses
5335
- // them. Physical is the lynx target's norm — no RTL flow there.
5573
+ // lynx (signalxjs/lynx#1256, zero#478). No hover on a touch platform, no
5574
+ // `:not()`, no pseudo-elements, no descendant selectors: each web rule
5575
+ // those carry is restated on the part the runtime stamps. The skin's
5576
+ // `:dir(rtl)` mirror and the `@media` stops have no lynx form (no RTL
5577
+ // flow, no conditions) and stay dropped.
5336
5578
  targets: {
5337
5579
  lynx: {
5338
5580
  parts: {
5581
+ item: { states: lynxTreeRowStates, selectors: lynxTreeRowSelectors },
5582
+ 'branch-trigger': { states: lynxTreeRowStates, selectors: lynxTreeRowSelectors },
5583
+ // `inline-block` is not a lynx display value. The glyph is a
5584
+ // `<text>` part: a 1em box centred on it, so the quarter turn
5585
+ // pivots on the chevron instead of swinging it sideways.
5586
+ 'branch-indicator': { base: { display: 'flex', width: '1em', textAlign: 'center' } },
5587
+ // The mark is a `<text>` child lynx-zero renders (`✓` checked,
5588
+ // `−` indeterminate) in place of the web's clip-path `::after`
5589
+ // tick — clip-path and pseudo-elements are both dropped on
5590
+ // lynx. The glyph is the forced-colors fallback's shape
5591
+ // (`nodeTickFallback`), inked with the box's `-on` pair and
5592
+ // sized by inheritance from the row.
5593
+ 'node-checkbox': {
5594
+ base: {
5595
+ display: 'flex',
5596
+ alignItems: 'center',
5597
+ justifyContent: 'center',
5598
+ color: 'var(--tree-on-check)',
5599
+ },
5600
+ },
5601
+ // The indent, restated physically: logical spellings resolve
5602
+ // on iOS but not on Android (measured, signalxjs/lynx#1084),
5603
+ // so the emitter refuses them.
5339
5604
  'branch-content': { base: { paddingLeft: 'var(--space-lg)' } },
5340
5605
  },
5341
5606
  },
@@ -5439,6 +5704,10 @@ export const input: RecipeInput = {
5439
5704
  selectors: {
5440
5705
  // Same muting, same reason as `number-input/input` (#264).
5441
5706
  '&::placeholder': { color: 'color-mix(in oklab, var(--color-base-content) 60%, transparent)' },
5707
+ // zero draws its own ClearTrigger and clears on Escape, and Firefox
5708
+ // draws no native clear: hide the engine's cancel button everywhere (#446).
5709
+ '&::-webkit-search-cancel-button': { appearance: 'none', display: 'none' },
5710
+ '&::-webkit-search-decoration': { appearance: 'none' },
5442
5711
  },
5443
5712
  },
5444
5713
  // daisy's `label` inside `.input`: a quiet prefix/suffix at one edge,
@@ -5638,11 +5907,18 @@ export const textarea: RecipeInput = {
5638
5907
  // lands on top of the 2px gap and paints one solid band. A fixed-height
5639
5908
  // box never re-lays out; this auto-height one does, as its native field
5640
5909
  // measures and grows. So the ring is spelled with one outset shadow and
5641
- // one inset shadow, which paint disjoint areas and cannot swap: the ring
5910
+ // inset shadows, which paint areas the outset never reaches: the ring
5642
5911
  // just outside the box, the box's own border repainted as the base-100
5643
- // gap, and the hairline redrawn inside it by the inset spread, in the
5912
+ // gap, and the hairline redrawn inside it by the edge inset's spread, in the
5644
5913
  // edge colour (`--textarea-edge`, the error colour while invalid). The
5645
5914
  // gap is the border's width instead of the web's 2px.
5915
+ //
5916
+ // Android paints inset shadows OVER the border, where iOS paints them
5917
+ // under it (signalxjs/lynx#1230): the base-100 border vanished and the
5918
+ // whole 2px inset showed as a grey band against the ring. So the gap is
5919
+ // also drawn as a 1px base-100 inset stacked above the edge inset. On
5920
+ // Android that inset is the gap. On iOS the border already covers it,
5921
+ // whichever order the layers end up in, so iOS paints as before.
5646
5922
  targets: {
5647
5923
  lynx: {
5648
5924
  parts: {
@@ -5659,6 +5935,9 @@ export const textarea: RecipeInput = {
5659
5935
  outline: 'none',
5660
5936
  borderColor: 'var(--color-base-100)',
5661
5937
  boxShadow: '0 0 0 2px var(--textarea-accent), '
5938
+ // The gap: the border's width in base-100,
5939
+ // for Android, which paints this over the border.
5940
+ + 'inset 0 0 0 1px var(--color-base-100), '
5662
5941
  // Twice the border (1px in every shipped
5663
5942
  // theme): the inner half shows as the hairline.
5664
5943
  + 'inset 0 0 0 2px var(--textarea-edge)',
@@ -5785,6 +6064,42 @@ export const card: RecipeInput = {
5785
6064
  xl: { root: { base: { '--card-pad': 'var(--space-2xl)' } } },
5786
6065
  },
5787
6066
  },
6067
+ targets: {
6068
+ lynx: {
6069
+ parts: {
6070
+ // The root does not clip on lynx: a clipping layer takes its
6071
+ // own shadow with it on iOS, and the card's elevation is the
6072
+ // daisy look. The media band rounds its own corners instead
6073
+ // (below), which is all the clip was for.
6074
+ root: { base: { overflow: 'visible' } },
6075
+ // `inline-size` has no lynx spelling; the image selector and
6076
+ // the `:first-child` / `:last-child` corners are dropped (no
6077
+ // child or structural pseudo-classes). The band clips its
6078
+ // own content, so an `<image>` inside it takes the corners.
6079
+ media: { base: { width: '100%', overflow: 'hidden' } },
6080
+ // A lynx view is a column unless told otherwise.
6081
+ footer: { base: { flexDirection: 'row' } },
6082
+ },
6083
+ // The corners the media band shares with the card
6084
+ // (signalxjs/lynx#1235). Lynx has no `:first-child` or
6085
+ // `:last-child`, so lynx-zero stamps the band itself from its
6086
+ // mount order among the card's bands, as `data-mod-first` /
6087
+ // `data-mod-last` (the `zx-m-first` / `zx-m-last` classes). They
6088
+ // are a lynx-zero rendering detail, not modifiers an author sets,
6089
+ // so they are not declared in the vocabulary and are styled from
6090
+ // raw lynx css (toggle-group's join ends are the precedent).
6091
+ css: `
6092
+ .zx-card__media.zx-m-first {
6093
+ border-top-left-radius: var(--radius-box);
6094
+ border-top-right-radius: var(--radius-box);
6095
+ }
6096
+ .zx-card__media.zx-m-last {
6097
+ border-bottom-left-radius: var(--radius-box);
6098
+ border-bottom-right-radius: var(--radius-box);
6099
+ }
6100
+ `,
6101
+ },
6102
+ },
5788
6103
  };
5789
6104
 
5790
6105
  /**
@@ -5876,6 +6191,100 @@ export const alert: RecipeInput = {
5876
6191
  xl: { root: { base: { padding: 'var(--space-xl) var(--space-2xl)' } } },
5877
6192
  },
5878
6193
  },
6194
+ targets: {
6195
+ // No grid on lynx (signalxjs/lynx#1075, #1235): the web's three
6196
+ // columns (the icon over both rows | the title over the description
6197
+ // | the close button) are rebuilt as a flex column with the icon and
6198
+ // the close button placed absolutely — the toast's close pin is the
6199
+ // precedent. The padding moves onto two properties so the icon and
6200
+ // the close button can follow it per size.
6201
+ lynx: {
6202
+ tokens: {
6203
+ '--alert-pad-y': 'var(--space-md)',
6204
+ '--alert-pad-x': 'var(--space-lg)',
6205
+ },
6206
+ parts: {
6207
+ root: {
6208
+ base: {
6209
+ display: 'flex',
6210
+ flexDirection: 'column',
6211
+ alignItems: 'stretch',
6212
+ gap: 'var(--space-sm)',
6213
+ position: 'relative',
6214
+ boxSizing: 'border-box',
6215
+ padding: 'var(--alert-pad-y) var(--alert-pad-x)',
6216
+ },
6217
+ },
6218
+ // The icon column: a --text-xl slot at the padding edge,
6219
+ // centred over the alert's height (the web's two-row span).
6220
+ icon: {
6221
+ base: {
6222
+ position: 'absolute',
6223
+ left: 'var(--alert-pad-x)',
6224
+ top: '0',
6225
+ bottom: '0',
6226
+ width: 'var(--text-xl)',
6227
+ display: 'flex',
6228
+ justifyContent: 'center',
6229
+ },
6230
+ },
6231
+ // Pinned top-right, its glyph on the padding edge. `inherit`
6232
+ // is spelled as the root's own ink. A square chip, the
6233
+ // glyph centred in it: the web's `line-height: 1` + 2xs
6234
+ // padding make an almost-square box, but lynx does not carry
6235
+ // the line-height into the glyph's `<text>`, so the padded
6236
+ // box was a narrow × in a tall line — a 14 × 20pt pill whose
6237
+ // press wash and focus ring drew as vertical ovals
6238
+ // (signalxjs/lynx#1253). The box is sized outright instead.
6239
+ close: {
6240
+ base: {
6241
+ position: 'absolute',
6242
+ top: 'calc(var(--alert-pad-y) - var(--space-2xs))',
6243
+ right: 'calc(var(--alert-pad-x) - var(--space-2xs))',
6244
+ color: 'var(--color-base-content)',
6245
+ fontSize: 'var(--text-md)',
6246
+ display: 'flex',
6247
+ alignItems: 'center',
6248
+ justifyContent: 'center',
6249
+ boxSizing: 'border-box',
6250
+ padding: '0',
6251
+ width: 'calc(var(--text-md) + var(--space-2xs) * 2)',
6252
+ height: 'calc(var(--text-md) + var(--space-2xs) * 2)',
6253
+ },
6254
+ states: lynxFocus({ gap: 'var(--alert-tint)' }),
6255
+ },
6256
+ },
6257
+ variants: {
6258
+ size: {
6259
+ xs: { root: { base: { '--alert-pad-y': 'var(--space-2xs)', '--alert-pad-x': 'var(--space-sm)' } } },
6260
+ sm: { root: { base: { '--alert-pad-y': 'var(--space-xs)', '--alert-pad-x': 'var(--space-md)' } } },
6261
+ lg: { root: { base: { '--alert-pad-y': 'var(--space-lg)', '--alert-pad-x': 'var(--space-xl)' } } },
6262
+ xl: { root: { base: { '--alert-pad-y': 'var(--space-xl)', '--alert-pad-x': 'var(--space-2xl)' } } },
6263
+ },
6264
+ },
6265
+ // The title and description keep clear of the icon column and
6266
+ // the close button's corner. Lynx has no `:has()`, so lynx-zero
6267
+ // stamps what the alert renders onto both text parts, as
6268
+ // `data-mod-with-icon` / `data-mod-with-close` (the
6269
+ // `zx-m-with-icon` / `zx-m-with-close` classes) — a lynx-zero
6270
+ // rendering detail like toggle-group's join ends, styled from raw
6271
+ // lynx css.
6272
+ css: `
6273
+ .zx-alert__title.zx-m-with-icon {
6274
+ margin-left: calc(var(--text-xl) + var(--space-md));
6275
+ }
6276
+ .zx-alert__description.zx-m-with-icon {
6277
+ margin-left: calc(var(--text-xl) + var(--space-md));
6278
+ }
6279
+ .zx-alert__title.zx-m-with-close {
6280
+ margin-right: calc(var(--text-md) + var(--space-md));
6281
+ }
6282
+ .zx-alert__description.zx-m-with-close {
6283
+ margin-right: calc(var(--text-md) + var(--space-md));
6284
+ }
6285
+ `,
6286
+ },
6287
+ },
5879
6288
  };
5880
6289
 
5881
6290
  /**
@@ -5958,6 +6367,25 @@ export const emptyState: RecipeInput = {
5958
6367
  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)' } } },
5959
6368
  },
5960
6369
  },
6370
+ // signalxjs/lynx#1236 — the lynx replacements for what the shared
6371
+ // sections cannot say there.
6372
+ targets: {
6373
+ lynx: {
6374
+ parts: {
6375
+ // Physical margins: Android resolves no logical margin
6376
+ // (signalxjs/lynx#1084).
6377
+ icon: { base: { marginBottom: 'var(--space-xs)' } },
6378
+ // Title and description are lynx `<text>`: a view's
6379
+ // `text-align` and `color` do not reach one unless the app
6380
+ // turned CSS inheritance on, so each states its own ink and
6381
+ // centring. The web's 36ch measure becomes the root's width:
6382
+ // `ch` is unmeasured on lynx.
6383
+ title: { base: { color: 'var(--color-base-content)', textAlign: 'center' } },
6384
+ description: { base: { textAlign: 'center', maxInlineSize: '100%' } },
6385
+ actions: { base: { flexDirection: 'row', marginTop: 'var(--space-sm)' } },
6386
+ },
6387
+ },
6388
+ },
5961
6389
  };
5962
6390
 
5963
6391
  /**
@@ -6060,6 +6488,62 @@ export const badge: RecipeInput = {
6060
6488
  }, keyframes: {
6061
6489
  '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; }',
6062
6490
  },
6491
+ /**
6492
+ * Lynx (signalxjs/lynx#1234). The dot's `currentColor` never resolves
6493
+ * there (lynx#1079), so the uncoloured dot and its ring take the pill's
6494
+ * ink token, the value `currentColor` names on the web. The running halo
6495
+ * and pulse mix the recipe-local `--badge-dot`, which cannot bake, so the
6496
+ * halo is its own token mixed from each role's `--color-*` (baked per
6497
+ * theme) and the pulse breathes opacity instead of the halo's spread.
6498
+ * `inline-size`/`block-size` do not resolve on lynx (lynx#1145): the dot
6499
+ * is sized physically. A lynx view has no inline formatting context, so
6500
+ * the pill is content-sized with an explicit row (lynx#1165), and its
6501
+ * hairline and the dot's ring are longhands (lynx#1161/#1162).
6502
+ */
6503
+ targets: {
6504
+ lynx: {
6505
+ tokens: {
6506
+ '--badge-dot': 'var(--badge-ink)',
6507
+ '--badge-dot-ring': 'var(--badge-ink)',
6508
+ '--badge-dot-halo': 'color-mix(in oklch, var(--color-base-content) 35%, transparent)',
6509
+ },
6510
+ parts: {
6511
+ root: {
6512
+ base: {
6513
+ ...lynxBtnFit,
6514
+ flexDirection: 'row',
6515
+ border: '0 solid transparent',
6516
+ ...lynxSides('border{}Width', 'var(--border)'),
6517
+ },
6518
+ },
6519
+ dot: {
6520
+ base: {
6521
+ display: 'flex',
6522
+ width: '0.5em',
6523
+ height: '0.5em',
6524
+ flexShrink: '0',
6525
+ border: '0 solid transparent',
6526
+ ...lynxSides('border{}Width', '0.1em'),
6527
+ ...lynxSides('border{}Color', 'var(--badge-dot-ring)'),
6528
+ },
6529
+ states: {
6530
+ running: {
6531
+ boxShadow: '0 0 0 0.2em var(--badge-dot-halo)',
6532
+ animation: 'zero-daisy-badge-pulse 1.6s ease-in-out infinite',
6533
+ },
6534
+ },
6535
+ },
6536
+ },
6537
+ variants: {
6538
+ color: Object.fromEntries(ROLES.map((c) => [c, {
6539
+ dot: { base: { '--badge-dot-halo': `color-mix(in oklch, var(--color-${c}) 35%, transparent)` } },
6540
+ }])),
6541
+ },
6542
+ keyframes: {
6543
+ 'zero-daisy-badge-pulse': 'from, to { opacity: 1; } 50% { opacity: 0.45; }',
6544
+ },
6545
+ },
6546
+ },
6063
6547
  };
6064
6548
 
6065
6549
  /** daisy "divider": the base-300 rule, thickening with the ramp. */
@@ -6130,6 +6614,11 @@ export const divider: RecipeInput = {
6130
6614
  },
6131
6615
  targets: {
6132
6616
  lynx: {
6617
+ // `auto` behind a custom property, so the labelled root's size
6618
+ // is var-bearing too and can beat the var-bearing line thickness
6619
+ // below (lynx expands var() declarations after the cascade,
6620
+ // signalxjs/lynx#1161).
6621
+ tokens: { '--divider-fit': 'auto' },
6133
6622
  parts: {
6134
6623
  // Lynx drops the ::before/::after segments the rule is drawn
6135
6624
  // with above, which left the lynx divider with no line and,
@@ -6144,7 +6633,64 @@ export const divider: RecipeInput = {
6144
6633
  '&[data-orientation="vertical"]': { inlineSize: 'var(--divider-thickness)' },
6145
6634
  },
6146
6635
  },
6636
+ // The label's inset, physical (Android resolves no logical
6637
+ // padding, signalxjs/lynx#1084), and flush on the side its
6638
+ // placement gave up. Lynx has no descendant combinator from
6639
+ // the root, so lynx-zero stamps the root's orientation on the
6640
+ // label itself; vertical, the inset turns to the block axis.
6641
+ // Every zero is spelled through var() so it beats the
6642
+ // var-bearing inset (signalxjs/lynx#1161).
6643
+ label: {
6644
+ base: {
6645
+ paddingLeft: 'var(--space-md)',
6646
+ paddingRight: 'var(--space-md)',
6647
+ },
6648
+ selectors: {
6649
+ '&[data-placement="start"]': { paddingLeft: 'calc(var(--space-md) * 0)' },
6650
+ '&[data-placement="end"]': { paddingRight: 'calc(var(--space-md) * 0)' },
6651
+ '&[data-orientation="vertical"]': {
6652
+ paddingLeft: 'calc(var(--space-md) * 0)',
6653
+ paddingRight: 'calc(var(--space-md) * 0)',
6654
+ paddingTop: 'var(--space-sm)',
6655
+ paddingBottom: 'var(--space-sm)',
6656
+ },
6657
+ '&[data-orientation="vertical"][data-placement="start"]': { paddingTop: 'calc(var(--space-sm) * 0)' },
6658
+ '&[data-orientation="vertical"][data-placement="end"]': { paddingBottom: 'calc(var(--space-sm) * 0)' },
6659
+ },
6660
+ },
6147
6661
  },
6662
+ // A labelled divider (signalxjs/lynx#1236). With no pseudo-
6663
+ // elements, lynx-zero draws the rule's two segments as views
6664
+ // carrying the root's own line classes plus `zx-m-segment` —
6665
+ // so each paints this recipe's line, ink and thickness ramp — and
6666
+ // leaves out the one on the label's placed side (the web's
6667
+ // `:has(> [data-placement])::before { display: none }`). The root
6668
+ // holding them is stamped `zx-m-labelled` and stops being a line:
6669
+ // no fill, its cross size following the label, a row when
6670
+ // horizontal. Both are lynx-zero rendering details, not
6671
+ // modifiers an author sets, so they are styled from raw lynx css
6672
+ // (the toggle-group join ends' reasoning).
6673
+ css: `
6674
+ .zx-divider__root.zx-m-labelled {
6675
+ --divider-ink: transparent;
6676
+ }
6677
+ .zx-divider__root.zx-m-labelled.zx-o-horizontal {
6678
+ flex-direction: row;
6679
+ block-size: var(--divider-fit);
6680
+ }
6681
+ .zx-divider__root.zx-m-labelled.zx-o-vertical {
6682
+ inline-size: var(--divider-fit);
6683
+ }
6684
+ .zx-divider__root.zx-m-segment {
6685
+ flex: 1 1 0;
6686
+ }
6687
+ .zx-divider__root.zx-m-segment.zx-o-horizontal {
6688
+ min-width: 0;
6689
+ }
6690
+ .zx-divider__root.zx-m-segment.zx-o-vertical {
6691
+ min-height: 0;
6692
+ }
6693
+ `,
6148
6694
  },
6149
6695
  },
6150
6696
  };
@@ -6201,13 +6747,29 @@ export const skeleton: RecipeInput = {
6201
6747
  keyframes: {
6202
6748
  'zero-daisyui-skeleton': 'from, to { opacity: 1; } 50% { opacity: 0.55; }',
6203
6749
  },
6750
+ // signalxjs/lynx#1237. The pulse is an opacity loop on the part itself —
6751
+ // no pseudo-element, so it carries as is; only its keyframes are
6752
+ // restated as three plain stops, not the `from, to` selector list. The
6753
+ // reduced-motion stop cannot follow: lynx emits no `@media`.
6754
+ targets: {
6755
+ lynx: {
6756
+ keyframes: {
6757
+ 'zero-daisyui-skeleton': '0% { opacity: 1; } 50% { opacity: 0.55; } 100% { opacity: 1; }',
6758
+ },
6759
+ },
6760
+ },
6204
6761
  };
6205
6762
 
6206
- /** daisy "loading-spinner": the ring, with one quadrant in the role's ink. */
6763
+ /**
6764
+ * daisy "loading-spinner": the ring, with one quadrant in the role's ink.
6765
+ * Sized on daisy's `loading-*` ramp — `--size-selector * 4…8` (16–32px), md
6766
+ * the `loading` default of `* 6` (signalxjs/lynx#1237: the ramp was
6767
+ * `--size-field * 0.35…0.9`, a 2px box that the 2px border swallowed).
6768
+ */
6207
6769
  export const spinner: RecipeInput = {
6208
6770
  component: 'spinner',
6209
6771
  tokens: {
6210
- '--spinner-size': 'calc(var(--size-field) * 0.5)',
6772
+ '--spinner-size': 'calc(var(--size-selector) * 6)',
6211
6773
  '--spinner-ink': 'var(--color-primary)',
6212
6774
  '--spinner-track': 'var(--color-base-300)',
6213
6775
  },
@@ -6231,14 +6793,40 @@ export const spinner: RecipeInput = {
6231
6793
  '--spinner-ink': `var(--color-${c})`,
6232
6794
  } } }])),
6233
6795
  size: {
6234
- xs: { root: { base: { '--spinner-size': 'calc(var(--size-field) * 0.35)' } } },
6235
- sm: { root: { base: { '--spinner-size': 'calc(var(--size-field) * 0.42)' } } },
6796
+ xs: { root: { base: { '--spinner-size': 'calc(var(--size-selector) * 4)' } } },
6797
+ sm: { root: { base: { '--spinner-size': 'calc(var(--size-selector) * 5)' } } },
6236
6798
  md: {},
6237
- lg: { root: { base: { '--spinner-size': 'calc(var(--size-field) * 0.7)' } } },
6238
- xl: { root: { base: { '--spinner-size': 'calc(var(--size-field) * 0.9)' } } },
6799
+ lg: { root: { base: { '--spinner-size': 'calc(var(--size-selector) * 7)' } } },
6800
+ xl: { root: { base: { '--spinner-size': 'calc(var(--size-selector) * 8)' } } },
6239
6801
  },
6240
6802
  },
6241
6803
  keyframes: { 'zero-daisyui-spin': 'to { transform: rotate(360deg); }' },
6804
+ // signalxjs/lynx#1237 — the ring in physical spellings. The logical box
6805
+ // size and `border-block-start-color` are not lynx's; the ring is
6806
+ // restated as longhands, because a var-bearing `border` shorthand
6807
+ // expands after the cascade and would repaint the head in the track
6808
+ // colour (signalxjs/lynx#1161). A flex box, since `inline-block` is not a
6809
+ // lynx display value, that keeps its size in a row. The reduced-motion
6810
+ // stop cannot follow: lynx emits no `@media`.
6811
+ targets: {
6812
+ lynx: {
6813
+ parts: {
6814
+ root: {
6815
+ base: {
6816
+ display: 'flex',
6817
+ flexShrink: '0',
6818
+ width: 'var(--spinner-size)',
6819
+ height: 'var(--spinner-size)',
6820
+ border: '0 solid transparent',
6821
+ borderStyle: 'solid',
6822
+ ...lynxSides('border{}Width', 'calc(var(--border) * 2)'),
6823
+ ...lynxSides('border{}Color', 'var(--spinner-track)', ['Right', 'Bottom', 'Left']),
6824
+ borderTopColor: 'var(--spinner-ink)',
6825
+ },
6826
+ },
6827
+ },
6828
+ },
6829
+ },
6242
6830
  };
6243
6831
 
6244
6832
  // ── The content-tier sweep (#334) ─────────────────────────────────────────
@@ -6285,6 +6873,44 @@ export const kbd: RecipeInput = {
6285
6873
  xl: { root: { base: { fontSize: 'var(--text-md)', padding: 'var(--space-xs) var(--space-lg)' } } },
6286
6874
  },
6287
6875
  },
6876
+ /**
6877
+ * Lynx (signalxjs/lynx#1234). `min-inline-size` and the logical bottom
6878
+ * edge do not resolve on lynx (lynx#1145, #1084), so the cap's floor is
6879
+ * a physical `min-width` and its doubled bottom edge a physical
6880
+ * longhand; the whole hairline is longhands so a var-bearing shorthand
6881
+ * cannot expand over the thicker edge (lynx#1161/#1162). A lynx view has
6882
+ * no inline formatting context: the cap is content-sized with an
6883
+ * explicit row (lynx#1165).
6884
+ */
6885
+ targets: {
6886
+ lynx: {
6887
+ parts: {
6888
+ root: {
6889
+ base: {
6890
+ ...lynxBtnFit,
6891
+ flexDirection: 'row',
6892
+ minWidth: '1.75em',
6893
+ border: '0 solid transparent',
6894
+ ...lynxSides('border{}Width', 'var(--border)', ['Top', 'Right', 'Left']),
6895
+ borderBottomWidth: 'calc(var(--border) * 2)',
6896
+ ...lynxSides('border{}Color', 'var(--color-base-300)'),
6897
+ // The web's monospace cap comes from the browser's
6898
+ // UA style for `<kbd>`, not the recipe; lynx has no
6899
+ // UA sheet, so the face is spelled here
6900
+ // (signalxjs/lynx#1254). Concrete names, not a
6901
+ // token: iOS Lynx walks the list with
6902
+ // `UIFont fontWithName:` (Menlo ships on iOS, the
6903
+ // CSS generics do not resolve there), and
6904
+ // `monospace` serves the lynx web target.
6905
+ fontFamily: 'Menlo, monospace',
6906
+ },
6907
+ },
6908
+ },
6909
+ variants: {
6910
+ size: { xs: { root: { base: { minWidth: '1.5em' } } } },
6911
+ },
6912
+ },
6913
+ },
6288
6914
  };
6289
6915
 
6290
6916
  /**
@@ -6326,6 +6952,37 @@ export const status: RecipeInput = {
6326
6952
  xl: { root: { base: { '--status-size': 'calc(var(--size-selector) * 3.5)' } } },
6327
6953
  },
6328
6954
  },
6955
+ /**
6956
+ * Lynx (signalxjs/lynx#1234). The halo mixes the recipe-local
6957
+ * `--status-ink`, which cannot bake, so it is its own token mixed from
6958
+ * each role's `--color-*` (baked per theme). `inline-size`/`block-size`
6959
+ * do not resolve on lynx (lynx#1145): the dot is sized physically, and
6960
+ * it is a flex box rather than an inline-block the engine has no
6961
+ * formatting context for.
6962
+ */
6963
+ targets: {
6964
+ lynx: {
6965
+ tokens: {
6966
+ '--status-halo': 'color-mix(in oklch, var(--color-base-content) 25%, transparent)',
6967
+ },
6968
+ parts: {
6969
+ root: {
6970
+ base: {
6971
+ display: 'flex',
6972
+ flexShrink: '0',
6973
+ width: 'var(--status-size)',
6974
+ height: 'var(--status-size)',
6975
+ boxShadow: '0 0 0 calc(var(--status-size) / 4) var(--status-halo)',
6976
+ },
6977
+ },
6978
+ },
6979
+ variants: {
6980
+ color: Object.fromEntries(ROLES.map((c) => [c, { root: { base: {
6981
+ '--status-halo': `color-mix(in oklch, var(--color-${c}) 25%, transparent)`,
6982
+ } } }])),
6983
+ },
6984
+ },
6985
+ },
6329
6986
  };
6330
6987
 
6331
6988
  /**
@@ -6510,6 +7167,90 @@ export const stats: RecipeInput = {
6510
7167
  xl: { value: { base: { fontSize: 'var(--text-3xl)' } } },
6511
7168
  },
6512
7169
  },
7170
+ // signalxjs/lynx#1236 — the lynx replacements for what the shared
7171
+ // sections cannot say there.
7172
+ targets: {
7173
+ lynx: {
7174
+ parts: {
7175
+ // A row unless vertical: a lynx flex container does not
7176
+ // default to one.
7177
+ root: { base: { flexDirection: 'row' } },
7178
+ // Lynx has no grid, so `display: grid` left the item in the
7179
+ // engine's default linear layout. The bands stack in a
7180
+ // centred column instead, and the figure is pinned to the
7181
+ // item's end edge (below). The padding as longhands, so the
7182
+ // `figure` rule below can widen one side by specificity (a
7183
+ // var-bearing `padding` shorthand would expand over it,
7184
+ // signalxjs/lynx#1161).
7185
+ item: {
7186
+ base: {
7187
+ display: 'flex',
7188
+ flexDirection: 'column',
7189
+ justifyContent: 'center',
7190
+ position: 'relative',
7191
+ padding: '0',
7192
+ paddingTop: 'var(--space-lg)',
7193
+ paddingBottom: 'var(--space-lg)',
7194
+ paddingLeft: 'var(--space-xl)',
7195
+ paddingRight: 'var(--space-xl)',
7196
+ },
7197
+ selectors: {
7198
+ // The seams: lynx has no sibling combinator, so the
7199
+ // web's `item + item` edge cannot be selected. Every
7200
+ // item draws its leading seam and the `first`
7201
+ // modifier takes it off the first (raw css below).
7202
+ '&[data-orientation="horizontal"]': {
7203
+ borderLeftWidth: 'var(--border)',
7204
+ borderLeftStyle: 'solid',
7205
+ borderLeftColor: 'var(--color-base-200)',
7206
+ },
7207
+ '&[data-orientation="vertical"]': {
7208
+ borderTopWidth: 'var(--border)',
7209
+ borderTopStyle: 'solid',
7210
+ borderTopColor: 'var(--color-base-200)',
7211
+ },
7212
+ },
7213
+ },
7214
+ // The web's second grid column, spanning the three bands and
7215
+ // centred on them: pinned to the item's end edge, inside its
7216
+ // padding, and centred top to bottom.
7217
+ figure: {
7218
+ base: {
7219
+ position: 'absolute',
7220
+ top: '0',
7221
+ bottom: '0',
7222
+ right: 'var(--space-xl)',
7223
+ // The web centres the grid cell with align-self; the
7224
+ // insets place it here.
7225
+ alignSelf: 'auto',
7226
+ display: 'flex',
7227
+ flexDirection: 'column',
7228
+ justifyContent: 'center',
7229
+ },
7230
+ },
7231
+ },
7232
+ // lynx-zero stamps the row's first item `zx-m-first` (mount
7233
+ // order; no `:first-child`) and an item holding a figure
7234
+ // `zx-m-figure`. Rendering details, not author modifiers, so raw
7235
+ // lynx css (the toggle-group join ends' reasoning). The first
7236
+ // item drops the leading seam, its zero width spelled through
7237
+ // var() to beat the var-bearing seam (signalxjs/lynx#1161). An
7238
+ // item with a figure keeps the figure's column clear: the
7239
+ // padding, the web's `--space-md` column gap, and a 2rem figure
7240
+ // (daisy's `stat-figure` holds a `size-8` icon or avatar).
7241
+ css: `
7242
+ .zx-stats__item.zx-m-first.zx-o-horizontal {
7243
+ border-left-width: calc(var(--border) * 0);
7244
+ }
7245
+ .zx-stats__item.zx-m-first.zx-o-vertical {
7246
+ border-top-width: calc(var(--border) * 0);
7247
+ }
7248
+ .zx-stats__item.zx-m-figure {
7249
+ padding-right: calc(var(--space-xl) + var(--space-md) + 2rem);
7250
+ }
7251
+ `,
7252
+ },
7253
+ },
6513
7254
  };
6514
7255
 
6515
7256
  /**
@@ -7231,6 +7972,28 @@ export const navbar: RecipeInput = {
7231
7972
  xl: { root: { base: { minBlockSize: '6rem', fontSize: 'var(--text-lg)' } } },
7232
7973
  },
7233
7974
  },
7975
+ // Lynx (signalxjs/lynx#1257): `min-block-size` is not a lynx property,
7976
+ // so the bar's height ramp is restated as `min-height`; and a lynx flex
7977
+ // container is only a row when it says so, so the bar and its three
7978
+ // sections spell `flex-direction: row`.
7979
+ targets: {
7980
+ lynx: {
7981
+ parts: {
7982
+ root: { base: { flexDirection: 'row', minHeight: '4rem' } },
7983
+ start: { base: { flexDirection: 'row' } },
7984
+ center: { base: { flexDirection: 'row' } },
7985
+ end: { base: { flexDirection: 'row' } },
7986
+ },
7987
+ variants: {
7988
+ size: {
7989
+ xs: { root: { base: { minHeight: '2.5rem' } } },
7990
+ sm: { root: { base: { minHeight: '3rem' } } },
7991
+ lg: { root: { base: { minHeight: '5rem' } } },
7992
+ xl: { root: { base: { minHeight: '6rem' } } },
7993
+ },
7994
+ },
7995
+ },
7996
+ },
7234
7997
  };
7235
7998
 
7236
7999
  /**
@@ -7290,6 +8053,14 @@ export const navList: RecipeInput = {
7290
8053
  alignItems: 'center',
7291
8054
  gap: 'var(--space-sm)',
7292
8055
  textDecoration: 'none',
8056
+ // Without an href the link is a <button> (#451): drop its chrome.
8057
+ appearance: 'none',
8058
+ background: 'none',
8059
+ border: '0',
8060
+ font: 'inherit',
8061
+ textAlign: 'start',
8062
+ inlineSize: '100%',
8063
+ cursor: 'pointer',
7293
8064
  color: 'var(--nav-ink)',
7294
8065
  padding: 'var(--space-sm) var(--space-lg)',
7295
8066
  borderRadius: 'var(--radius-field)',
@@ -7337,6 +8108,27 @@ export const navList: RecipeInput = {
7337
8108
  xl: { root: { base: { fontSize: 'var(--text-lg)' } }, link: { base: { padding: 'var(--space-lg) var(--space-xl)' } } },
7338
8109
  },
7339
8110
  },
8111
+ // The web's block and inline boxes, restated for lynx (signalxjs/lynx#1259).
8112
+ // No `:hover` on a touch platform, and the link's anatomy declares no
8113
+ // `pressed` flag: a held link has lynx-zero's main-thread press scale.
8114
+ targets: {
8115
+ lynx: {
8116
+ parts: {
8117
+ // `display: block` has no lynx projection: a column, so the
8118
+ // link spans the list as a block `<li>` makes it.
8119
+ item: { base: { display: 'flex', flexDirection: 'column' } },
8120
+ // The icon / label / meta row, explicit (lynx does not
8121
+ // default the direction the way a browser does).
8122
+ link: { base: { flexDirection: 'row' } },
8123
+ // `inline-size` restated physically; the glyph centred in
8124
+ // its column on both axes.
8125
+ icon: { base: { width: '1.25em', flexDirection: 'row', alignItems: 'center' } },
8126
+ // The count's push to the far edge, restated physically —
8127
+ // `margin-inline-start` does not resolve on Android (#1084).
8128
+ meta: { base: { marginLeft: 'auto', flexDirection: 'row' } },
8129
+ },
8130
+ },
8131
+ },
7340
8132
  };
7341
8133
 
7342
8134
  /**
@@ -7446,6 +8238,37 @@ export const breadcrumbs: RecipeInput = {
7446
8238
  xl: { root: { base: { fontSize: 'var(--text-lg)' } } },
7447
8239
  },
7448
8240
  },
8241
+ // Lynx (signalxjs/lynx#1257):
8242
+ // - The list, the items and the ellipsis are explicit rows (a lynx flex
8243
+ // container is only a row when it says so).
8244
+ // - The trigger's `hover` ink is dropped on a touch platform; the
8245
+ // `pressed` flag carries it instead. Its resting ink moves onto a
8246
+ // lynx-only token so the base rule stays a plain var() read: the
8247
+ // emitter bakes a `color-mix` declaration per theme under the host
8248
+ // class, and that baked rule would outrank the pressed one.
8249
+ // - The focus ring is the lynx box-shadow ring (outline has no offset
8250
+ // and ignores the radius there).
8251
+ // - The link's `hover` stays dropped: the link anatomy declares no
8252
+ // pressed flag, so there is nothing on lynx to carry it.
8253
+ // - The separator is a real part on both targets (lynx-zero renders it
8254
+ // as a text glyph), so daisy's `::before` chevron needs no lynx form.
8255
+ targets: {
8256
+ lynx: {
8257
+ tokens: { '--bc-trigger-ink': 'color-mix(in oklch, var(--color-base-content) 75%, transparent)' },
8258
+ parts: {
8259
+ list: { base: { flexDirection: 'row' } },
8260
+ item: { base: { flexDirection: 'row' } },
8261
+ ellipsis: { base: { flexDirection: 'row' } },
8262
+ 'ellipsis-trigger': {
8263
+ base: { color: 'var(--bc-trigger-ink)' },
8264
+ states: {
8265
+ pressed: { color: 'var(--color-base-content)' },
8266
+ 'focus-visible': lynxFocusRing('var(--color-primary)'),
8267
+ },
8268
+ },
8269
+ },
8270
+ },
8271
+ },
7449
8272
  };
7450
8273
 
7451
8274
  /**
@@ -7484,6 +8307,19 @@ const pageTrigger: PartStyles = {
7484
8307
  },
7485
8308
  };
7486
8309
 
8310
+ /** A pagination cell's box on lynx — see the recipe's lynx section. */
8311
+ const pageCellLynx: NonNullable<PartStyles['base']> = {
8312
+ minWidth: 'var(--pg-size)',
8313
+ height: 'var(--pg-size)',
8314
+ flexShrink: '0',
8315
+ };
8316
+
8317
+ /** The four triggers on lynx: the cell box, the held wash + sink, the ring. */
8318
+ const pageTriggerLynx: PartStyles = {
8319
+ base: pageCellLynx,
8320
+ states: { pressed: { background: 'var(--color-base-300)' }, ...lynxFocus() },
8321
+ };
8322
+
7487
8323
  /**
7488
8324
  * Pagination — daisy's join-of-buttons made of standalone btn cells: the
7489
8325
  * quiet base-200 fill, base-300 hover, the current page inverted into the
@@ -7580,13 +8416,35 @@ export const pagination: RecipeInput = {
7580
8416
  xl: { root: { base: { '--pg-size': 'calc(var(--size-field) * 14)', '--pg-font': 'var(--text-lg)' } } },
7581
8417
  },
7582
8418
  },
7583
- // The cell's inline padding, restated physically: logical spellings
7584
- // resolve on iOS but not on Android (measured, signalxjs/lynx#1084), so
7585
- // the emitter refuses them.
8419
+ // Lynx (signalxjs/lynx#1258):
8420
+ // - the cells' size, restated physically: `min-inline-size` and
8421
+ // `block-size` do not resolve on lynx (lynx#1145), so every cell
8422
+ // collapsed to its glyph. Read through `--pg-size`, so the compile
8423
+ // inlines the size steps per part, as it does the web's;
8424
+ // - the item's inline padding, physical: logical spellings resolve on
8425
+ // iOS but not on Android (measured, lynx#1084);
8426
+ // - the press: the web answers a pointer with `:hover`, which a touch
8427
+ // platform drops, so the held cell paints daisy's hover wash with the
8428
+ // 1px sink (`lynxBtnPressed`'s pair) — the current page keeps its
8429
+ // accent fill and only sinks;
8430
+ // - the focus ring as `lynxFocus`: lynx's outline has no offset and
8431
+ // ignores the radius (lynx#1163);
8432
+ // - no row shrink: a cell never squeezes below its size in a narrow row.
8433
+ // The glyphs' rtl flip has no replacement: lynx has no RTL flow.
7586
8434
  targets: {
7587
8435
  lynx: {
7588
8436
  parts: {
7589
- item: { base: { paddingLeft: 'var(--space-2xs)', paddingRight: 'var(--space-2xs)' } },
8437
+ root: { base: { flexDirection: 'row' } },
8438
+ item: {
8439
+ base: { ...pageCellLynx, paddingLeft: 'var(--space-2xs)', paddingRight: 'var(--space-2xs)' },
8440
+ states: lynxFocus(),
8441
+ selectors: { '&[data-pressed][data-state="inactive"]': { background: 'var(--color-base-300)' } },
8442
+ },
8443
+ ellipsis: { base: pageCellLynx },
8444
+ 'first-trigger': pageTriggerLynx,
8445
+ 'prev-trigger': pageTriggerLynx,
8446
+ 'next-trigger': pageTriggerLynx,
8447
+ 'last-trigger': pageTriggerLynx,
7590
8448
  },
7591
8449
  },
7592
8450
  },
@@ -7606,6 +8464,12 @@ const stepInk = (accent: string, role: string): string =>
7606
8464
  `color-mix(in oklab, ${accent} `
7607
8465
  + `${Math.min(ROLE_INK_KEEP[role as keyof typeof roles] ?? 55, 70)}%, var(--color-base-content))`;
7608
8466
 
8467
+ /**
8468
+ * The complete disc's fill: 20% of the accent over base-100, in OKLAB (see
8469
+ * the indicator's `complete` state for why not OKLCH).
8470
+ */
8471
+ const stepTint = (accent: string): string => `color-mix(in oklab, ${accent} 20%, var(--color-base-100))`;
8472
+
7609
8473
  /** A Steps root holding wizard parts (#296) — the panel or the triggers. */
7610
8474
  const STEPS_WIZARD = '&:has(> [data-scope="steps"]:is([data-part="content"], [data-part="prev-trigger"], [data-part="next-trigger"]))';
7611
8475
 
@@ -7723,7 +8587,7 @@ export const steps: RecipeInput = {
7723
8587
  // (warning's yellow came out pink) — which the browser parity
7724
8588
  // check caught once per-step colour put all eight roles on
7725
8589
  // the disc (#112).
7726
- complete: { background: 'color-mix(in oklab, var(--steps-accent) 20%, var(--color-base-100))', color: 'var(--steps-complete-ink)' },
8590
+ complete: { background: stepTint('var(--steps-accent)'), color: 'var(--steps-complete-ink)' },
7727
8591
  inactive: { background: 'var(--color-base-200)', color: 'var(--color-base-content)' },
7728
8592
  invalid: { background: 'var(--color-error)', color: 'var(--color-error-content)' },
7729
8593
  },
@@ -7811,32 +8675,82 @@ export const steps: RecipeInput = {
7811
8675
  xl: { root: { base: { '--steps-ind': 'calc(var(--size-selector) * 9)', '--steps-font': 'var(--text-md)' } } },
7812
8676
  },
7813
8677
  },
7814
- // The bridge's anchors and the vertical item's tail padding, restated
7815
- // physically: logical spellings resolve on iOS but not on Android
7816
- // (measured, signalxjs/lynx#1084), so the emitter refuses them. Physical
7817
- // is the lynx target's norm — no RTL flow there to mirror with.
8678
+ // Lynx. Physical spellings throughout: logical insets, margins and
8679
+ // paddings resolve on iOS but not on Android (measured,
8680
+ // signalxjs/lynx#1084), and `inline-size` / `block-size` do not resolve
8681
+ // at all (lynx#1145) — without the restatement the discs and lines did
8682
+ // not paint. Also (signalxjs/lynx#1258):
8683
+ // - the complete disc's tint and ink read `--steps-accent` through a
8684
+ // colour function, which cannot bake; the base pair is restated over
8685
+ // primary, and each colour already restates the ink on its item, so
8686
+ // the tint joins it there;
8687
+ // - the bridge runs from this disc's edge to the next disc's edge —
8688
+ // `50% + ind/2` for `100% - ind` — instead of centre to centre under
8689
+ // the disc, so it needs no z-index across sibling items; the item
8690
+ // stops clipping (a lynx view's `overflow` defaults to hidden) so the
8691
+ // bridge can reach past its box;
8692
+ // - the `:has()` wizard wrap becomes the `wizard` stamp lynx-zero puts on
8693
+ // the root while a panel or trigger is mounted (raw css below);
8694
+ // - the focus ring as `lynxFocus` (lynx#1163), and Back/Next's press as
8695
+ // daisy's hover wash + sink (`lynxBtnPressed`), `:hover` being dropped.
7818
8696
  targets: {
7819
8697
  lynx: {
8698
+ tokens: {
8699
+ '--steps-complete-ink': stepInk('var(--color-primary)', 'primary'),
8700
+ '--steps-complete-tint': stepTint('var(--color-primary)'),
8701
+ },
7820
8702
  parts: {
8703
+ root: { base: { flexDirection: 'row', overflow: 'visible' } },
7821
8704
  item: {
8705
+ base: { overflow: 'visible' },
8706
+ states: lynxFocus(),
7822
8707
  selectors: {
7823
8708
  '&[data-orientation="vertical"]': { paddingBottom: 'var(--space-lg)' },
7824
8709
  },
7825
8710
  },
8711
+ indicator: {
8712
+ base: { width: 'var(--steps-ind)', height: 'var(--steps-ind)' },
8713
+ states: { complete: { background: 'var(--steps-complete-tint)' } },
8714
+ },
7826
8715
  separator: {
7827
8716
  selectors: {
7828
8717
  '&[data-orientation="horizontal"]': {
7829
- top: 'calc(var(--space-xs) + var(--steps-ind) / 2)',
7830
- left: '50%',
8718
+ top: 'calc(var(--space-xs) + (var(--steps-ind) - var(--border)) / 2)',
8719
+ left: 'calc(50% + var(--steps-ind) / 2)',
8720
+ width: 'calc(100% - var(--steps-ind))',
8721
+ height: 'var(--border)',
7831
8722
  },
7832
8723
  '&[data-orientation="vertical"]': {
7833
8724
  left: 'calc(var(--space-xs) + (var(--steps-ind) - var(--border)) / 2)',
7834
8725
  top: 'calc(var(--space-xs) + var(--steps-ind))',
7835
8726
  bottom: 'calc(var(--space-xs) * -1)',
8727
+ width: 'var(--border)',
7836
8728
  },
7837
8729
  },
7838
8730
  },
8731
+ 'prev-trigger': {
8732
+ base: { ...lynxBtnPad(4), ...lynxBtnFit },
8733
+ states: { pressed: lynxBtnPressed, ...lynxFocus() },
8734
+ },
8735
+ 'next-trigger': {
8736
+ base: { ...lynxBtnPad(4), ...lynxBtnFit, marginLeft: 'auto' },
8737
+ states: { pressed: lynxBtnPressed, ...lynxFocus() },
8738
+ },
8739
+ },
8740
+ variants: {
8741
+ color: Object.fromEntries(ROLES.map((c) => [c, { item: { base: {
8742
+ '--steps-complete-tint': stepTint(`var(--color-${c})`),
8743
+ } } }])),
7839
8744
  },
8745
+ // lynx-zero stamps `wizard` on the root while a Content,
8746
+ // PrevTrigger or NextTrigger is mounted — the web's `:has()`.
8747
+ // A lynx-zero rendering detail, not a modifier an author sets.
8748
+ css: `
8749
+ .zx-steps__root.zx-m-wizard {
8750
+ flex-wrap: wrap;
8751
+ row-gap: var(--space-md);
8752
+ }
8753
+ `,
7840
8754
  },
7841
8755
  },
7842
8756
  };