@higinio/design-mcp 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/cli.js ADDED
@@ -0,0 +1,269 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * design-lint — CLI de los validadores puros del MCP (validate_code, validate_motion,
4
+ * check_slop, checklist_preflight) para correr sobre archivos locales (pre-commit, CI).
5
+ *
6
+ * Uso: design-lint --system celeste|nocturna [--lang css|html|js] [--json] <archivos o globs...>
7
+ *
8
+ * Exit codes: 0 = sin hallazgos de severidad alta · 1 = hay al menos un hallazgo de
9
+ * severidad alta (level "error") · 2 = error de uso (argumentos inválidos, sin archivos).
10
+ *
11
+ * Excepciones: un comentario `design-lint-ignore: <regla>[, <regla>...]` en la línea
12
+ * inmediatamente anterior (o en la misma línea) suprime los hallazgos de esas reglas en
13
+ * esa línea. Los hallazgos a nivel de bloque/archivo (línea 0) no son suprimibles.
14
+ */
15
+ import { readFileSync, readdirSync, statSync, realpathSync } from "node:fs";
16
+ import { extname, resolve, join, relative } from "node:path";
17
+ import { fileURLToPath } from "node:url";
18
+ import { loadAllSystems } from "./data.js";
19
+ import { resolveToken } from "./tokens.js";
20
+ import { validateCode } from "./validate.js";
21
+ import { auditMotion } from "./motion.js";
22
+ import { checkSlop } from "./slop.js";
23
+ import { checkPreflight } from "./preflight.js";
24
+ const LANG_BY_EXT = {
25
+ ".css": "css",
26
+ ".html": "html",
27
+ ".htm": "html",
28
+ ".js": "js",
29
+ ".mjs": "js",
30
+ ".cjs": "js",
31
+ ".ts": "js",
32
+ ".jsx": "js",
33
+ ".tsx": "js",
34
+ };
35
+ /** Infiere el lenguaje del validador a partir de la extensión del archivo. */
36
+ export function inferLang(file) {
37
+ return LANG_BY_EXT[extname(file).toLowerCase()] ?? null;
38
+ }
39
+ /** Extrae los `design-lint-ignore: regla[, regla]` por número de línea (base 1). */
40
+ export function collectIgnores(code) {
41
+ const out = new Map();
42
+ code.split("\n").forEach((line, i) => {
43
+ const m = line.match(/design-lint-ignore:\s*([a-z0-9-]+(?:\s*,\s*[a-z0-9-]+)*)/i);
44
+ if (!m)
45
+ return;
46
+ const rules = new Set(m[1].split(",").map((r) => r.trim().toLowerCase()).filter(Boolean));
47
+ out.set(i + 1, rules);
48
+ });
49
+ return out;
50
+ }
51
+ function isIgnored(finding, ignores) {
52
+ if (finding.line <= 0)
53
+ return false;
54
+ const rule = finding.rule.toLowerCase();
55
+ return ignores.get(finding.line - 1)?.has(rule) === true || ignores.get(finding.line)?.has(rule) === true;
56
+ }
57
+ /** Techo de duración del sistema (--duration-page), igual que la tool validate_motion. */
58
+ function pageDurationMs(sys) {
59
+ const page = resolveToken(sys.aliases, sys.tokenIndex, "--duration-page");
60
+ return page ? parseInt(page.value, 10) || 320 : 320;
61
+ }
62
+ /**
63
+ * Corre los cuatro validadores puros sobre un fragmento de código y aplica las
64
+ * excepciones `design-lint-ignore`. Para `js` solo aplica validate_motion (los otros
65
+ * tres validadores son de CSS/HTML).
66
+ */
67
+ export function lintCode(sys, code, lang) {
68
+ const findings = [];
69
+ const add = (tool, violations) => {
70
+ for (const v of violations)
71
+ findings.push({ ...v, tool });
72
+ };
73
+ if (lang === "css" || lang === "html") {
74
+ add("validate_code", validateCode(sys, code, lang));
75
+ add("check_slop", checkSlop(code, lang, sys.meta.mode));
76
+ add("checklist_preflight", checkPreflight(code, lang));
77
+ }
78
+ add("validate_motion", auditMotion(code, lang, pageDurationMs(sys)));
79
+ const ignores = collectIgnores(code);
80
+ return findings.filter((f) => !isIgnored(f, ignores));
81
+ }
82
+ const MAGIC = /[*?]/;
83
+ function globToRegExp(pattern) {
84
+ let out = "";
85
+ let i = 0;
86
+ while (i < pattern.length) {
87
+ if (pattern.startsWith("**/", i)) {
88
+ out += "(?:[^/]+/)*";
89
+ i += 3;
90
+ }
91
+ else if (pattern.startsWith("**", i)) {
92
+ out += ".*";
93
+ i += 2;
94
+ }
95
+ else if (pattern[i] === "*") {
96
+ out += "[^/]*";
97
+ i += 1;
98
+ }
99
+ else if (pattern[i] === "?") {
100
+ out += "[^/]";
101
+ i += 1;
102
+ }
103
+ else {
104
+ out += pattern[i].replace(/[.+^${}()|[\]\\]/g, "\\$&");
105
+ i += 1;
106
+ }
107
+ }
108
+ return new RegExp(`^${out}$`);
109
+ }
110
+ function walk(dir, acc) {
111
+ let entries;
112
+ try {
113
+ entries = readdirSync(dir);
114
+ }
115
+ catch {
116
+ return;
117
+ }
118
+ for (const name of entries) {
119
+ if (name === "node_modules" || name === ".git")
120
+ continue;
121
+ const full = join(dir, name);
122
+ try {
123
+ const st = statSync(full);
124
+ if (st.isDirectory())
125
+ walk(full, acc);
126
+ else
127
+ acc.push(full);
128
+ }
129
+ catch {
130
+ /* enlaces rotos, permisos: se ignoran */
131
+ }
132
+ }
133
+ }
134
+ /** Expande patrones con `*`/`**` (relativos a cwd) y deja pasar rutas literales. */
135
+ export function expandInputs(patterns, cwd = process.cwd()) {
136
+ const files = [];
137
+ for (const raw of patterns) {
138
+ const pattern = raw.replace(/\\/g, "/");
139
+ if (!MAGIC.test(pattern)) {
140
+ files.push(resolve(cwd, pattern));
141
+ continue;
142
+ }
143
+ const staticPrefix = pattern.slice(0, pattern.search(MAGIC));
144
+ const baseRel = staticPrefix.includes("/") ? staticPrefix.slice(0, staticPrefix.lastIndexOf("/")) : "";
145
+ const base = resolve(cwd, baseRel || ".");
146
+ const re = globToRegExp(pattern);
147
+ const all = [];
148
+ walk(base, all);
149
+ for (const f of all) {
150
+ const rel = relative(cwd, f).replace(/\\/g, "/");
151
+ if (re.test(rel))
152
+ files.push(f);
153
+ }
154
+ }
155
+ return [...new Set(files)];
156
+ }
157
+ /** Corre los validadores sobre una lista de archivos ya resuelta. */
158
+ export function lintFiles(store, system, files, langOverride) {
159
+ const sys = store[system];
160
+ const reports = [];
161
+ const totals = { error: 0, warn: 0, info: 0 };
162
+ for (const file of files) {
163
+ const lang = langOverride ?? inferLang(file);
164
+ if (!lang)
165
+ continue; // extensión no soportada: se omite en silencio
166
+ const code = readFileSync(file, "utf8");
167
+ const findings = lintCode(sys, code, lang);
168
+ for (const f of findings)
169
+ totals[f.level] += 1;
170
+ reports.push({ file, lang, findings });
171
+ }
172
+ return { system, reports, totals, exitCode: totals.error > 0 ? 1 : 0 };
173
+ }
174
+ /** Formatea el resumen como texto agrupado por archivo. */
175
+ export function formatText(summary) {
176
+ const lines = [];
177
+ for (const r of summary.reports) {
178
+ if (r.findings.length === 0)
179
+ continue;
180
+ lines.push(`${r.file} (${r.lang}) — ${r.findings.length} hallazgo${r.findings.length === 1 ? "" : "s"}`);
181
+ for (const f of r.findings) {
182
+ const where = f.line > 0 ? `L${f.line}` : "—";
183
+ lines.push(` ${where}\t${f.level.toUpperCase()}\t${f.rule}\t[${f.tool}]\t${f.message}`);
184
+ lines.push(` \tfix: ${f.fix}`);
185
+ }
186
+ lines.push("");
187
+ }
188
+ const n = summary.reports.length;
189
+ lines.push(`${summary.totals.error} errores · ${summary.totals.warn} avisos · ${summary.totals.info} infos ` +
190
+ `en ${n} archivo${n === 1 ? "" : "s"} (sistema: ${summary.system}).`);
191
+ return lines.join("\n");
192
+ }
193
+ const USAGE = `Uso: design-lint --system celeste|nocturna [--lang css|html|js] [--json] <archivos o globs...>
194
+
195
+ Corre validate_code, validate_motion, check_slop y checklist_preflight sobre cada archivo.
196
+ El lenguaje se infiere por extensión (.css / .html|.htm / .js|.mjs|.cjs|.ts|.jsx|.tsx) salvo que se pase --lang.
197
+
198
+ Exit codes: 0 sin hallazgos de severidad alta · 1 hay errores (level "error") · 2 error de uso.
199
+ Excepciones: comentario "design-lint-ignore: <regla>[, <regla>]" en la línea anterior a la infracción.`;
200
+ /** Parsea argv (sin node ni script). Solo parsea; no toca el filesystem. */
201
+ export function parseArgs(argv) {
202
+ const opts = { json: false, help: false, patterns: [], errors: [] };
203
+ for (let i = 0; i < argv.length; i++) {
204
+ const a = argv[i];
205
+ if (a === "--system")
206
+ opts.system = argv[++i];
207
+ else if (a === "--lang")
208
+ opts.lang = argv[++i];
209
+ else if (a === "--json")
210
+ opts.json = true;
211
+ else if (a === "--help" || a === "-h")
212
+ opts.help = true;
213
+ else if (a.startsWith("--"))
214
+ opts.errors.push(`Flag desconocido: ${a}`);
215
+ else
216
+ opts.patterns.push(a);
217
+ }
218
+ if (!opts.help) {
219
+ if (!opts.system)
220
+ opts.errors.push("Falta --system (celeste|nocturna).");
221
+ else if (opts.system !== "celeste" && opts.system !== "nocturna")
222
+ opts.errors.push(`Sistema desconocido: ${opts.system} (usa celeste o nocturna).`);
223
+ if (opts.lang && !["css", "html", "js"].includes(opts.lang))
224
+ opts.errors.push(`Lenguaje desconocido: ${opts.lang} (usa css, html o js).`);
225
+ if (opts.patterns.length === 0)
226
+ opts.errors.push("Indica al menos un archivo o glob.");
227
+ }
228
+ return opts;
229
+ }
230
+ function main() {
231
+ const opts = parseArgs(process.argv.slice(2));
232
+ if (opts.help) {
233
+ console.log(USAGE);
234
+ process.exit(0);
235
+ }
236
+ if (opts.errors.length) {
237
+ for (const e of opts.errors)
238
+ console.error(`design-lint: ${e}`);
239
+ console.error(`\n${USAGE}`);
240
+ process.exit(2);
241
+ }
242
+ const files = expandInputs(opts.patterns);
243
+ if (files.length === 0) {
244
+ console.error("design-lint: ningún archivo coincide con los patrones dados.");
245
+ process.exit(2);
246
+ }
247
+ let summary;
248
+ try {
249
+ summary = lintFiles(loadAllSystems(), opts.system, files, opts.lang);
250
+ }
251
+ catch (err) {
252
+ console.error(`design-lint: ${err instanceof Error ? err.message : String(err)}`);
253
+ process.exit(2);
254
+ }
255
+ console.log(opts.json ? JSON.stringify(summary, null, 2) : formatText(summary));
256
+ process.exit(summary.exitCode);
257
+ }
258
+ function isMainModule() {
259
+ if (!process.argv[1])
260
+ return false;
261
+ try {
262
+ return realpathSync(process.argv[1]) === fileURLToPath(import.meta.url);
263
+ }
264
+ catch {
265
+ return false;
266
+ }
267
+ }
268
+ if (isMainModule())
269
+ main();
package/dist/data.js ADDED
@@ -0,0 +1,33 @@
1
+ import { readFileSync } from "node:fs";
2
+ import { dirname, join } from "node:path";
3
+ import { fileURLToPath } from "node:url";
4
+ import { SYSTEMS, SYSTEM_IDS } from "./systems.js";
5
+ import { parseDtcgTokens, parseCssAliases } from "./tokens.js";
6
+ import { indexMarkdown } from "./doc.js";
7
+ export const ASSETS_DIR = join(dirname(fileURLToPath(import.meta.url)), "..", "assets");
8
+ const FILE_NAMES = [
9
+ "tokens.json",
10
+ "tokens.css",
11
+ "tailwind.css",
12
+ "framework.css",
13
+ "design-doc.md",
14
+ "styleguide.html",
15
+ ];
16
+ export function loadSystem(id, assetsDir = ASSETS_DIR) {
17
+ const base = join(assetsDir, id);
18
+ const files = {};
19
+ for (const name of FILE_NAMES) {
20
+ files[name] = readFileSync(join(base, name), "utf8");
21
+ }
22
+ const tokens = parseDtcgTokens(JSON.parse(files["tokens.json"]));
23
+ const tokenIndex = new Map(tokens.map((t) => [t.path, t]));
24
+ const aliases = parseCssAliases(files["tokens.css"]);
25
+ const sections = indexMarkdown(files["design-doc.md"]);
26
+ return { meta: SYSTEMS[id], tokens, tokenIndex, aliases, sections, files };
27
+ }
28
+ export function loadAllSystems(assetsDir = ASSETS_DIR) {
29
+ const out = {};
30
+ for (const id of SYSTEM_IDS)
31
+ out[id] = loadSystem(id, assetsDir);
32
+ return out;
33
+ }
package/dist/doc.js ADDED
@@ -0,0 +1,52 @@
1
+ export function slugify(text) {
2
+ return text
3
+ .toLowerCase()
4
+ .normalize("NFD")
5
+ .replace(/\p{Diacritic}/gu, "")
6
+ .replace(/[^a-z0-9]+/g, "-")
7
+ .replace(/^-+|-+$/g, "");
8
+ }
9
+ /** Divide un Markdown en secciones por encabezado ATX. Ignora encabezados dentro de bloques ```. */
10
+ export function indexMarkdown(md) {
11
+ const lines = md.replace(/\r\n/g, "\n").split("\n");
12
+ const heads = [];
13
+ let inFence = false;
14
+ lines.forEach((line, i) => {
15
+ if (/^\s*```/.test(line)) {
16
+ inFence = !inFence;
17
+ return;
18
+ }
19
+ if (inFence)
20
+ return;
21
+ const m = line.match(/^(#{1,6})\s+(.*)$/);
22
+ if (m)
23
+ heads.push({ level: m[1].length, heading: m[2].trim(), start: i });
24
+ });
25
+ return heads.map((h, idx) => {
26
+ const end = idx + 1 < heads.length ? heads[idx + 1].start : lines.length;
27
+ const body = lines.slice(h.start + 1, end).join("\n").trim();
28
+ return { heading: h.heading, level: h.level, slug: slugify(h.heading), body };
29
+ });
30
+ }
31
+ function isComponentsHeading(text) {
32
+ return slugify(text).includes("component");
33
+ }
34
+ /** Nombres de componentes: headings nivel 3 tras una sección "Componentes", hasta el siguiente H2. */
35
+ export function listComponents(sections) {
36
+ const out = [];
37
+ let inComponents = false;
38
+ for (const s of sections) {
39
+ if (s.level <= 2)
40
+ inComponents = isComponentsHeading(s.heading);
41
+ else if (inComponents && s.level === 3)
42
+ out.push(s.heading);
43
+ }
44
+ return out;
45
+ }
46
+ /** Devuelve la sección de un componente por slug exacto o por inclusión. */
47
+ export function getComponentSpec(sections, name) {
48
+ const target = slugify(name);
49
+ return (sections.find((s) => s.slug === target) ??
50
+ sections.find((s) => s.slug.includes(target)) ??
51
+ null);
52
+ }
package/dist/http.js ADDED
@@ -0,0 +1,64 @@
1
+ import express from "express";
2
+ import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
3
+ import { buildServer } from "./server.js";
4
+ function jsonRpcError(code, message) {
5
+ return { jsonrpc: "2.0", error: { code, message }, id: null };
6
+ }
7
+ /** Arranca un servidor HTTP streamable (stateless) para el MCP. Devuelve el http.Server. */
8
+ export function startHttpServer(store, opts) {
9
+ const app = express();
10
+ app.use(express.json({ limit: "4mb" }));
11
+ const host = opts.host ?? "127.0.0.1";
12
+ app.get("/health", (_req, res) => {
13
+ res.json({ status: "ok", server: "design-mcp" });
14
+ });
15
+ app.post("/mcp", async (req, res) => {
16
+ // Protección contra DNS rebinding: valida Host/Origin si hay allowlist configurada.
17
+ if (opts.allowedHosts?.length) {
18
+ const reqHost = req.headers.host;
19
+ if (!reqHost || !opts.allowedHosts.includes(reqHost)) {
20
+ res.status(403).json(jsonRpcError(-32003, "Host no permitido."));
21
+ return;
22
+ }
23
+ }
24
+ if (opts.allowedOrigins?.length) {
25
+ const origin = req.headers.origin;
26
+ if (origin && !opts.allowedOrigins.includes(origin)) {
27
+ res.status(403).json(jsonRpcError(-32003, "Origin no permitido."));
28
+ return;
29
+ }
30
+ }
31
+ // Auth por bearer token (solo si hay token configurado).
32
+ if (opts.token) {
33
+ if (req.headers.authorization !== `Bearer ${opts.token}`) {
34
+ res.status(401).json(jsonRpcError(-32001, "No autorizado: token bearer inválido o ausente."));
35
+ return;
36
+ }
37
+ }
38
+ // Stateless: un server+transport efímero por request (patrón oficial v1.x).
39
+ try {
40
+ const server = buildServer(store);
41
+ const transport = new StreamableHTTPServerTransport({ sessionIdGenerator: undefined });
42
+ res.on("close", () => {
43
+ void transport.close();
44
+ void server.close();
45
+ });
46
+ await server.connect(transport);
47
+ await transport.handleRequest(req, res, req.body);
48
+ }
49
+ catch (err) {
50
+ if (!res.headersSent) {
51
+ res.status(500).json(jsonRpcError(-32603, "Error interno del servidor."));
52
+ }
53
+ }
54
+ });
55
+ const methodNotAllowed = (_req, res) => {
56
+ res.status(405).json(jsonRpcError(-32000, "Método no permitido: usa POST /mcp."));
57
+ };
58
+ app.get("/mcp", methodNotAllowed);
59
+ app.delete("/mcp", methodNotAllowed);
60
+ return app.listen(opts.port, host, () => {
61
+ const mode = opts.token ? "auth: bearer" : "SIN AUTH";
62
+ console.error(`[design-mcp] HTTP escuchando en ${host}:${opts.port} (POST /mcp · ${mode})`);
63
+ });
64
+ }
package/dist/index.js ADDED
@@ -0,0 +1,39 @@
1
+ #!/usr/bin/env node
2
+ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
3
+ import { buildServer } from "./server.js";
4
+ import { loadAllSystems } from "./data.js";
5
+ import { startHttpServer } from "./http.js";
6
+ const store = loadAllSystems();
7
+ const useHttp = process.env.MCP_TRANSPORT === "http" || !!process.env.PORT;
8
+ function csv(v) {
9
+ const items = v?.split(",").map((s) => s.trim()).filter(Boolean);
10
+ return items && items.length ? items : undefined;
11
+ }
12
+ if (useHttp) {
13
+ const port = Number(process.env.PORT ?? 3000);
14
+ const token = process.env.MCP_AUTH_TOKEN;
15
+ const allowNoAuth = process.env.MCP_ALLOW_NO_AUTH === "1";
16
+ if (!token && !allowNoAuth) {
17
+ console.error("[design-mcp] ERROR: el modo HTTP requiere MCP_AUTH_TOKEN. " +
18
+ "Configúralo para exigir 'Authorization: Bearer <token>', " +
19
+ "o define MCP_ALLOW_NO_AUTH=1 para arrancar sin autenticación (no recomendado).");
20
+ process.exit(1);
21
+ }
22
+ if (!token) {
23
+ console.error("[design-mcp] ⚠️ Arrancando en HTTP SIN autenticación (MCP_ALLOW_NO_AUTH=1).");
24
+ }
25
+ const host = process.env.MCP_BIND ?? "127.0.0.1";
26
+ startHttpServer(store, {
27
+ port,
28
+ host,
29
+ token,
30
+ allowedHosts: csv(process.env.MCP_ALLOWED_HOSTS),
31
+ allowedOrigins: csv(process.env.MCP_ALLOWED_ORIGINS),
32
+ });
33
+ }
34
+ else {
35
+ const server = buildServer(store);
36
+ const transport = new StdioServerTransport();
37
+ await server.connect(transport);
38
+ console.error("[design-mcp] servidor listo (stdio)");
39
+ }
package/dist/motion.js ADDED
@@ -0,0 +1,142 @@
1
+ const UTILITY = /\b(dropdown|menu|modal|tooltip|popover|select|overlay)\b/;
2
+ const LOOP_NAME = /(spin|shimmer|skeleton)/;
3
+ const PULSE_NAME = /(pulse|ping|glow|breathe)/;
4
+ function y1OfBezier(s) {
5
+ const m = s.match(/cubic-bezier\(\s*[-\d.]+\s*,\s*([-\d.]+)/);
6
+ return m ? parseFloat(m[1]) : null;
7
+ }
8
+ function toMs(n, unit) {
9
+ return unit === "s" ? n * 1000 : n;
10
+ }
11
+ /** Audita un fragmento de CSS/HTML/JS contra las reglas de motion (§11 del DESIGN.md). */
12
+ export function auditMotion(code, lang, maxMs = 320) {
13
+ const out = [];
14
+ const lines = code.split("\n");
15
+ const lc = code.toLowerCase();
16
+ lines.forEach((line, i) => {
17
+ const ln = i + 1;
18
+ const l = line.toLowerCase();
19
+ const isMotionLine = /\b(transition|animation)\b/.test(l);
20
+ if (/transition(-property)?\s*:\s*[^;]*\ball\b/.test(l)) {
21
+ out.push({ level: "error", rule: "transition-all", line: ln,
22
+ message: "`transition: all` anima cualquier propiedad, incluidas las costosas.",
23
+ fix: "Enumera solo lo que cambia: `transition: transform 200ms, opacity 200ms`." });
24
+ }
25
+ if (isMotionLine && /\bease-in\b(?!-out)/.test(l)) {
26
+ out.push({ level: "warn", rule: "ease-in-entrada", line: ln,
27
+ message: "`ease-in` arranca lento y retrasa el instante que el usuario mira.",
28
+ fix: "Usa `--ease-out` para entradas; reserva `ease-in` para salidas." });
29
+ }
30
+ const transDecl = l.match(/transition(?:-property)?\s*:\s*([^;}]*)/);
31
+ if (transDecl) {
32
+ const layoutProps = [...transDecl[1].matchAll(/\b(width|height|top|left|right|bottom|margin|padding|inset)\b/g)].map((m) => m[1]);
33
+ const hasProgress = /progress/.test(l);
34
+ const offending = layoutProps.filter((p) => !(p === "width" && hasProgress));
35
+ if (offending.length) {
36
+ out.push({ level: "warn", rule: "prop-no-animable", line: ln,
37
+ message: "Animar propiedades de layout (width/height/top/left…) provoca reflow.",
38
+ fix: "Anima `transform`/`opacity`. Excepciones: width de Progress, grid-template-rows del Accordion." });
39
+ }
40
+ }
41
+ if (/(filter|backdrop-filter)\s*:\s*[^;]*blur\(/.test(l)) {
42
+ out.push({ level: "warn", rule: "blur-en-motion", line: ln,
43
+ message: "El blur como recurso visual rompe la regla de fondos planos.",
44
+ fix: "Quita el blur; usa sombras teñidas de tinta si necesitas profundidad." });
45
+ }
46
+ if (isMotionLine && !/\binfinite\b/.test(l) && !LOOP_NAME.test(l)) {
47
+ for (const d of l.match(/(\d*\.?\d+)\s*(ms|s)\b/g) ?? []) {
48
+ const m = d.match(/(\d*\.?\d+)\s*(ms|s)/);
49
+ if (toMs(parseFloat(m[1]), m[2]) > maxMs) {
50
+ out.push({ level: "warn", rule: "duracion-excesiva", line: ln,
51
+ message: `Duración ${d} supera el techo del sistema (${maxMs}ms) para una interacción.`,
52
+ fix: `Recorta a ≤${maxMs}ms; reserva loops solo para carga (spinner, skeleton).` });
53
+ break;
54
+ }
55
+ }
56
+ }
57
+ if (/scale[xyz]?3?d?\(\s*0\s*[,)]/.test(l)) {
58
+ out.push({ level: "warn", rule: "scale-cero", line: ln,
59
+ message: "`scale(0)` colapsa el elemento y se siente brusco.",
60
+ fix: "Arranca en `scale(0.9–0.98)` + `opacity: 0`." });
61
+ }
62
+ const y1 = y1OfBezier(l);
63
+ if (y1 !== null && y1 > 1.6) {
64
+ out.push({ level: "warn", rule: "overshoot-excesivo", line: ln,
65
+ message: `Overshoot ${y1} es demasiado marcado para UI sobria.`,
66
+ fix: "Reduce el rebote (y1 ≤ ~1.4) o usa `--ease-out`." });
67
+ }
68
+ if (/@keyframes\s+\w*(pulse|ping|glow|breathe)/i.test(line) ||
69
+ (/\banimation\b/.test(l) && PULSE_NAME.test(l) && /\binfinite\b/.test(l))) {
70
+ out.push({ level: "warn", rule: "pulsing", line: ln,
71
+ message: "Los indicadores que laten (pulse/ping/glow) son ruido de motion genérico.",
72
+ fix: "Usa un estado estático claro; el spinner solo durante carga real." });
73
+ }
74
+ });
75
+ // Block-level (CSS/HTML): bounce en utilitarios (selector con dropdown/menú/modal/… + overshoot).
76
+ if (lang !== "js") {
77
+ // Limitación conocida: esta extracción no maneja llaves anidadas (p. ej. reglas
78
+ // dentro de @media/@keyframes), así que bloques anidados no se parsean para
79
+ // bounce-utilitario.
80
+ for (const b of code.match(/[^{}]*\{[^}]*\}/g) ?? []) {
81
+ const bl = b.toLowerCase();
82
+ const sel = bl.slice(0, bl.indexOf("{"));
83
+ const y = y1OfBezier(bl);
84
+ const hasSpringAlias = /ease-spring/.test(bl);
85
+ if (UTILITY.test(sel) && ((y !== null && y > 1) || hasSpringAlias)) {
86
+ out.push({ level: "warn", rule: "bounce-utilitario", line: 0,
87
+ message: "Rebote (spring) en un componente utilitario (dropdown/menú/modal/tooltip).",
88
+ fix: "Usa `--ease-out`; el spring se reserva a check y toggle." });
89
+ }
90
+ }
91
+ }
92
+ // Global aggregates.
93
+ const blurCount = (lc.match(/blur\(/g) ?? []).length;
94
+ if (blurCount >= 3) {
95
+ out.push({ level: "error", rule: "blur-everywhere", line: 0,
96
+ message: `Hay ${blurCount} usos de blur; el blur no es un recurso de esta marca.`,
97
+ fix: "Rediseña sin blur (fondos planos)." });
98
+ }
99
+ const hoverScale = (code.match(/:hover[^{]*\{[^}]*scale\(/gi) ?? []).length
100
+ + (lc.match(/whilehover[^}]*scale/g) ?? []).length;
101
+ if (hoverScale >= 3) {
102
+ out.push({ level: "info", rule: "hover-scale-en-todo", line: 0,
103
+ message: `${hoverScale} elementos escalan en hover; se vuelve ruido.`,
104
+ fix: "Reserva el scale en hover para 1–2 acciones clave." });
105
+ }
106
+ // Gating de hover: en touch el tap dispara hovers falsos que se quedan pegados.
107
+ // Regex a nivel de archivo (no parsea anidamiento): si hay algún :hover que
108
+ // mueva/eleve y el archivo no contiene ningún @media (hover: hover), se señala.
109
+ if (lang !== "js") {
110
+ const hoverConMovimiento = /:hover[^{}]*\{[^}]*(transform\s*:|translate|scale\()/i.test(code);
111
+ const tieneGating = /@media[^{]*\bhover:\s*hover\b/i.test(code);
112
+ if (hoverConMovimiento && !tieneGating) {
113
+ out.push({ level: "info", rule: "hover-sin-gating", line: 0,
114
+ message: "Hay hover que mueve o eleva (transform/translate/scale) sin gating de puntero; en touch el tap deja el hover pegado.",
115
+ fix: "Envuelve esos :hover en `@media (hover: hover) and (pointer: fine)`; los hover de solo color/fondo pueden quedar fuera." });
116
+ }
117
+ }
118
+ const stagger = (lc.match(/staggerchildren/g) ?? []).length
119
+ + (lc.match(/nth-child\([^)]*\)[^{]*\{[^}]*(animation-delay|transition-delay)/g) ?? []).length;
120
+ if (stagger >= 2) {
121
+ out.push({ level: "info", rule: "stagger-spam", line: 0,
122
+ message: "Varias listas con stagger en la misma vista compiten por atención.",
123
+ fix: "Limita el stagger a una lista por vista (máx. 6 ítems)." });
124
+ }
125
+ if ((lc.match(/opacity\s*:\s*0(?!\.?\d)/g) ?? []).length >= 4) {
126
+ out.push({ level: "info", rule: "uniform-fade", line: 0,
127
+ message: "Muchas entradas con el mismo fade; el efecto se diluye.",
128
+ fix: "Varía duración/origen o justifica que compartan entrada." });
129
+ }
130
+ const translates = [...lc.matchAll(/translate[xy]?3?d?\([^)]*?(-?\d+)px/g)].map((m) => Math.abs(parseInt(m[1], 10)));
131
+ if (translates.some((v) => v > 16)) {
132
+ out.push({ level: "info", rule: "distancia-larga", line: 0,
133
+ message: "Un desplazamiento de entrada mayor a 16px es largo.",
134
+ fix: "Usa 4–8px; nada debería volar por la pantalla." });
135
+ }
136
+ if (/(@keyframes|\banimation\b)/.test(lc) && !/prefers-reduced-motion/.test(lc)) {
137
+ out.push({ level: "info", rule: "falta-reduced-motion", line: 0,
138
+ message: "Hay animación pero no se ve un bloque prefers-reduced-motion.",
139
+ fix: "Añade el bloque de accesibilidad de §11.5." });
140
+ }
141
+ return out;
142
+ }