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>
This commit is contained in:
@@ -0,0 +1,180 @@
|
||||
#!/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.`);
|
||||
@@ -0,0 +1,191 @@
|
||||
#!/usr/bin/env node
|
||||
// DSMMG token migration codemod
|
||||
// Renomme les références --mmg-* legacy vers la nomenclature --mmg-color-*.
|
||||
// Sûr et idempotent : applique les mappings dans l'ordre du plus spécifique
|
||||
// au plus générique (longest-match-first).
|
||||
import { readFile, writeFile, readdir, stat } from "node:fs/promises";
|
||||
import { join, extname } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { dirname } from "node:path";
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
const ROOT = join(__dirname, "..");
|
||||
|
||||
// IMPORTANT : ordre du plus long au plus court pour éviter les collisions
|
||||
// (ex: --mmg-text-1 doit matcher AVANT --mmg-text-).
|
||||
const MAPPINGS = [
|
||||
// — Texte (couleur) — longest first ——————————
|
||||
["--mmg-text-inverse", "--mmg-color-text-inverse"],
|
||||
["--mmg-text-1", "--mmg-color-text-primary"],
|
||||
["--mmg-text-2", "--mmg-color-text-secondary"],
|
||||
["--mmg-text-3", "--mmg-color-text-tertiary"],
|
||||
["--mmg-text-4", "--mmg-color-text-quaternary"],
|
||||
|
||||
// — Texte (font-size) — distincts ——————————
|
||||
["--mmg-text-xs", "--mmg-font-size-xs"],
|
||||
["--mmg-text-sm", "--mmg-font-size-sm"],
|
||||
["--mmg-text-base", "--mmg-font-size-base"],
|
||||
["--mmg-text-lg", "--mmg-font-size-lg"],
|
||||
["--mmg-text-xl", "--mmg-font-size-xl"],
|
||||
["--mmg-text-2xl", "--mmg-font-size-2xl"],
|
||||
["--mmg-text-3xl", "--mmg-font-size-3xl"],
|
||||
["--mmg-text-4xl", "--mmg-font-size-4xl"],
|
||||
["--mmg-text-5xl", "--mmg-font-size-5xl"],
|
||||
|
||||
// — Background ——————————
|
||||
["--mmg-bg-page", "--mmg-color-bg-page"],
|
||||
["--mmg-bg-surface", "--mmg-color-bg-surface"],
|
||||
["--mmg-bg-raised", "--mmg-color-bg-raised"],
|
||||
["--mmg-bg-muted", "--mmg-color-bg-muted"],
|
||||
["--mmg-bg-subtle", "--mmg-color-bg-subtle"],
|
||||
["--mmg-bg-overlay", "--mmg-color-bg-overlay"],
|
||||
|
||||
// — Border ——————————
|
||||
["--mmg-border-soft", "--mmg-color-border-soft"],
|
||||
["--mmg-border-strong", "--mmg-color-border-strong"],
|
||||
["--mmg-border", "--mmg-color-border"],
|
||||
|
||||
// — Brand → Accent ——————————
|
||||
["--mmg-brand-hover", "--mmg-color-accent-hover"],
|
||||
["--mmg-brand-active", "--mmg-color-accent-active"],
|
||||
["--mmg-brand-soft", "--mmg-color-accent-soft"],
|
||||
["--mmg-brand-border", "--mmg-color-accent-border"],
|
||||
["--mmg-brand-strong", "--mmg-color-accent-strong"],
|
||||
["--mmg-brand-on", "--mmg-color-accent-on"],
|
||||
["--mmg-brand", "--mmg-color-accent"],
|
||||
|
||||
// — Semantic colors ——————————
|
||||
["--mmg-success-soft", "--mmg-color-success-soft"],
|
||||
["--mmg-success-border", "--mmg-color-success-border"],
|
||||
["--mmg-success-strong", "--mmg-color-success-strong"],
|
||||
["--mmg-success-on", "--mmg-color-success-on"],
|
||||
["--mmg-success", "--mmg-color-success"],
|
||||
["--mmg-warning-soft", "--mmg-color-warning-soft"],
|
||||
["--mmg-warning-border", "--mmg-color-warning-border"],
|
||||
["--mmg-warning-strong", "--mmg-color-warning-strong"],
|
||||
["--mmg-warning-on", "--mmg-color-warning-on"],
|
||||
["--mmg-warning", "--mmg-color-warning"],
|
||||
["--mmg-danger-soft", "--mmg-color-danger-soft"],
|
||||
["--mmg-danger-border", "--mmg-color-danger-border"],
|
||||
["--mmg-danger-strong", "--mmg-color-danger-strong"],
|
||||
["--mmg-danger-on", "--mmg-color-danger-on"],
|
||||
["--mmg-danger", "--mmg-color-danger"],
|
||||
["--mmg-info-soft", "--mmg-color-info-soft"],
|
||||
["--mmg-info-border", "--mmg-color-info-border"],
|
||||
["--mmg-info-strong", "--mmg-color-info-strong"],
|
||||
["--mmg-info-on", "--mmg-color-info-on"],
|
||||
["--mmg-info", "--mmg-color-info"],
|
||||
|
||||
// — Artwork ——————————
|
||||
["--mmg-art-major", "--mmg-color-art-major"],
|
||||
["--mmg-art-minor", "--mmg-color-art-minor"],
|
||||
["--mmg-art-light", "--mmg-color-art-light"],
|
||||
["--mmg-art-dark", "--mmg-color-art-dark"],
|
||||
["--mmg-art-accent", "--mmg-color-art-accent"],
|
||||
|
||||
// — État système ——————————
|
||||
["--mmg-state-disabled-bg", "--mmg-color-state-disabled-bg"],
|
||||
["--mmg-state-disabled-text", "--mmg-color-state-disabled-text"],
|
||||
["--mmg-state-disabled-border", "--mmg-color-state-disabled-border"],
|
||||
["--mmg-state-selection-bg", "--mmg-color-state-selection-bg"],
|
||||
["--mmg-state-selection-text", "--mmg-color-state-selection-text"],
|
||||
|
||||
// — Line height ——————————
|
||||
["--mmg-leading-tight", "--mmg-line-height-tight"],
|
||||
["--mmg-leading-snug", "--mmg-line-height-snug"],
|
||||
["--mmg-leading-normal", "--mmg-line-height-normal"],
|
||||
|
||||
// — Font weight ——————————
|
||||
["--mmg-weight-regular", "--mmg-font-weight-regular"],
|
||||
["--mmg-weight-medium", "--mmg-font-weight-medium"],
|
||||
["--mmg-weight-semi", "--mmg-font-weight-semi"],
|
||||
["--mmg-weight-bold", "--mmg-font-weight-bold"],
|
||||
["--mmg-weight-extra", "--mmg-font-weight-extra"],
|
||||
|
||||
// — Shadow accent ——————————
|
||||
["--mmg-shadow-brand-hover", "--mmg-shadow-accent-hover"],
|
||||
["--mmg-shadow-brand-soft", "--mmg-shadow-accent-soft"],
|
||||
["--mmg-shadow-brand", "--mmg-shadow-accent"],
|
||||
];
|
||||
|
||||
// Cibles : tous les .css et .tsx hors node_modules / dist / tokens (déjà OK)
|
||||
const TARGET_DIRS = [
|
||||
"packages/css/src/components",
|
||||
"packages/css/src", // pour base.css uniquement, on filtre tokens via skip
|
||||
"packages/react/src",
|
||||
"demo/src",
|
||||
];
|
||||
const SKIP_FILES = new Set([
|
||||
// Les nouveaux tokens sont déjà à la nouvelle nomenclature.
|
||||
"tokens.css",
|
||||
"primitives.css",
|
||||
"semantic.css",
|
||||
"accent.css",
|
||||
"system.css",
|
||||
]);
|
||||
const EXTS = new Set([".css", ".tsx", ".ts"]);
|
||||
|
||||
async function* walk(dir) {
|
||||
const entries = await readdir(dir, { withFileTypes: true });
|
||||
for (const e of entries) {
|
||||
if (e.name === "node_modules" || e.name === "dist") continue;
|
||||
const full = join(dir, e.name);
|
||||
if (e.isDirectory()) yield* walk(full);
|
||||
else yield full;
|
||||
}
|
||||
}
|
||||
|
||||
function migrate(content) {
|
||||
let out = content;
|
||||
let count = 0;
|
||||
for (const [from, to] of MAPPINGS) {
|
||||
// Word-boundary safe : --mmg-brand ne doit PAS matcher dans --mmg-brand-soft.
|
||||
// Comme MAPPINGS est trié longest-first, lorsque --mmg-brand est traité,
|
||||
// les longs ont déjà été remplacés. Mais on ajoute un lookahead négatif
|
||||
// pour les caractères pouvant prolonger un identifiant CSS (-, lettre, chiffre).
|
||||
const re = new RegExp(escape(from) + "(?![\\w-])", "g");
|
||||
const matches = out.match(re);
|
||||
if (matches) {
|
||||
count += matches.length;
|
||||
out = out.replace(re, to);
|
||||
}
|
||||
}
|
||||
return { out, count };
|
||||
}
|
||||
|
||||
function escape(s) {
|
||||
return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||
}
|
||||
|
||||
async function main() {
|
||||
let totalReplacements = 0;
|
||||
let touched = 0;
|
||||
for (const rel of TARGET_DIRS) {
|
||||
const dir = join(ROOT, rel);
|
||||
try {
|
||||
await stat(dir);
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
for await (const file of walk(dir)) {
|
||||
const ext = extname(file);
|
||||
if (!EXTS.has(ext)) continue;
|
||||
const base = file.split(/[\\/]/).pop();
|
||||
if (SKIP_FILES.has(base)) continue;
|
||||
const content = await readFile(file, "utf8");
|
||||
const { out, count } = migrate(content);
|
||||
if (count > 0) {
|
||||
await writeFile(file, out, "utf8");
|
||||
touched += 1;
|
||||
totalReplacements += count;
|
||||
console.log(` ${count.toString().padStart(4)} ${file.replace(ROOT, "")}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
console.log(`\n✓ ${totalReplacements} replacements across ${touched} files`);
|
||||
}
|
||||
|
||||
main().catch((err) => {
|
||||
console.error(err);
|
||||
process.exit(1);
|
||||
});
|
||||
Reference in New Issue
Block a user