+
{isResponseSendingFinished ? (
<>
{endingCard.type === "endScreen" && (
diff --git a/packages/surveys/src/components/general/survey.tsx b/packages/surveys/src/components/general/survey.tsx
index f69b7d62d4da..fa635acca352 100644
--- a/packages/surveys/src/components/general/survey.tsx
+++ b/packages/surveys/src/components/general/survey.tsx
@@ -253,6 +253,11 @@ export function Survey({
return localSurvey.blocks[0]?.id;
});
+ // True once the user navigated between cards (Next/Back/auto-progress). Moving focus into
+ // the new card is then a response to a user action (safe under WCAG 3.2.x), unlike the
+ // initial render of an embedded survey, where stealing focus from the host page is not.
+ const hasUserNavigatedRef = useRef(false);
+
const [errorType, setErrorType] = useState(undefined);
const [showError, setShowError] = useState(false);
const [isRetrying, setIsRetrying] = useState(false);
@@ -933,8 +938,19 @@ export function Survey({
}
}, [isResponseSendingFinished, isSurveyFinished, onFinished]);
+ // The outgoing card stays visible while the card transition cross-fades, so a
+ // control that kept focus would show its focus ring hanging mid-fade before
+ // vanishing with the card. Drop focus when navigation starts; the incoming
+ // card focuses its first control on mount.
+ const blurOutgoingCard = (): void => {
+ const active = document.activeElement;
+ if (active instanceof HTMLElement) active.blur();
+ };
+
const onSubmit = async (surveyResponseData: TResponseData, responsettc: TResponseTtc) => {
isNavigatingBackRef.current = false;
+ hasUserNavigatedRef.current = true;
+ blurOutgoingCard();
// Get the first responded element ID for tracking
const respondedElementIds = Object.keys(surveyResponseData);
@@ -1033,6 +1049,8 @@ export function Survey({
const onBack = (): void => {
isNavigatingBackRef.current = true;
+ hasUserNavigatedRef.current = true;
+ blurOutgoingCard();
let prevBlockId: string | undefined;
// use history if available
@@ -1133,7 +1151,7 @@ export function Survey({
survey={localSurvey}
languageCode={selectedLanguage}
responseCount={responseCount}
- autoFocusEnabled={autoFocusEnabled}
+ autoFocusEnabled={autoFocusEnabled || hasUserNavigatedRef.current}
isCurrent={offset === 0}
responseData={responseData}
variablesData={currentVariables}
@@ -1152,7 +1170,7 @@ export function Survey({
survey={localSurvey}
endingCard={endingCard}
isRedirectDisabled={isRedirectDisabled}
- autoFocusEnabled={autoFocusEnabled}
+ autoFocusEnabled={autoFocusEnabled || hasUserNavigatedRef.current}
isCurrent={offset === 0}
languageCode={selectedLanguage}
isResponseSendingFinished={isResponseSendingFinished}
@@ -1193,6 +1211,7 @@ export function Survey({
isLastBlock={block.id === localSurvey.blocks[localSurvey.blocks.length - 1].id}
languageCode={selectedLanguage}
autoFocusEnabled={autoFocusEnabled}
+ shouldFocusOnMount={autoFocusEnabled || hasUserNavigatedRef.current}
isBackButtonHidden={localSurvey.isBackButtonHidden}
isAutoProgressingEnabled={localSurvey.isAutoProgressingEnabled}
onOpenExternalURL={onOpenExternalURL}
diff --git a/packages/surveys/src/lib/storage.test.ts b/packages/surveys/src/lib/storage.test.ts
index 0ccfa4792a4b..38a98783007f 100644
--- a/packages/surveys/src/lib/storage.test.ts
+++ b/packages/surveys/src/lib/storage.test.ts
@@ -1,5 +1,26 @@
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
-import { getOriginalFileNameFromUrl } from "./storage";
+import { getImageAltFromUrl, getOriginalFileNameFromUrl } from "./storage";
+
+describe("getImageAltFromUrl", () => {
+ test("decodes URL-encoded file names and strips the extension", () => {
+ const url = "https://example.com/storage/ChatGPT%20Image%20Jun%205%2C%202026--fid--abc123.png";
+ expect(getImageAltFromUrl(url)).toBe("ChatGPT Image Jun 5, 2026");
+ });
+
+ test("handles double-encoded file names", () => {
+ const url = "https://example.com/storage/My%2520Holiday%2520Photo.jpeg";
+ expect(getImageAltFromUrl(url)).toBe("My Holiday Photo");
+ });
+
+ test("replaces separator noise with spaces", () => {
+ const url = "https://example.com/storage/team_photo-2026_final.webp";
+ expect(getImageAltFromUrl(url)).toBe("team photo 2026 final");
+ });
+
+ test("returns empty string when nothing readable is left", () => {
+ expect(getImageAltFromUrl("https://example.com/files/path/")).toBe("");
+ });
+});
describe("getOriginalFileNameFromUrl", () => {
let consoleErrorSpy: any; // Explicitly 'any' to avoid type issues for now
diff --git a/packages/surveys/src/lib/storage.ts b/packages/surveys/src/lib/storage.ts
index 5b0a90297710..db31ecdb0b7d 100644
--- a/packages/surveys/src/lib/storage.ts
+++ b/packages/surveys/src/lib/storage.ts
@@ -1,3 +1,30 @@
+/**
+ * Human-readable alt text derived from a storage URL: the decoded file name
+ * without extension or separator noise, so screen readers don't spell out
+ * strings like "ChatGPT%20Image%20Jun%205.png". Returns "" when nothing
+ * readable is left (callers should fall back to a localized label).
+ */
+export const getImageAltFromUrl = (fileURL: string): string => {
+ let name = getOriginalFileNameFromUrl(fileURL);
+
+ // Stored URLs are sometimes double-encoded; decode until stable.
+ for (let i = 0; i < 3; i++) {
+ try {
+ const decoded = decodeURIComponent(name);
+ if (decoded === name) break;
+ name = decoded;
+ } catch {
+ break;
+ }
+ }
+
+ return name
+ .replace(/\.[a-z0-9]+$/i, "")
+ .replace(/[-_+]+/g, " ")
+ .replace(/\s+/g, " ")
+ .trim();
+};
+
export const getOriginalFileNameFromUrl = (fileURL: string): string => {
try {
const fileNameFromURL = fileURL.startsWith("/storage/")
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 8d6633a983f1..dade29e27133 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -1031,6 +1031,9 @@ importers:
'@tailwindcss/vite':
specifier: 4.2.4
version: 4.2.4(vite@7.3.5(@types/node@25.4.0)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.47.1)(tsx@4.21.0)(yaml@2.9.0))
+ '@testing-library/react':
+ specifier: 16.3.2
+ version: 16.3.2(@testing-library/dom@8.20.1)(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
'@types/react':
specifier: 19.2.14
version: 19.2.14
@@ -1043,6 +1046,9 @@ importers:
'@vitest/coverage-v8':
specifier: 4.1.6
version: 4.1.6(vitest@4.1.6)
+ jsdom:
+ specifier: 29.1.1
+ version: 29.1.1(@noble/hashes@2.0.1)
react:
specifier: 19.2.6
version: 19.2.6
@@ -9375,10 +9381,6 @@ packages:
lru-cache@10.4.3:
resolution: {integrity: sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==}
- lru-cache@11.2.6:
- resolution: {integrity: sha512-ESL2CrkS/2wTPfuend7Zhkzo2u0daGJ/A2VucJOgQ/C48S/zB8MMeMHSGKYpXhIjbPxfuezITkaBH1wqv00DDQ==}
- engines: {node: 20 || >=22}
-
lru-cache@11.3.6:
resolution: {integrity: sha512-Gf/KoL3C/MlI7Bt0PGI9I+TeTC/I6r/csU58N4BSNc4lppLBeKsOdFYkK+dX0ABDUMJNfCHTyPpzwwO21Awd3A==}
engines: {node: 20 || >=22}
@@ -18461,7 +18463,7 @@ snapshots:
'@testing-library/react@16.3.2(@testing-library/dom@8.20.1)(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)':
dependencies:
- '@babel/runtime': 7.28.4
+ '@babel/runtime': 7.28.6
'@testing-library/dom': 8.20.1
react: 19.2.6
react-dom: 19.2.6(react@19.2.6)
@@ -19172,7 +19174,7 @@ snapshots:
obug: 2.1.1
std-env: 4.1.0
tinyrainbow: 3.1.0
- vitest: 4.1.6(@opentelemetry/api@1.9.0)(@types/node@25.4.0)(@vitest/coverage-v8@4.1.6)(happy-dom@20.8.9)(jsdom@29.1.1(@noble/hashes@2.0.1))(vite@8.0.16(@types/node@25.4.0)(esbuild@0.28.1)(jiti@2.6.1)(terser@5.47.1)(tsx@4.21.0)(yaml@2.9.0))
+ vitest: 4.1.6(@opentelemetry/api@1.9.0)(@types/node@25.4.0)(@vitest/coverage-v8@4.1.6)(happy-dom@20.8.9)(jsdom@29.1.1(@noble/hashes@2.0.1))(vite@7.3.5(@types/node@25.4.0)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.47.1)(tsx@4.21.0)(yaml@2.9.0))
'@vitest/eslint-plugin@1.6.17(@typescript-eslint/eslint-plugin@8.57.2(@typescript-eslint/parser@8.57.2(eslint@8.57.1)(typescript@5.9.3))(eslint@8.57.1)(typescript@5.9.3))(eslint@8.57.1)(typescript@5.9.3)(vitest@4.1.6)':
dependencies:
@@ -22401,8 +22403,6 @@ snapshots:
lru-cache@10.4.3: {}
- lru-cache@11.2.6: {}
-
lru-cache@11.3.6: {}
lru-cache@5.1.1:
@@ -23149,7 +23149,7 @@ snapshots:
path-scurry@2.0.2:
dependencies:
- lru-cache: 11.2.6
+ lru-cache: 11.3.6
minipass: 7.1.3
path-to-regexp@8.4.2: {}
@@ -23674,7 +23674,7 @@ snapshots:
react-error-boundary@6.0.0(react@19.2.6):
dependencies:
- '@babel/runtime': 7.28.4
+ '@babel/runtime': 7.28.6
react: 19.2.6
react-grid-layout@2.2.2(react-dom@19.2.6(react@19.2.6))(react@19.2.6):
@@ -24118,7 +24118,7 @@ snapshots:
rtl-css-js@1.16.1:
dependencies:
- '@babel/runtime': 7.28.4
+ '@babel/runtime': 7.28.6
run-applescript@7.1.0: {}
diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml
index aa9e4a8f27ce..d11a84e72c3c 100644
--- a/pnpm-workspace.yaml
+++ b/pnpm-workspace.yaml
@@ -83,7 +83,12 @@ shamefullyHoist: true
sharedWorkspaceLockfile: true
access: public
enablePrePostScripts: true
-legacyPeerDeps: true
+# Flat, npm-style node_modules. Deliberate for now: several packages still rely on hoisted
+# (undeclared) dependencies — e.g. @formbricks/js-core reaches into sibling packages (ENG-1681)
+# and react/react-dom are hoisted from the workspace root (ENG-1693). Moving to the strict
+# `isolated` linker is the long-term goal (ENG-1674); fix the undeclared deps first.
+# `shamefullyHoist` above is left untouched here — it becomes meaningful only under the isolated
+# linker, so it is handled together with that migration rather than in this cleanup.
nodeLinker: hoisted
saveExact: true
# Supply-chain release cooldown: refuse to install package versions published less than