merge: merge color-heading-optimization into feat-init
- Add color contrast optimization and checking tools - Add heading hierarchy guidelines and checker - Resolve package-lock.json conflict (keeping feat-init version)
This commit is contained in:
@@ -0,0 +1,3 @@
|
||||
# 颜色对比度和标题层级优化报告
|
||||
## 项目概述
|
||||
本报告记录了Novalon网站的颜色对比度和标题层级结构优化工作,旨在提升网站的可访问性(WCAG 2.1 AA标准)和SEO性能。
|
||||
@@ -0,0 +1 @@
|
||||
# 标题层级结构规范
|
||||
@@ -10,6 +10,8 @@
|
||||
"test": "playwright test",
|
||||
"test:smoke": "playwright test --grep @smoke",
|
||||
"test:report": "allure generate test-results/allure-results && allure open",
|
||||
"check:contrast": "tsx scripts/check-color-contrast.ts",
|
||||
"check:headings": "tsx scripts/check-heading-hierarchy.ts",
|
||||
"audit:performance": "node scripts/performance-audit.js",
|
||||
"audit:seo": "node scripts/seo-check.js",
|
||||
"audit:accessibility": "node scripts/accessibility-test.js",
|
||||
@@ -19,6 +21,7 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@antv/g2": "^5.4.8",
|
||||
"@playwright/test": "^1.58.2",
|
||||
"@radix-ui/react-dialog": "^1.1.15",
|
||||
"@radix-ui/react-dropdown-menu": "^2.1.16",
|
||||
"@types/three": "^0.183.1",
|
||||
@@ -48,6 +51,7 @@
|
||||
"eslint-config-next": "^0.2.4",
|
||||
"lighthouse": "^13.0.3",
|
||||
"tailwindcss": "^4",
|
||||
"tsx": "^4.21.0",
|
||||
"typescript": "^5"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
import { calculateContrastRatio, meetsWCAGStandard } from '../src/lib/color-contrast.ts';
|
||||
|
||||
interface ColorPair {
|
||||
name: string;
|
||||
foreground: string;
|
||||
background: string;
|
||||
textSize?: 'normal' | 'large';
|
||||
}
|
||||
|
||||
const criticalColorPairs: ColorPair[] = [
|
||||
{ name: 'Primary text on primary background', foreground: '#1C1C1C', background: '#FFFFFF' },
|
||||
{ name: 'Secondary text on primary background', foreground: '#3D3D3D', background: '#FFFFFF' },
|
||||
{ name: 'Tertiary text on primary background', foreground: '#4A4A4A', background: '#FFFFFF' },
|
||||
{ name: 'Muted text on primary background', foreground: '#6B6B6B', background: '#FFFFFF' },
|
||||
{ name: 'Brand primary on white', foreground: '#C41E3A', background: '#FFFFFF' },
|
||||
{ name: 'Brand primary on brand bg', foreground: '#C41E3A', background: '#FEF2F4' },
|
||||
{ name: 'Link on hover', foreground: '#C41E3A', background: '#FFFFFF' },
|
||||
];
|
||||
|
||||
function checkContrast() {
|
||||
console.log('🎨 Color Contrast Check\n');
|
||||
|
||||
let failures = 0;
|
||||
let passes = 0;
|
||||
|
||||
criticalColorPairs.forEach(pair => {
|
||||
const result = meetsWCAGStandard(
|
||||
pair.foreground,
|
||||
pair.background,
|
||||
'AA',
|
||||
pair.textSize || 'normal'
|
||||
);
|
||||
|
||||
const status = result.passes ? '✅ PASS' : '❌ FAIL';
|
||||
const ratio = result.ratio.toFixed(2);
|
||||
|
||||
console.log(`${status} ${pair.name}`);
|
||||
console.log(` Ratio: ${ratio}:1 (Required: ${result.requiredRatio}:1)`);
|
||||
console.log(` FG: ${pair.foreground} | BG: ${pair.background}\n`);
|
||||
|
||||
if (result.passes) {
|
||||
passes++;
|
||||
} else {
|
||||
failures++;
|
||||
}
|
||||
});
|
||||
|
||||
console.log(`\n📊 Summary:`);
|
||||
console.log(` Total: ${criticalColorPairs.length}`);
|
||||
console.log(` ✅ Passes: ${passes}`);
|
||||
console.log(` ❌ Failures: ${failures}`);
|
||||
|
||||
if (failures > 0) {
|
||||
console.log('\n⚠️ Some color pairs do not meet WCAG AA standards!');
|
||||
process.exit(1);
|
||||
} else {
|
||||
console.log('\n✅ All color pairs meet WCAG AA standards!');
|
||||
process.exit(0);
|
||||
}
|
||||
}
|
||||
|
||||
checkContrast();
|
||||
@@ -0,0 +1 @@
|
||||
import { chromium } from "playwright";
|
||||
@@ -0,0 +1,25 @@
|
||||
const { calculateContrastRatio, meetsWCAGStandard } = require('../src/lib/color-contrast.ts');
|
||||
|
||||
console.log('Testing CSS color contrast...');
|
||||
|
||||
const primaryResult = meetsWCAGStandard('#1C1C1C', '#FFFFFF', 'AA', 'normal');
|
||||
console.log('Primary text (#1C1C1C) on background (#FFFFFF):', primaryResult);
|
||||
|
||||
const tertiaryResult = meetsWCAGStandard('#4A4A4A', '#FFFFFF', 'AA', 'normal');
|
||||
console.log('Tertiary text (#4A4A4A) on background (#FFFFFF):', tertiaryResult);
|
||||
|
||||
const mutedResult = meetsWCAGStandard('#6B6B6B', '#FFFFFF', 'AA', 'normal');
|
||||
console.log('Muted text (#6B6B6B) on background (#FFFFFF):', mutedResult);
|
||||
|
||||
console.log('\nExpected: All should pass (passes: true)');
|
||||
console.log('Actual results:');
|
||||
console.log('- Primary:', primaryResult.passes ? '✓ PASS' : '✗ FAIL', `(ratio: ${primaryResult.ratio.toFixed(2)}:1)`);
|
||||
console.log('- Tertiary:', tertiaryResult.passes ? '✓ PASS' : '✗ FAIL', `(ratio: ${tertiaryResult.ratio.toFixed(2)}:1)`);
|
||||
console.log('- Muted:', mutedResult.passes ? '✓ PASS' : '✗ FAIL', `(ratio: ${mutedResult.ratio.toFixed(2)}:1)`);
|
||||
|
||||
if (!primaryResult.passes || !tertiaryResult.passes || !mutedResult.passes) {
|
||||
console.log('\n⚠️ Some tests failed - need to optimize CSS variables');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
console.log('\n✅ All tests passed!');
|
||||
@@ -0,0 +1,15 @@
|
||||
const { calculateContrastRatio, meetsWCAGStandard } = require('../src/lib/color-contrast.ts');
|
||||
|
||||
console.log('Testing color contrast functions...');
|
||||
|
||||
const ratio = calculateContrastRatio('#000000', '#FFFFFF');
|
||||
console.log('Black on white ratio:', ratio);
|
||||
console.log('Expected: ~21, Actual:', ratio);
|
||||
|
||||
const result = meetsWCAGStandard('#000000', '#FFFFFF', 'AA', 'normal');
|
||||
console.log('WCAG AA compliance:', result);
|
||||
|
||||
const lowContrastResult = meetsWCAGStandard('#808080', '#FFFFFF', 'AA', 'normal');
|
||||
console.log('Low contrast test:', lowContrastResult);
|
||||
|
||||
console.log('All tests completed!');
|
||||
@@ -223,7 +223,7 @@ export function AboutClient() {
|
||||
<span className="text-sm font-medium text-[#C41E3A]">{milestone.date}</span>
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<h4 className="font-semibold text-[#1A1A2E] mb-1">{milestone.title}</h4>
|
||||
<h3 className="font-semibold text-[#1A1A2E] mb-1">{milestone.title}</h3>
|
||||
<p className="text-[#718096] text-sm">{milestone.description}</p>
|
||||
</div>
|
||||
</motion.div>
|
||||
|
||||
@@ -183,7 +183,7 @@ export default function ContactPage() {
|
||||
`}
|
||||
>
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold text-[#1C1C1C] mb-6">联系方式</h3>
|
||||
<h2 className="text-lg font-semibold text-[#1C1C1C] mb-6">联系方式</h2>
|
||||
<div className="space-y-4" data-testid="contact-info">
|
||||
<div className="flex items-start gap-4 group" data-testid="email-info">
|
||||
<div className="w-10 h-10 bg-[#C41E3A] rounded-md flex items-center justify-center flex-shrink-0 transition-transform duration-200 group-hover:scale-105">
|
||||
@@ -222,7 +222,7 @@ export default function ContactPage() {
|
||||
<div className="bg-[#FFFBF5] p-5 rounded-lg border border-[#E5E5E5]" data-testid="work-hours-card">
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<Clock className="w-4 h-4 text-[#C41E3A]" />
|
||||
<h4 className="text-sm font-medium text-[#1C1C1C]">工作时间</h4>
|
||||
<h2 className="text-sm font-medium text-[#1C1C1C]">工作时间</h2>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<div className="flex justify-between text-sm" data-testid="work-hours-row">
|
||||
@@ -235,7 +235,7 @@ export default function ContactPage() {
|
||||
<div className="bg-[#FFFBF5] p-5 rounded-lg border border-[#E5E5E5]">
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<HeadphonesIcon className="w-4 h-4 text-[#C41E3A]" />
|
||||
<h4 className="text-sm font-medium text-[#1C1C1C]">我们的承诺</h4>
|
||||
<h2 className="text-sm font-medium text-[#1C1C1C]">我们的承诺</h2>
|
||||
</div>
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-start gap-2">
|
||||
@@ -262,7 +262,7 @@ export default function ContactPage() {
|
||||
`}
|
||||
>
|
||||
<div className="bg-[#F5F7FA] p-6 sm:p-8 rounded-lg border border-[#E2E8F0] flex-1 flex flex-col">
|
||||
<h3 className="text-lg font-semibold text-[#1A1A2E] mb-6">发送消息</h3>
|
||||
<h2 className="text-lg font-semibold text-[#1A1A2E] mb-6">发送消息</h2>
|
||||
|
||||
{isSubmitted ? (
|
||||
<div className="text-center py-12 flex-1 flex items-center justify-center">
|
||||
|
||||
+3
-3
@@ -31,7 +31,7 @@
|
||||
--color-brand-primary-bg: #FEF2F4;
|
||||
|
||||
/* 背景色系 - 宣纸白 */
|
||||
--color-bg-primary: #FAFAFA;
|
||||
--color-bg-primary: #FFFFFF;
|
||||
--color-bg-secondary: #FFFBF5;
|
||||
--color-bg-tertiary: #F5F5F5;
|
||||
--color-bg-hover: #EFEFEF;
|
||||
@@ -39,8 +39,8 @@
|
||||
/* 文字色系 - 墨色层次 */
|
||||
--color-text-primary: #1C1C1C;
|
||||
--color-text-secondary: #3D3D3D;
|
||||
--color-text-tertiary: #5C5C5C;
|
||||
--color-text-muted: #8C8C8C;
|
||||
--color-text-tertiary: #4A4A4A;
|
||||
--color-text-muted: #6B6B6B;
|
||||
|
||||
/* 边框色系 */
|
||||
--color-border-primary: #E5E5E5;
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
interface ColorRGB {
|
||||
r: number;
|
||||
g: number;
|
||||
b: number;
|
||||
}
|
||||
|
||||
interface ContrastResult {
|
||||
passes: boolean;
|
||||
ratio: number;
|
||||
requiredRatio: number;
|
||||
}
|
||||
|
||||
function hexToRgb(hex: string): ColorRGB {
|
||||
const cleanHex = hex.replace('#', '');
|
||||
const r = parseInt(cleanHex.substring(0, 2), 16);
|
||||
const g = parseInt(cleanHex.substring(2, 4), 16);
|
||||
const b = parseInt(cleanHex.substring(4, 6), 16);
|
||||
return { r, g, b };
|
||||
}
|
||||
|
||||
function getLuminance(rgb: ColorRGB): number {
|
||||
const { r, g, b } = rgb;
|
||||
const a = [r, g, b].map(v => {
|
||||
v /= 255;
|
||||
return v <= 0.03928 ? v / 12.92 : Math.pow((v + 0.055) / 1.055, 2.4);
|
||||
});
|
||||
return a[0] * 0.2126 + a[1] * 0.7152 + a[2] * 0.0722;
|
||||
}
|
||||
|
||||
export function calculateContrastRatio(foreground: string, background: string): number {
|
||||
const fgRgb = hexToRgb(foreground);
|
||||
const bgRgb = hexToRgb(background);
|
||||
|
||||
const fgLuminance = getLuminance(fgRgb);
|
||||
const bgLuminance = getLuminance(bgRgb);
|
||||
|
||||
const lighter = Math.max(fgLuminance, bgLuminance);
|
||||
const darker = Math.min(fgLuminance, bgLuminance);
|
||||
|
||||
return (lighter + 0.05) / (darker + 0.05);
|
||||
}
|
||||
|
||||
export function meetsWCAGStandard(
|
||||
foreground: string,
|
||||
background: string,
|
||||
level: 'AA' | 'AAA',
|
||||
textSize: 'normal' | 'large'
|
||||
): ContrastResult {
|
||||
const ratio = calculateContrastRatio(foreground, background);
|
||||
|
||||
let requiredRatio: number;
|
||||
if (level === 'AA') {
|
||||
requiredRatio = textSize === 'normal' ? 4.5 : 3;
|
||||
} else {
|
||||
requiredRatio = textSize === 'normal' ? 7 : 4.5;
|
||||
}
|
||||
|
||||
return {
|
||||
passes: ratio >= requiredRatio,
|
||||
ratio,
|
||||
requiredRatio
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import { test, expect } from '@playwright/test';
|
||||
import { calculateContrastRatio, meetsWCAGStandard } from '@/lib/color-contrast';
|
||||
|
||||
test('should calculate correct contrast ratio for black on white', () => {
|
||||
const ratio = calculateContrastRatio('#000000', '#FFFFFF');
|
||||
expect(ratio).toBeCloseTo(21, 1);
|
||||
});
|
||||
|
||||
test('should identify WCAG AA compliance for normal text', () => {
|
||||
const result = meetsWCAGStandard('#000000', '#FFFFFF', 'AA', 'normal');
|
||||
expect(result.passes).toBe(true);
|
||||
expect(result.ratio).toBeGreaterThan(4.5);
|
||||
});
|
||||
|
||||
test('should fail WCAG AA for low contrast colors', () => {
|
||||
const result = meetsWCAGStandard('#808080', '#FFFFFF', 'AA', 'normal');
|
||||
expect(result.passes).toBe(false);
|
||||
expect(result.ratio).toBeLessThan(4.5);
|
||||
});
|
||||
@@ -0,0 +1,17 @@
|
||||
import { test, expect } from '@playwright/test';
|
||||
import { calculateContrastRatio, meetsWCAGStandard } from '@/lib/color-contrast';
|
||||
|
||||
test('primary text on primary background should meet WCAG AA', () => {
|
||||
const result = meetsWCAGStandard('#1C1C1C', '#FAFAFA', 'AA', 'normal');
|
||||
expect(result.passes).toBe(true);
|
||||
});
|
||||
|
||||
test('tertiary text on primary background should meet WCAG AA', () => {
|
||||
const result = meetsWCAGStandard('#5C5C5C', '#FAFAFA', 'AA', 'normal');
|
||||
expect(result.passes).toBe(true);
|
||||
});
|
||||
|
||||
test('muted text on primary background should meet WCAG AA', () => {
|
||||
const result = meetsWCAGStandard('#8C8C8C', '#FAFAFA', 'AA', 'normal');
|
||||
expect(result.passes).toBe(true);
|
||||
});
|
||||
Reference in New Issue
Block a user