@happyvertical/smrt-svelte 0.30.0 → 0.31.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 (47) hide show
  1. package/AGENTS.md +4 -1
  2. package/dist/Provider.svelte +12 -2
  3. package/dist/Provider.svelte.d.ts.map +1 -1
  4. package/dist/components/admin/AgentSettingsShell.svelte +72 -4
  5. package/dist/components/admin/AgentSettingsShell.svelte.d.ts.map +1 -1
  6. package/dist/components/admin/__tests__/AgentSettingsShell.tablist.test.js +93 -0
  7. package/dist/components/forms/DateRangeInput.svelte +29 -5
  8. package/dist/components/forms/DateRangeInput.svelte.d.ts.map +1 -1
  9. package/dist/components/forms/DateTimeInput.svelte +34 -7
  10. package/dist/components/forms/DateTimeInput.svelte.d.ts.map +1 -1
  11. package/dist/components/forms/FileUpload.svelte +3 -1
  12. package/dist/components/forms/FileUpload.svelte.d.ts.map +1 -1
  13. package/dist/components/forms/Form.svelte +72 -36
  14. package/dist/components/forms/Form.svelte.d.ts.map +1 -1
  15. package/dist/components/forms/FormMicButton.svelte +14 -11
  16. package/dist/components/forms/FormMicButton.svelte.d.ts.map +1 -1
  17. package/dist/components/forms/PhoneInput.svelte +29 -5
  18. package/dist/components/forms/PhoneInput.svelte.d.ts.map +1 -1
  19. package/dist/components/forms/TextInput.svelte +35 -7
  20. package/dist/components/forms/TextInput.svelte.d.ts.map +1 -1
  21. package/dist/components/forms/TextareaInput.svelte +29 -5
  22. package/dist/components/forms/TextareaInput.svelte.d.ts.map +1 -1
  23. package/dist/components/forms/__tests__/FileUpload.error-alert.test.js +37 -0
  24. package/dist/components/forms/__tests__/Form.stt-error.test.js +57 -0
  25. package/dist/components/forms/__tests__/FormMicButton.test.js +7 -0
  26. package/dist/components/forms/__tests__/mic-keyboard-a11y.test.js +74 -0
  27. package/dist/hooks/__tests__/stt-consumer.fixture.svelte +25 -0
  28. package/dist/hooks/__tests__/stt-consumer.fixture.svelte.d.ts +15 -0
  29. package/dist/hooks/__tests__/stt-consumer.fixture.svelte.d.ts.map +1 -0
  30. package/dist/hooks/__tests__/stt-ownership-harness.svelte +42 -0
  31. package/dist/hooks/__tests__/stt-ownership-harness.svelte.d.ts +20 -0
  32. package/dist/hooks/__tests__/stt-ownership-harness.svelte.d.ts.map +1 -0
  33. package/dist/hooks/__tests__/useSTT-ownership.test.js +102 -0
  34. package/dist/hooks/useSTT.svelte.d.ts.map +1 -1
  35. package/dist/hooks/useSTT.svelte.js +20 -6
  36. package/dist/hooks/useTTS.svelte.d.ts.map +1 -1
  37. package/dist/hooks/useTTS.svelte.js +20 -6
  38. package/dist/i18n/server.d.ts +5 -5
  39. package/dist/i18n/server.js +5 -5
  40. package/dist/internal/logger.d.ts +13 -0
  41. package/dist/internal/logger.d.ts.map +1 -0
  42. package/dist/internal/logger.js +12 -0
  43. package/dist/state/__tests__/app-state-ai-lifecycle.test.js +240 -0
  44. package/dist/state/app-state.svelte.d.ts +40 -8
  45. package/dist/state/app-state.svelte.d.ts.map +1 -1
  46. package/dist/state/app-state.svelte.js +224 -54
  47. package/package.json +6 -5
@@ -4,6 +4,7 @@ import { onDestroy, onMount } from 'svelte';
4
4
  import { useAppState } from '../../hooks/useAppState.svelte.js';
5
5
  import { useSTT } from '../../hooks/useSTT.svelte.js';
6
6
  import { M } from '../../i18n/strings.forms.js';
7
+ import { logger } from '../../internal/logger.js';
7
8
  import {
8
9
  type FieldDefinition,
9
10
  tryGetFormContext,
@@ -162,15 +163,26 @@ onDestroy(() => {
162
163
  async function startHoldRecording() {
163
164
  if (!isSmrt || disabled || isProcessing) return;
164
165
 
165
- if (!stt.isReady || stt.adapterType !== 'whisper-wasm') {
166
- await stt.initialize({ type: 'whisper-wasm' });
167
- }
168
-
169
166
  processError = null;
170
167
  recordingStartTime = Date.now();
171
168
  isHolding = true;
172
169
 
173
- await stt.start({ continuous: true, interimResults: false });
170
+ // Guard STT init / mic acquisition so a rejection can't leave the field
171
+ // wedged in a permanent "Recording..." state with no visible error (C2).
172
+ try {
173
+ if (!stt.isReady || stt.adapterType !== 'whisper-wasm') {
174
+ await stt.initialize({ type: 'whisper-wasm' });
175
+ }
176
+ await stt.start({ continuous: true, interimResults: false });
177
+ } catch (err) {
178
+ isHolding = false;
179
+ processError =
180
+ err instanceof Error ? err.message : 'Could not start voice input';
181
+ logger.error('PhoneInput: failed to start voice recording', {
182
+ field: name,
183
+ error: err,
184
+ });
185
+ }
174
186
  }
175
187
 
176
188
  async function stopHoldRecording() {
@@ -232,6 +244,17 @@ function handleTouchEnd() {
232
244
  stopHoldRecording();
233
245
  }
234
246
 
247
+ // Keyboard activation for the mic (WCAG 2.1.1): Enter/Space toggles recording.
248
+ function handleMicKeydown(e: KeyboardEvent) {
249
+ if (e.key !== 'Enter' && e.key !== ' ') return;
250
+ e.preventDefault();
251
+ if (isHolding) {
252
+ stopHoldRecording();
253
+ } else {
254
+ startHoldRecording();
255
+ }
256
+ }
257
+
235
258
  function handleInput(e: Event) {
236
259
  const target = e.target as HTMLInputElement;
237
260
  const formatted = formatPhoneNumber(target.value);
@@ -281,6 +304,7 @@ function handleInput(e: Event) {
281
304
  class="mic-btn"
282
305
  class:active={isHolding}
283
306
  {disabled}
307
+ onkeydown={handleMicKeydown}
284
308
  onmousedown={handleMouseDown}
285
309
  onmouseup={handleMouseUp}
286
310
  onmouseleave={handleMouseLeave}
@@ -1 +1 @@
1
- {"version":3,"file":"PhoneInput.svelte.d.ts","sourceRoot":"","sources":["../../../src/components/forms/PhoneInput.svelte.ts"],"names":[],"mappings":"AAcA,MAAM,WAAW,KAAK;IACpB,iBAAiB;IACjB,IAAI,EAAE,MAAM,CAAC;IACb,kBAAkB;IAClB,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,uCAAuC;IACvC,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB,uBAAuB;IACvB,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB,+BAA+B;IAC/B,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,qBAAqB;IACrB,QAAQ,CAAC,EAAE,OAAO,CAAC;IACnB,qBAAqB;IACrB,QAAQ,CAAC,EAAE,OAAO,CAAC;IACnB,gCAAgC;IAChC,QAAQ,CAAC,EAAE,CAAC,KAAK,EAAE,MAAM,KAAK,IAAI,CAAC;CACpC;AAqRD,QAAA,MAAM,UAAU,gDAAwC,CAAC;AACzD,KAAK,UAAU,GAAG,UAAU,CAAC,OAAO,UAAU,CAAC,CAAC;AAChD,eAAe,UAAU,CAAC"}
1
+ {"version":3,"file":"PhoneInput.svelte.d.ts","sourceRoot":"","sources":["../../../src/components/forms/PhoneInput.svelte.ts"],"names":[],"mappings":"AAeA,MAAM,WAAW,KAAK;IACpB,iBAAiB;IACjB,IAAI,EAAE,MAAM,CAAC;IACb,kBAAkB;IAClB,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,uCAAuC;IACvC,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB,uBAAuB;IACvB,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB,+BAA+B;IAC/B,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,qBAAqB;IACrB,QAAQ,CAAC,EAAE,OAAO,CAAC;IACnB,qBAAqB;IACrB,QAAQ,CAAC,EAAE,OAAO,CAAC;IACnB,gCAAgC;IAChC,QAAQ,CAAC,EAAE,CAAC,KAAK,EAAE,MAAM,KAAK,IAAI,CAAC;CACpC;AA4SD,QAAA,MAAM,UAAU,gDAAwC,CAAC;AACzD,KAAK,UAAU,GAAG,UAAU,CAAC,OAAO,UAAU,CAAC,CAAC;AAChD,eAAe,UAAU,CAAC"}
@@ -9,6 +9,7 @@ import { onDestroy, onMount } from 'svelte';
9
9
  import { useAppState } from '../../hooks/useAppState.svelte.js';
10
10
  import { useSTT } from '../../hooks/useSTT.svelte.js';
11
11
  import { M } from '../../i18n/strings.forms.js';
12
+ import { logger } from '../../internal/logger.js';
12
13
  import {
13
14
  type FieldDefinition,
14
15
  tryGetFormContext,
@@ -115,19 +116,31 @@ onDestroy(() => {
115
116
  async function startHoldRecording() {
116
117
  if (!isSmrt || disabled || isProcessing) return;
117
118
 
118
- // Initialize STT with Whisper v2 for speed + accuracy
119
- if (!stt.isReady || stt.adapterType !== 'whisper-wasm') {
120
- await stt.initialize({ type: 'whisper-wasm' });
121
- }
122
-
123
119
  // Store current value for append mode
124
120
  valueBeforeRecording = value;
125
121
  processError = null;
126
122
  recordingStartTime = Date.now();
127
123
  isHolding = true;
128
124
 
129
- // Use continuous mode to capture all speech while holding
130
- await stt.start({ continuous: true, interimResults: false });
125
+ // STT init / mic acquisition can reject (model fetch failure, mic-permission
126
+ // denial). Without this guard `isHolding` would stay true and no error would
127
+ // surface, wedging the field in a permanent "Recording..." state (C2).
128
+ try {
129
+ // Initialize STT with Whisper v2 for speed + accuracy
130
+ if (!stt.isReady || stt.adapterType !== 'whisper-wasm') {
131
+ await stt.initialize({ type: 'whisper-wasm' });
132
+ }
133
+ // Use continuous mode to capture all speech while holding
134
+ await stt.start({ continuous: true, interimResults: false });
135
+ } catch (err) {
136
+ isHolding = false;
137
+ processError =
138
+ err instanceof Error ? err.message : 'Could not start voice input';
139
+ logger.error('TextInput: failed to start voice recording', {
140
+ field: name,
141
+ error: err,
142
+ });
143
+ }
131
144
  }
132
145
 
133
146
  async function stopHoldRecording() {
@@ -202,6 +215,20 @@ function handleMicClick(e: MouseEvent) {
202
215
  e.stopPropagation();
203
216
  }
204
217
 
218
+ // Keyboard activation for the mic (WCAG 2.1.1). The pointer flow is
219
+ // hold-to-record; keyboard maps to a toggle — Enter/Space starts recording,
220
+ // pressing again stops. preventDefault stops Space from also firing the
221
+ // button's synthetic click and from scrolling the page.
222
+ function handleMicKeydown(e: KeyboardEvent) {
223
+ if (e.key !== 'Enter' && e.key !== ' ') return;
224
+ e.preventDefault();
225
+ if (isHolding) {
226
+ stopHoldRecording();
227
+ } else {
228
+ startHoldRecording();
229
+ }
230
+ }
231
+
205
232
  function handleInput(e: Event) {
206
233
  const target = e.target as HTMLInputElement;
207
234
  updateValue(target.value);
@@ -253,6 +280,7 @@ function handleInput(e: Event) {
253
280
  {disabled}
254
281
  use:ripple
255
282
  onclick={handleMicClick}
283
+ onkeydown={handleMicKeydown}
256
284
  onmousedown={(e) => { e.stopPropagation(); if (e.button === 0) startHoldRecording(); }}
257
285
  onmouseup={handleMouseUp}
258
286
  onmouseleave={handleMouseLeave}
@@ -1 +1 @@
1
- {"version":3,"file":"TextInput.svelte.d.ts","sourceRoot":"","sources":["../../../src/components/forms/TextInput.svelte.ts"],"names":[],"mappings":"AAmBA,MAAM,WAAW,KAAK;IACpB,iBAAiB;IACjB,IAAI,EAAE,MAAM,CAAC;IACb,kBAAkB;IAClB,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,2CAA2C;IAC3C,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB,iBAAiB;IACjB,IAAI,CAAC,EAAE,MAAM,GAAG,OAAO,CAAC;IACxB,uBAAuB;IACvB,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB,+BAA+B;IAC/B,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,qBAAqB;IACrB,QAAQ,CAAC,EAAE,OAAO,CAAC;IACnB,qBAAqB;IACrB,QAAQ,CAAC,EAAE,OAAO,CAAC;IACnB,sDAAsD;IACtD,UAAU,CAAC,EAAE,OAAO,CAAC;IACrB,gCAAgC;IAChC,QAAQ,CAAC,EAAE,CAAC,KAAK,EAAE,MAAM,KAAK,IAAI,CAAC;CACpC;AA6ND,QAAA,MAAM,SAAS,gDAAwC,CAAC;AACxD,KAAK,SAAS,GAAG,UAAU,CAAC,OAAO,SAAS,CAAC,CAAC;AAC9C,eAAe,SAAS,CAAC"}
1
+ {"version":3,"file":"TextInput.svelte.d.ts","sourceRoot":"","sources":["../../../src/components/forms/TextInput.svelte.ts"],"names":[],"mappings":"AAoBA,MAAM,WAAW,KAAK;IACpB,iBAAiB;IACjB,IAAI,EAAE,MAAM,CAAC;IACb,kBAAkB;IAClB,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,2CAA2C;IAC3C,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB,iBAAiB;IACjB,IAAI,CAAC,EAAE,MAAM,GAAG,OAAO,CAAC;IACxB,uBAAuB;IACvB,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB,+BAA+B;IAC/B,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,qBAAqB;IACrB,QAAQ,CAAC,EAAE,OAAO,CAAC;IACnB,qBAAqB;IACrB,QAAQ,CAAC,EAAE,OAAO,CAAC;IACnB,sDAAsD;IACtD,UAAU,CAAC,EAAE,OAAO,CAAC;IACrB,gCAAgC;IAChC,QAAQ,CAAC,EAAE,CAAC,KAAK,EAAE,MAAM,KAAK,IAAI,CAAC;CACpC;AAwPD,QAAA,MAAM,SAAS,gDAAwC,CAAC;AACxD,KAAK,SAAS,GAAG,UAAU,CAAC,OAAO,SAAS,CAAC,CAAC;AAC9C,eAAe,SAAS,CAAC"}
@@ -6,6 +6,7 @@ import { onDestroy, onMount } from 'svelte';
6
6
  import { useAppState } from '../../hooks/useAppState.svelte.js';
7
7
  import { useSTT } from '../../hooks/useSTT.svelte.js';
8
8
  import { M } from '../../i18n/strings.forms.js';
9
+ import { logger } from '../../internal/logger.js';
9
10
  import {
10
11
  type FieldDefinition,
11
12
  tryGetFormContext,
@@ -103,16 +104,27 @@ onDestroy(() => {
103
104
  async function startHoldRecording() {
104
105
  if (!isSmrt || disabled || isProcessing) return;
105
106
 
106
- if (!stt.isReady || stt.adapterType !== 'whisper-wasm') {
107
- await stt.initialize({ type: 'whisper-wasm' });
108
- }
109
-
110
107
  valueBeforeRecording = value;
111
108
  processError = null;
112
109
  recordingStartTime = Date.now();
113
110
  isHolding = true;
114
111
 
115
- await stt.start({ continuous: true, interimResults: false });
112
+ // Guard STT init / mic acquisition so a rejection can't leave the field
113
+ // wedged in a permanent "Recording..." state with no visible error (C2).
114
+ try {
115
+ if (!stt.isReady || stt.adapterType !== 'whisper-wasm') {
116
+ await stt.initialize({ type: 'whisper-wasm' });
117
+ }
118
+ await stt.start({ continuous: true, interimResults: false });
119
+ } catch (err) {
120
+ isHolding = false;
121
+ processError =
122
+ err instanceof Error ? err.message : 'Could not start voice input';
123
+ logger.error('TextareaInput: failed to start voice recording', {
124
+ field: name,
125
+ error: err,
126
+ });
127
+ }
116
128
  }
117
129
 
118
130
  async function stopHoldRecording() {
@@ -179,6 +191,17 @@ function handleTouchEnd() {
179
191
  stopHoldRecording();
180
192
  }
181
193
 
194
+ // Keyboard activation for the mic (WCAG 2.1.1): Enter/Space toggles recording.
195
+ function handleMicKeydown(e: KeyboardEvent) {
196
+ if (e.key !== 'Enter' && e.key !== ' ') return;
197
+ e.preventDefault();
198
+ if (isHolding) {
199
+ stopHoldRecording();
200
+ } else {
201
+ startHoldRecording();
202
+ }
203
+ }
204
+
182
205
  function handleInput(e: Event) {
183
206
  const target = e.target as HTMLTextAreaElement;
184
207
  updateValue(target.value);
@@ -228,6 +251,7 @@ function handleInput(e: Event) {
228
251
  class:active={isHolding}
229
252
  {disabled}
230
253
  use:ripple
254
+ onkeydown={handleMicKeydown}
231
255
  onmousedown={(e) => { e.stopPropagation(); if (e.button === 0) startHoldRecording(); }}
232
256
  onmouseup={handleMouseUp}
233
257
  onmouseleave={handleMouseLeave}
@@ -1 +1 @@
1
- {"version":3,"file":"TextareaInput.svelte.d.ts","sourceRoot":"","sources":["../../../src/components/forms/TextareaInput.svelte.ts"],"names":[],"mappings":"AAgBA,MAAM,WAAW,KAAK;IACpB,iBAAiB;IACjB,IAAI,EAAE,MAAM,CAAC;IACb,kBAAkB;IAClB,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,uCAAuC;IACvC,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB,uBAAuB;IACvB,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB,+BAA+B;IAC/B,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,qBAAqB;IACrB,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,qBAAqB;IACrB,QAAQ,CAAC,EAAE,OAAO,CAAC;IACnB,qBAAqB;IACrB,QAAQ,CAAC,EAAE,OAAO,CAAC;IACnB,sDAAsD;IACtD,UAAU,CAAC,EAAE,OAAO,CAAC;IACrB,gCAAgC;IAChC,QAAQ,CAAC,EAAE,CAAC,KAAK,EAAE,MAAM,KAAK,IAAI,CAAC;CACpC;AAuMD,QAAA,MAAM,aAAa,gDAAwC,CAAC;AAC5D,KAAK,aAAa,GAAG,UAAU,CAAC,OAAO,aAAa,CAAC,CAAC;AACtD,eAAe,aAAa,CAAC"}
1
+ {"version":3,"file":"TextareaInput.svelte.d.ts","sourceRoot":"","sources":["../../../src/components/forms/TextareaInput.svelte.ts"],"names":[],"mappings":"AAiBA,MAAM,WAAW,KAAK;IACpB,iBAAiB;IACjB,IAAI,EAAE,MAAM,CAAC;IACb,kBAAkB;IAClB,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,uCAAuC;IACvC,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB,uBAAuB;IACvB,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB,+BAA+B;IAC/B,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,qBAAqB;IACrB,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,qBAAqB;IACrB,QAAQ,CAAC,EAAE,OAAO,CAAC;IACnB,qBAAqB;IACrB,QAAQ,CAAC,EAAE,OAAO,CAAC;IACnB,sDAAsD;IACtD,UAAU,CAAC,EAAE,OAAO,CAAC;IACrB,gCAAgC;IAChC,QAAQ,CAAC,EAAE,CAAC,KAAK,EAAE,MAAM,KAAK,IAAI,CAAC;CACpC;AA8ND,QAAA,MAAM,aAAa,gDAAwC,CAAC;AAC5D,KAAK,aAAa,GAAG,UAAU,CAAC,OAAO,aAAa,CAAC,CAAC;AACtD,eAAe,aAAa,CAAC"}
@@ -0,0 +1,37 @@
1
+ /**
2
+ * Regression: FileUpload validation errors are announced (C7).
3
+ *
4
+ * The error `<p>` was a plain element, so async reject errors (wrong type /
5
+ * oversize) were not surfaced to screen readers — unlike Form/TextInput which
6
+ * announce. The fix makes it a live region (role="alert" + aria-live).
7
+ */
8
+ import { render, screen, waitFor } from '@testing-library/svelte';
9
+ import userEvent from '@testing-library/user-event';
10
+ import { describe, expect, it } from 'vitest';
11
+ import FileUpload from '../FileUpload.svelte';
12
+ function fileInput(container) {
13
+ const input = container.querySelector('input[type="file"]');
14
+ if (!input)
15
+ throw new Error('file input not found');
16
+ return input;
17
+ }
18
+ describe('FileUpload — error live region (C7)', () => {
19
+ it('announces a max-size error via role="alert"', async () => {
20
+ const { container } = render(FileUpload, {
21
+ // maxSize forces a validation error without an `accept` filter (which
22
+ // userEvent.upload would otherwise apply before the file reaches the
23
+ // component, suppressing the error path under test).
24
+ props: { label: 'Upload', maxSize: 4 },
25
+ });
26
+ const big = new File(['way too many bytes'], 'big.bin', {
27
+ type: 'application/octet-stream',
28
+ });
29
+ await userEvent.upload(fileInput(container), big);
30
+ await waitFor(() => {
31
+ const alert = screen.getByRole('alert');
32
+ expect(alert).toBeInTheDocument();
33
+ expect(alert).toHaveAttribute('aria-live');
34
+ expect(alert.textContent).toMatch(/maximum size/i);
35
+ });
36
+ });
37
+ });
@@ -0,0 +1,57 @@
1
+ /**
2
+ * Regression: Form voice-listening init failures don't wedge the form (C2/C3).
3
+ *
4
+ * `startFormListening` awaited `stt.initialize()`/`stt.start()` with no
5
+ * try/catch. On rejection the listening flags stayed set, the auto-stop $effect
6
+ * (gated on !isStarting) was suppressed, and the form was stuck in a permanent
7
+ * "listening" state with no surfaced error. The fix wraps the flow in
8
+ * try/catch/finally: reset the flags and show the error.
9
+ */
10
+ import { render, screen, waitFor } from '@testing-library/svelte';
11
+ import userEvent from '@testing-library/user-event';
12
+ import { createRawSnippet } from 'svelte';
13
+ import { describe, expect, it, vi } from 'vitest';
14
+ const sttStub = {
15
+ isListening: false,
16
+ isInitializing: false,
17
+ isReady: false,
18
+ adapterType: null,
19
+ downloadProgress: 0,
20
+ lastResult: '',
21
+ initialize: vi.fn(async () => {
22
+ throw new Error('model download failed');
23
+ }),
24
+ start: vi.fn(async () => { }),
25
+ stop: vi.fn(async () => { }),
26
+ };
27
+ vi.mock('../../../hooks/useAppState.svelte.js', () => ({
28
+ useAppState: () => ({ state: { mode: 'smrt' }, setMode: vi.fn() }),
29
+ }));
30
+ vi.mock('../../../hooks/useSTT.svelte.js', () => ({
31
+ useSTT: () => sttStub,
32
+ }));
33
+ import Form from '../Form.svelte';
34
+ function child() {
35
+ return createRawSnippet(() => ({
36
+ render: () => '<span>fields</span>',
37
+ }));
38
+ }
39
+ describe('Form — voice-listening init failure (C2)', () => {
40
+ it('surfaces an error and does not wedge when STT init rejects', async () => {
41
+ render(Form, {
42
+ props: { children: child(), showFormListen: true },
43
+ });
44
+ // The form-listen button is shown in smrt mode; clicking it starts listening.
45
+ const listenBtn = screen.getByRole('button');
46
+ await userEvent.click(listenBtn);
47
+ // initialize() rejected — an error must be surfaced (role=alert region).
48
+ await waitFor(() => {
49
+ expect(screen.getByRole('alert')).toBeInTheDocument();
50
+ });
51
+ expect(screen.getByRole('alert').textContent).toMatch(/model download failed/i);
52
+ // And the form is NOT wedged: the polite status region is not stuck on the
53
+ // "listening" message (listening flag was reset).
54
+ const status = screen.getByRole('status');
55
+ expect(status.textContent?.trim()).toBe('');
56
+ });
57
+ });
@@ -68,6 +68,13 @@ describe('FormMicButton', () => {
68
68
  await userEvent.keyboard('{Enter}');
69
69
  expect(toggleListening).toHaveBeenCalledTimes(1);
70
70
  });
71
+ it('toggles listening when activated with Space (CS2)', async () => {
72
+ render(FormMicButton);
73
+ const button = screen.getByRole('button', { name: 'Click to speak' });
74
+ button.focus();
75
+ await userEvent.keyboard(' ');
76
+ expect(toggleListening).toHaveBeenCalledTimes(1);
77
+ });
71
78
  it('relabels to "Stop listening" once the form is listening', async () => {
72
79
  formState = { isFormListening: true, isExtracting: false };
73
80
  render(FormMicButton);
@@ -0,0 +1,74 @@
1
+ /**
2
+ * Regression: per-field voice mics are keyboard-operable (C1, WCAG 2.1.1).
3
+ *
4
+ * Before the fix the mic <button>s were bound only to mousedown/up + touch
5
+ * handlers (onclick was a no-op preventDefault), so a keyboard user could focus
6
+ * the mic but never record. The fix adds Enter/Space toggle activation, mirror-
7
+ * ing FormMicButton. TextInput is the representative editable field;
8
+ * DateTimeInput is covered too because its SMRT-mode text input is read-only —
9
+ * the mic is the ONLY keyboard entry path there.
10
+ *
11
+ * useAppState/useSTT throw outside a <Provider>, so both are mocked. The STT
12
+ * stub starts `isReady: true` so the hold flow skips initialize() and the
13
+ * start()/stop() spies fire synchronously enough to assert.
14
+ */
15
+ import { render, screen } from '@testing-library/svelte';
16
+ import userEvent from '@testing-library/user-event';
17
+ import { beforeEach, describe, expect, it, vi } from 'vitest';
18
+ const sttStub = {
19
+ isListening: false,
20
+ isInitializing: false,
21
+ isReady: true,
22
+ adapterType: 'whisper-wasm',
23
+ downloadProgress: 0,
24
+ lastResult: '',
25
+ initialize: vi.fn(async () => { }),
26
+ start: vi.fn(async () => { }),
27
+ stop: vi.fn(async () => { }),
28
+ };
29
+ vi.mock('../../../hooks/useAppState.svelte.js', () => ({
30
+ useAppState: () => ({ state: { mode: 'smrt' }, setMode: vi.fn() }),
31
+ }));
32
+ vi.mock('../../../hooks/useSTT.svelte.js', () => ({
33
+ useSTT: () => sttStub,
34
+ }));
35
+ import DateTimeInput from '../DateTimeInput.svelte';
36
+ import TextInput from '../TextInput.svelte';
37
+ beforeEach(() => {
38
+ vi.clearAllMocks();
39
+ sttStub.adapterType = 'whisper-wasm';
40
+ });
41
+ describe('TextInput mic — keyboard activation (C1)', () => {
42
+ it('exposes the mic as a focusable button with an accessible name', () => {
43
+ render(TextInput, { props: { name: 'note', label: 'Note' } });
44
+ const mic = screen.getByRole('button', { name: /speak|mic|voice/i });
45
+ expect(mic).toBeInTheDocument();
46
+ // Native <button> => in the tab order (not an empty/dead tab stop).
47
+ expect(mic).not.toHaveAttribute('tabindex', '-1');
48
+ });
49
+ it('starts recording on Enter and stops on the next Enter (toggle)', async () => {
50
+ render(TextInput, { props: { name: 'note', label: 'Note' } });
51
+ const mic = screen.getByRole('button', { name: /speak|mic|voice/i });
52
+ mic.focus();
53
+ await userEvent.keyboard('{Enter}');
54
+ expect(sttStub.start).toHaveBeenCalledTimes(1);
55
+ await userEvent.keyboard('{Enter}');
56
+ expect(sttStub.stop).toHaveBeenCalledTimes(1);
57
+ });
58
+ it('starts recording on Space', async () => {
59
+ render(TextInput, { props: { name: 'note', label: 'Note' } });
60
+ const mic = screen.getByRole('button', { name: /speak|mic|voice/i });
61
+ mic.focus();
62
+ await userEvent.keyboard(' ');
63
+ expect(sttStub.start).toHaveBeenCalledTimes(1);
64
+ });
65
+ });
66
+ describe('DateTimeInput mic — keyboard activation (C1)', () => {
67
+ it('records via keyboard even though the text field is read-only', async () => {
68
+ render(DateTimeInput, { props: { name: 'when', label: 'When' } });
69
+ const mic = screen.getByRole('button', { name: /speak|mic|voice|date/i });
70
+ mic.focus();
71
+ await userEvent.keyboard('{Enter}');
72
+ expect(sttStub.start).toHaveBeenCalledTimes(1);
73
+ });
74
+ });
@@ -0,0 +1,25 @@
1
+ <script lang="ts">
2
+ /**
3
+ * Test consumer that calls the real `useSTT()` hook so its `onDestroy`
4
+ * per-hook ownership logic (R4) runs when this component unmounts.
5
+ */
6
+ import { type UseSTTReturn, useSTT } from '../useSTT.svelte.js';
7
+
8
+ interface Props {
9
+ /** Hand the live hook back to the test so it can call start()/stop(). */
10
+ onReady?: (stt: UseSTTReturn) => void;
11
+ /** If true, start a listening session as soon as this consumer mounts. */
12
+ autoStart?: boolean;
13
+ }
14
+
15
+ const { onReady, autoStart = false }: Props = $props();
16
+
17
+ const stt = useSTT();
18
+ // This fixture intentionally snapshots props during setup (test-only).
19
+ // svelte-ignore state_referenced_locally
20
+ onReady?.(stt);
21
+ // svelte-ignore state_referenced_locally
22
+ if (autoStart) {
23
+ void stt.start();
24
+ }
25
+ </script>
@@ -0,0 +1,15 @@
1
+ /**
2
+ * Test consumer that calls the real `useSTT()` hook so its `onDestroy`
3
+ * per-hook ownership logic (R4) runs when this component unmounts.
4
+ */
5
+ import { type UseSTTReturn } from '../useSTT.svelte.js';
6
+ interface Props {
7
+ /** Hand the live hook back to the test so it can call start()/stop(). */
8
+ onReady?: (stt: UseSTTReturn) => void;
9
+ /** If true, start a listening session as soon as this consumer mounts. */
10
+ autoStart?: boolean;
11
+ }
12
+ declare const SttConsumer: import("svelte").Component<Props, {}, "">;
13
+ type SttConsumer = ReturnType<typeof SttConsumer>;
14
+ export default SttConsumer;
15
+ //# sourceMappingURL=stt-consumer.fixture.svelte.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"stt-consumer.fixture.svelte.d.ts","sourceRoot":"","sources":["../../../src/hooks/__tests__/stt-consumer.fixture.svelte.ts"],"names":[],"mappings":"AAGA;;;GAGG;AACH,OAAO,EAAE,KAAK,YAAY,EAAU,MAAM,qBAAqB,CAAC;AAGhE,UAAU,KAAK;IACb,yEAAyE;IACzE,OAAO,CAAC,EAAE,CAAC,GAAG,EAAE,YAAY,KAAK,IAAI,CAAC;IACtC,0EAA0E;IAC1E,SAAS,CAAC,EAAE,OAAO,CAAC;CACrB;AAoBD,QAAA,MAAM,WAAW,2CAAwC,CAAC;AAC1D,KAAK,WAAW,GAAG,UAAU,CAAC,OAAO,WAAW,CAAC,CAAC;AAClD,eAAe,WAAW,CAAC"}
@@ -0,0 +1,42 @@
1
+ <script lang="ts">
2
+ /**
3
+ * R4 harness: sets a fake app-state manager on context, then mounts/unmounts
4
+ * `useSTT()` consumers so the test can prove per-hook ownership — unmounting a
5
+ * consumer that did NOT start listening must not stop the shared singleton.
6
+ */
7
+ import type { SmrtAppStateManager } from '../../state/app-state.svelte.js';
8
+ import { setAppStateContext } from '../../state/context.js';
9
+ import type { UseSTTReturn } from '../useSTT.svelte.js';
10
+ import SttConsumer from './stt-consumer.fixture.svelte';
11
+
12
+ interface Props {
13
+ manager: SmrtAppStateManager;
14
+ showA?: boolean;
15
+ showB?: boolean;
16
+ onReadyA?: (stt: UseSTTReturn) => void;
17
+ onReadyB?: (stt: UseSTTReturn) => void;
18
+ autoStartA?: boolean;
19
+ autoStartB?: boolean;
20
+ }
21
+
22
+ const {
23
+ manager,
24
+ showA = true,
25
+ showB = true,
26
+ onReadyA,
27
+ onReadyB,
28
+ autoStartA = false,
29
+ autoStartB = false,
30
+ }: Props = $props();
31
+
32
+ // Context is set once during setup with the initial manager (test-only).
33
+ // svelte-ignore state_referenced_locally
34
+ setAppStateContext(manager);
35
+ </script>
36
+
37
+ {#if showA}
38
+ <SttConsumer onReady={onReadyA} autoStart={autoStartA} />
39
+ {/if}
40
+ {#if showB}
41
+ <SttConsumer onReady={onReadyB} autoStart={autoStartB} />
42
+ {/if}
@@ -0,0 +1,20 @@
1
+ /**
2
+ * R4 harness: sets a fake app-state manager on context, then mounts/unmounts
3
+ * `useSTT()` consumers so the test can prove per-hook ownership — unmounting a
4
+ * consumer that did NOT start listening must not stop the shared singleton.
5
+ */
6
+ import type { SmrtAppStateManager } from '../../state/app-state.svelte.js';
7
+ import type { UseSTTReturn } from '../useSTT.svelte.js';
8
+ interface Props {
9
+ manager: SmrtAppStateManager;
10
+ showA?: boolean;
11
+ showB?: boolean;
12
+ onReadyA?: (stt: UseSTTReturn) => void;
13
+ onReadyB?: (stt: UseSTTReturn) => void;
14
+ autoStartA?: boolean;
15
+ autoStartB?: boolean;
16
+ }
17
+ declare const SttOwnershipHarness: import("svelte").Component<Props, {}, "">;
18
+ type SttOwnershipHarness = ReturnType<typeof SttOwnershipHarness>;
19
+ export default SttOwnershipHarness;
20
+ //# sourceMappingURL=stt-ownership-harness.svelte.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"stt-ownership-harness.svelte.d.ts","sourceRoot":"","sources":["../../../src/hooks/__tests__/stt-ownership-harness.svelte.ts"],"names":[],"mappings":"AAGA;;;;GAIG;AACH,OAAO,KAAK,EAAE,mBAAmB,EAAE,MAAM,iCAAiC,CAAC;AAE3E,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,qBAAqB,CAAC;AAIxD,UAAU,KAAK;IACb,OAAO,EAAE,mBAAmB,CAAC;IAC7B,KAAK,CAAC,EAAE,OAAO,CAAC;IAChB,KAAK,CAAC,EAAE,OAAO,CAAC;IAChB,QAAQ,CAAC,EAAE,CAAC,GAAG,EAAE,YAAY,KAAK,IAAI,CAAC;IACvC,QAAQ,CAAC,EAAE,CAAC,GAAG,EAAE,YAAY,KAAK,IAAI,CAAC;IACvC,UAAU,CAAC,EAAE,OAAO,CAAC;IACrB,UAAU,CAAC,EAAE,OAAO,CAAC;CACtB;AAiCD,QAAA,MAAM,mBAAmB,2CAAwC,CAAC;AAClE,KAAK,mBAAmB,GAAG,UAAU,CAAC,OAAO,mBAAmB,CAAC,CAAC;AAClE,eAAe,mBAAmB,CAAC"}
@@ -0,0 +1,102 @@
1
+ /**
2
+ * R4 regression (T2 #1403): useSTT()/useTTS() attach onDestroy to the
3
+ * Provider-level *singleton* manager. Before the fix, unmounting ANY hook
4
+ * consumer that found the manager listening would call stopListening() —
5
+ * aborting a recording another consumer had started. The fix tracks per-hook
6
+ * ownership: a consumer only stops the singleton if it started the session.
7
+ */
8
+ import { render } from '@testing-library/svelte';
9
+ import { tick } from 'svelte';
10
+ import { describe, expect, it, vi } from 'vitest';
11
+ import Harness from './stt-ownership-harness.svelte';
12
+ /**
13
+ * Minimal fake manager exposing only what useSTT touches. `isListening`
14
+ * flips true once any consumer calls startListening().
15
+ */
16
+ function makeFakeManager() {
17
+ const listening = { value: false };
18
+ const startListening = vi.fn(async () => {
19
+ listening.value = true;
20
+ });
21
+ const stopListening = vi.fn(async () => {
22
+ listening.value = false;
23
+ });
24
+ const manager = {
25
+ startListening,
26
+ stopListening,
27
+ initializeSTT: vi.fn(async () => ({})),
28
+ get state() {
29
+ return {
30
+ ai: {
31
+ stt: {
32
+ get isListening() {
33
+ return listening.value;
34
+ },
35
+ },
36
+ },
37
+ };
38
+ },
39
+ };
40
+ return { manager, startListening, stopListening, listening };
41
+ }
42
+ describe('useSTT per-hook ownership (R4)', () => {
43
+ it("unmounting a non-owning consumer does not stop another consumer's recording", async () => {
44
+ const { manager, startListening, stopListening } = makeFakeManager();
45
+ let sttA;
46
+ const { rerender, unmount } = render(Harness, {
47
+ props: {
48
+ manager,
49
+ showA: true,
50
+ showB: true,
51
+ onReadyA: (s) => {
52
+ sttA = s;
53
+ },
54
+ },
55
+ });
56
+ // Consumer A starts the shared listening session.
57
+ await sttA?.start();
58
+ await tick();
59
+ expect(startListening).toHaveBeenCalledTimes(1);
60
+ // Unmount consumer B (which never started). It must NOT stop A's session.
61
+ await rerender({ manager, showA: true, showB: false });
62
+ await tick();
63
+ expect(stopListening).not.toHaveBeenCalled();
64
+ // Now unmount everything: the owning consumer A stops its own session.
65
+ unmount();
66
+ await tick();
67
+ expect(stopListening).toHaveBeenCalledTimes(1);
68
+ });
69
+ it('an owning consumer stops the session it started on unmount', async () => {
70
+ const { manager, startListening, stopListening } = makeFakeManager();
71
+ let sttA;
72
+ const { unmount } = render(Harness, {
73
+ props: {
74
+ manager,
75
+ showA: true,
76
+ showB: false,
77
+ onReadyA: (s) => {
78
+ sttA = s;
79
+ },
80
+ },
81
+ });
82
+ await sttA?.start();
83
+ await tick();
84
+ expect(startListening).toHaveBeenCalledTimes(1);
85
+ unmount();
86
+ await tick();
87
+ expect(stopListening).toHaveBeenCalledTimes(1);
88
+ });
89
+ it('a consumer that never started does not stop a session on unmount', async () => {
90
+ const { manager, stopListening, listening } = makeFakeManager();
91
+ // Simulate the singleton already listening (started elsewhere).
92
+ listening.value = true;
93
+ const { unmount } = render(Harness, {
94
+ props: { manager, showA: true, showB: false },
95
+ });
96
+ await tick();
97
+ // Consumer A never called start(); unmounting must leave the session alone.
98
+ unmount();
99
+ await tick();
100
+ expect(stopListening).not.toHaveBeenCalled();
101
+ });
102
+ });
@@ -1 +1 @@
1
- {"version":3,"file":"useSTT.svelte.d.ts","sourceRoot":"","sources":["../../src/hooks/useSTT.svelte.ts"],"names":[],"mappings":"AAAA;;GAEG;AAGH,OAAO,KAAK,EAAE,aAAa,EAAE,UAAU,EAAE,MAAM,wBAAwB,CAAC;AAGxE;;GAEG;AACH,MAAM,WAAW,aAAa;IAC5B,+BAA+B;IAC/B,QAAQ,CAAC,EAAE,OAAO,CAAC;IACnB,0BAA0B;IAC1B,cAAc,CAAC,EAAE,UAAU,CAAC;CAC7B;AAED;;GAEG;AACH,MAAM,WAAW,YAAY;IAC3B,sBAAsB;IACtB,KAAK,EAAE,CAAC,OAAO,CAAC,EAAE,UAAU,KAAK,OAAO,CAAC,IAAI,CAAC,CAAC;IAC/C,qBAAqB;IACrB,IAAI,EAAE,MAAM,OAAO,CAAC,IAAI,CAAC,CAAC;IAC1B,gEAAgE;IAChE,UAAU,EAAE,CAAC,OAAO,CAAC,EAAE,aAAa,KAAK,OAAO,CAAC,IAAI,CAAC,CAAC;IACvD,+BAA+B;IAC/B,QAAQ,CAAC,WAAW,EAAE,OAAO,CAAC;IAC9B,QAAQ,CAAC,OAAO,EAAE,OAAO,CAAC;IAC1B,QAAQ,CAAC,cAAc,EAAE,OAAO,CAAC;IACjC,4DAA4D;IAC5D,QAAQ,CAAC,UAAU,EAAE,MAAM,CAAC;IAC5B,4DAA4D;IAC5D,QAAQ,CAAC,aAAa,EAAE,MAAM,CAAC;IAC/B,sEAAsE;IACtE,QAAQ,CAAC,aAAa,EAAE,MAAM,CAAC;IAC/B,QAAQ,CAAC,KAAK,EAAE,KAAK,GAAG,IAAI,CAAC;IAC7B,QAAQ,CAAC,gBAAgB,EAAE,MAAM,CAAC;IAClC,4EAA4E;IAC5E,QAAQ,CAAC,WAAW,EAAE,MAAM,GAAG,IAAI,CAAC;CACrC;AAED;;;;;;;;;;;;;;;;;;;;;;;;;;;GA2BG;AACH,wBAAgB,MAAM,CAAC,OAAO,GAAE,aAAkB,GAAG,YAAY,CAqEhE"}
1
+ {"version":3,"file":"useSTT.svelte.d.ts","sourceRoot":"","sources":["../../src/hooks/useSTT.svelte.ts"],"names":[],"mappings":"AAAA;;GAEG;AAGH,OAAO,KAAK,EAAE,aAAa,EAAE,UAAU,EAAE,MAAM,wBAAwB,CAAC;AAGxE;;GAEG;AACH,MAAM,WAAW,aAAa;IAC5B,+BAA+B;IAC/B,QAAQ,CAAC,EAAE,OAAO,CAAC;IACnB,0BAA0B;IAC1B,cAAc,CAAC,EAAE,UAAU,CAAC;CAC7B;AAED;;GAEG;AACH,MAAM,WAAW,YAAY;IAC3B,sBAAsB;IACtB,KAAK,EAAE,CAAC,OAAO,CAAC,EAAE,UAAU,KAAK,OAAO,CAAC,IAAI,CAAC,CAAC;IAC/C,qBAAqB;IACrB,IAAI,EAAE,MAAM,OAAO,CAAC,IAAI,CAAC,CAAC;IAC1B,gEAAgE;IAChE,UAAU,EAAE,CAAC,OAAO,CAAC,EAAE,aAAa,KAAK,OAAO,CAAC,IAAI,CAAC,CAAC;IACvD,+BAA+B;IAC/B,QAAQ,CAAC,WAAW,EAAE,OAAO,CAAC;IAC9B,QAAQ,CAAC,OAAO,EAAE,OAAO,CAAC;IAC1B,QAAQ,CAAC,cAAc,EAAE,OAAO,CAAC;IACjC,4DAA4D;IAC5D,QAAQ,CAAC,UAAU,EAAE,MAAM,CAAC;IAC5B,4DAA4D;IAC5D,QAAQ,CAAC,aAAa,EAAE,MAAM,CAAC;IAC/B,sEAAsE;IACtE,QAAQ,CAAC,aAAa,EAAE,MAAM,CAAC;IAC/B,QAAQ,CAAC,KAAK,EAAE,KAAK,GAAG,IAAI,CAAC;IAC7B,QAAQ,CAAC,gBAAgB,EAAE,MAAM,CAAC;IAClC,4EAA4E;IAC5E,QAAQ,CAAC,WAAW,EAAE,MAAM,GAAG,IAAI,CAAC;CACrC;AAED;;;;;;;;;;;;;;;;;;;;;;;;;;;GA2BG;AACH,wBAAgB,MAAM,CAAC,OAAO,GAAE,aAAkB,GAAG,YAAY,CAmFhE"}