@axzydev/axzy_ui_system 1.6.0 → 1.7.1

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 (71) hide show
  1. package/dist/index.cjs +7842 -4676
  2. package/dist/index.cjs.map +1 -1
  3. package/dist/index.css +1 -1
  4. package/dist/index.css.map +1 -1
  5. package/dist/index.d.cts +883 -3
  6. package/dist/index.d.ts +883 -3
  7. package/dist/index.js +9118 -5971
  8. package/dist/index.js.map +1 -1
  9. package/dist/layered.css +1 -1
  10. package/package.json +14 -7
  11. package/src/App.tsx +70 -2
  12. package/src/components/atoms/image/image.props.ts +2 -0
  13. package/src/components/atoms/image/image.tsx +3 -2
  14. package/src/components/molecules/json-editor/json-editor-tree.tsx +235 -0
  15. package/src/components/molecules/json-editor/json-editor.index.ts +2 -0
  16. package/src/components/molecules/json-editor/json-editor.props.ts +82 -0
  17. package/src/components/molecules/json-editor/json-editor.tsx +972 -0
  18. package/src/components/molecules/json-editor/json-editor.utils.ts +717 -0
  19. package/src/components/molecules/json-editor/useJsonEditorKeyboard.ts +256 -0
  20. package/src/components/molecules/json-renderer/json-renderer.index.ts +2 -0
  21. package/src/components/molecules/json-renderer/json-renderer.props.ts +31 -0
  22. package/src/components/molecules/json-renderer/json-renderer.tsx +79 -0
  23. package/src/components/organisms/dialog/dialog.props.ts +2 -0
  24. package/src/components/organisms/dialog/dialog.tsx +4 -3
  25. package/src/dev.css +30 -1
  26. package/src/hooks/useJsonRender.tsx +9 -0
  27. package/src/i18n/locales/en.ts +54 -0
  28. package/src/i18n/locales/es.ts +54 -0
  29. package/src/index.css +414 -0
  30. package/src/index.ts +99 -2
  31. package/src/json-renderer/catalog/axzyCatalog.ts +362 -0
  32. package/src/json-renderer/catalog/catalog.props.ts +128 -0
  33. package/src/json-renderer/catalog/resolve.ts +225 -0
  34. package/src/json-renderer/index.ts +70 -0
  35. package/src/json-renderer/kernel/NodeBoundary.tsx +53 -0
  36. package/src/json-renderer/kernel/SchemaRenderer.tsx +197 -0
  37. package/src/json-renderer/kernel/kernel.test.tsx +341 -0
  38. package/src/json-renderer/kernel/partial.ts +92 -0
  39. package/src/json-renderer/kernel/renderNode.tsx +113 -0
  40. package/src/json-renderer/kernel/schemaRenderer.props.ts +42 -0
  41. package/src/json-renderer/kernel/types.ts +159 -0
  42. package/src/json-renderer/shared/errors.ts +92 -0
  43. package/src/json-renderer/shared/limits.ts +87 -0
  44. package/src/json-renderer/shared/paths.ts +66 -0
  45. package/src/json-renderer/shared/shared.test.ts +108 -0
  46. package/src/json-renderer/stream/tolerantParse.test.ts +152 -0
  47. package/src/json-renderer/stream/tolerantParse.ts +449 -0
  48. package/src/json-renderer/test/setup.ts +38 -0
  49. package/src/json-renderer/validation/migrate.ts +83 -0
  50. package/src/json-renderer/validation/validate.ts +161 -0
  51. package/src/json-renderer/validation/validation.test.ts +165 -0
  52. package/src/json-renderer/vocabulary/buildContract.ts +158 -0
  53. package/src/sandbox/AxzyLogo.tsx +139 -0
  54. package/src/sandbox/axzyConfig.test.ts +40 -0
  55. package/src/sandbox/axzyConfig.ts +77 -0
  56. package/src/sandbox/i18n.ts +220 -0
  57. package/src/sandbox/navigation.ts +3 -0
  58. package/src/sandbox/useHashRoute.ts +39 -9
  59. package/src/showcases/DemoJsonDashboard.tsx +489 -0
  60. package/src/showcases/DemoJsonDocs.tsx +339 -0
  61. package/src/showcases/DemoJsonPortfolio.tsx +257 -0
  62. package/src/showcases/FeedbackShowcases.tsx +16 -5
  63. package/src/showcases/HomeShowcase.tsx +16 -9
  64. package/src/showcases/JsonEditorShowcase.tsx +150 -0
  65. package/src/showcases/JsonRendererShowcase.tsx +70 -0
  66. package/src/showcases/JsonScreenShowcase.tsx +341 -0
  67. package/src/showcases/StructureShowcases.tsx +6 -1
  68. package/src/showcases/demoApp/demoData.ts +181 -0
  69. package/src/showcases/demoApp/nodeTree.test.ts +39 -0
  70. package/src/showcases/demoApp/nodeTree.ts +35 -0
  71. package/src/showcases/demoApp/schemas.test.ts +54 -0
@@ -0,0 +1,161 @@
1
+ /**
2
+ * Validación de esquemas con **tres veredictos**.
3
+ *
4
+ * Un esquema a medio llegar no es un esquema roto: `partial` significa "válido
5
+ * hasta donde ha llegado", y es el estado normal mientras un emisor (una IA, por
6
+ * ejemplo) sigue escribiendo. `invalid` es lo que no se puede pintar ni esperar.
7
+ */
8
+
9
+ import { issue, type JsonEngineIssue } from "../shared/errors";
10
+ import { formatPath } from "../shared/paths";
11
+ import type { Catalog } from "../catalog/catalog.props";
12
+ import { axzyCatalog } from "../catalog/axzyCatalog";
13
+ import { resolveNode } from "../catalog/resolve";
14
+ import type { NodeSchema, ScreenSchema } from "../kernel/types";
15
+ import { isNodePending } from "../stream/tolerantParse";
16
+
17
+ /** Veredicto de la validación. */
18
+ export type ValidationVerdict = "valid" | "partial" | "invalid";
19
+
20
+ /** Informe de validación. */
21
+ export interface ValidationReport {
22
+ /** Veredicto global. */
23
+ verdict: ValidationVerdict;
24
+ /** Errores que impiden pintar. */
25
+ errors: JsonEngineIssue[];
26
+ /** Avisos que no impiden pintar. */
27
+ warnings: JsonEngineIssue[];
28
+ /** Nodos que aún no están cerrados. */
29
+ pendingNodes: number;
30
+ /** Versión del esquema, si la declara. */
31
+ version?: number;
32
+ }
33
+
34
+ /** Opciones de validación. */
35
+ export interface ValidateOptions {
36
+ /** Catálogo contra el que se validan tipos y props. @default axzyCatalog */
37
+ catalog?: Catalog;
38
+ /** Versión máxima admitida del formato. @default 1 */
39
+ maxVersion?: number;
40
+ }
41
+
42
+ /** Versión actual del formato de pantalla. */
43
+ export const CURRENT_SCHEMA_VERSION = 1;
44
+
45
+ /** Recorre el árbol y valida cada nodo contra el catálogo. */
46
+ function validateNodes(
47
+ node: NodeSchema,
48
+ path: (string | number)[],
49
+ catalog: Catalog,
50
+ errors: JsonEngineIssue[],
51
+ warnings: JsonEngineIssue[]
52
+ ): number {
53
+ if (!node || typeof node !== "object") {
54
+ errors.push(issue("JR021", "El nodo no es un objeto.", formatPath(path), { severity: "error" }));
55
+ return 0;
56
+ }
57
+
58
+ // Un nodo pendiente no es un error: aún no ha llegado.
59
+ if (isNodePending(node)) return 1;
60
+
61
+ const resolved = resolveNode(node, catalog, path);
62
+ for (const item of resolved.issues) {
63
+ (item.severity === "error" ? errors : warnings).push(item);
64
+ }
65
+
66
+ let pending = 0;
67
+ const children = node.children === undefined ? [] : Array.isArray(node.children) ? node.children : [node.children];
68
+ children.forEach((child, index) => {
69
+ pending += validateNodes(child, [...path, "children", index], catalog, errors, warnings);
70
+ });
71
+ return pending;
72
+ }
73
+
74
+ /**
75
+ * Valida un esquema de pantalla.
76
+ *
77
+ * No lanza nunca: devuelve un informe con el veredicto y los avisos, cada uno con
78
+ * su código y su ruta.
79
+ *
80
+ * @example
81
+ * validateSchema({ version: 1, id: "x", content: { type: "ITText", text: "ok" } }).verdict; // "valid"
82
+ *
83
+ * @example
84
+ * validateSchema(parsePartialSchema(tokens).tree).verdict; // "partial" mientras llega
85
+ */
86
+ export function validateSchema(schema: unknown, options: ValidateOptions = {}): ValidationReport {
87
+ const catalog = options.catalog ?? axzyCatalog;
88
+ const maxVersion = options.maxVersion ?? CURRENT_SCHEMA_VERSION;
89
+ const errors: JsonEngineIssue[] = [];
90
+ const warnings: JsonEngineIssue[] = [];
91
+
92
+ if (schema === null || schema === undefined) {
93
+ return { verdict: "invalid", errors: [issue("JR020", "No hay esquema que validar.", "")], warnings, pendingNodes: 0 };
94
+ }
95
+
96
+ if (typeof schema !== "object" || Array.isArray(schema)) {
97
+ return {
98
+ verdict: "invalid",
99
+ errors: [issue("JR020", "El esquema debe ser un objeto.", "", { severity: "error" })],
100
+ warnings,
101
+ pendingNodes: 0,
102
+ };
103
+ }
104
+
105
+ const candidate = schema as ScreenSchema;
106
+
107
+ // 1. Formato y versión.
108
+ if (candidate.$schema !== undefined && candidate.$schema !== "axzy/screen") {
109
+ errors.push(
110
+ issue("JR020", `El formato declarado no es \`axzy/screen\`.`, "$schema", {
111
+ severity: "error",
112
+ hint: "Usa `$schema: \"axzy/screen\"` o quita la propiedad.",
113
+ })
114
+ );
115
+ }
116
+
117
+ if (candidate.version === undefined) {
118
+ errors.push(issue("JR020", "Falta `version` en el esquema.", "version", { severity: "error" }));
119
+ } else if (typeof candidate.version !== "number" || !Number.isInteger(candidate.version)) {
120
+ errors.push(issue("JR020", "`version` debe ser un entero.", "version", { severity: "error" }));
121
+ } else if (candidate.version > maxVersion) {
122
+ errors.push(
123
+ issue("JR020", `La versión ${candidate.version} es más nueva que la soportada (${maxVersion}).`, "version", {
124
+ severity: "error",
125
+ hint: "Actualiza el motor o migra el esquema.",
126
+ })
127
+ );
128
+ }
129
+
130
+ // 2. Identidad del esquema.
131
+ if (typeof candidate.id !== "string" || candidate.id.trim() === "") {
132
+ errors.push(issue("JR020", "Falta `id` en el esquema.", "id", { severity: "error" }));
133
+ }
134
+
135
+ // 3. Contenido.
136
+ if (candidate.content === undefined) {
137
+ errors.push(issue("JR020", "El esquema no declara `content`.", "content", { severity: "error" }));
138
+ return { verdict: "invalid", errors, warnings, pendingNodes: 0, version: candidate.version };
139
+ }
140
+
141
+ const content = Array.isArray(candidate.content) ? candidate.content : [candidate.content];
142
+ if (content.length === 0) {
143
+ warnings.push(
144
+ issue("JR021", "`content` está vacío: la pantalla se pinta sin nodos.", "content", { severity: "warning" })
145
+ );
146
+ }
147
+
148
+ let pendingNodes = 0;
149
+ content
150
+ .map((node, index) => ({ node, index }))
151
+ // Un árbol parcial puede traer huecos: se ignoran, no se reportan.
152
+ .filter(({ node }) => node !== undefined && node !== null)
153
+ .forEach(({ node, index }) => {
154
+ pendingNodes += validateNodes(node, ["content", index], catalog, errors, warnings);
155
+ });
156
+
157
+ // 4. Veredicto: un error real invalida; nodos pendientes solo lo hacen parcial.
158
+ const verdict: ValidationVerdict = errors.length > 0 ? "invalid" : pendingNodes > 0 ? "partial" : "valid";
159
+
160
+ return { verdict, errors, warnings, pendingNodes, version: candidate.version };
161
+ }
@@ -0,0 +1,165 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { axzyCatalog } from "../catalog/axzyCatalog";
3
+ import { parsePartialSchema } from "../stream/tolerantParse";
4
+ import type { ScreenSchema } from "../kernel/types";
5
+ import { CURRENT_SCHEMA_VERSION, validateSchema } from "./validate";
6
+ import { migrateSchema, type MigrationRegistry } from "./migrate";
7
+ import { buildVocabularyContract, formatVocabularyForPrompt } from "../vocabulary/buildContract";
8
+
9
+ const screen = (content: ScreenSchema["content"]): ScreenSchema => ({ version: 1, id: "test", content });
10
+
11
+ describe("validateSchema", () => {
12
+ it("da por válido un esquema correcto", () => {
13
+ const report = validateSchema(screen({ type: "ITText", text: "Hola" }));
14
+ expect(report.verdict).toBe("valid");
15
+ expect(report.errors).toHaveLength(0);
16
+ expect(report.warnings).toHaveLength(0);
17
+ });
18
+
19
+ it("declara partial un esquema a medias y sin errores", () => {
20
+ const full = JSON.stringify(screen([{ type: "ITText", text: "uno" }, { type: "ITCard", props: { title: "dos" } }]));
21
+ const cut = full.indexOf('"title"') + 4;
22
+ const partial = parsePartialSchema(full.slice(0, cut));
23
+ const report = validateSchema(partial.tree);
24
+
25
+ expect(report.verdict).toBe("partial");
26
+ expect(report.errors).toHaveLength(0);
27
+ expect(report.pendingNodes).toBeGreaterThan(0);
28
+ });
29
+
30
+ it("invalida un esquema sin versión", () => {
31
+ const report = validateSchema({ id: "x", content: { type: "ITText", text: "x" } });
32
+ expect(report.verdict).toBe("invalid");
33
+ expect(report.errors.some((e) => e.code === "JR020" && e.path === "version")).toBe(true);
34
+ });
35
+
36
+ it("invalida una versión futura", () => {
37
+ const report = validateSchema({ version: 99, id: "x", content: { type: "ITText", text: "x" } });
38
+ expect(report.verdict).toBe("invalid");
39
+ expect(report.errors[0]?.message).toContain("99");
40
+ });
41
+
42
+ it("invalida un esquema sin id o sin content", () => {
43
+ expect(validateSchema({ version: 1, content: { type: "ITText" } }).verdict).toBe("invalid");
44
+ expect(validateSchema({ version: 1, id: "x" }).verdict).toBe("invalid");
45
+ });
46
+
47
+ it("separa errores de avisos: prop desconocida es aviso, tipo desconocido es error", () => {
48
+ const report = validateSchema(
49
+ screen([
50
+ { type: "ITText", text: "ok", props: { inventada: 1 } },
51
+ { type: "ITNope" },
52
+ ] as ScreenSchema["content"])
53
+ );
54
+ expect(report.verdict).toBe("invalid");
55
+ expect(report.errors.some((e) => e.code === "JR010")).toBe(true);
56
+ expect(report.warnings.some((w) => w.code === "JR011")).toBe(true);
57
+ });
58
+
59
+ it("no se cae con un esquema parcial sin content", () => {
60
+ const { tree } = parsePartialSchema('{"version":1,"id":"x"');
61
+ const report = validateSchema(tree);
62
+ expect(() => validateSchema(tree)).not.toThrow();
63
+ // Sin content todavía: no es un error del esquema, es un stream a medias.
64
+ expect(report.errors.some((e) => e.path === "content")).toBe(true);
65
+ });
66
+
67
+ it("no lanza con entradas absurdas", () => {
68
+ for (const value of [null, undefined, 42, "texto", [], [1, 2]]) {
69
+ expect(() => validateSchema(value)).not.toThrow();
70
+ expect(validateSchema(value).verdict).toBe("invalid");
71
+ }
72
+ });
73
+
74
+ it("acepta el formato completo con $schema", () => {
75
+ const report = validateSchema({
76
+ $schema: "axzy/screen",
77
+ version: CURRENT_SCHEMA_VERSION,
78
+ id: "demo",
79
+ content: { type: "ITCard", props: { title: "ok" }, children: [{ type: "ITText", text: "x" }] },
80
+ });
81
+ expect(report.verdict).toBe("valid");
82
+ });
83
+ });
84
+
85
+ describe("migrateSchema", () => {
86
+ it("no migra si ya está en la versión objetivo", () => {
87
+ const result = migrateSchema(screen({ type: "ITText", text: "x" }), 1);
88
+ expect(result.migrated).toBe(false);
89
+ expect(result.issues).toHaveLength(0);
90
+ });
91
+
92
+ it("avisa cuando falta la migración (JR070)", () => {
93
+ const result = migrateSchema({ ...screen({ type: "ITText", text: "x" }), version: 1 }, 2, {});
94
+ expect(result.migrated).toBe(false);
95
+ expect(result.issues[0]?.code).toBe("JR070");
96
+ });
97
+
98
+ it("aplica migraciones en cadena", () => {
99
+ const migrations: MigrationRegistry = {
100
+ 1: (schema) => ({ ...schema, id: `${schema.id}-v2` }),
101
+ 2: (schema) => ({ ...schema, id: `${schema.id}-v3` }),
102
+ };
103
+ const result = migrateSchema({ ...screen({ type: "ITText", text: "x" }), version: 1 }, 3, migrations);
104
+ expect(result.migrated).toBe(true);
105
+ expect(result.to).toBe(3);
106
+ expect(result.schema.version).toBe(3);
107
+ expect(result.schema.id).toBe("test-v2-v3");
108
+ });
109
+
110
+ it("no permite degradar un esquema", () => {
111
+ const result = migrateSchema({ ...screen({ type: "ITText", text: "x" }), version: 3 }, 1);
112
+ expect(result.migrated).toBe(false);
113
+ expect(result.issues[0]?.code).toBe("JR020");
114
+ });
115
+ });
116
+
117
+ describe("contrato de vocabulario", () => {
118
+ const contract = buildVocabularyContract();
119
+
120
+ it("cubre todos los tipos del catálogo", () => {
121
+ expect(contract.components).toHaveLength(Object.keys(axzyCatalog.entries).length);
122
+ expect(contract.components.map((c) => c.type)).toContain("ITCard");
123
+ });
124
+
125
+ it("publica props con tipo y obligatoriedad", () => {
126
+ const card = contract.components.find((c) => c.type === "ITCard");
127
+ expect(card?.props.some((p) => p.name === "title" && p.kind === "string")).toBe(true);
128
+ expect(card?.props.find((p) => p.name === "title")?.required).toBe(false);
129
+
130
+ const image = contract.components.find((c) => c.type === "ITImage");
131
+ expect(image?.props.find((p) => p.name === "src")?.required).toBe(true);
132
+ });
133
+
134
+ it("incluye enums y valores admitidos", () => {
135
+ const alert = contract.components.find((c) => c.type === "ITAlert");
136
+ const variant = alert?.props.find((p) => p.name === "variant");
137
+ expect(variant?.values).toEqual(["info", "success", "warning", "error"]);
138
+ });
139
+
140
+ it("da un nodo mínimo que funciona para cada componente", () => {
141
+ for (const component of contract.components) {
142
+ const report = validateSchema(screen(component.minimal as never));
143
+ expect(report.errors, `mínimo inválido para ${component.type}`).toHaveLength(0);
144
+ }
145
+ });
146
+
147
+ it("publica las exclusiones con su motivo", () => {
148
+ expect(contract.excluded.ITWysiwyg).toContain("HTML");
149
+ expect(contract.excluded.ITJsonRenderer).toContain("Recursión");
150
+ });
151
+
152
+ it("las reglas mencionan las prohibiciones clave", () => {
153
+ const rules = contract.rules.join(" ");
154
+ expect(rules).toContain("i18n");
155
+ expect(rules).toContain("HTML");
156
+ expect(rules).toContain("action");
157
+ });
158
+
159
+ it("se serializa para el prompt con todos los componentes", () => {
160
+ const text = formatVocabularyForPrompt(contract);
161
+ expect(text).toContain("ITCard");
162
+ expect(text).toContain("props:");
163
+ expect(text.split("\n").length).toBeGreaterThan(contract.components.length);
164
+ });
165
+ });
@@ -0,0 +1,158 @@
1
+ /**
2
+ * Contrato de vocabulario para el emisor.
3
+ *
4
+ * Para que un modelo (o un backend) acierte con el esquema, necesita saber qué
5
+ * `type` existen, qué props acepta cada uno, qué valores admiten los enums y cuál
6
+ * es el nodo mínimo que funciona. Ese contrato **se genera desde el catálogo**,
7
+ * no se escribe a mano: así el prompt nunca se desincroniza del motor.
8
+ *
9
+ * Se publica como artefacto versionado (`pnpm docs:json-renderer`) y se puede
10
+ * entregar tal cual en el prompt del sistema.
11
+ */
12
+
13
+ import type { Catalog, CatalogEntry, PropKind, PropSpec } from "../catalog/catalog.props";
14
+ import { axzyCatalog } from "../catalog/axzyCatalog";
15
+ import { CURRENT_SCHEMA_VERSION } from "../validation/validate";
16
+
17
+ /** Descripción de una prop en el contrato. */
18
+ export interface PropContract {
19
+ /** Nombre de la prop. */
20
+ name: string;
21
+ /** Tipo admitido. */
22
+ kind: PropKind;
23
+ /** `true` si el componente no funciona sin ella. */
24
+ required: boolean;
25
+ /** Valores admitidos en los enums. */
26
+ values?: string[];
27
+ /** Valor por defecto que aplica el motor. */
28
+ default?: unknown;
29
+ /** Nota para el emisor. */
30
+ doc?: string;
31
+ }
32
+
33
+ /** Descripción de un componente en el contrato. */
34
+ export interface ComponentContract {
35
+ /** Tipo a emitir (`ITCard`). */
36
+ type: string;
37
+ /** Para qué sirve, en una línea. */
38
+ summary?: string;
39
+ /** `true` si admite `children`. */
40
+ children: boolean;
41
+ /** `true` si admite el atajo `text`. */
42
+ text: boolean;
43
+ /** Props permitidas. */
44
+ props: PropContract[];
45
+ /** Props prohibidas (los nodos React no viajan en JSON). */
46
+ forbidden: string[];
47
+ /** Nodo mínimo que se puede emitir y funciona. */
48
+ minimal: Record<string, unknown>;
49
+ }
50
+
51
+ /** Contrato completo, listo para serializar. */
52
+ export interface VocabularyContract {
53
+ /** Identificador del formato. */
54
+ $schema: "axzy/vocabulary";
55
+ /** Versión del formato de pantalla al que corresponde. */
56
+ schemaVersion: number;
57
+ /** Reglas que el emisor debe respetar. */
58
+ rules: string[];
59
+ /** Componentes disponibles, ordenados por nombre. */
60
+ components: ComponentContract[];
61
+ /** Tipos excluidos y su motivo (el emisor no debe usarlos). */
62
+ excluded: Record<string, string>;
63
+ }
64
+
65
+ /** Reglas fijas que acompañan al contrato. */
66
+ const RULES: string[] = [
67
+ "Emite SOLO JSON válido con la forma: { $schema: \"axzy/screen\", version, id, content }.",
68
+ "`content` es un nodo o un array de nodos; cada nodo necesita `type` del catálogo.",
69
+ "Usa solo props declaradas para ese `type`; el resto se ignora y se avisa.",
70
+ "Los hijos se expresan con `children` (nodos) y los textos con `text`, nunca con props de nodo React.",
71
+ "Las acciones se declaran por nombre: `\"onClick\": { \"action\": \"nombre.registrado\" }`. No hay código en el JSON.",
72
+ "Prohibido: HTML crudo, URLs, fetch, expresiones JS y props como icon/actions/render.",
73
+ "Los textos visibles deben ser claves de i18n: { \"i18n\": \"namespace.clave\" }.",
74
+ ];
75
+
76
+ /** Construye el nodo mínimo que funciona para una entrada del catálogo. */
77
+ function buildMinimal(entry: CatalogEntry): Record<string, unknown> {
78
+ const props: Record<string, unknown> = {};
79
+ for (const [name, spec] of Object.entries(entry.props ?? {})) {
80
+ if (!spec.required) continue;
81
+ props[name] = sampleValue(name, spec);
82
+ }
83
+ const node: Record<string, unknown> = { type: entry.type };
84
+ if (Object.keys(props).length > 0) node.props = props;
85
+ if (entry.text) node.text = "Texto";
86
+ return node;
87
+ }
88
+
89
+ /** Genera un valor de ejemplo para una prop obligatoria. */
90
+ function sampleValue(name: string, spec: PropSpec): unknown {
91
+ if (spec.values?.length) return spec.values[0];
92
+ if (spec.kind === "number" || spec.kind === "integer") return 1;
93
+ if (spec.kind === "boolean") return false;
94
+ if (spec.kind === "i18n") return { i18n: `ejemplo.${name}` };
95
+ return name === "src" ? "/imagen.png" : `${name}`;
96
+ }
97
+
98
+ /** Deriva el contrato de vocabulario de un catálogo. */
99
+ export function buildVocabularyContract(
100
+ catalog: Catalog = axzyCatalog,
101
+ schemaVersion: number = CURRENT_SCHEMA_VERSION
102
+ ): VocabularyContract {
103
+ const components = Object.values(catalog.entries)
104
+ .map<ComponentContract>((entry) => ({
105
+ type: entry.type,
106
+ summary: entry.summary,
107
+ children: Boolean(entry.children),
108
+ text: Boolean(entry.text),
109
+ props: Object.entries(entry.props ?? {}).map(([name, spec]) => ({
110
+ name,
111
+ kind: spec.kind,
112
+ required: Boolean(spec.required),
113
+ values: spec.values ? [...spec.values] : undefined,
114
+ default: spec.default,
115
+ doc: spec.doc,
116
+ })),
117
+ forbidden: [...(entry.forbidden ?? [])],
118
+ minimal: buildMinimal(entry),
119
+ }))
120
+ .sort((a, b) => a.type.localeCompare(b.type));
121
+
122
+ return {
123
+ $schema: "axzy/vocabulary",
124
+ schemaVersion,
125
+ rules: RULES,
126
+ components,
127
+ excluded: catalog.excluded ?? {},
128
+ };
129
+ }
130
+
131
+ /** Serializa el contrato como texto compacto para el prompt. */
132
+ export function formatVocabularyForPrompt(contract: VocabularyContract): string {
133
+ const lines: string[] = [
134
+ `# Contrato de vocabulario AXZY (formato v${contract.schemaVersion})`,
135
+ "",
136
+ "Reglas:",
137
+ ...contract.rules.map((rule) => `- ${rule}`),
138
+ "",
139
+ "Componentes:",
140
+ ];
141
+
142
+ for (const component of contract.components) {
143
+ const props = component.props
144
+ .map((prop) => {
145
+ const parts = [`${prop.name}:${prop.kind}${prop.required ? "*" : ""}`];
146
+ if (prop.values?.length) parts.push(`(${prop.values.join("|")})`);
147
+ return parts.join(" ");
148
+ })
149
+ .join(", ");
150
+ lines.push(`- ${component.type}${component.summary ? ` — ${component.summary}` : ""}`);
151
+ if (props) lines.push(` props: ${props}`);
152
+ if (component.children) lines.push(" children: sí");
153
+ if (component.text) lines.push(" text: sí");
154
+ lines.push(` mínimo: ${JSON.stringify(component.minimal)}`);
155
+ }
156
+
157
+ return lines.join("\n");
158
+ }
@@ -0,0 +1,139 @@
1
+ /**
2
+ * Marca axzydev para el sitio (sandbox + portafolio).
3
+ *
4
+ * Vive en `src/sandbox/` a propósito: es chrome del sitio, **no** un componente
5
+ * publicado. Los assets son los SVG del kit (`axzydev-brand-kit/logos/svg`)
6
+ * copiados a `public/brand/`; no hay dependencias nuevas ni animaciones Lottie.
7
+ *
8
+ * El wordmark elige su variante según el tema resuelto: `logo-color` (tinta
9
+ * navy sobre claro) o `logo-dark` (texto claro sobre oscuro), de modo que el
10
+ * logo se lee en ambos modos sin recolorear nada a mano.
11
+ *
12
+ * @example
13
+ * ```tsx
14
+ * <AxzyLogo variante="icono" tamano={24} /> // navbar / topbar
15
+ * <AxzyLogo variante="wordmark" tamano={260} /> // hero / footer
16
+ * <AxzyLogo variante="loader" tamano={40} etiqueta="Cargando" />
17
+ * ```
18
+ */
19
+
20
+ import { useITThemeSafe } from "@/theme/theme-context";
21
+
22
+ /** Forma del logo: solo ícono, wordmark completo o loader de marca. */
23
+ export type AxzyLogoVariante = "icono" | "wordmark" | "loader";
24
+
25
+ /** Props de `AxzyLogo`. */
26
+ export interface AxzyLogoProps {
27
+ /** Forma del logo. @default "wordmark" */
28
+ variante?: AxzyLogoVariante;
29
+ /** Ancho en píxeles. El alto se calcula solo por la proporción del SVG. @default 28 (icono/loader) · 240 (wordmark) */
30
+ tamano?: number;
31
+ /** Fuerza el tono del wordmark en lugar de seguir el tema. */
32
+ tono?: "claro" | "oscuro";
33
+ /**
34
+ * Texto alternativo del logo. Por defecto `"axzydev"` para el wordmark y
35
+ * `""` (decorativo) para el ícono, que suele ir acompañado del nombre.
36
+ */
37
+ etiqueta?: string;
38
+ /** Clases del elemento raíz. */
39
+ className?: string;
40
+ /** Anima el ícono con un latido suave cuando `variante="loader"`. Respeta `prefers-reduced-motion`. */
41
+ latido?: boolean;
42
+ }
43
+
44
+ /** Ruta del asset según la forma y el tono. */
45
+ const ASSETS: Record<AxzyLogoVariante, { claro: string; oscuro: string }> = {
46
+ icono: { claro: "/brand/icon.svg", oscuro: "/brand/icon.svg" },
47
+ wordmark: { claro: "/brand/logo-color.svg", oscuro: "/brand/logo-dark.svg" },
48
+ loader: { claro: "/brand/icon.svg", oscuro: "/brand/icon.svg" },
49
+ };
50
+
51
+ /** Alto por defecto según la forma (el wordmark es 621×208). */
52
+ const ALTO_POR_TAMANO: Record<AxzyLogoVariante, number> = {
53
+ icono: 28,
54
+ wordmark: 240,
55
+ loader: 40,
56
+ };
57
+
58
+ /**
59
+ * Marca axzydev del sitio: SVG del kit, conmutada por tema y sin dependencias.
60
+ *
61
+ * @example
62
+ * ```tsx
63
+ * <AxzyLogo variante="icono" tamano={24} />
64
+ * <AxzyLogo variante="wordmark" tamano={260} className="mb-6" />
65
+ * ```
66
+ */
67
+ export const AxzyLogo = ({
68
+ variante = "wordmark",
69
+ tamano,
70
+ tono,
71
+ etiqueta,
72
+ className = "",
73
+ latido = true,
74
+ }: AxzyLogoProps) => {
75
+ const contexto = useITThemeSafe();
76
+ const modo = tono ?? (contexto?.resolvedTheme === "dark" ? "oscuro" : "claro");
77
+ const ancho = tamano ?? ALTO_POR_TAMANO[variante];
78
+
79
+ // El ícono mide 128×128; el wordmark, 621×208. El alto sale de la proporción.
80
+ const alto = variante === "icono" || variante === "loader" ? ancho : Math.round((ancho * 208) / 621);
81
+ const decorativo = etiqueta === "" || (etiqueta === undefined && variante !== "wordmark");
82
+ const alt = etiqueta ?? (variante === "wordmark" ? "axzydev" : "");
83
+
84
+ return (
85
+ <img
86
+ src={ASSETS[variante][modo]}
87
+ width={ancho}
88
+ height={alto}
89
+ alt={alt}
90
+ aria-hidden={decorativo || undefined}
91
+ draggable={false}
92
+ className={[
93
+ "select-none",
94
+ variante === "loader" && latido ? "it-logo-latido" : "",
95
+ className,
96
+ ]
97
+ .filter(Boolean)
98
+ .join(" ")}
99
+ style={{ width: ancho, height: alto }}
100
+ />
101
+ );
102
+ };
103
+
104
+ /**
105
+ * Loader de marca: el ícono latiendo con un rótulo mono al lado.
106
+ *
107
+ * Es la versión estática del `loader` del kit (que allí es una animación Lottie)
108
+ * y se apoya en `ITLoader` para la etiqueta y el rol accesible.
109
+ *
110
+ * @example
111
+ * ```tsx
112
+ * <AxzyLoader etiqueta="Cargando componentes" />
113
+ * ```
114
+ */
115
+ export const AxzyLoader = ({
116
+ etiqueta,
117
+ tamano = 32,
118
+ className = "",
119
+ }: {
120
+ /** Texto que acompaña al ícono. */
121
+ etiqueta?: string;
122
+ /** Ancho del ícono en píxeles. @default 32 */
123
+ tamano?: number;
124
+ /** Clases del contenedor. */
125
+ className?: string;
126
+ }) => (
127
+ <span
128
+ role="status"
129
+ aria-live="polite"
130
+ className={`inline-flex items-center gap-3 ${className}`}
131
+ >
132
+ <AxzyLogo variante="loader" tamano={tamano} />
133
+ {etiqueta ? (
134
+ <span className="font-mono text-[11px] uppercase tracking-[0.14em] text-slate-500 dark:text-slate-400">
135
+ {etiqueta}
136
+ </span>
137
+ ) : null}
138
+ </span>
139
+ );
@@ -0,0 +1,40 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { DEFAULT_AXZY_CONFIG, mergeAxzyConfig } from "./axzyConfig";
3
+
4
+ describe("axzy.json", () => {
5
+ it("sin archivo devuelve los defaults", () => {
6
+ expect(mergeAxzyConfig(null)).toEqual(DEFAULT_AXZY_CONFIG);
7
+ expect(mergeAxzyConfig(undefined)).toEqual(DEFAULT_AXZY_CONFIG);
8
+ });
9
+
10
+ it("acepta los valores válidos", () => {
11
+ const config = mergeAxzyConfig({ radius: 0, density: 1.4, shadow: 3, appearance: "flat", showFab: true });
12
+ expect(config.radius).toBe(0);
13
+ expect(config.density).toBe(1.4);
14
+ expect(config.shadow).toBe(3);
15
+ expect(config.appearance).toBe("flat");
16
+ expect(config.showFab).toBe(true);
17
+ });
18
+
19
+ it("recorta fuera de rango en vez de romper", () => {
20
+ const config = mergeAxzyConfig({ density: 9, radius: 99, shadow: -3 });
21
+ expect(config.density).toBe(1.5);
22
+ expect(config.radius).toBe(24);
23
+ expect(config.shadow).toBe(0);
24
+ });
25
+
26
+ it("ignora tipos raros y conserva el resto", () => {
27
+ const config = mergeAxzyConfig({ density: "alta", radius: null, appearance: "neón", theme: { primary: 42, danger: "#000" } });
28
+ expect(config.density).toBe(DEFAULT_AXZY_CONFIG.density);
29
+ expect(config.radius).toBe(DEFAULT_AXZY_CONFIG.radius);
30
+ expect(config.appearance).toBe("soft");
31
+ expect(config.theme).toEqual({ danger: "#000" });
32
+ });
33
+
34
+ it("mezcla la paleta con los defaults sin perder claves", () => {
35
+ const config = mergeAxzyConfig({ theme: { primary: "#111", table: { rowBg: "#fff" } } });
36
+ expect(config.theme.primary).toBe("#111");
37
+ expect(config.theme.table).toEqual({ rowBg: "#fff" });
38
+ expect(config.theme.danger).toBeUndefined();
39
+ });
40
+ });