tawasuyu

tawasuyu/llimphi

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

Rust 660Markdown 302TOML 157RON 312382 Definiciones

docs: sincroniza MANUAL.md y SDD.md con el monorepo (70 widgets / 11 módulos)

c5a232cf24788ab75a850ffe81eca5a7af50e2f8 · por Sergio · (hace 2 meses) · padre 93253cc22 · ver el árbol en este commit

Delta semántico

Ninguna definición cambió: el cambio es de texto, documentación o datos.

4 archivos · +518 −43

diff --git a/LEEME.md b/LEEME.md
index 9949174..5674241 100644
--- a/LEEME.md
+++ b/LEEME.md
@@ -2,9 +2,9 @@
 
 > Framework de UI nativa: HAL · raster · layout · text · theme · ui — más widgets y módulos.
 
-`llimphi` es el motor gráfico que comparten todas las apps del monorepo. Pipeline retained-mode declarativa sobre `vello` + `wgpu` + `taffy`, con shaping `fontdue`/`harfbuzz`, theme `Dark/Light/Aurora/Sunset`, HAL multiplataforma (Wayland · X11 · Win32 · Android · Wawa).
+`llimphi` es el motor gráfico que comparten todas las apps del monorepo. Pipeline retained-mode declarativa sobre `vello` 0.7 + `wgpu` 27 + `taffy`, con shaping `parley` 0.6 (DejaVu Sans embebida como fallback de símbolos), theme `Dark/Light/Aurora/Sunset`, accesibilidad AccessKit, HAL multiplataforma (Wayland · X11 · Win32 · Android · Wawa).
 
-**Manual de uso:** [MANUAL.md](MANUAL.md) — referencia completa (bucle Elm, DSL `View<Msg>`, los ~44 widgets y 10 módulos, GPU directo, gotchas) para humanos e IA. Diseño y roadmap: [SDD.md](SDD.md).
+**Manual de uso:** [MANUAL.md](MANUAL.md) — referencia completa (bucle Elm, DSL `View<Msg>`, los 70 widgets y 11 módulos, GPU directo, gotchas) para humanos e IA. Diseño y roadmap: [SDD.md](SDD.md).
 
 Filosofía: **un widget no se diseña pensando en mockups; se diseña con lo que `vello` y `taffy` pueden hacer.**
 
diff --git a/MANUAL.md b/MANUAL.md
index 8f1e19a..5424850 100644
--- a/MANUAL.md
+++ b/MANUAL.md
@@ -1,6 +1,6 @@
 # Manual de Llimphi
 
-> Motor gráfico soberano de gioser. `wgpu` + `vello` + `taffy` + `parley`,
+> 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.
@@ -52,7 +52,7 @@ Llimphi es **Elm sobre la GPU**. Una app es un tipo que implementa el trait
 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**: declarás qué se
+compartida, no hay vDOM ajeno, no hay callbacks imperativos**: declaras qué se
 ve y qué `Msg` emite cada nodo.
 
 ```
@@ -83,7 +83,7 @@ Tres reglas de oro:
 
 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í escribís `llimphi_ui::View`
+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.
 
@@ -91,7 +91,7 @@ Auxiliares: `llimphi-theme` (paletas), `llimphi-motion` (tweens),
 `llimphi-icons` (iconos vectoriales), `llimphi-surface` (texturas externas),
 `llimphi-workspace` (chasis tmux), `llimphi-gallery` (showcase).
 
-Catálogo: **~45 widgets** (visuales) + **10 módulos** (features con estado).
+Catálogo: **70 widgets** (visuales) + **11 módulos** (features con estado).
 
 ---
 
@@ -197,7 +197,7 @@ pub trait App: 'static {
     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; guardás `id` en tu Model para pintar el ring
+    /// 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 }
 
@@ -247,9 +247,27 @@ impl<Msg: Send + 'static> Handle<Msg> {
     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
 }
 ```
 
+- **`on_modifiers`** (hook del trait `App`) — el `WindowEvent::ModifiersChanged`.
+  Existe porque **los handlers de click llevan un `Msg` plano y no reciben los
+  modificadores**: para hacer Shift+click (rango) o Ctrl+click (aditivo) en una
+  lista, la app guarda el estado en su modelo desde este hook y lo consulta en
+  el `update` del click. Devolvé `None` cuando el cambio no te importa (si no,
+  cada roce de Shift dispara un update y un repintado).
+
+- **`for_test` vs `for_test_with`** — `for_test` **descarta** los `Msg`; ojo con
+  eso: la closure de un `spawn` **sí se ejecuta** (para no perder sus efectos),
+  sólo se tira el mensaje. Un test que necesitaba el resultado terminaba
+  volviendo a hacer el trabajo a mano → el mismo job dos veces, en paralelo, y
+  carreras intermitentes sobre el disco. `for_test_with` entrega cada `Msg` a un
+  sink (típicamente el `Sender` de un canal, detrás de un `Mutex` porque el sink
+  pide `Sync`); el test lo drena y lo realimenta a `update`, que es lo que hace
+  el runtime real. Con eso el trabajo corre **una sola vez** y el test es
+  determinista.
+
 - **`spawn`** — trabajo bloqueante (IO, PAM, parse, efemérides). El `Msg` que
   devuelve la closure se entrega al `update` en el hilo de UI. **Este es el
   patrón obligatorio para todo cómputo pesado** (§16).
@@ -273,10 +291,15 @@ View::new(style: Style) -> View<Msg>
 | Método | Efecto |
 |---|---|
 | `.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 |
+| `.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 |
@@ -289,6 +312,10 @@ View::new(style: Style) -> View<Msg>
 .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)
@@ -304,6 +331,12 @@ View::new(style: Style) -> View<Msg>
 .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
+.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)
 ```
 
@@ -320,6 +353,16 @@ Notas clave:
 - `PaintRect { x, y, w, h }` es el rect **absoluto** del nodo en píxeles físicos.
 - `DragPhase` = `Move` (un evento por `CursorMoved`, `dx/dy` = delta **desde el
   evento anterior**, no acumulado) | `End` (al soltar).
+- **Gestos (`on_scale`/`on_double_tap`/`on_long_press`)** son **aditivos**: se
+  resuelven con su propio hit-test y no interfieren con `on_click`/`draggable`
+  del mismo nodo. El caso limpio (sin disparos cruzados) es ponerlos en un nodo
+  que **no** tenga `on_click` — p. ej. un canvas con `draggable` (pan) +
+  `on_scale` (zoom) + `on_long_press` (marca). `GesturePhase` = `Begin`/`Update`/
+  `End`; en `on_scale`, `factor` es **multiplicativo incremental** (`>1` agranda)
+  y `(fx, fy)` el focal local — `Ctrl+rueda` lo sintetiza en cualquier desktop
+  (Wayland/Windows no emiten el pinch del trackpad; macOS sí, vía `PinchGesture`).
+  El long-press lo arbitra el tiempo (~500 ms quieto); moverse (>8px) o soltar lo
+  cancela. Demo completo: `cargo run -p llimphi-ui --example gestos --release`.
 
 ---
 
@@ -352,7 +395,7 @@ Helpers de `prelude`: `length(px)`, `percent(frac)`, `auto()`, `Dimension`,
   `.node(style, &children)`, `.compute(...)`, `.compute_with_measure(F)`.
 - `Rect { x, y, w, h }` y `ComputedLayout { rects: HashMap<NodeId, Rect> }`.
 
-En el 99% de los casos no tocás `LayoutTree` a mano: lo maneja el runtime al
+En el 99% de los casos no tocas `LayoutTree` a mano: lo maneja el runtime al
 montar tu `View`. Sólo armás `Style`s.
 
 ---
@@ -371,6 +414,9 @@ montar tu `View`. Sólo armás `Style`s.
 | Drag&drop entre zonas | origen: `.drag_payload(id)`; destino: `.on_drop(\|id\| ...)` + `.drop_hover_fill` |
 | Scroll global | `App::on_wheel(model, delta, cursor, mods)` |
 | Área de scroll | widget `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\| ...)` |
+| Long-press (mantener) | `.on_long_press(Msg)` / `.on_long_press_at(\|lx,ly,w,h\| ...)` |
 | Teclado | `App::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) |
@@ -388,13 +434,29 @@ 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/inercia
-(driveado por `Handle::spawn_periodic`).
-
-**Foco y teclado.** Marcá los nodos navegables con `.focusable(id)` (id `u64`
-que vos elegís). El runtime es la **única fuente de verdad** del foco: lo mueve
+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):
+- `scroll_xy(offset:(x,y), content_size:(w,h), viewport_size:(w,h), content,
+  on_scroll:(dx,dy)→Msg, &palette)` — dos ejes, una barra por eje con overflow.
+- **Inercia (fling)**: `fling_step(velocity, dt, friction) → (v', delta)` +
+  `fling_settled(v)` — suelta con velocidad y avanza el offset por frame con el
+  ticker (`spawn_periodic`); `FLING_FRICTION`/`FLING_STOP` defaults. **Bounce**:
+  `rubber_band(overscroll, dim)` amortigua el desplazamiento más allá del tope.
+- **Sliver app-bar colapsable**: `sliver_app_bar(offset, header_max, header_min,
+  header(frac)→View, content, content_len, viewport_len, on_scroll, &palette)` —
+  un único offset colapsa el header (de max a min) y luego scrollea el cuerpo
+  bajo él; `header(frac)` recibe `frac∈[0,1]` para fundir título/subtítulo.
+  Clampeá con `sliver_max_offset(...)`. Helpers puros: `collapsed_height`,
+  `collapse_fraction`, `sticky_y(offset, section_top, section_h, header_h)` para
+  encabezados de sección pegados al tope. Demo: `cargo run -p
+  llimphi-widget-scroll --example scroll_avanzado`.
+
+**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>)`. Guardás el id en
+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.
@@ -402,8 +464,8 @@ 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)` (insertá como tecleado) o
-`Disabled`. Reportá el área del caret con `ime_cursor_area(model)` para ubicar
+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.
 
 ---
@@ -464,6 +526,29 @@ nakui, pineal denso. Rango 100 K – 10 M+ primitivos. **No** soporta texto ni A
 fino ni múltiples grosores de stroke por flush. Para texto encima de un render
 GPU, usar `view_overlay` (segunda Scene vello).
 
+### 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:
+
+```rust
+// 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?
 | Pregunta | vello (`paint_with`) | GPU directo (`gpu_paint_with`) |
 |---|---|---|
@@ -510,7 +595,7 @@ 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 reconstruís todo.
+tema, el `theme-switcher` emite `Msg(next_theme)` y reconstruyes todo.
 
 ---
 
@@ -526,7 +611,7 @@ 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 tocás HAL si escribís un runtime nuevo.
+todo esto; sólo tocas HAL si escribes un runtime nuevo.
 
 ### `llimphi-raster` — rasterización
 ```rust
@@ -542,6 +627,19 @@ GpuBatch::new(&pipelines)
 ```
 Re-exporta `vello` y `peniko` (`Color`, `Image`, `Fill`, etc.).
 
+### `llimphi-image` — decode pipeline
+```rust
+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
+```
+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).
+
 ### `llimphi-text` — shaping
 ```rust
 Typesetter::new()                          // una por proceso (FontContext es caro)
@@ -560,7 +658,7 @@ 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: guardás `Tween<T>` en el Model, `animate(...)` en el update, la `view`
+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)
@@ -602,25 +700,100 @@ cada uno trae `XxxPalette::from_theme(&Theme)`. Crates en
 soporta undo/redo + selección con Shift). Render:
 `text_input_view(&state, placeholder, focused, &palette, on_focus: Msg)`.
 
-**text-area** — multilínea con estado `TextAreaState` (Enter = newline, sin
-auto-submit). `text_area_view(&state, placeholder, focused, body_height, &palette, on_focus)`.
+**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.**
+- `Metricas { font_size, line_h, font_family, pad_x, pad_r, pad_y }` —
+  `Metricas::mono(px)` + `.con_zoom(z)`. La usan pintado, ajuste blando y
+  click→columna: una sola, o el caret cae entre glifos que no son los que se ven.
+- `Adornos { tramos, ghost, placeholder, caret, ahora_ms, marco }`.
+  `tramos: Vec<(ini, fin, Color)>` en **bytes del texto completo** (se recortan
+  solos a cada renglón); `ghost` = sufijo tenue tras el caret (sólo si el caret
+  está al final); `Placeholder { texto, color, italic, icono, icono_color, alpha }`;
+  `marco: false` = sin caja propia (para un host que ya tiene la suya — el borde
+  del widget es un relleno **opaco** que taparía un backdrop frosted).
+- `CaretEstilo::vivo()` = parpadeo que queda **sólido** tras cada tecla y
+  **estela** al moverse (el eyecandy en honor a kitty). El reloj lo trae el host:
+  `Adornos::ahora_ms` por cuadro + `state.marcar_actividad(ms)` en cada tecla —
+  el widget no llama a `now()` por dentro (sería intesteable, y un `bool` de fase
+  alternado por un timer externo se muestrea mal cuando el panel repinta lento).
+- Consultas para un host que **crece con el contenido**:
+  `renglones_visuales()`, `renglones_visuales_de(txt)`, `avance_en_renglon()`,
+  `ancho_caracter()`, `fijar_ancho_caja(px)` (sembrar el ancho antes del primer
+  cuadro, o testear el wrap sin GPU).
+`text_area_view` es esta misma vista con `Adornos::default()` — un solo
+renderizador. Uso canónico: el input de la barra de shuma
+(`02_ruway/shuma/sandbox/shuma-module-shell/src/view/input.rs`), que le pasa los
+tokens del shell como `tramos` y monta su propio marco con `marco: false`.
 
 **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}`.
 
@@ -636,6 +809,29 @@ con `on_pointer_enter/leave`.
 **skeleton** — placeholder con shimmer. `skeleton_view`, `skeleton_box_view(w,h,..)`,
 `skeleton_line_view(w,..)`. Requiere redraws periódicos.
 
+**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.
+
 **banner** — tira de status. `banner_view(BannerKind::{Info,Success,Warning,Error}, message)`.
 
 ### Contenedores y layout
@@ -652,6 +848,33 @@ 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.
 
+**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).
+```rust
+// 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.
@@ -661,6 +884,34 @@ 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.
 
+**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`.
+```rust
+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)
+// 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)
+// 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 }`.
@@ -673,6 +924,43 @@ panes_view(&layout, focused: PaneId, leaf: FnMut(PaneId)->View, on_resize: Fn(Ve
            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`.
+```rust
+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).
+
+**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).
+```rust
+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>
+```
+
 **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, ... })`.
@@ -733,6 +1021,74 @@ nodegraph_view(&nodes, &wires, &palette, &metrics,
 **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.
+```rust
+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").
+```rust
+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.
+```rust
+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([], ..)`.
+
 **text-editor** — editor IDE (capa visual sobre el core agnóstico). La app guarda
 `EditorState`:
 ```rust
@@ -775,6 +1131,68 @@ syntax_palette_dark(&theme) -> SyntaxPalette   // en lib.rs del widget
 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`.
+- **Store** (`Scrollback::new(limit_bytes)`): append-only por línea con índice
+  de offsets O(1), cap por memoria con recorte de frente, `line_id` global
+  **estable** que sobrevive al recorte (`index_of_id`), `push_line`,
+  `slice_text`, `clear`. `dropped()` / `total_pushed()` para el `numbering`
+  global.
+- **Spill a disco** (`SpillStore::create(path)` + `Scrollback::enable_spill(spill)`):
+  cada línea recortada del frente se appendea al archive; lookup O(1) con
+  `read_spilled(global_id)` + helpers `has_spill`/`spilled_count`/`spill_path`
+  para que el host exponga UX (chip de status, builtin `:scrollback open`,
+  prepend del archive al view).
+- **Modo línea** (`line_surface(store, scroll_y, viewport_h, metrics, &palette,
+  line_style: Fn(usize, &str) -> LineStyle, on_scroll: Fn(f32) -> Msg, measure:
+  Option<Arc<Mutex<f32>>>)`): materializa sólo la ventana visible; scroll
+  sub-renglón (`partial_px`), numeración global 1-based, color base + runs +
+  tinte de fondo inyectados por el caller en `LineStyle`. Helpers puros:
+  `visible_window`, `content_height`, `scroll_to_bottom`. `measure` opcional
+  publica el `viewport_h` real medido al host (regla del SDD: verificar con
+  viewport real).
+- **Modo bloques** (`block_surface(store, items: Vec<Item<Msg>>, ...)`): el
+  stream es una secuencia de `Item::chrome(height, view)` (header/badge/etapa
+  de alto fijo que el caller pinta) + `Item::lines(start, end)` (rango del
+  store). Virtualiza sobre alturas mixtas con búsqueda binaria (O(log n) en
+  bloques) — dentro de un `Lines` enorme sólo se materializan las sub-filas
+  visibles. Colapsar = no emitir el `Lines`. Helpers: `blocks_height`,
+  `blocks_scroll_to_bottom`, `gutter_width(&store, metrics)`,
+  `line_top_in_content(&items_geo, row_h, target_line)`.
+- **Selección + copy** (`block_surface_with_selection` + `SelectionConfig { range:
+  Option<&SelectionRange>, on_drag: Fn(DragPhase, lx0, ly0, dx, dy) -> Option<Msg>,
+  on_double_click: Fn(lx, ly, w, h) -> Option<Msg> }`): el widget pinta el
+  overlay translúcido y dispara callbacks; el caller mantiene el rango
+  (`SelectionRange { anchor: Point, head: Point }`, `Point { line, col }` con
+  `col` en **bytes** UTF-8) en su Model y resuelve clicks con `point_at(items,
+  scroll_y, viewport_h, metrics, gutter_w, store, lx, ly)` —o su variante
+  `Copy`-stashable `point_at_geo(&[ItemGeo], ...)` para resolver el frame
+  anterior. `selection_rects` para overlays custom.
+- **Find** (`find_matches(&store, query, FindOpts { case_insensitive })`):
+  búsqueda sobre el scrollback **completo** (no sólo el viewport), O(total_bytes);
+  `FindMatch { line, start, end }` con `start`/`end` en bytes UTF-8 slice-safe.
+  Nav: `next_match(&matches, current)` / `prev_match`. El caller resuelve el
+  `line` a su `scroll_y` con `line_top_in_content`.
+- **Grilla GPU** (modo TUI, Fase 4): `CellPipeline::new(device, format)` +
+  `GlyphAtlas` rasterizan cada celda con **quads instanciados** y atlas de
+  glifos (precedente: `atlas` de wawa, Fontdue) — una sola draw call por
+  viewport, escala a 200×80 sin penalty. Tipos: `CellInstance { cell_x/y,
+  uv_x/y/w/h, fg_rgba, bg_rgba }` (POD 32 B), `CellUniforms`, `pack_rgba`,
+  `instances_to_bytes`, `CELL_WGSL`, `CellPipeline::create_atlas_texture`.
+  Wireado al widget contenedor vía `gpu_paint_with`. Activo en shuma con
+  `SHUMA_GPU_GRID=1`.
+
+Headless dumps:
+```bash
+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).
 
@@ -790,11 +1208,17 @@ o `menubar_overlay_animated(spec, active, appear)`. Navegación por teclado:
 `ContextMenuSpec`. `apply(&mut state, EditAction, &mut clipboard) -> ApplyResult`.
 `EditAction::{Undo,Redo,Cut,Copy,Paste,Delete,SelectAll}`.
 
-**context-menu** — menú contextual genérico (look "webpage"). `ContextMenuItem::
-action(label).with_shortcut(..).icon(..).disabled().destructive().submenu(children)`
-o `::separator()`. `context_menu_view(ContextMenuSpec { anchor, viewport, header,
-items, active, on_pick: Fn(usize)->Msg, on_dismiss, palette })`; `context_menu_view_ex`
-con submenús/animación. Se monta en `view_overlay` con scrim.
+**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`.
@@ -947,6 +1371,42 @@ La **generación** es imprescindible si el recálculo se dispara seguido. Ver
 
 ---
 
+## 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.
+
+```rust
+// 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
+}
+```
+
+- **Generar/actualizar goldens:** `LLIMPHI_GOLDEN_UPDATE=1 cargo test -p <crate>`
+  (escribe los PNG en `tests/goldens/`; commitéalos).
+- **En fallo:** escribe `mi_card.actual.png` + `mi_card.diff.png` (heatmap) al lado
+  del golden y paniquea con `DiffStats { differing_pixels, max_delta, fraction }`
+  — la stat numérica **es** la evidencia, no hace falta abrir el PNG.
+- **Tolerancia:** `GoldenConfig { max_channel_delta, max_diff_fraction }` (default
+  casi bit-exacto). Para control fino: `Harness::render(...)` + `diff(...)` +
+  `compare_golden(path, &rendered, &cfg)` sueltos.
+- **Determinismo:** el adapter de software es bit-exacto (el self-test
+  `readback_es_determinista` verifica 0px entre dos renders). Si la máquina no
+  tiene lavapipe, cae al adapter normal y la tolerancia absorbe la diferencia.
+- **Path GPU-directo** (`GpuBatch`/`gpu_paint_with`, que no pasa por vello):
+  `Harness::render_gpu(w, h, bg, |device, queue, encoder, view, vp| { … })` —
+  misma firma que `View::gpu_paint_with`; limpia a `bg` y te deja pintar directo,
+  después lee de vuelta para el golden (ver `llimphi-test/tests/gpu_golden.rs`).
+
+---
+
 ## 17. Comandos y demos
 
 ```bash
@@ -963,8 +1423,22 @@ cargo run -p nada            --release                     # editor real para ej
 
 # benchmark GPU directo vs vello:
 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
 ```
 
+`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 … mount … layout … paint …]`. Sin esa feature el tramo de allocs
+dice `n/a` en vez de ceros falsos. 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-gallery` (`src/main.rs`, ~967 líneas) 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).
@@ -982,8 +1456,8 @@ llimphi_ui::run::<X>();
 
 // ── Nodo ────────────────────────────────────────────────────
 View::new(Style{ flex_direction, size, gap, padding, align_items, justify_content, ..default() })
-    .fill(c).hover_fill(c).radius(r).clip(b).alpha(a).transform(xf)
-    .text(s, px, c) | .text_aligned(s,px,c,al) | .text_runs(s,px,c,runs,al)
+    .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)
@@ -1019,12 +1493,13 @@ fn view_overlay(m) -> Option<View<Msg>> { if m.open { Some(menu) } else { None }
 `llimphi-gpu-bench`.
 
 **Widgets** (`widgets/`, dep `llimphi-widget-<n>`): app-header · avatar · badge ·
-banner · breadcrumb · button · card · clipboard · context-menu · edit-menu ·
-empty · field · gallery · grid · list · menubar · modal · navigator · nodegraph ·
-panel · panes · progress · segmented · shortcuts-help · skeleton · slider · splash ·
-splitter · stat-card · status-bar · switch · tabs · text-area · text-editor ·
-text-editor-core · text-editor-lsp · text-input · theme-switcher · tiled ·
-timeline · toast · tooltip · tree · wawa-mark.
+banner · breadcrumb · button · card · clipboard · color-picker · context-menu ·
+dock-rail · edit-menu · empty · field · gallery · grid · lazy-list · list · menubar · modal ·
+navigator · nodegraph · panel · panes · progress · segmented · select ·
+shortcuts-help · skeleton · slider · splash · splitter · stat-card · status-bar ·
+switch · 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.
 
 **Módulos** (`modules/`): bookmarks · command-palette · diff-viewer · fif ·
 file-picker · mini-map · plugin-host · selector · shuma-term · symbol-outline.
diff --git a/README.md b/README.md
index 38eb688..4831b38 100644
--- a/README.md
+++ b/README.md
@@ -28,7 +28,7 @@
 
 `llimphi-voxel` adds the *content* layer on top: procedural world-gen (`WorldRecipe`), articulated characters (age + animation clips) and a scripted scene **director**. The GIF above is one such world. (A full voxel **world studio** — edit worlds, cast characters, direct filmed scenes, export to video — is built on these in the wider project.)
 
-**Usage manual:** [MANUAL.md](MANUAL.md) — full reference (Elm loop, `View<Msg>` DSL, the ~44 widgets and 10 modules, GPU path, gotchas) for humans and AI. Design rationale and roadmap: [SDD.md](SDD.md).
+**Usage manual:** [MANUAL.md](MANUAL.md) — full reference (Elm loop, `View<Msg>` DSL, the 70 widgets and 11 modules, GPU path, gotchas) for humans and AI. Design rationale and roadmap: [SDD.md](SDD.md).
 
 Philosophy: **widgets aren't designed against mockups; they're designed with what `vello` and `taffy` can do.**
 
diff --git a/SDD.md b/SDD.md
index 2f7a55d..867cf06 100644
--- a/SDD.md
+++ b/SDD.md
@@ -4,11 +4,11 @@
 
 > **Regla dura para apps:** nada de cómputo pesado síncrono en `App::update`/`init`/handlers — congela la UI ("Not Responding"). Ver [COMPUTO-FUERA-DEL-HILO-UI.md](COMPUTO-FUERA-DEL-HILO-UI.md) (patrón worker + checklist por app, prioridad urgente).
 
-> **¿Buscás cómo *usar* Llimphi?** Este SDD es el *porqué* (diseño, fases, roadmap). La referencia de *uso* — bucle Elm, DSL `View<Msg>`, catálogo de widgets/módulos, GPU directo — está en [MANUAL.md](MANUAL.md), verificada contra el código.
+> **¿Buscas cómo *usar* Llimphi?** Este SDD es el *porqué* (diseño, fases, roadmap). La referencia de *uso* — bucle Elm, DSL `View<Msg>`, catálogo de widgets/módulos, GPU directo — está en [MANUAL.md](MANUAL.md), verificada contra el código.
 
 ## Tesis
 
-Soberanía total sobre el píxel. Renderizar las geometrías exactas del simulador cósmico (`cosmos`), el compositor (`mirada`), las apps de escritorio (`nahual`) y el visor (`pluma`) sin cajas negras de Apple/Google/navegadores. Reemplazo total de **GPUI** en la pila gioser.
+Soberanía total sobre el píxel. Renderizar las geometrías exactas del simulador cósmico (`cosmos`), el compositor (`mirada`), las apps de escritorio (`nahual`) y el visor (`pluma`) sin cajas negras de Apple/Google/navegadores. Reemplazo total de **GPUI** en la pila tawasuyu.
 
 ## Anatomía — 4 capas estrictas (S₀ → S₂)
 
@@ -74,7 +74,7 @@ El mayor problema de las interfaces (y por qué falló el paradigma OOP en esto)
 
 ## Veredicto arquitectónico
 
-No es una biblioteca genérica. Es un **motor de combate**. `wgpu + vello + taffy + DAG monádico` da un frontend capaz de competir en rendimiento con los mejores editores del mundo, diseñado como **traje a medida** para las topologías de gioser. Sin abstracciones de navegadores, sin cajas negras de Apple/Google.
+No es una biblioteca genérica. Es un **motor de combate**. `wgpu + vello + taffy + DAG monádico` da un frontend capaz de competir en rendimiento con los mejores editores del mundo, diseñado como **traje a medida** para las topologías de tawasuyu. Sin abstracciones de navegadores, sin cajas negras de Apple/Google.
 
 ## Pila exacta (sin negociación)
 
@@ -111,8 +111,8 @@ Apps actualmente en GPUI que deben portarse:
 
 ### Pendiente
 - Runtime sobre framebuffer de `wawa` (`WawaFramebufferSurface`) reusando el compositor winit-free — habilitado por el split pero aún no escrito.
-- Backend GPU directo: sin MSAA/AA fino, sin texto, una sola `line_width` por flush; falta primer caller real denso (cosmos starfield) que mida una falla concreta antes de extender shaders.
-- Widgets `llimphi-widget-{transport, waveform}` aún por extraer (la nota de media los deja como futuro no bloqueante).
+- Backend GPU directo: **MSAA 4× SÍ está cableado** (`gpu.rs:59` `MSAA_SAMPLES=4` en los 4 pipelines + AA por SDF/smoothstep en discos/anillos) — corregido 2026-07-13, el "sin MSAA/AA fino" previo era falso. **Color del vértice ensanchado a `Float32x4`** (2026-07-13): HDR (>1.0) y sub-8-bit sobreviven a un target `Rgba16Float` (certificado por texto, `tests/gpu_hdr_precision`). **`GpuBatch::scissor(x,y,w,h)`** agregado (certificado, `tests/gpu_scissor`) → habilita damage denso. Lo que **aún** falta: sin texto; una sola `line_width` por flush; el `damage_rect` a nivel runtime + present parcial de surface (`[METAL]`); y el primer caller real denso (cosmos starfield, `[METAL]` fps). Plan de maduración (llave que desbloquea HDR #3 y damage #1 densos): `PLAN-MOTOR-PROFUNDO.md`.
+- Widgets `llimphi-widget-{transport, waveform}` ✅ ambos extraídos (2026-06-07). `waveform`: visor de envelope min/max stateless y agnóstico (consumidor: `media-app::waveform_panel`, ~150 → ~25 LOC). `transport`: 17 botones (play/pause/prev/next/seek/volume/mute/repeat/shuffle/speed/snapshot/record/eq) con enum `TransportAction` semántico y enum `TransportButton` con estado por variante; el caller traduce `TransportAction` → `MediaCommand` (consumidor: `media-app::bar_item_view`, 22 → 19 LOC con paridad pixel via `TransportPalette` custom). 11 tests entre ambos crates.
 - Investigación abierta: cuelgue/deadlock de apps Llimphi tras click/scroll (hipótesis `get_current_texture` Wayland FIFO) — pendiente reproducir+backtrace.
 
 ## Estado — bitácora histórica
@@ -152,7 +152,7 @@ El techo aparece cuando una app necesita rendir **>1 M primitivos por
 frame**. En ese régimen el overhead de construir `BezPath`, ensamblar
 buffers para los shaders internos de vello y hacer una pasada compute
 por cada batch domina sobre el tiempo de raster real. Casos concretos
-en gioser:
+en tawasuyu:
 
 | App | Carga potencial | Trigger probable |
 |---|---|---|
@@ -349,7 +349,7 @@ real — el bench corrió en llvmpipe.
 
 No empezar hasta tener un caller real que mida una falla concreta.
 El candidato natural es cosmos (starfield Gaia o sky-survey overlay).
-Hasta entonces, el item queda acá en este SDD como decisión arquitectónica
+Hasta entonces, el item queda aquí en este SDD como decisión arquitectónica
 tomada — todas las apps saben que el techo existe y que la salida
 está diseñada.