@dorsk/tsumikit 0.73.2 → 0.74.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.
@@ -233,22 +233,22 @@
233
233
  }
234
234
  .progress.indeterminate .bar {
235
235
  width: 40%;
236
- animation: kt-progress-slide 1.1s ease-in-out infinite;
236
+ animation: kt-progress-slide 1.3s cubic-bezier(0.65, 0, 0.35, 1) infinite;
237
237
  }
238
238
  .progress.striped .bar {
239
239
  animation: kt-stripe 0.6s linear infinite;
240
240
  }
241
241
  .progress.striped.indeterminate .bar {
242
242
  animation:
243
- kt-progress-slide 1.1s ease-in-out infinite,
243
+ kt-progress-slide 1.3s cubic-bezier(0.65, 0, 0.35, 1) infinite,
244
244
  kt-stripe 0.6s linear infinite;
245
245
  }
246
246
  @keyframes kt-progress-slide {
247
247
  0% {
248
- transform: translateX(-110%);
248
+ transform: translateX(-100%);
249
249
  }
250
250
  100% {
251
- transform: translateX(310%);
251
+ transform: translateX(250%);
252
252
  }
253
253
  }
254
254
  @keyframes kt-stripe {
@@ -265,5 +265,9 @@
265
265
  .progress.striped.indeterminate .bar {
266
266
  animation: none;
267
267
  }
268
+ .progress.indeterminate .bar {
269
+ width: 100%;
270
+ opacity: 0.35;
271
+ }
268
272
  }
269
273
  </style>
@@ -2,6 +2,8 @@
2
2
  import type { HTMLAttributes } from 'svelte/elements';
3
3
  // Shimmering placeholder bar(s) standing in for content that is still
4
4
  // loading. Decorative only — the surrounding region should carry aria-busy.
5
+ // Bars are tinted from --text so they read on any surface; the sweep is
6
+ // viewport-anchored so every bar on screen shimmers in sync.
5
7
  type Own = {
6
8
  width?: string;
7
9
  height?: string;
@@ -46,24 +48,28 @@
46
48
  max-width: 100%;
47
49
  }
48
50
  .skeleton-bar {
51
+ --sk-base: var(--skeleton, color-mix(in srgb, var(--text) 7%, transparent));
52
+ --sk-hi: var(--skeleton-highlight, color-mix(in srgb, var(--text) 13%, transparent));
49
53
  display: block;
50
54
  width: 100%;
51
- background: linear-gradient(90deg, var(--bg-elevated-2) 25%, var(--bg-elevated) 50%, var(--bg-elevated-2) 75%);
52
- background-size: 200% 100%;
53
- animation: tsu-shimmer 1.4s ease-in-out infinite;
55
+ background: linear-gradient(100deg, var(--sk-base) 42%, var(--sk-hi) 50%, var(--sk-base) 58%) var(--sk-base);
56
+ background-size: 200vw 100%;
57
+ background-attachment: fixed;
58
+ animation: tsu-shimmer 1.8s linear infinite;
54
59
  }
55
60
  @keyframes tsu-shimmer {
56
- from {
57
- background-position: 200% 0;
61
+ 0% {
62
+ background-position: 100% 0;
58
63
  }
59
- to {
60
- background-position: -200% 0;
64
+ 75%,
65
+ 100% {
66
+ background-position: 0% 0;
61
67
  }
62
68
  }
63
69
  @media (prefers-reduced-motion: reduce) {
64
70
  .skeleton-bar {
65
71
  animation: none;
66
- background: var(--bg-elevated-2);
72
+ background: var(--sk-base);
67
73
  }
68
74
  }
69
75
  </style>
@@ -1,15 +1,46 @@
1
+ <script lang="ts" module>
2
+ export type SpinnerVariant =
3
+ | 'icon'
4
+ | 'arc'
5
+ | 'dots'
6
+ | 'orbit'
7
+ | 'pulse'
8
+ | 'bars'
9
+ | 'grid'
10
+ | 'clock'
11
+ | 'bounce';
12
+
13
+ // Inner spans per variant; `.in` wraps the flex/grid ones in an absolute
14
+ // layer so the root keeps the same inline baseline as the others.
15
+ const PARTS: Record<Exclude<SpinnerVariant, 'icon'>, { count: number; wrap: boolean }> = {
16
+ arc: { count: 0, wrap: false },
17
+ dots: { count: 3, wrap: true },
18
+ orbit: { count: 2, wrap: false },
19
+ pulse: { count: 1, wrap: false },
20
+ bars: { count: 4, wrap: true },
21
+ grid: { count: 9, wrap: true },
22
+ clock: { count: 0, wrap: false },
23
+ bounce: { count: 2, wrap: false }
24
+ };
25
+ </script>
26
+
1
27
  <script lang="ts">
2
- // Inline loading spinner — a spinning `loader` glyph. Tracks the surrounding
3
- // text size by default (1em), or pass an explicit pixel `size`. The spin
4
- // animation is owned by Icon and is halted under prefers-reduced-motion, so
5
- // motion-sensitive users see a static glyph instead.
28
+ // Inline loading indicator. `icon` (default) spins the `loader` glyph; the
29
+ // other variants are pure-CSS pictograms drawn in currentColor inside a 1em
30
+ // box. Every variant's base style is a meaningful static pose, which is what
31
+ // shows under prefers-reduced-motion. `--spinner-speed` scales the tempo.
32
+ // `clock` and `bounce` read best at 20px and up.
6
33
  import Icon from './Icon.svelte';
7
34
 
8
35
  let {
36
+ variant = 'icon',
9
37
  size,
10
38
  label = 'Loading',
39
+ class: klass = '',
40
+ style: styleProp = '',
11
41
  ...rest
12
42
  }: {
43
+ variant?: SpinnerVariant;
13
44
  /** Explicit pixel size. Omit to track the surrounding text (1em). */
14
45
  size?: number;
15
46
  /** AT label announcing the busy state. Set to undefined to make the
@@ -19,6 +50,434 @@
19
50
  style?: string;
20
51
  [key: string]: unknown;
21
52
  } = $props();
53
+
54
+ const parts = $derived(variant === 'icon' ? undefined : PARTS[variant]);
22
55
  </script>
23
56
 
24
- <Icon name="loader" spin {size} {label} data-tsu="Spinner" {...rest} />
57
+ {#if !parts}
58
+ <Icon name="loader" spin {size} {label} data-tsu="Spinner" class={klass} style={styleProp} {...rest} />
59
+ {:else}
60
+ <span
61
+ data-tsu="Spinner"
62
+ class="spinner v-{variant} {klass}"
63
+ style="{size ? `font-size: ${size}px;` : ''}{styleProp}"
64
+ role={label ? 'img' : undefined}
65
+ aria-label={label}
66
+ aria-hidden={label ? undefined : 'true'}
67
+ {...rest}
68
+ >
69
+ {#if parts.wrap}
70
+ <span class="in">
71
+ {#each { length: parts.count } as _, i (i)}<span></span>{/each}
72
+ </span>
73
+ {:else}
74
+ {#each { length: parts.count } as _, i (i)}<span></span>{/each}
75
+ {/if}
76
+ </span>
77
+ {/if}
78
+
79
+ <style>
80
+ @property --tsu-spin-arc {
81
+ syntax: '<angle>';
82
+ inherits: false;
83
+ initial-value: 100deg;
84
+ }
85
+
86
+ .spinner {
87
+ --sw: max(1.5px, 0.11em);
88
+ --t: calc(1s * var(--spinner-speed, 1));
89
+ position: relative;
90
+ display: inline-block;
91
+ flex: none;
92
+ width: 1em;
93
+ height: 1em;
94
+ vertical-align: -0.125em;
95
+ color: inherit;
96
+ }
97
+ .spinner span,
98
+ .spinner::before,
99
+ .spinner::after {
100
+ box-sizing: border-box;
101
+ }
102
+ .in {
103
+ position: absolute;
104
+ inset: 0;
105
+ }
106
+
107
+ .v-arc::before,
108
+ .v-arc::after {
109
+ content: '';
110
+ position: absolute;
111
+ inset: 0;
112
+ border-radius: 50%;
113
+ background: currentColor;
114
+ -webkit-mask: radial-gradient(
115
+ farthest-side,
116
+ transparent calc(100% - var(--sw)),
117
+ #000 calc(100% - var(--sw) + 0.5px)
118
+ );
119
+ mask: radial-gradient(
120
+ farthest-side,
121
+ transparent calc(100% - var(--sw)),
122
+ #000 calc(100% - var(--sw) + 0.5px)
123
+ );
124
+ }
125
+ .v-arc::before {
126
+ opacity: 0.18;
127
+ }
128
+ .v-arc::after {
129
+ --tsu-spin-arc: 100deg;
130
+ -webkit-mask:
131
+ radial-gradient(farthest-side, transparent calc(100% - var(--sw)), #000 calc(100% - var(--sw) + 0.5px)),
132
+ conic-gradient(#000 var(--tsu-spin-arc), transparent 0);
133
+ -webkit-mask-composite: source-in;
134
+ mask:
135
+ radial-gradient(farthest-side, transparent calc(100% - var(--sw)), #000 calc(100% - var(--sw) + 0.5px))
136
+ intersect,
137
+ conic-gradient(#000 var(--tsu-spin-arc), transparent 0);
138
+ mask-composite: intersect;
139
+ animation:
140
+ tsu-spin-arc calc(var(--t) * 1.4) cubic-bezier(0.5, 0, 0.5, 1) infinite,
141
+ tsu-spin-rot calc(var(--t) * 1.4) linear infinite;
142
+ }
143
+ @keyframes tsu-spin-arc {
144
+ 0% {
145
+ --tsu-spin-arc: 20deg;
146
+ }
147
+ 50% {
148
+ --tsu-spin-arc: 280deg;
149
+ }
150
+ 100% {
151
+ --tsu-spin-arc: 20deg;
152
+ }
153
+ }
154
+ @keyframes tsu-spin-rot {
155
+ 0% {
156
+ transform: rotate(0deg);
157
+ }
158
+ 50% {
159
+ transform: rotate(160deg);
160
+ }
161
+ 100% {
162
+ transform: rotate(720deg);
163
+ }
164
+ }
165
+
166
+ .v-dots .in {
167
+ display: flex;
168
+ align-items: center;
169
+ justify-content: space-between;
170
+ padding: 0 0.04em;
171
+ }
172
+ .v-dots .in span {
173
+ width: 0.24em;
174
+ height: 0.24em;
175
+ border-radius: 50%;
176
+ background: currentColor;
177
+ animation: tsu-spin-dot calc(var(--t) * 1.1) cubic-bezier(0.45, 0, 0.55, 1) infinite;
178
+ }
179
+ .v-dots .in span:nth-child(1) {
180
+ opacity: 1;
181
+ }
182
+ .v-dots .in span:nth-child(2) {
183
+ opacity: 0.6;
184
+ animation-delay: calc(var(--t) * 0.14);
185
+ }
186
+ .v-dots .in span:nth-child(3) {
187
+ opacity: 0.32;
188
+ animation-delay: calc(var(--t) * 0.28);
189
+ }
190
+ @keyframes tsu-spin-dot {
191
+ 0%,
192
+ 60%,
193
+ 100% {
194
+ transform: translateY(0);
195
+ opacity: 0.32;
196
+ }
197
+ 25% {
198
+ transform: translateY(-0.22em);
199
+ opacity: 1;
200
+ }
201
+ }
202
+
203
+ .v-orbit::before {
204
+ content: '';
205
+ position: absolute;
206
+ inset: 0.08em;
207
+ border-radius: 50%;
208
+ border: max(1px, 0.06em) solid currentColor;
209
+ opacity: 0.22;
210
+ }
211
+ .v-orbit > span:nth-child(1) {
212
+ position: absolute;
213
+ left: 50%;
214
+ top: 50%;
215
+ width: 0.3em;
216
+ height: 0.3em;
217
+ margin: -0.15em;
218
+ border-radius: 50%;
219
+ background: currentColor;
220
+ animation: tsu-spin-core calc(var(--t) * 1.2) ease-in-out infinite;
221
+ }
222
+ .v-orbit > span:nth-child(2) {
223
+ position: absolute;
224
+ inset: 0;
225
+ transform: rotate(45deg);
226
+ animation: tsu-spin-orbit calc(var(--t) * 1.2) cubic-bezier(0.6, 0.15, 0.4, 0.85) infinite;
227
+ }
228
+ .v-orbit > span:nth-child(2)::before {
229
+ content: '';
230
+ position: absolute;
231
+ left: 50%;
232
+ top: 0;
233
+ width: 0.24em;
234
+ height: 0.24em;
235
+ margin-left: -0.12em;
236
+ border-radius: 50%;
237
+ background: currentColor;
238
+ }
239
+ @keyframes tsu-spin-orbit {
240
+ from {
241
+ transform: rotate(45deg);
242
+ }
243
+ to {
244
+ transform: rotate(405deg);
245
+ }
246
+ }
247
+ @keyframes tsu-spin-core {
248
+ 0%,
249
+ 100% {
250
+ transform: scale(1);
251
+ }
252
+ 50% {
253
+ transform: scale(0.7);
254
+ }
255
+ }
256
+
257
+ .v-pulse > span {
258
+ position: absolute;
259
+ left: 50%;
260
+ top: 50%;
261
+ width: 0.3em;
262
+ height: 0.3em;
263
+ margin: -0.15em;
264
+ border-radius: 50%;
265
+ background: currentColor;
266
+ }
267
+ .v-pulse::before,
268
+ .v-pulse::after {
269
+ content: '';
270
+ position: absolute;
271
+ inset: 0;
272
+ border-radius: 50%;
273
+ border: var(--sw) solid currentColor;
274
+ transform: scale(0.72);
275
+ opacity: 0.45;
276
+ animation: tsu-spin-ripple calc(var(--t) * 1.6) cubic-bezier(0.2, 0.6, 0.35, 1) infinite;
277
+ }
278
+ .v-pulse::after {
279
+ animation-delay: calc(var(--t) * -0.8);
280
+ }
281
+ @keyframes tsu-spin-ripple {
282
+ 0% {
283
+ transform: scale(0.3);
284
+ opacity: 1;
285
+ }
286
+ 100% {
287
+ transform: scale(1);
288
+ opacity: 0;
289
+ }
290
+ }
291
+
292
+ .v-bars .in {
293
+ display: flex;
294
+ align-items: center;
295
+ justify-content: space-between;
296
+ padding: 0.06em 0.08em;
297
+ }
298
+ .v-bars .in span {
299
+ width: 0.13em;
300
+ height: 100%;
301
+ border-radius: 0.1em;
302
+ background: currentColor;
303
+ transform-origin: 50% 50%;
304
+ animation: tsu-spin-bar var(--t) ease-in-out infinite;
305
+ }
306
+ .v-bars .in span:nth-child(1) {
307
+ transform: scaleY(0.4);
308
+ animation-delay: calc(var(--t) * -0.9);
309
+ }
310
+ .v-bars .in span:nth-child(2) {
311
+ transform: scaleY(0.85);
312
+ animation-delay: calc(var(--t) * -0.6);
313
+ }
314
+ .v-bars .in span:nth-child(3) {
315
+ transform: scaleY(0.6);
316
+ animation-delay: calc(var(--t) * -0.3);
317
+ }
318
+ .v-bars .in span:nth-child(4) {
319
+ transform: scaleY(1);
320
+ animation-delay: calc(var(--t) * -0.75);
321
+ }
322
+ @keyframes tsu-spin-bar {
323
+ 0%,
324
+ 100% {
325
+ transform: scaleY(0.3);
326
+ }
327
+ 50% {
328
+ transform: scaleY(1);
329
+ }
330
+ }
331
+
332
+ .v-grid .in {
333
+ display: grid;
334
+ grid-template-columns: repeat(3, 1fr);
335
+ place-items: center;
336
+ padding: 0.04em;
337
+ }
338
+ .v-grid .in span {
339
+ width: 0.2em;
340
+ height: 0.2em;
341
+ border-radius: 0.05em;
342
+ background: currentColor;
343
+ opacity: calc(1 - var(--d) * 0.18);
344
+ animation: tsu-spin-cell calc(var(--t) * 1.2) ease-in-out infinite;
345
+ animation-delay: calc(var(--t) * 0.1 * var(--d) - var(--t) * 1.2);
346
+ }
347
+ .v-grid .in span:nth-child(1) {
348
+ --d: 0;
349
+ }
350
+ .v-grid .in span:nth-child(2),
351
+ .v-grid .in span:nth-child(4) {
352
+ --d: 1;
353
+ }
354
+ .v-grid .in span:nth-child(3),
355
+ .v-grid .in span:nth-child(5),
356
+ .v-grid .in span:nth-child(7) {
357
+ --d: 2;
358
+ }
359
+ .v-grid .in span:nth-child(6),
360
+ .v-grid .in span:nth-child(8) {
361
+ --d: 3;
362
+ }
363
+ .v-grid .in span:nth-child(9) {
364
+ --d: 4;
365
+ }
366
+ @keyframes tsu-spin-cell {
367
+ 0%,
368
+ 100% {
369
+ opacity: 0.2;
370
+ transform: scale(0.7);
371
+ }
372
+ 35% {
373
+ opacity: 1;
374
+ transform: scale(1);
375
+ }
376
+ }
377
+
378
+ .v-clock {
379
+ border-radius: 50%;
380
+ border: var(--sw) solid currentColor;
381
+ }
382
+ .v-clock::before,
383
+ .v-clock::after {
384
+ content: '';
385
+ position: absolute;
386
+ left: 50%;
387
+ bottom: 50%;
388
+ width: var(--sw);
389
+ margin-left: calc(var(--sw) / -2);
390
+ border-radius: var(--sw);
391
+ background: currentColor;
392
+ transform-origin: 50% 100%;
393
+ translate: 0 calc(var(--sw) / 2);
394
+ }
395
+ .v-clock::before {
396
+ height: 0.32em;
397
+ transform: rotate(0deg);
398
+ animation: tsu-spin-hand-m var(--t) linear infinite;
399
+ }
400
+ .v-clock::after {
401
+ height: 0.22em;
402
+ transform: rotate(90deg);
403
+ animation: tsu-spin-hand-h calc(var(--t) * 12) linear infinite;
404
+ }
405
+ @keyframes tsu-spin-hand-m {
406
+ from {
407
+ transform: rotate(0deg);
408
+ }
409
+ to {
410
+ transform: rotate(360deg);
411
+ }
412
+ }
413
+ @keyframes tsu-spin-hand-h {
414
+ from {
415
+ transform: rotate(90deg);
416
+ }
417
+ to {
418
+ transform: rotate(450deg);
419
+ }
420
+ }
421
+
422
+ .v-bounce > span:nth-child(1) {
423
+ position: absolute;
424
+ left: 50%;
425
+ bottom: 0.16em;
426
+ width: 0.34em;
427
+ height: 0.34em;
428
+ margin-left: -0.17em;
429
+ border-radius: 50%;
430
+ background: currentColor;
431
+ transform-origin: 50% 100%;
432
+ animation: tsu-spin-ball calc(var(--t) * 0.9) infinite;
433
+ }
434
+ .v-bounce > span:nth-child(2) {
435
+ position: absolute;
436
+ left: 50%;
437
+ bottom: 0.06em;
438
+ width: 0.46em;
439
+ height: 0.08em;
440
+ margin-left: -0.23em;
441
+ border-radius: 50%;
442
+ background: currentColor;
443
+ opacity: 0.3;
444
+ animation: tsu-spin-shadow calc(var(--t) * 0.9) infinite;
445
+ }
446
+ @keyframes tsu-spin-ball {
447
+ 0% {
448
+ transform: translateY(0) scale(1.18, 0.82);
449
+ animation-timing-function: cubic-bezier(0.2, 0.7, 0.4, 1);
450
+ }
451
+ 12% {
452
+ transform: translateY(0) scale(1, 1);
453
+ animation-timing-function: cubic-bezier(0.2, 0.7, 0.4, 1);
454
+ }
455
+ 50% {
456
+ transform: translateY(-0.5em) scale(0.95, 1.05);
457
+ animation-timing-function: cubic-bezier(0.6, 0, 0.8, 0.3);
458
+ }
459
+ 100% {
460
+ transform: translateY(0) scale(1.18, 0.82);
461
+ }
462
+ }
463
+ @keyframes tsu-spin-shadow {
464
+ 0%,
465
+ 100% {
466
+ transform: scaleX(1);
467
+ opacity: 0.35;
468
+ }
469
+ 50% {
470
+ transform: scaleX(0.5);
471
+ opacity: 0.12;
472
+ }
473
+ }
474
+
475
+ @media (prefers-reduced-motion: reduce) {
476
+ .spinner.spinner,
477
+ .spinner.spinner span,
478
+ .spinner.spinner::before,
479
+ .spinner.spinner::after {
480
+ animation: none;
481
+ }
482
+ }
483
+ </style>
@@ -1,4 +1,6 @@
1
+ export type SpinnerVariant = 'icon' | 'arc' | 'dots' | 'orbit' | 'pulse' | 'bars' | 'grid' | 'clock' | 'bounce';
1
2
  type $$ComponentProps = {
3
+ variant?: SpinnerVariant;
2
4
  /** Explicit pixel size. Omit to track the surrounding text (1em). */
3
5
  size?: number;
4
6
  /** AT label announcing the busy state. Set to undefined to make the
package/dist/index.d.ts CHANGED
@@ -25,6 +25,7 @@ export type { SelectOption } from './components/atoms/Select.svelte';
25
25
  export { default as Select } from './components/atoms/Select.svelte';
26
26
  export { default as Skeleton } from './components/atoms/Skeleton.svelte';
27
27
  export { default as Slider } from './components/atoms/Slider.svelte';
28
+ export type { SpinnerVariant } from './components/atoms/Spinner.svelte';
28
29
  export { default as Spinner } from './components/atoms/Spinner.svelte';
29
30
  export { default as Swatch } from './components/atoms/Swatch.svelte';
30
31
  export { default as Switch } from './components/atoms/Switch.svelte';
@@ -114,7 +115,9 @@ export * as filterQuery from './query';
114
115
  export { type AndNode, activeToken, compilePredicate, defaultOperator, type ExprNode, type FieldDef, type FieldType, type FilterNode, filters, findField, freeText, type LeafNode, type NotNode, OPERATORS, type Operator, type OperatorId, type OrNode, operatorByCode, operatorById, operatorsFor, parse, type Query, type QueryNode, resolveValues, type Schema, type Suggestion, type SuggestKind, type SuggestState, serialize, serializeFilter, singleQuery, suggest, suggestSingle, type TextNode, toSql, type ValueContext, type ValueOption, type ValueProvider, walk, } from './query';
115
116
  export { type OptionSection, sectionOptions } from './select-options';
116
117
  export type { ControlSize } from './size';
118
+ export { playSound, type SoundMuted, type SoundName } from './sound';
117
119
  export { fontScale, SCALE_LEVELS, type ScaleLevel } from './stores/fontscale.svelte';
120
+ export { sound } from './stores/sound.svelte';
118
121
  export { type Mode, THEMES, theme } from './stores/theme.svelte';
119
122
  export { type Toast, type ToastAction, type ToastOptions, type ToastTone, type ToastToneInput, toasts, } from './stores/toast.svelte';
120
123
  export { AUTO_THEME, chooseTheme, DEFAULT_THEME_PREFERENCE, pickerValue, preferenceFrom, resolveTheme, type SlotOf, type ThemeChoice, type ThemePreference, type ThemeSlot, } from './theme-mode';
package/dist/index.js CHANGED
@@ -2,7 +2,7 @@
2
2
  //
3
3
  // Styles are NOT auto-imported (so consumers control bundling). Import once at
4
4
  // your app root:
5
- // import '@dorsk/tsumikit/styles/app.css';
5
+ // import './styles/app.css';
6
6
  // then use the components below.
7
7
  export { artworkGradient, artworkHue, initials } from './artwork';
8
8
  export { autoresize } from './autoresize';
@@ -123,8 +123,10 @@ export { describeMediaTime, formatMediaTime, isLiveDuration } from './media-time
123
123
  export * as filterQuery from './query';
124
124
  export { activeToken, compilePredicate, defaultOperator, filters, findField, freeText, OPERATORS, operatorByCode, operatorById, operatorsFor, parse, resolveValues, serialize, serializeFilter, singleQuery, suggest, suggestSingle, toSql, walk, } from './query';
125
125
  export { sectionOptions } from './select-options';
126
+ export { playSound } from './sound';
126
127
  export { fontScale, SCALE_LEVELS } from './stores/fontscale.svelte';
127
128
  // ---- stores / actions ----
129
+ export { sound } from './stores/sound.svelte';
128
130
  export { THEMES, theme } from './stores/theme.svelte';
129
131
  export { toasts, } from './stores/toast.svelte';
130
132
  export { AUTO_THEME, chooseTheme, DEFAULT_THEME_PREFERENCE, pickerValue, preferenceFrom, resolveTheme, } from './theme-mode';
@@ -0,0 +1,3 @@
1
+ import type { SoundName } from './stores/sound.svelte';
2
+ export declare function unlock(): void;
3
+ export declare function play(name: SoundName, volume: number): void;
@@ -0,0 +1,86 @@
1
+ const PALETTE = {
2
+ click: [
3
+ ['n', 3200, 0, 0, 0.012, 0.25],
4
+ ['sine', 620, 380, 0, 0.04, 0.18],
5
+ ],
6
+ 'toggle-on': [
7
+ ['n', 3000, 0, 0, 0.008, 0.3],
8
+ ['triangle', 660, 880, 0.045, 0.05, 0.3],
9
+ ],
10
+ 'toggle-off': [
11
+ ['n', 3000, 0, 0, 0.008, 0.3],
12
+ ['triangle', 880, 620, 0.045, 0.05, 0.3],
13
+ ],
14
+ open: [['sine', 420, 760, 0, 0.09, 0.25]],
15
+ close: [['sine', 700, 380, 0, 0.08, 0.25]],
16
+ success: [
17
+ ['sine', 784, 784, 0, 0.07, 0.25],
18
+ ['sine', 1175, 1175, 0.06, 0.1, 0.22],
19
+ ],
20
+ error: [
21
+ ['triangle', 330, 300, 0, 0.07, 0.28],
22
+ ['triangle', 262, 240, 0.075, 0.1, 0.28],
23
+ ],
24
+ tick: [['n', 5200, 0, 0, 0.006, 0.25]],
25
+ };
26
+ let ctx;
27
+ let out;
28
+ let noise;
29
+ function context() {
30
+ ctx ??= new AudioContext();
31
+ if (ctx.state === 'suspended')
32
+ ctx.resume().catch(() => { });
33
+ return ctx;
34
+ }
35
+ // Call synchronously inside a user gesture so Safari lets the context start.
36
+ export function unlock() {
37
+ context();
38
+ }
39
+ export function play(name, volume) {
40
+ const voices = PALETTE[name];
41
+ if (!voices)
42
+ return;
43
+ const c = context();
44
+ if (!out) {
45
+ out = c.createGain();
46
+ out.connect(c.destination);
47
+ }
48
+ out.gain.value = volume;
49
+ const t = c.currentTime + 0.005;
50
+ for (const [wave, f0, f1, at, d, g] of voices) {
51
+ const env = c.createGain();
52
+ const s = t + at;
53
+ env.gain.setValueAtTime(0.0001, s);
54
+ env.gain.exponentialRampToValueAtTime(g, s + 0.003);
55
+ env.gain.exponentialRampToValueAtTime(0.0001, s + d);
56
+ env.connect(out);
57
+ let src;
58
+ if (wave === 'n') {
59
+ if (!noise) {
60
+ noise = c.createBuffer(1, c.sampleRate * 0.05, c.sampleRate);
61
+ const data = noise.getChannelData(0);
62
+ for (let i = 0; i < data.length; i++)
63
+ data[i] = Math.random() * 2 - 1;
64
+ }
65
+ const buf = c.createBufferSource();
66
+ buf.buffer = noise;
67
+ const bp = c.createBiquadFilter();
68
+ bp.type = 'bandpass';
69
+ bp.frequency.value = f0;
70
+ bp.Q.value = 1.2;
71
+ buf.connect(bp).connect(env);
72
+ src = buf;
73
+ }
74
+ else {
75
+ const osc = c.createOscillator();
76
+ osc.type = wave;
77
+ osc.frequency.setValueAtTime(f0, s);
78
+ osc.frequency.exponentialRampToValueAtTime(f1, s + d);
79
+ osc.connect(env);
80
+ src = osc;
81
+ }
82
+ src.start(s);
83
+ src.stop(s + d + 0.02);
84
+ src.onended = () => env.disconnect();
85
+ }
86
+ }
@@ -0,0 +1 @@
1
+ export { playSound, type SoundMuted, type SoundName } from './stores/sound.svelte';
package/dist/sound.js ADDED
@@ -0,0 +1,3 @@
1
+ // What components import to play a sound. No audio code lives here: the synth
2
+ // is a separate chunk that `playSound` imports on demand once sounds are on.
3
+ export { playSound } from './stores/sound.svelte';
@@ -0,0 +1,20 @@
1
+ export type SoundName = 'click' | 'toggle-on' | 'toggle-off' | 'open' | 'close' | 'success' | 'error' | 'tick';
2
+ export type SoundMuted = 'off' | 'reduced-motion' | null;
3
+ /**
4
+ * Play a UI sound if the user enabled sounds. `allow` is the per-component
5
+ * opt-out (`sound={false}`). Safe to call during SSR: it is a no-op there.
6
+ */
7
+ export declare function playSound(name: SoundName, allow?: boolean | undefined): void;
8
+ declare class Sound {
9
+ enabled: boolean;
10
+ volume: number;
11
+ constructor();
12
+ /** Why nothing would play right now, for a settings hint. */
13
+ get muted(): SoundMuted;
14
+ setEnabled(on: boolean): void;
15
+ setVolume(v: number): void;
16
+ play(name: SoundName): void;
17
+ private save;
18
+ }
19
+ export declare const sound: Sound;
20
+ export {};
@@ -0,0 +1,150 @@
1
+ import { browser } from '../env';
2
+ import { readStorage, writeStorage } from './storage';
3
+ // OPT-IN UI sound effects. Off by default and persisted; while off nothing is
4
+ // imported, no AudioContext exists and no listener is attached. The synth is a
5
+ // separate chunk loaded on first play (or eagerly when enabling, so a gesture
6
+ // listener can unlock audio for Safari).
7
+ //
8
+ // With `prefers-reduced-motion: reduce` only the feedback sounds (success,
9
+ // error) play: they carry information, the decorative ones are muted.
10
+ // Calls inside one frame are coalesced and the highest priority wins
11
+ // (error > success > open/close > toggle > click > tick); the same sound plays
12
+ // at most once per 50 ms (tick: 40 ms). Hidden tabs are silent.
13
+ const KEY = 'tsumikit-sound';
14
+ const DEFAULT_VOLUME = 0.5;
15
+ // Lowest to highest priority; the last two are feedback and survive reduced motion.
16
+ const ORDER = [
17
+ 'tick',
18
+ 'click',
19
+ 'toggle-on',
20
+ 'toggle-off',
21
+ 'open',
22
+ 'close',
23
+ 'success',
24
+ 'error',
25
+ ];
26
+ const GAP_MS = 50;
27
+ const TICK_GAP_MS = 40;
28
+ let synth = null;
29
+ let loading = null;
30
+ let armed = false;
31
+ let pending = null;
32
+ const last = new Map();
33
+ function clamp(v, fallback) {
34
+ return Number.isFinite(v) ? Math.min(1, Math.max(0, v)) : fallback;
35
+ }
36
+ function reducedMotion() {
37
+ return !!globalThis.matchMedia?.('(prefers-reduced-motion: reduce)').matches;
38
+ }
39
+ function load() {
40
+ loading ??= import('../sound-synth').then((m) => (synth = m));
41
+ return loading;
42
+ }
43
+ function unlock() {
44
+ if (!synth)
45
+ return;
46
+ synth.unlock();
47
+ listen(false);
48
+ }
49
+ function listen(on) {
50
+ if (armed === on)
51
+ return;
52
+ armed = on;
53
+ for (const type of ['pointerdown', 'keydown']) {
54
+ if (on)
55
+ addEventListener(type, unlock, { capture: true });
56
+ else
57
+ removeEventListener(type, unlock, { capture: true });
58
+ }
59
+ }
60
+ function flush() {
61
+ const name = pending;
62
+ pending = null;
63
+ if (!name || !sound.enabled)
64
+ return;
65
+ const now = performance.now();
66
+ if (now - (last.get(name) ?? -1e9) < (name === 'tick' ? TICK_GAP_MS : GAP_MS))
67
+ return;
68
+ last.set(name, now);
69
+ const volume = sound.volume;
70
+ if (synth)
71
+ synth.play(name, volume);
72
+ else
73
+ load().then((m) => m.play(name, volume), () => { });
74
+ }
75
+ /**
76
+ * Play a UI sound if the user enabled sounds. `allow` is the per-component
77
+ * opt-out (`sound={false}`). Safe to call during SSR: it is a no-op there.
78
+ */
79
+ export function playSound(name, allow = true) {
80
+ if (!browser || allow === false || !sound.enabled || sound.volume <= 0)
81
+ return;
82
+ if (document.hidden)
83
+ return;
84
+ if (navigator.userActivation && !navigator.userActivation.hasBeenActive)
85
+ return;
86
+ const rank = ORDER.indexOf(name);
87
+ if (rank < ORDER.length - 2 && reducedMotion())
88
+ return;
89
+ if (pending === null) {
90
+ requestAnimationFrame(flush);
91
+ pending = name;
92
+ }
93
+ else if (rank > ORDER.indexOf(pending))
94
+ pending = name;
95
+ }
96
+ class Sound {
97
+ enabled = $state(false);
98
+ volume = $state(DEFAULT_VOLUME);
99
+ constructor() {
100
+ if (!browser)
101
+ return;
102
+ const raw = readStorage(KEY);
103
+ if (raw) {
104
+ try {
105
+ const saved = JSON.parse(raw);
106
+ if (typeof saved?.enabled === 'boolean')
107
+ this.enabled = saved.enabled;
108
+ if (typeof saved?.volume === 'number')
109
+ this.volume = clamp(saved.volume, DEFAULT_VOLUME);
110
+ }
111
+ catch { }
112
+ }
113
+ if (this.enabled) {
114
+ load().catch(() => { });
115
+ listen(true);
116
+ }
117
+ }
118
+ /** Why nothing would play right now, for a settings hint. */
119
+ get muted() {
120
+ if (!this.enabled)
121
+ return 'off';
122
+ return reducedMotion() ? 'reduced-motion' : null;
123
+ }
124
+ setEnabled(on) {
125
+ this.enabled = on;
126
+ this.save();
127
+ if (!browser)
128
+ return;
129
+ if (on) {
130
+ load().catch(() => { });
131
+ listen(true);
132
+ unlock();
133
+ }
134
+ else
135
+ listen(false);
136
+ }
137
+ setVolume(v) {
138
+ this.volume = clamp(v, this.volume);
139
+ this.save();
140
+ }
141
+ play(name) {
142
+ playSound(name);
143
+ }
144
+ save() {
145
+ if (!browser)
146
+ return;
147
+ writeStorage(KEY, JSON.stringify({ enabled: this.enabled, volume: this.volume }));
148
+ }
149
+ }
150
+ export const sound = new Sound();
@@ -106,14 +106,31 @@ pre {
106
106
  }
107
107
  }
108
108
 
109
+ /* Reduced motion: fewer and gentler, not zero. Movement tokens go neutral and
110
+ transitions are limited to colour/opacity, so feedback still fades while
111
+ nothing moves. */
109
112
  @media (prefers-reduced-motion: reduce) {
113
+ :root {
114
+ --press-scale: 1;
115
+ --press-scale-sm: 1;
116
+ --press-scale-lg: 1;
117
+ --enter-scale: 1;
118
+ --tip-scale: 1;
119
+ --shift-xs: 0px;
120
+ --shift-sm: 0px;
121
+ --shift-md: 0px;
122
+ --ease-spring: var(--ease-out);
123
+ --ease-spring-bounce: var(--ease-out);
124
+ }
110
125
  *,
111
126
  *::before,
112
127
  *::after {
128
+ scroll-behavior: auto !important;
113
129
  animation-duration: 0.01ms !important;
114
130
  animation-iteration-count: 1 !important;
115
- transition-duration: 0.01ms !important;
116
- scroll-behavior: auto !important;
131
+ transition-property: opacity, color, background-color, border-color, outline-color, box-shadow, fill, stroke, text-decoration-color, visibility, display, overlay !important;
132
+ transition-duration: var(--dur-fast) !important;
133
+ transition-delay: 0ms !important;
117
134
  }
118
135
  }
119
136
 
@@ -123,6 +123,9 @@
123
123
  ballooning UI. Default 1 = no scaling. */
124
124
  --fs-scale: 1;
125
125
 
126
+ /* Type scale. 2xs is the floor (never go below 11px). 13 / 15 / 16 are
127
+ "UI small / UI / body": no more steps between them. */
128
+ --fs-2xs: calc(0.6875rem * var(--fs-scale)); /* 11 */
126
129
  --fs-xs: calc(0.75rem * var(--fs-scale)); /* 12 */
127
130
  --fs-sm: calc(0.8125rem * var(--fs-scale)); /* 13 */
128
131
  --fs-base: calc(0.9375rem * var(--fs-scale)); /* 15 */
@@ -130,18 +133,28 @@
130
133
  --fs-lg: calc(1.125rem * var(--fs-scale)); /* 18 */
131
134
  --fs-xl: calc(1.375rem * var(--fs-scale)); /* 22 */
132
135
  --fs-2xl: calc(1.75rem * var(--fs-scale)); /* 28 */
136
+ --fs-3xl: calc(2.25rem * var(--fs-scale)); /* 36 */
133
137
 
134
138
  --fw-normal: 400;
135
139
  --fw-medium: 500;
136
140
  --fw-semibold: 600;
137
141
  --fw-bold: 700;
138
142
 
143
+ /* Leading paired to the scale: none = single-line controls and glyphs,
144
+ snug = chips / captions / tooltips (2xs–sm), tight = fields and labels
145
+ (sm–md), normal = body prose (md–lg), heading = xl and up. */
146
+ --lh-none: 1;
147
+ --lh-heading: 1.2;
139
148
  --lh-tight: 1.25;
149
+ --lh-snug: 1.375;
140
150
  --lh-normal: 1.5;
141
151
 
142
152
  /* ---- Spacing (4px base) ---- */
143
153
  --sp-0: 0;
154
+ --sp-px: 1px;
155
+ --sp-0-5: 0.125rem;
144
156
  --sp-1: 0.25rem;
157
+ --sp-1-5: 0.375rem;
145
158
  --sp-2: 0.5rem;
146
159
  --sp-3: 0.75rem;
147
160
  --sp-4: 1rem;
@@ -152,6 +165,8 @@
152
165
  --sp-12: 3rem;
153
166
 
154
167
  /* ---- Radii ---- */
168
+ /* Nested corners stay concentric: inner = outer - (border + padding). */
169
+ --r-xs: 2px;
155
170
  --r-sm: 6px;
156
171
  --r-md: 10px;
157
172
  --r-lg: 14px;
@@ -170,16 +185,46 @@
170
185
  --control-height-default: 2.5rem;
171
186
  --control-height-large: 3rem;
172
187
  --control-height: 2.75rem;
173
- /* Keyboard focus indicator shared by every interactive atom. */
188
+ /* Per-tier control contract (sm 32 / md 40 / lg 48): height, label size,
189
+ side padding, icon size. Text buttons get roomier sides via --btn-px-*. */
190
+ --control-sm: var(--control-height-compact);
191
+ --control-md: var(--control-height-default);
192
+ --control-lg: var(--control-height-large);
193
+ --control-fs-sm: var(--fs-xs);
194
+ --control-fs-md: var(--fs-sm);
195
+ --control-fs-lg: var(--fs-base);
196
+ --control-px-sm: var(--sp-2);
197
+ --control-px-md: var(--sp-3);
198
+ --control-px-lg: var(--sp-4);
199
+ --control-icon-sm: var(--icon-sm);
200
+ --control-icon-md: var(--icon-md);
201
+ --control-icon-lg: var(--icon-lg);
202
+ --btn-px-sm: var(--sp-3);
203
+ --btn-px-md: var(--sp-4);
204
+ --btn-px-lg: var(--sp-5);
205
+ /* Chip-class heights (Badge, Switch track, Checkbox box) on the 4px grid. */
206
+ --chip-xs: 1rem;
207
+ --chip-sm: 1.25rem;
208
+ --chip-md: 1.5rem;
209
+ /* Icon glyph sizes follow the text scale, not the chrome. */
210
+ --icon-xs: calc(0.75rem * var(--fs-scale));
211
+ --icon-sm: calc(0.875rem * var(--fs-scale));
212
+ --icon-md: calc(1rem * var(--fs-scale));
213
+ --icon-lg: calc(1.25rem * var(--fs-scale));
214
+ --icon-xl: calc(1.5rem * var(--fs-scale));
215
+ --disabled-opacity: 0.45;
216
+ /* Keyboard focus indicator shared by every interactive atom. `-inset` is for
217
+ rows and clipped containers that cannot draw outside their box. */
218
+ --focus-ring-width: 2px;
174
219
  --focus-ring: 2px solid var(--accent);
175
- --focus-ring-offset: 1px;
220
+ --focus-ring-offset: 2px;
221
+ --focus-ring-offset-inset: calc(-1 * var(--focus-ring-width));
176
222
  /* Square box scale for icon-only controls (IconButton, Button `box`/`square`,
177
- SelectButton, Popover trigger, CopyButton, FileButton). md is the classic
178
- 2.25rem icon button; sm/lg reuse the control heights so a boxed control
179
- lines up with text controls in the same row. */
223
+ SelectButton, Popover trigger, CopyButton, FileButton). sm/md/lg match the
224
+ control heights so a boxed control lines up with text controls in a row. */
180
225
  --box-xs: 1.5rem;
181
226
  --box-sm: var(--control-height-compact);
182
- --box-md: 2.25rem;
227
+ --box-md: 2.5rem;
183
228
  --box-lg: var(--control-height-default);
184
229
  /* Minimum hit area on coarse pointers (WCAG 2.5.8); px so it never shrinks
185
230
  under a small root font-size. */
@@ -212,5 +257,43 @@
212
257
  --safe-left: env(safe-area-inset-left, 0px);
213
258
  --safe-right: env(safe-area-inset-right, 0px);
214
259
 
260
+ /* ---- Motion ---- */
215
261
  --ease: cubic-bezier(0.2, 0.8, 0.2, 1);
262
+ --ease-out: cubic-bezier(0.23, 1, 0.32, 1);
263
+ --ease-in-out: cubic-bezier(0.77, 0, 0.175, 1);
264
+ --ease-drawer: cubic-bezier(0.32, 0.72, 0, 1);
265
+ --ease-spring: var(--ease-out);
266
+ --ease-spring-bounce: cubic-bezier(0.34, 1.36, 0.64, 1);
267
+
268
+ --dur-instant: 0ms;
269
+ --dur-press: 100ms;
270
+ --dur-fast: 120ms;
271
+ --dur-base: 180ms;
272
+ --dur-slow: 240ms;
273
+ --dur-sheet: 280ms;
274
+ --dur-exit-fast: 80ms;
275
+ --dur-exit: 140ms;
276
+ --dur-exit-slow: 200ms;
277
+ --dur-spring: 250ms;
278
+ --dur-spring-bounce: 330ms;
279
+
280
+ --press-scale: 0.97;
281
+ --press-scale-sm: 0.92;
282
+ --press-scale-lg: 0.985;
283
+ --enter-scale: 0.96;
284
+ --tip-scale: 0.97;
285
+ --shift-xs: 2px;
286
+ --shift-sm: 4px;
287
+ --shift-md: 8px;
288
+ }
289
+
290
+ @supports (animation-timing-function: linear(0, 1)) {
291
+ :root {
292
+ /* stiffness 1200, damping 56, mass 1: zeta 0.81, 1.1% overshoot, settles 250ms */
293
+ /* prettier-ignore */
294
+ --ease-spring: linear(0, 0.007 1.2%, 0.023 2.4%, 0.055 4%, 0.098 5.6%, 0.188 8.4%, 0.53 18%, 0.629 21.2%, 0.704 24%, 0.778 27.2%, 0.839 30.4%, 0.887 33.6%, 0.928 37.2%, 0.959 40.8%, 0.982 44.8%, 0.998 49.2%, 1.007 54.4%, 1.011 64.4%, 1);
295
+ /* stiffness 1000, damping 40, mass 1: zeta 0.63, 7.4% overshoot, settles 330ms */
296
+ /* prettier-ignore */
297
+ --ease-spring-bounce: linear(0, 0.01 1.2%, 0.033 2.4%, 0.067 3.6%, 0.122 5.2%, 0.244 7.9%, 0.502 13%, 0.618 15.5%, 0.735 18.2%, 0.824 20.6%, 0.898 23%, 0.957 25.5%, 1.007 28.2%, 1.041 30.9%, 1.067 34.8%, 1.073 39.4%, 1.064 44.2%, 1.016 58.2%, 1 66.4%, 0.995 77.3%, 1);
298
+ }
216
299
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dorsk/tsumikit",
3
- "version": "0.73.2",
3
+ "version": "0.74.0",
4
4
  "description": "Minimal, dependency-free Svelte 5 + pure-CSS UI kit. Token-driven atoms, molecules & layouts with theming out of the box.",
5
5
  "type": "module",
6
6
  "license": "MIT",