@designcrowd/fe-shared-lib 1.6.9 → 1.6.10-voiceText-1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1896,6 +1896,10 @@ video {
1896
1896
  --tw-text-opacity: 1;
1897
1897
  color: rgb(140 18 59 / var(--tw-text-opacity));
1898
1898
  }
1899
+ .theme-crazyDomains .tw-text-grayscale-400 {
1900
+ --tw-text-opacity: 1;
1901
+ color: rgb(235 238 243 / var(--tw-text-opacity));
1902
+ }
1899
1903
  .theme-crazyDomains .tw-text-grayscale-500 {
1900
1904
  --tw-text-opacity: 1;
1901
1905
  color: rgb(199 204 207 / var(--tw-text-opacity));
@@ -2049,6 +2053,11 @@ video {
2049
2053
  --tw-drop-shadow: drop-shadow(0 1px 1px rgb(0 0 0 / 0.05));
2050
2054
  filter: var(--tw-blur) var(--tw-brightness) var(--tw-contrast) var(--tw-grayscale) var(--tw-hue-rotate) var(--tw-invert) var(--tw-saturate) var(--tw-sepia) var(--tw-drop-shadow);
2051
2055
  }
2056
+ .theme-crazyDomains .tw-transition-all {
2057
+ transition-property: all;
2058
+ transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1);
2059
+ transition-duration: 150ms;
2060
+ }
2052
2061
  .theme-crazyDomains .tw-transition-colors {
2053
2062
  transition-property: color, background-color, border-color, text-decoration-color, fill, stroke;
2054
2063
  transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1);
@@ -2076,6 +2085,10 @@ video {
2076
2085
  .theme-crazyDomains .tw-ease-out {
2077
2086
  transition-timing-function: cubic-bezier(0, 0, 0.2, 1);
2078
2087
  }
2088
+ .theme-crazyDomains .hover\:tw-border-grayscale-300:hover {
2089
+ --tw-border-opacity: 1;
2090
+ border-color: rgb(240 240 245 / var(--tw-border-opacity));
2091
+ }
2079
2092
  .theme-crazyDomains .hover\:tw-border-grayscale-400:hover {
2080
2093
  --tw-border-opacity: 1;
2081
2094
  border-color: rgb(235 238 243 / var(--tw-border-opacity));
@@ -2240,6 +2253,18 @@ video {
2240
2253
  outline: 2px solid transparent;
2241
2254
  outline-offset: 2px;
2242
2255
  }
2256
+ .theme-crazyDomains .focus\:tw-ring-2:focus {
2257
+ --tw-ring-offset-shadow: var(--tw-ring-inset) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color);
2258
+ --tw-ring-shadow: var(--tw-ring-inset) 0 0 0 calc(2px + var(--tw-ring-offset-width)) var(--tw-ring-color);
2259
+ box-shadow: var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow, 0 0 #0000);
2260
+ }
2261
+ .theme-crazyDomains .focus\:tw-ring-primary-500:focus {
2262
+ --tw-ring-opacity: 1;
2263
+ --tw-ring-color: rgb(111 172 47 / var(--tw-ring-opacity));
2264
+ }
2265
+ .theme-crazyDomains .focus\:tw-ring-offset-2:focus {
2266
+ --tw-ring-offset-width: 2px;
2267
+ }
2243
2268
  .theme-crazyDomains .tw-group:hover .group-hover\:tw-text-info-500 {
2244
2269
  --tw-text-opacity: 1;
2245
2270
  color: rgb(0 161 239 / var(--tw-text-opacity));
@@ -1896,6 +1896,10 @@ video {
1896
1896
  --tw-text-opacity: 1;
1897
1897
  color: rgb(136 44 32 / var(--tw-text-opacity));
1898
1898
  }
1899
+ .theme-designCom .tw-text-grayscale-400 {
1900
+ --tw-text-opacity: 1;
1901
+ color: rgb(227 227 227 / var(--tw-text-opacity));
1902
+ }
1899
1903
  .theme-designCom .tw-text-grayscale-500 {
1900
1904
  --tw-text-opacity: 1;
1901
1905
  color: rgb(209 209 209 / var(--tw-text-opacity));
@@ -2049,6 +2053,11 @@ video {
2049
2053
  --tw-drop-shadow: drop-shadow(0 1px 1px rgb(0 0 0 / 0.05));
2050
2054
  filter: var(--tw-blur) var(--tw-brightness) var(--tw-contrast) var(--tw-grayscale) var(--tw-hue-rotate) var(--tw-invert) var(--tw-saturate) var(--tw-sepia) var(--tw-drop-shadow);
2051
2055
  }
2056
+ .theme-designCom .tw-transition-all {
2057
+ transition-property: all;
2058
+ transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1);
2059
+ transition-duration: 150ms;
2060
+ }
2052
2061
  .theme-designCom .tw-transition-colors {
2053
2062
  transition-property: color, background-color, border-color, text-decoration-color, fill, stroke;
2054
2063
  transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1);
@@ -2076,6 +2085,10 @@ video {
2076
2085
  .theme-designCom .tw-ease-out {
2077
2086
  transition-timing-function: cubic-bezier(0, 0, 0.2, 1);
2078
2087
  }
2088
+ .theme-designCom .hover\:tw-border-grayscale-300:hover {
2089
+ --tw-border-opacity: 1;
2090
+ border-color: rgb(237 237 237 / var(--tw-border-opacity));
2091
+ }
2079
2092
  .theme-designCom .hover\:tw-border-grayscale-400:hover {
2080
2093
  --tw-border-opacity: 1;
2081
2094
  border-color: rgb(227 227 227 / var(--tw-border-opacity));
@@ -2240,6 +2253,18 @@ video {
2240
2253
  outline: 2px solid transparent;
2241
2254
  outline-offset: 2px;
2242
2255
  }
2256
+ .theme-designCom .focus\:tw-ring-2:focus {
2257
+ --tw-ring-offset-shadow: var(--tw-ring-inset) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color);
2258
+ --tw-ring-shadow: var(--tw-ring-inset) 0 0 0 calc(2px + var(--tw-ring-offset-width)) var(--tw-ring-color);
2259
+ box-shadow: var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow, 0 0 #0000);
2260
+ }
2261
+ .theme-designCom .focus\:tw-ring-primary-500:focus {
2262
+ --tw-ring-opacity: 1;
2263
+ --tw-ring-color: rgb(63 89 246 / var(--tw-ring-opacity));
2264
+ }
2265
+ .theme-designCom .focus\:tw-ring-offset-2:focus {
2266
+ --tw-ring-offset-width: 2px;
2267
+ }
2243
2268
  .theme-designCom .tw-group:hover .group-hover\:tw-text-info-500 {
2244
2269
  --tw-text-opacity: 1;
2245
2270
  color: rgb(63 89 246 / var(--tw-text-opacity));
@@ -1896,6 +1896,10 @@ video {
1896
1896
  --tw-text-opacity: 1;
1897
1897
  color: rgb(146 38 36 / var(--tw-text-opacity));
1898
1898
  }
1899
+ .theme-designCrowd .tw-text-grayscale-400 {
1900
+ --tw-text-opacity: 1;
1901
+ color: rgb(230 230 230 / var(--tw-text-opacity));
1902
+ }
1899
1903
  .theme-designCrowd .tw-text-grayscale-500 {
1900
1904
  --tw-text-opacity: 1;
1901
1905
  color: rgb(204 204 204 / var(--tw-text-opacity));
@@ -2049,6 +2053,11 @@ video {
2049
2053
  --tw-drop-shadow: drop-shadow(0 1px 1px rgb(0 0 0 / 0.05));
2050
2054
  filter: var(--tw-blur) var(--tw-brightness) var(--tw-contrast) var(--tw-grayscale) var(--tw-hue-rotate) var(--tw-invert) var(--tw-saturate) var(--tw-sepia) var(--tw-drop-shadow);
2051
2055
  }
2056
+ .theme-designCrowd .tw-transition-all {
2057
+ transition-property: all;
2058
+ transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1);
2059
+ transition-duration: 150ms;
2060
+ }
2052
2061
  .theme-designCrowd .tw-transition-colors {
2053
2062
  transition-property: color, background-color, border-color, text-decoration-color, fill, stroke;
2054
2063
  transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1);
@@ -2076,6 +2085,10 @@ video {
2076
2085
  .theme-designCrowd .tw-ease-out {
2077
2086
  transition-timing-function: cubic-bezier(0, 0, 0.2, 1);
2078
2087
  }
2088
+ .theme-designCrowd .hover\:tw-border-grayscale-300:hover {
2089
+ --tw-border-opacity: 1;
2090
+ border-color: rgb(239 239 239 / var(--tw-border-opacity));
2091
+ }
2079
2092
  .theme-designCrowd .hover\:tw-border-grayscale-400:hover {
2080
2093
  --tw-border-opacity: 1;
2081
2094
  border-color: rgb(230 230 230 / var(--tw-border-opacity));
@@ -2240,6 +2253,18 @@ video {
2240
2253
  outline: 2px solid transparent;
2241
2254
  outline-offset: 2px;
2242
2255
  }
2256
+ .theme-designCrowd .focus\:tw-ring-2:focus {
2257
+ --tw-ring-offset-shadow: var(--tw-ring-inset) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color);
2258
+ --tw-ring-shadow: var(--tw-ring-inset) 0 0 0 calc(2px + var(--tw-ring-offset-width)) var(--tw-ring-color);
2259
+ box-shadow: var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow, 0 0 #0000);
2260
+ }
2261
+ .theme-designCrowd .focus\:tw-ring-primary-500:focus {
2262
+ --tw-ring-opacity: 1;
2263
+ --tw-ring-color: rgb(17 151 235 / var(--tw-ring-opacity));
2264
+ }
2265
+ .theme-designCrowd .focus\:tw-ring-offset-2:focus {
2266
+ --tw-ring-offset-width: 2px;
2267
+ }
2243
2268
  .theme-designCrowd .tw-group:hover .group-hover\:tw-text-info-500 {
2244
2269
  --tw-text-opacity: 1;
2245
2270
  color: rgb(17 151 235 / var(--tw-text-opacity));
@@ -237,6 +237,7 @@ import IconLinkInBioFilled from './icons/link-in-bio-filled.vue';
237
237
  import IconMedia from './icons/media.vue';
238
238
  import IconMinusCircleLight from './icons/minus-circle-light.vue';
239
239
  import IconMinus from './icons/minus.vue';
240
+ import IconMicrophone from './icons/microphone.vue';
240
241
  import IconMobile from './icons/mobile.vue';
241
242
  import IconOther from './icons/other.vue';
242
243
  import IconPageButtons from './icons/page-buttons.vue';
@@ -636,6 +637,7 @@ export default {
636
637
  IconContactMessage,
637
638
  IconMinus,
638
639
  IconMinusCircleLight,
640
+ IconMicrophone,
639
641
  IconMobile,
640
642
  IconMug,
641
643
  IconOther,
@@ -0,0 +1,5 @@
1
+ <template>
2
+ <path
3
+ d="M8 1a2 2 0 0 0-2 2v4a2 2 0 1 0 4 0V3a2 2 0 0 0-2-2zM4.5 7a.5.5 0 0 1 .5.5V8a3 3 0 1 0 6 0v-.5a.5.5 0 0 1 1 0V8a4 4 0 0 1-3.5 3.97V14h2a.5.5 0 0 1 0 1h-5a.5.5 0 0 1 0-1h2v-2.03A4 4 0 0 1 4 8v-.5a.5.5 0 0 1 .5-.5z"
4
+ />
5
+ </template>
@@ -0,0 +1,105 @@
1
+ import VoiceToTextButton from './VoiceToTextButton.vue';
2
+
3
+ export default {
4
+ title: 'Components/VoiceToTextButton',
5
+ component: VoiceToTextButton,
6
+ };
7
+
8
+ export const PromptInputDemo = () => ({
9
+ components: { VoiceToTextButton },
10
+ data() {
11
+ return {
12
+ transcript: '',
13
+ isListening: false,
14
+ error: null,
15
+ };
16
+ },
17
+ template: `
18
+ <div class="tw-min-h-[400px] tw-p-8 tw-flex tw-flex-col tw-items-center tw-justify-center" style="background: linear-gradient(180deg, #1a1a2e 0%, #16213e 100%);">
19
+ <h1 class="tw-text-white tw-text-3xl tw-font-bold tw-mb-2 tw-text-center">Design anything with AI</h1>
20
+ <p class="tw-text-grayscale-400 tw-mb-8 tw-text-center">Design a logo, a website, or anything else in seconds with the power of AI</p>
21
+
22
+ <div class="tw-w-full tw-max-w-xl tw-bg-grayscale-800 tw-rounded-full tw-px-6 tw-py-3 tw-flex tw-items-center tw-gap-3">
23
+ <input
24
+ v-model="transcript"
25
+ type="text"
26
+ placeholder="What would you like to design?"
27
+ class="tw-flex-1 tw-bg-transparent tw-border-none tw-text-white tw-placeholder-grayscale-500 focus:tw-outline-none tw-text-base"
28
+ />
29
+ <VoiceToTextButton
30
+ size="md"
31
+ @on-transcript="transcript = $event"
32
+ @on-interim-transcript="transcript = $event"
33
+ @on-start="isListening = true; error = null"
34
+ @on-stop="isListening = false"
35
+ @on-error="error = $event"
36
+ />
37
+ <button class="tw-w-10 tw-h-10 tw-rounded-full tw-bg-secondary-500 tw-flex tw-items-center tw-justify-center tw-text-white hover:tw-bg-secondary-600 tw-transition-colors">
38
+ <svg xmlns="http://www.w3.org/2000/svg" class="tw-w-5 tw-h-5" viewBox="0 0 20 20" fill="currentColor">
39
+ <path fill-rule="evenodd" d="M10.293 3.293a1 1 0 011.414 0l6 6a1 1 0 010 1.414l-6 6a1 1 0 01-1.414-1.414L14.586 11H3a1 1 0 110-2h11.586l-4.293-4.293a1 1 0 010-1.414z" clip-rule="evenodd" />
40
+ </svg>
41
+ </button>
42
+ </div>
43
+
44
+ <div class="tw-mt-4 tw-text-sm tw-h-6">
45
+ <p v-if="isListening" class="tw-text-error-500 tw-font-medium">
46
+ Recording... speak now
47
+ </p>
48
+ <p v-else-if="error" class="tw-text-error-400">
49
+ {{ error }}
50
+ </p>
51
+ </div>
52
+ </div>
53
+ `,
54
+ });
55
+
56
+ PromptInputDemo.story = {
57
+ name: 'Prompt Input (Dark Theme)',
58
+ };
59
+
60
+ export const PromptVariantStates = () => ({
61
+ components: { VoiceToTextButton },
62
+ template: `
63
+ <div class="tw-p-8" style="background: #1a1a2e;">
64
+ <h3 class="tw-text-white tw-text-lg tw-font-semibold tw-mb-6">Prompt Variant - All States</h3>
65
+
66
+ <div class="tw-flex tw-gap-8 tw-items-start">
67
+ <div class="tw-text-center">
68
+ <div class="tw-mb-2 tw-p-4 tw-bg-grayscale-800 tw-rounded-lg tw-inline-block">
69
+ <VoiceToTextButton size="sm" />
70
+ </div>
71
+ <p class="tw-text-grayscale-400 tw-text-xs">Small</p>
72
+ </div>
73
+
74
+ <div class="tw-text-center">
75
+ <div class="tw-mb-2 tw-p-4 tw-bg-grayscale-800 tw-rounded-lg tw-inline-block">
76
+ <VoiceToTextButton size="md" />
77
+ </div>
78
+ <p class="tw-text-grayscale-400 tw-text-xs">Medium (default)</p>
79
+ </div>
80
+
81
+ <div class="tw-text-center">
82
+ <div class="tw-mb-2 tw-p-4 tw-bg-grayscale-800 tw-rounded-lg tw-inline-block">
83
+ <VoiceToTextButton size="lg" />
84
+ </div>
85
+ <p class="tw-text-grayscale-400 tw-text-xs">Large</p>
86
+ </div>
87
+
88
+ <div class="tw-text-center">
89
+ <div class="tw-mb-2 tw-p-4 tw-bg-grayscale-800 tw-rounded-lg tw-inline-block">
90
+ <VoiceToTextButton :disabled="true" />
91
+ </div>
92
+ <p class="tw-text-grayscale-400 tw-text-xs">Disabled</p>
93
+ </div>
94
+ </div>
95
+
96
+ <p class="tw-text-grayscale-500 tw-text-sm tw-mt-6">
97
+ Click any button to see the recording state with red ring animation
98
+ </p>
99
+ </div>
100
+ `,
101
+ });
102
+
103
+ PromptVariantStates.story = {
104
+ name: 'Prompt Variant States',
105
+ };
@@ -0,0 +1,121 @@
1
+ <template>
2
+ <button
3
+ v-if="isSupported"
4
+ type="button"
5
+ :disabled="disabled"
6
+ :aria-label="isListening ? 'Stop voice input' : 'Start voice input'"
7
+ data-test="voice-to-text-button"
8
+ class="voice-prompt-button tw-rounded-full tw-border-2 tw-bg-transparent tw-flex tw-items-center tw-justify-center tw-transition-all tw-duration-200 tw-cursor-pointer focus:tw-outline-none focus:tw-ring-2 focus:tw-ring-offset-2 focus:tw-ring-primary-500"
9
+ :class="[
10
+ sizeClasses,
11
+ isListening ? 'voice-prompt-listening tw-border-error-500' : 'tw-border-grayscale-400 hover:tw-border-grayscale-300',
12
+ disabled ? 'tw-opacity-50 tw-cursor-not-allowed' : '',
13
+ ]"
14
+ @click="toggle"
15
+ >
16
+ <Icon
17
+ name="microphone"
18
+ :size="iconSize"
19
+ :class="isListening ? 'tw-text-error-500' : 'tw-text-grayscale-400'"
20
+ />
21
+ </button>
22
+ </template>
23
+
24
+ <script setup>
25
+ import { watch, toRef, computed } from 'vue';
26
+ import Icon from '../Icon/Icon.vue';
27
+ import { useVoiceToText } from '../../../useVoiceToText';
28
+
29
+ const props = defineProps({
30
+ lang: {
31
+ type: String,
32
+ default: 'en-US',
33
+ },
34
+ disabled: {
35
+ type: Boolean,
36
+ default: false,
37
+ },
38
+ size: {
39
+ type: String,
40
+ default: 'md',
41
+ validator: (value) => ['sm', 'md', 'lg'].includes(value),
42
+ },
43
+ });
44
+
45
+ const sizeClasses = computed(() => {
46
+ const sizes = {
47
+ sm: 'tw-w-8 tw-h-8',
48
+ md: 'tw-w-10 tw-h-10',
49
+ lg: 'tw-w-12 tw-h-12',
50
+ };
51
+ return sizes[props.size];
52
+ });
53
+
54
+ const iconSize = computed(() => {
55
+ const sizes = {
56
+ sm: 'sm',
57
+ md: 'md',
58
+ lg: 'md',
59
+ };
60
+ return sizes[props.size];
61
+ });
62
+
63
+ const emit = defineEmits(['on-transcript', 'on-interim-transcript', 'on-start', 'on-stop', 'on-error']);
64
+
65
+ const { isSupported, isListening, transcript, isFinal, error, toggle, setLang } = useVoiceToText({
66
+ lang: props.lang,
67
+ });
68
+
69
+ // Keep recognition language in sync with prop
70
+ watch(toRef(props, 'lang'), setLang);
71
+
72
+ // Watch for transcript changes and emit appropriate events
73
+ watch(
74
+ [transcript, isFinal],
75
+ ([newTranscript, newIsFinal]) => {
76
+ if (newIsFinal && newTranscript) {
77
+ emit('on-transcript', newTranscript);
78
+ } else if (newTranscript) {
79
+ emit('on-interim-transcript', newTranscript);
80
+ }
81
+ },
82
+ { flush: 'sync' },
83
+ );
84
+
85
+ // Watch for listening state changes
86
+ watch(isListening, (newVal, oldVal) => {
87
+ if (newVal && !oldVal) {
88
+ emit('on-start');
89
+ }
90
+ if (!newVal && oldVal) {
91
+ emit('on-stop');
92
+ }
93
+ });
94
+
95
+ // Watch for errors
96
+ watch(error, (newError) => {
97
+ if (newError) {
98
+ emit('on-error', newError);
99
+ }
100
+ });
101
+ </script>
102
+
103
+ <style scoped>
104
+ .voice-prompt-button {
105
+ -webkit-tap-highlight-color: transparent;
106
+ }
107
+
108
+ .voice-prompt-listening {
109
+ animation: voice-ring-pulse 1.5s ease-in-out infinite;
110
+ }
111
+
112
+ @keyframes voice-ring-pulse {
113
+ 0%,
114
+ 100% {
115
+ box-shadow: 0 0 0 0 rgba(239, 68, 68, 0.4);
116
+ }
117
+ 50% {
118
+ box-shadow: 0 0 0 6px rgba(239, 68, 68, 0);
119
+ }
120
+ }
121
+ </style>
@@ -0,0 +1,174 @@
1
+ import { ref, computed, readonly } from 'vue';
2
+
3
+ // Singleton instance and state (lazily initialized)
4
+ let recognition = null;
5
+ let isInitialized = false;
6
+ let errorClearTimeout = null;
7
+ let state = null;
8
+
9
+ // Error message mapping per spec
10
+ const ERROR_MESSAGES = {
11
+ 'not-allowed': 'Microphone permission was denied. Please allow access.',
12
+ 'language-not-supported': 'This language is not supported.',
13
+ network: 'A network error occurred. Please check your connection.',
14
+ 'audio-capture': 'No microphone was found or microphone is not working.',
15
+ };
16
+
17
+ const ERROR_CLEAR_DELAY = 5000;
18
+
19
+ function getState() {
20
+ if (!state) {
21
+ state = {
22
+ isListening: ref(false),
23
+ transcript: ref(''),
24
+ isFinal: ref(false),
25
+ error: ref(null),
26
+ };
27
+ }
28
+ return state;
29
+ }
30
+
31
+ /**
32
+ * Singleton composable that wraps the Web Speech API (SpeechRecognition).
33
+ * All calls to useVoiceToText() return the same shared instance.
34
+ *
35
+ * @param {Object} options
36
+ * @param {string} options.lang - BCP 47 language tag (default: 'en-US')
37
+ * @returns {Object} Voice-to-text state and controls
38
+ */
39
+ export function useVoiceToText(options = {}) {
40
+ const { lang = 'en-US' } = options;
41
+
42
+ // Get or create shared state
43
+ const { isListening, transcript, isFinal, error } = getState();
44
+
45
+ // Check for browser support
46
+ const SpeechRecognition =
47
+ typeof window !== 'undefined' ? window.SpeechRecognition || window.webkitSpeechRecognition : null;
48
+
49
+ const isSupported = computed(() => !!SpeechRecognition);
50
+
51
+ // Initialize singleton once
52
+ if (!isInitialized && SpeechRecognition) {
53
+ recognition = new SpeechRecognition();
54
+ recognition.continuous = true;
55
+ recognition.interimResults = true;
56
+
57
+ recognition.onresult = (event) => {
58
+ let interimTranscript = '';
59
+ let finalTranscript = '';
60
+
61
+ for (let i = event.resultIndex; i < event.results.length; i += 1) {
62
+ const result = event.results[i];
63
+ if (result.isFinal) {
64
+ finalTranscript += result[0].transcript;
65
+ } else {
66
+ interimTranscript += result[0].transcript;
67
+ }
68
+ }
69
+
70
+ if (finalTranscript) {
71
+ transcript.value = finalTranscript;
72
+ isFinal.value = true;
73
+ } else {
74
+ transcript.value = interimTranscript;
75
+ isFinal.value = false;
76
+ }
77
+ };
78
+
79
+ recognition.onerror = (event) => {
80
+ // Suppress no-speech and aborted errors per spec
81
+ if (event.error === 'no-speech' || event.error === 'aborted') {
82
+ return;
83
+ }
84
+
85
+ const message = ERROR_MESSAGES[event.error] || 'An error occurred with speech recognition.';
86
+ error.value = message;
87
+
88
+ // eslint-disable-next-line no-console
89
+ console.warn('[useVoiceToText]', event.error, message);
90
+
91
+ // Auto-clear error after timeout
92
+ if (errorClearTimeout) {
93
+ clearTimeout(errorClearTimeout);
94
+ }
95
+ errorClearTimeout = setTimeout(() => {
96
+ error.value = null;
97
+ }, ERROR_CLEAR_DELAY);
98
+ };
99
+
100
+ recognition.onend = () => {
101
+ isListening.value = false;
102
+ };
103
+
104
+ isInitialized = true;
105
+ }
106
+
107
+ // Update language on existing instance
108
+ if (recognition) {
109
+ recognition.lang = lang;
110
+ }
111
+
112
+ const start = () => {
113
+ if (!recognition || isListening.value) return;
114
+
115
+ // Clear previous state
116
+ transcript.value = '';
117
+ isFinal.value = false;
118
+ error.value = null;
119
+ if (errorClearTimeout) {
120
+ clearTimeout(errorClearTimeout);
121
+ errorClearTimeout = null;
122
+ }
123
+
124
+ try {
125
+ recognition.start();
126
+ isListening.value = true;
127
+ } catch (e) {
128
+ // Handle case where recognition is already started
129
+ // eslint-disable-next-line no-console
130
+ console.warn('[useVoiceToText] Failed to start:', e.message);
131
+ }
132
+ };
133
+
134
+ const stop = () => {
135
+ if (!recognition || !isListening.value) return;
136
+
137
+ try {
138
+ recognition.stop();
139
+ } catch (e) {
140
+ // Handle case where recognition is already stopped
141
+ // eslint-disable-next-line no-console
142
+ console.warn('[useVoiceToText] Failed to stop:', e.message);
143
+ }
144
+ };
145
+
146
+ const toggle = () => {
147
+ if (isListening.value) {
148
+ stop();
149
+ } else {
150
+ start();
151
+ }
152
+ };
153
+
154
+ const setLang = (newLang) => {
155
+ if (recognition) {
156
+ recognition.lang = newLang;
157
+ }
158
+ };
159
+
160
+ return {
161
+ // State (reactive, read-only)
162
+ isSupported,
163
+ isListening: readonly(isListening),
164
+ transcript: readonly(transcript),
165
+ isFinal: readonly(isFinal),
166
+ error: readonly(error),
167
+
168
+ // Actions
169
+ start,
170
+ stop,
171
+ toggle,
172
+ setLang,
173
+ };
174
+ }