@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
@@ -123,7 +123,9 @@
123
123
  indLeft = left;
124
124
  indWidth = width;
125
125
  ind.style.width = `${width}px`;
126
- if (!animate || !placed || width === 0) {
126
+ const fx = getComputedStyle(ind).getPropertyValue('--tabs-fx').trim();
127
+ const still = globalThis.matchMedia?.('(prefers-reduced-motion: reduce)').matches ?? false;
128
+ if (!animate || !placed || width === 0 || still) {
127
129
  ind.style.transition = 'none';
128
130
  ind.style.translate = `${left}px 0`;
129
131
  ind.style.scale = '1 1';
@@ -132,6 +134,22 @@
132
134
  placed = true;
133
135
  return;
134
136
  }
137
+ if (fx === 'stretch' && typeof ind.animate === 'function') {
138
+ const lo = Math.min(prevLeft, left);
139
+ const hi = Math.max(prevLeft + prevWidth, left + width);
140
+ const ease = (name: string) => getComputedStyle(ind).getPropertyValue(name).trim() || 'ease-out';
141
+ ind.style.translate = `${left}px 0`;
142
+ ind.style.scale = '1 1';
143
+ ind.animate(
144
+ [
145
+ { translate: `${prevLeft}px 0`, scale: `${prevWidth / width} 1`, easing: ease('--ease-out') },
146
+ { translate: `${lo}px 0`, scale: `${(hi - lo) / width} 1`, offset: 0.4, easing: ease('--ease-spring') },
147
+ { translate: `${left}px 0`, scale: '1 1' }
148
+ ],
149
+ { duration: 280 }
150
+ );
151
+ return;
152
+ }
135
153
  ind.style.transition = 'none';
136
154
  ind.style.translate = `${prevLeft}px 0`;
137
155
  ind.style.scale = `${prevWidth / width} 1`;
@@ -152,6 +170,14 @@
152
170
  return () => ro.disconnect();
153
171
  });
154
172
 
173
+ function inkFrom(e: MouseEvent) {
174
+ const el = e.currentTarget as HTMLElement;
175
+ const r = el.getBoundingClientRect();
176
+ const pointer = e.detail > 0;
177
+ el.style.setProperty('--ink-x', pointer ? `${e.clientX - r.left}px` : '50%');
178
+ el.style.setProperty('--ink-y', pointer ? `${e.clientY - r.top}px` : '50%');
179
+ }
180
+
155
181
  function select(id: string, focus = false) {
156
182
  if (tabs.find((t) => t.id === id)?.disabled) return;
157
183
  if (value !== id) playSound('click', sound);
@@ -161,7 +187,11 @@
161
187
  // The list scrolls when it is too narrow, so keyboard selection has to
162
188
  // bring the tab back into view or it moves somewhere unseen.
163
189
  el?.scrollIntoView({ block: 'nearest', inline: 'nearest' });
164
- if (focus) el?.focus();
190
+ if (focus) {
191
+ el?.style.removeProperty('--ink-x');
192
+ el?.style.removeProperty('--ink-y');
193
+ el?.focus();
194
+ }
165
195
  });
166
196
  }
167
197
  // Step to the next non-disabled tab in a direction, wrapping around.
@@ -236,7 +266,10 @@
236
266
  class="tab closable"
237
267
  class:selected={value === t.id}
238
268
  class:disabled={t.disabled}
239
- onclick={() => select(t.id)}
269
+ onclick={(e) => {
270
+ inkFrom(e);
271
+ select(t.id);
272
+ }}
240
273
  onauxclick={(e) => onauxclick(e, t.id)}
241
274
  >
242
275
  {@render body(t)}
@@ -268,7 +301,10 @@
268
301
  tabindex={stop?.id === t.id ? 0 : -1}
269
302
  class="tab"
270
303
  class:selected={value === t.id}
271
- onclick={() => select(t.id)}
304
+ onclick={(e) => {
305
+ inkFrom(e);
306
+ select(t.id);
307
+ }}
272
308
  >
273
309
  {@render body(t)}
274
310
  </button>
@@ -389,4 +425,121 @@
389
425
  outline-offset: var(--focus-ring-offset);
390
426
  border-radius: var(--r-sm);
391
427
  }
428
+
429
+ /* Sliding pill: a raised pill glides behind the selected tab with a slight overshoot. */
430
+ .tabs:not(:global([data-fx-tabs]:not([data-fx-tabs='pill'])) *),
431
+ :global([data-fx-tabs='pill']) .tabs:not(:global([data-fx-tabs='pill'] [data-fx-tabs]:not([data-fx-tabs='pill'])) *) {
432
+ --tabs-fx: pill;
433
+ & .tablist {
434
+ isolation: isolate;
435
+ flex: 0 1 auto;
436
+ padding: 3px;
437
+ border-bottom: 0;
438
+ border-radius: var(--r-pill);
439
+ background: var(--surface-fill-2);
440
+ box-shadow: inset 0 0 0 var(--edge-width) var(--surface-edge);
441
+ }
442
+ & .actions {
443
+ border-bottom: 0;
444
+ }
445
+ & .tab {
446
+ min-height: calc(var(--control-md) - 6px);
447
+ margin-bottom: 0;
448
+ border-bottom: 0;
449
+ border-radius: var(--r-pill);
450
+ }
451
+ & .tab.selected {
452
+ color: var(--text);
453
+ }
454
+ & .tablist:not(.placed) .tab.selected {
455
+ background: var(--surface);
456
+ }
457
+ & .tab.selected:focus-visible {
458
+ outline: none;
459
+ }
460
+ & .indicator {
461
+ top: 3px;
462
+ bottom: 3px;
463
+ z-index: -1;
464
+ height: auto;
465
+ border-radius: var(--r-pill);
466
+ background: var(--surface);
467
+ box-shadow:
468
+ 0 0 0 var(--edge-width) color-mix(in srgb, var(--accent) 35%, var(--surface-edge)),
469
+ 0 1px 3px color-mix(in srgb, var(--text) 14%, transparent);
470
+ transition-timing-function: var(--ease-spring-bounce);
471
+ }
472
+ & .tablist:has(.tab.selected:focus-visible) .indicator {
473
+ box-shadow:
474
+ 0 0 0 2px var(--accent),
475
+ 0 1px 3px color-mix(in srgb, var(--text) 14%, transparent);
476
+ }
477
+ }
478
+ /* Stretch line: the script stretches the line across both tabs, then lets the tail catch up. */
479
+ :global([data-fx-tabs='stretch']) .tabs:not(:global([data-fx-tabs='stretch'] [data-fx-tabs]:not([data-fx-tabs='stretch'])) *) {
480
+ --tabs-fx: stretch;
481
+ }
482
+ /* Ink blot: a tint spreads from the click point and a short mark springs in under the label. */
483
+ :global([data-fx-tabs='ink']) .tabs:not(:global([data-fx-tabs='ink'] [data-fx-tabs]:not([data-fx-tabs='ink'])) *) {
484
+ --tabs-fx: ink;
485
+ & .indicator {
486
+ display: none;
487
+ }
488
+ & .tab {
489
+ position: relative;
490
+ isolation: isolate;
491
+ overflow: hidden;
492
+ border-radius: var(--r-md) var(--r-md) 0 0;
493
+ }
494
+ & .tab::before,
495
+ & .tab::after {
496
+ content: '';
497
+ position: absolute;
498
+ pointer-events: none;
499
+ }
500
+ & .tab::before {
501
+ left: var(--ink-x, 50%);
502
+ top: var(--ink-y, 50%);
503
+ z-index: -1;
504
+ width: 260%;
505
+ aspect-ratio: 1;
506
+ translate: -50% -50%;
507
+ border-radius: var(--r-pill);
508
+ background: radial-gradient(
509
+ circle closest-side,
510
+ color-mix(in srgb, var(--accent) 22%, transparent) 0 55%,
511
+ color-mix(in srgb, var(--accent) 14%, transparent) 80%,
512
+ transparent
513
+ );
514
+ scale: 0;
515
+ opacity: 0;
516
+ transition-property: opacity, scale;
517
+ transition-duration: var(--dur-exit), var(--dur-instant);
518
+ transition-delay: var(--dur-instant), var(--dur-exit);
519
+ transition-timing-function: ease;
520
+ }
521
+ & .tab.selected::before {
522
+ scale: 1;
523
+ opacity: 1;
524
+ transition-duration: var(--dur-fast), var(--dur-slow);
525
+ transition-delay: var(--dur-instant);
526
+ transition-timing-function: ease, var(--ease-out);
527
+ }
528
+ & .tab::after {
529
+ inset: auto 30% 4px;
530
+ height: 2px;
531
+ border-radius: var(--r-xs);
532
+ background: var(--accent);
533
+ opacity: 0;
534
+ scale: 0.2 1;
535
+ transition-property: opacity, scale;
536
+ transition-duration: var(--dur-exit-fast);
537
+ }
538
+ & .tab.selected::after {
539
+ opacity: 1;
540
+ scale: 1 1;
541
+ transition-duration: var(--dur-fast), var(--dur-spring);
542
+ transition-timing-function: ease, var(--ease-spring-bounce);
543
+ }
544
+ }
392
545
  </style>
@@ -5,9 +5,12 @@
5
5
  // (--c-bg, --c-surface, --c-text, --c-accent; the derived --bg etc. are
6
6
  // resolved once at :root and would not re-scope). The root default theme
7
7
  // has no [data-theme] block, so its swatch carries the :root values.
8
+ // Prerendered, the trigger swatch is unscoped: it shows the theme prepainted
9
+ // on <html> until hydration.
8
10
  import type { ComponentProps } from 'svelte';
9
11
  import type { AnchorAttributes } from '../../anchor';
10
12
  import Popover from './Popover.svelte';
13
+ import { browser } from '../../env';
11
14
  import { type ThemeDef, theme } from '../../stores/theme.svelte';
12
15
  import { AUTO_THEME } from '../../theme-mode';
13
16
 
@@ -63,7 +66,9 @@
63
66
  );
64
67
  const named = (id: string) => theme.all.find((t) => t.id === id)?.label ?? id;
65
68
  const autoCaption = $derived(`${autoLabel} · ${named(theme.pref.light)} / ${named(theme.pref.dark)}`);
66
- const title = $derived(isAuto ? `${autoLabel} · ${theme.label}` : `Theme: ${theme.label}`);
69
+ const title = $derived(
70
+ !browser ? 'Theme' : isAuto ? `${autoLabel} · ${theme.label}` : `Theme: ${theme.label}`
71
+ );
67
72
  const caption = $derived(
68
73
  hoveredAuto || (isAuto && !hovered)
69
74
  ? autoCaption
@@ -71,7 +76,7 @@
71
76
  );
72
77
  </script>
73
78
 
74
- {#snippet swatch(id: string)}
79
+ {#snippet swatch(id: string | undefined)}
75
80
  <span class="swatch" data-theme={id} aria-hidden="true">
76
81
  <i class="q bg"></i><i class="q surface"></i><i class="q text"></i><i class="q accent"></i>
77
82
  </span>
@@ -94,7 +99,7 @@
94
99
  triggerClass={klass}
95
100
  style={styleProp}
96
101
  >
97
- {#snippet trigger()}<span class="trigger" data-tsu="ThemePicker" {title}>{@render swatch(theme.current)}{#if isAuto}<span class="auto-dot" aria-hidden="true">◐</span>{/if}</span>{/snippet}
102
+ {#snippet trigger()}<span class="trigger" data-tsu="ThemePicker" {title}>{@render swatch(browser ? theme.current : undefined)}{#if isAuto}<span class="auto-dot" aria-hidden="true">◐</span>{/if}</span>{/snippet}
98
103
  <div class="panel">
99
104
  {#each groups as g (g.mode)}
100
105
  <div class="group-label">{g.label}</div>
package/dist/fx.d.ts ADDED
@@ -0,0 +1,149 @@
1
+ export declare const FX: {
2
+ readonly hover: {
3
+ readonly label: "Button hover";
4
+ readonly variants: readonly [{
5
+ readonly id: "smoke";
6
+ readonly label: "Smoke";
7
+ }, {
8
+ readonly id: "ember";
9
+ readonly label: "Ember";
10
+ }, {
11
+ readonly id: "aurora";
12
+ readonly label: "Aurora";
13
+ }, {
14
+ readonly id: "none";
15
+ readonly label: "Off";
16
+ }];
17
+ };
18
+ readonly check: {
19
+ readonly label: "Checkbox";
20
+ readonly variants: readonly [{
21
+ readonly id: "draw";
22
+ readonly label: "Draw";
23
+ }, {
24
+ readonly id: "pop";
25
+ readonly label: "Pop";
26
+ }, {
27
+ readonly id: "confetti";
28
+ readonly label: "Confetti";
29
+ }, {
30
+ readonly id: "morph";
31
+ readonly label: "Morph";
32
+ }, {
33
+ readonly id: "none";
34
+ readonly label: "Off";
35
+ }];
36
+ };
37
+ readonly radio: {
38
+ readonly label: "Radio";
39
+ readonly variants: readonly [{
40
+ readonly id: "spring";
41
+ readonly label: "Spring";
42
+ }, {
43
+ readonly id: "liquid";
44
+ readonly label: "Liquid";
45
+ }, {
46
+ readonly id: "none";
47
+ readonly label: "Off";
48
+ }];
49
+ };
50
+ readonly switch: {
51
+ readonly label: "Switch";
52
+ readonly variants: readonly [{
53
+ readonly id: "squash";
54
+ readonly label: "Squash";
55
+ }, {
56
+ readonly id: "glow";
57
+ readonly label: "Glow";
58
+ }, {
59
+ readonly id: "calm";
60
+ readonly label: "Calm";
61
+ }, {
62
+ readonly id: "none";
63
+ readonly label: "Off";
64
+ }];
65
+ };
66
+ readonly focus: {
67
+ readonly label: "Input focus";
68
+ readonly variants: readonly [{
69
+ readonly id: "bloom";
70
+ readonly label: "Bloom";
71
+ }, {
72
+ readonly id: "label";
73
+ readonly label: "Spring label";
74
+ }, {
75
+ readonly id: "none";
76
+ readonly label: "Off";
77
+ }];
78
+ };
79
+ readonly tabs: {
80
+ readonly label: "Tabs";
81
+ readonly variants: readonly [{
82
+ readonly id: "pill";
83
+ readonly label: "Pill";
84
+ }, {
85
+ readonly id: "stretch";
86
+ readonly label: "Stretch";
87
+ }, {
88
+ readonly id: "ink";
89
+ readonly label: "Ink";
90
+ }, {
91
+ readonly id: "none";
92
+ readonly label: "Off";
93
+ }];
94
+ };
95
+ readonly copy: {
96
+ readonly label: "Copy";
97
+ readonly variants: readonly [{
98
+ readonly id: "burst";
99
+ readonly label: "Burst";
100
+ }, {
101
+ readonly id: "tick";
102
+ readonly label: "Tick";
103
+ }, {
104
+ readonly id: "none";
105
+ readonly label: "Off";
106
+ }];
107
+ };
108
+ readonly link: {
109
+ readonly label: "Link hover";
110
+ readonly variants: readonly [{
111
+ readonly id: "sweep";
112
+ readonly label: "Sweep";
113
+ }, {
114
+ readonly id: "highlight";
115
+ readonly label: "Highlight";
116
+ }, {
117
+ readonly id: "none";
118
+ readonly label: "Off";
119
+ }];
120
+ };
121
+ readonly card: {
122
+ readonly label: "Card hover";
123
+ readonly variants: readonly [{
124
+ readonly id: "spotlight";
125
+ readonly label: "Spotlight";
126
+ }, {
127
+ readonly id: "orbit";
128
+ readonly label: "Orbit";
129
+ }, {
130
+ readonly id: "none";
131
+ readonly label: "Off";
132
+ }];
133
+ };
134
+ };
135
+ export type FxFamily = keyof typeof FX;
136
+ export type FxVariant<F extends FxFamily = FxFamily> = (typeof FX)[F]['variants'][number]['id'];
137
+ export type FxSettings = {
138
+ [F in FxFamily]: FxVariant<F>;
139
+ };
140
+ export declare const FX_FAMILIES: FxFamily[];
141
+ export declare const DEFAULT_FX: FxSettings;
142
+ export declare function fxAttr(family: FxFamily): `data-fx-${FxFamily}`;
143
+ export declare function isFxVariant<F extends FxFamily>(family: F, v: unknown): v is FxVariant<F>;
144
+ /** Settings from an untrusted blob: unknown families are dropped, unknown
145
+ * variants fall back to `defaults`. */
146
+ export declare function parseFx(raw: unknown, defaults?: FxSettings): FxSettings;
147
+ export declare function fxDiff(settings: FxSettings): Partial<FxSettings>;
148
+ /** Inline <head> script painting the persisted `data-fx-*` before first paint. */
149
+ export declare function fxPrepaint(key: string): string;
package/dist/fx.js ADDED
@@ -0,0 +1,111 @@
1
+ // `data-fx-<family>="<variant>"` on <html> or any ancestor picks a family's
2
+ // effect; without the attribute the first variant applies.
3
+ export const FX = {
4
+ hover: {
5
+ label: 'Button hover',
6
+ variants: [
7
+ { id: 'smoke', label: 'Smoke' },
8
+ { id: 'ember', label: 'Ember' },
9
+ { id: 'aurora', label: 'Aurora' },
10
+ { id: 'none', label: 'Off' },
11
+ ],
12
+ },
13
+ check: {
14
+ label: 'Checkbox',
15
+ variants: [
16
+ { id: 'draw', label: 'Draw' },
17
+ { id: 'pop', label: 'Pop' },
18
+ { id: 'confetti', label: 'Confetti' },
19
+ { id: 'morph', label: 'Morph' },
20
+ { id: 'none', label: 'Off' },
21
+ ],
22
+ },
23
+ radio: {
24
+ label: 'Radio',
25
+ variants: [
26
+ { id: 'spring', label: 'Spring' },
27
+ { id: 'liquid', label: 'Liquid' },
28
+ { id: 'none', label: 'Off' },
29
+ ],
30
+ },
31
+ switch: {
32
+ label: 'Switch',
33
+ variants: [
34
+ { id: 'squash', label: 'Squash' },
35
+ { id: 'glow', label: 'Glow' },
36
+ { id: 'calm', label: 'Calm' },
37
+ { id: 'none', label: 'Off' },
38
+ ],
39
+ },
40
+ focus: {
41
+ label: 'Input focus',
42
+ variants: [
43
+ { id: 'bloom', label: 'Bloom' },
44
+ { id: 'label', label: 'Spring label' },
45
+ { id: 'none', label: 'Off' },
46
+ ],
47
+ },
48
+ tabs: {
49
+ label: 'Tabs',
50
+ variants: [
51
+ { id: 'pill', label: 'Pill' },
52
+ { id: 'stretch', label: 'Stretch' },
53
+ { id: 'ink', label: 'Ink' },
54
+ { id: 'none', label: 'Off' },
55
+ ],
56
+ },
57
+ copy: {
58
+ label: 'Copy',
59
+ variants: [
60
+ { id: 'burst', label: 'Burst' },
61
+ { id: 'tick', label: 'Tick' },
62
+ { id: 'none', label: 'Off' },
63
+ ],
64
+ },
65
+ link: {
66
+ label: 'Link hover',
67
+ variants: [
68
+ { id: 'sweep', label: 'Sweep' },
69
+ { id: 'highlight', label: 'Highlight' },
70
+ { id: 'none', label: 'Off' },
71
+ ],
72
+ },
73
+ card: {
74
+ label: 'Card hover',
75
+ variants: [
76
+ { id: 'spotlight', label: 'Spotlight' },
77
+ { id: 'orbit', label: 'Orbit' },
78
+ { id: 'none', label: 'Off' },
79
+ ],
80
+ },
81
+ };
82
+ export const FX_FAMILIES = Object.keys(FX);
83
+ export const DEFAULT_FX = Object.fromEntries(FX_FAMILIES.map((f) => [f, FX[f].variants[0].id]));
84
+ export function fxAttr(family) {
85
+ return `data-fx-${family}`;
86
+ }
87
+ export function isFxVariant(family, v) {
88
+ return FX[family].variants.some((x) => x.id === v);
89
+ }
90
+ /** Settings from an untrusted blob: unknown families are dropped, unknown
91
+ * variants fall back to `defaults`. */
92
+ export function parseFx(raw, defaults = DEFAULT_FX) {
93
+ const blob = raw && typeof raw === 'object' ? raw : {};
94
+ const out = { ...defaults };
95
+ for (const f of FX_FAMILIES)
96
+ if (isFxVariant(f, blob[f]))
97
+ out[f] = blob[f];
98
+ return out;
99
+ }
100
+ export function fxDiff(settings) {
101
+ const out = {};
102
+ for (const f of FX_FAMILIES)
103
+ if (settings[f] !== DEFAULT_FX[f])
104
+ out[f] = settings[f];
105
+ return out;
106
+ }
107
+ /** Inline <head> script painting the persisted `data-fx-*` before first paint. */
108
+ export function fxPrepaint(key) {
109
+ const valid = Object.fromEntries(FX_FAMILIES.map((f) => [f, FX[f].variants.slice(1).map((v) => v.id)]));
110
+ return `(function(){try{var s=localStorage.getItem(${JSON.stringify(key)});if(!s)return;var b=JSON.parse(s),v=${JSON.stringify(valid)},d=document.documentElement;if(!b||typeof b!=='object')return;for(var f in v)if(v[f].indexOf(b[f])>=0)d.setAttribute('data-fx-'+f,b[f])}catch(e){}})();`;
111
+ }
package/dist/fx.md ADDED
@@ -0,0 +1,58 @@
1
+ # Micro-interactions
2
+
3
+ Each component family has a set of effects. One attribute on `<html>`, or on any ancestor, picks the
4
+ effect for that family:
5
+
6
+ ```html
7
+ <html data-fx-hover="ember" data-fx-check="pop"></html>
8
+ ```
9
+
10
+ With no attribute the first variant applies. `none` turns the family's effect off.
11
+
12
+ | Family | Attribute | Variants (first = default) |
13
+ | -------- | ---------------- | --------------------------------------- |
14
+ | `hover` | `data-fx-hover` | `smoke` `ember` `aurora` `none` |
15
+ | `check` | `data-fx-check` | `draw` `pop` `confetti` `morph` `none` |
16
+ | `radio` | `data-fx-radio` | `spring` `liquid` `none` |
17
+ | `switch` | `data-fx-switch` | `squash` `glow` `calm` `none` |
18
+ | `focus` | `data-fx-focus` | `bloom` `label` `none` |
19
+ | `tabs` | `data-fx-tabs` | `pill` `stretch` `ink` `none` |
20
+ | `copy` | `data-fx-copy` | `burst` `tick` `none` |
21
+ | `link` | `data-fx-link` | `sweep` `highlight` `none` |
22
+ | `card` | `data-fx-card` | `spotlight` `orbit` `none` |
23
+
24
+ Loaders aren't a page setting. They're component props: Spinner, Progress, Skeleton and Button
25
+ `loadingStyle`.
26
+
27
+ All effects take their colours from the theme, fall back to a static state change under
28
+ `prefers-reduced-motion: reduce`, only animate hover on fine pointers, and never shift layout.
29
+
30
+ ## Persisted settings
31
+
32
+ ```svelte
33
+ <script>
34
+ import { FX_PREPAINT, MotionSettings, fx } from '@dorsk/tsumikit';
35
+ </script>
36
+
37
+ <svelte:head>{@html `<script>${FX_PREPAINT}</` + `script>`}</svelte:head>
38
+
39
+ <MotionSettings size="sm" />
40
+ ```
41
+
42
+ - `fx` holds the settings (`fx.settings`, `fx.get(family)`, `fx.set(family, variant)`, `fx.reset()`), stores
43
+ the non-default families in localStorage under `tsumikit-fx` and writes `data-fx-*` on `<html>`. Defaults
44
+ leave the attribute absent.
45
+ - `FX_PREPAINT` paints the saved attributes before first paint. Inline it in `<head>` next to
46
+ `THEME_PREPAINT`.
47
+ - `fx.onchange` and `fx.hydrate(settings)` let an app keep the settings in its own (server-side) store.
48
+ - `FX`, `FX_FAMILIES`, `DEFAULT_FX` and `parseFx` expose the registry for custom controls.
49
+
50
+ ## Writing an effect
51
+
52
+ Scope every variant with `:where()` so the selector keeps zero specificity. Give the first variant the
53
+ `:not([…])` default:
54
+
55
+ ```css
56
+ :where(:root:not([data-fx-hover]), [data-fx-hover='smoke']) .btn-primary::before { … }
57
+ :where([data-fx-hover='ember']) .btn-primary::before { … }
58
+ ```
package/dist/index.d.ts CHANGED
@@ -31,6 +31,7 @@ export { default as Swatch } from './components/atoms/Swatch.svelte';
31
31
  export { default as Switch } from './components/atoms/Switch.svelte';
32
32
  export { default as Text } from './components/atoms/Text.svelte';
33
33
  export { default as Textarea } from './components/atoms/Textarea.svelte';
34
+ export { default as Thinking } from './components/atoms/Thinking.svelte';
34
35
  export { default as TimeDisplay } from './components/atoms/TimeDisplay.svelte';
35
36
  export { default as AppShell } from './components/layouts/AppShell.svelte';
36
37
  export { default as AutoGrid } from './components/layouts/AutoGrid.svelte';
@@ -74,10 +75,12 @@ export { default as IconButton } from './components/molecules/IconButton.svelte'
74
75
  export { default as InputGroup } from './components/molecules/InputGroup.svelte';
75
76
  export { default as KeyValue, type KeyValueRow, type KeyValueTone, } from './components/molecules/KeyValue.svelte';
76
77
  export { default as LoadMore } from './components/molecules/LoadMore.svelte';
78
+ export { default as LookPicker } from './components/molecules/LookPicker.svelte';
77
79
  export { default as Menu, type MenuItem } from './components/molecules/Menu.svelte';
78
80
  export { default as Meter } from './components/molecules/Meter.svelte';
79
81
  export { default as Metric, default as StatTile, type MetricSegment, } from './components/molecules/Metric.svelte';
80
82
  export { default as Modal } from './components/molecules/Modal.svelte';
83
+ export { default as MotionSettings } from './components/molecules/MotionSettings.svelte';
81
84
  export { default as OptionButton } from './components/molecules/OptionButton.svelte';
82
85
  export { default as Pagination } from './components/molecules/Pagination.svelte';
83
86
  export { default as Popover } from './components/molecules/Popover.svelte';
@@ -111,18 +114,21 @@ export { default as FilterSearchBar } from './components/organisms/FilterSearchB
111
114
  export { formatCount, hasCount } from './count';
112
115
  export { EMOJI_GROUPS, type EmojiEntry, type EmojiGroup, searchEmoji } from './emoji';
113
116
  export { FIELD_KEY, type FieldContext, getFieldContext, setFieldContext, warnUnlabelled, } from './field-context';
117
+ export { DEFAULT_FX, FX, FX_FAMILIES, type FxFamily, type FxSettings, type FxVariant, fxAttr, fxDiff, fxPrepaint, isFxVariant, parseFx, } from './fx';
114
118
  export { escapeHtml, type Highlight, type RenderMarkdownOptions, renderMarkdown, stripAnsi, } from './markdown';
115
119
  export { describeMediaTime, formatMediaTime, isLiveDuration } from './media-time';
120
+ export { trackPointer } from './pointer';
116
121
  export * as filterQuery from './query';
117
122
  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';
118
123
  export { type OptionSection, sectionOptions } from './select-options';
119
124
  export type { ControlSize } from './size';
120
125
  export { playSound, type SoundMuted, type SoundName } from './sound';
121
126
  export { fontScale, SCALE_LEVELS, type ScaleLevel } from './stores/fontscale.svelte';
127
+ export { FX_PREPAINT, fx } from './stores/fx.svelte';
122
128
  export { sound } from './stores/sound.svelte';
123
- export { type Mode, THEMES, theme } from './stores/theme.svelte';
129
+ export { type Mode, THEME_PREPAINT, THEMES, theme } from './stores/theme.svelte';
124
130
  export { type Toast, type ToastAction, type ToastOptions, type ToastTone, type ToastToneInput, toasts, } from './stores/toast.svelte';
125
- export { AUTO_THEME, chooseTheme, DEFAULT_THEME_PREFERENCE, pickerValue, preferenceFrom, resolveTheme, type SlotOf, type ThemeChoice, type ThemePreference, type ThemeSlot, } from './theme-mode';
131
+ export { AUTO_THEME, chooseTheme, DEFAULT_LOOK, DEFAULT_THEME_PREFERENCE, LOOKS, type LookId, type LookStatus, parseLook, pickerValue, preferenceFrom, resolveTheme, type SlotOf, type ThemeChoice, type ThemePreference, type ThemeSlot, themePrepaint, } from './theme-mode';
126
132
  export { formatTimestamp, localTimeZone, relativeTime, type TimeInput, type TimestampMode, } from './timestamp';
127
133
  export { canonicalTone, type Tone } from './tone';
128
134
  export { pathCandidates, type TruncateMode, type TruncateOptions, truncate } from './truncate';
package/dist/index.js CHANGED
@@ -34,6 +34,7 @@ export { default as Switch } from './components/atoms/Switch.svelte';
34
34
  // ---- atoms ----
35
35
  export { default as Text } from './components/atoms/Text.svelte';
36
36
  export { default as Textarea } from './components/atoms/Textarea.svelte';
37
+ export { default as Thinking } from './components/atoms/Thinking.svelte';
37
38
  export { default as TimeDisplay } from './components/atoms/TimeDisplay.svelte';
38
39
  // ---- layouts ----
39
40
  export { default as AppShell } from './components/layouts/AppShell.svelte';
@@ -79,12 +80,14 @@ export { default as IconButton } from './components/molecules/IconButton.svelte'
79
80
  export { default as InputGroup } from './components/molecules/InputGroup.svelte';
80
81
  export { default as KeyValue, } from './components/molecules/KeyValue.svelte';
81
82
  export { default as LoadMore } from './components/molecules/LoadMore.svelte';
83
+ export { default as LookPicker } from './components/molecules/LookPicker.svelte';
82
84
  export { default as Menu } from './components/molecules/Menu.svelte';
83
85
  export { default as Meter } from './components/molecules/Meter.svelte';
84
86
  export { default as Metric,
85
87
  // StatTile is an alias for Metric — same component, dashboard-friendly name.
86
88
  default as StatTile, } from './components/molecules/Metric.svelte';
87
89
  export { default as Modal } from './components/molecules/Modal.svelte';
90
+ export { default as MotionSettings } from './components/molecules/MotionSettings.svelte';
88
91
  export { default as OptionButton } from './components/molecules/OptionButton.svelte';
89
92
  export { default as Pagination } from './components/molecules/Pagination.svelte';
90
93
  export { default as Popover } from './components/molecules/Popover.svelte';
@@ -119,19 +122,22 @@ export { default as FilterSearchBar } from './components/organisms/FilterSearchB
119
122
  export { formatCount, hasCount } from './count';
120
123
  export { EMOJI_GROUPS, searchEmoji } from './emoji';
121
124
  export { FIELD_KEY, getFieldContext, setFieldContext, warnUnlabelled, } from './field-context';
125
+ export { DEFAULT_FX, FX, FX_FAMILIES, fxAttr, fxDiff, fxPrepaint, isFxVariant, parseFx, } from './fx';
122
126
  export { escapeHtml, renderMarkdown, stripAnsi, } from './markdown';
123
127
  export { describeMediaTime, formatMediaTime, isLiveDuration } from './media-time';
128
+ export { trackPointer } from './pointer';
124
129
  // ---- query core (headless: schema / parser / AST / suggest / compilers) ----
125
130
  export * as filterQuery from './query';
126
131
  export { activeToken, compilePredicate, defaultOperator, filters, findField, freeText, OPERATORS, operatorByCode, operatorById, operatorsFor, parse, resolveValues, serialize, serializeFilter, singleQuery, suggest, suggestSingle, toSql, walk, } from './query';
127
132
  export { sectionOptions } from './select-options';
128
133
  export { playSound } from './sound';
129
134
  export { fontScale, SCALE_LEVELS } from './stores/fontscale.svelte';
135
+ export { FX_PREPAINT, fx } from './stores/fx.svelte';
130
136
  // ---- stores / actions ----
131
137
  export { sound } from './stores/sound.svelte';
132
- export { THEMES, theme } from './stores/theme.svelte';
138
+ export { THEME_PREPAINT, THEMES, theme } from './stores/theme.svelte';
133
139
  export { toasts, } from './stores/toast.svelte';
134
- export { AUTO_THEME, chooseTheme, DEFAULT_THEME_PREFERENCE, pickerValue, preferenceFrom, resolveTheme, } from './theme-mode';
140
+ export { AUTO_THEME, chooseTheme, DEFAULT_LOOK, DEFAULT_THEME_PREFERENCE, LOOKS, parseLook, pickerValue, preferenceFrom, resolveTheme, themePrepaint, } from './theme-mode';
135
141
  export { formatTimestamp, localTimeZone, relativeTime, } from './timestamp';
136
142
  export { canonicalTone } from './tone';
137
143
  export { pathCandidates, truncate } from './truncate';