diff --git a/src/frontend/src/CustomNodes/GenericNode/components/OutputComponent/index.tsx b/src/frontend/src/CustomNodes/GenericNode/components/OutputComponent/index.tsx
index a67d7cfd4f..49cc2a6d6f 100644
--- a/src/frontend/src/CustomNodes/GenericNode/components/OutputComponent/index.tsx
+++ b/src/frontend/src/CustomNodes/GenericNode/components/OutputComponent/index.tsx
@@ -76,7 +76,7 @@ export default function OutputComponent({
unstyled
role="combobox"
ref={refButton}
- className="no-focus-visible group flex items-center gap-2"
+ className="focus-visible:ring-1 focus-visible:ring-ring focus-visible:ring-offset-2 group flex items-center gap-2"
data-testid={`dropdown-output-${outputName?.toLowerCase()}`}
>
diff --git a/src/frontend/src/components/core/dropdownComponent/index.tsx b/src/frontend/src/components/core/dropdownComponent/index.tsx
index a1319f8977..f1bf44648d 100644
--- a/src/frontend/src/components/core/dropdownComponent/index.tsx
+++ b/src/frontend/src/components/core/dropdownComponent/index.tsx
@@ -380,7 +380,7 @@ export default function Dropdown({
editNode
? "dropdown-component-outline input-edit-node"
: "dropdown-component-false-outline py-2",
- "no-focus-visible w-full justify-between font-normal disabled:bg-muted disabled:text-muted-foreground",
+ "focus-visible:ring-1 focus-visible:ring-ring focus-visible:ring-offset-2 w-full justify-between font-normal disabled:bg-muted disabled:text-muted-foreground",
)}
>
diff --git a/src/frontend/src/style/applies.css b/src/frontend/src/style/applies.css
index d34efc6fbe..f1a813f381 100644
--- a/src/frontend/src/style/applies.css
+++ b/src/frontend/src/style/applies.css
@@ -1240,7 +1240,7 @@ input[type="password"]::-ms-clear {
@apply top-[-23px];
}
.popover-input {
- @apply h-fit w-fit flex-1 border-none bg-transparent p-0 shadow-none outline-none ring-0 ring-offset-0 placeholder:text-muted-foreground focus:border-foreground focus:outline-none focus:ring-0 focus:ring-offset-0 focus-visible:ring-0 focus-visible:ring-offset-0 disabled:cursor-not-allowed disabled:opacity-50;
+ @apply h-fit w-fit flex-1 border-none bg-transparent p-0 shadow-none outline-none ring-0 ring-offset-0 placeholder:text-muted-foreground focus:border-foreground focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50;
}
.beta-badge {
@@ -1295,14 +1295,6 @@ input[type="password"]::-ms-clear {
@apply flex h-[32px] w-[32px] items-center justify-center rounded-md bg-muted font-bold transition-all;
}
- .no-focus-visible {
- @apply focus-visible:outline-none focus-visible:ring-0 focus-visible:ring-offset-0;
- --tw-ring-offset-width: none !important;
- --tw-ring-shadow: none !important;
- --tw-ring-offset-shadow: none !important;
- --tw-ring-color: none !important;
- }
-
.helper-lines {
@apply pointer-events-none absolute left-0 top-0 z-10 h-full w-full;
}
diff --git a/src/frontend/src/style/index.css b/src/frontend/src/style/index.css
index 4cb54cce74..14127dea32 100644
--- a/src/frontend/src/style/index.css
+++ b/src/frontend/src/style/index.css
@@ -493,3 +493,11 @@
--indigo-foreground: 234.5 89.5% 73.9%;
}
}
+
+/* WCAG 2.4.7 — visible focus indicator fallback for any element not covered
+ by component-level Tailwind ring classes. Component ring classes (box-shadow)
+ take precedence over this outline via the cascade. */
+:focus-visible {
+ outline: 2px solid hsl(var(--ring));
+ outline-offset: 2px;
+}
diff --git a/src/frontend/tailwind.config.mjs b/src/frontend/tailwind.config.mjs
index d683703cfd..ba56569b30 100644
--- a/src/frontend/tailwind.config.mjs
+++ b/src/frontend/tailwind.config.mjs
@@ -480,10 +480,6 @@ const config = {
".text-align-last-right": {
"text-align-last": "right",
},
- ":focus-visible": {
- outline: "none !important",
- outlineOffset: "0px !important",
- },
".note-node-markdown": {
lineHeight: "1",
"& ul li::marker": {
diff --git a/src/frontend/tests/extended/features/focus-visible.spec.ts b/src/frontend/tests/extended/features/focus-visible.spec.ts
new file mode 100644
index 0000000000..2269ca801d
--- /dev/null
+++ b/src/frontend/tests/extended/features/focus-visible.spec.ts
@@ -0,0 +1,120 @@
+import { expect, test } from "../../fixtures";
+
+/**
+ * WCAG 2.4.7 Focus Visible regression tests.
+ *
+ * Each test tabs to an interactive element and confirms it has a visible
+ * focus indicator — either a non-zero outline or a non-"none" box-shadow
+ * (Tailwind ring classes render as box-shadow).
+ */
+
+async function getFocusStyle(page) {
+ return page.evaluate(() => {
+ const el = document.activeElement as HTMLElement | null;
+ if (!el) return null;
+ const style = window.getComputedStyle(el);
+ return {
+ tag: el.tagName.toLowerCase(),
+ testId: el.getAttribute("data-testid") ?? "",
+ outlineWidth: style.outlineWidth,
+ outlineStyle: style.outlineStyle,
+ boxShadow: style.boxShadow,
+ };
+ });
+}
+
+function hasFocusIndicator(focusStyle: {
+ outlineWidth: string;
+ outlineStyle: string;
+ boxShadow: string;
+}) {
+ const hasOutline =
+ focusStyle.outlineStyle !== "none" &&
+ focusStyle.outlineWidth !== "0px" &&
+ focusStyle.outlineWidth !== "";
+ const hasRing =
+ focusStyle.boxShadow !== "none" && focusStyle.boxShadow !== "";
+ return hasOutline || hasRing;
+}
+
+test(
+ "login page — every interactive element shows a visible focus indicator when tabbed to",
+ { tag: ["@release", "@workspace"] },
+ async ({ page }) => {
+ await page.goto("/login");
+ await page.waitForLoadState("networkidle");
+
+ // Tab through up to 20 focusable elements
+ const violations: string[] = [];
+ for (let i = 0; i < 20; i++) {
+ await page.keyboard.press("Tab");
+ const focusStyle = await getFocusStyle(page);
+ if (!focusStyle) continue;
+
+ // Skip elements that are intentionally not interactive (body, html)
+ if (["body", "html"].includes(focusStyle.tag)) continue;
+
+ if (!hasFocusIndicator(focusStyle)) {
+ violations.push(
+ `Element <${focusStyle.tag}> data-testid="${focusStyle.testId}" has no visible focus indicator (outline: ${focusStyle.outlineWidth} ${focusStyle.outlineStyle}, box-shadow: ${focusStyle.boxShadow})`,
+ );
+ }
+ }
+
+ expect(
+ violations,
+ `Focus visible violations found:\n${violations.join("\n")}`,
+ ).toHaveLength(0);
+ },
+);
+
+test(
+ "canvas controls — add note, zoom, and fit view buttons show focus ring when tabbed to",
+ { tag: ["@release", "@workspace"] },
+ async ({ page }) => {
+ await page.goto("/");
+ await page.waitForLoadState("networkidle");
+
+ const controlTestIds = [
+ "canvas-add-note-button",
+ "zoom_in",
+ "zoom_out",
+ "fit_view",
+ ];
+
+ for (const testId of controlTestIds) {
+ const button = page.getByTestId(testId);
+ if (!(await button.isVisible())) continue;
+
+ await button.focus();
+ const focusStyle = await getFocusStyle(page);
+ if (!focusStyle) continue;
+
+ expect(
+ hasFocusIndicator(focusStyle),
+ `Canvas control [data-testid="${testId}"] has no visible focus indicator`,
+ ).toBe(true);
+ }
+ },
+);
+
+test(
+ "dropdown trigger shows focus ring on keyboard focus",
+ { tag: ["@release", "@workspace"] },
+ async ({ page }) => {
+ await page.goto("/");
+ await page.waitForLoadState("networkidle");
+
+ // Find any visible button-role element and tab to it
+ const triggers = page.locator('[role="combobox"]:visible').first();
+ if (!(await triggers.isVisible())) return;
+
+ await triggers.focus();
+ const focusStyle = await getFocusStyle(page);
+
+ expect(
+ focusStyle && hasFocusIndicator(focusStyle),
+ `Dropdown trigger has no visible focus indicator (outline: ${focusStyle?.outlineWidth}, box-shadow: ${focusStyle?.boxShadow})`,
+ ).toBe(true);
+ },
+);