@axzydev/axzy_ui_system 1.7.3 → 1.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.
Files changed (82) hide show
  1. package/dist/index.cjs +1845 -721
  2. package/dist/index.cjs.map +1 -1
  3. package/dist/index.css +1 -1
  4. package/dist/index.d.cts +386 -15
  5. package/dist/index.d.ts +386 -15
  6. package/dist/index.js +1532 -418
  7. package/dist/index.js.map +1 -1
  8. package/dist/layered.css +1 -1
  9. package/package.json +4 -2
  10. package/src/App.tsx +6 -8
  11. package/src/components/atoms/button/button.tsx +7 -0
  12. package/src/components/atoms/flex/flex.props.ts +3 -1
  13. package/src/components/atoms/flex/flex.tsx +11 -3
  14. package/src/components/atoms/input/input.tsx +5 -0
  15. package/src/components/atoms/segmented-control/segmented-control.tsx +22 -5
  16. package/src/components/atoms/textarea/textarea.props.ts +4 -0
  17. package/src/components/atoms/textarea/textarea.tsx +13 -3
  18. package/src/components/molecules/card/card.props.ts +14 -0
  19. package/src/components/molecules/card/card.tsx +66 -23
  20. package/src/components/molecules/chip-input/chip-input.tsx +2 -0
  21. package/src/components/molecules/line-bar-chart/line-bar-chart.props.ts +4 -0
  22. package/src/components/molecules/line-bar-chart/line-bar-chart.tsx +57 -41
  23. package/src/components/molecules/multi-select/multi-select.tsx +2 -0
  24. package/src/components/molecules/tabs/tabs.tsx +22 -8
  25. package/src/components/organisms/dropfile/dropfile.props.ts +8 -2
  26. package/src/components/organisms/dropfile/dropfile.tsx +17 -0
  27. package/src/components/organisms/page-header/page-header.tsx +22 -6
  28. package/src/components/organisms/sidebar/sidebar.tsx +8 -5
  29. package/src/components/organisms/topbar/topbar.props.ts +2 -0
  30. package/src/components/organisms/topbar/topbar.tsx +56 -34
  31. package/src/components/templates/layout/layout.tsx +3 -2
  32. package/src/components/theme-provider/themeProvider.props.ts +1 -1
  33. package/src/components/theme-provider/themeProvider.tsx +54 -1
  34. package/src/i18n/locales/en.ts +7 -0
  35. package/src/i18n/locales/es.ts +7 -0
  36. package/src/index.ts +35 -7
  37. package/src/json-renderer/actions/effects.test.tsx +115 -0
  38. package/src/json-renderer/actions/effects.ts +291 -0
  39. package/src/json-renderer/actions/effectsChain.test.ts +117 -0
  40. package/src/json-renderer/binding/binding.test.tsx +168 -0
  41. package/src/json-renderer/binding/resolveValue.ts +193 -0
  42. package/src/json-renderer/catalog/axzyCatalog.ts +5 -0
  43. package/src/json-renderer/estado-acciones.test.tsx +166 -0
  44. package/src/json-renderer/form/FormNode.tsx +92 -0
  45. package/src/json-renderer/form/form.test.tsx +148 -0
  46. package/src/json-renderer/form/formLength.test.tsx +99 -0
  47. package/src/json-renderer/form/rules.ts +162 -0
  48. package/src/json-renderer/index.ts +21 -0
  49. package/src/json-renderer/kernel/SchemaRenderer.tsx +83 -7
  50. package/src/json-renderer/kernel/capabilities.ts +30 -0
  51. package/src/json-renderer/kernel/renderNode.tsx +256 -3
  52. package/src/json-renderer/kernel/schemaRenderer.props.ts +29 -3
  53. package/src/json-renderer/kernel/slots.test.tsx +104 -0
  54. package/src/json-renderer/kernel/slots.ts +34 -0
  55. package/src/json-renderer/kernel/types.ts +4 -0
  56. package/src/json-renderer/routing/match.ts +82 -0
  57. package/src/json-renderer/routing/navigation.ts +71 -0
  58. package/src/json-renderer/routing/routes.ts +179 -0
  59. package/src/json-renderer/routing/routing.test.ts +147 -0
  60. package/src/json-renderer/seguridad.test.tsx +299 -0
  61. package/src/json-renderer/shared/errors.ts +1 -0
  62. package/src/json-renderer/stream/tolerantParse.ts +5 -0
  63. package/src/json-renderer/stream/useSchemaStream.test.tsx +143 -0
  64. package/src/json-renderer/stream/useSchemaStream.ts +227 -0
  65. package/src/json-renderer/validation/jsonSchema.test.ts +105 -0
  66. package/src/json-renderer/validation/jsonSchema.ts +317 -0
  67. package/src/json-renderer/validation/validate.ts +17 -0
  68. package/src/sandbox/axzyConfig.test.ts +4 -0
  69. package/src/sandbox/axzyConfig.ts +1 -1
  70. package/src/sandbox/i18n.ts +44 -0
  71. package/src/sandbox/useHashRoute.ts +2 -1
  72. package/src/showcases/DemoJsonDashboard.tsx +218 -103
  73. package/src/showcases/DemoJsonDocs.tsx +11 -11
  74. package/src/showcases/FeedbackShowcases.tsx +2 -1
  75. package/src/showcases/JsonScreenShowcase.tsx +43 -53
  76. package/src/showcases/StructureShowcases.tsx +17 -2
  77. package/src/showcases/demoApp/demoJson.test.tsx +241 -0
  78. package/src/showcases/demoApp/routes.test.ts +63 -0
  79. package/src/showcases/demoApp/schemas.test.ts +6 -17
  80. package/src/theme/theme-context.ts +10 -2
  81. package/src/theme/theme.ts +1 -1
  82. package/src/theme/theme.types.ts +2 -1
@@ -1,4 +1,4 @@
1
- import React, { useCallback, useEffect, useMemo, useRef, useState } from "react";
1
+ import React, { useCallback, useEffect, useMemo, useState } from "react";
2
2
  import { FaBolt, FaBug, FaCheck, FaTrashAlt } from "react-icons/fa";
3
3
  import {
4
4
  ITAlert,
@@ -11,8 +11,9 @@ import {
11
11
  ITText,
12
12
  type JsonEngineIssue,
13
13
  type ScreenSchema,
14
- parsePartialSchema,
14
+ type StreamSource,
15
15
  axzyCatalogTypes,
16
+ useSchemaStream,
16
17
  } from "../index";
17
18
  import { ShowcaseLayout } from "./ShowcaseLayout";
18
19
 
@@ -53,7 +54,7 @@ const EJEMPLO: ScreenSchema = {
53
54
  props: { title: "Listo para pintar" },
54
55
  children: [
55
56
  { type: "ITText", text: "Este árbol viene de un JSON, no de TSX." },
56
- { type: "ITProgress", props: { value: 64, max: 100, size: "sm" } },
57
+ { type: "ITText", props: { muted: true }, text: "Progreso: 64 de 100" },
57
58
  ],
58
59
  },
59
60
  ],
@@ -77,9 +78,17 @@ const EJEMPLO: ScreenSchema = {
77
78
  /** Texto inicial del editor: el esquema anterior, formateado. */
78
79
  const TEXTO_INICIAL = JSON.stringify(EJEMPLO, null, 2);
79
80
 
80
- /** Velocidad del stream simulado (caracteres por tick). */
81
+ /** Velocidad del stream simulado (caracteres por trozo). */
81
82
  const CHUNK = 6;
82
83
 
84
+ /** Emite un texto en trozos de `CHUNK` caracteres, con una pausa entre ellos, como haría un modelo. */
85
+ async function* emitir(total: string): AsyncGenerator<string> {
86
+ for (let cursor = 0; cursor < total.length; cursor += CHUNK) {
87
+ await new Promise((resolve) => window.setTimeout(resolve, 24));
88
+ yield total.slice(cursor, cursor + CHUNK);
89
+ }
90
+ }
91
+
83
92
  /**
84
93
  * Laboratorio del motor de pantallas declarativas.
85
94
  *
@@ -92,63 +101,40 @@ const CHUNK = 6;
92
101
  */
93
102
  export const JsonScreenShowcase: React.FC = () => {
94
103
  const [texto, setTexto] = useState(TEXTO_INICIAL);
95
- const [streaming, setStreaming] = useState(false);
96
- const [emitido, setEmitido] = useState(TEXTO_INICIAL.length);
97
104
  const [avisos, setAvisos] = useState<JsonEngineIssue[]>([]);
98
- const timer = useRef<number | null>(null);
105
+ /** Fuente del stream simulado. Mientras existe, el laboratorio está emitiendo. */
106
+ const [fuente, setFuente] = useState<StreamSource | null>(null);
107
+ const stream = useSchemaStream(fuente);
108
+ const streaming = fuente !== null;
109
+ const emitido = stream.text.length;
99
110
 
100
- /** Estado del esquema: mientras se emite, se parsea en modo tolerante. */
111
+ /** Estado del esquema: mientras se emite, lo da el hook; si no, se lee el texto del editor. */
101
112
  const estado = useMemo(() => {
102
- if (!streaming) {
103
- try {
104
- const completo = JSON.parse(texto) as ScreenSchema;
105
- return { tree: completo, status: "complete" as const, pending: 0, roto: false };
106
- } catch {
107
- return { tree: null, status: "complete" as const, pending: 0, roto: true };
108
- }
113
+ if (streaming) {
114
+ return { tree: stream.tree, status: stream.status, pending: stream.pendingNodes, roto: stream.broken };
109
115
  }
110
- const parcial = parsePartialSchema(texto);
111
- return {
112
- tree: parcial.tree,
113
- status: parcial.status,
114
- pending: parcial.pendingNodes,
115
- roto: parcial.broken,
116
- };
117
- }, [texto, streaming]);
118
-
119
- /** Detiene el stream y limpia el temporizador. */
120
- const detener = useCallback(() => {
121
- if (timer.current !== null) {
122
- window.clearInterval(timer.current);
123
- timer.current = null;
116
+ try {
117
+ const completo = JSON.parse(texto) as ScreenSchema;
118
+ return { tree: completo, status: "complete" as const, pending: 0, roto: false };
119
+ } catch {
120
+ return { tree: null, status: "complete" as const, pending: 0, roto: true };
124
121
  }
125
- setStreaming(false);
126
- }, []);
122
+ }, [texto, streaming, stream]);
127
123
 
128
- useEffect(() => () => detener(), [detener]);
124
+ /** Al terminar la emisión, el texto completo pasa al editor y el laboratorio vuelve a ser editable. */
125
+ useEffect(() => {
126
+ if (fuente !== null && !stream.streaming && stream.text.length > 0) {
127
+ setTexto(stream.text);
128
+ setFuente(null);
129
+ }
130
+ }, [fuente, stream.streaming, stream.text]);
129
131
 
130
- /** Emite el esquema carácter a carácter, como haría un modelo. */
132
+ /** Emite el esquema por trozos, como haría un modelo. */
131
133
  const emitirStream = useCallback(() => {
132
- detener();
133
134
  setAvisos([]);
134
- setEmitido(0);
135
135
  setTexto("");
136
- setStreaming(true);
137
-
138
- const total = TEXTO_INICIAL;
139
- let cursor = 0;
140
-
141
- timer.current = window.setInterval(() => {
142
- cursor = Math.min(total.length, cursor + CHUNK);
143
- setTexto(total.slice(0, cursor));
144
- setEmitido(cursor);
145
- if (cursor >= total.length) {
146
- window.clearInterval(timer.current as number);
147
- timer.current = null;
148
- setStreaming(false);
149
- }
150
- }, 24);
151
- }, [detener]);
136
+ setFuente(emitir(TEXTO_INICIAL));
137
+ }, []);
152
138
 
153
139
  /** Registro de acciones del host: el esquema solo puede pedir estos nombres. */
154
140
  const resolveAction = useCallback(
@@ -166,6 +152,11 @@ export const JsonScreenShowcase: React.FC = () => {
166
152
  );
167
153
  }, []);
168
154
 
155
+ /** Los avisos del stream (JR080: falló, o terminó sin cerrar) pasan al panel de avisos. */
156
+ useEffect(() => {
157
+ stream.issues.forEach(registrarAviso);
158
+ }, [stream.issues, registrarAviso]);
159
+
169
160
  const codigo = `<ITSchemaRenderer
170
161
  schema={parsePartialSchema(texto).tree}
171
162
  onIssue={(issue) => registrar(issue)}
@@ -246,9 +237,8 @@ export const JsonScreenShowcase: React.FC = () => {
246
237
  variant="outlined"
247
238
  color="gray"
248
239
  onClick={() => {
249
- detener();
240
+ setFuente(null);
250
241
  setTexto(TEXTO_INICIAL);
251
- setEmitido(TEXTO_INICIAL.length);
252
242
  setAvisos([]);
253
243
  }}
254
244
  />
@@ -372,6 +372,19 @@ export const CardShowcase = () => {
372
372
  }
373
373
  gallery={
374
374
  <ITStack spacing={8}>
375
+ <GallerySection
376
+ title={t("showcase.structure.cardToneTitle")}
377
+ subtitle={t("showcase.structure.cardToneSubtitle")}
378
+ >
379
+ <div className="grid grid-cols-2 lg:grid-cols-4 gap-4">
380
+ {(["primary", "secondary", "ternary", "success", "info", "warning", "danger"] as const).map((tone) => (
381
+ <ITCard key={tone} tone={tone} title={tone}>
382
+ {t("showcase.structure.cardToneBody")}
383
+ </ITCard>
384
+ ))}
385
+ </div>
386
+ </GallerySection>
387
+
375
388
  <GallerySection
376
389
  title={t("showcase.structure.galleryRealCases")}
377
390
  subtitle={t("showcase.structure.galleryCardsSubtitle")}
@@ -537,13 +550,14 @@ export const CardShowcase = () => {
537
550
  }
538
551
  doc={{
539
552
  summary: "Contenedor de tarjeta con imagen, título, cuerpo y pie de acciones opcionales; con onClick se vuelve interactivo.",
540
- description: "Pieza base de composición para agrupar contenido relacionado. Borde, radio y sombra se adaptan al aspecto del tema (flat / soft).",
553
+ description: "Pieza base de composición para agrupar contenido relacionado. Borde, radio y sombra se adaptan al aspecto del tema (soft / flat / bento).",
541
554
  examples: [
542
555
  '<ITCard title="Ingresos del mes" actions={<ITButton label="Ver detalle" size="sm" />}>$48.230</ITCard>',
543
556
  '<ITCard title="Curso" image="/courses/ts.jpg" imageClassName="h-32" onClick={() => open(course.id)}>…</ITCard>',
544
557
  '<ITCard className="max-w-sm"><ITText muted>Tarjeta estática, sin cabecera ni acciones.</ITText></ITCard>',
545
558
  ],
546
559
  props: [
560
+ { name: "tone", type: '"primary" | "secondary" | "ternary" | "success" | "info" | "warning" | "danger"', default: "—", description: "Tarjeta pastel: tiñe el fondo con el token --color-<tone>-soft (sustituye al blanco). En soft/flat usa --color-<tone>-soft-border como borde; en bento sigue sin borde." },
547
561
  { name: "onClick", type: "() => void", default: "—", description: "Al recibirlo la tarjeta se vuelve clicable: cursor pointer, transición de sombra y elevación en hover. Sin él es estática." },
548
562
  { name: "title", type: "string", default: "—", description: "Título de la cabecera; se renderiza como h3 mediante ITText." },
549
563
  { name: "image", type: "string", default: "—", description: "URL de la imagen superior, a ancho completo con h-48 object-cover." },
@@ -559,7 +573,8 @@ export const CardShowcase = () => {
559
573
  notes: [
560
574
  "El aspecto del tema decide borde y sombra: en flat el borde es un hairline y solo hay sombra en hover si la tarjeta es clicable; en soft flota con --shadow-sm y sube a --shadow-lg con translateY(-2px).",
561
575
  "Variables CSS: --it-card-bg, --it-card-border, --it-card-flat-border y --it-card-radius.",
562
- "El padding es fijo (cuerpo 1.25rem, pie 1rem): no hay prop de tamaño.",
576
+ "En bento la tarjeta no tiene borde, usa --it-bento-card-shadow (más marcada en hover si es clicable, sin translateY), radio de 24 px y padding de 1.5rem; un radius/shadow explícito en el provider gana.",
577
+ "El padding es fijo (cuerpo 1.25rem, 1.5rem en bento; pie 1rem): no hay prop de tamaño.",
563
578
  ],
564
579
  }}
565
580
  />
@@ -0,0 +1,241 @@
1
+ /**
2
+ * Pruebas de los JSON del demo (`public/demo/app/*.json`).
3
+ *
4
+ * Los JSON son contrato con el host: cada acción, slot, fuente, parámetro y clave de
5
+ * traducción que nombran tiene que existir del lado del host. Estas pruebas lo
6
+ * comprueban para todas las páginas y para `routes.json`, y renderizan cada página
7
+ * con el motor exigiendo cero avisos.
8
+ */
9
+
10
+ import React from "react";
11
+ import { describe, expect, it } from "vitest";
12
+ import { render } from "@testing-library/react";
13
+ import { existsSync, readdirSync, readFileSync } from "node:fs";
14
+ import { join } from "node:path";
15
+ import { ITSchemaRenderer } from "../../json-renderer/kernel/SchemaRenderer";
16
+ import type { JsonEngineIssue } from "../../json-renderer/shared/errors";
17
+ import type { SlotRegistry } from "../../json-renderer/kernel/slots";
18
+ import { validateSchema, validateRoutes, flattenRoutes, matchRoute, type RoutesSchema } from "../../json-renderer";
19
+ import { sandboxMessages } from "../../sandbox/i18n";
20
+ import { DEMO_ACTIONS, DEMO_SLOT_NAMES } from "../DemoJsonDashboard";
21
+ import { isDemoSource } from "./demoData";
22
+
23
+ const DIR = join(process.cwd(), "public/demo/app");
24
+ const NO_SON_PAGINAS = ["layout.json", "routes.json"];
25
+
26
+ /** Lee un JSON del demo. */
27
+ function leer<T = unknown>(nombre: string): T {
28
+ return JSON.parse(readFileSync(join(DIR, nombre), "utf8")) as T;
29
+ }
30
+
31
+ /** Recorre todos los objetos de un JSON y llama a `visita` con cada uno. */
32
+ function recorrer(valor: unknown, visita: (objeto: Record<string, unknown>) => void): void {
33
+ if (Array.isArray(valor)) {
34
+ valor.forEach((item) => recorrer(item, visita));
35
+ return;
36
+ }
37
+ if (valor && typeof valor === "object") {
38
+ visita(valor as Record<string, unknown>);
39
+ Object.values(valor).forEach((item) => recorrer(item, visita));
40
+ }
41
+ }
42
+
43
+ /** Todas las claves `{ i18n }` de un JSON. */
44
+ function clavesI18n(json: unknown): string[] {
45
+ const claves: string[] = [];
46
+ recorrer(json, (objeto) => {
47
+ if (typeof objeto.i18n === "string") claves.push(objeto.i18n);
48
+ });
49
+ return claves;
50
+ }
51
+
52
+ /** Todos los nombres de acción (`{ action: "…" }`) de un JSON. */
53
+ function nombresDeAccion(json: unknown): string[] {
54
+ const nombres: string[] = [];
55
+ recorrer(json, (objeto) => {
56
+ if (typeof objeto.action === "string") nombres.push(objeto.action);
57
+ });
58
+ return nombres;
59
+ }
60
+
61
+ /** Todos los `{ $param: "…" }` de un JSON. */
62
+ function parametrosUsados(json: unknown): string[] {
63
+ const nombres: string[] = [];
64
+ recorrer(json, (objeto) => {
65
+ if (typeof objeto.$param === "string") nombres.push(objeto.$param);
66
+ });
67
+ return nombres;
68
+ }
69
+
70
+ /** Nodos `slot` de un JSON, por nombre de slot. */
71
+ function slotsUsados(json: unknown): string[] {
72
+ const nombres: string[] = [];
73
+ recorrer(json, (objeto) => {
74
+ if (objeto.type === "slot" && typeof (objeto.props as { name?: unknown } | undefined)?.name === "string") {
75
+ nombres.push((objeto.props as { name: string }).name);
76
+ }
77
+ });
78
+ return nombres;
79
+ }
80
+
81
+ /** Todos los `{ $source: "…" }` de un JSON. */
82
+ function fuentesUsadas(json: unknown): string[] {
83
+ const nombres: string[] = [];
84
+ recorrer(json, (objeto) => {
85
+ if (typeof objeto.$source === "string") nombres.push(objeto.$source);
86
+ });
87
+ return nombres;
88
+ }
89
+
90
+ /** Páginas de pantalla: todos los JSON salvo el shell y las rutas. */
91
+ const PAGINAS = readdirSync(DIR).filter((archivo) => archivo.endsWith(".json") && !NO_SON_PAGINAS.includes(archivo));
92
+
93
+ const rutas = leer<RoutesSchema>("routes.json");
94
+ const planas = flattenRoutes(rutas.routes);
95
+
96
+ /** Pantalla que pinta una página, según la ruta que la declara (params a "1"). */
97
+ function ejemploDePagina(pagina: string) {
98
+ const ruta = planas.find((item) => item.page === pagina);
99
+ if (!ruta) return null;
100
+ const segmentos = ruta.segments.map((segmento) => (segmento.startsWith(":") ? "1" : segmento));
101
+ return matchRoute(planas, segmentos, ruta.query);
102
+ }
103
+
104
+ /** Diccionario de traducción en español para resolver `{ i18n }` al renderizar. */
105
+ const es = sandboxMessages.es as Record<string, string>;
106
+ const en = sandboxMessages.en as Record<string, string>;
107
+
108
+ describe("routes.json", () => {
109
+ it("es un documento de rutas válido", () => {
110
+ expect(validateRoutes(rutas)).toEqual([]);
111
+ });
112
+
113
+ it("cada ruta hoja pinta una página que existe (docs es la página TSX)", () => {
114
+ for (const ruta of planas) {
115
+ if (ruta.page === "docs") continue;
116
+ expect(existsSync(join(DIR, `${ruta.page}.json`)), `${ruta.id} → ${ruta.page}.json`).toBe(true);
117
+ }
118
+ });
119
+
120
+ it("no hay páginas huérfanas: cada JSON de pantalla tiene una ruta que lo pinta", () => {
121
+ const paginasConRuta = new Set(planas.map((ruta) => ruta.page));
122
+ for (const archivo of PAGINAS) {
123
+ expect(paginasConRuta.has(archivo.replace(/\.json$/, "")), `${archivo} sin ruta`).toBe(true);
124
+ }
125
+ });
126
+
127
+ it("los ids de ruta son únicos", () => {
128
+ const ids = planas.map((ruta) => ruta.id);
129
+ expect(new Set(ids).size).toBe(ids.length);
130
+ });
131
+
132
+ it("cada clave de traducción existe en español e inglés", () => {
133
+ for (const clave of clavesI18n(rutas)) {
134
+ expect(es[clave], `es: ${clave}`).toBeTruthy();
135
+ expect(en[clave], `en: ${clave}`).toBeTruthy();
136
+ }
137
+ });
138
+
139
+ it("cada ruta con :param declara un parámetro que su página usa, y viceversa", () => {
140
+ for (const ruta of planas) {
141
+ const declarados = ruta.segments.filter((segmento) => segmento.startsWith(":")).map((segmento) => segmento.slice(1));
142
+ if (declarados.length === 0 || ruta.page === "docs") continue;
143
+ const pagina = leer(`${ruta.page}.json`);
144
+ for (const usado of parametrosUsados(pagina)) {
145
+ expect(declarados, `${ruta.page} usa $param "${usado}"`).toContain(usado);
146
+ }
147
+ }
148
+ });
149
+ });
150
+
151
+ describe.each(PAGINAS)("%s", (archivo) => {
152
+ const pagina = leer(archivo);
153
+ const nombre = archivo.replace(/\.json$/, "");
154
+
155
+ it("es un esquema válido, sin nodos pendientes", () => {
156
+ const informe = validateSchema(pagina);
157
+ expect(informe.errors, JSON.stringify(informe.errors)).toHaveLength(0);
158
+ expect(informe.verdict).toBe("valid");
159
+ expect(informe.pendingNodes).toBe(0);
160
+ });
161
+
162
+ it("solo usa acciones que el host resuelve", () => {
163
+ for (const accion of nombresDeAccion(pagina)) {
164
+ expect(DEMO_ACTIONS as readonly string[], `acción "${accion}"`).toContain(accion);
165
+ }
166
+ });
167
+
168
+ it("cada clave de traducción existe en español e inglés", () => {
169
+ for (const clave of clavesI18n(pagina)) {
170
+ expect(es[clave], `es: ${clave}`).toBeTruthy();
171
+ expect(en[clave], `en: ${clave}`).toBeTruthy();
172
+ }
173
+ });
174
+
175
+ it("cada slot nombra un bloque que el host registra", () => {
176
+ for (const slot of slotsUsados(pagina)) {
177
+ expect(DEMO_SLOT_NAMES, `slot "${slot}"`).toContain(slot);
178
+ }
179
+ });
180
+
181
+ it("cada $source usa una fuente declarada en `data`, con un handler que existe", () => {
182
+ const data = ((pagina as { data?: Record<string, { handler: string }> }).data ?? {}) as Record<string, { handler: string }>;
183
+ for (const fuente of fuentesUsadas(pagina)) {
184
+ expect(data[fuente], `fuente "${fuente}" sin declarar en data`).toBeTruthy();
185
+ expect(isDemoSource(data[fuente].handler), `handler "${data[fuente].handler}"`).toBe(true);
186
+ }
187
+ });
188
+
189
+ it("cada $param tiene una ruta que lo proporciona", () => {
190
+ const ejemplo = ejemploDePagina(nombre);
191
+ if (parametrosUsados(pagina).length === 0) return;
192
+ expect(ejemplo, `${nombre} necesita una ruta con :param`).not.toBeNull();
193
+ for (const usado of parametrosUsados(pagina)) {
194
+ expect(ejemplo?.params, `$param "${usado}"`).toHaveProperty(usado);
195
+ }
196
+ });
197
+
198
+ it("se pinta con el motor sin avisos (ni errores ni advertencias)", () => {
199
+ const issues: JsonEngineIssue[] = [];
200
+ const ejemplo = ejemploDePagina(nombre);
201
+ const slots: SlotRegistry = Object.fromEntries(
202
+ DEMO_SLOT_NAMES.map((nombreSlot) => [nombreSlot, () => <div data-slot={nombreSlot} />])
203
+ );
204
+
205
+ render(
206
+ <ITSchemaRenderer
207
+ schema={pagina as never}
208
+ params={ejemplo?.params ?? {}}
209
+ // Como el host real: todos los efectos del demo están implementados.
210
+ effects={{ toast: () => undefined, navigate: () => undefined, refresh: () => undefined, openOverlay: () => undefined, closeOverlay: () => undefined }}
211
+ // Como el host real: solo las acciones de DEMO_ACTIONS tienen handler.
212
+ resolveAction={(_node, _prop, accion) => {
213
+ const nombre = (accion as { action?: unknown } | null)?.action;
214
+ return typeof nombre === "string" && (DEMO_ACTIONS as readonly string[]).includes(nombre) ? () => undefined : undefined;
215
+ }}
216
+ slots={slots}
217
+ dataHandlers={{
218
+ usuarios: async () => ({ data: [], total: 0 }),
219
+ pedidos: async () => ({ data: [], total: 0 }),
220
+ productos: async () => ({ data: [], total: 0 }),
221
+ }}
222
+ resolveValue={(valor) =>
223
+ valor && typeof valor === "object" && typeof (valor as { i18n?: unknown }).i18n === "string"
224
+ ? es[(valor as { i18n: string }).i18n]
225
+ : valor
226
+ }
227
+ onIssue={(issue) => issues.push(issue)}
228
+ />
229
+ );
230
+
231
+ expect(issues.map((issue) => `${issue.code} ${issue.path}: ${issue.message}`)).toEqual([]);
232
+ });
233
+ });
234
+
235
+ describe("layout.json", () => {
236
+ it("declara la marca y nada más: el menú sale de routes.json", () => {
237
+ const layout = leer<{ layout: { brand?: { text?: string }; modules?: unknown } }>("layout.json");
238
+ expect(layout.layout.brand?.text).toBeTruthy();
239
+ expect(layout.layout.modules).toBeUndefined();
240
+ });
241
+ });
@@ -0,0 +1,63 @@
1
+ import { existsSync, readFileSync } from "node:fs";
2
+ import { join } from "node:path";
3
+ import { describe, expect, it } from "vitest";
4
+ import { buildNavigationTree, flattenRoutes, matchRoute, routeHref, validateRoutes, type NavNode, type RoutesSchema } from "../../json-renderer";
5
+ import { sandboxMessages } from "../../sandbox/i18n";
6
+
7
+ const DIR = join(process.cwd(), "public/demo/app");
8
+ const rutas = JSON.parse(readFileSync(join(DIR, "routes.json"), "utf8")) as RoutesSchema;
9
+ const planas = flattenRoutes(rutas.routes);
10
+
11
+ /** Todas las claves `{ i18n }` de un árbol de navegación. */
12
+ function claves(nodos: NavNode[]): string[] {
13
+ return nodos.flatMap((nodo) => [
14
+ ...(typeof nodo.label === "object" && nodo.label !== null && "i18n" in nodo.label ? [String(nodo.label.i18n)] : []),
15
+ ...(nodo.children ? claves(nodo.children) : []),
16
+ ]);
17
+ }
18
+
19
+ describe("routes.json del demo", () => {
20
+ it("es un documento de rutas válido", () => {
21
+ expect(validateRoutes(rutas)).toEqual([]);
22
+ });
23
+
24
+ it("cada página de una hoja tiene su JSON (docs es la página TSX)", () => {
25
+ for (const ruta of planas) {
26
+ if (ruta.page === "docs") continue;
27
+ expect(existsSync(join(DIR, `${ruta.page}.json`)), `${ruta.id} → ${ruta.page}.json`).toBe(true);
28
+ }
29
+ });
30
+
31
+ it("todas las etiquetas del menú tienen traducción en el catálogo del sandbox", () => {
32
+ for (const clave of claves(buildNavigationTree(rutas.routes))) {
33
+ expect(sandboxMessages.es[clave], clave).toBeTruthy();
34
+ }
35
+ });
36
+
37
+ it("el menú sale de las rutas: grupos con sus sub-items y la marca de pedidos", () => {
38
+ const arbol = buildNavigationTree(rutas.routes);
39
+ expect(arbol.map((entrada) => entrada.id)).toEqual(["inicio", "usuarios", "pedidos", "productos", "reportes", "docs"]);
40
+ expect(arbol.find((entrada) => entrada.id === "pedidos")?.badge).toBe(11);
41
+ expect(arbol.find((entrada) => entrada.id === "usuarios")?.children?.map((hijo) => hijo.href)).toEqual([
42
+ "/usuarios",
43
+ "/usuarios?estado=Activo",
44
+ "/usuarios?estado=Inactivo",
45
+ ]);
46
+ });
47
+
48
+ it("cada dirección del menú casa con la ruta que la declara", () => {
49
+ for (const ruta of planas) {
50
+ const casada = matchRoute(planas, ruta.segments, ruta.query);
51
+ expect(casada?.route.id, routeHref(ruta)).toBe(ruta.id);
52
+ }
53
+ });
54
+
55
+ it("la ruta de detalle recibe el id como :param y no compite con /usuarios", () => {
56
+ expect(matchRoute(planas, ["usuarios", "7"])).toMatchObject({ route: { id: "usuario-detalle" }, params: { id: "7" } });
57
+ expect(matchRoute(planas, ["usuarios"])?.route.id).toBe("u-todos");
58
+ });
59
+
60
+ it("una query sin ruta específica cae en la ruta base del mismo path", () => {
61
+ expect(matchRoute(planas, ["pedidos"], { estado: "Cancelado" })?.route.id).toBe("p-todos");
62
+ });
63
+ });
@@ -10,7 +10,9 @@ function leerJson(nombre: string): unknown {
10
10
  return JSON.parse(readFileSync(join(DIR, nombre), "utf8"));
11
11
  }
12
12
 
13
- const paginas = readdirSync(DIR).filter((archivo) => archivo.endsWith(".json") && archivo !== "layout.json");
13
+ // layout.json (marca) y routes.json (rutas) no son pantallas: se prueban aparte.
14
+ const NO_SON_PAGINAS = ["layout.json", "routes.json"];
15
+ const paginas = readdirSync(DIR).filter((archivo) => archivo.endsWith(".json") && !NO_SON_PAGINAS.includes(archivo));
14
16
 
15
17
  describe("JSON del demo", () => {
16
18
  it("hay páginas y un layout", () => {
@@ -25,23 +27,10 @@ describe("JSON del demo", () => {
25
27
  expect(informe.pendingNodes).toBe(0);
26
28
  });
27
29
 
28
- it("el layout declara marca, módulos y páginas existentes", () => {
29
- const layout = leerJson("layout.json") as {
30
- layout: { brand?: { text?: string }; modules: { id: string; page: string; icon?: string }[] };
31
- };
30
+ it("el layout declara la marca; el menú vive en routes.json", () => {
31
+ const layout = leerJson("layout.json") as { layout: { brand?: { text?: string }; modules?: unknown } };
32
32
  expect(layout.layout.brand?.text).toBeTruthy();
33
- expect(layout.layout.modules.length).toBeGreaterThanOrEqual(6);
34
- for (const modulo of layout.layout.modules) {
35
- expect(modulo.id, "cada módulo necesita id").toBeTruthy();
36
- expect(modulo.page, `${modulo.id} necesita page`).toBeTruthy();
37
- }
38
- // Todo módulo apunta a una página que existe (docs no tiene archivo propio).
39
- const ids = new Set(layout.layout.modules.map((modulo) => modulo.page));
40
- expect(ids.has("docs")).toBe(true);
41
- for (const pagina of ids) {
42
- if (pagina === "docs") continue;
43
- expect(paginas, `falta ${pagina}.json`).toContain(`${pagina}.json`);
44
- }
33
+ expect(layout.layout.modules, "los módulos ya no van en layout.json").toBeUndefined();
45
34
  });
46
35
 
47
36
  it("las páginas declaran título y descripción traducibles", () => {
@@ -38,10 +38,18 @@ export const useITThemeSafe = (): ITThemeContextType | undefined => {
38
38
  return useContext(ITThemeContext);
39
39
  };
40
40
 
41
+ /**
42
+ * Lenguaje visual activo (`"soft"` | `"flat"` | `"bento"`).
43
+ * Fuera de un ITThemeProvider devuelve `"soft"` (aspecto por defecto).
44
+ */
45
+ export const useITAppearance = (): ITThemeAppearance => {
46
+ return useContext(ITThemeContext)?.appearance ?? "soft";
47
+ };
48
+
41
49
  /**
42
50
  * `true` cuando el provider pide el lenguaje visual plano (`appearance="flat"`).
43
51
  * Fuera de un ITThemeProvider devuelve `false` (aspecto por defecto).
44
52
  */
45
53
  export const useITFlatAppearance = (): boolean => {
46
- return useContext(ITThemeContext)?.appearance === "flat";
47
- };
54
+ return useITAppearance() === "flat";
55
+ };
@@ -322,7 +322,7 @@ export const components = {
322
322
  input: {
323
323
  backgroundColor: 'var(--it-input-bg, #ffffff)',
324
324
  borderColor: `var(--it-input-border, ${semanticColors.gray[300]})`,
325
- borderRadius: 'var(--radius-md)',
325
+ borderRadius: 'var(--it-input-radius, var(--radius-md))',
326
326
  focus: {
327
327
  borderColor: `var(--it-input-focus-border, ${semanticColors.primary[500]})`,
328
328
  ring: `var(--it-input-focus-ring, 0 0 0 3px ${semanticColors.primary[100]})`,
@@ -16,8 +16,9 @@ export type ColorScale = {
16
16
  * Visual language of the whole system.
17
17
  * - `"soft"`: soft depth — floating cards, tinted shadows, glass sidebar and top bar (default).
18
18
  * - `"flat"`: flat surfaces — hairline borders instead of shadows, plain sidebar and top bar, compact page headers.
19
+ * - `"bento"`: modern pastel "bento" dashboards — light-gray canvas, borderless white cards with a diffuse shadow and 24px radius, pill controls (buttons, inputs), ink-pill navigation and pastel tiles via `ITCard` `tone`.
19
20
  */
20
- export type ITThemeAppearance = "soft" | "flat";
21
+ export type ITThemeAppearance = "soft" | "flat" | "bento";
21
22
 
22
23
  export interface ITThemePalette {
23
24
  /** Main brand accent color (hex). Drives primary buttons, active nav items, focus rings, links, and the `--color-primary` CSS variable. @default "#06b6d4" */