# Modules API — contrato publico de modulos C++

Contrato canonico de cada modulo C++ del registry. Una app DEBE poder integrar un modulo leyendo SOLO esta pagina, sin abrir el `.cpp`. Si una app inventa un patron distinto al documentado aqui, es bug — abrir issue o anadir la capacidad al modulo, NO improvisar.

Issue 0107 (modules-standardization). Mantenido en sync con `modules/*/module.md::version` por el slash command `/version`.

---

## Indice

1. [framework_cpp](#framework_cpp) — shell ImGui obligatorio
2. [data_table_cpp](#data_table_cpp) — tabla TQL completa
3. [Capability matrix consolidada](#capability-matrix-consolidada)
4. [Ciclo de vida de un modulo](#ciclo-de-vida-de-un-modulo)
5. [Como NO conectarse a un modulo (anti-patrones)](#como-no-conectarse-a-un-modulo)

---

## framework_cpp

**Static lib target:** `fn_framework`
**Header path:** `cpp/framework/app_base.h`
**Namespace:** `fn` + `fn_ui`
**Entry function:** `int fn::run_app(AppConfig cfg, std::function<void()> render_fn)`
**State:** no requerido (singleton interno via `fn::run_app` lifecycle)
**Linkage en apps:** transitivo via macro `add_imgui_app(<target> ...)` — NO se declara en `uses_modules`.
**Version actual:** `1.1.0`

### Opt-in

Apps cpp del registry obtienen `fn_framework` automaticamente via `add_imgui_app(<target> ...)`. NO listar miembros del framework en `uses_functions` ni en `uses_modules`.

### Ejemplo minimo

```cpp
#include "framework/app_base.h"

int main() {
    fn::AppConfig cfg;
    cfg.title  = "My App";
    cfg.width  = 1280;
    cfg.height = 720;
    cfg.about  = { "my_app", "1.0.0", "Lo que hace mi app." };
    cfg.log    = { "my_app.log", 1 };  // file relativo a local_files/, level Info
    return fn::run_app(cfg, []() {
        ImGui::Begin("Main");
        ImGui::Text("hello");
        ImGui::End();
    });
}
```

### API completa

| Campo `AppConfig` | Tipo | Default | Para que |
|---|---|---|---|
| `title` | `const char*` | `"fn_registry"` | Titulo de la ventana |
| `width` / `height` | `int` | 1280 / 720 | Tamano inicial |
| `vsync` | `bool` | `true` | VSync GL |
| `viewports` | `bool` | `true` | Multi-viewport (paneles dragged-out fuera del main) |
| `theme` | `ThemeMode` | `FnDark` | `FnDark` / `ImGuiDark` / `ImGuiLight` / `None` |
| `bg_r/g/b` | `float` | `fn_tokens::colors::bg` | Color background main |
| `about` | `AppAboutInfo` | `{}` | `name`, `version`, `description` para About window |
| `panels` + `panel_count` | `PanelToggle*` | `nullptr` | Toggles del menubar (paneles abre/cierra) |
| `layouts_cb` | `LayoutCallbacks*` | `nullptr` | Override del menu Layouts (default: auto-storage SQLite) |
| `auto_layouts` | `bool` | `true` | Si crear `LayoutStorage` por defecto |
| `auto_layouts_db` | `const char*` | `"layouts.db"` | Archivo SQLite relativo a `local_files/` |
| `view_extras` | `std::function<bool()>` | `{}` | Items extra en menu View |
| `init_gl_loader` | `bool` | `false` | Solo si la app llama `gl*` directo |
| `auto_dockspace` | `bool` | `true` | DockSpaceOverViewport antes de `render_fn` cada frame |
| `log` | `AppLogConfig` | `{}` | `file_path`, `level` 0-3 |
| `pre_frame` | `std::function<void()>` | `{}` | Hook antes del menubar (LayoutStorage custom apply) |

### Capacidades automaticas (zero opt-in)

- Window dark titlebar (Windows DWMWA_USE_IMMERSIVE_DARK_MODE).
- AltSnap-safe sizemove + Alt+RMB resize + Alt+LMB move (via Win32 WndProc subclass per HWND).
- App icon attach a HWND main + viewports secundarios (si `<app_dir>/appicon.ico` existe).
- Layouts persistentes (save/load/list/delete/reset) con restore-on-open + save-on-close.
- About / Settings / Logs windows + menubar.
- `imgui.ini` + `app_settings.ini` bajo `<exe_dir>/local_files/`.
- Migration desde cwd/exe_dir a `local_files/` para instalaciones viejas.

### Helpers publicos

```cpp
const char* fn::exe_dir();              // dir del exe
const char* fn::local_dir();            // <exe_dir>/local_files/
const char* fn::local_path(const char*); // <local_dir>/<name>
const char* fn::asset_dir();            // <exe_dir>/assets/
const char* fn::asset_path(const char*); // <asset_dir>/<name>
void fn::migrate_to_local_files(const char* const* names, size_t n);
const char* fn::framework_version();
const char* fn::framework_description();
```

### Gotchas

- `Restart=always` en systemd unit (NO `on-failure`) — `SIGTERM` limpio = exit success.
- App write-paths SIEMPRE via `fn::local_path("X")`. NO `fopen("X.db", ...)` con path relativo.
- `init_gl_loader = true` SOLO si la app llama GL directo (`gl_loader_init`). ImGui/ImPlot puro NO lo necesita.
- Anti-jitter pausa render durante `WM_ENTERSIZEMOVE` — apps con telemetria live deben tolerar pausa transitoria.

---

## data_table_cpp

**Static lib target:** `fn_module_data_table`
**Header path:** `data_table/data_table.h`
**Namespace:** `data_table`
**Entry function:** `void data_table::render(const char* id, const std::vector<TableInput>& tables, State& state, std::vector<TableEvent>* events_out, bool show_chrome)`
**State:** `data_table::State` opaco. Debe persistir entre frames (`static` per panel, NO stack-local).
**Linkage en apps:** explicito.
**Version actual:** `1.4.0` (sera `2.0.0` post 0107c).

### Opt-in en una app

1. `app.md` frontmatter:
   ```yaml
   uses_modules:
     - name: data_table_cpp
       min_version: "1.4"   # o "2.0" post 0107c
   ```
2. `CMakeLists.txt`:
   ```cmake
   target_link_libraries(<app> PRIVATE fn_module_data_table)
   ```
3. Header en el `.cpp`:
   ```cpp
   #include "data_table/data_table.h"
   #include "core/data_table_types.h"
   ```
4. Reservar `data_table::State` persistente entre frames. NO ponerla en stack.

### Tipos publicos

```cpp
struct TableInput {
    std::string                name;          // identificador estable (joins)
    std::vector<std::string>   headers;       // names de columna
    std::vector<ColumnType>    types;         // String/Int/Float/Bool/Date/Json/Auto
    const char* const*         cells;         // row-major, cells[r*cols + c]
    int                        rows;
    int                        cols;
    std::vector<ColumnSpec>    column_specs;  // renderer config per col
};

enum class ColumnType : uint8_t {
    Auto = 0, String, Int, Float, Bool, Date, Json,
};

struct ColumnSpec {
    std::string  id;                         // estable; usado en TQL
    CellRenderer renderer = CellRenderer::Text;

    // Renderer-specific (ver tabla mas abajo)
    std::vector<BadgeRule>     badges;
    std::vector<ChipRule>      chips;
    std::vector<IconMapEntry>  icon_map;
    bool                       progress_scale_100 = false;
    std::string                progress_color_hex;
    float                      duration_warn_ms   = 1000.0f;
    float                      duration_error_ms  = 5000.0f;
    std::string                button_action;
    std::string                button_label;
    std::string                button_color_hex;
    char                       dots_separator     = ',';
    float                      dots_glyph_size    = 0.0f;
    int                        dots_max           = 0;
    bool                       dots_show_count    = false;
    double                     range_min          = 0.0;
    double                     range_max          = 1.0;
    float                      range_alpha        = 0.25f;
    std::vector<ColorStop>     color_stops;
    std::string                tooltip;             // "auto" -> show cell value
    bool                       tooltip_on_hover = false;
};

enum class CellRenderer : uint8_t {
    Text=0, Badge=1, Progress=2, Duration=3, Icon=4, Button=5,
    Dots=8, CategoricalChip=9, ColorScale=10,
};

struct BadgeRule    { std::string value, color_hex, label; };
struct ChipRule     { std::string match, color; };
struct IconMapEntry { std::string value, icon_name, color_hex; };
struct ColorStop    { float position; std::string color; };

enum class TableEventKind : uint8_t {
    ButtonClick=1, RowDoubleClick=2, RowRightClick=3, CellEdit=4,
};

struct TableEvent {
    TableEventKind kind;
    int            row;            // index en TableInput, NO en stage output
    int            col;
    std::string    column_id;      // ColumnSpec.id de la columna clicada
    std::string    action_id;      // para ButtonClick: ColumnSpec.button_action
    std::string    value;          // valor de la celda
};

enum class JoinStrategy : uint8_t { Left, Inner, Right, Full };
```

### Capacidad 1: render basico (Text)

Caso minimo — todas las columnas como texto plano.

```cpp
static data_table::State st;
data_table::TableInput tbl;
tbl.name    = "items";
tbl.headers = {"id", "name", "price"};
tbl.types   = {data_table::ColumnType::String,
               data_table::ColumnType::String,
               data_table::ColumnType::Float};
tbl.cells   = &cells_flat[0];   // row-major
tbl.rows    = N;
tbl.cols    = 3;

data_table::render("##items_tbl", {tbl}, st);
```

NO `column_specs` necesario — `CellRenderer::Text` es default. Tabla aparece con sort, filter chips, freeze cols, todo gratis.

### Capacidad 2: Badge — colored badge per value

Para columnas con valores categoricos (status, version, env).

```cpp
tbl.column_specs.resize(tbl.cols);
for (int i = 0; i < tbl.cols; i++) tbl.column_specs[i].id = tbl.headers[i];

data_table::ColumnSpec& cs = tbl.column_specs[1];   // col "status"
cs.renderer = data_table::CellRenderer::Badge;
cs.badges = {
    data_table::BadgeRule{"ok",      "#22c55e", ""},     // verde
    data_table::BadgeRule{"error",   "#ef4444", "ERR"},  // rojo + label custom
    data_table::BadgeRule{"running", "#f59e0b", ""},     // amber
};
```

- Match exacto, case-sensitive.
- `label` vacio = usa el valor de la celda como texto.
- Si ningun rule matchea, se renderiza como Text (sin badge).

### Capacidad 3: CategoricalChip — dot + texto

Igual que Badge pero solo dot de color a la izquierda del texto. Menos visual que Badge.

```cpp
cs.renderer = data_table::CellRenderer::CategoricalChip;
cs.chips    = {
    {"go",  "#3b82f6"},
    {"py",  "#22c55e"},
    {"cpp", "#a855f7"},
};
```

Pattern canonico apps:
- `app_gestion`: `status_col` + `enabled_col`.
- `data_factory`: kind columns + status columns.
- `registry_dashboard`: language column.

### Capacidad 4: ColorScale — gradient en background de celda

Numerico continuo. Mapea `range_min..range_max` a gradient (default green→amber→red).

```cpp
cs.renderer    = data_table::CellRenderer::ColorScale;
cs.range_min   = 0.0;
cs.range_max   = 5000.0;       // ms
cs.range_alpha = 0.30f;         // bajo para legibilidad

// Opcional: custom stops
cs.color_stops = {
    {0.0f, "#22c55e"},
    {0.5f, "#f59e0b"},
    {1.0f, "#ef4444"},
};
```

Pattern canonico: `duration_ms`, `latency_ms`, `bytes_transferred`. Apps: `data_factory` (node_runs), `services_monitor` (health latency).

### Capacidad 5: Duration — milisegundos con thresholds

Especifico para tiempos. Renderiza valor formateado (`123ms`, `1.2s`) + color por threshold.

```cpp
cs.renderer          = data_table::CellRenderer::Duration;
cs.duration_warn_ms  = 1000.0f;
cs.duration_error_ms = 5000.0f;
```

< warn = verde, warn..error = amber, > error = rojo.

### Capacidad 6: Progress — barra de progreso

```cpp
cs.renderer            = data_table::CellRenderer::Progress;
cs.progress_scale_100  = false;   // true -> valor en 0..100, false -> 0..1
cs.progress_color_hex  = "#3b82f6"; // override; "" -> ImPlot auto
```

### Capacidad 7: Icon — Tabler icon lookup

```cpp
cs.renderer = data_table::CellRenderer::Icon;
cs.icon_map = {
    {"ok",      "TI_CHECK",  "#22c55e"},
    {"error",   "TI_X",      "#ef4444"},
    {"pending", "TI_CIRCLE", "#a3a3a3"},
};
```

Iconos disponibles: `cpp/functions/core/icons_tabler.h` (Tabler v3.41, 1500+ glyphs).

### Capacidad 8: Button — clickable + event sink

Genera click events que la app procesa.

```cpp
cs.renderer        = data_table::CellRenderer::Button;
cs.button_action   = "retry_run";     // semantic id que la app reconoce
cs.button_label    = "Retry";         // "" -> usa valor de la celda como label
cs.button_color_hex = "#3b82f6";       // "" -> color default
```

Consumir events:

```cpp
std::vector<data_table::TableEvent> events;
data_table::render("##runs", {tbl}, st, &events);

for (const auto& ev : events) {
    if (ev.kind == data_table::TableEventKind::ButtonClick) {
        if (ev.action_id == "retry_run") {
            retry_job(ev.value);  // ev.value = celda donde se clico
        }
        else if (ev.action_id == "cancel_job") {
            cancel_job(ev.value);
        }
    }
}
```

`events_out` puede ser `nullptr` si no quieres consumir eventos (back-compat).

### Capacidad 9: Dots — inline status sparkline

Celda contiene tokens separados (`"ok,error,ok,ok"`). Renderiza dots de color por token.

```cpp
cs.renderer        = data_table::CellRenderer::Dots;
cs.badges          = {  // reutiliza BadgeRule.color_hex (NO label)
    {"ok",     "#22c55e", ""},
    {"error",  "#ef4444", ""},
};
cs.dots_separator  = ',';
cs.dots_glyph_size = 0.0f;   // 0 = default font size
cs.dots_max        = 10;     // hard limit; 0 = unlimited
cs.dots_show_count = true;   // append " (N)" tras los dots
```

Pattern canonico: timeline de status de runs recientes.

### Capacidad 10: Tooltip por celda

```cpp
cs.tooltip          = "auto";        // muestra el valor de la celda en hover
// o:
cs.tooltip          = "Click to retry";
cs.tooltip_on_hover = true;
```

### Capacidad 11: RowDoubleClick — drill / open detail

Sin setup especial. Solo consumir el event.

```cpp
for (const auto& ev : events) {
    if (ev.kind == data_table::TableEventKind::RowDoubleClick) {
        open_detail_modal(ev.row);
    }
}
```

`ev.row` es el indice en `TableInput.cells`, NO en el output del stage activo si hay TQL aplicado.

### Capacidad 12: RowRightClick — context menu app-side

Igual que RowDoubleClick. App abre su propio popup ImGui cuando recibe el event.

```cpp
if (ev.kind == data_table::TableEventKind::RowRightClick) {
    g_ctx_menu_row = ev.row;
    ImGui::OpenPopup("##row_ctx");
}
// En el siguiente frame:
if (ImGui::BeginPopup("##row_ctx")) {
    if (ImGui::MenuItem("Inspect")) inspect(g_ctx_menu_row);
    if (ImGui::MenuItem("Delete"))  delete_row(g_ctx_menu_row);
    ImGui::EndPopup();
}
```

### Capacidad 13: Joins entre tablas

Pasar multiples `TableInput`. El usuario configura join via UI de chips (drag table → drop). El primer `tables[0]` es main, los demas son joinables.

```cpp
data_table::TableInput main_tbl, lookup_tbl;
// ... fill ambas ...
data_table::render("##joined", {main_tbl, lookup_tbl}, st, &events);
```

Strategies: `JoinStrategy::{Left, Inner, Right, Full}`. Default Left.

### Capacidad 14: TQL pipeline (filter / breakout / agg / sort)

Pipeline declarativo construido por usuario via chips bar. Estado en `State.stages[]`. App no toca esto — el modulo lo gestiona.

Stages adicionales (`State.stages.resize(N)`) si la app quiere PRE-cargar un pipeline:

```cpp
data_table::Stage s;
s.filters.push_back({0, data_table::Op::Eq, "go"});      // col 0 == "go"
s.breakouts.push_back("lang");                            // group by lang
s.aggregations.push_back({data_table::AggFn::Count});     // count(*)
st.stages.push_back(s);
st.active_stage = (int)st.stages.size() - 1;
```

Agg fns: `Count`, `Sum`, `Avg`, `Min`, `Max`, `Median`, `P95`, `Distinct`.

### Capacidad 15: Ask AI panel

Usuario abre via boton "Ask AI" en chips bar. Modal con prompt natural language → llama `llm_anthropic::ask` (si `FN_LLM_ANTHROPIC` definido) → devuelve TQL o SQL ejecutable → Apply mutates `State.stages`.

App NO necesita setup. Solo `ANTHROPIC_API_KEY` en env.

### Capacidad 16: Color rules condicionales

Reglas de color que el usuario configura via header menu → "Conditional color". Tres tipos:

- **CellBg**: color de fondo fijo si valor matchea.
- **CategoricalDot**: dot de color izquierdo (igual que `chips` pero configurado en runtime por usuario).
- **NumericRange**: gradient sobre rango numerico (igual que `ColorScale` pero por regla, no por spec).

Persistencia en `State.stages[k].color_rules` — la app puede preset reglas en su setup si quiere, pero normal es dejar al usuario.

### Capacidad 17: Drill-down between stages

Usuario click derecho en celda de stage > 0 → "Drill into N=valor" → anade filter al stage previo, activa ese stage. Stack de back/forward en `State.drill_back/forward`.

App no toca. Solo `data_table::render` lo provee.

### Capacidad 18: Save/Load TQL desde disco

Modal "Save TQL" / "Load TQL" — escribe/lee `<state>.tql` en `local_files/`. App no toca.

### Capacidad 19: Export CSV

Boton "Export CSV" en chips bar. Escribe el output del stage activo a CSV. App no toca.

### Capacidad 20: `show_chrome` toggle

Si `show_chrome=false`, oculta chips bar + breadcrumb. Util para dashboards densos donde la tabla es read-only. Usuario puede reactivar via boton "Show UI".

```cpp
data_table::render("##compact", {tbl}, st, &events, /*show_chrome=*/false);
```

### Capacidad 21: Module version metadata (post 0107c)

```cpp
const char* data_table::module_version();      // "2.0.0"
const char* data_table::module_description();
```

Para que About panel de la app muestre version del modulo, no solo del app.

### Cuando usar `data_table::render` vs `ImGui::BeginTable` directo

| Caso | API correcta | Razon |
|---|---|---|
| **Tabla de datos** con N filas dinamicas, sort/filter/freeze/drill | `data_table::render` | Es lo que el modulo aporta |
| **KPI grid** (4-8 cards en grid de columnas) | `ImGui::BeginTable` directo | No es tabla de datos — layout helper |
| **Form key/value** (inspector, settings, schema metadata) | `ImGui::BeginTable` directo | Form fijo, columnas estaticas |
| **2-col layout** (tree izda / detail dcha) | `ImGui::BeginTable` directo | Splitter pattern, no data table |
| **Chart grid** (4 plots lado a lado) | `ImGui::BeginTable` directo | Layout multi-panel, no datos |
| **Schema editor row-form** (1 fila por field, con inputs editables por columna) | discutible — `ImGui::BeginTable` si rows < 20, `data_table::render` con `CellRenderer::Button`/edit si rows >> 20 | Depende del scale |
| **Recipes / Flows / Issues list** (data dinamica con sort/filter) | `data_table::render` (migrar si esta inline) | Es tabla de datos canonica |

Regla heuristica: si la tabla necesita SORT, FILTER, FREEZE o RENDERERS DECLARATIVOS → usar `data_table::render`. Si es solo layout → BeginTable directo OK.

Audit automatico: `mcp__registry__fn_run audit_data_table_usage` detecta apps con `inline_begintable` warnings + verifica `state_not_persistent`, `no_child_host`, `no_event_sink`, `cmake_missing_link`. Output formato `dev/data_table_integration_audit.md`.

### Host container — BeginChild recommended

`data_table::render` NO crea su propio `BeginChild` para el contenido principal (si para sub-paneles de viz). Si la app no envuelve la llamada en un host con bounds definidos:

- La tabla se renderiza inline con `ImGui::GetContentRegionAvail()` actual.
- Si el contenedor padre es scrollable, la tabla NO scrollea independientemente — comparte el scroll del padre.
- Si hay widgets DESPUES del `render()` en el mismo Begin/BeginChild, compiten por espacio.

Pattern recomendado:

```cpp
ImGui::BeginChild("##tbl_host", ImVec2(-1, -1));   // o tamano fijo
data_table::render("##my_tbl", {tbl}, st, &events);
ImGui::EndChild();
```

Excepcion: si la tabla es lo UNICO que renderiza dentro de su `ImGui::Begin(...)` window, BeginChild es opcional. El audit lo marca como `no_child_host [warn]`, no error.

### Gotchas

- **`State` debe persistir entre frames**: `static` per panel. Si la pones en stack, pierdes filter chips/sort/etc cada frame. Cada panel necesita SU `State` (no compartir entre tablas distintas).
- **`cells` row-major**: `cells[row*cols + col]`. Apps que pasan column-major rompen sin warning.
- **`TableInput.name` no vacio si hay joins**: si declaras varias tablas, cada una con `name` unico para que el join UI las identifique.
- **`column_specs.size() == cols`**: si no resizeas, el modulo asume `CellRenderer::Text` para columnas faltantes.
- **`ColumnSpec.id` debe ser estable** entre frames. Si cambia, las preferencias de sort/filter del usuario sobre esa col se pierden.
- **`button_action` debe ser unico** dentro del scope semantic de la app. Apps mismas que reusan `"retry"` en multiples columnas/contextos deberian diferenciar (`"retry_run"`, `"retry_test"`).
- **`badges` vs `chips`**: Badge = fondo coloreado completo + label; CategoricalChip = solo dot + texto plano. Eligir segun densidad visual deseada.
- **`Dots` reusa `badges`** map pero IGNORA `BadgeRule.label`. Solo `color_hex` por token.
- **`ColorScale` requiere ColumnType numerico** (`Float`, `Int`, `Date`). String no calcula gradient.
- **`tooltip = "auto"`** = string magico literal. NO `tooltip.empty()`.
- **events_out se LIMPIA cada frame** — la app debe leerlo INMEDIATAMENTE tras `render()`. No acumular entre frames.
- **`events.row` NO indexa al output del stage** — siempre apunta a `TableInput.cells` original. Si tu app hizo TQL pipeline, mapea manualmente via `state.row_mapping` si necesitas el row del output.
- **Multi-instancia**: cada `data_table::render` debe usar `id` unico (`"##tbl_a"`, `"##tbl_b"`). Si dos paneles comparten id, ImGui se confunde.

### Pattern canonico end-to-end (replicable)

```cpp
// Setup BadgeRule sets reusables a nivel de app
static std::vector<data_table::BadgeRule> run_status_badges() {
    return {
        {"ok",      "#22c55e", ""},
        {"error",   "#ef4444", "ERR"},
        {"running", "#f59e0b", ""},
        {"pending", "#a3a3a3", ""},
    };
}

// Inside panel render
static data_table::State st;
data_table::TableInput tbl;
tbl.name    = "runs";
tbl.headers = {"id", "status", "duration_ms", "started_at", ""};
tbl.types   = {
    data_table::ColumnType::String,
    data_table::ColumnType::String,
    data_table::ColumnType::Float,
    data_table::ColumnType::Date,
    data_table::ColumnType::String,   // actions col (vacio, solo button)
};
tbl.cells = &flat_cells[0];
tbl.rows  = N;
tbl.cols  = 5;

tbl.column_specs.resize(tbl.cols);
for (int i = 0; i < tbl.cols; i++) tbl.column_specs[i].id = tbl.headers[i].empty()
    ? std::string("actions") : tbl.headers[i];

// Status → CategoricalChip
tbl.column_specs[1].renderer = data_table::CellRenderer::CategoricalChip;
tbl.column_specs[1].chips    = {
    {"ok", "#22c55e"}, {"error", "#ef4444"}, {"running", "#f59e0b"},
};

// duration_ms → ColorScale + Duration thresholds combinados
tbl.column_specs[2].renderer          = data_table::CellRenderer::Duration;
tbl.column_specs[2].duration_warn_ms  = 1000.0f;
tbl.column_specs[2].duration_error_ms = 5000.0f;

// Actions col → Button retry
tbl.column_specs[4].renderer      = data_table::CellRenderer::Button;
tbl.column_specs[4].button_action = "retry_run";
tbl.column_specs[4].button_label  = "Retry";
tbl.column_specs[4].tooltip       = "Re-run this job";
tbl.column_specs[4].tooltip_on_hover = true;

// Render
std::vector<data_table::TableEvent> events;
data_table::render("##runs_tbl", {tbl}, st, &events);

// Consume events
for (const auto& ev : events) {
    if (ev.kind == data_table::TableEventKind::ButtonClick && ev.action_id == "retry_run") {
        retry_run_by_id(ev.value);  // ev.value = id (col 0 de la fila clicada... NO)
        //                          ^ CORRECCION: ev.value = valor de la celda del boton
        //                            = "Retry" si button_label fijo. Para obtener el ID
        //                            de la fila: mapear via ev.row y leer tbl.cells[ev.row*cols + 0]
    }
    if (ev.kind == data_table::TableEventKind::RowDoubleClick) {
        open_run_detail_modal(/*run_id=*/ tbl.cells[ev.row * tbl.cols + 0]);
    }
}
```

---

## Capability matrix consolidada

| Capacidad | Renderer / Event | ColumnSpec fields | Apps que la usan hoy |
|---|---|---|---|
| Plain text | `Text` (default) | — | todas |
| Colored badge | `Badge` | `badges` | registry_dashboard, app_gestion (version pinning) |
| Categorical dot | `CategoricalChip` | `chips` | services_monitor, dag_engine_ui, data_factory |
| Numeric heatmap | `ColorScale` | `range_min/max/alpha`, `color_stops` | data_factory (duration), services_monitor (latency) |
| Duration with thresholds | `Duration` | `duration_warn_ms`, `duration_error_ms` | data_factory (node_runs) |
| Progress bar | `Progress` | `progress_scale_100`, `progress_color_hex` | (raro — candidato para nuevos consumers) |
| Tabler icon | `Icon` | `icon_map` | (raro — candidato) |
| Click button | `Button` + ButtonClick | `button_action`, `button_label`, `button_color_hex` | graph_explorer (cancel/delete_job), data_factory |
| Status timeline | `Dots` | `dots_separator`, `dots_max`, `badges` map | registry_dashboard (recent runs) |
| Hover tooltip | (cualquier renderer) | `tooltip`, `tooltip_on_hover` | varios |
| Open detail | RowDoubleClick | — | app_gestion, data_factory, services_monitor |
| Context menu | RowRightClick | — | (raro) |
| Joins | (UI-driven) | usar varios `TableInput` | (raro — UI feature) |
| TQL pipeline | (UI-driven) | preset via `State.stages` | data_factory (preload filters) |
| Ask AI | (UI-driven) | `FN_LLM_ANTHROPIC` env | usuario lo dispara |
| Color rules runtime | (UI-driven) | `State.stages[k].color_rules` | usuario lo configura |
| Drill-down | (UI-driven) | `State.drill_back/forward` | usuario lo dispara |
| Save/Load TQL | (UI-driven) | — | usuario lo dispara |
| Export CSV | (UI-driven) | — | usuario lo dispara |
| Hide chrome | `show_chrome=false` | parametro de render() | dashboards densos |

---

## Como NO conectarse a un modulo

Anti-patrones que generan "conexiones raras entre apps":

| Anti-patron | Consecuencia | Sustituir por |
|---|---|---|
| Llamar funciones internas del modulo (helpers `static` en `data_table.cpp`) via include de path interno | Acoplamiento fuerte; rompe en cualquier refactor | API publica `data_table::*` solo |
| Reescribir logica que el modulo ya hace (parser TQL propio, color picker propio) | Duplicacion + drift | Usar API; si falta capacidad, abrir issue |
| Acceder a `State` fields directamente sin pasar por la API | State es opaco — campos pueden cambiar versiones | Usar la UI (chips bar) o setear `State.stages` segun esta doc |
| Inventar nuevos `action_id` cross-app esperando que otro modulo lo entienda | events son scoped a la app que los emite | Cada app maneja sus propios action_ids |
| Asumir `events_out` acumula entre frames | Se limpia cada frame | Procesar inmediatamente despues de `render()` |
| Pasar `State` por valor / hacer copia | Pierde sort/filter/drill cada frame | `static data_table::State st;` referencia |
| Compartir `State` entre dos `render()` con distintos `id` | UI dual-rendering corrupta | UN State per panel |
| Reuso de `column_specs` con `id` que cambia entre frames | Sort/filter preferences perdidas | `id` estable como `TableInput.headers[i]` |
| Saltarse `column_specs.resize(cols)` y solo settear algunas | Modulo asume `Text` para faltantes — comportamiento confuso | resize SIEMPRE a `cols` |
| Pasar TableInput de UNA tabla con `name=""` y esperar joins | Joins necesitan name unico | Setear `name` siempre |
| Mezclar Badge + CategoricalChip + Dots en misma columna (no compatible) | Solo uno por columna | Eligir basado en densidad visual |
| Acoplar app a header interno (`modules/data_table/data_table_internal.h`) | header NO PUBLICO — solo para .cpp del modulo | API publica `data_table/data_table.h` solo |

---

## Ciclo de vida de un modulo

Crear modulo nuevo (ej. `chat_ia_cpp` tras 0107 cerrar):

1. `mkdir modules/chat_ia/` con:
   - `module.md` (frontmatter: name, version, description, members, dir_path).
   - `CMakeLists.txt` definiendo `add_library(fn_module_chat_ia STATIC ...)`.
   - `chat_ia.cpp` + `chat_ia.h` (entry function).
   - `chat_ia_internal.h` (compartido entre sub-cpp del modulo si tiene >1 archivo).

2. Anadir `add_subdirectory` en `cpp/CMakeLists.txt`.

3. Crear seccion en este doc (`docs/MODULES_API.md`) siguiendo el template de `data_table_cpp`.

4. Anadir fila en `modules/README.md`.

5. `fn index` registra el modulo en `registry.db::modules`.

6. Cada bump de version: `/version modules/chat_ia <major|minor|patch> "<reason>"`. Edita `module.md::version` + `## Capability growth log`.

7. Apps consumidoras declaran:
   ```yaml
   uses_modules:
     - name: chat_ia_cpp
       min_version: "0.1"
   ```
   + `target_link_libraries(<app> PRIVATE fn_module_chat_ia)`.

8. `fn doctor modules` audita drift uses_modules vs uses_functions (0107a).

---

## E2E testing por modulo

Cada modulo declara su test en `modules/<name>/e2e_run.sh` (o equivalente). Ver issue 0108 para el testbed agresivo de `data_table_cpp` que combina:

- Catch2 unit (`cpp/tests/`) para sub-funciones puras.
- Golden PNG diff (`primitives_gallery --capture`).
- Auto-driving worker (modelo `altsnap_jitter_test`).
- Dear ImGui Test Engine (`FN_BUILD_TESTS=ON`).
- `--self-test` flag headless.
- Cross-platform run (`e2e_run_cpp_windows.sh`).

Modulos sin testbed dedicado → al menos build smoke + linkage de >=2 apps consumidoras debe verificarse en `e2e_run.sh`.

---

## Mantenimiento de este doc

- Actualizar tras cada bump de version de un modulo (referenciado por `/version`).
- Si una app introduce un patron nuevo no listado en capability matrix → anadirlo aqui ANTES de commit (o la fila queda undocumentada).
- `fn doctor modules` (0107a) verifica que cada modulo en `registry.db::modules` tiene seccion aqui. Si no, WARN.
- Generado parcialmente desde `module.md` via codegen futuro (issue postponed). Hoy mantenido a mano.
