@sigx/zero-daisyui 0.2.0-beta.3 → 0.2.0-beta.5

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.
Files changed (58) hide show
  1. package/README.md +8 -0
  2. package/dist/components.d.ts +3 -2
  3. package/dist/css/components/button.css +6 -0
  4. package/dist/css/components/tabs.css +191 -19
  5. package/dist/css/index.css +197 -19
  6. package/dist/lynx/components/alert.css +1 -1
  7. package/dist/lynx/components/avatar.css +22 -7
  8. package/dist/lynx/components/badge.css +1 -1
  9. package/dist/lynx/components/button.css +6 -1
  10. package/dist/lynx/components/checkbox.css +25 -10
  11. package/dist/lynx/components/combobox.css +2 -2
  12. package/dist/lynx/components/countdown.css +1 -1
  13. package/dist/lynx/components/dialog.css +3 -3
  14. package/dist/lynx/components/divider.css +18 -19
  15. package/dist/lynx/components/drawer.css +2 -2
  16. package/dist/lynx/components/file-upload.css +18 -7
  17. package/dist/lynx/components/indicator.css +1 -1
  18. package/dist/lynx/components/input.css +2 -2
  19. package/dist/lynx/components/join.css +1 -1
  20. package/dist/lynx/components/kbd.css +1 -1
  21. package/dist/lynx/components/menu.css +1 -1
  22. package/dist/lynx/components/native-select.css +1 -1
  23. package/dist/lynx/components/number-input.css +2 -2
  24. package/dist/lynx/components/pagination.css +92 -23
  25. package/dist/lynx/components/popover.css +2 -2
  26. package/dist/lynx/components/progress.css +17 -25
  27. package/dist/lynx/components/radial-progress.css +22 -24
  28. package/dist/lynx/components/radio-group.css +26 -11
  29. package/dist/lynx/components/rating-group.css +19 -23
  30. package/dist/lynx/components/select.css +2 -2
  31. package/dist/lynx/components/slider.css +3 -0
  32. package/dist/lynx/components/spinner.css +22 -19
  33. package/dist/lynx/components/status.css +27 -20
  34. package/dist/lynx/components/steps.css +67 -18
  35. package/dist/lynx/components/switch.css +79 -37
  36. package/dist/lynx/components/table.css +51 -8
  37. package/dist/lynx/components/tabs.css +383 -21
  38. package/dist/lynx/components/textarea.css +1 -1
  39. package/dist/lynx/components/timeline.css +27 -20
  40. package/dist/lynx/components/toggle-group.css +2 -2
  41. package/dist/lynx/components/toggle.css +1 -1
  42. package/dist/lynx/components/tooltip.css +1 -1
  43. package/dist/lynx/index.css +952 -321
  44. package/dist/lynx/manifest.json +33 -7
  45. package/dist/manifest.json +31 -5
  46. package/dist/recipes.d.ts +28 -0
  47. package/dist/recipes.d.ts.map +1 -1
  48. package/dist/recipes.js +522 -80
  49. package/dist/recipes.js.map +1 -1
  50. package/dist/register.d.ts +2 -3
  51. package/dist/report.json +401 -44
  52. package/dist/tokens.d.ts +17 -5
  53. package/dist/tokens.d.ts.map +1 -1
  54. package/dist/tokens.js +22 -8
  55. package/dist/tokens.js.map +1 -1
  56. package/package.json +4 -4
  57. package/src/recipes.ts +527 -81
  58. package/src/tokens.ts +24 -8
package/src/recipes.ts CHANGED
@@ -187,6 +187,34 @@ const withPresence = (presence: PartStyles, styles: PartStyles): PartStyles => (
187
187
  ),
188
188
  });
189
189
 
190
+ /**
191
+ * daisy's three tab flavors (#377): `tabs-border` — daisy's own default look,
192
+ * an underline under the active tab — plus `tabs-lift` (bordered tab lifting
193
+ * out of a baseline) and `tabs-box` (list on a base-200 surface, active tab a
194
+ * raised base-100 pill). All three ported from daisyUI 5.5.19
195
+ * `components/tab.css`.
196
+ *
197
+ * The base holds only flavor-neutral structure. Every flavor's chrome —
198
+ * including the box surface this recipe used to keep in `parts` — lives in
199
+ * its own `variant` entry, because the web emitter twins the DEFAULT value's
200
+ * rules as `:not([data-variant])`: a default that leaned on base styles would
201
+ * leak them into the other two flavors, and the box chrome in base is exactly
202
+ * what `variant="border"` must not inherit.
203
+ *
204
+ * Sizing goes through a `--tab-py`/`--tab-px` pair rather than a literal
205
+ * `padding` per size — daisy's own mechanism (`--tab-p`/`--tab-paddings` in
206
+ * tab.css). It is what lets `lift` move `var(--border)` between border and
207
+ * padding without restating the size ramp per flavor: axis rules all target
208
+ * the same part with equal specificity, so a `lift` padding literal would
209
+ * either lose to or beat the size axis by source order alone.
210
+ *
211
+ * variant × color: `color` paints the active tab's INK (`roleInk`, not the
212
+ * raw role — #210: raw roles are fill colours with no contrast floor as ink).
213
+ * `border`'s underline and `lift`'s label follow it via `currentColor`;
214
+ * `box` is the one flavor where daisy fills the active pill with the role
215
+ * (`checked:bg-primary checked:text-primary-content` in its docs), so a
216
+ * compound per role restores the fill+`-content` pair there.
217
+ */
190
218
  export const tabs: RecipeInput = {
191
219
  component: 'tabs',
192
220
  parts: {
@@ -197,10 +225,6 @@ export const tabs: RecipeInput = {
197
225
  base: {
198
226
  display: 'inline-flex',
199
227
  alignSelf: 'flex-start',
200
- padding: 'var(--space-xs)',
201
- gap: 'var(--space-xs)',
202
- background: 'var(--color-base-200)',
203
- borderRadius: 'var(--radius-field)',
204
228
  },
205
229
  },
206
230
  tab: {
@@ -208,21 +232,31 @@ export const tabs: RecipeInput = {
208
232
  appearance: 'none',
209
233
  border: 'none',
210
234
  background: 'transparent',
211
- padding: 'var(--space-sm) var(--space-xl)',
235
+ '--tab-py': 'var(--space-sm)',
236
+ '--tab-px': 'var(--space-xl)',
237
+ // daisy's per-size underline/corner cap: `--tab-radius-min`
238
+ // in tab.css, `calc(.75rem - var(--border))` at md. Declared
239
+ // here (not in `lift`, its only reader) so the size axis can
240
+ // override it — a definition inside the variant entry would
241
+ // beat the size rules on source order.
242
+ '--tab-radius-min': 'calc(0.75rem - var(--border))',
243
+ padding: 'var(--tab-py) var(--tab-px)',
212
244
  fontSize: 'var(--text-sm)',
213
245
  fontWeight: 'var(--weight-semibold)',
214
246
  color: 'color-mix(in oklab, var(--color-base-content) 60%, transparent)',
215
- borderRadius: 'calc(var(--radius-field) - 0.25rem)',
216
247
  cursor: 'pointer',
248
+ // For the flavors' ::before marks (border's underline, lift's
249
+ // corner shoulders) — daisy's base `.tab` is `position:
250
+ // relative` for the same reason.
251
+ position: 'relative',
217
252
  transition: 'background var(--duration-normal) var(--ease-standard), color var(--duration-normal) var(--ease-standard)',
218
253
  },
219
254
  states: {
220
255
  hover: { color: 'var(--color-base-content)' },
221
- active: {
222
- background: 'var(--color-base-100)',
223
- color: 'var(--color-base-content)',
224
- boxShadow: 'var(--shadow-sm)',
225
- },
256
+ // Full-strength ink is the one active mark all three flavors
257
+ // share; each flavor's own chrome (underline, lift, pill)
258
+ // lives in its `variant` entry.
259
+ active: { color: 'var(--color-base-content)' },
226
260
  inactive: {},
227
261
  disabled: { opacity: 'var(--disabled-opacity)', cursor: 'not-allowed' },
228
262
  ...focusRing,
@@ -235,40 +269,244 @@ export const tabs: RecipeInput = {
235
269
  },
236
270
  variants: {
237
271
  size: {
238
- xs: { tab: { base: { fontSize: 'var(--text-xs)', padding: 'var(--space-2xs) var(--space-md)' } } },
239
- sm: { tab: { base: { fontSize: 'var(--text-xs)', padding: 'var(--space-xs) var(--space-lg)' } } },
272
+ xs: { tab: { base: { fontSize: 'var(--text-xs)', '--tab-py': 'var(--space-2xs)', '--tab-px': 'var(--space-md)', '--tab-radius-min': 'calc(0.5rem - var(--border))' } } },
273
+ sm: { tab: { base: { fontSize: 'var(--text-xs)', '--tab-py': 'var(--space-xs)', '--tab-px': 'var(--space-lg)', '--tab-radius-min': 'calc(0.5rem - var(--border))' } } },
240
274
  // `md` is the un-attributed render: the base already IS the
241
275
  // middle step, so restating it here would be a second copy free
242
276
  // to drift. An empty entry emits no rule and keeps the base.
243
277
  md: {},
244
- lg: { tab: { base: { fontSize: 'var(--text-md)', padding: 'var(--space-md) var(--space-2xl)' } } },
245
- xl: { tab: { base: { fontSize: 'var(--text-lg)', padding: 'var(--space-lg) var(--space-2xl)' } } },
278
+ lg: { tab: { base: { fontSize: 'var(--text-md)', '--tab-py': 'var(--space-md)', '--tab-px': 'var(--space-2xl)', '--tab-radius-min': 'calc(1.5rem - var(--border))' } } },
279
+ xl: { tab: { base: { fontSize: 'var(--text-lg)', '--tab-py': 'var(--space-lg)', '--tab-px': 'var(--space-2xl)', '--tab-radius-min': 'calc(2rem - var(--border))' } } },
246
280
  },
247
281
  // Every role, not just primary. `data-color` passes through whatever a
248
282
  // consumer sets, so a one-role axis made `<Tabs.Root color="success">`
249
283
  // type-check, emit the attribute, and match nothing — the tab just
250
284
  // stayed primary with no diagnostic anywhere.
285
+ //
286
+ // The role lands as the active tab's INK — `border`'s underline and
287
+ // everything drawn `currentColor` follow it for free. `box` refines
288
+ // this per role through `compoundVariants` below (fill + `-content`).
251
289
  color: Object.fromEntries(
252
290
  ROLES.map((c) => [
253
291
  c,
254
292
  {
255
293
  tab: {
256
294
  states: {
257
- active: {
258
- background: `var(--color-${c})`,
259
- color: `var(--color-${c}-content)`,
260
- },
295
+ active: { color: roleInk(c) },
261
296
  },
262
297
  },
263
298
  },
264
299
  ]),
265
300
  ),
301
+ variant: {
302
+ // daisy `.tabs-border`: an always-present ::before bar under each
303
+ // tab — transparent at rest (daisy reaches transparent through an
304
+ // invalid-at-computed-value-time 4-value custom property; the
305
+ // rendered result is the same), currentColor + a 3px top border
306
+ // when active. The border is daisy's forced-colors survival for a
307
+ // background-painted mark, kept verbatim so the bar's geometry
308
+ // matches daisy's exactly.
309
+ border: {
310
+ tab: {
311
+ base: { borderRadius: 'var(--radius-field)' },
312
+ selectors: {
313
+ '&::before': {
314
+ content: '""',
315
+ position: 'absolute',
316
+ bottom: '0',
317
+ insetInlineStart: '10%',
318
+ width: '80%',
319
+ // daisy renders under Tailwind preflight, whose
320
+ // `*,::before,::after { box-sizing: border-box }`
321
+ // makes the active `border-top: 3px` swallow the
322
+ // 3px height rather than stack on it. zero ships
323
+ // no such reset, so the bar carries the one
324
+ // declaration itself — without it the active
325
+ // underline doubles to 6px (review catch).
326
+ boxSizing: 'border-box',
327
+ height: '3px',
328
+ borderRadius: 'var(--radius-field)',
329
+ backgroundColor: 'transparent',
330
+ transition: 'background-color var(--duration-normal) var(--ease-standard)',
331
+ },
332
+ '&[data-state="active"]:not([data-disabled])::before': {
333
+ backgroundColor: 'currentColor',
334
+ borderTop: '3px solid',
335
+ },
336
+ },
337
+ },
338
+ },
339
+ // daisy `.tabs-lift`: every tab sits on a base-300 baseline; the
340
+ // active one swaps that bottom hairline for top/side borders, a
341
+ // base-100 fill, rounded top corners, and the radial-gradient
342
+ // "shoulders" that curve its bottom corners outward into the
343
+ // baseline. Widths and paddings trade `var(--border)` exactly as
344
+ // daisy's `--tab-border`/`--tab-paddings` do, so activating a tab
345
+ // never changes its box size.
346
+ lift: {
347
+ tab: {
348
+ base: {
349
+ '--tab-radius-limit': 'min(var(--radius-field), var(--tab-radius-min))',
350
+ borderStartStartRadius: 'var(--tab-radius-limit)',
351
+ borderStartEndRadius: 'var(--tab-radius-limit)',
352
+ borderWidth: '0 0 var(--border) 0',
353
+ borderStyle: 'solid',
354
+ borderColor: 'var(--color-base-300)',
355
+ padding: 'calc(var(--tab-py) + var(--border)) var(--tab-px) var(--tab-py)',
356
+ },
357
+ states: {
358
+ active: {
359
+ background: 'var(--color-base-100)',
360
+ borderWidth: 'var(--border) var(--border) 0 var(--border)',
361
+ padding: 'var(--tab-py) calc(var(--tab-px) - var(--border)) calc(var(--tab-py) + var(--border))',
362
+ },
363
+ },
364
+ selectors: {
365
+ '&[data-state="active"]::before': {
366
+ // daisy's `--tab-radius-grad` stop list:
367
+ // transparent inside the corner arc, a base-300
368
+ // ring of the border's width, base-100 outside.
369
+ // Declared here rather than on the tab: every
370
+ // shoulder rule below styles this same
371
+ // pseudo-element, so they all read it — and on
372
+ // lynx it drops with the ::before selector
373
+ // instead of surviving as a dead declaration.
374
+ '--tab-grad': '#0000 calc(69% - var(--border)), var(--color-base-300) calc(69% - var(--border) + 0.25px), var(--color-base-300) 69%, var(--color-base-100) calc(69% + 0.25px)',
375
+ content: '""',
376
+ position: 'absolute',
377
+ zIndex: '1',
378
+ bottom: '0',
379
+ // daisy centres the shoulders through the flex
380
+ // static position; an explicit symmetric overhang
381
+ // of one corner radius per side is the same
382
+ // geometry without needing the tab to be a flex
383
+ // container.
384
+ insetInlineStart: 'calc(var(--tab-radius-limit) * -1)',
385
+ width: 'calc(100% + var(--tab-radius-limit) * 2)',
386
+ height: 'var(--tab-radius-limit)',
387
+ backgroundImage: 'radial-gradient(circle at top left, var(--tab-grad)), radial-gradient(circle at top right, var(--tab-grad))',
388
+ backgroundPosition: 'top left, top right',
389
+ backgroundSize: 'var(--tab-radius-limit) var(--tab-radius-limit)',
390
+ backgroundRepeat: 'no-repeat',
391
+ },
392
+ // No shoulder past the row's edges — daisy's
393
+ // `--radius-start: none` / `--radius-end: none` on
394
+ // first/last, both suppressed for an only tab.
395
+ '&[data-state="active"]:first-child::before': {
396
+ backgroundImage: 'none, radial-gradient(circle at top right, var(--tab-grad))',
397
+ },
398
+ '&[data-state="active"]:last-child::before': {
399
+ backgroundImage: 'radial-gradient(circle at top left, var(--tab-grad)), none',
400
+ },
401
+ '&[data-state="active"]:only-child::before': {
402
+ backgroundImage: 'none',
403
+ },
404
+ // The gradients' corners are physical; when one side
405
+ // is suppressed the image is asymmetric, so RTL needs
406
+ // daisy's mirror on exactly the first/last tabs.
407
+ [`&[data-state="active"]:first-child${rtl}::before`]: {
408
+ transform: 'rotateY(180deg)',
409
+ },
410
+ [`&[data-state="active"]:last-child${rtl}::before`]: {
411
+ transform: 'rotateY(180deg)',
412
+ },
413
+ },
414
+ },
415
+ },
416
+ // daisy `.tabs-box`: the styles this recipe shipped as its only
417
+ // look, moved verbatim out of `parts` — list on a padded base-200
418
+ // surface, active tab a raised base-100 pill.
419
+ box: {
420
+ list: {
421
+ base: {
422
+ padding: 'var(--space-xs)',
423
+ gap: 'var(--space-xs)',
424
+ background: 'var(--color-base-200)',
425
+ borderRadius: 'var(--radius-field)',
426
+ },
427
+ },
428
+ tab: {
429
+ base: { borderRadius: 'calc(var(--radius-field) - 0.25rem)' },
430
+ states: {
431
+ active: {
432
+ background: 'var(--color-base-100)',
433
+ boxShadow: 'var(--shadow-sm)',
434
+ },
435
+ },
436
+ },
437
+ },
438
+ },
439
+ },
440
+ // box × color: daisy's box flavor fills the active pill with the role and
441
+ // flips the label to `-content` — the fill+pair form is contrast-safe by
442
+ // construction, so the raw role is right here (unlike the ink the single-
443
+ // axis rule paints). Emitted after the single-axis rules, so it wins the
444
+ // `color` tie on source order.
445
+ compoundVariants: ROLES.map((c) => ({
446
+ match: { variant: 'box', color: c },
447
+ parts: {
448
+ tab: {
449
+ states: {
450
+ active: {
451
+ background: `var(--color-${c})`,
452
+ color: `var(--color-${c}-content)`,
453
+ },
454
+ },
455
+ },
456
+ },
457
+ })),
458
+ // `md: {}` emits nothing — it states the size default for the manifest
459
+ // (signalxjs/lynx#1070). `border` is daisy's own default tab look, and a
460
+ // DELIBERATE web visual change (#377): the un-attributed render used to
461
+ // be the box pill. Deliberately no `color` default: the un-attributed
462
+ // active ink is base-content, outside the color vocabulary.
463
+ defaultVariants: { size: 'md', variant: 'border' },
464
+ targets: {
465
+ lynx: {
466
+ parts: {
467
+ // The default active ink — base-content, exactly what the
468
+ // shared `states.active.color` paints. One class, so any
469
+ // color-axis definition below (host + two classes, baked per
470
+ // theme) wins on specificity.
471
+ tab: { base: { '--tab-active-ink': 'var(--color-base-content)' } },
472
+ },
473
+ variants: {
474
+ // `currentColor` never resolves on lynx (measured on device
475
+ // on both platforms, signalxjs/lynx#1079 — the underline was
476
+ // transparent everywhere), so the color axis restates its
477
+ // active ink as a plain custom property. `roleInk` reads
478
+ // theme colors through color-mix(), which the emitter bakes
479
+ // once per theme — the same per-color/per-theme literal the
480
+ // active `color:` rules already land as.
481
+ color: Object.fromEntries(
482
+ ROLES.map((c) => [c, { tab: { base: { '--tab-active-ink': roleInk(c) } } }]),
483
+ ),
484
+ variant: {
485
+ // The underline is a ::before on the web, which lynx
486
+ // drops — redrawn as a real bottom border on the tab
487
+ // itself (transparent at rest so activation never moves
488
+ // the row). The active ink rides `--tab-active-ink`
489
+ // above — a plain var() chain, device-proven — instead
490
+ // of `currentColor`, which lynx never resolves.
491
+ border: {
492
+ tab: {
493
+ base: { borderBottom: '3px solid transparent' },
494
+ states: { active: { borderBottomColor: 'var(--tab-active-ink)' } },
495
+ },
496
+ },
497
+ // `min()` does not exist on lynx (signalxjs/lynx#1066) and
498
+ // the shared `--tab-radius-limit` would drop, leaving its
499
+ // readers dangling — the uncapped field radius is the
500
+ // closest expressible value.
501
+ lift: {
502
+ tab: {
503
+ base: { '--tab-radius-limit': 'var(--radius-field)' },
504
+ },
505
+ },
506
+ },
507
+ },
508
+ },
266
509
  },
267
- // Size only: `md: {}` emits nothing, so this changes no CSS — it states
268
- // the default for the manifest (signalxjs/lynx#1070). Deliberately no
269
- // `color` default: the un-attributed active tab is a neutral base-100
270
- // pill, which is outside the color vocabulary.
271
- defaultVariants: { size: 'md' },
272
510
  };
273
511
 
274
512
  /**
@@ -407,46 +645,21 @@ export const switchRecipe: RecipeInput = {
407
645
  },
408
646
  },
409
647
  control: {
410
- base: {
411
- // The knob slides because the grid's leading column GROWS:
412
- // `0fr 1fr 1fr` → `1fr 1fr 0fr`, with the knob parked in
413
- // column 2. That is daisy's own mechanism, and unlike a
414
- // `translateX` it is RTL-correct for free.
415
- display: 'inline-grid',
416
- gridTemplateColumns: '0fr 1fr 1fr',
417
- placeContent: 'center',
418
- position: 'relative',
419
- verticalAlign: 'middle',
420
- flexShrink: '0',
421
- boxSizing: 'border-box',
422
- width: 'calc((var(--switch-size) * 2) - (var(--border) + var(--switch-p)) * 2)',
423
- height: 'var(--switch-size)',
424
- padding: 'var(--switch-p)',
425
- // daisy's radius formula: the selector radius GROWN by the
426
- // padding and the border so the knob's own corner and the
427
- // track's stay concentric, each clamped at 3× the token so a
428
- // square-cornered theme (cyberpunk, wireframe) stays square.
429
- '--switch-radius-max': 'calc(var(--radius-selector) + var(--radius-selector) + var(--radius-selector))',
430
- borderRadius: 'calc(var(--radius-selector) + min(var(--switch-p), var(--switch-radius-max)) + min(var(--border), var(--switch-radius-max)))',
431
- border: 'var(--border) solid currentColor',
432
- color: 'var(--switch-ink)',
433
- boxShadow: '0 1px color-mix(in oklab, currentColor calc(var(--depth) * 10%), #0000) inset',
434
- userSelect: 'none',
435
- transition: 'color var(--duration-slow) var(--ease-standard), '
436
- + 'background-color var(--duration-normal) var(--ease-standard), '
437
- + 'grid-template-columns var(--duration-normal) var(--ease-standard)',
438
- },
648
+ // The track's whole box lives in `targets`: the web mechanism is
649
+ // an inline-grid whose leading column grows, spelled with grid
650
+ // and `min()` — both measured unresolvable by lynx's engine
651
+ // (signalxjs/lynx#1066), so the lynx section lays the same
652
+ // geometry out with flex + calc() instead. Whole blocks move
653
+ // (not single declarations) so the merged web view keeps its
654
+ // declaration order and the web goldens stay byte-identical.
439
655
  states: {
440
656
  // daisy's checked toggle empties its track to base-100 and paints
441
657
  // the KNOB with the accent — so the checked knob's contrast is
442
658
  // accent-on-base-100, not accent-content-on-accent. Same
443
659
  // trade-off as the checkbox's unchecked outline: 1.11:1 at worst
444
660
  // (sunset/`neutral`), 7.68 at best, daisy's own numbers.
445
- checked: {
446
- gridTemplateColumns: '1fr 1fr 0fr',
447
- color: 'var(--switch-accent)',
448
- backgroundColor: 'var(--color-base-100)',
449
- },
661
+ // Both targets restate the paint in their own spelling below.
662
+ checked: {},
450
663
  unchecked: {},
451
664
  // `invalid` is semantic: it stays error under every colour
452
665
  // variant. Stated as a longhand because the track's border is
@@ -458,24 +671,9 @@ export const switchRecipe: RecipeInput = {
458
671
  },
459
672
  },
460
673
  thumb: {
461
- base: {
462
- // daisy's `.toggle:before`: a square knob that fills its grid
463
- // cell and takes the UNGROWN selector radius, so it is a
464
- // rounded square in daisy's rounded-square themes and a circle
465
- // only where the theme says so.
466
- gridRowStart: '1',
467
- gridColumnStart: '2',
468
- aspectRatio: '1',
469
- width: '100%',
470
- height: '100%',
471
- borderRadius: 'var(--radius-selector)',
472
- backgroundColor: 'currentColor',
473
- backgroundSize: 'auto, calc(var(--noise) * 100%)',
474
- backgroundImage: 'none, var(--fx-noise)',
475
- boxShadow: '0 -1px var(--depth-shade) inset, 0 8px 0 -4px var(--depth-sheen) inset, '
476
- + '0 1px color-mix(in oklab, currentColor calc(var(--depth) * 10%), #0000)',
477
- transition: 'background-color var(--duration-instant) var(--ease-standard)',
478
- },
674
+ // Like `control`, the knob's box is per-target: the web knob fills
675
+ // its grid cell, the lynx knob is an explicit calc() square that
676
+ // `translateX`es — see `targets` below.
479
677
  states: {
480
678
  checked: {},
481
679
  unchecked: {},
@@ -507,6 +705,146 @@ export const switchRecipe: RecipeInput = {
507
705
  // control and its text. Declared rather than left implicit so the
508
706
  // delegation reads as a decision.
509
707
  skipStates: { root: ['focus-visible'] },
708
+ // ONE design decision, two spellings. The web track is daisy's own
709
+ // mechanism — an inline-grid whose leading column grows (`0fr 1fr 1fr` →
710
+ // `1fr 1fr 0fr`) with a min()-clamped radius — and lynx's engine resolves
711
+ // neither grid nor the comparison functions (measured on device,
712
+ // signalxjs/lynx#1066; the emitter refuses min()/max()/clamp() on that
713
+ // evidence). So the whole box moves here per target: the web section is
714
+ // the original spelling verbatim, the lynx section rebuilds the same
715
+ // geometry with flex + calc() and slides the knob with `translateX`.
716
+ targets: {
717
+ web: {
718
+ parts: {
719
+ control: {
720
+ base: {
721
+ // The knob slides because the grid's leading column GROWS:
722
+ // `0fr 1fr 1fr` → `1fr 1fr 0fr`, with the knob parked in
723
+ // column 2. That is daisy's own mechanism, and unlike a
724
+ // `translateX` it is RTL-correct for free.
725
+ display: 'inline-grid',
726
+ gridTemplateColumns: '0fr 1fr 1fr',
727
+ placeContent: 'center',
728
+ position: 'relative',
729
+ verticalAlign: 'middle',
730
+ flexShrink: '0',
731
+ boxSizing: 'border-box',
732
+ width: 'calc((var(--switch-size) * 2) - (var(--border) + var(--switch-p)) * 2)',
733
+ height: 'var(--switch-size)',
734
+ padding: 'var(--switch-p)',
735
+ // daisy's radius formula: the selector radius GROWN by the
736
+ // padding and the border so the knob's own corner and the
737
+ // track's stay concentric, each clamped at 3× the token so a
738
+ // square-cornered theme (cyberpunk, wireframe) stays square.
739
+ '--switch-radius-max': 'calc(var(--radius-selector) + var(--radius-selector) + var(--radius-selector))',
740
+ borderRadius: 'calc(var(--radius-selector) + min(var(--switch-p), var(--switch-radius-max)) + min(var(--border), var(--switch-radius-max)))',
741
+ border: 'var(--border) solid currentColor',
742
+ color: 'var(--switch-ink)',
743
+ boxShadow: '0 1px color-mix(in oklab, currentColor calc(var(--depth) * 10%), #0000) inset',
744
+ userSelect: 'none',
745
+ transition: 'color var(--duration-slow) var(--ease-standard), '
746
+ + 'background-color var(--duration-normal) var(--ease-standard), '
747
+ + 'grid-template-columns var(--duration-normal) var(--ease-standard)',
748
+ },
749
+ states: {
750
+ checked: {
751
+ gridTemplateColumns: '1fr 1fr 0fr',
752
+ color: 'var(--switch-accent)',
753
+ backgroundColor: 'var(--color-base-100)',
754
+ },
755
+ },
756
+ },
757
+ thumb: {
758
+ base: {
759
+ // daisy's `.toggle:before`: a square knob that fills its grid
760
+ // cell and takes the UNGROWN selector radius, so it is a
761
+ // rounded square in daisy's rounded-square themes and a circle
762
+ // only where the theme says so.
763
+ gridRowStart: '1',
764
+ gridColumnStart: '2',
765
+ aspectRatio: '1',
766
+ width: '100%',
767
+ height: '100%',
768
+ borderRadius: 'var(--radius-selector)',
769
+ backgroundColor: 'currentColor',
770
+ backgroundSize: 'auto, calc(var(--noise) * 100%)',
771
+ backgroundImage: 'none, var(--fx-noise)',
772
+ boxShadow: '0 -1px var(--depth-shade) inset, 0 8px 0 -4px var(--depth-sheen) inset, '
773
+ + '0 1px color-mix(in oklab, currentColor calc(var(--depth) * 10%), #0000)',
774
+ transition: 'background-color var(--duration-instant) var(--ease-standard)',
775
+ },
776
+ },
777
+ },
778
+ },
779
+ lynx: {
780
+ parts: {
781
+ root: {
782
+ // `inline-flex` has no lynx projection; restated as plain
783
+ // flex with the direction explicit (lynx does not default
784
+ // it the way a browser does).
785
+ base: { display: 'flex', flexDirection: 'row', alignItems: 'center' },
786
+ },
787
+ control: {
788
+ base: {
789
+ display: 'flex',
790
+ alignItems: 'center',
791
+ flexShrink: '0',
792
+ boxSizing: 'border-box',
793
+ // Same geometry as the web track: daisy's derived
794
+ // width, ONE `--switch-size` driving everything.
795
+ width: 'calc((var(--switch-size) * 2) - (var(--border) + var(--switch-p)) * 2)',
796
+ height: 'var(--switch-size)',
797
+ padding: 'var(--switch-p)',
798
+ // The radius formula minus its min() clamps: exact for
799
+ // every shipped theme (the clamp only bites when the
800
+ // padding or border exceeds 3× the selector radius,
801
+ // i.e. near-square themes this package does not ship).
802
+ borderRadius: 'calc(var(--radius-selector) + var(--switch-p) + var(--border))',
803
+ // No currentColor on this target — the ink is named
804
+ // directly, and `checked` moves border + fill below.
805
+ border: 'var(--border) solid var(--switch-ink)',
806
+ userSelect: 'none',
807
+ transition: 'border-color var(--duration-slow) var(--ease-standard), '
808
+ + 'background-color var(--duration-normal) var(--ease-standard)',
809
+ },
810
+ states: {
811
+ checked: {
812
+ borderColor: 'var(--switch-accent)',
813
+ backgroundColor: 'var(--color-base-100)',
814
+ },
815
+ },
816
+ },
817
+ thumb: {
818
+ base: {
819
+ // The web knob fills its square grid cell; here the
820
+ // same square is explicit: track height minus border
821
+ // and padding on both sides.
822
+ width: 'calc(var(--switch-size) - (var(--border) + var(--switch-p)) * 2)',
823
+ height: 'calc(var(--switch-size) - (var(--border) + var(--switch-p)) * 2)',
824
+ borderRadius: 'var(--radius-selector)',
825
+ backgroundColor: 'var(--switch-ink)',
826
+ transition: 'transform var(--duration-normal) var(--ease-standard), '
827
+ + 'background-color var(--duration-instant) var(--ease-standard)',
828
+ },
829
+ states: {
830
+ // The knob's travel is exactly one knob-width: the
831
+ // content box is two knobs wide, and the knob starts
832
+ // at the near end. `translateX` is measured working
833
+ // on lynx. The axis is PHYSICAL where the web's
834
+ // growing grid column was logical — an RTL layout
835
+ // would need a direction-aware multiplier, but the
836
+ // lynx grammar carries no direction hook yet and the
837
+ // runtime does not stamp one; revisit with the RTL
838
+ // plumbing rather than guessing at it here.
839
+ checked: {
840
+ backgroundColor: 'var(--switch-accent)',
841
+ transform: 'translateX(calc(var(--switch-size) - (var(--border) + var(--switch-p)) * 2))',
842
+ },
843
+ },
844
+ },
845
+ },
846
+ },
847
+ },
510
848
  };
511
849
 
512
850
  // --------------------------------------------------------------------------
@@ -1154,6 +1492,29 @@ export const checkbox: RecipeInput = {
1154
1492
  // control and its text. Declared rather than left implicit so the
1155
1493
  // delegation reads as a decision.
1156
1494
  skipStates: { root: ['focus-visible'] },
1495
+ // `currentColor` never resolves on lynx (measured on device on both
1496
+ // platforms, signalxjs/lynx#1079), so the tick shipped invisible and the
1497
+ // ring's color-mix-over-currentColor fallback dropped the whole border.
1498
+ // Both restated with the named accents the web spellings resolve to —
1499
+ // plain var() chains, the switch's device-proven #380 mechanism.
1500
+ targets: {
1501
+ lynx: {
1502
+ parts: {
1503
+ control: {
1504
+ // The web fallback (`color-mix(in oklab, … currentColor …)`)
1505
+ // exists for consumers that unset the accent; this package
1506
+ // always defines it, so the plain accent IS the ring.
1507
+ base: { border: 'var(--border) solid var(--checkbox-accent)' },
1508
+ },
1509
+ indicator: {
1510
+ // The tick's fill: the control's `color:` names
1511
+ // `--checkbox-on-accent`, and `currentColor` was that
1512
+ // ink by inheritance — spend it directly.
1513
+ base: { backgroundColor: 'var(--checkbox-on-accent)' },
1514
+ },
1515
+ },
1516
+ },
1517
+ },
1157
1518
  };
1158
1519
 
1159
1520
  // --------------------------------------------------------------------------
@@ -1271,6 +1632,25 @@ export const radioGroup: RecipeInput = {
1271
1632
  // wraps it. Declared rather than left implicit so the delegation reads
1272
1633
  // as a decision.
1273
1634
  skipStates: { item: ['focus-visible'] },
1635
+ // `currentColor` never resolves on lynx (measured on device on both
1636
+ // platforms, signalxjs/lynx#1079), so the checked ring and dot shipped
1637
+ // invisible, and the unchecked ring's color-mix-over-currentColor
1638
+ // fallback dropped the whole border. All three restated with the accent
1639
+ // the control's own `color:` names — plain var() chains, the switch's
1640
+ // device-proven #380 mechanism.
1641
+ targets: {
1642
+ lynx: {
1643
+ parts: {
1644
+ 'item-control': {
1645
+ base: { border: 'var(--border) solid var(--radio-accent)' },
1646
+ states: { checked: { borderColor: 'var(--radio-accent)' } },
1647
+ },
1648
+ 'item-indicator': {
1649
+ states: { checked: { backgroundColor: 'var(--radio-accent)' } },
1650
+ },
1651
+ },
1652
+ },
1653
+ },
1274
1654
  };
1275
1655
 
1276
1656
  // --------------------------------------------------------------------------
@@ -1494,6 +1874,50 @@ export const slider: RecipeInput = {
1494
1874
  // declared for the manifest (signalxjs/lynx#1070).
1495
1875
  defaultVariants: { color: 'primary', size: 'md' },
1496
1876
  skipStates: { root: ['invalid', 'focus-visible'] },
1877
+ // ONE design decision, two paints. On the web the accent reaches the
1878
+ // screen twice: the native control is UA-drawn behind `accent-color`, and
1879
+ // the composed range/thumb deepen the accent with `color-mix()`. Lynx
1880
+ // renders neither spelling — `accent-color` styles a native
1881
+ // `<input type=range>` lynx does not have, and the emitter drops
1882
+ // `color-mix()` as unresolvable — so the compiled range and thumb shipped
1883
+ // with no paint at all: grey track, invisible fill, invisible knob
1884
+ // (measured on device, signalxjs/lynx#1075). The geometry all survives
1885
+ // (the sizes are direct calc(), proven working); paint is the only gap,
1886
+ // so this section only restates paint, in lynx-expressible spellings.
1887
+ targets: {
1888
+ lynx: {
1889
+ parts: {
1890
+ range: {
1891
+ // The web fill deepens the accent 90/10 toward
1892
+ // base-content (`progressFill`'s recipe); the plain
1893
+ // accent — a var() chain, proven working on device — is
1894
+ // the closest lynx-expressible equivalent.
1895
+ base: { background: 'var(--slider-accent)' },
1896
+ },
1897
+ thumb: {
1898
+ // daisy's real range thumb, ported from daisyUI 5
1899
+ // `range.css` `::-webkit-slider-thumb`. The source reads
1900
+ // `background-color: var(--range-thumb)` +
1901
+ // `border: var(--range-p) solid` in the range's
1902
+ // currentColor; here each resolves to its value —
1903
+ // `--range-thumb` defaults to `var(--color-base-100)`,
1904
+ // `--range-p` is daisy's literal `.25rem` (deliberately
1905
+ // not the theme's size token), and the border ink is the
1906
+ // accent (currentColor on a daisy `.range-*` IS the
1907
+ // accent role) — because on lynx a named value is a
1908
+ // var-chain fewer to resolve. daisy paints the progress
1909
+ // fill itself as a 100cqw box-shadow thrown off this
1910
+ // thumb — a native-input trick with no lynx equivalent;
1911
+ // the composed `.zx-slider__range` box above IS that
1912
+ // fill here, so the knob only needs its own two paints.
1913
+ base: {
1914
+ background: 'var(--color-base-100)',
1915
+ border: '0.25rem solid var(--slider-accent)',
1916
+ },
1917
+ },
1918
+ },
1919
+ },
1920
+ },
1497
1921
  };
1498
1922
 
1499
1923
  export const accordion: RecipeInput = {
@@ -1712,6 +2136,23 @@ export const select: RecipeInput = {
1712
2136
  };
1713
2137
 
1714
2138
 
2139
+ /**
2140
+ * daisy's BUTTON ramp — `.btn`'s fixed height per size step, in `--size-field`
2141
+ * units, exactly as daisyUI 5 ships it (`components/button.css`: `.btn-xs`
2142
+ * `--size: calc(var(--size-field, .25rem) * 6)` through `.btn-xl` `* 14`,
2143
+ * `.btn`/`.btn-md` `* 10` — 1.5 / 2 / 2.5 / 3 / 3.5rem at the default
2144
+ * `--size-field: 0.25rem`).
2145
+ *
2146
+ * Its own table, not `FIELD_STEPS`: this repo's field ramp sits one step
2147
+ * higher than daisy's (md = 12 units, 3rem), and restating the button off it
2148
+ * would bake that offset into a second component. The button states daisy's
2149
+ * own numbers; #376 is exactly what happened while it had no height at all —
2150
+ * padding alone rendered md at ~30px against daisy's 40px.
2151
+ */
2152
+ const BTN_STEPS = { xs: 6, sm: 8, md: 10, lg: 12, xl: 14 } as const;
2153
+ const btnHeight = (step: keyof typeof BTN_STEPS): string =>
2154
+ `calc(var(--size-field) * ${BTN_STEPS[step]})`;
2155
+
1715
2156
  export const button: RecipeInput = {
1716
2157
  component: 'button',
1717
2158
  // The two axes meet here instead of multiplying. `color` sets the accent
@@ -1849,12 +2290,17 @@ export const button: RecipeInput = {
1849
2290
  },
1850
2291
  },
1851
2292
  },
2293
+ // daisy sizes a btn by fixed height (`--size`), not by padding: each
2294
+ // step states its `BTN_STEPS` height and keeps the padding/font ramps
2295
+ // for inline air; the root's flex centering absorbs the block axis.
2296
+ // The `<button>` element is UA `border-box`, so the stated height is
2297
+ // the rendered height, border included — as in daisy.
1852
2298
  size: {
1853
- xs: { root: { base: { padding: 'var(--space-2xs) var(--space-xs)', fontSize: 'var(--text-xs)' } } },
1854
- sm: { root: { base: { padding: 'var(--space-xs) var(--space-sm)', fontSize: 'var(--text-sm)' } } },
1855
- md: { root: { base: { padding: 'var(--space-sm) var(--space-lg)', fontSize: 'var(--text-md)' } } },
1856
- lg: { root: { base: { padding: 'var(--space-md) var(--space-xl)', fontSize: 'var(--text-lg)' } } },
1857
- xl: { root: { base: { padding: 'var(--space-lg) var(--space-2xl)', fontSize: 'var(--text-xl)' } } },
2299
+ xs: { root: { base: { height: btnHeight('xs'), padding: 'var(--space-2xs) var(--space-xs)', fontSize: 'var(--text-xs)' } } },
2300
+ sm: { root: { base: { height: btnHeight('sm'), padding: 'var(--space-xs) var(--space-sm)', fontSize: 'var(--text-sm)' } } },
2301
+ md: { root: { base: { height: btnHeight('md'), padding: 'var(--space-sm) var(--space-lg)', fontSize: 'var(--text-md)' } } },
2302
+ lg: { root: { base: { height: btnHeight('lg'), padding: 'var(--space-md) var(--space-xl)', fontSize: 'var(--text-lg)' } } },
2303
+ xl: { root: { base: { height: btnHeight('xl'), padding: 'var(--space-lg) var(--space-2xl)', fontSize: 'var(--text-xl)' } } },
1858
2304
  },
1859
2305
  },
1860
2306
  /**