diff --git a/.claude/rules/INDEX.md b/.claude/rules/INDEX.md index cfa5009c..d845b5e2 100644 --- a/.claude/rules/INDEX.md +++ b/.claude/rules/INDEX.md @@ -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 | diff --git a/.claude/rules/notebook_collaboration.md b/.claude/rules/notebook_collaboration.md new file mode 100644 index 00000000..6a6831cf --- /dev/null +++ b/.claude/rules/notebook_collaboration.md @@ -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 +``` + +### 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}/`). diff --git a/bash/functions/infra/install_nordvpn.md b/bash/functions/infra/install_nordvpn.md new file mode 100644 index 00000000..66aa70fc --- /dev/null +++ b/bash/functions/infra/install_nordvpn.md @@ -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. diff --git a/bash/functions/infra/install_nordvpn.sh b/bash/functions/infra/install_nordvpn.sh new file mode 100644 index 00000000..ed832e2c --- /dev/null +++ b/bash/functions/infra/install_nordvpn.sh @@ -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" +} diff --git a/bash/functions/infra/nordvpn_connect.md b/bash/functions/infra/nordvpn_connect.md new file mode 100644 index 00000000..19d0c0c9 --- /dev/null +++ b/bash/functions/infra/nordvpn_connect.md @@ -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. diff --git a/bash/functions/infra/nordvpn_connect.sh b/bash/functions/infra/nordvpn_connect.sh new file mode 100644 index 00000000..24b685e2 --- /dev/null +++ b/bash/functions/infra/nordvpn_connect.sh @@ -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 +} diff --git a/bash/functions/infra/nordvpn_disconnect.md b/bash/functions/infra/nordvpn_disconnect.md new file mode 100644 index 00000000..9b01d6c6 --- /dev/null +++ b/bash/functions/infra/nordvpn_disconnect.md @@ -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. diff --git a/bash/functions/infra/nordvpn_disconnect.sh b/bash/functions/infra/nordvpn_disconnect.sh new file mode 100644 index 00000000..c5cd6f85 --- /dev/null +++ b/bash/functions/infra/nordvpn_disconnect.sh @@ -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 +} diff --git a/bash/functions/infra/nordvpn_get_ip.md b/bash/functions/infra/nordvpn_get_ip.md new file mode 100644 index 00000000..58c06f73 --- /dev/null +++ b/bash/functions/infra/nordvpn_get_ip.md @@ -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. diff --git a/bash/functions/infra/nordvpn_get_ip.sh b/bash/functions/infra/nordvpn_get_ip.sh new file mode 100644 index 00000000..70ae1c75 --- /dev/null +++ b/bash/functions/infra/nordvpn_get_ip.sh @@ -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\"}" +} diff --git a/bash/functions/infra/nordvpn_list_cities.md b/bash/functions/infra/nordvpn_list_cities.md new file mode 100644 index 00000000..21c5bc58 --- /dev/null +++ b/bash/functions/infra/nordvpn_list_cities.md @@ -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. diff --git a/bash/functions/infra/nordvpn_list_cities.sh b/bash/functions/infra/nordvpn_list_cities.sh new file mode 100644 index 00000000..d5380974 --- /dev/null +++ b/bash/functions/infra/nordvpn_list_cities.sh @@ -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})) +' +} diff --git a/bash/functions/infra/nordvpn_list_countries.md b/bash/functions/infra/nordvpn_list_countries.md new file mode 100644 index 00000000..e7f38b27 --- /dev/null +++ b/bash/functions/infra/nordvpn_list_countries.md @@ -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. diff --git a/bash/functions/infra/nordvpn_list_countries.sh b/bash/functions/infra/nordvpn_list_countries.sh new file mode 100644 index 00000000..f05c5d7d --- /dev/null +++ b/bash/functions/infra/nordvpn_list_countries.sh @@ -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})) +' +} diff --git a/bash/functions/infra/nordvpn_set_protocol.md b/bash/functions/infra/nordvpn_set_protocol.md new file mode 100644 index 00000000..952093da --- /dev/null +++ b/bash/functions/infra/nordvpn_set_protocol.md @@ -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. diff --git a/bash/functions/infra/nordvpn_set_protocol.sh b/bash/functions/infra/nordvpn_set_protocol.sh new file mode 100644 index 00000000..8634064b --- /dev/null +++ b/bash/functions/infra/nordvpn_set_protocol.sh @@ -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 +} diff --git a/bash/functions/infra/nordvpn_status.md b/bash/functions/infra/nordvpn_status.md new file mode 100644 index 00000000..b197ef62 --- /dev/null +++ b/bash/functions/infra/nordvpn_status.md @@ -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. diff --git a/bash/functions/infra/nordvpn_status.sh b/bash/functions/infra/nordvpn_status.sh new file mode 100644 index 00000000..c7cdc1b0 --- /dev/null +++ b/bash/functions/infra/nordvpn_status.sh @@ -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)) +' +} diff --git a/bash/functions/infra/write_jupyter_launcher.sh b/bash/functions/infra/write_jupyter_launcher.sh index 7884d317..afcb0aef 100644 --- a/bash/functions/infra/write_jupyter_launcher.sh +++ b/bash/functions/infra/write_jupyter_launcher.sh @@ -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 diff --git a/bash/functions/infra/write_mcp_jupyter_config.sh b/bash/functions/infra/write_mcp_jupyter_config.sh index 470e327f..4a3dd2a2 100644 --- a/bash/functions/infra/write_mcp_jupyter_config.sh +++ b/bash/functions/infra/write_mcp_jupyter_config.sh @@ -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": "" + } } } } diff --git a/frontend/functions/core/chart_colors.md b/frontend/functions/core/chart_colors.md new file mode 100644 index 00000000..8bb9bd2a --- /dev/null +++ b/frontend/functions/core/chart_colors.md @@ -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. diff --git a/frontend/functions/core/chart_colors.ts b/frontend/functions/core/chart_colors.ts new file mode 100644 index 00000000..fc55e8cf --- /dev/null +++ b/frontend/functions/core/chart_colors.ts @@ -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] +} diff --git a/frontend/functions/core/cn.md b/frontend/functions/core/cn.md new file mode 100644 index 00000000..288ae534 --- /dev/null +++ b/frontend/functions/core/cn.md @@ -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. diff --git a/frontend/functions/core/cn.ts b/frontend/functions/core/cn.ts new file mode 100644 index 00000000..e3155433 --- /dev/null +++ b/frontend/functions/core/cn.ts @@ -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)) +} diff --git a/frontend/functions/core/format_compact.md b/frontend/functions/core/format_compact.md new file mode 100644 index 00000000..6542fa5b --- /dev/null +++ b/frontend/functions/core/format_compact.md @@ -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. diff --git a/frontend/functions/core/format_compact.ts b/frontend/functions/core/format_compact.ts new file mode 100644 index 00000000..ff7e4b9b --- /dev/null +++ b/frontend/functions/core/format_compact.ts @@ -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' +} diff --git a/frontend/functions/core/get_series_color.md b/frontend/functions/core/get_series_color.md new file mode 100644 index 00000000..6bb5aa95 --- /dev/null +++ b/frontend/functions/core/get_series_color.md @@ -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. diff --git a/frontend/functions/core/get_series_color.ts b/frontend/functions/core/get_series_color.ts new file mode 100644 index 00000000..0c89b11a --- /dev/null +++ b/frontend/functions/core/get_series_color.ts @@ -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 } diff --git a/frontend/functions/core/theme_config_to_colors.md b/frontend/functions/core/theme_config_to_colors.md new file mode 100644 index 00000000..b3a7dab0 --- /dev/null +++ b/frontend/functions/core/theme_config_to_colors.md @@ -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). diff --git a/frontend/functions/core/theme_config_to_colors.ts b/frontend/functions/core/theme_config_to_colors.ts new file mode 100644 index 00000000..884155db --- /dev/null +++ b/frontend/functions/core/theme_config_to_colors.ts @@ -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, + } +} diff --git a/frontend/functions/core/wails_cache.md b/frontend/functions/core/wails_cache.md new file mode 100644 index 00000000..2296276d --- /dev/null +++ b/frontend/functions/core/wails_cache.md @@ -0,0 +1,39 @@ +--- +name: wails_cache +kind: function +lang: typescript +domain: core +version: "1.0.0" +purity: pure +signature: "class WailsCache { get(key: string[]): T | null; set(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(['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. diff --git a/frontend/functions/core/wails_cache.ts b/frontend/functions/core/wails_cache.ts new file mode 100644 index 00000000..10f2c0ed --- /dev/null +++ b/frontend/functions/core/wails_cache.ts @@ -0,0 +1,99 @@ +interface CacheEntry { + data: unknown + timestamp: Date +} + +export class WailsCache { + private cache = new Map() + private subscribers = new Map void>>() + + /** Generar key string desde array */ + private getKey(queryKey: string[]): string { + return queryKey.join(':') + } + + /** Obtener dato del cache */ + get(queryKey: string[]): T | null { + const entry = this.cache.get(this.getKey(queryKey)) + return (entry?.data as T) ?? null + } + + /** Guardar dato en cache */ + set(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() diff --git a/frontend/functions/ui/alert.md b/frontend/functions/ui/alert.md new file mode 100644 index 00000000..fe735977 --- /dev/null +++ b/frontend/functions/ui/alert.md @@ -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 + + + Error + Something went wrong. + +``` + +## 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). diff --git a/frontend/functions/ui/alert.tsx b/frontend/functions/ui/alert.tsx new file mode 100644 index 00000000..788daf70 --- /dev/null +++ b/frontend/functions/ui/alert.tsx @@ -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) { + return
+} + +function AlertTitle({ className, ...props }: React.ComponentProps<"div">) { + return
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
+} + +function AlertAction({ className, ...props }: React.ComponentProps<"div">) { + return
+} + +export { Alert, AlertTitle, AlertDescription, AlertAction, alertVariants } diff --git a/frontend/functions/ui/analytics_page.md b/frontend/functions/ui/analytics_page.md new file mode 100644 index 00000000..08846bd5 --- /dev/null +++ b/frontend/functions/ui/analytics_page.md @@ -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: }, + { id: 'orders', title: 'Orders by Category', type: 'bar', content: }, + { id: 'trends', title: 'Customer Trends', type: 'line', content: }, + ], +}) +``` + +## Notas + +Layout inteligente: los KPIs se ajustan automáticamente a 2/3/4 columnas según cantidad. Los charts soportan span para ancho completo. diff --git a/frontend/functions/ui/analytics_page.tsx b/frontend/functions/ui/analytics_page.tsx new file mode 100644 index 00000000..9208aa03 --- /dev/null +++ b/frontend/functions/ui/analytics_page.tsx @@ -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 ( +
+ {/* Header */} +
+
+

{title}

+ {subtitle &&

{subtitle}

} +
+
+ {dateRange} + {actions} +
+
+ + {/* KPI Row */} +
+ {metrics.map((metric, i) => ( +
+

{metric.label}

+
+
+

{metric.value}

+ {metric.delta && ( +
+ {metric.delta.value > 0 ? '+' : ''}{metric.delta.value}% +
+ )} +
+
+
+ ))} +
+ + {/* Charts Grid */} +
+ {charts.map((chart) => ( +
+

{chart.title}

+ {chart.content} +
+ ))} +
+
+ ) +} + +export type { AnalyticsPageProps, MetricConfig, ChartConfig } diff --git a/frontend/functions/ui/apply_theme.md b/frontend/functions/ui/apply_theme.md new file mode 100644 index 00000000..9991facc --- /dev/null +++ b/frontend/functions/ui/apply_theme.md @@ -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. diff --git a/frontend/functions/ui/apply_theme.tsx b/frontend/functions/ui/apply_theme.tsx new file mode 100644 index 00000000..49eafbd5 --- /dev/null +++ b/frontend/functions/ui/apply_theme.tsx @@ -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 = { + 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 } diff --git a/frontend/functions/ui/area_chart.md b/frontend/functions/ui/area_chart.md new file mode 100644 index 00000000..92a95495 --- /dev/null +++ b/frontend/functions/ui/area_chart.md @@ -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[]" + 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 + + +``` diff --git a/frontend/functions/ui/area_chart.tsx b/frontend/functions/ui/area_chart.tsx new file mode 100644 index 00000000..f0ef51cb --- /dev/null +++ b/frontend/functions/ui/area_chart.tsx @@ -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[] + 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 ( + + + + {areas.map((area) => ( + + + + + ))} + + {showGrid && } + + + } cursor={{ stroke: 'hsl(var(--muted-foreground))', strokeDasharray: '3 3' }} /> + {showLegend && } + {areas.map((area) => ( + + ))} + + + ) +} + +export const AreaChart = AreaChartComponent +export type { AreaChartProps, GradientConfig } diff --git a/frontend/functions/ui/badge.md b/frontend/functions/ui/badge.md new file mode 100644 index 00000000..4558d802 --- /dev/null +++ b/frontend/functions/ui/badge.md @@ -0,0 +1,48 @@ +--- +name: badge +kind: component +lang: ts +domain: ui +version: "1.0.0" +purity: impure +signature: "Badge(props: BadgeProps & VariantProps): 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 +Active +Error +``` + +## Notas + +Versión simplificada que usa span nativo en lugar de useRender de Base-UI. Mantiene todas las variantes y la composibilidad con cn(). diff --git a/frontend/functions/ui/badge.tsx b/frontend/functions/ui/badge.tsx new file mode 100644 index 00000000..76f1fece --- /dev/null +++ b/frontend/functions/ui/badge.tsx @@ -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, VariantProps {} + +function Badge({ className, variant = "default", size = "default", ...props }: BadgeProps) { + return ( + + ) +} + +export { Badge, badgeVariants } diff --git a/frontend/functions/ui/bar_chart.md b/frontend/functions/ui/bar_chart.md new file mode 100644 index 00000000..0857994f --- /dev/null +++ b/frontend/functions/ui/bar_chart.md @@ -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[]" + 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 + + +``` diff --git a/frontend/functions/ui/bar_chart.tsx b/frontend/functions/ui/bar_chart.tsx new file mode 100644 index 00000000..2a37f122 --- /dev/null +++ b/frontend/functions/ui/bar_chart.tsx @@ -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[] + 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 ( + + + {showGrid && } + {horizontal ? ( + <> + + + + ) : ( + <> + + + + )} + } cursor={{ fill: 'hsl(var(--muted) / 0.3)' }} /> + {showLegend && } + {bars.map((bar) => )} + + + ) +} + +export const BarChart = BarChartComponent +export type { BarChartProps } diff --git a/frontend/functions/ui/button.md b/frontend/functions/ui/button.md new file mode 100644 index 00000000..a4ffada6 --- /dev/null +++ b/frontend/functions/ui/button.md @@ -0,0 +1,53 @@ +--- +name: button +kind: component +lang: ts +domain: ui +version: "1.0.0" +purity: impure +signature: "Button(props: ButtonProps & VariantProps): 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 + + + +``` + +## Notas + +Componente base del sistema. Usa Base-UI Button primitive para accesibilidad completa (keyboard, ARIA). CVA para gestión type-safe de variantes. diff --git a/frontend/functions/ui/button.tsx b/frontend/functions/ui/button.tsx new file mode 100644 index 00000000..74869947 --- /dev/null +++ b/frontend/functions/ui/button.tsx @@ -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) { + return ( + + ) +} + +export { Button, buttonVariants } diff --git a/frontend/functions/ui/card.md b/frontend/functions/ui/card.md new file mode 100644 index 00000000..7de9a260 --- /dev/null +++ b/frontend/functions/ui/card.md @@ -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 + + + Título + Descripción + + Contenido + Footer + +``` + +## Notas + +Sistema de slots via data-slot attributes. Card detecta automáticamente la presencia de CardFooter y ajusta el padding. Exporta 7 subcomponentes composables. diff --git a/frontend/functions/ui/card.tsx b/frontend/functions/ui/card.tsx new file mode 100644 index 00000000..7f5a24df --- /dev/null +++ b/frontend/functions/ui/card.tsx @@ -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 ( +
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 ( +
+ ) +} + +function CardTitle({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ) +} + +function CardDescription({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ) +} + +function CardAction({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ) +} + +function CardContent({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ) +} + +function CardFooter({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ) +} + +export { Card, CardHeader, CardFooter, CardTitle, CardAction, CardDescription, CardContent } diff --git a/frontend/functions/ui/chart_container.md b/frontend/functions/ui/chart_container.md new file mode 100644 index 00000000..7f163c01 --- /dev/null +++ b/frontend/functions/ui/chart_container.md @@ -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 + + ... + +``` + +## Notas + +Exporta: ChartContainer, ChartTooltipContent, ChartTooltip, ChartLegend, chartColors, defaultColors, getSeriesColor, Series. diff --git a/frontend/functions/ui/chart_container.tsx b/frontend/functions/ui/chart_container.tsx new file mode 100644 index 00000000..040a83ca --- /dev/null +++ b/frontend/functions/ui/chart_container.tsx @@ -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 ( +
+ + {children as React.ReactElement} + +
+ ) +} + +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 ( +
+

{labelFormatter(label || '')}

+
+ {payload.map((entry, index) => ( +
+
+ {entry.name}: + {valueFormatter(entry.value)} +
+ ))} +
+
+ ) +} + +export function ChartTooltip(props: React.ComponentProps) { + return } cursor={{ fill: 'hsl(var(--muted) / 0.3)' }} {...props} /> +} + +export function ChartLegend(props: React.ComponentProps) { + return +} diff --git a/frontend/functions/ui/crud_page.md b/frontend/functions/ui/crud_page.md new file mode 100644 index 00000000..9f139b00 --- /dev/null +++ b/frontend/functions/ui/crud_page.md @@ -0,0 +1,51 @@ +--- +name: crud_page +kind: function +lang: typescript +domain: ui +version: "1.0.0" +purity: pure +signature: "crudPage(props: CrudPageProps): 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) => {v} }, + ], + 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. diff --git a/frontend/functions/ui/crud_page.tsx b/frontend/functions/ui/crud_page.tsx new file mode 100644 index 00000000..effa6a24 --- /dev/null +++ b/frontend/functions/ui/crud_page.tsx @@ -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> { + 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) => void + onEdit?: (item: T) => void + onDelete?: (item: T) => void + actions?: React.ReactNode + className?: string +} + +export function crudPage>({ + title, + subtitle, + data, + fields, + columns, + onAdd, + onEdit, + onDelete, + actions, + className, +}: CrudPageProps): React.ReactElement { + return ( +
+ {/* Header */} +
+
+

{title}

+ {subtitle &&

{subtitle}

} +
+
+ {actions} + {onAdd && ( + + )} +
+
+ + {/* Table */} +
+ + + + {columns.map((col) => ( + + ))} + {(onEdit || onDelete) && ( + + )} + + + + {data.length === 0 ? ( + + + + ) : ( + data.map((row, i) => ( + + {columns.map((col) => ( + + ))} + {(onEdit || onDelete) && ( + + )} + + )) + )} + +
+ {col.label} + Actions
+ No items yet. +
+ {col.render ? col.render(row[col.key], row) : String(row[col.key] ?? '')} + +
+ {onEdit && ( + + )} + {onDelete && ( + + )} +
+
+
+ + {/* Form fields definition (for agent use — renders a form preview) */} +
+
+ ) +} + +export type { CrudPageProps, CrudField } diff --git a/frontend/functions/ui/dashboard_layout.md b/frontend/functions/ui/dashboard_layout.md new file mode 100644 index 00000000..e4b87676 --- /dev/null +++ b/frontend/functions/ui/dashboard_layout.md @@ -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: }, + { id: 'users', title: 'Users', content: }, + { id: 'chart', title: 'Trends', span: 2, content: }, + { id: 'table', span: 4, content: }, + ] +}) +``` + +## Notas + +Factory pura — dado el mismo input siempre genera el mismo JSX. Un agente puede construir dashboards completos pasando widgets como configuración declarativa. diff --git a/frontend/functions/ui/dashboard_layout.tsx b/frontend/functions/ui/dashboard_layout.tsx new file mode 100644 index 00000000..a3d3b89e --- /dev/null +++ b/frontend/functions/ui/dashboard_layout.tsx @@ -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 = { + 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 = { + 1: 'row-span-1', + 2: 'row-span-2', +} + +export function dashboardLayout({ + widgets, + columns = 4, + gap = 'md', + className, +}: DashboardLayoutProps): React.ReactElement { + const gridCols: Record = { + 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 ( +
+ {widgets.map((widget) => ( +
+ {widget.title && ( +

{widget.title}

+ )} + {widget.content} +
+ ))} +
+ ) +} + +export type { DashboardWidget, DashboardLayoutProps } diff --git a/frontend/functions/ui/detail_page.md b/frontend/functions/ui/detail_page.md new file mode 100644 index 00000000..3eb5c5bb --- /dev/null +++ b/frontend/functions/ui/detail_page.md @@ -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: Active, + 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: }, + { label: 'Activity', value: 'activity', count: 48, content: }, + ], + 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. diff --git a/frontend/functions/ui/detail_page.tsx b/frontend/functions/ui/detail_page.tsx new file mode 100644 index 00000000..86bae0d4 --- /dev/null +++ b/frontend/functions/ui/detail_page.tsx @@ -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 ( +
+ {/* Header */} +
+
+ {onBack && ( + + )} + {avatar &&
{avatar}
} +
+
+

{title}

+ {badge} +
+ {subtitle &&

{subtitle}

} +
+
+ {actions &&
{actions}
} +
+ + {/* Fields grid */} +
+ {fields.map((field, i) => ( +
+

{field.label}

+
{field.value}
+
+ ))} +
+ + {/* Tabs */} + {tabs && tabs.length > 0 && ( +
+ + {tabs.find(t => t.value === activeTab)?.content} +
+ )} + + {/* Timeline */} + {timeline && timeline.length > 0 && ( +
+

Activity

+
+ {timeline.map((event, i) => ( +
+
+
+ {i < timeline.length - 1 &&
} +
+
+

{event.title}

+ {event.description &&

{event.description}

} +

{event.timestamp}

+
+
+ ))} +
+
+ )} +
+ ) +} + +export type { DetailPageProps, DetailField, DetailTab, TimelineEvent } diff --git a/frontend/functions/ui/dialog.md b/frontend/functions/ui/dialog.md new file mode 100644 index 00000000..f4cf5991 --- /dev/null +++ b/frontend/functions/ui/dialog.md @@ -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 + + + + + Título + Descripción + +

Contenido

+ + + +
+
+``` + +## Notas + +10 subcomponentes exportados. Base-UI Dialog primitive para accesibilidad completa (focus trap, escape, click outside). diff --git a/frontend/functions/ui/dialog.tsx b/frontend/functions/ui/dialog.tsx new file mode 100644 index 00000000..77c7d8fa --- /dev/null +++ b/frontend/functions/ui/dialog.tsx @@ -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 +} + +function DialogTrigger({ ...props }: DialogPrimitive.Trigger.Props) { + return +} + +function DialogPortal({ ...props }: DialogPrimitive.Portal.Props) { + return +} + +function DialogClose({ ...props }: DialogPrimitive.Close.Props) { + return +} + +function DialogOverlay({ className, ...props }: DialogPrimitive.Backdrop.Props) { + return ( + + ) +} + +function DialogContent({ className, children, showCloseButton = true, ...props }: DialogPrimitive.Popup.Props & { showCloseButton?: boolean }) { + return ( + + + + {children} + {showCloseButton && ( + + + Close + + )} + + + ) +} + +function DialogHeader({ className, ...props }: React.ComponentProps<"div">) { + return
+} + +function DialogFooter({ className, children, ...props }: React.ComponentProps<"div">) { + return ( +
+ {children} +
+ ) +} + +function DialogTitle({ className, ...props }: DialogPrimitive.Title.Props) { + return +} + +function DialogDescription({ className, ...props }: DialogPrimitive.Description.Props) { + return +} + +export { Dialog, DialogClose, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogOverlay, DialogPortal, DialogTitle, DialogTrigger } diff --git a/frontend/functions/ui/form_field.md b/frontend/functions/ui/form_field.md new file mode 100644 index 00000000..81035466 --- /dev/null +++ b/frontend/functions/ui/form_field.md @@ -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 + + + +``` diff --git a/frontend/functions/ui/form_field.tsx b/frontend/functions/ui/form_field.tsx new file mode 100644 index 00000000..d238aa43 --- /dev/null +++ b/frontend/functions/ui/form_field.tsx @@ -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>, { + id: inputId, + "aria-invalid": error ? true : undefined, + "aria-describedby": describedBy, + }) + } + return child + }) + + return ( +
+ {label && } + {childWithProps} + {helperText && !error &&

{helperText}

} + {error &&

{error}

} +
+ ) +} + +export { FormField } +export type { FormFieldProps } diff --git a/frontend/functions/ui/input.md b/frontend/functions/ui/input.md new file mode 100644 index 00000000..10dbe0f9 --- /dev/null +++ b/frontend/functions/ui/input.md @@ -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 + + + + + +``` + +## Notas + +Exporta Input, InputGroup e InputIcon. InputGroup detecta automáticamente la presencia de iconos y ajusta padding del Input. diff --git a/frontend/functions/ui/input.tsx b/frontend/functions/ui/input.tsx new file mode 100644 index 00000000..e5168797 --- /dev/null +++ b/frontend/functions/ui/input.tsx @@ -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 ( + + ) +} + +interface InputGroupProps { + children: React.ReactNode + className?: string +} + +function InputGroup({ children, className }: InputGroupProps) { + return ( +
+ {children} +
+ ) +} + +interface InputIconProps { + children: React.ReactNode + position: "start" | "end" + className?: string +} + +function InputIcon({ children, position, className }: InputIconProps) { + return ( + + {children} + + ) +} + +export { Input, InputGroup, InputIcon } diff --git a/frontend/functions/ui/kpi_card.md b/frontend/functions/ui/kpi_card.md new file mode 100644 index 00000000..b22b2965 --- /dev/null +++ b/frontend/functions/ui/kpi_card.md @@ -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 + +} /> +``` diff --git a/frontend/functions/ui/kpi_card.tsx b/frontend/functions/ui/kpi_card.tsx new file mode 100644 index 00000000..d45a50b8 --- /dev/null +++ b/frontend/functions/ui/kpi_card.tsx @@ -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 { + label: string + value: string | number + delta?: Delta + icon?: React.ReactNode + subtitle?: string + size?: KPICardSize +} + +const sizeStyles: Record = { + 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( + ({ 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 ( +
+
+
+

{label}

+ {subtitle &&

{subtitle}

} +
+ {icon &&
{icon}
} +
+
+
+

{value}

+ {delta && ( +
+ {delta.value > 0 ? '+' : ''}{delta.value}% +
+ )} +
+
+
+ ) + } +) +KPICard.displayName = 'KPICard' + +export { KPICard, type KPICardProps, type Delta, type KPICardSize } diff --git a/frontend/functions/ui/label.md b/frontend/functions/ui/label.md new file mode 100644 index 00000000..73b31c7c --- /dev/null +++ b/frontend/functions/ui/label.md @@ -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 + +``` diff --git a/frontend/functions/ui/label.tsx b/frontend/functions/ui/label.tsx new file mode 100644 index 00000000..56bb58a6 --- /dev/null +++ b/frontend/functions/ui/label.tsx @@ -0,0 +1,17 @@ +import * as React from "react" +import { cn } from "../core/cn" + +function Label({ className, ...props }: React.ComponentProps<"label">) { + return ( +