@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/slop.js ADDED
@@ -0,0 +1,257 @@
1
+ const AI_FONTS = /font-family\s*:[^;}]*\b(inter|roboto|geist|plus jakarta|montserrat|poppins)\b/;
2
+ const AI_PALETTE = /#(7c3aed|8b5cf6|a855f7|6d28d9|06b6d4|22d3ee|faf9f6|fefce8)\b/;
3
+ const BUZZWORDS = /\b(seamless(?:ly)?|revolutionary|cutting-edge|unlock|leverage|supercharge|effortless(?:ly)?|game-?changer|world-class|easy|simply|just|revoluciona|de vanguardia|siguiente nivel|fácil(?:es|mente)?|simplemente|solo tienes que|sin esfuerzo)\b/;
4
+ const EMOJI = /[\u{1F000}-\u{1FAFF}\u{1F1E6}-\u{1F1FF}]/u;
5
+ // Cadencia aforística: "No es X. Es Y." / "It's not X. It's Y."
6
+ const CADENCIA_ES = /\bno\s+(?:es|son)\s+[^.!?,;:]{2,60}\.\s+(?:es|son)\s+\S/i;
7
+ const CADENCIA_EN = /\b(?:it|this)(?:['’]s|\s+is)\s+not\s+[^.!?,;:]{2,60}\.\s+(?:it|this)(?:['’]s|\s+is)\s+\S/i;
8
+ // Marcador numerado de eyebrow: "01 · Capacidades", "02 / Proceso", "03." o "01" solo.
9
+ const EYEBROW_NUM = /^\s*0\d\s*(?:[·/.\-–—]|$)/;
10
+ /** ¿La firma de eyebrow (uppercase + tracking positivo) está en este bloque/estilo? */
11
+ function hasEyebrowSignature(style) {
12
+ if (!/text-transform\s*:\s*uppercase/.test(style))
13
+ return false;
14
+ const ls = style.match(/letter-spacing\s*:\s*(-?\d*\.?\d+)\s*(?:em|px)/);
15
+ return ls !== null && parseFloat(ls[1]) > 0;
16
+ }
17
+ /** ¿La declaración box-shadow es un glow (var --glow/--accent, o blur ≥12px con color visible)? */
18
+ function isGlowShadow(value) {
19
+ if (/var\(--(?:glow|accent)/.test(value))
20
+ return true;
21
+ let maxBlur = 0;
22
+ for (const m of value.matchAll(/(?:-?\d*\.?\d+(?:px)?\s+){2}(\d*\.?\d+)px/g)) {
23
+ maxBlur = Math.max(maxBlur, parseFloat(m[1]));
24
+ }
25
+ if (maxBlur < 12)
26
+ return false;
27
+ for (const m of value.matchAll(/rgba?\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)/g)) {
28
+ if (Math.max(+m[1], +m[2], +m[3]) >= 80)
29
+ return true;
30
+ }
31
+ for (const m of value.matchAll(/#([0-9a-f]{6}|[0-9a-f]{3})\b/g)) {
32
+ const h = m[1].length === 3 ? m[1].split("").map((c) => c + c).join("") : m[1];
33
+ if (Math.max(...[0, 2, 4].map((o) => parseInt(h.slice(o, o + 2), 16))) >= 80)
34
+ return true;
35
+ }
36
+ return false;
37
+ }
38
+ /** ¿border-left / border-inline-start ≥2px sólido con color no neutro? */
39
+ function accentSideBorder(body) {
40
+ const bm = body.match(/border-(?:left|inline-start)\s*:\s*(\d*\.?\d+)px\s+solid\s+([^;}"]+)/);
41
+ if (!bm || parseFloat(bm[1]) < 2)
42
+ return false;
43
+ const color = bm[2].trim();
44
+ if (/var\(--(?:accent|primary|success|warning|danger|info)/.test(color))
45
+ return true;
46
+ if (/var\(/.test(color))
47
+ return false; // otras variables: no concluyente, no marcar
48
+ if (/\b(?:red|blue|green|orange|purple|violet|teal|cyan|magenta|pink|yellow|crimson|coral)\b/.test(color))
49
+ return true;
50
+ const rgb = color.match(/rgba?\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)/);
51
+ if (rgb) {
52
+ const c = [+rgb[1], +rgb[2], +rgb[3]];
53
+ return Math.max(...c) - Math.min(...c) >= 30;
54
+ }
55
+ const hex = color.match(/#([0-9a-f]{6}|[0-9a-f]{3})\b/);
56
+ if (hex) {
57
+ const h = hex[1].length === 3 ? hex[1].split("").map((ch) => ch + ch).join("") : hex[1];
58
+ const chans = [0, 2, 4].map((o) => parseInt(h.slice(o, o + 2), 16));
59
+ return Math.max(...chans) - Math.min(...chans) >= 30;
60
+ }
61
+ return false;
62
+ }
63
+ /** Detecta tells de diseño genérico ("AI slop") en CSS/HTML, adaptado a las reglas de Higinio. */
64
+ export function checkSlop(code, lang, mode = "light") {
65
+ const out = [];
66
+ const lines = code.split("\n");
67
+ // Reglas por línea (aplican a CSS y a estilos inline de HTML).
68
+ lines.forEach((line, i) => {
69
+ const ln = i + 1;
70
+ const l = line.toLowerCase();
71
+ if (/(linear|radial|conic)-gradient\(/.test(l)) {
72
+ out.push({ level: "warn", rule: "gradiente", line: ln,
73
+ message: "Gradiente detectado; la marca usa fondos planos.",
74
+ fix: "Usa un fondo sólido (--bg); sin gradientes." });
75
+ }
76
+ if (/(?:-webkit-)?background-clip\s*:\s*text/.test(l)) {
77
+ out.push({ level: "warn", rule: "gradient-text", line: ln,
78
+ message: "Texto con relleno recortado (background-clip: text), típico de gradient-text.",
79
+ fix: "Usa color sólido (--text-primary)." });
80
+ }
81
+ if (/(?:-webkit-)?backdrop-filter\s*:[^;}]*blur\(/.test(l) || /filter\s*:[^;}]*blur\(/.test(l)) {
82
+ out.push({ level: "warn", rule: "glassmorphism", line: ln,
83
+ message: "Glassmorphism (blur en superficie); la marca usa superficies planas.",
84
+ fix: "Superficie plana + sombra teñida de tinta." });
85
+ }
86
+ if (mode === "light" && /box-shadow\s*:[^;}]*(#000(\b|000)|\bblack\b|rgba\(\s*0\s*,\s*0\s*,\s*0)/.test(l)) {
87
+ out.push({ level: "warn", rule: "sombra-negro-puro", line: ln,
88
+ message: "Sombra de negro puro; la marca tiñe las sombras de tinta.",
89
+ fix: "En modo claro, usa una sombra teñida de tinta en lugar de negro puro." });
90
+ }
91
+ if (AI_FONTS.test(l)) {
92
+ out.push({ level: "warn", rule: "fuente-generica", line: ln,
93
+ message: "Fuente genérica de IA (Inter/Roboto/Geist…).",
94
+ fix: "Usa las fuentes de marca del sistema activo." });
95
+ }
96
+ if (/text-align\s*:\s*justify/.test(l)) {
97
+ out.push({ level: "warn", rule: "texto-justificado", line: ln,
98
+ message: "Texto justificado; crea ríos y daña la legibilidad.",
99
+ fix: "Alinea a la izquierda." });
100
+ }
101
+ const ls = l.match(/letter-spacing\s*:\s*(-?\d*\.?\d+)\s*(em|px)/);
102
+ if (ls) {
103
+ const em = ls[2] === "px" ? parseFloat(ls[1]) / 16 : parseFloat(ls[1]);
104
+ if (em > 0.05) {
105
+ out.push({ level: "info", rule: "tracking-amplio", line: ln,
106
+ message: `letter-spacing ${ls[1]}${ls[2]} es amplio; la marca topa en +0.04em (solo labels).`,
107
+ fix: "Máx. +0.04em, y solo en labels uppercase." });
108
+ }
109
+ }
110
+ if (AI_PALETTE.test(l)) {
111
+ out.push({ level: "info", rule: "paleta-ia", line: ln,
112
+ message: "Color de paleta genérica de IA (violeta/cyan/crema).",
113
+ fix: "Usa la paleta del sistema." });
114
+ }
115
+ const fs = l.match(/font-size\s*:\s*(\d*\.?\d+)px/);
116
+ if (fs && parseFloat(fs[1]) < 12) {
117
+ out.push({ level: "warn", rule: "texto-diminuto", line: ln,
118
+ message: `Texto de ${fs[1]}px; el mínimo de la marca es 12px.`,
119
+ fix: "Usa ≥12px con line-height 1.5." });
120
+ }
121
+ });
122
+ // Reglas a nivel de bloque CSS (emparejan font-size con line-height / text-transform).
123
+ for (const b of code.match(/\{[^}]*\}/g) ?? []) {
124
+ const bl = b.toLowerCase();
125
+ const fsM = bl.match(/font-size\s*:\s*(\d*\.?\d+)px/);
126
+ const fontSize = fsM ? parseFloat(fsM[1]) : null;
127
+ const lhM = bl.match(/line-height\s*:\s*(\d*\.?\d+)(px|em|%|rem)?/);
128
+ if (lhM && !lhM[2] && fontSize !== null && fontSize <= 18 && parseFloat(lhM[1]) < 1.4) {
129
+ out.push({ level: "warn", rule: "interlineado-apretado", line: 0,
130
+ message: `Interlineado ${lhM[1]} en texto de cuerpo (${fontSize}px); apretado.`,
131
+ fix: "Usa 1.5–1.6 en cuerpo (los headings pueden ir más ajustados)." });
132
+ }
133
+ if (/text-transform\s*:\s*uppercase/.test(bl) && fontSize !== null && (fontSize > 14 || !/letter-spacing/.test(bl))) {
134
+ out.push({ level: "info", rule: "mayusculas-cuerpo", line: 0,
135
+ message: `Mayúsculas en texto de ${fontSize}px; reserva uppercase para labels pequeños.`,
136
+ fix: "Solo en labels ≤14px con tracking +0.04em." });
137
+ }
138
+ }
139
+ // Glow duplicado (solo modo oscuro): la regla de Nocturna es "glow máximo uno por pantalla" (§7).
140
+ if (mode === "dark") {
141
+ const glowLines = [];
142
+ lines.forEach((line, i) => {
143
+ for (const m of line.toLowerCase().matchAll(/box-shadow\s*:\s*([^;}]*)/g)) {
144
+ if (isGlowShadow(m[1]))
145
+ glowLines.push(i + 1);
146
+ }
147
+ });
148
+ if (glowLines.length >= 2) {
149
+ out.push({ level: "warn", rule: "glow-budget", line: glowLines[1],
150
+ message: `${glowLines.length} sombras con glow de acento en la misma vista; el glow se raciona.`,
151
+ fix: "El glow es del elemento hero/CTA; máximo uno por pantalla." });
152
+ }
153
+ }
154
+ // Borde lateral de acento en tarjetas. Exención: Toast y Alert usan border-left semántico de 3px por spec (§10.17).
155
+ for (const m of code.matchAll(/([^{}]*)\{([^}]*)\}/g)) {
156
+ if (/toast|alert/i.test(m[1]))
157
+ continue;
158
+ if (accentSideBorder(m[2].toLowerCase())) {
159
+ out.push({ level: "info", rule: "borde-acento-lateral", line: 0,
160
+ message: "Borde lateral de acento en una superficie; tell de tarjeta de IA.",
161
+ fix: "Reserva el border-left semántico para Toast y Alert (3px, §10.17); las tarjetas usan borde neutro completo." });
162
+ }
163
+ }
164
+ lines.forEach((line, i) => {
165
+ for (const m of line.matchAll(/<[a-z][^>]*\bstyle\s*=\s*"([^"]*)"/gi)) {
166
+ if (/toast|alert/i.test(m[0]))
167
+ continue;
168
+ if (accentSideBorder(m[1].toLowerCase())) {
169
+ out.push({ level: "info", rule: "borde-acento-lateral", line: i + 1,
170
+ message: "Borde lateral de acento en una superficie; tell de tarjeta de IA.",
171
+ fix: "Reserva el border-left semántico para Toast y Alert (3px, §10.17); las tarjetas usan borde neutro completo." });
172
+ }
173
+ }
174
+ });
175
+ // Reglas de copy sobre nodos de texto HTML (fuera de <style>/<script>).
176
+ if (lang === "html") {
177
+ let inBlock = false;
178
+ let numberedEyebrows = 0;
179
+ lines.forEach((line, i) => {
180
+ const ln = i + 1;
181
+ const low = line.toLowerCase();
182
+ const opens = /<(style|script)[\s>]/.test(low);
183
+ if (opens)
184
+ inBlock = true;
185
+ if (!inBlock) {
186
+ for (const m of line.matchAll(/>([^<]+)</g)) {
187
+ const t = m[1];
188
+ if (/[!¡]/.test(t) && !/!\s*=/.test(t)) {
189
+ out.push({ level: "warn", rule: "exclamacion", line: ln,
190
+ message: "Signo de exclamación en el texto; la voz de la marca no los usa.",
191
+ fix: "Reescribe sin signos de exclamación (§2)." });
192
+ }
193
+ if (EMOJI.test(t)) {
194
+ out.push({ level: "warn", rule: "emoji", line: ln,
195
+ message: "Emoji en el texto; la marca comunica con color + icono + texto.",
196
+ fix: "Quita el emoji." });
197
+ }
198
+ if ((t.match(/—/g) ?? []).length > 2) {
199
+ out.push({ level: "info", rule: "em-dash-abuso", line: ln,
200
+ message: "Más de 2 guiones largos en un bloque (cadence aforístico de IA).",
201
+ fix: "Máx. 2 em-dash por bloque." });
202
+ }
203
+ if (BUZZWORDS.test(t.toLowerCase())) {
204
+ out.push({ level: "info", rule: "jerga-marketing", line: ln,
205
+ message: "Jerga de marketing detectada.",
206
+ fix: "Usa lenguaje directo, sin jerga." });
207
+ }
208
+ if (CADENCIA_ES.test(t) || CADENCIA_EN.test(t)) {
209
+ out.push({ level: "info", rule: "cadencia-aforistica", line: ln,
210
+ message: "Cadencia aforística (\"No es X. Es Y.\"); tell de copy de IA.",
211
+ fix: "Di lo que es directamente, sin la negación previa." });
212
+ }
213
+ if (EYEBROW_NUM.test(t))
214
+ numberedEyebrows++;
215
+ }
216
+ const hM = line.match(/<h[1-6][^>]*>([^<]+)</i);
217
+ if (hM) {
218
+ const words = hM[1].trim().split(/\s+/).filter(Boolean);
219
+ const caps = words.slice(1).filter((w) => /^[A-ZÁÉÍÓÚÑ]/.test(w)).length;
220
+ if (words.length >= 2 && caps >= 1) {
221
+ out.push({ level: "info", rule: "title-case-heading", line: ln,
222
+ message: "Posible Title Case en un heading; la marca usa sentence case.",
223
+ fix: "Solo la primera palabra en mayúscula (salvo nombres propios)." });
224
+ }
225
+ }
226
+ }
227
+ if (/<\/(style|script)>/.test(low))
228
+ inBlock = false;
229
+ });
230
+ // Eyebrow spam: la página raciona los labels uppercase+tracking (máx. 1 por cada 3 secciones).
231
+ const eyebrowClasses = new Set();
232
+ for (const m of code.matchAll(/\.([a-zA-Z][\w-]*)\s*\{([^}]*)\}/g)) {
233
+ if (hasEyebrowSignature(m[2].toLowerCase()))
234
+ eyebrowClasses.add(m[1].toLowerCase());
235
+ }
236
+ let eyebrowCount = 0;
237
+ for (const m of code.matchAll(/<[a-z][^>]*\bclass\s*=\s*"([^"]*)"/gi)) {
238
+ if (m[1].toLowerCase().split(/\s+/).some((c) => eyebrowClasses.has(c)))
239
+ eyebrowCount++;
240
+ }
241
+ for (const m of code.matchAll(/<[a-z][^>]*\bstyle\s*=\s*"([^"]*)"/gi)) {
242
+ if (hasEyebrowSignature(m[1].toLowerCase()))
243
+ eyebrowCount++;
244
+ }
245
+ if (eyebrowCount >= 3) {
246
+ out.push({ level: "info", rule: "eyebrow-spam", line: 0,
247
+ message: `${eyebrowCount} labels uppercase con tracking en la misma página; ritmo templado de eyebrows de IA.`,
248
+ fix: "Máximo 1 eyebrow por cada 3 secciones; el headline solo suele bastar." });
249
+ }
250
+ if (numberedEyebrows >= 2) {
251
+ out.push({ level: "info", rule: "eyebrow-spam", line: 0,
252
+ message: `${numberedEyebrows} marcadores numerados tipo "01 ·"; enumerar secciones es un tell de IA.`,
253
+ fix: "El eyebrow nombra el tema en lenguaje plano, no lo enumera; mejor aún, quítalo." });
254
+ }
255
+ }
256
+ return out;
257
+ }
@@ -0,0 +1,54 @@
1
+ const CSS = {
2
+ button: `.btn-primary {
3
+ background: var(--accent);
4
+ color: var(--on-accent, #fff);
5
+ border: 1px solid transparent;
6
+ border-radius: var(--radius-md, 10px);
7
+ padding: 10px 16px;
8
+ font-family: var(--font-body);
9
+ }
10
+ .btn-primary:hover { background: var(--accent-hover); }`,
11
+ card: `.card {
12
+ background: var(--surface);
13
+ border: 1px solid var(--border);
14
+ border-radius: var(--radius-lg, 14px);
15
+ padding: 20px;
16
+ color: var(--text-primary);
17
+ }`,
18
+ input: `.input {
19
+ background: var(--surface);
20
+ border: 1px solid var(--border);
21
+ border-radius: var(--radius-md, 10px);
22
+ padding: 10px 12px;
23
+ color: var(--text-primary);
24
+ }
25
+ .input:focus { outline: 2px solid var(--accent); }`,
26
+ };
27
+ const REACT = {
28
+ button: `export function Button({ children, ...props }) {
29
+ return <button className="btn-primary" {...props}>{children}</button>;
30
+ }`,
31
+ card: `export function Card({ children }) {
32
+ return <div className="card">{children}</div>;
33
+ }`,
34
+ input: `export function Input(props) {
35
+ return <input className="input" {...props} />;
36
+ }`,
37
+ };
38
+ function tailwindFor(component) {
39
+ const map = {
40
+ button: `<button class="bg-[var(--accent)] text-[var(--on-accent)] rounded-[10px] px-4 py-2.5 font-[var(--font-body)] hover:bg-[var(--accent-hover)]">Acción</button>`,
41
+ card: `<div class="bg-[var(--surface)] border border-[var(--border)] rounded-[14px] p-5 text-[var(--text-primary)]">…</div>`,
42
+ input: `<input class="bg-[var(--surface)] border border-[var(--border)] rounded-[10px] px-3 py-2.5 text-[var(--text-primary)] focus:outline-2 focus:outline-[var(--accent)]" />`,
43
+ };
44
+ return map[component] ?? `<div class="bg-[var(--surface)] text-[var(--text-primary)] p-4">${component}</div>`;
45
+ }
46
+ export function generateSnippet(sys, component, stack) {
47
+ const key = component.toLowerCase();
48
+ const header = `/* ${sys.meta.name} — ${component} (${stack}). Usa solo aliases semánticos. */\n`;
49
+ if (stack === "css")
50
+ return header + (CSS[key] ?? `.${key} {\n background: var(--surface);\n color: var(--text-primary);\n}`);
51
+ if (stack === "react")
52
+ return header + (REACT[key] ?? `export function ${key.replace(/(^.|-.)/g, (m) => m.replace("-", "").toUpperCase())}() {\n return <div className="${key}">…</div>;\n}`);
53
+ return header + tailwindFor(key);
54
+ }
@@ -0,0 +1,21 @@
1
+ export const SYSTEMS = {
2
+ celeste: {
3
+ id: "celeste",
4
+ dir: "celeste-clasica",
5
+ name: "Celeste Clásica",
6
+ mode: "light",
7
+ philosophy: "Light-first, sereno y profesional. Banca privada boutique, no startup ruidosa.",
8
+ palette: ["#a0c0d4", "#7192ba", "#31343e"],
9
+ fonts: { display: "Fraunces", body: "Public Sans", mono: "IBM Plex Mono" },
10
+ },
11
+ nocturna: {
12
+ id: "nocturna",
13
+ dir: "nocturna-electrica",
14
+ name: "Nocturna Eléctrica",
15
+ mode: "dark",
16
+ philosophy: "Dark-first, técnico y de alto contraste.",
17
+ palette: ["#52baed", "#353d7c", "#010203"],
18
+ fonts: { display: "Space Grotesk", body: "Manrope", mono: "JetBrains Mono" },
19
+ },
20
+ };
21
+ export const SYSTEM_IDS = ["celeste", "nocturna"];
package/dist/tokens.js ADDED
@@ -0,0 +1,76 @@
1
+ function valueToString(value) {
2
+ if (value && typeof value === "object" && "hex" in value) {
3
+ return String(value.hex);
4
+ }
5
+ return String(value);
6
+ }
7
+ /** Aplana un árbol de tokens W3C DTCG a una lista plana. */
8
+ export function parseDtcgTokens(json) {
9
+ const out = [];
10
+ const walk = (node, path, inheritedType) => {
11
+ if (!node || typeof node !== "object")
12
+ return;
13
+ const obj = node;
14
+ const type = typeof obj.$type === "string" ? obj.$type : inheritedType;
15
+ if ("$value" in obj) {
16
+ out.push({
17
+ path: path.join("."),
18
+ value: valueToString(obj.$value),
19
+ type,
20
+ description: typeof obj.$description === "string" ? obj.$description : undefined,
21
+ });
22
+ return;
23
+ }
24
+ for (const [key, child] of Object.entries(obj)) {
25
+ if (key.startsWith("$"))
26
+ continue;
27
+ walk(child, [...path, key], type);
28
+ }
29
+ };
30
+ walk(json, [], "unknown");
31
+ return out;
32
+ }
33
+ /** Extrae variables CSS `--nombre: valor;` del bloque :root canónico (ignora overrides de tema). */
34
+ export function parseCssAliases(css) {
35
+ const map = new Map();
36
+ const rootMatch = css.match(/:root\s*\{([^}]*)\}/);
37
+ const scope = rootMatch ? rootMatch[1] : css;
38
+ const re = /(--[\w-]+)\s*:\s*([^;]+);/g;
39
+ let m;
40
+ while ((m = re.exec(scope)) !== null) {
41
+ map.set(m[1], m[2].trim());
42
+ }
43
+ return map;
44
+ }
45
+ /** Resuelve un alias (siguiendo var()) o un token DTCG por path. */
46
+ export function resolveToken(aliases, tokenIndex, name) {
47
+ if (name.startsWith("--")) {
48
+ if (!aliases.has(name))
49
+ return null;
50
+ const chain = [name];
51
+ let current = name;
52
+ for (let i = 0; i < 10; i++) {
53
+ const raw = aliases.get(current);
54
+ if (raw === undefined)
55
+ break;
56
+ const varMatch = raw.match(/^var\((--[\w-]+)\)$/);
57
+ if (varMatch) {
58
+ current = varMatch[1];
59
+ chain.push(current);
60
+ continue;
61
+ }
62
+ chain.push(raw);
63
+ return { name, value: raw, chain };
64
+ }
65
+ return null;
66
+ }
67
+ const token = tokenIndex.get(name);
68
+ if (!token)
69
+ return null;
70
+ return { name, value: token.value, chain: [name, token.value], type: token.type, description: token.description };
71
+ }
72
+ /** Busca tokens cuyo path o valor contenga la query (case-insensitive). */
73
+ export function searchTokens(tokens, query) {
74
+ const q = query.toLowerCase();
75
+ return tokens.filter((t) => t.path.toLowerCase().includes(q) || t.value.toLowerCase().includes(q));
76
+ }
package/dist/types.js ADDED
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,135 @@
1
+ function relLuminance(hex) {
2
+ const h = hex.replace("#", "");
3
+ const n = h.length === 3 ? h.split("").map((c) => c + c).join("") : h;
4
+ const rgb = [0, 2, 4].map((i) => parseInt(n.slice(i, i + 2), 16) / 255);
5
+ const lin = rgb.map((c) => (c <= 0.03928 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4));
6
+ return 0.2126 * lin[0] + 0.7152 * lin[1] + 0.0722 * lin[2];
7
+ }
8
+ export function contrastRatio(a, b) {
9
+ const la = relLuminance(a);
10
+ const lb = relLuminance(b);
11
+ const [hi, lo] = la > lb ? [la, lb] : [lb, la];
12
+ return (hi + 0.05) / (lo + 0.05);
13
+ }
14
+ /** Mapa hex(lowercase) → alias sugerido, a partir de los tokens del sistema. */
15
+ function hexToAlias(sys) {
16
+ const byHex = new Map();
17
+ for (const [alias, raw] of sys.aliases) {
18
+ const m = raw.match(/^#([0-9a-fA-F]{3,6})$/);
19
+ if (m)
20
+ byHex.set(("#" + m[1]).toLowerCase(), alias);
21
+ }
22
+ return byHex;
23
+ }
24
+ export function validateCode(sys, code, lang) {
25
+ const out = [];
26
+ const lines = code.split("\n");
27
+ const paletteHex = new Set(sys.tokens.filter((t) => t.type === "color").map((t) => t.value.toLowerCase()));
28
+ const aliasByHex = hexToAlias(sys);
29
+ lines.forEach((line, i) => {
30
+ const ln = i + 1;
31
+ const lower = line.toLowerCase();
32
+ const primary300AsText = sys.meta.id === "celeste" && /(?<![\w-])color\s*:\s*#a0c0d4/.test(lower);
33
+ for (const hex of lower.match(/#[0-9a-f]{6}\b/g) ?? []) {
34
+ if (hex === "#a0c0d4" && primary300AsText) {
35
+ out.push({ level: "error", rule: "primary-300-no-texto", line: ln,
36
+ message: "primary-300 (#a0c0d4) no debe usarse como color de texto.",
37
+ fix: "Usa --text-primary o --link; reserva #a0c0d4 para decoración." });
38
+ }
39
+ else if (aliasByHex.has(hex)) {
40
+ out.push({ level: "warn", rule: "hex-directo", line: ln,
41
+ message: `Hex directo ${hex} equivale al alias ${aliasByHex.get(hex)}.`,
42
+ fix: `Reemplaza ${hex} por var(${aliasByHex.get(hex)}).` });
43
+ }
44
+ else if (!paletteHex.has(hex)) {
45
+ out.push({ level: "warn", rule: "color-fuera-de-sistema", line: ln,
46
+ message: `El color ${hex} no pertenece a la paleta del sistema.`,
47
+ fix: "Usa un token/alias del sistema en lugar de un hex arbitrario." });
48
+ }
49
+ }
50
+ });
51
+ if (lang === "css") {
52
+ const blocks = code.match(/\{[^}]*\}/g) ?? [];
53
+ for (const block of blocks) {
54
+ const color = block.match(/(?<![\w-])color\s*:\s*(#[0-9a-fA-F]{6})/);
55
+ const bg = block.match(/background(?:-color)?\s*:\s*(#[0-9a-fA-F]{6})/);
56
+ if (color && bg && contrastRatio(color[1], bg[1]) < 4.5) {
57
+ out.push({ level: "warn", rule: "contraste-aa", line: 0,
58
+ message: `Contraste ${contrastRatio(color[1], bg[1]).toFixed(2)}:1 entre ${color[1]} y ${bg[1]} (< 4.5 AA).`,
59
+ fix: "Aumenta el contraste texto/fondo hasta al menos 4.5:1." });
60
+ }
61
+ }
62
+ if (/outline\s*:\s*(none|0)\b/.test(code) && !/:focus-visible/.test(code)) {
63
+ out.push({ level: "warn", rule: "outline-none", line: 0,
64
+ message: "outline: none sin reemplazo de foco visible.",
65
+ fix: "Provee foco con :focus-visible (nunca quites el foco sin alternativa)." });
66
+ }
67
+ const allowedRadii = new Set([0]);
68
+ for (const [alias, val] of sys.aliases) {
69
+ if (/^--radius-/.test(alias)) {
70
+ const mm = val.match(/(\d+)px/);
71
+ if (mm)
72
+ allowedRadii.add(parseInt(mm[1], 10));
73
+ }
74
+ }
75
+ for (const m of code.matchAll(/border-radius\s*:\s*([^;}]+)/g)) {
76
+ for (const px of m[1].matchAll(/(\d+)px/g)) {
77
+ const v = parseInt(px[1], 10);
78
+ if (v < 999 && !allowedRadii.has(v)) {
79
+ out.push({ level: "info", rule: "drift-radio", line: 0,
80
+ message: `border-radius ${v}px fuera de la escala de radios del sistema.`,
81
+ fix: "Usa la escala de radios del sistema (--radius-sm/md/lg/xl) o 50%/9999px para píldoras." });
82
+ }
83
+ }
84
+ }
85
+ // Escala tipográfica derivada de los aliases --text-* en rem (mismo enfoque que drift-radio con
86
+ // --radius-*). Si los tokens no la expusieran, cae a {12,14,16,18,20,25,31,39,49}: la escala 1.25
87
+ // base 16 de §4.2, idéntica en ambos sistemas. Solo se evalúan px; rem/em y 0 quedan exentos.
88
+ const allowedFontPx = new Set([0]);
89
+ for (const [alias, val] of sys.aliases) {
90
+ if (!/^--text-/.test(alias))
91
+ continue;
92
+ const rem = val.match(/^(\d*\.?\d+)rem$/);
93
+ if (rem)
94
+ allowedFontPx.add(Math.round(parseFloat(rem[1]) * 16));
95
+ }
96
+ if (allowedFontPx.size === 1)
97
+ for (const v of [12, 14, 16, 18, 20, 25, 31, 39, 49])
98
+ allowedFontPx.add(v);
99
+ for (const m of code.matchAll(/font-size\s*:\s*(\d*\.?\d+)px/g)) {
100
+ const v = parseFloat(m[1]);
101
+ if (!allowedFontPx.has(v)) {
102
+ out.push({ level: "info", rule: "drift-font-size", line: 0,
103
+ message: `font-size ${v}px fuera de la escala tipográfica del sistema.`,
104
+ fix: "Usa la escala del sistema (--text-xs…--text-5xl: 12/14/16/18/20/25/31/39/49px)." });
105
+ }
106
+ }
107
+ for (const m of code.matchAll(/letter-spacing\s*:\s*(-\d*\.?\d+)em/g)) {
108
+ const v = parseFloat(m[1]);
109
+ if (v <= -0.04) {
110
+ out.push({ level: "warn", rule: "tracking-negativo-extremo", line: 0,
111
+ message: `letter-spacing ${v}em aplasta el texto (≤ -0.04em).`,
112
+ fix: "El tracking más apretado del sistema es --tracking-display (-0.02em); no bajes de ahí." });
113
+ }
114
+ }
115
+ }
116
+ if (lang === "html") {
117
+ const primaries = (code.match(/btn[-\s_]*primary/gi) ?? []).length;
118
+ if (primaries > 1) {
119
+ out.push({ level: "warn", rule: "un-solo-primario", line: 0,
120
+ message: `Se detectaron ${primaries} botones primarios; debe haber uno solo por pantalla.`,
121
+ fix: "Deja un único botón primario visible; el resto, secundarios/ghost." });
122
+ }
123
+ for (const m of code.matchAll(/<(div|span)\b[^>]*\bonclick\s*=/gi)) {
124
+ out.push({ level: "warn", rule: "div-clickable", line: 0,
125
+ message: `<${m[1].toLowerCase()}> con onclick no es accesible por teclado.`,
126
+ fix: "Usa <button> para acciones." });
127
+ }
128
+ for (const _ of code.matchAll(/>([^<]*\.\.\.[^<]*)</g)) {
129
+ out.push({ level: "info", rule: "puntos-suspensivos", line: 0,
130
+ message: "Tres puntos '...' en el texto; usa el carácter '…'.",
131
+ fix: "Reemplaza '...' por '…'." });
132
+ }
133
+ }
134
+ return out;
135
+ }
package/package.json ADDED
@@ -0,0 +1,37 @@
1
+ {
2
+ "name": "@higinio/design-mcp",
3
+ "version": "0.1.0",
4
+ "description": "MCP server de los design systems de Higinio (Celeste Clásica y Nocturna Eléctrica).",
5
+ "type": "module",
6
+ "license": "UNLICENSED",
7
+ "author": "Higinio Sosa",
8
+ "repository": { "type": "git", "url": "git+https://github.com/HiginioSosa/Diseno-Higinio.git", "directory": "mcp" },
9
+ "homepage": "https://github.com/HiginioSosa/Diseno-Higinio#readme",
10
+ "bugs": { "url": "https://github.com/HiginioSosa/Diseno-Higinio/issues" },
11
+ "keywords": ["mcp", "design-system", "design-tokens", "celeste-clasica", "nocturna-electrica"],
12
+ "bin": { "design-mcp": "dist/index.js", "design-lint": "dist/cli.js" },
13
+ "files": ["dist", "assets"],
14
+ "publishConfig": { "access": "public" },
15
+ "engines": { "node": ">=20" },
16
+ "scripts": {
17
+ "sync-assets": "tsx scripts/sync-assets.ts",
18
+ "build": "tsc",
19
+ "prepare": "npm run sync-assets && npm run build",
20
+ "start": "node dist/index.js",
21
+ "dev": "tsx src/index.ts",
22
+ "test": "vitest run",
23
+ "test:watch": "vitest"
24
+ },
25
+ "dependencies": {
26
+ "@modelcontextprotocol/sdk": "^1.29.0",
27
+ "express": "^4.21.2",
28
+ "zod": "^3.23.8"
29
+ },
30
+ "devDependencies": {
31
+ "@types/express": "^4.17.21",
32
+ "@types/node": "^22.0.0",
33
+ "tsx": "^4.19.0",
34
+ "typescript": "^5.6.0",
35
+ "vitest": "^2.1.0"
36
+ }
37
+ }