fix(a11y): 无障碍与 lint 修复(对比度/alt 属性/触控目标收窄)+ Geist 本地字体系统
This commit is contained in:
@@ -122,7 +122,7 @@ export function AdminLayout({ children }: { children: React.ReactNode }) {
|
||||
return next;
|
||||
});
|
||||
}
|
||||
}, [pathname]);
|
||||
}, [pathname]); // eslint-disable-line react-hooks/exhaustive-deps -- getExpandedMenusForPath 为组件内函数(引用随渲染变化),effect 已用 prevPathname 变更保护,刻意不列入依赖
|
||||
|
||||
const toggleMenu = (label: string) => {
|
||||
setExpandedMenus((prev) => {
|
||||
|
||||
@@ -160,14 +160,14 @@ export function CookieConsent() {
|
||||
<button
|
||||
onClick={() => setShowSettings(true)}
|
||||
disabled={isAnimating}
|
||||
className="px-4 py-2 text-sm font-medium text-[var(--color-text-secondary)] bg-[var(--color-bg-primary)] border border-[var(--color-border-secondary)] rounded-lg hover:bg-[var(--color-brand-lighter)] transition-colors disabled:opacity-50"
|
||||
className="px-4 py-2 text-sm font-medium text-[var(--color-text-secondary)] bg-[var(--color-bg-primary)] border border-[var(--color-border-secondary)] rounded-lg hover:bg-[var(--color-brand-bg)] transition-colors disabled:opacity-50"
|
||||
>
|
||||
管理偏好
|
||||
</button>
|
||||
<button
|
||||
onClick={handleRejectAll}
|
||||
disabled={isAnimating}
|
||||
className="px-4 py-2 text-sm font-medium text-[var(--color-text-secondary)] bg-[var(--color-bg-primary)] border border-[var(--color-border-secondary)] rounded-lg hover:bg-[var(--color-brand-lighter)] transition-colors disabled:opacity-50"
|
||||
className="px-4 py-2 text-sm font-medium text-[var(--color-text-secondary)] bg-[var(--color-bg-primary)] border border-[var(--color-border-secondary)] rounded-lg hover:bg-[var(--color-brand-bg)] transition-colors disabled:opacity-50"
|
||||
>
|
||||
仅必要
|
||||
</button>
|
||||
@@ -196,7 +196,7 @@ export function CookieConsent() {
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-start gap-3 p-3 bg-[var(--color-brand-lighter)] rounded-lg">
|
||||
<div className="flex items-start gap-3 p-3 bg-[var(--color-brand-bg)] rounded-lg">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked
|
||||
@@ -215,7 +215,7 @@ export function CookieConsent() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-start gap-3 p-3 bg-[var(--color-brand-lighter)] rounded-lg">
|
||||
<div className="flex items-start gap-3 p-3 bg-[var(--color-brand-bg)] rounded-lg">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={preferences.analytics}
|
||||
@@ -231,7 +231,7 @@ export function CookieConsent() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-start gap-3 p-3 bg-[var(--color-brand-lighter)] rounded-lg opacity-50">
|
||||
<div className="flex items-start gap-3 p-3 bg-[var(--color-brand-bg)] rounded-lg opacity-50">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={preferences.marketing}
|
||||
@@ -252,7 +252,7 @@ export function CookieConsent() {
|
||||
<button
|
||||
onClick={() => setShowSettings(false)}
|
||||
disabled={isAnimating}
|
||||
className="px-4 py-2 text-sm font-medium text-[var(--color-text-secondary)] bg-[var(--color-bg-primary)] border border-[var(--color-border-secondary)] rounded-lg hover:bg-[var(--color-brand-lighter)] transition-colors disabled:opacity-50"
|
||||
className="px-4 py-2 text-sm font-medium text-[var(--color-text-secondary)] bg-[var(--color-bg-primary)] border border-[var(--color-border-secondary)] rounded-lg hover:bg-[var(--color-brand-bg)] transition-colors disabled:opacity-50"
|
||||
>
|
||||
取消
|
||||
</button>
|
||||
@@ -288,7 +288,7 @@ export function CookieSettingsButton() {
|
||||
const event = new CustomEvent('open-cookie-settings');
|
||||
window.dispatchEvent(event);
|
||||
}}
|
||||
className="fixed bottom-4 right-4 z-[9997] px-3 py-2 text-xs font-medium text-[var(--color-text-muted)] bg-[var(--color-bg-primary)] border border-[var(--color-border-secondary)] rounded-lg shadow-sm hover:bg-[var(--color-brand-lighter)] transition-colors"
|
||||
className="fixed bottom-4 right-4 z-[9997] px-3 py-2 text-xs font-medium text-[var(--color-text-muted)] bg-[var(--color-bg-primary)] border border-[var(--color-border-secondary)] rounded-lg shadow-sm hover:bg-[var(--color-brand-bg)] transition-colors"
|
||||
aria-label="Cookie 设置"
|
||||
>
|
||||
Cookie 设置
|
||||
|
||||
@@ -46,7 +46,7 @@ export function MobileMenu({ className }: MobileMenuProps) {
|
||||
<button
|
||||
onClick={() => setIsOpen(!isOpen)}
|
||||
onKeyDown={handleKeyDown}
|
||||
className="p-3 rounded-md hover:bg-[var(--color-brand-lighter)] transition-colors focus:outline-none focus:ring-2 focus:ring-[var(--color-brand)] focus:ring-offset-2 min-w-[48px] min-h-[48px] flex items-center justify-center"
|
||||
className="p-3 rounded-md hover:bg-[var(--color-brand-bg)] transition-colors focus:outline-none focus:ring-2 focus:ring-[var(--color-brand)] focus:ring-offset-2 min-w-[48px] min-h-[48px] flex items-center justify-center"
|
||||
aria-label={isOpen ? '关闭菜单' : '打开菜单'}
|
||||
aria-expanded={isOpen}
|
||||
aria-controls="mobile-menu-panel"
|
||||
@@ -101,7 +101,7 @@ export function MobileMenu({ className }: MobileMenuProps) {
|
||||
className="block px-4 py-3 text-sm text-[var(--color-text-muted)] hover:text-[var(--color-brand)] hover:bg-[var(--color-brand-bg)] rounded-md transition-colors"
|
||||
>
|
||||
<span className="font-medium text-[var(--color-text-primary)]">{sub.title}</span>
|
||||
<span className="block text-xs text-[var(--color-text-hint)] mt-0.5">{sub.description}</span>
|
||||
<span className="block text-xs text-[var(--color-text-muted)] mt-0.5">{sub.description}</span>
|
||||
</StaticLink>
|
||||
</li>
|
||||
))}
|
||||
|
||||
@@ -83,16 +83,18 @@ describe('CaseCard', () => {
|
||||
it('should render background image when provided', async () => {
|
||||
const { CaseCard } = await import('./case-card');
|
||||
const { container } = render(<CaseCard {...mockCase} />);
|
||||
const img = container.querySelector('img[alt=""]');
|
||||
expect(img).toBeInTheDocument();
|
||||
expect(img).toHaveAttribute('src', '/images/cases/manufacturing.jpg');
|
||||
// 组件用 CSS 背景图而非 <img>,使 404 图片静默降级为纯色底
|
||||
const bg = container.querySelector('[style*="background-image"]');
|
||||
expect(bg).toBeInTheDocument();
|
||||
expect(bg).toHaveAttribute('aria-hidden', 'true');
|
||||
expect(bg).toHaveStyle({ backgroundImage: 'url(/images/cases/manufacturing.jpg)' });
|
||||
});
|
||||
|
||||
it('should not render background image when not provided', async () => {
|
||||
const { CaseCard } = await import('./case-card');
|
||||
const { container } = render(<CaseCard {...mockCase} image={undefined} />);
|
||||
const img = container.querySelector('img[alt=""]');
|
||||
expect(img).not.toBeInTheDocument();
|
||||
const bg = container.querySelector('[style*="background-image"]');
|
||||
expect(bg).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should use context as fallback when challenge is empty', async () => {
|
||||
|
||||
@@ -75,9 +75,11 @@ describe('InsightCard', () => {
|
||||
const { container } = render(
|
||||
<InsightCard {...mockInsight} featured image="/images/insights/trends.jpg" />
|
||||
);
|
||||
const img = container.querySelector('img');
|
||||
expect(img).toBeInTheDocument();
|
||||
expect(img).toHaveAttribute('src', '/images/insights/trends.jpg');
|
||||
// 组件用 CSS 背景图而非 <img>,使 404 图片静默降级为纯色底
|
||||
const bg = container.querySelector('[style*="background-image"]');
|
||||
expect(bg).toBeInTheDocument();
|
||||
expect(bg).toHaveAttribute('aria-hidden', 'true');
|
||||
expect(bg).toHaveStyle({ backgroundImage: 'url(/images/insights/trends.jpg)' });
|
||||
});
|
||||
|
||||
it('should render link element when provided', async () => {
|
||||
|
||||
@@ -14,11 +14,12 @@ const alertVariants = cva(
|
||||
default: 'bg-bg-primary text-text-primary border-border-primary',
|
||||
destructive:
|
||||
'text-error bg-error-bg border-error [&>svg]:text-current',
|
||||
/* 状态色 DEFAULT 仅 3.2-3.7:1,作文字不达 AA,故统一用 -text 变体 */
|
||||
success:
|
||||
'text-success bg-success-bg border-success [&>svg]:text-current',
|
||||
'text-success-text bg-success-bg border-success [&>svg]:text-current',
|
||||
warning:
|
||||
'text-warning bg-warning-bg border-warning [&>svg]:text-current',
|
||||
info: 'text-info bg-info-bg border-info [&>svg]:text-current',
|
||||
'text-warning-text bg-warning-bg border-warning [&>svg]:text-current',
|
||||
info: 'text-info-text bg-info-bg border-info [&>svg]:text-current',
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
|
||||
@@ -16,10 +16,11 @@ const badgeVariants = cva(
|
||||
outline:
|
||||
'border-border-accent text-text-primary bg-transparent',
|
||||
ghost: 'text-text-muted hover:text-text-primary hover:bg-bg-secondary',
|
||||
success: 'bg-success/10 text-success border-transparent',
|
||||
warning: 'bg-warning/10 text-warning border-transparent',
|
||||
/* 状态色 DEFAULT 仅 3.2-3.7:1,作文字不达 AA,故统一用 -text 变体 */
|
||||
success: 'bg-success/10 text-success-text border-transparent',
|
||||
warning: 'bg-warning/10 text-warning-text border-transparent',
|
||||
error: 'bg-error/10 text-error border-transparent',
|
||||
info: 'bg-info/10 text-info border-transparent',
|
||||
info: 'bg-info/10 text-info-text border-transparent',
|
||||
},
|
||||
size: {
|
||||
sm: 'px-2 py-0.5 text-[10px]',
|
||||
|
||||
@@ -18,13 +18,13 @@ function FlipDigit({ digit, prevDigit }: FlipDigitProps) {
|
||||
return (
|
||||
<div className="relative w-14 h-20 sm:w-16 sm:h-24 md:w-20 md:h-28 perspective-1000">
|
||||
{/* 背景卡片 - 静态显示当前数字 */}
|
||||
<div className="absolute inset-0 bg-gradient-to-b from-[var(--color-bg-primary)] via-[var(--color-bg-primary)] to-[var(--color-flip-card-bg)] rounded-lg shadow-xl overflow-hidden border border-[var(--color-flip-card-border)]">
|
||||
<div className="absolute top-0 left-0 right-0 h-1/2 bg-gradient-to-b from-[var(--color-bg-primary)] to-[var(--color-flip-card-bg)] border-b border-[var(--color-flip-card-border)] overflow-hidden">
|
||||
<div className="absolute inset-0 bg-gradient-to-b from-[var(--color-bg-primary)] via-[var(--color-bg-primary)] to-[var(--color-bg-secondary)] rounded-lg shadow-xl overflow-hidden border border-[var(--color-border-primary)]">
|
||||
<div className="absolute top-0 left-0 right-0 h-1/2 bg-gradient-to-b from-[var(--color-bg-primary)] to-[var(--color-bg-secondary)] border-b border-[var(--color-border-primary)] overflow-hidden">
|
||||
<div className="absolute inset-0 flex items-center justify-center text-4xl sm:text-5xl md:text-6xl font-bold text-[var(--color-brand)]">
|
||||
{digit}
|
||||
</div>
|
||||
</div>
|
||||
<div className="absolute bottom-0 left-0 right-0 h-1/2 bg-gradient-to-b from-[var(--color-bg-primary)] to-[var(--color-flip-card-bg)] overflow-hidden">
|
||||
<div className="absolute bottom-0 left-0 right-0 h-1/2 bg-gradient-to-b from-[var(--color-bg-primary)] to-[var(--color-bg-secondary)] overflow-hidden">
|
||||
<div className="absolute inset-0 flex items-center justify-center text-4xl sm:text-5xl md:text-6xl font-bold text-[var(--color-brand)]">
|
||||
{digit}
|
||||
</div>
|
||||
@@ -40,7 +40,7 @@ function FlipDigit({ digit, prevDigit }: FlipDigitProps) {
|
||||
animate={{ rotateX: -180 }}
|
||||
exit={{ rotateX: -180 }}
|
||||
transition={{ duration: 0.6, ease: [0.4, 0, 0.2, 1] }}
|
||||
className="absolute inset-0 bg-gradient-to-b from-[var(--color-bg-primary)] to-[var(--color-flip-card-bg)] rounded-t-lg overflow-hidden border-t border-l border-r border-[var(--color-flip-card-border)]"
|
||||
className="absolute inset-0 bg-gradient-to-b from-[var(--color-bg-primary)] to-[var(--color-bg-secondary)] rounded-t-lg overflow-hidden border-t border-l border-r border-[var(--color-border-primary)]"
|
||||
style={{
|
||||
transformOrigin: 'bottom',
|
||||
backfaceVisibility: 'hidden',
|
||||
@@ -58,7 +58,7 @@ function FlipDigit({ digit, prevDigit }: FlipDigitProps) {
|
||||
animate={{ rotateX: 0 }}
|
||||
exit={{ rotateX: 0 }}
|
||||
transition={{ duration: 0.6, ease: [0.4, 0, 0.2, 1] }}
|
||||
className="absolute inset-0 bg-gradient-to-b from-[var(--color-bg-primary)] to-[var(--color-flip-card-bg)] rounded-b-lg overflow-hidden border-b border-l border-r border-[var(--color-flip-card-border)]"
|
||||
className="absolute inset-0 bg-gradient-to-b from-[var(--color-bg-primary)] to-[var(--color-bg-secondary)] rounded-b-lg overflow-hidden border-b border-l border-r border-[var(--color-border-primary)]"
|
||||
style={{
|
||||
transformOrigin: 'top',
|
||||
backfaceVisibility: 'hidden',
|
||||
@@ -70,9 +70,9 @@ function FlipDigit({ digit, prevDigit }: FlipDigitProps) {
|
||||
</motion.div>
|
||||
</AnimatePresence>
|
||||
|
||||
<div className="absolute top-1/2 left-0 right-0 h-px bg-[var(--color-flip-card-divider)] transform -translate-y-1/2" />
|
||||
<div className="absolute top-0 left-0 right-0 h-px bg-[var(--color-flip-card-divider-subtle)]/50" />
|
||||
<div className="absolute bottom-0 left-0 right-0 h-px bg-[var(--color-flip-card-divider-subtle)]/50" />
|
||||
<div className="absolute top-1/2 left-0 right-0 h-px bg-[var(--color-border-primary)] transform -translate-y-1/2" />
|
||||
<div className="absolute top-0 left-0 right-0 h-px bg-[var(--color-border-light)]/50" />
|
||||
<div className="absolute bottom-0 left-0 right-0 h-px bg-[var(--color-border-light)]/50" />
|
||||
|
||||
{/* 侧面阴影增强立体感 */}
|
||||
<div className="absolute inset-0 rounded-lg shadow-[inset_0_2px_4px_rgba(0,0,0,0.1)] pointer-events-none" />
|
||||
|
||||
@@ -15,7 +15,7 @@ const Input = React.forwardRef<HTMLInputElement, React.ComponentProps<'input'>>(
|
||||
data-slot="input"
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'file:text-foreground placeholder:text-text-hint selection:bg-brand selection:text-white',
|
||||
'file:text-foreground placeholder:text-text-placeholder selection:bg-brand selection:text-white',
|
||||
'flex h-11 w-full min-w-0 rounded-md border border-border-primary bg-bg-primary px-3 py-1 text-base',
|
||||
'transition-all duration-fast outline-none',
|
||||
'file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium',
|
||||
|
||||
@@ -22,10 +22,10 @@ const SelectTrigger = React.forwardRef<
|
||||
data-slot="select-trigger"
|
||||
className={cn(
|
||||
'flex h-11 w-full items-center justify-between rounded-md border border-border-primary bg-bg-primary px-3 py-2 text-sm shadow-sm',
|
||||
'placeholder:text-text-hint',
|
||||
'placeholder:text-text-placeholder',
|
||||
'focus:outline-none focus-visible:ring-2 focus-visible:ring-brand focus-visible:ring-offset-2',
|
||||
'disabled:cursor-not-allowed disabled:opacity-50',
|
||||
'data-[placeholder]:text-text-hint',
|
||||
'data-[placeholder]:text-text-placeholder',
|
||||
'transition-colors duration-fast hover:border-border-secondary',
|
||||
'[&>span]:line-clamp-1',
|
||||
className
|
||||
|
||||
@@ -27,9 +27,9 @@ const Toaster = ({ ...props }: ToasterProviderProps) => {
|
||||
} as React.CSSProperties
|
||||
}
|
||||
icons={{
|
||||
success: <CheckCircle2 className="size-4 text-success" />,
|
||||
success: <CheckCircle2 className="size-4 text-success-text" />,
|
||||
error: <AlertCircle className="size-4 text-error" />,
|
||||
info: <Info className="size-4 text-info" />,
|
||||
info: <Info className="size-4 text-info-text" />,
|
||||
close: <X className="size-4" />,
|
||||
}}
|
||||
{...props}
|
||||
|
||||
@@ -14,7 +14,7 @@ const Textarea = React.forwardRef<HTMLTextAreaElement, React.ComponentProps<'tex
|
||||
data-slot="textarea"
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'placeholder:text-text-hint selection:bg-brand selection:text-white',
|
||||
'placeholder:text-text-placeholder selection:bg-brand selection:text-white',
|
||||
'flex min-h-[80px] w-full rounded-md border border-border-primary bg-bg-primary px-3 py-2 text-base',
|
||||
'transition-all duration-fast outline-none',
|
||||
'disabled:cursor-not-allowed disabled:opacity-50',
|
||||
|
||||
Reference in New Issue
Block a user