@designcrowd/fe-shared-lib 1.8.4 → 1.8.5-edge-fallback-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.
Files changed (168) hide show
  1. package/.nvmrc +1 -0
  2. package/.storybook-static/assets/Auth-DT64t5h-.css +1 -0
  3. package/.storybook-static/assets/Auth.stories-C6eXcTSu.js +490 -0
  4. package/.storybook-static/assets/AuthCrazyDomains.stories-DGvEoWCa.js +73 -0
  5. package/.storybook-static/assets/Button-5UzSGUF6.css +1 -0
  6. package/.storybook-static/assets/Button-DKdQT6Fq.js +1 -0
  7. package/.storybook-static/assets/ButtonGroup-DDPXuhxR.css +1 -0
  8. package/.storybook-static/assets/ButtonGroup.stories-DlrYMRSk.js +504 -0
  9. package/.storybook-static/assets/ButtonPrimary-Bu6bXb_c.css +1 -0
  10. package/.storybook-static/assets/ButtonPrimary-BvWW6Duz.js +1 -0
  11. package/.storybook-static/assets/Buttons.stories-CKmd6hkZ.js +761 -0
  12. package/.storybook-static/assets/ButtonsCrazyDomains.stories-DdEuOUrn.js +199 -0
  13. package/.storybook-static/assets/Checkbox.mixin-DkHpdvGa.js +1 -0
  14. package/.storybook-static/assets/Checkbox.stories-DPBUC2Mx.js +246 -0
  15. package/.storybook-static/assets/Checktile.stories-ByaFwplD.js +88 -0
  16. package/.storybook-static/assets/CollapsiblePanel.stories-Y6q3gP9j.js +56 -0
  17. package/.storybook-static/assets/ColorPicker.stories-DdxPUB_R.js +73 -0
  18. package/.storybook-static/assets/CopyToClipboardText.stories-J9qndWxd.js +32 -0
  19. package/.storybook-static/assets/Dropdown.stories-1zKPATii.js +159 -0
  20. package/.storybook-static/assets/DropdownItem-BV-BdThU.css +1 -0
  21. package/.storybook-static/assets/DropdownItem-DA6TdpDb.js +1 -0
  22. package/.storybook-static/assets/FormControl.mixin-DcEBwrV3.js +1 -0
  23. package/.storybook-static/assets/HashRouteModal.stories-BGxvqE22.js +60 -0
  24. package/.storybook-static/assets/HelloBar-CYEZR2kQ.js +1 -0
  25. package/.storybook-static/assets/HelloBar.stories-597Kxj0W.js +342 -0
  26. package/.storybook-static/assets/Icon-C17LFvsP.js +145 -0
  27. package/.storybook-static/assets/Icon.stories-B9iAmcTU.js +151 -0
  28. package/.storybook-static/assets/Icon.stories-CR5vT9H7.js +791 -0
  29. package/.storybook-static/assets/Loader-BWGoT_xC.js +1 -0
  30. package/.storybook-static/assets/LogoBusinessBrandColours-CExzox1Z.js +1 -0
  31. package/.storybook-static/assets/LogoBusinessBrandColours-CeAaMKke.css +1 -0
  32. package/.storybook-static/assets/LogoBusinessBrandColours.stories-kuxAH8B8.js +36 -0
  33. package/.storybook-static/assets/Masonry-C2MNiGg0.css +1 -0
  34. package/.storybook-static/assets/Masonry.stories-CTXJLQ_i.js +71 -0
  35. package/.storybook-static/assets/Modal-CGwEIF5R.css +1 -0
  36. package/.storybook-static/assets/Modal-CydTNprT.js +1 -0
  37. package/.storybook-static/assets/Modal.stories-DZiG5NGM.js +345 -0
  38. package/.storybook-static/assets/Notice.stories-ChOj8CWm.js +222 -0
  39. package/.storybook-static/assets/NumberStepper-Blffv09R.css +1 -0
  40. package/.storybook-static/assets/NumberStepper.stories-CVbKJ_oJ.js +64 -0
  41. package/.storybook-static/assets/PaymentConfigList-BpUMV6cp.css +1 -0
  42. package/.storybook-static/assets/PaymentConfigList.stories-DUD7OZBS.js +130 -0
  43. package/.storybook-static/assets/Picture-B8m1I9xN.js +1 -0
  44. package/.storybook-static/assets/Picture.stories-MMzybhJ6.js +119 -0
  45. package/.storybook-static/assets/Pill-DLXZ_TL8.js +1 -0
  46. package/.storybook-static/assets/Pill.stories-DCP7szJm.js +18 -0
  47. package/.storybook-static/assets/PillBar-os4mJV3M.css +1 -0
  48. package/.storybook-static/assets/PillBar.stories-Bry-zQ6f.js +41 -0
  49. package/.storybook-static/assets/Price-C4GZbDSa.js +1 -0
  50. package/.storybook-static/assets/Price.stories-CMHly9V0.js +337 -0
  51. package/.storybook-static/assets/PromoCard.stories-xsbFtADE.js +299 -0
  52. package/.storybook-static/assets/PublishBrandPageModal-Q9-mNG1q.css +1 -0
  53. package/.storybook-static/assets/PublishBrandPageModal.stories-C9XzW_1m.js +324 -0
  54. package/.storybook-static/assets/SearchBar.stories-DaIneOSz.js +12 -0
  55. package/.storybook-static/assets/Select-DnioWQmi.css +1 -0
  56. package/.storybook-static/assets/Select.stories-BmGYB4pw.js +108 -0
  57. package/.storybook-static/assets/SellDomainNameList.fixtures-LC6fjr_b.js +1 -0
  58. package/.storybook-static/assets/SellDomainNameListModal-DH6khE10.css +1 -0
  59. package/.storybook-static/assets/SellDomainNameListModal-ymtVclFP.js +1 -0
  60. package/.storybook-static/assets/SellDomainNameListModal.stories-DvGvylgx.js +71 -0
  61. package/.storybook-static/assets/SellDomainNameListSearchResult-Cpxq0jDA.css +1 -0
  62. package/.storybook-static/assets/SellDomainNameListSearchResult-D-1CrQyf.js +1 -0
  63. package/.storybook-static/assets/SellDomainNameSearchWithResults-bX--zu97.js +1 -0
  64. package/.storybook-static/assets/SellDomainNameSearchWithResults.stories-DRUJjSdH.js +37 -0
  65. package/.storybook-static/assets/SellDomainNameWidget.stories-CC3LX10s.js +36 -0
  66. package/.storybook-static/assets/SignIn-CPjf8_2O.css +1 -0
  67. package/.storybook-static/assets/SignIn-DI0DSDFe.js +1 -0
  68. package/.storybook-static/assets/Slider-Cog2FFdj.css +1 -0
  69. package/.storybook-static/assets/Slider.stories-B2KGwnJy.js +141 -0
  70. package/.storybook-static/assets/SparkleIcon.stories-Dk904hVE.js +547 -0
  71. package/.storybook-static/assets/StarRating-BtKh7pzm.css +1 -0
  72. package/.storybook-static/assets/StarRating.stories-d2mgOuo2.js +45 -0
  73. package/.storybook-static/assets/TabMenu.stories-Cg2yenqj.js +47 -0
  74. package/.storybook-static/assets/TextCopyField-B66NKTk_.js +1 -0
  75. package/.storybook-static/assets/TextCopyField.stories-B4_ZlfLU.js +47 -0
  76. package/.storybook-static/assets/TextInput-CMoUjT_5.js +1 -0
  77. package/.storybook-static/assets/TextInput.stories-oyyxxf3j.js +233 -0
  78. package/.storybook-static/assets/Textarea.stories-BvhZR6K2.js +207 -0
  79. package/.storybook-static/assets/Toggle.stories-yT5-rL2k.js +161 -0
  80. package/.storybook-static/assets/Tooltip-DyXIgFQH.css +1 -0
  81. package/.storybook-static/assets/Tooltip-ZukyujG5.js +1 -0
  82. package/.storybook-static/assets/Tooltip.stories-sJFylRS_.js +953 -0
  83. package/.storybook-static/assets/UploadYourLogoApplication-Dmw8QcH3.css +1 -0
  84. package/.storybook-static/assets/UploadYourLogoApplication.stories-C9AvzHO_.js +186 -0
  85. package/.storybook-static/assets/UploadYourLogoDropzone-B1ffcicv.js +24 -0
  86. package/.storybook-static/assets/UploadYourLogoDropzone-DQqACf-e.css +1 -0
  87. package/.storybook-static/assets/UploadYourLogoDropzone.stories-D1Dt2ord.js +55 -0
  88. package/.storybook-static/assets/UploadedLogoSearchResultCard.stories-D8oF1Yrx.js +79 -0
  89. package/.storybook-static/assets/WebsiteContextualUpgradeModal-8u1zOZrW.css +1 -0
  90. package/.storybook-static/assets/WebsiteContextualUpgradeModal.stories-mtcvWOAg.js +211 -0
  91. package/.storybook-static/assets/_commonjsHelpers-CE1G-McA.js +1 -0
  92. package/.storybook-static/assets/_plugin-vue_export-helper-DlAUqK2U.js +1 -0
  93. package/.storybook-static/assets/api-lSJGRrF2.js +1 -0
  94. package/.storybook-static/assets/axe-DrS73Vi2.js +20 -0
  95. package/.storybook-static/assets/brand-crowd-api.client-D45NKshX.js +1 -0
  96. package/.storybook-static/assets/bundled-translations-BoWhEDU_.js +1 -0
  97. package/.storybook-static/assets/bundled-translations.de-DE-C4lqla4O.js +1 -0
  98. package/.storybook-static/assets/bundled-translations.es-ES-BxMIllUH.js +1 -0
  99. package/.storybook-static/assets/bundled-translations.fr-CA-MxZpyz0w.js +1 -0
  100. package/.storybook-static/assets/bundled-translations.fr-FR-N7UPCZVr.js +1 -0
  101. package/.storybook-static/assets/bundled-translations.pt-BR-C8tscYuG.js +1 -0
  102. package/.storybook-static/assets/bundled-translations.pt-PT-Dszj5Xfa.js +1 -0
  103. package/.storybook-static/assets/carousel-BelyIYOK.css +1 -0
  104. package/.storybook-static/assets/carousel.stories-CJw3-Iy6.js +668 -0
  105. package/.storybook-static/assets/event-constants-CMO9VQVu.js +1 -0
  106. package/.storybook-static/assets/iframe-B3A6OXQU.js +1104 -0
  107. package/.storybook-static/assets/index-B-eiLVzF.js +7 -0
  108. package/.storybook-static/assets/index-QquxUozE.js +6 -0
  109. package/.storybook-static/assets/matchers-5TDFFDYO-HJu_DfWo.js +14 -0
  110. package/.storybook-static/assets/mediaQueryMixin-CISNqd93.js +1 -0
  111. package/.storybook-static/assets/preload-helper-PPVm8Dsz.js +1 -0
  112. package/.storybook-static/assets/tracking-ATsLLehC.js +1 -0
  113. package/.storybook-static/css/tailwind-brandCrowd.css +2508 -0
  114. package/.storybook-static/css/tailwind-brandPage.css +2188 -0
  115. package/.storybook-static/css/tailwind-crazyDomains.css +2508 -0
  116. package/.storybook-static/css/tailwind-designCom.css +2508 -0
  117. package/.storybook-static/css/tailwind-designCrowd.css +2508 -0
  118. package/.storybook-static/favicon-wrapper.svg +46 -0
  119. package/.storybook-static/favicon.svg +1 -0
  120. package/.storybook-static/iframe.html +713 -0
  121. package/.storybook-static/index.html +148 -0
  122. package/.storybook-static/index.json +1 -0
  123. package/.storybook-static/nunito-sans-bold-italic.woff2 +0 -0
  124. package/.storybook-static/nunito-sans-bold.woff2 +0 -0
  125. package/.storybook-static/nunito-sans-italic.woff2 +0 -0
  126. package/.storybook-static/nunito-sans-regular.woff2 +0 -0
  127. package/.storybook-static/project.json +1 -0
  128. package/.storybook-static/sb-addons/a11y-1/manager-bundle.js +57 -0
  129. package/.storybook-static/sb-addons/links-2/manager-bundle.js +3 -0
  130. package/.storybook-static/sb-addons/storybook-core-server-presets-0/common-manager-bundle.js +628 -0
  131. package/.storybook-static/sb-addons/themes-3/manager-bundle.js +3 -0
  132. package/.storybook-static/sb-common-assets/favicon-wrapper.svg +46 -0
  133. package/.storybook-static/sb-common-assets/favicon.svg +1 -0
  134. package/.storybook-static/sb-common-assets/nunito-sans-bold-italic.woff2 +0 -0
  135. package/.storybook-static/sb-common-assets/nunito-sans-bold.woff2 +0 -0
  136. package/.storybook-static/sb-common-assets/nunito-sans-italic.woff2 +0 -0
  137. package/.storybook-static/sb-common-assets/nunito-sans-regular.woff2 +0 -0
  138. package/.storybook-static/sb-manager/globals-runtime.js +77935 -0
  139. package/.storybook-static/sb-manager/globals.js +24 -0
  140. package/.storybook-static/sb-manager/manager-stores.js +23 -0
  141. package/.storybook-static/sb-manager/runtime.js +20404 -0
  142. package/.storybook-static/vite-inject-mocker-entry.js +2 -0
  143. package/CLAUDE.md +12 -4
  144. package/README.md +20 -7
  145. package/dist/css/tailwind-brandCrowd.css +4 -59
  146. package/dist/css/tailwind-brandPage.css +3 -46
  147. package/dist/css/tailwind-crazyDomains.css +4 -59
  148. package/dist/css/tailwind-designCom.css +4 -59
  149. package/dist/css/tailwind-designCrowd.css +4 -59
  150. package/index.js +1 -0
  151. package/package.json +1 -1
  152. package/public/css/tailwind-brandCrowd.css +2563 -0
  153. package/public/css/tailwind-brandPage.css +2231 -0
  154. package/public/css/tailwind-crazyDomains.css +2563 -0
  155. package/public/css/tailwind-designCom.css +2563 -0
  156. package/public/css/tailwind-designCrowd.css +2563 -0
  157. package/scripts/publish-uat.sh +149 -0
  158. package/src/atoms/components/VoiceToTextButton/VoiceToTextButton.stories.ts +122 -0
  159. package/src/atoms/components/VoiceToTextButton/VoiceToTextButton.vue +36 -5
  160. package/src/useVoiceToText.ts +127 -2
  161. package/.claude/skills/playwright-cli/SKILL.md +0 -278
  162. package/.claude/skills/playwright-cli/references/request-mocking.md +0 -87
  163. package/.claude/skills/playwright-cli/references/running-code.md +0 -232
  164. package/.claude/skills/playwright-cli/references/session-management.md +0 -169
  165. package/.claude/skills/playwright-cli/references/storage-state.md +0 -275
  166. package/.claude/skills/playwright-cli/references/test-generation.md +0 -88
  167. package/.claude/skills/playwright-cli/references/tracing.md +0 -139
  168. package/.claude/skills/playwright-cli/references/video-recording.md +0 -43
@@ -0,0 +1,149 @@
1
+ #!/usr/bin/env bash
2
+ #
3
+ # publish-uat.sh — publish a UAT-suffixed version of @designcrowd/fe-shared-lib
4
+ #
5
+ # Usage: ./scripts/publish-uat.sh <suffix>
6
+ # Example: ./scripts/publish-uat.sh edge-fallback-2
7
+ #
8
+ # Reads the npm publish token from $NPM_PUBLISH_TOKEN_FILE
9
+ # (default ~/.config/designcrowd/npm-publish-token, mode 600).
10
+ # The token is never written into the repo.
11
+ #
12
+ # Workflow:
13
+ # 1. Validate branch (refuse master) and suffix.
14
+ # 2. Compute new version = <base>-<suffix>, refuse if already published.
15
+ # 3. Bump package.json, run bundle-translation.
16
+ # 4. npm pack — produces a tarball.
17
+ # 5. Secret scan: dangerous filenames + token-shaped strings + the
18
+ # literal token bytes from the token file (if it leaked into the
19
+ # tree somehow, this catches it).
20
+ # 6. npm publish <tarball> via a temp .npmrc in /tmp (mode 600,
21
+ # removed on exit).
22
+ # 7. git add + commit the version bump.
23
+ #
24
+ # Anything in steps 4-6 that fails leaves the working tree dirty so you
25
+ # can investigate. The commit only lands after the registry confirms
26
+ # the upload.
27
+
28
+ set -euo pipefail
29
+
30
+ PKG="@designcrowd/fe-shared-lib"
31
+ TOKEN_FILE="${NPM_PUBLISH_TOKEN_FILE:-$HOME/.config/designcrowd/npm-publish-token}"
32
+
33
+ abort() { echo "ABORT: $*" >&2; exit 1; }
34
+
35
+ # --- args ---
36
+ SUFFIX="${1:-}"
37
+ [[ -z "$SUFFIX" ]] && abort "usage: $0 <suffix> e.g. $0 edge-fallback-2"
38
+ # Semver pre-release identifiers are [0-9A-Za-z-], dot-separated. Reject underscores etc.
39
+ # so we fail before npm rejects the version mid-flight with a dirty tree.
40
+ [[ "$SUFFIX" =~ ^[A-Za-z0-9-]+(\.[A-Za-z0-9-]+)*$ ]] || abort "suffix must match semver pre-release: alphanumerics/hyphens, optionally dot-separated"
41
+
42
+ # --- repo state ---
43
+ cd "$(git rev-parse --show-toplevel)"
44
+ BRANCH=$(git symbolic-ref --short HEAD)
45
+ [[ "$BRANCH" == "master" || "$BRANCH" == "main" ]] && abort "refusing to publish from $BRANCH"
46
+
47
+ # --- preconditions: files we'll commit must be clean, so unrelated edits don't get swept into the version-bump commit ---
48
+ [[ -z "$(git status --porcelain -- package.json)" ]] || abort "package.json has uncommitted changes; commit or stash before publishing"
49
+ if [[ -d src/bundles ]] && [[ -n "$(git status --porcelain -- src/bundles)" ]]; then
50
+ abort "src/bundles has uncommitted changes; commit or stash before publishing"
51
+ fi
52
+
53
+ # --- token ---
54
+ [[ -f "$TOKEN_FILE" ]] || abort "no token at $TOKEN_FILE (set NPM_PUBLISH_TOKEN_FILE to override)"
55
+ TOKEN=$(tr -d '[:space:]' < "$TOKEN_FILE")
56
+ [[ -n "$TOKEN" ]] || abort "token file is empty: $TOKEN_FILE"
57
+
58
+ # --- toolchain version check ---
59
+ NODE_V=$(node -v)
60
+ NPM_V=$(npm -v)
61
+ echo "==> node $NODE_V, npm $NPM_V"
62
+ if [[ -f .nvmrc ]]; then
63
+ WANT=$(tr -d '[:space:]v' < .nvmrc)
64
+ WANT_MAJOR="${WANT%%.*}"
65
+ HAVE_MAJOR=$(echo "${NODE_V#v}" | cut -d. -f1)
66
+ if [[ "$WANT_MAJOR" != "$HAVE_MAJOR" ]]; then
67
+ echo "WARNING: .nvmrc wants Node $WANT (major $WANT_MAJOR), you're on $NODE_V. Consider 'nvm use'." >&2
68
+ fi
69
+ fi
70
+
71
+ # --- compute version ---
72
+ BASE=$(node -p "require('./package.json').version.replace(/-.*/, '')")
73
+ NEW="$BASE-$SUFFIX"
74
+
75
+ if npm view "$PKG@$NEW" version >/dev/null 2>&1; then
76
+ abort "$PKG@$NEW already published — pick a new suffix"
77
+ fi
78
+
79
+ echo "==> publishing $PKG@$NEW from branch $BRANCH"
80
+
81
+ # --- bump + bundle ---
82
+ node -e "const fs=require('fs');const p=require('./package.json');p.version='$NEW';fs.writeFileSync('./package.json', JSON.stringify(p, null, 2)+'\n');"
83
+ echo "==> running bundle-translation"
84
+ npm run --silent bundle-translation >/dev/null
85
+
86
+ # --- cleanup trap: registered before any artifacts so a failed mktemp/pack still tidies up ---
87
+ SCAN_DIR=""; TMP_NPMRC=""; TARBALL=""
88
+ cleanup() {
89
+ [[ -n "$SCAN_DIR" ]] && rm -rf "$SCAN_DIR"
90
+ [[ -n "$TMP_NPMRC" ]] && rm -f "$TMP_NPMRC"
91
+ [[ -n "$TARBALL" ]] && rm -f "$TARBALL"
92
+ return 0
93
+ }
94
+ trap cleanup EXIT
95
+
96
+ SCAN_DIR=$(mktemp -d)
97
+ TMP_NPMRC=$(mktemp /tmp/fe-shared-lib-publish.XXXXXX.npmrc)
98
+ chmod 600 "$TMP_NPMRC"
99
+
100
+ # --- pack ---
101
+ echo "==> packing"
102
+ TARBALL=$(npm pack --silent)
103
+ [[ -f "$TARBALL" ]] || abort "npm pack did not produce a tarball"
104
+
105
+ # --- secret scan: filenames ---
106
+ echo "==> scanning $TARBALL"
107
+ DANGEROUS_PATHS='(^|/)(\.env(\..+)?|\.npmrc|\.npm-publish-token|id_rsa.*|id_ed25519.*|.*\.pem|.*\.key|.*[._-]token([._-].*)?|.*[._-]secret([._-].*)?|.*[._-]credentials?([._-].*)?)$|(^|/)\.(aws|ssh|gnupg)/'
108
+ if BAD=$(tar tzf "$TARBALL" | grep -E -i "$DANGEROUS_PATHS" || true); [[ -n "$BAD" ]]; then
109
+ echo "$BAD" >&2
110
+ abort "dangerous filenames in tarball"
111
+ fi
112
+
113
+ # --- secret scan: contents ---
114
+ tar xzf "$TARBALL" -C "$SCAN_DIR"
115
+
116
+ # Token-shaped patterns. Length floors (e.g. {30,}) keep documentation placeholders
117
+ # like 'npm_TOKEN' or '${NPM_TOKEN}' from tripping the check.
118
+ SECRET_PATTERNS='(npm_[A-Za-z0-9]{30,}|AKIA[0-9A-Z]{16}|ASIA[0-9A-Z]{16}|gh[pousr]_[A-Za-z0-9]{20,}|xox[baprs]-[A-Za-z0-9-]{10,}|sk_live_[A-Za-z0-9]{20,}|-----BEGIN [A-Z ]*PRIVATE KEY-----)'
119
+ if HITS=$(grep -REn -I -E "$SECRET_PATTERNS" "$SCAN_DIR" 2>/dev/null || true); [[ -n "$HITS" ]]; then
120
+ echo "$HITS" >&2
121
+ abort "secret-shaped string in tarball"
122
+ fi
123
+
124
+ # Belt-and-braces: the token's own bytes. If our publish token ever
125
+ # lands in a tracked file by accident, this catches it regardless of
126
+ # pattern-matching. Pass the token via stdin (-f -) so it doesn't appear
127
+ # in the process arglist (visible to other users via `ps`).
128
+ if printf '%s' "$TOKEN" | grep -RFqf - "$SCAN_DIR" 2>/dev/null; then
129
+ abort "publish token bytes appear in tarball — DO NOT publish"
130
+ fi
131
+
132
+ FILES=$(tar tzf "$TARBALL" | wc -l | tr -d ' ')
133
+ SIZE=$(du -h "$TARBALL" | cut -f1)
134
+ echo "==> scan clean. $FILES files, $SIZE"
135
+
136
+ # --- publish ---
137
+ printf '//registry.npmjs.org/:_authToken=%s\n' "$TOKEN" > "$TMP_NPMRC"
138
+ echo "==> publishing"
139
+ npm publish "$TARBALL" --userconfig "$TMP_NPMRC"
140
+
141
+ # --- commit bump ---
142
+ git add -- package.json
143
+ [[ -d src/bundles ]] && git add -- src/bundles
144
+ git commit -m "bump version to $NEW" >/dev/null
145
+ echo
146
+ echo "Published $PKG@$NEW (commit $(git rev-parse --short HEAD))"
147
+ echo
148
+ echo "Install in BrandCrowd.Net:"
149
+ echo " npm i $PKG@$NEW"
@@ -1,4 +1,5 @@
1
1
  import VoiceToTextButton from './VoiceToTextButton.vue';
2
+ import { setVoiceToTextSessionDisabledForTesting } from '../../../useVoiceToText';
2
3
 
3
4
  export default {
4
5
  title: 'Components/VoiceToTextButton',
@@ -240,3 +241,124 @@ export const SideBySide = () => ({
240
241
  SideBySide.story = {
241
242
  name: 'Side by Side Comparison',
242
243
  };
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,16 +1,23 @@
1
1
  <template>
2
2
  <button
3
- v-if="isSupported"
4
3
  type="button"
5
- :disabled="disabled"
6
- :aria-label="isListening ? 'Stop voice input' : 'Start voice input'"
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>
@@ -30,6 +30,15 @@ let recognition: SpeechRecognition | null = null;
30
30
  let isInitialized = false;
31
31
  let errorClearTimeout: ReturnType<typeof setTimeout> | null = null;
32
32
  let state: VoiceToTextState | null = null;
33
+ let sessionDisabled: Ref<boolean> | null = null;
34
+ let hasReceivedResult = false;
35
+
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';
33
42
 
34
43
  // Error message mapping per spec
35
44
  const ERROR_MESSAGES: Record<string, string> = {
@@ -39,8 +48,24 @@ const ERROR_MESSAGES: Record<string, string> = {
39
48
  'audio-capture': 'No microphone was found or microphone is not working.',
40
49
  };
41
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
+
42
54
  const ERROR_CLEAR_DELAY = 5000;
43
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
+
44
69
  function getState(): VoiceToTextState {
45
70
  if (!state) {
46
71
  state = {
@@ -53,6 +78,54 @@ function getState(): VoiceToTextState {
53
78
  return state;
54
79
  }
55
80
 
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
+
56
129
  /**
57
130
  * Singleton composable that wraps the Web Speech API (SpeechRecognition).
58
131
  * All calls to useVoiceToText() return the same shared instance.
@@ -67,7 +140,8 @@ export function useVoiceToText(options: UseVoiceToTextOptions = {}): UseVoiceToT
67
140
  const SpeechRecognitionCtor: typeof SpeechRecognition | null =
68
141
  typeof window !== 'undefined' ? window.SpeechRecognition || window.webkitSpeechRecognition : null;
69
142
 
70
- const isSupported = computed(() => !!SpeechRecognitionCtor);
143
+ const sessionDisabledRef = getSessionDisabled();
144
+ const isSupported = computed(() => !!SpeechRecognitionCtor && !sessionDisabledRef.value);
71
145
 
72
146
  // Initialize singleton once
73
147
  if (!isInitialized && SpeechRecognitionCtor) {
@@ -88,16 +162,67 @@ export function useVoiceToText(options: UseVoiceToTextOptions = {}): UseVoiceToT
88
162
  }
89
163
  }
90
164
 
165
+ if (finalTranscript.length > 0 || interimTranscript.length > 0) {
166
+ hasReceivedResult = true;
167
+ }
168
+
91
169
  transcript.value = finalTranscript + interimTranscript;
92
170
  isFinal.value = interimTranscript === '';
93
171
  };
94
172
 
95
- recognition.onerror = (event: SpeechRecognitionErrorEvent) => {
173
+ recognition.onerror = async (event: SpeechRecognitionErrorEvent) => {
96
174
  // Suppress no-speech and aborted errors per spec
97
175
  if (event.error === 'no-speech' || event.error === 'aborted') {
98
176
  return;
99
177
  }
100
178
 
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
+
101
226
  const message = ERROR_MESSAGES[event.error] || 'An error occurred with speech recognition.';
102
227
  error.value = message;
103
228