#!/usr/bin/env node /** * lint-contrast.mjs — vérifie WCAG AA sur les paires texte/fond du DSMMG * * Pour chaque preset accent (light + dark), valide : * - text-primary / text-secondary / text-tertiary sur bg-page, bg-surface, bg-muted * - accent-on sur accent (le bouton primary doit avoir 4.5:1) * - accent sur bg-page (composant non-texte ≥ 3:1) * * Critères WCAG : * - Texte normal ≥ 4.5:1 (AA) * - Texte gros (18pt+/14pt+ bold) ≥ 3:1 (AA-large) * - Composants non-texte ≥ 3:1 (AA, SC 1.4.11) * * Sortie : * - Console + fichier dist/contrast-report.json * - Exit code 1 si violation détectée → fail CI */ import { readFile, writeFile, mkdir } from "node:fs/promises"; import { dirname, join } from "node:path"; import { existsSync } from "node:fs"; import { fileURLToPath } from "node:url"; const __dirname = dirname(fileURLToPath(import.meta.url)); const ROOT = join(__dirname, ".."); const REPORT_DIR = join(ROOT, "dist"); const REPORT_PATH = join(REPORT_DIR, "contrast-report.json"); // — Lecture des tokens depuis le DTCG —————————————————————— const tokensJson = JSON.parse( await readFile(join(ROOT, "packages/tokens/src/tokens.json"), "utf8"), ); function flatten(obj, prefix = "") { const out = {}; for (const [k, v] of Object.entries(obj)) { if (k.startsWith("$")) continue; const path = prefix ? `${prefix}.${k}` : k; if (v && typeof v === "object" && "$value" in v) { out[path] = v.$value; } else if (v && typeof v === "object") { Object.assign(out, flatten(v, path)); } } return out; } const flat = flatten(tokensJson); // — Conversion hex → RGB → luminance relative ——————————————— function hexToRgb(hex) { const m = hex.replace("#", "").match(/.{2}/g); if (!m || m.length !== 3) throw new Error(`Invalid hex: ${hex}`); return m.map((h) => parseInt(h, 16)); } /** WCAG relative luminance (sRGB). */ function relLuminance([r, g, b]) { const norm = [r, g, b].map((c) => { const s = c / 255; return s <= 0.03928 ? s / 12.92 : Math.pow((s + 0.055) / 1.055, 2.4); }); return 0.2126 * norm[0] + 0.7152 * norm[1] + 0.0722 * norm[2]; } /** WCAG contrast ratio. */ function ratio(c1, c2) { const l1 = relLuminance(hexToRgb(c1)); const l2 = relLuminance(hexToRgb(c2)); const [a, b] = l1 > l2 ? [l1, l2] : [l2, l1]; return (a + 0.05) / (b + 0.05); } // — Définition des presets light —————————————————————————— const PRESETS = ["synapse", "rose", "blue", "violet", "green", "amber", "red", "cyan", "slate"]; // Couleurs sémantiques light (résolues en hard-coded car semantic.css fait // les indirections via var(--mmg-color-neutral-*) — équivalent ici). // Reflète semantic.css (text-tertiary bumpé à neutral-600 pour AA). const LIGHT = { bgPage: flat["color.neutral.50"], bgSurface: flat["color.neutral.0"], bgMuted: flat["color.neutral.150"], textPrimary: flat["color.neutral.900"], textSecondary: flat["color.neutral.700"], textTertiary: flat["color.neutral.600"], }; // — Tests par preset ———————————————————————————————————————— const results = []; let failures = 0; function check(label, fg, bg, threshold = 4.5) { const r = ratio(fg, bg); const pass = r >= threshold; if (!pass) failures++; return { label, fg, bg, ratio: +r.toFixed(2), threshold, pass }; } // 1. Texte sur bg-page / bg-surface / bg-muted (sémantique fixe) results.push({ group: "Sémantique fixe — light", tests: [ check("text-primary on bg-page", LIGHT.textPrimary, LIGHT.bgPage, 4.5), check("text-secondary on bg-page", LIGHT.textSecondary, LIGHT.bgPage, 4.5), check("text-tertiary on bg-page", LIGHT.textTertiary, LIGHT.bgPage, 4.5), check("text-primary on bg-surface", LIGHT.textPrimary, LIGHT.bgSurface, 4.5), check("text-secondary on bg-surface", LIGHT.textSecondary, LIGHT.bgSurface, 4.5), check("text-primary on bg-muted", LIGHT.textPrimary, LIGHT.bgMuted, 4.5), ], }); // 2. Pour chaque preset accent (light) — accent-on sur accent (CTA primary) // Reflète accent.css : green/cyan utilisent -700 (au lieu de -500), // slate utilise -700, amber utilise -500 avec accent-on=text-primary. const ACCENT_SHADE = { synapse: 500, rose: 500, blue: 500, violet: 500, red: 500, amber: 600, green: 700, cyan: 700, slate: 700, }; const ACCENT_ON = (_preset) => "#FFFFFF"; for (const preset of PRESETS) { const shade = ACCENT_SHADE[preset]; const accent = flat[`color.${preset}.${shade}`]; const accentOn = ACCENT_ON(preset); results.push({ group: `Accent: ${preset} (${shade}) — light`, tests: [ check(`accent-on on ${preset}-${shade}`, accentOn, accent, 4.5), check(`${preset}-${shade} on bg-page`, accent, LIGHT.bgPage, 3.0), check(`${preset}-${shade} on bg-surface`, accent, LIGHT.bgSurface, 3.0), ], }); } // 3. Sémantique fixe sur bg-page light // Reflète semantic.css : warning bumpé à amber-600 pour passer AA. const SEM = { success: flat["color.green.500"], warning: flat["color.amber.600"], danger: flat["color.red.500"], info: flat["color.blue.500"], }; results.push({ group: "Sémantique success/warning/danger/info — light", tests: [ // success (vert): #0E9F6E sur fond clair → contraste sera < 4.5 mais ≥ 3 (composant) check("success on bg-page", SEM.success, LIGHT.bgPage, 3.0), check("warning on bg-page", SEM.warning, LIGHT.bgPage, 3.0), check("danger on bg-page", SEM.danger, LIGHT.bgPage, 3.0), check("info on bg-page", SEM.info, LIGHT.bgPage, 3.0), ], }); // — Sortie —————————————————————————————————————————————————— const total = results.reduce((s, g) => s + g.tests.length, 0); const passed = total - failures; console.log(`\n━━━ DSMMG contrast lint ━━━`); for (const group of results) { console.log(`\n${group.group}`); for (const t of group.tests) { const icon = t.pass ? "✓" : "✗"; const color = t.pass ? "\x1b[32m" : "\x1b[31m"; console.log(` ${color}${icon}\x1b[0m ${t.label.padEnd(40)} ${t.ratio.toFixed(2)}:1 (≥ ${t.threshold}:1)`); } } console.log(`\n${passed}/${total} passed.`); if (!existsSync(REPORT_DIR)) await mkdir(REPORT_DIR, { recursive: true }); await writeFile( REPORT_PATH, JSON.stringify({ passed, failed: failures, total, results }, null, 2), ); if (failures > 0) { console.error(`\n✗ ${failures} contrast violation(s).`); process.exit(1); } console.log(`\n✓ All contrast checks pass WCAG AA.`);