@aiaiai-pt/design-system 0.45.0 → 0.46.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,152 @@
1
+ <!--
2
+ @component JsonEditor
3
+
4
+ JSON field over the DS CodeEditor (#38, atelier#669 V1) with an explicit
5
+ invalid-state contract: invalid text NEVER silently drops or half-parses —
6
+ the field surfaces the parse error, emits NOTHING, and reports invalidity
7
+ through `oninvalid` so the host form can block submit. Empty and `{}` are
8
+ distinct (empty → null).
9
+
10
+ @example
11
+ <JsonEditor
12
+ label="METADATA"
13
+ value={{ kind: 'sensor' }}
14
+ onchange={(v) => save(v)}
15
+ oninvalid={(message) => (blocked = message !== null)}
16
+ />
17
+ -->
18
+ <script module>
19
+ let _jsonEditorUid = 0;
20
+ </script>
21
+
22
+ <script>
23
+ import CodeEditor from './CodeEditor.svelte';
24
+ import { evaluateJsonDraft, formatJsonValue } from './json-editor-contract';
25
+
26
+ let {
27
+ /** @type {unknown} — the current JSON value (object/array/scalar/null) */
28
+ value = null,
29
+ /** @type {string | undefined} */
30
+ label = undefined,
31
+ /** @type {string | undefined} */
32
+ help = undefined,
33
+ /** @type {string | undefined} — EXTERNAL (server) error; parse errors
34
+ * render through the same chrome automatically */
35
+ error = undefined,
36
+ /** @type {boolean} */
37
+ disabled = false,
38
+ /** @type {boolean} */
39
+ readonly = false,
40
+ /** @type {((value: unknown) => void) | undefined} — fires ONLY on a
41
+ * valid parse (or empty → null); never fires for invalid text */
42
+ onchange = undefined,
43
+ /** @type {((message: string | null) => void) | undefined} — parse-state
44
+ * transitions: a message while the draft is invalid, null when it
45
+ * becomes valid again. The host blocks submit while non-null. */
46
+ oninvalid = undefined,
47
+ /** @type {string} */
48
+ class: className = '',
49
+ ...rest
50
+ } = $props();
51
+
52
+ const editorId = `json-editor-${_jsonEditorUid++}`;
53
+ const hintId = `${editorId}-hint`;
54
+
55
+ let text = $state(formatJsonValue(value));
56
+ let parseError = $state(/** @type {string | null} */ (null));
57
+
58
+ // Watch the CodeEditor-bound text: evaluate on every draft change. The
59
+ // pipeline itself is the pure json-editor-contract module (CodeMirror is
60
+ // not edit-drivable under jsdom, so the contract carries the test weight).
61
+ let lastEvaluated = text;
62
+ $effect(() => {
63
+ if (text === lastEvaluated) return;
64
+ lastEvaluated = text;
65
+ const result = evaluateJsonDraft(text);
66
+ if (result.ok) {
67
+ parseError = null;
68
+ oninvalid?.(null);
69
+ onchange?.(result.value);
70
+ } else {
71
+ parseError = result.error;
72
+ oninvalid?.(result.error);
73
+ }
74
+ });
75
+
76
+ const shownError = $derived(error ?? parseError ?? undefined);
77
+ </script>
78
+
79
+ <div class="json-editor {className}" {...rest}>
80
+ {#if label}
81
+ <span class="json-editor-label" id={`${editorId}-label`}>{label}</span>
82
+ {/if}
83
+ <div
84
+ class="json-editor-surface"
85
+ class:json-editor-error={!!shownError}
86
+ class:json-editor-disabled={disabled}
87
+ aria-labelledby={label ? `${editorId}-label` : undefined}
88
+ aria-describedby={shownError || help ? hintId : undefined}
89
+ >
90
+ <CodeEditor
91
+ bind:value={text}
92
+ language="json"
93
+ readonly={readonly || disabled}
94
+ lineNumbers={false}
95
+ minLines={4}
96
+ maxLines={16}
97
+ />
98
+ </div>
99
+ {#if shownError}
100
+ <span id={hintId} class="json-editor-error-text" role="alert">{shownError}</span>
101
+ {:else if help}
102
+ <span id={hintId} class="json-editor-help">{help}</span>
103
+ {/if}
104
+ </div>
105
+
106
+ <style>
107
+ .json-editor {
108
+ display: flex;
109
+ flex-direction: column;
110
+ gap: var(--input-label-gap);
111
+ width: 100%;
112
+ }
113
+
114
+ .json-editor-label {
115
+ font-family: var(--input-label-font);
116
+ font-size: var(--input-label-size);
117
+ letter-spacing: var(--input-label-tracking);
118
+ color: var(--input-label-color);
119
+ }
120
+
121
+ .json-editor-surface {
122
+ border: var(--input-border);
123
+ border-radius: var(--input-radius);
124
+ background: var(--input-bg);
125
+ padding: var(--space-xs);
126
+ }
127
+
128
+ .json-editor-surface:focus-within {
129
+ border: var(--input-border-focus);
130
+ }
131
+
132
+ .json-editor-error {
133
+ border-color: var(--input-error-border-color);
134
+ }
135
+
136
+ .json-editor-disabled {
137
+ opacity: 0.5;
138
+ pointer-events: none;
139
+ }
140
+
141
+ .json-editor-help {
142
+ font-family: var(--input-help-font);
143
+ font-size: var(--input-help-size);
144
+ color: var(--input-help-color);
145
+ }
146
+
147
+ .json-editor-error-text {
148
+ font-family: var(--input-help-font);
149
+ font-size: var(--input-help-size);
150
+ color: var(--input-error-text);
151
+ }
152
+ </style>
@@ -0,0 +1,44 @@
1
+ export default JsonEditor;
2
+ type JsonEditor = {
3
+ $on?(type: string, callback: (e: any) => void): () => void;
4
+ $set?(props: Partial<$$ComponentProps>): void;
5
+ };
6
+ /**
7
+ * JsonEditor
8
+ *
9
+ * JSON field over the DS CodeEditor (#38, atelier#669 V1) with an explicit
10
+ * invalid-state contract: invalid text NEVER silently drops or half-parses —
11
+ * the field surfaces the parse error, emits NOTHING, and reports invalidity
12
+ * through `oninvalid` so the host form can block submit. Empty and `{}` are
13
+ * distinct (empty → null).
14
+ *
15
+ * @example
16
+ * <JsonEditor
17
+ * label="METADATA"
18
+ * value={{ kind: 'sensor' }}
19
+ * onchange={(v) => save(v)}
20
+ * oninvalid={(message) => (blocked = message !== null)}
21
+ * />
22
+ */
23
+ declare const JsonEditor: import("svelte").Component<{
24
+ value?: any;
25
+ label?: any;
26
+ help?: any;
27
+ error?: any;
28
+ disabled?: boolean;
29
+ readonly?: boolean;
30
+ onchange?: any;
31
+ oninvalid?: any;
32
+ class?: string;
33
+ } & Record<string, any>, {}, "">;
34
+ type $$ComponentProps = {
35
+ value?: any;
36
+ label?: any;
37
+ help?: any;
38
+ error?: any;
39
+ disabled?: boolean;
40
+ readonly?: boolean;
41
+ onchange?: any;
42
+ oninvalid?: any;
43
+ class?: string;
44
+ } & Record<string, any>;
@@ -0,0 +1,219 @@
1
+ <!--
2
+ @component MoneyInput
3
+
4
+ Number-semantics input with a currency affordance (#35, atelier#669 V1).
5
+ The WIRE value is always a raw number (or null when empty — empty and 0
6
+ are distinct); locale-aware currency formatting appears while the field is
7
+ NOT being edited; focus switches to the raw editable number.
8
+ Consumes --input-* tokens (mirrors Input's field chrome).
9
+
10
+ @example
11
+ <MoneyInput label="BUDGET" value={1234.5} onchange={(n) => save(n)} />
12
+
13
+ @example Non-default currency
14
+ <MoneyInput label="FEE" currency="USD" locale="en-US" />
15
+ -->
16
+ <script module>
17
+ let _moneyUid = 0;
18
+ </script>
19
+
20
+ <script>
21
+ let {
22
+ /** @type {number | null} — the raw amount; null = empty */
23
+ value = null,
24
+ /** @type {string} — ISO 4217 code for the display affordance */
25
+ currency = 'EUR',
26
+ /** @type {string | undefined} — BCP 47; defaults to the runtime locale */
27
+ locale = undefined,
28
+ /** @type {string | undefined} */
29
+ label = undefined,
30
+ /** @type {string | undefined} */
31
+ placeholder = undefined,
32
+ /** @type {string | undefined} */
33
+ help = undefined,
34
+ /** @type {string | undefined} */
35
+ error = undefined,
36
+ /** @type {'sm' | 'md' | 'lg'} */
37
+ size = 'md',
38
+ /** @type {boolean} */
39
+ disabled = false,
40
+ /** @type {boolean} */
41
+ readonly = false,
42
+ /** @type {string | undefined} */
43
+ id = undefined,
44
+ /** @type {string | undefined} */
45
+ name = undefined,
46
+ /** @type {((value: number | null) => void) | undefined} — fires with the
47
+ * RAW number (never a formatted string); null when cleared/unparseable */
48
+ onchange = undefined,
49
+ /** @type {string} */
50
+ class: className = '',
51
+ ...rest
52
+ } = $props();
53
+
54
+ const fallbackId = `money-input-${_moneyUid++}`;
55
+ const inputId = $derived(id ?? fallbackId);
56
+ const hintId = $derived(`${inputId}-hint`);
57
+ const hasHint = $derived(!!error || !!help);
58
+
59
+ let focused = $state(false);
60
+ let draft = $state('');
61
+
62
+ const formatter = $derived(
63
+ new Intl.NumberFormat(locale, { style: 'currency', currency }),
64
+ );
65
+
66
+ const display = $derived(
67
+ focused
68
+ ? draft
69
+ : value === null || value === undefined
70
+ ? ''
71
+ : formatter.format(Number(value)),
72
+ );
73
+
74
+ /**
75
+ * Parse the operator's raw text onto a number: strips grouping spaces,
76
+ * accepts a comma decimal. Empty/unparseable → null (never NaN, never a
77
+ * silent 0 — empty and 0 stay distinct).
78
+ * @param {string} text
79
+ * @returns {number | null}
80
+ */
81
+ function parseAmount(text) {
82
+ const trimmed = text.trim().replace(/\s+/g, '');
83
+ if (!trimmed) return null;
84
+ const normalized =
85
+ trimmed.includes(',') && !trimmed.includes('.')
86
+ ? trimmed.replace(',', '.')
87
+ : trimmed;
88
+ const parsed = Number(normalized);
89
+ return Number.isFinite(parsed) ? parsed : null;
90
+ }
91
+
92
+ function handleFocus() {
93
+ if (readonly || disabled) return;
94
+ draft = value === null || value === undefined ? '' : String(value);
95
+ focused = true;
96
+ }
97
+
98
+ /** @param {Event} event */
99
+ function handleInput(event) {
100
+ draft = /** @type {HTMLInputElement} */ (event.currentTarget).value;
101
+ const parsed = parseAmount(draft);
102
+ value = parsed;
103
+ onchange?.(parsed);
104
+ }
105
+
106
+ function handleBlur() {
107
+ focused = false;
108
+ }
109
+ </script>
110
+
111
+ <div class="money-input input-group {className}">
112
+ {#if label}
113
+ <label class="input-label" for={inputId}>{label}</label>
114
+ {/if}
115
+
116
+ <input
117
+ id={inputId}
118
+ {name}
119
+ type="text"
120
+ inputmode="decimal"
121
+ class="input input-{size}"
122
+ class:input-error={!!error}
123
+ class:input-readonly={readonly}
124
+ aria-invalid={error ? true : undefined}
125
+ aria-describedby={hasHint ? hintId : undefined}
126
+ {placeholder}
127
+ {disabled}
128
+ {readonly}
129
+ value={display}
130
+ onfocus={handleFocus}
131
+ oninput={handleInput}
132
+ onblur={handleBlur}
133
+ autocomplete="off"
134
+ {...rest}
135
+ />
136
+
137
+ {#if error}
138
+ <span id={hintId} class="input-error-text" role="alert">{error}</span>
139
+ {:else if help}
140
+ <span id={hintId} class="input-help">{help}</span>
141
+ {/if}
142
+ </div>
143
+
144
+ <style>
145
+ .money-input {
146
+ display: flex;
147
+ flex-direction: column;
148
+ gap: var(--input-label-gap);
149
+ width: 100%;
150
+ }
151
+
152
+ .input-label {
153
+ font-family: var(--input-label-font);
154
+ font-size: var(--input-label-size);
155
+ letter-spacing: var(--input-label-tracking);
156
+ color: var(--input-label-color);
157
+ }
158
+
159
+ .input {
160
+ font-family: var(--input-font);
161
+ font-size: var(--input-font-size);
162
+ border: var(--input-border);
163
+ border-radius: var(--input-radius);
164
+ background: var(--input-bg);
165
+ color: var(--input-text);
166
+ transition: border var(--input-transition);
167
+ width: 100%;
168
+ }
169
+
170
+ .input-sm {
171
+ height: var(--input-sm-height);
172
+ padding: 0 var(--input-sm-padding-x);
173
+ }
174
+
175
+ .input-md {
176
+ height: var(--input-md-height);
177
+ padding: 0 var(--input-md-padding-x);
178
+ }
179
+
180
+ .input-lg {
181
+ height: var(--input-lg-height);
182
+ padding: 0 var(--input-lg-padding-x);
183
+ }
184
+
185
+ .input::placeholder {
186
+ color: var(--input-placeholder);
187
+ }
188
+
189
+ .input:focus {
190
+ outline: none;
191
+ border: var(--input-border-focus);
192
+ }
193
+
194
+ .input:disabled {
195
+ opacity: 0.5;
196
+ cursor: not-allowed;
197
+ }
198
+
199
+ .input-readonly {
200
+ background: var(--color-surface-secondary);
201
+ cursor: default;
202
+ }
203
+
204
+ .input-error {
205
+ border-color: var(--input-error-border-color);
206
+ }
207
+
208
+ .input-help {
209
+ font-family: var(--input-help-font);
210
+ font-size: var(--input-help-size);
211
+ color: var(--input-help-color);
212
+ }
213
+
214
+ .input-error-text {
215
+ font-family: var(--input-help-font);
216
+ font-size: var(--input-help-size);
217
+ color: var(--input-error-text);
218
+ }
219
+ </style>
@@ -0,0 +1,52 @@
1
+ export default MoneyInput;
2
+ type MoneyInput = {
3
+ $on?(type: string, callback: (e: any) => void): () => void;
4
+ $set?(props: Partial<$$ComponentProps>): void;
5
+ };
6
+ /**
7
+ * MoneyInput
8
+ *
9
+ * Number-semantics input with a currency affordance (#35, atelier#669 V1).
10
+ * The WIRE value is always a raw number (or null when empty — empty and 0
11
+ * are distinct); locale-aware currency formatting appears while the field is
12
+ * NOT being edited; focus switches to the raw editable number.
13
+ * Consumes --input-* tokens (mirrors Input's field chrome).
14
+ *
15
+ * @example
16
+ * <MoneyInput label="BUDGET" value={1234.5} onchange={(n) => save(n)} />
17
+ *
18
+ * @example Non-default currency
19
+ * <MoneyInput label="FEE" currency="USD" locale="en-US" />
20
+ */
21
+ declare const MoneyInput: import("svelte").Component<{
22
+ value?: any;
23
+ currency?: string;
24
+ locale?: any;
25
+ label?: any;
26
+ placeholder?: any;
27
+ help?: any;
28
+ error?: any;
29
+ size?: string;
30
+ disabled?: boolean;
31
+ readonly?: boolean;
32
+ id?: any;
33
+ name?: any;
34
+ onchange?: any;
35
+ class?: string;
36
+ } & Record<string, any>, {}, "">;
37
+ type $$ComponentProps = {
38
+ value?: any;
39
+ currency?: string;
40
+ locale?: any;
41
+ label?: any;
42
+ placeholder?: any;
43
+ help?: any;
44
+ error?: any;
45
+ size?: string;
46
+ disabled?: boolean;
47
+ readonly?: boolean;
48
+ id?: any;
49
+ name?: any;
50
+ onchange?: any;
51
+ class?: string;
52
+ } & Record<string, any>;
@@ -0,0 +1,140 @@
1
+ <!--
2
+ @component MultiSelectCombobox
3
+
4
+ Multi-value picking: removable Tag chips over a search-to-add Combobox
5
+ (the Carbon MultiSelect / Polaris tag-combobox pattern). Presentation-only:
6
+ the parent owns the selection list AND the async item source — the widget
7
+ never holds the selection in its input (a single-pick replace of a
8
+ multi-value field is impossible by construction).
9
+
10
+ @example
11
+ <MultiSelectCombobox
12
+ label="INSPECTORS"
13
+ items={searchResults}
14
+ selected={[{ value: 'u-1', label: 'Ana Silva' }]}
15
+ onsearch={(q) => search(q)}
16
+ onadd={(value) => add(value)}
17
+ onremove={(value) => remove(value)}
18
+ />
19
+ -->
20
+ <script module>
21
+ let _mscUid = 0;
22
+ </script>
23
+
24
+ <script>
25
+ /**
26
+ * @typedef {{ value: string, label: string, group?: string, description?: string }} ComboboxItem
27
+ */
28
+
29
+ import Combobox from './Combobox.svelte';
30
+ import Tag from './Tag.svelte';
31
+
32
+ let {
33
+ /** @type {ComboboxItem[]} — the search-result pool (parent-owned; async via onsearch) */
34
+ items = [],
35
+ /** @type {ComboboxItem[]} — the current selection, WITH display labels */
36
+ selected = [],
37
+ /** @type {string | undefined} */
38
+ label = undefined,
39
+ /** @type {string | undefined} */
40
+ placeholder = 'Search...',
41
+ /** @type {boolean} */
42
+ disabled = false,
43
+ /** @type {boolean} */
44
+ loading = false,
45
+ /** @type {string | undefined} */
46
+ error = undefined,
47
+ /** @type {string | undefined} */
48
+ help = undefined,
49
+ /** @type {boolean} */
50
+ required = false,
51
+ /** @type {string} */
52
+ size = 'md',
53
+ /** @type {((value: string) => void) | undefined} — a NEW value was picked */
54
+ onadd = undefined,
55
+ /** @type {((value: string) => void) | undefined} — a chip was removed */
56
+ onremove = undefined,
57
+ /** @type {((query: string) => void) | undefined} — async search; parent owns items */
58
+ onsearch = undefined,
59
+ /** @type {string} */
60
+ class: className = '',
61
+ ...rest
62
+ } = $props();
63
+
64
+ const mscId = `msc-${_mscUid++}`;
65
+
66
+ // The draft is WIDGET-internal: it exists only to drive the Combobox and is
67
+ // cleared after every pick, so the input never displays a "selection".
68
+ let draft = $state('');
69
+
70
+ // An already-picked value never re-offers (no double-add by construction).
71
+ const available = $derived(
72
+ items.filter((item) => !selected.some((entry) => entry.value === item.value)),
73
+ );
74
+
75
+ /** @param {string} value */
76
+ function handlePick(value) {
77
+ if (value && !selected.some((entry) => entry.value === value)) {
78
+ onadd?.(value);
79
+ }
80
+ draft = '';
81
+ }
82
+ </script>
83
+
84
+ <div
85
+ class="msc {className}"
86
+ role="group"
87
+ aria-labelledby={label ? `${mscId}-label` : undefined}
88
+ {...rest}
89
+ >
90
+ {#if label}
91
+ <span id={`${mscId}-label`} class="msc-label"
92
+ >{label}{required ? ' (required)' : ''}</span
93
+ >
94
+ {/if}
95
+
96
+ {#if selected.length}
97
+ <div class="msc-chips">
98
+ {#each selected as entry (entry.value)}
99
+ <Tag removable={!disabled} onremove={() => onremove?.(entry.value)}>
100
+ {entry.label}
101
+ </Tag>
102
+ {/each}
103
+ </div>
104
+ {/if}
105
+
106
+ <Combobox
107
+ items={available}
108
+ bind:value={draft}
109
+ {placeholder}
110
+ {disabled}
111
+ {loading}
112
+ {error}
113
+ {help}
114
+ {size}
115
+ onsearch={onsearch}
116
+ onchange={handlePick}
117
+ />
118
+ </div>
119
+
120
+ <style>
121
+ .msc {
122
+ display: flex;
123
+ flex-direction: column;
124
+ gap: var(--space-xs);
125
+ width: 100%;
126
+ }
127
+
128
+ .msc-label {
129
+ font-family: var(--input-label-font);
130
+ font-size: var(--input-label-size);
131
+ letter-spacing: var(--input-label-tracking);
132
+ color: var(--input-label-color);
133
+ }
134
+
135
+ .msc-chips {
136
+ display: flex;
137
+ flex-wrap: wrap;
138
+ gap: var(--space-xs);
139
+ }
140
+ </style>
@@ -0,0 +1,62 @@
1
+ export default MultiSelectCombobox;
2
+ export type ComboboxItem = {
3
+ value: string;
4
+ label: string;
5
+ group?: string;
6
+ description?: string;
7
+ };
8
+ type MultiSelectCombobox = {
9
+ $on?(type: string, callback: (e: any) => void): () => void;
10
+ $set?(props: Partial<$$ComponentProps>): void;
11
+ };
12
+ /**
13
+ * MultiSelectCombobox
14
+ *
15
+ * Multi-value picking: removable Tag chips over a search-to-add Combobox
16
+ * (the Carbon MultiSelect / Polaris tag-combobox pattern). Presentation-only:
17
+ * the parent owns the selection list AND the async item source — the widget
18
+ * never holds the selection in its input (a single-pick replace of a
19
+ * multi-value field is impossible by construction).
20
+ *
21
+ * @example
22
+ * <MultiSelectCombobox
23
+ * label="INSPECTORS"
24
+ * items={searchResults}
25
+ * selected={[{ value: 'u-1', label: 'Ana Silva' }]}
26
+ * onsearch={(q) => search(q)}
27
+ * onadd={(value) => add(value)}
28
+ * onremove={(value) => remove(value)}
29
+ * />
30
+ */
31
+ declare const MultiSelectCombobox: import("svelte").Component<{
32
+ items?: any[];
33
+ selected?: any[];
34
+ label?: any;
35
+ placeholder?: string;
36
+ disabled?: boolean;
37
+ loading?: boolean;
38
+ error?: any;
39
+ help?: any;
40
+ required?: boolean;
41
+ size?: string;
42
+ onadd?: any;
43
+ onremove?: any;
44
+ onsearch?: any;
45
+ class?: string;
46
+ } & Record<string, any>, {}, "">;
47
+ type $$ComponentProps = {
48
+ items?: any[];
49
+ selected?: any[];
50
+ label?: any;
51
+ placeholder?: string;
52
+ disabled?: boolean;
53
+ loading?: boolean;
54
+ error?: any;
55
+ help?: any;
56
+ required?: boolean;
57
+ size?: string;
58
+ onadd?: any;
59
+ onremove?: any;
60
+ onsearch?: any;
61
+ class?: string;
62
+ } & Record<string, any>;
@@ -33,7 +33,11 @@
33
33
  {@render children()}
34
34
  {/if}
35
35
  {#if removable}
36
+ <!-- type="button": inside a <form> (e.g. ActionFormRenderer m2m chips) a
37
+ typeless button is a SUBMIT button — removing a chip must never
38
+ submit the form. -->
36
39
  <button
40
+ type="button"
37
41
  class="tag-remove"
38
42
  aria-label="Remove tag"
39
43
  onclick={(e) => {
@@ -47,9 +47,12 @@ export interface LayoutSection {
47
47
  /**
48
48
  * #634 S3 — whether a parameter's cell spans the full row inside a
49
49
  * `columns: 2` section. An explicit `span: "full"` on the parameter wins;
50
- * wide types clamp to full regardless; everything else flows half-width
51
- * (so a bare `columns: 2` declaration visibly two-columns its fields).
52
- * In a single-column section every cell is trivially full.
50
+ * wide WIDGET KINDS (map canvas, upload list, textarea, json editor —
51
+ * resolved widget-first per #36, so both the field summary's `widget` and
52
+ * a bare legacy `type` clamp) go full regardless; everything else flows
53
+ * half-width (so a bare `columns: 2` declaration visibly two-columns its
54
+ * fields). In a single-column section every cell is trivially full.
55
+ * Mirrors the CRUD form-surface compilers' FULL_WIDTH_WIDGETS clamp.
53
56
  */
54
57
  export declare function fieldSpansFull(parameter: Record<string, unknown>, columns: number | undefined): boolean;
55
58
  import type { Snippet } from "svelte";