@sigx/zero-daisyui 0.10.0 → 0.12.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/dist/recipes.js CHANGED
@@ -129,6 +129,13 @@ const ROLE_INK_KEEP = {
129
129
  const roleInk = (role) => `color-mix(in oklab, var(--color-${role}) `
130
130
  + `${ROLE_INK_KEEP[role] ?? 55}%, var(--color-base-content))`;
131
131
  // daisy "tabs-box" flavor: a rounded container, lifted active tab.
132
+ /**
133
+ * The swipe-to-dismiss offset (#293): `createSwipe` publishes the drag as
134
+ * `--swipe-x` / `--swipe-y` on a toast root or a drawer sheet, and this is
135
+ * the one place it is read — composed first into the part's `transform`, so
136
+ * an enter/exit offset or a slide rides on top of it. Unset, it is nothing.
137
+ */
138
+ const SWIPE = 'translate(var(--swipe-x, 0px), var(--swipe-y, 0px))';
132
139
  /**
133
140
  * Enter/exit presence for a top-layer popup.
134
141
  *
@@ -1614,6 +1621,10 @@ export const field = {
1614
1621
  label: {
1615
1622
  base: { fontSize: 'var(--text-sm)', fontWeight: 'var(--weight-semibold)', color: 'var(--field-accent)' },
1616
1623
  states: { disabled: { opacity: 'var(--disabled-opacity)' } },
1624
+ // lynx has no pseudo-elements and the anatomy no part to carry
1625
+ // the asterisk, so it stays a web-only mark: on lynx a required
1626
+ // field still stamps `required` on its root and label, and the
1627
+ // control inside adopts it (signalxjs/lynx#1205).
1617
1628
  selectors: {
1618
1629
  '&[data-required]::after': { content: '" *"', color: roleInk('error') },
1619
1630
  },
@@ -1704,6 +1715,33 @@ export const fieldset = {
1704
1715
  xl: { legend: { base: { fontSize: 'var(--text-lg)' } } },
1705
1716
  },
1706
1717
  },
1718
+ // Lynx has no grid: the one-column track becomes a flex column (the gap
1719
+ // survives), and the block/inline paddings are restated physically —
1720
+ // logical spellings resolve on iOS but not on Android (signalxjs/lynx#1084).
1721
+ targets: {
1722
+ lynx: {
1723
+ parts: {
1724
+ root: {
1725
+ base: {
1726
+ display: 'flex',
1727
+ flexDirection: 'column',
1728
+ paddingTop: 'var(--space-xs)',
1729
+ paddingBottom: 'var(--space-xs)',
1730
+ paddingLeft: '0',
1731
+ paddingRight: '0',
1732
+ },
1733
+ },
1734
+ legend: {
1735
+ base: {
1736
+ paddingTop: 'var(--space-sm)',
1737
+ paddingBottom: 'var(--space-sm)',
1738
+ paddingLeft: '0',
1739
+ paddingRight: '0',
1740
+ },
1741
+ },
1742
+ },
1743
+ },
1744
+ },
1707
1745
  };
1708
1746
  /**
1709
1747
  * daisy's tick, drawn by a clip-path that GROWS.
@@ -1932,11 +1970,25 @@ export const checkbox = {
1932
1970
  targets: {
1933
1971
  lynx: {
1934
1972
  parts: {
1973
+ // The box/label row, pinned: `inline-flex` is rewritten to
1974
+ // `flex`, and the row must not depend on the engine's
1975
+ // default direction (signalxjs/lynx#1203).
1976
+ root: { base: { display: 'flex', flexDirection: 'row' } },
1935
1977
  control: {
1936
1978
  // The web fallback (`color-mix(in oklab, … currentColor …)`)
1937
1979
  // exists for consumers that unset the accent; this package
1938
1980
  // always defines it, so the plain accent IS the ring.
1939
- base: { border: 'var(--border) solid var(--checkbox-accent)' },
1981
+ //
1982
+ // `inline-block` is not a lynx display value (no inline
1983
+ // formatting context); a flex box keeps daisy's geometry —
1984
+ // the padding insets the 100%-sized indicator either way.
1985
+ base: { display: 'flex', border: 'var(--border) solid var(--checkbox-accent)' },
1986
+ // The ring as spread shadows that follow the box's radius
1987
+ // (signalxjs/lynx#1163 — lynx's `outline` ignores it and
1988
+ // has no offset), keeping the box's inset depth line.
1989
+ states: {
1990
+ 'focus-visible': lynxFocusRing('var(--checkbox-accent)', { under: '0 1px var(--depth-shade) inset' }),
1991
+ },
1940
1992
  },
1941
1993
  indicator: {
1942
1994
  // The tick's fill: the control's `color:` names
@@ -1982,6 +2034,11 @@ export const radioGroup = {
1982
2034
  // `invalid` paints on each `item-control`, which carries the flag
1983
2035
  // itself (#267) — the root only groups the items.
1984
2036
  states: { invalid: {}, required: {} },
2037
+ // `orientation="horizontal"` lays the items in a wrapping row —
2038
+ // checkbox-group's rule (signalxjs/zero#393).
2039
+ selectors: {
2040
+ '&[data-orientation="horizontal"]': { flexDirection: 'row', flexWrap: 'wrap', columnGap: 'var(--space-lg)' },
2041
+ },
1985
2042
  },
1986
2043
  label: {
1987
2044
  base: { fontSize: 'var(--text-sm)', fontWeight: 'var(--weight-semibold)' },
@@ -2089,9 +2146,20 @@ export const radioGroup = {
2089
2146
  targets: {
2090
2147
  lynx: {
2091
2148
  parts: {
2149
+ // The control/label row, pinned — as checkbox's root
2150
+ // (signalxjs/lynx#1203).
2151
+ item: { base: { display: 'flex', flexDirection: 'row' } },
2092
2152
  'item-control': {
2093
- base: { border: 'var(--border) solid var(--radio-accent)' },
2094
- states: { checked: { borderColor: 'var(--radio-accent)' } },
2153
+ // `display: flex` in place of `inline-block`, which lynx
2154
+ // does not have — the padding still insets the dot.
2155
+ base: { display: 'flex', border: 'var(--border) solid var(--radio-accent)' },
2156
+ states: {
2157
+ checked: { borderColor: 'var(--radio-accent)' },
2158
+ // The round ring (signalxjs/lynx#1163): spread
2159
+ // shadows follow the 9999px radius where `outline`
2160
+ // drew a square.
2161
+ 'focus-visible': lynxFocusRing('var(--radio-accent)', { under: '0 1px var(--depth-shade) inset' }),
2162
+ },
2095
2163
  },
2096
2164
  'item-indicator': {
2097
2165
  states: { checked: { backgroundColor: 'var(--radio-accent)' } },
@@ -2846,11 +2914,6 @@ const fieldControl = {
2846
2914
  borderRadius: 'var(--radius-field)',
2847
2915
  boxShadow: 'var(--shadow-xs)',
2848
2916
  };
2849
- /**
2850
- * A select whose clear-trigger is rendered (#280) — anchored on the root, so
2851
- * a part inside the trigger can make room for the button laid over it.
2852
- */
2853
- const SELECT_CLEARABLE = '[data-scope="select"][data-part="root"]:has(> [data-scope="select"][data-part="clear-trigger"]) &';
2854
2917
  /**
2855
2918
  * The select trigger's lynx steps — `[inline padding in --size-field units,
2856
2919
  * font-size step]` — as the size variants set them (`lynxBtnPad` and the
@@ -2939,9 +3002,13 @@ export const select = {
2939
3002
  },
2940
3003
  indicator: {
2941
3004
  base: { opacity: '0.6', transition: 'transform var(--duration-normal) var(--ease-standard)' },
2942
- states: { open: { transform: 'rotate(180deg)' }, closed: {} },
2943
- // Room for the clear-trigger laid over the field (#280).
2944
- selectors: { [SELECT_CLEARABLE]: { marginInlineStart: 'calc(var(--space-2xl) + var(--space-sm))' } },
3005
+ states: {
3006
+ open: { transform: 'rotate(180deg)' },
3007
+ closed: {},
3008
+ // Room for the clear-trigger laid over the field (#280) — the
3009
+ // `clearable` flag the runtime stamps while it renders (#387).
3010
+ clearable: { marginInlineStart: 'calc(var(--space-2xl) + var(--space-sm))' },
3011
+ },
2945
3012
  },
2946
3013
  // Clears the selection (#280): daisy's ghost-btn over the field's
2947
3014
  // inline end, just before the chevron — muted like it, the base-200
@@ -3099,7 +3166,25 @@ export const select = {
3099
3166
  // focus ring was a tall pill. The ring is inset, so it hugs
3100
3167
  // the chip and can never reach the chevron, and no gap shadow
3101
3168
  // paints past the chip's rounded corners.
3102
- indicator: { base: { width: '1em', textAlign: 'right' } },
3169
+ indicator: {
3170
+ base: { width: '1em', textAlign: 'right' },
3171
+ // The web's room for the chip (`margin-inline-start`) is a
3172
+ // logical spelling Android never resolves, and it is
3173
+ // sized off the web's own offset. Here the chip is a
3174
+ // 1.5rem square one `--space-xs` short of the chevron
3175
+ // at every size, so the room is exactly that — and the
3176
+ // trigger's `gap` stays between the value and the chip.
3177
+ // Before the `clearable` flag the web reached this with
3178
+ // `:has()`, which lynx cannot express: at lg/xl the value
3179
+ // ran under the chip and its focus ring
3180
+ // (signalxjs/lynx#1191).
3181
+ states: { clearable: { marginLeft: 'calc(1.5rem + var(--space-xs))' } },
3182
+ },
3183
+ // The value gives way to the chip instead of running under
3184
+ // it: one line, clipped with an ellipsis — the web's
3185
+ // `nowrap` button text, with a flex item's `min-width: auto`
3186
+ // lifted so it can shrink below its content at all.
3187
+ value: { base: { flexShrink: '1', minWidth: '0', overflow: 'hidden', whiteSpace: 'nowrap', textOverflow: 'ellipsis' } },
3103
3188
  'clear-trigger': {
3104
3189
  base: { ...lynxSelectClear('md'), width: '1.5rem', height: '1.5rem', minWidth: '1.5rem', boxSizing: 'border-box' },
3105
3190
  states: lynxFocus({ inset: true }),
@@ -3725,6 +3810,8 @@ export const toast = {
3725
3810
  states: {
3726
3811
  open: { opacity: '1', transform: 'none' },
3727
3812
  closed: {},
3813
+ // Styled in `targets.web` — the swipe is web-only (#293).
3814
+ swiping: {},
3728
3815
  },
3729
3816
  at: {
3730
3817
  'reduced-motion': { base: { transition: 'none' }, states: { open: { transform: 'none' } } },
@@ -3850,6 +3937,22 @@ export const toast = {
3850
3937
  defaultVariants: { size: 'md' },
3851
3938
  keyframes: { 'zero-daisyui-toast-spin': 'to { transform: rotate(360deg); }' },
3852
3939
  targets: {
3940
+ // The swipe (#293) — web only, since the runtime's `--swipe-*` offset
3941
+ // is: composed ahead of the enter/exit offset, and no pan or pinch
3942
+ // starts on a toast, so a touch drag is the swipe's.
3943
+ web: {
3944
+ parts: {
3945
+ root: {
3946
+ base: { transform: `${SWIPE} translateY(var(--toast-from))`, touchAction: 'none' },
3947
+ states: { open: { transform: SWIPE } },
3948
+ selectors: {
3949
+ // Mid-swipe the toast tracks the pointer, not a transition.
3950
+ '&[data-swiping]': { transition: 'none', userSelect: 'none' },
3951
+ },
3952
+ at: { 'reduced-motion': { states: { open: { transform: SWIPE } } } },
3953
+ },
3954
+ },
3955
+ },
3853
3956
  lynx: {
3854
3957
  parts: {
3855
3958
  // The viewport is a full-width strip on lynx (the runtime pins
@@ -4245,6 +4348,45 @@ export const toggle = {
4245
4348
  },
4246
4349
  },
4247
4350
  defaultVariants: { color: 'primary', size: 'md' },
4351
+ // signalxjs/lynx#1204 — the lynx replacements for what this recipe's
4352
+ // shared sections cannot say there.
4353
+ targets: {
4354
+ lynx: {
4355
+ parts: {
4356
+ root: {
4357
+ // daisy's content-sized, never-squeezed btn (lynx#1165):
4358
+ // a lynx view has no inline formatting context, so the
4359
+ // toggle stretched to its column and a squeezed label
4360
+ // wrapped per character. The glyph + label sit in a row.
4361
+ // The hairline as longhands: a var-bearing `border`
4362
+ // shorthand expands after the cascade and would beat
4363
+ // any edge a later rule restates (lynx#1161/#1162).
4364
+ base: {
4365
+ ...lynxBtnFit,
4366
+ flexDirection: 'row',
4367
+ border: '0 solid transparent',
4368
+ ...lynxSides('border{}Width', 'var(--border)'),
4369
+ ...lynxSides('border{}Color', 'var(--color-base-300)'),
4370
+ },
4371
+ states: {
4372
+ // No hover on a touch platform: the held toggle
4373
+ // takes the wash daisy's hover gives it. The shared
4374
+ // pressed rule adds the btn's 1px sink.
4375
+ pressed: { background: 'var(--color-base-300)' },
4376
+ on: lynxSides('border{}Color', 'var(--toggle-accent)'),
4377
+ // lynx's `outline` ignores the radius and has no
4378
+ // offset (lynx#1163): the ring as spread shadows.
4379
+ 'focus-visible': lynxFocusRing('var(--toggle-accent)'),
4380
+ },
4381
+ selectors: {
4382
+ // A held on toggle keeps its fill (the web's
4383
+ // `[data-state=on]:hover` stays on the accent too).
4384
+ '&[data-state="on"][data-pressed]': { background: 'var(--toggle-accent)' },
4385
+ },
4386
+ },
4387
+ },
4388
+ },
4389
+ },
4248
4390
  };
4249
4391
  // daisy "join" of btns: one bordered capsule, hairline seams between items,
4250
4392
  // the on item filled with the accent.
@@ -4353,6 +4495,65 @@ export const toggleGroup = {
4353
4495
  ])),
4354
4496
  },
4355
4497
  defaultVariants: { color: 'primary' },
4498
+ // signalxjs/lynx#1204 — the lynx replacements for what this recipe's
4499
+ // shared sections cannot say there.
4500
+ targets: {
4501
+ lynx: {
4502
+ parts: {
4503
+ root: {
4504
+ // Content-sized like the web's inline-flex capsule (a
4505
+ // lynx view stretches to its column otherwise), a row
4506
+ // unless vertical, and the frame as longhands so the
4507
+ // orientation rules below can drop one edge by
4508
+ // specificity (a var-bearing `border` shorthand would
4509
+ // expand over them, lynx#1161/#1162).
4510
+ base: {
4511
+ width: 'max-content',
4512
+ flexShrink: '0',
4513
+ flexDirection: 'row',
4514
+ border: '0 solid transparent',
4515
+ ...lynxSides('border{}Width', 'var(--border)'),
4516
+ ...lynxSides('border{}Color', 'var(--color-base-300)'),
4517
+ },
4518
+ selectors: {
4519
+ // The join seams: lynx has no sibling combinator, so
4520
+ // the web's `item + item` edge cannot be selected.
4521
+ // Every item draws its leading seam instead (below),
4522
+ // and the frame gives up that edge — the first item's
4523
+ // seam stands in for it, inside the frame's clip.
4524
+ '&[data-orientation="horizontal"]': { borderLeftWidth: '0' },
4525
+ '&[data-orientation="vertical"]': { borderTopWidth: '0' },
4526
+ },
4527
+ },
4528
+ item: {
4529
+ base: { flexShrink: '0', flexWrap: 'nowrap' },
4530
+ states: {
4531
+ // No hover on a touch platform: a held item takes the
4532
+ // wash daisy's hover gives it; an on item keeps its
4533
+ // fill (the selector below).
4534
+ pressed: { background: 'var(--color-base-300)' },
4535
+ // The frame clips its items, so the ring is drawn
4536
+ // inside the item (lynx's `outline` has no offset
4537
+ // and ignores the radius, lynx#1163).
4538
+ 'focus-visible': lynxFocusRing('var(--toggle-group-accent)', { inset: true }),
4539
+ },
4540
+ selectors: {
4541
+ '&[data-state="on"][data-pressed]': { background: 'var(--toggle-group-accent)' },
4542
+ '&[data-orientation="horizontal"]': {
4543
+ borderLeftWidth: 'var(--border)',
4544
+ borderLeftStyle: 'solid',
4545
+ borderLeftColor: 'var(--color-base-300)',
4546
+ },
4547
+ '&[data-orientation="vertical"]': {
4548
+ borderTopWidth: 'var(--border)',
4549
+ borderTopStyle: 'solid',
4550
+ borderTopColor: 'var(--color-base-300)',
4551
+ },
4552
+ },
4553
+ },
4554
+ },
4555
+ },
4556
+ },
4356
4557
  };
4357
4558
  // daisy "join" of an input and two btns: one bordered capsule (the control),
4358
4559
  // hairline seams on the triggers' inner edges, neutral btn fills for the steppers.
@@ -4496,14 +4697,53 @@ export const numberInput = {
4496
4697
  xl: { control: { base: { height: fieldHeight('xl') } }, input: { base: { fontSize: 'var(--text-lg)', padding: 'var(--space-lg) var(--space-xl)' } } },
4497
4698
  },
4498
4699
  },
4499
- // The steppers' inline padding, restated physically: logical spellings
4500
- // resolve on iOS but not on Android (measured, signalxjs/lynx#1084), so
4501
- // the emitter refuses them.
4700
+ // Lynx restatements:
4701
+ // - the steppers' inline padding and their seams, physically: logical
4702
+ // spellings resolve on iOS but not on Android (measured,
4703
+ // signalxjs/lynx#1084);
4704
+ // - the steppers are views around a text glyph, with none of a
4705
+ // button's content centering;
4706
+ // - the native input takes no `inherit`: its ink is spelled out, and it
4707
+ // grows into any room a stretched control leaves between the steppers;
4708
+ // - the web root is inline-flex and hugs its content; lynx's flex column
4709
+ // stretches, so the children align to the start instead;
4710
+ // - the focus ring is `lynxFocusRing` (lynx's outline ignores the radius
4711
+ // and has no offset, signalxjs/lynx#1163). A shadow must not be
4712
+ // clipped by the box's own `overflow: hidden`, so the control stops
4713
+ // clipping and the steppers round their own outer corners instead.
4502
4714
  targets: {
4503
4715
  lynx: {
4504
4716
  parts: {
4505
- 'increment-trigger': { base: { paddingLeft: 'var(--space-lg)', paddingRight: 'var(--space-lg)' } },
4506
- 'decrement-trigger': { base: { paddingLeft: 'var(--space-lg)', paddingRight: 'var(--space-lg)' } },
4717
+ root: { base: { alignItems: 'flex-start' } },
4718
+ control: {
4719
+ base: { overflow: 'visible' },
4720
+ states: { 'focus-visible': lynxFocusRing('var(--number-input-accent)') },
4721
+ },
4722
+ input: { base: { color: 'var(--color-base-content)', flexGrow: '1' } },
4723
+ 'increment-trigger': {
4724
+ base: {
4725
+ display: 'flex',
4726
+ alignItems: 'center',
4727
+ justifyContent: 'center',
4728
+ paddingLeft: 'var(--space-lg)',
4729
+ paddingRight: 'var(--space-lg)',
4730
+ borderLeft: 'var(--border) solid var(--color-base-300)',
4731
+ borderTopRightRadius: 'var(--radius-field)',
4732
+ borderBottomRightRadius: 'var(--radius-field)',
4733
+ },
4734
+ },
4735
+ 'decrement-trigger': {
4736
+ base: {
4737
+ display: 'flex',
4738
+ alignItems: 'center',
4739
+ justifyContent: 'center',
4740
+ paddingLeft: 'var(--space-lg)',
4741
+ paddingRight: 'var(--space-lg)',
4742
+ borderRight: 'var(--border) solid var(--color-base-300)',
4743
+ borderTopLeftRadius: 'var(--radius-field)',
4744
+ borderBottomLeftRadius: 'var(--radius-field)',
4745
+ },
4746
+ },
4507
4747
  },
4508
4748
  },
4509
4749
  },
@@ -4906,6 +5146,14 @@ const affixSize = (fontSize) => ({
4906
5146
  'clear-trigger': { base: { fontSize } },
4907
5147
  'visibility-trigger': { base: { fontSize } },
4908
5148
  });
5149
+ /**
5150
+ * A text field's placeholder ink on lynx (signalxjs/lynx#1205): the web's
5151
+ * `::placeholder` mix, for lynx's `-x-placeholder-color` property — the
5152
+ * emitter bakes the `color-mix()` to one literal per theme.
5153
+ */
5154
+ const textFieldPlaceholder = 'color-mix(in oklab, var(--color-base-content) 60%, transparent)';
5155
+ /** The ghost field button's held look on lynx — the web's hover wash, on a press. */
5156
+ const fieldButtonHeld = { color: 'var(--color-base-content)', background: 'var(--color-base-200)' };
4909
5157
  /**
4910
5158
  * daisy "input" flavor: the shared field box with nothing in it but the text.
4911
5159
  * Same `fieldControl` metrics as select's trigger, combobox's control and the
@@ -5027,6 +5275,53 @@ export const input = {
5027
5275
  xl: { control: { base: { height: fieldHeight('xl') } }, input: { base: { fontSize: 'var(--text-lg)', padding: 'var(--space-lg) var(--space-xl)' } }, ...affixSize('var(--text-lg)') },
5028
5276
  },
5029
5277
  },
5278
+ // lynx (signalxjs/lynx#1205). The field is a native text widget inside
5279
+ // a view `control`, so what the web gets from the cascade is restated:
5280
+ targets: {
5281
+ lynx: {
5282
+ parts: {
5283
+ // The web control is an `inline-flex` row; the emitter's
5284
+ // `flex` rewrite keeps no direction, so the row is explicit.
5285
+ // No `:hover` border on a touch platform — the ring below
5286
+ // (box-shadow spread, signalxjs/lynx#1163) is the focus cue,
5287
+ // in the root's accent like the web outline.
5288
+ control: {
5289
+ base: { flexDirection: 'row' },
5290
+ states: { 'focus-visible': lynxFocusRing('var(--input-accent)') },
5291
+ },
5292
+ // `color: inherit` never reaches a native text field, and
5293
+ // `::placeholder` does not exist: the ink is explicit and the
5294
+ // placeholder takes lynx's own `-x-placeholder-color`, the
5295
+ // web's 60% mix baked per theme.
5296
+ input: {
5297
+ base: {
5298
+ color: 'var(--color-base-content)',
5299
+ '-x-placeholder-color': textFieldPlaceholder,
5300
+ flexGrow: '1',
5301
+ flexShrink: '1',
5302
+ },
5303
+ },
5304
+ // The adornment's edge padding, physical: Android never
5305
+ // resolves the logical spellings (signalxjs/lynx#1084).
5306
+ adornment: {
5307
+ selectors: {
5308
+ '&[data-placement="start"]': { paddingLeft: 'var(--space-md)' },
5309
+ '&[data-placement="end"]': { paddingRight: 'var(--space-md)' },
5310
+ },
5311
+ },
5312
+ // Same for the ghost buttons' end margin; the web's `:hover`
5313
+ // wash answers a press instead.
5314
+ 'clear-trigger': {
5315
+ base: { marginRight: 'var(--space-xs)' },
5316
+ states: { pressed: fieldButtonHeld },
5317
+ },
5318
+ 'visibility-trigger': {
5319
+ base: { marginRight: 'var(--space-xs)' },
5320
+ states: { pressed: fieldButtonHeld },
5321
+ },
5322
+ },
5323
+ },
5324
+ },
5030
5325
  };
5031
5326
  /**
5032
5327
  * daisy "textarea" flavor. The `fieldControl` box minus its `height`: the
@@ -5094,6 +5389,22 @@ export const textarea = {
5094
5389
  xl: { textarea: { base: { fontSize: 'var(--text-lg)', minHeight: `calc(${fieldHeight('xl')} * 2)`, padding: 'var(--space-lg) var(--space-xl)' } } },
5095
5390
  },
5096
5391
  },
5392
+ // lynx (signalxjs/lynx#1205): lynx-zero renders the part as a view (the
5393
+ // box, whose state classes repaint) around the native field, which wears
5394
+ // the part's base class as its text face. So the ink and the placeholder
5395
+ // are explicit here — `color: inherit` and `::placeholder` never reach a
5396
+ // native widget — and the web's `:hover` border and outline ring become
5397
+ // the lynx focus ring in the root's accent (signalxjs/lynx#1163).
5398
+ targets: {
5399
+ lynx: {
5400
+ parts: {
5401
+ textarea: {
5402
+ base: { color: 'var(--color-base-content)', '-x-placeholder-color': textFieldPlaceholder },
5403
+ states: { 'focus-visible': lynxFocusRing('var(--textarea-accent)') },
5404
+ },
5405
+ },
5406
+ },
5407
+ },
5097
5408
  };
5098
5409
  // ── Content tier (#311) ───────────────────────────────────────────────────
5099
5410
  /**
@@ -7249,7 +7560,8 @@ const sheetSlide = (enter, exit) => {
7249
7560
  const open = `${sheet}[data-state="open"]`;
7250
7561
  const travel = (tempo) => {
7251
7562
  const duration = tempo.split(' ')[0];
7252
- return `translate ${tempo}, display ${duration} allow-discrete, overlay ${duration} allow-discrete`;
7563
+ // `transform` rides along for the swipe's spring back (#293).
7564
+ return `translate ${tempo}, transform ${tempo}, display ${duration} allow-discrete, overlay ${duration} allow-discrete`;
7253
7565
  };
7254
7566
  return {
7255
7567
  base: { '--drawer-travel': '-100% 0' },
@@ -7383,8 +7695,27 @@ export const drawer = {
7383
7695
  // Trigger-carried axes — see `btnColors` for why the panel is out of
7384
7696
  // reach and the trigger is the whole story here.
7385
7697
  variants: { color: btnColors(), size: btnSizes },
7386
- // The btn paddings, restated physically — see `lynxBtnPad` (#1084).
7387
7698
  targets: {
7699
+ // Swipe to dismiss (#293) — web only, since the runtime's `--swipe-*`
7700
+ // offset is: the sheet wears the drag offset (`SWIPE`) in every
7701
+ // state — so a swiped sheet leaves from where it was let go — at a
7702
+ // weight above the presence's own open `transform`. Mid-swipe no
7703
+ // transition runs. A touch pan across the dismiss axis stays the
7704
+ // page's; along it is the gesture's, so content taller than a
7705
+ // block-edge sheet scrolls in a box of its own inside the panel.
7706
+ web: {
7707
+ parts: {
7708
+ panel: {
7709
+ selectors: {
7710
+ '&[data-l-dock="sheet"][data-state]': { transform: SWIPE },
7711
+ '&[data-l-dock="sheet"][data-state="open"][data-swiping]': { transition: 'none', userSelect: 'none' },
7712
+ '&[data-l-dock="sheet"]:where([data-placement="start"], [data-placement="end"])': { touchAction: 'pan-y' },
7713
+ '&[data-l-dock="sheet"]:where([data-placement="top"], [data-placement="bottom"])': { touchAction: 'pan-x' },
7714
+ },
7715
+ },
7716
+ },
7717
+ },
7718
+ // The btn paddings, restated physically — see `lynxBtnPad` (#1084).
7388
7719
  lynx: {
7389
7720
  parts: {
7390
7721
  trigger: { base: lynxBtnPad(4) },