mirror of
https://github.com/iflytek/skillhub.git
synced 2026-09-05 08:05:56 +00:00
fix(theme): harden responsive switch semantics
Signed-off-by: XiaoSeS <87064762+XiaoSeS@users.noreply.github.com>
This commit is contained in:
parent
0d48945fd2
commit
2e11705ebd
9 changed files with 108 additions and 16 deletions
|
|
@ -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 <meta> element")
|
||||
))
|
||||
|
|
|
|||
|
|
@ -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)]'
|
||||
|
|
|
|||
|
|
@ -116,7 +116,7 @@ export function Layout() {
|
|||
})}
|
||||
</nav>
|
||||
|
||||
<div className="flex items-center gap-3 text-[15px] font-normal sm:gap-4" style={{ color: 'hsl(var(--text-secondary))' }}>
|
||||
<div className="flex items-center gap-2 text-[15px] font-normal sm:gap-3" style={{ color: 'hsl(var(--text-secondary))' }}>
|
||||
<ThemeToggle />
|
||||
<LanguageSwitcher />
|
||||
{user && <NotificationBell />}
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
},
|
||||
|
|
|
|||
|
|
@ -488,6 +488,7 @@
|
|||
"pageSubtitle": "Учётные данные доступа для CLI и API"
|
||||
},
|
||||
"theme": {
|
||||
"darkMode": "Тёмная тема",
|
||||
"switchToLight": "Переключить на светлую тему",
|
||||
"switchToDark": "Переключить на тёмную тему"
|
||||
},
|
||||
|
|
|
|||
|
|
@ -488,6 +488,7 @@
|
|||
"pageSubtitle": "管理 CLI 和 API 使用的访问凭证"
|
||||
},
|
||||
"theme": {
|
||||
"darkMode": "深色主题",
|
||||
"switchToLight": "切换到浅色主题",
|
||||
"switchToDark": "切换到深色主题"
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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%;
|
||||
|
|
|
|||
|
|
@ -22,12 +22,12 @@ describe('ThemeToggle', () => {
|
|||
it('switches theme and keeps the selection in browser-local storage', () => {
|
||||
render(<ThemeToggle />)
|
||||
|
||||
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')
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
<button
|
||||
type="button"
|
||||
aria-label={label}
|
||||
aria-pressed={isDark}
|
||||
role="switch"
|
||||
aria-label={accessibleName}
|
||||
aria-checked={isDark}
|
||||
title={label}
|
||||
onClick={toggleTheme}
|
||||
className={cn(
|
||||
'group relative inline-flex h-10 w-[4.5rem] shrink-0 items-center rounded-full border border-border bg-muted/70 p-1 text-muted-foreground shadow-sm transition-[background-color,border-color] duration-200 hover:border-primary/40 hover:bg-muted focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background',
|
||||
'group relative inline-flex h-11 w-16 shrink-0 items-center rounded-full border border-border bg-muted/70 px-1 text-muted-foreground shadow-sm transition-[background-color,border-color] duration-200 hover:border-primary/40 hover:bg-muted focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background',
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className={cn(
|
||||
'absolute left-1 top-1 h-8 w-8 rounded-full border border-border/80 bg-card shadow-[0_3px_10px_-4px_hsl(var(--foreground)/0.45)] transition-transform duration-200 ease-out motion-reduce:transition-none',
|
||||
isDark && 'translate-x-8',
|
||||
'absolute left-1 top-1.5 h-8 w-7 rounded-full border border-border/80 bg-card shadow-[0_3px_10px_-4px_hsl(var(--foreground)/0.45)] transition-transform duration-200 ease-out motion-reduce:transition-none',
|
||||
isDark && 'translate-x-7',
|
||||
)}
|
||||
/>
|
||||
<span className="relative z-10 inline-flex h-8 w-8 items-center justify-center">
|
||||
<span className="relative z-10 inline-flex h-8 w-7 items-center justify-center">
|
||||
<Sun
|
||||
aria-hidden="true"
|
||||
className={cn('h-4 w-4 transition-colors duration-200', !isDark && 'text-foreground')}
|
||||
/>
|
||||
</span>
|
||||
<span className="relative z-10 inline-flex h-8 w-8 items-center justify-center">
|
||||
<span className="relative z-10 inline-flex h-8 w-7 items-center justify-center">
|
||||
<Moon
|
||||
aria-hidden="true"
|
||||
className={cn('h-4 w-4 transition-colors duration-200', isDark && 'text-foreground')}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue