@sigx/zero-daisyui 0.12.0 → 0.14.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/recipes.ts CHANGED
@@ -1825,6 +1825,13 @@ export const fieldset: RecipeInput = {
1825
1825
  * horizontal bar and lifts it to the middle. All three verbatim from daisyUI
1826
1826
  * 5.7.8's `.checkbox:before` / `:checked` / `:indeterminate`.
1827
1827
  */
1828
+ /**
1829
+ * The lynx tick's stroke: 20% of the checkbox's content box — the width of
1830
+ * daisy's polygon bars — as a length, since borders take no percentage.
1831
+ * The content box is the control less its padding and border on both sides.
1832
+ */
1833
+ const CHECKBOX_LYNX_STROKE = 'calc((var(--checkbox-size) - var(--checkbox-pad) * 2 - var(--border) * 2) * 0.2)';
1834
+
1828
1835
  const TICK_COLLAPSED = 'polygon(20% 100%, 20% 80%, 50% 80%, 50% 80%, 70% 80%, 70% 100%)';
1829
1836
  const TICK_DRAWN = 'polygon(20% 100%, 20% 80%, 50% 80%, 50% 0%, 70% 0%, 70% 100%)';
1830
1837
  const DASH_DRAWN = 'polygon(20% 100%, 20% 80%, 50% 80%, 50% 80%, 80% 80%, 80% 100%)';
@@ -2064,26 +2071,62 @@ export const checkbox: RecipeInput = {
2064
2071
  },
2065
2072
  },
2066
2073
  indicator: {
2067
- // The tick's fill: the control's `color:` names
2068
- // `--checkbox-on-accent`, and `currentColor` was that
2069
- // ink by inheritance — spend it directly.
2074
+ // The tick WITHOUT clip-path, which lynx does not apply
2075
+ // (measured on iOS, signalxjs/lynx#1216 — daisy's
2076
+ // polygon-cut box drew as a solid diamond; the emitter
2077
+ // now drops every clip-path). daisy's drawn polygon is an
2078
+ // L inside the content box: a bar at x 50–70% over the
2079
+ // full height and a foot at y 80–100% from x 20%, both
2080
+ // 20% of the box thick, rotated 45° about the box's
2081
+ // centre. That is exactly the classic two-border check:
2082
+ // a 50%-wide full-height box at x 20% with a right and a
2083
+ // bottom border 20% of the content box thick, turned
2084
+ // about the content box's centre (60% 50% of itself).
2070
2085
  //
2071
- // The tick's motion, restated as `transform`: the
2072
- // standalone `rotate`/`translate` properties resolve on
2073
- // iOS but not on Android (measured, signalxjs/lynx#1084),
2074
- // so the emitter refuses them. `transform` functions are
2075
- // proven on both platforms — and the transition follows
2076
- // the property that now moves.
2086
+ // The ink: the control's `color:` names
2087
+ // `--checkbox-on-accent`, and `currentColor` was that ink
2088
+ // by inheritance (it never resolves on lynx,
2089
+ // signalxjs/lynx#1079) — spent directly on the borders.
2090
+ //
2091
+ // The motion is `transform`: the standalone `rotate` /
2092
+ // `translate` properties resolve on iOS but not on
2093
+ // Android (signalxjs/lynx#1084). No sheen shadow — on a
2094
+ // hollow box it would band the inside of the L.
2077
2095
  base: {
2078
- backgroundColor: 'var(--checkbox-on-accent)',
2096
+ boxSizing: 'border-box',
2097
+ width: '50%',
2098
+ height: '100%',
2099
+ marginLeft: '20%',
2100
+ backgroundColor: 'transparent',
2101
+ borderStyle: 'solid',
2102
+ borderColor: 'var(--checkbox-on-accent)',
2103
+ borderTopWidth: '0',
2104
+ borderLeftWidth: '0',
2105
+ borderRightWidth: CHECKBOX_LYNX_STROKE,
2106
+ borderBottomWidth: CHECKBOX_LYNX_STROKE,
2107
+ boxShadow: 'none',
2108
+ transformOrigin: '60% 50%',
2079
2109
  transform: 'rotate(45deg)',
2080
- transition: 'clip-path var(--duration-slow) var(--ease-standard) var(--duration-instant), '
2081
- + 'opacity var(--duration-instant) var(--ease-standard) var(--duration-instant), '
2110
+ transition: 'opacity var(--duration-instant) var(--ease-standard) var(--duration-instant), '
2082
2111
  + 'transform var(--duration-slow) var(--ease-standard) var(--duration-instant)',
2083
2112
  },
2084
- // The order matches the standalone properties' fixed
2085
- // apply order (translate, then rotate).
2086
- states: { indeterminate: { transform: 'translateY(-35%) rotate(0deg)' } },
2113
+ states: {
2114
+ // daisy's dash (signalxjs/lynx#1217): the same six
2115
+ // points un-rotated into a bar at x 20–80%, 20% of
2116
+ // the box tall, lifted to the middle. Here a filled,
2117
+ // borderless 60% × 20% box centred on the cross
2118
+ // axis — the unclipped full-height box had painted
2119
+ // the top half of the control.
2120
+ indeterminate: {
2121
+ width: '60%',
2122
+ height: '20%',
2123
+ alignSelf: 'center',
2124
+ borderRightWidth: '0',
2125
+ borderBottomWidth: '0',
2126
+ backgroundColor: 'var(--checkbox-on-accent)',
2127
+ transform: 'rotate(0deg)',
2128
+ },
2129
+ },
2087
2130
  },
2088
2131
  },
2089
2132
  },
@@ -3730,6 +3773,80 @@ export const avatar: RecipeInput = {
3730
3773
  xl: { root: { base: { '--avatar-size': 'calc(var(--size-selector) * 16)', '--avatar-text': 'var(--text-lg)' } } },
3731
3774
  },
3732
3775
  },
3776
+ // signalxjs/lynx#1237 — the lynx layout for what the web gets from grid.
3777
+ targets: {
3778
+ lynx: {
3779
+ parts: {
3780
+ // No grid on lynx (signalxjs/lynx#1075): the web stacks the
3781
+ // image and the fallback in one grid cell. Here the root is a
3782
+ // flex box that does not shrink in a row, and both faces are
3783
+ // laid over it absolutely — the fallback, later in the tree,
3784
+ // paints over a pending image exactly as the grid's second
3785
+ // item does. lynx-zero renders neither face while its state
3786
+ // hides it (`hiddenIn`), so the web's `&:not([hidden])`
3787
+ // display rule has nothing to replace.
3788
+ root: {
3789
+ base: {
3790
+ display: 'flex',
3791
+ flexDirection: 'row',
3792
+ alignItems: 'center',
3793
+ justifyContent: 'center',
3794
+ flexShrink: '0',
3795
+ },
3796
+ },
3797
+ // Each face also rounds itself to the root's shape (below),
3798
+ // rather than trusting the root's clip to round an image on
3799
+ // both engines.
3800
+ image: {
3801
+ base: { position: 'absolute', top: '0', left: '0', borderRadius: '9999px' },
3802
+ },
3803
+ fallback: {
3804
+ base: {
3805
+ position: 'absolute',
3806
+ top: '0',
3807
+ left: '0',
3808
+ borderRadius: '9999px',
3809
+ display: 'flex',
3810
+ alignItems: 'center',
3811
+ justifyContent: 'center',
3812
+ textAlign: 'center',
3813
+ },
3814
+ },
3815
+ },
3816
+ variants: {
3817
+ shape: Object.fromEntries(([
3818
+ ['circle', '9999px'], ['square', '0'], ['rounded', 'var(--radius-selector)'],
3819
+ ] as const).map(([shape, radius]) => [shape, {
3820
+ image: { base: { borderRadius: radius } },
3821
+ fallback: { base: { borderRadius: radius } },
3822
+ }])),
3823
+ },
3824
+ // Every avatar after the first in an AvatarGroup overlaps its
3825
+ // neighbour by a quarter of its own box — the web's
3826
+ // `avatar-group` compose, `&:not(:first-child)`. Lynx has neither
3827
+ // the nested-scope compose nor `:first-child`, so lynx-zero stamps
3828
+ // those avatars `stacked` itself (`zx-m-stacked`, in mount order).
3829
+ // A lynx-zero rendering detail, not a modifier an author sets, so
3830
+ // it is styled from raw lynx css, one rule per avatar size.
3831
+ css: `
3832
+ .zx-avatar__root.zx-m-stacked {
3833
+ margin-left: calc(var(--size-selector) * -2.5);
3834
+ }
3835
+ .zx-avatar__root.zx-m-stacked.zx-a-size-xs {
3836
+ margin-left: calc(var(--size-selector) * -1.5);
3837
+ }
3838
+ .zx-avatar__root.zx-m-stacked.zx-a-size-sm {
3839
+ margin-left: calc(var(--size-selector) * -2);
3840
+ }
3841
+ .zx-avatar__root.zx-m-stacked.zx-a-size-lg {
3842
+ margin-left: calc(var(--size-selector) * -3);
3843
+ }
3844
+ .zx-avatar__root.zx-m-stacked.zx-a-size-xl {
3845
+ margin-left: calc(var(--size-selector) * -4);
3846
+ }
3847
+ `,
3848
+ },
3849
+ },
3733
3850
  };
3734
3851
 
3735
3852
  /**
@@ -3824,6 +3941,33 @@ export const avatarGroup: RecipeInput = {
3824
3941
  md: {},
3825
3942
  },
3826
3943
  },
3944
+ // signalxjs/lynx#1237. The avatars inside take the group's size and
3945
+ // colour from lynx-zero (the `composes` above has no class form on this
3946
+ // target), and their overlap is the avatar recipe's `stacked` rule.
3947
+ targets: {
3948
+ lynx: {
3949
+ parts: {
3950
+ // A row that sizes to its faces (a lynx view stretches to its
3951
+ // column otherwise).
3952
+ root: { base: { display: 'flex', flexDirection: 'row', width: 'max-content' } },
3953
+ // `inline-grid` centring as flex, and the logical inline
3954
+ // padding and overlap as physical spellings — logical
3955
+ // margins and paddings resolve on iOS but not on Android
3956
+ // (signalxjs/lynx#1084); lynx has no RTL flow, so start IS left.
3957
+ overflow: {
3958
+ base: {
3959
+ display: 'flex',
3960
+ alignItems: 'center',
3961
+ justifyContent: 'center',
3962
+ flexShrink: '0',
3963
+ paddingLeft: 'var(--space-2xs)',
3964
+ paddingRight: 'var(--space-2xs)',
3965
+ marginLeft: 'calc(var(--avatar-group-size) * -0.25)',
3966
+ },
3967
+ },
3968
+ },
3969
+ },
3970
+ },
3827
3971
  };
3828
3972
 
3829
3973
  /**
@@ -4120,7 +4264,99 @@ export const toast: RecipeInput = {
4120
4264
  base: { position: 'absolute', top: 'var(--space-xs)', right: 'var(--space-xs)' },
4121
4265
  states: lynxFocus({ gap: 'var(--toast-bg)' }),
4122
4266
  },
4267
+ // The promise toast's mark (signalxjs/lynx#1196). No grid and
4268
+ // no `:has()`: the mark is laid over the card's leading
4269
+ // padding, at the root's own padding offsets (per size,
4270
+ // below), and lynx-zero stamps `marked` on every part of a
4271
+ // toast that shows it, so the text steps aside (the raw css
4272
+ // below) — the web's leading grid column.
4273
+ indicator: {
4274
+ base: {
4275
+ position: 'absolute',
4276
+ left: 'var(--space-lg)',
4277
+ top: 'var(--space-md)',
4278
+ width: 'var(--toast-mark)',
4279
+ height: 'var(--toast-mark)',
4280
+ },
4281
+ states: {
4282
+ // daisy's ring in the toast's ink. The track is the
4283
+ // ink at 25%, which cannot bake over the recipe-local
4284
+ // `--toast-ink`: the neutral toast's track mixes the
4285
+ // theme's base-content, and each colour's twin (the
4286
+ // variants below) its role. Longhands, and the head as
4287
+ // `border-top-color`: logical spellings are not lynx's
4288
+ // (signalxjs/lynx#1084), and a var-bearing shorthand
4289
+ // would repaint the head after the cascade (#1161).
4290
+ loading: {
4291
+ // The shared shorthand is dropped on lynx (its
4292
+ // track cannot bake); a static one here keeps it
4293
+ // that way should the shared value ever bake.
4294
+ border: '0 solid transparent',
4295
+ borderStyle: 'solid',
4296
+ ...lynxSides('border{}Width', 'calc(var(--border) * 2)'),
4297
+ ...lynxSides('border{}Color', 'color-mix(in oklab, var(--color-base-content) 25%, transparent)', ['Right', 'Bottom', 'Left']),
4298
+ borderTopColor: 'var(--toast-ink)',
4299
+ },
4300
+ // The tick without clip-path, which lynx does not apply
4301
+ // (signalxjs/lynx#1216): the checkbox's two-border
4302
+ // check — a box 3/8 of the mark wide and 3/4 tall with
4303
+ // its right and bottom borders drawn, turned 45° —
4304
+ // pulled to the middle of the mark's slot by its
4305
+ // margins. The fill the web cuts the tick from is
4306
+ // cleared.
4307
+ complete: {
4308
+ background: 'transparent',
4309
+ boxSizing: 'border-box',
4310
+ width: 'calc(var(--toast-mark) * 0.375)',
4311
+ height: 'calc(var(--toast-mark) * 0.75)',
4312
+ marginLeft: 'calc(var(--toast-mark) * 0.3125)',
4313
+ borderStyle: 'solid',
4314
+ borderColor: TOAST_SUCCESS_INK,
4315
+ borderTopWidth: '0',
4316
+ borderLeftWidth: '0',
4317
+ borderRightWidth: 'calc(var(--border) * 2)',
4318
+ borderBottomWidth: 'calc(var(--border) * 2)',
4319
+ transform: 'rotate(45deg)',
4320
+ },
4321
+ // The cross without clip-path: its two diagonal bars
4322
+ // as hard-stop gradients over a clear fill.
4323
+ error: {
4324
+ background: `linear-gradient(45deg, transparent 41%, ${TOAST_ERROR_INK} 41%, ${TOAST_ERROR_INK} 59%, transparent 59%), `
4325
+ + `linear-gradient(-45deg, transparent 41%, ${TOAST_ERROR_INK} 41%, ${TOAST_ERROR_INK} 59%, transparent 59%)`,
4326
+ },
4327
+ },
4328
+ },
4123
4329
  },
4330
+ variants: {
4331
+ // Each colour's ring track: its role at 25% (the web's mix
4332
+ // over `--toast-ink`, which is that role).
4333
+ color: Object.fromEntries(ROLES.map((role) => [role, {
4334
+ indicator: {
4335
+ states: {
4336
+ loading: lynxSides('border{}Color', `color-mix(in oklab, var(--color-${role}) 25%, transparent)`, ['Right', 'Bottom', 'Left']),
4337
+ },
4338
+ },
4339
+ }])),
4340
+ // The mark sits at the root's padding offsets for its size.
4341
+ size: {
4342
+ xs: { indicator: { base: { left: 'var(--space-md)', top: 'var(--space-xs)' } } },
4343
+ sm: { indicator: { base: { left: 'var(--space-md)', top: 'var(--space-sm)' } } },
4344
+ lg: { indicator: { base: { left: 'var(--space-xl)', top: 'var(--space-lg)' } } },
4345
+ xl: { indicator: { base: { left: 'var(--space-2xl)', top: 'var(--space-xl)' } } },
4346
+ },
4347
+ },
4348
+ // The text of a marked toast steps past the mark and the web's
4349
+ // column gap. `marked` is a lynx-zero rendering detail (the
4350
+ // spelling of `:has(> indicator)`), not a modifier an author sets,
4351
+ // so it is styled from raw lynx css. The reduced-motion and
4352
+ // forced-colors stops cannot follow: lynx emits no `@media`.
4353
+ css: `
4354
+ .zx-toast__title.zx-m-marked,
4355
+ .zx-toast__description.zx-m-marked,
4356
+ .zx-toast__action.zx-m-marked {
4357
+ margin-left: calc(var(--toast-mark) + var(--space-md));
4358
+ }
4359
+ `,
4124
4360
  },
4125
4361
  },
4126
4362
  // The viewport's `open` (the stack expanded) is a fact this skin has no
@@ -4149,6 +4385,11 @@ export const combobox: RecipeInput = {
4149
4385
  flexWrap: 'wrap',
4150
4386
  minWidth: '13rem',
4151
4387
  ...fieldControl,
4388
+ // The one field control that wraps: under `multiple` its tags
4389
+ // flow onto new rows, so the field step is a floor, not a cap —
4390
+ // a fixed height spills wrapped tags over the next field (#407).
4391
+ height: 'auto',
4392
+ minHeight: fieldHeight('md'),
4152
4393
  transition: 'border-color var(--duration-fast) var(--ease-standard)',
4153
4394
  },
4154
4395
  states: {
@@ -4211,7 +4452,8 @@ export const combobox: RecipeInput = {
4211
4452
  fontWeight: 'var(--weight-medium)',
4212
4453
  lineHeight: 'var(--leading-none)',
4213
4454
  },
4214
- states: { disabled: {} },
4455
+ // Arrowed onto from the input (#411).
4456
+ states: { disabled: {}, ...focusRing },
4215
4457
  },
4216
4458
  'tag-label': { base: { whiteSpace: 'nowrap' } },
4217
4459
  'tag-remove': {
@@ -4496,6 +4738,11 @@ export const toggle: RecipeInput = {
4496
4738
  },
4497
4739
  };
4498
4740
 
4741
+ /** A toggle-group end item's outer corner: the frame's radius inside its hairline. */
4742
+ const joinInnerRadius = 'calc(var(--radius-field) - var(--border))';
4743
+ /** A zero seam width that still holds var(), so it can beat the var-bearing seam on lynx. */
4744
+ const noSeam = 'calc(var(--border) * 0)';
4745
+
4499
4746
  // daisy "join" of btns: one bordered capsule, hairline seams between items,
4500
4747
  // the on item filled with the accent.
4501
4748
  export const toggleGroup: RecipeInput = {
@@ -4625,15 +4872,13 @@ export const toggleGroup: RecipeInput = {
4625
4872
  ...lynxSides('border{}Width', 'var(--border)'),
4626
4873
  ...lynxSides('border{}Color', 'var(--color-base-300)'),
4627
4874
  },
4628
- selectors: {
4629
- // The join seams: lynx has no sibling combinator, so
4630
- // the web's `item + item` edge cannot be selected.
4631
- // Every item draws its leading seam instead (below),
4632
- // and the frame gives up that edge — the first item's
4633
- // seam stands in for it, inside the frame's clip.
4634
- '&[data-orientation="horizontal"]': { borderLeftWidth: '0' },
4635
- '&[data-orientation="vertical"]': { borderTopWidth: '0' },
4636
- },
4875
+ // The frame keeps all four edges (signalxjs/lynx#1218).
4876
+ // It used to give up its leading edge to the first item's
4877
+ // seam and trust its own clip to round that item's
4878
+ // corners, but on iOS the frame's clip does not round the
4879
+ // leading corners: the first item painted square, and its
4880
+ // seam was lost with them. The `first`/`last` modifiers
4881
+ // below carry the join's ends instead.
4637
4882
  },
4638
4883
  item: {
4639
4884
  base: { flexShrink: '0', flexWrap: 'nowrap' },
@@ -4649,6 +4894,10 @@ export const toggleGroup: RecipeInput = {
4649
4894
  },
4650
4895
  selectors: {
4651
4896
  '&[data-state="on"][data-pressed]': { background: 'var(--toggle-group-accent)' },
4897
+ // The join seams: lynx has no sibling combinator, so
4898
+ // the web's `item + item` edge cannot be selected.
4899
+ // Every item draws its leading seam, and the `first`
4900
+ // modifier takes it off the item at the frame's edge.
4652
4901
  '&[data-orientation="horizontal"]': {
4653
4902
  borderLeftWidth: 'var(--border)',
4654
4903
  borderLeftStyle: 'solid',
@@ -4662,6 +4911,40 @@ export const toggleGroup: RecipeInput = {
4662
4911
  },
4663
4912
  },
4664
4913
  },
4914
+ // The ends of the join (signalxjs/lynx#1218). Lynx has no
4915
+ // `:first-child` or `:last-child`, so lynx-zero stamps the end
4916
+ // items itself, as `data-mod-first` / `data-mod-last` (the
4917
+ // `zx-m-first` / `zx-m-last` classes). They are a lynx-zero
4918
+ // rendering detail, not modifiers an author sets, so they are
4919
+ // not declared in the vocabulary and are styled from raw lynx
4920
+ // css. The end items round their own outer corners to the
4921
+ // frame's inner radius, so an on or held end item follows the
4922
+ // frame's corners without relying on its clip; `first` also
4923
+ // drops the leading seam, which would double the frame's edge.
4924
+ // That zero width is spelled through var() on purpose: a static
4925
+ // longhand loses to the var-bearing seam width whatever its
4926
+ // specificity (lynx expands var() declarations after the
4927
+ // cascade, signalxjs/lynx#1161).
4928
+ css: `
4929
+ .zx-toggle-group__item.zx-m-first.zx-o-horizontal {
4930
+ border-left-width: ${noSeam};
4931
+ border-top-left-radius: ${joinInnerRadius};
4932
+ border-bottom-left-radius: ${joinInnerRadius};
4933
+ }
4934
+ .zx-toggle-group__item.zx-m-first.zx-o-vertical {
4935
+ border-top-width: ${noSeam};
4936
+ border-top-left-radius: ${joinInnerRadius};
4937
+ border-top-right-radius: ${joinInnerRadius};
4938
+ }
4939
+ .zx-toggle-group__item.zx-m-last.zx-o-horizontal {
4940
+ border-top-right-radius: ${joinInnerRadius};
4941
+ border-bottom-right-radius: ${joinInnerRadius};
4942
+ }
4943
+ .zx-toggle-group__item.zx-m-last.zx-o-vertical {
4944
+ border-bottom-left-radius: ${joinInnerRadius};
4945
+ border-bottom-right-radius: ${joinInnerRadius};
4946
+ }
4947
+ `,
4665
4948
  },
4666
4949
  },
4667
4950
  };
@@ -4830,7 +5113,19 @@ export const numberInput: RecipeInput = {
4830
5113
  base: { overflow: 'visible' },
4831
5114
  states: { 'focus-visible': lynxFocusRing('var(--number-input-accent)') },
4832
5115
  },
4833
- input: { base: { color: 'var(--color-base-content)', flexGrow: '1' } },
5116
+ // `::placeholder` does not exist on lynx, so the web's 60%
5117
+ // mix rides lynx's own `-x-placeholder-color` (baked per
5118
+ // theme) — without it the platform default reads dark on
5119
+ // dark (signalxjs/lynx#1222). Spelled out, not the
5120
+ // `textFieldPlaceholder` const: that one is declared further
5121
+ // down the module, after this literal is evaluated.
5122
+ input: {
5123
+ base: {
5124
+ color: 'var(--color-base-content)',
5125
+ '-x-placeholder-color': 'color-mix(in oklab, var(--color-base-content) 60%, transparent)',
5126
+ flexGrow: '1',
5127
+ },
5128
+ },
4834
5129
  'increment-trigger': {
4835
5130
  base: {
4836
5131
  display: 'flex',
@@ -5447,6 +5742,10 @@ export const input: RecipeInput = {
5447
5742
  },
5448
5743
  };
5449
5744
 
5745
+ /** The lynx textarea floor: two field heights, as ONE calc() (signalxjs/lynx#1219). */
5746
+ const textareaFloor = (step: keyof typeof FIELD_STEPS): string =>
5747
+ `calc(var(--size-field) * ${FIELD_STEPS[step] * 2})`;
5748
+
5450
5749
  /**
5451
5750
  * daisy "textarea" flavor. The `fieldControl` box minus its `height`: the
5452
5751
  * whole point of a textarea is that its height is the content's (and the
@@ -5519,12 +5818,64 @@ export const textarea: RecipeInput = {
5519
5818
  // are explicit here — `color: inherit` and `::placeholder` never reach a
5520
5819
  // native widget — and the web's `:hover` border and outline ring become
5521
5820
  // the lynx focus ring in the root's accent (signalxjs/lynx#1163).
5821
+ //
5822
+ // The floor is one flat `calc(var(--size-field) * n)` per size
5823
+ // (signalxjs/lynx#1219): the shared `calc(fieldHeight * 2)` nests a
5824
+ // calc() in a calc(), and on device the box never took it — an empty
5825
+ // textarea sat one line tall.
5826
+ //
5827
+ // The focus ring is NOT `lynxFocusRing`'s two stacked outset shadows
5828
+ // (signalxjs/lynx#1220). Lynx iOS re-inserts a view's outset shadow
5829
+ // layers at a stale index whenever it re-lays the view out
5830
+ // (`LynxBackgroundManager.updateShadow` reads the sublayer count before
5831
+ // removing the old layers), which reverses their stacking: the 4px ring
5832
+ // lands on top of the 2px gap and paints one solid band. A fixed-height
5833
+ // box never re-lays out; this auto-height one does, as its native field
5834
+ // measures and grows. So the ring is spelled with one outset shadow and
5835
+ // inset shadows, which paint areas the outset never reaches: the ring
5836
+ // just outside the box, the box's own border repainted as the base-100
5837
+ // gap, and the hairline redrawn inside it by the edge inset's spread, in the
5838
+ // edge colour (`--textarea-edge`, the error colour while invalid). The
5839
+ // gap is the border's width instead of the web's 2px.
5840
+ //
5841
+ // Android paints inset shadows OVER the border, where iOS paints them
5842
+ // under it (signalxjs/lynx#1230): the base-100 border vanished and the
5843
+ // whole 2px inset showed as a grey band against the ring. So the gap is
5844
+ // also drawn as a 1px base-100 inset stacked above the edge inset. On
5845
+ // Android that inset is the gap. On iOS the border already covers it,
5846
+ // whichever order the layers end up in, so iOS paints as before.
5522
5847
  targets: {
5523
5848
  lynx: {
5524
5849
  parts: {
5525
5850
  textarea: {
5526
- base: { color: 'var(--color-base-content)', '-x-placeholder-color': textFieldPlaceholder },
5527
- states: { 'focus-visible': lynxFocusRing('var(--textarea-accent)') },
5851
+ base: {
5852
+ color: 'var(--color-base-content)',
5853
+ '-x-placeholder-color': textFieldPlaceholder,
5854
+ '--textarea-edge': 'var(--color-base-300)',
5855
+ minHeight: textareaFloor('md'),
5856
+ },
5857
+ states: {
5858
+ invalid: { '--textarea-edge': 'var(--color-error)' },
5859
+ 'focus-visible': {
5860
+ outline: 'none',
5861
+ borderColor: 'var(--color-base-100)',
5862
+ boxShadow: '0 0 0 2px var(--textarea-accent), '
5863
+ // The gap: the border's width in base-100,
5864
+ // for Android, which paints this over the border.
5865
+ + 'inset 0 0 0 1px var(--color-base-100), '
5866
+ // Twice the border (1px in every shipped
5867
+ // theme): the inner half shows as the hairline.
5868
+ + 'inset 0 0 0 2px var(--textarea-edge)',
5869
+ },
5870
+ },
5871
+ },
5872
+ },
5873
+ variants: {
5874
+ size: {
5875
+ xs: { textarea: { base: { minHeight: textareaFloor('xs') } } },
5876
+ sm: { textarea: { base: { minHeight: textareaFloor('sm') } } },
5877
+ lg: { textarea: { base: { minHeight: textareaFloor('lg') } } },
5878
+ xl: { textarea: { base: { minHeight: textareaFloor('xl') } } },
5528
5879
  },
5529
5880
  },
5530
5881
  },
@@ -5638,6 +5989,42 @@ export const card: RecipeInput = {
5638
5989
  xl: { root: { base: { '--card-pad': 'var(--space-2xl)' } } },
5639
5990
  },
5640
5991
  },
5992
+ targets: {
5993
+ lynx: {
5994
+ parts: {
5995
+ // The root does not clip on lynx: a clipping layer takes its
5996
+ // own shadow with it on iOS, and the card's elevation is the
5997
+ // daisy look. The media band rounds its own corners instead
5998
+ // (below), which is all the clip was for.
5999
+ root: { base: { overflow: 'visible' } },
6000
+ // `inline-size` has no lynx spelling; the image selector and
6001
+ // the `:first-child` / `:last-child` corners are dropped (no
6002
+ // child or structural pseudo-classes). The band clips its
6003
+ // own content, so an `<image>` inside it takes the corners.
6004
+ media: { base: { width: '100%', overflow: 'hidden' } },
6005
+ // A lynx view is a column unless told otherwise.
6006
+ footer: { base: { flexDirection: 'row' } },
6007
+ },
6008
+ // The corners the media band shares with the card
6009
+ // (signalxjs/lynx#1235). Lynx has no `:first-child` or
6010
+ // `:last-child`, so lynx-zero stamps the band itself from its
6011
+ // mount order among the card's bands, as `data-mod-first` /
6012
+ // `data-mod-last` (the `zx-m-first` / `zx-m-last` classes). They
6013
+ // are a lynx-zero rendering detail, not modifiers an author sets,
6014
+ // so they are not declared in the vocabulary and are styled from
6015
+ // raw lynx css (toggle-group's join ends are the precedent).
6016
+ css: `
6017
+ .zx-card__media.zx-m-first {
6018
+ border-top-left-radius: var(--radius-box);
6019
+ border-top-right-radius: var(--radius-box);
6020
+ }
6021
+ .zx-card__media.zx-m-last {
6022
+ border-bottom-left-radius: var(--radius-box);
6023
+ border-bottom-right-radius: var(--radius-box);
6024
+ }
6025
+ `,
6026
+ },
6027
+ },
5641
6028
  };
5642
6029
 
5643
6030
  /**
@@ -5729,6 +6116,87 @@ export const alert: RecipeInput = {
5729
6116
  xl: { root: { base: { padding: 'var(--space-xl) var(--space-2xl)' } } },
5730
6117
  },
5731
6118
  },
6119
+ targets: {
6120
+ // No grid on lynx (signalxjs/lynx#1075, #1235): the web's three
6121
+ // columns (the icon over both rows | the title over the description
6122
+ // | the close button) are rebuilt as a flex column with the icon and
6123
+ // the close button placed absolutely — the toast's close pin is the
6124
+ // precedent. The padding moves onto two properties so the icon and
6125
+ // the close button can follow it per size.
6126
+ lynx: {
6127
+ tokens: {
6128
+ '--alert-pad-y': 'var(--space-md)',
6129
+ '--alert-pad-x': 'var(--space-lg)',
6130
+ },
6131
+ parts: {
6132
+ root: {
6133
+ base: {
6134
+ display: 'flex',
6135
+ flexDirection: 'column',
6136
+ alignItems: 'stretch',
6137
+ gap: 'var(--space-sm)',
6138
+ position: 'relative',
6139
+ boxSizing: 'border-box',
6140
+ padding: 'var(--alert-pad-y) var(--alert-pad-x)',
6141
+ },
6142
+ },
6143
+ // The icon column: a --text-xl slot at the padding edge,
6144
+ // centred over the alert's height (the web's two-row span).
6145
+ icon: {
6146
+ base: {
6147
+ position: 'absolute',
6148
+ left: 'var(--alert-pad-x)',
6149
+ top: '0',
6150
+ bottom: '0',
6151
+ width: 'var(--text-xl)',
6152
+ display: 'flex',
6153
+ justifyContent: 'center',
6154
+ },
6155
+ },
6156
+ // Pinned top-right, its glyph on the padding edge. `inherit`
6157
+ // is spelled as the root's own ink.
6158
+ close: {
6159
+ base: {
6160
+ position: 'absolute',
6161
+ top: 'calc(var(--alert-pad-y) - var(--space-2xs))',
6162
+ right: 'calc(var(--alert-pad-x) - var(--space-2xs))',
6163
+ color: 'var(--color-base-content)',
6164
+ fontSize: 'var(--text-md)',
6165
+ },
6166
+ states: lynxFocus({ gap: 'var(--alert-tint)' }),
6167
+ },
6168
+ },
6169
+ variants: {
6170
+ size: {
6171
+ xs: { root: { base: { '--alert-pad-y': 'var(--space-2xs)', '--alert-pad-x': 'var(--space-sm)' } } },
6172
+ sm: { root: { base: { '--alert-pad-y': 'var(--space-xs)', '--alert-pad-x': 'var(--space-md)' } } },
6173
+ lg: { root: { base: { '--alert-pad-y': 'var(--space-lg)', '--alert-pad-x': 'var(--space-xl)' } } },
6174
+ xl: { root: { base: { '--alert-pad-y': 'var(--space-xl)', '--alert-pad-x': 'var(--space-2xl)' } } },
6175
+ },
6176
+ },
6177
+ // The title and description keep clear of the icon column and
6178
+ // the close button's corner. Lynx has no `:has()`, so lynx-zero
6179
+ // stamps what the alert renders onto both text parts, as
6180
+ // `data-mod-with-icon` / `data-mod-with-close` (the
6181
+ // `zx-m-with-icon` / `zx-m-with-close` classes) — a lynx-zero
6182
+ // rendering detail like toggle-group's join ends, styled from raw
6183
+ // lynx css.
6184
+ css: `
6185
+ .zx-alert__title.zx-m-with-icon {
6186
+ margin-left: calc(var(--text-xl) + var(--space-md));
6187
+ }
6188
+ .zx-alert__description.zx-m-with-icon {
6189
+ margin-left: calc(var(--text-xl) + var(--space-md));
6190
+ }
6191
+ .zx-alert__title.zx-m-with-close {
6192
+ margin-right: calc(var(--text-md) + var(--space-md));
6193
+ }
6194
+ .zx-alert__description.zx-m-with-close {
6195
+ margin-right: calc(var(--text-md) + var(--space-md));
6196
+ }
6197
+ `,
6198
+ },
6199
+ },
5732
6200
  };
5733
6201
 
5734
6202
  /**
@@ -5811,6 +6279,25 @@ export const emptyState: RecipeInput = {
5811
6279
  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)' } } },
5812
6280
  },
5813
6281
  },
6282
+ // signalxjs/lynx#1236 — the lynx replacements for what the shared
6283
+ // sections cannot say there.
6284
+ targets: {
6285
+ lynx: {
6286
+ parts: {
6287
+ // Physical margins: Android resolves no logical margin
6288
+ // (signalxjs/lynx#1084).
6289
+ icon: { base: { marginBottom: 'var(--space-xs)' } },
6290
+ // Title and description are lynx `<text>`: a view's
6291
+ // `text-align` and `color` do not reach one unless the app
6292
+ // turned CSS inheritance on, so each states its own ink and
6293
+ // centring. The web's 36ch measure becomes the root's width:
6294
+ // `ch` is unmeasured on lynx.
6295
+ title: { base: { color: 'var(--color-base-content)', textAlign: 'center' } },
6296
+ description: { base: { textAlign: 'center', maxInlineSize: '100%' } },
6297
+ actions: { base: { flexDirection: 'row', marginTop: 'var(--space-sm)' } },
6298
+ },
6299
+ },
6300
+ },
5814
6301
  };
5815
6302
 
5816
6303
  /**
@@ -5913,6 +6400,62 @@ export const badge: RecipeInput = {
5913
6400
  }, keyframes: {
5914
6401
  '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; }',
5915
6402
  },
6403
+ /**
6404
+ * Lynx (signalxjs/lynx#1234). The dot's `currentColor` never resolves
6405
+ * there (lynx#1079), so the uncoloured dot and its ring take the pill's
6406
+ * ink token, the value `currentColor` names on the web. The running halo
6407
+ * and pulse mix the recipe-local `--badge-dot`, which cannot bake, so the
6408
+ * halo is its own token mixed from each role's `--color-*` (baked per
6409
+ * theme) and the pulse breathes opacity instead of the halo's spread.
6410
+ * `inline-size`/`block-size` do not resolve on lynx (lynx#1145): the dot
6411
+ * is sized physically. A lynx view has no inline formatting context, so
6412
+ * the pill is content-sized with an explicit row (lynx#1165), and its
6413
+ * hairline and the dot's ring are longhands (lynx#1161/#1162).
6414
+ */
6415
+ targets: {
6416
+ lynx: {
6417
+ tokens: {
6418
+ '--badge-dot': 'var(--badge-ink)',
6419
+ '--badge-dot-ring': 'var(--badge-ink)',
6420
+ '--badge-dot-halo': 'color-mix(in oklch, var(--color-base-content) 35%, transparent)',
6421
+ },
6422
+ parts: {
6423
+ root: {
6424
+ base: {
6425
+ ...lynxBtnFit,
6426
+ flexDirection: 'row',
6427
+ border: '0 solid transparent',
6428
+ ...lynxSides('border{}Width', 'var(--border)'),
6429
+ },
6430
+ },
6431
+ dot: {
6432
+ base: {
6433
+ display: 'flex',
6434
+ width: '0.5em',
6435
+ height: '0.5em',
6436
+ flexShrink: '0',
6437
+ border: '0 solid transparent',
6438
+ ...lynxSides('border{}Width', '0.1em'),
6439
+ ...lynxSides('border{}Color', 'var(--badge-dot-ring)'),
6440
+ },
6441
+ states: {
6442
+ running: {
6443
+ boxShadow: '0 0 0 0.2em var(--badge-dot-halo)',
6444
+ animation: 'zero-daisy-badge-pulse 1.6s ease-in-out infinite',
6445
+ },
6446
+ },
6447
+ },
6448
+ },
6449
+ variants: {
6450
+ color: Object.fromEntries(ROLES.map((c) => [c, {
6451
+ dot: { base: { '--badge-dot-halo': `color-mix(in oklch, var(--color-${c}) 35%, transparent)` } },
6452
+ }])),
6453
+ },
6454
+ keyframes: {
6455
+ 'zero-daisy-badge-pulse': 'from, to { opacity: 1; } 50% { opacity: 0.45; }',
6456
+ },
6457
+ },
6458
+ },
5916
6459
  };
5917
6460
 
5918
6461
  /** daisy "divider": the base-300 rule, thickening with the ramp. */
@@ -5983,6 +6526,11 @@ export const divider: RecipeInput = {
5983
6526
  },
5984
6527
  targets: {
5985
6528
  lynx: {
6529
+ // `auto` behind a custom property, so the labelled root's size
6530
+ // is var-bearing too and can beat the var-bearing line thickness
6531
+ // below (lynx expands var() declarations after the cascade,
6532
+ // signalxjs/lynx#1161).
6533
+ tokens: { '--divider-fit': 'auto' },
5986
6534
  parts: {
5987
6535
  // Lynx drops the ::before/::after segments the rule is drawn
5988
6536
  // with above, which left the lynx divider with no line and,
@@ -5997,7 +6545,64 @@ export const divider: RecipeInput = {
5997
6545
  '&[data-orientation="vertical"]': { inlineSize: 'var(--divider-thickness)' },
5998
6546
  },
5999
6547
  },
6548
+ // The label's inset, physical (Android resolves no logical
6549
+ // padding, signalxjs/lynx#1084), and flush on the side its
6550
+ // placement gave up. Lynx has no descendant combinator from
6551
+ // the root, so lynx-zero stamps the root's orientation on the
6552
+ // label itself; vertical, the inset turns to the block axis.
6553
+ // Every zero is spelled through var() so it beats the
6554
+ // var-bearing inset (signalxjs/lynx#1161).
6555
+ label: {
6556
+ base: {
6557
+ paddingLeft: 'var(--space-md)',
6558
+ paddingRight: 'var(--space-md)',
6559
+ },
6560
+ selectors: {
6561
+ '&[data-placement="start"]': { paddingLeft: 'calc(var(--space-md) * 0)' },
6562
+ '&[data-placement="end"]': { paddingRight: 'calc(var(--space-md) * 0)' },
6563
+ '&[data-orientation="vertical"]': {
6564
+ paddingLeft: 'calc(var(--space-md) * 0)',
6565
+ paddingRight: 'calc(var(--space-md) * 0)',
6566
+ paddingTop: 'var(--space-sm)',
6567
+ paddingBottom: 'var(--space-sm)',
6568
+ },
6569
+ '&[data-orientation="vertical"][data-placement="start"]': { paddingTop: 'calc(var(--space-sm) * 0)' },
6570
+ '&[data-orientation="vertical"][data-placement="end"]': { paddingBottom: 'calc(var(--space-sm) * 0)' },
6571
+ },
6572
+ },
6000
6573
  },
6574
+ // A labelled divider (signalxjs/lynx#1236). With no pseudo-
6575
+ // elements, lynx-zero draws the rule's two segments as views
6576
+ // carrying the root's own line classes plus `zx-m-segment` —
6577
+ // so each paints this recipe's line, ink and thickness ramp — and
6578
+ // leaves out the one on the label's placed side (the web's
6579
+ // `:has(> [data-placement])::before { display: none }`). The root
6580
+ // holding them is stamped `zx-m-labelled` and stops being a line:
6581
+ // no fill, its cross size following the label, a row when
6582
+ // horizontal. Both are lynx-zero rendering details, not
6583
+ // modifiers an author sets, so they are styled from raw lynx css
6584
+ // (the toggle-group join ends' reasoning).
6585
+ css: `
6586
+ .zx-divider__root.zx-m-labelled {
6587
+ --divider-ink: transparent;
6588
+ }
6589
+ .zx-divider__root.zx-m-labelled.zx-o-horizontal {
6590
+ flex-direction: row;
6591
+ block-size: var(--divider-fit);
6592
+ }
6593
+ .zx-divider__root.zx-m-labelled.zx-o-vertical {
6594
+ inline-size: var(--divider-fit);
6595
+ }
6596
+ .zx-divider__root.zx-m-segment {
6597
+ flex: 1 1 0;
6598
+ }
6599
+ .zx-divider__root.zx-m-segment.zx-o-horizontal {
6600
+ min-width: 0;
6601
+ }
6602
+ .zx-divider__root.zx-m-segment.zx-o-vertical {
6603
+ min-height: 0;
6604
+ }
6605
+ `,
6001
6606
  },
6002
6607
  },
6003
6608
  };
@@ -6054,13 +6659,29 @@ export const skeleton: RecipeInput = {
6054
6659
  keyframes: {
6055
6660
  'zero-daisyui-skeleton': 'from, to { opacity: 1; } 50% { opacity: 0.55; }',
6056
6661
  },
6662
+ // signalxjs/lynx#1237. The pulse is an opacity loop on the part itself —
6663
+ // no pseudo-element, so it carries as is; only its keyframes are
6664
+ // restated as three plain stops, not the `from, to` selector list. The
6665
+ // reduced-motion stop cannot follow: lynx emits no `@media`.
6666
+ targets: {
6667
+ lynx: {
6668
+ keyframes: {
6669
+ 'zero-daisyui-skeleton': '0% { opacity: 1; } 50% { opacity: 0.55; } 100% { opacity: 1; }',
6670
+ },
6671
+ },
6672
+ },
6057
6673
  };
6058
6674
 
6059
- /** daisy "loading-spinner": the ring, with one quadrant in the role's ink. */
6675
+ /**
6676
+ * daisy "loading-spinner": the ring, with one quadrant in the role's ink.
6677
+ * Sized on daisy's `loading-*` ramp — `--size-selector * 4…8` (16–32px), md
6678
+ * the `loading` default of `* 6` (signalxjs/lynx#1237: the ramp was
6679
+ * `--size-field * 0.35…0.9`, a 2px box that the 2px border swallowed).
6680
+ */
6060
6681
  export const spinner: RecipeInput = {
6061
6682
  component: 'spinner',
6062
6683
  tokens: {
6063
- '--spinner-size': 'calc(var(--size-field) * 0.5)',
6684
+ '--spinner-size': 'calc(var(--size-selector) * 6)',
6064
6685
  '--spinner-ink': 'var(--color-primary)',
6065
6686
  '--spinner-track': 'var(--color-base-300)',
6066
6687
  },
@@ -6084,14 +6705,40 @@ export const spinner: RecipeInput = {
6084
6705
  '--spinner-ink': `var(--color-${c})`,
6085
6706
  } } }])),
6086
6707
  size: {
6087
- xs: { root: { base: { '--spinner-size': 'calc(var(--size-field) * 0.35)' } } },
6088
- sm: { root: { base: { '--spinner-size': 'calc(var(--size-field) * 0.42)' } } },
6708
+ xs: { root: { base: { '--spinner-size': 'calc(var(--size-selector) * 4)' } } },
6709
+ sm: { root: { base: { '--spinner-size': 'calc(var(--size-selector) * 5)' } } },
6089
6710
  md: {},
6090
- lg: { root: { base: { '--spinner-size': 'calc(var(--size-field) * 0.7)' } } },
6091
- xl: { root: { base: { '--spinner-size': 'calc(var(--size-field) * 0.9)' } } },
6711
+ lg: { root: { base: { '--spinner-size': 'calc(var(--size-selector) * 7)' } } },
6712
+ xl: { root: { base: { '--spinner-size': 'calc(var(--size-selector) * 8)' } } },
6092
6713
  },
6093
6714
  },
6094
6715
  keyframes: { 'zero-daisyui-spin': 'to { transform: rotate(360deg); }' },
6716
+ // signalxjs/lynx#1237 — the ring in physical spellings. The logical box
6717
+ // size and `border-block-start-color` are not lynx's; the ring is
6718
+ // restated as longhands, because a var-bearing `border` shorthand
6719
+ // expands after the cascade and would repaint the head in the track
6720
+ // colour (signalxjs/lynx#1161). A flex box, since `inline-block` is not a
6721
+ // lynx display value, that keeps its size in a row. The reduced-motion
6722
+ // stop cannot follow: lynx emits no `@media`.
6723
+ targets: {
6724
+ lynx: {
6725
+ parts: {
6726
+ root: {
6727
+ base: {
6728
+ display: 'flex',
6729
+ flexShrink: '0',
6730
+ width: 'var(--spinner-size)',
6731
+ height: 'var(--spinner-size)',
6732
+ border: '0 solid transparent',
6733
+ borderStyle: 'solid',
6734
+ ...lynxSides('border{}Width', 'calc(var(--border) * 2)'),
6735
+ ...lynxSides('border{}Color', 'var(--spinner-track)', ['Right', 'Bottom', 'Left']),
6736
+ borderTopColor: 'var(--spinner-ink)',
6737
+ },
6738
+ },
6739
+ },
6740
+ },
6741
+ },
6095
6742
  };
6096
6743
 
6097
6744
  // ── The content-tier sweep (#334) ─────────────────────────────────────────
@@ -6138,6 +6785,35 @@ export const kbd: RecipeInput = {
6138
6785
  xl: { root: { base: { fontSize: 'var(--text-md)', padding: 'var(--space-xs) var(--space-lg)' } } },
6139
6786
  },
6140
6787
  },
6788
+ /**
6789
+ * Lynx (signalxjs/lynx#1234). `min-inline-size` and the logical bottom
6790
+ * edge do not resolve on lynx (lynx#1145, #1084), so the cap's floor is
6791
+ * a physical `min-width` and its doubled bottom edge a physical
6792
+ * longhand; the whole hairline is longhands so a var-bearing shorthand
6793
+ * cannot expand over the thicker edge (lynx#1161/#1162). A lynx view has
6794
+ * no inline formatting context: the cap is content-sized with an
6795
+ * explicit row (lynx#1165).
6796
+ */
6797
+ targets: {
6798
+ lynx: {
6799
+ parts: {
6800
+ root: {
6801
+ base: {
6802
+ ...lynxBtnFit,
6803
+ flexDirection: 'row',
6804
+ minWidth: '1.75em',
6805
+ border: '0 solid transparent',
6806
+ ...lynxSides('border{}Width', 'var(--border)', ['Top', 'Right', 'Left']),
6807
+ borderBottomWidth: 'calc(var(--border) * 2)',
6808
+ ...lynxSides('border{}Color', 'var(--color-base-300)'),
6809
+ },
6810
+ },
6811
+ },
6812
+ variants: {
6813
+ size: { xs: { root: { base: { minWidth: '1.5em' } } } },
6814
+ },
6815
+ },
6816
+ },
6141
6817
  };
6142
6818
 
6143
6819
  /**
@@ -6179,6 +6855,37 @@ export const status: RecipeInput = {
6179
6855
  xl: { root: { base: { '--status-size': 'calc(var(--size-selector) * 3.5)' } } },
6180
6856
  },
6181
6857
  },
6858
+ /**
6859
+ * Lynx (signalxjs/lynx#1234). The halo mixes the recipe-local
6860
+ * `--status-ink`, which cannot bake, so it is its own token mixed from
6861
+ * each role's `--color-*` (baked per theme). `inline-size`/`block-size`
6862
+ * do not resolve on lynx (lynx#1145): the dot is sized physically, and
6863
+ * it is a flex box rather than an inline-block the engine has no
6864
+ * formatting context for.
6865
+ */
6866
+ targets: {
6867
+ lynx: {
6868
+ tokens: {
6869
+ '--status-halo': 'color-mix(in oklch, var(--color-base-content) 25%, transparent)',
6870
+ },
6871
+ parts: {
6872
+ root: {
6873
+ base: {
6874
+ display: 'flex',
6875
+ flexShrink: '0',
6876
+ width: 'var(--status-size)',
6877
+ height: 'var(--status-size)',
6878
+ boxShadow: '0 0 0 calc(var(--status-size) / 4) var(--status-halo)',
6879
+ },
6880
+ },
6881
+ },
6882
+ variants: {
6883
+ color: Object.fromEntries(ROLES.map((c) => [c, { root: { base: {
6884
+ '--status-halo': `color-mix(in oklch, var(--color-${c}) 25%, transparent)`,
6885
+ } } }])),
6886
+ },
6887
+ },
6888
+ },
6182
6889
  };
6183
6890
 
6184
6891
  /**
@@ -6363,6 +7070,90 @@ export const stats: RecipeInput = {
6363
7070
  xl: { value: { base: { fontSize: 'var(--text-3xl)' } } },
6364
7071
  },
6365
7072
  },
7073
+ // signalxjs/lynx#1236 — the lynx replacements for what the shared
7074
+ // sections cannot say there.
7075
+ targets: {
7076
+ lynx: {
7077
+ parts: {
7078
+ // A row unless vertical: a lynx flex container does not
7079
+ // default to one.
7080
+ root: { base: { flexDirection: 'row' } },
7081
+ // Lynx has no grid, so `display: grid` left the item in the
7082
+ // engine's default linear layout. The bands stack in a
7083
+ // centred column instead, and the figure is pinned to the
7084
+ // item's end edge (below). The padding as longhands, so the
7085
+ // `figure` rule below can widen one side by specificity (a
7086
+ // var-bearing `padding` shorthand would expand over it,
7087
+ // signalxjs/lynx#1161).
7088
+ item: {
7089
+ base: {
7090
+ display: 'flex',
7091
+ flexDirection: 'column',
7092
+ justifyContent: 'center',
7093
+ position: 'relative',
7094
+ padding: '0',
7095
+ paddingTop: 'var(--space-lg)',
7096
+ paddingBottom: 'var(--space-lg)',
7097
+ paddingLeft: 'var(--space-xl)',
7098
+ paddingRight: 'var(--space-xl)',
7099
+ },
7100
+ selectors: {
7101
+ // The seams: lynx has no sibling combinator, so the
7102
+ // web's `item + item` edge cannot be selected. Every
7103
+ // item draws its leading seam and the `first`
7104
+ // modifier takes it off the first (raw css below).
7105
+ '&[data-orientation="horizontal"]': {
7106
+ borderLeftWidth: 'var(--border)',
7107
+ borderLeftStyle: 'solid',
7108
+ borderLeftColor: 'var(--color-base-200)',
7109
+ },
7110
+ '&[data-orientation="vertical"]': {
7111
+ borderTopWidth: 'var(--border)',
7112
+ borderTopStyle: 'solid',
7113
+ borderTopColor: 'var(--color-base-200)',
7114
+ },
7115
+ },
7116
+ },
7117
+ // The web's second grid column, spanning the three bands and
7118
+ // centred on them: pinned to the item's end edge, inside its
7119
+ // padding, and centred top to bottom.
7120
+ figure: {
7121
+ base: {
7122
+ position: 'absolute',
7123
+ top: '0',
7124
+ bottom: '0',
7125
+ right: 'var(--space-xl)',
7126
+ // The web centres the grid cell with align-self; the
7127
+ // insets place it here.
7128
+ alignSelf: 'auto',
7129
+ display: 'flex',
7130
+ flexDirection: 'column',
7131
+ justifyContent: 'center',
7132
+ },
7133
+ },
7134
+ },
7135
+ // lynx-zero stamps the row's first item `zx-m-first` (mount
7136
+ // order; no `:first-child`) and an item holding a figure
7137
+ // `zx-m-figure`. Rendering details, not author modifiers, so raw
7138
+ // lynx css (the toggle-group join ends' reasoning). The first
7139
+ // item drops the leading seam, its zero width spelled through
7140
+ // var() to beat the var-bearing seam (signalxjs/lynx#1161). An
7141
+ // item with a figure keeps the figure's column clear: the
7142
+ // padding, the web's `--space-md` column gap, and a 2rem figure
7143
+ // (daisy's `stat-figure` holds a `size-8` icon or avatar).
7144
+ css: `
7145
+ .zx-stats__item.zx-m-first.zx-o-horizontal {
7146
+ border-left-width: calc(var(--border) * 0);
7147
+ }
7148
+ .zx-stats__item.zx-m-first.zx-o-vertical {
7149
+ border-top-width: calc(var(--border) * 0);
7150
+ }
7151
+ .zx-stats__item.zx-m-figure {
7152
+ padding-right: calc(var(--space-xl) + var(--space-md) + 2rem);
7153
+ }
7154
+ `,
7155
+ },
7156
+ },
6366
7157
  };
6367
7158
 
6368
7159
  /**