@designcrowd/fe-shared-lib 1.8.5-edge-fallback-5 → 1.8.5-edge-fallback-6

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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@designcrowd/fe-shared-lib",
3
- "version": "1.8.5-edge-fallback-5",
3
+ "version": "1.8.5-edge-fallback-6",
4
4
  "scripts": {
5
5
  "start": "run-p storybook watch:translation",
6
6
  "build": "npm run build:css --production",
@@ -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 || 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"
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
- (disabled || unavailableForSession) ? 'tw-opacity-50 tw-cursor-not-allowed' : '',
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, VOICE_UNAVAILABLE_MESSAGE } from '../../../useVoiceToText';
31
+ import { useVoiceToText } from '../../../useVoiceToText';
38
32
 
39
33
  type ButtonSize = 'sm' | 'md' | 'lg';
40
34
  type ButtonVariant = 'light' | 'dark';
@@ -100,9 +94,6 @@ 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
97
  // Keep recognition language in sync with prop
107
98
  watch(toRef(props, 'lang'), setLang);
108
99
 
@@ -156,23 +147,4 @@ watch(error, (newError) => {
156
147
  opacity: 0.5;
157
148
  }
158
149
  }
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
150
  </style>
@@ -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
- 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
- }
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.
@@ -140,11 +74,11 @@ export function useVoiceToText(options: UseVoiceToTextOptions = {}): UseVoiceToT
140
74
  const SpeechRecognitionCtor: typeof SpeechRecognition | null =
141
75
  typeof window !== 'undefined' ? window.SpeechRecognition || window.webkitSpeechRecognition : null;
142
76
 
143
- const sessionDisabledRef = getSessionDisabled();
144
- const isSupported = computed(() => !!SpeechRecognitionCtor && !sessionDisabledRef.value);
77
+ const supported = !!SpeechRecognitionCtor && !isEdgeBrowser();
78
+ const isSupported = computed(() => supported);
145
79
 
146
80
  // Initialize singleton once
147
- if (!isInitialized && SpeechRecognitionCtor) {
81
+ if (!isInitialized && supported && SpeechRecognitionCtor) {
148
82
  recognition = new SpeechRecognitionCtor();
149
83
  recognition.continuous = true;
150
84
  recognition.interimResults = true;
@@ -162,67 +96,16 @@ export function useVoiceToText(options: UseVoiceToTextOptions = {}): UseVoiceToT
162
96
  }
163
97
  }
164
98
 
165
- if (finalTranscript.length > 0 || interimTranscript.length > 0) {
166
- hasReceivedResult = true;
167
- }
168
-
169
99
  transcript.value = finalTranscript + interimTranscript;
170
100
  isFinal.value = interimTranscript === '';
171
101
  };
172
102
 
173
- recognition.onerror = async (event: SpeechRecognitionErrorEvent) => {
103
+ recognition.onerror = (event: SpeechRecognitionErrorEvent) => {
174
104
  // Suppress no-speech and aborted errors per spec
175
105
  if (event.error === 'no-speech' || event.error === 'aborted') {
176
106
  return;
177
107
  }
178
108
 
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
109
  const message = ERROR_MESSAGES[event.error] || 'An error occurred with speech recognition.';
227
110
  error.value = message;
228
111