# functions

id: fps_overlay_cpp_core
name: fps_overlay
kind: component
lang: cpp
domain: core
version: 1.0.0
purity: pure
signature: void fps_overlay()
tags: ["imgui","fps","overlay","profiling","debug"]
uses_functions: []
uses_types: []
returns: []
imports: ["imgui"]
tests: []
file_path: cpp/functions/core/fps_overlay.cpp
created_at: 2026-04-06T21:31:43Z
updated_at: 2026-04-06T21:31:43Z
props: []
emits: []
framework: imgui
variant: []
content_hash: 1a1d25ddac5db7eef21dee59a15e38cd6ace1059e873d19556df7319a5b801d8
params_schema: {"output":"Renderiza el overlay de FPS en el frame ImGui actual"}
description:
  Renderiza un overlay de FPS y frametime en la esquina superior derecha, con soporte opcional de Tracy

documentation:
  # fps_overlay

  Muestra FPS y frametime (ms) en una ventana semi-transparente en la esquina superior derecha.

  Si se compila con `TRACY_ENABLE`, incluye un `ZoneScoped` para profiling con Tracy.

code:
  #include "core/fps_overlay.h"
  #include "imgui.h"

  #ifdef TRACY_ENABLE
  #include "tracy/Tracy.hpp"
  #endif

  void fps_overlay() {
  #ifdef TRACY_ENABLE
      ZoneScoped;
  #endif

      ImGuiIO& io = ImGui::GetIO();
      ImGuiWindowFlags flags = ImGuiWindowFlags_NoDecoration
                             | ImGuiWindowFlags_AlwaysAutoResize
                             | ImGuiWindowFlags_NoSavedSettings
                             | ImGuiWindowFlags_NoFocusOnAppearing
                             | ImGuiWindowFlags_NoNav
                             | ImGuiWindowFlags_NoMove;

      const float pad = 10.0f;
      const ImGuiViewport* viewport = ImGui::GetMainViewport();
      ImVec2 pos(viewport->WorkPos.x + viewport->WorkSize.x - pad,
                 viewport->WorkPos.y + pad);
      ImGui::SetNextWindowPos(pos, ImGuiCond_Always, ImVec2(1.0f, 0.0f));
      ImGui::SetNextWindowBgAlpha(0.65f);

      if (ImGui::Begin("##fps_overlay", nullptr, flags)) {
          ImGui::Text("%.1f FPS", io.Framerate);
          ImGui::Text("%.3f ms", 1000.0f / io.Framerate);
      }
      ImGui::End();
  }


id: fullscreen_window_cpp_core
name: fullscreen_window
kind: component
lang: cpp
domain: core
version: 0.1.0
purity: pure
signature: bool fullscreen_window_begin(const char* id = "##fullscreen"); void fullscreen_window_end()
tags: ["imgui","layout","fullscreen","window"]
uses_functions: []
uses_types: []
returns: []
imports: ["imgui"]
tests: []
file_path: cpp/functions/core/fullscreen_window.cpp
created_at: 2026-04-07T22:24:27Z
updated_at: 2026-04-07T22:24:27Z
props: []
emits: []
framework: imgui
variant: []
content_hash: cf3510f5959117e66eb340deae1d7b929588aa536f25ab649c655615a3c5a289
description:
  Ventana ImGui fullscreen sin decoraciones que ocupa todo el viewport, elimina la necesidad de usar el sistema de ventanas interno

notes:
  - Pura en el sentido de que no hace I/O ni tiene estado propio; solo configura el estado next-frame de ImGui.
  - `WorkPos`/`WorkSize` respetan los menu bars del sistema operativo (en plataformas que los tienen). Para ocupar literalmente toda la pantalla usar `Pos`/`Size` del viewport.
  - Compatible con `dashboard_grid` y `dashboard_panel`: el fullscreen_window actua como contenedor raiz y los paneles/grids se renderizan dentro.
  - El patron begin/end es idiomatico en ImGui: `end` debe llamarse siempre para cerrar la ventana correctamente, aunque `begin` retorne false.

documentation:
  # fullscreen_window

  Wrapper que crea una ventana ImGui que ocupa exactamente el viewport de trabajo (`WorkPos` / `WorkSize`). Elimina todas las decoraciones: title bar, resize grip, move, collapse. Ideal como capa raiz de una aplicacion ImGui donde el contenido propio gestiona el layout.

  ## Uso

  ```cpp
  if (fullscreen_window_begin()) {
      // todo el layout de la app va aqui
      dashboard_grid_begin(3, 8.0f);
      // ...
      dashboard_grid_end();
  }
  fullscreen_window_end(); // siempre llamar
  ```

  Con ID explicito (si se necesitan multiples capas):

  ```cpp
  if (fullscreen_window_begin("##background")) {
      render_background();
  }
  fullscreen_window_end();
  ```

  ## Implementacion

  - `GetMainViewport()` obtiene el viewport principal (compatible con viewports multi-monitor de ImGui)
  - `SetNextWindowPos(vp->WorkPos)` posiciona en el area de trabajo (excluye menu bars del OS)
  - `SetNextWindowSize(vp->WorkSize)` ocupa exactamente el area disponible
  - Flags: `NoTitleBar | NoResize | NoMove | NoCollapse | NoBringToFrontOnFocus | NoNavFocus`
  - `NoBringToFrontOnFocus` y `NoNavFocus` evitan que la ventana fullscreen robe el foco de ventanas superpuestas

code:
  #include "core/fullscreen_window.h"
  #include "imgui.h"

  bool fullscreen_window_begin(const char* id) {
      const ImGuiViewport* vp = ImGui::GetMainViewport();
      ImGui::SetNextWindowPos(vp->WorkPos);
      ImGui::SetNextWindowSize(vp->WorkSize);
      return ImGui::Begin(id, nullptr,
          ImGuiWindowFlags_NoTitleBar      |
          ImGuiWindowFlags_NoResize        |
          ImGuiWindowFlags_NoMove          |
          ImGuiWindowFlags_NoCollapse      |
          ImGuiWindowFlags_NoBringToFrontOnFocus |
          ImGuiWindowFlags_NoNavFocus);
  }

  void fullscreen_window_end() {
      ImGui::End();
  }

params_schema:
  {"params":[{"name":"id","desc":"Identificador ImGui de la ventana, default ##fullscreen (el prefijo ## oculta el texto del titulo)"}],"output":"true si la ventana es visible (siempre true en fullscreen); llamar siempre fullscreen_window_end() independientemente del valor de retorno"}


id: graph_spatial_hash_cpp_core
name: graph_spatial_hash
kind: function
lang: cpp
domain: core
version: 1.0.0
purity: pure
signature: SpatialHash(float cell_size, int table_size)
tags: ["spatial","hash","acceleration","graph","query","hittest"]
uses_functions: []
uses_types: []
returns: []
imports: []
tests: []
file_path: cpp/functions/core/graph_spatial_hash.cpp
created_at: 2026-04-07T22:04:57Z
updated_at: 2026-04-07T22:04:57Z
props: []
emits: []
framework: imgui
variant: []
content_hash: a5192801758a6cfb6edc9d9ed505000f8013c39539a6bf4bdff5a879177e81b4
description:
  Spatial hash grid para busqueda O(1) de puntos por posicion, usado para hit-testing de nodos en grafos

notes:
  - No es thread-safe: disenado para uso single-threaded en el hilo de render de ImGui.
  - `build()` debe llamarse cada frame si las posiciones de los nodos cambian.
  - `cell_size` debe ser >= al radio maximo de los nodos para que la vecindad 3x3 capture todos los candidatos posibles.
  - La distancia efectiva al nodo se calcula como `dist(centro) - size`, de modo que el hit-test funciona correctamente para nodos de distintos tamaños.
  - No implementa `operator=` ni copy constructor (deleted) para evitar doble-free de los arrays internos.

documentation:
  # graph_spatial_hash

  Spatial hash grid para busqueda eficiente de nodos en grafos por posicion 2D. Util para hit-testing de nodos en editores de grafos basados en ImGui, donde se necesita saber que nodo esta bajo el cursor en cada frame.

  ## Uso

  ```cpp
  SpatialHash sh(20.0f, 4096);

  // Reconstruir cada frame (o cuando cambian posiciones)
  sh.build(node_xs, node_ys, node_sizes, node_count);

  // Hit-test: nodo mas cercano al cursor
  float dist;
  int hovered = sh.query_nearest(mouse_x, mouse_y, 30.0f, &dist);

  // Seleccion por area: todos los nodos dentro de un radio
  int results[256];
  int count = sh.query_radius(center_x, center_y, radius, results, 256);
  ```

  ## Estructura interna

  - `buckets[table_size]`: cada entrada contiene el indice del primer slot de la cadena, o -1 si el bucket esta vacio.
  - `entries[2 * entry_capacity]`: pares `[node_index, next_in_chain]` almacenados como flat array. Se expande con `realloc` si el numero de nodos supera la capacidad inicial (256).
  - El snapshot de posiciones (`xs`, `ys`, `sizes`) se copia internamente en `build()` para que `query_nearest` y `query_radius` puedan calcular distancias sin recibir los arrays como parametro.

  ## Hash function

  ```
  hash(cx, cy) = (cx * 73856093 ^ cy * 19349663) % table_size
  ```

  Donde `cx = floor(x / cell_size)`, `cy = floor(y / cell_size)`.

code:
  #include "graph_spatial_hash.h"

  #include <cmath>
  #include <cstdlib>
  #include <cstring>

  // ---------------------------------------------------------------------------
  // Helpers
  // ---------------------------------------------------------------------------

  static inline int floor_div(float v, float cell) {
      return static_cast<int>(std::floor(v / cell));
  }

  static inline float sq(float x) { return x * x; }

  // ---------------------------------------------------------------------------
  // Snapshot de posiciones (almacenado internamente en build)
  // La interfaz del header no pasa xs/ys a los metodos de query, por lo que
  // SpatialHash conserva copias internas para calcular distancias.
  // ---------------------------------------------------------------------------

  struct SpatialHashSnap {
      float* xs    = nullptr;
      float* ys    = nullptr;
      float* sizes = nullptr;
      int    count = 0;
  };

  // Almacenamos el snapshot en memoria contigua al final del bloque de entries,
  // para no añadir campos al header publico. Usamos un puntero opaco en un
  // campo reservado. Como el header ya esta fijado, guardamos el snapshot como
  // una variable estatica por instancia — aceptable para uso single-threaded
  // tipico de ImGui. Para multi-instancia se puede usar un map.
  //
  // En la practica, la convencion del registry es que SpatialHash se crea una
  // vez por frame de ImGui y se usa en el mismo hilo de render.

  static SpatialHashSnap g_snap;   // snapshot mas reciente

  // ---------------------------------------------------------------------------
  // SpatialHash
  // ---------------------------------------------------------------------------

  int SpatialHash::cell_hash(int cx, int cy) const {
      unsigned int h = static_cast<unsigned int>(cx) * 73856093u
                     ^ static_cast<unsigned int>(cy) * 19349663u;
      return static_cast<int>(h % static_cast<unsigned int>(table_size));
  }

  SpatialHash::SpatialHash(float cell_size_, int table_size_)
      : cell_size(cell_size_)
      , table_size(table_size_)
      , entry_count(0)
      , entry_capacity(256)
  {
      buckets = static_cast<int*>(std::malloc(
          static_cast<std::size_t>(table_size) * sizeof(int)));

      entries = static_cast<int*>(std::malloc(
          static_cast<std::size_t>(entry_capacity) * 2 * sizeof(int)));

      std::memset(buckets, -1,
          static_cast<std::size_t>(table_size) * sizeof(int));
  }

  SpatialHash::~SpatialHash() {
      std::free(buckets);
      std::free(entries);

      std::free(g_snap.xs);
      std::free(g_snap.ys);
      std::free(g_snap.sizes);
      g_snap = {};
  }

  void SpatialHash::build(const float* xs, const float* ys, const float* sizes, int count) {
      // --- Limpiar tabla ---
      std::memset(buckets, -1,
          static_cast<std::size_t>(table_size) * sizeof(int));
      entry_count = 0;

      // --- Snapshot de posiciones para queries ---
      if (g_snap.count < count) {
          std::free(g_snap.xs);
          std::free(g_snap.ys);
          std::free(g_snap.sizes);
          g_snap.xs    = static_cast<float*>(std::malloc(static_cast<std::size_t>(count) * sizeof(float)));
          g_snap.ys    = static_cast<float*>(std::malloc(static_cast<std::size_t>(count) * sizeof(float)));
          g_snap.sizes = static_cast<float*>(std::malloc(static_cast<std::size_t>(count) * sizeof(float)));
      }
      std::memcpy(g_snap.xs,    xs,    static_cast<std::size_t>(count) * sizeof(float));
      std::memcpy(g_snap.ys,    ys,    static_cast<std::size_t>(count) * sizeof(float));
      std::memcpy(g_snap.sizes, sizes, static_cast<std::size_t>(count) * sizeof(float));
      g_snap.count = count;

      // --- Insertar nodos en la tabla hash ---
      for (int i = 0; i < count; ++i) {
          int cx     = floor_div(xs[i], cell_size);
          int cy     = floor_div(ys[i], cell_size);
          int bucket = cell_hash(cx, cy);

          // Crecer entries si necesario
          if (entry_count >= entry_capacity) {
              entry_capacity *= 2;
              entries = static_cast<int*>(std::realloc(
                  entries,
                  static_cast<std::size_t>(entry_capacity) * 2 * sizeof(int)));
          }

          // Insertar al frente de la cadena encadenada
          int slot = entry_count++;
          entries[slot * 2 + 0] = i;               // node_index
          entries[slot * 2 + 1] = buckets[bucket];  // next_in_chain
          buckets[bucket] = slot;
      }
  }

  int SpatialHash::query_nearest(float qx, float qy, float radius, float* out_dist) const {
      int   best_idx = -1;
      float best_d   = radius;  // umbral: solo aceptamos dentro del radio

      int qcx = floor_div(qx, cell_size);
      int qcy = floor_div(qy, cell_size);

      // Escanear vecindad 3x3 de celdas
      for (int dy = -1; dy <= 1; ++dy) {
          for (int dx = -1; dx <= 1; ++dx) {
              int bucket = cell_hash(qcx + dx, qcy + dy);
              int slot   = buckets[bucket];
              while (slot != -1) {
                  int ni      = entries[slot * 2 + 0];
                  float ex    = g_snap.xs[ni] - qx;
                  float ey    = g_snap.ys[ni] - qy;
                  float dist  = std::sqrt(sq(ex) + sq(ey)) - g_snap.sizes[ni];
                  if (dist < best_d) {
                      best_d   = dist;
                      best_idx = ni;
                  }
                  slot = entries[slot * 2 + 1];
              }
          }
      }

      if (out_dist && best_idx != -1)
          *out_dist = best_d;

      return best_idx;
  }

  int SpatialHash::query_radius(float qx, float qy, float radius, int* out, int max_out) const {
      int found = 0;

      int qcx = floor_div(qx, cell_size);
      int qcy = floor_div(qy, cell_size);

      for (int dy = -1; dy <= 1; ++dy) {
          for (int dx = -1; dx <= 1; ++dx) {
              int bucket = cell_hash(qcx + dx, qcy + dy);
              int slot   = buckets[bucket];
              while (slot != -1 && found < max_out) {
                  int ni     = entries[slot * 2 + 0];
                  float ex   = g_snap.xs[ni] - qx;
                  float ey   = g_snap.ys[ni] - qy;
                  float dist = std::sqrt(sq(ex) + sq(ey)) - g_snap.sizes[ni];
                  if (dist <= radius)
                      out[found++] = ni;
                  slot = entries[slot * 2 + 1];
              }
          }
      }

      return found;
  }

params_schema:
  {"params":[{"name":"cell_size","desc":"Tamaño de cada celda del grid espacial (debe ser \u003e= radio maximo de nodos)"},{"name":"table_size","desc":"Numero de buckets en la tabla hash (potencia de 2 recomendada para distribucion uniforme)"}],"output":"Estructura de hash espacial lista para queries de puntos cercanos"}


id: memory_overlay_cpp_core
name: memory_overlay
kind: component
lang: cpp
domain: core
version: 1.0.0
purity: pure
signature: void memory_overlay()
description: Renderiza un overlay de estadisticas de memoria (RSS, peak, vsize) en la esquina inferior derecha
tags: ["imgui","memory","overlay","debug","dashboard","profiling"]
uses_functions: []
uses_types: []
returns: []
imports: ["imgui"]
example:
  ```cpp
  // En el loop de render, dentro de un frame ImGui:
  fps_overlay();
  memory_overlay();
  ```

tests: []
file_path: cpp/functions/core/memory_overlay.cpp
created_at: 2026-04-07T21:42:49Z
updated_at: 2026-04-07T21:42:49Z
props: []
emits: []
framework: imgui
variant: []
content_hash: 7c325bf54f798be7495f41e26cb8c6110baa191bcf11fc37e8f2df409e90b7d7
params_schema: {"output":"Renderiza el overlay de memoria en el frame ImGui actual"}
notes:
  - La función sigue la misma convención que `fps_overlay`: mismos flags de ventana, mismo alpha (0.65).
  - Posición: esquina inferior derecha (`pivot = {1,1}`), separada 10 px del borde.
  - La lectura de `/proc/self/status` es I/O local de ~0.1 ms, amortizada a 1 Hz — despreciable.
  - Con `TRACY_ENABLE` activo, la función registra una zona `ZoneScoped` para que aparezca en el profiler.
  - Si la plataforma no puede leer las estadísticas, muestra "Memory: N/A" en gris.

documentation:
  # memory_overlay

  Muestra estadísticas de memoria del proceso en una ventana semi-transparente en la esquina inferior derecha. Complementa `fps_overlay` para un dashboard mínimo de rendimiento.

  Las métricas se leen como máximo una vez por segundo y se cachean en una variable estática local, evitando I/O excesivo en el hot path de render.

  ## Métricas mostradas

  | Campo | Linux | Windows |
  |---|---|---|
  | RSS (MB) | `VmRSS` de `/proc/self/status` | `WorkingSetSize` (PSAPI) |
  | Peak RSS (MB) | `VmPeak` de `/proc/self/status` | `PeakWorkingSetSize` (PSAPI) |
  | Virtual Size (MB) | `VmSize` de `/proc/self/status` | N/A (no mostrado) |

  En Windows se requiere enlazar `psapi.lib` (el `.cpp` incluye `#pragma comment(lib, "psapi.lib")`).

code:
  #include "core/memory_overlay.h"
  #include "imgui.h"

  #ifdef TRACY_ENABLE
  #include "tracy/Tracy.hpp"
  #endif

  #ifdef _WIN32
  #define WIN32_LEAN_AND_MEAN
  #include <windows.h>
  #include <psapi.h>
  #pragma comment(lib, "psapi.lib")
  #else
  #include <cstdio>
  #endif

  #include <cstring>

  namespace {

  struct MemStats {
      long rss_kb    = -1; // Resident Set Size
      long peak_kb   = -1; // Peak RSS
      long vsize_kb  = -1; // Virtual memory size
  };

  static MemStats s_cached;
  static double   s_last_sample = -1.0;

  #ifdef _WIN32
  static MemStats sample_memory() {
      MemStats s;
      PROCESS_MEMORY_COUNTERS pmc{};
      if (GetProcessMemoryInfo(GetCurrentProcess(), &pmc, sizeof(pmc))) {
          s.rss_kb  = static_cast<long>(pmc.WorkingSetSize       / 1024);
          s.peak_kb = static_cast<long>(pmc.PeakWorkingSetSize   / 1024);
          s.vsize_kb = -1; // not easily available on Windows without VirtualQuery loop
      }
      return s;
  }
  #else
  static MemStats sample_memory() {
      MemStats s;
      FILE* f = std::fopen("/proc/self/status", "r");
      if (!f) return s;

      char line[128];
      while (std::fgets(line, sizeof(line), f)) {
          long val = 0;
          if (std::sscanf(line, "VmRSS: %ld kB",  &val) == 1) s.rss_kb   = val;
          if (std::sscanf(line, "VmPeak: %ld kB", &val) == 1) s.peak_kb  = val;
          if (std::sscanf(line, "VmSize: %ld kB", &val) == 1) s.vsize_kb = val;
      }
      std::fclose(f);
      return s;
  }
  #endif

  } // namespace

  void memory_overlay() {
  #ifdef TRACY_ENABLE
      ZoneScoped;
  #endif

      // Sample at most once per second
      const double now = ImGui::GetTime();
      if (s_last_sample < 0.0 || (now - s_last_sample) >= 1.0) {
          s_cached      = sample_memory();
          s_last_sample = now;
      }

      ImGuiWindowFlags flags = ImGuiWindowFlags_NoDecoration
                             | ImGuiWindowFlags_AlwaysAutoResize
                             | ImGuiWindowFlags_NoSavedSettings
                             | ImGuiWindowFlags_NoFocusOnAppearing
                             | ImGuiWindowFlags_NoNav
                             | ImGuiWindowFlags_NoMove;

      const float pad = 10.0f;
      const ImGuiViewport* vp = ImGui::GetMainViewport();
      ImVec2 pos(vp->WorkPos.x + vp->WorkSize.x - pad,
                 vp->WorkPos.y + vp->WorkSize.y - pad);
      ImGui::SetNextWindowPos(pos, ImGuiCond_Always, ImVec2(1.0f, 1.0f));
      ImGui::SetNextWindowBgAlpha(0.65f);

      if (ImGui::Begin("##memory_overlay", nullptr, flags)) {
          if (s_cached.rss_kb >= 0) {
              ImGui::Text("RSS:    %5ld MB", s_cached.rss_kb   / 1024);
              ImGui::Text("Peak:   %5ld MB", s_cached.peak_kb  / 1024);
  #ifndef _WIN32
              ImGui::Text("VSize:  %5ld MB", s_cached.vsize_kb / 1024);
  #endif
          } else {
              ImGui::TextDisabled("Memory: N/A");
          }
      }
      ImGui::End();
  }


id: plot_theme_cpp_core
name: plot_theme
kind: function
lang: cpp
domain: core
version: 1.0.0
purity: pure
tags: ["theme","colors","palette","styling","dashboard"]
uses_functions: []
uses_types: []
returns: []
imports: ["imgui","implot"]
tests: []
file_path: cpp/functions/core/plot_theme.cpp
created_at: 2026-04-07T21:42:49Z
updated_at: 2026-04-07T21:42:49Z
props: []
emits: []
framework: imgui
variant: []
content_hash: ccec236a278c13ca850773915133808c920c0a482636fd354e86e2161376e65a
signature:
  void plot_theme_dark() / void plot_theme_light() / void plot_theme_high_contrast() / void plot_theme_apply(const PlotTheme& theme)

description:
  Gestiona temas y paletas de colores para ImPlot e ImGui, con presets dark/light/high-contrast y soporte para temas custom

notes:
  - `plot_theme_apply` llama a `ImPlot::AddColormap` y `ImPlot::SetColormap` con el campo `name` del tema como identificador. Si se llama dos veces con el mismo `name`, ImPlot registra el colormap de nuevo — usar nombres distintos si se crean multiples variantes en runtime.
  - La funcion es "pure" en el sentido de que su unico efecto es escribir en `ImGuiStyle` e `ImPlotStyle` del contexto activo, sin I/O ni estado global propio.
  - Requiere que `ImGui::CreateContext()` e `ImPlot::CreateContext()` hayan sido llamados antes del primer uso.
  - Compatible con C++17. No usa excepciones ni RTTI.

documentation:
  # plot_theme

  Configura el aspecto visual de ventanas ImGui y graficas ImPlot en un solo paso. Tres presets listos para usar y una API de tema custom para casos especificos.

  ## Presets

  ### `plot_theme_dark()`

  Fondo oscuro (`#1A1A1F`) con paleta de 10 colores suaves: steel blue, soft green, warm orange, muted red, lavender, teal, gold, lime, pink, periwinkle. Ideal para dashboards de monitoreo.

  ### `plot_theme_light()`

  Fondo claro (`#F7F7F7`) con colores vibrantes de alto contraste sobre blanco. Recomendado para capturas de pantalla o exportacion a documentos.

  ### `plot_theme_high_contrast()`

  Fondo negro puro con colores neon: cyan, green, orange, magenta, yellow. Disenado para presentaciones en pantallas grandes o condiciones de poca luz.

  ## Uso

  ```cpp
  #include "plot_theme.h"

  // Al inicio del frame, antes de BeginMainMenuBar / Begin
  plot_theme_dark();

  // O con tema custom
  PlotTheme my_theme = plot_theme_preset_dark();
  my_theme.palette[0] = ImVec4(1.0f, 0.0f, 0.5f, 1.0f); // override primer color
  plot_theme_apply(my_theme);
  ```

  ## API

  ```cpp
  // Presets de aplicacion directa
  void plot_theme_dark();
  void plot_theme_light();
  void plot_theme_high_contrast();

  // Tema custom
  void plot_theme_apply(const PlotTheme& theme);

  // Obtener structs para inspeccion o modificacion parcial
  PlotTheme plot_theme_preset_dark();
  PlotTheme plot_theme_preset_light();
  PlotTheme plot_theme_preset_high_contrast();
  ```

  ## Estructura PlotTheme

  ```cpp
  struct PlotTheme {
      const char* name;       // nombre del colormap registrado en ImPlot
      ImVec4 bg;              // fondo del plot (ImPlotCol_PlotBg, ImGuiCol_WindowBg)
      ImVec4 frame_bg;        // fondo del frame (ImPlotCol_FrameBg, ImGuiCol_FrameBg)
      ImVec4 text;            // color del texto (todos los ImGui/ImPlot text cols)
      ImVec4 grid;            // lineas de cuadricula y ticks
      ImVec4 palette[10];     // paleta de colores para series de datos
      int palette_count;      // numero de colores activos en la paleta (1-10)
  };
  ```

code:
  #include "plot_theme.h"

  // ---------------------------------------------------------------------------
  // Preset structs
  // ---------------------------------------------------------------------------

  PlotTheme plot_theme_preset_dark() {
      PlotTheme t;
      t.name          = "dark";
      t.bg            = ImVec4(0.10f, 0.10f, 0.12f, 1.00f);
      t.frame_bg      = ImVec4(0.14f, 0.14f, 0.17f, 1.00f);
      t.text          = ImVec4(0.88f, 0.88f, 0.90f, 1.00f);
      t.grid          = ImVec4(0.30f, 0.30f, 0.35f, 0.60f);
      t.palette_count = 10;
      t.palette[0]    = ImVec4(0.33f, 0.62f, 0.91f, 1.00f); // steel blue
      t.palette[1]    = ImVec4(0.35f, 0.78f, 0.54f, 1.00f); // soft green
      t.palette[2]    = ImVec4(0.96f, 0.60f, 0.25f, 1.00f); // warm orange
      t.palette[3]    = ImVec4(0.85f, 0.38f, 0.42f, 1.00f); // muted red
      t.palette[4]    = ImVec4(0.72f, 0.52f, 0.88f, 1.00f); // lavender
      t.palette[5]    = ImVec4(0.38f, 0.80f, 0.82f, 1.00f); // teal
      t.palette[6]    = ImVec4(0.95f, 0.80f, 0.32f, 1.00f); // gold
      t.palette[7]    = ImVec4(0.60f, 0.82f, 0.38f, 1.00f); // lime
      t.palette[8]    = ImVec4(0.90f, 0.55f, 0.75f, 1.00f); // pink
      t.palette[9]    = ImVec4(0.55f, 0.70f, 0.95f, 1.00f); // periwinkle
      return t;
  }

  PlotTheme plot_theme_preset_light() {
      PlotTheme t;
      t.name          = "light";
      t.bg            = ImVec4(0.97f, 0.97f, 0.97f, 1.00f);
      t.frame_bg      = ImVec4(1.00f, 1.00f, 1.00f, 1.00f);
      t.text          = ImVec4(0.10f, 0.10f, 0.12f, 1.00f);
      t.grid          = ImVec4(0.70f, 0.70f, 0.72f, 0.60f);
      t.palette_count = 10;
      t.palette[0]    = ImVec4(0.12f, 0.47f, 0.71f, 1.00f); // deep blue
      t.palette[1]    = ImVec4(0.17f, 0.63f, 0.17f, 1.00f); // deep green
      t.palette[2]    = ImVec4(0.84f, 0.37f, 0.00f, 1.00f); // burnt orange
      t.palette[3]    = ImVec4(0.84f, 0.15f, 0.16f, 1.00f); // vivid red
      t.palette[4]    = ImVec4(0.58f, 0.40f, 0.74f, 1.00f); // purple
      t.palette[5]    = ImVec4(0.09f, 0.75f, 0.81f, 1.00f); // cyan
      t.palette[6]    = ImVec4(0.74f, 0.74f, 0.13f, 1.00f); // olive
      t.palette[7]    = ImVec4(0.09f, 0.75f, 0.81f, 1.00f); // sky blue
      t.palette[8]    = ImVec4(0.89f, 0.47f, 0.76f, 1.00f); // rose
      t.palette[9]    = ImVec4(0.50f, 0.50f, 0.50f, 1.00f); // grey
      return t;
  }

  PlotTheme plot_theme_preset_high_contrast() {
      PlotTheme t;
      t.name          = "high_contrast";
      t.bg            = ImVec4(0.00f, 0.00f, 0.00f, 1.00f);
      t.frame_bg      = ImVec4(0.05f, 0.05f, 0.05f, 1.00f);
      t.text          = ImVec4(1.00f, 1.00f, 1.00f, 1.00f);
      t.grid          = ImVec4(0.30f, 0.30f, 0.30f, 0.80f);
      t.palette_count = 10;
      t.palette[0]    = ImVec4(0.00f, 1.00f, 1.00f, 1.00f); // cyan neon
      t.palette[1]    = ImVec4(0.00f, 1.00f, 0.00f, 1.00f); // green neon
      t.palette[2]    = ImVec4(1.00f, 0.60f, 0.00f, 1.00f); // orange neon
      t.palette[3]    = ImVec4(1.00f, 0.00f, 0.50f, 1.00f); // magenta
      t.palette[4]    = ImVec4(1.00f, 1.00f, 0.00f, 1.00f); // yellow
      t.palette[5]    = ImVec4(0.40f, 0.80f, 1.00f, 1.00f); // sky blue
      t.palette[6]    = ImVec4(1.00f, 0.40f, 0.40f, 1.00f); // salmon
      t.palette[7]    = ImVec4(0.60f, 1.00f, 0.40f, 1.00f); // lime
      t.palette[8]    = ImVec4(1.00f, 0.80f, 1.00f, 1.00f); // lavender bright
      t.palette[9]    = ImVec4(0.80f, 1.00f, 0.80f, 1.00f); // mint bright
      return t;
  }

  // ---------------------------------------------------------------------------
  // Internal helper
  // ---------------------------------------------------------------------------

  static void apply_imgui_colors(const PlotTheme& theme) {
      ImGuiStyle& s = ImGui::GetStyle();
      s.Colors[ImGuiCol_WindowBg]       = theme.bg;
      s.Colors[ImGuiCol_ChildBg]        = theme.bg;
      s.Colors[ImGuiCol_PopupBg]        = theme.frame_bg;
      s.Colors[ImGuiCol_FrameBg]        = theme.frame_bg;
      s.Colors[ImGuiCol_FrameBgHovered] = theme.frame_bg;
      s.Colors[ImGuiCol_FrameBgActive]  = theme.frame_bg;
      s.Colors[ImGuiCol_Text]           = theme.text;
      s.Colors[ImGuiCol_TextDisabled]   = ImVec4(theme.text.x * 0.5f,
                                                 theme.text.y * 0.5f,
                                                 theme.text.z * 0.5f,
                                                 0.80f);
  }

  static void apply_implot_colors(const PlotTheme& theme) {
      ImPlotStyle& ps = ImPlot::GetStyle();
      ps.Colors[ImPlotCol_PlotBg]       = theme.bg;
      ps.Colors[ImPlotCol_PlotBorder]   = theme.frame_bg;
      ps.Colors[ImPlotCol_FrameBg]      = theme.frame_bg;
      ps.Colors[ImPlotCol_LegendBg]     = theme.frame_bg;
      ps.Colors[ImPlotCol_LegendBorder] = theme.grid;
      ps.Colors[ImPlotCol_LegendText]   = theme.text;
      ps.Colors[ImPlotCol_TitleText]    = theme.text;
      ps.Colors[ImPlotCol_InlayText]    = theme.text;
      ps.Colors[ImPlotCol_AxisText]     = theme.text;
      ps.Colors[ImPlotCol_AxisGrid]     = theme.grid;
      ps.Colors[ImPlotCol_AxisTick]     = theme.grid;

      // Register custom colormap from palette
      const int count = theme.palette_count > 10 ? 10 : theme.palette_count;
      ImPlot::AddColormap(theme.name, theme.palette, count, false);
      ImPlot::SetColormap(theme.name);
  }

  // ---------------------------------------------------------------------------
  // Public API
  // ---------------------------------------------------------------------------

  void plot_theme_apply(const PlotTheme& theme) {
      apply_imgui_colors(theme);
      apply_implot_colors(theme);
  }

  void plot_theme_dark() {
      PlotTheme t = plot_theme_preset_dark();
      plot_theme_apply(t);
  }

  void plot_theme_light() {
      PlotTheme t = plot_theme_preset_light();
      plot_theme_apply(t);
  }

  void plot_theme_high_contrast() {
      PlotTheme t = plot_theme_preset_high_contrast();
      plot_theme_apply(t);
  }

params_schema:
  {"params":[{"name":"theme","desc":"Estructura PlotTheme con nombre, colores de fondo/frame/texto/grid y paleta de hasta 10 colores para series de datos"}],"output":"Aplica el tema al contexto ImPlot/ImGui actual modificando ImGuiStyle y ImPlotStyle en memoria"}


id: sidebar_cpp_core
name: sidebar
kind: component
lang: cpp
domain: core
version: 1.0.0
purity: pure
signature: bool sidebar_begin(const char* title, bool* open, float width = 250.0f)
description: Panel lateral colapsable para filtros y controles de dashboard
tags: ["imgui","sidebar","panel","layout","dashboard","controls"]
uses_functions: []
uses_types: []
returns: []
imports: ["imgui"]
tests: []
file_path: cpp/functions/core/sidebar.cpp
created_at: 2026-04-07T21:42:49Z
updated_at: 2026-04-07T21:42:49Z
props: []
emits: []
framework: imgui
variant: []
content_hash: 6d17e2d385a23082779e6593baa3f6806992f0657326d379bb873260164bdff5
example:
  ```cpp
  static bool filters_open = true;

  void render_fn() {
      if (sidebar_begin("Filters", &filters_open)) {
          ImGui::SliderFloat("Min value", &min_val, 0.0f, 100.0f);
          ImGui::Checkbox("Show inactive", &show_inactive);
      }
      sidebar_end();

      // contenido principal
      ImGui::Begin("Main");
      // ...
      ImGui::End();
  }
  ```

notes:
  El estado de `s_sidebar_was_open` es una variable estatica interna que coordina `sidebar_begin` y `sidebar_end`. Solo un sidebar activo a la vez por frame (patron begin/end clasico de ImGui). Para multiples sidebars simultaneos, instanciar logica propia con estado separado.

documentation:
  # sidebar

  Panel lateral colapsable. Cuando `*open == true` renderiza una ventana ImGui de ancho fijo con boton de cierre. Cuando `*open == false` muestra un boton compacto ">" para reabrir.

  Siempre llamar `sidebar_end()` despues de `sidebar_begin()`, independientemente del valor de retorno.

code:
  #include "core/sidebar.h"
  #include "imgui.h"

  static bool s_sidebar_was_open = false;

  bool sidebar_begin(const char* title, bool* open, float width) {
      if (!*open) {
          s_sidebar_was_open = false;
          if (ImGui::Button(">")) {
              *open = true;
          }
          return false;
      }

      s_sidebar_was_open = true;
      ImGui::SetNextWindowSize(ImVec2(width, 0.0f), ImGuiCond_Always);
      ImGui::Begin(title, open, ImGuiWindowFlags_NoCollapse);
      return true;
  }

  void sidebar_end() {
      if (s_sidebar_was_open) {
          ImGui::End();
      }
  }

params_schema:
  {"params":[{"name":"title","desc":"Titulo del sidebar mostrado en la barra de titulo de la ventana"},{"name":"open","desc":"Puntero al estado abierto/cerrado; se pone a false si el usuario cierra la ventana"},{"name":"width","desc":"Ancho del sidebar en pixels (por defecto 250)"}],"output":"true si el sidebar esta abierto y se debe renderizar contenido entre sidebar_begin y sidebar_end"}


id: tab_container_cpp_core
name: tab_container
kind: component
lang: cpp
domain: core
version: 1.0.0
purity: pure
signature: bool tab_container_begin(const char* id)
description: Contenedor de tabs para organizar vistas multiples en un dashboard
tags: ["imgui","tabs","container","layout","dashboard"]
uses_functions: []
uses_types: []
returns: []
imports: ["imgui"]
tests: []
file_path: cpp/functions/core/tab_container.cpp
created_at: 2026-04-07T21:42:49Z
updated_at: 2026-04-07T21:42:49Z
props: []
emits: []
framework: imgui
variant: []
content_hash: 4ab6bab6a6677ab5d3079bf8537e2fd9ae4ebeefe9b68520b57f5ad269c24003
example:
  ```cpp
  void render_fn() {
      ImGui::Begin("Dashboard");

      if (tab_container_begin("##main_tabs")) {
          if (tab_container_tab("Overview")) {
              render_overview();
              tab_container_tab_end();
          }
          if (tab_container_tab("Details")) {
              render_details();
              tab_container_tab_end();
          }
          if (tab_container_tab("Settings")) {
              render_settings();
              tab_container_tab_end();
          }
      }
      tab_container_end();

      ImGui::End();
  }
  ```

notes:
  Los flags `ImGuiTabBarFlags_Reorderable | ImGuiTabBarFlags_FittingPolicyResizeDown` son fijos para estandarizar la experiencia de tabs en dashboards. Si se necesitan flags distintos usar `ImGui::BeginTabBar` directamente.

documentation:
  # tab_container

  Wrapper fino sobre `ImGui::BeginTabBar` / `EndTabBar` con flags de dashboard preconfigurados: `Reorderable` (arrastrar tabs) y `FittingPolicyResizeDown` (tabs se achican si no caben).

  API de cuatro funciones siguiendo el patron begin/end de ImGui. Siempre llamar `tab_container_end()` si `tab_container_begin()` retorno true. Siempre llamar `tab_container_tab_end()` dentro del bloque `if (tab_container_tab(...))`.

code:
  #include "core/tab_container.h"
  #include "imgui.h"

  bool tab_container_begin(const char* id) {
      return ImGui::BeginTabBar(
          id,
          ImGuiTabBarFlags_Reorderable | ImGuiTabBarFlags_FittingPolicyResizeDown
      );
  }

  bool tab_container_tab(const char* label) {
      return ImGui::BeginTabItem(label);
  }

  void tab_container_tab_end() {
      ImGui::EndTabItem();
  }

  void tab_container_end() {
      ImGui::EndTabBar();
  }

params_schema:
  {"params":[{"name":"id","desc":"Identificador unico del tab bar (ej: '##views'). Puede ser invisible con prefijo ##"},{"name":"label","desc":"Etiqueta visible de cada tab individual"}],"output":"tab_container_begin: true si el tab bar esta activo. tab_container_tab: true si el tab esta seleccionado y se debe renderizar su contenido"}


id: time_series_buffer_cpp_core
name: time_series_buffer
kind: function
lang: cpp
domain: core
version: 1.0.0
purity: pure
signature: TimeSeriesBuffer(size_t capacity)
tags: ["buffer","timeseries","streaming","dashboard","data"]
uses_functions: []
uses_types: []
returns: []
imports: []
tests: []
file_path: cpp/functions/core/time_series_buffer.cpp
created_at: 2026-04-07T21:42:49Z
updated_at: 2026-04-07T21:42:49Z
props: []
emits: []
framework: imgui
variant: []
content_hash: bf82ceb467c8e2bf492d9b9805f3562c7f7ab08c358c9babe336335522726c3d
description:
  Ring buffer circular para datos de series temporales, optimizado para streaming de metricas en dashboards en tiempo real

notes:
  - No depende de ImGui ni ImPlot directamente — es una estructura de datos pura.
  - No es thread-safe. Para uso multihilo, proteger con mutex externo.
  - Move semantics implementadas; copy queda eliminada (`= delete`) para evitar copias accidentales del heap.
  - `min`, `max` y `average` iteran sobre `count` elementos — O(n). Para buffers grandes en hot paths, considerar mantener un acumulador incremental.

documentation:
  # time_series_buffer

  Ring buffer circular de floats para series temporales en tiempo real. Diseñado para alimentar plots de ImPlot con metricas de streaming (FPS, latencia, uso de CPU, etc.).

  ## Uso basico

  ```cpp
  TimeSeriesBuffer fps_history(512);

  // En el loop principal:
  fps_history.push(ImGui::GetIO().Framerate);

  // Para renderizar con ImPlot:
  float ordered[512];
  size_t n = fps_history.copy_ordered(ordered, 512);
  ImPlot::PlotLines("FPS", ordered, (int)n);
  ```

  ## Semantica del ring buffer

  - `push(v)` escribe en `data[offset % capacity]` y avanza el write head.
  - `count` crece hasta `capacity` y se mantiene ahi — el buffer nunca desborda.
  - `get(0)` retorna el elemento mas antiguo; `get(count-1)` el mas reciente.
  - `latest()` es equivalente a `get(count-1)` pero mas explicito.
  - `copy_ordered` extrae los datos en orden cronologico para pasarlos a ImPlot u otro consumidor que espere un array contiguo.

code:
  #include "time_series_buffer.h"

  #include <algorithm>
  #include <cassert>
  #include <cstring>
  #include <limits>
  #include <numeric>
  #include <utility>

  TimeSeriesBuffer::TimeSeriesBuffer(size_t cap)
      : data(new float[cap]), capacity(cap), count(0), offset(0) {}

  TimeSeriesBuffer::~TimeSeriesBuffer() {
      delete[] data;
  }

  TimeSeriesBuffer::TimeSeriesBuffer(TimeSeriesBuffer&& other) noexcept
      : data(other.data), capacity(other.capacity), count(other.count), offset(other.offset) {
      other.data     = nullptr;
      other.capacity = 0;
      other.count    = 0;
      other.offset   = 0;
  }

  TimeSeriesBuffer& TimeSeriesBuffer::operator=(TimeSeriesBuffer&& other) noexcept {
      if (this != &other) {
          delete[] data;
          data           = other.data;
          capacity       = other.capacity;
          count          = other.count;
          offset         = other.offset;
          other.data     = nullptr;
          other.capacity = 0;
          other.count    = 0;
          other.offset   = 0;
      }
      return *this;
  }

  void TimeSeriesBuffer::push(float value) {
      data[offset % capacity] = value;
      offset++;
      if (count < capacity) count++;
  }

  float TimeSeriesBuffer::get(size_t index) const {
      assert(index < count);
      // oldest element is at (offset - count) % capacity
      size_t real = (offset - count + index) % capacity;
      return data[real];
  }

  float TimeSeriesBuffer::latest() const {
      assert(count > 0);
      return data[(offset - 1) % capacity];
  }

  float TimeSeriesBuffer::min() const {
      float m = std::numeric_limits<float>::max();
      for (size_t i = 0; i < count; ++i) m = std::min(m, get(i));
      return m;
  }

  float TimeSeriesBuffer::max() const {
      float m = std::numeric_limits<float>::lowest();
      for (size_t i = 0; i < count; ++i) m = std::max(m, get(i));
      return m;
  }

  float TimeSeriesBuffer::average() const {
      if (count == 0) return 0.0f;
      float sum = 0.0f;
      for (size_t i = 0; i < count; ++i) sum += get(i);
      return sum / static_cast<float>(count);
  }

  size_t TimeSeriesBuffer::size() const {
      return count;
  }

  bool TimeSeriesBuffer::full() const {
      return count == capacity;
  }

  size_t TimeSeriesBuffer::copy_ordered(float* out, size_t out_capacity) const {
      size_t n = std::min(count, out_capacity);
      for (size_t i = 0; i < n; ++i)
          out[i] = get(i);
      return n;
  }

params_schema:
  {"params":[{"name":"capacity","desc":"Numero maximo de muestras que almacena el buffer"}],"output":"Buffer circular listo para push de datos y lectura ordenada"}


id: tracy_zone_cpp_core
name: tracy_zone
kind: function
lang: cpp
domain: core
version: 1.0.0
purity: pure
signature: FN_ZONE(name) / FN_ZONE_COLOR(name, color) / FN_FRAME_MARK / FN_PLOT(name, val)
description: Macros y constantes de conveniencia para Tracy profiling zones, compilables sin Tracy
tags: ["tracy","profiling","debug","performance","raii"]
uses_functions: []
uses_types: []
returns: []
imports: []
tests: []
file_path: cpp/functions/core/tracy_zone.cpp
created_at: 2026-04-07T21:42:49Z
updated_at: 2026-04-07T21:42:49Z
props: []
emits: []
framework: imgui
variant: []
content_hash: d97e1d27b60810d0c0ae4455f1e85d22d3ffa3b25cd2927535c48a76ffc12c91
example:
  ```cpp
  #include "core/tracy_zone.h"

  void render_scene() {
      FN_ZONE_COLOR("render_scene", fn_tracy::COLOR_RENDER);
      // ... render calls ...
  }

  void update(float dt) {
      FN_ZONE("update");
      // ... game logic ...
      FN_PLOT("dt_ms", dt * 1000.0f);
  }

  void main_loop() {
      while (running) {
          update(dt);
          render_scene();
          FN_FRAME_MARK;
      }
  }
  ```

notes:
  - Compilar con `-DTRACY_ENABLE` y enlazar `TracyClient.cpp` para activar profiling.
  - Sin `TRACY_ENABLE` el .cpp es prácticamente vacío — coste cero en producción.
  - Los colores son ARGB; si el alpha es 0 Tracy aplica su color automático por zona.
  - `FN_ZONE` expande a `ZoneScopedN(name)` de Tracy, que crea el contexto con `__LINE__` como discriminador — es seguro llamar varias veces en el mismo scope.

documentation:
  # tracy_zone

  Macros de scope para instrumentar secciones de código con Tracy Profiler. Cuando `TRACY_ENABLE` no está definido (ej. builds de producción) todas las macros se expanden a `(void)0`, sin coste alguno.

  ## Macros disponibles

  | Macro | Descripción |
  |---|---|
  | `FN_ZONE("name")` | Zona con nombre, color automático |
  | `FN_ZONE_COLOR("name", color)` | Zona con nombre y color ARGB explícito |
  | `FN_FRAME_MARK` | Marca el límite de frame (eje X de Tracy) |
  | `FN_PLOT("name", val)` | Envía un valor escalar al panel de plots |

  ## Colores predefinidos (`fn_tracy::`)

  ```cpp
  fn_tracy::COLOR_RENDER  // 0x2196F3 azul   — rendering
  fn_tracy::COLOR_UPDATE  // 0x4CAF50 verde  — game update / logic
  fn_tracy::COLOR_IO      // 0xFF9800 naranja — I/O disco/red
  fn_tracy::COLOR_NETWORK // 0xF44336 rojo   — red/HTTP
  fn_tracy::COLOR_COMPUTE // 0x9C27B0 morado — compute / shader prep
  ```

code:
  // tracy_zone.cpp
  // All definitions live in tracy_zone.h (macros + constexpr).
  // This translation unit exists so the header can be included in any build
  // without requiring Tracy — compile with -DTRACY_ENABLE to activate profiling.
  #include "core/tracy_zone.h"

params_schema:
  {"params":[{"name":"name","desc":"Nombre de la zona tal como aparece en la timeline de Tracy"},{"name":"color","desc":"Color ARGB uint32 de la zona en Tracy (opcional, solo FN_ZONE_COLOR)"}],"output":"Zona Tracy activa durante el scope actual; no-op cuando TRACY_ENABLE no está definido"}


id: bar_chart_cpp_viz
name: bar_chart
kind: component
lang: cpp
domain: viz
version: 1.0.0
purity: pure
description: Renderiza un grafico de barras verticales usando ImPlot dentro de un frame ImGui
tags: ["implot","chart","visualization","gpu","bar"]
uses_functions: []
uses_types: []
returns: []
imports: ["implot"]
tests: []
file_path: cpp/functions/viz/bar_chart.cpp
created_at: 2026-04-06T21:31:43Z
updated_at: 2026-04-06T21:31:43Z
props: []
emits: []
framework: imgui
variant: []
content_hash: f4b83230686eb4031c0bd4fcc3851aee0475dcc7e8baa1297e759c9356783d48
signature:
  void bar_chart(const char* title, const char* const* labels, const float* values, int count, float bar_width)

documentation:
  # bar_chart

  Wrapper atomico sobre `ImPlot::PlotBars` con configuracion automatica de etiquetas en el eje X.

  Debe llamarse dentro del render callback de `fn::run_app`.

code:
  #include "viz/bar_chart.h"
  #include "implot.h"

  #include <vector>

  void bar_chart(const char* title, const char* const* labels, const float* values, int count, float bar_width) {
      if (ImPlot::BeginPlot(title, ImVec2(-1, 0))) {
          std::vector<double> positions(count);
          for (int i = 0; i < count; i++) positions[i] = i;

          ImPlot::SetupAxisTicks(ImAxis_X1, positions.data(), count, labels);
          ImPlot::PlotBars("##data", values, count, bar_width);
          ImPlot::EndPlot();
      }
  }

  void bar_chart(const char* title, const char* const* labels, const double* values, int count, double bar_width) {
      if (ImPlot::BeginPlot(title, ImVec2(-1, 0))) {
          std::vector<double> positions(count);
          for (int i = 0; i < count; i++) positions[i] = i;

          ImPlot::SetupAxisTicks(ImAxis_X1, positions.data(), count, labels);
          ImPlot::PlotBars("##data", values, count, bar_width);
          ImPlot::EndPlot();
      }
  }

params_schema:
  {"params":[{"name":"title","desc":"Titulo del grafico de barras"},{"name":"labels","desc":"Array de etiquetas para el eje X, una por barra"},{"name":"values","desc":"Array de valores numericos para la altura de cada barra"},{"name":"count","desc":"Numero de barras (longitud de labels y values)"},{"name":"bar_width","desc":"Ancho de cada barra como fraccion del espacio disponible (default 0.67)"}],"output":"Renderiza el grafico de barras en el frame ImGui actual"}


id: candlestick_cpp_viz
name: candlestick
kind: component
lang: cpp
domain: viz
version: 1.0.0
purity: pure
tags: ["implot","chart","visualization","gpu","candlestick","ohlc","finance"]
uses_functions: []
uses_types: []
returns: []
imports: ["implot","imgui"]
tests: []
file_path: cpp/functions/viz/candlestick.cpp
created_at: 2026-04-07T21:42:49Z
updated_at: 2026-04-07T21:42:49Z
props: []
emits: []
framework: imgui
variant: []
content_hash: c21c6466b2b06e80e69f72790865b6f012153a8665c1da016f8ac97a6e5897d0
signature:
  void candlestick(const char* title, const double* dates, const double* opens, const double* closes, const double* lows, const double* highs, int count, float width_percent = 0.25f, bool tooltip = true)

description:
  Renderiza un grafico de velas OHLC usando ImPlot custom rendering. Verde para velas alcistas (close >= open), rojo para bajistas.

example:
  ```cpp
  // arrays de datos financieros (timestamps Unix, precios)
  candlestick("BTC/USD", dates, opens, closes, lows, highs, 90);

  // sin tooltip, velas mas anchas
  candlestick("ETH/USD", dates, opens, closes, lows, highs, 30, 0.6f, false);
  ```

notes:
  - El ancho de cada vela se calcula como `(dates[1] - dates[0]) * width_percent * 0.5` en cada lado. Asume spacing uniforme entre velas.
  - Para un solo punto (`count == 1`) el spacing por defecto es 1.0.
  - La deteccion de hover usa un margen de 4px alrededor del area cuerpo+wick para facilitar la interaccion.
  - El eje X usa `ImPlotScale_Time` — si los datos son indices numericos simples (0, 1, 2...) en lugar de timestamps, pasar `ImPlotAxisFlags_NoDecorations` o cambiar `SetupAxisScale`.

documentation:
  # candlestick

  Grafico de velas OHLC completo usando custom rendering de ImPlot. Dibuja body (open-close) y wicks (high-low) por vela usando `ImPlot::GetPlotDrawList()` y `ImPlot::PlotToPixels()` para conversion de coordenadas.

  Debe llamarse dentro del render callback de `fn::run_app` (o cualquier contexto con un frame ImGui activo). El eje X se configura con `ImPlotScale_Time` para timestamps Unix.

  Solo tiene overload `double` porque los datos financieros requieren doble precision.

code:
  #include "viz/candlestick.h"
  #include "imgui.h"
  #include "implot.h"

  void candlestick(const char* title, const double* dates, const double* opens,
                   const double* closes, const double* lows, const double* highs,
                   int count, float width_percent, bool tooltip) {
      if (count <= 0) return;

      // Compute half-width of each candle body in data coordinates.
      // Use spacing between consecutive dates when count > 1, else fallback to 0.5.
      double spacing = (count > 1) ? (dates[1] - dates[0]) : 1.0;
      double half_w  = spacing * (double)width_percent * 0.5;

      ImPlot::SetupAxes("Date", "Price", ImPlotAxisFlags_None, ImPlotAxisFlags_AutoFit);
      ImPlot::SetupAxisScale(ImAxis_X1, ImPlotScale_Time);

      // Auto-fit X axis to the data range with a small margin.
      ImPlot::SetupAxisLimits(ImAxis_X1,
                              dates[0]         - spacing,
                              dates[count - 1] + spacing,
                              ImGuiCond_Always);

      if (ImPlot::BeginPlot(title, ImVec2(-1, 0))) {
          ImDrawList* draw = ImPlot::GetPlotDrawList();

          const ImU32 col_bull = IM_COL32(0,  200, 80,  255);  // green — close >= open
          const ImU32 col_bear = IM_COL32(220, 50, 50,  255);  // red   — close < open

          int hovered_idx = -1;

          for (int i = 0; i < count; i++) {
              double x     = dates[i];
              double open  = opens[i];
              double close = closes[i];
              double low   = lows[i];
              double high  = highs[i];

              bool bullish = (close >= open);
              ImU32 col   = bullish ? col_bull : col_bear;

              // Convert data coordinates to screen pixels.
              ImVec2 body_tl = ImPlot::PlotToPixels(x - half_w, bullish ? close : open);
              ImVec2 body_br = ImPlot::PlotToPixels(x + half_w, bullish ? open  : close);
              ImVec2 wick_hi = ImPlot::PlotToPixels(x, high);
              ImVec2 wick_lo = ImPlot::PlotToPixels(x, low);
              float  cx      = (body_tl.x + body_br.x) * 0.5f;

              // Wick (high-low vertical line).
              draw->AddLine(ImVec2(cx, wick_hi.y), ImVec2(cx, wick_lo.y), col, 1.5f);

              // Body rectangle (open-close).
              // Ensure at least 1px height so flat candles are visible.
              if (body_br.y <= body_tl.y + 1.0f) body_br.y = body_tl.y + 1.0f;
              draw->AddRectFilled(body_tl, body_br, col);
              draw->AddRect(body_tl, body_br, col);

              // Track hovered candle for tooltip.
              if (tooltip && ImPlot::IsPlotHovered()) {
                  ImVec2 mouse = ImGui::GetMousePos();
                  if (mouse.x >= body_tl.x - 4 && mouse.x <= body_br.x + 4 &&
                      mouse.y >= wick_hi.y  - 4 && mouse.y <= wick_lo.y  + 4) {
                      hovered_idx = i;
                  }
              }
          }

          // Tooltip for the hovered candle.
          if (tooltip && hovered_idx >= 0) {
              int i = hovered_idx;
              ImGui::BeginTooltip();
              ImGui::Text("O: %.4f", opens[i]);
              ImGui::Text("H: %.4f", highs[i]);
              ImGui::Text("L: %.4f", lows[i]);
              ImGui::Text("C: %.4f", closes[i]);
              ImGui::EndTooltip();
          }

          ImPlot::EndPlot();
      }
  }

params_schema:
  {"params":[{"name":"title","desc":"Titulo del grafico, se muestra como header del plot"},{"name":"dates","desc":"Array de timestamps Unix o indices numericos del eje X, uno por vela"},{"name":"opens","desc":"Array de precios de apertura, uno por vela"},{"name":"closes","desc":"Array de precios de cierre, uno por vela"},{"name":"lows","desc":"Array de precios minimos (punta inferior del wick), uno por vela"},{"name":"highs","desc":"Array de precios maximos (punta superior del wick), uno por vela"},{"name":"count","desc":"Numero de velas (longitud de todos los arrays)"},{"name":"width_percent","desc":"Ancho del body de cada vela como fraccion del espacio entre puntos consecutivos (0.0-1.0, default 0.25)"},{"name":"tooltip","desc":"Si true, muestra tooltip con valores O/H/L/C al hacer hover sobre una vela"}],"output":"Renderiza el grafico de velas OHLC en el frame ImGui actual, sin retornar valor"}


id: gauge_cpp_viz
name: gauge
kind: component
lang: cpp
domain: viz
version: 1.0.0
purity: pure
signature: void gauge(const char* label, float value, float min_val, float max_val, float radius = 60.0f)
description: Renderiza un indicador circular tipo gauge/velocimetro usando ImGui draw primitives
tags: ["imgui","visualization","gauge","kpi","dashboard"]
uses_functions: []
uses_types: []
returns: []
imports: ["imgui"]
tests: []
file_path: cpp/functions/viz/gauge.cpp
created_at: 2026-04-07T21:42:49Z
updated_at: 2026-04-07T21:42:49Z
props: []
emits: []
framework: imgui
variant: []
content_hash: 585a6c8133bbb20d80773f10639191b4fbb118ac7496940a7745f3e06b01b86a
example:
  ```cpp
  // KPI card con gauge de temperatura
  gauge("CPU Temp", 72.5f, 0.0f, 100.0f, 50.0f);

  // Gauge grande para dashboard principal
  gauge("Velocidad", 3200.0f, 0.0f, 5000.0f, 80.0f);
  ```

notes:
  - El arco de fondo es gris oscuro (IM_COL32(60,60,60,220)), 6px de grosor.
  - La aguja tiene longitud del 75% del radio para evitar solapar el arco.
  - Usa solo `float`; no ofrece overload `double` porque ImGui DrawList trabaja en coordenadas de pantalla (float).
  - El espacio reservado es `diameter x (diameter + line_height)` para incluir la etiqueta.

documentation:
  # gauge

  Indicador circular tipo gauge/velocimetro construido sobre ImGui draw primitives. No requiere ImPlot.

  El arco ocupa 240 grados (de 150deg a 390deg en sentido horario). El color del arco de valor interpolado de verde (minimo) a amarillo (mitad) a rojo (maximo). Una aguja blanca apunta al valor actual.

  Debe llamarse dentro del render callback de `fn::run_app` (o cualquier contexto con un frame ImGui activo).

code:
  #include "viz/gauge.h"
  #include "imgui.h"
  #include <cmath>
  #include <cstdio>

  #ifndef M_PI
  #define M_PI 3.14159265358979323846f
  #endif

  void gauge(const char* label, float value, float min_val, float max_val, float radius) {
      ImDrawList* draw_list = ImGui::GetWindowDrawList();
      ImVec2 pos = ImGui::GetCursorScreenPos();

      // Reserve space: diameter + label line
      float diameter = radius * 2.0f;
      ImGui::Dummy(ImVec2(diameter, diameter + ImGui::GetTextLineHeightWithSpacing()));

      ImVec2 center = ImVec2(pos.x + radius, pos.y + radius);

      // Arc spans 240 degrees: from 150deg to 390deg (i.e. 150 to 30 going clockwise)
      // In screen space Y is down, so angles go clockwise.
      // Start angle: 150 degrees = bottom-left, End angle: 390 = 30 degrees = bottom-right
      const float angle_start = (150.0f * (float)M_PI) / 180.0f;
      const float angle_end   = (390.0f * (float)M_PI) / 180.0f;
      const int   num_segments = 64;

      // Background arc (dark gray)
      ImU32 bg_color = IM_COL32(60, 60, 60, 220);
      for (int i = 0; i < num_segments; i++) {
          float a0 = angle_start + (angle_end - angle_start) * ((float)i       / num_segments);
          float a1 = angle_start + (angle_end - angle_start) * ((float)(i + 1) / num_segments);
          draw_list->AddLine(
              ImVec2(center.x + cosf(a0) * radius, center.y + sinf(a0) * radius),
              ImVec2(center.x + cosf(a1) * radius, center.y + sinf(a1) * radius),
              bg_color, 6.0f);
      }

      // Normalize value to [0, 1]
      float t = 0.0f;
      if (max_val > min_val) {
          t = (value - min_val) / (max_val - min_val);
          if (t < 0.0f) t = 0.0f;
          if (t > 1.0f) t = 1.0f;
      }

      // Color: green (t=0) -> yellow (t=0.5) -> red (t=1)
      float r, g, b;
      if (t < 0.5f) {
          float s = t * 2.0f;
          r = (unsigned char)(s * 255);
          g = 200;
          b = 0;
      } else {
          float s = (t - 0.5f) * 2.0f;
          r = 220;
          g = (unsigned char)((1.0f - s) * 200);
          b = 0;
      }
      ImU32 value_color = IM_COL32((int)r, (int)g, (int)b, 255);

      // Value arc
      float angle_value = angle_start + (angle_end - angle_start) * t;
      int value_segments = (int)(num_segments * t);
      for (int i = 0; i < value_segments; i++) {
          float a0 = angle_start + (angle_end - angle_start) * ((float)i       / num_segments);
          float a1 = angle_start + (angle_end - angle_start) * ((float)(i + 1) / num_segments);
          draw_list->AddLine(
              ImVec2(center.x + cosf(a0) * radius, center.y + sinf(a0) * radius),
              ImVec2(center.x + cosf(a1) * radius, center.y + sinf(a1) * radius),
              value_color, 6.0f);
      }

      // Needle: line from center to arc at current angle
      float needle_len = radius * 0.75f;
      ImVec2 needle_tip = ImVec2(
          center.x + cosf(angle_value) * needle_len,
          center.y + sinf(angle_value) * needle_len);
      draw_list->AddLine(center, needle_tip, IM_COL32(255, 255, 255, 240), 2.0f);
      draw_list->AddCircleFilled(center, 4.0f, IM_COL32(255, 255, 255, 200));

      // Value text centered
      char val_buf[32];
      snprintf(val_buf, sizeof(val_buf), "%.1f", value);
      ImVec2 val_size = ImGui::CalcTextSize(val_buf);
      draw_list->AddText(
          ImVec2(center.x - val_size.x * 0.5f, center.y + radius * 0.35f),
          IM_COL32(230, 230, 230, 255), val_buf);

      // Label below value
      ImVec2 label_size = ImGui::CalcTextSize(label);
      draw_list->AddText(
          ImVec2(center.x - label_size.x * 0.5f, center.y + radius * 0.35f + val_size.y + 2.0f),
          IM_COL32(180, 180, 180, 200), label);
  }

params_schema:
  {"params":[{"name":"label","desc":"Etiqueta del gauge, se muestra centrada debajo del valor numerico"},{"name":"value","desc":"Valor actual a mostrar en el gauge"},{"name":"min_val","desc":"Valor minimo de la escala (extremo izquierdo del arco)"},{"name":"max_val","desc":"Valor maximo de la escala (extremo derecho del arco)"},{"name":"radius","desc":"Radio del gauge en pixels (default 60.0)"}],"output":"Renderiza el gauge en el frame ImGui actual, reservando espacio con ImGui::Dummy"}


id: graph_force_layout_cpp_viz
name: graph_force_layout
kind: function
lang: cpp
domain: viz
version: 1.0.0
purity: pure
signature: float graph_force_layout_step(GraphData& graph, const ForceLayoutConfig& config)
tags: ["graph","layout","force-directed","barnes-hut","physics","gpu"]
uses_functions: []
uses_types: ["GraphData_cpp_viz"]
returns: []
imports: []
tests: []
file_path: cpp/functions/viz/graph_force_layout.cpp
created_at: 2026-04-07T22:05:15Z
updated_at: 2026-04-07T22:05:15Z
props: []
emits: []
framework: imgui
variant: []
content_hash: 9311ea971afaf855d46541d53f54f62c1e6e92f8232d28eee5deb1fd506dff4f
description:
  Layout force-directed con aproximacion Barnes-Hut para grafos grandes, ejecuta un paso de simulacion por llamada

example:
  ```cpp
  static ForceLayoutConfig cfg;
  static bool running = true;

  if (running) {
      float energy = graph_force_layout_step(my_graph, cfg);
      if (energy < 0.01f) running = false; // convergido
  }
  ```

notes:
  - El quadtree usa un pool estatico de `1 << 20` (~1M) celdas. Para grafos de >500K nodos
    se recomienda reducir `MAX_QUAD_NODES` o aumentarlo segun memoria disponible.
  - La pila de traversal en `quad_force` es tambien estatica (`static int stack[]`); no es
    thread-safe si se llama desde multiples hilos simultaneamente.
  - `graph_force_layout_reset` usa `rand()`. Para reproducibilidad llama `srand(seed)` antes.
  - Los buffers de fuerza (`fx_buf`, `fy_buf`) se realocan una sola vez cuando el conteo de
    nodos supera la capacidad previa; en el uso normal (tamano fijo) no hay allocaciones
    por frame.

documentation:
  # graph_force_layout

  Implementa el algoritmo de layout force-directed clasico (Fruchterman-Reingold / Eades) con aproximacion Barnes-Hut O(n log n) para escalar a grafos de miles de nodos.

  ## Algoritmo

  Cada llamada a `graph_force_layout_step` ejecuta `config.iterations` pasos. Un paso:

  1. **Construccion del quadtree** (Barnes-Hut): se calcula el bounding box de las posiciones actuales, se construye un quadtree flat en `quad_pool` (sin allocaciones por nodo). Cada celda acumula centro de masa y masa total.
  2. **Repulsion**: para cada nodo se recorre el quadtree. Si el cociente `cell_size / distance < theta`, la celda se trata como una sola masa puntual (multipolo de orden 0). Si no, se desciende a los hijos. Con `theta=0` es O(n²) exacto; con `theta=0.5` es O(n log n).
  3. **Atraccion**: para cada arista `(s, t)`, fuerza de Hooke `F = k * dist * weight` en la direccion del arco.
  4. **Gravedad**: fuerza proporcional a la distancia al origen, evita que el grafo derive fuera de pantalla.
  5. **Integracion**: `v = v * damping + F`, `pos += v`, con clamping de velocidad.
  6. Nodos con `pinned = true` no se mueven en ningun paso.

  ## Funciones auxiliares

  ```cpp
  // Randomizar posiciones para empezar la simulacion
  graph_force_layout_reset(graph, 200.0f);

  // Layout circular instantaneo (sin iteracion)
  graph_layout_circular(graph, 150.0f);

  // Layout en grid instantaneo
  graph_layout_grid(graph, 25.0f);
  ```

code:
  #include "viz/graph_force_layout.h"
  #include "viz/graph_types.h"

  #include <cmath>
  #include <cstdlib>
  #include <algorithm>

  // ---------------------------------------------------------------------------
  // Quadtree for Barnes-Hut approximation
  // ---------------------------------------------------------------------------

  struct QuadNode {
      float cx, cy;       // center of mass
      float mass;         // total mass (node count in subtree)
      float x0, y0;       // bounding box min
      float x1, y1;       // bounding box max
      int children[4];    // NW=0, NE=1, SW=2, SE=3  (-1 = empty)
      int body;           // node index if leaf (-1 if internal)
  };

  static constexpr int MAX_QUAD_NODES = 1 << 20; // supports graphs up to ~1M nodes
  static QuadNode quad_pool[MAX_QUAD_NODES];
  static int quad_count = 0;

  static int quad_new(float x0, float y0, float x1, float y1) {
      if (quad_count >= MAX_QUAD_NODES) return -1;
      int idx = quad_count++;
      QuadNode& q = quad_pool[idx];
      q.cx = 0; q.cy = 0; q.mass = 0;
      q.x0 = x0; q.y0 = y0; q.x1 = x1; q.y1 = y1;
      q.children[0] = q.children[1] = q.children[2] = q.children[3] = -1;
      q.body = -1;
      return idx;
  }

  // Determine quadrant index for point (px,py) relative to cell midpoint.
  // 0=NW, 1=NE, 2=SW, 3=SE
  static int quad_child_idx(const QuadNode& q, float px, float py) {
      float mx = (q.x0 + q.x1) * 0.5f;
      float my = (q.y0 + q.y1) * 0.5f;
      int xi = (px >= mx) ? 1 : 0;
      int yi = (py >= my) ? 2 : 0;
      return xi | yi;
  }

  // Subdivide cell qi into four children.
  static void quad_subdivide(int qi) {
      QuadNode& q = quad_pool[qi];
      float mx = (q.x0 + q.x1) * 0.5f;
      float my = (q.y0 + q.y1) * 0.5f;
      // NW
      quad_pool[qi].children[0] = quad_new(q.x0, q.y0, mx, my);
      // NE
      quad_pool[qi].children[1] = quad_new(mx, q.y0, q.x1, my);
      // SW
      quad_pool[qi].children[2] = quad_new(q.x0, my, mx, q.y1);
      // SE
      quad_pool[qi].children[3] = quad_new(mx, my, q.x1, q.y1);
  }

  // Insert body (node_idx at position nx,ny with mass nmass) into cell qi.
  // Uses iterative descent to avoid stack overflow on deep trees.
  static void quad_insert(int root, int node_idx, float nx, float ny, float nmass) {
      int qi = root;
      while (qi >= 0) {
          QuadNode& q = quad_pool[qi];
          // Update center of mass
          float total = q.mass + nmass;
          q.cx = (q.cx * q.mass + nx * nmass) / total;
          q.cy = (q.cy * q.mass + ny * nmass) / total;
          q.mass = total;

          if (q.body == -1 && q.children[0] == -1) {
              // Empty leaf: place body here
              q.body = node_idx;
              return;
          }

          if (q.body >= 0) {
              // Leaf with existing body: subdivide, push existing body down
              quad_subdivide(qi);
              // Move old body into correct child (re-read q after subdivide since pool may shift)
              QuadNode& qq = quad_pool[qi];
              int old_body = qq.body;
              float obx = /* we need positions */ 0, oby = 0;
              // We store positions in the GraphData, pass via closure is not possible here.
              // Instead we pass a pointer to positions alongside. We'll fix this by using
              // a file-scope pointer set before each build.
              (void)old_body; (void)obx; (void)oby;
              // NOTE: positions accessed via file-scope g_nodes pointer below.
              qq.body = -1;
          }

          int ci = quad_child_idx(quad_pool[qi], nx, ny);
          qi = quad_pool[qi].children[ci];
      }
  }

  // File-scope pointers set before each tree build (avoids passing them everywhere).
  static const GraphNode* g_nodes = nullptr;

  // Insert body knowing positions from g_nodes.
  static void quad_insert_body(int qi, int node_idx) {
      float nx = g_nodes[node_idx].x;
      float ny = g_nodes[node_idx].y;
      const float nmass = 1.0f;

      while (qi >= 0) {
          QuadNode& q = quad_pool[qi];
          float total = q.mass + nmass;
          q.cx = (q.cx * q.mass + nx * nmass) / total;
          q.cy = (q.cy * q.mass + ny * nmass) / total;
          q.mass = total;

          if (q.body == -1 && q.children[0] == -1) {
              // Empty leaf
              q.body = node_idx;
              return;
          }

          if (q.children[0] == -1) {
              // Leaf occupied: subdivide and push existing body down
              int old_body = q.body;
              q.body = -1;
              quad_subdivide(qi);

              // Push old body into child
              int old_ci = quad_child_idx(quad_pool[qi], g_nodes[old_body].x, g_nodes[old_body].y);
              int old_child = quad_pool[qi].children[old_ci];
              if (old_child >= 0) {
                  QuadNode& oc = quad_pool[old_child];
                  oc.cx = g_nodes[old_body].x;
                  oc.cy = g_nodes[old_body].y;
                  oc.mass = 1.0f;
                  oc.body = old_body;
              }
          }

          int ci = quad_child_idx(quad_pool[qi], nx, ny);
          qi = quad_pool[qi].children[ci];
      }
  }

  // Compute Barnes-Hut repulsion force on node at (nx,ny) from subtree qi.
  // Accumulates force into (fx, fy).
  static void quad_force(int qi, float nx, float ny,
                         float theta, float repulsion, float min_dist,
                         float& fx, float& fy) {
      // Iterative traversal using a small stack to avoid recursion depth issues.
      static int stack[MAX_QUAD_NODES]; // reuse static stack
      int top = 0;
      stack[top++] = qi;

      while (top > 0) {
          int ci = stack[--top];
          if (ci < 0) continue;
          const QuadNode& q = quad_pool[ci];
          if (q.mass == 0) continue;

          float dx = q.cx - nx;
          float dy = q.cy - ny;
          float dist2 = dx * dx + dy * dy;
          float dist = std::sqrt(dist2);
          if (dist < min_dist) dist = min_dist;

          // Cell size
          float cell_size = q.x1 - q.x0;

          // Use multipole approximation if far enough OR if leaf
          bool is_leaf = (q.children[0] == -1);
          if (is_leaf || (cell_size / dist) < theta) {
              // Coulomb repulsion: F = repulsion * mass / dist^2
              float force = repulsion * q.mass / (dist * dist);
              fx -= force * dx / dist;
              fy -= force * dy / dist;
          } else {
              // Push children
              for (int k = 0; k < 4; ++k) {
                  if (q.children[k] >= 0)
                      stack[top++] = q.children[k];
              }
          }
      }
  }

  // ---------------------------------------------------------------------------
  // Public API
  // ---------------------------------------------------------------------------

  float graph_force_layout_step(GraphData& graph, const ForceLayoutConfig& config) {
      if (graph.node_count <= 0) return 0.0f;

      // Temporary force accumulators (stack-allocated for small graphs, static for large)
      static float* fx_buf = nullptr;
      static float* fy_buf = nullptr;
      static int    buf_cap = 0;

      if (graph.node_count > buf_cap) {
          delete[] fx_buf;
          delete[] fy_buf;
          buf_cap = graph.node_count + 64;
          fx_buf = new float[buf_cap];
          fy_buf = new float[buf_cap];
      }

      float total_energy = 0.0f;

      for (int iter = 0; iter < config.iterations; ++iter) {
          // Zero forces
          for (int i = 0; i < graph.node_count; ++i) {
              fx_buf[i] = 0.0f;
              fy_buf[i] = 0.0f;
          }

          // ---- Build Barnes-Hut quadtree ----
          // Compute bounding box of current positions
          float bx0 = graph.nodes[0].x, bx1 = graph.nodes[0].x;
          float by0 = graph.nodes[0].y, by1 = graph.nodes[0].y;
          for (int i = 1; i < graph.node_count; ++i) {
              float px = graph.nodes[i].x, py = graph.nodes[i].y;
              if (px < bx0) bx0 = px; if (px > bx1) bx1 = px;
              if (py < by0) by0 = py; if (py > by1) by1 = py;
          }
          // Add margin to avoid degeneracies
          float margin = (bx1 - bx0 + by1 - by0) * 0.05f + 1.0f;
          bx0 -= margin; bx1 += margin;
          by0 -= margin; by1 += margin;
          // Make it square
          float side = std::max(bx1 - bx0, by1 - by0);
          float cx = (bx0 + bx1) * 0.5f, cy = (by0 + by1) * 0.5f;
          bx0 = cx - side * 0.5f; bx1 = cx + side * 0.5f;
          by0 = cy - side * 0.5f; by1 = cy + side * 0.5f;

          quad_count = 0;
          g_nodes = graph.nodes;
          int root = quad_new(bx0, by0, bx1, by1);

          for (int i = 0; i < graph.node_count; ++i) {
              quad_insert_body(root, i);
          }

          // ---- Repulsion via Barnes-Hut ----
          for (int i = 0; i < graph.node_count; ++i) {
              if (graph.nodes[i].pinned) continue;
              quad_force(root,
                         graph.nodes[i].x, graph.nodes[i].y,
                         config.theta, config.repulsion, config.min_distance,
                         fx_buf[i], fy_buf[i]);
              // Subtract self-interaction (the tree includes the node itself)
              // Self-force: repulsion * 1 / min_dist^2, but direction is (0,0) -> skip
          }

          // ---- Attraction along edges (spring force) ----
          for (int e = 0; e < graph.edge_count; ++e) {
              const GraphEdge& edge = graph.edges[e];
              int s = (int)edge.source;
              int t = (int)edge.target;
              if (s < 0 || s >= graph.node_count) continue;
              if (t < 0 || t >= graph.node_count) continue;

              float dx = graph.nodes[t].x - graph.nodes[s].x;
              float dy = graph.nodes[t].y - graph.nodes[s].y;
              float dist = std::sqrt(dx * dx + dy * dy);
              if (dist < config.min_distance) dist = config.min_distance;

              // F = k * dist * weight  (Hooke: pulls toward equilibrium at 0)
              float force = config.attraction * dist * edge.weight;
              float fx_e = force * dx / dist;
              float fy_e = force * dy / dist;

              if (!graph.nodes[s].pinned) { fx_buf[s] += fx_e; fy_buf[s] += fy_e; }
              if (!graph.nodes[t].pinned) { fx_buf[t] -= fx_e; fy_buf[t] -= fy_e; }
          }

          // ---- Gravity toward center (0,0) ----
          if (config.gravity != 0.0f) {
              for (int i = 0; i < graph.node_count; ++i) {
                  if (graph.nodes[i].pinned) continue;
                  fx_buf[i] -= config.gravity * graph.nodes[i].x;
                  fy_buf[i] -= config.gravity * graph.nodes[i].y;
              }
          }

          // ---- Integrate: v = v * damping + F; pos += v ----
          total_energy = 0.0f;
          for (int i = 0; i < graph.node_count; ++i) {
              GraphNode& n = graph.nodes[i];
              if (n.pinned) continue;

              n.vx = n.vx * config.damping + fx_buf[i];
              n.vy = n.vy * config.damping + fy_buf[i];

              // Clamp velocity
              n.vx = std::max(-config.max_velocity, std::min(config.max_velocity, n.vx));
              n.vy = std::max(-config.max_velocity, std::min(config.max_velocity, n.vy));

              n.x += n.vx;
              n.y += n.vy;

              total_energy += n.vx * n.vx + n.vy * n.vy;
          }
      }

      graph.update_bounds();
      return total_energy;
  }

  void graph_force_layout_reset(GraphData& graph, float spread) {
      for (int i = 0; i < graph.node_count; ++i) {
          GraphNode& n = graph.nodes[i];
          if (n.pinned) continue;
          // rand() produces [0, RAND_MAX]; map to [-spread, spread]
          n.x = spread * (2.0f * (float)rand() / (float)RAND_MAX - 1.0f);
          n.y = spread * (2.0f * (float)rand() / (float)RAND_MAX - 1.0f);
          n.vx = 0.0f;
          n.vy = 0.0f;
      }
      graph.update_bounds();
  }

  void graph_layout_circular(GraphData& graph, float radius) {
      if (graph.node_count <= 0) return;
      const float two_pi = 6.28318530718f;
      for (int i = 0; i < graph.node_count; ++i) {
          GraphNode& n = graph.nodes[i];
          if (n.pinned) continue;
          float angle = two_pi * (float)i / (float)graph.node_count;
          n.x = radius * std::cos(angle);
          n.y = radius * std::sin(angle);
          n.vx = 0.0f;
          n.vy = 0.0f;
      }
      graph.update_bounds();
  }

  void graph_layout_grid(GraphData& graph, float spacing) {
      if (graph.node_count <= 0) return;
      int cols = (int)std::ceil(std::sqrt((float)graph.node_count));
      int rows = (graph.node_count + cols - 1) / cols;
      float ox = -0.5f * (cols - 1) * spacing;
      float oy = -0.5f * (rows - 1) * spacing;
      for (int i = 0; i < graph.node_count; ++i) {
          GraphNode& n = graph.nodes[i];
          if (n.pinned) continue;
          int col = i % cols;
          int row = i / cols;
          n.x = ox + col * spacing;
          n.y = oy + row * spacing;
          n.vx = 0.0f;
          n.vy = 0.0f;
      }
      graph.update_bounds();
  }

params_schema:
  {"params":[{"name":"graph","desc":"Referencia al grafo (GraphData) cuyos nodos se actualizan in-place. Modifica x, y, vx, vy de cada nodo no pinned."},{"name":"config","desc":"Parametros de la simulacion: repulsion (fuerza coulombiana), attraction (spring constant), damping (decay de velocidad), theta (precision Barnes-Hut 0=exacto/1=rapido), gravity (atraccion al centro), max_velocity, iterations."}],"output":"Energia cinetica total (suma de |v|^2). Cuando cae por debajo de un umbral elegido por el caller, el layout ha convergido y se puede dejar de llamar."}


id: graph_renderer_cpp_viz
name: graph_renderer
kind: function
lang: cpp
domain: viz
version: 1.0.0
purity: impure
signature: GraphRenderer* graph_renderer_create(int width, int height, const GraphRendererConfig& config)
tags: ["graph","renderer","opengl","gpu","instanced","fbo","visualization"]
uses_functions: []
uses_types: ["GraphData_cpp_viz"]
returns: []
error_type: error_go_core
imports: ["imgui"]
tests: []
file_path: cpp/functions/viz/graph_renderer.cpp
created_at: 2026-04-07T22:05:15Z
updated_at: 2026-04-07T22:05:15Z
props: []
emits: []
framework: imgui
variant: []
content_hash: 454b3d858672beb05e1fe34e5c7691ea1cb09ea8f72fe370645e6d6a67960d27
description:
  Renderer GPU de grafos con instanced rendering a FBO, compatible con ImGui::Image para visualizacion de grafos grandes

example:
  ```cpp
  // Inicializacion (una vez)
  GraphRenderer* renderer = graph_renderer_create(800, 600);

  // En el render loop
  ImVec2 panel_size = ImGui::GetContentRegionAvail();
  graph_renderer_resize(renderer, (int)panel_size.x, (int)panel_size.y);

  unsigned int tex = graph_renderer_draw(renderer, graph_data,
                                          cam_x, cam_y, cam_zoom);

  ImGui::Image((ImTextureID)(uintptr_t)tex,
               panel_size,
               ImVec2(0, 1), ImVec2(1, 0)); // flip Y para OpenGL

  // Destruccion
  graph_renderer_destroy(renderer);
  ```

notes:
  **Renderizado de nodos:** Instanced rendering con un quad unitario [-0.5, 0.5] expandido por el tamano del nodo. El fragment shader aplica un SDF circular con anti-aliasing via `smoothstep` y un anillo de outline.

  **Renderizado de aristas:** `GL_LINES` con datos de posicion y color empaquetados por arista. El ancho se controla con `GraphRendererConfig::edge_width`.

  **Transformacion de camara:**
  ```
  tx = -cam_x * zoom + width/2
  ty = -cam_y * zoom + height/2
  ndc = (screen / viewport) * 2 - 1
  ```

  **Paleta de comunidades:** 10 colores ABGR usados cuando `node.color == 0`, seleccionados por `node.community % 10`.

  **Estado GL:** Guarda y restaura `GL_FRAMEBUFFER_BINDING` y `GL_VIEWPORT` para ser compatible con el render loop de ImGui sin efectos secundarios.

  **Includes GL:** Usa `#define GL_GLEXT_PROTOTYPES` + `<GL/gl.h>` + `<GL/glext.h>`. Si el proyecto carga funciones GL via glad/gl3w, reemplazar estos includes por el loader correspondiente.

documentation:
  # graph_renderer

  Renderer GPU de grafos basado en OpenGL 3.3 core profile con instanced rendering. Renderiza nodos y aristas de un `GraphData` a un FBO interno y retorna el texture ID para integracion directa con `ImGui::Image()`.

  ## Funciones del API

  ```cpp
  // Ciclo de vida
  GraphRenderer* graph_renderer_create(int width, int height, const GraphRendererConfig& config = {});
  void graph_renderer_destroy(GraphRenderer* r);
  void graph_renderer_resize(GraphRenderer* r, int width, int height);

  // Renderizado
  unsigned int graph_renderer_draw(GraphRenderer* r, const GraphData& graph,
                                    float cam_x, float cam_y, float cam_zoom);
  ```

code:
  #include "viz/graph_renderer.h"
  #include "viz/graph_types.h"

  #define GL_GLEXT_PROTOTYPES
  #include <GL/gl.h>
  #include <GL/glext.h>

  #include <cstdlib>
  #include <cstring>
  #include <cstdio>
  #include <cmath>

  // ---------------------------------------------------------------------------
  // Community palette (ABGR packed, 10 colors)
  // ---------------------------------------------------------------------------
  static const uint32_t k_palette[10] = {
      0xFF4CAF50, // green
      0xFFF44336, // red
      0xFF2196F3, // blue
      0xFFFF9800, // orange
      0xFF9C27B0, // purple
      0xFF00BCD4, // cyan
      0xFFFFEB3B, // yellow
      0xFFE91E63, // pink
      0xFF795548, // brown
      0xFF607D8B  // blue-grey
  };

  // ---------------------------------------------------------------------------
  // Internal struct
  // ---------------------------------------------------------------------------
  struct GraphRenderer {
      unsigned int fbo;
      unsigned int texture;
      unsigned int rbo; // depth/stencil renderbuffer
      int width, height;

      // Node rendering (instanced quads)
      unsigned int node_vao, node_quad_vbo, node_instance_vbo;
      unsigned int node_shader;

      // Edge rendering (lines)
      unsigned int edge_vao, edge_vbo;
      unsigned int edge_shader;

      GraphRendererConfig config;
  };

  // ---------------------------------------------------------------------------
  // Shader sources
  // ---------------------------------------------------------------------------

  // Node vertex shader — instanced unit quad
  static const char* k_node_vert = R"(
  #version 330 core
  // Quad corners [-0.5, 0.5]
  layout(location = 0) in vec2 a_quad;

  // Per-instance: world position, size, RGBA color
  layout(location = 1) in vec2  a_pos;
  layout(location = 2) in float a_size;
  layout(location = 3) in vec4  a_color;

  out vec2  v_uv;
  out vec4  v_color;

  uniform vec2  u_viewport;   // (width, height) in pixels
  uniform float u_scale;      // cam_zoom
  uniform vec2  u_translate;  // (tx, ty) in pixels

  void main() {
      // World -> screen (pixels)
      vec2 screen = a_pos * u_scale + u_translate;
      // Expand quad by node radius (size = diameter)
      screen += a_quad * a_size * u_scale;
      // Screen -> NDC
      vec2 ndc = (screen / u_viewport) * 2.0 - 1.0;
      ndc.y = -ndc.y; // flip Y (screen Y grows downward)
      gl_Position = vec4(ndc, 0.0, 1.0);
      v_uv    = a_quad + 0.5; // [0,1]
      v_color = a_color;
  }
  )";

  // Node fragment shader — SDF circle with outline
  static const char* k_node_frag = R"(
  #version 330 core
  in vec2 v_uv;
  in vec4 v_color;

  out vec4 frag_color;

  uniform float u_outline_px;  // outline width in uv units
  uniform float u_node_px;     // node diameter in pixels (= size * zoom)

  void main() {
      // SDF circle centered at (0.5, 0.5) in uv space
      float dist = length(v_uv - 0.5);
      float r    = 0.5;

      // Anti-alias edge (in uv units; 1px ~ 1/u_node_px in uv space)
      float fwidth_uv = 1.5 / max(u_node_px, 1.0);

      float alpha = 1.0 - smoothstep(r - fwidth_uv, r, dist);
      if (alpha < 0.001) discard;

      // Outline ring
      float outline_uv = u_outline_px / max(u_node_px, 1.0);
      float outline    = smoothstep(r - outline_uv - fwidth_uv, r - outline_uv, dist);

      vec3 fill    = v_color.rgb;
      vec3 outline_col = mix(fill, vec3(1.0), 0.6); // lighter outline
      vec3 color   = mix(fill, outline_col, outline);

      frag_color = vec4(color, v_color.a * alpha);
  }
  )";

  // Edge vertex shader
  static const char* k_edge_vert = R"(
  #version 330 core
  layout(location = 0) in vec2 a_pos;
  layout(location = 1) in vec4 a_color;

  out vec4 v_color;

  uniform vec2  u_viewport;
  uniform float u_scale;
  uniform vec2  u_translate;

  void main() {
      vec2 screen = a_pos * u_scale + u_translate;
      vec2 ndc    = (screen / u_viewport) * 2.0 - 1.0;
      ndc.y = -ndc.y;
      gl_Position = vec4(ndc, 0.0, 1.0);
      v_color = a_color;
  }
  )";

  // Edge fragment shader
  static const char* k_edge_frag = R"(
  #version 330 core
  in vec4 v_color;
  out vec4 frag_color;

  void main() {
      frag_color = v_color;
  }
  )";

  // ---------------------------------------------------------------------------
  // Shader helpers
  // ---------------------------------------------------------------------------
  static unsigned int compile_shader(GLenum type, const char* src) {
      unsigned int s = glCreateShader(type);
      glShaderSource(s, 1, &src, nullptr);
      glCompileShader(s);
      int ok;
      glGetShaderiv(s, GL_COMPILE_STATUS, &ok);
      if (!ok) {
          char buf[512];
          glGetShaderInfoLog(s, sizeof(buf), nullptr, buf);
          fprintf(stderr, "[graph_renderer] shader compile error: %s\n", buf);
      }
      return s;
  }

  static unsigned int link_program(const char* vert_src, const char* frag_src) {
      unsigned int vs = compile_shader(GL_VERTEX_SHADER,   vert_src);
      unsigned int fs = compile_shader(GL_FRAGMENT_SHADER, frag_src);
      unsigned int prog = glCreateProgram();
      glAttachShader(prog, vs);
      glAttachShader(prog, fs);
      glLinkProgram(prog);
      int ok;
      glGetProgramiv(prog, GL_LINK_STATUS, &ok);
      if (!ok) {
          char buf[512];
          glGetProgramInfoLog(prog, sizeof(buf), nullptr, buf);
          fprintf(stderr, "[graph_renderer] program link error: %s\n", buf);
      }
      glDeleteShader(vs);
      glDeleteShader(fs);
      return prog;
  }

  // ---------------------------------------------------------------------------
  // FBO helpers
  // ---------------------------------------------------------------------------
  static void create_fbo(GraphRenderer* r) {
      // Texture
      glGenTextures(1, &r->texture);
      glBindTexture(GL_TEXTURE_2D, r->texture);
      glTexImage2D(GL_TEXTURE_2D, 0, GL_RGBA8, r->width, r->height, 0, GL_RGBA, GL_UNSIGNED_BYTE, nullptr);
      glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_LINEAR);
      glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_LINEAR);
      glBindTexture(GL_TEXTURE_2D, 0);

      // Depth renderbuffer
      glGenRenderbuffers(1, &r->rbo);
      glBindRenderbuffer(GL_RENDERBUFFER, r->rbo);
      glRenderbufferStorage(GL_RENDERBUFFER, GL_DEPTH_COMPONENT24, r->width, r->height);
      glBindRenderbuffer(GL_RENDERBUFFER, 0);

      // FBO
      glGenFramebuffers(1, &r->fbo);
      glBindFramebuffer(GL_FRAMEBUFFER, r->fbo);
      glFramebufferTexture2D(GL_FRAMEBUFFER, GL_COLOR_ATTACHMENT0, GL_TEXTURE_2D, r->texture, 0);
      glFramebufferRenderbuffer(GL_FRAMEBUFFER, GL_DEPTH_ATTACHMENT, GL_RENDERBUFFER, r->rbo);
      glBindFramebuffer(GL_FRAMEBUFFER, 0);
  }

  static void destroy_fbo(GraphRenderer* r) {
      glDeleteFramebuffers(1,  &r->fbo);
      glDeleteTextures(1,      &r->texture);
      glDeleteRenderbuffers(1, &r->rbo);
      r->fbo = r->texture = r->rbo = 0;
  }

  // ---------------------------------------------------------------------------
  // Helper: unpack ABGR uint32 to float RGBA
  // ---------------------------------------------------------------------------
  static inline void abgr_to_rgba(uint32_t abgr, float& r, float& g, float& b, float& a) {
      // ABGR layout: bits 31-24 = A, 23-16 = B, 15-8 = G, 7-0 = R
      a = ((abgr >> 24) & 0xFF) / 255.0f;
      b = ((abgr >> 16) & 0xFF) / 255.0f;
      g = ((abgr >>  8) & 0xFF) / 255.0f;
      r = ((abgr      ) & 0xFF) / 255.0f;
  }

  // ---------------------------------------------------------------------------
  // Public API
  // ---------------------------------------------------------------------------

  GraphRenderer* graph_renderer_create(int width, int height, const GraphRendererConfig& config) {
      GraphRenderer* r = new GraphRenderer();
      r->width  = width;
      r->height = height;
      r->config = config;

      // --- FBO ---
      create_fbo(r);

      // --- Node VAO ---
      // Unit quad: 4 vertices, each (x, y) in [-0.5, 0.5]
      static const float quad_verts[8] = {
          -0.5f, -0.5f,
           0.5f, -0.5f,
          -0.5f,  0.5f,
           0.5f,  0.5f,
      };

      glGenVertexArrays(1, &r->node_vao);
      glBindVertexArray(r->node_vao);

      // Quad VBO (location 0)
      glGenBuffers(1, &r->node_quad_vbo);
      glBindBuffer(GL_ARRAY_BUFFER, r->node_quad_vbo);
      glBufferData(GL_ARRAY_BUFFER, sizeof(quad_verts), quad_verts, GL_STATIC_DRAW);
      glEnableVertexAttribArray(0);
      glVertexAttribPointer(0, 2, GL_FLOAT, GL_FALSE, 2 * sizeof(float), (void*)0);

      // Instance VBO (location 1,2,3 — position, size, color)
      glGenBuffers(1, &r->node_instance_vbo);
      glBindBuffer(GL_ARRAY_BUFFER, r->node_instance_vbo);
      // layout: x, y, size, r, g, b, a  — 7 floats per instance
      glEnableVertexAttribArray(1); // pos
      glVertexAttribPointer(1, 2, GL_FLOAT, GL_FALSE, 7 * sizeof(float), (void*)0);
      glVertexAttribDivisor(1, 1);
      glEnableVertexAttribArray(2); // size
      glVertexAttribPointer(2, 1, GL_FLOAT, GL_FALSE, 7 * sizeof(float), (void*)(2 * sizeof(float)));
      glVertexAttribDivisor(2, 1);
      glEnableVertexAttribArray(3); // color
      glVertexAttribPointer(3, 4, GL_FLOAT, GL_FALSE, 7 * sizeof(float), (void*)(3 * sizeof(float)));
      glVertexAttribDivisor(3, 1);

      glBindVertexArray(0);

      // --- Edge VAO ---
      // Each edge: 2 vertices x (x, y, r, g, b, a) = 2 * 6 floats
      glGenVertexArrays(1, &r->edge_vao);
      glBindVertexArray(r->edge_vao);

      glGenBuffers(1, &r->edge_vbo);
      glBindBuffer(GL_ARRAY_BUFFER, r->edge_vbo);
      glEnableVertexAttribArray(0); // pos
      glVertexAttribPointer(0, 2, GL_FLOAT, GL_FALSE, 6 * sizeof(float), (void*)0);
      glEnableVertexAttribArray(1); // color
      glVertexAttribPointer(1, 4, GL_FLOAT, GL_FALSE, 6 * sizeof(float), (void*)(2 * sizeof(float)));

      glBindVertexArray(0);

      // --- Shaders ---
      r->node_shader = link_program(k_node_vert, k_node_frag);
      r->edge_shader = link_program(k_edge_vert, k_edge_frag);

      return r;
  }

  void graph_renderer_destroy(GraphRenderer* r) {
      if (!r) return;
      destroy_fbo(r);
      glDeleteVertexArrays(1, &r->node_vao);
      glDeleteBuffers(1, &r->node_quad_vbo);
      glDeleteBuffers(1, &r->node_instance_vbo);
      glDeleteVertexArrays(1, &r->edge_vao);
      glDeleteBuffers(1, &r->edge_vbo);
      glDeleteProgram(r->node_shader);
      glDeleteProgram(r->edge_shader);
      delete r;
  }

  void graph_renderer_resize(GraphRenderer* r, int width, int height) {
      if (!r) return;
      if (r->width == width && r->height == height) return;
      r->width  = width;
      r->height = height;
      destroy_fbo(r);
      create_fbo(r);
  }

  unsigned int graph_renderer_draw(GraphRenderer* r, const GraphData& graph,
                                    float cam_x, float cam_y, float cam_zoom) {
      if (!r) return 0;

      // --- Save GL state ---
      GLint prev_fbo;
      glGetIntegerv(GL_FRAMEBUFFER_BINDING, &prev_fbo);
      GLint prev_viewport[4];
      glGetIntegerv(GL_VIEWPORT, prev_viewport);

      // --- Bind FBO ---
      glBindFramebuffer(GL_FRAMEBUFFER, r->fbo);
      glViewport(0, 0, r->width, r->height);

      // Clear with bg_color (ABGR)
      float bg_a, bg_b, bg_g, bg_cr;
      abgr_to_rgba(r->config.bg_color, bg_cr, bg_g, bg_b, bg_a);
      glClearColor(bg_cr, bg_g, bg_b, bg_a);
      glClear(GL_COLOR_BUFFER_BIT | GL_DEPTH_BUFFER_BIT);

      // Enable blending for anti-aliasing and transparency
      glEnable(GL_BLEND);
      glBlendFunc(GL_SRC_ALPHA, GL_ONE_MINUS_SRC_ALPHA);

      // View transform: world -> screen pixels
      // tx = -cam_x * scale + width/2
      // ty = -cam_y * scale + height/2
      float scale = cam_zoom;
      float tx = -cam_x * scale + (float)r->width  * 0.5f;
      float ty = -cam_y * scale + (float)r->height * 0.5f;

      // ----------------------------------------------------------------
      // Draw edges
      // ----------------------------------------------------------------
      if (graph.edge_count > 0 && graph.edges && graph.nodes) {
          // Pack: 2 vertices per edge, each vertex = (x, y, r, g, b, a) = 6 floats
          const int floats_per_edge = 2 * 6;
          float* edge_buf = (float*)malloc((size_t)graph.edge_count * floats_per_edge * sizeof(float));
          int vi = 0;
          for (int i = 0; i < graph.edge_count; ++i) {
              const GraphEdge& e = graph.edges[i];
              uint32_t ecol = e.color != 0 ? e.color : 0xFF888888u; // default gray
              float er, eg, eb, ea;
              abgr_to_rgba(ecol, er, eg, eb, ea);
              ea *= r->config.edge_alpha;

              if (e.source < (uint32_t)graph.node_count && e.target < (uint32_t)graph.node_count) {
                  const GraphNode& ns = graph.nodes[e.source];
                  const GraphNode& nt = graph.nodes[e.target];

                  // Source vertex
                  edge_buf[vi++] = ns.x; edge_buf[vi++] = ns.y;
                  edge_buf[vi++] = er;   edge_buf[vi++] = eg;
                  edge_buf[vi++] = eb;   edge_buf[vi++] = ea;
                  // Target vertex
                  edge_buf[vi++] = nt.x; edge_buf[vi++] = nt.y;
                  edge_buf[vi++] = er;   edge_buf[vi++] = eg;
                  edge_buf[vi++] = eb;   edge_buf[vi++] = ea;
              }
          }

          glUseProgram(r->edge_shader);
          glUniform2f(glGetUniformLocation(r->edge_shader, "u_viewport"), (float)r->width, (float)r->height);
          glUniform1f(glGetUniformLocation(r->edge_shader, "u_scale"),    scale);
          glUniform2f(glGetUniformLocation(r->edge_shader, "u_translate"), tx, ty);

          glLineWidth(r->config.edge_width);

          glBindVertexArray(r->edge_vao);
          glBindBuffer(GL_ARRAY_BUFFER, r->edge_vbo);
          glBufferData(GL_ARRAY_BUFFER, vi * (int)sizeof(float), edge_buf, GL_DYNAMIC_DRAW);
          glDrawArrays(GL_LINES, 0, vi / 6);
          glBindVertexArray(0);

          free(edge_buf);
      }

      // ----------------------------------------------------------------
      // Draw nodes (instanced quads)
      // ----------------------------------------------------------------
      if (graph.node_count > 0 && graph.nodes) {
          // Pack: 7 floats per node: x, y, size, r, g, b, a
          float* node_buf = (float*)malloc((size_t)graph.node_count * 7 * sizeof(float));
          for (int i = 0; i < graph.node_count; ++i) {
              const GraphNode& n = graph.nodes[i];
              uint32_t ncol = n.color != 0 ? n.color : k_palette[n.community % 10];
              float nr, ng, nb, na;
              abgr_to_rgba(ncol, nr, ng, nb, na);

              float sz = n.size > 0.0f ? n.size : 4.0f;
              float* p = node_buf + i * 7;
              p[0] = n.x; p[1] = n.y; p[2] = sz;
              p[3] = nr;  p[4] = ng;  p[5] = nb; p[6] = na;
          }

          glUseProgram(r->node_shader);
          glUniform2f(glGetUniformLocation(r->node_shader, "u_viewport"),  (float)r->width, (float)r->height);
          glUniform1f(glGetUniformLocation(r->node_shader, "u_scale"),     scale);
          glUniform2f(glGetUniformLocation(r->node_shader, "u_translate"), tx, ty);
          glUniform1f(glGetUniformLocation(r->node_shader, "u_outline_px"), r->config.node_outline);

          glBindVertexArray(r->node_vao);
          glBindBuffer(GL_ARRAY_BUFFER, r->node_instance_vbo);
          glBufferData(GL_ARRAY_BUFFER, graph.node_count * 7 * (int)sizeof(float), node_buf, GL_DYNAMIC_DRAW);

          // Draw 4 vertices (triangle strip quad) x node_count instances
          // Pass per-instance node_px uniform via the average size (approximation)
          // For exact per-node pixel size we'd need a texture or another approach;
          // use a uniform average for AA quality — good enough for most graphs.
          float avg_px = 8.0f * scale; // rough estimate
          glUniform1f(glGetUniformLocation(r->node_shader, "u_node_px"), avg_px);

          glDrawArraysInstanced(GL_TRIANGLE_STRIP, 0, 4, graph.node_count);
          glBindVertexArray(0);

          free(node_buf);
      }

      // --- Restore GL state ---
      glDisable(GL_BLEND);
      glBindFramebuffer(GL_FRAMEBUFFER, (GLuint)prev_fbo);
      glViewport(prev_viewport[0], prev_viewport[1], prev_viewport[2], prev_viewport[3]);

      return r->texture;
  }

params_schema:
  {"params":[{"name":"width","desc":"Ancho del framebuffer en pixels"},{"name":"height","desc":"Alto del framebuffer en pixels"},{"name":"config","desc":"Configuracion visual: outline width, edge width, edge alpha, color de fondo, fade de aristas por distancia a camara"}],"output":"Handle opaco al renderer. Usar graph_renderer_draw() para obtener texture ID de OpenGL, pasable directamente a ImGui::Image()"}


id: graph_viewport_cpp_viz
name: graph_viewport
kind: component
lang: cpp
domain: viz
version: 1.0.0
purity: impure
signature: bool graph_viewport(const char* id, GraphData& graph, GraphViewportState& state, ImVec2 size)
tags: ["graph","viewport","imgui","interactive","pan","zoom","dashboard"]
uses_functions: ["graph_renderer_cpp_viz","graph_force_layout_cpp_viz","graph_spatial_hash_cpp_core"]
uses_types: ["GraphData_cpp_viz"]
returns: []
error_type: error_go_core
imports: ["imgui"]
tests: []
file_path: cpp/functions/viz/graph_viewport.cpp
created_at: 2026-04-07T22:05:15Z
updated_at: 2026-04-07T22:05:15Z
emits: []
framework: imgui
variant: []
content_hash: 724d590828b6a821060c7dee9eb04d38f36dcf9811fd6674045c9dfd83312e85
description:
  Widget ImGui completo para visualizacion interactiva de grafos con pan, zoom, hover, seleccion y layout en vivo

props:
  [{"name":"id","type":"const char*","required":true,"description":"Identificador unico del widget ImGui"},{"name":"graph","type":"GraphData\u0026","required":true,"description":"Referencia al grafo (lectura de datos, escritura de posiciones al drag)"},{"name":"state","type":"GraphViewportState\u0026","required":true,"description":"Estado persistente del viewport (camera, seleccion, renderer). Debe vivir mas que los frames."},{"name":"size","type":"ImVec2","required":false,"description":"Tamanio del widget en pixeles. ImVec2(0,0) usa todo el espacio disponible."}]

notes:
  - Usa `ImGui::InvisibleButton` con flags para los tres botones del raton, capturando input sin dibujar ningun boton visible.
  - La textura del renderer se muestra con UV volteado en Y (`ImVec2(0,1)` a `ImVec2(1,0)`) para corregir la convencion de coordenadas de OpenGL vs ImGui.
  - El spatial hash se reconstruye cada frame desde las posiciones actuales de los nodos, garantizando hit-testing correcto despues de drag o layout.
  - El zoom hacia el cursor mantiene el punto del grafo bajo el cursor fijo en pantalla ajustando `cam_x`/`cam_y`.

documentation:
  # graph_viewport

  Widget ImGui self-contained para visualizar grafos interactivos. Integra rendering GPU, force-directed layout y hit-testing espacial en una sola llamada por frame.

  ## Uso basico

  ```cpp
  // Declarar estado persistente (fuera del loop de render)
  GraphViewportState vp_state;

  // En el loop de render (dentro de una ventana ImGui):
  if (graph_viewport("mi_grafo", my_graph, vp_state)) {
      // hubo interaccion este frame
      if (vp_state.selected_node >= 0) {
          auto& n = my_graph.nodes[vp_state.selected_node];
          // mostrar panel de detalle de n
      }
  }

  // Al terminar:
  graph_viewport_destroy(vp_state);
  ```

  ## Estado de camara

  La camara usa coordenadas del espacio del grafo:

  - `cam_x`, `cam_y`: centro de la camara en espacio del grafo
  - `zoom`: pixeles por unidad de grafo

  `graph_viewport_fit()` centra y ajusta el zoom para que el grafo quepa con 10% de padding.

  ## Controles

  | Accion | Control |
  |--------|---------|
  | Pan | Boton medio o derecho + arrastrar |
  | Zoom | Rueda del raton (hacia el cursor) |
  | Seleccionar nodo | Click izquierdo |
  | Arrastrar nodo | Click izquierdo sobre nodo |
  | Toggle layout | Barra espaciadora |
  | Fit camara | F |

  ## Force layout

  El layout se ejecuta automaticamente cada frame mientras `state.layout_running == true`. Se detiene solo cuando la energia cinetica cae por debajo de `0.01`. Se puede pausar/reanudar con la barra espaciadora.

  Los nodos arrastrados se marcan como `pinned = true` durante el drag, impidiendo que el force layout los mueva. Al soltar, `pinned` vuelve a `false`.

  ## Tooltip

  Al hacer hover sobre un nodo se muestra un tooltip con: label, id numerico, community, degree (aristas conectadas) y value.

  ## Status bar

  En la parte inferior del widget aparece: numero de nodos, aristas, zoom actual, energia del layout y recordatorio de atajos de teclado.

  ## Inicializacion lazy

  El renderer OpenGL y el spatial hash se crean en el primer frame. La camara se ajusta automaticamente con `graph_viewport_fit` en la inicializacion.

code:
  #include "viz/graph_viewport.h"
  #include "viz/graph_types.h"
  #include "viz/graph_renderer.h"
  #include "viz/graph_force_layout.h"
  #include "core/graph_spatial_hash.h"
  #include "imgui.h"

  #include <cstdio>   // snprintf
  #include <cstring>  // memset
  #include <vector>

  // ---------------------------------------------------------------------------
  // Internal helpers
  // ---------------------------------------------------------------------------

  static void viewport_to_graph(float vx, float vy,
                                 float widget_x, float widget_y,
                                 float widget_w, float widget_h,
                                 float cam_x, float cam_y, float zoom,
                                 float& gx, float& gy)
  {
      gx = (vx - widget_x - widget_w * 0.5f) / zoom + cam_x;
      gy = (vy - widget_y - widget_h * 0.5f) / zoom + cam_y;
  }

  // ---------------------------------------------------------------------------
  // graph_viewport_fit
  // ---------------------------------------------------------------------------

  void graph_viewport_fit(GraphData& graph, GraphViewportState& state)
  {
      graph.update_bounds();
      if (graph.node_count == 0) {
          state.cam_x = 0.0f;
          state.cam_y = 0.0f;
          state.zoom  = 1.0f;
          return;
      }

      float cx = (graph.min_x + graph.max_x) * 0.5f;
      float cy = (graph.min_y + graph.max_y) * 0.5f;
      state.cam_x = cx;
      state.cam_y = cy;

      float span_x = graph.max_x - graph.min_x;
      float span_y = graph.max_y - graph.min_y;
      float span   = (span_x > span_y ? span_x : span_y);

      // Use render dimensions if available; fall back to a safe default.
      float view_px = (state.render_w > 0 ? (float)state.render_w : 600.0f);
      float view_py = (state.render_h > 0 ? (float)state.render_h : 400.0f);
      float view_min = (view_px < view_py ? view_px : view_py);

      if (span > 0.0f) {
          state.zoom = (view_min * 0.9f) / span;
      } else {
          state.zoom = 1.0f;
      }

      // Clamp to allowed range
      if (state.zoom < state.zoom_min) state.zoom = state.zoom_min;
      if (state.zoom > state.zoom_max) state.zoom = state.zoom_max;
  }

  // ---------------------------------------------------------------------------
  // graph_viewport_destroy
  // ---------------------------------------------------------------------------

  void graph_viewport_destroy(GraphViewportState& state)
  {
      if (state.renderer) {
          graph_renderer_destroy(state.renderer);
          state.renderer = nullptr;
      }
      if (state.spatial) {
          delete state.spatial;
          state.spatial = nullptr;
      }
      state.initialized = false;
  }

  // ---------------------------------------------------------------------------
  // graph_viewport — main widget
  // ---------------------------------------------------------------------------

  bool graph_viewport(const char* id, GraphData& graph, GraphViewportState& state,
                      ImVec2 size)
  {
      bool interacted = false;

      // Resolve size
      ImVec2 avail = ImGui::GetContentRegionAvail();
      float w = (size.x > 0.0f) ? size.x : avail.x;
      float h = (size.y > 0.0f) ? size.y : avail.y;
      if (w < 1.0f) w = 1.0f;
      if (h < 1.0f) h = 1.0f;

      int iw = (int)w, ih = (int)h;

      // -------------------------------------------------------------------
      // 1. Lazy init
      // -------------------------------------------------------------------
      if (!state.initialized) {
          state.renderer  = graph_renderer_create(iw, ih);
          state.spatial   = new SpatialHash(20.0f, 4096);
          state.render_w  = iw;
          state.render_h  = ih;
          state.initialized = true;
          graph_viewport_fit(graph, state);
      }

      // -------------------------------------------------------------------
      // 2. Resize
      // -------------------------------------------------------------------
      if (iw != state.render_w || ih != state.render_h) {
          graph_renderer_resize(state.renderer, iw, ih);
          state.render_w = iw;
          state.render_h = ih;
      }

      // -------------------------------------------------------------------
      // 3. Force layout step
      // -------------------------------------------------------------------
      if (state.layout_running && graph.node_count > 0) {
          state.layout_energy = graph_force_layout_step(graph);
          if (state.layout_energy < 0.01f) {
              state.layout_running = false;
          }
      }

      // -------------------------------------------------------------------
      // 4. Build spatial hash
      // -------------------------------------------------------------------
      if (graph.node_count > 0) {
          static std::vector<float> xs_buf, ys_buf, sz_buf;
          xs_buf.resize(graph.node_count);
          ys_buf.resize(graph.node_count);
          sz_buf.resize(graph.node_count);
          for (int i = 0; i < graph.node_count; ++i) {
              xs_buf[i] = graph.nodes[i].x;
              ys_buf[i] = graph.nodes[i].y;
              sz_buf[i] = graph.nodes[i].size;
          }
          state.spatial->build(xs_buf.data(), ys_buf.data(), sz_buf.data(), graph.node_count);
      }

      // -------------------------------------------------------------------
      // 5. Invisible button to capture input
      // -------------------------------------------------------------------
      ImGui::PushID(id);

      ImVec2 widget_pos = ImGui::GetCursorScreenPos();
      ImGui::InvisibleButton("canvas", ImVec2(w, h),
                             ImGuiButtonFlags_MouseButtonLeft  |
                             ImGuiButtonFlags_MouseButtonMiddle|
                             ImGuiButtonFlags_MouseButtonRight);

      bool hovered  = ImGui::IsItemHovered();
      bool lm_down  = ImGui::IsMouseDown(ImGuiMouseButton_Left);
      bool lm_click = ImGui::IsMouseClicked(ImGuiMouseButton_Left);
      bool mm_down  = ImGui::IsMouseDown(ImGuiMouseButton_Middle);
      bool rm_down  = ImGui::IsMouseDown(ImGuiMouseButton_Right);

      ImVec2 mouse_pos = ImGui::GetMousePos();
      float  mx = mouse_pos.x, my = mouse_pos.y;

      // Convert mouse to graph space
      float gx_mouse, gy_mouse;
      viewport_to_graph(mx, my,
                        widget_pos.x, widget_pos.y, w, h,
                        state.cam_x, state.cam_y, state.zoom,
                        gx_mouse, gy_mouse);

      // -------------------------------------------------------------------
      // 5a. Pan (middle or right mouse drag)
      // -------------------------------------------------------------------
      if (hovered && (mm_down || rm_down)) {
          ImVec2 delta = ImGui::GetIO().MouseDelta;
          if (delta.x != 0.0f || delta.y != 0.0f) {
              state.cam_x -= delta.x / state.zoom;
              state.cam_y -= delta.y / state.zoom;
              interacted = true;
          }
      }

      // -------------------------------------------------------------------
      // 5b. Zoom (scroll wheel)
      // -------------------------------------------------------------------
      if (hovered) {
          float wheel = ImGui::GetIO().MouseWheel;
          if (wheel != 0.0f) {
              float old_zoom = state.zoom;
              float new_zoom = old_zoom * (1.0f + wheel * 0.1f);
              if (new_zoom < state.zoom_min) new_zoom = state.zoom_min;
              if (new_zoom > state.zoom_max) new_zoom = state.zoom_max;

              // Zoom toward cursor: keep gx_mouse/gy_mouse fixed in graph space
              float rel_x = (mx - widget_pos.x - w * 0.5f);
              float rel_y = (my - widget_pos.y - h * 0.5f);
              state.cam_x += rel_x / old_zoom - rel_x / new_zoom;
              state.cam_y += rel_y / old_zoom - rel_y / new_zoom;
              state.zoom   = new_zoom;
              interacted   = true;
          }
      }

      // -------------------------------------------------------------------
      // 5c. Hover — query nearest node
      // -------------------------------------------------------------------
      state.hovered_node = -1;
      if (hovered && graph.node_count > 0) {
          float hit_radius = 10.0f / state.zoom;
          int nearest = state.spatial->query_nearest(gx_mouse, gy_mouse, hit_radius);
          if (nearest >= 0) {
              state.hovered_node = nearest;
              interacted = true;
          }
      }

      // -------------------------------------------------------------------
      // 5d. Node drag (left mouse down on a node)
      // -------------------------------------------------------------------
      if (hovered && lm_down) {
          if (state.drag_node == -1 && state.hovered_node >= 0) {
              state.drag_node   = state.hovered_node;
              state.is_dragging = true;
          }
      } else {
          // Release drag
          if (state.drag_node >= 0 && state.drag_node < graph.node_count) {
              graph.nodes[state.drag_node].pinned = false;
          }
          state.drag_node   = -1;
          state.is_dragging = false;
      }

      if (state.drag_node >= 0 && state.drag_node < graph.node_count) {
          GraphNode& n = graph.nodes[state.drag_node];
          n.x      = gx_mouse;
          n.y      = gy_mouse;
          n.vx     = 0.0f;
          n.vy     = 0.0f;
          n.pinned = true;
          interacted = true;
      }

      // -------------------------------------------------------------------
      // 5e. Click — select node
      // -------------------------------------------------------------------
      if (hovered && lm_click && state.drag_node == -1) {
          state.selected_node = state.hovered_node;
          interacted = true;
      }

      // -------------------------------------------------------------------
      // 5f. Keyboard shortcuts (only when widget is active/hovered)
      // -------------------------------------------------------------------
      if (hovered) {
          if (ImGui::IsKeyPressed(ImGuiKey_Space)) {
              state.layout_running = !state.layout_running;
          }
          if (ImGui::IsKeyPressed(ImGuiKey_F)) {
              graph_viewport_fit(graph, state);
              interacted = true;
          }
      }

      // -------------------------------------------------------------------
      // 6. Render to GPU texture
      // -------------------------------------------------------------------
      unsigned int tex_id = graph_renderer_draw(state.renderer, graph,
                                                state.cam_x, state.cam_y,
                                                state.zoom);

      // -------------------------------------------------------------------
      // 7. Display texture (flip UV for OpenGL FBO convention)
      // -------------------------------------------------------------------
      ImDrawList* draw_list = ImGui::GetWindowDrawList();
      draw_list->AddImage(
          (ImTextureID)(intptr_t)tex_id,
          widget_pos,
          ImVec2(widget_pos.x + w, widget_pos.y + h),
          ImVec2(0.0f, 1.0f),   // UV top-left   (flipped Y)
          ImVec2(1.0f, 0.0f)    // UV bottom-right
      );

      // -------------------------------------------------------------------
      // 8. Tooltip on hovered node
      // -------------------------------------------------------------------
      if (state.hovered_node >= 0 && state.hovered_node < graph.node_count) {
          const GraphNode& n = graph.nodes[state.hovered_node];

          // Count degree
          int degree = 0;
          for (int i = 0; i < graph.edge_count; ++i) {
              if ((int)graph.edges[i].source == state.hovered_node ||
                  (int)graph.edges[i].target == state.hovered_node) {
                  ++degree;
              }
          }

          ImGui::BeginTooltip();
          if (n.label) ImGui::TextUnformatted(n.label);
          ImGui::Text("ID: %u", n.id);
          ImGui::Text("Community: %u", n.community);
          ImGui::Text("Degree: %d", degree);
          ImGui::Text("Value: %.3f", n.value);
          ImGui::EndTooltip();
      }

      // -------------------------------------------------------------------
      // 9. Status bar overlay
      // -------------------------------------------------------------------
      {
          char status[128];
          snprintf(status, sizeof(status),
                   "Nodes: %d | Edges: %d | Zoom: %.2fx | Energy: %.4f | [Space] layout [F] fit",
                   graph.node_count, graph.edge_count,
                   state.zoom, state.layout_energy);

          ImVec2 text_pos = ImVec2(widget_pos.x + 6.0f, widget_pos.y + h - 18.0f);
          draw_list->AddText(text_pos, IM_COL32(180, 180, 180, 200), status);
      }

      ImGui::PopID();
      return interacted;
  }

params_schema:
  {"params":[{"name":"id","desc":"Identificador unico del widget ImGui. Debe ser estable entre frames."},{"name":"graph","desc":"Grafo a visualizar. Las posiciones de nodos se modifican al arrastrar."},{"name":"state","desc":"Estado persistente: camara (cam_x, cam_y, zoom), nodo seleccionado/hovereado, renderer GPU, spatial hash. Alojado por el caller."},{"name":"size","desc":"Tamanio del widget en pixeles. (0,0) ocupa todo el espacio disponible en la ventana ImGui."}],"output":"true si hubo alguna interaccion del usuario en el frame actual (hover, click, drag, zoom, teclado)"}


id: heatmap_cpp_viz
name: heatmap
kind: component
lang: cpp
domain: viz
version: 1.0.0
purity: pure
description: Renderiza un mapa de calor 2D usando ImPlot dentro de un frame ImGui
tags: ["implot","chart","visualization","gpu","heatmap","matrix"]
uses_functions: []
uses_types: []
returns: []
imports: ["implot"]
tests: []
file_path: cpp/functions/viz/heatmap.cpp
created_at: 2026-04-06T21:31:43Z
updated_at: 2026-04-06T21:31:43Z
props: []
emits: []
framework: imgui
variant: []
content_hash: c89f29bd604bbed08bd575ac034f814bd8aab8e96108670f1f1a735512079945
signature:
  void heatmap(const char* title, const float* values, int rows, int cols, float scale_min, float scale_max)

documentation:
  # heatmap

  Wrapper atomico sobre `ImPlot::PlotHeatmap`. Renderiza una matriz de valores como mapa de calor con escala de color.

  Los datos deben estar en formato row-major. Si `scale_min` y `scale_max` son ambos 0, ImPlot autodetecta el rango.

code:
  #include "viz/heatmap.h"
  #include "implot.h"

  void heatmap(const char* title, const float* values, int rows, int cols,
               float scale_min, float scale_max) {
      if (ImPlot::BeginPlot(title, ImVec2(-1, 0), ImPlotFlags_NoLegend)) {
          ImPlot::SetupAxes(nullptr, nullptr,
              ImPlotAxisFlags_NoDecorations, ImPlotAxisFlags_NoDecorations);
          ImPlot::PlotHeatmap("##data", values, rows, cols,
                              scale_min, scale_max);
          ImPlot::EndPlot();
      }
  }

  void heatmap(const char* title, const double* values, int rows, int cols,
               double scale_min, double scale_max) {
      if (ImPlot::BeginPlot(title, ImVec2(-1, 0), ImPlotFlags_NoLegend)) {
          ImPlot::SetupAxes(nullptr, nullptr,
              ImPlotAxisFlags_NoDecorations, ImPlotAxisFlags_NoDecorations);
          ImPlot::PlotHeatmap("##data", values, rows, cols,
                              scale_min, scale_max);
          ImPlot::EndPlot();
      }
  }

params_schema:
  {"params":[{"name":"title","desc":"Titulo del heatmap"},{"name":"values","desc":"Array de valores en orden row-major (values[row * cols + col])"},{"name":"rows","desc":"Numero de filas de la matriz"},{"name":"cols","desc":"Numero de columnas de la matriz"},{"name":"scale_min","desc":"Valor minimo de la escala de color (0 para autodetectar)"},{"name":"scale_max","desc":"Valor maximo de la escala de color (0 para autodetectar)"}],"output":"Renderiza el heatmap en el frame ImGui actual"}


id: histogram_cpp_viz
name: histogram
kind: component
lang: cpp
domain: viz
version: 1.0.0
purity: pure
signature: void histogram(const char* title, const float* values, int count, int bins = -1)
tags: ["implot","chart","visualization","gpu","histogram","distribution"]
uses_functions: []
uses_types: []
returns: []
imports: ["implot"]
tests: []
file_path: cpp/functions/viz/histogram.cpp
created_at: 2026-04-07T21:42:49Z
updated_at: 2026-04-07T21:42:49Z
props: []
emits: []
framework: imgui
variant: []
content_hash: 48df96a635bfb690826e054e985cd330ea33d6498a24ab8d3bd45b5a67024982
description:
  Renderiza un histograma con bins automaticos o manuales usando ImPlot PlotHistogram dentro de un frame ImGui

documentation:
  # histogram

  Wrapper atomico sobre `ImPlot::PlotHistogram` con seleccion automatica del numero de bins.

  Cuando `bins == -1` usa `ImPlotBin_Sturges`, que calcula el numero de bins como `ceil(log2(n)) + 1`. Para distribuciones con muchos valores o alta varianza puede preferirse pasar un valor explicito.

  El plot usa `ImVec2(-1, 0)` para ocupar el ancho disponible con altura automatica.

  Debe llamarse dentro del render callback de `fn::run_app`.

code:
  #include "viz/histogram.h"
  #include "implot.h"

  void histogram(const char* title, const float* values, int count, int bins) {
      if (ImPlot::BeginPlot(title, ImVec2(-1, 0))) {
          int b = (bins > 0) ? bins : ImPlotBin_Sturges;
          ImPlot::PlotHistogram("##data", values, count, b);
          ImPlot::EndPlot();
      }
  }

  void histogram(const char* title, const double* values, int count, int bins) {
      if (ImPlot::BeginPlot(title, ImVec2(-1, 0))) {
          int b = (bins > 0) ? bins : ImPlotBin_Sturges;
          ImPlot::PlotHistogram("##data", values, count, b);
          ImPlot::EndPlot();
      }
  }

params_schema:
  {"params":[{"name":"title","desc":"Titulo del histograma mostrado como cabecera del plot"},{"name":"values","desc":"Array de valores numericos a distribuir en bins"},{"name":"count","desc":"Numero de valores en el array"},{"name":"bins","desc":"Numero de bins. -1 = automatico via regla de Sturges (ImPlotBin_Sturges). Positivo = numero explicito de bins"}],"output":"Renderiza el histograma en el frame ImGui actual"}


id: kpi_card_cpp_viz
name: kpi_card
kind: component
lang: cpp
domain: viz
version: 1.0.0
purity: pure
description: Card de KPI con valor grande, delta porcentual, y sparkline historico para dashboards
tags: ["imgui","kpi","card","dashboard","metrics","sparkline"]
uses_functions: ["sparkline_cpp_viz"]
uses_types: []
returns: []
imports: ["imgui"]
tests: []
file_path: cpp/functions/viz/kpi_card.cpp
created_at: 2026-04-07T21:42:49Z
updated_at: 2026-04-07T21:42:49Z
props: []
emits: []
framework: imgui
variant: []
content_hash: e226b3d68227148a0fbac12c2e36243fda7743a99a57a0b0d66da645e6523996
signature:
  void kpi_card(const char* label, float value, float delta_percent, const float* history = nullptr, int history_count = 0, const char* format = "%.1f")

example:
  ```cpp
  float history[] = {10.0f, 12.0f, 11.0f, 15.0f, 18.0f, 17.0f, 20.0f};
  kpi_card("Revenue", 20000.0f, 12.5f, history, 7, "$%.0f");

  // Sin sparkline
  kpi_card("Error Rate", 0.3f, -15.2f, nullptr, 0, "%.2f%%");

  // Grid de KPIs
  ImGui::Columns(3, "kpis", false);
  kpi_card("MAU",     1250000.0f,  3.4f, mau_history, 30);
  ImGui::NextColumn();
  kpi_card("Revenue", 89400.0f,   -1.2f, rev_history, 30, "$%.0f");
  ImGui::NextColumn();
  kpi_card("Churn",   2.1f,       -0.3f, churn_history, 30, "%.1f%%");
  ImGui::Columns(1);
  ```

notes:
  - El ancho total del grupo es aproximadamente 150px, apto para grids de 2-4 columnas.
  - El escalado de fuente usa `SetWindowFontScale(1.8f)` — compatible con cualquier fuente cargada; no requiere fonts adicionales.
  - Los caracteres UTF-8 del triangulo (`▲` U+25B2 y `▼` U+25BC) requieren que la fuente ImGui tenga el rango de simbolos geometricos cargado, o bien sustituir por ASCII (`^` y `v`).
  - El color verde del delta es `ImVec4(0.20, 0.80, 0.35, 1.0)` y el rojo `ImVec4(0.90, 0.25, 0.25, 1.0)`, coherentes con los colores del sparkline subyacente.
  - `BeginGroup`/`EndGroup` permite usar `SameLine()` despues de `kpi_card` y que el cursor avance correctamente.

documentation:
  # kpi_card

  Card compacta para dashboards ImGui que muestra un KPI con contexto de tendencia. Combina label, valor escalado, badge de delta colorizado y sparkline historico en un grupo coherente de ~150px de ancho.

  Usa `sparkline` del registry para el historico, con el mismo color que el badge (verde si delta >= 0, rojo si delta < 0).

  Debe llamarse dentro del render callback de `fn::run_app` (o cualquier contexto con un frame ImGui activo).

code:
  #include "kpi_card.h"
  #include "sparkline.h"
  #include <imgui.h>
  #include <cstdio>

  void kpi_card(const char* label, float value, float delta_percent,
                const float* history, int history_count,
                const char* format) {
      ImGui::BeginGroup();

      // Label — small, muted
      ImGui::TextDisabled("%s", label);

      // Value — scaled up font
      ImGui::SetWindowFontScale(1.8f);
      char value_buf[64];
      snprintf(value_buf, sizeof(value_buf), format, value);
      ImGui::Text("%s", value_buf);
      ImGui::SetWindowFontScale(1.0f);

      // Delta badge — green up arrow / red down arrow
      const bool positive = delta_percent >= 0.0f;
      const ImVec4 delta_color = positive
          ? ImVec4(0.20f, 0.80f, 0.35f, 1.0f)   // green
          : ImVec4(0.90f, 0.25f, 0.25f, 1.0f);  // red

      char delta_buf[32];
      if (positive) {
          snprintf(delta_buf, sizeof(delta_buf), "\xe2\x96\xb2 +%.1f%%", delta_percent);
      } else {
          snprintf(delta_buf, sizeof(delta_buf), "\xe2\x96\xbc %.1f%%", delta_percent);
      }

      ImGui::PushStyleColor(ImGuiCol_Text, delta_color);
      ImGui::Text("%s", delta_buf);
      ImGui::PopStyleColor();

      // Sparkline — matches delta color
      if (history != nullptr && history_count > 0) {
          sparkline(label, history, history_count, delta_color, 120.0f, 24.0f);
      }

      ImGui::EndGroup();
  }

params_schema:
  {"params":[{"name":"label","desc":"Nombre del KPI mostrado como header muted (ej: \"Revenue\", \"Users\")"},{"name":"value","desc":"Valor numerico actual del KPI"},{"name":"delta_percent","desc":"Cambio porcentual respecto al periodo anterior (positivo = mejora, negativo = deterioro)"},{"name":"history","desc":"Array de valores historicos para el sparkline. Nullable — si es nullptr no se renderiza sparkline"},{"name":"history_count","desc":"Numero de valores en el array history"},{"name":"format","desc":"Formato printf para el valor principal (ej: \"$%.0f\", \"%.1f%%\", \"%.2f\")"}],"output":"Renderiza la card KPI completa en el frame ImGui actual: label muted, valor grande, badge delta verde/rojo con triangulo, y sparkline de 120x24px"}


id: line_plot_cpp_viz
name: line_plot
kind: component
lang: cpp
domain: viz
version: 1.0.0
purity: pure
signature: void line_plot(const char* title, const float* xs, const float* ys, int count)
description: Renderiza un grafico de lineas 2D usando ImPlot dentro de un frame ImGui
tags: ["implot","chart","visualization","gpu","line"]
uses_functions: []
uses_types: []
returns: []
imports: ["implot"]
tests: []
file_path: cpp/functions/viz/line_plot.cpp
created_at: 2026-04-06T21:31:43Z
updated_at: 2026-04-06T21:31:43Z
props: []
emits: []
framework: imgui
variant: []
content_hash: 0d1dcfcea545da3a000a003dbd7cfdde13494e8ecad1cc8dd16f02cc938133cb
documentation:
  # line_plot

  Wrapper atomico sobre `ImPlot::PlotLine`. Renderiza un grafico de lineas 2D con los datos proporcionados.

  Debe llamarse dentro del render callback de `fn::run_app` (o cualquier contexto con un frame ImGui activo).

  Soporta `float` y `double` precision.

code:
  #include "viz/line_plot.h"
  #include "implot.h"

  void line_plot(const char* title, const float* xs, const float* ys, int count) {
      if (ImPlot::BeginPlot(title, ImVec2(-1, 0))) {
          ImPlot::PlotLine("##data", xs, ys, count);
          ImPlot::EndPlot();
      }
  }

  void line_plot(const char* title, const double* xs, const double* ys, int count) {
      if (ImPlot::BeginPlot(title, ImVec2(-1, 0))) {
          ImPlot::PlotLine("##data", xs, ys, count);
          ImPlot::EndPlot();
      }
  }

params_schema:
  {"params":[{"name":"title","desc":"Titulo del grafico, se muestra como header del plot"},{"name":"xs","desc":"Array de coordenadas X"},{"name":"ys","desc":"Array de coordenadas Y"},{"name":"count","desc":"Numero de puntos en los arrays xs/ys"}],"output":"Renderiza el grafico de lineas en el frame ImGui actual"}


id: pie_chart_cpp_viz
name: pie_chart
kind: component
lang: cpp
domain: viz
version: 1.0.0
purity: pure
description: Renderiza un grafico circular (pie/donut) usando ImPlot PlotPieChart dentro de un frame ImGui
tags: ["implot","chart","visualization","gpu","pie","donut"]
uses_functions: []
uses_types: []
returns: []
imports: ["implot"]
tests: []
file_path: cpp/functions/viz/pie_chart.cpp
created_at: 2026-04-07T21:42:49Z
updated_at: 2026-04-07T22:21:10Z
props: []
emits: []
framework: imgui
variant: []
content_hash: 144b3ead5db0e04a676cd844619816224cffb78053a175e2d2fa37e19f2f55ce
signature:
  void pie_chart(const char* title, const char* const* labels, const float* values, int count, float radius = 0.0f)

documentation:
  # pie_chart

  Wrapper atomico sobre `ImPlot::PlotPieChart` con soporte para modo pie y modo donut.

  El eje del plot se configura con `ImPlotAxisFlags_NoDecorations` para ocultar los ejes y mostrar solo el grafico circular. El aspecto se fuerza a cuadrado con `ImPlotFlags_Equal`.

  **Modo pie** (`radius >= 0`): dibuja un pie chart solido. Si `radius == 0`, usa radio automatico de 0.4.

  **Modo donut** (`radius < 0`): usa `|radius|` como radio exterior. El agujero interior es fijo en 0.2, suficiente para texto central.

  Debe llamarse dentro del render callback de `fn::run_app`.

code:
  #include "viz/pie_chart.h"
  #include "implot.h"

  void pie_chart(const char* title, const char* const* labels, const float* values, int count, float radius) {
      if (ImPlot::BeginPlot(title, ImVec2(-1, 0), ImPlotFlags_Equal | ImPlotFlags_NoLegend)) {
          ImPlot::SetupAxes(nullptr, nullptr, ImPlotAxisFlags_NoDecorations, ImPlotAxisFlags_NoDecorations);
          ImPlot::SetupAxesLimits(0, 1, 0, 1);
          if (radius < 0.0f) {
              ImPlot::PlotPieChart(labels, values, count, 0.5, 0.5, static_cast<double>(-radius), "%.1f", 90.0);
          } else {
              float r = (radius > 0.0f) ? radius : 0.4f;
              ImPlot::PlotPieChart(labels, values, count, 0.5, 0.5, static_cast<double>(r), "%.1f", 90.0);
          }
          ImPlot::EndPlot();
      }
  }

  void pie_chart(const char* title, const char* const* labels, const double* values, int count, double radius) {
      if (ImPlot::BeginPlot(title, ImVec2(-1, 0), ImPlotFlags_Equal | ImPlotFlags_NoLegend)) {
          ImPlot::SetupAxes(nullptr, nullptr, ImPlotAxisFlags_NoDecorations, ImPlotAxisFlags_NoDecorations);
          ImPlot::SetupAxesLimits(0, 1, 0, 1);
          if (radius < 0.0) {
              ImPlot::PlotPieChart(labels, values, count, 0.5, 0.5, -radius, "%.1f", 90.0);
          } else {
              double r = (radius > 0.0) ? radius : 0.4;
              ImPlot::PlotPieChart(labels, values, count, 0.5, 0.5, r, "%.1f", 90.0);
          }
          ImPlot::EndPlot();
      }
  }

params_schema:
  {"params":[{"name":"title","desc":"Titulo del grafico"},{"name":"labels","desc":"Array de etiquetas para cada segmento del pie"},{"name":"values","desc":"Array de valores numericos para cada segmento"},{"name":"count","desc":"Numero de segmentos (longitud de labels y values)"},{"name":"radius","desc":"Radio del pie (0 = auto 0.4). Positivo = radio explicito. Negativo = modo donut con outer radius = |radius| e inner = 0.2"}],"output":"Renderiza el grafico circular en el frame ImGui actual"}


id: scatter_plot_cpp_viz
name: scatter_plot
kind: component
lang: cpp
domain: viz
version: 1.0.0
purity: pure
signature: void scatter_plot(const char* title, const float* xs, const float* ys, int count)
description: Renderiza un grafico de dispersion usando ImPlot dentro de un frame ImGui
tags: ["implot","chart","visualization","gpu","scatter"]
uses_functions: []
uses_types: []
returns: []
imports: ["implot"]
tests: []
file_path: cpp/functions/viz/scatter_plot.cpp
created_at: 2026-04-06T21:31:43Z
updated_at: 2026-04-06T21:31:43Z
props: []
emits: []
framework: imgui
variant: []
content_hash: 0c6a73764dfdadd8538941a01048d05994e85d8484f488fff8c6d42c477a0842
documentation:
  # scatter_plot

  Wrapper atomico sobre `ImPlot::PlotScatter`. Renderiza un grafico de dispersion 2D.

  Debe llamarse dentro del render callback de `fn::run_app`.

code:
  #include "viz/scatter_plot.h"
  #include "implot.h"

  void scatter_plot(const char* title, const float* xs, const float* ys, int count) {
      if (ImPlot::BeginPlot(title, ImVec2(-1, 0))) {
          ImPlot::PlotScatter("##data", xs, ys, count);
          ImPlot::EndPlot();
      }
  }

  void scatter_plot(const char* title, const double* xs, const double* ys, int count) {
      if (ImPlot::BeginPlot(title, ImVec2(-1, 0))) {
          ImPlot::PlotScatter("##data", xs, ys, count);
          ImPlot::EndPlot();
      }
  }

params_schema:
  {"params":[{"name":"title","desc":"Titulo del grafico scatter"},{"name":"xs","desc":"Array de coordenadas X"},{"name":"ys","desc":"Array de coordenadas Y"},{"name":"count","desc":"Numero de puntos en los arrays xs/ys"}],"output":"Renderiza el grafico de dispersion en el frame ImGui actual"}


id: sparkline_cpp_viz
name: sparkline
kind: component
lang: cpp
domain: viz
version: 1.0.0
purity: pure
description: Renderiza un mini grafico de lineas inline para uso en tablas, headers y KPI cards
tags: ["imgui","visualization","sparkline","inline","dashboard"]
uses_functions: []
uses_types: []
returns: []
imports: ["imgui"]
tests: []
file_path: cpp/functions/viz/sparkline.cpp
created_at: 2026-04-07T21:42:49Z
updated_at: 2026-04-07T21:42:49Z
props: []
emits: []
framework: imgui
variant: []
content_hash: 4085beb86efd1d073735a7978bfb5418db362861f30ef8b3e59b5428f7cb11de
signature:
  void sparkline(const char* id, const float* values, int count, float width = 100.0f, float height = 20.0f)

example:
  ```cpp
  // En una celda de tabla
  ImGui::TableNextColumn();
  sparkline("##revenue_spark", revenue.data(), (int)revenue.size(), 80.0f, 18.0f);

  // Con color personalizado (rojo para valores negativos)
  sparkline("##pnl", pnl.data(), (int)pnl.size(),
            ImVec4(0.9f, 0.3f, 0.3f, 1.0f), 100.0f, 20.0f);

  // KPI card inline con label
  ImGui::Text("Revenue"); ImGui::SameLine();
  sparkline("kpi_rev", data, count);
  ```

notes:
  - El relleno bajo la curva usa alpha 40/255 del mismo color de la linea.
  - Si todos los valores son iguales (rango < 1e-6), la linea se dibuja en el centro verticalmente.
  - El grosor de linea es 1.5px para que sea legible a alturas de 16-24px.
  - `id` no se muestra visualmente; solo se pasa a `PushID` para que ImGui diferencie widgets con los mismos datos en la misma tabla.

documentation:
  # sparkline

  Mini grafico de lineas inline construido sobre ImGui draw primitives. No requiere ImPlot.

  Auto-escala el eje Y al rango minimo/maximo de los valores. Dibuja una polyline con relleno semitransparente bajo la curva. Disenado para encajar en celdas de tablas, headers y tarjetas KPI.

  Ofrece dos overloads:

  - Sin color: usa verde suave por defecto (`ImVec4(0.35, 0.85, 0.45, 1.0)`)

  - Con color: acepta cualquier `ImVec4` para personalizar la linea y el relleno

  Debe llamarse dentro del render callback de `fn::run_app` (o cualquier contexto con un frame ImGui activo).

code:
  #include "viz/sparkline.h"
  #include "imgui.h"

  void sparkline(const char* id, const float* values, int count, ImVec4 color,
                 float width, float height) {
      if (count <= 0) return;

      ImGui::PushID(id);

      ImVec2 pos = ImGui::GetCursorScreenPos();
      ImDrawList* draw_list = ImGui::GetWindowDrawList();

      // Reserve inline space
      ImGui::Dummy(ImVec2(width, height));

      // Find min/max for Y auto-scale
      float min_val = values[0];
      float max_val = values[0];
      for (int i = 1; i < count; i++) {
          if (values[i] < min_val) min_val = values[i];
          if (values[i] > max_val) max_val = values[i];
      }
      float range = max_val - min_val;
      if (range < 1e-6f) range = 1.0f; // avoid division by zero for flat lines

      // Fill area under curve (low alpha)
      if (count >= 2) {
          ImU32 fill_color = IM_COL32(
              (int)(color.x * 255),
              (int)(color.y * 255),
              (int)(color.z * 255),
              40);

          // Build fill polygon: baseline bottom-left -> points -> baseline bottom-right
          // We use AddConvexPolyFilled workaround: draw as a series of triangles from baseline
          float x0 = pos.x;
          float y_base = pos.y + height;

          for (int i = 0; i + 1 < count; i++) {
              float xa = x0 + ((float)i       / (count - 1)) * width;
              float xb = x0 + ((float)(i + 1) / (count - 1)) * width;
              float ya = pos.y + height - ((values[i]     - min_val) / range) * height;
              float yb = pos.y + height - ((values[i + 1] - min_val) / range) * height;

              draw_list->AddQuadFilled(
                  ImVec2(xa, y_base),
                  ImVec2(xa, ya),
                  ImVec2(xb, yb),
                  ImVec2(xb, y_base),
                  fill_color);
          }
      }

      // Draw polyline
      ImU32 line_color = IM_COL32(
          (int)(color.x * 255),
          (int)(color.y * 255),
          (int)(color.z * 255),
          (int)(color.w * 255));

      for (int i = 0; i + 1 < count; i++) {
          float xa = pos.x + ((float)i       / (count - 1)) * width;
          float xb = pos.x + ((float)(i + 1) / (count - 1)) * width;
          float ya = pos.y + height - ((values[i]     - min_val) / range) * height;
          float yb = pos.y + height - ((values[i + 1] - min_val) / range) * height;
          draw_list->AddLine(ImVec2(xa, ya), ImVec2(xb, yb), line_color, 1.5f);
      }

      ImGui::PopID();
  }

  void sparkline(const char* id, const float* values, int count,
                 float width, float height) {
      // Default color: soft green
      sparkline(id, values, count, ImVec4(0.35f, 0.85f, 0.45f, 1.0f), width, height);
  }

params_schema:
  {"params":[{"name":"id","desc":"Identificador unico del widget, usado con PushID/PopID para garantizar unicidad en tablas"},{"name":"values","desc":"Array de valores float del sparkline (serie temporal)"},{"name":"count","desc":"Numero de valores en el array"},{"name":"width","desc":"Ancho en pixels del sparkline (default 100.0)"},{"name":"height","desc":"Alto en pixels del sparkline (default 20.0)"}],"output":"Renderiza el sparkline inline en el frame ImGui actual, reservando espacio con ImGui::Dummy"}


id: surface_plot_3d_cpp_viz
name: surface_plot_3d
kind: component
lang: cpp
domain: viz
version: 1.0.0
purity: pure
description: [STUB] Renderiza una superficie 3D — requiere ImPlot3D (no vendoreado aun)
tags: ["implot3d","chart","visualization","gpu","surface","3d","stub"]
uses_functions: []
uses_types: []
returns: []
imports: ["imgui"]
tests: []
file_path: cpp/functions/viz/surface_plot_3d.cpp
created_at: 2026-04-07T21:42:49Z
updated_at: 2026-04-07T21:42:49Z
props: []
emits: []
framework: imgui
variant: []
content_hash: a742e3c71e19b9f57577cb18f6b5900a75300417c881c072da1c1804c8ffd0e6
signature:
  void surface_plot_3d(const char* title, const float* values, int rows, int cols, float z_min, float z_max)

documentation:
  # surface_plot_3d

  **STUB** — la implementacion real requiere [ImPlot3D](https://github.com/brenocq/implot3d), que todavia no esta vendoreado en `cpp/vendor/implot3d/`.

  Mientras tanto la funcion renderiza un grupo ImGui con un mensaje informativo que muestra el titulo, las dimensiones de la grilla y el rango Z. La firma es definitiva y no cambiara cuando se integre ImPlot3D.

  ## Dependencia pendiente

  Para activar la implementacion real:

  1. Clonar o copiar ImPlot3D en `cpp/vendor/implot3d/`
  2. Anadir `implot3d.cpp` al build system (CMake / Makefile)
  3. Reemplazar el cuerpo de `surface_plot_3d` por la llamada a `ImPlot3D::BeginPlot3D` / `ImPlot3D::PlotSurface` / `ImPlot3D::EndPlot3D`
  4. Actualizar `imports` del .md a `[imgui, implot3d]` y quitar el tag `stub`

  ## Uso

  ```cpp
  // values es un array row-major de rows*cols floats
  float grid[4 * 4] = { ... };
  surface_plot_3d("Mi Superficie", grid, 4, 4, -1.0f, 1.0f);
  ```

  Debe llamarse dentro del render callback de `fn::run_app` (o cualquier contexto con un frame ImGui activo).

code:
  #include "viz/surface_plot_3d.h"
  #include "imgui.h"

  void surface_plot_3d(const char* title, const float* values, int rows, int cols,
                       float z_min, float z_max) {
      ImGui::BeginGroup();
      ImGui::TextDisabled("[STUB] %s", title);
      ImGui::TextWrapped("surface_plot_3d requires ImPlot3D. "
                         "Add it to cpp/vendor/implot3d/ and rebuild.");
      ImGui::Text("Data: %dx%d, range [%.2f, %.2f]", rows, cols, z_min, z_max);
      ImGui::EndGroup();
  }

params_schema:
  {"params":[{"name":"title","desc":"Titulo de la superficie, se muestra como header del plot"},{"name":"values","desc":"Array row-major de alturas Z con dimension rows*cols"},{"name":"rows","desc":"Numero de filas de la grilla de la superficie"},{"name":"cols","desc":"Numero de columnas de la grilla de la superficie"},{"name":"z_min","desc":"Valor minimo del eje Z para escalar el colormap"},{"name":"z_max","desc":"Valor maximo del eje Z para escalar el colormap"}],"output":"Renderiza un placeholder informativo en el frame ImGui actual; cuando ImPlot3D este disponible, renderizara la superficie 3D"}


id: table_view_cpp_viz
name: table_view
kind: component
lang: cpp
domain: viz
version: 1.0.0
purity: pure
description: Renderiza una tabla interactiva con sorting y scroll usando ImGui Tables API
tags: ["imgui","table","visualization","dashboard","data"]
uses_functions: []
uses_types: []
returns: []
imports: ["imgui"]
tests: []
file_path: cpp/functions/viz/table_view.cpp
created_at: 2026-04-07T21:42:49Z
updated_at: 2026-04-07T21:42:49Z
props: []
emits: []
framework: imgui
variant: []
content_hash: 50cf7b87994224e944fd72890cfe9978e903768b5d4ebfe56243bf892bb1242f
signature:
  bool table_view(const char* id, const char* const* headers, int col_count, const char* const* cells, int row_count)

example:
  ```cpp
  const char* headers[] = {"Nombre", "Valor", "Estado"};
  const char* cells[] = {
      "Alpha", "1.23", "OK",
      "Beta",  "4.56", "WARN",
      "Gamma", "7.89", "ERROR",
  };
  table_view("##mi_tabla", headers, 3, cells, 3);
  ```

notes:
  - `id` debe comenzar con `##` si no se quiere mostrar como titulo de ventana en el contexto ImGui.
  - El outer size fijo de `ImVec2(0, 300)` puede parametrizarse en una version futura.
  - El sorting real de datos queda fuera del scope de esta funcion para mantenerla pura y componible.

documentation:
  # table_view

  Wrapper atomico sobre `ImGui::BeginTable` / `ImGui::EndTable`. Renderiza una tabla con las siguientes capacidades:

  - **Borders**: bordes entre celdas y columnas

  - **Sortable**: muestra indicadores de orden en los headers (el caller es responsable de ordenar `cells` antes de llamar)

  - **RowBg**: filas alternadas con color de fondo

  - **Resizable**: el usuario puede arrastrar los separadores de columna

  - **ScrollY**: scroll vertical con altura fija de 300px

  - **Reorderable**: el usuario puede reordenar columnas arrastrando los headers

  El caller controla el orden de los datos — `table_view` solo habilita el flag `Sortable` para que ImGui muestre los indicadores visuales, pero no reordena `cells` internamente.

  Debe llamarse dentro del render callback de `fn::run_app` (o cualquier contexto con un frame ImGui activo).

code:
  #include "viz/table_view.h"
  #include "imgui.h"

  bool table_view(const char* id, const char* const* headers, int col_count, const char* const* cells, int row_count) {
      ImGuiTableFlags flags =
          ImGuiTableFlags_Borders      |
          ImGuiTableFlags_Sortable     |
          ImGuiTableFlags_RowBg        |
          ImGuiTableFlags_Resizable    |
          ImGuiTableFlags_ScrollY      |
          ImGuiTableFlags_Reorderable;

      if (!ImGui::BeginTable(id, col_count, flags, ImVec2(0, 300))) {
          return false;
      }

      for (int col = 0; col < col_count; col++) {
          ImGui::TableSetupColumn(headers[col]);
      }
      ImGui::TableHeadersRow();

      for (int row = 0; row < row_count; row++) {
          ImGui::TableNextRow();
          for (int col = 0; col < col_count; col++) {
              ImGui::TableSetColumnIndex(col);
              ImGui::TextUnformatted(cells[row * col_count + col]);
          }
      }

      ImGui::EndTable();
      return true;
  }

params_schema:
  {"params":[{"name":"id","desc":"Identificador unico de la tabla para ImGui (debe ser unico en el frame)"},{"name":"headers","desc":"Array de strings con los nombres de las columnas"},{"name":"col_count","desc":"Numero de columnas"},{"name":"cells","desc":"Array flat row-major de strings; acceso a celda (row, col) via cells[row * col_count + col]"},{"name":"row_count","desc":"Numero de filas de datos, sin contar el header"}],"output":"true si la tabla se renderizo visible, false si fue clipped o skipped por ImGui"}


id: api_client_ts_core
name: api_client
kind: function
lang: ts
domain: core
version: 1.0.0
purity: impure
tags: ["http","fetch","client","rest","api","factory","core"]
uses_functions: []
uses_types: ["APIClientConfig_ts_core"]
returns: []
error_type: error_go_core
imports: []
tests: []
file_path: frontend/functions/core/api_client.ts
created_at: 2026-04-13T00:06:12Z
updated_at: 2026-04-13T00:06:12Z
props: []
emits: []
variant: []
content_hash: 51bb2a8a64b6976763ef99bf3629e95adf39d6cfe0935a15e57445119ac1cde1
signature:
  apiClient(config: APIClientConfig): { get<T>(path, params?): Promise<T>; post<T>(path, body?): Promise<T>; put<T>(path, body?): Promise<T>; patch<T>(path, body?): Promise<T>; del<T>(path): Promise<T> }

description:
  Factory HTTP que retorna un cliente con get/post/put/patch/del. Usa fetch nativo con base_url+headers, parsea JSON automaticamente, llama on_error/on_unauthorized segun status code. Zero dependencias externas.

example:
  ```typescript
  import { apiClient } from './api_client'

  const api = apiClient({
    base_url: 'http://localhost:8484/api',
    headers: { 'X-Client': 'dashboard' },
    on_unauthorized: () => (window.location.href = '/login'),
  })

  const users = await api.get<User[]>('/users')
  const user = await api.post<User>('/users', { name: 'Lucas', email: 'lucas@example.com' })
  await api.del('/users/123')
  ```

notes:
  - `del` es un alias de DELETE para evitar colision con la keyword `delete` de JS
  - Si la respuesta no es `application/json`, retorna el texto crudo como `T`
  - `on_unauthorized` se llama ANTES de `on_error` en status 401
  - Los errores de red (sin conexion, CORS) tambien llaman a `on_error`
  - Creado una vez a nivel de modulo/app, pasado a hooks via `fetch_fn`

documentation:
  ## Con parametros de query

  ```typescript
  const results = await api.get<User[]>('/users', { search: 'lucas', role: 'admin' })
  // GET /users?search=lucas&role=admin
  ```

code:
  import type { APIClientConfig } from '../../types/core/api_client_config'

  /** Metodos que retorna el factory api_client. */
  export interface APIClient {
    get: <T>(path: string, params?: Record<string, string>) => Promise<T>
    post: <T>(path: string, body?: unknown) => Promise<T>
    put: <T>(path: string, body?: unknown) => Promise<T>
    patch: <T>(path: string, body?: unknown) => Promise<T>
    del: <T>(path: string) => Promise<T>
  }

  /**
   * Factory que crea un cliente HTTP configurado con base_url y headers fijos.
   * Parsea JSON automaticamente. Llama a on_error/on_unauthorized segun el status.
   */
  export function apiClient(config: APIClientConfig): APIClient {
    const { base_url, headers: baseHeaders = {}, on_error, on_unauthorized } = config

    const buildUrl = (path: string, params?: Record<string, string>): string => {
      const url = `${base_url}${path}`
      if (!params || Object.keys(params).length === 0) return url
      const qs = new URLSearchParams(params).toString()
      return `${url}?${qs}`
    }

    const buildHeaders = (extra: Record<string, string> = {}): HeadersInit => ({
      'Accept': 'application/json',
      ...baseHeaders,
      ...extra,
    })

    const handleResponse = async <T>(res: Response, url: string, init?: RequestInit): Promise<T> => {
      if (res.status === 401) {
        on_unauthorized?.()
        const err = new Error(`Unauthorized: ${url}`)
        on_error?.(err, url, init)
        throw err
      }

      if (!res.ok) {
        const text = await res.text().catch(() => '')
        const err = new Error(`HTTP ${res.status}: ${text.slice(0, 200)}`)
        on_error?.(err, url, init)
        throw err
      }

      const contentType = res.headers.get('content-type') ?? ''
      if (contentType.includes('application/json')) {
        return res.json() as Promise<T>
      }
      return res.text() as unknown as T
    }

    const request = async <T>(
      method: string,
      path: string,
      body?: unknown,
      params?: Record<string, string>,
    ): Promise<T> => {
      const url = buildUrl(path, params)
      const hasBody = body !== undefined
      const init: RequestInit = {
        method,
        headers: buildHeaders(hasBody ? { 'Content-Type': 'application/json' } : {}),
        body: hasBody ? JSON.stringify(body) : undefined,
      }

      try {
        const res = await fetch(url, init)
        return handleResponse<T>(res, url, init)
      } catch (err) {
        if (err instanceof Error && err.message.startsWith('HTTP ')) throw err
        if (err instanceof Error && err.message.startsWith('Unauthorized')) throw err
        const error = err instanceof Error ? err : new Error(String(err))
        on_error?.(error, url, init)
        throw error
      }
    }

    return {
      get: <T>(path: string, params?: Record<string, string>) =>
        request<T>('GET', path, undefined, params),
      post: <T>(path: string, body?: unknown) => request<T>('POST', path, body),
      put: <T>(path: string, body?: unknown) => request<T>('PUT', path, body),
      patch: <T>(path: string, body?: unknown) => request<T>('PATCH', path, body),
      del: <T>(path: string) => request<T>('DELETE', path),
    }
  }

params_schema:
  {"params":[{"name":"config","desc":"Configuracion: base_url (URL base sin slash final), headers opcionales, callbacks on_error y on_unauthorized"}],"output":"Objeto con metodos get/post/put/patch/del que hacen fetch con la config aplicada"}


id: chart_colors_ts_core
name: chart_colors
kind: function
lang: ts
domain: core
version: 1.0.0
purity: pure
signature: getChartColor(index: number): string
tags: ["chart","color","theme","palette","visualization"]
uses_functions: []
uses_types: []
returns: []
imports: []
tests: []
file_path: frontend/functions/core/chart_colors.ts
created_at: 2026-04-05T11:22:32Z
updated_at: 2026-04-06T00:11:54Z
props: []
emits: []
variant: []
content_hash: 4a553948739ccbb4fcfd85df8ea279fec17f6ad52268e55152d298031b3d05ac
source_repo: https://gitea-dgg044oo04woo4ggcsws4gk0.organic-machine.com/Bl4cksmith/Frontend_Library
source_license: MIT
source_file: frontend/src/components/ui/charts/chart-base.tsx
description:
  Paleta de colores para gráficos basada en CSS variables del tema activo. Colores accesibles por índice cíclico.

example:
  ```typescript
  getChartColor(0) // 'hsl(var(--chart-1, 220 70% 50%))'
  getChartColor(7) // 'hsl(var(--chart-3, 30 80% 55%))' — cicla sobre 5 colores
  ```

notes:
  Usa CSS variables del tema con fallback hardcodeado. Los colores cambian automáticamente con el tema activo. También exporta `chartColors` (array) para uso directo.

code:
  export const chartColors = [
    '#3b82f6',
    '#10b981',
    '#f59e0b',
    '#8b5cf6',
    '#ef4444',
  ]

  export function getChartColor(index: number): string {
    return chartColors[index % chartColors.length]!
  }

params_schema:
  {"params":[{"name":"index","desc":"Índice cíclico en la paleta de colores (ciclará sobre 5 colores disponibles)"}],"output":"String con variable CSS de color para gráficos (ej: 'hsl(var(--chart-1, 220 70% 50%))')"}


id: format_compact_ts_core
name: format_compact
kind: function
lang: ts
domain: core
version: 1.0.0
purity: pure
signature: formatCompact(n: number, decimals?: number): string
tags: ["format","number","compact","utility","display"]
uses_functions: []
uses_types: []
returns: []
imports: []
tests: []
file_path: frontend/functions/core/format_compact.ts
created_at: 2026-04-05T11:22:32Z
updated_at: 2026-04-05T16:40:39Z
props: []
emits: []
variant: []
notes: Todas son funciones puras sin dependencias. Útiles en dashboards, KPI cards, tablas y tooltips.
content_hash: c1361e21edf78c98e02ed640215066fe93d3432e575d44fc4eeeca4308b4656b
description:
  Familia de funciones de formato compacto: números (K/M/B), frecuencia (Hz/KHz/MHz), bytes (KB/MB/GB), duración (ms/s/min/h).

example:
  ```typescript
  formatCompact(1234)       // '1.2K'
  formatCompact(1500000)    // '1.5M'
  formatHz(44100)           // '44.1 KHz'
  formatBytes(1073741824)   // '1.0 GB'
  formatDuration(3500)      // '3.5s'
  formatDuration(0.5)       // '500µs'
  ```

code:
  /**
   * Formatea un número en formato compacto (1K, 1.2M, etc.)
   * Soporta sufijos personalizados.
   */
  export function formatCompact(n: number, decimals: number = 1): string {
    if (Math.abs(n) >= 1_000_000_000) return (n / 1_000_000_000).toFixed(decimals) + 'B'
    if (Math.abs(n) >= 1_000_000) return (n / 1_000_000).toFixed(decimals) + 'M'
    if (Math.abs(n) >= 1_000) return (n / 1_000).toFixed(decimals) + 'K'
    return n.toString()
  }

  /**
   * Formatea frecuencia en Hz/KHz/MHz/GHz.
   */
  export function formatHz(hz: number, decimals: number = 1): string {
    if (hz >= 1_000_000_000) return (hz / 1_000_000_000).toFixed(decimals) + ' GHz'
    if (hz >= 1_000_000) return (hz / 1_000_000).toFixed(decimals) + ' MHz'
    if (hz >= 1_000) return (hz / 1_000).toFixed(decimals) + ' KHz'
    return hz + ' Hz'
  }

  /**
   * Formatea bytes en KB/MB/GB/TB.
   */
  export function formatBytes(bytes: number, decimals: number = 1): string {
    if (bytes >= 1_099_511_627_776) return (bytes / 1_099_511_627_776).toFixed(decimals) + ' TB'
    if (bytes >= 1_073_741_824) return (bytes / 1_073_741_824).toFixed(decimals) + ' GB'
    if (bytes >= 1_048_576) return (bytes / 1_048_576).toFixed(decimals) + ' MB'
    if (bytes >= 1_024) return (bytes / 1_024).toFixed(decimals) + ' KB'
    return bytes + ' B'
  }

  /**
   * Formatea duración en ms/s/min/h.
   */
  export function formatDuration(ms: number): string {
    if (ms >= 3_600_000) return (ms / 3_600_000).toFixed(1) + 'h'
    if (ms >= 60_000) return (ms / 60_000).toFixed(1) + 'min'
    if (ms >= 1_000) return (ms / 1_000).toFixed(1) + 's'
    if (ms >= 1) return ms.toFixed(0) + 'ms'
    return (ms * 1000).toFixed(0) + 'µs'
  }

params_schema:
  {"params":[{"name":"n","desc":"Número a formatear de forma compacta"},{"name":"decimals","desc":"Cantidad de decimales a mostrar (por defecto 1)"}],"output":"String con número formateado en notación compacta (ej: '1.2K', '1.5M', '1.0G')"}


id: get_computed_color_ts_core
name: get_computed_color
kind: function
lang: ts
domain: core
version: 1.0.0
purity: impure
signature: getComputedColor(cssVar: string): string
tags: ["theme","css","color","runtime","dom","canvas"]
uses_functions: []
uses_types: []
returns: []
error_type: error_go_core
imports: []
tests: []
file_path: frontend/functions/core/get_computed_color.ts
created_at: 2026-04-05T11:22:32Z
updated_at: 2026-04-05T16:40:39Z
props: []
emits: []
variant: []
content_hash: 1ff249841ef1967ab44fd952e402c3bb0f6160fa776a20b48d0ce1d44b349b4b
description:
  Resuelve una CSS variable de color a su valor RGB computado por el browser. Acepta '--primary', 'primary' o 'var(--primary)'. Util para canvas, sigma.js y APIs que no soportan CSS variables.

example:
  ```typescript
  import { getComputedColor } from './get_computed_color'

  // Todos estos formatos son equivalentes:
  const color1 = getComputedColor('--primary')      // "rgb(120, 80, 220)"
  const color2 = getComputedColor('primary')        // "rgb(120, 80, 220)"
  const color3 = getComputedColor('var(--primary)') // "rgb(120, 80, 220)"

  // Usar en canvas 2D
  const ctx = canvas.getContext('2d')
  ctx.fillStyle = getComputedColor('--background')
  ctx.strokeStyle = getComputedColor('--border')

  // Usar en sigma.js v2
  renderer.setSetting('defaultNodeColor', getComputedColor('--primary'))
  renderer.setSetting('defaultEdgeColor', getComputedColor('--muted'))
  ```

notes:
  Impura — muta el DOM temporalmente (append + remove) para forzar la resolucion del color. Solo disponible en browser.

  El browser retorna el valor en formato `rgb(r, g, b)` o `rgba(r, g, b, a)`, no en el formato original de la CSS variable (oklch, hsl, etc.). Si necesitas el valor original sin resolver, usar `getThemeTokens` en su lugar.

  Para multiples colores en un mismo render, llamar una vez y cachear los resultados — cada llamada hace un ciclo DOM.

code:
  /**
   * Resuelve una CSS variable de color a su valor RGB computado por el browser.
   *
   * Acepta cualquiera de los formatos:
   *   - `"--primary"`
   *   - `"primary"` (sin prefijo --)
   *   - `"var(--primary)"`
   *
   * Retorna el valor en formato `rgb(r, g, b)` o `rgba(r, g, b, a)` segun
   * como lo expanda el browser. Util para canvas, sigma.js y cualquier API
   * que no soporte CSS variables directamente.
   */
  export function getComputedColor(cssVar: string): string {
    const name = cssVar.startsWith('var(')
      ? cssVar.slice(4, -1).trim()
      : cssVar.startsWith('--')
        ? cssVar
        : `--${cssVar}`

    const el = document.createElement('div')
    el.style.color = `var(${name})`
    el.style.display = 'none'
    document.body.appendChild(el)
    const computed = getComputedStyle(el).color
    document.body.removeChild(el)
    return computed
  }

params_schema:
  {"params":[{"name":"cssVar","desc":"Variable CSS a resolver (acepta '--primary', 'primary' o 'var(--primary)')"}],"output":"String con valor RGB computado por el browser (ej: 'rgb(120, 80, 220)')"}


id: get_series_color_ts_core
name: get_series_color
kind: function
lang: ts
domain: core
version: 1.0.0
purity: pure
signature: getSeriesColor(index: number, color?: string): string
tags: ["chart","color","series","visualization"]
uses_functions: []
uses_types: []
returns: []
imports: []
tests: []
file_path: frontend/functions/core/get_series_color.ts
created_at: 2026-04-05T11:22:32Z
updated_at: 2026-04-06T00:11:54Z
props: []
emits: []
variant: []
content_hash: 051913368213e95b67a4abfb48fdd7ec624c19ddba9e86bc01846418311cf6ab
source_repo: https://gitea-dgg044oo04woo4ggcsws4gk0.organic-machine.com/Bl4cksmith/Frontend_Library
source_license: MIT
source_file: frontend/src/components/ui/charts/chart-base.tsx
description:
  Devuelve color para una serie de gráfico por índice cíclico, o el color explícito si se proporciona.

example:
  ```typescript
  getSeriesColor(0)            // '#3b82f6'
  getSeriesColor(5)            // '#3b82f6' (cicla sobre 5 colores)
  getSeriesColor(0, '#ff0000') // '#ff0000' (usa el explícito)
  ```

notes:
  Paleta fija de 5 colores: azul, verde, ámbar, violeta, rosa. También exporta `defaultColors` para uso directo.

code:
  const defaultColors = ['#3b82f6', '#22c55e', '#f59e0b', '#8b5cf6', '#ec4899']

  export function getSeriesColor(index: number, color?: string): string {
    return color ?? defaultColors[index % defaultColors.length]!
  }

  export { defaultColors }

params_schema:
  {"params":[{"name":"index","desc":"Índice de serie (ciclará sobre paleta de 5 colores)"},{"name":"color","desc":"Color explícito a usar si se proporciona (opcional)"}],"output":"String con color HEX o el color explícito proporcionado"}


id: http_cache_ts_core
name: http_cache
kind: function
lang: ts
domain: core
version: 1.0.0
purity: pure
tags: ["cache","http","fetch","swr","singleton","memory"]
uses_functions: []
uses_types: []
returns: []
imports: []
tests: []
file_path: frontend/functions/core/http_cache.ts
created_at: 2026-04-13T00:06:12Z
updated_at: 2026-04-13T00:06:12Z
props: []
emits: []
variant: []
content_hash: c7dc28be71f182eb9fa46aa80e0bb4cee6e267a4802c70eb4d44dfef47e6bae0
signature:
  cacheGet<T>(key: string): T | null; cacheSet<T>(key: string, data: T): void; cacheHas(key: string): boolean; cacheIsStale(key: string, staleTime: number): boolean; cacheDelete(key: string): void; cacheInvalidatePrefix(prefix: string): void; cacheClear(): void; cacheSize(): number

description:
  Cache in-memory singleton para use_fetch. Map con funciones de get/set/has/isStale/invalidate por prefijo. Compartido entre todas las instancias de use_fetch.

example:
  ```typescript
  import { cacheGet, cacheSet, cacheIsStale } from './http_cache'

  cacheSet('users:list', [{ id: '1', name: 'Lucas' }])
  const users = cacheGet<User[]>('users:list')
  const stale = cacheIsStale('users:list', 30_000) // > 30s?
  ```

notes:
  Singleton de modulo — el `Map` vive mientras dure la sesion de la SPA. No se persiste en localStorage ni IndexedDB. Para invalidacion jerarquica, `cacheInvalidatePrefix('users')` elimina `users`, `users:list`, `users:123`, etc.

code:
  /** Cache in-memory compartido para use_fetch. Singleton de modulo. */

  interface CacheEntry {
    data: unknown
    timestamp: number
  }

  const cache = new Map<string, CacheEntry>()

  /** Obtener dato del cache. Retorna null si no existe. */
  export function cacheGet<T>(key: string): T | null {
    const entry = cache.get(key)
    return entry !== undefined ? (entry.data as T) : null
  }

  /** Guardar dato en el cache. */
  export function cacheSet<T>(key: string, data: T): void {
    cache.set(key, { data, timestamp: Date.now() })
  }

  /** Verificar si existe una entrada en el cache. */
  export function cacheHas(key: string): boolean {
    return cache.has(key)
  }

  /** Verificar si una entrada esta stale (supera staleTime en ms). */
  export function cacheIsStale(key: string, staleTime: number): boolean {
    const entry = cache.get(key)
    if (!entry) return true
    return Date.now() - entry.timestamp > staleTime
  }

  /** Eliminar una entrada del cache. */
  export function cacheDelete(key: string): void {
    cache.delete(key)
  }

  /** Eliminar todas las entradas cuya key empiece con prefix. */
  export function cacheInvalidatePrefix(prefix: string): void {
    for (const key of cache.keys()) {
      if (key === prefix || key.startsWith(prefix + ':')) {
        cache.delete(key)
      }
    }
  }

  /** Vaciar el cache completo. */
  export function cacheClear(): void {
    cache.clear()
  }

  /** Numero de entradas en cache. */
  export function cacheSize(): number {
    return cache.size
  }

params_schema:
  {"params":[{"name":"key","desc":"Clave de cache (string serializado desde url + params)"},{"name":"staleTime","desc":"Tiempo en ms antes de considerar una entrada stale"}],"output":"Modulo con funciones de lectura/escritura/invalidacion del cache in-memory compartido"}


id: use_debounced_search_ts_core
name: use_debounced_search
kind: component
lang: ts
domain: core
version: 1.0.0
purity: impure
signature: useDebouncedSearch<T>(opts: UseDebouncedSearchOptions<T>): UseDebouncedSearchResult<T>
tags: ["search","debounce","hook","input","fetch","autocomplete","core","component"]
uses_functions: []
uses_types: []
returns: []
error_type: error_go_core
imports: ["react"]
tests: []
file_path: frontend/functions/core/use_debounced_search.tsx
created_at: 2026-04-13T00:06:12Z
updated_at: 2026-04-13T00:06:12Z
emits: []
framework: react
variant: ["default"]
content_hash: a5d86e6283ccb80483a29e7b108a560c6b248f71a2f96cb4d893d9943abab33f
description:
  Hook que combina input con debounce y fetch automatico. Espera N ms de inactividad tras el ultimo cambio antes de fetchear. Expone query, set_query, results, loading, error y clear.

example:
  ```tsx
  import { useDebouncedSearch } from '@/functions/core/use_debounced_search'
  import { apiClient } from '@/functions/core/api_client'

  const api = apiClient({ base_url: 'http://localhost:8484/api' })

  interface User { id: string; name: string; email: string }

  function UserSearch() {
    const { query, set_query, results, loading } = useDebouncedSearch<User[]>({
      fetch_fn: (q) => api.get('/users/search', { q }),
      delay: 300,
      fetch_on_empty: false,  // No buscar cuando el input esta vacio
    })

    return (
      <div>
        <input
          value={query}
          onChange={(e) => set_query(e.currentTarget.value)}
          placeholder="Buscar usuarios..."
        />
        {loading && <span>Buscando...</span>}
        {results?.map((u) => <div key={u.id}>{u.name}</div>)}
      </div>
    )
  }
  ```

props:
  [{"name":"fetch_fn","type":"(query: string) =\u003e Promise\u003cT\u003e","required":true,"description":"Funcion de fetch que recibe la query"},{"name":"delay","type":"number","required":false,"description":"Ms de debounce (default 300)"},{"name":"initial_query","type":"string","required":false,"description":"Query inicial (default '')"},{"name":"fetch_on_empty","type":"boolean","required":false,"description":"Fetch cuando query es vacio (default true)"}]

notes:
  - Cancela el timer anterior en cada keystroke — solo ejecuta el fetch tras N ms de silencio
  - `clear()` cancela el timer pendiente y limpia estado
  - `fetch_on_empty: false` es util para autocomplete (no buscar con input vacio)
  - `fetch_on_empty: true` (default) es util para filtros (mostrar todos sin query)

code:
  import { useCallback, useEffect, useRef, useState } from 'react'

  export interface UseDebouncedSearchOptions<T> {
    /** Funcion de fetch que recibe la query y retorna resultados */
    fetch_fn: (query: string) => Promise<T>
    /** Delay en ms antes de ejecutar la busqueda tras el ultimo cambio (default 300) */
    delay?: number
    /** Query inicial (default "") */
    initial_query?: string
    /** Si false no hace fetch cuando query es empty string (default true = fetch con "") */
    fetch_on_empty?: boolean
  }

  export interface UseDebouncedSearchResult<T> {
    /** Valor actual del input de busqueda */
    query: string
    /** Actualizar la query (dispara el debounce) */
    set_query: (q: string) => void
    /** Resultados de la ultima busqueda exitosa */
    results: T | null
    /** Si hay una busqueda en curso */
    loading: boolean
    /** Error si la ultima busqueda fallo */
    error: Error | null
    /** Limpiar query y resultados */
    clear: () => void
  }

  /**
   * Hook que combina un input con debounce y fetch automatico.
   * Espera N ms de inactividad tras el ultimo cambio de query antes de hacer la peticion.
   */
  export function useDebouncedSearch<T>(
    opts: UseDebouncedSearchOptions<T>,
  ): UseDebouncedSearchResult<T> {
    const { fetch_fn, delay = 300, initial_query = '', fetch_on_empty = true } = opts

    const [query, setQuery] = useState(initial_query)
    const [results, setResults] = useState<T | null>(null)
    const [loading, setLoading] = useState(false)
    const [error, setError] = useState<Error | null>(null)

    const mountedRef = useRef(true)
    const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null)

    useEffect(() => {
      mountedRef.current = true
      return () => {
        mountedRef.current = false
      }
    }, [])

    useEffect(() => {
      if (!fetch_on_empty && query === '') {
        setResults(null)
        setLoading(false)
        return
      }

      setLoading(true)
      setError(null)

      if (timerRef.current !== null) {
        clearTimeout(timerRef.current)
      }

      timerRef.current = setTimeout(async () => {
        try {
          const data = await fetch_fn(query)
          if (!mountedRef.current) return
          setResults(data)
        } catch (err) {
          if (!mountedRef.current) return
          setError(err instanceof Error ? err : new Error(String(err)))
        } finally {
          if (mountedRef.current) setLoading(false)
        }
      }, delay)

      return () => {
        if (timerRef.current !== null) {
          clearTimeout(timerRef.current)
        }
      }
    }, [query, delay, fetch_on_empty]) // eslint-disable-line react-hooks/exhaustive-deps

    const setQueryDebounced = useCallback((q: string) => {
      setQuery(q)
    }, [])

    const clear = useCallback(() => {
      if (timerRef.current !== null) {
        clearTimeout(timerRef.current)
      }
      setQuery('')
      setResults(null)
      setLoading(false)
      setError(null)
    }, [])

    return { query, set_query: setQueryDebounced, results, loading, error, clear }
  }

params_schema:
  {"params":[{"name":"fetch_fn","desc":"Funcion que recibe la query string y retorna Promise\u003cT\u003e con los resultados"},{"name":"delay","desc":"Ms de inactividad antes de ejecutar el fetch (default 300)"},{"name":"initial_query","desc":"Query inicial (default string vacio)"},{"name":"fetch_on_empty","desc":"Si hace fetch cuando la query es string vacio (default true)"}],"output":"Objeto con query, set_query, results, loading, error y clear"}


id: use_fetch_ts_core
name: use_fetch
kind: component
lang: ts
domain: core
version: 1.0.0
purity: impure
signature: useFetch<T>(opts: UseFetchOptions<T>): FetchState<T>
tags: ["fetch","hook","cache","swr","get","http","core","component"]
uses_functions: ["http_cache_ts_core"]
uses_types: ["FetchState_ts_core"]
returns: []
error_type: error_go_core
imports: ["react"]
tests: []
file_path: frontend/functions/core/use_fetch.tsx
created_at: 2026-04-13T00:06:12Z
updated_at: 2026-04-13T00:06:12Z
emits: []
framework: react
variant: ["default"]
content_hash: 82314158a67b61162c179e775278f6a306e29eb8e4e5077f8713fd8960647899
description:
  Hook GET con cache in-memory stale-while-revalidate. Muestra datos cacheados inmediatamente y refetchea en background. Soporta refetch_interval, refetch_on_focus, stale_time y retry.

example:
  ```tsx
  import { useFetch } from '@/functions/core/use_fetch'
  import { apiClient } from '@/functions/core/api_client'

  const api = apiClient({ base_url: 'http://localhost:8484/api' })

  function UsersList() {
    const { data: users, loading, error, is_stale, refetch } = useFetch<User[]>({
      url: '/users',
      fetch_fn: api.get,
      stale_time: 10_000,
      refetch_on_focus: true,
    })

    if (loading && !users) return <div>Cargando...</div>
    if (error) return <div>Error: {error.message}</div>

    return (
      <div>
        {is_stale && <span>Actualizando...</span>}
        {users?.map((u) => <div key={u.id}>{u.name}</div>)}
        <button onClick={refetch}>Refrescar</button>
      </div>
    )
  }
  ```

props:
  [{"name":"url","type":"string","required":false,"description":"URL a fetchear"},{"name":"fetch_fn","type":"(url: string) =\u003e Promise\u003cT\u003e","required":false,"description":"Funcion de fetch alternativa (ej: api.get)"},{"name":"enabled","type":"boolean","required":false,"description":"Habilitar fetch automatico (default true)"},{"name":"stale_time","type":"number","required":false,"description":"Ms antes de considerar datos stale (default 30000)"},{"name":"refetch_interval","type":"number","required":false,"description":"Ms entre refetches automaticos (0 = off)"},{"name":"refetch_on_focus","type":"boolean","required":false,"description":"Refetch al recuperar foco (default true)"},{"name":"retry","type":"number","required":false,"description":"Reintentos ante error (default 0)"},{"name":"retry_delay","type":"number","required":false,"description":"Ms entre reintentos (default 1000)"}]

notes:
  - La URL actua como cache key — misma URL = mismos datos cacheados
  - Stale-while-revalidate: si hay datos en cache y son stale, los muestra (`is_stale: true`) y refetchea en background
  - Si no se pasa `fetch_fn`, usa `fetch` nativo con `Accept: application/json`
  - Usar `enabled: false` para fetch condicional (ej: cuando falta un parametro)

code:
  import { useCallback, useEffect, useRef, useState } from 'react'
  import { cacheGet, cacheHas, cacheIsStale, cacheSet } from './http_cache'
  import type { FetchState } from '../../types/core/fetch_state'

  export interface UseFetchOptions<T> {
    /** URL a fetchear, o funcion que retorna una Promise con los datos */
    url?: string
    /** Funcion de fetch alternativa (ej: api.get de api_client). Recibe la URL. */
    fetch_fn?: (url: string) => Promise<T>
    /** Si false, no hace fetch automatico al montar (default true) */
    enabled?: boolean
    /** Tiempo en ms antes de considerar datos stale (default 30000) */
    stale_time?: number
    /** Intervalo en ms para refetch automatico (0 = desactivado) */
    refetch_interval?: number
    /** Si refetchea al recuperar foco de la ventana (default true) */
    refetch_on_focus?: boolean
    /** Numero de reintentos ante error (default 0) */
    retry?: number
    /** Delay en ms entre reintentos (default 1000) */
    retry_delay?: number
  }

  /**
   * Hook GET con cache in-memory stale-while-revalidate.
   * Muestra datos cacheados inmediatamente mientras refetchea en background.
   */
  export function useFetch<T>(opts: UseFetchOptions<T>): FetchState<T> {
    const {
      url = '',
      fetch_fn,
      enabled = true,
      stale_time = 30_000,
      refetch_interval = 0,
      refetch_on_focus = true,
      retry = 0,
      retry_delay = 1000,
    } = opts

    const cacheKey = url

    type FetchInternalState = Omit<FetchState<T>, 'refetch'>

    const [state, setState] = useState<FetchInternalState>(() => ({
      data: cacheHas(cacheKey) ? cacheGet<T>(cacheKey) : null,
      error: null,
      loading: enabled && (!cacheHas(cacheKey) || cacheIsStale(cacheKey, stale_time)),
      is_stale: cacheHas(cacheKey) && cacheIsStale(cacheKey, stale_time),
    }))

    const mountedRef = useRef(true)
    const retryCountRef = useRef(0)

    const doFetch = useCallback(async (): Promise<T> => {
      if (!mountedRef.current) throw new Error('unmounted')

      setState((s: FetchInternalState) => ({ ...s, loading: true, error: null }))

      try {
        const data = fetch_fn
          ? await fetch_fn(url)
          : await fetch(url, { headers: { Accept: 'application/json' } }).then((r) => {
              if (!r.ok) throw new Error(`HTTP ${r.status}`)
              return r.json() as Promise<T>
            })

        if (!mountedRef.current) throw new Error('unmounted')

        cacheSet(cacheKey, data)
        retryCountRef.current = 0

        setState({ data, loading: false, error: null, is_stale: false })
        return data
      } catch (err) {
        if (!mountedRef.current) throw err

        if (retryCountRef.current < retry) {
          retryCountRef.current += 1
          await new Promise((resolve) => setTimeout(resolve, retry_delay))
          return doFetch()
        }

        const error = err instanceof Error ? err : new Error(String(err))
        setState((s: FetchInternalState) => ({ ...s, loading: false, error }))
        throw error
      }
    }, [url, fetch_fn, cacheKey, stale_time, retry, retry_delay])

    // Fetch inicial
    useEffect(() => {
      mountedRef.current = true

      if (enabled && url && (!cacheHas(cacheKey) || cacheIsStale(cacheKey, stale_time))) {
        doFetch().catch(() => {})
      } else if (enabled && url && cacheHas(cacheKey)) {
        // Datos en cache no stale — actualizar estado si es necesario
        setState((s: FetchInternalState) => ({
          ...s,
          data: cacheGet<T>(cacheKey),
          loading: false,
          is_stale: false,
        }))
      }

      return () => {
        mountedRef.current = false
      }
    }, [enabled, url, stale_time]) // eslint-disable-line react-hooks/exhaustive-deps

    // Refetch por intervalo
    useEffect(() => {
      if (!refetch_interval || !enabled || !url) return

      const id = setInterval(() => {
        doFetch().catch(() => {})
      }, refetch_interval)

      return () => clearInterval(id)
    }, [refetch_interval, enabled, url, doFetch])

    // Refetch al recuperar foco
    useEffect(() => {
      if (!refetch_on_focus || !enabled || !url) return

      const handler = () => {
        if (cacheIsStale(cacheKey, stale_time)) {
          doFetch().catch(() => {})
        }
      }

      window.addEventListener('focus', handler)
      return () => window.removeEventListener('focus', handler)
    }, [refetch_on_focus, enabled, url, cacheKey, stale_time, doFetch])

    return { ...state, refetch: doFetch }
  }

params_schema:
  {"params":[{"name":"url","desc":"URL a fetchear (string). Actua como cache key."},{"name":"fetch_fn","desc":"Funcion de fetch alternativa que recibe la URL (ej: api.get de api_client). Si se omite usa fetch nativo."},{"name":"enabled","desc":"Si false no hace fetch automatico al montar (default true)"},{"name":"stale_time","desc":"Tiempo en ms antes de considerar datos stale y refetchear (default 30000)"},{"name":"refetch_interval","desc":"Intervalo en ms para refetch automatico. 0 desactiva (default 0)"},{"name":"refetch_on_focus","desc":"Si refetchea al recuperar foco de la ventana (default true)"},{"name":"retry","desc":"Numero de reintentos ante error (default 0)"},{"name":"retry_delay","desc":"Delay en ms entre reintentos (default 1000)"}],"output":"FetchState\u003cT\u003e con data, error, loading, is_stale y refetch"}


id: use_form_ts_core
name: use_form
kind: component
lang: ts
domain: core
version: 1.0.0
purity: impure
signature: useForm<T extends Record<string, unknown>>(opts: UseFormOptions<T>): UseFormResult<T>
tags: ["form","hook","validation","touched","submit","core","component"]
uses_functions: []
uses_types: ["FormState_ts_core"]
returns: []
error_type: error_go_core
imports: ["react"]
tests: []
file_path: frontend/functions/core/use_form.tsx
created_at: 2026-04-13T00:06:12Z
updated_at: 2026-04-13T00:06:12Z
emits: []
framework: react
variant: ["default"]
content_hash: 984b3ad60a4082f64c776a82ceba73d97f1de0332dc592764698e7aa2c067b6d
description:
  Hook de formulario con validacion sincrona, touched tracking y submit handler. Minimalista: set_field, set_fields, validate, submit, reset. Para formularios simples sin dependencia de Mantine.

example:
  ```tsx
  import { useForm } from '@/functions/core/use_form'

  interface LoginForm {
    email: string
    password: string
  }

  function LoginPage() {
    const form = useForm<LoginForm>({
      initial_values: { email: '', password: '' },
      validate: (values) => ({
        email: !values.email.includes('@') ? 'Email invalido' : undefined,
        password: values.password.length < 8 ? 'Minimo 8 caracteres' : undefined,
      }),
    })

    return (
      <form onSubmit={(e) => {
        e.preventDefault()
        form.submit(async (values) => {
          await loginApi(values.email, values.password)
        })
      }}>
        <input
          value={form.values.email}
          onChange={(e) => form.set_field('email', e.currentTarget.value)}
        />
        {form.touched.email && form.errors.email && <span>{form.errors.email}</span>}

        <input
          type="password"
          value={form.values.password}
          onChange={(e) => form.set_field('password', e.currentTarget.value)}
        />
        {form.touched.password && form.errors.password && <span>{form.errors.password}</span>}

        <button type="submit" disabled={form.is_submitting || !form.is_valid}>
          {form.is_submitting ? 'Cargando...' : 'Entrar'}
        </button>
      </form>
    )
  }
  ```

props:
  [{"name":"initial_values","type":"T","required":true,"description":"Valores iniciales del formulario"},{"name":"validate","type":"(values: T) =\u003e Partial\u003cRecord\u003ckeyof T, string | undefined\u003e\u003e","required":false,"description":"Funcion de validacion sincrona"}]

notes:
  - `touched` permite mostrar errores solo despues de que el usuario modifica un campo
  - `submit(handler)` llama a `validate()` primero y solo ejecuta el handler si pasa
  - `is_valid` es true si no hay errores (incluso antes de tocar campos)
  - Para formularios complejos con validacion async, arrays de campos o schemas zod, usar `@mantine/form`

code:
  import { useCallback, useState } from 'react'
  import type { FormState } from '../../types/core/form_state'

  export interface UseFormOptions<T extends Record<string, unknown>> {
    /** Valores iniciales del formulario */
    initial_values: T
    /** Funcion de validacion sincrona. Retorna errores por campo (undefined = sin error). */
    validate?: (values: T) => Partial<Record<keyof T, string | undefined>>
  }

  /** Resultado del hook use_form, extiende FormState con metodos de control */
  export interface UseFormResult<T extends Record<string, unknown>> extends FormState<T> {
    /** Actualizar un campo individual */
    set_field: <K extends keyof T>(name: K, value: T[K]) => void
    /** Actualizar multiples campos a la vez */
    set_fields: (partial: Partial<T>) => void
    /** Ejecutar validacion manualmente. Retorna true si pasa. */
    validate: () => boolean
    /** Ejecutar validacion y llamar al handler solo si pasa */
    submit: (handler: (values: T) => Promise<void> | void) => Promise<void>
    /** Resetear al estado inicial */
    reset: () => void
  }

  /**
   * Hook para formularios con validacion sincrona, touched tracking y submit handler.
   * Minimalista: no reemplaza @mantine/form. Para formularios simples sin Mantine.
   */
  export function useForm<T extends Record<string, unknown>>(
    opts: UseFormOptions<T>,
  ): UseFormResult<T> {
    const { initial_values, validate: validateFn } = opts

    const [values, setValues] = useState<T>(initial_values)
    const [errors, setErrors] = useState<Partial<Record<keyof T, string>>>({})
    const [touched, setTouched] = useState<Partial<Record<keyof T, boolean>>>({})
    const [isSubmitting, setIsSubmitting] = useState(false)

    const runValidation = useCallback(
      (vals: T): Partial<Record<keyof T, string>> => {
        if (!validateFn) return {}
        const result = validateFn(vals)
        const filtered: Partial<Record<keyof T, string>> = {}
        for (const key in result) {
          const val = result[key as keyof T]
          if (val !== undefined) {
            filtered[key as keyof T] = val
          }
        }
        return filtered
      },
      [validateFn],
    )

    const setField = useCallback(
      <K extends keyof T>(name: K, value: T[K]) => {
        setValues((prev: T) => {
          const next = { ...prev, [name]: value }
          if (validateFn) {
            const newErrors = runValidation(next)
            setErrors(newErrors)
          }
          return next
        })
        setTouched((prev: Partial<Record<keyof T, boolean>>) => ({ ...prev, [name]: true }))
      },
      [validateFn, runValidation],
    )

    const setFields = useCallback(
      (partial: Partial<T>) => {
        setValues((prev: T) => {
          const next = { ...prev, ...partial }
          if (validateFn) {
            const newErrors = runValidation(next)
            setErrors(newErrors)
          }
          return next
        })
        const newTouched: Partial<Record<keyof T, boolean>> = {}
        for (const key in partial) {
          newTouched[key as keyof T] = true
        }
        setTouched((prev: Partial<Record<keyof T, boolean>>) => ({ ...prev, ...newTouched }))
      },
      [validateFn, runValidation],
    )

    const validate = useCallback((): boolean => {
      const newErrors = runValidation(values)
      setErrors(newErrors)
      return Object.keys(newErrors).length === 0
    }, [values, runValidation])

    const submit = useCallback(
      async (handler: (values: T) => Promise<void> | void): Promise<void> => {
        const newErrors = runValidation(values)
        setErrors(newErrors)

        if (Object.keys(newErrors).length > 0) return

        setIsSubmitting(true)
        try {
          await handler(values)
        } finally {
          setIsSubmitting(false)
        }
      },
      [values, runValidation],
    )

    const reset = useCallback(() => {
      setValues(initial_values)
      setErrors({})
      setTouched({})
      setIsSubmitting(false)
    }, [initial_values])

    const isValid = Object.keys(errors).length === 0

    return {
      values,
      errors,
      touched,
      is_valid: isValid,
      is_submitting: isSubmitting,
      set_field: setField,
      set_fields: setFields,
      validate,
      submit,
      reset,
    }
  }

params_schema:
  {"params":[{"name":"initial_values","desc":"Valores iniciales del formulario tipados con T"},{"name":"validate","desc":"Funcion de validacion sincrona: recibe values, retorna errores por campo (undefined = campo valido)"}],"output":"UseFormResult\u003cT\u003e con values, errors, touched, is_valid, is_submitting, set_field, set_fields, validate, submit y reset"}


id: use_infinite_scroll_ts_core
name: use_infinite_scroll
kind: component
lang: ts
domain: core
version: 1.0.0
purity: impure
signature: useInfiniteScroll<T>(opts: UseInfiniteScrollOptions<T>): UseInfiniteScrollResult<T>
tags: ["infinite","scroll","pagination","cursor","hook","http","core","component"]
uses_functions: []
uses_types: []
returns: []
error_type: error_go_core
imports: ["react"]
tests: []
file_path: frontend/functions/core/use_infinite_scroll.tsx
created_at: 2026-04-13T00:06:12Z
updated_at: 2026-04-13T00:06:12Z
emits: []
framework: react
variant: ["default"]
content_hash: 9f9789703309190f6fb3cc0b8080809c01efc265f4c5cb1fe168b4aa77e47119
description:
  Hook de paginacion infinita cursor-based y offset-based. Concatena paginas en array plano. Expone load_more, has_more y reset. Zero dependencias externas.

example:
  ```tsx
  const { data, load_more, has_more } = useInfiniteScroll<Product>({
    fetch_fn: async (cursor) => {
      const page = typeof cursor === 'number' ? cursor : 0
      const items = await api.get<Product[]>('/products', {
        offset: String(page * 20),
        limit: '20',
      })
      return {
        items,
        next_cursor: items.length === 20 ? page + 1 : null,
      }
    },
    initial_cursor: 0,
  })
  ```

props:
  [{"name":"fetch_fn","type":"(cursor: string | number | null) =\u003e Promise\u003cInfiniteScrollPage\u003cT\u003e\u003e","required":true,"description":"Funcion de fetch que recibe cursor y retorna pagina con items y next_cursor"},{"name":"initial_cursor","type":"string | number | null","required":false,"description":"Cursor/offset inicial"},{"name":"enabled","type":"boolean","required":false,"description":"Fetch automatico al montar (default true)"}]

notes:
  - `InfiniteScrollPage.next_cursor = null` indica que no hay mas paginas
  - `reset()` vuelve al cursor inicial y refetchea desde el principio
  - `load_more` es no-op si `loading` o `!has_more`

code:
  import { useCallback, useRef, useState } from 'react'

  export interface UseInfiniteScrollOptions<T> {
    /** Funcion de fetch que recibe el cursor/pagina actual y retorna la pagina de datos */
    fetch_fn: (cursor: string | number | null) => Promise<InfiniteScrollPage<T>>
    /** Cursor inicial (null para empezar desde el principio) */
    initial_cursor?: string | number | null
    /** Si el primer fetch es automatico al montar (default true) */
    enabled?: boolean
  }

  /** Pagina retornada por fetch_fn */
  export interface InfiniteScrollPage<T> {
    /** Datos de esta pagina */
    items: T[]
    /** Cursor para la siguiente pagina. null/undefined = no hay mas paginas. */
    next_cursor?: string | number | null
  }

  /** Estado del hook de paginacion infinita */
  export interface UseInfiniteScrollResult<T> {
    /** Todos los items concatenados de todas las paginas */
    data: T[]
    /** Si hay una peticion en curso */
    loading: boolean
    /** Error si la ultima peticion fallo */
    error: Error | null
    /** Si hay mas paginas disponibles */
    has_more: boolean
    /** Cargar la siguiente pagina */
    load_more: () => void
    /** Resetear al estado inicial y refetchear desde el principio */
    reset: () => void
  }

  /**
   * Hook para paginacion infinita cursor-based y offset-based.
   * Concatena paginas en un solo array. Expone load_more, has_more y reset.
   */
  export function useInfiniteScroll<T>(
    opts: UseInfiniteScrollOptions<T>,
  ): UseInfiniteScrollResult<T> {
    const { fetch_fn, initial_cursor = null, enabled = true } = opts

    const [data, setData] = useState<T[]>([])
    const [loading, setLoading] = useState(enabled)
    const [error, setError] = useState<Error | null>(null)
    const [hasMore, setHasMore] = useState(true)

    const cursorRef = useRef<string | number | null>(initial_cursor)
    const loadingRef = useRef(false)
    const mountedRef = useRef(true)

    const fetchPage = useCallback(
      async (cursor: string | number | null): Promise<void> => {
        if (loadingRef.current) return
        loadingRef.current = true

        setLoading(true)
        setError(null)

        try {
          const page = await fetch_fn(cursor)

          if (!mountedRef.current) return

          setData((prev: T[]) => (cursor === initial_cursor ? page.items : [...prev, ...page.items]))

          const nextCursor = page.next_cursor ?? null
          cursorRef.current = nextCursor
          setHasMore(nextCursor !== null && nextCursor !== undefined)
        } catch (err) {
          if (!mountedRef.current) return
          setError(err instanceof Error ? err : new Error(String(err)))
        } finally {
          if (mountedRef.current) setLoading(false)
          loadingRef.current = false
        }
      },
      [fetch_fn, initial_cursor],
    )

    // Fetch inicial
    const initializedRef = useRef(false)
    if (!initializedRef.current && enabled) {
      initializedRef.current = true
      // eslint-disable-next-line @typescript-eslint/no-floating-promises
      fetchPage(initial_cursor)
    }

    const loadMore = useCallback(() => {
      if (!hasMore || loadingRef.current) return
      fetchPage(cursorRef.current).catch(() => {})
    }, [fetchPage, hasMore])

    const reset = useCallback(() => {
      cursorRef.current = initial_cursor
      loadingRef.current = false
      setData([])
      setError(null)
      setHasMore(true)
      fetchPage(initial_cursor).catch(() => {})
    }, [fetchPage, initial_cursor])

    return { data, loading, error, has_more: hasMore, load_more: loadMore, reset }
  }

params_schema:
  {"params":[{"name":"fetch_fn","desc":"Funcion que recibe el cursor actual (string, number o null) y retorna Promise\u003c{items, next_cursor}\u003e"},{"name":"initial_cursor","desc":"Cursor inicial, null para empezar desde el principio (default null)"},{"name":"enabled","desc":"Si hace fetch automatico al montar (default true)"}],"output":"Objeto con data (array concatenado), loading, error, has_more, load_more() y reset()"}


id: use_mutation_ts_core
name: use_mutation
kind: component
lang: ts
domain: core
version: 1.0.0
purity: impure
tags: ["mutation","hook","post","put","delete","http","optimistic","cache","core","component"]
uses_functions: ["http_cache_ts_core"]
uses_types: ["MutationState_ts_core"]
returns: []
error_type: error_go_core
imports: ["react"]
tests: []
file_path: frontend/functions/core/use_mutation.tsx
created_at: 2026-04-13T00:06:12Z
updated_at: 2026-04-13T00:06:12Z
emits: ["on_success","on_error","on_settled"]
framework: react
variant: ["default"]
content_hash: f5c449cc9e4c973e81eac897ecb61e5752a44400cb53a3f1b26f28231901e0e2
signature:
  useMutation<TData, TVariables>(opts: UseMutationOptions<TData, TVariables>): MutationState<TData, TVariables>

description:
  Hook POST/PUT/DELETE con optimistic updates, invalidacion de cache, retry y callbacks completos. No ejecuta al montar — expone mutate y mutate_async.

example:
  ```tsx
  import { useMutation } from '@/functions/core/use_mutation'
  import { apiClient } from '@/functions/core/api_client'

  const api = apiClient({ base_url: 'http://localhost:8484/api' })

  function DeleteButton({ userId }: { userId: string }) {
    const { mutate: deleteUser, loading } = useMutation<void, string>({
      mutation_fn: (id) => api.del(`/users/${id}`),
      invalidate_cache: ['users'],
      on_success: () => console.log('Eliminado'),
    })

    return (
      <button disabled={loading} onClick={() => deleteUser(userId)}>
        {loading ? 'Eliminando...' : 'Eliminar'}
      </button>
    )
  }
  ```

props:
  [{"name":"mutation_fn","type":"(variables: TVariables) =\u003e Promise\u003cTData\u003e","required":true,"description":"Funcion que ejecuta la mutacion"},{"name":"on_mutate","type":"(variables: TVariables) =\u003e Promise\u003cunknown\u003e | unknown","required":false,"description":"Optimistic update antes de la mutacion"},{"name":"on_success","type":"(data: TData, variables: TVariables, context: unknown) =\u003e void","required":false,"description":"Callback al exito"},{"name":"on_error","type":"(error: Error, variables: TVariables, context: unknown) =\u003e void","required":false,"description":"Callback al error (para rollback de optimistic updates)"},{"name":"invalidate_cache","type":"string[]","required":false,"description":"Prefijos de cache a invalidar en exito"},{"name":"retry","type":"number","required":false,"description":"Reintentos ante error (default 0)"},{"name":"retry_delay","type":"number","required":false,"description":"Ms entre reintentos (default 1000)"}]

notes:
  - `mutate` es fire-and-forget (no lanza, silencia el error)
  - `mutate_async` retorna Promise (puede rechazar — usar try/catch)
  - `invalidate_cache` usa `cacheInvalidatePrefix` — invalida prefijos jerarquicos

documentation:
  ## Optimistic update

  ```tsx
  const { mutate } = useMutation<User, Partial<User>>({
    mutation_fn: (data) => api.post('/users', data),
    on_mutate: (newUser) => {
      // Guardar snapshot para rollback
      const prev = cacheGet<User[]>('users')
      cacheSet('users', [...(prev ?? []), { ...newUser, id: 'temp' }])
      return { prev }
    },
    on_error: (_err, _vars, context) => {
      // Rollback
      const ctx = context as { prev: User[] }
      cacheSet('users', ctx.prev)
    },
  })
  ```

code:
  import { useCallback, useRef, useState } from 'react'
  import { cacheInvalidatePrefix } from './http_cache'
  import type { MutationState } from '../../types/core/mutation_state'

  export interface UseMutationOptions<TData, TVariables> {
    /** Funcion que ejecuta la mutacion (POST/PUT/DELETE) */
    mutation_fn: (variables: TVariables) => Promise<TData>
    /** Callback antes de la mutacion — permite optimistic updates. Retorna contexto de rollback. */
    on_mutate?: (variables: TVariables) => Promise<unknown> | unknown
    /** Callback al exito */
    on_success?: (data: TData, variables: TVariables, context: unknown) => void
    /** Callback al error */
    on_error?: (error: Error, variables: TVariables, context: unknown) => void
    /** Callback siempre (exito o error) */
    on_settled?: (data: TData | undefined, error: Error | null, variables: TVariables, context: unknown) => void
    /** Prefijos de cache a invalidar en exito (ej: ['users'] invalida users, users:list, etc.) */
    invalidate_cache?: string[]
    /** Numero de reintentos ante error (default 0) */
    retry?: number
    /** Delay en ms entre reintentos (default 1000) */
    retry_delay?: number
  }

  /**
   * Hook para operaciones de escritura POST/PUT/DELETE.
   * No ejecuta al montar. Expone mutate() y mutate_async().
   * Soporta optimistic updates via on_mutate y invalidacion de cache.
   */
  export function useMutation<TData, TVariables = void>(
    opts: UseMutationOptions<TData, TVariables>,
  ): MutationState<TData, TVariables> {
    const {
      mutation_fn,
      on_mutate,
      on_success,
      on_error,
      on_settled,
      invalidate_cache = [],
      retry = 0,
      retry_delay = 1000,
    } = opts

    type MutationInternalState = {
      status: 'idle' | 'loading' | 'success' | 'error'
      data: TData | null
      error: Error | null
    }

    const [state, setState] = useState<MutationInternalState>({ status: 'idle', data: null, error: null })

    const retryCountRef = useRef(0)

    const execute = useCallback(
      async (variables: TVariables): Promise<TData> => {
        setState((s: MutationInternalState) => ({ ...s, status: 'loading', error: null }))

        let context: unknown

        try {
          if (on_mutate) {
            context = await on_mutate(variables)
          }

          const data = await mutation_fn(variables)

          retryCountRef.current = 0
          setState({ status: 'success', data, error: null })

          // Invalidar cache en exito
          for (const prefix of invalidate_cache) {
            cacheInvalidatePrefix(prefix)
          }

          on_success?.(data, variables, context)
          on_settled?.(data, null, variables, context)

          return data
        } catch (err) {
          if (retryCountRef.current < retry) {
            retryCountRef.current += 1
            await new Promise((resolve) => setTimeout(resolve, retry_delay))
            return execute(variables)
          }

          const error = err instanceof Error ? err : new Error(String(err))
          setState((s: MutationInternalState) => ({ ...s, status: 'error', error }))

          on_error?.(error, variables, context)
          on_settled?.(undefined, error, variables, context)

          throw error
        }
      },
      [mutation_fn, on_mutate, on_success, on_error, on_settled, invalidate_cache, retry, retry_delay],
    )

    const mutate = useCallback(
      (variables: TVariables) => {
        execute(variables).catch(() => {})
      },
      [execute],
    )

    const reset = useCallback(() => {
      setState({ status: 'idle', data: null, error: null })
      retryCountRef.current = 0
    }, [])

    return {
      mutate,
      mutate_async: execute,
      data: state.data,
      error: state.error,
      loading: state.status === 'loading',
      reset,
    }
  }

params_schema:
  {"params":[{"name":"mutation_fn","desc":"Funcion que ejecuta la mutacion, recibe variables y retorna Promise\u003cTData\u003e"},{"name":"on_mutate","desc":"Callback antes de la mutacion para optimistic updates. Retorna contexto de rollback."},{"name":"on_success","desc":"Callback al exito con data, variables y context"},{"name":"on_error","desc":"Callback al error con error, variables y context para rollback"},{"name":"on_settled","desc":"Callback siempre (exito o error)"},{"name":"invalidate_cache","desc":"Prefijos de cache a invalidar en exito (ej: 'users' invalida users, users:list, etc.)"},{"name":"retry","desc":"Numero de reintentos ante error (default 0)"},{"name":"retry_delay","desc":"Delay en ms entre reintentos (default 1000)"}],"output":"MutationState\u003cTData, TVariables\u003e con mutate, mutate_async, data, error, loading y reset"}


id: use_sse_ts_core
name: use_sse
kind: component
lang: ts
domain: core
version: 1.0.0
purity: impure
signature: useSSE<T>(opts: UseSSEOptions<T>): UseSSEResult<T>
tags: ["sse","server-sent-events","stream","hook","realtime","reconnect","core","component"]
uses_functions: []
uses_types: []
returns: []
error_type: error_go_core
imports: ["react"]
tests: []
file_path: frontend/functions/core/use_sse.tsx
created_at: 2026-04-13T00:06:12Z
updated_at: 2026-04-13T00:06:12Z
emits: ["on_event","on_error"]
framework: react
variant: ["default"]
content_hash: 159288bd8baf905cee2574dd83c8270d1defc9010abbb45c914c1fc6b9c386fd
description:
  Hook para Server-Sent Events con auto-reconexion y backoff exponencial. Parsea eventos como JSON. Acumula todos los eventos en data[]. Expone last_event, status y close.

example:
  ```tsx
  import { useSSE } from '@/functions/core/use_sse'

  interface LogEntry { level: string; msg: string; ts: number }

  function LogViewer() {
    const { data: logs, last_event, status, close } = useSSE<LogEntry>({
      url: 'http://localhost:8484/api/logs/stream',
      reconnect: true,
      reconnect_interval: 2000,
    })

    return (
      <div>
        <span>Status: {status}</span>
        <div style={{ overflow: 'auto', height: 400 }}>
          {logs.map((log, i) => (
            <div key={i} style={{ color: log.level === 'error' ? 'red' : 'inherit' }}>
              [{log.level}] {log.msg}
            </div>
          ))}
        </div>
        <button onClick={close}>Cerrar conexion</button>
      </div>
    )
  }
  ```

props:
  [{"name":"url","type":"string","required":true,"description":"URL del endpoint SSE"},{"name":"enabled","type":"boolean","required":false,"description":"Conectar al montar (default true)"},{"name":"reconnect","type":"boolean","required":false,"description":"Auto-reconectar (default true)"},{"name":"reconnect_interval","type":"number","required":false,"description":"Delay inicial reconexion en ms (default 1000)"},{"name":"max_reconnect_interval","type":"number","required":false,"description":"Delay maximo reconexion en ms (default 30000)"},{"name":"event_name","type":"string","required":false,"description":"Nombre evento SSE (default 'message')"}]

notes:
  - El backoff exponencial duplica el delay hasta `max_reconnect_interval`
  - Si el evento no es JSON valido, se guarda como string
  - `close()` previene reconexion automatica
  - `connect()` fuerza reconexion manual aunque haya sido cerrado manualmente
  - `data` acumula todos los eventos de la sesion. Para limpiar, desmontar y remontar el componente.

code:
  import { useCallback, useEffect, useRef, useState } from 'react'

  export type SSEStatus = 'connecting' | 'open' | 'closed'

  export interface UseSSEOptions<T> {
    /** URL del endpoint SSE */
    url: string
    /** Si conecta automaticamente al montar (default true) */
    enabled?: boolean
    /** Si reconecta automaticamente al perder la conexion (default true) */
    reconnect?: boolean
    /** Delay inicial de reconexion en ms (default 1000). Se duplica con backoff hasta max_reconnect_interval. */
    reconnect_interval?: number
    /** Delay maximo de reconexion en ms (default 30000) */
    max_reconnect_interval?: number
    /** Nombre del evento a escuchar (default 'message'). Para multiples eventos usar on_event. */
    event_name?: string
    /** Callback por evento recibido. Recibe el dato parseado. */
    on_event?: (data: T) => void
    /** Callback al error */
    on_error?: (error: Event) => void
  }

  export interface UseSSEResult<T> {
    /** Todos los eventos recibidos en esta sesion */
    data: T[]
    /** Ultimo evento recibido */
    last_event: T | null
    /** Estado de la conexion */
    status: SSEStatus
    /** Error de conexion si ocurrio */
    error: Event | null
    /** Cerrar la conexion manualmente */
    close: () => void
    /** Reconectar manualmente */
    connect: () => void
  }

  /**
   * Hook para Server-Sent Events con auto-reconexion y backoff exponencial.
   * Parsea cada evento como JSON. Acumula todos los eventos en data[].
   */
  export function useSSE<T>(opts: UseSSEOptions<T>): UseSSEResult<T> {
    const {
      url,
      enabled = true,
      reconnect = true,
      reconnect_interval = 1000,
      max_reconnect_interval = 30_000,
      event_name = 'message',
      on_event,
      on_error,
    } = opts

    const [data, setData] = useState<T[]>([])
    const [lastEvent, setLastEvent] = useState<T | null>(null)
    const [status, setStatus] = useState<SSEStatus>('closed')
    const [error, setError] = useState<Event | null>(null)

    const esRef = useRef<EventSource | null>(null)
    const reconnectTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
    const currentIntervalRef = useRef(reconnect_interval)
    const mountedRef = useRef(true)
    const closedManuallyRef = useRef(false)

    const clearReconnectTimer = () => {
      if (reconnectTimerRef.current !== null) {
        clearTimeout(reconnectTimerRef.current)
        reconnectTimerRef.current = null
      }
    }

    const connect = useCallback(() => {
      if (!mountedRef.current) return

      // Cerrar conexion previa si existe
      if (esRef.current) {
        esRef.current.close()
        esRef.current = null
      }

      closedManuallyRef.current = false
      currentIntervalRef.current = reconnect_interval

      setStatus('connecting')
      setError(null)

      const es = new EventSource(url)
      esRef.current = es

      es.addEventListener(event_name, (e: MessageEvent) => {
        if (!mountedRef.current) return

        let parsed: T
        try {
          parsed = JSON.parse(e.data) as T
        } catch {
          parsed = e.data as unknown as T
        }

        setData((prev: T[]) => [...prev, parsed])
        setLastEvent(parsed)
        on_event?.(parsed)
        // Reset backoff on successful message
        currentIntervalRef.current = reconnect_interval
      })

      es.onopen = () => {
        if (!mountedRef.current) return
        setStatus('open')
        currentIntervalRef.current = reconnect_interval
      }

      es.onerror = (e) => {
        if (!mountedRef.current) return
        setError(e)
        on_error?.(e)
        setStatus('closed')
        es.close()
        esRef.current = null

        if (reconnect && !closedManuallyRef.current) {
          const delay = Math.min(currentIntervalRef.current, max_reconnect_interval)
          currentIntervalRef.current = Math.min(delay * 2, max_reconnect_interval)

          reconnectTimerRef.current = setTimeout(() => {
            if (mountedRef.current && !closedManuallyRef.current) {
              connect()
            }
          }, delay)
        }
      }
    }, [url, event_name, reconnect, reconnect_interval, max_reconnect_interval, on_event, on_error])

    const close = useCallback(() => {
      closedManuallyRef.current = true
      clearReconnectTimer()
      if (esRef.current) {
        esRef.current.close()
        esRef.current = null
      }
      setStatus('closed')
    }, [])

    useEffect(() => {
      mountedRef.current = true

      if (enabled) {
        connect()
      }

      return () => {
        mountedRef.current = false
        clearReconnectTimer()
        if (esRef.current) {
          esRef.current.close()
          esRef.current = null
        }
      }
    }, [enabled, url]) // eslint-disable-line react-hooks/exhaustive-deps

    return { data, last_event: lastEvent, status, error, close, connect }
  }

params_schema:
  {"params":[{"name":"url","desc":"URL del endpoint SSE (ej: http://localhost:8484/api/logs/stream)"},{"name":"enabled","desc":"Si conecta automaticamente al montar (default true)"},{"name":"reconnect","desc":"Si reconecta automaticamente con backoff exponencial (default true)"},{"name":"reconnect_interval","desc":"Delay inicial de reconexion en ms (default 1000). Se duplica hasta max_reconnect_interval."},{"name":"max_reconnect_interval","desc":"Delay maximo de reconexion en ms (default 30000)"},{"name":"event_name","desc":"Nombre del evento SSE a escuchar (default 'message')"},{"name":"on_event","desc":"Callback por cada evento recibido con el dato parseado"}],"output":"Objeto con data[], last_event, status (connecting/open/closed), error, close() y connect()"}


id: use_websocket_ts_core
name: use_websocket
kind: component
lang: ts
domain: core
version: 1.0.0
purity: impure
tags: ["websocket","ws","hook","realtime","reconnect","stream","core","component"]
uses_functions: []
uses_types: []
returns: []
error_type: error_go_core
imports: ["react"]
tests: []
file_path: frontend/functions/core/use_websocket.tsx
created_at: 2026-04-13T00:06:12Z
updated_at: 2026-04-13T00:06:12Z
emits: ["on_message","on_open","on_close","on_error"]
framework: react
variant: ["default"]
content_hash: 902bf5a137291f5429d0787180c93acf706a64a66a16f4c2c3d09ea9005c09fb
signature:
  useWebSocket<TSend, TRecv>(opts: UseWebSocketOptions<TSend, TRecv>): UseWebSocketResult<TSend, TRecv>

description:
  Hook WebSocket con auto-reconexion y backoff exponencial. Serializa envios como JSON, parsea recepcion como JSON. Buffer de mensajes con limite configurable. Mensajes en vuelo se bufferean hasta reconectar.

example:
  ```tsx
  import { useWebSocket } from '@/functions/core/use_websocket'

  interface ChatMessage { user: string; text: string; ts: number }
  interface SendMessage { text: string }

  function Chat() {
    const { send, messages, status } = useWebSocket<SendMessage, ChatMessage>({
      url: 'ws://localhost:8484/ws/chat',
      reconnect: true,
      max_messages: 50,
    })

    const [input, setInput] = useState('')

    return (
      <div>
        <div>Status: {status}</div>
        <div style={{ height: 300, overflow: 'auto' }}>
          {messages.map((msg, i) => (
            <div key={i}><b>{msg.user}:</b> {msg.text}</div>
          ))}
        </div>
        <input
          value={input}
          disabled={status !== 'open'}
          onChange={(e) => setInput(e.currentTarget.value)}
          onKeyDown={(e) => {
            if (e.key === 'Enter' && input.trim()) {
              send({ text: input })
              setInput('')
            }
          }}
        />
      </div>
    )
  }
  ```

props:
  [{"name":"url","type":"string","required":true,"description":"URL del WebSocket"},{"name":"enabled","type":"boolean","required":false,"description":"Conectar al montar (default true)"},{"name":"reconnect","type":"boolean","required":false,"description":"Auto-reconectar (default true)"},{"name":"reconnect_interval","type":"number","required":false,"description":"Delay inicial reconexion en ms (default 1000)"},{"name":"max_reconnect_interval","type":"number","required":false,"description":"Delay maximo reconexion en ms (default 30000)"},{"name":"max_messages","type":"number","required":false,"description":"Tamano del buffer de mensajes (default 100)"}]

notes:
  - Genericos separados: `TSend` para envio, `TRecv` para recepcion (pueden diferir)
  - Mensajes enviados antes de `open` se bufferean y envian al conectar
  - El buffer `messages` se limita a `max_messages` (FIFO, elimina el mas viejo)
  - El backoff exponencial duplica el delay hasta `max_reconnect_interval`
  - `close()` previene reconexion automatica; `connect()` fuerza reconexion manual

code:
  import { useCallback, useEffect, useRef, useState } from 'react'

  export type WebSocketStatus = 'connecting' | 'open' | 'closing' | 'closed'

  export interface UseWebSocketOptions<TRecv> {
    /** URL del WebSocket (ej: ws://localhost:8484/ws/chat) */
    url: string
    /** Si conecta automaticamente al montar (default true) */
    enabled?: boolean
    /** Si reconecta automaticamente al perder la conexion (default true) */
    reconnect?: boolean
    /** Delay inicial de reconexion en ms (default 1000) */
    reconnect_interval?: number
    /** Delay maximo de reconexion en ms (default 30000) */
    max_reconnect_interval?: number
    /** Numero maximo de mensajes a mantener en el buffer (default 100) */
    max_messages?: number
    /** Callback por cada mensaje recibido */
    on_message?: (data: TRecv) => void
    /** Callback al conectar */
    on_open?: () => void
    /** Callback al cerrar */
    on_close?: (event: CloseEvent) => void
    /** Callback al error */
    on_error?: (error: Event) => void
  }

  export interface UseWebSocketResult<TSend, TRecv> {
    /** Enviar un mensaje (serializado como JSON) */
    send: (data: TSend) => void
    /** Ultimo mensaje recibido */
    last_message: TRecv | null
    /** Buffer de mensajes recibidos (limitado a max_messages) */
    messages: TRecv[]
    /** Estado de la conexion */
    status: WebSocketStatus
    /** Cerrar la conexion manualmente */
    close: () => void
    /** Reconectar manualmente */
    connect: () => void
  }

  /**
   * Hook WebSocket con auto-reconexion y backoff exponencial.
   * Serializa mensajes enviados como JSON. Parsea mensajes recibidos como JSON.
   * Mantiene buffer de los ultimos N mensajes.
   */
  export function useWebSocket<TSend, TRecv>(
    opts: UseWebSocketOptions<TRecv>,
  ): UseWebSocketResult<TSend, TRecv> {
    const {
      url,
      enabled = true,
      reconnect = true,
      reconnect_interval = 1000,
      max_reconnect_interval = 30_000,
      max_messages = 100,
      on_message,
      on_open,
      on_close,
      on_error,
    } = opts

    const [messages, setMessages] = useState<TRecv[]>([])
    const [lastMessage, setLastMessage] = useState<TRecv | null>(null)
    const [status, setStatus] = useState<WebSocketStatus>('closed')

    const wsRef = useRef<WebSocket | null>(null)
    const reconnectTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
    const currentIntervalRef = useRef(reconnect_interval)
    const mountedRef = useRef(true)
    const closedManuallyRef = useRef(false)
    const pendingMessages = useRef<TSend[]>([])

    const clearReconnectTimer = () => {
      if (reconnectTimerRef.current !== null) {
        clearTimeout(reconnectTimerRef.current)
        reconnectTimerRef.current = null
      }
    }

    const connect = useCallback(() => {
      if (!mountedRef.current) return

      if (wsRef.current) {
        wsRef.current.close()
        wsRef.current = null
      }

      closedManuallyRef.current = false
      currentIntervalRef.current = reconnect_interval

      setStatus('connecting')

      const ws = new WebSocket(url)
      wsRef.current = ws

      ws.onopen = () => {
        if (!mountedRef.current) return
        setStatus('open')
        currentIntervalRef.current = reconnect_interval
        on_open?.()

        // Enviar mensajes pendientes
        for (const msg of pendingMessages.current) {
          ws.send(JSON.stringify(msg))
        }
        pendingMessages.current = []
      }

      ws.onmessage = (e: MessageEvent) => {
        if (!mountedRef.current) return

        let parsed: TRecv
        try {
          parsed = JSON.parse(e.data as string) as TRecv
        } catch {
          parsed = e.data as unknown as TRecv
        }

        setLastMessage(parsed)
        setMessages((prev: TRecv[]) => {
          const next = [...prev, parsed]
          return next.length > max_messages ? next.slice(next.length - max_messages) : next
        })
        on_message?.(parsed)
      }

      ws.onclose = (e) => {
        if (!mountedRef.current) return
        setStatus('closed')
        wsRef.current = null
        on_close?.(e)

        if (reconnect && !closedManuallyRef.current) {
          const delay = Math.min(currentIntervalRef.current, max_reconnect_interval)
          currentIntervalRef.current = Math.min(delay * 2, max_reconnect_interval)

          reconnectTimerRef.current = setTimeout(() => {
            if (mountedRef.current && !closedManuallyRef.current) {
              connect()
            }
          }, delay)
        }
      }

      ws.onerror = (e) => {
        if (!mountedRef.current) return
        on_error?.(e)
      }
    }, [url, reconnect, reconnect_interval, max_reconnect_interval, max_messages, on_message, on_open, on_close, on_error])

    const close = useCallback(() => {
      closedManuallyRef.current = true
      clearReconnectTimer()
      if (wsRef.current) {
        setStatus('closing')
        wsRef.current.close()
        wsRef.current = null
      }
      setStatus('closed')
    }, [])

    const send = useCallback((data: TSend) => {
      if (wsRef.current?.readyState === WebSocket.OPEN) {
        wsRef.current.send(JSON.stringify(data))
      } else {
        // Buffer el mensaje para enviarlo cuando conecte
        pendingMessages.current.push(data)
      }
    }, [])

    useEffect(() => {
      mountedRef.current = true

      if (enabled) {
        connect()
      }

      return () => {
        mountedRef.current = false
        clearReconnectTimer()
        if (wsRef.current) {
          wsRef.current.close()
          wsRef.current = null
        }
      }
    }, [enabled, url]) // eslint-disable-line react-hooks/exhaustive-deps

    return {
      send,
      last_message: lastMessage,
      messages,
      status,
      close,
      connect,
    }
  }

params_schema:
  {"params":[{"name":"url","desc":"URL del WebSocket (ej: ws://localhost:8484/ws/chat)"},{"name":"enabled","desc":"Si conecta automaticamente al montar (default true)"},{"name":"reconnect","desc":"Si reconecta automaticamente con backoff exponencial (default true)"},{"name":"reconnect_interval","desc":"Delay inicial de reconexion en ms (default 1000)"},{"name":"max_reconnect_interval","desc":"Delay maximo de reconexion en ms (default 30000)"},{"name":"max_messages","desc":"Numero maximo de mensajes a mantener en buffer (default 100)"},{"name":"on_message","desc":"Callback por cada mensaje recibido con el dato parseado"}],"output":"Objeto con send, last_message, messages[], status (connecting/open/closing/closed), close() y connect()"}


id: wails_cache_ts_core
name: wails_cache
kind: function
lang: ts
domain: core
version: 1.0.0
purity: pure
tags: ["wails","cache","ipc","reactive","state"]
uses_functions: []
uses_types: []
returns: []
imports: []
tests: []
file_path: frontend/functions/core/wails_cache.ts
created_at: 2026-04-05T11:22:32Z
updated_at: 2026-04-05T16:40:39Z
props: []
emits: []
variant: []
content_hash: fcdbc7db041c22eb5b51c3e6ae938454bd0fc18e98e1d8261b4b85b66442996f
source_repo: https://gitea-dgg044oo04woo4ggcsws4gk0.organic-machine.com/Bl4cksmith/Frontend_Library
source_license: MIT
source_file: frontend/src/lib/wails/cache.ts
signature:
  class WailsCache { get<T>(key: string[]): T | null; set<T>(key: string[], data: T): void; invalidate(key: string[]): void; subscribe(key: string[], cb: () => void): () => void }

description:
  Cache reactivo para IPC Wails con invalidación por prefijo, suscripción a cambios y tracking de staleness. Singleton global.

example:
  ```typescript
  import { wailsCache } from './wails_cache'

  wailsCache.set(['users', '123'], userData)
  const user = wailsCache.get<User>(['users', '123'])
  wailsCache.invalidate(['users']) // invalida users:*
  const unsub = wailsCache.subscribe(['users'], () => console.log('changed'))
  ```

notes:
  Key como string[] permite invalidación jerárquica: `invalidate(['users'])` invalida `users`, `users:123`, `users:456`, etc.

code:
  interface CacheEntry {
    data: unknown
    timestamp: Date
  }

  export class WailsCache {
    private cache = new Map<string, CacheEntry>()
    private subscribers = new Map<string, Set<() => void>>()

    /** Generar key string desde array */
    private getKey(queryKey: string[]): string {
      return queryKey.join(':')
    }

    /** Obtener dato del cache */
    get<T>(queryKey: string[]): T | null {
      const entry = this.cache.get(this.getKey(queryKey))
      return (entry?.data as T) ?? null
    }

    /** Guardar dato en cache */
    set<T>(queryKey: string[], data: T): void {
      const key = this.getKey(queryKey)
      this.cache.set(key, { data, timestamp: new Date() })
      this.notifySubscribers(key)
    }

    /** Verificar si existe en cache */
    has(queryKey: string[]): boolean {
      return this.cache.has(this.getKey(queryKey))
    }

    /** Obtener timestamp de última actualización */
    getTimestamp(queryKey: string[]): Date | null {
      const entry = this.cache.get(this.getKey(queryKey))
      return entry?.timestamp ?? null
    }

    /** Verificar si los datos están stale */
    isStale(queryKey: string[], staleTime: number): boolean {
      const entry = this.cache.get(this.getKey(queryKey))
      if (!entry) return true
      return Date.now() - entry.timestamp.getTime() > staleTime
    }

    /** Invalidar cache (esta key y todas las que empiezan igual) */
    invalidate(queryKey: string[]): void {
      const prefix = this.getKey(queryKey)
      const keysToDelete: string[] = []

      for (const key of this.cache.keys()) {
        if (key === prefix || key.startsWith(prefix + ':')) {
          keysToDelete.push(key)
        }
      }

      keysToDelete.forEach((key) => {
        this.cache.delete(key)
        this.notifySubscribers(key)
      })
    }

    /** Limpiar todo el cache */
    clear(): void {
      this.cache.clear()
      this.subscribers.forEach((_, key) => this.notifySubscribers(key))
    }

    /** Subscribirse a cambios en una key */
    subscribe(queryKey: string[], callback: () => void): () => void {
      const key = this.getKey(queryKey)
      if (!this.subscribers.has(key)) {
        this.subscribers.set(key, new Set())
      }
      this.subscribers.get(key)!.add(callback)

      return () => {
        this.subscribers.get(key)?.delete(callback)
      }
    }

    /** Notificar a subscribers */
    private notifySubscribers(key: string): void {
      this.subscribers.get(key)?.forEach((callback) => callback())
    }

    /** Obtener tamaño del cache */
    get size(): number {
      return this.cache.size
    }

    /** Obtener todas las keys */
    keys(): string[] {
      return Array.from(this.cache.keys())
    }
  }

  // Singleton global
  export const wailsCache = new WailsCache()

params_schema:
  {"output":"Clase WailsCache con métodos get, set, invalidate, subscribe para gestionar caché reactiva con invalidación jerárquica"}


id: accordion_ts_ui
name: accordion
kind: component
lang: ts
domain: ui
version: 1.0.0
purity: impure
signature: Accordion(props: AccordionProps): JSX.Element
tags: ["accordion","collapsible","component","ui","interactive","mantine"]
uses_functions: []
uses_types: []
returns: []
imports: ["@mantine/core"]
tests: []
file_path: frontend/functions/ui/accordion.tsx
created_at: 2026-04-05T11:22:32Z
updated_at: 2026-04-06T00:00:55Z
emits: []
framework: react
variant: []
content_hash: c7855b6b83792072a0da54136b86c658417fcd87c40c71e52e937647b3f40b6c
description:
  Secciones colapsables con animaciones. Mantine Accordion. Composable: AccordionItem + AccordionTrigger + AccordionContent.

example:
  ```tsx
  <Accordion defaultValue="section-1">
    <AccordionItem value="section-1">
      <AccordionTrigger>Seccion 1</AccordionTrigger>
      <AccordionContent>
        Contenido de la primera seccion.
      </AccordionContent>
    </AccordionItem>
    <AccordionItem value="section-2">
      <AccordionTrigger>Seccion 2</AccordionTrigger>
      <AccordionContent>
        Contenido de la segunda seccion.
      </AccordionContent>
    </AccordionItem>
  </Accordion>
  ```

props:
  [{"name":"className","type":"string","required":false,"description":"Clases CSS adicionales para el contenedor"}]

notes:
  Usa Mantine Accordion nativo. Soporta type single (default) y multiple para multiples items abiertos. El chevron se maneja automaticamente por Mantine. AccordionItem requiere prop value unico. Exports: Accordion, AccordionItem, AccordionTrigger, AccordionContent.

code:
  import * as React from "react"
  import { Accordion as MantineAccordion } from "@mantine/core"

  interface AccordionItem {
    value: string
    trigger: React.ReactNode
    content: React.ReactNode
    disabled?: boolean
  }

  interface AccordionProps {
    items?: AccordionItem[]
    type?: "single" | "multiple"
    defaultValue?: string | string[]
    className?: string
    itemClassName?: string
    children?: React.ReactNode
  }

  function Accordion({ className, type, defaultValue, children }: AccordionProps) {
    if (type === "multiple") {
      return (
        <MantineAccordion
          multiple
          data-slot="accordion"
          className={className}
          defaultValue={Array.isArray(defaultValue) ? defaultValue : undefined}
        >
          {children}
        </MantineAccordion>
      )
    }

    return (
      <MantineAccordion
        data-slot="accordion"
        className={className}
        defaultValue={typeof defaultValue === "string" ? defaultValue : undefined}
      >
        {children}
      </MantineAccordion>
    )
  }

  interface AccordionItemProps {
    value: string
    className?: string
    children?: React.ReactNode
    disabled?: boolean
  }

  function AccordionItem({ className, value, children, ...props }: AccordionItemProps) {
    return (
      <MantineAccordion.Item
        data-slot="accordion-item"
        value={value}
        className={className}
        {...props}
      >
        {children}
      </MantineAccordion.Item>
    )
  }

  function AccordionTrigger({ className, children, ...props }: { className?: string; children?: React.ReactNode }) {
    return (
      <MantineAccordion.Control
        data-slot="accordion-trigger"
        className={className}
        {...props}
      >
        {children}
      </MantineAccordion.Control>
    )
  }

  function AccordionContent({ className, children, ...props }: { className?: string; children?: React.ReactNode }) {
    return (
      <MantineAccordion.Panel
        data-slot="accordion-content"
        className={className}
        {...props}
      >
        {children}
      </MantineAccordion.Panel>
    )
  }

  export { Accordion, AccordionContent, AccordionItem, AccordionTrigger }
  export type { AccordionItem as AccordionItemData, AccordionProps }

params_schema:
  {"output":"Componente Accordion que renderiza secciones colapsables con soporte para múltiples items abiertos simultáneamente"}


id: action_icon_ts_ui
name: action_icon
kind: component
lang: ts
domain: ui
version: 1.0.0
purity: impure
signature: FnActionIcon(props: FnActionIconProps): JSX.Element
description: Boton de icono con variantes, loading y tooltip opcional. Wrapper sobre Mantine ActionIcon.
tags: ["mantine","button","icon","action","tooltip","component","ui"]
uses_functions: []
uses_types: []
returns: []
imports: ["@mantine/core"]
tests: []
file_path: frontend/functions/ui/action_icon.tsx
created_at: 2026-04-06T00:11:54Z
updated_at: 2026-04-06T00:11:54Z
emits: []
framework: react
variant: ["filled","light","outline","transparent","default","subtle"]
content_hash: 36e5b0e140513f01a20889f2f6ad5155549b3423fc1e650a8272967f820d3d9e
params_schema: {"output":"Boton de icono con tooltip opcional, estados loading/disabled y multiples variantes"}
example:
  ```tsx
  import { FnActionIcon } from '@fn_library'
  import { IconSettings } from '@tabler/icons-react'

  <FnActionIcon
    icon={<IconSettings size={18} />}
    tooltip="Configuracion"
    variant="light"
    onClick={() => openSettings()}
  />
  ```

props:
  [{"name":"icon","type":"ReactNode","required":true,"description":"Icono a renderizar dentro del boton"},{"name":"variant","type":"'filled' | 'light' | 'outline' | 'transparent' | 'default' | 'subtle'","required":false,"description":"Variante visual del boton, default 'default'"},{"name":"size","type":"MantineSize | number","required":false,"description":"Tamano del boton"},{"name":"color","type":"MantineColor","required":false,"description":"Color del boton"},{"name":"onClick","type":"MouseEventHandler","required":false,"description":"Callback al hacer click"},{"name":"loading","type":"boolean","required":false,"description":"Muestra spinner de carga"},{"name":"disabled","type":"boolean","required":false,"description":"Deshabilita el boton"},{"name":"tooltip","type":"string","required":false,"description":"Si se provee, envuelve el boton en un Tooltip"}]

notes:
  Wrapper sobre Mantine `ActionIcon`. Si se provee `tooltip`, envuelve automaticamente en Mantine `Tooltip`. Compatible con iconos de `@tabler/icons-react`.

code:
  import * as React from 'react'
  import { ActionIcon, Tooltip } from '@mantine/core'
  import type { MantineSize, MantineColor } from '@mantine/core'

  interface FnActionIconProps {
    icon: React.ReactNode
    variant?: 'filled' | 'light' | 'outline' | 'transparent' | 'default' | 'subtle'
    size?: MantineSize | number
    color?: MantineColor
    onClick?: React.MouseEventHandler<HTMLButtonElement>
    loading?: boolean
    disabled?: boolean
    tooltip?: string
  }

  function FnActionIcon({
    icon,
    variant = 'default',
    size = 'md',
    color,
    onClick,
    loading,
    disabled,
    tooltip,
  }: FnActionIconProps) {
    const button = (
      <ActionIcon
        variant={variant}
        size={size}
        color={color}
        onClick={onClick}
        loading={loading}
        disabled={disabled}
      >
        {icon}
      </ActionIcon>
    )

    if (tooltip) {
      return <Tooltip label={tooltip}>{button}</Tooltip>
    }

    return button
  }

  export { FnActionIcon }
  export type { FnActionIconProps }


id: alert_ts_ui
name: alert
kind: component
lang: ts
domain: ui
version: 1.0.0
purity: impure
signature: Alert(props: { variant?: 'default' | 'destructive' }): JSX.Element
tags: ["alert","feedback","component","ui","notification","mantine"]
uses_functions: []
uses_types: []
returns: []
imports: ["@mantine/core","react"]
tests: []
file_path: frontend/functions/ui/alert.tsx
created_at: 2026-04-05T11:22:32Z
updated_at: 2026-04-06T00:00:55Z
emits: []
framework: react
variant: ["default","destructive"]
content_hash: 42f8563e2dfa14e41cf1f0d9c0547ebd0ea8e47ee4d1d026c1e1a57e1849a464
source_repo: https://gitea-dgg044oo04woo4ggcsws4gk0.organic-machine.com/Bl4cksmith/Frontend_Library
source_license: MIT
source_file: frontend/src/components/ui/alert.tsx
description:
  Alerta accesible con variantes default y destructive. Mantine Alert con slots para título, descripción y acción.

example:
  ```tsx
  <Alert variant="destructive">
    <AlertCircle />
    <AlertTitle>Error</AlertTitle>
    <AlertDescription>Something went wrong.</AlertDescription>
  </Alert>
  ```

props:
  [{"name":"variant","type":"'default' | 'destructive'","required":false,"description":"Variante visual"}]

notes:
  Exporta 4 subcomponentes composables via data-slot: Alert, AlertTitle, AlertDescription, AlertAction.
  AlertAction se posiciona absolute top-right para acciones secundarias (ej: boton cerrar).
  alertVariants se exporta como objeto vacio por compatibilidad (Mantine gestiona variantes via color prop).

code:
  import * as React from 'react'
  import { Alert as MantineAlert, Box, Text } from '@mantine/core'

  type AlertVariant = 'default' | 'destructive'

  const variantColorMap: Record<AlertVariant, string | undefined> = {
    default: undefined,
    destructive: 'red',
  }

  function Alert({
    className,
    variant = 'default',
    children,
    ...props
  }: React.ComponentProps<'div'> & { variant?: AlertVariant }) {
    return (
      <MantineAlert
        data-slot="alert"
        color={variantColorMap[variant]}
        radius="md"
        variant="light"
        className={className}
        {...props}
      >
        {children}
      </MantineAlert>
    )
  }

  function AlertTitle({ className, ...props }: React.ComponentProps<'div'>) {
    return (
      <Text
        component="div"
        data-slot="alert-title"
        fw={500}
        size="sm"
        className={className}
        {...props}
      />
    )
  }

  function AlertDescription({ className, ...props }: React.ComponentProps<'div'>) {
    return (
      <Text
        component="div"
        data-slot="alert-description"
        size="sm"
        c="dimmed"
        className={className}
        {...props}
      />
    )
  }

  function AlertAction({ className, style, ...props }: React.ComponentProps<'div'>) {
    return (
      <Box
        data-slot="alert-action"
        style={{ position: 'absolute', top: 'var(--mantine-spacing-xs)', right: 'var(--mantine-spacing-xs)', ...style }}
        className={className}
        {...props}
      />
    )
  }

  const alertVariants = {} as const

  export { Alert, AlertTitle, AlertDescription, AlertAction, alertVariants }

params_schema:
  {"output":"Componente Alert que renderiza una alerta accesible via Mantine Alert con slots para título, descripción y acción"}


id: analytics_page_ts_ui
name: analytics_page
kind: function
lang: ts
domain: ui
version: 1.0.0
purity: pure
signature: analyticsPage(props: AnalyticsPageProps): ReactElement
tags: ["analytics","dashboard","kpi","charts","factory","composition","ui"]
uses_functions: []
uses_types: []
returns: []
imports: ["react","@mantine/core"]
tests: []
file_path: frontend/functions/ui/analytics_page.tsx
created_at: 2026-04-05T11:22:32Z
updated_at: 2026-04-06T00:11:54Z
props: []
emits: []
variant: []
content_hash: db4d028b6614888ae01a720ba3e9f313e9cd1c49c3dcb4dd09d37cbae2b1892f
source_repo: https://gitea-dgg044oo04woo4ggcsws4gk0.organic-machine.com/Bl4cksmith/Frontend_Library
source_license: MIT
description:
  Genera un dashboard de analytics completo con header, fila de KPIs con deltas y grid de charts configurables.

example:
  ```tsx
  analyticsPage({
    title: 'Sales Analytics',
    metrics: [
      { label: 'Revenue', value: '$124,500', delta: { value: 12.5, isPositive: true } },
      { label: 'Orders', value: '1,234', delta: { value: -3.2, isPositive: false } },
      { label: 'Avg Order', value: '$101', delta: { value: 0, isPositive: true } },
      { label: 'Customers', value: '892' },
    ],
    charts: [
      { id: 'revenue', title: 'Revenue Over Time', type: 'area', span: 2, content: <AreaChart data={revenueData} xKey="month" yKey="revenue" /> },
      { id: 'orders', title: 'Orders by Category', type: 'bar', content: <BarChart data={orderData} xKey="category" yKey="count" /> },
      { id: 'trends', title: 'Customer Trends', type: 'line', content: <LineChart data={trendData} xKey="week" yKey="customers" /> },
    ],
  })
  ```

notes:
  Layout inteligente: los KPIs se ajustan automáticamente a 2/3/4 columnas según cantidad. Los charts soportan span para ancho completo.

code:
  import * as React from 'react'
  import { Stack, Group, Title, Text, Paper, SimpleGrid } from '@mantine/core'

  interface MetricConfig {
    label: string
    value: string | number
    delta?: { value: number; isPositive: boolean }
    sparklineData?: number[]
  }

  interface ChartConfig {
    id: string
    title: string
    type: 'line' | 'bar' | 'area'
    span?: 1 | 2
    height?: number
    content: React.ReactNode
  }

  interface AnalyticsPageProps {
    title: string
    subtitle?: string
    dateRange?: React.ReactNode
    metrics: MetricConfig[]
    charts: ChartConfig[]
    actions?: React.ReactNode
    className?: string
  }

  export function analyticsPage({
    title,
    subtitle,
    dateRange,
    metrics,
    charts,
    actions,
  }: AnalyticsPageProps): React.ReactElement {
    const metricCols = metrics.length <= 2 ? { base: 1, md: 2 } : metrics.length <= 3 ? { base: 1, md: 3 } : { base: 1, md: 2, lg: 4 }

    return (
      <Stack gap="lg">
        {/* Header */}
        <Group justify="space-between" pb="md" style={{ borderBottom: '1px solid var(--mantine-color-default-border)' }}>
          <Stack gap={4}>
            <Title order={2}>{title}</Title>
            {subtitle && <Text size="sm" c="dimmed">{subtitle}</Text>}
          </Stack>
          <Group gap="xs">
            {dateRange}
            {actions}
          </Group>
        </Group>

        {/* KPI Row */}
        <SimpleGrid cols={metricCols} spacing="md">
          {metrics.map((metric, i) => (
            <Paper key={i} p="md" withBorder shadow="xs">
              <Text size="sm" c="dimmed">{metric.label}</Text>
              <Group mt="xs" justify="space-between" align="flex-end" gap="md">
                <Stack gap={4}>
                  <Text fz={30} fw={700} lh={1}>{metric.value}</Text>
                  {metric.delta && (
                    <Text
                      size="sm"
                      fw={500}
                      c={metric.delta.value === 0 ? 'dimmed' : metric.delta.isPositive ? 'green' : 'red'}
                    >
                      {metric.delta.value > 0 ? '+' : ''}{metric.delta.value}%
                    </Text>
                  )}
                </Stack>
              </Group>
            </Paper>
          ))}
        </SimpleGrid>

        {/* Charts Grid */}
        <SimpleGrid cols={{ base: 1, lg: 2 }} spacing="md">
          {charts.map((chart) => (
            <Paper
              key={chart.id}
              p="md"
              withBorder
              shadow="xs"
              radius="md"
              style={chart.span === 2 ? { gridColumn: 'span 2' } : undefined}
            >
              <Text size="sm" fw={500} c="dimmed" mb="sm">{chart.title}</Text>
              {chart.content}
            </Paper>
          ))}
        </SimpleGrid>
      </Stack>
    )
  }

  export type { AnalyticsPageProps, MetricConfig, ChartConfig }

params_schema:
  {"params":[{"name":"props","desc":"Configuración del dashboard: título, métricas con deltas, y lista de charts con span"}],"output":"Componente ReactElement que renderiza un dashboard de analytics completo con header, KPIs y grid de charts"}


id: app_shell_ts_ui
name: app_shell
kind: component
lang: ts
domain: ui
version: 1.0.0
purity: impure
signature: FnAppShell(props: FnAppShellProps): JSX.Element
description: Layout shell con header, navbar colapsable y area principal. Wrapper sobre Mantine AppShell.
tags: ["mantine","layout","shell","navigation","component","ui"]
uses_functions: []
uses_types: []
returns: []
imports: ["@mantine/core"]
tests: []
file_path: frontend/functions/ui/app_shell.tsx
created_at: 2026-04-06T00:11:54Z
updated_at: 2026-04-06T00:11:54Z
emits: []
framework: react
variant: []
content_hash: cfece7820c7408a5cab19f4bc447b9b77f9ebc02b222e41eafd0d220ec5bc2cc
params_schema: {"output":"Layout de aplicacion con header fijo, sidebar colapsable y area de contenido principal"}
example:
  ```tsx
  import { FnAppShell } from '@fn_library'

  <FnAppShell
    header={<Group px="md">Logo</Group>}
    navbar={<NavLinks />}
    navbarCollapsed={collapsed}
  >
    <MainContent />
  </FnAppShell>
  ```

props:
  [{"name":"header","type":"ReactNode","required":false,"description":"Contenido del header superior"},{"name":"navbar","type":"ReactNode","required":false,"description":"Contenido del sidebar de navegacion"},{"name":"navbarWidth","type":"number","required":false,"description":"Ancho del navbar en px, default 250"},{"name":"navbarCollapsed","type":"boolean","required":false,"description":"Si el navbar esta colapsado"},{"name":"children","type":"ReactNode","required":true,"description":"Contenido principal del area main"}]

notes:
  Wrapper sobre Mantine `AppShell`. El header tiene altura fija de 60px. El navbar colapsa tanto en mobile como en desktop cuando `navbarCollapsed` es true. El breakpoint de responsive es `sm`.

code:
  import * as React from 'react'
  import { AppShell } from '@mantine/core'

  interface FnAppShellProps {
    header?: React.ReactNode
    navbar?: React.ReactNode
    navbarWidth?: number
    navbarCollapsed?: boolean
    children: React.ReactNode
  }

  function FnAppShell({
    header,
    navbar,
    navbarWidth = 250,
    navbarCollapsed = false,
    children,
  }: FnAppShellProps) {
    return (
      <AppShell
        header={header ? { height: 60 } : undefined}
        navbar={navbar ? { width: navbarWidth, breakpoint: 'sm', collapsed: { mobile: navbarCollapsed, desktop: navbarCollapsed } } : undefined}
        padding="md"
      >
        {header && <AppShell.Header>{header}</AppShell.Header>}
        {navbar && <AppShell.Navbar p="md">{navbar}</AppShell.Navbar>}
        <AppShell.Main>{children}</AppShell.Main>
      </AppShell>
    )
  }

  export { FnAppShell }
  export type { FnAppShellProps }


id: area_chart_ts_ui
name: area_chart
kind: component
lang: ts
domain: ui
version: 1.0.0
purity: impure
signature: AreaChart(props: AreaChartProps): JSX.Element
description: Gráfico de área @mantine/charts con gradientes automáticos, multi-series, stacking y tooltips.
tags: ["chart","area","visualization","mantine","gradient","component","ui"]
uses_functions: ["chart_container_ts_ui"]
uses_types: ["ChartSeries_ts_ui"]
returns: []
imports: ["@mantine/charts","@mantine/core"]
tests: []
file_path: frontend/functions/ui/area_chart.tsx
created_at: 2026-04-05T11:22:32Z
updated_at: 2026-04-06T00:11:54Z
emits: []
framework: react
variant: ["default","stacked"]
content_hash: 38fd018a587009fb4d1b4e11897d43f0440076631d5252d36fd9b4c763d0f0ea
source_repo: https://gitea-dgg044oo04woo4ggcsws4gk0.organic-machine.com/Bl4cksmith/Frontend_Library
source_license: MIT
source_file: frontend/src/components/ui/charts/area-chart.tsx
example:
  ```tsx
  <AreaChart data={data} xKey="date" yKey="revenue" gradient />
  <AreaChart data={data} xKey="date" series={series} stacked showLegend />
  ```

props:
  [{"name":"data","type":"Record\u003cstring, unknown\u003e[]","required":true,"description":"Array de datos"},{"name":"xKey","type":"string","required":true,"description":"Key del eje X"},{"name":"stacked","type":"boolean","required":false,"description":"Apilar áreas"},{"name":"gradient","type":"GradientConfig | boolean","required":false,"description":"Gradiente (true por defecto)"},{"name":"series","type":"Series[]","required":false,"description":"Series de datos para multi-series"}]

code:
  import { AreaChart as MantineAreaChart } from '@mantine/charts'
  import { Paper } from '@mantine/core'
  import { type Series, getSeriesColor } from './chart_container'

  interface AreaChartProps {
    data: Record<string, unknown>[]
    xKey: string
    yKey?: string
    series?: Series[]
    stacked?: boolean
    gradient?: boolean
    showGrid?: boolean
    showLegend?: boolean
    height?: number
    xAxisFormatter?: (value: unknown) => string
    yAxisFormatter?: (value: unknown) => string
    valueFormatter?: (value: number) => string
  }

  function AreaChartComponent({
    data, xKey, yKey, series, stacked = false, gradient = true, showGrid = true,
    showLegend = false, height = 300, xAxisFormatter, yAxisFormatter,
    valueFormatter = (v) => v.toLocaleString(),
  }: AreaChartProps) {
    const chartSeries = series
      ? series.map((s, i) => ({ name: s.key, label: s.name, color: getSeriesColor(i, s.color) }))
      : yKey ? [{ name: yKey, label: yKey, color: getSeriesColor(0) }] : []

    return (
      <Paper p="md">
        <MantineAreaChart
          h={height}
          data={data}
          dataKey={xKey}
          series={chartSeries}
          type={stacked ? 'stacked' : 'default'}
          curveType="monotone"
          withGradient={gradient}
          gridAxis={showGrid ? 'xy' : 'none'}
          withLegend={showLegend}
          withTooltip
          valueFormatter={valueFormatter}
          xAxisProps={xAxisFormatter ? { tickFormatter: xAxisFormatter } : undefined}
          yAxisProps={yAxisFormatter ? { tickFormatter: yAxisFormatter } : undefined}
        />
      </Paper>
    )
  }

  /** @deprecated Gradient is handled by Mantine's withGradient prop */
  type GradientConfig = { from: string; to: string }

  export const AreaChart = AreaChartComponent
  export type { AreaChartProps, GradientConfig }

params_schema:
  {"output":"Componente JSX que renderiza un gráfico de área con gradientes, multi-series y tooltips"}


id: auth_form_ts_ui
name: auth_form
kind: component
lang: ts
domain: ui
version: 1.0.0
purity: impure
signature: AuthForm(config: AuthFormConfig): ReactElement
tags: ["auth","login","register","form","page","ui","mantine","toggle"]
uses_functions: []
uses_types: []
returns: []
imports: ["@mantine/core","@mantine/hooks"]
tests: []
file_path: frontend/functions/ui/auth_form.tsx
created_at: 2026-04-06T10:43:09Z
updated_at: 2026-04-06T10:43:09Z
props: []
emits: ["onSubmit"]
framework: react
variant: []
content_hash: 5ef739865a7d0cb0c9ed2724304b37af32d0ef80675e57521f44806142ef7d90
description:
  Genera página de autenticación con toggle login/register, social buttons opcionales, campos extra en registro y validación. Basado en Mantine AuthenticationForm.

example:
  ### Config mínima (solo login)

  ```tsx
  import { AuthForm } from '@fn_library/auth_form'

  function LoginPage() {
    return (
      <AuthForm
        title="Acceder"
        onSubmit={({ type, email, password }) => {
          console.log(type, email, password)
        }}
      />
    )
  }
  ```

  ### Con social buttons y campos extra en registro

  ```tsx
  import { AuthForm } from '@fn_library/auth_form'
  import { IconBrandGoogle, IconBrandGithub } from '@tabler/icons-react'

  function AuthPage() {
    return (
      <AuthForm
        title="fn_registry"
        defaultType="register"
        socialButtons={[
          { label: 'Google', icon: <IconBrandGoogle size={16} />, onClick: () => signInWithGoogle() },
          { label: 'GitHub', icon: <IconBrandGithub size={16} />, onClick: () => signInWithGitHub() },
        ]}
        extraFields={[
          { name: 'name', label: 'Nombre completo', placeholder: 'Lucas García', required: true },
          { name: 'company', label: 'Empresa', placeholder: 'Acme Corp' },
        ]}
        onSubmit={({ type, email, password, name, company }) => {
          if (type === 'register') {
            registerUser({ email, password, name, company })
          } else {
            loginUser({ email, password })
          }
        }}
      />
    )
  }
  ```

notes:
  Función con estado interno (useToggle, useState de @mantine/hooks). Gestiona el toggle entre login y register sin prop externa — el padre solo recibe el valor final via onSubmit.type.

  Los `extraFields` solo se renderizan en modo register. El campo de términos y condiciones (Checkbox) también es exclusivo del registro.

  Los `socialButtons` se renderizan con un Divider "O continúa con email" cuando están presentes. Sin socialButtons el Divider no aparece.

  El campo `password` usa PasswordInput de Mantine, que incluye el toggle de visibilidad integrado.

code:
  import * as React from 'react'
  import {
    Anchor,
    Button,
    Checkbox,
    Divider,
    Group,
    Paper,
    PasswordInput,
    Stack,
    Text,
    TextInput,
    Title,
    Container,
    type PaperProps,
  } from '@mantine/core'
  import { useToggle, upperFirst } from '@mantine/hooks'

  interface SocialButtonConfig {
    label: string
    icon?: React.ReactNode
    onClick?: () => void
  }

  interface ExtraFieldConfig {
    name: string
    label: string
    placeholder?: string
    required?: boolean
  }

  interface AuthFormSubmitValues {
    type: 'login' | 'register'
    email: string
    password: string
    [key: string]: unknown
  }

  interface AuthFormConfig {
    /** Título principal de la página */
    title?: string
    /** Botones de autenticación social opcionales */
    socialButtons?: SocialButtonConfig[]
    /** Campos adicionales que se muestran solo en el modo registro */
    extraFields?: ExtraFieldConfig[]
    /** Callback invocado al enviar el formulario */
    onSubmit?: (values: AuthFormSubmitValues) => void
    /** Modo inicial: 'login' (default) o 'register' */
    defaultType?: 'login' | 'register'
    /** Props adicionales para el Paper contenedor */
    paperProps?: PaperProps
  }

  function AuthForm({
    title = 'Welcome',
    socialButtons = [],
    extraFields = [],
    onSubmit,
    defaultType = 'login',
    paperProps,
  }: AuthFormConfig): React.ReactElement {
    const [type, toggle] = useToggle<'login' | 'register'>([
      defaultType,
      defaultType === 'login' ? 'register' : 'login',
    ])
    const [email, setEmail] = React.useState('')
    const [password, setPassword] = React.useState('')
    const [terms, setTerms] = React.useState(true)
    const [extraValues, setExtraValues] = React.useState<Record<string, string>>({})

    const handleSubmit = (e: React.FormEvent) => {
      e.preventDefault()
      onSubmit?.({ type, email, password, ...extraValues })
    }

    const handleExtraChange = (name: string, value: string) => {
      setExtraValues((prev) => ({ ...prev, [name]: value }))
    }

    return (
      <Container size={420} py={40}>
        <Paper radius="md" p="lg" withBorder {...paperProps}>
          <Title order={2} ta="center" mb="md">
            {title}
          </Title>

          {socialButtons.length > 0 && (
            <>
              <Group grow mb="md" gap="xs">
                {socialButtons.map((btn) => (
                  <Button
                    key={btn.label}
                    variant="default"
                    radius="xl"
                    leftSection={btn.icon}
                    onClick={btn.onClick}
                  >
                    {btn.label}
                  </Button>
                ))}
              </Group>
              <Divider label="O continúa con email" labelPosition="center" my="lg" />
            </>
          )}

          <form onSubmit={handleSubmit}>
            <Stack gap="sm">
              {type === 'register' &&
                extraFields.map((field) => (
                  <TextInput
                    key={field.name}
                    label={field.label}
                    placeholder={field.placeholder}
                    required={field.required}
                    value={extraValues[field.name] ?? ''}
                    onChange={(e) => handleExtraChange(field.name, e.currentTarget.value)}
                    radius="md"
                  />
                ))}

              <TextInput
                required
                label="Email"
                placeholder="tu@email.com"
                value={email}
                onChange={(e) => setEmail(e.currentTarget.value)}
                radius="md"
              />

              <PasswordInput
                required
                label="Contraseña"
                placeholder="Tu contraseña"
                value={password}
                onChange={(e) => setPassword(e.currentTarget.value)}
                radius="md"
              />

              {type === 'register' && (
                <Checkbox
                  label="Acepto los términos y condiciones"
                  checked={terms}
                  onChange={(e) => setTerms(e.currentTarget.checked)}
                />
              )}
            </Stack>

            <Group justify="space-between" mt="xl">
              <Anchor
                component="button"
                type="button"
                c="dimmed"
                size="xs"
                onClick={() => toggle()}
              >
                {type === 'register'
                  ? '¿Ya tienes cuenta? Inicia sesión'
                  : '¿No tienes cuenta? Regístrate'}
              </Anchor>
              <Button type="submit" radius="xl">
                {upperFirst(type)}
              </Button>
            </Group>
          </form>

          {type === 'register' && (
            <Text c="dimmed" size="xs" ta="center" mt="md">
              Al registrarte aceptas nuestra{' '}
              <Anchor size="xs" href="#">
                política de privacidad
              </Anchor>
              .
            </Text>
          )}
        </Paper>
      </Container>
    )
  }

  export { AuthForm }
  export type { AuthFormConfig, AuthFormSubmitValues, SocialButtonConfig, ExtraFieldConfig }

params_schema:
  {"params":[{"name":"title","desc":"Título principal que aparece en la cabecera del formulario (default: 'Welcome')"},{"name":"socialButtons","desc":"Lista de botones de login social, cada uno con label, icono opcional y callback onClick"},{"name":"extraFields","desc":"Campos de texto adicionales que se muestran únicamente en el modo registro (ej: nombre, empresa)"},{"name":"onSubmit","desc":"Callback invocado al enviar el formulario con type ('login'|'register'), email, password y valores de extraFields"},{"name":"defaultType","desc":"Modo inicial del formulario: 'login' (default) o 'register'"},{"name":"paperProps","desc":"Props de Mantine Paper para personalizar el contenedor (shadow, radius, p, etc.)"}],"output":"Página de autenticación completa con toggle login/register, campos email/password, botones sociales opcionales y campo de términos en registro"}


id: autocomplete_ts_ui
name: autocomplete
kind: component
lang: ts
domain: ui
version: 1.0.0
purity: impure
signature: Autocomplete(props: AutocompleteProps): JSX.Element
tags: ["autocomplete","input","form","suggestions","component","ui","interactive","mantine"]
uses_functions: []
uses_types: []
returns: []
error_type: error_go_core
imports: ["@mantine/core"]
tests: []
file_path: frontend/functions/ui/autocomplete.tsx
created_at: 2026-04-06T10:43:43Z
updated_at: 2026-04-06T10:43:43Z
emits: ["onChange"]
framework: react
variant: []
content_hash: 73fb05057ed560fae5d6aeae75ca918118858b2570c136893dae524cd28d8ce0
params_schema: {"output":"Componente Autocomplete que renderiza input con dropdown de sugerencias filtradas"}
description:
  Input con sugerencias de autocompletado. Permite valores libres a diferencia de Select. Wrapper sobre Mantine Autocomplete.

example:
  ```tsx
  import { Autocomplete } from '@fn_library/autocomplete'

  // Básico — lista de strings
  function BasicAutocomplete() {
    return (
      <Autocomplete
        label="País"
        placeholder="Escribe para buscar..."
        data={['Argentina', 'Brasil', 'Chile', 'Colombia', 'Uruguay']}
      />
    )
  }

  // Con grupos
  function GroupedAutocomplete() {
    return (
      <Autocomplete
        label="Ciudad"
        placeholder="Selecciona una ciudad"
        data={[
          { value: 'Buenos Aires', group: 'Argentina' },
          { value: 'Rosario', group: 'Argentina' },
          { value: 'São Paulo', group: 'Brasil' },
          { value: 'Río de Janeiro', group: 'Brasil' },
        ]}
        limit={5}
      />
    )
  }

  // Con loading y clearable (búsqueda asíncrona)
  function AsyncAutocomplete() {
    const [value, setValue] = useState('')
    const [data, setData] = useState<string[]>([])
    const [loading, setLoading] = useState(false)

    const handleChange = async (val: string) => {
      setValue(val)
      if (val.length < 2) return
      setLoading(true)
      const results = await fetchSuggestions(val)
      setData(results)
      setLoading(false)
    }

    return (
      <Autocomplete
        label="Búsqueda"
        placeholder="Escribe al menos 2 caracteres..."
        value={value}
        onChange={handleChange}
        data={data}
        loading={loading}
        clearable
      />
    )
  }
  ```

props:
  [{"name":"data","type":"string[] | { value: string; label?: string; group?: string }[]","required":true,"description":"Lista de opciones a mostrar en el dropdown"},{"name":"value","type":"string","required":false,"description":"Valor controlado del input"},{"name":"onChange","type":"(value: string) =\u003e void","required":false,"description":"Callback al cambiar el valor del input"},{"name":"label","type":"string","required":false,"description":"Etiqueta visible encima del input"},{"name":"placeholder","type":"string","required":false,"description":"Texto placeholder cuando el input está vacío"},{"name":"clearable","type":"boolean","required":false,"description":"Muestra botón para limpiar el valor"},{"name":"loading","type":"boolean","required":false,"description":"Muestra spinner de carga en el input"},{"name":"disabled","type":"boolean","required":false,"description":"Deshabilita el input"},{"name":"limit","type":"number","required":false,"description":"Número máximo de sugerencias a mostrar en el dropdown"},{"name":"size","type":"'xs' | 'sm' | 'md' | 'lg' | 'xl'","required":false,"description":"Tamaño visual del input"}]

notes:
  A diferencia de `Select`, `Autocomplete` permite que el usuario ingrese cualquier valor libre, no solo los de la lista. Ideal para búsquedas con sugerencias donde el valor final puede no estar en el dataset.

  Cuando `data` contiene objetos con `group`, el dropdown agrupa visualmente las opciones bajo el encabezado del grupo.

  El prop `limit` controla cuántas sugerencias se muestran simultáneamente (por defecto Mantine muestra todas). Útil para datasets grandes o búsquedas asíncronas donde se quiere limitar el ruido visual.

code:
  import { Autocomplete as MantineAutocomplete, type AutocompleteProps as MantineAutcompleteProps } from '@mantine/core'

  interface AutocompleteProps extends MantineAutcompleteProps {}

  function Autocomplete(props: AutocompleteProps) {
    return <MantineAutocomplete {...props} />
  }

  export { Autocomplete }
  export type { AutocompleteProps }


id: avatar_ts_ui
name: avatar
kind: component
lang: ts
domain: ui
version: 1.0.0
purity: impure
signature: Avatar(props: AvatarProps): JSX.Element
tags: ["avatar","user","image","component","ui","mantine"]
uses_functions: []
uses_types: []
returns: []
imports: ["@mantine/core"]
tests: []
file_path: frontend/functions/ui/avatar.tsx
created_at: 2026-04-05T11:22:32Z
updated_at: 2026-04-06T00:00:55Z
emits: []
framework: react
variant: ["xs","sm","md","lg","xl"]
content_hash: ddb73af7cb073f1bc07d2626962299edd45ecaa4159d6c1cd29364f2737832cf
description:
  Imagen de usuario circular con fallback a iniciales generadas automaticamente. 5 tamaños via Mantine Avatar.

example:
  ```tsx
  // Con imagen
  <Avatar src="https://example.com/user.jpg" alt="Juan Perez" size="md" />

  // Con fallback a iniciales
  <Avatar fallback="Juan Perez" size="lg" />

  // Iniciales explicitas
  <Avatar initials="JD" size="sm" />

  // Maneja error de imagen automaticamente
  <Avatar src="/broken-url.jpg" fallback="Maria Garcia" />
  ```

props:
  [{"name":"src","type":"string","required":false,"description":"URL de la imagen"},{"name":"alt","type":"string","required":false,"description":"Texto alternativo de la imagen"},{"name":"fallback","type":"string","required":false,"description":"Nombre completo del que extraer iniciales (ej: 'Juan Perez' -\u003e 'JP')"},{"name":"initials","type":"string","required":false,"description":"Iniciales explicitas para el fallback (sobrescribe fallback)"},{"name":"size","type":"'xs' | 'sm' | 'md' | 'lg' | 'xl'","required":false,"description":"Tamanio del avatar (default: md)"},{"name":"className","type":"string","required":false,"description":"Clases CSS adicionales"}]

notes:
  Usa Mantine Avatar que maneja errores de carga de imagen nativamente. La funcion getInitials extrae 2 iniciales del nombre completo (primera y ultima palabra). Si solo hay una palabra, toma los 2 primeros caracteres. Usa forwardRef para compatibilidad con wrappers.

code:
  import * as React from 'react'
  import { Avatar as MantineAvatar } from '@mantine/core'

  type AvatarSize = 'xs' | 'sm' | 'md' | 'lg' | 'xl'

  const sizeMap: Record<AvatarSize, string> = {
    xs: 'sm',
    sm: 'sm',
    md: 'md',
    lg: 'lg',
    xl: 'xl',
  }

  interface AvatarProps extends React.ComponentPropsWithoutRef<'div'> {
    src?: string
    alt?: string
    fallback?: string
    initials?: string
    size?: AvatarSize
  }

  function getInitials(name?: string): string {
    if (!name) return '?'
    const parts = name.trim().split(/\s+/)
    const first = parts[0] ?? ''
    const last = parts[parts.length - 1] ?? ''
    if (parts.length === 1) return first.slice(0, 2).toUpperCase()
    return ((first[0] ?? '') + (last[0] ?? '')).toUpperCase()
  }

  /** Kept for backwards compatibility */
  const avatarVariants = sizeMap

  const Avatar = React.forwardRef<HTMLDivElement, AvatarProps>(
    ({ className, size = 'md', src, alt, fallback, initials, ...props }, ref) => {
      const displayInitials = initials ?? getInitials(fallback ?? alt)

      return (
        <MantineAvatar
          ref={ref}
          data-slot="avatar"
          src={src}
          alt={alt ?? ''}
          size={sizeMap[size]}
          radius="xl"
          className={className}
          {...props}
        >
          {displayInitials}
        </MantineAvatar>
      )
    }
  )
  Avatar.displayName = 'Avatar'

  export { Avatar, avatarVariants }
  export type { AvatarProps, AvatarSize }

params_schema:
  {"output":"Componente Avatar que renderiza imagen de usuario circular con fallback a iniciales generadas"}


id: badge_ts_ui
name: badge
kind: component
lang: ts
domain: ui
version: 1.0.0
purity: impure
signature: Badge(props: BadgeProps & VariantProps<typeof badgeVariants>): JSX.Element
tags: ["badge","status","component","ui","indicator","mantine"]
uses_functions: []
uses_types: []
returns: []
imports: ["@mantine/core"]
example:
  ```tsx
  <Badge variant="success">Active</Badge>
  <Badge variant="error" size="sm">Error</Badge>
  ```

tests: []
file_path: frontend/functions/ui/badge.tsx
created_at: 2026-04-05T11:22:32Z
updated_at: 2026-04-06T00:00:55Z
emits: []
framework: react
variant: ["default","secondary","destructive","outline","ghost","link","success","warning","error","info"]
content_hash: 494db4cd19433eb18f2f21d449309fb65b50823d41ca521f24ca414268ce3f5c
source_repo: https://gitea-dgg044oo04woo4ggcsws4gk0.organic-machine.com/Bl4cksmith/Frontend_Library
source_license: MIT
source_file: frontend/src/components/ui/badge.tsx
description:
  Badge con 10 variantes semánticas (default, secondary, destructive, outline, ghost, link, success, warning, error, info) y 2 tamaños. Mantine Badge.

props:
  [{"name":"variant","type":"'default' | 'secondary' | 'destructive' | 'outline' | 'ghost' | 'link' | 'success' | 'warning' | 'error' | 'info'","required":false,"description":"Variante visual"},{"name":"size","type":"'default' | 'sm'","required":false,"description":"Tamaño"}]

notes:
  Usa Mantine Badge internamente. Las 10 variantes se mapean a combinaciones de variant+color de Mantine (filled, light, outline, subtle, transparent).

code:
  import * as React from 'react'
  import { Badge as MantineBadge } from '@mantine/core'

  type BadgeVariant = 'default' | 'secondary' | 'destructive' | 'outline' | 'ghost' | 'link' | 'success' | 'warning' | 'error' | 'info'
  type BadgeSize = 'default' | 'sm'

  const variantMap: Record<BadgeVariant, { variant: string; color?: string }> = {
    default: { variant: 'filled' },
    secondary: { variant: 'light' },
    destructive: { variant: 'light', color: 'red' },
    outline: { variant: 'outline' },
    ghost: { variant: 'subtle' },
    link: { variant: 'transparent' },
    success: { variant: 'light', color: 'green' },
    warning: { variant: 'light', color: 'yellow' },
    error: { variant: 'light', color: 'red' },
    info: { variant: 'light', color: 'blue' },
  }

  /** Kept for backwards compatibility */
  const badgeVariants = variantMap

  interface BadgeProps extends React.HTMLAttributes<HTMLDivElement> {
    variant?: BadgeVariant
    size?: BadgeSize
  }

  function Badge({ className, variant = 'default', size = 'default', children, ...props }: BadgeProps) {
    const mv = variantMap[variant]

    return (
      <MantineBadge
        data-slot="badge"
        variant={mv.variant}
        color={mv.color}
        size={size === 'sm' ? 'xs' : 'sm'}
        radius="xl"
        className={className}
        {...props}
      >
        {children}
      </MantineBadge>
    )
  }

  export { Badge, badgeVariants }
  export type { BadgeProps, BadgeVariant, BadgeSize }

params_schema:
  {"output":"Componente Badge que renderiza un indicador visual con 10 variantes semánticas de estado"}


id: bar_chart_ts_ui
name: bar_chart
kind: component
lang: ts
domain: ui
version: 1.1.0
purity: impure
signature: BarChart(props: BarChartProps): JSX.Element
description: Gráfico de barras @mantine/charts con multi-series, orientación horizontal/vertical y tooltips.
tags: ["chart","bar","visualization","mantine","component","ui"]
uses_functions: ["chart_container_ts_ui"]
uses_types: ["ChartSeries_ts_ui"]
returns: []
imports: ["@mantine/charts","@mantine/core"]
tests: []
file_path: frontend/functions/ui/bar_chart.tsx
created_at: 2026-04-05T11:22:32Z
updated_at: 2026-04-06T00:11:54Z
emits: []
framework: react
variant: ["vertical","horizontal"]
content_hash: f74ab8d388d5c1e4a10e652b01451a3c104bc4ed8f702ced3fa707dd00b99539
source_repo: https://gitea-dgg044oo04woo4ggcsws4gk0.organic-machine.com/Bl4cksmith/Frontend_Library
source_license: MIT
source_file: frontend/src/components/ui/charts/bar-chart.tsx
example:
  ```tsx
  <BarChart data={data} xKey="category" yKey="sales" showLegend />
  <BarChart data={data} xKey="name" series={series} horizontal />
  ```

props:
  [{"name":"data","type":"Record\u003cstring, unknown\u003e[]","required":true,"description":"Array de datos"},{"name":"xKey","type":"string","required":true,"description":"Key del eje X/categoría"},{"name":"horizontal","type":"boolean","required":false,"description":"Orientación horizontal"},{"name":"series","type":"Series[]","required":false,"description":"Series de datos para multi-series"}]

notes:
  En modo `horizontal=true` se pasa `orientation="vertical"` a Mantine BarChart, que internamente intercambia los ejes.

code:
  import { BarChart as MantineBarChart } from '@mantine/charts'
  import { Paper } from '@mantine/core'
  import { type Series, getSeriesColor } from './chart_container'

  interface BarChartProps {
    data: Record<string, unknown>[]
    xKey: string
    yKey?: string
    series?: Series[]
    horizontal?: boolean
    showGrid?: boolean
    showLegend?: boolean
    height?: number
    xAxisFormatter?: (value: unknown) => string
    yAxisFormatter?: (value: unknown) => string
    valueFormatter?: (value: number) => string
  }

  function BarChartComponent({
    data, xKey, yKey, series, horizontal = false, showGrid = true, showLegend = false,
    height = 300, xAxisFormatter, yAxisFormatter, valueFormatter = (v) => v.toLocaleString(),
  }: BarChartProps) {
    const chartSeries = series
      ? series.map((s, i) => ({ name: s.key, label: s.name, color: getSeriesColor(i, s.color) }))
      : yKey ? [{ name: yKey, label: yKey, color: getSeriesColor(0) }] : []

    return (
      <Paper p="md">
        <MantineBarChart
          h={height}
          data={data}
          dataKey={xKey}
          series={chartSeries}
          orientation={horizontal ? 'vertical' : 'horizontal'}
          gridAxis={showGrid ? 'xy' : 'none'}
          withLegend={showLegend}
          withTooltip
          valueFormatter={valueFormatter}
          xAxisProps={xAxisFormatter ? { tickFormatter: xAxisFormatter } : undefined}
          yAxisProps={yAxisFormatter ? { tickFormatter: yAxisFormatter } : undefined}
        />
      </Paper>
    )
  }

  export const BarChart = BarChartComponent
  export type { BarChartProps }

params_schema:
  {"output":"Componente JSX que renderiza un gráfico de barras vertical u horizontal con multi-series y tooltips"}


id: breadcrumb_ts_ui
name: breadcrumb
kind: component
lang: ts
domain: ui
version: 1.0.0
purity: impure
signature: Breadcrumb(props: BreadcrumbProps): JSX.Element
tags: ["breadcrumb","navigation","component","ui","mantine"]
uses_functions: []
uses_types: []
returns: []
imports: ["@mantine/core","@tabler/icons-react"]
tests: []
file_path: frontend/functions/ui/breadcrumb.tsx
created_at: 2026-04-05T11:22:32Z
updated_at: 2026-04-06T00:00:55Z
props: [{"name":"className","type":"string","required":false,"description":"Clases CSS adicionales"}]
emits: []
framework: react
variant: []
content_hash: 6b133d8a503addb7a936c5da2d9f0715a10ce0e555965f8ff19203cdf295b7aa
description:
  Navegacion jerarquica con separadores, elipsis para paths largos y soporte para router links via asChild. Mantine Anchor/Text.

example:
  ```tsx
  <Breadcrumb>
    <BreadcrumbList>
      <BreadcrumbItem>
        <BreadcrumbLink href="/">Inicio</BreadcrumbLink>
      </BreadcrumbItem>
      <BreadcrumbSeparator />
      <BreadcrumbItem>
        <BreadcrumbLink href="/docs">Documentacion</BreadcrumbLink>
      </BreadcrumbItem>
      <BreadcrumbSeparator />
      <BreadcrumbItem>
        <BreadcrumbPage>Componentes</BreadcrumbPage>
      </BreadcrumbItem>
    </BreadcrumbList>
  </Breadcrumb>

  // Con elipsis para paths largos
  <Breadcrumb>
    <BreadcrumbList>
      <BreadcrumbItem>
        <BreadcrumbLink href="/">Inicio</BreadcrumbLink>
      </BreadcrumbItem>
      <BreadcrumbSeparator />
      <BreadcrumbItem>
        <BreadcrumbEllipsis />
      </BreadcrumbItem>
      <BreadcrumbSeparator />
      <BreadcrumbItem>
        <BreadcrumbPage>Pagina actual</BreadcrumbPage>
      </BreadcrumbItem>
    </BreadcrumbList>
  </Breadcrumb>
  ```

notes:
  Exports: Breadcrumb (nav), BreadcrumbList (ol via Group), BreadcrumbItem (li via Group), BreadcrumbLink (Mantine Anchor con asChild), BreadcrumbPage (Text aria-current=page), BreadcrumbSeparator (IconChevronRight por defecto, customizable), BreadcrumbEllipsis (IconDots). BreadcrumbLink acepta asChild para usar con Link de React Router o Next.js. Usa Tabler icons en vez de lucide-react.

code:
  import * as React from "react"
  import { Anchor, Text, Box } from "@mantine/core"
  import { IconChevronRight, IconDots } from "@tabler/icons-react"

  function Breadcrumb({ children, ...props }: React.ComponentPropsWithoutRef<"nav">) {
    return <nav data-slot="breadcrumb" aria-label="breadcrumb" {...props}>{children}</nav>
  }

  function BreadcrumbList({ className, children, ...props }: React.ComponentPropsWithoutRef<"ol">) {
    return (
      <ol data-slot="breadcrumb-list" style={{ listStyle: "none", display: "flex", flexWrap: "wrap", alignItems: "center", gap: 8, padding: 0, margin: 0 }} className={className} {...props}>
        {children}
      </ol>
    )
  }

  function BreadcrumbItem({ className, children, ...props }: React.ComponentPropsWithoutRef<"li">) {
    return (
      <li data-slot="breadcrumb-item" style={{ display: "flex", alignItems: "center", gap: 8 }} className={className} {...props}>
        {children}
      </li>
    )
  }

  function BreadcrumbLink({
    className,
    href,
    asChild,
    children,
    ...props
  }: React.ComponentPropsWithoutRef<"a"> & { asChild?: boolean }) {
    if (asChild) {
      return (
        <Text data-slot="breadcrumb-link" component="span" size="sm" className={className} {...(props as React.ComponentPropsWithoutRef<"span">)}>
          {children}
        </Text>
      )
    }
    return (
      <Anchor data-slot="breadcrumb-link" href={href} size="sm" className={className} {...props}>
        {children}
      </Anchor>
    )
  }

  function BreadcrumbPage({ className, ...props }: React.ComponentPropsWithoutRef<"span">) {
    return (
      <Text
        data-slot="breadcrumb-page"
        component="span"
        size="sm"
        fw={500}
        role="link"
        aria-current="page"
        aria-disabled="true"
        className={className}
        {...props}
      />
    )
  }

  function BreadcrumbSeparator({ children, className, ...props }: React.ComponentProps<"li">) {
    return (
      <Box
        data-slot="breadcrumb-separator"
        component="li"
        role="presentation"
        aria-hidden="true"
        className={className}
        style={{ display: "flex", alignItems: "center" }}
        {...props}
      >
        {children ?? <IconChevronRight size={14} />}
      </Box>
    )
  }

  function BreadcrumbEllipsis({ className, ...props }: React.ComponentPropsWithoutRef<"span">) {
    return (
      <Box
        data-slot="breadcrumb-ellipsis"
        component="span"
        role="presentation"
        aria-hidden="true"
        style={{ display: "flex", width: 36, height: 36, alignItems: "center", justifyContent: "center" }}
        className={className}
        {...props}
      >
        <IconDots size={16} />
        <span style={{ position: "absolute", width: 1, height: 1, padding: 0, margin: -1, overflow: "hidden", clip: "rect(0,0,0,0)", whiteSpace: "nowrap", borderWidth: 0 }}>More</span>
      </Box>
    )
  }

  export { Breadcrumb, BreadcrumbEllipsis, BreadcrumbItem, BreadcrumbLink, BreadcrumbList, BreadcrumbPage, BreadcrumbSeparator }

params_schema:
  {"output":"Componente Breadcrumb que renderiza navegación jerárquica con separadores, elipsis y soporte para router links"}


id: button_ts_ui
name: button
kind: component
lang: ts
domain: ui
version: 1.0.0
purity: impure
signature: Button(props: ButtonProps & VariantProps<typeof buttonVariants>): JSX.Element
tags: ["button","component","ui","interactive","mantine"]
uses_functions: []
uses_types: []
returns: []
imports: ["@mantine/core"]
tests: []
file_path: frontend/functions/ui/button.tsx
created_at: 2026-04-05T11:22:32Z
updated_at: 2026-04-06T00:00:55Z
emits: ["onClick"]
framework: react
variant: ["default","outline","secondary","ghost","destructive","link"]
content_hash: 092cee02578ae6dc0bc83b0a5b00c4049b81d2ba1aaba4bbbb1adaf7211f2b9e
source_repo: https://gitea-dgg044oo04woo4ggcsws4gk0.organic-machine.com/Bl4cksmith/Frontend_Library
source_license: MIT
source_file: frontend/src/components/ui/button.tsx
params_schema: {"output":"JSX.Element: botón renderizado con los estilos y comportamientos configurados"}
description:
  Botón accesible con 6 variantes (default, outline, secondary, ghost, destructive, link) y 8 tamaños. Mantine Button.

example:
  ```tsx
  <Button variant="outline" size="sm">Click me</Button>
  <Button variant="destructive">Delete</Button>
  <Button variant="ghost" size="icon"><TrashIcon /></Button>
  ```

props:
  [{"name":"variant","type":"'default' | 'outline' | 'secondary' | 'ghost' | 'destructive' | 'link'","required":false,"description":"Estilo visual del botón"},{"name":"size","type":"'default' | 'xs' | 'sm' | 'lg' | 'icon' | 'icon-xs' | 'icon-sm' | 'icon-lg'","required":false,"description":"Tamaño del botón"},{"name":"className","type":"string","required":false,"description":"Clases CSS adicionales"}]

notes:
  Componente base del sistema. Usa Mantine Button para accesibilidad completa (keyboard, ARIA). Las variantes se mapean a Mantine: default->filled, outline->outline, secondary->light, ghost->subtle, destructive->filled(red), link->transparent.

code:
  import * as React from 'react'
  import { Button as MantineButton } from '@mantine/core'

  type ButtonVariant = 'default' | 'outline' | 'secondary' | 'ghost' | 'destructive' | 'link'
  type ButtonSize = 'default' | 'xs' | 'sm' | 'lg' | 'icon' | 'icon-xs' | 'icon-sm' | 'icon-lg'

  const variantMap: Record<ButtonVariant, { variant: string; color?: string }> = {
    default: { variant: 'filled' },
    outline: { variant: 'outline' },
    secondary: { variant: 'light' },
    ghost: { variant: 'subtle' },
    destructive: { variant: 'filled', color: 'red' },
    link: { variant: 'transparent' },
  }

  const sizeMap: Record<ButtonSize, { size: string; style?: React.CSSProperties }> = {
    default: { size: 'sm' },
    xs: { size: 'xs' },
    sm: { size: 'xs' },
    lg: { size: 'md' },
    icon: { size: 'sm', style: { width: 32, height: 32, padding: 0 } },
    'icon-xs': { size: 'xs', style: { width: 24, height: 24, padding: 0 } },
    'icon-sm': { size: 'xs', style: { width: 28, height: 28, padding: 0 } },
    'icon-lg': { size: 'md', style: { width: 36, height: 36, padding: 0 } },
  }

  /** Kept for backwards compatibility — maps variant names to Mantine equivalents */
  const buttonVariants = variantMap

  interface ButtonProps extends React.ComponentPropsWithoutRef<'button'> {
    variant?: ButtonVariant
    size?: ButtonSize
    children?: React.ReactNode
  }

  function Button({
    className,
    variant = 'default',
    size = 'default',
    style,
    children,
    ...props
  }: ButtonProps) {
    const mv = variantMap[variant]
    const ms = sizeMap[size]

    return (
      <MantineButton
        data-slot="button"
        variant={mv.variant}
        color={mv.color}
        size={ms.size}
        radius="md"
        className={className}
        style={{ ...ms.style, ...style }}
        {...props}
      >
        {children}
      </MantineButton>
    )
  }

  export { Button, buttonVariants }
  export type { ButtonProps, ButtonVariant, ButtonSize }


id: card_ts_ui
name: card
kind: component
lang: ts
domain: ui
version: 1.1.0
purity: impure
tags: ["card","container","layout","component","ui","dashboard","dark"]
uses_functions: []
uses_types: []
returns: []
imports: ["react"]
tests: []
file_path: frontend/functions/ui/card.tsx
created_at: 2026-04-06T00:12:06Z
updated_at: 2026-04-06T00:12:06Z
emits: []
framework: react
variant: ["default","sm","borderless","ghost"]
content_hash: 3d507a1d09f63f802915ee3d6f0ab13a86849d1e368fbfd04a5fa2d6ce0fdd63
source_repo: https://gitea-dgg044oo04woo4ggcsws4gk0.organic-machine.com/Bl4cksmith/Frontend_Library
source_license: MIT
source_file: frontend/src/components/ui/card.tsx
signature:
  Card(props: { size?: 'default' | 'sm'; variant?: 'default' | 'borderless' | 'ghost'; className?: string; children: ReactNode }): JSX.Element

description:
  Contenedor card con header, title, description, action, content y footer. Sistema de slots composable. Variantes default, borderless y ghost para dashboards dark.

example:
  ```tsx
  <Card>
    <CardHeader>
      <CardTitle>Título</CardTitle>
      <CardDescription>Descripción</CardDescription>
    </CardHeader>
    <CardContent>Contenido</CardContent>
    <CardFooter>Footer</CardFooter>
  </Card>

  {/* Dashboard dark — sin bordes */}
  <Card variant="borderless">
    <CardContent>Widget sin marco</CardContent>
  </Card>

  {/* Completamente transparente */}
  <Card variant="ghost">
    <CardContent>Sin fondo ni borde</CardContent>
  </Card>
  ```

props:
  [{"name":"size","type":"'default' | 'sm'","required":false,"description":"Tamaño del card"},{"name":"variant","type":"'default' | 'borderless' | 'ghost'","required":false,"description":"Variante visual. borderless quita borde/shadow, ghost además hace bg transparente"},{"name":"className","type":"string","required":false,"description":"Clases CSS adicionales"}]

notes:
  Sistema de slots via data-slot attributes. Card detecta automáticamente la presencia de CardFooter y ajusta el padding. Exporta 7 subcomponentes composables.

  Las variantes `borderless` y `ghost` eliminan el `ring-1` del borde por defecto. `ghost` además hace el fondo transparente. Alternativa con CSS global: `[data-slot="card"] { --tw-ring-opacity: 0; }` o `[data-variant="borderless"] { ring: 0 }` via `data-variant` attribute expuesto.

code:
  import * as React from 'react'
  import { Paper, Box, Text } from '@mantine/core'

  type CardVariant = 'default' | 'borderless' | 'ghost'

  function Card({
    className,
    size = 'default',
    variant = 'default',
    children,
    ...props
  }: React.ComponentProps<'div'> & { size?: 'default' | 'sm'; variant?: CardVariant }) {
    return (
      <Paper
        data-slot="card"
        data-size={size}
        data-variant={variant}
        withBorder={variant === 'default'}
        shadow={variant === 'default' ? 'xs' : undefined}
        radius="md"
        p={size === 'sm' ? 'sm' : 'md'}
        bg={variant === 'ghost' ? 'transparent' : undefined}
        className={className}
        {...props}
      >
        {children}
      </Paper>
    )
  }

  function CardHeader({ className, ...props }: React.ComponentProps<'div'>) {
    return (
      <Box
        data-slot="card-header"
        pb="xs"
        className={className}
        {...props}
      />
    )
  }

  function CardTitle({ className, ...props }: React.ComponentProps<'div'>) {
    return (
      <Text
        component="div"
        data-slot="card-title"
        fw={600}
        size="sm"
        className={className}
        {...props}
      />
    )
  }

  function CardDescription({ className, ...props }: React.ComponentProps<'div'>) {
    return (
      <Text
        component="div"
        data-slot="card-description"
        size="sm"
        c="dimmed"
        className={className}
        {...props}
      />
    )
  }

  function CardAction({ className, style, ...props }: React.ComponentProps<'div'>) {
    return (
      <Box
        data-slot="card-action"
        style={{ position: 'absolute', top: 0, right: 0, ...style }}
        className={className}
        {...props}
      />
    )
  }

  function CardContent({ className, ...props }: React.ComponentProps<'div'>) {
    return (
      <Box
        data-slot="card-content"
        className={className}
        {...props}
      />
    )
  }

  function CardFooter({ className, ...props }: React.ComponentProps<'div'>) {
    return (
      <Box
        data-slot="card-footer"
        pt="sm"
        mt="auto"
        style={{ borderTop: '1px solid var(--mantine-color-default-border)' }}
        className={className}
        {...props}
      />
    )
  }

  export { Card, CardHeader, CardFooter, CardTitle, CardAction, CardDescription, CardContent }

params_schema:
  {"output":"Componente Card que renderiza un contenedor con slots composables (header, content, footer) y 3 variantes visuales"}


id: chart_container_ts_ui
name: chart_container
kind: component
lang: ts
domain: ui
version: 1.0.0
purity: impure
signature: ChartContainer(props: { children: ReactNode; height?: number | string }): JSX.Element
description: Thin wrapper Paper y utilidades de colores/series para los charts @mantine/charts.
tags: ["chart","container","mantine","base","visualization","component","ui"]
uses_functions: []
uses_types: ["ChartSeries_ts_ui"]
returns: []
imports: ["@mantine/core"]
tests: []
file_path: frontend/functions/ui/chart_container.tsx
created_at: 2026-04-05T11:22:32Z
updated_at: 2026-04-06T00:11:54Z
emits: []
framework: react
variant: ["default"]
content_hash: 289ff310f93689519a41311225239b7463e0cf44c817278c0404792b991386dd
source_repo: https://gitea-dgg044oo04woo4ggcsws4gk0.organic-machine.com/Bl4cksmith/Frontend_Library
source_license: MIT
source_file: frontend/src/components/ui/charts/chart-base.tsx
example:
  ```tsx
  import { ChartContainer, getSeriesColor, type Series } from './chart_container'

  <ChartContainer height={400}>
    <MantineLineChart ... />
  </ChartContainer>
  ```

props:
  [{"name":"height","type":"number | string","required":false,"description":"Altura del chart (default 300)"},{"name":"className","type":"string","required":false,"description":"Clases CSS adicionales"}]

notes:
  Exporta: ChartContainer, defaultColors, getSeriesColor, Series. Wrapper fino sobre Mantine Paper para layout uniforme de charts.

code:
  import { Paper } from '@mantine/core'

  export const defaultColors = ['#3b82f6', '#22c55e', '#f59e0b', '#8b5cf6', '#ec4899']

  export interface Series {
    key: string
    name: string
    color?: string
  }

  export function getSeriesColor(index: number, color?: string): string {
    return color || defaultColors[index % defaultColors.length]!
  }

  interface ChartContainerProps {
    children: React.ReactNode
    height?: number | string
  }

  export function ChartContainer({ children, height = 300 }: ChartContainerProps) {
    return (
      <Paper p="md" style={{ height, minWidth: 100, minHeight: typeof height === 'number' ? Math.min(height, 100) : 100 }}>
        {children}
      </Paper>
    )
  }

  /** @deprecated Mantine charts handle tooltips internally. Kept for index.ts compat. */
  export function ChartTooltipContent() { return null }
  /** @deprecated Mantine charts handle tooltips internally. Kept for index.ts compat. */
  export function ChartTooltip() { return null }
  /** @deprecated Mantine charts handle legends internally. Kept for index.ts compat. */
  export function ChartLegend() { return null }

params_schema:
  {"output":"Componente ChartContainer Paper wrapper y utilidades getSeriesColor/Series para charts Mantine"}


id: checkbox_ts_ui
name: checkbox
kind: component
lang: ts
domain: ui
version: 1.0.0
purity: impure
signature: Checkbox(props: CheckboxProps): JSX.Element
description: Input booleano accesible con label opcional y variante indeterminate. Mantine Checkbox.
tags: ["checkbox","component","ui","interactive","form","mantine"]
uses_functions: []
uses_types: []
returns: []
imports: ["@mantine/core"]
tests: []
file_path: frontend/functions/ui/checkbox.tsx
created_at: 2026-04-05T11:22:32Z
updated_at: 2026-04-06T00:00:55Z
emits: ["onCheckedChange"]
framework: react
variant: []
content_hash: 8196320b87dfd584dcb6195f14125fa6a6427001d058586f3e627e02c13c573f
example:
  ```tsx
  // Basico
  <Checkbox label="Acepto los terminos" />

  // Controlado
  <Checkbox
    label="Seleccionar todos"
    checked={allSelected}
    indeterminate={someSelected}
    onCheckedChange={setAllSelected}
  />

  // Sin label
  <Checkbox checked={isActive} onCheckedChange={setIsActive} />
  ```

props:
  [{"name":"label","type":"string","required":false,"description":"Texto de etiqueta visible junto al checkbox"},{"name":"indeterminate","type":"boolean","required":false,"description":"Estado indeterminate (guion) en vez de checked/unchecked"},{"name":"checked","type":"boolean","required":false,"description":"Estado controlado del checkbox"},{"name":"defaultChecked","type":"boolean","required":false,"description":"Estado inicial no controlado"},{"name":"disabled","type":"boolean","required":false,"description":"Deshabilita el checkbox"},{"name":"onCheckedChange","type":"(checked: boolean) =\u003e void","required":false,"description":"Callback cuando cambia el estado"}]

notes:
  Usa Mantine Checkbox para accesibilidad completa (keyboard, ARIA). El estado indeterminate se muestra con un guion horizontal. El callback onCheckedChange se adapta desde el onChange nativo de Mantine.

code:
  import { Checkbox as MantineCheckbox } from "@mantine/core"

  interface CheckboxProps {
    label?: string
    indeterminate?: boolean
    className?: string
    labelClassName?: string
    checked?: boolean
    defaultChecked?: boolean
    disabled?: boolean
    onCheckedChange?: (checked: boolean) => void
    id?: string
  }

  function Checkbox({ className, label, id, indeterminate, checked, defaultChecked, disabled, onCheckedChange, ...props }: CheckboxProps) {
    return (
      <MantineCheckbox
        id={id}
        data-slot="checkbox"
        label={label}
        indeterminate={indeterminate}
        checked={checked}
        defaultChecked={defaultChecked}
        disabled={disabled}
        onChange={(event) => onCheckedChange?.(event.currentTarget.checked)}
        className={className}
        size="sm"
        {...props}
      />
    )
  }

  export { Checkbox }
  export type { CheckboxProps }

params_schema:
  {"output":"Componente Checkbox que renderiza input booleano accesible con label opcional y estado indeterminate"}


id: chip_ts_ui
name: chip
kind: component
lang: ts
domain: ui
version: 1.0.0
purity: impure
signature: Chip(props: ChipProps): JSX.Element
tags: ["chip","toggle","selection","component","ui","mantine"]
uses_functions: []
uses_types: []
returns: []
imports: ["@mantine/core"]
tests: []
file_path: frontend/functions/ui/chip.tsx
created_at: 2026-04-06T10:42:46Z
updated_at: 2026-04-06T10:42:46Z
emits: ["onChange"]
framework: react
variant: ["filled","outline","light"]
content_hash: bb6ff52eda5c53b81f97c635e9e60816dceb3510f83d2f79596e840b1f2864a4
description:
  Chip seleccionable con variantes filled/outline/light. ChipGroup para selección simple o múltiple. Wrapper sobre Mantine Chip.

example:
  ```tsx
  import { Chip, ChipGroup } from '@fn_library'

  // Chip individual controlado
  <Chip checked={active} onChange={setActive}>
    Activo
  </Chip>

  // ChipGroup selección simple (una sola opción)
  <ChipGroup value={selected} onChange={setSelected}>
    <Chip value="react">React</Chip>
    <Chip value="vue">Vue</Chip>
    <Chip value="svelte">Svelte</Chip>
  </ChipGroup>

  // ChipGroup selección múltiple
  <ChipGroup multiple value={tags} onChange={setTags}>
    <Chip value="frontend">Frontend</Chip>
    <Chip value="backend">Backend</Chip>
    <Chip value="devops">DevOps</Chip>
  </ChipGroup>

  // Con variante y color custom
  <Chip variant="outline" color="teal" size="lg">
    Destacado
  </Chip>
  ```

props:
  [{"name":"checked","type":"boolean","required":false,"description":"Estado seleccionado del chip (controlled)"},{"name":"onChange","type":"(checked: boolean) =\u003e void","required":false,"description":"Callback al cambiar el estado del chip"},{"name":"variant","type":"'filled' | 'outline' | 'light'","required":false,"description":"Estilo visual del chip"},{"name":"color","type":"string","required":false,"description":"Color del chip cuando está seleccionado"},{"name":"size","type":"'xs' | 'sm' | 'md' | 'lg' | 'xl'","required":false,"description":"Tamaño del chip"},{"name":"children","type":"React.ReactNode","required":true,"description":"Contenido del chip — texto o elemento"}]

notes:
  - Wrapper directo sobre `Chip` de `@mantine/core` v9. Todas las props de Mantine Chip son válidas.
  - `ChipGroup` es un alias de `MantineChip.Group` — gestiona el estado de selección entre chips hijos.
  - En `ChipGroup` sin `multiple`, `value` es `string`. Con `multiple`, `value` es `string[]`.
  - Internamente cada `Chip` renderiza un checkbox/radio accesible oculto con label visual.

code:
  import { Chip as MantineChip, type ChipProps as MantineChipProps } from '@mantine/core'

  interface ChipProps extends MantineChipProps {}

  function Chip(props: ChipProps) {
    return <MantineChip {...props} />
  }

  const ChipGroup = MantineChip.Group

  export { Chip, ChipGroup }
  export type { ChipProps }

params_schema:
  {"output":"Componente Chip que renderiza un elemento seleccionable tipo badge, con ChipGroup para gestionar selección simple o múltiple entre varios chips"}


id: color_input_ts_ui
name: color_input
kind: component
lang: ts
domain: ui
version: 1.0.0
purity: impure
signature: ColorInput(props: ColorInputProps): JSX.Element
tags: ["color","picker","input","form","component","ui","interactive","mantine"]
uses_functions: []
uses_types: []
returns: []
imports: ["@mantine/core"]
tests: []
file_path: frontend/functions/ui/color_input.tsx
created_at: 2026-04-06T10:43:43Z
updated_at: 2026-04-06T10:43:43Z
emits: ["onChange","onChangeEnd"]
framework: react
variant: []
content_hash: 09723ceffef9a2b9b2e7de24b2f716884647599c9c7ea6ebf193e7e7cd395136
params_schema: {"output":"Componente ColorInput que renderiza input de color con picker desplegable y swatches"}
description:
  Selector de color con picker, swatches predefinidos y eye dropper. Soporta hex, rgb, hsl con alpha. Wrapper sobre Mantine ColorInput.

example:
  ```tsx
  import { ColorInput } from '@fn_library/color_input'

  // Basico con hex (default)
  <ColorInput
    label="Color primario"
    placeholder="#228be6"
  />

  // Con swatches predefinidos
  <ColorInput
    label="Color de marca"
    swatches={['#2e2e2e', '#868e96', '#fa5252', '#e64980', '#be4bdb', '#228be6', '#15aabf', '#12b886', '#40c057', '#82c91e', '#fab005', '#fd7e14']}
    swatchesPerRow={7}
    placeholder="Elige un color"
  />

  // Con rgba y eye dropper
  <ColorInput
    label="Color con transparencia"
    format="rgba"
    withEyeDropper
    value="rgba(34, 139, 230, 0.5)"
    onChange={(value) => console.log(value)}
  />
  ```

props:
  [{"name":"format","type":"\"hex\" | \"hexa\" | \"rgb\" | \"rgba\" | \"hsl\" | \"hsla\"","required":false,"description":"Formato de color a usar en el valor. Por defecto hex."},{"name":"swatches","type":"string[]","required":false,"description":"Lista de colores predefinidos mostrados como swatches en el picker."},{"name":"withEyeDropper","type":"boolean","required":false,"description":"Muestra el boton eye dropper para seleccionar color de la pantalla."},{"name":"withPicker","type":"boolean","required":false,"description":"Muestra el color picker interactivo. Por defecto true."},{"name":"value","type":"string","required":false,"description":"Valor controlado del color en el formato especificado."},{"name":"onChange","type":"(value: string) =\u003e void","required":false,"description":"Callback invocado cuando el color cambia."},{"name":"label","type":"React.ReactNode","required":false,"description":"Etiqueta del campo."},{"name":"placeholder","type":"string","required":false,"description":"Placeholder del input de texto."},{"name":"disabled","type":"boolean","required":false,"description":"Deshabilita el input."},{"name":"size","type":"\"xs\" | \"sm\" | \"md\" | \"lg\" | \"xl\"","required":false,"description":"Tamanio del componente."}]

notes:
  Wrapper directo sobre `ColorInput` de Mantine v9. Hereda todas las props de Mantine sin restricciones.

  El eye dropper (`withEyeDropper`) solo funciona en browsers que soporten la EyeDropper API (Chrome/Edge). En Firefox no aparece el boton automaticamente.

  Cuando `format` es `hex` o `hsl`, el valor no incluye canal alpha. Para transparencia usar `hexa`, `rgba` o `hsla`.

code:
  import { ColorInput as MantineColorInput, type ColorInputProps as MantineColorInputProps } from '@mantine/core'

  interface ColorInputProps extends MantineColorInputProps {}

  function ColorInput(props: ColorInputProps) {
    return <MantineColorInput {...props} />
  }

  export { ColorInput }
  export type { ColorInputProps }


id: command_ts_ui
name: command
kind: component
lang: ts
domain: ui
version: 1.0.0
purity: impure
signature: Command(props: CommandProps): JSX.Element
tags: ["command","search","combobox","component","ui","interactive"]
uses_functions: []
uses_types: []
returns: []
imports: ["@mantine/core","@tabler/icons-react"]
tests: []
file_path: frontend/functions/ui/command.tsx
created_at: 2026-04-06T00:12:06Z
updated_at: 2026-04-06T00:12:06Z
emits: ["onValueChange"]
framework: react
variant: []
content_hash: 328d257adbc7692eca328e9f184ed9a300c7d81ccf87ed99f943e48b296acb32
description:
  Combobox de busqueda y seleccion estilo cmdk. Filtra items por query, soporta grupos, iconos y shortcuts. Incluye CommandSearch para uso de una linea.

example:
  ```tsx
  // Uso simple con CommandSearch
  const items = [
    { value: "react", label: "React", group: "Frameworks" },
    { value: "vue", label: "Vue", group: "Frameworks" },
    { value: "typescript", label: "TypeScript", group: "Lenguajes" },
  ]

  <CommandSearch
    items={items}
    placeholder="Buscar tecnologia..."
    onValueChange={(val) => console.log(val)}
  />

  // Composable para mayor control
  <Command>
    <CommandInput placeholder="Buscar..." value={query} onChange={(e) => setQuery(e.target.value)} />
    <CommandList>
      <CommandEmpty>Sin resultados.</CommandEmpty>
      <CommandGroup heading="Sugerencias">
        <CommandItem selected={selected === "1"} onSelect={() => setSelected("1")}>
          Opcion 1
          <CommandShortcut>⌘K</CommandShortcut>
        </CommandItem>
      </CommandGroup>
    </CommandList>
  </Command>
  ```

props:
  [{"name":"items","type":"CommandItem[]","required":true,"description":"Array de items con value, label, description, icon, disabled, group"},{"name":"value","type":"string","required":false,"description":"Valor seleccionado (controlado)"},{"name":"onValueChange","type":"(value: string) =\u003e void","required":false,"description":"Callback al seleccionar un item"},{"name":"placeholder","type":"string","required":false,"description":"Placeholder del input de busqueda (default: Search...)"},{"name":"emptyMessage","type":"string","required":false,"description":"Mensaje cuando no hay resultados (default: No results found.)"}]

notes:
  Implementacion propia (sin dependencia de cmdk) usando primitivos HTML nativos. CommandSearch es el wrapper de alto nivel con filtrado reactivo integrado. El filtrado es case-insensitive sobre label, description y value. Los grupos se renderizan en orden de aparicion en items.

code:
  import * as React from 'react'
  import { TextInput, Text, Box, ScrollArea } from '@mantine/core'
  import { IconSearch } from '@tabler/icons-react'

  interface CommandItemData {
    value: string
    label: string
    description?: string
    icon?: React.ReactNode
    disabled?: boolean
    group?: string
  }

  interface CommandProps {
    items: CommandItemData[]
    value?: string
    onValueChange?: (value: string) => void
    placeholder?: string
    emptyMessage?: string
    className?: string
    inputClassName?: string
    listClassName?: string
  }

  function Command({ className, children, ...props }: React.ComponentPropsWithoutRef<'div'>) {
    return <Box data-slot="command" className={className} {...props}>{children}</Box>
  }

  function CommandInput({ className, value, onChange, placeholder, ...props }: {
    className?: string
    value?: string
    onChange?: (e: React.ChangeEvent<HTMLInputElement>) => void
    placeholder?: string
  }) {
    return (
      <TextInput
        data-slot="command-input"
        leftSection={<IconSearch size={16} />}
        className={className}
        value={value}
        onChange={onChange}
        placeholder={placeholder}
        styles={{ input: { border: 'none', borderBottom: '1px solid var(--mantine-color-default-border)' } }}
        {...props}
      />
    )
  }

  function CommandList({ className, children }: { className?: string; children?: React.ReactNode }) {
    return (
      <ScrollArea.Autosize mah={300} data-slot="command-list" className={className}>
        {children}
      </ScrollArea.Autosize>
    )
  }

  function CommandEmpty({ className, children }: { className?: string; children?: React.ReactNode }) {
    return (
      <Text ta="center" c="dimmed" size="sm" py="xl" data-slot="command-empty" className={className}>
        {children}
      </Text>
    )
  }

  function CommandGroup({ className, heading, children }: { className?: string; heading?: string; children?: React.ReactNode }) {
    return (
      <Box data-slot="command-group" p={4} className={className}>
        {heading && <Text size="xs" fw={500} c="dimmed" px="sm" py={6}>{heading}</Text>}
        <div>{children}</div>
      </Box>
    )
  }

  function CommandSeparator({ className }: { className?: string }) {
    return <Box data-slot="command-separator" h={1} bg="var(--mantine-color-default-border)" mx={-4} className={className} />
  }

  function CommandItem({ className, selected, disabled, onSelect, children }: {
    className?: string
    selected?: boolean
    disabled?: boolean
    onSelect?: () => void
    children?: React.ReactNode
  }) {
    return (
      <Box
        data-slot="command-item"
        data-selected={selected}
        aria-disabled={disabled}
        role="option"
        aria-selected={selected}
        onClick={!disabled ? onSelect : undefined}
        px="sm"
        py={6}
        style={{
          display: 'flex',
          alignItems: 'center',
          gap: 8,
          borderRadius: 'var(--mantine-radius-sm)',
          cursor: disabled ? 'not-allowed' : 'pointer',
          opacity: disabled ? 0.5 : 1,
          backgroundColor: selected ? 'var(--mantine-color-default-hover)' : undefined,
          fontSize: 'var(--mantine-font-size-sm)',
        }}
        className={className}
      >
        {children}
      </Box>
    )
  }

  function CommandShortcut({ className, children }: { className?: string; children?: React.ReactNode }) {
    return <Text span size="xs" c="dimmed" ml="auto" className={className}>{children}</Text>
  }

  function CommandSearch({
    items,
    value,
    onValueChange,
    placeholder = 'Search...',
    emptyMessage = 'No results found.',
    className,
  }: CommandProps) {
    const [query, setQuery] = React.useState('')
    const [selectedValue, setSelectedValue] = React.useState(value ?? '')

    const filtered = React.useMemo(() => {
      if (!query) return items
      const q = query.toLowerCase()
      return items.filter(
        (item) =>
          item.label.toLowerCase().includes(q) ||
          item.description?.toLowerCase().includes(q) ||
          item.value.toLowerCase().includes(q)
      )
    }, [items, query])

    const groups = React.useMemo(() => {
      const map = new Map<string, CommandItemData[]>()
      for (const item of filtered) {
        const key = item.group ?? ''
        if (!map.has(key)) map.set(key, [])
        map.get(key)!.push(item)
      }
      return map
    }, [filtered])

    const handleSelect = (val: string) => {
      setSelectedValue(val)
      onValueChange?.(val)
    }

    return (
      <Command className={className}>
        <CommandInput
          value={query}
          onChange={(e) => setQuery(e.target.value)}
          placeholder={placeholder}
        />
        <CommandList>
          {filtered.length === 0 ? (
            <CommandEmpty>{emptyMessage}</CommandEmpty>
          ) : (
            Array.from(groups.entries()).map(([group, groupItems]) => (
              <CommandGroup key={group} heading={group || undefined}>
                {groupItems.map((item) => (
                  <CommandItem
                    key={item.value}
                    selected={selectedValue === item.value}
                    disabled={item.disabled}
                    onSelect={() => handleSelect(item.value)}
                  >
                    {item.icon && <span>{item.icon}</span>}
                    <span>{item.label}</span>
                    {item.description && (
                      <Text span size="xs" c="dimmed" ml="auto">{item.description}</Text>
                    )}
                  </CommandItem>
                ))}
              </CommandGroup>
            ))
          )}
        </CommandList>
      </Command>
    )
  }

  export { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList, CommandSearch, CommandSeparator, CommandShortcut }
  export type { CommandItemData, CommandProps }

params_schema:
  {"output":"Componente Command que renderiza combobox de búsqueda y selección con filtrado reactivo, grupos e iconos"}


id: crud_page_ts_ui
name: crud_page
kind: function
lang: ts
domain: ui
version: 1.0.0
purity: pure
signature: crudPage<T>(props: CrudPageProps<T>): ReactElement
tags: ["crud","page","table","form","factory","composition","ui"]
uses_functions: []
uses_types: []
returns: []
imports: ["react","@mantine/core","@tabler/icons-react"]
tests: []
file_path: frontend/functions/ui/crud_page.tsx
created_at: 2026-04-05T11:22:32Z
updated_at: 2026-04-06T00:11:54Z
props: []
emits: []
variant: []
content_hash: fbdf8ffd14118a1551b4cc9a709c2dcdda146f387736e3849c6a048a59ce6fc0
source_repo: https://gitea-dgg044oo04woo4ggcsws4gk0.organic-machine.com/Bl4cksmith/Frontend_Library
source_license: MIT
description:
  Genera una página CRUD completa con header, tabla con columnas configurables, botones de acción (add/edit/delete) y schema de formulario.

example:
  ```tsx
  crudPage({
    title: 'Users',
    subtitle: 'Manage system users',
    data: users,
    fields: [
      { key: 'name', label: 'Name', type: 'text', required: true },
      { key: 'email', label: 'Email', type: 'email', required: true },
      { key: 'role', label: 'Role', type: 'select', options: [{ label: 'Admin', value: 'admin' }, { label: 'User', value: 'user' }] },
    ],
    columns: [
      { key: 'name', label: 'Name' },
      { key: 'email', label: 'Email' },
      { key: 'role', label: 'Role', render: (v) => <Badge variant={v === 'admin' ? 'default' : 'secondary'}>{v}</Badge> },
    ],
    onAdd: handleAdd,
    onEdit: handleEdit,
    onDelete: handleDelete,
  })
  ```

notes:
  El schema de campos se almacena como data attribute para que un agente pueda leerlo y generar el formulario de diálogo correspondiente. La tabla incluye sorting visual implícito por columnas.

code:
  import * as React from 'react'
  import { Stack, Group, Title, Text, Paper, Table, Button, ActionIcon, Center } from '@mantine/core'
  import { IconPlus, IconPencil, IconTrash } from '@tabler/icons-react'

  interface CrudField {
    key: string
    label: string
    type: 'text' | 'number' | 'email' | 'select' | 'textarea'
    required?: boolean
    options?: Array<{ label: string; value: string }>
    placeholder?: string
  }

  interface CrudPageProps<T extends Record<string, unknown>> {
    title: string
    subtitle?: string
    data: T[]
    fields: CrudField[]
    columns: Array<{
      key: keyof T
      label: string
      render?: (value: unknown, row: T) => React.ReactNode
    }>
    onAdd?: (item: Partial<T>) => void
    onEdit?: (item: T) => void
    onDelete?: (item: T) => void
    actions?: React.ReactNode
    className?: string
  }

  export function crudPage<T extends Record<string, unknown>>({
    title,
    subtitle,
    data,
    fields,
    columns,
    onAdd,
    onEdit,
    onDelete,
    actions,
  }: CrudPageProps<T>): React.ReactElement {
    return (
      <Stack gap="lg">
        {/* Header */}
        <Group justify="space-between" pb="md" style={{ borderBottom: '1px solid var(--mantine-color-default-border)' }}>
          <Stack gap={4}>
            <Title order={2}>{title}</Title>
            {subtitle && <Text size="sm" c="dimmed">{subtitle}</Text>}
          </Stack>
          <Group gap="xs">
            {actions}
            {onAdd && (
              <Button size="xs" leftSection={<IconPlus size={16} />}>
                Add {title.replace(/s$/, '')}
              </Button>
            )}
          </Group>
        </Group>

        {/* Table */}
        <Paper withBorder radius="md">
          <Table highlightOnHover>
            <Table.Thead>
              <Table.Tr>
                {columns.map((col) => (
                  <Table.Th key={String(col.key)} fz="sm" fw={500} c="dimmed" px="md" py="sm">
                    {col.label}
                  </Table.Th>
                ))}
                {(onEdit || onDelete) && (
                  <Table.Th ta="right" fz="sm" fw={500} c="dimmed" px="md" py="sm">Actions</Table.Th>
                )}
              </Table.Tr>
            </Table.Thead>
            <Table.Tbody>
              {data.length === 0 ? (
                <Table.Tr>
                  <Table.Td colSpan={columns.length + (onEdit || onDelete ? 1 : 0)}>
                    <Center h={96}>
                      <Text c="dimmed">No items yet.</Text>
                    </Center>
                  </Table.Td>
                </Table.Tr>
              ) : (
                data.map((row, i) => (
                  <Table.Tr key={i}>
                    {columns.map((col) => (
                      <Table.Td key={String(col.key)} px="md" py="sm" style={{ verticalAlign: 'middle' }}>
                        {col.render ? col.render(row[col.key], row) : String(row[col.key] ?? '')}
                      </Table.Td>
                    ))}
                    {(onEdit || onDelete) && (
                      <Table.Td px="md" py="sm" ta="right">
                        <Group gap={4} justify="flex-end">
                          {onEdit && (
                            <ActionIcon variant="subtle" size="sm" onClick={() => onEdit(row)}>
                              <IconPencil size={14} />
                            </ActionIcon>
                          )}
                          {onDelete && (
                            <ActionIcon variant="subtle" color="red" size="sm" onClick={() => onDelete(row)}>
                              <IconTrash size={14} />
                            </ActionIcon>
                          )}
                        </Group>
                      </Table.Td>
                    )}
                  </Table.Tr>
                ))
              )}
            </Table.Tbody>
          </Table>
        </Paper>

        {/* Form fields definition (for agent use) */}
        <div style={{ display: 'none' }} data-slot="crud-form-schema" data-fields={JSON.stringify(fields)} />
      </Stack>
    )
  }

  export type { CrudPageProps, CrudField }

params_schema:
  {"params":[{"name":"props","desc":"Configuración CRUD: título, datos, columnas de tabla, campos de formulario y callbacks para add/edit/delete"}],"output":"Componente ReactElement que renderiza página CRUD completa con tabla y botones de acción"}


id: dashboard_layout_ts_ui
name: dashboard_layout
kind: function
lang: ts
domain: ui
version: 1.0.0
purity: pure
signature: dashboardLayout(props: DashboardLayoutProps): ReactElement
tags: ["dashboard","layout","grid","factory","composition","ui"]
uses_functions: []
uses_types: []
returns: []
imports: ["react","@mantine/core"]
tests: []
file_path: frontend/functions/ui/dashboard_layout.tsx
created_at: 2026-04-05T11:22:32Z
updated_at: 2026-04-06T00:11:54Z
props: []
emits: []
variant: []
content_hash: 7ffc204c545b63a545ebfa79fa9f0b01865540fc85c7db5f77a18aa9085aaf3e
source_repo: https://gitea-dgg044oo04woo4ggcsws4gk0.organic-machine.com/Bl4cksmith/Frontend_Library
source_license: MIT
description:
  Genera un grid responsive de dashboard a partir de un array de widgets con span configurable. 1-4 columnas con auto-responsive.

example:
  ```tsx
  dashboardLayout({
    columns: 4,
    widgets: [
      { id: 'revenue', title: 'Revenue', content: <KPICard label="Revenue" value="$12k" /> },
      { id: 'users', title: 'Users', content: <KPICard label="Users" value={1234} /> },
      { id: 'chart', title: 'Trends', span: 2, content: <LineChart data={data} xKey="month" yKey="value" /> },
      { id: 'table', span: 4, content: <DataTable columns={cols} data={rows} /> },
    ]
  })
  ```

notes:
  Factory pura — dado el mismo input siempre genera el mismo JSX. Un agente puede construir dashboards completos pasando widgets como configuración declarativa.

code:
  import * as React from 'react'
  import { SimpleGrid, Paper, Text } from '@mantine/core'

  interface DashboardWidget {
    id: string
    title?: string
    span?: 1 | 2 | 3 | 4
    rowSpan?: 1 | 2
    content: React.ReactNode
  }

  interface DashboardLayoutProps {
    widgets: DashboardWidget[]
    columns?: 1 | 2 | 3 | 4
    gap?: 'sm' | 'md' | 'lg'
    className?: string
  }

  const gapMap = { sm: 'xs', md: 'md', lg: 'lg' } as const

  export function dashboardLayout({
    widgets,
    columns = 4,
    gap = 'md',
  }: DashboardLayoutProps): React.ReactElement {
    return (
      <SimpleGrid
        cols={{ base: 1, md: Math.min(columns, 2), lg: columns }}
        spacing={gapMap[gap]}
      >
        {widgets.map((widget) => (
          <Paper
            key={widget.id}
            p="md"
            withBorder
            shadow="xs"
            radius="md"
            style={{
              gridColumn: widget.span && widget.span > 1 ? `span ${widget.span}` : undefined,
              gridRow: widget.rowSpan === 2 ? 'span 2' : undefined,
            }}
          >
            {widget.title && (
              <Text size="sm" fw={500} c="dimmed" mb="sm">{widget.title}</Text>
            )}
            {widget.content}
          </Paper>
        ))}
      </SimpleGrid>
    )
  }

  export type { DashboardWidget, DashboardLayoutProps }

params_schema:
  {"params":[{"name":"props","desc":"Configuración de layout: número de columnas y array de widgets con id, título, contenido y span"}],"output":"Componente ReactElement que renderiza grid responsive de dashboard con ancho adaptable por widget"}


id: data_table_ts_ui
name: data_table
kind: component
lang: ts
domain: ui
version: 1.0.0
purity: impure
signature: DataTable(props: DataTableProps): JSX.Element
tags: ["table","data","heatmap","dashboard","component","ui","format","visualization"]
uses_functions: []
uses_types: []
returns: []
imports: ["react","@mantine/core"]
tests: []
file_path: frontend/functions/ui/data_table.tsx
created_at: 2026-04-05T11:22:32Z
updated_at: 2026-04-06T00:11:54Z
emits: []
framework: react
variant: ["default","heatmap"]
content_hash: 270afe0531e114d8b85eca295994907d565c2091bb1bb85c723345e644aeb9df
description:
  Tabla de datos con sticky header, overflow scroll, heatmap por columna, formato condicional (number/datetime/currency) y hover rows. Auto-detecta columnas desde la primera fila si no se proveen.

example:
  ```tsx
  // Tabla simple con auto-detección de columnas
  <DataTable data={rows} />

  // Con columnas definidas y heatmap
  <DataTable
    data={metrics}
    columns={[
      { key: 'domain', label: 'Domain' },
      { key: 'count', label: 'Functions', format: ',' },
      { key: 'pure_pct', label: 'Pure %', format: '.1f' },
    ]}
    heatmapColumns={['count', 'pure_pct']}
  />

  // Con formato moneda y fecha
  <DataTable
    data={transactions}
    columns={[
      { key: 'date', label: 'Date', format: 'datetime' },
      { key: 'amount', label: 'Amount', format: '$,.2f', align: 'right' },
      { key: 'description', label: 'Description' },
    ]}
  />
  ```

props:
  [{"name":"data","type":"Record\u003cstring, unknown\u003e[]","required":true,"description":"Filas de datos. Cada objeto es una fila."},{"name":"columns","type":"ColumnDef[]","required":false,"description":"Definición de columnas con key, label, format y align. Si se omite, se auto-detectan desde la primera fila."},{"name":"heatmapColumns","type":"string[]","required":false,"description":"Keys de columnas numéricas que deben colorearse por intensidad (azul oscuro=bajo, azul claro=alto)."},{"name":"maxHeight","type":"number | string","required":false,"description":"Altura máxima antes de scroll. Default 500px."},{"name":"loading","type":"boolean","required":false,"description":"Estado de carga. Muestra spinner si data está vacía."},{"name":"error","type":"Error | null","required":false,"description":"Error a mostrar si la carga falló."}]

notes:
  Extraido y generalizado desde `apps/rapid_dashboards/frontend/src/components/widgets/TableWidget.tsx`. El heatmap usa `useMemo` para calcular min/max por columna solo cuando cambian `data` o `heatmapColumns`. La alineación de celdas numéricas es automática (derecha) cuando el valor es `typeof 'number'`; se puede sobreescribir con el campo `align` en ColumnDef.

documentation:
  ## Formatos soportados (campo `format` en ColumnDef)

  | format | Ejemplo input | Output |
  |--------|--------------|--------|
  | `','` | `1234567` | `1,234,567` |
  | `',.2f'` | `1234.5` | `1,234.50` |
  | `'$,.2f'` | `1234.5` | `$1,234.50` |
  | `'.0f'` | `42.7` | `43` |
  | `'datetime'` | `'2026-04-01T12:00:00Z'` | `4/1/2026, 12:00:00 PM` |

code:
  import * as React from 'react'
  import { Table, Text, Center, Loader } from '@mantine/core'

  interface ColumnDef {
    key: string
    label: string
    /** Format string: ',.2f' | '$,.2f' | 'datetime' | ',' */
    format?: string
    /** Alignment override. Numbers default to right, strings to left. */
    align?: 'left' | 'right' | 'center'
  }

  interface DataTableProps {
    data: Record<string, unknown>[]
    columns?: ColumnDef[]
    /** Column keys that should be colored by value intensity (heatmap). */
    heatmapColumns?: string[]
    maxHeight?: number | string
    loading?: boolean
    error?: Error | null
  }

  function formatCell(value: unknown, format?: string): string {
    if (value == null) return '—'
    if (!format) return String(value)

    if (format === 'datetime' && !isNaN(Date.parse(String(value)))) {
      return new Date(String(value)).toLocaleString()
    }

    const num = Number(value)
    if (!isNaN(num)) {
      if (format.includes('f')) {
        const match = format.match(/\.(\d+)f/)
        const d = match ? parseInt(match[1]!) : 0
        let str = num.toFixed(d)
        if (format.includes(',')) {
          str = Number(str).toLocaleString('en-US', { minimumFractionDigits: d, maximumFractionDigits: d })
        }
        if (format.startsWith('$')) str = '$' + str
        return str
      }
      if (format === ',') return num.toLocaleString()
    }
    return String(value)
  }

  function DataTableComponent({
    data,
    columns,
    heatmapColumns = [],
    maxHeight = 500,
    loading = false,
    error = null,
  }: DataTableProps) {
    // Auto-detect columns from first row if not provided
    const effectiveColumns: ColumnDef[] = (columns && columns.length > 0)
      ? columns
      : (data && data.length > 0)
        ? Object.keys(data[0]!).map(k => ({ key: k, label: k }))
        : []

    // Compute heatmap ranges per column
    const heatmapRanges = React.useMemo(() => {
      const ranges: Record<string, { min: number; max: number }> = {}
      if (heatmapColumns.length > 0 && data && data.length > 0) {
        for (const key of heatmapColumns) {
          const values = data.map(r => Number(r[key])).filter(n => !isNaN(n))
          if (values.length > 0) {
            ranges[key] = { min: Math.min(...values), max: Math.max(...values) }
          }
        }
      }
      return ranges
    }, [data, heatmapColumns])

    function heatmapStyle(key: string, value: unknown): React.CSSProperties | undefined {
      const range = heatmapRanges[key]
      if (!range || range.max === range.min) return undefined
      const num = Number(value)
      if (isNaN(num)) return undefined
      const t = (num - range.min) / (range.max - range.min)
      const alpha = 0.1 + t * 0.55
      return { backgroundColor: `rgba(59, 130, 246, ${alpha})` }
    }

    if (loading && (!data || data.length === 0)) {
      return (
        <Center h={200}>
          <Loader size="sm" />
        </Center>
      )
    }

    if (error) {
      return (
        <Center h={200}>
          <Text size="sm" c="red">{error.message}</Text>
        </Center>
      )
    }

    return (
      <Table.ScrollContainer minWidth={0} mah={maxHeight} type="scrollarea">
        <Table striped={false} highlightOnHover withTableBorder={false} withColumnBorders={false}>
          <Table.Thead style={{ position: 'sticky', top: 0, zIndex: 10, backgroundColor: 'var(--mantine-color-body)' }}>
            <Table.Tr>
              {effectiveColumns.map(col => (
                <Table.Th
                  key={col.key}
                  style={{ whiteSpace: 'nowrap' }}
                  fz="xs"
                  fw={500}
                  c="dimmed"
                  tt="uppercase"
                  py={6}
                  px="sm"
                >
                  {col.label}
                </Table.Th>
              ))}
            </Table.Tr>
          </Table.Thead>
          <Table.Tbody>
            {(data ?? []).map((row, i) => (
              <Table.Tr key={i}>
                {effectiveColumns.map(col => {
                  const align = col.align ?? (typeof row[col.key] === 'number' ? 'right' : 'left')
                  return (
                    <Table.Td
                      key={col.key}
                      style={{ textAlign: align, fontFamily: 'var(--mantine-font-family-monospace)', ...heatmapStyle(col.key, row[col.key]) }}
                      fz="xs"
                      py={6}
                      px="sm"
                    >
                      {formatCell(row[col.key], col.format)}
                    </Table.Td>
                  )
                })}
              </Table.Tr>
            ))}
          </Table.Tbody>
        </Table>
        {(!data || data.length === 0) && (
          <Center py="xl">
            <Text size="sm" c="dimmed">No data</Text>
          </Center>
        )}
      </Table.ScrollContainer>
    )
  }

  export const DataTable = DataTableComponent
  export type { DataTableProps, ColumnDef }

params_schema:
  {"output":"Componente DataTable que renderiza tabla con sticky header, heatmap condicional y formato automático de datos"}


id: date_picker_input_ts_ui
name: date_picker_input
kind: component
lang: ts
domain: ui
version: 1.0.0
purity: impure
signature: DatePickerInput(props: DatePickerInputProps): JSX.Element
tags: ["date","picker","calendar","form","component","ui","interactive","mantine"]
uses_functions: []
uses_types: []
returns: []
imports: ["@mantine/dates"]
tests: []
file_path: frontend/functions/ui/date_picker_input.tsx
created_at: 2026-04-06T10:42:46Z
updated_at: 2026-04-06T10:42:46Z
emits: ["onChange"]
framework: react
variant: ["default"]
content_hash: 6be456f1545db0a75befbf40c041bae937ba1a860363fd51c0f7aed18f08cd7a
description:
  Selector de fecha con input y calendario desplegable. Soporta fecha simple, múltiple y rango. Wrapper sobre Mantine DatePickerInput.

example:
  ```tsx
  import { DatePickerInput, DatePicker } from '@fn_library'
  import { useState } from 'react'

  // Fecha simple
  function SingleDateExample() {
    const [value, setValue] = useState<Date | null>(null)
    return (
      <DatePickerInput
        label="Fecha de inicio"
        placeholder="Selecciona una fecha"
        value={value}
        onChange={setValue}
        clearable
      />
    )
  }

  // Rango de fechas
  function RangeDateExample() {
    const [range, setRange] = useState<[Date | null, Date | null]>([null, null])
    return (
      <DatePickerInput
        type="range"
        label="Periodo"
        placeholder="Selecciona un rango"
        value={range}
        onChange={setRange}
        valueFormat="DD/MM/YYYY"
      />
    )
  }

  // Múltiples fechas
  function MultipleDateExample() {
    const [dates, setDates] = useState<Date[]>([])
    return (
      <DatePickerInput
        type="multiple"
        label="Días seleccionados"
        placeholder="Selecciona fechas"
        value={dates}
        onChange={setDates}
        minDate={new Date()}
      />
    )
  }

  // DatePicker inline (sin input)
  function InlineDateExample() {
    const [value, setValue] = useState<Date | null>(null)
    return (
      <DatePicker
        value={value}
        onChange={setValue}
      />
    )
  }
  ```

props:
  [{"name":"type","type":"'default' | 'multiple' | 'range'","required":false,"description":"Modo de selección — fecha simple, múltiples fechas, o rango de fechas"},{"name":"value","type":"DateValue | DateValue[] | [DateValue, DateValue] | null","required":false,"description":"Fecha o fechas seleccionadas (controlled)"},{"name":"onChange","type":"(value: DateValue | DateValue[] | [DateValue, DateValue] | null) =\u003e void","required":false,"description":"Callback al cambiar la selección de fecha"},{"name":"valueFormat","type":"string","required":false,"description":"Formato de la fecha mostrada en el input (ej: 'DD/MM/YYYY')"},{"name":"clearable","type":"boolean","required":false,"description":"Permite limpiar la selección de fecha"},{"name":"label","type":"string","required":false,"description":"Label del campo"},{"name":"placeholder","type":"string","required":false,"description":"Texto cuando no hay fecha seleccionada"},{"name":"minDate","type":"Date","required":false,"description":"Fecha mínima seleccionable"},{"name":"maxDate","type":"Date","required":false,"description":"Fecha máxima seleccionable"},{"name":"disabled","type":"boolean","required":false,"description":"Deshabilitar el selector"},{"name":"size","type":"'xs' | 'sm' | 'md' | 'lg' | 'xl'","required":false,"description":"Tamaño del componente"}]

notes:
  - Wrapper directo sobre `DatePickerInput` y `DatePicker` de `@mantine/dates` v9. Todas las props de Mantine son válidas.
  - Requiere importar `@mantine/dates/styles.css` — este wrapper ya lo incluye.
  - El prop `type` controla el modo: `'default'` (fecha simple), `'multiple'` (varias fechas), `'range'` (rango con inicio y fin).
  - `DatePicker` es el calendario inline sin input — útil para formularios donde el calendario debe estar siempre visible.
  - `valueFormat` acepta tokens de dayjs (ej: `'DD/MM/YYYY'`, `'MMMM D, YYYY'`).
  - Re-exporta también `DatePicker` de `@mantine/dates` con el mismo patrón de wrapper.

code:
  import { DatePickerInput as MantineDatePickerInput, DatePicker as MantineDatePicker } from '@mantine/dates'
  import type { DatePickerInputProps as MantineDatePickerInputProps, DatePickerProps as MantineDatePickerProps } from '@mantine/dates'
  import '@mantine/dates/styles.css'

  interface DatePickerInputProps extends MantineDatePickerInputProps<'default'> {}
  interface DatePickerProps extends MantineDatePickerProps<'default'> {}

  function DatePickerInput(props: DatePickerInputProps) {
    return <MantineDatePickerInput {...props} />
  }

  function DatePicker(props: DatePickerProps) {
    return <MantineDatePicker {...props} />
  }

  export { DatePickerInput, DatePicker }
  export type { DatePickerInputProps, DatePickerProps }

params_schema:
  {"params":[{"name":"props","desc":"Props del componente DatePickerInput — incluye type (modo de selección), value, onChange, valueFormat, clearable, label, placeholder, minDate, maxDate, disabled y size"}],"output":"Componente DatePickerInput que renderiza input con calendario para selección de fechas"}


id: detail_page_ts_ui
name: detail_page
kind: function
lang: ts
domain: ui
version: 1.0.0
purity: pure
signature: detailPage(props: DetailPageProps): ReactElement
tags: ["detail","page","entity","timeline","factory","composition","ui"]
uses_functions: []
uses_types: []
returns: []
imports: ["react","@mantine/core","@tabler/icons-react"]
tests: []
file_path: frontend/functions/ui/detail_page.tsx
created_at: 2026-04-05T11:22:32Z
updated_at: 2026-04-06T00:11:54Z
props: []
emits: []
variant: []
content_hash: 413d5548fbc3eb2711692b43ac67c28af8c912713edb02e7d17617e7114b0fbe
source_repo: https://gitea-dgg044oo04woo4ggcsws4gk0.organic-machine.com/Bl4cksmith/Frontend_Library
source_license: MIT
description:
  Genera una página de detalle de entidad con header (avatar, badge, back), grid de campos, tabs con contadores y timeline de actividad.

example:
  ```tsx
  detailPage({
    title: 'John Doe',
    subtitle: 'john@example.com',
    badge: <Badge variant="success">Active</Badge>,
    onBack: () => router.back(),
    fields: [
      { label: 'Role', value: 'Administrator' },
      { label: 'Created', value: 'Mar 15, 2026' },
      { label: 'Bio', value: 'Full stack developer...', span: 2 },
    ],
    tabs: [
      { label: 'Projects', value: 'projects', count: 12, content: <ProjectList /> },
      { label: 'Activity', value: 'activity', count: 48, content: <ActivityList /> },
    ],
    activeTab: 'projects',
    timeline: [
      { id: '1', title: 'Deployed v2.1', timestamp: '2 hours ago', variant: 'success' },
      { id: '2', title: 'Updated settings', timestamp: 'Yesterday' },
      { id: '3', title: 'Created project', timestamp: 'Mar 10, 2026' },
    ],
  })
  ```

notes:
  Factory completa para páginas de detalle. Combina header con back/avatar/badge, grid de metadata, tabs con badges de conteo, y timeline de actividad con variantes de color semántico.

code:
  import * as React from 'react'
  import { Stack, Group, Title, Text, ActionIcon, Box, Tabs, Badge, Timeline, SimpleGrid } from '@mantine/core'
  import { IconChevronLeft } from '@tabler/icons-react'

  interface DetailField {
    label: string
    value: React.ReactNode
    span?: 1 | 2
  }

  interface DetailTab {
    label: string
    value: string
    content: React.ReactNode
    count?: number
  }

  interface TimelineEvent {
    id: string
    title: string
    description?: string
    timestamp: string
    icon?: React.ReactNode
    variant?: 'default' | 'success' | 'warning' | 'error'
  }

  interface DetailPageProps {
    title: string
    subtitle?: string
    badge?: React.ReactNode
    avatar?: React.ReactNode
    actions?: React.ReactNode
    onBack?: () => void
    fields: DetailField[]
    tabs?: DetailTab[]
    activeTab?: string
    onTabChange?: (value: string) => void
    timeline?: TimelineEvent[]
    className?: string
  }

  const variantColors: Record<string, string> = {
    default: 'blue',
    success: 'green',
    warning: 'yellow',
    error: 'red',
  }

  export function detailPage({
    title, subtitle, badge, avatar, actions, onBack,
    fields, tabs, activeTab, onTabChange, timeline,
  }: DetailPageProps): React.ReactElement {
    return (
      <Stack gap="lg">
        {/* Header */}
        <Group justify="space-between" align="flex-start" pb="md" style={{ borderBottom: '1px solid var(--mantine-color-default-border)' }}>
          <Group align="flex-start" gap="md">
            {onBack && (
              <ActionIcon variant="subtle" size="sm" onClick={onBack} mt={4}>
                <IconChevronLeft size={16} />
              </ActionIcon>
            )}
            {avatar && (
              <Box
                w={48}
                h={48}
                style={{ flexShrink: 0, overflow: 'hidden', borderRadius: '50%', backgroundColor: 'var(--mantine-color-default)' }}
              >
                {avatar}
              </Box>
            )}
            <Stack gap={4}>
              <Group gap="sm" align="center">
                <Title order={2}>{title}</Title>
                {badge}
              </Group>
              {subtitle && <Text size="sm" c="dimmed">{subtitle}</Text>}
            </Stack>
          </Group>
          {actions && <Group gap="xs">{actions}</Group>}
        </Group>

        {/* Fields grid */}
        <SimpleGrid cols={{ base: 1, md: 2 }} spacing="md">
          {fields.map((field, i) => (
            <Box key={i} style={field.span === 2 ? { gridColumn: 'span 2' } : undefined}>
              <Stack gap={4}>
                <Text size="sm" c="dimmed">{field.label}</Text>
                <Text size="sm" fw={500}>{field.value}</Text>
              </Stack>
            </Box>
          ))}
        </SimpleGrid>

        {/* Tabs */}
        {tabs && tabs.length > 0 && (
          <Stack gap="md">
            <Tabs value={activeTab} onChange={(v) => v && onTabChange?.(v)}>
              <Tabs.List>
                {tabs.map((tab) => (
                  <Tabs.Tab
                    key={tab.value}
                    value={tab.value}
                    rightSection={tab.count !== undefined ? <Badge size="xs" variant="filled" circle>{tab.count}</Badge> : undefined}
                  >
                    {tab.label}
                  </Tabs.Tab>
                ))}
              </Tabs.List>
            </Tabs>
            {tabs.find(t => t.value === activeTab)?.content}
          </Stack>
        )}

        {/* Timeline */}
        {timeline && timeline.length > 0 && (
          <Stack gap="sm">
            <Text size="sm" fw={500} c="dimmed">Activity</Text>
            <Timeline active={timeline.length - 1} bulletSize={12} lineWidth={2}>
              {timeline.map((event) => (
                <Timeline.Item
                  key={event.id}
                  color={variantColors[event.variant || 'default']}
                  title={<Text size="sm" fw={500}>{event.title}</Text>}
                >
                  {event.description && <Text size="xs" c="dimmed">{event.description}</Text>}
                  <Text size="xs" c="dimmed" opacity={0.7}>{event.timestamp}</Text>
                </Timeline.Item>
              ))}
            </Timeline>
          </Stack>
        )}
      </Stack>
    )
  }

  export type { DetailPageProps, DetailField, DetailTab, TimelineEvent }

params_schema:
  {"params":[{"name":"props","desc":"Configuración de página de detalle: título, avatar, badge, tabs, timeline y campos de metadata"}],"output":"Componente ReactElement que renderiza página de detalle con header, grid de campos y timeline de actividad"}


id: dialog_ts_ui
name: dialog
kind: component
lang: ts
domain: ui
version: 1.0.0
purity: impure
signature: Dialog(props: DialogRootProps): JSX.Element
tags: ["dialog","modal","overlay","component","ui","interactive","mantine"]
uses_functions: []
uses_types: []
returns: []
imports: ["@mantine/core","react"]
tests: []
file_path: frontend/functions/ui/dialog.tsx
created_at: 2026-04-05T11:22:32Z
updated_at: 2026-04-06T00:00:55Z
emits: ["onOpenChange"]
framework: react
variant: ["default"]
content_hash: 8dd9f2cfdd8a9a435a7491a15f50f5156403ad77ec0213d196366f5c429d7444
source_repo: https://gitea-dgg044oo04woo4ggcsws4gk0.organic-machine.com/Bl4cksmith/Frontend_Library
source_license: MIT
source_file: frontend/src/components/ui/dialog.tsx
description:
  Diálogo modal accesible con close button y sistema de slots (header, footer, title, description). Mantine Modal.

example:
  ```tsx
  <Dialog>
    <DialogTrigger asChild><Button>Open</Button></DialogTrigger>
    <DialogContent>
      <DialogHeader>
        <DialogTitle>Título</DialogTitle>
        <DialogDescription>Descripción</DialogDescription>
      </DialogHeader>
      <p>Contenido</p>
      <DialogFooter>
        <Button>Confirmar</Button>
      </DialogFooter>
    </DialogContent>
  </Dialog>
  ```

props:
  [{"name":"showCloseButton","type":"boolean","required":false,"description":"Mostrar botón de cerrar (default true)"}]

notes:
  10 subcomponentes exportados. Mantine Modal para accesibilidad completa (focus trap, escape, click outside). DialogPortal y DialogOverlay son no-ops mantenidos por compatibilidad.

code:
  import * as React from 'react'
  import { Modal, Box, Text, Group } from '@mantine/core'

  interface DialogProps {
    open?: boolean
    onOpenChange?: (open: boolean) => void
    children: React.ReactNode
  }

  const DialogContext = React.createContext<{
    open: boolean
    setOpen: (open: boolean) => void
  }>({ open: false, setOpen: () => {} })

  function Dialog({ open: controlledOpen, onOpenChange, children }: DialogProps) {
    const [internalOpen, setInternalOpen] = React.useState(false)
    const open = controlledOpen ?? internalOpen
    const setOpen = React.useCallback(
      (v: boolean) => {
        onOpenChange?.(v)
        if (controlledOpen === undefined) setInternalOpen(v)
      },
      [controlledOpen, onOpenChange],
    )
    return (
      <DialogContext.Provider value={{ open, setOpen }}>
        {children}
      </DialogContext.Provider>
    )
  }

  function DialogTrigger({ children, ...props }: React.ComponentProps<'button'>) {
    const { setOpen } = React.useContext(DialogContext)
    return (
      <button type="button" data-slot="dialog-trigger" onClick={() => setOpen(true)} {...props}>
        {children}
      </button>
    )
  }

  function DialogPortal({ children }: { children: React.ReactNode }) {
    return <>{children}</>
  }

  function DialogClose({ children, ...props }: React.ComponentProps<'button'>) {
    const { setOpen } = React.useContext(DialogContext)
    return (
      <button type="button" data-slot="dialog-close" onClick={() => setOpen(false)} {...props}>
        {children}
      </button>
    )
  }

  function DialogOverlay() {
    return null
  }

  function DialogContent({
    children,
    showCloseButton = true,
    className,
    ...props
  }: React.ComponentProps<'div'> & { showCloseButton?: boolean }) {
    const { open, setOpen } = React.useContext(DialogContext)
    return (
      <Modal
        opened={open}
        onClose={() => setOpen(false)}
        withCloseButton={showCloseButton}
        radius="md"
        padding="md"
        size="sm"
        centered
        data-slot="dialog-content"
        className={className}
        {...props}
      >
        {children}
      </Modal>
    )
  }

  function DialogHeader({ className, ...props }: React.ComponentProps<'div'>) {
    return <Box data-slot="dialog-header" mb="xs" className={className} {...props} />
  }

  function DialogFooter({ className, children, ...props }: React.ComponentProps<'div'>) {
    return (
      <Group
        data-slot="dialog-footer"
        justify="flex-end"
        gap="sm"
        mt="md"
        pt="sm"
        style={{ borderTop: '1px solid var(--mantine-color-default-border)' }}
        className={className}
        {...props}
      >
        {children}
      </Group>
    )
  }

  function DialogTitle({ className, children, ...props }: React.ComponentProps<'div'>) {
    return (
      <Text
        component="div"
        data-slot="dialog-title"
        fw={500}
        size="md"
        className={className}
        {...props}
      >
        {children}
      </Text>
    )
  }

  function DialogDescription({ className, children, ...props }: React.ComponentProps<'div'>) {
    return (
      <Text
        component="div"
        data-slot="dialog-description"
        size="sm"
        c="dimmed"
        className={className}
        {...props}
      >
        {children}
      </Text>
    )
  }

  export { Dialog, DialogClose, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogOverlay, DialogPortal, DialogTitle, DialogTrigger }

params_schema:
  {"output":"Componente Dialog que renderiza modal accesible con focus trap y sistema de slots composables via Mantine Modal"}


id: dropdown_menu_ts_ui
name: dropdown_menu
kind: component
lang: ts
domain: ui
version: 1.0.0
purity: impure
signature: DropdownMenu(props: DropdownMenuProps): JSX.Element
tags: ["dropdown","menu","component","ui","interactive","overlay","mantine"]
uses_functions: []
uses_types: []
returns: []
imports: ["@mantine/core"]
tests: []
file_path: frontend/functions/ui/dropdown_menu.tsx
created_at: 2026-04-05T11:22:32Z
updated_at: 2026-04-06T00:00:55Z
emits: ["onOpenChange"]
framework: react
variant: []
content_hash: 208935252cc7c63176607e43d2c89f566f0ecf17494e2475fb1d8fd55dd94134
description:
  Menu de acciones y contexto accesible con items, checkboxes, radios, separadores y submenus. Base-UI Menu primitive.

example:
  ```tsx
  <DropdownMenu>
    <DropdownMenuTrigger asChild>
      <Button variant="outline">Acciones</Button>
    </DropdownMenuTrigger>
    <DropdownMenuContent>
      <DropdownMenuLabel>Mi cuenta</DropdownMenuLabel>
      <DropdownMenuSeparator />
      <DropdownMenuItem onActivate={() => console.log("Perfil")}>
        Perfil
      </DropdownMenuItem>
      <DropdownMenuCheckboxItem checked={showBookmarks} onCheckedChange={setShowBookmarks}>
        Marcadores
      </DropdownMenuCheckboxItem>
      <DropdownMenuSeparator />
      <DropdownMenuSub>
        <DropdownMenuSubTrigger>Mas opciones</DropdownMenuSubTrigger>
        <DropdownMenuSubContent>
          <DropdownMenuItem>Opcion A</DropdownMenuItem>
        </DropdownMenuSubContent>
      </DropdownMenuSub>
    </DropdownMenuContent>
  </DropdownMenu>
  ```

props:
  [{"name":"open","type":"boolean","required":false,"description":"Estado controlado de apertura"},{"name":"defaultOpen","type":"boolean","required":false,"description":"Estado inicial de apertura"},{"name":"onOpenChange","type":"(open: boolean) =\u003e void","required":false,"description":"Callback cuando cambia el estado"},{"name":"modal","type":"boolean","required":false,"description":"Comportamiento modal (default: true)"}]

notes:
  Exports: DropdownMenu, DropdownMenuTrigger, DropdownMenuContent, DropdownMenuItem, DropdownMenuCheckboxItem, DropdownMenuRadioGroup, DropdownMenuRadioItem, DropdownMenuGroup, DropdownMenuLabel, DropdownMenuSeparator, DropdownMenuShortcut, DropdownMenuSub, DropdownMenuSubTrigger, DropdownMenuSubContent, DropdownMenuPortal.

code:
  import * as React from 'react'
  import { Menu, Text } from '@mantine/core'

  function DropdownMenu({ children, ...props }: { children: React.ReactNode; open?: boolean; defaultOpen?: boolean; onOpenChange?: (open: boolean) => void; modal?: boolean }) {
    return (
      <Menu
        opened={props.open}
        defaultOpened={props.defaultOpen}
        onChange={props.onOpenChange}
        withinPortal
        shadow="md"
      >
        {children}
      </Menu>
    )
  }

  function DropdownMenuTrigger({ children, ...props }: { children: React.ReactNode; asChild?: boolean; className?: string }) {
    return <Menu.Target {...props}>{children}</Menu.Target>
  }

  function DropdownMenuPortal({ children }: { children?: React.ReactNode }) {
    return <>{children}</>
  }

  function DropdownMenuContent({ children, className }: { children?: React.ReactNode; className?: string; sideOffset?: number }) {
    return <Menu.Dropdown className={className}>{children}</Menu.Dropdown>
  }

  function DropdownMenuItem({ children, className, inset, ...props }: {
    children?: React.ReactNode
    className?: string
    inset?: boolean
    onClick?: () => void
    onActivate?: () => void
    disabled?: boolean
  }) {
    return (
      <Menu.Item
        className={className}
        onClick={props.onClick ?? props.onActivate}
        disabled={props.disabled}
        pl={inset ? 'xl' : undefined}
      >
        {children}
      </Menu.Item>
    )
  }

  function DropdownMenuCheckboxItem({ children, className, checked, onCheckedChange, ...props }: {
    children?: React.ReactNode
    className?: string
    checked?: boolean
    onCheckedChange?: (checked: boolean) => void
    disabled?: boolean
  }) {
    return (
      <Menu.Item
        className={className}
        onClick={() => onCheckedChange?.(!checked)}
        disabled={props.disabled}
        leftSection={checked ? <span style={{ fontSize: 14 }}>✓</span> : <span style={{ width: 14 }} />}
      >
        {children}
      </Menu.Item>
    )
  }

  function DropdownMenuRadioGroup({ children }: { children?: React.ReactNode; value?: string; onValueChange?: (value: string) => void }) {
    return <>{children}</>
  }

  function DropdownMenuRadioItem({ children, className, value, ...props }: {
    children?: React.ReactNode
    className?: string
    value?: string
    disabled?: boolean
    onClick?: () => void
  }) {
    return (
      <Menu.Item className={className} onClick={props.onClick} disabled={props.disabled}>
        {children}
      </Menu.Item>
    )
  }

  function DropdownMenuGroup({ children }: { children?: React.ReactNode }) {
    return <>{children}</>
  }

  function DropdownMenuLabel({ children, className, inset }: { children?: React.ReactNode; className?: string; inset?: boolean }) {
    return (
      <Menu.Label className={className} pl={inset ? 'xl' : undefined}>
        {children}
      </Menu.Label>
    )
  }

  function DropdownMenuSeparator({ className }: { className?: string }) {
    return <Menu.Divider className={className} />
  }

  function DropdownMenuShortcut({ children, className }: { children?: React.ReactNode; className?: string }) {
    return <Text span size="xs" c="dimmed" ml="auto" className={className}>{children}</Text>
  }

  function DropdownMenuSub({ children }: { children?: React.ReactNode }) {
    return <>{children}</>
  }

  function DropdownMenuSubTrigger({ children, className, inset }: { children?: React.ReactNode; className?: string; inset?: boolean }) {
    return (
      <Menu.Item className={className} pl={inset ? 'xl' : undefined}>
        {children}
      </Menu.Item>
    )
  }

  function DropdownMenuSubContent({ children, className }: { children?: React.ReactNode; className?: string }) {
    return <Menu.Dropdown className={className}>{children}</Menu.Dropdown>
  }

  export {
    DropdownMenu,
    DropdownMenuCheckboxItem,
    DropdownMenuContent,
    DropdownMenuGroup,
    DropdownMenuItem,
    DropdownMenuLabel,
    DropdownMenuPortal,
    DropdownMenuRadioGroup,
    DropdownMenuRadioItem,
    DropdownMenuSeparator,
    DropdownMenuShortcut,
    DropdownMenuSub,
    DropdownMenuSubContent,
    DropdownMenuSubTrigger,
    DropdownMenuTrigger,
  }

params_schema:
  {"output":"Componente DropdownMenu que renderiza menú desplegable accesible con items, checkboxes, radios y submenus"}


id: dropzone_ts_ui
name: dropzone
kind: component
lang: ts
domain: ui
version: 1.0.0
purity: impure
signature: Dropzone(props: DropzoneProps): JSX.Element
tags: ["dropzone","upload","drag-drop","file","component","ui","interactive","mantine"]
uses_functions: []
uses_types: []
returns: []
imports: ["@mantine/dropzone"]
tests: []
file_path: frontend/functions/ui/dropzone.tsx
created_at: 2026-04-06T10:42:46Z
updated_at: 2026-04-06T10:42:46Z
emits: ["onDrop","onReject"]
framework: react
variant: []
content_hash: 9f4890f617db6d59f3329aa9a888c0a062106978255e1c7b3fca029478d955fc
description:
  Zona de drag-and-drop para archivos con estados idle/accept/reject, límite de tamaño y tipos MIME. Wrapper sobre Mantine Dropzone.

example:
  ```tsx
  import {
    Dropzone,
    DropzoneAccept,
    DropzoneReject,
    DropzoneIdle,
    IMAGE_MIME_TYPE,
  } from '@fn_library/dropzone'
  import { IconPhoto, IconUpload, IconX } from '@tabler/icons-react'
  import { Group, Text } from '@mantine/core'

  function ImageUploader() {
    return (
      <Dropzone
        onDrop={(files) => console.log('Archivos aceptados:', files)}
        onReject={(files) => console.log('Archivos rechazados:', files)}
        accept={IMAGE_MIME_TYPE}
        maxSize={5 * 1024 ** 2}
      >
        <Group justify="center" gap="xl" mih={120} style={{ pointerEvents: 'none' }}>
          <DropzoneAccept>
            <IconUpload size={52} stroke={1.5} />
          </DropzoneAccept>
          <DropzoneReject>
            <IconX size={52} stroke={1.5} />
          </DropzoneReject>
          <DropzoneIdle>
            <IconPhoto size={52} stroke={1.5} />
          </DropzoneIdle>
          <div>
            <Text size="xl" inline>
              Arrastra imágenes aquí o haz clic para seleccionar
            </Text>
            <Text size="sm" c="dimmed" inline mt={7}>
              Máximo 5 MB por imagen
            </Text>
          </div>
        </Group>
      </Dropzone>
    )
  }
  ```

props:
  [{"name":"onDrop","type":"(files: File[]) =\u003e void","required":false,"description":"Callback ejecutado cuando el usuario suelta archivos aceptados"},{"name":"onReject","type":"(files: FileRejection[]) =\u003e void","required":false,"description":"Callback ejecutado cuando el usuario suelta archivos rechazados (tipo o tamaño inválido)"},{"name":"accept","type":"Record\u003cstring, string[]\u003e","required":false,"description":"Tipos MIME aceptados. Usar IMAGE_MIME_TYPE o MIME_TYPES para constantes predefinidas"},{"name":"maxSize","type":"number","required":false,"description":"Tamaño máximo de archivo en bytes"},{"name":"multiple","type":"boolean","required":false,"description":"Permite seleccionar múltiples archivos a la vez"},{"name":"loading","type":"boolean","required":false,"description":"Muestra estado de carga y desactiva la interacción"},{"name":"disabled","type":"boolean","required":false,"description":"Desactiva el dropzone"},{"name":"children","type":"React.ReactNode","required":true,"description":"Contenido interno, generalmente compuesto con DropzoneAccept, DropzoneReject y DropzoneIdle"}]

notes:
  El prop `onDrop` tiene un default vacío (`() => {}`) para que el componente sea válido sin handler. Siempre sobreescribirlo en uso real.

  Sub-componentes exportados:
  - `DropzoneAccept` — visible cuando el archivo arrastrado es aceptado (tipo y tamaño válidos)
  - `DropzoneReject` — visible cuando el archivo es rechazado
  - `DropzoneIdle` — visible en estado de reposo
  - `DropzoneFullScreen` — captura drops en cualquier parte de la pantalla

  Constantes de tipos MIME exportadas:
  - `IMAGE_MIME_TYPE` — imágenes comunes (png, jpg, gif, webp, etc.)
  - `MIME_TYPES` — objeto con claves por tipo (pdf, csv, xlsx, mp4, etc.)

code:
  import { Dropzone as MantineDropzone, IMAGE_MIME_TYPE, MIME_TYPES } from '@mantine/dropzone'
  import type { DropzoneProps as MantineDropzoneProps } from '@mantine/dropzone'
  import '@mantine/dropzone/styles.css'

  interface DropzoneProps extends Partial<MantineDropzoneProps> {
    children: React.ReactNode
  }

  function Dropzone({ children, ...props }: DropzoneProps) {
    return <MantineDropzone onDrop={() => {}} {...props}>{children}</MantineDropzone>
  }

  // Re-export sub-components and constants
  const DropzoneAccept = MantineDropzone.Accept
  const DropzoneReject = MantineDropzone.Reject
  const DropzoneIdle = MantineDropzone.Idle
  const DropzoneFullScreen = MantineDropzone.FullScreen

  export { Dropzone, DropzoneAccept, DropzoneReject, DropzoneIdle, DropzoneFullScreen, IMAGE_MIME_TYPE, MIME_TYPES }
  export type { DropzoneProps }

params_schema:
  {"output":"Componente Dropzone que renderiza área de arrastrar y soltar archivos con feedback visual"}


id: empty_state_ts_ui
name: empty_state
kind: component
lang: ts
domain: ui
version: 1.0.0
purity: impure
signature: EmptyState(props: EmptyStateProps): JSX.Element
tags: ["empty-state","placeholder","no-data","component","ui","mantine"]
uses_functions: []
uses_types: []
returns: []
imports: ["@mantine/core","@tabler/icons-react"]
tests: []
file_path: frontend/functions/ui/empty_state.tsx
created_at: 2026-04-06T10:43:43Z
updated_at: 2026-04-06T10:43:43Z
emits: ["onAction"]
framework: react
variant: []
content_hash: 0631ae3f509271461151c97b0caedf4ae7cc6ce163c599630ee6d45ba72bc48e
description:
  Placeholder para listas y tablas vacías con icono, título, descripción y acción opcional. Tabler Icons por defecto.

example:
  ```tsx
  import { EmptyState } from '@fn_library/empty_state'

  // Default — sin datos
  <EmptyState />

  // Con acción
  <EmptyState
    title="No functions found"
    description="Try adjusting your search or create a new function."
    actionLabel="Create function"
    onAction={() => navigate('/new')}
  />

  // Con icono custom
  import { IconDatabase } from '@tabler/icons-react'

  <EmptyState
    icon={<IconDatabase size={48} stroke={1.5} />}
    title="No databases connected"
    description="Connect a database to start querying data."
    size="lg"
  />

  // Dentro de una Card
  import { Card } from '@mantine/core'
  import { EmptyState } from '@fn_library/empty_state'

  <Card withBorder p="xl">
    <EmptyState
      title="No results"
      description="Your query returned no rows."
      size="sm"
    />
  </Card>
  ```

props:
  [{"name":"icon","type":"React.ReactNode","required":false,"description":"Icono a mostrar. Default: IconInbox de @tabler/icons-react."},{"name":"title","type":"string","required":false,"description":"Título del empty state. Default: 'No data found'."},{"name":"description","type":"string","required":false,"description":"Descripción explicativa. Default: 'There are no items to display yet.'."},{"name":"actionLabel","type":"string","required":false,"description":"Texto del botón de acción. Se muestra solo si también hay onAction."},{"name":"onAction","type":"() =\u003e void","required":false,"description":"Callback del botón de acción. Se muestra solo si también hay actionLabel."},{"name":"size","type":"MantineSize","required":false,"description":"Tamaño general del componente. Afecta el icono, texto y botón. Default: 'md'."},{"name":"children","type":"React.ReactNode","required":false,"description":"Contenido custom renderizado debajo de la descripción y antes del botón."}]

notes:
  El tamaño del icono escala con `size`: xs=32, sm=40, md=48, lg=64, xl=80.
  El orden del heading (`Title order`) es 5 para xs/sm y 4 para md/lg/xl.
  El botón usa `variant="light"` de Mantine — hereda el color primario del tema.
  `children` se renderiza entre la descripción y el botón, útil para filtros o links adicionales.

code:
  import * as React from 'react'
  import { Button, Stack, Text, Title, type MantineSize } from '@mantine/core'
  import { IconInbox } from '@tabler/icons-react'

  interface EmptyStateProps {
    /** Icono a mostrar (default: IconInbox) */
    icon?: React.ReactNode
    /** Título */
    title?: string
    /** Descripción */
    description?: string
    /** Texto del botón de acción */
    actionLabel?: string
    /** Callback del botón */
    onAction?: () => void
    /** Tamaño general */
    size?: MantineSize
    /** Contenido custom debajo de la descripción */
    children?: React.ReactNode
  }

  function EmptyState({
    icon,
    title = 'No data found',
    description = 'There are no items to display yet.',
    actionLabel,
    onAction,
    size = 'md',
    children,
  }: EmptyStateProps) {
    const iconSize = size === 'xs' ? 32 : size === 'sm' ? 40 : size === 'lg' ? 64 : size === 'xl' ? 80 : 48

    return (
      <Stack align="center" gap="sm" py="xl">
        <Text c="dimmed" style={{ opacity: 0.5 }}>
          {icon || <IconInbox size={iconSize} stroke={1.5} />}
        </Text>
        <Title order={size === 'xs' || size === 'sm' ? 5 : 4} ta="center">
          {title}
        </Title>
        <Text c="dimmed" size={size} ta="center" maw={400}>
          {description}
        </Text>
        {children}
        {actionLabel && onAction && (
          <Button variant="light" size={size} onClick={onAction} mt="xs">
            {actionLabel}
          </Button>
        )}
      </Stack>
    )
  }

  export { EmptyState }
  export type { EmptyStateProps }

params_schema:
  {"params":[{"name":"props","desc":"Props del componente EmptyState"}],"output":"Componente EmptyState centrado con icono, mensaje y botón de acción para estados sin datos"}


id: error_page_ts_ui
name: error_page
kind: component
lang: ts
domain: ui
version: 1.0.0
purity: impure
signature: ErrorPage(config: ErrorPageConfig): JSX.Element
tags: ["error","404","500","page","empty-state","ui","mantine"]
uses_functions: []
uses_types: []
returns: []
imports: ["react","@mantine/core"]
tests: []
file_path: frontend/functions/ui/error_page.tsx
created_at: 2026-04-06T10:42:46Z
updated_at: 2026-04-06T10:43:09Z
props: []
emits: ["onAction"]
framework: react
variant: []
content_hash: ebd12cccde155e575d2a8db82c2e95c65b59f5832b6963408c97298ab881be33
description:
  Genera página de error con código grande, título, descripción y acciones. Soporta 404, 500, 403 y cualquier código custom.

example:
  ```tsx
  import { ErrorPage } from '@fn_library/error_page'
  import { Button } from '@mantine/core'

  // 404 con defaults
  <ErrorPage onAction={() => navigate('/')} />

  // 500 custom
  <ErrorPage
    code={500}
    title="Internal Server Error"
    description="Something went wrong on our end. Please try again later or contact support."
    actionLabel="Retry"
    onAction={() => window.location.reload()}
  />

  // 403 con acciones extra
  <ErrorPage
    code={403}
    title="Access Denied"
    description="You don't have permission to view this page. Contact your administrator to request access."
    actionLabel="Go to Dashboard"
    onAction={() => navigate('/dashboard')}
    extraActions={
      <Button variant="outline" size="md" onClick={() => navigate('/login')}>
        Switch Account
      </Button>
    }
  />
  ```

notes:
  El código de error se muestra con `fz={120}` y opacidad reducida (0.25) para crear un efecto visual de fondo sin distraer del mensaje. Acepta `number | string` para soportar códigos custom como "503" o "Maintenance".

code:
  import * as React from 'react'
  import { Button, Container, Group, Stack, Text, Title } from '@mantine/core'

  interface ErrorPageConfig {
    /** Código de error (404, 500, 403, etc.) */
    code?: number | string
    /** Título del error */
    title?: string
    /** Descripción del error */
    description?: string
    /** Texto del botón de acción */
    actionLabel?: string
    /** Callback del botón */
    onAction?: () => void
    /** Acciones extra además del botón principal */
    extraActions?: React.ReactNode
  }

  function ErrorPage({
    code = 404,
    title = 'Page not found',
    description = 'The page you are looking for does not exist. You may have mistyped the address, or the page has been moved to another URL.',
    actionLabel = 'Go back to home',
    onAction,
    extraActions,
  }: ErrorPageConfig) {
    return (
      <Container py={80}>
        <Stack align="center" gap="md">
          <Text
            fz={120}
            fw={900}
            c="dimmed"
            style={{ lineHeight: 1, opacity: 0.25 }}
          >
            {code}
          </Text>
          <Title order={2} ta="center">
            {title}
          </Title>
          <Text c="dimmed" size="lg" ta="center" maw={500}>
            {description}
          </Text>
          <Group mt="md">
            <Button size="md" onClick={onAction}>
              {actionLabel}
            </Button>
            {extraActions}
          </Group>
        </Stack>
      </Container>
    )
  }

  export { ErrorPage }
  export type { ErrorPageConfig }

params_schema:
  {"params":[{"name":"code","desc":"Código de error numérico o string a mostrar prominentemente (404, 500, 403, o cualquier valor custom)"},{"name":"title","desc":"Título del error mostrado bajo el código. Default: 'Page not found'"},{"name":"description","desc":"Descripción explicativa del error. Default: mensaje genérico de página no encontrada"},{"name":"actionLabel","desc":"Texto del botón de acción principal. Default: 'Go back to home'"},{"name":"onAction","desc":"Callback invocado al pulsar el botón de acción principal"},{"name":"extraActions","desc":"Nodos React adicionales renderizados junto al botón principal (botones secundarios, links, etc.)"}],"output":"Página de error centrada con código prominente, mensaje descriptivo y botones de acción"}


id: file_input_ts_ui
name: file_input
kind: component
lang: ts
domain: ui
version: 1.0.0
purity: impure
signature: FileInput(props: FileInputProps): JSX.Element
tags: ["file","upload","input","form","component","ui","interactive","mantine"]
uses_functions: []
uses_types: []
returns: []
imports: ["@mantine/core"]
tests: []
file_path: frontend/functions/ui/file_input.tsx
created_at: 2026-04-06T10:43:43Z
updated_at: 2026-04-06T10:43:43Z
emits: ["onChange"]
framework: react
variant: []
content_hash: 21a2914e0ea648a59f5097e7ce24349839b9ad86b5e4f0e98adc04cc77d7e186
description:
  Input de archivos con soporte para múltiples archivos, tipos aceptados y botón de limpiar. Wrapper sobre Mantine FileInput.

example:
  ```tsx
  import { FileInput } from '@fn_library'

  // Archivo único
  <FileInput
    label="Subir documento"
    placeholder="Selecciona un archivo"
    clearable
  />

  // Múltiples archivos
  <FileInput
    label="Subir archivos"
    placeholder="Selecciona uno o varios archivos"
    multiple
    clearable
  />

  // Solo imágenes
  <FileInput
    label="Subir imagen"
    placeholder="Selecciona una imagen"
    accept="image/*"
    clearable
  />
  ```

props:
  [{"name":"multiple","type":"boolean","required":false,"description":"Permite seleccionar múltiples archivos"},{"name":"accept","type":"string","required":false,"description":"Tipos MIME o extensiones aceptadas (ej: 'image/*', '.pdf,.docx')"},{"name":"clearable","type":"boolean","required":false,"description":"Muestra botón para limpiar el archivo seleccionado"},{"name":"value","type":"File | File[] | null","required":false,"description":"Valor controlado del input"},{"name":"onChange","type":"(value: File | File[] | null) =\u003e void","required":false,"description":"Callback que se dispara al seleccionar o limpiar un archivo"},{"name":"placeholder","type":"string","required":false,"description":"Texto mostrado cuando no hay archivo seleccionado"},{"name":"label","type":"string","required":false,"description":"Etiqueta visible sobre el input"},{"name":"disabled","type":"boolean","required":false,"description":"Deshabilita el input"},{"name":"size","type":"'xs' | 'sm' | 'md' | 'lg' | 'xl'","required":false,"description":"Tamaño del componente"}]

notes:
  Wrapper directo sobre `FileInput` de `@mantine/core`. Acepta todas las props de Mantine sin restricciones.

  Para `multiple: true`, el tipo de `value` y `onChange` cambia a `File[] | null` automáticamente gracias al tipado genérico de Mantine.

  El prop `clearable` añade un ícono de X que permite vaciar la selección sin reabrir el explorador de archivos.

code:
  import { FileInput as MantineFileInput, type FileInputProps as MantineFileInputProps } from '@mantine/core'

  interface FileInputProps extends MantineFileInputProps {}

  function FileInput(props: FileInputProps) {
    return <MantineFileInput {...props} />
  }

  export { FileInput }
  export type { FileInputProps }

params_schema:
  {"params":[{"name":"props","desc":"Props de FileInput: archivo(s) seleccionado(s), tipos aceptados, modo múltiple, estado controlado y apariencia"}],"output":"Componente FileInput que renderiza input para selección de archivos con preview del nombre"}


id: form_field_ts_ui
name: form_field
kind: component
lang: ts
domain: ui
version: 1.0.0
purity: impure
signature: FormField(props: FormFieldProps): JSX.Element
tags: ["form","field","label","error","component","ui","accessibility"]
uses_functions: []
uses_types: []
returns: []
imports: ["@mantine/core"]
tests: []
file_path: frontend/functions/ui/form_field.tsx
created_at: 2026-04-06T00:12:06Z
updated_at: 2026-04-06T00:12:06Z
emits: []
framework: react
variant: ["default"]
content_hash: a69d4fc1004c567770aa66f0276945fb6e3cae21962ef397bd8fda05a953e5df
source_repo: https://gitea-dgg044oo04woo4ggcsws4gk0.organic-machine.com/Bl4cksmith/Frontend_Library
source_license: MIT
source_file: frontend/src/components/ui/form-field.tsx
description:
  Wrapper de campo de formulario con label, helper text, error y ARIA automáticos. Inyecta id y aria-describedby a hijos.

example:
  ```tsx
  <FormField label="Email" helperText="Tu email corporativo" error={errors.email}>
    <Input type="email" />
  </FormField>
  ```

props:
  [{"name":"label","type":"string","required":false,"description":"Texto del label"},{"name":"helperText","type":"string","required":false,"description":"Texto de ayuda"},{"name":"error","type":"string","required":false,"description":"Mensaje de error (reemplaza helperText)"},{"name":"children","type":"ReactNode","required":true,"description":"Input o componente de formulario"}]

code:
  import * as React from 'react'
  import { Box, Text } from '@mantine/core'

  interface FormFieldProps {
    label?: string
    helperText?: string
    error?: string
    children: React.ReactNode
    className?: string
  }

  function FormField({ label, helperText, error, children, className }: FormFieldProps) {
    const id = React.useId()
    const inputId = `${id}-input`
    const helperId = `${id}-helper`
    const errorId = `${id}-error`

    const describedBy = [helperText ? helperId : null, error ? errorId : null].filter(Boolean).join(' ') || undefined

    const childWithProps = React.Children.map(children, (child) => {
      if (React.isValidElement(child)) {
        return React.cloneElement(child as React.ReactElement<Record<string, unknown>>, {
          id: inputId,
          'aria-invalid': error ? true : undefined,
          'aria-describedby': describedBy,
          error: error || undefined,
        })
      }
      return child
    })

    return (
      <Box className={className}>
        {label && (
          <Text component="label" htmlFor={inputId} size="sm" fw={500} mb={4} display="block">
            {label}
          </Text>
        )}
        {childWithProps}
        {helperText && !error && (
          <Text id={helperId} size="sm" c="dimmed" mt={4}>
            {helperText}
          </Text>
        )}
        {error && (
          <Text id={errorId} size="sm" c="red" mt={4}>
            {error}
          </Text>
        )}
      </Box>
    )
  }

  export { FormField }
  export type { FormFieldProps }

params_schema:
  {"output":"Componente FormField que renderiza wrapper de campo con label, helper text, error y ARIA automáticos"}


id: graph_container_ts_ui
name: graph_container
kind: component
lang: ts
domain: ui
version: 1.0.0
purity: impure
signature: GraphContainer(props: GraphContainerProps): JSX.Element
description: Interactive graph visualization with sigma.js, graphology, and ForceAtlas2 layout
tags: ["component","ui","graph","visualization","sigma","graphology","network"]
uses_functions: []
uses_types: []
returns: []
imports: ["graphology","graphology-layout-forceatlas2","sigma"]
tests: []
file_path: frontend/functions/ui/graph/index.tsx
created_at: 2026-04-05T11:22:32Z
updated_at: 2026-04-06T21:31:43Z
emits: []
framework: react
variant: []
content_hash: cb9cbaa58b4181c4fe040b6ed32b8deed1198d2e4bed149c2f11560c7f231868
example:
  ```tsx
  import { GraphContainer } from '@fn_library/graph'
  import type { GraphData } from '@fn_library/graph'

  const data: GraphData = {
    nodes: [
      { id: '1', label: 'Node A', color: '#e74c3c', size: 10 },
      { id: '2', label: 'Node B', color: '#3498db', size: 8 },
    ],
    edges: [
      { id: 'e1', source: '1', target: '2', label: 'connects', type: 'arrow' },
    ],
  }

  function MyGraph() {
    return (
      <GraphContainer
        data={data}
        layout="organic"
        showLegend
        nodeTypes={[
          { type: 'person', color: '#e74c3c', label: 'Person' },
          { type: 'org', color: '#3498db', label: 'Organization' },
        ]}
        onNodeClick={(node) => console.log('clicked:', node.id)}
        height="500px"
      />
    )
  }
  ```

props:
  [{"name":"data","type":"GraphData","required":true,"description":"Graph data with nodes and edges arrays"},{"name":"layout","type":"'organic' | 'random'","required":false,"description":"Layout algorithm (default: organic/ForceAtlas2)"},{"name":"showLegend","type":"boolean","required":false,"description":"Show node type legend overlay"},{"name":"nodeTypes","type":"NodeType[]","required":false,"description":"Node type definitions for legend"},{"name":"onNodeClick","type":"(node: GraphNode) =\u003e void","required":false,"description":"Node click handler"},{"name":"onNodeDoubleClick","type":"(node: GraphNode) =\u003e void","required":false,"description":"Node double-click handler"},{"name":"theme","type":"GraphTheme","required":false,"description":"Visual theme overrides"},{"name":"height","type":"string | number","required":false,"description":"Container height (default: 100%)"},{"name":"className","type":"string","required":false,"description":"Additional CSS classes"}]

notes:
  - Usa graphology como estructura de datos de grafo
  - ForceAtlas2 para layout organico (iterations adaptativas segun numero de nodos)
  - Sigma.js para renderizado WebGL de alto rendimiento
  - Soporta grafos dirigidos multi-edge
  - El componente limpia la instancia Sigma al desmontar

code:
  import * as React from "react"
  import Graph from "graphology"
  import forceAtlas2 from "graphology-layout-forceatlas2"
  import { Sigma } from "sigma"

  // ── Types ─────────────────────────────────────────────────────────────────

  export interface GraphNode {
    id: string
    label: string
    type?: string
    color?: string
    size?: number
    x?: number
    y?: number
    [key: string]: unknown
  }

  export interface GraphEdge {
    id: string
    source: string
    target: string
    label?: string
    color?: string
    size?: number
    type?: "arrow" | "line"
    weight?: number
    [key: string]: unknown
  }

  export interface GraphData {
    nodes: GraphNode[]
    edges: GraphEdge[]
  }

  export interface NodeType {
    type: string
    color: string
    label: string
  }

  export interface GraphTheme {
    backgroundColor?: string
    nodeColor?: string
    nodeSize?: number
    edgeColor?: string
    edgeSize?: number
    labelColor?: string
    selectionColor?: string
  }

  export interface ContextMenuTarget {
    type: "node" | "edge" | "canvas"
    id?: string
    data?: GraphNode | GraphEdge
  }

  export interface GraphContainerProps {
    data: GraphData
    layout?: "organic" | "random"
    showToolbar?: boolean
    showLegend?: boolean
    showMinimap?: boolean
    nodeTypes?: NodeType[]
    onNodeClick?: (node: GraphNode) => void
    onNodeDoubleClick?: (node: GraphNode) => void
    onContextMenu?: (event: MouseEvent, target: ContextMenuTarget) => void
    enableSelection?: boolean
    selectionMode?: "single" | "multiple"
    theme?: GraphTheme
    height?: string | number
    className?: string
  }

  const DEFAULT_THEME: Required<GraphTheme> = {
    backgroundColor: "var(--background, #0a0a0f)",
    nodeColor: "#95a5a6",
    nodeSize: 8,
    edgeColor: "rgba(255,255,255,0.19)",
    edgeSize: 1,
    labelColor: "#e0e0e0",
    selectionColor: "#3b82f6",
  }

  // ── Component ─────────────────────────────────────────────────────────────

  function GraphContainer({
    data,
    layout = "organic",
    showLegend = false,
    nodeTypes = [],
    onNodeClick,
    onNodeDoubleClick,
    onContextMenu,
    theme: themeProp,
    height = "100%",
    className,
  }: GraphContainerProps) {
    const containerRef = React.useRef<HTMLDivElement>(null)
    const sigmaRef = React.useRef<Sigma | null>(null)
    const graphRef = React.useRef<Graph | null>(null)
    const theme = React.useMemo(
      () => ({ ...DEFAULT_THEME, ...themeProp }),
      [themeProp],
    )

    // Build + render — wait for container to have dimensions
    const [ready, setReady] = React.useState(false)
    React.useEffect(() => {
      const el = containerRef.current
      if (!el) return
      if (el.clientHeight > 0 && el.clientWidth > 0) {
        setReady(true)
        return
      }
      const ro = new ResizeObserver((entries) => {
        for (const entry of entries) {
          if (entry.contentRect.height > 0 && entry.contentRect.width > 0) {
            setReady(true)
            ro.disconnect()
          }
        }
      })
      ro.observe(el)
      return () => ro.disconnect()
    }, [])

    React.useEffect(() => {
      const el = containerRef.current
      if (!el || !ready) return

      // Cleanup previous instance
      if (sigmaRef.current) {
        sigmaRef.current.kill()
        sigmaRef.current = null
      }

      const g = new Graph({ multi: true, type: "directed" })
      graphRef.current = g

      // Add nodes — store entity type as entityType to avoid sigma interpreting it as render program
      for (const n of data.nodes) {
        g.addNode(n.id, {
          label: n.label,
          x: n.x ?? (Math.random() - 0.5) * 10,
          y: n.y ?? (Math.random() - 0.5) * 10,
          size: n.size ?? theme.nodeSize,
          color: n.color ?? theme.nodeColor,
          entityType: n.type,
        })
      }

      // Add edges
      for (const e of data.edges) {
        try {
          g.addEdgeWithKey(e.id, e.source, e.target, {
            label: e.label,
            size: e.size ?? theme.edgeSize,
            color: e.color ?? theme.edgeColor,
            type: e.type === "arrow" ? "arrow" : "line",
            weight: e.weight ?? 1,
          })
        } catch {
          // skip duplicate keys
        }
      }

      // Layout
      if (layout === "organic" && g.order > 0) {
        forceAtlas2.assign(g, {
          iterations: Math.min(500, Math.max(100, g.order * 5)),
          settings: {
            gravity: 1,
            scalingRatio: 2,
            slowDown: 5,
            barnesHutOptimize: g.order > 300,
          },
        })
      }

      // Render
      const renderer = new Sigma(g, el, {
        allowInvalidContainer: true,
        renderEdgeLabels: false,
        defaultEdgeColor: theme.edgeColor,
        defaultNodeColor: theme.nodeColor,
        labelColor: { color: theme.labelColor },
        labelSize: 11,
      })

      sigmaRef.current = renderer

      // Events
      if (onNodeClick) {
        renderer.on("clickNode", ({ node }) => {
          const attrs = g.getNodeAttributes(node)
          onNodeClick({ id: node, ...attrs } as unknown as GraphNode)
        })
      }
      if (onNodeDoubleClick) {
        renderer.on("doubleClickNode", ({ node }) => {
          const attrs = g.getNodeAttributes(node)
          onNodeDoubleClick({ id: node, ...attrs } as unknown as GraphNode)
        })
      }
      if (onContextMenu) {
        renderer.on("rightClickNode", ({ node, event }) => {
          const mouseEvent = event.original as MouseEvent
          mouseEvent.preventDefault()
          const attrs = g.getNodeAttributes(node)
          onContextMenu(mouseEvent, {
            type: "node",
            id: node,
            data: { id: node, ...attrs } as unknown as GraphNode,
          })
        })
        renderer.on("rightClickStage", ({ event }) => {
          const mouseEvent = event.original as MouseEvent
          mouseEvent.preventDefault()
          onContextMenu(mouseEvent, { type: "canvas" })
        })
      }

      return () => {
        renderer.kill()
        sigmaRef.current = null
        graphRef.current = null
      }
    }, [data, layout, theme, onNodeClick, onNodeDoubleClick, onContextMenu, ready])

    // Container background
    const containerStyle: React.CSSProperties = {
      height,
      width: "100%",
      position: "relative",
      background: theme.backgroundColor,
      borderRadius: "var(--radius, 0.5rem)",
      overflow: "hidden",
    }

    return (
      <div className={className} style={containerStyle}>
        <div ref={containerRef} style={{ width: "100%", height: "100%" }} />
        {showLegend && nodeTypes.length > 0 && (
          <div
            style={{
              position: "absolute",
              top: 12,
              right: 12,
              background: "rgba(0,0,0,0.7)",
              backdropFilter: "blur(6px)",
              borderRadius: 8,
              padding: "10px 14px",
              fontSize: 12,
              display: "flex",
              flexDirection: "column",
              gap: 6,
            }}
          >
            {nodeTypes.map((nt) => (
              <div
                key={nt.type}
                style={{ display: "flex", alignItems: "center", gap: 8 }}
              >
                <span
                  style={{
                    width: 10,
                    height: 10,
                    borderRadius: "50%",
                    background: nt.color,
                    flexShrink: 0,
                  }}
                />
                <span style={{ color: theme.labelColor }}>{nt.label}</span>
              </div>
            ))}
          </div>
        )}
      </div>
    )
  }

  export { GraphContainer }

params_schema:
  {"output":"Componente GraphContainer que renderiza grafo interactivo con sigma.js, ForceAtlas2 layout y legend"}


id: indicator_ts_ui
name: indicator
kind: component
lang: ts
domain: ui
version: 1.0.0
purity: impure
signature: FnIndicator(props: FnIndicatorProps): JSX.Element
description: Badge indicador posicionado sobre un elemento hijo. Wrapper sobre Mantine Indicator.
tags: ["mantine","indicator","badge","notification","dot","component","ui"]
uses_functions: []
uses_types: []
returns: []
imports: ["@mantine/core"]
tests: []
file_path: frontend/functions/ui/indicator.tsx
created_at: 2026-04-06T00:11:54Z
updated_at: 2026-04-06T00:11:54Z
emits: []
framework: react
variant: []
content_hash: b70cfbbec3d0679a403c26ca26aabf3926ea11ac92161525b7307f5c66b6fcee
params_schema: {"output":"Elemento hijo con un dot/badge indicador posicionado en una esquina"}
example:
  ```tsx
  import { FnIndicator } from '@fn_library'
  import { FnActionIcon } from '@fn_library'
  import { IconBell } from '@tabler/icons-react'

  {/* Dot simple */}
  <FnIndicator processing>
    <FnActionIcon icon={<IconBell size={18} />} />
  </FnIndicator>

  {/* Con contador */}
  <FnIndicator label={5} size={16} color="blue">
    <Avatar src="user.png" />
  </FnIndicator>
  ```

props:
  [{"name":"children","type":"ReactNode","required":true,"description":"Elemento sobre el cual se posiciona el indicador"},{"name":"color","type":"MantineColor","required":false,"description":"Color del indicador, default red"},{"name":"size","type":"number","required":false,"description":"Tamano del dot en px, default 10"},{"name":"position","type":"'top-start' | 'top-center' | 'top-end' | 'middle-start' | 'middle-center' | 'middle-end' | 'bottom-start' | 'bottom-center' | 'bottom-end'","required":false,"description":"Posicion del indicador, default top-end"},{"name":"processing","type":"boolean","required":false,"description":"Animacion de pulso, default false"},{"name":"disabled","type":"boolean","required":false,"description":"Oculta el indicador, default false"},{"name":"label","type":"ReactNode","required":false,"description":"Contenido dentro del indicador (numero, texto)"}]

notes:
  Wrapper sobre Mantine `Indicator`. El `processing` prop agrega una animacion de pulso al dot. Si se provee `label`, el indicador se agranda para mostrar contenido. `disabled` oculta el indicador sin desmontar el componente.

code:
  import * as React from 'react'
  import { Indicator } from '@mantine/core'
  import type { MantineColor } from '@mantine/core'

  interface FnIndicatorProps {
    children: React.ReactNode
    color?: MantineColor
    size?: number
    position?: 'top-start' | 'top-center' | 'top-end' | 'middle-start' | 'middle-center' | 'middle-end' | 'bottom-start' | 'bottom-center' | 'bottom-end'
    processing?: boolean
    disabled?: boolean
    label?: React.ReactNode
  }

  function FnIndicator({
    children,
    color = 'red',
    size = 10,
    position = 'top-end',
    processing = false,
    disabled = false,
    label,
  }: FnIndicatorProps) {
    return (
      <Indicator
        color={color}
        size={size}
        position={position}
        processing={processing}
        disabled={disabled}
        label={label}
      >
        {children}
      </Indicator>
    )
  }

  export { FnIndicator }
  export type { FnIndicatorProps }


id: input_ts_ui
name: input
kind: component
lang: ts
domain: ui
version: 1.0.0
purity: impure
signature: Input(props: InputHTMLAttributes): JSX.Element
tags: ["input","form","component","ui","interactive","mantine"]
uses_functions: []
uses_types: []
returns: []
imports: ["@mantine/core"]
tests: []
file_path: frontend/functions/ui/input.tsx
created_at: 2026-04-05T11:22:32Z
updated_at: 2026-04-06T00:00:55Z
emits: ["onChange","onFocus","onBlur"]
framework: react
variant: ["default"]
content_hash: e816180a4058744dd3e8e5f618f6f5187213f7555d34b5cdfa98b64913df6714
source_repo: https://gitea-dgg044oo04woo4ggcsws4gk0.organic-machine.com/Bl4cksmith/Frontend_Library
source_license: MIT
source_file: frontend/src/components/ui/input.tsx
description:
  Campo de entrada accesible con soporte para iconos, grupos, validación ARIA y estados disabled/invalid. Mantine TextInput.

example:
  ```tsx
  <Input placeholder="Email" type="email" />
  <InputGroup>
    <InputIcon position="start"><SearchIcon /></InputIcon>
    <Input placeholder="Buscar..." />
  </InputGroup>
  ```

props:
  [{"name":"type","type":"string","required":false,"description":"Tipo de input HTML"},{"name":"className","type":"string","required":false,"description":"Clases CSS adicionales"}]

notes:
  Exporta Input, InputGroup e InputIcon. Usa Mantine TextInput internamente. InputGroup e InputIcon se mantienen como wrappers de compatibilidad — para nuevos usos preferir leftSection/rightSection de Mantine TextInput directamente.

code:
  import * as React from 'react'
  import { TextInput, Box } from '@mantine/core'

  function Input({
    className,
    type,
    ...props
  }: React.ComponentProps<typeof TextInput> & { type?: string }) {
    return (
      <TextInput
        type={type}
        data-slot="input"
        size="sm"
        className={className}
        {...props}
      />
    )
  }

  interface InputGroupProps {
    children: React.ReactNode
    className?: string
  }

  function InputGroup({ children, className }: InputGroupProps) {
    return (
      <Box data-slot="input-group" className={className}>
        {children}
      </Box>
    )
  }

  interface InputIconProps {
    children: React.ReactNode
    position: 'start' | 'end'
    className?: string
  }

  function InputIcon({ children, position, className }: InputIconProps) {
    return (
      <Box
        data-slot={`input-icon-${position}`}
        component="span"
        className={className}
        style={{
          display: 'inline-flex',
          alignItems: 'center',
          pointerEvents: 'none',
        }}
      >
        {children}
      </Box>
    )
  }

  export { Input, InputGroup, InputIcon }
  export type { InputGroupProps, InputIconProps }

params_schema:
  {"output":"Componente Input que renderiza campo de entrada accesible con soporte para iconos y validación ARIA"}


id: kpi_card_ts_ui
name: kpi_card
kind: component
lang: ts
domain: ui
version: 2.0.0
purity: impure
signature: KPICard(props: KPICardProps): JSX.Element
tags: ["kpi","card","metrics","dashboard","component","ui","sparkline"]
uses_functions: []
uses_types: []
returns: []
imports: ["react"]
tests: []
file_path: frontend/functions/ui/kpi_card.tsx
created_at: 2026-04-06T00:12:06Z
updated_at: 2026-04-06T00:12:06Z
emits: []
framework: react
variant: ["sm","default","lg"]
content_hash: a329e3426e037a5ff266298d4f24b2a791e4c6fc397e6ef0430cb37b1f648293
source_repo: https://gitea-dgg044oo04woo4ggcsws4gk0.organic-machine.com/Bl4cksmith/Frontend_Library
source_license: MIT
source_file: frontend/src/components/ui/kpi-card.tsx
description:
  Card de KPI con label, valor+unidad, delta descriptivo con color semántico, icono, slot de chart inline y action. 3 tamaños.

example:
  ```tsx
  import { KPICard, Sparkline } from '@fn_library'

  {/* Básico */}
  <KPICard label="Revenue" value="$12,450" delta={{ value: 12.5, isPositive: true }} />

  {/* Con unidad separada, delta descriptivo, y mini barras */}
  <KPICard
    label="Processed Prompts"
    value="124"
    unit="k"
    icon={<ZapIcon className="h-4 w-4" />}
    delta={{ value: 15, isPositive: true, label: "Prompts Increased by", suffix: "vs yesterday" }}
    chart={<Sparkline data={[5, 8, 3, 9, 6, 12, 7]} variant="bar" colors={['#3b82f6', '#8b5cf6', '#f59e0b', '#10b981', '#ef4444', '#ec4899', '#06b6d4']} height={32} />}
    action={<button className="text-muted-foreground hover:text-foreground">...</button>}
  />

  {/* Dashboard dark sin bordes */}
  <KPICard label="Sessions" value={9821} className="border-0 shadow-none" />
  ```

props:
  [{"name":"label","type":"string","required":true,"description":"Etiqueta del KPI"},{"name":"value","type":"string | number","required":true,"description":"Valor principal"},{"name":"unit","type":"string","required":false,"description":"Unidad junto al valor en font menor (ej: k, ms, %)"},{"name":"delta","type":"{ value: number; isPositive: boolean; label?: string; suffix?: string }","required":false,"description":"Cambio con dirección, label descriptivo y sufijo"},{"name":"icon","type":"ReactNode","required":false,"description":"Icono a la izquierda del label"},{"name":"action","type":"ReactNode","required":false,"description":"Slot top-right para menú o acciones"},{"name":"chart","type":"ReactNode","required":false,"description":"Slot para mini chart inline junto al valor"},{"name":"size","type":"'sm' | 'default' | 'lg'","required":false,"description":"Tamaño"}]

notes:
  - El icono ahora se renderiza a la **izquierda** del label (antes estaba a la derecha).
  - `unit` separa la unidad del valor con font menor para el efecto "124 k" del diseño.
  - `delta.label` y `delta.suffix` permiten texto descriptivo: "Increased by ▲ +15% vs yesterday".
  - `chart` es un slot genérico — pasar un `<Sparkline variant="bar" colors={[...]} />` para mini barras multicolor.
  - `action` es un slot top-right para menú contextual.
  - Usa `cn()` para merge de clases. `className="border-0 shadow-none"` para dashboards dark.

code:
  import * as React from 'react'
  import { Paper, Text, Group, Stack, Box } from '@mantine/core'

  type KPICardSize = 'sm' | 'default' | 'lg'

  interface Delta {
    value: number
    isPositive: boolean
    label?: string
    suffix?: string
  }

  interface KPICardProps extends React.HTMLAttributes<HTMLDivElement> {
    label: string
    value: string | number
    unit?: string
    delta?: Delta
    icon?: React.ReactNode
    action?: React.ReactNode
    chart?: React.ReactNode
    subtitle?: string
    size?: KPICardSize
  }

  const valueSizes: Record<KPICardSize, string> = {
    sm: '1.5rem',
    default: '1.875rem',
    lg: '2.25rem',
  }

  const unitSizes: Record<KPICardSize, string> = {
    sm: 'md',
    default: 'lg',
    lg: 'xl',
  }

  const labelSizes: Record<KPICardSize, string> = {
    sm: 'xs',
    default: 'sm',
    lg: 'md',
  }

  const KPICard = React.forwardRef<HTMLDivElement, KPICardProps>(
    ({ label, value, unit, delta, icon, action, chart, subtitle, size = 'default', className, ...props }, ref) => {
      const deltaColor = delta
        ? delta.value === 0 ? 'dimmed'
          : delta.isPositive ? 'teal'
          : 'red'
        : undefined

      return (
        <Paper ref={ref} withBorder shadow="xs" radius="md" p="md" className={className} {...props}>
          <Group justify="space-between" align="flex-start">
            <Group gap="xs" align="center">
              {icon && <Box c="dimmed">{icon}</Box>}
              <Stack gap={2}>
                <Text size={labelSizes[size]} c="dimmed">{label}</Text>
                {subtitle && <Text size="xs" c="dimmed" opacity={0.8}>{subtitle}</Text>}
              </Stack>
            </Group>
            {action && <Box c="dimmed">{action}</Box>}
          </Group>

          <Group justify="space-between" align="flex-end" mt="md" gap="lg">
            <Stack gap={4}>
              <Group gap={4} align="baseline">
                <Text fw={700} style={{ fontSize: valueSizes[size], lineHeight: 1, letterSpacing: '-0.025em' }}>
                  {value}
                </Text>
                {unit && <Text size={unitSizes[size]} c="dimmed" fw={500}>{unit}</Text>}
              </Group>
              {delta && (
                <Group gap={4} align="center">
                  {delta.label && <Text size="xs" c="dimmed">{delta.label}</Text>}
                  <Text size="xs" fw={500} c={deltaColor}>
                    {delta.isPositive ? '\u25B2' : '\u25BC'} {delta.value > 0 ? '+' : ''}{delta.value}{delta.label ? '' : '%'}
                  </Text>
                  {delta.suffix && <Text size="xs" c="dimmed">{delta.suffix}</Text>}
                </Group>
              )}
            </Stack>
            {chart && <Box style={{ flexShrink: 0 }}>{chart}</Box>}
          </Group>
        </Paper>
      )
    }
  )
  KPICard.displayName = 'KPICard'

  export { KPICard }
  export type { KPICardProps, Delta, KPICardSize }

params_schema:
  {"output":"Componente KPICard que renderiza card de métrica con label, valor, delta descriptivo, icono y slot de mini chart"}


id: label_ts_ui
name: label
kind: component
lang: ts
domain: ui
version: 1.0.0
purity: impure
signature: Label(props: LabelHTMLAttributes): JSX.Element
tags: ["label","form","component","ui","mantine"]
uses_functions: []
uses_types: []
returns: []
imports: ["@mantine/core"]
example:
  ```tsx
  <Label htmlFor="email">Email</Label>
  ```

tests: []
file_path: frontend/functions/ui/label.tsx
created_at: 2026-04-05T11:22:32Z
updated_at: 2026-04-06T00:00:55Z
props: [{"name":"className","type":"string","required":false,"description":"Clases CSS adicionales"}]
emits: []
framework: react
variant: ["default"]
content_hash: 802fbfef5f66113c50c30c5f84ee531469f077deea2a9a605306e478375b64de
source_repo: https://gitea-dgg044oo04woo4ggcsws4gk0.organic-machine.com/Bl4cksmith/Frontend_Library
source_license: MIT
source_file: frontend/src/components/ui/label.tsx
description:
  Etiqueta de formulario accesible con soporte para estados disabled. Mantine Text con component=label.

code:
  import * as React from 'react'
  import { Text } from '@mantine/core'

  function Label({ className, ...props }: React.ComponentProps<'label'>) {
    return (
      <Text
        component="label"
        data-slot="label"
        size="sm"
        fw={500}
        style={{ display: 'inline-flex', alignItems: 'center', gap: 8, userSelect: 'none' }}
        className={className}
        {...props}
      />
    )
  }

  export { Label }

params_schema:
  {"output":"Componente Label que renderiza etiqueta de formulario accesible con soporte para estados disabled"}


id: line_chart_ts_ui
name: line_chart
kind: component
lang: ts
domain: ui
version: 1.0.0
purity: impure
signature: LineChart(props: LineChartProps): JSX.Element
tags: ["chart","line","visualization","mantine","component","ui"]
uses_functions: ["chart_container_ts_ui"]
uses_types: ["ChartSeries_ts_ui"]
returns: []
imports: ["@mantine/charts","@mantine/core"]
tests: []
file_path: frontend/functions/ui/line_chart.tsx
created_at: 2026-04-05T11:22:32Z
updated_at: 2026-04-06T00:11:54Z
emits: []
framework: react
variant: ["default"]
content_hash: f669922808b5972ca951e6b47e454c8439b0ba91f8ad3c278596f4db11aa5e23
source_repo: https://gitea-dgg044oo04woo4ggcsws4gk0.organic-machine.com/Bl4cksmith/Frontend_Library
source_license: MIT
source_file: frontend/src/components/ui/charts/line-chart.tsx
description:
  Gráfico de líneas @mantine/charts con multi-series, 5 tipos de curva, líneas de referencia y tooltips.

example:
  ```tsx
  <LineChart
    data={salesData}
    xKey="month"
    series={[
      { key: "revenue", name: "Revenue", color: "#3b82f6" },
      { key: "cost", name: "Cost", color: "#ef4444" },
    ]}
    zoomable
    showLegend
  />
  ```

props:
  [{"name":"data","type":"Record\u003cstring, unknown\u003e[]","required":true,"description":"Array de datos"},{"name":"xKey","type":"string","required":true,"description":"Key del eje X"},{"name":"series","type":"Series[]","required":false,"description":"Series de datos"},{"name":"curveType","type":"'linear' | 'monotone' | 'step' | 'stepBefore' | 'stepAfter'","required":false,"description":"Tipo de curva (default monotone)"},{"name":"referenceLines","type":"Array\u003c{ y: number; label?: string; color?: string }\u003e","required":false,"description":"Líneas de referencia horizontales"}]

code:
  import { LineChart as MantineLineChart } from '@mantine/charts'
  import { Paper } from '@mantine/core'
  import { type Series, getSeriesColor } from './chart_container'

  type CurveType = 'linear' | 'monotone' | 'step' | 'stepBefore' | 'stepAfter'

  interface LineChartProps {
    data: Record<string, unknown>[]
    xKey: string
    yKey?: string
    series?: Series[]
    curveType?: CurveType
    showGrid?: boolean
    showLegend?: boolean
    showDots?: boolean
    height?: number
    xAxisFormatter?: (value: unknown) => string
    yAxisFormatter?: (value: unknown) => string
    valueFormatter?: (value: number) => string
    referenceLines?: Array<{ y: number; label?: string; color?: string }>
  }

  function LineChartComponent({
    data, xKey, yKey, series, curveType = 'monotone', showGrid = true, showLegend = false,
    showDots = true, height = 300, xAxisFormatter, yAxisFormatter,
    valueFormatter = (v) => v.toLocaleString(), referenceLines = [],
  }: LineChartProps) {
    const chartSeries = series
      ? series.map((s, i) => ({ name: s.key, label: s.name, color: getSeriesColor(i, s.color) }))
      : yKey ? [{ name: yKey, label: yKey, color: getSeriesColor(0) }] : []

    const refLines = referenceLines.map((ref) => ({
      y: ref.y,
      label: ref.label || '',
      color: ref.color || 'gray.6',
    }))

    return (
      <Paper p="md">
        <MantineLineChart
          h={height}
          data={data}
          dataKey={xKey}
          series={chartSeries}
          curveType={curveType}
          gridAxis={showGrid ? 'xy' : 'none'}
          withLegend={showLegend}
          withTooltip
          withDots={showDots}
          valueFormatter={valueFormatter}
          referenceLines={refLines}
          xAxisProps={xAxisFormatter ? { tickFormatter: xAxisFormatter } : undefined}
          yAxisProps={yAxisFormatter ? { tickFormatter: yAxisFormatter } : undefined}
        />
      </Paper>
    )
  }

  export const LineChart = LineChartComponent
  export type { LineChartProps, CurveType }

params_schema:
  {"output":"Componente LineChart que renderiza gráfico de líneas multi-series con curvas customizables y líneas de referencia"}


id: loading_overlay_ts_ui
name: loading_overlay
kind: component
lang: ts
domain: ui
version: 1.0.0
purity: impure
signature: FnLoadingOverlay(props: FnLoadingOverlayProps): JSX.Element
description: Overlay de carga con blur y opacidad configurable. Wrapper sobre Mantine LoadingOverlay.
tags: ["mantine","loading","overlay","spinner","component","ui"]
uses_functions: []
uses_types: []
returns: []
imports: ["@mantine/core"]
tests: []
file_path: frontend/functions/ui/loading_overlay.tsx
created_at: 2026-04-06T00:11:54Z
updated_at: 2026-04-06T00:11:54Z
emits: []
framework: react
variant: []
content_hash: 12ef844c4f0c972651c0a7dc25f7a668de9edacfe5901af7b28b69cf7ac46246
params_schema: {"output":"Overlay semi-transparente con spinner centrado que cubre el contenedor padre"}
example:
  ```tsx
  import { FnLoadingOverlay } from '@fn_library'

  <Box pos="relative">
    <FnLoadingOverlay visible={loading} overlayBlur={3} />
    <DataTable data={rows} />
  </Box>
  ```

props:
  [{"name":"visible","type":"boolean","required":true,"description":"Controla visibilidad del overlay"},{"name":"loaderSize","type":"number | string","required":false,"description":"Tamano del loader/spinner"},{"name":"overlayBlur","type":"number","required":false,"description":"Intensidad del blur del fondo, default 2"},{"name":"overlayOpacity","type":"number","required":false,"description":"Opacidad del overlay, default 0.5"}]

notes:
  Wrapper sobre Mantine `LoadingOverlay`. El contenedor padre necesita `position: relative` para que el overlay se posicione correctamente. Usa `loaderProps` y `overlayProps` internamente para mapear las props simplificadas.

code:
  import { LoadingOverlay } from '@mantine/core'

  interface FnLoadingOverlayProps {
    visible: boolean
    loaderSize?: number | string
    overlayBlur?: number
    overlayOpacity?: number
  }

  function FnLoadingOverlay({
    visible,
    loaderSize,
    overlayBlur = 2,
    overlayOpacity = 0.5,
  }: FnLoadingOverlayProps) {
    return (
      <LoadingOverlay
        visible={visible}
        loaderProps={loaderSize ? { size: loaderSize } : undefined}
        overlayProps={{ blur: overlayBlur, backgroundOpacity: overlayOpacity }}
      />
    )
  }

  export { FnLoadingOverlay }
  export type { FnLoadingOverlayProps }


id: mantine_provider_ts_ui
name: mantine_provider
kind: component
lang: ts
domain: ui
version: 1.0.0
purity: impure
signature: FnMantineProvider({ children, theme?, defaultColorScheme? })
tags: ["mantine","provider","theme","react"]
uses_functions: []
uses_types: []
returns: []
error_type: error_go_core
imports: ["@mantine/core","@mantine/notifications","@mantine/charts"]
tests: []
file_path: frontend/functions/ui/mantine_provider.tsx
created_at: 2026-04-05T23:51:12Z
updated_at: 2026-04-05T23:51:12Z
emits: []
framework: react
variant: []
content_hash: 9c22c3fa629b9109a8c9ffd57869b77875d260937886188d6ef9b7a0b3de43eb
params_schema: {"output":"arbol React envuelto en MantineProvider con notificaciones"}
description:
  Provider raiz de Mantine para apps del registry. Wrappea MantineProvider con Notifications incluido. Importa los CSS de @mantine/core, charts y notifications.

example:
  ```tsx
  import { createTheme, MantineColorsTuple } from '@mantine/core'
  import { FnMantineProvider } from '@fn_library'

  const brand: MantineColorsTuple = [
    '#e5f3ff', '#cde2ff', '#9ac2ff', '#64a0ff', '#3884fe',
    '#1d72fe', '#0063ff', '#0058e4', '#004ecd', '#0043b5'
  ]

  const theme = createTheme({
    colors: { brand },
    primaryColor: 'brand',
  })

  function App() {
    return (
      <FnMantineProvider theme={theme} defaultColorScheme="dark">
        {/* Tu app aqui */}
      </FnMantineProvider>
    )
  }
  ```

props:
  [{"name":"children","type":"","required":false,"description":""},{"name":"theme","type":"","required":false,"description":""},{"name":"defaultColorScheme","type":"","required":false,"description":""}]

notes:
  Reemplaza ThemeProvider + applyTheme del sistema anterior. Las apps definen su propio tema con `createTheme()` y lo pasan como prop. Los CSS de Mantine se importan una sola vez aqui.

code:
  import '@mantine/core/styles.css'
  import '@mantine/charts/styles.css'
  import '@mantine/notifications/styles.css'

  import * as React from 'react'
  import { MantineProvider, type MantineThemeOverride, type MantineColorScheme } from '@mantine/core'
  import { Notifications } from '@mantine/notifications'

  interface FnMantineProviderProps {
    children: React.ReactNode
    theme?: MantineThemeOverride
    defaultColorScheme?: MantineColorScheme
  }

  function FnMantineProvider({
    children,
    theme,
    defaultColorScheme = 'dark',
  }: FnMantineProviderProps) {
    return (
      <MantineProvider theme={theme} defaultColorScheme={defaultColorScheme}>
        <Notifications position="top-right" />
        {children}
      </MantineProvider>
    )
  }

  export { FnMantineProvider }
  export type { FnMantineProviderProps }


id: multi_select_ts_ui
name: multi_select
kind: component
lang: ts
domain: ui
version: 1.0.0
purity: impure
signature: MultiSelect(props: MultiSelectProps): JSX.Element
description: Selector múltiple con búsqueda, pills y límite de selecciones. Wrapper sobre Mantine MultiSelect.
tags: ["multi-select","form","dropdown","pills","component","ui","interactive","mantine"]
uses_functions: []
uses_types: []
returns: []
imports: ["@mantine/core"]
tests: []
file_path: frontend/functions/ui/multi_select.tsx
created_at: 2026-04-06T10:42:46Z
updated_at: 2026-04-06T10:42:46Z
emits: ["onChange"]
framework: react
variant: ["default"]
content_hash: cf6374275f78097c84c0ad2407453a377a47430753f15f1e91249d049ac33d83
params_schema: {"output":"Componente MultiSelect que renderiza dropdown con selección múltiple, búsqueda y pills"}
example:
  ```tsx
  import { MultiSelect } from '@fn_library'

  // Opciones simples
  <MultiSelect
    label="Frameworks favoritos"
    placeholder="Elige uno o más"
    data={['React', 'Vue', 'Angular', 'Svelte']}
  />

  // Con búsqueda y clearable
  <MultiSelect
    label="Tecnologías"
    placeholder="Busca y selecciona"
    searchable
    clearable
    value={selected}
    onChange={setSelected}
    data={[
      { value: 'react', label: 'React' },
      { value: 'vue', label: 'Vue' },
      { value: 'ts', label: 'TypeScript' },
    ]}
  />

  // Máximo de selecciones
  <MultiSelect
    label="Elige hasta 2"
    placeholder="Máximo 2"
    maxValues={2}
    data={['Opción A', 'Opción B', 'Opción C', 'Opción D']}
  />
  ```

props:
  [{"name":"data","type":"string[] | { value: string; label: string; disabled?: boolean }[]","required":true,"description":"Opciones del selector — strings o objetos {value, label}"},{"name":"value","type":"string[]","required":false,"description":"Valores seleccionados (controlled)"},{"name":"onChange","type":"(value: string[]) =\u003e void","required":false,"description":"Callback al cambiar la selección"},{"name":"searchable","type":"boolean","required":false,"description":"Permite buscar entre opciones"},{"name":"clearable","type":"boolean","required":false,"description":"Muestra botón para limpiar toda la selección"},{"name":"maxValues","type":"number","required":false,"description":"Número máximo de valores seleccionables"},{"name":"placeholder","type":"string","required":false,"description":"Texto cuando no hay selección"},{"name":"label","type":"string","required":false,"description":"Label del campo"},{"name":"disabled","type":"boolean","required":false,"description":"Deshabilitar el selector"},{"name":"size","type":"'xs' | 'sm' | 'md' | 'lg' | 'xl'","required":false,"description":"Tamaño del componente"}]

notes:
  - Wrapper directo sobre `MultiSelect` de `@mantine/core` v9. Todas las props de Mantine MultiSelect son válidas.
  - A diferencia de `Select`, `value` es `string[]` y `onChange` recibe `string[]`.
  - Las selecciones se muestran como pills dentro del input, eliminables con clic.
  - `maxValues` limita cuántos items pueden seleccionarse — el dropdown bloquea más selecciones al alcanzar el límite.
  - Soporta `searchable` para filtrar opciones y `clearable` para un botón de limpiar todo.

code:
  import { MultiSelect as MantineMultiSelect, type MultiSelectProps as MantineMultiSelectProps } from '@mantine/core'

  interface MultiSelectProps extends MantineMultiSelectProps {}

  function MultiSelect(props: MultiSelectProps) {
    return <MantineMultiSelect {...props} />
  }

  export { MultiSelect }
  export type { MultiSelectProps }


id: nav_link_ts_ui
name: nav_link
kind: component
lang: ts
domain: ui
version: 1.0.0
purity: impure
signature: FnNavLink(props: FnNavLinkProps): JSX.Element
description: Link de navegacion con icono, descripcion y anidamiento. Wrapper sobre Mantine NavLink.
tags: ["mantine","navigation","link","sidebar","menu","component","ui"]
uses_functions: []
uses_types: []
returns: []
imports: ["@mantine/core"]
tests: []
file_path: frontend/functions/ui/nav_link.tsx
created_at: 2026-04-06T00:11:54Z
updated_at: 2026-04-06T00:11:54Z
emits: []
framework: react
variant: []
content_hash: 30fc234c90a3c0fe60b0da80dd432f92a541c3fb5a9c0270af29f4c47972dcc8
params_schema: {"output":"Link de navegacion con highlight activo, icono y soporte para sub-items colapsables"}
example:
  ```tsx
  import { FnNavLink } from '@fn_library'
  import { IconHome, IconSettings } from '@tabler/icons-react'

  <FnNavLink label="Home" icon={<IconHome size={16} />} active />
  <FnNavLink label="Settings" icon={<IconSettings size={16} />} defaultOpened>
    <FnNavLink label="General" />
    <FnNavLink label="Seguridad" />
  </FnNavLink>
  ```

props:
  [{"name":"label","type":"string","required":true,"description":"Texto principal del link"},{"name":"description","type":"string","required":false,"description":"Texto secundario debajo del label"},{"name":"icon","type":"ReactNode","required":false,"description":"Icono a la izquierda del label"},{"name":"active","type":"boolean","required":false,"description":"Estado activo/seleccionado"},{"name":"onClick","type":"MouseEventHandler","required":false,"description":"Callback al hacer click"},{"name":"href","type":"string","required":false,"description":"URL para navegacion como anchor"},{"name":"children","type":"ReactNode","required":false,"description":"NavLinks hijos para crear arbol anidado"},{"name":"opened","type":"boolean","required":false,"description":"Controla si los hijos estan expandidos"},{"name":"defaultOpened","type":"boolean","required":false,"description":"Estado inicial de expansion de hijos"}]

notes:
  Wrapper sobre Mantine `NavLink`. Soporta anidamiento -- pasar `FnNavLink` como children crea un arbol colapsable. El `icon` se mapea a `leftSection` internamente. Ideal para uso dentro de `FnAppShell` navbar.

code:
  import * as React from 'react'
  import { NavLink } from '@mantine/core'

  interface FnNavLinkProps {
    label: string
    description?: string
    icon?: React.ReactNode
    active?: boolean
    onClick?: React.MouseEventHandler<HTMLButtonElement>
    href?: string
    children?: React.ReactNode
    opened?: boolean
    defaultOpened?: boolean
  }

  function FnNavLink({
    label,
    description,
    icon,
    active,
    onClick,
    href,
    children,
    opened,
    defaultOpened,
  }: FnNavLinkProps) {
    return (
      <NavLink
        label={label}
        description={description}
        leftSection={icon}
        active={active}
        onClick={onClick}
        href={href}
        opened={opened}
        defaultOpened={defaultOpened}
      >
        {children}
      </NavLink>
    )
  }

  export { FnNavLink }
  export type { FnNavLinkProps }


id: number_input_ts_ui
name: number_input
kind: component
lang: ts
domain: ui
version: 1.0.0
purity: impure
signature: FnNumberInput(props: FnNumberInputProps): JSX.Element
description: Input numerico con min/max, step, prefijo y sufijo. Wrapper sobre Mantine NumberInput.
tags: ["mantine","input","number","form","component","ui"]
uses_functions: []
uses_types: []
returns: []
imports: ["@mantine/core"]
tests: []
file_path: frontend/functions/ui/number_input.tsx
created_at: 2026-04-06T00:11:54Z
updated_at: 2026-04-06T00:11:54Z
emits: []
framework: react
variant: []
content_hash: e81f97e6fb1dd0c80d22863aacd45a43dc04b101dc7c6cf00cc8e1ff475c1bf0
params_schema: {"output":"Input numerico con controles de incremento, validacion y decoradores de texto"}
example:
  ```tsx
  import { FnNumberInput } from '@fn_library'

  <FnNumberInput
    label="Precio"
    value={price}
    onChange={setPrice}
    min={0}
    max={10000}
    step={0.01}
    prefix="$"
    description="Precio unitario en USD"
  />
  ```

props:
  [{"name":"value","type":"number | string","required":false,"description":"Valor actual del input"},{"name":"onChange","type":"(value: number | string) =\u003e void","required":false,"description":"Callback cuando cambia el valor"},{"name":"min","type":"number","required":false,"description":"Valor minimo permitido"},{"name":"max","type":"number","required":false,"description":"Valor maximo permitido"},{"name":"step","type":"number","required":false,"description":"Incremento/decremento por click"},{"name":"label","type":"string","required":false,"description":"Etiqueta del input"},{"name":"description","type":"string","required":false,"description":"Texto descriptivo debajo del input"},{"name":"error","type":"string","required":false,"description":"Mensaje de error"},{"name":"placeholder","type":"string","required":false,"description":"Placeholder del input"},{"name":"prefix","type":"string","required":false,"description":"Texto prefijo dentro del input (ej: $)"},{"name":"suffix","type":"string","required":false,"description":"Texto sufijo dentro del input (ej: kg)"}]

notes:
  Wrapper sobre Mantine `NumberInput`. Soporta prefix/suffix para decorar el valor visualmente. Los controles de incremento/decremento respetan min/max/step.

code:
  import { NumberInput } from '@mantine/core'

  interface FnNumberInputProps {
    value?: number | string
    onChange?: (value: number | string) => void
    min?: number
    max?: number
    step?: number
    label?: string
    description?: string
    error?: string
    placeholder?: string
    prefix?: string
    suffix?: string
  }

  function FnNumberInput({
    value,
    onChange,
    min,
    max,
    step,
    label,
    description,
    error,
    placeholder,
    prefix,
    suffix,
  }: FnNumberInputProps) {
    return (
      <NumberInput
        value={value}
        onChange={onChange}
        min={min}
        max={max}
        step={step}
        label={label}
        description={description}
        error={error}
        placeholder={placeholder}
        prefix={prefix}
        suffix={suffix}
      />
    )
  }

  export { FnNumberInput }
  export type { FnNumberInputProps }


