diff --git a/src/components/layout/LayoutSidebarContent.tsx b/src/components/layout/LayoutSidebarContent.tsx
index 06437d5cb..d1d44a397 100644
--- a/src/components/layout/LayoutSidebarContent.tsx
+++ b/src/components/layout/LayoutSidebarContent.tsx
@@ -8,6 +8,7 @@ import { NAV } from "@/constants/navItems";
import { NextLink } from "@/components/NextLink";
import { NavLink } from "@/components/NavLink";
+import { LanguageSelector } from "@/components/LanguageSelector";
import { WindowContext } from "./LayoutContextProvider";
@@ -33,6 +34,7 @@ export const LayoutSidebarContent = ({ children }: { children: ReactNode }) => {
{/* External links */}
+
{
+ const expire = "expires=Thu, 01 Jan 1970 00:00:00 UTC";
+ const parts = window.location.hostname.split(".");
+
+ for (let i = 0; i < parts.length - 1; i++) {
+ const domain = parts.slice(i).join(".");
+ document.cookie = `googtrans=; path=/; domain=${domain}; ${expire}`;
+ document.cookie = `googtrans=; path=/; domain=.${domain}; ${expire}`;
+ }
+
+ // Also clear the host-only (no domain attribute) cookie
+ document.cookie = `googtrans=; path=/; ${expire}`;
+};
+
+/**
+ * Sets the Google Translate language cookie and reloads the page.
+ *
+ * Clears all existing googtrans cookies at every domain level first to
+ * prevent stale cookies (set by Google Translate's own script at a parent
+ * domain) from overriding the new selection on reload.
+ *
+ * @param languageCode - BCP 47 language code (e.g. 'es', 'fr', 'pt', 'zh-CN')
+ *
+ * @example
+ * selectLanguage('es'); // translates to Spanish and reloads
+ */
+export const selectLanguage = (languageCode: string): void => {
+ clearGoogTransCookies();
+ document.cookie = `googtrans=/en/${languageCode}; path=/`;
+ window.location.reload();
+};
+
+/**
+ * Clears the Google Translate cookie and reloads the page, returning
+ * the UI to English.
+ *
+ * @example
+ * resetLanguage(); // returns to English and reloads
+ */
+export const resetLanguage = (): void => {
+ clearGoogTransCookies();
+ window.location.reload();
+};
+
+/**
+ * Reads the active Google Translate language from the `googtrans` cookie.
+ *
+ * @returns The active language code (e.g. 'es') or 'en' if no translation
+ * cookie is set.
+ *
+ * @example
+ * const lang = getActiveLanguage(); // 'es'
+ */
+export const getActiveLanguage = (): string => {
+ const match = document.cookie.match(/googtrans=\/en\/([^;]+)/);
+ return match ? match[1] : "en";
+};
diff --git a/src/middleware.ts b/src/middleware.ts
index 187bc04a7..b94ca61e9 100644
--- a/src/middleware.ts
+++ b/src/middleware.ts
@@ -12,13 +12,13 @@ export function middleware(request: NextRequest) {
const cspHeader = `
default-src 'self';
script-src 'self' 'nonce-${nonce}' 'strict-dynamic' https: 'unsafe-inline' 'unsafe-eval';
- script-src-elem 'self' 'nonce-${nonce}' 'strict-dynamic' https://www.googletagmanager.com/ https: 'unsafe-inline';
+ script-src-elem 'self' 'nonce-${nonce}' 'strict-dynamic' https://www.googletagmanager.com/ https://translate.google.com https://translate.googleapis.com https: 'unsafe-inline';
style-src 'self' https: 'unsafe-inline';
- img-src 'self' https://stellar.creit.tech/wallet-icons/ https://www.googletagmanager.com/ https://storage.herewallet.app/ blob: data:;
+ img-src 'self' https://stellar.creit.tech/wallet-icons/ https://www.googletagmanager.com/ https://storage.herewallet.app/ https://www.gstatic.com/ https://fonts.gstatic.com/ https://www.google.com/ https://translate.google.com https://translate.googleapis.com blob: data:;
connect-src 'self' http://localhost:* https:;
- font-src 'self' https://fonts.gstatic.com/ https://cdn.jsdelivr.net/npm/monaco-editor@0.52.2/min/vs/base/browser/ui/codicons/codicon/codicon.ttf;
+ font-src 'self' https://fonts.gstatic.com/ https://cdn.jsdelivr.net/npm/monaco-editor@0.52.2/min/vs/base/browser/ui/codicons/codicon/codicon.ttf data:;
object-src 'none';
- frame-src 'self' https://connect.trezor.io/ https://hot-labs.org/ https://www.youtube.com/;
+ frame-src 'self' https://connect.trezor.io/ https://hot-labs.org/ https://www.youtube.com/ https://translate.google.com https://translate.googleapis.com;
base-uri 'self';
form-action 'self';
frame-ancestors 'none';
diff --git a/src/styles/globals.scss b/src/styles/globals.scss
index 9d62edfe8..f2dd72d46 100644
--- a/src/styles/globals.scss
+++ b/src/styles/globals.scss
@@ -1483,3 +1483,34 @@
#stella-embed > button {
display: none !important;
}
+
+// =============================================================================
+// Google Translate: suppress injected UI and prevent layout shifts
+// =============================================================================
+
+// Prevent Google Translate from shifting the page down when it injects a
+// top banner. The engine sets `body { top: -40px }` via an injected style;
+// this rule wins back that space.
+body {
+ top: 0 !important;
+}
+
+// Hide the Google Translate toolbar banner and its outer wrapper entirely.
+// We drive language selection through our own cookie-based UI.
+.goog-te-banner-frame,
+.skiptranslate {
+ display: none !important;
+}
+
+// =============================================================================
+// Sidebar: allow the language-selector drop-up to escape the sidebar boundary
+// =============================================================================
+
+// The sidebar has overflow-y: hidden and its parent has overflow: hidden, so
+// any absolutely-positioned popup would be clipped. The language selector
+// component must use `position: fixed` for its dropdown so that it paints
+// above both overflow containers. The rule below is a safety net that removes
+// overflow clipping from the bottom area where the trigger button lives.
+.LabLayout__sidebar--bottom {
+ overflow: visible;
+}
diff --git a/tests/e2e/fundAccountPage.test.ts b/tests/e2e/fundAccountPage.test.ts
index d905061f8..6650d53c6 100644
--- a/tests/e2e/fundAccountPage.test.ts
+++ b/tests/e2e/fundAccountPage.test.ts
@@ -47,7 +47,7 @@ test.describe("[futurenet/testnet] Fund Account Page", () => {
const submitButton = page.getByTestId("networkSelector-submit-button");
// Select 'Futurenet' in the network dropdown list
- await expect(submitButton).toHaveText("Switch to futurenet");
+ await expect(submitButton).toHaveText("Switch to Futurenet");
await expect(submitButton).toBeEnabled();
// Click 'Switch to Futurenet' button
diff --git a/tests/e2e/languageSelector.test.ts b/tests/e2e/languageSelector.test.ts
new file mode 100644
index 000000000..ec4ca8767
--- /dev/null
+++ b/tests/e2e/languageSelector.test.ts
@@ -0,0 +1,91 @@
+import { test, expect } from "@playwright/test";
+
+test.describe("Language selector", () => {
+ test.beforeEach(async ({ page }) => {
+ await page.goto("/");
+ });
+
+ test("Defaults to English", async ({ page }) => {
+ await expect(page.getByTestId("language-selector-trigger")).toHaveText(
+ "Language: English",
+ );
+ await expect(page.getByTestId("language-selector-menu")).toBeHidden();
+ });
+
+ test("Opens dropdown and shows language options", async ({ page }) => {
+ await page.getByTestId("language-selector-trigger").click();
+ await expect(page.getByTestId("language-selector-menu")).toBeVisible();
+ await expect(page.getByTestId("language-selector-option")).toContainText([
+ "English",
+ "Español",
+ "Français",
+ "Português",
+ "Deutsch",
+ "中文 (简体)",
+ "日本語",
+ "한국어",
+ "Русский",
+ "العربية",
+ "हिन्दी",
+ "Türkçe",
+ ]);
+ });
+
+ test("Active language shows Current marker", async ({ page }) => {
+ await page.getByTestId("language-selector-trigger").click();
+
+ const activeOption = page
+ .getByTestId("language-selector-option")
+ .filter({ hasText: "English" });
+
+ await expect(activeOption).toHaveAttribute("data-is-active", "true");
+ await expect(activeOption).toContainText("Current");
+ });
+
+ test("Escape closes the dropdown", async ({ page }) => {
+ await page.getByTestId("language-selector-trigger").click();
+ await expect(page.getByTestId("language-selector-menu")).toBeVisible();
+
+ await page.keyboard.press("Escape");
+ await expect(page.getByTestId("language-selector-menu")).toBeHidden();
+ });
+
+ test("Selecting a language sets the googtrans cookie", async ({
+ page,
+ context,
+ }) => {
+ await page.getByTestId("language-selector-trigger").click();
+
+ await page
+ .getByTestId("language-selector-option")
+ .filter({ hasText: "Español" })
+ .click();
+ await page.waitForLoadState("load");
+
+ const cookies = await context.cookies();
+ const googtrans = cookies.find((c) => c.name === "googtrans");
+ expect(googtrans?.value).toBe("/en/es");
+ });
+
+ test("Selecting English resets the language", async ({ page, context }) => {
+ // Set a non-English language first
+ await page.getByTestId("language-selector-trigger").click();
+ await page
+ .getByTestId("language-selector-option")
+ .filter({ hasText: "Español" })
+ .click();
+ await page.waitForLoadState("load");
+
+ // Reset back to English
+ await page.getByTestId("language-selector-trigger").click();
+ await page
+ .getByTestId("language-selector-option")
+ .filter({ hasText: "English" })
+ .click();
+ await page.waitForLoadState("load");
+
+ const cookies = await context.cookies();
+ const googtrans = cookies.find((c) => c.name === "googtrans");
+ expect(googtrans).toBeUndefined();
+ });
+});
diff --git a/tests/e2e/networkSelector.test.ts b/tests/e2e/networkSelector.test.ts
index 3decc94b1..a36c8969b 100644
--- a/tests/e2e/networkSelector.test.ts
+++ b/tests/e2e/networkSelector.test.ts
@@ -66,7 +66,7 @@ test.describe("Network selector", () => {
// Submit button
const submitButton = page.getByTestId("networkSelector-submit-button");
- await expect(submitButton).toHaveText("Switch to testnet");
+ await expect(submitButton).toHaveText("Switch to Testnet");
await expect(submitButton).toBeDisabled();
});
@@ -110,7 +110,7 @@ test.describe("Network selector", () => {
// Submit button
const submitButton = page.getByTestId("networkSelector-submit-button");
- await expect(submitButton).toHaveText("Switch to futurenet");
+ await expect(submitButton).toHaveText("Switch to Futurenet");
await expect(submitButton).toBeEnabled();
// Network not changed until the submit button is clicked
diff --git a/tests/e2e/savedRequests.test.ts b/tests/e2e/savedRequests.test.ts
index 8678d26d9..0e47ff6d5 100644
--- a/tests/e2e/savedRequests.test.ts
+++ b/tests/e2e/savedRequests.test.ts
@@ -30,7 +30,7 @@ test.describe("Saved Requests Page", () => {
await rpcTab.click();
await expect(
- container.getByText("There are no saved RPC Methods Testnet network"),
+ container.getByText("There are no saved RPC Methods on Testnet network"),
).toBeVisible();
});
diff --git a/tests/e2e/smartContractsStorage.test.ts b/tests/e2e/smartContractsStorage.test.ts
index 0d327ec4a..4f197b003 100644
--- a/tests/e2e/smartContractsStorage.test.ts
+++ b/tests/e2e/smartContractsStorage.test.ts
@@ -52,6 +52,12 @@ test.describe("Smart Contracts: Contract Storage", () => {
.getByTestId("contract-contract-storage")
.getByText("Contract Storage")
.click();
+
+ // Wait for the table to be visible, then a further 150ms so the Dropdown
+ // filter components' 100ms mount setTimeout has fully fired before any
+ // test interacts with the filter headers.
+ await page.getByTestId("contract-storage-table").waitFor({ state: "visible" });
+ await page.waitForTimeout(150);
});
test("Loads", async ({ page }) => {