@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
@@ -4,7 +4,7 @@
4
4
  // initials (or an icon) when there is no source or it fails to load. Width
5
5
  // comes from `size`, height from `aspect`; `status` overlays a Dot/Badge in
6
6
  // the bottom-right corner.
7
- import type { Snippet } from 'svelte';
7
+ import { untrack, type Snippet } from 'svelte';
8
8
  import { artworkGradient, initials as initialsOf } from '../../artwork';
9
9
  import Icon from './Icon.svelte';
10
10
  import type { IconName } from './Icon.svelte';
@@ -51,23 +51,27 @@
51
51
  ...rest
52
52
  }: Omit<HTMLAttributes<HTMLElement>, keyof Own> & Own = $props();
53
53
 
54
- let failed = $state(false);
55
- $effect(() => {
56
- src;
57
- srcset;
58
- failed = false;
59
- });
54
+ const source = $derived(`${src ?? ''} ${srcset ?? ''}`);
55
+ let failedSource = $state<string | null>(null);
60
56
 
61
57
  const key = $derived(seed ?? alt);
62
- const showImage = $derived(Boolean(src) && !failed);
58
+ const showImage = $derived(Boolean(src) && failedSource !== source);
63
59
  const tile = $derived(showImage ? undefined : alt.trim() ? 'named' : 'decorative');
64
60
  const gradient = $derived(showImage ? undefined : artworkGradient(key));
65
61
  const text = $derived(fallback === 'initials' ? initialsOf(key) : '');
66
62
 
67
63
  function fail() {
68
- failed = true;
64
+ failedSource = source;
69
65
  onerror?.();
70
66
  }
67
+
68
+ function watchLoad(img: HTMLImageElement) {
69
+ img.addEventListener('error', fail);
70
+ untrack(() => {
71
+ if (img.complete && img.naturalWidth === 0) fail();
72
+ });
73
+ return () => img.removeEventListener('error', fail);
74
+ }
71
75
  </script>
72
76
 
73
77
  <div
@@ -83,7 +87,7 @@
83
87
  {...rest}
84
88
  >
85
89
  {#if showImage}
86
- <img {src} {srcset} {sizes} {alt} class="img fit-{fit}" loading="lazy" decoding="async" onerror={fail} />
90
+ <img {src} {srcset} {sizes} {alt} class="img fit-{fit}" loading="lazy" decoding="async" {@attach watchLoad} />
87
91
  {:else if fallback === 'initials' && text}
88
92
  <span class="initials" aria-hidden="true">{text}</span>
89
93
  {:else if fallback === 'icon'}
@@ -1,5 +1,5 @@
1
1
  import type { HTMLAttributes } from 'svelte/elements';
2
- import type { Snippet } from 'svelte';
2
+ import { type Snippet } from 'svelte';
3
3
  import type { IconName } from './Icon.svelte';
4
4
  type Own = {
5
5
  src?: string | null;
@@ -213,7 +213,7 @@
213
213
  line-height: var(--lh-none);
214
214
  }
215
215
  .toned {
216
- color: var(--badge-fg, var(--badge-tone));
216
+ color: var(--badge-fg, color-mix(in oklab, var(--badge-tone) 75%, var(--text)));
217
217
  border-color: var(--badge-border, color-mix(in srgb, var(--badge-tone) 40%, transparent));
218
218
  background: var(--badge-bg, color-mix(in srgb, var(--badge-tone) 12%, transparent));
219
219
  }
@@ -310,15 +310,12 @@
310
310
  font: inherit;
311
311
  line-height: var(--lh-none);
312
312
  cursor: pointer;
313
- opacity: 0.6;
314
313
  transition:
315
- opacity var(--dur-fast) var(--ease),
316
314
  background var(--dur-fast) var(--ease),
317
315
  scale var(--dur-fast) var(--ease-out);
318
316
  }
319
317
  @media (hover: hover) and (pointer: fine) {
320
318
  .action:hover {
321
- opacity: 1;
322
319
  background: color-mix(in srgb, currentColor 12%, transparent);
323
320
  transition-duration: var(--dur-instant);
324
321
  }
@@ -57,8 +57,20 @@
57
57
  // moving layout; `compact` opts out in dense tables.
58
58
  hitArea?: 'auto' | 'compact';
59
59
  // Async/busy state: shows a spinner, blocks clicks, sets aria-busy. Stays
60
- // disabled-equivalent while true (so a double-submit can't fire).
61
- loading?: boolean;
60
+ // disabled-equivalent while true (so a double-submit can't fire). `'done'`
61
+ // is the settled state after a busy run: clickable again, a check beside
62
+ // the label (with `loadingStyle="morph"` the label springs back in).
63
+ loading?: boolean | 'done';
64
+ // How a busy button shows it: an inline spinner before the label, the label
65
+ // morphing into a centred spinner at constant width, or a progress fill
66
+ // pouring in from the left (driven by `progress`).
67
+ loadingStyle?: 'spinner' | 'morph' | 'fill';
68
+ // 0–1 for `loadingStyle="fill"`; omit for an indeterminate idle fill.
69
+ progress?: number;
70
+ // Text beside the check in the `'done'` state; defaults to the children.
71
+ // With `morph`, keep the children constant and set this instead so both
72
+ // stacked labels size the button and its width never changes.
73
+ doneLabel?: string;
62
74
  // Corner count indicator (unread, selected, pending). Hidden at 0/undefined;
63
75
  // shows `${countMax}+` past the cap while the accessible name carries the
64
76
  // exact number. Tint via `--btn-count-bg` / `--btn-count-fg`.
@@ -100,6 +112,9 @@
100
112
  collapseLabel = 'never',
101
113
  hitArea = 'auto',
102
114
  loading = false,
115
+ loadingStyle = 'spinner',
116
+ progress,
117
+ doneLabel,
103
118
  count,
104
119
  countMax = 99,
105
120
  layout = 'inline',
@@ -120,7 +135,10 @@
120
135
  // A link-flavoured Button is a real <a>; native `disabled`/`type` don't apply,
121
136
  // so an inactive link drops `href` (no focus, no keyboard activation).
122
137
  const tag = $derived(href !== undefined || as === 'a' ? 'a' : 'button');
123
- const inactive = $derived(disabled || loading);
138
+ const busy = $derived(loading === true);
139
+ const done = $derived(loading === 'done');
140
+ const layered = $derived(loadingStyle !== 'spinner');
141
+ const inactive = $derived(disabled || busy);
124
142
  const elementAttrs = $derived(
125
143
  tag === 'a'
126
144
  ? inactive
@@ -174,7 +192,7 @@
174
192
  {...rest}
175
193
  {...elementAttrs}
176
194
  aria-label={ariaLabel}
177
- aria-busy={loading || undefined}
195
+ aria-busy={busy || undefined}
178
196
  aria-pressed={pressed ?? rest['aria-pressed']}
179
197
  aria-keyshortcuts={rest['aria-keyshortcuts'] ?? keyHint}
180
198
  {title}
@@ -198,6 +216,7 @@
198
216
  class:btn-collapse-container={collapseLabel === 'container'}
199
217
  class:hit-compact={hitArea === 'compact'}
200
218
  style:--btn-box={box ? `var(--box-${box})` : undefined}
219
+ style:--btn-progress={loadingStyle === 'fill' && progress !== undefined ? Math.min(1, Math.max(0, progress)) : undefined}
201
220
  class:btn-tone-accent={t === 'accent'}
202
221
  class:btn-tone-success={t === 'ok'}
203
222
  class:btn-tone-info={t === 'info'}
@@ -205,13 +224,17 @@
205
224
  class:btn-tone-danger={t === 'danger'}
206
225
  class:btn-icon-inline={iconInline}
207
226
  class:hover-danger={hoverDanger}
208
- class:loading
227
+ class:loading={busy}
228
+ class:btn-done={done}
229
+ class:btn-morph={loadingStyle === 'morph'}
230
+ class:btn-fill={loadingStyle === 'fill'}
209
231
  class:btn-counted={counted}
210
232
  class:btn-stacked={stacked}
211
233
  class:btn-hinted={keyHint !== undefined}
212
234
  onclick={handleClick}
213
235
  >
214
- {#if loading}<span class="btn-spinner" aria-hidden="true"></span>{/if}
236
+ {#if loadingStyle === 'fill'}<span class="btn-fill-bar" aria-hidden="true"></span>{/if}
237
+ {#if busy && !layered}<span class="btn-spinner" aria-hidden="true"></span>{:else if done && !layered}<span class="btn-check" aria-hidden="true"><Icon name="check" /></span>{/if}
215
238
  {#if glyph !== undefined}
216
239
  <span class="btn-glyph" aria-hidden="true">
217
240
  {#if typeof glyph !== 'string'}{@render glyph()}{:else if isIconName(glyph)}<Icon name={glyph} />{:else}{glyph}{/if}
@@ -219,6 +242,16 @@
219
242
  {/if}
220
243
  {#if stacked}
221
244
  <span class="btn-stack-label">{@render children?.()}</span>
245
+ {:else if layered}
246
+ <span class="btn-layers">
247
+ <span class="btn-layer btn-layer-label">{@render children?.()}</span>
248
+ {#if loadingStyle === 'morph'}
249
+ <span class="btn-layer btn-layer-spin" aria-hidden="true"></span>
250
+ <span class="btn-layer btn-layer-done" aria-hidden="true"><Icon name="check" />{#if doneLabel !== undefined}{doneLabel}{:else}{@render children?.()}{/if}</span>
251
+ {:else}
252
+ <span class="btn-layer btn-layer-ink" aria-hidden="true">{@render children?.()}</span>
253
+ {/if}
254
+ </span>
222
255
  {:else}
223
256
  {@render children?.()}
224
257
  {/if}
@@ -253,15 +286,17 @@
253
286
  font-size: var(--control-fs-md);
254
287
  line-height: var(--lh-none);
255
288
  --icon-size: var(--control-icon-md);
256
- --btn-scale-dur: var(--dur-fast);
289
+ --btn-scale-dur: var(--dur-release);
290
+ --btn-scale-ease: var(--ease-spring-bounce);
257
291
  transition:
258
292
  background var(--dur-fast) ease,
259
293
  border-color var(--dur-fast) ease,
260
294
  box-shadow var(--dur-fast) ease,
261
295
  color var(--dur-fast) ease,
262
296
  opacity var(--dur-fast) ease,
297
+ filter var(--dur-fast) ease,
263
298
  translate var(--dur-fast) var(--ease-out),
264
- scale var(--btn-scale-dur) var(--ease-out);
299
+ scale var(--btn-scale-dur) var(--btn-scale-ease);
265
300
  user-select: none;
266
301
  white-space: normal;
267
302
  overflow-wrap: break-word;
@@ -273,9 +308,10 @@
273
308
  .btn:focus-visible:not(.btn-link, .btn-icon-inline, [aria-pressed='true']) {
274
309
  box-shadow: var(--btn-shadow-rest), var(--focus-glow);
275
310
  }
276
- /* Press: scale in over --dur-press, release over --dur-fast. */
311
+ /* Press: scale in over --dur-press, spring back over --dur-release. */
277
312
  .btn:active:not(:disabled, [aria-disabled='true'], .loading, .btn-link) {
278
313
  --btn-scale-dur: var(--dur-press);
314
+ --btn-scale-ease: var(--ease-out);
279
315
  scale: var(--press-scale);
280
316
  }
281
317
  .btn-icon:active:not(:disabled, [aria-disabled='true'], .loading),
@@ -809,4 +845,262 @@
809
845
  color: var(--danger);
810
846
  }
811
847
  }
848
+ /* Loading styles. `morph`: label, spinner and done check stack in one grid
849
+ cell so the width never changes. `fill`: a progress bar under the label,
850
+ the label recoloured where the bar has passed. */
851
+ .btn-check {
852
+ display: inline-flex;
853
+ flex: none;
854
+ --icon-size: 0.9em;
855
+ }
856
+ .btn-layers {
857
+ position: relative;
858
+ display: grid;
859
+ place-items: center;
860
+ min-width: 0;
861
+ }
862
+ .btn-layer {
863
+ grid-area: 1 / 1;
864
+ display: inline-flex;
865
+ align-items: center;
866
+ gap: var(--sp-2);
867
+ white-space: nowrap;
868
+ font-variant-numeric: tabular-nums;
869
+ transition:
870
+ opacity var(--dur-base) var(--ease-out),
871
+ scale var(--dur-base) var(--ease-out),
872
+ translate var(--dur-base) var(--ease-out);
873
+ }
874
+ .btn-layer-spin {
875
+ width: 1em;
876
+ height: 1em;
877
+ border-radius: var(--r-pill);
878
+ mask: radial-gradient(farthest-side, transparent calc(100% - 2px), #000 calc(100% - 1.5px));
879
+ background: conic-gradient(from 0deg, transparent 0 30%, currentColor);
880
+ opacity: 0;
881
+ scale: 0.4;
882
+ }
883
+ .btn-layer-done {
884
+ opacity: 0;
885
+ translate: 0 0.5em;
886
+ --icon-size: 0.9em;
887
+ }
888
+ .btn-morph.loading .btn-layer-label,
889
+ .btn-morph.btn-done .btn-layer-label {
890
+ opacity: 0;
891
+ scale: 0.85;
892
+ }
893
+ .btn-morph.loading .btn-layer-spin {
894
+ opacity: 1;
895
+ scale: 1;
896
+ animation: btn-spin 0.9s linear infinite;
897
+ }
898
+ .btn-morph.btn-done .btn-layer-done {
899
+ opacity: 1;
900
+ translate: none;
901
+ transition-timing-function: var(--ease-spring-bounce);
902
+ transition-duration: var(--dur-spring);
903
+ }
904
+ .btn-fill {
905
+ position: relative;
906
+ isolation: isolate;
907
+ overflow: hidden;
908
+ --btn-progress-fill: var(--accent);
909
+ --btn-progress-fg: var(--text-on-accent);
910
+ }
911
+ .btn-fill.btn-primary {
912
+ --btn-progress-fill: color-mix(in srgb, var(--text-on-accent) 22%, transparent);
913
+ --btn-progress-fg: currentColor;
914
+ }
915
+ .btn-fill-bar {
916
+ position: absolute;
917
+ inset: 0;
918
+ z-index: 0;
919
+ background: var(--btn-progress-fill);
920
+ scale: var(--btn-progress, 0) 1;
921
+ transform-origin: left;
922
+ transition: scale var(--dur-slow) var(--ease-out);
923
+ pointer-events: none;
924
+ }
925
+ .btn-fill .btn-layers {
926
+ z-index: 1;
927
+ }
928
+ .btn-layer-ink {
929
+ position: absolute;
930
+ inset: 0;
931
+ justify-content: center;
932
+ color: var(--btn-progress-fg);
933
+ clip-path: inset(0 calc((1 - var(--btn-progress, 0)) * 100%) 0 0);
934
+ transition: clip-path var(--dur-slow) var(--ease-out);
935
+ }
936
+ .btn-fill:not(.loading, .btn-done) .btn-fill-bar,
937
+ .btn-fill:not(.loading, .btn-done) .btn-layer-ink {
938
+ transition-duration: var(--dur-fast);
939
+ }
940
+
941
+ /* Ambient hover: an effect chosen by `data-fx-hover` on the nearest ancestor
942
+ that sets it (`smoke` when none does, `none` turns it off). Two layers under
943
+ the content run their loop only while hovered; keyboard focus holds a still
944
+ frame. Boxless buttons and a corner count (clipped by overflow) opt out. */
945
+ .btn {
946
+ --fx-ink: color-mix(in srgb, var(--btn-tone, var(--accent)) 26%, transparent);
947
+ --fx-ember: color-mix(in srgb, var(--warn) 55%, var(--accent));
948
+ --fx-a1: color-mix(in srgb, var(--accent) 34%, transparent);
949
+ --fx-a2: color-mix(in srgb, var(--info) 30%, transparent);
950
+ --fx-a3: color-mix(in srgb, var(--ok) 30%, transparent);
951
+ }
952
+ .btn-danger {
953
+ --fx-ink: color-mix(in srgb, var(--danger) 26%, transparent);
954
+ }
955
+ .btn-primary {
956
+ --fx-ink: color-mix(in srgb, var(--text-on-accent) 16%, transparent);
957
+ --fx-ember: color-mix(in srgb, var(--warn) 70%, var(--text-on-accent));
958
+ --fx-a1: color-mix(in srgb, var(--text-on-accent) 18%, transparent);
959
+ --fx-a2: color-mix(in srgb, var(--info) 45%, transparent);
960
+ --fx-a3: color-mix(in srgb, var(--ok) 40%, transparent);
961
+ }
962
+ @media (hover: hover) and (pointer: fine) {
963
+ .btn:where(:not(.btn-link, .btn-icon-inline, .btn-counted)):where(:not(:global([data-fx-hover]) *), :global([data-fx-hover]:not([data-fx-hover='none'])) *) {
964
+ position: relative;
965
+ isolation: isolate;
966
+ overflow: hidden;
967
+ }
968
+ .btn:where(:not(.btn-link, .btn-icon-inline, .btn-counted)):where(:not(:global([data-fx-hover]) *), :global([data-fx-hover]:not([data-fx-hover='none'])) *)::before,
969
+ .btn:where(:not(.btn-link, .btn-icon-inline, .btn-counted)):where(:not(:global([data-fx-hover]) *), :global([data-fx-hover]:not([data-fx-hover='none'])) *)::after {
970
+ content: '';
971
+ position: absolute;
972
+ inset: 0;
973
+ z-index: -1;
974
+ pointer-events: none;
975
+ opacity: 0;
976
+ transition: opacity var(--dur-fast) ease;
977
+ }
978
+ .btn:hover:not(:disabled)::before,
979
+ .btn:hover:not(:disabled)::after,
980
+ .btn:focus-visible::before,
981
+ .btn:focus-visible::after {
982
+ opacity: 1;
983
+ }
984
+
985
+ /* smoke: two soft puffs drift up and across the fill */
986
+ .btn:where(:not(:global([data-fx-hover]) *), :global([data-fx-hover='smoke']) *):where(:not(:global([data-fx-hover='smoke'] [data-fx-hover]:not([data-fx-hover='smoke'])) *))::before,
987
+ .btn:where(:not(:global([data-fx-hover]) *), :global([data-fx-hover='smoke']) *):where(:not(:global([data-fx-hover='smoke'] [data-fx-hover]:not([data-fx-hover='smoke'])) *))::after {
988
+ inset: -60% -30%;
989
+ background:
990
+ radial-gradient(40% 55% at 30% 60%, var(--fx-ink), transparent 70%),
991
+ radial-gradient(35% 45% at 70% 40%, var(--fx-ink), transparent 70%);
992
+ translate: 0 15%;
993
+ animation: fx-smoke 7s var(--ease-in-out) infinite alternate;
994
+ }
995
+ .btn:where(:not(:global([data-fx-hover]) *), :global([data-fx-hover='smoke']) *):where(:not(:global([data-fx-hover='smoke'] [data-fx-hover]:not([data-fx-hover='smoke'])) *))::after {
996
+ background:
997
+ radial-gradient(30% 40% at 55% 70%, var(--fx-ink), transparent 70%),
998
+ radial-gradient(25% 35% at 20% 30%, var(--fx-ink), transparent 70%);
999
+ translate: 0 -10%;
1000
+ animation: fx-smoke-b 9s var(--ease-in-out) infinite alternate;
1001
+ }
1002
+
1003
+ /* ember: a warm glow breathes at the bottom edge, sparks rise; press sinks 1px */
1004
+ .btn:where(:global([data-fx-hover='ember']) *):where(:not(:global([data-fx-hover='ember'] [data-fx-hover]:not([data-fx-hover='ember'])) *)) {
1005
+ --press-scale: var(--press-scale-lg);
1006
+ }
1007
+ .btn:where(:global([data-fx-hover='ember']) *):where(:not(:global([data-fx-hover='ember'] [data-fx-hover]:not([data-fx-hover='ember'])) *)):active:not(:disabled, [aria-disabled='true'], .loading, .btn-link) {
1008
+ translate: 0 calc(var(--shift-xs) / 2 - var(--hover-lift));
1009
+ --btn-shadow-rest: none;
1010
+ }
1011
+ .btn:where(:global([data-fx-hover='ember']) *):where(:not(:global([data-fx-hover='ember'] [data-fx-hover]:not([data-fx-hover='ember'])) *))::before {
1012
+ background: radial-gradient(70% 90% at 50% 115%, color-mix(in srgb, var(--fx-ember) 55%, transparent), transparent 70%);
1013
+ transform-origin: 50% 100%;
1014
+ animation: fx-ember 2.4s var(--ease-in-out) infinite;
1015
+ }
1016
+ .btn:where(:global([data-fx-hover='ember']) *):where(:not(:global([data-fx-hover='ember'] [data-fx-hover]:not([data-fx-hover='ember'])) *))::after {
1017
+ inset: 0 0 -100% 0;
1018
+ background:
1019
+ radial-gradient(circle at 18% 62%, var(--fx-ember) 0 1px, transparent 1.6px),
1020
+ radial-gradient(circle at 36% 88%, var(--fx-ember) 0 1.3px, transparent 1.9px),
1021
+ radial-gradient(circle at 52% 70%, var(--fx-ember) 0 0.9px, transparent 1.5px),
1022
+ radial-gradient(circle at 70% 94%, var(--fx-ember) 0 1.2px, transparent 1.8px),
1023
+ radial-gradient(circle at 84% 58%, var(--fx-ember) 0 1px, transparent 1.6px),
1024
+ radial-gradient(circle at 92% 80%, var(--fx-ember) 0 0.8px, transparent 1.4px);
1025
+ animation: fx-sparks 3.2s linear infinite;
1026
+ }
1027
+
1028
+ /* aurora: accent, info and success flow through the fill in two crossing layers */
1029
+ .btn:where(:global([data-fx-hover='aurora']) *):where(:not(:global([data-fx-hover='aurora'] [data-fx-hover]:not([data-fx-hover='aurora'])) *))::before,
1030
+ .btn:where(:global([data-fx-hover='aurora']) *):where(:not(:global([data-fx-hover='aurora'] [data-fx-hover]:not([data-fx-hover='aurora'])) *))::after {
1031
+ inset: 0 auto 0 0;
1032
+ width: 300%;
1033
+ background: linear-gradient(90deg, var(--fx-a1), var(--fx-a2), var(--fx-a3), var(--fx-a1));
1034
+ background-size: 33.3334% 100%;
1035
+ mask-image: radial-gradient(120% 110% at 50% -10%, #000 40%, transparent 100%);
1036
+ translate: -33.3334% 0;
1037
+ animation: fx-aurora 7s linear infinite;
1038
+ }
1039
+ .btn:where(:global([data-fx-hover='aurora']) *):where(:not(:global([data-fx-hover='aurora'] [data-fx-hover]:not([data-fx-hover='aurora'])) *))::after {
1040
+ background: linear-gradient(90deg, var(--fx-a3), var(--fx-a1), var(--fx-a2), var(--fx-a3));
1041
+ background-size: 33.3334% 100%;
1042
+ mask-image: radial-gradient(120% 110% at 50% 110%, #000 30%, transparent 100%);
1043
+ translate: -66.6667% 0;
1044
+ animation: fx-aurora-back 11s linear infinite;
1045
+ }
1046
+ .btn:not(:hover)::before,
1047
+ .btn:not(:hover)::after,
1048
+ .btn:disabled::before,
1049
+ .btn:disabled::after {
1050
+ animation-play-state: paused;
1051
+ }
1052
+ }
1053
+ @keyframes fx-smoke {
1054
+ from {
1055
+ translate: -14% 22%;
1056
+ rotate: -4deg;
1057
+ }
1058
+ to {
1059
+ translate: 12% -20%;
1060
+ rotate: 5deg;
1061
+ }
1062
+ }
1063
+ @keyframes fx-smoke-b {
1064
+ from {
1065
+ translate: 10% 18%;
1066
+ rotate: 3deg;
1067
+ }
1068
+ to {
1069
+ translate: -12% -22%;
1070
+ rotate: -5deg;
1071
+ }
1072
+ }
1073
+ @keyframes fx-ember {
1074
+ 0%,
1075
+ 100% {
1076
+ scale: 1 1;
1077
+ }
1078
+ 50% {
1079
+ scale: 1.12 1.25;
1080
+ }
1081
+ }
1082
+ @keyframes fx-sparks {
1083
+ from {
1084
+ translate: 0 0;
1085
+ }
1086
+ to {
1087
+ translate: 0 -100%;
1088
+ }
1089
+ }
1090
+ @keyframes fx-aurora {
1091
+ from {
1092
+ translate: -33.3334% 0;
1093
+ }
1094
+ to {
1095
+ translate: 0 0;
1096
+ }
1097
+ }
1098
+ @keyframes fx-aurora-back {
1099
+ from {
1100
+ translate: -33.3334% 0;
1101
+ }
1102
+ to {
1103
+ translate: -66.6667% 0;
1104
+ }
1105
+ }
812
1106
  </style>
@@ -23,7 +23,10 @@ type ButtonProps = HTMLButtonAttributes & {
23
23
  hoverDanger?: boolean;
24
24
  collapseLabel?: 'never' | 'mobile' | 'container';
25
25
  hitArea?: 'auto' | 'compact';
26
- loading?: boolean;
26
+ loading?: boolean | 'done';
27
+ loadingStyle?: 'spinner' | 'morph' | 'fill';
28
+ progress?: number;
29
+ doneLabel?: string;
27
30
  count?: number;
28
31
  countMax?: number;
29
32
  layout?: 'inline' | 'stacked';
@@ -96,6 +96,45 @@
96
96
  const framed = $derived(!!(header || footer || title));
97
97
  const native = $derived(as === 'button' || as === 'a');
98
98
 
99
+ let el = $state<HTMLElement>();
100
+ const lit = $derived(tap || interactive);
101
+
102
+ // The spotlight follows the pointer itself unless the app already runs trackPointer().
103
+ $effect(() => {
104
+ const node = el;
105
+ if (!node || !lit) return;
106
+ let frame = 0;
107
+ let x = 0;
108
+ let y = 0;
109
+ const flush = () => {
110
+ frame = 0;
111
+ const r = node.getBoundingClientRect();
112
+ node.style.setProperty('--pointer-x', `${x - r.left}px`);
113
+ node.style.setProperty('--pointer-y', `${y - r.top}px`);
114
+ };
115
+ const move = (e: PointerEvent) => {
116
+ if (e.pointerType !== 'mouse') return;
117
+ x = e.clientX;
118
+ y = e.clientY;
119
+ if (!frame) frame = requestAnimationFrame(flush);
120
+ };
121
+ const enter = (e: PointerEvent) => {
122
+ if (e.pointerType !== 'mouse' || globalThis.matchMedia?.('(prefers-reduced-motion: reduce)').matches) return;
123
+ if (getComputedStyle(node).getPropertyValue('--card-fx').trim() !== 'spotlight') return;
124
+ move(e);
125
+ node.addEventListener('pointermove', move, { passive: true });
126
+ };
127
+ const leave = () => node.removeEventListener('pointermove', move);
128
+ node.addEventListener('pointerenter', enter, { passive: true });
129
+ node.addEventListener('pointerleave', leave, { passive: true });
130
+ return () => {
131
+ leave();
132
+ node.removeEventListener('pointerenter', enter);
133
+ node.removeEventListener('pointerleave', leave);
134
+ if (frame) cancelAnimationFrame(frame);
135
+ };
136
+ });
137
+
99
138
  const NESTED = 'button,a,input,select,textarea,[role=button],[contenteditable],[contenteditable=true]';
100
139
  function fromNested(e: Event): boolean {
101
140
  const target = e.target as Element | null;
@@ -118,6 +157,7 @@
118
157
 
119
158
  <svelte:element
120
159
  this={as}
160
+ bind:this={el}
121
161
  data-tsu="Card"
122
162
  class="card {klass}"
123
163
  class:pad-none={padding === 'none'}
@@ -168,6 +208,7 @@
168
208
  {:else}
169
209
  {@render children?.()}
170
210
  {/if}
211
+ {#if lit}<span class="card-fx" aria-hidden="true"></span>{/if}
171
212
  </svelte:element>
172
213
 
173
214
  <style>
@@ -252,8 +293,109 @@
252
293
  border-color: var(--surface-edge-strong);
253
294
  translate: 0 calc(-1 * var(--hover-lift));
254
295
  --card-shadow-rest: var(--surface-shadow-hover);
296
+ --card-glow: 100%;
297
+ --card-lit: 1;
298
+ --card-fade: var(--dur-fast);
299
+ --card-spin: var(--card-orbit-spin);
300
+ }
301
+ }
302
+ .card-tap:focus-visible {
303
+ --card-glow: 100%;
304
+ --card-lit: 1;
305
+ --card-fade: var(--dur-fast);
306
+ --card-spin: var(--card-orbit-spin);
307
+ }
308
+
309
+ @media (hover: hover) and (pointer: fine) {
310
+ .card-tap:hover > .card-fx {
311
+ --card-orbit: 360deg;
312
+ }
313
+ }
314
+ .card-tap:focus-visible > .card-fx {
315
+ --card-orbit: 360deg;
316
+ }
317
+
318
+ /* Hover light for tappable cards: a wash on the surface plus a ring on the edge. */
319
+ .card-tap {
320
+ position: relative;
321
+ }
322
+ .card-fx {
323
+ position: absolute;
324
+ inset: calc(-1 * var(--edge-width));
325
+ padding: 1.5px;
326
+ border-radius: inherit;
327
+ pointer-events: none;
328
+ opacity: var(--card-lit, 0);
329
+ mask:
330
+ linear-gradient(var(--text), var(--text)) content-box exclude,
331
+ linear-gradient(var(--text), var(--text));
332
+ transition:
333
+ opacity var(--card-fade, var(--dur-exit)) ease,
334
+ --card-orbit var(--card-spin, var(--dur-instant)) var(--ease-out);
335
+ }
336
+ .card-tap:not(:global([data-fx-card='none']) *),
337
+ :global(:is([data-fx-card='spotlight'], [data-fx-card='orbit'])) .card-tap:not(:global(:is([data-fx-card='spotlight'], [data-fx-card='orbit']) [data-fx-card='none']) *) {
338
+ transition:
339
+ border-color var(--dur-fast) var(--ease),
340
+ background var(--dur-fast) var(--ease),
341
+ box-shadow var(--dur-fast) var(--ease),
342
+ translate var(--dur-fast) var(--ease-out),
343
+ scale var(--dur-fast) var(--ease-out),
344
+ --card-glow var(--card-fade, var(--dur-exit)) ease;
345
+ }
346
+
347
+ /* Spotlight: a soft light follows the pointer and lights the nearest edge. */
348
+ .card-tap:not(:global([data-fx-card]:not([data-fx-card='spotlight'])) *),
349
+ :global([data-fx-card='spotlight']) .card-tap:not(:global([data-fx-card='spotlight'] [data-fx-card]:not([data-fx-card='spotlight'])) *) {
350
+ --card-fx: spotlight;
351
+ background-image: radial-gradient(
352
+ circle 11rem at var(--card-spot-x) var(--card-spot-y),
353
+ color-mix(in srgb, var(--accent) calc(var(--card-glow) * 0.2), transparent),
354
+ transparent 70%
355
+ );
356
+ --card-spot-x: var(--pointer-x, 50%);
357
+ --card-spot-y: var(--pointer-y, 33%);
358
+ & .card-fx {
359
+ background: radial-gradient(
360
+ circle 7rem at calc(var(--card-spot-x) + var(--edge-width)) calc(var(--card-spot-y) + var(--edge-width)),
361
+ var(--accent) 15%,
362
+ transparent
363
+ );
364
+ }
365
+ }
366
+
367
+ /* Border orbit: on hover-enter a comet makes exactly one revolution of the edge, then rests. */
368
+ :global([data-fx-card='orbit']) .card-tap:not(:global([data-fx-card='orbit'] [data-fx-card]:not([data-fx-card='orbit'])) *) {
369
+ --card-fx: orbit;
370
+ background-image: linear-gradient(
371
+ 160deg,
372
+ color-mix(in srgb, var(--accent) calc(var(--card-glow) * 0.09), transparent),
373
+ transparent 55%
374
+ );
375
+ --card-orbit-spin: calc(var(--dur-slow) * 4);
376
+ & .card-fx {
377
+ background:
378
+ conic-gradient(
379
+ from var(--card-orbit),
380
+ transparent 0 62%,
381
+ color-mix(in srgb, var(--accent) 40%, transparent) 80%,
382
+ var(--accent) 92%,
383
+ color-mix(in srgb, var(--text) 85%, var(--accent)) 96%,
384
+ transparent 100%
385
+ ),
386
+ linear-gradient(color-mix(in srgb, var(--accent) 22%, transparent), color-mix(in srgb, var(--accent) 22%, transparent));
255
387
  }
256
388
  }
389
+ @property --card-glow {
390
+ syntax: '<percentage>';
391
+ inherits: false;
392
+ initial-value: 0%;
393
+ }
394
+ @property --card-orbit {
395
+ syntax: '<angle>';
396
+ inherits: false;
397
+ initial-value: 0deg;
398
+ }
257
399
  .card-max {
258
400
  width: 100%;
259
401
  }