orbital-ds 0.2.0 → 0.3.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.
@@ -3,14 +3,21 @@
3
3
  * orbital-ds doctor — comprueba que el sistema se cumple.
4
4
  *
5
5
  * 1. CONTRATO: cada tema (themes/*.css) define en :root todos los tokens de
6
- * contract.json (y la fuente `--font-sans`). En .dark solo avisa.
7
- * 2. REPETIDOS: ninguna variable CSS se define dos veces en el mismo bloque.
8
- * 3. VALORES FIJOS: ningún componente (ui/*.tsx) escribe colores, z-index,
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,
9
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.
10
17
  *
11
18
  * Uso:
12
19
  * node scripts/doctor.mjs # temas y componentes del paquete
13
- * node scripts/doctor.mjs --css app.css # además, un CSS de una app (contrato + repetidos)
20
+ * node scripts/doctor.mjs --css app.css # además, un CSS de una app (repetidos)
14
21
  *
15
22
  * Sale con código 1 si hay errores (los avisos no fallan).
16
23
  */
@@ -26,7 +33,7 @@ const warnings = [];
26
33
  const read = (p) => readFileSync(p, 'utf8');
27
34
  const stripComments = (css) => css.replace(/\/\*[\s\S]*?\*\//g, '');
28
35
 
29
- /** Devuelve el cuerpo de cada bloque `selector { ... }` (respeta llaves anidadas). */
36
+ /** Cuerpo de cada bloque `selector { ... }` (respeta llaves anidadas). */
30
37
  function blocks(css, selectorRe) {
31
38
  const out = [];
32
39
  const re = new RegExp(selectorRe.source + '\\s*\\{', 'g');
@@ -46,25 +53,37 @@ function blocks(css, selectorRe) {
46
53
  return out;
47
54
  }
48
55
 
49
- const definedVars = (body) => [...body.matchAll(/(^|[;{\s])(--[a-z0-9-]+)\s*:/gi)].map((m) => m[2]);
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
+ const themeInfo = {}; // nombre → { rootVars:Set, darkVars:Set }
50
69
 
51
- function checkTheme(file, label) {
70
+ function checkTheme(file, name) {
71
+ const label = `tema ${name}`;
52
72
  const css = stripComments(read(file));
53
73
  const roots = blocks(css, /(?:^|[\s}])\:root/);
54
74
  const darks = blocks(css, /(?:^|[\s}])\.dark/);
55
- const themeBlocks = blocks(css, /@theme\s+inline/);
56
75
 
57
- const rootVars = new Set(roots.flatMap(definedVars));
58
- const themeVars = new Set(themeBlocks.flatMap(definedVars));
59
- const darkVars = new Set(darks.flatMap(definedVars));
76
+ const rootDecl = roots.flatMap(declarations);
77
+ const darkDecl = darks.flatMap(declarations);
78
+ const rootVars = new Set(rootDecl.map((d) => d.name));
79
+ const darkVars = new Set(darkDecl.map((d) => d.name));
80
+ themeInfo[name] = { rootVars, darkVars };
60
81
 
61
82
  for (const t of contract.root) {
62
83
  if (!rootVars.has(`--${t}`)) errors.push(`${label}: falta el token obligatorio --${t} en :root`);
63
84
  }
64
85
  for (const f of contract.fonts) {
65
- if (!rootVars.has(`--${f}`) && !themeVars.has(`--${f}`)) {
66
- errors.push(`${label}: falta la fuente --${f} (en :root o @theme)`);
67
- }
86
+ if (!rootVars.has(`--${f}`)) errors.push(`${label}: falta la fuente --${f} en :root`);
68
87
  }
69
88
  if (darks.length) {
70
89
  for (const t of contract.dark.avisar_si_falta) {
@@ -74,38 +93,58 @@ function checkTheme(file, label) {
74
93
  warnings.push(`${label}: no define piel .dark`);
75
94
  }
76
95
 
77
- // Repetidos dentro de un mismo bloque
78
- for (const [name, list] of [['@theme', themeBlocks], [':root', roots], ['.dark', darks]]) {
96
+ // Repetidos (solo declaraciones del nivel superior de cada bloque)
97
+ for (const [kind, list] of [[':root', roots], ['.dark', darks]]) {
79
98
  list.forEach((body, idx) => {
80
99
  const seen = new Map();
81
- for (const v of definedVars(body)) seen.set(v, (seen.get(v) ?? 0) + 1);
100
+ for (const d of declarations(body)) seen.set(d.name, (seen.get(d.name) ?? 0) + 1);
82
101
  for (const [v, n] of seen) {
83
- if (n > 1) errors.push(`${label}: ${v} definido ${n} veces en el mismo bloque ${name} (#${idx + 1})`);
102
+ if (n > 1) errors.push(`${label}: ${v} definido ${n} veces en el mismo bloque ${kind} (#${idx + 1})`);
84
103
  }
85
104
  });
86
105
  }
106
+
107
+ // Sombras: `none` invalida el box-shadow
108
+ for (const d of [...rootDecl, ...darkDecl]) {
109
+ if (/^--ds-(?:shadow|sidebar-shadow)/.test(d.name) && /^none$/i.test(d.value)) {
110
+ errors.push(`${label}: ${d.name}: none — usa «0 0 #0000» (none invalida todo el box-shadow)`);
111
+ }
112
+ }
87
113
  }
88
114
 
89
- // 1+2) temas del paquete
115
+ // 1+2+3) temas
90
116
  const themesDir = join(ROOT, existsSync(join(ROOT, 'src/themes')) ? 'src/themes' : 'dist/themes');
91
- for (const f of readdirSync(themesDir).filter((n) => n.endsWith('.css'))) {
92
- checkTheme(join(themesDir, f), `tema ${f}`);
117
+ const themeFiles = readdirSync(themesDir).filter(
118
+ (n) => n.endsWith('.css') && !n.endsWith('.fonts.css') && !n.endsWith('.scoped.css'),
119
+ );
120
+ for (const f of themeFiles) checkTheme(join(themesDir, f), f.replace(/\.css$/, ''));
121
+
122
+ // 4) fugas desde el tema base
123
+ const base = contract.baseTheme;
124
+ if (base && themeInfo[base]) {
125
+ const baseDs = [...themeInfo[base].rootVars].filter((v) => v.startsWith('--ds-'));
126
+ for (const [name, info] of Object.entries(themeInfo)) {
127
+ if (name === base) continue;
128
+ for (const v of baseDs) {
129
+ if (!info.rootVars.has(v)) {
130
+ errors.push(`tema ${name}: no define ${v}, que sí define el tema base «${base}» — su valor se colaría al cambiar de tema`);
131
+ }
132
+ }
133
+ }
93
134
  }
94
135
 
95
136
  // CSS de una app (opcional)
96
137
  const cssArgIdx = process.argv.indexOf('--css');
97
138
  if (cssArgIdx > -1 && process.argv[cssArgIdx + 1]) {
98
- const f = resolve(process.argv[cssArgIdx + 1]);
99
- const css = stripComments(read(f));
100
- const roots = blocks(css, /(?:^|[\s}])\:root/);
101
- roots.forEach((body, idx) => {
139
+ const css = stripComments(read(resolve(process.argv[cssArgIdx + 1])));
140
+ blocks(css, /(?:^|[\s}])\:root/).forEach((body, idx) => {
102
141
  const seen = new Map();
103
- for (const v of definedVars(body)) seen.set(v, (seen.get(v) ?? 0) + 1);
142
+ for (const d of declarations(body)) seen.set(d.name, (seen.get(d.name) ?? 0) + 1);
104
143
  for (const [v, n] of seen) if (n > 1) errors.push(`app css: ${v} definido ${n} veces en :root (#${idx + 1})`);
105
144
  });
106
145
  }
107
146
 
108
- // 3) valores fijos en componentes
147
+ // 5) valores fijos en componentes
109
148
  const NO_HARDCODE = [
110
149
  { re: /#[0-9a-fA-F]{3,8}\b/g, why: 'color hexadecimal', skip: ['chart.tsx'] },
111
150
  { re: /\b(?:rgb|rgba|hsl|hsla|oklch)\((?!from_)/g, why: 'color literal' },
@@ -133,10 +172,31 @@ if (existsSync(uiDir)) {
133
172
  }
134
173
  }
135
174
 
175
+ // 6) API pública frente a la huella commiteada
176
+ const manifestPath = join(ROOT, 'components.manifest.json');
177
+ if (existsSync(manifestPath) && existsSync(uiDir)) {
178
+ const { buildManifest } = await import('./manifest.mjs');
179
+ const before = JSON.parse(read(manifestPath)).components;
180
+ const now = buildManifest().components;
181
+ for (const [name, comp] of Object.entries(before)) {
182
+ if (!now[name]) {
183
+ errors.push(`API pública: desaparece el componente «${name}» (¿versión mayor? regenera con pnpm manifest)`);
184
+ continue;
185
+ }
186
+ for (const e of comp.exports) {
187
+ if (!now[name].exports.includes(e)) {
188
+ errors.push(`API pública: «${name}» ya no exporta ${e} (¿versión mayor? regenera con pnpm manifest)`);
189
+ }
190
+ }
191
+ }
192
+ const added = Object.keys(now).filter((n) => !before[n]);
193
+ if (added.length) warnings.push(`API pública: componentes nuevos sin registrar en el manifiesto (${added.join(', ')}); ejecuta pnpm manifest`);
194
+ }
195
+
136
196
  for (const w of warnings) console.log(`AVISO ${w}`);
137
197
  for (const e of errors) console.log(`ERROR ${e}`);
138
198
  if (errors.length) {
139
199
  console.log(`\ndoctor: ${errors.length} error(es), ${warnings.length} aviso(s)`);
140
200
  process.exit(1);
141
201
  }
142
- console.log(`doctor: contrato, repetidos y valores fijos correctos (${warnings.length} aviso(s)).`);
202
+ console.log(`doctor: contrato, repetidos, sombras, fugas, valores fijos y API pública correctos (${warnings.length} aviso(s)).`);
@@ -0,0 +1,79 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Manifiesto de componentes de orbital-ds (`components.manifest.json`).
4
+ *
5
+ * Lo leen los agentes de IA (qué hay, cómo se importa, qué `data-slot` expone,
6
+ * qué dependencias opcionales pide) y `pnpm check:ds` lo usa como HUELLA de la API
7
+ * pública: si desaparece un componente o un export respecto al manifiesto
8
+ * commiteado, doctor falla salvo que se haga una versión mayor.
9
+ *
10
+ * node scripts/manifest.mjs # regenera components.manifest.json
11
+ */
12
+ import { readFileSync, readdirSync, writeFileSync, existsSync } from 'node:fs';
13
+ import { join, dirname, resolve } from 'node:path';
14
+ import { fileURLToPath } from 'node:url';
15
+
16
+ const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..');
17
+ const UI = join(ROOT, 'src/ui');
18
+
19
+ // Dependencias que SIEMPRE están (peers obligatorios): no se listan como opcionales.
20
+ const REQUIRED = new Set([
21
+ 'react', 'react-dom', 'class-variance-authority', 'clsx', 'tailwind-merge', 'lucide-react',
22
+ ]);
23
+
24
+ function parseExports(src) {
25
+ const names = new Set();
26
+ for (const m of src.matchAll(/export\s*\{([^}]*)\}/g)) {
27
+ for (const part of m[1].split(',')) {
28
+ const n = part.trim().split(/\s+as\s+/i).pop()?.trim();
29
+ if (n) names.add(n);
30
+ }
31
+ }
32
+ for (const m of src.matchAll(/export\s+(?:async\s+)?(?:function|const|class)\s+([A-Za-z0-9_]+)/g)) names.add(m[1]);
33
+ return [...names].sort();
34
+ }
35
+
36
+ function parseSlots(src) {
37
+ return [...new Set([...src.matchAll(/data-slot="([a-z0-9-]+)"/g)].map((m) => m[1]))].sort();
38
+ }
39
+
40
+ function parseOptionalPeers(src) {
41
+ const peers = new Set();
42
+ for (const m of src.matchAll(/from\s+"([^"]+)"/g)) {
43
+ const spec = m[1];
44
+ if (spec.startsWith('.')) continue;
45
+ const pkg = spec.startsWith('@') ? spec.split('/').slice(0, 2).join('/') : spec.split('/')[0];
46
+ if (pkg === '@base-ui/react' || REQUIRED.has(pkg)) continue;
47
+ peers.add(pkg);
48
+ }
49
+ return [...peers].sort();
50
+ }
51
+
52
+ export function buildManifest() {
53
+ const components = {};
54
+ for (const f of readdirSync(UI).filter((n) => n.endsWith('.tsx')).sort()) {
55
+ const src = readFileSync(join(UI, f), 'utf8');
56
+ const name = f.replace(/\.tsx$/, '');
57
+ components[name] = {
58
+ import: `orbital-ds/ui/${name}`,
59
+ exports: parseExports(src),
60
+ slots: parseSlots(src),
61
+ optionalPeers: parseOptionalPeers(src),
62
+ };
63
+ }
64
+ const pkg = JSON.parse(readFileSync(join(ROOT, 'package.json'), 'utf8'));
65
+ return {
66
+ package: pkg.name,
67
+ version: pkg.version,
68
+ engine: '@base-ui/react',
69
+ docs: ['README.md', 'docs/CONTRACT.md', 'AGENTS.md', 'contract.json'],
70
+ components,
71
+ };
72
+ }
73
+
74
+ if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
75
+ const out = join(ROOT, 'components.manifest.json');
76
+ const manifest = buildManifest();
77
+ writeFileSync(out, JSON.stringify(manifest, null, 2) + '\n');
78
+ console.log(`manifest: ${Object.keys(manifest.components).length} componentes → components.manifest.json`);
79
+ }