feat(kotlin-compose): finalize design system + apps + sync sub-repo gitlinks

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-11 16:30:43 +02:00
parent 42c14fae59
commit a396ee781a
18 changed files with 5285 additions and 57 deletions
Submodule cpp/apps/altsnap_jitter_test updated: 181c4f3dd6...6e52b658a3
@@ -1,6 +1,15 @@
# Tables playground - vive dentro de primitives_gallery/ (playgrounds.md).
# No es un app del registry: no tiene app.md, no se indexa.
# Tables playground (cpp_apps.md / playgrounds.md). NO se indexa.
add_imgui_app(tables_playground
main.cpp
${CMAKE_SOURCE_DIR}/functions/viz/table_view.cpp
data_table.cpp
data_table_logic.cpp
)
# Self-test E2E (logica pura, sin ImGui). No depende de fn_framework.
add_executable(tables_playground_self_test
self_test.cpp
data_table_logic.cpp
)
target_include_directories(tables_playground_self_test PRIVATE
${CMAKE_CURRENT_SOURCE_DIR}
)
@@ -0,0 +1,244 @@
#include "data_table.h"
#include "imgui.h"
#include <cstdio>
#include <string>
#include <unordered_map>
namespace data_table {
namespace {
// Estado UI por-celda/por-header — sobrevive entre frames pero NO se persiste
// a disco. Si se promueve al registry hay que pasarlo al State del caller.
struct UiState {
int pending_col = -1;
std::string pending_value;
bool open_cell_popup = false;
int header_popup_col = -1;
std::unordered_map<int, std::string> filter_inputs; // col -> buffer
std::unordered_map<int, std::string> color_value_inputs; // col -> buffer
std::unordered_map<int, ImVec4> color_picker_vals; // col -> color
};
UiState& ui() { static UiState s; return s; }
void ensure_init(State& st, int cols) {
if ((int)st.col_visible.size() != cols) st.col_visible.assign(cols, true);
}
void draw_chips(State& st, const char* const* headers, int cols) {
if (st.filters.empty()) {
ImGui::TextDisabled("Sin filtros. Click en celda -> elige operador.");
return;
}
for (size_t i = 0; i < st.filters.size(); ) {
const auto& f = st.filters[i];
const char* hdr = (f.col >= 0 && f.col < cols) ? headers[f.col] : "?";
char buf[256];
std::snprintf(buf, sizeof(buf), "%s %s %s x##chip%zu",
hdr, op_label(f.op), f.value.c_str(), i);
ImGui::PushStyleColor(ImGuiCol_Button, IM_COL32(60, 100, 160, 220));
ImGui::PushStyleColor(ImGuiCol_ButtonHovered, IM_COL32(80, 130, 200, 240));
ImGui::PushStyleColor(ImGuiCol_ButtonActive, IM_COL32(45, 80, 130, 240));
bool clicked = ImGui::SmallButton(buf);
ImGui::PopStyleColor(3);
if (clicked) { st.filters.erase(st.filters.begin() + i); continue; }
ImGui::SameLine();
++i;
}
ImGui::NewLine();
}
// Devuelve true y rellena out si el usuario eligio un operador.
bool draw_op_menu_items(Op& out) {
const Op ops[] = {Op::Eq, Op::Neq, Op::Gt, Op::Gte, Op::Lt, Op::Lte};
for (Op o : ops) {
if (ImGui::MenuItem(op_label(o))) { out = o; return true; }
}
return false;
}
void draw_header_menu(State& st, int col, const char* const* headers, int col_count) {
auto& U = ui();
auto& fbuf = U.filter_inputs[col];
fbuf.resize(256, '\0');
if (ImGui::BeginMenu("Filter...")) {
ImGui::SetNextItemWidth(180);
ImGui::InputText("##filterval", fbuf.data(), fbuf.size());
std::string val(fbuf.c_str());
const Op ops[] = {Op::Eq, Op::Neq, Op::Gt, Op::Gte, Op::Lt, Op::Lte};
for (size_t i = 0; i < sizeof(ops)/sizeof(ops[0]); ++i) {
if (i > 0) ImGui::SameLine();
if (ImGui::SmallButton(op_label(ops[i]))) {
st.filters.push_back({col, ops[i], val});
ImGui::CloseCurrentPopup();
}
}
ImGui::EndMenu();
}
if (ImGui::BeginMenu("Conditional color")) {
auto& vbuf = U.color_value_inputs[col];
vbuf.resize(256, '\0');
auto it = U.color_picker_vals.find(col);
if (it == U.color_picker_vals.end()) {
U.color_picker_vals[col] = ImVec4(0.85f, 0.40f, 0.30f, 0.60f);
}
ImVec4& cv = U.color_picker_vals[col];
ImGui::SetNextItemWidth(180);
ImGui::InputText("equals", vbuf.data(), vbuf.size());
ImGui::ColorEdit4("color", &cv.x, ImGuiColorEditFlags_NoInputs);
if (ImGui::Button("Apply")) {
ImU32 c = ImGui::ColorConvertFloat4ToU32(cv);
st.color_rules.push_back({col, std::string(vbuf.c_str()), (unsigned int)c});
ImGui::CloseCurrentPopup();
}
ImGui::SameLine();
if (ImGui::Button("Clear col")) {
for (size_t i = 0; i < st.color_rules.size();) {
if (st.color_rules[i].col == col) st.color_rules.erase(st.color_rules.begin() + i);
else ++i;
}
}
ImGui::EndMenu();
}
if (ImGui::MenuItem("Hide column")) {
st.col_visible[col] = false;
}
ImGui::Separator();
if (ImGui::BeginMenu("Columns")) {
for (int k = 0; k < col_count; ++k) {
bool v = st.col_visible[k];
if (ImGui::Checkbox(headers[k], &v)) st.col_visible[k] = v;
}
if (ImGui::MenuItem("Show all")) {
for (int k = 0; k < col_count; ++k) st.col_visible[k] = true;
}
ImGui::EndMenu();
}
}
} // namespace
void render(const char* id,
const char* const* headers,
int col_count,
const char* const* cells,
int row_count,
State& st)
{
ensure_init(st, col_count);
auto& U = ui();
draw_chips(st, headers, col_count);
auto visible_rows = compute_visible_rows(cells, row_count, col_count, st);
int visible_cols = 0;
for (bool v : st.col_visible) if (v) ++visible_cols;
ImGui::Text("Filas: %d / %d Columnas: %d / %d",
(int)visible_rows.size(), row_count, visible_cols, col_count);
if (visible_cols == 0) {
ImGui::TextDisabled("(todas las columnas ocultas - click derecho en cabecera anterior)");
return;
}
ImGuiTableFlags flags =
ImGuiTableFlags_Borders |
ImGuiTableFlags_Sortable |
ImGuiTableFlags_SortMulti |
ImGuiTableFlags_RowBg |
ImGuiTableFlags_Resizable |
ImGuiTableFlags_ScrollY |
ImGuiTableFlags_Reorderable;
if (!ImGui::BeginTable(id, visible_cols, flags, ImVec2(0, 0))) return;
// Setup columns with UserID = column index del dataset original.
for (int c = 0; c < col_count; ++c) {
if (!st.col_visible[c]) continue;
ImGui::TableSetupColumn(headers[c], ImGuiTableColumnFlags_None, 0.0f, (ImGuiID)c);
}
ImGui::TableSetupScrollFreeze(0, 1);
// Custom header row para soportar right-click context menu por columna.
ImGui::TableNextRow(ImGuiTableRowFlags_Headers);
int draw_col = 0;
for (int c = 0; c < col_count; ++c) {
if (!st.col_visible[c]) continue;
ImGui::TableSetColumnIndex(draw_col++);
ImGui::PushID(c);
ImGui::TableHeader(headers[c]);
if (ImGui::IsItemClicked(ImGuiMouseButton_Right)) {
U.header_popup_col = c;
ImGui::OpenPopup("##hdr_menu");
}
if (ImGui::BeginPopup("##hdr_menu") && U.header_popup_col == c) {
draw_header_menu(st, c, headers, col_count);
ImGui::EndPopup();
}
ImGui::PopID();
}
// Aplicar sort specs de ImGui -> State.
if (ImGuiTableSortSpecs* specs = ImGui::TableGetSortSpecs()) {
if (specs->SpecsDirty && specs->SpecsCount > 0) {
const ImGuiTableColumnSortSpecs& s = specs->Specs[0];
st.sort_col = (int)s.ColumnUserID;
st.sort_desc = (s.SortDirection == ImGuiSortDirection_Descending);
specs->SpecsDirty = false;
visible_rows = compute_visible_rows(cells, row_count, col_count, st);
} else if (specs->SpecsCount == 0 && st.sort_col >= 0) {
st.sort_col = -1;
visible_rows = compute_visible_rows(cells, row_count, col_count, st);
}
}
// Body.
for (int r : visible_rows) {
ImGui::TableNextRow();
int dc = 0;
for (int c = 0; c < col_count; ++c) {
if (!st.col_visible[c]) continue;
ImGui::TableSetColumnIndex(dc++);
const char* cell = cells[r * col_count + c];
for (const auto& cr : st.color_rules) {
if (cr.col == c && cell && cr.equals == cell) {
ImGui::TableSetBgColor(ImGuiTableBgTarget_CellBg, (ImU32)cr.color);
break;
}
}
ImGui::PushID(r * col_count + c);
if (ImGui::Selectable(cell ? cell : "", false, ImGuiSelectableFlags_AllowDoubleClick)) {
U.pending_col = c;
U.pending_value = cell ? cell : "";
U.open_cell_popup = true;
}
ImGui::PopID();
}
}
ImGui::EndTable();
if (U.open_cell_popup) { ImGui::OpenPopup("##cell_op"); U.open_cell_popup = false; }
if (ImGui::BeginPopup("##cell_op")) {
const char* hdr = (U.pending_col >= 0 && U.pending_col < col_count)
? headers[U.pending_col] : "?";
ImGui::TextDisabled("%s ?? \"%s\"", hdr, U.pending_value.c_str());
ImGui::Separator();
Op picked;
if (draw_op_menu_items(picked)) {
st.filters.push_back({U.pending_col, picked, U.pending_value});
ImGui::CloseCurrentPopup();
}
ImGui::EndPopup();
}
}
} // namespace data_table
@@ -0,0 +1,16 @@
#pragma once
#include "data_table_logic.h"
namespace data_table {
// Render barra-de-chips + tabla. Mutates `st` en respuesta a interaccion.
// Caller mantiene el State entre frames.
void render(const char* id,
const char* const* headers,
int col_count,
const char* const* cells,
int row_count,
State& st);
} // namespace data_table
@@ -0,0 +1,93 @@
#include "data_table_logic.h"
#include <algorithm>
#include <cstdlib>
#include <cstring>
namespace data_table {
const char* op_label(Op o) {
switch (o) {
case Op::Eq: return "=";
case Op::Neq: return "!=";
case Op::Gt: return ">";
case Op::Gte: return ">=";
case Op::Lt: return "<";
case Op::Lte: return "<=";
}
return "?";
}
bool parse_number(const char* s, double& out) {
if (!s || !*s) return false;
char* end = nullptr;
double v = std::strtod(s, &end);
if (end == s) return false;
while (*end == ' ' || *end == '\t') end++;
if (*end != '\0') return false;
out = v;
return true;
}
bool compare(const char* a, const char* b, Op op) {
if (!a) a = "";
if (!b) b = "";
double na, nb;
bool numeric = parse_number(a, na) && parse_number(b, nb);
if (numeric) {
switch (op) {
case Op::Eq: return na == nb;
case Op::Neq: return na != nb;
case Op::Gt: return na > nb;
case Op::Gte: return na >= nb;
case Op::Lt: return na < nb;
case Op::Lte: return na <= nb;
}
}
int c = std::strcmp(a, b);
switch (op) {
case Op::Eq: return c == 0;
case Op::Neq: return c != 0;
case Op::Gt: return c > 0;
case Op::Gte: return c >= 0;
case Op::Lt: return c < 0;
case Op::Lte: return c <= 0;
}
return false;
}
std::vector<int> compute_visible_rows(const char* const* cells,
int rows, int cols,
const State& st)
{
std::vector<int> out;
out.reserve(rows);
for (int r = 0; r < rows; ++r) {
bool keep = true;
for (const auto& f : st.filters) {
if (f.col < 0 || f.col >= cols) continue;
const char* cell = cells[r * cols + f.col];
if (!compare(cell, f.value.c_str(), f.op)) { keep = false; break; }
}
if (keep) out.push_back(r);
}
if (st.sort_col >= 0 && st.sort_col < cols) {
int sc = st.sort_col;
bool desc = st.sort_desc;
std::sort(out.begin(), out.end(), [&](int a, int b) {
const char* ca = cells[a * cols + sc];
const char* cb = cells[b * cols + sc];
if (!ca) ca = "";
if (!cb) cb = "";
double na, nb;
bool num = parse_number(ca, na) && parse_number(cb, nb);
int cmp;
if (num) cmp = (na < nb) ? -1 : (na > nb ? 1 : 0);
else cmp = std::strcmp(ca, cb);
return desc ? (cmp > 0) : (cmp < 0);
});
}
return out;
}
} // namespace data_table
@@ -0,0 +1,44 @@
// Logica pura del playground data_table. Sin ImGui — testable headless.
// Cuando se promueva al registry, esto sera la base de data_table_cpp_viz.
#pragma once
#include <string>
#include <vector>
namespace data_table {
enum class Op { Eq, Neq, Gt, Gte, Lt, Lte };
const char* op_label(Op o);
struct Filter {
int col;
Op op;
std::string value;
};
struct ColorRule {
int col;
std::string equals;
unsigned int color; // ImU32 (ABGR para ImGui)
};
struct State {
std::vector<Filter> filters;
std::vector<ColorRule> color_rules;
std::vector<bool> col_visible; // size = col_count; auto-init en render
int sort_col = -1; // -1 = sin sort
bool sort_desc = false;
};
// Parse "1.23" -> 1.23, true. False si la celda no es numero completo.
bool parse_number(const char* s, double& out);
// Compara dos celdas con operador. Numerico si ambas parseables; lexical si no.
bool compare(const char* a, const char* b, Op op);
// Aplica filtros y ordena. Devuelve indices de filas visibles.
std::vector<int> compute_visible_rows(const char* const* cells,
int rows, int cols,
const State& st);
} // namespace data_table
@@ -0,0 +1,29 @@
#!/usr/bin/env bash
# E2E playground tables. Compila + corre self-test linux + windows (si
# mingw esta disponible). Sale 0 si todo pasa.
set -euo pipefail
ROOT="${FN_REGISTRY_ROOT:-$(cd "$(dirname "$0")/../../../../.." && pwd)}"
cd "$ROOT"
echo "[e2e] linux build + self_test"
cmake -B cpp/build -S cpp >/dev/null
cmake --build cpp/build --target tables_playground_self_test -j"$(nproc)" >/dev/null
./cpp/build/apps/primitives_gallery/playground/tables/tables_playground_self_test
if command -v x86_64-w64-mingw32-g++ >/dev/null 2>&1; then
echo "[e2e] windows cross-build (mingw)"
source "$ROOT/bash/functions/infra/build_cpp_windows.sh"
build_cpp_windows tables_playground_self_test >/dev/null
echo "[e2e] windows self_test via wine si disponible"
EXE="$ROOT/cpp/build/windows/apps/primitives_gallery/playground/tables/tables_playground_self_test.exe"
if [ -f "$EXE" ]; then
if command -v wine >/dev/null 2>&1; then
wine "$EXE" || { echo "[e2e] FAIL windows self_test (wine)"; exit 1; }
else
echo "[e2e] SKIP wine no instalado; binario en $EXE"
fi
fi
fi
echo "[e2e] OK"
@@ -1,14 +1,11 @@
// Playground tables: visor de la funcion table_view_cpp_viz tal cual existe
// hoy en el registry. Iteraremos mejoras encima hasta promover una API v2
// que sustituya a los `ImGui::BeginTable` raw de las apps C++.
// Playground tables: iterador de la fn `data_table` antes de promoverla al
// registry y migrar las apps C++ que hoy usan `ImGui::BeginTable` raw.
#include "app_base.h"
#include "imgui.h"
#include "viz/table_view.h"
#include "core/logger.h"
#include "data_table.h"
#include <cstdio>
#include <string>
#include <vector>
namespace {
@@ -21,48 +18,44 @@ struct Row {
const char* description;
};
// Dataset de muestra inspirado en el registry. Filas reales-ish para
// hacer obvias las limitaciones actuales (sin sort, sin filter, sin
// per-cell render, alto fijo, etc.).
const std::vector<Row>& sample_rows() {
static const std::vector<Row> rows = {
{"filter_slice", "go", "core", "pure", "Filtra slice con predicado"},
{"map_slice", "go", "core", "pure", "Aplica f a cada elemento"},
{"reduce_slice", "go", "core", "pure", "Fold con acumulador"},
{"sma", "py", "finance", "pure", "Simple moving average"},
{"ema", "py", "finance", "pure", "Exponential moving average"},
{"rsi", "py", "finance", "pure", "Relative strength index"},
{"table_view", "cpp", "viz", "pure", "Tabla ImGui actual del registry"},
{"line_plot", "cpp", "viz", "pure", "ImPlot line wrapper"},
{"scatter_plot", "cpp", "viz", "pure", "ImPlot scatter wrapper"},
{"bar_chart", "cpp", "viz", "pure", "ImPlot bar wrapper"},
{"heatmap", "cpp", "viz", "pure", "ImPlot heatmap wrapper"},
{"sqlite_open", "go", "infra", "impure", "Open SQLite con WAL+FK"},
{"http_json_response", "go", "infra", "impure", "Helper JSON response"},
{"http_parse_body", "go", "infra", "impure", "Parse JSON body"},
{"rsync_deploy", "bash", "infra", "impure", "rsync local -> remoto"},
{"systemd_install", "go", "infra", "impure", "Sube unit + enable + start"},
{"systemd_restart", "go", "infra", "impure", "Restart servicio remoto"},
{"jupyter_discover", "py", "notebook", "impure", "Descubre instancias Jupyter"},
{"jupyter_exec", "py", "notebook", "impure", "Ejecuta celda y vuelca output"},
{"docker_pull_image", "go", "infra", "impure", "docker pull con timeout"},
{"graph_force_layout", "cpp", "viz", "pure", "Force-directed CPU"},
{"graph_force_layout_gpu","cpp", "viz", "pure", "Force-directed GPU (compute)"},
{"sql_workbench", "cpp", "core", "impure", "Workbench SQL embebido"},
{"text_editor", "cpp", "core", "impure", "Editor de texto con highlighting"},
{"icon_font", "cpp", "core", "impure", "Carga tabler-icons.ttf"},
{"filter_slice", "go", "core", "pure", "Filtra slice con predicado"},
{"map_slice", "go", "core", "pure", "Aplica f a cada elemento"},
{"reduce_slice", "go", "core", "pure", "Fold con acumulador"},
{"sma", "py", "finance", "pure", "Simple moving average"},
{"ema", "py", "finance", "pure", "Exponential moving average"},
{"rsi", "py", "finance", "pure", "Relative strength index"},
{"table_view", "cpp", "viz", "pure", "Tabla ImGui actual del registry"},
{"line_plot", "cpp", "viz", "pure", "ImPlot line wrapper"},
{"scatter_plot", "cpp", "viz", "pure", "ImPlot scatter wrapper"},
{"bar_chart", "cpp", "viz", "pure", "ImPlot bar wrapper"},
{"heatmap", "cpp", "viz", "pure", "ImPlot heatmap wrapper"},
{"sqlite_open", "go", "infra", "impure", "Open SQLite con WAL+FK"},
{"http_json_response", "go", "infra", "impure", "Helper JSON response"},
{"http_parse_body", "go", "infra", "impure", "Parse JSON body"},
{"rsync_deploy", "bash", "infra", "impure", "rsync local -> remoto"},
{"systemd_install", "go", "infra", "impure", "Sube unit + enable + start"},
{"systemd_restart", "go", "infra", "impure", "Restart servicio remoto"},
{"jupyter_discover", "py", "notebook", "impure", "Descubre instancias Jupyter"},
{"jupyter_exec", "py", "notebook", "impure", "Ejecuta celda y vuelca output"},
{"docker_pull_image", "go", "infra", "impure", "docker pull con timeout"},
{"graph_force_layout", "cpp", "viz", "pure", "Force-directed CPU"},
{"graph_force_layout_gpu","cpp", "viz", "pure", "Force-directed GPU (compute)"},
{"sql_workbench", "cpp", "core", "impure", "Workbench SQL embebido"},
{"text_editor", "cpp", "core", "impure", "Editor de texto con highlighting"},
{"icon_font", "cpp", "core", "impure", "Carga tabler-icons.ttf"},
};
return rows;
}
// Aplanado row-major para alimentar table_view_cpp_viz (firma `const char* const*`).
const char* const* flatten_cells(int& out_rows, int& out_cols) {
const char* const* flatten_cells(int& rows, int& cols) {
static std::vector<const char*> flat;
static bool built = false;
if (!built) {
const auto& rows = sample_rows();
flat.reserve(rows.size() * 5);
for (const auto& r : rows) {
const auto& src = sample_rows();
flat.reserve(src.size() * 5);
for (const auto& r : src) {
flat.push_back(r.name);
flat.push_back(r.lang);
flat.push_back(r.domain);
@@ -71,30 +64,26 @@ const char* const* flatten_cells(int& out_rows, int& out_cols) {
}
built = true;
}
out_rows = static_cast<int>(sample_rows().size());
out_cols = 5;
rows = (int)sample_rows().size();
cols = 5;
return flat.data();
}
} // namespace
void render() {
if (ImGui::Begin("Tables Playground - table_view actual")) {
static data_table::State st;
if (ImGui::Begin("Tables Playground - data_table v0.1")) {
ImGui::TextWrapped(
"Esta es la funcion `table_view_cpp_viz` del registry hoy. "
"Capacidades: borders, sortable (solo indicador, no sort real), "
"rowBg, resizable, scrollY (alto fijo 300px), reorderable. "
"Sin filter, sin selection, sin per-cell render, sin export. "
"Iteraremos mejoras encima de esto.");
"Iteracion 1: sort real al pulsar header, click en celda -> popup operador "
"(=, !=, >, >=, <, <=) -> chip removible. Click derecho header: filter input, "
"conditional color, hide column, show/hide columns.");
ImGui::Separator();
static const char* headers[] = {"name", "lang", "domain", "purity", "description"};
int rows = 0, cols = 0;
const char* const* cells = flatten_cells(rows, cols);
ImGui::Text("Filas: %d Columnas: %d", rows, cols);
ImGui::Spacing();
table_view("##registry_sample", headers, cols, cells, rows);
data_table::render("##registry_sample", headers, cols, cells, rows, st);
}
ImGui::End();
}
@@ -106,8 +95,8 @@ int main() {
.width = 1280,
.height = 800,
.about = {.name = "tables_playground",
.version = "0.1.0",
.description = "Playground para iterar mejoras sobre table_view_cpp_viz antes de promover a registry y migrar apps C++."},
.version = "0.2.0",
.description = "Playground para iterar mejoras sobre table_view antes de promover al registry."},
.log = {.file_path = "tables_playground.log",
.level = static_cast<int>(fn_log::Level::Info)}
}, render);
@@ -0,0 +1,116 @@
// E2E self-test del playground tables. Ejercita la logica pura
// (data_table_logic) sin ImGui. Build target separado:
//
// tables_playground_self_test -> linux
// tables_playground_self_test.exe -> windows
//
// Exit 0 = todos los checks pasan, 1 = falla.
#include "data_table_logic.h"
#include <cstdio>
#include <cstdlib>
#include <cstring>
#include <vector>
namespace {
int failed = 0;
int passed = 0;
void check(bool cond, const char* name) {
if (cond) { passed++; std::printf("PASS %s\n", name); }
else { failed++; std::printf("FAIL %s\n", name); }
}
} // namespace
using namespace data_table;
int main() {
// --- parse_number ---
double v = 0;
check(parse_number("1.23", v) && v == 1.23, "parse_number 1.23");
check(parse_number("42", v) && v == 42.0, "parse_number 42");
check(parse_number("-7.5", v) && v == -7.5, "parse_number -7.5");
check(!parse_number("abc", v), "parse_number abc rejected");
check(!parse_number("12x", v), "parse_number 12x rejected");
check(!parse_number("", v), "parse_number empty rejected");
check(!parse_number(nullptr, v), "parse_number null rejected");
// --- compare numerico ---
check( compare("10", "2", Op::Gt), "10 > 2 numerico");
check(!compare("10", "2", Op::Lt), "10 < 2 numerico false");
check( compare("2", "10", Op::Lt), "2 < 10 numerico");
check( compare("5", "5", Op::Eq), "5 == 5 numerico");
check( compare("5", "5", Op::Gte), "5 >= 5 numerico");
check( compare("5", "5", Op::Lte), "5 <= 5 numerico");
check( compare("5", "5", Op::Neq) == false, "5 != 5 numerico false");
// --- compare lexical (cuando no son numeros) ---
check( compare("go", "go", Op::Eq), "lexical eq");
check( compare("go", "py", Op::Neq), "lexical neq");
check( compare("py", "go", Op::Gt), "lexical gt");
check( compare("ab", "ac", Op::Lt), "lexical lt");
// --- compute_visible_rows: filter ---
const char* cells[] = {
"a","1",
"b","2",
"c","3",
"a","4",
};
State st;
st.filters.push_back({0, Op::Eq, "a"});
auto rows = compute_visible_rows(cells, 4, 2, st);
check(rows.size() == 2 && rows[0] == 0 && rows[1] == 3, "filter col0 = a");
// --- filter numerico ---
st.filters.clear();
st.filters.push_back({1, Op::Gt, "2"});
rows = compute_visible_rows(cells, 4, 2, st);
check(rows.size() == 2 && rows[0] == 2 && rows[1] == 3, "filter col1 > 2");
// --- combinacion: > 1 AND col0 != b ---
st.filters.clear();
st.filters.push_back({1, Op::Gt, "1"});
st.filters.push_back({0, Op::Neq, "b"});
rows = compute_visible_rows(cells, 4, 2, st);
check(rows.size() == 2 && rows[0] == 2 && rows[1] == 3, "filter combinado AND");
// --- sort ascendente numerico ---
st.filters.clear();
st.sort_col = 1;
st.sort_desc = false;
rows = compute_visible_rows(cells, 4, 2, st);
check(rows.size() == 4 && rows[0] == 0 && rows[3] == 3, "sort asc numerico");
// --- sort descendente numerico ---
st.sort_desc = true;
rows = compute_visible_rows(cells, 4, 2, st);
check(rows.size() == 4 && rows[0] == 3 && rows[3] == 0, "sort desc numerico");
// --- sort lexical ---
st.sort_col = 0;
st.sort_desc = false;
rows = compute_visible_rows(cells, 4, 2, st);
check(rows.size() == 4 && std::strcmp(cells[rows[0]*2], "a") == 0
&& std::strcmp(cells[rows[3]*2], "c") == 0, "sort asc lexical");
// --- filter + sort combinado ---
st.sort_col = 1;
st.sort_desc = true;
st.filters.push_back({0, Op::Eq, "a"});
rows = compute_visible_rows(cells, 4, 2, st);
check(rows.size() == 2 && rows[0] == 3 && rows[1] == 0, "filter+sort combinado");
// --- filter sobre columna inexistente: se ignora ---
st.filters.clear();
st.filters.push_back({99, Op::Eq, "x"});
st.sort_col = -1;
rows = compute_visible_rows(cells, 4, 2, st);
check(rows.size() == 4, "filter col fuera de rango ignorado");
std::printf("\n=== %d passed, %d failed ===\n", passed, failed);
return failed == 0 ? 0 : 1;
}
+5
View File
@@ -0,0 +1,5 @@
shaders_lab
shaders_lab.exe
build/
*.zip
operations.db*
@@ -0,0 +1,231 @@
# shaders_lab — proximos pasos: ventana sin decoraciones del SO + botones min/max/close en la MainMenuBar
## Motivacion
Hoy la ventana lleva la titlebar nativa de Windows/Linux ademas de la
MainMenuBar de ImGui (View). Son dos barras consumiendo ~60 px verticales.
Queremos:
1. **Recuperar ese espacio** quitando la titlebar del SO.
2. **Integrar los botones min / max / close** en la MainMenuBar de ImGui
(la que renderiza `panel_menu_cpp_core`), alineados a la derecha.
3. Resultado: una sola barra superior compacta con menus + botones de
ventana, igual que VSCode/Spotify/etc.
Aplicable a **todas las apps** del registry, no solo shaders_lab — debe
materializarse como funcion(es) reusables en `cpp/functions/core/`.
---
## Que hace falta
### 1. Crear la ventana sin decoraciones
Una linea en `cpp/framework/app_base.cpp` (donde se crea la GLFW window,
linea ~37):
```cpp
glfwWindowHint(GLFW_DECORATED, GLFW_FALSE);
```
Detras de un nuevo flag `AppConfig::borderless = false` para que las apps
existentes sigan iguales por defecto.
### 2. Dibujar la titlebar custom
Hoy `panel_menu_cpp_core` ya pinta una `BeginMainMenuBar()` con el menu
"View". Ampliamos esa misma barra con:
- Titulo de la app a la izquierda (antes del primer menu) — opcional.
- Espacio para drag en el centro (la propia barra ya es draggeable
porque ImGui detecta clicks fuera de los items).
- Tres botones a la derecha alineados con `SameLine` + offset:
min (—), max (□ / ⧉ segun estado), close (✕).
Glifos: ImGui::ImDrawList con lineas/rect, sin necesidad de fuente
de iconos. O symbols Unicode si la fuente los soporta.
### 3. Lo que el SO te daba gratis y hay que reimplementar
**Drag de la ventana**
```cpp
// Detectar mouse-down en la titlebar (no sobre items):
if (!ImGui::IsAnyItemHovered() && ImGui::IsMouseDragging(0)) {
double cx, cy; glfwGetCursorPos(window, &cx, &cy);
int wx, wy; glfwGetWindowPos(window, &wx, &wy);
// guardar offset al iniciar drag, aplicar glfwSetWindowPos cada frame
}
```
**Doble-click en titlebar → toggle maximize**
```cpp
if (ImGui::IsMouseDoubleClicked(0) && !ImGui::IsAnyItemHovered()) {
if (glfwGetWindowAttrib(window, GLFW_MAXIMIZED))
glfwRestoreWindow(window);
else
glfwMaximizeWindow(window);
}
```
**Botones**
```cpp
glfwIconifyWindow(window); // min
glfwMaximizeWindow(window) / glfwRestoreWindow(window); // max toggle
glfwSetWindowShouldClose(window, true); // close
```
**Resize desde bordes** — la parte fea. Sin decoraciones GLFW no expone
hit-test de bordes. Hay que detectar mouse en franjas de ~6 px en
los 8 lados, cambiar cursor (`glfwSetCursor` con `GLFW_HRESIZE_CURSOR`
etc.), y arrastrar reposicionando+resizing manualmente.
---
## Lo que se pierde
| Comportamiento | Recuperable |
|---------------------------------------------------------|----------------------------------------------------------------------------|
| Snap zones de Windows (Win+flecha, drag al borde) | Solo con codigo nativo Win32 (`WM_NCHITTEST`) — GLFW no lo expone |
| Aero shake, sombras nativas, animacion de minimizar | No facilmente |
| Snap del WM en Linux (i3/sway/KDE) | Igual |
| Bugs Wayland posicionando ventanas borderless | Si Linux es WSLg/X11 sin problema; en Wayland nativo verificar primero |
| Multi-viewport ImGui (`ConfigFlags_ViewportsEnable`) | Cada ventana secundaria tambien sin titlebar → custom en todas. Mas curro |
| Touch / accesibilidad (lectores de pantalla) | Marginal para nuestro caso |
---
## Plan de implementacion
### Fase 1 — funcion reusable + flag en app_base
**Funcion nueva**: `custom_titlebar_cpp_core` (componente, pure desde el
punto de vista del registry — solo dibuja UI; los efectos GLFW se aplican
fuera o se pasan como callbacks). Idealmente fusionada con
`panel_menu_cpp_core` o coordinada con ella para que el menu y los botones
vivan en la **misma** MainMenuBar.
Opcion A (mejor): extender `panel_menu` con parametros opcionales para
los botones del SO:
```cpp
struct WindowControls {
GLFWwindow* window;
bool show_min = true;
bool show_max = true;
bool show_close = true;
};
bool panel_menu(const char* menu_label,
const PanelToggle* items, std::size_t count,
const WindowControls* controls = nullptr); // opcional
```
Pero esto crea acople de `core` con GLFW. Mejor opcion B:
Opcion B (limpia): funcion separada `custom_titlebar_cpp_core` que se
llama **dentro** del menu existente (despues de los menus, antes del
EndMainMenuBar) usando `ImGui::SameLine` con offset al borde derecho. Y
una funcion auxiliar `window_controls_cpp_core` para los tres botones,
que recibe callbacks (`on_min`, `on_max`, `on_close`) sin saber nada de
GLFW. La app las cablea.
```cpp
namespace fn_ui {
struct WindowButtons {
bool min_clicked = false;
bool max_clicked = false;
bool close_clicked = false;
bool is_maximized = false; // input: pinta el icono correcto
};
// Renderiza tres iconos al borde derecho de la barra activa
// (BeginMainMenuBar o cualquier otro contenedor horizontal).
// Devuelve los flags que clico el usuario.
WindowButtons window_controls(bool is_maximized);
// Drag handler: llamar cada frame cuando mouse esta sobre la barra.
// Devuelve delta a aplicar a la posicion de ventana.
// (signature por afinar)
struct WindowDrag { int dx, dy; bool dragging; };
WindowDrag titlebar_drag_handler();
} // namespace fn_ui
```
La app conecta:
```cpp
auto wb = fn_ui::window_controls(glfwGetWindowAttrib(window, GLFW_MAXIMIZED));
if (wb.min_clicked) glfwIconifyWindow(window);
if (wb.max_clicked) { /* toggle */ }
if (wb.close_clicked) glfwSetWindowShouldClose(window, true);
```
Asi `core` no toca GLFW; `framework/app_base` o cada app cablean lo
nativo.
**Cambio en app_base**:
```cpp
struct AppConfig {
// ...existing...
bool borderless = false; // true → GLFW_DECORATED=false
};
// en run_app():
if (config.borderless) glfwWindowHint(GLFW_DECORATED, GLFW_FALSE);
```
### Fase 2 — integracion en shaders_lab
```cpp
fn::AppConfig cfg;
cfg.borderless = true;
// ...
```
En `render()` despues del `panel_menu("View", ...)`, en la misma barra
(reorganizar para que `panel_menu` no cierre `EndMainMenuBar` y deje
hacer SameLine al borde derecho con `window_controls`).
### Fase 3 (opcional) — resize por bordes
Manejador de hit-test en 8 px alrededor del borde de la ventana.
Cambia cursor con `glfwSetCursor`, en mouse-down inicia resize manual
con `glfwSetWindowSize` + `glfwSetWindowPos`.
### Fase 4 (solo si se nota la perdida) — snap de Windows nativo
`WM_NCHITTEST` via HWND. `#ifdef _WIN32`, `glfwGetWin32Window`,
SetWindowLongPtr para subclassear. Trabajo significativo; postponer
hasta haber medido si la falta de snap molesta de verdad.
---
## Decisiones pendientes para el dia que se haga
1. Resize manual por bordes en v1 o solo arrastre + maximizar.
2. Si hacemos `WM_NCHITTEST` o aceptamos sin snap de Windows.
3. Multi-viewport ImGui: queda off mientras la titlebar sea custom, o se
replica el control en cada secondary window.
4. Forma final del API: `panel_menu` extendido vs `window_controls` aparte
(preferencia actual: aparte, mas limpia).
---
## Mi recomendacion practica
Empezar minimal:
- Borderless ON
- Drag arrastrando la MainMenuBar
- Doble-click maximiza/restaura
- Botones min/max/close al borde derecho
- **Sin resize manual** (la ventana es solo maximizable; util para apps tipo lab/dashboard)
- **Sin WM_NCHITTEST** (sin snap de Windows)
- Multi-viewport off
Eso es 80% del valor con 20% del trabajo. Si despues echamos en falta el
resize manual o el snap, se anaden incremental.
@@ -0,0 +1,151 @@
# shaders_lab — proximos pasos: tipos de datos en las aristas del DAG
## Estado actual
Por cada arista del DAG circula **un solo tipo**: `vec4` (RGBA por pixel).
El DAG no es un grafo de pasadas de render — es una plantilla que se compila
a **un unico fragment shader GLSL 330 core** (ver `cpp/functions/gfx/dag_compile.cpp`).
Cada nodo se compila a `vec4 node_<i>(vec4 a, ..., vec2 uv)`. Las conexiones
del editor son llamadas a funcion dentro del mismo `main()`.
Datos accesibles dentro de cualquier nodo, **sin pasar por aristas**:
- `u_time`, `u_resolution`, `u_mouse` (preamble de `gl_shader`)
- `u_params[64]` (vec4 array global con todos los parametros del DAG)
- `u_preview_target` (int, para thumbnails per-nodo)
- `uv` (pasado explicitamente como ultimo argumento)
Tipos de nodo (`DagKind` en `cpp/functions/gfx/dag_types.h`):
| Kind | num_inputs | Recibe | Produce |
|--------|-----------:|------------------------------|------------------------|
| Gen | 0 | `uv`, `u_params` | `vec4` generado |
| Op | 1 | `vec4 a, vec2 uv` | `vec4` transformado |
| Blend | 2 | `vec4 a, vec4 b, vec2 uv` | `vec4` combinado |
| Output | 1 | (sumidero) | `fragColor` directo |
---
## Tier 0 — Pins tipados (otros tipos GLSL escalares/vectoriales)
Mismo modelo single-pass. Solo hay que **declarar tipo por pin** y ajustar
el fallback de input vacio en `dag_compile.cpp:75-89`.
| Tipo | Para que | Coste |
|--------|--------------------------------------------------|----------|
| float | mascaras, alpha, heightmaps, distance fields | trivial |
| vec2 | UVs deformadas, gradientes, flow fields | trivial |
| vec3 | normales, posiciones, color sin alpha | trivial |
| mat2/3 | warps, rotaciones, transforms 2D | trivial |
Desbloquea **Op de displacement**: nodo que toma `vec2` (offset) + `vec4`
(textura) y devuelve `vec4` muestreado con offset.
Cambios necesarios:
- `DagNodeDef` declara tipo por pin de entrada y por salida
- `dag_compile` genera `node_<i>` con esas firmas
- `dag_node_editor` pinta colores de pin distintos y rechaza conexiones incompatibles
---
## Tier 1 — Imagenes rasterizadas (texturas)
Sigue siendo single-pass. Anade un **Gen `image_load`** con `uniform sampler2D`
y emite `texture(u_img_<i>, uv)` como `vec4`. Lo mismo aplica a:
- **Video** → `sampler2D` re-subido cada frame
- **Webcam** → idem
- **Audio FFT** → `sampler1D` (espectro) o `sampler2D` con historial
Coste: **medio**. Hace falta gestor de texturas (slot binding, hot-reload,
resize). La primitiva `Framebuffer` ya existe (`gl_framebuffer.h`) y
`gl_shader` ya gestiona uniforms — solo falta el camino de carga PNG/JPG → GL.
---
## Tier 2 — Multi-pass (rompe el modelo de un solo shader)
**Salto arquitectonico**. Hay operaciones imposibles en un solo pase porque
cada nodo solo "ve" su propio pixel:
- Blur real con kernel grande (lee vecinos)
- Downsample / mipmap / piramides
- FFT, convoluciones, dilations
- Bloom, glow, SSAO
- Reaction-diffusion, fluidos, feedback (frame anterior)
- Cualquier filtro que requiera la imagen ya rasterizada
Una arista deja de ser `vec4` y pasa a ser **texture handle (FBO completo)**.
Cada nodo se compila a su propio fragment shader, renderiza a su FBO, y el
siguiente lo muestrea como `sampler2D`.
`dag_compile` cambia de naturaleza: pasa de "compilar a un main()" a
**planificar passes** (orden topologico, asignar FBOs con pool reusable,
ejecutar en orden).
**Modos coexisten**: cada nodo declara `mode: inline` (se inlinea como hoy)
o `mode: pass` (FBO propio). En cruces `pass→inline` se muestrea el FBO; en
`inline→inline` sigue siendo llamada a funcion. Mejor de los dos mundos.
Coste: **alto** pero la base ya esta (`Framebuffer`, `fullscreen_quad`).
Es trabajo de arquitectura, no de OpenGL.
---
## Tier 3 — SDF (Signed Distance Fields)
Sub-dominio aparte. Sigue siendo single-pass. **Mucho retorno por poco
codigo** — solo necesita pins de tipo `float` (Tier 0) + nuevos nodos.
- Aristas llevan `float` (distancia con signo)
- Gens: `sdf_sphere`, `sdf_box`, `sdf_torus`, `sdf_plane`
- Ops: `sdf_smooth_union`, `sdf_intersect`, `sdf_subtract`, `sdf_round`, `sdf_displace`
- Terminator: `sdf_raymarch` toma SDF + material → `vec4` (raymarching dentro del fragment)
Te da **3D real (esferas, mezclas organicas, fractales tipo Mandelbulb) sin
mallas, vertices ni camara fuera del shader**. Es el truco de la mitad de Shadertoy.
---
## Tier 4 — Geometria 3D real
Aqui ya **no es solo un fragment shader**. Necesita:
- Vertex buffers (`GL_ARRAY_BUFFER`) con posiciones, normales, UVs
- Vertex shader + fragment shader emparejados
- Depth buffer
- Matrices `model/view/projection`, camara
- Posiblemente indices, instancing, geometry/tess shaders
El DAG cambia de naturaleza: dos sub-grafos distintos.
1. **Grafo de geometria** (mallas, transforms, materiales, luces)
2. **Grafo de imagen** (post-process del render final)
Forma realista de integrarlo: un nodo "Render 3D Scene" (con mini-DAG
interno de mallas/camara/luces) **produce una textura** que entra en el
grafo 2D existente como cualquier otro `vec4`.
Coste: **muy alto**. Practicamente otra app dentro de la app.
---
## Tier 5 — Compute / particulas / simulaciones
Compute shaders (`GL_COMPUTE_SHADER`), SSBOs, transform feedback. Para
sistemas de particulas con miles/millones de elementos, simulaciones
fisicas, reaction-diffusion masivo, etc.
Coste muy alto, valor medio salvo que el objetivo del lab cambie.
---
## Recomendacion de orden
1. **Pins tipados** (Tier 0) — desbloquea displacement, mascaras, base para SDFs.
2. **Texturas** (Tier 1) — `image_load`, `video`, `webcam`, `audio_fft`.
3. **SDF + raymarch** (Tier 3) — maximo retorno por linea de codigo.
4. **Multi-pass** (Tier 2) — el salto arquitectonico. Permite blur real, bloom, feedback.
5. **Geometria 3D** (Tier 4) — solo si el caso de uso lo justifica; Tier 3 ya cubre mucho 3D estetico.
Tier 2 es el cruce de caminos: hasta ahi puedes ir extendiendo el modelo
actual; a partir de ahi toca redisenar `dag_compile` como planificador de passes.
+825
View File
@@ -0,0 +1,825 @@
# Shader Playground — MVP Spec
> Editor web de shaders GLSL con:
> - **Auto-UI** generada a partir de anotaciones en `uniform`s.
> - **Integración con Claude API** para generar y modificar shaders desde chat.
> - **Registro mínimo de funciones** reutilizables que el LLM puede consultar e inyectar.
> - **Sistema de sidebars modulares** estilo apps de VJing: canvas central protagonista, paneles acoplables/ocultables a los lados.
> - **Output fullscreen** para sesiones de VJing.
>
> Pensado para completarse en un finde (fase A sábado + fase B domingo).
---
## 0. Filosofía y no-objetivos
### Objetivos del MVP
- Escribir GLSL en un editor web, ver el resultado en vivo, con el canvas como protagonista visual.
- Declarar `uniform`s anotados → panel de controles se genera solo (sliders, color pickers, xy-pads, knobs).
- Chat lateral con Claude que genera shaders, los modifica, y **usa el fn-registry como herramienta** para reutilizar código existente.
- Biblioteca de funciones GLSL con búsqueda, tal que el usuario pueda "guardar este fbm" o "guardar este efecto de nube" y reutilizarlo en futuros shaders.
- Guardar/cargar shaders y funciones GLSL en `localStorage`.
- Modo fullscreen del canvas para usar en sesiones reales de VJ.
### NO objetivos (explícitamente fuera del MVP)
- ❌ Backend propio / base de datos / multi-usuario.
- ❌ Visualizaciones matemáticas auxiliares (FFT, campos, derivadas).
- ❌ MIDI / OSC / audio FFT / Syphon / Spout / NDI.
- ❌ Multi-pass / buffers encadenados tipo Shadertoy.
- ❌ Vertex shader custom (solo fullscreen quad fijo).
- ❌ Compute shaders.
- ❌ Fine-tuning del LLM, RAG elaborado, embeddings.
- ❌ Categorías/taxonomía compleja del registry (flat namespace con tags es suficiente).
- ❌ Múltiples shaders simultáneos con crossfade / capas estilo Photoshop.
- ❌ Sidebars flotantes arrastrables tipo Ableton/Resolume (siempre acoplados a los bordes).
Si el MVP se usa de verdad durante un mes, las features de arriba entran en futuras iteraciones **una a una**.
---
## 1. Stack técnico
- **Package manager / runtime dev:** Bun.
- **Build:** Vite.
- **UI:** React 18 + TypeScript strict.
- **Estilos:** Tailwind + shadcn/ui.
- **Iconos:** lucide-react.
- **Estado:** Zustand.
- **Editor de código:** CodeMirror 6 (paquetes `@codemirror/state`, `@codemirror/view`, `@codemirror/legacy-modes` para GLSL).
- **Renderer:** WebGL2 directo (sin regl ni Three.js). Wrapper propio minimal en `src/renderer/`.
- **Layout:** CSS Grid + `react-resizable-panels` para los sidebars acoplados.
- **Color picker:** `react-colorful`.
- **LLM:** Claude API (`@anthropic-ai/sdk`) usando `claude-opus-4-7` con streaming.
- **Persistencia:** `localStorage` directo, envuelto en módulo fino.
### Por qué WebGL2 puro y no regl
- Vamos a hacer cosas específicas (hot-swap de programas, introspección de uniforms activos, manejo fino de errores de compilación con números de línea) que regl abstrae de formas que más tarde querríamos revertir.
- Aprender la API te deja preparado para WebGPU/wgpu en v2.
- El wrapper que necesitamos son ~200 líneas de TypeScript. Aceptable.
### Estructura de carpetas
```
src/
editor/ # CodeMirror wrapper y modo GLSL
renderer/ # WebGL2 wrapper, compile pipeline, fullscreen quad
parser/ # Extracción de uniforms desde GLSL source
registry/ # fn-registry: CRUD, búsqueda, inyección
llm/ # Cliente de Claude + tool definitions + prompt templates
ui/
layout/ # Icon rail, sidebar containers, canvas stage
sidebars/ # CodeSidebar, ControlsSidebar, AgentSidebar, RegistrySidebar
controls/ # Slider, ColorPicker, XYPad, Knob, Toggle (widgets individuales)
components/ # shadcn/ui imports
store/ # Zustand stores (uno dedicado a layout)
storage/ # localStorage wrapper + schema
seed/ # Shaders y funciones de ejemplo que se cargan la primera vez
App.tsx
main.tsx
```
---
## 2. Layout de la aplicación (sistema de sidebars)
### Principios
- **El canvas del preview es siempre el protagonista visual.** Ocupa el área central y nunca se reduce a menos de ~60% del viewport salvo en layouts atípicos. Nada de mandarlo a un rincón.
- **Dos sidebars visibles a la vez** como configuración por defecto: uno a la izquierda (típicamente el Code), uno a la derecha (típicamente los Controls). El resto se invocan cuando hacen falta y reemplazan al que esté en ese lado.
- **Sidebars acoplados a los bordes**, no flotantes ni arrastrables. Simplicidad > flexibilidad en MVP.
- **Ancho de sidebar arrastrable** (min 240px, max ~600px), persistido por sidebar.
- **Toggle suave** (show/hide con animación corta, 150ms).
### Zonas y componentes
```
┌──┬────────────────────┬────────────────────────┬────────────────────┐
│ │ │ │ │
│ │ │ │ │
│I │ Left sidebar │ Canvas (preview) │ Right sidebar │
│c │ (CODE o │ WebGL2 fullscreen │ (CONTROLS o │
│o │ REGISTRY) │ quad │ AGENT) │
│n │ │ │ │
│ │ │ │ │
│R │ │ │ │
│a │ │ │ │
│i │ │ │ │
│l │ │ │ │
│ │ │ │ │
└──┴────────────────────┴────────────────────────┴────────────────────┘
```
### Icon rail (columna vertical fija, siempre visible)
Ancho ~48px en el borde izquierdo. Iconos verticales con `lucide-react`:
- 📄 **Code** (ícono `FileCode2`) — toggle del CodeSidebar.
- 🎛️ **Controls** (ícono `Sliders`) — toggle del ControlsSidebar.
- 💬 **Agent** (ícono `Sparkles` o `MessageSquare`) — toggle del AgentSidebar.
- 📚 **Registry** (ícono `Library` o `BookOpen`) — toggle del RegistrySidebar.
- ─── separador ───
- 💾 **Shaders** (ícono `Save`) — abre el panel de shaders guardados (también es un sidebar, en el lado opuesto al que esté libre).
- ⚙️ **Settings** (ícono `Settings`) — abre modal de settings (API key, modelo, tema).
- ⏏️ **Fullscreen** (ícono `Maximize2`) — entra en modo fullscreen VJ.
Cada botón del rail muestra un indicador visual si su sidebar está activo (punto de color al lado del icono, o fondo resaltado).
### Reglas de apertura de sidebars
Cada sidebar tiene un "lado preferido":
- `CODE` → izquierda (preferente).
- `CONTROLS` → derecha (preferente).
- `AGENT` → derecha (preferente).
- `REGISTRY` → izquierda (preferente).
- `SHADERS` → izquierda (preferente).
Al pulsar el icono:
1. Si ese sidebar ya está abierto → cerrarlo.
2. Si no está abierto → abrirlo en su lado preferido, sustituyendo lo que hubiera en ese lado.
3. Modificador `Alt + click` sobre el icono → abrirlo en el lado opuesto (forzar).
Solo puede haber **un sidebar por lado**. No se apilan, no hay tabs superpuestos en el MVP.
### Estado del layout en Zustand
```ts
type SidebarId = 'code' | 'controls' | 'agent' | 'registry' | 'shaders';
type Side = 'left' | 'right';
interface LayoutState {
sidebars: {
left: SidebarId | null;
right: SidebarId | null;
};
widths: Record<Side, number>; // px, persistido
fullscreen: boolean;
toggle: (id: SidebarId, opts?: { forceSide?: Side }) => void;
close: (side: Side) => void;
setWidth: (side: Side, width: number) => void;
enterFullscreen: () => void;
exitFullscreen: () => void;
}
```
### Layout por defecto al primer arranque
```
Left: CODE
Right: CONTROLS
```
Esto es el "layout trabajo": editor + controles en vivo alrededor del canvas. Persiste en localStorage.
### Modo fullscreen (modo VJ)
- Icon rail, sidebars y topbar desaparecen completamente.
- Canvas ocupa el 100% del viewport.
- Atajos siguen funcionando: `F` o `Esc` salen.
- Teclas `1..9` cargan shaders guardados por índice (útil para directo).
- **Overlay botón transparente** en esquina inferior derecha (icono `Maximize2` inverso, solo visible al mover el ratón en los últimos ~2 segundos, fade-out después). Click → salida de fullscreen. Esto es el "como en Resolume": para cuando estás tocando y quieres volver sin buscar tecla.
- Para ajustar uniforms en fullscreen sin salir, el camino es salir con `Esc`, ajustar, y volver a `F`. El MVP no tiene edge panels translúcidos — eso se evaluará en v2 tras uso real.
### Topbar (fuera de fullscreen)
Encima del canvas, arriba del todo, ~40px de alto:
- Izq: nombre del shader actual (editable en línea con doble click).
- Centro: botones Play / Pause / Reset time.
- Der: indicador de compile (verde OK / rojo con `error on line X`), botón nuevo, selector de shader (dropdown).
---
## 3. Contenido de cada sidebar
### CODE sidebar
- Header: título "Code" + indicador de modo actual (sidebar / overlay).
- Body: CodeMirror 6 con GLSL syntax highlight, números de línea, error underlining.
- Footer: info line con bytes, líneas, último compile time, estado (OK / Error línea N).
**Dos modos de visualización, elegibles en Settings:**
1. **Sidebar mode** (default): el editor vive acoplado al borde izquierdo, como cualquier otro sidebar. Coexiste con el preview y los controles. Es el modo "trabajo".
2. **Overlay mode** (estilo apps VJ): al activar CODE, en lugar de abrir un sidebar, aparece un **modal semitransparente** (70% opacidad, fondo oscurecido) flotante sobre el canvas. El canvas sigue renderizando por debajo. `Esc` o click fuera cierra el modal. Es el modo "live coding / VJ" donde el canvas al máximo es lo importante y el código es una ventana que aparece y desaparece.
La elección vive en el modal de Settings (ver §9). El usuario puede cambiar entre modos en cualquier momento. El comportamiento del icono "Code" en el rail se adapta automáticamente: en sidebar mode abre el sidebar, en overlay mode abre el modal.
**Atajo `Cmd/Ctrl + /`** — independientemente del modo configurado, abre el editor en overlay temporalmente. Útil para un vistazo rápido sin cambiar preferencia.
### CONTROLS sidebar
- Header: "Controls" + botón "Reset to defaults" (devuelve todos los uniforms a su `default` del shader actual).
- Body: lista vertical de widgets autogenerados desde los uniforms anotados. Cada uniform es una "card" con:
- Nombre del uniform.
- Widget (Slider / ColorPicker / XYPad / Knob / Toggle / Slider2D).
- Valor actual formateado numéricamente.
- Footer: contador "N uniforms detected".
- Scroll vertical si no caben.
Si el shader no tiene uniforms anotados: mensaje placeholder *"Declare uniforms with `// @slider ...` annotations to see controls here."* con un link "See annotation format" que abre un popover con ejemplos.
### AGENT sidebar
- Header: "Agent" + selector de modelo (dropdown: Opus/Sonnet/Haiku) + botón "Clear conversation".
- Body: lista de mensajes con markdown rendering, bloques de código GLSL con highlight, colapsables para `tool_use` y `tool_result` ("🔧 Searched registry: 3 results").
- Botón "Apply this shader" junto a bloques de código en respuestas del LLM (ver §7).
- Below body: chips con prompts de demostración.
- Footer: textarea de input multilínea, `Cmd/Ctrl + Enter` envía, `Shift + Enter` nueva línea.
### REGISTRY sidebar
- Header: "Functions" + input de búsqueda (filtra en vivo por name/description/tags).
- Body: lista de funciones registradas. Cada item:
- Nombre + signature.
- Tags como pills (color distinto por tag).
- Descripción corta (2 líneas max, truncada).
- Acciones hover: "Insert into current shader" (añade al `@registry_inject_begin/end` markers), "View code" (expande inline), "Edit", "Delete".
- Footer: botón "+ New function" (abre modal de creación), "Import/Export JSON".
### SHADERS sidebar
- Header: "Saved shaders" + botón "+ New".
- Body: lista de shaders guardados. Cada item:
- Thumbnail pequeño (64x36 px) generado rasterizando el shader en un canvas offscreen al guardar.
- Nombre + fecha de última edición.
- Acciones hover: "Load", "Rename", "Duplicate", "Delete", "Export".
- Search por nombre en la cabecera.
Los thumbnails son una mejora visual importante para VJing (reconocimiento instantáneo). Si no da tiempo, fallback a iconos/gradientes generados deterministicamente desde el nombre (tipo GitHub identicons).
---
## 4. Invocación de sidebars
### Canal principal: icon rail
El **icon rail vertical permanente** en el borde izquierdo es el único camino de descubrimiento y uso habitual. Siempre visible (excepto en fullscreen VJ). Click en el icono → toggle del sidebar. Alt+click → abrir en lado opuesto al preferido.
El rail es la fuente de verdad del layout: cualquier usuario, sin leer documentación, sabe qué sidebars existen y puede abrirlos con un click.
### Atajos de teclado (para usuarios avanzados)
Existen pero no son el canal principal — duplican funcionalidad del rail para quien quiera manos en el teclado:
- `F1..F5` — toggle de cada sidebar (ver §11 para la tabla completa).
- `Cmd/Ctrl + B` — colapsar ambos sidebars (canvas máximo sin fullscreen).
- `F` — fullscreen VJ.
- `Esc` — cerrar lo que esté abierto en cascada.
### Modo VJ: botón flotante para salir de fullscreen
En modo fullscreen, al mover el ratón aparece un único botón flotante translúcido en la esquina inferior derecha para salir. Fade-out tras 2s. No hay más invocaciones flotantes — todo lo demás via `Esc` o teclas de atajo.
---
## 5. Renderer (WebGL2 puro)
### Fullscreen quad fijo
Vertex shader no editable:
```glsl
#version 300 es
in vec2 a_position;
void main() {
gl_Position = vec4(a_position, 0.0, 1.0);
}
```
Geometría: dos triángulos cubriendo `[-1,1]²`.
### Fragment shader — lo escribe el usuario
El wrapper antepone automáticamente:
```glsl
#version 300 es
precision highp float;
out vec4 fragColor;
uniform vec2 u_resolution;
uniform float u_time;
uniform vec2 u_mouse;
```
Estos tres uniforms siempre están disponibles, el parser los ignora (no aparecen en controls).
### Render loop
- `requestAnimationFrame`, `performance.now()``u_time` en segundos.
- Play/pause congela/descongela `u_time`.
- Reset pone `u_time = 0`.
- Canvas se redimensiona vía `ResizeObserver` al tamaño del stage central (cambia cuando se abren/cierran sidebars).
### Compile pipeline
- Al cambiar source: debounce 250 ms.
- `gl.createShader``gl.shaderSource``gl.compileShader`.
- Si `COMPILE_STATUS` es false: `gl.getShaderInfoLog()`, parsear línea (formato `ERROR: 0:<line>: <msg>`), propagar al editor.
- Si compila: link program, swap atómico con el anterior, delete del anterior.
- Si el programa nuevo falla: mantener el anterior, canvas NUNCA se queda en negro por un error.
- Introspección post-link: `gl.getProgramParameter(program, gl.ACTIVE_UNIFORMS)` para validar que el parser encontró lo mismo que GLSL realmente expone. Discrepancias → warning en consola (no error).
### Wrapper API
```ts
interface Renderer {
compile(source: string): Promise<CompileResult>;
setUniform(name: string, value: number | number[] | boolean): void;
setPlaying(playing: boolean): void;
resetTime(): void;
resize(w: number, h: number): void;
snapshot(w: number, h: number): Promise<ImageBitmap>; // para thumbnails
dispose(): void;
}
type CompileResult =
| { ok: true; activeUniforms: string[] }
| { ok: false; line: number; message: string };
```
---
## 6. Parser de uniforms
### Formato de anotación
```glsl
uniform float u_speed; // @slider min=0 max=5 default=1
uniform float u_freq; // @slider min=0.1 max=100 default=10 log=true
uniform vec3 u_colorA; // @color default=0.1,0.2,0.5
uniform vec4 u_tint; // @color default=1,0.5,0,1
uniform vec2 u_origin; // @xy min=-1 max=1 default=0,0
uniform vec2 u_offset; // @slider2d min=-10,-10 max=10,10 default=0,0
uniform float u_angle; // @knob min=0 max=6.283 default=0
uniform int u_iter; // @slider min=1 max=50 default=10 step=1
uniform bool u_debug; // @toggle default=false
```
### Algoritmo (regex, suficiente para MVP)
Para cada línea:
1. Match `^\s*uniform\s+(\w+)\s+(\w+)\s*;\s*(?:\/\/\s*@(\w+)(.*))?$`
2. Grupo 1: tipo GLSL. Grupo 2: nombre. Grupo 3: widget kind (opcional). Grupo 4: resto de props.
3. Si no hay widget kind, usar defaults:
- `float``slider(min=0, max=1, default=0)`
- `vec2``xy(min=0,0 max=1,1 default=0.5,0.5)`
- `vec3``color(default=1,1,1)`
- `vec4``color(default=1,1,1,1)`
- `int``slider(step=1, min=0, max=10, default=0)`
- `bool``toggle(default=false)`
4. Parse props `key=value` separados por whitespace. Números: `parseFloat`. Vectores: split por `,`. Bools: `"true"/"false"`.
5. Ignorar uniforms con nombre en `{u_resolution, u_time, u_mouse}` (reservados).
6. Ignorar uniforms cuyo tipo sea `sampler2D` (no soportados, warning en consola).
### Tipos TS
```ts
type GLSLType = 'float' | 'vec2' | 'vec3' | 'vec4' | 'int' | 'bool';
type WidgetKind = 'slider' | 'slider2d' | 'color' | 'xy' | 'knob' | 'toggle';
interface UniformDescriptor {
name: string;
glslType: GLSLType;
widget: WidgetKind;
props: Record<string, number | number[] | boolean>;
defaultValue: number | number[] | boolean;
}
type ParseResult = { uniforms: UniformDescriptor[]; warnings: string[] };
```
### Tests (Vitest, en `src/parser/parser.test.ts`)
1. Uniform sin anotación → defaults por tipo.
2. `@slider min=0 max=10 default=5`.
3. `@color default=1,0,0` sobre `vec3`.
4. `@xy default=0.5,0.5` sobre `vec2`.
5. Uniforms reservados ignorados.
6. Comentario malformado → fallback a defaults, warning.
7. Múltiples uniforms en el mismo source, orden preservado.
8. Comentarios de bloque `/* ... */` no interfieren.
---
## 7. Widgets de control
Todos reciben `{value, onChange, descriptor}`, **controlados**, sin estado interno.
### Slider (float, int)
- shadcn/ui `<Slider>`.
- Label con valor numérico, click para editar.
- Si `log=true`: mapeo logarítmico.
- Si `step` definido: respetarlo.
### ColorPicker (vec3, vec4)
- `react-colorful` `RgbaColorPicker`.
- Almacena internamente como array `[r,g,b]` o `[r,g,b,a]` en `[0,1]`.
### XYPad (vec2 con @xy)
- Cuadrado ~150×150 px.
- Drag con `pointerdown/move/up`.
- Mapea `[0,1]²` del DOM a `[min.x, max.x] × [min.y, max.y]` con Y invertida.
- Valores numéricos debajo.
### Slider2D (vec2 con @slider2d)
- Dos sliders apilados con labels `x` / `y`.
### Knob (float con @knob)
- Círculo SVG con marca.
- Drag vertical u horizontal cambia el valor.
- Visual distinto al slider para que se distinga.
### Toggle (bool)
- shadcn/ui `<Switch>`.
---
## 8. fn-registry (diseñado para ser usado por el LLM)
Biblioteca mínima de funciones GLSL reutilizables, guardadas en `localStorage` y consultables tanto por el usuario (REGISTRY sidebar) como por el LLM (vía tool use).
### Modelo de datos
```ts
interface RegisteredFunction {
id: string; // nanoid
name: string; // nombre GLSL, e.g. "hash12"
signature: string; // "float hash12(vec2 p)"
description: string; // 1-2 frases: qué hace
tags: string[]; // ["noise", "hash"]
body: string; // cuerpo GLSL completo (función entera, firma incluida)
dependencies: string[]; // nombres de otras funciones del registry que usa
createdAt: number;
updatedAt: number;
}
```
### Operaciones
- `list()``RegisteredFunction[]`.
- `search(query: string)``RegisteredFunction[]` (match en name, description, tags).
- `get(name: string)``RegisteredFunction | null`.
- `save(fn: RegisteredFunction)` → upsert.
- `delete(id: string)` → void.
- `resolveDependencies(names: string[])` → devuelve el conjunto cerrado transitivo ordenado topológicamente.
### Inyección en el shader actual
Se usa el patrón de **markers**: el shader tiene un bloque marker, y el renderer — antes de compilar — reemplaza el contenido entre markers con el cuerpo de las funciones declaradas más sus dependencias transitivas.
```glsl
// @registry_inject_begin
// hash12, perlin2d, rotate2d
// @registry_inject_end
void main() { ... }
```
Si el usuario edita dentro del bloque manualmente, se regenera al guardar (con confirmación si hay cambios).
### Semilla inicial (seed)
Cargar ~15 funciones clásicas la primera vez que se abre la app. Mínimo:
- `hash11`, `hash12`, `hash22` (hashes deterministas sin `sin()`).
- `value_noise_2d`, `perlin_noise_2d`, `simplex_noise_2d`.
- `fbm` (fractal brownian motion).
- `rotate2d`.
- `sdf_circle`, `sdf_box`, `sdf_line`.
- `smoothmin`.
- `palette` (Inigo Quilez cosine palette).
- `hsv2rgb`, `rgb2hsv`.
Cada una con `tags` y `description` relevantes para que el LLM pueda buscarlas semánticamente.
### Panel Functions (ya descrito en §3 como REGISTRY sidebar)
---
## 9. Integración con Claude (LLM)
### Configuración
- Usuario pega su API key en un modal de Settings → se guarda en `localStorage` (warning claro: "se guarda en local, no la uses en ordenadores compartidos").
- Selector de modelo: `claude-opus-4-7` (default, mejor calidad), `claude-sonnet-4-6` (más rápido), `claude-haiku-4-5-20251001` (muy rápido, para iteración).
### Cliente
- `@anthropic-ai/sdk` con `dangerouslyAllowBrowser: true`.
- Streaming siempre activo.
- Historial de conversación persistido en `localStorage` (último N mensajes, truncable).
### System prompt (template)
```
You are a creative shader programmer helping the user write WebGL2 fragment shaders for visual art and VJing.
The host environment provides these uniforms automatically — never redeclare them:
uniform vec2 u_resolution;
uniform float u_time;
uniform vec2 u_mouse;
The target is WebGL2 / GLSL ES 3.00. Use `fragColor` as the output (it's predeclared as `out vec4 fragColor`). The `#version 300 es` directive is prepended automatically — don't include it.
When you declare uniforms the user should be able to tweak, annotate them with a magic comment so the UI generates a control automatically. Supported annotations:
uniform float u_speed; // @slider min=0 max=5 default=1
uniform float u_freq; // @slider min=0.1 max=100 default=10 log=true
uniform vec3 u_color; // @color default=0.1,0.2,0.5
uniform vec4 u_tint; // @color default=1,1,1,1
uniform vec2 u_pos; // @xy min=-1 max=1 default=0,0
uniform float u_angle; // @knob min=0 max=6.283 default=0
uniform bool u_debug; // @toggle default=false
Guidelines:
- Prefer to REUSE functions from the registry when possible. You have tools to search and insert registry functions.
- Keep shaders self-contained and working on first compile.
- Use functional style: pure functions, no side effects inside helpers, compose via explicit parameters.
- When producing a complete shader, annotate uniforms the user is likely to want to tweak live.
- Prefer hash functions that don't rely on `sin()` (use hash12/hash22 from the registry).
- If the user asks for a modification, return the full updated shader via apply_shader, not a diff.
- Keep aspect-ratio correctness in mind: use `(gl_FragCoord.xy - 0.5*u_resolution.xy) / u_resolution.y` for centered, non-stretched coordinates unless a different framing is asked for.
Tools available:
- search_registry(query): find functions by name/description/tags.
- get_function(name): retrieve a function's full body.
- list_registry(): list all available function names and signatures.
- apply_shader(source): replace the user's current shader with this source. Use this when the user explicitly asks you to generate or modify their shader.
- save_function({name, signature, description, tags, body, dependencies}): add a function to the registry.
```
### Tools (Anthropic tool use)
```ts
const tools = [
{
name: 'search_registry',
description: 'Search for reusable GLSL functions in the local registry by name, description, or tags.',
input_schema: { type: 'object', properties: { query: { type: 'string' } }, required: ['query'] },
},
{
name: 'get_function',
description: 'Retrieve the full body of a registered function by name.',
input_schema: { type: 'object', properties: { name: { type: 'string' } }, required: ['name'] },
},
{
name: 'list_registry',
description: 'List all functions in the registry with their signatures and tags.',
input_schema: { type: 'object', properties: {} },
},
{
name: 'apply_shader',
description: 'Replace the user\'s current fragment shader with new source. Use when the user asks to generate or modify their shader.',
input_schema: { type: 'object', properties: { source: { type: 'string' } }, required: ['source'] },
},
{
name: 'save_function',
description: 'Save a reusable GLSL function to the registry so it can be used in future shaders.',
input_schema: {
type: 'object',
properties: {
name: { type: 'string' },
signature: { type: 'string' },
description: { type: 'string' },
tags: { type: 'array', items: { type: 'string' } },
body: { type: 'string' },
dependencies: { type: 'array', items: { type: 'string' } },
},
required: ['name', 'signature', 'body'],
},
},
];
```
### Loop de tool use
Loop estándar de Anthropic: mandar mensaje con `tools`, si `stop_reason === 'tool_use'` ejecutar las tools, añadir resultados como `tool_result`, volver a llamar al API, repetir hasta `stop_reason === 'end_turn'`.
Las tools son **locales y síncronas**: todas tocan `localStorage` o el store Zustand. No hay red más allá de la llamada al API de Anthropic.
### Confirmación antes de aplicar
- `apply_shader` **no reemplaza silenciosamente** el código actual. Muestra un diff side-by-side y el usuario confirma. Crítico para que el LLM no borre trabajo del usuario.
- `save_function` aplica directamente (es aditivo, no destructivo), pero muestra toast con undo.
### Prompts de demostración (chips en el AgentSidebar)
- "Make a lava lamp shader"
- "Add audio-reactive colors using u_time"
- "Refactor the current shader to use registry functions"
- "Explain what this shader does line by line"
- "Create a kaleidoscope with 8 segments"
---
## 10. Persistencia (localStorage)
### Schema
```ts
interface StorageSchema {
version: 1;
currentShader: string; // id del shader actualmente cargado
shaders: Record<string, {
id: string;
name: string;
source: string;
uniformValues: Record<string, unknown>;
thumbnail?: string; // dataURL pequeño para mostrar en SHADERS sidebar
updatedAt: number;
}>;
functions: Record<string, RegisteredFunction>;
conversations: Array<{
id: string;
messages: Array<{ role: string; content: unknown }>;
updatedAt: number;
}>;
settings: {
apiKey: string | null;
model: string;
theme: 'dark' | 'light';
codeMode: 'sidebar' | 'overlay'; // modo del editor: acoplado o flotante
};
layout: {
sidebars: { left: string | null; right: string | null };
widths: { left: number; right: number };
};
}
```
### Claves
- Una única clave root: `shader-playground:v1`.
- Migraciones: si se encuentra `version < 1` o key vieja, crear backup con sufijo timestamp y regenerar.
- Debounce 500 ms en todas las escrituras.
- API key jamás incluida en exports/imports manuales del usuario.
---
## 11. Atajos de teclado (completos)
### Sidebars
- `F1` — toggle CODE sidebar.
- `F2` — toggle CONTROLS sidebar.
- `F3` — toggle AGENT sidebar.
- `F4` — toggle REGISTRY sidebar.
- `F5` — toggle SHADERS sidebar.
- `Cmd/Ctrl + B` — colapsar ambos sidebars (canvas máximo, no fullscreen).
- `Cmd/Ctrl + /` — abrir CODE como modal overlay sobre canvas.
### Render / modo VJ
- `F` — toggle fullscreen VJ.
- `Esc` — cerrar modal → cerrar sidebars → salir fullscreen (en cascada).
- `Space` (fuera del editor) — play/pause.
- `Cmd/Ctrl + R` — reset time.
- `1..9` (en fullscreen) — cargar shader guardado N.
### Trabajo
- `Cmd/Ctrl + S` — guardar snapshot inmediato.
- `Cmd/Ctrl + Enter` — forzar recompile (desde editor) o enviar mensaje (desde chat).
- `Cmd/Ctrl + K` — focus en chat input (abre AGENT si está cerrado).
---
## 12. Criterios de aceptación
### Fase A — Sábado (core + layout)
- [ ] Editor GLSL funcional con CodeMirror y highlight.
- [ ] WebGL2 renderer con fullscreen quad, hot-recompile con debounce.
- [ ] Error de compilación con línea, canvas mantiene el último válido.
- [ ] Parser de uniforms con todas las anotaciones funcionando.
- [ ] 6 widgets de control conectados.
- [ ] Icon rail visible con 6-7 iconos y toggles funcionales.
- [ ] Los 4 sidebars principales (CODE, CONTROLS, SHADERS, settings modal) funcionan con toggle y ancho redimensionable.
- [ ] Layout default (CODE izq + CONTROLS der) al primer arranque.
- [ ] Setting `codeMode` (sidebar / overlay) funciona: al cambiarlo en Settings, el icono "Code" del rail abre el editor en el modo elegido.
- [ ] Estado del layout persiste en localStorage.
- [ ] Cambiar un slider actualiza el render sin lag.
- [ ] Persistencia: recarga la página y vuelve todo igual, incluidos qué sidebars estaban abiertos.
- [ ] 4 shaders de ejemplo cargan correctamente.
- [ ] Fullscreen funciona con `F` y `Esc`, icon rail y sidebars desaparecen limpiamente.
- [ ] Modo "code overlay" funciona cuando está activado en Settings: el icono "Code" abre un modal flotante sobre el canvas en vez del sidebar.
- [ ] Tests del parser pasan.
**Si la fase A está completa y funciona, ya hay algo usable.** La fase B es aditiva.
### Fase B — Domingo (LLM + registry)
- [ ] REGISTRY sidebar con búsqueda, lista filtrable, acciones "insert / view / edit / delete".
- [ ] Seed inicial de ~15 funciones cargadas al primer arranque.
- [ ] Markers `@registry_inject_begin/end` funcionan, se reemplazan antes de compilar.
- [ ] Modal de Settings para API key de Claude.
- [ ] AGENT sidebar con chat funcional, streaming visible.
- [ ] Tool use funcional (`search_registry`, `get_function`, `list_registry`, `apply_shader`, `save_function`).
- [ ] `apply_shader` muestra diff y pide confirmación.
- [ ] Chips de prompts de demostración.
- [ ] Conversación persiste entre recargas.
### Features opcionales (solo si sobra tiempo)
- [ ] Thumbnails generados en el SHADERS sidebar.
- [ ] Botón overlay flotante en fullscreen para salir (si no, queda `Esc`).
- [ ] Export/import del registry como JSON.
### Criterio global
- [ ] Puedo usar la app para: (1) pedirle a Claude "haz un shader de nubes con double domain warping, guardado como función en el registry", (2) verlo aparecer en el REGISTRY sidebar, (3) abrir un shader nuevo en blanco, (4) pedirle "carga la función de nubes del registry y úsala con una paleta roja-naranja", (5) ajustar los sliders que aparezcan automáticamente, (6) guardarlo, (7) entrar en fullscreen, (8) recargar la página y seguir teniendo todo. **Si este flujo end-to-end no funciona, el MVP no está hecho.**
---
## 13. Orden de implementación
### Sábado (Fase A)
1. Scaffold: Bun init, Vite, React, Tailwind, shadcn CLI, Zustand. Layout con icon rail + stage central + sidebar containers vacíos.
2. WebGL2 wrapper: fullscreen quad, fragment shader hardcoded sólido. Verifica render.
3. CodeMirror con GLSL en CODE sidebar. Source en store. Recompile al cambiar (debounce).
4. Error handling: parse del infoLog, display en editor.
5. Layout store: toggle de sidebars, reglas de lado preferido, persistencia del layout.
6. Parser de uniforms + tests.
7. Store de uniformValues sincronizado con descriptors (diff al cambiar shader).
8. CONTROLS sidebar renderizando widgets autogenerados.
9. Widgets uno a uno: Slider → Toggle → Color → XY → Knob → Slider2D. Cada uno end-to-end.
10. Persistencia localStorage con debounce (shaders + layout + uniformValues).
11. SHADERS sidebar: lista, guardar/cargar/renombrar/duplicar.
12. Shaders de ejemplo en seed.
13. Fullscreen + atajos de teclado + setting `codeMode` con ambas variantes (sidebar y overlay).
### Domingo (Fase B)
14. fn-registry: modelo, CRUD, búsqueda, seed, tests.
15. Markers `@registry_inject_begin/end` y preprocesado antes de compilar.
16. REGISTRY sidebar con búsqueda y acciones.
17. Settings modal con API key.
18. Cliente Claude con streaming (sin tools).
19. AGENT sidebar: chat, markdown rendering, persistencia.
20. Tool definitions y loop de tool use.
21. Diff + confirmación para `apply_shader`.
22. Chips de prompts de demostración.
23. Pulido visual, toasts, mensajes de error amigables.
### Qué sacrificar si algo se alarga (en orden, de menos a más crítico)
1. Thumbnails del SHADERS sidebar (fallback: icon/gradient generado desde el nombre).
2. Botón flotante de salida de fullscreen (queda solo `Esc`).
3. `Slider2D` y `Knob` (los `vec2` usan XY, los `float` normal Slider).
4. Setting `codeMode` — dejar solo modo sidebar; overlay va a v2.
5. Modal de creación de función en registry (solo insert, edit a mano en JSON).
6. Chips de prompts de demostración.
7. Markers `@registry_inject_*` (el LLM pega código directo).
---
## 14. Calidad de código
- TypeScript strict mode, `noImplicitAny`, `strictNullChecks`.
- Módulos `parser/`, `renderer/`, `registry/`, `llm/` son **puros y testables sin DOM ni React**.
- Widgets reciben `value`/`onChange` y son ignorantes del store (el puente se hace en `ControlsSidebar`).
- Render loop NO pasa por React. Subscribe al store de Zustand y lee valores directamente cada frame.
- Cada sidebar es un componente React autocontenido que lee/escribe en su slice del store.
- Vitest para tests del parser y del registry (resolver dependencias transitivas).
- Prettier + ESLint básicos, sin fanatismo.
- Commits en imperativo corto ("Add icon rail", "Wire AGENT sidebar to Claude client").
---
## 15. Lo que NO hay que hacer aunque apetezca
- No añadir audio / MIDI en el MVP.
- No añadir multi-pass "porque es solo un buffer más".
- No refactorizar los widgets a una abstracción genérica antes de tener los 6 implementados.
- No hacer los sidebars flotantes/arrastrables tipo Ableton/Resolume. Siempre acoplados a bordes.
- No permitir más de un sidebar por lado en MVP. Si quieres ver REGISTRY y CONTROLS a la vez, abres uno en cada lado. No tabs apilados.
- No meter un sistema de plugins.
- No añadir LangChain, vector DBs, ni RAG. La tool `search_registry` es un string match simple y es suficiente.
- No hacer backend en Go para persistir "cuando sea". Todo en `localStorage` hasta que duela de verdad.
- No soportar vertex shaders custom.
- No soportar múltiples shaders simultáneos con crossfade. Un shader activo, fullscreen, listo.
Cada una de estas es un día comido y medio MVP menos. Después del MVP y de un mes usándolo de verdad, vuelvo a mirar qué duele y decido.
---
## 16. Referencias útiles
- The Book of Shaders: https://thebookofshaders.com
- Shadertoy: https://www.shadertoy.com
- ISF (precedente de las anotaciones): https://isf.video
- Dave Hoskins "Hash without Sine": https://www.shadertoy.com/view/4djSRW
- Inigo Quilez articles: https://iquilezles.org/articles/
- Resolume / VDMX / KodeLife (referencias de UX de VJing): cómo los sidebars se esconden y la importancia del canvas central.
- Claude API docs: https://docs.claude.com
- `@anthropic-ai/sdk` con navegador: usar `dangerouslyAllowBrowser: true`, warning explícito en la UI sobre la exposición de la API key.
@@ -0,0 +1,254 @@
# Shader DAG Lab — Arquitectura y hoja de ruta
Este documento resume el diseño acumulado tras iterar en artifacts desde
"composición funcional sobre listas" hasta "DAG de shaders WebGPU con fan-in".
Sirve como contexto para retomar el proyecto en Claude Code sin perder las
decisiones de diseño.
## El problema que resuelve
Un entorno para componer fragment shaders WGSL visualmente: el usuario arrastra
"nodos" (primitivas shader) desde una paleta a un pipeline, configura
parámetros con sliders / XY pads / color pickers, y el sistema compila el DAG
resultante a un único fragment shader ejecutado en WebGPU.
Las dos vistas — grafo y código — son proyecciones del mismo modelo interno.
El usuario casual arrastra cajas; el usuario avanzado lee el WGSL generado.
## Arquitectura en una frase
```
Pipeline state (árbol JSON)
↓ compileDagToWGSL()
WGSL source
↓ device.createShaderModule()
GPU pipeline
↓ render loop, escribe uniforms cada frame
Canvas
```
Separación crítica: la **topología** del DAG (qué nodos, en qué orden, con qué
aristas) dispara recompilación del shader. Los **valores de parámetros** NO
disparan recompilación — solo se escriben al uniform buffer cada frame. Esto
hace que mover un slider sea instantáneo mientras que añadir/quitar nodos
paga el coste de compilar un nuevo pipeline.
## Modelo de datos
Un `step` del pipeline es:
```ts
type Step = {
id: string; // UUID estable (sobrevive reorders)
name: string; // clave en el catálogo NODES
params: { // valores de parámetros editables
[key: string]: number
};
meta?: { // metadatos que afectan compilación
sourceId?: string; // para blends: id del otro nodo fuente
};
};
```
El `topologyKey` que dispara recompilación se computa como:
```
pipeline.map(s => `${s.name}:${s.meta?.sourceId ?? ''}`).join('|')
```
## Catálogo de nodos
Cada entrada en `NODES` declara:
```ts
{
kind: 'gen' | 'op' | 'blend' | 'warp' | 'sdf' | 'filter' | 'modulator',
label: string,
desc: string,
params: [{ k: string, d: number }], // hasta 4 slots del vec4<f32>
controls: Control[], // descriptores de UI
body: (idx: number) => string, // emite el cuerpo WGSL
}
```
### Tipos de control UI actualmente soportados
- `slider`: rango numérico con thumb
- `xy`: pad 2D que controla dos params contiguos
- `color`: picker RGB que controla tres params contiguos
- `select`: dropdown con opciones discretas (valor = índice)
- `source`: selector del nodo fuente para blends (escribe a `meta.sourceId`)
## Compilación del DAG a WGSL
`compileDagToWGSL(pipeline)` emite un shader con esta estructura:
```wgsl
struct Uniforms {
time: f32,
_pad: f32,
resolution: vec2<f32>,
params: array<vec4<f32>, 16>,
};
@group(0) @binding(0) var<uniform> u: Uniforms;
@vertex fn vs(...) { /* fullscreen triangle */ }
// Una función por nodo, nombrada node_<idx>
fn node_0(c: vec4<f32>, uv: vec2<f32>) -> vec4<f32> { ... }
fn node_1(a: vec4<f32>, b: vec4<f32>, uv: vec2<f32>) -> vec4<f32> { ... } // blend
@fragment fn fs(@builtin(position) pos: vec4<f32>) -> @location(0) vec4<f32> {
let uv = pos.xy / u.resolution;
let out_0 = node_0(vec4<f32>(0.0), uv);
let out_1 = node_1(out_0, out_0, uv); // blend con source=out_0
return out_1;
}
```
Los outputs intermedios `out_<i>` se preservan como variables locales para
que los blends puedan referenciar nodos anteriores arbitrarios. Esto es lo
que hace que el DAG sea más que un pipeline lineal.
## Estado actual de tipos de nodo
**Implementados (lab 004):**
- `gen`: solid, gradient, plasma, checker, circle, stripes, noise (hash)
- `op`: invert, gamma, contrast, saturate, hueShift, tint, posterize, vignette, ripple, pulse
- `blend`: mix, multiply, screen, add, difference, darken, lighten, mask
**Pendientes de implementar (ver hoja de ruta abajo):**
- `warp`: distorsiones de UV (twirl, polar, kaleidoscope, pixelate, chromatic)
- `sdf`: campos de distancia con compositing (smooth_union, subtract, intersect)
- `modulator`: LFOs que producen escalares animados para alimentar parámetros
- Ruidos procedurales reales: perlin, simplex, worley, fbm
- Filtros de luminancia: threshold, levels, duotone, channel_swap
- Inputs externos: mouse como uniform adicional
## Hoja de ruta post-artifact
### Lab 005 — Warps + Ruidos + Filtros de luma + Mouse
Cambios que requiere:
- **Refactor de compilación**: generadores pasan a ser `fn sample_gen_<i>(uv) -> vec4<f32>`
para que los warps puedan modificar uv antes del muestreo.
- La cadena main mantiene dos estados: `uv` (mutable por warps) y `c` (color).
- Nuevo kind `warp` con snippets que transforman `uv`.
- Nuevo uniform `mouse: vec2<f32>` (coords 0..1) actualizado desde pointer events.
- Perlin/FBM como snippets WGSL copiados de implementaciones conocidas
(hash-based gradient noise).
### Lab 006 — Multi-pass (convoluciones + feedback)
Cambio arquitectónico mayor: cada nodo puede escribir a una textura offscreen,
el siguiente samplea esa textura. Requiere:
- Render targets intermedios (pool de texturas)
- Múltiples bind groups
- Double buffer para feedback temporal (frame N+1 lee frame N)
- Detección de qué nodos necesitan aislar su pass y cuáles pueden fusionarse
Desbloquea: blur gaussiano, sobel, edge detection, bloom, reaction-diffusion,
trails, motion blur.
### Lab 007 — SDFs tipados
Introduce heterogeneidad de tipos en las aristas del DAG:
- Aristas de tipo `field` (`f32`) vs `color` (`vec4<f32>`)
- Validación de tipos en compilación: un operador de color no acepta un field
- Nodo terminal `render_sdf` que convierte field → color con shading opciones
(planar, gradient, stroke, inflate/outline)
- Operadores SDF: `smooth_union`, `subtract`, `intersect`, `round`, `onion`
Este es el salto conceptual a "DAG tipado" que formaliza lo que el lab 001
insinuaba (el fold cambiaba de tipo la arista).
### Lab 008 — Bidireccional código ↔ grafo
Hasta ahora solo va grafo → código. El inverso requiere:
- Parser acotado del WGSL que nosotros mismos emitimos (no WGSL general)
- Marcadores en comentarios `// @meta node=<name> id=<id>` para robustez
- Detección de diff estructural para mantener posiciones / parámetros al editar
- Editor de código integrado (CodeMirror) sincronizado con el pipeline
### Lab 009 — Nodos custom definidos por usuario
Modal donde el usuario escribe body WGSL + declara params, y se registra en
la paleta como si viniera del catálogo. Cierra la asimetría código→grafo sin
necesitar parser completo. Persiste en localStorage o export/import JSON.
## Decisiones de diseño que vale la pena recordar
1. **Los IDs de nodo son UUIDs, no índices posicionales**. Las referencias
de `sourceId` sobreviven a reorderings. Los índices se re-derivan en
compilación.
2. **El patrón "armed drag"** para el drag handle: el nodo es `draggable=false`
por defecto y solo se arma a `true` cuando ocurre pointerdown sobre el
header con el handle. Esto evita que los sliders internos activen drag
accidentalmente.
3. **Uniform packing**: todos los parámetros de un nodo van en `u.params[idx]`
(un `vec4<f32>`). Si un nodo necesita más de 4 floats, habría que
reasignar slots o usar dos slots. No hay nodos hasta ahora que lo pidan.
4. **MAX_NODES = 16** es arbitrario, limitado solo por el tamaño del array
de params en el uniform buffer. Subir es trivial: cambia la constante.
5. **Las arbitrary values de Tailwind no funcionan** en algunos entornos
sin JIT. Grid templates, min-h con calc, etc. se escriben con
`style={{...}}` inline. En el proyecto de Claude Code esto no debería
ser problema si usas Tailwind 3+ con su compilador.
## Stack sugerido para Claude Code
- **Vite + React + TypeScript**: setup estándar, HMR inmediato
- **Tailwind 3+** con JIT: los arbitrary values funcionarán esta vez
- **Zustand o Jotai** para el pipeline state (se va a hacer más complejo)
- **Biome o ESLint + Prettier** para formato consistente
- Opcional pero recomendado en cuanto crezca:
- **Vitest** para tests unitarios del compilador (`compileDagToWGSL`)
- **React Testing Library** si tests de UI
- **reactflow** si en lab 008 quieres visualizar el DAG como grafo editable
## Organización de archivos sugerida
```
src/
nodes/
index.ts # export del catálogo completo
generators.ts # gen kind
operators.ts # op kind
blends.ts # blend kind
warps.ts # (lab 005)
sdfs.ts # (lab 007)
types.ts # tipos compartidos NodeDef, Control, etc.
compiler/
compileDagToWGSL.ts # la función principal
uniforms.ts # packing y escritura del buffer
validate.ts # validación de tipos (lab 007+)
webgpu/
useWebGPU.ts # el hook
renderer.ts # setup del device, context, pipeline
ui/
PipelineNode.tsx
controls/
Slider.tsx
XYPad.tsx
ColorPicker.tsx
Select.tsx
SourceSelector.tsx
Palette.tsx
Canvas.tsx
WGSLView.tsx
store/
pipeline.ts # Zustand store
App.tsx
main.tsx
```
## Primeros pasos en Claude Code
1. `npm create vite@latest shader-dag -- --template react-ts`
2. Copiar `shader-dag-blends.jsx` como base monolítica, renombrar a `.tsx`
3. Arreglar los tipos TypeScript (muchas funciones del artifact no están tipadas)
4. Romper el monolito según la estructura de arriba
5. Implementar lab 005: refactor de compilación para habilitar warps
Nota: el artifact de base (`shader-dag-blends.jsx`) funciona pero tiene el
tipado implícito de JSX. Convertir a TS te va a revelar varios tipos que
merece la pena modelar explícitamente (especialmente `Control`, que ahora es
un union discriminado informal).
@@ -0,0 +1,598 @@
import { useState, useMemo, useRef } from 'react';
import { X, Trash2, GripVertical } from 'lucide-react';
// ─────────────────────────────────────────────────────────────────
// Categorías morfológicas — cada una con su identidad visual
// ─────────────────────────────────────────────────────────────────
const CATEGORIES = {
gen: {
label: 'Generadores',
subtitle: 'Anamorfismos',
signature: 'seed → [a]',
color: '#7dd3fc',
},
map: {
label: 'Transformaciones',
subtitle: 'Functor · map',
signature: '(a→b) → [a]→[b]',
color: '#c4b5fd',
},
filter: {
label: 'Filtros',
subtitle: 'Refinamientos',
signature: '[a] → [a]',
color: '#fcd34d',
},
scan: {
label: 'Scans',
subtitle: 'Folds acumulativos',
signature: '[a] → [a]',
color: '#86efac',
},
fold: {
label: 'Plegados',
subtitle: 'Catamorfismos',
signature: '[a] → b',
color: '#fda4af',
},
};
// PRNG determinista para que `random` sea reproducible
function mulberry32(seed) {
let a = seed;
return function() {
a = (a + 0x6D2B79F5) | 0;
let t = a;
t = Math.imul(t ^ (t >>> 15), t | 1);
t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};
}
// ─────────────────────────────────────────────────────────────────
// Catálogo de funciones disponibles
// ─────────────────────────────────────────────────────────────────
const FUNCTIONS = {
// — GENERADORES (anamorfismos: producen estructura desde un seed) —
range: { cat: 'gen', symbol: 'range', sig: 'n → [1..n]', params: [{k:'n',d:20,min:1,max:120}], run: (_,p) => Array.from({length:p.n}, (_,i)=>i+1) },
linspace: { cat: 'gen', symbol: 'linspace', sig: 'n → [0..2π]', params: [{k:'n',d:60,min:2,max:200}], run: (_,p) => Array.from({length:p.n}, (_,i)=>(i/(p.n-1))*2*Math.PI) },
sine: { cat: 'gen', symbol: 'sine', sig: 'n → sin(4π·t)', params: [{k:'n',d:80,min:4,max:200}], run: (_,p) => Array.from({length:p.n}, (_,i)=>Math.sin((i/(p.n-1))*4*Math.PI)) },
noise: { cat: 'gen', symbol: 'noise', sig: 'seed → U(-1,1)', params: [{k:'n',d:50,min:2,max:200},{k:'seed',d:7,min:1,max:9999}], run: (_,p) => { const r=mulberry32(p.seed); return Array.from({length:p.n}, ()=>r()*2-1); } },
fib: { cat: 'gen', symbol: 'fib', sig: 'n → Fibₙ', params: [{k:'n',d:12,min:2,max:25}], run: (_,p) => { const r=[1,1]; while(r.length<p.n) r.push(r[r.length-1]+r[r.length-2]); return r.slice(0,p.n); } },
// MAPS (functor f: ab aplicado punto-a-punto)
double: { cat: 'map', symbol: '·2', sig: 'x 2x', run: (a) => a.map(x=>x*2) },
square: { cat: 'map', symbol: 'x²', sig: 'x ↦ x²', run: (a) => a.map(x=>x*x) },
negate: { cat: 'map', symbol: '-x', sig: 'x ↦ -x', run: (a) => a.map(x=>-x) },
abs: { cat: 'map', symbol: '|x|', sig: 'x ↦ |x|', run: (a) => a.map(x=>Math.abs(x)) },
sqrt: { cat: 'map', symbol: '√', sig: 'x ↦ √|x|', run: (a) => a.map(x=>Math.sqrt(Math.abs(x))) },
sin: { cat: 'map', symbol: 'sin', sig: 'x ↦ sin x', run: (a) => a.map(x=>Math.sin(x)) },
log: { cat: 'map', symbol: 'ln', sig: 'x ↦ ln(1+|x|)', run: (a) => a.map(x=>Math.log(1+Math.abs(x))) },
// — FILTROS (refinamiento) —
positive: { cat: 'filter', symbol: '>0', sig: 'x > 0', run: (a) => a.filter(x=>x>0) },
even: { cat: 'filter', symbol: 'par', sig: 'x even', run: (a) => a.filter(x=>Math.round(x)%2===0) },
gt: { cat: 'filter', symbol: '>t', sig: 'x > t', params: [{k:'t',d:0,min:-50,max:50,step:0.5}], run: (a,p) => a.filter(x=>x>p.t) },
// — SCANS (folds acumulativos: dejan rastro) —
cumsum: { cat: 'scan', symbol: 'Σ*', sig: 'Σ prefix', run: (a) => { let s=0; return a.map(x=>s+=x); } },
cummax: { cat: 'scan', symbol: 'max*', sig: 'max prefix', run: (a) => { let m=-Infinity; return a.map(x=>{m=Math.max(m,x); return m;}); } },
diff: { cat: 'scan', symbol: 'Δ', sig: 'xₙ - xₙ₋₁', run: (a) => a.map((x,i)=>i===0?0:x-a[i-1]) },
mavg: { cat: 'scan', symbol: 'μ_k', sig: 'media móvil k', params: [{k:'k',d:3,min:1,max:15}], run: (a,p) => a.map((_,i)=>{ const s=Math.max(0,i-p.k+1); const sl=a.slice(s,i+1); return sl.reduce((t,v)=>t+v,0)/sl.length; }) },
// — FOLDS (catamorfismos: colapsan a escalar · terminales) —
sum: { cat: 'fold', symbol: 'Σ', sig: '[a] → Σa', run: (a) => a.reduce((s,x)=>s+x, 0) },
product: { cat: 'fold', symbol: '∏', sig: '[a] → ∏a', run: (a) => a.reduce((s,x)=>s*x, 1) },
max: { cat: 'fold', symbol: 'max', sig: '[a] → max', run: (a) => a.length ? Math.max(...a) : 0 },
min: { cat: 'fold', symbol: 'min', sig: '[a] → min', run: (a) => a.length ? Math.min(...a) : 0 },
mean: { cat: 'fold', symbol: 'μ', sig: '[a] → μ', run: (a) => a.length ? a.reduce((s,x)=>s+x,0)/a.length : 0 },
count: { cat: 'fold', symbol: '#', sig: '[a] → ', run: (a) => a.length },
};
const BY_CATEGORY = Object.entries(CATEGORIES).map(([catKey, catMeta]) => ({
...catMeta,
key: catKey,
items: Object.entries(FUNCTIONS).filter(([, f]) => f.cat === catKey).map(([name, f]) => ({ name, ...f })),
}));
// ─────────────────────────────────────────────────────────────────
// Ejecutor: aplica el pipeline y conserva salidas intermedias
// ─────────────────────────────────────────────────────────────────
function executePipeline(pipeline) {
const steps = [];
let current = Array.from({length: 10}, (_, i) => i + 1); // semilla si no hay generador
let terminated = false;
for (const item of pipeline) {
const def = FUNCTIONS[item.name];
if (!def) continue;
if (terminated) { steps.push({ ...item, def, unreachable: true }); continue; }
try {
const value = def.run(current, item.params || {});
steps.push({ ...item, def, value });
if (def.cat === 'fold') terminated = true;
else current = value;
} catch (e) {
steps.push({ ...item, def, error: e.message });
terminated = true;
}
}
return { steps, terminated };
}
// ─────────────────────────────────────────────────────────────────
// Helpers de id y parámetros
// ─────────────────────────────────────────────────────────────────
let _uid = 0;
const uid = () => `n${++_uid}_${Date.now().toString(36)}`;
function defaultParams(name) {
const def = FUNCTIONS[name];
if (!def.params) return {};
return Object.fromEntries(def.params.map(p => [p.k, p.d]));
}
// ─────────────────────────────────────────────────────────────────
// Sparkline compacto (SVG manual)
// ─────────────────────────────────────────────────────────────────
function Sparkline({ data, color = '#888', w = 180, h = 34 }) {
if (!Array.isArray(data) || data.length === 0) {
return <div style={{width:w, height:h}} className="flex items-center justify-center text-[10px] text-neutral-600"></div>;
}
const min = Math.min(...data);
const max = Math.max(...data);
const span = max - min || 1;
const n = data.length;
const xStep = n > 1 ? (w - 6) / (n - 1) : 0;
const pts = data.map((v, i) => [3 + i * xStep, h - 3 - ((v - min) / span) * (h - 6)]);
const path = pts.map((p, i) => (i === 0 ? `M${p[0].toFixed(1)},${p[1].toFixed(1)}` : `L${p[0].toFixed(1)},${p[1].toFixed(1)}`)).join(' ');
const zeroY = h - 3 - ((0 - min) / span) * (h - 6);
const showZero = min < 0 && max > 0;
return (
<svg width={w} height={h} className="block">
{showZero && <line x1={0} x2={w} y1={zeroY} y2={zeroY} stroke={color} strokeOpacity={0.15} strokeDasharray="2 3" />}
<path d={path} fill="none" stroke={color} strokeWidth={1.3} strokeLinecap="round" strokeLinejoin="round" />
{n <= 40 && pts.map((p, i) => (
<circle key={i} cx={p[0]} cy={p[1]} r={1.4} fill={color} fillOpacity={0.85} />
))}
</svg>
);
}
// ─────────────────────────────────────────────────────────────────
// Ficha en la paleta (draggable)
// ─────────────────────────────────────────────────────────────────
function PaletteItem({ fnName, fn, color, onDragStart, onClick }) {
return (
<div
draggable
onDragStart={(e) => { e.dataTransfer.setData('text/fn-name', fnName); e.dataTransfer.effectAllowed = 'copy'; onDragStart?.(); }}
onClick={onClick}
className="group flex items-center gap-2 px-2.5 py-1.5 rounded-md cursor-grab active:cursor-grabbing select-none transition-colors hover:bg-neutral-800/60"
style={{ borderLeft: `2px solid ${color}` }}
title={`${fnName} :: ${fn.sig}`}
>
<span className="font-mono text-[11px] font-semibold tracking-tight" style={{ color }}>{fn.symbol}</span>
<span className="font-mono text-[11px] text-neutral-400 flex-1 truncate">{fnName}</span>
<span className="font-mono text-[9px] text-neutral-600 opacity-0 group-hover:opacity-100 transition-opacity">drag</span>
</div>
);
}
// ─────────────────────────────────────────────────────────────────
// Nodo del pipeline
// ─────────────────────────────────────────────────────────────────
function PipelineNode({ step, index, isLast, onRemove, onParamChange, onDragStart, onDragOver, onDrop, isDragging }) {
const { def, value, unreachable, error } = step;
const cat = CATEGORIES[def.cat];
const color = cat.color;
const isScalar = def.cat === 'fold';
return (
<div
draggable
onDragStart={(e) => { e.dataTransfer.setData('text/reorder-index', String(index)); e.dataTransfer.effectAllowed = 'move'; onDragStart?.(index); }}
onDragOver={(e) => { e.preventDefault(); onDragOver?.(index); }}
onDrop={(e) => onDrop?.(e, index)}
className="relative group"
style={{ opacity: isDragging ? 0.4 : 1 }}
>
<div
className="flex flex-col rounded-lg backdrop-blur-sm transition-all"
style={{
background: `linear-gradient(180deg, ${color}14 0%, ${color}06 100%)`,
border: `1px solid ${color}40`,
boxShadow: `0 0 0 1px ${color}10, 0 8px 24px -12px ${color}30`,
minWidth: 200,
}}
>
{/* cabecera */}
<div className="flex items-center gap-1.5 px-2.5 py-1.5 border-b" style={{ borderColor: `${color}25` }}>
<GripVertical size={12} className="text-neutral-600 cursor-grab" />
<span className="font-mono text-[11px] font-bold" style={{ color }}>{def.symbol}</span>
<span className="font-mono text-[10px] text-neutral-500 flex-1">{step.name}</span>
<button
onClick={() => onRemove(index)}
className="opacity-0 group-hover:opacity-100 transition-opacity text-neutral-500 hover:text-neutral-200 p-0.5 rounded"
aria-label="Eliminar"
>
<X size={11} />
</button>
</div>
{/* params */}
{def.params && (
<div className="flex items-center gap-1.5 px-2.5 py-1.5 border-b flex-wrap" style={{ borderColor: `${color}18` }}>
{def.params.map(p => (
<label key={p.k} className="flex items-center gap-1 font-mono text-[10px] text-neutral-500">
<span>{p.k}=</span>
<input
type="number"
value={step.params?.[p.k] ?? p.d}
min={p.min} max={p.max} step={p.step ?? 1}
onChange={(e) => onParamChange(index, p.k, Number(e.target.value))}
className="w-12 bg-neutral-900/60 border border-neutral-700/50 rounded px-1 py-0.5 text-neutral-200 font-mono text-[10px] focus:outline-none focus:border-neutral-500"
/>
</label>
))}
</div>
)}
{/* preview */}
<div className="px-2.5 py-1.5">
{unreachable ? (
<div className="font-mono text-[10px] text-neutral-600 italic">inalcanzable · fold anterior terminó el pipeline</div>
) : error ? (
<div className="font-mono text-[10px] text-rose-400">error: {error}</div>
) : isScalar ? (
<div className="flex items-baseline gap-2">
<span className="font-mono text-[10px] text-neutral-500">resultado</span>
<span className="font-display text-2xl font-light" style={{ color }}>{formatScalar(value)}</span>
</div>
) : (
<div>
<Sparkline data={value} color={color} w={180} h={32} />
<div className="font-mono text-[9px] text-neutral-600 mt-1">
n={value?.length ?? 0} · range [{formatScalar(Math.min(...(value?.length?value:[0])))}, {formatScalar(Math.max(...(value?.length?value:[0])))}]
</div>
</div>
)}
</div>
</div>
{/* flecha de composición */}
{!isLast && (
<div className="absolute top-1/2 -right-5 -translate-y-1/2 font-mono text-neutral-600 text-sm pointer-events-none"></div>
)}
</div>
);
}
function formatScalar(v) {
if (v === undefined || v === null || !isFinite(v)) return '—';
if (Number.isInteger(v)) return String(v);
const abs = Math.abs(v);
if (abs >= 1000 || (abs > 0 && abs < 0.01)) return v.toExponential(2);
return v.toFixed(3);
}
// ─────────────────────────────────────────────────────────────────
// Visualización grande del output final
// ─────────────────────────────────────────────────────────────────
function FinalView({ lastStep }) {
if (!lastStep) {
return (
<div className="flex-1 flex items-center justify-center text-neutral-600 font-mono text-xs">
arrastra una función para empezar
</div>
);
}
const { def, value, error, unreachable } = lastStep;
if (error || unreachable) return null;
const color = CATEGORIES[def.cat].color;
if (def.cat === 'fold') {
return (
<div className="flex flex-col items-center justify-center py-6 w-full">
<span className="font-mono text-[10px] uppercase tracking-[0.2em] text-neutral-500 mb-2">resultado · escalar</span>
<span className="font-display text-5xl font-light break-all text-center" style={{ color }}>{formatScalar(value)}</span>
</div>
);
}
// plot grande
if (!Array.isArray(value) || value.length === 0) {
return <div className="font-mono text-xs text-neutral-600 p-4"> (lista vacía)</div>;
}
const W = 720, H = 180, pad = 20;
const min = Math.min(...value), max = Math.max(...value);
const span = max - min || 1;
const n = value.length;
const xStep = n > 1 ? (W - 2 * pad) / (n - 1) : 0;
const pts = value.map((v, i) => [pad + i * xStep, H - pad - ((v - min) / span) * (H - 2 * pad)]);
const path = pts.map((p, i) => (i === 0 ? `M${p[0].toFixed(1)},${p[1].toFixed(1)}` : `L${p[0].toFixed(1)},${p[1].toFixed(1)}`)).join(' ');
const areaPath = `${path} L${pts[pts.length-1][0]},${H-pad} L${pts[0][0]},${H-pad} Z`;
const zeroY = H - pad - ((0 - min) / span) * (H - 2 * pad);
const showZero = min < 0 && max > 0;
return (
<div className="flex flex-col">
<div className="flex items-baseline justify-between mb-2">
<span className="font-mono text-[10px] uppercase tracking-[0.2em] text-neutral-500">resultado · señal</span>
<span className="font-mono text-[10px] text-neutral-500">n={n} · [{formatScalar(min)}, {formatScalar(max)}]</span>
</div>
<svg viewBox={`0 0 ${W} ${H}`} className="w-full h-auto" preserveAspectRatio="none">
<defs>
<linearGradient id="areaGrad" x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stopColor={color} stopOpacity="0.3" />
<stop offset="100%" stopColor={color} stopOpacity="0" />
</linearGradient>
</defs>
{/* grid */}
{[0.25, 0.5, 0.75].map(f => (
<line key={f} x1={pad} x2={W-pad} y1={pad + f*(H-2*pad)} y2={pad + f*(H-2*pad)} stroke="#fff" strokeOpacity={0.04} />
))}
{showZero && <line x1={pad} x2={W-pad} y1={zeroY} y2={zeroY} stroke={color} strokeOpacity={0.25} strokeDasharray="3 4" />}
<path d={areaPath} fill="url(#areaGrad)" />
<path d={path} fill="none" stroke={color} strokeWidth={1.8} strokeLinecap="round" strokeLinejoin="round" />
{n <= 80 && pts.map((p, i) => (
<circle key={i} cx={p[0]} cy={p[1]} r={2} fill={color} />
))}
{/* eje */}
<line x1={pad} x2={W-pad} y1={H-pad} y2={H-pad} stroke="#fff" strokeOpacity={0.1} />
<line x1={pad} x2={pad} y1={pad} y2={H-pad} stroke="#fff" strokeOpacity={0.1} />
</svg>
</div>
);
}
// ─────────────────────────────────────────────────────────────────
// APP principal
// ─────────────────────────────────────────────────────────────────
export default function App() {
const [pipeline, setPipeline] = useState([
{ id: uid(), name: 'range', params: defaultParams('range') },
{ id: uid(), name: 'square', params: defaultParams('square') },
{ id: uid(), name: 'cumsum', params: defaultParams('cumsum') },
]);
const [dragIndex, setDragIndex] = useState(null);
const [hoverIndex, setHoverIndex] = useState(null);
const dropZoneRef = useRef(null);
const { steps } = useMemo(() => executePipeline(pipeline), [pipeline]);
const lastStep = steps[steps.length - 1];
// expresión simbólica: fold_sum ∘ map_square ∘ gen_range(20)
const expression = useMemo(() => {
if (pipeline.length === 0) return '∅';
const parts = pipeline.map(s => {
const def = FUNCTIONS[s.name];
const paramStr = def.params ? '(' + def.params.map(p => `${p.k}=${s.params?.[p.k] ?? p.d}`).join(',') + ')' : '';
return `${s.name}${paramStr}`;
}).reverse();
return parts.join(' ∘ ');
}, [pipeline]);
const addFunction = (name) => {
setPipeline(p => [...p, { id: uid(), name, params: defaultParams(name) }]);
};
const removeFunction = (index) => {
setPipeline(p => p.filter((_, i) => i !== index));
};
const changeParam = (index, key, value) => {
setPipeline(p => p.map((s, i) => i === index ? { ...s, params: { ...s.params, [key]: value } } : s));
};
const handleDropOnZone = (e) => {
e.preventDefault();
const fnName = e.dataTransfer.getData('text/fn-name');
const reorderIdx = e.dataTransfer.getData('text/reorder-index');
if (fnName && FUNCTIONS[fnName]) {
addFunction(fnName);
} else if (reorderIdx !== '') {
// soltar al final si vino de un nodo
const from = Number(reorderIdx);
setPipeline(p => {
const copy = [...p];
const [moved] = copy.splice(from, 1);
copy.push(moved);
return copy;
});
}
setDragIndex(null);
setHoverIndex(null);
};
const handleDropOnNode = (e, targetIdx) => {
e.preventDefault();
e.stopPropagation();
const fnName = e.dataTransfer.getData('text/fn-name');
const reorderIdx = e.dataTransfer.getData('text/reorder-index');
if (fnName && FUNCTIONS[fnName]) {
setPipeline(p => {
const copy = [...p];
copy.splice(targetIdx, 0, { id: uid(), name: fnName, params: defaultParams(fnName) });
return copy;
});
} else if (reorderIdx !== '') {
const from = Number(reorderIdx);
if (from === targetIdx) return;
setPipeline(p => {
const copy = [...p];
const [moved] = copy.splice(from, 1);
const adjusted = from < targetIdx ? targetIdx - 1 : targetIdx;
copy.splice(adjusted, 0, moved);
return copy;
});
}
setDragIndex(null);
setHoverIndex(null);
};
return (
<div className="w-full min-h-screen text-neutral-200" style={{
background: 'radial-gradient(ellipse at top, #0f1419 0%, #06080a 60%, #030506 100%)',
fontFamily: "'JetBrains Mono', ui-monospace, monospace",
}}>
<style>{`
@import url('https://fonts.googleapis.com/css2?family=Fraunces:ital,opsz,wght@0,9..144,300..900;1,9..144,300..900&family=JetBrains+Mono:wght@400;500;600;700&display=swap');
.font-display { font-family: 'Fraunces', serif; font-optical-sizing: auto; }
.font-mono { font-family: 'JetBrains Mono', ui-monospace, monospace; }
.grid-bg {
background-image:
linear-gradient(rgba(255,255,255,0.02) 1px, transparent 1px),
linear-gradient(90deg, rgba(255,255,255,0.02) 1px, transparent 1px);
background-size: 24px 24px;
}
/* scrollbar */
::-webkit-scrollbar { width: 8px; height: 8px; }
::-webkit-scrollbar-track { background: transparent; }
::-webkit-scrollbar-thumb { background: rgba(255,255,255,0.08); border-radius: 4px; }
::-webkit-scrollbar-thumb:hover { background: rgba(255,255,255,0.16); }
`}</style>
<div className="grid-bg min-h-screen">
{/* HEADER */}
<header className="px-6 pt-6 pb-5 border-b border-white/5">
<div className="flex items-end justify-between flex-wrap gap-4">
<div>
<div className="flex items-baseline gap-3">
<span className="font-mono text-[10px] uppercase tracking-[0.3em] text-neutral-500">lab · 001</span>
<span className="font-mono text-[10px] text-neutral-700">/</span>
<span className="font-mono text-[10px] text-neutral-500">composición funcional</span>
</div>
<h1 className="font-display text-3xl font-light tracking-tight mt-1.5">
laboratorio de <em className="italic" style={{color:'#c4b5fd'}}>morfismos</em>
</h1>
<p className="font-mono text-[11px] text-neutral-500 mt-1.5 max-w-2xl">
arrastra funciones al pipeline y componlas de izquierda a derecha. cada tipo corresponde a un esquema de recursión distinto.
</p>
</div>
<button
onClick={() => setPipeline([])}
className="flex items-center gap-2 font-mono text-[10px] uppercase tracking-wider text-neutral-500 hover:text-neutral-200 border border-white/10 hover:border-white/30 px-3 py-1.5 rounded transition-colors"
>
<Trash2 size={11} /> vaciar
</button>
</div>
</header>
{/* MAIN GRID · 2 columnas: paleta | (expresión + pipeline + visualizador apilados) */}
<main
style={{
display: 'grid',
gridTemplateColumns: '200px minmax(280px, 1fr)',
minHeight: 'calc(100vh - 140px)',
overflowX: 'auto',
}}
>
{/* ─── IZQUIERDA · PALETA ─── */}
<aside className="border-r border-white/5 p-5 overflow-y-auto" style={{maxHeight: 'calc(100vh - 140px)'}}>
<div className="font-mono text-[10px] uppercase tracking-[0.25em] text-neutral-500 mb-4">primitivas</div>
<div className="flex flex-col gap-5">
{BY_CATEGORY.map(cat => (
<section key={cat.key}>
<div className="flex items-center gap-2 mb-2 px-1">
<span className="w-1.5 h-1.5 rounded-full" style={{background: cat.color, boxShadow: `0 0 8px ${cat.color}`}} />
<span className="font-display text-sm italic" style={{color: cat.color}}>{cat.label}</span>
</div>
<div className="font-mono text-[9px] text-neutral-600 mb-2 px-1 leading-snug">
{cat.subtitle} · <span className="text-neutral-500">{cat.signature}</span>
</div>
<div className="flex flex-col gap-0.5">
{cat.items.map(item => (
<PaletteItem
key={item.name}
fnName={item.name}
fn={item}
color={cat.color}
onClick={() => addFunction(item.name)}
/>
))}
</div>
</section>
))}
</div>
<div className="mt-6 pt-4 border-t border-white/5 font-mono text-[9px] text-neutral-600 leading-relaxed">
tip · click o drag para añadir al pipeline. reordena arrastrando nodos existentes.
</div>
</aside>
{/* ─── DERECHA · EXPRESIÓN + PIPELINE + VISUALIZADOR ─── */}
<section className="p-4 md:p-6 overflow-y-auto flex flex-col gap-5" style={{maxHeight: 'calc(100vh - 140px)'}}>
{/* Expresión simbólica */}
<div>
<div className="flex items-baseline gap-3 mb-2">
<span className="font-mono text-[10px] uppercase tracking-[0.25em] text-neutral-500">expresión</span>
<span className="font-mono text-[9px] text-neutral-600">se aplica de derecha a izquierda</span>
</div>
<div className="rounded-lg bg-white/[0.03] border border-white/10 p-3">
<code className="font-mono text-xs text-neutral-200 break-all leading-relaxed">
{expression}
</code>
</div>
</div>
{/* Pipeline */}
<div>
<div className="flex items-baseline gap-3 mb-2">
<span className="font-mono text-[10px] uppercase tracking-[0.25em] text-neutral-500">pipeline</span>
<span className="font-mono text-[10px] text-neutral-600">{pipeline.length} {pipeline.length === 1 ? 'nodo' : 'nodos'}</span>
</div>
<div
ref={dropZoneRef}
onDragOver={(e) => { e.preventDefault(); e.dataTransfer.dropEffect = 'copy'; }}
onDrop={handleDropOnZone}
className="rounded-xl border border-dashed border-white/10 p-5 transition-colors hover:border-white/20"
style={{background: 'rgba(255,255,255,0.015)', minHeight: '180px'}}
>
{pipeline.length === 0 ? (
<div className="h-full flex flex-col items-center justify-center py-12 text-center">
<div className="font-display text-xl italic text-neutral-500 mb-2">zona de composición</div>
<div className="font-mono text-[10px] text-neutral-600 max-w-xs">arrastra una primitiva desde la izquierda. los nodos se encadenan de izquierda a derecha.</div>
</div>
) : (
<div className="flex flex-wrap gap-x-8 gap-y-4 items-start">
{steps.map((step, i) => (
<PipelineNode
key={step.id}
step={step}
index={i}
isLast={i === steps.length - 1}
onRemove={removeFunction}
onParamChange={changeParam}
onDragStart={setDragIndex}
onDragOver={setHoverIndex}
onDrop={handleDropOnNode}
isDragging={dragIndex === i}
/>
))}
</div>
)}
</div>
</div>
{/* Visualizador */}
<div>
<div className="font-mono text-[10px] uppercase tracking-[0.25em] text-neutral-500 mb-2">visualizador</div>
<div className="rounded-lg border border-white/5 bg-white/[0.015] p-4" style={{minHeight: '220px'}}>
<div className="h-full flex items-center justify-center">
<FinalView lastStep={lastStep} />
</div>
</div>
</div>
{/* Nota didáctica */}
<div className="font-mono text-[10px] text-neutral-600 leading-relaxed border-l-2 border-neutral-800 pl-3">
<span style={{color:'#7dd3fc'}}>anamorfismo</span> genera estructura · <span style={{color:'#c4b5fd'}}>functor map</span> transforma punto a punto ·{' '}
<span style={{color:'#fcd34d'}}>filtro</span> refina · <span style={{color:'#86efac'}}>scan</span> acumula dejando rastro ·{' '}
<span style={{color:'#fda4af'}}>catamorfismo</span> colapsa (es terminal)
</div>
</section>
</main>
</div>
</div>
);
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,878 @@
import { useState, useEffect, useRef, useCallback, useMemo } from 'react';
import { X, AlertCircle, RotateCcw, ChevronDown, ChevronRight, Trash2, GripVertical } from 'lucide-react';
// ═══════════════════════════════════════════════════════════════════
// CATÁLOGO DE NODOS — cada uno emite un snippet WGSL
// Convención: cada nodo compila a `fn node_<idx>(c, uv) -> vec4<f32>`
// Parámetros: hasta 4 floats empaquetados en u.params[idx] (vec4<f32>)
// ═══════════════════════════════════════════════════════════════════
const MAX_NODES = 16;
const ACCENT = '#5eead4';
const GEN_COLOR = '#5eead4';
const OP_COLOR = '#c4b5fd';
const NODES = {
// ── GENERADORES (ignoran c, producen nuevo color) ──
solid: {
kind: 'gen', label: 'solid', desc: 'color constante',
params: [
{ k: 'r', label: 'r', min: 0, max: 1, d: 0.35, step: 0.01 },
{ k: 'g', label: 'g', min: 0, max: 1, d: 0.25, step: 0.01 },
{ k: 'b', label: 'b', min: 0, max: 1, d: 0.55, step: 0.01 },
],
body: (i) => `
let p = u.params[${i}];
return vec4<f32>(p.x, p.y, p.z, 1.0);`,
},
gradient: {
kind: 'gen', label: 'gradient', desc: 'gradiente en ángulo',
params: [
{ k: 'angle', label: 'ángulo', min: 0, max: 6.2832, d: 0.8, step: 0.01 },
{ k: 'hue', label: 'tono', min: 0, max: 1, d: 0.5, step: 0.01 },
],
body: (i) => `
let p = u.params[${i}];
let dir = vec2<f32>(cos(p.x), sin(p.x));
let t = dot(uv - 0.5, dir) + 0.5;
let col = 0.5 + 0.5 * cos(6.28318 * (p.y + vec3<f32>(0.0, 0.33, 0.67) + t));
return vec4<f32>(col, 1.0);`,
},
plasma: {
kind: 'gen', label: 'plasma', desc: 'onda trigonométrica',
params: [
{ k: 'speed', label: 'velocidad', min: 0, max: 3, d: 1, step: 0.01 },
{ k: 'scale', label: 'escala', min: 0.5, max: 10, d: 2, step: 0.1 },
],
body: (i) => `
let p = u.params[${i}];
let t = u.time * p.x;
let col = 0.5 + 0.5 * cos(t + uv.xyx * p.y + vec3<f32>(0.0, 2.0, 4.0));
return vec4<f32>(col, 1.0);`,
},
checker: {
kind: 'gen', label: 'checker', desc: 'tablero rotando',
params: [
{ k: 'scale', label: 'escala', min: 1, max: 30, d: 8, step: 0.5 },
{ k: 'rot', label: 'rotación', min: -2, max: 2, d: 0.25, step: 0.01 },
],
body: (i) => `
let p = u.params[${i}];
let aspect = u.resolution.x / u.resolution.y;
let q0 = vec2<f32>((uv.x - 0.5) * aspect, uv.y - 0.5);
let a = u.time * p.y;
let rm = mat2x2<f32>(cos(a), -sin(a), sin(a), cos(a));
let q = rm * q0 * p.x;
let chk = (floor(q.x) + floor(q.y)) - 2.0 * floor((floor(q.x) + floor(q.y)) * 0.5);
return vec4<f32>(vec3<f32>(chk), 1.0);`,
},
circle: {
kind: 'gen', label: 'circle', desc: 'sdf de círculo',
params: [
{ k: 'radius', label: 'radio', min: 0, max: 1, d: 0.4, step: 0.01 },
{ k: 'soft', label: 'suavidad', min: 0.001, max: 0.1, d: 0.008, step: 0.001 },
{ k: 'pulse', label: 'pulso', min: 0, max: 1, d: 0.1, step: 0.01 },
],
body: (i) => `
let p = u.params[${i}];
let aspect = u.resolution.x / u.resolution.y;
let pos = vec2<f32>((uv.x - 0.5) * aspect, uv.y - 0.5);
let r = p.x + p.z * 0.15 * sin(u.time * 2.0);
let d = length(pos) - r;
let fill = smoothstep(p.y, -p.y, d);
return mix(c, vec4<f32>(1.0), fill);`,
},
stripes: {
kind: 'gen', label: 'stripes', desc: 'rayas animadas',
params: [
{ k: 'freq', label: 'frecuencia', min: 1, max: 80, d: 20, step: 0.5 },
{ k: 'speed', label: 'velocidad', min: -5, max: 5, d: 1, step: 0.05 },
{ k: 'angle', label: 'ángulo', min: 0, max: 3.1416, d: 0.5, step: 0.01 },
],
body: (i) => `
let p = u.params[${i}];
let dir = vec2<f32>(cos(p.z), sin(p.z));
let x = dot(uv, dir);
let v = 0.5 + 0.5 * sin(x * p.x + u.time * p.y);
return vec4<f32>(vec3<f32>(v), 1.0);`,
},
noise: {
kind: 'gen', label: 'noise', desc: 'hash pseudo-aleatorio',
params: [
{ k: 'scale', label: 'escala', min: 1, max: 200, d: 80, step: 1 },
{ k: 'seed', label: 'seed', min: 0, max: 100, d: 7, step: 1 },
],
body: (i) => `
let p = u.params[${i}];
let q = floor(uv * p.x + p.y);
let h = fract(sin(dot(q, vec2<f32>(12.9898, 78.233))) * 43758.5453);
return vec4<f32>(vec3<f32>(h), 1.0);`,
},
// ── OPERADORES (transforman c) ──
invert: {
kind: 'op', label: 'invert', desc: '1 rgb',
params: [],
body: () => `
return vec4<f32>(1.0 - c.rgb, c.a);`,
},
gamma: {
kind: 'op', label: 'gamma', desc: 'pow(rgb, γ)',
params: [
{ k: 'g', label: 'γ', min: 0.1, max: 5, d: 1, step: 0.01 },
],
body: (i) => `
let p = u.params[${i}];
let g = max(0.01, p.x);
return vec4<f32>(pow(max(c.rgb, vec3<f32>(0.0)), vec3<f32>(g)), c.a);`,
},
brightness: {
kind: 'op', label: 'brightness', desc: 'rgb + v',
params: [
{ k: 'v', label: 'valor', min: -1, max: 1, d: 0, step: 0.01 },
],
body: (i) => `
return vec4<f32>(c.rgb + vec3<f32>(u.params[${i}].x), c.a);`,
},
contrast: {
kind: 'op', label: 'contrast', desc: '(rgb 0.5)·k + 0.5',
params: [
{ k: 'k', label: 'k', min: 0, max: 3, d: 1, step: 0.01 },
],
body: (i) => `
return vec4<f32>((c.rgb - vec3<f32>(0.5)) * u.params[${i}].x + vec3<f32>(0.5), c.a);`,
},
saturate: {
kind: 'op', label: 'saturate', desc: 'lerp(luma, rgb, s)',
params: [
{ k: 's', label: 's', min: 0, max: 2, d: 1, step: 0.01 },
],
body: (i) => `
let luma = dot(c.rgb, vec3<f32>(0.2126, 0.7152, 0.0722));
return vec4<f32>(mix(vec3<f32>(luma), c.rgb, u.params[${i}].x), c.a);`,
},
hueShift: {
kind: 'op', label: 'hue shift', desc: 'rotar matiz',
params: [
{ k: 'h', label: 'h', min: 0, max: 1, d: 0, step: 0.01 },
],
body: (i) => `
let p = u.params[${i}];
let k = vec3<f32>(0.57735);
let ca = cos(p.x * 6.2832);
let sa = sin(p.x * 6.2832);
let rot = c.rgb * ca + cross(k, c.rgb) * sa + k * dot(k, c.rgb) * (1.0 - ca);
return vec4<f32>(rot, c.a);`,
},
tint: {
kind: 'op', label: 'tint', desc: 'rgb × tinte',
params: [
{ k: 'r', label: 'r', min: 0, max: 2, d: 1, step: 0.01 },
{ k: 'g', label: 'g', min: 0, max: 2, d: 1, step: 0.01 },
{ k: 'b', label: 'b', min: 0, max: 2, d: 1, step: 0.01 },
],
body: (i) => `
let p = u.params[${i}];
return vec4<f32>(c.rgb * vec3<f32>(p.x, p.y, p.z), c.a);`,
},
posterize: {
kind: 'op', label: 'posterize', desc: 'cuantizar a N niveles',
params: [
{ k: 'levels', label: 'niveles', min: 2, max: 16, d: 5, step: 1 },
],
body: (i) => `
let n = max(2.0, u.params[${i}].x);
return vec4<f32>(floor(c.rgb * n) / n, c.a);`,
},
vignette: {
kind: 'op', label: 'vignette', desc: 'oscurecer bordes',
params: [
{ k: 'strength', label: 'fuerza', min: 0, max: 2, d: 1, step: 0.01 },
{ k: 'radius', label: 'radio', min: 0, max: 1.4, d: 0.5, step: 0.01 },
],
body: (i) => `
let p = u.params[${i}];
let d = length(uv - vec2<f32>(0.5));
let v = 1.0 - smoothstep(p.y, p.y + 0.3, d) * p.x;
return vec4<f32>(c.rgb * v, c.a);`,
},
ripple: {
kind: 'op', label: 'ripple', desc: 'modular brillo con ondas',
params: [
{ k: 'freq', label: 'frecuencia', min: 1, max: 100, d: 30, step: 1 },
{ k: 'amp', label: 'amplitud', min: 0, max: 1, d: 0.2, step: 0.01 },
{ k: 'speed', label: 'velocidad', min: -5, max: 5, d: 2, step: 0.05 },
],
body: (i) => `
let p = u.params[${i}];
let d = length(uv - vec2<f32>(0.5));
let w = sin(d * p.x - u.time * p.z) * p.y;
return vec4<f32>(c.rgb * (1.0 + w), c.a);`,
},
pulse: {
kind: 'op', label: 'pulse', desc: 'multiplicar por onda',
params: [
{ k: 'freq', label: 'frecuencia', min: 0, max: 10, d: 2, step: 0.05 },
{ k: 'amount', label: 'cantidad', min: 0, max: 1, d: 0.3, step: 0.01 },
],
body: (i) => `
let p = u.params[${i}];
return vec4<f32>(c.rgb * (1.0 + p.y * sin(u.time * p.x)), c.a);`,
},
};
const NODES_BY_KIND = {
gen: Object.entries(NODES).filter(([, v]) => v.kind === 'gen').map(([k, v]) => ({ name: k, ...v })),
op: Object.entries(NODES).filter(([, v]) => v.kind === 'op' ).map(([k, v]) => ({ name: k, ...v })),
};
// ═══════════════════════════════════════════════════════════════════
// Compilador: DAG → WGSL
// ═══════════════════════════════════════════════════════════════════
function compileDagToWGSL(pipeline) {
const safePipeline = pipeline.slice(0, MAX_NODES);
const fns = safePipeline.map((step, idx) => {
const def = NODES[step.name];
return `fn node_${idx}(c: vec4<f32>, uv: vec2<f32>) -> vec4<f32> {${def.body(idx)}
}`;
}).join('\n\n');
const chain = safePipeline.length === 0
? ' // pipeline vacío · fondo por defecto\n c = vec4<f32>(0.04, 0.04, 0.06, 1.0);'
: safePipeline.map((_, idx) => ` c = node_${idx}(c, uv);`).join('\n');
return `struct Uniforms {
time: f32,
_pad: f32,
resolution: vec2<f32>,
params: array<vec4<f32>, ${MAX_NODES}>,
};
@group(0) @binding(0) var<uniform> u: Uniforms;
@vertex
fn vs(@builtin(vertex_index) i: u32) -> @builtin(position) vec4<f32> {
let p = array<vec2<f32>, 3>(
vec2<f32>(-1.0, -1.0),
vec2<f32>( 3.0, -1.0),
vec2<f32>(-1.0, 3.0)
);
return vec4<f32>(p[i], 0.0, 1.0);
}
${fns}
@fragment
fn fs(@builtin(position) pos: vec4<f32>) -> @location(0) vec4<f32> {
let uv = pos.xy / u.resolution;
var c = vec4<f32>(0.0, 0.0, 0.0, 1.0);
${chain}
return c;
}
`;
}
// ═══════════════════════════════════════════════════════════════════
// Uniform buffer: escribe los valores actuales de params + time/res
// ═══════════════════════════════════════════════════════════════════
const UNIFORM_FLOATS = 4 + MAX_NODES * 4; // header (4) + params (MAX_NODES × 4)
const UNIFORM_BYTES = UNIFORM_FLOATS * 4; // 272 bytes
function writeUniforms(device, buffer, time, width, height, pipeline) {
const data = new Float32Array(UNIFORM_FLOATS);
data[0] = time;
data[1] = 0;
data[2] = width;
data[3] = height;
for (let i = 0; i < Math.min(pipeline.length, MAX_NODES); i++) {
const step = pipeline[i];
const def = NODES[step.name];
const offset = 4 + i * 4;
for (let j = 0; j < 4; j++) {
const p = def.params[j];
data[offset + j] = p ? (step.params[p.k] ?? p.d) : 0;
}
}
device.queue.writeBuffer(buffer, 0, data);
}
// ═══════════════════════════════════════════════════════════════════
// Hook WebGPU + compilación de DAG
// ═══════════════════════════════════════════════════════════════════
function useWebGPUDag(canvasRef, pipeline, topologyKey) {
const gpu = useRef({
device: null, context: null, format: null,
pipeline: null, bindGroup: null, uniformBuffer: null,
startTime: 0,
});
const pipelineRef = useRef(pipeline);
useEffect(() => { pipelineRef.current = pipeline; }, [pipeline]);
const [status, setStatus] = useState('init');
const [shaderError, setShaderError] = useState(null);
const [fps, setFps] = useState(0);
// ── Init GPU ──
useEffect(() => {
let cancelled = false;
(async () => {
try {
if (!navigator.gpu) { setStatus('unsupported'); return; }
const adapter = await navigator.gpu.requestAdapter();
if (!adapter) { setStatus('unsupported'); return; }
const device = await adapter.requestDevice();
if (cancelled) return;
const canvas = canvasRef.current;
if (!canvas) return;
canvas.width = 400; canvas.height = 400;
const context = canvas.getContext('webgpu');
const format = navigator.gpu.getPreferredCanvasFormat();
context.configure({ device, format, alphaMode: 'premultiplied' });
const uniformBuffer = device.createBuffer({
size: UNIFORM_BYTES,
usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST,
});
gpu.current = {
...gpu.current, device, context, format, uniformBuffer,
startTime: performance.now(),
};
device.lost.then(() => setStatus('error'));
setStatus('ready');
} catch (e) {
console.error(e);
setShaderError(String(e.message || e));
setStatus('error');
}
})();
return () => { cancelled = true; };
}, [canvasRef]);
// ── Recompilar shader (solo en cambios de topología) ──
const compileShader = useCallback(async (wgsl) => {
const { device, format, uniformBuffer } = gpu.current;
if (!device) return;
device.pushErrorScope('validation');
const module = device.createShaderModule({ code: wgsl });
const info = await module.getCompilationInfo();
const errors = info.messages.filter(m => m.type === 'error');
if (errors.length > 0) {
setShaderError(errors.map(m => `línea ${m.lineNum}: ${m.message}`).join('\n'));
await device.popErrorScope();
return;
}
try {
const p = device.createRenderPipeline({
layout: 'auto',
vertex: { module, entryPoint: 'vs' },
fragment: { module, entryPoint: 'fs', targets: [{ format }] },
primitive:{ topology: 'triangle-list' },
});
const bg = device.createBindGroup({
layout: p.getBindGroupLayout(0),
entries: [{ binding: 0, resource: { buffer: uniformBuffer } }],
});
gpu.current.pipeline = p;
gpu.current.bindGroup = bg;
setShaderError(null);
} catch (e) {
setShaderError(String(e.message || e));
}
const err = await device.popErrorScope();
if (err) setShaderError(err.message);
}, []);
useEffect(() => {
if (status !== 'ready') return;
const wgsl = compileDagToWGSL(pipelineRef.current);
compileShader(wgsl);
}, [topologyKey, status, compileShader]);
// ── Render loop (lee params actuales cada frame via ref) ──
useEffect(() => {
if (status !== 'ready') return;
let running = true;
let frames = 0;
let lastFpsSample = performance.now();
const loop = () => {
if (!running) return;
const { device, context, pipeline: gpuPipe, bindGroup, uniformBuffer, startTime } = gpu.current;
const canvas = canvasRef.current;
if (device && context && gpuPipe && bindGroup && canvas) {
const dpr = Math.min(window.devicePixelRatio || 1, 2);
const w = Math.max(1, Math.floor(canvas.clientWidth * dpr));
const h = Math.max(1, Math.floor(canvas.clientHeight * dpr));
if (canvas.width !== w || canvas.height !== h) {
canvas.width = w; canvas.height = h;
}
const t = (performance.now() - startTime) / 1000;
writeUniforms(device, uniformBuffer, t, canvas.width, canvas.height, pipelineRef.current);
const encoder = device.createCommandEncoder();
const pass = encoder.beginRenderPass({
colorAttachments: [{
view: context.getCurrentTexture().createView(),
clearValue: { r: 0, g: 0, b: 0, a: 1 },
loadOp: 'clear', storeOp: 'store',
}],
});
pass.setPipeline(gpuPipe);
pass.setBindGroup(0, bindGroup);
pass.draw(3);
pass.end();
device.queue.submit([encoder.finish()]);
frames++;
const now = performance.now();
if (now - lastFpsSample >= 500) {
setFps(Math.round((frames * 1000) / (now - lastFpsSample)));
frames = 0; lastFpsSample = now;
}
}
requestAnimationFrame(loop);
};
requestAnimationFrame(loop);
return () => { running = false; };
}, [status, canvasRef]);
const resetTime = useCallback(() => {
gpu.current.startTime = performance.now();
}, []);
return { status, shaderError, fps, resetTime };
}
// ═══════════════════════════════════════════════════════════════════
// Utilidades
// ═══════════════════════════════════════════════════════════════════
let _uid = 0;
const uid = () => `n${++_uid}_${Date.now().toString(36)}`;
function defaultParams(name) {
const d = NODES[name];
return Object.fromEntries((d.params || []).map(p => [p.k, p.d]));
}
// ═══════════════════════════════════════════════════════════════════
// Sub-componentes
// ═══════════════════════════════════════════════════════════════════
function PaletteItem({ node, color, onDragStart, onClick }) {
return (
<div
draggable
onDragStart={(e) => {
e.dataTransfer.setData('text/node-name', node.name);
e.dataTransfer.effectAllowed = 'copy';
onDragStart?.();
}}
onClick={onClick}
className="group flex items-center gap-2 px-2 py-1 rounded cursor-grab active:cursor-grabbing select-none transition-colors hover:bg-neutral-800/60"
style={{ borderLeft: `2px solid ${color}` }}
title={node.desc}
>
<span className="font-mono text-[11px] font-medium flex-1" style={{ color }}>{node.label}</span>
<span className="font-mono text-[8px] text-neutral-600 opacity-0 group-hover:opacity-100 transition-opacity uppercase tracking-wider">drag</span>
</div>
);
}
function ParamSlider({ param, value, onChange, color }) {
const display = param.step >= 1 ? Math.round(value) : Number(value).toFixed(param.step < 0.1 ? 3 : 2);
return (
<div className="flex items-center gap-2">
<span className="font-mono text-[10px] text-neutral-400 w-14 shrink-0">{param.label}</span>
<input
type="range"
min={param.min} max={param.max} step={param.step}
value={value}
onChange={(e) => onChange(Number(e.target.value))}
className="flex-1"
style={{ accentColor: color, minWidth: '60px' }}
/>
<span className="font-mono text-[10px] text-neutral-500 w-10 text-right tabular-nums shrink-0">{display}</span>
</div>
);
}
function PipelineNode({ step, index, onRemove, onParamChange, onDragStart, onDrop, isDragging }) {
const def = NODES[step.name];
const color = def.kind === 'gen' ? GEN_COLOR : OP_COLOR;
return (
<div
draggable
onDragStart={(e) => {
e.dataTransfer.setData('text/reorder-index', String(index));
e.dataTransfer.effectAllowed = 'move';
onDragStart?.(index);
}}
onDragOver={(e) => e.preventDefault()}
onDrop={(e) => onDrop?.(e, index)}
className="rounded-lg transition-all"
style={{
background: `linear-gradient(180deg, ${color}14 0%, ${color}06 100%)`,
border: `1px solid ${color}30`,
opacity: isDragging ? 0.4 : 1,
}}
>
<div className="flex items-center gap-2 px-2.5 py-1.5 border-b" style={{ borderColor: `${color}20` }}>
<GripVertical size={11} className="text-neutral-600 cursor-grab shrink-0" />
<span className="font-mono text-[9px] uppercase tracking-wider text-neutral-500 shrink-0">
{def.kind} · {index}
</span>
<span className="font-mono text-xs font-semibold flex-1 truncate" style={{ color }}>{def.label}</span>
<button
onClick={() => onRemove(index)}
className="text-neutral-500 hover:text-neutral-200 p-0.5"
aria-label="Eliminar"
>
<X size={11} />
</button>
</div>
{def.params.length > 0 && (
<div className="px-2.5 py-2 flex flex-col gap-1.5">
{def.params.map(p => (
<ParamSlider
key={p.k}
param={p}
value={step.params[p.k] ?? p.d}
onChange={(v) => onParamChange(index, p.k, v)}
color={color}
/>
))}
</div>
)}
{def.params.length === 0 && (
<div className="px-2.5 py-1.5 font-mono text-[9px] text-neutral-600 italic">sin parámetros</div>
)}
</div>
);
}
function StatusBadge({ status }) {
const map = {
init: { color: '#fbbf24', label: 'inicializando' },
ready: { color: ACCENT, label: 'activo' },
unsupported: { color: '#f43f5e', label: 'sin webgpu' },
error: { color: '#f43f5e', label: 'error' },
};
const s = map[status] || map.init;
return (
<span className="flex items-center gap-1.5">
<span className="w-1.5 h-1.5 rounded-full" style={{ background: s.color, boxShadow: `0 0 8px ${s.color}` }} />
<span className="font-mono text-[10px]" style={{ color: s.color }}>{s.label}</span>
</span>
);
}
function StatusOverlay({ status, error }) {
if (status === 'ready') return null;
return (
<div className="absolute inset-0 flex items-center justify-center p-6 text-center" style={{ background: 'rgba(0,0,0,0.7)', backdropFilter: 'blur(4px)' }}>
{status === 'init' && <div className="font-mono text-[11px] text-neutral-400">inicializando adaptador GPU</div>}
{status === 'unsupported' && (
<div className="max-w-sm">
<div className="font-display text-xl italic mb-2" style={{ color: '#f43f5e' }}>WebGPU no disponible</div>
<div className="font-mono text-[10px] text-neutral-400 leading-relaxed">
chrome/edge 113+, safari 18+, o firefox nightly con flag. si estás en un navegador compatible, prueba abrir el artifact en pestaña nueva.
</div>
</div>
)}
{status === 'error' && (
<div className="max-w-sm">
<div className="font-display text-xl italic mb-2" style={{ color: '#f43f5e' }}>error</div>
<div className="font-mono text-[10px] text-neutral-400 whitespace-pre-wrap">{error}</div>
</div>
)}
</div>
);
}
// ═══════════════════════════════════════════════════════════════════
// APP
// ═══════════════════════════════════════════════════════════════════
export default function App() {
const canvasRef = useRef(null);
const [pipeline, setPipeline] = useState(() => [
{ id: uid(), name: 'plasma', params: defaultParams('plasma') },
{ id: uid(), name: 'vignette', params: defaultParams('vignette') },
]);
const [dragIndex, setDragIndex] = useState(null);
const [wgslOpen, setWgslOpen] = useState(false);
// La clave de topología cambia SOLO cuando cambia la estructura (no params)
const topologyKey = useMemo(() => pipeline.map(s => s.name).join('|'), [pipeline]);
const { status, shaderError, fps, resetTime } = useWebGPUDag(canvasRef, pipeline, topologyKey);
const generatedWGSL = useMemo(() => compileDagToWGSL(pipeline), [topologyKey]);
const addNode = (name) => {
if (pipeline.length >= MAX_NODES) return;
setPipeline(p => [...p, { id: uid(), name, params: defaultParams(name) }]);
};
const removeNode = (idx) => setPipeline(p => p.filter((_, i) => i !== idx));
const changeParam = (idx, key, value) => {
setPipeline(p => p.map((s, i) => i === idx ? { ...s, params: { ...s.params, [key]: value } } : s));
};
const handleDropOnZone = (e) => {
e.preventDefault();
const nodeName = e.dataTransfer.getData('text/node-name');
const reorderIdx = e.dataTransfer.getData('text/reorder-index');
if (nodeName && NODES[nodeName]) {
addNode(nodeName);
} else if (reorderIdx !== '') {
const from = Number(reorderIdx);
setPipeline(p => {
const copy = [...p];
const [m] = copy.splice(from, 1);
copy.push(m);
return copy;
});
}
setDragIndex(null);
};
const handleDropOnNode = (e, targetIdx) => {
e.preventDefault();
e.stopPropagation();
const nodeName = e.dataTransfer.getData('text/node-name');
const reorderIdx = e.dataTransfer.getData('text/reorder-index');
if (nodeName && NODES[nodeName]) {
setPipeline(p => {
const copy = [...p];
copy.splice(targetIdx, 0, { id: uid(), name: nodeName, params: defaultParams(nodeName) });
return copy;
});
} else if (reorderIdx !== '') {
const from = Number(reorderIdx);
if (from === targetIdx) return;
setPipeline(p => {
const copy = [...p];
const [m] = copy.splice(from, 1);
const adj = from < targetIdx ? targetIdx - 1 : targetIdx;
copy.splice(adj, 0, m);
return copy;
});
}
setDragIndex(null);
};
return (
<div className="w-full min-h-screen text-neutral-200" style={{
background: 'radial-gradient(ellipse at top, #0f1419 0%, #06080a 60%, #030506 100%)',
fontFamily: "'JetBrains Mono', ui-monospace, monospace",
}}>
<style>{`
@import url('https://fonts.googleapis.com/css2?family=Fraunces:ital,opsz,wght@0,9..144,300..900;1,9..144,300..900&family=JetBrains+Mono:wght@400;500;600;700&display=swap');
.font-display { font-family: 'Fraunces', serif; font-optical-sizing: auto; }
.font-mono { font-family: 'JetBrains Mono', ui-monospace, monospace; }
.grid-bg {
background-image:
linear-gradient(rgba(255,255,255,0.02) 1px, transparent 1px),
linear-gradient(90deg, rgba(255,255,255,0.02) 1px, transparent 1px);
background-size: 24px 24px;
}
::-webkit-scrollbar { width: 8px; height: 8px; }
::-webkit-scrollbar-track { background: transparent; }
::-webkit-scrollbar-thumb { background: rgba(255,255,255,0.08); border-radius: 4px; }
::-webkit-scrollbar-thumb:hover { background: rgba(255,255,255,0.16); }
input[type="range"] { height: 4px; }
`}</style>
<div className="grid-bg min-h-screen">
{/* HEADER */}
<header className="px-6 pt-6 pb-5 border-b border-white/5">
<div className="flex items-end justify-between flex-wrap gap-4">
<div>
<div className="flex items-baseline gap-3">
<span className="font-mono text-[10px] uppercase tracking-[0.3em] text-neutral-500">lab · 003</span>
<span className="font-mono text-[10px] text-neutral-700">/</span>
<span className="font-mono text-[10px] text-neutral-500">shader dag · webgpu</span>
<span className="font-mono text-[10px] text-neutral-700">/</span>
<StatusBadge status={status} />
</div>
<h1 className="font-display text-3xl font-light tracking-tight mt-1.5">
composición de <em className="italic" style={{ color: ACCENT }}>fragmentos</em>
</h1>
<p className="font-mono text-[11px] text-neutral-500 mt-1.5 max-w-2xl">
cada nodo emite un snippet WGSL · el DAG se concatena en un único fragment shader · los sliders actualizan uniforms sin recompilar
</p>
</div>
<div className="flex items-center gap-3">
<span className="font-mono text-[10px] text-neutral-600">{fps} fps</span>
<button
onClick={resetTime}
className="flex items-center gap-1.5 font-mono text-[10px] uppercase tracking-wider text-neutral-500 hover:text-neutral-200 border border-white/10 hover:border-white/30 px-3 py-1.5 rounded transition-colors"
>
<RotateCcw size={11} /> reset t
</button>
<button
onClick={() => setPipeline([])}
className="flex items-center gap-1.5 font-mono text-[10px] uppercase tracking-wider text-neutral-500 hover:text-neutral-200 border border-white/10 hover:border-white/30 px-3 py-1.5 rounded transition-colors"
>
<Trash2 size={11} /> vaciar
</button>
</div>
</div>
</header>
{/* MAIN · 3 columnas: paleta | pipeline+sliders | canvas+wgsl */}
<main style={{
display: 'grid',
gridTemplateColumns: '180px minmax(280px, 1fr) minmax(320px, 420px)',
minHeight: 'calc(100vh - 110px)',
overflowX: 'auto',
}}>
{/* ── PALETA ── */}
<aside className="border-r border-white/5 p-4 overflow-y-auto" style={{ maxHeight: 'calc(100vh - 110px)' }}>
<div className="font-mono text-[10px] uppercase tracking-[0.25em] text-neutral-500 mb-4">primitivas</div>
<section className="mb-5">
<div className="flex items-center gap-2 mb-2 px-1">
<span className="w-1.5 h-1.5 rounded-full" style={{ background: GEN_COLOR, boxShadow: `0 0 8px ${GEN_COLOR}` }} />
<span className="font-display text-sm italic" style={{ color: GEN_COLOR }}>generadores</span>
</div>
<div className="font-mono text-[9px] text-neutral-600 mb-2 px-1">producen color · ignoran c</div>
<div className="flex flex-col gap-0.5">
{NODES_BY_KIND.gen.map(n => (
<PaletteItem key={n.name} node={n} color={GEN_COLOR} onClick={() => addNode(n.name)} />
))}
</div>
</section>
<section>
<div className="flex items-center gap-2 mb-2 px-1">
<span className="w-1.5 h-1.5 rounded-full" style={{ background: OP_COLOR, boxShadow: `0 0 8px ${OP_COLOR}` }} />
<span className="font-display text-sm italic" style={{ color: OP_COLOR }}>operadores</span>
</div>
<div className="font-mono text-[9px] text-neutral-600 mb-2 px-1">transforman c · punto a punto</div>
<div className="flex flex-col gap-0.5">
{NODES_BY_KIND.op.map(n => (
<PaletteItem key={n.name} node={n} color={OP_COLOR} onClick={() => addNode(n.name)} />
))}
</div>
</section>
<div className="mt-6 pt-4 border-t border-white/5 font-mono text-[9px] text-neutral-600 leading-relaxed">
tip · click o drag para añadir. reordena arrastrando nodos del pipeline.
</div>
</aside>
{/* ── PIPELINE (vertical, con sliders integrados) ── */}
<section className="p-4 overflow-y-auto border-r border-white/5" style={{ maxHeight: 'calc(100vh - 110px)' }}>
<div className="flex items-baseline gap-3 mb-3">
<span className="font-mono text-[10px] uppercase tracking-[0.25em] text-neutral-500">pipeline</span>
<span className="font-mono text-[10px] text-neutral-600">
{pipeline.length}/{MAX_NODES} nodos
</span>
</div>
<div
onDragOver={(e) => { e.preventDefault(); e.dataTransfer.dropEffect = 'copy'; }}
onDrop={handleDropOnZone}
className="rounded-xl border border-dashed border-white/10 p-3 transition-colors hover:border-white/20"
style={{ background: 'rgba(255,255,255,0.015)', minHeight: '300px' }}
>
{pipeline.length === 0 ? (
<div className="flex flex-col items-center justify-center py-16 text-center">
<div className="font-display text-lg italic text-neutral-500 mb-1">pipeline vacío</div>
<div className="font-mono text-[10px] text-neutral-600">arrastra una primitiva de la izquierda</div>
</div>
) : (
<div className="flex flex-col gap-2">
{pipeline.map((step, i) => (
<PipelineNode
key={step.id}
step={step}
index={i}
onRemove={removeNode}
onParamChange={changeParam}
onDragStart={setDragIndex}
onDrop={handleDropOnNode}
isDragging={dragIndex === i}
/>
))}
</div>
)}
</div>
<div className="mt-4 font-mono text-[9px] text-neutral-600 leading-relaxed border-l-2 border-neutral-800 pl-3">
<span style={{ color: GEN_COLOR }}>gen</span> produce · <span style={{ color: OP_COLOR }}>op</span> transforma · el flujo es c node₀ node₁ nodeₙ
</div>
</section>
{/* ── CANVAS + WGSL ── */}
<aside className="p-4 overflow-y-auto flex flex-col gap-3" style={{ maxHeight: 'calc(100vh - 110px)' }}>
<div className="flex items-baseline gap-3">
<span className="font-mono text-[10px] uppercase tracking-[0.25em] text-neutral-500">output</span>
<span className="font-mono text-[9px] text-neutral-600">fragment · fullscreen triangle</span>
</div>
<div
className="rounded-xl border border-white/10 overflow-hidden relative"
style={{
aspectRatio: '1/1',
background: '#000',
boxShadow: `0 0 0 1px ${ACCENT}10, 0 20px 60px -30px ${ACCENT}30`,
}}
>
<canvas ref={canvasRef} className="block" style={{ width: '100%', height: '100%' }} />
{status !== 'ready' && <StatusOverlay status={status} error={shaderError} />}
</div>
{shaderError && status === 'ready' && (
<div className="rounded-lg p-3" style={{ background: '#f43f5e0a', border: '1px solid #f43f5e30' }}>
<div className="font-mono text-[10px] uppercase tracking-[0.2em] text-rose-400 mb-1.5 flex items-center gap-1.5">
<AlertCircle size={11} /> compilación fallida
</div>
<pre className="font-mono text-[10px] text-rose-300 whitespace-pre-wrap leading-relaxed">{shaderError}</pre>
<div className="font-mono text-[9px] text-neutral-500 mt-2">último pipeline válido sigue activo</div>
</div>
)}
{/* WGSL viewer */}
<div className="rounded-lg border border-white/5" style={{ background: 'rgba(255,255,255,0.015)' }}>
<button
onClick={() => setWgslOpen(o => !o)}
className="w-full flex items-center gap-2 px-3 py-2 font-mono text-[10px] uppercase tracking-[0.2em] text-neutral-500 hover:text-neutral-300"
>
{wgslOpen ? <ChevronDown size={11} /> : <ChevronRight size={11} />}
wgsl generado
<span className="ml-auto text-neutral-600 normal-case tracking-normal">{generatedWGSL.split('\n').length} líneas</span>
</button>
{wgslOpen && (
<pre className="font-mono text-[10px] text-neutral-400 px-3 pb-3 overflow-auto leading-relaxed" style={{ maxHeight: '260px' }}>
{generatedWGSL}
</pre>
)}
</div>
<div className="font-mono text-[9px] text-neutral-600 leading-relaxed border-l-2 border-neutral-800 pl-3">
los sliders escriben a <span style={{ color: ACCENT }}>u.params[idx]</span> sin recompilar · solo cambios de topología regeneran WGSL
</div>
</aside>
</main>
</div>
</div>
);
}
@@ -0,0 +1,505 @@
import { useState, useEffect, useRef, useCallback } from 'react';
import { Play, RotateCcw, AlertCircle } from 'lucide-react';
// ─────────────────────────────────────────────────────────────────
// Presets WGSL · cada uno es un shader completo autosuficiente
// ─────────────────────────────────────────────────────────────────
const SHADER_HEADER = `struct Uniforms {
time: f32,
resolution: vec2<f32>,
};
@group(0) @binding(0) var<uniform> u: Uniforms;
@vertex
fn vs(@builtin(vertex_index) i: u32) -> @builtin(position) vec4<f32> {
// triángulo fullscreen (truco de tres vértices)
let p = array<vec2<f32>, 3>(
vec2<f32>(-1.0, -1.0),
vec2<f32>( 3.0, -1.0),
vec2<f32>(-1.0, 3.0)
);
return vec4<f32>(p[i], 0.0, 1.0);
}
`;
const PRESETS = {
plasma: SHADER_HEADER + `
@fragment
fn fs(@builtin(position) pos: vec4<f32>) -> @location(0) vec4<f32> {
let uv = pos.xy / u.resolution;
let c = 0.5 + 0.5 * cos(u.time + uv.xyx + vec3<f32>(0.0, 2.0, 4.0));
return vec4<f32>(c, 1.0);
}
`,
circle: SHADER_HEADER + `
@fragment
fn fs(@builtin(position) pos: vec4<f32>) -> @location(0) vec4<f32> {
// SDF de un círculo con anillo y fondo sutil
let uv = (pos.xy / u.resolution) * 2.0 - 1.0;
let aspect = u.resolution.x / u.resolution.y;
let p = vec2<f32>(uv.x * aspect, uv.y);
let r = 0.4 + 0.05 * sin(u.time * 2.0);
let d = length(p) - r;
let fill = smoothstep(0.0, -0.01, d);
let ring = smoothstep(0.015, 0.0, abs(d));
let bg = vec3<f32>(0.05, 0.06, 0.08);
let col = mix(bg, vec3<f32>(0.94, 0.55, 0.72), fill) + vec3<f32>(ring * 0.9);
return vec4<f32>(col, 1.0);
}
`,
checker: SHADER_HEADER + `
@fragment
fn fs(@builtin(position) pos: vec4<f32>) -> @location(0) vec4<f32> {
// tablero rotando
let uv = pos.xy / u.resolution - 0.5;
let aspect = u.resolution.x / u.resolution.y;
let p0 = vec2<f32>(uv.x * aspect, uv.y);
let rot = u.time * 0.25;
let c = cos(rot);
let s = sin(rot);
let p = vec2<f32>(p0.x * c - p0.y * s, p0.x * s + p0.y * c) * 10.0;
let chk = (floor(p.x) + floor(p.y)) - 2.0 * floor((floor(p.x) + floor(p.y)) * 0.5);
let col = mix(vec3<f32>(0.93, 0.91, 0.86), vec3<f32>(0.10, 0.09, 0.13), chk);
return vec4<f32>(col, 1.0);
}
`,
waves: SHADER_HEADER + `
@fragment
fn fs(@builtin(position) pos: vec4<f32>) -> @location(0) vec4<f32> {
// interferencia de dos ondas senoidales
let uv = pos.xy / u.resolution;
let a = sin(uv.x * 20.0 + u.time * 2.0);
let b = sin(uv.y * 15.0 - u.time * 1.3);
let v = 0.5 + 0.5 * a * b;
let col = vec3<f32>(v, pow(v, 2.0) * 0.5 + 0.3, 1.0 - v * 0.7);
return vec4<f32>(col, 1.0);
}
`,
sdfBlob: SHADER_HEADER + `
fn sdCircle(p: vec2<f32>, r: f32) -> f32 { return length(p) - r; }
fn smoothMin(a: f32, b: f32, k: f32) -> f32 {
let h = max(k - abs(a - b), 0.0) / k;
return min(a, b) - h * h * h * k * (1.0 / 6.0);
}
@fragment
fn fs(@builtin(position) pos: vec4<f32>) -> @location(0) vec4<f32> {
// metaball · fusión de tres SDFs
let uv = (pos.xy / u.resolution) * 2.0 - 1.0;
let aspect = u.resolution.x / u.resolution.y;
let p = vec2<f32>(uv.x * aspect, uv.y);
let t = u.time;
let c1 = sdCircle(p - vec2<f32>(cos(t) * 0.4, sin(t * 1.3) * 0.3), 0.25);
let c2 = sdCircle(p - vec2<f32>(cos(t * 0.7 + 2.0) * 0.35, sin(t * 0.9) * 0.25), 0.22);
let c3 = sdCircle(p - vec2<f32>(sin(t * 1.1) * 0.3, cos(t * 0.6) * 0.35), 0.2);
let d = smoothMin(smoothMin(c1, c2, 0.35), c3, 0.35);
let fill = smoothstep(0.02, -0.02, d);
let glow = exp(-8.0 * max(d, 0.0));
let bg = vec3<f32>(0.04, 0.02, 0.06);
let core = vec3<f32>(0.95, 0.4, 0.6);
let col = bg + core * (fill * 0.9 + glow * 0.3);
return vec4<f32>(col, 1.0);
}
`,
};
const PRESET_ORDER = [
{ key: 'plasma', label: 'plasma', hint: 'cos-gradient clásico' },
{ key: 'circle', label: 'círculo sdf', hint: 'signed distance field' },
{ key: 'checker', label: 'tablero', hint: 'patrón rotando' },
{ key: 'waves', label: 'ondas', hint: 'interferencia senoidal' },
{ key: 'sdfBlob', label: 'metaball', hint: 'fusión suave de SDFs' },
];
// ─────────────────────────────────────────────────────────────────
// Hook: gestión de todo el ciclo WebGPU
// ─────────────────────────────────────────────────────────────────
function useWebGPU(canvasRef, code) {
const gpu = useRef({
device: null,
context: null,
format: null,
pipeline: null,
bindGroup: null,
uniformBuffer: null,
startTime: 0,
raf: 0,
});
const [status, setStatus] = useState('init'); // init · ready · unsupported · error
const [shaderError, setShaderError] = useState(null);
const [fps, setFps] = useState(0);
const [timeT, setTimeT] = useState(0);
// ── Inicialización ──
useEffect(() => {
let cancelled = false;
(async () => {
try {
if (!navigator.gpu) { setStatus('unsupported'); return; }
const adapter = await navigator.gpu.requestAdapter();
if (!adapter) { setStatus('unsupported'); return; }
const device = await adapter.requestDevice();
if (cancelled) return;
const canvas = canvasRef.current;
if (!canvas) return;
canvas.width = 400;
canvas.height = 400;
const context = canvas.getContext('webgpu');
const format = navigator.gpu.getPreferredCanvasFormat();
context.configure({ device, format, alphaMode: 'premultiplied' });
const uniformBuffer = device.createBuffer({
size: 16, // f32 time + pad + vec2<f32> resolution
usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST,
});
gpu.current = {
...gpu.current,
device, context, format, uniformBuffer,
startTime: performance.now(),
};
device.lost.then(() => setStatus('error'));
setStatus('ready');
} catch (e) {
console.error(e);
setShaderError(String(e.message || e));
setStatus('error');
}
})();
return () => { cancelled = true; };
}, [canvasRef]);
// ── Compilación del shader (debounced) ──
const compileShader = useCallback(async (wgsl) => {
const { device, format, uniformBuffer } = gpu.current;
if (!device) return;
device.pushErrorScope('validation');
const module = device.createShaderModule({ code: wgsl });
const info = await module.getCompilationInfo();
const errors = info.messages.filter(m => m.type === 'error');
if (errors.length > 0) {
const msg = errors.map(m => `línea ${m.lineNum}: ${m.message}`).join('\n');
setShaderError(msg);
await device.popErrorScope();
return;
}
try {
const pipeline = device.createRenderPipeline({
layout: 'auto',
vertex: { module, entryPoint: 'vs' },
fragment: { module, entryPoint: 'fs', targets: [{ format }] },
primitive:{ topology: 'triangle-list' },
});
const bindGroup = device.createBindGroup({
layout: pipeline.getBindGroupLayout(0),
entries: [{ binding: 0, resource: { buffer: uniformBuffer } }],
});
gpu.current.pipeline = pipeline;
gpu.current.bindGroup = bindGroup;
setShaderError(null);
} catch (e) {
setShaderError(String(e.message || e));
}
const err = await device.popErrorScope();
if (err) setShaderError(err.message);
}, []);
useEffect(() => {
if (status !== 'ready') return;
const id = setTimeout(() => compileShader(code), 180);
return () => clearTimeout(id);
}, [code, status, compileShader]);
// ── Render loop ──
useEffect(() => {
if (status !== 'ready') return;
let running = true;
let frames = 0;
let lastFpsSample = performance.now();
const loop = () => {
if (!running) return;
const { device, context, pipeline, bindGroup, uniformBuffer, startTime } = gpu.current;
const canvas = canvasRef.current;
if (device && context && pipeline && bindGroup && canvas) {
// redimensionado
const dpr = Math.min(window.devicePixelRatio || 1, 2);
const w = Math.max(1, Math.floor(canvas.clientWidth * dpr));
const h = Math.max(1, Math.floor(canvas.clientHeight * dpr));
if (canvas.width !== w || canvas.height !== h) {
canvas.width = w;
canvas.height = h;
}
const t = (performance.now() - startTime) / 1000;
const data = new Float32Array([t, 0, canvas.width, canvas.height]);
device.queue.writeBuffer(uniformBuffer, 0, data);
const encoder = device.createCommandEncoder();
const pass = encoder.beginRenderPass({
colorAttachments: [{
view: context.getCurrentTexture().createView(),
clearValue: { r: 0, g: 0, b: 0, a: 1 },
loadOp: 'clear',
storeOp: 'store',
}],
});
pass.setPipeline(pipeline);
pass.setBindGroup(0, bindGroup);
pass.draw(3);
pass.end();
device.queue.submit([encoder.finish()]);
frames++;
const now = performance.now();
if (now - lastFpsSample >= 500) {
setFps(Math.round((frames * 1000) / (now - lastFpsSample)));
setTimeT(t);
frames = 0;
lastFpsSample = now;
}
}
requestAnimationFrame(loop);
};
requestAnimationFrame(loop);
return () => { running = false; };
}, [status, canvasRef]);
const resetTime = useCallback(() => {
gpu.current.startTime = performance.now();
}, []);
return { status, shaderError, fps, timeT, resetTime };
}
// ─────────────────────────────────────────────────────────────────
// APP
// ─────────────────────────────────────────────────────────────────
export default function App() {
const canvasRef = useRef(null);
const [code, setCode] = useState(PRESETS.plasma);
const [activePreset, setActivePreset] = useState('plasma');
const { status, shaderError, fps, timeT, resetTime } = useWebGPU(canvasRef, code);
const loadPreset = (key) => {
setActivePreset(key);
setCode(PRESETS[key]);
};
const ACCENT = '#5eead4'; // teal-300
return (
<div className="w-full min-h-screen text-neutral-200" style={{
background: 'radial-gradient(ellipse at top, #0f1419 0%, #06080a 60%, #030506 100%)',
fontFamily: "'JetBrains Mono', ui-monospace, monospace",
}}>
<style>{`
@import url('https://fonts.googleapis.com/css2?family=Fraunces:ital,opsz,wght@0,9..144,300..900;1,9..144,300..900&family=JetBrains+Mono:wght@400;500;600;700&display=swap');
.font-display { font-family: 'Fraunces', serif; font-optical-sizing: auto; }
.font-mono { font-family: 'JetBrains Mono', ui-monospace, monospace; }
.grid-bg {
background-image:
linear-gradient(rgba(255,255,255,0.02) 1px, transparent 1px),
linear-gradient(90deg, rgba(255,255,255,0.02) 1px, transparent 1px);
background-size: 24px 24px;
}
::-webkit-scrollbar { width: 8px; height: 8px; }
::-webkit-scrollbar-track { background: transparent; }
::-webkit-scrollbar-thumb { background: rgba(255,255,255,0.08); border-radius: 4px; }
::-webkit-scrollbar-thumb:hover { background: rgba(255,255,255,0.16); }
textarea.wgsl {
tab-size: 2;
-moz-tab-size: 2;
}
`}</style>
<div className="grid-bg min-h-screen">
{/* HEADER */}
<header className="px-6 pt-6 pb-5 border-b border-white/5">
<div className="flex items-end justify-between flex-wrap gap-4">
<div>
<div className="flex items-baseline gap-3">
<span className="font-mono text-[10px] uppercase tracking-[0.3em] text-neutral-500">lab · 002</span>
<span className="font-mono text-[10px] text-neutral-700">/</span>
<span className="font-mono text-[10px] text-neutral-500">shaders · webgpu</span>
<span className="font-mono text-[10px] text-neutral-700">/</span>
<StatusBadge status={status} />
</div>
<h1 className="font-display text-3xl font-light tracking-tight mt-1.5">
laboratorio de <em className="italic" style={{color: ACCENT}}>píxels</em>
</h1>
<p className="font-mono text-[11px] text-neutral-500 mt-1.5 max-w-2xl">
editor WGSL en vivo · cada tecla recompila el fragment shader · el uniform block expone <span style={{color: ACCENT}}>time</span> y <span style={{color: ACCENT}}>resolution</span>
</p>
</div>
<div className="flex items-center gap-3">
<span className="font-mono text-[10px] text-neutral-600">t = {timeT.toFixed(2)}s</span>
<span className="font-mono text-[10px] text-neutral-600">{fps} fps</span>
<button
onClick={resetTime}
className="flex items-center gap-1.5 font-mono text-[10px] uppercase tracking-wider text-neutral-500 hover:text-neutral-200 border border-white/10 hover:border-white/30 px-3 py-1.5 rounded transition-colors"
>
<RotateCcw size={11} /> reset t
</button>
</div>
</div>
</header>
{/* MAIN · 2 columnas: canvas | editor */}
<main style={{
display: 'grid',
gridTemplateColumns: 'minmax(280px, 1fr) minmax(340px, 1fr)',
minHeight: 'calc(100vh - 110px)',
overflowX: 'auto',
}}>
{/* IZQUIERDA · CANVAS */}
<section className="p-4 md:p-6 border-r border-white/5 flex flex-col gap-3" style={{maxHeight: 'calc(100vh - 110px)'}}>
<div className="flex items-baseline gap-3">
<span className="font-mono text-[10px] uppercase tracking-[0.25em] text-neutral-500">canvas</span>
<span className="font-mono text-[9px] text-neutral-600">fragment shader · fullscreen triangle</span>
</div>
<div
className="flex-1 rounded-xl border border-white/10 overflow-hidden relative"
style={{
background: '#000',
boxShadow: `0 0 0 1px ${ACCENT}10, 0 20px 60px -30px ${ACCENT}30`,
minHeight: '320px',
}}
>
<canvas
ref={canvasRef}
className="block"
style={{width: '100%', height: '100%'}}
/>
{status !== 'ready' && <StatusOverlay status={status} error={shaderError} />}
</div>
<div className="font-mono text-[9px] text-neutral-600">
consejo · el vertex shader genera un triángulo que cubre toda la pantalla. todo el trabajo interesante pasa en el <span style={{color: ACCENT}}>fragment</span>.
</div>
</section>
{/* DERECHA · EDITOR */}
<section className="p-4 md:p-6 flex flex-col gap-3" style={{maxHeight: 'calc(100vh - 110px)'}}>
{/* presets */}
<div>
<div className="font-mono text-[10px] uppercase tracking-[0.25em] text-neutral-500 mb-2">presets</div>
<div className="flex flex-wrap gap-1.5">
{PRESET_ORDER.map(p => {
const active = p.key === activePreset;
return (
<button
key={p.key}
onClick={() => loadPreset(p.key)}
className="font-mono text-[11px] px-2.5 py-1 rounded transition-all"
style={{
background: active ? `${ACCENT}18` : 'rgba(255,255,255,0.02)',
border: `1px solid ${active ? ACCENT + '60' : 'rgba(255,255,255,0.08)'}`,
color: active ? ACCENT : '#a3a3a3',
}}
title={p.hint}
>
{p.label}
</button>
);
})}
</div>
</div>
{/* editor */}
<div className="flex flex-col flex-1 gap-2 min-h-0">
<div className="flex items-baseline gap-3">
<span className="font-mono text-[10px] uppercase tracking-[0.25em] text-neutral-500">fuente · wgsl</span>
{shaderError ? (
<span className="font-mono text-[10px] text-rose-400 flex items-center gap-1">
<AlertCircle size={10} /> error de compilación
</span>
) : (
<span className="font-mono text-[10px]" style={{color: ACCENT}}> compilado</span>
)}
</div>
<textarea
className="wgsl flex-1 w-full rounded-lg p-3 font-mono text-[12px] leading-relaxed resize-none outline-none"
value={code}
onChange={(e) => setCode(e.target.value)}
spellCheck={false}
style={{
background: 'rgba(255,255,255,0.02)',
border: `1px solid ${shaderError ? '#f43f5e40' : 'rgba(255,255,255,0.08)'}`,
color: '#d4d4d4',
minHeight: '300px',
}}
/>
</div>
{/* errores */}
{shaderError && (
<div className="rounded-lg p-3" style={{
background: '#f43f5e0a',
border: '1px solid #f43f5e30',
}}>
<div className="font-mono text-[10px] uppercase tracking-[0.2em] text-rose-400 mb-1.5 flex items-center gap-1.5">
<AlertCircle size={11} /> compilación fallida
</div>
<pre className="font-mono text-[11px] text-rose-300 whitespace-pre-wrap leading-relaxed">{shaderError}</pre>
</div>
)}
<div className="font-mono text-[9px] text-neutral-600 leading-relaxed border-l-2 border-neutral-800 pl-3">
uniforms · <span style={{color: ACCENT}}>u.time</span> (f32, segundos desde inicio) · <span style={{color: ACCENT}}>u.resolution</span> (vec2&lt;f32&gt;, px). el último pipeline válido se mantiene hasta que la próxima compilación tenga éxito.
</div>
</section>
</main>
</div>
</div>
);
}
// ─────────────────────────────────────────────────────────────────
// Sub-componentes
// ─────────────────────────────────────────────────────────────────
function StatusBadge({ status }) {
const map = {
init: { color: '#fbbf24', label: 'inicializando' },
ready: { color: '#5eead4', label: 'activo' },
unsupported: { color: '#f43f5e', label: 'sin webgpu' },
error: { color: '#f43f5e', label: 'error' },
};
const s = map[status] || map.init;
return (
<span className="flex items-center gap-1.5">
<span className="w-1.5 h-1.5 rounded-full" style={{background: s.color, boxShadow: `0 0 8px ${s.color}`}} />
<span className="font-mono text-[10px]" style={{color: s.color}}>{s.label}</span>
</span>
);
}
function StatusOverlay({ status, error }) {
if (status === 'ready') return null;
return (
<div className="absolute inset-0 flex items-center justify-center p-6 text-center" style={{background: 'rgba(0,0,0,0.7)', backdropFilter: 'blur(4px)'}}>
{status === 'init' && (
<div className="font-mono text-[11px] text-neutral-400">inicializando adaptador GPU</div>
)}
{status === 'unsupported' && (
<div className="max-w-sm">
<div className="font-display text-xl italic mb-2" style={{color: '#f43f5e'}}>WebGPU no disponible</div>
<div className="font-mono text-[10px] text-neutral-400 leading-relaxed">
este navegador no expone <code>navigator.gpu</code>. prueba con chrome/edge recientes, o safari 18+, o activa el flag en firefox nightly.
</div>
</div>
)}
{status === 'error' && (
<div className="max-w-sm">
<div className="font-display text-xl italic mb-2" style={{color: '#f43f5e'}}>error de inicialización</div>
<div className="font-mono text-[10px] text-neutral-400 whitespace-pre-wrap">{error}</div>
</div>
)}
</div>
);
}