@sigx/zero-daisyui 0.11.0 → 0.13.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
@@ -1690,6 +1690,10 @@ export const field: RecipeInput = {
1690
1690
  label: {
1691
1691
  base: { fontSize: 'var(--text-sm)', fontWeight: 'var(--weight-semibold)', color: 'var(--field-accent)' },
1692
1692
  states: { disabled: { opacity: 'var(--disabled-opacity)' } },
1693
+ // lynx has no pseudo-elements and the anatomy no part to carry
1694
+ // the asterisk, so it stays a web-only mark: on lynx a required
1695
+ // field still stamps `required` on its root and label, and the
1696
+ // control inside adopts it (signalxjs/lynx#1205).
1693
1697
  selectors: {
1694
1698
  '&[data-required]::after': { content: '" *"', color: roleInk('error') },
1695
1699
  },
@@ -1781,6 +1785,33 @@ export const fieldset: RecipeInput = {
1781
1785
  xl: { legend: { base: { fontSize: 'var(--text-lg)' } } },
1782
1786
  },
1783
1787
  },
1788
+ // Lynx has no grid: the one-column track becomes a flex column (the gap
1789
+ // survives), and the block/inline paddings are restated physically —
1790
+ // logical spellings resolve on iOS but not on Android (signalxjs/lynx#1084).
1791
+ targets: {
1792
+ lynx: {
1793
+ parts: {
1794
+ root: {
1795
+ base: {
1796
+ display: 'flex',
1797
+ flexDirection: 'column',
1798
+ paddingTop: 'var(--space-xs)',
1799
+ paddingBottom: 'var(--space-xs)',
1800
+ paddingLeft: '0',
1801
+ paddingRight: '0',
1802
+ },
1803
+ },
1804
+ legend: {
1805
+ base: {
1806
+ paddingTop: 'var(--space-sm)',
1807
+ paddingBottom: 'var(--space-sm)',
1808
+ paddingLeft: '0',
1809
+ paddingRight: '0',
1810
+ },
1811
+ },
1812
+ },
1813
+ },
1814
+ },
1784
1815
  };
1785
1816
 
1786
1817
  /**
@@ -1794,6 +1825,13 @@ export const fieldset: RecipeInput = {
1794
1825
  * horizontal bar and lifts it to the middle. All three verbatim from daisyUI
1795
1826
  * 5.7.8's `.checkbox:before` / `:checked` / `:indeterminate`.
1796
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
+
1797
1835
  const TICK_COLLAPSED = 'polygon(20% 100%, 20% 80%, 50% 80%, 50% 80%, 70% 80%, 70% 100%)';
1798
1836
  const TICK_DRAWN = 'polygon(20% 100%, 20% 80%, 50% 80%, 50% 0%, 70% 0%, 70% 100%)';
1799
1837
  const DASH_DRAWN = 'polygon(20% 100%, 20% 80%, 50% 80%, 50% 80%, 80% 80%, 80% 100%)';
@@ -2012,33 +2050,83 @@ export const checkbox: RecipeInput = {
2012
2050
  targets: {
2013
2051
  lynx: {
2014
2052
  parts: {
2053
+ // The box/label row, pinned: `inline-flex` is rewritten to
2054
+ // `flex`, and the row must not depend on the engine's
2055
+ // default direction (signalxjs/lynx#1203).
2056
+ root: { base: { display: 'flex', flexDirection: 'row' } },
2015
2057
  control: {
2016
2058
  // The web fallback (`color-mix(in oklab, … currentColor …)`)
2017
2059
  // exists for consumers that unset the accent; this package
2018
2060
  // always defines it, so the plain accent IS the ring.
2019
- base: { border: 'var(--border) solid var(--checkbox-accent)' },
2061
+ //
2062
+ // `inline-block` is not a lynx display value (no inline
2063
+ // formatting context); a flex box keeps daisy's geometry —
2064
+ // the padding insets the 100%-sized indicator either way.
2065
+ base: { display: 'flex', border: 'var(--border) solid var(--checkbox-accent)' },
2066
+ // The ring as spread shadows that follow the box's radius
2067
+ // (signalxjs/lynx#1163 — lynx's `outline` ignores it and
2068
+ // has no offset), keeping the box's inset depth line.
2069
+ states: {
2070
+ 'focus-visible': lynxFocusRing('var(--checkbox-accent)', { under: '0 1px var(--depth-shade) inset' }),
2071
+ },
2020
2072
  },
2021
2073
  indicator: {
2022
- // The tick's fill: the control's `color:` names
2023
- // `--checkbox-on-accent`, and `currentColor` was that
2024
- // 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).
2085
+ //
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.
2025
2090
  //
2026
- // The tick's motion, restated as `transform`: the
2027
- // standalone `rotate`/`translate` properties resolve on
2028
- // iOS but not on Android (measured, signalxjs/lynx#1084),
2029
- // so the emitter refuses them. `transform` functions are
2030
- // proven on both platforms — and the transition follows
2031
- // the property that now moves.
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.
2032
2095
  base: {
2033
- 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%',
2034
2109
  transform: 'rotate(45deg)',
2035
- transition: 'clip-path var(--duration-slow) var(--ease-standard) var(--duration-instant), '
2036
- + 'opacity var(--duration-instant) var(--ease-standard) var(--duration-instant), '
2110
+ transition: 'opacity var(--duration-instant) var(--ease-standard) var(--duration-instant), '
2037
2111
  + 'transform var(--duration-slow) var(--ease-standard) var(--duration-instant)',
2038
2112
  },
2039
- // The order matches the standalone properties' fixed
2040
- // apply order (translate, then rotate).
2041
- 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
+ },
2042
2130
  },
2043
2131
  },
2044
2132
  },
@@ -2064,6 +2152,11 @@ export const radioGroup: RecipeInput = {
2064
2152
  // `invalid` paints on each `item-control`, which carries the flag
2065
2153
  // itself (#267) — the root only groups the items.
2066
2154
  states: { invalid: {}, required: {} },
2155
+ // `orientation="horizontal"` lays the items in a wrapping row —
2156
+ // checkbox-group's rule (signalxjs/zero#393).
2157
+ selectors: {
2158
+ '&[data-orientation="horizontal"]': { flexDirection: 'row', flexWrap: 'wrap', columnGap: 'var(--space-lg)' },
2159
+ },
2067
2160
  },
2068
2161
  label: {
2069
2162
  base: { fontSize: 'var(--text-sm)', fontWeight: 'var(--weight-semibold)' },
@@ -2171,9 +2264,20 @@ export const radioGroup: RecipeInput = {
2171
2264
  targets: {
2172
2265
  lynx: {
2173
2266
  parts: {
2267
+ // The control/label row, pinned — as checkbox's root
2268
+ // (signalxjs/lynx#1203).
2269
+ item: { base: { display: 'flex', flexDirection: 'row' } },
2174
2270
  'item-control': {
2175
- base: { border: 'var(--border) solid var(--radio-accent)' },
2176
- states: { checked: { borderColor: 'var(--radio-accent)' } },
2271
+ // `display: flex` in place of `inline-block`, which lynx
2272
+ // does not have — the padding still insets the dot.
2273
+ base: { display: 'flex', border: 'var(--border) solid var(--radio-accent)' },
2274
+ states: {
2275
+ checked: { borderColor: 'var(--radio-accent)' },
2276
+ // The round ring (signalxjs/lynx#1163): spread
2277
+ // shadows follow the 9999px radius where `outline`
2278
+ // drew a square.
2279
+ 'focus-visible': lynxFocusRing('var(--radio-accent)', { under: '0 1px var(--depth-shade) inset' }),
2280
+ },
2177
2281
  },
2178
2282
  'item-indicator': {
2179
2283
  states: { checked: { backgroundColor: 'var(--radio-accent)' } },
@@ -4088,6 +4192,11 @@ export const combobox: RecipeInput = {
4088
4192
  flexWrap: 'wrap',
4089
4193
  minWidth: '13rem',
4090
4194
  ...fieldControl,
4195
+ // The one field control that wraps: under `multiple` its tags
4196
+ // flow onto new rows, so the field step is a floor, not a cap —
4197
+ // a fixed height spills wrapped tags over the next field (#407).
4198
+ height: 'auto',
4199
+ minHeight: fieldHeight('md'),
4091
4200
  transition: 'border-color var(--duration-fast) var(--ease-standard)',
4092
4201
  },
4093
4202
  states: {
@@ -4394,8 +4503,52 @@ export const toggle: RecipeInput = {
4394
4503
  },
4395
4504
  },
4396
4505
  defaultVariants: { color: 'primary', size: 'md' },
4506
+ // signalxjs/lynx#1204 — the lynx replacements for what this recipe's
4507
+ // shared sections cannot say there.
4508
+ targets: {
4509
+ lynx: {
4510
+ parts: {
4511
+ root: {
4512
+ // daisy's content-sized, never-squeezed btn (lynx#1165):
4513
+ // a lynx view has no inline formatting context, so the
4514
+ // toggle stretched to its column and a squeezed label
4515
+ // wrapped per character. The glyph + label sit in a row.
4516
+ // The hairline as longhands: a var-bearing `border`
4517
+ // shorthand expands after the cascade and would beat
4518
+ // any edge a later rule restates (lynx#1161/#1162).
4519
+ base: {
4520
+ ...lynxBtnFit,
4521
+ flexDirection: 'row',
4522
+ border: '0 solid transparent',
4523
+ ...lynxSides('border{}Width', 'var(--border)'),
4524
+ ...lynxSides('border{}Color', 'var(--color-base-300)'),
4525
+ },
4526
+ states: {
4527
+ // No hover on a touch platform: the held toggle
4528
+ // takes the wash daisy's hover gives it. The shared
4529
+ // pressed rule adds the btn's 1px sink.
4530
+ pressed: { background: 'var(--color-base-300)' },
4531
+ on: lynxSides('border{}Color', 'var(--toggle-accent)'),
4532
+ // lynx's `outline` ignores the radius and has no
4533
+ // offset (lynx#1163): the ring as spread shadows.
4534
+ 'focus-visible': lynxFocusRing('var(--toggle-accent)'),
4535
+ },
4536
+ selectors: {
4537
+ // A held on toggle keeps its fill (the web's
4538
+ // `[data-state=on]:hover` stays on the accent too).
4539
+ '&[data-state="on"][data-pressed]': { background: 'var(--toggle-accent)' },
4540
+ },
4541
+ },
4542
+ },
4543
+ },
4544
+ },
4397
4545
  };
4398
4546
 
4547
+ /** A toggle-group end item's outer corner: the frame's radius inside its hairline. */
4548
+ const joinInnerRadius = 'calc(var(--radius-field) - var(--border))';
4549
+ /** A zero seam width that still holds var(), so it can beat the var-bearing seam on lynx. */
4550
+ const noSeam = 'calc(var(--border) * 0)';
4551
+
4399
4552
  // daisy "join" of btns: one bordered capsule, hairline seams between items,
4400
4553
  // the on item filled with the accent.
4401
4554
  export const toggleGroup: RecipeInput = {
@@ -4505,6 +4658,101 @@ export const toggleGroup: RecipeInput = {
4505
4658
  ),
4506
4659
  },
4507
4660
  defaultVariants: { color: 'primary' },
4661
+ // signalxjs/lynx#1204 — the lynx replacements for what this recipe's
4662
+ // shared sections cannot say there.
4663
+ targets: {
4664
+ lynx: {
4665
+ parts: {
4666
+ root: {
4667
+ // Content-sized like the web's inline-flex capsule (a
4668
+ // lynx view stretches to its column otherwise), a row
4669
+ // unless vertical, and the frame as longhands so the
4670
+ // orientation rules below can drop one edge by
4671
+ // specificity (a var-bearing `border` shorthand would
4672
+ // expand over them, lynx#1161/#1162).
4673
+ base: {
4674
+ width: 'max-content',
4675
+ flexShrink: '0',
4676
+ flexDirection: 'row',
4677
+ border: '0 solid transparent',
4678
+ ...lynxSides('border{}Width', 'var(--border)'),
4679
+ ...lynxSides('border{}Color', 'var(--color-base-300)'),
4680
+ },
4681
+ // The frame keeps all four edges (signalxjs/lynx#1218).
4682
+ // It used to give up its leading edge to the first item's
4683
+ // seam and trust its own clip to round that item's
4684
+ // corners, but on iOS the frame's clip does not round the
4685
+ // leading corners: the first item painted square, and its
4686
+ // seam was lost with them. The `first`/`last` modifiers
4687
+ // below carry the join's ends instead.
4688
+ },
4689
+ item: {
4690
+ base: { flexShrink: '0', flexWrap: 'nowrap' },
4691
+ states: {
4692
+ // No hover on a touch platform: a held item takes the
4693
+ // wash daisy's hover gives it; an on item keeps its
4694
+ // fill (the selector below).
4695
+ pressed: { background: 'var(--color-base-300)' },
4696
+ // The frame clips its items, so the ring is drawn
4697
+ // inside the item (lynx's `outline` has no offset
4698
+ // and ignores the radius, lynx#1163).
4699
+ 'focus-visible': lynxFocusRing('var(--toggle-group-accent)', { inset: true }),
4700
+ },
4701
+ selectors: {
4702
+ '&[data-state="on"][data-pressed]': { background: 'var(--toggle-group-accent)' },
4703
+ // The join seams: lynx has no sibling combinator, so
4704
+ // the web's `item + item` edge cannot be selected.
4705
+ // Every item draws its leading seam, and the `first`
4706
+ // modifier takes it off the item at the frame's edge.
4707
+ '&[data-orientation="horizontal"]': {
4708
+ borderLeftWidth: 'var(--border)',
4709
+ borderLeftStyle: 'solid',
4710
+ borderLeftColor: 'var(--color-base-300)',
4711
+ },
4712
+ '&[data-orientation="vertical"]': {
4713
+ borderTopWidth: 'var(--border)',
4714
+ borderTopStyle: 'solid',
4715
+ borderTopColor: 'var(--color-base-300)',
4716
+ },
4717
+ },
4718
+ },
4719
+ },
4720
+ // The ends of the join (signalxjs/lynx#1218). Lynx has no
4721
+ // `:first-child` or `:last-child`, so lynx-zero stamps the end
4722
+ // items itself, as `data-mod-first` / `data-mod-last` (the
4723
+ // `zx-m-first` / `zx-m-last` classes). They are a lynx-zero
4724
+ // rendering detail, not modifiers an author sets, so they are
4725
+ // not declared in the vocabulary and are styled from raw lynx
4726
+ // css. The end items round their own outer corners to the
4727
+ // frame's inner radius, so an on or held end item follows the
4728
+ // frame's corners without relying on its clip; `first` also
4729
+ // drops the leading seam, which would double the frame's edge.
4730
+ // That zero width is spelled through var() on purpose: a static
4731
+ // longhand loses to the var-bearing seam width whatever its
4732
+ // specificity (lynx expands var() declarations after the
4733
+ // cascade, signalxjs/lynx#1161).
4734
+ css: `
4735
+ .zx-toggle-group__item.zx-m-first.zx-o-horizontal {
4736
+ border-left-width: ${noSeam};
4737
+ border-top-left-radius: ${joinInnerRadius};
4738
+ border-bottom-left-radius: ${joinInnerRadius};
4739
+ }
4740
+ .zx-toggle-group__item.zx-m-first.zx-o-vertical {
4741
+ border-top-width: ${noSeam};
4742
+ border-top-left-radius: ${joinInnerRadius};
4743
+ border-top-right-radius: ${joinInnerRadius};
4744
+ }
4745
+ .zx-toggle-group__item.zx-m-last.zx-o-horizontal {
4746
+ border-top-right-radius: ${joinInnerRadius};
4747
+ border-bottom-right-radius: ${joinInnerRadius};
4748
+ }
4749
+ .zx-toggle-group__item.zx-m-last.zx-o-vertical {
4750
+ border-bottom-left-radius: ${joinInnerRadius};
4751
+ border-bottom-right-radius: ${joinInnerRadius};
4752
+ }
4753
+ `,
4754
+ },
4755
+ },
4508
4756
  };
4509
4757
 
4510
4758
  // daisy "join" of an input and two btns: one bordered capsule (the control),
@@ -4649,14 +4897,65 @@ export const numberInput: RecipeInput = {
4649
4897
  xl: { control: { base: { height: fieldHeight('xl') } }, input: { base: { fontSize: 'var(--text-lg)', padding: 'var(--space-lg) var(--space-xl)' } } },
4650
4898
  },
4651
4899
  },
4652
- // The steppers' inline padding, restated physically: logical spellings
4653
- // resolve on iOS but not on Android (measured, signalxjs/lynx#1084), so
4654
- // the emitter refuses them.
4900
+ // Lynx restatements:
4901
+ // - the steppers' inline padding and their seams, physically: logical
4902
+ // spellings resolve on iOS but not on Android (measured,
4903
+ // signalxjs/lynx#1084);
4904
+ // - the steppers are views around a text glyph, with none of a
4905
+ // button's content centering;
4906
+ // - the native input takes no `inherit`: its ink is spelled out, and it
4907
+ // grows into any room a stretched control leaves between the steppers;
4908
+ // - the web root is inline-flex and hugs its content; lynx's flex column
4909
+ // stretches, so the children align to the start instead;
4910
+ // - the focus ring is `lynxFocusRing` (lynx's outline ignores the radius
4911
+ // and has no offset, signalxjs/lynx#1163). A shadow must not be
4912
+ // clipped by the box's own `overflow: hidden`, so the control stops
4913
+ // clipping and the steppers round their own outer corners instead.
4655
4914
  targets: {
4656
4915
  lynx: {
4657
4916
  parts: {
4658
- 'increment-trigger': { base: { paddingLeft: 'var(--space-lg)', paddingRight: 'var(--space-lg)' } },
4659
- 'decrement-trigger': { base: { paddingLeft: 'var(--space-lg)', paddingRight: 'var(--space-lg)' } },
4917
+ root: { base: { alignItems: 'flex-start' } },
4918
+ control: {
4919
+ base: { overflow: 'visible' },
4920
+ states: { 'focus-visible': lynxFocusRing('var(--number-input-accent)') },
4921
+ },
4922
+ // `::placeholder` does not exist on lynx, so the web's 60%
4923
+ // mix rides lynx's own `-x-placeholder-color` (baked per
4924
+ // theme) — without it the platform default reads dark on
4925
+ // dark (signalxjs/lynx#1222). Spelled out, not the
4926
+ // `textFieldPlaceholder` const: that one is declared further
4927
+ // down the module, after this literal is evaluated.
4928
+ input: {
4929
+ base: {
4930
+ color: 'var(--color-base-content)',
4931
+ '-x-placeholder-color': 'color-mix(in oklab, var(--color-base-content) 60%, transparent)',
4932
+ flexGrow: '1',
4933
+ },
4934
+ },
4935
+ 'increment-trigger': {
4936
+ base: {
4937
+ display: 'flex',
4938
+ alignItems: 'center',
4939
+ justifyContent: 'center',
4940
+ paddingLeft: 'var(--space-lg)',
4941
+ paddingRight: 'var(--space-lg)',
4942
+ borderLeft: 'var(--border) solid var(--color-base-300)',
4943
+ borderTopRightRadius: 'var(--radius-field)',
4944
+ borderBottomRightRadius: 'var(--radius-field)',
4945
+ },
4946
+ },
4947
+ 'decrement-trigger': {
4948
+ base: {
4949
+ display: 'flex',
4950
+ alignItems: 'center',
4951
+ justifyContent: 'center',
4952
+ paddingLeft: 'var(--space-lg)',
4953
+ paddingRight: 'var(--space-lg)',
4954
+ borderRight: 'var(--border) solid var(--color-base-300)',
4955
+ borderTopLeftRadius: 'var(--radius-field)',
4956
+ borderBottomLeftRadius: 'var(--radius-field)',
4957
+ },
4958
+ },
4660
4959
  },
4661
4960
  },
4662
4961
  },
@@ -5070,6 +5369,15 @@ const affixSize = (fontSize: string) => ({
5070
5369
  'visibility-trigger': { base: { fontSize } },
5071
5370
  });
5072
5371
 
5372
+ /**
5373
+ * A text field's placeholder ink on lynx (signalxjs/lynx#1205): the web's
5374
+ * `::placeholder` mix, for lynx's `-x-placeholder-color` property — the
5375
+ * emitter bakes the `color-mix()` to one literal per theme.
5376
+ */
5377
+ const textFieldPlaceholder = 'color-mix(in oklab, var(--color-base-content) 60%, transparent)';
5378
+ /** The ghost field button's held look on lynx — the web's hover wash, on a press. */
5379
+ const fieldButtonHeld: CssProps = { color: 'var(--color-base-content)', background: 'var(--color-base-200)' };
5380
+
5073
5381
  /**
5074
5382
  * daisy "input" flavor: the shared field box with nothing in it but the text.
5075
5383
  * Same `fieldControl` metrics as select's trigger, combobox's control and the
@@ -5191,8 +5499,59 @@ export const input: RecipeInput = {
5191
5499
  xl: { control: { base: { height: fieldHeight('xl') } }, input: { base: { fontSize: 'var(--text-lg)', padding: 'var(--space-lg) var(--space-xl)' } }, ...affixSize('var(--text-lg)') },
5192
5500
  },
5193
5501
  },
5502
+ // lynx (signalxjs/lynx#1205). The field is a native text widget inside
5503
+ // a view `control`, so what the web gets from the cascade is restated:
5504
+ targets: {
5505
+ lynx: {
5506
+ parts: {
5507
+ // The web control is an `inline-flex` row; the emitter's
5508
+ // `flex` rewrite keeps no direction, so the row is explicit.
5509
+ // No `:hover` border on a touch platform — the ring below
5510
+ // (box-shadow spread, signalxjs/lynx#1163) is the focus cue,
5511
+ // in the root's accent like the web outline.
5512
+ control: {
5513
+ base: { flexDirection: 'row' },
5514
+ states: { 'focus-visible': lynxFocusRing('var(--input-accent)') },
5515
+ },
5516
+ // `color: inherit` never reaches a native text field, and
5517
+ // `::placeholder` does not exist: the ink is explicit and the
5518
+ // placeholder takes lynx's own `-x-placeholder-color`, the
5519
+ // web's 60% mix baked per theme.
5520
+ input: {
5521
+ base: {
5522
+ color: 'var(--color-base-content)',
5523
+ '-x-placeholder-color': textFieldPlaceholder,
5524
+ flexGrow: '1',
5525
+ flexShrink: '1',
5526
+ },
5527
+ },
5528
+ // The adornment's edge padding, physical: Android never
5529
+ // resolves the logical spellings (signalxjs/lynx#1084).
5530
+ adornment: {
5531
+ selectors: {
5532
+ '&[data-placement="start"]': { paddingLeft: 'var(--space-md)' },
5533
+ '&[data-placement="end"]': { paddingRight: 'var(--space-md)' },
5534
+ },
5535
+ },
5536
+ // Same for the ghost buttons' end margin; the web's `:hover`
5537
+ // wash answers a press instead.
5538
+ 'clear-trigger': {
5539
+ base: { marginRight: 'var(--space-xs)' },
5540
+ states: { pressed: fieldButtonHeld },
5541
+ },
5542
+ 'visibility-trigger': {
5543
+ base: { marginRight: 'var(--space-xs)' },
5544
+ states: { pressed: fieldButtonHeld },
5545
+ },
5546
+ },
5547
+ },
5548
+ },
5194
5549
  };
5195
5550
 
5551
+ /** The lynx textarea floor: two field heights, as ONE calc() (signalxjs/lynx#1219). */
5552
+ const textareaFloor = (step: keyof typeof FIELD_STEPS): string =>
5553
+ `calc(var(--size-field) * ${FIELD_STEPS[step] * 2})`;
5554
+
5196
5555
  /**
5197
5556
  * daisy "textarea" flavor. The `fieldControl` box minus its `height`: the
5198
5557
  * whole point of a textarea is that its height is the content's (and the
@@ -5259,6 +5618,64 @@ export const textarea: RecipeInput = {
5259
5618
  xl: { textarea: { base: { fontSize: 'var(--text-lg)', minHeight: `calc(${fieldHeight('xl')} * 2)`, padding: 'var(--space-lg) var(--space-xl)' } } },
5260
5619
  },
5261
5620
  },
5621
+ // lynx (signalxjs/lynx#1205): lynx-zero renders the part as a view (the
5622
+ // box, whose state classes repaint) around the native field, which wears
5623
+ // the part's base class as its text face. So the ink and the placeholder
5624
+ // are explicit here — `color: inherit` and `::placeholder` never reach a
5625
+ // native widget — and the web's `:hover` border and outline ring become
5626
+ // the lynx focus ring in the root's accent (signalxjs/lynx#1163).
5627
+ //
5628
+ // The floor is one flat `calc(var(--size-field) * n)` per size
5629
+ // (signalxjs/lynx#1219): the shared `calc(fieldHeight * 2)` nests a
5630
+ // calc() in a calc(), and on device the box never took it — an empty
5631
+ // textarea sat one line tall.
5632
+ //
5633
+ // The focus ring is NOT `lynxFocusRing`'s two stacked outset shadows
5634
+ // (signalxjs/lynx#1220). Lynx iOS re-inserts a view's outset shadow
5635
+ // layers at a stale index whenever it re-lays the view out
5636
+ // (`LynxBackgroundManager.updateShadow` reads the sublayer count before
5637
+ // removing the old layers), which reverses their stacking: the 4px ring
5638
+ // lands on top of the 2px gap and paints one solid band. A fixed-height
5639
+ // box never re-lays out; this auto-height one does, as its native field
5640
+ // 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
5642
+ // 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
5644
+ // edge colour (`--textarea-edge`, the error colour while invalid). The
5645
+ // gap is the border's width instead of the web's 2px.
5646
+ targets: {
5647
+ lynx: {
5648
+ parts: {
5649
+ textarea: {
5650
+ base: {
5651
+ color: 'var(--color-base-content)',
5652
+ '-x-placeholder-color': textFieldPlaceholder,
5653
+ '--textarea-edge': 'var(--color-base-300)',
5654
+ minHeight: textareaFloor('md'),
5655
+ },
5656
+ states: {
5657
+ invalid: { '--textarea-edge': 'var(--color-error)' },
5658
+ 'focus-visible': {
5659
+ outline: 'none',
5660
+ borderColor: 'var(--color-base-100)',
5661
+ boxShadow: '0 0 0 2px var(--textarea-accent), '
5662
+ // Twice the border (1px in every shipped
5663
+ // theme): the inner half shows as the hairline.
5664
+ + 'inset 0 0 0 2px var(--textarea-edge)',
5665
+ },
5666
+ },
5667
+ },
5668
+ },
5669
+ variants: {
5670
+ size: {
5671
+ xs: { textarea: { base: { minHeight: textareaFloor('xs') } } },
5672
+ sm: { textarea: { base: { minHeight: textareaFloor('sm') } } },
5673
+ lg: { textarea: { base: { minHeight: textareaFloor('lg') } } },
5674
+ xl: { textarea: { base: { minHeight: textareaFloor('xl') } } },
5675
+ },
5676
+ },
5677
+ },
5678
+ },
5262
5679
  };
5263
5680
 
5264
5681
  // ── Content tier (#311) ───────────────────────────────────────────────────