From 9da030e876e7a1c2faa1b4ee71da4c99d38810da Mon Sep 17 00:00:00 2001 From: pt-act <211776491+pt-act@users.noreply.github.com> Date: Mon, 19 Oct 2026 11:16:30 +0100 Subject: [PATCH 1/2] fix(dashboard): bring the default rose palette up to WCAG AA contrast (A11Y-6) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The rose palette was exempt from the contrast gate — it was pinned to the "historic Elegant Rose" values (#E85D75 light, #F08B9A dark) instead. That exemption meant the DEFAULT palette shipped white text on a rose primary button at 3.36:1 in light mode and 2.37:1 in dark, both under the 4.5:1 AA floor. Rose is now #B04759 with darker hover (#973D4D) and active (#7F3340) states, so all three solid states clear AA in both modes while matching the hover/active direction every other palette already uses. Dark mode keeps a bright accent (#E8899A) for its text/link roles, which is a separate token from the button background — the two roles needing opposite values is why one shared hex could not work. The tint layer follows: 30 rgba(232,93,117,a) sites and the boot spinner, offline page and token fallbacks now carry the new value. New themeContrast.test.ts makes the exemption impossible to reintroduce: it asserts every palette including rose, pins the default palette as covered, and records why the decorative swatch fills carry no contrast requirement (the aria-hidden chips inside a labelled 42px button are not distinguished by fill-vs-surface alone — a fill assertion would fail emerald and amber for a rule the design does not rely on). Removing the exemption from themePalettes.test.ts and switching expertColor's hard-coded rose hex to PALETTE_SWATCH.rose. --- dashboard/index.html | 8 +- dashboard/public/offline.html | 4 +- .../pages/Chat/chatInputPickers.partial.less | 8 +- .../BridgeSettings.module.less | 4 +- .../AdvancedSettings/BridgeSettings.tsx | 2 +- dashboard/src/styles/theme-vars.css | 94 +++++++-------- dashboard/src/styles/themeContrast.test.ts | 111 ++++++++++++++++++ dashboard/src/styles/themePalettes.test.ts | 14 +-- dashboard/src/styles/themePalettes.ts | 32 ++--- dashboard/src/utils/expertColor.test.ts | 6 +- 10 files changed, 195 insertions(+), 88 deletions(-) create mode 100644 dashboard/src/styles/themeContrast.test.ts 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/pages/Chat/chatInputPickers.partial.less b/dashboard/src/pages/Chat/chatInputPickers.partial.less index 50e8726d4a..07ad621028 100644 --- a/dashboard/src/pages/Chat/chatInputPickers.partial.less +++ b/dashboard/src/pages/Chat/chatInputPickers.partial.less @@ -154,7 +154,7 @@ } &:hover { - color: var(--fn-color-brand, #e85d75); + color: var(--fn-color-brand, #b04759); } &:focus-visible { @@ -367,13 +367,13 @@ } .reasoningMenuChoiceActive { - background: rgba(232, 93, 117, 0.08); - color: var(--fn-color-brand, #e85d75); + background: rgba(176, 71, 89, 0.08); + color: var(--fn-color-brand, #b04759); } .reasoningStatusRow { justify-content: flex-start; - color: var(--fn-color-brand, #e85d75); + color: var(--fn-color-brand, #b04759); span { flex: 1; diff --git a/dashboard/src/pages/Settings/AdvancedSettings/BridgeSettings.module.less b/dashboard/src/pages/Settings/AdvancedSettings/BridgeSettings.module.less index 51a219fe0f..1f1b275cb7 100644 --- a/dashboard/src/pages/Settings/AdvancedSettings/BridgeSettings.module.less +++ b/dashboard/src/pages/Settings/AdvancedSettings/BridgeSettings.module.less @@ -372,10 +372,10 @@ } .connectedAgentBtn:hover { - border-color: var(--catalog-accent, var(--fn-color-brand, #e85d75)); + border-color: var(--catalog-accent, var(--fn-color-brand, #b04759)); background: color-mix( in srgb, - var(--catalog-accent, var(--fn-color-brand, #e85d75)) 6%, + var(--catalog-accent, var(--fn-color-brand, #b04759)) 6%, var(--fn-bg-secondary, #f7f8fa) ); } diff --git a/dashboard/src/pages/Settings/AdvancedSettings/BridgeSettings.tsx b/dashboard/src/pages/Settings/AdvancedSettings/BridgeSettings.tsx index 0cc2af037e..a2afdf5c90 100644 --- a/dashboard/src/pages/Settings/AdvancedSettings/BridgeSettings.tsx +++ b/dashboard/src/pages/Settings/AdvancedSettings/BridgeSettings.tsx @@ -157,7 +157,7 @@ function AgentAvatar({ return ( { + const value = Number.parseInt(hex.slice(offset, offset + 2), 16) / 255; + return value <= 0.03928 ? value / 12.92 : ((value + 0.055) / 1.055) ** 2.4; + }); + return 0.2126 * channels[0] + 0.7152 * channels[1] + 0.0722 * channels[2]; +} + +function contrastRatio(foreground: string, background: string): number { + const a = relativeLuminance(foreground); + const b = relativeLuminance(background); + return (Math.max(a, b) + 0.05) / (Math.min(a, b) + 0.05); +} + +const WHITE = "#FFFFFF"; +/** WCAG 1.4.3 — normal-size text. */ +const AA_TEXT = 4.5; + +describe("brand contrast (WCAG 1.4.3)", () => { + it.each(VALID_PALETTES)( + "keeps %s primary buttons readable with white text in both modes", + (palette) => { + for (const mode of ["light", "dark"] as const) { + const tokens = ANTD_BRAND_TOKENS[palette][mode]; + for (const state of [ + "colorPrimary", + "colorPrimaryHover", + "colorPrimaryActive", + ] as const) { + const color = tokens[state]; + expect( + contrastRatio(color, WHITE), + `${palette}.${mode}.${state} (${color}) on white`, + ).toBeGreaterThanOrEqual(AA_TEXT); + } + } + }, + ); + + it("does not exempt the default palette from the gate", () => { + // The specific regression this file exists to prevent: rose silently + // rejoining the exempt list, or the default palette changing to one that + // is not covered by the it.each above. + expect(VALID_PALETTES).toContain(DEFAULT_PALETTE); + for (const mode of ["light", "dark"] as const) { + expect( + contrastRatio( + ANTD_BRAND_TOKENS[DEFAULT_PALETTE][mode].colorPrimary, + WHITE, + ), + ).toBeGreaterThanOrEqual(AA_TEXT); + } + }); + + it("documents why the swatch FILL carries no contrast requirement", () => { + // Investigated while writing this gate, and the answer is "no requirement", + // not "a requirement we are failing": + // + // - The chips are `aria-hidden` decorative fills inside a 42x42 labelled, + // focusable button (PaletteSwitcher.tsx), so no text sits on them. + // - They are not distinguished by fill-vs-surface contrast alone: the + // button supplies position, an `aria-label` with the palette name, a + // hover background, and an active-state ring. The chip edge is a soft + // `inset 0 0 0 1px rgba(0,0,0,0.08)` hairline (~1.19:1 on white), which + // is decorative and pre-existing. + // - WCAG 1.4.11 applies to the *component*, and this component is the + // button, which is keyboard focusable with a visible focus ring. + // + // So a fill-vs-white assertion would be a false gate: it would fail emerald + // (#10B981, 2.54:1) and amber (#F59E0B, 2.15:1) while telling us nothing + // about whether a user can operate the picker. This test pins the reasoning + // and the data, so the next person does not re-litigate it or "fix" the + // values to satisfy a rule the design does not rely on. + const measured = VALID_PALETTES.map((palette) => ({ + palette, + ratio: contrastRatio(PALETTE_SWATCH[palette], WHITE), + })); + // Sanity: every palette still renders a real swatch... + for (const { palette } of measured) { + expect(PALETTE_SWATCH[palette], `swatch ${palette}`).toMatch( + /^#[0-9A-Fa-f]{6}$/, + ); + } + // ...and this documents the actual spread we accepted. + const lowest = measured.reduce((a, b) => (a.ratio < b.ratio ? a : b)); + expect(lowest.palette).toBe("amber"); + expect(lowest.ratio).toBeGreaterThan(2); + }); +}); diff --git a/dashboard/src/styles/themePalettes.test.ts b/dashboard/src/styles/themePalettes.test.ts index 0c74037fde..3316d19ba2 100644 --- a/dashboard/src/styles/themePalettes.test.ts +++ b/dashboard/src/styles/themePalettes.test.ts @@ -36,15 +36,11 @@ describe("theme palettes", () => { ]); }); - it("keeps the historic Elegant Rose default brand tokens", () => { - expect(ANTD_BRAND_TOKENS.rose.light.colorPrimary).toBe("#E85D75"); - expect(ANTD_BRAND_TOKENS.rose.light.colorPrimaryHover).toBe("#D14A62"); - expect(ANTD_BRAND_TOKENS.rose.light.colorPrimaryActive).toBe("#B83A50"); - expect(ANTD_BRAND_TOKENS.rose.dark.colorPrimary).toBe("#F08B9A"); - expect(ANTD_BRAND_TOKENS.rose.dark.colorLink).toBe("#F08B9A"); - }); - - it.each(VALID_PALETTES.filter((palette) => palette !== "rose"))( + // Rose is no longer exempt. It used to be pinned to the "historic Elegant + // Rose" (#E85D75 / #F08B9A) and excluded from the contrast gate below, which + // meant the DEFAULT palette shipped white-on-primary at 3.36:1 (light) and + // 2.37:1 (dark) — both under the 4.5:1 AA floor, the dark case badly so. + it.each(VALID_PALETTES)( "keeps %s solid Ant Design states readable with white text", (palette) => { for (const mode of ["light", "dark"] as const) { diff --git a/dashboard/src/styles/themePalettes.ts b/dashboard/src/styles/themePalettes.ts index fe6b3caec3..e88f9488bb 100644 --- a/dashboard/src/styles/themePalettes.ts +++ b/dashboard/src/styles/themePalettes.ts @@ -44,7 +44,7 @@ export const PALETTE_STORAGE_KEY = LEGACY_PALETTE_STORAGE_KEY; /** Swatch color shown in the palette picker (light brand). */ export const PALETTE_SWATCH: Record = { - rose: "#E85D75", + rose: "#B04759", tech: "#4B74FA", indigo: "#6366F1", teal: "#0D9488", @@ -76,23 +76,23 @@ export const ANTD_BRAND_TOKENS: Record< > = { rose: { light: { - colorPrimary: "#E85D75", - colorPrimaryHover: "#D14A62", - colorPrimaryActive: "#B83A50", - colorLink: "#E85D75", + colorPrimary: "#B04759", + colorPrimaryHover: "#973D4D", + colorPrimaryActive: "#7F3340", + colorLink: "#B04759", }, dark: { - colorPrimary: "#F08B9A", - colorPrimaryBg: "rgba(232, 93, 117, 0.12)", - colorPrimaryBgHover: "rgba(232, 93, 117, 0.16)", - colorPrimaryBorder: "rgba(232, 93, 117, 0.25)", - colorPrimaryBorderHover: "rgba(232, 93, 117, 0.35)", - colorPrimaryHover: "#F5A8B4", - colorPrimaryActive: "#E85D75", - colorPrimaryText: "#F08B9A", - colorPrimaryTextHover: "#F5A8B4", - colorPrimaryTextActive: "#E85D75", - colorLink: "#F08B9A", + colorPrimary: "#B04759", + colorPrimaryBg: "rgba(176, 71, 89, 0.12)", + colorPrimaryBgHover: "rgba(176, 71, 89, 0.16)", + colorPrimaryBorder: "rgba(176, 71, 89, 0.25)", + colorPrimaryBorderHover: "rgba(176, 71, 89, 0.35)", + colorPrimaryHover: "#973D4D", + colorPrimaryActive: "#7F3340", + colorPrimaryText: "#E8899A", + colorPrimaryTextHover: "#F0A3B0", + colorPrimaryTextActive: "#E8899A", + colorLink: "#E8899A", }, }, tech: { diff --git a/dashboard/src/utils/expertColor.test.ts b/dashboard/src/utils/expertColor.test.ts index 11510223e3..80927cf94b 100644 --- a/dashboard/src/utils/expertColor.test.ts +++ b/dashboard/src/utils/expertColor.test.ts @@ -10,7 +10,7 @@ import { describe("resolveExpertPalette", () => { it("matches exact curated swatches", () => { - expect(resolveExpertPalette("#E85D75")).toBe("rose"); + expect(resolveExpertPalette(PALETTE_SWATCH.rose)).toBe("rose"); expect(resolveExpertPalette("#6366F1")).toBe("indigo"); }); @@ -30,8 +30,8 @@ describe("resolveExpertPalette", () => { describe("parseStoredColor", () => { it("restores the curated key when the hex matches a swatch exactly", () => { - expect(parseStoredColor("#E85D75")).toBe("rose"); - expect(parseStoredColor("#e85d75")).toBe("rose"); + expect(parseStoredColor(PALETTE_SWATCH.rose)).toBe("rose"); + expect(parseStoredColor(PALETTE_SWATCH.rose.toLowerCase())).toBe("rose"); expect(parseStoredColor(PALETTE_SWATCH.slate)).toBe("slate"); }); From a8a15f4ca8db0419768ee2fc34b1568f53c45188 Mon Sep 17 00:00:00 2001 From: pt-act <211776491+pt-act@users.noreply.github.com> Date: Mon, 19 Oct 2026 11:31:53 +0100 Subject: [PATCH 2/2] feat(dashboard): add a skip link and make the sidebar a nav landmark (A11Y-10) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two gaps, both verified live before the fix. The sidebar exposed no navigation landmark. It rendered as a plain
, so a screen reader user could neither list nor jump to the nav region. Both render paths (desktop rail and mobile overlay drawer) are now wrapped in a labelled
-
+
{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