@sigx/zero-daisyui 0.15.0 → 0.17.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
@@ -240,6 +240,98 @@ const disclosureExit = (scope: 'collapsible' | 'accordion'): RecipeInput['target
240
240
  },
241
241
  });
242
242
 
243
+ /**
244
+ * Collapsible's non-native mode (#453): a `<div>` root, a `<button>` trigger
245
+ * and a panel hidden with `hidden="until-found"`. Nothing lives on
246
+ * `::details-content` there, so the panel does that wrapper's work itself:
247
+ * collapsed while `hidden` (the UA's `content-visibility: hidden` keeps the
248
+ * box — padding and border included — on the page), clipped so its content
249
+ * never spills out of a box that is growing or shrinking, and grown
250
+ * from collapsed to `auto` on the open state (`interpolate-size` is the
251
+ * root's, and inherits); the close is `disclosureExit`'s, as in native mode.
252
+ * Inert in native mode: a native panel is never `hidden`, and its block size
253
+ * never moves.
254
+ *
255
+ * The trigger sheds the paint a UA `<button>` has and a `<summary>` never
256
+ * had — only where the recipe's own base leaves a property unset, since this
257
+ * rule (`:where(button)`, the base's specificity) is emitted after it.
258
+ * Web-only, like `hidden="until-found"` itself.
259
+ */
260
+ const BUTTON_RESET: CssProps = {
261
+ appearance: 'none',
262
+ margin: '0',
263
+ padding: '0',
264
+ border: '0',
265
+ background: 'none',
266
+ color: 'inherit',
267
+ fontFamily: 'inherit',
268
+ fontSize: 'inherit',
269
+ fontWeight: 'inherit',
270
+ fontStyle: 'inherit',
271
+ lineHeight: 'inherit',
272
+ letterSpacing: 'inherit',
273
+ textTransform: 'inherit',
274
+ textAlign: 'start',
275
+ inlineSize: '100%',
276
+ boxSizing: 'border-box',
277
+ };
278
+ /** The reset minus what `base` states itself (a `borderRadius` is no border). */
279
+ const summaryLike = (base: CssProps = {}): CssProps => {
280
+ const own = Object.keys(base).filter((key) => !key.endsWith('Radius'));
281
+ return Object.fromEntries(Object.entries(BUTTON_RESET)
282
+ .filter(([key]) => !own.some((k) => k === key || k.startsWith(key))));
283
+ };
284
+ const withNonNative = (recipe: RecipeInput): RecipeInput => {
285
+ const web = recipe.targets?.web ?? {};
286
+ const trigger = web.parts?.trigger ?? {};
287
+ const panel = web.parts?.panel ?? {};
288
+ const reduced = panel.at?.['reduced-motion'] ?? {};
289
+ return {
290
+ ...recipe,
291
+ targets: {
292
+ ...recipe.targets,
293
+ web: {
294
+ ...web,
295
+ parts: {
296
+ ...web.parts,
297
+ trigger: {
298
+ ...trigger,
299
+ selectors: { ...trigger.selectors, '&:where(button)': summaryLike(recipe.parts.trigger?.base) },
300
+ },
301
+ panel: {
302
+ ...panel,
303
+ base: { ...panel.base, overflow: 'clip' },
304
+ selectors: {
305
+ ...panel.selectors,
306
+ '&[data-state="closed"][hidden]': {
307
+ blockSize: '0',
308
+ paddingBlock: '0',
309
+ borderBlockWidth: '0',
310
+ animation: 'none',
311
+ },
312
+ },
313
+ // The open half: from the collapsed box to `auto`.
314
+ states: {
315
+ ...panel.states,
316
+ open: {
317
+ ...panel.states?.open,
318
+ transition: 'block-size var(--duration-normal) var(--ease-standard), padding-block var(--duration-normal) var(--ease-standard)',
319
+ },
320
+ },
321
+ at: {
322
+ ...panel.at,
323
+ 'reduced-motion': {
324
+ ...reduced,
325
+ states: { ...reduced.states, open: { transition: 'none' } },
326
+ },
327
+ },
328
+ },
329
+ },
330
+ },
331
+ },
332
+ };
333
+ };
334
+
243
335
  /**
244
336
  * Merge presence into a part's own styles per KEY, not per block: a recipe
245
337
  * that already writes `states: { open: {} }` — the "deliberately unstyled"
@@ -660,7 +752,7 @@ const disclosureSizes: Record<string, Record<string, PartStyles>> = {
660
752
  };
661
753
 
662
754
  // daisy "collapse collapse-arrow" flavor.
663
- export const collapsible: RecipeInput = {
755
+ export const collapsible: RecipeInput = withNonNative({
664
756
  component: 'collapsible',
665
757
  targets: disclosureExit('collapsible'),
666
758
  // Public to a design system derived from this one (#73).
@@ -739,7 +831,7 @@ export const collapsible: RecipeInput = {
739
831
  // signalxjs/lynx#1070. No `color` default: the un-attributed accent is
740
832
  // base-content ink, outside the color vocabulary.
741
833
  defaultVariants: { size: 'md' },
742
- };
834
+ });
743
835
 
744
836
  // daisy "toggle" flavor.
745
837
  export const switchRecipe: RecipeInput = {
@@ -1413,7 +1505,17 @@ export const tooltip: RecipeInput = {
1413
1505
  targets: {
1414
1506
  web: floatingArrow('tooltip', { background: 'var(--color-neutral)' }, '0.5rem'),
1415
1507
  lynx: {
1416
- parts: { trigger: { base: lynxBtnPad(4) } },
1508
+ parts: {
1509
+ // Content-sized like daisy's btn, never broken mid-word
1510
+ // (signalxjs/lynx#1165). No pressed rule: the anatomy
1511
+ // declares none — the long press paints the `open` state.
1512
+ trigger: { base: { ...lynxBtnPad(4), ...lynxBtnFit } },
1513
+ // The arrow's paint: the bubble's neutral, continued. The
1514
+ // web's `floatingArrow` geometry rides runtime custom
1515
+ // properties lynx does not write, so lynx-zero places the
1516
+ // 8px square and turns it itself (signalxjs/lynx#1277).
1517
+ arrow: { base: { background: 'var(--color-neutral)' } },
1518
+ },
1417
1519
  variants: { size: lynxBtnSizes('trigger') },
1418
1520
  },
1419
1521
  },
@@ -4632,6 +4734,70 @@ export const combobox: RecipeInput = {
4632
4734
  },
4633
4735
  targets: {
4634
4736
  web: { parts: { popup: { base: anchoredListbox } } },
4737
+ // lynx-zero's combobox (signalxjs/lynx#1278): a native text field in
4738
+ // a painted `control` view, the list in the overlay outlet.
4739
+ lynx: {
4740
+ parts: {
4741
+ // select's bounds (signalxjs/lynx#1146): 20rem, never wider
4742
+ // than the container. The web's 13rem control floor
4743
+ // overflowed any cell narrower than 208px on a phone; the
4744
+ // control fills the root instead.
4745
+ root: { base: { width: '20rem', maxWidth: '100%', minWidth: '3rem' } },
4746
+ // The web `:hover` border is dropped on a touch platform. The
4747
+ // ring is the box-shadow spelling (signalxjs/lynx#1163): lynx
4748
+ // has no `outline-offset`, and its outline ignores the radius.
4749
+ control: {
4750
+ base: { minWidth: '3rem', width: '100%' },
4751
+ states: lynxFocus(),
4752
+ },
4753
+ // `color: inherit` never reaches a native text field, and
4754
+ // `::placeholder` does not exist: the ink is explicit, and the
4755
+ // placeholder takes lynx's `-x-placeholder-color` — the web's
4756
+ // 60% mix, baked per theme (input's spelling; its constant is
4757
+ // declared below this recipe, so the value is restated).
4758
+ input: {
4759
+ base: {
4760
+ color: 'var(--color-base-content)',
4761
+ '-x-placeholder-color': 'color-mix(in oklab, var(--color-base-content) 60%, transparent)',
4762
+ flexGrow: '1',
4763
+ flexShrink: '1',
4764
+ },
4765
+ },
4766
+ // The chevron and the × are `<view>`s holding a glyph: centred
4767
+ // in the stretched row (a lynx view lays its child out at the
4768
+ // top), in explicit ink. A held chevron comes up to full ink —
4769
+ // the touch answer to the web's resting 60%. The clear-trigger
4770
+ // declares no pressed flag; its feedback is the text leaving.
4771
+ trigger: {
4772
+ base: { color: 'var(--color-base-content)', display: 'flex', alignItems: 'center', justifyContent: 'center' },
4773
+ states: { pressed: { opacity: '1' } },
4774
+ },
4775
+ 'clear-trigger': {
4776
+ base: { color: 'var(--color-base-content)', display: 'flex', alignItems: 'center', justifyContent: 'center' },
4777
+ },
4778
+ // The chip's ring is the lynx one; its remove button's ring is
4779
+ // drawn inside, so it never paints over the chip's label.
4780
+ tag: { states: lynxFocus() },
4781
+ 'tag-remove': {
4782
+ base: { color: 'var(--color-base-content)' },
4783
+ states: lynxFocus({ inset: true }),
4784
+ },
4785
+ // The popup renders in the overlay outlet, outside the root,
4786
+ // so it cannot inherit `--combobox-accent`, and an unresolved
4787
+ // `var()` on lynx has no fallback (select's
4788
+ // signalxjs/lynx#1168). The popup restates the accent; its
4789
+ // colour-axis twins are in `variants.color` below.
4790
+ popup: { base: { '--combobox-accent': 'var(--color-primary)' } },
4791
+ // A touch list has no pointer highlight: a held option takes
4792
+ // the highlighted wash.
4793
+ item: { states: { pressed: { background: 'var(--color-base-200)' } } },
4794
+ },
4795
+ variants: {
4796
+ color: Object.fromEntries(ROLES.map((c) => [c, { popup: { base: {
4797
+ '--combobox-accent': `var(--color-${c})`,
4798
+ } } }])),
4799
+ },
4800
+ },
4635
4801
  },
4636
4802
  };
4637
4803
 
@@ -5415,6 +5581,15 @@ const lynxTreeRowStates: Record<string, CssProps> = {
5415
5581
  ...lynxFocus({ inset: true }),
5416
5582
  };
5417
5583
 
5584
+ /**
5585
+ * The tree rows' lynx base: the fill still eases, the ink snaps — a
5586
+ * transitioned `color` never reaches the row's inheriting label on lynx
5587
+ * (signalxjs/lynx#1292; see the tree-view recipe's `targets`).
5588
+ */
5589
+ const lynxTreeRowBase: CssProps = {
5590
+ transition: 'background var(--duration-fast) var(--ease-standard)',
5591
+ };
5592
+
5418
5593
  /** A held selected row keeps its accent fill (the web's `:not([data-selected])`). */
5419
5594
  const lynxTreeRowSelectors: Record<string, CssProps> = {
5420
5595
  '&[data-selected][data-pressed]': { background: 'var(--tree-accent)' },
@@ -5575,11 +5750,23 @@ export const treeView: RecipeInput = {
5575
5750
  // those carry is restated on the part the runtime stamps. The skin's
5576
5751
  // `:dir(rtl)` mirror and the `@media` stops have no lynx form (no RTL
5577
5752
  // flow, no conditions) and stay dropped.
5753
+ //
5754
+ // The rows transition `background` only (signalxjs/lynx#1292, zero#521).
5755
+ // A row's label is the author's `<text>`, inked by inheritance from the
5756
+ // row. Lynx's new animator (on by default) takes a transitioned `color`
5757
+ // change away from the element's own style pass and ticks it into the
5758
+ // row's paint alone, and the row's children inherit its computed colour
5759
+ // only in that pass, so they kept the OLD ink: a row selected live
5760
+ // painted base-content text on the accent fill, while a row selected at
5761
+ // mount (no transition runs) was right. Read in Lynx's
5762
+ // `fiber_element.cc` (`ConsumeStyleInternal`) and
5763
+ // `css_transition_manager.cc` (`ConsumeCSSProperty`). Without a `color`
5764
+ // transition the new ink is set in the style pass and reaches the label.
5578
5765
  targets: {
5579
5766
  lynx: {
5580
5767
  parts: {
5581
- item: { states: lynxTreeRowStates, selectors: lynxTreeRowSelectors },
5582
- 'branch-trigger': { states: lynxTreeRowStates, selectors: lynxTreeRowSelectors },
5768
+ item: { base: lynxTreeRowBase, states: lynxTreeRowStates, selectors: lynxTreeRowSelectors },
5769
+ 'branch-trigger': { base: lynxTreeRowBase, states: lynxTreeRowStates, selectors: lynxTreeRowSelectors },
5583
5770
  // `inline-block` is not a lynx display value. The glyph is a
5584
5771
  // `<text>` part: a 1em box centred on it, so the quarter turn
5585
5772
  // pivots on the chevron instead of swinging it sideways.
@@ -6669,7 +6856,11 @@ export const divider: RecipeInput = {
6669
6856
  // no fill, its cross size following the label, a row when
6670
6857
  // horizontal. Both are lynx-zero rendering details, not
6671
6858
  // modifiers an author sets, so they are styled from raw lynx css
6672
- // (the toggle-group join ends' reasoning).
6859
+ // (the toggle-group join ends' reasoning). A segment centres
6860
+ // itself on the label (signalxjs/lynx#1272): it carries the
6861
+ // root's `align-self: stretch`, and a stretched item with a
6862
+ // definite thickness lands at cross-start on lynx — the top of
6863
+ // the label row, not the middle daisy's rules sit on.
6673
6864
  css: `
6674
6865
  .zx-divider__root.zx-m-labelled {
6675
6866
  --divider-ink: transparent;
@@ -6683,6 +6874,7 @@ export const divider: RecipeInput = {
6683
6874
  }
6684
6875
  .zx-divider__root.zx-m-segment {
6685
6876
  flex: 1 1 0;
6877
+ align-self: center;
6686
6878
  }
6687
6879
  .zx-divider__root.zx-m-segment.zx-o-horizontal {
6688
6880
  min-width: 0;
@@ -7621,14 +7813,43 @@ export const chat: RecipeInput = {
7621
7813
  xl: { bubble: { base: { fontSize: 'var(--text-lg)', padding: 'var(--space-md) var(--space-xl)' } } },
7622
7814
  },
7623
7815
  },
7624
- // The row's vertical padding, restated physically: logical spellings
7625
- // resolve on iOS but not on Android (measured, signalxjs/lynx#1084), so
7626
- // the emitter refuses them.
7816
+ // Lynx (signalxjs/lynx#1276):
7817
+ // - the row's vertical padding, restated physically: logical spellings
7818
+ // resolve on iOS but not on Android (measured, signalxjs/lynx#1084), so
7819
+ // the emitter refuses them;
7820
+ // - lynx has no grid, so the row is a flex column (header, bubble,
7821
+ // footer), gathered to the placement's side. The avatar leaves the
7822
+ // flow: lynx-zero pins it to the row's bottom corner and reserves its
7823
+ // measured width beside the column;
7824
+ // - the `[data-placement] > &` selectors have no class form on the
7825
+ // parts (placement is the root's), so the bubble's square tail corner
7826
+ // is raw lynx css, a descendant of the placed root — the shape the
7827
+ // theme restatements are proven to match. Physical corners: lynx has
7828
+ // no RTL flow.
7627
7829
  targets: {
7628
7830
  lynx: {
7629
7831
  parts: {
7630
- root: { base: { paddingTop: 'var(--space-2xs)', paddingBottom: 'var(--space-2xs)' } },
7832
+ root: {
7833
+ base: {
7834
+ display: 'flex',
7835
+ flexDirection: 'column',
7836
+ alignItems: 'flex-start',
7837
+ paddingTop: 'var(--space-2xs)',
7838
+ paddingBottom: 'var(--space-2xs)',
7839
+ },
7840
+ selectors: {
7841
+ '&[data-placement="end"]': { alignItems: 'flex-end' },
7842
+ },
7843
+ },
7631
7844
  },
7845
+ css: `
7846
+ .zx-chat__root.zx-p-start .zx-chat__bubble {
7847
+ border-bottom-left-radius: 0;
7848
+ }
7849
+ .zx-chat__root.zx-p-end .zx-chat__bubble {
7850
+ border-bottom-right-radius: 0;
7851
+ }
7852
+ `,
7632
7853
  },
7633
7854
  },
7634
7855
  };
@@ -7721,6 +7942,59 @@ export const chatLog: RecipeInput = {
7721
7942
  xl: { content: { base: { rowGap: 'var(--space-md)' } }, 'jump-trigger': { base: { '--chat-log-jump-size': 'calc(var(--size-field) * 10)', fontSize: 'var(--text-sm)' } } },
7722
7943
  },
7723
7944
  },
7945
+ // Lynx (signalxjs/lynx#1276). lynx-zero's root is the frame and a native
7946
+ // scroll-view inside Content does the scrolling, so:
7947
+ // - the frame's padding moves onto the content (physical — logical
7948
+ // spellings do not resolve on Android, signalxjs/lynx#1084), so rows
7949
+ // scroll to the frame's edge instead of clipping at an inset;
7950
+ // - the content's grid is a flex column (lynx has no grid); a short
7951
+ // transcript sits at the top — `margin-block-start: auto` has nothing
7952
+ // to push against inside a scroll-view;
7953
+ // - the jump trigger floats in lynx-zero's absolute dock at the frame's
7954
+ // foot, so it drops `position: sticky` and the negative margin, and
7955
+ // restates its inline padding physically;
7956
+ // - `:hover` is dropped on touch: the held trigger shows the hover's
7957
+ // 90% darkening (restated per colour — a colour function over
7958
+ // `--chat-log-fill` cannot bake), with the existing 1px sink;
7959
+ // - the focus rings as `lynxFocus` (lynx#1163), the frame's drawn inside
7960
+ // like the web's negative offset.
7961
+ targets: {
7962
+ lynx: {
7963
+ parts: {
7964
+ root: {
7965
+ base: { padding: '0', overflow: 'hidden' },
7966
+ states: { 'focus-visible': lynxFocusRing('var(--color-base-content)', { inset: true }) },
7967
+ },
7968
+ content: {
7969
+ base: {
7970
+ display: 'flex',
7971
+ flexDirection: 'column',
7972
+ paddingTop: 'var(--space-sm)',
7973
+ paddingBottom: 'var(--space-sm)',
7974
+ paddingLeft: 'var(--space-md)',
7975
+ paddingRight: 'var(--space-md)',
7976
+ },
7977
+ },
7978
+ 'jump-trigger': {
7979
+ base: {
7980
+ position: 'relative',
7981
+ flexDirection: 'row',
7982
+ paddingLeft: 'var(--space-md)',
7983
+ paddingRight: 'var(--space-md)',
7984
+ },
7985
+ states: {
7986
+ pressed: { background: 'color-mix(in oklab, var(--color-primary) 90%, black)' },
7987
+ ...lynxFocus(),
7988
+ },
7989
+ },
7990
+ },
7991
+ variants: {
7992
+ color: Object.fromEntries(ROLES.map((c) => [c, { 'jump-trigger': { states: {
7993
+ pressed: { background: `color-mix(in oklab, var(--color-${c}) 90%, black)` },
7994
+ } } }])),
7995
+ },
7996
+ },
7997
+ },
7724
7998
  };
7725
7999
 
7726
8000
  /** daisy radial-progress: --value became --progress-percent; same ring. */
@@ -7976,13 +8250,26 @@ export const navbar: RecipeInput = {
7976
8250
  // so the bar's height ramp is restated as `min-height`; and a lynx flex
7977
8251
  // container is only a row when it says so, so the bar and its three
7978
8252
  // sections spell `flex-direction: row`.
8253
+ //
8254
+ // No section squeezes a word (signalxjs/lynx#1274, zero#499). On the web
8255
+ // `flex: 1 1 0%` is floored by the flex item's automatic minimum (its
8256
+ // min-content width), so "Acme" never breaks. Lynx's flex layout has no
8257
+ // automatic minimum, and its `min-width` takes no intrinsic keyword, so
8258
+ // a zero basis shrank the start below its text and the `<text>` wrapped
8259
+ // per character ("Acm / e"). On lynx the ends start from their content
8260
+ // (`flex-basis: auto`), never shrink, and share the slack equally; the
8261
+ // centre takes daisy's own `flex-shrink: 0`. A bar too narrow for its
8262
+ // content overflows whole, as daisy's does — the btn rule (lynx#1165).
8263
+ // The cost: with a centre and unequal ends, the centre sits off-centre
8264
+ // by half the ends' difference (the web's is exact until a section
8265
+ // reaches its min-content).
7979
8266
  targets: {
7980
8267
  lynx: {
7981
8268
  parts: {
7982
8269
  root: { base: { flexDirection: 'row', minHeight: '4rem' } },
7983
- start: { base: { flexDirection: 'row' } },
7984
- center: { base: { flexDirection: 'row' } },
7985
- end: { base: { flexDirection: 'row' } },
8270
+ start: { base: { flexDirection: 'row', flexBasis: 'auto', flexShrink: '0' } },
8271
+ center: { base: { flexDirection: 'row', flexShrink: '0' } },
8272
+ end: { base: { flexDirection: 'row', flexBasis: 'auto', flexShrink: '0' } },
7986
8273
  },
7987
8274
  variants: {
7988
8275
  size: {
@@ -8932,11 +9219,68 @@ export const drawer: RecipeInput = {
8932
9219
  },
8933
9220
  },
8934
9221
  },
8935
- // The btn paddings, restated physically — see `lynxBtnPad` (#1084).
9222
+ // The btn paddings, restated physically — see `lynxBtnPad` (#1084) —
9223
+ // and the btns' press rendering (`lynxBtnPressed`), as dialog wears
9224
+ // them. The edge sheet (signalxjs/lynx#1277): lynx-zero lays the
9225
+ // panel on its edge with flexbox inside the backdrop, so the sheet
9226
+ // stays in flow (`position: relative`, never `fixed`) and sizes to
9227
+ // it — no viewport units, which lynx does not resolve against the
9228
+ // overlay outlet. The skin keeps the cap, the corners and the slide.
8936
9229
  lynx: {
9230
+ keyframes: {
9231
+ // `translate` is Android-asymmetric (#1084): the travel is a
9232
+ // transform function, one per edge.
9233
+ 'zero-daisy-drawer-in-start': 'from { transform: translateX(-100%); } to { transform: translateX(0); }',
9234
+ 'zero-daisy-drawer-in-end': 'from { transform: translateX(100%); } to { transform: translateX(0); }',
9235
+ 'zero-daisy-drawer-in-top': 'from { transform: translateY(-100%); } to { transform: translateY(0); }',
9236
+ 'zero-daisy-drawer-in-bottom': 'from { transform: translateY(100%); } to { transform: translateY(0); }',
9237
+ },
8937
9238
  parts: {
8938
- trigger: { base: lynxBtnPad(4) },
8939
- close: { base: lynxBtnPad(4) },
9239
+ trigger: { base: { ...lynxBtnPad(4), ...lynxBtnFit }, states: { pressed: lynxBtnPressed, ...lynxFocus() } },
9240
+ close: { base: { ...lynxBtnPad(4), ...lynxBtnFit }, states: { pressed: lynxBtnPressed, ...lynxFocus() } },
9241
+ panel: {
9242
+ // The cap alone: `min(20rem, 85vw)` has no lynx spelling
9243
+ // (no min()); lynx-zero states the 85% share inline and
9244
+ // this caps it — together, the web's width.
9245
+ base: { '--l-measure': '20rem' },
9246
+ selectors: {
9247
+ '&[data-l-dock="sheet"]': {
9248
+ position: 'relative',
9249
+ blockSize: 'auto',
9250
+ maxBlockSize: 'none',
9251
+ },
9252
+ // The block edges run full width (the web's `:where()`
9253
+ // pair, one selector each — no `:where()` on lynx),
9254
+ // content-sized up to 85% of the outlet.
9255
+ '&[data-placement="top"]': { '--l-measure': 'none' },
9256
+ '&[data-placement="bottom"]': { '--l-measure': 'none' },
9257
+ '&[data-placement="top"][data-l-dock="sheet"]': {
9258
+ maxBlockSize: '85%',
9259
+ borderBottomLeftRadius: 'var(--radius-box)',
9260
+ borderBottomRightRadius: 'var(--radius-box)',
9261
+ },
9262
+ '&[data-placement="bottom"][data-l-dock="sheet"]': {
9263
+ maxBlockSize: '85%',
9264
+ borderTopLeftRadius: 'var(--radius-box)',
9265
+ borderTopRightRadius: 'var(--radius-box)',
9266
+ },
9267
+ // The slide in from the edge (the web's `translate`
9268
+ // over `@starting-style`, both dropped here). The
9269
+ // exit is an unmount on lynx, so only the entry runs.
9270
+ '&[data-placement="start"][data-l-dock="sheet"][data-state="open"]': {
9271
+ animation: 'zero-daisy-drawer-in-start var(--duration-slow) var(--ease-standard)',
9272
+ },
9273
+ '&[data-placement="end"][data-l-dock="sheet"][data-state="open"]': {
9274
+ animation: 'zero-daisy-drawer-in-end var(--duration-slow) var(--ease-standard)',
9275
+ },
9276
+ '&[data-placement="top"][data-l-dock="sheet"][data-state="open"]': {
9277
+ animation: 'zero-daisy-drawer-in-top var(--duration-slow) var(--ease-standard)',
9278
+ },
9279
+ '&[data-placement="bottom"][data-l-dock="sheet"][data-state="open"]': {
9280
+ animation: 'zero-daisy-drawer-in-bottom var(--duration-slow) var(--ease-standard)',
9281
+ },
9282
+ },
9283
+ },
8940
9284
  },
8941
9285
  variants: { size: lynxBtnSizes('trigger') },
8942
9286
  },
@@ -9136,6 +9480,96 @@ export const table: RecipeInput = {
9136
9480
  },
9137
9481
  },
9138
9482
  },
9483
+ // signalxjs/lynx#1279. Lynx has no table layout: lynx-zero lays the
9484
+ // parts out as flex rows itself. The `hover` modifier has no lynx
9485
+ // answer (a touch screen has no hover), nor has the viewport-width
9486
+ // `@media` of the stacked mode — lynx-zero resolves `stack` in JS and
9487
+ // stamps the parts instead (see `css` below).
9488
+ targets: {
9489
+ lynx: {
9490
+ parts: {
9491
+ // The web's `overflow-x: auto` box: a lynx view clips its
9492
+ // rounded frame; a table of fixed columns scrolls in a
9493
+ // scroll-view lynx-zero renders.
9494
+ root: {
9495
+ base: { overflow: 'hidden' },
9496
+ // daisy's ring drawn inside the box, as on the web
9497
+ // (outline-offset: -2px) — spelled as an inset shadow.
9498
+ states: { 'focus-visible': lynxFocusRing('var(--color-base-content)', { inset: true }) },
9499
+ },
9500
+ // The muted header ink: the colour function over
9501
+ // `--table-accent` cannot bake (a recipe-local property),
9502
+ // so the default accent's mix is restated here and each
9503
+ // colour's below.
9504
+ 'header-cell': { base: { color: 'color-mix(in oklab, var(--color-base-content) 60%, transparent)' } },
9505
+ 'cell-label': { base: { color: 'color-mix(in oklab, var(--color-base-content) 60%, transparent)' } },
9506
+ // The web's hover ink, while held; the lynx ring.
9507
+ 'sort-trigger': {
9508
+ base: { flexDirection: 'row' },
9509
+ states: { pressed: { color: 'var(--table-accent)' }, ...lynxFocus() },
9510
+ },
9511
+ // The mark never takes its width out of the header's word
9512
+ // (signalxjs/lynx#1299): lynx's flex has no automatic
9513
+ // minimum, so the ▲ (kept in layout while unsorted) squeezed
9514
+ // "Name" into "Nam / e" at xl. lynx-zero holds the label's
9515
+ // `<text>` at its content width; a header too narrow for
9516
+ // both overflows whole, as the web's column grows.
9517
+ 'sort-indicator': { base: { flexShrink: '0' } },
9518
+ },
9519
+ variants: {
9520
+ color: Object.fromEntries(ROLES.map((c) => {
9521
+ const muted = `color-mix(in oklab, ${roleInk(c)} 60%, transparent)`;
9522
+ return [c, {
9523
+ 'header-cell': { base: { color: muted } },
9524
+ 'cell-label': { base: { color: muted } },
9525
+ }];
9526
+ })),
9527
+ },
9528
+ // lynx-zero rendering details, not modifiers an author sets:
9529
+ // - `stripe`: lynx has no `:nth-child`, so the body stamps its
9530
+ // even, unselected rows (mount order) under `zebra`.
9531
+ // - `held`: the unsorted mark the web reveals under a hovered or
9532
+ // keyboard-focused trigger, shown while the trigger is held.
9533
+ // - `stacked`: the stacked mode's card chrome (the web's
9534
+ // below-breakpoint rules). Zero widths and paddings keep a
9535
+ // var() so they beat the var-bearing base values (lynx expands
9536
+ // var() declarations after the cascade, signalxjs/lynx#1161);
9537
+ // the root keeps its base-100 fill (a var-free transparent
9538
+ // would lose the same way).
9539
+ css: `
9540
+ .zx-table__root.zx-m-stacked {
9541
+ border-width: calc(var(--border) * 0);
9542
+ border-radius: calc(var(--radius-box) * 0);
9543
+ }
9544
+ .zx-table__caption.zx-m-stacked {
9545
+ padding-left: calc(var(--table-pad-inline) * 0);
9546
+ padding-right: calc(var(--table-pad-inline) * 0);
9547
+ }
9548
+ .zx-table__row.zx-m-stacked {
9549
+ border: var(--border) solid var(--color-base-200);
9550
+ border-radius: var(--radius-box);
9551
+ background: var(--color-base-100);
9552
+ padding: var(--space-sm) var(--table-pad-inline);
9553
+ margin-bottom: var(--space-md);
9554
+ }
9555
+ .zx-table__row.zx-m-stacked.zx-f-selected {
9556
+ background: var(--color-base-300);
9557
+ }
9558
+ .zx-table__cell.zx-m-stacked {
9559
+ padding-left: calc(var(--table-pad-inline) * 0);
9560
+ padding-right: calc(var(--table-pad-inline) * 0);
9561
+ padding-top: var(--space-xs);
9562
+ padding-bottom: var(--space-xs);
9563
+ }
9564
+ .zx-table__row.zx-m-stripe {
9565
+ background: var(--color-base-200);
9566
+ }
9567
+ .zx-table__sort-indicator.zx-m-held.zx-s-none {
9568
+ opacity: 0.6;
9569
+ }
9570
+ `,
9571
+ },
9572
+ },
9139
9573
  };
9140
9574
 
9141
9575
  /**
@@ -9302,6 +9736,73 @@ export const fileUpload: RecipeInput = {
9302
9736
  xl: { root: { base: { '--fu-pad': 'calc(var(--space-xl) * 1.25)', '--fu-font': 'var(--text-md)' } } },
9303
9737
  },
9304
9738
  },
9739
+ // Lynx (signalxjs/lynx#1276):
9740
+ // - lynx has no grid: the root and the item list are flex columns, and
9741
+ // the stretched parts (dropzone, item list) say so with `align-self`;
9742
+ // the rows (trigger, item) state `flex-direction: row`;
9743
+ // - the dropzone centres its words in a column (lynx text alignment is
9744
+ // the text's own), and its highlighted wash — a colour function over
9745
+ // `--fu-accent`, which cannot bake — is restated per colour over the
9746
+ // role. lynx never drives `highlighted` (no drag source); it shows
9747
+ // through `ForceStates` in the gallery;
9748
+ // - `:hover` is dropped on touch: the held trigger shows daisy's
9749
+ // base-300 wash + 1px sink (`lynxBtnPressed`, in place of the
9750
+ // standalone `translate`, which Android does not resolve), and the
9751
+ // held × and clear show the ghost's base-200 wash;
9752
+ // - the focus rings as `lynxFocus` (lynx#1163).
9753
+ targets: {
9754
+ lynx: {
9755
+ parts: {
9756
+ root: { base: { display: 'flex', flexDirection: 'column', alignItems: 'flex-start' } },
9757
+ trigger: {
9758
+ base: { flexDirection: 'row' },
9759
+ states: { pressed: lynxBtnPressed, ...lynxFocus() },
9760
+ },
9761
+ dropzone: {
9762
+ base: { alignSelf: 'stretch', display: 'flex', flexDirection: 'column', alignItems: 'center' },
9763
+ states: {
9764
+ highlighted: { background: 'color-mix(in oklab, var(--color-primary) 8%, var(--color-base-100))' },
9765
+ },
9766
+ },
9767
+ 'item-group': {
9768
+ base: { alignSelf: 'stretch', display: 'flex', flexDirection: 'column' },
9769
+ },
9770
+ item: { base: { flexDirection: 'row' } },
9771
+ // Only the name gives way in a narrow row (signalxjs/lynx#1294).
9772
+ // Lynx's flex has no automatic minimum, so every item
9773
+ // shrank below its content: "1.5 kB" wrapped to two lines
9774
+ // and the × squeezed into an oval. The size and the × keep
9775
+ // their content width, as the web's do; the name
9776
+ // (`flex: 1 1 auto; min-width: 0`) ellipsizes.
9777
+ 'item-size': { base: { flexShrink: '0' } },
9778
+ 'item-remove': {
9779
+ base: { flexShrink: '0' },
9780
+ states: { pressed: { background: 'var(--color-base-200)' }, ...lynxFocus() },
9781
+ },
9782
+ 'clear-trigger': {
9783
+ states: { pressed: { background: 'var(--color-base-200)' }, ...lynxFocus() },
9784
+ },
9785
+ },
9786
+ variants: {
9787
+ color: Object.fromEntries(ROLES.map((c) => [c, { dropzone: { states: {
9788
+ highlighted: { background: `color-mix(in oklab, var(--color-${c}) 8%, var(--color-base-100))` },
9789
+ } } }])),
9790
+ },
9791
+ },
9792
+ },
9793
+ };
9794
+
9795
+ /** A carousel nav circle on lynx: the glyph centred as flex (no `grid`). */
9796
+ const carouselTriggerLynx: CssProps = {
9797
+ display: 'flex',
9798
+ alignItems: 'center',
9799
+ justifyContent: 'center',
9800
+ };
9801
+
9802
+ /** A held carousel nav circle on lynx: daisy's hover wash plus the 1px sink, and the lynx ring. */
9803
+ const carouselTriggerLynxStates: Record<string, CssProps> = {
9804
+ pressed: { background: 'var(--color-base-300)', transform: 'translateY(1px)' },
9805
+ ...lynxFocus(),
9305
9806
  };
9306
9807
 
9307
9808
  /**
@@ -9454,14 +9955,60 @@ export const carousel: RecipeInput = {
9454
9955
  // spellings resolve on iOS but not on Android (measured,
9455
9956
  // signalxjs/lynx#1084), so the emitter refuses them. Physical is the
9456
9957
  // lynx target's norm — no RTL flow there, so `start` IS left.
9958
+ //
9959
+ // signalxjs/lynx#1279 — the rest of the lynx shape:
9960
+ // - `grid` does not lay out on lynx: the root is a flex column, and a
9961
+ // trigger centres its glyph as flex.
9962
+ // - The dot: the web draws it on `::before` inside a
9963
+ // `max(dot, 1.5rem)` button; lynx has neither pseudo-elements nor
9964
+ // `max()`, so lynx-zero renders the dot AS the indicator (its touch
9965
+ // area widened with `hit-slop`) and the ring and the active fill ride
9966
+ // the part.
9967
+ // - The web sinks a held trigger with the standalone `translate`,
9968
+ // which Android ignores (#1084): a transform function here, over
9969
+ // daisy's hover wash (no hover on touch).
9970
+ // - Rings: lynx's outline ignores radius and has no offset (#1163).
9971
+ // No replacement for the viewport's `:focus-visible` ring: there is no
9972
+ // keyboard focus on lynx, and the part declares no flag to key it on.
9457
9973
  targets: {
9458
9974
  lynx: {
9459
9975
  parts: {
9976
+ root: { base: { display: 'flex', flexDirection: 'column' } },
9460
9977
  'prev-trigger': {
9461
- base: { top: 'calc(50% - var(--carousel-nav) / 2)', left: 'var(--space-sm)' },
9978
+ base: {
9979
+ top: 'calc(50% - var(--carousel-nav) / 2)',
9980
+ left: 'var(--space-sm)',
9981
+ ...carouselTriggerLynx,
9982
+ },
9983
+ states: carouselTriggerLynxStates,
9462
9984
  },
9463
9985
  'next-trigger': {
9464
- base: { top: 'calc(50% - var(--carousel-nav) / 2)', right: 'var(--space-sm)' },
9986
+ base: {
9987
+ top: 'calc(50% - var(--carousel-nav) / 2)',
9988
+ right: 'var(--space-sm)',
9989
+ ...carouselTriggerLynx,
9990
+ },
9991
+ states: carouselTriggerLynxStates,
9992
+ },
9993
+ 'indicator-group': { base: { flexDirection: 'row', alignItems: 'center' } },
9994
+ indicator: {
9995
+ base: {
9996
+ display: 'flex',
9997
+ width: 'var(--carousel-dot)',
9998
+ height: 'var(--carousel-dot)',
9999
+ flexShrink: '0',
10000
+ // Longhands: the ring's colour bakes per theme only
10001
+ // on its own (a shorthand mixing it with the
10002
+ // --border calc cannot).
10003
+ borderWidth: 'calc(var(--border) * 2)',
10004
+ borderStyle: 'solid',
10005
+ borderColor: 'color-mix(in oklab, var(--color-base-content) 70%, transparent)',
10006
+ borderRadius: '9999px',
10007
+ },
10008
+ states: {
10009
+ active: { background: 'var(--carousel-accent)', borderColor: 'var(--carousel-accent)' },
10010
+ ...lynxFocus(),
10011
+ },
9465
10012
  },
9466
10013
  },
9467
10014
  },