orbital-ds 0.2.0 → 0.4.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,52 @@ 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
+ /** 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
+ }
50
82
 
51
- function checkTheme(file, label) {
83
+ const themeInfo = {}; // nombre → { rootVars:Set, darkVars:Set }
84
+
85
+ function checkTheme(file, name) {
86
+ const label = `tema ${name}`;
52
87
  const css = stripComments(read(file));
53
88
  const roots = blocks(css, /(?:^|[\s}])\:root/);
54
89
  const darks = blocks(css, /(?:^|[\s}])\.dark/);
55
- const themeBlocks = blocks(css, /@theme\s+inline/);
56
90
 
57
- const rootVars = new Set(roots.flatMap(definedVars));
58
- const themeVars = new Set(themeBlocks.flatMap(definedVars));
59
- const darkVars = new Set(darks.flatMap(definedVars));
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 };
60
96
 
61
97
  for (const t of contract.root) {
62
98
  if (!rootVars.has(`--${t}`)) errors.push(`${label}: falta el token obligatorio --${t} en :root`);
63
99
  }
64
100
  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
- }
101
+ if (!rootVars.has(`--${f}`)) errors.push(`${label}: falta la fuente --${f} en :root`);
68
102
  }
69
103
  if (darks.length) {
70
104
  for (const t of contract.dark.avisar_si_falta) {
@@ -74,38 +108,65 @@ function checkTheme(file, label) {
74
108
  warnings.push(`${label}: no define piel .dark`);
75
109
  }
76
110
 
77
- // Repetidos dentro de un mismo bloque
78
- for (const [name, list] of [['@theme', themeBlocks], [':root', roots], ['.dark', darks]]) {
111
+ // Repetidos (solo declaraciones del nivel superior de cada bloque)
112
+ for (const [kind, list] of [[':root', roots], ['.dark', darks]]) {
79
113
  list.forEach((body, idx) => {
80
114
  const seen = new Map();
81
- for (const v of definedVars(body)) seen.set(v, (seen.get(v) ?? 0) + 1);
115
+ for (const d of declarations(body)) seen.set(d.name, (seen.get(d.name) ?? 0) + 1);
82
116
  for (const [v, n] of seen) {
83
- if (n > 1) errors.push(`${label}: ${v} definido ${n} veces en el mismo bloque ${name} (#${idx + 1})`);
117
+ if (n > 1) errors.push(`${label}: ${v} definido ${n} veces en el mismo bloque ${kind} (#${idx + 1})`);
84
118
  }
85
119
  });
86
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
+ }
87
128
  }
88
129
 
89
- // 1+2) temas del paquete
130
+ // 1+2+3) temas
90
131
  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}`);
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
+ }
93
149
  }
94
150
 
95
151
  // CSS de una app (opcional)
96
152
  const cssArgIdx = process.argv.indexOf('--css');
97
153
  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) => {
154
+ const css = stripComments(read(resolve(process.argv[cssArgIdx + 1])));
155
+ blocks(css, /(?:^|[\s}])\:root/).forEach((body, idx) => {
102
156
  const seen = new Map();
103
- for (const v of definedVars(body)) seen.set(v, (seen.get(v) ?? 0) + 1);
157
+ for (const d of declarations(body)) seen.set(d.name, (seen.get(d.name) ?? 0) + 1);
104
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
+ }
105
166
  });
106
167
  }
107
168
 
108
- // 3) valores fijos en componentes
169
+ // 5) valores fijos en componentes
109
170
  const NO_HARDCODE = [
110
171
  { re: /#[0-9a-fA-F]{3,8}\b/g, why: 'color hexadecimal', skip: ['chart.tsx'] },
111
172
  { re: /\b(?:rgb|rgba|hsl|hsla|oklch)\((?!from_)/g, why: 'color literal' },
@@ -133,10 +194,31 @@ if (existsSync(uiDir)) {
133
194
  }
134
195
  }
135
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
+
136
218
  for (const w of warnings) console.log(`AVISO ${w}`);
137
219
  for (const e of errors) console.log(`ERROR ${e}`);
138
220
  if (errors.length) {
139
221
  console.log(`\ndoctor: ${errors.length} error(es), ${warnings.length} aviso(s)`);
140
222
  process.exit(1);
141
223
  }
142
- console.log(`doctor: contrato, repetidos y valores fijos correctos (${warnings.length} aviso(s)).`);
224
+ console.log(`doctor: contrato, repetidos, sombras, fugas, valores fijos y API pública correctos (${warnings.length} aviso(s)).`);
@@ -0,0 +1,55 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * orbital-ds-init — crea el fichero de MARCA de una app.
4
+ *
5
+ * pnpm exec orbital-ds-init # crea src/brand.css si no existe
6
+ * pnpm exec orbital-ds-init --out app/brand.css
7
+ *
8
+ * NUNCA sobrescribe un fichero existente. Ese fichero es de la app: ninguna
9
+ * actualización de orbital-ds lo toca, y se importa DESPUÉS del tema para que gane.
10
+ */
11
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
12
+ import { dirname, resolve } from 'node:path';
13
+ import { fileURLToPath } from 'node:url';
14
+
15
+ const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..');
16
+ const contract = JSON.parse(readFileSync(resolve(ROOT, 'contract.json'), 'utf8'));
17
+
18
+ const outIdx = process.argv.indexOf('--out');
19
+ const out = resolve(outIdx > -1 && process.argv[outIdx + 1] ? process.argv[outIdx + 1] : 'src/brand.css');
20
+
21
+ if (existsSync(out)) {
22
+ console.log(`Ya existe ${out}: no se toca (es de tu app).`);
23
+ process.exit(0);
24
+ }
25
+
26
+ const lines = contract.brand.tokens.map(
27
+ (t) => ` /* --${t.name}: ${t.example}; */ /* ${t.about} */`,
28
+ );
29
+
30
+ const css = `/*
31
+ * MARCA de la app — fichero TUYO.
32
+ *
33
+ * Ninguna actualización de orbital-ds lo toca. Se importa DESPUÉS del tema, así que
34
+ * lo que pongas aquí gana. Si no defines nada, se aplica el valor estándar del tema.
35
+ *
36
+ * Estas variables son el grupo estable de «lo que una marca suele cambiar»: renombrar
37
+ * o quitar una de ellas es una versión MAYOR de orbital-ds, con aviso previo.
38
+ * Descomenta solo lo que necesites y cambia el valor.
39
+ *
40
+ * Cambia el color principal TODO junto: primary, ring, link, brand, sidebar-primary y
41
+ * chart-1 deben ir a la par (y comprueba el contraste del texto sobre ellos).
42
+ */
43
+ :root {
44
+ ${lines.join('\n')}
45
+ }
46
+
47
+ /* Piel oscura: solo si tu marca necesita valores distintos en oscuro. */
48
+ .dark {
49
+ /* --primary: ...; */
50
+ }
51
+ `;
52
+
53
+ mkdirSync(dirname(out), { recursive: true });
54
+ writeFileSync(out, css);
55
+ console.log(`Creado ${out}.\n\nImpórtalo DESPUÉS del tema, en el CSS de tu app:\n\n @import 'orbital-ds/themes/<tema>.css';\n @import './brand.css';\n`);
@@ -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
+ }