@designcrowd/fe-shared-lib 1.8.4-edge-fallback-1 → 1.8.4-edge-fallback-3
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.
- package/.claude/scheduled_tasks.lock +1 -0
- package/.claude/settings.local.json +54 -0
- package/.claude/skills/playwright-cli/SKILL.md +278 -0
- package/.claude/skills/playwright-cli/references/request-mocking.md +87 -0
- package/.claude/skills/playwright-cli/references/running-code.md +232 -0
- package/.claude/skills/playwright-cli/references/session-management.md +169 -0
- package/.claude/skills/playwright-cli/references/storage-state.md +275 -0
- package/.claude/skills/playwright-cli/references/test-generation.md +88 -0
- package/.claude/skills/playwright-cli/references/tracing.md +139 -0
- package/.claude/skills/playwright-cli/references/video-recording.md +43 -0
- package/.playwright-cli/page-2026-04-27T23-43-18-200Z.yml +68 -0
- package/.playwright-cli/page-2026-04-28T01-20-32-525Z.yml +451 -0
- package/.playwright-cli/page-2026-05-05T01-37-40-892Z.yml +68 -0
- package/.playwright-cli/page-2026-05-05T01-37-44-367Z.yml +448 -0
- package/.playwright-cli/page-2026-05-05T01-37-54-274Z.yml +445 -0
- package/.playwright-cli/page-2026-05-05T01-38-02-746Z.yml +448 -0
- package/.playwright-cli/page-2026-05-05T01-38-11-519Z.yml +68 -0
- package/.playwright-cli/page-2026-05-05T01-38-30-060Z.yml +440 -0
- package/.playwright-cli/page-2026-05-05T01-38-42-137Z.yml +68 -0
- package/.playwright-cli/page-2026-05-05T01-38-45-269Z.yml +445 -0
- package/.playwright-cli/page-2026-05-05T01-43-09-147Z.yml +68 -0
- package/.playwright-cli/page-2026-05-05T01-43-14-323Z.yml +448 -0
- package/.playwright-cli/page-2026-05-05T01-43-31-765Z.png +0 -0
- package/.playwright-cli/page-2026-05-05T01-43-41-236Z.yml +445 -0
- package/.playwright-cli/page-2026-05-05T01-43-45-996Z.png +0 -0
- package/.playwright-cli/page-2026-05-05T01-43-55-336Z.yml +448 -0
- package/{force-unsupported-with-button.png → .playwright-cli/page-2026-05-05T01-43-59-714Z.png} +0 -0
- package/.playwright-cli/page-2026-05-05T01-44-09-967Z.yml +445 -0
- package/.playwright-cli/page-2026-05-05T01-44-11-884Z.yml +68 -0
- package/.playwright-cli/page-2026-05-05T01-44-18-229Z.yml +440 -0
- package/.playwright-cli/page-2026-05-05T01-44-54-928Z.png +0 -0
- package/.playwright-cli/page-2026-05-05T01-45-20-437Z.yml +0 -0
- package/.playwright-cli/page-2026-05-05T01-45-23-521Z.yml +438 -0
- package/.playwright-cli/page-2026-05-05T01-45-39-082Z.yml +438 -0
- package/.playwright-cli/page-2026-05-05T01-45-55-158Z.yml +435 -0
- package/.playwright-cli/page-2026-05-05T01-46-09-528Z.png +0 -0
- package/.playwright-cli/page-2026-05-06T01-41-27-537Z.yml +29 -0
- package/.playwright-cli/page-2026-05-06T01-41-50-840Z.yml +29 -0
- package/.playwright-cli/page-2026-05-06T01-42-06-069Z.yml +0 -0
- package/.playwright-cli/page-2026-05-06T01-42-17-046Z.yml +0 -0
- package/.playwright-cli/page-2026-05-06T01-42-52-104Z.yml +0 -0
- package/.playwright-cli/page-2026-05-06T01-43-28-465Z.yml +0 -0
- package/.playwright-cli/page-2026-05-06T01-54-25-745Z.yml +0 -0
- package/.playwright-cli/page-2026-05-06T01-54-33-270Z.yml +29 -0
- package/.playwright-cli/page-2026-05-06T01-54-35-602Z.yml +29 -0
- package/.playwright-cli/page-2026-05-06T01-54-58-541Z.yml +0 -0
- package/.playwright-cli/page-2026-05-06T01-55-08-584Z.yml +23 -0
- package/.playwright-cli/page-2026-05-06T01-55-14-358Z.yml +23 -0
- package/.playwright-cli/page-2026-05-06T01-55-33-735Z.yml +23 -0
- package/.playwright-cli/page-2026-05-06T01-55-41-079Z.yml +0 -0
- package/.playwright-cli/page-2026-05-06T01-55-45-770Z.yml +22 -0
- package/.playwright-cli/page-2026-05-06T01-55-51-522Z.yml +22 -0
- package/.playwright-cli/page-2026-05-06T01-55-57-985Z.yml +22 -0
- package/index.js +1 -0
- package/package.json +1 -1
- package/src/atoms/components/VoiceToTextButton/VoiceToTextButton.stories.ts +70 -16
- package/src/atoms/components/VoiceToTextButton/VoiceToTextButton.vue +36 -5
- package/src/useVoiceToText.ts +78 -10
- package/force-unsupported-hidden.png +0 -0
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
- generic [active] [ref=e3]:
|
|
2
|
+
- generic [ref=e5]:
|
|
3
|
+
- heading "Control vs Unsupported (side-by-side)" [level=3] [ref=e6]
|
|
4
|
+
- paragraph [ref=e7]: Both buttons share variant + size. The right one is rendered with the session-disabled latch flipped on, so it shows the new disabled state with the explanatory tooltip. Use this to compare visual treatments for design review.
|
|
5
|
+
- generic [ref=e8]:
|
|
6
|
+
- button "Force unsupported" [ref=e9] [cursor=pointer]
|
|
7
|
+
- button "Reset" [ref=e10] [cursor=pointer]
|
|
8
|
+
- generic [ref=e11]:
|
|
9
|
+
- generic [ref=e12]:
|
|
10
|
+
- button "Start voice input" [ref=e14] [cursor=pointer]:
|
|
11
|
+
- img [ref=e16]
|
|
12
|
+
- paragraph [ref=e18]: Control (default state)
|
|
13
|
+
- generic [ref=e19]:
|
|
14
|
+
- button "Start voice input" [ref=e21] [cursor=pointer]:
|
|
15
|
+
- img [ref=e23]
|
|
16
|
+
- paragraph [ref=e25]: Unsupported (after latch)
|
|
17
|
+
- paragraph [ref=e26]: "Note: the composable is a singleton, so flipping the latch affects every mounted instance — which is exactly what the production Edge case does. Both buttons will switch together."
|
|
18
|
+
- generic [ref=e27]:
|
|
19
|
+
- generic "Toggle devtools panel" [ref=e28] [cursor=pointer]:
|
|
20
|
+
- img [ref=e29]
|
|
21
|
+
- generic "Toggle Component Inspector" [ref=e34] [cursor=pointer]:
|
|
22
|
+
- img [ref=e35]
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
- generic [ref=e3]:
|
|
2
|
+
- generic [ref=e5]:
|
|
3
|
+
- heading "Control vs Unsupported (side-by-side)" [level=3] [ref=e6]
|
|
4
|
+
- paragraph [ref=e7]: Both buttons share variant + size. The right one is rendered with the session-disabled latch flipped on, so it shows the new disabled state with the explanatory tooltip. Use this to compare visual treatments for design review.
|
|
5
|
+
- generic [ref=e8]:
|
|
6
|
+
- button "Force unsupported" [active] [ref=e9] [cursor=pointer]
|
|
7
|
+
- button "Reset" [ref=e10] [cursor=pointer]
|
|
8
|
+
- generic [ref=e11]:
|
|
9
|
+
- generic [ref=e12]:
|
|
10
|
+
- button "Voice input not available in this browser" [disabled] [ref=e39] [cursor=pointer]:
|
|
11
|
+
- img [ref=e16]
|
|
12
|
+
- paragraph [ref=e18]: Control (default state)
|
|
13
|
+
- generic [ref=e19]:
|
|
14
|
+
- button "Voice input not available in this browser" [disabled] [ref=e40] [cursor=pointer]:
|
|
15
|
+
- img [ref=e23]
|
|
16
|
+
- paragraph [ref=e25]: Unsupported (after latch)
|
|
17
|
+
- paragraph [ref=e26]: "Note: the composable is a singleton, so flipping the latch affects every mounted instance — which is exactly what the production Edge case does. Both buttons will switch together."
|
|
18
|
+
- generic [ref=e27]:
|
|
19
|
+
- generic "Toggle devtools panel" [ref=e28] [cursor=pointer]:
|
|
20
|
+
- img [ref=e29]
|
|
21
|
+
- generic "Toggle Component Inspector" [ref=e34] [cursor=pointer]:
|
|
22
|
+
- img [ref=e35]
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
- generic [ref=e3]:
|
|
2
|
+
- generic [ref=e5]:
|
|
3
|
+
- heading "Control vs Unsupported (side-by-side)" [level=3] [ref=e6]
|
|
4
|
+
- paragraph [ref=e7]: Both buttons share variant + size. The right one is rendered with the session-disabled latch flipped on, so it shows the new disabled state with the explanatory tooltip. Use this to compare visual treatments for design review.
|
|
5
|
+
- generic [ref=e8]:
|
|
6
|
+
- button "Force unsupported" [ref=e9] [cursor=pointer]
|
|
7
|
+
- button "Reset" [active] [ref=e10] [cursor=pointer]
|
|
8
|
+
- generic [ref=e11]:
|
|
9
|
+
- generic [ref=e12]:
|
|
10
|
+
- button "Start voice input" [ref=e41] [cursor=pointer]:
|
|
11
|
+
- img [ref=e16]
|
|
12
|
+
- paragraph [ref=e18]: Control (default state)
|
|
13
|
+
- generic [ref=e19]:
|
|
14
|
+
- button "Start voice input" [ref=e42] [cursor=pointer]:
|
|
15
|
+
- img [ref=e23]
|
|
16
|
+
- paragraph [ref=e25]: Unsupported (after latch)
|
|
17
|
+
- paragraph [ref=e26]: "Note: the composable is a singleton, so flipping the latch affects every mounted instance — which is exactly what the production Edge case does. Both buttons will switch together."
|
|
18
|
+
- generic [ref=e27]:
|
|
19
|
+
- generic "Toggle devtools panel" [ref=e28] [cursor=pointer]:
|
|
20
|
+
- img [ref=e29]
|
|
21
|
+
- generic "Toggle Component Inspector" [ref=e34] [cursor=pointer]:
|
|
22
|
+
- img [ref=e35]
|
package/index.js
CHANGED
|
@@ -23,6 +23,7 @@ export { WEBSITE_UPGRADE_CONTEXT_TYPES } from './src/experiences/models/websiteC
|
|
|
23
23
|
|
|
24
24
|
export { setSharedLibLocaleAsync, tr } from './src/useSharedLibTranslate';
|
|
25
25
|
export { useVoiceToText } from './src/useVoiceToText';
|
|
26
|
+
export { setVoiceToTextSessionDisabledForTesting, VOICE_UNAVAILABLE_MESSAGE } from './src/useVoiceToText';
|
|
26
27
|
|
|
27
28
|
export { default as Button } from './src/atoms/components/Button/Button.vue';
|
|
28
29
|
export { default as ButtonGroup } from './src/atoms/components/ButtonGroup/ButtonGroup.vue';
|
package/package.json
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import VoiceToTextButton from './VoiceToTextButton.vue';
|
|
2
|
-
import {
|
|
2
|
+
import { setVoiceToTextSessionDisabledForTesting } from '../../../useVoiceToText';
|
|
3
3
|
|
|
4
4
|
export default {
|
|
5
5
|
title: 'Components/VoiceToTextButton',
|
|
@@ -244,22 +244,12 @@ SideBySide.story = {
|
|
|
244
244
|
|
|
245
245
|
export const ForceUnsupported = () => ({
|
|
246
246
|
components: { VoiceToTextButton },
|
|
247
|
-
data() {
|
|
248
|
-
return {
|
|
249
|
-
// Reactive ping so the button re-renders when we flip the session flag.
|
|
250
|
-
// The composable's isSupported is reactive on its own, but the parent
|
|
251
|
-
// template uses :key to make the toggle obvious in Storybook.
|
|
252
|
-
tick: 0,
|
|
253
|
-
};
|
|
254
|
-
},
|
|
255
247
|
methods: {
|
|
256
248
|
forceUnsupported() {
|
|
257
|
-
|
|
258
|
-
this.tick += 1;
|
|
249
|
+
setVoiceToTextSessionDisabledForTesting(true);
|
|
259
250
|
},
|
|
260
251
|
reset() {
|
|
261
|
-
|
|
262
|
-
this.tick += 1;
|
|
252
|
+
setVoiceToTextSessionDisabledForTesting(false);
|
|
263
253
|
},
|
|
264
254
|
},
|
|
265
255
|
template: `
|
|
@@ -267,8 +257,8 @@ export const ForceUnsupported = () => ({
|
|
|
267
257
|
<h3 class="tw-text-grayscale-800 tw-text-lg tw-font-semibold tw-mb-2">Force unsupported preview</h3>
|
|
268
258
|
<p class="tw-text-grayscale-600 tw-text-sm tw-mb-6 tw-text-center tw-max-w-md">
|
|
269
259
|
Simulates the Edge fallback: after a 'network' SpeechRecognitionError, the composable latches
|
|
270
|
-
<code class="tw-bg-grayscale-100 tw-px-1 tw-rounded">isSupported</code> to false for the rest of the session
|
|
271
|
-
|
|
260
|
+
<code class="tw-bg-grayscale-100 tw-px-1 tw-rounded">isSupported</code> to false for the rest of the session.
|
|
261
|
+
The button now stays visible but disabled, with a tooltip explaining the situation. Reset clears the session flag.
|
|
272
262
|
</p>
|
|
273
263
|
|
|
274
264
|
<div class="tw-flex tw-gap-3 tw-mb-6">
|
|
@@ -294,7 +284,7 @@ export const ForceUnsupported = () => ({
|
|
|
294
284
|
placeholder="Voice input would appear on the right..."
|
|
295
285
|
class="tw-flex-1 tw-bg-transparent tw-border-none tw-text-grayscale-800 tw-placeholder-grayscale-500 focus:tw-outline-none tw-text-base"
|
|
296
286
|
/>
|
|
297
|
-
<VoiceToTextButton
|
|
287
|
+
<VoiceToTextButton variant="light" size="md" />
|
|
298
288
|
</div>
|
|
299
289
|
|
|
300
290
|
<p class="tw-text-grayscale-500 tw-text-xs tw-mt-4">
|
|
@@ -308,3 +298,67 @@ export const ForceUnsupported = () => ({
|
|
|
308
298
|
ForceUnsupported.story = {
|
|
309
299
|
name: 'Force Unsupported (Edge Fallback)',
|
|
310
300
|
};
|
|
301
|
+
|
|
302
|
+
export const ControlVsUnsupported = () => ({
|
|
303
|
+
components: { VoiceToTextButton },
|
|
304
|
+
methods: {
|
|
305
|
+
forceUnsupported() {
|
|
306
|
+
setVoiceToTextSessionDisabledForTesting(true);
|
|
307
|
+
},
|
|
308
|
+
reset() {
|
|
309
|
+
setVoiceToTextSessionDisabledForTesting(false);
|
|
310
|
+
},
|
|
311
|
+
},
|
|
312
|
+
template: `
|
|
313
|
+
<div class="tw-min-h-[400px] tw-p-8" style="background: #1a1a2e;">
|
|
314
|
+
<h3 class="tw-text-white tw-text-lg tw-font-semibold tw-mb-2">Control vs Unsupported (side-by-side)</h3>
|
|
315
|
+
<p class="tw-text-grayscale-400 tw-text-sm tw-mb-6 tw-max-w-2xl">
|
|
316
|
+
Both buttons share variant + size. The right one is rendered with the session-disabled latch
|
|
317
|
+
flipped on, so it shows the new disabled state with the explanatory tooltip. Use this to compare
|
|
318
|
+
visual treatments for design review.
|
|
319
|
+
</p>
|
|
320
|
+
|
|
321
|
+
<div class="tw-flex tw-gap-3 tw-mb-6">
|
|
322
|
+
<button
|
|
323
|
+
type="button"
|
|
324
|
+
@click="forceUnsupported"
|
|
325
|
+
class="tw-px-4 tw-py-2 tw-rounded tw-bg-error-500 tw-text-white tw-text-sm hover:tw-bg-error-600"
|
|
326
|
+
>
|
|
327
|
+
Force unsupported
|
|
328
|
+
</button>
|
|
329
|
+
<button
|
|
330
|
+
type="button"
|
|
331
|
+
@click="reset"
|
|
332
|
+
class="tw-px-4 tw-py-2 tw-rounded tw-bg-grayscale-200 tw-text-grayscale-800 tw-text-sm hover:tw-bg-grayscale-300"
|
|
333
|
+
>
|
|
334
|
+
Reset
|
|
335
|
+
</button>
|
|
336
|
+
</div>
|
|
337
|
+
|
|
338
|
+
<div class="tw-flex tw-gap-12 tw-items-start">
|
|
339
|
+
<div class="tw-text-center">
|
|
340
|
+
<div class="tw-mb-2 tw-p-4 tw-bg-grayscale-800 tw-rounded-lg tw-inline-block">
|
|
341
|
+
<VoiceToTextButton variant="dark" size="md" />
|
|
342
|
+
</div>
|
|
343
|
+
<p class="tw-text-grayscale-400 tw-text-xs">Control (default state)</p>
|
|
344
|
+
</div>
|
|
345
|
+
|
|
346
|
+
<div class="tw-text-center">
|
|
347
|
+
<div class="tw-mb-2 tw-p-4 tw-bg-grayscale-800 tw-rounded-lg tw-inline-block">
|
|
348
|
+
<VoiceToTextButton variant="dark" size="md" />
|
|
349
|
+
</div>
|
|
350
|
+
<p class="tw-text-grayscale-400 tw-text-xs">Unsupported (after latch)</p>
|
|
351
|
+
</div>
|
|
352
|
+
</div>
|
|
353
|
+
|
|
354
|
+
<p class="tw-text-grayscale-500 tw-text-xs tw-mt-6">
|
|
355
|
+
Note: the composable is a singleton, so flipping the latch affects every mounted instance —
|
|
356
|
+
which is exactly what the production Edge case does. Both buttons will switch together.
|
|
357
|
+
</p>
|
|
358
|
+
</div>
|
|
359
|
+
`,
|
|
360
|
+
});
|
|
361
|
+
|
|
362
|
+
ControlVsUnsupported.story = {
|
|
363
|
+
name: 'Control vs Unsupported',
|
|
364
|
+
};
|
|
@@ -1,16 +1,23 @@
|
|
|
1
1
|
<template>
|
|
2
2
|
<button
|
|
3
|
-
v-if="isSupported"
|
|
4
3
|
type="button"
|
|
5
|
-
:disabled="disabled"
|
|
6
|
-
:aria-label="
|
|
4
|
+
:disabled="disabled || unavailableForSession"
|
|
5
|
+
:aria-label="
|
|
6
|
+
unavailableForSession
|
|
7
|
+
? 'Voice input not available in this browser'
|
|
8
|
+
: isListening
|
|
9
|
+
? 'Stop voice input'
|
|
10
|
+
: 'Start voice input'
|
|
11
|
+
"
|
|
12
|
+
:title="effectiveTitle"
|
|
7
13
|
data-test="voice-to-text-button"
|
|
8
14
|
class="voice-prompt-button tw-rounded-full tw-border-0 tw-flex tw-items-center tw-justify-center tw-transition-all tw-duration-200 tw-cursor-pointer focus:tw-outline-none focus-visible:tw-ring-2 focus-visible:tw-ring-offset-2 focus-visible:tw-ring-primary-500"
|
|
9
15
|
:class="[
|
|
10
16
|
sizeClasses,
|
|
11
17
|
variantClasses.button,
|
|
12
18
|
isListening ? 'voice-prompt-listening' : '',
|
|
13
|
-
disabled ? 'tw-opacity-50 tw-cursor-not-allowed' : '',
|
|
19
|
+
(disabled || unavailableForSession) ? 'tw-opacity-50 tw-cursor-not-allowed' : '',
|
|
20
|
+
unavailableForSession ? 'voice-prompt-unavailable' : '',
|
|
14
21
|
]"
|
|
15
22
|
:style="isListening ? { '--voice-bg': variantClasses.recordingBg } : {}"
|
|
16
23
|
@click="toggle"
|
|
@@ -27,7 +34,7 @@
|
|
|
27
34
|
<script setup lang="ts">
|
|
28
35
|
import { watch, toRef, computed } from 'vue';
|
|
29
36
|
import Icon from '../Icon/Icon.vue';
|
|
30
|
-
import { useVoiceToText } from '../../../useVoiceToText';
|
|
37
|
+
import { useVoiceToText, VOICE_UNAVAILABLE_MESSAGE } from '../../../useVoiceToText';
|
|
31
38
|
|
|
32
39
|
type ButtonSize = 'sm' | 'md' | 'lg';
|
|
33
40
|
type ButtonVariant = 'light' | 'dark';
|
|
@@ -37,6 +44,7 @@ interface VoiceToTextButtonProps {
|
|
|
37
44
|
disabled?: boolean;
|
|
38
45
|
size?: ButtonSize;
|
|
39
46
|
variant?: ButtonVariant;
|
|
47
|
+
title?: string;
|
|
40
48
|
}
|
|
41
49
|
|
|
42
50
|
const props = withDefaults(defineProps<VoiceToTextButtonProps>(), {
|
|
@@ -44,6 +52,7 @@ const props = withDefaults(defineProps<VoiceToTextButtonProps>(), {
|
|
|
44
52
|
disabled: false,
|
|
45
53
|
size: 'md',
|
|
46
54
|
variant: 'dark',
|
|
55
|
+
title: undefined,
|
|
47
56
|
});
|
|
48
57
|
|
|
49
58
|
const sizeClasses = computed(() => {
|
|
@@ -91,6 +100,9 @@ const variantClasses = computed(() => {
|
|
|
91
100
|
};
|
|
92
101
|
});
|
|
93
102
|
|
|
103
|
+
const unavailableForSession = computed(() => !isSupported.value);
|
|
104
|
+
const effectiveTitle = computed(() => (unavailableForSession.value ? VOICE_UNAVAILABLE_MESSAGE : props.title));
|
|
105
|
+
|
|
94
106
|
// Keep recognition language in sync with prop
|
|
95
107
|
watch(toRef(props, 'lang'), setLang);
|
|
96
108
|
|
|
@@ -144,4 +156,23 @@ watch(error, (newError) => {
|
|
|
144
156
|
opacity: 0.5;
|
|
145
157
|
}
|
|
146
158
|
}
|
|
159
|
+
|
|
160
|
+
.voice-prompt-unavailable {
|
|
161
|
+
position: relative;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
.voice-prompt-unavailable::after {
|
|
165
|
+
content: '';
|
|
166
|
+
position: absolute;
|
|
167
|
+
inset: 18%;
|
|
168
|
+
background: linear-gradient(
|
|
169
|
+
135deg,
|
|
170
|
+
transparent calc(50% - 1px),
|
|
171
|
+
#E34935 calc(50% - 1px),
|
|
172
|
+
#E34935 calc(50% + 1px),
|
|
173
|
+
transparent calc(50% + 1px)
|
|
174
|
+
);
|
|
175
|
+
pointer-events: none;
|
|
176
|
+
border-radius: inherit;
|
|
177
|
+
}
|
|
147
178
|
</style>
|
package/src/useVoiceToText.ts
CHANGED
|
@@ -31,6 +31,7 @@ let isInitialized = false;
|
|
|
31
31
|
let errorClearTimeout: ReturnType<typeof setTimeout> | null = null;
|
|
32
32
|
let state: VoiceToTextState | null = null;
|
|
33
33
|
let sessionDisabled: Ref<boolean> | null = null;
|
|
34
|
+
let hasReceivedResult = false;
|
|
34
35
|
|
|
35
36
|
// Edge ships window.SpeechRecognition but routes it through Microsoft's Online
|
|
36
37
|
// Speech service, which is gated by a Windows privacy toggle that's commonly
|
|
@@ -47,8 +48,24 @@ const ERROR_MESSAGES: Record<string, string> = {
|
|
|
47
48
|
'audio-capture': 'No microphone was found or microphone is not working.',
|
|
48
49
|
};
|
|
49
50
|
|
|
51
|
+
export const VOICE_UNAVAILABLE_MESSAGE =
|
|
52
|
+
"Voice input isn't available in this browser. Try Chrome or Safari for the best experience.";
|
|
53
|
+
|
|
50
54
|
const ERROR_CLEAR_DELAY = 5000;
|
|
51
55
|
|
|
56
|
+
async function checkMicPermission(): Promise<'granted' | 'denied' | 'prompt' | 'unknown'> {
|
|
57
|
+
try {
|
|
58
|
+
if (typeof navigator === 'undefined' || !navigator.permissions?.query) {
|
|
59
|
+
return 'unknown';
|
|
60
|
+
}
|
|
61
|
+
const result = await navigator.permissions.query({ name: 'microphone' as PermissionName });
|
|
62
|
+
return result.state as 'granted' | 'denied' | 'prompt';
|
|
63
|
+
} catch {
|
|
64
|
+
// Permissions API or the 'microphone' descriptor isn't supported (older Safari, Firefox quirks).
|
|
65
|
+
return 'unknown';
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
52
69
|
function getState(): VoiceToTextState {
|
|
53
70
|
if (!state) {
|
|
54
71
|
state = {
|
|
@@ -89,12 +106,23 @@ function setSessionDisabled(disabled: boolean): void {
|
|
|
89
106
|
}
|
|
90
107
|
}
|
|
91
108
|
|
|
109
|
+
function latchAsUnavailable(): void {
|
|
110
|
+
const { error: errorRef } = getState();
|
|
111
|
+
setSessionDisabled(true);
|
|
112
|
+
errorRef.value = VOICE_UNAVAILABLE_MESSAGE;
|
|
113
|
+
if (errorClearTimeout) {
|
|
114
|
+
clearTimeout(errorClearTimeout);
|
|
115
|
+
}
|
|
116
|
+
errorClearTimeout = setTimeout(() => {
|
|
117
|
+
errorRef.value = null;
|
|
118
|
+
}, ERROR_CLEAR_DELAY);
|
|
119
|
+
}
|
|
120
|
+
|
|
92
121
|
/**
|
|
93
122
|
* Test/Storybook helper: force the session-disabled latch on or off without
|
|
94
123
|
* needing a real network error. Not part of the public consumer API.
|
|
95
124
|
*/
|
|
96
|
-
|
|
97
|
-
export function __setVoiceToTextSessionDisabled(disabled: boolean): void {
|
|
125
|
+
export function setVoiceToTextSessionDisabledForTesting(disabled: boolean): void {
|
|
98
126
|
setSessionDisabled(disabled);
|
|
99
127
|
}
|
|
100
128
|
|
|
@@ -134,27 +162,67 @@ export function useVoiceToText(options: UseVoiceToTextOptions = {}): UseVoiceToT
|
|
|
134
162
|
}
|
|
135
163
|
}
|
|
136
164
|
|
|
165
|
+
if (finalTranscript.length > 0 || interimTranscript.length > 0) {
|
|
166
|
+
hasReceivedResult = true;
|
|
167
|
+
}
|
|
168
|
+
|
|
137
169
|
transcript.value = finalTranscript + interimTranscript;
|
|
138
170
|
isFinal.value = interimTranscript === '';
|
|
139
171
|
};
|
|
140
172
|
|
|
141
|
-
recognition.onerror = (event: SpeechRecognitionErrorEvent) => {
|
|
173
|
+
recognition.onerror = async (event: SpeechRecognitionErrorEvent) => {
|
|
142
174
|
// Suppress no-speech and aborted errors per spec
|
|
143
175
|
if (event.error === 'no-speech' || event.error === 'aborted') {
|
|
144
176
|
return;
|
|
145
177
|
}
|
|
146
178
|
|
|
147
|
-
//
|
|
148
|
-
//
|
|
149
|
-
|
|
179
|
+
// Diagnostic instrumentation: capture state at every non-suppressed error
|
|
180
|
+
// so QA has a data point on first failure across browser/permission combos.
|
|
181
|
+
const micPermission = await checkMicPermission();
|
|
182
|
+
const sessionDisabledFlag = getSessionDisabled().value;
|
|
183
|
+
// eslint-disable-next-line no-console
|
|
184
|
+
console.warn('[useVoiceToText] error event', {
|
|
185
|
+
error: event.error,
|
|
186
|
+
micPermission,
|
|
187
|
+
hasReceivedResult,
|
|
188
|
+
sessionDisabled: sessionDisabledFlag,
|
|
189
|
+
});
|
|
190
|
+
|
|
150
191
|
if (event.error === 'network') {
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
192
|
+
if (!hasReceivedResult) {
|
|
193
|
+
// First-attempt network failure on Edge with the privacy toggle off (or
|
|
194
|
+
// an actually-broken network). Latch the feature off for the session.
|
|
195
|
+
// eslint-disable-next-line no-console
|
|
196
|
+
console.warn('[useVoiceToText] network error before any result — disabling voice input for this session');
|
|
197
|
+
isListening.value = false;
|
|
198
|
+
latchAsUnavailable();
|
|
199
|
+
} else {
|
|
200
|
+
// Trailing network error after a successful result is a known Edge teardown
|
|
201
|
+
// quirk — swallow it instead of latching, since voice clearly works for this user.
|
|
202
|
+
// eslint-disable-next-line no-console
|
|
203
|
+
console.warn('[useVoiceToText] swallowing trailing network error after successful result');
|
|
204
|
+
isListening.value = false;
|
|
205
|
+
}
|
|
155
206
|
return;
|
|
156
207
|
}
|
|
157
208
|
|
|
209
|
+
if (event.error === 'not-allowed') {
|
|
210
|
+
if (micPermission === 'granted') {
|
|
211
|
+
// Page-level mic permission was granted but the underlying speech backend
|
|
212
|
+
// (Edge's Microsoft Online Speech service) refused. This is the Mac
|
|
213
|
+
// InPrivate / Windows-toggle-off failure mode. Latch instead of showing a
|
|
214
|
+
// misleading "permission denied" toast.
|
|
215
|
+
// eslint-disable-next-line no-console
|
|
216
|
+
console.warn(
|
|
217
|
+
'[useVoiceToText] not-allowed despite granted mic permission — disabling voice input for this session',
|
|
218
|
+
);
|
|
219
|
+
isListening.value = false;
|
|
220
|
+
latchAsUnavailable();
|
|
221
|
+
return;
|
|
222
|
+
}
|
|
223
|
+
// Otherwise fall through to the existing "permission was denied" toast.
|
|
224
|
+
}
|
|
225
|
+
|
|
158
226
|
const message = ERROR_MESSAGES[event.error] || 'An error occurred with speech recognition.';
|
|
159
227
|
error.value = message;
|
|
160
228
|
|
|
Binary file
|