feat: photo/video capture-date pipeline + premium UI/UX overhaul
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>
This commit is contained in:
@@ -0,0 +1,49 @@
|
||||
#!/usr/bin/env node
|
||||
// check-brand-drift.mjs — guard the brand mark against silent change.
|
||||
//
|
||||
// Fails (exit 1) if the canonical logo SVG or the logo gradient token drift
|
||||
// from the locked baseline below. The brand is an ownable asset; an accidental
|
||||
// recolour, a stretched glyph, or a "tweaked" gradient should never sneak in
|
||||
// via an unrelated PR. If a change IS intentional, update LOCK deliberately
|
||||
// (with design sign-off) — that diff is the audit trail.
|
||||
//
|
||||
// node scripts/check-brand-drift.mjs
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { createHash } from 'node:crypto';
|
||||
import { join, dirname } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const repo = join(dirname(fileURLToPath(import.meta.url)), '..');
|
||||
const sha16 = (s) => createHash('sha256').update(s).digest('hex').slice(0, 16);
|
||||
|
||||
// ── Locked baseline ─────────────────────────────────────────────────────────
|
||||
// Update INTENTIONALLY when the brand changes (and only then).
|
||||
const LOCK = {
|
||||
logoHash: '7fbd2016e9caeac1', // sha256(static/logo/logo-plain.svg)[:16]
|
||||
gradient: 'linear-gradient(135deg, #ff5e3a 0%, #ff8a5c 100%)' // --color-logo-gradient
|
||||
};
|
||||
|
||||
const logo = readFileSync(join(repo, 'static/logo/logo-plain.svg'), 'utf8');
|
||||
const vars = readFileSync(join(repo, 'static/css/base/variables.css'), 'utf8');
|
||||
const gradMatch = vars.match(/--color-logo-gradient:\s*([^;]+);/);
|
||||
const gradient = gradMatch ? gradMatch[1].trim().replace(/\s+/g, ' ') : '(token missing!)';
|
||||
|
||||
let failed = false;
|
||||
const actualLogoHash = sha16(logo);
|
||||
if (actualLogoHash !== LOCK.logoHash) {
|
||||
console.error(`✖ Brand mark drift: logo-plain.svg hash ${actualLogoHash} ≠ locked ${LOCK.logoHash}`);
|
||||
failed = true;
|
||||
}
|
||||
if (gradient !== LOCK.gradient) {
|
||||
console.error(`✖ Logo gradient drift:\n actual: ${gradient}\n locked: ${LOCK.gradient}`);
|
||||
failed = true;
|
||||
}
|
||||
|
||||
if (failed) {
|
||||
console.error(
|
||||
'\nThe brand mark or logo gradient changed. If this is intentional, update' +
|
||||
'\nLOCK in scripts/check-brand-drift.mjs (with design sign-off).'
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
console.log('✓ Brand mark + logo gradient match the locked baseline.');
|
||||
@@ -0,0 +1,103 @@
|
||||
#!/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.');
|
||||
@@ -0,0 +1,43 @@
|
||||
#!/usr/bin/env node
|
||||
// Dead-token report (informational, exit 0): design tokens defined in
|
||||
// variables.css but never referenced via var() anywhere in static/.
|
||||
//
|
||||
// NOTE: a cleanup AID, not a hard gate — some tokens (file-type / calendar
|
||||
// colours) are referenced by JS string construction, so excluded prefixes are
|
||||
// skipped to avoid false positives. Verify before pruning.
|
||||
//
|
||||
// node scripts/check-dead-tokens.mjs
|
||||
import { readFileSync, readdirSync, statSync } from 'node:fs';
|
||||
import { join, dirname } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const root = join(dirname(fileURLToPath(import.meta.url)), '..', 'static');
|
||||
const EXCLUDE_PREFIX = ['--color-ft-', '--color-cal-']; // referenced dynamically from JS
|
||||
|
||||
const varsSrc = readFileSync(join(root, 'css', 'base', 'variables.css'), 'utf8');
|
||||
const defined = [...varsSrc.matchAll(/(--[a-z0-9-]+)\s*:/gi)].map((m) => m[1]);
|
||||
|
||||
function walk(dir, files = []) {
|
||||
for (const name of readdirSync(dir)) {
|
||||
const p = join(dir, name);
|
||||
if (statSync(p).isDirectory()) walk(p, files);
|
||||
else if (/\.(css|js|html|webmanifest)$/.test(name)) files.push(p);
|
||||
}
|
||||
return files;
|
||||
}
|
||||
let corpus = '';
|
||||
for (const f of walk(root)) corpus += readFileSync(f, 'utf8');
|
||||
const used = new Set([...corpus.matchAll(/var\(\s*(--[a-z0-9-]+)/gi)].map((m) => m[1]));
|
||||
|
||||
const dead = defined
|
||||
.filter((t) => !used.has(t))
|
||||
.filter((t) => !EXCLUDE_PREFIX.some((p) => t.startsWith(p)))
|
||||
.sort();
|
||||
|
||||
if (dead.length) {
|
||||
console.log(`Dead-token candidates (defined, never referenced via var()): ${dead.length}`);
|
||||
console.log(' ' + dead.join('\n '));
|
||||
console.log('\n(Verify each before pruning — some may be referenced dynamically.)');
|
||||
} else {
|
||||
console.log('✓ No dead-token candidates.');
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
#!/usr/bin/env node
|
||||
// Heading-order guardrail: each page has at least one <h1> and never skips a
|
||||
// level going deeper (e.g. h1 → h3 without an h2). Fails (exit 1) on violations.
|
||||
//
|
||||
// node scripts/check-headings.mjs
|
||||
import { readFileSync, readdirSync } from 'node:fs';
|
||||
import { join, dirname } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const staticDir = join(dirname(fileURLToPath(import.meta.url)), '..', 'static');
|
||||
const pages = readdirSync(staticDir).filter((f) => f.endsWith('.html'));
|
||||
let problems = 0;
|
||||
|
||||
for (const page of pages) {
|
||||
const html = readFileSync(join(staticDir, page), 'utf8');
|
||||
const levels = [...html.matchAll(/<h([1-6])\b/gi)].map((m) => Number(m[1]));
|
||||
if (!levels.length) continue; // heading-less shells (error pages) are fine
|
||||
const issues = [];
|
||||
if (!levels.includes(1)) issues.push('no <h1>');
|
||||
let prev = 0;
|
||||
for (const lvl of levels) {
|
||||
if (prev && lvl > prev + 1) issues.push(`skips h${prev}→h${lvl}`);
|
||||
prev = lvl;
|
||||
}
|
||||
if (issues.length) {
|
||||
problems++;
|
||||
console.error(`${page}: ${issues.join(', ')} [order: ${levels.join(',')}]`);
|
||||
}
|
||||
}
|
||||
|
||||
if (problems) {
|
||||
console.error(`\n✖ ${problems} page(s) with heading-order issues.`);
|
||||
process.exit(1);
|
||||
}
|
||||
console.log('✓ All pages have an h1 and no skipped heading levels.');
|
||||
@@ -0,0 +1,55 @@
|
||||
#!/usr/bin/env node
|
||||
// Locale completeness + placeholder-integrity check.
|
||||
//
|
||||
// Fails (exit 1) if any locale is missing keys present in en.json, has stray
|
||||
// extra keys, or if a translated string's {placeholders} differ from the
|
||||
// English source. Wire into CI / pre-commit alongside the other linters.
|
||||
//
|
||||
// node scripts/check-locales.mjs
|
||||
import { readFileSync, readdirSync } from 'node:fs';
|
||||
import { join, dirname } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const localesDir = join(dirname(fileURLToPath(import.meta.url)), '..', 'static', 'locales');
|
||||
|
||||
/** Flatten a nested translation object to dotted keys. */
|
||||
function flat(obj, prefix = '', out = {}) {
|
||||
for (const [k, v] of Object.entries(obj)) {
|
||||
const key = prefix ? `${prefix}.${k}` : k;
|
||||
if (v && typeof v === 'object' && !Array.isArray(v)) flat(v, key, out);
|
||||
else out[key] = v;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
const placeholders = (s) => (typeof s === 'string' ? (s.match(/\{[^}]+\}/g) || []).sort() : []);
|
||||
|
||||
const en = flat(JSON.parse(readFileSync(join(localesDir, 'en.json'), 'utf8')));
|
||||
const enKeys = Object.keys(en);
|
||||
let problems = 0;
|
||||
|
||||
for (const file of readdirSync(localesDir).filter((f) => f.endsWith('.json') && f !== 'en.json')) {
|
||||
const loc = flat(JSON.parse(readFileSync(join(localesDir, file), 'utf8')));
|
||||
const missing = enKeys.filter((k) => !(k in loc));
|
||||
const extra = Object.keys(loc).filter((k) => !(k in en));
|
||||
const badPh = enKeys.filter(
|
||||
(k) => k in loc && placeholders(en[k]).join() !== placeholders(loc[k]).join()
|
||||
);
|
||||
if (missing.length || extra.length || badPh.length) {
|
||||
problems++;
|
||||
console.error(`\n${file}:`);
|
||||
if (missing.length)
|
||||
console.error(
|
||||
` missing ${missing.length}: ${missing.slice(0, 8).join(', ')}${missing.length > 8 ? ' …' : ''}`
|
||||
);
|
||||
if (extra.length) console.error(` extra ${extra.length}: ${extra.slice(0, 8).join(', ')}`);
|
||||
if (badPh.length)
|
||||
console.error(` placeholder mismatch ${badPh.length}: ${badPh.slice(0, 8).join(', ')}`);
|
||||
}
|
||||
}
|
||||
|
||||
if (problems) {
|
||||
console.error(`\n✖ ${problems} locale file(s) have issues.`);
|
||||
process.exit(1);
|
||||
}
|
||||
console.log('✓ All locales complete and placeholder-consistent.');
|
||||
@@ -0,0 +1,62 @@
|
||||
#!/usr/bin/env node
|
||||
// Generates docs/TOKENS.md — a grouped reference of every design token defined
|
||||
// in static/css/base/variables.css. Run after changing tokens:
|
||||
// node scripts/gen-token-docs.mjs
|
||||
import { readFileSync, writeFileSync } from 'node:fs';
|
||||
import { join, dirname } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const repo = join(dirname(fileURLToPath(import.meta.url)), '..');
|
||||
const src = readFileSync(join(repo, 'static', 'css', 'base', 'variables.css'), 'utf8');
|
||||
|
||||
const tokens = [];
|
||||
for (const m of src.matchAll(/^\s*(--[a-z0-9-]+)\s*:\s*([^;]+);/gim)) {
|
||||
if (!tokens.some((t) => t[0] === m[1])) tokens.push([m[1], m[2].trim().replace(/\s+/g, ' ')]);
|
||||
}
|
||||
|
||||
const CATEGORIES = [
|
||||
['Spacing (4px grid)', /^--space-/],
|
||||
['Radius', /^--radius/],
|
||||
['Typography', /^--(text-|leading-|weight-|tracking-|font-|measure-|icon-)/],
|
||||
['Z-index layers', /^--z-/],
|
||||
['Motion (durations + easing)', /^--(motion-|ease-|spin-)/],
|
||||
['Elevation (composed shadows)', /^--shadow-/],
|
||||
['Breakpoints (reference)', /^--bp-/],
|
||||
['Density', /^--density-/],
|
||||
['Layout shell', /^--(sidebar-width|gutter|grid-card)/],
|
||||
['Color — text', /^--color-text/],
|
||||
['Color — background', /^--color-bg/],
|
||||
['Color — border', /^--color-border/],
|
||||
['Color — accent / brand', /^--color-(accent|logo|focus|on-accent|primary)/],
|
||||
['Color — semantic (success/warn/danger/info)', /^--color-(success|error|danger|warning|info)/],
|
||||
['Color — shadow alphas / overlays', /^--color-(shadow|overlay|on-overlay)/],
|
||||
['Color — sidebar', /^--color-sidebar/],
|
||||
['Color — file types', /^--color-ft/],
|
||||
['Color — calendar dots', /^--color-cal/],
|
||||
['Color — badges', /^--color-badge/],
|
||||
['Color — other', /^--color-/],
|
||||
['Other', /.*/]
|
||||
];
|
||||
|
||||
const buckets = new Map(CATEGORIES.map(([name]) => [name, []]));
|
||||
for (const [name, value] of tokens) {
|
||||
const cat = CATEGORIES.find(([, re]) => re.test(name))[0];
|
||||
buckets.get(cat).push([name, value]);
|
||||
}
|
||||
|
||||
let md = `# Design tokens
|
||||
|
||||
> Auto-generated from \`static/css/base/variables.css\` by \`scripts/gen-token-docs.mjs\`.
|
||||
> Do not edit by hand — re-run the generator after changing tokens.
|
||||
|
||||
**${tokens.length} tokens** across ${[...buckets].filter(([, v]) => v.length).length} groups.
|
||||
\n`;
|
||||
|
||||
for (const [name, rows] of buckets) {
|
||||
if (!rows.length) continue;
|
||||
md += `\n## ${name}\n\n| Token | Value |\n| --- | --- |\n`;
|
||||
for (const [tok, val] of rows) md += `| \`${tok}\` | \`${val.replace(/\|/g, '\\|')}\` |\n`;
|
||||
}
|
||||
|
||||
writeFileSync(join(repo, 'docs', 'TOKENS.md'), md);
|
||||
console.log(`✓ Wrote docs/TOKENS.md (${tokens.length} tokens).`);
|
||||
Reference in New Issue
Block a user