- Create Economist SubmissionTracking package correctly: * mainArticle = full blog post content * coverLetter = 216-word SIR— letter * Links to blog post via blogPostId - Archive 'Letter to The Economist' from blog posts (it's the cover letter) - Fix date display on article cards (use published_at) - Target publication already displaying via blue badge Database changes: - Make blogPostId optional in SubmissionTracking model - Economist package ID: 68fa85ae49d4900e7f2ecd83 - Le Monde package ID: 68fa2abd2e6acd5691932150 Next: Enhanced modal with tabs, validation, export 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
226 lines
6.5 KiB
JavaScript
Executable file
226 lines
6.5 KiB
JavaScript
Executable file
#!/usr/bin/env node
|
|
|
|
/**
|
|
* Color Contrast Checker
|
|
*
|
|
* Verifies color contrast ratios meet WCAG 2.1 AA standards (4.5:1 normal text, 3:1 large text)
|
|
*
|
|
* Copyright 2025 Tractatus Project
|
|
* Licensed under Apache License 2.0
|
|
*/
|
|
|
|
const colors = {
|
|
reset: '\x1b[0m',
|
|
bright: '\x1b[1m',
|
|
green: '\x1b[32m',
|
|
yellow: '\x1b[33m',
|
|
red: '\x1b[31m',
|
|
cyan: '\x1b[36m'
|
|
};
|
|
|
|
function log(message, color = 'reset') {
|
|
console.log(`${colors[color]}${message}${colors.reset}`);
|
|
}
|
|
|
|
function success(message) {
|
|
log(` ✓ ${message}`, 'green');
|
|
}
|
|
|
|
function warning(message) {
|
|
log(` ⚠ ${message}`, 'yellow');
|
|
}
|
|
|
|
function error(message) {
|
|
log(` ✗ ${message}`, 'red');
|
|
}
|
|
|
|
/**
|
|
* Convert hex color to RGB
|
|
*/
|
|
function hexToRgb(hex) {
|
|
const result = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(hex);
|
|
return result ? {
|
|
r: parseInt(result[1], 16),
|
|
g: parseInt(result[2], 16),
|
|
b: parseInt(result[3], 16)
|
|
} : null;
|
|
}
|
|
|
|
/**
|
|
* Calculate relative luminance (WCAG formula)
|
|
*/
|
|
function getLuminance(rgb) {
|
|
const rsRGB = rgb.r / 255;
|
|
const gsRGB = rgb.g / 255;
|
|
const bsRGB = rgb.b / 255;
|
|
|
|
const r = rsRGB <= 0.03928 ? rsRGB / 12.92 : Math.pow((rsRGB + 0.055) / 1.055, 2.4);
|
|
const g = gsRGB <= 0.03928 ? gsRGB / 12.92 : Math.pow((gsRGB + 0.055) / 1.055, 2.4);
|
|
const b = bsRGB <= 0.03928 ? bsRGB / 12.92 : Math.pow((bsRGB + 0.055) / 1.055, 2.4);
|
|
|
|
return 0.2126 * r + 0.7152 * g + 0.0722 * b;
|
|
}
|
|
|
|
/**
|
|
* Calculate contrast ratio between two colors
|
|
*/
|
|
function getContrastRatio(color1, color2) {
|
|
const rgb1 = hexToRgb(color1);
|
|
const rgb2 = hexToRgb(color2);
|
|
|
|
const lum1 = getLuminance(rgb1);
|
|
const lum2 = getLuminance(rgb2);
|
|
|
|
const lighter = Math.max(lum1, lum2);
|
|
const darker = Math.min(lum1, lum2);
|
|
|
|
return (lighter + 0.05) / (darker + 0.05);
|
|
}
|
|
|
|
/**
|
|
* Check if contrast ratio meets WCAG AA standards
|
|
*/
|
|
function meetsWCAG_AA(ratio, largeText = false) {
|
|
const threshold = largeText ? 3.0 : 4.5;
|
|
return ratio >= threshold;
|
|
}
|
|
|
|
/**
|
|
* Tailwind color palette (common colors used in Tractatus site)
|
|
*/
|
|
const tailwindColors = {
|
|
'white': '#ffffff',
|
|
'gray-50': '#f9fafb',
|
|
'gray-100': '#f3f4f6',
|
|
'gray-200': '#e5e7eb',
|
|
'gray-300': '#d1d5db',
|
|
'gray-400': '#9ca3af',
|
|
'gray-500': '#6b7280',
|
|
'gray-600': '#4b5563',
|
|
'gray-700': '#374151',
|
|
'gray-800': '#1f2937',
|
|
'gray-900': '#111827',
|
|
'blue-50': '#eff6ff',
|
|
'blue-100': '#dbeafe',
|
|
'blue-400': '#60a5fa',
|
|
'blue-500': '#3b82f6',
|
|
'blue-600': '#2563eb',
|
|
'blue-700': '#1d4ed8',
|
|
'blue-800': '#1e40af',
|
|
'blue-900': '#1e3a8a',
|
|
'purple-500': '#a855f7',
|
|
'purple-600': '#9333ea',
|
|
'purple-700': '#7e22ce',
|
|
'green-500': '#22c55e',
|
|
'green-600': '#16a34a',
|
|
'green-700': '#15803d',
|
|
'yellow-600': '#ca8a04',
|
|
'amber-500': '#f59e0b',
|
|
'amber-800': '#92400e',
|
|
'amber-900': '#78350f',
|
|
'red-600': '#dc2626'
|
|
};
|
|
|
|
/**
|
|
* Color combinations used on site
|
|
*/
|
|
const colorCombinations = [
|
|
// Body text on backgrounds
|
|
{ name: 'Body text (gray-900 on white)', fg: 'gray-900', bg: 'white', largeText: false },
|
|
{ name: 'Body text (gray-700 on white)', fg: 'gray-700', bg: 'white', largeText: false },
|
|
{ name: 'Body text (gray-600 on white)', fg: 'gray-600', bg: 'white', largeText: false },
|
|
{ name: 'Muted text (gray-500 on white)', fg: 'gray-500', bg: 'white', largeText: false },
|
|
|
|
// Links
|
|
{ name: 'Link (blue-600 on white)', fg: 'blue-600', bg: 'white', largeText: false },
|
|
{ name: 'Link hover (blue-700 on white)', fg: 'blue-700', bg: 'white', largeText: false },
|
|
|
|
// Buttons
|
|
{ name: 'Button text (white on blue-600)', fg: 'white', bg: 'blue-600', largeText: false },
|
|
{ name: 'Button hover (white on blue-700)', fg: 'white', bg: 'blue-700', largeText: false },
|
|
{ name: 'Purple button (white on purple-600)', fg: 'white', bg: 'purple-600', largeText: false },
|
|
{ name: 'Green button (white on green-700)', fg: 'white', bg: 'green-700', largeText: false },
|
|
|
|
// Hero section
|
|
{ name: 'Hero subtitle (blue-100 on blue-700)', fg: 'blue-100', bg: 'blue-700', largeText: true },
|
|
|
|
// Footer
|
|
{ name: 'Footer text (gray-400 on gray-900)', fg: 'gray-400', bg: 'gray-900', largeText: false },
|
|
{ name: 'Footer links (blue-400 on gray-900)', fg: 'blue-400', bg: 'gray-900', largeText: false },
|
|
|
|
// Alerts/Messages
|
|
{ name: 'Success message (green-900 on green-50)', fg: '#065f46', bg: '#d1fae5', largeText: false },
|
|
{ name: 'Error message (red-900 on red-50)', fg: '#991b1b', bg: '#fee2e2', largeText: false },
|
|
{ name: 'Warning message (amber-900 on amber-50)', fg: 'amber-900', bg: '#fef3c7', largeText: false },
|
|
|
|
// Cards/Sections
|
|
{ name: 'Card text (gray-700 on white)', fg: 'gray-700', bg: 'white', largeText: false },
|
|
{ name: 'Card header (gray-900 on white)', fg: 'gray-900', bg: 'white', largeText: true },
|
|
];
|
|
|
|
/**
|
|
* Main check
|
|
*/
|
|
function main() {
|
|
log('═'.repeat(70), 'cyan');
|
|
log(' Color Contrast Checker (WCAG 2.1 AA)', 'bright');
|
|
log('═'.repeat(70), 'cyan');
|
|
console.log('');
|
|
|
|
let passCount = 0;
|
|
let failCount = 0;
|
|
let warnings = 0;
|
|
|
|
colorCombinations.forEach(combo => {
|
|
const fgColor = tailwindColors[combo.fg] || combo.fg;
|
|
const bgColor = tailwindColors[combo.bg] || combo.bg;
|
|
|
|
const ratio = getContrastRatio(fgColor, bgColor);
|
|
const passes = meetsWCAG_AA(ratio, combo.largeText);
|
|
const threshold = combo.largeText ? '3:1' : '4.5:1';
|
|
|
|
const ratioStr = ratio.toFixed(2) + ':1';
|
|
|
|
if (passes) {
|
|
success(`${combo.name.padEnd(45)} ${ratioStr.padStart(8)} (>= ${threshold}) ✓`);
|
|
passCount++;
|
|
} else {
|
|
// Check if it's close (within 0.3 of threshold)
|
|
const minRatio = combo.largeText ? 3.0 : 4.5;
|
|
if (ratio >= minRatio - 0.3) {
|
|
warning(`${combo.name.padEnd(45)} ${ratioStr.padStart(8)} (< ${threshold}) ⚠`);
|
|
warnings++;
|
|
} else {
|
|
error(`${combo.name.padEnd(45)} ${ratioStr.padStart(8)} (< ${threshold}) ✗`);
|
|
failCount++;
|
|
}
|
|
}
|
|
});
|
|
|
|
console.log('');
|
|
log('═'.repeat(70), 'cyan');
|
|
log(' Summary', 'bright');
|
|
log('═'.repeat(70), 'cyan');
|
|
console.log('');
|
|
log(` Combinations Checked: ${colorCombinations.length}`, 'bright');
|
|
log(` Passed: ${passCount}`, 'green');
|
|
if (warnings > 0) log(` Warnings: ${warnings}`, 'yellow');
|
|
if (failCount > 0) log(` Failed: ${failCount}`, 'red');
|
|
console.log('');
|
|
|
|
if (failCount > 0) {
|
|
error('Some color combinations fail WCAG AA standards');
|
|
console.log('');
|
|
process.exit(1);
|
|
} else if (warnings > 0) {
|
|
warning('All combinations pass, but some are borderline');
|
|
console.log('');
|
|
process.exit(0);
|
|
} else {
|
|
success('All color combinations meet WCAG AA standards');
|
|
console.log('');
|
|
process.exit(0);
|
|
}
|
|
}
|
|
|
|
main();
|