@designcrowd/fe-shared-lib 1.8.5-edge-fallback-5 → 1.8.5
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/index.js
CHANGED
|
@@ -23,7 +23,6 @@ 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';
|
|
27
26
|
|
|
28
27
|
export { default as Button } from './src/atoms/components/Button/Button.vue';
|
|
29
28
|
export { default as ButtonGroup } from './src/atoms/components/ButtonGroup/ButtonGroup.vue';
|
package/package.json
CHANGED
package/scripts/publish-uat.sh
CHANGED
|
@@ -1,17 +1,19 @@
|
|
|
1
1
|
#!/usr/bin/env bash
|
|
2
2
|
#
|
|
3
|
-
# publish-uat.sh — publish
|
|
3
|
+
# publish-uat.sh — publish @designcrowd/fe-shared-lib (UAT or stable)
|
|
4
4
|
#
|
|
5
|
-
# Usage: ./scripts/publish-uat.sh <suffix>
|
|
5
|
+
# Usage: ./scripts/publish-uat.sh <suffix> # UAT pre-release
|
|
6
|
+
# ./scripts/publish-uat.sh --stable # final release (no suffix)
|
|
6
7
|
# Example: ./scripts/publish-uat.sh edge-fallback-2
|
|
8
|
+
# ./scripts/publish-uat.sh --stable
|
|
7
9
|
#
|
|
8
10
|
# Reads the npm publish token from $NPM_PUBLISH_TOKEN_FILE
|
|
9
11
|
# (default ~/.config/designcrowd/npm-publish-token, mode 600).
|
|
10
12
|
# The token is never written into the repo.
|
|
11
13
|
#
|
|
12
14
|
# Workflow:
|
|
13
|
-
# 1. Validate
|
|
14
|
-
# 2. Compute new version
|
|
15
|
+
# 1. Validate args; in UAT mode also refuse master/main.
|
|
16
|
+
# 2. Compute new version (<base>-<suffix> or <base>); refuse if already published.
|
|
15
17
|
# 3. Bump package.json, run bundle-translation.
|
|
16
18
|
# 4. npm pack — produces a tarball.
|
|
17
19
|
# 5. Secret scan: dangerous filenames + token-shaped strings + the
|
|
@@ -33,16 +35,28 @@ TOKEN_FILE="${NPM_PUBLISH_TOKEN_FILE:-$HOME/.config/designcrowd/npm-publish-toke
|
|
|
33
35
|
abort() { echo "ABORT: $*" >&2; exit 1; }
|
|
34
36
|
|
|
35
37
|
# --- args ---
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
38
|
+
MODE="uat"
|
|
39
|
+
SUFFIX=""
|
|
40
|
+
ARG="${1:-}"
|
|
41
|
+
if [[ "$ARG" == "--stable" ]]; then
|
|
42
|
+
MODE="stable"
|
|
43
|
+
elif [[ -n "$ARG" ]]; then
|
|
44
|
+
SUFFIX="$ARG"
|
|
45
|
+
# Semver pre-release identifiers are [0-9A-Za-z-], dot-separated. Reject underscores etc.
|
|
46
|
+
# so we fail before npm rejects the version mid-flight with a dirty tree.
|
|
47
|
+
[[ "$SUFFIX" =~ ^[A-Za-z0-9-]+(\.[A-Za-z0-9-]+)*$ ]] || abort "suffix must match semver pre-release: alphanumerics/hyphens, optionally dot-separated"
|
|
48
|
+
else
|
|
49
|
+
abort $'usage: '"$0"$' <suffix> # UAT pre-release (e.g. edge-fallback-2)\n '"$0"$' --stable # final release'
|
|
50
|
+
fi
|
|
41
51
|
|
|
42
52
|
# --- repo state ---
|
|
43
53
|
cd "$(git rev-parse --show-toplevel)"
|
|
44
54
|
BRANCH=$(git symbolic-ref --short HEAD)
|
|
45
|
-
|
|
55
|
+
# UAT publishes from feature branches only; stable can come from any branch
|
|
56
|
+
# (master is the typical case but feature-branch hot publishes are allowed too).
|
|
57
|
+
if [[ "$MODE" == "uat" ]]; then
|
|
58
|
+
[[ "$BRANCH" == "master" || "$BRANCH" == "main" ]] && abort "refusing UAT publish from $BRANCH"
|
|
59
|
+
fi
|
|
46
60
|
|
|
47
61
|
# --- preconditions: files we'll commit must be clean, so unrelated edits don't get swept into the version-bump commit ---
|
|
48
62
|
[[ -z "$(git status --porcelain -- package.json)" ]] || abort "package.json has uncommitted changes; commit or stash before publishing"
|
|
@@ -70,10 +84,18 @@ fi
|
|
|
70
84
|
|
|
71
85
|
# --- compute version ---
|
|
72
86
|
BASE=$(node -p "require('./package.json').version.replace(/-.*/, '')")
|
|
73
|
-
|
|
87
|
+
if [[ "$MODE" == "stable" ]]; then
|
|
88
|
+
NEW="$BASE"
|
|
89
|
+
else
|
|
90
|
+
NEW="$BASE-$SUFFIX"
|
|
91
|
+
fi
|
|
74
92
|
|
|
75
93
|
if npm view "$PKG@$NEW" version >/dev/null 2>&1; then
|
|
76
|
-
|
|
94
|
+
if [[ "$MODE" == "stable" ]]; then
|
|
95
|
+
abort "$PKG@$NEW already published — bump the base version in package.json first"
|
|
96
|
+
else
|
|
97
|
+
abort "$PKG@$NEW already published — pick a new suffix"
|
|
98
|
+
fi
|
|
77
99
|
fi
|
|
78
100
|
|
|
79
101
|
echo "==> publishing $PKG@$NEW from branch $BRANCH"
|
|
@@ -1,5 +1,4 @@
|
|
|
1
1
|
import VoiceToTextButton from './VoiceToTextButton.vue';
|
|
2
|
-
import { setVoiceToTextSessionDisabledForTesting } from '../../../useVoiceToText';
|
|
3
2
|
|
|
4
3
|
export default {
|
|
5
4
|
title: 'Components/VoiceToTextButton',
|
|
@@ -241,124 +240,3 @@ export const SideBySide = () => ({
|
|
|
241
240
|
SideBySide.story = {
|
|
242
241
|
name: 'Side by Side Comparison',
|
|
243
242
|
};
|
|
244
|
-
|
|
245
|
-
export const ForceUnsupported = () => ({
|
|
246
|
-
components: { VoiceToTextButton },
|
|
247
|
-
methods: {
|
|
248
|
-
forceUnsupported() {
|
|
249
|
-
setVoiceToTextSessionDisabledForTesting(true);
|
|
250
|
-
},
|
|
251
|
-
reset() {
|
|
252
|
-
setVoiceToTextSessionDisabledForTesting(false);
|
|
253
|
-
},
|
|
254
|
-
},
|
|
255
|
-
template: `
|
|
256
|
-
<div class="tw-min-h-[400px] tw-p-8 tw-flex tw-flex-col tw-items-center tw-justify-center tw-bg-white">
|
|
257
|
-
<h3 class="tw-text-grayscale-800 tw-text-lg tw-font-semibold tw-mb-2">Force unsupported preview</h3>
|
|
258
|
-
<p class="tw-text-grayscale-600 tw-text-sm tw-mb-6 tw-text-center tw-max-w-md">
|
|
259
|
-
Simulates the Edge fallback: after a 'network' SpeechRecognitionError, the composable latches
|
|
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.
|
|
262
|
-
</p>
|
|
263
|
-
|
|
264
|
-
<div class="tw-flex tw-gap-3 tw-mb-6">
|
|
265
|
-
<button
|
|
266
|
-
type="button"
|
|
267
|
-
@click="forceUnsupported"
|
|
268
|
-
class="tw-px-4 tw-py-2 tw-rounded tw-bg-error-500 tw-text-white tw-text-sm hover:tw-bg-error-600"
|
|
269
|
-
>
|
|
270
|
-
Force unsupported
|
|
271
|
-
</button>
|
|
272
|
-
<button
|
|
273
|
-
type="button"
|
|
274
|
-
@click="reset"
|
|
275
|
-
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"
|
|
276
|
-
>
|
|
277
|
-
Reset
|
|
278
|
-
</button>
|
|
279
|
-
</div>
|
|
280
|
-
|
|
281
|
-
<div class="tw-w-full tw-max-w-xl tw-bg-grayscale-100 tw-rounded-full tw-px-6 tw-py-3 tw-flex tw-items-center tw-gap-3 tw-border tw-border-grayscale-300 tw-min-h-[64px]">
|
|
282
|
-
<input
|
|
283
|
-
type="text"
|
|
284
|
-
placeholder="Voice input would appear on the right..."
|
|
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"
|
|
286
|
-
/>
|
|
287
|
-
<VoiceToTextButton variant="light" size="md" />
|
|
288
|
-
</div>
|
|
289
|
-
|
|
290
|
-
<p class="tw-text-grayscale-500 tw-text-xs tw-mt-4">
|
|
291
|
-
Tip: open DevTools → Application → Session Storage to see the
|
|
292
|
-
<code class="tw-bg-grayscale-100 tw-px-1 tw-rounded">fe-shared-lib:voice-to-text-disabled</code> key.
|
|
293
|
-
</p>
|
|
294
|
-
</div>
|
|
295
|
-
`,
|
|
296
|
-
});
|
|
297
|
-
|
|
298
|
-
ForceUnsupported.story = {
|
|
299
|
-
name: 'Force Unsupported (Edge Fallback)',
|
|
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,23 +1,17 @@
|
|
|
1
1
|
<template>
|
|
2
2
|
<button
|
|
3
|
+
v-if="isSupported"
|
|
3
4
|
type="button"
|
|
4
|
-
:disabled="disabled
|
|
5
|
-
:aria-label="
|
|
6
|
-
|
|
7
|
-
? 'Voice input not available in this browser'
|
|
8
|
-
: isListening
|
|
9
|
-
? 'Stop voice input'
|
|
10
|
-
: 'Start voice input'
|
|
11
|
-
"
|
|
12
|
-
:title="effectiveTitle"
|
|
5
|
+
:disabled="disabled"
|
|
6
|
+
:aria-label="isListening ? 'Stop voice input' : 'Start voice input'"
|
|
7
|
+
:title="title"
|
|
13
8
|
data-test="voice-to-text-button"
|
|
14
9
|
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"
|
|
15
10
|
:class="[
|
|
16
11
|
sizeClasses,
|
|
17
12
|
variantClasses.button,
|
|
18
13
|
isListening ? 'voice-prompt-listening' : '',
|
|
19
|
-
|
|
20
|
-
unavailableForSession ? 'voice-prompt-unavailable' : '',
|
|
14
|
+
disabled ? 'tw-opacity-50 tw-cursor-not-allowed' : '',
|
|
21
15
|
]"
|
|
22
16
|
:style="isListening ? { '--voice-bg': variantClasses.recordingBg } : {}"
|
|
23
17
|
@click="toggle"
|
|
@@ -34,7 +28,7 @@
|
|
|
34
28
|
<script setup lang="ts">
|
|
35
29
|
import { watch, toRef, computed } from 'vue';
|
|
36
30
|
import Icon from '../Icon/Icon.vue';
|
|
37
|
-
import { useVoiceToText
|
|
31
|
+
import { useVoiceToText } from '../../../useVoiceToText';
|
|
38
32
|
|
|
39
33
|
type ButtonSize = 'sm' | 'md' | 'lg';
|
|
40
34
|
type ButtonVariant = 'light' | 'dark';
|
|
@@ -100,13 +94,8 @@ const variantClasses = computed(() => {
|
|
|
100
94
|
};
|
|
101
95
|
});
|
|
102
96
|
|
|
103
|
-
const unavailableForSession = computed(() => !isSupported.value);
|
|
104
|
-
const effectiveTitle = computed(() => (unavailableForSession.value ? VOICE_UNAVAILABLE_MESSAGE : props.title));
|
|
105
|
-
|
|
106
|
-
// Keep recognition language in sync with prop
|
|
107
97
|
watch(toRef(props, 'lang'), setLang);
|
|
108
98
|
|
|
109
|
-
// Watch for transcript changes and emit appropriate events
|
|
110
99
|
watch(
|
|
111
100
|
[transcript, isFinal],
|
|
112
101
|
([newTranscript, newIsFinal]) => {
|
|
@@ -119,7 +108,6 @@ watch(
|
|
|
119
108
|
{ flush: 'sync' },
|
|
120
109
|
);
|
|
121
110
|
|
|
122
|
-
// Watch for listening state changes
|
|
123
111
|
watch(isListening, (newVal, oldVal) => {
|
|
124
112
|
if (newVal && !oldVal) {
|
|
125
113
|
emit('on-start');
|
|
@@ -129,7 +117,6 @@ watch(isListening, (newVal, oldVal) => {
|
|
|
129
117
|
}
|
|
130
118
|
});
|
|
131
119
|
|
|
132
|
-
// Watch for errors
|
|
133
120
|
watch(error, (newError) => {
|
|
134
121
|
if (newError) {
|
|
135
122
|
emit('on-error', newError);
|
|
@@ -156,23 +143,4 @@ watch(error, (newError) => {
|
|
|
156
143
|
opacity: 0.5;
|
|
157
144
|
}
|
|
158
145
|
}
|
|
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
|
-
}
|
|
178
146
|
</style>
|
package/src/useVoiceToText.ts
CHANGED
|
@@ -25,22 +25,11 @@ interface VoiceToTextState {
|
|
|
25
25
|
error: Ref<string | null>;
|
|
26
26
|
}
|
|
27
27
|
|
|
28
|
-
// Singleton instance and state (lazily initialized)
|
|
29
28
|
let recognition: SpeechRecognition | null = null;
|
|
30
29
|
let isInitialized = false;
|
|
31
30
|
let errorClearTimeout: ReturnType<typeof setTimeout> | null = null;
|
|
32
31
|
let state: VoiceToTextState | null = null;
|
|
33
|
-
let sessionDisabled: Ref<boolean> | null = null;
|
|
34
|
-
let hasReceivedResult = false;
|
|
35
32
|
|
|
36
|
-
// Edge ships window.SpeechRecognition but routes it through Microsoft's Online
|
|
37
|
-
// Speech service, which is gated by a Windows privacy toggle that's commonly
|
|
38
|
-
// off — start() then fires a 'network' error. There's no synchronous capability
|
|
39
|
-
// check, so we latch on the first network error and treat the feature as
|
|
40
|
-
// unsupported for the rest of the session. See issue #161.
|
|
41
|
-
const SESSION_DISABLED_KEY = 'fe-shared-lib:voice-to-text-disabled';
|
|
42
|
-
|
|
43
|
-
// Error message mapping per spec
|
|
44
33
|
const ERROR_MESSAGES: Record<string, string> = {
|
|
45
34
|
'not-allowed': 'Microphone permission was denied. Please allow access.',
|
|
46
35
|
'language-not-supported': 'This language is not supported.',
|
|
@@ -48,22 +37,15 @@ const ERROR_MESSAGES: Record<string, string> = {
|
|
|
48
37
|
'audio-capture': 'No microphone was found or microphone is not working.',
|
|
49
38
|
};
|
|
50
39
|
|
|
51
|
-
export const VOICE_UNAVAILABLE_MESSAGE =
|
|
52
|
-
"Voice input isn't available in this browser. Try Chrome or Safari for the best experience.";
|
|
53
|
-
|
|
54
40
|
const ERROR_CLEAR_DELAY = 5000;
|
|
55
41
|
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
} catch {
|
|
64
|
-
// Permissions API or the 'microphone' descriptor isn't supported (older Safari, Firefox quirks).
|
|
65
|
-
return 'unknown';
|
|
66
|
-
}
|
|
42
|
+
// Edge exposes window.SpeechRecognition but routes it through Microsoft's
|
|
43
|
+
// Online Speech service, which fails inconsistently in ways the spec doesn't
|
|
44
|
+
// cover (network errors, not-allowed even when mic is granted, mic UI
|
|
45
|
+
// disappearing after first use). Treat Edge as unsupported until MS fixes it.
|
|
46
|
+
function isEdgeBrowser(): boolean {
|
|
47
|
+
if (typeof navigator === 'undefined' || !navigator.userAgent) return false;
|
|
48
|
+
return /Edg(e|A|iOS)?\//.test(navigator.userAgent);
|
|
67
49
|
}
|
|
68
50
|
|
|
69
51
|
function getState(): VoiceToTextState {
|
|
@@ -78,54 +60,6 @@ function getState(): VoiceToTextState {
|
|
|
78
60
|
return state;
|
|
79
61
|
}
|
|
80
62
|
|
|
81
|
-
function getSessionDisabled(): Ref<boolean> {
|
|
82
|
-
if (!sessionDisabled) {
|
|
83
|
-
let initial = false;
|
|
84
|
-
try {
|
|
85
|
-
initial = typeof window !== 'undefined' && window.sessionStorage?.getItem(SESSION_DISABLED_KEY) === '1';
|
|
86
|
-
} catch {
|
|
87
|
-
// sessionStorage can throw in sandboxed iframes / disabled-cookie contexts
|
|
88
|
-
}
|
|
89
|
-
sessionDisabled = ref(initial);
|
|
90
|
-
}
|
|
91
|
-
return sessionDisabled;
|
|
92
|
-
}
|
|
93
|
-
|
|
94
|
-
function setSessionDisabled(disabled: boolean): void {
|
|
95
|
-
const flag = getSessionDisabled();
|
|
96
|
-
flag.value = disabled;
|
|
97
|
-
try {
|
|
98
|
-
if (typeof window === 'undefined') return;
|
|
99
|
-
if (disabled) {
|
|
100
|
-
window.sessionStorage?.setItem(SESSION_DISABLED_KEY, '1');
|
|
101
|
-
} else {
|
|
102
|
-
window.sessionStorage?.removeItem(SESSION_DISABLED_KEY);
|
|
103
|
-
}
|
|
104
|
-
} catch {
|
|
105
|
-
// ignore storage failures — in-memory flag still applies for this session
|
|
106
|
-
}
|
|
107
|
-
}
|
|
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
|
-
|
|
121
|
-
/**
|
|
122
|
-
* Test/Storybook helper: force the session-disabled latch on or off without
|
|
123
|
-
* needing a real network error. Not part of the public consumer API.
|
|
124
|
-
*/
|
|
125
|
-
export function setVoiceToTextSessionDisabledForTesting(disabled: boolean): void {
|
|
126
|
-
setSessionDisabled(disabled);
|
|
127
|
-
}
|
|
128
|
-
|
|
129
63
|
/**
|
|
130
64
|
* Singleton composable that wraps the Web Speech API (SpeechRecognition).
|
|
131
65
|
* All calls to useVoiceToText() return the same shared instance.
|
|
@@ -133,18 +67,15 @@ export function setVoiceToTextSessionDisabledForTesting(disabled: boolean): void
|
|
|
133
67
|
export function useVoiceToText(options: UseVoiceToTextOptions = {}): UseVoiceToTextReturn {
|
|
134
68
|
const { lang = 'en-US' } = options;
|
|
135
69
|
|
|
136
|
-
// Get or create shared state
|
|
137
70
|
const { isListening, transcript, isFinal, error } = getState();
|
|
138
71
|
|
|
139
|
-
// Check for browser support
|
|
140
72
|
const SpeechRecognitionCtor: typeof SpeechRecognition | null =
|
|
141
73
|
typeof window !== 'undefined' ? window.SpeechRecognition || window.webkitSpeechRecognition : null;
|
|
142
74
|
|
|
143
|
-
const
|
|
144
|
-
const isSupported = computed(() =>
|
|
75
|
+
const supported = !!SpeechRecognitionCtor && !isEdgeBrowser();
|
|
76
|
+
const isSupported = computed(() => supported);
|
|
145
77
|
|
|
146
|
-
|
|
147
|
-
if (!isInitialized && SpeechRecognitionCtor) {
|
|
78
|
+
if (!isInitialized && supported && SpeechRecognitionCtor) {
|
|
148
79
|
recognition = new SpeechRecognitionCtor();
|
|
149
80
|
recognition.continuous = true;
|
|
150
81
|
recognition.interimResults = true;
|
|
@@ -162,74 +93,22 @@ export function useVoiceToText(options: UseVoiceToTextOptions = {}): UseVoiceToT
|
|
|
162
93
|
}
|
|
163
94
|
}
|
|
164
95
|
|
|
165
|
-
if (finalTranscript.length > 0 || interimTranscript.length > 0) {
|
|
166
|
-
hasReceivedResult = true;
|
|
167
|
-
}
|
|
168
|
-
|
|
169
96
|
transcript.value = finalTranscript + interimTranscript;
|
|
170
97
|
isFinal.value = interimTranscript === '';
|
|
171
98
|
};
|
|
172
99
|
|
|
173
|
-
recognition.onerror =
|
|
100
|
+
recognition.onerror = (event: SpeechRecognitionErrorEvent) => {
|
|
174
101
|
// Suppress no-speech and aborted errors per spec
|
|
175
102
|
if (event.error === 'no-speech' || event.error === 'aborted') {
|
|
176
103
|
return;
|
|
177
104
|
}
|
|
178
105
|
|
|
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
|
-
|
|
191
|
-
if (event.error === 'network') {
|
|
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
|
-
}
|
|
206
|
-
return;
|
|
207
|
-
}
|
|
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
|
-
|
|
226
106
|
const message = ERROR_MESSAGES[event.error] || 'An error occurred with speech recognition.';
|
|
227
107
|
error.value = message;
|
|
228
108
|
|
|
229
109
|
// eslint-disable-next-line no-console
|
|
230
110
|
console.warn('[useVoiceToText]', event.error, message);
|
|
231
111
|
|
|
232
|
-
// Auto-clear error after timeout
|
|
233
112
|
if (errorClearTimeout) {
|
|
234
113
|
clearTimeout(errorClearTimeout);
|
|
235
114
|
}
|
|
@@ -245,7 +124,6 @@ export function useVoiceToText(options: UseVoiceToTextOptions = {}): UseVoiceToT
|
|
|
245
124
|
isInitialized = true;
|
|
246
125
|
}
|
|
247
126
|
|
|
248
|
-
// Update language on existing instance
|
|
249
127
|
if (recognition) {
|
|
250
128
|
recognition.lang = lang;
|
|
251
129
|
}
|
|
@@ -253,7 +131,6 @@ export function useVoiceToText(options: UseVoiceToTextOptions = {}): UseVoiceToT
|
|
|
253
131
|
const start = () => {
|
|
254
132
|
if (!recognition || isListening.value) return;
|
|
255
133
|
|
|
256
|
-
// Clear previous state
|
|
257
134
|
transcript.value = '';
|
|
258
135
|
isFinal.value = false;
|
|
259
136
|
error.value = null;
|
|
@@ -266,7 +143,6 @@ export function useVoiceToText(options: UseVoiceToTextOptions = {}): UseVoiceToT
|
|
|
266
143
|
recognition.start();
|
|
267
144
|
isListening.value = true;
|
|
268
145
|
} catch (e: unknown) {
|
|
269
|
-
// Handle case where recognition is already started
|
|
270
146
|
// eslint-disable-next-line no-console
|
|
271
147
|
console.warn('[useVoiceToText] Failed to start:', (e as Error).message);
|
|
272
148
|
}
|
|
@@ -279,7 +155,6 @@ export function useVoiceToText(options: UseVoiceToTextOptions = {}): UseVoiceToT
|
|
|
279
155
|
try {
|
|
280
156
|
recognition.stop();
|
|
281
157
|
} catch (e: unknown) {
|
|
282
|
-
// Handle case where recognition is already stopped
|
|
283
158
|
// eslint-disable-next-line no-console
|
|
284
159
|
console.warn('[useVoiceToText] Failed to stop:', (e as Error).message);
|
|
285
160
|
}
|