81a93a489b
Backend — Photos timeline now groups by real capture date instead of upload time. New MediaMetadataService (FileLifecycleHook) extracts EXIF DateTimeOriginal from images and container creation_time from videos (mov/mp4/mkv) via nom-exif, timezone-correct (OffsetTimeOriginal), persisting captured_at so the existing media_sort_date trigger takes over. Adds POST /admin/photos/metadata/reextract to backfill existing media. Falls back to upload date when no embedded date exists. Frontend — premium grid cards: combined metadata line (relative date · size, owner avatar when shared), custom selection checkbox with a clear checked state, uniform full-width 4:3 thumbnail tiles independent of filename length, centered file-type icons, and a hit-test fix so checkbox/star/kebab clicks reach the controls (the decorative thumbnail no longer captures pointer events). Notification messages internationalised across all 16 locales. Broader polish: design tokens, a11y/focus-visible states, brand + PWA assets. Chore — bump semver-compatible dependencies (cargo upgrade); add nom-exif 3.6.1. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
104 lines
3.6 KiB
JavaScript
104 lines
3.6 KiB
JavaScript
#!/usr/bin/env node
|
|
// WCAG AA contrast guardrail.
|
|
//
|
|
// Resolves every text/background design token (through light-dark() and var()
|
|
// aliases) and fails (exit 1) if any text-on-surface or semantic text-on-tint
|
|
// pair drops below 4.5:1 in either light or dark mode. Keeps the palette from
|
|
// silently regressing into unreadable greys.
|
|
//
|
|
// node scripts/check-contrast.mjs
|
|
import { readFileSync } from 'node:fs';
|
|
import { join, dirname } from 'node:path';
|
|
import { fileURLToPath } from 'node:url';
|
|
|
|
const varsPath = join(
|
|
dirname(fileURLToPath(import.meta.url)),
|
|
'..',
|
|
'static',
|
|
'css',
|
|
'base',
|
|
'variables.css'
|
|
);
|
|
const src = readFileSync(varsPath, 'utf8');
|
|
|
|
/** First (\:root) definition of each token. */
|
|
const raw = {};
|
|
for (const m of src.matchAll(/(--[a-z0-9-]+)\s*:\s*([^;]+);/gi)) {
|
|
if (!(m[1] in raw)) raw[m[1]] = m[2].trim();
|
|
}
|
|
|
|
function resolve(val, mode, depth = 0) {
|
|
if (depth > 12 || val == null) return null;
|
|
val = String(val).trim();
|
|
let m = val.match(/^light-dark\(\s*(.+?),\s*(.+)\)\s*$/);
|
|
if (m) return resolve(mode === 'light' ? m[1] : m[2], mode, depth + 1);
|
|
m = val.match(/^var\(\s*(--[a-z0-9-]+)\s*\)/);
|
|
if (m) return resolve(raw[m[1]], mode, depth + 1);
|
|
m = val.match(/^#([0-9a-fA-F]{3,8})\b/);
|
|
if (m) {
|
|
let h = m[1];
|
|
if (h.length === 3)
|
|
h = [...h].map((c) => c + c).join('');
|
|
return '#' + h.slice(0, 6).toLowerCase();
|
|
}
|
|
if (val === 'white') return '#ffffff';
|
|
if (val === 'black') return '#000000';
|
|
return null;
|
|
}
|
|
|
|
const lin = (c) => {
|
|
c /= 255;
|
|
return c <= 0.03928 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4;
|
|
};
|
|
const lum = (h) =>
|
|
0.2126 * lin(parseInt(h.slice(1, 3), 16)) +
|
|
0.7152 * lin(parseInt(h.slice(3, 5), 16)) +
|
|
0.0722 * lin(parseInt(h.slice(5, 7), 16));
|
|
const ratio = (fg, bg) => {
|
|
const a = lum(fg);
|
|
const b = lum(bg);
|
|
return (Math.max(a, b) + 0.05) / (Math.min(a, b) + 0.05);
|
|
};
|
|
|
|
const texts = [
|
|
'--color-text', '--color-text-heading', '--color-text-secondary', '--color-text-muted',
|
|
'--color-text-subtle', '--color-text-faint', '--color-text-placeholder', '--color-text-gray',
|
|
'--color-text-medium', '--color-text-light', '--color-text-faint2', '--color-text-dim', '--color-text-dark'
|
|
];
|
|
const bgs = [
|
|
'--color-bg-surface', '--color-bg-page', '--color-bg-hover', '--color-bg-input',
|
|
'--color-bg-subtle', '--color-bg-muted', '--color-bg-input-alt'
|
|
];
|
|
const semantic = [
|
|
['--color-success-text', '--color-success-bg'],
|
|
['--color-error-text', '--color-error-bg'],
|
|
['--color-warning-text', '--color-warning-bg'],
|
|
['--color-info-text', '--color-info-bg'],
|
|
['--color-accent-text', '--color-bg-surface'],
|
|
['--color-accent-text', '--color-bg-page'],
|
|
['--color-accent-text', '--color-bg-muted']
|
|
];
|
|
|
|
const fails = [];
|
|
for (const mode of ['light', 'dark']) {
|
|
for (const t of texts)
|
|
for (const b of bgs) {
|
|
const fg = resolve(raw[t], mode);
|
|
const bg = resolve(raw[b], mode);
|
|
if (fg && bg && ratio(fg, bg) < 4.5)
|
|
fails.push(`${mode}: ${t}(${fg}) on ${b}(${bg}) = ${ratio(fg, bg).toFixed(2)}`);
|
|
}
|
|
for (const [t, b] of semantic) {
|
|
const fg = resolve(raw[t], mode);
|
|
const bg = resolve(raw[b], mode);
|
|
if (fg && bg && ratio(fg, bg) < 4.5)
|
|
fails.push(`${mode}: ${t}(${fg}) on ${b}(${bg}) = ${ratio(fg, bg).toFixed(2)}`);
|
|
}
|
|
}
|
|
|
|
if (fails.length) {
|
|
console.error('✖ WCAG AA contrast failures (<4.5:1):\n ' + fails.join('\n '));
|
|
process.exit(1);
|
|
}
|
|
console.log('✓ All text/background token pairs pass WCAG AA (4.5:1) in light and dark.');
|