@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.
@@ -0,0 +1,181 @@
1
+ /** Verifica estáticamente la estructura de un documento HTML/CSS antes de producción (SEO, a11y, perf estático). */
2
+ export function checkPreflight(code, lang) {
3
+ const out = [];
4
+ const isFullDoc = /<!doctype|<html|<head/i.test(code);
5
+ if (lang === "html") {
6
+ const h1count = (code.match(/<h1\b/gi) ?? []).length;
7
+ if (isFullDoc) {
8
+ if (/<html\b/i.test(code) && !/<html\b[^>]*\blang\s*=/i.test(code)) {
9
+ out.push({ level: "warn", rule: "falta-lang", line: 0,
10
+ message: "El <html> no declara idioma (lang).",
11
+ fix: 'Añade lang al <html> (ej. <html lang="es">).' });
12
+ }
13
+ if (/<head\b/i.test(code) && !/<title\b/i.test(code)) {
14
+ out.push({ level: "warn", rule: "falta-title", line: 0,
15
+ message: "El documento no tiene <title>.",
16
+ fix: "Añade un <title> descriptivo (50–60 caracteres)." });
17
+ }
18
+ if (!/<meta\b[^>]*charset/i.test(code)) {
19
+ out.push({ level: "info", rule: "falta-charset", line: 0,
20
+ message: "No hay <meta charset>.",
21
+ fix: 'Añade <meta charset="utf-8"> como primer elemento del <head>.' });
22
+ }
23
+ if (!/<meta\b[^>]*name\s*=\s*["']viewport["']/i.test(code)) {
24
+ out.push({ level: "warn", rule: "falta-viewport", line: 0,
25
+ message: 'No hay <meta name="viewport">.',
26
+ fix: "Añade el viewport responsive." });
27
+ }
28
+ if (!/<meta\b[^>]*name\s*=\s*["']description["']/i.test(code)) {
29
+ out.push({ level: "info", rule: "falta-meta-description", line: 0,
30
+ message: 'No hay <meta name="description">.',
31
+ fix: "Añade una meta description (150–160 caracteres)." });
32
+ }
33
+ if (!/<link\b[^>]*rel\s*=\s*["']canonical["']/i.test(code)) {
34
+ out.push({ level: "info", rule: "falta-canonical", line: 0,
35
+ message: 'No hay <link rel="canonical">.',
36
+ fix: "Añade el canonical auto-referente." });
37
+ }
38
+ if (h1count === 0) {
39
+ out.push({ level: "info", rule: "h1-ausente", line: 0,
40
+ message: "La página no tiene <h1>.",
41
+ fix: "Añade un <h1> que describa la página." });
42
+ }
43
+ if (!/<meta\b[^>]*name\s*=\s*["']theme-color["']/i.test(code)) {
44
+ out.push({ level: "info", rule: "sin-theme-color", line: 0,
45
+ message: 'No hay <meta name="theme-color">.',
46
+ fix: "Añade theme-color con el fondo de la marca (Nocturna #010203, Celeste #f7f8fa); sin él la barra del navegador queda clara sobre una app oscura." });
47
+ }
48
+ for (const meta of code.match(/<meta\b[^>]*>/gi) ?? []) {
49
+ if (/name\s*=\s*["']robots["']/i.test(meta) && /noindex/i.test(meta)) {
50
+ out.push({ level: "warn", rule: "noindex-accidental", line: 0,
51
+ message: '<meta name="robots"> contiene noindex.',
52
+ fix: "Quítalo si la página debe indexarse; noindex la saca de los buscadores." });
53
+ break;
54
+ }
55
+ }
56
+ const bodyM = code.match(/<body\b[^>]*>/i);
57
+ if (/<nav\b/i.test(code) && bodyM) {
58
+ const inicio = code.slice(code.indexOf(bodyM[0]) + bodyM[0].length).slice(0, 1500);
59
+ if (!/<a\b[^>]*href\s*=\s*["']#/i.test(inicio)) {
60
+ out.push({ level: "info", rule: "sin-skip-link", line: 0,
61
+ message: "Hay <nav> pero ningún enlace interno al inicio del <body> (skip link).",
62
+ fix: "Añade un skip link como primer elemento enfocable («Saltar al contenido», href=#main)." });
63
+ }
64
+ }
65
+ }
66
+ const titleM = code.match(/<title\b[^>]*>([^<]*)<\/title>/i);
67
+ if (titleM) {
68
+ const len = titleM[1].trim().length;
69
+ if (len > 0 && (len < 30 || len > 60)) {
70
+ out.push({ level: "info", rule: "title-longitud", line: 0,
71
+ message: `El <title> tiene ${len} caracteres.`,
72
+ fix: "Apunta a 50–60 caracteres." });
73
+ }
74
+ }
75
+ for (const meta of code.match(/<meta\b[^>]*>/gi) ?? []) {
76
+ if (!/name\s*=\s*["']description["']/i.test(meta))
77
+ continue;
78
+ const cM = meta.match(/content\s*=\s*["']([^"']*)["']/i);
79
+ if (cM) {
80
+ const len = cM[1].trim().length;
81
+ if (len > 0 && (len < 70 || len > 160)) {
82
+ out.push({ level: "info", rule: "meta-description-longitud", line: 0,
83
+ message: `La meta description tiene ${len} caracteres.`,
84
+ fix: "Apunta a 150–160 caracteres." });
85
+ }
86
+ }
87
+ break;
88
+ }
89
+ if (h1count > 1) {
90
+ out.push({ level: "info", rule: "h1-multiple", line: 0,
91
+ message: `Hay ${h1count} <h1>; debe haber uno por página.`,
92
+ fix: "Deja un solo <h1>; el resto, <h2>–<h6>." });
93
+ }
94
+ let prevHeading = 0;
95
+ for (const h of code.matchAll(/<h([1-6])\b/gi)) {
96
+ const lvl = parseInt(h[1], 10);
97
+ if (prevHeading > 0 && lvl > prevHeading + 1) {
98
+ out.push({ level: "warn", rule: "salto-de-heading", line: 0,
99
+ message: `La jerarquía de headings salta de h${prevHeading} a h${lvl}.`,
100
+ fix: "No saltes niveles de heading; usa el siguiente y ajusta el tamaño con CSS si hace falta." });
101
+ }
102
+ prevHeading = lvl;
103
+ }
104
+ for (const img of code.match(/<img\b[^>]*>/gi) ?? []) {
105
+ if (!/\balt\s*=/i.test(img)) {
106
+ out.push({ level: "warn", rule: "img-sin-alt", line: 0,
107
+ message: "Una <img> no tiene alt.",
108
+ fix: 'Añade alt (cadena vacía alt="" si es decorativa).' });
109
+ }
110
+ if (!/\bwidth\s*=/i.test(img) || !/\bheight\s*=/i.test(img)) {
111
+ out.push({ level: "info", rule: "img-sin-dimensiones", line: 0,
112
+ message: "Una <img> no declara width y height.",
113
+ fix: "Añade width y height para evitar CLS." });
114
+ }
115
+ if (!/\bsrc\s*=/i.test(img) || /\bsrc\s*=\s*(["'])\s*\1/i.test(img)) {
116
+ out.push({ level: "error", rule: "img-src-vacio", line: 0,
117
+ message: "Una <img> tiene src vacío o no declara src.",
118
+ fix: "Pon una URL válida en src (un src vacío vuelve a solicitar la página actual)." });
119
+ }
120
+ }
121
+ for (const btn of code.match(/<button\b[^>]*>([\s\S]*?)<\/button>/gi) ?? []) {
122
+ const inner = btn.replace(/<button\b[^>]*>|<\/button>/gi, "");
123
+ const hasIcon = /<svg\b|<i\b/i.test(inner);
124
+ const textContent = inner.replace(/<[^>]*>/g, "").trim();
125
+ const hasLabel = /\baria-label\s*=|\baria-labelledby\s*=|\btitle\s*=/i.test(btn);
126
+ if (hasIcon && !textContent && !hasLabel) {
127
+ out.push({ level: "warn", rule: "boton-icono-sin-label", line: 0,
128
+ message: "Un <button> solo con icono no tiene texto ni aria-label.",
129
+ fix: "Añade aria-label describiendo la acción." });
130
+ }
131
+ }
132
+ for (const meta of code.match(/<meta\b[^>]*>/gi) ?? []) {
133
+ if (/name\s*=\s*["']viewport["']/i.test(meta) && /(user-scalable\s*=\s*no|maximum-scale\s*=\s*1\b)/i.test(meta)) {
134
+ out.push({ level: "warn", rule: "zoom-bloqueado", line: 0,
135
+ message: "El viewport bloquea el zoom (user-scalable=no / maximum-scale=1).",
136
+ fix: "No bloquees el zoom; es un problema de accesibilidad." });
137
+ break;
138
+ }
139
+ }
140
+ for (const input of code.match(/<input\b[^>]*>/gi) ?? []) {
141
+ const personal = /type\s*=\s*["'](email|tel|password)["']/i.test(input);
142
+ if (personal && !/\bautocomplete\s*=/i.test(input)) {
143
+ out.push({ level: "info", rule: "input-sin-autocomplete", line: 0,
144
+ message: "Un <input> de dato personal no declara autocomplete.",
145
+ fix: "Añade autocomplete apropiado (email, tel, current-password…)." });
146
+ }
147
+ }
148
+ if (/onpaste\s*=\s*["'][^"']*(?:preventDefault|return\s+false)/i.test(code)
149
+ || /["']paste["'][\s\S]{0,160}?preventDefault/i.test(code)) {
150
+ out.push({ level: "warn", rule: "paste-bloqueado", line: 0,
151
+ message: "Se bloquea el pegado (onpaste con preventDefault / return false).",
152
+ fix: "Nunca bloquees pegar: rompe gestores de contraseñas y códigos de verificación." });
153
+ }
154
+ const genericos = new Set(["click aquí", "haz clic aquí", "aquí", "click here", "read more", "leer más"]);
155
+ for (const a of code.matchAll(/<a\b[^>]*>([\s\S]*?)<\/a>/gi)) {
156
+ const texto = a[1].replace(/<[^>]*>/g, "").replace(/\s+/g, " ").trim().toLowerCase();
157
+ if (genericos.has(texto)) {
158
+ out.push({ level: "info", rule: "enlace-generico", line: 0,
159
+ message: `Enlace con texto genérico «${texto}».`,
160
+ fix: "Usa texto que describa el destino («Ver la guía de tokens», no «aquí»)." });
161
+ }
162
+ }
163
+ for (const s of code.match(/<script\b[^>]*\bsrc\s*=\s*["']https?:\/\/[^"']*["'][^>]*>/gi) ?? []) {
164
+ if (!/\bintegrity\s*=/i.test(s)) {
165
+ out.push({ level: "info", rule: "script-externo-sin-sri", line: 0,
166
+ message: "Un <script> externo no tiene integrity (SRI).",
167
+ fix: "Añade integrity (sha384) y crossorigin." });
168
+ }
169
+ }
170
+ }
171
+ if (lang === "css") {
172
+ for (const face of code.match(/@font-face\s*\{[^}]*\}/gi) ?? []) {
173
+ if (!/font-display\s*:/i.test(face)) {
174
+ out.push({ level: "info", rule: "font-display-faltante", line: 0,
175
+ message: "Un @font-face no declara font-display.",
176
+ fix: "Añade font-display: swap (u optional)." });
177
+ }
178
+ }
179
+ }
180
+ return out;
181
+ }
package/dist/server.js ADDED
@@ -0,0 +1,219 @@
1
+ import { McpServer, ResourceTemplate } from "@modelcontextprotocol/sdk/server/mcp.js";
2
+ import { z } from "zod";
3
+ import { SYSTEM_IDS } from "./systems.js";
4
+ import { resolveToken, searchTokens } from "./tokens.js";
5
+ import { listComponents, getComponentSpec } from "./doc.js";
6
+ import { validateCode } from "./validate.js";
7
+ import { generateSnippet } from "./snippet.js";
8
+ import { auditMotion } from "./motion.js";
9
+ import { checkSlop } from "./slop.js";
10
+ import { checkPreflight } from "./preflight.js";
11
+ const systemEnum = z.enum(["celeste", "nocturna"]);
12
+ function text(payload) {
13
+ const body = typeof payload === "string" ? payload : JSON.stringify(payload, null, 2);
14
+ return { content: [{ type: "text", text: body }] };
15
+ }
16
+ export function buildServer(store) {
17
+ const server = new McpServer({ name: "design-mcp", version: "0.1.0" });
18
+ server.registerTool("list_design_systems", {
19
+ description: "Lista los sistemas de diseño disponibles con su metadata (filosofía, paleta, fuentes).",
20
+ inputSchema: {},
21
+ }, async () => text(SYSTEM_IDS.map((id) => {
22
+ const m = store[id].meta;
23
+ return { id, name: m.name, mode: m.mode, philosophy: m.philosophy, palette: m.palette, fonts: m.fonts };
24
+ })));
25
+ server.registerTool("get_token", {
26
+ description: "Resuelve un alias semántico (p. ej. --accent) o un token DTCG por path (p. ej. color.primary.500) a su valor.",
27
+ inputSchema: { system: systemEnum, name: z.string() },
28
+ }, async ({ system, name }) => {
29
+ const s = store[system];
30
+ const r = resolveToken(s.aliases, s.tokenIndex, name);
31
+ return r ? text(r) : text(`No se encontró el token "${name}" en ${system}.`);
32
+ });
33
+ server.registerTool("search_tokens", {
34
+ description: "Busca tokens por substring en su path o valor.",
35
+ inputSchema: { system: systemEnum, query: z.string() },
36
+ }, async ({ system, query }) => text(searchTokens(store[system].tokens, query)));
37
+ server.registerTool("list_components", { description: "Lista los componentes documentados del sistema.", inputSchema: { system: systemEnum } }, async ({ system }) => text(listComponents(store[system].sections)));
38
+ server.registerTool("get_component_spec", {
39
+ description: "Devuelve la especificación (estados, medidas, variantes) de un componente del DESIGN.md.",
40
+ inputSchema: { system: systemEnum, name: z.string() },
41
+ }, async ({ system, name }) => {
42
+ const spec = getComponentSpec(store[system].sections, name);
43
+ return spec ? text(`## ${spec.heading}\n\n${spec.body}`) : text(`No se encontró el componente "${name}".`);
44
+ });
45
+ server.registerTool("get_rules", {
46
+ description: "Reglas duras del sistema y referencia rápida (paleta, fuentes, filosofía).",
47
+ inputSchema: { system: systemEnum },
48
+ }, async ({ system }) => {
49
+ const m = store[system].meta;
50
+ const rules = getComponentSpec(store[system].sections, "reglas");
51
+ return text(`# ${m.name}\n\n${m.philosophy}\n\nPaleta: ${m.palette.join(" · ")}\n` +
52
+ `Fuentes: ${m.fonts.display} / ${m.fonts.body} / ${m.fonts.mono}\n\n` +
53
+ (rules ? `## ${rules.heading}\n\n${rules.body}` : ""));
54
+ });
55
+ const FILE_MIME = {
56
+ "tokens.json": "application/json",
57
+ "tokens.css": "text/css",
58
+ "tailwind.css": "text/css",
59
+ "framework.css": "text/css",
60
+ "design-doc.md": "text/markdown",
61
+ "styleguide.html": "text/html",
62
+ };
63
+ server.registerResource("systems-index", "design://systems", { title: "Índice de sistemas", description: "Los sistemas disponibles y su metadata.", mimeType: "application/json" }, async (uri) => ({
64
+ contents: [
65
+ {
66
+ uri: uri.href,
67
+ mimeType: "application/json",
68
+ text: JSON.stringify(SYSTEM_IDS.map((id) => store[id].meta), null, 2),
69
+ },
70
+ ],
71
+ }));
72
+ server.registerResource("system-file", new ResourceTemplate("design://{system}/{file}", { list: undefined }), { title: "Archivo de un sistema", description: "tokens.json, tokens.css, tailwind.css, framework.css, design-doc.md o styleguide.html." }, async (uri, { system, file }) => {
73
+ const s = store[system];
74
+ const content = s?.files[file];
75
+ if (!content)
76
+ throw new Error(`Recurso no encontrado: ${uri.href}`);
77
+ return {
78
+ contents: [{ uri: uri.href, mimeType: FILE_MIME[file] ?? "text/plain", text: content }],
79
+ };
80
+ });
81
+ server.registerTool("validate_code", {
82
+ description: "Valida CSS o HTML contra las reglas duras del sistema (hex directos, primary-300 como texto, doble primario, contraste AA, drift de border-radius y font-size fuera de las escalas del sistema, tracking negativo extremo).",
83
+ inputSchema: { system: systemEnum, code: z.string(), lang: z.enum(["css", "html"]) },
84
+ }, async ({ system, code, lang }) => {
85
+ const violations = validateCode(store[system], code, lang);
86
+ return text(violations.length ? violations : "Sin violaciones detectadas.");
87
+ });
88
+ server.registerTool("validate_motion", {
89
+ description: "Audita CSS/HTML/JS contra las reglas de motion del sistema: transition:all, ease-in en entradas, animar propiedades de layout, blur, duración > el techo del sistema (--duration-page), scale(0), rebote en utilitarios, overshoot excesivo, blur/hover-scale/stagger repetidos, pulsing, hover con transform sin gating @media (hover: hover) y falta de prefers-reduced-motion.",
90
+ inputSchema: { system: systemEnum, code: z.string(), lang: z.enum(["css", "html", "js"]) },
91
+ }, async ({ system, code, lang }) => {
92
+ const s = store[system];
93
+ const page = resolveToken(s.aliases, s.tokenIndex, "--duration-page");
94
+ const maxMs = page ? (parseInt(page.value, 10) || 320) : 320;
95
+ const violations = auditMotion(code, lang, maxMs);
96
+ return text(violations.length ? violations : "Sin violaciones de motion detectadas.");
97
+ });
98
+ server.registerTool("check_slop", {
99
+ description: "Detecta clichés de diseño generado por IA en CSS/HTML: gradientes, glassmorphism, sombras de negro puro, fuentes genéricas (Inter/Roboto…), texto diminuto, interlineado apretado, texto justificado, mayúsculas/tracking en cuerpo, paletas IA, y copy (exclamaciones, emoji, jerga de marketing, Title Case, abuso de em-dash).",
100
+ inputSchema: { system: systemEnum, code: z.string(), lang: z.enum(["css", "html"]) },
101
+ }, async ({ system, code, lang }) => {
102
+ const violations = checkSlop(code, lang, store[system].meta.mode);
103
+ return text(violations.length ? violations : "Sin indicios de slop detectados.");
104
+ });
105
+ server.registerTool("checklist_preflight", {
106
+ description: "Checklist estático de pre-producción sobre HTML/CSS: lang, title, meta charset/viewport/description, canonical, un solo h1, theme-color, noindex accidental, skip link, saltos de heading, img sin alt/dimensiones/src, botón-icono sin aria-label, zoom bloqueado, inputs sin autocomplete, paste bloqueado, enlaces con texto genérico, SRI en scripts externos y font-display. Los umbrales de runtime (Core Web Vitals, Lighthouse, cabeceras HTTP) se miden con Lighthouse/PageSpeed, no aquí. `system` se acepta por simetría con validate_code/validate_motion/check_slop (para invocarlas en secuencia con los mismos argumentos), pero este checklist es agnóstico de marca y no lo usa.",
107
+ inputSchema: { system: systemEnum, code: z.string(), lang: z.enum(["css", "html"]) },
108
+ }, async ({ code, lang }) => {
109
+ const violations = checkPreflight(code, lang);
110
+ return text(violations.length ? violations : "Sin problemas de pre-producción detectados.");
111
+ });
112
+ server.registerTool("generate_snippet", {
113
+ description: "Genera un snippet de un componente usando solo aliases semánticos. React devuelve una plantilla basada en aliases, no un componente de producción.",
114
+ inputSchema: { system: systemEnum, component: z.string(), stack: z.enum(["css", "tailwind", "react"]) },
115
+ }, async ({ system, component, stack }) => text(generateSnippet(store[system], component, stack)));
116
+ server.registerPrompt("build_ui", {
117
+ title: "Construir UI con el sistema",
118
+ description: "Instrucciones para construir UI respetando el sistema de diseño.",
119
+ argsSchema: { system: systemEnum },
120
+ }, ({ system }) => {
121
+ const m = store[system].meta;
122
+ return {
123
+ messages: [
124
+ {
125
+ role: "user",
126
+ content: {
127
+ type: "text",
128
+ text: `Construye UI siguiendo el sistema "${m.name}" (${m.mode}-first). ${m.philosophy}\n` +
129
+ `Paleta: ${m.palette.join(" · ")}. Fuentes: ${m.fonts.display}/${m.fonts.body}/${m.fonts.mono}.\n` +
130
+ `Consume solo aliases semánticos (usa la tool get_token). Antes de entregar, pasa el código por validate_code(system="${system}").`,
131
+ },
132
+ },
133
+ ],
134
+ };
135
+ });
136
+ server.registerPrompt("audit_ui", {
137
+ title: "Auditar UI contra el sistema",
138
+ description: "Instrucciones para auditar código/una pantalla contra el sistema.",
139
+ argsSchema: { system: systemEnum },
140
+ }, ({ system }) => ({
141
+ messages: [
142
+ {
143
+ role: "user",
144
+ content: {
145
+ type: "text",
146
+ text: `Audita el siguiente código contra el sistema "${store[system].meta.name}". ` +
147
+ `Usa validate_code(system="${system}"), validate_motion(system="${system}"), check_slop(system="${system}") y checklist_preflight(system="${system}") y explica cada violación con su arreglo. ` +
148
+ `Verifica además jerarquía tipográfica, uso de acento (≤10%), contraste AA y los anti-patrones de motion (§11.6).`,
149
+ },
150
+ },
151
+ ],
152
+ }));
153
+ server.registerPrompt("audit_diseno", {
154
+ title: "Auditar el diseño con la rúbrica",
155
+ description: "Instrucciones para auditar una interfaz con la rúbrica de 14 dimensiones, Nielsen, carga cognitiva y 5 personas.",
156
+ argsSchema: { system: systemEnum },
157
+ }, ({ system }) => {
158
+ const m = store[system].meta;
159
+ return {
160
+ messages: [
161
+ {
162
+ role: "user",
163
+ content: {
164
+ type: "text",
165
+ text: `Audita la interfaz contra el sistema "${m.name}" (${m.mode}-first) recorriendo las 14 dimensiones ` +
166
+ `(jerarquía, espaciado, tipografía, color/contraste, grid, componentes y sus estados, iconografía, motion, ` +
167
+ `estados empty/loading/error, dark mode, densidad, responsividad, accesibilidad, consistencia). ` +
168
+ `Puntúa con Nielsen /40; evalúa la carga cognitiva (Miller ≤4, nav ≤5, jerarquías ≤3) y las 5 personas ` +
169
+ `(power-user, first-timer, accesibilidad, estrés, móvil). Usa validate_code(system="${system}"), ` +
170
+ `validate_motion(system="${system}") y check_slop(system="${system}") para lo verificable. ` +
171
+ `Presenta 2-3 alternativas con trade-offs para las decisiones abiertas y entrega la salida por fases ` +
172
+ `(Crítico → Refinamiento → Pulido), cada hallazgo con archivo:línea y el arreglo referenciando tokens.`,
173
+ },
174
+ },
175
+ ],
176
+ };
177
+ });
178
+ server.registerPrompt("recomendar_direccion", {
179
+ title: "Recomendar dirección de diseño desde un brief",
180
+ description: "Dado un brief de producto, recomienda el sistema (Celeste vs Nocturna), los valores de dial (variación/motion/densidad) y una dirección de diseño sobria.",
181
+ argsSchema: { brief: z.string() },
182
+ }, ({ brief }) => ({
183
+ messages: [
184
+ {
185
+ role: "user",
186
+ content: {
187
+ type: "text",
188
+ text: `Brief: "${brief}".\n\n` +
189
+ `Recomienda una dirección de diseño para uno de los dos sistemas de Higinio y justifícalo:\n` +
190
+ `- Elige "Celeste Clásica" (light-first, sereno, profesional) o "Nocturna Eléctrica" (dark-first, técnico, alto contraste), en una línea con el porqué.\n` +
191
+ `- Propón valores de dial acotados a la sobriedad de la marca: VARIANCE 1-6 (1=simétrico, 6=asimetría contenida), MOTION 1-5 (1=estático, 5=reveals sobrios), DENSITY por superficie (aireada en landings, densa en dashboards).\n` +
192
+ `- Da 2-3 decisiones clave (tipografía, uso del acento ≤10%, layout), dentro de las reglas duras del sistema elegido.\n\n` +
193
+ `Ancla la recomendación con list_design_systems y get_rules. No propongas gradientes, glassmorphism ni fuentes genéricas (Inter/Roboto).`,
194
+ },
195
+ },
196
+ ],
197
+ }));
198
+ server.registerPrompt("recomendar_grafica", {
199
+ title: "Recomendar gráfica y paleta desde una descripción de datos",
200
+ description: "Dada una descripción de datos, recomienda el tipo de gráfica, la paleta (categórica/secuencial/divergente/estado) y el fallback de accesibilidad.",
201
+ argsSchema: { datos: z.string() },
202
+ }, ({ datos }) => ({
203
+ messages: [
204
+ {
205
+ role: "user",
206
+ content: {
207
+ type: "text",
208
+ text: `Datos: "${datos}".\n\n` +
209
+ `Recomienda cómo visualizarlos con la skill dataviz-marca de Higinio:\n` +
210
+ `- El tipo de gráfica según la función del dato (magnitud → barras; tiempo → línea/área; parte de un todo → barra apilada; relación → scatter; un solo número → stat tile). Si no es una gráfica, dilo. Para tareas más específicas (waterfall, funnel, heatmap, treemap, box plot, bullet en vez de gauge, waffle, banda de confianza, anomalías) la tabla ampliada —con umbral de volumen, grado de accesibilidad y fallback por tipo— vive en la skill dataviz-marca (references/paletas-graficas.md); no la repitas aquí.\n` +
211
+ `- Qué paleta del sistema activo usar: categórica (identidad), secuencial (magnitud, una hue claro→oscuro), divergente (polaridad, dos hues + gris al centro) o estado (con icono + etiqueta). Usa la paleta del sistema (Celeste o Nocturna); no inventes hex.\n` +
212
+ `- El fallback de accesibilidad: leyenda + etiquetas directas, vista de tabla, y patrón/textura para daltonismo. La identidad nunca es solo por color.\n\n` +
213
+ `Nunca dual-axis, ni arcoíris, ni 3D. El texto lleva tokens de ink, no el color de la serie.`,
214
+ },
215
+ },
216
+ ],
217
+ }));
218
+ return server;
219
+ }