Files
DSMMG/scripts/lint-contrast.mjs
Dinawo 62317f2ad7
Release / Release / open changeset PR (push) Has been cancelled
CI / Build, typecheck, test, a11y (push) Has been cancelled
chore: initial DSMMG v0.2 — refonte architecturale complète
Mise en place du Design System ManageMate Group v0.2 — refonte du
système de tokens (préfixe --mmg-color-*), 9 presets accent
user-themable validés WCAG AA, overlays Radix UI + Floating UI,
Storybook 8 + Vitest + axe-core en CI, doc Astro Starlight,
DESIGN.md (format google-labs-code) et exports tokens DTCG/CSS/
TS/Figma/Tailwind v3 et v4.

- 4 packages monorepo pnpm : @managemate/{tokens,css,react,icons}
- 62 composants React headless-first (Sheet, HoverCard, ContextMenu,
  Slider, ToggleGroup, AvatarGroup, UserCard, ProfileHeader,
  MetricCard, PricingCard, FeatureCard, Text/Display/Eyebrow/Lead…)
- Lint contraste WCAG : 37/37 paires AA, branché CI
- Toast pile Sonner-style avec ResizeObserver
- Theming user (9 presets) sans casser sémantique fixe
- Identité Synapse (rose #D12B6A) préservée

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-04 22:08:38 +02:00

181 lines
6.7 KiB
JavaScript

#!/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.`);