diff --git a/dashboard/index.html b/dashboard/index.html index 364eb80f53..de9ace8c97 100644 --- a/dashboard/index.html +++ b/dashboard/index.html @@ -133,13 +133,13 @@ position: absolute; inset: 0; border-radius: 50%; - border: 3px solid rgba(232, 93, 117, 0.18); - border-top-color: #e85d75; + border: 3px solid rgba(176, 71, 89, 0.18); + border-top-color: #b04759; animation: octop-boot-spin 0.85s linear infinite; } html[data-theme="dark"] #octop-boot-ring { - border-color: rgba(240, 139, 154, 0.22); - border-top-color: #f08b9a; + border-color: rgba(232, 137, 154, 0.22); + border-top-color: #e8899a; } .octop-boot-logo { position: absolute; diff --git a/dashboard/public/offline.html b/dashboard/public/offline.html index aea84eda44..d8cc238284 100644 --- a/dashboard/public/offline.html +++ b/dashboard/public/offline.html @@ -42,7 +42,7 @@ font-size: 22px; font-weight: 600; margin-bottom: 10px; - color: #f08b9a; + color: #e8899a; } p { font-size: 14px; @@ -54,7 +54,7 @@ padding: 10px 28px; border-radius: 999px; border: none; - background: #e85d75; + background: #b04759; color: #fff; font-size: 14px; font-weight: 500; diff --git a/dashboard/src/components/AgentSelector.a11y.test.tsx b/dashboard/src/components/AgentSelector.a11y.test.tsx new file mode 100644 index 0000000000..83674052fe --- /dev/null +++ b/dashboard/src/components/AgentSelector.a11y.test.tsx @@ -0,0 +1,159 @@ +import { describe, expect, it, vi, beforeEach } from "vitest"; +import { render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import type { OctopAgent } from "../context/AgentContext"; + +/** + * A11Y-9 regression guard. + * + * The agent picker showed a row of chips with no tablist semantics, so the + * grouping was invisible to assistive tech and every chip was its own tab stop. + */ + +const state = { + agents: [] as OctopAgent[], + activeAgentId: "writer" as string | null, + loading: false, +}; + +const setActiveAgent = vi.fn((id: string | null) => { + state.activeAgentId = id; +}); + +vi.mock("../context/AgentContext", () => ({ + useAgent: () => ({ + get agents() { + return state.agents; + }, + get activeAgentId() { + return state.activeAgentId; + }, + get loading() { + return state.loading; + }, + setActiveAgent, + }), +})); + +import AgentSelector from "./AgentSelector"; + +function fakeAgent( + partial: Pick & Partial, +): OctopAgent { + return { + id: 1, + description: null, + persona_mbti: null, + default_model: null, + system_prompt: null, + template_name: null, + state: "running", + last_error: null, + icon: null, + icon_name: null, + icon_url: null, + color: "#2563eb", + config: {}, + is_owner: true, + kind: "expert", + ...partial, + }; +} + +const a = fakeAgent({ agent_id: "a", name: "Alpha" }); +const b = fakeAgent({ agent_id: "b", name: "Beta" }); +const c = fakeAgent({ agent_id: "c", name: "Gamma" }); + +function setup(activeId = "a") { + state.agents = [a, b, c]; + state.activeAgentId = activeId; + return render(); +} + +describe("AgentSelector tablist (A11Y-9)", () => { + beforeEach(() => { + setActiveAgent.mockClear(); + }); + + it("exposes the picker as a tablist of tabs", () => { + setup(); + expect(screen.getByRole("tablist")).toBeInTheDocument(); + expect(screen.getAllByRole("tab")).toHaveLength(3); + }); + + it("marks exactly one tab selected", () => { + setup("b"); + const selected = screen + .getAllByRole("tab") + .filter((el) => el.getAttribute("aria-selected") === "true"); + expect(selected).toHaveLength(1); + expect(selected[0]).toHaveAccessibleName(/Beta/); + }); + + it("keeps only the selected tab in the tab order (roving tabindex)", () => { + setup("b"); + const tabs = screen.getAllByRole("tab"); + const focusable = tabs.filter((el) => el.getAttribute("tabindex") === "0"); + expect(focusable).toHaveLength(1); + expect(focusable[0]).toHaveAccessibleName(/Beta/); + }); + + it("moves selection with ArrowRight and wraps at the end", async () => { + setup("a"); + const [alpha] = screen.getAllByRole("tab"); + alpha.focus(); + + await userEvent.keyboard("{ArrowRight}"); + expect(setActiveAgent).toHaveBeenLastCalledWith("b"); + + await userEvent.keyboard("{ArrowRight}"); + expect(setActiveAgent).toHaveBeenLastCalledWith("c"); + + // Wrap: c -> a + await userEvent.keyboard("{ArrowRight}"); + expect(setActiveAgent).toHaveBeenLastCalledWith("a"); + }); + + it("supports Home and End", async () => { + setup("b"); + const tabs = screen.getAllByRole("tab"); + tabs[1].focus(); + + await userEvent.keyboard("{End}"); + expect(setActiveAgent).toHaveBeenLastCalledWith("c"); + + await userEvent.keyboard("{Home}"); + expect(setActiveAgent).toHaveBeenLastCalledWith("a"); + }); + + it("names each tab with its agent AND its state", () => { + // The dot is colour-only, so the state must reach the accessible name. + state.agents = [ + fakeAgent({ agent_id: "ok", name: "Healthy", state: "running" }), + fakeAgent({ agent_id: "bad", name: "Broken", state: "failed" }), + ]; + state.activeAgentId = "ok"; + render(); + + // The i18n mock in test/setup.ts resolves t(key, fallback) to the fallback, + // so the state reads as the raw value here. The contract under test is that + // the state is part of the name at all — without it the dot's colour is the + // only signal, which is what a deuteranope cannot read. + const running = screen.getByRole("tab", { name: /Healthy.*running/ }); + const failed = screen.getByRole("tab", { name: /Broken.*failed/ }); + expect(running).toBeInTheDocument(); + expect(failed).toBeInTheDocument(); + // And the two are distinguishable by name alone, not just by dot colour. + expect(running.getAttribute("aria-label")).not.toBe( + failed.getAttribute("aria-label"), + ); + }); + + it("hides the state dot from assistive tech (name carries it instead)", () => { + setup(); + const tab = screen.getAllByRole("tab")[0]; + const dot = tab.querySelector("[data-state]"); + expect(dot).not.toBeNull(); + expect(dot).toHaveAttribute("aria-hidden"); + }); +}); diff --git a/dashboard/src/components/AgentSelector.tsx b/dashboard/src/components/AgentSelector.tsx index 78e808247e..3fdee30f65 100644 --- a/dashboard/src/components/AgentSelector.tsx +++ b/dashboard/src/components/AgentSelector.tsx @@ -104,12 +104,25 @@ function AgentChip({ const title = disconnected ? `${agent.name} · ${t("agentSelector.disconnected")}` : agent.description ?? agent.name; + // The state dot is a bare coloured span. Without this it is announced as + // nothing and its colour is the only carrier of state — which for the + // red/green running-vs-failed pair is exactly what a deuteranope cannot read. + const stateLabel = disconnected + ? t("agentSelector.stateDisconnected", "Disconnected") + : t(`agentSelector.state.${agent.state}`, agent.state); + return ( ); @@ -212,6 +226,46 @@ export default function AgentSelector({ const measureRef = useRef(null); const [hiddenIds, setHiddenIds] = useState([]); + // Roving focus across the tablist. Arrow keys move selection, matching the + // automatic-activation behaviour of an ARIA tablist; Home/End jump to the + // ends. Only the selected tab is in the tab order, so Tab leaves the group. + const handleTablistKeyDown = (e: React.KeyboardEvent) => { + const keys = [ + "ArrowRight", + "ArrowLeft", + "ArrowDown", + "ArrowUp", + "Home", + "End", + ]; + if (!keys.includes(e.key)) return; + const chips = Array.from( + e.currentTarget.querySelectorAll('[role="tab"]'), + ); + if (chips.length === 0) return; + const current = chips.findIndex((c) => c === document.activeElement); + let next: number; + switch (e.key) { + case "Home": + next = 0; + break; + case "End": + next = chips.length - 1; + break; + case "ArrowRight": + case "ArrowDown": + next = current < 0 ? 0 : (current + 1) % chips.length; + break; + default: + next = current <= 0 ? chips.length - 1 : current - 1; + } + e.preventDefault(); + chips[next]?.focus(); + // Select as focus moves, so the tablist behaves like antd's Tabs. + const id = chips[next]?.getAttribute("data-agent-id"); + if (id) setActiveAgent(id); + }; + useEffect(() => { if (loading) return; const next = nextPickerSelection(activeAgentId, agents, selectable); @@ -366,7 +420,13 @@ export default function AgentSelector({ -
+
{visibleGroups.map((group) => renderGroup(group, false))}
{showMore ? ( diff --git a/dashboard/src/layouts/MainLayout/index.module.less b/dashboard/src/layouts/MainLayout/index.module.less new file mode 100644 index 0000000000..19af5fd175 --- /dev/null +++ b/dashboard/src/layouts/MainLayout/index.module.less @@ -0,0 +1,22 @@ +/* Skip link: visually hidden until focused. Inline styles cannot express + :focus-visible or ::before, so this one rule lives in a stylesheet. */ +.skipLink { + position: absolute; + top: -100px; + left: 8px; + z-index: 2000; + padding: 10px 16px; + border-radius: 8px; + background: var(--fn-bg-elevated, #fff); + color: var(--fn-text-primary, #111); + border: 1px solid var(--fn-border-focus, #b04759); + box-shadow: var(--fn-shadow-brand, 0 4px 14px rgba(176, 71, 89, 0.18)); + font-size: 14px; + font-weight: 500; + text-decoration: none; + transition: top 0.15s ease; +} + +.skipLink:focus { + top: 8px; +} diff --git a/dashboard/src/layouts/MainLayout/index.tsx b/dashboard/src/layouts/MainLayout/index.tsx index dbf53d7370..fc7f2c2e14 100644 --- a/dashboard/src/layouts/MainLayout/index.tsx +++ b/dashboard/src/layouts/MainLayout/index.tsx @@ -26,6 +26,9 @@ import RequirePermission from "../../components/RequirePermission"; import { routeNeedsPermission } from "../../utils/permissions"; import { useLayoutMode } from "../../context/LayoutModeContext"; import { useDashboardPushToast } from "../../hooks/useDashboardPushToast"; +import { pageTitleFor } from "../pageTitle"; +import { useTranslation } from "react-i18next"; +import styles from "./index.module.less"; const Chat = lazy(() => import("../../pages/Chat")); const WorkbenchPage = lazy(() => import("../../pages/Control/Workbench")); @@ -34,6 +37,9 @@ const { Content } = Layout; const SIDEBAR_COLLAPSED_KEY = "octop:sidebar:collapsed"; +/** Focus target for the skip link. antd's Content renders
. */ +export const MAIN_CONTENT_ID = "octop-main-content"; + function getSavedCollapsed(): boolean { try { const saved = localStorage.getItem(SIDEBAR_COLLAPSED_KEY); @@ -59,6 +65,7 @@ export default function MainLayout() { const selectedKey = resolveSelectedKey(currentPath); const isMobile = useIsMobile(); const { layoutMode } = useLayoutMode(); + const { t } = useTranslation(); useDashboardPushToast(); useKeyboardOffset(); const isMinimalLayout = layoutMode === "minimal"; @@ -68,6 +75,29 @@ export default function MainLayout() { (isMobile && MOBILE_FULLSCREEN_PATHS.has(currentPath)); const onWorkbench = isWorkbenchPath(currentPath); + // Reflect the current section in the tab title. The app name is set once in + // App.tsx; without this every tab, bookmark and history entry reads the same. + useEffect(() => { + document.title = pageTitleFor(t("app.pageTitle"), selectedKey, t); + }, [t, selectedKey]); + + // First focusable element on the page: lets a keyboard user jump past the + // sidebar (which is ~19 nav items) straight to the content. + const skipLink = ( + { + // Move focus as well as the scroll position, otherwise the next Tab + // continues from the link rather than from the content. + e.preventDefault(); + document.getElementById(MAIN_CONTENT_ID)?.focus(); + }} + > + {t("nav.skipToContent", "Skip to content")} + + ); + const [collapsed, setCollapsed] = useState(() => getSavedCollapsed()); const [chatSidebarOpen, setChatSidebarOpen] = useChatSidebarOpen(); const [workbenchMounted, setWorkbenchMounted] = useState(() => onWorkbench); @@ -196,6 +226,8 @@ export default function MainLayout() { overflow: "hidden", }} > + {skipLink} + {/* Mobile overlay backdrop */} {isMobile && !collapsed && (
- {brandInner} +
+ {brandInner} +
+
- -
- -
- {navScrollBody} -
+ {navScrollBody} +
-
- {userFooter} - {customizer} +
+ {userFooter} + {customizer} +
-
+ ); } // Desktop: custom sidebar with icon-only collapsed mode. // Right border is drawn by MainLayout's RailEdgeControl. return ( -
- {brandInner} -
- -
- {navScrollBody} -
+
+ {brandInner} +
- {userFooter} - {customizer} -
+
+ {navScrollBody} +
+ + {userFooter} + {customizer} + + ); } diff --git a/dashboard/src/layouts/pageTitle.test.ts b/dashboard/src/layouts/pageTitle.test.ts new file mode 100644 index 0000000000..284de55b31 --- /dev/null +++ b/dashboard/src/layouts/pageTitle.test.ts @@ -0,0 +1,86 @@ +import { describe, expect, it } from "vitest"; +import { readFileSync } from "node:fs"; +import { resolve } from "node:path"; +import { pageTitleFor, SECTION_KEYS_WITH_TITLES } from "./pageTitle"; +import en from "../locales/en.json"; +import zh from "../locales/zh.json"; + +/** + * A11Y-11 regression guard. + * + * `document.title` was set once from the app name and never updated, so every + * tab, bookmark and history entry read identically and a screen-reader user + * navigating by tab title could not tell sections apart. + */ + +/** Collect every `nav.*` key defined in a locale bundle. */ +function navKeys(bundle: unknown): Set { + const nav = (bundle as { nav?: Record }).nav ?? {}; + return new Set(Object.keys(nav).map((k) => `nav.${k}`)); +} + +const EN_NAV = navKeys(en); +const ZH_NAV = navKeys(zh); + +describe("pageTitleFor", () => { + it("prefixes the section name to the app name", () => { + const t = (key: string) => (key === "app.pageTitle" ? "Octop" : "Experts"); + expect(pageTitleFor("Octop", "experts", t)).toBe("Experts - Octop"); + }); + + it("uses the bare app name for an unknown section", () => { + const t = (key: string) => (key === "app.pageTitle" ? "Octop" : key); + expect(pageTitleFor("Octop", "not-a-section", t)).toBe("Octop"); + expect(pageTitleFor("Octop", "", t)).toBe("Octop"); + }); + + it("falls back rather than displaying a raw i18n key", () => { + // i18next returns the key itself when a translation is missing; showing + // "nav.chat - Octop" in a tab is worse than showing just the app name. + const t = (key: string) => key; + expect(pageTitleFor("Octop", "chat", t)).toBe("Octop"); + }); + + it("every mapped section has a real key in BOTH locales", () => { + // Catches a typo like nav.adminSecurity (which does not exist; the sidebar + // uses nav.security) and a key added to en.json but not zh.json. + for (const section of SECTION_KEYS_WITH_TITLES) { + const labelKey = (() => { + const t = (key: string) => (EN_NAV.has(key) ? "LABEL" : key); + // Recover the key the map chose by calling with a sentinel. + const title = pageTitleFor("Octop", section, t); + return title === "Octop" ? null : title.replace(" - Octop", ""); + })(); + expect(labelKey, `section "${section}" resolves to a missing key`).toBe( + "LABEL", + ); + } + }); + + it("keeps every sidebar entry covered by the title map", () => { + // The map must not rot as nav items are added. Read the sidebar source for + // its `labelKey` values and require each nav key to have a title entry. + const src = readFileSync(resolve(__dirname, "sidebarNav.tsx"), "utf8"); + const items = [ + ...src.matchAll( + /key: "([a-z-]+)",\s*\n\s*path: "[^"]+",\s*\n\s*icon: [^\n]+\n\s*labelKey: "([^"]+)"/g, + ), + ].map((m) => ({ key: m[1], labelKey: m[2] })); + + expect(items.length).toBeGreaterThan(10); + for (const { key, labelKey } of items) { + expect( + SECTION_KEYS_WITH_TITLES, + `sidebar item "${key}" has no page title`, + ).toContain(key); + // And the title must use the SAME label the sidebar shows, so the tab and + // the nav entry can never disagree. + expect(EN_NAV.has(labelKey), `${labelKey} missing from en.json`).toBe( + true, + ); + expect(ZH_NAV.has(labelKey), `${labelKey} missing from zh.json`).toBe( + true, + ); + } + }); +}); diff --git a/dashboard/src/layouts/pageTitle.ts b/dashboard/src/layouts/pageTitle.ts new file mode 100644 index 0000000000..68b89dd2d1 --- /dev/null +++ b/dashboard/src/layouts/pageTitle.ts @@ -0,0 +1,57 @@ +/** + * Mapping from a nav section key to its localized page-title key. + * + * `document.title` used to be set once, from the app name, and never updated — + * so every browser tab, bookmark, and history entry read identically regardless + * of the section, and a screen-reader user navigating by tab title had no way to + * tell pages apart. These are the SAME labels the sidebar uses, so the tab title + * and the visible nav entry can never disagree. + * + * Keys come from `resolveSelectedKey()` in `dashboard/src/routes`, which already + * maps every route path to one of `SIDEBAR_NAV_KEYS`. + */ +const SECTION_LABEL_KEYS: Record = { + chat: "nav.chat", + experts: "nav.experts", + tasks: "nav.tasks", + "token-usage": "nav.tokenUsage", + personalization: "nav.personalization", + channels: "nav.channels", + connectors: "nav.connectors", + "skill-packages": "nav.skillPackages", + "knowledge-bases": "nav.knowledgeBases", + bridge: "nav.bridge", + workbench: "nav.workbench", + "remote-desktop": "nav.remoteDesktop", + acp: "nav.acp", + "admin-users": "nav.adminUsers", + models: "nav.models", + "admin-storage": "nav.adminStorage", + "admin-plugins": "nav.adminPlugins", + "admin-security": "nav.security", + "admin-advanced": "nav.adminAdvanced", + "agent-config": "nav.agentConfig", +}; + +/** + * Build the document title for a section. + * + * Returns the bare app name when the key is unknown, so an unmapped or new + * route degrades to today's behaviour rather than showing a raw key. + */ +export function pageTitleFor( + appName: string, + sectionKey: string, + translate: (key: string) => string, +): string { + const labelKey = SECTION_LABEL_KEYS[sectionKey]; + if (!labelKey) return appName; + const section = translate(labelKey); + // If a label is missing the translator may return the key itself; showing + // "nav.chat - Octop" is worse than showing nothing, so fall back. + if (!section || section === labelKey) return appName; + return `${section} - ${appName}`; +} + +/** Exposed for the test that keeps this in sync with the sidebar. */ +export const SECTION_KEYS_WITH_TITLES = Object.keys(SECTION_LABEL_KEYS); diff --git a/dashboard/src/layouts/skipLink.a11y.test.ts b/dashboard/src/layouts/skipLink.a11y.test.ts new file mode 100644 index 0000000000..2a3547166d --- /dev/null +++ b/dashboard/src/layouts/skipLink.a11y.test.ts @@ -0,0 +1,87 @@ +import { describe, expect, it } from "vitest"; +import { readFileSync } from "node:fs"; +import { resolve } from "node:path"; +import en from "../locales/en.json"; +import zh from "../locales/zh.json"; + +/** + * A11Y-10 regression guard. + * + * Two separate gaps, both verified live before the fix: + * 1. No skip link — a keyboard user had to Tab through the whole sidebar + * (~19 items plus group headers) to reach page content on every navigation. + * 2. The sidebar was a plain
, so it exposed no navigation landmark and + * a screen reader user could not jump to or list the nav region. + * + * Asserted against source rather than by mounting. MainLayout pulls in the + * entire route table, ServiceRestart/Backup providers and a lazily-loaded Chat + * page; Sidebar reaches the preferences API. Mounting them in jsdom is slow and + * brittle, and for a landmark/attribute contract source is the honest check. + * (Same approach as MessageList's virtualised branch — see its test comment.) + */ + +const MAIN_LAYOUT = readFileSync( + resolve(__dirname, "MainLayout/index.tsx"), + "utf8", +); +const SIDEBAR = readFileSync(resolve(__dirname, "Sidebar.tsx"), "utf8"); + +const count = (haystack: string, needle: string) => + haystack.split(needle).length - 1; + +describe("skip link (A11Y-10)", () => { + it("renders an anchor that targets the main content id", () => { + const id = MAIN_LAYOUT.match( + /export const MAIN_CONTENT_ID = "([^"]+)"/, + )?.[1]; + expect(id).toBeTruthy(); + // The link points at it... + expect(MAIN_LAYOUT).toContain("href={`#${MAIN_CONTENT_ID}`}"); + // ...and the
element carries it. + expect(MAIN_LAYOUT).toContain("id={MAIN_CONTENT_ID}"); + }); + + it("makes main focusable so focus actually moves", () => { + // Without tabIndex={-1} the anchor would scroll but not move focus, and the + // next Tab would resume from the skip link instead of the content. + expect(MAIN_LAYOUT).toMatch(/tabIndex={-1}/); + expect(MAIN_LAYOUT).toContain(".focus()"); + }); + + it("is the first focusable element in the DOM", () => { + // A skip link that appears after the sidebar is useless. + const skipAt = MAIN_LAYOUT.indexOf("{skipLink}"); + const sidebarAt = MAIN_LAYOUT.indexOf(" { + expect(en.nav.skipToContent).toBeTruthy(); + expect(zh.nav.skipToContent).toBeTruthy(); + // Not left as an untranslated copy of the English string. + expect(zh.nav.skipToContent).not.toBe(en.nav.skipToContent); + }); +}); + +describe("sidebar navigation landmark (A11Y-10)", () => { + it("wraps BOTH render paths in a labelled