@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
@@ -39,6 +39,7 @@
39
39
 
40
40
  const labelShown = $derived(showLabel ?? box === undefined);
41
41
  let copied = $state(false);
42
+ let run = $state(0);
42
43
  let status = $state('');
43
44
  let timer: ReturnType<typeof setTimeout> | undefined;
44
45
 
@@ -46,6 +47,7 @@
46
47
  const ok = await copyToClipboard(text);
47
48
  playSound(ok ? 'success' : 'error', sound);
48
49
  copied = ok;
50
+ if (ok) run++;
49
51
  status = ok ? copiedLabel : 'Copy failed';
50
52
  clearTimeout(timer);
51
53
  timer = setTimeout(() => {
@@ -63,26 +65,33 @@
63
65
  class="copy-btn {labelShown && box !== undefined ? 'copy-labelled' : ''} {klass}"
64
66
  sound={false}
65
67
  onclick={copy}
68
+ data-copied={copied || undefined}
66
69
  aria-label={copied ? copiedLabel : label}
67
70
  title={label}
68
71
  >
69
- <span class="glyphs" class:copied aria-hidden="true">
72
+ <span class="glyphs" class:copied class:tinted={variant === 'ghost' || variant === 'default'} aria-hidden="true">
70
73
  <span class="glyph copy"><Icon name="copy" /></span>
71
74
  <span class="glyph check"><Icon name="check" /></span>
75
+ <svg class="glyph tick" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.25" stroke-linecap="round" stroke-linejoin="round">
76
+ <path d="M4.5 12.5l5 5L19.5 7" pathLength="1" />
77
+ </svg>
78
+ {#key run}<span class="sparks"></span>{/key}
72
79
  </span>
73
- {#if labelShown}<span>{copied ? copiedLabel : label}</span>{/if}
80
+ {#if labelShown}
81
+ <span class="words" class:copied aria-hidden="true"><span class="word idle">{label}</span><span class="word done">{copiedLabel}</span></span>
82
+ {/if}
74
83
  </Button>
75
84
  <span class="sr-only" role="status" aria-live="polite">{status}</span>
76
85
 
77
86
  <style>
78
- /* Both glyphs share one grid cell so the swap never changes the width. The
79
- check springs in (the kit's one bounce: a rare confirmation); the copy
80
- glyph fades out fast and back in over the base duration on revert. */
87
+ /* Both glyphs share one grid cell and both words another, so the swap never
88
+ changes the width. */
81
89
  :global(.copy-btn.btn-box.copy-labelled) {
82
90
  width: auto;
83
91
  padding-inline: var(--sp-2);
84
92
  }
85
93
  .glyphs {
94
+ position: relative;
86
95
  display: inline-grid;
87
96
  place-items: center;
88
97
  }
@@ -111,4 +120,136 @@
111
120
  opacity var(--dur-spring-bounce) ease,
112
121
  scale var(--dur-spring-bounce) var(--ease-spring-bounce);
113
122
  }
123
+ .tick,
124
+ .sparks {
125
+ display: none;
126
+ }
127
+ .words {
128
+ display: inline-grid;
129
+ overflow: hidden;
130
+ text-align: start;
131
+ }
132
+ .word {
133
+ grid-area: 1 / 1;
134
+ }
135
+ .done,
136
+ .copied .idle {
137
+ opacity: 0;
138
+ }
139
+ .copied .done {
140
+ opacity: 1;
141
+ }
142
+
143
+ /* Morph burst: the check springs in on a turn inside a ring of eight sparks. */
144
+ .glyphs:not(:global([data-fx-copy]:not([data-fx-copy='burst'])) *),
145
+ :global([data-fx-copy='burst']) .glyphs:not(:global([data-fx-copy='burst'] [data-fx-copy]:not([data-fx-copy='burst'])) *) {
146
+ & .check {
147
+ scale: 0.4;
148
+ rotate: 60deg;
149
+ transition-property: opacity, scale, rotate;
150
+ transition-duration: var(--dur-exit-fast);
151
+ transition-timing-function: ease;
152
+ }
153
+ &.copied .check {
154
+ scale: 1;
155
+ rotate: 0deg;
156
+ transition-duration: var(--dur-fast), var(--dur-spring), var(--dur-spring);
157
+ transition-delay: calc(var(--dur-exit-fast) / 2);
158
+ transition-timing-function: ease, var(--ease-spring-bounce), var(--ease-out);
159
+ }
160
+ & .sparks {
161
+ --spark-a: color-mix(in srgb, var(--ok) 85%, transparent);
162
+ --spark-b: color-mix(in srgb, var(--accent) 85%, transparent);
163
+ position: absolute;
164
+ inset: -9px;
165
+ display: block;
166
+ pointer-events: none;
167
+ background:
168
+ radial-gradient(circle at 50% 4%, var(--spark-a) 1.6px, transparent 2.2px),
169
+ radial-gradient(circle at 83% 17%, var(--spark-b) 1.3px, transparent 1.9px),
170
+ radial-gradient(circle at 96% 50%, var(--spark-a) 1.6px, transparent 2.2px),
171
+ radial-gradient(circle at 83% 83%, var(--spark-b) 1.3px, transparent 1.9px),
172
+ radial-gradient(circle at 50% 96%, var(--spark-a) 1.6px, transparent 2.2px),
173
+ radial-gradient(circle at 17% 83%, var(--spark-b) 1.3px, transparent 1.9px),
174
+ radial-gradient(circle at 4% 50%, var(--spark-a) 1.6px, transparent 2.2px),
175
+ radial-gradient(circle at 17% 17%, var(--spark-b) 1.3px, transparent 1.9px);
176
+ opacity: 0;
177
+ }
178
+ &.copied .sparks {
179
+ animation: copy-burst calc(var(--dur-slow) + var(--dur-exit-fast)) var(--ease-out);
180
+ }
181
+ }
182
+ @keyframes copy-burst {
183
+ 0% {
184
+ opacity: 0;
185
+ scale: 0.35;
186
+ rotate: -20deg;
187
+ }
188
+ 30% {
189
+ opacity: 1;
190
+ }
191
+ 100% {
192
+ opacity: 0;
193
+ scale: 1.15;
194
+ rotate: 0deg;
195
+ }
196
+ }
197
+
198
+ /* Tick and slide: the tick draws itself while the label rolls up into its
199
+ copied form. */
200
+ :global([data-fx-copy='tick']) .glyphs:not(:global([data-fx-copy='tick'] [data-fx-copy]:not([data-fx-copy='tick'])) *) {
201
+ & .check {
202
+ display: none;
203
+ }
204
+ & .tick {
205
+ display: block;
206
+ width: 1em;
207
+ height: 1em;
208
+ opacity: 0;
209
+ transition: opacity var(--dur-exit-fast) ease var(--dur-instant);
210
+ }
211
+ &.copied .tick {
212
+ opacity: 1;
213
+ transition-duration: var(--dur-fast);
214
+ }
215
+ & .tick path {
216
+ stroke-dasharray: 1;
217
+ stroke-dashoffset: 1;
218
+ transition: stroke-dashoffset var(--dur-instant) ease var(--dur-exit-fast);
219
+ }
220
+ &.copied .tick path {
221
+ stroke-dashoffset: 0;
222
+ transition: stroke-dashoffset var(--dur-slow) var(--ease-out) calc(var(--dur-fast) / 2);
223
+ }
224
+ & .copy {
225
+ transition-property: opacity, scale;
226
+ transition-duration: var(--dur-base), var(--dur-spring);
227
+ transition-timing-function: ease, var(--ease-spring);
228
+ }
229
+ &.copied .copy {
230
+ scale: 0.6;
231
+ transition-duration: var(--dur-exit-fast), var(--dur-exit);
232
+ }
233
+ }
234
+ :global([data-fx-copy='tick']) .words:not(:global([data-fx-copy='tick'] [data-fx-copy]:not([data-fx-copy='tick'])) *) {
235
+ & .word {
236
+ transition-property: opacity, translate;
237
+ transition-duration: var(--dur-base), var(--dur-spring);
238
+ transition-timing-function: ease, var(--ease-spring);
239
+ }
240
+ & .done {
241
+ translate: 0 100%;
242
+ }
243
+ &.copied .idle {
244
+ translate: 0 -100%;
245
+ transition-duration: var(--dur-exit-fast), var(--dur-exit);
246
+ }
247
+ &.copied .done {
248
+ translate: 0 0;
249
+ transition-delay: calc(var(--dur-exit-fast) / 2);
250
+ }
251
+ }
252
+ .tinted.copied :is(.check, .tick) {
253
+ color: var(--ok);
254
+ }
114
255
  </style>
@@ -59,7 +59,7 @@
59
59
 
60
60
  <div class="field {klass}" class:field-grow={grow} class:field-no-shrink={!shrink} class:field-inline={layout === 'inline'} data-tsu="Field">
61
61
  {#if label}
62
- <label class="label" for={forId} style:width={labelWidth}>{label}</label>
62
+ <label class="label" for={forId} style:width={labelWidth}><span class="label-text">{label}</span></label>
63
63
  {/if}
64
64
  {@render children?.()}
65
65
  {#if hint}
@@ -103,4 +103,58 @@
103
103
  font-size: var(--fs-xs);
104
104
  color: var(--danger);
105
105
  }
106
+
107
+ /* Spring label: over a md text field the label rests inside the control and
108
+ springs into its top edge on focus or once filled. */
109
+ :global([data-fx-focus='label']) .field:not(.field-inline):not(:global([data-fx-focus='label'] [data-fx-focus]:not([data-fx-focus='label'])) *):has(> .label + :global(input.input:not(.input-sm, .input-lg, .grouped)), > .label + :global(.textarea-wrap:not(.grouped) > .textarea:not(.textarea-sm, .textarea-lg))) {
110
+ --float-h: 3.25rem;
111
+ position: relative;
112
+ & > .label {
113
+ position: absolute;
114
+ inset-block-start: 0;
115
+ inset-inline-start: 0;
116
+ z-index: 1;
117
+ display: flex;
118
+ align-items: center;
119
+ max-width: 100%;
120
+ height: var(--float-h);
121
+ padding-inline: calc(var(--control-px-md) + var(--edge-width));
122
+ font-size: var(--control-fs-md);
123
+ pointer-events: none;
124
+ }
125
+ & .label-text {
126
+ overflow: hidden;
127
+ text-overflow: ellipsis;
128
+ white-space: nowrap;
129
+ line-height: var(--lh-none);
130
+ transform-origin: left center;
131
+ transition-property: translate, scale, color;
132
+ transition-duration: var(--dur-spring), var(--dur-spring), var(--dur-fast);
133
+ transition-timing-function: var(--ease-spring-bounce), var(--ease-spring-bounce), ease;
134
+ }
135
+ & :global(:is(.input, .textarea)) {
136
+ min-height: var(--float-h);
137
+ padding-top: 1.35rem;
138
+ padding-bottom: 0.4rem;
139
+ }
140
+ & :global(:is(.input, .textarea):not(:focus)::placeholder) {
141
+ color: transparent;
142
+ }
143
+ & :global(:is(.input, .textarea):focus) {
144
+ animation: none;
145
+ box-shadow:
146
+ var(--control-shadow),
147
+ inset 0 -1px 0 var(--focus-tint);
148
+ }
149
+ &:has(:global(:is(.input, .textarea):is(:focus, :not(:placeholder-shown), :autofill))) .label-text {
150
+ translate: 0 -0.7rem;
151
+ scale: 0.78;
152
+ }
153
+ &:has(:global(:is(.input, .textarea):focus)) > .label {
154
+ color: var(--accent);
155
+ }
156
+ &:has(:global([aria-invalid='true'])) > .label {
157
+ color: var(--danger);
158
+ }
159
+ }
106
160
  </style>
@@ -122,7 +122,8 @@
122
122
  cursor: pointer;
123
123
  user-select: none;
124
124
  --icon-size: var(--control-icon-md);
125
- --file-scale-dur: var(--dur-fast);
125
+ --file-scale-dur: var(--dur-release);
126
+ --file-scale-ease: var(--ease-spring-bounce);
126
127
  transition:
127
128
  background var(--dur-fast) ease,
128
129
  border-color var(--dur-fast) ease,
@@ -130,10 +131,11 @@
130
131
  color var(--dur-fast) ease,
131
132
  opacity var(--dur-fast) ease,
132
133
  translate var(--dur-fast) var(--ease-out),
133
- scale var(--file-scale-dur) var(--ease-out);
134
+ scale var(--file-scale-dur) var(--file-scale-ease);
134
135
  }
135
136
  .file-btn:active:not(.disabled) {
136
137
  --file-scale-dur: var(--dur-press);
138
+ --file-scale-ease: var(--ease-out);
137
139
  scale: var(--press-scale);
138
140
  }
139
141
  .file-btn.icon-only:active:not(.disabled),
@@ -234,7 +234,7 @@
234
234
  .ig-bar:has(.ig-field :global([aria-invalid='true']))::before {
235
235
  border-color: var(--danger);
236
236
  }
237
- .ig:has(:focus-visible)::after {
237
+ .ig:has(:global(:focus-visible))::after {
238
238
  content: '';
239
239
  position: absolute;
240
240
  inset: 0;
@@ -0,0 +1,100 @@
1
+ <script lang="ts">
2
+ import type { ComponentProps } from 'svelte';
3
+ import SegmentedControl, { type SegmentOption } from './SegmentedControl.svelte';
4
+ import { browser } from '../../env';
5
+ import { theme } from '../../stores/theme.svelte';
6
+ import { LOOKS, type LookId } from '../../theme-mode';
7
+
8
+ let {
9
+ looks,
10
+ experimental = true,
11
+ experimentalLabel = 'beta',
12
+ label = 'Look',
13
+ size = 'md',
14
+ control,
15
+ block,
16
+ scroll,
17
+ sound,
18
+ onchange,
19
+ class: klass = '',
20
+ }: Pick<ComponentProps<typeof SegmentedControl>, 'size' | 'control' | 'block' | 'scroll' | 'sound'> & {
21
+ /** Restrict and order the offered looks; defaults to every registered look. */
22
+ looks?: readonly LookId[];
23
+ /** Offer looks whose status is `experimental`. */
24
+ experimental?: boolean;
25
+ /** Badge on experimental looks; empty hides it. */
26
+ experimentalLabel?: string;
27
+ label?: string;
28
+ onchange?: (look: LookId) => void;
29
+ class?: string;
30
+ } = $props();
31
+
32
+ const options = $derived(
33
+ (looks ?? LOOKS.map((l) => l.id)).flatMap((id) => {
34
+ const l = LOOKS.find((x) => x.id === id);
35
+ const beta = l?.status === 'experimental';
36
+ if (!l || (beta && !experimental)) return [];
37
+ return [{ value: l.id, label: l.label }];
38
+ })
39
+ );
40
+ const tagged = new Set<string>(LOOKS.filter((l) => l.status === 'experimental').map((l) => l.id));
41
+ </script>
42
+
43
+ {#snippet option(o: SegmentOption)}
44
+ <span class="look" data-look-option={o.value}>
45
+ <span class="name">{o.label}</span>
46
+ {#if experimentalLabel && tagged.has(o.value)}<span class="tag">{experimentalLabel}</span>{/if}
47
+ </span>
48
+ {/snippet}
49
+
50
+ <SegmentedControl
51
+ {options}
52
+ value={browser ? theme.look : undefined}
53
+ onchange={(v) => {
54
+ theme.setLook(v);
55
+ onchange?.(theme.look);
56
+ }}
57
+ {label}
58
+ {size}
59
+ {control}
60
+ {block}
61
+ {scroll}
62
+ {sound}
63
+ class="look-picker {klass}"
64
+ {option}
65
+ />
66
+
67
+ <style>
68
+ .look {
69
+ display: inline-flex;
70
+ align-items: center;
71
+ gap: var(--sp-1);
72
+ min-width: 0;
73
+ }
74
+ :global(.seg-block) .name {
75
+ min-width: 0;
76
+ overflow: hidden;
77
+ text-overflow: ellipsis;
78
+ }
79
+ /* Prerendered markup selects nothing; until hydration the look prepainted on <html> shows as selected. */
80
+ :global(:root:not([data-look]) .look-picker:not(:has(.selected)) .seg-item:has(> [data-look-option='default'])),
81
+ :global(:root[data-look='depth'] .look-picker:not(:has(.selected)) .seg-item:has(> [data-look-option='depth'])),
82
+ :global(:root[data-look='tactile'] .look-picker:not(:has(.selected)) .seg-item:has(> [data-look-option='tactile'])),
83
+ :global(:root[data-look='editorial'] .look-picker:not(:has(.selected)) .seg-item:has(> [data-look-option='editorial'])) {
84
+ color: var(--text);
85
+ background: var(--thumb-fill);
86
+ box-shadow: var(--thumb-shadow);
87
+ }
88
+ :global(:root[data-look='editorial'] .look-picker:not(:has(.selected)) .seg-item:has(> [data-look-option='editorial'])) {
89
+ color: var(--bg);
90
+ }
91
+ .tag {
92
+ flex: none;
93
+ padding: 0 var(--sp-1);
94
+ border: 1px solid currentColor;
95
+ border-radius: var(--r-pill);
96
+ font-size: var(--fs-xs);
97
+ font-weight: var(--fw-semibold);
98
+ line-height: var(--lh-tight);
99
+ }
100
+ </style>
@@ -0,0 +1,17 @@
1
+ import type { ComponentProps } from 'svelte';
2
+ import SegmentedControl from './SegmentedControl.svelte';
3
+ import { type LookId } from '../../theme-mode';
4
+ type $$ComponentProps = Pick<ComponentProps<typeof SegmentedControl>, 'size' | 'control' | 'block' | 'scroll' | 'sound'> & {
5
+ /** Restrict and order the offered looks; defaults to every registered look. */
6
+ looks?: readonly LookId[];
7
+ /** Offer looks whose status is `experimental`. */
8
+ experimental?: boolean;
9
+ /** Badge on experimental looks; empty hides it. */
10
+ experimentalLabel?: string;
11
+ label?: string;
12
+ onchange?: (look: LookId) => void;
13
+ class?: string;
14
+ };
15
+ declare const LookPicker: import("svelte").Component<$$ComponentProps, {}, "">;
16
+ type LookPicker = ReturnType<typeof LookPicker>;
17
+ export default LookPicker;
@@ -0,0 +1,166 @@
1
+ <script lang="ts">
2
+ import type { ComponentProps } from 'svelte';
3
+ import Button from '../atoms/Button.svelte';
4
+ import Icon from '../atoms/Icon.svelte';
5
+ import Select from '../atoms/Select.svelte';
6
+ import Popover from './Popover.svelte';
7
+ import { FX, FX_FAMILIES, type FxFamily, type FxSettings } from '../../fx';
8
+ import { fx } from '../../stores/fx.svelte';
9
+
10
+ type TriggerChrome = Pick<
11
+ ComponentProps<typeof Popover>,
12
+ 'variant' | 'tone' | 'box' | 'pill' | 'control' | 'block' | 'bare' | 'hitArea' | 'placement' | 'disabled'
13
+ >;
14
+
15
+ let {
16
+ families = FX_FAMILIES,
17
+ size = 'md',
18
+ box = size,
19
+ placement = 'bottom-end',
20
+ variant,
21
+ tone,
22
+ pill,
23
+ control,
24
+ block,
25
+ bare,
26
+ hitArea,
27
+ disabled,
28
+ label = 'Motion',
29
+ resetLabel = 'Reset',
30
+ reducedMotionHint = 'Your system asks for reduced motion: effects show their static fallback.',
31
+ onchange,
32
+ class: klass = '',
33
+ style: styleProp = '',
34
+ }: TriggerChrome & {
35
+ /** Restrict and order the offered families. */
36
+ families?: readonly FxFamily[];
37
+ size?: 'sm' | 'md';
38
+ label?: string;
39
+ resetLabel?: string;
40
+ reducedMotionHint?: string;
41
+ onchange?: (settings: FxSettings) => void;
42
+ class?: string;
43
+ style?: string;
44
+ } = $props();
45
+
46
+ const uid = $props.id();
47
+ let reduced = $state(false);
48
+
49
+ $effect(() => {
50
+ const mq = globalThis.matchMedia?.('(prefers-reduced-motion: reduce)');
51
+ if (!mq) return;
52
+ reduced = mq.matches;
53
+ const on = (e: MediaQueryListEvent) => (reduced = e.matches);
54
+ mq.addEventListener?.('change', on);
55
+ return () => mq.removeEventListener?.('change', on);
56
+ });
57
+
58
+ const offCount = $derived(families.filter((f) => fx.settings[f] === 'none').length);
59
+
60
+ function pick(family: FxFamily, value: string) {
61
+ fx.set(family, value);
62
+ onchange?.(fx.settings);
63
+ }
64
+ </script>
65
+
66
+ <Popover
67
+ {label}
68
+ {placement}
69
+ {box}
70
+ {variant}
71
+ {tone}
72
+ {pill}
73
+ {control}
74
+ {block}
75
+ {bare}
76
+ {hitArea}
77
+ {disabled}
78
+ triggerClass={klass}
79
+ style={styleProp}
80
+ sound={false}
81
+ >
82
+ {#snippet trigger()}
83
+ <span
84
+ data-tsu="MotionSettings"
85
+ class="glyph"
86
+ title={offCount === families.length ? `${label}: off` : label}
87
+ >
88
+ <Icon name="zap" size={size === 'sm' ? 16 : 18} />
89
+ </span>
90
+ {/snippet}
91
+ <div class="panel" class:panel-sm={size === 'sm'}>
92
+ <div class="grid">
93
+ {#each families as family (family)}
94
+ <label class="name" for="{uid}-{family}">{FX[family].label}</label>
95
+ <Select
96
+ id="{uid}-{family}"
97
+ size="sm"
98
+ value={fx.settings[family]}
99
+ options={FX[family].variants.map((v) => ({ value: v.id, label: v.label }))}
100
+ onchange={(e) => pick(family, (e.currentTarget as HTMLSelectElement).value)}
101
+ />
102
+ {/each}
103
+ </div>
104
+ <div class="row">
105
+ {#if reduced}
106
+ <div class="hint" aria-live="polite">{reducedMotionHint}</div>
107
+ {/if}
108
+ <Button
109
+ size="sm"
110
+ variant="ghost"
111
+ class="reset"
112
+ disabled={fx.isDefault}
113
+ sound={false}
114
+ onclick={() => {
115
+ fx.reset();
116
+ onchange?.(fx.settings);
117
+ }}>{resetLabel}</Button
118
+ >
119
+ </div>
120
+ </div>
121
+ </Popover>
122
+
123
+ <style>
124
+ .glyph {
125
+ display: inline-flex;
126
+ line-height: var(--lh-none);
127
+ }
128
+ .panel {
129
+ width: min(19rem, calc(100vw - var(--sp-6)));
130
+ box-sizing: border-box;
131
+ display: grid;
132
+ gap: var(--sp-2);
133
+ padding: var(--sp-3);
134
+ font-size: 0.875rem;
135
+ line-height: var(--lh-snug);
136
+ }
137
+ .panel-sm {
138
+ width: min(17rem, calc(100vw - var(--sp-6)));
139
+ padding: var(--sp-2) var(--sp-3);
140
+ font-size: 0.8125rem;
141
+ }
142
+ .grid {
143
+ display: grid;
144
+ grid-template-columns: auto minmax(0, 1fr);
145
+ align-items: center;
146
+ gap: var(--sp-1) var(--sp-3);
147
+ }
148
+ .name {
149
+ color: var(--text-muted);
150
+ white-space: nowrap;
151
+ }
152
+ .row {
153
+ display: flex;
154
+ align-items: center;
155
+ gap: var(--sp-2);
156
+ }
157
+ .row > :global(.reset) {
158
+ margin-inline-start: auto;
159
+ }
160
+ .hint {
161
+ flex: 1 1 0;
162
+ min-width: 0;
163
+ font-size: 0.75rem;
164
+ color: var(--text-muted);
165
+ }
166
+ </style>
@@ -0,0 +1,18 @@
1
+ import type { ComponentProps } from 'svelte';
2
+ import Popover from './Popover.svelte';
3
+ import { type FxFamily, type FxSettings } from '../../fx';
4
+ type TriggerChrome = Pick<ComponentProps<typeof Popover>, 'variant' | 'tone' | 'box' | 'pill' | 'control' | 'block' | 'bare' | 'hitArea' | 'placement' | 'disabled'>;
5
+ type $$ComponentProps = TriggerChrome & {
6
+ /** Restrict and order the offered families. */
7
+ families?: readonly FxFamily[];
8
+ size?: 'sm' | 'md';
9
+ label?: string;
10
+ resetLabel?: string;
11
+ reducedMotionHint?: string;
12
+ onchange?: (settings: FxSettings) => void;
13
+ class?: string;
14
+ style?: string;
15
+ };
16
+ declare const MotionSettings: import("svelte").Component<$$ComponentProps, {}, "">;
17
+ type MotionSettings = ReturnType<typeof MotionSettings>;
18
+ export default MotionSettings;
@@ -203,6 +203,67 @@
203
203
  opacity var(--dur-spring) var(--ease-spring),
204
204
  scale var(--dur-spring) var(--ease-spring);
205
205
  }
206
+ /* Effects follow the closest data-fx-radio ancestor; spring is the default. */
207
+ .radio-group:not(:global([data-fx-radio]:not([data-fx-radio='spring'])) *),
208
+ :global([data-fx-radio='spring']) .radio-group:not(:global([data-fx-radio='spring'] [data-fx-radio]:not([data-fx-radio='spring'])) *) {
209
+ & .dot-ctl::after {
210
+ inset: 22%;
211
+ width: auto;
212
+ height: auto;
213
+ margin: 0;
214
+ opacity: 1;
215
+ scale: 0;
216
+ transition: scale var(--dur-exit-fast) var(--ease-out);
217
+ }
218
+ & input:checked + .dot-ctl::after {
219
+ scale: 1;
220
+ transition: scale var(--dur-spring) var(--ease-spring-bounce);
221
+ }
222
+ }
223
+
224
+ /* Accent rises like liquid with a wobbling surface, then the pip drops in. */
225
+ :global([data-fx-radio='liquid']) .radio-group:not(:global([data-fx-radio='liquid'] [data-fx-radio]:not([data-fx-radio='liquid'])) *) {
226
+ & .dot-ctl {
227
+ overflow: hidden;
228
+ }
229
+ & .dot-ctl::before {
230
+ content: '';
231
+ position: absolute;
232
+ left: -50%;
233
+ top: 0;
234
+ width: 200%;
235
+ height: 200%;
236
+ border-radius: 42% 46% 40% 44%;
237
+ background: var(--accent);
238
+ translate: 0 50%;
239
+ pointer-events: none;
240
+ transition:
241
+ translate var(--dur-exit) var(--ease-in-out),
242
+ rotate var(--dur-exit) var(--ease-in-out);
243
+ }
244
+ & input:checked + .dot-ctl::before {
245
+ translate: 0 -30%;
246
+ rotate: 150deg;
247
+ transition:
248
+ translate var(--dur-slow) var(--ease-out),
249
+ rotate var(--dur-slow) var(--ease-out);
250
+ }
251
+ & .dot-ctl::after {
252
+ inset: 30%;
253
+ width: auto;
254
+ height: auto;
255
+ margin: 0;
256
+ background: var(--text-on-accent);
257
+ opacity: 1;
258
+ scale: 0;
259
+ transition: scale var(--dur-exit-fast) var(--ease-out);
260
+ }
261
+ & input:checked + .dot-ctl::after {
262
+ scale: 1;
263
+ transition: scale var(--dur-exit) var(--ease-spring-bounce) var(--dur-fast);
264
+ }
265
+ }
266
+
206
267
  input:focus-visible + .dot-ctl {
207
268
  outline: var(--focus-ring);
208
269
  outline-offset: var(--focus-ring-offset);
@@ -258,6 +258,8 @@
258
258
  border: 0;
259
259
  background: none;
260
260
  font: inherit;
261
+ text-transform: inherit;
262
+ letter-spacing: inherit;
261
263
  color: inherit;
262
264
  text-align: start;
263
265
  cursor: pointer;