orbital-ds 0.5.0 → 0.6.2

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.
@@ -1,224 +1,301 @@
1
- #!/usr/bin/env node
2
- /**
3
- * orbital-ds doctor — comprueba que el sistema se cumple.
4
- *
5
- * 1. CONTRATO: cada tema (themes/*.css) define en :root todos los tokens de
6
- * contract.json y la fuente `--ds-font-sans`. En .dark solo avisa.
7
- * 2. REPETIDOS: ninguna variable se define dos veces en el mismo bloque.
8
- * 3. SOMBRAS: «sin sombra» es `0 0 #0000`, nunca `none` (una variable con
9
- * `none` invalida todo el box-shadow, anillo de foco incluido).
10
- * 4. FUGAS: el tema base (contract.baseTheme) está aplicado siempre; todo tema
11
- * con ámbito debe definir cada `--ds-*` que defina el base, o su valor se
12
- * colaría al cambiar de tema.
13
- * 5. VALORES FIJOS: ningún componente (ui/*.tsx) escribe colores, z-index,
14
- * duraciones ni sombras a mano; todo sale de tokens.
15
- * 6. API PÚBLICA: no desaparece ningún componente ni export respecto a
16
- * components.manifest.json (huella de la última versión). Lo nuevo solo avisa.
17
- *
18
- * Uso:
19
- * node scripts/doctor.mjs # temas y componentes del paquete
20
- * node scripts/doctor.mjs --css app.css # además, un CSS de una app (repetidos)
21
- *
22
- * Sale con código 1 si hay errores (los avisos no fallan).
23
- */
24
- import { readFileSync, readdirSync, existsSync } from 'node:fs';
25
- import { join, dirname, resolve } from 'node:path';
26
- import { fileURLToPath } from 'node:url';
27
-
28
- const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..');
29
- const contract = JSON.parse(readFileSync(join(ROOT, 'contract.json'), 'utf8'));
30
-
31
- const errors = [];
32
- const warnings = [];
33
- const read = (p) => readFileSync(p, 'utf8');
34
- const stripComments = (css) => css.replace(/\/\*[\s\S]*?\*\//g, '');
35
-
36
- /** Cuerpo de cada bloque `selector { ... }` (respeta llaves anidadas). */
37
- function blocks(css, selectorRe) {
38
- const out = [];
39
- const re = new RegExp(selectorRe.source + '\\s*\\{', 'g');
40
- let m;
41
- while ((m = re.exec(css))) {
42
- let depth = 1;
43
- let i = re.lastIndex;
44
- const start = i;
45
- while (i < css.length && depth > 0) {
46
- if (css[i] === '{') depth++;
47
- else if (css[i] === '}') depth--;
48
- i++;
49
- }
50
- out.push(css.slice(start, i - 1));
51
- re.lastIndex = i;
52
- }
53
- return out;
54
- }
55
-
56
- /** Declaraciones `--x: valor;` del nivel superior de un bloque (no las anidadas). */
57
- function declarations(body) {
58
- let depth = 0;
59
- let top = '';
60
- for (const ch of body) {
61
- if (ch === '{') depth++;
62
- else if (ch === '}') depth--;
63
- else if (depth === 0) top += ch;
64
- }
65
- return [...top.matchAll(/(^|[;\s])(--[a-z0-9-]+)\s*:\s*([^;]*);?/gi)].map((m) => ({ name: m[2], value: m[3].trim() }));
66
- }
67
-
68
- /** Variables --ds-* que la estructura del paquete define (las que una app puede usar). */
69
- function knownDsVars() {
70
- const f = existsSync(join(ROOT, 'src/styles.css')) ? join(ROOT, 'src/styles.css') : join(ROOT, 'dist/styles.css');
71
- return new Set([...read(f).matchAll(/(--ds-[a-z0-9-]+)\s*:/g)].map((m) => m[1]));
72
- }
73
-
74
- // El grupo de marca debe ser coherente con el contrato: cada variable, o es del contrato, o es --ds-*.
75
- for (const t of contract.brand?.tokens ?? []) {
76
- const inContract =
77
- contract.root.includes(t.name) ||
78
- contract.fonts.includes(t.name) ||
79
- (t.name.startsWith('ds-') && knownDsVars().has(`--${t.name}`));
80
- if (!inContract) errors.push(`contrato: la variable de marca --${t.name} ni es del contrato ni la define la estructura`);
81
- }
82
-
83
- const themeInfo = {}; // nombre → { rootVars:Set, darkVars:Set }
84
-
85
- function checkTheme(file, name) {
86
- const label = `tema ${name}`;
87
- const css = stripComments(read(file));
88
- const roots = blocks(css, /(?:^|[\s}])\:root/);
89
- const darks = blocks(css, /(?:^|[\s}])\.dark/);
90
-
91
- const rootDecl = roots.flatMap(declarations);
92
- const darkDecl = darks.flatMap(declarations);
93
- const rootVars = new Set(rootDecl.map((d) => d.name));
94
- const darkVars = new Set(darkDecl.map((d) => d.name));
95
- themeInfo[name] = { rootVars, darkVars };
96
-
97
- for (const t of contract.root) {
98
- if (!rootVars.has(`--${t}`)) errors.push(`${label}: falta el token obligatorio --${t} en :root`);
99
- }
100
- for (const f of contract.fonts) {
101
- if (!rootVars.has(`--${f}`)) errors.push(`${label}: falta la fuente --${f} en :root`);
102
- }
103
- if (darks.length) {
104
- for (const t of contract.dark.avisar_si_falta) {
105
- if (!darkVars.has(`--${t}`)) warnings.push(`${label}: .dark no redefine --${t} (hereda el valor claro)`);
106
- }
107
- } else {
108
- warnings.push(`${label}: no define piel .dark`);
109
- }
110
-
111
- // Repetidos (solo declaraciones del nivel superior de cada bloque)
112
- for (const [kind, list] of [[':root', roots], ['.dark', darks]]) {
113
- list.forEach((body, idx) => {
114
- const seen = new Map();
115
- for (const d of declarations(body)) seen.set(d.name, (seen.get(d.name) ?? 0) + 1);
116
- for (const [v, n] of seen) {
117
- if (n > 1) errors.push(`${label}: ${v} definido ${n} veces en el mismo bloque ${kind} (#${idx + 1})`);
118
- }
119
- });
120
- }
121
-
122
- // Sombras: `none` invalida el box-shadow
123
- for (const d of [...rootDecl, ...darkDecl]) {
124
- if (/^--ds-(?:shadow|sidebar-shadow)/.test(d.name) && /^none$/i.test(d.value)) {
125
- errors.push(`${label}: ${d.name}: none — usa «0 0 #0000» (none invalida todo el box-shadow)`);
126
- }
127
- }
128
- }
129
-
130
- // 1+2+3) temas
131
- const themesDir = join(ROOT, existsSync(join(ROOT, 'src/themes')) ? 'src/themes' : 'dist/themes');
132
- const themeFiles = readdirSync(themesDir).filter(
133
- (n) => n.endsWith('.css') && !n.endsWith('.fonts.css') && !n.endsWith('.scoped.css'),
134
- );
135
- for (const f of themeFiles) checkTheme(join(themesDir, f), f.replace(/\.css$/, ''));
136
-
137
- // 4) fugas desde el tema base
138
- const base = contract.baseTheme;
139
- if (base && themeInfo[base]) {
140
- const baseDs = [...themeInfo[base].rootVars].filter((v) => v.startsWith('--ds-'));
141
- for (const [name, info] of Object.entries(themeInfo)) {
142
- if (name === base) continue;
143
- for (const v of baseDs) {
144
- if (!info.rootVars.has(v)) {
145
- errors.push(`tema ${name}: no define ${v}, que sí define el tema base «${base}» — su valor se colaría al cambiar de tema`);
146
- }
147
- }
148
- }
149
- }
150
-
151
- // CSS de una app (opcional)
152
- const cssArgIdx = process.argv.indexOf('--css');
153
- if (cssArgIdx > -1 && process.argv[cssArgIdx + 1]) {
154
- const css = stripComments(read(resolve(process.argv[cssArgIdx + 1])));
155
- blocks(css, /(?:^|[\s}])\:root/).forEach((body, idx) => {
156
- const seen = new Map();
157
- for (const d of declarations(body)) seen.set(d.name, (seen.get(d.name) ?? 0) + 1);
158
- for (const [v, n] of seen) if (n > 1) errors.push(`app css: ${v} definido ${n} veces en :root (#${idx + 1})`);
159
- // Una variable --ds-* que el paquete ya no tiene: la marca la ignoraría en silencio.
160
- const known = knownDsVars();
161
- for (const d of declarations(body)) {
162
- if (d.name.startsWith('--ds-') && !known.has(d.name)) {
163
- errors.push(`app css: ${d.name} no existe en orbital-ds (¿renombrada o retirada? mira docs/CONTRACT.md)`);
164
- }
165
- }
166
- });
167
- }
168
-
169
- // 5) valores fijos en componentes
170
- const NO_HARDCODE = [
171
- { re: /#[0-9a-fA-F]{3,8}\b/g, why: 'color hexadecimal', skip: ['chart.tsx'] },
172
- { re: /\b(?:rgb|rgba|hsl|hsla|oklch)\((?!from_)/g, why: 'color literal' },
173
- { re: /(?<![\w-])z-(?!0(?![\w-]))\d+(?![\w-])/g, why: 'z-index numérico (usa --ds-z-*)' },
174
- { re: /(?<![\w-])duration-(?!0(?![\w-]))\d+(?![\w-])/g, why: 'duración fija (usa --ds-motion-*)' },
175
- { re: /(?<![\w-])(?:bg|text|border)-(?:black|white)(?:\/\d+)?(?![\w-])/g, why: 'color fijo (usa un token de color)' },
176
- { re: /(?<![\w-])shadow-(?:xs|sm|md|lg|xl|2xl)(?![\w-])/g, why: 'sombra de la escala Tailwind (usa shadow-card/control/raised/overlay/popover)' },
177
- { re: /(?<![\w-])text-\[\d+px\]/g, why: 'tamaño de texto arbitrario (usa la escala estándar)' },
178
- { re: /(?<![\w-])rounded-(?:3xl|4xl)(?![\w-])/g, why: 'radio fuera de la rampa (usa rounded-control/overlay o la rampa sm…2xl)' },
179
- { re: /(?<![\w-])(?:bg|text|border|ring|fill|stroke)-(?:red|orange|amber|yellow|lime|green|emerald|teal|cyan|sky|blue|indigo|violet|purple|fuchsia|pink|rose|slate|gray|zinc|neutral|stone)-\d{2,3}(?![\w-])/g, why: 'color de la paleta Tailwind (usa tokens del tema)' },
180
- ];
181
-
182
- const uiDir = join(ROOT, 'src/ui');
183
- if (existsSync(uiDir)) {
184
- for (const f of readdirSync(uiDir).filter((n) => n.endsWith('.tsx'))) {
185
- const lines = read(join(uiDir, f)).split(/\r?\n/);
186
- lines.forEach((line, i) => {
187
- const code = line.replace(/\/\/.*$/, '');
188
- for (const rule of NO_HARDCODE) {
189
- if (rule.skip?.includes(f)) continue;
190
- const hits = code.match(rule.re);
191
- if (hits) errors.push(`ui/${f}:${i + 1}: ${rule.why} → ${[...new Set(hits)].join(', ')}`);
192
- }
193
- });
194
- }
195
- }
196
-
197
- // 6) API pública frente a la huella commiteada
198
- const manifestPath = join(ROOT, 'components.manifest.json');
199
- if (existsSync(manifestPath) && existsSync(uiDir)) {
200
- const { buildManifest } = await import('./manifest.mjs');
201
- const before = JSON.parse(read(manifestPath)).components;
202
- const now = buildManifest().components;
203
- for (const [name, comp] of Object.entries(before)) {
204
- if (!now[name]) {
205
- errors.push(`API pública: desaparece el componente «${name}» (¿versión mayor? regenera con pnpm manifest)`);
206
- continue;
207
- }
208
- for (const e of comp.exports) {
209
- if (!now[name].exports.includes(e)) {
210
- errors.push(`API pública: «${name}» ya no exporta ${e} (¿versión mayor? regenera con pnpm manifest)`);
211
- }
212
- }
213
- }
214
- const added = Object.keys(now).filter((n) => !before[n]);
215
- if (added.length) warnings.push(`API pública: componentes nuevos sin registrar en el manifiesto (${added.join(', ')}); ejecuta pnpm manifest`);
216
- }
217
-
218
- for (const w of warnings) console.log(`AVISO ${w}`);
219
- for (const e of errors) console.log(`ERROR ${e}`);
220
- if (errors.length) {
221
- console.log(`\ndoctor: ${errors.length} error(es), ${warnings.length} aviso(s)`);
222
- process.exit(1);
223
- }
224
- console.log(`doctor: contrato, repetidos, sombras, fugas, valores fijos y API pública correctos (${warnings.length} aviso(s)).`);
1
+ #!/usr/bin/env node
2
+ /**
3
+ * orbital-ds doctor — comprueba que el sistema se cumple.
4
+ *
5
+ * 1. CONTRATO: cada tema (themes/*.css) define en :root todos los tokens de
6
+ * contract.json y la fuente `--ds-font-sans`. En .dark solo avisa.
7
+ * 2. REPETIDOS: ninguna variable se define dos veces en el mismo bloque.
8
+ * 3. SOMBRAS: «sin sombra» es `0 0 #0000`, nunca `none` (una variable con
9
+ * `none` invalida todo el box-shadow, anillo de foco incluido).
10
+ * 4. FUGAS: el tema base (contract.baseTheme) está aplicado siempre; todo tema
11
+ * con ámbito debe definir cada `--ds-*` que defina el base, o su valor se
12
+ * colaría al cambiar de tema.
13
+ * 5. VALORES FIJOS: ningún componente (ui/*.tsx) escribe colores, z-index,
14
+ * duraciones ni sombras a mano; todo sale de tokens.
15
+ * 6. API PÚBLICA: no desaparece ningún componente ni export respecto a
16
+ * components.manifest.json (huella de la última versión). Lo nuevo solo avisa.
17
+ *
18
+ * Uso:
19
+ * node scripts/doctor.mjs # temas y componentes del paquete
20
+ * node scripts/doctor.mjs --css app.css # además, un CSS de una app (repetidos)
21
+ *
22
+ * Sale con código 1 si hay errores (los avisos no fallan).
23
+ */
24
+ import { readFileSync, readdirSync, existsSync } from 'node:fs';
25
+ import { join, dirname, resolve } from 'node:path';
26
+ import { fileURLToPath } from 'node:url';
27
+
28
+ const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..');
29
+ const contract = JSON.parse(readFileSync(join(ROOT, 'contract.json'), 'utf8'));
30
+
31
+ const errors = [];
32
+ const warnings = [];
33
+ const read = (p) => readFileSync(p, 'utf8');
34
+ const stripComments = (css) => css.replace(/\/\*[\s\S]*?\*\//g, '');
35
+
36
+ /** Cuerpo de cada bloque `selector { ... }` (respeta llaves anidadas). */
37
+ function blocks(css, selectorRe) {
38
+ const out = [];
39
+ const re = new RegExp(selectorRe.source + '\\s*\\{', 'g');
40
+ let m;
41
+ while ((m = re.exec(css))) {
42
+ let depth = 1;
43
+ let i = re.lastIndex;
44
+ const start = i;
45
+ while (i < css.length && depth > 0) {
46
+ if (css[i] === '{') depth++;
47
+ else if (css[i] === '}') depth--;
48
+ i++;
49
+ }
50
+ out.push(css.slice(start, i - 1));
51
+ re.lastIndex = i;
52
+ }
53
+ return out;
54
+ }
55
+
56
+ /** Declaraciones `--x: valor;` del nivel superior de un bloque (no las anidadas). */
57
+ function declarations(body) {
58
+ let depth = 0;
59
+ let top = '';
60
+ for (const ch of body) {
61
+ if (ch === '{') depth++;
62
+ else if (ch === '}') depth--;
63
+ else if (depth === 0) top += ch;
64
+ }
65
+ return [...top.matchAll(/(^|[;\s])(--[a-z0-9-]+)\s*:\s*([^;]*);?/gi)].map((m) => ({ name: m[2], value: m[3].trim() }));
66
+ }
67
+
68
+ /** Variables --ds-* que la estructura del paquete define (las que una app puede usar). */
69
+ function knownDsVars() {
70
+ const f = existsSync(join(ROOT, 'src/styles.css')) ? join(ROOT, 'src/styles.css') : join(ROOT, 'dist/styles.css');
71
+ return new Set([...read(f).matchAll(/(--ds-[a-z0-9-]+)\s*:/g)].map((m) => m[1]));
72
+ }
73
+
74
+ // El grupo de marca debe ser coherente con el contrato: cada variable, o es del contrato, o es --ds-*.
75
+ for (const t of contract.brand?.tokens ?? []) {
76
+ const inContract =
77
+ contract.root.includes(t.name) ||
78
+ contract.fonts.includes(t.name) ||
79
+ (t.name.startsWith('ds-') && knownDsVars().has(`--${t.name}`));
80
+ if (!inContract) errors.push(`contrato: la variable de marca --${t.name} ni es del contrato ni la define la estructura`);
81
+ }
82
+
83
+ const themeInfo = {}; // nombre → { rootVars:Set, darkVars:Set }
84
+
85
+ function checkTheme(file, name) {
86
+ const label = `tema ${name}`;
87
+ const css = stripComments(read(file));
88
+ const roots = blocks(css, /(?:^|[\s}])\:root/);
89
+ const darks = blocks(css, /(?:^|[\s}])\.dark/);
90
+
91
+ const rootDecl = roots.flatMap(declarations);
92
+ const darkDecl = darks.flatMap(declarations);
93
+ const rootVars = new Set(rootDecl.map((d) => d.name));
94
+ const darkVars = new Set(darkDecl.map((d) => d.name));
95
+ themeInfo[name] = { rootVars, darkVars };
96
+
97
+ for (const t of contract.root) {
98
+ if (!rootVars.has(`--${t}`)) errors.push(`${label}: falta el token obligatorio --${t} en :root`);
99
+ }
100
+ for (const f of contract.fonts) {
101
+ if (!rootVars.has(`--${f}`)) errors.push(`${label}: falta la fuente --${f} en :root`);
102
+ }
103
+ if (darks.length) {
104
+ for (const t of contract.dark.avisar_si_falta) {
105
+ if (!darkVars.has(`--${t}`)) warnings.push(`${label}: .dark no redefine --${t} (hereda el valor claro)`);
106
+ }
107
+ } else {
108
+ warnings.push(`${label}: no define piel .dark`);
109
+ }
110
+
111
+ // Repetidos (solo declaraciones del nivel superior de cada bloque)
112
+ for (const [kind, list] of [[':root', roots], ['.dark', darks]]) {
113
+ list.forEach((body, idx) => {
114
+ const seen = new Map();
115
+ for (const d of declarations(body)) seen.set(d.name, (seen.get(d.name) ?? 0) + 1);
116
+ for (const [v, n] of seen) {
117
+ if (n > 1) errors.push(`${label}: ${v} definido ${n} veces en el mismo bloque ${kind} (#${idx + 1})`);
118
+ }
119
+ });
120
+ }
121
+
122
+ // Sombras: `none` invalida el box-shadow
123
+ for (const d of [...rootDecl, ...darkDecl]) {
124
+ if (/^--ds-(?:shadow|sidebar-shadow)/.test(d.name) && /^none$/i.test(d.value)) {
125
+ errors.push(`${label}: ${d.name}: none — usa «0 0 #0000» (none invalida todo el box-shadow)`);
126
+ }
127
+ }
128
+ }
129
+
130
+ // 1+2+3) temas
131
+ const themesDir = join(ROOT, existsSync(join(ROOT, 'src/themes')) ? 'src/themes' : 'dist/themes');
132
+ const themeFiles = readdirSync(themesDir).filter(
133
+ (n) => n.endsWith('.css') && !n.endsWith('.fonts.css') && !n.endsWith('.scoped.css'),
134
+ );
135
+ for (const f of themeFiles) checkTheme(join(themesDir, f), f.replace(/\.css$/, ''));
136
+
137
+ // 1b) documentación de cada tema: guía de diseño (.guide.md) + ficha legible por máquina (.meta.json)
138
+ const META_CLAVES = ['name', 'label', 'essence', 'signature', 'fonts', 'palette', 'references', 'status', 'guide'];
139
+ for (const f of themeFiles) {
140
+ const t = f.replace(/\.css$/, '');
141
+ if (!existsSync(join(themesDir, `${t}.guide.md`))) errors.push(`tema ${t}: falta ${t}.guide.md (guía de diseño)`);
142
+ const metaPath = join(themesDir, `${t}.meta.json`);
143
+ if (!existsSync(metaPath)) { errors.push(`tema ${t}: falta ${t}.meta.json (ficha del tema)`); continue; }
144
+ let meta;
145
+ try { meta = JSON.parse(read(metaPath)); } catch { errors.push(`tema ${t}: ${t}.meta.json no es JSON válido`); continue; }
146
+ for (const k of META_CLAVES) if (!(k in meta)) errors.push(`tema ${t}: ${t}.meta.json no define «${k}»`);
147
+ if (meta.name !== t) errors.push(`tema ${t}: ${t}.meta.json tiene name «${meta.name}»`);
148
+ }
149
+
150
+ // 1c) CONTRASTE de los textos de estado: info, danger, warning, success y neutral
151
+ // sobre su fondo suave (compuesto sobre el lienzo), mínimo WCAG AA 4.5:1, en claro y oscuro.
152
+ // No incluye `link` ni `primary-foreground`: dependen de colores de marca fijados por la
153
+ // identidad (p. ej. el azul de Twitter), y se documentan como limitación en la guía del tema.
154
+ {
155
+ const parseColor = (raw) => {
156
+ const v = raw.replace(/\s+/g, ' ').trim();
157
+ let m = v.match(/^#([0-9a-f]{3,8})$/i);
158
+ if (m) {
159
+ let h = m[1];
160
+ if (h.length === 3) h = [...h].map((c) => c + c).join('');
161
+ return { r: parseInt(h.slice(0, 2), 16), g: parseInt(h.slice(2, 4), 16), b: parseInt(h.slice(4, 6), 16), a: h.length === 8 ? parseInt(h.slice(6, 8), 16) / 255 : 1 };
162
+ }
163
+ m = v.match(/^rgba?\(([^)]+)\)$/i);
164
+ if (m) { const p = m[1].split(/[ ,/]+/).filter(Boolean).map(Number); return { r: p[0], g: p[1], b: p[2], a: p[3] ?? 1 }; }
165
+ m = v.match(/^oklch\(\s*([\d.]+%?)\s+([\d.]+)\s+([\d.]+)(?:\s*\/\s*([\d.]+%?))?\s*\)$/i);
166
+ if (m) {
167
+ const L = m[1].endsWith('%') ? parseFloat(m[1]) / 100 : +m[1];
168
+ const C = +m[2], H = (+m[3] * Math.PI) / 180;
169
+ const a = C * Math.cos(H), b = C * Math.sin(H);
170
+ const l = (L + 0.3963377774 * a + 0.2158037573 * b) ** 3;
171
+ const mm = (L - 0.1055613458 * a - 0.0638541728 * b) ** 3;
172
+ const s = (L - 0.0894841775 * a - 1.291485548 * b) ** 3;
173
+ const lin = [4.0767416621 * l - 3.3077115913 * mm + 0.2309699292 * s, -1.2684380046 * l + 2.6097574011 * mm - 0.3413193965 * s, -0.0041960863 * l - 0.7034186147 * mm + 1.707614701 * s];
174
+ const g = (x) => Math.round(255 * Math.min(1, Math.max(0, x <= 0.0031308 ? 12.92 * x : 1.055 * x ** (1 / 2.4) - 0.055)));
175
+ const al = m[4] ? (m[4].endsWith('%') ? parseFloat(m[4]) / 100 : +m[4]) : 1;
176
+ return { r: g(lin[0]), g: g(lin[1]), b: g(lin[2]), a: al };
177
+ }
178
+ return null;
179
+ };
180
+ const lum = ({ r, g, b }) => {
181
+ const f = (c) => { c /= 255; return c <= 0.03928 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4; };
182
+ return 0.2126 * f(r) + 0.7152 * f(g) + 0.0722 * f(b);
183
+ };
184
+ const over = (fg, bg) => ({ r: fg.r * fg.a + bg.r * (1 - fg.a), g: fg.g * fg.a + bg.g * (1 - fg.a), b: fg.b * fg.a + bg.b * (1 - fg.a), a: 1 });
185
+ const ratio = (x, y) => { const [p, q] = [lum(x), lum(y)].sort((u, w) => w - u); return (p + 0.05) / (q + 0.05); };
186
+ const varsDe = (css, selector) => {
187
+ const m = new RegExp(`(^|\\n)${selector}\\s*\\{`).exec(css);
188
+ if (!m) return {};
189
+ let i = m.index + m[0].length, d = 1, j = i;
190
+ while (d && j < css.length) { if (css[j] === '{') d++; else if (css[j] === '}') d--; j++; }
191
+ const out = {};
192
+ for (const mm of css.slice(i, j - 1).matchAll(/--([\w-]+):\s*([^;]+);/g)) out[mm[1]] = mm[2];
193
+ return out;
194
+ };
195
+ for (const f of themeFiles) {
196
+ const tema = f.replace(/\.css$/, '');
197
+ const css = stripComments(read(join(themesDir, f)));
198
+ const claro = varsDe(css, ':root');
199
+ for (const [piel, vars] of [['claro', claro], ['oscuro', { ...claro, ...varsDe(css, '\\.dark') }]]) {
200
+ const lienzo = parseColor(vars.background ?? '#fff');
201
+ if (!lienzo) continue;
202
+ for (const estado of ['info', 'danger', 'warning', 'success', 'neutral']) {
203
+ const fg = vars[estado] && parseColor(vars[estado]);
204
+ const suave = vars[`${estado}-soft`] && parseColor(vars[`${estado}-soft`]);
205
+ if (!fg || !suave) continue;
206
+ const fondo = over(suave, over(lienzo, { r: 255, g: 255, b: 255, a: 1 }));
207
+ const r = ratio(over(fg, fondo), fondo);
208
+ if (r < 4.5) errors.push(`tema ${tema} (${piel}): ${estado} sobre ${estado}-soft = ${r.toFixed(2)}:1 (mínimo 4.5:1)`);
209
+ }
210
+ }
211
+ }
212
+ }
213
+
214
+ // 4) fugas desde el tema base
215
+ const base = contract.baseTheme;
216
+ if (base && themeInfo[base]) {
217
+ const baseDs = [...themeInfo[base].rootVars].filter((v) => v.startsWith('--ds-'));
218
+ for (const [name, info] of Object.entries(themeInfo)) {
219
+ if (name === base) continue;
220
+ for (const v of baseDs) {
221
+ if (!info.rootVars.has(v)) {
222
+ errors.push(`tema ${name}: no define ${v}, que sí define el tema base «${base}» — su valor se colaría al cambiar de tema`);
223
+ }
224
+ }
225
+ }
226
+ }
227
+
228
+ // CSS de una app (opcional)
229
+ const cssArgIdx = process.argv.indexOf('--css');
230
+ if (cssArgIdx > -1 && process.argv[cssArgIdx + 1]) {
231
+ const css = stripComments(read(resolve(process.argv[cssArgIdx + 1])));
232
+ blocks(css, /(?:^|[\s}])\:root/).forEach((body, idx) => {
233
+ const seen = new Map();
234
+ for (const d of declarations(body)) seen.set(d.name, (seen.get(d.name) ?? 0) + 1);
235
+ for (const [v, n] of seen) if (n > 1) errors.push(`app css: ${v} definido ${n} veces en :root (#${idx + 1})`);
236
+ // Una variable --ds-* que el paquete ya no tiene: la marca la ignoraría en silencio.
237
+ const known = knownDsVars();
238
+ for (const d of declarations(body)) {
239
+ if (d.name.startsWith('--ds-') && !known.has(d.name)) {
240
+ errors.push(`app css: ${d.name} no existe en orbital-ds (¿renombrada o retirada? mira docs/CONTRACT.md)`);
241
+ }
242
+ }
243
+ });
244
+ }
245
+
246
+ // 5) valores fijos en componentes
247
+ const NO_HARDCODE = [
248
+ { re: /#[0-9a-fA-F]{3,8}\b/g, why: 'color hexadecimal', skip: ['chart.tsx'] },
249
+ { re: /\b(?:rgb|rgba|hsl|hsla|oklch)\((?!from_)/g, why: 'color literal' },
250
+ { re: /(?<![\w-])z-(?!0(?![\w-]))\d+(?![\w-])/g, why: 'z-index numérico (usa --ds-z-*)' },
251
+ { re: /(?<![\w-])duration-(?!0(?![\w-]))\d+(?![\w-])/g, why: 'duración fija (usa --ds-motion-*)' },
252
+ { re: /(?<![\w-])(?:bg|text|border)-(?:black|white)(?:\/\d+)?(?![\w-])/g, why: 'color fijo (usa un token de color)' },
253
+ { re: /(?<![\w-])shadow-(?:xs|sm|md|lg|xl|2xl)(?![\w-])/g, why: 'sombra de la escala Tailwind (usa shadow-card/control/raised/overlay/popover)' },
254
+ { re: /(?<![\w-])text-\[\d+px\]/g, why: 'tamaño de texto arbitrario (usa la escala estándar)' },
255
+ { re: /(?<![\w-])rounded-(?:3xl|4xl)(?![\w-])/g, why: 'radio fuera de la rampa (usa rounded-control/overlay o la rampa sm…2xl)' },
256
+ { re: /(?<![\w-])(?:bg|text|border|ring|fill|stroke)-(?:red|orange|amber|yellow|lime|green|emerald|teal|cyan|sky|blue|indigo|violet|purple|fuchsia|pink|rose|slate|gray|zinc|neutral|stone)-\d{2,3}(?![\w-])/g, why: 'color de la paleta Tailwind (usa tokens del tema)' },
257
+ ];
258
+
259
+ const uiDir = join(ROOT, 'src/ui');
260
+ if (existsSync(uiDir)) {
261
+ for (const f of readdirSync(uiDir).filter((n) => n.endsWith('.tsx'))) {
262
+ const lines = read(join(uiDir, f)).split(/\r?\n/);
263
+ lines.forEach((line, i) => {
264
+ const code = line.replace(/\/\/.*$/, '');
265
+ for (const rule of NO_HARDCODE) {
266
+ if (rule.skip?.includes(f)) continue;
267
+ const hits = code.match(rule.re);
268
+ if (hits) errors.push(`ui/${f}:${i + 1}: ${rule.why} → ${[...new Set(hits)].join(', ')}`);
269
+ }
270
+ });
271
+ }
272
+ }
273
+
274
+ // 6) API pública frente a la huella commiteada
275
+ const manifestPath = join(ROOT, 'components.manifest.json');
276
+ if (existsSync(manifestPath) && existsSync(uiDir)) {
277
+ const { buildManifest } = await import('./manifest.mjs');
278
+ const before = JSON.parse(read(manifestPath)).components;
279
+ const now = buildManifest().components;
280
+ for (const [name, comp] of Object.entries(before)) {
281
+ if (!now[name]) {
282
+ errors.push(`API pública: desaparece el componente «${name}» (¿versión mayor? regenera con pnpm manifest)`);
283
+ continue;
284
+ }
285
+ for (const e of comp.exports) {
286
+ if (!now[name].exports.includes(e)) {
287
+ errors.push(`API pública: «${name}» ya no exporta ${e} (¿versión mayor? regenera con pnpm manifest)`);
288
+ }
289
+ }
290
+ }
291
+ const added = Object.keys(now).filter((n) => !before[n]);
292
+ if (added.length) warnings.push(`API pública: componentes nuevos sin registrar en el manifiesto (${added.join(', ')}); ejecuta pnpm manifest`);
293
+ }
294
+
295
+ for (const w of warnings) console.log(`AVISO ${w}`);
296
+ for (const e of errors) console.log(`ERROR ${e}`);
297
+ if (errors.length) {
298
+ console.log(`\ndoctor: ${errors.length} error(es), ${warnings.length} aviso(s)`);
299
+ process.exit(1);
300
+ }
301
+ console.log(`doctor: contrato, repetidos, sombras, fugas, valores fijos y API pública correctos (${warnings.length} aviso(s)).`);