orbital-ds 0.7.0 → 0.8.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/AGENTS.md CHANGED
@@ -64,7 +64,7 @@ Copia `themes/qratia.css` (o `zyro.css`), cambia valores, no quites ninguno, añ
64
64
  Para que una app (o un agente que la edite) use orbital-ds sin romper nada:
65
65
 
66
66
  1. **La marca vive solo en `brand.css`.** Ninguna regla de color propia fuera de ese fichero; jamás se edita el paquete.
67
- 2. **Solo variables públicas.** Nada de variables privadas de un tema (por ejemplo `--zw-*`): se renombran sin aviso de versión mayor. Usa `--ds-*` y los tokens del contrato.
67
+ 2. **Solo variables públicas.** Nada de variables privadas de un tema: pueden renombrarse. Usa `--ds-*` y los tokens del contrato (`--zw-*` de `tactile` están obsoletas: usa `--ds-key-outline`, `--ds-key-edge` y `--ds-marker`).
68
68
  3. **Sin `!important` ni selectores propios** para vencer a la librería (lateral, raíl, botones). Si algo no se puede ajustar con un token, es un hueco de la librería: abre un issue.
69
69
  4. **El tema se elige con un solo `@import`** (o `pnpm theme`). Los temas con ámbito (`*.scoped.css`) son solo para un banco de pruebas; la marca (`brand.css`) gana siempre, también sobre ellos.
70
70
  5. **Patrón de página.** La cabecera lleva solo botón de menú, título y, siempre visibles, idioma y tema (y avisos si la app los tiene). El subtítulo y las acciones de la vista van al inicio del contenido, centrado en un ancho máximo; solo un lienzo (mapa, kanban, editor) ocupa todo el ancho.
package/README.md CHANGED
@@ -1,73 +1,87 @@
1
- # orbital-ds
2
-
3
- Sistema de diseño genérico: **estructura común + temas intercambiables + ~60 componentes**
4
- sobre [Base UI](https://base-ui.com) y Tailwind v4 (los de shadcn en su versión Base UI).
5
-
6
- - `styles.css` — la ESTRUCTURA: mapea variables de tema a utilidades de Tailwind y define los
7
- tokens semánticos (radios, alturas, sombras, espaciado, capas, movimiento, foco, materiales)
8
- y la capa base. No lleva colores ni fuentes.
9
- - `themes/<tema>.css` — un TEMA: paleta (claro/oscuro), radio base y tipografía.
10
- Hoy: `qratia` y `zyro` (cristal). Añadir otro = añadir otro fichero.
11
- - `themes/<tema>.fonts.css` — las fuentes del tema (un `@import url()`; va el primero).
12
- - `ui/*` — componentes, uno por fichero (`orbital-ds/ui/button`, …): quien no usa gráficos
13
- no necesita `recharts`, quien no usa el calendario no necesita `react-day-picker`.
14
- - `contract.json` + `pnpm check:ds` — el contrato de tema y su comprobación.
15
-
16
- ## Uso en una app (Tailwind v4)
17
-
18
- ```css
19
- @import 'orbital-ds/themes/qratia.fonts.css'; /* fuentes: lo primero */
20
- @import 'tailwindcss';
21
- @plugin '@tailwindcss/typography';
22
- @import 'tw-animate-css';
23
- @import 'orbital-ds/styles.css'; /* estructura */
24
- @import 'orbital-ds/themes/qratia.css'; /* tema (después de la estructura) */
25
- @source '../node_modules/orbital-ds/dist'; /* que Tailwind vea las clases del paquete */
26
- ```
27
-
28
- ```tsx
29
- import { Button } from 'orbital-ds/ui/button';
30
- ```
31
-
32
- Cambiar de identidad = cambiar `qratia` por `zyro` en esas dos líneas de tema/fuentes.
33
- Detalle del contrato, tokens y reglas de nombres en [`docs/CONTRACT.md`](docs/CONTRACT.md).
34
-
35
- ## Guía de cada tema
36
-
37
- Cada tema lleva su documentación, que viaja con el paquete:
38
-
39
- - `orbital-ds/themes/<tema>.guide.md` — guía de diseño: esencia, gesto firma, principios, referencias (qué se tomó de cada una), paleta, tipografía, formas, accesibilidad y estado de los valores (confirmados, aproximados o de diseño propio).
40
- - `orbital-ds/themes/<tema>.meta.json` — la misma ficha legible por máquina.
41
-
42
- `pnpm check:ds` falla si a un tema le falta alguna de las dos. Un tema nuevo debe nacer con ambas.
43
-
44
- ## Marca de una app
45
-
46
- Lo propio de cada app (color principal, acento, radio, fuentes) va en **su** `src/brand.css`,
47
- importado después del tema. Ninguna actualización de orbital-ds lo toca. `pnpm exec orbital-ds-init`
48
- lo crea la primera vez. Detalle en [`docs/CONTRACT.md`](docs/CONTRACT.md).
49
-
50
- ## Regla
51
-
52
- Ningún componente lleva valores propios (colores, medidas, sombras, z-index, duraciones):
53
- pide un hueco a una variable. Para cambiar el aspecto se cambia el tema, no el componente.
54
- Si un componente necesita un hueco nuevo, se AÑADE el token con un valor que deje el aspecto
55
- como estaba. `pnpm check:ds` lo comprueba.
56
-
57
- ## Desarrollo
58
-
59
- ```bash
60
- pnpm install
61
- pnpm verify # doctor + typecheck + build
62
- pnpm check:ds # solo el contrato de tema y los valores fijos
63
- ```
64
-
65
- Peers obligatorios: `react`, `react-dom`, `@base-ui/react`, `tailwindcss`, `tailwind-merge`,
66
- `class-variance-authority`, `clsx`, `lucide-react`, `tw-animate-css`.
67
- Opcionales (solo si usas ese componente): `recharts`, `sonner`, `next-themes`, `cmdk`,
68
- `date-fns` + `react-day-picker`, `embla-carousel-react`, `input-otp`,
69
- `react-resizable-panels`, `@shadcn/react`.
70
-
71
- ## Licencia
72
-
73
- MIT © Fractalia Systems
1
+ # orbital-ds
2
+
3
+ Sistema de diseño genérico: **estructura común + temas intercambiables + ~60 componentes**
4
+ sobre [Base UI](https://base-ui.com) y Tailwind v4 (los de shadcn en su versión Base UI).
5
+
6
+ - `styles.css` — la ESTRUCTURA: mapea variables de tema a utilidades de Tailwind y define los
7
+ tokens semánticos (radios, alturas, sombras, espaciado, capas, movimiento, foco, materiales)
8
+ y la capa base. No lleva colores ni fuentes.
9
+ - `themes/<tema>.css` — un TEMA: paleta (claro/oscuro), radio base y tipografía.
10
+ Hoy: `qratia` y `zyro` (cristal). Añadir otro = añadir otro fichero.
11
+ - `themes/<tema>.fonts.css` — las fuentes del tema (un `@import url()`; va el primero).
12
+ - `ui/*` — componentes, uno por fichero (`orbital-ds/ui/button`, …): quien no usa gráficos
13
+ no necesita `recharts`, quien no usa el calendario no necesita `react-day-picker`.
14
+ - `contract.json` + `pnpm check:ds` — el contrato de tema y su comprobación.
15
+
16
+ ## Uso en una app (Tailwind v4)
17
+
18
+ ```css
19
+ @import 'orbital-ds/themes/qratia.fonts.css'; /* fuentes: lo primero */
20
+ @import 'tailwindcss';
21
+ @plugin '@tailwindcss/typography';
22
+ @import 'tw-animate-css';
23
+ @import 'orbital-ds/styles.css'; /* estructura */
24
+ @import 'orbital-ds/themes/qratia.css'; /* tema (después de la estructura) */
25
+ @source '../node_modules/orbital-ds/dist'; /* que Tailwind vea las clases del paquete */
26
+ ```
27
+
28
+ ```tsx
29
+ import { Button } from 'orbital-ds/ui/button';
30
+ ```
31
+
32
+ Cambiar de identidad = cambiar `qratia` por `zyro` en esas dos líneas de tema/fuentes.
33
+ Detalle del contrato, tokens y reglas de nombres en [`docs/CONTRACT.md`](docs/CONTRACT.md).
34
+
35
+ ## Generar la marca desde un color (asistente)
36
+
37
+ ```
38
+ pnpm exec orbital-ds-init # asistente interactivo
39
+ pnpm exec orbital-ds-init --primary "#1D63D8" # sin preguntas (CI, scripts)
40
+ ```
41
+
42
+ A partir del **color primario** (hex, `rgb()`, `hsl()` u `oklch()`) deriva en OKLCH los neutros teñidos
43
+ (papel, tinta, tarjeta, bordes, lateral), el texto sobre el primario, el enlace y el foco, en piel clara y
44
+ oscura, y **comprueba el contraste** (AA 4,5:1). Si un color no llega, lo ajusta lo mínimo y te lo dice.
45
+ Escribe solo un bloque delimitado en `brand.css`; con `--update` lo reescribe y lo tuyo de fuera no se
46
+ toca. Opciones: `--theme`, `--accent`, `--no-marker` (apaga el rotulador de `tactile`), `--dry-run`,
47
+ `--yes`, `--out`. Sin dependencias.
48
+
49
+ ## Guía de cada tema
50
+
51
+ Cada tema lleva su documentación, que viaja con el paquete:
52
+
53
+ - `orbital-ds/themes/<tema>.guide.md` — guía de diseño: esencia, gesto firma, principios, referencias (qué se tomó de cada una), paleta, tipografía, formas, accesibilidad y estado de los valores (confirmados, aproximados o de diseño propio).
54
+ - `orbital-ds/themes/<tema>.meta.json` — la misma ficha legible por máquina.
55
+
56
+ `pnpm check:ds` falla si a un tema le falta alguna de las dos. Un tema nuevo debe nacer con ambas.
57
+
58
+ ## Marca de una app
59
+
60
+ Lo propio de cada app (color principal, acento, radio, fuentes) va en **su** `src/brand.css`,
61
+ importado después del tema. Ninguna actualización de orbital-ds lo toca. `pnpm exec orbital-ds-init`
62
+ lo crea la primera vez. Detalle en [`docs/CONTRACT.md`](docs/CONTRACT.md).
63
+
64
+ ## Regla
65
+
66
+ Ningún componente lleva valores propios (colores, medidas, sombras, z-index, duraciones):
67
+ pide un hueco a una variable. Para cambiar el aspecto se cambia el tema, no el componente.
68
+ Si un componente necesita un hueco nuevo, se AÑADE el token con un valor que deje el aspecto
69
+ como estaba. `pnpm check:ds` lo comprueba.
70
+
71
+ ## Desarrollo
72
+
73
+ ```bash
74
+ pnpm install
75
+ pnpm verify # doctor + typecheck + build
76
+ pnpm check:ds # solo el contrato de tema y los valores fijos
77
+ ```
78
+
79
+ Peers obligatorios: `react`, `react-dom`, `@base-ui/react`, `tailwindcss`, `tailwind-merge`,
80
+ `class-variance-authority`, `clsx`, `lucide-react`, `tw-animate-css`.
81
+ Opcionales (solo si usas ese componente): `recharts`, `sonner`, `next-themes`, `cmdk`,
82
+ `date-fns` + `react-day-picker`, `embla-carousel-react`, `input-otp`,
83
+ `react-resizable-panels`, `@shadcn/react`.
84
+
85
+ ## Licencia
86
+
87
+ MIT © Fractalia Systems
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "package": "orbital-ds",
3
- "version": "0.7.0",
3
+ "version": "0.8.0",
4
4
  "engine": "@base-ui/react",
5
5
  "docs": [
6
6
  "README.md",
@@ -10,7 +10,8 @@
10
10
  * - Formas: 8 · 12 · 14 · 16. Sin sombras difusas: la profundidad la dan las aristas.
11
11
  * - Titulares en Fraunces (serifa suave) y cuerpo en Figtree.
12
12
  *
13
- * Importar DESPUÉS de la estructura. Variables `--zw-*`: privadas de este tema.
13
+ * Importar DESPUÉS de la estructura. Gesto: `--ds-key-outline`, `--ds-key-edge`, `--ds-marker`.
14
+ * Las antiguas `--zw-outline|edge|mark` siguen funcionando como alternativa, pero están obsoletas.
14
15
  * Fuentes: `tactile.fonts.css`, lo primero del CSS de la app.
15
16
  */
16
17
 
@@ -138,10 +139,10 @@
138
139
  --ds-icon-tile-radius: var(--ds-radius-xl);
139
140
  --ds-icon-tile-fill: 0.2;
140
141
 
141
- /* Privadas de tactile */
142
- --zw-outline: #2a2233;
143
- --zw-edge: rgba(42, 34, 51, 0.26);
144
- --zw-mark: color-mix(in srgb, var(--primary) 62%, transparent);
142
+ /* Gesto de tactile (variables públicas; apaga el rotulador con --ds-marker: transparent) */
143
+ --ds-key-outline: var(--zw-outline, #2a2233); /* contorno de las teclas (--zw-outline está obsoleta) */
144
+ --ds-key-edge: var(--zw-edge, rgba(42, 34, 51, 0.26)); /* arista de las teclas */
145
+ --ds-marker: var(--zw-mark, color-mix(in srgb, var(--primary) 62%, transparent)); /* rotulador bajo los títulos; transparent = apagado */
145
146
  }
146
147
 
147
148
  /* ── VARIABLES · PIEL OSCURA («noche») ───────────────────────────────────── */
@@ -205,9 +206,9 @@
205
206
  --ds-shadow-overlay: 0 8px 0 rgba(0, 0, 0, 0.4);
206
207
  --ds-shadow-popover: 0 6px 0 rgba(0, 0, 0, 0.4);
207
208
 
208
- --zw-outline: rgba(244, 235, 218, 0.92);
209
- --zw-edge: rgba(244, 235, 218, 0.26);
210
- --zw-mark: color-mix(in srgb, var(--primary) 45%, transparent);
209
+ --ds-key-outline: var(--zw-outline, rgba(244, 235, 218, 0.92));
210
+ --ds-key-edge: var(--zw-edge, rgba(244, 235, 218, 0.26));
211
+ --ds-marker: var(--zw-mark, color-mix(in srgb, var(--primary) 45%, transparent));
211
212
  }
212
213
 
213
214
  /* ── PERSONALIDAD ────────────────────────────────────────────────────────────
@@ -217,7 +218,7 @@
217
218
  :root {
218
219
  /* Teclas: contorno de tinta y arista inferior; al pulsar, se hunden */
219
220
  [data-slot='button'] {
220
- border: 2px solid var(--zw-outline);
221
+ border: 2px solid var(--ds-key-outline);
221
222
  border-bottom-width: 5px;
222
223
  border-radius: 14px;
223
224
  font-weight: 700;
@@ -270,7 +271,7 @@
270
271
  /* Superficies: filete suave y una arista inferior más marcada */
271
272
  [data-slot='card'] {
272
273
  border: 2px solid var(--border);
273
- border-bottom: 5px solid var(--zw-edge);
274
+ border-bottom: 5px solid var(--ds-key-edge);
274
275
  box-shadow: none;
275
276
  }
276
277
 
@@ -303,12 +304,12 @@
303
304
  [data-slot='tabs-trigger'][data-active] {
304
305
  background: var(--primary);
305
306
  color: var(--primary-foreground);
306
- border-color: var(--zw-outline);
307
+ border-color: var(--ds-key-outline);
307
308
  }
308
309
 
309
310
  /* Interruptor, casilla y progreso, con tinta */
310
311
  [data-slot='switch'] {
311
- border: 2px solid var(--zw-outline);
312
+ border: 2px solid var(--ds-key-outline);
312
313
  border-bottom-width: 4px;
313
314
  border-radius: 12px;
314
315
  }
@@ -317,10 +318,10 @@
317
318
  }
318
319
  [data-slot='switch-thumb'] {
319
320
  border-radius: 7px;
320
- background: var(--zw-outline);
321
+ background: var(--ds-key-outline);
321
322
  }
322
323
  [data-slot='checkbox'] {
323
- border: 2px solid var(--zw-outline);
324
+ border: 2px solid var(--ds-key-outline);
324
325
  border-radius: 8px;
325
326
  }
326
327
  [data-slot='checkbox'][data-checked] {
@@ -328,7 +329,7 @@
328
329
  color: var(--primary-foreground);
329
330
  }
330
331
  [data-slot='progress'] {
331
- border: 2px solid var(--zw-outline);
332
+ border: 2px solid var(--ds-key-outline);
332
333
  border-radius: 8px;
333
334
  background: var(--background);
334
335
  }
@@ -339,19 +340,19 @@
339
340
  /* Capas flotantes: contorno de tinta y arista inferior */
340
341
  [data-slot='dialog-content'],
341
342
  [data-slot='sheet-content'] {
342
- border: 2px solid var(--zw-outline);
343
+ border: 2px solid var(--ds-key-outline);
343
344
  border-bottom-width: 6px;
344
345
  }
345
346
  [data-slot='popover-content'],
346
347
  [data-slot='dropdown-menu-content'],
347
348
  [data-slot='select-content'] {
348
- border: 2px solid var(--zw-outline);
349
+ border: 2px solid var(--ds-key-outline);
349
350
  border-bottom-width: 4px;
350
351
  }
351
352
 
352
353
  /* Avisos con tinta */
353
354
  [data-slot='alert'] {
354
- border: 2px solid var(--zw-outline);
355
+ border: 2px solid var(--ds-key-outline);
355
356
  border-radius: 14px;
356
357
  }
357
358
 
@@ -369,7 +370,7 @@
369
370
 
370
371
  /* Teclas del teclado */
371
372
  [data-slot='kbd'] {
372
- border: 2px solid var(--zw-outline);
373
+ border: 2px solid var(--ds-key-outline);
373
374
  border-bottom-width: 4px;
374
375
  border-radius: 8px;
375
376
  background: var(--card);
@@ -383,7 +384,7 @@
383
384
  header h1 {
384
385
  width: fit-content;
385
386
  max-width: 100%;
386
- background: linear-gradient(transparent 56%, var(--zw-mark) 56%);
387
+ background: linear-gradient(transparent 56%, var(--ds-marker) 56%);
387
388
  padding-inline: 0.14em;
388
389
  margin-inline: -0.14em;
389
390
  -webkit-box-decoration-break: clone;
@@ -400,7 +401,7 @@
400
401
  /* Señalada: la tecla se asoma (papel de tarjeta, contorno de tinta y arista); pulsada, se hunde */
401
402
  [data-sidebar='menu-button']:hover:not([data-active='true']) {
402
403
  background: var(--card);
403
- border-color: var(--zw-outline);
404
+ border-color: var(--ds-key-outline);
404
405
  border-bottom-width: 4px;
405
406
  }
406
407
  [data-sidebar='menu-button']:active:not([data-active='true']) {
@@ -411,7 +412,7 @@
411
412
  [data-sidebar='menu-button'][data-active='true'] {
412
413
  background: var(--primary);
413
414
  color: var(--primary-foreground);
414
- border-color: var(--zw-outline);
415
+ border-color: var(--ds-key-outline);
415
416
  border-bottom-width: 5px;
416
417
  }
417
418
  /* Tooltips: etiqueta de tinta con arista inferior, como una tecla invertida */
@@ -419,7 +420,7 @@
419
420
  background: var(--foreground);
420
421
  color: var(--background);
421
422
  border-radius: 12px;
422
- border-bottom: 4px solid var(--zw-edge);
423
+ border-bottom: 4px solid var(--ds-key-edge);
423
424
  font-weight: 700;
424
425
  }
425
426
  /* Sin flecha: un rombo de tinta tapaba la primera letra de la etiqueta */
@@ -2,15 +2,81 @@
2
2
  "name": "tactile",
3
3
  "label": "Táctil",
4
4
  "essence": "Interfaz de papel y tinta que se toca: teclas con arista y rotulador bajo los títulos.",
5
- "signature": ["tecla con arista inferior que baja al pulsar", "rotulador bajo los títulos"],
6
- "fonts": { "display": "Fraunces", "body": "Figtree", "file": "tactile.fonts.css", "distributable": true },
7
- "palette": { "light": { "background": "#F6F0E3", "foreground": "#2A2233", "primary": "#F2B33D" }, "dark": { "background": "#1D1824" } },
8
- "radii": [8, 12, 14, 16],
5
+ "signature": [
6
+ "tecla con arista inferior que baja al pulsar",
7
+ "rotulador bajo los títulos"
8
+ ],
9
+ "fonts": {
10
+ "display": "Fraunces",
11
+ "body": "Figtree",
12
+ "file": "tactile.fonts.css",
13
+ "distributable": true
14
+ },
15
+ "palette": {
16
+ "light": {
17
+ "background": "#F6F0E3",
18
+ "foreground": "#2A2233",
19
+ "primary": "#F2B33D"
20
+ },
21
+ "dark": {
22
+ "background": "#1D1824"
23
+ }
24
+ },
25
+ "radii": [
26
+ 8,
27
+ 12,
28
+ 14,
29
+ 16
30
+ ],
9
31
  "references": [
10
- { "name": "Duolingo", "tookFromIt": "arista inferior que baja al pulsar", "source": "fuentes de terceros" },
11
- { "name": "Endorsal", "tookFromIt": "rotulador bajo títulos, iconos duotono", "source": "análisis en navegador" },
12
- { "name": "SerpWatch", "tookFromIt": "lienzo no blanco", "source": "análisis en navegador" }
32
+ {
33
+ "name": "Duolingo",
34
+ "tookFromIt": "arista inferior que baja al pulsar",
35
+ "source": "fuentes de terceros"
36
+ },
37
+ {
38
+ "name": "Endorsal",
39
+ "tookFromIt": "rotulador bajo títulos, iconos duotono",
40
+ "source": "análisis en navegador"
41
+ },
42
+ {
43
+ "name": "SerpWatch",
44
+ "tookFromIt": "lienzo no blanco",
45
+ "source": "análisis en navegador"
46
+ }
47
+ ],
48
+ "status": {
49
+ "verified": [],
50
+ "approximate": [],
51
+ "designed": [
52
+ "todos los valores"
53
+ ],
54
+ "pending": [
55
+ "piel noche con captura real",
56
+ "variante índigo"
57
+ ]
58
+ },
59
+ "guide": "tactile.guide.md",
60
+ "gestures": [
61
+ {
62
+ "id": "marker",
63
+ "token": "--ds-marker",
64
+ "off": "transparent",
65
+ "about": "rotulador bajo los títulos"
66
+ },
67
+ {
68
+ "id": "key-outline",
69
+ "token": "--ds-key-outline",
70
+ "about": "contorno de las teclas"
71
+ },
72
+ {
73
+ "id": "key-edge",
74
+ "token": "--ds-key-edge",
75
+ "about": "arista inferior de las teclas"
76
+ }
13
77
  ],
14
- "status": { "verified": [], "approximate": [], "designed": ["todos los valores"], "pending": ["piel noche con captura real", "variante índigo"] },
15
- "guide": "tactile.guide.md"
78
+ "a11y": {
79
+ "level": "AA",
80
+ "exceptions": []
81
+ }
16
82
  }
@@ -10,7 +10,8 @@
10
10
  * - Formas: 8 · 12 · 14 · 16. Sin sombras difusas: la profundidad la dan las aristas.
11
11
  * - Titulares en Fraunces (serifa suave) y cuerpo en Figtree.
12
12
  *
13
- * Importar DESPUÉS de la estructura. Variables `--zw-*`: privadas de este tema.
13
+ * Importar DESPUÉS de la estructura. Gesto: `--ds-key-outline`, `--ds-key-edge`, `--ds-marker`.
14
+ * Las antiguas `--zw-outline|edge|mark` siguen funcionando como alternativa, pero están obsoletas.
14
15
  * Fuentes: `tactile.fonts.css`, lo primero del CSS de la app.
15
16
  */
16
17
 
@@ -138,10 +139,10 @@
138
139
  --ds-icon-tile-radius: var(--ds-radius-xl);
139
140
  --ds-icon-tile-fill: 0.2;
140
141
 
141
- /* Privadas de tactile */
142
- --zw-outline: #2a2233;
143
- --zw-edge: rgba(42, 34, 51, 0.26);
144
- --zw-mark: color-mix(in srgb, var(--primary) 62%, transparent);
142
+ /* Gesto de tactile (variables públicas; apaga el rotulador con --ds-marker: transparent) */
143
+ --ds-key-outline: var(--zw-outline, #2a2233); /* contorno de las teclas (--zw-outline está obsoleta) */
144
+ --ds-key-edge: var(--zw-edge, rgba(42, 34, 51, 0.26)); /* arista de las teclas */
145
+ --ds-marker: var(--zw-mark, color-mix(in srgb, var(--primary) 62%, transparent)); /* rotulador bajo los títulos; transparent = apagado */
145
146
  }
146
147
 
147
148
  /* ── VARIABLES · PIEL OSCURA («noche») ───────────────────────────────────── */
@@ -205,9 +206,9 @@
205
206
  --ds-shadow-overlay: 0 8px 0 rgba(0, 0, 0, 0.4);
206
207
  --ds-shadow-popover: 0 6px 0 rgba(0, 0, 0, 0.4);
207
208
 
208
- --zw-outline: rgba(244, 235, 218, 0.92);
209
- --zw-edge: rgba(244, 235, 218, 0.26);
210
- --zw-mark: color-mix(in srgb, var(--primary) 45%, transparent);
209
+ --ds-key-outline: var(--zw-outline, rgba(244, 235, 218, 0.92));
210
+ --ds-key-edge: var(--zw-edge, rgba(244, 235, 218, 0.26));
211
+ --ds-marker: var(--zw-mark, color-mix(in srgb, var(--primary) 45%, transparent));
211
212
  }
212
213
 
213
214
  /* ── PERSONALIDAD ────────────────────────────────────────────────────────────
@@ -217,7 +218,7 @@
217
218
  :root:where([data-theme='tactile']) {
218
219
  /* Teclas: contorno de tinta y arista inferior; al pulsar, se hunden */
219
220
  [data-slot='button'] {
220
- border: 2px solid var(--zw-outline);
221
+ border: 2px solid var(--ds-key-outline);
221
222
  border-bottom-width: 5px;
222
223
  border-radius: 14px;
223
224
  font-weight: 700;
@@ -270,7 +271,7 @@
270
271
  /* Superficies: filete suave y una arista inferior más marcada */
271
272
  [data-slot='card'] {
272
273
  border: 2px solid var(--border);
273
- border-bottom: 5px solid var(--zw-edge);
274
+ border-bottom: 5px solid var(--ds-key-edge);
274
275
  box-shadow: none;
275
276
  }
276
277
 
@@ -303,12 +304,12 @@
303
304
  [data-slot='tabs-trigger'][data-active] {
304
305
  background: var(--primary);
305
306
  color: var(--primary-foreground);
306
- border-color: var(--zw-outline);
307
+ border-color: var(--ds-key-outline);
307
308
  }
308
309
 
309
310
  /* Interruptor, casilla y progreso, con tinta */
310
311
  [data-slot='switch'] {
311
- border: 2px solid var(--zw-outline);
312
+ border: 2px solid var(--ds-key-outline);
312
313
  border-bottom-width: 4px;
313
314
  border-radius: 12px;
314
315
  }
@@ -317,10 +318,10 @@
317
318
  }
318
319
  [data-slot='switch-thumb'] {
319
320
  border-radius: 7px;
320
- background: var(--zw-outline);
321
+ background: var(--ds-key-outline);
321
322
  }
322
323
  [data-slot='checkbox'] {
323
- border: 2px solid var(--zw-outline);
324
+ border: 2px solid var(--ds-key-outline);
324
325
  border-radius: 8px;
325
326
  }
326
327
  [data-slot='checkbox'][data-checked] {
@@ -328,7 +329,7 @@
328
329
  color: var(--primary-foreground);
329
330
  }
330
331
  [data-slot='progress'] {
331
- border: 2px solid var(--zw-outline);
332
+ border: 2px solid var(--ds-key-outline);
332
333
  border-radius: 8px;
333
334
  background: var(--background);
334
335
  }
@@ -339,19 +340,19 @@
339
340
  /* Capas flotantes: contorno de tinta y arista inferior */
340
341
  [data-slot='dialog-content'],
341
342
  [data-slot='sheet-content'] {
342
- border: 2px solid var(--zw-outline);
343
+ border: 2px solid var(--ds-key-outline);
343
344
  border-bottom-width: 6px;
344
345
  }
345
346
  [data-slot='popover-content'],
346
347
  [data-slot='dropdown-menu-content'],
347
348
  [data-slot='select-content'] {
348
- border: 2px solid var(--zw-outline);
349
+ border: 2px solid var(--ds-key-outline);
349
350
  border-bottom-width: 4px;
350
351
  }
351
352
 
352
353
  /* Avisos con tinta */
353
354
  [data-slot='alert'] {
354
- border: 2px solid var(--zw-outline);
355
+ border: 2px solid var(--ds-key-outline);
355
356
  border-radius: 14px;
356
357
  }
357
358
 
@@ -369,7 +370,7 @@
369
370
 
370
371
  /* Teclas del teclado */
371
372
  [data-slot='kbd'] {
372
- border: 2px solid var(--zw-outline);
373
+ border: 2px solid var(--ds-key-outline);
373
374
  border-bottom-width: 4px;
374
375
  border-radius: 8px;
375
376
  background: var(--card);
@@ -383,7 +384,7 @@
383
384
  header h1 {
384
385
  width: fit-content;
385
386
  max-width: 100%;
386
- background: linear-gradient(transparent 56%, var(--zw-mark) 56%);
387
+ background: linear-gradient(transparent 56%, var(--ds-marker) 56%);
387
388
  padding-inline: 0.14em;
388
389
  margin-inline: -0.14em;
389
390
  -webkit-box-decoration-break: clone;
@@ -400,7 +401,7 @@
400
401
  /* Señalada: la tecla se asoma (papel de tarjeta, contorno de tinta y arista); pulsada, se hunde */
401
402
  [data-sidebar='menu-button']:hover:not([data-active='true']) {
402
403
  background: var(--card);
403
- border-color: var(--zw-outline);
404
+ border-color: var(--ds-key-outline);
404
405
  border-bottom-width: 4px;
405
406
  }
406
407
  [data-sidebar='menu-button']:active:not([data-active='true']) {
@@ -411,7 +412,7 @@
411
412
  [data-sidebar='menu-button'][data-active='true'] {
412
413
  background: var(--primary);
413
414
  color: var(--primary-foreground);
414
- border-color: var(--zw-outline);
415
+ border-color: var(--ds-key-outline);
415
416
  border-bottom-width: 5px;
416
417
  }
417
418
  /* Tooltips: etiqueta de tinta con arista inferior, como una tecla invertida */
@@ -419,7 +420,7 @@
419
420
  background: var(--foreground);
420
421
  color: var(--background);
421
422
  border-radius: 12px;
422
- border-bottom: 4px solid var(--zw-edge);
423
+ border-bottom: 4px solid var(--ds-key-edge);
423
424
  font-weight: 700;
424
425
  }
425
426
  /* Sin flecha: un rombo de tinta tapaba la primera letra de la etiqueta */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "orbital-ds",
3
- "version": "0.7.0",
3
+ "version": "0.8.0",
4
4
  "description": "Sistema de diseño genérico: estructura común + temas con personalidad (qratia, zyro) + ~60 componentes sobre Base UI y Tailwind v4, con contrato verificable y legible por agentes.",
5
5
  "keywords": [
6
6
  "design-system",
@@ -49,6 +49,14 @@
49
49
  "./contract.json": "./contract.json",
50
50
  "./components.manifest.json": "./components.manifest.json"
51
51
  },
52
+ "scripts": {
53
+ "build": "tsup && node scripts/build-css.mjs",
54
+ "dev": "tsup --watch --onSuccess \"node scripts/copy-css.mjs\"",
55
+ "typecheck": "tsc --noEmit",
56
+ "verify": "pnpm check:ds && pnpm typecheck && pnpm build",
57
+ "manifest": "node scripts/manifest.mjs",
58
+ "check:ds": "node scripts/doctor.mjs"
59
+ },
52
60
  "peerDependencies": {
53
61
  "@base-ui/react": ">=1.9",
54
62
  "class-variance-authority": ">=0.7",
@@ -130,13 +138,5 @@
130
138
  "bin": {
131
139
  "orbital-ds-doctor": "./scripts/doctor.mjs",
132
140
  "orbital-ds-init": "./scripts/init.mjs"
133
- },
134
- "scripts": {
135
- "build": "tsup && node scripts/build-css.mjs",
136
- "dev": "tsup --watch --onSuccess \"node scripts/copy-css.mjs\"",
137
- "typecheck": "tsc --noEmit",
138
- "verify": "pnpm check:ds && pnpm typecheck && pnpm build",
139
- "manifest": "node scripts/manifest.mjs",
140
- "check:ds": "node scripts/doctor.mjs"
141
141
  }
142
- }
142
+ }
File without changes
package/scripts/init.mjs CHANGED
@@ -1,89 +1,351 @@
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
- * DESIGN.md: orbital-ds NO lo crea. Si la app ya tiene uno (`./DESIGN.md` o
12
- * `--design ruta`), añade —o actualiza— solo un bloque delimitado por marcas
13
- * `<!-- orbital-ds:start -->` … `<!-- orbital-ds:end -->`; el resto no se toca.
14
- */
15
- import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
16
- import { dirname, resolve } from 'node:path';
17
- import { fileURLToPath } from 'node:url';
18
-
19
- const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..');
20
- const contract = JSON.parse(readFileSync(resolve(ROOT, 'contract.json'), 'utf8'));
21
-
22
- const outIdx = process.argv.indexOf('--out');
23
- const out = resolve(outIdx > -1 && process.argv[outIdx + 1] ? process.argv[outIdx + 1] : 'src/brand.css');
24
-
25
- const brandExiste = existsSync(out);
26
- if (brandExiste) console.log(`Ya existe ${out}: no se toca (es de tu app).`);
27
-
28
- if (!brandExiste) {
29
- const lines = contract.brand.tokens.map(
30
- (t) => ` /* --${t.name}: ${t.example}; */ /* ${t.about} */`,
31
- );
32
-
33
- const css = `/*
34
- * MARCA de la app — fichero TUYO.
35
- *
36
- * Ninguna actualización de orbital-ds lo toca. Se importa DESPUÉS del tema, así que
37
- * lo que pongas aquí gana. Si no defines nada, se aplica el valor estándar del tema.
38
- *
39
- * Estas variables son el grupo estable de «lo que una marca suele cambiar»: renombrar
40
- * o quitar una de ellas es una versión MAYOR de orbital-ds, con aviso previo.
41
- * Descomenta solo lo que necesites y cambia el valor.
42
- *
43
- * Cambia el color principal TODO junto: primary, ring, link, brand, sidebar-primary y
44
- * chart-1 deben ir a la par (y comprueba el contraste del texto sobre ellos).
45
- */
46
- :root {
47
- ${lines.join('\n')}
48
- }
49
-
50
- /* Piel oscura: solo si tu marca necesita valores distintos en oscuro. */
51
- .dark {
52
- /* --primary: ...; */
53
- }
54
- `;
55
-
56
- mkdirSync(dirname(out), { recursive: true });
57
- writeFileSync(out, css);
58
- 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`);
59
- }
60
-
61
- // ── DESIGN.md de la app: bloque adicional, nunca el fichero entero ───────────────
62
- const designIdx = process.argv.indexOf('--design');
63
- const design = resolve(designIdx > -1 && process.argv[designIdx + 1] ? process.argv[designIdx + 1] : 'DESIGN.md');
64
- if (existsSync(design)) {
65
- const INI = '<!-- orbital-ds:start -->';
66
- const FIN = '<!-- orbital-ds:end -->';
67
- const bloque = `${INI}
68
- ## Sistema de diseño (orbital-ds)
69
-
70
- Bloque gestionado por \`orbital-ds-init\`; lo de fuera de las marcas es de la app y no se toca.
71
-
72
- - Reglas de uso: \`node_modules/orbital-ds/AGENTS.md\`
73
- - Contrato de tokens: \`orbital-ds/contract.json\` · componentes: \`orbital-ds/components.manifest.json\`
74
- - Cada tema trae su guía (\`orbital-ds/themes/<tema>.guide.md\`) y su ficha (\`<tema>.meta.json\`).
75
- - La marca de la app va en \`brand.css\` (importado después del tema), nunca editando el paquete.
76
- - Comprobación: \`pnpm exec orbital-ds-doctor\`
77
- ${FIN}`;
78
- const actual = readFileSync(design, 'utf8');
79
- let nuevo;
80
- if (actual.includes(INI) && actual.includes(FIN)) {
81
- nuevo = actual.slice(0, actual.indexOf(INI)) + bloque + actual.slice(actual.indexOf(FIN) + FIN.length);
82
- } else {
83
- nuevo = `${actual.replace(/\s*$/, '')}\n\n${bloque}\n`;
84
- }
85
- if (nuevo !== actual) {
86
- writeFileSync(design, nuevo);
87
- console.log(`Actualizado el bloque de orbital-ds en ${design} (el resto no se toca).`);
88
- } else console.log(`${design}: el bloque de orbital-ds ya está al día.`);
89
- }
1
+ #!/usr/bin/env node
2
+ /**
3
+ * orbital-ds-init — asistente de MARCA de una app.
4
+ *
5
+ * pnpm exec orbital-ds-init # asistente interactivo (terminal)
6
+ * pnpm exec orbital-ds-init --primary "#1D63D8" # sin preguntas (scripts / CI)
7
+ *
8
+ * Opciones:
9
+ * --theme <tema> tema para el que se genera (por defecto: el que importa tu CSS, o qratia)
10
+ * --primary <color> color primario: hex, rgb(), hsl() u oklch()
11
+ * --accent <color> color de acento/gesto (si no, el complementario del primario)
12
+ * --no-marker apaga el rotulador del tema (si el tema lo tiene)
13
+ * --out <fichero> fichero de marca (por defecto src/brand.css)
14
+ * --update reescribe el bloque generado si ya existe
15
+ * --dry-run muestra el resultado sin escribir nada
16
+ * --wizard fuerza el asistente aunque no haya terminal interactiva
17
+ * --yes no pregunta (usa los valores dados)
18
+ * --design <ruta> DESIGN.md donde añadir el bloque de orbital-ds
19
+ *
20
+ * Qué hace: a partir del primario deriva —en OKLCH— los neutros teñidos (papel, tinta,
21
+ * tarjeta, bordes, lateral), el texto sobre el primario, el enlace, el foco, las dos
22
+ * pieles (clara y oscura) y comprueba el contraste (WCAG AA 4,5:1). Escribe SOLO un
23
+ * bloque delimitado en el fichero de marca; lo que la app tenga fuera no se toca.
24
+ *
25
+ * NUNCA sobrescribe lo que es de la app. Sin opciones y sin terminal interactiva (CI),
26
+ * se comporta como siempre: crea la plantilla de marca si falta y añade el bloque de
27
+ * DESIGN.md.
28
+ *
29
+ * Sin dependencias: el color se calcula aquí (matrices OKLab estándar).
30
+ */
31
+ import { existsSync, mkdirSync, readFileSync, writeFileSync, readdirSync } from 'node:fs';
32
+ import { dirname, join, resolve } from 'node:path';
33
+ import { fileURLToPath } from 'node:url';
34
+ import { createInterface } from 'node:readline/promises';
35
+
36
+ const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..');
37
+ const contract = JSON.parse(readFileSync(resolve(ROOT, 'contract.json'), 'utf8'));
38
+ const args = process.argv.slice(2);
39
+ const flag = (n) => args.includes(`--${n}`);
40
+ const opt = (n) => { const i = args.indexOf(`--${n}`); return i > -1 && args[i + 1] && !args[i + 1].startsWith('--') ? args[i + 1] : undefined; };
41
+
42
+ /* ═══════════════════════════════ COLOR ═══════════════════════════════════════ */
43
+ const clamp01 = (x) => Math.min(1, Math.max(0, x));
44
+ const toGamma = (x) => (x <= 0.0031308 ? 12.92 * x : 1.055 * x ** (1 / 2.4) - 0.055);
45
+ const toLinear = (x) => (x <= 0.04045 ? x / 12.92 : ((x + 0.055) / 1.055) ** 2.4);
46
+
47
+ /** OKLCH → sRGB lineal (sin recortar). */
48
+ function oklchLinear({ l, c, h }) {
49
+ const a = c * Math.cos((h * Math.PI) / 180), b = c * Math.sin((h * Math.PI) / 180);
50
+ const l_ = (l + 0.3963377774 * a + 0.2158037573 * b) ** 3;
51
+ const m_ = (l - 0.1055613458 * a - 0.0638541728 * b) ** 3;
52
+ const s_ = (l - 0.0894841775 * a - 1.291485548 * b) ** 3;
53
+ return [4.0767416621 * l_ - 3.3077115913 * m_ + 0.2309699292 * s_, -1.2684380046 * l_ + 2.6097574011 * m_ - 0.3413193965 * s_, -0.0041960863 * l_ - 0.7034186147 * m_ + 1.707614701 * s_];
54
+ }
55
+ const inGamut = (lin) => lin.every((v) => v >= -1e-4 && v <= 1 + 1e-4);
56
+
57
+ /** OKLCH → {r,g,b} 0..255, reduciendo el croma hasta caber en sRGB. */
58
+ function oklchToRgb({ l, c, h }) {
59
+ let cc = c;
60
+ if (!inGamut(oklchLinear({ l, c: cc, h }))) {
61
+ let lo = 0, hi = c;
62
+ for (let i = 0; i < 24; i++) { const mid = (lo + hi) / 2; if (inGamut(oklchLinear({ l, c: mid, h }))) lo = mid; else hi = mid; }
63
+ cc = lo;
64
+ }
65
+ const lin = oklchLinear({ l, c: cc, h }).map(clamp01);
66
+ const [r, g, b] = lin.map((v) => Math.round(255 * toGamma(v)));
67
+ return { r, g, b };
68
+ }
69
+ /** {r,g,b} 0..255 → OKLCH. */
70
+ function rgbToOklch({ r, g, b }) {
71
+ const [lr, lg, lb] = [r, g, b].map((v) => toLinear(v / 255));
72
+ const l = Math.cbrt(0.4122214708 * lr + 0.5363325363 * lg + 0.0514459929 * lb);
73
+ const m = Math.cbrt(0.2119034982 * lr + 0.6806995451 * lg + 0.1073969566 * lb);
74
+ const s = Math.cbrt(0.0883024619 * lr + 0.2817188376 * lg + 0.6299787005 * lb);
75
+ const L = 0.2104542553 * l + 0.793617785 * m - 0.0040720468 * s;
76
+ const A = 1.9779984951 * l - 2.428592205 * m + 0.4505937099 * s;
77
+ const B = 0.0259040371 * l + 0.7827717662 * m - 0.808675766 * s;
78
+ const c = Math.hypot(A, B);
79
+ let h = (Math.atan2(B, A) * 180) / Math.PI; if (h < 0) h += 360;
80
+ return { l: L, c, h: c < 1e-4 ? 0 : h };
81
+ }
82
+ const hex = ({ r, g, b }) => '#' + [r, g, b].map((v) => v.toString(16).padStart(2, '0')).join('');
83
+ const rgba = ({ r, g, b }, a) => `rgba(${r}, ${g}, ${b}, ${a})`;
84
+ const ok = (l, c, h) => oklchToRgb({ l, c, h });
85
+
86
+ function lumin({ r, g, b }) { const f = (v) => toLinear(v / 255); return 0.2126 * f(r) + 0.7152 * f(g) + 0.0722 * f(b); }
87
+ function ratio(a, b) { const [x, y] = [lumin(a), lumin(b)].sort((p, q) => q - p); return (x + 0.05) / (y + 0.05); }
88
+
89
+ /** Color CSS → {r,g,b}. Acepta #hex, rgb(), hsl() y oklch(). */
90
+ function parseColor(raw) {
91
+ const v = raw.trim().toLowerCase();
92
+ let m = v.match(/^#([0-9a-f]{3}|[0-9a-f]{6})$/);
93
+ if (m) { let h = m[1]; if (h.length === 3) h = [...h].map((c) => c + c).join(''); return { r: parseInt(h.slice(0, 2), 16), g: parseInt(h.slice(2, 4), 16), b: parseInt(h.slice(4, 6), 16) }; }
94
+ m = v.match(/^rgb\(\s*(\d+)[\s,]+(\d+)[\s,]+(\d+)\s*\)$/);
95
+ if (m) return { r: +m[1], g: +m[2], b: +m[3] };
96
+ m = v.match(/^hsl\(\s*([\d.]+)(?:deg)?[\s,]+([\d.]+)%[\s,]+([\d.]+)%\s*\)$/);
97
+ if (m) {
98
+ const h = +m[1] / 360, s = +m[2] / 100, l = +m[3] / 100;
99
+ const q = l < 0.5 ? l * (1 + s) : l + s - l * s, p = 2 * l - q;
100
+ const f = (t) => { t = (t + 1) % 1; return t < 1 / 6 ? p + (q - p) * 6 * t : t < 1 / 2 ? q : t < 2 / 3 ? p + (q - p) * (2 / 3 - t) * 6 : p; };
101
+ return { r: Math.round(255 * f(h + 1 / 3)), g: Math.round(255 * f(h)), b: Math.round(255 * f(h - 1 / 3)) };
102
+ }
103
+ m = v.match(/^oklch\(\s*([\d.]+)(%?)\s+([\d.]+)\s+([\d.]+)\s*\)$/);
104
+ if (m) return oklchToRgb({ l: m[2] ? +m[1] / 100 : +m[1], c: +m[3], h: +m[4] });
105
+ return null;
106
+ }
107
+
108
+ /* ═══════════════════════════════ DERIVACIÓN ══════════════════════════════════ */
109
+ /** Ajusta la luminosidad de `color` (OKLCH) en la dirección dada hasta que `bien(rgb)` sea cierto. */
110
+ function ajustar(color, dir, bien) {
111
+ const o = rgbToOklch(color);
112
+ for (let i = 0; i <= 80; i++) {
113
+ const c = oklchToRgb({ ...o, l: clamp01(o.l + dir * 0.01 * i) });
114
+ if (bien(c)) return c;
115
+ }
116
+ return null;
117
+ }
118
+
119
+ /**
120
+ * Deriva la marca. Devuelve { claro, oscuro, avisos } con los tokens (sin el prefijo --).
121
+ * `acento` y `marcador` solo si el tema tiene gestos.
122
+ */
123
+ function derivar(primario, { acento, marcador = true, gestos = false }) {
124
+ const avisos = [];
125
+ const P = rgbToOklch(primario);
126
+ const H = P.c < 0.02 ? 255 : P.h; // primario casi gris: neutros azulados por defecto
127
+ const tinte = Math.min(0.02, 0.008 + P.c * 0.05);
128
+
129
+ /* ── piel clara ── */
130
+ const papel = ok(0.965, tinte, H), tinta = ok(0.235, 0.05, H), blanco = { r: 255, g: 255, b: 255 };
131
+ const suave = ok(0.925, tinte + 0.008, H);
132
+ let prim = primario;
133
+ const textoSobre = (c) => (ratio(blanco, c) >= ratio(tinta, c) ? blanco : tinta);
134
+ if (ratio(textoSobre(prim), prim) < 4.5) {
135
+ const m = ajustar(prim, -1, (c) => ratio(blanco, c) >= 4.5);
136
+ if (m) { avisos.push(`primario claro ajustado a ${hex(m)} para que el texto sobre él llegue a 4.5:1 (era ${hex(prim)})`); prim = m; }
137
+ }
138
+ const primFg = textoSobre(prim);
139
+ const enlace = ajustar(prim, -1, (c) => ratio(c, papel) >= 4.5 && ratio(c, blanco) >= 4.5) ?? prim;
140
+ const enlaceHover = ajustar(enlace, -1, (c) => rgbToOklch(c).l <= rgbToOklch(enlace).l - 0.07) ?? enlace;
141
+ const brillo = oklchToRgb({ ...rgbToOklch(prim), l: Math.min(0.95, rgbToOklch(prim).l + 0.08) });
142
+
143
+ const acentoC = acento ?? ok(0.82, 0.16, (H + 180) % 360);
144
+ const claro = {
145
+ primary: hex(prim), 'primary-foreground': hex(primFg), ring: hex(prim), link: hex(enlace), 'link-hover': hex(enlaceHover),
146
+ brand: hex(prim), 'brand-bright': hex(brillo), 'sidebar-primary': hex(prim), 'sidebar-primary-foreground': hex(primFg), 'sidebar-ring': hex(prim),
147
+ 'chart-1': hex(prim), 'accent-foreground': hex(tinta),
148
+ background: hex(papel), foreground: hex(tinta), card: '#ffffff', 'card-foreground': hex(tinta), popover: '#ffffff', 'popover-foreground': hex(tinta),
149
+ secondary: hex(suave), 'secondary-foreground': hex(tinta), muted: hex(suave), 'muted-foreground': rgba(tinta, 0.72),
150
+ border: rgba(tinta, 0.14), input: hex(tinta),
151
+ sidebar: hex(suave), 'sidebar-foreground': hex(tinta), 'sidebar-accent': '#ffffff', 'sidebar-accent-foreground': hex(tinta), 'sidebar-border': rgba(tinta, 0.26),
152
+ inset: hex(suave), 'inset-foreground': hex(tinta), neutral: rgba(tinta, 0.72), 'neutral-soft': rgba(tinta, 0.08),
153
+ };
154
+ if (gestos) {
155
+ claro['ds-key-outline'] = hex(tinta); claro['ds-key-edge'] = rgba(tinta, 0.26);
156
+ claro['ds-marker'] = marcador ? rgba(acentoC, 0.62) : 'transparent';
157
+ }
158
+
159
+ /* ── piel oscura ── */
160
+ const fondoO = ok(0.19, 0.03, H), tarjetaO = ok(0.25, 0.04, H), suaveO = ok(0.22, 0.035, H), textoO = ok(0.95, 0.012, H);
161
+ let primO = oklchToRgb({ ...P, l: Math.max(P.l, 0.74), c: Math.min(P.c, 0.2) });
162
+ const primOFg = fondoO;
163
+ if (ratio(primOFg, primO) < 4.5) primO = ajustar(primO, +1, (c) => ratio(primOFg, c) >= 4.5) ?? primO;
164
+ const enlaceO = ajustar(primO, +1, (c) => ratio(c, fondoO) >= 4.5) ?? primO;
165
+ const enlaceHoverO = oklchToRgb({ ...rgbToOklch(enlaceO), l: Math.min(0.95, rgbToOklch(enlaceO).l + 0.07) });
166
+ const oscuro = {
167
+ primary: hex(primO), 'primary-foreground': hex(primOFg), ring: hex(primO), link: hex(enlaceO), 'link-hover': hex(enlaceHoverO),
168
+ brand: hex(primO), 'brand-bright': hex(enlaceO), 'sidebar-primary': hex(primO), 'sidebar-primary-foreground': hex(primOFg), 'sidebar-ring': hex(primO),
169
+ 'chart-1': hex(primO), 'accent-foreground': hex(textoO),
170
+ background: hex(fondoO), foreground: hex(textoO), card: hex(tarjetaO), 'card-foreground': hex(textoO), popover: hex(tarjetaO), 'popover-foreground': hex(textoO),
171
+ secondary: hex(suaveO), 'secondary-foreground': hex(textoO), muted: hex(suaveO), 'muted-foreground': rgba(textoO, 0.72),
172
+ border: rgba(textoO, 0.14), input: rgba(textoO, 0.92),
173
+ sidebar: hex(suaveO), 'sidebar-foreground': hex(textoO), 'sidebar-accent': hex(tarjetaO), 'sidebar-accent-foreground': hex(textoO), 'sidebar-border': rgba(textoO, 0.26),
174
+ inset: hex(suaveO), 'inset-foreground': hex(textoO), neutral: rgba(textoO, 0.72), 'neutral-soft': rgba(textoO, 0.1),
175
+ };
176
+ if (gestos) {
177
+ oscuro['ds-key-outline'] = rgba(textoO, 0.92); oscuro['ds-key-edge'] = rgba(textoO, 0.26);
178
+ oscuro['ds-marker'] = marcador ? rgba(acentoC, 0.45) : 'transparent';
179
+ }
180
+
181
+ /* ── comprobación ── */
182
+ const pares = [
183
+ ['claro', 'texto/papel', tinta, papel], ['claro', 'texto/tarjeta', tinta, blanco], ['claro', 'texto sobre primario', primFg, prim],
184
+ ['claro', 'enlace/papel', enlace, papel], ['oscuro', 'texto/fondo', textoO, fondoO], ['oscuro', 'texto sobre primario', primOFg, primO], ['oscuro', 'enlace/fondo', enlaceO, fondoO],
185
+ ].map(([piel, nombre, f, b]) => ({ piel, nombre, r: ratio(f, b) }));
186
+ return { claro, oscuro, avisos, pares, muestras: { papel, tinta, prim, suave, acentoC, fondoO, textoO, primO } };
187
+ }
188
+
189
+ /* ═══════════════════════════════ SALIDA ══════════════════════════════════════ */
190
+ const TTY_COLOR = process.stdout.isTTY && (process.env.COLORTERM === 'truecolor' || process.env.COLORTERM === '24bit' || process.env.WT_SESSION);
191
+ const muestra = (c) => (TTY_COLOR ? `\x1b[48;2;${c.r};${c.g};${c.b}m \x1b[0m ` : '');
192
+ const INI = '/* orbital-ds:brand:start */';
193
+ const FIN = '/* orbital-ds:brand:end */';
194
+
195
+ function bloqueCss(res, { tema, primario }) {
196
+ const fmt = (tokens) => Object.entries(tokens).map(([k, v]) => ` --${k}: ${v};`).join('\n');
197
+ return `${INI}
198
+ /* Generado por \`orbital-ds-init\` (tema ${tema}, primario ${primario}). Si vuelves a ejecutarlo con
199
+ * --update, solo se reescribe este bloque; lo que tengas fuera no se toca. */
200
+ :root {
201
+ ${fmt(res.claro)}
202
+ }
203
+ .dark {
204
+ ${fmt(res.oscuro)}
205
+ }
206
+ ${FIN}
207
+ `;
208
+ }
209
+
210
+ function escribirBloque(file, bloque) {
211
+ const actual = existsSync(file) ? readFileSync(file, 'utf8') : null;
212
+ if (actual === null) {
213
+ mkdirSync(dirname(file), { recursive: true });
214
+ writeFileSync(file, `/*\n * MARCA de la app — fichero TUYO. Se importa DESPUÉS del tema, así que lo que pongas aquí gana.\n */\n\n${bloque}`);
215
+ return 'creado';
216
+ }
217
+ if (actual.includes(INI) && actual.includes(FIN)) {
218
+ const nuevo = actual.slice(0, actual.indexOf(INI)) + bloque.trimEnd() + actual.slice(actual.indexOf(FIN) + FIN.length);
219
+ if (nuevo === actual) return 'igual';
220
+ writeFileSync(file, nuevo);
221
+ return 'actualizado';
222
+ }
223
+ writeFileSync(file, `${actual.replace(/\s*$/, '')}\n\n${bloque}`);
224
+ return 'añadido';
225
+ }
226
+
227
+ /* ═══════════════════════════════ TEMAS ═══════════════════════════════════════ */
228
+ const temasDir = existsSync(join(ROOT, 'src/themes')) ? join(ROOT, 'src/themes') : join(ROOT, 'dist/themes');
229
+ const temas = readdirSync(temasDir).filter((f) => f.endsWith('.meta.json')).map((f) => JSON.parse(readFileSync(join(temasDir, f), 'utf8')));
230
+ const TEMA_CSS = (() => {
231
+ for (const f of ['src/styles.css', 'app/styles.css', 'src/index.css']) {
232
+ if (existsSync(f)) { const m = readFileSync(f, 'utf8').match(/orbital-ds\/themes\/([\w-]+)\.css/); if (m) return m[1]; }
233
+ }
234
+ return 'qratia';
235
+ })();
236
+ /** Un tema tiene gestos si su ficha (`<tema>.meta.json`) los declara con variables públicas. */
237
+ const tieneGestos = (tema) => (temas.find((t) => t.name === tema)?.gestures ?? []).length > 0;
238
+
239
+ /* ═══════════════════════════════ FLUJO ═══════════════════════════════════════ */
240
+ function plantillaClasica() {
241
+ const out = resolve(opt('out') ?? 'src/brand.css');
242
+ if (existsSync(out)) console.log(`Ya existe ${out}: no se toca (es de tu app).`);
243
+ else {
244
+ const lines = contract.brand.tokens.map((t) => ` /* --${t.name}: ${t.example}; */ /* ${t.about} */`);
245
+ mkdirSync(dirname(out), { recursive: true });
246
+ writeFileSync(out, `/*\n * MARCA de la app — fichero TUYO.\n *\n * Ninguna actualización de orbital-ds lo toca. Se importa DESPUÉS del tema, así que\n * lo que pongas aquí gana. Si no defines nada, se aplica el valor estándar del tema.\n *\n * Para generar la marca completa desde un color: pnpm exec orbital-ds-init --primary "#1D63D8"\n */\n:root {\n${lines.join('\n')}\n}\n\n/* Piel oscura: solo si tu marca necesita valores distintos en oscuro. */\n.dark {\n /* --primary: ...; */\n}\n`);
247
+ 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`);
248
+ }
249
+ }
250
+
251
+ function actualizarDesign() {
252
+ const design = resolve(opt('design') ?? 'DESIGN.md');
253
+ if (!existsSync(design)) return;
254
+ const I = '<!-- orbital-ds:start -->', F = '<!-- orbital-ds:end -->';
255
+ const bloque = `${I}
256
+ ## Sistema de diseño (orbital-ds)
257
+
258
+ Bloque gestionado por \`orbital-ds-init\`; lo de fuera de las marcas es de la app y no se toca.
259
+
260
+ - Reglas de uso y estándar de integración: \`node_modules/orbital-ds/AGENTS.md\`
261
+ - Contrato de tokens: \`orbital-ds/contract.json\` · componentes: \`orbital-ds/components.manifest.json\`
262
+ - Cada tema trae su guía (\`orbital-ds/themes/<tema>.guide.md\`) y su ficha (\`<tema>.meta.json\`).
263
+ - La marca de la app va en \`brand.css\` (importado después del tema), nunca editando el paquete.
264
+ - Comprobación: \`pnpm exec orbital-ds-doctor --css src/brand.css\` (contraste AA incluido)
265
+ ${F}`;
266
+ const actual = readFileSync(design, 'utf8');
267
+ const nuevo = actual.includes(I) && actual.includes(F)
268
+ ? actual.slice(0, actual.indexOf(I)) + bloque + actual.slice(actual.indexOf(F) + F.length)
269
+ : `${actual.replace(/\s*$/, '')}\n\n${bloque}\n`;
270
+ if (nuevo !== actual) { writeFileSync(design, nuevo); console.log(`Actualizado el bloque de orbital-ds en ${design} (el resto no se toca).`); }
271
+ }
272
+
273
+ function mostrar(res, tema, primario) {
274
+ const m = res.muestras;
275
+ console.log(`\nMarca derivada de ${primario} para el tema «${tema}»\n`);
276
+ console.log(` Claro ${muestra(m.papel)}papel ${hex(m.papel)} ${muestra(m.tinta)}tinta ${hex(m.tinta)} ${muestra(m.prim)}primario ${hex(m.prim)} ${muestra(m.acentoC)}acento ${hex(m.acentoC)}`);
277
+ console.log(` Oscuro ${muestra(m.fondoO)}fondo ${hex(m.fondoO)} ${muestra(m.textoO)}texto ${hex(m.textoO)} ${muestra(m.primO)}primario ${hex(m.primO)}\n`);
278
+ console.log(' Contraste (mínimo AA 4.5:1):');
279
+ for (const p of res.pares) console.log(` ${p.r >= 7 ? '✓ AAA' : p.r >= 4.5 ? '✓ AA ' : '✗ '} ${p.r.toFixed(2).padStart(5)}:1 ${p.piel.padEnd(6)} ${p.nombre}`);
280
+ for (const a of res.avisos) console.log(` ! ${a}`);
281
+ console.log('');
282
+ }
283
+
284
+ async function asistente() {
285
+ const rl = createInterface({ input: process.stdin, terminal: false });
286
+ const lineas = rl[Symbol.asyncIterator]();
287
+ const preguntar = async (q, def) => {
288
+ process.stdout.write(def ? `${q} [${def}] ` : `${q} `);
289
+ const { value, done } = await lineas.next();
290
+ const r = (done ? '' : value).trim();
291
+ if (!process.stdin.isTTY) process.stdout.write(`${r}
292
+ `); // eco cuando las respuestas llegan por tubería
293
+ return r || def || '';
294
+ };
295
+ console.log('\norbital-ds · asistente de marca\n');
296
+ console.log('Temas disponibles:');
297
+ temas.forEach((t, i) => console.log(` ${i + 1}. ${t.name.padEnd(9)} ${t.essence}`));
298
+ const nt = await preguntar('\n¿Para qué tema? (número o nombre)', TEMA_CSS);
299
+ const tema = (temas[+nt - 1] ?? temas.find((t) => t.name === nt) ?? temas.find((t) => t.name === TEMA_CSS)).name;
300
+ let primario;
301
+ for (;;) {
302
+ const raw = await preguntar('Color primario (hex, rgb(), hsl() u oklch()):', '');
303
+ primario = raw && parseColor(raw);
304
+ if (primario) { console.log(` ${muestra(primario)}${hex(primario)}`); break; }
305
+ console.log(' No entiendo ese color. Ejemplos: #1D63D8 · rgb(29 99 216) · hsl(219 76% 48%) · oklch(0.55 0.2 260)');
306
+ }
307
+ const gestos = tieneGestos(tema);
308
+ let marcador = true, acento;
309
+ if (gestos) {
310
+ const P = rgbToOklch(primario);
311
+ const prop = [['complementario', 180], ['análogo', 30], ['triádico', 120]].map(([n, d]) => [n, ok(0.82, 0.16, (P.h + d) % 360)]);
312
+ console.log('\nAcento / color del rotulador (opcional). Propuestas:');
313
+ prop.forEach(([n, c], i) => console.log(` ${i + 1}. ${muestra(c)}${n.padEnd(15)} ${hex(c)}`));
314
+ const a = await preguntar('Elige 1-3, escribe otro color, o Enter para el complementario:', '1');
315
+ acento = prop[+a - 1]?.[1] ?? parseColor(a) ?? prop[0][1];
316
+ const r = await preguntar('¿Rotulador bajo los títulos? (s/n)', 's');
317
+ marcador = !/^n/i.test(r);
318
+ }
319
+ const res = derivar(primario, { acento, marcador, gestos });
320
+ mostrar(res, tema, hex(primario));
321
+ const out = resolve(opt('out') ?? 'src/brand.css');
322
+ const ok2 = await preguntar(`¿Escribo el bloque en ${out}? (s/n)`, 's');
323
+ rl.close();
324
+ if (/^n/i.test(ok2)) { console.log('No se ha escrito nada.'); return; }
325
+ const estado = escribirBloque(out, bloqueCss(res, { tema, primario: hex(primario) }));
326
+ console.log(`\nBloque ${estado} en ${out}. Lo que tengas fuera de las marcas no se ha tocado.`);
327
+ actualizarDesign();
328
+ }
329
+
330
+ async function directo() {
331
+ const primario = parseColor(opt('primary'));
332
+ if (!primario) { console.error('Color primario no válido. Usa hex, rgb(), hsl() u oklch().'); process.exit(1); }
333
+ const tema = opt('theme') ?? TEMA_CSS;
334
+ if (!temas.some((t) => t.name === tema)) { console.error(`El tema «${tema}» no existe. Disponibles: ${temas.map((t) => t.name).join(', ')}`); process.exit(1); }
335
+ const acento = opt('accent') ? parseColor(opt('accent')) : undefined;
336
+ const res = derivar(primario, { acento, marcador: !flag('no-marker'), gestos: tieneGestos(tema) });
337
+ mostrar(res, tema, hex(primario));
338
+ const bloque = bloqueCss(res, { tema, primario: hex(primario) });
339
+ const out = resolve(opt('out') ?? 'src/brand.css');
340
+ if (flag('dry-run')) { console.log(bloque); return; }
341
+ if (existsSync(out) && readFileSync(out, 'utf8').includes(INI) && !flag('update')) {
342
+ console.log(`Ya hay un bloque generado en ${out}. Usa --update para reescribirlo.`); return;
343
+ }
344
+ const estado = escribirBloque(out, bloque);
345
+ console.log(`Bloque ${estado} en ${out}.`);
346
+ actualizarDesign();
347
+ }
348
+
349
+ if (opt('primary')) await directo();
350
+ else if (flag('wizard') || (process.stdin.isTTY && process.stdout.isTTY && !flag('classic'))) await asistente();
351
+ else { plantillaClasica(); actualizarDesign(); }