@dorsk/tsumikit 0.79.0 → 0.80.1

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 (53) hide show
  1. package/dist/components/atoms/Artwork.svelte +14 -10
  2. package/dist/components/atoms/Artwork.svelte.d.ts +1 -1
  3. package/dist/components/atoms/Badge.svelte +1 -4
  4. package/dist/components/atoms/Button.svelte +303 -9
  5. package/dist/components/atoms/Button.svelte.d.ts +4 -1
  6. package/dist/components/atoms/Card.svelte +142 -0
  7. package/dist/components/atoms/Checkbox.svelte +177 -1
  8. package/dist/components/atoms/Input.svelte +35 -0
  9. package/dist/components/atoms/Link.svelte +61 -0
  10. package/dist/components/atoms/Progress.svelte +74 -1
  11. package/dist/components/atoms/Progress.svelte.d.ts +1 -0
  12. package/dist/components/atoms/Select.svelte +30 -0
  13. package/dist/components/atoms/Skeleton.svelte +54 -3
  14. package/dist/components/atoms/Skeleton.svelte.d.ts +1 -0
  15. package/dist/components/atoms/Spinner.svelte +152 -3
  16. package/dist/components/atoms/Spinner.svelte.d.ts +1 -1
  17. package/dist/components/atoms/Switch.svelte +135 -0
  18. package/dist/components/atoms/Textarea.svelte +36 -0
  19. package/dist/components/atoms/Thinking.svelte +130 -0
  20. package/dist/components/atoms/Thinking.svelte.d.ts +15 -0
  21. package/dist/components/molecules/CopyButton.svelte +146 -5
  22. package/dist/components/molecules/Field.svelte +55 -1
  23. package/dist/components/molecules/FileButton.svelte +4 -2
  24. package/dist/components/molecules/InputGroup.svelte +1 -1
  25. package/dist/components/molecules/LookPicker.svelte +100 -0
  26. package/dist/components/molecules/LookPicker.svelte.d.ts +17 -0
  27. package/dist/components/molecules/MotionSettings.svelte +166 -0
  28. package/dist/components/molecules/MotionSettings.svelte.d.ts +18 -0
  29. package/dist/components/molecules/RadioGroup.svelte +61 -0
  30. package/dist/components/molecules/SectionHeader.svelte +2 -0
  31. package/dist/components/molecules/Tabs.svelte +157 -4
  32. package/dist/components/molecules/ThemePicker.svelte +8 -3
  33. package/dist/fx.d.ts +149 -0
  34. package/dist/fx.js +111 -0
  35. package/dist/fx.md +58 -0
  36. package/dist/index.d.ts +8 -2
  37. package/dist/index.js +8 -2
  38. package/dist/looks/README.md +101 -0
  39. package/dist/looks/depth.css +939 -0
  40. package/dist/looks/editorial.css +545 -0
  41. package/dist/looks/tactile.css +871 -0
  42. package/dist/markdown.js +47 -21
  43. package/dist/pointer.d.ts +3 -0
  44. package/dist/pointer.js +40 -0
  45. package/dist/stores/fx.svelte.d.ts +17 -0
  46. package/dist/stores/fx.svelte.js +64 -0
  47. package/dist/stores/theme.svelte.d.ts +8 -2
  48. package/dist/stores/theme.svelte.js +33 -7
  49. package/dist/styles/themes.css +249 -249
  50. package/dist/styles/tokens.css +4 -3
  51. package/dist/theme-mode.d.ts +31 -0
  52. package/dist/theme-mode.js +19 -0
  53. package/package.json +3 -2
@@ -44,8 +44,10 @@
44
44
 
45
45
  const field = getFieldContext();
46
46
  const isInvalid = $derived(invalid || !!field?.invalid);
47
+ let armed = $state(false);
47
48
 
48
49
  function change(e: Event & { currentTarget: EventTarget & HTMLInputElement }) {
50
+ armed = true;
49
51
  onchange?.(e);
50
52
  playSound(e.currentTarget.checked ? 'toggle-on' : 'toggle-off', sound);
51
53
  }
@@ -60,6 +62,7 @@
60
62
  class="checkbox {klass}"
61
63
  class:checkbox-sm={size === 'sm'}
62
64
  class:checkbox-lg={size === 'lg'}
65
+ class:armed
63
66
  data-tsu="Checkbox"
64
67
  >
65
68
  <input
@@ -72,7 +75,10 @@
72
75
  aria-describedby={ariaDescribedby ?? field?.describedBy}
73
76
  aria-invalid={ariaInvalid ?? (isInvalid ? 'true' : undefined)}
74
77
  />
75
- <span class="box" aria-hidden="true"></span>
78
+ <span class="box" aria-hidden="true">
79
+ <svg viewBox="0 0 20 20"><path class="tick" pathLength="1" d="M5.5 10.5l3 3 6-7" /></svg>
80
+ <span class="fx"></span>
81
+ </span>
76
82
  <span class="label-text" class:sr-only={labelHidden}>{label}</span>
77
83
  </label>
78
84
 
@@ -110,6 +116,7 @@
110
116
  border-radius: var(--r-sm);
111
117
  background: var(--control-fill);
112
118
  box-shadow: var(--control-shadow);
119
+ isolation: isolate;
113
120
  transition:
114
121
  background var(--dur-fast) var(--ease),
115
122
  border-color var(--dur-fast) var(--ease),
@@ -156,6 +163,175 @@
156
163
  opacity var(--dur-spring) var(--ease-spring),
157
164
  scale var(--dur-spring) var(--ease-spring);
158
165
  }
166
+ svg {
167
+ position: absolute;
168
+ inset: -1px;
169
+ width: calc(100% + 2px);
170
+ height: calc(100% + 2px);
171
+ overflow: visible;
172
+ fill: none;
173
+ stroke-linecap: round;
174
+ stroke-linejoin: round;
175
+ }
176
+ .tick {
177
+ stroke: var(--text-on-accent);
178
+ stroke-width: 2.25;
179
+ stroke-dasharray: 1 2;
180
+ stroke-dashoffset: 1.05;
181
+ transition: stroke-dashoffset var(--dur-exit-fast) var(--ease-out);
182
+ }
183
+ .fx {
184
+ position: absolute;
185
+ inset: 0;
186
+ z-index: -1;
187
+ font-size: var(--cb-box, var(--chip-sm));
188
+ pointer-events: none;
189
+ }
190
+ .fx::before,
191
+ .fx::after {
192
+ content: '';
193
+ position: absolute;
194
+ opacity: 0;
195
+ }
196
+
197
+ /* Effects follow the closest data-fx-check ancestor; draw is the default. */
198
+ .checkbox:not(:global([data-fx-check]:not([data-fx-check='draw'])) *),
199
+ :global([data-fx-check='draw']) .checkbox:not(:global([data-fx-check='draw'] [data-fx-check]:not([data-fx-check='draw'])) *),
200
+ :global([data-fx-check='pop']) .checkbox:not(:global([data-fx-check='pop'] [data-fx-check]:not([data-fx-check='pop'])) *),
201
+ :global([data-fx-check='confetti']) .checkbox:not(:global([data-fx-check='confetti'] [data-fx-check]:not([data-fx-check='confetti'])) *) {
202
+ & input:checked:not(:indeterminate) + .box::after {
203
+ opacity: 0;
204
+ transition: none;
205
+ }
206
+ & input:checked:not(:indeterminate) + .box .tick {
207
+ stroke-dashoffset: 0;
208
+ transition: stroke-dashoffset var(--dur-exit-slow) var(--ease-out) calc(var(--dur-fast) / 3);
209
+ }
210
+ }
211
+
212
+ :global([data-fx-check='pop']) .checkbox:not(:global([data-fx-check='pop'] [data-fx-check]:not([data-fx-check='pop'])) *) {
213
+ &.armed input:checked + .box {
214
+ animation: cb-pop var(--dur-spring) var(--ease-out);
215
+ }
216
+ }
217
+ @keyframes cb-pop {
218
+ 30% {
219
+ scale: 0.8;
220
+ }
221
+ 65% {
222
+ scale: 1.14;
223
+ }
224
+ }
225
+
226
+ :global([data-fx-check='confetti']) .checkbox:not(:global([data-fx-check='confetti'] [data-fx-check]:not([data-fx-check='confetti'])) *) {
227
+ & .fx::before,
228
+ & .fx::after {
229
+ inset: -0.85em;
230
+ border-radius: var(--r-pill);
231
+ }
232
+ & .fx::before {
233
+ background:
234
+ radial-gradient(circle at 50% 6%, var(--accent) 0 0.06em, transparent 0.08em),
235
+ radial-gradient(circle at 94% 50%, var(--info) 0 0.06em, transparent 0.08em),
236
+ radial-gradient(circle at 50% 94%, var(--accent) 0 0.06em, transparent 0.08em),
237
+ radial-gradient(circle at 6% 50%, var(--ok) 0 0.06em, transparent 0.08em);
238
+ }
239
+ & .fx::after {
240
+ background:
241
+ radial-gradient(circle at 81% 19%, var(--warn) 0 0.045em, transparent 0.065em),
242
+ radial-gradient(circle at 81% 81%, var(--accent) 0 0.045em, transparent 0.065em),
243
+ radial-gradient(circle at 19% 81%, var(--info) 0 0.045em, transparent 0.065em),
244
+ radial-gradient(circle at 19% 19%, var(--accent) 0 0.045em, transparent 0.065em);
245
+ }
246
+ &.armed input:checked:not(:indeterminate) + .box .fx::before {
247
+ animation: cb-burst var(--dur-spring) var(--ease);
248
+ }
249
+ &.armed input:checked:not(:indeterminate) + .box .fx::after {
250
+ animation: cb-burst-late var(--dur-spring) var(--ease);
251
+ }
252
+ }
253
+ @keyframes cb-burst {
254
+ 0% {
255
+ opacity: 0;
256
+ scale: 0.35;
257
+ }
258
+ 20%,
259
+ 60% {
260
+ opacity: 1;
261
+ }
262
+ 100% {
263
+ opacity: 0;
264
+ scale: 1.05;
265
+ rotate: 18deg;
266
+ }
267
+ }
268
+ @keyframes cb-burst-late {
269
+ 0% {
270
+ opacity: 0;
271
+ scale: 0.3;
272
+ }
273
+ 30%,
274
+ 70% {
275
+ opacity: 1;
276
+ }
277
+ 100% {
278
+ opacity: 0;
279
+ scale: 0.9;
280
+ rotate: -14deg;
281
+ }
282
+ }
283
+
284
+ /* Two bars, the short and long legs of the tick, fold flat into the dash. */
285
+ :global([data-fx-check='morph']) .checkbox:not(:global([data-fx-check='morph'] [data-fx-check]:not([data-fx-check='morph'])) *) {
286
+ & .box::after {
287
+ display: none;
288
+ }
289
+ & .fx {
290
+ z-index: 0;
291
+ }
292
+ & .fx::before,
293
+ & .fx::after {
294
+ left: 0;
295
+ top: 0;
296
+ height: 0.11em;
297
+ border-radius: var(--r-pill);
298
+ background: var(--text-on-accent);
299
+ scale: 0;
300
+ transition:
301
+ opacity var(--dur-fast) var(--ease),
302
+ translate var(--dur-spring) var(--ease-spring),
303
+ rotate var(--dur-spring) var(--ease-spring),
304
+ scale var(--dur-spring) var(--ease-spring);
305
+ }
306
+ & .fx::before {
307
+ width: 0.26em;
308
+ translate: 0.22em 0.545em;
309
+ rotate: 45deg;
310
+ }
311
+ & .fx::after {
312
+ width: 0.5em;
313
+ translate: 0.325em 0.445em;
314
+ rotate: -49deg;
315
+ }
316
+ & input:checked + .box .fx::before,
317
+ & input:checked + .box .fx::after {
318
+ opacity: 1;
319
+ scale: 1;
320
+ }
321
+ & input:indeterminate + .box .fx::before {
322
+ opacity: 1;
323
+ translate: 0.37em 0.445em;
324
+ rotate: 0deg;
325
+ scale: 1;
326
+ }
327
+ & input:indeterminate + .box .fx::after {
328
+ opacity: 1;
329
+ translate: 0.25em 0.445em;
330
+ rotate: 0deg;
331
+ scale: 1;
332
+ }
333
+ }
334
+
159
335
  input:focus-visible + .box {
160
336
  outline: var(--focus-ring);
161
337
  outline-offset: var(--focus-ring-offset);
@@ -106,6 +106,7 @@
106
106
  style:width={wrapped ? undefined : width}
107
107
  bind:value
108
108
  {...rest}
109
+ placeholder={rest.placeholder ?? (field ? ' ' : undefined)}
109
110
  disabled={isDisabled}
110
111
  onkeydown={onenter || onsubmit || onkeydown ? handleKeydown : undefined}
111
112
  id={id ?? field?.id}
@@ -158,6 +159,10 @@
158
159
  outline-offset: var(--focus-ring-offset);
159
160
  box-shadow: var(--control-shadow), var(--focus-glow);
160
161
  }
162
+ .input::placeholder {
163
+ color: var(--text-faint);
164
+ opacity: 1;
165
+ }
161
166
  .input:disabled {
162
167
  opacity: var(--disabled-opacity);
163
168
  cursor: not-allowed;
@@ -208,6 +213,36 @@
208
213
  .input[aria-invalid='true']:focus {
209
214
  border-color: var(--danger);
210
215
  }
216
+ .input {
217
+ --focus-tint: var(--accent);
218
+ }
219
+ .input[aria-invalid='true'] {
220
+ --focus-tint: var(--danger);
221
+ }
222
+ /* Focus bloom: a solid ring whose soft halo swells, then settles to a quieter glow. */
223
+ .input:not(.grouped):focus:not(:global([data-fx-focus='none']) *),
224
+ :global(:is([data-fx-focus='bloom'], [data-fx-focus='label'])) .input:not(.grouped):focus:not(:global(:is([data-fx-focus='bloom'], [data-fx-focus='label']) [data-fx-focus='none']) *) {
225
+ outline: none;
226
+ box-shadow:
227
+ var(--control-shadow),
228
+ 0 0 0 var(--focus-ring-width) var(--focus-tint),
229
+ 0 0 16px 2px color-mix(in srgb, var(--focus-tint) 24%, transparent);
230
+ animation: focus-bloom var(--dur-fast) var(--ease-out);
231
+ }
232
+ @keyframes focus-bloom {
233
+ 0% {
234
+ box-shadow:
235
+ var(--control-shadow),
236
+ 0 0 0 0 transparent,
237
+ 0 0 0 0 transparent;
238
+ }
239
+ 45% {
240
+ box-shadow:
241
+ var(--control-shadow),
242
+ 0 0 0 calc(var(--focus-ring-width) + 1px) var(--focus-tint),
243
+ 0 0 20px 4px color-mix(in srgb, var(--focus-tint) 32%, transparent);
244
+ }
245
+ }
211
246
  /* Inside an InputGroup the group draws the focus ring, dims the whole group
212
247
  and the overlaid adornments report their widths; the text stays clear of them. */
213
248
  .input.grouped {
@@ -77,10 +77,71 @@
77
77
  .underline-hover:hover {
78
78
  text-decoration: underline;
79
79
  }
80
+ .link:hover {
81
+ --link-on: 0%;
82
+ --link-draw: 100%;
83
+ --link-mark: color-mix(in srgb, currentColor 30%, transparent);
84
+ --link-deco: transparent;
85
+ --link-in: var(--dur-press);
86
+ --link-grow: var(--dur-fast);
87
+ --link-hold: var(--dur-instant);
88
+ }
80
89
  }
81
90
  .align-center {
82
91
  text-align: center;
83
92
  }
93
+
94
+ /* Hover, focus and press only raise these; each variant decides what they paint. */
95
+ .link:is(:focus-visible, :active) {
96
+ --link-on: 0%;
97
+ --link-draw: 100%;
98
+ --link-mark: color-mix(in srgb, currentColor 30%, transparent);
99
+ --link-deco: transparent;
100
+ --link-in: var(--dur-press);
101
+ --link-grow: var(--dur-fast);
102
+ --link-hold: var(--dur-instant);
103
+ }
104
+
105
+ /* Sweep line: the underline fills in from the left and leaves to the right. */
106
+ .link:not(.underline-none):not(:global([data-fx-link]:not([data-fx-link='sweep'])) *),
107
+ :global([data-fx-link='sweep']) .link:not(.underline-none):not(:global([data-fx-link='sweep'] [data-fx-link]:not([data-fx-link='sweep'])) *) {
108
+ --link-base: 0%;
109
+ text-decoration: none;
110
+ background-image:
111
+ linear-gradient(currentColor, currentColor),
112
+ linear-gradient(color-mix(in srgb, currentColor 30%, transparent), color-mix(in srgb, currentColor 30%, transparent));
113
+ background-repeat: no-repeat;
114
+ background-position: var(--link-on, 100%) 100%, 0 100%;
115
+ background-size: var(--link-draw, 0%) 1.5px, var(--link-base) 1px;
116
+ transition:
117
+ color var(--dur-fast) ease,
118
+ background-size var(--dur-fast) var(--ease-out);
119
+ &.underline-always {
120
+ --link-base: 100%;
121
+ }
122
+ }
123
+
124
+ /* Highlighter: a marker swipes in behind the words and fades on leave. */
125
+ :global([data-fx-link='highlight']) .link:not(.underline-none):not(:global([data-fx-link='highlight'] [data-fx-link]:not([data-fx-link='highlight'])) *) {
126
+ margin-inline: -0.18em;
127
+ padding-inline: 0.18em;
128
+ border-radius: 0.2em 0.35em 0.25em 0.4em;
129
+ background-image: linear-gradient(100deg, var(--link-mark), color-mix(in srgb, var(--link-mark) 70%, transparent));
130
+ background-repeat: no-repeat;
131
+ background-size: var(--link-draw, 0%) 100%;
132
+ text-decoration-color: var(--link-deco, color-mix(in srgb, currentColor 45%, transparent));
133
+ text-underline-offset: 0.2em;
134
+ transition:
135
+ color var(--dur-fast) ease,
136
+ text-decoration-color var(--dur-fast) ease,
137
+ --link-mark var(--link-in, var(--dur-exit)) ease,
138
+ background-size var(--link-grow, var(--dur-instant)) var(--ease-out) var(--link-hold, var(--dur-exit));
139
+ }
140
+ @property --link-mark {
141
+ syntax: '<color>';
142
+ inherits: false;
143
+ initial-value: transparent;
144
+ }
84
145
  @media (pointer: coarse) {
85
146
  .link {
86
147
  position: relative;
@@ -10,6 +10,9 @@
10
10
  // gradient — accent→teal fill (storage meter / library-health bars).
11
11
  // striped — diagonal stripes; animated while in-flight (unpacking).
12
12
  // indeterminate — explicit "scanning"/"importing" mode without a percentage.
13
+ // variant — `liquid`: two soft pulses flow through the track while
14
+ // indeterminate; `stripes`: a diagonal shimmer drifts over
15
+ // the fill.
13
16
  let {
14
17
  value,
15
18
  max = 100,
@@ -22,6 +25,7 @@
22
25
  shrink = true,
23
26
  block = false,
24
27
  indeterminate: indeterminateProp = false,
28
+ variant = 'default',
25
29
  markers = [],
26
30
  toneAt,
27
31
  color,
@@ -51,6 +55,7 @@
51
55
  striped?: boolean;
52
56
  // Force indeterminate mode even when a `value` is supplied.
53
57
  indeterminate?: boolean;
58
+ variant?: 'default' | 'liquid' | 'stripes';
54
59
  /** Thin vertical lines at `at / max` (caps, thresholds). */
55
60
  markers?: { at: number; label?: string; tone?: Tone }[];
56
61
  /** Percent thresholds that auto-pick warn/danger; an explicit `tone` other
@@ -80,7 +85,7 @@
80
85
  {#snippet track()}
81
86
  <div
82
87
  data-tsu="Progress"
83
- class="progress tone-{toneClass} size-{size} {klass}"
88
+ class="progress tone-{toneClass} size-{size} v-{variant} {klass}"
84
89
  class:grow={grow}
85
90
  class:no-shrink={!shrink}
86
91
  class:block={block}
@@ -272,4 +277,72 @@
272
277
  opacity: 0.35;
273
278
  }
274
279
  }
280
+ .progress.v-liquid.indeterminate .bar {
281
+ animation: none;
282
+ opacity: 0;
283
+ }
284
+ .progress.v-liquid.indeterminate::before,
285
+ .progress.v-liquid.indeterminate::after {
286
+ content: '';
287
+ position: absolute;
288
+ inset: 0;
289
+ width: 55%;
290
+ border-radius: inherit;
291
+ background: linear-gradient(90deg, transparent, var(--fill, var(--accent)) 35% 65%, transparent);
292
+ transform: translateX(-100%);
293
+ animation: tsu-progress-liquid 1.7s var(--ease-drawer) infinite;
294
+ }
295
+ .progress.v-liquid.indeterminate::after {
296
+ width: 35%;
297
+ opacity: 0.55;
298
+ animation: tsu-progress-liquid-2 1.7s var(--ease-in-out) -0.7s infinite;
299
+ }
300
+ @keyframes tsu-progress-liquid {
301
+ to {
302
+ transform: translateX(282%);
303
+ }
304
+ }
305
+ @keyframes tsu-progress-liquid-2 {
306
+ to {
307
+ transform: translateX(386%);
308
+ }
309
+ }
310
+ .progress.v-stripes .bar {
311
+ position: relative;
312
+ overflow: hidden;
313
+ }
314
+ .progress.v-stripes .bar::after {
315
+ content: '';
316
+ position: absolute;
317
+ inset: 0 -1.5rem 0 0;
318
+ background: repeating-linear-gradient(
319
+ 135deg,
320
+ transparent 0 0.5rem,
321
+ color-mix(in srgb, var(--text-on-accent) 28%, transparent) 0.5rem 1rem
322
+ );
323
+ animation: tsu-progress-stripes 0.7s linear infinite;
324
+ }
325
+ @keyframes tsu-progress-stripes {
326
+ from {
327
+ transform: translateX(-1.4142rem);
328
+ }
329
+ to {
330
+ transform: translateX(0);
331
+ }
332
+ }
333
+ @media (prefers-reduced-motion: reduce) {
334
+ .progress.v-liquid.indeterminate {
335
+ background: color-mix(in srgb, var(--fill, var(--accent)) 35%, transparent);
336
+ }
337
+ .progress.v-liquid.indeterminate::before,
338
+ .progress.v-liquid.indeterminate::after {
339
+ display: none;
340
+ }
341
+ .progress.v-liquid.indeterminate .bar {
342
+ display: none;
343
+ }
344
+ .progress.v-stripes .bar::after {
345
+ animation: none;
346
+ }
347
+ }
275
348
  </style>
@@ -15,6 +15,7 @@ type $$ComponentProps = {
15
15
  gradient?: boolean;
16
16
  striped?: boolean;
17
17
  indeterminate?: boolean;
18
+ variant?: 'default' | 'liquid' | 'stripes';
18
19
  /** Thin vertical lines at `at / max` (caps, thresholds). */
19
20
  markers?: {
20
21
  at: number;
@@ -279,6 +279,36 @@
279
279
  .select[aria-invalid='true']:focus {
280
280
  border-color: var(--danger);
281
281
  }
282
+ .select {
283
+ --focus-tint: var(--accent);
284
+ }
285
+ .select[aria-invalid='true'] {
286
+ --focus-tint: var(--danger);
287
+ }
288
+ /* Focus bloom: a solid ring whose soft halo swells, then settles to a quieter glow. */
289
+ .select:not(.ghost, .embedded):focus:not(:global([data-fx-focus='none']) *),
290
+ :global(:is([data-fx-focus='bloom'], [data-fx-focus='label'])) .select:not(.ghost, .embedded):focus:not(:global(:is([data-fx-focus='bloom'], [data-fx-focus='label']) [data-fx-focus='none']) *) {
291
+ outline: none;
292
+ box-shadow:
293
+ var(--control-shadow),
294
+ 0 0 0 var(--focus-ring-width) var(--focus-tint),
295
+ 0 0 16px 2px color-mix(in srgb, var(--focus-tint) 24%, transparent);
296
+ animation: focus-bloom var(--dur-fast) var(--ease-out);
297
+ }
298
+ @keyframes focus-bloom {
299
+ 0% {
300
+ box-shadow:
301
+ var(--control-shadow),
302
+ 0 0 0 0 transparent,
303
+ 0 0 0 0 transparent;
304
+ }
305
+ 45% {
306
+ box-shadow:
307
+ var(--control-shadow),
308
+ 0 0 0 calc(var(--focus-ring-width) + 1px) var(--focus-tint),
309
+ 0 0 20px 4px color-mix(in srgb, var(--focus-tint) 32%, transparent);
310
+ }
311
+ }
282
312
  .select:disabled {
283
313
  cursor: not-allowed;
284
314
  }
@@ -2,8 +2,10 @@
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
+ // Bars are tinted from --text so they read on any surface. `shimmer` is a
6
+ // viewport-anchored sweep so every bar on screen shimmers in sync; `sheen`
7
+ // runs one light across each bar; `pulse` breathes in opacity, each bar a
8
+ // beat behind the previous one (`--skeleton-phase` offsets the whole set).
7
9
  type Own = {
8
10
  width?: string;
9
11
  height?: string;
@@ -12,6 +14,7 @@
12
14
  /** Round avatar placeholder — width follows height. */
13
15
  circle?: boolean;
14
16
  radius?: string;
17
+ variant?: 'shimmer' | 'sheen' | 'pulse';
15
18
  class?: string;
16
19
  };
17
20
  let {
@@ -20,6 +23,7 @@
20
23
  lines = 1,
21
24
  circle = false,
22
25
  radius = 'var(--r-sm)',
26
+ variant = 'shimmer',
23
27
  class: klass = '',
24
28
  ...rest
25
29
  }: Omit<HTMLAttributes<HTMLElement>, keyof Own> & Own = $props();
@@ -29,13 +33,14 @@
29
33
  const count = $derived(Math.max(1, Math.floor(lines)));
30
34
  </script>
31
35
 
32
- <span class="skeleton {klass}" data-tsu="Skeleton" aria-hidden="true" style:width={w} {...rest}>
36
+ <span class="skeleton v-{variant} {klass}" data-tsu="Skeleton" aria-hidden="true" style:width={w} {...rest}>
33
37
  {#each { length: count } as _, i (i)}
34
38
  <span
35
39
  class="skeleton-bar"
36
40
  style:height
37
41
  style:border-radius={r}
38
42
  style:width={count > 1 && i === count - 1 ? '60%' : undefined}
43
+ style:--i={i}
39
44
  ></span>
40
45
  {/each}
41
46
  </span>
@@ -72,4 +77,50 @@
72
77
  background: var(--sk-base);
73
78
  }
74
79
  }
80
+ .v-sheen .skeleton-bar,
81
+ .v-pulse .skeleton-bar {
82
+ background: var(--sk-base);
83
+ }
84
+ .v-sheen .skeleton-bar {
85
+ position: relative;
86
+ overflow: hidden;
87
+ animation: none;
88
+ }
89
+ .v-sheen .skeleton-bar::after {
90
+ content: '';
91
+ position: absolute;
92
+ inset: 0;
93
+ width: 45%;
94
+ background: linear-gradient(100deg, transparent 20%, var(--sk-hi) 50%, transparent 80%);
95
+ transform: translateX(-100%);
96
+ animation: tsu-skeleton-sheen 2s var(--ease-in-out) infinite;
97
+ }
98
+ @keyframes tsu-skeleton-sheen {
99
+ 70%,
100
+ 100% {
101
+ transform: translateX(260%);
102
+ }
103
+ }
104
+ .v-pulse .skeleton-bar {
105
+ animation: tsu-skeleton-pulse 1.6s var(--ease-in-out) infinite;
106
+ animation-delay: calc(0.13s * (var(--i, 0) + var(--skeleton-phase, 0)));
107
+ }
108
+ @keyframes tsu-skeleton-pulse {
109
+ 0%,
110
+ 100% {
111
+ opacity: 1;
112
+ }
113
+ 50% {
114
+ opacity: 0.45;
115
+ }
116
+ }
117
+ @media (prefers-reduced-motion: reduce) {
118
+ .v-pulse .skeleton-bar {
119
+ animation: none;
120
+ }
121
+ .v-sheen .skeleton-bar::after {
122
+ animation: none;
123
+ transform: translateX(60%);
124
+ }
125
+ }
75
126
  </style>
@@ -7,6 +7,7 @@ type Own = {
7
7
  /** Round avatar placeholder — width follows height. */
8
8
  circle?: boolean;
9
9
  radius?: string;
10
+ variant?: 'shimmer' | 'sheen' | 'pulse';
10
11
  class?: string;
11
12
  };
12
13
  type $$ComponentProps = Omit<HTMLAttributes<HTMLElement>, keyof Own> & Own;