tawasuyu

tawasuyu/llimphi

Sovereign retained-mode GPU UI framework in Rust (wgpu+vello+taffy+parley), Elm loop, 40+ widgets.

Rust 229TOML 101Markdown 82Shell 24052 Sutichaykuna

⎇ 168cd187519c0ad575d3ce63baee2f164c38d9d1 MANUAL.md Markdown · 2351 siq'ikuna · 128.6 KB Ñawpa willay ch'uya

chore: refresco desde el monorepo — 128 miembros, los 76 widgets y los 11 módulos b3f12e136 · Sergio ·

1# Manual de Llimphi
2
3> Motor gráfico soberano de tawasuyu. `wgpu` + `vello` + `taffy` + `parley`,
4> bucle Elm `input → update → view → layout → raster → present`.
5> Reemplazo total de GPUI (extinto 2026-05-26): toda app gráfica de la suite
6> corre sobre Llimphi.
7
8Este documento es la **referencia de uso** orientada a humanos y a IA.
9Está organizado para salto directo: cada capa, widget y módulo trae su API
10real (firmas copiadas del código). Para el **porqué** arquitectónico ver
11[`SDD.md`](SDD.md); para la regla de concurrencia ver
12[`COMPUTO-FUERA-DEL-HILO-UI.md`](COMPUTO-FUERA-DEL-HILO-UI.md).
13
14---
15
16## Índice
17
181. [Modelo mental en 60 segundos](#1-modelo-mental-en-60-segundos)
192. [Arquitectura — las capas](#2-arquitectura--las-capas)
203. [Quickstart — la app mínima](#3-quickstart--la-app-mínima)
214. [El trait `App` (bucle Elm)](#4-el-trait-app-bucle-elm)
225. [`Handle` — efectos y concurrencia](#5-handle--efectos-y-concurrencia)
236. [`View<Msg>` — el DSL declarativo](#6-viewmsg--el-dsl-declarativo)
247. [Layout (`taffy` / `Style`)](#7-layout-taffy--style)
258. [Eventos e interacción](#8-eventos-e-interacción)
269. [Texto](#9-texto)
2710. [Canvas custom y GPU directo](#10-canvas-custom-y-gpu-directo)
2811. [Theme y paletas](#11-theme-y-paletas)
2912. [Capas base (hal · raster · text · motion · icons · surface)](#12-capas-base)
3013. [Catálogo de widgets](#13-catálogo-de-widgets)
3114. [Catálogo de módulos](#14-catálogo-de-módulos)
3215. [`llimphi-workspace` — chasis tipo tmux](#15-llimphi-workspace--chasis-tipo-tmux)
3316. [Reglas duras y gotchas](#16-reglas-duras-y-gotchas)
3416.bis. [Tests de regresión visual (golden-image)](#16bis-tests-de-regresión-visual--llimphi-test-golden-image)
3516.ter. [Semántica accesible y tests por consulta](#16ter-semántica-accesible--tests-por-consulta)
3617. [Comandos y demos](#17-comandos-y-demos)
3718. [Cheat-sheet](#18-cheat-sheet)
3819. [Índice de crates](#19-índice-de-crates)
39
40---
41
42## 1. Modelo mental en 60 segundos
43
44Llimphi es **Elm sobre la GPU**. Una app es un tipo que implementa el trait
45`App` con cuatro piezas:
46
47- `Model` — estado **inmutable** de la app.
48- `Msg` — todo lo que puede pasar (`Clone + Send`).
49- `update(model, msg, handle) -> model` — transición **pura** que devuelve un
50  modelo nuevo.
51- `view(&model) -> View<Msg>` — función **pura** que describe la pantalla como
52  un árbol de `View`.
53
54El runtime hace el bucle: un evento (click/tecla/rueda) produce un `Msg`,
55`update` deriva el nuevo `Model`, `view` reconstruye el árbol, `taffy` calcula
56las cajas, `vello` rasteriza, y se hace swap del frame. **No hay mutabilidad
57compartida, no hay vDOM ajeno, no hay callbacks imperativos**: declaras qué se
58ve y qué `Msg` emite cada nodo.
59
60```
61   evento ─▶ Msg ─▶ update(model,msg) ─▶ model' ─▶ view(model') ─▶ View<Msg>
62                                                                      │
63   present ◀─ raster(vello) ◀─ layout(taffy) ◀──────────────────────┘
64```
65
66Tres reglas de oro:
671. **`view` es pura** — no muta nada, sólo lee el modelo y arma el árbol.
682. **Cómputo pesado va a un worker** vía `Handle::spawn`, nunca síncrono en
69   `update`/`init`/handlers (congela la ventana → "Not Responding").
703. **Widgets son visuales y stateless**; el estado vive en tu `Model`.
71   **Módulos** sí encapsulan estado + comportamiento.
72
73---
74
75## 2. Arquitectura — las capas
76
77```
784. llimphi-ui ........... runtime winit del bucle Elm (App, Handle, run, KeyEvent)
79   └ llimphi-compositor . árbol View<Msg>, mount sobre taffy, paint, hit-test (winit-free)
803. llimphi-layout ....... motor de layout (taffy: flexbox + grid)
812. llimphi-raster ....... rasterizador vectorial (vello) + backend GPU directo
821. llimphi-text ......... shaping + fuentes (parley): bidi, ligaduras, CJK/emoji
83   └ llimphi-glifos ..... atlas de cobertura: el texto del camino GPU-directo
840. llimphi-hal .......... abstracción de superficie (wgpu + winit / framebuffer)
85```
86
87El **split compositor/runtime** (2026-05-31) es importante: `llimphi-compositor`
88es *winit-free* (sólo `View`, `mount`, `paint`, hit-test). `llimphi-ui` lo corre
89sobre winit y **re-exporta todo el compositor**, así escribes `llimphi_ui::View`
90sin enterarte del split. Esto habilita un futuro runtime sobre el framebuffer
91del kernel `wawa` reusando el mismo compositor.
92
93Auxiliares: `llimphi-theme` (paletas), `llimphi-motion` (tweens),
94`llimphi-icons` (iconos vectoriales), `llimphi-surface` (texturas externas),
95`llimphi-workspace` (chasis tmux), `llimphi-gallery` (showcase).
96
97Catálogo: **76 widgets** (visuales, crates `llimphi-widget-*`) + **11 módulos**
98(features con estado). `ls -d widgets/*/` da **77** carpetas: la que sobra es
99`llimphi-clipboard`, que vive ahí sin ser un widget. Recontado el 2026-08-27;
100el número sale del comando, no de esta línea.
101
102---
103
104## 3. Quickstart — la app mínima
105
106```rust
107use llimphi_ui::llimphi_layout::taffy::prelude::*;
108use llimphi_ui::llimphi_raster::peniko::Color;
109use llimphi_ui::{App, Handle, View};
110
111#[derive(Clone)]
112enum Msg { Increment, Reset }
113
114struct Counter;
115
116impl App for Counter {
117    type Model = u32;
118    type Msg = Msg;
119
120    fn title() -> &'static str { "llimphi · counter" }
121
122    fn init(_: &Handle<Self::Msg>) -> Self::Model { 0 }
123
124    fn update(model: Self::Model, msg: Self::Msg, _: &Handle<Self::Msg>) -> Self::Model {
125        match msg {
126            Msg::Increment => model.saturating_add(1),
127            Msg::Reset => 0,
128        }
129    }
130
131    fn view(model: &Self::Model) -> View<Self::Msg> {
132        let boton = View::new(Style {
133            size: Size { width: length(160.0), height: length(56.0) },
134            align_items: Some(AlignItems::Center),
135            justify_content: Some(JustifyContent::Center),
136            ..Default::default()
137        })
138        .fill(Color::from_rgba8(60, 200, 130, 255))
139        .radius(12.0)
140        .text("+1", 28.0, Color::from_rgba8(10, 30, 20, 255))
141        .on_click(Msg::Increment);
142
143        View::new(Style {
144            flex_direction: FlexDirection::Column,
145            size: Size { width: percent(1.0), height: percent(1.0) },
146            align_items: Some(AlignItems::Center),
147            justify_content: Some(JustifyContent::Center),
148            gap: Size { width: length(0.0), height: length(24.0) },
149            ..Default::default()
150        })
151        .fill(Color::from_rgba8(20, 24, 32, 255))
152        .children(vec![
153            View::new(Style::default()).text(model.to_string(), 160.0, Color::WHITE),
154            boton,
155        ])
156    }
157}
158
159fn main() { llimphi_ui::run::<Counter>(); }
160```
161
162`Cargo.toml`:
163```toml
164[dependencies]
165llimphi-ui    = { workspace = true }
166llimphi-theme = { workspace = true }
167# + los widgets/modules que uses:
168# llimphi-widget-button = { workspace = true }
169```
170
171Corre con `cargo run -p <tu-crate> --release`. El ejemplo vivo está en
172`llimphi-ui/examples/counter.rs`.
173
174---
175
176## 4. El trait `App` (bucle Elm)
177
178Definido en `llimphi-ui/src/lib.rs`. El estado es inmutable; cada evento
179produce un `Model` nuevo.
180
181```rust
182pub trait App: 'static {
183    type Model: 'static;
184    type Msg: Clone + Send + 'static;
185
186    fn init(handle: &Handle<Self::Msg>) -> Self::Model;
187    fn update(model: Self::Model, msg: Self::Msg, handle: &Handle<Self::Msg>) -> Self::Model;
188    fn view(model: &Self::Model) -> View<Self::Msg>;
189
190    // --- Todo lo de abajo tiene default; sobreescribí lo que necesites ---
191
192    fn on_key(_model: &Self::Model, _event: &KeyEvent) -> Option<Self::Msg> { None }
193
194    fn on_wheel(_model: &Self::Model, _delta: WheelDelta,
195                _cursor: (f32, f32), _modifiers: Modifiers) -> Option<Self::Msg> { None }
196
197    /// Capa de overlay (menús, modales, popovers). Si devuelve `Some`, se pinta
198    /// encima y clicks/hover van EXCLUSIVAMENTE a ella (el fondo queda "bajo
199    /// vidrio"). La transición la maneja tu Model.
200    fn view_overlay(_model: &Self::Model) -> Option<View<Self::Msg>> { None }
201
202    /// Drag&drop de archivos desde el file manager. Un evento por archivo.
203    fn on_file_drop(_model: &Self::Model, _path: std::path::PathBuf) -> Option<Self::Msg> { None }
204
205    /// El foco cambió (Tab/Shift+Tab o click sobre un nodo `focusable`). El
206    /// runtime administra el foco; guardas `id` en tu Model para pintar el ring
207    /// y rutear el teclado. Ver §8 (Foco y teclado).
208    fn on_focus(_model: &Self::Model, _id: Option<u64>) -> Option<Self::Msg> { None }
209
210    /// IME (composición de texto: CJK, acentos muertos, emoji). Opt-in vía
211    /// `ime_allowed()` para no robarle el texto a las apps que sólo leen
212    /// `on_key`. Flujo: Enabled → Preedit* → Commit/Disabled. Ver §8 (IME).
213    fn ime_allowed() -> bool { false }
214    fn on_ime(_model: &Self::Model, _event: &ImeEvent) -> Option<Self::Msg> { None }
215    /// Área del caret en px físicos para ubicar la ventana de candidatos.
216    fn ime_cursor_area(_model: &Self::Model) -> Option<(f32, f32, f32, f32)> { None }
217
218    fn title() -> &'static str { "llimphi" }
219    fn app_id() -> Option<&'static str> { None }   // app_id del xdg-toplevel en Wayland
220    fn initial_size() -> (u32, u32) { (960, 540) }
221}
222```
223
224Punto de entrada: `pub fn run<A: App>()` — corre hasta que el usuario cierre la
225ventana o la app llame `Handle::quit`.
226
227**Eventos de teclado** (`KeyEvent`):
228```rust
229pub struct KeyEvent {
230    pub key: Key,                 // re-export de winit; usar NamedKey para teclas especiales
231    pub state: KeyState,          // Pressed | Released
232    pub text: Option<String>,     // texto resultante con IME/modifiers; None para flechas etc.
233    pub modifiers: Modifiers,     // { shift, ctrl, alt, meta }
234    pub repeat: bool,
235}
236```
237`Key` y `NamedKey` se re-exportan desde `llimphi_ui`.
238
239**Rueda** (`WheelDelta { x, y }`): normalizado a "líneas". Convención CSS:
240`y` positivo = scroll hacia abajo.
241
242**El sentido (natural / clásico) va por CLASE de dispositivo y no lo decide la
243app.** `llimphi_entrada::PoliticaRueda` tiene dos sentidos —uno para la rueda del
244ratón, otro para el touchpad—, y lo aplican las tres fronteras donde nace un
245`WheelDelta`: el runner de ventana, el de subventana y el de capa. Una `App`
246recibe el delta ya en el sentido que el humano eligió; **no lo inviertas de
247nuevo** (invertir dos veces cancela el ajuste). Invertir para *zoom* —rueda
248arriba = acercar— sí es tuyo: eso es semántica de la app, no sentido de scroll.
249
250La política la siembra el runtime al arrancar (`llimphi_ui::sembrar_politica_rueda`).
251En tawasuyu sale del bus `wawa-config` (`entrada.rueda_raton` / `entrada.rueda_touchpad`,
252que escribe wawa-panel) gracias a la feature `config-wawa`, encendida en el
253`[workspace.dependencies]` de la raíz; fuera del monorepo se fija a mano con
254`llimphi_entrada::fijar_politica_rueda`. Bajo `mirada` el runtime se queda neutro
255a propósito: ahí lo aplica el compositor en libinput, por dispositivo, y así lo
256obedece toda la sesión.
257
258---
259
260## 5. `Handle` — efectos y concurrencia
261
262`Handle<Msg>` es `Send + Clone`. Llega a `init` y `update`. Es el único modo
263legítimo de producir efectos sin romper la pureza de la transición.
264
265```rust
266impl<Msg: Send + 'static> Handle<Msg> {
267    pub fn quit(&self);                 // cierra la ventana / termina el bucle
268    pub fn dispatch(&self, msg: Msg);   // encola un Msg para el próximo turno
269    pub fn spawn<F: FnOnce() -> Msg + Send + 'static>(&self, f: F);   // worker; su Msg reentra al update
270    pub fn spawn_periodic<F: Fn() -> Msg + Send + 'static>(&self, period: Duration, f: F);  // tick periódico
271    pub fn for_test() -> Self;          // handle "muerto" para tests sin event loop
272    pub fn for_test_with<F: Fn(Msg) + Send + Sync + 'static>(sink: F) -> Self;  // tests que SÍ quieren los Msg
273
274    // --- ventana (sólo la primaria; en un handle `lift`ado son no-op) ---
275    pub fn set_fullscreen(&self, on: bool);   // pantalla completa sin bordes, en su monitor
276    pub fn set_minimized(&self, on: bool);    // minimizar / restaurar
277    pub fn set_content_type(&self, k: ContentType);  // qué mostrás: None/Photo/Video/Game
278    pub fn open_window(&self, key: u64, title: impl Into<String>, w: u32, h: u32);  // ventana OS secundaria
279    pub fn close_window(&self, key: u64);
280}
281```
282
283- **`on_modifiers`** (hook del trait `App`) — el `WindowEvent::ModifiersChanged`.
284  Existe porque **los handlers de click llevan un `Msg` plano y no reciben los
285  modificadores**: para hacer Shift+click (rango) o Ctrl+click (aditivo) en una
286  lista, la app guarda el estado en su modelo desde este hook y lo consulta en
287  el `update` del click. Devolvé `None` cuando el cambio no te importa (si no,
288  cada roce de Shift dispara un update y un repintado).
289
290- **`for_test` vs `for_test_with`** — `for_test` **descarta** los `Msg`; ojo con
291  eso: la closure de un `spawn` **sí se ejecuta** (para no perder sus efectos),
292  sólo se tira el mensaje. Un test que necesitaba el resultado terminaba
293  volviendo a hacer el trabajo a mano → el mismo job dos veces, en paralelo, y
294  carreras intermitentes sobre el disco. `for_test_with` entrega cada `Msg` a un
295  sink (típicamente el `Sender` de un canal, detrás de un `Mutex` porque el sink
296  pide `Sync`); el test lo drena y lo realimenta a `update`, que es lo que hace
297  el runtime real. Con eso el trabajo corre **una sola vez** y el test es
298  determinista.
299
300- **`set_fullscreen`** — `Fullscreen::Borderless` sobre el monitor donde ya
301  está la ventana (no cambia el modo de video). Es una **petición** al
302  compositor y el runtime **no lleva la cuenta**: el estado vive en el modelo
303  de la app, que decide cuándo entra y cuándo sale. Si el usuario sale por el
304  WM, tu `bool` queda desfasado — repetir el pedido es inocuo. El resize llega
305  por el camino normal (`Resized`), no hay que tocar la surface.
306- **`set_content_type`** — declara **qué** muestra la ventana
307  (`wp_content_type_v1`). No cambia nada de cómo pintás: es una pista para que
308  el compositor ajuste su política. mirada le **cede la pantalla** al contenido
309  con ritmo propio —deja de animar su fondo de marca, que le cuesta una
310  recomposición completa por latido— y con lo mismo decide VRR y tearing. Un
311  reproductor declara `Video` al arrancar y vuelve a `Photo` al pausar (un video
312  en pausa **es** una imagen fija) o a `None` al cerrar el medio; repetir el
313  mismo valor es gratis. Fuera de Wayland, o con un compositor que no exponga el
314  protocolo, es un no-op silencioso (avisa una vez por stderr).
315
316  Winit no habla ese protocolo: por debajo se monta una segunda conexión
317  wayland-client sobre el **mismo** `wl_display` y se envuelve la `wl_surface`
318  de winit — el mismo cruce que validó el spike `llimphi-video-plane`. La pista
319  es estado doble-buffereado, así que viaja con el **próximo cuadro** que
320  pintes. Ejemplo vivo: `cargo run -p llimphi-ui --example content_type`.
321- **`spawn`** — trabajo bloqueante (IO, PAM, parse, efemérides). El `Msg` que
322  devuelve la closure se entrega al `update` en el hilo de UI. **Este es el
323  patrón obligatorio para todo cómputo pesado** (§16).
324- **`spawn_periodic`** — feeds a intervalos: ticks de simulación (~11 Hz en
325  dominium), polling, animaciones por reloj. El thread muere cuando se cierra
326  el event loop.
327
328---
329
330## 6. `View<Msg>` — el DSL declarativo
331
332Un `View` = `Style` de taffy + relleno + texto/imagen/painter + handlers +
333hijos. Todo se arma con builders encadenables (`self -> Self`). Definido en
334`llimphi-compositor/src/view.rs`.
335
336```rust
337View::new(style: Style) -> View<Msg>
338```
339
340### Apariencia
341| Método | Efecto |
342|---|---|
343| `.fill(Color)` | color de fondo |
344| `.fill_gradient(Gradient)` | relleno con gradiente (autoreado en el cuadrado unidad `[0,1]²`, mapeado al rect). Gana sobre `fill`; `hover_fill` lo overridea en hover |
345| `.hover_fill(Color)` | color al pasar el cursor (habilita hit-test de hover) |
346| `.radius(f64)` | esquinas redondeadas (radio uniforme) |
347| `.radius_corners(tl,tr,br,bl)` | radio **por esquina** (CSS `border-radius` 4 valores); override de `.radius`. El borde sigue las 4 esquinas; la sombra usa el radio escalar |
348| `.shadow(Shadow)` | drop shadow (vello `draw_blurred_rounded_rect`). `Shadow::soft(alpha,blur)` + `.offset(dx,dy)`/`.spread(s)` |
349| `.border(width, Color)` | stroke sobre el contorno redondeado, inset hacia adentro (border-box) |
350| `.alpha(f32)` | opacidad de todo el subtree `[0,1]` (capa intermedia — no gratis) |
351| `.transform(Affine)` | afín 2D alrededor del centro del rect (estilo CSS `transform-origin:50% 50%`) |
352| `.animated(key, Duration)` | animación **implícita** estilo Flutter `AnimatedContainer`: si `fill`/`radius` cambian entre frames, el runtime interpola (ease-out cúbico) en vez de saltar. `key` estable entre rebuilds. `.animated_curve(key,dur,fn)` para otra curva |
353| `.clip(bool)` | recorta hijos al rect (paint + hit-test) |
354| `.image(Image)` | pinta `peniko::Image` centrada, preservando aspect ratio |
355| `.children(Vec<View<Msg>>)` | hijos |
356
357### Texto (ver §9)
358```rust
359.text(content, size_px, color)                            // centrado
360.text_aligned(content, size_px, color, Alignment)
361.text_aligned_italic(content, size_px, color, Alignment, italic)
362.text_aligned_full(content, size_px, color, Alignment, italic, font_family: Option<String>)
363.text_runs(content, size_px, default_color, runs: Vec<(usize,usize,Color)>, Alignment) // multicolor 1-pasada
364.line_height(mult)                                        // override interlínea (default 1.2)
365.text_weight(f32)                                         // peso CSS: 400 normal, 600 semibold, 700 bold
366.bold()                                                   // atajo de text_weight(700.0)
367.ellipsis(n)                                              // clampa a n líneas terminando en … (n=1 = single-line)
368.max_lines(n)                                             // clampa a n líneas sin glifo (corte seco)
369```
370
371### Interacción (ver §8)
372```rust
373.on_click(Msg)
374.on_click_at(|lx, ly, w, h| -> Option<Msg>)     // posición local + tamaño del rect
375.on_right_click(Msg) / .on_right_click_at(...)
376.on_middle_click(Msg)
377.on_pointer_enter(Msg) / .on_pointer_leave(Msg)
378.draggable(|phase: DragPhase, dx, dy| -> Option<Msg>)
379.draggable_at(|phase, dx, dy, lx0, ly0| -> Option<Msg>)   // + posición inicial del press
380.drag_payload(u64)                                        // payload que viaja con el drag
381.on_drop(|payload: u64| -> Option<Msg>)                   // este nodo es drop target
382.drop_hover_fill(Color)                                   // resaltado mientras un drag lo sobrevuela
383.on_scroll(|dx, dy| -> Option<Msg>)                       // rueda local (antes del on_wheel global)
384.on_scroll_take(|dx, dy| -> (Option<Msg>, rx, ry))        // reparto PARCIAL: consume lo que puede y el
385                                                          // sobrante sigue al ancestro (mismo evento);
386                                                          // scroll_y/lazy_list ya lo usan (scroll_take)
387.on_scale(|phase: GesturePhase, factor, fx, fy| -> Option<Msg>)  // pinch-to-zoom (Ctrl+rueda / trackpad)
388.on_double_tap(Msg) / .on_double_tap_at(|lx, ly, w, h| ...)      // dos clicks rápidos y cercanos
389.double_tap_exclusive()                                   // …y que ARBITRE contra el click del nodo
390.on_long_press(Msg) / .on_long_press_at(|lx, ly, w, h| ...)      // mantener ~500 ms quieto
391.focusable(u64)                                           // nodo enfocable por Tab/click (id opaco)
392```
393
394### Pintura custom (ver §10)
395```rust
396.paint_with(|scene: &mut vello::Scene, ts: &mut Typesetter, rect: PaintRect| { ... })
397.gpu_paint_with(|device, queue, encoder, view, rect: PaintRect, (vp_w, vp_h)| { ... })
398```
399
400Notas clave:
401- **Un nodo es draggable *o* clickable**, no ambos: `draggable` sobreescribe
402  `on_click`.
403- Las variantes `*_at` ganan sobre las simples si ambas están.
404- `PaintRect { x, y, w, h }` es el rect **absoluto** del nodo en píxeles físicos.
405- `DragPhase` = `Move` (un evento por `CursorMoved`, `dx/dy` = delta **desde el
406  evento anterior**, no acumulado) | `End` (al soltar).
407- **`double_tap_exclusive()`** rompe esa aditividad **para el doble-tap**, y es
408  la salida para el nodo que necesita las dos cosas en el mismo botón: el
409  runtime **retiene** el click del primer tap hasta que vence la ventana del
410  doble (400 ms) y lo **descarta** si el segundo llega. Cuesta latencia —el
411  click de un toque suelto sale al vencer, no al soltar—, por eso es opt-in: sin
412  esto una fila que elige con un click y abre con dos sigue disparando los dos, y
413  esa es la semántica normal. Lo estrena el **diente** del `rag-sidebar` (un
414  click despliega su panel, dos alternan el eje Flota/Fijo): sin arbitrar, el
415  doble desplegaba y replegaba el panel de paso, y en FIJO eso suelta y vuelve a
416  tomar la franja reservada del escritorio. Vale en los tres bucles (ventana,
417  `llimphi-layer` y `pata`).
418- **Gestos (`on_scale`/`on_double_tap`/`on_long_press`)** son **aditivos**: se
419  resuelven con su propio hit-test y no interfieren con `on_click`/`draggable`
420  del mismo nodo. El caso limpio (sin disparos cruzados) es ponerlos en un nodo
421  que **no** tenga `on_click` — p. ej. un canvas con `draggable` (pan) +
422  `on_scale` (zoom) + `on_long_press` (marca). `GesturePhase` = `Begin`/`Update`/
423  `End`; en `on_scale`, `factor` es **multiplicativo incremental** (`>1` agranda)
424  y `(fx, fy)` el focal local — `Ctrl+rueda` lo sintetiza en cualquier desktop
425  (Wayland/Windows no emiten el pinch del trackpad; macOS sí, vía `PinchGesture`).
426  El long-press lo arbitra el tiempo (~500 ms quieto); moverse (>8px) o soltar lo
427  cancela. Demo completo: `cargo run -p llimphi-ui --example gestos --release`.
428
429---
430
431## 7. Layout (`taffy` / `Style`)
432
433`Style` es el `taffy::Style` directo, re-exportado vía
434`llimphi_ui::llimphi_layout::taffy::prelude::*`. Es Flexbox + CSS Grid puro.
435
436Campos más usados:
437```rust
438Style {
439    flex_direction: FlexDirection::Row | Column,
440    size:    Size { width, height },          // length(px) | percent(0..1) | Dimension::auto()
441    min_size, max_size,
442    flex_grow: f32, flex_shrink: f32,
443    align_items:     Some(AlignItems::{Start,Center,End,Stretch}),
444    justify_content: Some(JustifyContent::{Start,Center,End,SpaceBetween,...}),
445    gap:     Size { width, height },
446    padding: Rect { left, right, top, bottom },   // con length(px)
447    margin:  Rect { ... },
448    ..Default::default()
449}
450```
451
452Helpers de `prelude`: `length(px)`, `percent(frac)`, `auto()`, `Dimension`,
453`Size`, `Rect`, `FlexDirection`, `AlignItems`, `JustifyContent`.
454
455`llimphi-layout` además expone:
456- `LayoutTree::new()` / `.clear()` (reuso entre frames), `.leaf(style)`,
457  `.node(style, &children)`, `.compute(...)`, `.compute_with_measure(F)`.
458- `Rect { x, y, w, h }` y `ComputedLayout { rects: HashMap<NodeId, Rect> }`.
459
460En el 99% de los casos no tocas `LayoutTree` a mano: lo maneja el runtime al
461montar tu `View`. Sólo armás `Style`s.
462
463---
464
465## 8. Eventos e interacción
466
467| Quiero… | Cómo |
468|---|---|
469| Botón / fila clickable | `.on_click(Msg)` (+ `.hover_fill` para feedback) |
470| Saber dónde se clickeó (canvas) | `.on_click_at(\|lx,ly,w,h\| ...)` → convertir a coords de mundo |
471| Menú contextual | `.on_right_click(Msg::OpenMenu{..})`, guardar pos en Model, abrir en `view_overlay` |
472| Abrir en pestaña nueva | `.on_middle_click(Msg)` |
473| Preview al pasar el mouse | `.on_pointer_enter(Msg)` / `.on_pointer_leave(Msg)` |
474| Resize de panel | `.draggable(\|phase,dx,dy\| ...)` acumulando delta en el Model |
475| Arrastrar entidad de un canvas | `.draggable_at(\|phase,dx,dy,lx0,ly0\| ...)` |
476| Drag&drop entre zonas | origen: `.drag_payload(id)`; destino: `.on_drop(\|id\| ...)` + `.drop_hover_fill` |
477| Scroll global | `App::on_wheel(model, delta, cursor, mods)` |
478| Área de scroll | widget `scroll_y(...)` (autocontenido) o `.on_scroll(\|dx,dy\| ...)` por nodo |
479| Zoom de canvas (pinch) | `.on_scale(\|phase,factor,fx,fy\| ...)` → `zoom *= factor`, reajustar pan al focal |
480| Doble-click | `.on_double_tap(Msg)` / `.on_double_tap_at(\|lx,ly,w,h\| ...)`; `+ .double_tap_exclusive()` si el mismo nodo tiene click |
481| Long-press (mantener) | `.on_long_press(Msg)` / `.on_long_press_at(\|lx,ly,w,h\| ...)` |
482| Teclado | `App::on_key(model, &KeyEvent) -> Option<Msg>` |
483| Foco / Tab | `.focusable(id)` en los nodos + `App::on_focus(model, id)` (ver abajo) |
484| IME (CJK, acentos) | `App::ime_allowed() -> true` + `App::on_ime(model, &ImeEvent)` (ver abajo) |
485| Drop de archivos del SO | `App::on_file_drop(model, path)` |
486
487**Patrón overlay** (menús/modales): el modelo guarda "menú abierto sí/no".
488Mientras esté abierto, `view_overlay` devuelve `Some(view)`; clicks fuera se
489cierran envolviendo los items en un scrim a pantalla completa con
490`on_click = DismissOverlay`. Cuando el modelo dice cerrado, `view_overlay`
491devuelve `None`.
492
493**Scroll** (widget `llimphi-widget-scroll`). `scroll_y(offset, content_len,
494viewport_len, content, on_scroll, &palette)` arma un viewport clipeado +
495contenido desplazado `-offset` + barra arrastrable. Es **stateless**: el offset
496vive en tu Model. `on_scroll(delta_px)` (rueda y arrastre) emite un delta a
497sumar; clampealo con `scroll::clamp_offset` en tu `update`. Helpers:
498`ensure_visible(offset, vp, item_top, item_h)` para llevar la selección a la
499vista (teclado); `approach(cur, target, factor)` para scroll suave hacia un
500objetivo (driveado por `Handle::spawn_periodic`).
501
502**Scroll 2D / física / slivers** (Tier 5, mismo widget, todo stateless):
503- `scroll_xy(offset:(x,y), content_size:(w,h), viewport_size:(w,h), content,
504  on_scroll:(dx,dy)→Msg, &palette)` — dos ejes, una barra por eje con overflow.
505- **Inercia (fling)**: `fling_step(velocity, dt, friction) → (v', delta)` +
506  `fling_settled(v)` — suelta con velocidad y avanza el offset por frame con el
507  ticker (`spawn_periodic`); `FLING_FRICTION`/`FLING_STOP` defaults. **Bounce**:
508  `rubber_band(overscroll, dim)` amortigua el desplazamiento más allá del tope.
509- **Sliver app-bar colapsable**: `sliver_app_bar(offset, header_max, header_min,
510  header(frac)→View, content, content_len, viewport_len, on_scroll, &palette)` —
511  un único offset colapsa el header (de max a min) y luego scrollea el cuerpo
512  bajo él; `header(frac)` recibe `frac∈[0,1]` para fundir título/subtítulo.
513  Clampeá con `sliver_max_offset(...)`. Helpers puros: `collapsed_height`,
514  `collapse_fraction`, `sticky_y(offset, section_top, section_h, header_h)` para
515  encabezados de sección pegados al tope. Demo: `cargo run -p
516  llimphi-widget-scroll --example scroll_avanzado`.
517
518**Foco y teclado.** Marca los nodos navegables con `.focusable(id)` (id `u64`
519que vos eliges). El runtime es la **única fuente de verdad** del foco: lo mueve
520con Tab/Shift+Tab en orden de árbol (envolviendo) y al clickear un nodo
521enfocable, y te avisa con `App::on_focus(model, Option<u64>)`. Guardas el id en
522tu Model para (a) pintar el ring (`if model.focus == Some(id) { .fill(accent) }`
523en `view`) y (b) rutear el teclado al campo activo desde `on_key`. No setees el
524foco por tu cuenta vía Msg: quedaría desincronizado del runtime.
525
526**IME** (composición de texto). Opt-in: `ime_allowed() -> true`. Con IME activo
527el texto compuesto **no** llega por `KeyEvent.text` sino por `on_ime`:
528`ImeEvent::Enabled` → uno o más `Preedit{text, cursor}` (texto en composición, a
529pintar subrayado en el caret) → `Commit(text)` (inserta como tecleado) o
530`Disabled`. Reporta el área del caret con `ime_cursor_area(model)` para ubicar
531la ventana de candidatos (CJK) junto al cursor.
532
533---
534
535## 9. Texto
536
537`TextSpec` (en compositor) describe el texto de un nodo:
538```rust
539pub struct TextSpec {
540    pub content: String,
541    pub size_px: f32,
542    pub color: Color,
543    pub alignment: Alignment,          // Start | Center | End | Justify
544    pub italic: bool,
545    pub font_family: Option<String>,   // string CSS con fallbacks
546    pub line_height: f32,              // múltiplo; default 1.2
547    pub runs: Option<Vec<(usize, usize, Color)>>,  // color por rango de BYTES
548}
549```
550
551- `Center` es el default (apto para labels). Para editores/párrafos usar
552  `.text_aligned(..., Alignment::Start)`.
553- **Multicolor en una sola pasada de shaping**: `.text_runs(...)` colorea
554  rangos de bytes — es la base del syntax highlighting (un nodo por línea, no
555  por token). Anclado arriba-izquierda; el caller dimensiona el rect.
556- El runtime mide el texto con parley durante el layout (`compute_with_measure`)
557  para que taffy reserve el alto real del texto envuelto a varias líneas
558  (evita "textos aplastados").
559- Shaping completo: bidi, ligaduras, kerning, fallback CJK/emoji vía fontique.
560
561---
562
563## 10. Canvas custom y GPU directo
564
565Dos hooks para pintar primitivas no expresables como composición de `View`s.
566Conviven en el mismo árbol; el runtime pinta **toda la pasada vello primero**,
567luego los `gpu_painter` en orden DFS.
568
569### `paint_with` — vía vello (el default)
570```rust
571.paint_with(|scene: &mut vello::Scene, ts: &mut Typesetter, rect: PaintRect| {
572    // dibujar BezPath, kurbo, texto con `ts`, etc. dentro de `rect`.
573    // NO dejar push_layer sin pop_layer; NO resetear la scene.
574})
575```
576Para: dominium-canvas, osciloscopios de pluma, charts de cosmos, pineal.
577Bueno hasta ~500 K primitivos por frame (rebuild) o ~2 M (Scene reusada).
578
579### `gpu_paint_with` — sube vertex buffers directo a wgpu, salta vello
580```rust
581.gpu_paint_with(|device, queue, encoder, view, rect: PaintRect, (vp_w, vp_h)| {
582    // abrir begin_render_pass con LoadOp::Load (NO clear) para preservar vello.
583    // (vp_w, vp_h) = tamaño en px de la TextureView destino, para calcular NDC.
584})
585```
586Para volumen masivo: starfield Gaia de cosmos, particles de tinkuy, viewport de
587nakui, pineal denso. Rango 100 K – 10 M+ primitivos. **Sí** soporta texto desde
5882026-08-05 (§12, `llimphi-glifos` + `add_glyph`) y múltiples grosores de stroke
589por flush desde el mismo día. Lo que sigue sin soportar es el AA analítico de
590curvas: los bordes de rects/líneas/tris salen del MSAA 4×, no de cobertura
591exacta. Para texto **con brush** (gradiente, imagen) o decoraciones sigue
592haciendo falta la Scene vello de `view_overlay`.
593
594### UI encima de contenido GPU — `View::over` y `paint_over`
595
596El orden del frame es `[vello base] → [gpu_paint] → [vello over] → [overlay/
597menús]`: **cualquier `View` normal que se solape con un `gpu_paint_with` queda
598tapado por él** (el blit corre después de la pasada base). Dos salidas:
599
600```rust
601// Subárbol entero de Views pintado en la pasada over (fills, texto, widgets,
602// hover — la maquinaria completa). El primitivo para un OSC de reproductor,
603// un HUD, un rail que solapa un canvas GPU. El hit-test no cambia.
604View::new(estilo_absoluto).over(true).children(vec![barra_de_transporte])
605
606// Closure vello suelta en la pasada over (sprites/texto AA sobre celdas
607// instanciadas — dominium, motor voxel).
608.paint_over(|scene, ts, rect| { ... })
609```
610
611Limitación de ambas: la capa over se compone con alpha sobre la intermedia;
612clips/alpha/transform de **ancestros fuera** del subárbol no la afectan. Caso
613canónico de `.over(true)`: el OSC estilo mpv de media-app
614(`02_ruway/media/media-app/src/vista.rs::overlay_bars_view`) y su fondo de
615marca (`src/fondo.rs::fondo_overlay`) flotando sobre el video.
616
617### ¿Cuándo cada uno?
618| Pregunta | vello (`paint_with`) | GPU directo (`gpu_paint_with`) |
619|---|---|---|
620| Primitivos/frame | < ~500 K rebuild / < ~2 M Scene reusada | 100 K – 10 M+ |
621| ¿Cambian cada frame? | sí, rebuild barato | mejor estático (buffer persistente) |
622| Curvas Bezier | nativas | hay que teselar |
623| Texto | sí, con brush | sí, color sólido (atlas de cobertura) |
624| AA fino | sí (analítico) | bordes por MSAA 4×; el texto trae el suyo |
625
626**Default: `paint_with`** salvo que ya midas que el volumen lo justifica
627(factores ~11× a 1M en GPU mid sólo en el régimen persistente). El backend GPU
628expone `GpuPipelines`/`GpuBatch` en `llimphi-raster` (§12).
629
630---
631
632## 11. Theme y paletas
633
634`llimphi-theme::Theme` es un struct de slots semánticos de color. Cuatro presets
635`const`: `Theme::dark()` (default), `light()`, `aurora()`, `sunset()`.
636
637```rust
638pub struct Theme {
639    pub name: &'static str,
640    // fondos
641    pub bg_app, bg_panel, bg_panel_alt, bg_input, bg_input_focus,
642    pub bg_button, bg_button_hover, bg_selected, bg_row_hover: Color,
643    // texto
644    pub fg_text, fg_muted, fg_placeholder, fg_destructive: Color,
645    // bordes y acento
646    pub border, border_focus, accent: Color,
647}
648
649Theme::all() -> Vec<Theme>                 // orden de rotación canónico
650Theme::by_name(name) -> Option<Theme>
651Theme::next_after(current_name) -> Theme   // para el theme-switcher
652```
653
654Tokens auxiliares en el mismo crate:
655- `motion::{FAST=80ms, NORMAL=160ms, SLOW=320ms}` + `ease_out_cubic`,
656  `ease_in_out_cubic`, `linear`.
657- `alpha::{SCRIM, GLASS_PANEL, DISABLED, HINT}` (constantes `u8`).
658- `radius::{XS=2, SM=4, MD=8, LG=12, XL=20}` (`f64`).
659
660**Patrón de widgets**: cada widget define su `XxxPalette` con
661`Palette::from_theme(&theme)`. Tu app guarda un `Theme` en el Model, deriva las
662paletas que necesita en `view`, y se las pasa a los widgets. Para cambiar de
663tema, el `theme-switcher` emite `Msg(next_theme)` y reconstruyes todo.
664
665---
666
667## 12. Capas base
668
669### `llimphi-hal` — superficie
670```rust
671Hal::new(compatible_surface: Option<&wgpu::Surface>) -> Result<Hal, HalError>   // async
672trait Surface { fn size(); fn resize(w,h); fn acquire() -> Result<Frame,_>; fn present(frame, hal); }
673WinitSurface::new(hal, window: Arc<Window>) -> Result<Self, HalError>
674Frame::view() -> &wgpu::TextureView;  Frame::size() -> (u32,u32)
675```
676`Hal::new` pide adapter `Backends::PRIMARY` (Vulkan) y cae a `all()` sólo si no
677hay — **no volver a `InstanceDescriptor::default()`**: el backend GL de Mesa
678sobre Wayland segfaultea en el teardown. El runtime de `llimphi-ui` ya maneja
679todo esto; sólo tocas HAL si escribes un runtime nuevo.
680
681### `llimphi-raster` — rasterización
682```rust
683Renderer::new(hal) -> Result<Renderer,_>
684Renderer::render(&mut self, hal, scene: &vello::Scene, frame: &Frame, base_color: Color)
685// GPU directo:
686GpuPipelines::new(device, color_format) -> Self   // campos: lines, tris, rects, bind_layout
687GpuBatch::new(&pipelines)
688  .line_width(w)                    // pluma: vale para lo que se agregue DESPUÉS
689  .add_line(p0,p1,color) .add_polyline(&pts,color)
690  .add_tri(a,b,c, ca,cb,cc) .add_tri_list(&verts,color) .add_rect(x,y,w,h,color)
691  .add_disc(cx,cy,r,color) .add_ring(cx,cy,r,stroke,color)
692  .atlas(&AtlasTextura)             // sin esto los quads NO se dibujan
693  .add_glyph(x,y,w,h,(u0,v0,u1,v1),color)   // quad texturado: el TEXTO
694  .scissor(x,y,w,h)                 // sólo se toca ese rect (damage)
695  .max_buffer_bytes(bytes)          // baja el techo por buffer; normalmente no se toca
696  .primitive_count() -> u32
697  .flush(device, queue, encoder, view, viewport, load_op)
698units_per_chunk(stride, max_bytes, multiplo) -> u32
699RECT_INSTANCE_STRIDE / LINE_INSTANCE_STRIDE / DISC_INSTANCE_STRIDE / TRI_VERTEX_STRIDE
700QUAD_INSTANCE_STRIDE
701// la textura de cobertura contra la que se muestrean los quads:
702AtlasTextura::new(device, &pipelines, lado) -> Self
703  .subir(queue, pixeles, (x,y,w,h))   // sube SÓLO ese rect, no el atlas entero
704```
705Re-exporta `vello` y `peniko` (`Color`, `Image`, `Fill`, etc.).
706
707Cuatro cosas del `flush` que conviene saber y no se ven en la firma:
708
709- **`line_width` es una pluma, no un modo del lote (cambió 2026-08-05).** Cada
710  línea se queda con el grosor que estaba puesto cuando se la agregó, así que
711  una grilla fina y una curva gruesa entran en el mismo `flush`. Antes el grosor
712  era un uniforme del lote entero y dibujar dos grosores obligaba a cortar —
713  y cortar no cuesta una draw call más: **cuesta el ciclo entero otra vez**,
714  porque cada `flush` arma su propia textura MSAA 4×, la resuelve y la compone
715  (2 texturas + 2 render passes por `flush`). El precio es 4 B más por línea
716  (36 en vez de 32), que baja el techo de un solo buffer de 8,4 M de líneas a
717  7,4 M; el troceado ya lo maneja.
718
719- **No hay techo de primitivas.** `max_buffer_size` del dispositivo (256 MB en
720  una Iris Xe) topa a ~8,4 M de instancias en **una** reserva, y pasado eso wgpu
721  rechaza: la app no se pone lenta, se cae. `flush` parte cada tipo en lotes y
722  dibuja uno por lote en orden, así que el alpha-over sale idéntico. En el caso
723  normal es un lote y las mismas cuatro draw calls. `max_buffer_bytes` existe
724  sobre todo para **testear** el troceado sin reservar cientos de MB.
725- **`scissor` recorta el costo, no sólo el dibujo.** Las texturas intermedias
726  (MSAA 4× + resolve) se crean del tamaño del rect sucio, porque el
727  `LoadOp::Clear` del attachment y el resolve son de toda la superficie y el
728  scissor no los toca. Sin eso, pintar un cursor de 32 px costaba limpiar y
729  resolver la pantalla entera. El rect se pide siempre en **coordenadas de
730  viewport**; `flush` baja el origen a par (las derivadas de `fwidth`, de las
731  que sale el AA del disco, se calculan por bloques de 2×2 alineados al target:
732  un corrimiento impar cambiaría la orla), así que puede tocar una fila y una
733  columna de más.
734- **Los quads sin atlas no se dibujan, y no revientan.** `add_glyph` acumula
735  igual, pero si nadie llamó a `atlas()` el `flush` los saltea en silencio. La
736  alternativa —un panic adentro del `flush`— sería reventar el frame de una app
737  porque alguien pidió texto antes de tener el atlas listo. Que falte el texto es
738  un síntoma leíble; que se caiga la ventana, no.
739
740### `llimphi-glifos` — el texto del camino GPU-directo
741```rust
742AtlasGlifos::new(lado) -> Self          // atlas cuadrado de cobertura, 1 B/px
743  .pixeles() -> &[u8]  .lado() -> u32  .version() -> u64  .entradas() -> usize
744  .tomar_sucio() -> Option<Sucio>       // qué cambió desde la última vez, y limpia
745  .limpiar()                            // única salida cuando `fallos` sube
746  .fallos / .aciertos / .rasterizados   // contadores públicos
747quads_de_layout(&mut atlas, &layout, (x,y), &mut Vec<QuadGlifo>) -> usize
748quads_de_run(&mut atlas, &glyph_run, (x,y), &mut Vec<QuadGlifo>)  // multicolor
749```
750Rasteriza cada glifo **una sola vez** a un mapa de 8 bits y lo empaqueta por
751estantes; de ahí en más pintar texto es pintar quads. Un renglón de 80
752caracteres pasa de 80 contornos rellenos a 80 instancias de 48 B.
753
754Lo que hay que saber antes de usarlo:
755
756- **No shapea.** Recibe un `parley::Layout` ya resuelto —el que arma
757  `llimphi_text::Typesetter`—, así que el texto por GPU-directo y el texto por
758  vello parten del **mismo** layout. Certificado:
759  `llimphi-test/tests/texto_gpu_directo.rs` pone los dos caminos sobre el mismo
760  layout y mide `Δ64:0` — ningún píxel difiere más de 64/255 — con la caja de lo
761  pintado coincidente y 0,31 % de tinta de diferencia.
762- **Un color sólido por run.** El atlas guarda cobertura, no color, así que el
763  mismo glifo cacheado sirve para cualquier color sin re-rasterizar — pero un
764  brush (gradiente, imagen recortada al texto) no entra. Eso sigue siendo vello.
765- **Ni emoji ni glifos de color**: el atlas es de un canal. Pedirían un atlas
766  RGBA y un pipeline propio.
767- **No hintea**, a propósito: vello rasteriza el contorno crudo, y hintear haría
768  que el mismo texto saliera con métricas distintas por cada camino.
769- **No crece ni desaloja.** Lleno, devuelve `None` y suma a `fallos`. La salida
770  es `limpiar()` y volver a llenar. Una política de desalojo sin una app que la
771  presione se diseña a ciegas.
772- **Subí sólo el rect sucio.** `tomar_sucio()` + `AtlasTextura::subir` es el
773  camino esperado; subir el atlas entero por frame anula el punto de cachear (un
774  atlas de 1024² son 1 MiB por cuadro).
775
776**Hay DOS atlas de glifos en llimphi, y hay que saber cuál usar.** El otro es
777`llimphi_widget_terminal::GlyphAtlas`, anterior (Fase 4 del SDD-TERMINAL) y vivo:
778lo consume el modo TUI de shuma. `llimphi-glifos` se escribió sin advertirlo.
779
780Desde el 2026-08-05 **comparten rasterizador** (`llimphi_glifos::Rasterizador`,
781swash/zeno); antes el terminal tenía `fontdue` propio. Lo que sigue habiendo dos
782es el **empaquetado**, y eso es de diseño:
783
784| | `GlyphAtlas` (widget terminal) | `AtlasGlifos` (`llimphi-glifos`) |
785|---|---|---|
786| clave | `char` (charmap, sin shaping) | `(fuente, id, tamaño, subpíxel, síntesis)` |
787| empaquetado | grilla de celdas iguales | estantes de tamaño variable |
788| subpíxel | no | 4×4 |
789| fuentes | una, monoespaciada | cualquiera, varias por renglón |
790
791**Cuál usar:** una grilla de terminal, `GlyphAtlas` — puede asumir monoespaciado,
792celdas enteras y cero shaping, y esas suposiciones son parte de por qué es
793rápido. Cualquier otro texto, `llimphi-glifos`.
794
795**Si empaquetás a tu manera**, no escribas un tercer rasterizador:
796`Rasterizador::glifo(datos, ClaveGlifo::de_glifo(id, tam))` devuelve el
797`MapaGlifo` (cobertura + colocación) y vos lo ponés donde quieras. Y para lo que
798no necesita shaping hay `metricas`, `id_de_caracter` y `avance`.
799
800**La migración se hizo contra dos oráculos, no a ojo:**
801- `tests/metricas_fontdue.rs` — `fontdue` sobrevive como **dev-dependency** para
802  esto: la celda sale **idéntica** en 11/12/13/14/16/18 px, y de 42 glifos
803  comparados sólo **1** difiere (1 px de caja, el `0` a 13 px).
804- `tests/dos_atlas.rs` — los dos empaquetados entregan el mismo glifo.
805
806Y un tropiezo que dejó regla: la primera corrida de `dos_atlas` daba **44 %** de
807diferencia. No era el rasterizador — `TextBlock::simple` deja
808`font_family: None` y parley cae a **sans-serif**, así que comparaba dos
809tipografías. **Si comparás glifos, fijá la familia primero.**
810
811**¿Cuándo conviene?** Medido en metal (Iris Xe/Vulkan, `llimphi-gpu-bench`
812sección «Texto», 12.096 glifos): el total baja **3,6-5,0×** contra vello, y lo
813que importa es la *forma* — el costo del atlas es casi plano con la cantidad de
814texto (5,0 → 5,5 ms entre 7.200 y 12.096 glifos) mientras vello crece lineal
815(13,6 → 26,4 ms). Pero la mitad de **CPU** del atlas es **peor** que la de vello
816(~0,7×): vello apila un id y una posición por glifo, el atlas hace una búsqueda
817en tabla y arma un quad. Toda la ganancia viene del lado GPU.
818
819O sea: el atlas es para **mucho texto que se repinta seguido** — grilla de
820terminal, editor, etiquetas sobre un canvas denso. Para un rótulo suelto no
821compra nada y encima paga el ciclo fijo del `flush` (MSAA + resolve +
822composite). **El default sigue siendo vello.**
823
824### `llimphi-image` — decode pipeline
825```rust
826decode_bytes(&[u8]) -> Result<peniko::Image, DecodeError>
827load_path(&Path, max_bytes: u64) -> Result<peniko::Image, DecodeError>
828from_rgba8(rgba: Vec<u8>, w: u32, h: u32) -> peniko::Image
829// mismo decode, pero además dice de qué espacio venía (para editores):
830decode_bytes_con_origen(&[u8]) -> Result<Decodificada, DecodeError>
831load_path_con_origen(&Path, max_bytes: u64) -> Result<Decodificada, DecodeError>
832Decodificada { imagen: Image, origen: Option<Perfil>, convertida: bool }
833Decodificada::devolver_al_origen(&self, rgba: &mut [u8]) -> bool
834// perfiles de color (mod perfil):
835perfil::leer_icc(&[u8]) -> Option<Perfil>          // matrix-shaper; None si no lo es
836perfil::convertir_a_srgb(&mut [u8], &Perfil) -> bool      // entrada: del perfil a sRGB
837perfil::convertir_desde_srgb(&mut [u8], &Perfil) -> bool  // salida: de sRGB al perfil
838perfil::matriz_a_srgb(&[f32;9]) -> [f32;9] / matriz_desde_srgb(&[f32;9]) -> Option<[f32;9]>
839perfil::es_identidad(&[f32;9], tol) -> bool
840Curva::{Lineal, Gamma(f32), Tabla(Vec<u16>), Parametrica{tipo, p}}
841Curva::a_lineal(v) -> f32 / Curva::desde_lineal(lin) -> f32   // una es inversa de la otra
842```
843Encapsula el patrón `image::ImageReader + to_rgba8 + Blob + Image::new` que cada
844app que decodifica imágenes tenía duplicado. `max_bytes` aplica al tamaño del
845archivo en disco (no a la imagen decodificada, que en RGBA8 puede ser mucho
846mayor — un PNG 4K ocupa ~64 MB descomprimido); `0` deshabilita el cap.
847Re-exporta `peniko::{Blob, Image, ImageFormat}`. Formatos según las features
848del crate `image` upstream del workspace (hoy: PNG, JPEG, WEBP).
849
850**Gestión de color (2026-08-05).** `decode_bytes` / `load_path` leen el perfil
851ICC embebido y llevan la imagen a **sRGB**. Antes se tiraba: una foto Display P3
852—lo que saca cualquier teléfono desde 2016— se pintaba **sobresaturada**, porque
853sus rojos, que son más rojos que los de sRGB, se mostraban como si fueran los de
854sRGB. Nadie tiene que hacer nada: el caller sigue recibiendo RGBA8 sRGB.
855
856- Cubre perfiles **matrix-shaper** (matriz 3×3 + curva), que es lo que son sRGB,
857  Display P3, Adobe RGB, Rec.2020 y casi todos los de pantalla y cámara.
858- **No** cubre los basados en LUT (`A2B0`), como los CMYK de imprenta: ahí no
859  convierte y deja la imagen como estaba, que es mejor que inventar.
860- Sin perfil se asume sRGB (la convención de la web). Con perfil sRGB, la matriz
861  da la identidad y **no se recorre un solo píxel**.
862- El recorte a gamut es por saturación de canal: un rojo P3 puro no existe en
863  sRGB y termina en el rojo sRGB más cercano.
864**Volver al espacio de origen (2026-08-05).** Un editor no puede conformarse con
865recibir sRGB: si `tullpu` abre una foto Display P3 y la guarda, tiene que
866guardarla **en P3**, no degradar el archivo del usuario por el solo hecho de
867haberlo abierto. Para eso están `decode_bytes_con_origen` / `load_path_con_origen`,
868que devuelven una `Decodificada` con el perfil del archivo al lado de los píxeles.
869
870```rust
871let d = llimphi_image::load_path_con_origen(ruta, 64 << 20)?;
872let mut px: Vec<u8> = d.imagen.image.data.as_ref().to_vec();   // sRGB, para editar
873// …edición…
874d.devolver_al_origen(&mut px);   // false si no hay a dónde volver: se guarda sRGB
875```
876
877- `origen: None` **no** significa «era sRGB»: significa que no había perfil o que
878  era de los que este crate no entiende (CMYK/LUT). En los dos casos los píxeles
879  quedaron como venían y no hay a dónde volver.
880- `convertida: false` con `origen: Some(…)` es el caso corriente: el archivo ya
881  venía en sRGB y no se recorrió un píxel.
882- **La ida y vuelta no es sin pérdida, por dos razones distintas.** La segunda
883  cuantización a 8 bits mete ±1. Y lo que se recortó al **entrar** (un rojo P3 que
884  en sRGB no existe) no vuelve: se perdió en el recorte, no al salir. Editar un P3
885  sin pérdida pide trabajar en P3; el `Perfil` expuesto es lo que lo habilita.
886- Quien sólo va a pintar sigue usando `decode_bytes` / `load_path` y no se entera:
887  son las mismas funciones, que ahora delegan y tiran el perfil.
888
889### `llimphi-text` — shaping
890```rust
891Typesetter::new()                          // una por proceso (FontContext es caro)
892  .layout(text, size_px, max_width, alignment, line_height, italic, font_family) -> Layout<()>
893  .layout_runs(text, size_px, default_color, &runs, alignment, line_height) -> Layout<RunBrush>
894TextBlock::simple(text, size_px, color, origin)
895layout_block(ts, &block) / measure(ts, &block) -> Measurement
896draw_layout(scene, &layout, color, origin) / draw_layout_runs(scene, &layout, origin)
897Alignment::{Start, Center, End, Justify}
898```
899
900### `llimphi-motion` — tweens
901```rust
902trait Lerp { fn lerp(self, other, t: f32) -> Self; }   // impl para f32,f64,(f32,f32),(f64,f64),Color
903Tween::new(from, to, duration, easing: fn(f32)->f32)   // o Tween::idle(value)
904tween.value() / .progress() / .done()
905animate(handle, duration, make_msg)                    // arranca los ticks del tween
906```
907Patrón: guardas `Tween<T>` en el Model, `animate(...)` en el update, la `view`
908lee `tween.value()` cada repaint. El tween se auto-termina.
909
910### `llimphi-icons` — iconos vectoriales (~23, grid 24×24)
911```rust
912Icon::{File, Folder, Save, Plus, Minus, X, Check, Edit, Trash, ChevronUp/Down/Left/Right,
913       Home, Search, Info, Warning, Error, Bell, Settings, More, ...}
914icon_view(Icon, color, stroke_width) -> View<Msg>
915paint_icon(scene, rect, icon, color, stroke_width)     // dentro de un paint_with
916```
917`stroke_width` en unidades del grid 24×24 (1.6 es armónico).
918
919### `llimphi-surface` — texturas externas
920```rust
921ExternalSurface::new(device, queue)        // barato de clonar (Arc<Mutex> interno)
922  .upload(&rgba, w, h)                      // desde otro hilo/decoder/cámara
923  .view(style) -> View<Msg>                 // blittea a su rect en el árbol Elm
924  .blit(queue, encoder, dst_view, rect, viewport)   // o manual desde gpu_paint_with
925```
926
927---
928
929## 13. Catálogo de widgets
930
931Los widgets son **funciones puras** que devuelven `View<Msg>` (o specs que se
932convierten a `View`). Son **stateless**: el estado vive en tu Model. Convención:
933cada uno trae `XxxPalette::from_theme(&Theme)`. Crates en
934`widgets/<nombre>/`, dep `llimphi-widget-<nombre>`.
935
936### Controles
937
938**button** — `button_view(label, &ButtonPalette, on_click: Msg) -> View`;
939`button_styled(label, style, alignment, &palette, on_click)`.
940
941**field** — wrapper de formulario (label + helper/error + requerido).
942`field_view(FieldSpec { label, control: View<Msg>, required, helper, error, palette })`.
943
944**text-input** — input single-line **con estado** `TextInputState`
945(`new()`/`masked()`, `text()`, `set_text()`, `apply_key(&KeyEvent) -> bool`,
946soporta undo/redo + selección con Shift). Render:
947`text_input_view(&state, placeholder, focused, &palette, on_focus: Msg)`.
948
949**text-area** — input **multilínea** con paridad real (mismo motor `EditorState`
950que el text-input; NO el viejo `String` plano). El estado es
951`TextInputState::multiline()` (o `llimphi_widget_text_area::new_state()`).
952`text_area_view(&state, placeholder, focused, rows, &palette, |ev| Msg::Area(ev))`
953emite `TextAreaEvent` (Key/Ime/Press(x,y)/Drag/DragEnd/Context) ya envueltos; en
954`update` → `state.handle_area(ev, &mut clipboard)`. Da: **click 2D** (renglón+columna
955por glifos reales), ↑/↓ entre renglones, selección a través de líneas (arrastre 2D,
956doble/triple-click, Shift+flechas), copiar/cortar/pegar, undo/redo, IME, Enter=`\n`,
957**soft-wrap** (la línea larga se envuelve al ancho de la caja) y **scroll vertical**
958(caret a la vista si hay más renglones que `rows`). Caret + selección pintados por
959`paint_over` en coords de renglón visual; fuente única del layout: `visual_layout`
960(render/hit-testing/caret/nav comparten). **↑/↓ navegan por renglón visual** (no por
961línea lógica: en texto envuelto bajan un renglón, no la línea entera), con columna
962meta en px preservada (`move_vertical_visual`; Shift extiende selección). El crate
963`llimphi-widget-text-area` es una fachada que re-exporta esto. Demo:
964`cargo run -p llimphi-widget-text-input --example area_demo`.
965
966**text-area RICO** — el mismo widget con los adornos que antes obligaban a
967escribirse un pintor propio. `text_area_view_rico(&state, focused, rows,
968&palette, &Metricas, &Adornos, wrap)`. **Si necesitás un input mono, coloreado,
969con fantasma o con caret animado, es ESTE, no un `paint_with` a mano.**
970- `Metricas { font_size, line_h, font_family, pad_x, pad_r, pad_y }` —
971  `Metricas::mono(px)` + `.con_zoom(z)`. La usan pintado, ajuste blando y
972  click→columna: una sola, o el caret cae entre glifos que no son los que se ven.
973- `Adornos { tramos, ghost, placeholder, caret, ahora_ms, marco }`.
974  `tramos: Vec<(ini, fin, Color)>` en **bytes del texto completo** (se recortan
975  solos a cada renglón); `ghost` = sufijo tenue tras el caret (sólo si el caret
976  está al final); `Placeholder { texto, color, italic, icono, icono_color, alpha }`;
977  `marco: false` = sin caja propia (para un host que ya tiene la suya — el borde
978  del widget es un relleno **opaco** que taparía un backdrop frosted).
979- `CaretEstilo::vivo()` = parpadeo que queda **sólido** tras cada tecla y
980  **estela** al moverse (el eyecandy en honor a kitty). El reloj lo trae el host:
981  `Adornos::ahora_ms` por cuadro + `state.marcar_actividad(ms)` en cada tecla —
982  el widget no llama a `now()` por dentro (sería intesteable, y un `bool` de fase
983  alternado por un timer externo se muestrea mal cuando el panel repinta lento).
984- Consultas para un host que **crece con el contenido**:
985  `renglones_visuales()`, `renglones_visuales_de(txt)`, `avance_en_renglon()`,
986  `ancho_caracter()`, `fijar_ancho_caja(px)` (sembrar el ancho antes del primer
987  cuadro, o testear el wrap sin GPU).
988`text_area_view` es esta misma vista con `Adornos::default()` — un solo
989renderizador. Uso canónico: el input de la barra de shuma
990(`02_ruway/shuma/sandbox/shuma-module-shell/src/view/input.rs`), que le pasa los
991tokens del shell como `tramos` y monta su propio marco con `marco: false`.
992
993**slider** — sin estado. `slider_view(label, value, min, max, &palette,
994on_change: Fn(DragPhase, delta_value) -> Option<Msg>)`. El delta viene en
995unidades, no píxeles.
996
997**color-picker** — selector RGBA: swatch actual + paleta de chips + barra
998de hue + sliders R/G/B/A (+ campo `#RRGGBB` opcional). Agnóstico, emite
999`[u8; 4]`. `color_picker_view(rgba: [u8;4], swatches: &[[u8;3]] (p.ej.
1000DEFAULT_SWATCHES), &ColorPickerPalette, hex: Option<HexField { focused,
1001state: Option<&TextInputState>, on_focus }>, on_change: Fn([u8;4]) -> Msg)`.
1002Helpers puros: `rgba_to_hex`, `parse_hex(s, cur_alpha)` (acepta 3/6/8
1003dígitos, `#` opcional), `color_picker_height(with_hex)` para pre-calcular
1004el alto.
1005
1006**switch** — `switch_view(progress: f32 [0..1], on_toggle: Msg, &palette)`. La
1007app guarda el `bool` y opcionalmente anima `progress` con un `Tween`.
1008
1009**segmented** — N opciones exclusivas. `segmented_view(&[&str], selected: usize,
1010make_msg: Fn(usize)->Msg, &palette)`.
1011
1012**select** — combobox/dropdown moderno (disparador cerrado + menú flotante en
1013`view_overlay`). Soporta **ítems ricos** (`SelectItem { label, sublabel, icon
1014(glifo unicode), badge: SelectBadge, enabled }`), **selección múltiple**
1015(`selected: &[usize]` con check ✓), **búsqueda** in-menu y **fases async**
1016(`SelectPhase::{Loading, Error(&str), Ready(&[SelectItem])}`) para listas que
1017vienen de `Handle::spawn`. El cerrado se pinta con `select_trigger_view(selected:
1018Option<&SelectItem>, placeholder, open, width, &palette, on_toggle: Msg)`; el
1019menú con `select_menu_view(SelectMenuSpec { anchor, viewport, width, phase,
1020visible: &[orig_idx], active, selected, query, searchable, empty_text, appear,
1021on_pick: Fn(orig_idx)->Msg, on_hover, on_dismiss, on_retry, palette })`. La app
1022mantiene `query`/`active`/`visible`/`selected` en el Model. Helpers puros para
1023el filtro: `filter(items, query) -> Vec<orig_idx>`, `step_active(items, visible,
1024current, ±1)` para nav, `resolve(visible, pos) -> Option<orig_idx>`.
1025
1026**progress** — `linear_progress_view(progress, track, fill, height)` y
1027`radial_progress_view(progress, track, fill, stroke_ratio)`. Sin eventos.
1028
1029**spinner** — `spinner_view(color, stroke_ratio, speed_rev_per_sec)`. Animado por
1030reloj absoluto; requiere redraws periódicos (`spawn_periodic`).
1031
1032**rive-button** — botón reactivo dirigido por **máquina de estados** (`llimphi-anim`,
1033estilo Rive) — primer consumidor real de la `StateMachine` fuera del studio.
1034**Stateful** (como `text-editor`): guardas `RiveButton` en tu Model. Tres estados
1035`idle`/`hover`/`press`, cada uno con su clip Lottie; hover por bool (listeners
1036Enter/Exit del motor), press por trigger (`ClipDone` lo devuelve solo). API:
1037`RiveButton::new(idle, hover, press, press_secs, blend_secs)` o `::builtin()`
1038(clips embebidos); `advance(dt)` desde un tick, `pointer(Option<(f64,f64)>)`,
1039`press()` desde el `on_click` del host, `current_state()`/`is_transitioning()`.
1040Render: `view(on_pointer: Fn(Option<(f64,f64)>)->Msg, on_click: Msg)`. Demo:
1041`cargo run -p llimphi-widget-rive-button --example rive_button_demo --release`.
1042
1043**badge** — `count_badge_view(count, BadgeKind)` ("99+" si ≥100) y
1044`dot_badge_view(BadgeKind)`. `BadgeKind::{Info,Success,Warning,Error,Neutral}`.
1045
1046**avatar** — `avatar_view(name, size_px)`: círculo determinista (color por hash
1047del nombre + inicial).
1048
1049**tooltip** — render puro. `tooltip_view(TooltipSpec { anchor, viewport, side:
1050Side, text, palette })`. Se monta en `view_overlay`; la app controla visibilidad
1051con `on_pointer_enter/leave`.
1052
1053**empty** — empty-state. `empty_view(Icon, title, description: Option<&str>, &palette)`.
1054
1055**skeleton** — placeholder con shimmer: una banda de gradiente `[low, high,
1056low]` que **cruza** el rect. `skeleton_view`, `skeleton_box_view(w,h,..)`,
1057`skeleton_line_view(w,..)`. Requiere redraws periódicos (`spawn_periodic(50ms)`
1058mientras haya skeletons visibles). Esas tres arrancan reloj propio y están
1059gateadas a `std`; `skeleton_view_en(inicio, ahora, &pal)` recibe el instante por
1060argumento y es la que sirve **sin `std`** (jaula/kernel), misma doctrina que el
1061`today` del calendar. La aritmética es pura y testeable: `banda_shimmer(ancho,
1062progreso) -> (izq, der)` —la banda entra y sale entera del rect, con piso de
1063ancho para que en un avatar chico el destello no sea un píxel— y
1064`progreso_shimmer(segundos) -> [0,1)`.
1065
1066**carousel** — pager paginado de N páginas. `carousel_view(CarouselSpec {
1067pages, current, wrap: CarouselWrap::{Wrap, Clamp}, show_arrows, palette,
1068on_change: Fn(usize) -> Msg })`. Dots indicadores abajo (clickeables para saltar
1069a la página i) + flechas opcionales `‹` / `›` a los costados. v1 sin swipe;
1070para v2 sumar swipe horizontal usando `draggable_velocity` + `fling_step` para
1071snap-on-release.
1072
1073**fitted-box** — escala un subárbol al slot del padre. `fitted_box((iw, ih),
1074BoxFit::{Contain, Cover, Fill, None, ScaleDown}, || inner_view())` aplica un
1075transform afín al inner (medido por el seam `LayoutBuilder`) para que entre en
1076el slot real, centrado, preservando aspect salvo `Fill`. Útil para imágenes,
1077canvases `paint_with` y texto grande que deben caber en celdas chicas sin que el
1078caller los re-mida. La función pura `compute_fit(slot, inner, fit) ->
1079(sx, sy, dx, dy)` es testeable.
1080
1081**calendar** — vista mensual 6×7 (`calendar_view(CalendarSpec { view_year,
1082view_month, selected, today, week_start, palette, on_select: Fn(NaiveDate)->Msg,
1083on_view_change: Fn(year, month)->Msg })`). Header con `< Mes Año >` para navegar
1084entre meses, fila de iniciales (`L M M J V S D` ó `D L M M J V S` según
1085`WeekStart`), grilla siempre de 6 filas (no reflowea al cambiar de mes). El
1086caller le inyecta `today` — el widget no toca el reloj. Base del date-picker:
1087combinar con `view_overlay` + un `field`/`button` disparador.
1088`calendar_view_ext(spec, CalendarOpciones { limites, rango, navegacion_anual })`
1089suma lo que un `<input type=date>` sí tiene, sin tocar a quien ya usa
1090`calendar_view` (el `Default` de las opciones **es** el calendario de siempre):
1091`Limites{min,max}` inclusive apaga los días fuera de rango (`aria_disabled`, sin
1092click) y las flechas cuyo mes destino esté entero afuera (`mes_habil`);
1093`RangoSeleccion` pinta la banda entre dos extremos y avanza sola con
1094`.click(fecha)` (1º fija inicio, 2º cierra **ordenando**, 3º empieza de nuevo;
1095dos clicks al mismo día = rango de un día), con `contiene`/`es_extremo`/`dias`/
1096`recortado(&limites)` —que **interseca**, no clampea cada punta— para el host;
1097`navegacion_anual` agrega una fila `« año »` arriba de la de mes (fila aparte y
1098no cuatro flechas en una: el ancho lo fija la grilla y «septiembre» no entraría).
1099Teclado: `cal_move_desde_tecla(&KeyEvent) -> Option<CalMove>` es el mapa del
1100picker nativo (flechas = día/semana, PageUp/Down = mes, **Ctrl**+PageUp/Down =
1101año, Home/End = bordes del mes) y `nav_date_limitada(fecha, mov, &limites)`
1102aterriza en el borde en vez de salirse.
1103
1104**accordion** — secciones plegables (expansion panels).
1105`accordion_view(AccordionSpec{secciones, abiertas: &[usize], modo, on_toggle,
1106palette})` con `Modo::{Una, Varias}`; la máquina de qué queda abierto es
1107`alternar(&abiertas, i, modo) -> Vec<usize>` (pura) y `todas(n, modo)` da el
1108«expandir/plegar todo» — en `Modo::Una` devuelve **una**, porque devolver todas
1109dejaría el `Model` en un estado que el propio modo prohíbe. El cuerpo llega como
1110`Fn() -> View` y **no se construye si la sección está cerrada**: en un acordeón
1111de veinte secciones con formularios adentro, armar los diecinueve invisibles es
1112el costo real. `Seccion::con_resumen("Visa ••4242")` muestra el dato a la
1113derecha **sólo cuando está cerrada**.
1114
1115**stepper** — asistente por pasos. `stepper_view(StepperSpec{pasos: &[Paso],
1116actual, cuerpo, on_ir, on_finalizar, palette})`; `Paso::{pendiente, actual,
1117hecho, con_error}`. La regla de navegación es pura: `puede_ir_a(pasos, actual,
1118destino)` —atrás siempre, adelante sólo si el actual está `Hecho`, de un salto
1119sólo si **todos** los intermedios lo están—, más `siguiente`/`anterior`/
1120`es_ultimo`/`hechos`. En el último paso el botón dice **Finalizar** y emite otro
1121`Msg`. Los botones del pie y los pasos inalcanzables se **apagan** con
1122`aria_disabled`, no desaparecen ni quedan mudos.
1123
1124**form** — **validación** de formularios sobre el `field`. Tres piezas, y la
1125tercera es la que se hace mal en todos lados:
1126```rust
1127Formulario::new(vec![                              // qué se pide
1128  CampoDef::new(Id::Correo, "Correo").reglas(vec![Regla::Requerido, Regla::Correo]),
1129  CampoDef::new(Id::Edad, "Edad").regla(Regla::Rango(18.0, 120.0)).ayuda("…"),
1130])
1131form.validar(&[(Id::Correo, &m.correo), …]) -> Errores<Id>   // qué está mal: TODOS
1132estado.tocar(id) · estado.enviar() · estado.visibles(&errores)  // cuándo se dice
1133campo_view(&form, id, control, &visibles, &pal)    // etiqueta + asterisco + error
1134resumen_errores_view(&visibles, Msg::IrA, &pal) -> Option<View>
1135```
1136`Regla::{Requerido, LargoMin, LargoMax, Numero, Rango, Correo, Propia(fn)}`. Un
1137valor **vacío pasa todas menos `Requerido`** (si no, un campo opcional en blanco
1138se queja de formato). El error se muestra al **salir** del campo o al **intentar
1139enviar**, nunca mientras se tipea por primera vez; tras el submit se muestran
1140todos, porque esconder alguno sería mentir sobre por qué no se envió.
1141`campo_view` saca del formulario la etiqueta, el **asterisco de obligatorio**
1142(derivado de las reglas, no de un flag aparte) y el error: los tres dejan de
1143escribirse dos veces. `resumen_errores_view` devuelve `None` si no hay nada que
1144decir, y cada ítem es clickeable para ir al campo — en un formulario largo el
1145error puede quedar fuera de pantalla y sólo se ve que el botón no hizo nada.
1146
1147**datetime-picker** — pickers de **fecha** y **hora**, los dos con la misma
1148forma: un `*_trigger` en el formulario (muestra el valor formateado o el
1149placeholder, emite `Msg` al click) y un `*_popup` que el host monta en
1150`App::view_overlay` con scrim que cierra al click-fuera. Sin estado propio: el
1151valor, si está abierto y el mes en foco viven en el `Model`.
1152```rust
1153date_picker_trigger(DateTrigger{valor, formato, placeholder, enabled, on_open, palette})
1154date_picker_popup(DatePopup{anchor, viewport, valor, view:(año,mes), today,
1155    week_start, opciones: CalendarOpciones, on_pick, on_view_change, on_dismiss,
1156    on_clear, acciones: Acciones::hoy_y_limpiar(), palette, theme})
1157time_picker_trigger(TimeTrigger{..}) · time_picker_popup(TimePopup{.., paso_minutos})
1158formatear_fecha(d, FormatoFecha::{Iso,DiaMesAnio,Largo}) · parsear_fecha(&str)
1159Hora{h,m}: formatear/parsear (FormatoHora::{H24,H12}) · mover(campo,±1,paso) · al_paso
1160```
1161El de fecha **compone el `calendar`**, así que hereda límites min/max, rango,
1162salto de año y mapa de teclas — no reimplementa la grilla. El de hora es grilla
1163de horas + minutos por paso: elegir la hora **conserva** el minuto y viceversa.
1164`parsear_fecha` acepta ISO y día-primero con `-`, `/` o `.`, y **rechaza** el año
1165de dos dígitos (adivinar en una fecha es peor que devolver `None`). `Hora`
1166resuelve el caso que todos yerran: medianoche es 12 AM y mediodía 12 PM, no 0.
1167
1168**banner** — tira de status. `banner_view(BannerKind::{Info,Success,Warning,Error}, message)`.
1169
1170### Contenedores y layout
1171
1172**panel** — chrome (gradiente + hairline accent). `panel_view(children, PanelStyle)`;
1173`PanelStyle::{from_theme, from_theme_large, neutral}`. `panel_signature_painter(style)`
1174para reusar el look en un `paint_with`.
1175
1176**card** — `card_view(children, CardOptions { accent, padding, gap, radius, signature }, &CardPalette)`.
1177
1178**stat-card** — métrica de dashboard. `stat_card_view(label, value, description,
1179accent, &recent_items, &palette)`.
1180
1181**tabs** — `tabs_view(TabsSpec { labels, active: usize, on_select: Fn(usize)->Msg,
1182content: View<Msg>, tab_height, palette, tab_width })`. Selección la maneja la app.
1183`tabs_view_sliding(spec, indicator_t)` cambia el acento por-tab (que se prende de
1184golpe) por un **realce único que se desliza** hasta `indicator_t` — el índice
1185animado que el host tween-ea con `llimphi-motion` hacia el activo. Como cada tab
1186mide lo que dice su label, interpola **posición y ancho** a la vez
1187(`indicator_bounds_px(&anchos, t)`; los anchos con `tab_widths(&labels,
1188tab_width, closable)`, la misma cuenta que el render y que `strip_width`), así se
1189estira al ir hacia un tab más largo. Con overflow viaja dentro de la tira, o sea
1190que el `strip_offset` lo arrastra solo. `indicator_t = active as f32` da la
1191posición estática, para un host que no quiera animar.
1192
1193**router** — navegación por **pila de rutas** con transiciones de página
1194(el `Navigator` de Flutter, a la Elm). La pila vive en el Model; el widget
1195pinta sólo la ruta actual y anima el cambio con la infra de ghosts del
1196compositor (`animated_switch_styled`). Push entra desde la derecha y lo viejo
1197retrocede 30 % (shared-axis X); pop lo inverso; `Zoom` = fade-through;
1198`Cover` = **push opaco estilo UIKit** (la nueva tapa sin fade; el pop desliza
1199la vieja por encima revelando — scene-split de fantasmas: el saliente se
1200pinta DEBAJO del entrante, en su posición z real); `None` = corte seco sin
1201captura. Los **hero** cruzan rutas gratis (misma key en ambas páginas).
1202```rust
1203// Model: nav: RouteStack<Ruta>   (enum propio: Clone + PartialEq + Hash)
1204m.nav.push(Ruta::Detalle(7));  m.nav.pop();  m.nav.replace(r);  m.nav.pop_to_root();
1205router_view(key, &m.nav, PageTransition::{None,Fade,Slide,Zoom,Cover}, dur,
1206    page: Fn(&Ruta) -> View<Msg>)          // mide su slot vía LayoutBuilder
1207router_view_sized(key, &m.nav, tr, dur, (w, h), page)  // slot explícito — para
1208    // páginas que anidan otro layout_builder (límite v1 del seam: sin anidar)
1209```
1210Demo: `cargo run -p llimphi-widget-router --example router_demo --release`
1211(hero volando entre Lista y Detalle; `T` cicla la transición, Esc vuelve).
1212
1213**hero** — shared-element transition (el Hero de Flutter auténtico): un nodo
1214con la misma `key` que aparece en otro rect en un frame posterior **vuela**
1215del rect viejo al nuevo (el `HeroRegistry` del runtime interpola `transform`).
1216`hero_view(key, child)` (480 ms) / `hero_quick(key, child)` (320 ms), o el
1217primitive `View::hero(key, dur)` directo. Key estable y única entre los heroes
1218de un mismo frame.
1219
1220**splitter** — divisor draggable de 2 panes. `splitter_two(Direction::{Row,Column},
1221a, a_size, b, b_size, on_resize: Fn(DragPhase, delta)->Option<Msg>, &palette)`.
1222`PaneSize::{Fixed(px), Flex}`. La app acumula el delta en su Model (con
1223`apply_resize(actual, delta, min, max)`, que clampea por paso).
1224`splitter_two_ext(…, SplitterOpciones { on_toggle, focus_id })` agrega el
1225**doble-click** sobre el divisor y lo hace **enfocable**, que es la puerta del
1226resize por teclado: con el foco puesto, el host traduce la tecla con
1227`ajuste_desde_tecla(&ev, direction) -> Option<AjusteSplitter>` (las flechas del
1228eje del divisor —←/→ en `Row`, ↑/↓ en `Column`—, `Shift` para el paso grande,
1229Home/End a los topes, Enter/Espacio para alternar) y la aplica con
1230`aplicar_ajuste`. El colapso lo resuelve `alternar_colapso(actual, recordado,
1231por_defecto) -> (nuevo, a_recordar)`: guarda **cuánto** medía, no que estaba
1232abierto, así restaurar devuelve el layout del usuario y no un ancho de fábrica.
1233
1234**scroll** — área de scroll vertical con barra arrastrable. `scroll_y(offset,
1235content_len, viewport_len, content, on_scroll: Fn(delta_px)->Msg, &palette)`.
1236Stateless (offset en el Model); rueda autocontenida. Helpers: `clamp_offset`,
1237`ensure_visible` (selección a la vista), `approach` (scroll suave). Ver §8.
1238Para darle scroll al cuerpo de un widget de filas está la pieza **compartida**
1239—la usan `list`, `tree` y `detail-table`, y por eso vive acá y no copiada tres
1240veces—: `cuerpo_scrolleable(contenido, contenido_len, DesplazamientoV{offset,
1241viewport_len, on_scroll}, medidor: Option<AltoVisible>, &palette)`.
1242`AltoVisible` publica el alto **medido** desde el pintado (el alto disponible no
1243existe hasta que taffy repartió) y `offset_efectivo(offset, viewport_len)` pinta
1244desde arriba mientras no haya medida — `clamp_offset` con viewport `0` da por
1245bueno cualquier offset, así que sin esto una lista re-montada con offset
1246guardado arranca desplazada y salta al cuadro siguiente.
1247
1248**lazy-list** — lista **perezosa** llave-en-mano (Bloque 22 de
1249`PLAN-NUCLEO-RETENIDO.md`). A diferencia de `list`/`grid` (el caller windowea a
1250mano) y de `scroll_y` (recibe el contenido ya construido), recibe un **builder**
1251`item: Fn(usize) -> View` + `total` y **sólo llama `item(i)` para la ventana
1252visible + overscan** — las filas fuera de pantalla no se construyen, no se
1253montan, no se layoutean (costo/frame ∝ lo visible, no ∝ el total). Sin estado
1254(offset en el Model); reusa la barra/física de `scroll`.
1255```rust
1256lazy_list_fixed(total, item_h, offset, viewport_h, overscan,
1257    item: Fn(usize)->View, on_scroll: Fn(f32)->Msg, &ScrollPalette)   // extent uniforme, ventana O(1)
1258lazy_list_varied(offsets: &[f32] /*len total+1, tops acumulados*/, offset, viewport_h,
1259    overscan, item, on_scroll, &palette)                             // extent por fila, O(log n)
1260// extent CALCULABLE barato (el sabor a preferir para listas grandes de alto variado):
1261IndiceExtent::nuevo(total, extent: Fn(usize)->f32)   // índice por bloques de BLOQUE_EXTENT (1024)
1262lazy_list_extent(&indice, extent, offset, viewport_h, overscan, item, on_scroll, &palette)
1263// guarda 1 float cada 1024 filas en vez de 1 por fila (1M filas: 978 floats ≈ 3,9 KB
1264// contra 4 MB) y NUNCA construye un View para medir. Consulta = binaria + ≤1 bloque.
1265// helpers puros: visible_window(total, item_h, offset, vp, overscan) -> Window{first,count,y0}
1266//               visible_window_varied(offsets, offset, vp, overscan), content_height(total, item_h)
1267//               visible_window_extent(&indice, &extent, offset, vp, overscan)
1268//               IndiceExtent::{y, indice_en, alto_total, len_indice}
1269// altura MEDIDA (listas heterogéneas cuyo alto depende del contenido/reflow):
1270item_offsets(total, ancho, item: Fn(usize)->View) -> Vec<f32>   // mide cada ítem → offsets acumulados
1271// el caller lo llama al cambiar contenido/ancho, cachea el Vec, y por frame usa
1272// lazy_list_varied(&offsets, ...). Usa llimphi_compositor::measured_height(view, ancho).
1273// SECCIONES con encabezado sticky llave-en-mano (agenda/contactos):
1274lazy_list_sections(rows_por_seccion: &[usize], item_h, header_h, offset, viewport_h,
1275    overscan, header: Fn(s)->View, item: Fn(s, fila_local)->View, on_scroll, &palette)
1276// el header de la sección activa queda PINEADO al tope y la siguiente lo empuja;
1277// darle fill opaco (pinta encima de las filas). Ventana O(#secciones + visibles).
1278// puros: section_tops, sections_window -> Vec<SectionUnit>, sticky_section
1279// re-exporta de scroll: clamp_offset, fling_step, rubber_band, sticky_y, DEFAULT_OVERSCAN, ScrollPalette
1280```
1281Demo: `cargo run -p llimphi-widget-lazy-list --example stress_lazy --release -- --nodes 1000000`.
1282
1283**tiled** — grilla auto cols×rows de tiles con title bar. `tiled_view(tiles, &palette)`,
1284`tiled_view_cols(tiles, cols, &palette)`, y variantes `*_reorderable*` con
1285`on_reorder: Fn(from, to)->Option<Msg>` (drag-to-swap por la title bar). `TileSpec { label, content }`.
1286
1287**panes** — árbol binario BSP tipo tmux. La app guarda un `Layout`:
1288```rust
1289Layout::single(id) / Layout::Split { axis: Axis, ratio, first, second }
1290layout.split(target, new, axis) / .without(target) / .resize(&path, delta) / .leaves()
1291panes_view(&layout, focused: PaneId, leaf: FnMut(PaneId)->View, on_resize: Fn(Vec<Side>,DragPhase,delta)->Option<Msg>,
1292           on_focus: Fn(PaneId)->Msg, &palette)
1293```
1294
1295**dock-rail** — rail vertical de **dientes** para sidebars acoplables. Cada
1296diente es una pestaña: **barra de acento de 3px pegada al borde interno**
1297(encendida cuando está activo) + icono centrado. Apilados en una franja
1298flotante que **sobresale del panel** hacia el centro — el patrón canónico
1299de cosmos (`01_yachay/cosmos/cosmos-app-llimphi/src/chrome.rs`:
1300`dock_rail_overlay` / `dock_panel_for`). Cada item se identifica por un `u64`
1301opaco; el caller mantiene el orden, qué está activo, y el mapping
1302`u64 ↔ DominioPropio` (típicamente vía `enum::{to_u64,from_u64}`). Cada
1303diente es **arrastrable** (su id como payload) y el rail entero es **drop
1304target** → mover un diente de un sidebar a otro soltándolo sobre el rail
1305opuesto. Render-only, agnóstico del `Msg`.
1306```rust
1307DockRailItem { id: u64, active: bool }
1308dock_rail_view(&items, width: f32, &DockRailPalette,
1309    make_icon: Fn(id, size_px, color) -> View<Msg>,
1310    on_activate: Fn(id) -> Msg,                  // dispara en el press, no pelea con el drag
1311    on_drop:     Fn(payload_id) -> Option<Msg>)  // None = el rail no acepta drops
1312```
1313Layout canónico: el rail va como **overlay absoluto** pegado al borde interno
1314del centro (los dientes sobresalen sobre la rueda/canvas), y el panel del
1315item activo va al costado como un pane en el área resizable. NO es una lista
1316con rótulo: leer cosmos antes de inventar UX (lección 2026-06-05).
1317
1318**fugaces** — la **banda de controles fantasma**: más acciones y estados que
1319píxeles, resueltos por *voz* en vez de por espacio. No pinta íconos ni sabe qué
1320representan — es la **política**, y el caller pone la identidad (trait
1321`Fantasma`: una clave estable para persistir el uso y un `prior` de asiento) y
1322el dibujo de cada cara.
1323
1324Cada fantasma declara por cuadro su [`Nivel`]: `Nada` (sólo en el reveal —
1325acciones puras) · `Ambiente` (mirable, turno lento y al 55 % de alfa) · `Leve`
1326(estado que dura; se turna cada 4 s) · `Evento` (acuse de segundos: **no espera
1327turno**) · `Critica` (sale fija, y todas juntas). En reposo se ve una sola cosa;
1328al acercar el puntero salen todas.
1329
1330Las tres reglas que no son obvias, y que ya costaron reportes: el **orden se
1331congela** bajo el mouse (un click aprende, pero el asiento nuevo rige recién al
1332esfumarse el reveal); la **membresía** también (el que llega a mitad del hover
1333no se inserta, y el que se va deja un **hueco de su ancho**); y el asiento sale
1334de `prior + 2·√usos`, que **satura** — los primeros clicks mandan y después el
1335orden queda quieto, que es lo que permite memorizarlo.
1336
1337Dos consumidores, dos formas del mismo patrón: `pata` lo monta como overlay a la
1338derecha del cabezal de shuma (estados del sistema), y `nahual-shell` como banda
1339al borde derecho de la barra de herramientas (`nahual-shell-llimphi::fugaces`),
1340donde **la acción aparece cuando el contexto la vuelve posible** — marcás dos
1341archivos y sale *cotejar*.
1342```rust
1343trait Fantasma { fn clave(self) -> &'static str; fn prior(self) -> f32; }
1344elegir_visibles(&[(F, Nivel)], idx, pin) -> Vec<F>   // qué se ve sin reveal
1345avanzar_idx(&mut idx, &mut reloj, ahora_us, congelar) -> bool
1346orden_asientos(&todos, usos: Fn(F) -> u32) -> Vec<F>
1347congelar(&todos, &cands, usos, idx, pin, ahora_us) -> Freeze<F>
1348slots(cands: Vec<(F, Nivel, View<Msg>)>, &Franja<F>, usos, ancho_slot) -> Vec<View<Msg>>
1349Uso::abrir(Option<PathBuf>) / .bump(f) / .usos(f)    // feature `persistencia`
1350```
1351El contenedor es del caller: `slots` devuelve la fila lista, no una `View` — la
1352franja caliente, el ancla y el `hover_fill` los pone quien la hospeda.
1353
1354**toolbar** — barra de herramientas moderna: **grupos** de botones-ícono
1355planos (hover redondeado; activo = fondo de selección + ícono en acento;
1356deshabilitado atenuado y sin click) con separadores sutiles entre grupos.
1357Los íconos los dibuja el caller vía closure (mismo contrato que dock-rail);
1358los grupos son **datos** (`Vec<ToolbarGroup>`) → la barra es componible y
1359configurable por construcción. Uso canónico:
1360`nahual-shell-llimphi::shell_toolbar` (navegación + modos de vista + acciones).
1361```rust
1362ToolbarItem::new(icon: Fn(size_px, color) -> View<Msg>, on_click: Msg)
1363    .with_label("subir").active(bool).enabled(bool)
1364ToolbarGroup::new(vec![items...])
1365toolbar_view(groups: Vec<ToolbarGroup<Msg>>, height: f32, &ToolbarPalette) -> View<Msg>
1366// Overflow + tooltips (aditivo; Default = la barra de siempre):
1367let medidor = AnchoBarra::new();                    // se lee el cuadro siguiente
1368let anchos  = item_widths(&groups);                 // misma cuenta que el render
1369let visibles = overflow_count(&anchos, medidor.px(), MORE_W, ITEM_GAP);
1370let ocultos  = items_ocultos(&groups, visibles);    // para armar el popup
1371toolbar_view_ext(groups, 36.0, &pal, ToolbarOpciones {
1372    medidor: Some(medidor), visibles: Some(visibles),
1373    on_overflow: Some(Msg::AbrirMas),               // dibuja «⋯» sólo si sobra algo
1374    on_hover: Some(Arc::new(|i| Msg::Tooltip(i))),  // Some(i) al entrar, None al salir
1375})
1376```
1377El **ancho disponible** no se sabe hasta que taffy repartió, así que `AnchoBarra`
1378lo publica desde el pintado y el host lo usa en el cuadro siguiente (un frame de
1379latencia en el primero y en el resize — lo mismo que hace cualquier toolbar con
1380overflow). `item_offsets(&groups)` da `(left, width)` de cada ítem contando
1381padding, gaps **y separadores**: es el ancla del `tooltip` (que es render puro y
1382lo monta el host en su `view_overlay`). El menú de overflow también lo monta el
1383host, con `context_menu_view`; `items_ocultos` le da label/estado/`Msg` en orden
1384para que el índice del `on_pick` no se desincronice.
1385
1386**grid** — grilla 2D virtualizada. `ventana_visible(total, vp_w, vp_h, scroll_fila,
1387&metrics) -> VisibleWindow` para virtualizar, luego `grid_view(GridSpec { cells:
1388Vec<GridCell { content, label, selected, on_click }>, cols, metrics, caption, ... })`.
1389
1390**list** — lista vertical virtualizada. `list_view(ListSpec { rows: Vec<ListRow {
1391label, selected, on_click }>, total, caption, truncated_hint, row_height, palette })`.
1392La app prefiltra las filas visibles. `list_view_ext(spec, ListOpciones{scroll,
1393medidor})` le da **scroll real** (no sólo `clip`): las filas se desplazan y
1394tienen barra arrastrable, mientras el `caption` y el aviso de truncado quedan
1395fijos — son rótulos de la lista, no contenido de ella.
1396
1397**tree** — árbol expand/collapse. `tree_view(TreeSpec { rows: Vec<TreeRow { label,
1398depth, has_children, expanded, selected, on_toggle, on_select }>, row_height,
1399indent_px, palette })`. La app aplana el árbol según nodos expandidos.
1400`tree_view_ext(spec, TreeOpciones{scroll, medidor})` le da el mismo scroll real
1401que la `list`. Cada fila acepta además `icon` (un `View`), `color_label`
1402(teñir por tipo/estado), `trailing`, `on_context` y `on_double`.
1403`tree_view_2d(spec, Vista2D{offset, viewport, ancho_contenido, on_scroll,
1404medidor, fijadas})` agrega lo que un árbol de proyecto necesita y una lista
1405indentada no: **rueda en los dos ejes** (un nombre anidado no entra en un panel
1406angosto y el `…` no lo hace legible) y **filas fijadas** — `FilaFijada{fila,
1407y_natural, huerfana}` se apila contra el borde por el que salió del viewport
1408(`reparto_fijadas`, `cupo_de_pila` = un tercio del alto), y una `huerfana` (su
1409carpeta está plegada, no tiene fila en el cuerpo) se muestra igual. El ancho del
1410contenido se estima con `ancho_de_filas`. El uso canónico es `nada::arbol`, que
1411es el mismo árbol para el editor suelto y para el panel del anfitrión.
1412
1413**navigator** — navegador data-agnóstico de nodos en dos modos conmutables
1414(**árbol** ↔ **grafo**, reusa tree + nodegraph). Render-only: la app guarda
1415`expanded`/`selected`/`mode`. Pasa un bosque de `NavNode { id: u64, label,
1416kind: NavKind (Monad|Group|Dir|File|Other), children }` y callbacks por id.
1417```rust
1418navigator_view(NavSpec { roots, mode: NavMode::{Tree,Graph}, selected, palette, guides },
1419    is_expanded: Fn(u64)->bool, on_toggle: Fn(u64)->Msg,
1420    on_select: Fn(u64)->Msg, on_context: Option<Fn(u64)->Msg>)
1421// árbol: click selecciona, chevron expande, icono por kind. grafo: cables de
1422// contención padre→hijo, arrastrar selecciona, right-click abre. Pensado para
1423// el sidebar de Mónadas/archivos de pata, pero no sabe de nouser.
1424```
1425
1426**app-header** — `app_header(label, actions: Vec<View<Msg>>, &palette)`.
1427
1428**status-bar** — `status_bar_view(left, center, right, &palette)` con
1429`StatusSegment::text(..).with_icon(Icon).clickable(Msg).emphasized()`.
1430
1431**breadcrumb** — `breadcrumb_view(&[&str], make_msg: Fn(usize)->Msg, &palette)`
1432(el último segmento no es clickable).
1433
1434**modal** — diálogo centrado con scrim. `modal_view(ModalSpec { title, body:
1435View<Msg>, buttons: Vec<ModalButton>, size, viewport, on_dismiss, palette })`.
1436`ModalButton::{primary, cancel, destructive}(label, msg)`. Se monta en `view_overlay`.
1437
1438**toast** — notificaciones efímeras bottom-right. La app guarda `Vec<Toast>`
1439(`Toast::{info,success,warning,error}(id, text, duration)`), filtra
1440`is_alive(now)`, y `toast_stack_view(&toasts, viewport, make_dismiss: Fn(u64)->Msg)`.
1441
1442**splash** — splash de arranque (cuatro cuadrantes andinos). `splash_view(started_at:
1443Instant, bg, fg_text)`; basado en tiempo, requiere redraws.
1444
1445### Ricos / interactivos
1446
1447**nodegraph** — lienzo de nodos + cables Bezier. Sin estado (la app guarda
1448posiciones y `Wire`s).
1449```rust
1450NodeSpec { id: NodeId(u32), label, x, y, inputs: Vec<String>, outputs: Vec<String> }
1451Wire { from_node, from_output: PinIdx(u16), to_node, to_input }
1452nodegraph_view(&nodes, &wires, &palette, &metrics,
1453    on_drag_node: Fn(NodeId, DragPhase, dx, dy)->Option<Msg>,
1454    on_connect:   Fn(NodeId, PinIdx, NodeId, PinIdx)->Option<Msg>)
1455// + nodegraph_view_ex (right-click) y nodegraph_view_styled (tints por nodo/cable)
1456// + wire_dash / edge_dash en NodegraphSpec: predicado por cable/arista → trazo
1457//   PUNTEADO. Para la distinción que el color no lleva solo (lo sabido vs lo
1458//   supuesto): soltar pinta punteada la clave foránea que sólo se ve en el dato
1459//   y sólida la que una persona declaró.
1460// + nodegraph_view_spec(NodegraphSpec{..}) — el editor real: cámara (pan/zoom),
1461//   selección, aristas sin pins, y CUERPO EMBEBIDO por nodo.
1462```
1463
1464**Un nodo puede contener algo** (2026-08-17). `NodegraphSpec.cuerpo` es
1465`Option<&dyn Fn(NodeId, zoom) -> Option<CuerpoNodo<Msg>>>`, y `CuerpoNodo` es
1466`{ alto: f32, vista: View<Msg> }` — el alto en coordenadas de canvas, que el
1467widget escala por el zoom. El cuerpo se dibuja **debajo de la fila de pins**, así
1468que las posiciones de los pins no se mueven y los cables siguen enganchando donde
1469`precompute_wires` los calcula. `None` = el nodo clásico.
1470
1471Para qué: `krama` pinta adentro del nodo el valor con su unidad, la perilla que
1472se arrastra y la curva de `pineal-cartesian`; `tullpu` podría poner el preview
1473del ajuste, `takiy` la onda de su eslabón, `pluma-notebook` el output de la
1474celda. **No va en `NodeSpec`** (que es `Debug + Clone` y no genérico en `Msg`):
1475una `View` adentro lo volvería genérico y rompería sus 12 sitios de construcción.
1476
1477Dos cosas medidas que conviene saber: `fit_to_view` **no** conoce los cuerpos
1478(encuadra sobre la altura de pins, así que queda corto por abajo con nodos
1479altos), y `node_height` suma 6 px fijos que `scaled()` no escala — el nodo entero
1480no se duplica exactamente al duplicar el zoom.
1481
1482**timeline** — scrub clickeable. `timeline_view(progress: f32, &palette,
1483on_seek: Fn(f32 [0..1])->Option<Msg>)`.
1484
1485**transport** — botones de **transporte de reproductor** (play/pause/prev/
1486next/seek/volume/mute/repeat/shuffle/speed/snapshot/record/eq). Stateless:
1487el caller arma un `TransportButton` por cada botón con el flag que define su
1488estado activo (p.ej. `PlayPause { playing }`, `Mute { muted }`,
1489`Record { recording }`), lo pasa al widget, y recibe el `TransportAction`
1490semántico en un closure cuando el usuario clickea. El widget elige el icono
1491(de `llimphi-icons`), el color activo, el rojo de REC y arma la acción
1492(p.ej. `SeekBack { secs }` → `TransportAction::SeekBy(-secs)`). Quien
1493traduce `TransportAction` al comando del dominio (`MediaCommand`, etc.) es
1494la app.
1495```rust
1496TransportPalette { bg, bg_active, bg_hover, fg, fg_active, fg_record,
1497                   btn_w, btn_h, radius, icon_stroke, gap }
1498TransportPalette::from_theme(&Theme)
1499TransportAction::{TogglePlay, Stop, Prev, Next, SeekBy(i64), VolumeBy(f32),
1500                  ToggleMute, CycleRepeat, ToggleShuffle, SpeedStep(i32),
1501                  SpeedReset, Snapshot, ToggleRecord, ToggleEqualizer}
1502TransportButton::{PlayPause{playing}, Stop, Prev, Next,
1503                  SeekBack{secs}, SeekForward{secs},
1504                  VolumeDown{step}, VolumeUp{step},
1505                  Mute{muted}, Repeat{active}, Shuffle{active},
1506                  SpeedDown, SpeedUp, SpeedReset{is_default},
1507                  Snapshot, Record{recording}, Equalizer{enabled}}
1508transport_button_view(button, &palette, on_action: Fn(TransportAction)->Msg) -> View<Msg>
1509```
1510Para una fila, el caller mapea su `Vec<TransportButton>` con
1511`.iter().map(|b| transport_button_view(*b, &pal, on_action.clone()))` y los
1512pone como children de una `View` con `flex_direction: Row` y `gap:
1513palette.gap`. Consumidor de referencia: `02_ruway/media/media-app::bar_item_view`.
1514
1515**waveform** — visor de **forma de onda en vivo**. Stateless y agnóstico de
1516cpal/AudioProbe: el caller pasa un closure `Fn(&mut Vec<f32>) -> u16` que
1517rellena un buffer con los últimos samples intercalados y devuelve cuántos
1518**canales** trae; el widget hace el fold a mono y dibuja un **envelope
1519min/max por columna** (polígono cerrado con relleno tenue + stroke top/bot)
1520sobre una línea central que siempre está como "ground" — paint-only, sin
1521mouse. Si el closure devuelve `0` canales, el widget pinta sólo la línea
1522central ("visor vivo, sin señal").
1523```rust
1524WaveformPalette { bg, center, stroke, fill, radius, pad_x, pad_y, stroke_w }
1525WaveformPalette::from_theme(&Theme)  // bg=bg_panel_alt, stroke=accent, fill=accent@α70
1526waveform_view(source: Fn(&mut Vec<f32>) -> u16 + Send + Sync + 'static,
1527              palette: &WaveformPalette) -> View<Msg>
1528```
1529Consumidor de referencia: `02_ruway/media/media-app::waveform_panel` (cablea
1530un `AudioProbe::snapshot`).
1531
1532**table** — tabla **editable** de celdas-texto: filas/columnas + agregar/quitar
1533fila + foco por celda. Stateless — la app posee el foco y el buffer de la
1534celda en edición; el resto se pinta desde `&rows`. Pensado para datos planos
1535editables (paramétricos, scratchpad, listas curables); no es la `list`/`tree`
1536virtualizada read-only ni un grid de hoja de cálculo.
1537```rust
1538table_view(&[String], headers,                          // [] = sin header
1539    &[Vec<String>] rows,
1540    focused: Option<(row, col)>,
1541    focused_state: Option<&TextInputState>,             // buffer prestado de la celda en edición
1542    add_label: &str, &TablePalette,
1543    on_focus_cell: Fn(row, col) -> Msg,                 // clic en celda
1544    on_remove_row: Fn(row) -> Msg,
1545    on_add_row:    Fn() -> Msg)
1546list_view(&[String], items, focused_row, focused_state, // sola columna sin header
1547    add_label, &palette, on_focus_cell: Fn(row)->Msg, on_remove_row, on_add_row)
1548table_height(n_rows, has_header) -> f32                 // pre-calcular alto del bloque
1549```
1550La variante `list_view` no se confunde con la `list` virtualizada de §13:
1551ésta es **una sola columna editable** que delega en `table_view([], ..)`.
1552
1553**detail-table** — la vista *detalle* de un file manager: grilla read-only con
1554columnas `ColWidth::{Flex(peso), Fixed(px)}` y encabezados que ordenan (▲/▼).
1555`detail_table_view(DetailSpec{columns, rows, sort, row_height, caption,
1556palette}, on_sort)`; `detail_table_view_dnd(..)` agrega arrastre por fila.
1557`detail_table_view_ext(spec, on_sort, DetailOpciones{scroll, medidor,
1558on_resize})` suma **encabezado fijo con cuerpo scrolleable** y los **handles de
1559resize** entre columnas:
1560```rust
1561DetailScroll { offset, viewport_h, on_scroll: Fn(f32)->Msg }   // compone scroll_y
1562AltoCuerpo::new()          // publica el alto medido del cuerpo (post-layout)
1563offset_efectivo(offset, viewport_h)  // sin medir todavía ⇒ se pinta desde arriba
1564resize_col(ancho, delta, min)        // el host aplica el delta del handle
1565```
1566El «sticky» no es un truco de posición: con `scroll` el encabezado queda
1567**afuera** del cuerpo desplazado, así que no viaja. El cuerpo se compone del
1568`scroll` canónico (rueda, barra, clamp y fling ya viven ahí). El handle lleva
1569`draggable` y **no** `on_click`, así agarrarlo no dispara el orden de la columna
1570que tiene debajo — que es el bug clásico de esta UI.
1571
1572**Es también la DataTable** read-only ordenable y paginable — no hay una tabla
1573aparte para eso. Lo que faltaba y ya está:
1574```rust
1575ciclar_orden(sort_actual, col) -> Option<(col, SortDir)>   // asc → desc → SIN orden
1576orden_de_filas(&celdas, col, dir, &TipoCol) -> Vec<usize>  // permutación estable
1577TipoCol::{Texto, Numero, Natural, Propio(fn)}              // «archivo2» < «archivo10»
1578Pagina{indice, tamano}: rango(total) · total_paginas · acotada(total) · etiqueta
1579botones_pagina(actual, total, max) -> Vec<BotonPagina>     // 1 … 4 5 6 … 20
1580pager_view(pagina, total_filas, Fn(usize)->Msg, &palette)
1581```
1582`orden_de_filas` devuelve **índices**, no filas movidas: así ordena sin conocer
1583el tipo del caller (una `DetailRow` lleva `Msg` y `View`) y sin perder el orden
1584original, que es al que vuelve el tercer click. `Numero` manda al **final** lo
1585que no parsea (un «—» encabezando no es información) y no hay variante `Fecha`:
1586en ISO ordena bien como `Texto`, y para otros formatos está `Propio` — un parser
1587de fechas acá sería un segundo parser distinto del del `datetime-picker`.
1588
1589**text-editor** — editor IDE (capa visual sobre el core agnóstico). La app guarda
1590`EditorState`:
1591```rust
1592EditorState::new(); .text(); .set_text(s); .has_selection(); .can_undo()/.can_redo();
1593.add_cursor_at(line,col);  .apply_key_with_clipboard(&KeyEvent, &mut dyn Clipboard) -> ApplyResult;
1594.ensure_caret_visible(visible_lines)
1595// nota: `metrics` se pasa POR VALOR; el callback es on_pointer: Fn(PointerEvent)->Option<Msg>
1596text_editor_view(&state, &EditorPalette, metrics: EditorMetrics, visible_lines: usize, on_pointer)
1597text_editor_view_highlighted(&state, &palette, metrics, visible_lines, language: Language, on_pointer)
1598text_editor_view_full(&state, &palette, metrics, visible_lines, language, match_ranges: &[(usize,usize)], on_pointer)
1599syntax_palette_dark(&theme) -> SyntaxPalette   // en lib.rs del widget
1600```
1601
1602**text-editor-core** — núcleo **agnóstico** (sin GPU, sin Llimphi; sólo
1603`peniko::Color`). Reutilizable en TUI/web/headless. Tipos clave:
1604- `Buffer` (sobre `ropey`): `from_str`, `text`, `insert(offset,s)`, `delete(s,e)`,
1605  `offset_to_pos`, `pos_to_offset`, `slice`, `line(n)`.
1606- `Pos { line, col }`, `Selection { anchor, caret }`, `Cursor { caret, anchor:
1607  Option, desired_col }` con `move_left/right/up/down/word_left/...`,
1608  `selection_range(&buf)`, `collapse`.
1609- Ops: `replace_selection`, `delete_backward/forward`, `indent_or_insert_tab`,
1610  `insert_newline_auto_indent` → devuelven `EditDelta { start, removed, inserted,
1611  cursor_before, cursor_after }` con `.apply()/.undo()`.
1612- `UndoStack`: `push(delta)`, `undo/redo(&mut buf, &mut cursor) -> bool`, `can_undo/redo`.
1613- `FindState { query, case_sensitive }`: `all_matches`, `find_next`, `find_prev`.
1614- Matching de brackets: `find_bracket_pair(&buf, &cursor) -> Option<(Pos, Pos)>`, `Direction`.
1615- `Clipboard` (trait `get/set`), `MemClipboard`, `NullClipboard`.
1616- `Diagnostic { range: DiagnosticRange { start: Pos, end: Pos }, severity: Severity,
1617  message: String, source: Option<String> }` (+ ctors `error(..)`, `warning(..)`);
1618  `Severity::{Error, Warning, Information, Hint}`.
1619- Highlight tree-sitter: `Language::{Plain, Rust, Python, Wat}`
1620  (+ `Language::from_cell_language(s)`); `Highlighter::new(lang)` con
1621  `.highlight(&mut self, source: &str) -> Vec<Vec<Span>>` (un `Vec<Span>` **por
1622  línea**), `.set_language(lang)`, `.language()`; helpers de módulo
1623  `invalidate_tree_cache(lang)` y `apply_pending_edits(lang, &edits)` para el
1624  caché incremental. `TokenKind`, `Span`, `SyntaxPalette::color(kind)`.
1625
1626**text-editor-lsp** — cliente LSP por stdin/stdout. `trait LspClient` (fire-and-forget
1627`request_*` + lecturas de caché `latest_*`/`clear_*`): completions, hover,
1628definition, references, rename, formatting, signature help, document symbols.
1629`RustAnalyzerClient::start(workspace_root)`; `NoopLspClient` para tests.
1630
1631**terminal** — superficie de **scrollback infinita virtualizada** (modo línea /
1632bloques / grilla GPU). Reemplaza al `output_pane` clásico: el costo de render
1633es **constante** a scrollback ilimitado — sólo se materializa la ventana visible
1634— bajo un `scroll_y` **propio del widget** (no `transform` del padre, la
1635anti-feature del SDD). Núcleo agnóstico, el caller arma el modelo y el widget
1636pinta (Regla 2). Diseño completo: `02_ruway/shuma/SDD-TERMINAL.md`. Consumidor
1637de referencia: `shuma-module-shell::output_pane_surface`.
1638- **Store** (`Scrollback::new(limit_bytes)`): append-only por línea con índice
1639  de offsets O(1), cap por memoria con recorte de frente, `line_id` global
1640  **estable** que sobrevive al recorte (`index_of_id`), `push_line`,
1641  `slice_text`, `clear`. `dropped()` / `total_pushed()` para el `numbering`
1642  global.
1643- **Spill a disco** (`SpillStore::create(path)` + `Scrollback::enable_spill(spill)`):
1644  cada línea recortada del frente se appendea al archive; lookup O(1) con
1645  `read_spilled(global_id)` + helpers `has_spill`/`spilled_count`/`spill_path`
1646  para que el host exponga UX (chip de status, builtin `:scrollback open`,
1647  prepend del archive al view).
1648- **Modo línea** (`line_surface(store, scroll_y, viewport_h, metrics, &palette,
1649  line_style: Fn(usize, &str) -> LineStyle, on_scroll: Fn(f32) -> Msg, measure:
1650  Option<Arc<Mutex<f32>>>)`): materializa sólo la ventana visible; scroll
1651  sub-renglón (`partial_px`), numeración global 1-based, color base + runs +
1652  tinte de fondo inyectados por el caller en `LineStyle`. Helpers puros:
1653  `visible_window`, `content_height`, `scroll_to_bottom`. `measure` opcional
1654  publica el `viewport_h` real medido al host (regla del SDD: verificar con
1655  viewport real).
1656- **Modo bloques** (`block_surface(store, items: Vec<Item<Msg>>, ...)`): el
1657  stream es una secuencia de `Item::chrome(height, view)` (header/badge/etapa
1658  de alto fijo que el caller pinta) + `Item::lines(start, end)` (rango del
1659  store). Virtualiza sobre alturas mixtas con búsqueda binaria (O(log n) en
1660  bloques) — dentro de un `Lines` enorme sólo se materializan las sub-filas
1661  visibles. Colapsar = no emitir el `Lines`. Helpers: `blocks_height`,
1662  `blocks_scroll_to_bottom`, `gutter_width(&store, metrics)`,
1663  `line_top_in_content(&items_geo, row_h, target_line)`.
1664- **Selección + copy** (`block_surface_with_selection` + `SelectionConfig { range:
1665  Option<&SelectionRange>, on_drag: Fn(DragPhase, lx0, ly0, dx, dy) -> Option<Msg>,
1666  on_double_click: Fn(lx, ly, w, h) -> Option<Msg> }`): el widget pinta el
1667  overlay translúcido y dispara callbacks; el caller mantiene el rango
1668  (`SelectionRange { anchor: Point, head: Point }`, `Point { line, col }` con
1669  `col` en **bytes** UTF-8) en su Model y resuelve clicks con `point_at(items,
1670  scroll_y, viewport_h, metrics, gutter_w, store, lx, ly)` —o su variante
1671  `Copy`-stashable `point_at_geo(&[ItemGeo], ...)` para resolver el frame
1672  anterior. `selection_rects` para overlays custom.
1673- **Find** (`find_matches(&store, query, FindOpts { case_insensitive })`):
1674  búsqueda sobre el scrollback **completo** (no sólo el viewport), O(total_bytes);
1675  `FindMatch { line, start, end }` con `start`/`end` en bytes UTF-8 slice-safe.
1676  Nav: `next_match(&matches, current)` / `prev_match`. El caller resuelve el
1677  `line` a su `scroll_y` con `line_top_in_content`.
1678- **Grilla GPU** (modo TUI, Fase 4): `CellPipeline::new(device, format)` +
1679  `GlyphAtlas` rasterizan cada celda con **quads instanciados** y atlas de
1680  glifos (precedente: `atlas` de wawa, Fontdue) — una sola draw call por
1681  viewport, escala a 200×80 sin penalty. Tipos: `CellInstance { cell_x/y,
1682  uv_x/y/w/h, fg_rgba, bg_rgba }` (POD 32 B), `CellUniforms`, `pack_rgba`,
1683  `instances_to_bytes`, `CELL_WGSL`, `CellPipeline::create_atlas_texture`.
1684  Wireado al widget contenedor vía `gpu_paint_with`. Activo en shuma con
1685  `SHUMA_GPU_GRID=1`.
1686
1687Headless dumps:
1688```bash
1689cargo run -p llimphi-widget-terminal --example dump_terminal   # modo línea, 1M renglones
1690cargo run -p llimphi-widget-terminal --example dump_blocks     # bloques + flood 500k
1691```
1692
1693**clipboard** — portapapeles del sistema vía `arboard`. `SystemClipboard::new()`,
1694`is_available()`, impl `Clipboard`. No-op silencioso si no hay display (CI headless).
1695
1696**menubar** — barra de menú mac-style. `menubar_view(&MenuBarSpec { menu: &AppMenu,
1697open: Option<usize>, theme, viewport, height, on_open: Fn(Option<usize>)->Msg,
1698on_command: Fn(&str)->Msg })`; dropdown en `view_overlay` con `menubar_overlay(spec)`
1699o `menubar_overlay_animated(spec, active, appear)`. Navegación por teclado:
1700`menubar_nav`, `menubar_command_at`.
1701
1702**edit-menu** — menú estándar de edición sobre un editor.
1703`EditFlags::from_editor(&state, masked)`, `edit_context_menu(anchor, viewport,
1704&theme, flags, on_action: Fn(EditAction)->Msg, on_dismiss)` →
1705`ContextMenuSpec`. `apply(&mut state, EditAction, &mut clipboard) -> ApplyResult`.
1706`EditAction::{Undo,Redo,Cut,Copy,Paste,Delete,SelectAll}`.
1707
1708**context-menu** — menú contextual genérico (panel elevado con sombra + borde +
1709esquinas redondeadas). `ContextMenuItem::action(label).with_shortcut(..).icon(..)
1710.checked(bool).disabled().destructive().submenu(children)` o `::separator()`
1711(`checked` pinta ✓ en el gutter y alterna estado). `context_menu_view(ContextMenuSpec
1712{ anchor, viewport, header, items, active, on_pick: Fn(usize)->Msg, on_dismiss,
1713palette })`; `context_menu_view_ex` con submenús (flyout que **flipea** a la izq. si
1714no cabe) + animación de aparición. Se monta en `view_overlay` con scrim. **Teclado
1715(helpers puros que el host cablea):** `step_active(items, cur, ±1)` (↑/↓, saltea
1716sep/disabled), `edge_active(items, to_end)` (Inicio/Fin), `type_ahead(items, cur, ch)`
1717(mnemónico por 1ª letra). Submenú por teclado = `step_active` sobre `parent.children`.
1718Demo completo: `cargo run -p llimphi-widget-context-menu --example menu_demo`.
1719
1720**theme-switcher** — `theme_switcher_view(&current: &Theme, on_change: Fn(Theme)->Msg)`
1721(+ `_styled`/`_flex`). Cicla `Theme::next_after`.
1722
1723**shortcuts-help** — overlay "?" con atajos agrupados. `shortcuts_help_view(
1724ShortcutsHelpSpec { title, groups: Vec<ShortcutGroup { title, entries:
1725Vec<ShortcutEntry { keys, description }> }>, viewport, on_dismiss, palette })`.
1726
1727**wawa-mark** — sello vectorial del SO wawa. `wawa_mark_view(&WawaMarkPalette)`;
1728`paint_mark(scene, rect, &palette)` para canvas custom. Usar en contenedor cuadrado.
1729
1730---
1731
1732## 14. Catálogo de módulos
1733
1734Los módulos encapsulan **estado + comportamiento** (a diferencia de los widgets).
1735Todos siguen el mismo contrato:
1736
1737```
1738State  +  Msg  +  Action  +  apply(state, msg, ...) -> Action
1739                            +  on_key(state, &KeyEvent) -> Option<Msg>
1740                            +  open_shortcut(&KeyEvent) -> bool
1741                            +  view(state, ..., to_host: F) -> View<HostMsg>
1742                            +  Palette
1743```
1744
1745La app guarda `Option<ModuleState>` (o el state directo, p. ej. bookmarks),
1746rutea el atajo de apertura con `open_shortcut`, rutea teclas con `on_key`, aplica
1747`Msg`s con `apply`, y monta el `view` pasando un mapeo `to_host: Fn(ModuleMsg) ->
1748HostMsg`. Cuando `apply` devuelve una `Action` (p. ej. `Invoke(id)`, `OpenAt{..}`,
1749`GoTo{..}`), la app ejecuta el efecto. Crates en `modules/<nombre>/`.
1750
1751| Módulo | Atajo | Acción que devuelve | Propósito |
1752|---|---|---|---|
1753| **command-palette** | `Ctrl+Shift+P` | `Invoke(String)` | paleta de comandos fuzzy. El host declara `&[Command]` |
1754| **file-picker** | `Ctrl+P` | `Open(PathBuf)` | fuzzy file picker; host pasa `&[PathBuf]` + `root` |
1755| **fif** (find-in-files) | `Ctrl+Shift+F` | `OpenAt{path,line,col}`, `Searched{..}`, `Replaced{..}` | buscar/reemplazar; dual-view (dialog + barra). `search()` / `replace_all()` hacen el I/O |
1756| **diff-viewer** | `Ctrl+Shift+D` | — | diff side-by-side. `DiffState::new(before_label, after_label, before, after)` computa con `similar` |
1757| **mini-map** | `Ctrl+Shift+M` | `JumpTo(line)` | minimapa del buffer; agnóstico del editor (recibe `Snapshot`) |
1758| **bookmarks** | `Ctrl+Alt+B` toggle, `Ctrl+Shift+B` lista, `Ctrl+Alt+N/P` nav | `JumpTo{path,line}` | marcadores per-file persistentes (state directo, no Option) |
1759| **symbol-outline** | `Ctrl+Shift+O` | `GoTo{line,col}` | outline de símbolos; host arma `Vec<SymbolItem>` (LSP/tree-sitter/custom) |
1760| **selector** | — | — | abstracción portátil abrir/guardar: `trait Selector` (`HostSelector` con PathBuf, `WawaSelector` placeholder content-addressed) |
1761| **plugin-host** | — | `OpenAt{..}`, `SetStatus(..)` | runtime WASM (wasmi) con permisos por bitfield; `PluginHost::load_from_dir`/`invoke(id, cap, args)` |
1762| **shuma-term** | `` Ctrl+` `` | `SetStatus(..)` | terminal integrada. `spawn(cwd)` lanza PTY (`shuma_exec`), `vt100::Parser` renderiza; `Tick` drena el PTY |
1763
1764Patrón típico de integración (command-palette):
1765```rust
1766struct Model { palette: Option<PaletteState>, commands: Vec<Command>, /* … */ }
1767enum Msg { Palette(PaletteMsg), /* … */ }
1768
1769// on_key:
1770if command_palette::open_shortcut(ev) { return Some(Msg::Palette(PaletteMsg::Open)); }
1771if let Some(_) = &model.palette { return command_palette::on_key(p, ev).map(Msg::Palette); }
1772
1773// update:
1774Msg::Palette(m) => {
1775    if let Some(state) = model.palette.as_mut() {
1776        match command_palette::apply(state, m, &model.commands) {
1777            PaletteAction::Invoke(id) => { /* ejecutar comando id */ model.palette = None; }
1778            PaletteAction::Close => model.palette = None,
1779            PaletteAction::None => {}
1780        }
1781    }
1782}
1783
1784// view_overlay:
1785model.palette.as_ref().map(|s|
1786    command_palette::view(s, &model.commands, &palette, Msg::Palette))
1787```
1788
1789---
1790
1791## 15. `llimphi-workspace` — chasis tipo tmux
1792
1793Monta cualquier componente en un layout intercambiable con splits resizables
1794(máquina de estados de foco/split/cierre + chrome estándar). Construido sobre
1795`llimphi-widget-panes`.
1796
1797```rust
1798Workspace::new()
1799  .focused() -> PaneId         .count()      .leaves() -> Vec<PaneId>     .layout() -> &Layout
1800  .focus(id)  .split(Axis) -> PaneId   .close() -> Option<PaneId>   .resize(&path, delta)
1801  .apply(WsMsg) -> WsEffect
1802
1803enum WsMsg { Focus(PaneId), Split(Axis), Close, Resize(Vec<Side>, f32) }
1804enum WsEffect { None, Created(PaneId), Closed(PaneId) }
1805
1806workspace_view(&ws, &WorkspacePalette,
1807    leaf: FnMut(PaneId)->View<Host>,           // materializa el contenido de cada panel
1808    lift: Fn(WsMsg)->Host)                      // sube los Msg del chasis a tu Msg
1809```
1810
1811Patrón: `enum Msg { Ws(WsMsg), Panel(PaneId, PanelMsg) }`. En `update`,
1812`ws.apply(msg)` te avisa con `WsEffect::{Created,Closed}(id)` para que crees o
1813destruyas el estado del panel correspondiente.
1814
1815---
1816
1817## 16. Reglas duras y gotchas
1818
1819### 🔴 Cómputo pesado fuera del hilo de UI (PRIORIDAD URGENTE)
1820Ningún `update`/`init`/handler puede ejecutar trabajo **síncrono** pesado
1821(efemérides, simulación, IO, parse, embeddings, layout de árboles grandes).
1822Bloquea el hilo → "Not Responding". **`init` corre dentro de `resumed`, después
1823de crear la ventana**, así que un cómputo pesado ahí ya congela una ventana
1824visible.
1825
1826Patrón (referencia: `cosmos-app-llimphi`):
1827```rust
1828// Model: Option<Resultado> (None = "calculando…") + flag dirty + contador de generación.
1829struct Model { x: Option<Resultado>, x_dirty: bool, x_gen: u64 }
1830enum Msg { XComputed(u64, Arc<Resultado>) }
1831
1832// al FINAL de update() (que tiene el Handle):
1833if m.x_dirty {
1834    m.x_dirty = false;
1835    m.x_gen = m.x_gen.wrapping_add(1);
1836    let (gen, input) = (m.x_gen, m.input.clone());     // sólo lo que el worker necesita (Send)
1837    handle.spawn(move || Msg::XComputed(gen, Arc::new(compute(&input))));
1838}
1839// al recibir: aplicar SÓLO si la generación sigue vigente (evita que un
1840// resultado tardío pise a uno más nuevo en drags/toggles rápidos).
1841Msg::XComputed(gen, x) => if gen == m.x_gen {
1842    m.x = Some(Arc::try_unwrap(x).unwrap_or_else(|a| (*a).clone()));
1843}
1844```
1845La **generación** es imprescindible si el recálculo se dispara seguido. Ver
1846[`COMPUTO-FUERA-DEL-HILO-UI.md`](COMPUTO-FUERA-DEL-HILO-UI.md) y su checklist por app.
1847
1848### Otras
1849- **Solvers iterativos** (Newton/bisección): cota dura `for _ in 0..N`, nunca
1850  `loop {}` con corte pegado al epsilon de f64 — en debug no converge → loop
1851  infinito.
1852- **Backend GPU**: preferir Vulkan (`Backends::PRIMARY`); el GL de Mesa sobre
1853  Wayland segfaultea en el teardown. Ya está hecho en `Hal::new`, no revertir.
1854- **Un nodo es draggable o clickable**, no ambos.
1855- **`alpha` y `clip`** crean capas intermedias: tienen costo, usar sólo cuando
1856  hace falta.
1857- **`paint_with`** no debe dejar `push_layer` sin `pop_layer` ni resetear la
1858  Scene.
1859- **Hit-test respeta `.transform()`**: un nodo rotado/escalado/trasladado recibe
1860  los clicks donde se ve pintado (el runtime invierte el afín acumulado). Lo que
1861  **no** se ajusta todavía: la posición local que reciben los handlers `*_at` se
1862  reporta en coords de pantalla, no en el espacio local del nodo transformado.
1863- **GPUI está extinto**: no agregar dependencias ni código GPUI (regla §3 de
1864  `CLAUDE.md`).
1865- **Texto en regla pesada**: crear un `Typesetter` por frame es caro
1866  (`FontContext::new` enumera fuentes del sistema). El runtime ya cachea uno y lo
1867  pasa a `paint_with`.
1868
1869---
1870
1871## 16.bis Tests de regresión visual — `llimphi-test` (golden-image)
1872
1873Para certificar que un widget/pantalla **no cambió de aspecto** sin renderizar-y-
1874mirar a mano (CLAUDE.md §8), usar el arnés `llimphi-test`. Renderiza un `View`
1875headless por el adapter de **software** (llvmpipe/lavapipe → mismo píxel host-a-
1876host y en CI) y lo diffea contra un golden con tolerancia perceptual.
1877
1878```rust
1879// en tests/<algo>.rs de tu crate
1880use llimphi_test::assert_golden;
1881
1882#[test]
1883fn mi_card() {
1884    let v = mi_widget::card(/* … */);
1885    assert_golden!("mi_card", v, 400, 240);   // vs tests/goldens/mi_card.png
1886}
1887```
1888
1889- **Generar/actualizar goldens:** `LLIMPHI_GOLDEN_UPDATE=1 cargo test -p <crate>`
1890  (escribe los PNG en `tests/goldens/`; commitéalos).
1891- **En fallo:** escribe `mi_card.actual.png` + `mi_card.diff.png` (heatmap) al lado
1892  del golden y paniquea con `DiffStats { differing_pixels, max_delta, fraction }`
1893  — la stat numérica **es** la evidencia, no hace falta abrir el PNG.
1894- **Tolerancia:** `GoldenConfig { max_channel_delta, max_diff_fraction }` (default
1895  casi bit-exacto). Para control fino: `Harness::render(...)` + `diff(...)` +
1896  `compare_golden(path, &rendered, &cfg)` sueltos.
1897- **Determinismo:** el adapter de software es bit-exacto (el self-test
1898  `readback_es_determinista` verifica 0px entre dos renders). Si la máquina no
1899  tiene lavapipe, cae al adapter normal y la tolerancia absorbe la diferencia.
1900- **Path GPU-directo** (`GpuBatch`/`gpu_paint_with`, que no pasa por vello):
1901  `Harness::render_gpu(w, h, bg, |device, queue, encoder, view, vp| { … })` —
1902  misma firma que `View::gpu_paint_with`; limpia a `bg` y te deja pintar directo,
1903  después lee de vuelta para el golden (ver `llimphi-test/tests/gpu_golden.rs`).
1904
1905### El golden es el ÚLTIMO recurso, no el primero
1906
1907Un golden es una imagen bendecida: pasa a ser correcta porque alguien dijo que
1908lo era. Eso funciona para «esto no cambió», pero no contesta «¿esto está bien?»,
1909y para bendecirla hay que mirarla — justo lo que §8 pide evitar.
1910
1911Casi siempre hay algo mejor: **el oráculo es la otra manera de calcular lo
1912mismo**. En vez de comparar contra una imagen, se pinta el mismo árbol por dos
1913caminos que tienen que coincidir, y la afirmación pasa a ser objetiva. Los que
1914ya existen en el repo, todos escritos así:
1915
1916| test | los dos caminos | resultado |
1917|---|---|---|
1918| `boundary_cache.rs` | `paint` contra `paint_cached` | 0 px |
1919| `gpu_lotes.rs` | un buffer entero contra troceado | idénticos |
1920| `gpu_grosores.rs` | un lote con dos plumas contra dos lotes de una | idénticos |
1921| `gpu_scissor.rs` | pasada completa contra recorte | mismo píxel exacto |
1922| `cpu_vs_gpu.rs` | `vello_cpu` (wawa) contra `vello` (Linux) | tabla por patrón |
1923| `wire_vs_directo.rs` | por el cable contra en proceso | bit a bit |
1924| `hybrid_vs_cpu.rs` | `vello_hybrid` (Mali) contra CPU | Δ1:0 |
1925| `texto_gpu_directo.rs` | atlas de glifos contra vello | Δ64:0 |
1926
1927### Cuatro reglas que salieron de escribirlos
1928
19291. **Poné el umbral DESPUÉS de medir, no antes.** Los umbrales «prudentes» que
1930   se eligen a ojo casi nunca se rozan, y un umbral que nada roza no es un gate.
1931   En `hybrid_vs_cpu` el 5 % sobre Δ16 que parecía razonable no habría agarrado
1932   el sabotaje; lo medido (0 px sobre Δ1) sí. Dejá el número medido escrito al
1933   lado del `assert`, con fecha: es lo que permite decidir el umbral siguiente.
19342. **Saboteá el test antes de creerle.** Rompé a mano lo que dice cubrir y mirá
1935   que falle *ese* test y no otro. Dos veces en esta tanda el test pasaba con la
1936   pieza rota: `boundary_gradiente_imagen` reconstruía el objeto con `default()`
1937   en vez de clonarlo, y cada `default()` hacía un `Blob` nuevo — o sea que
1938   invalidaba por identidad y nunca llegaba a probar el hash. Cuatro de cinco
1939   sabotajes pasaban.
19403. **Todo test de igualdad necesita una guarda de arnés.** Dos lienzos vacíos
1941   coinciden perfecto. Afirmá además que se pintó algo (`pintados > total/8`),
1942   que el caché acertó (`hits > 0`), que se ejecutaron las órdenes
1943   (`ejecutadas > 0, salteadas == 0`), que se compararon todos los casos
1944   (`probados == patrones().len()`). Sin eso, el test firma cualquier cosa.
19454. **Recorré el camino difícil, no el fácil.** `texto_gpu_directo` sube sólo el
1946   rect sucio y no el atlas entero, porque es lo que hace una app; y tiene un
1947   caso incremental aparte justamente porque en un atlas recién hecho lo sucio
1948   arranca en (0,0), donde un error de coordenada pasa desapercibido.
1949
1950Cuándo sí un golden: cuando **no hay** segundo camino. `gpu_golden.rs` (starfield
1951por GPU directo) es el caso — no existe otra forma de producir esa imagen, así
1952que la referencia tiene que ser ella misma.
1953
1954### Correr la suite contra la GPU de verdad
1955
1956```bash
1957LLIMPHI_TEST_GPU=1 cargo test --no-fail-fast -p llimphi-test -p llimphi-raster -p llimphi-hybrid
1958```
1959
1960El arnés fuerza el adapter de **software** para que un golden valga lo mismo en
1961cualquier máquina, y eso está bien — pero deja una pregunta sin contestar: *¿el
1962hardware hace lo mismo?* Con esa env se ignora la preferencia y se usa la GPU
1963real. Es para preguntar a mano, no para cambiar la referencia.
1964
1965**Medido el 2026-08-05 (Iris Xe/Vulkan contra llvmpipe), y el resultado dice más
1966que cualquier argumento sobre goldens:**
1967
1968| | software | GPU real |
1969|---|---:|---:|
1970| tests en verde | 113 | **112** |
1971
1972El único que se cae es `gpu_golden` — el único que compara contra una imagen
1973bendecida (2,74 % de píxeles, Δ máx 44). **Los ocho oráculos pasan en las dos**,
1974y con casi el mismo número:
1975
1976| test | software | GPU real |
1977|---|---|---|
1978| `texto_gpu_directo` | Δ16:417 Δ64:0 · tinta +0,31 % | Δ16:423 Δ64:0 · tinta +0,29 % |
1979| `hybrid_vs_cpu` | Δ0:236 Δ1:0 | Δ0:232 Δ1:0 |
1980| `boundary_cache` / `gpu_lotes` / `gpu_scissor` | 0 px | 0 px |
1981
1982Un oráculo compara dos cosas que el mismo rasterizador produce, así que la
1983diferencia de rasterizador **se cancela**. Una foto no puede cancelar nada. Por
1984eso `gpu_golden` se saltea solo cuando se pide GPU: así el comando de arriba es
1985una pregunta limpia y no una corrida con un fallo conocido de ruido.
1986
1987---
1988
1989## 16.ter Semántica accesible + tests por consulta
1990
1991Un `View` describe **cajas**: posición, color, texto. No dice qué *significan*.
1992Esa segunda capa se declara con `View::role` + `View::aria_*`, y paga dos veces:
1993
19941. **Lectores de pantalla, gratis.** El runtime traduce el árbol montado a
1995   AccessKit en cada frame (`llimphi-ui::a11y`), y el adapter de Linux habla
1996   **AT-SPI2 por DBus** — el mismo bus que usa Orca. No hay que encender nada:
1997   `accesskit_winit` ya está cableado en el event loop, sin feature gate.
19982. **Tests que preguntan por significado**, no por píxeles (ver más abajo).
1999
2000### Declarar
2001
2002```rust
2003View::new(style)
2004    .role(Role::Button)          // qué ES el nodo
2005    .aria_label("Guardar")       // cómo se llama (si no hay texto visible)
2006    .aria_value("70%")           // dato dinámico: valor de un slider, texto de un input
2007    .aria_description("…")       // contexto extra; el lector lo dice aparte
2008    .aria_pressed(activo)        // …y los flags: checked/pressed/expanded/
2009    .aria_disabled(!habilitado)  //    disabled/readonly/required
2010```
2011
2012**Dónde va el rol: en el nodo que lleva el `on_click`**, no en el contenedor.
2013AccessKit sólo ofrece `Action::Click` donde hay handler; un rol `Button` sobre
2014una caja sin handler produce un botón que el lector no puede activar.
2015
2016**El nombre accesible** es `aria_label` si está, si no el `text` visible. Por eso
2017un nodo que ya pinta su texto no necesita label — pero si el texto vive en un
2018**hijo** (ícono + etiqueta en una fila), el nodo con el rol queda sin nombre y hay
2019que dárselo explícito. Es el error más común.
2020
2021Roles disponibles (`llimphi_ui::Role`): `Button` `TextInput` `MultilineTextInput`
2022`Heading` `Checkbox` `Switch` `Label` `Link` `Image` `Slider` `ProgressBar`
2023`MenuItem` `Menu` `MenuBar` `Tab` `TabList` `Toolbar` `List` `ListItem` `Tree`
2024`TreeItem` `Table` `Row` `Cell` `ColumnHeader` `Dialog` `Alert` `Status`
2025`Tooltip` `Terminal` `Splitter` `Group`. Si falta uno, agregalo en
2026`llimphi-compositor/src/semantics.rs` **y** mapealo en `llimphi-ui/src/a11y.rs` —
2027cuando aparezca un caller real, no antes.
2028
2029**Cuándo NO declarar:** decorativo puro (divider, gradiente, sombra, `View`
2030envoltorio de layout). Un `Role::Group` en cada caja ensucia la navegación más de
2031lo que ayuda.
2032
2033### Consultar — `llimphi_test::query`
2034
2035La misma declaración habilita tests que **no miran píxeles**:
2036
2037```rust
2038use llimphi_test::query::{Sel, Ui};
2039use llimphi_ui::Role;
2040
2041let ui = Ui::new(app.view(), 800.0, 600.0);
2042
2043assert_eq!(ui.find_all(Role::Tab).len(), 3);
2044let guardar = ui.get(Sel::new().role(Role::Button).label("Guardar"));
2045assert!(guardar.is_enabled());
2046
2047// Clickear = obtener el Msg y metérselo al update.
2048app.update(guardar.click().unwrap());
2049```
2050
2051- **No necesita GPU** — sólo `mount` + layout. Corre en cualquier CI, a
2052  diferencia de `Harness` (que pide lavapipe).
2053- **`Ui::get`** paniquea con el árbol semántico entero en el mensaje si no
2054  encuentra (o si hay más de uno). El fallo se lee sin abrir un PNG.
2055- **`Ui::dump()`** vuelca el árbol como texto indentado — la evidencia barata que
2056  pide CLAUDE.md §8:
2057
2058  ```
2059  ·  @0,0 510×100
2060    Heading txt:"Datos"  @0,0 50×100
2061    TextInput "Nombre" = "sergio" required=true  @50,0 200×32
2062    Button "Guardar" [click]  @250,0 80×32
2063    Button "Cancelar" disabled [click]  @330,0 80×32
2064    · [click]  @410,0 100×100
2065  ```
2066
2067  El `·` es un nodo sin rol declarado. El último de ese volcado —`· [click]`— es
2068  un canvas con handler y sin semántica: un lector de pantalla no puede
2069  anunciarlo. Verlo en el dump **es** el hallazgo.
2070
2071- **Selectores:** `Sel::new()` + `.role() .label() .label_contains() .value()
2072  .checked() .enabled() .clickable() .focusable() .role_none()`. Atajos por
2073  conversión: `ui.find(Role::Button)` y `ui.find("Guardar")` (por nombre).
2074- **Auditar lo mudo:** `ui.find_all(Sel::new().clickable(true).role_none())`
2075  devuelve exactamente los nodos interactivos sin semántica — lo que un lector
2076  de pantalla no sabe anunciar.
2077
2078### Estado de la cobertura
2079
2080**50 de 76 widgets anotados** (medido el 2026-08-27: `grep -rlE '\.role\(|Role::'
2081widgets/*/src`). De los 26 restantes, 25 lo están **a propósito** — ver abajo — y
2082uno, `tablado`, es un hueco real con nombre.
2083
2084Anotados: `accordion` `app-header` `avatar` `badge` `banner` `breadcrumb`
2085`button` `calendar` `chip` `color-picker` `context-menu` `datetime-picker`
2086`detail-table` `dock-rail` `empty` `fab` `field` `form` `gauge` `list` `menubar`
2087`modal` `nodegraph` `progress` `range-slider` `rating` `rive-button` `segmented`
2088`select` `shortcuts-help` `slider` `spinner` `splitter` `stat-card` `status-bar`
2089`stepper` `switch` `table` `tabs` `terminal` `text-editor` `text-input`
2090`theme-switcher` `timeline` `toast` `toolbar` `tooltip` `transport` `tree`
2091`waveform`.
2092
2093**Sin anotar por diseño** (el módulo `semantics` lo pide explícitamente: un
2094`Role::Group` en cada caja ensucia la navegación más de lo que ayuda):
2095
2096- *Contenedores de layout puro* — `card` `panel` `grid` `wrap` `hero`
2097  `fitted-box` `tiled` `panes` `scroll` `router` `scaffold` `lazy-list`.
2098  La semántica la traen sus hijos.
2099- *Decorativos* — `skeleton` `splash` `wawa-mark`.
2100- *Sin `View` propio* — `app-icon` (resuelve íconos XDG a bytes, no pinta),
2101  `clipboard` (que además no es un widget: es `llimphi-clipboard` viviendo en
2102  `widgets/`), `text-area` `text-editor-core` `text-editor-lsp` `carousel`
2103  (sólo helpers), `edit-menu` (produce `ContextMenuItem`, que ya viene
2104  anotado), `gallery` (binario de ejemplo).
2105- *Componen widgets ya anotados* — `navigator` (sobre `tree` + `nodegraph`),
2106  `rag-sidebar` (sobre `segmented` + `dock-rail`).
2107- *Devuelve slots, no una `View`* — `fugaces`: la caja hospedadora y el
2108  anuncio los pone el caller, que es el único que sabe qué es cada fantasma.
2109
2110⚠️ **El que no está por decisión sino por hueco:** `tablado`, el nodo donde el
2111anfitrión pinta la `EscenaWire` de una app enjaulada. Por el cable viaja el IR de
2112**escena** y no el de vista (`SDD-JAULA-LINUX.md`), así que del lado del
2113anfitrión no hay roles que anunciar: para un lector de pantalla, una app
2114enjaulada es un rectángulo mudo. Cerrarlo pide que la semántica del huésped
2115cruce el cable, que es diseño pendiente y no una anotación que falte.
2116
2117### Activación: el rol y el handler no tienen por qué coincidir
2118
2119Los widgets ponen el rol en la **fila** (`TreeItem`, `Row`, `ListItem`) porque es
2120la unidad que el lector navega, pero el `on_click` suele vivir en un **hijo** (el
2121nodo del label). `a11y::click_target` resuelve ese desajuste:
2122
21231. Si el nodo mismo tiene handler, es él.
21242. Si no, y **declara un rol**, se delega en su subárbol — siempre que haya
2125   **exactamente un** descendiente clickeable.
21263. Si hay varios, no se delega: la elección sería arbitraria, y esos hijos ya
2127   están en el árbol como nodos propios para activarlos directo.
2128
2129El requisito de rol en (2) es lo que evita que toda caja de layout se vuelva
2130activable. El runtime además sintetiza la posición para los handlers
2131`on_click_at` (centro del rect del nodo en el layout del frame) — misma
2132convención que `query::NodeRef::click`.
2133
2134### Dientes y botones animados: el `label` es obligatorio
2135
2136`DockRailItem` y `RiveButton` no tienen texto en el árbol (el ícono lo pinta un
2137closure opaco), así que el nombre accesible viaja explícito:
2138
2139```rust
2140DockRailItem::nombrado(id, activo, "Configuración")   // recomendado
2141DockRailItem::new(id, activo)                          // queda mudo
2142RiveButton::builtin().con_label("Ejecutar")
2143```
2144
2145Y un patrón que apareció en casi todos: **el ítem sólo-ícono**. Un botón sin
2146texto visible (`toolbar` sin `with_label`, transport, chevrones de calendario,
2147el `×` de una pestaña) es mudo salvo que se le dé `aria_label` a mano. Los que
2148tienen un nombre derivable ya lo llevan; los que dependen del caller están
2149señalados en el código.
2150
2151---
2152
2153## 17. Comandos y demos
2154
2155```bash
2156cargo check --workspace                              # smoke test mínimo (debe pasar siempre)
2157cargo run -p <crate> --release                       # correr una app
2158cargo run -p <crate> --example <demo> --release      # correr un demo
2159
2160# demos del propio framework:
2161cargo run -p llimphi-ui      --example counter --release   # bucle Elm completo
2162cargo run -p llimphi-ui      --example editor  --release   # text field + teclado
2163cargo run -p llimphi-ui      --example gpu_paint_demo --release
2164cargo run -p llimphi-gallery --release                     # showcase de TODO el kit
2165cargo test -p llimphi-gallery                              # la vitrina como CALLER: los widgets responden
2166cargo run -p nada            --release                     # editor real para ejercitar widgets
2167
2168# benchmark GPU directo vs vello (primitivas Y texto — pedile GPU real, en
2169# llvmpipe la mitad de rasterizado no decide nada):
2170cargo run -p llimphi-gpu-bench --release
2171
2172# perfilador por frame (a stderr cada 60 frames: media/p95 por etapa):
2173LLIMPHI_PERF=1 cargo run -p <crate> --release
2174# ...con conteo de asignaciones de heap por frame y su desglose por etapa:
2175LLIMPHI_PERF=1 cargo run -p <crate> --release --features llimphi-ui/perf-alloc
2176
2177# dónde poner un .repaint_boundary() en TU app (ver más abajo):
2178LLIMPHI_BOUNDARY_HINT=1 cargo run -p <crate> --release
2179# un update que tarda de más y congela la UI:
2180LLIMPHI_UI_SLOW_MS=50 cargo run -p <crate> --release
2181```
2182
2183`LLIMPHI_PERF=N` reporta cada N frames (default 60) el desglose
2184`view/mount/layout/paint/raster/gpu/present` en µs + `layout-reuso`, el caché de
2185`repaint_boundary` y —si está la feature `perf-alloc`— `alloc <media>/<p95>
2186(<KB>) [view N·KB mount N·KB layout N·KB paint N·KB resto N·KB]`, donde `resto`
2187es el tramo posterior a paint (raster vello + GPU + present) y el desglose suma
2188el total. Sin esa feature el tramo de allocs dice `n/a` en vez de ceros falsos.
2189Ojo: los contadores son del proceso, no del hilo — lo que asigne otro hilo
2190durante una etapa cae en esa etapa. El contador es
2191`llimphi_ui::alloc::ContadorAlloc`: si tu app ya elige su allocator, instalalo a
2192mano (`#[global_allocator]`) en vez de usar la feature. El camino `cache_hit`
2193(frame retenido entero) **no** se muestrea a propósito.
2194
2195### `LLIMPHI_BOUNDARY_HINT` — dónde poner un `repaint_boundary`
2196
2197`.repaint_boundary(clave)` cachea la rasterización de un subárbol quieto y la
2198reusa mientras su contenido no cambie (Bloque 23). El problema práctico nunca fue
2199la API sino **dónde**: marcar un subárbol que en realidad cambia todos los
2200cuadros no es neutro —se paga el hash entero cada frame y no se ahorra nada—, y
2201desde afuera un panel quieto y uno que se reconstruye idéntico a sí mismo se ven
2202igual. Por eso ninguna app lo usaba.
2203
2204```bash
2205LLIMPHI_BOUNDARY_HINT=1 cargo run -p <tu-app> --release   # reporta cada 120 cuadros
2206LLIMPHI_BOUNDARY_HINT=300 …                                # cada 300
2207```
2208
2209Usá la app un rato con el puntero **fuera** de lo que sospechás. La salida:
2210
2211```
2212[llimphi] 2 subárbol(es) quieto(s), 431 nodos que se repintan de gusto
2213          — poneles .repaint_boundary(clave):
2214  nodo 12 · 287 nodos · (0,48 260×672) · 118 cuadros quieto «Archivos»
2215  nodo 640 · 144 nodos · (0,0 1920×48) · 118 cuadros quieto «Inicio»
2216```
2217
2218Cada línea es el subárbol **máximo** que vino idéntico: si un panel entero está
2219quieto no se listan también sus hijos. El texto es el primero que aparece
2220adentro, para que lo reconozcas sin contar nodos.
2221
2222Es una **predicción, no una opinión**: el detector consulta las mismas tres
2223funciones que el caché de verdad (posición limpia, paint-puro, y el mismo hash
2224que decide hit o miss), así que lo que señala el caché lo va a acertar. Dos
2225cosas que no dice:
2226
2227- **Si conviene.** Sugiere «esto habría sido cache-hit»; el hash se paga siempre.
2228  Por eso ignora subárboles de menos de 16 nodos.
2229- **Que esté quieto para siempre.** Un panel quieto 120 cuadros puede cambiar al
2230  siguiente click, y ahí el boundary falla y repinta — correcto, pero sin ahorro.
2231
2232Con el puntero **adentro** de un subárbol, ni el caché lo sirve ni el detector lo
2233sugiere: el hover cambia lo pintado. Y es diagnóstico, no producción — hashea
2234cada candidato por cuadro. Encendelo, anotá, apagalo.
2235
2236**Qué descalifica a un subárbol** (el detector se calla y el boundary, si lo
2237ponés igual, se pinta fresco): cualquier `paint_with`/`gpu_paint_with`, una
2238animación viva, un ripple, un hero, una `mask_image`, o estar bajo un
2239clip/alpha/transform de un ancestro. **Degradé e imagen sí entran** desde
22402026-08-05 — antes no, y como un sidebar real casi siempre tiene un ícono o un
2241fondo con degradé, un solo nodo así descalificaba el panel entero y el caché no
2242servía para lo que existía.
2243
2244`llimphi-gallery` (`src/main.rs`) es la **referencia viva** del
2245patrón completo: `Model`/`Msg`/`init`/`update`/`view`/`view_overlay` con overlays
2246mutuamente excluyentes (modal > atajos > toasts > context-menu > dropdown).
2247Controles: click en switches/segments; "Mostrar toast"/"Abrir modal"; `?` abre
2248atajos; `Esc` cierra el overlay activo.
2249
2250---
2251
2252## 18. Cheat-sheet
2253
2254```rust
2255// ── App mínima ──────────────────────────────────────────────
2256impl App for X { type Model; type Msg; init; update; view; }
2257llimphi_ui::run::<X>();
2258
2259// ── Nodo ────────────────────────────────────────────────────
2260View::new(Style{ flex_direction, size, gap, padding, align_items, justify_content, ..default() })
2261    .fill(c).fill_gradient(g).hover_fill(c).radius(r).radius_corners(tl,tr,br,bl).shadow(sh).border(w,c).clip(b).alpha(a).transform(xf).animated(key,dur)
2262    .text(s, px, c) | .text_aligned(s,px,c,al) | .text_runs(s,px,c,runs,al) | .text_weight(w) | .bold() | .ellipsis(n) | .max_lines(n)
2263    .image(img) | .paint_with(|scene,ts,rect|{}) | .gpu_paint_with(|d,q,enc,view,rect,vp|{})
2264    .on_click(m) | .on_click_at(|lx,ly,w,h|) | .on_right_click(m) | .on_middle_click(m)
2265    .on_pointer_enter(m) | .on_pointer_leave(m)
2266    .draggable(|ph,dx,dy|) | .draggable_at(|ph,dx,dy,lx0,ly0|)
2267    .drag_payload(id) | .on_drop(|id|) | .drop_hover_fill(c)
2268    .children(vec![..])
2269
2270// ── Efectos ─────────────────────────────────────────────────
2271handle.spawn(|| Msg::Done(compute()));          // worker → reentra al update
2272handle.spawn_periodic(dur, || Msg::Tick);       // feed periódico
2273handle.dispatch(Msg::X);  handle.quit();
2274
2275// ── Estilo de layout (taffy prelude) ────────────────────────
2276length(px)  percent(0..1)  Dimension::auto()
2277FlexDirection::{Row,Column}  AlignItems::{Start,Center,End,Stretch}
2278JustifyContent::{Start,Center,End,SpaceBetween}
2279
2280// ── Theme ───────────────────────────────────────────────────
2281Theme::dark()/light()/aurora()/sunset();  Theme::next_after(name);  XxxPalette::from_theme(&t)
2282
2283// ── Overlay (menús/modales) ─────────────────────────────────
2284fn view_overlay(m) -> Option<View<Msg>> { if m.open { Some(menu) } else { None } }
2285```
2286
2287---
2288
2289## 19. Índice de crates
2290
2291> Recontado contra el disco el **2026-08-27**. Son 139 crates en el workspace
2292> raíz más dos que viven en workspaces aparte (`llimphi-jaula`, `llimphi-wawa`:
2293> los backends de `llimphi-text` son excluyentes y no conviven en un proceso).
2294
2295**Núcleo del camino a píxeles** (`02_ruway/llimphi/`): `llimphi-hal` ·
2296`llimphi-raster` · `llimphi-text` · `llimphi-glifos` · `llimphi-layout` ·
2297`llimphi-compositor` · `llimphi-ui`.
2298
2299**Auxiliares del runtime**: `llimphi-theme` · `llimphi-motion` · `llimphi-icons` ·
2300`llimphi-image` · `llimphi-svg` · `llimphi-surface` · `llimphi-entrada` ·
2301`llimphi-despacho` · `llimphi-layer` · `llimphi-workspace` · `llimphi-test`
2302(golden-image) · `llimphi-gallery` · `llimphi-gpu-bench` · `demo`.
2303
2304**Los otros caminos a píxeles**: `llimphi-cpu` (`vello_cpu`, el de wawa y el
2305Android sin compute) · `llimphi-hybrid` (`vello_hybrid`) · `llimphi-reproductor`
2306(el pintor único de los dos anfitriones) · `llimphi-cable` ·
2307`llimphi-wire-escena` · `llimphi-wire-view` · `llimphi-jaula` y `llimphi-wawa`
2308(*workspaces aparte*). Se comparan por número con
2309`scripts/check-llimphi-cpu-vs-gpu.sh`.
2310
2311**3D, voxel y animación**: `llimphi-3d` · `llimphi-mesh` · `llimphi-voxel` ·
2312`llimphi-voxel-render` · `llimphi-voxel-app` · `llimphi-voxel-studio` ·
2313`llimphi-anim` · `llimphi-anim-studio` · `llimphi-lottie` ·
2314`llimphi-video-plane` · `llimphi-term-graphics`.
2315
2316**Camino web / WASM** (guardián: `scripts/check-llimphi-web.sh`):
2317`llimphi-wasm-core` · `llimphi-wasm-app-sdk` · `llimphi-wasm-runner` ·
2318`llimphi-wasm-wasi` · `llimphi-wasm-net` · `llimphi-wasm-registry` ·
2319`llimphi-wasm-dist` · `llimphi-wasm-open` · `llimphi-wasm-demo-counter` ·
2320`llimphi-wasm-demo-form`.
2321
2322**Widgets** (`widgets/`, dep `llimphi-widget-<n>`) — **76**, más
2323`llimphi-clipboard` que vive ahí sin serlo: accordion · app-header · app-icon ·
2324avatar · badge · banner · breadcrumb · button · calendar · card · carousel ·
2325chip · color-picker · context-menu · datetime-picker · detail-table · dock-rail ·
2326edit-menu · empty · fab · field · fitted-box · form · fugaces · gallery · gauge ·
2327grid · hero · lazy-list · list · menubar · modal · navigator · nodegraph ·
2328panel · panes · progress · rag-sidebar · range-slider · rating · rive-button ·
2329router · scaffold · scroll · segmented · select · shortcuts-help · skeleton ·
2330slider · spinner · splash · splitter · stat-card · status-bar · stepper ·
2331switch · tablado · table · tabs · terminal · text-area · text-editor ·
2332text-editor-core · text-editor-lsp · text-input · theme-switcher · tiled ·
2333timeline · toast · toolbar · tooltip · transport · tree · waveform ·
2334wawa-mark · wrap.
2335
2336**Módulos** (`modules/`) — **11**: allichay · bookmarks · command-palette ·
2337diff-viewer · fif · file-picker · mini-map · plugin-host · selector ·
2338shuma-term · symbol-outline.
2339
2340**Android** (`android/`): clear-screen-android · vello-hello-android ·
2341vello-text-android · hybrid-probe-android · llimphi-hybrid-android.
2342
2343---
2344
2345> Documentos hermanos: [`SDD.md`](SDD.md) (diseño y roadmap),
2346> [`COMPUTO-FUERA-DEL-HILO-UI.md`](COMPUTO-FUERA-DEL-HILO-UI.md) (regla de
2347> concurrencia), [`README.md`](README.md) / [`LEEME.md`](LEEME.md) (overview).
2348> Las firmas de este manual reflejan el código al 2026-06-01; el índice de
2349> crates y los conteos de widgets/módulos se recontaron contra el disco el
2350> 2026-08-27. Ante divergencia, la fuente autoritativa es el `lib.rs` de cada
2351> crate — y para cualquier conteo, el comando.

MANUAL.md

Manual de Llimphi

Motor gráfico soberano de tawasuyu. wgpu + vello + taffy + parley, bucle Elm input → update → view → layout → raster → present. Reemplazo total de GPUI (extinto 2026-05-26): toda app gráfica de la suite corre sobre Llimphi.

Este documento es la referencia de uso orientada a humanos y a IA. Está organizado para salto directo: cada capa, widget y módulo trae su API real (firmas copiadas del código). Para el porqué arquitectónico ver SDD.md; para la regla de concurrencia ver COMPUTO-FUERA-DEL-HILO-UI.md.


Índice

  1. Modelo mental en 60 segundos
  2. Arquitectura — las capas
  3. Quickstart — la app mínima
  4. El trait App (bucle Elm)
  5. Handle — efectos y concurrencia
  6. View<Msg> — el DSL declarativo
  7. Layout (taffy / Style)
  8. Eventos e interacción
  9. Texto
  10. Canvas custom y GPU directo
  11. Theme y paletas
  12. Capas base (hal · raster · text · motion · icons · surface)
  13. Catálogo de widgets
  14. Catálogo de módulos
  15. llimphi-workspace — chasis tipo tmux
  16. Reglas duras y gotchas 16.bis. Tests de regresión visual (golden-image) 16.ter. Semántica accesible y tests por consulta
  17. Comandos y demos
  18. Cheat-sheet
  19. Índice de crates

1. Modelo mental en 60 segundos

Llimphi es Elm sobre la GPU. Una app es un tipo que implementa el trait App con cuatro piezas:

El runtime hace el bucle: un evento (click/tecla/rueda) produce un Msg, update deriva el nuevo Model, view reconstruye el árbol, taffy calcula las cajas, vello rasteriza, y se hace swap del frame. No hay mutabilidad compartida, no hay vDOM ajeno, no hay callbacks imperativos: declaras qué se ve y qué Msg emite cada nodo.

   evento ─▶ Msg ─▶ update(model,msg) ─▶ model' ─▶ view(model') ─▶ View<Msg>
                                                                      │
   present ◀─ raster(vello) ◀─ layout(taffy) ◀──────────────────────┘

Tres reglas de oro:

  1. view es pura — no muta nada, sólo lee el modelo y arma el árbol.
  2. Cómputo pesado va a un worker vía Handle::spawn, nunca síncrono en update/init/handlers (congela la ventana → "Not Responding").
  3. Widgets son visuales y stateless; el estado vive en tu Model. Módulos sí encapsulan estado + comportamiento.

2. Arquitectura — las capas

4. llimphi-ui ........... runtime winit del bucle Elm (App, Handle, run, KeyEvent)
   └ llimphi-compositor . árbol View<Msg>, mount sobre taffy, paint, hit-test (winit-free)
3. llimphi-layout ....... motor de layout (taffy: flexbox + grid)
2. llimphi-raster ....... rasterizador vectorial (vello) + backend GPU directo
1. llimphi-text ......... shaping + fuentes (parley): bidi, ligaduras, CJK/emoji
   └ llimphi-glifos ..... atlas de cobertura: el texto del camino GPU-directo
0. llimphi-hal .......... abstracción de superficie (wgpu + winit / framebuffer)

El split compositor/runtime (2026-05-31) es importante: llimphi-compositor es winit-free (sólo View, mount, paint, hit-test). llimphi-ui lo corre sobre winit y re-exporta todo el compositor, así escribes llimphi_ui::View sin enterarte del split. Esto habilita un futuro runtime sobre el framebuffer del kernel wawa reusando el mismo compositor.

Auxiliares: llimphi-theme (paletas), llimphi-motion (tweens), llimphi-icons (iconos vectoriales), llimphi-surface (texturas externas), llimphi-workspace (chasis tmux), llimphi-gallery (showcase).

Catálogo: 76 widgets (visuales, crates llimphi-widget-*) + 11 módulos (features con estado). ls -d widgets/*/ da 77 carpetas: la que sobra es llimphi-clipboard, que vive ahí sin ser un widget. Recontado el 2026-08-27; el número sale del comando, no de esta línea.


3. Quickstart — la app mínima

use llimphi_ui::llimphi_layout::taffy::prelude::*;
use llimphi_ui::llimphi_raster::peniko::Color;
use llimphi_ui::{App, Handle, View};

#[derive(Clone)]
enum Msg { Increment, Reset }

struct Counter;

impl App for Counter {
    type Model = u32;
    type Msg = Msg;

    fn title() -> &'static str { "llimphi · counter" }

    fn init(_: &Handle<Self::Msg>) -> Self::Model { 0 }

    fn update(model: Self::Model, msg: Self::Msg, _: &Handle<Self::Msg>) -> Self::Model {
        match msg {
            Msg::Increment => model.saturating_add(1),
            Msg::Reset => 0,
        }
    }

    fn view(model: &Self::Model) -> View<Self::Msg> {
        let boton = View::new(Style {
            size: Size { width: length(160.0), height: length(56.0) },
            align_items: Some(AlignItems::Center),
            justify_content: Some(JustifyContent::Center),
            ..Default::default()
        })
        .fill(Color::from_rgba8(60, 200, 130, 255))
        .radius(12.0)
        .text("+1", 28.0, Color::from_rgba8(10, 30, 20, 255))
        .on_click(Msg::Increment);

        View::new(Style {
            flex_direction: FlexDirection::Column,
            size: Size { width: percent(1.0), height: percent(1.0) },
            align_items: Some(AlignItems::Center),
            justify_content: Some(JustifyContent::Center),
            gap: Size { width: length(0.0), height: length(24.0) },
            ..Default::default()
        })
        .fill(Color::from_rgba8(20, 24, 32, 255))
        .children(vec![
            View::new(Style::default()).text(model.to_string(), 160.0, Color::WHITE),
            boton,
        ])
    }
}

fn main() { llimphi_ui::run::<Counter>(); }

Cargo.toml:

[dependencies]
llimphi-ui    = { workspace = true }
llimphi-theme = { workspace = true }
# + los widgets/modules que uses:
# llimphi-widget-button = { workspace = true }

Corre con cargo run -p <tu-crate> --release. El ejemplo vivo está en llimphi-ui/examples/counter.rs.


4. El trait App (bucle Elm)

Definido en llimphi-ui/src/lib.rs. El estado es inmutable; cada evento produce un Model nuevo.

pub trait App: 'static {
    type Model: 'static;
    type Msg: Clone + Send + 'static;

    fn init(handle: &Handle<Self::Msg>) -> Self::Model;
    fn update(model: Self::Model, msg: Self::Msg, handle: &Handle<Self::Msg>) -> Self::Model;
    fn view(model: &Self::Model) -> View<Self::Msg>;

    // --- Todo lo de abajo tiene default; sobreescribí lo que necesites ---

    fn on_key(_model: &Self::Model, _event: &KeyEvent) -> Option<Self::Msg> { None }

    fn on_wheel(_model: &Self::Model, _delta: WheelDelta,
                _cursor: (f32, f32), _modifiers: Modifiers) -> Option<Self::Msg> { None }

    /// Capa de overlay (menús, modales, popovers). Si devuelve `Some`, se pinta
    /// encima y clicks/hover van EXCLUSIVAMENTE a ella (el fondo queda "bajo
    /// vidrio"). La transición la maneja tu Model.
    fn view_overlay(_model: &Self::Model) -> Option<View<Self::Msg>> { None }

    /// Drag&drop de archivos desde el file manager. Un evento por archivo.
    fn on_file_drop(_model: &Self::Model, _path: std::path::PathBuf) -> Option<Self::Msg> { None }

    /// El foco cambió (Tab/Shift+Tab o click sobre un nodo `focusable`). El
    /// runtime administra el foco; guardas `id` en tu Model para pintar el ring
    /// y rutear el teclado. Ver §8 (Foco y teclado).
    fn on_focus(_model: &Self::Model, _id: Option<u64>) -> Option<Self::Msg> { None }

    /// IME (composición de texto: CJK, acentos muertos, emoji). Opt-in vía
    /// `ime_allowed()` para no robarle el texto a las apps que sólo leen
    /// `on_key`. Flujo: Enabled → Preedit* → Commit/Disabled. Ver §8 (IME).
    fn ime_allowed() -> bool { false }
    fn on_ime(_model: &Self::Model, _event: &ImeEvent) -> Option<Self::Msg> { None }
    /// Área del caret en px físicos para ubicar la ventana de candidatos.
    fn ime_cursor_area(_model: &Self::Model) -> Option<(f32, f32, f32, f32)> { None }

    fn title() -> &'static str { "llimphi" }
    fn app_id() -> Option<&'static str> { None }   // app_id del xdg-toplevel en Wayland
    fn initial_size() -> (u32, u32) { (960, 540) }
}

Punto de entrada: pub fn run<A: App>() — corre hasta que el usuario cierre la ventana o la app llame Handle::quit.

Eventos de teclado (KeyEvent):

pub struct KeyEvent {
    pub key: Key,                 // re-export de winit; usar NamedKey para teclas especiales
    pub state: KeyState,          // Pressed | Released
    pub text: Option<String>,     // texto resultante con IME/modifiers; None para flechas etc.
    pub modifiers: Modifiers,     // { shift, ctrl, alt, meta }
    pub repeat: bool,
}

Key y NamedKey se re-exportan desde llimphi_ui.

Rueda (WheelDelta { x, y }): normalizado a "líneas". Convención CSS: y positivo = scroll hacia abajo.

El sentido (natural / clásico) va por CLASE de dispositivo y no lo decide la app. llimphi_entrada::PoliticaRueda tiene dos sentidos —uno para la rueda del ratón, otro para el touchpad—, y lo aplican las tres fronteras donde nace un WheelDelta: el runner de ventana, el de subventana y el de capa. Una App recibe el delta ya en el sentido que el humano eligió; no lo inviertas de nuevo (invertir dos veces cancela el ajuste). Invertir para zoom —rueda arriba = acercar— sí es tuyo: eso es semántica de la app, no sentido de scroll.

La política la siembra el runtime al arrancar (llimphi_ui::sembrar_politica_rueda). En tawasuyu sale del bus wawa-config (entrada.rueda_raton / entrada.rueda_touchpad, que escribe wawa-panel) gracias a la feature config-wawa, encendida en el [workspace.dependencies] de la raíz; fuera del monorepo se fija a mano con llimphi_entrada::fijar_politica_rueda. Bajo mirada el runtime se queda neutro a propósito: ahí lo aplica el compositor en libinput, por dispositivo, y así lo obedece toda la sesión.


5. Handle — efectos y concurrencia

Handle<Msg> es Send + Clone. Llega a init y update. Es el único modo legítimo de producir efectos sin romper la pureza de la transición.

impl<Msg: Send + 'static> Handle<Msg> {
    pub fn quit(&self);                 // cierra la ventana / termina el bucle
    pub fn dispatch(&self, msg: Msg);   // encola un Msg para el próximo turno
    pub fn spawn<F: FnOnce() -> Msg + Send + 'static>(&self, f: F);   // worker; su Msg reentra al update
    pub fn spawn_periodic<F: Fn() -> Msg + Send + 'static>(&self, period: Duration, f: F);  // tick periódico
    pub fn for_test() -> Self;          // handle "muerto" para tests sin event loop
    pub fn for_test_with<F: Fn(Msg) + Send + Sync + 'static>(sink: F) -> Self;  // tests que SÍ quieren los Msg

    // --- ventana (sólo la primaria; en un handle `lift`ado son no-op) ---
    pub fn set_fullscreen(&self, on: bool);   // pantalla completa sin bordes, en su monitor
    pub fn set_minimized(&self, on: bool);    // minimizar / restaurar
    pub fn set_content_type(&self, k: ContentType);  // qué mostrás: None/Photo/Video/Game
    pub fn open_window(&self, key: u64, title: impl Into<String>, w: u32, h: u32);  // ventana OS secundaria
    pub fn close_window(&self, key: u64);
}

6. View<Msg> — el DSL declarativo

Un View = Style de taffy + relleno + texto/imagen/painter + handlers + hijos. Todo se arma con builders encadenables (self -> Self). Definido en llimphi-compositor/src/view.rs.

View::new(style: Style) -> View<Msg>

Apariencia

MétodoEfecto
.fill(Color)color de fondo
.fill_gradient(Gradient)relleno con gradiente (autoreado en el cuadrado unidad [0,1]², mapeado al rect). Gana sobre fill; hover_fill lo overridea en hover
.hover_fill(Color)color al pasar el cursor (habilita hit-test de hover)
.radius(f64)esquinas redondeadas (radio uniforme)
.radius_corners(tl,tr,br,bl)radio por esquina (CSS border-radius 4 valores); override de .radius. El borde sigue las 4 esquinas; la sombra usa el radio escalar
.shadow(Shadow)drop shadow (vello draw_blurred_rounded_rect). Shadow::soft(alpha,blur) + .offset(dx,dy)/.spread(s)
.border(width, Color)stroke sobre el contorno redondeado, inset hacia adentro (border-box)
.alpha(f32)opacidad de todo el subtree [0,1] (capa intermedia — no gratis)
.transform(Affine)afín 2D alrededor del centro del rect (estilo CSS transform-origin:50% 50%)
.animated(key, Duration)animación implícita estilo Flutter AnimatedContainer: si fill/radius cambian entre frames, el runtime interpola (ease-out cúbico) en vez de saltar. key estable entre rebuilds. .animated_curve(key,dur,fn) para otra curva
.clip(bool)recorta hijos al rect (paint + hit-test)
.image(Image)pinta peniko::Image centrada, preservando aspect ratio
.children(Vec<View<Msg>>)hijos

Texto (ver §9)

.text(content, size_px, color)                            // centrado
.text_aligned(content, size_px, color, Alignment)
.text_aligned_italic(content, size_px, color, Alignment, italic)
.text_aligned_full(content, size_px, color, Alignment, italic, font_family: Option<String>)
.text_runs(content, size_px, default_color, runs: Vec<(usize,usize,Color)>, Alignment) // multicolor 1-pasada
.line_height(mult)                                        // override interlínea (default 1.2)
.text_weight(f32)                                         // peso CSS: 400 normal, 600 semibold, 700 bold
.bold()                                                   // atajo de text_weight(700.0)
.ellipsis(n)                                              // clampa a n líneas terminando en … (n=1 = single-line)
.max_lines(n)                                             // clampa a n líneas sin glifo (corte seco)

Interacción (ver §8)

.on_click(Msg)
.on_click_at(|lx, ly, w, h| -> Option<Msg>)     // posición local + tamaño del rect
.on_right_click(Msg) / .on_right_click_at(...)
.on_middle_click(Msg)
.on_pointer_enter(Msg) / .on_pointer_leave(Msg)
.draggable(|phase: DragPhase, dx, dy| -> Option<Msg>)
.draggable_at(|phase, dx, dy, lx0, ly0| -> Option<Msg>)   // + posición inicial del press
.drag_payload(u64)                                        // payload que viaja con el drag
.on_drop(|payload: u64| -> Option<Msg>)                   // este nodo es drop target
.drop_hover_fill(Color)                                   // resaltado mientras un drag lo sobrevuela
.on_scroll(|dx, dy| -> Option<Msg>)                       // rueda local (antes del on_wheel global)
.on_scroll_take(|dx, dy| -> (Option<Msg>, rx, ry))        // reparto PARCIAL: consume lo que puede y el
                                                          // sobrante sigue al ancestro (mismo evento);
                                                          // scroll_y/lazy_list ya lo usan (scroll_take)
.on_scale(|phase: GesturePhase, factor, fx, fy| -> Option<Msg>)  // pinch-to-zoom (Ctrl+rueda / trackpad)
.on_double_tap(Msg) / .on_double_tap_at(|lx, ly, w, h| ...)      // dos clicks rápidos y cercanos
.double_tap_exclusive()                                   // …y que ARBITRE contra el click del nodo
.on_long_press(Msg) / .on_long_press_at(|lx, ly, w, h| ...)      // mantener ~500 ms quieto
.focusable(u64)                                           // nodo enfocable por Tab/click (id opaco)

Pintura custom (ver §10)

.paint_with(|scene: &mut vello::Scene, ts: &mut Typesetter, rect: PaintRect| { ... })
.gpu_paint_with(|device, queue, encoder, view, rect: PaintRect, (vp_w, vp_h)| { ... })

Notas clave:


7. Layout (taffy / Style)

Style es el taffy::Style directo, re-exportado vía llimphi_ui::llimphi_layout::taffy::prelude::*. Es Flexbox + CSS Grid puro.

Campos más usados:

Style {
    flex_direction: FlexDirection::Row | Column,
    size:    Size { width, height },          // length(px) | percent(0..1) | Dimension::auto()
    min_size, max_size,
    flex_grow: f32, flex_shrink: f32,
    align_items:     Some(AlignItems::{Start,Center,End,Stretch}),
    justify_content: Some(JustifyContent::{Start,Center,End,SpaceBetween,...}),
    gap:     Size { width, height },
    padding: Rect { left, right, top, bottom },   // con length(px)
    margin:  Rect { ... },
    ..Default::default()
}

Helpers de prelude: length(px), percent(frac), auto(), Dimension, Size, Rect, FlexDirection, AlignItems, JustifyContent.

llimphi-layout además expone:

En el 99% de los casos no tocas LayoutTree a mano: lo maneja el runtime al montar tu View. Sólo armás Styles.


8. Eventos e interacción

Quiero…Cómo
Botón / fila clickable.on_click(Msg) (+ .hover_fill para feedback)
Saber dónde se clickeó (canvas).on_click_at(|lx,ly,w,h| ...) → convertir a coords de mundo
Menú contextual.on_right_click(Msg::OpenMenu{..}), guardar pos en Model, abrir en view_overlay
Abrir en pestaña nueva.on_middle_click(Msg)
Preview al pasar el mouse.on_pointer_enter(Msg) / .on_pointer_leave(Msg)
Resize de panel.draggable(|phase,dx,dy| ...) acumulando delta en el Model
Arrastrar entidad de un canvas.draggable_at(|phase,dx,dy,lx0,ly0| ...)
Drag&drop entre zonasorigen: .drag_payload(id); destino: .on_drop(|id| ...) + .drop_hover_fill
Scroll globalApp::on_wheel(model, delta, cursor, mods)
Área de scrollwidget scroll_y(...) (autocontenido) o .on_scroll(|dx,dy| ...) por nodo
Zoom de canvas (pinch).on_scale(|phase,factor,fx,fy| ...) → zoom *= factor, reajustar pan al focal
Doble-click.on_double_tap(Msg) / .on_double_tap_at(|lx,ly,w,h| ...); + .double_tap_exclusive() si el mismo nodo tiene click
Long-press (mantener).on_long_press(Msg) / .on_long_press_at(|lx,ly,w,h| ...)
TecladoApp::on_key(model, &KeyEvent) -> Option<Msg>
Foco / Tab.focusable(id) en los nodos + App::on_focus(model, id) (ver abajo)
IME (CJK, acentos)App::ime_allowed() -> true + App::on_ime(model, &ImeEvent) (ver abajo)
Drop de archivos del SOApp::on_file_drop(model, path)

Patrón overlay (menús/modales): el modelo guarda "menú abierto sí/no". Mientras esté abierto, view_overlay devuelve Some(view); clicks fuera se cierran envolviendo los items en un scrim a pantalla completa con on_click = DismissOverlay. Cuando el modelo dice cerrado, view_overlay devuelve None.

Scroll (widget llimphi-widget-scroll). scroll_y(offset, content_len, viewport_len, content, on_scroll, &palette) arma un viewport clipeado + contenido desplazado -offset + barra arrastrable. Es stateless: el offset vive en tu Model. on_scroll(delta_px) (rueda y arrastre) emite un delta a sumar; clampealo con scroll::clamp_offset en tu update. Helpers: ensure_visible(offset, vp, item_top, item_h) para llevar la selección a la vista (teclado); approach(cur, target, factor) para scroll suave hacia un objetivo (driveado por Handle::spawn_periodic).

Scroll 2D / física / slivers (Tier 5, mismo widget, todo stateless):

Foco y teclado. Marca los nodos navegables con .focusable(id) (id u64 que vos eliges). El runtime es la única fuente de verdad del foco: lo mueve con Tab/Shift+Tab en orden de árbol (envolviendo) y al clickear un nodo enfocable, y te avisa con App::on_focus(model, Option<u64>). Guardas el id en tu Model para (a) pintar el ring (if model.focus == Some(id) { .fill(accent) } en view) y (b) rutear el teclado al campo activo desde on_key. No setees el foco por tu cuenta vía Msg: quedaría desincronizado del runtime.

IME (composición de texto). Opt-in: ime_allowed() -> true. Con IME activo el texto compuesto no llega por KeyEvent.text sino por on_ime: ImeEvent::Enabled → uno o más Preedit{text, cursor} (texto en composición, a pintar subrayado en el caret) → Commit(text) (inserta como tecleado) o Disabled. Reporta el área del caret con ime_cursor_area(model) para ubicar la ventana de candidatos (CJK) junto al cursor.


9. Texto

TextSpec (en compositor) describe el texto de un nodo:

pub struct TextSpec {
    pub content: String,
    pub size_px: f32,
    pub color: Color,
    pub alignment: Alignment,          // Start | Center | End | Justify
    pub italic: bool,
    pub font_family: Option<String>,   // string CSS con fallbacks
    pub line_height: f32,              // múltiplo; default 1.2
    pub runs: Option<Vec<(usize, usize, Color)>>,  // color por rango de BYTES
}

10. Canvas custom y GPU directo

Dos hooks para pintar primitivas no expresables como composición de Views. Conviven en el mismo árbol; el runtime pinta toda la pasada vello primero, luego los gpu_painter en orden DFS.

paint_with — vía vello (el default)

.paint_with(|scene: &mut vello::Scene, ts: &mut Typesetter, rect: PaintRect| {
    // dibujar BezPath, kurbo, texto con `ts`, etc. dentro de `rect`.
    // NO dejar push_layer sin pop_layer; NO resetear la scene.
})

Para: dominium-canvas, osciloscopios de pluma, charts de cosmos, pineal. Bueno hasta ~500 K primitivos por frame (rebuild) o ~2 M (Scene reusada).

gpu_paint_with — sube vertex buffers directo a wgpu, salta vello

.gpu_paint_with(|device, queue, encoder, view, rect: PaintRect, (vp_w, vp_h)| {
    // abrir begin_render_pass con LoadOp::Load (NO clear) para preservar vello.
    // (vp_w, vp_h) = tamaño en px de la TextureView destino, para calcular NDC.
})

Para volumen masivo: starfield Gaia de cosmos, particles de tinkuy, viewport de nakui, pineal denso. Rango 100 K – 10 M+ primitivos. Sí soporta texto desde 2026-08-05 (§12, llimphi-glifos + add_glyph) y múltiples grosores de stroke por flush desde el mismo día. Lo que sigue sin soportar es el AA analítico de curvas: los bordes de rects/líneas/tris salen del MSAA 4×, no de cobertura exacta. Para texto con brush (gradiente, imagen) o decoraciones sigue haciendo falta la Scene vello de view_overlay.

UI encima de contenido GPU — View::over y paint_over

El orden del frame es [vello base] → [gpu_paint] → [vello over] → [overlay/ menús]: cualquier View normal que se solape con un gpu_paint_with queda tapado por él (el blit corre después de la pasada base). Dos salidas:

// Subárbol entero de Views pintado en la pasada over (fills, texto, widgets,
// hover — la maquinaria completa). El primitivo para un OSC de reproductor,
// un HUD, un rail que solapa un canvas GPU. El hit-test no cambia.
View::new(estilo_absoluto).over(true).children(vec![barra_de_transporte])

// Closure vello suelta en la pasada over (sprites/texto AA sobre celdas
// instanciadas — dominium, motor voxel).
.paint_over(|scene, ts, rect| { ... })

Limitación de ambas: la capa over se compone con alpha sobre la intermedia; clips/alpha/transform de ancestros fuera del subárbol no la afectan. Caso canónico de .over(true): el OSC estilo mpv de media-app (02_ruway/media/media-app/src/vista.rs::overlay_bars_view) y su fondo de marca (src/fondo.rs::fondo_overlay) flotando sobre el video.

¿Cuándo cada uno?

Preguntavello (paint_with)GPU directo (gpu_paint_with)
Primitivos/frame< ~500 K rebuild / < ~2 M Scene reusada100 K – 10 M+
¿Cambian cada frame?sí, rebuild baratomejor estático (buffer persistente)
Curvas Beziernativashay que teselar
Textosí, con brushsí, color sólido (atlas de cobertura)
AA finosí (analítico)bordes por MSAA 4×; el texto trae el suyo

Default: paint_with salvo que ya midas que el volumen lo justifica (factores ~11× a 1M en GPU mid sólo en el régimen persistente). El backend GPU expone GpuPipelines/GpuBatch en llimphi-raster (§12).


11. Theme y paletas

llimphi-theme::Theme es un struct de slots semánticos de color. Cuatro presets const: Theme::dark() (default), light(), aurora(), sunset().

pub struct Theme {
    pub name: &'static str,
    // fondos
    pub bg_app, bg_panel, bg_panel_alt, bg_input, bg_input_focus,
    pub bg_button, bg_button_hover, bg_selected, bg_row_hover: Color,
    // texto
    pub fg_text, fg_muted, fg_placeholder, fg_destructive: Color,
    // bordes y acento
    pub border, border_focus, accent: Color,
}

Theme::all() -> Vec<Theme>                 // orden de rotación canónico
Theme::by_name(name) -> Option<Theme>
Theme::next_after(current_name) -> Theme   // para el theme-switcher

Tokens auxiliares en el mismo crate:

Patrón de widgets: cada widget define su XxxPalette con Palette::from_theme(&theme). Tu app guarda un Theme en el Model, deriva las paletas que necesita en view, y se las pasa a los widgets. Para cambiar de tema, el theme-switcher emite Msg(next_theme) y reconstruyes todo.


12. Capas base

llimphi-hal — superficie

Hal::new(compatible_surface: Option<&wgpu::Surface>) -> Result<Hal, HalError>   // async
trait Surface { fn size(); fn resize(w,h); fn acquire() -> Result<Frame,_>; fn present(frame, hal); }
WinitSurface::new(hal, window: Arc<Window>) -> Result<Self, HalError>
Frame::view() -> &wgpu::TextureView;  Frame::size() -> (u32,u32)

Hal::new pide adapter Backends::PRIMARY (Vulkan) y cae a all() sólo si no hay — no volver a InstanceDescriptor::default(): el backend GL de Mesa sobre Wayland segfaultea en el teardown. El runtime de llimphi-ui ya maneja todo esto; sólo tocas HAL si escribes un runtime nuevo.

llimphi-raster — rasterización

Renderer::new(hal) -> Result<Renderer,_>
Renderer::render(&mut self, hal, scene: &vello::Scene, frame: &Frame, base_color: Color)
// GPU directo:
GpuPipelines::new(device, color_format) -> Self   // campos: lines, tris, rects, bind_layout
GpuBatch::new(&pipelines)
  .line_width(w)                    // pluma: vale para lo que se agregue DESPUÉS
  .add_line(p0,p1,color) .add_polyline(&pts,color)
  .add_tri(a,b,c, ca,cb,cc) .add_tri_list(&verts,color) .add_rect(x,y,w,h,color)
  .add_disc(cx,cy,r,color) .add_ring(cx,cy,r,stroke,color)
  .atlas(&AtlasTextura)             // sin esto los quads NO se dibujan
  .add_glyph(x,y,w,h,(u0,v0,u1,v1),color)   // quad texturado: el TEXTO
  .scissor(x,y,w,h)                 // sólo se toca ese rect (damage)
  .max_buffer_bytes(bytes)          // baja el techo por buffer; normalmente no se toca
  .primitive_count() -> u32
  .flush(device, queue, encoder, view, viewport, load_op)
units_per_chunk(stride, max_bytes, multiplo) -> u32
RECT_INSTANCE_STRIDE / LINE_INSTANCE_STRIDE / DISC_INSTANCE_STRIDE / TRI_VERTEX_STRIDE
QUAD_INSTANCE_STRIDE
// la textura de cobertura contra la que se muestrean los quads:
AtlasTextura::new(device, &pipelines, lado) -> Self
  .subir(queue, pixeles, (x,y,w,h))   // sube SÓLO ese rect, no el atlas entero

Re-exporta vello y peniko (Color, Image, Fill, etc.).

Cuatro cosas del flush que conviene saber y no se ven en la firma:

llimphi-glifos — el texto del camino GPU-directo

AtlasGlifos::new(lado) -> Self          // atlas cuadrado de cobertura, 1 B/px
  .pixeles() -> &[u8]  .lado() -> u32  .version() -> u64  .entradas() -> usize
  .tomar_sucio() -> Option<Sucio>       // qué cambió desde la última vez, y limpia
  .limpiar()                            // única salida cuando `fallos` sube
  .fallos / .aciertos / .rasterizados   // contadores públicos
quads_de_layout(&mut atlas, &layout, (x,y), &mut Vec<QuadGlifo>) -> usize
quads_de_run(&mut atlas, &glyph_run, (x,y), &mut Vec<QuadGlifo>)  // multicolor

Rasteriza cada glifo una sola vez a un mapa de 8 bits y lo empaqueta por estantes; de ahí en más pintar texto es pintar quads. Un renglón de 80 caracteres pasa de 80 contornos rellenos a 80 instancias de 48 B.

Lo que hay que saber antes de usarlo:

Hay DOS atlas de glifos en llimphi, y hay que saber cuál usar. El otro es llimphi_widget_terminal::GlyphAtlas, anterior (Fase 4 del SDD-TERMINAL) y vivo: lo consume el modo TUI de shuma. llimphi-glifos se escribió sin advertirlo.

Desde el 2026-08-05 comparten rasterizador (llimphi_glifos::Rasterizador, swash/zeno); antes el terminal tenía fontdue propio. Lo que sigue habiendo dos es el empaquetado, y eso es de diseño:

GlyphAtlas (widget terminal)AtlasGlifos (llimphi-glifos)
clavechar (charmap, sin shaping)(fuente, id, tamaño, subpíxel, síntesis)
empaquetadogrilla de celdas igualesestantes de tamaño variable
subpíxelno4×4
fuentesuna, monoespaciadacualquiera, varias por renglón

Cuál usar: una grilla de terminal, GlyphAtlas — puede asumir monoespaciado, celdas enteras y cero shaping, y esas suposiciones son parte de por qué es rápido. Cualquier otro texto, llimphi-glifos.

Si empaquetás a tu manera, no escribas un tercer rasterizador: Rasterizador::glifo(datos, ClaveGlifo::de_glifo(id, tam)) devuelve el MapaGlifo (cobertura + colocación) y vos lo ponés donde quieras. Y para lo que no necesita shaping hay metricas, id_de_caracter y avance.

La migración se hizo contra dos oráculos, no a ojo:

Y un tropiezo que dejó regla: la primera corrida de dos_atlas daba 44 % de diferencia. No era el rasterizador — TextBlock::simple deja font_family: None y parley cae a sans-serif, así que comparaba dos tipografías. Si comparás glifos, fijá la familia primero.

¿Cuándo conviene? Medido en metal (Iris Xe/Vulkan, llimphi-gpu-bench sección «Texto», 12.096 glifos): el total baja 3,6-5,0× contra vello, y lo que importa es la forma — el costo del atlas es casi plano con la cantidad de texto (5,0 → 5,5 ms entre 7.200 y 12.096 glifos) mientras vello crece lineal (13,6 → 26,4 ms). Pero la mitad de CPU del atlas es peor que la de vello (~0,7×): vello apila un id y una posición por glifo, el atlas hace una búsqueda en tabla y arma un quad. Toda la ganancia viene del lado GPU.

O sea: el atlas es para mucho texto que se repinta seguido — grilla de terminal, editor, etiquetas sobre un canvas denso. Para un rótulo suelto no compra nada y encima paga el ciclo fijo del flush (MSAA + resolve + composite). El default sigue siendo vello.

llimphi-image — decode pipeline

decode_bytes(&[u8]) -> Result<peniko::Image, DecodeError>
load_path(&Path, max_bytes: u64) -> Result<peniko::Image, DecodeError>
from_rgba8(rgba: Vec<u8>, w: u32, h: u32) -> peniko::Image
// mismo decode, pero además dice de qué espacio venía (para editores):
decode_bytes_con_origen(&[u8]) -> Result<Decodificada, DecodeError>
load_path_con_origen(&Path, max_bytes: u64) -> Result<Decodificada, DecodeError>
Decodificada { imagen: Image, origen: Option<Perfil>, convertida: bool }
Decodificada::devolver_al_origen(&self, rgba: &mut [u8]) -> bool
// perfiles de color (mod perfil):
perfil::leer_icc(&[u8]) -> Option<Perfil>          // matrix-shaper; None si no lo es
perfil::convertir_a_srgb(&mut [u8], &Perfil) -> bool      // entrada: del perfil a sRGB
perfil::convertir_desde_srgb(&mut [u8], &Perfil) -> bool  // salida: de sRGB al perfil
perfil::matriz_a_srgb(&[f32;9]) -> [f32;9] / matriz_desde_srgb(&[f32;9]) -> Option<[f32;9]>
perfil::es_identidad(&[f32;9], tol) -> bool
Curva::{Lineal, Gamma(f32), Tabla(Vec<u16>), Parametrica{tipo, p}}
Curva::a_lineal(v) -> f32 / Curva::desde_lineal(lin) -> f32   // una es inversa de la otra

Encapsula el patrón image::ImageReader + to_rgba8 + Blob + Image::new que cada app que decodifica imágenes tenía duplicado. max_bytes aplica al tamaño del archivo en disco (no a la imagen decodificada, que en RGBA8 puede ser mucho mayor — un PNG 4K ocupa ~64 MB descomprimido); 0 deshabilita el cap. Re-exporta peniko::{Blob, Image, ImageFormat}. Formatos según las features del crate image upstream del workspace (hoy: PNG, JPEG, WEBP).

Gestión de color (2026-08-05). decode_bytes / load_path leen el perfil ICC embebido y llevan la imagen a sRGB. Antes se tiraba: una foto Display P3 —lo que saca cualquier teléfono desde 2016— se pintaba sobresaturada, porque sus rojos, que son más rojos que los de sRGB, se mostraban como si fueran los de sRGB. Nadie tiene que hacer nada: el caller sigue recibiendo RGBA8 sRGB.

let d = llimphi_image::load_path_con_origen(ruta, 64 << 20)?;
let mut px: Vec<u8> = d.imagen.image.data.as_ref().to_vec();   // sRGB, para editar
// …edición…
d.devolver_al_origen(&mut px);   // false si no hay a dónde volver: se guarda sRGB

llimphi-text — shaping

Typesetter::new()                          // una por proceso (FontContext es caro)
  .layout(text, size_px, max_width, alignment, line_height, italic, font_family) -> Layout<()>
  .layout_runs(text, size_px, default_color, &runs, alignment, line_height) -> Layout<RunBrush>
TextBlock::simple(text, size_px, color, origin)
layout_block(ts, &block) / measure(ts, &block) -> Measurement
draw_layout(scene, &layout, color, origin) / draw_layout_runs(scene, &layout, origin)
Alignment::{Start, Center, End, Justify}

llimphi-motion — tweens

trait Lerp { fn lerp(self, other, t: f32) -> Self; }   // impl para f32,f64,(f32,f32),(f64,f64),Color
Tween::new(from, to, duration, easing: fn(f32)->f32)   // o Tween::idle(value)
tween.value() / .progress() / .done()
animate(handle, duration, make_msg)                    // arranca los ticks del tween

Patrón: guardas Tween<T> en el Model, animate(...) en el update, la view lee tween.value() cada repaint. El tween se auto-termina.

llimphi-icons — iconos vectoriales (~23, grid 24×24)

Icon::{File, Folder, Save, Plus, Minus, X, Check, Edit, Trash, ChevronUp/Down/Left/Right,
       Home, Search, Info, Warning, Error, Bell, Settings, More, ...}
icon_view(Icon, color, stroke_width) -> View<Msg>
paint_icon(scene, rect, icon, color, stroke_width)     // dentro de un paint_with

stroke_width en unidades del grid 24×24 (1.6 es armónico).

llimphi-surface — texturas externas

ExternalSurface::new(device, queue)        // barato de clonar (Arc<Mutex> interno)
  .upload(&rgba, w, h)                      // desde otro hilo/decoder/cámara
  .view(style) -> View<Msg>                 // blittea a su rect en el árbol Elm
  .blit(queue, encoder, dst_view, rect, viewport)   // o manual desde gpu_paint_with

13. Catálogo de widgets

Los widgets son funciones puras que devuelven View<Msg> (o specs que se convierten a View). Son stateless: el estado vive en tu Model. Convención: cada uno trae XxxPalette::from_theme(&Theme). Crates en widgets/<nombre>/, dep llimphi-widget-<nombre>.

Controles

button — button_view(label, &ButtonPalette, on_click: Msg) -> View; button_styled(label, style, alignment, &palette, on_click).

field — wrapper de formulario (label + helper/error + requerido). field_view(FieldSpec { label, control: View<Msg>, required, helper, error, palette }).

text-input — input single-line con estado TextInputState (new()/masked(), text(), set_text(), apply_key(&KeyEvent) -> bool, soporta undo/redo + selección con Shift). Render: text_input_view(&state, placeholder, focused, &palette, on_focus: Msg).

text-area — input multilínea con paridad real (mismo motor EditorState que el text-input; NO el viejo String plano). El estado es TextInputState::multiline() (o llimphi_widget_text_area::new_state()). text_area_view(&state, placeholder, focused, rows, &palette, |ev| Msg::Area(ev)) emite TextAreaEvent (Key/Ime/Press(x,y)/Drag/DragEnd/Context) ya envueltos; en update → state.handle_area(ev, &mut clipboard). Da: click 2D (renglón+columna por glifos reales), ↑/↓ entre renglones, selección a través de líneas (arrastre 2D, doble/triple-click, Shift+flechas), copiar/cortar/pegar, undo/redo, IME, Enter=\n, soft-wrap (la línea larga se envuelve al ancho de la caja) y scroll vertical (caret a la vista si hay más renglones que rows). Caret + selección pintados por paint_over en coords de renglón visual; fuente única del layout: visual_layout (render/hit-testing/caret/nav comparten). ↑/↓ navegan por renglón visual (no por línea lógica: en texto envuelto bajan un renglón, no la línea entera), con columna meta en px preservada (move_vertical_visual; Shift extiende selección). El crate llimphi-widget-text-area es una fachada que re-exporta esto. Demo: cargo run -p llimphi-widget-text-input --example area_demo.

text-area RICO — el mismo widget con los adornos que antes obligaban a escribirse un pintor propio. text_area_view_rico(&state, focused, rows, &palette, &Metricas, &Adornos, wrap). Si necesitás un input mono, coloreado, con fantasma o con caret animado, es ESTE, no un paint_with a mano.

slider — sin estado. slider_view(label, value, min, max, &palette, on_change: Fn(DragPhase, delta_value) -> Option<Msg>). El delta viene en unidades, no píxeles.

color-picker — selector RGBA: swatch actual + paleta de chips + barra de hue + sliders R/G/B/A (+ campo #RRGGBB opcional). Agnóstico, emite [u8; 4]. color_picker_view(rgba: [u8;4], swatches: &[[u8;3]] (p.ej. DEFAULT_SWATCHES), &ColorPickerPalette, hex: Option<HexField { focused, state: Option<&TextInputState>, on_focus }>, on_change: Fn([u8;4]) -> Msg). Helpers puros: rgba_to_hex, parse_hex(s, cur_alpha) (acepta 3/6/8 dígitos, # opcional), color_picker_height(with_hex) para pre-calcular el alto.

switch — switch_view(progress: f32 [0..1], on_toggle: Msg, &palette). La app guarda el bool y opcionalmente anima progress con un Tween.

segmented — N opciones exclusivas. segmented_view(&[&str], selected: usize, make_msg: Fn(usize)->Msg, &palette).

select — combobox/dropdown moderno (disparador cerrado + menú flotante en view_overlay). Soporta ítems ricos (SelectItem { label, sublabel, icon (glifo unicode), badge: SelectBadge, enabled }), selección múltiple (selected: &[usize] con check ✓), búsqueda in-menu y fases async (SelectPhase::{Loading, Error(&str), Ready(&[SelectItem])}) para listas que vienen de Handle::spawn. El cerrado se pinta con select_trigger_view(selected: Option<&SelectItem>, placeholder, open, width, &palette, on_toggle: Msg); el menú con select_menu_view(SelectMenuSpec { anchor, viewport, width, phase, visible: &[orig_idx], active, selected, query, searchable, empty_text, appear, on_pick: Fn(orig_idx)->Msg, on_hover, on_dismiss, on_retry, palette }). La app mantiene query/active/visible/selected en el Model. Helpers puros para el filtro: filter(items, query) -> Vec<orig_idx>, step_active(items, visible, current, ±1) para nav, resolve(visible, pos) -> Option<orig_idx>.

progress — linear_progress_view(progress, track, fill, height) y radial_progress_view(progress, track, fill, stroke_ratio). Sin eventos.

spinner — spinner_view(color, stroke_ratio, speed_rev_per_sec). Animado por reloj absoluto; requiere redraws periódicos (spawn_periodic).

rive-button — botón reactivo dirigido por máquina de estados (llimphi-anim, estilo Rive) — primer consumidor real de la StateMachine fuera del studio. Stateful (como text-editor): guardas RiveButton en tu Model. Tres estados idle/hover/press, cada uno con su clip Lottie; hover por bool (listeners Enter/Exit del motor), press por trigger (ClipDone lo devuelve solo). API: RiveButton::new(idle, hover, press, press_secs, blend_secs) o ::builtin() (clips embebidos); advance(dt) desde un tick, pointer(Option<(f64,f64)>), press() desde el on_click del host, current_state()/is_transitioning(). Render: view(on_pointer: Fn(Option<(f64,f64)>)->Msg, on_click: Msg). Demo: cargo run -p llimphi-widget-rive-button --example rive_button_demo --release.

badge — count_badge_view(count, BadgeKind) ("99+" si ≥100) y dot_badge_view(BadgeKind). BadgeKind::{Info,Success,Warning,Error,Neutral}.

avatar — avatar_view(name, size_px): círculo determinista (color por hash del nombre + inicial).

tooltip — render puro. tooltip_view(TooltipSpec { anchor, viewport, side: Side, text, palette }). Se monta en view_overlay; la app controla visibilidad con on_pointer_enter/leave.

empty — empty-state. empty_view(Icon, title, description: Option<&str>, &palette).

skeleton — placeholder con shimmer: una banda de gradiente [low, high, low] que cruza el rect. skeleton_view, skeleton_box_view(w,h,..), skeleton_line_view(w,..). Requiere redraws periódicos (spawn_periodic(50ms) mientras haya skeletons visibles). Esas tres arrancan reloj propio y están gateadas a std; skeleton_view_en(inicio, ahora, &pal) recibe el instante por argumento y es la que sirve sin std (jaula/kernel), misma doctrina que el today del calendar. La aritmética es pura y testeable: banda_shimmer(ancho, progreso) -> (izq, der) —la banda entra y sale entera del rect, con piso de ancho para que en un avatar chico el destello no sea un píxel— y progreso_shimmer(segundos) -> [0,1).

carousel — pager paginado de N páginas. carousel_view(CarouselSpec { pages, current, wrap: CarouselWrap::{Wrap, Clamp}, show_arrows, palette, on_change: Fn(usize) -> Msg }). Dots indicadores abajo (clickeables para saltar a la página i) + flechas opcionales ‹ / › a los costados. v1 sin swipe; para v2 sumar swipe horizontal usando draggable_velocity + fling_step para snap-on-release.

fitted-box — escala un subárbol al slot del padre. fitted_box((iw, ih), BoxFit::{Contain, Cover, Fill, None, ScaleDown}, || inner_view()) aplica un transform afín al inner (medido por el seam LayoutBuilder) para que entre en el slot real, centrado, preservando aspect salvo Fill. Útil para imágenes, canvases paint_with y texto grande que deben caber en celdas chicas sin que el caller los re-mida. La función pura compute_fit(slot, inner, fit) -> (sx, sy, dx, dy) es testeable.

calendar — vista mensual 6×7 (calendar_view(CalendarSpec { view_year, view_month, selected, today, week_start, palette, on_select: Fn(NaiveDate)->Msg, on_view_change: Fn(year, month)->Msg })). Header con < Mes Año > para navegar entre meses, fila de iniciales (L M M J V S D ó D L M M J V S según WeekStart), grilla siempre de 6 filas (no reflowea al cambiar de mes). El caller le inyecta today — el widget no toca el reloj. Base del date-picker: combinar con view_overlay + un field/button disparador. calendar_view_ext(spec, CalendarOpciones { limites, rango, navegacion_anual }) suma lo que un <input type=date> sí tiene, sin tocar a quien ya usa calendar_view (el Default de las opciones es el calendario de siempre): Limites{min,max} inclusive apaga los días fuera de rango (aria_disabled, sin click) y las flechas cuyo mes destino esté entero afuera (mes_habil); RangoSeleccion pinta la banda entre dos extremos y avanza sola con .click(fecha) (1º fija inicio, 2º cierra ordenando, 3º empieza de nuevo; dos clicks al mismo día = rango de un día), con contiene/es_extremo/dias/ recortado(&limites) —que interseca, no clampea cada punta— para el host; navegacion_anual agrega una fila « año » arriba de la de mes (fila aparte y no cuatro flechas en una: el ancho lo fija la grilla y «septiembre» no entraría). Teclado: cal_move_desde_tecla(&KeyEvent) -> Option<CalMove> es el mapa del picker nativo (flechas = día/semana, PageUp/Down = mes, Ctrl+PageUp/Down = año, Home/End = bordes del mes) y nav_date_limitada(fecha, mov, &limites) aterriza en el borde en vez de salirse.

accordion — secciones plegables (expansion panels). accordion_view(AccordionSpec{secciones, abiertas: &[usize], modo, on_toggle, palette}) con Modo::{Una, Varias}; la máquina de qué queda abierto es alternar(&abiertas, i, modo) -> Vec<usize> (pura) y todas(n, modo) da el «expandir/plegar todo» — en Modo::Una devuelve una, porque devolver todas dejaría el Model en un estado que el propio modo prohíbe. El cuerpo llega como Fn() -> View y no se construye si la sección está cerrada: en un acordeón de veinte secciones con formularios adentro, armar los diecinueve invisibles es el costo real. Seccion::con_resumen("Visa ••4242") muestra el dato a la derecha sólo cuando está cerrada.

stepper — asistente por pasos. stepper_view(StepperSpec{pasos: &[Paso], actual, cuerpo, on_ir, on_finalizar, palette}); Paso::{pendiente, actual, hecho, con_error}. La regla de navegación es pura: puede_ir_a(pasos, actual, destino) —atrás siempre, adelante sólo si el actual está Hecho, de un salto sólo si todos los intermedios lo están—, más siguiente/anterior/ es_ultimo/hechos. En el último paso el botón dice Finalizar y emite otro Msg. Los botones del pie y los pasos inalcanzables se apagan con aria_disabled, no desaparecen ni quedan mudos.

form — validación de formularios sobre el field. Tres piezas, y la tercera es la que se hace mal en todos lados:

Formulario::new(vec![                              // qué se pide
  CampoDef::new(Id::Correo, "Correo").reglas(vec![Regla::Requerido, Regla::Correo]),
  CampoDef::new(Id::Edad, "Edad").regla(Regla::Rango(18.0, 120.0)).ayuda("…"),
])
form.validar(&[(Id::Correo, &m.correo), …]) -> Errores<Id>   // qué está mal: TODOS
estado.tocar(id) · estado.enviar() · estado.visibles(&errores)  // cuándo se dice
campo_view(&form, id, control, &visibles, &pal)    // etiqueta + asterisco + error
resumen_errores_view(&visibles, Msg::IrA, &pal) -> Option<View>

Regla::{Requerido, LargoMin, LargoMax, Numero, Rango, Correo, Propia(fn)}. Un valor vacío pasa todas menos Requerido (si no, un campo opcional en blanco se queja de formato). El error se muestra al salir del campo o al intentar enviar, nunca mientras se tipea por primera vez; tras el submit se muestran todos, porque esconder alguno sería mentir sobre por qué no se envió. campo_view saca del formulario la etiqueta, el asterisco de obligatorio (derivado de las reglas, no de un flag aparte) y el error: los tres dejan de escribirse dos veces. resumen_errores_view devuelve None si no hay nada que decir, y cada ítem es clickeable para ir al campo — en un formulario largo el error puede quedar fuera de pantalla y sólo se ve que el botón no hizo nada.

datetime-picker — pickers de fecha y hora, los dos con la misma forma: un *_trigger en el formulario (muestra el valor formateado o el placeholder, emite Msg al click) y un *_popup que el host monta en App::view_overlay con scrim que cierra al click-fuera. Sin estado propio: el valor, si está abierto y el mes en foco viven en el Model.

date_picker_trigger(DateTrigger{valor, formato, placeholder, enabled, on_open, palette})
date_picker_popup(DatePopup{anchor, viewport, valor, view:(año,mes), today,
    week_start, opciones: CalendarOpciones, on_pick, on_view_change, on_dismiss,
    on_clear, acciones: Acciones::hoy_y_limpiar(), palette, theme})
time_picker_trigger(TimeTrigger{..}) · time_picker_popup(TimePopup{.., paso_minutos})
formatear_fecha(d, FormatoFecha::{Iso,DiaMesAnio,Largo}) · parsear_fecha(&str)
Hora{h,m}: formatear/parsear (FormatoHora::{H24,H12}) · mover(campo,±1,paso) · al_paso

El de fecha compone el calendar, así que hereda límites min/max, rango, salto de año y mapa de teclas — no reimplementa la grilla. El de hora es grilla de horas + minutos por paso: elegir la hora conserva el minuto y viceversa. parsear_fecha acepta ISO y día-primero con -, / o ., y rechaza el año de dos dígitos (adivinar en una fecha es peor que devolver None). Hora resuelve el caso que todos yerran: medianoche es 12 AM y mediodía 12 PM, no 0.

banner — tira de status. banner_view(BannerKind::{Info,Success,Warning,Error}, message).

Contenedores y layout

panel — chrome (gradiente + hairline accent). panel_view(children, PanelStyle); PanelStyle::{from_theme, from_theme_large, neutral}. panel_signature_painter(style) para reusar el look en un paint_with.

card — card_view(children, CardOptions { accent, padding, gap, radius, signature }, &CardPalette).

stat-card — métrica de dashboard. stat_card_view(label, value, description, accent, &recent_items, &palette).

tabs — tabs_view(TabsSpec { labels, active: usize, on_select: Fn(usize)->Msg, content: View<Msg>, tab_height, palette, tab_width }). Selección la maneja la app. tabs_view_sliding(spec, indicator_t) cambia el acento por-tab (que se prende de golpe) por un realce único que se desliza hasta indicator_t — el índice animado que el host tween-ea con llimphi-motion hacia el activo. Como cada tab mide lo que dice su label, interpola posición y ancho a la vez (indicator_bounds_px(&anchos, t); los anchos con tab_widths(&labels, tab_width, closable), la misma cuenta que el render y que strip_width), así se estira al ir hacia un tab más largo. Con overflow viaja dentro de la tira, o sea que el strip_offset lo arrastra solo. indicator_t = active as f32 da la posición estática, para un host que no quiera animar.

router — navegación por pila de rutas con transiciones de página (el Navigator de Flutter, a la Elm). La pila vive en el Model; el widget pinta sólo la ruta actual y anima el cambio con la infra de ghosts del compositor (animated_switch_styled). Push entra desde la derecha y lo viejo retrocede 30 % (shared-axis X); pop lo inverso; Zoom = fade-through; Cover = push opaco estilo UIKit (la nueva tapa sin fade; el pop desliza la vieja por encima revelando — scene-split de fantasmas: el saliente se pinta DEBAJO del entrante, en su posición z real); None = corte seco sin captura. Los hero cruzan rutas gratis (misma key en ambas páginas).

// Model: nav: RouteStack<Ruta>   (enum propio: Clone + PartialEq + Hash)
m.nav.push(Ruta::Detalle(7));  m.nav.pop();  m.nav.replace(r);  m.nav.pop_to_root();
router_view(key, &m.nav, PageTransition::{None,Fade,Slide,Zoom,Cover}, dur,
    page: Fn(&Ruta) -> View<Msg>)          // mide su slot vía LayoutBuilder
router_view_sized(key, &m.nav, tr, dur, (w, h), page)  // slot explícito — para
    // páginas que anidan otro layout_builder (límite v1 del seam: sin anidar)

Demo: cargo run -p llimphi-widget-router --example router_demo --release (hero volando entre Lista y Detalle; T cicla la transición, Esc vuelve).

hero — shared-element transition (el Hero de Flutter auténtico): un nodo con la misma key que aparece en otro rect en un frame posterior vuela del rect viejo al nuevo (el HeroRegistry del runtime interpola transform). hero_view(key, child) (480 ms) / hero_quick(key, child) (320 ms), o el primitive View::hero(key, dur) directo. Key estable y única entre los heroes de un mismo frame.

splitter — divisor draggable de 2 panes. splitter_two(Direction::{Row,Column}, a, a_size, b, b_size, on_resize: Fn(DragPhase, delta)->Option<Msg>, &palette). PaneSize::{Fixed(px), Flex}. La app acumula el delta en su Model (con apply_resize(actual, delta, min, max), que clampea por paso). splitter_two_ext(…, SplitterOpciones { on_toggle, focus_id }) agrega el doble-click sobre el divisor y lo hace enfocable, que es la puerta del resize por teclado: con el foco puesto, el host traduce la tecla con ajuste_desde_tecla(&ev, direction) -> Option<AjusteSplitter> (las flechas del eje del divisor —←/→ en Row, ↑/↓ en Column—, Shift para el paso grande, Home/End a los topes, Enter/Espacio para alternar) y la aplica con aplicar_ajuste. El colapso lo resuelve alternar_colapso(actual, recordado, por_defecto) -> (nuevo, a_recordar): guarda cuánto medía, no que estaba abierto, así restaurar devuelve el layout del usuario y no un ancho de fábrica.

scroll — área de scroll vertical con barra arrastrable. scroll_y(offset, content_len, viewport_len, content, on_scroll: Fn(delta_px)->Msg, &palette). Stateless (offset en el Model); rueda autocontenida. Helpers: clamp_offset, ensure_visible (selección a la vista), approach (scroll suave). Ver §8. Para darle scroll al cuerpo de un widget de filas está la pieza compartida —la usan list, tree y detail-table, y por eso vive acá y no copiada tres veces—: cuerpo_scrolleable(contenido, contenido_len, DesplazamientoV{offset, viewport_len, on_scroll}, medidor: Option<AltoVisible>, &palette). AltoVisible publica el alto medido desde el pintado (el alto disponible no existe hasta que taffy repartió) y offset_efectivo(offset, viewport_len) pinta desde arriba mientras no haya medida — clamp_offset con viewport 0 da por bueno cualquier offset, así que sin esto una lista re-montada con offset guardado arranca desplazada y salta al cuadro siguiente.

lazy-list — lista perezosa llave-en-mano (Bloque 22 de PLAN-NUCLEO-RETENIDO.md). A diferencia de list/grid (el caller windowea a mano) y de scroll_y (recibe el contenido ya construido), recibe un builder item: Fn(usize) -> View + total y sólo llama item(i) para la ventana visible + overscan — las filas fuera de pantalla no se construyen, no se montan, no se layoutean (costo/frame ∝ lo visible, no ∝ el total). Sin estado (offset en el Model); reusa la barra/física de scroll.

lazy_list_fixed(total, item_h, offset, viewport_h, overscan,
    item: Fn(usize)->View, on_scroll: Fn(f32)->Msg, &ScrollPalette)   // extent uniforme, ventana O(1)
lazy_list_varied(offsets: &[f32] /*len total+1, tops acumulados*/, offset, viewport_h,
    overscan, item, on_scroll, &palette)                             // extent por fila, O(log n)
// extent CALCULABLE barato (el sabor a preferir para listas grandes de alto variado):
IndiceExtent::nuevo(total, extent: Fn(usize)->f32)   // índice por bloques de BLOQUE_EXTENT (1024)
lazy_list_extent(&indice, extent, offset, viewport_h, overscan, item, on_scroll, &palette)
// guarda 1 float cada 1024 filas en vez de 1 por fila (1M filas: 978 floats ≈ 3,9 KB
// contra 4 MB) y NUNCA construye un View para medir. Consulta = binaria + ≤1 bloque.
// helpers puros: visible_window(total, item_h, offset, vp, overscan) -> Window{first,count,y0}
//               visible_window_varied(offsets, offset, vp, overscan), content_height(total, item_h)
//               visible_window_extent(&indice, &extent, offset, vp, overscan)
//               IndiceExtent::{y, indice_en, alto_total, len_indice}
// altura MEDIDA (listas heterogéneas cuyo alto depende del contenido/reflow):
item_offsets(total, ancho, item: Fn(usize)->View) -> Vec<f32>   // mide cada ítem → offsets acumulados
// el caller lo llama al cambiar contenido/ancho, cachea el Vec, y por frame usa
// lazy_list_varied(&offsets, ...). Usa llimphi_compositor::measured_height(view, ancho).
// SECCIONES con encabezado sticky llave-en-mano (agenda/contactos):
lazy_list_sections(rows_por_seccion: &[usize], item_h, header_h, offset, viewport_h,
    overscan, header: Fn(s)->View, item: Fn(s, fila_local)->View, on_scroll, &palette)
// el header de la sección activa queda PINEADO al tope y la siguiente lo empuja;
// darle fill opaco (pinta encima de las filas). Ventana O(#secciones + visibles).
// puros: section_tops, sections_window -> Vec<SectionUnit>, sticky_section
// re-exporta de scroll: clamp_offset, fling_step, rubber_band, sticky_y, DEFAULT_OVERSCAN, ScrollPalette

Demo: cargo run -p llimphi-widget-lazy-list --example stress_lazy --release -- --nodes 1000000.

tiled — grilla auto cols×rows de tiles con title bar. tiled_view(tiles, &palette), tiled_view_cols(tiles, cols, &palette), y variantes *_reorderable* con on_reorder: Fn(from, to)->Option<Msg> (drag-to-swap por la title bar). TileSpec { label, content }.

panes — árbol binario BSP tipo tmux. La app guarda un Layout:

Layout::single(id) / Layout::Split { axis: Axis, ratio, first, second }
layout.split(target, new, axis) / .without(target) / .resize(&path, delta) / .leaves()
panes_view(&layout, focused: PaneId, leaf: FnMut(PaneId)->View, on_resize: Fn(Vec<Side>,DragPhase,delta)->Option<Msg>,
           on_focus: Fn(PaneId)->Msg, &palette)

dock-rail — rail vertical de dientes para sidebars acoplables. Cada diente es una pestaña: barra de acento de 3px pegada al borde interno (encendida cuando está activo) + icono centrado. Apilados en una franja flotante que sobresale del panel hacia el centro — el patrón canónico de cosmos (01_yachay/cosmos/cosmos-app-llimphi/src/chrome.rs: dock_rail_overlay / dock_panel_for). Cada item se identifica por un u64 opaco; el caller mantiene el orden, qué está activo, y el mapping u64 ↔ DominioPropio (típicamente vía enum::{to_u64,from_u64}). Cada diente es arrastrable (su id como payload) y el rail entero es drop target → mover un diente de un sidebar a otro soltándolo sobre el rail opuesto. Render-only, agnóstico del Msg.

DockRailItem { id: u64, active: bool }
dock_rail_view(&items, width: f32, &DockRailPalette,
    make_icon: Fn(id, size_px, color) -> View<Msg>,
    on_activate: Fn(id) -> Msg,                  // dispara en el press, no pelea con el drag
    on_drop:     Fn(payload_id) -> Option<Msg>)  // None = el rail no acepta drops

Layout canónico: el rail va como overlay absoluto pegado al borde interno del centro (los dientes sobresalen sobre la rueda/canvas), y el panel del item activo va al costado como un pane en el área resizable. NO es una lista con rótulo: leer cosmos antes de inventar UX (lección 2026-06-05).

fugaces — la banda de controles fantasma: más acciones y estados que píxeles, resueltos por voz en vez de por espacio. No pinta íconos ni sabe qué representan — es la política, y el caller pone la identidad (trait Fantasma: una clave estable para persistir el uso y un prior de asiento) y el dibujo de cada cara.

Cada fantasma declara por cuadro su [Nivel]: Nada (sólo en el reveal — acciones puras) · Ambiente (mirable, turno lento y al 55 % de alfa) · Leve (estado que dura; se turna cada 4 s) · Evento (acuse de segundos: no espera turno) · Critica (sale fija, y todas juntas). En reposo se ve una sola cosa; al acercar el puntero salen todas.

Las tres reglas que no son obvias, y que ya costaron reportes: el orden se congela bajo el mouse (un click aprende, pero el asiento nuevo rige recién al esfumarse el reveal); la membresía también (el que llega a mitad del hover no se inserta, y el que se va deja un hueco de su ancho); y el asiento sale de prior + 2·√usos, que satura — los primeros clicks mandan y después el orden queda quieto, que es lo que permite memorizarlo.

Dos consumidores, dos formas del mismo patrón: pata lo monta como overlay a la derecha del cabezal de shuma (estados del sistema), y nahual-shell como banda al borde derecho de la barra de herramientas (nahual-shell-llimphi::fugaces), donde la acción aparece cuando el contexto la vuelve posible — marcás dos archivos y sale cotejar.

trait Fantasma { fn clave(self) -> &'static str; fn prior(self) -> f32; }
elegir_visibles(&[(F, Nivel)], idx, pin) -> Vec<F>   // qué se ve sin reveal
avanzar_idx(&mut idx, &mut reloj, ahora_us, congelar) -> bool
orden_asientos(&todos, usos: Fn(F) -> u32) -> Vec<F>
congelar(&todos, &cands, usos, idx, pin, ahora_us) -> Freeze<F>
slots(cands: Vec<(F, Nivel, View<Msg>)>, &Franja<F>, usos, ancho_slot) -> Vec<View<Msg>>
Uso::abrir(Option<PathBuf>) / .bump(f) / .usos(f)    // feature `persistencia`

El contenedor es del caller: slots devuelve la fila lista, no una View — la franja caliente, el ancla y el hover_fill los pone quien la hospeda.

toolbar — barra de herramientas moderna: grupos de botones-ícono planos (hover redondeado; activo = fondo de selección + ícono en acento; deshabilitado atenuado y sin click) con separadores sutiles entre grupos. Los íconos los dibuja el caller vía closure (mismo contrato que dock-rail); los grupos son datos (Vec<ToolbarGroup>) → la barra es componible y configurable por construcción. Uso canónico: nahual-shell-llimphi::shell_toolbar (navegación + modos de vista + acciones).

ToolbarItem::new(icon: Fn(size_px, color) -> View<Msg>, on_click: Msg)
    .with_label("subir").active(bool).enabled(bool)
ToolbarGroup::new(vec![items...])
toolbar_view(groups: Vec<ToolbarGroup<Msg>>, height: f32, &ToolbarPalette) -> View<Msg>
// Overflow + tooltips (aditivo; Default = la barra de siempre):
let medidor = AnchoBarra::new();                    // se lee el cuadro siguiente
let anchos  = item_widths(&groups);                 // misma cuenta que el render
let visibles = overflow_count(&anchos, medidor.px(), MORE_W, ITEM_GAP);
let ocultos  = items_ocultos(&groups, visibles);    // para armar el popup
toolbar_view_ext(groups, 36.0, &pal, ToolbarOpciones {
    medidor: Some(medidor), visibles: Some(visibles),
    on_overflow: Some(Msg::AbrirMas),               // dibuja «⋯» sólo si sobra algo
    on_hover: Some(Arc::new(|i| Msg::Tooltip(i))),  // Some(i) al entrar, None al salir
})

El ancho disponible no se sabe hasta que taffy repartió, así que AnchoBarra lo publica desde el pintado y el host lo usa en el cuadro siguiente (un frame de latencia en el primero y en el resize — lo mismo que hace cualquier toolbar con overflow). item_offsets(&groups) da (left, width) de cada ítem contando padding, gaps y separadores: es el ancla del tooltip (que es render puro y lo monta el host en su view_overlay). El menú de overflow también lo monta el host, con context_menu_view; items_ocultos le da label/estado/Msg en orden para que el índice del on_pick no se desincronice.

grid — grilla 2D virtualizada. ventana_visible(total, vp_w, vp_h, scroll_fila, &metrics) -> VisibleWindow para virtualizar, luego grid_view(GridSpec { cells: Vec<GridCell { content, label, selected, on_click }>, cols, metrics, caption, ... }).

list — lista vertical virtualizada. list_view(ListSpec { rows: Vec<ListRow { label, selected, on_click }>, total, caption, truncated_hint, row_height, palette }). La app prefiltra las filas visibles. list_view_ext(spec, ListOpciones{scroll, medidor}) le da scroll real (no sólo clip): las filas se desplazan y tienen barra arrastrable, mientras el caption y el aviso de truncado quedan fijos — son rótulos de la lista, no contenido de ella.

tree — árbol expand/collapse. tree_view(TreeSpec { rows: Vec<TreeRow { label, depth, has_children, expanded, selected, on_toggle, on_select }>, row_height, indent_px, palette }). La app aplana el árbol según nodos expandidos. tree_view_ext(spec, TreeOpciones{scroll, medidor}) le da el mismo scroll real que la list. Cada fila acepta además icon (un View), color_label (teñir por tipo/estado), trailing, on_context y on_double. tree_view_2d(spec, Vista2D{offset, viewport, ancho_contenido, on_scroll, medidor, fijadas}) agrega lo que un árbol de proyecto necesita y una lista indentada no: rueda en los dos ejes (un nombre anidado no entra en un panel angosto y el … no lo hace legible) y filas fijadas — FilaFijada{fila, y_natural, huerfana} se apila contra el borde por el que salió del viewport (reparto_fijadas, cupo_de_pila = un tercio del alto), y una huerfana (su carpeta está plegada, no tiene fila en el cuerpo) se muestra igual. El ancho del contenido se estima con ancho_de_filas. El uso canónico es nada::arbol, que es el mismo árbol para el editor suelto y para el panel del anfitrión.

navigator — navegador data-agnóstico de nodos en dos modos conmutables (árbol ↔ grafo, reusa tree + nodegraph). Render-only: la app guarda expanded/selected/mode. Pasa un bosque de NavNode { id: u64, label, kind: NavKind (Monad|Group|Dir|File|Other), children } y callbacks por id.

navigator_view(NavSpec { roots, mode: NavMode::{Tree,Graph}, selected, palette, guides },
    is_expanded: Fn(u64)->bool, on_toggle: Fn(u64)->Msg,
    on_select: Fn(u64)->Msg, on_context: Option<Fn(u64)->Msg>)
// árbol: click selecciona, chevron expande, icono por kind. grafo: cables de
// contención padre→hijo, arrastrar selecciona, right-click abre. Pensado para
// el sidebar de Mónadas/archivos de pata, pero no sabe de nouser.

app-header — app_header(label, actions: Vec<View<Msg>>, &palette).

status-bar — status_bar_view(left, center, right, &palette) con StatusSegment::text(..).with_icon(Icon).clickable(Msg).emphasized().

breadcrumb — breadcrumb_view(&[&str], make_msg: Fn(usize)->Msg, &palette) (el último segmento no es clickable).

modal — diálogo centrado con scrim. modal_view(ModalSpec { title, body: View<Msg>, buttons: Vec<ModalButton>, size, viewport, on_dismiss, palette }). ModalButton::{primary, cancel, destructive}(label, msg). Se monta en view_overlay.

toast — notificaciones efímeras bottom-right. La app guarda Vec<Toast> (Toast::{info,success,warning,error}(id, text, duration)), filtra is_alive(now), y toast_stack_view(&toasts, viewport, make_dismiss: Fn(u64)->Msg).

splash — splash de arranque (cuatro cuadrantes andinos). splash_view(started_at: Instant, bg, fg_text); basado en tiempo, requiere redraws.

Ricos / interactivos

nodegraph — lienzo de nodos + cables Bezier. Sin estado (la app guarda posiciones y Wires).

NodeSpec { id: NodeId(u32), label, x, y, inputs: Vec<String>, outputs: Vec<String> }
Wire { from_node, from_output: PinIdx(u16), to_node, to_input }
nodegraph_view(&nodes, &wires, &palette, &metrics,
    on_drag_node: Fn(NodeId, DragPhase, dx, dy)->Option<Msg>,
    on_connect:   Fn(NodeId, PinIdx, NodeId, PinIdx)->Option<Msg>)
// + nodegraph_view_ex (right-click) y nodegraph_view_styled (tints por nodo/cable)
// + wire_dash / edge_dash en NodegraphSpec: predicado por cable/arista → trazo
//   PUNTEADO. Para la distinción que el color no lleva solo (lo sabido vs lo
//   supuesto): soltar pinta punteada la clave foránea que sólo se ve en el dato
//   y sólida la que una persona declaró.
// + nodegraph_view_spec(NodegraphSpec{..}) — el editor real: cámara (pan/zoom),
//   selección, aristas sin pins, y CUERPO EMBEBIDO por nodo.

Un nodo puede contener algo (2026-08-17). NodegraphSpec.cuerpo es Option<&dyn Fn(NodeId, zoom) -> Option<CuerpoNodo<Msg>>>, y CuerpoNodo es { alto: f32, vista: View<Msg> } — el alto en coordenadas de canvas, que el widget escala por el zoom. El cuerpo se dibuja debajo de la fila de pins, así que las posiciones de los pins no se mueven y los cables siguen enganchando donde precompute_wires los calcula. None = el nodo clásico.

Para qué: krama pinta adentro del nodo el valor con su unidad, la perilla que se arrastra y la curva de pineal-cartesian; tullpu podría poner el preview del ajuste, takiy la onda de su eslabón, pluma-notebook el output de la celda. No va en NodeSpec (que es Debug + Clone y no genérico en Msg): una View adentro lo volvería genérico y rompería sus 12 sitios de construcción.

Dos cosas medidas que conviene saber: fit_to_view no conoce los cuerpos (encuadra sobre la altura de pins, así que queda corto por abajo con nodos altos), y node_height suma 6 px fijos que scaled() no escala — el nodo entero no se duplica exactamente al duplicar el zoom.

timeline — scrub clickeable. timeline_view(progress: f32, &palette, on_seek: Fn(f32 [0..1])->Option<Msg>).

transport — botones de transporte de reproductor (play/pause/prev/ next/seek/volume/mute/repeat/shuffle/speed/snapshot/record/eq). Stateless: el caller arma un TransportButton por cada botón con el flag que define su estado activo (p.ej. PlayPause { playing }, Mute { muted }, Record { recording }), lo pasa al widget, y recibe el TransportAction semántico en un closure cuando el usuario clickea. El widget elige el icono (de llimphi-icons), el color activo, el rojo de REC y arma la acción (p.ej. SeekBack { secs } → TransportAction::SeekBy(-secs)). Quien traduce TransportAction al comando del dominio (MediaCommand, etc.) es la app.

TransportPalette { bg, bg_active, bg_hover, fg, fg_active, fg_record,
                   btn_w, btn_h, radius, icon_stroke, gap }
TransportPalette::from_theme(&Theme)
TransportAction::{TogglePlay, Stop, Prev, Next, SeekBy(i64), VolumeBy(f32),
                  ToggleMute, CycleRepeat, ToggleShuffle, SpeedStep(i32),
                  SpeedReset, Snapshot, ToggleRecord, ToggleEqualizer}
TransportButton::{PlayPause{playing}, Stop, Prev, Next,
                  SeekBack{secs}, SeekForward{secs},
                  VolumeDown{step}, VolumeUp{step},
                  Mute{muted}, Repeat{active}, Shuffle{active},
                  SpeedDown, SpeedUp, SpeedReset{is_default},
                  Snapshot, Record{recording}, Equalizer{enabled}}
transport_button_view(button, &palette, on_action: Fn(TransportAction)->Msg) -> View<Msg>

Para una fila, el caller mapea su Vec<TransportButton> con .iter().map(|b| transport_button_view(*b, &pal, on_action.clone())) y los pone como children de una View con flex_direction: Row y gap: palette.gap. Consumidor de referencia: 02_ruway/media/media-app::bar_item_view.

waveform — visor de forma de onda en vivo. Stateless y agnóstico de cpal/AudioProbe: el caller pasa un closure Fn(&mut Vec<f32>) -> u16 que rellena un buffer con los últimos samples intercalados y devuelve cuántos canales trae; el widget hace el fold a mono y dibuja un envelope min/max por columna (polígono cerrado con relleno tenue + stroke top/bot) sobre una línea central que siempre está como "ground" — paint-only, sin mouse. Si el closure devuelve 0 canales, el widget pinta sólo la línea central ("visor vivo, sin señal").

WaveformPalette { bg, center, stroke, fill, radius, pad_x, pad_y, stroke_w }
WaveformPalette::from_theme(&Theme)  // bg=bg_panel_alt, stroke=accent, fill=accent@α70
waveform_view(source: Fn(&mut Vec<f32>) -> u16 + Send + Sync + 'static,
              palette: &WaveformPalette) -> View<Msg>

Consumidor de referencia: 02_ruway/media/media-app::waveform_panel (cablea un AudioProbe::snapshot).

table — tabla editable de celdas-texto: filas/columnas + agregar/quitar fila + foco por celda. Stateless — la app posee el foco y el buffer de la celda en edición; el resto se pinta desde &rows. Pensado para datos planos editables (paramétricos, scratchpad, listas curables); no es la list/tree virtualizada read-only ni un grid de hoja de cálculo.

table_view(&[String], headers,                          // [] = sin header
    &[Vec<String>] rows,
    focused: Option<(row, col)>,
    focused_state: Option<&TextInputState>,             // buffer prestado de la celda en edición
    add_label: &str, &TablePalette,
    on_focus_cell: Fn(row, col) -> Msg,                 // clic en celda
    on_remove_row: Fn(row) -> Msg,
    on_add_row:    Fn() -> Msg)
list_view(&[String], items, focused_row, focused_state, // sola columna sin header
    add_label, &palette, on_focus_cell: Fn(row)->Msg, on_remove_row, on_add_row)
table_height(n_rows, has_header) -> f32                 // pre-calcular alto del bloque

La variante list_view no se confunde con la list virtualizada de §13: ésta es una sola columna editable que delega en table_view([], ..).

detail-table — la vista detalle de un file manager: grilla read-only con columnas ColWidth::{Flex(peso), Fixed(px)} y encabezados que ordenan (▲/▼). detail_table_view(DetailSpec{columns, rows, sort, row_height, caption, palette}, on_sort); detail_table_view_dnd(..) agrega arrastre por fila. detail_table_view_ext(spec, on_sort, DetailOpciones{scroll, medidor, on_resize}) suma encabezado fijo con cuerpo scrolleable y los handles de resize entre columnas:

DetailScroll { offset, viewport_h, on_scroll: Fn(f32)->Msg }   // compone scroll_y
AltoCuerpo::new()          // publica el alto medido del cuerpo (post-layout)
offset_efectivo(offset, viewport_h)  // sin medir todavía ⇒ se pinta desde arriba
resize_col(ancho, delta, min)        // el host aplica el delta del handle

El «sticky» no es un truco de posición: con scroll el encabezado queda afuera del cuerpo desplazado, así que no viaja. El cuerpo se compone del scroll canónico (rueda, barra, clamp y fling ya viven ahí). El handle lleva draggable y no on_click, así agarrarlo no dispara el orden de la columna que tiene debajo — que es el bug clásico de esta UI.

Es también la DataTable read-only ordenable y paginable — no hay una tabla aparte para eso. Lo que faltaba y ya está:

ciclar_orden(sort_actual, col) -> Option<(col, SortDir)>   // asc → desc → SIN orden
orden_de_filas(&celdas, col, dir, &TipoCol) -> Vec<usize>  // permutación estable
TipoCol::{Texto, Numero, Natural, Propio(fn)}              // «archivo2» < «archivo10»
Pagina{indice, tamano}: rango(total) · total_paginas · acotada(total) · etiqueta
botones_pagina(actual, total, max) -> Vec<BotonPagina>     // 1 … 4 5 6 … 20
pager_view(pagina, total_filas, Fn(usize)->Msg, &palette)

orden_de_filas devuelve índices, no filas movidas: así ordena sin conocer el tipo del caller (una DetailRow lleva Msg y View) y sin perder el orden original, que es al que vuelve el tercer click. Numero manda al final lo que no parsea (un «—» encabezando no es información) y no hay variante Fecha: en ISO ordena bien como Texto, y para otros formatos está Propio — un parser de fechas acá sería un segundo parser distinto del del datetime-picker.

text-editor — editor IDE (capa visual sobre el core agnóstico). La app guarda EditorState:

EditorState::new(); .text(); .set_text(s); .has_selection(); .can_undo()/.can_redo();
.add_cursor_at(line,col);  .apply_key_with_clipboard(&KeyEvent, &mut dyn Clipboard) -> ApplyResult;
.ensure_caret_visible(visible_lines)
// nota: `metrics` se pasa POR VALOR; el callback es on_pointer: Fn(PointerEvent)->Option<Msg>
text_editor_view(&state, &EditorPalette, metrics: EditorMetrics, visible_lines: usize, on_pointer)
text_editor_view_highlighted(&state, &palette, metrics, visible_lines, language: Language, on_pointer)
text_editor_view_full(&state, &palette, metrics, visible_lines, language, match_ranges: &[(usize,usize)], on_pointer)
syntax_palette_dark(&theme) -> SyntaxPalette   // en lib.rs del widget

text-editor-core — núcleo agnóstico (sin GPU, sin Llimphi; sólo peniko::Color). Reutilizable en TUI/web/headless. Tipos clave:

text-editor-lsp — cliente LSP por stdin/stdout. trait LspClient (fire-and-forget request_* + lecturas de caché latest_*/clear_*): completions, hover, definition, references, rename, formatting, signature help, document symbols. RustAnalyzerClient::start(workspace_root); NoopLspClient para tests.

terminal — superficie de scrollback infinita virtualizada (modo línea / bloques / grilla GPU). Reemplaza al output_pane clásico: el costo de render es constante a scrollback ilimitado — sólo se materializa la ventana visible — bajo un scroll_y propio del widget (no transform del padre, la anti-feature del SDD). Núcleo agnóstico, el caller arma el modelo y el widget pinta (Regla 2). Diseño completo: 02_ruway/shuma/SDD-TERMINAL.md. Consumidor de referencia: shuma-module-shell::output_pane_surface.

Headless dumps:

cargo run -p llimphi-widget-terminal --example dump_terminal   # modo línea, 1M renglones
cargo run -p llimphi-widget-terminal --example dump_blocks     # bloques + flood 500k

clipboard — portapapeles del sistema vía arboard. SystemClipboard::new(), is_available(), impl Clipboard. No-op silencioso si no hay display (CI headless).

menubar — barra de menú mac-style. menubar_view(&MenuBarSpec { menu: &AppMenu, open: Option<usize>, theme, viewport, height, on_open: Fn(Option<usize>)->Msg, on_command: Fn(&str)->Msg }); dropdown en view_overlay con menubar_overlay(spec) o menubar_overlay_animated(spec, active, appear). Navegación por teclado: menubar_nav, menubar_command_at.

edit-menu — menú estándar de edición sobre un editor. EditFlags::from_editor(&state, masked), edit_context_menu(anchor, viewport, &theme, flags, on_action: Fn(EditAction)->Msg, on_dismiss) → ContextMenuSpec. apply(&mut state, EditAction, &mut clipboard) -> ApplyResult. EditAction::{Undo,Redo,Cut,Copy,Paste,Delete,SelectAll}.

context-menu — menú contextual genérico (panel elevado con sombra + borde + esquinas redondeadas). ContextMenuItem::action(label).with_shortcut(..).icon(..) .checked(bool).disabled().destructive().submenu(children) o ::separator() (checked pinta ✓ en el gutter y alterna estado). context_menu_view(ContextMenuSpec { anchor, viewport, header, items, active, on_pick: Fn(usize)->Msg, on_dismiss, palette }); context_menu_view_ex con submenús (flyout que flipea a la izq. si no cabe) + animación de aparición. Se monta en view_overlay con scrim. Teclado (helpers puros que el host cablea): step_active(items, cur, ±1) (↑/↓, saltea sep/disabled), edge_active(items, to_end) (Inicio/Fin), type_ahead(items, cur, ch) (mnemónico por 1ª letra). Submenú por teclado = step_active sobre parent.children. Demo completo: cargo run -p llimphi-widget-context-menu --example menu_demo.

theme-switcher — theme_switcher_view(&current: &Theme, on_change: Fn(Theme)->Msg) (+ _styled/_flex). Cicla Theme::next_after.

shortcuts-help — overlay "?" con atajos agrupados. shortcuts_help_view( ShortcutsHelpSpec { title, groups: Vec<ShortcutGroup { title, entries: Vec<ShortcutEntry { keys, description }> }>, viewport, on_dismiss, palette }).

wawa-mark — sello vectorial del SO wawa. wawa_mark_view(&WawaMarkPalette); paint_mark(scene, rect, &palette) para canvas custom. Usar en contenedor cuadrado.


14. Catálogo de módulos

Los módulos encapsulan estado + comportamiento (a diferencia de los widgets). Todos siguen el mismo contrato:

State  +  Msg  +  Action  +  apply(state, msg, ...) -> Action
                            +  on_key(state, &KeyEvent) -> Option<Msg>
                            +  open_shortcut(&KeyEvent) -> bool
                            +  view(state, ..., to_host: F) -> View<HostMsg>
                            +  Palette

La app guarda Option<ModuleState> (o el state directo, p. ej. bookmarks), rutea el atajo de apertura con open_shortcut, rutea teclas con on_key, aplica Msgs con apply, y monta el view pasando un mapeo to_host: Fn(ModuleMsg) -> HostMsg. Cuando apply devuelve una Action (p. ej. Invoke(id), OpenAt{..}, GoTo{..}), la app ejecuta el efecto. Crates en modules/<nombre>/.

MóduloAtajoAcción que devuelvePropósito
command-paletteCtrl+Shift+PInvoke(String)paleta de comandos fuzzy. El host declara &[Command]
file-pickerCtrl+POpen(PathBuf)fuzzy file picker; host pasa &[PathBuf] + root
fif (find-in-files)Ctrl+Shift+FOpenAt{path,line,col}, Searched{..}, Replaced{..}buscar/reemplazar; dual-view (dialog + barra). search() / replace_all() hacen el I/O
diff-viewerCtrl+Shift+D—diff side-by-side. DiffState::new(before_label, after_label, before, after) computa con similar
mini-mapCtrl+Shift+MJumpTo(line)minimapa del buffer; agnóstico del editor (recibe Snapshot)
bookmarksCtrl+Alt+B toggle, Ctrl+Shift+B lista, Ctrl+Alt+N/P navJumpTo{path,line}marcadores per-file persistentes (state directo, no Option)
symbol-outlineCtrl+Shift+OGoTo{line,col}outline de símbolos; host arma Vec<SymbolItem> (LSP/tree-sitter/custom)
selector——abstracción portátil abrir/guardar: trait Selector (HostSelector con PathBuf, WawaSelector placeholder content-addressed)
plugin-host—OpenAt{..}, SetStatus(..)runtime WASM (wasmi) con permisos por bitfield; PluginHost::load_from_dir/invoke(id, cap, args)
shuma-termCtrl+`SetStatus(..)terminal integrada. spawn(cwd) lanza PTY (shuma_exec), vt100::Parser renderiza; Tick drena el PTY

Patrón típico de integración (command-palette):

struct Model { palette: Option<PaletteState>, commands: Vec<Command>, /* … */ }
enum Msg { Palette(PaletteMsg), /* … */ }

// on_key:
if command_palette::open_shortcut(ev) { return Some(Msg::Palette(PaletteMsg::Open)); }
if let Some(_) = &model.palette { return command_palette::on_key(p, ev).map(Msg::Palette); }

// update:
Msg::Palette(m) => {
    if let Some(state) = model.palette.as_mut() {
        match command_palette::apply(state, m, &model.commands) {
            PaletteAction::Invoke(id) => { /* ejecutar comando id */ model.palette = None; }
            PaletteAction::Close => model.palette = None,
            PaletteAction::None => {}
        }
    }
}

// view_overlay:
model.palette.as_ref().map(|s|
    command_palette::view(s, &model.commands, &palette, Msg::Palette))

15. llimphi-workspace — chasis tipo tmux

Monta cualquier componente en un layout intercambiable con splits resizables (máquina de estados de foco/split/cierre + chrome estándar). Construido sobre llimphi-widget-panes.

Workspace::new()
  .focused() -> PaneId         .count()      .leaves() -> Vec<PaneId>     .layout() -> &Layout
  .focus(id)  .split(Axis) -> PaneId   .close() -> Option<PaneId>   .resize(&path, delta)
  .apply(WsMsg) -> WsEffect

enum WsMsg { Focus(PaneId), Split(Axis), Close, Resize(Vec<Side>, f32) }
enum WsEffect { None, Created(PaneId), Closed(PaneId) }

workspace_view(&ws, &WorkspacePalette,
    leaf: FnMut(PaneId)->View<Host>,           // materializa el contenido de cada panel
    lift: Fn(WsMsg)->Host)                      // sube los Msg del chasis a tu Msg

Patrón: enum Msg { Ws(WsMsg), Panel(PaneId, PanelMsg) }. En update, ws.apply(msg) te avisa con WsEffect::{Created,Closed}(id) para que crees o destruyas el estado del panel correspondiente.


16. Reglas duras y gotchas

🔴 Cómputo pesado fuera del hilo de UI (PRIORIDAD URGENTE)

Ningún update/init/handler puede ejecutar trabajo síncrono pesado (efemérides, simulación, IO, parse, embeddings, layout de árboles grandes). Bloquea el hilo → "Not Responding". init corre dentro de resumed, después de crear la ventana, así que un cómputo pesado ahí ya congela una ventana visible.

Patrón (referencia: cosmos-app-llimphi):

// Model: Option<Resultado> (None = "calculando…") + flag dirty + contador de generación.
struct Model { x: Option<Resultado>, x_dirty: bool, x_gen: u64 }
enum Msg { XComputed(u64, Arc<Resultado>) }

// al FINAL de update() (que tiene el Handle):
if m.x_dirty {
    m.x_dirty = false;
    m.x_gen = m.x_gen.wrapping_add(1);
    let (gen, input) = (m.x_gen, m.input.clone());     // sólo lo que el worker necesita (Send)
    handle.spawn(move || Msg::XComputed(gen, Arc::new(compute(&input))));
}
// al recibir: aplicar SÓLO si la generación sigue vigente (evita que un
// resultado tardío pise a uno más nuevo en drags/toggles rápidos).
Msg::XComputed(gen, x) => if gen == m.x_gen {
    m.x = Some(Arc::try_unwrap(x).unwrap_or_else(|a| (*a).clone()));
}

La generación es imprescindible si el recálculo se dispara seguido. Ver COMPUTO-FUERA-DEL-HILO-UI.md y su checklist por app.

Otras


16.bis Tests de regresión visual — llimphi-test (golden-image)

Para certificar que un widget/pantalla no cambió de aspecto sin renderizar-y- mirar a mano (CLAUDE.md §8), usar el arnés llimphi-test. Renderiza un View headless por el adapter de software (llvmpipe/lavapipe → mismo píxel host-a- host y en CI) y lo diffea contra un golden con tolerancia perceptual.

// en tests/<algo>.rs de tu crate
use llimphi_test::assert_golden;

#[test]
fn mi_card() {
    let v = mi_widget::card(/* … */);
    assert_golden!("mi_card", v, 400, 240);   // vs tests/goldens/mi_card.png
}

El golden es el ÚLTIMO recurso, no el primero

Un golden es una imagen bendecida: pasa a ser correcta porque alguien dijo que lo era. Eso funciona para «esto no cambió», pero no contesta «¿esto está bien?», y para bendecirla hay que mirarla — justo lo que §8 pide evitar.

Casi siempre hay algo mejor: el oráculo es la otra manera de calcular lo mismo. En vez de comparar contra una imagen, se pinta el mismo árbol por dos caminos que tienen que coincidir, y la afirmación pasa a ser objetiva. Los que ya existen en el repo, todos escritos así:

testlos dos caminosresultado
boundary_cache.rspaint contra paint_cached0 px
gpu_lotes.rsun buffer entero contra troceadoidénticos
gpu_grosores.rsun lote con dos plumas contra dos lotes de unaidénticos
gpu_scissor.rspasada completa contra recortemismo píxel exacto
cpu_vs_gpu.rsvello_cpu (wawa) contra vello (Linux)tabla por patrón
wire_vs_directo.rspor el cable contra en procesobit a bit
hybrid_vs_cpu.rsvello_hybrid (Mali) contra CPUΔ1:0
texto_gpu_directo.rsatlas de glifos contra velloΔ64:0

Cuatro reglas que salieron de escribirlos

  1. Poné el umbral DESPUÉS de medir, no antes. Los umbrales «prudentes» que se eligen a ojo casi nunca se rozan, y un umbral que nada roza no es un gate. En hybrid_vs_cpu el 5 % sobre Δ16 que parecía razonable no habría agarrado el sabotaje; lo medido (0 px sobre Δ1) sí. Dejá el número medido escrito al lado del assert, con fecha: es lo que permite decidir el umbral siguiente.
  2. Saboteá el test antes de creerle. Rompé a mano lo que dice cubrir y mirá que falle ese test y no otro. Dos veces en esta tanda el test pasaba con la pieza rota: boundary_gradiente_imagen reconstruía el objeto con default() en vez de clonarlo, y cada default() hacía un Blob nuevo — o sea que invalidaba por identidad y nunca llegaba a probar el hash. Cuatro de cinco sabotajes pasaban.
  3. Todo test de igualdad necesita una guarda de arnés. Dos lienzos vacíos coinciden perfecto. Afirmá además que se pintó algo (pintados > total/8), que el caché acertó (hits > 0), que se ejecutaron las órdenes (ejecutadas > 0, salteadas == 0), que se compararon todos los casos (probados == patrones().len()). Sin eso, el test firma cualquier cosa.
  4. Recorré el camino difícil, no el fácil. texto_gpu_directo sube sólo el rect sucio y no el atlas entero, porque es lo que hace una app; y tiene un caso incremental aparte justamente porque en un atlas recién hecho lo sucio arranca en (0,0), donde un error de coordenada pasa desapercibido.

Cuándo sí un golden: cuando no hay segundo camino. gpu_golden.rs (starfield por GPU directo) es el caso — no existe otra forma de producir esa imagen, así que la referencia tiene que ser ella misma.

Correr la suite contra la GPU de verdad

LLIMPHI_TEST_GPU=1 cargo test --no-fail-fast -p llimphi-test -p llimphi-raster -p llimphi-hybrid

El arnés fuerza el adapter de software para que un golden valga lo mismo en cualquier máquina, y eso está bien — pero deja una pregunta sin contestar: ¿el hardware hace lo mismo? Con esa env se ignora la preferencia y se usa la GPU real. Es para preguntar a mano, no para cambiar la referencia.

Medido el 2026-08-05 (Iris Xe/Vulkan contra llvmpipe), y el resultado dice más que cualquier argumento sobre goldens:

softwareGPU real
tests en verde113112

El único que se cae es gpu_golden — el único que compara contra una imagen bendecida (2,74 % de píxeles, Δ máx 44). Los ocho oráculos pasan en las dos, y con casi el mismo número:

testsoftwareGPU real
texto_gpu_directoΔ16:417 Δ64:0 · tinta +0,31 %Δ16:423 Δ64:0 · tinta +0,29 %
hybrid_vs_cpuΔ0:236 Δ1:0Δ0:232 Δ1:0
boundary_cache / gpu_lotes / gpu_scissor0 px0 px

Un oráculo compara dos cosas que el mismo rasterizador produce, así que la diferencia de rasterizador se cancela. Una foto no puede cancelar nada. Por eso gpu_golden se saltea solo cuando se pide GPU: así el comando de arriba es una pregunta limpia y no una corrida con un fallo conocido de ruido.


16.ter Semántica accesible + tests por consulta

Un View describe cajas: posición, color, texto. No dice qué significan. Esa segunda capa se declara con View::role + View::aria_*, y paga dos veces:

  1. Lectores de pantalla, gratis. El runtime traduce el árbol montado a AccessKit en cada frame (llimphi-ui::a11y), y el adapter de Linux habla AT-SPI2 por DBus — el mismo bus que usa Orca. No hay que encender nada: accesskit_winit ya está cableado en el event loop, sin feature gate.
  2. Tests que preguntan por significado, no por píxeles (ver más abajo).

Declarar

View::new(style)
    .role(Role::Button)          // qué ES el nodo
    .aria_label("Guardar")       // cómo se llama (si no hay texto visible)
    .aria_value("70%")           // dato dinámico: valor de un slider, texto de un input
    .aria_description("…")       // contexto extra; el lector lo dice aparte
    .aria_pressed(activo)        // …y los flags: checked/pressed/expanded/
    .aria_disabled(!habilitado)  //    disabled/readonly/required

Dónde va el rol: en el nodo que lleva el on_click, no en el contenedor. AccessKit sólo ofrece Action::Click donde hay handler; un rol Button sobre una caja sin handler produce un botón que el lector no puede activar.

El nombre accesible es aria_label si está, si no el text visible. Por eso un nodo que ya pinta su texto no necesita label — pero si el texto vive en un hijo (ícono + etiqueta en una fila), el nodo con el rol queda sin nombre y hay que dárselo explícito. Es el error más común.

Roles disponibles (llimphi_ui::Role): Button TextInput MultilineTextInput Heading Checkbox Switch Label Link Image Slider ProgressBar MenuItem Menu MenuBar Tab TabList Toolbar List ListItem Tree TreeItem Table Row Cell ColumnHeader Dialog Alert Status Tooltip Terminal Splitter Group. Si falta uno, agregalo en llimphi-compositor/src/semantics.rs y mapealo en llimphi-ui/src/a11y.rs — cuando aparezca un caller real, no antes.

Cuándo NO declarar: decorativo puro (divider, gradiente, sombra, View envoltorio de layout). Un Role::Group en cada caja ensucia la navegación más de lo que ayuda.

Consultar — llimphi_test::query

La misma declaración habilita tests que no miran píxeles:

use llimphi_test::query::{Sel, Ui};
use llimphi_ui::Role;

let ui = Ui::new(app.view(), 800.0, 600.0);

assert_eq!(ui.find_all(Role::Tab).len(), 3);
let guardar = ui.get(Sel::new().role(Role::Button).label("Guardar"));
assert!(guardar.is_enabled());

// Clickear = obtener el Msg y metérselo al update.
app.update(guardar.click().unwrap());

Estado de la cobertura

50 de 76 widgets anotados (medido el 2026-08-27: grep -rlE '\.role\(|Role::' widgets/*/src). De los 26 restantes, 25 lo están a propósito — ver abajo — y uno, tablado, es un hueco real con nombre.

Anotados: accordion app-header avatar badge banner breadcrumb button calendar chip color-picker context-menu datetime-picker detail-table dock-rail empty fab field form gauge list menubar modal nodegraph progress range-slider rating rive-button segmented select shortcuts-help slider spinner splitter stat-card status-bar stepper switch table tabs terminal text-editor text-input theme-switcher timeline toast toolbar tooltip transport tree waveform.

Sin anotar por diseño (el módulo semantics lo pide explícitamente: un Role::Group en cada caja ensucia la navegación más de lo que ayuda):

⚠️ El que no está por decisión sino por hueco: tablado, el nodo donde el anfitrión pinta la EscenaWire de una app enjaulada. Por el cable viaja el IR de escena y no el de vista (SDD-JAULA-LINUX.md), así que del lado del anfitrión no hay roles que anunciar: para un lector de pantalla, una app enjaulada es un rectángulo mudo. Cerrarlo pide que la semántica del huésped cruce el cable, que es diseño pendiente y no una anotación que falte.

Activación: el rol y el handler no tienen por qué coincidir

Los widgets ponen el rol en la fila (TreeItem, Row, ListItem) porque es la unidad que el lector navega, pero el on_click suele vivir en un hijo (el nodo del label). a11y::click_target resuelve ese desajuste:

  1. Si el nodo mismo tiene handler, es él.
  2. Si no, y declara un rol, se delega en su subárbol — siempre que haya exactamente un descendiente clickeable.
  3. Si hay varios, no se delega: la elección sería arbitraria, y esos hijos ya están en el árbol como nodos propios para activarlos directo.

El requisito de rol en (2) es lo que evita que toda caja de layout se vuelva activable. El runtime además sintetiza la posición para los handlers on_click_at (centro del rect del nodo en el layout del frame) — misma convención que query::NodeRef::click.

Dientes y botones animados: el label es obligatorio

DockRailItem y RiveButton no tienen texto en el árbol (el ícono lo pinta un closure opaco), así que el nombre accesible viaja explícito:

DockRailItem::nombrado(id, activo, "Configuración")   // recomendado
DockRailItem::new(id, activo)                          // queda mudo
RiveButton::builtin().con_label("Ejecutar")

Y un patrón que apareció en casi todos: el ítem sólo-ícono. Un botón sin texto visible (toolbar sin with_label, transport, chevrones de calendario, el × de una pestaña) es mudo salvo que se le dé aria_label a mano. Los que tienen un nombre derivable ya lo llevan; los que dependen del caller están señalados en el código.


17. Comandos y demos

cargo check --workspace                              # smoke test mínimo (debe pasar siempre)
cargo run -p <crate> --release                       # correr una app
cargo run -p <crate> --example <demo> --release      # correr un demo

# demos del propio framework:
cargo run -p llimphi-ui      --example counter --release   # bucle Elm completo
cargo run -p llimphi-ui      --example editor  --release   # text field + teclado
cargo run -p llimphi-ui      --example gpu_paint_demo --release
cargo run -p llimphi-gallery --release                     # showcase de TODO el kit
cargo test -p llimphi-gallery                              # la vitrina como CALLER: los widgets responden
cargo run -p nada            --release                     # editor real para ejercitar widgets

# benchmark GPU directo vs vello (primitivas Y texto — pedile GPU real, en
# llvmpipe la mitad de rasterizado no decide nada):
cargo run -p llimphi-gpu-bench --release

# perfilador por frame (a stderr cada 60 frames: media/p95 por etapa):
LLIMPHI_PERF=1 cargo run -p <crate> --release
# ...con conteo de asignaciones de heap por frame y su desglose por etapa:
LLIMPHI_PERF=1 cargo run -p <crate> --release --features llimphi-ui/perf-alloc

# dónde poner un .repaint_boundary() en TU app (ver más abajo):
LLIMPHI_BOUNDARY_HINT=1 cargo run -p <crate> --release
# un update que tarda de más y congela la UI:
LLIMPHI_UI_SLOW_MS=50 cargo run -p <crate> --release

LLIMPHI_PERF=N reporta cada N frames (default 60) el desglose view/mount/layout/paint/raster/gpu/present en µs + layout-reuso, el caché de repaint_boundary y —si está la feature perf-alloc— alloc <media>/<p95> (<KB>) [view N·KB mount N·KB layout N·KB paint N·KB resto N·KB], donde resto es el tramo posterior a paint (raster vello + GPU + present) y el desglose suma el total. Sin esa feature el tramo de allocs dice n/a en vez de ceros falsos. Ojo: los contadores son del proceso, no del hilo — lo que asigne otro hilo durante una etapa cae en esa etapa. El contador es llimphi_ui::alloc::ContadorAlloc: si tu app ya elige su allocator, instalalo a mano (#[global_allocator]) en vez de usar la feature. El camino cache_hit (frame retenido entero) no se muestrea a propósito.

LLIMPHI_BOUNDARY_HINT — dónde poner un repaint_boundary

.repaint_boundary(clave) cachea la rasterización de un subárbol quieto y la reusa mientras su contenido no cambie (Bloque 23). El problema práctico nunca fue la API sino dónde: marcar un subárbol que en realidad cambia todos los cuadros no es neutro —se paga el hash entero cada frame y no se ahorra nada—, y desde afuera un panel quieto y uno que se reconstruye idéntico a sí mismo se ven igual. Por eso ninguna app lo usaba.

LLIMPHI_BOUNDARY_HINT=1 cargo run -p <tu-app> --release   # reporta cada 120 cuadros
LLIMPHI_BOUNDARY_HINT=300 …                                # cada 300

Usá la app un rato con el puntero fuera de lo que sospechás. La salida:

[llimphi] 2 subárbol(es) quieto(s), 431 nodos que se repintan de gusto
          — poneles .repaint_boundary(clave):
  nodo 12 · 287 nodos · (0,48 260×672) · 118 cuadros quieto «Archivos»
  nodo 640 · 144 nodos · (0,0 1920×48) · 118 cuadros quieto «Inicio»

Cada línea es el subárbol máximo que vino idéntico: si un panel entero está quieto no se listan también sus hijos. El texto es el primero que aparece adentro, para que lo reconozcas sin contar nodos.

Es una predicción, no una opinión: el detector consulta las mismas tres funciones que el caché de verdad (posición limpia, paint-puro, y el mismo hash que decide hit o miss), así que lo que señala el caché lo va a acertar. Dos cosas que no dice:

Con el puntero adentro de un subárbol, ni el caché lo sirve ni el detector lo sugiere: el hover cambia lo pintado. Y es diagnóstico, no producción — hashea cada candidato por cuadro. Encendelo, anotá, apagalo.

Qué descalifica a un subárbol (el detector se calla y el boundary, si lo ponés igual, se pinta fresco): cualquier paint_with/gpu_paint_with, una animación viva, un ripple, un hero, una mask_image, o estar bajo un clip/alpha/transform de un ancestro. Degradé e imagen sí entran desde 2026-08-05 — antes no, y como un sidebar real casi siempre tiene un ícono o un fondo con degradé, un solo nodo así descalificaba el panel entero y el caché no servía para lo que existía.

llimphi-gallery (src/main.rs) es la referencia viva del patrón completo: Model/Msg/init/update/view/view_overlay con overlays mutuamente excluyentes (modal > atajos > toasts > context-menu > dropdown). Controles: click en switches/segments; "Mostrar toast"/"Abrir modal"; ? abre atajos; Esc cierra el overlay activo.


18. Cheat-sheet

// ── App mínima ──────────────────────────────────────────────
impl App for X { type Model; type Msg; init; update; view; }
llimphi_ui::run::<X>();

// ── Nodo ────────────────────────────────────────────────────
View::new(Style{ flex_direction, size, gap, padding, align_items, justify_content, ..default() })
    .fill(c).fill_gradient(g).hover_fill(c).radius(r).radius_corners(tl,tr,br,bl).shadow(sh).border(w,c).clip(b).alpha(a).transform(xf).animated(key,dur)
    .text(s, px, c) | .text_aligned(s,px,c,al) | .text_runs(s,px,c,runs,al) | .text_weight(w) | .bold() | .ellipsis(n) | .max_lines(n)
    .image(img) | .paint_with(|scene,ts,rect|{}) | .gpu_paint_with(|d,q,enc,view,rect,vp|{})
    .on_click(m) | .on_click_at(|lx,ly,w,h|) | .on_right_click(m) | .on_middle_click(m)
    .on_pointer_enter(m) | .on_pointer_leave(m)
    .draggable(|ph,dx,dy|) | .draggable_at(|ph,dx,dy,lx0,ly0|)
    .drag_payload(id) | .on_drop(|id|) | .drop_hover_fill(c)
    .children(vec![..])

// ── Efectos ─────────────────────────────────────────────────
handle.spawn(|| Msg::Done(compute()));          // worker → reentra al update
handle.spawn_periodic(dur, || Msg::Tick);       // feed periódico
handle.dispatch(Msg::X);  handle.quit();

// ── Estilo de layout (taffy prelude) ────────────────────────
length(px)  percent(0..1)  Dimension::auto()
FlexDirection::{Row,Column}  AlignItems::{Start,Center,End,Stretch}
JustifyContent::{Start,Center,End,SpaceBetween}

// ── Theme ───────────────────────────────────────────────────
Theme::dark()/light()/aurora()/sunset();  Theme::next_after(name);  XxxPalette::from_theme(&t)

// ── Overlay (menús/modales) ─────────────────────────────────
fn view_overlay(m) -> Option<View<Msg>> { if m.open { Some(menu) } else { None } }

19. Índice de crates

Recontado contra el disco el 2026-08-27. Son 139 crates en el workspace raíz más dos que viven en workspaces aparte (llimphi-jaula, llimphi-wawa: los backends de llimphi-text son excluyentes y no conviven en un proceso).

Núcleo del camino a píxeles (02_ruway/llimphi/): llimphi-hal · llimphi-raster · llimphi-text · llimphi-glifos · llimphi-layout · llimphi-compositor · llimphi-ui.

Auxiliares del runtime: llimphi-theme · llimphi-motion · llimphi-icons · llimphi-image · llimphi-svg · llimphi-surface · llimphi-entrada · llimphi-despacho · llimphi-layer · llimphi-workspace · llimphi-test (golden-image) · llimphi-gallery · llimphi-gpu-bench · demo.

Los otros caminos a píxeles: llimphi-cpu (vello_cpu, el de wawa y el Android sin compute) · llimphi-hybrid (vello_hybrid) · llimphi-reproductor (el pintor único de los dos anfitriones) · llimphi-cable · llimphi-wire-escena · llimphi-wire-view · llimphi-jaula y llimphi-wawa (workspaces aparte). Se comparan por número con scripts/check-llimphi-cpu-vs-gpu.sh.

3D, voxel y animación: llimphi-3d · llimphi-mesh · llimphi-voxel · llimphi-voxel-render · llimphi-voxel-app · llimphi-voxel-studio · llimphi-anim · llimphi-anim-studio · llimphi-lottie · llimphi-video-plane · llimphi-term-graphics.

Camino web / WASM (guardián: scripts/check-llimphi-web.sh): llimphi-wasm-core · llimphi-wasm-app-sdk · llimphi-wasm-runner · llimphi-wasm-wasi · llimphi-wasm-net · llimphi-wasm-registry · llimphi-wasm-dist · llimphi-wasm-open · llimphi-wasm-demo-counter · llimphi-wasm-demo-form.

Widgets (widgets/, dep llimphi-widget-<n>) — 76, más llimphi-clipboard que vive ahí sin serlo: accordion · app-header · app-icon · avatar · badge · banner · breadcrumb · button · calendar · card · carousel · chip · color-picker · context-menu · datetime-picker · detail-table · dock-rail · edit-menu · empty · fab · field · fitted-box · form · fugaces · gallery · gauge · grid · hero · lazy-list · list · menubar · modal · navigator · nodegraph · panel · panes · progress · rag-sidebar · range-slider · rating · rive-button · router · scaffold · scroll · segmented · select · shortcuts-help · skeleton · slider · spinner · splash · splitter · stat-card · status-bar · stepper · switch · tablado · table · tabs · terminal · text-area · text-editor · text-editor-core · text-editor-lsp · text-input · theme-switcher · tiled · timeline · toast · toolbar · tooltip · transport · tree · waveform · wawa-mark · wrap.

Módulos (modules/) — 11: allichay · bookmarks · command-palette · diff-viewer · fif · file-picker · mini-map · plugin-host · selector · shuma-term · symbol-outline.

Android (android/): clear-screen-android · vello-hello-android · vello-text-android · hybrid-probe-android · llimphi-hybrid-android.


Documentos hermanos: SDD.md (diseño y roadmap), COMPUTO-FUERA-DEL-HILO-UI.md (regla de concurrencia), README.md / LEEME.md (overview). Las firmas de este manual reflejan el código al 2026-06-01; el índice de crates y los conteos de widgets/módulos se recontaron contra el disco el 2026-08-27. Ante divergencia, la fuente autoritativa es el lib.rs de cada crate — y para cualquier conteo, el comando.