tawasuyu

tawasuyu/pluma

Multi-canvas authoring (bundle of bodies) + reactive notebook, in Rust, on Llimphi.

Rust 193Markdown 141TOML 663959 Definiciones

feat: pluma standalone — autoría multilienzo (haz de cuerpos) + notebook reactivo (front-door, git-dep al monorepo)

Front-door limpio: solo crates del dominio; Llimphi y lo fundacional por
git-dep del monorepo gioser.git. cargo check pasa (40 crates, 0 errores).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

30467600bcd9effa15e8338fb32d357c1edab660 · por Sergio · (hace 4 meses) · ver el árbol en este commit

Delta semántico · 1600

00_unanchay/pluma/pluma-align/src/lib.rs

00_unanchay/pluma/pluma-app/src/clipboard.rs

00_unanchay/pluma/pluma-app/src/init.rs

00_unanchay/pluma/pluma-app/src/main.rs

00_unanchay/pluma/pluma-app/src/model.rs

00_unanchay/pluma/pluma-app/src/update.rs

00_unanchay/pluma/pluma-app/src/util.rs

00_unanchay/pluma/pluma-app/src/view.rs

00_unanchay/pluma/pluma-core/src/lib.rs

00_unanchay/pluma/pluma-cuerpo/src/lib.rs

00_unanchay/pluma/pluma-deck-app/src/main.rs

00_unanchay/pluma/pluma-deck-core/src/camara.rs

00_unanchay/pluma/pluma-deck-core/src/lib.rs

00_unanchay/pluma/pluma-deck-core/src/recorrido.rs

00_unanchay/pluma/pluma-deck-recorrido-llimphi/examples/recorrido_demo.rs

00_unanchay/pluma/pluma-deck-recorrido-llimphi/examples/recorrido_editor_demo.rs

00_unanchay/pluma/pluma-deck-recorrido-llimphi/examples/recorrido_imagen_demo.rs

00_unanchay/pluma/pluma-deck-recorrido-llimphi/examples/recorrido_md_demo.rs

00_unanchay/pluma/pluma-deck-recorrido-llimphi/src/lib.rs

00_unanchay/pluma/pluma-deck-web/src/lib.rs

00_unanchay/pluma/pluma-deck-web/src/recorrido.rs

00_unanchay/pluma/pluma-editor-llimphi/examples/cuerpo_ide_demo.rs

00_unanchay/pluma/pluma-editor-llimphi/examples/editor_unico_demo.rs

00_unanchay/pluma/pluma-editor-llimphi/examples/multilienzo_completo_demo.rs

00_unanchay/pluma/pluma-editor-llimphi/examples/multilienzo_demo.rs

00_unanchay/pluma/pluma-editor-llimphi/examples/multilienzo_dinamico_demo.rs

00_unanchay/pluma/pluma-editor-llimphi/examples/multilienzo_llm_demo.rs

00_unanchay/pluma/pluma-editor-llimphi/examples/multilienzo_store_demo.rs

00_unanchay/pluma/pluma-editor-llimphi/examples/zona_transform_demo.rs

00_unanchay/pluma/pluma-editor-llimphi/src/cuerpo_ide.rs

00_unanchay/pluma/pluma-editor-llimphi/src/lib.rs

00_unanchay/pluma/pluma-graph/src/lib.rs

00_unanchay/pluma/pluma-llm-anthropic/src/lib.rs

00_unanchay/pluma/pluma-llm-cohere/src/lib.rs

00_unanchay/pluma/pluma-llm-core/src/lib.rs

00_unanchay/pluma/pluma-llm-gemini/examples/smoke.rs

00_unanchay/pluma/pluma-llm-gemini/src/lib.rs

00_unanchay/pluma/pluma-llm-openai-compatible/src/lib.rs

00_unanchay/pluma/pluma-llm/src/lib.rs

00_unanchay/pluma/pluma-md-reader-web/src/lib.rs

00_unanchay/pluma/pluma-md/src/lib.rs

00_unanchay/pluma/pluma-notebook-app/src/main.rs

00_unanchay/pluma/pluma-notebook-core/src/cell.rs

00_unanchay/pluma/pluma-notebook-core/src/lib.rs

00_unanchay/pluma/pluma-notebook-exec/src/lib.rs

00_unanchay/pluma/pluma-notebook-graph-llimphi/examples/notebook_graph_demo.rs

00_unanchay/pluma/pluma-notebook-graph-llimphi/examples/notebook_graph_dominium_demo.rs

00_unanchay/pluma/pluma-notebook-kernel-llm/examples/notebook_llm_demo.rs

00_unanchay/pluma/pluma-notebook-kernel-media/src/lib.rs

00_unanchay/pluma/pluma-notebook-kernel-multi/src/lib.rs

00_unanchay/pluma/pluma-notebook-kernel-tinkuy/src/lib.rs

00_unanchay/pluma/pluma-notebook-kernel-wasm/src/lib.rs

00_unanchay/pluma/pluma-notebook-llimphi/src/main.rs

00_unanchay/pluma/pluma-notebook-store/src/lib.rs

00_unanchay/pluma/pluma-render-plan/src/lib.rs

00_unanchay/pluma/pluma-semantic/src/lib.rs

00_unanchay/pluma/pluma-store/src/lib.rs

00_unanchay/pluma/pluma-store/src/multilienzo.rs

00_unanchay/pluma/pluma-transform-llm/src/lib.rs

00_unanchay/pluma/pluma-transform/src/lib.rs

195 archivos · +38852 −0

diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..b7141ea
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,3 @@
+/target
+**/*.rs.bk
+*.pdb
diff --git a/00_unanchay/pluma/LEEME.md b/00_unanchay/pluma/LEEME.md
new file mode 100644
index 0000000..bb46fda
--- /dev/null
+++ b/00_unanchay/pluma/LEEME.md
@@ -0,0 +1,91 @@
+# pluma
+
+> Documentos vivos. Markdown como grafo de átomos editables; LLM como transformador, no como autor.
+
+`pluma` trata un documento como un DAG de párrafos (átomos) con identidad estable. La edición preserva ids; el LLM se invoca como **transformación pura** sobre subgrafos (resumir esta sección, traducir aquel párrafo) — siempre con diff visible y reversible. Incluye notebook (con kernels Python/WASM/LLM/cosmos/dominium), editor visual, deck (slides) y reader web.
+
+## Instalación
+
+```sh
+# editor de markdown (Llimphi desktop)
+cargo run --release -p pluma-app
+
+# notebook
+cargo run --release -p pluma-notebook-app
+
+# reader web (WASM)
+./scripts/build-gioser-web.sh
+```
+
+## Compatibilidad
+
+- **Linux / macOS / Windows** — apps Llimphi nativas.
+- **Wawa** — `pluma` viaja como app del kernel (`03_ukupacha/wawa/apps/pluma/`).
+- **Web** — `pluma-md-reader-web` renderiza markdown en navegador (el reader que usa este sitio).
+
+## Crates
+
+| Crate | Rol |
+|---|---|
+| [`pluma-core`](pluma-core/README.md) | Modelo de documento: átomos, grafo, ids. |
+| [`pluma-cuerpo`](pluma-cuerpo/README.md) | Texto del documento como secuencia de átomos. |
+| [`pluma-store`](pluma-store/README.md) | Persistencia (`$XDG_DATA_HOME/pluma/`). |
+| [`pluma-md`](pluma-md/README.md) | Parser GFM (pulldown-cmark) → HTML temable. |
+| [`pluma-md-reader-web`](pluma-md-reader-web/README.md) | Reader markdown para WASM. |
+| [`pluma-graph`](pluma-graph/README.md) | DAG de átomos con identidad. |
+| [`pluma-graph-transform`](pluma-graph-transform/README.md) | Mutaciones del DAG (insert/mutar/eliminar). |
+| [`pluma-transform`](pluma-transform/README.md) | Marco general de transformaciones puras. |
+| [`pluma-transform-llm`](pluma-transform-llm/README.md) | Transforms LLM (resumir, traducir, ...). |
+| [`pluma-transform-tabla`](pluma-transform-tabla/README.md) | Transforms tabulares. |
+| [`pluma-llm`](pluma-llm/README.md) | Fachada `Arc<dyn ChatClient>` con autodetect. |
+| [`pluma-llm-core`](pluma-llm-core/README.md) | Trait `ChatClient`. |
+| [`pluma-llm-anthropic`](pluma-llm-anthropic/README.md) | Backend Claude API. |
+| [`pluma-llm-gemini`](pluma-llm-gemini/README.md) | Backend Gemini. |
+| [`pluma-llm-cohere`](pluma-llm-cohere/README.md) | Backend Cohere. |
+| [`pluma-llm-openai-compatible`](pluma-llm-openai-compatible/README.md) | OpenAI / DeepSeek / Ollama / proxies. |
+| [`pluma-llm-mock`](pluma-llm-mock/README.md) | Backend mock para tests. |
+| [`pluma-align`](pluma-align/README.md) | Alineamiento texto–texto. |
+| [`pluma-align-embeddings`](pluma-align-embeddings/README.md) | Alineamiento por embeddings. |
+| [`pluma-semantic`](pluma-semantic/README.md) | Anotaciones semánticas del documento. |
+| [`pluma-editor-cuerpo`](pluma-editor-cuerpo/README.md) | Editor texto↔átomos con diff (greedy). |
+| [`pluma-editor-llimphi`](pluma-editor-llimphi/README.md) | Editor visual Llimphi. |
+| [`pluma-app`](pluma-app/README.md) | Binario del editor. |
+| [`pluma-render-plan`](pluma-render-plan/README.md) | Plan de render del documento. |
+| [`pluma-deck-core`](pluma-deck-core/README.md) | Deck (slides) sobre pluma. |
+| [`pluma-deck-web`](pluma-deck-web/README.md) | Deck en navegador. |
+| [`pluma-notebook-core`](pluma-notebook-core/README.md) | Notebook: celdas + outputs addressable. |
+| [`pluma-notebook-store`](pluma-notebook-store/README.md) | Persistencia notebook. |
+| [`pluma-notebook-exec`](pluma-notebook-exec/README.md) | Despacho a kernels. |
+| [`pluma-notebook-kernel-python`](pluma-notebook-kernel-python/README.md) | Python via RustPython/WASM. |
+| [`pluma-notebook-kernel-wasm`](pluma-notebook-kernel-wasm/README.md) | WASM genérico (cranelift AOT). |
+| [`pluma-notebook-kernel-llm`](pluma-notebook-kernel-llm/README.md) | Celdas LLM. |
+| [`pluma-notebook-kernel-cosmos`](pluma-notebook-kernel-cosmos/README.md) | Kernel astronomía (cosmos-sky). |
+| [`pluma-notebook-kernel-dominium`](pluma-notebook-kernel-dominium/README.md) | Kernel simulador (dominium). |
+| [`pluma-notebook-llimphi`](pluma-notebook-llimphi/README.md) | Notebook UI Llimphi. |
+| [`pluma-notebook-graph-llimphi`](pluma-notebook-graph-llimphi/README.md) | Vista grafo del notebook (celdas como nodos). |
+| [`pluma-notebook-app`](pluma-notebook-app/README.md) | Binario del notebook. |
+
+## Estado (2026-05-31)
+
+### Hecho
+
+- Núcleo de documento vivo: `pluma-core`/`pluma-cuerpo`/`pluma-graph` (DAG de átomos con ids estables) + `pluma-graph-transform` + `pluma-store` (sled).
+- Transformaciones puras: `pluma-transform` + `pluma-transform-llm` (resumir/traducir) + `pluma-transform-tabla`, con diff visible y reversible.
+- Fachada LLM `pluma-llm` con autodetect + backends anthropic/gemini/cohere/openai-compatible/mock.
+- Alineación texto-texto (`pluma-align`) + por embeddings (`pluma-align-embeddings`) + anotaciones semánticas (`pluma-semantic`).
+- Editor visual `pluma-editor-llimphi` + binario `pluma-app`; reader web `pluma-md-reader-web`.
+- Notebook: `pluma-notebook-core`/`-exec`/`-store` + UI `pluma-notebook-llimphi` + vista grafo `pluma-notebook-graph-llimphi` + binario. Kernels reales: LLM, cosmos, dominium, media, tinkuy.
+- Deck: `pluma-deck-core`/`-web` + modo Recorrido tipo Prezi (`pluma-deck-recorrido-llimphi`) con autoría completa, persistencia (postcard), camino narrativo visible, modo presentador (autoplay/bucle) y undo/redo; binario `pluma-deck`.
+- Menú principal + menús contextuales cableados en las apps.
+
+### Pendiente
+
+- `pluma-notebook-kernel-python` (RustPython) y `-wasm` (wasmi): cimientos funcionando; falta el camino WASM-AOT cranelift completo y librerías nativas.
+- Puente `foreign-docx`: import/export DOCX aún parcial; resto de la familia `foreign-*` (xlsx/pptx/psd) no en disco.
+- Deuda del deck: split de tullpu + `Camara` (ver PLAN §6.sexies).
+
+## Consideraciones
+
+- **El LLM no escribe; transforma.** No hay "modo redacción libre" — cada llamada devuelve una mutación atómica que el usuario aprueba o rechaza.
+- Los IDs de átomo son la unidad de verdad: rename/move conservan referencias internas y links externos.
+- Kernels del notebook son **WASM-first** (sandboxing del notebook por defecto).
diff --git a/00_unanchay/pluma/PRUEBAS.md b/00_unanchay/pluma/PRUEBAS.md
new file mode 100644
index 0000000..78382ac
--- /dev/null
+++ b/00_unanchay/pluma/PRUEBAS.md
@@ -0,0 +1,390 @@
+# Cómo probar pluma — guía rápida
+
+Pluma es la familia de crates para edición de documentos multilienzo: un
+documento es un *haz de cuerpos* (lienzos) — el original, sus
+traducciones, resúmenes, anotaciones — alineados párrafo a párrafo por
+*hebras*. Esta guía muestra qué correr para ver cada pieza funcionando.
+
+> Última actualización: 2026-05-26. Las versiones de modelo y nombres de
+> env vars están vigentes al cierre de la primera iteración del stack
+> LLM+multilienzo.
+
+## TL;DR
+
+```bash
+# Tests unitarios del haz multilienzo (sin red, sin GUI):
+cargo test \
+  -p pluma-cuerpo \
+  -p pluma-align \
+  -p pluma-align-embeddings \
+  -p pluma-transform \
+  -p pluma-transform-tabla \
+  -p pluma-transform-llm \
+  -p pluma-graph-transform \
+  -p pluma-store \
+  -p pluma-llm-core \
+  -p pluma-llm-mock \
+  -p pluma-llm-anthropic \
+  -p pluma-llm-gemini \
+  -p pluma-llm-openai-compatible \
+  -p pluma-llm \
+  -p pluma-notebook-kernel-llm \
+  -p pluma-editor-llimphi
+
+# Demo visual sin red ni keys (mock pre-poblado):
+cargo run -p pluma-editor-llimphi --example multilienzo_demo --release
+```
+
+Lo demás en esta guía es para ir cubriendo el resto del stack en
+profundidad y/o usar IAs reales.
+
+## 1. Validar tu API key contra un servicio real (gasto mínimo)
+
+Antes de meterse a generar un multilienzo entero, corré el smoke de
+Gemini para confirmar que tu key funciona. Una sola request,
+~30 tokens, fracción de centavo:
+
+```bash
+GEMINI_API_KEY=tu_key cargo run \
+  -p pluma-llm-gemini --example smoke --release
+```
+
+Salida esperada:
+
+```
+smoke :: usando modelo gemini-2.5-flash
+respuesta: Rimay
+tokens: input=28 output=2 cache_read=0 cache_creation=0
+stop_reason: MAX_TOKENS
+```
+
+Existe el mismo patrón para los otros backends en cuanto se sumen
+smokes — Anthropic y DeepSeek implementan la misma `ChatClient` y se
+prueban igual contra sus respectivas API keys.
+
+## 2. El multilienzo visual
+
+Tres demos visuales del editor, todos ejecutables sin recompilar entre
+ellos:
+
+### 2.1 Estático — datos hardcoded
+
+Lo más sencillo. No necesita ni LLM ni embeddings.
+
+```bash
+cargo run -p pluma-editor-llimphi --example multilienzo_demo --release
+```
+
+Tres cuerpos (`es` / `qu` runa simi / `en` resumen) con los cuatro
+estados de hebra: Derivada fresca verde, Embeddings azul atenuado por
+fuerza, Manual ámbar, Stale gris punteado.
+
+### 2.2 LLM-driven — un solo flujo, cinco backends
+
+El factory `pluma_llm::from_env` elige el backend según env vars.
+Cambiar de IA = una variable.
+
+```bash
+# Sin keys → mock predecible con traducciones hardcoded:
+cargo run -p pluma-editor-llimphi --example multilienzo_llm_demo --release
+
+# Gemini real:
+GEMINI_API_KEY=... PLUMA_LLM_BACKEND=gemini \
+  cargo run -p pluma-editor-llimphi --example multilienzo_llm_demo --release
+
+# Anthropic:
+ANTHROPIC_API_KEY=sk-ant-... \
+  cargo run -p pluma-editor-llimphi --example multilienzo_llm_demo --release
+
+# DeepSeek:
+DEEPSEEK_API_KEY=... PLUMA_LLM_BACKEND=deepseek \
+  cargo run -p pluma-editor-llimphi --example multilienzo_llm_demo --release
+
+# Ollama 100% local (requiere `ollama serve` y el modelo pulled):
+PLUMA_LLM_BACKEND=ollama PLUMA_LLM_MODEL=llama3.1 \
+  cargo run -p pluma-editor-llimphi --example multilienzo_llm_demo --release
+```
+
+Para que las hebras `qu↔en` sean semánticamente verdaderas (no
+random del mock), levanta el embedder global en paralelo:
+
+```bash
+verbo-daemon --provider fastembed &
+# (descarga ~120 MB de multilingual-e5-small la primera vez)
+
+# después correr cualquiera de los demos
+```
+
+El demo detecta el socket en `$XDG_RUNTIME_DIR/verbo.sock` y se conecta
+automáticamente.
+
+### 2.3 Dinámico — botones de transformación
+
+Toolbar con cuatro botones (`→ qu`, `→ en`, `tono formal`, `resumir 30p`).
+Click → spawn thread con runtime tokio efímero → LLM transparente →
+columna nueva al volver. Un trabajo a la vez (los botones quedan
+deshabilitados durante la ejecución).
+
+```bash
+GEMINI_API_KEY=... PLUMA_LLM_BACKEND=gemini \
+  cargo run -p pluma-editor-llimphi \
+  --example multilienzo_dinamico_demo --release
+```
+
+### 2.3.0 Importar un archivo `.docx` como cuerpo madre
+
+```rust
+use foreign_docx::parse_docx;
+let bytes = std::fs::read("informe.docx")?;
+let imp = parse_docx(&bytes, "es", "informe.docx", ahora_unix())?;
+// imp.cuerpo: Original, branch_id "es", lengua None
+// imp.atoms: un NarrativeAtom por <w:p> con texto no vacío
+```
+
+Mismo shape que `pluma_md::DocumentoImportado` para que el caller los
+trate uniforme. Formato Word (negrita, cursiva, estilos, headers,
+footers, tablas, comments) se descarta — solo contenido legible.
+
+### 2.3.1 Importar un archivo `.md` como cuerpo madre
+
+```rust
+use pluma_md::parse_md;
+let texto = std::fs::read_to_string("notas.md")?;
+let imp = parse_md(&texto, "es", "notas.md", ahora_unix());
+// imp.atoms: un NarrativeAtom por bloque (párrafo, lista, encabezado…)
+// imp.cuerpo: Intencion::Original con todos los atoms en orden.
+for atom in &imp.atoms {
+    graph.insert(atom.clone());
+}
+// Pasar imp.cuerpo como madre a cualquier ejecutor LLM.
+```
+
+Formato inline (negrita, cursiva, code inline) se aplana — el LLM
+recibe texto limpio. Encabezados preservan jerarquía vía prefijo `# `,
+`## `, etc.
+
+### 2.4 Completo — toolbar dinámica CON persistencia + focus + búsqueda
+
+El más cercano a "app real": botones LLM como en 2.3, pero CADA
+transformación se persiste en `~/.cache/gioser/pluma-multilienzo-completo/`
+antes de mostrarse. Cierra el demo, volvé a abrirlo: cuerpos y hebras
+siguen ahí + podés seguir generando.
+
+```bash
+GEMINI_API_KEY=... PLUMA_LLM_BACKEND=gemini \
+  cargo run -p pluma-editor-llimphi \
+  --example multilienzo_completo_demo --release
+
+# Reset:
+MULTILIENZO_COMPLETO_RESET=1 cargo run -p pluma-editor-llimphi \
+  --example multilienzo_completo_demo --release
+```
+
+Tras unas cuantas corridas tendrás un haz crecido: una madre `es` con
+varias derivadas (`qu`, `en`, formal, resumen). El editor las muestra
+todas alineadas por hebras Derivadas 1↔1.
+
+**Atajos y botones del demo completo**:
+
+| Acción | Cómo |
+|---|---|
+| Derivar cuerpo nuevo | Botones `→ qu` · `→ en` · `tono formal` · `resumir 30p` |
+| Editar la madre | Botón `editar madre` (anexa marca incremental al 1er párrafo) |
+| Marcar todas las hijas stale | Botón `tocar madre` |
+| Regenerar hija stale | Botón `regenerar stale (N)` — una a la vez |
+| Cambiar IA en runtime | Botón `modelo: X` — cicla por los 6 backends |
+| Scroll horizontal | `Shift + rueda del mouse` · o eje X de touchpad |
+| Focus mode | Botón `solo madre` / `todos` |
+| Búsqueda transversal | Tipeá cualquier texto · `Backspace` borra · `Esc` limpia |
+| Persistencia UI | Automática: scroll/focus/búsqueda se restauran al reabrir |
+| Reset cache | `MULTILIENZO_COMPLETO_RESET=1` en el env |
+
+### 2.5 Persistente — sobrevive entre corridas
+
+Primera vez: genera y guarda en `~/.cache/gioser/pluma-multilienzo/`.
+Siguientes: lee y muestra instantáneo, sin red.
+
+```bash
+# Primera corrida — pega al LLM:
+GEMINI_API_KEY=... PLUMA_LLM_BACKEND=gemini \
+  cargo run -p pluma-editor-llimphi \
+  --example multilienzo_store_demo --release
+
+# Segunda corrida — sin red ni tokens:
+cargo run -p pluma-editor-llimphi --example multilienzo_store_demo --release
+
+# Resetear el cache:
+MULTILIENZO_RESET=1 cargo run -p pluma-editor-llimphi \
+  --example multilienzo_store_demo --release
+```
+
+## 3. El stack LLM por crate (sin GUI)
+
+Si querés ver cómo encajan las piezas sin abrir una ventana, los tests
+unitarios cuentan bien la historia. Cada crate tiene su `pruebas` mod
+con happy path + failure modes:
+
+```bash
+# El contrato + tipos:
+cargo test -p pluma-llm-core
+
+# Determinista para tests:
+cargo test -p pluma-llm-mock
+
+# Cada backend:
+cargo test -p pluma-llm-anthropic
+cargo test -p pluma-llm-gemini
+cargo test -p pluma-llm-openai-compatible
+
+# Fachada / factory:
+cargo test -p pluma-llm
+
+# Ejecutores de transformación:
+cargo test -p pluma-transform-tabla
+cargo test -p pluma-transform-llm
+
+# Pegamento con el grafo y la store:
+cargo test -p pluma-graph-transform
+cargo test -p pluma-store
+
+# Notebook con kernel LLM:
+cargo test -p pluma-notebook-kernel-llm
+```
+
+## 4. Embeddings: provider real vs mock
+
+`pluma-align-embeddings` calcula hebras `qu↔en` (o cualquier par) con
+cualquier `rimay_verbo_core::Provider`. Tres formas de servirlo:
+
+```bash
+# Mock determinista (sin descarga, sin red, vectores random estables):
+verbo-daemon --provider mock
+
+# BGE local sin API key (descarga ~120 MB la primera vez):
+verbo-daemon --provider fastembed
+
+# Otro socket o dimensión:
+verbo-daemon --provider mock --socket /tmp/mi.sock --dim 768
+```
+
+Y en la app:
+
+```rust
+let daemon = DaemonClient::connect("$XDG_RUNTIME_DIR/verbo.sock").await?;
+let carta = alinear_por_embeddings(&qu, &en, &idx, &daemon, &params, ahora).await?;
+```
+
+Cualquier consumidor de `&dyn Provider` (pluma-semantic, chasqui-core,
+khipu) habla igual.
+
+## 5. Notebook + LLM — demo CLI ejecutable
+
+`notebook_llm_demo` arma un notebook con cuatro celdas (markdown fuente
++ traducir-qu + tono-formal + resumir-20), corre `run_all` con el
+`LlmKernel`, e imprime los outputs por consola. No usa ventana — útil
+para verificar que el flujo entero funciona en un servidor sin GUI.
+
+```bash
+# Sin keys (mock que diferencia acciones por system):
+cargo run -p pluma-notebook-kernel-llm --example notebook_llm_demo --release
+
+# Con Gemini:
+GEMINI_API_KEY=... PLUMA_LLM_BACKEND=gemini \
+  cargo run -p pluma-notebook-kernel-llm --example notebook_llm_demo --release
+
+# Con Ollama:
+PLUMA_LLM_BACKEND=ollama PLUMA_LLM_MODEL=llama3.1 \
+  cargo run -p pluma-notebook-kernel-llm --example notebook_llm_demo --release
+```
+
+Salida esperada (modo mock):
+
+```
+notebook_llm_demo :: LLM = mock-nb
+=== ejecución ===
+ejecutadas: 4   fallidas: 0   skipped: 0
+=== outputs ===
+[1/markdown] sin output
+[2/llm-traducir-qu]     Kuntur wayqu hanaqpachatakta…
+[3/llm-tono-formal]     El cóndor surcó con majestuosidad…
+[4/llm-resumir-20]      Amanecer andino: cóndor, llamas, tejedora.
+```
+
+## 5.bis Notebook + LLM — uso programático
+
+```rust
+use pluma_llm::{build_client, BackendKind, LlmConfig};
+use pluma_notebook_kernel_llm::LlmKernel;
+use pluma_notebook_exec::run_all;
+
+let chat = build_client(&LlmConfig {
+    kind: BackendKind::Gemini,
+    ..Default::default()
+})?;
+let kernel = LlmKernel::from_arc(chat);
+
+let mut notebook = /* armar celdas con language="llm-traducir-qu", etc. */;
+let reporte = run_all(&mut notebook, &kernel).await.unwrap();
+println!("ejecutadas: {} | fallidas: {}", reporte.executed.len(), reporte.failed.len());
+```
+
+Lenguajes que `LlmKernel` entiende:
+
+| `language`             | Qué hace                                              |
+|------------------------|-------------------------------------------------------|
+| `llm-prompt`           | El source ES el prompt completo. Sin system.          |
+| `llm-traducir-{LANG}`  | Traduce al idioma dado (qu, en, fr…).                 |
+| `llm-tono-{ETIQUETA}`  | Reescribe con tono (formal, casual, infantil…).       |
+| `llm-resumir`          | Resumen libre.                                        |
+| `llm-resumir-{N}`      | Resumen a aproximadamente N palabras.                 |
+| `llm-reescribir`       | Primera línea del source = prompt, resto = texto.     |
+
+## 6. Mapa del stack
+
+```
+pluma-core           NarrativeAtom + coherencia
+pluma-graph          NarrativeGraph (DAG con propagación PendingEvaluation)
+pluma-cuerpo         Cuerpo + MetaCuerpo + Intencion
+pluma-align          Alineamiento + CartaHebras + alineadores manuales
+pluma-align-embeddings    alineador semántico async via Provider
+pluma-transform      trait Ejecutor async + EjecutorIdentidad
+pluma-transform-tabla     EjecutorTraducirTabla (tabla explícita)
+pluma-transform-llm  Ejecutor{Traducir,Tono,Resumir,Reescribir}Llm
+pluma-graph-transform  indice_atoms + persistir_producto (pegamento)
+pluma-store          PlumaStore (atoms+cuerpos+transformaciones+cartas+EstadoUi en sled)
+pluma-editor-llimphi::multilienzo  vista columnas+carriles+hebras
+pluma-editor-llimphi::cuerpo_ide   text-editor IDE de Llimphi sobre EditorCuerpo
+                                   (apply_key → buffer; diff → CambioAtom)
+pluma-editor-cuerpo  sincronia cuerpo ↔ buffer plano (diff Mutar/Crear/Eliminar)
+                     reusa Uuids al mutar → hebras vivas tras edit
+shared/foreign-docx  importar .docx como cuerpo madre
+
+pluma-llm-core       trait ChatClient (contrato agnóstico de proveedor)
+pluma-llm-mock       determinista para tests
+pluma-llm-anthropic  Claude con prompt caching del system
+pluma-llm-gemini     Gemini con cachedContentTokenCount
+pluma-llm-cohere     Cohere Command (Anthropic-shape de response)
+pluma-llm-openai-compatible  DeepSeek + Ollama + Groq/Together/vLLM
+pluma-llm            fachada transparente: build_client(&cfg)/from_env
+
+pluma-notebook-{core,exec,store}  notebook reactivo (DAG de celdas)
+pluma-notebook-kernel-llm  conecta el notebook al LLM transparente
+pluma-notebook-kernel-{python,wasm}  los otros kernels
+
+rimay-verbo-{core,mock,daemon,daemon-bin,fastembed}  embedder global
+```
+
+## 7. Variables de entorno reconocidas
+
+| Variable | Quién | Para qué |
+|---|---|---|
+| `PLUMA_LLM_BACKEND` | `pluma_llm::from_env` | `anthropic`/`gemini`/`deepseek`/`ollama`/`mock` |
+| `PLUMA_LLM_MODEL` | `pluma_llm::from_env` | sobrescribe el default del backend |
+| `PLUMA_LLM_ENDPOINT` | `pluma_llm::from_env` | proxy interno o endpoint custom |
+| `ANTHROPIC_API_KEY` | `pluma-llm-anthropic` | Claude |
+| `GEMINI_API_KEY` o `GOOGLE_API_KEY` | `pluma-llm-gemini` | Gemini AI Studio |
+| `DEEPSEEK_API_KEY` | `pluma-llm-openai-compatible` | DeepSeek |
+| `COHERE_API_KEY` | `pluma-llm-cohere` | Cohere Command |
+| `MULTILIENZO_RESET` | `multilienzo_store_demo` | `=1` para limpiar el cache |
+| `XDG_RUNTIME_DIR` | `verbo-daemon` y clientes | path del socket del embedder |
+| `XDG_CACHE_HOME` | `multilienzo_store_demo` | base del cache de PlumaStore |
diff --git a/00_unanchay/pluma/README.md b/00_unanchay/pluma/README.md
new file mode 100644
index 0000000..a7464c3
--- /dev/null
+++ b/00_unanchay/pluma/README.md
@@ -0,0 +1,46 @@
+# pluma
+
+> Living documents. Markdown as a graph of editable atoms; LLM as transformer, not author.
+
+`pluma` treats a document as a DAG of paragraphs (atoms) with stable identity. Editing preserves ids; the LLM is invoked as **pure transformation** over subgraphs (summarize this section, translate that paragraph) — always with visible, reversible diff. Includes notebook (with Python/WASM/LLM/cosmos/dominium kernels), visual editor, deck (slides) and web reader.
+
+## Install
+
+```sh
+# markdown editor (Llimphi desktop)
+cargo run --release -p pluma-app
+
+# notebook
+cargo run --release -p pluma-notebook-app
+
+# web reader (WASM)
+./scripts/build-gioser-web.sh
+```
+
+## Compatibility
+
+- **Linux / macOS / Windows** — native Llimphi apps.
+- **Wawa** — `pluma` ships as a kernel app (`03_ukupacha/wawa/apps/pluma/`).
+- **Web** — `pluma-md-reader-web` renders markdown in the browser (the reader this site uses).
+
+## Crates
+
+Core + parser: `pluma-core`, `pluma-cuerpo`, `pluma-store`, `pluma-md`, `pluma-md-reader-web`, `pluma-graph`, `pluma-graph-transform`, `pluma-semantic`, `pluma-align`, `pluma-align-embeddings`, `pluma-render-plan`.
+
+Transforms: `pluma-transform`, `pluma-transform-llm`, `pluma-transform-tabla`.
+
+LLM facade: `pluma-llm`, `pluma-llm-core`, `pluma-llm-anthropic`, `pluma-llm-gemini`, `pluma-llm-cohere`, `pluma-llm-openai-compatible`, `pluma-llm-mock`.
+
+Editor: `pluma-editor-cuerpo`, `pluma-editor-llimphi`, `pluma-app`.
+
+Deck: `pluma-deck-core`, `pluma-deck-web`.
+
+Notebook: `pluma-notebook-{core,store,exec,llimphi,graph-llimphi,app,kernel-python,kernel-wasm,kernel-llm,kernel-cosmos,kernel-dominium}`.
+
+Full table in [README.md](README.md).
+
+## Considerations
+
+- **The LLM doesn't write; it transforms.** No "free writing mode" — each call returns an atomic mutation that the user approves or rejects.
+- Atom IDs are the unit of truth: rename/move preserves internal refs and outside links.
+- Notebook kernels are **WASM-first** (notebook sandboxing by default).
diff --git a/00_unanchay/pluma/README.qu.md b/00_unanchay/pluma/README.qu.md
new file mode 100644
index 0000000..51cfacc
--- /dev/null
+++ b/00_unanchay/pluma/README.qu.md
@@ -0,0 +1,43 @@
+<!-- Quechua (Cusco/Collao). Revisión bienvenida. -->
+
+# pluma
+
+> Kawsasqa qillqakuna. Markdown — átomu-grafu hina, LLM transformadormi, manataq autor.
+
+`pluma` qillqasqata DAG hina qhawan: párrafokuna (átomokuna) sumaq sutiyuq. Tikraspa idskuna waqaychasqa; LLM **ch'uya transformación** hina sub-grafukunapi qharin (kay parte huñuy, chay párrafo tikray) — qhawana diff-wan, kutichinapaq atisqa. Notebook (Python/WASM/LLM/cosmos/dominium kernels), rikuna editor, deck (slides), web reader.
+
+## Churay
+
+```sh
+# markdown editor (Llimphi desktop)
+cargo run --release -p pluma-app
+
+# notebook
+cargo run --release -p pluma-notebook-app
+
+# web reader (WASM)
+./scripts/build-gioser-web.sh
+```
+
+## Tinkuy
+
+- **Linux / macOS / Windows** — Llimphi natural apps.
+- **Wawa** — `pluma` kernel-pa apps-nin hina (`03_ukupacha/wawa/apps/pluma/`).
+- **Web** — `pluma-md-reader-web` markdown navegador ukhupi riqsichiq (kay sitio chayta usanqa).
+
+## Crateskuna
+
+Sumaq tabla [README.md](README.md)-pi. Familiakuna:
+
+- **Core + parser**: `pluma-core`, `pluma-cuerpo`, `pluma-md`, `pluma-md-reader-web`, `pluma-graph`, `pluma-semantic`, `pluma-align*`, `pluma-render-plan`, `pluma-store`.
+- **Transforms**: `pluma-transform`, `pluma-transform-llm`, `pluma-transform-tabla`.
+- **LLM**: `pluma-llm`, `pluma-llm-{core,anthropic,gemini,cohere,openai-compatible,mock}`.
+- **Editor**: `pluma-editor-{cuerpo,llimphi}`, `pluma-app`.
+- **Deck**: `pluma-deck-{core,web}`.
+- **Notebook**: `pluma-notebook-{core,store,exec,llimphi,graph-llimphi,app}` + `pluma-notebook-kernel-{python,wasm,llm,cosmos,dominium}`.
+
+## Yuyaykunaq
+
+- **LLM manan qillqaqchu; tikraqmi.** Mana "ch'iqaq qillqana modo"; sapanka llamayqa atómica mutación kutichin, runa allichaspa utaq mana munaspa.
+- Átomu IDmi cheqaq unidad: tikrakuna sutikunata waqaychan, ukhupi rikchakuna hawapi linkkuna ima.
+- Notebook kernelkuna **WASM-ñawpaq** (notebook sandbox kasqa).
diff --git a/00_unanchay/pluma/foreign-docx/Cargo.toml b/00_unanchay/pluma/foreign-docx/Cargo.toml
new file mode 100644
index 0000000..9f8df63
--- /dev/null
+++ b/00_unanchay/pluma/foreign-docx/Cargo.toml
@@ -0,0 +1,16 @@
+[package]
+name = "foreign-docx"
+version.workspace = true
+edition.workspace = true
+license.workspace = true
+authors.workspace = true
+publish.workspace = true
+description = "pluma/foreign-docx — importa `.docx` (Word 2007+ y compatibles) como cuerpo madre del multilienzo. Un párrafo `<w:p>` del XML → un NarrativeAtom; runs `<w:t>` concatenados, formato (negrita/cursiva) descartado."
+
+[dependencies]
+zip = { version = "2", default-features = false, features = ["deflate"] }
+quick-xml = { workspace = true }
+thiserror = { workspace = true }
+pluma-core = { path = "../pluma-core" }
+pluma-cuerpo = { path = "../pluma-cuerpo" }
+uuid = { workspace = true }
diff --git a/00_unanchay/pluma/foreign-docx/src/lib.rs b/00_unanchay/pluma/foreign-docx/src/lib.rs
new file mode 100644
index 0000000..9fb4d96
--- /dev/null
+++ b/00_unanchay/pluma/foreign-docx/src/lib.rs
@@ -0,0 +1,460 @@
+//! `foreign-docx` — importa archivos `.docx` (Office Open XML) como
+//! cuerpos madre del multilienzo de pluma.
+//!
+//! Un `.docx` es un zip que contiene `word/document.xml` con el cuerpo
+//! del documento serializado en XML. Este crate hace lo mínimo
+//! necesario: descomprime, abre `document.xml`, recorre los párrafos
+//! `<w:p>`, junta los runs `<w:t>` de cada uno, y produce un
+//! `NarrativeAtom` por párrafo.
+//!
+//! NO interpreta formato (negrita, cursiva, color), estilos, headers,
+//! footers, tablas, comments, ni elementos avanzados. La meta es
+//! ingestar el contenido legible — quien quiera fidelidad de formato
+//! debería trabajar el doc en su editor nativo, no traerlo a pluma.
+//!
+//! ## Ejemplo
+//!
+//! ```no_run
+//! use foreign_docx::parse_docx;
+//! let bytes = std::fs::read("informe.docx").unwrap();
+//! let imp = parse_docx(&bytes, "es", "informe.docx", 0).unwrap();
+//! // imp.cuerpo: Intencion::Original
+//! // imp.atoms: un NarrativeAtom por párrafo del .docx
+//! ```
+
+#![forbid(unsafe_code)]
+
+use std::collections::HashMap;
+use std::io::{Cursor, Read, Write};
+
+use quick_xml::events::Event;
+use quick_xml::reader::Reader;
+use thiserror::Error;
+use uuid::Uuid;
+
+use pluma_core::NarrativeAtom;
+use pluma_cuerpo::{Cuerpo, Intencion};
+
+/// Resultado del import: el cuerpo madre + sus átomos. Misma shape que
+/// `pluma_md::DocumentoImportado` para que los demos puedan tratar
+/// ambos igual.
+#[derive(Debug)]
+pub struct DocumentoImportado {
+    pub cuerpo: Cuerpo,
+    pub atoms: Vec<NarrativeAtom>,
+}
+
+#[derive(Debug, Error)]
+pub enum DocxError {
+    #[error("no es un zip válido (.docx): {0}")]
+    Zip(#[from] zip::result::ZipError),
+    #[error("falta `word/document.xml` en el archivo")]
+    DocumentoFaltante,
+    #[error("lectura del archivo interno falló: {0}")]
+    Io(#[from] std::io::Error),
+    #[error("XML malformado: {0}")]
+    Xml(#[from] quick_xml::Error),
+}
+
+/// Importa un `.docx` (bytes crudos del archivo, p. ej. devuelto por
+/// `std::fs::read`) como cuerpo Original. `branch_id`, `nombre` y
+/// `ahora` se anotan en `MetaCuerpo`.
+pub fn parse_docx(
+    bytes: &[u8],
+    branch_id: impl Into<String>,
+    nombre: impl Into<String>,
+    ahora: u64,
+) -> Result<DocumentoImportado, DocxError> {
+    let cursor = Cursor::new(bytes);
+    let mut zip = zip::ZipArchive::new(cursor)?;
+    let mut xml = String::new();
+    {
+        let mut entry = zip
+            .by_name("word/document.xml")
+            .map_err(|_| DocxError::DocumentoFaltante)?;
+        entry.read_to_string(&mut xml)?;
+    }
+    let parrafos = extraer_parrafos(&xml)?;
+
+    let branch = branch_id.into();
+    let mut cuerpo = Cuerpo::nuevo(branch.clone(), nombre, Intencion::Original, ahora);
+    let mut atoms = Vec::with_capacity(parrafos.len());
+    for texto in parrafos {
+        let texto = texto.trim();
+        if texto.is_empty() {
+            continue;
+        }
+        let atom = NarrativeAtom::new(texto.to_string(), &branch);
+        cuerpo.agregar(atom.id, ahora);
+        atoms.push(atom);
+    }
+    Ok(DocumentoImportado { cuerpo, atoms })
+}
+
+/// Parser SAX mínimo: cada `<w:p>` abre un buffer; cada `<w:t>` añade
+/// su texto; `</w:p>` cierra y empuja al output.
+///
+/// Reconocemos tanto `w:t` como `t` (algunos generadores omiten el
+/// prefijo; el namespace queda igual al ser el default del documento).
+/// Saltos `<w:br/>` se ignoran — quedan como espacio implícito entre
+/// los runs adyacentes.
+fn extraer_parrafos(xml: &str) -> Result<Vec<String>, quick_xml::Error> {
+    let mut reader = Reader::from_str(xml);
+    reader.trim_text(false);
+
+    let mut parrafos = Vec::new();
+    let mut buf = String::new();
+    let mut en_parrafo = false;
+    let mut en_text = false;
+
+    loop {
+        match reader.read_event() {
+            Ok(Event::Start(e)) => {
+                let name = e.name();
+                let local = nombre_local(name.as_ref());
+                if local == b"p" {
+                    en_parrafo = true;
+                    buf.clear();
+                } else if en_parrafo && local == b"t" {
+                    en_text = true;
+                }
+            }
+            Ok(Event::End(e)) => {
+                let name = e.name();
+                let local = nombre_local(name.as_ref());
+                if local == b"p" {
+                    parrafos.push(buf.clone());
+                    buf.clear();
+                    en_parrafo = false;
+                } else if local == b"t" {
+                    en_text = false;
+                }
+            }
+            Ok(Event::Text(e)) => {
+                if en_parrafo && en_text {
+                    let s = e.unescape()?;
+                    buf.push_str(s.as_ref());
+                }
+            }
+            // Word usa `<w:br/>` para saltos de línea suaves dentro de
+            // un párrafo. Lo tratamos como espacio para no concatenar.
+            Ok(Event::Empty(e)) => {
+                let name = e.name();
+                let local = nombre_local(name.as_ref());
+                if en_parrafo && local == b"br" {
+                    if !buf.ends_with(' ') && !buf.is_empty() {
+                        buf.push(' ');
+                    }
+                }
+            }
+            Ok(Event::Eof) => break,
+            Err(e) => return Err(e),
+            _ => {}
+        }
+    }
+    Ok(parrafos)
+}
+
+/// Exporta `cuerpo` + atoms como un `.docx` mínimo válido (zip con
+/// `[Content_Types].xml`, `_rels/.rels`, `word/_rels/document.xml.rels`
+/// y `word/document.xml`). Suficiente para que Word, LibreOffice y
+/// nuestro propio `parse_docx` lo abran sin quejarse.
+///
+/// Cada atom del `cuerpo.orden` se vuelca como un `<w:p>` con un solo
+/// `<w:r><w:t xml:space="preserve">…</w:t></w:r>`. Sin formato,
+/// estilos, headings (los prefijos `# `/`## ` quedan como texto crudo
+/// del párrafo — no se traducen a `pStyle="Heading1"` etc.). Lossy en
+/// formato es deliberado: la idea del exportador es entregar el
+/// contenido alineable, no recrear la apariencia del Word de turno.
+///
+/// Atoms ausentes del índice se saltan en silencio (igual semántica que
+/// [`pluma_md::to_md`]).
+pub fn write_docx(
+    cuerpo: &Cuerpo,
+    atoms: &HashMap<Uuid, NarrativeAtom>,
+) -> Result<Vec<u8>, DocxError> {
+    let mut buf = Vec::new();
+    {
+        let mut w = zip::ZipWriter::new(Cursor::new(&mut buf));
+        let opts: zip::write::SimpleFileOptions = Default::default();
+
+        // [Content_Types].xml — declara el tipo MIME del documento.
+        w.start_file("[Content_Types].xml", opts)?;
+        w.write_all(CONTENT_TYPES_XML.as_bytes())?;
+
+        // _rels/.rels — el package relationship que apunta al document.
+        w.start_file("_rels/.rels", opts)?;
+        w.write_all(PACKAGE_RELS_XML.as_bytes())?;
+
+        // word/_rels/document.xml.rels — vacío pero presente, Word/LO
+        // lo esperan para abrir el archivo.
+        w.start_file("word/_rels/document.xml.rels", opts)?;
+        w.write_all(DOCUMENT_RELS_XML.as_bytes())?;
+
+        // word/document.xml — el contenido.
+        let mut xml = String::from(DOCUMENT_HEAD);
+        for atom_id in &cuerpo.orden {
+            let Some(atom) = atoms.get(atom_id) else {
+                continue;
+            };
+            xml.push_str("<w:p><w:r><w:t xml:space=\"preserve\">");
+            xml.push_str(&escapar_xml(&atom.content));
+            xml.push_str("</w:t></w:r></w:p>");
+        }
+        // Si el cuerpo está vacío, igual escribimos un párrafo vacío —
+        // un .docx sin <w:p> a veces lo rechazan parsers estrictos.
+        if cuerpo.orden.is_empty() || cuerpo.orden.iter().all(|id| !atoms.contains_key(id)) {
+            xml.push_str("<w:p/>");
+        }
+        xml.push_str(DOCUMENT_TAIL);
+
+        w.start_file("word/document.xml", opts)?;
+        w.write_all(xml.as_bytes())?;
+        w.finish()?;
+    }
+    Ok(buf)
+}
+
+/// Misma lógica que [`write_docx`] pero con índice por `&NarrativeAtom`
+/// — útil para callers que tienen prestados los atoms (mismo patrón
+/// que `pluma_md::to_md_borrow`).
+pub fn write_docx_borrow(
+    cuerpo: &Cuerpo,
+    atoms: &HashMap<Uuid, &NarrativeAtom>,
+) -> Result<Vec<u8>, DocxError> {
+    let mut buf = Vec::new();
+    {
+        let mut w = zip::ZipWriter::new(Cursor::new(&mut buf));
+        let opts: zip::write::SimpleFileOptions = Default::default();
+
+        w.start_file("[Content_Types].xml", opts)?;
+        w.write_all(CONTENT_TYPES_XML.as_bytes())?;
+
+        w.start_file("_rels/.rels", opts)?;
+        w.write_all(PACKAGE_RELS_XML.as_bytes())?;
+
+        w.start_file("word/_rels/document.xml.rels", opts)?;
+        w.write_all(DOCUMENT_RELS_XML.as_bytes())?;
+
+        let mut xml = String::from(DOCUMENT_HEAD);
+        for atom_id in &cuerpo.orden {
+            let Some(atom) = atoms.get(atom_id) else {
+                continue;
+            };
+            xml.push_str("<w:p><w:r><w:t xml:space=\"preserve\">");
+            xml.push_str(&escapar_xml(&atom.content));
+            xml.push_str("</w:t></w:r></w:p>");
+        }
+        if cuerpo.orden.is_empty() || cuerpo.orden.iter().all(|id| !atoms.contains_key(id)) {
+            xml.push_str("<w:p/>");
+        }
+        xml.push_str(DOCUMENT_TAIL);
+
+        w.start_file("word/document.xml", opts)?;
+        w.write_all(xml.as_bytes())?;
+        w.finish()?;
+    }
+    Ok(buf)
+}
+
+const CONTENT_TYPES_XML: &str = r#"<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
+<Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types">
+  <Default Extension="rels" ContentType="application/vnd.openxmlformats-package.relationships+xml"/>
+  <Default Extension="xml" ContentType="application/xml"/>
+  <Override PartName="/word/document.xml" ContentType="application/vnd.openxmlformats-officedocument.wordprocessingml.document.main+xml"/>
+</Types>"#;
+
+const PACKAGE_RELS_XML: &str = r#"<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
+<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">
+  <Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument" Target="word/document.xml"/>
+</Relationships>"#;
+
+const DOCUMENT_RELS_XML: &str = r#"<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
+<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">
+</Relationships>"#;
+
+const DOCUMENT_HEAD: &str = r#"<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
+<w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main">
+  <w:body>"#;
+
+const DOCUMENT_TAIL: &str = r#"</w:body>
+</w:document>"#;
+
+/// Escapa los cinco caracteres XML que romperían el documento. Word es
+/// flexible con & y < dentro de `xml:space="preserve"`, pero los
+/// parsers strictos no.
+fn escapar_xml(s: &str) -> String {
+    let mut out = String::with_capacity(s.len());
+    for c in s.chars() {
+        match c {
+            '&' => out.push_str("&amp;"),
+            '<' => out.push_str("&lt;"),
+            '>' => out.push_str("&gt;"),
+            '"' => out.push_str("&quot;"),
+            '\'' => out.push_str("&apos;"),
+            _ => out.push(c),
+        }
+    }
+    out
+}
+
+/// Quita el prefijo de namespace (`w:p` → `p`). Acepta tanto QName con
+/// prefijo (`w:p`) como sin (`p`). El default-ns del documento Word es
+/// el namespace `w`, pero hay docs ajenos sin prefijo — soportamos los
+/// dos.
+fn nombre_local(qname: &[u8]) -> &[u8] {
+    match qname.iter().position(|b| *b == b':') {
+        Some(i) => &qname[i + 1..],
+        None => qname,
+    }
+}
+
+#[cfg(test)]
+mod pruebas {
+    use super::*;
+    use std::io::Write;
+
+    /// Crea un `.docx` mínimo en memoria con los párrafos dados.
+    /// Genera solo el `[Content_Types].xml` mínimo, `_rels/.rels`,
+    /// `word/_rels/document.xml.rels` y `word/document.xml` con los
+    /// párrafos. Suficiente para que ZipArchive lo abra y nuestro
+    /// parser extraiga el contenido.
+    fn docx_de(parrafos: &[&str]) -> Vec<u8> {
+        let mut buf = Vec::new();
+        {
+            let mut w = zip::ZipWriter::new(Cursor::new(&mut buf));
+            let opts: zip::write::SimpleFileOptions = Default::default();
+            // Content_Types mínimo. Word lo requiere para abrir el doc,
+            // pero nuestro parser no lo consulta — basta con que el zip
+            // tenga la entrada `word/document.xml`.
+            w.start_file("[Content_Types].xml", opts).unwrap();
+            w.write_all(
+                br#"<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
+<Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types">
+  <Default Extension="xml" ContentType="application/xml"/>
+  <Override PartName="/word/document.xml" ContentType="application/vnd.openxmlformats-officedocument.wordprocessingml.document.main+xml"/>
+</Types>"#,
+            )
+            .unwrap();
+            // El documento.
+            let mut xml = String::from(
+                r#"<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
+<w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main">
+  <w:body>"#,
+            );
+            for p in parrafos {
+                xml.push_str(&format!(
+                    "<w:p><w:r><w:t xml:space=\"preserve\">{}</w:t></w:r></w:p>",
+                    p
+                ));
+            }
+            xml.push_str("</w:body></w:document>");
+            w.start_file("word/document.xml", opts).unwrap();
+            w.write_all(xml.as_bytes()).unwrap();
+            w.finish().unwrap();
+        }
+        buf
+    }
+
+    #[test]
+    fn parrafos_se_separan_en_atoms() {
+        let docx = docx_de(&["Primero.", "Segundo.", "Tercero."]);
+        let imp = parse_docx(&docx, "es", "test.docx", 1).unwrap();
+        assert_eq!(imp.atoms.len(), 3);
+        assert_eq!(imp.atoms[0].content.as_str(), "Primero.");
+        assert_eq!(imp.atoms[1].content.as_str(), "Segundo.");
+        assert_eq!(imp.atoms[2].content.as_str(), "Tercero.");
+        assert_eq!(imp.cuerpo.metadatos.intencion, Intencion::Original);
+        assert_eq!(imp.cuerpo.branch_id, "es");
+        assert_eq!(imp.cuerpo.orden.len(), 3);
+    }
+
+    #[test]
+    fn parrafos_vacios_se_omiten() {
+        let docx = docx_de(&["", "Solo este.", "", "   "]);
+        let imp = parse_docx(&docx, "es", "x", 0).unwrap();
+        assert_eq!(imp.atoms.len(), 1);
+        assert_eq!(imp.atoms[0].content.as_str(), "Solo este.");
+    }
+
+    #[test]
+    fn archivo_sin_document_xml_devuelve_error() {
+        // Zip vacío.
+        let mut buf = Vec::new();
+        {
+            let mut w = zip::ZipWriter::new(Cursor::new(&mut buf));
+            w.start_file("hola.txt", zip::write::SimpleFileOptions::default())
+                .unwrap();
+            w.write_all(b"no soy docx").unwrap();
+            w.finish().unwrap();
+        }
+        match parse_docx(&buf, "es", "x", 0) {
+            Err(DocxError::DocumentoFaltante) => {}
+            otro => panic!("esperaba DocumentoFaltante, fue {otro:?}"),
+        }
+    }
+
+    #[test]
+    fn bytes_no_zip_devuelve_zip_error() {
+        let basura = b"esto no es un zip ni en broma";
+        assert!(matches!(
+            parse_docx(basura, "es", "x", 0),
+            Err(DocxError::Zip(_))
+        ));
+    }
+
+    #[test]
+    fn cuerpo_orden_y_atoms_referencian_los_mismos_uuids() {
+        let docx = docx_de(&["a", "b", "c"]);
+        let imp = parse_docx(&docx, "es", "x", 0).unwrap();
+        for (atom, uuid) in imp.atoms.iter().zip(imp.cuerpo.orden.iter()) {
+            assert_eq!(&atom.id, uuid);
+        }
+    }
+
+    fn cuerpo_y_atoms(textos: &[&str]) -> (Cuerpo, HashMap<Uuid, NarrativeAtom>) {
+        let mut c = Cuerpo::nuevo("es", "es", Intencion::Original, 0);
+        let mut map = HashMap::new();
+        for t in textos {
+            let atom = NarrativeAtom::new(*t, "es");
+            c.agregar(atom.id, 0);
+            map.insert(atom.id, atom);
+        }
+        (c, map)
+    }
+
+    #[test]
+    fn write_docx_roundtrip_preserva_orden_y_texto() {
+        let textos = vec!["Primero.", "Segundo párrafo.", "Tercero & último."];
+        let (c, atoms) = cuerpo_y_atoms(&textos);
+        let bytes = write_docx(&c, &atoms).unwrap();
+        // Releemos con nuestro parser — el camino más estricto: si
+        // parse_docx puede con lo que escribimos, Word puede también.
+        let imp = parse_docx(&bytes, "es", "roundtrip.docx", 0).unwrap();
+        assert_eq!(imp.atoms.len(), textos.len());
+        for (a, esperado) in imp.atoms.iter().zip(textos.iter()) {
+            assert_eq!(a.content.as_str(), *esperado);
+        }
+    }
+
+    #[test]
+    fn write_docx_cuerpo_vacio_genera_doc_minimo_valido() {
+        let c = Cuerpo::nuevo("es", "es", Intencion::Original, 0);
+        let atoms: HashMap<Uuid, NarrativeAtom> = HashMap::new();
+        let bytes = write_docx(&c, &atoms).unwrap();
+        // Releer no debe fallar; sin atoms el resultado del parse es vacío.
+        let imp = parse_docx(&bytes, "es", "vacio.docx", 0).unwrap();
+        assert!(imp.atoms.is_empty());
+    }
+
+    #[test]
+    fn write_docx_escapa_caracteres_xml() {
+        let (c, atoms) = cuerpo_y_atoms(&["<x> & \"comillas\" 'simples'"]);
+        let bytes = write_docx(&c, &atoms).unwrap();
+        let imp = parse_docx(&bytes, "es", "esc.docx", 0).unwrap();
+        assert_eq!(imp.atoms.len(), 1);
+        // El parser des-escapa los entities — el roundtrip debe devolver
+        // el texto original.
+        assert_eq!(imp.atoms[0].content.as_str(), "<x> & \"comillas\" 'simples'");
+    }
+}
diff --git a/00_unanchay/pluma/pluma-align-embeddings/Cargo.toml b/00_unanchay/pluma/pluma-align-embeddings/Cargo.toml
new file mode 100644
index 0000000..c47e6a3
--- /dev/null
+++ b/00_unanchay/pluma/pluma-align-embeddings/Cargo.toml
@@ -0,0 +1,21 @@
+[package]
+name = "pluma-align-embeddings"
+version.workspace = true
+edition.workspace = true
+license.workspace = true
+authors.workspace = true
+publish.workspace = true
+description = "pluma — alineador de cuerpos por embeddings: produce hebras (CartaHebras) calculando similitud coseno entre los párrafos de dos cuerpos, vía cualquier rimay_verbo_core::Provider (mock determinista, BGE local, Cohere remoto)."
+
+[dependencies]
+uuid = { workspace = true, features = ["serde"] }
+async-trait = { workspace = true }
+anyhow = { workspace = true }
+pluma-core = { path = "../pluma-core" }
+pluma-cuerpo = { path = "../pluma-cuerpo" }
+pluma-align = { path = "../pluma-align" }
+rimay-verbo-core = { workspace = true }
+
+[dev-dependencies]
+tokio = { workspace = true }
+rimay-verbo-mock = { workspace = true }
diff --git a/00_unanchay/pluma/pluma-align-embeddings/LEEME.md b/00_unanchay/pluma/pluma-align-embeddings/LEEME.md
new file mode 100644
index 0000000..dee9c34
--- /dev/null
+++ b/00_unanchay/pluma/pluma-align-embeddings/LEEME.md
@@ -0,0 +1,19 @@
+# pluma-align-embeddings
+
+> Alineamiento por embeddings para [pluma](../README.md).
+
+Refina el alineamiento naïve de [`pluma-align`](../pluma-align/README.md) usando similaridad coseno entre embeddings de párrafo. Consulta a [`rimay-verbo`](../../rimay/README.md) o a un mock; resuelve casos donde la heurística por longitud falla (textos reordenados, sinónimos).
+
+## API
+
+```rust
+use pluma_align_embeddings::alinear_con_embeddings;
+
+let pares = alinear_con_embeddings(&doc_a, &doc_b, &verbo).await?;
+```
+
+## Deps
+
+- [`pluma-align`](../pluma-align/README.md), [`pluma-core`](../pluma-core/README.md)
+- [`rimay-verbo-core`](../../rimay/rimay-verbo-core/README.md)
+- `serde`, `uuid`
diff --git a/00_unanchay/pluma/pluma-align-embeddings/README.md b/00_unanchay/pluma/pluma-align-embeddings/README.md
new file mode 100644
index 0000000..da12c01
--- /dev/null
+++ b/00_unanchay/pluma/pluma-align-embeddings/README.md
@@ -0,0 +1,19 @@
+# pluma-align-embeddings
+
+> Embeddings-based alignment for [pluma](../README.md).
+
+Refines the naïve [`pluma-align`](../pluma-align/README.md) using cosine similarity between paragraph embeddings. Queries [`rimay-verbo`](../../rimay/README.md) or a mock; solves cases where length heuristics fail (reordered text, synonyms, distant paraphrases).
+
+## API
+
+```rust
+use pluma_align_embeddings::alinear_con_embeddings;
+
+let pares = alinear_con_embeddings(&doc_a, &doc_b, &verbo).await?;
+```
+
+## Deps
+
+- [`pluma-align`](../pluma-align/README.md), [`pluma-core`](../pluma-core/README.md)
+- [`rimay-verbo-core`](../../rimay/rimay-verbo-core/README.md)
+- `serde`, `uuid`
diff --git a/00_unanchay/pluma/pluma-align-embeddings/src/lib.rs b/00_unanchay/pluma/pluma-align-embeddings/src/lib.rs
new file mode 100644
index 0000000..c84d867
--- /dev/null
+++ b/00_unanchay/pluma/pluma-align-embeddings/src/lib.rs
@@ -0,0 +1,372 @@
+//! `pluma-align-embeddings` — alineador de cuerpos por embeddings.
+//!
+//! Toma dos cuerpos, calcula embeddings de cada párrafo vía un
+//! `rimay_verbo_core::Provider` (mock determinista, BGE local, Cohere
+//! remoto — quien implemente el rasgo) y produce una [`CartaHebras`]
+//! cuya fuerza es la similitud coseno entre los embeddings. La política
+//! de selección de pares la elige el caller.
+//!
+//! Este crate vive APARTE de `pluma-align` porque introduce async +
+//! dependencia a `rimay-verbo-core`. Mantener `pluma-align` sincrónico y
+//! agnóstico permite usarlo en contextos sin runtime async (UI thread,
+//! tests rápidos, wawa userspace).
+
+#![forbid(unsafe_code)]
+
+use std::collections::HashMap;
+
+use anyhow::{Context, Result};
+use uuid::Uuid;
+
+use pluma_align::{Alineamiento, CartaHebras, OrigenAlineamiento};
+use pluma_core::NarrativeAtom;
+use pluma_cuerpo::Cuerpo;
+use rimay_verbo_core::Provider;
+
+/// Política con la que se eligen pares ganadores en la matriz NxM de
+/// similitudes.
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+pub enum ModoAlineacion {
+    /// Para cada átomo del cuerpo A, registrar UNA hebra con el átomo de
+    /// B de mayor similitud (siempre que pase el umbral). Un átomo de B
+    /// puede recibir varias hebras — caso típico cuando la traducción
+    /// fusiona dos párrafos del original en uno.
+    MejorParaCadaA,
+    /// Greedy mutuo: registrar el par `(a, b)` solo si `a` es el mejor
+    /// candidato de `b` Y `b` es el mejor de `a`. Hebras 1↔1, más limpias.
+    /// Reduce las hebras y deja sin emparejar lo que es genuinamente
+    /// ambiguo — la UI muestra los huérfanos como "sin contraparte".
+    MutuoMejor,
+}
+
+/// Parámetros de la alineación. Defaults pensados para que un MockProvider
+/// (vectores aleatorios) NO produzca hebras espurias: umbral elevado.
+#[derive(Debug, Clone, Copy)]
+pub struct ParamsAlineacion {
+    /// Umbral mínimo de similitud coseno para registrar una hebra. Por
+    /// debajo, el par se descarta. Rango razonable con modelos reales:
+    /// 0.45–0.7. Para mock random, conviene >= 0.95 (efectivamente nada).
+    pub umbral_minimo: f32,
+    /// Política de selección.
+    pub modo: ModoAlineacion,
+}
+
+impl Default for ParamsAlineacion {
+    fn default() -> Self {
+        Self {
+            umbral_minimo: 0.55,
+            modo: ModoAlineacion::MutuoMejor,
+        }
+    }
+}
+
+/// Alinea dos cuerpos calculando embeddings de cada párrafo y emparejando
+/// según `params`. La carta resultante tiene `OrigenAlineamiento::Embeddings
+/// { modelo: provider.model_id().name, timestamp: ahora }`.
+///
+/// `ahora` es el timestamp (segundos UNIX) que el caller decide — el crate
+/// no lee el reloj, para mantener el flujo testeable.
+///
+/// Errores propagados: del provider, si falla la inferencia. Átomos
+/// referenciados que no existan en `atoms` se omiten en silencio (no
+/// son alineables si no hay texto).
+pub async fn alinear_por_embeddings(
+    cuerpo_a: &Cuerpo,
+    cuerpo_b: &Cuerpo,
+    atoms: &HashMap<Uuid, &NarrativeAtom>,
+    provider: &dyn Provider,
+    params: &ParamsAlineacion,
+    ahora: u64,
+) -> Result<CartaHebras> {
+    let (ids_a, textos_a) = recolectar_textos(cuerpo_a, atoms);
+    let (ids_b, textos_b) = recolectar_textos(cuerpo_b, atoms);
+
+    if ids_a.is_empty() || ids_b.is_empty() {
+        return Ok(CartaHebras::nueva().con_par(cuerpo_a.id, cuerpo_b.id));
+    }
+
+    let emb_a = provider
+        .embed_batch(&textos_a)
+        .await
+        .context("embedding lote del cuerpo A falló")?;
+    let emb_b = provider
+        .embed_batch(&textos_b)
+        .await
+        .context("embedding lote del cuerpo B falló")?;
+
+    // Matriz NxM de similitudes coseno.
+    let n = emb_a.len();
+    let m = emb_b.len();
+    let mut sim = vec![0.0f32; n * m];
+    for i in 0..n {
+        for j in 0..m {
+            let s = emb_a[i].cosine(&emb_b[j]).context(
+                "similitud coseno cruzando modelos distintos — provider inconsistente",
+            )?;
+            sim[i * m + j] = s;
+        }
+    }
+
+    let modelo = provider.model_id().name.clone();
+    let origen = OrigenAlineamiento::Embeddings {
+        modelo,
+        timestamp: ahora,
+    };
+
+    let pares = match params.modo {
+        ModoAlineacion::MejorParaCadaA => mejores_por_fila(&sim, n, m, params.umbral_minimo),
+        ModoAlineacion::MutuoMejor => mutuos_mejor(&sim, n, m, params.umbral_minimo),
+    };
+
+    let mut carta = CartaHebras::nueva().con_par(cuerpo_a.id, cuerpo_b.id);
+    for (i, j, s) in pares {
+        carta.agregar(Alineamiento::nuevo(ids_a[i], ids_b[j], s, origen.clone()));
+    }
+    Ok(carta)
+}
+
+/// Recolecta (uuids_en_orden, textos_en_orden) de un cuerpo, omitiendo
+/// átomos no presentes en el índice.
+fn recolectar_textos(
+    cuerpo: &Cuerpo,
+    atoms: &HashMap<Uuid, &NarrativeAtom>,
+) -> (Vec<Uuid>, Vec<String>) {
+    let mut ids = Vec::with_capacity(cuerpo.orden.len());
+    let mut textos = Vec::with_capacity(cuerpo.orden.len());
+    for &id in &cuerpo.orden {
+        if let Some(atom) = atoms.get(&id) {
+            ids.push(id);
+            textos.push(atom.content.as_str().to_string());
+        }
+    }
+    (ids, textos)
+}
+
+/// Para cada fila `i` de la matriz, encuentra el `j` con mayor sim y lo
+/// emite si supera el umbral. Genera hasta N pares (uno por átomo de A).
+fn mejores_por_fila(sim: &[f32], n: usize, m: usize, umbral: f32) -> Vec<(usize, usize, f32)> {
+    let mut out = Vec::with_capacity(n);
+    for i in 0..n {
+        let mut mejor = (usize::MAX, f32::NEG_INFINITY);
+        for j in 0..m {
+            let s = sim[i * m + j];
+            if s > mejor.1 {
+                mejor = (j, s);
+            }
+        }
+        if mejor.0 != usize::MAX && mejor.1 >= umbral {
+            out.push((i, mejor.0, mejor.1));
+        }
+    }
+    out
+}
+
+/// Greedy mutuo: i↔j solo si i es el mejor candidato de j y j es el mejor
+/// de i. Garantiza 1↔1 y descarta pares ambiguos.
+fn mutuos_mejor(sim: &[f32], n: usize, m: usize, umbral: f32) -> Vec<(usize, usize, f32)> {
+    // Mejor j por cada i.
+    let mut mejor_de_a = vec![usize::MAX; n];
+    let mut mejor_de_a_s = vec![f32::NEG_INFINITY; n];
+    for i in 0..n {
+        for j in 0..m {
+            let s = sim[i * m + j];
+            if s > mejor_de_a_s[i] {
+                mejor_de_a_s[i] = s;
+                mejor_de_a[i] = j;
+            }
+        }
+    }
+    // Mejor i por cada j.
+    let mut mejor_de_b = vec![usize::MAX; m];
+    let mut mejor_de_b_s = vec![f32::NEG_INFINITY; m];
+    for j in 0..m {
+        for i in 0..n {
+            let s = sim[i * m + j];
+            if s > mejor_de_b_s[j] {
+                mejor_de_b_s[j] = s;
+                mejor_de_b[j] = i;
+            }
+        }
+    }
+    // Emisión: solo pares mutuos sobre umbral.
+    let mut out = Vec::new();
+    for i in 0..n {
+        let j = mejor_de_a[i];
+        if j == usize::MAX {
+            continue;
+        }
+        if mejor_de_b[j] == i && mejor_de_a_s[i] >= umbral {
+            out.push((i, j, mejor_de_a_s[i]));
+        }
+    }
+    out
+}
+
+#[cfg(test)]
+mod pruebas {
+    use super::*;
+    use pluma_align::OrigenAlineamiento;
+    use pluma_cuerpo::Intencion;
+    use rimay_verbo_mock::MockProvider;
+
+    fn cuerpo_con_atomos(
+        branch: &str,
+        intencion: Intencion,
+        textos: &[&str],
+    ) -> (Cuerpo, Vec<NarrativeAtom>) {
+        let mut c = Cuerpo::nuevo(branch, branch, intencion, 100);
+        let atoms: Vec<NarrativeAtom> =
+            textos.iter().map(|t| NarrativeAtom::new(*t, branch)).collect();
+        for a in &atoms {
+            c.agregar(a.id, 101);
+        }
+        (c, atoms)
+    }
+
+    fn indice<'a>(atoms: &'a [NarrativeAtom]) -> HashMap<Uuid, &'a NarrativeAtom> {
+        atoms.iter().map(|a| (a.id, a)).collect()
+    }
+
+    #[tokio::test]
+    async fn textos_identicos_dan_fuerza_uno() {
+        let (a, atoms_a) = cuerpo_con_atomos(
+            "a",
+            Intencion::Original,
+            &["alpha", "beta", "gamma"],
+        );
+        let (b, atoms_b) = cuerpo_con_atomos(
+            "b",
+            Intencion::Traduccion,
+            &["alpha", "beta", "gamma"],
+        );
+        let mut atoms_all = atoms_a;
+        atoms_all.extend(atoms_b);
+        let idx = indice(&atoms_all);
+
+        let provider = MockProvider::default();
+        // Mock es determinista por texto: misma cadena → mismo vector →
+        // coseno = 1. Subimos el umbral para asegurar que solo registra
+        // pares de coseno ≈ 1.
+        let params = ParamsAlineacion {
+            umbral_minimo: 0.99,
+            modo: ModoAlineacion::MutuoMejor,
+        };
+        let carta = alinear_por_embeddings(&a, &b, &idx, &provider, &params, 7).await.unwrap();
+        assert_eq!(carta.hebras.len(), 3);
+        for h in &carta.hebras {
+            assert!(h.fuerza > 0.99);
+            match &h.origen {
+                OrigenAlineamiento::Embeddings { timestamp, .. } => assert_eq!(*timestamp, 7),
+                _ => panic!("origen debería ser Embeddings"),
+            }
+        }
+    }
+
+    #[tokio::test]
+    async fn textos_random_no_pasan_umbral_alto() {
+        let (a, atoms_a) = cuerpo_con_atomos(
+            "a",
+            Intencion::Original,
+            &["alfa beta gamma", "delta epsilon"],
+        );
+        let (b, atoms_b) = cuerpo_con_atomos(
+            "b",
+            Intencion::Traduccion,
+            &["lorem ipsum dolor", "consectetur adipiscing"],
+        );
+        let mut atoms_all = atoms_a;
+        atoms_all.extend(atoms_b);
+        let idx = indice(&atoms_all);
+
+        let provider = MockProvider::default();
+        // Umbral alto: el mock (vectores aleatorios entre textos no
+        // idénticos) no debe producir hebras.
+        let params = ParamsAlineacion {
+            umbral_minimo: 0.95,
+            modo: ModoAlineacion::MutuoMejor,
+        };
+        let carta = alinear_por_embeddings(&a, &b, &idx, &provider, &params, 1).await.unwrap();
+        assert!(carta.hebras.is_empty());
+    }
+
+    #[tokio::test]
+    async fn cuerpo_vacio_devuelve_carta_vacia_sin_llamar_al_provider() {
+        // Si uno de los cuerpos está vacío, no hay nada que embeddear.
+        let (a, atoms_a) = cuerpo_con_atomos("a", Intencion::Original, &["solo a"]);
+        let b = Cuerpo::nuevo("b", "vacio", Intencion::Traduccion, 0);
+        let idx = indice(&atoms_a);
+        let provider = MockProvider::default();
+        let carta = alinear_por_embeddings(
+            &a,
+            &b,
+            &idx,
+            &provider,
+            &ParamsAlineacion::default(),
+            1,
+        )
+        .await
+        .unwrap();
+        assert!(carta.hebras.is_empty());
+        assert_eq!(carta.cuerpo_a, Some(a.id));
+        assert_eq!(carta.cuerpo_b, Some(b.id));
+    }
+
+    #[tokio::test]
+    async fn mejor_para_cada_a_puede_apuntar_dos_a_uno() {
+        // Cuerpo A con 2 textos idénticos a UN texto de B.
+        let (a, atoms_a) = cuerpo_con_atomos(
+            "a",
+            Intencion::Original,
+            &["compartido", "compartido"],
+        );
+        let (b, atoms_b) = cuerpo_con_atomos(
+            "b",
+            Intencion::Traduccion,
+            &["compartido", "diferente"],
+        );
+        let mut atoms_all = atoms_a;
+        atoms_all.extend(atoms_b);
+        let idx = indice(&atoms_all);
+
+        let provider = MockProvider::default();
+        let params = ParamsAlineacion {
+            umbral_minimo: 0.99,
+            modo: ModoAlineacion::MejorParaCadaA,
+        };
+        let carta = alinear_por_embeddings(&a, &b, &idx, &provider, &params, 1).await.unwrap();
+        // Las dos filas de A apuntan al mismo j=0 (texto "compartido" de B).
+        assert_eq!(carta.hebras.len(), 2);
+        let target = carta.hebras[0].atom_b;
+        assert_eq!(carta.hebras[1].atom_b, target);
+    }
+
+    #[tokio::test]
+    async fn mutuo_mejor_descarta_pares_ambiguos() {
+        // Dos textos de A idénticos a UN texto de B: con MutuoMejor, solo
+        // UN par mutuo gana (B prefiere su mejor único; la otra fila de A
+        // queda huérfana).
+        let (a, atoms_a) = cuerpo_con_atomos(
+            "a",
+            Intencion::Original,
+            &["compartido", "compartido"],
+        );
+        let (b, atoms_b) = cuerpo_con_atomos(
+            "b",
+            Intencion::Traduccion,
+            &["compartido", "diferente"],
+        );
+        let mut atoms_all = atoms_a;
+        atoms_all.extend(atoms_b);
+        let idx = indice(&atoms_all);
+
+        let provider = MockProvider::default();
+        let params = ParamsAlineacion {
+            umbral_minimo: 0.99,
+            modo: ModoAlineacion::MutuoMejor,
+        };
+        let carta = alinear_por_embeddings(&a, &b, &idx, &provider, &params, 1).await.unwrap();
+        // Solo una hebra: el primer "compartido" de A con "compartido" de B
+        // (los dos j=0 candidatos empatan; el algoritmo se queda con el primer i).
+        assert_eq!(carta.hebras.len(), 1);
+    }
+}
diff --git a/00_unanchay/pluma/pluma-align/Cargo.toml b/00_unanchay/pluma/pluma-align/Cargo.toml
new file mode 100644
index 0000000..a206b08
--- /dev/null
+++ b/00_unanchay/pluma/pluma-align/Cargo.toml
@@ -0,0 +1,14 @@
+[package]
+name = "pluma-align"
+version.workspace = true
+edition.workspace = true
+license.workspace = true
+authors.workspace = true
+publish.workspace = true
+description = "pluma — alineamientos entre cuerpos paralelos: las hebras que la UI pinta como barras de color entre columnas."
+
+[dependencies]
+uuid = { workspace = true, features = ["serde"] }
+serde = { workspace = true }
+postcard = { workspace = true }
+pluma-cuerpo = { path = "../pluma-cuerpo" }
diff --git a/00_unanchay/pluma/pluma-align/LEEME.md b/00_unanchay/pluma/pluma-align/LEEME.md
new file mode 100644
index 0000000..6265dad
--- /dev/null
+++ b/00_unanchay/pluma/pluma-align/LEEME.md
@@ -0,0 +1,18 @@
+# pluma-align
+
+> Alineamiento texto–texto para [pluma](../README.md).
+
+Dados dos documentos (ej: original + traducción, draft + revisión), produce un mapping átomo–átomo usando heurística greedy por longitud + LCS sobre la secuencia. Resultado: `Vec<Par>` donde cada par puede ser `(a, b)`, `(a, ∅)`, `(∅, b)`. Base para diff visual y para apoyar las traducciones de [pluma-transform-llm](../pluma-transform-llm/README.md).
+
+## API
+
+```rust
+use pluma_align::alinear;
+
+let pares = alinear(&doc_a, &doc_b);
+```
+
+## Deps
+
+- [`pluma-core`](../pluma-core/README.md), [`pluma-cuerpo`](../pluma-cuerpo/README.md)
+- `serde`, `uuid`
diff --git a/00_unanchay/pluma/pluma-align/README.md b/00_unanchay/pluma/pluma-align/README.md
new file mode 100644
index 0000000..7943f4a
--- /dev/null
+++ b/00_unanchay/pluma/pluma-align/README.md
@@ -0,0 +1,18 @@
+# pluma-align
+
+> Text-text alignment for [pluma](../README.md).
+
+Given two documents (e.g., original + translation, draft + revision), produces atom-atom mapping using length-greedy heuristic + LCS over the sequence. Result: `Vec<Par>` where each pair can be `(a, b)`, `(a, ∅)`, `(∅, b)`. Foundation for visual diff and for backing the translations of [pluma-transform-llm](../pluma-transform-llm/README.md).
+
+## API
+
+```rust
+use pluma_align::alinear;
+
+let pares = alinear(&doc_a, &doc_b);
+```
+
+## Deps
+
+- [`pluma-core`](../pluma-core/README.md), [`pluma-cuerpo`](../pluma-cuerpo/README.md)
+- `serde`, `uuid`
diff --git a/00_unanchay/pluma/pluma-align/src/lib.rs b/00_unanchay/pluma/pluma-align/src/lib.rs
new file mode 100644
index 0000000..a8d4ed8
--- /dev/null
+++ b/00_unanchay/pluma/pluma-align/src/lib.rs
@@ -0,0 +1,385 @@
+//! `pluma-align` — los alineamientos entre cuerpos paralelos.
+//!
+//! Un *alineamiento* enlaza un átomo de un cuerpo madre con un átomo de un
+//! cuerpo hija (o de cualquier cuerpo del haz: la noción no impone dirección).
+//! Llevan asociados una *fuerza* (qué tan correspondientes son los párrafos)
+//! y un *origen* (cómo se calculó: a mano, por embeddings o como subproducto
+//! de una transformación). La UI los pinta como *hebras*: barras de color
+//! verticales entre columnas, con saturación = fuerza y tipo de trazo = origen.
+//!
+//! Un átomo puede tener múltiples alineamientos:
+//!   - 1↔1 — caso traducción típica;
+//!   - 1↔N o N↔1 — un párrafo del original se traduce en dos del destino;
+//!   - 0↔1 — un párrafo del destino sin contraparte en el original
+//!           (texto añadido por el traductor);
+//!   - 1↔0 — un párrafo del original que el destino eliminó.
+//!
+//! El crate no decide la política — solo modela. Las hebras 0↔X se representan
+//! como *ausencia* de alineamientos para ese átomo, no como un alineamiento
+//! degenerado: que no tenga hebra ES la información.
+
+#![forbid(unsafe_code)]
+
+use serde::{Deserialize, Serialize};
+use std::collections::HashMap;
+use uuid::Uuid;
+
+use pluma_cuerpo::Cuerpo;
+
+/// Cómo se produjo un alineamiento. La UI lo usa para distinguir hebras:
+/// continuas (Derivado), punteadas (Embeddings de baja confianza),
+/// trazos manuales con marca.
+#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
+pub enum OrigenAlineamiento {
+    /// Lo trazó un humano. La autoría queda anotada para auditoría.
+    Manual {
+        autor: String,
+        timestamp: u64,
+    },
+    /// Lo dedujo un calculador de embeddings (rimay/iniy). `modelo` identifica
+    /// la versión del calculador — si cambia, los alineamientos viejos pueden
+    /// requerir recálculo.
+    Embeddings {
+        modelo: String,
+        timestamp: u64,
+    },
+    /// Cae como subproducto de una transformación de `pluma-transform`. Cuando
+    /// `Identidad` deriva un cuerpo hija desde una madre, cada átomo
+    /// arrastra su contraparte 1:1 — esos son los alineamientos `Derivado`.
+    Derivado {
+        /// `Uuid` de la `Transformacion` (en `pluma-transform`) que lo emitió.
+        transformacion: Uuid,
+        timestamp: u64,
+    },
+}
+
+impl OrigenAlineamiento {
+    /// El instante en que este origen fue establecido. Útil para invalidar
+    /// alineamientos viejos en bloque ("todos los que tengan modelo X
+    /// anterior a hace una semana").
+    pub fn timestamp(&self) -> u64 {
+        match self {
+            OrigenAlineamiento::Manual { timestamp, .. } => *timestamp,
+            OrigenAlineamiento::Embeddings { timestamp, .. } => *timestamp,
+            OrigenAlineamiento::Derivado { timestamp, .. } => *timestamp,
+        }
+    }
+}
+
+/// Un alineamiento entre dos átomos de dos cuerpos. La dirección
+/// (`atom_a → atom_b`) no implica jerarquía — el grafo de cuerpos decide
+/// quién deriva de quién; aquí solo se anota el par. La UI pinta una sola
+/// hebra por par independientemente del orden.
+#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
+pub struct Alineamiento {
+    /// Identidad estable del alineamiento — sobrevive a recálculos del par
+    /// (si el caller mantiene el mismo `Uuid`).
+    pub id: Uuid,
+    /// Átomo del cuerpo de la izquierda (el primer término del par).
+    pub atom_a: Uuid,
+    /// Átomo del cuerpo de la derecha.
+    pub atom_b: Uuid,
+    /// `[0.0, 1.0]` — qué tan correspondientes son los dos párrafos. `1.0` es
+    /// "el mismo párrafo" (o derivación directa); `0.0` no se almacena
+    /// (mejor no almacenar nada). La UI mapea esto a saturación de color.
+    pub fuerza: f32,
+    /// Cómo se calculó este alineamiento.
+    pub origen: OrigenAlineamiento,
+    /// `true` si el alineamiento sigue siendo válido frente al estado actual
+    /// de los cuerpos; `false` si el algoritmo lo marcó como stale tras una
+    /// edición. La UI lo usa para desaturar la hebra.
+    pub fresco: bool,
+}
+
+impl Alineamiento {
+    /// Crea un alineamiento nuevo con id aleatorio. La fuerza se sujeta al
+    /// intervalo `[0, 1]` — un input fuera de rango se acepta como saturado.
+    pub fn nuevo(
+        atom_a: Uuid,
+        atom_b: Uuid,
+        fuerza: f32,
+        origen: OrigenAlineamiento,
+    ) -> Self {
+        Self {
+            id: Uuid::new_v4(),
+            atom_a,
+            atom_b,
+            fuerza: fuerza.clamp(0.0, 1.0),
+            origen,
+            fresco: true,
+        }
+    }
+
+    /// `true` si este alineamiento toca el átomo dado, sea por `atom_a` o por
+    /// `atom_b`. Útil para listar todas las hebras incidentes a un párrafo.
+    pub fn toca(&self, atom_id: Uuid) -> bool {
+        self.atom_a == atom_id || self.atom_b == atom_id
+    }
+
+    /// Dado uno de los dos átomos del par, devuelve el otro. `None` si el
+    /// átomo no participa.
+    pub fn contraparte(&self, atom_id: Uuid) -> Option<Uuid> {
+        if self.atom_a == atom_id {
+            Some(self.atom_b)
+        } else if self.atom_b == atom_id {
+            Some(self.atom_a)
+        } else {
+            None
+        }
+    }
+}
+
+/// Una *carta de hebras* entre dos cuerpos: la colección de alineamientos
+/// que viven en ese par. Es la unidad que la UI consume: pasa por las hebras
+/// en orden y pinta una barra por cada una.
+///
+/// `cuerpo_a` y `cuerpo_b` se anotan para detectar al vuelo que la carta
+/// corresponde al par que la UI espera (evita confundir hebras de pares
+/// distintos). El crate no fuerza qué par es a/b vs b/a — el caller decide
+/// y se mantiene consistente.
+#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
+pub struct CartaHebras {
+    pub cuerpo_a: Option<Uuid>,
+    pub cuerpo_b: Option<Uuid>,
+    pub hebras: Vec<Alineamiento>,
+}
+
+impl CartaHebras {
+    /// Una carta vacía, sin par anotado.
+    pub fn nueva() -> Self {
+        Self::default()
+    }
+
+    /// Anota el par al que pertenece esta carta. Útil al ingestarla.
+    pub fn con_par(mut self, cuerpo_a: Uuid, cuerpo_b: Uuid) -> Self {
+        self.cuerpo_a = Some(cuerpo_a);
+        self.cuerpo_b = Some(cuerpo_b);
+        self
+    }
+
+    /// Agrega una hebra. La carta no deduplica; eso es del caller (las hebras
+    /// son por id, no por pares — un par puede tener varias hebras de orígenes
+    /// distintos coexistiendo, p. ej. una Manual y una Embeddings de respaldo).
+    pub fn agregar(&mut self, hebra: Alineamiento) {
+        self.hebras.push(hebra);
+    }
+
+    /// Itera las hebras que tocan el átomo dado.
+    pub fn hebras_de(&self, atom_id: Uuid) -> impl Iterator<Item = &Alineamiento> {
+        self.hebras.iter().filter(move |h| h.toca(atom_id))
+    }
+
+    /// Marca como stale todas las hebras cuyo origen sea anterior a
+    /// `umbral_ts` — útil tras una edición sustancial de un cuerpo. Devuelve
+    /// cuántas se marcaron.
+    pub fn marcar_stale_anteriores_a(&mut self, umbral_ts: u64) -> usize {
+        let mut n = 0;
+        for h in self.hebras.iter_mut() {
+            if h.fresco && h.origen.timestamp() < umbral_ts {
+                h.fresco = false;
+                n += 1;
+            }
+        }
+        n
+    }
+
+    /// Serializa la carta a postcard.
+    pub fn serializar(&self) -> Result<Vec<u8>, &'static str> {
+        postcard::to_allocvec(self).map_err(|_| "carta :: serializacion fallida")
+    }
+
+    /// Reconstruye la carta desde postcard.
+    pub fn deserializar(bytes: &[u8]) -> Result<CartaHebras, &'static str> {
+        postcard::from_bytes::<CartaHebras>(bytes)
+            .map_err(|_| "carta :: deserializacion fallida")
+    }
+}
+
+// =============================================================================
+//  Alineadores — estrategias concretas para producir CartaHebras
+// =============================================================================
+
+/// Alinea dos cuerpos posición-a-posición, hasta agotar el más corto. Es el
+/// alineador trivial: útil como baseline, como salida natural de una
+/// transformación `Identidad`, o cuando el usuario importa textos que sabe
+/// estructurados con la misma cantidad de párrafos. La `fuerza` es siempre
+/// 1.0 — el alineador no juzga similitud semántica, asume que la posición
+/// es verdad. Los párrafos sobrantes del cuerpo más largo quedan sin hebra
+/// (no se inventan alineamientos huérfanos).
+///
+/// `origen` se aplica idéntico a cada hebra producida.
+pub fn alinear_uno_a_uno(
+    cuerpo_a: &Cuerpo,
+    cuerpo_b: &Cuerpo,
+    origen: OrigenAlineamiento,
+) -> CartaHebras {
+    let mut carta = CartaHebras::nueva().con_par(cuerpo_a.id, cuerpo_b.id);
+    for (a, b) in cuerpo_a.orden.iter().zip(cuerpo_b.orden.iter()) {
+        carta.agregar(Alineamiento::nuevo(*a, *b, 1.0, origen.clone()));
+    }
+    carta
+}
+
+/// Alinea dos cuerpos a partir de una *tabla* explícita de pares
+/// `(atom_a, atom_b, fuerza)`. Es el camino para alineamientos manuales —
+/// el editor recibe los pares del usuario y los confirma de un golpe—.
+/// Los pares cuyos átomos no aparezcan en los respectivos cuerpos se
+/// descartan en silencio: alinear lo que no existe no tiene sentido.
+pub fn alinear_explicito(
+    cuerpo_a: &Cuerpo,
+    cuerpo_b: &Cuerpo,
+    pares: &[(Uuid, Uuid, f32)],
+    origen: OrigenAlineamiento,
+) -> CartaHebras {
+    // Indexar los cuerpos para chequeo de pertenencia O(1).
+    let en_a: HashMap<Uuid, ()> = cuerpo_a.orden.iter().map(|&id| (id, ())).collect();
+    let en_b: HashMap<Uuid, ()> = cuerpo_b.orden.iter().map(|&id| (id, ())).collect();
+    let mut carta = CartaHebras::nueva().con_par(cuerpo_a.id, cuerpo_b.id);
+    for &(a, b, fuerza) in pares {
+        if en_a.contains_key(&a) && en_b.contains_key(&b) {
+            carta.agregar(Alineamiento::nuevo(a, b, fuerza, origen.clone()));
+        }
+    }
+    carta
+}
+
+#[cfg(test)]
+mod pruebas {
+    use super::*;
+    use pluma_cuerpo::Intencion;
+
+    fn cuerpos_paralelos(n: usize) -> (Cuerpo, Cuerpo, Vec<Uuid>, Vec<Uuid>) {
+        let mut a = Cuerpo::nuevo("es", "es", Intencion::Original, 100);
+        let mut b = Cuerpo::nuevo("qu", "qu", Intencion::Traduccion, 100);
+        // En este test no validamos consistencia: nos importa la alineación.
+        let ids_a: Vec<Uuid> = (0..n).map(|_| Uuid::new_v4()).collect();
+        let ids_b: Vec<Uuid> = (0..n).map(|_| Uuid::new_v4()).collect();
+        for &id in &ids_a { a.agregar(id, 101); }
+        for &id in &ids_b { b.agregar(id, 101); }
+        (a, b, ids_a, ids_b)
+    }
+
+    #[test]
+    fn nuevo_alineamiento_clampea_fuerza() {
+        let h_alto = Alineamiento::nuevo(
+            Uuid::new_v4(), Uuid::new_v4(), 5.0,
+            OrigenAlineamiento::Manual { autor: "yo".into(), timestamp: 1 },
+        );
+        let h_bajo = Alineamiento::nuevo(
+            Uuid::new_v4(), Uuid::new_v4(), -0.3,
+            OrigenAlineamiento::Manual { autor: "yo".into(), timestamp: 1 },
+        );
+        assert_eq!(h_alto.fuerza, 1.0);
+        assert_eq!(h_bajo.fuerza, 0.0);
+        assert!(h_alto.fresco);
+    }
+
+    #[test]
+    fn contraparte_y_toca_funcionan_en_ambos_sentidos() {
+        let a = Uuid::new_v4();
+        let b = Uuid::new_v4();
+        let h = Alineamiento::nuevo(
+            a, b, 0.8,
+            OrigenAlineamiento::Manual { autor: "x".into(), timestamp: 0 },
+        );
+        assert!(h.toca(a));
+        assert!(h.toca(b));
+        assert!(!h.toca(Uuid::new_v4()));
+        assert_eq!(h.contraparte(a), Some(b));
+        assert_eq!(h.contraparte(b), Some(a));
+        assert_eq!(h.contraparte(Uuid::new_v4()), None);
+    }
+
+    #[test]
+    fn alinear_uno_a_uno_empareja_hasta_el_mas_corto() {
+        let (a, b, ids_a, ids_b) = cuerpos_paralelos(3);
+        // Acortar b a 2 párrafos.
+        let mut b = b;
+        b.remover(ids_b[2], 102);
+        let carta = alinear_uno_a_uno(
+            &a, &b,
+            OrigenAlineamiento::Manual { autor: "x".into(), timestamp: 200 },
+        );
+        assert_eq!(carta.hebras.len(), 2);
+        assert_eq!(carta.cuerpo_a, Some(a.id));
+        assert_eq!(carta.cuerpo_b, Some(b.id));
+        // Los pares son (ids_a[0], ids_b[0]) y (ids_a[1], ids_b[1]).
+        assert_eq!(carta.hebras[0].atom_a, ids_a[0]);
+        assert_eq!(carta.hebras[0].atom_b, ids_b[0]);
+        assert_eq!(carta.hebras[1].atom_a, ids_a[1]);
+        assert_eq!(carta.hebras[1].atom_b, ids_b[1]);
+        // Fuerza al máximo — es alineamiento posicional, no semántico.
+        assert!(carta.hebras.iter().all(|h| h.fuerza == 1.0));
+        // ids_a[2] queda huérfano: ninguna hebra lo toca.
+        assert!(carta.hebras_de(ids_a[2]).next().is_none());
+    }
+
+    #[test]
+    fn alinear_explicito_descarta_pares_ajenos() {
+        let (a, b, ids_a, ids_b) = cuerpos_paralelos(2);
+        let huerfano = Uuid::new_v4();
+        let pares = vec![
+            (ids_a[0], ids_b[0], 0.95),
+            (ids_a[1], ids_b[1], 0.80),
+            (huerfano, ids_b[0], 0.99), // este se descarta
+            (ids_a[0], huerfano, 0.50), // y este también
+        ];
+        let carta = alinear_explicito(
+            &a, &b, &pares,
+            OrigenAlineamiento::Manual { autor: "ana".into(), timestamp: 300 },
+        );
+        assert_eq!(carta.hebras.len(), 2);
+        assert!((carta.hebras[0].fuerza - 0.95).abs() < 1e-6);
+        assert!((carta.hebras[1].fuerza - 0.80).abs() < 1e-6);
+    }
+
+    #[test]
+    fn hebras_de_filtra_por_atomo_en_ambos_lados() {
+        let (a, b, ids_a, ids_b) = cuerpos_paralelos(3);
+        let carta = alinear_uno_a_uno(
+            &a, &b,
+            OrigenAlineamiento::Manual { autor: "x".into(), timestamp: 1 },
+        );
+        // Cada átomo de a participa en exactamente UNA hebra.
+        for &id in &ids_a {
+            assert_eq!(carta.hebras_de(id).count(), 1);
+        }
+        for &id in &ids_b {
+            assert_eq!(carta.hebras_de(id).count(), 1);
+        }
+    }
+
+    #[test]
+    fn marcar_stale_solo_toca_anteriores_al_umbral() {
+        let (a, b, ids_a, ids_b) = cuerpos_paralelos(2);
+        let mut carta = CartaHebras::nueva().con_par(a.id, b.id);
+        carta.agregar(Alineamiento::nuevo(
+            ids_a[0], ids_b[0], 1.0,
+            OrigenAlineamiento::Embeddings { modelo: "m1".into(), timestamp: 100 },
+        ));
+        carta.agregar(Alineamiento::nuevo(
+            ids_a[1], ids_b[1], 1.0,
+            OrigenAlineamiento::Embeddings { modelo: "m1".into(), timestamp: 500 },
+        ));
+        let n = carta.marcar_stale_anteriores_a(300);
+        assert_eq!(n, 1);
+        assert!(!carta.hebras[0].fresco);
+        assert!(carta.hebras[1].fresco);
+
+        // Llamarlo de nuevo no vuelve a contar el ya stale.
+        let n2 = carta.marcar_stale_anteriores_a(300);
+        assert_eq!(n2, 0);
+    }
+
+    #[test]
+    fn roundtrip_postcard_de_carta() {
+        let (a, b, _, _) = cuerpos_paralelos(2);
+        let carta = alinear_uno_a_uno(
+            &a, &b,
+            OrigenAlineamiento::Derivado { transformacion: Uuid::new_v4(), timestamp: 7 },
+        );
+        let bytes = carta.serializar().unwrap();
+        let recuperada = CartaHebras::deserializar(&bytes).unwrap();
+        assert_eq!(recuperada, carta);
+    }
+}
diff --git a/00_unanchay/pluma/pluma-app/Cargo.toml b/00_unanchay/pluma/pluma-app/Cargo.toml
new file mode 100644
index 0000000..db6e838
--- /dev/null
+++ b/00_unanchay/pluma/pluma-app/Cargo.toml
@@ -0,0 +1,46 @@
+[package]
+name = "pluma-app"
+version.workspace = true
+edition.workspace = true
+rust-version.workspace = true
+license.workspace = true
+authors.workspace = true
+publish.workspace = true
+description = "pluma-app — editor de escritura multilienzo: panel de documentos del sled, cuerpo_ide central editable, panel LLM con cycler de backends y derivación de hijas."
+
+[[bin]]
+name = "pluma-app"
+path = "src/main.rs"
+
+[dependencies]
+pluma-core = { path = "../pluma-core" }
+pluma-cuerpo = { path = "../pluma-cuerpo" }
+pluma-md = { path = "../pluma-md" }
+foreign-docx = { path = "../foreign-docx" }
+pluma-align = { path = "../pluma-align" }
+pluma-editor-cuerpo = { path = "../pluma-editor-cuerpo" }
+pluma-editor-llimphi = { path = "../pluma-editor-llimphi" }
+pluma-store = { path = "../pluma-store" }
+pluma-transform = { path = "../pluma-transform" }
+pluma-transform-llm = { path = "../pluma-transform-llm" }
+pluma-llm = { path = "../pluma-llm" }
+pluma-llm-core = { path = "../pluma-llm-core" }
+pluma-llm-mock = { path = "../pluma-llm-mock" }
+llimphi-ui = { workspace = true }
+# Rail hospedado: pluma puede prestar sus secciones (Documentos/LLM/Buscar/Diff)
+# al rail de pata y colapsar sus columnas (PLUMA_DELEGATE_SIDEBAR).
+pata-host = { workspace = true }
+llimphi-theme = { workspace = true }
+llimphi-widget-button = { workspace = true }
+llimphi-widget-splitter = { workspace = true }
+llimphi-widget-list = { workspace = true }
+llimphi-widget-text-editor = { workspace = true }
+llimphi-widget-text-input = { workspace = true }
+llimphi-widget-menubar = { workspace = true }
+llimphi-widget-edit-menu = { workspace = true }
+llimphi-widget-context-menu = { workspace = true }
+llimphi-motion = { workspace = true }
+app-bus = { workspace = true }
+tokio = { workspace = true }
+uuid = { workspace = true }
+arboard = { workspace = true }
diff --git a/00_unanchay/pluma/pluma-app/LEEME.md b/00_unanchay/pluma/pluma-app/LEEME.md
new file mode 100644
index 0000000..9a37324
--- /dev/null
+++ b/00_unanchay/pluma/pluma-app/LEEME.md
@@ -0,0 +1,16 @@
+# pluma-app
+
+> Binario del editor de [pluma](../README.md).
+
+Wrapper mínimo que arranca [`pluma-editor-llimphi`](../pluma-editor-llimphi/README.md) con la `Config` cargada desde `wawa-config`. Sin lógica propia — todo vive en los crates de soporte.
+
+## Uso
+
+```sh
+cargo run --release -p pluma-app
+```
+
+## Deps
+
+- [`pluma-editor-llimphi`](../pluma-editor-llimphi/README.md)
+- [`wawa-config-llimphi`](../../../shared/wawa-config-llimphi/)
diff --git a/00_unanchay/pluma/pluma-app/README.md b/00_unanchay/pluma/pluma-app/README.md
new file mode 100644
index 0000000..34496ff
--- /dev/null
+++ b/00_unanchay/pluma/pluma-app/README.md
@@ -0,0 +1,16 @@
+# pluma-app
+
+> Editor binary of [pluma](../README.md).
+
+Minimal wrapper that starts [`pluma-editor-llimphi`](../pluma-editor-llimphi/README.md) with the `Config` loaded from `wawa-config`. No logic of its own — everything lives in the supporting crates.
+
+## Usage
+
+```sh
+cargo run --release -p pluma-app
+```
+
+## Deps
+
+- [`pluma-editor-llimphi`](../pluma-editor-llimphi/README.md)
+- [`wawa-config-llimphi`](../../../shared/wawa-config-llimphi/)
diff --git a/00_unanchay/pluma/pluma-app/src/clipboard.rs b/00_unanchay/pluma/pluma-app/src/clipboard.rs
new file mode 100644
index 0000000..d5700d3
--- /dev/null
+++ b/00_unanchay/pluma/pluma-app/src/clipboard.rs
@@ -0,0 +1,30 @@
+//! Puente al portapapeles del sistema vía `arboard`.
+
+use llimphi_widget_text_editor::Clipboard;
+
+/// Wrapper sobre `arboard::Clipboard`. Si el sistema no expone uno
+/// (headless CI, sin Wayland/X), `inner` queda en `None` y los métodos
+/// son no-op silenciosos — exactamente la semántica documentada del
+/// trait [`Clipboard`].
+pub(crate) struct ArboardClipboard {
+    inner: Option<arboard::Clipboard>,
+}
+
+impl ArboardClipboard {
+    pub(crate) fn new() -> Self {
+        Self {
+            inner: arboard::Clipboard::new().ok(),
+        }
+    }
+}
+
+impl Clipboard for ArboardClipboard {
+    fn get(&mut self) -> Option<String> {
+        self.inner.as_mut()?.get_text().ok()
+    }
+    fn set(&mut self, s: &str) {
+        if let Some(c) = self.inner.as_mut() {
+            let _ = c.set_text(s.to_owned());
+        }
+    }
+}
diff --git a/00_unanchay/pluma/pluma-app/src/init.rs b/00_unanchay/pluma/pluma-app/src/init.rs
new file mode 100644
index 0000000..b5a6416
--- /dev/null
+++ b/00_unanchay/pluma/pluma-app/src/init.rs
@@ -0,0 +1,149 @@
+//! Inicialización del modelo: apertura del sled, carga de cuerpos/atoms/
+//! cartas/transformaciones, sembrado del primer documento y autodetección
+//! del backend LLM.
+
+use std::collections::HashMap;
+use std::sync::Arc;
+
+use pluma_align::CartaHebras;
+use pluma_core::NarrativeAtom;
+use pluma_cuerpo::{Cuerpo, Intencion};
+use pluma_editor_llimphi::cuerpo_ide::CuerpoIde;
+use pluma_llm::{build_client, BackendKind, LlmConfig};
+use pluma_llm_core::ChatClient;
+use pluma_store::PlumaStore;
+use pluma_transform::Transformacion;
+use llimphi_widget_text_input::TextInputState;
+use uuid::Uuid;
+
+use crate::clipboard::ArboardClipboard;
+use crate::model::{Model, BACKENDS};
+use crate::util::{ahora_unix, ruta_sled};
+
+pub(crate) fn init_modelo() -> Model {
+    let path = ruta_sled();
+    if let Some(parent) = path.parent() {
+        let _ = std::fs::create_dir_all(parent);
+    }
+    let store = match PlumaStore::open(&path) {
+        Ok(s) => Arc::new(s),
+        Err(e) => panic!("pluma-app :: PlumaStore::open({path:?}) falló: {e:?}"),
+    };
+
+    let mut atoms: HashMap<Uuid, NarrativeAtom> = store
+        .iter_atoms()
+        .filter_map(|r| r.ok())
+        .map(|a| (a.id, a))
+        .collect();
+    let mut cuerpos: Vec<Cuerpo> = store.iter_cuerpos().filter_map(|r| r.ok()).collect();
+    let transformaciones: Vec<Transformacion> = store
+        .iter_transformaciones()
+        .filter_map(|r| r.ok())
+        .collect();
+    let cartas: Vec<CartaHebras> = store.iter_cartas().filter_map(|r| r.ok()).collect();
+
+    // Si el sled está vacío, sembrar un documento Original para que la
+    // ventana no esté muerta al primer arranque.
+    if cuerpos.is_empty() {
+        let ahora = ahora_unix();
+        let atom = NarrativeAtom::new("Empieza a escribir aquí…", "es");
+        let mut cuerpo = Cuerpo::nuevo("es", "documento sin título", Intencion::Original, ahora);
+        cuerpo.agregar(atom.id, ahora);
+        let _ = store.put_atom(&atom);
+        let _ = store.put_cuerpo(&cuerpo);
+        let _ = store.flush();
+        atoms.insert(atom.id, atom);
+        cuerpos.push(cuerpo);
+    }
+
+    // Cuerpo activo = primer Original; si no hay, el primero a secas.
+    let activo = cuerpos
+        .iter()
+        .find(|c| !c.metadatos.intencion.es_derivada())
+        .map(|c| c.id)
+        .or_else(|| cuerpos.first().map(|c| c.id));
+
+    let ide = match activo {
+        Some(id) => {
+            let cuerpo = cuerpos.iter().find(|c| c.id == id).cloned().unwrap();
+            let idx: HashMap<Uuid, &NarrativeAtom> =
+                atoms.iter().map(|(k, v)| (*k, v)).collect();
+            CuerpoIde::from_cuerpo(&cuerpo, &idx)
+        }
+        None => CuerpoIde::nuevo_vacio(),
+    };
+
+    let (chat, backend_idx) = inicializar_backend();
+
+    Model {
+        store,
+        cuerpos,
+        atoms,
+        cartas,
+        transformaciones,
+        activo,
+        ide,
+        clipboard: ArboardClipboard::new(),
+        drag_accum: (0.0, 0.0),
+        chat,
+        backend_idx,
+        en_curso: false,
+        ultimo_error: None,
+        ultimo_status: "listo".to_string(),
+        path_input: TextInputState::new(),
+        path_focused: false,
+        find_input: TextInputState::new(),
+        find_visible: false,
+        find_matches: Vec::new(),
+        find_idx: 0,
+        diff_visible: false,
+        side_izq_w: 280.0,
+        side_der_w: 340.0,
+        menu_open: None,
+        menu_active: usize::MAX,
+        menu_anim: llimphi_motion::Tween::idle(1.0),
+        edit_menu: None,
+        edit_active: usize::MAX,
+        edit_anim: llimphi_motion::Tween::idle(1.0),
+        // Rail hospedado: leemos el opt-in acá; el HostClient se conecta en `init`
+        // (necesita el Handle). Las columnas arrancan visibles aunque se delegue —
+        // el usuario las colapsa desde el rail de pata.
+        delegated: std::env::var_os("PLUMA_DELEGATE_SIDEBAR").is_some(),
+        side_izq_visible: true,
+        side_der_visible: true,
+        _host: None,
+    }
+}
+
+/// Intenta el primer backend con env key configurada; si ninguno
+/// matchea, cae a Mock. Devuelve también el índice en `BACKENDS`.
+fn inicializar_backend() -> (Arc<dyn ChatClient>, usize) {
+    let preferencias: &[(BackendKind, &[&str])] = &[
+        (BackendKind::Anthropic, &["ANTHROPIC_API_KEY"]),
+        (BackendKind::Gemini, &["GEMINI_API_KEY", "GOOGLE_API_KEY"]),
+        (BackendKind::DeepSeek, &["DEEPSEEK_API_KEY"]),
+        (BackendKind::Cohere, &["COHERE_API_KEY"]),
+    ];
+    for (kind, envs) in preferencias {
+        if envs.iter().any(|e| std::env::var(e).is_ok()) {
+            if let Ok(c) = build_client(&LlmConfig {
+                kind: *kind,
+                ..Default::default()
+            }) {
+                let idx = BACKENDS.iter().position(|k| k == kind).unwrap_or(0);
+                return (c, idx);
+            }
+        }
+    }
+    // Fallback: Mock (siempre construye).
+    let c = build_client(&LlmConfig {
+        kind: BackendKind::Mock,
+        ..Default::default()
+    })
+    .expect("Mock backend no debería fallar");
+    let idx = BACKENDS
+        .iter()
+        .position(|k| *k == BackendKind::Mock)
+        .unwrap_or(0);
+    (c, idx)
+}
diff --git a/00_unanchay/pluma/pluma-app/src/main.rs b/00_unanchay/pluma/pluma-app/src/main.rs
new file mode 100644
index 0000000..337ae91
--- /dev/null
+++ b/00_unanchay/pluma/pluma-app/src/main.rs
@@ -0,0 +1,200 @@
+//! `pluma-app` — editor de escritura multilienzo.
+//!
+//! Layout en tres columnas (splitters draggables):
+//!
+//! ```text
+//!   ┌─────────────┬───────────────────────────┬───────────────┐
+//!   │ documentos  │   cuerpo_ide editable     │ panel LLM     │
+//!   │ (lista de   │   (cuerpo activo)         │ - backend ▼   │
+//!   │  cuerpos    │                           │ - botones LLM │
+//!   │  del sled)  │                           │ - lista hijas │
+//!   └─────────────┴───────────────────────────┴───────────────┘
+//! ```
+//!
+//! Persistencia automática en `~/.cache/gioser/pluma-app/pluma.sled`
+//! vía [`PlumaStore`]. Al primer arranque siembra un documento vacío
+//! para que la ventana no esté muerta. Tras ese punto, todo doc/atom/
+//! transformación/carta vive en sled.
+//!
+//! Atajos:
+//!   - `Ctrl+S` guarda el cuerpo activo (diff buffer → atoms → sled).
+//!   - `Ctrl+N` crea un documento Original nuevo.
+//!   - `Ctrl+J` togglea la junction anterior al caret (zonas).
+//!   - `Ctrl+Shift+]/[` saltan entre zonas.
+//!
+//! Botones del panel derecho dispara una transformación LLM sobre el
+//! cuerpo activo completo (Traducir → qu/en, Tono formal, Resumir 30p).
+//! La hija aparece como un cuerpo nuevo en la lista izquierda — click
+//! la activa.
+//!
+//! El crate está partido en módulos: `model` (Model+Msg+consts),
+//! `clipboard` (arboard), `util` (paths/etiquetas/reloj), `init`
+//! (apertura del sled + backend), `update` (lógica + LLM) y `view`
+//! (las tres columnas). Acá queda el `impl App` y el ruteo de teclado.
+
+mod clipboard;
+mod init;
+mod model;
+mod update;
+mod util;
+mod view;
+
+use llimphi_ui::{App, Handle, Key, KeyEvent, KeyState, NamedKey};
+use llimphi_ui::View;
+
+use crate::init::init_modelo;
+use crate::model::{Model, Msg};
+use crate::update::actualizar;
+use crate::view::{vista, vista_overlay};
+
+fn main() {
+    llimphi_ui::run::<Pluma>();
+}
+
+struct Pluma;
+
+impl App for Pluma {
+    type Model = Model;
+    type Msg = Msg;
+
+    fn title() -> &'static str {
+        "pluma · editor multilienzo"
+    }
+
+    /// `app_id` Wayland: pata lo usa para correlacionar foco ↔ dientes hospedados.
+    fn app_id() -> Option<&'static str> {
+        Some("gioser.pluma")
+    }
+
+    fn initial_size() -> (u32, u32) {
+        (1600, 900)
+    }
+
+    fn init(handle: &Handle<Msg>) -> Model {
+        let mut m = init_modelo();
+        // Rail hospedado: si delega, publica sus secciones como dientes en pata.
+        if m.delegated {
+            let teeth = vec![
+                pata_host::HostedTooth::new(0, "folder", "Documentos"),
+                pata_host::HostedTooth::new(1, "tools", "LLM"),
+                pata_host::HostedTooth::new(2, "files", "Buscar"),
+                pata_host::HostedTooth::new(3, "tools", "Diff"),
+            ];
+            let h = handle.clone();
+            m._host = pata_host::HostClient::connect("gioser.pluma", "Pluma", teeth, move |id| {
+                h.dispatch(Msg::HostActivate(id))
+            });
+        }
+        m
+    }
+
+    fn update(model: Model, msg: Msg, handle: &Handle<Msg>) -> Model {
+        actualizar(model, msg, handle)
+    }
+
+    fn on_key(model: &Self::Model, event: &KeyEvent) -> Option<Self::Msg> {
+        if event.state != KeyState::Pressed {
+            return None;
+        }
+        // Menús abiertos: las flechas navegan y tienen prioridad sobre todo.
+        if let Some(mi) = model.menu_open {
+            let n = crate::update::menu_principal(model).menus.len().max(1);
+            return match &event.key {
+                Key::Named(NamedKey::Escape) => Some(Msg::CloseMenus),
+                Key::Named(NamedKey::ArrowLeft) => Some(Msg::MenuOpen(Some((mi + n - 1) % n))),
+                Key::Named(NamedKey::ArrowRight) => Some(Msg::MenuOpen(Some((mi + 1) % n))),
+                Key::Named(NamedKey::ArrowDown) => Some(Msg::MenuNav(1)),
+                Key::Named(NamedKey::ArrowUp) => Some(Msg::MenuNav(-1)),
+                Key::Named(NamedKey::Enter) => Some(Msg::MenuActivate),
+                _ => None,
+            };
+        }
+        if model.edit_menu.is_some() {
+            return match &event.key {
+                Key::Named(NamedKey::Escape) => Some(Msg::CloseMenus),
+                Key::Named(NamedKey::ArrowDown) => Some(Msg::EditNav(1)),
+                Key::Named(NamedKey::ArrowUp) => Some(Msg::EditNav(-1)),
+                Key::Named(NamedKey::Enter) => Some(Msg::EditActivate),
+                _ => None,
+            };
+        }
+        // Si el input de ruta tiene foco, las teclas van ahí — incluso
+        // Ctrl/Shift combos. Esc lo apaga; cualquier otra cosa edita.
+        if model.path_focused {
+            if matches!(&event.key, Key::Named(NamedKey::Escape)) {
+                return Some(Msg::DefocusPath);
+            }
+            return Some(Msg::PathInputKey(event.clone()));
+        }
+        let ctrl = event.modifiers.ctrl || event.modifiers.meta;
+        let shift = event.modifiers.shift;
+        let alt = event.modifiers.alt;
+        // Alt+Flecha: mover el átomo bajo el caret. Lo capturamos antes
+        // que el editor para que no procese el evento como navegación.
+        if alt && !ctrl {
+            if matches!(&event.key, Key::Named(NamedKey::ArrowUp)) {
+                return Some(Msg::MoverAtomArriba);
+            }
+            if matches!(&event.key, Key::Named(NamedKey::ArrowDown)) {
+                return Some(Msg::MoverAtomAbajo);
+            }
+        }
+        // Find overlay capturado: Esc cierra, Enter/Shift+Enter ciclan
+        // matches, todo lo demás edita el query.
+        if model.find_visible {
+            if matches!(&event.key, Key::Named(NamedKey::Escape)) {
+                return Some(Msg::FindClose);
+            }
+            if matches!(&event.key, Key::Named(NamedKey::Enter)) {
+                return Some(if shift {
+                    Msg::FindAnterior
+                } else {
+                    Msg::FindSiguiente
+                });
+            }
+            // Ctrl+F otra vez cierra (atajo simétrico a abrir).
+            if ctrl {
+                if let Key::Character(s) = &event.key {
+                    if s.eq_ignore_ascii_case("f") {
+                        return Some(Msg::FindClose);
+                    }
+                }
+            }
+            return Some(Msg::FindKey(event.clone()));
+        }
+        if ctrl {
+            if let Key::Character(s) = &event.key {
+                if s.eq_ignore_ascii_case("s") {
+                    return Some(Msg::Guardar);
+                }
+                if s.eq_ignore_ascii_case("n") {
+                    return Some(Msg::NuevoDoc);
+                }
+                if s.eq_ignore_ascii_case("f") {
+                    return Some(Msg::FindToggle);
+                }
+                if s.eq_ignore_ascii_case("d") {
+                    return Some(Msg::DiffToggle);
+                }
+                if shift && (s == "}" || s == "]") {
+                    return Some(Msg::ZonaSiguiente);
+                }
+                if shift && (s == "{" || s == "[") {
+                    return Some(Msg::ZonaAnterior);
+                }
+                if s.eq_ignore_ascii_case("j") {
+                    return Some(Msg::ToglearFusion);
+                }
+            }
+        }
+        Some(Msg::EditorKey(event.clone()))
+    }
+
+    fn view(model: &Model) -> View<Msg> {
+        vista(model)
+    }
+
+    fn view_overlay(model: &Model) -> Option<View<Msg>> {
+        vista_overlay(model)
+    }
+}
diff --git a/00_unanchay/pluma/pluma-app/src/model.rs b/00_unanchay/pluma/pluma-app/src/model.rs
new file mode 100644
index 0000000..3f49c30
--- /dev/null
+++ b/00_unanchay/pluma/pluma-app/src/model.rs
@@ -0,0 +1,168 @@
+//! Modelo de la app y mensajes del bucle Elm.
+
+use std::collections::HashMap;
+use std::sync::Arc;
+
+use llimphi_ui::KeyEvent;
+use llimphi_widget_text_editor::{EditorMetrics, PointerEvent};
+use llimphi_widget_text_input::TextInputState;
+use pluma_align::CartaHebras;
+use pluma_core::NarrativeAtom;
+use pluma_cuerpo::Cuerpo;
+use pluma_editor_llimphi::cuerpo_ide::CuerpoIde;
+use pluma_llm::BackendKind;
+use pluma_llm_core::ChatClient;
+use pluma_store::PlumaStore;
+use pluma_transform::Transformacion;
+use uuid::Uuid;
+
+use crate::clipboard::ArboardClipboard;
+
+pub(crate) const METRICS: EditorMetrics = EditorMetrics::for_font_size(13.0);
+pub(crate) const VISIBLE_LINES: usize = 200;
+
+pub(crate) const BACKENDS: [BackendKind; 6] = [
+    BackendKind::Mock,
+    BackendKind::Gemini,
+    BackendKind::Anthropic,
+    BackendKind::DeepSeek,
+    BackendKind::Cohere,
+    BackendKind::Ollama,
+];
+
+#[derive(Clone, Debug)]
+pub(crate) enum Msg {
+    EditorKey(KeyEvent),
+    EditorPointer(PointerEvent),
+    AbrirDoc(Uuid),
+    NuevoDoc,
+    Guardar,
+    PathInputKey(KeyEvent),
+    FocusPath,
+    DefocusPath,
+    AbrirArchivo,
+    ExportarMd,
+    FindToggle,
+    FindKey(KeyEvent),
+    FindSiguiente,
+    FindAnterior,
+    FindClose,
+    DiffToggle,
+    /// Rail hospedado: pata reenvió un clic en un diente prestado (0=Documentos,
+    /// 1=LLM, 2=Buscar, 3=Diff). Togglea esa sección.
+    HostActivate(u32),
+    MoverAtomArriba,
+    MoverAtomAbajo,
+    TocarMadre,
+    RegenerarStale,
+    ToglearFusion,
+    ZonaSiguiente,
+    ZonaAnterior,
+    CicloBackend,
+    PedirTraducir(String),
+    PedirTono(String),
+    PedirResumir(Option<u32>),
+    LlmListo {
+        hija: Cuerpo,
+        atoms_nuevos: Vec<NarrativeAtom>,
+        carta: CartaHebras,
+        transformacion: Transformacion,
+    },
+    LlmError(String),
+    ResizeIzq(f32),
+    ResizeDer(f32),
+
+    // --- Menú principal + menú de edición contextual ---
+    /// Abre/cierra un dropdown del menú principal (índice del menú raíz).
+    MenuOpen(Option<usize>),
+    /// Comando string del menú principal (rebota desde `on_command`).
+    MenuCommand(String),
+    /// Navegación por teclado en el menú principal (`+1` baja, `-1` sube).
+    MenuNav(i32),
+    /// Enter en el menú principal: ejecuta la fila activa.
+    MenuActivate,
+    /// Tick de animación de menús (sólo re-render).
+    MenuTick,
+    /// Navegación por teclado en el menú de edición.
+    EditNav(i32),
+    /// Enter en el menú de edición: ejecuta la fila activa.
+    EditActivate,
+    /// Right-click: abre el menú de edición anclado en (x, y) de ventana.
+    EditMenuOpen(f32, f32),
+    /// Acción elegida en el menú de edición contextual.
+    EditMenuAction(llimphi_widget_edit_menu::EditAction),
+    /// Cierra cualquier menú abierto (dropdown o edición).
+    CloseMenus,
+}
+
+pub(crate) struct Model {
+    pub(crate) store: Arc<PlumaStore>,
+    pub(crate) cuerpos: Vec<Cuerpo>,
+    pub(crate) atoms: HashMap<Uuid, NarrativeAtom>,
+    pub(crate) cartas: Vec<CartaHebras>,
+    pub(crate) transformaciones: Vec<Transformacion>,
+    /// `id` del `Cuerpo` activo (el que se ve en `ide`). `None` sólo si
+    /// la lista de cuerpos está vacía — el init siembra uno para evitarlo.
+    pub(crate) activo: Option<Uuid>,
+    pub(crate) ide: CuerpoIde,
+    pub(crate) clipboard: ArboardClipboard,
+    pub(crate) drag_accum: (f32, f32),
+
+    pub(crate) chat: Arc<dyn ChatClient>,
+    pub(crate) backend_idx: usize,
+    pub(crate) en_curso: bool,
+    pub(crate) ultimo_error: Option<String>,
+    pub(crate) ultimo_status: String,
+
+    /// Ruta del archivo a abrir/exportar — input compartido.
+    /// Se interpreta según qué botón clickea el usuario.
+    pub(crate) path_input: TextInputState,
+    /// Cuando es `true`, las teclas del usuario van al `path_input` en
+    /// vez del editor. Click sobre el input lo enciende; Esc, o un
+    /// click fuera (en realidad, sólo Esc) lo apaga.
+    pub(crate) path_focused: bool,
+
+    /// Find-in-page sobre el cuerpo activo. `Ctrl+F` muestra el overlay
+    /// y lo enfoca; Esc lo cierra; Enter/Shift+Enter cyclan matches.
+    pub(crate) find_input: TextInputState,
+    pub(crate) find_visible: bool,
+    pub(crate) find_matches: Vec<(usize, usize)>,
+    pub(crate) find_idx: usize,
+
+    /// Cuando es `true` y el cuerpo activo es una hija, el centro
+    /// muestra la madre y la hija lado a lado con las hebras pintadas
+    /// (read-only). Cuando el activo es Original o no se encuentra la
+    /// madre, el flag igual existe pero la vista cae al cuerpo_ide
+    /// normal con un cartel.
+    pub(crate) diff_visible: bool,
+
+    pub(crate) side_izq_w: f32,
+    pub(crate) side_der_w: f32,
+
+    /// Índice del menú raíz cuyo dropdown está abierto (`None` = cerrado).
+    pub(crate) menu_open: Option<usize>,
+    /// Fila resaltada por teclado en el menú principal (`usize::MAX` = ninguna).
+    pub(crate) menu_active: usize,
+    /// Animación de aparición/swap del dropdown del menú principal (0→1).
+    pub(crate) menu_anim: llimphi_motion::Tween<f32>,
+    /// Ancla (x, y) en coords de ventana del menú de edición contextual,
+    /// o `None` si no está abierto.
+    pub(crate) edit_menu: Option<(f32, f32)>,
+    /// Fila resaltada por teclado en el menú de edición (`usize::MAX` = ninguna).
+    pub(crate) edit_active: usize,
+    /// Animación de aparición del menú de edición (0→1).
+    pub(crate) edit_anim: llimphi_motion::Tween<f32>,
+
+    // --- Rail hospedado (sidebar delegado a pata) ---
+    /// `true` si pluma delega su sidebar a pata (`PLUMA_DELEGATE_SIDEBAR`): sus
+    /// secciones aparecen como dientes en el rail de pata cuando tiene foco, y las
+    /// columnas laterales se pueden colapsar (editor a pantalla completa).
+    pub(crate) delegated: bool,
+    /// Visibilidad de la columna de Documentos (sólo aplica en modo delegado).
+    pub(crate) side_izq_visible: bool,
+    /// Visibilidad de la columna LLM (sólo aplica en modo delegado).
+    pub(crate) side_der_visible: bool,
+    /// Cliente del rail hospedado; sólo se retiene (las activaciones llegan por
+    /// callback). `_` evita el lint de campo sin leer.
+    pub(crate) _host: Option<pata_host::HostClient>,
+}
diff --git a/00_unanchay/pluma/pluma-app/src/update.rs b/00_unanchay/pluma/pluma-app/src/update.rs
new file mode 100644
index 0000000..4a0bed4
--- /dev/null
+++ b/00_unanchay/pluma/pluma-app/src/update.rs
@@ -0,0 +1,1004 @@
+//! Lógica de actualización del bucle Elm: el `match` central, las
+//! mutaciones del modelo (abrir/crear/guardar/mover/regenerar), el
+//! find-in-page, y el trabajo LLM lanzado en un thread aparte.
+
+use std::collections::HashMap;
+
+use llimphi_motion::{animate, motion, Tween};
+use llimphi_ui::Handle;
+use llimphi_widget_edit_menu::{self as editmenu, EditAction, EditFlags};
+use llimphi_widget_text_editor::PointerEvent;
+use pluma_align::CartaHebras;
+use pluma_core::NarrativeAtom;
+use pluma_cuerpo::{Cuerpo, Intencion};
+use pluma_editor_cuerpo::CambioAtom;
+use pluma_llm::{build_client, LlmConfig};
+use pluma_transform::{TipoTransformacion, Transformacion};
+use pluma_transform_llm::{EjecutorResumirLlm, EjecutorTonoLlm, EjecutorTraducirLlm};
+use uuid::Uuid;
+
+use crate::model::{Model, Msg, BACKENDS, METRICS, VISIBLE_LINES};
+use crate::util::{ahora_unix, etiqueta_backend, expandir_ruta, extension_lower};
+
+pub(crate) fn actualizar(mut model: Model, msg: Msg, handle: &Handle<Msg>) -> Model {
+    match msg {
+        Msg::EditorKey(ev) => {
+            let _ = model.ide.apply_key_with_clipboard(&ev, &mut model.clipboard);
+        }
+        Msg::EditorPointer(ev) => {
+            let scroll = model.ide.state.scroll_offset;
+            match ev {
+                PointerEvent::Click { x, y } => {
+                    model.drag_accum = (0.0, 0.0);
+                    let (line, col) = METRICS.screen_to_pos(x, y, scroll);
+                    model.ide.set_caret(line, col);
+                }
+                PointerEvent::Drag {
+                    initial_x,
+                    initial_y,
+                    dx,
+                    dy,
+                } => {
+                    model.drag_accum.0 += dx;
+                    model.drag_accum.1 += dy;
+                    let cx = initial_x + model.drag_accum.0;
+                    let cy = initial_y + model.drag_accum.1;
+                    let (line, col) = METRICS.screen_to_pos(cx, cy, scroll);
+                    model.ide.state.extend_selection_to(line, col);
+                }
+            }
+        }
+        Msg::AbrirDoc(id) => {
+            cambiar_activo(&mut model, id);
+        }
+        Msg::NuevoDoc => {
+            crear_doc_nuevo(&mut model);
+        }
+        Msg::Guardar => {
+            guardar_activo(&mut model);
+        }
+        Msg::PathInputKey(ev) => {
+            model.path_input.apply_key(&ev);
+        }
+        Msg::FocusPath => {
+            model.path_focused = true;
+        }
+        Msg::DefocusPath => {
+            model.path_focused = false;
+        }
+        Msg::AbrirArchivo => {
+            model.path_focused = false;
+            abrir_archivo(&mut model);
+        }
+        Msg::ExportarMd => {
+            model.path_focused = false;
+            exportar_md(&mut model);
+        }
+        Msg::FindToggle => {
+            model.find_visible = !model.find_visible;
+            if model.find_visible {
+                recomputar_matches(&mut model);
+                if !model.find_matches.is_empty() {
+                    saltar_a_match(&mut model);
+                }
+            }
+        }
+        Msg::FindKey(ev) => {
+            model.find_input.apply_key(&ev);
+            recomputar_matches(&mut model);
+            if !model.find_matches.is_empty() {
+                saltar_a_match(&mut model);
+            }
+        }
+        Msg::FindSiguiente => {
+            if model.find_matches.is_empty() {
+                return model;
+            }
+            model.find_idx = (model.find_idx + 1) % model.find_matches.len();
+            saltar_a_match(&mut model);
+        }
+        Msg::FindAnterior => {
+            if model.find_matches.is_empty() {
+                return model;
+            }
+            let n = model.find_matches.len();
+            model.find_idx = (model.find_idx + n - 1) % n;
+            saltar_a_match(&mut model);
+        }
+        Msg::FindClose => {
+            model.find_visible = false;
+        }
+        Msg::DiffToggle => {
+            model.diff_visible = !model.diff_visible;
+        }
+        // Rail hospedado: pata reenvió un diente. 0/1 colapsan las columnas
+        // (editor a pantalla completa), 2/3 togglean Buscar/Diff (su misma lógica).
+        Msg::HostActivate(id) => match id {
+            0 => model.side_izq_visible = !model.side_izq_visible,
+            1 => model.side_der_visible = !model.side_der_visible,
+            2 => {
+                model.find_visible = !model.find_visible;
+                if model.find_visible {
+                    recomputar_matches(&mut model);
+                    if !model.find_matches.is_empty() {
+                        saltar_a_match(&mut model);
+                    }
+                }
+            }
+            3 => model.diff_visible = !model.diff_visible,
+            _ => {}
+        },
+        Msg::MoverAtomArriba => {
+            mover_atom_caret(&mut model, -1);
+        }
+        Msg::MoverAtomAbajo => {
+            mover_atom_caret(&mut model, 1);
+        }
+        Msg::TocarMadre => {
+            tocar_madre(&mut model);
+        }
+        Msg::RegenerarStale => {
+            regenerar_siguiente_stale(&mut model, handle);
+        }
+        Msg::ToglearFusion => {
+            if let Some(idx) = model.ide.junction_antes_del_caret() {
+                model.ide.togglear_junction(idx);
+            }
+        }
+        Msg::ZonaSiguiente => {
+            model.ide.ir_a_zona_siguiente();
+            model.ide.state.ensure_caret_visible(VISIBLE_LINES);
+        }
+        Msg::ZonaAnterior => {
+            model.ide.ir_a_zona_anterior();
+            model.ide.state.ensure_caret_visible(VISIBLE_LINES);
+        }
+        Msg::CicloBackend => {
+            cycle_backend(&mut model);
+        }
+        Msg::PedirTraducir(lengua) => {
+            lanzar(&mut model, handle, TrabajoLlm::Traducir(lengua));
+        }
+        Msg::PedirTono(etiqueta) => {
+            lanzar(&mut model, handle, TrabajoLlm::Tono(etiqueta));
+        }
+        Msg::PedirResumir(palabras) => {
+            lanzar(&mut model, handle, TrabajoLlm::Resumir(palabras));
+        }
+        Msg::LlmListo {
+            hija,
+            atoms_nuevos,
+            carta,
+            transformacion,
+        } => {
+            recibir_hija(&mut model, hija, atoms_nuevos, carta, transformacion);
+        }
+        Msg::LlmError(s) => {
+            eprintln!("pluma-app :: error LLM: {s}");
+            model.ultimo_error = Some(s);
+            model.en_curso = false;
+        }
+        Msg::ResizeIzq(dx) => {
+            model.side_izq_w = (model.side_izq_w + dx).clamp(160.0, 420.0);
+        }
+        Msg::ResizeDer(dx) => {
+            // El divisor está del lado izquierdo de la columna derecha:
+            // dx>0 = divisor a la derecha = panel der encoge.
+            model.side_der_w = (model.side_der_w - dx).clamp(220.0, 520.0);
+        }
+
+        // --- Menú principal + menú de edición contextual ---
+        Msg::MenuOpen(idx) => {
+            model.menu_open = idx;
+            model.menu_active = usize::MAX;
+            model.edit_menu = None;
+            if idx.is_some() {
+                model.menu_anim = Tween::new(0.0, 1.0, motion::FAST, motion::ease_out_cubic);
+                animate(handle, motion::FAST, || Msg::MenuTick);
+            }
+        }
+        Msg::CloseMenus => {
+            model.menu_open = None;
+            model.menu_active = usize::MAX;
+            model.edit_menu = None;
+            model.edit_active = usize::MAX;
+        }
+        Msg::EditMenuOpen(x, y) => {
+            model.edit_menu = Some((x, y));
+            model.edit_active = usize::MAX;
+            model.menu_open = None;
+            model.edit_anim = Tween::new(0.0, 1.0, motion::FAST, motion::ease_out_cubic);
+            animate(handle, motion::FAST, || Msg::MenuTick);
+        }
+        Msg::EditMenuAction(action) => {
+            return aplicar_edit_menu(model, action);
+        }
+        Msg::MenuCommand(cmd) => {
+            return ejecutar_menu_command(model, cmd, handle);
+        }
+        Msg::MenuNav(dir) => {
+            if let Some(mi) = model.menu_open {
+                let menu = menu_principal(&model);
+                model.menu_active =
+                    llimphi_widget_menubar::menubar_nav(&menu, mi, model.menu_active, dir);
+            }
+        }
+        Msg::MenuActivate => {
+            if let Some(mi) = model.menu_open {
+                let menu = menu_principal(&model);
+                if let Some(cmd) =
+                    llimphi_widget_menubar::menubar_command_at(&menu, mi, model.menu_active)
+                {
+                    return ejecutar_menu_command(model, cmd, handle);
+                }
+            }
+        }
+        Msg::MenuTick => {}
+        Msg::EditNav(dir) => {
+            let flags = EditFlags::from_editor(&model.ide.state, false);
+            model.edit_active = editmenu::edit_menu_step(flags, model.edit_active, dir);
+        }
+        Msg::EditActivate => {
+            let flags = EditFlags::from_editor(&model.ide.state, false);
+            if let Some(action) = editmenu::edit_menu_action_at(flags, model.edit_active) {
+                return aplicar_edit_menu(model, action);
+            }
+        }
+    }
+    model
+}
+
+/// Construye el menú principal de pluma reflejando el estado actual: los
+/// ítems de Editar quedan grises cuando no hay selección (Cortar/Copiar) o
+/// historial (Deshacer/Rehacer). El editor focuseado es el `cuerpo_ide`
+/// central (único editor de texto rico de la app).
+pub(crate) fn menu_principal(model: &Model) -> app_bus::AppMenu {
+    use app_bus::{AppMenu, Menu, MenuItem};
+
+    let ed = &model.ide.state;
+    let has_sel = ed.has_selection();
+    let can_undo = ed.can_undo();
+    let can_redo = ed.can_redo();
+
+    let mut undo = MenuItem::new("Deshacer", "edit.undo").shortcut("Ctrl+Z");
+    if !can_undo {
+        undo = undo.disabled();
+    }
+    let mut redo = MenuItem::new("Rehacer", "edit.redo").shortcut("Ctrl+Y");
+    if !can_redo {
+        redo = redo.disabled();
+    }
+    let mut cut = MenuItem::new("Cortar", "edit.cut").shortcut("Ctrl+X").separated();
+    let mut copy = MenuItem::new("Copiar", "edit.copy").shortcut("Ctrl+C");
+    if !has_sel {
+        cut = cut.disabled();
+        copy = copy.disabled();
+    }
+    let paste = MenuItem::new("Pegar", "edit.paste").shortcut("Ctrl+V");
+    let sel_all = MenuItem::new("Seleccionar todo", "edit.selectall")
+        .shortcut("Ctrl+A")
+        .separated();
+
+    // El botón de regenerar stale sólo tiene sentido si hay alguna hija
+    // stale del activo — lo grisamos cuando no.
+    let mut regen = MenuItem::new("Regenerar stale", "llm.regen");
+    if contar_stale_del_activo(model) == 0 {
+        regen = regen.disabled();
+    }
+
+    AppMenu::new()
+        .menu(
+            Menu::new("Archivo")
+                .item(MenuItem::new("Nuevo documento", "file.nuevo").shortcut("Ctrl+N"))
+                .item(MenuItem::new("Guardar", "file.guardar").shortcut("Ctrl+S"))
+                .item(MenuItem::new("Abrir archivo (ruta)", "file.abrir").separated())
+                .item(MenuItem::new("Exportar (md/docx)", "file.exportar")),
+        )
+        .menu(
+            Menu::new("Editar")
+                .item(undo)
+                .item(redo)
+                .item(cut)
+                .item(copy)
+                .item(paste)
+                .item(sel_all),
+        )
+        .menu(
+            Menu::new("Buscar")
+                .item(MenuItem::new("Buscar en documento", "search.find").shortcut("Ctrl+F")),
+        )
+        .menu(
+            Menu::new("Multilienzo")
+                .item(MenuItem::new("Diff madre ↔ hija", "mult.diff").shortcut("Ctrl+D"))
+                .item(MenuItem::new("Togglear fusión (zona)", "mult.fusion").shortcut("Ctrl+J"))
+                .item(MenuItem::new("Zona siguiente", "mult.zona_sig").separated())
+                .item(MenuItem::new("Zona anterior", "mult.zona_ant")),
+        )
+        .menu(
+            Menu::new("LLM")
+                .item(MenuItem::new("Ciclar backend", "llm.backend"))
+                .item(MenuItem::new("Traducir → qu", "llm.trad_qu"))
+                .item(MenuItem::new("Traducir → en", "llm.trad_en"))
+                .item(MenuItem::new("Tono formal", "llm.tono"))
+                .item(MenuItem::new("Resumir 30p", "llm.resumir"))
+                .item(MenuItem::new("Tocar madre", "llm.tocar").separated())
+                .item(regen),
+        )
+        .menu(
+            Menu::new("Ayuda")
+                .item(MenuItem::new("pluma · editor multilienzo", "help.about").disabled()),
+        )
+}
+
+/// Traduce el `command` string del menú principal al `Msg` real de la app
+/// y lo aplica. Cierra el dropdown antes de actuar. Los comandos `edit.*`
+/// se enrutan al menú de edición sobre el `cuerpo_ide`.
+fn ejecutar_menu_command(mut model: Model, command: String, handle: &Handle<Msg>) -> Model {
+    model.menu_open = None;
+    let target = match command.as_str() {
+        "file.nuevo" => Some(Msg::NuevoDoc),
+        "file.guardar" => Some(Msg::Guardar),
+        "file.abrir" => Some(Msg::AbrirArchivo),
+        "file.exportar" => Some(Msg::ExportarMd),
+        "edit.undo" => Some(Msg::EditMenuAction(EditAction::Undo)),
+        "edit.redo" => Some(Msg::EditMenuAction(EditAction::Redo)),
+        "edit.cut" => Some(Msg::EditMenuAction(EditAction::Cut)),
+        "edit.copy" => Some(Msg::EditMenuAction(EditAction::Copy)),
+        "edit.paste" => Some(Msg::EditMenuAction(EditAction::Paste)),
+        "edit.selectall" => Some(Msg::EditMenuAction(EditAction::SelectAll)),
+        "search.find" => Some(Msg::FindToggle),
+        "mult.diff" => Some(Msg::DiffToggle),
+        "mult.fusion" => Some(Msg::ToglearFusion),
+        "mult.zona_sig" => Some(Msg::ZonaSiguiente),
+        "mult.zona_ant" => Some(Msg::ZonaAnterior),
+        "llm.backend" => Some(Msg::CicloBackend),
+        "llm.trad_qu" => Some(Msg::PedirTraducir("qu".into())),
+        "llm.trad_en" => Some(Msg::PedirTraducir("en".into())),
+        "llm.tono" => Some(Msg::PedirTono("formal".into())),
+        "llm.resumir" => Some(Msg::PedirResumir(Some(30))),
+        "llm.tocar" => Some(Msg::TocarMadre),
+        "llm.regen" => Some(Msg::RegenerarStale),
+        _ => None,
+    };
+    match target {
+        Some(msg) => actualizar(model, msg, handle),
+        None => model,
+    }
+}
+
+/// Aplica una acción del menú de edición al `EditorState` del cuerpo_ide,
+/// reusando `editmenu::apply` (mismo camino que las teclas de edición).
+/// Cierra el menú. Como `apply_key_with_clipboard`, no necesita marcar
+/// dirty manual: el `CuerpoIde` deriva el pendiente_sync de su `edit_seq`.
+fn aplicar_edit_menu(mut model: Model, action: EditAction) -> Model {
+    model.edit_menu = None;
+    let _ = llimphi_widget_edit_menu::apply(&mut model.ide.state, action, &mut model.clipboard);
+    model.ide.state.ensure_caret_visible(VISIBLE_LINES);
+    model
+}
+
+fn cambiar_activo(model: &mut Model, id: Uuid) {
+    if model.activo == Some(id) {
+        return;
+    }
+    let cuerpo = match model.cuerpos.iter().find(|c| c.id == id) {
+        Some(c) => c.clone(),
+        None => return,
+    };
+    model.activo = Some(id);
+    let idx: HashMap<Uuid, &NarrativeAtom> =
+        model.atoms.iter().map(|(k, v)| (*k, v)).collect();
+    model.ide.recargar(&cuerpo, &idx);
+    model.ultimo_status = format!("doc: {}", cuerpo.metadatos.nombre_legible);
+}
+
+fn crear_doc_nuevo(model: &mut Model) {
+    let ahora = ahora_unix();
+    let n = model
+        .cuerpos
+        .iter()
+        .filter(|c| !c.metadatos.intencion.es_derivada())
+        .count()
+        + 1;
+    let atom = NarrativeAtom::new("Empieza a escribir aquí…", "es");
+    let mut cuerpo = Cuerpo::nuevo(
+        format!("es-{n}"),
+        format!("doc #{n} sin título"),
+        Intencion::Original,
+        ahora,
+    );
+    cuerpo.agregar(atom.id, ahora);
+    let _ = model.store.put_atom(&atom);
+    let _ = model.store.put_cuerpo(&cuerpo);
+    let _ = model.store.flush();
+    let id = cuerpo.id;
+    model.atoms.insert(atom.id, atom);
+    model.cuerpos.push(cuerpo);
+    cambiar_activo(model, id);
+    model.ultimo_status = format!("doc #{n} creado");
+}
+
+fn guardar_activo(model: &mut Model) {
+    let Some(activo_id) = model.activo else {
+        model.ultimo_status = "sin doc activo".into();
+        return;
+    };
+    let idx: HashMap<Uuid, &NarrativeAtom> =
+        model.atoms.iter().map(|(k, v)| (*k, v)).collect();
+    let cambios = model.ide.diff(&idx);
+    drop(idx);
+
+    if cambios.is_empty() {
+        model.ultimo_status = "sin cambios".into();
+        return;
+    }
+
+    let mut creados: Vec<Uuid> = Vec::new();
+    let branch_id = model
+        .cuerpos
+        .iter()
+        .find(|c| c.id == activo_id)
+        .map(|c| c.branch_id.clone())
+        .unwrap_or_else(|| "es".to_string());
+
+    for c in &cambios {
+        match c {
+            CambioAtom::Mutar { id, texto_nuevo } => {
+                if let Some(a) = model.atoms.get_mut(id) {
+                    a.set_content(texto_nuevo.as_str());
+                    let _ = model.store.put_atom(a);
+                }
+            }
+            CambioAtom::Crear { texto, posicion: _ } => {
+                let atom = NarrativeAtom::new(texto.as_str(), &branch_id);
+                let id = atom.id;
+                let _ = model.store.put_atom(&atom);
+                model.atoms.insert(id, atom);
+                creados.push(id);
+            }
+            CambioAtom::Eliminar { id } => {
+                model.atoms.remove(id);
+                // El sled mantiene el atom histórico — no lo borramos
+                // del backend porque hijas/cartas pueden seguir apuntando
+                // a él. La memoria local sí lo descarta.
+            }
+        }
+    }
+
+    model.ide.aplicar_cambios(&cambios, &creados);
+
+    // Reconstruir `cuerpo.orden` con el orden nuevo del IDE.
+    let nuevo_orden: Vec<Uuid> = model.ide.editor_cuerpo.atom_ids.clone();
+    if let Some(c) = model.cuerpos.iter_mut().find(|c| c.id == activo_id) {
+        let ahora = c.metadatos.modificado_en.saturating_add(1);
+        let viejo: Vec<Uuid> = c.orden.clone();
+        for id in &viejo {
+            let _ = c.remover(*id, ahora);
+        }
+        for id in &nuevo_orden {
+            c.agregar(*id, ahora);
+        }
+        let _ = model.store.put_cuerpo(c);
+    }
+    let _ = model.store.flush();
+
+    let n_mut = cambios
+        .iter()
+        .filter(|c| matches!(c, CambioAtom::Mutar { .. }))
+        .count();
+    let n_new = creados.len();
+    let n_del = cambios
+        .iter()
+        .filter(|c| matches!(c, CambioAtom::Eliminar { .. }))
+        .count();
+    model.ultimo_status = format!("guardado: {n_mut} mut · {n_new} crear · {n_del} del");
+}
+
+/// Recalcula las posiciones (línea, col) donde aparece el query en el
+/// buffer actual. Búsqueda case-insensitive, substring. Llamarlo cada
+/// vez que el query o el texto cambian. Reset de `find_idx` al primer
+/// match cuando hay alguno; lo deja en 0 si no hay (consistente con
+/// "0 de 0"), pero la UI no salta si está vacío.
+fn recomputar_matches(model: &mut Model) {
+    let query = model.find_input.text();
+    if query.is_empty() {
+        model.find_matches.clear();
+        model.find_idx = 0;
+        return;
+    }
+    let q_lower = query.to_lowercase();
+    let mut matches: Vec<(usize, usize)> = Vec::new();
+    let texto = model.ide.texto_buffer();
+    for (line_idx, linea) in texto.lines().enumerate() {
+        let l_lower = linea.to_lowercase();
+        let mut start = 0;
+        while let Some(pos) = l_lower[start..].find(&q_lower) {
+            let col = start + pos;
+            matches.push((line_idx, col));
+            start = col + q_lower.len().max(1);
+            if start >= l_lower.len() {
+                break;
+            }
+        }
+    }
+    model.find_matches = matches;
+    if model.find_idx >= model.find_matches.len() {
+        model.find_idx = 0;
+    }
+}
+
+fn saltar_a_match(model: &mut Model) {
+    let Some(&(line, col)) = model.find_matches.get(model.find_idx) else {
+        return;
+    };
+    model.ide.set_caret(line, col);
+    model.ide.state.ensure_caret_visible(VISIBLE_LINES);
+}
+
+/// Avanza `modificado_en` del cuerpo activo a la hora actual. Cualquier
+/// hija derivada cuyo `regenerada_en` sea anterior se vuelve `es_stale`
+/// y aparece en el botón «regenerar stale (N)». Caso de uso típico:
+/// editaste la madre sin querer recordar todos los detalles y querés
+/// invalidar las derivadas para que vuelvan a salir del LLM.
+fn tocar_madre(model: &mut Model) {
+    let Some(activo_id) = model.activo else {
+        model.ultimo_status = "sin doc activo".into();
+        return;
+    };
+    let ahora = ahora_unix();
+    if let Some(c) = model.cuerpos.iter_mut().find(|c| c.id == activo_id) {
+        c.metadatos.modificado_en = ahora;
+        let _ = model.store.put_cuerpo(c);
+    }
+    let _ = model.store.flush();
+    let n = contar_stale_del_activo(model);
+    model.ultimo_status = format!("madre tocada — {n} hija(s) ahora stale");
+    model.ultimo_error = None;
+}
+
+pub(crate) fn contar_stale_del_activo(model: &Model) -> usize {
+    let Some(activo_id) = model.activo else {
+        return 0;
+    };
+    let Some(madre) = model.cuerpos.iter().find(|c| c.id == activo_id) else {
+        return 0;
+    };
+    let modif = madre.metadatos.modificado_en;
+    model
+        .cuerpos
+        .iter()
+        .filter(|c| {
+            c.metadatos.derivado_de == Some(activo_id) && c.es_derivado() && c.es_stale(modif)
+        })
+        .count()
+}
+
+/// Encuentra la primera hija del activo que sea stale, busca la
+/// `Transformacion` original registrada (madre==activo, hija==hija_id),
+/// traduce su `TipoTransformacion` a un `TrabajoLlm`, y lo lanza con la
+/// madre actualizada — el ejecutor produce una hija nueva fresca; la
+/// vieja queda en el modelo (sigue visible por si querés diff). Lo
+/// hacemos hija-por-hija (no batch) para que el progreso sea visible
+/// y un error no aborte todas.
+fn regenerar_siguiente_stale(model: &mut Model, handle: &Handle<Msg>) {
+    if model.en_curso {
+        model.ultimo_status = "LLM ocupado — esperá".into();
+        return;
+    }
+    let Some(activo_id) = model.activo else {
+        model.ultimo_status = "sin doc activo".into();
+        return;
+    };
+    let madre_modif = match model.cuerpos.iter().find(|c| c.id == activo_id) {
+        Some(c) => c.metadatos.modificado_en,
+        None => return,
+    };
+    let hija_id_opt = model
+        .cuerpos
+        .iter()
+        .find(|c| {
+            c.metadatos.derivado_de == Some(activo_id)
+                && c.es_derivado()
+                && c.es_stale(madre_modif)
+        })
+        .map(|c| c.id);
+    let Some(hija_id) = hija_id_opt else {
+        model.ultimo_status = "no hay hijas stale — tocar madre primero".into();
+        return;
+    };
+    // Buscar la Transformacion original. Prioridad: la del store (en
+    // memoria está cargada al iniciar; el sled es la fuente de verdad).
+    let tipo = model
+        .transformaciones
+        .iter()
+        .find(|t| t.madre == activo_id && t.hija == hija_id)
+        .map(|t| t.tipo.clone());
+    let Some(tipo) = tipo else {
+        model.ultimo_status = format!(
+            "no se halló transformación para regenerar {hija_id} — falta historial"
+        );
+        return;
+    };
+    let Some(trabajo) = trabajo_de_tipo(&tipo) else {
+        model.ultimo_status = format!("tipo {tipo:?} no es regenerable automáticamente");
+        return;
+    };
+    lanzar(model, handle, trabajo);
+}
+
+/// Traduce un `TipoTransformacion` persistido al `TrabajoLlm` que
+/// `lanzar` sabe correr. `Identidad`/`Reescribir`/`Custom` no son
+/// auto-regenerables — Reescribir necesita prompt humano, Custom Rhai,
+/// Identidad no aporta nada nuevo.
+fn trabajo_de_tipo(t: &TipoTransformacion) -> Option<TrabajoLlm> {
+    match t {
+        TipoTransformacion::Traducir { lengua_destino } => {
+            Some(TrabajoLlm::Traducir(lengua_destino.clone()))
+        }
+        TipoTransformacion::Tono { etiqueta } => Some(TrabajoLlm::Tono(etiqueta.clone())),
+        TipoTransformacion::Resumir { palabras_objetivo } => {
+            Some(TrabajoLlm::Resumir(*palabras_objetivo))
+        }
+        _ => None,
+    }
+}
+
+/// Mueve el átomo donde está el caret una posición arriba (`delta=-1`)
+/// o abajo (`delta=1`). Sincroniza el buffer al modelo antes de
+/// reordenar (para no perder ediciones pendientes), muta `cuerpo.orden`,
+/// persiste, y recarga el IDE — junctions resetean a separadores (es
+/// el costo del reorder; el usuario las re-fusiona si las quería).
+/// El caret queda en la primera línea del átomo movido.
+fn mover_atom_caret(model: &mut Model, delta: i32) {
+    let Some(activo_id) = model.activo else {
+        return;
+    };
+    // Sincroniza pendientes para no perderlos al recargar.
+    guardar_activo(model);
+
+    let (caret_line, _) = model.ide.caret();
+    let Some(atom_id) = model.ide.atom_id_en_linea(caret_line) else {
+        return;
+    };
+    let cuerpo = match model.cuerpos.iter_mut().find(|c| c.id == activo_id) {
+        Some(c) => c,
+        None => return,
+    };
+    let n = cuerpo.orden.len();
+    if n < 2 {
+        return;
+    }
+    let i = match cuerpo.orden.iter().position(|x| *x == atom_id) {
+        Some(i) => i,
+        None => return,
+    };
+    let j = if delta < 0 {
+        if i == 0 {
+            return;
+        }
+        i - 1
+    } else {
+        if i + 1 >= n {
+            return;
+        }
+        i + 1
+    };
+    cuerpo.orden.swap(i, j);
+    cuerpo.metadatos.modificado_en = cuerpo.metadatos.modificado_en.saturating_add(1);
+    let _ = model.store.put_cuerpo(cuerpo);
+    let _ = model.store.flush();
+
+    // Recargar el IDE con el orden nuevo. Snapshot la cuerpo data
+    // primero para evitar el borrow simultáneo del index.
+    let cuerpo_clon = cuerpo.clone();
+    // Liberamos el préstamo mutable de `model.cuerpos` antes de
+    // tomar uno inmutable de `model.atoms` para construir el índice.
+    let _ = cuerpo;
+    let idx: HashMap<Uuid, &NarrativeAtom> =
+        model.atoms.iter().map(|(k, v)| (*k, v)).collect();
+    model.ide.recargar(&cuerpo_clon, &idx);
+    drop(idx);
+
+    // Posicionar el caret al inicio del átomo movido. Su nuevo idx es
+    // `j`; sumamos lineas anteriores (cada atom = 1 + atoms_extra_lineas
+    // + separador). Más simple: usar posicion_de_atom.
+    if let Some((line, col)) = model.ide.posicion_de_atom(atom_id) {
+        model.ide.set_caret(line, col);
+        model.ide.state.ensure_caret_visible(VISIBLE_LINES);
+    }
+
+    model.ultimo_status = format!(
+        "atom movido {}",
+        if delta < 0 { "↑" } else { "↓" }
+    );
+    model.ultimo_error = None;
+}
+
+fn abrir_archivo(model: &mut Model) {
+    let path_raw = model.path_input.text().trim().to_string();
+    if path_raw.is_empty() {
+        model.ultimo_error = Some("ruta vacía".into());
+        return;
+    }
+    let path = expandir_ruta(&path_raw);
+    let bytes = match std::fs::read(&path) {
+        Ok(b) => b,
+        Err(e) => {
+            model.ultimo_error = Some(format!("leyendo {path:?}: {e}"));
+            return;
+        }
+    };
+    let nombre = path
+        .file_name()
+        .map(|s| s.to_string_lossy().into_owned())
+        .unwrap_or_else(|| "archivo".to_string());
+    let ahora = ahora_unix();
+
+    let importado = if extension_lower(&path) == Some("docx".to_string()) {
+        match foreign_docx::parse_docx(&bytes, "es", nombre.clone(), ahora) {
+            Ok(imp) => (imp.cuerpo, imp.atoms),
+            Err(e) => {
+                model.ultimo_error = Some(format!("parse_docx {nombre}: {e:?}"));
+                return;
+            }
+        }
+    } else if extension_lower(&path) == Some("md".to_string())
+        || extension_lower(&path) == Some("markdown".to_string())
+        || extension_lower(&path) == Some("txt".to_string())
+    {
+        let texto = match std::str::from_utf8(&bytes) {
+            Ok(s) => s.to_string(),
+            Err(e) => {
+                model.ultimo_error = Some(format!("{nombre} no es UTF-8: {e}"));
+                return;
+            }
+        };
+        let imp = pluma_md::parse_md(&texto, "es", nombre.clone(), ahora);
+        (imp.cuerpo, imp.atoms)
+    } else {
+        model.ultimo_error = Some(format!(
+            "extensión no soportada en {nombre} — usá .md o .docx"
+        ));
+        return;
+    };
+
+    let (cuerpo, atoms_nuevos) = importado;
+    if atoms_nuevos.is_empty() {
+        model.ultimo_error = Some(format!("{nombre} no produjo átomos"));
+        return;
+    }
+    for a in &atoms_nuevos {
+        let _ = model.store.put_atom(a);
+        model.atoms.insert(a.id, a.clone());
+    }
+    let _ = model.store.put_cuerpo(&cuerpo);
+    let _ = model.store.flush();
+    let id = cuerpo.id;
+    let n = atoms_nuevos.len();
+    model.cuerpos.push(cuerpo);
+    model.ultimo_status = format!("abierto «{nombre}»: {n} átomos");
+    model.ultimo_error = None;
+    cambiar_activo(model, id);
+}
+
+fn exportar_md(model: &mut Model) {
+    let Some(activo_id) = model.activo else {
+        model.ultimo_error = Some("sin doc activo".into());
+        return;
+    };
+    let path_raw = model.path_input.text().trim().to_string();
+    if path_raw.is_empty() {
+        model.ultimo_error = Some("ruta vacía".into());
+        return;
+    }
+    let path = expandir_ruta(&path_raw);
+    let Some(cuerpo) = model.cuerpos.iter().find(|c| c.id == activo_id) else {
+        model.ultimo_error = Some("doc activo desapareció".into());
+        return;
+    };
+
+    let ext = extension_lower(&path).unwrap_or_default();
+    let bytes: Vec<u8> = if ext == "docx" {
+        match foreign_docx::write_docx(cuerpo, &model.atoms) {
+            Ok(b) => b,
+            Err(e) => {
+                model.ultimo_error = Some(format!("write_docx: {e}"));
+                return;
+            }
+        }
+    } else if ext.is_empty() || ext == "md" || ext == "markdown" || ext == "txt" {
+        let md = pluma_md::to_md(cuerpo, &model.atoms);
+        if md.is_empty() {
+            model.ultimo_error = Some("doc vacío — nada que exportar".into());
+            return;
+        }
+        md.into_bytes()
+    } else {
+        model.ultimo_error = Some(format!(
+            "extensión .{ext} no soportada — usá .md o .docx"
+        ));
+        return;
+    };
+
+    if let Some(parent) = path.parent() {
+        let _ = std::fs::create_dir_all(parent);
+    }
+    match std::fs::write(&path, &bytes) {
+        Ok(()) => {
+            model.ultimo_status = format!(
+                "exportado «{}» a {} ({} bytes)",
+                cuerpo.metadatos.nombre_legible,
+                path.display(),
+                bytes.len(),
+            );
+            model.ultimo_error = None;
+        }
+        Err(e) => {
+            model.ultimo_error = Some(format!("escribiendo {path:?}: {e}"));
+        }
+    }
+}
+
+fn cycle_backend(model: &mut Model) {
+    let total = BACKENDS.len();
+    for step in 1..=total {
+        let try_idx = (model.backend_idx + step) % total;
+        let kind = BACKENDS[try_idx];
+        match build_client(&LlmConfig {
+            kind,
+            ..Default::default()
+        }) {
+            Ok(c) => {
+                model.chat = c;
+                model.backend_idx = try_idx;
+                model.ultimo_status = format!("backend → {}", etiqueta_backend(kind));
+                model.ultimo_error = None;
+                return;
+            }
+            Err(e) => {
+                model.ultimo_error = Some(format!("backend {kind:?}: {e}"));
+            }
+        }
+    }
+    // Si todos fallaron (no debería: Mock siempre funciona), no-op.
+}
+
+fn recibir_hija(
+    model: &mut Model,
+    hija: Cuerpo,
+    atoms_nuevos: Vec<NarrativeAtom>,
+    carta: CartaHebras,
+    transformacion: Transformacion,
+) {
+    for a in &atoms_nuevos {
+        let _ = model.store.put_atom(a);
+        model.atoms.insert(a.id, a.clone());
+    }
+    let _ = model.store.put_cuerpo(&hija);
+    let _ = model.store.put_carta(&carta);
+    let _ = model.store.put_transformacion(&transformacion);
+    let _ = model.store.flush();
+    let hija_id = hija.id;
+    let nombre = hija.metadatos.nombre_legible.clone();
+    model.cuerpos.push(hija);
+    model.cartas.push(carta);
+    model.transformaciones.push(transformacion);
+    model.en_curso = false;
+    model.ultimo_status = format!("hija «{nombre}» derivada");
+    cambiar_activo(model, hija_id);
+}
+
+// ---------------------------------------------------------------------
+// Trabajo LLM
+// ---------------------------------------------------------------------
+
+pub(crate) enum TrabajoLlm {
+    Traducir(String),
+    Tono(String),
+    Resumir(Option<u32>),
+}
+
+fn lanzar(model: &mut Model, handle: &Handle<Msg>, trabajo: TrabajoLlm) {
+    if model.en_curso {
+        return;
+    }
+    let Some(activo_id) = model.activo else {
+        model.ultimo_status = "sin doc activo".into();
+        return;
+    };
+    // Sincronizar antes de transformar — si el usuario tipeó sin Ctrl+S,
+    // queremos que el LLM vea el texto editado.
+    guardar_activo(model);
+
+    let madre = match model.cuerpos.iter().find(|c| c.id == activo_id) {
+        Some(c) => c.clone(),
+        None => {
+            model.ultimo_error = Some("doc activo desapareció".into());
+            return;
+        }
+    };
+    if madre.orden.is_empty() {
+        model.ultimo_status = "madre vacía — nada que transformar".into();
+        return;
+    }
+
+    let atoms_owned: Vec<NarrativeAtom> = model.atoms.values().cloned().collect();
+    let chat = model.chat.clone();
+    let h = handle.clone();
+    let ahora = ahora_unix();
+
+    model.en_curso = true;
+    model.ultimo_error = None;
+    model.ultimo_status = format!("LLM en curso ({} backend)", etiqueta_backend(BACKENDS[model.backend_idx]));
+
+    handle.spawn(move || {
+        let rt = match tokio::runtime::Builder::new_current_thread()
+            .enable_all()
+            .build()
+        {
+            Ok(rt) => rt,
+            Err(e) => return Msg::LlmError(format!("runtime tokio: {e}")),
+        };
+        let idx: HashMap<Uuid, &NarrativeAtom> =
+            atoms_owned.iter().map(|a| (a.id, a)).collect();
+
+        let resultado = rt.block_on(async {
+            match trabajo {
+                TrabajoLlm::Traducir(lengua) => {
+                    let ej = EjecutorTraducirLlm::from_arc(chat, lengua.clone());
+                    let t = Transformacion::nueva(
+                        madre.id,
+                        Uuid::new_v4(),
+                        TipoTransformacion::Traducir {
+                            lengua_destino: lengua,
+                        },
+                        "ui",
+                        ahora,
+                    );
+                    ej.aplicar_con_atoms(&t, &madre, &idx, ahora)
+                        .await
+                        .map(|p| (p, t))
+                }
+                TrabajoLlm::Tono(etiq) => {
+                    let ej = EjecutorTonoLlm::from_arc(chat, etiq.clone());
+                    let t = Transformacion::nueva(
+                        madre.id,
+                        Uuid::new_v4(),
+                        TipoTransformacion::Tono { etiqueta: etiq },
+                        "ui",
+                        ahora,
+                    );
+                    ej.aplicar_con_atoms(&t, &madre, &idx, ahora)
+                        .await
+                        .map(|p| (p, t))
+                }
+                TrabajoLlm::Resumir(palabras) => {
+                    let ej = EjecutorResumirLlm::from_arc(chat, palabras);
+                    let t = Transformacion::nueva(
+                        madre.id,
+                        Uuid::new_v4(),
+                        TipoTransformacion::Resumir {
+                            palabras_objetivo: palabras,
+                        },
+                        "ui",
+                        ahora,
+                    );
+                    ej.aplicar_con_atoms(&t, &madre, &idx, ahora)
+                        .await
+                        .map(|p| (p, t))
+                }
+            }
+        });
+
+        let _ = h;
+        match resultado {
+            Ok((prod, transformacion)) => Msg::LlmListo {
+                hija: prod.hija,
+                atoms_nuevos: prod.atoms_nuevos,
+                carta: prod.carta,
+                transformacion,
+            },
+            Err(e) => Msg::LlmError(format!("{e:?}")),
+        }
+    });
+}
diff --git a/00_unanchay/pluma/pluma-app/src/util.rs b/00_unanchay/pluma/pluma-app/src/util.rs
new file mode 100644
index 0000000..6967472
--- /dev/null
+++ b/00_unanchay/pluma/pluma-app/src/util.rs
@@ -0,0 +1,101 @@
+//! Helpers puros: rutas, expansión de `~`, etiquetas legibles de
+//! backend/intención/transformación, y el reloj unix.
+
+use std::path::{Path, PathBuf};
+
+use pluma_cuerpo::Intencion;
+use pluma_llm::BackendKind;
+use pluma_transform::TipoTransformacion;
+
+pub(crate) fn expandir_ruta(raw: &str) -> PathBuf {
+    if let Some(rest) = raw.strip_prefix("~/") {
+        if let Ok(home) = std::env::var("HOME") {
+            return PathBuf::from(home).join(rest);
+        }
+    } else if raw == "~" {
+        if let Ok(home) = std::env::var("HOME") {
+            return PathBuf::from(home);
+        }
+    }
+    PathBuf::from(raw)
+}
+
+pub(crate) fn extension_lower(p: &Path) -> Option<String> {
+    p.extension().map(|e| e.to_string_lossy().to_lowercase())
+}
+
+pub(crate) fn etiqueta_backend(k: BackendKind) -> &'static str {
+    match k {
+        BackendKind::Mock => "mock",
+        BackendKind::Gemini => "gemini",
+        BackendKind::Anthropic => "anthropic",
+        BackendKind::DeepSeek => "deepseek",
+        BackendKind::Cohere => "cohere",
+        BackendKind::Ollama => "ollama",
+    }
+}
+
+pub(crate) fn etiqueta_intencion(i: &Intencion) -> String {
+    match i {
+        Intencion::Original => "original".into(),
+        Intencion::Traduccion => "traducción".into(),
+        Intencion::Tono { etiqueta } => format!("tono {etiqueta}"),
+        Intencion::Resumen {
+            palabras_objetivo: Some(n),
+        } => format!("resumen ≈{n}p"),
+        Intencion::Resumen {
+            palabras_objetivo: None,
+        } => "resumen".into(),
+        Intencion::Reescritura { .. } => "reescritura".into(),
+        Intencion::Anotacion => "anotación".into(),
+        Intencion::Custom { kind } => kind.clone(),
+    }
+}
+
+pub(crate) fn etiqueta_tipo(t: &TipoTransformacion) -> String {
+    match t {
+        TipoTransformacion::Identidad => "identidad".into(),
+        TipoTransformacion::Traducir { lengua_destino } => format!("traducir → {lengua_destino}"),
+        TipoTransformacion::Tono { etiqueta } => format!("tono {etiqueta}"),
+        TipoTransformacion::Resumir {
+            palabras_objetivo: Some(n),
+        } => format!("resumir ≈{n}p"),
+        TipoTransformacion::Resumir {
+            palabras_objetivo: None,
+        } => "resumir".into(),
+        TipoTransformacion::Reescribir { .. } => "reescribir".into(),
+        TipoTransformacion::Custom { kind, .. } => kind.clone(),
+    }
+}
+
+pub(crate) fn recortar(s: &str, max: usize) -> String {
+    if s.chars().count() <= max {
+        s.to_string()
+    } else {
+        let mut o: String = s.chars().take(max.saturating_sub(1)).collect();
+        o.push('…');
+        o
+    }
+}
+
+pub(crate) fn ruta_sled() -> PathBuf {
+    if let Ok(p) = std::env::var("PLUMA_APP_SLED") {
+        return PathBuf::from(p);
+    }
+    let base = std::env::var("XDG_CACHE_HOME")
+        .map(PathBuf::from)
+        .unwrap_or_else(|_| {
+            std::env::var("HOME")
+                .map(|h| PathBuf::from(h).join(".cache"))
+                .unwrap_or_else(|_| PathBuf::from(".cache"))
+        });
+    base.join("gioser").join("pluma-app").join("pluma.sled")
+}
+
+pub(crate) fn ahora_unix() -> u64 {
+    use std::time::{SystemTime, UNIX_EPOCH};
+    SystemTime::now()
+        .duration_since(UNIX_EPOCH)
+        .map(|d| d.as_secs())
+        .unwrap_or(0)
+}
diff --git a/00_unanchay/pluma/pluma-app/src/view.rs b/00_unanchay/pluma/pluma-app/src/view.rs
new file mode 100644
index 0000000..0267f1c
--- /dev/null
+++ b/00_unanchay/pluma/pluma-app/src/view.rs
@@ -0,0 +1,821 @@
+//! Vistas: las tres columnas (documentos · editor/diff · panel LLM), la
+//! barra de status, el overlay de find, y las secciones de hijas/historial.
+
+use llimphi_ui::llimphi_layout::taffy::{
+    prelude::{auto, length, percent, FlexDirection, Size, Style},
+    AlignItems, Rect,
+};
+use llimphi_ui::llimphi_raster::peniko::Color;
+use llimphi_ui::llimphi_text::Alignment;
+use llimphi_theme::Theme;
+use llimphi_ui::{DragPhase, View};
+use llimphi_widget_button::{button_view, ButtonPalette};
+use llimphi_widget_list::{list_view, ListPalette, ListRow, ListSpec};
+use llimphi_widget_splitter::{splitter_two, Direction, PaneSize, SplitterPalette};
+use llimphi_widget_context_menu::{context_menu_view_ex, ContextMenuExtras};
+use llimphi_widget_edit_menu::{self as editmenu, EditFlags};
+use llimphi_widget_menubar::{
+    menubar_overlay_animated, menubar_view, MenuBarSpec, DEFAULT_HEIGHT as MENU_H,
+};
+use llimphi_widget_text_editor::{EditorPalette as TEPalette, Language};
+use llimphi_widget_text_input::{text_input_view, TextInputPalette};
+use pluma_align::CartaHebras;
+use pluma_cuerpo::Cuerpo;
+use pluma_editor_llimphi::cuerpo_ide::cuerpo_ide_view;
+use pluma_editor_llimphi::multilienzo::{
+    multilienzo_view, IndiceAtoms, MultilienzoConfig, PaletaHebras,
+};
+use pluma_editor_llimphi::Palette as MultPalette;
+use pluma_transform::Transformacion;
+use uuid::Uuid;
+
+use crate::model::{Model, Msg, BACKENDS, METRICS, VISIBLE_LINES};
+use crate::update::{contar_stale_del_activo, menu_principal};
+use crate::util::{etiqueta_backend, etiqueta_intencion, etiqueta_tipo, recortar};
+
+/// Tamaño de ventana del init — usado como viewport para clampear los
+/// dropdowns del menú (la app no trackea el tamaño real hoy).
+const VIEWPORT: (f32, f32) = (1600.0, 900.0);
+
+/// Arma el `MenuBarSpec` compartido entre `menubar_view` (barra) y
+/// `menubar_overlay` (dropdown). El `menu` se construye afuera y se
+/// presta por referencia para no clonarlo dos veces.
+fn menubar_spec<'a>(
+    menu: &'a app_bus::AppMenu,
+    model: &Model,
+    theme: &'a Theme,
+) -> MenuBarSpec<'a, Msg> {
+    MenuBarSpec {
+        menu,
+        open: model.menu_open,
+        theme,
+        viewport: VIEWPORT,
+        height: MENU_H,
+        on_open: std::sync::Arc::new(Msg::MenuOpen),
+        on_command: std::sync::Arc::new(|c: &str| Msg::MenuCommand(c.to_string())),
+    }
+}
+
+pub(crate) fn vista(model: &Model) -> View<Msg> {
+    let theme = Theme::dark();
+    let editor_palette = TEPalette::default();
+    let splitter_palette = SplitterPalette::from_theme(&theme);
+
+    let menu = menu_principal(model);
+    let menubar = menubar_view(&menubar_spec(&menu, model, &theme));
+    let status = barra_status(model, &theme);
+    let panel_centro = panel_editor(model, &editor_palette);
+
+    // En modo delegado (sidebar prestado a pata) las columnas laterales se pueden
+    // colapsar desde el rail de pata → editor a pantalla completa ("puro canvas").
+    // Sin delegar, ambas van siempre (comportamiento original).
+    let show_izq = !model.delegated || model.side_izq_visible;
+    let show_der = !model.delegated || model.side_der_visible;
+
+    // Splitter anidado: izq | (centro | der). Cada lado oculto se saca del árbol
+    // (y con él su splitter), no sólo se esconde.
+    let centro_der = if show_der {
+        splitter_two(
+            Direction::Row,
+            panel_centro,
+            PaneSize::Flex,
+            panel_llm(model, &theme),
+            PaneSize::Fixed(model.side_der_w),
+            |phase, dx| match phase {
+                DragPhase::Move => Some(Msg::ResizeDer(dx)),
+                DragPhase::End => None,
+            },
+            &splitter_palette,
+        )
+    } else {
+        panel_centro
+    };
+    let body = if show_izq {
+        splitter_two(
+            Direction::Row,
+            panel_documentos(model, &theme),
+            PaneSize::Fixed(model.side_izq_w),
+            centro_der,
+            PaneSize::Flex,
+            |phase, dx| match phase {
+                DragPhase::Move => Some(Msg::ResizeIzq(dx)),
+                DragPhase::End => None,
+            },
+            &splitter_palette,
+        )
+    } else {
+        centro_der
+    };
+
+    // El right-click se engancha en la raíz (origen 0,0 → las coords
+    // locales que llegan al handler ya son de ventana) y abre el menú de
+    // edición sobre el cuerpo_ide. La barra de menú va de primer hijo.
+    View::new(Style {
+        flex_direction: FlexDirection::Column,
+        size: Size {
+            width: percent(1.0_f32),
+            height: percent(1.0_f32),
+        },
+        ..Default::default()
+    })
+    .fill(theme.bg_app)
+    .on_right_click_at(|x, y, _w, _h| Some(Msg::EditMenuOpen(x, y)))
+    .children(vec![menubar, status, body])
+}
+
+/// Overlay flotante: el menú de edición contextual tiene prioridad; si no
+/// está abierto, cae al dropdown del menú principal. La app no tenía otros
+/// popups flotantes (find es inline), así que estos dos son todo.
+pub(crate) fn vista_overlay(model: &Model) -> Option<View<Msg>> {
+    let theme = Theme::dark();
+    if let Some((x, y)) = model.edit_menu {
+        let flags = EditFlags::from_editor(&model.ide.state, false);
+        let mut spec = editmenu::edit_context_menu(
+            (x, y),
+            VIEWPORT,
+            &theme,
+            flags,
+            Msg::EditMenuAction,
+            Msg::CloseMenus,
+        );
+        spec.active = model.edit_active;
+        return Some(context_menu_view_ex(
+            spec,
+            ContextMenuExtras {
+                appear: model.edit_anim.value(),
+                ..Default::default()
+            },
+        ));
+    }
+    let menu = menu_principal(model);
+    menubar_overlay_animated(
+        &menubar_spec(&menu, model, &theme),
+        model.menu_active,
+        model.menu_anim.value(),
+    )
+}
+
+fn barra_status(model: &Model, theme: &Theme) -> View<Msg> {
+    let nombre = model
+        .activo
+        .and_then(|id| model.cuerpos.iter().find(|c| c.id == id))
+        .map(|c| c.metadatos.nombre_legible.clone())
+        .unwrap_or_else(|| "(sin doc)".to_string());
+    let zona = model.ide.zona_del_caret();
+    let n_zonas = model.ide.n_zonas();
+    let backend = etiqueta_backend(BACKENDS[model.backend_idx]);
+    let estado = if model.en_curso {
+        "⏳"
+    } else if model.ultimo_error.is_some() {
+        "⚠"
+    } else {
+        "○"
+    };
+    let texto = format!(
+        "pluma · {nombre} · zona {zona}/{n_zonas} · backend {backend} · {estado} {}",
+        model.ultimo_status
+    );
+    View::new(Style {
+        size: Size {
+            width: percent(1.0_f32),
+            height: length(30.0_f32),
+        },
+        padding: Rect {
+            left: length(12.0_f32),
+            right: length(12.0_f32),
+            top: length(0.0_f32),
+            bottom: length(0.0_f32),
+        },
+        align_items: Some(AlignItems::Center),
+        ..Default::default()
+    })
+    .fill(theme.bg_panel)
+    .text_aligned(texto, 12.0, theme.fg_text, Alignment::Start)
+}
+
+fn panel_documentos(model: &Model, theme: &Theme) -> View<Msg> {
+    let palette_btn = ButtonPalette::from_theme(theme);
+    let palette_list = ListPalette::from_theme(theme);
+
+    // Originales primero, luego derivadas — el orden en la lista es
+    // estable porque clonamos `model.cuerpos` y particionamos.
+    let mut originales: Vec<&Cuerpo> = Vec::new();
+    let mut derivadas: Vec<&Cuerpo> = Vec::new();
+    for c in &model.cuerpos {
+        if c.metadatos.intencion.es_derivada() {
+            derivadas.push(c);
+        } else {
+            originales.push(c);
+        }
+    }
+
+    let mut rows: Vec<ListRow<Msg>> = Vec::new();
+    for c in originales.iter().chain(derivadas.iter()) {
+        let prefijo = if c.metadatos.intencion.es_derivada() {
+            "  ↳ "
+        } else {
+            "■ "
+        };
+        let label = format!(
+            "{prefijo}{} · {}",
+            c.metadatos.nombre_legible, c.branch_id
+        );
+        rows.push(ListRow {
+            label,
+            selected: model.activo == Some(c.id),
+            on_click: Msg::AbrirDoc(c.id),
+        });
+    }
+
+    let n = rows.len();
+    let lista = list_view(ListSpec {
+        rows,
+        total: n,
+        caption: Some(format!("{n} documentos")),
+        truncated_hint: None,
+        row_height: 22.0,
+        palette: palette_list,
+    });
+
+    let boton_nuevo = button_view::<Msg>("+  nuevo doc  (Ctrl+N)", &palette_btn, Msg::NuevoDoc);
+    let boton_guardar = button_view::<Msg>("💾  guardar  (Ctrl+S)", &palette_btn, Msg::Guardar);
+
+    let header = View::new(Style {
+        size: Size {
+            width: percent(1.0_f32),
+            height: length(20.0_f32),
+        },
+        padding: Rect {
+            left: length(4.0_f32),
+            right: length(4.0_f32),
+            top: length(2.0_f32),
+            bottom: length(2.0_f32),
+        },
+        ..Default::default()
+    })
+    .text_aligned("DOCUMENTOS".to_string(), 10.0, theme.fg_muted, Alignment::Start);
+
+    let archivo = seccion_archivo(model, theme);
+
+    View::new(Style {
+        flex_direction: FlexDirection::Column,
+        size: Size {
+            width: percent(1.0_f32),
+            height: percent(1.0_f32),
+        },
+        padding: Rect {
+            left: length(10.0_f32),
+            right: length(10.0_f32),
+            top: length(10.0_f32),
+            bottom: length(10.0_f32),
+        },
+        gap: Size {
+            width: length(0.0_f32),
+            height: length(6.0_f32),
+        },
+        ..Default::default()
+    })
+    .fill(theme.bg_panel)
+    .clip(true)
+    .children(vec![header, boton_nuevo, boton_guardar, archivo, lista])
+}
+
+fn seccion_archivo(model: &Model, theme: &Theme) -> View<Msg> {
+    let palette_btn = ButtonPalette::from_theme(theme);
+    let palette_input = TextInputPalette::from_theme(theme);
+
+    let header = View::new(Style {
+        size: Size {
+            width: percent(1.0_f32),
+            height: length(18.0_f32),
+        },
+        padding: Rect {
+            left: length(4.0_f32),
+            right: length(4.0_f32),
+            top: length(2.0_f32),
+            bottom: length(2.0_f32),
+        },
+        ..Default::default()
+    })
+    .text_aligned(
+        "ARCHIVO".to_string(),
+        10.0,
+        theme.fg_muted,
+        Alignment::Start,
+    );
+
+    let input = text_input_view::<Msg>(
+        &model.path_input,
+        "ruta .md o .docx (Esc para salir)",
+        model.path_focused,
+        &palette_input,
+        Msg::FocusPath,
+    );
+
+    let fila_botones = View::new(Style {
+        flex_direction: FlexDirection::Row,
+        size: Size {
+            width: percent(1.0_f32),
+            height: length(28.0_f32),
+        },
+        gap: Size {
+            width: length(6.0_f32),
+            height: length(0.0_f32),
+        },
+        ..Default::default()
+    })
+    .children(vec![
+        button_view::<Msg>("📂 abrir", &palette_btn, Msg::AbrirArchivo),
+        button_view::<Msg>("⬆ exportar (md/docx)", &palette_btn, Msg::ExportarMd),
+    ]);
+
+    View::new(Style {
+        flex_direction: FlexDirection::Column,
+        size: Size {
+            width: percent(1.0_f32),
+            height: length(82.0_f32),
+        },
+        gap: Size {
+            width: length(0.0_f32),
+            height: length(4.0_f32),
+        },
+        ..Default::default()
+    })
+    .children(vec![header, input, fila_botones])
+}
+
+fn panel_editor(model: &Model, palette_editor: &TEPalette) -> View<Msg> {
+    let cuerpo_central: View<Msg> = if model.diff_visible {
+        vista_diff(model, palette_editor)
+    } else {
+        cuerpo_ide_view::<Msg>(
+            &model.ide,
+            palette_editor,
+            METRICS,
+            VISIBLE_LINES,
+            Language::Plain,
+            |ev| Some(Msg::EditorPointer(ev)),
+        )
+    };
+
+    let mut hijos: Vec<View<Msg>> = Vec::new();
+    if model.find_visible {
+        hijos.push(barra_find(model));
+    }
+    hijos.push(cuerpo_central);
+
+    View::new(Style {
+        flex_direction: FlexDirection::Column,
+        size: Size {
+            width: percent(1.0_f32),
+            height: percent(1.0_f32),
+        },
+        padding: Rect {
+            left: length(10.0_f32),
+            right: length(10.0_f32),
+            top: length(8.0_f32),
+            bottom: length(8.0_f32),
+        },
+        gap: Size {
+            width: length(0.0_f32),
+            height: length(6.0_f32),
+        },
+        ..Default::default()
+    })
+    .fill(palette_editor.bg)
+    .clip(true)
+    .children(hijos)
+}
+
+fn vista_diff(model: &Model, palette_editor: &TEPalette) -> View<Msg> {
+    // Resolver activo + madre. Si activo no es derivado o la madre no
+    // se encuentra, mostramos un cartel y volvemos a `cuerpo_ide_view`.
+    let theme = Theme::dark();
+    let activo_id = match model.activo {
+        Some(id) => id,
+        None => return cartel_diff("sin doc activo", palette_editor),
+    };
+    let activo = match model.cuerpos.iter().find(|c| c.id == activo_id) {
+        Some(c) => c,
+        None => return cartel_diff("activo no encontrado", palette_editor),
+    };
+    let madre_id = match activo.metadatos.derivado_de {
+        Some(id) => id,
+        None => {
+            // Activo es Original — fallback al editor normal con cartel.
+            return View::new(Style {
+                flex_direction: FlexDirection::Column,
+                size: Size {
+                    width: percent(1.0_f32),
+                    height: percent(1.0_f32),
+                },
+                gap: Size {
+                    width: length(0.0_f32),
+                    height: length(4.0_f32),
+                },
+                ..Default::default()
+            })
+            .children(vec![
+                cartel_diff(
+                    "este cuerpo es Original — no tiene madre con que diffear (Ctrl+D para cerrar)",
+                    palette_editor,
+                ),
+                cuerpo_ide_view::<Msg>(
+                    &model.ide,
+                    palette_editor,
+                    METRICS,
+                    VISIBLE_LINES,
+                    Language::Plain,
+                    |ev| Some(Msg::EditorPointer(ev)),
+                ),
+            ]);
+        }
+    };
+    let madre = match model.cuerpos.iter().find(|c| c.id == madre_id) {
+        Some(c) => c,
+        None => return cartel_diff(
+            "madre referenciada no está en el sled — ¿borrada?",
+            palette_editor,
+        ),
+    };
+
+    // Buscar la carta de hebras entre estos dos. `pluma_align::CartaHebras`
+    // anota su par; consideramos cualquier orden.
+    let carta = model.cartas.iter().find(|c| {
+        (c.cuerpo_a == Some(madre.id) && c.cuerpo_b == Some(activo.id))
+            || (c.cuerpo_a == Some(activo.id) && c.cuerpo_b == Some(madre.id))
+    });
+
+    let cuerpos_ref: Vec<&Cuerpo> = vec![madre, activo];
+    let cartas_ref: Vec<Option<&CartaHebras>> = vec![carta];
+    let atoms_idx: IndiceAtoms = model.atoms.iter().map(|(k, v)| (*k, v)).collect();
+    let cfg = MultilienzoConfig::default();
+    let paleta_hebras = PaletaHebras::default();
+    let palette_mult = MultPalette::from_theme(&theme);
+
+    let mult = multilienzo_view::<Msg>(
+        &cuerpos_ref,
+        &atoms_idx,
+        &cartas_ref,
+        &cfg,
+        &paleta_hebras,
+        &palette_mult,
+    );
+
+    let header_text = format!(
+        "DIFF · madre «{}» ↔ hija «{}» ({})",
+        madre.metadatos.nombre_legible,
+        activo.metadatos.nombre_legible,
+        if carta.is_some() {
+            "con hebras"
+        } else {
+            "sin carta — hebras no disponibles"
+        },
+    );
+    let header = View::new(Style {
+        size: Size {
+            width: percent(1.0_f32),
+            height: length(20.0_f32),
+        },
+        padding: Rect {
+            left: length(4.0_f32),
+            right: length(4.0_f32),
+            top: length(2.0_f32),
+            bottom: length(2.0_f32),
+        },
+        ..Default::default()
+    })
+    .text_aligned(header_text, 11.0, theme.fg_muted, Alignment::Start);
+
+    View::new(Style {
+        flex_direction: FlexDirection::Column,
+        size: Size {
+            width: percent(1.0_f32),
+            height: percent(1.0_f32),
+        },
+        gap: Size {
+            width: length(0.0_f32),
+            height: length(4.0_f32),
+        },
+        ..Default::default()
+    })
+    .children(vec![header, mult])
+}
+
+fn cartel_diff(texto: &str, palette_editor: &TEPalette) -> View<Msg> {
+    View::new(Style {
+        size: Size {
+            width: percent(1.0_f32),
+            height: length(40.0_f32),
+        },
+        padding: Rect {
+            left: length(12.0_f32),
+            right: length(12.0_f32),
+            top: length(10.0_f32),
+            bottom: length(10.0_f32),
+        },
+        ..Default::default()
+    })
+    .text_aligned(
+        texto.to_string(),
+        12.0,
+        palette_editor.fg_line_number,
+        Alignment::Start,
+    )
+}
+
+fn barra_find(model: &Model) -> View<Msg> {
+    let theme = Theme::dark();
+    let palette_input = TextInputPalette::from_theme(&theme);
+    let palette_btn = ButtonPalette::from_theme(&theme);
+
+    let input = text_input_view::<Msg>(
+        &model.find_input,
+        "buscar (Enter siguiente · Shift+Enter previo · Esc cerrar)",
+        true, // find_visible implica que tiene foco
+        &palette_input,
+        Msg::FindToggle, // click en el input no cambia foco — siempre vivo
+    );
+
+    let total = model.find_matches.len();
+    let pos = if total == 0 {
+        0
+    } else {
+        model.find_idx + 1
+    };
+    let counter = View::new(Style {
+        size: Size {
+            width: length(80.0_f32),
+            height: length(34.0_f32),
+        },
+        padding: Rect {
+            left: length(8.0_f32),
+            right: length(8.0_f32),
+            top: length(8.0_f32),
+            bottom: length(0.0_f32),
+        },
+        ..Default::default()
+    })
+    .text_aligned(
+        format!("{pos}/{total}"),
+        12.0,
+        theme.fg_muted,
+        Alignment::Center,
+    );
+
+    let prev = button_view::<Msg>("◀", &palette_btn, Msg::FindAnterior);
+    let next = button_view::<Msg>("▶", &palette_btn, Msg::FindSiguiente);
+    let cerrar = button_view::<Msg>("✕", &palette_btn, Msg::FindClose);
+
+    let input_wrap = View::new(Style {
+        flex_grow: 1.0,
+        flex_shrink: 1.0,
+        size: Size {
+            width: percent(1.0_f32),
+            height: length(34.0_f32),
+        },
+        ..Default::default()
+    })
+    .children(vec![input]);
+
+    View::new(Style {
+        flex_direction: FlexDirection::Row,
+        size: Size {
+            width: percent(1.0_f32),
+            height: length(40.0_f32),
+        },
+        gap: Size {
+            width: length(6.0_f32),
+            height: length(0.0_f32),
+        },
+        align_items: Some(AlignItems::Center),
+        ..Default::default()
+    })
+    .children(vec![input_wrap, counter, prev, next, cerrar])
+}
+
+fn panel_llm(model: &Model, theme: &Theme) -> View<Msg> {
+    let palette_btn_activo = ButtonPalette::from_theme(theme);
+    let palette_btn_off = ButtonPalette {
+        bg: Color::from_rgba8(60, 60, 60, 255),
+        bg_hover: Color::from_rgba8(60, 60, 60, 255),
+        fg: Color::from_rgba8(140, 140, 140, 255),
+        radius: palette_btn_activo.radius,
+    };
+    let pal = if model.en_curso {
+        &palette_btn_off
+    } else {
+        &palette_btn_activo
+    };
+    let pal_backend = &palette_btn_activo;
+
+    let etiqueta_back = format!(
+        "🔀  backend: {}",
+        etiqueta_backend(BACKENDS[model.backend_idx])
+    );
+    let cycler = button_view::<Msg>(&etiqueta_back, pal_backend, Msg::CicloBackend);
+
+    let etiqueta_diff = if model.diff_visible {
+        "↔  diff: ON  (Ctrl+D)"
+    } else {
+        "↔  diff: off  (Ctrl+D)"
+    };
+    let diff_btn = button_view::<Msg>(etiqueta_diff, pal_backend, Msg::DiffToggle);
+
+    let header = View::new(Style {
+        size: Size {
+            width: percent(1.0_f32),
+            height: length(20.0_f32),
+        },
+        padding: Rect {
+            left: length(4.0_f32),
+            right: length(4.0_f32),
+            top: length(2.0_f32),
+            bottom: length(2.0_f32),
+        },
+        ..Default::default()
+    })
+    .text_aligned("LLM".to_string(), 10.0, theme.fg_muted, Alignment::Start);
+
+    let mk = |label: &str, m: Msg| button_view::<Msg>(label, pal, m);
+    let botones: Vec<View<Msg>> = vec![
+        mk("→  traducir qu", Msg::PedirTraducir("qu".into())),
+        mk("→  traducir en", Msg::PedirTraducir("en".into())),
+        mk("✎  tono formal", Msg::PedirTono("formal".into())),
+        mk("✂  resumir 30p", Msg::PedirResumir(Some(30))),
+    ];
+
+    let n_stale = contar_stale_del_activo(model);
+    let label_regen = if n_stale > 0 {
+        format!("⟳  regenerar stale ({n_stale})")
+    } else {
+        "⟳  regenerar stale (0)".to_string()
+    };
+    let tocar_btn = button_view::<Msg>("⏰  tocar madre", pal, Msg::TocarMadre);
+    let regen_btn = button_view::<Msg>(&label_regen, pal, Msg::RegenerarStale);
+
+    // Lista de hijas del cuerpo activo — para abrirlas con click.
+    let hijas_seccion = seccion_hijas(model, theme);
+
+    let mut hijos: Vec<View<Msg>> = Vec::new();
+    hijos.push(header);
+    hijos.push(cycler);
+    hijos.push(diff_btn);
+    hijos.extend(botones);
+    hijos.push(tocar_btn);
+    hijos.push(regen_btn);
+    hijos.push(divider(theme));
+    hijos.push(hijas_seccion);
+
+    View::new(Style {
+        flex_direction: FlexDirection::Column,
+        size: Size {
+            width: percent(1.0_f32),
+            height: percent(1.0_f32),
+        },
+        padding: Rect {
+            left: length(10.0_f32),
+            right: length(10.0_f32),
+            top: length(10.0_f32),
+            bottom: length(10.0_f32),
+        },
+        gap: Size {
+            width: length(0.0_f32),
+            height: length(6.0_f32),
+        },
+        ..Default::default()
+    })
+    .fill(theme.bg_panel)
+    .clip(true)
+    .children(hijos)
+}
+
+fn seccion_hijas(model: &Model, theme: &Theme) -> View<Msg> {
+    let palette_list = ListPalette::from_theme(theme);
+    let activo = model.activo;
+
+    let hijas: Vec<&Cuerpo> = model
+        .cuerpos
+        .iter()
+        .filter(|c| {
+            c.metadatos.intencion.es_derivada() && c.metadatos.derivado_de == activo
+        })
+        .collect();
+
+    let mut rows: Vec<ListRow<Msg>> = Vec::new();
+    for h in &hijas {
+        let label = format!("• {} · {}", h.branch_id, etiqueta_intencion(&h.metadatos.intencion));
+        rows.push(ListRow {
+            label,
+            selected: false,
+            on_click: Msg::AbrirDoc(h.id),
+        });
+    }
+
+    let n = rows.len();
+    let lista = list_view(ListSpec {
+        rows,
+        total: n,
+        caption: Some(format!("hijas: {n}")),
+        truncated_hint: None,
+        row_height: 20.0,
+        palette: palette_list,
+    });
+
+    let historial = seccion_historial(model, theme);
+
+    View::new(Style {
+        flex_direction: FlexDirection::Column,
+        size: Size {
+            width: percent(1.0_f32),
+            height: auto(),
+        },
+        flex_grow: 1.0,
+        gap: Size {
+            width: length(0.0_f32),
+            height: length(6.0_f32),
+        },
+        ..Default::default()
+    })
+    .children(vec![lista, divider(theme), historial])
+}
+
+fn seccion_historial(model: &Model, theme: &Theme) -> View<Msg> {
+    let palette_list = ListPalette::from_theme(theme);
+
+    // Index para resolver Uuid → Cuerpo, cuerpo.metadatos.nombre_legible.
+    let cuerpo_de = |id: Uuid| model.cuerpos.iter().find(|c| c.id == id);
+
+    // Transformaciones del cuerpo activo: ya sea como madre o como hija.
+    // Lo más útil al usuario suele ser "todo lo que pasó alrededor de
+    // este doc" — así una hija de cuya madre vengo, lo veo.
+    let activo = model.activo;
+    let mut filtradas: Vec<&Transformacion> = model
+        .transformaciones
+        .iter()
+        .filter(|t| match activo {
+            Some(id) => t.madre == id || t.hija == id,
+            None => true,
+        })
+        .collect();
+    // Más recientes arriba.
+    filtradas.sort_by(|a, b| b.creada_en.cmp(&a.creada_en));
+
+    let mut rows: Vec<ListRow<Msg>> = Vec::new();
+    for t in &filtradas {
+        let madre = cuerpo_de(t.madre)
+            .map(|c| c.metadatos.nombre_legible.as_str())
+            .unwrap_or("?");
+        let hija = cuerpo_de(t.hija)
+            .map(|c| c.metadatos.nombre_legible.as_str())
+            .unwrap_or("?");
+        let tipo = etiqueta_tipo(&t.tipo);
+        // Truncar nombres largos para que la fila no se rompa visual.
+        let label = format!(
+            "{}  →  {}  ·  {}",
+            recortar(madre, 18),
+            recortar(hija, 18),
+            tipo,
+        );
+        rows.push(ListRow {
+            label,
+            selected: false,
+            on_click: Msg::AbrirDoc(t.hija),
+        });
+    }
+
+    let n = rows.len();
+    let lista = list_view(ListSpec {
+        rows,
+        total: n,
+        caption: Some(if activo.is_some() {
+            format!("historial activo: {n}")
+        } else {
+            format!("historial: {n}")
+        }),
+        truncated_hint: None,
+        row_height: 20.0,
+        palette: palette_list,
+    });
+
+    View::new(Style {
+        flex_direction: FlexDirection::Column,
+        size: Size {
+            width: percent(1.0_f32),
+            height: auto(),
+        },
+        ..Default::default()
+    })
+    .children(vec![lista])
+}
+
+fn divider(theme: &Theme) -> View<Msg> {
+    View::new(Style {
+        size: Size {
+            width: percent(1.0_f32),
+            height: length(1.0_f32),
+        },
+        ..Default::default()
+    })
+    .fill(theme.border)
+}
diff --git a/00_unanchay/pluma/pluma-core/Cargo.toml b/00_unanchay/pluma/pluma-core/Cargo.toml
new file mode 100644
index 0000000..9ace970
--- /dev/null
+++ b/00_unanchay/pluma/pluma-core/Cargo.toml
@@ -0,0 +1,13 @@
+[package]
+name = "pluma-core"
+version.workspace = true
+edition.workspace = true
+license.workspace = true
+authors.workspace = true
+publish.workspace = true
+description = "pluma-app — átomo narrativo (NarrativeAtom) + estado de coherencia. Tipos puros del editor DAG, agnósticos de UI."
+
+[dependencies]
+uuid = { workspace = true, features = ["serde"] }
+sha2 = { workspace = true }
+serde = { workspace = true, features = ["rc"] }
diff --git a/00_unanchay/pluma/pluma-core/LEEME.md b/00_unanchay/pluma/pluma-core/LEEME.md
new file mode 100644
index 0000000..f24acb9
--- /dev/null
+++ b/00_unanchay/pluma/pluma-core/LEEME.md
@@ -0,0 +1,19 @@
+# pluma-core
+
+> Modelo de documento de [pluma](../README.md): átomos, grafo, ids.
+
+`Atomo` = unidad mínima (un párrafo, una celda, un bloque de código) con `Uuid` estable. `Documento` es un DAG de átomos con orden lineal por default + referencias laterales (links, tags). Las mutaciones se aplican como `CambioAtom { Crear, Mutar, Eliminar }`.
+
+## API
+
+```rust
+use pluma_core::{Documento, Atomo, Uuid};
+
+let mut doc = Documento::new();
+let id = doc.crear_atomo("texto")?;
+```
+
+## Deps
+
+- `serde`, [`uuid`](https://crates.io/crates/uuid)
+- Cero deps gráficas / network
diff --git a/00_unanchay/pluma/pluma-core/README.md b/00_unanchay/pluma/pluma-core/README.md
new file mode 100644
index 0000000..c053dde
--- /dev/null
+++ b/00_unanchay/pluma/pluma-core/README.md
@@ -0,0 +1,19 @@
+# pluma-core
+
+> Document model of [pluma](../README.md): atoms, graph, ids.
+
+`Atomo` = minimal unit (a paragraph, a cell, a code block) with stable `Uuid`. `Documento` is a DAG of atoms with default linear order + lateral references (links, tags). Mutations apply as `CambioAtom { Crear, Mutar, Eliminar }`.
+
+## API
+
+```rust
+use pluma_core::{Documento, Atomo, Uuid};
+
+let mut doc = Documento::new();
+let id = doc.crear_atomo("text")?;
+```
+
+## Deps
+
+- `serde`, [`uuid`](https://crates.io/crates/uuid)
+- Zero graphics / network deps
diff --git a/00_unanchay/pluma/pluma-core/src/lib.rs b/00_unanchay/pluma/pluma-core/src/lib.rs
new file mode 100644
index 0000000..b6d0ad9
--- /dev/null
+++ b/00_unanchay/pluma/pluma-core/src/lib.rs
@@ -0,0 +1,138 @@
+//! `pluma_app-core` — el átomo narrativo y su estado de coherencia.
+//!
+//! Tipos puros del editor DAG de pluma_app: sin UI, sin storage, sin red. El
+//! documento es un grafo de [`NarrativeAtom`]s; cada átomo comparte su
+//! texto vía `Arc<String>` para que ramificar una línea temporal sea
+//! O(1) (structural sharing).
+//!
+//! Invariante: `content_hash` siempre corresponde a `content` —
+//! ver [`NarrativeAtom::hash_matches`].
+
+#![forbid(unsafe_code)]
+
+use serde::{Deserialize, Serialize};
+use std::collections::HashMap;
+use std::sync::Arc;
+use uuid::Uuid;
+
+/// Estado de coherencia lógica de un átomo dentro del grafo narrativo.
+#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
+pub enum CoherenceState {
+    /// Consistente con sus dependencias.
+    Valid,
+    /// En conflicto: una dependencia cambió y lo contradice.
+    InConflict { origin: Uuid, reason: String },
+    /// Marcado para re-evaluación (una dependencia mutó; falta verificar).
+    PendingEvaluation,
+}
+
+/// Un átomo narrativo: la unidad atómica del documento.
+#[derive(Debug, Clone, Serialize, Deserialize)]
+pub struct NarrativeAtom {
+    pub id: Uuid,
+    /// SHA-256 del contenido — verifica integridad de toda mutación.
+    pub content_hash: [u8; 32],
+    /// Texto compartido. Clonar una rama no duplica el texto.
+    pub content: Arc<String>,
+    /// Concepto → intensidad. Lo puebla `pluma_app-semantic`.
+    pub semantic_vectors: HashMap<String, f32>,
+    /// Átomos prerrequisito (sus "padres" lógicos).
+    pub dependencies: Vec<Uuid>,
+    /// Identificador de la rama / línea temporal.
+    pub branch_id: String,
+    pub coherence: CoherenceState,
+}
+
+impl NarrativeAtom {
+    /// Crea un átomo nuevo con id aleatorio. Hashea el contenido.
+    pub fn new(content: impl Into<String>, branch_id: impl Into<String>) -> Self {
+        let content = content.into();
+        let content_hash = sha256(content.as_bytes());
+        Self {
+            id: Uuid::new_v4(),
+            content_hash,
+            content: Arc::new(content),
+            semantic_vectors: HashMap::new(),
+            dependencies: Vec::new(),
+            branch_id: branch_id.into(),
+            coherence: CoherenceState::Valid,
+        }
+    }
+
+    /// Declara una dependencia (prerrequisito lógico).
+    pub fn depends_on(mut self, dep: Uuid) -> Self {
+        if !self.dependencies.contains(&dep) {
+            self.dependencies.push(dep);
+        }
+        self
+    }
+
+    /// Reemplaza el contenido: re-hashea y vuelve a `PendingEvaluation`
+    /// (toda mutación exige re-verificar la coherencia).
+    pub fn set_content(&mut self, content: impl Into<String>) {
+        let content = content.into();
+        self.content_hash = sha256(content.as_bytes());
+        self.content = Arc::new(content);
+        self.coherence = CoherenceState::PendingEvaluation;
+    }
+
+    /// `true` si `content_hash` corresponde al `content` actual.
+    /// El editor valida esto en toda mutación de texto.
+    pub fn hash_matches(&self) -> bool {
+        sha256(self.content.as_bytes()) == self.content_hash
+    }
+}
+
+/// SHA-256 de un buffer de bytes.
+pub fn sha256(bytes: &[u8]) -> [u8; 32] {
+    use sha2::{Digest, Sha256};
+    let mut h = Sha256::new();
+    h.update(bytes);
+    h.finalize().into()
+}
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+
+    #[test]
+    fn new_atom_is_valid_with_matching_hash() {
+        let a = NarrativeAtom::new("había una vez", "main");
+        assert_eq!(a.coherence, CoherenceState::Valid);
+        assert!(a.hash_matches());
+        assert_eq!(a.branch_id, "main");
+    }
+
+    #[test]
+    fn set_content_rehashes_and_marks_pending() {
+        let mut a = NarrativeAtom::new("v1", "main");
+        let h1 = a.content_hash;
+        a.set_content("v2 distinto");
+        assert_ne!(a.content_hash, h1);
+        assert!(a.hash_matches());
+        assert_eq!(a.coherence, CoherenceState::PendingEvaluation);
+    }
+
+    #[test]
+    fn branch_shares_content_arc() {
+        let a = NarrativeAtom::new("texto largo compartido", "main");
+        let b = a.clone();
+        // Clonar la rama NO duplica el String — comparten el Arc.
+        assert!(Arc::ptr_eq(&a.content, &b.content));
+    }
+
+    #[test]
+    fn depends_on_dedups() {
+        let d = Uuid::new_v4();
+        let a = NarrativeAtom::new("x", "main").depends_on(d).depends_on(d);
+        assert_eq!(a.dependencies.len(), 1);
+    }
+
+    #[test]
+    fn tampered_content_fails_hash_check() {
+        let mut a = NarrativeAtom::new("original", "main");
+        // Forzar desincronización (lo que el editor debe detectar).
+        a.content = Arc::new("manipulado".to_string());
+        assert!(!a.hash_matches());
+    }
+}
diff --git a/00_unanchay/pluma/pluma-cuerpo/Cargo.toml b/00_unanchay/pluma/pluma-cuerpo/Cargo.toml
new file mode 100644
index 0000000..5565fb9
--- /dev/null
+++ b/00_unanchay/pluma/pluma-cuerpo/Cargo.toml
@@ -0,0 +1,14 @@
+[package]
+name = "pluma-cuerpo"
+version.workspace = true
+edition.workspace = true
+license.workspace = true
+authors.workspace = true
+publish.workspace = true
+description = "pluma — modelo de Cuerpo (lienzo): secuencia ordenada de párrafos del mismo documento bajo una mirada (idioma, tono, resumen, derivación). Hueso del paralelismo multilienzo."
+
+[dependencies]
+uuid = { workspace = true, features = ["serde"] }
+serde = { workspace = true }
+postcard = { workspace = true }
+pluma-core = { path = "../pluma-core" }
diff --git a/00_unanchay/pluma/pluma-cuerpo/LEEME.md b/00_unanchay/pluma/pluma-cuerpo/LEEME.md
new file mode 100644
index 0000000..22888b3
--- /dev/null
+++ b/00_unanchay/pluma/pluma-cuerpo/LEEME.md
@@ -0,0 +1,19 @@
+# pluma-cuerpo
+
+> Texto del documento como secuencia de átomos para [pluma](../README.md).
+
+`Cuerpo` es la vista lineal del documento: lista ordenada de `Atomo` ids, con su texto concatenado por un separador (default `"\n\n"`). Sirve como "vista plana" para el editor y para serialización a Markdown.
+
+## API
+
+```rust
+use pluma_cuerpo::Cuerpo;
+
+let cuerpo = Cuerpo::from_doc(&doc);
+let texto = cuerpo.como_string();
+```
+
+## Deps
+
+- [`pluma-core`](../pluma-core/README.md)
+- `serde`, `uuid`
diff --git a/00_unanchay/pluma/pluma-cuerpo/README.md b/00_unanchay/pluma/pluma-cuerpo/README.md
new file mode 100644
index 0000000..45ee620
--- /dev/null
+++ b/00_unanchay/pluma/pluma-cuerpo/README.md
@@ -0,0 +1,19 @@
+# pluma-cuerpo
+
+> Document text as a sequence of atoms for [pluma](../README.md).
+
+`Cuerpo` is the linear view of the document: ordered list of `Atomo` ids with their text concatenated by a separator (default `"\n\n"`). Used as the "flat view" for the editor and for Markdown serialization.
+
+## API
+
+```rust
+use pluma_cuerpo::Cuerpo;
+
+let cuerpo = Cuerpo::from_doc(&doc);
+let text = cuerpo.como_string();
+```
+
+## Deps
+
+- [`pluma-core`](../pluma-core/README.md)
+- `serde`, `uuid`
diff --git a/00_unanchay/pluma/pluma-cuerpo/src/lib.rs b/00_unanchay/pluma/pluma-cuerpo/src/lib.rs
new file mode 100644
index 0000000..ce96b8b
--- /dev/null
+++ b/00_unanchay/pluma/pluma-cuerpo/src/lib.rs
@@ -0,0 +1,379 @@
+//! `pluma-cuerpo` — el *cuerpo* (lienzo) de un documento multivista.
+//!
+//! Un documento pluma deja de ser una sola secuencia lineal de párrafos: es un
+//! *haz* de cuerpos. Cada cuerpo recorre el mismo material desde una mirada
+//! distinta — el original en español, su traducción al quechua, el resumen
+//! en inglés, el comentario crítico, la versión "tono infantil". Todos viven
+//! en paralelo, sincronizados por alineamientos párrafo-a-párrafo
+//! (ver `pluma-align`).
+//!
+//! Este crate define solo el cuerpo: una colección ordenada de [`Uuid`]s de
+//! `NarrativeAtom`s, con metadatos que explican qué *intención* tiene este
+//! cuerpo dentro del haz, y de qué cuerpo madre deriva si es derivado.
+//!
+//! No define la UI ni la alineación entre cuerpos. Esos viven en
+//! `pluma-editor-llimphi` y `pluma-align` respectivamente.
+
+#![forbid(unsafe_code)]
+
+use serde::{Deserialize, Serialize};
+use uuid::Uuid;
+
+/// La identidad de una lengua dentro de un cuerpo: un código corto estilo
+/// ISO 639 (`"es"`, `"qu"`, `"en"`). No se restringe la enumeracion para no
+/// encerrarnos en un catalogo fijo; el campo es un `String`, y `pluma-localize`
+/// (o `rimay-localize`) decide qué significa cada código.
+pub type Lengua = String;
+
+/// La razón por la que existe un cuerpo dentro del haz. Si es `Original`, el
+/// cuerpo no deriva de ningún otro — es la madre. Cualquier otra variante
+/// implica que existe un `derivado_de` apuntando a un cuerpo madre.
+#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
+pub enum Intencion {
+    /// Cuerpo "madre" del haz — no deriva de ningún otro.
+    Original,
+    /// Traducción de la madre a otra lengua.
+    Traduccion,
+    /// Reescritura con un tono distinto (formal, casual, técnico, infantil…).
+    /// La `etiqueta` es libre — la UI la presenta como label de la columna.
+    Tono { etiqueta: String },
+    /// Resumen de la madre, con un objetivo opcional de palabras.
+    Resumen { palabras_objetivo: Option<u32> },
+    /// Reescritura libre dictada por un prompt humano.
+    Reescritura { prompt: String },
+    /// Comentario crítico, glosa o anotación marginal — cada átomo del cuerpo
+    /// anotación se alinea al átomo del cuerpo madre que comenta.
+    Anotacion,
+    /// Cualquier otra cosa que la app quiera categorizar — la etiqueta es libre.
+    Custom { kind: String },
+}
+
+impl Intencion {
+    /// `true` si esta intención implica que el cuerpo deriva de otro. Solo
+    /// `Original` es la excepción: vive solo.
+    pub fn es_derivada(&self) -> bool {
+        !matches!(self, Intencion::Original)
+    }
+}
+
+/// Metadatos descriptivos de un cuerpo: lo que le explica al usuario y al
+/// sistema qué representa este cuerpo dentro del haz.
+#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
+pub struct MetaCuerpo {
+    /// Nombre legible — el rótulo que la UI muestra en la cabecera de la
+    /// columna. Libre. Ejemplos: `"es"`, `"qu (Cuzco)"`, `"borrador 2"`,
+    /// `"comentario de Ana"`.
+    pub nombre_legible: String,
+    /// Lengua del cuerpo, si aplica. Útil para encadenar con `rimay-localize`,
+    /// motores de embeddings y exportadores. `None` para cuerpos sin lengua
+    /// específica (notas formales, listas de tareas, etc.).
+    pub lengua: Option<Lengua>,
+    /// Por qué existe este cuerpo dentro del haz.
+    pub intencion: Intencion,
+    /// Cuerpo madre del que deriva, si lo hay. Si `intencion` no es
+    /// `Original`, este campo debería estar `Some`; el constructor lo respeta,
+    /// y `valida_consistencia` lo verifica.
+    pub derivado_de: Option<Uuid>,
+    /// Marca temporal (segundos UNIX) del estado de la madre cuando este
+    /// cuerpo se regeneró por última vez. Si la madre cambia después de este
+    /// timestamp, el cuerpo queda *stale* — la UI lo pinta con la hebra
+    /// desaturada. `None` mientras el cuerpo nunca se haya regenerado o sea
+    /// `Original`.
+    pub fresco_hasta: Option<u64>,
+    /// Instante de creación (segundos UNIX). Se fija en el constructor.
+    pub creado_en: u64,
+    /// Instante de la última modificación de la estructura del cuerpo
+    /// (inserciones, removidos, reordenamientos). NO se actualiza cuando
+    /// cambia el contenido de un átomo — eso lo gestiona el átomo.
+    pub modificado_en: u64,
+}
+
+/// El cuerpo (lienzo) en sí: una secuencia ORDENADA de identidades de
+/// `NarrativeAtom`s. La identidad estable de un párrafo es su `Uuid`; los
+/// alineamientos entre cuerpos hablan en términos de esos `Uuid`s, no de
+/// posiciones — así un párrafo movido dentro del cuerpo no rompe sus hebras.
+///
+/// El cuerpo NO posee los átomos: solo los referencia. La posesión vive en el
+/// `NarrativeGraph` (en `pluma-graph`). Esa separación permite que distintos
+/// cuerpos compartan átomos (un párrafo idéntico en madre e hija — caso
+/// común tras `Intencion::Identidad`) sin duplicación.
+#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
+pub struct Cuerpo {
+    /// Identidad del cuerpo. Estable: ni una traducción ni un renombre lo
+    /// cambian. Es el ancla a la que los alineamientos apuntan.
+    pub id: Uuid,
+    /// Identificador de rama, en el sentido que ya entiende `pluma-core`. Por
+    /// convención: `"es"`, `"qu"`, `"borrador-2"`, `"resumen-en"`. La rama da
+    /// también la identidad del `branch_id` que los `NarrativeAtom`s
+    /// referencian.
+    pub branch_id: String,
+    /// Metadatos del cuerpo.
+    pub metadatos: MetaCuerpo,
+    /// Orden de presentación de los párrafos del cuerpo. Cada `Uuid` debe
+    /// existir en el `NarrativeGraph` que aloja al documento; el cuerpo no
+    /// lo valida (no conoce el grafo), eso queda para el caller.
+    pub orden: Vec<Uuid>,
+}
+
+impl Cuerpo {
+    /// Crea un cuerpo vacío con la identidad y los metadatos esenciales.
+    /// `ahora` es el timestamp (segundos UNIX) que el caller decide — el
+    /// crate no toca el reloj para mantenerse `no_std`-amigable y testable.
+    pub fn nuevo(
+        branch_id: impl Into<String>,
+        nombre_legible: impl Into<String>,
+        intencion: Intencion,
+        ahora: u64,
+    ) -> Self {
+        Self {
+            id: Uuid::new_v4(),
+            branch_id: branch_id.into(),
+            metadatos: MetaCuerpo {
+                nombre_legible: nombre_legible.into(),
+                lengua: None,
+                intencion,
+                derivado_de: None,
+                fresco_hasta: None,
+                creado_en: ahora,
+                modificado_en: ahora,
+            },
+            orden: Vec::new(),
+        }
+    }
+
+    /// Marca este cuerpo como derivado de `madre`, fijando también el
+    /// timestamp de frescura con el que se regeneró por primera vez. Útil al
+    /// crear cuerpos via `pluma-transform`.
+    pub fn deriva_de(mut self, madre: Uuid, fresco_hasta: u64) -> Self {
+        self.metadatos.derivado_de = Some(madre);
+        self.metadatos.fresco_hasta = Some(fresco_hasta);
+        self
+    }
+
+    /// Anota la lengua del cuerpo. Útil al encadenar — `Cuerpo::nuevo(...).con_lengua("qu")`.
+    pub fn con_lengua(mut self, lengua: impl Into<Lengua>) -> Self {
+        self.metadatos.lengua = Some(lengua.into());
+        self
+    }
+
+    /// Agrega un átomo al final del cuerpo. Actualiza `modificado_en`. No
+    /// verifica duplicados: un mismo átomo PUEDE aparecer dos veces si el
+    /// caller lo necesita (caso raro, pero no se prohíbe — el cuerpo es
+    /// agnóstico).
+    pub fn agregar(&mut self, atom_id: Uuid, ahora: u64) {
+        self.orden.push(atom_id);
+        self.metadatos.modificado_en = ahora;
+    }
+
+    /// Inserta un átomo en `indice`. Si `indice > len`, se inserta al final.
+    pub fn insertar(&mut self, indice: usize, atom_id: Uuid, ahora: u64) {
+        let pos = indice.min(self.orden.len());
+        self.orden.insert(pos, atom_id);
+        self.metadatos.modificado_en = ahora;
+    }
+
+    /// Remueve el primer átomo con `atom_id`. Devuelve `true` si removió.
+    pub fn remover(&mut self, atom_id: Uuid, ahora: u64) -> bool {
+        if let Some(pos) = self.orden.iter().position(|&id| id == atom_id) {
+            self.orden.remove(pos);
+            self.metadatos.modificado_en = ahora;
+            true
+        } else {
+            false
+        }
+    }
+
+    /// Mueve la primera ocurrencia de `atom_id` al nuevo índice. Devuelve
+    /// `true` si efectuó el cambio. Un movimiento al mismo índice es no-op
+    /// y devuelve `true`. Si `nuevo_indice > len-1`, se mueve al final.
+    pub fn mover(&mut self, atom_id: Uuid, nuevo_indice: usize, ahora: u64) -> bool {
+        let Some(actual) = self.orden.iter().position(|&id| id == atom_id) else {
+            return false;
+        };
+        let destino = nuevo_indice.min(self.orden.len().saturating_sub(1));
+        if actual == destino {
+            return true;
+        }
+        let id = self.orden.remove(actual);
+        self.orden.insert(destino, id);
+        self.metadatos.modificado_en = ahora;
+        true
+    }
+
+    /// Posición del primer átomo con `atom_id`, si existe en el cuerpo.
+    pub fn posicion(&self, atom_id: Uuid) -> Option<usize> {
+        self.orden.iter().position(|&id| id == atom_id)
+    }
+
+    /// `true` si el cuerpo deriva de otro (su intención no es `Original`).
+    pub fn es_derivado(&self) -> bool {
+        self.metadatos.intencion.es_derivada()
+    }
+
+    /// `true` si la madre cambió después de `fresco_hasta`. Si no es derivado
+    /// o nunca se regeneró, devuelve `false` (no aplica el concepto).
+    pub fn es_stale(&self, modificado_madre_en: u64) -> bool {
+        match self.metadatos.fresco_hasta {
+            Some(ts) => modificado_madre_en > ts,
+            None => false,
+        }
+    }
+
+    /// Marca el cuerpo como recién regenerado: `fresco_hasta = ahora`.
+    pub fn marcar_fresco(&mut self, ahora: u64) {
+        self.metadatos.fresco_hasta = Some(ahora);
+        self.metadatos.modificado_en = ahora;
+    }
+
+    /// Verifica la consistencia interna del cuerpo. Útil al cargar de disco o
+    /// tras transformaciones que prometen no romper invariantes. Reglas:
+    ///
+    /// - Si `intencion.es_derivada()`, `derivado_de` debe ser `Some`.
+    /// - Si `intencion == Original`, `derivado_de` debe ser `None`.
+    /// - `modificado_en >= creado_en`.
+    pub fn valida_consistencia(&self) -> Result<(), &'static str> {
+        let m = &self.metadatos;
+        if m.intencion.es_derivada() && m.derivado_de.is_none() {
+            return Err("cuerpo :: intencion derivada sin `derivado_de`");
+        }
+        if !m.intencion.es_derivada() && m.derivado_de.is_some() {
+            return Err("cuerpo :: intencion Original con `derivado_de` no nulo");
+        }
+        if m.modificado_en < m.creado_en {
+            return Err("cuerpo :: `modificado_en` anterior a `creado_en`");
+        }
+        Ok(())
+    }
+
+    /// Serializa el cuerpo a su forma binaria `postcard` — el codec ya
+    /// canónico del workspace (lo usa `format`/`akasha` en wawa).
+    pub fn serializar(&self) -> Result<Vec<u8>, &'static str> {
+        postcard::to_allocvec(self).map_err(|_| "cuerpo :: serializacion fallida")
+    }
+
+    /// Reconstruye un cuerpo desde su forma binaria.
+    pub fn deserializar(bytes: &[u8]) -> Result<Cuerpo, &'static str> {
+        postcard::from_bytes::<Cuerpo>(bytes)
+            .map_err(|_| "cuerpo :: deserializacion fallida")
+    }
+}
+
+#[cfg(test)]
+mod pruebas {
+    use super::*;
+
+    fn ahora_test() -> u64 {
+        1_716_724_800
+    }
+
+    #[test]
+    fn nuevo_es_consistente_y_vacio() {
+        let c = Cuerpo::nuevo("es", "es (original)", Intencion::Original, ahora_test());
+        assert!(c.orden.is_empty());
+        assert!(!c.es_derivado());
+        assert!(c.metadatos.derivado_de.is_none());
+        assert_eq!(c.metadatos.creado_en, c.metadatos.modificado_en);
+        c.valida_consistencia().unwrap();
+    }
+
+    #[test]
+    fn derivado_exige_madre() {
+        // Construir un cuerpo derivado sin madre — debe detectarse.
+        let mut c = Cuerpo::nuevo("qu", "qu", Intencion::Traduccion, ahora_test());
+        assert!(c.es_derivado());
+        assert!(c.valida_consistencia().is_err());
+
+        c.metadatos.derivado_de = Some(Uuid::new_v4());
+        c.valida_consistencia().unwrap();
+    }
+
+    #[test]
+    fn original_no_puede_tener_madre() {
+        let mut c = Cuerpo::nuevo("es", "es", Intencion::Original, ahora_test());
+        c.metadatos.derivado_de = Some(Uuid::new_v4());
+        assert!(c.valida_consistencia().is_err());
+    }
+
+    #[test]
+    fn agregar_insertar_remover_mover_actualiza_modificado() {
+        let mut c = Cuerpo::nuevo("es", "es", Intencion::Original, 100);
+        let a = Uuid::new_v4();
+        let b = Uuid::new_v4();
+        let d = Uuid::new_v4();
+        c.agregar(a, 101);
+        c.agregar(b, 102);
+        c.insertar(1, d, 103);
+        // orden: [a, d, b]
+        assert_eq!(c.orden, vec![a, d, b]);
+        assert_eq!(c.metadatos.modificado_en, 103);
+        assert_eq!(c.posicion(d), Some(1));
+
+        // Mover b al inicio.
+        assert!(c.mover(b, 0, 104));
+        assert_eq!(c.orden, vec![b, a, d]);
+        assert_eq!(c.metadatos.modificado_en, 104);
+
+        // Remover a.
+        assert!(c.remover(a, 105));
+        assert_eq!(c.orden, vec![b, d]);
+
+        // Remover algo que no está.
+        assert!(!c.remover(Uuid::new_v4(), 106));
+        // El timestamp NO se mueve si no removió.
+        assert_eq!(c.metadatos.modificado_en, 105);
+    }
+
+    #[test]
+    fn insertar_mas_alla_del_final_aterriza_al_final() {
+        let mut c = Cuerpo::nuevo("es", "es", Intencion::Original, 0);
+        let a = Uuid::new_v4();
+        let b = Uuid::new_v4();
+        c.agregar(a, 1);
+        c.insertar(99, b, 2);
+        assert_eq!(c.orden, vec![a, b]);
+    }
+
+    #[test]
+    fn stale_solo_aplica_si_madre_se_modifico_despues() {
+        let c = Cuerpo::nuevo("qu", "qu", Intencion::Traduccion, 100)
+            .deriva_de(Uuid::new_v4(), 200);
+        // La madre no se ha tocado desde la regeneración.
+        assert!(!c.es_stale(150));
+        assert!(!c.es_stale(200));
+        // La madre cambió DESPUES.
+        assert!(c.es_stale(201));
+    }
+
+    #[test]
+    fn marcar_fresco_actualiza_ambos_timestamps() {
+        let mut c = Cuerpo::nuevo("qu", "qu", Intencion::Traduccion, 100)
+            .deriva_de(Uuid::new_v4(), 100);
+        c.marcar_fresco(500);
+        assert_eq!(c.metadatos.fresco_hasta, Some(500));
+        assert_eq!(c.metadatos.modificado_en, 500);
+    }
+
+    #[test]
+    fn roundtrip_postcard_es_simetrico() {
+        let mut c = Cuerpo::nuevo("qu", "quechua del Cuzco", Intencion::Traduccion, 1000)
+            .deriva_de(Uuid::new_v4(), 1000)
+            .con_lengua("qu");
+        c.agregar(Uuid::new_v4(), 1001);
+        c.agregar(Uuid::new_v4(), 1002);
+        let bytes = c.serializar().unwrap();
+        let recuperado = Cuerpo::deserializar(&bytes).unwrap();
+        assert_eq!(recuperado, c);
+        recuperado.valida_consistencia().unwrap();
+    }
+
+    #[test]
+    fn intencion_es_derivada_distingue_original_de_lo_demas() {
+        assert!(!Intencion::Original.es_derivada());
+        assert!(Intencion::Traduccion.es_derivada());
+        assert!(Intencion::Tono { etiqueta: "formal".into() }.es_derivada());
+        assert!(Intencion::Resumen { palabras_objetivo: Some(200) }.es_derivada());
+        assert!(Intencion::Reescritura { prompt: "p".into() }.es_derivada());
+        assert!(Intencion::Anotacion.es_derivada());
+        assert!(Intencion::Custom { kind: "x".into() }.es_derivada());
+    }
+}
diff --git a/00_unanchay/pluma/pluma-deck-app/Cargo.toml b/00_unanchay/pluma/pluma-deck-app/Cargo.toml
new file mode 100644
index 0000000..e212a09
--- /dev/null
+++ b/00_unanchay/pluma/pluma-deck-app/Cargo.toml
@@ -0,0 +1,28 @@
+[package]
+name = "pluma-deck-app"
+version.workspace = true
+edition.workspace = true
+rust-version.workspace = true
+license.workspace = true
+authors.workspace = true
+publish.workspace = true
+description = "App de presentaciones espaciales (modo Recorrido, tipo Prezi): abre .deck/.md, presenta, autorea y guarda. Frontend Llimphi sobre pluma-deck-core."
+
+[[bin]]
+name = "pluma-deck"
+path = "src/main.rs"
+
+[dependencies]
+# Modelo + cámara + ruta + autoría + persistencia (postcard) + adaptador pluma.
+pluma-deck-core = { path = "../pluma-deck-core", features = ["pluma", "serde"] }
+# Frontend Llimphi (pintura + vistas presentar/editar).
+pluma-deck-recorrido-llimphi = { path = "../pluma-deck-recorrido-llimphi" }
+# Para abrir documentos markdown reales → Recorrido vía el adaptador del core.
+pluma-md = { path = "../pluma-md" }
+llimphi-ui = { workspace = true }
+# Barra de menú principal + menú contextual sobre el marco seleccionado.
+llimphi-widget-menubar = { workspace = true }
+llimphi-widget-context-menu = { workspace = true }
+llimphi-theme = { workspace = true }
+llimphi-motion = { workspace = true }
+app-bus = { workspace = true }
diff --git a/00_unanchay/pluma/pluma-deck-app/src/main.rs b/00_unanchay/pluma/pluma-deck-app/src/main.rs
new file mode 100644
index 0000000..e632cb0
--- /dev/null
+++ b/00_unanchay/pluma/pluma-deck-app/src/main.rs
@@ -0,0 +1,706 @@
+//! `pluma-deck` — app de **presentaciones espaciales** (modo Recorrido, tipo Prezi).
+//!
+//! Unifica en un solo binario lo que antes vivía repartido en demos: abrir un
+//! documento, **presentarlo** (la cámara vuela por la ruta) y **autorearlo**
+//! (mover/crear/borrar/rotar marcos), con guardar/cargar en el formato nativo.
+//! Toda la lógica vive en `pluma-deck-core` (cámara, ruta, autoría, persistencia,
+//! adaptador pluma); aquí sólo se cablean eventos y se elige la vista por modo.
+//!
+//! Uso:
+//!   `cargo run -p pluma-deck-app -- [archivo.deck | archivo.md]`
+//!   - `*.deck`  → carga el recorrido binario (postcard) y guarda sobre él.
+//!   - `*.md`    → importa markdown como recorrido (guarda en `recorrido.deck`).
+//!   - sin arg   → recorrido de bienvenida (guarda en `recorrido.deck`).
+//!
+//! Controles comunes: **flechas / Espacio / Enter** vuela por la ruta ·
+//! **Home/Esc** vista general · **p** modo presentador (autoplay) · **rueda**
+//! zoom-a-cursor · **Tab** alterna presentar/editar · **Ctrl+S / Ctrl+O**
+//! guarda / carga · **Ctrl+Z / Ctrl+Shift+Z** deshace / rehace autoría. En
+//! **editar**: arrastrar mueve/selecciona un marco (o panea el vacío), **n**
+//! crea, **Supr** elimina, **[ ]** rota. En **presentar**: arrastrar panea libre.
+
+use std::path::PathBuf;
+use std::sync::Arc;
+use std::time::Duration;
+
+use llimphi_theme::Theme;
+use llimphi_ui::llimphi_layout::taffy::prelude::{percent, FlexDirection, Size, Style};
+use llimphi_ui::{App, DragPhase, Handle, Key, KeyEvent, KeyState, Modifiers, NamedKey, View, WheelDelta};
+use llimphi_widget_context_menu::{
+    context_menu_view, ContextMenuItem, ContextMenuPalette, ContextMenuSpec,
+};
+use llimphi_widget_menubar::{
+    menubar_command_at, menubar_nav, menubar_overlay_animated, menubar_view, MenuBarSpec,
+    DEFAULT_HEIGHT as MENU_H,
+};
+use llimphi_motion::{animate, motion, Tween};
+use pluma_deck_core::adaptador::recorrido_desde_atomos;
+use pluma_deck_core::{Autoplay, ContenidoMarco, Marco, Recorrido, RecorridoState, Rect, RejillaOpts};
+use pluma_deck_recorrido_llimphi::{dentro, panel_actual, recorrido_view, recorrido_view_editor, ZOOM_BASE};
+
+use app_bus::{AppMenu, Menu, MenuItem};
+
+const PANEL_INICIAL: Rect = Rect { x: 0.0, y: 0.0, w: 1200.0, h: 760.0 };
+
+#[derive(Clone, Copy, PartialEq)]
+enum Modo {
+    Presentar,
+    Editar,
+}
+
+#[derive(Clone)]
+enum Msg {
+    Zoom { mult: f64, cursor: (f32, f32) },
+    /// Pan libre (modo presentar): delta de pantalla.
+    Pan { dx: f32, dy: f32 },
+    /// Arrastre de autoría (modo editar): delta + posición del press.
+    Arrastre { dx: f32, dy: f32, lx: f32, ly: f32 },
+    FinArrastre,
+    NuevoMarco,
+    Eliminar,
+    Rotar(f64),
+    Deshacer,
+    Rehacer,
+    Guardar,
+    Cargar,
+    ToggleModo,
+    VistaGeneral,
+    ToggleAutoplay,
+    Siguiente,
+    Anterior,
+    Tick,
+    /// Cicla el tema del chrome (barra de menú / overlays).
+    CambiarTema,
+    /// Barra de menú principal: abrir/cerrar un menú raíz (`None` cierra).
+    MenuOpen(Option<usize>),
+    /// Comando elegido en el menú principal — se traduce al `Msg` real.
+    MenuCommand(String),
+    /// Navegación de teclado en el dropdown del menú principal (±1 fila).
+    MenuNav(i32),
+    /// Enter sobre la fila activa del menú principal.
+    MenuActivate,
+    /// Tick de re-render para la animación de aparición del dropdown.
+    MenuTick,
+    /// Cierra cualquier menú abierto (click-fuera / Esc).
+    CloseMenus,
+    /// Right-click en el lienzo → menú contextual anclado en `(x, y)` de
+    /// ventana. En modo editar selecciona el marco bajo el cursor (si lo hay).
+    ContextMenuOpen(f32, f32),
+}
+
+struct Model {
+    rec: Recorrido,
+    state: RecorridoState,
+    autoplay: Autoplay,
+    modo: Modo,
+    seleccionado: Option<u64>,
+    /// `None` = sin arrastre. `Some(None)` = paneando. `Some(Some(id))` = moviendo ese marco.
+    arrastrando: Option<Option<u64>>,
+    /// Destino de Ctrl+S (postcard).
+    guardar_en: PathBuf,
+    /// Undo/redo de autoría (snapshots del recorrido).
+    historial: Historial<Recorrido>,
+    /// Tema del chrome (barra de menú / overlays). El lienzo usa su paleta propia.
+    theme: Theme,
+    /// Barra de menú principal: índice del menú raíz abierto (`None` cerrado).
+    menu_open: Option<usize>,
+    /// Fila activa (resaltada por teclado) del dropdown del menú principal.
+    menu_active: usize,
+    /// Animación de aparición/swap del dropdown del menú principal (0→1).
+    menu_anim: Tween<f32>,
+    /// Menú contextual del lienzo: `(x, y)` ancla en ventana. `None` cerrado.
+    context_menu: Option<(f32, f32)>,
+}
+
+/// Recorrido de bienvenida cuando no se pasa archivo.
+fn bienvenida() -> Recorrido {
+    let slide = |t: &str, ps: &[&str]| ContenidoMarco::Texto {
+        titulo: Some(t.into()),
+        parrafos: ps.iter().map(|s| s.to_string()).collect(),
+    };
+    Recorrido::en_rejilla(
+        vec![
+            slide("pluma · deck", &["Presentaciones espaciales tipo Prezi.", "Pasá un .deck o un .md, o autoreá desde cero."]),
+            slide("Presentar", &["Flechas / Espacio vuelan por la ruta.", "Home/Esc: vista general.   p: autoplay."]),
+            slide("Editar (Tab)", &["Arrastrá para mover/seleccionar un marco.", "n: nuevo   Supr: borrar   [ ]: rotar."]),
+            slide("Guardar", &["Ctrl+S guarda, Ctrl+O carga.", "Formato nativo postcard (.deck)."]),
+        ],
+        RejillaOpts { cols: 2, marco_w: 640.0, marco_h: 400.0, gap_x: 220.0, gap_y: 180.0 },
+    )
+}
+
+/// Abre un archivo como Recorrido: `.md` se importa con el adaptador pluma;
+/// cualquier otro se intenta como `.deck` binario. `None` si falla.
+fn abrir(ruta: &str) -> Option<Recorrido> {
+    let bytes = std::fs::read(ruta).ok()?;
+    if ruta.ends_with(".md") {
+        let md = String::from_utf8(bytes).ok()?;
+        let doc = pluma_md::parse_md(&md, "es", "deck", 0);
+        Some(recorrido_desde_atomos(&doc.atoms, RejillaOpts::default()))
+    } else {
+        Recorrido::deserializar(&bytes).ok()
+    }
+}
+
+struct Deck;
+
+impl App for Deck {
+    type Model = Model;
+    type Msg = Msg;
+
+    fn title() -> &'static str {
+        "pluma · deck — presentaciones espaciales (Tab: presentar/editar)"
+    }
+
+    fn initial_size() -> (u32, u32) {
+        (1200, 760)
+    }
+
+    fn init(handle: &Handle<Self::Msg>) -> Self::Model {
+        // Resuelve el recorrido inicial y el destino de guardado según el arg.
+        let (rec, guardar_en) = match std::env::args().nth(1) {
+            Some(p) if p.ends_with(".deck") => {
+                let r = abrir(&p).unwrap_or_else(bienvenida);
+                (r, PathBuf::from(p))
+            }
+            // .md u otro: importa si puede, pero guarda en un .deck para no
+            // sobrescribir el fuente con binario.
+            Some(p) => (abrir(&p).unwrap_or_else(bienvenida), PathBuf::from("recorrido.deck")),
+            None => (bienvenida(), PathBuf::from("recorrido.deck")),
+        };
+        let mut state = RecorridoState::new();
+        state.saltar_a_paso(&rec, 0, PANEL_INICIAL);
+        handle.spawn_periodic(Duration::from_millis(16), || Msg::Tick);
+        Model {
+            rec,
+            state,
+            autoplay: Autoplay::default(),
+            modo: Modo::Presentar,
+            seleccionado: None,
+            arrastrando: None,
+            guardar_en,
+            historial: Historial::new(64),
+            theme: Theme::dark(),
+            menu_open: None,
+            menu_active: usize::MAX,
+            menu_anim: Tween::idle(1.0),
+            context_menu: None,
+        }
+    }
+
+    fn update(mut model: Self::Model, msg: Self::Msg, handle: &Handle<Self::Msg>) -> Self::Model {
+        let panel = panel_actual().unwrap_or(PANEL_INICIAL);
+        match msg {
+            Msg::Zoom { mult, cursor } => {
+                model.state.wheel(mult, (cursor.0 as f64, cursor.1 as f64), panel);
+            }
+            Msg::Pan { dx, dy } => model.state.arrastrar_delta(dx as f64, dy as f64),
+            Msg::Arrastre { dx, dy, lx, ly } => {
+                let modo = match model.arrastrando {
+                    Some(m) => m,
+                    None => {
+                        let world = model.state.camara.screen_to_world((lx as f64, ly as f64), panel);
+                        let m = model.rec.marco_en_punto(world);
+                        model.arrastrando = Some(m);
+                        if m.is_some() {
+                            model.seleccionado = m;
+                            // Una instantánea por arrastre (al agarrar el marco),
+                            // no por cada move — undo revierte el movimiento entero.
+                            model.historial.registrar(&model.rec);
+                        }
+                        m
+                    }
+                };
+                match modo {
+                    Some(id) => {
+                        let (wdx, wdy) = model.state.camara.delta_pantalla_a_mundo(dx as f64, dy as f64);
+                        model.rec.mover_marco(id, wdx, wdy);
+                    }
+                    None => model.state.arrastrar_delta(dx as f64, dy as f64),
+                }
+            }
+            Msg::FinArrastre => model.arrastrando = None,
+            Msg::NuevoMarco => {
+                model.historial.registrar(&model.rec);
+                let id = model.rec.marcos.iter().map(|m| m.id).max().unwrap_or(0) + 1;
+                let (cx, cy) = model.state.camara.centro;
+                let (w, h) = (520.0, 320.0);
+                model.rec.agregar_marco(Marco::new(
+                    id,
+                    Rect::new(cx - w * 0.5, cy - h * 0.5, w, h),
+                    ContenidoMarco::Texto { titulo: Some(format!("marco {id}")), parrafos: vec![] },
+                ));
+                model.rec.pasos.push(id);
+                model.seleccionado = Some(id);
+            }
+            Msg::Eliminar => {
+                if let Some(id) = model.seleccionado.take() {
+                    model.historial.registrar(&model.rec);
+                    model.rec.eliminar_marco(id);
+                    let idx = model.state.paso.min(model.rec.n_pasos().saturating_sub(1));
+                    model.state.saltar_a_paso(&model.rec, idx, panel);
+                }
+            }
+            Msg::Rotar(d) => {
+                if let Some(id) = model.seleccionado {
+                    model.historial.registrar(&model.rec);
+                    model.rec.rotar_marco(id, d);
+                }
+            }
+            Msg::Deshacer | Msg::Rehacer => {
+                let nuevo = match msg {
+                    Msg::Deshacer => model.historial.deshacer(&model.rec),
+                    _ => model.historial.rehacer(&model.rec),
+                };
+                if let Some(rec) = nuevo {
+                    model.rec = rec;
+                    // La selección puede haber dejado de existir; el paso se clampa.
+                    if model.seleccionado.map_or(false, |id| model.rec.marco(id).is_none()) {
+                        model.seleccionado = None;
+                    }
+                    let idx = model.state.paso.min(model.rec.n_pasos().saturating_sub(1));
+                    model.state.saltar_a_paso(&model.rec, idx, panel);
+                }
+            }
+            Msg::Guardar => match model.rec.serializar() {
+                Ok(bytes) => {
+                    let _ = std::fs::write(&model.guardar_en, &bytes);
+                    eprintln!("guardado {} ({} bytes)", model.guardar_en.display(), bytes.len());
+                }
+                Err(e) => eprintln!("error al guardar: {e}"),
+            },
+            Msg::Cargar => {
+                let r = std::fs::read(&model.guardar_en)
+                    .map_err(|_| "no se pudo leer")
+                    .and_then(|b| Recorrido::deserializar(&b));
+                match r {
+                    Ok(rec) => {
+                        model.rec = rec;
+                        model.seleccionado = None;
+                        model.state.saltar_a_paso(&model.rec, 0, panel);
+                        eprintln!("cargado {}", model.guardar_en.display());
+                    }
+                    Err(e) => eprintln!("error al cargar: {e}"),
+                }
+            }
+            Msg::ToggleModo => {
+                model.modo = match model.modo {
+                    Modo::Presentar => Modo::Editar,
+                    Modo::Editar => Modo::Presentar,
+                };
+                model.autoplay.pausa();
+                eprintln!("modo: {}", if model.modo == Modo::Editar { "editar" } else { "presentar" });
+            }
+            Msg::VistaGeneral => {
+                model.state.vista_general(&model.rec, panel);
+            }
+            Msg::ToggleAutoplay => {
+                model.autoplay.toggle();
+            }
+            Msg::Siguiente => {
+                model.state.siguiente(&model.rec, panel);
+            }
+            Msg::Anterior => {
+                model.state.anterior(&model.rec, panel);
+            }
+            Msg::Tick => {
+                model.state.avanzar(1.0 / 60.0);
+                model.autoplay.tick(1.0 / 60.0, &mut model.state, &model.rec, panel);
+            }
+            Msg::CambiarTema => {
+                model.theme = Theme::next_after(model.theme.name);
+            }
+            Msg::MenuOpen(which) => {
+                model.menu_open = which;
+                model.menu_active = usize::MAX;
+                // Abrir un menú raíz cierra cualquier contextual.
+                model.context_menu = None;
+                // Animación de aparición/swap: cada vez que se abre (o se
+                // cambia de) menú, el dropdown se funde+desliza de nuevo.
+                if which.is_some() {
+                    model.menu_anim = Tween::new(0.0, 1.0, motion::FAST, motion::ease_out_cubic);
+                    animate(handle, motion::FAST, || Msg::MenuTick);
+                }
+            }
+            Msg::MenuNav(dir) => {
+                if let Some(mi) = model.menu_open {
+                    let menu = app_menu(&model);
+                    model.menu_active = menubar_nav(&menu, mi, model.menu_active, dir);
+                }
+            }
+            Msg::MenuActivate => {
+                if let Some(mi) = model.menu_open {
+                    let menu = app_menu(&model);
+                    if let Some(cmd) = menubar_command_at(&menu, mi, model.menu_active) {
+                        return Deck::update(model, Msg::MenuCommand(cmd), handle);
+                    }
+                }
+            }
+            Msg::MenuTick => {}
+            Msg::CloseMenus => {
+                model.menu_open = None;
+                model.menu_active = usize::MAX;
+                model.context_menu = None;
+            }
+            Msg::MenuCommand(cmd) => {
+                model.menu_open = None;
+                model.menu_active = usize::MAX;
+                if let Some(next) = comando_a_msg(&cmd) {
+                    return Deck::update(model, next, handle);
+                }
+                // Comandos sin Msg directo (salir / no-op).
+                if cmd == "file.quit" {
+                    std::process::exit(0);
+                }
+            }
+            Msg::ContextMenuOpen(x, y) => {
+                model.menu_open = None;
+                // En editar, el right-click también selecciona el marco bajo el
+                // cursor (si lo hay) para que las acciones del menú apliquen a él.
+                if model.modo == Modo::Editar {
+                    let world = model.state.camara.screen_to_world((x as f64, y as f64), panel);
+                    if let Some(id) = model.rec.marco_en_punto(world) {
+                        model.seleccionado = Some(id);
+                    }
+                }
+                model.context_menu = Some((x, y));
+            }
+        }
+        model
+    }
+
+    fn view(model: &Self::Model) -> View<Self::Msg> {
+        let menu = app_menu(model);
+        let menubar = menubar_view(&menubar_spec(&menu, model));
+
+        let lienzo = match model.modo {
+            Modo::Editar => recorrido_view_editor(&model.rec, &model.state, model.seleccionado)
+                .draggable_at(|phase, dx, dy, lx, ly| match phase {
+                    DragPhase::Move => Some(Msg::Arrastre { dx, dy, lx, ly }),
+                    DragPhase::End => Some(Msg::FinArrastre),
+                }),
+            Modo::Presentar => recorrido_view(&model.rec, &model.state).draggable(|phase, dx, dy| match phase {
+                DragPhase::Move => Some(Msg::Pan { dx, dy }),
+                DragPhase::End => None,
+            }),
+        };
+
+        // Column raíz: barra de menú arriba + lienzo a pantalla completa debajo.
+        // El right-click va en la RAÍZ (origen 0,0 ⇒ local == ventana) para anclar
+        // el menú contextual en coords de ventana, igual que el panel del lienzo.
+        View::new(Style {
+            flex_direction: FlexDirection::Column,
+            size: Size { width: percent(1.0_f32), height: percent(1.0_f32) },
+            ..Default::default()
+        })
+        .fill(model.theme.bg_app)
+        .on_right_click_at(|x, y, _w, _h| Some(Msg::ContextMenuOpen(x, y)))
+        .children(vec![menubar, lienzo])
+    }
+
+    fn view_overlay(model: &Self::Model) -> Option<View<Self::Msg>> {
+        // Menú contextual del lienzo tiene prioridad sobre el dropdown principal.
+        if let Some((x, y)) = model.context_menu {
+            let viewport = viewport_of(model);
+            // Acciones según el modo. En editar, sobre el marco seleccionado;
+            // en presentar, navegación. Sólo comandos que mapean a Msg reales.
+            let (header, items, on_pick): (String, Vec<ContextMenuItem>, Arc<dyn Fn(usize) -> Msg + Send + Sync>) =
+                if model.modo == Modo::Editar {
+                    let hay_sel = model.seleccionado.is_some();
+                    // Las acciones sobre marco se deshabilitan (gris) sin selección.
+                    let opt = |it: ContextMenuItem| if hay_sel { it } else { it.disabled() };
+                    let rotar_l = opt(ContextMenuItem::action("Rotar ⟲"));
+                    let rotar_r = opt(ContextMenuItem::action("Rotar ⟳"));
+                    let borrar = opt(ContextMenuItem::action("Eliminar marco").destructive());
+                    let items = vec![
+                        ContextMenuItem::action("Nuevo marco"),
+                        rotar_l,
+                        rotar_r,
+                        ContextMenuItem::separator(),
+                        borrar,
+                    ];
+                    let on_pick: Arc<dyn Fn(usize) -> Msg + Send + Sync> = Arc::new(|i: usize| match i {
+                        0 => Msg::NuevoMarco,
+                        1 => Msg::Rotar(-0.08),
+                        2 => Msg::Rotar(0.08),
+                        4 => Msg::Eliminar,
+                        _ => Msg::CloseMenus,
+                    });
+                    ("Editar marco".to_string(), items, on_pick)
+                } else {
+                    let items = vec![
+                        ContextMenuItem::action("Siguiente"),
+                        ContextMenuItem::action("Anterior"),
+                        ContextMenuItem::action("Vista general"),
+                        ContextMenuItem::separator(),
+                        ContextMenuItem::action("Presentar (autoplay)"),
+                    ];
+                    let on_pick: Arc<dyn Fn(usize) -> Msg + Send + Sync> = Arc::new(|i: usize| match i {
+                        0 => Msg::Siguiente,
+                        1 => Msg::Anterior,
+                        2 => Msg::VistaGeneral,
+                        4 => Msg::ToggleAutoplay,
+                        _ => Msg::CloseMenus,
+                    });
+                    ("Presentar".to_string(), items, on_pick)
+                };
+            return Some(context_menu_view(ContextMenuSpec {
+                anchor: (x, y),
+                viewport,
+                header: Some(header),
+                items,
+                active: usize::MAX,
+                on_pick,
+                on_dismiss: Msg::CloseMenus,
+                palette: ContextMenuPalette::from_theme(&model.theme),
+            }));
+        }
+        // Si no, el dropdown del menú principal (con nav por teclado + animación).
+        let menu = app_menu(model);
+        menubar_overlay_animated(
+            &menubar_spec(&menu, model),
+            model.menu_active,
+            model.menu_anim.value(),
+        )
+    }
+
+    fn on_wheel(_m: &Self::Model, delta: WheelDelta, cursor: (f32, f32), _mods: Modifiers) -> Option<Self::Msg> {
+        let panel = panel_actual()?;
+        if !dentro(panel, cursor.0, cursor.1) {
+            return None;
+        }
+        Some(Msg::Zoom { mult: ZOOM_BASE.powf(-delta.y as f64), cursor })
+    }
+
+    fn on_key(model: &Self::Model, ev: &KeyEvent) -> Option<Self::Msg> {
+        if ev.state != KeyState::Pressed {
+            return None;
+        }
+        // Menú principal abierto: las flechas navegan. ←/→ cambian de menú
+        // raíz (con wrap), ↑/↓ mueven la fila activa, Enter ejecuta, Esc
+        // cierra. Tiene prioridad sobre todo lo demás.
+        if let Some(mi) = model.menu_open {
+            let n = app_menu(model).menus.len().max(1);
+            return match &ev.key {
+                Key::Named(NamedKey::Escape) => Some(Msg::CloseMenus),
+                Key::Named(NamedKey::ArrowLeft) => Some(Msg::MenuOpen(Some((mi + n - 1) % n))),
+                Key::Named(NamedKey::ArrowRight) => Some(Msg::MenuOpen(Some((mi + 1) % n))),
+                Key::Named(NamedKey::ArrowDown) => Some(Msg::MenuNav(1)),
+                Key::Named(NamedKey::ArrowUp) => Some(Msg::MenuNav(-1)),
+                Key::Named(NamedKey::Enter) => Some(Msg::MenuActivate),
+                _ => None,
+            };
+        }
+        // Guardar/cargar en cualquier modo.
+        if ev.modifiers.ctrl {
+            return match &ev.key {
+                Key::Character(c) if c.eq_ignore_ascii_case("s") => Some(Msg::Guardar),
+                Key::Character(c) if c.eq_ignore_ascii_case("o") => Some(Msg::Cargar),
+                // Ctrl+Z deshace; Ctrl+Shift+Z o Ctrl+Y rehacen.
+                Key::Character(c) if c.eq_ignore_ascii_case("z") => {
+                    Some(if ev.modifiers.shift { Msg::Rehacer } else { Msg::Deshacer })
+                }
+                Key::Character(c) if c.eq_ignore_ascii_case("y") => Some(Msg::Rehacer),
+                _ => None,
+            };
+        }
+        // Comunes a ambos modos.
+        match &ev.key {
+            Key::Named(NamedKey::Tab) => return Some(Msg::ToggleModo),
+            Key::Named(NamedKey::Home | NamedKey::Escape) => return Some(Msg::VistaGeneral),
+            Key::Named(NamedKey::ArrowRight | NamedKey::ArrowDown | NamedKey::Enter | NamedKey::Space) => {
+                return Some(Msg::Siguiente)
+            }
+            Key::Named(NamedKey::ArrowLeft | NamedKey::ArrowUp) => return Some(Msg::Anterior),
+            Key::Character(c) if c.eq_ignore_ascii_case("p") => return Some(Msg::ToggleAutoplay),
+            _ => {}
+        }
+        // Sólo en modo editar.
+        if model.modo == Modo::Editar {
+            return match &ev.key {
+                Key::Character(c) if c.as_str() == "n" => Some(Msg::NuevoMarco),
+                Key::Character(c) if c.as_str() == "[" => Some(Msg::Rotar(-0.08)),
+                Key::Character(c) if c.as_str() == "]" => Some(Msg::Rotar(0.08)),
+                Key::Named(NamedKey::Delete | NamedKey::Backspace) => Some(Msg::Eliminar),
+                _ => None,
+            };
+        }
+        None
+    }
+}
+
+/// Pila de undo/redo genérica para autoría. Antes de cada cambio se `registrar`a
+/// el estado previo; `deshacer`/`rehacer` mueven el estado entre pasado y futuro.
+/// Acotada a `max` entradas (descarta las más viejas).
+struct Historial<T> {
+    pasado: Vec<T>,
+    futuro: Vec<T>,
+    max: usize,
+}
+
+impl<T: Clone> Historial<T> {
+    fn new(max: usize) -> Self {
+        Self { pasado: Vec::new(), futuro: Vec::new(), max: max.max(1) }
+    }
+
+    /// Registra `actual` (estado **previo** al cambio que está por aplicarse) y
+    /// limpia el redo — una rama nueva invalida los rehacer pendientes.
+    fn registrar(&mut self, actual: &T) {
+        self.pasado.push(actual.clone());
+        if self.pasado.len() > self.max {
+            self.pasado.remove(0);
+        }
+        self.futuro.clear();
+    }
+
+    /// Deshace: devuelve el último estado registrado y manda `actual` al futuro.
+    fn deshacer(&mut self, actual: &T) -> Option<T> {
+        let prev = self.pasado.pop()?;
+        self.futuro.push(actual.clone());
+        Some(prev)
+    }
+
+    /// Rehace: devuelve el último estado deshecho y manda `actual` al pasado.
+    fn rehacer(&mut self, actual: &T) -> Option<T> {
+        let next = self.futuro.pop()?;
+        self.pasado.push(actual.clone());
+        Some(next)
+    }
+}
+
+/// Viewport para clampear overlays. El deck no trackea el resize, así que
+/// usamos el tamaño inicial — basta para anclar los menús dentro de pantalla.
+fn viewport_of(_model: &Model) -> (f32, f32) {
+    let (w, h) = Deck::initial_size();
+    (w as f32, h as f32)
+}
+
+/// Arma el `MenuBarSpec` compartido por `menubar_view` y `menubar_overlay`.
+fn menubar_spec<'a>(menu: &'a AppMenu, model: &'a Model) -> MenuBarSpec<'a, Msg> {
+    MenuBarSpec {
+        menu,
+        open: model.menu_open,
+        theme: &model.theme,
+        viewport: viewport_of(model),
+        height: MENU_H,
+        on_open: Arc::new(Msg::MenuOpen),
+        on_command: Arc::new(|c: &str| Msg::MenuCommand(c.to_string())),
+    }
+}
+
+/// Menú principal del deck. Sólo comandos que mapean a acciones reales del
+/// `update`. "Editar" (Slide) aparece siempre porque la autoría no necesita
+/// foco de texto; sus ítems se deshabilitan según el modo / selección reales.
+fn app_menu(model: &Model) -> AppMenu {
+    let editar = model.modo == Modo::Editar;
+    let hay_sel = model.seleccionado.is_some();
+    // Helper: ítem habilitado/deshabilitado según condición real.
+    let item = |label: &str, cmd: &str, on: bool| {
+        let it = MenuItem::new(label, cmd);
+        if on { it } else { it.disabled() }
+    };
+    AppMenu::new()
+        .menu(
+            Menu::new("Archivo")
+                .item(MenuItem::new("Guardar", "file.save").shortcut("Ctrl+S"))
+                .item(MenuItem::new("Cargar", "file.open").shortcut("Ctrl+O")),
+        )
+        .menu(
+            Menu::new("Editar")
+                .item(MenuItem::new("Deshacer", "edit.undo").shortcut("Ctrl+Z"))
+                .item(MenuItem::new("Rehacer", "edit.redo").shortcut("Ctrl+Shift+Z").separated())
+                .item(item("Rotar ⟲", "edit.rotar_l", editar && hay_sel))
+                .item(item("Rotar ⟳", "edit.rotar_r", editar && hay_sel)),
+        )
+        .menu(
+            Menu::new("Slide")
+                .item(item("Nuevo marco", "slide.nuevo", editar).shortcut("n"))
+                .item(item("Eliminar marco", "slide.eliminar", editar && hay_sel).shortcut("Supr"))
+                .item(MenuItem::new("Siguiente", "slide.siguiente").shortcut("→").separated())
+                .item(MenuItem::new("Anterior", "slide.anterior").shortcut("←")),
+        )
+        .menu(
+            Menu::new("Ver")
+                .item(MenuItem::new(
+                    if editar { "Presentar (Tab)" } else { "Editar (Tab)" },
+                    "view.modo",
+                ))
+                .item(MenuItem::new("Vista general", "view.general").shortcut("Home"))
+                .item(MenuItem::new("Autoplay", "view.autoplay").shortcut("p"))
+                .item(MenuItem::new("Cambiar tema", "view.theme").separated()),
+        )
+        .menu(Menu::new("Ayuda").item(MenuItem::new("Acerca de", "help.about")))
+}
+
+/// Traduce un command id del menú principal al `Msg` real del deck. `None` para
+/// los que no tienen Msg directo (`file.quit` se maneja aparte; `help.about` no-op).
+fn comando_a_msg(cmd: &str) -> Option<Msg> {
+    Some(match cmd {
+        "file.save" => Msg::Guardar,
+        "file.open" => Msg::Cargar,
+        "edit.undo" => Msg::Deshacer,
+        "edit.redo" => Msg::Rehacer,
+        "edit.rotar_l" => Msg::Rotar(-0.08),
+        "edit.rotar_r" => Msg::Rotar(0.08),
+        "slide.nuevo" => Msg::NuevoMarco,
+        "slide.eliminar" => Msg::Eliminar,
+        "slide.siguiente" => Msg::Siguiente,
+        "slide.anterior" => Msg::Anterior,
+        "view.modo" => Msg::ToggleModo,
+        "view.general" => Msg::VistaGeneral,
+        "view.autoplay" => Msg::ToggleAutoplay,
+        "view.theme" => Msg::CambiarTema,
+        _ => return None,
+    })
+}
+
+fn main() {
+    llimphi_ui::run::<Deck>();
+}
+
+#[cfg(test)]
+mod pruebas {
+    use super::Historial;
+
+    #[test]
+    fn deshacer_rehacer_round_trip() {
+        let mut h = Historial::new(10);
+        // estado: 0 → (registrar 0) → 1 → (registrar 1) → 2
+        h.registrar(&0);
+        h.registrar(&1);
+        let mut actual = 2;
+        // deshacer dos veces: 2→1→0
+        actual = h.deshacer(&actual).unwrap();
+        assert_eq!(actual, 1);
+        actual = h.deshacer(&actual).unwrap();
+        assert_eq!(actual, 0);
+        assert!(h.deshacer(&actual).is_none(), "sin más pasado");
+        // rehacer dos veces: 0→1→2
+        actual = h.rehacer(&actual).unwrap();
+        assert_eq!(actual, 1);
+        actual = h.rehacer(&actual).unwrap();
+        assert_eq!(actual, 2);
+        assert!(h.rehacer(&actual).is_none(), "sin más futuro");
+    }
+
+    #[test]
+    fn registrar_tras_deshacer_limpia_el_futuro() {
+        let mut h = Historial::new(10);
+        h.registrar(&0);
+        let actual = h.deshacer(&1).unwrap(); // actual ahora = 0, futuro = [1]
+        assert_eq!(actual, 0);
+        h.registrar(&actual); // rama nueva: el futuro se descarta
+        assert!(h.rehacer(&actual).is_none());
+    }
+
+    #[test]
+    fn respeta_el_tope_descartando_lo_mas_viejo() {
+        let mut h = Historial::new(2);
+        h.registrar(&1);
+        h.registrar(&2);
+        h.registrar(&3); // descarta el 1
+        assert_eq!(h.deshacer(&99), Some(3));
+        assert_eq!(h.deshacer(&3), Some(2));
+        assert!(h.deshacer(&2).is_none());
+    }
+}
diff --git a/00_unanchay/pluma/pluma-deck-core/Cargo.toml b/00_unanchay/pluma/pluma-deck-core/Cargo.toml
new file mode 100644
index 0000000..d745206
--- /dev/null
+++ b/00_unanchay/pluma/pluma-deck-core/Cargo.toml
@@ -0,0 +1,25 @@
+[package]
+name = "pluma-deck-core"
+description = "Vista — máquina de estados agnóstica para deck horizontal con snap. Sin dependencias web/DOM."
+version.workspace = true
+edition.workspace = true
+license.workspace = true
+authors.workspace = true
+publish.workspace = true
+
+[features]
+# Adaptador opcional pluma → Recorrido. El core sigue agnóstico por defecto
+# (cero dependencias); activar `pluma` trae el puente desde el modelo de
+# documento de pluma (NarrativeAtom / Cuerpo) hacia los slides del Recorrido.
+pluma = ["dep:pluma-core", "dep:pluma-cuerpo", "dep:uuid"]
+# Persistencia del Recorrido en el codec nativo del workspace (postcard, el
+# mismo de format/akasha/pluma-cuerpo). Deriva serde sobre el modelo de datos
+# (Rect/Camara/Marco/ContenidoMarco/Recorrido) + helpers serializar/deserializar.
+serde = ["dep:serde", "dep:postcard"]
+
+[dependencies]
+pluma-core = { path = "../pluma-core", optional = true }
+pluma-cuerpo = { path = "../pluma-cuerpo", optional = true }
+uuid = { workspace = true, optional = true }
+serde = { workspace = true, optional = true, features = ["derive"] }
+postcard = { workspace = true, optional = true }
diff --git a/00_unanchay/pluma/pluma-deck-core/LEEME.md b/00_unanchay/pluma/pluma-deck-core/LEEME.md
new file mode 100644
index 0000000..dad3c38
--- /dev/null
+++ b/00_unanchay/pluma/pluma-deck-core/LEEME.md
@@ -0,0 +1,18 @@
+# pluma-deck-core
+
+> Deck (slides) sobre [pluma](../README.md).
+
+Modelo de slides: un `Deck` es una secuencia de `Slide`, cada slide es un subgrafo del documento + layout hint (full · split · cover). Sin UI ni render. Sirve como capa de organización para presentaciones cuya fuente sigue siendo el doc pluma.
+
+## API
+
+```rust
+use pluma_deck_core::{Deck, Slide};
+
+let deck = Deck::from_doc(&doc, &slide_breaks);
+```
+
+## Deps
+
+- [`pluma-core`](../pluma-core/README.md), [`pluma-cuerpo`](../pluma-cuerpo/README.md)
+- `serde`
diff --git a/00_unanchay/pluma/pluma-deck-core/README.md b/00_unanchay/pluma/pluma-deck-core/README.md
new file mode 100644
index 0000000..e96de85
--- /dev/null
+++ b/00_unanchay/pluma/pluma-deck-core/README.md
@@ -0,0 +1,18 @@
+# pluma-deck-core
+
+> Deck (slides) on [pluma](../README.md).
+
+Slide model: a `Deck` is a sequence of `Slide`, each slide is a subgraph of the document + layout hint (full · split · cover). No UI or render. Acts as an organization layer for presentations whose source remains the pluma doc.
+
+## API
+
+```rust
+use pluma_deck_core::{Deck, Slide};
+
+let deck = Deck::from_doc(&doc, &slide_breaks);
+```
+
+## Deps
+
+- [`pluma-core`](../pluma-core/README.md), [`pluma-cuerpo`](../pluma-cuerpo/README.md)
+- `serde`
diff --git a/00_unanchay/pluma/pluma-deck-core/src/adaptador.rs b/00_unanchay/pluma/pluma-deck-core/src/adaptador.rs
new file mode 100644
index 0000000..69e4bff
--- /dev/null
+++ b/00_unanchay/pluma/pluma-deck-core/src/adaptador.rs
@@ -0,0 +1,141 @@
+//! Adaptador opcional **pluma → Recorrido** (feature `pluma`).
+//!
+//! Puente entre el modelo de documento de pluma ([`NarrativeAtom`] /
+//! [`Cuerpo`]) y los slides agnósticos de [`crate::ContenidoMarco`]. Promovido
+//! desde el glue que vivía inline en el example `recorrido_md_demo` — ahora es
+//! reusable y testeado. El core sigue agnóstico: este módulo sólo compila con
+//! la feature `pluma` activa; sin ella, `pluma-deck-core` no conoce pluma.
+//!
+//! Regla de agrupación: un átomo cuyo texto arranca con `#`+espacio (encabezado
+//! markdown) abre un slide nuevo cuyo título es ese encabezado; los demás átomos
+//! son párrafos del slide actual. Es el mismo criterio que `pluma-md` usa al
+//! emitir un átomo por bloque.
+
+use pluma_core::NarrativeAtom;
+use pluma_cuerpo::Cuerpo;
+use uuid::Uuid;
+
+use crate::{ContenidoMarco, Recorrido, RejillaOpts};
+
+/// Cierra el slide en construcción (si tiene algo) y lo empuja a `slides`.
+fn empujar(slides: &mut Vec<ContenidoMarco>, titulo: &mut Option<String>, parrafos: &mut Vec<String>) {
+    if titulo.is_some() || !parrafos.is_empty() {
+        slides.push(ContenidoMarco::Texto {
+            titulo: titulo.take(),
+            parrafos: std::mem::take(parrafos),
+        });
+    }
+}
+
+/// Agrupa una secuencia de textos (en orden de documento) en slides. Un texto
+/// que arranca con `#`+espacio abre un slide nuevo (su título = el encabezado
+/// sin los `#`); los textos vacíos se ignoran; el resto son párrafos del slide
+/// actual. Es la pieza pura — `recorrido_desde_*` la alimentan.
+pub fn slides_desde_textos<'a>(textos: impl IntoIterator<Item = &'a str>) -> Vec<ContenidoMarco> {
+    let mut slides = Vec::new();
+    let mut titulo: Option<String> = None;
+    let mut parrafos: Vec<String> = Vec::new();
+    for c in textos {
+        let hashes = c.chars().take_while(|&ch| ch == '#').count();
+        let es_encabezado = hashes > 0 && c[hashes..].starts_with(' ');
+        if es_encabezado {
+            empujar(&mut slides, &mut titulo, &mut parrafos);
+            titulo = Some(c[hashes..].trim().to_string());
+        } else if !c.trim().is_empty() {
+            parrafos.push(c.to_string());
+        }
+    }
+    empujar(&mut slides, &mut titulo, &mut parrafos);
+    slides
+}
+
+/// Recorrido desde una secuencia de átomos en **orden de documento** (un
+/// encabezado abre slide). `opts` controla el auto-layout en rejilla.
+pub fn recorrido_desde_atomos(atomos: &[NarrativeAtom], opts: RejillaOpts) -> Recorrido {
+    Recorrido::en_rejilla(slides_desde_textos(atomos.iter().map(|a| a.content.as_str())), opts)
+}
+
+/// Recorrido desde un [`Cuerpo`]: recorre `cuerpo.orden` resolviendo cada `Uuid`
+/// a su átomo con `resolver` (el cuerpo no conoce el grafo, lo resuelve el
+/// caller); los ids que no resuelven se omiten. Así un lienzo del haz multilienzo
+/// alimenta una presentación sin que el core conozca el `NarrativeGraph`.
+pub fn recorrido_desde_cuerpo<'a>(
+    cuerpo: &Cuerpo,
+    resolver: impl Fn(&Uuid) -> Option<&'a NarrativeAtom>,
+    opts: RejillaOpts,
+) -> Recorrido {
+    let textos = cuerpo.orden.iter().filter_map(|id| resolver(id)).map(|a| a.content.as_str());
+    Recorrido::en_rejilla(slides_desde_textos(textos), opts)
+}
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+    use pluma_cuerpo::Intencion;
+
+    fn atom(c: &str) -> NarrativeAtom {
+        NarrativeAtom::new(c, "es")
+    }
+
+    fn como_texto(c: &ContenidoMarco) -> (Option<&str>, &[String]) {
+        match c {
+            ContenidoMarco::Texto { titulo, parrafos } => (titulo.as_deref(), parrafos.as_slice()),
+            _ => panic!("se esperaba ContenidoMarco::Texto"),
+        }
+    }
+
+    #[test]
+    fn slides_agrupan_por_encabezado() {
+        let textos = ["# Título A", "p1", "", "p2", "## Título B", "p3"];
+        let slides = slides_desde_textos(textos.iter().copied());
+        assert_eq!(slides.len(), 2);
+        let (t0, p0) = como_texto(&slides[0]);
+        assert_eq!(t0, Some("Título A"));
+        assert_eq!(p0, &["p1".to_string(), "p2".to_string()]); // el vacío se ignora
+        let (t1, p1) = como_texto(&slides[1]);
+        assert_eq!(t1, Some("Título B"));
+        assert_eq!(p1, &["p3".to_string()]);
+    }
+
+    #[test]
+    fn parrafos_sueltos_sin_encabezado_forman_un_slide_sin_titulo() {
+        let slides = slides_desde_textos(["solo párrafo", "y otro"].iter().copied());
+        assert_eq!(slides.len(), 1);
+        let (t, p) = como_texto(&slides[0]);
+        assert_eq!(t, None);
+        assert_eq!(p.len(), 2);
+    }
+
+    #[test]
+    fn recorrido_desde_atomos_rutea_en_orden_de_lectura() {
+        let atomos = vec![atom("# Uno"), atom("cuerpo de uno"), atom("# Dos")];
+        let rec = recorrido_desde_atomos(&atomos, RejillaOpts::default());
+        assert_eq!(rec.marcos.len(), 2);
+        assert_eq!(rec.pasos, vec![1, 2]);
+    }
+
+    #[test]
+    fn recorrido_desde_cuerpo_resuelve_el_orden_y_omite_faltantes() {
+        let a = atom("# Hola");
+        let b = atom("mundo");
+        let mut cuerpo = Cuerpo::nuevo("es", "doc", Intencion::Original, 0);
+        cuerpo.agregar(a.id, 0);
+        cuerpo.agregar(b.id, 0);
+        cuerpo.agregar(Uuid::new_v4(), 0); // id huérfano: no resuelve
+        let resolver = |id: &Uuid| {
+            if *id == a.id {
+                Some(&a)
+            } else if *id == b.id {
+                Some(&b)
+            } else {
+                None
+            }
+        };
+        let rec = recorrido_desde_cuerpo(&cuerpo, resolver, RejillaOpts::default());
+        // Un solo slide: "Hola" con el párrafo "mundo"; el huérfano se omitió.
+        assert_eq!(rec.marcos.len(), 1);
+        let (t, p) = como_texto(&rec.marcos[0].contenido);
+        assert_eq!(t, Some("Hola"));
+        assert_eq!(p, &["mundo".to_string()]);
+    }
+}
diff --git a/00_unanchay/pluma/pluma-deck-core/src/camara.rs b/00_unanchay/pluma/pluma-deck-core/src/camara.rs
new file mode 100644
index 0000000..6e32c5c
--- /dev/null
+++ b/00_unanchay/pluma/pluma-deck-core/src/camara.rs
@@ -0,0 +1,257 @@
+//! Cámara 2D para el modo `Recorrido` (presentación espacial, tipo Prezi).
+//!
+//! Matemática agnóstica del lienzo infinito: convierte entre coordenadas de
+//! *mundo* (donde viven los marcos) y coordenadas de *pantalla* (px del panel),
+//! con zoom + paneo + giro. Es la versión extraída y generalizada del zoom/pan
+//! que `tullpu-app-llimphi` resolvió inline (`factor_zoom`/`pan`/zoom-a-cursor):
+//! aquí vive como tipo reusable, sin render ni DOM.
+//!
+//! Convención: `centro` es el punto de mundo que cae en el centro del panel;
+//! `zoom` es el factor mundo→px (zoom 2.0 ⇒ 1 unidad de mundo mide 2 px);
+//! `rot_rad` gira la vista (la pantalla rota `-rot` respecto al mundo, de modo
+//! que un marco con `rot_rad` propio se ve recto cuando la cámara lo iguala).
+
+/// Clamp inferior del zoom — más allá la presentación se pierde en el infinito.
+pub const ZOOM_MIN: f64 = 0.02;
+/// Clamp superior del zoom — más allá se pixela el contenido.
+pub const ZOOM_MAX: f64 = 64.0;
+/// Fracción del panel que ocupa un marco al hacer `fit_marco` (deja aire).
+pub const FIT_MARGEN: f64 = 0.9;
+
+/// Rectángulo axis-aligned. Sirve tanto para el panel (px de pantalla) como
+/// para el `rect` de un marco (coords de mundo).
+#[derive(Clone, Copy, Debug, PartialEq)]
+#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
+pub struct Rect {
+    pub x: f64,
+    pub y: f64,
+    pub w: f64,
+    pub h: f64,
+}
+
+impl Rect {
+    pub fn new(x: f64, y: f64, w: f64, h: f64) -> Self {
+        Self { x, y, w, h }
+    }
+
+    /// Centro geométrico.
+    pub fn centro(&self) -> (f64, f64) {
+        (self.x + self.w * 0.5, self.y + self.h * 0.5)
+    }
+}
+
+/// Función de suavizado para la interpolación entre pasos del recorrido.
+#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)]
+#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
+pub enum Ease {
+    /// Velocidad constante.
+    Lineal,
+    /// Arranca y frena suave (`smoothstep`). Es el sabor "vuelo Prezi".
+    #[default]
+    SuaveInOut,
+}
+
+impl Ease {
+    /// Mapea `t ∈ [0,1]` a la curva elegida (también en `[0,1]`).
+    pub fn aplicar(self, t: f64) -> f64 {
+        let t = t.clamp(0.0, 1.0);
+        match self {
+            Ease::Lineal => t,
+            Ease::SuaveInOut => t * t * (3.0 - 2.0 * t),
+        }
+    }
+}
+
+#[derive(Clone, Copy, Debug, PartialEq)]
+#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
+pub struct Camara {
+    /// Punto de mundo que cae en el centro del panel.
+    pub centro: (f64, f64),
+    /// Factor mundo→px. Siempre dentro de `[ZOOM_MIN, ZOOM_MAX]`.
+    pub zoom: f64,
+    /// Giro de la vista en radianes.
+    pub rot_rad: f64,
+}
+
+impl Default for Camara {
+    fn default() -> Self {
+        Self { centro: (0.0, 0.0), zoom: 1.0, rot_rad: 0.0 }
+    }
+}
+
+impl Camara {
+    pub fn new(centro: (f64, f64), zoom: f64, rot_rad: f64) -> Self {
+        Self { centro, zoom: zoom.clamp(ZOOM_MIN, ZOOM_MAX), rot_rad }
+    }
+
+    /// Mundo → pantalla. `panel` son los px del viewport donde se pinta.
+    pub fn world_to_screen(&self, p: (f64, f64), panel: Rect) -> (f64, f64) {
+        let (cx, cy) = panel.centro();
+        let dx = p.0 - self.centro.0;
+        let dy = p.1 - self.centro.1;
+        // La pantalla rota -rot respecto al mundo.
+        let (s, c) = (-self.rot_rad).sin_cos();
+        let rx = dx * c - dy * s;
+        let ry = dx * s + dy * c;
+        (cx + rx * self.zoom, cy + ry * self.zoom)
+    }
+
+    /// Pantalla → mundo. Inversa exacta de [`world_to_screen`](Self::world_to_screen).
+    pub fn screen_to_world(&self, p: (f64, f64), panel: Rect) -> (f64, f64) {
+        let (cx, cy) = panel.centro();
+        let sx = (p.0 - cx) / self.zoom;
+        let sy = (p.1 - cy) / self.zoom;
+        let (s, c) = self.rot_rad.sin_cos();
+        let wx = sx * c - sy * s;
+        let wy = sx * s + sy * c;
+        (self.centro.0 + wx, self.centro.1 + wy)
+    }
+
+    /// Wheel zoom anclado al cursor: el punto de mundo bajo `cursor` queda
+    /// fijo en pantalla tras escalar por `mult` (`mult>1` acerca). Réplica del
+    /// zoom-a-cursor de tullpu. `panel`/`cursor` en px de pantalla.
+    pub fn zoom_a_cursor(&mut self, mult: f64, cursor: (f64, f64), panel: Rect) {
+        let ancla = self.screen_to_world(cursor, panel);
+        self.zoom = (self.zoom * mult).clamp(ZOOM_MIN, ZOOM_MAX);
+        // Recolocar `centro` para que `ancla` vuelva a caer bajo `cursor`.
+        let (cx, cy) = panel.centro();
+        let sx = (cursor.0 - cx) / self.zoom;
+        let sy = (cursor.1 - cy) / self.zoom;
+        let (s, c) = self.rot_rad.sin_cos();
+        let wx = sx * c - sy * s;
+        let wy = sx * s + sy * c;
+        self.centro = (ancla.0 - wx, ancla.1 - wy);
+    }
+
+    /// Convierte un delta de pantalla (px) en su delta de mundo equivalente,
+    /// deshaciendo zoom + giro. Útil para mover un objeto siguiendo al cursor
+    /// (`objeto += delta`) o para panear (`centro -= delta`).
+    pub fn delta_pantalla_a_mundo(&self, dx: f64, dy: f64) -> (f64, f64) {
+        let (s, c) = self.rot_rad.sin_cos();
+        ((dx * c - dy * s) / self.zoom, (dx * s + dy * c) / self.zoom)
+    }
+
+    /// Paneo: arrastra el contenido `(dx, dy)` px de pantalla. El punto de
+    /// mundo bajo el cursor sigue al dedo.
+    pub fn pan(&mut self, dx: f64, dy: f64) {
+        let (wx, wy) = self.delta_pantalla_a_mundo(dx, dy);
+        self.centro = (self.centro.0 - wx, self.centro.1 - wy);
+    }
+
+    /// Cámara que centra y encuadra (`contain`) `marco` en `panel`, igualando
+    /// su giro para que se vea recto. Deja `FIT_MARGEN` de aire.
+    pub fn fit(marco: Rect, marco_rot_rad: f64, panel: Rect) -> Camara {
+        let zw = if marco.w > 0.0 { panel.w / marco.w } else { 1.0 };
+        let zh = if marco.h > 0.0 { panel.h / marco.h } else { 1.0 };
+        let zoom = (zw.min(zh) * FIT_MARGEN).clamp(ZOOM_MIN, ZOOM_MAX);
+        Camara { centro: marco.centro(), zoom, rot_rad: marco_rot_rad }
+    }
+
+    /// Interpola dos cámaras. El zoom se mezcla en **espacio logarítmico**
+    /// (un acercamiento percibido constante — el "vuelo" suave de Prezi);
+    /// centro y giro, linealmente. `t` se pasa por `ease` antes de mezclar.
+    pub fn interpolar(a: &Camara, b: &Camara, t: f64, ease: Ease) -> Camara {
+        let u = ease.aplicar(t);
+        let lerp = |x: f64, y: f64| x + (y - x) * u;
+        Camara {
+            centro: (lerp(a.centro.0, b.centro.0), lerp(a.centro.1, b.centro.1)),
+            zoom: (a.zoom.ln() * (1.0 - u) + b.zoom.ln() * u).exp(),
+            rot_rad: lerp(a.rot_rad, b.rot_rad),
+        }
+    }
+}
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+
+    const PANEL: Rect = Rect { x: 0.0, y: 0.0, w: 800.0, h: 600.0 };
+
+    fn aprox(a: (f64, f64), b: (f64, f64)) {
+        assert!((a.0 - b.0).abs() < 1e-6 && (a.1 - b.1).abs() < 1e-6, "{a:?} != {b:?}");
+    }
+
+    #[test]
+    fn centro_cae_en_centro_de_panel() {
+        let cam = Camara::new((10.0, 20.0), 2.0, 0.0);
+        aprox(cam.world_to_screen((10.0, 20.0), PANEL), (400.0, 300.0));
+    }
+
+    #[test]
+    fn round_trip_world_screen_sin_giro() {
+        let cam = Camara::new((5.0, -3.0), 1.7, 0.0);
+        let p = (123.0, 456.0);
+        aprox(cam.world_to_screen(cam.screen_to_world(p, PANEL), PANEL), p);
+    }
+
+    #[test]
+    fn round_trip_world_screen_con_giro() {
+        let cam = Camara::new((5.0, -3.0), 1.7, 0.6);
+        let p = (123.0, 456.0);
+        aprox(cam.world_to_screen(cam.screen_to_world(p, PANEL), PANEL), p);
+    }
+
+    #[test]
+    fn zoom_a_cursor_deja_fijo_el_punto_bajo_el_cursor() {
+        let mut cam = Camara::new((0.0, 0.0), 1.0, 0.0);
+        let cursor = (650.0, 120.0);
+        let mundo_antes = cam.screen_to_world(cursor, PANEL);
+        cam.zoom_a_cursor(1.1, cursor, PANEL);
+        // El mismo punto de mundo debe seguir bajo el cursor.
+        aprox(cam.world_to_screen(mundo_antes, PANEL), cursor);
+        assert!((cam.zoom - 1.1).abs() < 1e-9);
+    }
+
+    #[test]
+    fn zoom_a_cursor_respeta_clamps() {
+        let mut cam = Camara::new((0.0, 0.0), ZOOM_MAX, 0.0);
+        cam.zoom_a_cursor(10.0, (400.0, 300.0), PANEL);
+        assert_eq!(cam.zoom, ZOOM_MAX);
+        cam.zoom = ZOOM_MIN;
+        cam.zoom_a_cursor(0.001, (400.0, 300.0), PANEL);
+        assert_eq!(cam.zoom, ZOOM_MIN);
+    }
+
+    #[test]
+    fn pan_mueve_el_punto_bajo_el_cursor() {
+        let mut cam = Camara::new((0.0, 0.0), 2.0, 0.0);
+        let antes = cam.world_to_screen((0.0, 0.0), PANEL);
+        cam.pan(30.0, -10.0);
+        let despues = cam.world_to_screen((0.0, 0.0), PANEL);
+        aprox((despues.0 - antes.0, despues.1 - antes.1), (30.0, -10.0));
+    }
+
+    #[test]
+    fn fit_centra_y_encuadra() {
+        let marco = Rect::new(100.0, 100.0, 400.0, 200.0);
+        let cam = Camara::fit(marco, 0.0, PANEL);
+        // Centra el marco.
+        assert_eq!(cam.centro, (300.0, 200.0));
+        // Encaja por el eje más ajustado (800/400=2 vs 600/200=3 → 2) con margen.
+        assert!((cam.zoom - 2.0 * FIT_MARGEN).abs() < 1e-9);
+        // El centro del marco cae en el centro del panel.
+        aprox(cam.world_to_screen((300.0, 200.0), PANEL), (400.0, 300.0));
+    }
+
+    #[test]
+    fn interpolar_extremos_y_zoom_logaritmico() {
+        let a = Camara::new((0.0, 0.0), 1.0, 0.0);
+        let b = Camara::new((10.0, 20.0), 4.0, 1.0);
+        assert_eq!(Camara::interpolar(&a, &b, 0.0, Ease::Lineal), a);
+        assert_eq!(Camara::interpolar(&a, &b, 1.0, Ease::Lineal), b);
+        // En t=0.5 lineal el zoom es la media geométrica (2.0), no la aritmética (2.5).
+        let m = Camara::interpolar(&a, &b, 0.5, Ease::Lineal);
+        assert!((m.zoom - 2.0).abs() < 1e-9);
+        assert_eq!(m.centro, (5.0, 10.0));
+    }
+
+    #[test]
+    fn ease_suave_extremos_y_simetria() {
+        assert_eq!(Ease::SuaveInOut.aplicar(0.0), 0.0);
+        assert_eq!(Ease::SuaveInOut.aplicar(1.0), 1.0);
+        assert!((Ease::SuaveInOut.aplicar(0.5) - 0.5).abs() < 1e-9);
+        // Clamp fuera de rango.
+        assert_eq!(Ease::SuaveInOut.aplicar(-1.0), 0.0);
+        assert_eq!(Ease::SuaveInOut.aplicar(2.0), 1.0);
+    }
+}
diff --git a/00_unanchay/pluma/pluma-deck-core/src/lib.rs b/00_unanchay/pluma/pluma-deck-core/src/lib.rs
new file mode 100644
index 0000000..acb8c0c
--- /dev/null
+++ b/00_unanchay/pluma/pluma-deck-core/src/lib.rs
@@ -0,0 +1,195 @@
+//! Deck core — máquinas de estado agnósticas para presentar, en dos modos:
+//!
+//! - **Lineal** ([`DeckState`], este archivo): strip horizontal de páginas con
+//!   drag/snap. Dados los eventos crudos de pointer (coords + viewport width
+//!   + n_pages) decide cuándo arrancar drag horizontal, cuánto trasladar el
+//!   strip y a qué página snapear al soltar.
+//! - **Espacial** ([`recorrido`], tipo Prezi): lienzo 2D infinito con marcos en
+//!   coordenadas de mundo y una ruta de cámara ([`camara::Camara`]) que vuela
+//!   entre ellos con zoom/pan/giro. El modo lineal es su caso degenerado.
+//!
+//! Todo agnóstico: sin DOM, sin wasm-bindgen, sin render.
+
+pub mod camara;
+pub mod recorrido;
+/// Adaptador opcional pluma → Recorrido. Sólo con la feature `pluma`.
+#[cfg(feature = "pluma")]
+pub mod adaptador;
+
+pub use camara::{Camara, Ease, Rect, FIT_MARGEN, ZOOM_MAX, ZOOM_MIN};
+pub use recorrido::{
+    Autoplay, ContenidoMarco, Marco, MarcoId, Recorrido, RecorridoState, RejillaOpts,
+    DURACION_PASO_S, DWELL_S,
+};
+
+/// Umbral en pixels para confirmar gesto horizontal vs vertical.
+pub const DRAG_DECISION_PX: f64 = 8.0;
+/// Cuán más horizontal que vertical debe ser el delta para considerarse "swipe".
+pub const HORIZONTAL_BIAS: f64 = 1.3;
+
+#[derive(Clone, Debug, Default)]
+pub struct DeckState {
+    pub current_index: usize,
+    pointer_start: Option<(f64, f64, i32)>,
+    drag_active: bool,
+    drag_start_offset: f64,
+}
+
+#[derive(Clone, Debug, PartialEq)]
+pub enum DragOutcome {
+    /// Aún no hay decisión — esperar más movimiento.
+    Idle,
+    /// Empezar drag horizontal: el host debe capturar el pointer.
+    StartHorizontal { pointer_id: i32 },
+    /// Movimiento vertical predominante — host debe ceder al scroll nativo.
+    CancelVertical,
+    /// Drag en curso — host debe trasladar el strip a este offset.
+    DragOffset(f64),
+}
+
+#[derive(Clone, Debug, PartialEq)]
+pub struct SnapResult {
+    pub target_index: usize,
+    pub offset_px: f64,
+    pub changed: bool,
+}
+
+impl DeckState {
+    pub fn new() -> Self { Self::default() }
+
+    /// Marca el inicio de un gesto. `viewport_width` se usa para anclar el
+    /// drag_start_offset a la página visible actual.
+    pub fn pointer_down(&mut self, x: f64, y: f64, pointer_id: i32, viewport_width: f64) {
+        self.pointer_start = Some((x, y, pointer_id));
+        self.drag_active = false;
+        self.drag_start_offset = -(self.current_index as f64) * viewport_width;
+    }
+
+    /// Procesa un movimiento. Devuelve la acción que el host debe ejecutar.
+    pub fn pointer_move(&mut self, x: f64, y: f64) -> DragOutcome {
+        let Some((sx, sy, pid)) = self.pointer_start else {
+            return DragOutcome::Idle;
+        };
+        let dx = x - sx;
+        let dy = y - sy;
+        if !self.drag_active {
+            let abs_dx = dx.abs();
+            let abs_dy = dy.abs();
+            if abs_dx > DRAG_DECISION_PX && abs_dx > abs_dy * HORIZONTAL_BIAS {
+                self.drag_active = true;
+                return DragOutcome::StartHorizontal { pointer_id: pid };
+            } else if abs_dy > DRAG_DECISION_PX {
+                self.pointer_start = None;
+                return DragOutcome::CancelVertical;
+            } else {
+                return DragOutcome::Idle;
+            }
+        }
+        DragOutcome::DragOffset(self.drag_start_offset + dx)
+    }
+
+    /// Finaliza el gesto. Si había drag activo, calcula la página snap y
+    /// actualiza `current_index`. `current_offset` viene del estado real
+    /// del strip (el host lee CSS transform / variable).
+    pub fn pointer_end(
+        &mut self,
+        current_offset: f64,
+        viewport_width: f64,
+        n_pages: usize,
+    ) -> Option<SnapResult> {
+        let was_active = self.drag_active;
+        self.drag_active = false;
+        self.pointer_start = None;
+        if !was_active || viewport_width <= 0.0 || n_pages == 0 {
+            return None;
+        }
+        let raw = -current_offset / viewport_width;
+        let target = (raw.round().max(0.0) as usize).min(n_pages - 1);
+        let offset_px = -(target as f64) * viewport_width;
+        let changed = self.current_index != target;
+        self.current_index = target;
+        Some(SnapResult { target_index: target, offset_px, changed })
+    }
+
+    /// Salto programático (click en tabs externos). Devuelve el offset
+    /// resultante para que el host lo aplique al strip.
+    pub fn goto(&mut self, index: usize, viewport_width: f64) -> SnapResult {
+        let offset_px = -(index as f64) * viewport_width;
+        let changed = self.current_index != index;
+        self.current_index = index;
+        SnapResult { target_index: index, offset_px, changed }
+    }
+
+    /// Reposiciona tras un resize. Devuelve el offset que el host debe
+    /// aplicar sin animación.
+    pub fn reposition(&self, viewport_width: f64) -> f64 {
+        -(self.current_index as f64) * viewport_width
+    }
+}
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+
+    #[test]
+    fn vertical_drag_is_cancelled() {
+        let mut s = DeckState::new();
+        s.pointer_down(100.0, 100.0, 1, 800.0);
+        // Movimiento vertical mayor que el umbral.
+        let r = s.pointer_move(100.0, 120.0);
+        assert_eq!(r, DragOutcome::CancelVertical);
+    }
+
+    #[test]
+    fn horizontal_drag_starts_after_threshold() {
+        let mut s = DeckState::new();
+        s.pointer_down(100.0, 100.0, 7, 800.0);
+        // Justo por debajo del umbral → Idle.
+        assert_eq!(s.pointer_move(105.0, 100.0), DragOutcome::Idle);
+        // Sobre el umbral con bias horizontal → Start.
+        let r = s.pointer_move(120.0, 100.0);
+        assert_eq!(r, DragOutcome::StartHorizontal { pointer_id: 7 });
+    }
+
+    #[test]
+    fn snap_rounds_to_nearest_page() {
+        let mut s = DeckState::new();
+        s.current_index = 1;
+        s.pointer_down(0.0, 0.0, 1, 1000.0); // drag_start_offset = -1000
+        // Forzar drag activo
+        s.pointer_move(20.0, 0.0);
+        // Offset actual = -1000 + 20 = -980 → target round(980/1000) = 1, sin cambio
+        let r = s.pointer_end(-980.0, 1000.0, 3).unwrap();
+        assert_eq!(r.target_index, 1);
+        assert!(!r.changed);
+        // Mover lo suficiente para snapear a página 0
+        s.pointer_down(0.0, 0.0, 1, 1000.0);
+        s.pointer_move(600.0, 0.0);
+        let r = s.pointer_end(-400.0, 1000.0, 3).unwrap();
+        assert_eq!(r.target_index, 0);
+        assert!(r.changed);
+    }
+
+    #[test]
+    fn snap_clamps_to_bounds() {
+        let mut s = DeckState::new();
+        s.current_index = 2;
+        s.pointer_down(0.0, 0.0, 1, 500.0);
+        s.pointer_move(50.0, 0.0);
+        // Offset muy a la izquierda → debería clamp a n_pages-1
+        let r = s.pointer_end(-9999.0, 500.0, 3).unwrap();
+        assert_eq!(r.target_index, 2);
+    }
+
+    #[test]
+    fn goto_updates_index_and_offset() {
+        let mut s = DeckState::new();
+        let r = s.goto(2, 800.0);
+        assert_eq!(r.target_index, 2);
+        assert_eq!(r.offset_px, -1600.0);
+        assert!(r.changed);
+        // segundo goto al mismo índice → changed=false
+        let r = s.goto(2, 800.0);
+        assert!(!r.changed);
+    }
+}
diff --git a/00_unanchay/pluma/pluma-deck-core/src/recorrido.rs b/00_unanchay/pluma/pluma-deck-core/src/recorrido.rs
new file mode 100644
index 0000000..b21d6fa
--- /dev/null
+++ b/00_unanchay/pluma/pluma-deck-core/src/recorrido.rs
@@ -0,0 +1,850 @@
+//! Modo `Recorrido` — presentación espacial sobre lienzo infinito (tipo Prezi).
+//!
+//! Un `Recorrido` coloca `Marco`s en coordenadas de mundo y define una **ruta**
+//! ordenada (`pasos`) que la cámara recorre: avanzar un paso encuadra el marco
+//! destino animando zoom/pan/giro desde la cámara actual. Entre pasos el usuario
+//! puede volar libre (drag = pan, wheel = zoom-a-cursor).
+//!
+//! El strip lineal de [`crate::DeckState`] es el caso degenerado: marcos del
+//! mismo tamaño en fila, zoom fijo, sin giro. Aquí el lienzo es 2D.
+//!
+//! Como toda pieza `*-core` del repo, esto es una máquina de estados pura: el
+//! host traduce pointer/wheel/teclado → llamadas, y tick'ea la animación con
+//! [`RecorridoState::avanzar`]; no hay render ni reloj propio.
+
+use crate::camara::{Camara, Ease, Rect};
+
+/// Duración por defecto del vuelo entre dos pasos, en segundos.
+pub const DURACION_PASO_S: f64 = 0.8;
+
+/// Tamaño mínimo (mundo) al redimensionar un marco — evita marcos degenerados.
+pub const MIN_MARCO: f64 = 20.0;
+
+pub type MarcoId = u64;
+
+/// Qué pinta el host dentro de un marco. El core es agnóstico: guarda una
+/// referencia o etiqueta y deja la resolución (cuerpo, subgrafo de átomos,
+/// imagen, página de deck) al frontend vía `pluma-render-plan` u otro.
+#[derive(Clone, Debug, PartialEq, Eq, Default)]
+#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
+pub enum ContenidoMarco {
+    #[default]
+    Vacio,
+    /// Texto plano de una línea — títulos de sección, hitos del recorrido.
+    Etiqueta(String),
+    /// Contenido de "slide": título opcional + párrafos. Agnóstico (sólo
+    /// strings); un adaptador convierte un cuerpo/subgrafo de pluma a esto.
+    Texto { titulo: Option<String>, parrafos: Vec<String> },
+    /// Imagen rasterizada: bytes **codificados** (PNG/JPEG/WebP) + dimensiones
+    /// en px. El core es agnóstico — guarda los bytes sin decodificar y deja la
+    /// rasterización al frontend; `ancho`/`alto` permiten encuadrar/aspectar el
+    /// marco sin tener que decodificar.
+    Imagen { bytes: Vec<u8>, ancho: u32, alto: u32 },
+    /// Referencia opaca que el host resuelve (hash BLAKE3, id de cuerpo, ruta…).
+    Ref(String),
+}
+
+/// Un marco colocado en el lienzo: su rectángulo en coordenadas de mundo, su
+/// giro propio y su contenido.
+#[derive(Clone, Debug, PartialEq)]
+#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
+pub struct Marco {
+    pub id: MarcoId,
+    pub rect: Rect,
+    pub rot_rad: f64,
+    pub contenido: ContenidoMarco,
+}
+
+impl Marco {
+    pub fn new(id: MarcoId, rect: Rect, contenido: ContenidoMarco) -> Self {
+        Self { id, rect, rot_rad: 0.0, contenido }
+    }
+
+    pub fn con_giro(mut self, rot_rad: f64) -> Self {
+        self.rot_rad = rot_rad;
+        self
+    }
+
+    /// Cámara que encuadra este marco en `panel`.
+    pub fn fit(&self, panel: Rect) -> Camara {
+        Camara::fit(self.rect, self.rot_rad, panel)
+    }
+
+    /// Bounding box axis-aligned (en coordenadas de mundo) que contiene al
+    /// marco **ya girado** alrededor de su centro. Para un marco recto coincide
+    /// con su `rect`; para uno girado lo envuelve sin recortar esquinas. Base de
+    /// [`Recorrido::bbox`] → vista general.
+    pub fn aabb(&self) -> Rect {
+        let (cx, cy) = self.rect.centro();
+        let (hw, hh) = (self.rect.w * 0.5, self.rect.h * 0.5);
+        let (s, c) = self.rot_rad.sin_cos();
+        // Las cuatro esquinas relativas al centro, giradas; el AABB lo fija la
+        // mayor extensión en cada eje (simétrico, así que basta el máximo).
+        let ex = (hw * c).abs() + (hh * s).abs();
+        let ey = (hw * s).abs() + (hh * c).abs();
+        Rect::new(cx - ex, cy - ey, ex * 2.0, ey * 2.0)
+    }
+
+    /// `true` si el punto de mundo `p` cae dentro del marco, considerando su
+    /// giro propio (deshace la rotación con que se dibuja antes del aabb test).
+    pub fn contiene(&self, p: (f64, f64)) -> bool {
+        let (cx, cy) = self.rect.centro();
+        // Inversa de la rotación de dibujo: local = centro + R(-rot)·(p-centro).
+        let (s, c) = (-self.rot_rad).sin_cos();
+        let dx = p.0 - cx;
+        let dy = p.1 - cy;
+        let lx = dx * c - dy * s + cx;
+        let ly = dx * s + dy * c + cy;
+        lx >= self.rect.x
+            && lx <= self.rect.x + self.rect.w
+            && ly >= self.rect.y
+            && ly <= self.rect.y + self.rect.h
+    }
+}
+
+/// Lienzo + ruta narrativa. `pasos` es una secuencia de `MarcoId` (puede
+/// repetir un marco, saltarse otros, o recorrerlos en cualquier orden).
+#[derive(Clone, Debug, Default)]
+#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
+pub struct Recorrido {
+    pub marcos: Vec<Marco>,
+    pub pasos: Vec<MarcoId>,
+}
+
+impl Recorrido {
+    pub fn new() -> Self {
+        Self::default()
+    }
+
+    pub fn agregar_marco(&mut self, marco: Marco) -> MarcoId {
+        let id = marco.id;
+        self.marcos.push(marco);
+        id
+    }
+
+    pub fn marco(&self, id: MarcoId) -> Option<&Marco> {
+        self.marcos.iter().find(|m| m.id == id)
+    }
+
+    /// Marco bajo un punto de mundo, si hay — el de más arriba (último
+    /// dibujado) gana cuando se solapan. Para hit-test de autoría.
+    pub fn marco_en_punto(&self, p: (f64, f64)) -> Option<MarcoId> {
+        self.marcos.iter().rev().find(|m| m.contiene(p)).map(|m| m.id)
+    }
+
+    /// Traslada el marco `id` por un delta de mundo `(dx, dy)`. No-op si el id
+    /// no existe. Para arrastrar marcos en modo edición.
+    pub fn mover_marco(&mut self, id: MarcoId, dx: f64, dy: f64) {
+        if let Some(m) = self.marcos.iter_mut().find(|m| m.id == id) {
+            m.rect.x += dx;
+            m.rect.y += dy;
+        }
+    }
+
+    /// Elimina el marco `id` y purga **todos** los pasos que lo referencian
+    /// (manteniendo el resto del guion). Devuelve `true` si el marco existía.
+    /// Para autoría — deja la ruta consistente sin ids colgantes.
+    pub fn eliminar_marco(&mut self, id: MarcoId) -> bool {
+        let antes = self.marcos.len();
+        self.marcos.retain(|m| m.id != id);
+        let elimino = self.marcos.len() != antes;
+        if elimino {
+            self.pasos.retain(|p| *p != id);
+        }
+        elimino
+    }
+
+    /// Redimensiona el marco `id` a `w`×`h` (clamp a [`MIN_MARCO`]), conservando
+    /// su esquina superior-izquierda. No-op si el id no existe.
+    pub fn redimensionar_marco(&mut self, id: MarcoId, w: f64, h: f64) {
+        if let Some(m) = self.marcos.iter_mut().find(|m| m.id == id) {
+            m.rect.w = w.max(MIN_MARCO);
+            m.rect.h = h.max(MIN_MARCO);
+        }
+    }
+
+    /// Suma `delta_rad` al giro propio del marco `id`. No-op si no existe.
+    pub fn rotar_marco(&mut self, id: MarcoId, delta_rad: f64) {
+        if let Some(m) = self.marcos.iter_mut().find(|m| m.id == id) {
+            m.rot_rad += delta_rad;
+        }
+    }
+
+    /// Reordena el guion: mueve el paso en el índice `desde` a la posición
+    /// `hasta` (clamp al final). No-op si `desde` está fuera de rango.
+    pub fn mover_paso(&mut self, desde: usize, hasta: usize) {
+        if desde >= self.pasos.len() {
+            return;
+        }
+        let id = self.pasos.remove(desde);
+        self.pasos.insert(hasta.min(self.pasos.len()), id);
+    }
+
+    /// Marco al que apunta el paso `idx` (resolviendo el id contra `marcos`).
+    pub fn marco_en_paso(&self, idx: usize) -> Option<&Marco> {
+        self.pasos.get(idx).and_then(|id| self.marco(*id))
+    }
+
+    pub fn n_pasos(&self) -> usize {
+        self.pasos.len()
+    }
+
+    /// Bounding box de **todos** los marcos (cada uno por su [`Marco::aabb`], así
+    /// los girados entran enteros). `None` si no hay marcos. Es el encuadre de la
+    /// *vista general* — el zoom-out narrativo que muestra el mapa completo.
+    pub fn bbox(&self) -> Option<Rect> {
+        let mut it = self.marcos.iter().map(Marco::aabb);
+        let primero = it.next()?;
+        let (mut min_x, mut min_y) = (primero.x, primero.y);
+        let (mut max_x, mut max_y) = (primero.x + primero.w, primero.y + primero.h);
+        for r in it {
+            min_x = min_x.min(r.x);
+            min_y = min_y.min(r.y);
+            max_x = max_x.max(r.x + r.w);
+            max_y = max_y.max(r.y + r.h);
+        }
+        Some(Rect::new(min_x, min_y, max_x - min_x, max_y - min_y))
+    }
+
+    /// Auto-layout: coloca una secuencia de contenidos en una rejilla y arma
+    /// la ruta en orden de lectura (fila por fila). Es el "dame N piezas →
+    /// dame un recorrido listo" — el frontend sólo pinta y vuela. Los ids se
+    /// asignan `1..=n` en orden.
+    pub fn en_rejilla(contenidos: Vec<ContenidoMarco>, opts: RejillaOpts) -> Recorrido {
+        let cols = opts.cols.max(1);
+        let mut rec = Recorrido::new();
+        for (i, c) in contenidos.into_iter().enumerate() {
+            let col = (i % cols) as f64;
+            let row = (i / cols) as f64;
+            let x = col * (opts.marco_w + opts.gap_x);
+            let y = row * (opts.marco_h + opts.gap_y);
+            let id = (i + 1) as MarcoId;
+            rec.agregar_marco(Marco::new(id, Rect::new(x, y, opts.marco_w, opts.marco_h), c));
+            rec.pasos.push(id);
+        }
+        rec
+    }
+}
+
+#[cfg(feature = "serde")]
+impl Recorrido {
+    /// Serializa el recorrido (marcos + ruta) a su forma binaria `postcard` —
+    /// el codec nativo del workspace (mismo que `format`/`akasha`/`pluma-cuerpo`).
+    /// Persiste sólo el modelo de datos; el estado de interacción
+    /// ([`RecorridoState`]) es efímero y no se guarda.
+    pub fn serializar(&self) -> Result<Vec<u8>, &'static str> {
+        postcard::to_allocvec(self).map_err(|_| "recorrido :: serializacion fallida")
+    }
+
+    /// Reconstruye un recorrido desde su forma binaria `postcard`.
+    pub fn deserializar(bytes: &[u8]) -> Result<Recorrido, &'static str> {
+        postcard::from_bytes::<Recorrido>(bytes).map_err(|_| "recorrido :: deserializacion fallida")
+    }
+}
+
+/// Parámetros del auto-layout en rejilla de [`Recorrido::en_rejilla`].
+#[derive(Clone, Copy, Debug)]
+pub struct RejillaOpts {
+    pub cols: usize,
+    pub marco_w: f64,
+    pub marco_h: f64,
+    pub gap_x: f64,
+    pub gap_y: f64,
+}
+
+impl Default for RejillaOpts {
+    fn default() -> Self {
+        Self { cols: 3, marco_w: 640.0, marco_h: 400.0, gap_x: 220.0, gap_y: 180.0 }
+    }
+}
+
+/// Animación de cámara en curso entre dos encuadres.
+#[derive(Clone, Copy, Debug)]
+struct Vuelo {
+    desde: Camara,
+    hasta: Camara,
+    /// Tiempo transcurrido / duración total, en segundos.
+    t: f64,
+    dur: f64,
+    ease: Ease,
+}
+
+/// Máquina de interacción del recorrido: cámara viva + paso actual + vuelo en
+/// curso + estado de arrastre para el paneo libre.
+#[derive(Clone, Debug)]
+pub struct RecorridoState {
+    pub camara: Camara,
+    /// Índice del paso actual dentro de `Recorrido::pasos`.
+    pub paso: usize,
+    vuelo: Option<Vuelo>,
+    arrastre: Option<(f64, f64)>,
+}
+
+impl Default for RecorridoState {
+    fn default() -> Self {
+        Self { camara: Camara::default(), paso: 0, vuelo: None, arrastre: None }
+    }
+}
+
+impl RecorridoState {
+    pub fn new() -> Self {
+        Self::default()
+    }
+
+    /// `true` si hay un vuelo de cámara animándose.
+    pub fn animando(&self) -> bool {
+        self.vuelo.is_some()
+    }
+
+    // ---- Roam libre -------------------------------------------------------
+
+    /// Inicio de arrastre para panear. Cancela cualquier vuelo en curso (el
+    /// usuario toma el control manual).
+    pub fn pointer_down(&mut self, x: f64, y: f64) {
+        self.vuelo = None;
+        self.arrastre = Some((x, y));
+    }
+
+    /// Movimiento de puntero: si hay arrastre activo, panea la cámara por el
+    /// delta y devuelve `true` (el host debe repintar).
+    pub fn pointer_move(&mut self, x: f64, y: f64) -> bool {
+        let Some((px, py)) = self.arrastre else { return false };
+        self.camara.pan(x - px, y - py);
+        self.arrastre = Some((x, y));
+        true
+    }
+
+    pub fn pointer_up(&mut self) {
+        self.arrastre = None;
+    }
+
+    /// Paneo por delta de pantalla — para hosts que ya entregan el delta del
+    /// arrastre (p. ej. `llimphi-ui::draggable`, que da `(dx, dy)` por evento).
+    /// Cancela el vuelo (control manual). Alternativa a `pointer_down/move/up`.
+    pub fn arrastrar_delta(&mut self, dx: f64, dy: f64) {
+        self.vuelo = None;
+        self.camara.pan(dx, dy);
+    }
+
+    /// Wheel: zoom-a-cursor inmediato. Cancela el vuelo (control manual).
+    pub fn wheel(&mut self, mult: f64, cursor: (f64, f64), panel: Rect) {
+        self.vuelo = None;
+        self.camara.zoom_a_cursor(mult, cursor, panel);
+    }
+
+    // ---- Reproducción guiada ---------------------------------------------
+
+    /// Arranca un vuelo desde la cámara actual hasta encuadrar el paso `idx`.
+    /// No hace nada si el índice o su marco no existen. Fija `paso = idx`.
+    pub fn ir_a_paso(&mut self, rec: &Recorrido, idx: usize, panel: Rect) {
+        let Some(marco) = rec.marco_en_paso(idx) else { return };
+        self.paso = idx;
+        self.iniciar_vuelo(marco.fit(panel), DURACION_PASO_S);
+    }
+
+    /// Avanza al paso siguiente (clamp al final). Devuelve `true` si arrancó
+    /// un vuelo nuevo.
+    pub fn siguiente(&mut self, rec: &Recorrido, panel: Rect) -> bool {
+        if rec.n_pasos() == 0 || self.paso + 1 >= rec.n_pasos() {
+            return false;
+        }
+        self.ir_a_paso(rec, self.paso + 1, panel);
+        true
+    }
+
+    /// Retrocede al paso anterior (clamp en 0). Devuelve `true` si arrancó un
+    /// vuelo nuevo.
+    pub fn anterior(&mut self, rec: &Recorrido, panel: Rect) -> bool {
+        if self.paso == 0 {
+            return false;
+        }
+        self.ir_a_paso(rec, self.paso - 1, panel);
+        true
+    }
+
+    /// Vuela a la **vista general**: aleja la cámara hasta encuadrar todos los
+    /// marcos (recta, sin giro), el gesto-firma de Prezi "alejarse para ver el
+    /// mapa". No toca `paso` — `siguiente`/`anterior` siguen desde donde iban.
+    /// Devuelve `true` si arrancó un vuelo (`false` si el lienzo está vacío).
+    pub fn vista_general(&mut self, rec: &Recorrido, panel: Rect) -> bool {
+        let Some(bbox) = rec.bbox() else { return false };
+        self.iniciar_vuelo(Camara::fit(bbox, 0.0, panel), DURACION_PASO_S);
+        true
+    }
+
+    /// Salto instantáneo (sin vuelo) al encuadre del paso `idx` — útil para
+    /// reposicionar tras un resize, o para "jump to" sin animación.
+    pub fn saltar_a_paso(&mut self, rec: &Recorrido, idx: usize, panel: Rect) {
+        let Some(marco) = rec.marco_en_paso(idx) else { return };
+        self.paso = idx;
+        self.vuelo = None;
+        self.camara = marco.fit(panel);
+    }
+
+    fn iniciar_vuelo(&mut self, hasta: Camara, dur: f64) {
+        if dur <= 0.0 {
+            self.camara = hasta;
+            self.vuelo = None;
+            return;
+        }
+        self.vuelo = Some(Vuelo { desde: self.camara, hasta, t: 0.0, dur, ease: Ease::default() });
+    }
+
+    /// Avanza la animación `dt` segundos. Devuelve `true` mientras siga
+    /// animando (el host repite el tick); `false` cuando ya no hay vuelo.
+    /// El host la llama desde un timer (p. ej. `Handle::spawn_periodic`).
+    pub fn avanzar(&mut self, dt: f64) -> bool {
+        let Some(mut v) = self.vuelo else { return false };
+        v.t += dt;
+        if v.t >= v.dur {
+            self.camara = v.hasta;
+            self.vuelo = None;
+            return false;
+        }
+        self.camara = Camara::interpolar(&v.desde, &v.hasta, v.t / v.dur, v.ease);
+        self.vuelo = Some(v);
+        true
+    }
+}
+
+/// Reproducción automática ("modo presentador"): tras aterrizar en un paso
+/// espera `dwell_s` segundos y avanza solo al siguiente. Al llegar al final,
+/// vuelve al inicio si `bucle`, o se detiene. Máquina de tiempo **pura** — el
+/// host la tickea junto a [`RecorridoState::avanzar`]; no tiene reloj propio.
+#[derive(Clone, Copy, Debug)]
+pub struct Autoplay {
+    /// Segundos de permanencia en cada paso una vez que el vuelo aterrizó.
+    pub dwell_s: f64,
+    /// Si al final vuelve al primer paso (`true`) o se detiene (`false`).
+    pub bucle: bool,
+    activo: bool,
+    espera: f64,
+}
+
+/// Dwell por defecto del modo presentador, en segundos.
+pub const DWELL_S: f64 = 2.5;
+
+impl Default for Autoplay {
+    fn default() -> Self {
+        Self { dwell_s: DWELL_S, bucle: true, activo: false, espera: 0.0 }
+    }
+}
+
+impl Autoplay {
+    pub fn new(dwell_s: f64, bucle: bool) -> Self {
+        Self { dwell_s, bucle, activo: false, espera: 0.0 }
+    }
+
+    pub fn activo(&self) -> bool {
+        self.activo
+    }
+
+    /// Arranca la reproducción (resetea el contador de permanencia).
+    pub fn play(&mut self) {
+        self.activo = true;
+        self.espera = 0.0;
+    }
+
+    pub fn pausa(&mut self) {
+        self.activo = false;
+    }
+
+    /// Alterna play/pausa. Devuelve el nuevo estado.
+    pub fn toggle(&mut self) -> bool {
+        if self.activo {
+            self.pausa();
+        } else {
+            self.play();
+        }
+        self.activo
+    }
+
+    /// Tick del modo presentador. No hace nada si está pausado. Mientras el
+    /// vuelo se anima, espera (no acumula dwell). Una vez quieto, acumula `dt`;
+    /// al superar `dwell_s` avanza un paso (o vuelve al inicio si `bucle`; o se
+    /// detiene). Devuelve `true` si disparó un avance este tick.
+    pub fn tick(&mut self, dt: f64, state: &mut RecorridoState, rec: &Recorrido, panel: Rect) -> bool {
+        if !self.activo || rec.n_pasos() == 0 {
+            return false;
+        }
+        if state.animando() {
+            self.espera = 0.0;
+            return false;
+        }
+        self.espera += dt;
+        if self.espera < self.dwell_s {
+            return false;
+        }
+        self.espera = 0.0;
+        if state.paso + 1 < rec.n_pasos() {
+            state.siguiente(rec, panel);
+        } else if self.bucle {
+            state.ir_a_paso(rec, 0, panel);
+        } else {
+            self.activo = false;
+            return false;
+        }
+        true
+    }
+}
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+
+    const PANEL: Rect = Rect { x: 0.0, y: 0.0, w: 800.0, h: 600.0 };
+
+    fn recorrido_demo() -> Recorrido {
+        let mut r = Recorrido::new();
+        r.agregar_marco(Marco::new(1, Rect::new(0.0, 0.0, 400.0, 300.0), ContenidoMarco::Etiqueta("a".into())));
+        r.agregar_marco(Marco::new(2, Rect::new(2000.0, 0.0, 200.0, 150.0), ContenidoMarco::Etiqueta("b".into())));
+        r.agregar_marco(Marco::new(3, Rect::new(1000.0, 1000.0, 800.0, 600.0), ContenidoMarco::Etiqueta("c".into())));
+        r.pasos = vec![1, 2, 3];
+        r
+    }
+
+    #[test]
+    fn en_rejilla_coloca_y_rutea_en_orden_de_lectura() {
+        let contenidos = vec![
+            ContenidoMarco::Etiqueta("a".into()),
+            ContenidoMarco::Etiqueta("b".into()),
+            ContenidoMarco::Etiqueta("c".into()),
+            ContenidoMarco::Etiqueta("d".into()),
+        ];
+        let opts = RejillaOpts { cols: 2, marco_w: 100.0, marco_h: 50.0, gap_x: 20.0, gap_y: 10.0 };
+        let rec = Recorrido::en_rejilla(contenidos, opts);
+        assert_eq!(rec.marcos.len(), 4);
+        // Ruta secuencial 1..=4.
+        assert_eq!(rec.pasos, vec![1, 2, 3, 4]);
+        // Índice 0 en (0,0); índice 1 en la columna siguiente; índice 2 baja de fila.
+        assert_eq!(rec.marco(1).unwrap().rect, Rect::new(0.0, 0.0, 100.0, 50.0));
+        assert_eq!(rec.marco(2).unwrap().rect, Rect::new(120.0, 0.0, 100.0, 50.0));
+        assert_eq!(rec.marco(3).unwrap().rect, Rect::new(0.0, 60.0, 100.0, 50.0));
+    }
+
+    #[test]
+    fn marco_en_punto_devuelve_el_de_arriba() {
+        let r = recorrido_demo();
+        // Punto dentro del marco 1 (0,0,400,300).
+        assert_eq!(r.marco_en_punto((10.0, 10.0)), Some(1));
+        // Punto dentro del marco 2 (2000,0,200,150).
+        assert_eq!(r.marco_en_punto((2050.0, 50.0)), Some(2));
+        // Punto en el vacío.
+        assert_eq!(r.marco_en_punto((-500.0, -500.0)), None);
+    }
+
+    #[test]
+    fn marco_en_punto_respeta_giro() {
+        let mut r = Recorrido::new();
+        // Marco cuadrado centrado en (0,0), girado 45°.
+        r.agregar_marco(
+            Marco::new(7, Rect::new(-50.0, -50.0, 100.0, 100.0), ContenidoMarco::Vacio)
+                .con_giro(std::f64::consts::FRAC_PI_4),
+        );
+        // El centro siempre está dentro.
+        assert_eq!(r.marco_en_punto((0.0, 0.0)), Some(7));
+        // Una esquina del aabb sin girar (49,49) queda FUERA del cuadrado girado
+        // (su semidiagonal es ~70.7, pero el lado rotado pasa antes por los ejes).
+        assert_eq!(r.marco_en_punto((49.0, 49.0)), None);
+        // Sobre el eje X a distancia 60 < semidiagonal: dentro del rombo.
+        assert_eq!(r.marco_en_punto((60.0, 0.0)), Some(7));
+    }
+
+    #[test]
+    fn marco_con_imagen_es_agnostico_al_hit_test() {
+        // El core no decodifica la imagen: guarda bytes + dims y el hit-test
+        // sigue dependiendo sólo de la geometría del marco.
+        let mut r = Recorrido::new();
+        let img = ContenidoMarco::Imagen { bytes: vec![0xDE, 0xAD, 0xBE, 0xEF], ancho: 320, alto: 240 };
+        r.agregar_marco(Marco::new(5, Rect::new(0.0, 0.0, 400.0, 300.0), img.clone()));
+        assert_eq!(r.marco_en_punto((100.0, 100.0)), Some(5));
+        assert_eq!(r.marco_en_punto((9999.0, 0.0)), None);
+        // La variante conserva bytes y dimensiones tal cual.
+        assert_eq!(r.marco(5).unwrap().contenido, img);
+    }
+
+    #[test]
+    fn mover_marco_traslada_el_rect() {
+        let mut r = recorrido_demo();
+        r.mover_marco(1, 100.0, -40.0);
+        assert_eq!(r.marco(1).unwrap().rect, Rect::new(100.0, -40.0, 400.0, 300.0));
+        // Id inexistente: no-op.
+        r.mover_marco(999, 1.0, 1.0);
+    }
+
+    #[test]
+    fn eliminar_marco_purga_la_ruta() {
+        let mut r = recorrido_demo(); // marcos 1,2,3; pasos [1,2,3]
+        r.pasos = vec![1, 2, 3, 2]; // el 2 aparece dos veces en el guion
+        assert!(r.eliminar_marco(2));
+        assert!(r.marco(2).is_none());
+        assert_eq!(r.pasos, vec![1, 3], "se purgan TODAS las apariciones del 2");
+        // Id inexistente: no-op, devuelve false.
+        assert!(!r.eliminar_marco(999));
+        assert_eq!(r.marcos.len(), 2);
+    }
+
+    #[test]
+    fn redimensionar_marco_clampa_al_minimo() {
+        let mut r = recorrido_demo();
+        r.redimensionar_marco(1, 500.0, 5.0); // alto por debajo del mínimo
+        let m = r.marco(1).unwrap();
+        assert_eq!(m.rect.w, 500.0);
+        assert_eq!(m.rect.h, MIN_MARCO);
+        // Conserva la esquina sup-izq (marco 1 estaba en 0,0).
+        assert_eq!((m.rect.x, m.rect.y), (0.0, 0.0));
+    }
+
+    #[test]
+    fn rotar_marco_acumula() {
+        let mut r = recorrido_demo();
+        r.rotar_marco(1, 0.3);
+        r.rotar_marco(1, 0.2);
+        assert!((r.marco(1).unwrap().rot_rad - 0.5).abs() < 1e-12);
+        r.rotar_marco(404, 1.0); // inexistente: no-op
+    }
+
+    #[test]
+    fn mover_paso_reordena_el_guion() {
+        let mut r = recorrido_demo(); // pasos [1,2,3]
+        r.mover_paso(0, 2); // lleva el primero al final
+        assert_eq!(r.pasos, vec![2, 3, 1]);
+        r.mover_paso(2, 0); // y de vuelta al inicio
+        assert_eq!(r.pasos, vec![1, 2, 3]);
+        // hasta fuera de rango → clamp al final; desde fuera de rango → no-op.
+        r.mover_paso(0, 99);
+        assert_eq!(r.pasos, vec![2, 3, 1]);
+        r.mover_paso(99, 0);
+        assert_eq!(r.pasos, vec![2, 3, 1]);
+    }
+
+    #[test]
+    fn marco_en_paso_resuelve_id() {
+        let r = recorrido_demo();
+        assert_eq!(r.marco_en_paso(1).unwrap().id, 2);
+        assert!(r.marco_en_paso(9).is_none());
+    }
+
+    #[test]
+    fn pan_libre_mueve_y_cancela_vuelo() {
+        let r = recorrido_demo();
+        let mut s = RecorridoState::new();
+        s.ir_a_paso(&r, 1, PANEL);
+        assert!(s.animando());
+        s.pointer_down(100.0, 100.0);
+        assert!(!s.animando(), "el drag cancela el vuelo");
+        assert!(s.pointer_move(130.0, 100.0));
+        s.pointer_up();
+        assert!(!s.pointer_move(200.0, 200.0), "sin arrastre no panea");
+    }
+
+    #[test]
+    fn arrastrar_delta_panea_y_cancela_vuelo() {
+        let r = recorrido_demo();
+        let mut s = RecorridoState::new();
+        s.ir_a_paso(&r, 1, PANEL);
+        s.camara.zoom = 2.0;
+        let antes = s.camara.world_to_screen((0.0, 0.0), PANEL);
+        s.arrastrar_delta(40.0, -20.0);
+        assert!(!s.animando());
+        let despues = s.camara.world_to_screen((0.0, 0.0), PANEL);
+        assert!((despues.0 - antes.0 - 40.0).abs() < 1e-9);
+        assert!((despues.1 - antes.1 + 20.0).abs() < 1e-9);
+    }
+
+    #[test]
+    fn wheel_hace_zoom_y_cancela_vuelo() {
+        let r = recorrido_demo();
+        let mut s = RecorridoState::new();
+        s.ir_a_paso(&r, 1, PANEL);
+        let z = s.camara.zoom;
+        s.wheel(1.1, (400.0, 300.0), PANEL);
+        assert!(!s.animando());
+        assert!((s.camara.zoom - z * 1.1).abs() < 1e-9);
+    }
+
+    #[test]
+    fn siguiente_y_anterior_respetan_bordes() {
+        let r = recorrido_demo();
+        let mut s = RecorridoState::new();
+        assert!(!s.anterior(&r, PANEL), "ya en el primero");
+        assert!(s.siguiente(&r, PANEL));
+        assert_eq!(s.paso, 1);
+        assert!(s.siguiente(&r, PANEL));
+        assert_eq!(s.paso, 2);
+        assert!(!s.siguiente(&r, PANEL), "ya en el último");
+        assert!(s.anterior(&r, PANEL));
+        assert_eq!(s.paso, 1);
+    }
+
+    #[test]
+    fn avanzar_completa_el_vuelo_y_aterriza_exacto() {
+        let r = recorrido_demo();
+        let mut s = RecorridoState::new();
+        s.ir_a_paso(&r, 2, PANEL);
+        let objetivo = r.marco_en_paso(2).unwrap().fit(PANEL);
+        // Tickea en pasos hasta que el vuelo termina.
+        let mut iter = 0;
+        while s.avanzar(0.1) {
+            iter += 1;
+            assert!(iter < 1000, "el vuelo no converge");
+        }
+        // Al terminar aterriza EXACTAMENTE en el encuadre objetivo.
+        assert_eq!(s.camara, objetivo);
+        assert!(!s.animando());
+        // Un avanzar extra no hace nada.
+        assert!(!s.avanzar(0.1));
+    }
+
+    #[test]
+    fn aabb_recto_coincide_con_rect_y_girado_lo_envuelve() {
+        // Marco recto: el aabb es su propio rect.
+        let m = Marco::new(1, Rect::new(10.0, 20.0, 100.0, 60.0), ContenidoMarco::Vacio);
+        assert_eq!(m.aabb(), Rect::new(10.0, 20.0, 100.0, 60.0));
+        // Cuadrado 100×100 centrado en (0,0) girado 45°: su aabb es el cuadrado
+        // que lo circunscribe, lado = diagonal = 100·√2 ≈ 141.42.
+        let g = Marco::new(2, Rect::new(-50.0, -50.0, 100.0, 100.0), ContenidoMarco::Vacio)
+            .con_giro(std::f64::consts::FRAC_PI_4);
+        let bb = g.aabb();
+        let lado = 100.0 * std::f64::consts::SQRT_2;
+        assert!((bb.w - lado).abs() < 1e-6 && (bb.h - lado).abs() < 1e-6, "{bb:?}");
+        assert!((bb.centro().0).abs() < 1e-9 && (bb.centro().1).abs() < 1e-9);
+    }
+
+    #[test]
+    fn bbox_une_todos_los_marcos() {
+        let r = recorrido_demo();
+        // Marcos: (0,0,400,300), (2000,0,200,150), (1000,1000,800,600).
+        let bb = r.bbox().unwrap();
+        assert_eq!(bb, Rect::new(0.0, 0.0, 2200.0, 1600.0));
+        assert!(Recorrido::new().bbox().is_none(), "lienzo vacío no tiene bbox");
+    }
+
+    #[test]
+    fn vista_general_vuela_a_encuadrar_todo_sin_tocar_el_paso() {
+        let r = recorrido_demo();
+        let mut s = RecorridoState::new();
+        s.ir_a_paso(&r, 2, PANEL);
+        while s.avanzar(0.1) {}
+        assert_eq!(s.paso, 2);
+        assert!(s.vista_general(&r, PANEL));
+        assert!(s.animando());
+        // No cambió el paso narrativo.
+        assert_eq!(s.paso, 2);
+        while s.avanzar(0.1) {}
+        // Aterriza en el fit del bbox completo, recto.
+        let objetivo = Camara::fit(r.bbox().unwrap(), 0.0, PANEL);
+        assert_eq!(s.camara, objetivo);
+        // Lienzo vacío: no-op.
+        assert!(!RecorridoState::new().vista_general(&Recorrido::new(), PANEL));
+    }
+
+    /// Tickea estado + autoplay hasta que el autoplay dispare un avance (o se
+    /// agote el presupuesto de iteraciones). Simula el bucle del host.
+    fn correr_hasta_avance(ap: &mut Autoplay, s: &mut RecorridoState, r: &Recorrido) -> bool {
+        for _ in 0..100_000 {
+            s.avanzar(1.0 / 60.0);
+            if ap.tick(1.0 / 60.0, s, r, PANEL) {
+                return true;
+            }
+        }
+        false
+    }
+
+    #[test]
+    fn autoplay_pausado_no_hace_nada() {
+        let r = recorrido_demo();
+        let mut s = RecorridoState::new();
+        let mut ap = Autoplay::new(0.5, false);
+        assert!(!ap.activo());
+        // Sin play, mil ticks no mueven el paso.
+        for _ in 0..1000 {
+            assert!(!ap.tick(1.0 / 60.0, &mut s, &r, PANEL));
+        }
+        assert_eq!(s.paso, 0);
+    }
+
+    #[test]
+    fn autoplay_avanza_tras_el_dwell_esperando_a_que_aterrice_el_vuelo() {
+        let r = recorrido_demo();
+        let mut s = RecorridoState::new();
+        let mut ap = Autoplay::new(0.5, false);
+        ap.play();
+        assert!(ap.toggle() == false, "toggle desde activo pausa");
+        ap.play();
+        // Mientras el contador de dwell no llega, no avanza; el avance ocurre
+        // recién tras ~0.5s quietos.
+        assert!(correr_hasta_avance(&mut ap, &mut s, &r));
+        assert_eq!(s.paso, 1);
+        // Y respeta que el vuelo aterrice antes de contar el siguiente dwell.
+        assert!(correr_hasta_avance(&mut ap, &mut s, &r));
+        assert_eq!(s.paso, 2);
+    }
+
+    #[test]
+    fn autoplay_al_final_sin_bucle_se_detiene_y_con_bucle_reinicia() {
+        let r = recorrido_demo(); // 3 pasos
+        // Sin bucle: tras el último, se desactiva solo.
+        let mut s = RecorridoState::new();
+        let mut ap = Autoplay::new(0.2, false);
+        ap.play();
+        correr_hasta_avance(&mut ap, &mut s, &r); // → paso 1
+        correr_hasta_avance(&mut ap, &mut s, &r); // → paso 2 (último)
+        assert_eq!(s.paso, 2);
+        // En el último, el dwell vence pero no avanza: se apaga.
+        let arranco = correr_hasta_avance(&mut ap, &mut s, &r);
+        assert!(!arranco && !ap.activo(), "sin bucle se detiene en el final");
+        // Con bucle: del último vuelve al inicio.
+        let mut s = RecorridoState::new();
+        s.saltar_a_paso(&r, 2, PANEL);
+        let mut ap = Autoplay::new(0.2, true);
+        ap.play();
+        assert!(correr_hasta_avance(&mut ap, &mut s, &r));
+        assert_eq!(s.paso, 0, "con bucle reinicia");
+    }
+
+    #[cfg(feature = "serde")]
+    #[test]
+    fn roundtrip_postcard_preserva_marcos_ruta_y_contenido() {
+        let mut r = recorrido_demo();
+        // Un marco con giro, imagen (bytes crudos) y texto, para cubrir variantes.
+        r.agregar_marco(
+            Marco::new(4, Rect::new(10.0, 20.0, 300.0, 200.0), ContenidoMarco::Imagen {
+                bytes: vec![1, 2, 3, 4, 5],
+                ancho: 64,
+                alto: 48,
+            })
+            .con_giro(0.37),
+        );
+        r.agregar_marco(Marco::new(
+            5,
+            Rect::new(0.0, 0.0, 100.0, 100.0),
+            ContenidoMarco::Texto { titulo: Some("T".into()), parrafos: vec!["p1".into(), "p2".into()] },
+        ));
+        r.pasos = vec![1, 2, 3, 4, 5];
+        let bytes = r.serializar().unwrap();
+        let r2 = Recorrido::deserializar(&bytes).unwrap();
+        assert_eq!(r2.pasos, r.pasos);
+        assert_eq!(r2.marcos.len(), r.marcos.len());
+        // El marco girado con imagen conserva geometría, giro y bytes.
+        let m4 = r2.marco(4).unwrap();
+        assert_eq!(m4.rect, Rect::new(10.0, 20.0, 300.0, 200.0));
+        assert!((m4.rot_rad - 0.37).abs() < 1e-12);
+        assert_eq!(
+            m4.contenido,
+            ContenidoMarco::Imagen { bytes: vec![1, 2, 3, 4, 5], ancho: 64, alto: 48 }
+        );
+        // Bytes corruptos no panican: error controlado.
+        assert!(Recorrido::deserializar(&[0xFF]).is_err());
+    }
+
+    #[test]
+    fn saltar_a_paso_es_instantaneo() {
+        let r = recorrido_demo();
+        let mut s = RecorridoState::new();
+        s.saltar_a_paso(&r, 2, PANEL);
+        assert!(!s.animando());
+        assert_eq!(s.paso, 2);
+        assert_eq!(s.camara, r.marco_en_paso(2).unwrap().fit(PANEL));
+    }
+}
diff --git a/00_unanchay/pluma/pluma-deck-recorrido-llimphi/Cargo.toml b/00_unanchay/pluma/pluma-deck-recorrido-llimphi/Cargo.toml
new file mode 100644
index 0000000..ad1959f
--- /dev/null
+++ b/00_unanchay/pluma/pluma-deck-recorrido-llimphi/Cargo.toml
@@ -0,0 +1,25 @@
+[package]
+name = "pluma-deck-recorrido-llimphi"
+version.workspace = true
+edition.workspace = true
+rust-version.workspace = true
+license.workspace = true
+authors.workspace = true
+publish.workspace = true
+description = "Frontend Llimphi del modo Recorrido de pluma-deck: lienzo infinito + cámara que vuela entre marcos (presentación tipo Prezi)."
+
+[dependencies]
+pluma-deck-core = { path = "../pluma-deck-core" }
+llimphi-ui = { workspace = true }
+# Decodifica los bytes de ContenidoMarco::Imagen (PNG/JPEG/WebP) → RGBA8 para
+# pintarlos como peniko::Image. El core guarda bytes crudos; aquí se rasterizan.
+image = { workspace = true }
+
+[dev-dependencies]
+# Sólo para el example recorrido_md_demo: muestra la cadena markdown →
+# cuerpo/átomos → Recorrido sin acoplar el lib al modelo de documento. La
+# feature `pluma` del core (sólo en dev) trae el adaptador reusable que el
+# demo dogfoodea en vez de su antiguo glue inline.
+pluma-md = { path = "../pluma-md" }
+pluma-core = { path = "../pluma-core" }
+pluma-deck-core = { path = "../pluma-deck-core", features = ["pluma", "serde"] }
diff --git a/00_unanchay/pluma/pluma-deck-recorrido-llimphi/examples/recorrido_demo.rs b/00_unanchay/pluma/pluma-deck-recorrido-llimphi/examples/recorrido_demo.rs
new file mode 100644
index 0000000..17e42e6
--- /dev/null
+++ b/00_unanchay/pluma/pluma-deck-recorrido-llimphi/examples/recorrido_demo.rs
@@ -0,0 +1,181 @@
+//! Demo del modo Recorrido (presentación espacial tipo Prezi).
+//!
+//! Un lienzo infinito con 5 marcos esparcidos a distintas escalas y giros;
+//! la cámara vuela entre ellos siguiendo la ruta. Controles:
+//!   - **→ / ↓ / Espacio / Enter**: paso siguiente (la cámara vuela al marco).
+//!   - **← / ↑**: paso anterior.
+//!   - **Home / Esc**: vista general (aleja para ver todo el lienzo).
+//!   - **p**: play/pausa del modo presentador (avance automático con bucle).
+//!   - **rueda**: zoom-a-cursor.
+//!   - **arrastrar**: paneo libre por el lienzo.
+//!
+//! Corre con:
+//!   `cargo run -p pluma-deck-recorrido-llimphi --example recorrido_demo --release`
+
+use std::time::Duration;
+
+use llimphi_ui::{App, DragPhase, Handle, Key, KeyEvent, KeyState, Modifiers, NamedKey, View, WheelDelta};
+use pluma_deck_core::{Autoplay, ContenidoMarco, Marco, Recorrido, RecorridoState, Rect, RejillaOpts};
+use pluma_deck_recorrido_llimphi::{dentro, panel_actual, recorrido_view, ZOOM_BASE};
+
+/// Panel inicial supuesto antes del primer paint (= `initial_size`), para
+/// encuadrar el primer marco de entrada. Tras el primer frame se usa el real.
+const PANEL_INICIAL: Rect = Rect { x: 0.0, y: 0.0, w: 1100.0, h: 720.0 };
+
+#[derive(Clone)]
+enum Msg {
+    Zoom { mult: f64, cursor: (f32, f32) },
+    Pan { dx: f32, dy: f32 },
+    Siguiente,
+    Anterior,
+    VistaGeneral,
+    ToggleAutoplay,
+    Tick,
+}
+
+struct Model {
+    rec: Recorrido,
+    state: RecorridoState,
+    autoplay: Autoplay,
+}
+
+struct Demo;
+
+impl App for Demo {
+    type Model = Model;
+    type Msg = Msg;
+
+    fn title() -> &'static str {
+        "pluma · recorrido (presentación espacial tipo Prezi)"
+    }
+
+    fn initial_size() -> (u32, u32) {
+        (1100, 720)
+    }
+
+    fn init(handle: &Handle<Self::Msg>) -> Self::Model {
+        // Seis "slides" con contenido real (título + párrafos), auto-colocados
+        // en rejilla por en_rejilla; la ruta los recorre en orden de lectura.
+        let slide = |t: &str, ps: &[&str]| ContenidoMarco::Texto {
+            titulo: Some(t.into()),
+            parrafos: ps.iter().map(|s| s.to_string()).collect(),
+        };
+        let contenidos = vec![
+            slide(
+                "Presentaciones espaciales",
+                &[
+                    "Tipo Prezi: un lienzo infinito en vez de una pila de diapositivas.",
+                    "La cámara vuela entre marcos haciendo zoom y paneo — el recorrido ES la narrativa.",
+                ],
+            ),
+            slide(
+                "Un solo material",
+                &[
+                    "Cada marco vive en coordenadas de mundo; el orden de los pasos define el guion.",
+                    "El strip lineal de pluma-deck es el caso degenerado de esto.",
+                ],
+            ),
+            slide(
+                "Zoom narrativo",
+                &["Alejarse muestra el mapa completo; acercarse, el detalle.", "El zoom se interpola en espacio logarítmico para un vuelo natural."],
+            ),
+            slide(
+                "Contenido nativo",
+                &["El contenido es agnóstico (título + párrafos).", "Un adaptador mapea un cuerpo o subgrafo de pluma a estos marcos."],
+            ),
+            slide(
+                "Controles",
+                &["Flechas / Espacio / Enter: volar al paso.", "Rueda: zoom-a-cursor.  Arrastrar: paneo libre."],
+            ),
+            slide("Fin", &["Esto es la Fase 3a del §6.sexies en marcha."]),
+        ];
+        let mut rec = Recorrido::en_rejilla(
+            contenidos,
+            RejillaOpts { cols: 3, marco_w: 660.0, marco_h: 420.0, gap_x: 240.0, gap_y: 200.0 },
+        );
+        // Un par de marcos sueltos con giro para lucir la libertad espacial.
+        let id_a = (rec.marcos.len() + 1) as u64;
+        rec.agregar_marco(
+            Marco::new(id_a, Rect::new(-520.0, 760.0, 300.0, 200.0), ContenidoMarco::Etiqueta("← lienzo infinito →".into()))
+                .con_giro(-0.12),
+        );
+
+        let mut state = RecorridoState::new();
+        state.saltar_a_paso(&rec, 0, PANEL_INICIAL);
+
+        // Tick de animación a ~60 Hz; avanzar() es no-op cuando no hay vuelo.
+        handle.spawn_periodic(Duration::from_millis(16), || Msg::Tick);
+
+        Model { rec, state, autoplay: Autoplay::default() }
+    }
+
+    fn update(mut model: Self::Model, msg: Self::Msg, _: &Handle<Self::Msg>) -> Self::Model {
+        let panel = panel_actual().unwrap_or(PANEL_INICIAL);
+        match msg {
+            Msg::Zoom { mult, cursor } => {
+                model.state.wheel(mult, (cursor.0 as f64, cursor.1 as f64), panel);
+            }
+            Msg::Pan { dx, dy } => {
+                model.state.arrastrar_delta(dx as f64, dy as f64);
+            }
+            Msg::Siguiente => {
+                model.state.siguiente(&model.rec, panel);
+            }
+            Msg::Anterior => {
+                model.state.anterior(&model.rec, panel);
+            }
+            Msg::VistaGeneral => {
+                model.state.vista_general(&model.rec, panel);
+            }
+            Msg::ToggleAutoplay => {
+                model.autoplay.toggle();
+            }
+            Msg::Tick => {
+                model.state.avanzar(1.0 / 60.0);
+                model.autoplay.tick(1.0 / 60.0, &mut model.state, &model.rec, panel);
+            }
+        }
+        model
+    }
+
+    fn view(model: &Self::Model) -> View<Self::Msg> {
+        recorrido_view(&model.rec, &model.state).draggable(|phase, dx, dy| match phase {
+            DragPhase::Move => Some(Msg::Pan { dx, dy }),
+            DragPhase::End => None,
+        })
+    }
+
+    fn on_wheel(
+        _model: &Self::Model,
+        delta: WheelDelta,
+        cursor: (f32, f32),
+        _modifiers: Modifiers,
+    ) -> Option<Self::Msg> {
+        let panel = panel_actual()?;
+        if !dentro(panel, cursor.0, cursor.1) {
+            return None;
+        }
+        // delta.y > 0 ⇒ scroll abajo ⇒ alejar (convención CSS, igual que tullpu).
+        let mult = ZOOM_BASE.powf(-delta.y as f64);
+        Some(Msg::Zoom { mult, cursor })
+    }
+
+    fn on_key(_model: &Self::Model, ev: &KeyEvent) -> Option<Self::Msg> {
+        if ev.state != KeyState::Pressed {
+            return None;
+        }
+        match &ev.key {
+            Key::Named(NamedKey::ArrowRight | NamedKey::ArrowDown | NamedKey::Enter | NamedKey::Space) => {
+                Some(Msg::Siguiente)
+            }
+            Key::Named(NamedKey::ArrowLeft | NamedKey::ArrowUp) => Some(Msg::Anterior),
+            Key::Named(NamedKey::Home | NamedKey::Escape) => Some(Msg::VistaGeneral),
+            Key::Character(c) if c.as_str().eq_ignore_ascii_case("p") => Some(Msg::ToggleAutoplay),
+            _ => None,
+        }
+    }
+}
+
+fn main() {
+    llimphi_ui::run::<Demo>();
+}
diff --git a/00_unanchay/pluma/pluma-deck-recorrido-llimphi/examples/recorrido_editor_demo.rs b/00_unanchay/pluma/pluma-deck-recorrido-llimphi/examples/recorrido_editor_demo.rs
new file mode 100644
index 0000000..06f5859
--- /dev/null
+++ b/00_unanchay/pluma/pluma-deck-recorrido-llimphi/examples/recorrido_editor_demo.rs
@@ -0,0 +1,210 @@
+//! Demo de **autoría**: colocar/mover/editar marcos en el lienzo y guardar.
+//!
+//! A diferencia de `recorrido_demo` (presentar), aquí el arrastre edita:
+//!   - **arrastrar sobre un marco**: lo mueve (y lo selecciona; borde ámbar).
+//!   - **arrastrar sobre el vacío**: panea el lienzo.
+//!   - **n**: crea un marco nuevo en el centro de la cámara (lo agrega a la ruta y lo selecciona).
+//!   - **Supr / Retroceso**: elimina el marco seleccionado (y lo purga del guion).
+//!   - **[ / ]**: rota el marco seleccionado.
+//!   - **Ctrl+S / Ctrl+O**: guarda / carga el recorrido en `recorrido.deck` (postcard).
+//!   - **rueda**: zoom-a-cursor.   **flechas / Espacio**: vuela por la ruta.
+//!
+//! El hit-test, el movimiento y las ops de autoría (`eliminar_marco`,
+//! `rotar_marco`, …) y la persistencia (`serializar`/`deserializar`) viven en
+//! `pluma-deck-core`; aquí sólo se cablean a eventos.
+//!
+//! Corre con:
+//!   `cargo run -p pluma-deck-recorrido-llimphi --example recorrido_editor_demo --release`
+
+use std::time::Duration;
+
+use llimphi_ui::{App, DragPhase, Handle, Key, KeyEvent, KeyState, Modifiers, NamedKey, View, WheelDelta};
+use pluma_deck_core::{ContenidoMarco, Marco, Recorrido, RecorridoState, Rect, RejillaOpts};
+use pluma_deck_recorrido_llimphi::{dentro, panel_actual, recorrido_view_editor, ZOOM_BASE};
+
+const PANEL_INICIAL: Rect = Rect { x: 0.0, y: 0.0, w: 1100.0, h: 720.0 };
+/// Archivo donde guarda/carga el demo (codec nativo postcard).
+const ARCHIVO: &str = "recorrido.deck";
+
+#[derive(Clone)]
+enum Msg {
+    Zoom { mult: f64, cursor: (f32, f32) },
+    /// Move del arrastre: delta `(dx,dy)` + posición inicial del press `(lx,ly)`.
+    Arrastre { dx: f32, dy: f32, lx: f32, ly: f32 },
+    FinArrastre,
+    NuevoMarco,
+    Eliminar,
+    Rotar(f64),
+    Guardar,
+    Cargar,
+    Siguiente,
+    Anterior,
+    Tick,
+}
+
+struct Model {
+    rec: Recorrido,
+    state: RecorridoState,
+    /// `None` = sin arrastre. `Some(None)` = paneando. `Some(Some(id))` = moviendo ese marco.
+    arrastrando: Option<Option<u64>>,
+    /// Marco seleccionado (objetivo de eliminar/rotar). Se realza en ámbar.
+    seleccionado: Option<u64>,
+}
+
+struct Demo;
+
+impl App for Demo {
+    type Model = Model;
+    type Msg = Msg;
+
+    fn title() -> &'static str {
+        "pluma · recorrido (autoría: arrastrar mueve marcos, n crea)"
+    }
+
+    fn initial_size() -> (u32, u32) {
+        (1100, 720)
+    }
+
+    fn init(handle: &Handle<Self::Msg>) -> Self::Model {
+        let etiqueta = |s: &str| ContenidoMarco::Etiqueta(s.into());
+        let rec = Recorrido::en_rejilla(
+            vec![etiqueta("arrastrá un marco"), etiqueta("o el vacío para panear"), etiqueta("tecla n: marco nuevo")],
+            RejillaOpts { cols: 3, marco_w: 460.0, marco_h: 300.0, gap_x: 200.0, gap_y: 160.0 },
+        );
+        let mut state = RecorridoState::new();
+        state.saltar_a_paso(&rec, 0, PANEL_INICIAL);
+        handle.spawn_periodic(Duration::from_millis(16), || Msg::Tick);
+        Model { rec, state, arrastrando: None, seleccionado: None }
+    }
+
+    fn update(mut model: Self::Model, msg: Self::Msg, _: &Handle<Self::Msg>) -> Self::Model {
+        let panel = panel_actual().unwrap_or(PANEL_INICIAL);
+        match msg {
+            Msg::Zoom { mult, cursor } => {
+                model.state.wheel(mult, (cursor.0 as f64, cursor.1 as f64), panel);
+            }
+            Msg::Arrastre { dx, dy, lx, ly } => {
+                // En el primer Move decidimos qué se agarró (marco o vacío) y
+                // lo fijamos hasta soltar — así no se cambia de presa a mitad.
+                let modo = match model.arrastrando {
+                    Some(m) => m,
+                    None => {
+                        let world = model.state.camara.screen_to_world((lx as f64, ly as f64), panel);
+                        let m = model.rec.marco_en_punto(world);
+                        model.arrastrando = Some(m);
+                        // Agarrar un marco lo selecciona (objetivo de editar).
+                        if m.is_some() {
+                            model.seleccionado = m;
+                        }
+                        m
+                    }
+                };
+                match modo {
+                    Some(id) => {
+                        let (wdx, wdy) = model.state.camara.delta_pantalla_a_mundo(dx as f64, dy as f64);
+                        model.rec.mover_marco(id, wdx, wdy);
+                    }
+                    None => model.state.arrastrar_delta(dx as f64, dy as f64),
+                }
+            }
+            Msg::FinArrastre => model.arrastrando = None,
+            Msg::NuevoMarco => {
+                let id = model.rec.marcos.iter().map(|m| m.id).max().unwrap_or(0) + 1;
+                let (cx, cy) = model.state.camara.centro;
+                let (w, h) = (420.0, 260.0);
+                model.rec.agregar_marco(Marco::new(
+                    id,
+                    Rect::new(cx - w * 0.5, cy - h * 0.5, w, h),
+                    ContenidoMarco::Etiqueta(format!("marco {id}")),
+                ));
+                model.rec.pasos.push(id);
+                model.seleccionado = Some(id);
+            }
+            Msg::Eliminar => {
+                if let Some(id) = model.seleccionado.take() {
+                    model.rec.eliminar_marco(id);
+                    // Reencuadra el paso actual (el guion pudo encogerse).
+                    let idx = model.state.paso.min(model.rec.n_pasos().saturating_sub(1));
+                    model.state.saltar_a_paso(&model.rec, idx, panel);
+                }
+            }
+            Msg::Rotar(d) => {
+                if let Some(id) = model.seleccionado {
+                    model.rec.rotar_marco(id, d);
+                }
+            }
+            Msg::Guardar => match model.rec.serializar() {
+                Ok(bytes) => {
+                    let _ = std::fs::write(ARCHIVO, &bytes);
+                    eprintln!("guardado {ARCHIVO} ({} bytes)", bytes.len());
+                }
+                Err(e) => eprintln!("error al guardar: {e}"),
+            },
+            Msg::Cargar => match std::fs::read(ARCHIVO).map_err(|_| "no se pudo leer").and_then(|b| Recorrido::deserializar(&b)) {
+                Ok(rec) => {
+                    model.rec = rec;
+                    model.seleccionado = None;
+                    model.state.saltar_a_paso(&model.rec, 0, panel);
+                    eprintln!("cargado {ARCHIVO}");
+                }
+                Err(e) => eprintln!("error al cargar: {e}"),
+            },
+            Msg::Siguiente => {
+                model.state.siguiente(&model.rec, panel);
+            }
+            Msg::Anterior => {
+                model.state.anterior(&model.rec, panel);
+            }
+            Msg::Tick => {
+                model.state.avanzar(1.0 / 60.0);
+            }
+        }
+        model
+    }
+
+    fn view(model: &Self::Model) -> View<Self::Msg> {
+        recorrido_view_editor(&model.rec, &model.state, model.seleccionado).draggable_at(
+            |phase, dx, dy, lx, ly| match phase {
+                DragPhase::Move => Some(Msg::Arrastre { dx, dy, lx, ly }),
+                DragPhase::End => Some(Msg::FinArrastre),
+            },
+        )
+    }
+
+    fn on_wheel(_m: &Self::Model, delta: WheelDelta, cursor: (f32, f32), _mods: Modifiers) -> Option<Self::Msg> {
+        let panel = panel_actual()?;
+        if !dentro(panel, cursor.0, cursor.1) {
+            return None;
+        }
+        Some(Msg::Zoom { mult: ZOOM_BASE.powf(-delta.y as f64), cursor })
+    }
+
+    fn on_key(_m: &Self::Model, ev: &KeyEvent) -> Option<Self::Msg> {
+        if ev.state != KeyState::Pressed {
+            return None;
+        }
+        // Atajos con Ctrl: guardar / cargar.
+        if ev.modifiers.ctrl {
+            return match &ev.key {
+                Key::Character(c) if c.eq_ignore_ascii_case("s") => Some(Msg::Guardar),
+                Key::Character(c) if c.eq_ignore_ascii_case("o") => Some(Msg::Cargar),
+                _ => None,
+            };
+        }
+        match &ev.key {
+            Key::Character(c) if c.as_str() == "n" => Some(Msg::NuevoMarco),
+            Key::Character(c) if c.as_str() == "[" => Some(Msg::Rotar(-0.08)),
+            Key::Character(c) if c.as_str() == "]" => Some(Msg::Rotar(0.08)),
+            Key::Named(NamedKey::Delete | NamedKey::Backspace) => Some(Msg::Eliminar),
+            Key::Named(NamedKey::ArrowRight | NamedKey::ArrowDown | NamedKey::Enter | NamedKey::Space) => {
+                Some(Msg::Siguiente)
+            }
+            Key::Named(NamedKey::ArrowLeft | NamedKey::ArrowUp) => Some(Msg::Anterior),
+            _ => None,
+        }
+    }
+}
+
+fn main() {
+    llimphi_ui::run::<Demo>();
+}
diff --git a/00_unanchay/pluma/pluma-deck-recorrido-llimphi/examples/recorrido_imagen_demo.rs b/00_unanchay/pluma/pluma-deck-recorrido-llimphi/examples/recorrido_imagen_demo.rs
new file mode 100644
index 0000000..8a2a6ca
--- /dev/null
+++ b/00_unanchay/pluma/pluma-deck-recorrido-llimphi/examples/recorrido_imagen_demo.rs
@@ -0,0 +1,163 @@
+//! Demo de **imagen dentro del marco** (cierra el pendiente menor de la Fase 3
+//! del §6.sexies): un marco cuyo contenido es `ContenidoMarco::Imagen`.
+//!
+//! El core guarda los bytes **codificados** (aquí un PNG generado en memoria,
+//! sin tocar disco) + sus dimensiones; el frontend Llimphi los decodifica una
+//! vez, los cachea y los pinta encajados en el marco preservando aspect ratio
+//! —respetando giro y zoom de la cámara igual que el texto—.
+//!
+//! Mezcla slides de texto con dos marcos-imagen (uno girado, para lucir que la
+//! imagen vuela con el marco). Controles iguales que `recorrido_demo`:
+//!   - **→ / ↓ / Espacio / Enter**: paso siguiente.   **← / ↑**: anterior.
+//!   - **rueda**: zoom-a-cursor.   **arrastrar**: paneo libre.
+//!
+//! Corre con:
+//!   `cargo run -p pluma-deck-recorrido-llimphi --example recorrido_imagen_demo --release`
+
+use std::time::Duration;
+
+use llimphi_ui::{App, DragPhase, Handle, Key, KeyEvent, KeyState, Modifiers, NamedKey, View, WheelDelta};
+use pluma_deck_core::{ContenidoMarco, Marco, Recorrido, RecorridoState, Rect, RejillaOpts};
+use pluma_deck_recorrido_llimphi::{dentro, panel_actual, recorrido_view, ZOOM_BASE};
+
+const PANEL_INICIAL: Rect = Rect { x: 0.0, y: 0.0, w: 1100.0, h: 720.0 };
+
+#[derive(Clone)]
+enum Msg {
+    Zoom { mult: f64, cursor: (f32, f32) },
+    Pan { dx: f32, dy: f32 },
+    Siguiente,
+    Anterior,
+    Tick,
+}
+
+struct Model {
+    rec: Recorrido,
+    state: RecorridoState,
+}
+
+/// Genera un PNG en memoria: degradado diagonal con una rejilla, para tener una
+/// imagen reconocible sin depender de un asset en disco. Devuelve los bytes PNG
+/// codificados (lo que viaja en `ContenidoMarco::Imagen`) + sus dimensiones.
+fn png_demo(w: u32, h: u32, base: (u8, u8, u8)) -> (Vec<u8>, u32, u32) {
+    let mut img = image::RgbaImage::new(w, h);
+    for (x, y, px) in img.enumerate_pixels_mut() {
+        let fx = x as f32 / w as f32;
+        let fy = y as f32 / h as f32;
+        // Degradado diagonal sobre el color base + rejilla cada 40 px.
+        let t = (fx + fy) * 0.5;
+        let mezcla = |c: u8| (c as f32 * (0.35 + 0.65 * t)) as u8;
+        let rejilla = (x % 40 == 0 || y % 40 == 0) as u8 * 40;
+        *px = image::Rgba([
+            mezcla(base.0).saturating_add(rejilla),
+            mezcla(base.1).saturating_add(rejilla),
+            mezcla(base.2).saturating_add(rejilla),
+            255,
+        ]);
+    }
+    let mut bytes = Vec::new();
+    image::DynamicImage::ImageRgba8(img)
+        .write_to(&mut std::io::Cursor::new(&mut bytes), image::ImageFormat::Png)
+        .expect("codificar PNG demo");
+    (bytes, w, h)
+}
+
+struct Demo;
+
+impl App for Demo {
+    type Model = Model;
+    type Msg = Msg;
+
+    fn title() -> &'static str {
+        "pluma · recorrido (imagen dentro del marco)"
+    }
+
+    fn initial_size() -> (u32, u32) {
+        (1100, 720)
+    }
+
+    fn init(handle: &Handle<Self::Msg>) -> Self::Model {
+        let slide = |t: &str, ps: &[&str]| ContenidoMarco::Texto {
+            titulo: Some(t.into()),
+            parrafos: ps.iter().map(|s| s.to_string()).collect(),
+        };
+        let (png_a, wa, ha) = png_demo(480, 320, (90, 150, 230));
+        let (png_b, wb, hb) = png_demo(360, 360, (230, 140, 90));
+
+        let contenidos = vec![
+            slide(
+                "Imagen nativa en el marco",
+                &[
+                    "El marco siguiente lleva una imagen: el core guarda bytes PNG; el frontend los rasteriza y encaja.",
+                    "La imagen vuela con la cámara — zoom y giro la transforman como a cualquier marco.",
+                ],
+            ),
+            ContenidoMarco::Imagen { bytes: png_a, ancho: wa, alto: ha },
+            slide("Aspect ratio preservado", &["La imagen se centra y encaja sin deformarse, clipeada al marco."]),
+        ];
+        let mut rec = Recorrido::en_rejilla(
+            contenidos,
+            RejillaOpts { cols: 3, marco_w: 640.0, marco_h: 420.0, gap_x: 240.0, gap_y: 200.0 },
+        );
+        // Marco-imagen suelto y girado, para lucir que la imagen sigue el giro.
+        let id = (rec.marcos.len() + 1) as u64;
+        rec.agregar_marco(
+            Marco::new(id, Rect::new(220.0, 760.0, 360.0, 360.0), ContenidoMarco::Imagen { bytes: png_b, ancho: wb, alto: hb })
+                .con_giro(0.18),
+        );
+        rec.pasos.push(id);
+
+        let mut state = RecorridoState::new();
+        state.saltar_a_paso(&rec, 0, PANEL_INICIAL);
+        handle.spawn_periodic(Duration::from_millis(16), || Msg::Tick);
+        Model { rec, state }
+    }
+
+    fn update(mut model: Self::Model, msg: Self::Msg, _: &Handle<Self::Msg>) -> Self::Model {
+        let panel = panel_actual().unwrap_or(PANEL_INICIAL);
+        match msg {
+            Msg::Zoom { mult, cursor } => model.state.wheel(mult, (cursor.0 as f64, cursor.1 as f64), panel),
+            Msg::Pan { dx, dy } => model.state.arrastrar_delta(dx as f64, dy as f64),
+            Msg::Siguiente => {
+                model.state.siguiente(&model.rec, panel);
+            }
+            Msg::Anterior => {
+                model.state.anterior(&model.rec, panel);
+            }
+            Msg::Tick => {
+                model.state.avanzar(1.0 / 60.0);
+            }
+        }
+        model
+    }
+
+    fn view(model: &Self::Model) -> View<Self::Msg> {
+        recorrido_view(&model.rec, &model.state).draggable(|phase, dx, dy| match phase {
+            DragPhase::Move => Some(Msg::Pan { dx, dy }),
+            DragPhase::End => None,
+        })
+    }
+
+    fn on_wheel(_m: &Self::Model, delta: WheelDelta, cursor: (f32, f32), _mods: Modifiers) -> Option<Self::Msg> {
+        let panel = panel_actual()?;
+        if !dentro(panel, cursor.0, cursor.1) {
+            return None;
+        }
+        Some(Msg::Zoom { mult: ZOOM_BASE.powf(-delta.y as f64), cursor })
+    }
+
+    fn on_key(_m: &Self::Model, ev: &KeyEvent) -> Option<Self::Msg> {
+        if ev.state != KeyState::Pressed {
+            return None;
+        }
+        match &ev.key {
+            Key::Named(NamedKey::ArrowRight | NamedKey::ArrowDown | NamedKey::Enter | NamedKey::Space) => Some(Msg::Siguiente),
+            Key::Named(NamedKey::ArrowLeft | NamedKey::ArrowUp) => Some(Msg::Anterior),
+            _ => None,
+        }
+    }
+}
+
+fn main() {
+    llimphi_ui::run::<Demo>();
+}
diff --git a/00_unanchay/pluma/pluma-deck-recorrido-llimphi/examples/recorrido_md_demo.rs b/00_unanchay/pluma/pluma-deck-recorrido-llimphi/examples/recorrido_md_demo.rs
new file mode 100644
index 0000000..4e6b4a0
--- /dev/null
+++ b/00_unanchay/pluma/pluma-deck-recorrido-llimphi/examples/recorrido_md_demo.rs
@@ -0,0 +1,146 @@
+//! Demo: un documento **markdown real** presentado como recorrido espacial.
+//!
+//! Cadena completa: `markdown → pluma_md::parse_md → átomos → Recorrido`.
+//! Cada encabezado (`#`, `##`, …) abre un "slide" cuyo título es el del
+//! encabezado y cuyos párrafos son los bloques siguientes hasta el próximo
+//! encabezado. `en_rejilla` los coloca y rutea en orden de lectura.
+//!
+//! El adaptador (`recorrido_desde_atomos`) ya **no vive aquí**: se promovió a
+//! `pluma_deck_core::adaptador` (feature `pluma`) y este demo lo dogfoodea, sin
+//! glue inline. El core sigue agnóstico por defecto; la feature sólo se activa
+//! en dev para los demos que tocan el modelo de documento de pluma.
+//!
+//! Corre con un .md propio o el de ejemplo embebido:
+//!   `cargo run -p pluma-deck-recorrido-llimphi --example recorrido_md_demo --release [archivo.md]`
+
+use std::time::Duration;
+
+use llimphi_ui::{App, DragPhase, Handle, Key, KeyEvent, KeyState, Modifiers, NamedKey, View, WheelDelta};
+use pluma_deck_core::adaptador::recorrido_desde_atomos;
+use pluma_deck_core::{Recorrido, RecorridoState, Rect, RejillaOpts};
+use pluma_deck_recorrido_llimphi::{dentro, panel_actual, recorrido_view, ZOOM_BASE};
+
+const PANEL_INICIAL: Rect = Rect { x: 0.0, y: 0.0, w: 1100.0, h: 720.0 };
+
+const MD_EJEMPLO: &str = "\
+# Presentaciones desde markdown
+
+gioser convierte un documento real en un recorrido espacial.
+
+Cada encabezado abre un marco; los párrafos que le siguen son su cuerpo.
+
+## El pipeline
+
+El markdown se parsea con `pluma-md` en átomos (un bloque por átomo).
+
+El adaptador agrupa esos átomos en slides por encabezado.
+
+## El render
+
+`pluma-deck-core` coloca los slides en una rejilla y arma la ruta.
+
+El frontend Llimphi pinta cada marco y la cámara vuela entre ellos.
+
+## Cierre
+
+Mismo material que ya escribís — presentado sin diapositivas.
+";
+
+#[derive(Clone)]
+enum Msg {
+    Zoom { mult: f64, cursor: (f32, f32) },
+    Pan { dx: f32, dy: f32 },
+    Siguiente,
+    Anterior,
+    Tick,
+}
+
+struct Model {
+    rec: Recorrido,
+    state: RecorridoState,
+}
+
+struct Demo;
+
+impl App for Demo {
+    type Model = Model;
+    type Msg = Msg;
+
+    fn title() -> &'static str {
+        "pluma · recorrido desde markdown"
+    }
+
+    fn initial_size() -> (u32, u32) {
+        (1100, 720)
+    }
+
+    fn init(handle: &Handle<Self::Msg>) -> Self::Model {
+        // Si pasan un .md por arg lo leemos; si no, el embebido.
+        let md = std::env::args()
+            .nth(1)
+            .and_then(|p| std::fs::read_to_string(p).ok())
+            .unwrap_or_else(|| MD_EJEMPLO.to_string());
+        let doc = pluma_md::parse_md(&md, "es", "recorrido", 0);
+        let rec = recorrido_desde_atomos(
+            &doc.atoms,
+            RejillaOpts { cols: 3, marco_w: 660.0, marco_h: 420.0, gap_x: 240.0, gap_y: 200.0 },
+        );
+
+        let mut state = RecorridoState::new();
+        state.saltar_a_paso(&rec, 0, PANEL_INICIAL);
+        handle.spawn_periodic(Duration::from_millis(16), || Msg::Tick);
+        Model { rec, state }
+    }
+
+    fn update(mut model: Self::Model, msg: Self::Msg, _: &Handle<Self::Msg>) -> Self::Model {
+        let panel = panel_actual().unwrap_or(PANEL_INICIAL);
+        match msg {
+            Msg::Zoom { mult, cursor } => {
+                model.state.wheel(mult, (cursor.0 as f64, cursor.1 as f64), panel);
+            }
+            Msg::Pan { dx, dy } => model.state.arrastrar_delta(dx as f64, dy as f64),
+            Msg::Siguiente => {
+                model.state.siguiente(&model.rec, panel);
+            }
+            Msg::Anterior => {
+                model.state.anterior(&model.rec, panel);
+            }
+            Msg::Tick => {
+                model.state.avanzar(1.0 / 60.0);
+            }
+        }
+        model
+    }
+
+    fn view(model: &Self::Model) -> View<Self::Msg> {
+        recorrido_view(&model.rec, &model.state).draggable(|phase, dx, dy| match phase {
+            DragPhase::Move => Some(Msg::Pan { dx, dy }),
+            DragPhase::End => None,
+        })
+    }
+
+    fn on_wheel(_m: &Self::Model, delta: WheelDelta, cursor: (f32, f32), _mods: Modifiers) -> Option<Self::Msg> {
+        let panel = panel_actual()?;
+        if !dentro(panel, cursor.0, cursor.1) {
+            return None;
+        }
+        Some(Msg::Zoom { mult: ZOOM_BASE.powf(-delta.y as f64), cursor })
+    }
+
+    fn on_key(_m: &Self::Model, ev: &KeyEvent) -> Option<Self::Msg> {
+        if ev.state != KeyState::Pressed {
+            return None;
+        }
+        match &ev.key {
+            Key::Named(NamedKey::ArrowRight | NamedKey::ArrowDown | NamedKey::Enter | NamedKey::Space) => {
+                Some(Msg::Siguiente)
+            }
+            Key::Named(NamedKey::ArrowLeft | NamedKey::ArrowUp) => Some(Msg::Anterior),
+            _ => None,
+        }
+    }
+}
+
+fn main() {
+    llimphi_ui::run::<Demo>();
+}
diff --git a/00_unanchay/pluma/pluma-deck-recorrido-llimphi/src/lib.rs b/00_unanchay/pluma/pluma-deck-recorrido-llimphi/src/lib.rs
new file mode 100644
index 0000000..26fdeb6
--- /dev/null
+++ b/00_unanchay/pluma/pluma-deck-recorrido-llimphi/src/lib.rs
@@ -0,0 +1,463 @@
+//! Frontend Llimphi del modo `Recorrido` de `pluma-deck-core` — presentación
+//! espacial tipo Prezi: un lienzo 2D infinito con marcos colocados en
+//! coordenadas de mundo y una cámara que vuela entre ellos.
+//!
+//! La lógica vive entera en `pluma-deck-core` (cámara, ruta, máquina de
+//! interacción); aquí sólo hay **pintura** (`View::paint_with` aplicando el
+//! transform de la cámara) y el cableado de eventos. Sigue la regla #2 del
+//! repo: la UI es un frontend intercambiable sobre un `*-core` agnóstico.
+//!
+//! El host arma su `App` así:
+//! - `view`: nodo a pantalla completa con [`recorrido_view`] (registra el rect
+//!   del panel en un side-channel para que `on_wheel` sepa el tamaño).
+//! - `on_wheel`: lee [`panel_actual`] y despacha un zoom-a-cursor.
+//! - drag sobre el nodo: `RecorridoState::arrastrar_delta` (pan libre).
+//! - flechas: `siguiente`/`anterior` + un tick periódico que llama
+//!   `RecorridoState::avanzar(dt)` para animar el vuelo.
+
+use std::collections::HashMap;
+use std::sync::{Mutex, OnceLock};
+
+use llimphi_ui::llimphi_raster::kurbo::{Affine, BezPath, Circle, Rect as KurboRect, RoundedRect, Stroke};
+use llimphi_ui::llimphi_raster::peniko::{Blob, Color, Fill, Image as PenikoImage, ImageFormat, Mix};
+use llimphi_ui::llimphi_text::{draw_layout_xf, layout_block, measurement, Alignment, TextBlock};
+use llimphi_ui::llimphi_layout::taffy::prelude::{percent, Size, Style};
+use llimphi_ui::{PaintRect, View};
+
+use pluma_deck_core::{Camara, ContenidoMarco, MarcoId, Recorrido, RecorridoState, Rect};
+
+/// Base del zoom por "clic" de rueda (igual criterio que tullpu: `1.1`).
+pub const ZOOM_BASE: f64 = 1.1;
+
+type Scene = llimphi_ui::llimphi_raster::vello::Scene;
+type Ts = llimphi_ui::llimphi_text::Typesetter;
+
+// ---- Side-channel del rect del panel -------------------------------------
+//
+// `App::on_wheel` recibe el cursor absoluto pero no el tamaño del viewport.
+// El `paint_with` de [`recorrido_view`] escribe el rect del panel cada frame
+// y `on_wheel`/handlers lo leen. Mismo patrón que `tullpu` (`LIENZO_RECT`).
+
+static PANEL_RECT: OnceLock<Mutex<Option<Rect>>> = OnceLock::new();
+
+fn panel_set(r: Rect) {
+    let cell = PANEL_RECT.get_or_init(|| Mutex::new(None));
+    if let Ok(mut g) = cell.lock() {
+        *g = Some(r);
+    }
+}
+
+/// Último rect del panel (px de pantalla) registrado por [`recorrido_view`].
+/// `None` hasta el primer frame pintado. Lo usan `on_wheel` (zoom-a-cursor),
+/// `siguiente`/`anterior` (encuadre) en el `update` del host.
+pub fn panel_actual() -> Option<Rect> {
+    PANEL_RECT.get()?.lock().ok().and_then(|g| *g)
+}
+
+/// `true` si `(cx, cy)` (px de pantalla) cae dentro de `panel`.
+pub fn dentro(panel: Rect, cx: f32, cy: f32) -> bool {
+    let (cx, cy) = (cx as f64, cy as f64);
+    cx >= panel.x && cx <= panel.x + panel.w && cy >= panel.y && cy <= panel.y + panel.h
+}
+
+// ---- Caché de imágenes decodificadas -------------------------------------
+//
+// `ContenidoMarco::Imagen` guarda bytes **codificados** (PNG/JPEG/WebP): el
+// core es agnóstico al render. Decodificarlos a RGBA8 en cada frame sería
+// carísimo, así que se decodifica una vez y se cachea la `peniko::Image`
+// (que es barata de clonar — su `Blob` es `Arc`). La clave `(id, len)` detecta
+// el caso de reemplazar la imagen de un marco por otra de distinto tamaño.
+
+static IMG_CACHE: OnceLock<Mutex<HashMap<(MarcoId, usize), PenikoImage>>> = OnceLock::new();
+
+/// Devuelve la `peniko::Image` del marco `id`, decodificando+cacheando la
+/// primera vez. `None` si los bytes no son una imagen válida.
+fn imagen_cacheada(id: MarcoId, bytes: &[u8]) -> Option<PenikoImage> {
+    let cell = IMG_CACHE.get_or_init(|| Mutex::new(HashMap::new()));
+    let mut g = cell.lock().ok()?;
+    if let Some(img) = g.get(&(id, bytes.len())) {
+        return Some(img.clone());
+    }
+    let img = decodificar(bytes)?;
+    g.insert((id, bytes.len()), img.clone());
+    Some(img)
+}
+
+fn decodificar(bytes: &[u8]) -> Option<PenikoImage> {
+    let img = image::load_from_memory(bytes).ok()?.to_rgba8();
+    let (w, h) = (img.width(), img.height());
+    let blob = Blob::from(img.into_raw());
+    Some(PenikoImage::new(blob, ImageFormat::Rgba8, w, h))
+}
+
+// ---- Pintura por marco ---------------------------------------------------
+//
+// El `paint_with` corre cada frame con un closure `Send + Sync`. Para no clonar
+// los bytes de imagen (ni re-decodificar) por frame, `recorrido_view` precocina
+// cada marco a una `Pintura` ligera: el texto se clona (barato) y la imagen se
+// resuelve a una `peniko::Image` cacheada (clon barato).
+
+enum Pintura {
+    Etiqueta(String),
+    Texto { titulo: Option<String>, parrafos: Vec<String> },
+    Imagen(PenikoImage),
+    Nada,
+}
+
+struct MarcoPintura {
+    id: MarcoId,
+    rect: Rect,
+    rot_rad: f64,
+    pintura: Pintura,
+}
+
+// ---- Colores del lienzo (no temáticos todavía; placeholder sobrio) -------
+
+const FONDO: Color = Color::from_rgba8(18, 20, 28, 255);
+const MARCO_FONDO: Color = Color::from_rgba8(38, 42, 56, 255);
+const MARCO_BORDE: Color = Color::from_rgba8(80, 86, 104, 255);
+const MARCO_ACENTO: Color = Color::from_rgba8(120, 180, 255, 255);
+const TEXTO: Color = Color::from_rgba8(225, 230, 240, 235);
+const TEXTO_TENUE: Color = Color::from_rgba8(186, 194, 210, 225);
+// Camino narrativo: línea punteada que une los marcos en orden de la ruta,
+// pintada por detrás de los marcos. Acento tenue para no robar protagonismo.
+const RUTA: Color = Color::from_rgba8(120, 180, 255, 120);
+// Realce del marco seleccionado en autoría — ámbar, distinto del acento azul
+// del paso actual, para que selección y "paso actual" se distingan de un vistazo.
+const SELECCION: Color = Color::from_rgba8(255, 196, 92, 255);
+// HUD "paso X / N": píldora sobria abajo-centro, en espacio de pantalla.
+const HUD_FONDO: Color = Color::from_rgba8(12, 14, 20, 190);
+const HUD_TEXTO: Color = Color::from_rgba8(205, 212, 226, 235);
+
+/// Nodo a pantalla completa que pinta el recorrido y registra el rect del
+/// panel. `Msg` es libre: el caller suele colgarle un `.draggable(...)` para
+/// el pan — esta función no lo impone para no fijar el tipo de mensaje.
+pub fn recorrido_view<Msg: 'static>(rec: &Recorrido, state: &RecorridoState) -> View<Msg> {
+    vista(rec, state, None)
+}
+
+/// Igual que [`recorrido_view`] pero realzando el marco `seleccionado` (autoría):
+/// se pinta con un borde de selección distinto del acento del paso actual.
+pub fn recorrido_view_editor<Msg: 'static>(
+    rec: &Recorrido,
+    state: &RecorridoState,
+    seleccionado: Option<MarcoId>,
+) -> View<Msg> {
+    vista(rec, state, seleccionado)
+}
+
+fn vista<Msg: 'static>(rec: &Recorrido, state: &RecorridoState, seleccionado: Option<MarcoId>) -> View<Msg> {
+    // Precocinamos cada marco a una `Pintura` ligera (texto clonado, imagen
+    // resuelta a peniko::Image cacheada) para no clonar bytes ni re-decodificar
+    // por frame, y para que el closure `Send + Sync` sobreviva sin los bytes.
+    let pinturas: Vec<MarcoPintura> = rec
+        .marcos
+        .iter()
+        .map(|m| {
+            let pintura = match &m.contenido {
+                ContenidoMarco::Etiqueta(t) if !t.is_empty() => Pintura::Etiqueta(t.clone()),
+                ContenidoMarco::Texto { titulo, parrafos } => {
+                    Pintura::Texto { titulo: titulo.clone(), parrafos: parrafos.clone() }
+                }
+                ContenidoMarco::Imagen { bytes, .. } => {
+                    imagen_cacheada(m.id, bytes).map(Pintura::Imagen).unwrap_or(Pintura::Nada)
+                }
+                _ => Pintura::Nada,
+            };
+            MarcoPintura { id: m.id, rect: m.rect, rot_rad: m.rot_rad, pintura }
+        })
+        .collect();
+    let paso_id = rec.pasos.get(state.paso).copied();
+    // Centros (coords de mundo) de los marcos en orden de la ruta, para pintar
+    // el camino narrativo. Los pasos cuyo id no resuelve se omiten.
+    let ruta: Vec<(f64, f64)> =
+        rec.pasos.iter().filter_map(|id| rec.marco(*id)).map(|m| m.rect.centro()).collect();
+    let camara = state.camara;
+    let paso = state.paso;
+    let n_pasos = rec.n_pasos();
+    View::new(Style {
+        size: Size { width: percent(1.0_f32), height: percent(1.0_f32) },
+        ..Default::default()
+    })
+    .fill(FONDO)
+    .paint_with(move |scene, ts, rect: PaintRect| {
+        panel_set(to_rect(rect));
+        pintar(scene, ts, rect, &pinturas, &ruta, paso_id, seleccionado, &camara);
+        pintar_hud(scene, ts, rect, paso, n_pasos);
+    })
+}
+
+fn to_rect(r: PaintRect) -> Rect {
+    Rect::new(r.x as f64, r.y as f64, r.w as f64, r.h as f64)
+}
+
+/// Affine mundo→pantalla de una cámara, dado el rect del panel.
+/// `pantalla = centro_panel + escala(zoom) · rot(-rot) · (mundo - centro)`.
+fn world_to_screen_affine(cam: &Camara, panel: Rect) -> Affine {
+    let pcx = panel.x + panel.w * 0.5;
+    let pcy = panel.y + panel.h * 0.5;
+    Affine::translate((pcx, pcy))
+        * Affine::scale(cam.zoom)
+        * Affine::rotate(-cam.rot_rad)
+        * Affine::translate((-cam.centro.0, -cam.centro.1))
+}
+
+fn pintar(
+    scene: &mut llimphi_ui::llimphi_raster::vello::Scene,
+    ts: &mut llimphi_ui::llimphi_text::Typesetter,
+    rect: PaintRect,
+    marcos: &[MarcoPintura],
+    ruta: &[(f64, f64)],
+    paso_id: Option<MarcoId>,
+    seleccionado: Option<MarcoId>,
+    cam: &Camara,
+) {
+    if rect.w <= 0.0 || rect.h <= 0.0 {
+        return;
+    }
+    let panel = to_rect(rect);
+    let w2s = world_to_screen_affine(cam, panel);
+
+    // Clip al panel: un marco con zoom-in no debe derramar fuera del nodo.
+    let node = KurboRect::new(
+        rect.x as f64,
+        rect.y as f64,
+        (rect.x + rect.w) as f64,
+        (rect.y + rect.h) as f64,
+    );
+    scene.push_layer(Mix::Clip, 1.0, Affine::IDENTITY, &node);
+
+    // Camino narrativo por detrás de los marcos.
+    pintar_ruta(scene, cam, panel, ruta);
+
+    for m in marcos {
+        let (mcx, mcy) = m.rect.centro();
+        // Giro propio del marco alrededor de su centro, encadenado al mundo→pantalla.
+        let xf = w2s
+            * Affine::translate((mcx, mcy))
+            * Affine::rotate(m.rot_rad)
+            * Affine::translate((-mcx, -mcy));
+        let kr = KurboRect::new(
+            m.rect.x,
+            m.rect.y,
+            m.rect.x + m.rect.w,
+            m.rect.y + m.rect.h,
+        );
+        scene.fill(Fill::NonZero, xf, MARCO_FONDO, None, &kr);
+
+        // La imagen se pinta encajada en el marco (respeta giro/zoom vía `xf`).
+        if let Pintura::Imagen(img) = &m.pintura {
+            pintar_imagen(scene, xf, &m.rect, img);
+        }
+
+        let actual = paso_id == Some(m.id);
+        let (grosor, color) = if actual { (3.0, MARCO_ACENTO) } else { (1.0, MARCO_BORDE) };
+        scene.stroke(&Stroke::new(grosor), xf, color, None, &kr);
+
+        // Realce de selección (autoría): borde ámbar punteado por encima, así se
+        // distingue del acento azul del paso actual aunque coincidan.
+        if seleccionado == Some(m.id) {
+            let sel = Stroke::new(2.5).with_dashes(0.0, [7.0, 5.0]);
+            scene.stroke(&sel, xf, SELECCION, None, &kr);
+        }
+
+        // El texto se pinta en el **espacio local del marco** (origen en su
+        // esquina sup-izq, ejes alineados al marco, 1 unidad = 1 px de pantalla),
+        // así sigue el giro del marco como lo hace su borde. Los tamaños de
+        // fuente se clampan por zoom para seguir legibles lejos/cerca.
+        let local = marco_local_xf(cam, panel, &m.rect, m.rot_rad);
+        let w_px = (m.rect.w * cam.zoom) as f32;
+        let h_px = (m.rect.h * cam.zoom) as f32;
+        match &m.pintura {
+            Pintura::Etiqueta(t) => pintar_etiqueta(scene, ts, local, w_px, h_px, cam.zoom, t),
+            Pintura::Texto { titulo, parrafos } => {
+                pintar_texto(scene, ts, local, w_px, h_px, cam.zoom, titulo.as_deref(), parrafos);
+            }
+            Pintura::Imagen(_) | Pintura::Nada => {}
+        }
+    }
+
+    scene.pop_layer();
+}
+
+/// Pinta el **camino narrativo**: una polilínea punteada que une los centros
+/// de los marcos en orden de la ruta, con un nodo en cada paso. Se dibuja en
+/// espacio de pantalla (grosor constante en px a cualquier zoom) y por detrás
+/// de los marcos. No-op con menos de dos pasos.
+fn pintar_ruta(scene: &mut Scene, cam: &Camara, panel: Rect, ruta: &[(f64, f64)]) {
+    if ruta.len() < 2 {
+        return;
+    }
+    let mut path = BezPath::new();
+    for (i, p) in ruta.iter().enumerate() {
+        let s = cam.world_to_screen(*p, panel);
+        if i == 0 {
+            path.move_to(s);
+        } else {
+            path.line_to(s);
+        }
+    }
+    let trazo = Stroke::new(2.0).with_dashes(0.0, [9.0, 7.0]);
+    scene.stroke(&trazo, Affine::IDENTITY, RUTA, None, &path);
+    for p in ruta {
+        let s = cam.world_to_screen(*p, panel);
+        scene.fill(Fill::NonZero, Affine::IDENTITY, RUTA, None, &Circle::new(s, 3.5));
+    }
+}
+
+/// Pinta `img` encajada en el rect del marco preservando aspect ratio,
+/// centrada y clipeada al marco (en su espacio transformado, así respeta el
+/// giro propio). `xf` es el mundo→pantalla del marco ya con su rotación.
+fn pintar_imagen(scene: &mut Scene, xf: Affine, rect: &Rect, img: &PenikoImage) {
+    let (iw, ih) = (img.width as f64, img.height as f64);
+    if iw <= 0.0 || ih <= 0.0 || rect.w <= 0.0 || rect.h <= 0.0 {
+        return;
+    }
+    let s = (rect.w / iw).min(rect.h / ih);
+    let (dw, dh) = (iw * s, ih * s);
+    let ox = rect.x + (rect.w - dw) * 0.5;
+    let oy = rect.y + (rect.h - dh) * 0.5;
+    let kr = KurboRect::new(rect.x, rect.y, rect.x + rect.w, rect.y + rect.h);
+    // Clip al rect del marco en su propio espacio (xf incluye giro+zoom).
+    scene.push_layer(Mix::Clip, 1.0, xf, &kr);
+    let img_xf = xf * Affine::translate((ox, oy)) * Affine::scale(s);
+    scene.draw_image(img, img_xf);
+    scene.pop_layer();
+}
+
+/// Afín que lleva coordenadas **locales del marco** a pantalla: el origen (0,0)
+/// es su esquina superior-izquierda, los ejes están alineados al marco (rotados
+/// según el giro del marco *relativo a la cámara*) y 1 unidad local = 1 px de
+/// pantalla. Pintar texto en este espacio hace que siga el giro del marco igual
+/// que su borde, sin que el zoom deforme el tamaño de fuente (que se clampa).
+fn marco_local_xf(cam: &Camara, panel: Rect, rect: &Rect, rot_rad: f64) -> Affine {
+    let (scx, scy) = cam.world_to_screen(rect.centro(), panel);
+    // Giro del marco visto en pantalla: la cámara rota -rot, el marco +rot_rad.
+    let ang = rot_rad - cam.rot_rad;
+    let (w_px, h_px) = (rect.w * cam.zoom, rect.h * cam.zoom);
+    Affine::translate((scx, scy))
+        * Affine::rotate(ang)
+        * Affine::translate((-w_px * 0.5, -h_px * 0.5))
+}
+
+/// Etiqueta de una línea centrada en el marco, en su espacio local (sigue el
+/// giro). El tamaño escala con el zoom (clamp para seguir legible lejos/cerca).
+fn pintar_etiqueta(scene: &mut Scene, ts: &mut Ts, local: Affine, w_px: f32, h_px: f32, zoom: f64, t: &str) {
+    if w_px < 12.0 {
+        return; // demasiado chico para texto
+    }
+    let size_px = ((16.0 * zoom) as f32).clamp(9.0, 40.0);
+    let block = TextBlock {
+        text: t,
+        size_px,
+        color: TEXTO,
+        // Centrado vertical aproximado dentro del marco; el `max_width` + center
+        // resuelven el horizontal.
+        origin: (0.0, (h_px as f64 - size_px as f64) * 0.5),
+        max_width: Some(w_px),
+        alignment: Alignment::Center,
+        line_height: 1.2,
+        italic: false,
+        font_family: None,
+    };
+    let layout = layout_block(ts, &block);
+    draw_layout_xf(scene, &layout, block.color, local * Affine::translate(block.origin));
+}
+
+/// Contenido de "slide": título (si hay) + párrafos, fluidos desde la esquina
+/// superior-izquierda del marco, clipeados a su rect — todo en el espacio local
+/// del marco, así el texto gira con él. El apilado usa la altura medida del
+/// título.
+fn pintar_texto(
+    scene: &mut Scene,
+    ts: &mut Ts,
+    local: Affine,
+    w_px: f32,
+    h_px: f32,
+    zoom: f64,
+    titulo: Option<&str>,
+    parrafos: &[String],
+) {
+    if w_px < 40.0 || h_px < 24.0 {
+        return; // demasiado chico para texto fluido
+    }
+    let pad = ((12.0 * zoom) as f32).clamp(5.0, 22.0);
+    let inner_w = (w_px - 2.0 * pad).max(8.0);
+    let left = pad as f64;
+    let mut y = pad as f64;
+
+    // Clip al rect del marco en su espacio local (gira con el marco).
+    let clip = KurboRect::new(0.0, 0.0, w_px as f64, h_px as f64);
+    scene.push_layer(Mix::Clip, 1.0, local, &clip);
+
+    if let Some(tt) = titulo.filter(|s| !s.is_empty()) {
+        let size = ((22.0 * zoom) as f32).clamp(12.0, 46.0);
+        let block = TextBlock {
+            text: tt,
+            size_px: size,
+            color: TEXTO,
+            origin: (left, y),
+            max_width: Some(inner_w),
+            alignment: Alignment::Start,
+            line_height: 1.15,
+            italic: false,
+            font_family: None,
+        };
+        let layout = layout_block(ts, &block);
+        let medida = measurement(&layout);
+        draw_layout_xf(scene, &layout, TEXTO, local * Affine::translate((left, y)));
+        y += medida.height as f64 + ((10.0 * zoom) as f32).clamp(4.0, 18.0) as f64;
+    }
+
+    if !parrafos.is_empty() {
+        let cuerpo = parrafos.join("\n\n");
+        let size = ((15.0 * zoom) as f32).clamp(9.0, 32.0);
+        let block = TextBlock {
+            text: &cuerpo,
+            size_px: size,
+            color: TEXTO_TENUE,
+            origin: (left, y),
+            max_width: Some(inner_w),
+            alignment: Alignment::Start,
+            line_height: 1.35,
+            italic: false,
+            font_family: None,
+        };
+        let layout = layout_block(ts, &block);
+        draw_layout_xf(scene, &layout, TEXTO_TENUE, local * Affine::translate((left, y)));
+    }
+
+    scene.pop_layer();
+}
+
+/// HUD de progreso "paso actual / total" — píldora sobria abajo-centro, pintada
+/// en **espacio de pantalla** (no la afecta la cámara). Orienta al presentador
+/// sin robar protagonismo al lienzo. No-op si la ruta está vacía.
+fn pintar_hud(scene: &mut Scene, ts: &mut Ts, rect: PaintRect, paso: usize, n_pasos: usize) {
+    if n_pasos == 0 || rect.w <= 0.0 || rect.h <= 0.0 {
+        return;
+    }
+    let texto = format!("{} / {}", paso + 1, n_pasos);
+    let block = TextBlock {
+        text: &texto,
+        size_px: 14.0,
+        color: HUD_TEXTO,
+        origin: (0.0, 0.0),
+        max_width: None,
+        alignment: Alignment::Start,
+        line_height: 1.2,
+        italic: false,
+        font_family: None,
+    };
+    let layout = layout_block(ts, &block);
+    let m = measurement(&layout);
+    let (pad_x, pad_y) = (12.0_f64, 6.0_f64);
+    let w = m.width as f64 + pad_x * 2.0;
+    let h = m.height as f64 + pad_y * 2.0;
+    let cx = rect.x as f64 + rect.w as f64 * 0.5;
+    let x0 = cx - w * 0.5;
+    let y0 = rect.y as f64 + rect.h as f64 - h - 16.0;
+    let pill = RoundedRect::new(x0, y0, x0 + w, y0 + h, h * 0.5);
+    scene.fill(Fill::NonZero, Affine::IDENTITY, HUD_FONDO, None, &pill);
+    draw_layout_xf(scene, &layout, HUD_TEXTO, Affine::translate((x0 + pad_x, y0 + pad_y)));
+}
diff --git a/00_unanchay/pluma/pluma-deck-web/Cargo.toml b/00_unanchay/pluma/pluma-deck-web/Cargo.toml
new file mode 100644
index 0000000..8e9de25
--- /dev/null
+++ b/00_unanchay/pluma/pluma-deck-web/Cargo.toml
@@ -0,0 +1,32 @@
+[package]
+name = "pluma-deck-web"
+version.workspace = true
+edition.workspace = true
+license.workspace = true
+authors.workspace = true
+publish.workspace = true
+
+[dependencies]
+pluma-deck-core = { path = "../pluma-deck-core" }
+wasm-bindgen.workspace = true
+js-sys.workspace = true
+
+[dependencies.web-sys]
+workspace = true
+features = [
+    "Window",
+    "Document",
+    "Element",
+    "HtmlElement",
+    "CssStyleDeclaration",
+    "DomTokenList",
+    "Event",
+    "EventTarget",
+    "PointerEvent",
+    "MouseEvent",
+    "WheelEvent",
+    "KeyboardEvent",
+    "HtmlCollection",
+    "DomRect",
+    "AddEventListenerOptions",
+]
diff --git a/00_unanchay/pluma/pluma-deck-web/LEEME.md b/00_unanchay/pluma/pluma-deck-web/LEEME.md
new file mode 100644
index 0000000..fe0b400
--- /dev/null
+++ b/00_unanchay/pluma/pluma-deck-web/LEEME.md
@@ -0,0 +1,54 @@
+# pluma-deck-web
+
+> Deck en navegador para [pluma](../README.md).
+
+Toma un `Deck` de [`pluma-deck-core`](../pluma-deck-core/README.md) y lo renderiza como SPA: slide actual + nav (←/→ teclas), aspect ratio configurable, full-screen, soporta el mismo theme system que el reader.
+
+## API
+
+```rust
+use pluma_deck_web::Presenter;
+
+let p = Presenter::new(container);
+p.cargar(&deck);
+```
+
+## Modo espacial — Recorrido (tipo Prezi)
+
+Espejo web del frontend Llimphi (`pluma-deck-recorrido-llimphi`): en vez del
+strip 1D, un **lienzo infinito** con marcos en coordenadas de mundo y una
+cámara que vuela entre ellos. La lógica (cámara/ruta/gesto) vive en
+[`pluma-deck-core`](../pluma-deck-core/README.md); el binding (`recorrido`)
+sólo aplica la cámara como **un único `transform` CSS** sobre `mundo`
+(estilo impress.js) y delega el vuelo entre pasos a una transición CSS.
+
+### Contrato DOM
+
+```html
+<div class="recorrido-viewport">
+  <div class="recorrido-mundo">
+    <div class="recorrido-marco" data-x="0"   data-y="0" data-w="640" data-h="400">…</div>
+    <div class="recorrido-marco" data-x="900" data-y="0" data-w="640" data-h="400" data-rot="0.1">…</div>
+  </div>
+</div>
+```
+
+Cada marco lleva su rect de **mundo** en `data-{x,y,w,h}` (px) y un giro opcional
+`data-rot` (radianes). El orden DOM define la ruta. El contenido HTML interno es
+libre (texto, `<img>`, etc.).
+
+### API
+
+```rust
+use pluma_deck_web::recorrido::RecorridoWeb;
+
+let r = RecorridoWeb::mount(viewport, mundo)?;
+r.on_change(|paso| { /* … */ });
+// flechas/espacio/enter avanzan; rueda = zoom-a-cursor; arrastrar = paneo.
+r.siguiente();  r.anterior();  r.goto(2, true);
+```
+
+## Deps
+
+- [`pluma-deck-core`](../pluma-deck-core/README.md), [`pluma-md`](../pluma-md/README.md)
+- `wasm-bindgen`, `web-sys`
diff --git a/00_unanchay/pluma/pluma-deck-web/README.md b/00_unanchay/pluma/pluma-deck-web/README.md
new file mode 100644
index 0000000..3386600
--- /dev/null
+++ b/00_unanchay/pluma/pluma-deck-web/README.md
@@ -0,0 +1,19 @@
+# pluma-deck-web
+
+> Browser-side deck for [pluma](../README.md).
+
+Takes a `Deck` from [`pluma-deck-core`](../pluma-deck-core/README.md) and renders it as an SPA: current slide + nav (←/→ keys), configurable aspect ratio, fullscreen, same theme system as the reader.
+
+## API
+
+```rust
+use pluma_deck_web::Presenter;
+
+let p = Presenter::new(container);
+p.cargar(&deck);
+```
+
+## Deps
+
+- [`pluma-deck-core`](../pluma-deck-core/README.md), [`pluma-md`](../pluma-md/README.md)
+- `wasm-bindgen`, `web-sys`
diff --git a/00_unanchay/pluma/pluma-deck-web/src/lib.rs b/00_unanchay/pluma/pluma-deck-web/src/lib.rs
new file mode 100644
index 0000000..071c83b
--- /dev/null
+++ b/00_unanchay/pluma/pluma-deck-web/src/lib.rs
@@ -0,0 +1,212 @@
+//! Vista-web — binding DOM del deck horizontal. Toda la lógica de
+//! decisión de gesto/snap vive en `vista-core`; este crate sólo traduce
+//! `PointerEvent`s en eventos de `DeckState` y aplica el offset al DOM.
+//!
+//! Contrato CSS (el caller provee):
+//! ```css
+//! .vista-deck { overflow: hidden; touch-action: pan-y; }
+//! .vista-strip {
+//!     display: flex;
+//!     width: 100%;
+//!     height: 100%;
+//!     transform: translate3d(var(--vista-offset, 0px), 0, 0);
+//!     transition: transform 360ms cubic-bezier(0.22, 0.61, 0.36, 1);
+//! }
+//! .vista-strip.vista-dragging,
+//! .vista-strip.vista-instant { transition: none; }
+//! .vista-page { flex: 0 0 100%; height: 100%; overflow-y: auto; }
+//! ```
+
+pub mod recorrido;
+
+use std::cell::RefCell;
+use std::rc::Rc;
+
+use pluma_deck_core::{DeckState, DragOutcome};
+use wasm_bindgen::prelude::*;
+use wasm_bindgen::JsCast;
+use web_sys::{Event, HtmlElement, PointerEvent};
+
+#[derive(Clone)]
+pub struct Deck {
+    strip: HtmlElement,
+    inner: Rc<RefCell<Inner>>,
+}
+
+struct Inner {
+    state: DeckState,
+    on_change: Option<Box<dyn FnMut(usize)>>,
+}
+
+impl Deck {
+    pub fn mount(strip: HtmlElement) -> Result<Self, JsValue> {
+        let inner = Rc::new(RefCell::new(Inner {
+            state: DeckState::new(),
+            on_change: None,
+        }));
+        install_pointerdown(&strip, &inner)?;
+        install_pointermove(&strip, &inner)?;
+        install_pointerend(&strip, &inner, "pointerup")?;
+        install_pointerend(&strip, &inner, "pointercancel")?;
+        install_pointerend(&strip, &inner, "pointerleave")?;
+        install_resize(&strip, &inner)?;
+        Ok(Self { strip, inner })
+    }
+
+    pub fn goto(&self, index: usize, smooth: bool) {
+        let width = self.strip.client_width() as f64;
+        let mut i = self.inner.borrow_mut();
+        let r = i.state.goto(index, width);
+        drop(i);
+        if !smooth {
+            let _ = self.strip.class_list().add_1("vista-instant");
+        }
+        set_offset(&self.strip, r.offset_px);
+        if !smooth {
+            clear_instant_next_frame(&self.strip);
+        }
+        if r.changed {
+            let mut i = self.inner.borrow_mut();
+            if let Some(cb) = i.on_change.as_mut() {
+                cb(r.target_index);
+            }
+        }
+    }
+
+    pub fn current_index(&self) -> usize {
+        self.inner.borrow().state.current_index
+    }
+
+    pub fn page_count(&self) -> u32 {
+        self.strip.child_element_count()
+    }
+
+    pub fn on_change<F: FnMut(usize) + 'static>(&self, cb: F) {
+        self.inner.borrow_mut().on_change = Some(Box::new(cb));
+    }
+
+    pub fn strip(&self) -> &HtmlElement {
+        &self.strip
+    }
+}
+
+fn install_pointerdown(strip: &HtmlElement, inner: &Rc<RefCell<Inner>>) -> Result<(), JsValue> {
+    let strip2 = strip.clone();
+    let inner2 = inner.clone();
+    let cb = Closure::<dyn FnMut(PointerEvent)>::new(move |e: PointerEvent| {
+        let width = strip2.client_width() as f64;
+        inner2.borrow_mut().state.pointer_down(
+            e.client_x() as f64,
+            e.client_y() as f64,
+            e.pointer_id(),
+            width,
+        );
+    });
+    strip.add_event_listener_with_callback("pointerdown", cb.as_ref().unchecked_ref())?;
+    cb.forget();
+    Ok(())
+}
+
+fn install_pointermove(strip: &HtmlElement, inner: &Rc<RefCell<Inner>>) -> Result<(), JsValue> {
+    let strip2 = strip.clone();
+    let inner2 = inner.clone();
+    let cb = Closure::<dyn FnMut(PointerEvent)>::new(move |e: PointerEvent| {
+        let outcome = inner2
+            .borrow_mut()
+            .state
+            .pointer_move(e.client_x() as f64, e.client_y() as f64);
+        match outcome {
+            DragOutcome::StartHorizontal { pointer_id } => {
+                let _ = strip2.class_list().add_1("vista-dragging");
+                let _ = strip2.set_pointer_capture(pointer_id);
+            }
+            DragOutcome::DragOffset(offset) => {
+                set_offset(&strip2, offset);
+                e.prevent_default();
+            }
+            DragOutcome::Idle | DragOutcome::CancelVertical => {}
+        }
+    });
+    let opts = web_sys::AddEventListenerOptions::new();
+    opts.set_passive(false);
+    strip.add_event_listener_with_callback_and_add_event_listener_options(
+        "pointermove",
+        cb.as_ref().unchecked_ref(),
+        &opts,
+    )?;
+    cb.forget();
+    Ok(())
+}
+
+fn install_pointerend(
+    strip: &HtmlElement,
+    inner: &Rc<RefCell<Inner>>,
+    event_name: &str,
+) -> Result<(), JsValue> {
+    let strip2 = strip.clone();
+    let inner2 = inner.clone();
+    let cb = Closure::<dyn FnMut(PointerEvent)>::new(move |e: PointerEvent| {
+        let width = strip2.client_width() as f64;
+        let offset = current_offset_px(&strip2);
+        let n_pages = strip2.child_element_count() as usize;
+        let res = inner2.borrow_mut().state.pointer_end(offset, width, n_pages);
+        let _ = strip2.class_list().remove_1("vista-dragging");
+        let _ = strip2.release_pointer_capture(e.pointer_id());
+        if let Some(r) = res {
+            set_offset(&strip2, r.offset_px);
+            if r.changed {
+                let mut i = inner2.borrow_mut();
+                if let Some(cb) = i.on_change.as_mut() {
+                    cb(r.target_index);
+                }
+            }
+        }
+    });
+    strip.add_event_listener_with_callback(event_name, cb.as_ref().unchecked_ref())?;
+    cb.forget();
+    Ok(())
+}
+
+fn install_resize(strip: &HtmlElement, inner: &Rc<RefCell<Inner>>) -> Result<(), JsValue> {
+    let Some(window) = web_sys::window() else { return Ok(()) };
+    let strip2 = strip.clone();
+    let inner2 = inner.clone();
+    let cb = Closure::<dyn FnMut()>::new(move || {
+        let width = strip2.client_width() as f64;
+        let offset = inner2.borrow().state.reposition(width);
+        let _ = strip2.class_list().add_1("vista-instant");
+        set_offset(&strip2, offset);
+        clear_instant_next_frame(&strip2);
+    });
+    window.add_event_listener_with_callback("resize", cb.as_ref().unchecked_ref())?;
+    cb.forget();
+    Ok(())
+}
+
+fn set_offset(strip: &HtmlElement, offset_px: f64) {
+    let _ = strip
+        .style()
+        .set_property("--vista-offset", &format!("{}px", offset_px));
+}
+
+fn current_offset_px(strip: &HtmlElement) -> f64 {
+    let s = strip
+        .style()
+        .get_property_value("--vista-offset")
+        .unwrap_or_default();
+    s.trim().trim_end_matches("px").parse::<f64>().unwrap_or(0.0)
+}
+
+fn clear_instant_next_frame(strip: &HtmlElement) {
+    let strip2 = strip.clone();
+    let cb = Closure::once(Box::new(move || {
+        let _ = strip2.class_list().remove_1("vista-instant");
+    }) as Box<dyn FnOnce()>);
+    if let Some(w) = web_sys::window() {
+        let _ = w.request_animation_frame(cb.as_ref().unchecked_ref());
+    }
+    cb.forget();
+}
+
+#[doc(hidden)]
+pub fn __unused_event_marker(_e: &Event) {}
diff --git a/00_unanchay/pluma/pluma-deck-web/src/recorrido.rs b/00_unanchay/pluma/pluma-deck-web/src/recorrido.rs
new file mode 100644
index 0000000..a4958fb
--- /dev/null
+++ b/00_unanchay/pluma/pluma-deck-web/src/recorrido.rs
@@ -0,0 +1,603 @@
+//! Vista-web **espacial** — binding DOM del modo Recorrido (tipo Prezi).
+//!
+//! Espejo web del frontend Llimphi (`pluma-deck-recorrido-llimphi`): la lógica
+//! de cámara/ruta/gesto vive entera en `pluma-deck-core`; aquí sólo se traduce
+//! pointer/wheel/teclado → llamadas al core y se aplica la cámara como **un
+//! único `transform` CSS** sobre un contenedor `mundo` (estilo impress.js).
+//!
+//! A diferencia del strip lineal ([`crate::Deck`], `translate3d` 1D), el modo
+//! espacial coloca cada marco en coordenadas de mundo dentro de `mundo` y
+//! mueve la cámara sobre todos ellos. El vuelo guiado entre pasos se delega a
+//! una **transición CSS** del transform (el core sólo provee la cámara objetivo
+//! vía `fit`), igual que el strip delega su deslizamiento a `transition`.
+//!
+//! Contrato DOM (el caller provee):
+//! ```html
+//! <div class="recorrido-viewport">
+//!   <div class="recorrido-mundo">
+//!     <div class="recorrido-marco" data-x="0"   data-y="0" data-w="640" data-h="400">…</div>
+//!     <div class="recorrido-marco" data-x="900" data-y="0" data-w="640" data-h="400" data-rot="0.1">…</div>
+//!   </div>
+//! </div>
+//! ```
+//! Cada `.recorrido-marco` lleva su rect de **mundo** en `data-{x,y,w,h}` (px) y
+//! un giro opcional `data-rot` (radianes). El orden DOM define la ruta. El
+//! binding posiciona los marcos y mueve la cámara; el contenido HTML interno es
+//! libre (texto, `<img>`, lo que sea).
+
+use std::cell::RefCell;
+use std::rc::Rc;
+
+use pluma_deck_core::{Camara, ContenidoMarco, Marco, Recorrido, RecorridoState, Rect, DURACION_PASO_S};
+use wasm_bindgen::prelude::*;
+use wasm_bindgen::JsCast;
+use web_sys::{HtmlElement, KeyboardEvent, PointerEvent, WheelEvent};
+
+/// Base de zoom por "clic" de rueda (igual criterio que tullpu y el frontend Llimphi).
+pub const ZOOM_BASE: f64 = 1.1;
+/// Normalizador del `deltaY` de la rueda a "notches" de zoom: deltaY en modo
+/// pixel ronda ±100 por muesca, así que dividir por esto da ~1 notch por muesca
+/// y a la vez deja que el pinch de trackpad (deltas chicos) zoomee proporcional.
+const WHEEL_NORM: f64 = 100.0;
+/// Permanencia por defecto del modo presentador, en ms (espejo de `DWELL_S`).
+pub const DWELL_MS: i32 = 2500;
+
+/// Curva del vuelo entre pasos (misma que el strip lineal: salida suave).
+const EASE_VUELO: &str = "cubic-bezier(0.22, 0.61, 0.36, 1)";
+
+#[derive(Clone)]
+pub struct RecorridoWeb {
+    viewport: HtmlElement,
+    mundo: HtmlElement,
+    inner: Rc<RefCell<Inner>>,
+}
+
+struct Inner {
+    rec: Recorrido,
+    state: RecorridoState,
+    /// `Some((x,y))` = paneando desde esa última posición de pointer.
+    arrastrando: Option<(f64, f64)>,
+    on_change: Option<Box<dyn FnMut(usize)>>,
+    /// Handle del `setInterval` del modo presentador (autoplay), si está activo.
+    autoplay_id: Option<i32>,
+    /// Closure del intervalo — se guarda para mantenerla viva mientras corre.
+    autoplay_cb: Option<Closure<dyn FnMut()>>,
+}
+
+impl RecorridoWeb {
+    /// Monta el recorrido sobre `viewport` (clip) y `mundo` (contenedor
+    /// transformado). Lee los `.recorrido-marco` hijos de `mundo`, los posiciona
+    /// en coordenadas de mundo y encuadra el primero.
+    pub fn mount(viewport: HtmlElement, mundo: HtmlElement) -> Result<Self, JsValue> {
+        estilo(&viewport, "overflow", "hidden");
+        estilo(&viewport, "position", "relative");
+        estilo(&viewport, "touch-action", "none");
+        estilo(&viewport, "user-select", "none");
+        estilo(&mundo, "position", "absolute");
+        estilo(&mundo, "left", "0");
+        estilo(&mundo, "top", "0");
+        estilo(&mundo, "transform-origin", "0 0");
+        estilo(&mundo, "will-change", "transform");
+
+        let rec = leer_marcos(&mundo);
+        let mut state = RecorridoState::new();
+        let panel = panel_de(&viewport);
+        state.saltar_a_paso(&rec, 0, panel);
+
+        let inner = Rc::new(RefCell::new(Inner {
+            rec,
+            state,
+            arrastrando: None,
+            on_change: None,
+            autoplay_id: None,
+            autoplay_cb: None,
+        }));
+        aplicar_camara(&mundo, &inner.borrow().state.camara, panel, false);
+
+        let this = Self { viewport, mundo, inner };
+        this.install_pointer()?;
+        this.install_wheel()?;
+        this.install_keys()?;
+        Ok(this)
+    }
+
+    fn panel(&self) -> Rect {
+        panel_de(&self.viewport)
+    }
+
+    fn aplicar(&self, transicion: bool) {
+        let panel = self.panel();
+        aplicar_camara(&self.mundo, &self.inner.borrow().state.camara, panel, transicion);
+    }
+
+    /// Vuela a encuadrar el paso `idx` (con transición si `smooth`). Notifica
+    /// `on_change` si cambió el paso.
+    pub fn goto(&self, idx: usize, smooth: bool) {
+        let panel = self.panel();
+        let mut i = self.inner.borrow_mut();
+        let antes = i.state.paso;
+        let Inner { rec, state, .. } = &mut *i;
+        state.saltar_a_paso(rec, idx, panel);
+        let ahora = state.paso;
+        drop(i);
+        self.aplicar(smooth);
+        if ahora != antes {
+            if let Some(cb) = self.inner.borrow_mut().on_change.as_mut() {
+                cb(ahora);
+            }
+        }
+    }
+
+    /// Paso siguiente (clamp al final). `true` si se movió.
+    pub fn siguiente(&self) -> bool {
+        let i = self.inner.borrow();
+        let n = i.rec.n_pasos();
+        let p = i.state.paso;
+        drop(i);
+        if n == 0 || p + 1 >= n {
+            return false;
+        }
+        self.goto(p + 1, true);
+        true
+    }
+
+    /// Paso anterior (clamp en 0). `true` si se movió.
+    pub fn anterior(&self) -> bool {
+        let p = self.inner.borrow().state.paso;
+        if p == 0 {
+            return false;
+        }
+        self.goto(p - 1, true);
+        true
+    }
+
+    /// Vuela a la **vista general** (aleja para encuadrar todo el lienzo). Gesto
+    /// Prezi de "ver el mapa". No cambia el paso narrativo. Igual que `goto`, el
+    /// vuelo es una transición CSS: se fija la cámara objetivo del core (instante)
+    /// y el navegador la anima — el core nunca tickea `avanzar` en web.
+    pub fn vista_general(&self) {
+        let panel = self.panel();
+        {
+            let mut i = self.inner.borrow_mut();
+            let Some(bbox) = i.rec.bbox() else { return };
+            i.state.camara = Camara::fit(bbox, 0.0, panel);
+        }
+        self.aplicar(true);
+    }
+
+    pub fn paso_actual(&self) -> usize {
+        self.inner.borrow().state.paso
+    }
+
+    /// Exporta el recorrido a un documento **HTML autocontenido** (un solo
+    /// `.html` sin servidor ni `.wasm`): lee los `.recorrido-marco` vivos del
+    /// DOM (geometría de mundo + su HTML interno) y emite un documento con CSS y
+    /// un JS vanilla que replica la cámara del core (fit, zoom-a-cursor, pan,
+    /// pasos, vista general). Para publicar la presentación offline.
+    pub fn exportar_html(&self, titulo: &str) -> String {
+        let hijos = self.mundo.children();
+        let mut marcos = Vec::new();
+        for idx in 0..hijos.length() {
+            let Some(el) = hijos.item(idx).and_then(|e| e.dyn_into::<HtmlElement>().ok()) else {
+                continue;
+            };
+            marcos.push(MarcoExport {
+                x: attr_f64(&el, "data-x", 0.0),
+                y: attr_f64(&el, "data-y", 0.0),
+                w: attr_f64(&el, "data-w", 640.0),
+                h: attr_f64(&el, "data-h", 400.0),
+                rot: attr_f64(&el, "data-rot", 0.0),
+                html: el.inner_html(),
+            });
+        }
+        recorrido_a_html(titulo, &marcos)
+    }
+
+    /// `true` si el modo presentador (autoplay) está corriendo.
+    pub fn autoplay_activo(&self) -> bool {
+        self.inner.borrow().autoplay_id.is_some()
+    }
+
+    /// Arranca el modo presentador: cada `dur_paso + dwell_ms` avanza un paso
+    /// solo (vuelve al inicio al llegar al final). Cancela cualquier autoplay
+    /// previo. Espejo vivo del `setInterval` del HTML exportado.
+    pub fn iniciar_autoplay(&self, dwell_ms: i32) {
+        self.detener_autoplay();
+        let Some(window) = web_sys::window() else { return };
+        let this = self.clone();
+        let cb = Closure::<dyn FnMut()>::new(move || {
+            let (paso, n) = {
+                let i = this.inner.borrow();
+                (i.state.paso, i.rec.n_pasos())
+            };
+            if n == 0 {
+                return;
+            }
+            this.goto(if paso + 1 < n { paso + 1 } else { 0 }, true);
+        });
+        let periodo = (DURACION_PASO_S * 1000.0) as i32 + dwell_ms.max(0);
+        if let Ok(id) = window.set_interval_with_callback_and_timeout_and_arguments_0(
+            cb.as_ref().unchecked_ref(),
+            periodo,
+        ) {
+            let mut i = self.inner.borrow_mut();
+            i.autoplay_id = Some(id);
+            i.autoplay_cb = Some(cb);
+        }
+    }
+
+    /// Detiene el modo presentador (no-op si no estaba activo).
+    pub fn detener_autoplay(&self) {
+        let mut i = self.inner.borrow_mut();
+        if let Some(id) = i.autoplay_id.take() {
+            if let Some(w) = web_sys::window() {
+                w.clear_interval_with_handle(id);
+            }
+        }
+        i.autoplay_cb = None;
+    }
+
+    /// Alterna el modo presentador con el dwell por defecto. Devuelve el nuevo
+    /// estado (`true` = corriendo).
+    pub fn toggle_autoplay(&self) -> bool {
+        if self.autoplay_activo() {
+            self.detener_autoplay();
+            false
+        } else {
+            self.iniciar_autoplay(DWELL_MS);
+            true
+        }
+    }
+
+    pub fn on_change<F: FnMut(usize) + 'static>(&self, cb: F) {
+        self.inner.borrow_mut().on_change = Some(Box::new(cb));
+    }
+
+    // ---- Cableado de eventos --------------------------------------------
+
+    fn install_pointer(&self) -> Result<(), JsValue> {
+        // down: arranca paneo (cancela cualquier vuelo, control manual).
+        {
+            let this = self.clone();
+            let cb = Closure::<dyn FnMut(PointerEvent)>::new(move |e: PointerEvent| {
+                this.inner.borrow_mut().arrastrando = Some((e.client_x() as f64, e.client_y() as f64));
+                let _ = this.viewport.set_pointer_capture(e.pointer_id());
+            });
+            self.viewport
+                .add_event_listener_with_callback("pointerdown", cb.as_ref().unchecked_ref())?;
+            cb.forget();
+        }
+        // move: si hay arrastre, panea por el delta de pantalla (sin transición).
+        {
+            let this = self.clone();
+            let cb = Closure::<dyn FnMut(PointerEvent)>::new(move |e: PointerEvent| {
+                let (x, y) = (e.client_x() as f64, e.client_y() as f64);
+                let mut i = this.inner.borrow_mut();
+                let Some((px, py)) = i.arrastrando else { return };
+                i.state.arrastrar_delta(x - px, y - py);
+                i.arrastrando = Some((x, y));
+                drop(i);
+                this.aplicar(false);
+            });
+            self.viewport
+                .add_event_listener_with_callback("pointermove", cb.as_ref().unchecked_ref())?;
+            cb.forget();
+        }
+        // up/cancel/leave: fin del paneo.
+        for ev in ["pointerup", "pointercancel", "pointerleave"] {
+            let this = self.clone();
+            let cb = Closure::<dyn FnMut(PointerEvent)>::new(move |e: PointerEvent| {
+                this.inner.borrow_mut().arrastrando = None;
+                let _ = this.viewport.release_pointer_capture(e.pointer_id());
+            });
+            self.viewport
+                .add_event_listener_with_callback(ev, cb.as_ref().unchecked_ref())?;
+            cb.forget();
+        }
+        Ok(())
+    }
+
+    fn install_wheel(&self) -> Result<(), JsValue> {
+        let this = self.clone();
+        let cb = Closure::<dyn FnMut(WheelEvent)>::new(move |e: WheelEvent| {
+            e.prevent_default();
+            let rect = this.viewport.get_bounding_client_rect();
+            let cursor = (e.client_x() as f64 - rect.left(), e.client_y() as f64 - rect.top());
+            // deltaY>0 ⇒ scroll abajo ⇒ alejar (convención CSS, igual que tullpu).
+            // Proporcional al delta (no sólo al signo) para que el pinch de
+            // trackpad sea suave; normalizado por `WHEEL_NORM` (un "notch" de
+            // rueda ≈ ±100px en modo pixel) y acotado para que un golpe fuerte no
+            // teletransporte el zoom.
+            let pasos = (e.delta_y() / WHEEL_NORM).clamp(-3.0, 3.0);
+            let mult = ZOOM_BASE.powf(-pasos);
+            let panel = this.panel();
+            this.inner.borrow_mut().state.wheel(mult, cursor, panel);
+            this.aplicar(false);
+        });
+        let opts = web_sys::AddEventListenerOptions::new();
+        opts.set_passive(false);
+        self.viewport.add_event_listener_with_callback_and_add_event_listener_options(
+            "wheel",
+            cb.as_ref().unchecked_ref(),
+            &opts,
+        )?;
+        cb.forget();
+        Ok(())
+    }
+
+    fn install_keys(&self) -> Result<(), JsValue> {
+        let Some(window) = web_sys::window() else { return Ok(()) };
+        let this = self.clone();
+        let cb = Closure::<dyn FnMut(KeyboardEvent)>::new(move |e: KeyboardEvent| {
+            let movido = match e.key().as_str() {
+                "ArrowRight" | "ArrowDown" | " " | "Spacebar" | "Enter" => this.siguiente(),
+                "ArrowLeft" | "ArrowUp" => this.anterior(),
+                "Home" | "Escape" => {
+                    this.vista_general();
+                    true
+                }
+                "p" | "P" => {
+                    this.toggle_autoplay();
+                    true
+                }
+                _ => return,
+            };
+            if movido {
+                e.prevent_default();
+            }
+        });
+        window.add_event_listener_with_callback("keydown", cb.as_ref().unchecked_ref())?;
+        cb.forget();
+        Ok(())
+    }
+}
+
+// ---- Lectura del DOM + geometría -----------------------------------------
+
+/// Construye el `Recorrido` del core leyendo los `.recorrido-marco` hijos de
+/// `mundo`, y posiciona cada uno en coordenadas de mundo. El orden DOM define
+/// la ruta; los ids se asignan `1..=n`.
+fn leer_marcos(mundo: &HtmlElement) -> Recorrido {
+    let mut rec = Recorrido::new();
+    let hijos = mundo.children();
+    for idx in 0..hijos.length() {
+        let Some(el) = hijos.item(idx).and_then(|e| e.dyn_into::<HtmlElement>().ok()) else { continue };
+        let x = attr_f64(&el, "data-x", 0.0);
+        let y = attr_f64(&el, "data-y", 0.0);
+        let w = attr_f64(&el, "data-w", 640.0);
+        let h = attr_f64(&el, "data-h", 400.0);
+        let rot = attr_f64(&el, "data-rot", 0.0);
+        // El marco vive en coordenadas de mundo dentro de `mundo`: left/top/size
+        // en px-mundo y su giro propio alrededor del centro (transform-origin
+        // por defecto = center), espejando el render Llimphi.
+        estilo(&el, "position", "absolute");
+        estilo(&el, "left", &format!("{x}px"));
+        estilo(&el, "top", &format!("{y}px"));
+        estilo(&el, "width", &format!("{w}px"));
+        estilo(&el, "height", &format!("{h}px"));
+        estilo(&el, "box-sizing", "border-box");
+        if rot != 0.0 {
+            estilo(&el, "transform", &format!("rotate({rot}rad)"));
+        }
+        let id = (idx + 1) as u64;
+        rec.agregar_marco(Marco::new(id, Rect::new(x, y, w, h), ContenidoMarco::Vacio).con_giro(rot));
+        rec.pasos.push(id);
+    }
+    rec
+}
+
+fn panel_de(viewport: &HtmlElement) -> Rect {
+    Rect::new(0.0, 0.0, viewport.client_width() as f64, viewport.client_height() as f64)
+}
+
+/// Construye el `transform` CSS de la cámara: réplica exacta de
+/// `Camara::world_to_screen` (`centro_panel + zoom·R(-rot)·(mundo - centro)`)
+/// como cadena aplicable a `mundo` con `transform-origin: 0 0`.
+fn camara_css(cam: &Camara, panel: Rect) -> String {
+    let (pcx, pcy) = panel.centro();
+    format!(
+        "translate({pcx}px,{pcy}px) scale({z}) rotate({r}rad) translate({cx}px,{cy}px)",
+        z = cam.zoom,
+        r = -cam.rot_rad,
+        cx = -cam.centro.0,
+        cy = -cam.centro.1,
+    )
+}
+
+fn aplicar_camara(mundo: &HtmlElement, cam: &Camara, panel: Rect, transicion: bool) {
+    let trans = if transicion {
+        format!("transform {}ms {EASE_VUELO}", (DURACION_PASO_S * 1000.0) as i64)
+    } else {
+        "none".to_string()
+    };
+    estilo(mundo, "transition", &trans);
+    estilo(mundo, "transform", &camara_css(cam, panel));
+}
+
+fn estilo(el: &HtmlElement, prop: &str, val: &str) {
+    let _ = el.style().set_property(prop, val);
+}
+
+fn attr_f64(el: &HtmlElement, name: &str, def: f64) -> f64 {
+    el.get_attribute(name).and_then(|s| s.trim().parse::<f64>().ok()).unwrap_or(def)
+}
+
+// ---- Export a HTML autocontenido -----------------------------------------
+
+/// Un marco listo para exportar: su rect de mundo + giro + su HTML interno.
+#[derive(Clone, Debug, PartialEq)]
+pub struct MarcoExport {
+    pub x: f64,
+    pub y: f64,
+    pub w: f64,
+    pub h: f64,
+    pub rot: f64,
+    /// HTML interno del marco (texto, `<img>`, lo que sea).
+    pub html: String,
+}
+
+/// Genera un documento HTML **autocontenido** desde los marcos (función pura,
+/// sin DOM — testeable en host). Embebe el CSS y un JS vanilla que replica la
+/// cámara de `pluma-deck-core` (`fit`/`zoom_a_cursor`/`pan`/pasos/`vista_general`)
+/// para que el `.html` resultante presente offline sin servidor ni `.wasm`.
+pub fn recorrido_a_html(titulo: &str, marcos: &[MarcoExport]) -> String {
+    let t = escapar_html(titulo);
+    let mut cuerpo = String::new();
+    for m in marcos {
+        let rot = if m.rot != 0.0 {
+            format!(";transform:rotate({}rad)", m.rot)
+        } else {
+            String::new()
+        };
+        cuerpo.push_str(&format!(
+            "<div class=\"recorrido-marco\" data-x=\"{x}\" data-y=\"{y}\" data-w=\"{w}\" data-h=\"{h}\" data-rot=\"{r}\" \
+             style=\"left:{x}px;top:{y}px;width:{w}px;height:{h}px{rot}\">{html}</div>",
+            x = m.x, y = m.y, w = m.w, h = m.h, r = m.rot, rot = rot, html = m.html,
+        ));
+    }
+    format!(
+        "<!DOCTYPE html>\n<html lang=\"es\"><head><meta charset=\"utf-8\">\
+         <meta name=\"viewport\" content=\"width=device-width,initial-scale=1\">\
+         <title>{t}</title><style>{css}</style></head><body>\
+         <div class=\"recorrido-viewport\"><div class=\"recorrido-mundo\">{cuerpo}</div>\
+         <div class=\"recorrido-hud\"></div></div><script>{js}</script></body></html>",
+        t = t, css = EXPORT_CSS, cuerpo = cuerpo, js = EXPORT_JS,
+    )
+}
+
+/// Escape mínimo para insertar texto en HTML (sólo para el `<title>`).
+fn escapar_html(s: &str) -> String {
+    s.replace('&', "&amp;").replace('<', "&lt;").replace('>', "&gt;")
+}
+
+const EXPORT_CSS: &str = "\
+html,body{margin:0;height:100%;background:#12141c;font-family:system-ui,sans-serif}\
+.recorrido-viewport{position:relative;width:100vw;height:100vh;overflow:hidden;touch-action:none;user-select:none}\
+.recorrido-mundo{position:absolute;left:0;top:0;transform-origin:0 0;will-change:transform}\
+.recorrido-marco{position:absolute;box-sizing:border-box;background:#262a38;border:1px solid #505668;\
+border-radius:6px;color:#e1e6f0;padding:20px;overflow:hidden}\
+.recorrido-marco h1,.recorrido-marco h2{margin:0 0 .4em;color:#fff}\
+.recorrido-marco img{max-width:100%;max-height:100%;object-fit:contain}\
+.recorrido-hud{position:fixed;left:50%;bottom:16px;transform:translateX(-50%);\
+background:rgba(12,14,20,.78);color:#cdd4e2;padding:6px 12px;border-radius:999px;font-size:14px;pointer-events:none}";
+
+// JS vanilla — espejo de RecorridoWeb/Camara. Se inserta como argumento de
+// format!, así sus llaves no necesitan escaparse.
+const EXPORT_JS: &str = r#"(function(){
+var vp=document.querySelector('.recorrido-viewport');
+var mundo=document.querySelector('.recorrido-mundo');
+var hud=document.querySelector('.recorrido-hud');
+var ZB=1.1,FIT=0.9,ZMIN=0.02,ZMAX=64,DUR=800,WN=100,DWELL=2500;
+var autoTimer=null;
+var EASE='transform '+DUR+'ms cubic-bezier(0.22,0.61,0.36,1)';
+var marcos=[].slice.call(mundo.children).map(function(el){return{
+ x:+el.dataset.x||0,y:+el.dataset.y||0,w:+el.dataset.w||640,h:+el.dataset.h||400,rot:+el.dataset.rot||0};});
+var cam={cx:0,cy:0,zoom:1,rot:0},paso=0;
+function P(){return{w:vp.clientWidth,h:vp.clientHeight};}
+function clamp(z){return Math.max(ZMIN,Math.min(ZMAX,z));}
+function fit(m){var p=P();var zw=m.w>0?p.w/m.w:1,zh=m.h>0?p.h/m.h:1;
+ return{cx:m.x+m.w/2,cy:m.y+m.h/2,zoom:clamp(Math.min(zw,zh)*FIT),rot:m.rot};}
+function fitAll(){if(!marcos.length)return cam;var p=P();
+ var minx=1/0,miny=1/0,maxx=-1/0,maxy=-1/0;
+ marcos.forEach(function(m){var hw=m.w/2,hh=m.h/2,c=Math.abs(Math.cos(m.rot)),s=Math.abs(Math.sin(m.rot));
+  var ex=hw*c+hh*s,ey=hw*s+hh*c,cx=m.x+hw,cy=m.y+hh;
+  minx=Math.min(minx,cx-ex);miny=Math.min(miny,cy-ey);maxx=Math.max(maxx,cx+ex);maxy=Math.max(maxy,cy+ey);});
+ var bw=maxx-minx,bh=maxy-miny;var zw=bw>0?p.w/bw:1,zh=bh>0?p.h/bh:1;
+ return{cx:(minx+maxx)/2,cy:(miny+maxy)/2,zoom:clamp(Math.min(zw,zh)*FIT),rot:0};}
+function apply(smooth){var p=P();mundo.style.transition=smooth?EASE:'none';
+ mundo.style.transform='translate('+(p.w/2)+'px,'+(p.h/2)+'px) scale('+cam.zoom+') rotate('+(-cam.rot)+'rad) translate('+(-cam.cx)+'px,'+(-cam.cy)+'px)';
+ if(hud)hud.textContent=(paso+1)+' / '+marcos.length;}
+function s2w(px,py){var p=P();var sx=(px-p.w/2)/cam.zoom,sy=(py-p.h/2)/cam.zoom;
+ var c=Math.cos(cam.rot),s=Math.sin(cam.rot);return[cam.cx+sx*c-sy*s,cam.cy+sx*s+sy*c];}
+function goto(i,smooth){if(i<0||i>=marcos.length)return;paso=i;cam=fit(marcos[i]);apply(smooth);}
+function setAuto(on){if(autoTimer){clearInterval(autoTimer);autoTimer=null;}
+ if(on)autoTimer=setInterval(function(){goto(paso+1<marcos.length?paso+1:0,true);},DUR+DWELL);}
+marcos.forEach(function(m,i){var el=mundo.children[i];el.style.position='absolute';
+ el.style.left=m.x+'px';el.style.top=m.y+'px';el.style.width=m.w+'px';el.style.height=m.h+'px';
+ el.style.boxSizing='border-box';if(m.rot)el.style.transform='rotate('+m.rot+'rad)';});
+vp.addEventListener('wheel',function(e){e.preventDefault();var r=vp.getBoundingClientRect();
+ var cx=e.clientX-r.left,cy=e.clientY-r.top;var pasos=Math.max(-3,Math.min(3,e.deltaY/WN));
+ var mult=Math.pow(ZB,-pasos);var anc=s2w(cx,cy);cam.zoom=clamp(cam.zoom*mult);
+ var p=P();var sx=(cx-p.w/2)/cam.zoom,sy=(cy-p.h/2)/cam.zoom;var c=Math.cos(cam.rot),s=Math.sin(cam.rot);
+ cam.cx=anc[0]-(sx*c-sy*s);cam.cy=anc[1]-(sx*s+sy*c);apply(false);},{passive:false});
+var drag=null;
+vp.addEventListener('pointerdown',function(e){drag=[e.clientX,e.clientY];vp.setPointerCapture(e.pointerId);});
+vp.addEventListener('pointermove',function(e){if(!drag)return;var dx=e.clientX-drag[0],dy=e.clientY-drag[1];
+ drag=[e.clientX,e.clientY];var c=Math.cos(cam.rot),s=Math.sin(cam.rot);
+ cam.cx-=(dx*c-dy*s)/cam.zoom;cam.cy-=(dx*s+dy*c)/cam.zoom;apply(false);});
+['pointerup','pointercancel','pointerleave'].forEach(function(ev){vp.addEventListener(ev,function(){drag=null;});});
+window.addEventListener('keydown',function(e){var k=e.key;
+ if(k==='ArrowRight'||k==='ArrowDown'||k===' '||k==='Spacebar'||k==='Enter'){if(paso+1<marcos.length){goto(paso+1,true);e.preventDefault();}}
+ else if(k==='ArrowLeft'||k==='ArrowUp'){if(paso>0){goto(paso-1,true);e.preventDefault();}}
+ else if(k==='Home'||k==='Escape'){cam=fitAll();apply(true);e.preventDefault();}
+ else if(k==='p'||k==='P'){setAuto(!autoTimer);e.preventDefault();}});
+window.addEventListener('resize',function(){apply(false);});
+goto(0,false);
+})();"#;
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+
+    const PANEL: Rect = Rect { x: 0.0, y: 0.0, w: 800.0, h: 600.0 };
+
+    #[test]
+    fn camara_identidad_centra_el_origen() {
+        // Cámara por defecto (centro 0,0 zoom 1 rot 0): el transform lleva el
+        // origen de mundo al centro del panel.
+        let css = camara_css(&Camara::default(), PANEL);
+        assert_eq!(css, "translate(400px,300px) scale(1) rotate(-0rad) translate(-0px,-0px)");
+    }
+
+    #[test]
+    fn camara_css_refleja_world_to_screen() {
+        // Para varios puntos, evaluar el transform CSS a mano debe coincidir con
+        // Camara::world_to_screen — el CSS es ese mismo afín.
+        let cam = Camara::new((120.0, -40.0), 1.7, 0.3);
+        for &(wx, wy) in &[(0.0, 0.0), (200.0, 50.0), (-80.0, 300.0)] {
+            let esperado = cam.world_to_screen((wx, wy), PANEL);
+            // Aplicar el transform manualmente: translate(pc)·scale·rotate(-rot)·translate(-c).
+            let (pcx, pcy) = PANEL.centro();
+            let (dx, dy) = (wx - cam.centro.0, wy - cam.centro.1);
+            let (s, c) = (-cam.rot_rad).sin_cos();
+            let (rx, ry) = (dx * c - dy * s, dx * s + dy * c);
+            let got = (pcx + rx * cam.zoom, pcy + ry * cam.zoom);
+            assert!((got.0 - esperado.0).abs() < 1e-9, "x: {got:?} vs {esperado:?}");
+            assert!((got.1 - esperado.1).abs() < 1e-9, "y: {got:?} vs {esperado:?}");
+        }
+        // Y la cadena contiene los componentes esperados.
+        let css = camara_css(&cam, PANEL);
+        assert!(css.contains("scale(1.7)"), "{css}");
+        assert!(css.contains("rotate(-0.3rad)"), "{css}");
+    }
+
+    #[test]
+    fn export_html_es_documento_autocontenido() {
+        let marcos = vec![
+            MarcoExport { x: 0.0, y: 0.0, w: 640.0, h: 400.0, rot: 0.0, html: "<h1>Uno</h1>".into() },
+            MarcoExport { x: 900.0, y: 0.0, w: 640.0, h: 400.0, rot: 0.12, html: "<p>Dos</p>".into() },
+        ];
+        let html = recorrido_a_html("Mi <demo>", &marcos);
+        // Documento completo con CSS + JS embebidos (sin recursos externos).
+        assert!(html.starts_with("<!DOCTYPE html>"));
+        assert!(html.contains("<style>") && html.contains("<script>"));
+        assert!(!html.contains("http://") && !html.contains("https://") && !html.contains(".wasm"));
+        // El título se escapa.
+        assert!(html.contains("<title>Mi &lt;demo&gt;</title>"), "{}", &html[..200]);
+        // Cada marco con su geometría de mundo y su HTML interno.
+        assert!(html.contains("data-x=\"900\"") && html.contains("data-rot=\"0.12\""));
+        assert!(html.contains("<h1>Uno</h1>") && html.contains("<p>Dos</p>"));
+        // El JS replica la cámara del core (funciones clave presentes).
+        assert!(html.contains("function fit(") && html.contains("function fitAll("));
+        assert!(html.contains("function s2w(") && html.contains("goto(0,false)"));
+        // Y el modo presentador (autoplay con setInterval, tecla 'p').
+        assert!(html.contains("function setAuto(") && html.contains("setInterval("));
+    }
+
+    #[test]
+    fn export_html_sin_marcos_sigue_siendo_valido() {
+        let html = recorrido_a_html("vacío", &[]);
+        assert!(html.starts_with("<!DOCTYPE html>") && html.ends_with("</html>"));
+        assert!(html.contains("recorrido-mundo"));
+    }
+}
diff --git a/00_unanchay/pluma/pluma-editor-cuerpo/Cargo.toml b/00_unanchay/pluma/pluma-editor-cuerpo/Cargo.toml
new file mode 100644
index 0000000..53b771a
--- /dev/null
+++ b/00_unanchay/pluma/pluma-editor-cuerpo/Cargo.toml
@@ -0,0 +1,14 @@
+[package]
+name = "pluma-editor-cuerpo"
+version.workspace = true
+edition.workspace = true
+license.workspace = true
+authors.workspace = true
+publish.workspace = true
+description = "pluma — sincronía bidireccional cuerpo ↔ texto plano. Un solo control (el texto), átomos por debajo: edit donde se sienta natural, los cambios bajan a `NarrativeAtom`s con el mínimo diff posible."
+
+[dependencies]
+uuid = { workspace = true, features = ["serde"] }
+serde = { workspace = true }
+pluma-core = { path = "../pluma-core" }
+pluma-cuerpo = { path = "../pluma-cuerpo" }
diff --git a/00_unanchay/pluma/pluma-editor-cuerpo/LEEME.md b/00_unanchay/pluma/pluma-editor-cuerpo/LEEME.md
new file mode 100644
index 0000000..29ccec6
--- /dev/null
+++ b/00_unanchay/pluma/pluma-editor-cuerpo/LEEME.md
@@ -0,0 +1,20 @@
+# pluma-editor-cuerpo
+
+> Editor texto ↔ átomos con diff (greedy) para [pluma](../README.md).
+
+`EditorCuerpo { texto, atom_ids }`. `from_cuerpo(c, atoms)` concatena con `SEPARADOR = "\n\n"`. `parrafos()` recupera el split actual. `diff(atoms_originales) -> Vec<CambioAtom>` con greedy por contenido: párrafos que coinciden se saltan, los que difieren emiten `Mutar` reusando el `Uuid` (hebras vivas), sobrantes emiten `Crear` o `Eliminar`. `aplicar_cambios(cambios, nuevos_ids)` extiende/remueve el `atom_ids` tras persistir.
+
+## API
+
+```rust
+use pluma_editor_cuerpo::EditorCuerpo;
+
+let mut e = EditorCuerpo::from_cuerpo(&c, &atoms);
+e.set_texto(nuevo);
+let cambios = e.diff(&atoms_originales);
+```
+
+## Deps
+
+- [`pluma-core`](../pluma-core/README.md), [`pluma-cuerpo`](../pluma-cuerpo/README.md)
+- `serde`, `uuid`
diff --git a/00_unanchay/pluma/pluma-editor-cuerpo/README.md b/00_unanchay/pluma/pluma-editor-cuerpo/README.md
new file mode 100644
index 0000000..dd9544e
--- /dev/null
+++ b/00_unanchay/pluma/pluma-editor-cuerpo/README.md
@@ -0,0 +1,20 @@
+# pluma-editor-cuerpo
+
+> Text ↔ atoms editor with diff (greedy) for [pluma](../README.md).
+
+`EditorCuerpo { texto, atom_ids }`. `from_cuerpo(c, atoms)` concatenates with `SEPARADOR = "\n\n"`. `parrafos()` retrieves the current split. `diff(atoms_originales) -> Vec<CambioAtom>` with greedy content-matching: matching paragraphs are skipped, differing ones emit `Mutar` reusing the `Uuid` (live threads), excess emits `Crear` or `Eliminar`. `aplicar_cambios(cambios, nuevos_ids)` extends/removes `atom_ids` after persisting.
+
+## API
+
+```rust
+use pluma_editor_cuerpo::EditorCuerpo;
+
+let mut e = EditorCuerpo::from_cuerpo(&c, &atoms);
+e.set_texto(nuevo);
+let cambios = e.diff(&atoms_originales);
+```
+
+## Deps
+
+- [`pluma-core`](../pluma-core/README.md), [`pluma-cuerpo`](../pluma-cuerpo/README.md)
+- `serde`, `uuid`
diff --git a/00_unanchay/pluma/pluma-editor-cuerpo/src/lib.rs b/00_unanchay/pluma/pluma-editor-cuerpo/src/lib.rs
new file mode 100644
index 0000000..24a94cc
--- /dev/null
+++ b/00_unanchay/pluma/pluma-editor-cuerpo/src/lib.rs
@@ -0,0 +1,352 @@
+//! `pluma-editor-cuerpo` — sincronía entre un cuerpo (lista ordenada de
+//! `NarrativeAtom`s) y un único buffer de texto plano editable.
+//!
+//! La idea del editor multilienzo es **un solo control para la página
+//! entera**: el usuario ve todos los párrafos concatenados en un
+//! `text-editor` IDE estándar (cursor libre, selección entre párrafos,
+//! undo global) — pero por debajo cada párrafo sigue siendo un
+//! `NarrativeAtom` con `Uuid` propio. Hebras, alineamientos,
+//! transformaciones LLM, persistencia: todo lo que pluma ya hace, sigue
+//! funcionando sin saber que la UI muestra un buffer único.
+//!
+//! Este crate cubre la traducción en los dos sentidos:
+//!
+//! - **Cuerpo → texto**: concatena los atoms con `\n\n` entre cada uno.
+//!   Doble salto es el separador natural de párrafo en markdown y un
+//!   usuario no escribiría doble enter dentro de un mismo párrafo.
+//!
+//! - **Texto → cuerpo**: dado el texto editado + el orden previo de
+//!   atoms + un índice de los contenidos viejos, computa el mínimo
+//!   diff (mutar / crear / eliminar atoms) para que el cuerpo refleje
+//!   el texto. Greedy con anclas por contenido: si un párrafo del texto
+//!   coincide byte-a-byte con un atom viejo, lo reusamos (UUID
+//!   preservado — las hebras siguen vigentes).
+//!
+//! El crate NO renderiza nada. La UI que reuse el `text-editor` IDE
+//! (con sus EditorState/cursor/undo/highlight) vive arriba y consume
+//! estas estructuras para sincronizar el `Buffer` de ropey con el grafo.
+
+#![forbid(unsafe_code)]
+
+use std::collections::HashMap;
+
+use uuid::Uuid;
+
+use pluma_core::NarrativeAtom;
+use pluma_cuerpo::Cuerpo;
+
+/// Separador canónico entre párrafos en el buffer plano.
+/// Doble salto: lo entiende cualquier markdown reader y nadie lo usa
+/// dentro de un párrafo normal.
+pub const SEPARADOR: &str = "\n\n";
+
+/// Diferencia entre dos estados del cuerpo. `EditorCuerpo::diff`
+/// produce esta lista en orden de aplicación; el caller la consume
+/// secuencialmente.
+#[derive(Debug, Clone, PartialEq)]
+pub enum CambioAtom {
+    /// El atom `id` existe en el cuerpo viejo Y en el nuevo, pero su
+    /// contenido cambió. Aplicar: `graph.get_mut(id).set_content(...)`
+    /// + `propagate_mutation(id)`.
+    Mutar { id: Uuid, texto_nuevo: String },
+    /// Un párrafo nuevo apareció. Crear un `NarrativeAtom` con el texto
+    /// y agregarlo al cuerpo en la posición indicada (0-based).
+    Crear { texto: String, posicion: usize },
+    /// El atom `id` ya no aparece en el texto. Removerlo del cuerpo
+    /// (el atom mismo puede quedar en el grafo — el usuario decide).
+    Eliminar { id: Uuid },
+}
+
+/// Vista plana de un cuerpo como texto editable + mapeo a los Uuids
+/// originales.
+#[derive(Debug, Clone, PartialEq, Eq)]
+pub struct EditorCuerpo {
+    /// Texto plano del cuerpo entero: párrafos concatenados con
+    /// `SEPARADOR` entre cada uno. El text-editor IDE edita esto.
+    pub texto: String,
+    /// Uuids de los atoms del cuerpo en el orden en que aparecen en
+    /// `texto`. `atom_ids.len()` siempre coincide con la cantidad de
+    /// párrafos no-vacíos en `texto` AL CONSTRUIRSE (después de un
+    /// edit del usuario puede divergir — la función `diff` resuelve).
+    pub atom_ids: Vec<Uuid>,
+}
+
+impl EditorCuerpo {
+    /// Construye un editor a partir de un cuerpo + el índice de atoms
+    /// del grafo. Los atoms cuyo Uuid no esté en el índice se omiten
+    /// (cuerpo huérfano — un caso de datos corruptos).
+    pub fn from_cuerpo(cuerpo: &Cuerpo, atoms: &HashMap<Uuid, &NarrativeAtom>) -> Self {
+        let mut chunks: Vec<&str> = Vec::with_capacity(cuerpo.orden.len());
+        let mut ids: Vec<Uuid> = Vec::with_capacity(cuerpo.orden.len());
+        for id in &cuerpo.orden {
+            if let Some(atom) = atoms.get(id) {
+                chunks.push(atom.content.as_str());
+                ids.push(*id);
+            }
+        }
+        let texto = chunks.join(SEPARADOR);
+        Self {
+            texto,
+            atom_ids: ids,
+        }
+    }
+
+    /// Lista los párrafos actuales del texto (split por `SEPARADOR`,
+    /// trim de espacios alrededor, vacíos descartados). El resultado
+    /// puede tener tamaño distinto a `atom_ids` después de una edición
+    /// que insertó/eliminó separadores.
+    pub fn parrafos(&self) -> Vec<&str> {
+        self.texto
+            .split(SEPARADOR)
+            .map(str::trim)
+            .filter(|s| !s.is_empty())
+            .collect()
+    }
+
+    /// Reemplaza el texto entero — equivalente a aplicar la salida del
+    /// `text-editor` IDE de un golpe. No toca `atom_ids`; el caller
+    /// debe llamar `diff` después para sincronizar el cuerpo.
+    pub fn set_texto(&mut self, nuevo: impl Into<String>) {
+        self.texto = nuevo.into();
+    }
+
+    /// Calcula el diff mínimo para que el cuerpo (referenciado por
+    /// `atom_ids`) refleje el `texto` actual. Estrategia greedy con
+    /// anclas por contenido:
+    ///
+    /// 1. Recorre los párrafos del texto y los atoms originales en
+    ///    paralelo.
+    /// 2. Si el párrafo i coincide byte-a-byte con el atom i del
+    ///    `atoms_originales`, no hay cambio para esa posición.
+    /// 3. Si difiere, emite `Mutar` reusando el Uuid del atom i (las
+    ///    hebras siguen apuntando al mismo Uuid).
+    /// 4. Si el texto tiene MÁS párrafos que los atoms originales, los
+    ///    sobrantes son `Crear` al final.
+    /// 5. Si el texto tiene MENOS, los atoms sobrantes son `Eliminar`.
+    ///
+    /// `atoms_originales` debe contener los Uuids de `self.atom_ids`
+    /// (los del cuerpo cuando se construyó el editor). El caller suele
+    /// recolectarlos del grafo justo antes de llamar a `diff`.
+    pub fn diff(
+        &self,
+        atoms_originales: &HashMap<Uuid, &NarrativeAtom>,
+    ) -> Vec<CambioAtom> {
+        let parrafos_nuevos = self.parrafos();
+        let mut cambios = Vec::new();
+        let n = parrafos_nuevos.len();
+        let m = self.atom_ids.len();
+        let comun = n.min(m);
+
+        for i in 0..comun {
+            let id_viejo = self.atom_ids[i];
+            let texto_nuevo = parrafos_nuevos[i].to_string();
+            let texto_viejo = atoms_originales
+                .get(&id_viejo)
+                .map(|a| a.content.as_str())
+                .unwrap_or("");
+            if texto_viejo != texto_nuevo {
+                cambios.push(CambioAtom::Mutar {
+                    id: id_viejo,
+                    texto_nuevo,
+                });
+            }
+        }
+        // Sobrantes del texto: párrafos nuevos.
+        for (offset, p) in parrafos_nuevos.iter().enumerate().skip(comun) {
+            cambios.push(CambioAtom::Crear {
+                texto: p.to_string(),
+                posicion: offset,
+            });
+        }
+        // Sobrantes del cuerpo: atoms a eliminar.
+        for &id in self.atom_ids.iter().skip(comun) {
+            cambios.push(CambioAtom::Eliminar { id });
+        }
+        cambios
+    }
+
+    /// Aplica una lista de cambios al `atom_ids` del editor — útil tras
+    /// que el caller persistió los cambios en el grafo. Devuelve los
+    /// Uuids de los atoms recién creados, en orden, para que el caller
+    /// los asigne a los `Crear` reales (el editor no sabe generar
+    /// `Uuid`s — eso lo hace `NarrativeAtom::new` arriba).
+    ///
+    /// `nuevos_ids` debe tener al menos tantos elementos como cambios
+    /// `Crear` haya. Si tiene menos, los `Crear` sobrantes se descartan;
+    /// si más, se ignoran los extras.
+    pub fn aplicar_cambios(&mut self, cambios: &[CambioAtom], nuevos_ids: &[Uuid]) {
+        let mut idx_nuevo = 0;
+        let mut a_eliminar: Vec<Uuid> = Vec::new();
+        for c in cambios {
+            match c {
+                CambioAtom::Mutar { .. } => {} // el atom_id viejo se reusa
+                CambioAtom::Crear { .. } => {
+                    if let Some(&id) = nuevos_ids.get(idx_nuevo) {
+                        self.atom_ids.push(id);
+                        idx_nuevo += 1;
+                    }
+                }
+                CambioAtom::Eliminar { id } => {
+                    a_eliminar.push(*id);
+                }
+            }
+        }
+        self.atom_ids.retain(|id| !a_eliminar.contains(id));
+    }
+}
+
+#[cfg(test)]
+mod pruebas {
+    use super::*;
+    use pluma_cuerpo::Intencion;
+
+    fn cuerpo_con_atoms(textos: &[&str]) -> (Cuerpo, Vec<NarrativeAtom>) {
+        let mut c = Cuerpo::nuevo("es", "es", Intencion::Original, 0);
+        let atoms: Vec<NarrativeAtom> =
+            textos.iter().map(|t| NarrativeAtom::new(*t, "es")).collect();
+        for a in &atoms {
+            c.agregar(a.id, 0);
+        }
+        (c, atoms)
+    }
+
+    fn indice(atoms: &[NarrativeAtom]) -> HashMap<Uuid, &NarrativeAtom> {
+        atoms.iter().map(|a| (a.id, a)).collect()
+    }
+
+    #[test]
+    fn from_cuerpo_concatena_con_separador_y_mantiene_orden() {
+        let (c, atoms) = cuerpo_con_atoms(&["Uno.", "Dos.", "Tres."]);
+        let idx = indice(&atoms);
+        let ed = EditorCuerpo::from_cuerpo(&c, &idx);
+        assert_eq!(ed.texto, "Uno.\n\nDos.\n\nTres.");
+        assert_eq!(ed.atom_ids, vec![atoms[0].id, atoms[1].id, atoms[2].id]);
+    }
+
+    #[test]
+    fn parrafos_split_y_trim_de_vacios() {
+        let (c, atoms) = cuerpo_con_atoms(&["Uno", "Dos"]);
+        let idx = indice(&atoms);
+        let mut ed = EditorCuerpo::from_cuerpo(&c, &idx);
+        // El usuario agrega espacios y un párrafo vacío entre medio:
+        ed.set_texto("  Uno  \n\n\n\n  Dos  ");
+        let p = ed.parrafos();
+        assert_eq!(p, vec!["Uno", "Dos"]);
+    }
+
+    #[test]
+    fn sin_cambios_diff_vacio() {
+        let (c, atoms) = cuerpo_con_atoms(&["uno", "dos"]);
+        let idx = indice(&atoms);
+        let ed = EditorCuerpo::from_cuerpo(&c, &idx);
+        assert!(ed.diff(&idx).is_empty());
+    }
+
+    #[test]
+    fn mutar_un_parrafo_emite_mutar_con_uuid_preservado() {
+        let (c, atoms) = cuerpo_con_atoms(&["uno", "dos", "tres"]);
+        let idx = indice(&atoms);
+        let mut ed = EditorCuerpo::from_cuerpo(&c, &idx);
+        // El usuario edita el segundo párrafo.
+        ed.set_texto("uno\n\nDOS!\n\ntres");
+        let d = ed.diff(&idx);
+        assert_eq!(d.len(), 1);
+        assert_eq!(d[0], CambioAtom::Mutar {
+            id: atoms[1].id,
+            texto_nuevo: "DOS!".to_string(),
+        });
+    }
+
+    #[test]
+    fn agregar_parrafos_emite_crear() {
+        let (c, atoms) = cuerpo_con_atoms(&["uno", "dos"]);
+        let idx = indice(&atoms);
+        let mut ed = EditorCuerpo::from_cuerpo(&c, &idx);
+        ed.set_texto("uno\n\ndos\n\ntres\n\ncuatro");
+        let d = ed.diff(&idx);
+        assert_eq!(d.len(), 2);
+        assert_eq!(d[0], CambioAtom::Crear {
+            texto: "tres".to_string(),
+            posicion: 2,
+        });
+        assert_eq!(d[1], CambioAtom::Crear {
+            texto: "cuatro".to_string(),
+            posicion: 3,
+        });
+    }
+
+    #[test]
+    fn eliminar_parrafos_emite_eliminar_para_los_ids_sobrantes() {
+        let (c, atoms) = cuerpo_con_atoms(&["uno", "dos", "tres", "cuatro"]);
+        let idx = indice(&atoms);
+        let mut ed = EditorCuerpo::from_cuerpo(&c, &idx);
+        // El usuario borra los dos últimos.
+        ed.set_texto("uno\n\ndos");
+        let d = ed.diff(&idx);
+        assert_eq!(d.len(), 2);
+        assert_eq!(d[0], CambioAtom::Eliminar { id: atoms[2].id });
+        assert_eq!(d[1], CambioAtom::Eliminar { id: atoms[3].id });
+    }
+
+    #[test]
+    fn cambios_mixtos_solo_emiten_lo_que_cambia() {
+        let (c, atoms) = cuerpo_con_atoms(&["uno", "dos", "tres"]);
+        let idx = indice(&atoms);
+        let mut ed = EditorCuerpo::from_cuerpo(&c, &idx);
+        // Cambia el primero ("uno" → "UNO!"), conserva el segundo,
+        // cambia el tercero ("tres" → "nuevo"). 3 párrafos, 3 atoms.
+        ed.set_texto("UNO!\n\ndos\n\nnuevo");
+        let d = ed.diff(&idx);
+        // Solo dos Mutar — el segundo párrafo coincide byte-a-byte y
+        // se omite sin emitir cambio.
+        assert_eq!(d.len(), 2);
+        match &d[0] {
+            CambioAtom::Mutar { id, texto_nuevo } => {
+                assert_eq!(*id, atoms[0].id);
+                assert_eq!(texto_nuevo, "UNO!");
+            }
+            otro => panic!("esperaba Mutar(0), fue {otro:?}"),
+        }
+        match &d[1] {
+            CambioAtom::Mutar { id, texto_nuevo } => {
+                assert_eq!(*id, atoms[2].id);
+                assert_eq!(texto_nuevo, "nuevo");
+            }
+            otro => panic!("esperaba Mutar(2), fue {otro:?}"),
+        }
+    }
+
+    #[test]
+    fn aplicar_cambios_extiende_atom_ids_para_crear_y_remueve_eliminar() {
+        let (c, atoms) = cuerpo_con_atoms(&["uno", "dos"]);
+        let idx = indice(&atoms);
+        let mut ed = EditorCuerpo::from_cuerpo(&c, &idx);
+        // Texto nuevo: dos crear + un eliminar.
+        ed.set_texto("uno\n\ntres\n\ncuatro\n\ncinco");
+        let cambios = ed.diff(&idx);
+        // 1 Mutar(dos → tres) + 2 Crear(cuatro, cinco) + 0 Eliminar.
+        // ATOM ORIGINAL: ["uno", "dos"] (2 items)
+        // PARRAFOS NUEVOS: ["uno", "tres", "cuatro", "cinco"] (4 items)
+        // comun = 2 → Mutar(atoms[1].id, "tres") (uno está igual).
+        // Crear "cuatro" pos 2, Crear "cinco" pos 3.
+        let nuevos_ids: Vec<Uuid> = vec![Uuid::new_v4(), Uuid::new_v4()];
+        ed.aplicar_cambios(&cambios, &nuevos_ids);
+        assert_eq!(ed.atom_ids.len(), 4);
+        assert_eq!(ed.atom_ids[0], atoms[0].id);
+        assert_eq!(ed.atom_ids[1], atoms[1].id); // reusado en Mutar
+        assert_eq!(ed.atom_ids[2], nuevos_ids[0]);
+        assert_eq!(ed.atom_ids[3], nuevos_ids[1]);
+    }
+
+    #[test]
+    fn aplicar_cambios_remueve_atom_ids_eliminados() {
+        let (c, atoms) = cuerpo_con_atoms(&["uno", "dos", "tres"]);
+        let idx = indice(&atoms);
+        let mut ed = EditorCuerpo::from_cuerpo(&c, &idx);
+        ed.set_texto("uno");
+        let cambios = ed.diff(&idx);
+        // 0 Mutar + 0 Crear + 2 Eliminar (dos, tres).
+        ed.aplicar_cambios(&cambios, &[]);
+        assert_eq!(ed.atom_ids, vec![atoms[0].id]);
+    }
+}
diff --git a/00_unanchay/pluma/pluma-editor-llimphi/Cargo.toml b/00_unanchay/pluma/pluma-editor-llimphi/Cargo.toml
new file mode 100644
index 0000000..28ed5dc
--- /dev/null
+++ b/00_unanchay/pluma/pluma-editor-llimphi/Cargo.toml
@@ -0,0 +1,37 @@
+[package]
+name = "pluma-editor-llimphi"
+version.workspace = true
+edition.workspace = true
+license.workspace = true
+authors.workspace = true
+publish.workspace = true
+description = "pluma — backend Llimphi del editor DAG: pinta un RenderPlan como bloques de átomo, conectores de dependencia y osciloscopio de coherencia."
+
+[dependencies]
+pluma-render-plan = { path = "../pluma-render-plan" }
+pluma-core = { path = "../pluma-core" }
+pluma-cuerpo = { path = "../pluma-cuerpo" }
+pluma-align = { path = "../pluma-align" }
+pluma-editor-cuerpo = { path = "../pluma-editor-cuerpo" }
+llimphi-ui = { workspace = true }
+llimphi-theme = { workspace = true }
+llimphi-widget-text-editor = { workspace = true }
+rimay-localize = { workspace = true }
+uuid = { workspace = true, features = ["serde"] }
+
+[dev-dependencies]
+pluma-graph = { path = "../pluma-graph" }
+pluma-graph-transform = { path = "../pluma-graph-transform" }
+pluma-transform = { path = "../pluma-transform" }
+pluma-transform-tabla = { path = "../pluma-transform-tabla" }
+pluma-transform-llm = { path = "../pluma-transform-llm" }
+pluma-align-embeddings = { path = "../pluma-align-embeddings" }
+pluma-llm = { path = "../pluma-llm" }
+pluma-llm-core = { path = "../pluma-llm-core" }
+pluma-llm-mock = { path = "../pluma-llm-mock" }
+pluma-store = { path = "../pluma-store" }
+llimphi-widget-button = { workspace = true }
+rimay-verbo-core = { workspace = true }
+rimay-verbo-mock = { workspace = true }
+rimay-verbo-daemon = { workspace = true }
+tokio = { workspace = true }
diff --git a/00_unanchay/pluma/pluma-editor-llimphi/LEEME.md b/00_unanchay/pluma/pluma-editor-llimphi/LEEME.md
new file mode 100644
index 0000000..e1a492e
--- /dev/null
+++ b/00_unanchay/pluma/pluma-editor-llimphi/LEEME.md
@@ -0,0 +1,13 @@
+# pluma-editor-llimphi
+
+> Editor visual Llimphi de [pluma](../README.md).
+
+UI: panel izquierdo con lista de documentos (de [`pluma-store`](../pluma-store/README.md)), centro con [`text-editor`](../../../02_ruway/llimphi/widgets/text-editor/README.md) sobre el cuerpo concatenado, panel derecho con LLM (cycler de backends via [`pluma-llm`](../pluma-llm/README.md)), historial, diff. Cada save dispara [`pluma-editor-cuerpo::diff`](../pluma-editor-cuerpo/README.md).
+
+Selector de modelo en runtime: botón cíclico Mock → Gemini → Anthropic → DeepSeek → Cohere → Ollama → Mock. Click → `build_client(...)` reconstruye `Arc<dyn ChatClient>` en vivo; si el backend no está configurado, conserva el anterior con mensaje de error.
+
+## Deps
+
+- Todos los crates `pluma-*`
+- [`llimphi-ui`](../../../02_ruway/llimphi/) + widgets `text-editor`, `tree`, `tabs`, `splitter`
+- [`wawa-config-llimphi`](../../../shared/wawa-config-llimphi/)
diff --git a/00_unanchay/pluma/pluma-editor-llimphi/README.md b/00_unanchay/pluma/pluma-editor-llimphi/README.md
new file mode 100644
index 0000000..086fa96
--- /dev/null
+++ b/00_unanchay/pluma/pluma-editor-llimphi/README.md
@@ -0,0 +1,13 @@
+# pluma-editor-llimphi
+
+> Visual Llimphi editor for [pluma](../README.md).
+
+UI: left panel with document list (from [`pluma-store`](../pluma-store/README.md)), center with [`text-editor`](../../../02_ruway/llimphi/widgets/text-editor/README.md) over the concatenated body, right panel with LLM (backend cycler via [`pluma-llm`](../pluma-llm/README.md)), history, diff. Each save triggers [`pluma-editor-cuerpo::diff`](../pluma-editor-cuerpo/README.md).
+
+Runtime model selector: cyclic button Mock → Gemini → Anthropic → DeepSeek → Cohere → Ollama → Mock. Click → `build_client(...)` rebuilds the `Arc<dyn ChatClient>` live; if a backend isn't configured, keeps the previous one with an error message.
+
+## Deps
+
+- All `pluma-*` crates
+- [`llimphi-ui`](../../../02_ruway/llimphi/) + widgets `text-editor`, `tree`, `tabs`, `splitter`
+- [`wawa-config-llimphi`](../../../shared/wawa-config-llimphi/)
diff --git a/00_unanchay/pluma/pluma-editor-llimphi/examples/cuerpo_ide_demo.rs b/00_unanchay/pluma/pluma-editor-llimphi/examples/cuerpo_ide_demo.rs
new file mode 100644
index 0000000..a01449f
--- /dev/null
+++ b/00_unanchay/pluma/pluma-editor-llimphi/examples/cuerpo_ide_demo.rs
@@ -0,0 +1,439 @@
+//! Demo del **text-editor IDE de Llimphi sobre `EditorCuerpo`**.
+//!
+//! Pintamos un cuerpo `es` con 5 párrafos sintéticos. El widget
+//! `text-editor` lo ve como UN buffer plano — cada `\n\n` separa un
+//! átomo. Editás libremente como en cualquier IDE:
+//!
+//!   - Tipeo / borrado normal, multi-cursor con `Ctrl+Alt+↑/↓`.
+//!   - `Ctrl+Z` undo / `Ctrl+Shift+Z` redo (del widget).
+//!   - Click + drag = selección; `Ctrl+C/X/V` con un clipboard en memoria.
+//!   - **`Ctrl+S`** = "guardar": calcula el diff contra los atoms
+//!     originales, lo aplica al `HashMap<Uuid, NarrativeAtom>` del
+//!     modelo (mutando contenido, creando atoms nuevos para los
+//!     párrafos extra, eliminando los faltantes), sincroniza el
+//!     `Cuerpo.orden` y resetea el editor sobre el cuerpo nuevo
+//!     preservando la posición del caret y el scroll.
+//!   - **`Ctrl+]`** salta al siguiente átomo (`posicion_de_atom` +
+//!     `set_caret`). Demuestra el lookup átomo → línea.
+//!
+//! No hay `pluma-graph` ni `pluma-store` acá — el modelo guarda los
+//! atoms y el `Cuerpo` directamente. Es el caso pelado del IDE: el
+//! caller real reemplaza el `HashMap` por su `NarrativeGraph` y
+//! persiste con `pluma-store`.
+//!
+//! ```bash
+//! cargo run -p pluma-editor-llimphi --example cuerpo_ide_demo --release
+//! ```
+
+use std::collections::HashMap;
+
+use llimphi_ui::llimphi_layout::taffy::prelude::{
+    auto, length, percent, FlexDirection, Rect, Size, Style,
+};
+use llimphi_ui::llimphi_raster::peniko::Color;
+use llimphi_ui::llimphi_text::Alignment;
+use llimphi_ui::{App, Handle, Key, KeyEvent, KeyState, View};
+use llimphi_widget_text_editor::{
+    EditorMetrics, EditorPalette, Language, MemClipboard, PointerEvent,
+};
+use pluma_core::NarrativeAtom;
+use pluma_cuerpo::{Cuerpo, Intencion};
+use pluma_editor_cuerpo::CambioAtom;
+use pluma_editor_llimphi::cuerpo_ide::{cuerpo_ide_view, CuerpoIde};
+use uuid::Uuid;
+
+/// Métricas del editor — fijas durante toda la sesión. Definidas como
+/// const para no recalcularlas en cada `update` / `view`.
+const METRICS: EditorMetrics = EditorMetrics::for_font_size(13.0);
+
+/// Cap muy amplio para `visible_lines`. El widget lo trunca a 200
+/// internamente — pasamos 200 y dejamos que decida.
+const VISIBLE_LINES: usize = 200;
+
+#[derive(Clone, Debug)]
+enum Msg {
+    EditorKey(KeyEvent),
+    EditorPointer(PointerEvent),
+    /// `Ctrl+S` — el caller pidió persistir los cambios.
+    Guardar,
+    /// `Ctrl+]` — saltar al átomo siguiente del cuerpo activo.
+    SaltarAtomoSiguiente,
+    /// `Ctrl+J` — togglea la junction inmediatamente anterior al
+    /// átomo bajo el caret: si era separador (línea-guarda), pasa a
+    /// fundida (línea editable, parte de la misma zona); si era
+    /// fundida, vuelve a separador.
+    ToglearFusion,
+    /// `Ctrl+Shift+]` — salta el caret a la siguiente zona (grupo de
+    /// atoms unidos por junctions fundidas). Wrap circular al final.
+    ZonaSiguiente,
+    /// `Ctrl+Shift+[` — salta a la zona anterior. Wrap circular al inicio.
+    ZonaAnterior,
+    /// `Ctrl+Shift+A` — selecciona la zona donde está el caret.
+    SeleccionarZona,
+}
+
+struct Model {
+    /// Cuerpo activo — su `orden` es lo que el editor reconstruye.
+    cuerpo: Cuerpo,
+    /// Atoms del "grafo" plano. Clave = `id`, valor = atom completo.
+    atoms: HashMap<Uuid, NarrativeAtom>,
+    /// El IDE: buffer + cursor + diff vs `editor_cuerpo`.
+    ide: CuerpoIde,
+    /// Clipboard local (no toca el del sistema — los demos viven en
+    /// sandbox). `MemClipboard` cubre Ctrl+C/X/V durante la sesión.
+    clipboard: MemClipboard,
+    /// Mensaje del último `Guardar` para mostrar en el footer.
+    ultimo_save: String,
+    /// Acumulador de drag para selección por mouse.
+    drag_accum: (f32, f32),
+}
+
+struct Demo;
+
+impl App for Demo {
+    type Model = Model;
+    type Msg = Msg;
+
+    fn title() -> &'static str {
+        "pluma · text-editor IDE sobre EditorCuerpo"
+    }
+
+    fn initial_size() -> (u32, u32) {
+        (1100, 720)
+    }
+
+    fn init(_: &Handle<Msg>) -> Model {
+        let textos = [
+            "El cóndor cruzó el cielo del valle al amanecer.",
+            "Las llamas pastaban entre los pastizales del altiplano.",
+            "Una mujer joven tejía un telar bajo el alero.",
+            "El río Apurímac descendía rugiente por las rocas.",
+            "Al caer la tarde, las nubes cubrieron el sol.",
+        ];
+        let atoms_vec: Vec<NarrativeAtom> = textos
+            .iter()
+            .map(|t| NarrativeAtom::new(*t, "es"))
+            .collect();
+        let mut cuerpo = Cuerpo::nuevo("es", "español (original)", Intencion::Original, 0);
+        for a in &atoms_vec {
+            cuerpo.agregar(a.id, 0);
+        }
+        let atoms: HashMap<Uuid, NarrativeAtom> =
+            atoms_vec.into_iter().map(|a| (a.id, a)).collect();
+
+        let idx: HashMap<Uuid, &NarrativeAtom> = atoms.iter().map(|(k, v)| (*k, v)).collect();
+        let ide = CuerpoIde::from_cuerpo(&cuerpo, &idx);
+
+        Model {
+            cuerpo,
+            atoms,
+            ide,
+            clipboard: MemClipboard::default(),
+            ultimo_save: String::new(),
+            drag_accum: (0.0, 0.0),
+        }
+    }
+
+    fn update(model: Model, msg: Msg, _: &Handle<Msg>) -> Model {
+        match msg {
+            Msg::EditorKey(ev) => {
+                let mut model = model;
+                let _ = model.ide.apply_key_with_clipboard(&ev, &mut model.clipboard);
+                model
+            }
+            Msg::EditorPointer(ev) => {
+                let mut model = model;
+                let scroll = model.ide.state.scroll_offset;
+                match ev {
+                    PointerEvent::Click { x, y } => {
+                        model.drag_accum = (0.0, 0.0);
+                        let (line, col) = METRICS.screen_to_pos(x, y, scroll);
+                        model.ide.set_caret(line, col);
+                    }
+                    PointerEvent::Drag {
+                        initial_x,
+                        initial_y,
+                        dx,
+                        dy,
+                    } => {
+                        model.drag_accum.0 += dx;
+                        model.drag_accum.1 += dy;
+                        let cx = initial_x + model.drag_accum.0;
+                        let cy = initial_y + model.drag_accum.1;
+                        let (line, col) = METRICS.screen_to_pos(cx, cy, scroll);
+                        model.ide.state.extend_selection_to(line, col);
+                    }
+                }
+                model
+            }
+            Msg::Guardar => guardar(model),
+            Msg::SaltarAtomoSiguiente => {
+ 

El parche sigue, pero se cortó acá: pedí el commit por git para verlo entero.