fix(a11y): 无障碍与 lint 修复(对比度/alt 属性/触控目标收窄)+ Geist 本地字体系统

This commit is contained in:
2026-08-31 12:03:32 +08:00
parent 0786af3db9
commit b65ddd1ff7
20 changed files with 125 additions and 76 deletions
+6 -1
View File
@@ -2,10 +2,15 @@ const cdnDomain = process.env.CDN_DOMAIN || '';
const nextConfig = { const nextConfig = {
output: 'standalone', output: 'standalone',
distDir: 'dist', // 默认 dist/;可用 NEXT_DIST_DIR 覆盖(CI 分环境输出,或本地需要避开既有构建产物)
distDir: process.env.NEXT_DIST_DIR || 'dist',
assetPrefix: cdnDomain || undefined, assetPrefix: cdnDomain || undefined,
images: { images: {
// 有意关闭 Next 图片优化:产物经 Nginx 静态托管 + CDN 分发(见 docs/CDN_CONFIGURATION.md、
// CLAUDE.md「Images are unoptimized (static export limitation)」)。
// 不要仅因「性能」理由改为 false —— 在无 Node 运行时接管 /_next/image 的部署模式下会导致图片 404。
unoptimized: true, unoptimized: true,
// 注意:unoptimized 为 true 时下面这行不生效,仅在改回 false 后才会参与构建产物生成。
formats: ['image/avif', 'image/webp'], formats: ['image/avif', 'image/webp'],
}, },
compress: true, compress: true,
+2 -1
View File
@@ -193,7 +193,8 @@ export default function MediaPage() {
{items.length === 0 && !loading && ( {items.length === 0 && !loading && (
<div className="text-center py-16 bg-white rounded-lg border border-dashed border-gray-300"> <div className="text-center py-16 bg-white rounded-lg border border-dashed border-gray-300">
<Image className="w-12 h-12 mx-auto mb-4 text-gray-300" /> {/* eslint-disable-next-line jsx-a11y/alt-text -- lucide Image 图标(装饰性 SVG,非 <img>),aria-hidden 表明装饰意图 */}
<Image aria-hidden="true" className="w-12 h-12 mx-auto mb-4 text-gray-300" />
<h3 className="text-sm font-medium text-gray-900 mb-1"></h3> <h3 className="text-sm font-medium text-gray-900 mb-1"></h3>
<p className="text-sm text-gray-500 mb-4">使</p> <p className="text-sm text-gray-500 mb-4">使</p>
<label className="inline-flex items-center gap-2 bg-gray-900 text-white rounded-lg px-4 py-2 text-sm font-medium hover:bg-gray-800 cursor-pointer transition-colors"> <label className="inline-flex items-center gap-2 bg-gray-900 text-white rounded-lg px-4 py-2 text-sm font-medium hover:bg-gray-800 cursor-pointer transition-colors">
+2 -2
View File
@@ -81,7 +81,7 @@ export default function Error({
href="/contact" href="/contact"
className="flex items-center p-4 bg-[var(--color-bg-primary)] rounded-lg hover:shadow-md transition-shadow group" className="flex items-center p-4 bg-[var(--color-bg-primary)] rounded-lg hover:shadow-md transition-shadow group"
> >
<div className="w-10 h-10 bg-[var(--color-brand-bg)] rounded-lg flex items-center justify-center mr-4 group-hover:bg-[var(--color-challenge-isolation-hover)] transition-colors"> <div className="w-10 h-10 bg-[var(--color-brand-bg)] rounded-lg flex items-center justify-center mr-4 group-hover:bg-[var(--color-brand-soft)] transition-colors">
<AlertTriangle className="w-5 h-5 text-[var(--color-brand)]" /> <AlertTriangle className="w-5 h-5 text-[var(--color-brand)]" />
</div> </div>
<div className="text-left"> <div className="text-left">
@@ -94,7 +94,7 @@ export default function Error({
href="/services" href="/services"
className="flex items-center p-4 bg-[var(--color-bg-primary)] rounded-lg hover:shadow-md transition-shadow group" className="flex items-center p-4 bg-[var(--color-bg-primary)] rounded-lg hover:shadow-md transition-shadow group"
> >
<div className="w-10 h-10 bg-[var(--color-brand-bg)] rounded-lg flex items-center justify-center mr-4 group-hover:bg-[var(--color-challenge-isolation-hover)] transition-colors"> <div className="w-10 h-10 bg-[var(--color-brand-bg)] rounded-lg flex items-center justify-center mr-4 group-hover:bg-[var(--color-brand-soft)] transition-colors">
<RefreshCw className="w-5 h-5 text-[var(--color-brand)]" /> <RefreshCw className="w-5 h-5 text-[var(--color-brand)]" />
</div> </div>
<div className="text-left"> <div className="text-left">
+26 -1
View File
@@ -1,4 +1,5 @@
import type { Metadata, Viewport } from "next"; import type { Metadata, Viewport } from "next";
import localFont from "next/font/local";
import "./globals.css"; import "./globals.css";
import { Suspense } from "react"; import { Suspense } from "react";
import { GoogleAnalyticsWrapper } from "@/components/analytics/GoogleAnalyticsWrapper"; import { GoogleAnalyticsWrapper } from "@/components/analytics/GoogleAnalyticsWrapper";
@@ -18,6 +19,26 @@ import { SiteConfigProvider, type SiteConfig, type NavigationItem, type MegaDrop
import { COMPANY_INFO, BRAND_NARRATIVE, type BrandPillar } from "@/lib/constants/company"; import { COMPANY_INFO, BRAND_NARRATIVE, type BrandPillar } from "@/lib/constants/company";
import { NAVIGATION_V2, MEGA_DROPDOWN_DATA } from "@/lib/constants/navigation"; import { NAVIGATION_V2, MEGA_DROPDOWN_DATA } from "@/lib/constants/navigation";
/**
* 拉丁字形使用 Geist(本地 woff2,无网络请求);
* 中文由 globals.css 的 --font-sans 回退到 PingFang SC / 微软雅黑。
* 这样英文数字有设计字体,中文又不需下载数 MB 的 CJK 字重。
* 两者总重 140KBdisplay: swap 不阻塞首屏。
*/
const geistSans = localFont({
src: "./fonts/geist-sans.woff2",
variable: "--font-geist-sans",
display: "swap",
preload: true,
});
const geistMono = localFont({
src: "./fonts/geist-mono.woff2",
variable: "--font-geist-mono",
display: "swap",
preload: false,
});
export const metadata: Metadata = { export const metadata: Metadata = {
metadataBase: new URL("https://www.novalon.cn"), metadataBase: new URL("https://www.novalon.cn"),
title: { title: {
@@ -143,7 +164,11 @@ export default async function RootLayout({
}; };
return ( return (
<html lang="zh-CN" className="scroll-smooth" suppressHydrationWarning> <html
lang="zh-CN"
className={`scroll-smooth ${geistSans.variable} ${geistMono.variable}`}
suppressHydrationWarning
>
<head> <head>
<link rel="icon" type="image/svg+xml" href="/favicon.svg" /> <link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<link rel="icon" type="image/png" sizes="32x32" href="/favicon-32x32.png" /> <link rel="icon" type="image/png" sizes="32x32" href="/favicon-32x32.png" />
+4 -4
View File
@@ -47,7 +47,7 @@ export function NotFoundContent() {
href="/products" href="/products"
className="flex flex-col items-center p-4 bg-[var(--color-bg-primary)] rounded-lg hover:shadow-md transition-shadow group" className="flex flex-col items-center p-4 bg-[var(--color-bg-primary)] rounded-lg hover:shadow-md transition-shadow group"
> >
<div className="w-9 h-9 bg-[var(--color-brand-bg)] rounded-lg flex items-center justify-center mb-2 group-hover:bg-[var(--color-challenge-isolation-hover)] transition-colors"> <div className="w-9 h-9 bg-[var(--color-brand-bg)] rounded-lg flex items-center justify-center mb-2 group-hover:bg-[var(--color-brand-soft)] transition-colors">
<Search className="w-4 h-4 text-[var(--color-brand)]" /> <Search className="w-4 h-4 text-[var(--color-brand)]" />
</div> </div>
<span className="font-medium text-sm text-[var(--color-text-primary)]"></span> <span className="font-medium text-sm text-[var(--color-text-primary)]"></span>
@@ -57,7 +57,7 @@ export function NotFoundContent() {
href="/solutions" href="/solutions"
className="flex flex-col items-center p-4 bg-[var(--color-bg-primary)] rounded-lg hover:shadow-md transition-shadow group" className="flex flex-col items-center p-4 bg-[var(--color-bg-primary)] rounded-lg hover:shadow-md transition-shadow group"
> >
<div className="w-9 h-9 bg-[var(--color-brand-bg)] rounded-lg flex items-center justify-center mb-2 group-hover:bg-[var(--color-challenge-isolation-hover)] transition-colors"> <div className="w-9 h-9 bg-[var(--color-brand-bg)] rounded-lg flex items-center justify-center mb-2 group-hover:bg-[var(--color-brand-soft)] transition-colors">
<Search className="w-4 h-4 text-[var(--color-brand)]" /> <Search className="w-4 h-4 text-[var(--color-brand)]" />
</div> </div>
<span className="font-medium text-sm text-[var(--color-text-primary)]"></span> <span className="font-medium text-sm text-[var(--color-text-primary)]"></span>
@@ -67,7 +67,7 @@ export function NotFoundContent() {
href="/about" href="/about"
className="flex flex-col items-center p-4 bg-[var(--color-bg-primary)] rounded-lg hover:shadow-md transition-shadow group" className="flex flex-col items-center p-4 bg-[var(--color-bg-primary)] rounded-lg hover:shadow-md transition-shadow group"
> >
<div className="w-9 h-9 bg-[var(--color-brand-bg)] rounded-lg flex items-center justify-center mb-2 group-hover:bg-[var(--color-challenge-isolation-hover)] transition-colors"> <div className="w-9 h-9 bg-[var(--color-brand-bg)] rounded-lg flex items-center justify-center mb-2 group-hover:bg-[var(--color-brand-soft)] transition-colors">
<Search className="w-4 h-4 text-[var(--color-brand)]" /> <Search className="w-4 h-4 text-[var(--color-brand)]" />
</div> </div>
<span className="font-medium text-sm text-[var(--color-text-primary)]"></span> <span className="font-medium text-sm text-[var(--color-text-primary)]"></span>
@@ -77,7 +77,7 @@ export function NotFoundContent() {
href="/contact" href="/contact"
className="flex flex-col items-center p-4 bg-[var(--color-bg-primary)] rounded-lg hover:shadow-md transition-shadow group" className="flex flex-col items-center p-4 bg-[var(--color-bg-primary)] rounded-lg hover:shadow-md transition-shadow group"
> >
<div className="w-9 h-9 bg-[var(--color-brand-bg)] rounded-lg flex items-center justify-center mb-2 group-hover:bg-[var(--color-challenge-isolation-hover)] transition-colors"> <div className="w-9 h-9 bg-[var(--color-brand-bg)] rounded-lg flex items-center justify-center mb-2 group-hover:bg-[var(--color-brand-soft)] transition-colors">
<Search className="w-4 h-4 text-[var(--color-brand)]" /> <Search className="w-4 h-4 text-[var(--color-brand)]" />
</div> </div>
<span className="font-medium text-sm text-[var(--color-text-primary)]"></span> <span className="font-medium text-sm text-[var(--color-text-primary)]"></span>
+7 -7
View File
@@ -136,16 +136,16 @@ function PrivacyContent() {
使 Cookie Cookie 使 Cookie Cookie
</p> </p>
<div className="overflow-x-auto mb-6"> <div className="overflow-x-auto mb-6">
<table className="min-w-full border border-[var(--color-border)] rounded-lg"> <table className="min-w-full border border-[var(--color-border-primary)] rounded-lg">
<thead className="bg-[var(--color-bg-tertiary)]"> <thead className="bg-[var(--color-bg-tertiary)]">
<tr> <tr>
<th className="px-4 py-3 text-left text-sm font-semibold text-[var(--color-text-primary)] border-b border-[var(--color-border)]">Cookie </th> <th className="px-4 py-3 text-left text-sm font-semibold text-[var(--color-text-primary)] border-b border-[var(--color-border-primary)]">Cookie </th>
<th className="px-4 py-3 text-left text-sm font-semibold text-[var(--color-text-primary)] border-b border-[var(--color-border)]"></th> <th className="px-4 py-3 text-left text-sm font-semibold text-[var(--color-text-primary)] border-b border-[var(--color-border-primary)]"></th>
<th className="px-4 py-3 text-left text-sm font-semibold text-[var(--color-text-primary)] border-b border-[var(--color-border)]"></th> <th className="px-4 py-3 text-left text-sm font-semibold text-[var(--color-text-primary)] border-b border-[var(--color-border-primary)]"></th>
<th className="px-4 py-3 text-left text-sm font-semibold text-[var(--color-text-primary)] border-b border-[var(--color-border)]"></th> <th className="px-4 py-3 text-left text-sm font-semibold text-[var(--color-text-primary)] border-b border-[var(--color-border-primary)]"></th>
</tr> </tr>
</thead> </thead>
<tbody className="divide-y divide-[var(--color-border)]"> <tbody className="divide-y divide-[var(--color-border-primary)]">
<tr> <tr>
<td className="px-4 py-3 text-sm text-[var(--color-text-muted)]"> Cookie</td> <td className="px-4 py-3 text-sm text-[var(--color-text-muted)]"> Cookie</td>
<td className="px-4 py-3 text-sm text-[var(--color-text-muted)]"></td> <td className="px-4 py-3 text-sm text-[var(--color-text-muted)]"></td>
@@ -248,7 +248,7 @@ export default async function PrivacyPolicyPage() {
return ( return (
<div className="min-h-screen bg-[var(--color-bg-primary)]"> <div className="min-h-screen bg-[var(--color-bg-primary)]">
<div className="bg-gradient-to-br from-[var(--color-brand)] via-[var(--color-brand)]/80 to-[var(--color-hero-dark-end)] py-20"> <div className="bg-gradient-to-br from-[var(--color-brand)] via-[var(--color-brand)]/80 to-[var(--color-brand-section)] py-20">
<div className="container-wide"> <div className="container-wide">
<h1 className="text-4xl md:text-5xl font-bold text-white mb-4"> <h1 className="text-4xl md:text-5xl font-bold text-white mb-4">
{heroTitle} {heroTitle}
+1 -1
View File
@@ -193,7 +193,7 @@ export default async function TermsOfServicePage() {
return ( return (
<div className="min-h-screen bg-[var(--color-bg-primary)]"> <div className="min-h-screen bg-[var(--color-bg-primary)]">
<div className="bg-gradient-to-br from-[var(--color-brand)] via-[var(--color-brand)]/80 to-[var(--color-hero-dark-end)] py-20"> <div className="bg-gradient-to-br from-[var(--color-brand)] via-[var(--color-brand)]/80 to-[var(--color-brand-section)] py-20">
<div className="container-wide"> <div className="container-wide">
<h1 className="text-4xl md:text-5xl font-bold text-white mb-4"> <h1 className="text-4xl md:text-5xl font-bold text-white mb-4">
{heroTitle} {heroTitle}
+1 -1
View File
@@ -122,7 +122,7 @@ export function AdminLayout({ children }: { children: React.ReactNode }) {
return next; return next;
}); });
} }
}, [pathname]); }, [pathname]); // eslint-disable-line react-hooks/exhaustive-deps -- getExpandedMenusForPath 为组件内函数(引用随渲染变化),effect 已用 prevPathname 变更保护,刻意不列入依赖
const toggleMenu = (label: string) => { const toggleMenu = (label: string) => {
setExpandedMenus((prev) => { setExpandedMenus((prev) => {
+7 -7
View File
@@ -160,14 +160,14 @@ export function CookieConsent() {
<button <button
onClick={() => setShowSettings(true)} onClick={() => setShowSettings(true)}
disabled={isAnimating} 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>
<button <button
onClick={handleRejectAll} onClick={handleRejectAll}
disabled={isAnimating} 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>
@@ -196,7 +196,7 @@ export function CookieConsent() {
</div> </div>
<div className="space-y-3"> <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 <input
type="checkbox" type="checkbox"
checked checked
@@ -215,7 +215,7 @@ export function CookieConsent() {
</div> </div>
</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 <input
type="checkbox" type="checkbox"
checked={preferences.analytics} checked={preferences.analytics}
@@ -231,7 +231,7 @@ export function CookieConsent() {
</div> </div>
</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 <input
type="checkbox" type="checkbox"
checked={preferences.marketing} checked={preferences.marketing}
@@ -252,7 +252,7 @@ export function CookieConsent() {
<button <button
onClick={() => setShowSettings(false)} onClick={() => setShowSettings(false)}
disabled={isAnimating} 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>
@@ -288,7 +288,7 @@ export function CookieSettingsButton() {
const event = new CustomEvent('open-cookie-settings'); const event = new CustomEvent('open-cookie-settings');
window.dispatchEvent(event); 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 设置" aria-label="Cookie 设置"
> >
Cookie Cookie
+2 -2
View File
@@ -46,7 +46,7 @@ export function MobileMenu({ className }: MobileMenuProps) {
<button <button
onClick={() => setIsOpen(!isOpen)} onClick={() => setIsOpen(!isOpen)}
onKeyDown={handleKeyDown} 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-label={isOpen ? '关闭菜单' : '打开菜单'}
aria-expanded={isOpen} aria-expanded={isOpen}
aria-controls="mobile-menu-panel" 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" 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="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> </StaticLink>
</li> </li>
))} ))}
+7 -5
View File
@@ -83,16 +83,18 @@ describe('CaseCard', () => {
it('should render background image when provided', async () => { it('should render background image when provided', async () => {
const { CaseCard } = await import('./case-card'); const { CaseCard } = await import('./case-card');
const { container } = render(<CaseCard {...mockCase} />); const { container } = render(<CaseCard {...mockCase} />);
const img = container.querySelector('img[alt=""]'); // 组件用 CSS 背景图而非 <img>,使 404 图片静默降级为纯色底
expect(img).toBeInTheDocument(); const bg = container.querySelector('[style*="background-image"]');
expect(img).toHaveAttribute('src', '/images/cases/manufacturing.jpg'); 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 () => { it('should not render background image when not provided', async () => {
const { CaseCard } = await import('./case-card'); const { CaseCard } = await import('./case-card');
const { container } = render(<CaseCard {...mockCase} image={undefined} />); const { container } = render(<CaseCard {...mockCase} image={undefined} />);
const img = container.querySelector('img[alt=""]'); const bg = container.querySelector('[style*="background-image"]');
expect(img).not.toBeInTheDocument(); expect(bg).not.toBeInTheDocument();
}); });
it('should use context as fallback when challenge is empty', async () => { it('should use context as fallback when challenge is empty', async () => {
@@ -75,9 +75,11 @@ describe('InsightCard', () => {
const { container } = render( const { container } = render(
<InsightCard {...mockInsight} featured image="/images/insights/trends.jpg" /> <InsightCard {...mockInsight} featured image="/images/insights/trends.jpg" />
); );
const img = container.querySelector('img'); // 组件用 CSS 背景图而非 <img>,使 404 图片静默降级为纯色底
expect(img).toBeInTheDocument(); const bg = container.querySelector('[style*="background-image"]');
expect(img).toHaveAttribute('src', '/images/insights/trends.jpg'); 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 () => { it('should render link element when provided', async () => {
+4 -3
View File
@@ -14,11 +14,12 @@ const alertVariants = cva(
default: 'bg-bg-primary text-text-primary border-border-primary', default: 'bg-bg-primary text-text-primary border-border-primary',
destructive: destructive:
'text-error bg-error-bg border-error [&>svg]:text-current', 'text-error bg-error-bg border-error [&>svg]:text-current',
/* 状态色 DEFAULT 仅 3.2-3.7:1,作文字不达 AA,故统一用 -text 变体 */
success: success:
'text-success bg-success-bg border-success [&>svg]:text-current', 'text-success-text bg-success-bg border-success [&>svg]:text-current',
warning: warning:
'text-warning bg-warning-bg border-warning [&>svg]:text-current', 'text-warning-text bg-warning-bg border-warning [&>svg]:text-current',
info: 'text-info bg-info-bg border-info [&>svg]:text-current', info: 'text-info-text bg-info-bg border-info [&>svg]:text-current',
}, },
}, },
defaultVariants: { defaultVariants: {
+4 -3
View File
@@ -16,10 +16,11 @@ const badgeVariants = cva(
outline: outline:
'border-border-accent text-text-primary bg-transparent', 'border-border-accent text-text-primary bg-transparent',
ghost: 'text-text-muted hover:text-text-primary hover:bg-bg-secondary', ghost: 'text-text-muted hover:text-text-primary hover:bg-bg-secondary',
success: 'bg-success/10 text-success border-transparent', /* 状态色 DEFAULT 仅 3.2-3.7:1,作文字不达 AA,故统一用 -text 变体 */
warning: 'bg-warning/10 text-warning border-transparent', 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', 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: { size: {
sm: 'px-2 py-0.5 text-[10px]', sm: 'px-2 py-0.5 text-[10px]',
+8 -8
View File
@@ -18,13 +18,13 @@ function FlipDigit({ digit, prevDigit }: FlipDigitProps) {
return ( return (
<div className="relative w-14 h-20 sm:w-16 sm:h-24 md:w-20 md:h-28 perspective-1000"> <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 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-flip-card-bg)] border-b border-[var(--color-flip-card-border)] overflow-hidden"> <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)]"> <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} {digit}
</div> </div>
</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)]"> <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} {digit}
</div> </div>
@@ -40,7 +40,7 @@ function FlipDigit({ digit, prevDigit }: FlipDigitProps) {
animate={{ rotateX: -180 }} animate={{ rotateX: -180 }}
exit={{ rotateX: -180 }} exit={{ rotateX: -180 }}
transition={{ duration: 0.6, ease: [0.4, 0, 0.2, 1] }} 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={{ style={{
transformOrigin: 'bottom', transformOrigin: 'bottom',
backfaceVisibility: 'hidden', backfaceVisibility: 'hidden',
@@ -58,7 +58,7 @@ function FlipDigit({ digit, prevDigit }: FlipDigitProps) {
animate={{ rotateX: 0 }} animate={{ rotateX: 0 }}
exit={{ rotateX: 0 }} exit={{ rotateX: 0 }}
transition={{ duration: 0.6, ease: [0.4, 0, 0.2, 1] }} 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={{ style={{
transformOrigin: 'top', transformOrigin: 'top',
backfaceVisibility: 'hidden', backfaceVisibility: 'hidden',
@@ -70,9 +70,9 @@ function FlipDigit({ digit, prevDigit }: FlipDigitProps) {
</motion.div> </motion.div>
</AnimatePresence> </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-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-flip-card-divider-subtle)]/50" /> <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-flip-card-divider-subtle)]/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" /> <div className="absolute inset-0 rounded-lg shadow-[inset_0_2px_4px_rgba(0,0,0,0.1)] pointer-events-none" />
+1 -1
View File
@@ -15,7 +15,7 @@ const Input = React.forwardRef<HTMLInputElement, React.ComponentProps<'input'>>(
data-slot="input" data-slot="input"
ref={ref} ref={ref}
className={cn( 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', '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', 'transition-all duration-fast outline-none',
'file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium', 'file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium',
+2 -2
View File
@@ -22,10 +22,10 @@ const SelectTrigger = React.forwardRef<
data-slot="select-trigger" data-slot="select-trigger"
className={cn( 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', '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', 'focus:outline-none focus-visible:ring-2 focus-visible:ring-brand focus-visible:ring-offset-2',
'disabled:cursor-not-allowed disabled:opacity-50', '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', 'transition-colors duration-fast hover:border-border-secondary',
'[&>span]:line-clamp-1', '[&>span]:line-clamp-1',
className className
+2 -2
View File
@@ -27,9 +27,9 @@ const Toaster = ({ ...props }: ToasterProviderProps) => {
} as React.CSSProperties } as React.CSSProperties
} }
icons={{ icons={{
success: <CheckCircle2 className="size-4 text-success" />, success: <CheckCircle2 className="size-4 text-success-text" />,
error: <AlertCircle className="size-4 text-error" />, 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" />, close: <X className="size-4" />,
}} }}
{...props} {...props}
+1 -1
View File
@@ -14,7 +14,7 @@ const Textarea = React.forwardRef<HTMLTextAreaElement, React.ComponentProps<'tex
data-slot="textarea" data-slot="textarea"
ref={ref} ref={ref}
className={cn( 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', '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', 'transition-all duration-fast outline-none',
'disabled:cursor-not-allowed disabled:opacity-50', 'disabled:cursor-not-allowed disabled:opacity-50',
+33 -21
View File
@@ -9,17 +9,21 @@ module.exports = {
theme: { theme: {
extend: { extend: {
colors: { colors: {
/* 重要:Tailwind v3 无法对纯 hex 的 CSS 变量套用 /50 这类透明度修饰符,
会生成无效 CSS 并静默失效(样式不报错,但效果不出现)。
故常用色改用 rgb(通道 / <alpha-value>) 形式 —— 无修饰符时 alpha 默认 1,
视觉结果与原来一致,同时 bg-brand/10、border-ink/30 等能正确编译。 */
ink: { ink: {
DEFAULT: 'var(--color-ink)', DEFAULT: 'rgb(var(--color-ink-rgb) / <alpha-value>)',
light: 'var(--color-ink-light)', light: 'var(--color-ink-light)',
lighter: 'var(--color-ink-lighter)', lighter: 'var(--color-ink-lighter)',
rgb: 'var(--color-ink-rgb)', rgb: 'var(--color-ink-rgb)',
}, },
brand: { brand: {
DEFAULT: 'var(--color-brand)', DEFAULT: 'rgb(var(--color-brand-rgb) / <alpha-value>)',
hover: 'var(--color-brand-hover)', hover: 'var(--color-brand-hover)',
light: 'var(--color-brand-light)', light: 'var(--color-brand-light)',
soft: 'var(--color-brand-soft)', soft: 'rgb(var(--color-brand-rgb) / calc(0.12 * <alpha-value>))',
bg: 'var(--color-brand-bg)', bg: 'var(--color-brand-bg)',
rgb: 'var(--color-brand-rgb)', rgb: 'var(--color-brand-rgb)',
section: 'var(--color-brand-section)', section: 'var(--color-brand-section)',
@@ -27,18 +31,20 @@ module.exports = {
'section-muted': 'var(--color-brand-section-muted)', 'section-muted': 'var(--color-brand-section-muted)',
}, },
accent: { accent: {
blue: 'var(--color-accent-blue)', blue: 'rgb(var(--color-accent-blue-rgb) / <alpha-value>)',
'blue-soft': 'var(--color-accent-blue-soft)', 'blue-soft': 'var(--color-accent-blue-soft)',
teal: 'var(--color-accent-teal)', teal: 'rgb(var(--color-accent-teal-rgb) / <alpha-value>)',
'teal-soft': 'var(--color-accent-teal-soft)', 'teal-soft': 'var(--color-accent-teal-soft)',
amber: 'var(--color-accent-amber)', amber: 'rgb(var(--color-accent-amber-rgb) / <alpha-value>)',
'amber-soft': 'var(--color-accent-amber-soft)', 'amber-soft': 'var(--color-accent-amber-soft)',
purple: 'var(--color-accent-purple)', purple: 'rgb(var(--color-accent-purple-rgb) / <alpha-value>)',
'purple-soft': 'var(--color-accent-purple-soft)', 'purple-soft': 'var(--color-accent-purple-soft)',
cyan: 'rgb(var(--color-accent-cyan-rgb) / <alpha-value>)',
'cyan-soft': 'var(--color-accent-cyan-soft)',
}, },
bg: { bg: {
primary: 'var(--color-bg-primary)', primary: 'rgb(var(--color-bg-primary-rgb) / <alpha-value>)',
secondary: 'var(--color-bg-secondary)', secondary: 'rgb(var(--color-bg-secondary-rgb) / <alpha-value>)',
tertiary: 'var(--color-bg-tertiary)', tertiary: 'var(--color-bg-tertiary)',
section: 'var(--color-bg-section)', section: 'var(--color-bg-section)',
hover: 'var(--color-bg-hover)', hover: 'var(--color-bg-hover)',
@@ -46,47 +52,53 @@ module.exports = {
}, },
text: { text: {
primary: 'var(--color-text-primary)', primary: 'var(--color-text-primary)',
secondary: 'var(--color-text-secondary)', secondary: 'rgb(var(--color-text-secondary-rgb) / <alpha-value>)',
tertiary: 'var(--color-text-tertiary)', tertiary: 'var(--color-text-tertiary)',
muted: 'var(--color-text-muted)', muted: 'rgb(var(--color-text-muted-rgb) / <alpha-value>)',
subtle: 'var(--color-text-subtle)', subtle: 'var(--color-text-subtle)',
placeholder: 'var(--color-text-placeholder)', placeholder: 'var(--color-text-placeholder)',
hint: 'var(--color-text-hint)', hint: 'var(--color-text-hint)',
inverse: 'var(--color-text-inverse)', inverse: 'var(--color-text-inverse)',
}, },
border: { border: {
primary: 'var(--color-border-primary)', primary: 'rgb(var(--color-border-primary-rgb) / <alpha-value>)',
secondary: 'var(--color-border-secondary)', secondary: 'rgb(var(--color-border-secondary-rgb) / <alpha-value>)',
accent: 'var(--color-border-accent)', accent: 'rgb(var(--color-border-accent-rgb) / <alpha-value>)',
light: 'var(--color-border-light)', light: 'rgb(var(--color-border-light-rgb) / <alpha-value>)',
dark: 'var(--color-border-dark)', dark: 'rgb(var(--color-border-dark-rgb) / <alpha-value>)',
brand: 'var(--color-border-brand)', brand: 'rgb(var(--color-border-brand-rgb) / <alpha-value>)',
}, },
link: { link: {
DEFAULT: 'var(--color-link)', DEFAULT: 'var(--color-link)',
hover: 'var(--color-link-hover)', hover: 'var(--color-link-hover)',
}, },
success: { success: {
DEFAULT: 'var(--color-success)', DEFAULT: 'rgb(var(--color-success-rgb) / <alpha-value>)',
hover: 'var(--color-success-hover)', hover: 'var(--color-success-hover)',
bg: 'var(--color-success-bg)', bg: 'var(--color-success-bg)',
rgb: 'var(--color-success-rgb)', rgb: 'var(--color-success-rgb)',
/** 文字请用此变体:DEFAULT 仅 3.30:1,不达 AA */
text: 'var(--color-success-text)',
}, },
warning: { warning: {
DEFAULT: 'var(--color-warning)', DEFAULT: 'rgb(var(--color-warning-rgb) / <alpha-value>)',
hover: 'var(--color-warning-hover)', hover: 'var(--color-warning-hover)',
bg: 'var(--color-warning-bg)', bg: 'var(--color-warning-bg)',
rgb: 'var(--color-warning-rgb)', rgb: 'var(--color-warning-rgb)',
/** 文字请用此变体:DEFAULT 仅 3.19:1,不达 AA */
text: 'var(--color-warning-text)',
}, },
error: { error: {
DEFAULT: 'var(--color-error)', DEFAULT: 'rgb(var(--color-error-rgb) / <alpha-value>)',
hover: 'var(--color-error-hover)', hover: 'var(--color-error-hover)',
bg: 'var(--color-error-bg)', bg: 'var(--color-error-bg)',
rgb: 'var(--color-error-rgb)', rgb: 'var(--color-error-rgb)',
}, },
info: { info: {
DEFAULT: 'var(--color-info)', DEFAULT: 'rgb(var(--color-info-rgb) / <alpha-value>)',
bg: 'var(--color-info-bg)', bg: 'var(--color-info-bg)',
/** 文字请用此变体:DEFAULT 仅 3.68:1,不达 AA */
text: 'var(--color-info-text)',
}, },
dark: { dark: {
bg: 'var(--color-dark-bg)', bg: 'var(--color-dark-bg)',