@dorsk/tsumikit 0.74.0 → 0.76.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 (132) 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 +4 -2
  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/Slider.svelte +79 -22
  23. package/dist/components/atoms/Slider.svelte.d.ts +2 -0
  24. package/dist/components/atoms/Swatch.svelte +19 -8
  25. package/dist/components/atoms/Switch.svelte +34 -21
  26. package/dist/components/atoms/Switch.svelte.d.ts +2 -0
  27. package/dist/components/atoms/Text.svelte +1 -1
  28. package/dist/components/atoms/Textarea.svelte +34 -12
  29. package/dist/components/atoms/TimeDisplay.svelte +4 -2
  30. package/dist/components/layouts/AppShell.svelte +26 -15
  31. package/dist/components/layouts/MasterDetail.svelte +23 -21
  32. package/dist/components/layouts/NavBar.svelte +11 -8
  33. package/dist/components/layouts/NavItem.svelte +15 -7
  34. package/dist/components/layouts/NavSection.svelte +1 -1
  35. package/dist/components/layouts/ResizablePanel.svelte +37 -16
  36. package/dist/components/layouts/TabBar.svelte +24 -14
  37. package/dist/components/layouts/TabBar.svelte.d.ts +2 -0
  38. package/dist/components/molecules/Accordion.svelte +4 -0
  39. package/dist/components/molecules/Accordion.svelte.d.ts +2 -0
  40. package/dist/components/molecules/AttachmentList.svelte +14 -11
  41. package/dist/components/molecules/Breadcrumb.svelte +13 -9
  42. package/dist/components/molecules/Callout.svelte +3 -3
  43. package/dist/components/molecules/CapBar.svelte +23 -22
  44. package/dist/components/molecules/Carousel.svelte +27 -17
  45. package/dist/components/molecules/ChatBubble.svelte +2 -2
  46. package/dist/components/molecules/CodeBlock.svelte +2 -2
  47. package/dist/components/molecules/Combobox.svelte +30 -1
  48. package/dist/components/molecules/Combobox.svelte.d.ts +4 -0
  49. package/dist/components/molecules/Composer.svelte +2 -2
  50. package/dist/components/molecules/ConfirmModal.svelte +4 -0
  51. package/dist/components/molecules/ConfirmModal.svelte.d.ts +2 -0
  52. package/dist/components/molecules/CopyButton.svelte +46 -2
  53. package/dist/components/molecules/CopyButton.svelte.d.ts +2 -0
  54. package/dist/components/molecules/Disclosure.svelte +23 -12
  55. package/dist/components/molecules/Disclosure.svelte.d.ts +2 -0
  56. package/dist/components/molecules/Drawer.svelte +43 -23
  57. package/dist/components/molecules/Drawer.svelte.d.ts +2 -0
  58. package/dist/components/molecules/Dropzone.svelte +24 -8
  59. package/dist/components/molecules/EmojiPicker.svelte +27 -8
  60. package/dist/components/molecules/EmojiPicker.svelte.d.ts +2 -0
  61. package/dist/components/molecules/EmptyState.svelte +5 -5
  62. package/dist/components/molecules/Fieldset.svelte +8 -13
  63. package/dist/components/molecules/FileButton.svelte +57 -31
  64. package/dist/components/molecules/FileButton.svelte.d.ts +2 -0
  65. package/dist/components/molecules/FilterInput.svelte +51 -11
  66. package/dist/components/molecules/FontScalePicker.svelte +20 -9
  67. package/dist/components/molecules/GitRef.svelte +9 -7
  68. package/dist/components/molecules/GlyphStack.svelte +46 -14
  69. package/dist/components/molecules/IconButton.svelte +6 -3
  70. package/dist/components/molecules/IconButton.svelte.d.ts +2 -0
  71. package/dist/components/molecules/InputGroup.svelte +13 -12
  72. package/dist/components/molecules/LoadMore.svelte +6 -1
  73. package/dist/components/molecules/LoadMore.svelte.d.ts +2 -0
  74. package/dist/components/molecules/Menu.svelte +33 -7
  75. package/dist/components/molecules/Menu.svelte.d.ts +2 -0
  76. package/dist/components/molecules/Metric.svelte +11 -9
  77. package/dist/components/molecules/Modal.svelte +76 -26
  78. package/dist/components/molecules/Modal.svelte.d.ts +2 -0
  79. package/dist/components/molecules/OptionButton.svelte +28 -10
  80. package/dist/components/molecules/OptionButton.svelte.d.ts +2 -0
  81. package/dist/components/molecules/Pagination.svelte +26 -9
  82. package/dist/components/molecules/Pagination.svelte.d.ts +2 -0
  83. package/dist/components/molecules/Popover.svelte +107 -51
  84. package/dist/components/molecules/Popover.svelte.d.ts +2 -0
  85. package/dist/components/molecules/Prose.svelte +1 -1
  86. package/dist/components/molecules/PullToRefresh.svelte +14 -10
  87. package/dist/components/molecules/PullToRefresh.svelte.d.ts +2 -0
  88. package/dist/components/molecules/RadialMenu.svelte +52 -19
  89. package/dist/components/molecules/RadialMenu.svelte.d.ts +2 -0
  90. package/dist/components/molecules/RadioGroup.svelte +38 -23
  91. package/dist/components/molecules/RadioGroup.svelte.d.ts +2 -0
  92. package/dist/components/molecules/RichSelect.svelte +583 -0
  93. package/dist/components/molecules/RichSelect.svelte.d.ts +68 -0
  94. package/dist/components/molecules/SectionHeader.svelte +13 -8
  95. package/dist/components/molecules/SeekSlider.svelte +38 -27
  96. package/dist/components/molecules/SeekSlider.svelte.d.ts +2 -0
  97. package/dist/components/molecules/SegmentedControl.svelte +139 -86
  98. package/dist/components/molecules/SegmentedControl.svelte.d.ts +2 -0
  99. package/dist/components/molecules/SelectButton.svelte +21 -6
  100. package/dist/components/molecules/SelectButton.svelte.d.ts +2 -0
  101. package/dist/components/molecules/SortableList.svelte +10 -11
  102. package/dist/components/molecules/SoundSettings.svelte +161 -0
  103. package/dist/components/molecules/SoundSettings.svelte.d.ts +17 -0
  104. package/dist/components/molecules/SplitButton.svelte +30 -3
  105. package/dist/components/molecules/SplitButton.svelte.d.ts +2 -0
  106. package/dist/components/molecules/SwipeRow.svelte +18 -15
  107. package/dist/components/molecules/SwipeRow.svelte.d.ts +2 -0
  108. package/dist/components/molecules/Tabs.svelte +92 -10
  109. package/dist/components/molecules/Tabs.svelte.d.ts +2 -0
  110. package/dist/components/molecules/ThemePicker.svelte +25 -10
  111. package/dist/components/molecules/Timestamp.svelte +12 -7
  112. package/dist/components/molecules/Toaster.svelte +65 -14
  113. package/dist/components/molecules/Toaster.svelte.d.ts +2 -0
  114. package/dist/components/molecules/Toggle.svelte +36 -13
  115. package/dist/components/molecules/Toggle.svelte.d.ts +2 -0
  116. package/dist/components/molecules/Tooltip.svelte +41 -10
  117. package/dist/components/molecules/Tooltip.svelte.d.ts +1 -1
  118. package/dist/components/molecules/VolumeSlider.svelte +34 -14
  119. package/dist/components/molecules/VolumeSlider.svelte.d.ts +2 -0
  120. package/dist/components/molecules/WorkingDir.svelte +1 -0
  121. package/dist/components/molecules/rich-select-keyboard.d.ts +82 -0
  122. package/dist/components/molecules/rich-select-keyboard.js +114 -0
  123. package/dist/components/organisms/ConversationFeed.svelte +24 -1
  124. package/dist/components/organisms/DataTable.svelte +37 -14
  125. package/dist/floating.js +26 -0
  126. package/dist/index.d.ts +2 -0
  127. package/dist/index.js +2 -0
  128. package/dist/sound-synth.js +17 -12
  129. package/dist/stores/toast.svelte.d.ts +9 -0
  130. package/dist/stores/toast.svelte.js +45 -9
  131. package/dist/styles/reset.css +3 -3
  132. package/package.json +1 -1
@@ -7,6 +7,7 @@
7
7
  import type { Snippet } from 'svelte';
8
8
  import type { HTMLAttributes } from 'svelte/elements';
9
9
  import { describeMediaTime, formatMediaTime, isLiveDuration } from '../../media-time';
10
+ import { playSound } from '../../sound';
10
11
 
11
12
  type Own = {
12
13
  /** Position in seconds. Updated only on commit, never while dragging. */
@@ -22,6 +23,8 @@
22
23
  disabled?: boolean;
23
24
  size?: 'sm' | 'md';
24
25
  label?: string;
26
+ /** Play UI sounds when enabled globally; false silences this instance. */
27
+ sound?: boolean;
25
28
  formatTime?: (seconds: number, ref?: number) => string;
26
29
  /** Fired on every pointer move while scrubbing with the previewed time. */
27
30
  oninput?: (seconds: number) => void;
@@ -44,6 +47,7 @@
44
47
  disabled = false,
45
48
  size = 'md',
46
49
  label = 'Seek',
50
+ sound = true,
47
51
  formatTime = formatMediaTime,
48
52
  oninput,
49
53
  onchange,
@@ -77,6 +81,7 @@
77
81
  .filter(([a, b]) => b > a)
78
82
  );
79
83
  const valueText = $derived(describeMediaTime(shown, live ? null : duration, formatTime));
84
+ const discrete = $derived(duration > 0 && duration / step <= 20);
80
85
 
81
86
  function fracAt(el: HTMLElement, clientX: number) {
82
87
  const r = el.getBoundingClientRect();
@@ -125,6 +130,7 @@
125
130
  const el = e.currentTarget as HTMLElement;
126
131
  if (pointerId === e.pointerId) {
127
132
  preview = fracAt(el, e.clientX) * duration;
133
+ if (discrete) playSound('tick', sound);
128
134
  oninput?.(preview);
129
135
  return;
130
136
  }
@@ -182,6 +188,7 @@
182
188
  }
183
189
  if (next === null) return;
184
190
  e.preventDefault();
191
+ playSound('tick', sound);
185
192
  commit(next);
186
193
  }
187
194
  </script>
@@ -233,8 +240,9 @@
233
240
  <style>
234
241
  .seek {
235
242
  --seek-track: 3px;
236
- --seek-track-active: 5px;
243
+ --seek-track-grow: 1.6;
237
244
  --seek-thumb: 12px;
245
+ --seek-halo: 0 0 0 4px color-mix(in srgb, var(--seek-accent) 20%, transparent);
238
246
  --seek-slab: 1.25rem;
239
247
  --seek-accent: var(--slider-accent, var(--accent));
240
248
  position: relative;
@@ -249,7 +257,6 @@
249
257
  }
250
258
  .seek--md {
251
259
  --seek-track: 4px;
252
- --seek-track-active: 6px;
253
260
  --seek-thumb: 14px;
254
261
  --seek-slab: 1.5rem;
255
262
  }
@@ -259,8 +266,8 @@
259
266
  }
260
267
  }
261
268
  .seek--inert {
262
- cursor: default;
263
- opacity: 0.45;
269
+ cursor: not-allowed;
270
+ opacity: var(--disabled-opacity);
264
271
  }
265
272
  .seek-track {
266
273
  position: absolute;
@@ -271,19 +278,12 @@
271
278
  border-radius: var(--r-pill);
272
279
  background: var(--bg-elevated-2);
273
280
  overflow: hidden;
274
- transition:
275
- height 120ms var(--ease),
276
- margin-top 120ms var(--ease);
281
+ transform-origin: center;
282
+ transition: scale var(--dur-fast) var(--ease);
277
283
  }
278
- .seek:hover .seek-track,
279
284
  .seek:focus-visible .seek-track,
280
285
  .seek--dragging .seek-track {
281
- height: var(--seek-track-active);
282
- margin-top: calc(var(--seek-track-active) / -2);
283
- }
284
- .seek--inert:hover .seek-track {
285
- height: var(--seek-track);
286
- margin-top: calc(var(--seek-track) / -2);
286
+ scale: 1 var(--seek-track-grow);
287
287
  }
288
288
  .seek-buffered,
289
289
  .seek-played {
@@ -319,28 +319,45 @@
319
319
  top: 0;
320
320
  width: var(--seek-thumb);
321
321
  height: var(--seek-thumb);
322
- border-radius: 50%;
322
+ border-radius: var(--r-pill);
323
323
  background: var(--seek-accent);
324
324
  border: 2px solid var(--bg);
325
325
  box-shadow: var(--shadow-sm);
326
- transform: translate(-50%, -50%) scale(0.6);
326
+ transform: translate(-50%, -50%);
327
+ scale: 0.6;
327
328
  opacity: 0;
328
329
  transition:
329
- opacity 120ms var(--ease),
330
- transform 120ms var(--ease);
330
+ opacity var(--dur-fast) var(--ease),
331
+ scale var(--dur-press) var(--ease-out),
332
+ box-shadow var(--dur-fast) var(--ease);
331
333
  }
332
- .seek:hover .seek-thumb,
333
334
  .seek:focus-visible .seek-thumb,
334
335
  .seek--dragging .seek-thumb {
335
336
  opacity: 1;
336
- transform: translate(-50%, -50%) scale(1);
337
+ scale: 1;
338
+ }
339
+ .seek--dragging .seek-thumb {
340
+ scale: 1.15;
341
+ }
342
+ @media (hover: hover) and (pointer: fine) {
343
+ .seek:not(.seek--inert):hover .seek-track {
344
+ scale: 1 var(--seek-track-grow);
345
+ }
346
+ .seek:not(.seek--inert):hover .seek-thumb {
347
+ opacity: 1;
348
+ scale: 1;
349
+ box-shadow: var(--shadow-sm), var(--seek-halo);
350
+ }
351
+ .seek--dragging .seek-thumb {
352
+ scale: 1.15;
353
+ }
337
354
  }
338
355
  .seek--inert .seek-thumb {
339
356
  opacity: 0;
340
357
  }
341
358
  .seek:focus-visible .seek-thumb {
342
359
  outline: var(--focus-ring);
343
- outline-offset: 2px;
360
+ outline-offset: var(--focus-ring-offset);
344
361
  }
345
362
  .seek-tip {
346
363
  position: absolute;
@@ -361,10 +378,4 @@
361
378
  font-variant-numeric: tabular-nums;
362
379
  white-space: nowrap;
363
380
  }
364
- @media (prefers-reduced-motion: reduce) {
365
- .seek-track,
366
- .seek-thumb {
367
- transition: none;
368
- }
369
- }
370
381
  </style>
@@ -19,6 +19,8 @@ type Own = {
19
19
  disabled?: boolean;
20
20
  size?: 'sm' | 'md';
21
21
  label?: string;
22
+ /** Play UI sounds when enabled globally; false silences this instance. */
23
+ sound?: boolean;
22
24
  formatTime?: (seconds: number, ref?: number) => string;
23
25
  /** Fired on every pointer move while scrubbing with the previewed time. */
24
26
  oninput?: (seconds: number) => void;
@@ -23,9 +23,11 @@
23
23
  // Implemented with `radiogroup`/`radio` semantics + roving tabindex so the
24
24
  // browser-free keyboard model matches Tabs: ←/→ and Home/End move selection,
25
25
  // Space/Enter (or click) activate. `value` is bindable.
26
+ import { tick } from 'svelte';
26
27
  import type { Snippet } from 'svelte';
27
28
  import type { Attachment } from 'svelte/attachments';
28
29
  import Icon from '../atoms/Icon.svelte';
30
+ import { playSound } from '../../sound';
29
31
  import { formatSegmentCount } from './segmented-control-count.js';
30
32
  import { nextEnabledSegment } from './segmented-control-keyboard.js';
31
33
 
@@ -49,6 +51,7 @@
49
51
  collapseLabels = 'never',
50
52
  scroll = false,
51
53
  block = false,
54
+ sound = true,
52
55
  class: klass = '',
53
56
  // Custom rendering per option (overrides the built-in label/count/icon).
54
57
  option: optionSnippet
@@ -75,6 +78,8 @@
75
78
  scroll?: boolean;
76
79
  /** Fill the parent width, sharing it equally between segments. */
77
80
  block?: boolean;
81
+ /** Play UI sounds when enabled globally; false silences this instance. */
82
+ sound?: boolean;
78
83
  class?: string;
79
84
  option?: Snippet<[SegmentOption, boolean]>;
80
85
  } = $props();
@@ -116,8 +121,55 @@
116
121
  };
117
122
  };
118
123
 
124
+ // A thumb slides under the selected segment on translate/scale (never
125
+ // width/left): the size snaps, the old-to-new width ratio is inverted and
126
+ // released (FLIP). It snaps on mount and reflow; until placed the selected
127
+ // segment paints its own fill.
128
+ let thumb = $state<HTMLSpanElement | null>(null);
129
+ let placed = $state(false);
130
+ let rect = { x: 0, y: 0, w: 0, h: 0 };
131
+ function placeThumb(animate: boolean) {
132
+ const el = listEl?.querySelector<HTMLElement>('.seg-item.selected');
133
+ const th = thumb;
134
+ if (!el || !th) {
135
+ placed = false;
136
+ return;
137
+ }
138
+ const prev = rect;
139
+ rect = { x: el.offsetLeft, y: el.offsetTop, w: el.offsetWidth, h: el.offsetHeight };
140
+ th.style.width = `${rect.w}px`;
141
+ th.style.height = `${rect.h}px`;
142
+ th.style.transition = 'none';
143
+ if (!animate || !placed || rect.w === 0) {
144
+ th.style.translate = `${rect.x}px ${rect.y}px`;
145
+ th.style.scale = '1 1';
146
+ th.getBoundingClientRect();
147
+ th.style.transition = '';
148
+ placed = true;
149
+ return;
150
+ }
151
+ th.style.translate = `${prev.x}px ${prev.y}px`;
152
+ th.style.scale = `${prev.w / rect.w} ${prev.h / rect.h}`;
153
+ th.getBoundingClientRect();
154
+ th.style.transition = '';
155
+ th.style.translate = `${rect.x}px ${rect.y}px`;
156
+ th.style.scale = '1 1';
157
+ }
158
+ $effect(() => {
159
+ value;
160
+ options;
161
+ tick().then(() => placeThumb(true));
162
+ });
163
+ $effect(() => {
164
+ if (!listEl || typeof ResizeObserver === 'undefined') return;
165
+ const ro = new ResizeObserver(() => placeThumb(false));
166
+ ro.observe(listEl);
167
+ return () => ro.disconnect();
168
+ });
169
+
119
170
  function select(val: string, focus = false) {
120
171
  if (options.find((o) => o.value === val)?.disabled) return;
172
+ if (value !== val) playSound('click', sound);
121
173
  value = val;
122
174
  onchange?.(val);
123
175
  if (focus) {
@@ -149,10 +201,12 @@
149
201
  class:seg-scroll={scroll}
150
202
  class:seg-block={block}
151
203
  class:seg-overflow={overflowing}
204
+ class:placed
152
205
  data-tsu="SegmentedControl"
153
206
  {onkeydown}
154
207
  {@attach watchOverflow}
155
208
  >
209
+ <span bind:this={thumb} class="seg-thumb" aria-hidden="true"></span>
156
210
  {#each options as o (o.value)}
157
211
  {@const selected = value === o.value}
158
212
  <button
@@ -180,11 +234,19 @@
180
234
  </div>
181
235
 
182
236
  <style>
237
+ /* Every size pins the track height (sm 32 / md 40 / control 44 / box 40);
238
+ items fill the inside, which is the height minus border and padding. */
183
239
  .seg {
240
+ --seg-h: var(--control-md);
241
+ --seg-inset: calc(var(--sp-0-5) + 1px);
242
+ --seg-item-h: calc(var(--seg-h) - 2 * var(--seg-inset));
243
+ position: relative;
184
244
  display: inline-flex;
185
245
  align-items: center;
186
246
  gap: var(--sp-1);
187
- padding: 3px;
247
+ height: var(--seg-h);
248
+ min-height: var(--seg-h);
249
+ padding: var(--sp-0-5);
188
250
  background: var(--bg-elevated-2);
189
251
  border: 1px solid var(--border);
190
252
  border-radius: var(--r-pill);
@@ -194,8 +256,19 @@
194
256
  .seg-icon {
195
257
  border-radius: var(--r-md);
196
258
  }
259
+ .seg-sm {
260
+ --seg-h: var(--control-sm);
261
+ }
262
+ .seg-control {
263
+ --seg-h: var(--control-height);
264
+ height: var(--control-height);
265
+ }
266
+ .seg-box {
267
+ --seg-h: var(--control-height-default);
268
+ }
197
269
  .seg-wrap {
198
270
  flex-wrap: wrap;
271
+ height: auto;
199
272
  }
200
273
  .justify-start {
201
274
  margin-inline-end: auto;
@@ -246,103 +319,83 @@
246
319
  text-overflow: ellipsis;
247
320
  }
248
321
 
322
+ .seg-thumb {
323
+ position: absolute;
324
+ top: 0;
325
+ left: 0;
326
+ width: 0;
327
+ height: 0;
328
+ border-radius: var(--r-pill);
329
+ background: var(--bg);
330
+ box-shadow: var(--shadow-sm);
331
+ transform-origin: top left;
332
+ pointer-events: none;
333
+ transition:
334
+ translate var(--dur-spring) var(--ease-spring),
335
+ scale var(--dur-spring) var(--ease-spring);
336
+ }
337
+ .seg:not(.placed) .seg-thumb {
338
+ display: none;
339
+ }
249
340
  .seg-item {
341
+ position: relative;
250
342
  display: inline-flex;
251
343
  align-items: center;
252
344
  justify-content: center;
253
345
  gap: var(--sp-2);
254
- padding: 0.3rem var(--sp-3);
346
+ height: 100%;
347
+ padding: 0 var(--control-px-md);
255
348
  border: 0;
256
349
  border-radius: var(--r-pill);
257
350
  background: transparent;
258
351
  color: var(--text-muted);
259
- font-size: var(--fs-sm);
352
+ font-size: var(--control-fs-md);
260
353
  font-weight: var(--fw-medium);
261
- line-height: 1.3;
354
+ line-height: var(--lh-none);
355
+ --icon-size: var(--control-icon-md);
262
356
  white-space: nowrap;
263
357
  cursor: pointer;
264
358
  transition:
265
- background 0.12s var(--ease),
266
- color 0.12s var(--ease);
267
- }
268
- .seg-sm .seg-item {
269
- padding: 0.2rem var(--sp-2);
270
- font-size: var(--fs-xs);
271
- }
272
- /* Compact toolbar contract: the outer interactive box exactly matches
273
- Button size="sm" and Popover size="sm". */
274
- .seg-sm {
275
- height: var(--control-height-compact);
276
- min-height: var(--control-height-compact);
359
+ background var(--dur-fast) var(--ease),
360
+ color var(--dur-fast) var(--ease);
277
361
  }
278
362
  .seg-sm .seg-item {
279
- height: 100%;
280
- }
281
- /* Shared toolbar/composer height contract — matches Button/Popover `control`. */
282
- .seg-control {
283
- height: var(--control-height);
284
- min-height: var(--control-height);
285
- }
286
- .seg-control .seg-item {
287
- height: 100%;
288
- }
289
- .seg-box {
290
- height: var(--control-height-default);
291
- min-height: var(--control-height-default);
292
- }
293
- .seg-box .seg-item {
294
- height: 100%;
295
- }
296
- /* Wrapped rows size to their content; each item keeps the one-row height. */
297
- .seg-wrap.seg-sm,
298
- .seg-wrap.seg-control,
299
- .seg-wrap.seg-box {
300
- height: auto;
301
- }
302
- .seg-wrap.seg-sm .seg-item {
303
- height: auto;
304
- min-height: calc(var(--control-height-compact) - 8px);
363
+ padding: 0 var(--control-px-sm);
364
+ font-size: var(--control-fs-sm);
365
+ --icon-size: var(--control-icon-sm);
305
366
  }
306
- .seg-wrap.seg-control .seg-item {
367
+ .seg-wrap .seg-item {
307
368
  height: auto;
308
- min-height: calc(var(--control-height) - 8px);
309
- }
310
- .seg-wrap.seg-box .seg-item {
311
- height: auto;
312
- min-height: calc(var(--control-height-default) - 8px);
369
+ min-height: var(--seg-item-h);
313
370
  }
314
371
  /* Capped at half a row: one row stays a pill, extra rows square the corners. */
315
372
  .seg-wrap:not(.seg-icon) {
316
- border-radius: calc((0.6rem + 1.3 * var(--fs-sm)) / 2 + 4px);
317
- }
318
- .seg-wrap.seg-sm:not(.seg-icon) {
319
- border-radius: calc(var(--control-height-compact) / 2);
320
- }
321
- .seg-wrap.seg-control:not(.seg-icon) {
322
- border-radius: calc(var(--control-height) / 2);
373
+ border-radius: calc(var(--seg-h) / 2);
323
374
  }
324
- .seg-wrap.seg-box:not(.seg-icon) {
325
- border-radius: calc(var(--control-height-default) / 2);
375
+ /* Icon-only segments are squares; corners stay concentric with the track. */
376
+ .seg-icon .seg-item,
377
+ .seg-icon .seg-thumb {
378
+ border-radius: calc(var(--r-md) - var(--seg-inset));
326
379
  }
327
380
  .seg-icon .seg-item {
328
381
  gap: 0;
329
- padding: 0.35rem;
330
- border-radius: var(--r-sm);
331
- font-size: var(--fs-md);
382
+ padding: 0;
383
+ width: var(--seg-item-h);
384
+ font-size: var(--control-icon-md);
332
385
  }
333
386
  .seg-icon.seg-sm .seg-item {
334
- padding: 0.25rem;
335
- font-size: var(--fs-sm);
387
+ font-size: var(--control-icon-sm);
336
388
  }
337
389
  /* A labelled icon segment renders <icon><label>, so it must keep the normal
338
390
  icon/label gap and the roomier pill padding — only true icon-only segments
339
- collapse to the square gap:0 layout above. */
391
+ collapse to the square layout above. */
340
392
  .seg-icon .seg-item.has-label {
341
393
  gap: var(--sp-2);
342
- padding: 0.3rem var(--sp-3);
394
+ width: auto;
395
+ padding: 0 var(--control-px-md);
343
396
  }
344
397
  .seg-icon.seg-sm .seg-item.has-label {
345
- padding: 0.2rem var(--sp-2);
398
+ padding: 0 var(--control-px-sm);
346
399
  }
347
400
 
348
401
  /* collapseLabels='mobile': below the breakpoint, drop the label text and
@@ -354,10 +407,8 @@
354
407
  }
355
408
  .seg-collapse-mobile .seg-item.has-label {
356
409
  gap: 0;
357
- padding: 0.35rem;
358
- }
359
- .seg-collapse-mobile.seg-sm .seg-item.has-label {
360
- padding: 0.25rem;
410
+ padding: 0;
411
+ width: var(--seg-item-h);
361
412
  }
362
413
  }
363
414
  @container (max-width: 35rem) {
@@ -366,37 +417,42 @@
366
417
  }
367
418
  .collapse-container .seg-item.has-label {
368
419
  gap: 0;
369
- padding: 0.35rem;
370
- }
371
- .collapse-container.seg-sm .seg-item.has-label {
372
- padding: 0.25rem;
420
+ padding: 0;
421
+ width: var(--seg-item-h);
373
422
  }
374
423
  }
375
424
 
376
- .seg-item:hover:not(:disabled):not(.selected) {
377
- color: var(--text);
378
- background: var(--bg-elevated);
425
+ @media (hover: hover) and (pointer: fine) {
426
+ .seg-item:hover:not(:disabled):not(.selected) {
427
+ color: var(--text);
428
+ background: var(--bg-elevated);
429
+ }
379
430
  }
380
431
  .seg-item.selected {
381
432
  color: var(--text);
382
433
  background: var(--bg);
383
434
  box-shadow: var(--shadow-sm);
384
435
  }
436
+ .seg.placed .seg-item.selected {
437
+ background: transparent;
438
+ box-shadow: none;
439
+ }
385
440
  .seg-item:disabled {
386
- opacity: 0.45;
441
+ opacity: var(--disabled-opacity);
387
442
  cursor: not-allowed;
388
443
  }
444
+ /* Scrolling tracks clip their overflow, so the ring sits inside the item. */
389
445
  .seg-item:focus-visible {
390
- outline: 2px solid var(--accent);
391
- outline-offset: 2px;
446
+ outline: var(--focus-ring);
447
+ outline-offset: var(--focus-ring-offset-inset);
392
448
  }
393
449
 
394
450
  .seg-count {
395
451
  display: inline-flex;
396
452
  align-items: center;
397
453
  justify-content: center;
398
- min-width: 1.25rem;
399
- padding: 0 0.35rem;
454
+ min-width: var(--sp-5);
455
+ padding: 0 var(--sp-1-5);
400
456
  border-radius: var(--r-pill);
401
457
  background: var(--bg-elevated-2);
402
458
  color: var(--text-faint);
@@ -404,13 +460,10 @@
404
460
  font-weight: var(--fw-semibold);
405
461
  font-variant-numeric: tabular-nums;
406
462
  transition:
407
- background 0.12s var(--ease),
408
- color 0.12s var(--ease);
463
+ background var(--dur-fast) var(--ease),
464
+ color var(--dur-fast) var(--ease);
409
465
  }
410
466
  @media (pointer: coarse) {
411
- .seg-item {
412
- position: relative;
413
- }
414
467
  .seg-item::before {
415
468
  content: '';
416
469
  position: absolute;
@@ -36,6 +36,8 @@ type $$ComponentProps = {
36
36
  scroll?: boolean;
37
37
  /** Fill the parent width, sharing it equally between segments. */
38
38
  block?: boolean;
39
+ /** Play UI sounds when enabled globally; false silences this instance. */
40
+ sound?: boolean;
39
41
  class?: string;
40
42
  option?: Snippet<[SegmentOption, boolean]>;
41
43
  };
@@ -8,6 +8,7 @@
8
8
  import Button from '../atoms/Button.svelte';
9
9
  import Icon, { type IconName } from '../atoms/Icon.svelte';
10
10
  import Select from '../atoms/Select.svelte';
11
+ import { playSound } from '../../sound';
11
12
 
12
13
  type Option = { value: string; label: string };
13
14
 
@@ -28,6 +29,7 @@
28
29
  grow = false,
29
30
  shrink = true,
30
31
  block = false,
32
+ sound = true,
31
33
  class: klass = '',
32
34
  ...rest
33
35
  }: {
@@ -60,9 +62,16 @@
60
62
  // Shared square box scale (`--box-xs/sm/md/lg`); default is the 2.25rem icon box.
61
63
  box?: 'xs' | 'sm' | 'md' | 'lg';
62
64
  hitArea?: 'auto' | 'compact';
65
+ /** Play UI sounds when enabled globally; false silences this instance. */
66
+ sound?: boolean;
63
67
  class?: string;
64
68
  [key: string]: unknown;
65
69
  } = $props();
70
+
71
+ function change(e: Event) {
72
+ playSound('click', sound);
73
+ onchange((e.currentTarget as HTMLSelectElement).value);
74
+ }
66
75
  </script>
67
76
 
68
77
  <!-- The wrapper (owned here, so styled scoped) is the positioning context that
@@ -89,7 +98,7 @@
89
98
  aria-label={label}
90
99
  {title}
91
100
  {value}
92
- onchange={(e) => onchange((e.currentTarget as HTMLSelectElement).value)}
101
+ onchange={change}
93
102
  >
94
103
  {#if groups}
95
104
  {#each groups as g, i (`${i}:${g.label}`)}
@@ -129,14 +138,20 @@
129
138
  .select-button.active {
130
139
  color: var(--accent);
131
140
  }
132
- /* The inert face takes no pointer events: mirror ghost hover and the
141
+ /* The inert face takes no pointer events: mirror ghost hover, press and the
133
142
  focus ring from the select overlaying it. */
134
- .select-button:hover :global(.btn-ghost) {
135
- background: var(--bg-elevated-2);
143
+ .select-button:active :global(.btn-ghost) {
144
+ --btn-scale-dur: var(--dur-press);
145
+ scale: var(--press-scale-sm);
136
146
  }
137
147
  .select-button:has(:focus-visible) {
138
- outline: 2px solid var(--accent);
139
- outline-offset: 2px;
148
+ outline: var(--focus-ring);
149
+ outline-offset: var(--focus-ring-offset);
150
+ }
151
+ @media (hover: hover) and (pointer: fine) {
152
+ .select-button:hover :global(.btn-ghost) {
153
+ background: var(--bg-elevated-2);
154
+ }
140
155
  }
141
156
  .select-button.row :global(.btn) {
142
157
  justify-content: flex-start;
@@ -32,6 +32,8 @@ type $$ComponentProps = {
32
32
  onchange: (value: string) => void;
33
33
  box?: 'xs' | 'sm' | 'md' | 'lg';
34
34
  hitArea?: 'auto' | 'compact';
35
+ /** Play UI sounds when enabled globally; false silences this instance. */
36
+ sound?: boolean;
35
37
  class?: string;
36
38
  [key: string]: unknown;
37
39
  };
@@ -585,8 +585,8 @@
585
585
  border-radius: var(--r-sm);
586
586
  background: transparent;
587
587
  transition:
588
- transform 0.16s var(--ease),
589
- box-shadow 0.16s var(--ease);
588
+ transform var(--dur-base) var(--ease),
589
+ box-shadow var(--dur-base) var(--ease);
590
590
  }
591
591
  .sortable-sm .sl-lift {
592
592
  min-height: var(--sortable-row-h, 1.875rem);
@@ -637,13 +637,17 @@
637
637
  translate: -50% -50%;
638
638
  background: radial-gradient(circle, currentColor 1px, transparent 1.5px) 0 0 / 5px 5px;
639
639
  }
640
- .sl-grip:hover,
640
+ @media (hover: hover) and (pointer: fine) {
641
+ .sl-grip:hover {
642
+ color: var(--accent);
643
+ }
644
+ }
641
645
  .sl-grip[aria-pressed='true'] {
642
646
  color: var(--accent);
643
647
  }
644
648
  .sl-grip:focus-visible {
645
649
  outline: var(--focus-ring);
646
- outline-offset: -2px;
650
+ outline-offset: var(--focus-ring-offset-inset);
647
651
  }
648
652
  .handle-row .sl-row:not(.locked) {
649
653
  cursor: grab;
@@ -676,7 +680,7 @@
676
680
  }
677
681
  button.sl-body:focus-visible {
678
682
  outline: var(--focus-ring);
679
- outline-offset: -2px;
683
+ outline-offset: var(--focus-ring-offset-inset);
680
684
  border-radius: var(--r-sm);
681
685
  }
682
686
  .sl-row.selected button.sl-body {
@@ -687,7 +691,7 @@
687
691
  flex: none;
688
692
  display: inline-flex;
689
693
  align-items: center;
690
- gap: 2px;
694
+ gap: var(--sp-0-5);
691
695
  }
692
696
  .sl-live {
693
697
  position: absolute;
@@ -712,9 +716,4 @@
712
716
  inset-inline: 0;
713
717
  }
714
718
  }
715
- @media (prefers-reduced-motion: reduce) {
716
- .sl-lift {
717
- transition: none;
718
- }
719
- }
720
719
  </style>