@dorsk/tsumikit 0.73.2 → 0.75.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.
Files changed (137) hide show
  1. package/README.md +38 -1
  2. package/dist/autoresize.d.ts +2 -0
  3. package/dist/autoresize.js +8 -1
  4. package/dist/components/atoms/Artwork.svelte +12 -5
  5. package/dist/components/atoms/Avatar.svelte +1 -1
  6. package/dist/components/atoms/Badge.svelte +59 -23
  7. package/dist/components/atoms/Button.svelte +130 -96
  8. package/dist/components/atoms/Button.svelte.d.ts +2 -0
  9. package/dist/components/atoms/Card.svelte +10 -4
  10. package/dist/components/atoms/Checkbox.svelte +36 -9
  11. package/dist/components/atoms/Checkbox.svelte.d.ts +2 -0
  12. package/dist/components/atoms/Gauge.svelte +20 -14
  13. package/dist/components/atoms/Heading.svelte +1 -1
  14. package/dist/components/atoms/Icon.svelte +1 -0
  15. package/dist/components/atoms/Input.svelte +67 -13
  16. package/dist/components/atoms/Kbd.svelte +3 -3
  17. package/dist/components/atoms/Link.svelte +7 -2
  18. package/dist/components/atoms/Progress.svelte +12 -6
  19. package/dist/components/atoms/Scrim.svelte +3 -8
  20. package/dist/components/atoms/SegmentedProgress.svelte +5 -4
  21. package/dist/components/atoms/Select.svelte +58 -27
  22. package/dist/components/atoms/Skeleton.svelte +14 -8
  23. package/dist/components/atoms/Slider.svelte +79 -22
  24. package/dist/components/atoms/Slider.svelte.d.ts +2 -0
  25. package/dist/components/atoms/Spinner.svelte +464 -5
  26. package/dist/components/atoms/Spinner.svelte.d.ts +2 -0
  27. package/dist/components/atoms/Swatch.svelte +19 -8
  28. package/dist/components/atoms/Switch.svelte +34 -21
  29. package/dist/components/atoms/Switch.svelte.d.ts +2 -0
  30. package/dist/components/atoms/Text.svelte +1 -1
  31. package/dist/components/atoms/Textarea.svelte +34 -12
  32. package/dist/components/atoms/TimeDisplay.svelte +4 -2
  33. package/dist/components/layouts/AppShell.svelte +26 -15
  34. package/dist/components/layouts/MasterDetail.svelte +23 -21
  35. package/dist/components/layouts/NavBar.svelte +11 -8
  36. package/dist/components/layouts/NavItem.svelte +15 -7
  37. package/dist/components/layouts/NavSection.svelte +1 -1
  38. package/dist/components/layouts/ResizablePanel.svelte +37 -16
  39. package/dist/components/layouts/TabBar.svelte +24 -14
  40. package/dist/components/layouts/TabBar.svelte.d.ts +2 -0
  41. package/dist/components/molecules/Accordion.svelte +4 -0
  42. package/dist/components/molecules/Accordion.svelte.d.ts +2 -0
  43. package/dist/components/molecules/AttachmentList.svelte +14 -11
  44. package/dist/components/molecules/Breadcrumb.svelte +13 -9
  45. package/dist/components/molecules/Callout.svelte +3 -3
  46. package/dist/components/molecules/CapBar.svelte +23 -22
  47. package/dist/components/molecules/Carousel.svelte +27 -17
  48. package/dist/components/molecules/ChatBubble.svelte +2 -2
  49. package/dist/components/molecules/CodeBlock.svelte +2 -2
  50. package/dist/components/molecules/Combobox.svelte +30 -1
  51. package/dist/components/molecules/Combobox.svelte.d.ts +4 -0
  52. package/dist/components/molecules/Composer.svelte +2 -2
  53. package/dist/components/molecules/ConfirmModal.svelte +4 -0
  54. package/dist/components/molecules/ConfirmModal.svelte.d.ts +2 -0
  55. package/dist/components/molecules/CopyButton.svelte +46 -2
  56. package/dist/components/molecules/CopyButton.svelte.d.ts +2 -0
  57. package/dist/components/molecules/Disclosure.svelte +23 -12
  58. package/dist/components/molecules/Disclosure.svelte.d.ts +2 -0
  59. package/dist/components/molecules/Drawer.svelte +43 -23
  60. package/dist/components/molecules/Drawer.svelte.d.ts +2 -0
  61. package/dist/components/molecules/Dropzone.svelte +24 -8
  62. package/dist/components/molecules/EmojiPicker.svelte +27 -8
  63. package/dist/components/molecules/EmojiPicker.svelte.d.ts +2 -0
  64. package/dist/components/molecules/EmptyState.svelte +5 -5
  65. package/dist/components/molecules/Fieldset.svelte +8 -13
  66. package/dist/components/molecules/FileButton.svelte +57 -31
  67. package/dist/components/molecules/FileButton.svelte.d.ts +2 -0
  68. package/dist/components/molecules/FilterInput.svelte +51 -11
  69. package/dist/components/molecules/FontScalePicker.svelte +20 -9
  70. package/dist/components/molecules/GitRef.svelte +9 -7
  71. package/dist/components/molecules/GlyphStack.svelte +38 -14
  72. package/dist/components/molecules/IconButton.svelte +6 -3
  73. package/dist/components/molecules/IconButton.svelte.d.ts +2 -0
  74. package/dist/components/molecules/InputGroup.svelte +13 -12
  75. package/dist/components/molecules/LoadMore.svelte +6 -1
  76. package/dist/components/molecules/LoadMore.svelte.d.ts +2 -0
  77. package/dist/components/molecules/Menu.svelte +33 -7
  78. package/dist/components/molecules/Menu.svelte.d.ts +2 -0
  79. package/dist/components/molecules/Metric.svelte +11 -9
  80. package/dist/components/molecules/Modal.svelte +76 -26
  81. package/dist/components/molecules/Modal.svelte.d.ts +2 -0
  82. package/dist/components/molecules/OptionButton.svelte +28 -10
  83. package/dist/components/molecules/OptionButton.svelte.d.ts +2 -0
  84. package/dist/components/molecules/Pagination.svelte +26 -9
  85. package/dist/components/molecules/Pagination.svelte.d.ts +2 -0
  86. package/dist/components/molecules/Popover.svelte +107 -51
  87. package/dist/components/molecules/Popover.svelte.d.ts +2 -0
  88. package/dist/components/molecules/Prose.svelte +1 -1
  89. package/dist/components/molecules/PullToRefresh.svelte +14 -10
  90. package/dist/components/molecules/PullToRefresh.svelte.d.ts +2 -0
  91. package/dist/components/molecules/RadialMenu.svelte +52 -19
  92. package/dist/components/molecules/RadialMenu.svelte.d.ts +2 -0
  93. package/dist/components/molecules/RadioGroup.svelte +38 -23
  94. package/dist/components/molecules/RadioGroup.svelte.d.ts +2 -0
  95. package/dist/components/molecules/SectionHeader.svelte +13 -8
  96. package/dist/components/molecules/SeekSlider.svelte +38 -27
  97. package/dist/components/molecules/SeekSlider.svelte.d.ts +2 -0
  98. package/dist/components/molecules/SegmentedControl.svelte +139 -86
  99. package/dist/components/molecules/SegmentedControl.svelte.d.ts +2 -0
  100. package/dist/components/molecules/SelectButton.svelte +21 -6
  101. package/dist/components/molecules/SelectButton.svelte.d.ts +2 -0
  102. package/dist/components/molecules/SortableList.svelte +10 -11
  103. package/dist/components/molecules/SoundSettings.svelte +161 -0
  104. package/dist/components/molecules/SoundSettings.svelte.d.ts +17 -0
  105. package/dist/components/molecules/SplitButton.svelte +30 -3
  106. package/dist/components/molecules/SplitButton.svelte.d.ts +2 -0
  107. package/dist/components/molecules/SwipeRow.svelte +18 -15
  108. package/dist/components/molecules/SwipeRow.svelte.d.ts +2 -0
  109. package/dist/components/molecules/Tabs.svelte +92 -10
  110. package/dist/components/molecules/Tabs.svelte.d.ts +2 -0
  111. package/dist/components/molecules/ThemePicker.svelte +25 -10
  112. package/dist/components/molecules/Timestamp.svelte +12 -7
  113. package/dist/components/molecules/Toaster.svelte +65 -14
  114. package/dist/components/molecules/Toaster.svelte.d.ts +2 -0
  115. package/dist/components/molecules/Toggle.svelte +36 -13
  116. package/dist/components/molecules/Toggle.svelte.d.ts +2 -0
  117. package/dist/components/molecules/Tooltip.svelte +41 -10
  118. package/dist/components/molecules/Tooltip.svelte.d.ts +1 -1
  119. package/dist/components/molecules/VolumeSlider.svelte +34 -14
  120. package/dist/components/molecules/VolumeSlider.svelte.d.ts +2 -0
  121. package/dist/components/molecules/WorkingDir.svelte +1 -0
  122. package/dist/components/organisms/ConversationFeed.svelte +4 -0
  123. package/dist/components/organisms/DataTable.svelte +37 -14
  124. package/dist/floating.js +26 -0
  125. package/dist/index.d.ts +4 -0
  126. package/dist/index.js +4 -1
  127. package/dist/sound-synth.d.ts +3 -0
  128. package/dist/sound-synth.js +91 -0
  129. package/dist/sound.d.ts +1 -0
  130. package/dist/sound.js +3 -0
  131. package/dist/stores/sound.svelte.d.ts +20 -0
  132. package/dist/stores/sound.svelte.js +150 -0
  133. package/dist/stores/toast.svelte.d.ts +9 -0
  134. package/dist/stores/toast.svelte.js +45 -9
  135. package/dist/styles/reset.css +19 -2
  136. package/dist/styles/tokens.css +89 -6
  137. package/package.json +1 -1
@@ -13,6 +13,7 @@
13
13
  import type { HTMLAttributes } from 'svelte/elements';
14
14
  import { formatCount, hasCount } from '../../count';
15
15
  import { place } from '../../floating';
16
+ import { playSound } from '../../sound';
16
17
  import Toggle from './Toggle.svelte';
17
18
  import { HOVER_CLOSE_GRACE, HOVER_OPEN_DELAY, createHoverIntent, opensOnHover } from './popover-hover.js';
18
19
 
@@ -90,6 +91,8 @@
90
91
  /** Class / inline style on the floating panel. */
91
92
  panelClass?: string;
92
93
  panelStyle?: string;
94
+ /** Play UI sounds when enabled globally; false silences this instance. */
95
+ sound?: boolean;
93
96
  };
94
97
 
95
98
  let {
@@ -125,6 +128,7 @@
125
128
  style: styleProp = '',
126
129
  panelClass = '',
127
130
  panelStyle = '',
131
+ sound = true,
128
132
  ...rest
129
133
  }: Omit<HTMLAttributes<HTMLElement>, keyof Own> & Own = $props();
130
134
 
@@ -161,6 +165,10 @@
161
165
  let opened = $state(false);
162
166
 
163
167
  let shown = false;
168
+ // An `open` that is already true on mount is a sync, not a transition: no sound.
169
+ let quiet = open;
170
+ // Keyboard opens skip the enter animation (`data-instant`).
171
+ let instant = false;
164
172
 
165
173
  $effect(() => {
166
174
  if (!panelEl) return;
@@ -223,12 +231,21 @@
223
231
  }
224
232
 
225
233
  function onTriggerKeydown(e: KeyboardEvent) {
226
- if (as !== 'a' || href !== undefined || disabled) return;
227
- if (e.key === 'Enter' || e.key === ' ') {
234
+ if (disabled) return;
235
+ const key = e.key;
236
+ if (key === 'ArrowDown' && !open) {
228
237
  e.preventDefault();
229
238
  hover.cancel();
230
- toggle();
239
+ instant = true;
240
+ show();
241
+ return;
231
242
  }
243
+ if (key !== 'Enter' && key !== ' ') return;
244
+ instant = !open;
245
+ if (as !== 'a' || href !== undefined) return;
246
+ e.preventDefault();
247
+ hover.cancel();
248
+ toggle();
232
249
  }
233
250
 
234
251
  const triggerAttrs = $derived(
@@ -252,6 +269,13 @@
252
269
  const firstOpen = !opened;
253
270
  opened = true;
254
271
  open = true;
272
+ if (panelEl) {
273
+ if (instant) panelEl.dataset.instant = '';
274
+ else delete panelEl.dataset.instant;
275
+ }
276
+ instant = false;
277
+ if (!quiet) playSound('open', sound);
278
+ quiet = false;
255
279
  // On the first open the content snippet mounts this tick; measure the
256
280
  // panel only once it has, so placement accounts for its real size.
257
281
  if (firstOpen) await tick();
@@ -261,8 +285,10 @@
261
285
  onopen?.();
262
286
  } else {
263
287
  open = false;
288
+ instant = false;
264
289
  removeEventListener('scroll', reposition, true);
265
290
  removeEventListener('resize', reposition);
291
+ playSound('close', sound);
266
292
  onclose?.();
267
293
  }
268
294
  }
@@ -371,68 +397,96 @@
371
397
  background: var(--pop-trigger-bg, transparent);
372
398
  color: var(--pop-trigger-fg, var(--text));
373
399
  font-size: var(--pop-trigger-size, inherit);
400
+ --icon-size: var(--control-icon-md);
401
+ --pop-scale-dur: var(--dur-fast);
374
402
  transition:
375
- background 0.12s var(--ease),
376
- border-color 0.12s var(--ease);
403
+ background-color var(--dur-fast) var(--ease),
404
+ border-color var(--dur-fast) var(--ease),
405
+ color var(--dur-fast) var(--ease),
406
+ scale var(--pop-scale-dur) var(--ease-out);
407
+ }
408
+ .pop-trigger:not(.bare, .trigger-toggle):active:not(:disabled, .is-disabled) {
409
+ --pop-scale-dur: var(--dur-press);
410
+ scale: var(--press-scale-sm);
411
+ }
412
+ .pop-trigger.canonical:active:not(:disabled, .is-disabled) {
413
+ scale: var(--press-scale);
377
414
  }
378
- :where(.pop-trigger:not(.bare):hover:not(:disabled)) {
379
- background: var(--bg-elevated-2);
415
+ @media (hover: hover) and (pointer: fine) {
416
+ :where(.pop-trigger:not(.bare):hover:not(:disabled)) {
417
+ background: var(--bg-elevated-2);
418
+ }
419
+ .pop-trigger.canonical:hover:not(:disabled) {
420
+ border-color: var(--accent);
421
+ background: var(--surface);
422
+ }
423
+ .pop-trigger.trigger-primary:hover:not(:disabled) {
424
+ background: var(--accent);
425
+ border-color: var(--accent);
426
+ filter: brightness(1.08);
427
+ }
428
+ .pop-trigger.trigger-ghost:hover:not(:disabled) {
429
+ background: var(--bg-elevated-2);
430
+ border-color: transparent;
431
+ }
432
+ .pop-trigger.trigger-danger:hover:not(:disabled) {
433
+ background: color-mix(in srgb, var(--danger) 14%, transparent);
434
+ border-color: var(--danger);
435
+ }
436
+ .pop-trigger.trigger-tone-accent:hover:not(:disabled),
437
+ .pop-trigger.trigger-tone-success:hover:not(:disabled),
438
+ .pop-trigger.trigger-tone-info:hover:not(:disabled),
439
+ .pop-trigger.trigger-tone-warn:hover:not(:disabled),
440
+ .pop-trigger.trigger-tone-danger:hover:not(:disabled) {
441
+ background: color-mix(in srgb, var(--pop-trigger-tone) 14%, transparent);
442
+ border-color: var(--pop-trigger-tone);
443
+ }
444
+ .pop-trigger.trigger-primary.trigger-tone-success:hover:not(:disabled) {
445
+ background: var(--ok);
446
+ border-color: var(--ok);
447
+ filter: brightness(1.08);
448
+ }
380
449
  }
381
450
  /* Supplying canonical chrome props opts into the same dimensions, variants
382
451
  and state tones as Button while keeping this one native trigger element. */
383
452
  .pop-trigger.canonical {
384
453
  min-width: 0;
385
454
  min-height: var(--control-height-default);
386
- padding: var(--pop-trigger-pad, var(--sp-2) var(--sp-4));
455
+ padding: var(--pop-trigger-pad, var(--sp-2) var(--btn-px-md));
387
456
  border-color: var(--pop-trigger-border, var(--border-strong));
388
457
  background: var(--pop-trigger-bg, var(--surface));
389
458
  font-weight: var(--fw-medium);
390
- font-size: var(--pop-trigger-size, var(--fs-sm));
391
- line-height: 1;
459
+ font-size: var(--pop-trigger-size, var(--control-fs-md));
460
+ line-height: var(--lh-none);
392
461
  user-select: none;
393
462
  white-space: nowrap;
394
463
  }
395
- .pop-trigger.canonical:hover:not(:disabled) {
396
- border-color: var(--accent);
397
- background: var(--surface);
398
- }
399
464
  .pop-trigger.trigger-primary {
400
465
  background: var(--pop-trigger-bg, var(--accent));
401
466
  border-color: var(--pop-trigger-border, var(--accent));
402
467
  color: var(--pop-trigger-fg, var(--text-on-accent));
403
468
  font-weight: var(--fw-semibold);
404
469
  }
405
- .pop-trigger.trigger-primary:hover:not(:disabled) {
406
- background: var(--accent);
407
- border-color: var(--accent);
408
- filter: brightness(1.08);
409
- }
410
470
  .pop-trigger.trigger-ghost {
411
471
  background: var(--pop-trigger-bg, transparent);
412
472
  border-color: var(--pop-trigger-border, transparent);
413
473
  }
414
- .pop-trigger.trigger-ghost:hover:not(:disabled) {
415
- background: var(--bg-elevated-2);
416
- border-color: transparent;
417
- }
418
474
  .pop-trigger.trigger-danger {
419
475
  color: var(--pop-trigger-fg, var(--danger));
420
476
  border-color: var(--pop-trigger-border, color-mix(in srgb, var(--danger) 50%, var(--border)));
421
477
  }
422
- .pop-trigger.trigger-danger:hover:not(:disabled) {
423
- background: color-mix(in srgb, var(--danger) 14%, transparent);
424
- border-color: var(--danger);
425
- }
426
478
  .pop-trigger.trigger-sm {
427
479
  height: var(--control-height-compact);
428
480
  min-height: var(--control-height-compact);
429
- padding: var(--pop-trigger-pad, var(--sp-1) var(--sp-3));
430
- font-size: var(--pop-trigger-size, var(--fs-xs));
481
+ padding: var(--pop-trigger-pad, var(--sp-1) var(--btn-px-sm));
482
+ font-size: var(--pop-trigger-size, var(--control-fs-sm));
483
+ --icon-size: var(--control-icon-sm);
431
484
  }
432
485
  .pop-trigger.trigger-lg {
433
486
  min-height: var(--control-height-large);
434
- padding: var(--pop-trigger-pad, var(--sp-3) var(--sp-5));
435
- font-size: var(--pop-trigger-size, var(--fs-base));
487
+ padding: var(--pop-trigger-pad, var(--sp-3) var(--btn-px-lg));
488
+ font-size: var(--pop-trigger-size, var(--control-fs-lg));
489
+ --icon-size: var(--control-icon-lg);
436
490
  }
437
491
  .pop-trigger.trigger-control {
438
492
  height: var(--control-height);
@@ -491,27 +545,14 @@
491
545
  color: var(--pop-trigger-fg, var(--pop-trigger-tone));
492
546
  border-color: var(--pop-trigger-border, color-mix(in srgb, var(--pop-trigger-tone) 50%, var(--border)));
493
547
  }
494
- .pop-trigger.trigger-tone-accent:hover:not(:disabled),
495
- .pop-trigger.trigger-tone-success:hover:not(:disabled),
496
- .pop-trigger.trigger-tone-info:hover:not(:disabled),
497
- .pop-trigger.trigger-tone-warn:hover:not(:disabled),
498
- .pop-trigger.trigger-tone-danger:hover:not(:disabled) {
499
- background: color-mix(in srgb, var(--pop-trigger-tone) 14%, transparent);
500
- border-color: var(--pop-trigger-tone);
501
- }
502
548
  .pop-trigger.trigger-primary.trigger-tone-success {
503
549
  background: var(--pop-trigger-bg, var(--ok));
504
550
  border-color: var(--pop-trigger-border, var(--ok));
505
551
  color: var(--pop-trigger-fg, var(--text-on-success));
506
552
  }
507
- .pop-trigger.trigger-primary.trigger-tone-success:hover:not(:disabled) {
508
- background: var(--ok);
509
- border-color: var(--ok);
510
- filter: brightness(1.08);
511
- }
512
553
  .pop-trigger:disabled,
513
554
  .pop-trigger.is-disabled {
514
- opacity: 0.45;
555
+ opacity: var(--disabled-opacity);
515
556
  cursor: not-allowed;
516
557
  }
517
558
  .pop-trigger.is-disabled {
@@ -544,7 +585,7 @@
544
585
  font-size: var(--fs-xs);
545
586
  font-weight: var(--fw-semibold);
546
587
  font-variant-numeric: tabular-nums;
547
- line-height: 1;
588
+ line-height: var(--lh-none);
548
589
  white-space: nowrap;
549
590
  pointer-events: none;
550
591
  }
@@ -584,15 +625,30 @@
584
625
  border: 1px solid var(--border-strong);
585
626
  border-radius: var(--r-md);
586
627
  box-shadow: var(--shadow-md);
628
+ transform-origin: var(--tsu-origin, top left);
629
+ opacity: 0;
630
+ scale: var(--enter-scale);
631
+ transition:
632
+ opacity var(--dur-exit) var(--ease-out),
633
+ scale var(--dur-exit) var(--ease-out),
634
+ translate var(--dur-exit) var(--ease-out),
635
+ display var(--dur-exit) allow-discrete,
636
+ overlay var(--dur-exit) allow-discrete;
587
637
  }
588
- /* Enter animation (skipped under reduced-motion via the global rule). */
589
638
  .pop-panel:popover-open {
590
- animation: pop-in 0.12s var(--ease);
639
+ opacity: 1;
640
+ scale: 1;
641
+ translate: 0;
642
+ transition-duration: var(--dur-base);
591
643
  }
592
- @keyframes pop-in {
593
- from {
644
+ @starting-style {
645
+ .pop-panel:popover-open {
594
646
  opacity: 0;
595
- transform: translateY(-4px);
647
+ scale: var(--enter-scale);
648
+ translate: var(--tsu-shift, 0 calc(-1 * var(--shift-sm)));
596
649
  }
597
650
  }
651
+ .pop-panel[data-instant] {
652
+ transition-duration: var(--dur-instant);
653
+ }
598
654
  </style>
@@ -75,6 +75,8 @@ type Own = {
75
75
  /** Class / inline style on the floating panel. */
76
76
  panelClass?: string;
77
77
  panelStyle?: string;
78
+ /** Play UI sounds when enabled globally; false silences this instance. */
79
+ sound?: boolean;
78
80
  };
79
81
  type $$ComponentProps = Omit<HTMLAttributes<HTMLElement>, keyof Own> & Own;
80
82
  declare const Popover: import("svelte").Component<$$ComponentProps, {}, "open">;
@@ -89,7 +89,7 @@
89
89
  overflow-x: auto;
90
90
  font-family: var(--font-mono);
91
91
  font-size: var(--fs-xs);
92
- line-height: 1.5;
92
+ line-height: var(--lh-normal);
93
93
  }
94
94
  .prose :global(pre code) {
95
95
  padding: 0;
@@ -7,6 +7,7 @@
7
7
  import type { Snippet } from 'svelte';
8
8
  import Icon from '../atoms/Icon.svelte';
9
9
  import Spinner from '../atoms/Spinner.svelte';
10
+ import { playSound } from '../../sound';
10
11
 
11
12
  let {
12
13
  children,
@@ -16,6 +17,7 @@
16
17
  pullLabel = 'Pull to refresh',
17
18
  releaseLabel = 'Release to refresh',
18
19
  disabled = false,
20
+ sound = true,
19
21
  class: klass = '',
20
22
  ...rest
21
23
  }: {
@@ -29,6 +31,8 @@
29
31
  pullLabel?: string;
30
32
  releaseLabel?: string;
31
33
  disabled?: boolean;
34
+ /** Play UI sounds when enabled globally; false silences this instance. */
35
+ sound?: boolean;
32
36
  class?: string;
33
37
  [key: string]: unknown;
34
38
  } = $props();
@@ -68,7 +72,9 @@
68
72
  if (dy < SLOP) return;
69
73
  pulling = true;
70
74
  }
75
+ const wasReady = ready;
71
76
  offset = dy > 0 ? damp(dy) : 0;
77
+ if (ready && !wasReady) playSound('tick', sound);
72
78
  }
73
79
 
74
80
  function onpointerup(e: PointerEvent) {
@@ -125,6 +131,7 @@
125
131
  class:pulling
126
132
  class:pending
127
133
  style:--ptr-offset="{offset}px"
134
+ style:--ptr-threshold="{threshold}px"
128
135
  style:--ptr-progress={progress}
129
136
  aria-busy={pending || undefined}
130
137
  {onpointerdown}
@@ -156,7 +163,7 @@
156
163
  }
157
164
  .ptr-content {
158
165
  translate: 0 var(--ptr-offset, 0px);
159
- transition: translate 200ms var(--ease);
166
+ transition: translate var(--dur-exit-slow) var(--ease-out);
160
167
  }
161
168
  .pulling .ptr-content {
162
169
  transition: none;
@@ -167,11 +174,13 @@
167
174
  display: flex;
168
175
  align-items: center;
169
176
  justify-content: center;
170
- height: var(--ptr-offset, 0px);
171
- overflow: hidden;
177
+ height: var(--ptr-threshold, 72px);
178
+ translate: 0 calc(var(--ptr-offset, 0px) - 100%);
172
179
  color: var(--text-muted);
173
180
  opacity: var(--ptr-progress, 0);
174
- transition: height 200ms var(--ease);
181
+ transition:
182
+ translate var(--dur-exit-slow) var(--ease-out),
183
+ opacity var(--dur-exit-slow) var(--ease-out);
175
184
  pointer-events: none;
176
185
  }
177
186
  .pulling .ptr-indicator {
@@ -179,7 +188,7 @@
179
188
  }
180
189
  .ptr-indicator :global(.ptr-arrow) {
181
190
  rotate: calc(180deg * var(--ptr-progress, 0));
182
- transition: rotate 150ms var(--ease);
191
+ transition: rotate var(--dur-base) var(--ease);
183
192
  }
184
193
  .pending .ptr-indicator {
185
194
  color: var(--accent);
@@ -193,11 +202,6 @@
193
202
  white-space: nowrap;
194
203
  }
195
204
  @media (prefers-reduced-motion: reduce) {
196
- .ptr-content,
197
- .ptr-indicator,
198
- .ptr-indicator :global(.ptr-arrow) {
199
- transition: none;
200
- }
201
205
  .ptr-indicator :global(.ptr-arrow) {
202
206
  rotate: none;
203
207
  }
@@ -10,6 +10,8 @@ type $$ComponentProps = {
10
10
  pullLabel?: string;
11
11
  releaseLabel?: string;
12
12
  disabled?: boolean;
13
+ /** Play UI sounds when enabled globally; false silences this instance. */
14
+ sound?: boolean;
13
15
  class?: string;
14
16
  [key: string]: unknown;
15
17
  };
@@ -19,6 +19,7 @@
19
19
  import { menuItemCloses, menuItemGlyphs } from './menu-item';
20
20
  import { type Bounds, fanLayout, type Point } from '../../floating';
21
21
  import { slideSelect } from '../../slide-select';
22
+ import { playSound } from '../../sound';
22
23
  import { canonicalTone, type Tone } from '../../tone';
23
24
 
24
25
  type Own = {
@@ -28,6 +29,8 @@
28
29
  open?: boolean;
29
30
  onopen?: () => void;
30
31
  onclose?: () => void;
32
+ /** Play UI sounds when enabled globally; false silences this instance. */
33
+ sound?: boolean;
31
34
  /** Replaces the default FAB content. */
32
35
  trigger?: Snippet<[{ open: boolean }]>;
33
36
  icon?: IconName;
@@ -66,6 +69,7 @@
66
69
  status,
67
70
  class: klass = '',
68
71
  style: styleProp = '',
72
+ sound = true,
69
73
  ...rest
70
74
  }: Omit<HTMLButtonAttributes, keyof Own> & Own = $props();
71
75
 
@@ -85,12 +89,16 @@
85
89
  let active = $state(0);
86
90
  let petalPx = 0;
87
91
  let shown = false;
92
+ // An `open` already true on mount is a sync, not a transition: no sound.
93
+ let quiet = open;
88
94
  let focusOnOpen = false;
89
95
  let pressedOpen = false;
90
96
  let swallowClick = false;
91
97
  let releasedAt = Number.NEGATIVE_INFINITY;
92
98
 
93
99
  const petals = $derived(items.filter((item) => !item.control));
100
+ // Keep the whole fan inside the default budget: base enter + (n-1) × stagger ≤ 300 ms.
101
+ const stagger = $derived(`${Math.min(18, petals.length > 1 ? 120 / (petals.length - 1) : 18)}ms`);
94
102
  const ring = $derived(tone === 'neutral' ? 'var(--border-strong)' : `var(--${canonicalTone(tone)})`);
95
103
  const forward = $derived(points.length < 2 || points[points.length - 1].x >= points[0].x);
96
104
 
@@ -210,6 +218,8 @@
210
218
  if (!panel || want === shown) return;
211
219
  shown = want;
212
220
  untrack(() => {
221
+ if (!quiet) playSound(want ? 'open' : 'close', sound);
222
+ quiet = false;
213
223
  if (want) {
214
224
  show();
215
225
  position();
@@ -332,7 +342,7 @@
332
342
  {#if trigger}
333
343
  {@render trigger({ open })}
334
344
  {:else}
335
- <Icon name={open ? closeIcon : icon} size={20} />
345
+ <span class="radial-face-glyph" aria-hidden="true"><Icon name={open ? closeIcon : icon} /></span>
336
346
  {#if caption}
337
347
  <span class="radial-caption">
338
348
  {#if typeof caption === 'string'}{caption}{:else}{@render caption()}{/if}
@@ -388,7 +398,7 @@
388
398
  </div>
389
399
  {/if}
390
400
  {/if}
391
- <div id={menuId} role="menu" aria-label={label} class="radial-menu">
401
+ <div id={menuId} role="menu" aria-label={label} class="radial-menu" style:--radial-stagger={stagger}>
392
402
  {#if open}
393
403
  {#each petals as item, i (item.id ?? `${i}:${item.label}`)}
394
404
  {@const p = points[i]}
@@ -418,9 +428,9 @@
418
428
  {:else}
419
429
  <span class="radial-glyph" aria-hidden="true">
420
430
  {#if glyphs.leading === 'icon' && item.icon}
421
- <Icon name={item.icon} size={16} />
431
+ <Icon name={item.icon} />
422
432
  {:else if glyphs.leading === 'check'}
423
- <Icon name={item.pressed ? 'check' : 'minus'} size={16} />
433
+ <Icon name={item.pressed ? 'check' : 'minus'} />
424
434
  {/if}
425
435
  </span>
426
436
  <span class="radial-label">{item.label}</span>
@@ -440,7 +450,7 @@
440
450
  flex-direction: column;
441
451
  align-items: center;
442
452
  justify-content: center;
443
- gap: 1px;
453
+ gap: var(--sp-px);
444
454
  flex: none;
445
455
  width: var(--radial-fab-size, 3.75rem);
446
456
  height: var(--radial-fab-size, 3.75rem);
@@ -456,18 +466,31 @@
456
466
  touch-action: none;
457
467
  user-select: none;
458
468
  -webkit-touch-callout: none;
469
+ --radial-scale-dur: var(--dur-fast);
470
+ transition:
471
+ background-color var(--dur-fast) var(--ease),
472
+ border-color var(--dur-fast) var(--ease),
473
+ scale var(--radial-scale-dur) var(--ease-out);
474
+ }
475
+ .radial-fab:active:not(:disabled) {
476
+ --radial-scale-dur: var(--dur-press);
477
+ scale: var(--press-scale-sm);
478
+ }
479
+ .radial-face-glyph {
480
+ display: inline-flex;
481
+ font-size: var(--icon-lg);
459
482
  }
460
483
  .radial-fab:focus-visible {
461
484
  outline: var(--focus-ring);
462
485
  outline-offset: var(--focus-ring-offset);
463
486
  }
464
487
  .radial-fab:disabled {
465
- opacity: 0.45;
488
+ opacity: var(--disabled-opacity);
466
489
  cursor: not-allowed;
467
490
  }
468
491
  .radial-caption {
469
492
  font-size: var(--fs-xs);
470
- line-height: 1;
493
+ line-height: var(--lh-none);
471
494
  color: var(--text-muted);
472
495
  white-space: nowrap;
473
496
  }
@@ -515,22 +538,31 @@
515
538
  flex-direction: column;
516
539
  align-items: center;
517
540
  justify-content: center;
518
- gap: 2px;
541
+ gap: var(--sp-0-5);
519
542
  width: max(var(--radial-petal-size, 3.5rem), var(--touch-target));
520
543
  height: max(var(--radial-petal-size, 3.5rem), var(--touch-target));
521
- padding: 0 2px;
544
+ padding: 0 var(--sp-0-5);
522
545
  border: 1px solid var(--border-strong);
523
546
  border-radius: 50%;
524
547
  background: var(--bg-elevated);
525
548
  color: var(--text);
526
549
  box-shadow: var(--shadow-md);
527
550
  font-size: var(--fs-xs);
528
- line-height: 1.1;
551
+ line-height: var(--lh-heading);
529
552
  translate: -50% -50%;
530
553
  pointer-events: auto;
531
554
  cursor: pointer;
532
- animation: radial-in 0.16s var(--ease) both;
533
- animation-delay: calc(var(--radial-i, 0) * 18ms);
555
+ --radial-scale-dur: var(--dur-fast);
556
+ transition:
557
+ background-color var(--dur-fast) var(--ease),
558
+ border-color var(--dur-fast) var(--ease),
559
+ scale var(--radial-scale-dur) var(--ease-out);
560
+ animation: radial-in var(--dur-base) var(--ease-out) backwards;
561
+ animation-delay: calc(var(--radial-i, 0) * var(--radial-stagger, 18ms));
562
+ }
563
+ .radial-petal:active:not(:disabled) {
564
+ --radial-scale-dur: var(--dur-press);
565
+ scale: var(--press-scale-sm);
534
566
  }
535
567
  @keyframes radial-in {
536
568
  from {
@@ -540,6 +572,7 @@
540
572
  }
541
573
  .radial-glyph {
542
574
  display: inline-flex;
575
+ font-size: var(--icon-md);
543
576
  color: var(--text-muted);
544
577
  }
545
578
  .radial-label {
@@ -548,11 +581,16 @@
548
581
  text-overflow: ellipsis;
549
582
  white-space: nowrap;
550
583
  }
551
- .radial-petal:hover:not(:disabled),
552
584
  .radial-petal.armed {
553
585
  background: var(--bg-elevated-2);
554
586
  border-color: var(--accent);
555
587
  }
588
+ @media (hover: hover) and (pointer: fine) {
589
+ .radial-petal:hover:not(:disabled) {
590
+ background: var(--bg-elevated-2);
591
+ border-color: var(--accent);
592
+ }
593
+ }
556
594
  .radial-petal:focus-visible {
557
595
  outline: var(--focus-ring);
558
596
  outline-offset: var(--focus-ring-offset);
@@ -568,12 +606,7 @@
568
606
  border-color: var(--danger);
569
607
  }
570
608
  .radial-petal:disabled {
571
- opacity: 0.45;
609
+ opacity: var(--disabled-opacity);
572
610
  cursor: not-allowed;
573
611
  }
574
- @media (prefers-reduced-motion: reduce) {
575
- .radial-petal {
576
- animation: none;
577
- }
578
- }
579
612
  </style>
@@ -15,6 +15,8 @@ type Own = {
15
15
  open?: boolean;
16
16
  onopen?: () => void;
17
17
  onclose?: () => void;
18
+ /** Play UI sounds when enabled globally; false silences this instance. */
19
+ sound?: boolean;
18
20
  /** Replaces the default FAB content. */
19
21
  trigger?: Snippet<[{
20
22
  open: boolean;