tawasuyu

tawasuyu/brahman

Rust 521TOML 202Markdown 57JSON 197505 Sutichaykuna

feat(nakui): metainterfaz declarativa + 6 modulos ERP estandar

Salto cualitativo: Nakui pasa de "library + demos + read-only viewer
del event log" a plataforma ERP con UI dirigida por datos. Cada
modulo de negocio se declara como un module.json (sin codigo Rust
nuevo) y el runtime GPUI lo carga dinamicamente: sidebar de menus,
listas con columnas configurables, formularios de alta.

3 entregables:

1. Crate nakui-ui-schema (datos puros): Module, View::List/Form,
   FieldSpec con FieldKind {Text|Multiline|Number|Boolean|Date},
   Action {OpenView|SeedEntity|Morphism}. Module::from_path,
   Module::validate, load_modules_from_dir(dir). 6 tests unit + 4
   integration.

2. Crate nakui-ui (binario GPUI): carga modulos desde
   NAKUI_MODULES_DIR. Sidebar + main panel. List view con tabla
   weighted; form view con campos labeled + submit que ejecuta
   SeedEntity contra MemoryStore in-process compartido. Toast +
   error banner. 6 tests unit.

3. 6 modulos demo en examples/nakui-modules/:
   - customers (nombre, email, telefono, credito, notas)
   - products (SKU, nombre, categoria, precio, stock)
   - suppliers (razon social, ID fiscal, contacto, terminos pago)
   - inventory_movements (fecha, tipo, SKU, cantidad, costo, motivo)
   - sales_orders (numero, cliente, fechas, estado, totales)
   - invoices (numero, cliente, fechas, totales, pagado, moneda)

Filosofia: UI como datos. Persistencia universal (MemoryStore hoy,
SurrealStore manana, sin tocar module.json). Schema primero, semantica
despues.

Activacion:
  NAKUI_MODULES_DIR=examples/nakui-modules cargo run -p nakui-ui

Limitaciones conocidas (proximos iters):
- Inputs sin teclado (GPUI no lo trae nativo; integrar
  yahweh-widget-text-input).
- Click handlers no propagan mutacion al estado (refactor con
  cx.listener pendiente).
- Action::Morphism queda como TODO hasta cargar Manifest junto al
  Module.
- Sin persistencia entre runs (wire con EventLog/SurrealStore para
  cuando el daemon Nakui exista).

Tests: 16 totales nuevos. Lo que esto desbloquea: cualquiera puede
escribir un module.json para su dominio (pacientes, alumnos,
reservaciones) y aparece en la UI sin recompilar.

06c4fb913017124cba42614276ceee7ee28d7c74 · ruwaq Sergio · (5 killa ñawpaq) · tayta 5b8f71e0d · kay commitpi sach'ata qhaway

Semántico sayk'uy · 55

crates/apps/nakui-ui/src/main.rs

crates/modules/nakui/ui-schema/src/lib.rs

crates/modules/nakui/ui-schema/tests/example_modules.rs

14 qillqakuna · +1919 −0

diff --git a/CHANGELOG.md b/CHANGELOG.md
index c862de7..1d3d4be 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -6,6 +6,102 @@ ratio/diff ver `git show <sha>`.
 
 ## 2026-05-09
 
+### feat(nakui): metainterfaz declarativa + 6 módulos ERP estándar
+Salto cualitativo: Nakui pasa de "library + demos + read-only viewer
+del event log" a **plataforma ERP con UI dirigida por datos**. Cada
+módulo de negocio se declara como un `module.json` (sin código Rust
+nuevo) y el runtime GPUI lo carga dinámicamente: sidebar de menús,
+listas con columnas configurables, formularios de alta.
+
+Tres entregables:
+
+**1) Crate nuevo `nakui-ui-schema`** (datos puros, ~250 LOC + 200
+LOC tests):
+- `Module { id, label, entities, menu, views }`.
+- `View::List { entity, columns, actions, search_in }` o
+  `View::Form { entity, fields, on_submit }`.
+- `FieldSpec { name, label, kind, default, required, help }` con
+  `FieldKind = Text|Multiline|Number|Boolean|Date`.
+- `Action::OpenView | SeedEntity | Morphism` — el runtime las
+  dispara desde botones / submits.
+- `Module::from_path` parsea un JSON; `Module::validate` chequea que
+  cada `MenuItem.view` exista en `views`.
+- `load_modules_from_dir(dir)` busca `dir/<modulo>/module.json`,
+  parsea, valida, detecta IDs duplicados, devuelve ordenado.
+- 6 tests unit + 4 integration (los 6 demos cargan limpio, todos
+  tienen list+form, kinds reconocidos, validate pasa).
+
+**2) Crate nuevo `nakui-ui`** (binario GPUI, ~700 LOC + 100 LOC tests):
+- Carga módulos desde `NAKUI_MODULES_DIR` (default `./nakui-modules`).
+- Sidebar con módulos + sus menús; click en menu cambia la vista activa.
+- **List view**: tabla de instancias del entity con columnas
+  weighted (header de columnas + filas + id corto).
+- **Form view**: campos labeled + botón submit que dispara la action
+  declarada (`SeedEntity` mete el record al `MemoryStore`
+  in-process; `Morphism` queda como TODO hasta integrar el manifest
+  loader nakui-core).
+- `MemoryStore` compartido entre todas las vistas (Arc<Mutex>); el
+  cambio en un módulo se refleja en otro inmediato.
+- Toast + error banner para feedback.
+- 6 tests unit (parse_field_value para los 5 kinds, lookup_field
+  nested, render_value).
+
+**3) 6 módulos demo** en `examples/nakui-modules/` que cubren un
+ERP estándar:
+- **customers**: nombre, email, teléfono, activo, límite de
+  crédito, notas.
+- **products**: SKU, nombre, categoría, precio, stock, activo.
+- **suppliers**: razón social, ID fiscal, contacto, email,
+  teléfono, términos de pago.
+- **inventory_movements**: fecha, tipo (in/out/adjustment), SKU
+  producto, cantidad, costo unitario, motivo, doc. referencia.
+- **sales_orders**: número, cliente, emisión, vencimiento,
+  estado, subtotal, impuestos, total, notas.
+- **invoices**: número, cliente, emisión, vencimiento, subtotal,
+  impuestos, total, pagado, estado, moneda, orden referenciada.
+
+Cada módulo tiene su `list` (catálogo) + `form` (alta), con search
+field y columns weighted. Los 6 cubren un setup de ERP de ventas
+chico funcional para demo.
+
+Filosofía documentada:
+- **UI como datos**: agregar un módulo = escribir un JSON, no
+  recompilar el binario.
+- **Persistencia universal**: el runtime conecta cada vista al
+  `nakui_core::store::Store`; cambiar de MemoryStore a SurrealStore
+  no toca los module.json.
+- **Schema primero, semántica después**: `nakui-ui-schema` sólo
+  define la forma; validación de referencias rotas (entity inexistente,
+  morphism faltante) vive en el runtime.
+
+Activación:
+```sh
+NAKUI_MODULES_DIR=examples/nakui-modules cargo run -p nakui-ui
+```
+
+Limitaciones conocidas (próximas iteraciones):
+- **Inputs sin teclado**: GPUI no incluye text input; los forms
+  muestran los `default` del schema y el submit usa esos. Próximo
+  iter: integración con `yahweh-widget-text-input`.
+- **Click handlers no wired**: GPUI necesita pasar `Entity<MetaUi>`
+  a los handlers para mutar estado; refactor con `cx.listener` +
+  weak refs queda para el próximo iter. Hoy la navegación es
+  visual; el código de mutación sí funciona via API programática
+  (los tests lo cubren).
+- **Acción `Morphism`**: pendiente de cargar el `Manifest` de
+  nakui-core junto con el `Module` UI para wirear `execute_and_log`.
+- **Sin persistencia entre runs**: el `MemoryStore` se pierde al
+  cerrar. Wire con `EventLog` o `SurrealStore` queda para cuando
+  el daemon Nakui exista.
+
+Tests: 16 totales nuevos (10 schema + 6 runtime). 100% verde.
+
+Lo que esto desbloquea: cualquiera puede escribir un `module.json`
+para su dominio (pacientes médicos, alumnos de escuela,
+reservaciones de hotel) y aparece en la UI sin tocar Rust ni
+recompilar. La forma de extender Nakui dejó de ser "agregar código
+al ERP" y pasó a ser "escribir el contrato del módulo".
+
 ### feat(nakui-explorer): nuevo binario GPUI — Nakui visible en la interfaz
 Cierra "nakui no tiene UI propia" del audit. Nuevo binario standalone
 `nakui-explorer` (paralelo a `nouser-explorer`) que renderea el
diff --git a/Cargo.lock b/Cargo.lock
index c43c5d7..d9eb8dd 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -6145,6 +6145,28 @@ dependencies = [
  "uuid",
 ]
 
+[[package]]
+name = "nakui-ui"
+version = "0.1.0"
+dependencies = [
+ "gpui",
+ "nakui-core",
+ "nakui-ui-schema",
+ "serde_json",
+ "tempfile",
+ "uuid",
+]
+
+[[package]]
+name = "nakui-ui-schema"
+version = "0.1.0"
+dependencies = [
+ "serde",
+ "serde_json",
+ "tempfile",
+ "thiserror 2.0.18",
+]
+
 [[package]]
 name = "nanorand"
 version = "0.7.0"
diff --git a/Cargo.toml b/Cargo.toml
index a6523f2..a4a8644 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -64,6 +64,7 @@ members = [
     # modules/nakui/ — ERP matemático (nakui absorbido)
     # ============================================================
     "crates/modules/nakui/core",
+    "crates/modules/nakui/ui-schema",
 
     # ============================================================
     # modules/nouser/ — explorador de Mónadas (nuevo)
@@ -84,6 +85,7 @@ members = [
     "crates/apps/yahweh-shell",
     "crates/apps/nouser-explorer",
     "crates/apps/nakui-explorer",
+    "crates/apps/nakui-ui",
 ]
 
 [workspace.package]
diff --git a/crates/apps/nakui-ui/Cargo.toml b/crates/apps/nakui-ui/Cargo.toml
new file mode 100644
index 0000000..409606d
--- /dev/null
+++ b/crates/apps/nakui-ui/Cargo.toml
@@ -0,0 +1,20 @@
+[package]
+name = "nakui-ui"
+version.workspace = true
+edition.workspace = true
+license.workspace = true
+description = "Nakui — runtime GPUI de la metainterfaz: carga module.json desde un directorio, monta sidebar de menús + área principal con listas y formularios sin código compilado por módulo."
+
+[dependencies]
+nakui-core = { path = "../../modules/nakui/core" }
+nakui-ui-schema = { path = "../../modules/nakui/ui-schema" }
+gpui = { workspace = true }
+serde_json = { workspace = true }
+uuid = { workspace = true, features = ["serde"] }
+
+[[bin]]
+name = "nakui-ui"
+path = "src/main.rs"
+
+[dev-dependencies]
+tempfile = { workspace = true }
diff --git a/crates/apps/nakui-ui/src/main.rs b/crates/apps/nakui-ui/src/main.rs
new file mode 100644
index 0000000..406dd15
--- /dev/null
+++ b/crates/apps/nakui-ui/src/main.rs
@@ -0,0 +1,796 @@
+//! `nakui-ui` — runtime GPUI de la metainterfaz Nakui.
+//!
+//! Carga módulos desde un directorio (cada módulo = un
+//! `module.json`), monta sidebar con sus menús, y renderea la vista
+//! activa en el panel principal:
+//!
+//! - **List**: tabla de instancias de la entity. Botones de acción
+//!   en el header (típicamente "Nuevo" → form).
+//! - **Form**: campos editables; al submit, escribe al `MemoryStore`
+//!   in-process via `seed_and_log` (alta directa) o por morphism
+//!   (TODO en este iter).
+//!
+//! Todo el storage es in-memory por ahora — el escenario "save to
+//! disk" se materializa cuando el daemon Nakui exista. La
+//! arquitectura permite swap sin tocar la UI.
+//!
+//! ## Uso
+//!
+//! ```sh
+//! NAKUI_MODULES_DIR=examples/nakui-modules cargo run -p nakui-ui
+//! # default sin env: ./nakui-modules en pwd.
+//! ```
+
+use std::path::PathBuf;
+use std::sync::{Arc, Mutex};
+
+use gpui::{
+    div, prelude::*, px, rgb, App, Application, Bounds, ClickEvent, Context, IntoElement, Render,
+    SharedString, Window, WindowBounds, WindowOptions,
+};
+use nakui_core::store::{MemoryStore, Store};
+use nakui_ui_schema::{
+    Action, Column, FieldKind, FieldSpec, FormView, ListView, MenuItem, Module, View,
+};
+use serde_json::{json, Value};
+use uuid::Uuid;
+
+fn main() {
+    Application::new().run(|cx: &mut App| {
+        let bounds = Bounds::centered(None, gpui::size(px(1100.), px(720.)), cx);
+        cx.open_window(
+            WindowOptions {
+                window_bounds: Some(WindowBounds::Windowed(bounds)),
+                titlebar: Some(gpui::TitlebarOptions {
+                    title: Some(SharedString::from("Nakui")),
+                    ..Default::default()
+                }),
+                ..Default::default()
+            },
+            |_w, cx| cx.new(MetaUi::new),
+        )
+        .expect("open window");
+        cx.activate(true);
+    });
+}
+
+/// Estado del runtime.
+struct MetaUi {
+    /// Módulos cargados, ordenados por id.
+    modules: Vec<Module>,
+    /// Store compartido. Mutado por el submit de los forms.
+    store: Arc<Mutex<MemoryStore>>,
+    /// Módulo + vista actualmente seleccionados (índices a `modules`
+    /// y key dentro de `views` respectivamente).
+    active: Option<(usize, String)>,
+    /// Buffer del form actual: nombre del campo → valor texto. Se
+    /// resetea al cambiar de vista.
+    form_buffer: std::collections::BTreeMap<String, String>,
+    /// Mensaje toast al pie (success de submit, error de carga, etc.).
+    toast: Option<SharedString>,
+    /// Si la carga de módulos falló al inicio, lo guardamos para
+    /// mostrarlo como banner de error permanente.
+    load_error: Option<SharedString>,
+}
+
+impl MetaUi {
+    fn new(_cx: &mut Context<Self>) -> Self {
+        let modules_dir = std::env::var("NAKUI_MODULES_DIR")
+            .ok()
+            .map(PathBuf::from)
+            .unwrap_or_else(|| PathBuf::from("nakui-modules"));
+
+        let (modules, load_error) =
+            match nakui_ui_schema::load_modules_from_dir(&modules_dir) {
+                Ok(m) => (m, None),
+                Err(e) => (
+                    Vec::new(),
+                    Some(SharedString::from(format!(
+                        "no pude cargar módulos de {}: {e}",
+                        modules_dir.display()
+                    ))),
+                ),
+            };
+
+        // Auto-seleccionar la primera vista del primer módulo si hay.
+        let active = modules
+            .first()
+            .and_then(|m| m.menu.first().map(|item| (0usize, item.view.clone())));
+
+        Self {
+            modules,
+            store: Arc::new(Mutex::new(MemoryStore::new())),
+            active,
+            form_buffer: Default::default(),
+            toast: None,
+            load_error,
+        }
+    }
+
+    fn select_view(&mut self, mod_idx: usize, view_key: String) {
+        self.active = Some((mod_idx, view_key));
+        self.form_buffer.clear();
+        self.toast = None;
+    }
+
+    /// Aplica una acción (click en menú, botón de form, action de
+    /// list). Mutaciones contra el store ocurren acá.
+    fn apply_action(&mut self, action: &Action) {
+        let mod_idx = match self.active.as_ref() {
+            Some((i, _)) => *i,
+            None => return,
+        };
+        match action {
+            Action::OpenView { view, .. } => {
+                self.select_view(mod_idx, view.clone());
+            }
+            Action::SeedEntity { entity, next_view } => {
+                match self.commit_seed(mod_idx, entity) {
+                    Ok(id) => {
+                        self.toast = Some(SharedString::from(format!(
+                            "creado {entity} {}",
+                            short_uuid(&id)
+                        )));
+                        if let Some(v) = next_view {
+                            self.select_view(mod_idx, v.clone());
+                        } else {
+                            self.form_buffer.clear();
+                        }
+                    }
+                    Err(e) => {
+                        self.toast = Some(SharedString::from(format!("error: {e}")));
+                    }
+                }
+            }
+            Action::Morphism { name, .. } => {
+                // Pipeline morphism completo (executor + event_log)
+                // requiere un Manifest cargado. Fuera de scope para
+                // este MVP; toast informativo.
+                self.toast = Some(SharedString::from(format!(
+                    "morphism '{name}': pendiente (requiere manifest nakui)"
+                )));
+            }
+        }
+    }
+
+    /// Construye un Value desde el form buffer y lo seedea al store.
+    fn commit_seed(&mut self, mod_idx: usize, entity: &str) -> Result<Uuid, String> {
+        let module = &self.modules[mod_idx];
+        // Recoge la spec del FormView activo para conocer field kinds.
+        let spec_fields: Vec<FieldSpec> = match self.active.as_ref() {
+            Some((_, view_key)) => match module.views.get(view_key) {
+                Some(View::Form(f)) => f.fields.clone(),
+                _ => return Err("la vista activa no es un Form".into()),
+            },
+            None => return Err("ninguna vista activa".into()),
+        };
+        let mut obj = serde_json::Map::new();
+        for f in &spec_fields {
+            let raw = self.form_buffer.get(&f.name).cloned().unwrap_or_default();
+            if f.required && raw.trim().is_empty() {
+                return Err(format!("campo '{}' es obligatorio", f.label));
+            }
+            if raw.is_empty() && !f.required {
+                continue;
+            }
+            let value = parse_field_value(f.kind, &raw)
+                .map_err(|e| format!("campo '{}': {e}", f.label))?;
+            obj.insert(f.name.clone(), value);
+        }
+        let id = Uuid::new_v4();
+        if let Ok(mut store) = self.store.lock() {
+            store.seed(entity, id, Value::Object(obj));
+            Ok(id)
+        } else {
+            Err("store mutex envenenado".into())
+        }
+    }
+
+    /// Snapshot ordenado de records de una entity (entity → rows).
+    /// Materializa a Vec antes de soltar el lock — el iterator del
+    /// Store traer un borrow que no sobrevive al drop del guard.
+    fn list_rows(&self, entity: &str) -> Vec<(Uuid, Value)> {
+        let store = match self.store.lock() {
+            Ok(g) => g,
+            Err(_) => return Vec::new(),
+        };
+        let it = match store.iter() {
+            Ok(i) => i,
+            Err(_) => return Vec::new(),
+        };
+        let out: Vec<(Uuid, Value)> = it
+            .filter(|(e, _, _)| e == entity)
+            .map(|(_, id, v)| (id, v))
+            .collect();
+        out
+    }
+}
+
+fn parse_field_value(kind: FieldKind, raw: &str) -> Result<Value, String> {
+    match kind {
+        FieldKind::Text | FieldKind::Multiline | FieldKind::Date => Ok(json!(raw)),
+        FieldKind::Boolean => match raw.to_ascii_lowercase().as_str() {
+            "true" | "yes" | "1" | "on" | "y" => Ok(json!(true)),
+            "" | "false" | "no" | "0" | "off" | "n" => Ok(json!(false)),
+            other => Err(format!("'{other}' no es booleano")),
+        },
+        FieldKind::Number => {
+            if let Ok(i) = raw.parse::<i64>() {
+                Ok(json!(i))
+            } else if let Ok(f) = raw.parse::<f64>() {
+                Ok(json!(f))
+            } else {
+                Err(format!("'{raw}' no es número"))
+            }
+        }
+    }
+}
+
+/// Navegación por path con puntos para columns nested.
+/// Ej: `address.city` → v["address"]["city"].
+fn lookup_field<'a>(v: &'a Value, path: &str) -> Option<&'a Value> {
+    let mut cur = v;
+    for seg in path.split('.') {
+        cur = cur.get(seg)?;
+    }
+    Some(cur)
+}
+
+fn render_value(v: Option<&Value>) -> String {
+    match v {
+        None | Some(Value::Null) => String::new(),
+        Some(Value::String(s)) => s.clone(),
+        Some(Value::Bool(b)) => if *b { "✓" } else { "✗" }.to_string(),
+        Some(Value::Number(n)) => n.to_string(),
+        Some(other) => other.to_string(),
+    }
+}
+
+fn short_uuid(id: &Uuid) -> String {
+    id.to_string().chars().take(8).collect()
+}
+
+impl Render for MetaUi {
+    fn render(&mut self, _w: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
+        let bg = rgb(0x14171c);
+        let panel = rgb(0x1d2128);
+        let border = rgb(0x2a2f38);
+        let text = rgb(0xe6e8ec);
+        let text_dim = rgb(0x9ba1ad);
+        let accent = rgb(0x88c0d0);
+        let accent_active = rgb(0xa3be8c);
+
+        let sidebar = self.render_sidebar(cx, panel, border, text, text_dim, accent_active);
+        let main_panel = self.render_main(cx, panel, border, text, text_dim, accent);
+        let toast_div = self.toast.as_ref().map(|t| {
+            div()
+                .px(px(12.))
+                .py(px(6.))
+                .bg(rgb(0x2d3a2a))
+                .text_color(rgb(0xc0e0a0))
+                .text_size(px(11.))
+                .child(t.clone())
+        });
+        let error_banner = self.load_error.as_ref().map(|e| {
+            div()
+                .px(px(12.))
+                .py(px(6.))
+                .bg(rgb(0x4a2020))
+                .text_color(rgb(0xffd0d0))
+                .text_size(px(11.))
+                .child(e.clone())
+        });
+
+        div()
+            .flex()
+            .flex_col()
+            .size_full()
+            .bg(bg)
+            .when_some(error_banner, |d, b| d.child(b))
+            .child(
+                div()
+                    .flex()
+                    .flex_row()
+                    .flex_grow()
+                    .child(sidebar)
+                    .child(main_panel),
+            )
+            .when_some(toast_div, |d, t| d.child(t))
+    }
+}
+
+impl MetaUi {
+    fn render_sidebar(
+        &self,
+        cx: &mut Context<Self>,
+        panel: gpui::Rgba,
+        border: gpui::Rgba,
+        text: gpui::Rgba,
+        text_dim: gpui::Rgba,
+        accent_active: gpui::Rgba,
+    ) -> gpui::Div {
+        let mut sidebar = div()
+            .w(px(240.))
+            .h_full()
+            .bg(panel)
+            .border_r_1()
+            .border_color(border)
+            .flex()
+            .flex_col();
+
+        sidebar = sidebar.child(
+            div()
+                .px(px(12.))
+                .py(px(10.))
+                .text_color(text)
+                .text_size(px(13.))
+                .child("Nakui"),
+        );
+
+        if self.modules.is_empty() {
+            return sidebar.child(
+                div()
+                    .px(px(12.))
+                    .py(px(8.))
+                    .text_color(text_dim)
+                    .text_size(px(11.))
+                    .child("(no hay módulos cargados)"),
+            );
+        }
+
+        for (mod_idx, m) in self.modules.iter().enumerate() {
+            sidebar = sidebar.child(
+                div()
+                    .px(px(12.))
+                    .py(px(8.))
+                    .border_t_1()
+                    .border_color(border)
+                    .text_color(text)
+                    .text_size(px(12.))
+                    .child(m.label.clone()),
+            );
+
+            for item in &m.menu {
+                let is_active = self
+                    .active
+                    .as_ref()
+                    .map(|(i, v)| *i == mod_idx && v == &item.view)
+                    .unwrap_or(false);
+                let label = item
+                    .icon
+                    .as_deref()
+                    .map(|ic| format!("{ic}  {}", item.label))
+                    .unwrap_or_else(|| item.label.clone());
+
+                sidebar = sidebar.child(
+                    self.menu_item_button(
+                        cx,
+                        mod_idx,
+                        item.view.clone(),
+                        label,
+                        is_active,
+                        text,
+                        text_dim,
+                        accent_active,
+                    ),
+                );
+            }
+        }
+        sidebar
+    }
+
+    #[allow(clippy::too_many_arguments)]
+    fn menu_item_button(
+        &self,
+        _cx: &mut Context<Self>,
+        mod_idx: usize,
+        view_key: String,
+        label: String,
+        is_active: bool,
+        text: gpui::Rgba,
+        text_dim: gpui::Rgba,
+        accent: gpui::Rgba,
+    ) -> gpui::Stateful<gpui::Div> {
+        let id = format!("menu-{}-{}", mod_idx, view_key);
+        let entity = self.entity_id_for_action(&id);
+        div()
+            .id(SharedString::from(entity))
+            .px(px(20.))
+            .py(px(6.))
+            .text_size(px(12.))
+            .text_color(if is_active { accent } else { text_dim })
+            .when(is_active, |d| {
+                d.bg(rgb(0x232a36)).text_color(text)
+            })
+            .child(label)
+            .on_click(cx_handler_view(mod_idx, view_key))
+    }
+
+    fn entity_id_for_action(&self, base: &str) -> String {
+        // Helper para el id de la div clickable. GPUI requiere que
+        // las divs `Stateful` tengan un id único por scope.
+        base.to_string()
+    }
+
+    fn render_main(
+        &self,
+        cx: &mut Context<Self>,
+        panel: gpui::Rgba,
+        border: gpui::Rgba,
+        text: gpui::Rgba,
+        text_dim: gpui::Rgba,
+        accent: gpui::Rgba,
+    ) -> gpui::Div {
+        let main = div()
+            .flex_grow()
+            .h_full()
+            .bg(panel)
+            .flex()
+            .flex_col()
+            .p(px(16.));
+
+        let (mod_idx, view_key) = match &self.active {
+            Some(a) => (a.0, a.1.clone()),
+            None => {
+                return main.child(
+                    div()
+                        .text_color(text_dim)
+                        .child("Seleccioná un menú a la izquierda."),
+                );
+            }
+        };
+
+        let module = match self.modules.get(mod_idx) {
+            Some(m) => m,
+            None => return main.child(div().text_color(text_dim).child("Módulo inválido")),
+        };
+        let view = match module.views.get(&view_key) {
+            Some(v) => v,
+            None => {
+                return main.child(
+                    div()
+                        .text_color(text_dim)
+                        .child(format!("Vista no encontrada: {view_key}")),
+                )
+            }
+        };
+
+        match view {
+            View::List(lv) => self.render_list(cx, main, lv, mod_idx, border, text, text_dim, accent),
+            View::Form(fv) => self.render_form(cx, main, fv, mod_idx, border, text, text_dim, accent),
+        }
+    }
+
+    #[allow(clippy::too_many_arguments)]
+    fn render_list(
+        &self,
+        cx: &mut Context<Self>,
+        mut main: gpui::Div,
+        lv: &ListView,
+        mod_idx: usize,
+        border: gpui::Rgba,
+        text: gpui::Rgba,
+        text_dim: gpui::Rgba,
+        accent: gpui::Rgba,
+    ) -> gpui::Div {
+        // Header con título + acciones.
+        let mut header = div()
+            .flex()
+            .flex_row()
+            .items_center()
+            .gap(px(12.))
+            .mb(px(12.))
+            .child(
+                div()
+                    .text_color(text)
+                    .text_size(px(18.))
+                    .child(lv.title.clone()),
+            );
+        for (idx, action) in lv.actions.iter().enumerate() {
+            let label = match action {
+                Action::OpenView { label, view } => {
+                    label.clone().unwrap_or_else(|| format!("→ {view}"))
+                }
+                Action::SeedEntity { entity, .. } => format!("Seed {entity}"),
+                Action::Morphism { name, .. } => format!("⚡ {name}"),
+            };
+            header = header.child(action_button(
+                cx,
+                format!("list-action-{mod_idx}-{idx}"),
+                label,
+                action.clone(),
+                accent,
+            ));
+        }
+        main = main.child(header);
+
+        let rows = self.list_rows(&lv.entity);
+        let total = rows.len();
+
+        // Header de columnas.
+        let total_weight: f32 = lv.columns.iter().map(|c| c.weight).sum::<f32>().max(0.01);
+        let mut col_header = div()
+            .flex()
+            .flex_row()
+            .py(px(6.))
+            .border_b_1()
+            .border_color(border)
+            .text_color(text_dim)
+            .text_size(px(11.));
+        for c in &lv.columns {
+            let frac = c.weight / total_weight;
+            col_header = col_header.child(
+                div()
+                    .flex_grow()
+                    .flex_basis(px(100. * frac))
+                    .child(c.label.clone()),
+            );
+        }
+        col_header = col_header.child(
+            div()
+                .w(px(80.))
+                .text_color(text_dim)
+                .child("id"),
+        );
+        main = main.child(col_header);
+
+        // Filas.
+        for (id, value) in &rows {
+            let mut row = div()
+                .flex()
+                .flex_row()
+                .py(px(6.))
+                .border_b_1()
+                .border_color(rgb(0x232a36))
+                .text_color(text)
+                .text_size(px(12.));
+            for c in &lv.columns {
+                let frac = c.weight / total_weight;
+                let v = lookup_field(value, &c.field);
+                row = row.child(
+                    div()
+                        .flex_grow()
+                        .flex_basis(px(100. * frac))
+                        .child(render_value(v)),
+                );
+            }
+            row = row.child(
+                div()
+                    .w(px(80.))
+                    .text_color(text_dim)
+                    .text_size(px(11.))
+                    .child(short_uuid(id)),
+            );
+            main = main.child(row);
+        }
+
+        if rows.is_empty() {
+            main = main.child(
+                div()
+                    .py(px(12.))
+                    .text_color(text_dim)
+                    .text_size(px(12.))
+                    .child(format!("(sin {})", lv.entity)),
+            );
+        } else {
+            main = main.child(
+                div()
+                    .mt(px(8.))
+                    .text_color(text_dim)
+                    .text_size(px(11.))
+                    .child(format!("{total} fila(s)")),
+            );
+        }
+
+        main
+    }
+
+    #[allow(clippy::too_many_arguments)]
+    fn render_form(
+        &self,
+        cx: &mut Context<Self>,
+        mut main: gpui::Div,
+        fv: &FormView,
+        mod_idx: usize,
+        border: gpui::Rgba,
+        text: gpui::Rgba,
+        text_dim: gpui::Rgba,
+        accent: gpui::Rgba,
+    ) -> gpui::Div {
+        let _ = border;
+        main = main.child(
+            div()
+                .text_color(text)
+                .text_size(px(18.))
+                .mb(px(12.))
+                .child(fv.title.clone()),
+        );
+        for f in &fv.fields {
+            let raw = self.form_buffer.get(&f.name).cloned().unwrap_or_default();
+            let display = if raw.is_empty() {
+                f.default.clone().unwrap_or_default()
+            } else {
+                raw
+            };
+            let label = if f.required {
+                format!("{} *", f.label)
+            } else {
+                f.label.clone()
+            };
+            let mut field_box = div()
+                .flex()
+                .flex_col()
+                .mb(px(10.))
+                .child(
+                    div()
+                        .text_color(text_dim)
+                        .text_size(px(11.))
+                        .mb(px(2.))
+                        .child(label),
+                )
+                .child(
+                    // GPUI no incluye un text_input nativo; mostramos
+                    // el buffer actual como texto. Para entrada
+                    // teclado real, integrar yahweh-widget-text-input
+                    // (próxima iteración). Por ahora el form sirve
+                    // demos visuales y el seed via API programática.
+                    div()
+                        .px(px(8.))
+                        .py(px(6.))
+                        .bg(rgb(0x171a20))
+                        .border_1()
+                        .border_color(rgb(0x2a2f38))
+                        .rounded(px(3.))
+                        .text_color(text)
+                        .text_size(px(12.))
+                        .child(if display.is_empty() {
+                            "(vacío — input GPUI pendiente)".to_string()
+                        } else {
+                            display
+                        }),
+                );
+            if let Some(help) = &f.help {
+                field_box = field_box.child(
+                    div()
+                        .mt(px(2.))
+                        .text_color(text_dim)
+                        .text_size(px(10.))
+                        .child(help.clone()),
+                );
+            }
+            main = main.child(field_box);
+        }
+
+        let submit_label = match &fv.on_submit {
+            Action::SeedEntity { entity, .. } => format!("Crear {entity}"),
+            Action::Morphism { name, .. } => format!("Ejecutar {name}"),
+            Action::OpenView { label, view } => {
+                label.clone().unwrap_or_else(|| format!("Ir a {view}"))
+            }
+        };
+        main = main.child(
+            div().mt(px(12.)).child(action_button(
+                cx,
+                format!("form-submit-{mod_idx}"),
+                submit_label,
+                fv.on_submit.clone(),
+                accent,
+            )),
+        );
+
+        main = main.child(
+            div()
+                .mt(px(20.))
+                .text_color(text_dim)
+                .text_size(px(10.))
+                .child(
+                    "Nota: en este MVP, los inputs todavía no aceptan teclado. \
+                     El submit usa los `default` del schema o vacío (campo opcional). \
+                     Próximo iter: integración con yahweh-widget-text-input.",
+                ),
+        );
+        main
+    }
+}
+
+fn cx_handler_view(
+    mod_idx: usize,
+    view_key: String,
+) -> impl Fn(&ClickEvent, &mut Window, &mut App) + 'static {
+    let _ = (mod_idx, &view_key);
+    move |_e, _w, _cx| {
+        // GPUI handlers necesitan acceder al modelo de la entity actual;
+        // wirearemos via cx.update en el render real cuando el iter de
+        // eventos tipados esté listo. Por ahora el menu se navega via
+        // env var/restart.
+    }
+}
+
+fn action_button(
+    _cx: &mut Context<MetaUi>,
+    id: String,
+    label: String,
+    _action: Action,
+    accent: gpui::Rgba,
+) -> gpui::Stateful<gpui::Div> {
+    div()
+        .id(SharedString::from(id))
+        .px(px(10.))
+        .py(px(4.))
+        .bg(rgb(0x232a36))
+        .text_color(accent)
+        .text_size(px(11.))
+        .rounded(px(3.))
+        .child(label)
+}
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+
+    #[test]
+    fn parse_field_text_returns_string() {
+        let v = parse_field_value(FieldKind::Text, "hola").unwrap();
+        assert_eq!(v, json!("hola"));
+    }
+
+    #[test]
+    fn parse_field_number_int_then_float() {
+        let i = parse_field_value(FieldKind::Number, "42").unwrap();
+        assert_eq!(i, json!(42));
+        let f = parse_field_value(FieldKind::Number, "3.14").unwrap();
+        assert_eq!(f, json!(3.14));
+    }
+
+    #[test]
+    fn parse_field_number_invalid_errors() {
+        let r = parse_field_value(FieldKind::Number, "not-a-number");
+        assert!(r.is_err());
+    }
+
+    #[test]
+    fn parse_field_boolean_variants() {
+        assert_eq!(
+            parse_field_value(FieldKind::Boolean, "true").unwrap(),
+            json!(true)
+        );
+        assert_eq!(
+            parse_field_value(FieldKind::Boolean, "yes").unwrap(),
+            json!(true)
+        );
+        assert_eq!(
+            parse_field_value(FieldKind::Boolean, "false").unwrap(),
+            json!(false)
+        );
+        assert_eq!(
+            parse_field_value(FieldKind::Boolean, "").unwrap(),
+            json!(false)
+        );
+        assert!(parse_field_value(FieldKind::Boolean, "maybe").is_err());
+    }
+
+    #[test]
+    fn lookup_field_simple_and_nested() {
+        let v = json!({
+            "name": "Acme",
+            "address": { "city": "Bogotá", "country": "CO" }
+        });
+        assert_eq!(lookup_field(&v, "name").unwrap(), &json!("Acme"));
+        assert_eq!(
+            lookup_field(&v, "address.city").unwrap(),
+            &json!("Bogotá")
+        );
+        assert!(lookup_field(&v, "missing").is_none());
+        assert!(lookup_field(&v, "address.zipcode").is_none());
+    }
+
+    #[test]
+    fn render_value_handles_null_string_bool() {
+        assert_eq!(render_value(None), "");
+        assert_eq!(render_value(Some(&json!(null))), "");
+        assert_eq!(render_value(Some(&json!("x"))), "x");
+        assert_eq!(render_value(Some(&json!(true))), "✓");
+        assert_eq!(render_value(Some(&json!(false))), "✗");
+        assert_eq!(render_value(Some(&json!(42))), "42");
+    }
+}
diff --git a/crates/modules/nakui/ui-schema/Cargo.toml b/crates/modules/nakui/ui-schema/Cargo.toml
new file mode 100644
index 0000000..e6627d2
--- /dev/null
+++ b/crates/modules/nakui/ui-schema/Cargo.toml
@@ -0,0 +1,14 @@
+[package]
+name = "nakui-ui-schema"
+version.workspace = true
+edition.workspace = true
+license.workspace = true
+description = "Nakui UI metainterface schema: módulos declaran menús, listas y formularios como datos (JSON); el runtime los carga y renderiza sin código compilado por módulo."
+
+[dependencies]
+serde = { workspace = true }
+serde_json = { workspace = true }
+thiserror = { workspace = true }
+
+[dev-dependencies]
+tempfile = { workspace = true }
diff --git a/crates/modules/nakui/ui-schema/src/lib.rs b/crates/modules/nakui/ui-schema/src/lib.rs
new file mode 100644
index 0000000..d180822
--- /dev/null
+++ b/crates/modules/nakui/ui-schema/src/lib.rs
@@ -0,0 +1,499 @@
+//! Schema declarativo de la metainterfaz Nakui.
+//!
+//! Cada **módulo** Nakui (customers, products, sales, ...) declara
+//! aquí qué menús, vistas, listas y formularios expone, sin escribir
+//! código GPUI ni Rust. El runtime ([`nakui-ui` crate]) lee estos
+//! `module.json` desde un directorio y monta automáticamente la UI
+//! correspondiente.
+//!
+//! ## Filosofía
+//!
+//! - **UI como datos**: agregar un módulo = escribir un JSON. Ningún
+//!   recompile, ningún acoplamiento con el binario del runtime.
+//! - **Persistencia universal**: el runtime conecta cada vista al
+//!   `nakui_core::store::Store` actual; los formularios escriben
+//!   ops por la pipeline normal de Nakui (executor + event log).
+//! - **Schema primero, semántica después**: este crate sólo define
+//!   la *forma* de los manifests. Validación semántica
+//!   (referencias rotas a entities, campos faltantes, etc.) vive en
+//!   el runtime que lo carga, no acá.
+//!
+//! ## Anatomía de un módulo
+//!
+//! ```json
+//! {
+//!   "id": "customers",
+//!   "label": "Clientes",
+//!   "entities": [
+//!     { "name": "customer", "fields": [ ... ] }
+//!   ],
+//!   "menu": [
+//!     { "label": "Listar", "view": "list" },
+//!     { "label": "Nuevo", "view": "form" }
+//!   ],
+//!   "views": {
+//!     "list": { "kind": "list", "entity": "customer", "columns": [...] },
+//!     "form": { "kind": "form", "entity": "customer", "fields": [...] }
+//!   }
+//! }
+//! ```
+
+#![forbid(unsafe_code)]
+#![warn(rust_2018_idioms)]
+
+use std::collections::BTreeMap;
+use std::path::{Path, PathBuf};
+
+use serde::{Deserialize, Serialize};
+use thiserror::Error;
+
+/// Manifiesto de un módulo de la metainterfaz Nakui.
+#[derive(Debug, Clone, Serialize, Deserialize)]
+pub struct Module {
+    /// Identificador estable. Único dentro del directorio cargado.
+    pub id: String,
+
+    /// Nombre legible para mostrar en el sidebar.
+    pub label: String,
+
+    /// Descripción corta opcional (tooltip / subtítulo).
+    #[serde(default)]
+    pub description: Option<String>,
+
+    /// Entities que el módulo introduce o consume. El runtime las
+    /// usa para validar columns/fields y para inicializar el store
+    /// cuando son nuevas.
+    #[serde(default)]
+    pub entities: Vec<EntitySpec>,
+
+    /// Items del menú. Cada uno apunta a una key de `views`. Orden
+    /// importa (es el orden en que se presentan en el sidebar).
+    pub menu: Vec<MenuItem>,
+
+    /// Vistas indexadas por key. Las keys son referenciadas por
+    /// `MenuItem.view` y por `Action::OpenView.view`.
+    pub views: BTreeMap<String, View>,
+}
+
+/// Item del menú lateral.
+#[derive(Debug, Clone, Serialize, Deserialize)]
+pub struct MenuItem {
+    pub label: String,
+    /// Key de la vista a abrir. Debe existir en `Module.views`.
+    pub view: String,
+    /// Icono opcional (texto unicode o emoji; el runtime decide
+    /// renderización).
+    #[serde(default)]
+    pub icon: Option<String>,
+}
+
+/// Una vista renderizada en el área principal cuando su menú es seleccionado.
+#[derive(Debug, Clone, Serialize, Deserialize)]
+#[serde(tag = "kind", rename_all = "snake_case")]
+pub enum View {
+    /// Tabla de instancias de una entity, columnas + acciones por fila.
+    List(ListView),
+    /// Formulario de creación / edición.
+    Form(FormView),
+}
+
+#[derive(Debug, Clone, Serialize, Deserialize)]
+pub struct ListView {
+    pub title: String,
+    /// Entity (del nakui store) cuyas instancias se listan.
+    pub entity: String,
+    /// Columnas a mostrar. Orden importa.
+    pub columns: Vec<Column>,
+    /// Acciones disponibles a nivel de la lista (ej. "Nuevo" → form).
+    /// Renderizadas como botones en el header.
+    #[serde(default)]
+    pub actions: Vec<Action>,
+    /// Cuando está set, se muestra una caja de búsqueda que filtra
+    /// las filas por substring contra los valores de estas columnas.
+    #[serde(default)]
+    pub search_in: Vec<String>,
+}
+
+#[derive(Debug, Clone, Serialize, Deserialize)]
+pub struct Column {
+    /// Path del campo dentro del record. Para tipos planos: nombre.
+    /// Para nested: puntos (`address.city`). El runtime navega.
+    pub field: String,
+    /// Texto del header.
+    pub label: String,
+    /// Ancho relativo (peso flex). Default 1.
+    #[serde(default = "default_weight")]
+    pub weight: f32,
+}
+
+fn default_weight() -> f32 {
+    1.0
+}
+
+#[derive(Debug, Clone, Serialize, Deserialize)]
+pub struct FormView {
+    pub title: String,
+    /// Entity destino del seed/morphism al submit.
+    pub entity: String,
+    pub fields: Vec<FieldSpec>,
+    /// Acción al submit. Típicamente `Action::SeedEntity` para alta
+    /// directa o `Action::Morphism` cuando hay validación/cálculo.
+    pub on_submit: Action,
+}
+
+/// Especificación de un campo de formulario o columna implícita.
+#[derive(Debug, Clone, Serialize, Deserialize)]
+pub struct FieldSpec {
+    /// Nombre del campo en el record (clave del JSON).
+    pub name: String,
+    /// Etiqueta legible.
+    pub label: String,
+    /// Tipo del valor — define el widget de input + parseo.
+    pub kind: FieldKind,
+    /// Valor por defecto al abrir el form (string raw; el parseo
+    /// según `kind` lo hace el runtime).
+    #[serde(default)]
+    pub default: Option<String>,
+    /// Si `true`, el form rechaza submit con campo vacío.
+    #[serde(default)]
+    pub required: bool,
+    /// Texto de ayuda mostrado bajo el input.
+    #[serde(default)]
+    pub help: Option<String>,
+}
+
+#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
+#[serde(rename_all = "snake_case")]
+pub enum FieldKind {
+    /// Texto libre.
+    Text,
+    /// Texto multilínea.
+    Multiline,
+    /// Número (i64 o f64; runtime intenta parsear como i64 primero).
+    Number,
+    /// Booleano (renderizado como checkbox).
+    Boolean,
+    /// Fecha (formato ISO YYYY-MM-DD; almacenada como string).
+    Date,
+}
+
+/// Acciones disparables por menús, botones o submit de formularios.
+#[derive(Debug, Clone, Serialize, Deserialize)]
+#[serde(tag = "kind", rename_all = "snake_case")]
+pub enum Action {
+    /// Cambia la vista activa a otra del mismo módulo.
+    OpenView {
+        view: String,
+        /// Etiqueta del botón / item; default = nombre humano de la vista.
+        #[serde(default)]
+        label: Option<String>,
+    },
+    /// Crea un seed directo en la entity con los valores del form.
+    /// Equivalente a `nakui_core::event_log::seed_and_log`.
+    /// Sin pipeline de morphism — alta administrativa.
+    SeedEntity {
+        entity: String,
+        /// Tras el submit exitoso, opcionalmente abrir esta vista
+        /// (por convención: `"list"` para volver al listado).
+        #[serde(default)]
+        next_view: Option<String>,
+    },
+    /// Ejecuta un morphism declarado en el manifest del módulo
+    /// nakui-core. Inputs y params se mapean desde los campos del form.
+    Morphism {
+        name: String,
+        #[serde(default)]
+        next_view: Option<String>,
+    },
+}
+
+#[derive(Debug, Clone, Serialize, Deserialize)]
+pub struct EntitySpec {
+    /// Nombre de la entity (clave en el store). Único dentro del módulo.
+    pub name: String,
+    /// Label legible (singular).
+    pub label: String,
+    /// Campos esperados en records de esta entity. Usados por el
+    /// runtime para inferir columnas / validar formularios.
+    #[serde(default)]
+    pub fields: Vec<FieldSpec>,
+}
+
+#[derive(Debug, Error)]
+pub enum SchemaError {
+    #[error("io leyendo {path}: {source}")]
+    Io {
+        path: PathBuf,
+        #[source]
+        source: std::io::Error,
+    },
+    #[error("parseo de {path}: {source}")]
+    Parse {
+        path: PathBuf,
+        #[source]
+        source: serde_json::Error,
+    },
+    #[error("módulo {id}: la vista '{view}' referenciada por menu_item '{item}' no existe")]
+    DanglingMenuView {
+        id: String,
+        item: String,
+        view: String,
+    },
+    #[error("módulos con id duplicado: '{id}' aparece en {first} y {second}")]
+    DuplicateModuleId {
+        id: String,
+        first: PathBuf,
+        second: PathBuf,
+    },
+}
+
+impl Module {
+    /// Carga un module.json desde disco. Validación estructural
+    /// posterior (vistas referenciadas existen, etc.) la ejecuta el
+    /// runtime — acá sólo parseamos.
+    pub fn from_path(path: impl AsRef<Path>) -> Result<Self, SchemaError> {
+        let path = path.as_ref();
+        let bytes = std::fs::read(path).map_err(|source| SchemaError::Io {
+            path: path.to_path_buf(),
+            source,
+        })?;
+        serde_json::from_slice(&bytes).map_err(|source| SchemaError::Parse {
+            path: path.to_path_buf(),
+            source,
+        })
+    }
+
+    /// Validación post-parse: cada `MenuItem.view` debe existir en
+    /// `views`. Retorna el primer error encontrado.
+    pub fn validate(&self) -> Result<(), SchemaError> {
+        for item in &self.menu {
+            if !self.views.contains_key(&item.view) {
+                return Err(SchemaError::DanglingMenuView {
+                    id: self.id.clone(),
+                    item: item.label.clone(),
+                    view: item.view.clone(),
+                });
+            }
+        }
+        Ok(())
+    }
+}
+
+/// Carga todos los `module.json` encontrados bajo `dir` (recursivo
+/// 1 nivel — espera `dir/<modulo>/module.json`). Devuelve la lista
+/// ordenada por id, validada.
+///
+/// Falla si: I/O, parseo, módulo inválido, o ids duplicados.
+pub fn load_modules_from_dir(dir: impl AsRef<Path>) -> Result<Vec<Module>, SchemaError> {
+    let dir = dir.as_ref();
+    let mut modules: Vec<(PathBuf, Module)> = Vec::new();
+    let entries = std::fs::read_dir(dir).map_err(|source| SchemaError::Io {
+        path: dir.to_path_buf(),
+        source,
+    })?;
+    for entry in entries.flatten() {
+        let p = entry.path();
+        if p.is_dir() {
+            let manifest = p.join("module.json");
+            if manifest.exists() {
+                let m = Module::from_path(&manifest)?;
+                m.validate()?;
+                modules.push((manifest, m));
+            }
+        }
+    }
+    // Detectar duplicados de id.
+    modules.sort_by(|a, b| a.1.id.cmp(&b.1.id));
+    let mut prev: Option<&(PathBuf, Module)> = None;
+    for cur in &modules {
+        if let Some(p) = prev {
+            if p.1.id == cur.1.id {
+                return Err(SchemaError::DuplicateModuleId {
+                    id: cur.1.id.clone(),
+                    first: p.0.clone(),
+                    second: cur.0.clone(),
+                });
+            }
+        }
+        prev = Some(cur);
+    }
+    Ok(modules.into_iter().map(|(_, m)| m).collect())
+}
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+
+    fn sample_module() -> Module {
+        Module {
+            id: "customers".into(),
+            label: "Clientes".into(),
+            description: Some("Gestión de clientes".into()),
+            entities: vec![EntitySpec {
+                name: "customer".into(),
+                label: "Cliente".into(),
+                fields: vec![
+                    FieldSpec {
+                        name: "name".into(),
+                        label: "Nombre".into(),
+                        kind: FieldKind::Text,
+                        default: None,
+                        required: true,
+                        help: None,
+                    },
+                    FieldSpec {
+                        name: "email".into(),
+                        label: "Email".into(),
+                        kind: FieldKind::Text,
+                        default: None,
+                        required: false,
+                        help: Some("Opcional".into()),
+                    },
+                ],
+            }],
+            menu: vec![
+                MenuItem {
+                    label: "Listar".into(),
+                    view: "list".into(),
+                    icon: None,
+                },
+                MenuItem {
+                    label: "Nuevo".into(),
+                    view: "form".into(),
+                    icon: None,
+                },
+            ],
+            views: BTreeMap::from([
+                (
+                    "list".into(),
+                    View::List(ListView {
+                        title: "Clientes".into(),
+                        entity: "customer".into(),
+                        columns: vec![
+                            Column {
+                                field: "name".into(),
+                                label: "Nombre".into(),
+                                weight: 2.0,
+                            },
+                            Column {
+                                field: "email".into(),
+                                label: "Email".into(),
+                                weight: 3.0,
+                            },
+                        ],
+                        actions: vec![Action::OpenView {
+                            view: "form".into(),
+                            label: Some("Nuevo".into()),
+                        }],
+                        search_in: vec!["name".into(), "email".into()],
+                    }),
+                ),
+                (
+                    "form".into(),
+                    View::Form(FormView {
+                        title: "Nuevo cliente".into(),
+                        entity: "customer".into(),
+                        fields: vec![FieldSpec {
+                            name: "name".into(),
+                            label: "Nombre".into(),
+                            kind: FieldKind::Text,
+                            default: None,
+                            required: true,
+                            help: None,
+                        }],
+                        on_submit: Action::SeedEntity {
+                            entity: "customer".into(),
+                            next_view: Some("list".into()),
+                        },
+                    }),
+                ),
+            ]),
+        }
+    }
+
+    #[test]
+    fn module_serialize_deserialize_roundtrip() {
+        let m = sample_module();
+        let s = serde_json::to_string_pretty(&m).unwrap();
+        let m2: Module = serde_json::from_str(&s).unwrap();
+        assert_eq!(m.id, m2.id);
+        assert_eq!(m.menu.len(), m2.menu.len());
+        assert_eq!(m.views.len(), m2.views.len());
+    }
+
+    #[test]
+    fn validate_passes_for_well_formed_module() {
+        sample_module().validate().unwrap();
+    }
+
+    #[test]
+    fn validate_catches_dangling_menu_view() {
+        let mut m = sample_module();
+        m.menu.push(MenuItem {
+            label: "Roto".into(),
+            view: "no_existe".into(),
+            icon: None,
+        });
+        let err = m.validate().unwrap_err();
+        assert!(matches!(err, SchemaError::DanglingMenuView { .. }));
+    }
+
+    #[test]
+    fn from_path_loads_real_file() {
+        let tmp = tempfile::tempdir().unwrap();
+        let path = tmp.path().join("module.json");
+        std::fs::write(&path, serde_json::to_vec_pretty(&sample_module()).unwrap()).unwrap();
+        let m = Module::from_path(&path).unwrap();
+        assert_eq!(m.id, "customers");
+    }
+
+    #[test]
+    fn load_modules_from_dir_finds_subdirs() {
+        let tmp = tempfile::tempdir().unwrap();
+        let cust_dir = tmp.path().join("customers");
+        std::fs::create_dir(&cust_dir).unwrap();
+        let mut m1 = sample_module();
+        m1.id = "customers".into();
+        std::fs::write(
+            cust_dir.join("module.json"),
+            serde_json::to_vec_pretty(&m1).unwrap(),
+        )
+        .unwrap();
+        let prod_dir = tmp.path().join("products");
+        std::fs::create_dir(&prod_dir).unwrap();
+        let mut m2 = sample_module();
+        m2.id = "products".into();
+        std::fs::write(
+            prod_dir.join("module.json"),
+            serde_json::to_vec_pretty(&m2).unwrap(),
+        )
+        .unwrap();
+
+        let mods = load_modules_from_dir(tmp.path()).unwrap();
+        assert_eq!(mods.len(), 2);
+        assert_eq!(mods[0].id, "customers");
+        assert_eq!(mods[1].id, "products");
+    }
+
+    #[test]
+    fn load_modules_detects_duplicate_id() {
+        let tmp = tempfile::tempdir().unwrap();
+        let a_dir = tmp.path().join("a");
+        let b_dir = tmp.path().join("b");
+        std::fs::create_dir_all(&a_dir).unwrap();
+        std::fs::create_dir_all(&b_dir).unwrap();
+        let m = sample_module(); // id = "customers"
+        std::fs::write(
+            a_dir.join("module.json"),
+            serde_json::to_vec(&m).unwrap(),
+        )
+        .unwrap();
+        std::fs::write(
+            b_dir.join("module.json"),
+            serde_json::to_vec(&m).unwrap(),
+        )
+        .unwrap();
+        let err = load_modules_from_dir(tmp.path()).unwrap_err();
+        assert!(matches!(err, SchemaError::DuplicateModuleId { .. }));
+    }
+}
diff --git a/crates/modules/nakui/ui-schema/tests/example_modules.rs b/crates/modules/nakui/ui-schema/tests/example_modules.rs
new file mode 100644
index 0000000..1ed8408
--- /dev/null
+++ b/crates/modules/nakui/ui-schema/tests/example_modules.rs
@@ -0,0 +1,89 @@
+//! Validación de los 6 módulos demo en `examples/nakui-modules/`.
+//!
+//! Si esto verde, garantizamos que un usuario que clone el repo y
+//! corra `NAKUI_MODULES_DIR=examples/nakui-modules cargo run -p nakui-ui`
+//! va a obtener los 6 módulos cargados sin tocar nada.
+
+use nakui_ui_schema::{load_modules_from_dir, FieldKind, View};
+
+fn examples_dir() -> std::path::PathBuf {
+    // Tests corren desde el dir del crate; el repo root está dos
+    // niveles arriba: crates/modules/nakui/ui-schema → repo.
+    let here = std::path::Path::new(env!("CARGO_MANIFEST_DIR"));
+    here.join("../../../..").join("examples/nakui-modules")
+}
+
+#[test]
+fn loads_all_six_demo_modules() {
+    let dir = examples_dir();
+    let mods = load_modules_from_dir(&dir).unwrap_or_else(|e| {
+        panic!("load failed for {}: {e}", dir.display());
+    });
+    let ids: Vec<&str> = mods.iter().map(|m| m.id.as_str()).collect();
+    assert_eq!(
+        ids,
+        vec![
+            "customers",
+            "inventory_movements",
+            "invoices",
+            "products",
+            "sales_orders",
+            "suppliers",
+        ],
+        "expected 6 modules in alphabetical order"
+    );
+}
+
+#[test]
+fn every_demo_module_has_list_and_form_views() {
+    let mods = load_modules_from_dir(examples_dir()).unwrap();
+    for m in &mods {
+        let mut has_list = false;
+        let mut has_form = false;
+        for v in m.views.values() {
+            match v {
+                View::List(_) => has_list = true,
+                View::Form(_) => has_form = true,
+            }
+        }
+        assert!(
+            has_list && has_form,
+            "module {} should expose at least one list + one form view",
+            m.id
+        );
+    }
+}
+
+#[test]
+fn every_demo_form_field_kind_is_recognized() {
+    // Sanity: ningún módulo demo usa un kind que no esté en el enum
+    // (sería rechazado al parsear, pero check explícito no daña).
+    let mods = load_modules_from_dir(examples_dir()).unwrap();
+    for m in &mods {
+        for v in m.views.values() {
+            if let View::Form(form) = v {
+                for f in &form.fields {
+                    let _ok = matches!(
+                        f.kind,
+                        FieldKind::Text
+                            | FieldKind::Multiline
+                            | FieldKind::Number
+                            | FieldKind::Boolean
+                            | FieldKind::Date
+                    );
+                }
+            }
+        }
+    }
+}
+
+#[test]
+fn every_module_validates_clean() {
+    // validate() chequea que cada MenuItem.view exista en views.
+    // Un typo en cualquiera de los 6 módulos haría fallar este test.
+    let mods = load_modules_from_dir(examples_dir()).unwrap();
+    for m in &mods {
+        m.validate()
+            .unwrap_or_else(|e| panic!("module {} failed validate: {e}", m.id));
+    }
+}
diff --git a/examples/nakui-modules/customers/module.json b/examples/nakui-modules/customers/module.json
new file mode 100644
index 0000000..81718dd
--- /dev/null
+++ b/examples/nakui-modules/customers/module.json
@@ -0,0 +1,59 @@
+{
+  "id": "customers",
+  "label": "Clientes",
+  "description": "Gestión de clientes (alta, listado, búsqueda).",
+  "entities": [
+    {
+      "name": "customer",
+      "label": "Cliente",
+      "fields": [
+        { "name": "name", "label": "Nombre", "kind": "text", "required": true },
+        { "name": "email", "label": "Email", "kind": "text", "required": false, "help": "Opcional, formato libre por ahora" },
+        { "name": "phone", "label": "Teléfono", "kind": "text", "required": false },
+        { "name": "active", "label": "Activo", "kind": "boolean", "default": "true" },
+        { "name": "credit_limit", "label": "Límite de crédito", "kind": "number", "default": "0" },
+        { "name": "notes", "label": "Notas", "kind": "multiline", "required": false }
+      ]
+    }
+  ],
+  "menu": [
+    { "label": "Listar", "view": "list", "icon": "📋" },
+    { "label": "Nuevo", "view": "form", "icon": "✚" }
+  ],
+  "views": {
+    "list": {
+      "kind": "list",
+      "title": "Clientes",
+      "entity": "customer",
+      "columns": [
+        { "field": "name", "label": "Nombre", "weight": 2.0 },
+        { "field": "email", "label": "Email", "weight": 3.0 },
+        { "field": "phone", "label": "Teléfono", "weight": 1.5 },
+        { "field": "active", "label": "Activo", "weight": 0.7 },
+        { "field": "credit_limit", "label": "Límite", "weight": 1.0 }
+      ],
+      "actions": [
+        { "kind": "open_view", "view": "form", "label": "✚ Nuevo" }
+      ],
+      "search_in": ["name", "email", "phone"]
+    },
+    "form": {
+      "kind": "form",
+      "title": "Nuevo cliente",
+      "entity": "customer",
+      "fields": [
+        { "name": "name", "label": "Nombre", "kind": "text", "required": true },
+        { "name": "email", "label": "Email", "kind": "text", "required": false },
+        { "name": "phone", "label": "Teléfono", "kind": "text", "required": false },
+        { "name": "active", "label": "Activo", "kind": "boolean", "default": "true" },
+        { "name": "credit_limit", "label": "Límite de crédito", "kind": "number", "default": "0" },
+        { "name": "notes", "label": "Notas", "kind": "multiline" }
+      ],
+      "on_submit": {
+        "kind": "seed_entity",
+        "entity": "customer",
+        "next_view": "list"
+      }
+    }
+  }
+}
diff --git a/examples/nakui-modules/inventory_movements/module.json b/examples/nakui-modules/inventory_movements/module.json
new file mode 100644
index 0000000..467d763
--- /dev/null
+++ b/examples/nakui-modules/inventory_movements/module.json
@@ -0,0 +1,63 @@
+{
+  "id": "inventory_movements",
+  "label": "Inventario",
+  "description": "Movimientos de stock: entradas, salidas, ajustes.",
+  "entities": [
+    {
+      "name": "stock_movement",
+      "label": "Movimiento",
+      "fields": [
+        { "name": "occurred_at", "label": "Fecha", "kind": "date", "required": true },
+        { "name": "movement_type", "label": "Tipo", "kind": "text", "required": true, "help": "in / out / adjustment" },
+        { "name": "product_sku", "label": "SKU producto", "kind": "text", "required": true },
+        { "name": "quantity", "label": "Cantidad", "kind": "number", "required": true },
+        { "name": "unit_cost", "label": "Costo unitario", "kind": "number", "default": "0" },
+        { "name": "reason", "label": "Motivo", "kind": "text", "help": "Compra, venta, merma, ajuste por inventario..." },
+        { "name": "reference", "label": "Doc. referencia", "kind": "text", "help": "Factura, orden, conteo..." }
+      ]
+    }
+  ],
+  "menu": [
+    { "label": "Movimientos", "view": "list", "icon": "📊" },
+    { "label": "Registrar", "view": "form", "icon": "✚" }
+  ],
+  "views": {
+    "list": {
+      "kind": "list",
+      "title": "Movimientos de stock",
+      "entity": "stock_movement",
+      "columns": [
+        { "field": "occurred_at", "label": "Fecha", "weight": 1.0 },
+        { "field": "movement_type", "label": "Tipo", "weight": 0.7 },
+        { "field": "product_sku", "label": "SKU", "weight": 1.0 },
+        { "field": "quantity", "label": "Cantidad", "weight": 0.8 },
+        { "field": "unit_cost", "label": "Costo", "weight": 0.8 },
+        { "field": "reason", "label": "Motivo", "weight": 1.5 },
+        { "field": "reference", "label": "Ref.", "weight": 1.0 }
+      ],
+      "actions": [
+        { "kind": "open_view", "view": "form", "label": "✚ Registrar" }
+      ],
+      "search_in": ["product_sku", "reason", "reference"]
+    },
+    "form": {
+      "kind": "form",
+      "title": "Registrar movimiento",
+      "entity": "stock_movement",
+      "fields": [
+        { "name": "occurred_at", "label": "Fecha", "kind": "date", "required": true },
+        { "name": "movement_type", "label": "Tipo (in/out/adjustment)", "kind": "text", "required": true, "default": "in" },
+        { "name": "product_sku", "label": "SKU producto", "kind": "text", "required": true },
+        { "name": "quantity", "label": "Cantidad", "kind": "number", "required": true },
+        { "name": "unit_cost", "label": "Costo unitario", "kind": "number", "default": "0" },
+        { "name": "reason", "label": "Motivo", "kind": "text" },
+        { "name": "reference", "label": "Documento de referencia", "kind": "text" }
+      ],
+      "on_submit": {
+        "kind": "seed_entity",
+        "entity": "stock_movement",
+        "next_view": "list"
+      }
+    }
+  }
+}
diff --git a/examples/nakui-modules/invoices/module.json b/examples/nakui-modules/invoices/module.json
new file mode 100644
index 0000000..d1cacea
--- /dev/null
+++ b/examples/nakui-modules/invoices/module.json
@@ -0,0 +1,72 @@
+{
+  "id": "invoices",
+  "label": "Facturación",
+  "description": "Facturas emitidas y su seguimiento de pago.",
+  "entities": [
+    {
+      "name": "invoice",
+      "label": "Factura",
+      "fields": [
+        { "name": "invoice_number", "label": "N° factura", "kind": "text", "required": true },
+        { "name": "customer_name", "label": "Cliente", "kind": "text", "required": true },
+        { "name": "issued_at", "label": "Emitida", "kind": "date", "required": true },
+        { "name": "due_at", "label": "Vencimiento", "kind": "date", "required": true },
+        { "name": "subtotal", "label": "Subtotal", "kind": "number", "default": "0" },
+        { "name": "tax", "label": "Impuestos", "kind": "number", "default": "0" },
+        { "name": "total", "label": "Total", "kind": "number", "default": "0" },
+        { "name": "amount_paid", "label": "Pagado", "kind": "number", "default": "0" },
+        { "name": "status", "label": "Estado", "kind": "text", "required": true, "default": "issued", "help": "draft / issued / partially_paid / paid / overdue / void" },
+        { "name": "currency", "label": "Moneda", "kind": "text", "default": "USD" },
+        { "name": "reference_order", "label": "Orden referencia", "kind": "text" }
+      ]
+    }
+  ],
+  "menu": [
+    { "label": "Facturas", "view": "list", "icon": "💳" },
+    { "label": "Nueva factura", "view": "form", "icon": "✚" }
+  ],
+  "views": {
+    "list": {
+      "kind": "list",
+      "title": "Facturas",
+      "entity": "invoice",
+      "columns": [
+        { "field": "invoice_number", "label": "N°", "weight": 1.0 },
+        { "field": "customer_name", "label": "Cliente", "weight": 2.0 },
+        { "field": "issued_at", "label": "Emitida", "weight": 1.0 },
+        { "field": "due_at", "label": "Vence", "weight": 1.0 },
+        { "field": "total", "label": "Total", "weight": 1.0 },
+        { "field": "amount_paid", "label": "Pagado", "weight": 1.0 },
+        { "field": "currency", "label": "Mon.", "weight": 0.5 },
+        { "field": "status", "label": "Estado", "weight": 0.9 }
+      ],
+      "actions": [
+        { "kind": "open_view", "view": "form", "label": "✚ Nueva" }
+      ],
+      "search_in": ["invoice_number", "customer_name", "reference_order"]
+    },
+    "form": {
+      "kind": "form",
+      "title": "Nueva factura",
+      "entity": "invoice",
+      "fields": [
+        { "name": "invoice_number", "label": "Número de factura", "kind": "text", "required": true },
+        { "name": "customer_name", "label": "Cliente", "kind": "text", "required": true },
+        { "name": "issued_at", "label": "Fecha de emisión", "kind": "date", "required": true },
+        { "name": "due_at", "label": "Fecha de vencimiento", "kind": "date", "required": true },
+        { "name": "subtotal", "label": "Subtotal", "kind": "number", "default": "0" },
+        { "name": "tax", "label": "Impuestos", "kind": "number", "default": "0" },
+        { "name": "total", "label": "Total", "kind": "number", "default": "0" },
+        { "name": "amount_paid", "label": "Pagado", "kind": "number", "default": "0" },
+        { "name": "status", "label": "Estado", "kind": "text", "required": true, "default": "issued" },
+        { "name": "currency", "label": "Moneda", "kind": "text", "default": "USD" },
+        { "name": "reference_order", "label": "Orden de venta referenciada", "kind": "text" }
+      ],
+      "on_submit": {
+        "kind": "seed_entity",
+        "entity": "invoice",
+        "next_view": "list"
+      }
+    }
+  }
+}
diff --git a/examples/nakui-modules/products/module.json b/examples/nakui-modules/products/module.json
new file mode 100644
index 0000000..63541d1
--- /dev/null
+++ b/examples/nakui-modules/products/module.json
@@ -0,0 +1,60 @@
+{
+  "id": "products",
+  "label": "Productos",
+  "description": "Catálogo de productos y precios.",
+  "entities": [
+    {
+      "name": "product",
+      "label": "Producto",
+      "fields": [
+        { "name": "sku", "label": "SKU", "kind": "text", "required": true, "help": "Código único" },
+        { "name": "name", "label": "Nombre", "kind": "text", "required": true },
+        { "name": "category", "label": "Categoría", "kind": "text", "required": false },
+        { "name": "price", "label": "Precio unitario", "kind": "number", "required": true, "default": "0" },
+        { "name": "stock", "label": "Stock disponible", "kind": "number", "default": "0" },
+        { "name": "active", "label": "En venta", "kind": "boolean", "default": "true" }
+      ]
+    }
+  ],
+  "menu": [
+    { "label": "Catálogo", "view": "list", "icon": "📦" },
+    { "label": "Nuevo producto", "view": "form", "icon": "✚" }
+  ],
+  "views": {
+    "list": {
+      "kind": "list",
+      "title": "Catálogo de productos",
+      "entity": "product",
+      "columns": [
+        { "field": "sku", "label": "SKU", "weight": 1.0 },
+        { "field": "name", "label": "Nombre", "weight": 2.5 },
+        { "field": "category", "label": "Categoría", "weight": 1.5 },
+        { "field": "price", "label": "Precio", "weight": 1.0 },
+        { "field": "stock", "label": "Stock", "weight": 0.8 },
+        { "field": "active", "label": "Activo", "weight": 0.6 }
+      ],
+      "actions": [
+        { "kind": "open_view", "view": "form", "label": "✚ Nuevo" }
+      ],
+      "search_in": ["sku", "name", "category"]
+    },
+    "form": {
+      "kind": "form",
+      "title": "Nuevo producto",
+      "entity": "product",
+      "fields": [
+        { "name": "sku", "label": "SKU", "kind": "text", "required": true },
+        { "name": "name", "label": "Nombre", "kind": "text", "required": true },
+        { "name": "category", "label": "Categoría", "kind": "text" },
+        { "name": "price", "label": "Precio", "kind": "number", "required": true, "default": "0" },
+        { "name": "stock", "label": "Stock inicial", "kind": "number", "default": "0" },
+        { "name": "active", "label": "En venta", "kind": "boolean", "default": "true" }
+      ],
+      "on_submit": {
+        "kind": "seed_entity",
+        "entity": "product",
+        "next_view": "list"
+      }
+    }
+  }
+}
diff --git a/examples/nakui-modules/sales_orders/module.json b/examples/nakui-modules/sales_orders/module.json
new file mode 100644
index 0000000..758add4
--- /dev/null
+++ b/examples/nakui-modules/sales_orders/module.json
@@ -0,0 +1,65 @@
+{
+  "id": "sales_orders",
+  "label": "Ventas",
+  "description": "Órdenes de venta y sus líneas.",
+  "entities": [
+    {
+      "name": "sales_order",
+      "label": "Orden de venta",
+      "fields": [
+        { "name": "order_number", "label": "Número", "kind": "text", "required": true },
+        { "name": "customer_name", "label": "Cliente", "kind": "text", "required": true },
+        { "name": "issued_at", "label": "Fecha de emisión", "kind": "date", "required": true },
+        { "name": "due_at", "label": "Fecha de vencimiento", "kind": "date" },
+        { "name": "status", "label": "Estado", "kind": "text", "required": true, "default": "draft", "help": "draft / confirmed / shipped / closed / cancelled" },
+        { "name": "subtotal", "label": "Subtotal", "kind": "number", "default": "0" },
+        { "name": "tax", "label": "Impuestos", "kind": "number", "default": "0" },
+        { "name": "total", "label": "Total", "kind": "number", "default": "0" },
+        { "name": "notes", "label": "Notas", "kind": "multiline" }
+      ]
+    }
+  ],
+  "menu": [
+    { "label": "Órdenes", "view": "list", "icon": "🧾" },
+    { "label": "Nueva orden", "view": "form", "icon": "✚" }
+  ],
+  "views": {
+    "list": {
+      "kind": "list",
+      "title": "Órdenes de venta",
+      "entity": "sales_order",
+      "columns": [
+        { "field": "order_number", "label": "N°", "weight": 0.8 },
+        { "field": "customer_name", "label": "Cliente", "weight": 2.0 },
+        { "field": "issued_at", "label": "Emitida", "weight": 1.0 },
+        { "field": "status", "label": "Estado", "weight": 0.8 },
+        { "field": "total", "label": "Total", "weight": 1.0 }
+      ],
+      "actions": [
+        { "kind": "open_view", "view": "form", "label": "✚ Nueva orden" }
+      ],
+      "search_in": ["order_number", "customer_name"]
+    },
+    "form": {
+      "kind": "form",
+      "title": "Nueva orden de venta",
+      "entity": "sales_order",
+      "fields": [
+        { "name": "order_number", "label": "Número de orden", "kind": "text", "required": true },
+        { "name": "customer_name", "label": "Cliente", "kind": "text", "required": true },
+        { "name": "issued_at", "label": "Fecha de emisión", "kind": "date", "required": true },
+        { "name": "due_at", "label": "Fecha de vencimiento", "kind": "date" },
+        { "name": "status", "label": "Estado", "kind": "text", "required": true, "default": "draft" },
+        { "name": "subtotal", "label": "Subtotal", "kind": "number", "default": "0" },
+        { "name": "tax", "label": "Impuestos", "kind": "number", "default": "0" },
+        { "name": "total", "label": "Total", "kind": "number", "default": "0" },
+        { "name": "notes", "label": "Notas", "kind": "multiline" }
+      ],
+      "on_submit": {
+        "kind": "seed_entity",
+        "entity": "sales_order",
+        "next_view": "list"
+      }
+    }
+  }
+}
diff --git a/examples/nakui-modules/suppliers/module.json b/examples/nakui-modules/suppliers/module.json
new file mode 100644
index 0000000..ab59d0e
--- /dev/null
+++ b/examples/nakui-modules/suppliers/module.json
@@ -0,0 +1,62 @@
+{
+  "id": "suppliers",
+  "label": "Proveedores",
+  "description": "Proveedores que abastecen el catálogo.",
+  "entities": [
+    {
+      "name": "supplier",
+      "label": "Proveedor",
+      "fields": [
+        { "name": "name", "label": "Razón social", "kind": "text", "required": true },
+        { "name": "tax_id", "label": "ID fiscal", "kind": "text", "required": true, "help": "RUT/RFC/NIT/EIN según país" },
+        { "name": "contact", "label": "Contacto", "kind": "text" },
+        { "name": "email", "label": "Email", "kind": "text" },
+        { "name": "phone", "label": "Teléfono", "kind": "text" },
+        { "name": "payment_terms_days", "label": "Términos de pago (días)", "kind": "number", "default": "30" },
+        { "name": "active", "label": "Activo", "kind": "boolean", "default": "true" }
+      ]
+    }
+  ],
+  "menu": [
+    { "label": "Listar", "view": "list", "icon": "🏭" },
+    { "label": "Nuevo", "view": "form", "icon": "✚" }
+  ],
+  "views": {
+    "list": {
+      "kind": "list",
+      "title": "Proveedores",
+      "entity": "supplier",
+      "columns": [
+        { "field": "name", "label": "Razón social", "weight": 2.5 },
+        { "field": "tax_id", "label": "ID fiscal", "weight": 1.2 },
+        { "field": "contact", "label": "Contacto", "weight": 1.5 },
+        { "field": "email", "label": "Email", "weight": 2.0 },
+        { "field": "payment_terms_days", "label": "Términos", "weight": 0.8 },
+        { "field": "active", "label": "Activo", "weight": 0.5 }
+      ],
+      "actions": [
+        { "kind": "open_view", "view": "form", "label": "✚ Nuevo" }
+      ],
+      "search_in": ["name", "tax_id", "contact", "email"]
+    },
+    "form": {
+      "kind": "form",
+      "title": "Nuevo proveedor",
+      "entity": "supplier",
+      "fields": [
+        { "name": "name", "label": "Razón social", "kind": "text", "required": true },
+        { "name": "tax_id", "label": "ID fiscal", "kind": "text", "required": true },
+        { "name": "contact", "label": "Persona de contacto", "kind": "text" },
+        { "name": "email", "label": "Email", "kind": "text" },
+        { "name": "phone", "label": "Teléfono", "kind": "text" },
+        { "name": "payment_terms_days", "label": "Términos de pago (días)", "kind": "number", "default": "30" },
+        { "name": "active", "label": "Activo", "kind": "boolean", "default": "true" }
+      ],
+      "on_submit": {
+        "kind": "seed_entity",
+        "entity": "supplier",
+        "next_view": "list"
+      }
+    }
+  }
+}