@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/src/recipes.ts CHANGED
@@ -148,6 +148,14 @@ const roleInk = (role: string): string =>
148
148
  + `${ROLE_INK_KEEP[role as keyof typeof roles] ?? 55}%, var(--color-base-content))`;
149
149
 
150
150
  // daisy "tabs-box" flavor: a rounded container, lifted active tab.
151
+ /**
152
+ * The swipe-to-dismiss offset (#293): `createSwipe` publishes the drag as
153
+ * `--swipe-x` / `--swipe-y` on a toast root or a drawer sheet, and this is
154
+ * the one place it is read — composed first into the part's `transform`, so
155
+ * an enter/exit offset or a slide rides on top of it. Unset, it is nothing.
156
+ */
157
+ const SWIPE = 'translate(var(--swipe-x, 0px), var(--swipe-y, 0px))';
158
+
151
159
  /**
152
160
  * Enter/exit presence for a top-layer popup.
153
161
  *
@@ -1682,6 +1690,10 @@ export const field: RecipeInput = {
1682
1690
  label: {
1683
1691
  base: { fontSize: 'var(--text-sm)', fontWeight: 'var(--weight-semibold)', color: 'var(--field-accent)' },
1684
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).
1685
1697
  selectors: {
1686
1698
  '&[data-required]::after': { content: '" *"', color: roleInk('error') },
1687
1699
  },
@@ -1773,6 +1785,33 @@ export const fieldset: RecipeInput = {
1773
1785
  xl: { legend: { base: { fontSize: 'var(--text-lg)' } } },
1774
1786
  },
1775
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
+ },
1776
1815
  };
1777
1816
 
1778
1817
  /**
@@ -2004,11 +2043,25 @@ export const checkbox: RecipeInput = {
2004
2043
  targets: {
2005
2044
  lynx: {
2006
2045
  parts: {
2046
+ // The box/label row, pinned: `inline-flex` is rewritten to
2047
+ // `flex`, and the row must not depend on the engine's
2048
+ // default direction (signalxjs/lynx#1203).
2049
+ root: { base: { display: 'flex', flexDirection: 'row' } },
2007
2050
  control: {
2008
2051
  // The web fallback (`color-mix(in oklab, … currentColor …)`)
2009
2052
  // exists for consumers that unset the accent; this package
2010
2053
  // always defines it, so the plain accent IS the ring.
2011
- base: { border: 'var(--border) solid var(--checkbox-accent)' },
2054
+ //
2055
+ // `inline-block` is not a lynx display value (no inline
2056
+ // formatting context); a flex box keeps daisy's geometry —
2057
+ // the padding insets the 100%-sized indicator either way.
2058
+ base: { display: 'flex', border: 'var(--border) solid var(--checkbox-accent)' },
2059
+ // The ring as spread shadows that follow the box's radius
2060
+ // (signalxjs/lynx#1163 — lynx's `outline` ignores it and
2061
+ // has no offset), keeping the box's inset depth line.
2062
+ states: {
2063
+ 'focus-visible': lynxFocusRing('var(--checkbox-accent)', { under: '0 1px var(--depth-shade) inset' }),
2064
+ },
2012
2065
  },
2013
2066
  indicator: {
2014
2067
  // The tick's fill: the control's `color:` names
@@ -2056,6 +2109,11 @@ export const radioGroup: RecipeInput = {
2056
2109
  // `invalid` paints on each `item-control`, which carries the flag
2057
2110
  // itself (#267) — the root only groups the items.
2058
2111
  states: { invalid: {}, required: {} },
2112
+ // `orientation="horizontal"` lays the items in a wrapping row —
2113
+ // checkbox-group's rule (signalxjs/zero#393).
2114
+ selectors: {
2115
+ '&[data-orientation="horizontal"]': { flexDirection: 'row', flexWrap: 'wrap', columnGap: 'var(--space-lg)' },
2116
+ },
2059
2117
  },
2060
2118
  label: {
2061
2119
  base: { fontSize: 'var(--text-sm)', fontWeight: 'var(--weight-semibold)' },
@@ -2163,9 +2221,20 @@ export const radioGroup: RecipeInput = {
2163
2221
  targets: {
2164
2222
  lynx: {
2165
2223
  parts: {
2224
+ // The control/label row, pinned — as checkbox's root
2225
+ // (signalxjs/lynx#1203).
2226
+ item: { base: { display: 'flex', flexDirection: 'row' } },
2166
2227
  'item-control': {
2167
- base: { border: 'var(--border) solid var(--radio-accent)' },
2168
- states: { checked: { borderColor: 'var(--radio-accent)' } },
2228
+ // `display: flex` in place of `inline-block`, which lynx
2229
+ // does not have — the padding still insets the dot.
2230
+ base: { display: 'flex', border: 'var(--border) solid var(--radio-accent)' },
2231
+ states: {
2232
+ checked: { borderColor: 'var(--radio-accent)' },
2233
+ // The round ring (signalxjs/lynx#1163): spread
2234
+ // shadows follow the 9999px radius where `outline`
2235
+ // drew a square.
2236
+ 'focus-visible': lynxFocusRing('var(--radio-accent)', { under: '0 1px var(--depth-shade) inset' }),
2237
+ },
2169
2238
  },
2170
2239
  'item-indicator': {
2171
2240
  states: { checked: { backgroundColor: 'var(--radio-accent)' } },
@@ -2933,12 +3002,6 @@ const fieldControl: NonNullable<PartStyles['base']> = {
2933
3002
  boxShadow: 'var(--shadow-xs)',
2934
3003
  };
2935
3004
 
2936
- /**
2937
- * A select whose clear-trigger is rendered (#280) — anchored on the root, so
2938
- * a part inside the trigger can make room for the button laid over it.
2939
- */
2940
- const SELECT_CLEARABLE = '[data-scope="select"][data-part="root"]:has(> [data-scope="select"][data-part="clear-trigger"]) &';
2941
-
2942
3005
  /**
2943
3006
  * The select trigger's lynx steps — `[inline padding in --size-field units,
2944
3007
  * font-size step]` — as the size variants set them (`lynxBtnPad` and the
@@ -3029,9 +3092,13 @@ export const select: RecipeInput = {
3029
3092
  },
3030
3093
  indicator: {
3031
3094
  base: { opacity: '0.6', transition: 'transform var(--duration-normal) var(--ease-standard)' },
3032
- states: { open: { transform: 'rotate(180deg)' }, closed: {} },
3033
- // Room for the clear-trigger laid over the field (#280).
3034
- selectors: { [SELECT_CLEARABLE]: { marginInlineStart: 'calc(var(--space-2xl) + var(--space-sm))' } },
3095
+ states: {
3096
+ open: { transform: 'rotate(180deg)' },
3097
+ closed: {},
3098
+ // Room for the clear-trigger laid over the field (#280) — the
3099
+ // `clearable` flag the runtime stamps while it renders (#387).
3100
+ clearable: { marginInlineStart: 'calc(var(--space-2xl) + var(--space-sm))' },
3101
+ },
3035
3102
  },
3036
3103
  // Clears the selection (#280): daisy's ghost-btn over the field's
3037
3104
  // inline end, just before the chevron — muted like it, the base-200
@@ -3189,7 +3256,25 @@ export const select: RecipeInput = {
3189
3256
  // focus ring was a tall pill. The ring is inset, so it hugs
3190
3257
  // the chip and can never reach the chevron, and no gap shadow
3191
3258
  // paints past the chip's rounded corners.
3192
- indicator: { base: { width: '1em', textAlign: 'right' } },
3259
+ indicator: {
3260
+ base: { width: '1em', textAlign: 'right' },
3261
+ // The web's room for the chip (`margin-inline-start`) is a
3262
+ // logical spelling Android never resolves, and it is
3263
+ // sized off the web's own offset. Here the chip is a
3264
+ // 1.5rem square one `--space-xs` short of the chevron
3265
+ // at every size, so the room is exactly that — and the
3266
+ // trigger's `gap` stays between the value and the chip.
3267
+ // Before the `clearable` flag the web reached this with
3268
+ // `:has()`, which lynx cannot express: at lg/xl the value
3269
+ // ran under the chip and its focus ring
3270
+ // (signalxjs/lynx#1191).
3271
+ states: { clearable: { marginLeft: 'calc(1.5rem + var(--space-xs))' } },
3272
+ },
3273
+ // The value gives way to the chip instead of running under
3274
+ // it: one line, clipped with an ellipsis — the web's
3275
+ // `nowrap` button text, with a flex item's `min-width: auto`
3276
+ // lifted so it can shrink below its content at all.
3277
+ value: { base: { flexShrink: '1', minWidth: '0', overflow: 'hidden', whiteSpace: 'nowrap', textOverflow: 'ellipsis' } },
3193
3278
  'clear-trigger': {
3194
3279
  base: { ...lynxSelectClear('md'), width: '1.5rem', height: '1.5rem', minWidth: '1.5rem', boxSizing: 'border-box' },
3195
3280
  states: lynxFocus({ inset: true }),
@@ -3210,7 +3295,6 @@ export const select: RecipeInput = {
3210
3295
  },
3211
3296
  };
3212
3297
 
3213
-
3214
3298
  /**
3215
3299
  * daisy's BUTTON ramp — `.btn`'s fixed height per size step, in `--size-field`
3216
3300
  * units, exactly as daisyUI 5 ships it (`components/button.css`: `.btn-xs`
@@ -3829,6 +3913,8 @@ export const toast: RecipeInput = {
3829
3913
  states: {
3830
3914
  open: { opacity: '1', transform: 'none' },
3831
3915
  closed: {},
3916
+ // Styled in `targets.web` — the swipe is web-only (#293).
3917
+ swiping: {},
3832
3918
  },
3833
3919
  at: {
3834
3920
  'reduced-motion': { base: { transition: 'none' }, states: { open: { transform: 'none' } } },
@@ -3954,6 +4040,22 @@ export const toast: RecipeInput = {
3954
4040
  defaultVariants: { size: 'md' },
3955
4041
  keyframes: { 'zero-daisyui-toast-spin': 'to { transform: rotate(360deg); }' },
3956
4042
  targets: {
4043
+ // The swipe (#293) — web only, since the runtime's `--swipe-*` offset
4044
+ // is: composed ahead of the enter/exit offset, and no pan or pinch
4045
+ // starts on a toast, so a touch drag is the swipe's.
4046
+ web: {
4047
+ parts: {
4048
+ root: {
4049
+ base: { transform: `${SWIPE} translateY(var(--toast-from))`, touchAction: 'none' },
4050
+ states: { open: { transform: SWIPE } },
4051
+ selectors: {
4052
+ // Mid-swipe the toast tracks the pointer, not a transition.
4053
+ '&[data-swiping]': { transition: 'none', userSelect: 'none' },
4054
+ },
4055
+ at: { 'reduced-motion': { states: { open: { transform: SWIPE } } } },
4056
+ },
4057
+ },
4058
+ },
3957
4059
  lynx: {
3958
4060
  parts: {
3959
4061
  // The viewport is a full-width strip on lynx (the runtime pins
@@ -4353,6 +4455,45 @@ export const toggle: RecipeInput = {
4353
4455
  },
4354
4456
  },
4355
4457
  defaultVariants: { color: 'primary', size: 'md' },
4458
+ // signalxjs/lynx#1204 — the lynx replacements for what this recipe's
4459
+ // shared sections cannot say there.
4460
+ targets: {
4461
+ lynx: {
4462
+ parts: {
4463
+ root: {
4464
+ // daisy's content-sized, never-squeezed btn (lynx#1165):
4465
+ // a lynx view has no inline formatting context, so the
4466
+ // toggle stretched to its column and a squeezed label
4467
+ // wrapped per character. The glyph + label sit in a row.
4468
+ // The hairline as longhands: a var-bearing `border`
4469
+ // shorthand expands after the cascade and would beat
4470
+ // any edge a later rule restates (lynx#1161/#1162).
4471
+ base: {
4472
+ ...lynxBtnFit,
4473
+ flexDirection: 'row',
4474
+ border: '0 solid transparent',
4475
+ ...lynxSides('border{}Width', 'var(--border)'),
4476
+ ...lynxSides('border{}Color', 'var(--color-base-300)'),
4477
+ },
4478
+ states: {
4479
+ // No hover on a touch platform: the held toggle
4480
+ // takes the wash daisy's hover gives it. The shared
4481
+ // pressed rule adds the btn's 1px sink.
4482
+ pressed: { background: 'var(--color-base-300)' },
4483
+ on: lynxSides('border{}Color', 'var(--toggle-accent)'),
4484
+ // lynx's `outline` ignores the radius and has no
4485
+ // offset (lynx#1163): the ring as spread shadows.
4486
+ 'focus-visible': lynxFocusRing('var(--toggle-accent)'),
4487
+ },
4488
+ selectors: {
4489
+ // A held on toggle keeps its fill (the web's
4490
+ // `[data-state=on]:hover` stays on the accent too).
4491
+ '&[data-state="on"][data-pressed]': { background: 'var(--toggle-accent)' },
4492
+ },
4493
+ },
4494
+ },
4495
+ },
4496
+ },
4356
4497
  };
4357
4498
 
4358
4499
  // daisy "join" of btns: one bordered capsule, hairline seams between items,
@@ -4464,6 +4605,65 @@ export const toggleGroup: RecipeInput = {
4464
4605
  ),
4465
4606
  },
4466
4607
  defaultVariants: { color: 'primary' },
4608
+ // signalxjs/lynx#1204 — the lynx replacements for what this recipe's
4609
+ // shared sections cannot say there.
4610
+ targets: {
4611
+ lynx: {
4612
+ parts: {
4613
+ root: {
4614
+ // Content-sized like the web's inline-flex capsule (a
4615
+ // lynx view stretches to its column otherwise), a row
4616
+ // unless vertical, and the frame as longhands so the
4617
+ // orientation rules below can drop one edge by
4618
+ // specificity (a var-bearing `border` shorthand would
4619
+ // expand over them, lynx#1161/#1162).
4620
+ base: {
4621
+ width: 'max-content',
4622
+ flexShrink: '0',
4623
+ flexDirection: 'row',
4624
+ border: '0 solid transparent',
4625
+ ...lynxSides('border{}Width', 'var(--border)'),
4626
+ ...lynxSides('border{}Color', 'var(--color-base-300)'),
4627
+ },
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
+ },
4637
+ },
4638
+ item: {
4639
+ base: { flexShrink: '0', flexWrap: 'nowrap' },
4640
+ states: {
4641
+ // No hover on a touch platform: a held item takes the
4642
+ // wash daisy's hover gives it; an on item keeps its
4643
+ // fill (the selector below).
4644
+ pressed: { background: 'var(--color-base-300)' },
4645
+ // The frame clips its items, so the ring is drawn
4646
+ // inside the item (lynx's `outline` has no offset
4647
+ // and ignores the radius, lynx#1163).
4648
+ 'focus-visible': lynxFocusRing('var(--toggle-group-accent)', { inset: true }),
4649
+ },
4650
+ selectors: {
4651
+ '&[data-state="on"][data-pressed]': { background: 'var(--toggle-group-accent)' },
4652
+ '&[data-orientation="horizontal"]': {
4653
+ borderLeftWidth: 'var(--border)',
4654
+ borderLeftStyle: 'solid',
4655
+ borderLeftColor: 'var(--color-base-300)',
4656
+ },
4657
+ '&[data-orientation="vertical"]': {
4658
+ borderTopWidth: 'var(--border)',
4659
+ borderTopStyle: 'solid',
4660
+ borderTopColor: 'var(--color-base-300)',
4661
+ },
4662
+ },
4663
+ },
4664
+ },
4665
+ },
4666
+ },
4467
4667
  };
4468
4668
 
4469
4669
  // daisy "join" of an input and two btns: one bordered capsule (the control),
@@ -4608,14 +4808,53 @@ export const numberInput: RecipeInput = {
4608
4808
  xl: { control: { base: { height: fieldHeight('xl') } }, input: { base: { fontSize: 'var(--text-lg)', padding: 'var(--space-lg) var(--space-xl)' } } },
4609
4809
  },
4610
4810
  },
4611
- // The steppers' inline padding, restated physically: logical spellings
4612
- // resolve on iOS but not on Android (measured, signalxjs/lynx#1084), so
4613
- // the emitter refuses them.
4811
+ // Lynx restatements:
4812
+ // - the steppers' inline padding and their seams, physically: logical
4813
+ // spellings resolve on iOS but not on Android (measured,
4814
+ // signalxjs/lynx#1084);
4815
+ // - the steppers are views around a text glyph, with none of a
4816
+ // button's content centering;
4817
+ // - the native input takes no `inherit`: its ink is spelled out, and it
4818
+ // grows into any room a stretched control leaves between the steppers;
4819
+ // - the web root is inline-flex and hugs its content; lynx's flex column
4820
+ // stretches, so the children align to the start instead;
4821
+ // - the focus ring is `lynxFocusRing` (lynx's outline ignores the radius
4822
+ // and has no offset, signalxjs/lynx#1163). A shadow must not be
4823
+ // clipped by the box's own `overflow: hidden`, so the control stops
4824
+ // clipping and the steppers round their own outer corners instead.
4614
4825
  targets: {
4615
4826
  lynx: {
4616
4827
  parts: {
4617
- 'increment-trigger': { base: { paddingLeft: 'var(--space-lg)', paddingRight: 'var(--space-lg)' } },
4618
- 'decrement-trigger': { base: { paddingLeft: 'var(--space-lg)', paddingRight: 'var(--space-lg)' } },
4828
+ root: { base: { alignItems: 'flex-start' } },
4829
+ control: {
4830
+ base: { overflow: 'visible' },
4831
+ states: { 'focus-visible': lynxFocusRing('var(--number-input-accent)') },
4832
+ },
4833
+ input: { base: { color: 'var(--color-base-content)', flexGrow: '1' } },
4834
+ 'increment-trigger': {
4835
+ base: {
4836
+ display: 'flex',
4837
+ alignItems: 'center',
4838
+ justifyContent: 'center',
4839
+ paddingLeft: 'var(--space-lg)',
4840
+ paddingRight: 'var(--space-lg)',
4841
+ borderLeft: 'var(--border) solid var(--color-base-300)',
4842
+ borderTopRightRadius: 'var(--radius-field)',
4843
+ borderBottomRightRadius: 'var(--radius-field)',
4844
+ },
4845
+ },
4846
+ 'decrement-trigger': {
4847
+ base: {
4848
+ display: 'flex',
4849
+ alignItems: 'center',
4850
+ justifyContent: 'center',
4851
+ paddingLeft: 'var(--space-lg)',
4852
+ paddingRight: 'var(--space-lg)',
4853
+ borderRight: 'var(--border) solid var(--color-base-300)',
4854
+ borderTopLeftRadius: 'var(--radius-field)',
4855
+ borderBottomLeftRadius: 'var(--radius-field)',
4856
+ },
4857
+ },
4619
4858
  },
4620
4859
  },
4621
4860
  },
@@ -5029,6 +5268,15 @@ const affixSize = (fontSize: string) => ({
5029
5268
  'visibility-trigger': { base: { fontSize } },
5030
5269
  });
5031
5270
 
5271
+ /**
5272
+ * A text field's placeholder ink on lynx (signalxjs/lynx#1205): the web's
5273
+ * `::placeholder` mix, for lynx's `-x-placeholder-color` property — the
5274
+ * emitter bakes the `color-mix()` to one literal per theme.
5275
+ */
5276
+ const textFieldPlaceholder = 'color-mix(in oklab, var(--color-base-content) 60%, transparent)';
5277
+ /** The ghost field button's held look on lynx — the web's hover wash, on a press. */
5278
+ const fieldButtonHeld: CssProps = { color: 'var(--color-base-content)', background: 'var(--color-base-200)' };
5279
+
5032
5280
  /**
5033
5281
  * daisy "input" flavor: the shared field box with nothing in it but the text.
5034
5282
  * Same `fieldControl` metrics as select's trigger, combobox's control and the
@@ -5150,6 +5398,53 @@ export const input: RecipeInput = {
5150
5398
  xl: { control: { base: { height: fieldHeight('xl') } }, input: { base: { fontSize: 'var(--text-lg)', padding: 'var(--space-lg) var(--space-xl)' } }, ...affixSize('var(--text-lg)') },
5151
5399
  },
5152
5400
  },
5401
+ // lynx (signalxjs/lynx#1205). The field is a native text widget inside
5402
+ // a view `control`, so what the web gets from the cascade is restated:
5403
+ targets: {
5404
+ lynx: {
5405
+ parts: {
5406
+ // The web control is an `inline-flex` row; the emitter's
5407
+ // `flex` rewrite keeps no direction, so the row is explicit.
5408
+ // No `:hover` border on a touch platform — the ring below
5409
+ // (box-shadow spread, signalxjs/lynx#1163) is the focus cue,
5410
+ // in the root's accent like the web outline.
5411
+ control: {
5412
+ base: { flexDirection: 'row' },
5413
+ states: { 'focus-visible': lynxFocusRing('var(--input-accent)') },
5414
+ },
5415
+ // `color: inherit` never reaches a native text field, and
5416
+ // `::placeholder` does not exist: the ink is explicit and the
5417
+ // placeholder takes lynx's own `-x-placeholder-color`, the
5418
+ // web's 60% mix baked per theme.
5419
+ input: {
5420
+ base: {
5421
+ color: 'var(--color-base-content)',
5422
+ '-x-placeholder-color': textFieldPlaceholder,
5423
+ flexGrow: '1',
5424
+ flexShrink: '1',
5425
+ },
5426
+ },
5427
+ // The adornment's edge padding, physical: Android never
5428
+ // resolves the logical spellings (signalxjs/lynx#1084).
5429
+ adornment: {
5430
+ selectors: {
5431
+ '&[data-placement="start"]': { paddingLeft: 'var(--space-md)' },
5432
+ '&[data-placement="end"]': { paddingRight: 'var(--space-md)' },
5433
+ },
5434
+ },
5435
+ // Same for the ghost buttons' end margin; the web's `:hover`
5436
+ // wash answers a press instead.
5437
+ 'clear-trigger': {
5438
+ base: { marginRight: 'var(--space-xs)' },
5439
+ states: { pressed: fieldButtonHeld },
5440
+ },
5441
+ 'visibility-trigger': {
5442
+ base: { marginRight: 'var(--space-xs)' },
5443
+ states: { pressed: fieldButtonHeld },
5444
+ },
5445
+ },
5446
+ },
5447
+ },
5153
5448
  };
5154
5449
 
5155
5450
  /**
@@ -5218,6 +5513,22 @@ export const textarea: RecipeInput = {
5218
5513
  xl: { textarea: { base: { fontSize: 'var(--text-lg)', minHeight: `calc(${fieldHeight('xl')} * 2)`, padding: 'var(--space-lg) var(--space-xl)' } } },
5219
5514
  },
5220
5515
  },
5516
+ // lynx (signalxjs/lynx#1205): lynx-zero renders the part as a view (the
5517
+ // box, whose state classes repaint) around the native field, which wears
5518
+ // the part's base class as its text face. So the ink and the placeholder
5519
+ // are explicit here — `color: inherit` and `::placeholder` never reach a
5520
+ // native widget — and the web's `:hover` border and outline ring become
5521
+ // the lynx focus ring in the root's accent (signalxjs/lynx#1163).
5522
+ targets: {
5523
+ lynx: {
5524
+ parts: {
5525
+ textarea: {
5526
+ base: { color: 'var(--color-base-content)', '-x-placeholder-color': textFieldPlaceholder },
5527
+ states: { 'focus-visible': lynxFocusRing('var(--textarea-accent)') },
5528
+ },
5529
+ },
5530
+ },
5531
+ },
5221
5532
  };
5222
5533
 
5223
5534
  // ── Content tier (#311) ───────────────────────────────────────────────────
@@ -7401,7 +7712,8 @@ const sheetSlide = (enter: string, exit: string): PartStyles => {
7401
7712
  const open = `${sheet}[data-state="open"]`;
7402
7713
  const travel = (tempo: string): string => {
7403
7714
  const duration = tempo.split(' ')[0];
7404
- return `translate ${tempo}, display ${duration} allow-discrete, overlay ${duration} allow-discrete`;
7715
+ // `transform` rides along for the swipe's spring back (#293).
7716
+ return `translate ${tempo}, transform ${tempo}, display ${duration} allow-discrete, overlay ${duration} allow-discrete`;
7405
7717
  };
7406
7718
  return {
7407
7719
  base: { '--drawer-travel': '-100% 0' },
@@ -7539,8 +7851,27 @@ export const drawer: RecipeInput = {
7539
7851
  // Trigger-carried axes — see `btnColors` for why the panel is out of
7540
7852
  // reach and the trigger is the whole story here.
7541
7853
  variants: { color: btnColors(), size: btnSizes },
7542
- // The btn paddings, restated physically — see `lynxBtnPad` (#1084).
7543
7854
  targets: {
7855
+ // Swipe to dismiss (#293) — web only, since the runtime's `--swipe-*`
7856
+ // offset is: the sheet wears the drag offset (`SWIPE`) in every
7857
+ // state — so a swiped sheet leaves from where it was let go — at a
7858
+ // weight above the presence's own open `transform`. Mid-swipe no
7859
+ // transition runs. A touch pan across the dismiss axis stays the
7860
+ // page's; along it is the gesture's, so content taller than a
7861
+ // block-edge sheet scrolls in a box of its own inside the panel.
7862
+ web: {
7863
+ parts: {
7864
+ panel: {
7865
+ selectors: {
7866
+ '&[data-l-dock="sheet"][data-state]': { transform: SWIPE },
7867
+ '&[data-l-dock="sheet"][data-state="open"][data-swiping]': { transition: 'none', userSelect: 'none' },
7868
+ '&[data-l-dock="sheet"]:where([data-placement="start"], [data-placement="end"])': { touchAction: 'pan-y' },
7869
+ '&[data-l-dock="sheet"]:where([data-placement="top"], [data-placement="bottom"])': { touchAction: 'pan-x' },
7870
+ },
7871
+ },
7872
+ },
7873
+ },
7874
+ // The btn paddings, restated physically — see `lynxBtnPad` (#1084).
7544
7875
  lynx: {
7545
7876
  parts: {
7546
7877
  trigger: { base: lynxBtnPad(4) },