@happyvertical/smrt-svelte 0.30.0 → 0.31.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.
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
package/AGENTS.md CHANGED
@@ -198,7 +198,10 @@ await expectNoA11yViolations(container); // axe; color-contrast off (jsdom has n
198
198
  ## Dependencies
199
199
 
200
200
  - `@happyvertical/smrt-types` (shared types) — includes the identity data contracts (`User`, `Role`, `Membership`, `Tenant`) the role/membership components type against, so no dependency on `smrt-users` / `smrt-profiles` is needed
201
- - Peer: `svelte` >=5.18.2, `@happyvertical/smrt-agents`, `@happyvertical/smrt-jobs` (all optional)
201
+ - `@happyvertical/smrt-ui` (UI runtime: primitives, theme system, i18n client, module registry) and `@happyvertical/smrt-agents` (admin shells type against its `/ui` contracts) are hard `dependencies`.
202
+ - `@happyvertical/smrt-languages` is a hard `dependency` (not an optional peer): the Node-only `/i18n/server` subpath imports its resolver. The browser bundle still excludes it — the client `/i18n` layer never imports the languages root, so it tree-shakes out.
203
+ - `@happyvertical/logger` (SDK) is a `dependency` — the browser-safe console logger used for voice/AI error reporting in the form components.
204
+ - Peer (all optional): `svelte` >=5.18.2, plus the browser-AI engines (`@huggingface/transformers`, `@mlc-ai/web-llm`, `@remotion/whisper-web`, `@xenova/transformers`) and `chrono-node`.
202
205
 
203
206
  ## Workspace shell primitives
204
207
 
@@ -7,6 +7,7 @@ import {
7
7
  import type { Snippet } from 'svelte';
8
8
  import { onDestroy, untrack } from 'svelte';
9
9
  import AILoadingOverlay from './browser-ai/svelte/components/AILoadingOverlay.svelte';
10
+ import { logger } from './internal/logger.js';
10
11
  import type {
11
12
  AIConfig,
12
13
  AILoadingState,
@@ -188,9 +189,18 @@ $effect(() => {
188
189
  }
189
190
  });
190
191
 
191
- // Cleanup on destroy
192
+ // Cleanup on destroy. Dispose the whole manager (not just the socket) so it
193
+ // unsubscribes its listeners from the module-surviving warm AI adapters; left
194
+ // attached, each destroyed Provider would keep pinning its `_state` proxy via
195
+ // those adapters' listener `Set`s and leak one set per navigation (R1). The
196
+ // warm cache itself survives — dispose() leaves cached adapters intact.
192
197
  onDestroy(() => {
193
- appState.disconnectSocket();
198
+ // dispose() is async; isolate + log its rejection so a failing adapter
199
+ // teardown surfaces as a logged error instead of an unhandled promise
200
+ // rejection during Provider teardown.
201
+ void appState.dispose().catch((error) => {
202
+ logger.error('AppState dispose failed during Provider teardown', { error });
203
+ });
194
204
  });
195
205
  </script>
196
206
 
@@ -1 +1 @@
1
- {"version":3,"file":"Provider.svelte.d.ts","sourceRoot":"","sources":["../src/Provider.svelte.ts"],"names":[],"mappings":"AAGA,OAAO,EAEL,KAAK,YAAY,EAElB,MAAM,6BAA6B,CAAC;AACrC,OAAO,KAAK,EAAE,OAAO,EAAE,MAAM,QAAQ,CAAC;AAGtC,OAAO,KAAK,EACV,QAAQ,EACR,cAAc,EACd,OAAO,EACP,YAAY,EACZ,IAAI,EACL,MAAM,sBAAsB,CAAC;AAK9B,UAAU,KAAK;IACb;;;OAGG;IACH,IAAI,CAAC,EAAE,OAAO,CAAC;IACf;;;OAGG;IACH,cAAc,CAAC,EAAE,OAAO,CAAC;IACzB;;;OAGG;IACH,IAAI,CAAC,EAAE,IAAI,GAAG,IAAI,CAAC;IACnB;;OAEG;IACH,WAAW,CAAC,EAAE,MAAM,EAAE,CAAC;IACvB;;;OAGG;IACH,MAAM,CAAC,EAAE,YAAY,CAAC;IACtB;;;;;;;;;;;;;;;OAeG;IACH,EAAE,CAAC,EAAE,QAAQ,CAAC;IACd;;OAEG;IACH,OAAO,CAAC,EAAE,MAAM,IAAI,CAAC;IACrB;;OAEG;IACH,YAAY,CAAC,EAAE,CAAC,IAAI,EAAE,OAAO,KAAK,IAAI,CAAC;IACvC;;OAEG;IACH,iBAAiB,CAAC,EAAE,CAAC,KAAK,EAAE,cAAc,KAAK,IAAI,CAAC;IACpD;;;;;OAKG;IACH,IAAI,CAAC,EAAE,YAAY,CAAC;IACpB;;OAEG;IACH,QAAQ,EAAE,OAAO,CAAC;CACnB;AAiID,QAAA,MAAM,QAAQ,2CAAwC,CAAC;AACvD,KAAK,QAAQ,GAAG,UAAU,CAAC,OAAO,QAAQ,CAAC,CAAC;AAC5C,eAAe,QAAQ,CAAC"}
1
+ {"version":3,"file":"Provider.svelte.d.ts","sourceRoot":"","sources":["../src/Provider.svelte.ts"],"names":[],"mappings":"AAGA,OAAO,EAEL,KAAK,YAAY,EAElB,MAAM,6BAA6B,CAAC;AACrC,OAAO,KAAK,EAAE,OAAO,EAAE,MAAM,QAAQ,CAAC;AAItC,OAAO,KAAK,EACV,QAAQ,EACR,cAAc,EACd,OAAO,EACP,YAAY,EACZ,IAAI,EACL,MAAM,sBAAsB,CAAC;AAK9B,UAAU,KAAK;IACb;;;OAGG;IACH,IAAI,CAAC,EAAE,OAAO,CAAC;IACf;;;OAGG;IACH,cAAc,CAAC,EAAE,OAAO,CAAC;IACzB;;;OAGG;IACH,IAAI,CAAC,EAAE,IAAI,GAAG,IAAI,CAAC;IACnB;;OAEG;IACH,WAAW,CAAC,EAAE,MAAM,EAAE,CAAC;IACvB;;;OAGG;IACH,MAAM,CAAC,EAAE,YAAY,CAAC;IACtB;;;;;;;;;;;;;;;OAeG;IACH,EAAE,CAAC,EAAE,QAAQ,CAAC;IACd;;OAEG;IACH,OAAO,CAAC,EAAE,MAAM,IAAI,CAAC;IACrB;;OAEG;IACH,YAAY,CAAC,EAAE,CAAC,IAAI,EAAE,OAAO,KAAK,IAAI,CAAC;IACvC;;OAEG;IACH,iBAAiB,CAAC,EAAE,CAAC,KAAK,EAAE,cAAc,KAAK,IAAI,CAAC;IACpD;;;;;OAKG;IACH,IAAI,CAAC,EAAE,YAAY,CAAC;IACpB;;OAEG;IACH,QAAQ,EAAE,OAAO,CAAC;CACnB;AA2ID,QAAA,MAAM,QAAQ,2CAAwC,CAAC;AACvD,KAAK,QAAQ,GAAG,UAAU,CAAC,OAAO,QAAQ,CAAC,CAAC;AAC5C,eAAe,QAAQ,CAAC"}
@@ -49,7 +49,11 @@ const {
49
49
  dbConfigs = {},
50
50
  }: Props = $props();
51
51
 
52
+ // Stable per-instance id base so multiple shells don't collide on tab/panel ids.
53
+ const idBase = $props.id();
54
+
52
55
  let activeAgentId = $state<string | null>(null);
56
+ let tablistEl: HTMLElement | null = $state(null);
53
57
 
54
58
  // Initialize to first agent
55
59
  $effect(() => {
@@ -64,6 +68,50 @@ function handleAgentClick(agentId: string) {
64
68
  activeAgentId = agentId;
65
69
  }
66
70
 
71
+ /**
72
+ * Roving-tabindex keyboard navigation for the agent switcher (C8). The list is
73
+ * a vertical tablist, so Up/Down move between agents (plus Home/End); mirrors
74
+ * the horizontal pattern in AgentAdminTabs.
75
+ */
76
+ function handleAgentKeydown(event: KeyboardEvent, currentAgentId: string) {
77
+ const currentIndex = agents.findIndex((a) => a.id === currentAgentId);
78
+ if (currentIndex === -1) return;
79
+
80
+ let nextIndex: number | null = null;
81
+ switch (event.key) {
82
+ case 'ArrowDown':
83
+ case 'ArrowRight':
84
+ event.preventDefault();
85
+ nextIndex = (currentIndex + 1) % agents.length;
86
+ break;
87
+ case 'ArrowUp':
88
+ case 'ArrowLeft':
89
+ event.preventDefault();
90
+ nextIndex = (currentIndex - 1 + agents.length) % agents.length;
91
+ break;
92
+ case 'Home':
93
+ event.preventDefault();
94
+ nextIndex = 0;
95
+ break;
96
+ case 'End':
97
+ event.preventDefault();
98
+ nextIndex = agents.length - 1;
99
+ break;
100
+ }
101
+
102
+ if (nextIndex !== null && nextIndex !== currentIndex) {
103
+ const nextAgentId = agents[nextIndex].id;
104
+ activeAgentId = nextAgentId;
105
+ // Focus the newly-selected tab after the DOM updates.
106
+ requestAnimationFrame(() => {
107
+ const tabButton = tablistEl?.querySelector(
108
+ `[data-agent-id="${CSS.escape(nextAgentId)}"]`,
109
+ ) as HTMLElement | null;
110
+ tabButton?.focus();
111
+ });
112
+ }
113
+ }
114
+
67
115
  async function handleSave(slotId: string, config: unknown) {
68
116
  if (activeAgentId && onSave) {
69
117
  await onSave(activeAgentId, slotId, config);
@@ -74,13 +122,26 @@ async function handleSave(slotId: string, config: unknown) {
74
122
  <div class="agent-settings-shell" class:single-agent={agents.length === 1}>
75
123
  {#if agents.length > 1}
76
124
  <aside class="agents-sidebar">
77
- <h3 class="sidebar-title">Agents</h3>
78
- <nav class="agents-list">
125
+ <h3 class="sidebar-title" id="{idBase}-agents-title">Agents</h3>
126
+ <div
127
+ class="agents-list"
128
+ role="tablist"
129
+ aria-orientation="vertical"
130
+ aria-labelledby="{idBase}-agents-title"
131
+ bind:this={tablistEl}
132
+ >
79
133
  {#each agents as agent}
80
134
  <button
81
135
  class="agent-button"
82
136
  class:active={activeAgentId === agent.id}
137
+ role="tab"
138
+ aria-selected={activeAgentId === agent.id}
139
+ aria-controls="{idBase}-panel"
140
+ id="{idBase}-tab-{agent.id}"
141
+ data-agent-id={agent.id}
142
+ tabindex={activeAgentId === agent.id ? 0 : -1}
83
143
  onclick={() => handleAgentClick(agent.id)}
144
+ onkeydown={(e) => handleAgentKeydown(e, agent.id)}
84
145
  >
85
146
  <span class="agent-class">{agent.agentClass}</span>
86
147
  {#if agent.name}
@@ -88,11 +149,18 @@ async function handleSave(slotId: string, config: unknown) {
88
149
  {/if}
89
150
  </button>
90
151
  {/each}
91
- </nav>
152
+ </div>
92
153
  </aside>
93
154
  {/if}
94
155
 
95
- <main class="agent-content">
156
+ <main
157
+ class="agent-content"
158
+ id="{idBase}-panel"
159
+ role={agents.length > 1 ? 'tabpanel' : undefined}
160
+ aria-labelledby={agents.length > 1 && activeAgentId
161
+ ? `${idBase}-tab-${activeAgentId}`
162
+ : undefined}
163
+ >
96
164
  {#if activeAgent}
97
165
  <header class="agent-header">
98
166
  <h2 class="agent-title">{activeAgent.agentClass}</h2>
@@ -1 +1 @@
1
- {"version":3,"file":"AgentSettingsShell.svelte.d.ts","sourceRoot":"","sources":["../../../src/components/admin/AgentSettingsShell.svelte.ts"],"names":[],"mappings":"AAGA,OAAO,KAAK,EACV,wBAAwB,EACxB,YAAY,EACb,MAAM,+BAA+B,CAAC;AAMvC;;;GAGG;AACH,UAAU,SAAS;IACjB,EAAE,EAAE,MAAM,CAAC;IACX,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,iFAAiF;IACjF,UAAU,EAAE,MAAM,CAAC;IACnB,8BAA8B;IAC9B,KAAK,EAAE,YAAY,CAAC;CACrB;AAED,MAAM,WAAW,KAAK;IACpB,8CAA8C;IAC9C,QAAQ,EAAE,wBAAwB,CAAC;IACnC,iEAAiE;IACjE,MAAM,EAAE,SAAS,EAAE,CAAC;IACpB,uEAAuE;IACvE,OAAO,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC,CAAC;IACjD,sCAAsC;IACtC,MAAM,CAAC,EAAE,CAAC,OAAO,EAAE,MAAM,EAAE,MAAM,EAAE,MAAM,EAAE,MAAM,EAAE,OAAO,KAAK,OAAO,CAAC,IAAI,CAAC,CAAC;IAC7E,uCAAuC;IACvC,QAAQ,CAAC,EAAE,OAAO,CAAC;IACnB,gDAAgD;IAChD,WAAW,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC,CAAC;IACtD,oDAAoD;IACpD,SAAS,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC,CAAC;CACrD;AAsFD,QAAA,MAAM,kBAAkB,2CAAwC,CAAC;AACjE,KAAK,kBAAkB,GAAG,UAAU,CAAC,OAAO,kBAAkB,CAAC,CAAC;AAChE,eAAe,kBAAkB,CAAC"}
1
+ {"version":3,"file":"AgentSettingsShell.svelte.d.ts","sourceRoot":"","sources":["../../../src/components/admin/AgentSettingsShell.svelte.ts"],"names":[],"mappings":"AAGA,OAAO,KAAK,EACV,wBAAwB,EACxB,YAAY,EACb,MAAM,+BAA+B,CAAC;AAMvC;;;GAGG;AACH,UAAU,SAAS;IACjB,EAAE,EAAE,MAAM,CAAC;IACX,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,iFAAiF;IACjF,UAAU,EAAE,MAAM,CAAC;IACnB,8BAA8B;IAC9B,KAAK,EAAE,YAAY,CAAC;CACrB;AAED,MAAM,WAAW,KAAK;IACpB,8CAA8C;IAC9C,QAAQ,EAAE,wBAAwB,CAAC;IACnC,iEAAiE;IACjE,MAAM,EAAE,SAAS,EAAE,CAAC;IACpB,uEAAuE;IACvE,OAAO,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC,CAAC;IACjD,sCAAsC;IACtC,MAAM,CAAC,EAAE,CAAC,OAAO,EAAE,MAAM,EAAE,MAAM,EAAE,MAAM,EAAE,MAAM,EAAE,OAAO,KAAK,OAAO,CAAC,IAAI,CAAC,CAAC;IAC7E,uCAAuC;IACvC,QAAQ,CAAC,EAAE,OAAO,CAAC;IACnB,gDAAgD;IAChD,WAAW,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC,CAAC;IACtD,oDAAoD;IACpD,SAAS,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC,CAAC;CACrD;AAwID,QAAA,MAAM,kBAAkB,2CAAwC,CAAC;AACjE,KAAK,kBAAkB,GAAG,UAAU,CAAC,OAAO,kBAAkB,CAAC,CAAC;AAChE,eAAe,kBAAkB,CAAC"}
@@ -0,0 +1,93 @@
1
+ /**
2
+ * Regression: the agent switcher is a proper ARIA tablist (C8).
3
+ *
4
+ * It was a <nav> of <button>s acting as single-select tabs, with no
5
+ * role/aria-selected/roving-tabindex/arrow-key nav — unlike sibling
6
+ * AgentAdminTabs which does the full tablist. The fix adds role="tablist" +
7
+ * role="tab" + aria-selected + roving tabindex + Up/Down/Home/End navigation,
8
+ * and marks the content panel role="tabpanel".
9
+ */
10
+ import { createUIRegistry } from '@happyvertical/smrt-agents/ui';
11
+ import { render, screen, waitFor, within } from '@testing-library/svelte';
12
+ import userEvent from '@testing-library/user-event';
13
+ import { describe, expect, it } from 'vitest';
14
+ import AgentSettingsShell from '../AgentSettingsShell.svelte';
15
+ /**
16
+ * Roving focus moves via requestAnimationFrame, so wait for the expected tab to
17
+ * actually receive focus before dispatching the next key (otherwise the next
18
+ * keydown fires on the previously-focused tab).
19
+ */
20
+ async function expectFocused(el) {
21
+ await waitFor(() => expect(el).toHaveFocus());
22
+ }
23
+ function agents() {
24
+ return [
25
+ { id: 'a1', agentClass: 'Praeco', name: 'Alpha', slots: {} },
26
+ { id: 'a2', agentClass: 'Caelus', name: 'Beta', slots: {} },
27
+ { id: 'a3', agentClass: 'Nimbus', name: 'Gamma', slots: {} },
28
+ ];
29
+ }
30
+ function renderShell() {
31
+ return render(AgentSettingsShell, {
32
+ props: {
33
+ registry: createUIRegistry(),
34
+ agents: agents(),
35
+ configs: {},
36
+ },
37
+ });
38
+ }
39
+ /**
40
+ * The shell embeds AgentAdminTabs (its own slot tablist), so scope queries to
41
+ * the agent-switcher tablist — identified by its "Agents" accessible name.
42
+ */
43
+ function switcher() {
44
+ return screen.getByRole('tablist', { name: 'Agents' });
45
+ }
46
+ function switcherTabs() {
47
+ return within(switcher()).getAllByRole('tab');
48
+ }
49
+ describe('AgentSettingsShell — agent switcher tablist (C8)', () => {
50
+ it('renders a tablist of tabs with the first selected', () => {
51
+ renderShell();
52
+ expect(switcher()).toBeInTheDocument();
53
+ const tabs = switcherTabs();
54
+ expect(tabs).toHaveLength(3);
55
+ expect(tabs[0]).toHaveAttribute('aria-selected', 'true');
56
+ expect(tabs[1]).toHaveAttribute('aria-selected', 'false');
57
+ });
58
+ it('uses a roving tabindex (only the selected tab is tabbable)', () => {
59
+ renderShell();
60
+ const tabs = switcherTabs();
61
+ expect(tabs[0]).toHaveAttribute('tabindex', '0');
62
+ expect(tabs[1]).toHaveAttribute('tabindex', '-1');
63
+ expect(tabs[2]).toHaveAttribute('tabindex', '-1');
64
+ });
65
+ it('moves selection with ArrowDown / ArrowUp', async () => {
66
+ renderShell();
67
+ switcherTabs()[0].focus();
68
+ await userEvent.keyboard('{ArrowDown}');
69
+ expect(switcherTabs()[1]).toHaveAttribute('aria-selected', 'true');
70
+ await expectFocused(switcherTabs()[1]);
71
+ await userEvent.keyboard('{ArrowUp}');
72
+ expect(switcherTabs()[0]).toHaveAttribute('aria-selected', 'true');
73
+ await expectFocused(switcherTabs()[0]);
74
+ });
75
+ it('jumps to last/first with End / Home', async () => {
76
+ renderShell();
77
+ switcherTabs()[0].focus();
78
+ await userEvent.keyboard('{End}');
79
+ expect(switcherTabs()[2]).toHaveAttribute('aria-selected', 'true');
80
+ await expectFocused(switcherTabs()[2]);
81
+ await userEvent.keyboard('{Home}');
82
+ expect(switcherTabs()[0]).toHaveAttribute('aria-selected', 'true');
83
+ await expectFocused(switcherTabs()[0]);
84
+ });
85
+ it('exposes a tabpanel wired to the selected tab', () => {
86
+ renderShell();
87
+ const panel = screen.getByRole('tabpanel');
88
+ const selectedTab = switcherTabs().find((t) => t.getAttribute('aria-selected') === 'true');
89
+ expect(selectedTab).toBeTruthy();
90
+ expect(panel).toHaveAttribute('aria-labelledby', selectedTab?.id);
91
+ expect(selectedTab).toHaveAttribute('aria-controls', panel.id);
92
+ });
93
+ });
@@ -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,
@@ -219,15 +220,26 @@ onDestroy(() => {
219
220
  async function startRecording() {
220
221
  if (!isSmrt || disabled || isParsing) return;
221
222
 
222
- if (!stt.isReady || stt.adapterType !== 'whisper-wasm') {
223
- await stt.initialize({ type: 'whisper-wasm' });
224
- }
225
-
226
223
  parseError = null;
227
224
  recordingStartTime = Date.now();
228
225
  isRecording = true;
229
226
 
230
- await stt.start({ continuous: true, interimResults: false });
227
+ // Guard STT init / mic acquisition so a rejection can't leave the field
228
+ // wedged in a permanent "Recording..." state with no visible error (C2).
229
+ try {
230
+ if (!stt.isReady || stt.adapterType !== 'whisper-wasm') {
231
+ await stt.initialize({ type: 'whisper-wasm' });
232
+ }
233
+ await stt.start({ continuous: true, interimResults: false });
234
+ } catch (err) {
235
+ isRecording = false;
236
+ parseError =
237
+ err instanceof Error ? err.message : 'Could not start voice input';
238
+ logger.error('DateRangeInput: failed to start voice recording', {
239
+ field: name,
240
+ error: err,
241
+ });
242
+ }
231
243
  }
232
244
 
233
245
  async function stopRecording() {
@@ -307,6 +319,17 @@ function handleTouchEnd() {
307
319
  stopRecording();
308
320
  }
309
321
 
322
+ // Keyboard activation for the mic (WCAG 2.1.1): Enter/Space toggles recording.
323
+ function handleMicKeydown(e: KeyboardEvent) {
324
+ if (e.key !== 'Enter' && e.key !== ' ') return;
325
+ e.preventDefault();
326
+ if (isRecording) {
327
+ stopRecording();
328
+ } else {
329
+ startRecording();
330
+ }
331
+ }
332
+
310
333
  const primaryControlId = $derived(isSmrt ? `${name}_voice` : `${name}_start`);
311
334
  </script>
312
335
 
@@ -342,6 +365,7 @@ const primaryControlId = $derived(isSmrt ? `${name}_voice` : `${name}_start`);
342
365
  class="mic-btn"
343
366
  class:active={isRecording}
344
367
  disabled={disabled || isParsing}
368
+ onkeydown={handleMicKeydown}
345
369
  onmousedown={handleMouseDown}
346
370
  onmouseup={handleMouseUp}
347
371
  onmouseleave={handleMouseLeave}
@@ -1 +1 @@
1
- {"version":3,"file":"DateRangeInput.svelte.d.ts","sourceRoot":"","sources":["../../../src/components/forms/DateRangeInput.svelte.ts"],"names":[],"mappings":"AAYA,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,YAAY,CAAC;AAGjD,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,6BAA6B;IAC7B,gBAAgB,CAAC,EAAE,MAAM,CAAC;IAC1B,2BAA2B;IAC3B,cAAc,CAAC,EAAE,MAAM,CAAC;IACxB,2DAA2D;IAC3D,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,yDAAyD;IACzD,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB,8BAA8B;IAC9B,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB,8BAA8B;IAC9B,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB,qBAAqB;IACrB,QAAQ,CAAC,EAAE,OAAO,CAAC;IACnB,qBAAqB;IACrB,QAAQ,CAAC,EAAE,OAAO,CAAC;IACnB,+BAA+B;IAC/B,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,gCAAgC;IAChC,QAAQ,CAAC,EAAE,CAAC,KAAK,EAAE,cAAc,KAAK,IAAI,CAAC;CAC5C;AA2WD,QAAA,MAAM,cAAc,gEAAwC,CAAC;AAC7D,KAAK,cAAc,GAAG,UAAU,CAAC,OAAO,cAAc,CAAC,CAAC;AACxD,eAAe,cAAc,CAAC"}
1
+ {"version":3,"file":"DateRangeInput.svelte.d.ts","sourceRoot":"","sources":["../../../src/components/forms/DateRangeInput.svelte.ts"],"names":[],"mappings":"AAaA,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,YAAY,CAAC;AAGjD,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,6BAA6B;IAC7B,gBAAgB,CAAC,EAAE,MAAM,CAAC;IAC1B,2BAA2B;IAC3B,cAAc,CAAC,EAAE,MAAM,CAAC;IACxB,2DAA2D;IAC3D,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,yDAAyD;IACzD,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB,8BAA8B;IAC9B,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB,8BAA8B;IAC9B,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB,qBAAqB;IACrB,QAAQ,CAAC,EAAE,OAAO,CAAC;IACnB,qBAAqB;IACrB,QAAQ,CAAC,EAAE,OAAO,CAAC;IACnB,+BAA+B;IAC/B,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,gCAAgC;IAChC,QAAQ,CAAC,EAAE,CAAC,KAAK,EAAE,cAAc,KAAK,IAAI,CAAC;CAC5C;AAkYD,QAAA,MAAM,cAAc,gEAAwC,CAAC;AAC7D,KAAK,cAAc,GAAG,UAAU,CAAC,OAAO,cAAc,CAAC,CAAC;AACxD,eAAe,cAAc,CAAC"}
@@ -5,6 +5,7 @@ import { onDestroy, onMount } from 'svelte';
5
5
  import { useAppState } from '../../hooks/useAppState.svelte.js';
6
6
  import { useSTT } from '../../hooks/useSTT.svelte.js';
7
7
  import { M } from '../../i18n/strings.forms.js';
8
+ import { logger } from '../../internal/logger.js';
8
9
  import {
9
10
  type FieldDefinition,
10
11
  tryGetFormContext,
@@ -177,17 +178,28 @@ async function parseNaturalLanguage(text: string): Promise<string> {
177
178
  async function startHoldRecording() {
178
179
  if (!isSmrt || disabled || isParsing) return;
179
180
 
180
- // Initialize STT with Whisper v2 for speed + accuracy
181
- if (!stt.isReady || stt.adapterType !== 'whisper-wasm') {
182
- await stt.initialize({ type: 'whisper-wasm' });
183
- }
184
-
185
181
  parseError = null;
186
182
  recordingStartTime = Date.now();
187
183
  isHolding = true;
188
184
 
189
- // Use continuous mode to capture all speech while holding
190
- await stt.start({ continuous: true, interimResults: false });
185
+ // Guard STT init / mic acquisition so a rejection can't leave the field
186
+ // wedged in a permanent "Recording..." state with no visible error (C2).
187
+ try {
188
+ // Initialize STT with Whisper v2 for speed + accuracy
189
+ if (!stt.isReady || stt.adapterType !== 'whisper-wasm') {
190
+ await stt.initialize({ type: 'whisper-wasm' });
191
+ }
192
+ // Use continuous mode to capture all speech while holding
193
+ await stt.start({ continuous: true, interimResults: false });
194
+ } catch (err) {
195
+ isHolding = false;
196
+ parseError =
197
+ err instanceof Error ? err.message : 'Could not start voice input';
198
+ logger.error('DateTimeInput: failed to start voice recording', {
199
+ field: name,
200
+ error: err,
201
+ });
202
+ }
191
203
  }
192
204
 
193
205
  async function stopHoldRecording() {
@@ -276,6 +288,20 @@ function handleMicTouchStart(e: TouchEvent) {
276
288
  startHoldRecording();
277
289
  }
278
290
 
291
+ // Keyboard activation for the mic (WCAG 2.1.1): Enter/Space toggles recording.
292
+ // In SMRT mode the text field is read-only, so this is the only keyboard path
293
+ // to enter a value — it must not be removed.
294
+ function handleMicKeydown(e: KeyboardEvent) {
295
+ if (e.key !== 'Enter' && e.key !== ' ') return;
296
+ e.preventDefault();
297
+ e.stopPropagation();
298
+ if (isHolding) {
299
+ stopHoldRecording();
300
+ } else {
301
+ startHoldRecording();
302
+ }
303
+ }
304
+
279
305
  function handleNativeChange(e: Event) {
280
306
  const target = e.target as HTMLInputElement;
281
307
  updateValue(target.value);
@@ -319,6 +345,7 @@ function handleNativeChange(e: Event) {
319
345
  class:active={isHolding}
320
346
  disabled={disabled || isParsing}
321
347
  onclick={handleMicClick}
348
+ onkeydown={handleMicKeydown}
322
349
  onmousedown={handleMicMouseDown}
323
350
  onmouseup={handleMouseUp}
324
351
  onmouseleave={handleMouseLeave}
@@ -1 +1 @@
1
- {"version":3,"file":"DateTimeInput.svelte.d.ts","sourceRoot":"","sources":["../../../src/components/forms/DateTimeInput.svelte.ts"],"names":[],"mappings":"AAeA,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,iCAAiC;IACjC,WAAW,CAAC,EAAE,OAAO,CAAC;IACtB,uBAAuB;IACvB,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB,2CAA2C;IAC3C,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;AAmTD,QAAA,MAAM,aAAa,gDAAwC,CAAC;AAC5D,KAAK,aAAa,GAAG,UAAU,CAAC,OAAO,aAAa,CAAC,CAAC;AACtD,eAAe,aAAa,CAAC"}
1
+ {"version":3,"file":"DateTimeInput.svelte.d.ts","sourceRoot":"","sources":["../../../src/components/forms/DateTimeInput.svelte.ts"],"names":[],"mappings":"AAgBA,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,iCAAiC;IACjC,WAAW,CAAC,EAAE,OAAO,CAAC;IACtB,uBAAuB;IACvB,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB,2CAA2C;IAC3C,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;AA6UD,QAAA,MAAM,aAAa,gDAAwC,CAAC;AAC5D,KAAK,aAAa,GAAG,UAAU,CAAC,OAAO,aAAa,CAAC,CAAC;AACtD,eAAe,aAAa,CAAC"}
@@ -204,7 +204,9 @@ function formatFileSize(bytes: number): string {
204
204
  </div>
205
205
 
206
206
  {#if error}
207
- <p class="file-upload__error">{error}</p>
207
+ <!-- Live region so async validation/reject errors are announced (C7),
208
+ matching the Form/TextInput announcing pattern. -->
209
+ <p class="file-upload__error" role="alert" aria-live="assertive">{error}</p>
208
210
  {/if}
209
211
 
210
212
  {#if files.length > 0}
@@ -1 +1 @@
1
- {"version":3,"file":"FileUpload.svelte.d.ts","sourceRoot":"","sources":["../../../src/components/forms/FileUpload.svelte.ts"],"names":[],"mappings":"AAkBA,MAAM,WAAW,KAAK;IACpB,4EAA4E;IAC5E,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,2BAA2B;IAC3B,QAAQ,CAAC,EAAE,OAAO,CAAC;IACnB,gCAAgC;IAChC,KAAK,CAAC,EAAE,IAAI,EAAE,CAAC;IACf,iCAAiC;IACjC,QAAQ,CAAC,EAAE,CAAC,KAAK,EAAE,IAAI,EAAE,KAAK,IAAI,CAAC;IACnC,iCAAiC;IACjC,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB,qBAAqB;IACrB,QAAQ,CAAC,EAAE,OAAO,CAAC;IACnB,mCAAmC;IACnC,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,sCAAsC;IACtC,IAAI,CAAC,EAAE,MAAM,CAAC;CACf;AAyLD,QAAA,MAAM,UAAU,gDAAwC,CAAC;AACzD,KAAK,UAAU,GAAG,UAAU,CAAC,OAAO,UAAU,CAAC,CAAC;AAChD,eAAe,UAAU,CAAC"}
1
+ {"version":3,"file":"FileUpload.svelte.d.ts","sourceRoot":"","sources":["../../../src/components/forms/FileUpload.svelte.ts"],"names":[],"mappings":"AAkBA,MAAM,WAAW,KAAK;IACpB,4EAA4E;IAC5E,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,2BAA2B;IAC3B,QAAQ,CAAC,EAAE,OAAO,CAAC;IACnB,gCAAgC;IAChC,KAAK,CAAC,EAAE,IAAI,EAAE,CAAC;IACf,iCAAiC;IACjC,QAAQ,CAAC,EAAE,CAAC,KAAK,EAAE,IAAI,EAAE,KAAK,IAAI,CAAC;IACnC,iCAAiC;IACjC,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB,qBAAqB;IACrB,QAAQ,CAAC,EAAE,OAAO,CAAC;IACnB,mCAAmC;IACnC,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,sCAAsC;IACtC,IAAI,CAAC,EAAE,MAAM,CAAC;CACf;AA0LD,QAAA,MAAM,UAAU,gDAAwC,CAAC;AACzD,KAAK,UAAU,GAAG,UAAU,CAAC,OAAO,UAAU,CAAC,CAAC;AAChD,eAAe,UAAU,CAAC"}
@@ -5,6 +5,7 @@ import { onDestroy } from 'svelte';
5
5
  import { useAppState } from '../../hooks/useAppState.svelte.js';
6
6
  import { useSTT } from '../../hooks/useSTT.svelte.js';
7
7
  import { M } from '../../i18n/strings.forms.js';
8
+ import { logger } from '../../internal/logger.js';
8
9
  import {
9
10
  type FieldDefinition,
10
11
  type SMRTFormContext,
@@ -253,7 +254,6 @@ async function startAudioLevelMonitoring(stream: MediaStream) {
253
254
 
254
255
  const dataArray = new Uint8Array(analyser.frequencyBinCount);
255
256
  const SPEECH_THRESHOLD = 20; // Lowered from 30 for better sensitivity
256
- let checksWithSpeech = 0;
257
257
 
258
258
  audioLevelInterval = setInterval(() => {
259
259
  if (!analyser || !isFormListening) return;
@@ -261,17 +261,22 @@ async function startAudioLevelMonitoring(stream: MediaStream) {
261
261
  analyser.getByteFrequencyData(dataArray);
262
262
  const average = dataArray.reduce((a, b) => a + b, 0) / dataArray.length;
263
263
 
264
- // Log periodically to debug
265
- checksWithSpeech++;
266
- if (checksWithSpeech % 10 === 0) {
267
- }
268
-
269
264
  if (average > SPEECH_THRESHOLD) {
270
265
  // Speech detected - reset silence timer
271
266
  resetSilenceTimer();
272
267
  }
273
268
  }, 200); // Check every 200ms
274
- } catch (err) {}
269
+ } catch (err) {
270
+ // Audio-level monitoring is a silence-detection enhancement, not the
271
+ // recording path itself — surface the failure but don't abort listening.
272
+ // A getUserMedia/AudioContext denial here usually means mic-permission
273
+ // issues the user needs to see (C3).
274
+ extractError =
275
+ err instanceof Error
276
+ ? `Microphone monitoring unavailable: ${err.message}`
277
+ : 'Microphone monitoring unavailable';
278
+ logger.warn('Form: audio-level monitoring failed to start', { error: err });
279
+ }
275
280
  }
276
281
 
277
282
  // Stop audio level monitoring
@@ -369,37 +374,68 @@ async function startFormListening() {
369
374
  // Set starting flag to prevent premature stop detection
370
375
  isStarting = true;
371
376
 
372
- // Initialize STT with selected adapter
373
- if (!stt.isReady || stt.adapterType !== sttAdapter) {
374
- await stt.initialize({ type: sttAdapter });
375
- }
376
-
377
- extractError = null;
378
- spokenText = '';
379
- // Set to current stale result so the effect skips it, but new results will be processed
380
- lastProcessedResult = stt.lastResult || '';
381
- isFormListening = true;
382
- isStopping = false;
383
- lastSpeechTime = Date.now();
377
+ // STT init / start can reject (model fetch failure, mic-permission denial).
378
+ // Without this guard `isStarting`/`isFormListening` would stay set, the
379
+ // auto-stop $effect (gated on `!isStarting`) would stay suppressed, and the
380
+ // form would wedge in a permanent "listening" state with no surfaced error
381
+ // (C2). On failure: tear down, reset flags, and show the error.
382
+ try {
383
+ // Initialize STT with selected adapter
384
+ if (!stt.isReady || stt.adapterType !== sttAdapter) {
385
+ await stt.initialize({ type: sttAdapter });
386
+ }
384
387
 
385
- // Start silence timer
386
- resetSilenceTimer();
388
+ extractError = null;
389
+ spokenText = '';
390
+ // Set to current stale result so the effect skips it, but new results will be processed
391
+ lastProcessedResult = stt.lastResult || '';
392
+ isFormListening = true;
393
+ isStopping = false;
394
+ lastSpeechTime = Date.now();
395
+
396
+ // Start silence timer
397
+ resetSilenceTimer();
398
+
399
+ // Start audio level monitoring for Whisper (no interim results)
400
+ // Browser STT has interim results so doesn't need this
401
+ if (sttAdapter === 'whisper-wasm') {
402
+ try {
403
+ const levelStream = await navigator.mediaDevices.getUserMedia({
404
+ audio: true,
405
+ });
406
+ startAudioLevelMonitoring(levelStream);
407
+ } catch (err) {
408
+ // Mic-permission denial / no device — surface it; the user must see
409
+ // why dictation isn't working (C3).
410
+ extractError =
411
+ err instanceof Error
412
+ ? `Microphone access failed: ${err.message}`
413
+ : 'Microphone access failed';
414
+ logger.warn('Form: getUserMedia failed for audio-level monitoring', {
415
+ error: err,
416
+ });
417
+ }
418
+ }
387
419
 
388
- // Start audio level monitoring for Whisper (no interim results)
389
- // Browser STT has interim results so doesn't need this
390
- if (sttAdapter === 'whisper-wasm') {
391
- try {
392
- const levelStream = await navigator.mediaDevices.getUserMedia({
393
- audio: true,
394
- });
395
- startAudioLevelMonitoring(levelStream);
396
- } catch (err) {}
420
+ await stt.start({ continuous: true, interimResults: true });
421
+ } catch (err) {
422
+ // Reset listening state so the form isn't wedged, and stop any monitoring
423
+ // that may have started before the failure.
424
+ isFormListening = false;
425
+ stopAudioLevelMonitoring();
426
+ if (silenceTimer) {
427
+ clearTimeout(silenceTimer);
428
+ silenceTimer = null;
429
+ }
430
+ extractError =
431
+ err instanceof Error
432
+ ? err.message
433
+ : 'Could not start voice input. Check microphone permissions.';
434
+ logger.error('Form: failed to start form listening', { error: err });
435
+ } finally {
436
+ // Clear starting flag now that STT is actually listening (or has failed).
437
+ isStarting = false;
397
438
  }
398
-
399
- await stt.start({ continuous: true, interimResults: true });
400
-
401
- // Clear starting flag now that STT is actually listening
402
- isStarting = false;
403
439
  }
404
440
 
405
441
  async function stopFormListening() {
@@ -730,7 +766,7 @@ function getFormData(): Record<string, unknown> {
730
766
  was never emitted (issue #1431); color-mix derives the alpha from the
731
767
  emitted `--smrt-color-primary` token instead. */
732
768
  background: color-mix(in srgb, var(--smrt-color-primary, #166534) 90%, transparent);
733
- color: white;
769
+ color: var(--smrt-color-on-primary, #fff);
734
770
  font-size: var(--smrt-typography-body-medium-size, 0.875rem);
735
771
  box-shadow: 0 -2px 12px color-mix(in srgb, var(--smrt-color-shadow) 15%, transparent);
736
772
  z-index: var(--smrt-z-index-toast, 1500);
@@ -1 +1 @@
1
- {"version":3,"file":"Form.svelte.d.ts","sourceRoot":"","sources":["../../../src/components/forms/Form.svelte.ts"],"names":[],"mappings":"AAIA,OAAO,KAAK,EAAE,OAAO,EAAE,MAAM,QAAQ,CAAC;AAUtC,OAAO,KAAK,EAAE,UAAU,EAAE,cAAc,EAAE,MAAM,YAAY,CAAC;AAG7D,MAAM,WAAW,KAAK;IACpB,oBAAoB;IACpB,QAAQ,EAAE,OAAO,CAAC;IAClB,8BAA8B;IAC9B,cAAc,CAAC,EAAE,OAAO,CAAC;IACzB,oCAAoC;IACpC,cAAc,CAAC,EAAE,OAAO,CAAC;IACzB,iDAAiD;IACjD,cAAc,CAAC,EAAE,MAAM,CAAC;IACxB,uBAAuB;IACvB,UAAU,CAAC,EAAE,cAAc,CAAC;IAC5B,0DAA0D;IAC1D,QAAQ,CAAC,EAAE,UAAU,CAAC;IACtB,8EAA8E;IAC9E,QAAQ,CAAC,EAAE,CAAC,IAAI,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,KAAK,IAAI,CAAC;IACnD,4DAA4D;IAC5D,MAAM,CAAC,EAAE,KAAK,GAAG,MAAM,CAAC;IACxB,iDAAiD;IACjD,MAAM,CAAC,EAAE,MAAM,CAAC;CACjB;AA0hBD,QAAA,MAAM,IAAI,2CAAwC,CAAC;AACnD,KAAK,IAAI,GAAG,UAAU,CAAC,OAAO,IAAI,CAAC,CAAC;AACpC,eAAe,IAAI,CAAC"}
1
+ {"version":3,"file":"Form.svelte.d.ts","sourceRoot":"","sources":["../../../src/components/forms/Form.svelte.ts"],"names":[],"mappings":"AAIA,OAAO,KAAK,EAAE,OAAO,EAAE,MAAM,QAAQ,CAAC;AAWtC,OAAO,KAAK,EAAE,UAAU,EAAE,cAAc,EAAE,MAAM,YAAY,CAAC;AAG7D,MAAM,WAAW,KAAK;IACpB,oBAAoB;IACpB,QAAQ,EAAE,OAAO,CAAC;IAClB,8BAA8B;IAC9B,cAAc,CAAC,EAAE,OAAO,CAAC;IACzB,oCAAoC;IACpC,cAAc,CAAC,EAAE,OAAO,CAAC;IACzB,iDAAiD;IACjD,cAAc,CAAC,EAAE,MAAM,CAAC;IACxB,uBAAuB;IACvB,UAAU,CAAC,EAAE,cAAc,CAAC;IAC5B,0DAA0D;IAC1D,QAAQ,CAAC,EAAE,UAAU,CAAC;IACtB,8EAA8E;IAC9E,QAAQ,CAAC,EAAE,CAAC,IAAI,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,KAAK,IAAI,CAAC;IACnD,4DAA4D;IAC5D,MAAM,CAAC,EAAE,KAAK,GAAG,MAAM,CAAC;IACxB,iDAAiD;IACjD,MAAM,CAAC,EAAE,MAAM,CAAC;CACjB;AA8jBD,QAAA,MAAM,IAAI,2CAAwC,CAAC;AACnD,KAAK,IAAI,GAAG,UAAU,CAAC,OAAO,IAAI,CAAC,CAAC;AACpC,eAAe,IAAI,CAAC"}
@@ -29,13 +29,8 @@ let isExtracting = $state(false);
29
29
  $effect(() => {
30
30
  const checkState = () => {
31
31
  if (formContext) {
32
- const newListening = formContext.isFormListening;
33
- const newExtracting = formContext.isExtracting;
34
- // Only log on change to reduce noise
35
- if (newListening !== isListening || newExtracting !== isExtracting) {
36
- }
37
- isListening = newListening;
38
- isExtracting = newExtracting;
32
+ isListening = formContext.isFormListening;
33
+ isExtracting = formContext.isExtracting;
39
34
  }
40
35
  };
41
36
 
@@ -51,6 +46,14 @@ $effect(() => {
51
46
  function handleClick() {
52
47
  formContext?.toggleListening();
53
48
  }
49
+
50
+ // Keyboard activation for the role="button" span. A native <button> activates
51
+ // on both Enter and Space; this custom control must replicate both (CS2).
52
+ function handleKeydown(e: KeyboardEvent) {
53
+ if (e.key !== 'Enter' && e.key !== ' ') return;
54
+ e.preventDefault();
55
+ handleClick();
56
+ }
54
57
  </script>
55
58
 
56
59
  {#if isSmrt && formContext}
@@ -59,7 +62,7 @@ function handleClick() {
59
62
  class:listening={isListening}
60
63
  class:extracting={isExtracting}
61
64
  onclick={handleClick}
62
- onkeydown={(e) => e.key === 'Enter' && handleClick()}
65
+ onkeydown={handleKeydown}
63
66
  role="button"
64
67
  tabindex="0"
65
68
  aria-label={isListening ? 'Stop listening' : 'Click to speak'}
@@ -131,8 +134,8 @@ function handleClick() {
131
134
  transform: translateX(-50%);
132
135
  margin-top: 0.5rem;
133
136
  padding: 0.5rem 0.75rem;
134
- background: var(--smrt-color-on-surface, #1f2937);
135
- color: white;
137
+ background: var(--smrt-color-inverse-surface, var(--smrt-color-on-surface, #1f2937));
138
+ color: var(--smrt-color-inverse-on-surface, #fff);
136
139
  font-size: var(--smrt-typography-label-medium-size, 0.75rem);
137
140
  font-weight: var(--smrt-typography-weight-normal, 400);
138
141
  border-radius: 0.375rem;
@@ -148,7 +151,7 @@ function handleClick() {
148
151
  left: 50%;
149
152
  transform: translateX(-50%);
150
153
  border: 6px solid transparent;
151
- border-bottom-color: var(--smrt-color-on-surface);
154
+ border-bottom-color: var(--smrt-color-inverse-surface, var(--smrt-color-on-surface));
152
155
  }
153
156
 
154
157
  @keyframes pulse {
@@ -1 +1 @@
1
- {"version":3,"file":"FormMicButton.svelte.d.ts","sourceRoot":"","sources":["../../../src/components/forms/FormMicButton.svelte.ts"],"names":[],"mappings":"AASA,MAAM,WAAW,KAAK;IACpB,2BAA2B;IAC3B,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,2BAA2B;IAC3B,KAAK,CAAC,EAAE,MAAM,CAAC;CAChB;AAyED,QAAA,MAAM,aAAa,2CAAwC,CAAC;AAC5D,KAAK,aAAa,GAAG,UAAU,CAAC,OAAO,aAAa,CAAC,CAAC;AACtD,eAAe,aAAa,CAAC"}
1
+ {"version":3,"file":"FormMicButton.svelte.d.ts","sourceRoot":"","sources":["../../../src/components/forms/FormMicButton.svelte.ts"],"names":[],"mappings":"AASA,MAAM,WAAW,KAAK;IACpB,2BAA2B;IAC3B,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,2BAA2B;IAC3B,KAAK,CAAC,EAAE,MAAM,CAAC;CAChB;AA4ED,QAAA,MAAM,aAAa,2CAAwC,CAAC;AAC5D,KAAK,aAAa,GAAG,UAAU,CAAC,OAAO,aAAa,CAAC,CAAC;AACtD,eAAe,aAAa,CAAC"}