@happyvertical/smrt-ui 0.54.4 → 0.55.10

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 (78) hide show
  1. package/AGENTS.md +2 -1
  2. package/README.md +113 -5
  3. package/dist/components/forms/CodeSelect.svelte +12 -2
  4. package/dist/components/forms/CodeSelect.svelte.d.ts.map +1 -1
  5. package/dist/components/forms/Combobox.svelte +50 -7
  6. package/dist/components/forms/Combobox.svelte.d.ts +26 -0
  7. package/dist/components/forms/Combobox.svelte.d.ts.map +1 -1
  8. package/dist/components/forms/DictationButton.svelte +110 -3
  9. package/dist/components/forms/DictationButton.svelte.d.ts.map +1 -1
  10. package/dist/components/forms/DictationStatus.svelte +33 -5
  11. package/dist/components/forms/DictationStatus.svelte.d.ts +2 -0
  12. package/dist/components/forms/DictationStatus.svelte.d.ts.map +1 -1
  13. package/dist/components/forms/FieldLabel.svelte +44 -0
  14. package/dist/components/forms/FieldLabel.svelte.d.ts +20 -0
  15. package/dist/components/forms/FieldLabel.svelte.d.ts.map +1 -0
  16. package/dist/components/forms/FormGroup.svelte +2 -17
  17. package/dist/components/forms/FormGroup.svelte.d.ts.map +1 -1
  18. package/dist/components/forms/RelationInput.svelte +378 -0
  19. package/dist/components/forms/RelationInput.svelte.d.ts +75 -0
  20. package/dist/components/forms/RelationInput.svelte.d.ts.map +1 -0
  21. package/dist/components/forms/code-select-options.d.ts +8 -1
  22. package/dist/components/forms/code-select-options.d.ts.map +1 -1
  23. package/dist/components/forms/code-select-options.js +46 -5
  24. package/dist/components/forms/code-select-types.d.ts +4 -0
  25. package/dist/components/forms/code-select-types.d.ts.map +1 -1
  26. package/dist/components/forms/dictation.svelte.d.ts +84 -2
  27. package/dist/components/forms/dictation.svelte.d.ts.map +1 -1
  28. package/dist/components/forms/dictation.svelte.js +236 -2
  29. package/dist/components/forms/hands-free-capture.d.ts +70 -0
  30. package/dist/components/forms/hands-free-capture.d.ts.map +1 -0
  31. package/dist/components/forms/hands-free-capture.js +19 -0
  32. package/dist/components/forms/hands-free-vad.d.ts +5 -0
  33. package/dist/components/forms/hands-free-vad.d.ts.map +1 -0
  34. package/dist/components/forms/hands-free-vad.js +80 -0
  35. package/dist/components/forms/index.d.ts +4 -1
  36. package/dist/components/forms/index.d.ts.map +1 -1
  37. package/dist/components/forms/index.js +3 -0
  38. package/dist/components/forms/types.d.ts +9 -0
  39. package/dist/components/forms/types.d.ts.map +1 -1
  40. package/dist/components/layout/ActionGroup.svelte +55 -0
  41. package/dist/components/layout/ActionGroup.svelte.d.ts +18 -0
  42. package/dist/components/layout/ActionGroup.svelte.d.ts.map +1 -0
  43. package/dist/components/layout/Container.svelte +1 -0
  44. package/dist/components/layout/Grid.svelte +2 -1
  45. package/dist/components/layout/PageHeader.svelte +3 -3
  46. package/dist/components/layout/PageHeader.svelte.d.ts +3 -3
  47. package/dist/components/layout/PageLayout.svelte +61 -0
  48. package/dist/components/layout/PageLayout.svelte.d.ts +18 -0
  49. package/dist/components/layout/PageLayout.svelte.d.ts.map +1 -0
  50. package/dist/components/layout/index.d.ts +2 -0
  51. package/dist/components/layout/index.d.ts.map +1 -1
  52. package/dist/components/layout/index.js +2 -0
  53. package/dist/components/permissions/PermissionPicker.svelte +213 -0
  54. package/dist/components/permissions/PermissionPicker.svelte.d.ts +27 -0
  55. package/dist/components/permissions/PermissionPicker.svelte.d.ts.map +1 -0
  56. package/dist/currency.d.ts +3 -0
  57. package/dist/currency.d.ts.map +1 -0
  58. package/dist/currency.js +2 -0
  59. package/dist/i18n/strings.ui.d.ts +7 -0
  60. package/dist/i18n/strings.ui.d.ts.map +1 -1
  61. package/dist/i18n/strings.ui.js +7 -0
  62. package/dist/index.d.ts +1 -0
  63. package/dist/index.d.ts.map +1 -1
  64. package/dist/index.js +1 -0
  65. package/dist/svelte/playground/InteractiveControlsPreview.svelte +22 -0
  66. package/dist/svelte/playground/InteractiveControlsPreview.svelte.d.ts.map +1 -1
  67. package/dist/svelte/playground/PermissionPickerPreview.svelte +27 -0
  68. package/dist/svelte/playground/PermissionPickerPreview.svelte.d.ts +19 -0
  69. package/dist/svelte/playground/PermissionPickerPreview.svelte.d.ts.map +1 -0
  70. package/dist/svelte/playground.d.ts +14 -2
  71. package/dist/svelte/playground.d.ts.map +1 -1
  72. package/dist/svelte/playground.js +10 -0
  73. package/dist/themes/README.md +5 -0
  74. package/dist/themes/styles/base.css +31 -0
  75. package/dist/themes/styles/index.d.ts +2 -0
  76. package/dist/themes/styles/index.d.ts.map +1 -1
  77. package/dist/themes/styles/index.js +2 -0
  78. package/package.json +17 -2
package/AGENTS.md CHANGED
@@ -17,7 +17,7 @@ SMRT has one shared set of UI primitives, split across two packages by concern:
17
17
  `Tooltip`, `Skeleton`, `Tree`, `Pagination`, `DataTable`, `ContentList`, … — **plus the
18
18
  Provider-free base FORM primitives** under `./forms` (`Form`, `Input`,
19
19
  `Select`, `Textarea`, `Checkbox`, `Switch`, `RadioGroup`, `Slider`,
20
- `RangeSlider`, `Combobox`, `MultiSelect`, `TagsInput`, `FilePicker`,
20
+ `RangeSlider`, `Combobox`, `RelationInput`, `MultiSelect`, `TagsInput`, `FilePicker`,
21
21
  `FormGroup`), relocated here in #1589's
22
22
  deferred-forms phase so domain packages can adopt them without pulling in the
23
23
  smrt-svelte Provider or closing a build-graph cycle. These are dependency-free:
@@ -53,6 +53,7 @@ components are exempt — they *are* the primitives.
53
53
  | `./calendar` | `CalendarView` (time-zone-aware month/week grids + phone agenda) and its Svelte-free date model (`toEntries`, `layoutMonth`, `layoutWeek`, `dateKeyInZone`, …); deprecated `Calendar`, `DayView` |
54
54
  | `./chat` | `MessageBubble`, `ReactionPicker`, `TypingIndicator` |
55
55
  | `./forms` | Provider-free fields, choice controls, sliders/ranges, combobox/listbox/multiselect/tags, date/time/file controls, `CameraCapture`/`SignaturePad` capture, plus the transport-neutral control interaction registry |
56
+ | `.` permissions | `PermissionCheck` and the domain-agnostic `PermissionPicker` for grouped, searchable native role-grant forms over host-supplied catalog records |
56
57
  | `./form-retry` | **Svelte-free** form retry (#3291): submission key, in-flight refusal, conditional reset, opt-in restore-after-reload, browser `runOnce()` digest. Also re-exported from `./forms`. Framework-free TS typed structurally against SvelteKit's `SubmitFunction` — never import `@sveltejs/kit` here. Guide: `docs/content/form-retry.md` |
57
58
  | `./i18n` | i18n **client**: `useI18n`, `<Trans>`, `defineMessages`, `renderTemplate` (no `smrt-languages` import — the server resolver stays in `smrt-svelte/i18n/server`) |
58
59
  | `./registry` | `ModuleUIRegistry` for cross-package component discovery |
package/README.md CHANGED
@@ -14,6 +14,8 @@ pnpm add @happyvertical/smrt-ui
14
14
  import { ThemeProvider } from '@happyvertical/smrt-ui/themes';
15
15
  import { Button } from '@happyvertical/smrt-ui/ui';
16
16
  import { Form, FormGroup, Input, Switch } from '@happyvertical/smrt-ui/forms';
17
+ import '@happyvertical/smrt-ui/themes/styles/base.css';
18
+ import '@happyvertical/smrt-ui/themes/styles/material.css';
17
19
  </script>
18
20
 
19
21
  <ThemeProvider preset="material" colorScheme="system">
@@ -30,7 +32,7 @@ pnpm add @happyvertical/smrt-ui
30
32
  | Area | Components |
31
33
  | --- | --- |
32
34
  | Fields | `Form`, `Field`/`FormGroup`, `Fieldset`, `InputGroup`, `ErrorSummary`, `FormActionBar` |
33
- | Text and structured input | `Input`, `Textarea`, `Select`, `Combobox`, `Listbox`, `MultiSelect`, `TagsInput`, `SearchInput` |
35
+ | Text and structured input | `Input`, `Textarea`, `Select`, `Combobox`, `RelationInput`, `Listbox`, `MultiSelect`, `TagsInput`, `SearchInput` |
34
36
  | Choices | `Checkbox`, `RadioGroup`/`Radio`, `Switch`, `Toggle`, `ToggleButton`, `SegmentedControl` |
35
37
  | Values and files | `Slider`, `RangeSlider`, `DatePicker`, `TimePicker`, `FilePicker` |
36
38
  | Capture | `CameraCapture`, `SignaturePad` |
@@ -38,7 +40,7 @@ pnpm add @happyvertical/smrt-ui
38
40
  | Disclosure and overlays | `Popover`, `Disclosure`, `Accordion`/`AccordionItem`, `Modal`, `Drawer`/`Sheet`, `PhoneSheet`, `ConfirmDialog` |
39
41
  | Feedback | `Alert`, `ToastViewport`, `Progress`, `Meter`, `Spinner`, `LoadingOverlay`, `WorkingStrip` |
40
42
  | Collections | `CollectionToolbar`, `CollectionList`/`ContentList`, `DataTable`, `Pagination` |
41
- | Layout and navigation | `Container`, `Grid`, `Header`, `Footer`, `PageHeader`, `EmptyState`, `Tabs`, `FilterChips` |
43
+ | Layout and navigation | `Container`, `PageLayout`, `Grid`, `ActionGroup`, `Header`, `Footer`, `PageHeader`, `EmptyState`, `Tabs`, `FilterChips` |
42
44
  | Calendar | `CalendarView` (deprecated: `Calendar`, `DayView`) |
43
45
 
44
46
  ### Phone surfaces
@@ -79,6 +81,36 @@ pnpm add @happyvertical/smrt-ui
79
81
  body (`Content-Type` is the recording's type, `?language=&durationMs=`)
80
82
  and answers `{ text }`; 413, 503 and 401/403 become "too long", "not set
81
83
  up" and "not allowed" messages.
84
+ - Hands-free dictation: `new Dictation({ mode: 'hands-free', handsFreeCapture:
85
+ createHandsFreeCapture, … })` keeps the microphone on and writes each
86
+ sentence down when the speaker pauses; one tap on the microphone ends it.
87
+ `createHandsFreeCapture` comes from `@happyvertical/smrt-ui/forms/hands-free`,
88
+ the one entry that imports the optional peer `@happyvertical/speech`
89
+ (>= 0.102.5: on-device voice activity detection with an adaptive noise
90
+ floor, pre-roll and a 30 s split; no audio leaves the page). It needs a
91
+ speech source with `transcribePcm` (smrt-svelte's on-device `whisper-local`
92
+ and `moonshine`); with any of the three missing it is plain press-to-talk.
93
+ Utterances are written down one at a time in the order spoken
94
+ (`dictation.queued` counts those waiting); `speaking` and `level` (0 to 1)
95
+ drive the button: a steady ring while listening, a halo that swells with the
96
+ voice while speaking, a spinner while writing down, all still under
97
+ `prefers-reduced-motion`. `vad` tunes `silenceMs` (default 800),
98
+ `minSpeechMs`, `preRollMs`, `maxUtteranceMs` and `sensitivity`.
99
+ The help text ("Just talk; I write it down when you pause...") is the
100
+ microphone's tooltip and `aria-describedby`, not a line in the form;
101
+ `DictationStatus` keeps a visually hidden polite live region ("Listening",
102
+ "Hearing you...", "Writing it down...") and shows errors. Its `sending` prop
103
+ adds the one short visible hint, "Sending...". The chat composer's
104
+ `sendOnPause` / `sendOnPauseMs` (default 1200) builds send-on-pause on it.
105
+ Half-duplex: browsers do not reliably cancel `speechSynthesis` from the
106
+ microphone, so call `dictation.suspend()` while the page reads a reply aloud
107
+ and `dictation.resume()` after (the chat composer/dock do this from their
108
+ `speaking` prop). The microphone stays open but nothing is heard (an
109
+ utterance in progress is discarded, a ~350 ms guard follows `resume()`);
110
+ `dictation.suspended` drives the dimmed, dashed-outline button and the
111
+ "Paused while the assistant speaks" tooltip/live region. A custom
112
+ `HandsFreeCapture` may implement optional `suspend()` / `resume()`;
113
+ `Dictation` ignores utterances that arrive while suspended either way.
82
114
 
83
115
  ### Link tabs
84
116
 
@@ -93,11 +125,15 @@ under the phone top bar. Pair it with `useLinkSurface` (smrt-svelte) so
93
125
  agents can switch tabs too.
94
126
 
95
127
  Use the focused subpaths (`/forms`, `/form-retry`, `/ui`, `/feedback`, `/data`,
96
- `/data-surface`, `/layout`, `/themes`) to keep imports explicit. The
128
+ `/data-surface`, `/currency`, `/layout`, `/themes`) to keep imports explicit. The
97
129
  Svelte-free `/data-surface` entry exposes the registry contracts and shared
98
130
  protocol limits for server adapters; the Svelte-free `/form-retry` entry
99
- exposes the form-retry helper. The package root remains a compatibility
100
- barrel.
131
+ exposes the form-retry helper. The Svelte-free `/currency` entry exposes
132
+ `ISO_4217_MINOR_UNITS`, the shared ISO 4217 registry used for deterministic
133
+ minor-unit scaling. A missing code is unsupported; a `null` value is a valid
134
+ ISO fund, metal, test, or no-currency code without a defined minor unit, so
135
+ callers handling integer minor-unit money must reject both cases rather than
136
+ inventing a decimal precision. The package root remains a compatibility barrel.
101
137
 
102
138
  ### Calendar dates and shop time
103
139
 
@@ -124,6 +160,50 @@ controls with no name or an empty name are omitted. Disabled controls, including
124
160
  those inside disabled fieldsets, are omitted by native `FormData`. Native form
125
161
  reset restores the initial selection and its label, unless reset is canceled.
126
162
 
163
+ ### RelationInput
164
+
165
+ `RelationInput` (`/forms`) moved here from `@happyvertical/smrt-svelte/forms` in #3637 (originally #3600); smrt-svelte keeps a deprecated re-export. Labels are props (`createLabel`, `clearLabel`, `loadingText`, ...); there is no message catalog.
166
+
167
+ Searchable single-select for relation (foreign-key) fields, a thin wrapper over
168
+ `Combobox`. It is presentation only: the caller supplies the data,
169
+ so the same lookup an assistant uses to turn "Acme" into a record id backs the
170
+ picker. Option shape is `RelationOption` — `{ id, label, detail? }`.
171
+
172
+ - **Props.** `name`, `label`, `value` (bindable id, `''` = none), `required`,
173
+ `disabled`, `error`, `placeholder`, `description`, `search(query)`, optional
174
+ `resolve(id)` and `onCreate(query)`, plus `debounceMs` (250), `interaction`,
175
+ `onchange` and overridable text (`createLabel`, `clearLabel`, `loadingText`,
176
+ `emptyText`, `errorText`, `resultsText`).
177
+ - **`search`** runs with `''` when the list opens and then debounced per
178
+ keystroke; a response for a superseded query is dropped. A rejected search
179
+ shows `errorText` and the next keystroke retries.
180
+ - **`resolve`** supplies the label of a `value` that has not been searched yet
181
+ (initial value, parent rebind, form reset). Without it such a value shows an
182
+ empty field, never the raw id. A `null` result also leaves the field empty.
183
+ - **`onCreate`** adds a "New ..." button; it receives the text most recently
184
+ searched for and may return the created `RelationOption`, which is selected.
185
+ - **Form integration** is through `Combobox`'s control registration (`Form`
186
+ provides the registry), not a `FieldDefinition`: agents see a `combobox`
187
+ control whose value is the id. The form posts the id under `name`.
188
+ - **Known limit.** An agent staging a value through the registered control can
189
+ only choose among the options last listed (the registry matches against
190
+ `options`; `allowCustom` is off), so it cannot write an id it has not
191
+ searched. It fails closed. Writing a resolved id needs an async hook on
192
+ `Combobox` and is tracked separately.
193
+ - **Stale options.** Typing clears the listed options until the new search
194
+ answers, so Enter or a click can never pick a record for an older query.
195
+ - **Clear button** shows only when not `required`, not `disabled`, and a value
196
+ is set; it returns focus to the field.
197
+ - **Accessibility** is the combobox pattern (`role=combobox`, `aria-expanded`,
198
+ `aria-activedescendant`, listbox/option, Arrow/Enter/Escape) plus a polite
199
+ `role=status` live region announcing searching / "N results" / no matches,
200
+ `aria-busy` while fetching, and `aria-invalid` + `aria-describedby` for
201
+ `error` and `description`.
202
+ - Options render `detail` under the label, so the option's accessible name
203
+ includes it. The `Combobox` additions that make this possible (`filter`,
204
+ `onquery`, `status`, `busy`, `invalid`, `describedby`, `optionContent`) are
205
+ generic and default to the previous behaviour.
206
+
127
207
  ### MultiSelect form submission
128
208
 
129
209
  A named `MultiSelect` submits one hidden native input per selected option value,
@@ -944,6 +1024,7 @@ hand-authored, and every text pairing clears WCAG AA.
944
1024
  ```svelte
945
1025
  <script>
946
1026
  import { ThemeProvider } from '@happyvertical/smrt-ui/themes';
1027
+ import '@happyvertical/smrt-ui/themes/styles/base.css';
947
1028
  import '@happyvertical/smrt-ui/themes/styles/all.css';
948
1029
  import '@happyvertical/smrt-ui/themes/styles/fonts.css';
949
1030
  </script>
@@ -953,6 +1034,33 @@ hand-authored, and every text pairing clears WCAG AA.
953
1034
  </ThemeProvider>
954
1035
  ```
955
1036
 
1037
+ The application baseline is intentionally separate from theme tokens. Import
1038
+ `base.css` once at the app entry point to remove the browser body margin, use
1039
+ border-box sizing, apply the active theme to the document, and make native form
1040
+ controls inherit the app typeface. It does not reset lists or content margins.
1041
+
1042
+ PageLayout keeps wide tables and tab rows within the available page width,
1043
+ including when nested inside a semantic section. Their own scroll containers
1044
+ remain scrollable. Place visual sections directly under PageLayout to use its
1045
+ vertical gap.
1046
+
1047
+ Use `PageLayout` inside an application shell to give each route responsive
1048
+ gutters, block padding, vertical rhythm, and the width appropriate to its
1049
+ content. It never creates a scroll container or claims viewport height.
1050
+ Use `ActionGroup` for adjacent page or card actions; it wraps with token spacing
1051
+ and can justify actions at the start or end without form-specific behavior.
1052
+
1053
+ ```svelte
1054
+ <script>
1055
+ import { PageHeader, PageLayout } from '@happyvertical/smrt-ui/layout';
1056
+ </script>
1057
+
1058
+ <PageLayout maxWidth="xl" gap="md">
1059
+ <PageHeader title="Projects" subtitle="Plan and track active work" />
1060
+ <!-- collection, cards, form, or detail content -->
1061
+ </PageLayout>
1062
+ ```
1063
+
956
1064
  Run the shared playground to inspect the full catalog under every preset and
957
1065
  light/dark scheme.
958
1066
 
@@ -1,6 +1,7 @@
1
1
  <script lang="ts">
2
2
  import type { ComponentProps } from 'svelte';
3
3
  import type { CodeSelectProps } from './code-select-types.js';
4
+ import FieldLabel from './FieldLabel.svelte';
4
5
  import Input from './Input.svelte';
5
6
  import Select from './Select.svelte';
6
7
 
@@ -18,9 +19,17 @@ let {
18
19
  locale: _locale,
19
20
  name,
20
21
  form,
22
+ label,
23
+ id: idProp,
24
+ required,
21
25
  ...rest
22
26
  }: Props = $props();
23
27
  const hiddenId = $props.id();
28
+ const labelId = `${hiddenId}-label`;
29
+ const controlId = $derived(idProp ?? `${hiddenId}-control`);
30
+ const labelProps = $derived(
31
+ label ? { id: controlId, 'aria-labelledby': labelId } : { id: idProp },
32
+ );
24
33
  let select: Select | undefined = $state();
25
34
  let input: Input | undefined = $state();
26
35
  const choices = $derived(options.filter((option) => option.value !== ''));
@@ -38,10 +47,11 @@ export function getElement(): HTMLInputElement | HTMLSelectElement | null {
38
47
  return (freeText ? input : select)?.getElement() ?? null;
39
48
  }
40
49
  </script>
50
+ {#if label}<FieldLabel id={labelId} for={controlId} {label} {required} />{/if}
41
51
  {#if freeText}
42
- <Input {...inputRest} bind:this={input} bind:value type="text" {name} {form} {placeholder} disabled={disabled || readOnly} />
52
+ <Input {...inputRest} {...labelProps} {required} bind:this={input} bind:value type="text" {name} {form} {placeholder} disabled={disabled || readOnly} />
43
53
  {:else}
44
- <Select {...rest} bind:this={select} bind:value {name} {form} disabled={disabled || readOnly}>
54
+ <Select {...rest} {...labelProps} {required} bind:this={select} bind:value {name} {form} disabled={disabled || readOnly}>
45
55
  <option value="">{placeholder}</option>
46
56
  {#if retained}<option value={value}>{value}</option>{/if}
47
57
  {#each choices as option}<option value={option.value} disabled={option.disabled}>{option.label ?? option.value}</option>{/each}
@@ -1 +1 @@
1
- {"version":3,"file":"CodeSelect.svelte.d.ts","sourceRoot":"","sources":["../../../src/components/forms/CodeSelect.svelte.ts"],"names":[],"mappings":"AAIA,OAAO,KAAK,EAAE,eAAe,EAAE,MAAM,wBAAwB,CAAC;AAK9D,UAAU,KAAM,SAAQ,eAAe;IACrC,6EAA6E;IAC7E,QAAQ,CAAC,EAAE,OAAO,CAAC;CACpB;AAkDD,QAAA,MAAM,UAAU;iBArBG,IAAI;sBAIC,gBAAgB,GAAG,iBAAiB,GAAG,IAAI;WAiBX,CAAC;AACzD,KAAK,UAAU,GAAG,UAAU,CAAC,OAAO,UAAU,CAAC,CAAC;AAChD,eAAe,UAAU,CAAC"}
1
+ {"version":3,"file":"CodeSelect.svelte.d.ts","sourceRoot":"","sources":["../../../src/components/forms/CodeSelect.svelte.ts"],"names":[],"mappings":"AAIA,OAAO,KAAK,EAAE,eAAe,EAAE,MAAM,wBAAwB,CAAC;AAM9D,UAAU,KAAM,SAAQ,eAAe;IACrC,6EAA6E;IAC7E,QAAQ,CAAC,EAAE,OAAO,CAAC;CACpB;AA4DD,QAAA,MAAM,UAAU;iBAtBG,IAAI;sBAIC,gBAAgB,GAAG,iBAAiB,GAAG,IAAI;WAkBX,CAAC;AACzD,KAAK,UAAU,GAAG,UAAU,CAAC,OAAO,UAAU,CAAC,CAAC;AAChD,eAAe,UAAU,CAAC"}
@@ -1,5 +1,5 @@
1
1
  <script lang="ts">
2
- import { untrack } from 'svelte';
2
+ import { type Snippet, untrack } from 'svelte';
3
3
  import {
4
4
  emitControlChange,
5
5
  highlightControl,
@@ -17,6 +17,7 @@ import {
17
17
  matchingOption,
18
18
  prepareTextControlValue,
19
19
  } from './control-value-validation.js';
20
+ import FieldLabel from './FieldLabel.svelte';
20
21
  import { useControlRegistration } from './use-control-registration.svelte.js';
21
22
  export interface Props {
22
23
  /** Array of options to select from. */
@@ -43,6 +44,31 @@ export interface Props {
43
44
  * field stays empty rather than flashing the raw value (an id).
44
45
  */
45
46
  valueLabel?: string;
47
+ /**
48
+ * Whether typing filters `options` by label. Set `false` when the caller
49
+ * supplies the matching options itself (server-side search via `onquery`),
50
+ * so a result that matched on something other than its label stays listed.
51
+ */
52
+ filter?: boolean;
53
+ /**
54
+ * Called with the text to search for when the list opens (empty) and on
55
+ * every keystroke. Pair with `filter={false}` and fresh `options`.
56
+ */
57
+ onquery?: (query: string) => void;
58
+ /**
59
+ * Message announced politely to assistive technology while the list is open
60
+ * ("Searching…", "3 results", "No results"). It is also shown inside the
61
+ * list while there are no options to list.
62
+ */
63
+ status?: string;
64
+ /** Marks the combobox busy while options are being fetched. */
65
+ busy?: boolean;
66
+ /** Marks the field invalid (`aria-invalid`). */
67
+ invalid?: boolean;
68
+ /** Ids of elements describing the field (error text, hints). */
69
+ describedby?: string;
70
+ /** Custom content for one option row; defaults to the label. */
71
+ optionContent?: Snippet<[ControlOption]>;
46
72
  /** Callback when the selected value changes. */
47
73
  onvaluechange?: (value: string) => void;
48
74
  /** CSS class to apply to the combobox container. */
@@ -59,6 +85,13 @@ let {
59
85
  allowCustom = false,
60
86
  interaction,
61
87
  valueLabel,
88
+ filter = true,
89
+ onquery,
90
+ status,
91
+ busy = false,
92
+ invalid = false,
93
+ describedby,
94
+ optionContent,
62
95
  onvaluechange,
63
96
  class: className = '',
64
97
  }: Props = $props();
@@ -83,7 +116,7 @@ let query = $state(untrack(() => labelForValue(value)));
83
116
  let typed = $state(false);
84
117
  let activeIndex = $state(0);
85
118
  const filtered = $derived(
86
- typed
119
+ typed && filter
87
120
  ? options.filter((option) =>
88
121
  option.label.toLowerCase().includes(query.toLowerCase()),
89
122
  )
@@ -136,6 +169,7 @@ function openList() {
136
169
  (option) => String(option.value) === value,
137
170
  );
138
171
  activeIndex = selected >= 0 ? selected : 0;
172
+ onquery?.('');
139
173
  }
140
174
  function closeList() {
141
175
  open = false;
@@ -147,6 +181,7 @@ function handleInput(event: Event & { currentTarget: HTMLInputElement }) {
147
181
  typed = true;
148
182
  open = true;
149
183
  activeIndex = 0;
184
+ onquery?.(query);
150
185
  if (allowCustom) {
151
186
  value = query;
152
187
  onvaluechange?.(value);
@@ -179,6 +214,10 @@ function handleKeydown(event: KeyboardEvent) {
179
214
  closeList();
180
215
  }
181
216
  }
217
+ // Options that arrive late can be fewer than the highlighted row.
218
+ $effect(() => {
219
+ if (activeIndex >= filtered.length) activeIndex = 0;
220
+ });
182
221
  $effect(() => {
183
222
  const form = inputEl?.form;
184
223
  if (!form) return;
@@ -296,19 +335,23 @@ useControlRegistration(() => {
296
335
  <div bind:this={rootEl} class="combobox {className}" data-smrt-control={controlId} data-smrt-form={interactionContext?.formId}
297
336
  data-smrt-subject-type={interaction === false ? undefined : interaction?.subject?.type}
298
337
  data-smrt-subject-id={interaction === false ? undefined : interaction?.subject?.id}>
299
- <label for={inputId}>{label}</label>
338
+ <FieldLabel for={inputId} {label} {required} />
300
339
  <!-- The form posts the committed value (an option id), never the label the person sees. -->
301
340
  {#if name}<input type="hidden" {name} {value} {disabled} />{/if}
302
341
  <input bind:this={inputEl} id={inputId} role="combobox" autocomplete="off" {placeholder} {disabled} required={required && !value} aria-required={required ? 'true' : undefined} value={query}
303
- aria-expanded={open} aria-controls={listId} aria-autocomplete="list" aria-activedescendant={open && filtered[activeIndex] ? `${listId}-${activeIndex}` : undefined}
342
+ aria-expanded={open} aria-controls={listId} aria-busy={busy ? 'true' : undefined} aria-invalid={invalid ? 'true' : undefined} aria-describedby={describedby} aria-autocomplete="list" aria-activedescendant={open && filtered[activeIndex] ? `${listId}-${activeIndex}` : undefined}
304
343
  onfocus={openList} onclick={openList} oninput={handleInput} onkeydown={handleKeydown} />
305
344
  {#if open && filtered.length}<div id={listId} class="options" role="listbox">{#each filtered as option, index (option.value)}<button id={`${listId}-${index}`} type="button" role="option"
306
- aria-selected={String(option.value) === value} class:active={index === activeIndex} disabled={option.disabled} onpointerdown={(event) => event.preventDefault()} onclick={() => commit(option, true)}>{option.label}</button>{/each}</div>{/if}
345
+ aria-selected={String(option.value) === value} class:active={index === activeIndex} disabled={option.disabled} onpointerdown={(event) => event.preventDefault()} onclick={() => commit(option, true)}>{#if optionContent}{@render optionContent(option)}{:else}{option.label}{/if}</button>{/each}</div>
346
+ {:else if open && status}<div class="options empty" aria-hidden="true">{status}</div>{/if}
347
+ {#if status !== undefined}<div class="sr-status" role="status" aria-live="polite">{open ? status : ''}</div>{/if}
307
348
  </div>
308
349
  <style>
309
- .combobox { position: relative; display: grid; gap: var(--smrt-spacing-1); color: var(--smrt-color-on-surface); }
310
- label { font: var(--smrt-typography-label-large-font); } input { box-sizing: border-box; width: 100%; padding: var(--smrt-spacing-2) var(--smrt-spacing-3); border: 1px solid var(--smrt-color-outline); border-radius: var(--smrt-radius-small); background: var(--smrt-color-surface); color: inherit; }
350
+ .combobox { position: relative; display: grid; gap: 0; color: var(--smrt-color-on-surface); }
351
+ input { box-sizing: border-box; width: 100%; padding: var(--smrt-spacing-2) var(--smrt-spacing-3); border: 1px solid var(--smrt-color-outline); border-radius: var(--smrt-radius-small); background: var(--smrt-color-surface); color: inherit; }
311
352
  input:focus { outline: 2px solid var(--smrt-color-primary); outline-offset: 1px; } .options { position: absolute; z-index: var(--smrt-z-index-dropdown); top: 100%; left: 0; right: 0; display: grid; padding: var(--smrt-spacing-1); border: 1px solid var(--smrt-color-outline-variant); border-radius: var(--smrt-radius-small); background: var(--smrt-color-surface-container); box-shadow: var(--smrt-elevation-2); }
312
353
  .options button { padding: var(--smrt-spacing-2) var(--smrt-spacing-3); border: 0; border-radius: var(--smrt-radius-extra-small); background: transparent; color: var(--smrt-color-on-surface); text-align: left; } .options button.active { background: var(--smrt-color-secondary-container); }
354
+ .options.empty { padding: var(--smrt-spacing-2) var(--smrt-spacing-3); color: var(--smrt-color-on-surface-variant); }
355
+ .sr-status { position: absolute; width: 1px; height: 1px; margin: -1px; padding: 0; overflow: hidden; clip-path: inset(50%); white-space: nowrap; border: 0; }
313
356
  :global(.combobox[data-smrt-highlighted='true']) { outline: 3px solid var(--smrt-color-tertiary); outline-offset: 4px; }
314
357
  </style>
@@ -1,3 +1,4 @@
1
+ import { type Snippet } from 'svelte';
1
2
  import type { ControlInteractionOptions, ControlOption } from './control-interaction.js';
2
3
  export interface Props {
3
4
  /** Array of options to select from. */
@@ -24,6 +25,31 @@ export interface Props {
24
25
  * field stays empty rather than flashing the raw value (an id).
25
26
  */
26
27
  valueLabel?: string;
28
+ /**
29
+ * Whether typing filters `options` by label. Set `false` when the caller
30
+ * supplies the matching options itself (server-side search via `onquery`),
31
+ * so a result that matched on something other than its label stays listed.
32
+ */
33
+ filter?: boolean;
34
+ /**
35
+ * Called with the text to search for when the list opens (empty) and on
36
+ * every keystroke. Pair with `filter={false}` and fresh `options`.
37
+ */
38
+ onquery?: (query: string) => void;
39
+ /**
40
+ * Message announced politely to assistive technology while the list is open
41
+ * ("Searching…", "3 results", "No results"). It is also shown inside the
42
+ * list while there are no options to list.
43
+ */
44
+ status?: string;
45
+ /** Marks the combobox busy while options are being fetched. */
46
+ busy?: boolean;
47
+ /** Marks the field invalid (`aria-invalid`). */
48
+ invalid?: boolean;
49
+ /** Ids of elements describing the field (error text, hints). */
50
+ describedby?: string;
51
+ /** Custom content for one option row; defaults to the label. */
52
+ optionContent?: Snippet<[ControlOption]>;
27
53
  /** Callback when the selected value changes. */
28
54
  onvaluechange?: (value: string) => void;
29
55
  /** CSS class to apply to the combobox container. */
@@ -1 +1 @@
1
- {"version":3,"file":"Combobox.svelte.d.ts","sourceRoot":"","sources":["../../../src/components/forms/Combobox.svelte.ts"],"names":[],"mappings":"AASA,OAAO,KAAK,EACV,yBAAyB,EACzB,aAAa,EACd,MAAM,0BAA0B,CAAC;AAWlC,MAAM,WAAW,KAAK;IACpB,uCAAuC;IACvC,OAAO,EAAE,aAAa,EAAE,CAAC;IACzB,kDAAkD;IAClD,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,sCAAsC;IACtC,KAAK,EAAE,MAAM,CAAC;IACd,4BAA4B;IAC5B,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,kDAAkD;IAClD,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB,uCAAuC;IACvC,QAAQ,CAAC,EAAE,OAAO,CAAC;IACnB,uCAAuC;IACvC,QAAQ,CAAC,EAAE,OAAO,CAAC;IACnB,8DAA8D;IAC9D,WAAW,CAAC,EAAE,OAAO,CAAC;IACtB,mDAAmD;IACnD,WAAW,CAAC,EAAE,yBAAyB,GAAG,KAAK,CAAC;IAChD;;;;OAIG;IACH,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,gDAAgD;IAChD,aAAa,CAAC,EAAE,CAAC,KAAK,EAAE,MAAM,KAAK,IAAI,CAAC;IACxC,oDAAoD;IACpD,KAAK,CAAC,EAAE,MAAM,CAAC;CAChB;AAyQD,QAAA,MAAM,QAAQ,gDAAwC,CAAC;AACvD,KAAK,QAAQ,GAAG,UAAU,CAAC,OAAO,QAAQ,CAAC,CAAC;AAC5C,eAAe,QAAQ,CAAC"}
1
+ {"version":3,"file":"Combobox.svelte.d.ts","sourceRoot":"","sources":["../../../src/components/forms/Combobox.svelte.ts"],"names":[],"mappings":"AAGA,OAAO,EAAE,KAAK,OAAO,EAAW,MAAM,QAAQ,CAAC;AAM/C,OAAO,KAAK,EACV,yBAAyB,EACzB,aAAa,EACd,MAAM,0BAA0B,CAAC;AAYlC,MAAM,WAAW,KAAK;IACpB,uCAAuC;IACvC,OAAO,EAAE,aAAa,EAAE,CAAC;IACzB,kDAAkD;IAClD,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,sCAAsC;IACtC,KAAK,EAAE,MAAM,CAAC;IACd,4BAA4B;IAC5B,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,kDAAkD;IAClD,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB,uCAAuC;IACvC,QAAQ,CAAC,EAAE,OAAO,CAAC;IACnB,uCAAuC;IACvC,QAAQ,CAAC,EAAE,OAAO,CAAC;IACnB,8DAA8D;IAC9D,WAAW,CAAC,EAAE,OAAO,CAAC;IACtB,mDAAmD;IACnD,WAAW,CAAC,EAAE,yBAAyB,GAAG,KAAK,CAAC;IAChD;;;;OAIG;IACH,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB;;;;OAIG;IACH,MAAM,CAAC,EAAE,OAAO,CAAC;IACjB;;;OAGG;IACH,OAAO,CAAC,EAAE,CAAC,KAAK,EAAE,MAAM,KAAK,IAAI,CAAC;IAClC;;;;OAIG;IACH,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,+DAA+D;IAC/D,IAAI,CAAC,EAAE,OAAO,CAAC;IACf,gDAAgD;IAChD,OAAO,CAAC,EAAE,OAAO,CAAC;IAClB,gEAAgE;IAChE,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB,gEAAgE;IAChE,aAAa,CAAC,EAAE,OAAO,CAAC,CAAC,aAAa,CAAC,CAAC,CAAC;IACzC,gDAAgD;IAChD,aAAa,CAAC,EAAE,CAAC,KAAK,EAAE,MAAM,KAAK,IAAI,CAAC;IACxC,oDAAoD;IACpD,KAAK,CAAC,EAAE,MAAM,CAAC;CAChB;AAyRD,QAAA,MAAM,QAAQ,gDAAwC,CAAC;AACvD,KAAK,QAAQ,GAAG,UAAU,CAAC,OAAO,QAAQ,CAAC,CAAC;AAC5C,eAAe,QAAQ,CAAC"}
@@ -7,6 +7,16 @@
7
7
  * 44px target, named by `aria-label`, `aria-pressed` while listening, and a
8
8
  * listening dot that does not rely on color alone (the name changes too).
9
9
  * Pair it with `DictationStatus` for the words ("Listening…", errors).
10
+ *
11
+ * Hands-free (`mode: 'hands-free'`): a steady ring means it is listening,
12
+ * a halo that swells with the voice means someone is speaking, and a small
13
+ * spinner means sentences are being written down. The ring is the
14
+ * non-motion cue; the halo and spinner stop moving for people who ask for
15
+ * reduced motion. While the page speaks a reply (`Dictation.suspend()`) the
16
+ * button dims with a dashed outline and its tooltip reads "Paused while the
17
+ * assistant speaks". Tapping ends it either way. The help text ("Just talk;
18
+ * I write it down when you pause...") is the tooltip and the button's
19
+ * `aria-describedby`, never a line of text in the form.
10
20
  */
11
21
  import { M } from '../../i18n/strings.ui.js';
12
22
  import { useI18n } from '../../i18n/use-i18n.js';
@@ -40,6 +50,11 @@ let {
40
50
 
41
51
  const { t } = useI18n();
42
52
  const active = $derived(dictation.active);
53
+ const handsFree = $derived(dictation.handsFree);
54
+ // Paused while the assistant speaks (half-duplex): not hearing, still open.
55
+ const paused = $derived(active && handsFree && dictation.suspended);
56
+ const speaking = $derived(active && handsFree && dictation.speaking && !paused);
57
+ const writing = $derived(handsFree && dictation.queued > 0);
43
58
  // While a recording is being written down, a tap would do nothing.
44
59
  const busy = $derived(dictation.state === 'transcribing');
45
60
  const name = $derived(
@@ -47,6 +62,22 @@ const name = $derived(
47
62
  ? (stopLabel ?? t(M['ui.dictation.stop']))
48
63
  : (label ?? t(M['ui.dictation.start'])),
49
64
  );
65
+ const uid = $props.id();
66
+ const helpId = `smrt-dictation-help-${uid}`;
67
+ const hint = $derived(
68
+ active
69
+ ? handsFree
70
+ ? paused
71
+ ? t(M['ui.dictation.paused_speaking'])
72
+ : t(M['ui.dictation.listening_hands_free'])
73
+ : name
74
+ : (title ??
75
+ t(
76
+ dictation.wantsHandsFree
77
+ ? M['ui.dictation.start_hands_free']
78
+ : M['ui.dictation.start_hint'],
79
+ )),
80
+ );
50
81
 
51
82
  function handleClick() {
52
83
  // A tap is a user gesture: wake the audio so the ready beep can play.
@@ -60,14 +91,19 @@ function handleClick() {
60
91
  type="button"
61
92
  variant="ghost"
62
93
  size="sm"
63
- class={`smrt-dictation-button${active ? ' smrt-dictation-button--listening' : ''} ${className}`.trim()}
94
+ class={`smrt-dictation-button${active ? ' smrt-dictation-button--listening' : ''}${active && handsFree ? ' smrt-dictation-button--hands-free' : ''}${speaking ? ' smrt-dictation-button--speaking' : ''}${paused ? ' smrt-dictation-button--paused' : ''} ${className}`.trim()}
64
95
  aria-label={name}
65
96
  aria-pressed={active}
66
97
  aria-busy={busy || undefined}
67
- title={active ? name : (title ?? t(M['ui.dictation.start_hint']))}
98
+ title={hint}
99
+ aria-describedby={hint !== name ? helpId : undefined}
68
100
  disabled={disabled || busy}
69
101
  onclick={handleClick}
70
102
  data-dictation-state={dictation.state}
103
+ data-dictation-mode={handsFree ? 'hands-free' : undefined}
104
+ data-dictation-speaking={speaking || undefined}
105
+ data-dictation-paused={paused || undefined}
106
+ style={speaking ? `--smrt-dictation-level: ${dictation.level}` : undefined}
71
107
  >
72
108
  <svg viewBox="0 0 24 24" width="20" height="20" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
73
109
  <rect x="9" y="2" width="6" height="12" rx="3"></rect>
@@ -77,7 +113,13 @@ function handleClick() {
77
113
  {#if active}
78
114
  <span class="smrt-dictation-dot" aria-hidden="true"></span>
79
115
  {/if}
116
+ {#if writing || (handsFree && dictation.state === 'transcribing')}
117
+ <span class="smrt-dictation-writing" aria-hidden="true"></span>
118
+ {/if}
80
119
  </Button>
120
+ {#if hint !== name}
121
+ <span id={helpId} class="smrt-dictation-help">{hint}</span>
122
+ {/if}
81
123
 
82
124
  <style>
83
125
  :global(.smrt-dictation-button) {
@@ -99,6 +141,16 @@ function handleClick() {
99
141
  color: var(--smrt-color-on-error-container, #410002);
100
142
  }
101
143
 
144
+ /* Read out with the button's name; never shown. */
145
+ .smrt-dictation-help {
146
+ position: absolute;
147
+ inline-size: 1px;
148
+ block-size: 1px;
149
+ overflow: hidden;
150
+ clip-path: inset(50%);
151
+ white-space: nowrap;
152
+ }
153
+
102
154
  .smrt-dictation-dot {
103
155
  position: absolute;
104
156
  inset-block-start: 6px;
@@ -110,6 +162,54 @@ function handleClick() {
110
162
  animation: smrt-dictation-pulse 1.2s ease-in-out infinite;
111
163
  }
112
164
 
165
+ /* Hands-free: a steady ring while listening ... */
166
+ :global(.smrt-dictation-button--hands-free) {
167
+ box-shadow: 0 0 0 2px var(--smrt-color-error, #ba1a1a);
168
+ }
169
+
170
+ /* ... and a halo that swells with the voice while someone is speaking. */
171
+ :global(.smrt-dictation-button--speaking) {
172
+ box-shadow:
173
+ 0 0 0 3px var(--smrt-color-error, #ba1a1a),
174
+ 0 0 0
175
+ calc(4px + 10px * var(--smrt-dictation-level, 0))
176
+ color-mix(in srgb, var(--smrt-color-error, #ba1a1a) 28%, transparent);
177
+ transition: box-shadow 80ms linear;
178
+ }
179
+
180
+ /* Paused while the assistant speaks: a dashed, dimmed ring (not color alone). */
181
+ :global(.smrt-dictation-button--paused) {
182
+ opacity: 0.6;
183
+ box-shadow: none;
184
+ outline: 2px dashed var(--smrt-color-outline, #73777f);
185
+ outline-offset: -2px;
186
+ }
187
+
188
+ :global(.smrt-dictation-button--paused) .smrt-dictation-dot {
189
+ animation: none;
190
+ opacity: 0.4;
191
+ }
192
+
193
+ /* Sentences are being written down. */
194
+ .smrt-dictation-writing {
195
+ position: absolute;
196
+ inset-block-end: 6px;
197
+ inset-inline-end: 6px;
198
+ inline-size: 10px;
199
+ block-size: 10px;
200
+ border-radius: 50%;
201
+ border: 2px solid var(--smrt-color-outline-variant, #c4c6cf);
202
+ border-block-start-color: var(--smrt-color-primary, #2f5ea8);
203
+ background: var(--smrt-color-surface, #fff);
204
+ animation: smrt-dictation-spin 0.9s linear infinite;
205
+ }
206
+
207
+ @keyframes smrt-dictation-spin {
208
+ to {
209
+ transform: rotate(360deg);
210
+ }
211
+ }
212
+
113
213
  @keyframes smrt-dictation-pulse {
114
214
  0%,
115
215
  100% {
@@ -121,8 +221,15 @@ function handleClick() {
121
221
  }
122
222
 
123
223
  @media (prefers-reduced-motion: reduce) {
124
- .smrt-dictation-dot {
224
+ .smrt-dictation-dot,
225
+ .smrt-dictation-writing {
125
226
  animation: none;
126
227
  }
228
+
229
+ /* Still ringed, but the halo no longer animates with the voice. */
230
+ :global(.smrt-dictation-button--speaking) {
231
+ transition: none;
232
+ box-shadow: 0 0 0 4px var(--smrt-color-error, #ba1a1a);
233
+ }
127
234
  }
128
235
  </style>
@@ -1 +1 @@
1
- {"version":3,"file":"DictationButton.svelte.d.ts","sourceRoot":"","sources":["../../../src/components/forms/DictationButton.svelte.ts"],"names":[],"mappings":"AAeA,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,uBAAuB,CAAC;AAIvD,MAAM,WAAW,KAAK;IACpB,kDAAkD;IAClD,SAAS,EAAE,SAAS,CAAC;IACrB,6DAA6D;IAC7D,QAAQ,CAAC,EAAE,OAAO,CAAC;IACnB,oEAAoE;IACpE,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,0DAA0D;IAC1D,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,iEAAiE;IACjE,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,yBAAyB;IACzB,KAAK,CAAC,EAAE,MAAM,CAAC;CAChB;AAoDD,QAAA,MAAM,eAAe,2CAAwC,CAAC;AAC9D,KAAK,eAAe,GAAG,UAAU,CAAC,OAAO,eAAe,CAAC,CAAC;AAC1D,eAAe,eAAe,CAAC"}
1
+ {"version":3,"file":"DictationButton.svelte.d.ts","sourceRoot":"","sources":["../../../src/components/forms/DictationButton.svelte.ts"],"names":[],"mappings":"AAyBA,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,uBAAuB,CAAC;AAIvD,MAAM,WAAW,KAAK;IACpB,kDAAkD;IAClD,SAAS,EAAE,SAAS,CAAC;IACrB,6DAA6D;IAC7D,QAAQ,CAAC,EAAE,OAAO,CAAC;IACnB,oEAAoE;IACpE,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,0DAA0D;IAC1D,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,iEAAiE;IACjE,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,yBAAyB;IACzB,KAAK,CAAC,EAAE,MAAM,CAAC;CAChB;AA+ED,QAAA,MAAM,eAAe,2CAAwC,CAAC;AAC9D,KAAK,eAAe,GAAG,UAAU,CAAC,OAAO,eAAe,CAAC,CAAC;AAC1D,eAAe,eAAe,CAAC"}