merge: quick/nordvpn-db-wails-frontend-notebook — NordVPN, DB multi-engine, Wails, frontend React/TS, Jupyter notebook, lorenz_step

This commit is contained in:
2026-04-01 20:56:18 +02:00
165 changed files with 10688 additions and 11 deletions
+1
View File
@@ -15,3 +15,4 @@ Reglas operativas del proyecto. Cada archivo es una regla independiente.
| 09 | [go_packages.md](go_packages.md) | Nombre de paquete Go = nombre del directorio |
| 10 | [apps_vs_functions.md](apps_vs_functions.md) | Codigo reutilizable en functions/, no reutilizable en apps/ |
| 11 | [sources.md](sources.md) | Extraccion de funciones desde repos externos |
| 12 | [notebook_collaboration.md](notebook_collaboration.md) | Colaboración en notebooks Jupyter via funciones del registry |
+55
View File
@@ -0,0 +1,55 @@
## Colaboración en notebooks Jupyter
### Requisito previo
El usuario debe tener Jupyter Lab corriendo en modo colaborativo (`--collaborative`) y el notebook abierto en el browser. Sin esto, los cambios no se ven en tiempo real.
El launcher estándar (`run-jupyter-lab.sh` generado por `init_jupyter_analysis`) ya incluye `--collaborative`.
### Funciones del registry (dominio `notebook`)
| Función | ID | Para qué |
|---|---|---|
| `jupyter_discover` | `jupyter_discover_py_notebook` | Descubrir instancias Jupyter activas, kernels, sesiones, modo colaborativo |
| `jupyter_read` | `jupyter_read_py_notebook` | Leer celdas (todas o una), metadata del notebook |
| `jupyter_exec` | `jupyter_exec_py_notebook` | Ejecutar: append+execute, execute celda existente, o directo al kernel |
| `jupyter_write` | `jupyter_write_py_notebook` | Escribir: append code/markdown, insert, edit, delete celdas |
| `jupyter_kernel` | `jupyter_kernel_py_notebook` | CRUD de kernels: list, start, restart, interrupt, shutdown, sessions |
### Invocación desde cualquier sesión de Claude
```bash
PYTHON="python/.venv/bin/python3"
# 1. Descubrir qué Jupyter está corriendo
$PYTHON python/functions/notebook/jupyter_discover.py --json
# 2. Leer notebook
$PYTHON python/functions/notebook/jupyter_read.py notebooks/01.ipynb --json
# 3. Añadir celda y ejecutar (el usuario la ve en tiempo real)
$PYTHON python/functions/notebook/jupyter_exec.py append notebooks/01.ipynb "df.describe()"
# 4. Ejecutar celda existente
$PYTHON python/functions/notebook/jupyter_exec.py cell notebooks/01.ipynb 3
# 5. Ejecutar en kernel sin tocar notebook
$PYTHON python/functions/notebook/jupyter_exec.py kernel "print(df.shape)"
# 6. Añadir markdown
$PYTHON python/functions/notebook/jupyter_write.py append-markdown notebooks/01.ipynb "## Resumen"
# 7. Gestionar kernels
$PYTHON python/functions/notebook/jupyter_kernel.py list
$PYTHON python/functions/notebook/jupyter_kernel.py sessions
$PYTHON python/functions/notebook/jupyter_kernel.py shutdown <kernel_id>
```
### Reglas de uso
- **SIEMPRE** ejecutar `jupyter_discover` primero para confirmar que Jupyter está activo y el notebook abierto.
- Las funciones resuelven automáticamente el `kernel_id` de la sesión del notebook y el `username` colaborativo via `/api/sessions` y `/api/me`.
- Después de escribir/ejecutar, las funciones mantienen la conexión WebSocket 2 segundos para que Y.js propague los cambios al browser.
- **NO usar MCP jupyter** — estas funciones reemplazan al MCP y funcionan desde cualquier directorio sin registrar nada.
- El token por defecto es vacío (sin auth). Si el server tiene token, pasarlo con `--token`.
- Los paths de notebooks son relativos a la raíz del servidor Jupyter (normalmente `analysis/{tema}/`).
+37
View File
@@ -0,0 +1,37 @@
---
name: install_nordvpn
kind: function
lang: bash
domain: infra
version: "1.0.0"
purity: impure
signature: "install_nordvpn() -> void"
description: "Instala NordVPN CLI en Ubuntu/Debian (incluido WSL2). Configura repositorio oficial, instala paquete y habilita servicio nordvpnd. Idempotente."
tags: [vpn, nordvpn, install, infra, wsl2]
uses_functions: []
uses_types: []
returns: []
returns_optional: false
error_type: "error_go_core"
imports: []
tested: false
tests: []
test_file_path: ""
file_path: "bash/functions/infra/install_nordvpn.sh"
---
## Ejemplo
```bash
source install_nordvpn.sh
install_nordvpn
# nordvpn ya instalado: NordVPN Version 3.x.x
# — o —
# Instalando NordVPN CLI...
# NordVPN instalado: NordVPN Version 3.x.x
# NOTA: ejecuta 'nordvpn login' para autenticarte
```
## Notas
Usa el script de instalacion oficial de NordVPN. En WSL2 sin systemd, levanta nordvpnd manualmente. Agrega el usuario al grupo nordvpn para evitar sudo en comandos posteriores. Despues de instalar, se requiere `nordvpn login` para autenticarse.
+41
View File
@@ -0,0 +1,41 @@
# install_nordvpn
# ---------------
# Instala NordVPN CLI en Ubuntu/Debian (incluido WSL2).
# Configura el repositorio oficial, instala el paquete y habilita el servicio.
# Si ya esta instalado, no hace nada.
#
# USO (sourced):
# source install_nordvpn.sh
# install_nordvpn
install_nordvpn() {
if command -v nordvpn &>/dev/null; then
echo "nordvpn ya instalado: $(nordvpn version 2>/dev/null)"
return 0
fi
echo "Instalando NordVPN CLI..."
# Descargar e instalar via script oficial
sh <(curl -sSf https://downloads.nordcdn.com/apps/linux/install.sh) 2>&1
if ! command -v nordvpn &>/dev/null; then
echo "install_nordvpn: fallo la instalacion" >&2
return 1
fi
# Agregar usuario al grupo nordvpn para evitar sudo
sudo usermod -aG nordvpn "$USER" 2>/dev/null || true
# Habilitar servicio (systemd o manual para WSL2)
if command -v systemctl &>/dev/null && systemctl is-system-running &>/dev/null 2>&1; then
sudo systemctl enable --now nordvpnd 2>/dev/null || true
else
# WSL2 sin systemd — levantar daemon manualmente
sudo nordvpnd &>/dev/null &
sleep 2
fi
echo "NordVPN instalado: $(nordvpn version 2>/dev/null)"
echo "NOTA: ejecuta 'nordvpn login' para autenticarte"
}
+40
View File
@@ -0,0 +1,40 @@
---
name: nordvpn_connect
kind: function
lang: bash
domain: infra
version: "1.0.0"
purity: impure
signature: "nordvpn_connect(country?: string, city?: string) -> json"
description: "Conecta a NordVPN por pais, ciudad o servidor especifico. Sin argumentos conecta al mejor servidor disponible. Devuelve JSON con resultado."
tags: [vpn, nordvpn, connect, infra, network]
uses_functions: []
uses_types: []
returns: []
returns_optional: false
error_type: "error_go_core"
imports: []
tested: false
tests: []
test_file_path: ""
file_path: "bash/functions/infra/nordvpn_connect.sh"
---
## Ejemplo
```bash
source nordvpn_connect.sh
nordvpn_connect
# {"ok":true,"server":"us1234.nordvpn.com","country":"auto","city":"auto"}
nordvpn_connect Spain
# {"ok":true,"server":"es42.nordvpn.com","country":"Spain","city":"auto"}
nordvpn_connect Spain Madrid
# {"ok":true,"server":"es15.nordvpn.com","country":"Spain","city":"Madrid"}
```
## Notas
Requiere NordVPN CLI instalado y autenticado (`nordvpn login`). La salida JSON facilita composicion con otros scripts y pipelines. Si ya hay una conexion activa, NordVPN reconecta automaticamente al nuevo destino.
+39
View File
@@ -0,0 +1,39 @@
# nordvpn_connect
# ---------------
# Conecta a NordVPN. Acepta pais, ciudad o servidor especifico.
# Sin argumentos conecta al mejor servidor disponible.
# Imprime JSON con el resultado de la conexion.
#
# USO (sourced):
# source nordvpn_connect.sh
# nordvpn_connect # mejor servidor
# nordvpn_connect Spain # por pais
# nordvpn_connect Spain Madrid # por ciudad
# nordvpn_connect Spain '#42' # servidor especifico
nordvpn_connect() {
local country="${1:-}"
local city="${2:-}"
if ! command -v nordvpn &>/dev/null; then
echo '{"ok":false,"error":"nordvpn no instalado"}' >&2
return 1
fi
local args=()
[ -n "$country" ] && args+=("$country")
[ -n "$city" ] && args+=("$city")
local output
output=$(nordvpn connect "${args[@]}" 2>&1)
local rc=$?
if [ $rc -eq 0 ] && echo "$output" | grep -qi "connected"; then
local server
server=$(echo "$output" | grep -oP '(?<=to )\S+' | head -1)
echo "{\"ok\":true,\"server\":\"${server}\",\"country\":\"${country:-auto}\",\"city\":\"${city:-auto}\"}"
else
echo "{\"ok\":false,\"error\":$(echo "$output" | python3 -c 'import sys,json; print(json.dumps(sys.stdin.read().strip()))' 2>/dev/null || echo "\"$output\"")}"
return 1
fi
}
@@ -0,0 +1,34 @@
---
name: nordvpn_disconnect
kind: function
lang: bash
domain: infra
version: "1.0.0"
purity: impure
signature: "nordvpn_disconnect() -> json"
description: "Desconecta de NordVPN. Idempotente — si no hay conexion activa retorna ok. Devuelve JSON con resultado."
tags: [vpn, nordvpn, disconnect, infra, network]
uses_functions: []
uses_types: []
returns: []
returns_optional: false
error_type: "error_go_core"
imports: []
tested: false
tests: []
test_file_path: ""
file_path: "bash/functions/infra/nordvpn_disconnect.sh"
---
## Ejemplo
```bash
source nordvpn_disconnect.sh
nordvpn_disconnect
# {"ok":true,"status":"disconnected"}
```
## Notas
Idempotente: si no hay conexion activa, retorna ok sin error. Requiere NordVPN CLI instalado.
@@ -0,0 +1,26 @@
# nordvpn_disconnect
# ------------------
# Desconecta de NordVPN. Idempotente — si no hay conexion activa, retorna ok.
# Imprime JSON con el resultado.
#
# USO (sourced):
# source nordvpn_disconnect.sh
# nordvpn_disconnect
nordvpn_disconnect() {
if ! command -v nordvpn &>/dev/null; then
echo '{"ok":false,"error":"nordvpn no instalado"}' >&2
return 1
fi
local output
output=$(nordvpn disconnect 2>&1)
local rc=$?
if [ $rc -eq 0 ] || echo "$output" | grep -qi "not connected\|disconnected"; then
echo '{"ok":true,"status":"disconnected"}'
else
echo "{\"ok\":false,\"error\":$(echo "$output" | python3 -c 'import sys,json; print(json.dumps(sys.stdin.read().strip()))' 2>/dev/null || echo "\"$output\"")}"
return 1
fi
}
+39
View File
@@ -0,0 +1,39 @@
---
name: nordvpn_get_ip
kind: function
lang: bash
domain: infra
version: "1.0.0"
purity: impure
signature: "nordvpn_get_ip() -> json"
description: "Obtiene IP publica actual con fallback entre multiples servicios. Indica si la conexion VPN esta activa y el servidor usado."
tags: [vpn, nordvpn, ip, infra, network, verification]
uses_functions: []
uses_types: []
returns: []
returns_optional: false
error_type: "error_go_core"
imports: []
tested: false
tests: []
test_file_path: ""
file_path: "bash/functions/infra/nordvpn_get_ip.sh"
---
## Ejemplo
```bash
source nordvpn_get_ip.sh
# Con VPN activa:
nordvpn_get_ip
# {"ok":true,"ip":"185.x.x.x","vpn_connected":true,"vpn_server":"es42.nordvpn.com","source":"https://api.ipify.org"}
# Sin VPN:
nordvpn_get_ip
# {"ok":true,"ip":"88.x.x.x","vpn_connected":false,"vpn_server":"","source":"https://api.ipify.org"}
```
## Notas
Usa ipify.org como servicio primario con fallback a ifconfig.me e icanhazip.com. Timeout de 5 segundos por servicio. Util para verificar que el tunel VPN esta activo antes de ejecutar operaciones sensibles a la IP.
+42
View File
@@ -0,0 +1,42 @@
# nordvpn_get_ip
# --------------
# Obtiene la IP publica actual para verificar que el tunel VPN funciona.
# Usa multiples servicios como fallback.
#
# USO (sourced):
# source nordvpn_get_ip.sh
# nordvpn_get_ip
nordvpn_get_ip() {
local ip=""
local source=""
# Intentar multiples servicios
for svc in "https://api.ipify.org" "https://ifconfig.me" "https://icanhazip.com"; do
ip=$(curl -s --max-time 5 "$svc" 2>/dev/null)
if echo "$ip" | grep -qP '^\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}$'; then
source="$svc"
break
fi
ip=""
done
if [ -z "$ip" ]; then
echo '{"ok":false,"error":"no se pudo obtener IP publica"}' >&2
return 1
fi
# Si nordvpn esta disponible, incluir info de conexion
local connected="false"
local vpn_server=""
if command -v nordvpn &>/dev/null; then
local status_output
status_output=$(nordvpn status 2>/dev/null)
if echo "$status_output" | grep -qi "connected"; then
connected="true"
vpn_server=$(echo "$status_output" | grep -iP "hostname|server" | head -1 | sed 's/.*: *//')
fi
fi
echo "{\"ok\":true,\"ip\":\"$ip\",\"vpn_connected\":$connected,\"vpn_server\":\"$vpn_server\",\"source\":\"$source\"}"
}
@@ -0,0 +1,37 @@
---
name: nordvpn_list_cities
kind: function
lang: bash
domain: infra
version: "1.0.0"
purity: impure
signature: "nordvpn_list_cities(country: string) -> json"
description: "Lista ciudades disponibles de un pais en NordVPN como array JSON ordenado."
tags: [vpn, nordvpn, cities, infra, network]
uses_functions: []
uses_types: []
returns: []
returns_optional: false
error_type: "error_go_core"
imports: []
tested: false
tests: []
test_file_path: ""
file_path: "bash/functions/infra/nordvpn_list_cities.sh"
---
## Ejemplo
```bash
source nordvpn_list_cities.sh
nordvpn_list_cities Spain
# {"ok":true,"country":"Spain","count":2,"cities":["Barcelona","Madrid"]}
nordvpn_list_cities "United_States"
# {"ok":true,"country":"United_States","count":15,"cities":["Atlanta","Buffalo",...]}
```
## Notas
El nombre de pais debe coincidir con lo que devuelve `nordvpn countries`. Usa underscores para paises compuestos (ej: United_States). Las ciudades se devuelven con espacios.
@@ -0,0 +1,38 @@
# nordvpn_list_cities
# -------------------
# Lista las ciudades disponibles de un pais en NordVPN como array JSON.
#
# USO (sourced):
# source nordvpn_list_cities.sh
# nordvpn_list_cities Spain
nordvpn_list_cities() {
local country="${1:?nordvpn_list_cities: se requiere pais como argumento}"
if ! command -v nordvpn &>/dev/null; then
echo '{"ok":false,"error":"nordvpn no instalado"}' >&2
return 1
fi
local output
output=$(nordvpn cities "$country" 2>&1)
local rc=$?
if [ $rc -ne 0 ]; then
echo "{\"ok\":false,\"error\":$(echo "$output" | python3 -c 'import sys,json; print(json.dumps(sys.stdin.read().strip()))' 2>/dev/null || echo "\"$output\"")}"
return 1
fi
echo "$output" | python3 -c '
import sys, json, re
country = "'"$country"'"
text = sys.stdin.read()
text = re.sub(r"\x1b\[[0-9;]*m", "", text)
text = re.sub(r"[\t\r]", " ", text)
cities = [c.strip().replace("_", " ") for c in re.split(r"[,\n]+", text) if c.strip() and c.strip() != "-"]
cities = [c for c in cities if len(c) > 1]
cities.sort()
print(json.dumps({"ok": True, "country": country, "count": len(cities), "cities": cities}))
'
}
@@ -0,0 +1,34 @@
---
name: nordvpn_list_countries
kind: function
lang: bash
domain: infra
version: "1.0.0"
purity: impure
signature: "nordvpn_list_countries() -> json"
description: "Lista paises disponibles en NordVPN como array JSON ordenado alfabeticamente."
tags: [vpn, nordvpn, countries, infra, network]
uses_functions: []
uses_types: []
returns: []
returns_optional: false
error_type: "error_go_core"
imports: []
tested: false
tests: []
test_file_path: ""
file_path: "bash/functions/infra/nordvpn_list_countries.sh"
---
## Ejemplo
```bash
source nordvpn_list_countries.sh
nordvpn_list_countries
# {"ok":true,"count":60,"countries":["Albania","Argentina","Australia",...,"United States","Vietnam"]}
```
## Notas
Parsea la salida de `nordvpn countries` eliminando codigos ANSI y normalizando separadores. Los nombres de paises se devuelven con espacios en vez de underscores.
@@ -0,0 +1,36 @@
# nordvpn_list_countries
# ----------------------
# Lista los paises disponibles en NordVPN como array JSON.
#
# USO (sourced):
# source nordvpn_list_countries.sh
# nordvpn_list_countries
nordvpn_list_countries() {
if ! command -v nordvpn &>/dev/null; then
echo '{"ok":false,"error":"nordvpn no instalado"}' >&2
return 1
fi
local output
output=$(nordvpn countries 2>&1)
local rc=$?
if [ $rc -ne 0 ]; then
echo "{\"ok\":false,\"error\":$(echo "$output" | python3 -c 'import sys,json; print(json.dumps(sys.stdin.read().strip()))' 2>/dev/null || echo "\"$output\"")}"
return 1
fi
echo "$output" | python3 -c '
import sys, json, re
text = sys.stdin.read()
text = re.sub(r"\x1b\[[0-9;]*m", "", text)
text = re.sub(r"[\t\r]", " ", text)
# Split by comma, whitespace, or newline and clean
countries = [c.strip().replace("_", " ") for c in re.split(r"[,\n]+", text) if c.strip() and c.strip() != "-"]
countries = [c for c in countries if len(c) > 1]
countries.sort()
print(json.dumps({"ok": True, "count": len(countries), "countries": countries}))
'
}
@@ -0,0 +1,37 @@
---
name: nordvpn_set_protocol
kind: function
lang: bash
domain: infra
version: "1.0.0"
purity: impure
signature: "nordvpn_set_protocol(protocol: string) -> json"
description: "Cambia el protocolo de NordVPN entre NordLynx (WireGuard) y OpenVPN. NordLynx recomendado por velocidad."
tags: [vpn, nordvpn, protocol, nordlynx, wireguard, openvpn, infra]
uses_functions: []
uses_types: []
returns: []
returns_optional: false
error_type: "error_go_core"
imports: []
tested: false
tests: []
test_file_path: ""
file_path: "bash/functions/infra/nordvpn_set_protocol.sh"
---
## Ejemplo
```bash
source nordvpn_set_protocol.sh
nordvpn_set_protocol NordLynx
# {"ok":true,"protocol":"NordLynx"}
nordvpn_set_protocol OpenVPN
# {"ok":true,"protocol":"OpenVPN"}
```
## Notas
NordLynx es WireGuard wrapeado por NordVPN — mas rapido y moderno. OpenVPN es mas compatible con redes restrictivas. El cambio de protocolo requiere reconectar si hay una conexion activa.
@@ -0,0 +1,38 @@
# nordvpn_set_protocol
# --------------------
# Cambia el protocolo de NordVPN (NordLynx o OpenVPN).
# NordLynx = WireGuard (recomendado por velocidad).
#
# USO (sourced):
# source nordvpn_set_protocol.sh
# nordvpn_set_protocol NordLynx
# nordvpn_set_protocol OpenVPN
nordvpn_set_protocol() {
local protocol="${1:?nordvpn_set_protocol: se requiere protocolo (NordLynx|OpenVPN)}"
if ! command -v nordvpn &>/dev/null; then
echo '{"ok":false,"error":"nordvpn no instalado"}' >&2
return 1
fi
case "$protocol" in
NordLynx|nordlynx|NORDLYNX) protocol="NordLynx" ;;
OpenVPN|openvpn|OPENVPN) protocol="OpenVPN" ;;
*)
echo "{\"ok\":false,\"error\":\"protocolo invalido: $protocol (usar NordLynx o OpenVPN)\"}"
return 1
;;
esac
local output
output=$(nordvpn set protocol "$protocol" 2>&1)
local rc=$?
if [ $rc -eq 0 ] || echo "$output" | grep -qi "already set\|successfully"; then
echo "{\"ok\":true,\"protocol\":\"$protocol\"}"
else
echo "{\"ok\":false,\"error\":$(echo "$output" | python3 -c 'import sys,json; print(json.dumps(sys.stdin.read().strip()))' 2>/dev/null || echo "\"$output\"")}"
return 1
fi
}
+37
View File
@@ -0,0 +1,37 @@
---
name: nordvpn_status
kind: function
lang: bash
domain: infra
version: "1.0.0"
purity: impure
signature: "nordvpn_status() -> json"
description: "Obtiene estado actual de NordVPN como JSON estructurado. Incluye servidor, IP, pais, protocolo y estado de conexion."
tags: [vpn, nordvpn, status, infra, network]
uses_functions: []
uses_types: []
returns: []
returns_optional: false
error_type: "error_go_core"
imports: []
tested: false
tests: []
test_file_path: ""
file_path: "bash/functions/infra/nordvpn_status.sh"
---
## Ejemplo
```bash
source nordvpn_status.sh
nordvpn_status
# {"ok":true,"connected":true,"status":"Connected","hostname":"es42.nordvpn.com","ip":"185.x.x.x","country":"Spain","city":"Madrid","current_technology":"NordLynx","current_protocol":"nordlynx","transfer":"1.2 MiB received, 500 KiB sent","uptime":"5 minutes 32 seconds"}
# Desconectado:
# {"ok":true,"connected":false,"status":"Disconnected"}
```
## Notas
Parsea la salida clave-valor de `nordvpn status` eliminando codigos ANSI. Los campos disponibles dependen del estado de conexion — cuando esta desconectado solo devuelve status y connected.
+43
View File
@@ -0,0 +1,43 @@
# nordvpn_status
# --------------
# Obtiene el estado actual de NordVPN como JSON estructurado.
# Parsea la salida clave-valor de `nordvpn status` a campos JSON.
#
# USO (sourced):
# source nordvpn_status.sh
# nordvpn_status
nordvpn_status() {
if ! command -v nordvpn &>/dev/null; then
echo '{"ok":false,"error":"nordvpn no instalado"}' >&2
return 1
fi
local output
output=$(nordvpn status 2>&1)
local rc=$?
if [ $rc -ne 0 ]; then
echo "{\"ok\":false,\"error\":$(echo "$output" | python3 -c 'import sys,json; print(json.dumps(sys.stdin.read().strip()))' 2>/dev/null || echo "\"$output\"")}"
return 1
fi
# Parsear output clave: valor a JSON con python3
echo "$output" | python3 -c '
import sys, json, re
lines = sys.stdin.read().strip().split("\n")
data = {"ok": True}
for line in lines:
line = re.sub(r"\x1b\[[0-9;]*m", "", line).strip()
line = line.lstrip("- ")
if ":" in line:
key, _, val = line.partition(":")
key = key.strip().lower().replace(" ", "_")
val = val.strip()
if key == "status":
data["connected"] = val.lower() == "connected"
data[key] = val
print(json.dumps(data))
'
}
@@ -56,7 +56,6 @@ jupyter lab \
--ServerApp.disable_check_xsrf=True \
--ServerApp.allow_origin='*' \
--ServerApp.root_dir="$(pwd)" \
--YDocExtension.ystore_class='ypy_websocket.ystore.TempFileYStore' \
--collaborative
LAUNCHER
@@ -1,7 +1,8 @@
# write_mcp_jupyter_config
# -------------------------
# Genera o actualiza .mcp.json con la configuracion de jupyter-mcp-server.
# Usa el python del venv local con -m jupyter_mcp_server.
# Usa el python del venv local con -m jupyter_mcp_server.server.
# Configura via env vars (SERVER_URL, TOKEN) — no CLI args.
# Hace merge si ya existe .mcp.json (requiere jq).
#
# USO (sourced):
@@ -33,11 +34,11 @@ write_mcp_jupyter_config() {
"mcpServers": {
"jupyter": {
"command": "${python_bin}",
"args": [
"-m", "jupyter_mcp_server",
"--runtime-url", "http://localhost:${port}",
"--start-new-runtime", "false"
]
"args": ["-m", "jupyter_mcp_server.server"],
"env": {
"SERVER_URL": "http://localhost:${port}",
"TOKEN": ""
}
}
}
}
+35
View File
@@ -0,0 +1,35 @@
---
name: chart_colors
kind: function
lang: typescript
domain: core
version: "1.0.0"
purity: pure
signature: "getChartColor(index: number): string"
description: "Paleta de colores para gráficos basada en CSS variables del tema activo. Colores accesibles por índice cíclico."
tags: [chart, color, theme, palette, visualization]
uses_functions: []
uses_types: []
returns: []
returns_optional: false
error_type: ""
imports: []
tested: false
tests: []
test_file_path: ""
file_path: "frontend/functions/core/chart_colors.ts"
source_repo: "https://gitea-dgg044oo04woo4ggcsws4gk0.organic-machine.com/Bl4cksmith/Frontend_Library"
source_license: "MIT"
source_file: "frontend/src/components/ui/charts/chart-base.tsx"
---
## Ejemplo
```typescript
getChartColor(0) // 'hsl(var(--chart-1, 220 70% 50%))'
getChartColor(7) // 'hsl(var(--chart-3, 30 80% 55%))' — cicla sobre 5 colores
```
## Notas
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.
+11
View File
@@ -0,0 +1,11 @@
export const chartColors = [
'hsl(var(--chart-1, 220 70% 50%))',
'hsl(var(--chart-2, 160 60% 45%))',
'hsl(var(--chart-3, 30 80% 55%))',
'hsl(var(--chart-4, 280 65% 60%))',
'hsl(var(--chart-5, 340 75% 55%))',
]
export function getChartColor(index: number): string {
return chartColors[index % chartColors.length]
}
+36
View File
@@ -0,0 +1,36 @@
---
name: cn
kind: function
lang: typescript
domain: core
version: "1.0.0"
purity: pure
signature: "cn(...inputs: ClassValue[]): string"
description: "Combina clases CSS con clsx y resuelve conflictos Tailwind con tailwind-merge. Utilidad fundamental para composición de estilos."
tags: [css, tailwind, classname, merge, utility]
uses_functions: []
uses_types: []
returns: []
returns_optional: false
error_type: ""
imports: [clsx, tailwind-merge]
tested: false
tests: []
test_file_path: ""
file_path: "frontend/functions/core/cn.ts"
source_repo: "https://gitea-dgg044oo04woo4ggcsws4gk0.organic-machine.com/Bl4cksmith/Frontend_Library"
source_license: "MIT"
source_file: "frontend/src/lib/utils.ts"
---
## Ejemplo
```typescript
cn("px-4 py-2", "px-6") // "px-6 py-2" (tailwind-merge resuelve conflicto)
cn("text-red-500", false && "hidden") // "text-red-500" (clsx filtra falsy)
cn("rounded-lg", className) // composición con className externo
```
## Notas
Base de todo el sistema de estilos. Todos los componentes la usan para componer className.
+6
View File
@@ -0,0 +1,6 @@
import { clsx, type ClassValue } from "clsx"
import { twMerge } from "tailwind-merge"
export function cn(...inputs: ClassValue[]): string {
return twMerge(clsx(inputs))
}
+36
View File
@@ -0,0 +1,36 @@
---
name: format_compact
kind: function
lang: typescript
domain: core
version: "1.0.0"
purity: pure
signature: "formatCompact(n: number, decimals?: number): string"
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)."
tags: [format, number, compact, utility, display]
uses_functions: []
uses_types: []
returns: []
returns_optional: false
error_type: ""
imports: []
tested: false
tests: []
test_file_path: ""
file_path: "frontend/functions/core/format_compact.ts"
---
## Ejemplo
```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'
```
## Notas
Todas son funciones puras sin dependencias. Útiles en dashboards, KPI cards, tablas y tooltips.
+42
View File
@@ -0,0 +1,42 @@
/**
* 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'
}
@@ -0,0 +1,36 @@
---
name: get_series_color
kind: function
lang: typescript
domain: core
version: "1.0.0"
purity: pure
signature: "getSeriesColor(index: number, color?: string): string"
description: "Devuelve color para una serie de gráfico por índice cíclico, o el color explícito si se proporciona."
tags: [chart, color, series, visualization]
uses_functions: []
uses_types: []
returns: []
returns_optional: false
error_type: ""
imports: []
tested: false
tests: []
test_file_path: ""
file_path: "frontend/functions/core/get_series_color.ts"
source_repo: "https://gitea-dgg044oo04woo4ggcsws4gk0.organic-machine.com/Bl4cksmith/Frontend_Library"
source_license: "MIT"
source_file: "frontend/src/components/ui/charts/chart-base.tsx"
---
## Ejemplo
```typescript
getSeriesColor(0) // '#3b82f6'
getSeriesColor(5) // '#3b82f6' (cicla sobre 5 colores)
getSeriesColor(0, '#ff0000') // '#ff0000' (usa el explícito)
```
## Notas
Paleta fija de 5 colores: azul, verde, ámbar, violeta, rosa. También exporta `defaultColors` para uso directo.
@@ -0,0 +1,7 @@
const defaultColors = ['#3b82f6', '#22c55e', '#f59e0b', '#8b5cf6', '#ec4899']
export function getSeriesColor(index: number, color?: string): string {
return color || defaultColors[index % defaultColors.length]
}
export { defaultColors }
@@ -0,0 +1,37 @@
---
name: theme_config_to_colors
kind: function
lang: typescript
domain: core
version: "1.0.0"
purity: pure
signature: "themeConfigToColors(config: ThemeConfig): ThemeColors"
description: "Convierte un ThemeConfig completo a ThemeColors plano para inyectar como CSS variables. Mapea tokens semánticos a variables CSS."
tags: [theme, colors, css-variables, conversion]
uses_functions: []
uses_types: []
returns: []
returns_optional: false
error_type: ""
imports: []
tested: false
tests: []
test_file_path: ""
file_path: "frontend/functions/core/theme_config_to_colors.ts"
source_repo: "https://gitea-dgg044oo04woo4ggcsws4gk0.organic-machine.com/Bl4cksmith/Frontend_Library"
source_license: "MIT"
source_file: "frontend/src/themes/types.ts"
---
## Ejemplo
```typescript
const colors = themeConfigToColors(darkThemeConfig)
// { background: '...', foreground: '...', primary: '...', ... }
```
## Notas
Puente entre el sistema de temas estructurado (ThemeConfig) y el sistema plano de CSS variables que consumen los componentes.
Depende de los tipos ThemeConfig y ThemeColors definidos en `frontend/types/ui/theme_config.ts`. El tipo aún no está indexado en la BD (pendiente añadir theme_config.md para que fn index lo registre).
@@ -0,0 +1,49 @@
import type { ThemeConfig, ThemeColors } from "../../types/ui/theme_config"
export function themeConfigToColors(config: ThemeConfig): ThemeColors {
const { colors } = config
return {
background: colors.background.default,
foreground: colors.foreground.default,
card: colors.surface.raised,
cardForeground: colors.foreground.default,
popover: colors.surface.overlay,
popoverForeground: colors.foreground.default,
primary: colors.brand.primary,
primaryForeground: colors.brand.primaryForeground,
secondary: colors.brand.secondary,
secondaryForeground: colors.brand.secondaryForeground,
muted: colors.background.muted,
mutedForeground: colors.foreground.muted,
accent: colors.brand.accent,
accentForeground: colors.brand.accentForeground,
destructive: colors.status.error,
destructiveForeground: colors.status.errorForeground,
success: colors.status.success,
successForeground: colors.status.successForeground,
warning: colors.status.warning,
warningForeground: colors.status.warningForeground,
info: colors.status.info,
infoForeground: colors.status.infoForeground,
surface: colors.surface.raised,
surfaceHover: colors.background.subtle,
overlay: colors.surface.overlay,
border: colors.border.default,
input: colors.border.default,
ring: colors.ring,
chart1: colors.chart[1],
chart2: colors.chart[2],
chart3: colors.chart[3],
chart4: colors.chart[4],
chart5: colors.chart[5],
sidebar: colors.sidebar.background,
sidebarForeground: colors.sidebar.foreground,
sidebarPrimary: colors.brand.primary,
sidebarPrimaryForeground: colors.brand.primaryForeground,
sidebarAccent: colors.sidebar.accent,
sidebarAccentForeground: colors.sidebar.accentForeground,
sidebarBorder: colors.sidebar.border,
sidebarRing: colors.sidebar.ring,
}
}
+39
View File
@@ -0,0 +1,39 @@
---
name: wails_cache
kind: function
lang: typescript
domain: core
version: "1.0.0"
purity: pure
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."
tags: [wails, cache, ipc, reactive, state]
uses_functions: []
uses_types: []
returns: []
returns_optional: false
error_type: ""
imports: []
tested: false
tests: []
test_file_path: ""
file_path: "frontend/functions/core/wails_cache.ts"
source_repo: "https://gitea-dgg044oo04woo4ggcsws4gk0.organic-machine.com/Bl4cksmith/Frontend_Library"
source_license: "MIT"
source_file: "frontend/src/lib/wails/cache.ts"
---
## Ejemplo
```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'))
```
## Notas
Key como string[] permite invalidación jerárquica: `invalidate(['users'])` invalida `users`, `users:123`, `users:456`, etc.
+99
View File
@@ -0,0 +1,99 @@
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()
+49
View File
@@ -0,0 +1,49 @@
---
name: alert
kind: component
lang: typescript
domain: ui
version: "1.0.0"
purity: impure
signature: "Alert(props: { variant?: 'default' | 'destructive' }): JSX.Element"
description: "Alerta accesible con variantes default y destructive. Sistema de slots para título, descripción, icono y acción."
tags: [alert, feedback, component, ui, notification]
uses_functions: [cn_typescript_core]
uses_types: []
returns: []
returns_optional: false
error_type: ""
imports: [react, class-variance-authority]
tested: false
tests: []
test_file_path: ""
file_path: "frontend/functions/ui/alert.tsx"
props:
- name: variant
type: "'default' | 'destructive'"
required: false
description: "Variante visual"
emits: []
has_state: false
framework: react
variant: [default, destructive]
source_repo: "https://gitea-dgg044oo04woo4ggcsws4gk0.organic-machine.com/Bl4cksmith/Frontend_Library"
source_license: "MIT"
source_file: "frontend/src/components/ui/alert.tsx"
---
## Ejemplo
```tsx
<Alert variant="destructive">
<AlertCircle />
<AlertTitle>Error</AlertTitle>
<AlertDescription>Something went wrong.</AlertDescription>
</Alert>
```
## Notas
Exporta 4 subcomponentes composables via data-slot: Alert, AlertTitle, AlertDescription, AlertAction.
El icono SVG se posiciona automáticamente en grid cuando es hijo directo de Alert.
AlertAction se posiciona absolute top-right para acciones secundarias (ej: botón cerrar).
+34
View File
@@ -0,0 +1,34 @@
import * as React from "react"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "../core/cn"
const alertVariants = cva(
"group/alert relative grid w-full gap-0.5 rounded-lg border px-2.5 py-2 text-left text-sm has-data-[slot=alert-action]:relative has-data-[slot=alert-action]:pr-18 has-[>svg]:grid-cols-[auto_1fr] has-[>svg]:gap-x-2 *:[svg]:row-span-2 *:[svg]:translate-y-0.5 *:[svg]:text-current *:[svg:not([class*='size-'])]:size-4",
{
variants: {
variant: {
default: "bg-card text-card-foreground",
destructive: "bg-card text-destructive *:data-[slot=alert-description]:text-destructive/90 *:[svg]:text-current",
},
},
defaultVariants: { variant: "default" },
}
)
function Alert({ className, variant, ...props }: React.ComponentProps<"div"> & VariantProps<typeof alertVariants>) {
return <div data-slot="alert" role="alert" className={cn(alertVariants({ variant }), className)} {...props} />
}
function AlertTitle({ className, ...props }: React.ComponentProps<"div">) {
return <div data-slot="alert-title" className={cn("font-medium group-has-[>svg]/alert:col-start-2 [&_a]:underline [&_a]:underline-offset-3 [&_a]:hover:text-foreground", className)} {...props} />
}
function AlertDescription({ className, ...props }: React.ComponentProps<"div">) {
return <div data-slot="alert-description" className={cn("text-sm text-balance text-muted-foreground md:text-pretty [&_a]:underline [&_a]:underline-offset-3 [&_a]:hover:text-foreground [&_p:not(:last-child)]:mb-4", className)} {...props} />
}
function AlertAction({ className, ...props }: React.ComponentProps<"div">) {
return <div data-slot="alert-action" className={cn("absolute top-2 right-2", className)} {...props} />
}
export { Alert, AlertTitle, AlertDescription, AlertAction, alertVariants }
+47
View File
@@ -0,0 +1,47 @@
---
name: analytics_page
kind: function
lang: typescript
domain: ui
version: "1.0.0"
purity: pure
signature: "analyticsPage(props: AnalyticsPageProps): ReactElement"
description: "Genera un dashboard de analytics completo con header, fila de KPIs con deltas y grid de charts configurables."
tags: [analytics, dashboard, kpi, charts, factory, composition, ui]
uses_functions: [cn_typescript_core]
uses_types: []
returns: []
returns_optional: false
error_type: ""
imports: [react]
tested: false
tests: []
test_file_path: ""
file_path: "frontend/functions/ui/analytics_page.tsx"
source_repo: "https://gitea-dgg044oo04woo4ggcsws4gk0.organic-machine.com/Bl4cksmith/Frontend_Library"
source_license: "MIT"
source_file: ""
---
## Ejemplo
```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" /> },
],
})
```
## Notas
Layout inteligente: los KPIs se ajustan automáticamente a 2/3/4 columnas según cantidad. Los charts soportan span para ancho completo.
+101
View File
@@ -0,0 +1,101 @@
import * as React from 'react'
import { cn } from '../core/cn'
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,
className,
}: AnalyticsPageProps): React.ReactElement {
return (
<div className={cn('space-y-6', className)}>
{/* Header */}
<div className="flex items-center justify-between border-b pb-4">
<div className="space-y-1">
<h1 className="text-2xl font-semibold tracking-tight">{title}</h1>
{subtitle && <p className="text-sm text-muted-foreground">{subtitle}</p>}
</div>
<div className="flex items-center gap-2">
{dateRange}
{actions}
</div>
</div>
{/* KPI Row */}
<div className={cn(
'grid gap-4',
metrics.length <= 2 ? 'grid-cols-1 md:grid-cols-2' :
metrics.length <= 3 ? 'grid-cols-1 md:grid-cols-3' :
'grid-cols-1 md:grid-cols-2 lg:grid-cols-4'
)}>
{metrics.map((metric, i) => (
<div key={i} className="rounded-lg border bg-card p-4 text-card-foreground shadow-sm">
<p className="text-sm text-muted-foreground">{metric.label}</p>
<div className="mt-2 flex items-end justify-between gap-4">
<div className="space-y-1">
<p className="text-3xl font-bold tracking-tight">{metric.value}</p>
{metric.delta && (
<div className={cn(
'flex items-center gap-1 text-sm font-medium',
metric.delta.value === 0 ? 'text-muted-foreground' :
metric.delta.isPositive ? 'text-green-600 dark:text-green-500' :
'text-red-600 dark:text-red-500'
)}>
<span>{metric.delta.value > 0 ? '+' : ''}{metric.delta.value}%</span>
</div>
)}
</div>
</div>
</div>
))}
</div>
{/* Charts Grid */}
<div className="grid grid-cols-1 gap-4 lg:grid-cols-2">
{charts.map((chart) => (
<div
key={chart.id}
className={cn(
'rounded-xl border bg-card p-4 text-card-foreground shadow-sm',
chart.span === 2 && 'lg:col-span-2'
)}
>
<h3 className="mb-3 text-sm font-medium text-muted-foreground">{chart.title}</h3>
{chart.content}
</div>
))}
</div>
</div>
)
}
export type { AnalyticsPageProps, MetricConfig, ChartConfig }
+40
View File
@@ -0,0 +1,40 @@
---
name: apply_theme
kind: function
lang: typescript
domain: ui
version: "1.0.0"
purity: impure
signature: "applyTheme(theme: Theme): void"
description: "Inyecta un tema como CSS variables en document.documentElement. Maneja clase dark automáticamente. Mapea 40 tokens semánticos."
tags: [theme, css-variables, apply, runtime, ui]
uses_functions: []
uses_types: [ThemeConfig_typescript_ui]
returns: []
returns_optional: false
error_type: "error_go_core"
imports: []
tested: false
tests: []
test_file_path: ""
file_path: "frontend/functions/ui/apply_theme.tsx"
source_repo: "https://gitea-dgg044oo04woo4ggcsws4gk0.organic-machine.com/Bl4cksmith/Frontend_Library"
source_license: "MIT"
source_file: "frontend/src/hooks/use-theme.tsx"
---
## Ejemplo
```typescript
import { applyTheme } from './apply_theme'
applyTheme({
name: 'dark',
label: 'Oscuro',
colors: themeConfigToColors(darkThemeConfig)
})
```
## Notas
Función impura (modifica el DOM). Mapea cada key de ThemeColors a una CSS variable. Temas oscuros (dark, midnight, sunset) añaden clase `dark` al root.
+111
View File
@@ -0,0 +1,111 @@
interface ThemeColors {
background: string
foreground: string
card: string
cardForeground: string
popover: string
popoverForeground: string
primary: string
primaryForeground: string
secondary: string
secondaryForeground: string
muted: string
mutedForeground: string
accent: string
accentForeground: string
destructive: string
destructiveForeground: string
success: string
successForeground: string
warning: string
warningForeground: string
info: string
infoForeground: string
surface: string
surfaceHover: string
overlay: string
border: string
input: string
ring: string
chart1: string
chart2: string
chart3: string
chart4: string
chart5: string
sidebar: string
sidebarForeground: string
sidebarPrimary: string
sidebarPrimaryForeground: string
sidebarAccent: string
sidebarAccentForeground: string
sidebarBorder: string
sidebarRing: string
}
interface Theme {
name: string
label: string
colors: ThemeColors
}
const cssVarMap: Record<keyof ThemeColors, string> = {
background: '--background',
foreground: '--foreground',
card: '--card',
cardForeground: '--card-foreground',
popover: '--popover',
popoverForeground: '--popover-foreground',
primary: '--primary',
primaryForeground: '--primary-foreground',
secondary: '--secondary',
secondaryForeground: '--secondary-foreground',
muted: '--muted',
mutedForeground: '--muted-foreground',
accent: '--accent',
accentForeground: '--accent-foreground',
destructive: '--destructive',
destructiveForeground: '--destructive-foreground',
success: '--success',
successForeground: '--success-foreground',
warning: '--warning',
warningForeground: '--warning-foreground',
info: '--info',
infoForeground: '--info-foreground',
surface: '--surface',
surfaceHover: '--surface-hover',
overlay: '--overlay',
border: '--border',
input: '--input',
ring: '--ring',
chart1: '--chart-1',
chart2: '--chart-2',
chart3: '--chart-3',
chart4: '--chart-4',
chart5: '--chart-5',
sidebar: '--sidebar',
sidebarForeground: '--sidebar-foreground',
sidebarPrimary: '--sidebar-primary',
sidebarPrimaryForeground: '--sidebar-primary-foreground',
sidebarAccent: '--sidebar-accent',
sidebarAccentForeground: '--sidebar-accent-foreground',
sidebarBorder: '--sidebar-border',
sidebarRing: '--sidebar-ring',
}
export function applyTheme(theme: Theme): void {
const root = document.documentElement
const colors = theme.colors
Object.entries(cssVarMap).forEach(([key, cssVar]) => {
const value = colors[key as keyof ThemeColors]
root.style.setProperty(cssVar, value)
})
if (theme.name === 'dark' || theme.name === 'midnight' || theme.name === 'sunset') {
root.classList.add('dark')
} else {
root.classList.remove('dark')
}
}
export type { Theme, ThemeColors }
+56
View File
@@ -0,0 +1,56 @@
---
name: area_chart
kind: component
lang: typescript
domain: ui
version: "1.0.0"
purity: impure
signature: "AreaChart(props: AreaChartProps): JSX.Element"
description: "Gráfico de área Recharts con gradientes automáticos, multi-series, stacking y tooltips temáticos."
tags: [chart, area, visualization, recharts, gradient, component, ui]
uses_functions: [cn_typescript_core, chart_container_typescript_ui, get_series_color_typescript_core]
uses_types: [ChartSeries_typescript_ui]
returns: []
returns_optional: false
error_type: ""
imports: [recharts]
tested: false
tests: []
test_file_path: ""
file_path: "frontend/functions/ui/area_chart.tsx"
props:
- name: data
type: "Record<string, unknown>[]"
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"
emits: []
has_state: false
framework: react
variant: [default, stacked]
source_repo: "https://gitea-dgg044oo04woo4ggcsws4gk0.organic-machine.com/Bl4cksmith/Frontend_Library"
source_license: "MIT"
source_file: "frontend/src/components/ui/charts/area-chart.tsx"
---
## Ejemplo
```tsx
<AreaChart data={data} xKey="date" yKey="revenue" gradient />
<AreaChart data={data} xKey="date" series={series} stacked showLegend />
```
+62
View File
@@ -0,0 +1,62 @@
import {
AreaChart as RechartsAreaChart, Area, XAxis, YAxis, CartesianGrid, Tooltip, Legend,
} from 'recharts'
import { ChartContainer, ChartTooltipContent, type Series, getSeriesColor } from './chart_container'
interface GradientConfig { from: string; to: string }
interface AreaChartProps {
data: Record<string, unknown>[]
xKey: string
yKey?: string
series?: Series[]
stacked?: boolean
gradient?: GradientConfig | boolean
showGrid?: boolean
showLegend?: boolean
height?: number | string
className?: string
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, className, xAxisFormatter, yAxisFormatter,
valueFormatter = (v) => v.toLocaleString(),
}: AreaChartProps) {
const areas = series
? series.map((s, i) => ({ dataKey: s.key, name: s.name, color: getSeriesColor(i, s.color) }))
: yKey ? [{ dataKey: yKey, name: yKey, color: getSeriesColor(0) }] : []
const gradientConfig: GradientConfig | null = gradient
? typeof gradient === 'object' ? gradient : { from: '', to: 'transparent' }
: null
return (
<ChartContainer className={className} height={height}>
<RechartsAreaChart data={data} margin={{ top: 10, right: 10, left: 10, bottom: 10 }}>
<defs>
{areas.map((area) => (
<linearGradient key={area.dataKey} id={`gradient-${area.dataKey}`} x1="0" y1="0" x2="0" y2="1">
<stop offset="5%" stopColor={gradientConfig?.from || area.color} stopOpacity={0.8} />
<stop offset="95%" stopColor={gradientConfig?.to || area.color} stopOpacity={0.1} />
</linearGradient>
))}
</defs>
{showGrid && <CartesianGrid strokeDasharray="3 3" className="stroke-muted" />}
<XAxis dataKey={xKey} tickFormatter={xAxisFormatter} className="text-xs fill-muted-foreground" />
<YAxis tickFormatter={yAxisFormatter} className="text-xs fill-muted-foreground" />
<Tooltip content={<ChartTooltipContent valueFormatter={valueFormatter} />} cursor={{ stroke: 'hsl(var(--muted-foreground))', strokeDasharray: '3 3' }} />
{showLegend && <Legend />}
{areas.map((area) => (
<Area key={area.dataKey} type="monotone" dataKey={area.dataKey} name={area.name} stroke={area.color} strokeWidth={2} fill={gradient ? `url(#gradient-${area.dataKey})` : area.color} fillOpacity={gradient ? 1 : 0.3} stackId={stacked ? 'stack' : undefined} />
))}
</RechartsAreaChart>
</ChartContainer>
)
}
export const AreaChart = AreaChartComponent
export type { AreaChartProps, GradientConfig }
+48
View File
@@ -0,0 +1,48 @@
---
name: badge
kind: component
lang: ts
domain: ui
version: "1.0.0"
purity: impure
signature: "Badge(props: BadgeProps & VariantProps<typeof badgeVariants>): JSX.Element"
description: "Badge con 10 variantes semánticas (default, secondary, destructive, outline, ghost, link, success, warning, error, info) y 2 tamaños."
tags: [badge, status, component, ui, indicator]
uses_functions: [cn_typescript_core]
uses_types: []
returns: []
returns_optional: false
error_type: ""
imports: ["class-variance-authority"]
tested: false
tests: []
test_file_path: ""
file_path: "frontend/functions/ui/badge.tsx"
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"
emits: []
has_state: false
framework: react
variant: [default, secondary, destructive, outline, ghost, link, success, warning, error, info]
source_repo: "https://gitea-dgg044oo04woo4ggcsws4gk0.organic-machine.com/Bl4cksmith/Frontend_Library"
source_license: "MIT"
source_file: "frontend/src/components/ui/badge.tsx"
---
## Ejemplo
```tsx
<Badge variant="success">Active</Badge>
<Badge variant="error" size="sm">Error</Badge>
```
## Notas
Versión simplificada que usa span nativo en lugar de useRender de Base-UI. Mantiene todas las variantes y la composibilidad con cn().
+45
View File
@@ -0,0 +1,45 @@
import * as React from "react"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "../core/cn"
const badgeVariants = cva(
"group/badge inline-flex h-5 w-fit shrink-0 items-center justify-center gap-1 overflow-hidden rounded-4xl border border-transparent px-2 py-0.5 text-xs font-medium whitespace-nowrap transition-all focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 [&>svg]:pointer-events-none [&>svg]:size-3!",
{
variants: {
variant: {
default: "bg-primary text-primary-foreground [a]:hover:bg-primary/80",
secondary: "bg-secondary text-secondary-foreground [a]:hover:bg-secondary/80",
destructive: "bg-destructive/10 text-destructive [a]:hover:bg-destructive/20",
outline: "border-border text-foreground [a]:hover:bg-muted",
ghost: "hover:bg-muted hover:text-muted-foreground dark:hover:bg-muted/50",
link: "text-primary underline-offset-4 hover:underline",
success: "bg-green-500/10 text-green-600 dark:bg-green-500/20 dark:text-green-400",
warning: "bg-yellow-500/10 text-yellow-600 dark:bg-yellow-500/20 dark:text-yellow-400",
error: "bg-red-500/10 text-red-600 dark:bg-red-500/20 dark:text-red-400",
info: "bg-blue-500/10 text-blue-600 dark:bg-blue-500/20 dark:text-blue-400",
},
size: {
default: "h-5 px-2 text-xs",
sm: "h-4 px-1.5 text-[10px]",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
}
)
interface BadgeProps extends React.HTMLAttributes<HTMLSpanElement>, VariantProps<typeof badgeVariants> {}
function Badge({ className, variant = "default", size = "default", ...props }: BadgeProps) {
return (
<span
data-slot="badge"
className={cn(badgeVariants({ variant, size }), className)}
{...props}
/>
)
}
export { Badge, badgeVariants }
+52
View File
@@ -0,0 +1,52 @@
---
name: bar_chart
kind: component
lang: typescript
domain: ui
version: "1.0.0"
purity: impure
signature: "BarChart(props: BarChartProps): JSX.Element"
description: "Gráfico de barras Recharts con multi-series, orientación horizontal/vertical, tooltips temáticos y bordes redondeados."
tags: [chart, bar, visualization, recharts, component, ui]
uses_functions: [cn_typescript_core, chart_container_typescript_ui, get_series_color_typescript_core]
uses_types: [ChartSeries_typescript_ui]
returns: []
returns_optional: false
error_type: ""
imports: [recharts]
tested: false
tests: []
test_file_path: ""
file_path: "frontend/functions/ui/bar_chart.tsx"
props:
- name: data
type: "Record<string, unknown>[]"
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"
emits: []
has_state: false
framework: react
variant: [vertical, horizontal]
source_repo: "https://gitea-dgg044oo04woo4ggcsws4gk0.organic-machine.com/Bl4cksmith/Frontend_Library"
source_license: "MIT"
source_file: "frontend/src/components/ui/charts/bar-chart.tsx"
---
## Ejemplo
```tsx
<BarChart data={data} xKey="category" yKey="sales" showLegend />
<BarChart data={data} xKey="name" series={series} horizontal />
```
+53
View File
@@ -0,0 +1,53 @@
import {
BarChart as RechartsBarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, Legend,
} from 'recharts'
import { ChartContainer, ChartTooltipContent, 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 | string
className?: string
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, className, xAxisFormatter, yAxisFormatter, valueFormatter = (v) => v.toLocaleString(),
}: BarChartProps) {
const bars = series
? series.map((s, i) => ({ dataKey: s.key, name: s.name, fill: getSeriesColor(i, s.color) }))
: yKey ? [{ dataKey: yKey, name: yKey, fill: getSeriesColor(0) }] : []
return (
<ChartContainer className={className} height={height}>
<RechartsBarChart data={data} layout={horizontal ? 'vertical' : 'horizontal'} margin={{ top: 10, right: 10, left: 10, bottom: 10 }}>
{showGrid && <CartesianGrid strokeDasharray="3 3" className="stroke-muted" />}
{horizontal ? (
<>
<XAxis type="number" tickFormatter={yAxisFormatter} className="text-xs fill-muted-foreground" />
<YAxis dataKey={xKey} type="category" tickFormatter={xAxisFormatter} width={80} className="text-xs fill-muted-foreground" />
</>
) : (
<>
<XAxis dataKey={xKey} tickFormatter={xAxisFormatter} className="text-xs fill-muted-foreground" />
<YAxis tickFormatter={yAxisFormatter} className="text-xs fill-muted-foreground" />
</>
)}
<Tooltip content={<ChartTooltipContent valueFormatter={valueFormatter} />} cursor={{ fill: 'hsl(var(--muted) / 0.3)' }} />
{showLegend && <Legend />}
{bars.map((bar) => <Bar key={bar.dataKey} dataKey={bar.dataKey} name={bar.name} fill={bar.fill} radius={[4, 4, 0, 0]} />)}
</RechartsBarChart>
</ChartContainer>
)
}
export const BarChart = BarChartComponent
export type { BarChartProps }
+53
View File
@@ -0,0 +1,53 @@
---
name: button
kind: component
lang: ts
domain: ui
version: "1.0.0"
purity: impure
signature: "Button(props: ButtonProps & VariantProps<typeof buttonVariants>): JSX.Element"
description: "Botón accesible con 6 variantes (default, outline, secondary, ghost, destructive, link) y 8 tamaños. Base-UI primitivo con CVA."
tags: [button, component, ui, interactive, cva]
uses_functions: [cn_typescript_core]
uses_types: []
returns: []
returns_optional: false
error_type: ""
imports: ["@base-ui/react", "class-variance-authority"]
tested: false
tests: []
test_file_path: ""
file_path: "frontend/functions/ui/button.tsx"
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"
emits: [onClick]
has_state: false
framework: react
variant: [default, outline, secondary, ghost, destructive, link]
source_repo: "https://gitea-dgg044oo04woo4ggcsws4gk0.organic-machine.com/Bl4cksmith/Frontend_Library"
source_license: "MIT"
source_file: "frontend/src/components/ui/button.tsx"
---
## Ejemplo
```tsx
<Button variant="outline" size="sm">Click me</Button>
<Button variant="destructive">Delete</Button>
<Button variant="ghost" size="icon"><TrashIcon /></Button>
```
## Notas
Componente base del sistema. Usa Base-UI Button primitive para accesibilidad completa (keyboard, ARIA). CVA para gestión type-safe de variantes.
+52
View File
@@ -0,0 +1,52 @@
"use client"
import { Button as ButtonPrimitive } from "@base-ui/react/button"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "../core/cn"
const buttonVariants = cva(
"group/button inline-flex shrink-0 items-center justify-center rounded-lg border border-transparent bg-clip-padding text-sm font-medium whitespace-nowrap transition-all outline-none select-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 active:translate-y-px disabled:pointer-events-none disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
{
variants: {
variant: {
default: "bg-primary text-primary-foreground [a]:hover:bg-primary/80",
outline: "border-border bg-background hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:border-input dark:bg-input/30 dark:hover:bg-input/50",
secondary: "bg-secondary text-secondary-foreground hover:bg-secondary/80 aria-expanded:bg-secondary aria-expanded:text-secondary-foreground",
ghost: "hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:hover:bg-muted/50",
destructive: "bg-destructive/10 text-destructive hover:bg-destructive/20 focus-visible:border-destructive/40 focus-visible:ring-destructive/20 dark:bg-destructive/20 dark:hover:bg-destructive/30 dark:focus-visible:ring-destructive/40",
link: "text-primary underline-offset-4 hover:underline",
},
size: {
default: "h-8 gap-1.5 px-2.5 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2",
xs: "h-6 gap-1 rounded-[min(var(--radius-md),10px)] px-2 text-xs",
sm: "h-7 gap-1 rounded-[min(var(--radius-md),12px)] px-2.5 text-[0.8rem]",
lg: "h-9 gap-1.5 px-2.5 has-data-[icon=inline-end]:pr-3 has-data-[icon=inline-start]:pl-3",
icon: "size-8",
"icon-xs": "size-6 rounded-[min(var(--radius-md),10px)]",
"icon-sm": "size-7 rounded-[min(var(--radius-md),12px)]",
"icon-lg": "size-9",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
}
)
function Button({
className,
variant = "default",
size = "default",
...props
}: ButtonPrimitive.Props & VariantProps<typeof buttonVariants>) {
return (
<ButtonPrimitive
data-slot="button"
className={cn(buttonVariants({ variant, size, className }))}
{...props}
/>
)
}
export { Button, buttonVariants }
+54
View File
@@ -0,0 +1,54 @@
---
name: card
kind: component
lang: ts
domain: ui
version: "1.0.0"
purity: impure
signature: "Card(props: { size?: 'default' | 'sm'; className?: string; children: ReactNode }): JSX.Element"
description: "Contenedor card con header, title, description, action, content y footer. Sistema de slots composable."
tags: [card, container, layout, component, ui]
uses_functions: [cn_typescript_core]
uses_types: []
returns: []
returns_optional: false
error_type: ""
imports: ["react"]
tested: false
tests: []
test_file_path: ""
file_path: "frontend/functions/ui/card.tsx"
props:
- name: size
type: "'default' | 'sm'"
required: false
description: "Tamaño del card"
- name: className
type: "string"
required: false
description: "Clases CSS adicionales"
emits: []
has_state: false
framework: react
variant: [default, sm]
source_repo: "https://gitea-dgg044oo04woo4ggcsws4gk0.organic-machine.com/Bl4cksmith/Frontend_Library"
source_license: "MIT"
source_file: "frontend/src/components/ui/card.tsx"
---
## Ejemplo
```tsx
<Card>
<CardHeader>
<CardTitle>Título</CardTitle>
<CardDescription>Descripción</CardDescription>
</CardHeader>
<CardContent>Contenido</CardContent>
<CardFooter>Footer</CardFooter>
</Card>
```
## Notas
Sistema de slots via data-slot attributes. Card detecta automáticamente la presencia de CardFooter y ajusta el padding. Exporta 7 subcomponentes composables.
+85
View File
@@ -0,0 +1,85 @@
import * as React from "react"
import { cn } from "../core/cn"
function Card({
className,
size = "default",
...props
}: React.ComponentProps<"div"> & { size?: "default" | "sm" }) {
return (
<div
data-slot="card"
data-size={size}
className={cn(
"group/card flex flex-col gap-4 overflow-hidden rounded-xl bg-card py-4 text-sm text-card-foreground ring-1 ring-foreground/10 has-data-[slot=card-footer]:pb-0 has-[>img:first-child]:pt-0 data-[size=sm]:gap-3 data-[size=sm]:py-3 data-[size=sm]:has-data-[slot=card-footer]:pb-0 *:[img:first-child]:rounded-t-xl *:[img:last-child]:rounded-b-xl",
className
)}
{...props}
/>
)
}
function CardHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-header"
className={cn(
"group/card-header @container/card-header grid auto-rows-min items-start gap-1 rounded-t-xl px-4 group-data-[size=sm]/card:px-3 has-data-[slot=card-action]:grid-cols-[1fr_auto] has-data-[slot=card-description]:grid-rows-[auto_auto] [.border-b]:pb-4 group-data-[size=sm]/card:[.border-b]:pb-3",
className
)}
{...props}
/>
)
}
function CardTitle({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-title"
className={cn("text-base leading-snug font-medium group-data-[size=sm]/card:text-sm", className)}
{...props}
/>
)
}
function CardDescription({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-description"
className={cn("text-sm text-muted-foreground", className)}
{...props}
/>
)
}
function CardAction({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-action"
className={cn("col-start-2 row-span-2 row-start-1 self-start justify-self-end", className)}
{...props}
/>
)
}
function CardContent({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-content"
className={cn("px-4 group-data-[size=sm]/card:px-3", className)}
{...props}
/>
)
}
function CardFooter({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-footer"
className={cn("flex items-center rounded-b-xl border-t bg-muted/50 p-4 group-data-[size=sm]/card:p-3", className)}
{...props}
/>
)
}
export { Card, CardHeader, CardFooter, CardTitle, CardAction, CardDescription, CardContent }
+49
View File
@@ -0,0 +1,49 @@
---
name: chart_container
kind: component
lang: typescript
domain: ui
version: "1.0.0"
purity: impure
signature: "ChartContainer(props: { children: ReactNode; height?: number | string }): JSX.Element"
description: "Base para todos los charts Recharts: container responsive, tooltip temático, legend y utilidades de colores por serie."
tags: [chart, container, recharts, base, visualization, component, ui]
uses_functions: [cn_typescript_core, get_series_color_typescript_core]
uses_types: [ChartSeries_typescript_ui]
returns: []
returns_optional: false
error_type: ""
imports: [recharts, react]
tested: false
tests: []
test_file_path: ""
file_path: "frontend/functions/ui/chart_container.tsx"
props:
- name: height
type: "number | string"
required: false
description: "Altura del chart (default 300)"
- name: className
type: "string"
required: false
description: "Clases CSS adicionales"
emits: []
has_state: false
framework: react
variant: [default]
source_repo: "https://gitea-dgg044oo04woo4ggcsws4gk0.organic-machine.com/Bl4cksmith/Frontend_Library"
source_license: "MIT"
source_file: "frontend/src/components/ui/charts/chart-base.tsx"
---
## Ejemplo
```tsx
<ChartContainer height={400}>
<RechartsLineChart data={data}>...</RechartsLineChart>
</ChartContainer>
```
## Notas
Exporta: ChartContainer, ChartTooltipContent, ChartTooltip, ChartLegend, chartColors, defaultColors, getSeriesColor, Series.
+80
View File
@@ -0,0 +1,80 @@
import * as React from 'react'
import { cn } from '../core/cn'
import { ResponsiveContainer, Tooltip as RechartsTooltip, Legend as RechartsLegend } from 'recharts'
export const chartColors = [
'hsl(var(--chart-1, 220 70% 50%))',
'hsl(var(--chart-2, 160 60% 45%))',
'hsl(var(--chart-3, 30 80% 55%))',
'hsl(var(--chart-4, 280 65% 60%))',
'hsl(var(--chart-5, 340 75% 55%))',
]
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
className?: string
height?: number | string
}
export function ChartContainer({ children, className, height = 300 }: ChartContainerProps) {
return (
<div
className={cn('w-full', className)}
style={{ height, minWidth: 100, minHeight: typeof height === 'number' ? Math.min(height, 100) : 100 }}
>
<ResponsiveContainer width="100%" height="100%">
{children as React.ReactElement}
</ResponsiveContainer>
</div>
)
}
interface ChartTooltipContentProps {
active?: boolean
payload?: Array<{ name: string; value: number; color: string; dataKey: string }>
label?: string
labelFormatter?: (label: string) => string
valueFormatter?: (value: number) => string
}
export function ChartTooltipContent({
active, payload, label,
labelFormatter = (l) => l,
valueFormatter = (v) => v.toLocaleString(),
}: ChartTooltipContentProps) {
if (!active || !payload?.length) return null
return (
<div className="rounded-lg border bg-background p-2 shadow-md">
<p className="mb-1 text-sm font-medium">{labelFormatter(label || '')}</p>
<div className="space-y-0.5">
{payload.map((entry, index) => (
<div key={index} className="flex items-center gap-2 text-sm">
<div className="size-2.5 rounded-full" style={{ backgroundColor: entry.color }} />
<span className="text-muted-foreground">{entry.name}:</span>
<span className="font-medium">{valueFormatter(entry.value)}</span>
</div>
))}
</div>
</div>
)
}
export function ChartTooltip(props: React.ComponentProps<typeof RechartsTooltip>) {
return <RechartsTooltip content={<ChartTooltipContent />} cursor={{ fill: 'hsl(var(--muted) / 0.3)' }} {...props} />
}
export function ChartLegend(props: React.ComponentProps<typeof RechartsLegend>) {
return <RechartsLegend wrapperStyle={{ paddingTop: 16 }} {...props} />
}
+51
View File
@@ -0,0 +1,51 @@
---
name: crud_page
kind: function
lang: typescript
domain: ui
version: "1.0.0"
purity: pure
signature: "crudPage<T>(props: CrudPageProps<T>): ReactElement"
description: "Genera una página CRUD completa con header, tabla con columnas configurables, botones de acción (add/edit/delete) y schema de formulario."
tags: [crud, page, table, form, factory, composition, ui]
uses_functions: [cn_typescript_core]
uses_types: []
returns: []
returns_optional: false
error_type: ""
imports: [react]
tested: false
tests: []
test_file_path: ""
file_path: "frontend/functions/ui/crud_page.tsx"
source_repo: "https://gitea-dgg044oo04woo4ggcsws4gk0.organic-machine.com/Bl4cksmith/Frontend_Library"
source_license: "MIT"
source_file: ""
---
## Ejemplo
```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,
})
```
## Notas
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.
+120
View File
@@ -0,0 +1,120 @@
import * as React from 'react'
import { cn } from '../core/cn'
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,
className,
}: CrudPageProps<T>): React.ReactElement {
return (
<div className={cn('space-y-6', className)}>
{/* Header */}
<div className="flex items-center justify-between border-b pb-4">
<div className="space-y-1">
<h1 className="text-2xl font-semibold tracking-tight">{title}</h1>
{subtitle && <p className="text-sm text-muted-foreground">{subtitle}</p>}
</div>
<div className="flex items-center gap-2">
{actions}
{onAdd && (
<button className="inline-flex h-8 items-center gap-1.5 rounded-lg bg-primary px-2.5 text-sm font-medium text-primary-foreground hover:bg-primary/80">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><path d="M12 5v14M5 12h14"/></svg>
Add {title.replace(/s$/, '')}
</button>
)}
</div>
</div>
{/* Table */}
<div className="rounded-lg border">
<table className="w-full caption-bottom text-sm">
<thead className="border-b bg-muted/50">
<tr>
{columns.map((col) => (
<th key={String(col.key)} className="px-4 py-3 text-left text-sm font-medium text-muted-foreground">
{col.label}
</th>
))}
{(onEdit || onDelete) && (
<th className="px-4 py-3 text-right text-sm font-medium text-muted-foreground">Actions</th>
)}
</tr>
</thead>
<tbody className="divide-y">
{data.length === 0 ? (
<tr>
<td colSpan={columns.length + (onEdit || onDelete ? 1 : 0)} className="h-24 text-center text-muted-foreground">
No items yet.
</td>
</tr>
) : (
data.map((row, i) => (
<tr key={i} className="hover:bg-muted/50">
{columns.map((col) => (
<td key={String(col.key)} className="px-4 py-3 align-middle">
{col.render ? col.render(row[col.key], row) : String(row[col.key] ?? '')}
</td>
))}
{(onEdit || onDelete) && (
<td className="px-4 py-3 text-right">
<div className="flex justify-end gap-1">
{onEdit && (
<button onClick={() => onEdit(row)} className="inline-flex size-7 items-center justify-center rounded-md hover:bg-muted">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"/><path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z"/></svg>
</button>
)}
{onDelete && (
<button onClick={() => onDelete(row)} className="inline-flex size-7 items-center justify-center rounded-md text-destructive hover:bg-destructive/10">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><path d="M3 6h18M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/></svg>
</button>
)}
</div>
</td>
)}
</tr>
))
)}
</tbody>
</table>
</div>
{/* Form fields definition (for agent use — renders a form preview) */}
<div className="hidden" data-slot="crud-form-schema" data-fields={JSON.stringify(fields)} />
</div>
)
}
export type { CrudPageProps, CrudField }
+42
View File
@@ -0,0 +1,42 @@
---
name: dashboard_layout
kind: function
lang: typescript
domain: ui
version: "1.0.0"
purity: pure
signature: "dashboardLayout(props: DashboardLayoutProps): ReactElement"
description: "Genera un grid responsive de dashboard a partir de un array de widgets con span configurable. 1-4 columnas con auto-responsive."
tags: [dashboard, layout, grid, factory, composition, ui]
uses_functions: [cn_typescript_core]
uses_types: []
returns: []
returns_optional: false
error_type: ""
imports: [react]
tested: false
tests: []
test_file_path: ""
file_path: "frontend/functions/ui/dashboard_layout.tsx"
source_repo: "https://gitea-dgg044oo04woo4ggcsws4gk0.organic-machine.com/Bl4cksmith/Frontend_Library"
source_license: "MIT"
source_file: ""
---
## Ejemplo
```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} /> },
]
})
```
## Notas
Factory pura — dado el mismo input siempre genera el mismo JSX. Un agente puede construir dashboards completos pasando widgets como configuración declarativa.
@@ -0,0 +1,67 @@
import * as React from 'react'
import { cn } from '../core/cn'
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 gapClasses = { sm: 'gap-2', md: 'gap-4', lg: 'gap-6' }
const spanClasses: Record<number, string> = {
1: 'col-span-1',
2: 'col-span-1 md:col-span-2',
3: 'col-span-1 md:col-span-2 lg:col-span-3',
4: 'col-span-1 md:col-span-2 lg:col-span-4',
}
const rowSpanClasses: Record<number, string> = {
1: 'row-span-1',
2: 'row-span-2',
}
export function dashboardLayout({
widgets,
columns = 4,
gap = 'md',
className,
}: DashboardLayoutProps): React.ReactElement {
const gridCols: Record<number, string> = {
1: 'grid-cols-1',
2: 'grid-cols-1 md:grid-cols-2',
3: 'grid-cols-1 md:grid-cols-2 lg:grid-cols-3',
4: 'grid-cols-1 md:grid-cols-2 lg:grid-cols-4',
}
return (
<div className={cn('grid', gridCols[columns], gapClasses[gap], className)}>
{widgets.map((widget) => (
<div
key={widget.id}
className={cn(
'rounded-xl border bg-card p-4 text-card-foreground shadow-sm',
spanClasses[widget.span || 1],
rowSpanClasses[widget.rowSpan || 1]
)}
>
{widget.title && (
<h3 className="mb-3 text-sm font-medium text-muted-foreground">{widget.title}</h3>
)}
{widget.content}
</div>
))}
</div>
)
}
export type { DashboardWidget, DashboardLayoutProps }
+54
View File
@@ -0,0 +1,54 @@
---
name: detail_page
kind: function
lang: typescript
domain: ui
version: "1.0.0"
purity: pure
signature: "detailPage(props: DetailPageProps): ReactElement"
description: "Genera una página de detalle de entidad con header (avatar, badge, back), grid de campos, tabs con contadores y timeline de actividad."
tags: [detail, page, entity, timeline, factory, composition, ui]
uses_functions: [cn_typescript_core]
uses_types: []
returns: []
returns_optional: false
error_type: ""
imports: [react]
tested: false
tests: []
test_file_path: ""
file_path: "frontend/functions/ui/detail_page.tsx"
source_repo: "https://gitea-dgg044oo04woo4ggcsws4gk0.organic-machine.com/Bl4cksmith/Frontend_Library"
source_license: "MIT"
source_file: ""
---
## Ejemplo
```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' },
],
})
```
## Notas
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.
+134
View File
@@ -0,0 +1,134 @@
import * as React from 'react'
import { cn } from '../core/cn'
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 variantDotColors = {
default: 'bg-primary',
success: 'bg-green-500',
warning: 'bg-amber-500',
error: 'bg-red-500',
}
export function detailPage({
title, subtitle, badge, avatar, actions, onBack,
fields, tabs, activeTab, onTabChange, timeline, className,
}: DetailPageProps): React.ReactElement {
return (
<div className={cn('space-y-6', className)}>
{/* Header */}
<div className="flex items-start justify-between border-b pb-4">
<div className="flex items-start gap-4">
{onBack && (
<button onClick={onBack} className="mt-1 inline-flex size-7 shrink-0 items-center justify-center rounded-md hover:bg-muted">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><path d="m15 18-6-6 6-6"/></svg>
</button>
)}
{avatar && <div className="size-12 shrink-0 overflow-hidden rounded-full bg-muted">{avatar}</div>}
<div className="space-y-1">
<div className="flex items-center gap-3">
<h1 className="text-2xl font-semibold tracking-tight">{title}</h1>
{badge}
</div>
{subtitle && <p className="text-sm text-muted-foreground">{subtitle}</p>}
</div>
</div>
{actions && <div className="flex items-center gap-2">{actions}</div>}
</div>
{/* Fields grid */}
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
{fields.map((field, i) => (
<div key={i} className={cn('space-y-1', field.span === 2 && 'md:col-span-2')}>
<p className="text-sm text-muted-foreground">{field.label}</p>
<div className="text-sm font-medium">{field.value}</div>
</div>
))}
</div>
{/* Tabs */}
{tabs && tabs.length > 0 && (
<div className="space-y-4">
<nav className="flex gap-4 border-b">
{tabs.map((tab) => (
<button
key={tab.value}
type="button"
onClick={() => onTabChange?.(tab.value)}
className={cn(
'inline-flex items-center gap-2 border-b-2 px-1 pb-3 text-sm font-medium transition-colors',
activeTab === tab.value ? 'border-primary text-foreground' : 'border-transparent text-muted-foreground hover:text-foreground'
)}
>
{tab.label}
{tab.count !== undefined && (
<span className="inline-flex h-5 items-center rounded-full bg-muted px-2 text-xs font-medium">{tab.count}</span>
)}
</button>
))}
</nav>
{tabs.find(t => t.value === activeTab)?.content}
</div>
)}
{/* Timeline */}
{timeline && timeline.length > 0 && (
<div className="space-y-3">
<h3 className="text-sm font-medium text-muted-foreground">Activity</h3>
<div className="space-y-0">
{timeline.map((event, i) => (
<div key={event.id} className="flex gap-3 pb-4">
<div className="flex flex-col items-center">
<div className={cn('mt-1 size-2.5 rounded-full', variantDotColors[event.variant || 'default'])} />
{i < timeline.length - 1 && <div className="flex-1 w-px bg-border" />}
</div>
<div className="flex-1 space-y-0.5 pb-2">
<p className="text-sm font-medium">{event.title}</p>
{event.description && <p className="text-xs text-muted-foreground">{event.description}</p>}
<p className="text-xs text-muted-foreground/70">{event.timestamp}</p>
</div>
</div>
))}
</div>
</div>
)}
</div>
)
}
export type { DetailPageProps, DetailField, DetailTab, TimelineEvent }
+55
View File
@@ -0,0 +1,55 @@
---
name: dialog
kind: component
lang: typescript
domain: ui
version: "1.0.0"
purity: impure
signature: "Dialog(props: DialogRootProps): JSX.Element"
description: "Diálogo modal accesible con overlay blur, animaciones, close button y sistema de slots (header, footer, title, description)."
tags: [dialog, modal, overlay, component, ui, interactive]
uses_functions: [cn_typescript_core]
uses_types: []
returns: []
returns_optional: false
error_type: ""
imports: ["@base-ui/react", lucide-react, react]
tested: false
tests: []
test_file_path: ""
file_path: "frontend/functions/ui/dialog.tsx"
props:
- name: showCloseButton
type: "boolean"
required: false
description: "Mostrar botón de cerrar (default true)"
emits: [onOpenChange]
has_state: true
framework: react
variant: [default]
source_repo: "https://gitea-dgg044oo04woo4ggcsws4gk0.organic-machine.com/Bl4cksmith/Frontend_Library"
source_license: "MIT"
source_file: "frontend/src/components/ui/dialog.tsx"
---
## Ejemplo
```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>
```
## Notas
10 subcomponentes exportados. Base-UI Dialog primitive para accesibilidad completa (focus trap, escape, click outside).
+73
View File
@@ -0,0 +1,73 @@
import * as React from "react"
import { Dialog as DialogPrimitive } from "@base-ui/react/dialog"
import { cn } from "../core/cn"
import { XIcon } from "lucide-react"
function Dialog({ ...props }: DialogPrimitive.Root.Props) {
return <DialogPrimitive.Root data-slot="dialog" {...props} />
}
function DialogTrigger({ ...props }: DialogPrimitive.Trigger.Props) {
return <DialogPrimitive.Trigger data-slot="dialog-trigger" {...props} />
}
function DialogPortal({ ...props }: DialogPrimitive.Portal.Props) {
return <DialogPrimitive.Portal data-slot="dialog-portal" {...props} />
}
function DialogClose({ ...props }: DialogPrimitive.Close.Props) {
return <DialogPrimitive.Close data-slot="dialog-close" {...props} />
}
function DialogOverlay({ className, ...props }: DialogPrimitive.Backdrop.Props) {
return (
<DialogPrimitive.Backdrop
data-slot="dialog-overlay"
className={cn("fixed inset-0 isolate z-50 bg-black/10 duration-100 supports-backdrop-filter:backdrop-blur-xs data-open:animate-in data-open:fade-in-0 data-closed:animate-out data-closed:fade-out-0", className)}
{...props}
/>
)
}
function DialogContent({ className, children, showCloseButton = true, ...props }: DialogPrimitive.Popup.Props & { showCloseButton?: boolean }) {
return (
<DialogPortal>
<DialogOverlay />
<DialogPrimitive.Popup
data-slot="dialog-content"
className={cn("fixed top-1/2 left-1/2 z-50 grid w-full max-w-[calc(100%-2rem)] -translate-x-1/2 -translate-y-1/2 gap-4 rounded-xl bg-background p-4 text-sm ring-1 ring-foreground/10 duration-100 outline-none sm:max-w-sm data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95", className)}
{...props}
>
{children}
{showCloseButton && (
<DialogPrimitive.Close data-slot="dialog-close" className="absolute top-2 right-2 inline-flex size-7 items-center justify-center rounded-md hover:bg-muted">
<XIcon className="size-4" />
<span className="sr-only">Close</span>
</DialogPrimitive.Close>
)}
</DialogPrimitive.Popup>
</DialogPortal>
)
}
function DialogHeader({ className, ...props }: React.ComponentProps<"div">) {
return <div data-slot="dialog-header" className={cn("flex flex-col gap-2", className)} {...props} />
}
function DialogFooter({ className, children, ...props }: React.ComponentProps<"div">) {
return (
<div data-slot="dialog-footer" className={cn("-mx-4 -mb-4 flex flex-col-reverse gap-2 rounded-b-xl border-t bg-muted/50 p-4 sm:flex-row sm:justify-end", className)} {...props}>
{children}
</div>
)
}
function DialogTitle({ className, ...props }: DialogPrimitive.Title.Props) {
return <DialogPrimitive.Title data-slot="dialog-title" className={cn("text-base leading-none font-medium", className)} {...props} />
}
function DialogDescription({ className, ...props }: DialogPrimitive.Description.Props) {
return <DialogPrimitive.Description data-slot="dialog-description" className={cn("text-sm text-muted-foreground", className)} {...props} />
}
export { Dialog, DialogClose, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogOverlay, DialogPortal, DialogTitle, DialogTrigger }
+53
View File
@@ -0,0 +1,53 @@
---
name: form_field
kind: component
lang: typescript
domain: ui
version: "1.0.0"
purity: impure
signature: "FormField(props: FormFieldProps): JSX.Element"
description: "Wrapper de campo de formulario con label, helper text, error y ARIA automáticos. Inyecta id y aria-describedby a hijos."
tags: [form, field, label, error, component, ui, accessibility]
uses_functions: [cn_typescript_core]
uses_types: []
returns: []
returns_optional: false
error_type: ""
imports: [react]
tested: false
tests: []
test_file_path: ""
file_path: "frontend/functions/ui/form_field.tsx"
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"
emits: []
has_state: false
framework: react
variant: [default]
source_repo: "https://gitea-dgg044oo04woo4ggcsws4gk0.organic-machine.com/Bl4cksmith/Frontend_Library"
source_license: "MIT"
source_file: "frontend/src/components/ui/form-field.tsx"
---
## Ejemplo
```tsx
<FormField label="Email" helperText="Tu email corporativo" error={errors.email}>
<Input type="email" />
</FormField>
```
+42
View File
@@ -0,0 +1,42 @@
import * as React from "react"
import { cn } from "../core/cn"
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,
})
}
return child
})
return (
<div className={cn("flex flex-col gap-1.5", className)}>
{label && <label htmlFor={inputId} className="text-sm font-medium text-foreground">{label}</label>}
{childWithProps}
{helperText && !error && <p id={helperId} className="text-sm text-muted-foreground">{helperText}</p>}
{error && <p id={errorId} className="text-sm text-destructive">{error}</p>}
</div>
)
}
export { FormField }
export type { FormFieldProps }
+51
View File
@@ -0,0 +1,51 @@
---
name: input
kind: component
lang: ts
domain: ui
version: "1.0.0"
purity: impure
signature: "Input(props: InputHTMLAttributes): JSX.Element"
description: "Campo de entrada accesible con soporte para iconos, grupos, validación ARIA y estados disabled/invalid."
tags: [input, form, component, ui, interactive]
uses_functions: [cn_typescript_core]
uses_types: []
returns: []
returns_optional: false
error_type: ""
imports: ["@base-ui/react", "react"]
tested: false
tests: []
test_file_path: ""
file_path: "frontend/functions/ui/input.tsx"
props:
- name: type
type: "string"
required: false
description: "Tipo de input HTML"
- name: className
type: "string"
required: false
description: "Clases CSS adicionales"
emits: [onChange, onFocus, onBlur]
has_state: false
framework: react
variant: [default]
source_repo: "https://gitea-dgg044oo04woo4ggcsws4gk0.organic-machine.com/Bl4cksmith/Frontend_Library"
source_license: "MIT"
source_file: "frontend/src/components/ui/input.tsx"
---
## Ejemplo
```tsx
<Input placeholder="Email" type="email" />
<InputGroup>
<InputIcon position="start"><SearchIcon /></InputIcon>
<Input placeholder="Buscar..." />
</InputGroup>
```
## Notas
Exporta Input, InputGroup e InputIcon. InputGroup detecta automáticamente la presencia de iconos y ajusta padding del Input.
+56
View File
@@ -0,0 +1,56 @@
import * as React from "react"
import { Input as InputPrimitive } from "@base-ui/react/input"
import { cn } from "../core/cn"
function Input({ className, type, ...props }: React.ComponentProps<"input">) {
return (
<InputPrimitive
type={type}
data-slot="input"
className={cn(
"h-8 w-full min-w-0 rounded-lg border border-input bg-transparent px-2.5 py-1 text-base transition-colors outline-none file:inline-flex file:h-6 file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:pointer-events-none disabled:cursor-not-allowed disabled:bg-input/50 disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 md:text-sm dark:bg-input/30 dark:disabled:bg-input/80 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40",
"group-has-[data-slot=input-icon-start]/input-group:pl-9",
"group-has-[data-slot=input-icon-end]/input-group:pr-9",
className
)}
{...props}
/>
)
}
interface InputGroupProps {
children: React.ReactNode
className?: string
}
function InputGroup({ children, className }: InputGroupProps) {
return (
<div data-slot="input-group" className={cn("group/input-group relative", className)}>
{children}
</div>
)
}
interface InputIconProps {
children: React.ReactNode
position: "start" | "end"
className?: string
}
function InputIcon({ children, position, className }: InputIconProps) {
return (
<span
data-slot={`input-icon-${position}`}
className={cn(
"pointer-events-none absolute top-1/2 -translate-y-1/2 text-muted-foreground [&_svg]:size-4",
position === "start" && "left-2.5",
position === "end" && "right-2.5",
className
)}
>
{children}
</span>
)
}
export { Input, InputGroup, InputIcon }
+56
View File
@@ -0,0 +1,56 @@
---
name: kpi_card
kind: component
lang: typescript
domain: ui
version: "1.0.0"
purity: impure
signature: "KPICard(props: KPICardProps): JSX.Element"
description: "Card de KPI con label, valor, delta porcentual con color semántico, icono y subtítulo. 3 tamaños."
tags: [kpi, card, metrics, dashboard, component, ui]
uses_functions: [cn_typescript_core]
uses_types: []
returns: []
returns_optional: false
error_type: ""
imports: [react]
tested: false
tests: []
test_file_path: ""
file_path: "frontend/functions/ui/kpi_card.tsx"
props:
- name: label
type: "string"
required: true
description: "Etiqueta del KPI"
- name: value
type: "string | number"
required: true
description: "Valor principal"
- name: delta
type: "{ value: number; isPositive: boolean }"
required: false
description: "Cambio porcentual con dirección"
- name: icon
type: "ReactNode"
required: false
description: "Icono decorativo"
- name: size
type: "'sm' | 'default' | 'lg'"
required: false
description: "Tamaño"
emits: []
has_state: false
framework: react
variant: [sm, default, lg]
source_repo: "https://gitea-dgg044oo04woo4ggcsws4gk0.organic-machine.com/Bl4cksmith/Frontend_Library"
source_license: "MIT"
source_file: "frontend/src/components/ui/kpi-card.tsx"
---
## Ejemplo
```tsx
<KPICard label="Revenue" value="$12,450" delta={{ value: 12.5, isPositive: true }} />
<KPICard label="Users" value={1234} size="lg" icon={<UsersIcon />} />
```
+60
View File
@@ -0,0 +1,60 @@
import * as React from 'react'
import { cn } from '../core/cn'
type KPICardSize = 'sm' | 'default' | 'lg'
interface Delta {
value: number
isPositive: boolean
}
interface KPICardProps extends React.HTMLAttributes<HTMLDivElement> {
label: string
value: string | number
delta?: Delta
icon?: React.ReactNode
subtitle?: string
size?: KPICardSize
}
const sizeStyles: Record<KPICardSize, { value: string; label: string }> = {
sm: { value: 'text-2xl font-bold', label: 'text-xs' },
default: { value: 'text-3xl font-bold', label: 'text-sm' },
lg: { value: 'text-4xl font-bold', label: 'text-base' },
}
const KPICard = React.forwardRef<HTMLDivElement, KPICardProps>(
({ label, value, delta, icon, subtitle, size = 'default', className, ...props }, ref) => {
const styles = sizeStyles[size]
const deltaColor = delta
? delta.value === 0 ? 'text-muted-foreground'
: delta.isPositive ? 'text-green-600 dark:text-green-500'
: 'text-red-600 dark:text-red-500'
: ''
return (
<div ref={ref} className={cn('rounded-lg border bg-card p-4 text-card-foreground shadow-sm', className)} {...props}>
<div className="flex items-start justify-between">
<div className="space-y-1">
<p className={cn('text-muted-foreground', styles.label)}>{label}</p>
{subtitle && <p className="text-xs text-muted-foreground/80">{subtitle}</p>}
</div>
{icon && <div className="text-muted-foreground">{icon}</div>}
</div>
<div className="mt-3 flex items-end justify-between gap-4">
<div className="space-y-1">
<p className={cn('tracking-tight', styles.value)}>{value}</p>
{delta && (
<div className={cn('flex items-center gap-1 text-sm font-medium', deltaColor)}>
<span>{delta.value > 0 ? '+' : ''}{delta.value}%</span>
</div>
)}
</div>
</div>
</div>
)
}
)
KPICard.displayName = 'KPICard'
export { KPICard, type KPICardProps, type Delta, type KPICardSize }
+39
View File
@@ -0,0 +1,39 @@
---
name: label
kind: component
lang: ts
domain: ui
version: "1.0.0"
purity: impure
signature: "Label(props: LabelHTMLAttributes): JSX.Element"
description: "Etiqueta de formulario accesible con soporte para estados disabled y peer-disabled."
tags: [label, form, component, ui]
uses_functions: [cn_typescript_core]
uses_types: []
returns: []
returns_optional: false
error_type: ""
imports: ["react"]
tested: false
tests: []
test_file_path: ""
file_path: "frontend/functions/ui/label.tsx"
props:
- name: className
type: "string"
required: false
description: "Clases CSS adicionales"
emits: []
has_state: false
framework: react
variant: [default]
source_repo: "https://gitea-dgg044oo04woo4ggcsws4gk0.organic-machine.com/Bl4cksmith/Frontend_Library"
source_license: "MIT"
source_file: "frontend/src/components/ui/label.tsx"
---
## Ejemplo
```tsx
<Label htmlFor="email">Email</Label>
```
+17
View File
@@ -0,0 +1,17 @@
import * as React from "react"
import { cn } from "../core/cn"
function Label({ className, ...props }: React.ComponentProps<"label">) {
return (
<label
data-slot="label"
className={cn(
"flex items-center gap-2 text-sm leading-none font-medium select-none group-data-[disabled=true]:pointer-events-none group-data-[disabled=true]:opacity-50 peer-disabled:cursor-not-allowed peer-disabled:opacity-50",
className
)}
{...props}
/>
)
}
export { Label }
+68
View File
@@ -0,0 +1,68 @@
---
name: line_chart
kind: component
lang: typescript
domain: ui
version: "1.0.0"
purity: impure
signature: "LineChart(props: LineChartProps): JSX.Element"
description: "Gráfico de líneas Recharts con multi-series, 5 tipos de curva, zoom brush, líneas de referencia, tooltips temáticos."
tags: [chart, line, visualization, recharts, component, ui]
uses_functions: [cn_typescript_core, chart_container_typescript_ui, get_series_color_typescript_core]
uses_types: [ChartSeries_typescript_ui]
returns: []
returns_optional: false
error_type: ""
imports: [recharts]
tested: false
tests: []
test_file_path: ""
file_path: "frontend/functions/ui/line_chart.tsx"
props:
- name: data
type: "Record<string, unknown>[]"
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: zoomable
type: "boolean"
required: false
description: "Habilitar zoom brush"
- name: curveType
type: "'linear' | 'monotone' | 'step' | 'stepBefore' | 'stepAfter'"
required: false
description: "Tipo de curva (default monotone)"
- name: referenceLines
type: "Array<{ y: number; label?: string; color?: string }>"
required: false
description: "Líneas de referencia horizontales"
emits: []
has_state: false
framework: react
variant: [default]
source_repo: "https://gitea-dgg044oo04woo4ggcsws4gk0.organic-machine.com/Bl4cksmith/Frontend_Library"
source_license: "MIT"
source_file: "frontend/src/components/ui/charts/line-chart.tsx"
---
## Ejemplo
```tsx
<LineChart
data={salesData}
xKey="month"
series={[
{ key: "revenue", name: "Revenue", color: "#3b82f6" },
{ key: "cost", name: "Cost", color: "#ef4444" },
]}
zoomable
showLegend
/>
```
+57
View File
@@ -0,0 +1,57 @@
import {
LineChart as RechartsLineChart, Line, XAxis, YAxis, CartesianGrid,
Tooltip, Legend, Brush, ReferenceLine,
} from 'recharts'
import { ChartContainer, ChartTooltipContent, 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
zoomable?: boolean
height?: number | string
className?: string
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, zoomable = false, height = 300, className, xAxisFormatter, yAxisFormatter,
valueFormatter = (v) => v.toLocaleString(), referenceLines = [],
}: LineChartProps) {
const lines = series
? series.map((s, i) => ({ dataKey: s.key, name: s.name, stroke: getSeriesColor(i, s.color) }))
: yKey ? [{ dataKey: yKey, name: yKey, stroke: getSeriesColor(0) }] : []
return (
<ChartContainer className={className} height={height}>
<RechartsLineChart data={data} margin={{ top: 10, right: 10, left: 10, bottom: zoomable ? 30 : 10 }}>
{showGrid && <CartesianGrid strokeDasharray="3 3" className="stroke-muted" />}
<XAxis dataKey={xKey} tickFormatter={xAxisFormatter} className="text-xs fill-muted-foreground" />
<YAxis tickFormatter={yAxisFormatter} className="text-xs fill-muted-foreground" />
<Tooltip content={<ChartTooltipContent valueFormatter={valueFormatter} />} cursor={{ stroke: 'hsl(var(--muted-foreground))', strokeDasharray: '3 3' }} />
{showLegend && <Legend />}
{referenceLines.map((ref, i) => (
<ReferenceLine key={i} y={ref.y} stroke={ref.color || 'hsl(var(--muted-foreground))'} strokeDasharray="3 3" label={ref.label ? { value: ref.label, position: 'right' } : undefined} />
))}
{lines.map((line) => (
<Line key={line.dataKey} type={curveType} dataKey={line.dataKey} name={line.name} stroke={line.stroke} strokeWidth={2} dot={showDots ? { r: 3, fill: line.stroke } : false} activeDot={{ r: 5, fill: line.stroke }} />
))}
{zoomable && <Brush dataKey={xKey} height={20} stroke="hsl(var(--primary))" fill="hsl(var(--muted))" tickFormatter={xAxisFormatter} />}
</RechartsLineChart>
</ChartContainer>
)
}
export const LineChart = LineChartComponent
export type { LineChartProps, CurveType }
+62
View File
@@ -0,0 +1,62 @@
---
name: page_header
kind: component
lang: typescript
domain: ui
version: "1.0.0"
purity: impure
signature: "PageHeader(props: PageHeaderProps): JSX.Element"
description: "Cabecera de página con título, subtítulo, acciones, back button, tabs integrados, badge y modo sticky. Incluye SimplePageHeader."
tags: [header, page, layout, navigation, component, ui]
uses_functions: [cn_typescript_core]
uses_types: []
returns: []
returns_optional: false
error_type: ""
imports: [react]
tested: false
tests: []
test_file_path: ""
file_path: "frontend/functions/ui/page_header.tsx"
props:
- name: title
type: "string"
required: true
description: "Título principal"
- name: subtitle
type: "string"
required: false
description: "Subtítulo"
- name: actions
type: "ReactNode"
required: false
description: "Botones de acción"
- name: tabs
type: "TabItem[]"
required: false
description: "Tabs de navegación integrados"
- name: sticky
type: "boolean"
required: false
description: "Header fijo al scroll"
emits: [onBack, onTabChange]
has_state: false
framework: react
variant: [full, simple]
source_repo: "https://gitea-dgg044oo04woo4ggcsws4gk0.organic-machine.com/Bl4cksmith/Frontend_Library"
source_license: "MIT"
source_file: "frontend/src/components/ui/page-header.tsx"
---
## Ejemplo
```tsx
<PageHeader
title="Dashboard"
subtitle="Vista general"
actions={<Button>Export</Button>}
tabs={[{ label: "Overview", value: "overview" }, { label: "Analytics", value: "analytics" }]}
activeTab="overview"
onTabChange={setTab}
/>
```
+94
View File
@@ -0,0 +1,94 @@
"use client"
import * as React from "react"
import { cn } from "../core/cn"
interface TabItem {
label: string
value: string
icon?: React.ReactNode
disabled?: boolean
}
interface PageHeaderProps extends React.HTMLAttributes<HTMLDivElement> {
title: string
subtitle?: string
actions?: React.ReactNode
onBack?: () => void
tabs?: TabItem[]
activeTab?: string
onTabChange?: (value: string) => void
badge?: React.ReactNode
sticky?: boolean
}
function PageHeader({
title, subtitle, actions, onBack, tabs, activeTab, onTabChange,
badge, sticky = false, className, ...props
}: PageHeaderProps) {
return (
<header
data-slot="page-header"
className={cn("space-y-4 border-b bg-background pb-4", sticky && "sticky top-0 z-20", className)}
{...props}
>
<div className="flex items-start justify-between gap-4">
<div className="flex items-start gap-3">
{onBack && (
<button onClick={onBack} className="mt-1 inline-flex size-7 shrink-0 items-center justify-center rounded-md hover:bg-muted">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><path d="m15 18-6-6 6-6"/></svg>
</button>
)}
<div className="space-y-1">
<div className="flex items-center gap-3">
<h1 className="text-2xl font-semibold tracking-tight">{title}</h1>
{badge}
</div>
{subtitle && <p className="text-sm text-muted-foreground">{subtitle}</p>}
</div>
</div>
{actions && <div className="flex shrink-0 items-center gap-2">{actions}</div>}
</div>
{tabs && tabs.length > 0 && (
<nav className="flex gap-4 border-b -mb-4 pb-0">
{tabs.map((tab) => (
<button
key={tab.value}
type="button"
disabled={tab.disabled}
onClick={() => onTabChange?.(tab.value)}
className={cn(
"inline-flex items-center gap-2 border-b-2 px-1 pb-3 text-sm font-medium transition-colors",
activeTab === tab.value ? "border-primary text-foreground" : "border-transparent text-muted-foreground hover:text-foreground",
tab.disabled && "pointer-events-none opacity-50"
)}
>
{tab.icon && <span className="size-4">{tab.icon}</span>}
{tab.label}
</button>
))}
</nav>
)}
</header>
)
}
interface SimplePageHeaderProps extends React.HTMLAttributes<HTMLDivElement> {
title: string
description?: string
children?: React.ReactNode
}
function SimplePageHeader({ title, description, children, className, ...props }: SimplePageHeaderProps) {
return (
<div className={cn("flex items-center justify-between border-b pb-4", className)} {...props}>
<div className="space-y-1">
<h1 className="text-2xl font-semibold tracking-tight">{title}</h1>
{description && <p className="text-sm text-muted-foreground">{description}</p>}
</div>
{children && <div className="flex items-center gap-2">{children}</div>}
</div>
)
}
export { PageHeader, SimplePageHeader }
+81
View File
@@ -0,0 +1,81 @@
---
name: progress_bar
kind: component
lang: typescript
domain: ui
version: "1.0.0"
purity: impure
signature: "ProgressBar(props: ProgressBarProps): JSX.Element"
description: "Barra de progreso con variantes de color y tamaño, buffer, animación, modo indeterminado y display de valor."
tags: [progress, loading, component, ui, feedback]
uses_functions: [cn_typescript_core]
uses_types: []
returns: []
returns_optional: false
error_type: ""
imports: [class-variance-authority]
tested: false
tests: []
test_file_path: ""
file_path: "frontend/functions/ui/progress_bar.tsx"
props:
- name: value
type: "number"
required: true
description: "Valor actual de progreso"
- name: max
type: "number"
required: false
description: "Valor máximo (default 100)"
- name: buffer
type: "number"
required: false
description: "Valor de buffer secundario (opcional)"
- name: showValue
type: "boolean"
required: false
description: "Mostrar porcentaje como texto superpuesto"
- name: animated
type: "boolean"
required: false
description: "Activar animación de rayas (stripes)"
- name: indeterminate
type: "boolean"
required: false
description: "Modo indeterminado sin valor conocido"
- name: size
type: "'sm' | 'md' | 'lg'"
required: false
description: "Altura de la barra (default md)"
- name: color
type: "'primary' | 'success' | 'warning' | 'destructive'"
required: false
description: "Color semántico (default primary)"
- name: label
type: "string"
required: false
description: "aria-label para accesibilidad"
emits: []
has_state: false
framework: react
variant: [primary, success, warning, destructive]
source_repo: "https://gitea-dgg044oo04woo4ggcsws4gk0.organic-machine.com/Bl4cksmith/Frontend_Library"
source_license: "MIT"
source_file: "frontend/src/components/ui/progress/progress-bar.tsx"
---
## Ejemplo
```tsx
<ProgressBar value={75} color="success" showValue />
<ProgressBar value={0} indeterminate />
<ProgressBar value={50} buffer={80} animated />
<ProgressBar value={30} size="lg" color="warning" />
```
## Notas
El porcentaje se clampea a [0, 100] internamente. El buffer se renderiza como capa semitransparente detrás del fill.
Las animaciones de stripes e indeterminate requieren keyframes definidos en el CSS global:
- `progress-stripes`: background-position de 0 a 1rem
- `progress-indeterminate`: translateX de -100% a 300%
+68
View File
@@ -0,0 +1,68 @@
import { cva, type VariantProps } from 'class-variance-authority'
import { cn } from '../core/cn'
const progressBarVariants = cva(
'relative w-full overflow-hidden rounded-full bg-muted',
{
variants: {
size: { sm: 'h-1', md: 'h-2', lg: 'h-3' },
color: {
primary: '[&_.progress-fill]:bg-primary',
success: '[&_.progress-fill]:bg-emerald-500',
warning: '[&_.progress-fill]:bg-amber-500',
destructive: '[&_.progress-fill]:bg-destructive',
},
},
defaultVariants: { size: 'md', color: 'primary' },
}
)
export interface ProgressBarProps extends VariantProps<typeof progressBarVariants> {
value: number
max?: number
buffer?: number
showValue?: boolean
animated?: boolean
indeterminate?: boolean
label?: string
className?: string
}
export function ProgressBar({
value, max = 100, buffer, showValue = false, animated = false,
indeterminate = false, size = 'md', color = 'primary', label, className,
}: ProgressBarProps) {
const percentage = Math.min(100, Math.max(0, (value / max) * 100))
const bufferPercentage = buffer ? Math.min(100, Math.max(0, (buffer / max) * 100)) : undefined
return (
<div
data-slot="progress-bar"
role="progressbar"
aria-valuenow={indeterminate ? undefined : value}
aria-valuemin={0}
aria-valuemax={max}
aria-label={label}
className={cn(progressBarVariants({ size, color }), className)}
>
{bufferPercentage !== undefined && (
<div className="progress-buffer absolute inset-0 bg-current opacity-20 transition-all duration-300" style={{ width: `${bufferPercentage}%` }} />
)}
<div
className={cn(
'progress-fill h-full transition-all duration-300',
animated && 'bg-[length:1rem_1rem] bg-[linear-gradient(45deg,rgba(255,255,255,.15)_25%,transparent_25%,transparent_50%,rgba(255,255,255,.15)_50%,rgba(255,255,255,.15)_75%,transparent_75%,transparent)] animate-[progress-stripes_1s_linear_infinite]',
indeterminate && 'w-1/3 animate-[progress-indeterminate_1.5s_ease-in-out_infinite]'
)}
style={indeterminate ? undefined : { width: `${percentage}%` }}
/>
{showValue && !indeterminate && (
<span className="absolute inset-0 flex items-center justify-center text-[10px] font-medium text-foreground mix-blend-difference">
{Math.round(percentage)}%
</span>
)}
</div>
)
}
export { progressBarVariants }
+68
View File
@@ -0,0 +1,68 @@
---
name: select
kind: component
lang: typescript
domain: ui
version: "1.0.0"
purity: impure
signature: "Select<T>(props: SelectRootProps<T>): JSX.Element"
description: "Select genérico accesible con grupos, separadores y animaciones. Base-UI primitive con posicionamiento automático."
tags: [select, form, dropdown, component, ui, interactive]
uses_functions: [cn_typescript_core]
uses_types: []
returns: []
returns_optional: false
error_type: ""
imports: ["@base-ui/react", lucide-react, react]
tested: false
tests: []
test_file_path: ""
file_path: "frontend/functions/ui/select.tsx"
props:
- name: value
type: "T"
required: false
description: "Valor seleccionado (controlled)"
- name: onValueChange
type: "(value: T) => void"
required: false
description: "Callback al cambiar selección"
- name: defaultValue
type: "T"
required: false
description: "Valor inicial (uncontrolled)"
- name: disabled
type: "boolean"
required: false
description: "Deshabilitar el select"
emits: [onValueChange]
has_state: true
framework: react
variant: [default]
source_repo: "https://gitea-dgg044oo04woo4ggcsws4gk0.organic-machine.com/Bl4cksmith/Frontend_Library"
source_license: "MIT"
source_file: "frontend/src/components/ui/select.tsx"
---
## Ejemplo
```tsx
<Select>
<SelectTrigger><SelectValue placeholder="Elegir..." /></SelectTrigger>
<SelectContent>
<SelectGroup>
<SelectGroupLabel>Frutas</SelectGroupLabel>
<SelectItem value="apple">Manzana</SelectItem>
<SelectItem value="banana">Banana</SelectItem>
</SelectGroup>
<SelectSeparator />
<SelectItem value="other">Otro</SelectItem>
</SelectContent>
</Select>
```
## Notas
Exporta 9 subcomponentes composables: Select, SelectTrigger, SelectValue, SelectPortal, SelectContent, SelectGroup, SelectGroupLabel, SelectItem, SelectSeparator.
Genérico sobre el tipo de valor T — TypeScript infiere el tipo desde el prop value/defaultValue.
Depende de @base-ui/react y lucide-react.
+88
View File
@@ -0,0 +1,88 @@
"use client"
import * as React from "react"
import { Select as SelectPrimitive } from "@base-ui/react/select"
import { ChevronDown, Check } from "lucide-react"
import { cn } from "../core/cn"
function Select<T>({ ...props }: SelectPrimitive.Root.Props<T>) {
return <SelectPrimitive.Root data-slot="select" {...props} />
}
function SelectValue({ ...props }: SelectPrimitive.Value.Props) {
return <SelectPrimitive.Value data-slot="select-value" {...props} />
}
function SelectTrigger({ className, children, ...props }: SelectPrimitive.Trigger.Props) {
return (
<SelectPrimitive.Trigger
data-slot="select-trigger"
className={cn(
"flex h-8 w-full items-center justify-between gap-2 rounded-lg border border-input bg-transparent px-2.5 py-1 text-sm transition-colors outline-none placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:bg-input/30 [&>span]:line-clamp-1",
className
)}
{...props}
>
{children}
<SelectPrimitive.Icon>
<ChevronDown className="size-4 shrink-0 text-muted-foreground" />
</SelectPrimitive.Icon>
</SelectPrimitive.Trigger>
)
}
function SelectPortal({ ...props }: SelectPrimitive.Portal.Props) {
return <SelectPrimitive.Portal data-slot="select-portal" {...props} />
}
function SelectContent({ className, children, ...props }: SelectPrimitive.Positioner.Props) {
return (
<SelectPortal>
<SelectPrimitive.Positioner
data-slot="select-content"
className={cn(
"relative z-50 max-h-[300px] min-w-[8rem] overflow-hidden rounded-lg border bg-popover text-popover-foreground shadow-md",
"data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95",
"data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95",
className
)}
sideOffset={4}
{...props}
>
<SelectPrimitive.Popup className="w-full p-1">{children}</SelectPrimitive.Popup>
</SelectPrimitive.Positioner>
</SelectPortal>
)
}
function SelectGroup({ ...props }: SelectPrimitive.Group.Props) {
return <SelectPrimitive.Group data-slot="select-group" {...props} />
}
function SelectGroupLabel({ className, ...props }: SelectPrimitive.GroupLabel.Props) {
return <SelectPrimitive.GroupLabel data-slot="select-group-label" className={cn("px-2 py-1.5 text-xs font-medium text-muted-foreground", className)} {...props} />
}
function SelectItem({ className, children, ...props }: SelectPrimitive.Item.Props) {
return (
<SelectPrimitive.Item
data-slot="select-item"
className={cn(
"relative flex w-full cursor-default select-none items-center rounded-md py-1.5 pl-8 pr-2 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-disabled:pointer-events-none data-disabled:opacity-50",
className
)}
{...props}
>
<span className="absolute left-2 flex size-4 items-center justify-center">
<SelectPrimitive.ItemIndicator><Check className="size-4" /></SelectPrimitive.ItemIndicator>
</span>
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
</SelectPrimitive.Item>
)
}
function SelectSeparator({ className, ...props }: React.ComponentProps<"div">) {
return <div data-slot="select-separator" className={cn("-mx-1 my-1 h-px bg-muted", className)} {...props} />
}
export { Select, SelectContent, SelectGroup, SelectGroupLabel, SelectItem, SelectPortal, SelectSeparator, SelectTrigger, SelectValue }
+57
View File
@@ -0,0 +1,57 @@
---
name: settings_page
kind: function
lang: typescript
domain: ui
version: "1.0.0"
purity: pure
signature: "settingsPage(props: SettingsPageProps): ReactElement"
description: "Genera una página de configuración con navegación lateral, secciones y campos de formulario (text, number, toggle, select, textarea)."
tags: [settings, page, form, sections, factory, composition, ui]
uses_functions: [cn_typescript_core]
uses_types: []
returns: []
returns_optional: false
error_type: ""
imports: [react]
tested: false
tests: []
test_file_path: ""
file_path: "frontend/functions/ui/settings_page.tsx"
source_repo: "https://gitea-dgg044oo04woo4ggcsws4gk0.organic-machine.com/Bl4cksmith/Frontend_Library"
source_license: "MIT"
source_file: ""
---
## Ejemplo
```tsx
settingsPage({
title: 'Project Settings',
sections: [
{
id: 'general',
title: 'General',
description: 'Basic project configuration',
fields: [
{ key: 'name', label: 'Project Name', type: 'text', value: 'My Project' },
{ key: 'visibility', label: 'Public', description: 'Make project visible to everyone', type: 'toggle', value: true },
{ key: 'language', label: 'Language', type: 'select', options: [{ label: 'English', value: 'en' }, { label: 'Spanish', value: 'es' }] },
],
},
{
id: 'notifications',
title: 'Notifications',
fields: [
{ key: 'email', label: 'Email notifications', type: 'toggle', value: false },
{ key: 'webhook', label: 'Webhook URL', type: 'text', placeholder: 'https://...' },
],
},
],
onSave: handleSave,
})
```
## Notas
Layout de settings estándar con sidebar de navegación (oculta en mobile). Secciones con anclas para scroll. Soporta 5 tipos de campo.
+112
View File
@@ -0,0 +1,112 @@
import * as React from 'react'
import { cn } from '../core/cn'
interface SettingField {
key: string
label: string
description?: string
type: 'text' | 'number' | 'toggle' | 'select' | 'textarea'
value?: unknown
options?: Array<{ label: string; value: string }>
placeholder?: string
}
interface SettingSection {
id: string
title: string
description?: string
fields: SettingField[]
}
interface SettingsPageProps {
title?: string
subtitle?: string
sections: SettingSection[]
onSave?: (values: Record<string, unknown>) => void
className?: string
}
export function settingsPage({
title = 'Settings',
subtitle,
sections,
onSave,
className,
}: SettingsPageProps): React.ReactElement {
return (
<div className={cn('space-y-6', className)}>
{/* Header */}
<div className="border-b pb-4">
<h1 className="text-2xl font-semibold tracking-tight">{title}</h1>
{subtitle && <p className="text-sm text-muted-foreground">{subtitle}</p>}
</div>
{/* Tabs navigation */}
<div className="flex gap-6">
<nav className="hidden w-48 shrink-0 md:block">
<div className="space-y-1">
{sections.map((section) => (
<a
key={section.id}
href={`#${section.id}`}
className="block rounded-md px-3 py-2 text-sm font-medium text-muted-foreground hover:bg-muted hover:text-foreground"
>
{section.title}
</a>
))}
</div>
</nav>
{/* Sections */}
<div className="flex-1 space-y-8">
{sections.map((section) => (
<div key={section.id} id={section.id} className="space-y-4">
<div>
<h2 className="text-lg font-medium">{section.title}</h2>
{section.description && <p className="text-sm text-muted-foreground">{section.description}</p>}
</div>
<div className="space-y-4 rounded-lg border p-4">
{section.fields.map((field) => (
<div key={field.key} className="flex flex-col gap-1.5 sm:flex-row sm:items-center sm:justify-between">
<div className="space-y-0.5">
<label className="text-sm font-medium">{field.label}</label>
{field.description && <p className="text-xs text-muted-foreground">{field.description}</p>}
</div>
<div className="w-full sm:w-64">
{field.type === 'toggle' ? (
<button className={cn(
'relative inline-flex h-5 w-9 shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors',
field.value ? 'bg-primary' : 'bg-input'
)}>
<span className={cn('pointer-events-none block size-4 rounded-full bg-background shadow-lg ring-0 transition-transform', field.value ? 'translate-x-4' : 'translate-x-0')} />
</button>
) : field.type === 'select' ? (
<select className="h-8 w-full rounded-lg border border-input bg-transparent px-2.5 text-sm">
{field.options?.map((opt) => <option key={opt.value} value={opt.value}>{opt.label}</option>)}
</select>
) : field.type === 'textarea' ? (
<textarea className="w-full rounded-lg border border-input bg-transparent px-2.5 py-1.5 text-sm" rows={3} placeholder={field.placeholder} defaultValue={String(field.value ?? '')} />
) : (
<input type={field.type} className="h-8 w-full rounded-lg border border-input bg-transparent px-2.5 text-sm" placeholder={field.placeholder} defaultValue={String(field.value ?? '')} />
)}
</div>
</div>
))}
</div>
</div>
))}
{onSave && (
<div className="flex justify-end border-t pt-4">
<button className="inline-flex h-8 items-center rounded-lg bg-primary px-4 text-sm font-medium text-primary-foreground hover:bg-primary/80">
Save changes
</button>
</div>
)}
</div>
</div>
</div>
)
}
export type { SettingsPageProps, SettingSection, SettingField }
+65
View File
@@ -0,0 +1,65 @@
---
name: skeleton
kind: component
lang: typescript
domain: ui
version: "1.0.0"
purity: impure
signature: "Skeleton(props: HTMLAttributes<HTMLDivElement>): JSX.Element"
description: "Sistema de loading skeletons: base, text, card, avatar, button, table. Variantes preconfiguradas para estados de carga."
tags: [skeleton, loading, placeholder, component, ui]
uses_functions: [cn_typescript_core]
uses_types: []
returns: []
returns_optional: false
error_type: ""
imports: [react]
tested: false
tests: []
test_file_path: ""
file_path: "frontend/functions/ui/skeleton.tsx"
props:
- name: className
type: "string"
required: false
description: "Clases CSS adicionales"
- name: lines
type: "number"
required: false
description: "Número de líneas (SkeletonText, default 3)"
- name: rows
type: "number"
required: false
description: "Filas de tabla (SkeletonTable, default 5)"
- name: columns
type: "number"
required: false
description: "Columnas de tabla (SkeletonTable, default 4)"
- name: size
type: "'xs' | 'sm' | 'md' | 'lg' | 'xl'"
required: false
description: "Tamaño del avatar (SkeletonAvatar, default md)"
emits: []
has_state: false
framework: react
variant: [base, text, card, avatar, button, table]
source_repo: "https://gitea-dgg044oo04woo4ggcsws4gk0.organic-machine.com/Bl4cksmith/Frontend_Library"
source_license: "MIT"
source_file: "frontend/src/components/ui/skeleton.tsx"
---
## Ejemplo
```tsx
<SkeletonCard />
<SkeletonText lines={4} />
<SkeletonTable rows={10} columns={5} />
<SkeletonAvatar size="lg" />
<SkeletonButton />
```
## Notas
Exporta 6 variantes preconfiguradas. Todas componen sobre el Skeleton base con animate-pulse.
La última línea de SkeletonText se acorta a w-4/5 para simular texto real.
SkeletonCard incluye imagen (h-[180px]) + dos líneas de texto.
+54
View File
@@ -0,0 +1,54 @@
import * as React from "react"
import { cn } from "../core/cn"
function Skeleton({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) {
return <div data-slot="skeleton" className={cn("animate-pulse rounded-md bg-muted", className)} {...props} />
}
function SkeletonText({ className, lines = 3, ...props }: React.HTMLAttributes<HTMLDivElement> & { lines?: number }) {
return (
<div className={cn("space-y-2", className)} {...props}>
{Array.from({ length: lines }).map((_, i) => (
<Skeleton key={i} className={cn("h-4", i === lines - 1 ? "w-4/5" : "w-full")} />
))}
</div>
)
}
function SkeletonCard({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) {
return (
<div className={cn("space-y-3", className)} {...props}>
<Skeleton className="h-[180px] w-full rounded-lg" />
<Skeleton className="h-4 w-3/4" />
<Skeleton className="h-4 w-1/2" />
</div>
)
}
function SkeletonAvatar({ className, size = "md", ...props }: React.HTMLAttributes<HTMLDivElement> & { size?: "xs" | "sm" | "md" | "lg" | "xl" }) {
const sizeClasses = { xs: "size-6", sm: "size-8", md: "size-10", lg: "size-12", xl: "size-16" }
return <Skeleton className={cn("rounded-full", sizeClasses[size], className)} {...props} />
}
function SkeletonButton({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) {
return <Skeleton className={cn("h-8 w-24 rounded-lg", className)} {...props} />
}
function SkeletonTable({ className, rows = 5, columns = 4, ...props }: React.HTMLAttributes<HTMLDivElement> & { rows?: number; columns?: number }) {
return (
<div className={cn("space-y-3", className)} {...props}>
<div className="flex gap-4">
{Array.from({ length: columns }).map((_, i) => <Skeleton key={i} className="h-4 flex-1" />)}
</div>
<div className="space-y-2">
{Array.from({ length: rows }).map((_, rowIndex) => (
<div key={rowIndex} className="flex gap-4">
{Array.from({ length: columns }).map((_, colIndex) => <Skeleton key={colIndex} className="h-4 flex-1" />)}
</div>
))}
</div>
</div>
)
}
export { Skeleton, SkeletonAvatar, SkeletonButton, SkeletonCard, SkeletonTable, SkeletonText }
+60
View File
@@ -0,0 +1,60 @@
---
name: sparkline
kind: component
lang: typescript
domain: ui
version: "1.0.0"
purity: impure
signature: "Sparkline(props: SparklineProps): JSX.Element"
description: "Mini gráfico inline SVG puro (sin Recharts) con variantes line, area y bar. Para KPI cards y tablas."
tags: [sparkline, chart, inline, svg, component, ui, visualization]
uses_functions: [cn_typescript_core]
uses_types: []
returns: []
returns_optional: false
error_type: ""
imports: [react]
tested: false
tests: []
test_file_path: ""
file_path: "frontend/functions/ui/sparkline.tsx"
props:
- name: data
type: "number[]"
required: true
description: "Array de valores numéricos"
- name: variant
type: "'line' | 'area' | 'bar'"
required: false
description: "Tipo de visualización"
- name: color
type: "string"
required: false
description: "Color del gráfico"
- name: width
type: "number"
required: false
description: "Ancho en px (default 80)"
- name: height
type: "number"
required: false
description: "Alto en px (default 24)"
emits: []
has_state: false
framework: react
variant: [line, area, bar]
source_repo: "https://gitea-dgg044oo04woo4ggcsws4gk0.organic-machine.com/Bl4cksmith/Frontend_Library"
source_license: "MIT"
source_file: "frontend/src/components/ui/sparkline.tsx"
---
## Ejemplo
```tsx
<Sparkline data={[10, 25, 15, 40, 30, 55]} variant="area" color="#22c55e" />
<Sparkline data={[5, 3, 8, 1, 9]} variant="bar" width={60} height={20} />
```
## Notas
SVG puro — sin dependencia de Recharts. Ideal para inline en tablas y cards.
+72
View File
@@ -0,0 +1,72 @@
import * as React from 'react'
import { cn } from '../core/cn'
type SparklineVariant = 'line' | 'area' | 'bar'
interface SparklineProps extends React.SVGAttributes<SVGSVGElement> {
data: number[]
variant?: SparklineVariant
color?: string
width?: number
height?: number
strokeWidth?: number
showLastPoint?: boolean
}
function getPath(data: number[], width: number, height: number, padding: number = 2) {
if (data.length === 0) return { linePath: '', areaPath: '' }
const min = Math.min(...data)
const max = Math.max(...data)
const range = max - min || 1
const ew = width - padding * 2
const eh = height - padding * 2
const points = data.map((value, index) => ({
x: padding + (index / (data.length - 1)) * ew,
y: padding + eh - ((value - min) / range) * eh,
}))
const linePath = points.map((p, i) => `${i === 0 ? 'M' : 'L'} ${p.x} ${p.y}`).join(' ')
const areaPath = `${linePath} L ${points[points.length - 1].x} ${height - padding} L ${padding} ${height - padding} Z`
return { linePath, areaPath }
}
const Sparkline = React.forwardRef<SVGSVGElement, SparklineProps>(
({ data, variant = 'line', color = 'currentColor', width = 80, height = 24, strokeWidth = 1.5, showLastPoint = true, className, ...props }, ref) => {
if (data.length === 0) return <svg ref={ref} width={width} height={height} viewBox={`0 0 ${width} ${height}`} className={cn('text-primary', className)} {...props} />
if (variant === 'bar') {
const min = Math.min(...data, 0)
const max = Math.max(...data)
const range = max - min || 1
const p = 2
const eh = height - p * 2
const bw = (width - p * 2) / data.length - 1
return (
<svg ref={ref} width={width} height={height} viewBox={`0 0 ${width} ${height}`} className={cn('text-primary', className)} {...props}>
{data.map((value, index) => {
const bh = ((value - min) / range) * eh
const x = p + index * ((width - p * 2) / data.length) + 0.5
const y = p + eh - bh
return <rect key={index} x={x} y={y} width={Math.max(bw, 1)} height={Math.max(bh, 1)} fill={color} opacity={0.8} />
})}
</svg>
)
}
const { linePath, areaPath } = getPath(data, width, height)
const lastPoint = {
x: width - 2,
y: 2 + (height - 4) - ((data[data.length - 1] - Math.min(...data)) / (Math.max(...data) - Math.min(...data) || 1)) * (height - 4)
}
return (
<svg ref={ref} width={width} height={height} viewBox={`0 0 ${width} ${height}`} className={cn('text-primary', className)} {...props}>
{variant === 'area' && <path d={areaPath} fill={color} opacity={0.2} />}
<path d={linePath} fill="none" stroke={color} strokeWidth={strokeWidth} strokeLinecap="round" strokeLinejoin="round" />
{showLastPoint && <circle cx={lastPoint.x} cy={lastPoint.y} r={2.5} fill={color} />}
</svg>
)
}
)
Sparkline.displayName = 'Sparkline'
export { Sparkline, type SparklineProps, type SparklineVariant }
+50
View File
@@ -0,0 +1,50 @@
---
name: tabs
kind: component
lang: typescript
domain: ui
version: "1.0.0"
purity: impure
signature: "Tabs(props: TabsRootProps): JSX.Element"
description: "Sistema de tabs con orientación horizontal/vertical, variantes default y line, y soporte para iconos. Base-UI primitive."
tags: [tabs, navigation, component, ui, interactive]
uses_functions: [cn_typescript_core]
uses_types: []
returns: []
returns_optional: false
error_type: ""
imports: ["@base-ui/react", class-variance-authority]
tested: false
tests: []
test_file_path: ""
file_path: "frontend/functions/ui/tabs.tsx"
props:
- name: orientation
type: "'horizontal' | 'vertical'"
required: false
description: "Orientación de los tabs"
- name: variant
type: "'default' | 'line'"
required: false
description: "Estilo visual de la lista"
emits: [onValueChange]
has_state: true
framework: react
variant: [default, line]
source_repo: "https://gitea-dgg044oo04woo4ggcsws4gk0.organic-machine.com/Bl4cksmith/Frontend_Library"
source_license: "MIT"
source_file: "frontend/src/components/ui/tabs.tsx"
---
## Ejemplo
```tsx
<Tabs defaultValue="general">
<TabsList>
<TabsTrigger value="general">General</TabsTrigger>
<TabsTrigger value="security">Security</TabsTrigger>
</TabsList>
<TabsContent value="general">...</TabsContent>
<TabsContent value="security">...</TabsContent>
</Tabs>
```
+43
View File
@@ -0,0 +1,43 @@
import { Tabs as TabsPrimitive } from "@base-ui/react/tabs"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "../core/cn"
function Tabs({ className, orientation = "horizontal", ...props }: TabsPrimitive.Root.Props) {
return (
<TabsPrimitive.Root data-slot="tabs" data-orientation={orientation} className={cn("group/tabs flex gap-2 data-horizontal:flex-col", className)} {...props} />
)
}
const tabsListVariants = cva(
"group/tabs-list inline-flex w-fit items-center justify-center rounded-lg p-[3px] text-muted-foreground group-data-horizontal/tabs:h-8 group-data-vertical/tabs:h-fit group-data-vertical/tabs:flex-col data-[variant=line]:rounded-none",
{
variants: {
variant: { default: "bg-muted", line: "gap-1 bg-transparent" },
},
defaultVariants: { variant: "default" },
}
)
function TabsList({ className, variant = "default", ...props }: TabsPrimitive.List.Props & VariantProps<typeof tabsListVariants>) {
return <TabsPrimitive.List data-slot="tabs-list" data-variant={variant} className={cn(tabsListVariants({ variant }), className)} {...props} />
}
function TabsTrigger({ className, ...props }: TabsPrimitive.Tab.Props) {
return (
<TabsPrimitive.Tab
data-slot="tabs-trigger"
className={cn(
"relative inline-flex h-[calc(100%-1px)] flex-1 items-center justify-center gap-1.5 rounded-md border border-transparent px-1.5 py-0.5 text-sm font-medium whitespace-nowrap text-foreground/60 transition-all hover:text-foreground focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:pointer-events-none disabled:opacity-50",
"data-active:bg-background data-active:text-foreground dark:data-active:border-input dark:data-active:bg-input/30",
className
)}
{...props}
/>
)
}
function TabsContent({ className, ...props }: TabsPrimitive.Panel.Props) {
return <TabsPrimitive.Panel data-slot="tabs-content" className={cn("flex-1 text-sm outline-none", className)} {...props} />
}
export { Tabs, TabsList, TabsTrigger, TabsContent, tabsListVariants }
+65
View File
@@ -0,0 +1,65 @@
---
name: theme_provider
kind: component
lang: typescript
domain: ui
version: "1.0.0"
purity: impure
signature: "ThemeProvider(props: { children: ReactNode; themes: Record<string, Theme>; defaultTheme?: string }): JSX.Element"
description: "Provider de tema React con context, persistencia en localStorage, detección de preferencia del sistema y hook useTheme."
tags: [theme, provider, context, hook, component, ui]
uses_functions: [apply_theme_typescript_ui]
uses_types: [ThemeConfig_typescript_ui]
returns: []
returns_optional: false
error_type: ""
imports: [react]
tested: false
tests: []
test_file_path: ""
file_path: "frontend/functions/ui/theme_provider.tsx"
props:
- name: themes
type: "Record<string, Theme>"
required: true
description: "Mapa de temas disponibles"
- name: defaultTheme
type: "string"
required: false
description: "Nombre del tema por defecto"
- name: children
type: "ReactNode"
required: true
description: "Contenido de la app"
emits: []
has_state: true
framework: react
variant: [default]
source_repo: "https://gitea-dgg044oo04woo4ggcsws4gk0.organic-machine.com/Bl4cksmith/Frontend_Library"
source_license: "MIT"
source_file: "frontend/src/hooks/use-theme.tsx"
---
## Ejemplo
```tsx
import { ThemeProvider, useTheme } from './theme_provider'
<ThemeProvider themes={allThemes} defaultTheme="dark">
<App />
</ThemeProvider>
// Dentro de un componente:
function ThemeSwitcher() {
const { themeName, setTheme, themes } = useTheme()
return (
<select value={themeName} onChange={(e) => setTheme(e.target.value)}>
{Object.values(themes).map(t => <option key={t.name} value={t.name}>{t.label}</option>)}
</select>
)
}
```
## Notas
Detecta prefers-color-scheme automáticamente. Persiste elección en localStorage. Exporta ThemeProvider (componente) y useTheme (hook).
+101
View File
@@ -0,0 +1,101 @@
import { createContext, useCallback, useContext, useEffect, useState, type ReactNode } from 'react'
interface ThemeColors {
[key: string]: string
}
interface Theme {
name: string
label: string
colors: ThemeColors
}
interface ThemeContextValue {
theme: Theme
themeName: string
setTheme: (name: string) => void
themes: Record<string, Theme>
}
const STORAGE_KEY = 'frontend-library-theme'
const ThemeContext = createContext<ThemeContextValue | null>(null)
function applyThemeColors(theme: Theme) {
const root = document.documentElement
const cssVarMap: Record<string, string> = {
background: '--background', foreground: '--foreground',
card: '--card', cardForeground: '--card-foreground',
popover: '--popover', popoverForeground: '--popover-foreground',
primary: '--primary', primaryForeground: '--primary-foreground',
secondary: '--secondary', secondaryForeground: '--secondary-foreground',
muted: '--muted', mutedForeground: '--muted-foreground',
accent: '--accent', accentForeground: '--accent-foreground',
destructive: '--destructive', destructiveForeground: '--destructive-foreground',
success: '--success', successForeground: '--success-foreground',
warning: '--warning', warningForeground: '--warning-foreground',
info: '--info', infoForeground: '--info-foreground',
surface: '--surface', surfaceHover: '--surface-hover', overlay: '--overlay',
border: '--border', input: '--input', ring: '--ring',
chart1: '--chart-1', chart2: '--chart-2', chart3: '--chart-3', chart4: '--chart-4', chart5: '--chart-5',
sidebar: '--sidebar', sidebarForeground: '--sidebar-foreground',
sidebarPrimary: '--sidebar-primary', sidebarPrimaryForeground: '--sidebar-primary-foreground',
sidebarAccent: '--sidebar-accent', sidebarAccentForeground: '--sidebar-accent-foreground',
sidebarBorder: '--sidebar-border', sidebarRing: '--sidebar-ring',
}
Object.entries(cssVarMap).forEach(([key, cssVar]) => {
if (theme.colors[key]) root.style.setProperty(cssVar, theme.colors[key])
})
if (theme.name === 'dark' || theme.name === 'midnight' || theme.name === 'sunset') {
root.classList.add('dark')
} else {
root.classList.remove('dark')
}
}
interface ThemeProviderProps {
children: ReactNode
themes: Record<string, Theme>
defaultTheme?: string
}
export function ThemeProvider({ children, themes, defaultTheme: initialTheme }: ThemeProviderProps) {
const [themeName, setThemeName] = useState<string>(() => {
if (initialTheme) return initialTheme
if (typeof window === 'undefined') return 'default'
const stored = localStorage.getItem(STORAGE_KEY)
if (stored && themes[stored]) return stored
if (window.matchMedia('(prefers-color-scheme: dark)').matches) return 'dark'
return 'default'
})
const theme = themes[themeName] ?? themes['default'] ?? Object.values(themes)[0]
const setTheme = useCallback((name: string) => {
if (themes[name]) {
setThemeName(name)
localStorage.setItem(STORAGE_KEY, name)
}
}, [themes])
useEffect(() => { applyThemeColors(theme) }, [theme])
useEffect(() => {
const mq = window.matchMedia('(prefers-color-scheme: dark)')
const handle = (e: MediaQueryListEvent) => {
if (!localStorage.getItem(STORAGE_KEY)) setThemeName(e.matches ? 'dark' : 'default')
}
mq.addEventListener('change', handle)
return () => mq.removeEventListener('change', handle)
}, [])
return <ThemeContext.Provider value={{ theme, themeName, setTheme, themes }}>{children}</ThemeContext.Provider>
}
export function useTheme() {
const ctx = useContext(ThemeContext)
if (!ctx) throw new Error('useTheme must be used within a ThemeProvider')
return ctx
}
export { ThemeContext }
export type { ThemeContextValue, ThemeProviderProps, Theme, ThemeColors }
+51
View File
@@ -0,0 +1,51 @@
---
name: tooltip
kind: component
lang: typescript
domain: ui
version: "1.0.0"
purity: impure
signature: "Tooltip(props: TooltipRootProps): JSX.Element"
description: "Tooltip accesible con animaciones, posicionamiento automático y arrow. Base-UI primitive con delay configurable."
tags: [tooltip, overlay, component, ui, help]
uses_functions: [cn_typescript_core]
uses_types: []
returns: []
returns_optional: false
error_type: ""
imports: ["@base-ui/react", react]
tested: false
tests: []
test_file_path: ""
file_path: "frontend/functions/ui/tooltip.tsx"
props:
- name: delayDuration
type: "number"
required: false
description: "Delay en ms antes de mostrar el tooltip (default 300)"
emits: []
has_state: true
framework: react
variant: [default]
source_repo: "https://gitea-dgg044oo04woo4ggcsws4gk0.organic-machine.com/Bl4cksmith/Frontend_Library"
source_license: "MIT"
source_file: "frontend/src/components/ui/tooltip.tsx"
---
## Ejemplo
```tsx
<TooltipProvider>
<Tooltip>
<TooltipTrigger>Hover me</TooltipTrigger>
<TooltipContent>Tooltip text</TooltipContent>
</Tooltip>
</TooltipProvider>
```
## Notas
Exporta 5 subcomponentes: TooltipProvider, Tooltip, TooltipTrigger, TooltipPortal, TooltipContent.
TooltipProvider gestiona el delay global — envolver la app o sección con un único Provider.
TooltipContent incluye Arrow con fill-primary automático.
Depende de @base-ui/react — asegurarse de que está en package.json del frontend.
+45
View File
@@ -0,0 +1,45 @@
"use client"
import * as React from "react"
import { Tooltip as TooltipPrimitive } from "@base-ui/react/tooltip"
import { cn } from "../core/cn"
function TooltipProvider({ delayDuration = 300, ...props }: { delayDuration?: number; children: React.ReactNode }) {
return <TooltipPrimitive.Provider delay={delayDuration} {...props} />
}
function Tooltip({ ...props }: TooltipPrimitive.Root.Props) {
return <TooltipPrimitive.Root data-slot="tooltip" {...props} />
}
function TooltipTrigger({ ...props }: TooltipPrimitive.Trigger.Props) {
return <TooltipPrimitive.Trigger data-slot="tooltip-trigger" {...props} />
}
function TooltipPortal({ ...props }: TooltipPrimitive.Portal.Props) {
return <TooltipPrimitive.Portal data-slot="tooltip-portal" {...props} />
}
function TooltipContent({ className, sideOffset = 4, ...props }: TooltipPrimitive.Positioner.Props) {
return (
<TooltipPortal>
<TooltipPrimitive.Positioner
data-slot="tooltip-content"
sideOffset={sideOffset}
className={cn(
"z-50 overflow-hidden rounded-md bg-primary px-3 py-1.5 text-xs text-primary-foreground",
"animate-in fade-in-0 zoom-in-95",
"data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95",
"data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
className
)}
{...props}
>
<TooltipPrimitive.Popup>{props.children}</TooltipPrimitive.Popup>
<TooltipPrimitive.Arrow className="fill-primary" />
</TooltipPrimitive.Positioner>
</TooltipPortal>
)
}
export { Tooltip, TooltipContent, TooltipPortal, TooltipProvider, TooltipTrigger }
@@ -0,0 +1,57 @@
---
name: use_animated_canvas
kind: component
lang: typescript
domain: ui
version: "1.0.0"
purity: impure
signature: "useAnimatedCanvas(options: AnimatedCanvasOptions): AnimatedCanvasResult"
description: "Hook React para canvas animado a N fps via requestAnimationFrame. Maneja DPR, resize, throttling, y contador de FPS real."
tags: [canvas, animation, fps, requestAnimationFrame, hook, realtime, component, ui]
uses_functions: []
uses_types: []
returns: []
returns_optional: false
error_type: ""
imports: [react]
tested: false
tests: []
test_file_path: ""
file_path: "frontend/functions/ui/use_animated_canvas.tsx"
props:
- name: fps
type: "number"
required: false
description: "Target FPS (default 60)"
- name: draw
type: "(ctx: CanvasRenderingContext2D, width: number, height: number, frameCount: number) => void"
required: true
description: "Callback de renderizado"
emits: []
has_state: true
framework: react
variant: [default]
---
## Ejemplo
```tsx
const { canvasRef, renderFpsRef } = useAnimatedCanvas({
fps: 100,
draw: (ctx, w, h) => {
// Dibujar lo que sea a 100fps
ctx.fillStyle = '#3b82f6'
ctx.fillRect(0, 0, w * Math.random(), h)
},
})
return <canvas ref={canvasRef} style={{ width: '100%', height: 300 }} />
```
## Notas
- DPR automático: escala el canvas al devicePixelRatio de la pantalla
- Resize automático: detecta cambios de tamaño via getBoundingClientRect
- Throttle configurable: rAF corre a ~144fps nativo, el hook filtra a N fps
- FPS real: `renderFpsRef.current` tiene los FPS medidos (no el target)
- drawRef pattern: actualiza el callback sin re-crear el loop de animación
@@ -0,0 +1,80 @@
import { useEffect, useRef } from 'react'
export interface AnimatedCanvasOptions {
/** Target FPS (default 60). El throttle real es 1000/fps ms. */
fps?: number
/** Callback de dibujo. Recibe el canvas 2d context y dimensiones. */
draw: (ctx: CanvasRenderingContext2D, width: number, height: number, frameCount: number) => void
}
export interface AnimatedCanvasResult {
canvasRef: React.RefObject<HTMLCanvasElement | null>
/** FPS real de renderizado (actualizado cada segundo) */
renderFpsRef: React.RefObject<number>
}
/**
* Hook para renderizar un canvas a N fps usando requestAnimationFrame.
* Maneja DPR, resize, y throttling automático.
*/
export function useAnimatedCanvas(options: AnimatedCanvasOptions): AnimatedCanvasResult {
const { fps = 60, draw } = options
const canvasRef = useRef<HTMLCanvasElement>(null)
const renderFpsRef = useRef(0)
const rafRef = useRef(0)
const lastDrawRef = useRef(0)
const frameCountRef = useRef(0)
const fpsTimerRef = useRef(0)
const drawRef = useRef(draw)
// Keep draw ref updated without re-subscribing the effect
drawRef.current = draw
useEffect(() => {
const interval = 1000 / fps
const loop = (now: number) => {
rafRef.current = requestAnimationFrame(loop)
if (now - lastDrawRef.current < interval) return
lastDrawRef.current = now
// FPS counting
frameCountRef.current++
if (now - fpsTimerRef.current >= 1000) {
renderFpsRef.current = frameCountRef.current
frameCountRef.current = 0
fpsTimerRef.current = now
}
const canvas = canvasRef.current
if (!canvas) return
const rect = canvas.getBoundingClientRect()
const w = rect.width
const h = rect.height
const dpr = window.devicePixelRatio || 1
const targetW = Math.floor(w * dpr)
const targetH = Math.floor(h * dpr)
if (canvas.width !== targetW || canvas.height !== targetH) {
canvas.width = targetW
canvas.height = targetH
}
const ctx = canvas.getContext('2d')
if (!ctx) return
ctx.setTransform(dpr, 0, 0, dpr, 0, 0)
ctx.clearRect(0, 0, w, h)
drawRef.current(ctx, w, h, frameCountRef.current)
}
rafRef.current = requestAnimationFrame(loop)
return () => cancelAnimationFrame(rafRef.current)
}, [fps])
return { canvasRef, renderFpsRef }
}
+62
View File
@@ -0,0 +1,62 @@
---
name: use_wails_event
kind: component
lang: typescript
domain: ui
version: "1.0.0"
purity: impure
signature: "useWailsEvent<T>(opts: UseWailsEventOptions<T>): UseWailsEventResult<T>"
description: "Hook para suscripción a eventos Go→TS y emisión TS→Go via Wails runtime. Soporta once, maxCallbacks, emit bidireccional."
tags: [wails, event, hook, ipc, realtime, component, ui]
uses_functions: []
uses_types: []
returns: []
returns_optional: false
error_type: ""
imports: [react]
tested: false
tests: []
test_file_path: ""
file_path: "frontend/functions/ui/use_wails_event.tsx"
props:
- name: eventName
type: "string"
required: true
description: "Nombre del evento Wails"
- name: onEvent
type: "(data: T) => void"
required: false
description: "Callback al recibir evento"
- name: once
type: "boolean"
required: false
description: "Solo escuchar una vez"
- name: enabled
type: "boolean"
required: false
description: "Habilitar suscripción"
emits: [onEvent]
has_state: true
framework: react
variant: [default]
source_repo: "https://gitea-dgg044oo04woo4ggcsws4gk0.organic-machine.com/Bl4cksmith/Frontend_Library"
source_license: "MIT"
source_file: "frontend/src/lib/wails/use-wails-event.tsx"
---
## Ejemplo
```tsx
// Escuchar eventos de Go
const { lastData, eventCount, emit } = useWailsEvent<PriceUpdate>({
eventName: 'price:update',
onEvent: (price) => updateChart(price),
})
// Emitir de TS a Go
emit({ symbol: 'BTC', action: 'subscribe' })
```
## Notas
Exporta también `useWailsEmit()` para emit sin suscripción. Usa `window.runtime.EventsOn/Off/Emit` del Wails runtime.
+115
View File
@@ -0,0 +1,115 @@
import { useCallback, useEffect, useRef, useState } from 'react'
// Types for Wails runtime (will be available when running in Wails)
declare global {
interface Window {
runtime?: {
EventsOn: (eventName: string, callback: (...args: unknown[]) => void) => () => void
EventsOff: (eventName: string, ...additionalEventNames: string[]) => void
EventsOnce: (eventName: string, callback: (...args: unknown[]) => void) => () => void
EventsOnMultiple: (eventName: string, callback: (...args: unknown[]) => void, maxCallbacks: number) => () => void
EventsEmit: (eventName: string, ...data: unknown[]) => void
}
}
}
export interface UseWailsEventOptions<T> {
/** Nombre del evento a escuchar */
eventName: string
/** Callback cuando llega el evento */
onEvent?: (data: T) => void
/** Si está habilitado */
enabled?: boolean
/** Solo escuchar una vez */
once?: boolean
/** Número máximo de veces a escuchar */
maxCallbacks?: number
}
export interface UseWailsEventResult<T> {
/** Último dato recibido */
lastData: T | undefined
/** Número de eventos recibidos */
eventCount: number
/** Emitir un evento */
emit: (data?: T) => void
/** Resetear estado */
reset: () => void
}
/**
* Hook para suscribirse a eventos de Wails
*/
export function useWailsEvent<T = unknown>({
eventName,
onEvent,
enabled = true,
once = false,
maxCallbacks,
}: UseWailsEventOptions<T>): UseWailsEventResult<T> {
const [lastData, setLastData] = useState<T | undefined>(undefined)
const [eventCount, setEventCount] = useState(0)
const callbackRef = useRef(onEvent)
// Keep callback ref updated
useEffect(() => {
callbackRef.current = onEvent
}, [onEvent])
useEffect(() => {
if (!enabled || !window.runtime) return
const handleEvent = (...args: unknown[]) => {
const data = args[0] as T
setLastData(data)
setEventCount((c) => c + 1)
callbackRef.current?.(data)
}
let unsubscribe: (() => void) | undefined
if (once) {
unsubscribe = window.runtime.EventsOnce(eventName, handleEvent)
} else if (maxCallbacks !== undefined) {
unsubscribe = window.runtime.EventsOnMultiple(eventName, handleEvent, maxCallbacks)
} else {
unsubscribe = window.runtime.EventsOn(eventName, handleEvent)
}
return () => {
unsubscribe?.()
}
}, [eventName, enabled, once, maxCallbacks])
const emit = useCallback(
(data?: T) => {
if (window.runtime) {
window.runtime.EventsEmit(eventName, data)
}
},
[eventName]
)
const reset = useCallback(() => {
setLastData(undefined)
setEventCount(0)
}, [])
return {
lastData,
eventCount,
emit,
reset,
}
}
/**
* Hook para emitir eventos a Wails (sin suscripción)
*/
export function useWailsEmit() {
return useCallback((eventName: string, ...data: unknown[]) => {
if (window.runtime) {
window.runtime.EventsEmit(eventName, ...data)
}
}, [])
}
@@ -0,0 +1,64 @@
---
name: use_wails_mutation
kind: component
lang: typescript
domain: ui
version: "1.0.0"
purity: impure
signature: "useWailsMutation<TData, TVariables>(opts: UseWailsMutationOptions<TData, TVariables>): UseWailsMutationResult<TData, TVariables>"
description: "Hook para escrituras IPC Wails con optimistic updates, invalidación automática de queries, retry y callbacks completos."
tags: [wails, mutation, hook, ipc, optimistic, component, ui]
uses_functions: [wails_cache_typescript_core, wails_provider_typescript_ui]
uses_types: [WailsIPC_typescript_ui]
returns: []
returns_optional: false
error_type: ""
imports: [react]
tested: false
tests: []
test_file_path: ""
file_path: "frontend/functions/ui/use_wails_mutation.tsx"
props:
- name: mutationFn
type: "(variables: TVariables) => Promise<TData>"
required: true
description: "Función que ejecuta la mutación via IPC"
- name: invalidateQueries
type: "string[][]"
required: false
description: "Query keys a invalidar en éxito"
- name: onMutate
type: "(variables: TVariables) => unknown"
required: false
description: "Optimistic update antes de la mutación"
emits: [onSuccess, onError, onSettled]
has_state: true
framework: react
variant: [default]
source_repo: "https://gitea-dgg044oo04woo4ggcsws4gk0.organic-machine.com/Bl4cksmith/Frontend_Library"
source_license: "MIT"
source_file: "frontend/src/lib/wails/use-wails-mutation.tsx"
---
## Ejemplo
```tsx
const { mutate, isLoading } = useWailsMutation({
mutationFn: (user: User) => CreateUser(user),
invalidateQueries: [['users']],
onMutate: (user) => {
// Optimistic: añadir al cache antes de confirmar
const prev = wailsCache.get<User[]>(['users', 'list'])
wailsCache.set(['users', 'list'], [...(prev || []), user])
return { prev }
},
onError: (_err, _vars, context) => {
// Rollback
wailsCache.set(['users', 'list'], context.prev)
},
})
```
## Notas
`mutate()` es fire-and-forget, `mutateAsync()` retorna Promise. Estado: idle → loading → success/error.
@@ -0,0 +1,150 @@
import { useCallback, useRef, useState } from 'react'
import { useWailsContext } from './wails_provider'
import type { MutationOptions } from '../../types/ui/wails_ipc'
export interface UseWailsMutationOptions<TData, TVariables> extends MutationOptions {
/** Función que ejecuta la mutación */
mutationFn: (variables: TVariables) => Promise<TData>
/** Callback antes de la mutación (optimistic update) */
onMutate?: (variables: TVariables) => Promise<unknown> | unknown
/** Callback en éxito */
onSuccess?: (data: TData, variables: TVariables, context: unknown) => void
/** Callback en error */
onError?: (error: Error, variables: TVariables, context: unknown) => void
/** Callback siempre (éxito o error) */
onSettled?: (data: TData | undefined, error: Error | null, variables: TVariables, context: unknown) => void
/** Query keys a invalidar en éxito */
invalidateQueries?: string[][]
}
export interface UseWailsMutationResult<TData, TVariables> {
/** Ejecutar la mutación */
mutate: (variables: TVariables) => void
/** Ejecutar la mutación (async) */
mutateAsync: (variables: TVariables) => Promise<TData>
/** Estado de carga */
isLoading: boolean
/** Datos del resultado */
data: TData | undefined
/** Error si ocurrió */
error: Error | null
/** Resetear estado */
reset: () => void
/** Si fue exitoso */
isSuccess: boolean
/** Si hubo error */
isError: boolean
/** Si está idle */
isIdle: boolean
}
export function useWailsMutation<TData, TVariables = void>({
mutationFn,
onMutate,
onSuccess,
onError,
onSettled,
invalidateQueries,
retry = false,
retryDelay = 1000,
}: UseWailsMutationOptions<TData, TVariables>): UseWailsMutationResult<TData, TVariables> {
const { cache } = useWailsContext()
const [state, setState] = useState<{
status: 'idle' | 'loading' | 'success' | 'error'
data: TData | undefined
error: Error | null
}>({
status: 'idle',
data: undefined,
error: null,
})
const retryCountRef = useRef(0)
const executeMutation = useCallback(
async (variables: TVariables): Promise<TData> => {
setState((s) => ({ ...s, status: 'loading', error: null }))
let context: unknown
try {
// Optimistic update
if (onMutate) {
context = await onMutate(variables)
}
const data = await mutationFn(variables)
// Reset retry count on success
retryCountRef.current = 0
setState({
status: 'success',
data,
error: null,
})
// Invalidate queries
if (invalidateQueries) {
invalidateQueries.forEach((queryKey) => {
cache.invalidate(queryKey)
})
}
onSuccess?.(data, variables, context)
onSettled?.(data, null, variables, context)
return data
} catch (error) {
const maxRetries = typeof retry === 'number' ? retry : retry ? 3 : 0
if (retryCountRef.current < maxRetries) {
retryCountRef.current += 1
await new Promise((resolve) => setTimeout(resolve, retryDelay))
return executeMutation(variables)
}
setState((s) => ({
...s,
status: 'error',
error: error as Error,
}))
onError?.(error as Error, variables, context)
onSettled?.(undefined, error as Error, variables, context)
throw error
}
},
[mutationFn, onMutate, onSuccess, onError, onSettled, invalidateQueries, cache, retry, retryDelay]
)
const mutate = useCallback(
(variables: TVariables) => {
executeMutation(variables).catch(() => {})
},
[executeMutation]
)
const reset = useCallback(() => {
setState({
status: 'idle',
data: undefined,
error: null,
})
retryCountRef.current = 0
}, [])
return {
mutate,
mutateAsync: executeMutation,
isLoading: state.status === 'loading',
data: state.data,
error: state.error,
reset,
isSuccess: state.status === 'success',
isError: state.status === 'error',
isIdle: state.status === 'idle',
}
}
+65
View File
@@ -0,0 +1,65 @@
---
name: use_wails_query
kind: component
lang: typescript
domain: ui
version: "1.0.0"
purity: impure
signature: "useWailsQuery<T>(opts: UseWailsQueryOptions<T>): UseWailsQueryResult<T>"
description: "Hook React Query-like sobre IPC Wails. Cache automático, refetch por intervalo/foco, retry con backoff, invalidación."
tags: [wails, query, hook, ipc, cache, component, ui]
uses_functions: [wails_cache_typescript_core, wails_provider_typescript_ui]
uses_types: [WailsIPC_typescript_ui]
returns: []
returns_optional: false
error_type: ""
imports: [react]
tested: false
tests: []
test_file_path: ""
file_path: "frontend/functions/ui/use_wails_query.tsx"
props:
- name: queryKey
type: "string[]"
required: true
description: "Key única para cache"
- name: queryFn
type: "() => Promise<T>"
required: true
description: "Función que llama al binding Wails"
- name: enabled
type: "boolean"
required: false
description: "Habilitar auto-fetch (default true)"
- name: refetchInterval
type: "number"
required: false
description: "Refetch cada N ms"
- name: staleTime
type: "number"
required: false
description: "Tiempo antes de considerar datos stale"
emits: [onSuccess, onError]
has_state: true
framework: react
variant: [default]
source_repo: "https://gitea-dgg044oo04woo4ggcsws4gk0.organic-machine.com/Bl4cksmith/Frontend_Library"
source_license: "MIT"
source_file: "frontend/src/lib/wails/use-wails-query.tsx"
---
## Ejemplo
```tsx
const { data, loading, refetch, invalidate } = useWailsQuery({
queryKey: ['users', 'list'],
queryFn: () => GetUsers(), // Wails binding
refetchInterval: 30000, // Cada 30s
staleTime: 5000, // Fresh por 5s
onSuccess: (users) => console.log(`${users.length} users`),
})
```
## Notas
API inspirada en TanStack Query pero sobre IPC Wails (sin HTTP). Cache compartido via WailsProvider o singleton. Refetch automático en window focus.
+163
View File
@@ -0,0 +1,163 @@
import { useCallback, useEffect, useRef, useState } from 'react'
import { useWailsContext } from './wails_provider'
import { DEFAULT_QUERY_OPTIONS, type QueryOptions, type QueryState } from '../../types/ui/wails_ipc'
export interface UseWailsQueryOptions<T> extends QueryOptions {
/** Key única para identificar esta query en el cache */
queryKey: string[]
/** Función que ejecuta la llamada a Wails */
queryFn: () => Promise<T>
/** Callback en éxito */
onSuccess?: (data: T) => void
/** Callback en error */
onError?: (error: Error) => void
}
export interface UseWailsQueryResult<T> extends QueryState<T> {
/** Re-ejecutar la query manualmente */
refetch: () => Promise<T>
/** Invalidar cache de esta query */
invalidate: () => void
}
export function useWailsQuery<T>({
queryKey,
queryFn,
onSuccess,
onError,
...options
}: UseWailsQueryOptions<T>): UseWailsQueryResult<T> {
const { cache, defaultQueryOptions } = useWailsContext()
// Merge options with defaults
const opts = {
...DEFAULT_QUERY_OPTIONS,
...defaultQueryOptions,
...options,
}
const {
enabled,
staleTime,
refetchInterval,
refetchOnFocus,
retry,
retryDelay,
} = opts
const [state, setState] = useState<QueryState<T>>(() => ({
data: cache.get<T>(queryKey),
loading: !cache.has(queryKey) && enabled,
error: null,
isStale: cache.isStale(queryKey, staleTime),
lastUpdated: cache.getTimestamp(queryKey),
}))
const retryCountRef = useRef(0)
const mountedRef = useRef(true)
const fetch = useCallback(async (): Promise<T> => {
if (!mountedRef.current) throw new Error('Component unmounted')
setState((s) => ({ ...s, loading: true, error: null }))
try {
const data = await queryFn()
if (!mountedRef.current) throw new Error('Component unmounted')
cache.set(queryKey, data)
retryCountRef.current = 0
setState({
data,
loading: false,
error: null,
isStale: false,
lastUpdated: new Date(),
})
onSuccess?.(data)
return data
} catch (error) {
if (!mountedRef.current) throw error
const maxRetries = typeof retry === 'number' ? retry : retry ? 3 : 0
if (retryCountRef.current < maxRetries) {
retryCountRef.current += 1
await new Promise((resolve) => setTimeout(resolve, retryDelay))
return fetch()
}
setState((s) => ({
...s,
loading: false,
error: error as Error,
}))
onError?.(error as Error)
throw error
}
}, [queryKey.join(':'), queryFn, cache, staleTime, retry, retryDelay, onSuccess, onError])
// Initial fetch
useEffect(() => {
mountedRef.current = true
if (enabled && (!cache.has(queryKey) || cache.isStale(queryKey, staleTime))) {
fetch().catch(() => {})
}
return () => {
mountedRef.current = false
}
}, [enabled, queryKey.join(':'), staleTime])
// Refetch interval
useEffect(() => {
if (!refetchInterval || !enabled) return
const interval = setInterval(() => {
fetch().catch(() => {})
}, refetchInterval)
return () => clearInterval(interval)
}, [refetchInterval, enabled, fetch])
// Refetch on window focus
useEffect(() => {
if (!refetchOnFocus || !enabled) return
const handleFocus = () => {
if (cache.isStale(queryKey, staleTime)) {
fetch().catch(() => {})
}
}
window.addEventListener('focus', handleFocus)
return () => window.removeEventListener('focus', handleFocus)
}, [refetchOnFocus, enabled, queryKey.join(':'), staleTime, fetch])
// Subscribe to cache changes
useEffect(() => {
return cache.subscribe(queryKey, () => {
setState((s) => ({
...s,
data: cache.get<T>(queryKey),
isStale: cache.isStale(queryKey, staleTime),
lastUpdated: cache.getTimestamp(queryKey),
}))
})
}, [queryKey.join(':'), staleTime])
const invalidate = useCallback(() => {
cache.invalidate(queryKey)
}, [queryKey.join(':')])
return {
...state,
refetch: fetch,
invalidate,
}
}
+69
View File
@@ -0,0 +1,69 @@
---
name: use_wails_stream
kind: component
lang: typescript
domain: ui
version: "1.0.0"
purity: impure
signature: "useWailsStream<T>(opts: UseWailsStreamOptions<T>): UseWailsStreamResult<T>"
description: "Hook para streaming de datos Go→TS con buffer configurable, auto-complete, transform y control start/stop. Incluye useWailsLogs."
tags: [wails, stream, hook, ipc, realtime, buffer, component, ui]
uses_functions: [use_wails_event_typescript_ui]
uses_types: []
returns: []
returns_optional: false
error_type: ""
imports: [react]
tested: false
tests: []
test_file_path: ""
file_path: "frontend/functions/ui/use_wails_stream.tsx"
props:
- name: streamName
type: "string"
required: true
description: "Nombre del evento de stream"
- name: startFn
type: "() => Promise<void>"
required: false
description: "Función Go para iniciar el stream"
- name: stopFn
type: "() => Promise<void>"
required: false
description: "Función Go para detener el stream"
- name: bufferSize
type: "number"
required: false
description: "Tamaño máximo del buffer (default 1000)"
- name: transform
type: "(data: unknown) => T"
required: false
description: "Transformar datos entrantes"
emits: [onData, onComplete, onError]
has_state: true
framework: react
variant: [default]
source_repo: "https://gitea-dgg044oo04woo4ggcsws4gk0.organic-machine.com/Bl4cksmith/Frontend_Library"
source_license: "MIT"
source_file: "frontend/src/lib/wails/use-wails-stream.tsx"
---
## Ejemplo
```tsx
// Stream de logs en tiempo real
const { data: logs, isStreaming, start, stop, clear } = useWailsStream<string>({
streamName: 'app:logs',
startFn: () => StartLogStream(),
stopFn: () => StopLogStream(),
bufferSize: 500,
autoStart: true,
})
// Versión simplificada para logs
const { data: logs } = useWailsLogs('deploy:logs')
```
## Notas
Protocolo de stream: Go emite chunks en `{streamName}`, completa con `{streamName}:complete`, errores con `{streamName}:error`. Buffer circular con tamaño configurable.
+196
View File
@@ -0,0 +1,196 @@
import { useCallback, useEffect, useRef, useState } from 'react'
import { useWailsEvent } from './use_wails_event'
export interface UseWailsStreamOptions<T> {
/** Nombre del evento de stream */
streamName: string
/** Función para iniciar el stream en Go */
startFn?: () => Promise<void>
/** Función para detener el stream en Go */
stopFn?: () => Promise<void>
/** Transformar datos entrantes */
transform?: (data: unknown) => T
/** Callback por cada chunk */
onData?: (data: T) => void
/** Callback cuando el stream termina */
onComplete?: () => void
/** Callback en error */
onError?: (error: Error) => void
/** Tamaño máximo del buffer */
bufferSize?: number
/** Auto-iniciar el stream */
autoStart?: boolean
}
export interface UseWailsStreamResult<T> {
/** Datos acumulados */
data: T[]
/** Último dato recibido */
lastChunk: T | undefined
/** Si está activo */
isStreaming: boolean
/** Si está cargando (iniciando) */
isLoading: boolean
/** Error si ocurrió */
error: Error | null
/** Iniciar stream */
start: () => Promise<void>
/** Detener stream */
stop: () => Promise<void>
/** Limpiar buffer */
clear: () => void
/** Número de chunks recibidos */
chunkCount: number
}
/**
* Hook para manejar streams de datos desde Wails
* Útil para datos en tiempo real como logs, métricas, etc.
*/
export function useWailsStream<T = unknown>({
streamName,
startFn,
stopFn,
transform,
onData,
onComplete,
onError,
bufferSize = 1000,
autoStart = false,
}: UseWailsStreamOptions<T>): UseWailsStreamResult<T> {
const [data, setData] = useState<T[]>([])
const [isStreaming, setIsStreaming] = useState(false)
const [isLoading, setIsLoading] = useState(false)
const [error, setError] = useState<Error | null>(null)
const [chunkCount, setChunkCount] = useState(0)
const lastChunkRef = useRef<T | undefined>(undefined)
const mountedRef = useRef(true)
// Listen for stream data
useWailsEvent<unknown>({
eventName: streamName,
enabled: isStreaming,
onEvent: (rawData) => {
if (!mountedRef.current) return
const chunk = transform ? transform(rawData) : (rawData as T)
lastChunkRef.current = chunk
setData((prev) => {
const newData = [...prev, chunk]
// Mantener buffer limitado
if (newData.length > bufferSize) {
return newData.slice(-bufferSize)
}
return newData
})
setChunkCount((c) => c + 1)
onData?.(chunk)
},
})
// Listen for stream complete
useWailsEvent({
eventName: `${streamName}:complete`,
enabled: isStreaming,
onEvent: () => {
if (!mountedRef.current) return
setIsStreaming(false)
onComplete?.()
},
})
// Listen for stream error
useWailsEvent<{ message: string }>({
eventName: `${streamName}:error`,
enabled: isStreaming,
onEvent: (errorData) => {
if (!mountedRef.current) return
const err = new Error(errorData?.message || 'Stream error')
setError(err)
setIsStreaming(false)
onError?.(err)
},
})
const start = useCallback(async () => {
if (isStreaming || isLoading) return
setIsLoading(true)
setError(null)
try {
if (startFn) {
await startFn()
}
setIsStreaming(true)
} catch (err) {
const error = err as Error
setError(error)
onError?.(error)
} finally {
setIsLoading(false)
}
}, [isStreaming, isLoading, startFn, onError])
const stop = useCallback(async () => {
if (!isStreaming) return
try {
if (stopFn) {
await stopFn()
}
setIsStreaming(false)
} catch (err) {
const error = err as Error
setError(error)
onError?.(error)
}
}, [isStreaming, stopFn, onError])
const clear = useCallback(() => {
setData([])
setChunkCount(0)
lastChunkRef.current = undefined
}, [])
// Auto-start
useEffect(() => {
if (autoStart) {
start()
}
}, []) // eslint-disable-line react-hooks/exhaustive-deps
// Cleanup
useEffect(() => {
mountedRef.current = true
return () => {
mountedRef.current = false
}
}, [])
return {
data,
lastChunk: lastChunkRef.current,
isStreaming,
isLoading,
error,
start,
stop,
clear,
chunkCount,
}
}
/**
* Hook simplificado para logs en tiempo real
*/
export function useWailsLogs(eventName: string = 'logs') {
return useWailsStream<string>({
streamName: eventName,
transform: (data) => (typeof data === 'string' ? data : JSON.stringify(data)),
bufferSize: 500,
})
}
+53
View File
@@ -0,0 +1,53 @@
---
name: wails_provider
kind: component
lang: typescript
domain: ui
version: "1.0.0"
purity: impure
signature: "WailsProvider(props: { children: ReactNode; cache?: WailsCache; defaultQueryOptions?: QueryOptions }): JSX.Element"
description: "Provider React para IPC Wails con cache context, opciones default y fallback a singleton. Exporta useWailsContext y useWailsCache."
tags: [wails, provider, context, ipc, component, ui]
uses_functions: [wails_cache_typescript_core]
uses_types: [WailsIPC_typescript_ui]
returns: []
returns_optional: false
error_type: ""
imports: [react]
tested: false
tests: []
test_file_path: ""
file_path: "frontend/functions/ui/wails_provider.tsx"
props:
- name: cache
type: "WailsCache"
required: false
description: "Cache custom (default: singleton global)"
- name: defaultQueryOptions
type: "Partial<QueryOptions>"
required: false
description: "Opciones default para todas las queries"
- name: children
type: "ReactNode"
required: true
description: "App content"
emits: []
has_state: false
framework: react
variant: [default]
source_repo: "https://gitea-dgg044oo04woo4ggcsws4gk0.organic-machine.com/Bl4cksmith/Frontend_Library"
source_license: "MIT"
source_file: "frontend/src/lib/wails/wails-provider.tsx"
---
## Ejemplo
```tsx
<WailsProvider defaultQueryOptions={{ staleTime: 5000, retry: 2 }}>
<App />
</WailsProvider>
```
## Notas
Sin provider, los hooks hacen fallback al singleton `wailsCache`. El provider solo es necesario para cache custom o opciones globales.
+60
View File
@@ -0,0 +1,60 @@
import { createContext, useContext, useMemo, type ReactNode } from 'react'
import { WailsCache, wailsCache } from '../core/wails_cache'
import type { QueryOptions, MutationOptions } from '../../types/ui/wails_ipc'
interface WailsContextValue {
cache: WailsCache
defaultQueryOptions: Partial<QueryOptions>
defaultMutationOptions: Partial<MutationOptions>
}
const WailsContext = createContext<WailsContextValue | null>(null)
export interface WailsProviderProps {
children: ReactNode
/** Usar un cache custom en lugar del singleton */
cache?: WailsCache
/** Opciones por defecto para queries */
defaultQueryOptions?: Partial<QueryOptions>
/** Opciones por defecto para mutations */
defaultMutationOptions?: Partial<MutationOptions>
}
export function WailsProvider({
children,
cache,
defaultQueryOptions = {},
defaultMutationOptions = {},
}: WailsProviderProps) {
const value = useMemo<WailsContextValue>(
() => ({
cache: cache ?? wailsCache,
defaultQueryOptions,
defaultMutationOptions,
}),
[cache, defaultQueryOptions, defaultMutationOptions]
)
return (
<WailsContext.Provider value={value}>
{children}
</WailsContext.Provider>
)
}
export function useWailsContext(): WailsContextValue {
const context = useContext(WailsContext)
if (!context) {
// Fallback to singleton cache if no provider
return {
cache: wailsCache,
defaultQueryOptions: {},
defaultMutationOptions: {},
}
}
return context
}
export function useWailsCache(): WailsCache {
return useWailsContext().cache
}
+40
View File
@@ -0,0 +1,40 @@
---
name: ComponentVariants
lang: typescript
domain: core
version: "1.0.0"
algebraic: product
definition: |
interface ComponentBaseProps { className?: string; children?: React.ReactNode }
type PropsWithVariants<V> = ComponentBaseProps & VariantProps<V>
description: "Tipos base para componentes con variantes CVA. Props comunes y composición de variantes type-safe."
tags: [component, variants, cva, props, base]
uses_types: []
file_path: "frontend/types/core/component_variants.ts"
source_repo: "https://gitea-dgg044oo04woo4ggcsws4gk0.organic-machine.com/Bl4cksmith/Frontend_Library"
source_license: "MIT"
source_file: "frontend/src/components/ui/button.tsx"
---
## Tipos exportados
- `VariantProps<V>` — re-exportado de `class-variance-authority`. Extrae el tipo de props de un `cva()` call.
- `ComponentBaseProps` — props comunes a todos los componentes: `className` y `children` opcionales.
- `PropsWithVariants<V>` — combinación de `ComponentBaseProps` con las variantes de un `cva()` concreto. Usar como base para las props de cualquier componente con variantes.
## Uso
```typescript
import { cva } from "class-variance-authority"
import { type PropsWithVariants } from "@/types/core/component_variants"
const buttonVariants = cva("base-classes", {
variants: { size: { sm: "...", md: "..." } }
})
type ButtonProps = PropsWithVariants<typeof buttonVariants>
```
## Notas
Requiere `class-variance-authority` como dependencia del proyecto consumidor. `React.ReactNode` requiere que `react` esté en el scope — en proyectos con `@types/react` esto se resuelve automáticamente.
+10
View File
@@ -0,0 +1,10 @@
import { type VariantProps } from "class-variance-authority"
export type { VariantProps }
export interface ComponentBaseProps {
className?: string
children?: React.ReactNode
}
export type PropsWithVariants<V> = ComponentBaseProps & VariantProps<V>

Some files were not shown because too many files have changed in this diff Show More