From 2e11705ebdf98b5266993dd7ed0aed5521a8dc8b Mon Sep 17 00:00:00 2001 From: XiaoSeS <87064762+XiaoSeS@users.noreply.github.com> Date: Tue, 1 Sep 2026 15:54:01 +0800 Subject: [PATCH] fix(theme): harden responsive switch semantics Signed-off-by: XiaoSeS <87064762+XiaoSeS@users.noreply.github.com> --- web/e2e/theme-toggle.spec.ts | 93 ++++++++++++++++++- web/src/app/layout-header-style.ts | 2 +- web/src/app/layout.tsx | 2 +- web/src/i18n/locales/en.json | 1 + web/src/i18n/locales/ru.json | 1 + web/src/i18n/locales/zh.json | 1 + web/src/index.css | 2 +- .../shared/components/theme-toggle.test.tsx | 6 +- web/src/shared/components/theme-toggle.tsx | 16 ++-- 9 files changed, 108 insertions(+), 16 deletions(-) diff --git a/web/e2e/theme-toggle.spec.ts b/web/e2e/theme-toggle.spec.ts index 0983b75a..721abf52 100644 --- a/web/e2e/theme-toggle.spec.ts +++ b/web/e2e/theme-toggle.spec.ts @@ -5,6 +5,52 @@ test.describe('Light and dark theme', () => { test.beforeEach(async ({ page }) => { await setEnglishLocale(page) await page.context().setExtraHTTPHeaders({ 'X-Mock-User-Id': 'local-user' }) + await page.route('**/api/v1/auth/me', async (route) => { + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ + code: 0, + msg: 'success', + data: { + userId: 'theme-layout-user', + displayName: 'Theme Layout User', + avatarUrl: 'data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///ywAAAAAAQABAAACAUwAOw==', + platformRoles: [], + oauthProvider: 'local', + canChangePassword: true, + }, + timestamp: '2026-09-01T00:00:00Z', + requestId: 'theme-auth-fixture', + }), + }) + }) + await page.route('**/api/web/me/namespaces', async (route) => { + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ + code: 0, + msg: 'success', + data: [], + timestamp: '2026-09-01T00:00:00Z', + requestId: 'theme-namespace-fixture', + }), + }) + }) + await page.route('**/api/web/notifications/unread-count', async (route) => { + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ + code: 0, + msg: 'success', + data: { count: 1 }, + timestamp: '2026-09-01T00:00:00Z', + requestId: 'theme-unread-fixture', + }), + }) + }) await page.addInitScript(() => { const observedWindow = window as Window & { __themeAtFirstReactContent?: boolean } const observer = new MutationObserver(() => { @@ -62,15 +108,45 @@ test.describe('Light and dark theme', () => { const header = page.locator('header') const lightHeaderBackground = await header.evaluate((element) => getComputedStyle(element).backgroundColor) - await page.getByRole('button', { name: 'Switch to dark theme' }).click() + const themeSwitch = page.getByRole('switch', { name: 'Dark theme' }) + await expect(themeSwitch).toHaveAttribute('aria-checked', 'false') + await themeSwitch.click() await expect(page.locator('html')).toHaveClass(/dark/) + await expect(themeSwitch).toHaveAttribute('aria-checked', 'true') await expect.poll(() => header.evaluate((element) => getComputedStyle(element).backgroundColor)) .not.toBe(lightHeaderBackground) await expect.poll(() => page.evaluate(() => window.localStorage.getItem('skillhub-theme'))).toBe('dark') + const destructiveContrast = await page.evaluate(() => { + const probe = document.createElement('button') + probe.className = 'bg-destructive text-destructive-foreground' + probe.textContent = 'Destructive contrast probe' + document.body.append(probe) + const styles = getComputedStyle(probe) + + const luminance = (color: string) => { + const channels = color.match(/[\d.]+/g)?.slice(0, 3).map(Number) + if (!channels || channels.length !== 3) { + throw new Error(`Unable to parse computed color: ${color}`) + } + const linear = channels.map((channel) => { + const normalized = channel / 255 + return normalized <= 0.04045 + ? normalized / 12.92 + : ((normalized + 0.055) / 1.055) ** 2.4 + }) + return 0.2126 * linear[0] + 0.7152 * linear[1] + 0.0722 * linear[2] + } + + const background = luminance(styles.backgroundColor) + const foreground = luminance(styles.color) + probe.remove() + return (Math.max(background, foreground) + 0.05) / (Math.min(background, foreground) + 0.05) + }) + expect(destructiveContrast).toBeGreaterThanOrEqual(4.5) await page.reload() await expect(page.locator('html')).toHaveClass(/dark/) - await expect(page.getByRole('button', { name: 'Switch to light theme' })).toBeVisible() + await expect(page.getByRole('switch', { name: 'Dark theme' })).toHaveAttribute('aria-checked', 'true') await expect(page.getByRole('heading', { name: 'SkillHub', exact: true })).toBeVisible() await expect.poll(() => page.evaluate(() => ( window as Window & { __themeAtFirstReactContent?: boolean } @@ -94,10 +170,21 @@ test.describe('Light and dark theme', () => { await notificationButton.click() await page.setViewportSize({ width: 390, height: 844 }) - await expect(page.getByRole('button', { name: 'Switch to light theme' })).toBeVisible() + await expect(page.getByRole('switch', { name: 'Dark theme' })).toBeVisible() await expect.poll(() => page.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth)).toBe(true) await page.screenshot({ path: testInfo.outputPath('dark-mobile.png'), fullPage: true }) + await page.setViewportSize({ width: 320, height: 568 }) + const headerControls = page.locator('header > div') + const [headerBox, controlsBox] = await Promise.all([header.boundingBox(), headerControls.boundingBox()]) + expect(headerBox).not.toBeNull() + expect(controlsBox).not.toBeNull() + expect((controlsBox?.x ?? 0) + (controlsBox?.width ?? 0)).toBeLessThanOrEqual( + (headerBox?.x ?? 0) + (headerBox?.width ?? 0), + ) + await expect.poll(() => page.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth)).toBe(true) + await page.screenshot({ path: testInfo.outputPath('dark-mobile-320.png'), fullPage: true }) + const unexpectedConsoleErrors = consoleErrors.filter((message) => ( !message.includes("frame-ancestors' is ignored when delivered via a element") )) diff --git a/web/src/app/layout-header-style.ts b/web/src/app/layout-header-style.ts index 467dd7a5..49288285 100644 --- a/web/src/app/layout-header-style.ts +++ b/web/src/app/layout-header-style.ts @@ -1,7 +1,7 @@ import { cn } from '@/shared/lib/utils' export const APP_HEADER_BASE_CLASS_NAME = - 'sticky top-0 z-50 flex items-center justify-between border-b border-border/70 bg-background/90 px-6 py-4 backdrop-blur-xl transition-[background-color,border-color,box-shadow] duration-200 supports-[backdrop-filter]:bg-background/80 md:px-12' + 'sticky top-0 z-50 flex items-center justify-between border-b border-border/70 bg-background/90 px-4 py-4 backdrop-blur-xl transition-[background-color,border-color,box-shadow] duration-200 supports-[backdrop-filter]:bg-background/80 sm:px-6 md:px-12' export const APP_HEADER_ELEVATED_CLASS_NAME = 'shadow-[0_12px_30px_-24px_hsl(var(--foreground)/0.45)]' diff --git a/web/src/app/layout.tsx b/web/src/app/layout.tsx index a93f0199..7d5000a8 100644 --- a/web/src/app/layout.tsx +++ b/web/src/app/layout.tsx @@ -116,7 +116,7 @@ export function Layout() { })} -
+
{user && } diff --git a/web/src/i18n/locales/en.json b/web/src/i18n/locales/en.json index 219daffc..fe95fbd1 100644 --- a/web/src/i18n/locales/en.json +++ b/web/src/i18n/locales/en.json @@ -488,6 +488,7 @@ "pageSubtitle": "Manage access credentials for CLI and API" }, "theme": { + "darkMode": "Dark theme", "switchToLight": "Switch to light theme", "switchToDark": "Switch to dark theme" }, diff --git a/web/src/i18n/locales/ru.json b/web/src/i18n/locales/ru.json index 7fabf7af..a3d13fe4 100644 --- a/web/src/i18n/locales/ru.json +++ b/web/src/i18n/locales/ru.json @@ -488,6 +488,7 @@ "pageSubtitle": "Учётные данные доступа для CLI и API" }, "theme": { + "darkMode": "Тёмная тема", "switchToLight": "Переключить на светлую тему", "switchToDark": "Переключить на тёмную тему" }, diff --git a/web/src/i18n/locales/zh.json b/web/src/i18n/locales/zh.json index c8ef69a4..30de1e66 100644 --- a/web/src/i18n/locales/zh.json +++ b/web/src/i18n/locales/zh.json @@ -488,6 +488,7 @@ "pageSubtitle": "管理 CLI 和 API 使用的访问凭证" }, "theme": { + "darkMode": "深色主题", "switchToLight": "切换到浅色主题", "switchToDark": "切换到深色主题" }, diff --git a/web/src/index.css b/web/src/index.css index 9b9a58a6..c1742f66 100644 --- a/web/src/index.css +++ b/web/src/index.css @@ -66,7 +66,7 @@ --muted-foreground: 216 14% 66%; --accent: 272 86% 72%; --accent-foreground: 0 0% 100%; - --destructive: 0 72% 61%; + --destructive: 0 72% 52%; --destructive-foreground: 0 0% 100%; --border: 222 16% 25%; --input: 222 16% 25%; diff --git a/web/src/shared/components/theme-toggle.test.tsx b/web/src/shared/components/theme-toggle.test.tsx index 58841889..f528cb00 100644 --- a/web/src/shared/components/theme-toggle.test.tsx +++ b/web/src/shared/components/theme-toggle.test.tsx @@ -22,12 +22,12 @@ describe('ThemeToggle', () => { it('switches theme and keeps the selection in browser-local storage', () => { render() - const toggle = screen.getByRole('button', { name: 'theme.switchToDark' }) - expect(toggle.getAttribute('aria-pressed')).toBe('false') + const toggle = screen.getByRole('switch', { name: 'theme.darkMode' }) + expect(toggle.getAttribute('aria-checked')).toBe('false') fireEvent.click(toggle) - expect(screen.getByRole('button', { name: 'theme.switchToLight' }).getAttribute('aria-pressed')).toBe('true') + expect(screen.getByRole('switch', { name: 'theme.darkMode' }).getAttribute('aria-checked')).toBe('true') expect(document.documentElement.classList.contains('dark')).toBe(true) expect(window.localStorage.getItem(THEME_STORAGE_KEY)).toBe('dark') }) diff --git a/web/src/shared/components/theme-toggle.tsx b/web/src/shared/components/theme-toggle.tsx index d913166c..b669e80a 100644 --- a/web/src/shared/components/theme-toggle.tsx +++ b/web/src/shared/components/theme-toggle.tsx @@ -12,33 +12,35 @@ export function ThemeToggle({ className }: ThemeToggleProps) { const { theme, toggleTheme } = useTheme() const isDark = theme === 'dark' const label = isDark ? t('theme.switchToLight') : t('theme.switchToDark') + const accessibleName = t('theme.darkMode') return (