tawasuyu

tawasuyu/khipu

Sovereign local-first P2P notes, content-addressed, in Rust.

Rust 31Markdown 18TOML 8YAML 1517 Definiciones

chore: refresco desde el monorepo (pin tawasuyu.git v0.3.0)

Trae los README y LEEME de todos los crates del dominio, que ahora existen
para los 1.096 crates del workspace.

8d107fd2c11290217f1ac439fccab4ebf1a2871f · por Sergio · (hace 1 mes) · padre 48e5a861a · ver el árbol en este commit

Delta semántico · 149

00_unanchay/khipu/khipu-android/src/android.rs

00_unanchay/khipu/khipu-android/src/lib.rs

00_unanchay/khipu/khipu-app/examples/khipu_showreel.rs

00_unanchay/khipu/khipu-app/src/cuerdas.rs

00_unanchay/khipu/khipu-app/src/dibujo.rs

00_unanchay/khipu/khipu-app/src/estado.rs

00_unanchay/khipu/khipu-app/src/lib.rs

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

00_unanchay/khipu/khipu-app/src/menu.rs

00_unanchay/khipu/khipu-app/src/modelo.rs

  • cambió de firma enum Focus pub
  • cambió de firma struct Model pub
  • cambió de firma enum Msg pub
  • cambió de firma struct PeerInfo pub
  • cambió por dentro def blank

00_unanchay/khipu/khipu-app/src/panels.rs

00_unanchay/khipu/khipu-app/src/redaccion.rs

00_unanchay/khipu/khipu-app/src/showreel.rs

00_unanchay/khipu/khipu-brahman/tests/p2p_roundtrip.rs

00_unanchay/khipu/khipu-core/src/nombre.rs

  • cambió por dentro fn note

00_unanchay/khipu/khipu-core/src/note.rs

  • cambió de firma struct Note pub
  • nueva fn dibujado pub
  • cambió por dentro fn note

35 archivos · +4665 −932

diff --git a/00_unanchay/khipu/LEEME.md b/00_unanchay/khipu/LEEME.md
index 8d73d00..d2a4025 100644
--- a/00_unanchay/khipu/LEEME.md
+++ b/00_unanchay/khipu/LEEME.md
@@ -10,10 +10,14 @@ Un lugar donde botar ideas sueltas en cualquier estado mental — pensamientos,
 - **Resonancia** — al capturar, khipu busca los nudos previos afines (embeddings) y te los muestra ahí mismo: *«esto resuena con…»*. Un click anuda el nudo nuevo a la cuerda del vecino, o crea una cuerda nueva con ambos. Ese instante es el cierre de la fuga.
 - **Cuerda** — una idea recurrente. No se crea por adelantado: nace cuando dos nudos resuenan. Se bautiza con un nombre propuesto del contenido (editable). Su **masa** crece con cada nudo; su **brillo**, con la frescura del último crecimiento. Nada decae: lo que no resuena **sedimenta** (queda abajo, buscable, sin estorbar).
 - **Tejido** — cuando una cuerda engorda pero la idea sigue difusa, «tejer» le pide al LLM un encuadre de sus nudos: *qué se repite, qué ya está claro, qué falta decidir*. El resultado es un nudo-síntesis **editable** al final de la cuerda (rombo ◈); re-tejer lo reemplaza. Es la función para la que khipu existe: el caso canónico fue la idea de khipu mismo.
+- **Dibujo** — un pensamiento no siempre es una frase. Cualquier nudo abre un lienzo de la **pizarra** (`◇ dibujar` en el editor del nudo, o menú Archivo): una flecha entre dos cosas, un esquema de tres cajas, la forma de un problema. El dibujo vive **dentro del nudo**, no en otro archivo, y el nudo se marca `◇` en el cuaderno. Es el mismo lienzo de `pizarra` — las siete herramientas, deshacer, zoom y paneo — no una versión recortada.
+- **Redacción** — cuando la idea maduró, «redactar» la proyecta a un haz de **pluma**: el tejido de apertura y los nudos como párrafos, en cronología. Sale un `.pluma` en `$XDG_DATA_HOME/khipu/redacciones/` que abre el editor multilienzo, y de ahí en más el borrador es de pluma (traducir, tono, resumir, versionar, exportar a docx). khipu junta y deja resonar; pluma escribe.
 
 ## La ventana es un khipu
 
-Captura siempre lista arriba; franja de sueltos frescos; y el lienzo: una viga de la que cuelgan las cuerdas ordenadas por masa (la más gorda a la izquierda), con sus nudos en cronología. Pendiente = anillo hueco, hecho = relleno tenue, tejido = rombo. Click en un nudo abre el editor (overlay derecho); click en la cabeza de una cuerda abre su tarjeta (tejer / renombrar / desatar). `☰ nudos` abre el cajón con búsqueda; el sedimento vive ahí. `Esc` cierra en cascada. Pan con arrastre o rueda.
+Captura siempre lista arriba; franja de sueltos frescos; y el lienzo: una viga de la que cuelgan las cuerdas ordenadas por masa (la más gorda a la izquierda), con sus nudos en cronología. Pendiente = anillo hueco, hecho = relleno tenue, tejido = rombo, **con dibujo = rombito hueco al lado** (la forma del nudo sigue diciendo su estado: un pendiente dibujado se ve pendiente). Click en un nudo abre el editor (overlay derecho); click en la cabeza de una cuerda abre su tarjeta (tejer / redactar / renombrar / desatar). `☰ nudos` abre el cajón con búsqueda; el sedimento vive ahí. `Esc` cierra en cascada. Pan con arrastre o rueda.
+
+Con un dibujo abierto el lienzo **ocupa la ventana**: es un modo, no un overlay, porque el cuaderno y el dibujo se pelearían el mismo puntero. `Esc` guarda y vuelve; `Ctrl+Z`/`Ctrl+Shift+Z` deshacen el dibujo (la pila es de la sesión de dibujo, no viaja al disco). Con la herramienta de texto, un click abre la caja **donde tocaste** y `Enter` la deja.
 
 ## Instalación
 
@@ -36,7 +40,16 @@ El backend sale de `pluma-llm::from_env()`: `ANTHROPIC_API_KEY` / `GEMINI_API_KE
 
 ## Persistencia y migración
 
-`$XDG_DATA_HOME/khipu/notes.bin` (postcard, formato V4: nudos + cuerdas). Los cuadernos del khipu viejo (V1–V3, el del mapa gravitatorio) **migran solos** al abrir: las notas conservan id y contenido, nacen sueltas (las cuerdas se anudan de ahí en más), y el archivo original queda respaldado en `notes.v3.bak`.
+`$XDG_DATA_HOME/khipu/notes.bin` (postcard, formato **V5**: nudos + cuerdas + dibujos). Los borradores van aparte, en `$XDG_DATA_HOME/khipu/redacciones/*.pluma`.
+
+Todo formato anterior **migra solo** al abrir, con respaldo:
+
+| Desde | Qué era | Migra | Respaldo |
+|---|---|---|---|
+| **V4** | el cuaderno de resonancia sin dibujos | entero: nudos, cuerdas, anudados y tejidos conservan su identidad; `dibujo` nace vacío | `notes.v4.bak` |
+| **V1–V3** | el khipu viejo, el del mapa gravitatorio | las notas conservan id y contenido y nacen **sueltas** (las cuerdas se anudan de ahí en más); se descartan posiciones y regiones del mapa muerto | `notes.v3.bak` |
+
+Los respaldos son de una sola vez: si ya existe uno, no se pisa — conserva el estado previo a la primera migración. (postcard es posicional, así que cada formato viejo tiene su sombra en el código y un test que escribe bytes de verdad y los relee: una migración que no se prueba contra bytes es una promesa.)
 
 ## Crates
 
@@ -46,7 +59,8 @@ El backend sale de `pluma-llm::from_env()`: `ANTHROPIC_API_KEY` / `GEMINI_API_KE
 | [`khipu-gravity`](khipu-gravity/README.md) | Las físicas puras: campo semántico (resonancia), masa/frescura de cuerda, y el decay temporal (que ya no usa la app — lo consumen pata e iniy-derive). |
 | `khipu-share` | Sobres de notas firmados (Ed25519) y direccionados por contenido (BLAKE3) sobre agora; transporte TCP/LAN + descubrimiento UDP + identidad cifrada. |
 | `khipu-brahman` | Transporte de sobres sobre libp2p (BrahmanNet): stream cifrado + descubrimiento por DHT. |
-| [`khipu-app`](khipu-app/README.md) | UI Llimphi: captura, lienzo de cuerdas, resonancia, tejido. |
+| `khipu-redaccion` | La **cuerda redactada**: proyección de una cuerda al haz de pluma (tejido de apertura + nudos como átomos narrativos). Puro: sin UI, sin disco, sin reloj propio. |
+| [`khipu-app`](khipu-app/README.md) | UI Llimphi: captura, lienzo de cuerdas, resonancia, tejido, el lienzo del nudo y el gesto de redactar. |
 
 ## Compartir (agora)
 
@@ -64,6 +78,9 @@ Sin cambios de fondo respecto del khipu viejo — viaja el **contenido** (títul
 
 ### Pendiente (fase 2)
 
+- **El dibujo no viaja todavía.** `khipu-share` manda `SharedNote` (título, cuerpo, etiquetas) y su sobre **no está versionado**: agregarle un campo rompería en silencio a todo par que corra la versión anterior (postcard es posicional). El dibujo se queda en el cuaderno local hasta que el sobre tenga versión — y ahí viaja gratis, porque `DocPizarra` es un CRDT y funde solo.
+- **El dibujo no resuena.** Los embeddings leen título y cuerpo; un trazo no tiene texto que comparar, y fabricar un vector desde la geometría sería inventar una afinidad que nadie midió. Un nudo dibujado **con** texto resuena por su texto, como cualquier otro.
+- **Redactar es un corte, no una sincronización.** El `.pluma` se lleva una copia del estado de la cuerda en ese momento; después el borrador es de pluma. Volver a redactar la misma cuerda **pisa** el mismo archivo (el nombre lleva el id de la cuerda) en vez de dejar copias casi iguales.
 - **Captura global**: hotkey de mirada → popup de captura; puente chasqui (correo → nudo con `origen`); estampa de pacha/workspace. El modelo ya trae el campo `origen`.
 - Unificar la captura rápida de la barra de pata (hoy store propio `pata-quick.json`) con el cuaderno de khipu.
 - Resonancia entre cuerdas (sugerir fusión de cuerdas gemelas).
diff --git a/00_unanchay/khipu/README.md b/00_unanchay/khipu/README.md
index b2283d6..954a7b1 100644
--- a/00_unanchay/khipu/README.md
+++ b/00_unanchay/khipu/README.md
@@ -10,10 +10,14 @@ A place to dump loose ideas in any mental state — thoughts, TODOs, quotes —
 - **Resonance** — on capture, khipu finds affine previous knots (embeddings) and shows them right there: *"this resonates with…"*. One click ties the new knot to the neighbor's cord, or spins a new cord from both. That instant is the leak being closed.
 - **Cord (cuerda)** — a recurrent idea. Never created ahead of time: it's born when two knots resonate. It's christened with a name proposed from the content (editable). Its **mass** grows with each knot; its **glow**, with how recently it last grew. Nothing decays: what never resonates **settles as sediment** (down below, searchable, out of the way).
 - **Weave (tejido)** — when a cord fattens but the idea stays diffuse, «tejer» asks the LLM for a framing of its knots: *what keeps repeating, what's already clear, what's left to decide*. The result is an **editable** synthesis-knot at the end of the cord (diamond ◈); re-weaving replaces it. This is the function khipu exists for — the canonical case was the idea of khipu itself.
+- **Drawing (dibujo)** — a thought isn't always a sentence. Any knot opens a **pizarra** canvas (`◇ dibujar` in the knot editor, or the Archivo menu): an arrow between two things, a three-box sketch, the shape of a problem. The drawing lives **inside the knot**, not in a separate file, and the knot is marked `◇` in the notebook. It's the same canvas as `pizarra` — all seven tools, undo, zoom and pan — not a cut-down version.
+- **Draft (redacción)** — once the idea has matured, «redactar» projects it into a **pluma** beam: the weave as the opening, the knots as paragraphs in chronological order. Out comes a `.pluma` in `$XDG_DATA_HOME/khipu/redacciones/` that the multi-canvas editor opens, and from then on the draft belongs to pluma (translate, tone, summarize, version, export to docx). khipu gathers and lets things resonate; pluma writes.
 
 ## The window is a khipu
 
-Capture always ready on top; a strip of fresh loose knots; and the canvas: a beam from which the cords hang ordered by mass (fattest on the left), knots in chronology. Pending = hollow ring, done = faint fill, weave = diamond. Click a knot to open the editor (right overlay); click a cord's head to open its card (weave / rename / untie). `☰ nudos` opens the searchable drawer; sediment lives there. `Esc` closes in cascade. Pan by drag or wheel.
+Capture always ready on top; a strip of fresh loose knots; and the canvas: a beam from which the cords hang ordered by mass (fattest on the left), knots in chronology. Pending = hollow ring, done = faint fill, weave = diamond, **has a drawing = small hollow diamond beside it** (the knot's own shape keeps saying its state: a pending knot with a drawing still looks pending). Click a knot to open the editor (right overlay); click a cord's head to open its card (weave / draft / rename / untie). `☰ nudos` opens the searchable drawer; sediment lives there. `Esc` closes in cascade. Pan by drag or wheel.
+
+With a drawing open the canvas **takes the whole window**: it's a mode, not an overlay, because the notebook and the drawing would fight over the same pointer. `Esc` saves and returns; `Ctrl+Z`/`Ctrl+Shift+Z` undo the drawing (the stack belongs to the drawing session and doesn't travel to disk). With the text tool, a click opens the box **where you clicked** and `Enter` commits it.
 
 ## Install
 
@@ -36,7 +40,16 @@ The backend comes from `pluma-llm::from_env()`: `ANTHROPIC_API_KEY` / `GEMINI_AP
 
 ## Persistence and migration
 
-`$XDG_DATA_HOME/khipu/notes.bin` (postcard, V4 format: knots + cords). Notebooks from the old khipu (V1–V3, the gravity map) **migrate automatically** on open: notes keep id and content, are born loose (cords get tied from then on), and the original file is backed up as `notes.v3.bak`.
+`$XDG_DATA_HOME/khipu/notes.bin` (postcard, **V5** format: knots + cords + drawings). Drafts live apart, in `$XDG_DATA_HOME/khipu/redacciones/*.pluma`.
+
+Every earlier format **migrates automatically** on open, with a backup:
+
+| From | What it was | Migrates | Backup |
+|---|---|---|---|
+| **V4** | the resonance notebook without drawings | whole: knots, cords, ties and weaves keep their identity; `dibujo` starts empty | `notes.v4.bak` |
+| **V1-V3** | the old khipu, the gravity map one | notes keep id and content and are born **loose** (cords get tied from then on); dead-map positions and regions are dropped | `notes.v3.bak` |
+
+Backups are written once: if one already exists it isn't overwritten - it preserves the state before the first migration. (postcard is positional, so each old format has its shadow in the code and a test that writes real bytes and reads them back: an unproven migration is a promise.)
 
 ## Crates
 
@@ -46,7 +59,8 @@ The backend comes from `pluma-llm::from_env()`: `ANTHROPIC_API_KEY` / `GEMINI_AP
 | [`khipu-gravity`](khipu-gravity/README.md) | The pure physics: semantic field (resonance), cord mass/freshness, and the temporal decay (no longer used by the app — consumed by pata and iniy-derive). |
 | `khipu-share` | Note envelopes, Ed25519-signed and content-addressed (BLAKE3) over agora; TCP/LAN transport + UDP discovery + encrypted identity. |
 | `khipu-brahman` | Envelope transport over libp2p (BrahmanNet): encrypted stream + DHT discovery. |
-| [`khipu-app`](khipu-app/README.md) | Llimphi UI: capture, cord canvas, resonance, weaving. |
+| `khipu-redaccion` | The **drafted cord**: projection of a cord into pluma's beam (weave as opening + knots as narrative atoms). Pure: no UI, no disk, no clock of its own. |
+| [`khipu-app`](khipu-app/README.md) | Llimphi UI: capture, cord canvas, resonance, weaving, the knot's canvas and the drafting gesture. |
 
 ## Sharing (agora)
 
@@ -64,6 +78,9 @@ Unchanged at its core from the old khipu — what travels is the **content** (ti
 
 ### Pending (phase 2)
 
+- **Drawings don't travel yet.** `khipu-share` sends `SharedNote` (title, body, tags) and its envelope is **unversioned**: adding a field would silently break every peer on the previous version (postcard is positional). Drawings stay in the local notebook until the envelope gets a version - and then they travel for free, because `DocPizarra` is a CRDT and merges on its own.
+- **Drawings don't resonate.** Embeddings read title and body; a stroke has no text to compare, and fabricating a vector from geometry would invent an affinity nobody measured. A drawn knot that also has text resonates by its text, like any other.
+- **Drafting is a cut, not a sync.** The `.pluma` takes a copy of the cord's state at that moment; afterwards the draft belongs to pluma. Drafting the same cord again **overwrites** the same file (the name carries the cord id) instead of leaving near-identical copies.
 - **Global capture**: mirada hotkey → capture popup; chasqui bridge (mail → knot with `origen`); pacha/workspace stamp. The model already carries the `origen` field.
 - Unify pata's quick-capture bar (today its own `pata-quick.json` store) with the khipu notebook.
 - Cord-to-cord resonance (suggest merging twin cords).
diff --git a/00_unanchay/khipu/README.qu.md b/00_unanchay/khipu/README.qu.md
index 411863f..510454b 100644
--- a/00_unanchay/khipu/README.qu.md
+++ b/00_unanchay/khipu/README.qu.md
@@ -6,6 +6,10 @@
 
 Yuyaykunata usqhaylla wikch'uy, ima sunqu-kaypipas. Musuq yuyay ñawpaq yuyaykunawan tinkuptin, khipu rikuchisunki — chaymanta **wask'a** (cuerda) paqarin: kutimuq yuyay chayta wiñachin, llasachan. Manan imapas chinkanchu: mana tinkuq yuyaykuna allpaman tiyaykun (maskhaypi tarikullanqapuni). Wask'a wirayaptin, «away» (tejido) LLM-wan huk qhawariyta awan: imas kutimun, imas sut'iña, imas faltashan.
 
+Manan llapa yuyaychu rimay-hina kan. Sapa nudo **siq'inata** kichan (`pizarra`-manta): huk wachi iskay imakuna chawpipi, huk pisi qhawarichiy. Siq'iqa nudo **ukhupim** tiyan, manan huk qillqapichu, nudotaq `◇`-wan unanchasqa rikhurin.
+
+Wask'a wiñasqaña kaptin, «qillqay» (redactar) chayta **pluma**-man apan: awasqa ñawpaqpi, nudokunataq párrafo-hina, pacha-ñiqinpi. Huk `.pluma` qillqa lluqsin, pluma-q qillqaqnin kichan — chaymantaqa borradorqa pluma-qñam. khipu huñun, pluma qillqan.
+
 ## Churay
 
 ```sh
@@ -15,7 +19,7 @@ cargo run --release -p khipu-app
 ## Tinkuy
 
 - **Linux / macOS / Windows** — Llimphi UI (Wayland/X11/Win32 `winit` patapi).
-- Allpa-yana waqaychay `$XDG_DATA_HOME/khipu/`-pi (ñawpaq khipuq qillqankuna kikinmanta migrasqa, `notes.v3.bak` copiayuq).
+- Allpa-yana waqaychay `$XDG_DATA_HOME/khipu/`-pi, **V5** formato (nudo + wask'a + siq'i). Ñawpaq formatokuna kikinmanta migrasqa, copiayuq: V4 (mana siq'iyuq) `notes.v4.bak`-man, V1–V3 (ñawpaq khipu) `notes.v3.bak`-man. Borradorkunataq `redacciones/`-pi, huk t'aqapi.
 
 ## Crateskuna
 
@@ -24,7 +28,8 @@ cargo run --release -p khipu-app
 | [`khipu-core`](khipu-core/README.md) | Nudo + wask'a modelo, waqaychay; mana UI. |
 | [`khipu-gravity`](khipu-gravity/README.md) | Físicakuna: tinkuy (resonancia), wask'a llasay/musuq-kay. |
 | `khipu-share` / `khipu-brahman` | Firmasqa sobrekuna agora-wan, libp2p-wan willanakuy. |
-| [`khipu-app`](khipu-app/README.md) | Llimphi UI: hap'iy, wask'a lienzo, tinkuy, away. |
+| `khipu-redaccion` | Wask'ata pluma-q hazninman t'ikrachiy; ch'uya (mana UI, mana disco). |
+| [`khipu-app`](khipu-app/README.md) | Llimphi UI: hap'iy, wask'a lienzo, tinkuy, away, nudoq siq'inan, qillqay. |
 
 ## Yuyaykunaq
 
diff --git a/00_unanchay/khipu/khipu-android/Cargo.toml b/00_unanchay/khipu/khipu-android/Cargo.toml
new file mode 100644
index 0000000..d66b0ef
--- /dev/null
+++ b/00_unanchay/khipu/khipu-android/Cargo.toml
@@ -0,0 +1,45 @@
+[package]
+name = "khipu-android"
+version.workspace = true
+edition.workspace = true
+rust-version.workspace = true
+license.workspace = true
+authors.workspace = true
+publish.workspace = true
+description = "khipu en el bolsillo — chasis Android (Llimphi) del cuaderno de resonancia P2P: el cuaderno que se sincroniza entre pares, ahí donde uno anota."
+
+# NativeActivity dlopen-ea la lib como .so → cdylib con `android_main`. `rlib`
+# además para que el example de escritorio consuma la misma app.
+[lib]
+crate-type = ["cdylib", "rlib"]
+
+[dependencies]
+khipu-app = { path = "../khipu-app" }
+llimphi-ui = { workspace = true }
+log = "0.4"
+
+[target.'cfg(target_os = "android")'.dependencies]
+# Misma versión que winit 0.30 usa internamente — el `AndroidApp` de
+# `android_main` debe ser EL MISMO tipo que consume `with_android_app`.
+android-activity = { version = "0.6", features = ["native-activity"] }
+android_logger = "0.14"
+# Activa el backend NativeActivity de winit para todo el grafo.
+winit = { workspace = true, features = ["android-native-activity"] }
+
+[package.metadata.android]
+package = "net.tawasuyu.khipu"
+build_targets = ["aarch64-linux-android"]
+min_sdk_version = 24
+target_sdk_version = 33
+
+[package.metadata.android.application]
+label = "Khipu"
+debuggable = true
+
+[[package.metadata.android.uses_permission]]
+name = "android.permission.INTERNET"
+
+[package.metadata.android.application.activity]
+config_changes = "orientation|screenSize|keyboardHidden"
+launch_mode = "singleTop"
+orientation = "unspecified"
diff --git a/00_unanchay/khipu/khipu-android/LEEME.md b/00_unanchay/khipu/khipu-android/LEEME.md
new file mode 100644
index 0000000..3ecd736
--- /dev/null
+++ b/00_unanchay/khipu/khipu-android/LEEME.md
@@ -0,0 +1,32 @@
+# khipu-android
+
+*Read this in English: [README.md](README.md).*
+
+> khipu en el bolsillo — el chasis Android (Llimphi) del cuaderno de resonancia P2P.
+
+El cerebro es `khipu-app` sin tocar. Acá sólo se resuelven las tres cosas que el
+teléfono no tiene: un **logger** que llegue a logcat, un **panic hook** (sin él un
+panic muere en silencio: Android cierra el proceso antes de que nada flushee), y un
+**`HOME` escribible**, que en Android es el dir privado de la app.
+
+La regla 2 del repo en su forma más literal: la app no sabe quién la pinta ni dónde
+corre, y este crate **no** reimplementa ni un widget.
+
+⚠️ Los permisos y el label salen de `manifest.yaml`, **no** del
+`[package.metadata.android]` del `Cargo.toml` — xbuild ignora ese bloque.
+
+## Los gates, en orden
+
+```bash
+cargo check -p khipu-app                                    # 1. la app de escritorio sigue andando
+. scripts/andenv.sh                                       # 2. NDK + linker + CC
+cargo check --target aarch64-linux-android -p khipu-android
+./02_ruway/llimphi/android/scripts/build-android.sh 00_unanchay/khipu/khipu-android arm64 release
+```
+
+El procedimiento completo del porte está en
+[`RECETA-PORTE.md`](../../../02_ruway/llimphi/android/RECETA-PORTE.md).
+
+---
+
+Parte de **khipu** — ver [khipu](../LEEME.md).
diff --git a/00_unanchay/khipu/khipu-android/README.md b/00_unanchay/khipu/khipu-android/README.md
new file mode 100644
index 0000000..6ec8dcd
--- /dev/null
+++ b/00_unanchay/khipu/khipu-android/README.md
@@ -0,0 +1,30 @@
+# khipu-android
+
+> khipu in your pocket — the Android chassis (Llimphi) of the P2P resonance notebook.
+
+The brain is `khipu-app`, untouched. This crate only solves the three things the
+phone does not give for free: a **logger** that reaches logcat, a **panic hook**
+(without it a panic dies in silence — Android kills the process before anything
+flushes), and a **writable `HOME`**, which on Android is the app's private dir.
+
+Hard rule #2 in its most literal form: the app does not know who paints it or
+where it runs, and this crate does **not** reimplement a single widget.
+
+⚠️ Permissions and label come from `manifest.yaml`, **not** from
+`[package.metadata.android]` in `Cargo.toml` — xbuild ignores that block.
+
+## Gates, in order
+
+```bash
+cargo check -p khipu-app                                    # 1. the desktop app still works
+. scripts/andenv.sh                                       # 2. NDK + linker + CC
+cargo check --target aarch64-linux-android -p khipu-android
+./02_ruway/llimphi/android/scripts/build-android.sh 00_unanchay/khipu/khipu-android arm64 release
+```
+
+See [`RECETA-PORTE.md`](../../../02_ruway/llimphi/android/RECETA-PORTE.md) for the
+whole porting procedure.
+
+---
+
+Part of **khipu** — see [khipu](../README.md).
diff --git a/00_unanchay/khipu/khipu-android/manifest.yaml b/00_unanchay/khipu/khipu-android/manifest.yaml
new file mode 100644
index 0000000..1077982
--- /dev/null
+++ b/00_unanchay/khipu/khipu-android/manifest.yaml
@@ -0,0 +1,18 @@
+# Fuente autoritativa del manifiesto para xbuild — el `[package.metadata.android]`
+# del Cargo.toml lo IGNORA (costó un diagnóstico entero descubrirlo, ver
+# 02_ruway/llimphi/android/DIAGNOSTICO.md).
+android:
+  manifest:
+    package: net.tawasuyu.khipu
+    uses_permission:
+      - name: android.permission.INTERNET
+      - name: android.permission.ACCESS_NETWORK_STATE
+    application:
+      label: Khipu
+      activities:
+        - config_changes: orientation|screenSize|keyboardHidden
+          launch_mode: singleTop
+    sdk:
+      min_sdk_version: 24
+      # xbuild 0.2.0 no sube de targetSdk 33 — es el techo que empaqueta.
+      target_sdk_version: 33
diff --git a/00_unanchay/khipu/khipu-android/src/android.rs b/00_unanchay/khipu/khipu-android/src/android.rs
new file mode 100644
index 0000000..616f873
--- /dev/null
+++ b/00_unanchay/khipu/khipu-android/src/android.rs
@@ -0,0 +1,30 @@
+//! Entry-point Android: NativeActivity dlopen-ea la cdylib y `android-activity`
+//! invoca este `android_main`. Todo lo específico del target vive acá.
+
+const TAG: &str = "Khipu";
+
+#[no_mangle]
+fn android_main(app: android_activity::AndroidApp) {
+    android_logger::init_once(
+        android_logger::Config::default()
+            .with_max_level(log::LevelFilter::Debug)
+            .with_tag(TAG),
+    );
+    // Sin esto un panic muere en silencio: Android cierra el proceso antes de
+    // que nada flushee. El hook lo manda a logcat primero.
+    std::panic::set_hook(Box::new(|info| {
+        log::error!("PANIC: {info}");
+    }));
+
+    // La app guarda su estado bajo $HOME; en Android el único directorio
+    // escribible garantizado es el privado de la app.
+    if let Some(dir) = app.internal_data_path() {
+        std::env::set_var("HOME", &dir);
+        log::info!("HOME = {}", dir.display());
+    } else {
+        log::error!("sin internal_data_path — la app arranca sin estado persistente");
+    }
+
+    log::info!("android_main → llimphi_ui::run_android");
+    llimphi_ui::run_android::<khipu_app::app::KhipuApp>(app);
+}
diff --git a/00_unanchay/khipu/khipu-android/src/lib.rs b/00_unanchay/khipu/khipu-android/src/lib.rs
new file mode 100644
index 0000000..98432bd
--- /dev/null
+++ b/00_unanchay/khipu/khipu-android/src/lib.rs
@@ -0,0 +1,15 @@
+//! Chasis Android de Khipu.
+//!
+//! El cerebro es `khipu-app` sin tocar: acá sólo se resuelve lo que el teléfono no
+//! tiene — un `HOME` escribible y el arranque por `android_main`. Es la regla 2
+//! del repo en su forma más literal: la app no sabe quién la pinta ni dónde
+//! corre, y este crate no reimplementa ni un widget.
+
+#[cfg(target_os = "android")]
+mod android;
+
+/// Runner de escritorio: el mismo camino que Android sin el `AndroidApp`.
+/// Existe para poder ejercitar el chasis sin un teléfono en la mano.
+pub fn correr() {
+    llimphi_ui::run::<khipu_app::app::KhipuApp>();
+}
diff --git a/00_unanchay/khipu/khipu-app/Cargo.toml b/00_unanchay/khipu/khipu-app/Cargo.toml
index de09504..5d63989 100644
--- a/00_unanchay/khipu/khipu-app/Cargo.toml
+++ b/00_unanchay/khipu/khipu-app/Cargo.toml
@@ -16,6 +16,10 @@ path = "src/main.rs"
 name = "demo_cli"
 path = "examples/demo_cli.rs"
 
+[[example]]
+name = "khipu_showreel"
+path = "examples/khipu_showreel.rs"
+
 [dependencies]
 khipu-core = { path = "../khipu-core" }
 khipu-gravity = { path = "../khipu-gravity" }
@@ -26,6 +30,17 @@ rimay-verbo = { workspace = true }
 # El tejido: la síntesis de una cuerda la redacta el LLM que haya en el
 # entorno (fachada transparente; Mock sin credenciales).
 pluma-llm = { workspace = true }
+# El nudo dibujado: el documento vive en `khipu-core` como `DocPizarra` y se
+# edita con el WIDGET de la pizarra (`pizarra-llimphi`, el compositor), no con
+# `pizarra-app`, que es el shell — khipu ya tiene el suyo.
+pizarra-core = { workspace = true }
+pizarra-llimphi = { workspace = true }
+# La cuerda redactada: la proyección a un haz de pluma vive en su propio crate,
+# puro y testeable, igual que `ende-core` proyecta el haz a película.
+khipu-redaccion = { path = "../khipu-redaccion" }
+# El archivo `.pluma` lo escribe `Proyecto::guardar`: khipu-redaccion es puro
+# (arma el haz y no toca el disco), así que el envase lo pone la app.
+pluma-proyecto = { workspace = true }
 tokio = { workspace = true }
 llimphi-ui = { workspace = true }
 llimphi-theme = { workspace = true }
@@ -46,3 +61,8 @@ serde = { workspace = true }
 blake3 = { workspace = true }
 willay-checkpoint = { workspace = true }
 willay-emit = { workspace = true }
+# Sólo para el showreel headless (`src/showreel.rs`): volcado de la vista a PNG
+# sin abrir ventana. Van como dependencias normales, no dev, porque el reel vive
+# adentro del crate para poder ver el `Model`.
+png = { workspace = true }
+pollster = { workspace = true }
diff --git a/00_unanchay/khipu/khipu-app/README.md b/00_unanchay/khipu/khipu-app/README.md
index 3d5b905..c24479b 100644
--- a/00_unanchay/khipu/khipu-app/README.md
+++ b/00_unanchay/khipu/khipu-app/README.md
@@ -2,7 +2,12 @@
 
 > Llimphi UI of the [khipu](../README.md) resonance notebook. The user binary.
 
-The window is a khipu: **capture** always ready on top (Enter drops the knot), a strip of fresh **loose knots**, and the **cord canvas** hanging from the beam — thickness by mass, glow by freshness, knots in chronology, weave ◈ at the end. On capture, the **resonance** panel offers to tie with what you already thought; **weave** (cord card or the Cuerda menu) asks the LLM for the idea's framing. List and editor are overlays (`☰ nudos`, click a knot); `Esc` closes in cascade; `Ctrl+N` captures, `Ctrl+F` searches.
+The window is a khipu: **capture** always ready on top (Enter drops the knot), a strip of fresh **loose knots**, and the **cord canvas** hanging from the beam — thickness by mass, glow by freshness, knots in chronology, weave ◈ at the end, and a small hollow ◇ beside a knot that carries a **drawing**. On capture, the **resonance** panel offers to tie with what you already thought; **weave** (cord card or the Cuerda menu) asks the LLM for the idea's framing. List and editor are overlays (`☰ nudos`, click a knot); `Esc` closes in cascade; `Ctrl+N` captures, `Ctrl+F` searches.
+
+Two gestures take the idea out of the notebook:
+
+- **`◇ dibujar`** (knot editor, or Archivo menu) opens the **pizarra** canvas on that knot — a mode, not an overlay, since notebook and drawing would fight over the same pointer. Seven tools, undo (`Ctrl+Z`/`Ctrl+Shift+Z`, per drawing session), zoom and pan; the text tool opens its box where you clicked. `Esc` saves and returns, and a canvas closed without a stroke doesn't touch the knot's `updated_at`. The drawing lives inside the knot (`Note.dibujo`), so it needs no file of its own.
+- **`¶ redactar`** (cord card, or Cuerda menu) projects the cord into a **pluma** beam via `khipu-redaccion`, writes `$XDG_DATA_HOME/khipu/redacciones/<cuerda>-<id>.pluma` and hands it to whoever the AppBus says handles `application/x-pluma`. If nobody does, the draft is still on disk and the status bar says where.
 
 ## Usage
 
@@ -12,10 +17,12 @@ cargo run --release -p khipu-app
 # real LLM weaving:          ANTHROPIC_API_KEY=… (or Gemini/DeepSeek/Cohere/Ollama; nothing → Mock)
 ```
 
-Persistence: `$XDG_DATA_HOME/khipu/notes.bin` (postcard V4); old-khipu notebooks migrate automatically with a `notes.v3.bak` backup.
+Persistence: `$XDG_DATA_HOME/khipu/notes.bin` (postcard **V5**: knots + cords + drawings). V4 notebooks (resonance without drawings) migrate whole with a `notes.v4.bak` backup; old-khipu ones (V1–V3, the gravity map) migrate with `notes.v3.bak`. Drafts live apart in `redacciones/`.
 
 ## Deps
 
-- [`khipu-core`](../khipu-core/README.md), [`khipu-gravity`](../khipu-gravity/README.md), `khipu-share`, `khipu-brahman`
+- [`khipu-core`](../khipu-core/README.md), [`khipu-gravity`](../khipu-gravity/README.md), `khipu-share`, `khipu-brahman`, `khipu-redaccion`
 - [`llimphi-ui`](../../../02_ruway/llimphi/) + widgets `text-editor`, `text-input`, `list`, `menubar`, `edit-menu`
-- `pluma-llm` (the weave), `rimay-verbo` (embeddings), `agora-core` (identity), `willay-*` (timeline)
+- `pizarra-llimphi` + `pizarra-core` (the knot's canvas — the **compositor**, not `pizarra-app`: khipu brings its own shell)
+- `pluma-llm` (the weave), `pluma-proyecto` (the `.pluma` envelope), `app-bus` (who opens it)
+- `rimay-verbo` (embeddings), `agora-core` (identity), `willay-*` (timeline)
diff --git a/00_unanchay/khipu/khipu-app/examples/khipu_showreel.rs b/00_unanchay/khipu/khipu-app/examples/khipu_showreel.rs
new file mode 100644
index 0000000..a38fae3
--- /dev/null
+++ b/00_unanchay/khipu/khipu-app/examples/khipu_showreel.rs
@@ -0,0 +1,21 @@
+//! **Showreel** de khipu — render headless determinista del cuaderno de
+//! resonancia real, para la ficha de tawasuyu.net. Reusa la `view()` de la app
+//! sobre un cuaderno sintético (tres cuerdas con sus nudos en cronología); la
+//! lógica vive en `khipu_app::showreel`, adentro del crate, que es lo que le
+//! permite ver el `Model` sin abrirlo al mundo.
+//!
+//! ```text
+//! cargo run -p khipu-app --example khipu_showreel --release -- [out_dir] [n] [W] [H]
+//! ```
+//! Defaults: `out_dir=showreel_frames_khipu`, `n=300`, `W=1600`, `H=900`.
+
+fn main() {
+    let mut args = std::env::args().skip(1);
+    let out_dir = args
+        .next()
+        .unwrap_or_else(|| "showreel_frames_khipu".to_string());
+    let n: usize = args.next().and_then(|v| v.parse().ok()).unwrap_or(300);
+    let w: u32 = args.next().and_then(|v| v.parse().ok()).unwrap_or(1600);
+    let h: u32 = args.next().and_then(|v| v.parse().ok()).unwrap_or(900);
+    khipu_app::showreel::render_frames(&out_dir, n, w, h);
+}
diff --git a/00_unanchay/khipu/khipu-app/src/app.rs b/00_unanchay/khipu/khipu-app/src/app.rs
index 521dc18..7ef4d3e 100644
--- a/00_unanchay/khipu/khipu-app/src/app.rs
+++ b/00_unanchay/khipu/khipu-app/src/app.rs
@@ -6,7 +6,8 @@
 //! `cuerdas`, `panels`, `tejido` y `net`.
 
 use llimphi_motion::{animate, motion, Tween};
-use llimphi_ui::llimphi_hal::winit::keyboard::{Key, NamedKey};
+// Los del MODELO (W4.7), no los de winit: `event.key` ya es del compositor.
+use llimphi_ui::{Key, NamedKey};
 use llimphi_ui::llimphi_layout::taffy::{
     prelude::{length, percent, FlexDirection, Size, Style},
     Dimension,
@@ -36,14 +37,14 @@ use crate::panels::{
     captura_bar, editor_panel, header_view, list_panel, nombre_overlay, receive_panel,
     resonancia_panel, status_bar, sueltos_strip, tarjeta_cuerda, unlock_view,
 };
-use crate::tejido;
+use crate::{dibujo, redaccion, tejido};
 
 /// Tamaño inicial de la ventana. Compartido por `menubar_spec` y el App trait.
 const INITIAL_W: u32 = 1280;
 const INITIAL_H: u32 = 760;
 
 /// La aplicación Llimphi de khipu. Sin estado propio: todo vive en `Model`.
-pub(crate) struct KhipuApp;
+pub struct KhipuApp;
 
 // =====================================================================
 // Helpers locales de menú (no pueden vivir en menu.rs por ciclo de módulos)
@@ -62,6 +63,8 @@ fn handle_menu_command(mut model: Model, command: String, h: &Handle<Msg>) -> Mo
         "share.receive" => Some(Msg::Receive),
         "view.search" => Some(Msg::Focus(Focus::Search)),
         "cuerda.tejer" => model.cuerda_sel.map(Msg::Tejer),
+        "cuerda.redactar" => model.cuerda_sel.map(Msg::Redactar),
+        "note.dibujar" => model.selected.map(Msg::AbrirDibujo),
         "cuerda.renombrar" => model.cuerda_sel.map(Msg::RenombrarCuerda),
         "cuerda.desatar" => model.cuerda_sel.map(Msg::BorrarCuerda),
         "edit.undo" => Some(Msg::EditMenuAction(EditAction::Undo)),
@@ -96,6 +99,7 @@ fn apply_edit_menu_action(mut model: Model, action: EditAction, h: &Handle<Msg>)
         Focus::Search => Some(editmenu::apply(model.search.editor_mut(), action, clip)),
         Focus::PeerAddr => Some(editmenu::apply(model.peer_input.editor_mut(), action, clip)),
         Focus::Nombre => Some(editmenu::apply(model.nombre_input.editor_mut(), action, clip)),
+        Focus::DibujoTexto => Some(editmenu::apply(model.dibujo_texto.editor_mut(), action, clip)),
         Focus::Passphrase => Some(editmenu::apply(model.passphrase.editor_mut(), action, clip)),
         Focus::None => None,
     };
@@ -134,6 +138,19 @@ impl App for KhipuApp {
             None => {
                 // Un archivo presente pero ilegible NUNCA se pisa con el
                 // seed sin dejar copia: el cuaderno vale más que la app.
+                //
+                // Este `fs::copy` es IO sincrónico en `init` y lo marca el
+                // barrido de `scripts/auditar-hilo-ui.py`. **Es a propósito y
+                // no hay que moverlo a un worker.** El `persist(&model)` de
+                // unas líneas más abajo escribe sobre ESTA MISMA ruta: si la
+                // copia fuera asincrónica podría perder la carrera y el
+                // respaldo saldría del archivo ya pisado por el seed — es
+                // decir, justo la pérdida que esta copia existe para evitar.
+                // Además sólo corre en el camino raro (archivo presente pero
+                // ilegible) y es un `copy` de un archivo chico.
+                //
+                // auditar-hilo-ui: intencional — persist() pisa esta misma
+                // ruta al final de init; asincrónico perdería la carrera.
                 if let Some(p) = data_path.as_ref().filter(|p| p.exists()) {
                     let _ = std::fs::copy(p, p.with_extension("ilegible.bak"));
                 }
@@ -343,6 +360,116 @@ impl App for KhipuApp {
                 model.status = Some(format!("tejiendo «{}»…", c.nombre));
                 tejido::tejer(&model, cid, h);
             }
+            // --- la cuerda redactada -----------------------------------
+            Msg::Redactar(cid) => {
+                // Es un gesto síncrono corto (armar el haz + escribir un
+                // archivo + spawn); no vale un worker. Lo que puede tardar es
+                // pluma arrancando, y eso pasa en OTRO proceso.
+                let r = redaccion::redactar_cuerda(&model, cid, now_secs());
+                model.status = Some(r.aviso());
+            }
+
+            // --- el nudo dibujado --------------------------------------
+            Msg::AbrirDibujo(id) => {
+                if model.store.get(id).is_none() {
+                    return model;
+                }
+                let doc = model.store.dibujo(id).cloned();
+                model.dibujo = Some(dibujo::DibujoAbierto::abrir(id, doc));
+                model.dibujo_texto.clear();
+                // El lienzo se come la ventana: los overlays que taparían el
+                // dibujo se cierran, igual que hace `Esc` en cascada.
+                model.show_list = false;
+                model.resonancia = None;
+                model.menu_open = None;
+                model.edit_menu = None;
+                model.focus = Focus::None;
+            }
+            Msg::CerrarDibujo => {
+                if let Some(d) = model.dibujo.take() {
+                    // Sólo se toca la nota si algo cambió: cerrar un lienzo que
+                    // se abrió y no se dibujó no le corre el `updated_at`, que
+                    // es lo que ordena el cuaderno.
+                    if d.sucio {
+                        model.store.set_dibujo(d.nota, d.piz.doc, now_secs());
+                        // Un dibujo es una edición del nudo como cualquier
+                        // otra: entra a la línea de tiempo universal (willay).
+                        emitir_checkpoint_nota(&model, d.nota);
+                        persist(&model);
+                    }
+                }
+                model.dibujo_texto.clear();
+                model.focus = Focus::None;
+            }
+            Msg::DibujoHerramienta(herr) => {
+                if let Some(d) = model.dibujo.as_mut() {
+                    d.ui.usar(herr);
+                    d.escribiendo = None;
+                }
+                model.focus = Focus::None;
+            }
+            Msg::DibujoDrag(fase, dx, dy, lx, ly) => {
+                if let Some(d) = model.dibujo.as_mut() {
+                    if let Some(g) = d.ui.aplicar_drag(fase, dx, dy, lx, ly, &d.piz) {
+                        dibujo::aplicar_gesto(d, g);
+                    }
+                    model.focus = dibujo::foco_de(d);
+                }
+            }
+            Msg::DibujoClick(x, y) => {
+                if let Some(d) = model.dibujo.as_mut() {
+                    if let Some(g) = d.ui.aplicar_click(x, y, &d.piz) {
+                        dibujo::aplicar_gesto(d, g);
+                    }
+                    if d.escribiendo.is_some() {
+                        model.dibujo_texto.clear();
+                    }
+                    model.focus = dibujo::foco_de(d);
+                }
+            }
+            Msg::DibujoRueda(dy) => {
+                if let Some(d) = model.dibujo.as_mut() {
+                    // El foco del zoom es el último cursor conocido: la rueda
+                    // no dice dónde está (por eso el cable `mover`).
+                    let (px, py) = d.puntero;
+                    d.ui.aplicar_scroll(dy, px, py);
+                }
+            }
+            Msg::DibujoPuntero(x, y) => {
+                if let Some(d) = model.dibujo.as_mut() {
+                    d.puntero = (x, y);
+                }
+            }
+            Msg::DibujoDeshacer => {
+                if let Some(d) = model.dibujo.as_mut() {
+                    if d.piz.deshacer().is_some() {
+                        d.sucio = true;
+                    }
+                }
+            }
+            Msg::DibujoRehacer => {
+                if let Some(d) = model.dibujo.as_mut() {
+                    if d.piz.rehacer().is_some() {
+                        d.sucio = true;
+                    }
+                }
+            }
+            Msg::DibujoConfirmarTexto => {
+                let texto = model.dibujo_texto.text();
+                if let Some(d) = model.dibujo.as_mut() {
+                    dibujo::confirmar_texto(d, &texto);
+                }
+                model.dibujo_texto.clear();
+                model.focus = Focus::None;
+            }
+            Msg::DibujoCancelarTexto => {
+                if let Some(d) = model.dibujo.as_mut() {
+                    d.escribiendo = None;
+                }
+                model.dibujo_texto.clear();
+                model.focus = Focus::None;
+            }
+
             Msg::TejidoListo(cid, resultado) => {
                 model.tejiendo = None;
                 match resultado {
@@ -494,6 +621,9 @@ impl App for KhipuApp {
                     Focus::Nombre => {
                         model.nombre_input.handle(ev, clip);
                     }
+                    Focus::DibujoTexto => {
+                        model.dibujo_texto.handle(ev, clip);
+                    }
                     // El cuerpo es un editor multilínea (otro widget) y no hay
                     // campo sin foco: nunca llega un `Campo` con estos.
                     Focus::Body | Focus::None => {}
@@ -527,6 +657,10 @@ impl App for KhipuApp {
                         model.nombre_input.handle(TextInputEvent::Key(ev), &mut model.clipboard);
                         false
                     }
+                    Focus::DibujoTexto => {
+                        model.dibujo_texto.handle(TextInputEvent::Key(ev), &mut model.clipboard);
+                        false
+                    }
                     Focus::None => false,
                 };
                 if changed {
@@ -822,6 +956,43 @@ impl App for KhipuApp {
             .children(children);
         }
 
+        // El nudo dibujado: el lienzo ocupa la ventana entera bajo la
+        // cabecera. Es un modo, no un overlay — mientras se dibuja, el
+        // cuaderno no compite por el mismo puntero.
+        if let Some(lienzo) = dibujo::dibujo_panel(model) {
+            let mut capas: Vec<View<Msg>> = vec![lienzo];
+            if let Some(caja) = dibujo::texto_overlay(model, &input_palette) {
+                capas.push(caja);
+            }
+            let cuerpo = View::new(Style {
+                size: Size {
+                    width: percent(1.0_f32),
+                    height: Dimension::auto(),
+                },
+                flex_grow: 1.0,
+                min_size: Size {
+                    width: length(0.0_f32),
+                    height: length(0.0_f32),
+                },
+                ..Default::default()
+            })
+            .children(capas);
+            let mut children = vec![header_view(model), cuerpo];
+            if let Some(bar) = status_bar(model) {
+                children.push(bar);
+            }
+            return 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)
+            .children(children);
+        }
+
         let header = header_view(model);
         let captura = captura_bar(model, &input_palette);
         let sueltos = sueltos_strip(model);
@@ -971,6 +1142,37 @@ impl App for KhipuApp {
             }
             return Some(Msg::Key(event.clone()));
         }
+        // Con el lienzo de un nudo abierto las teclas son suyas: escribiendo
+        // un texto, Enter lo deja y Esc lo descarta; sin texto en curso, Esc
+        // cierra el lienzo (y guarda) — el mismo Esc en cascada del cuaderno.
+        if let Some(d) = model.dibujo.as_ref() {
+            if event.state == KeyState::Pressed && !event.repeat {
+                if d.escribiendo.is_some() {
+                    if matches!(&event.key, Key::Named(NamedKey::Enter)) {
+                        return Some(Msg::DibujoConfirmarTexto);
+                    }
+                    if matches!(&event.key, Key::Named(NamedKey::Escape)) {
+                        return Some(Msg::DibujoCancelarTexto);
+                    }
+                } else {
+                    if matches!(&event.key, Key::Named(NamedKey::Escape)) {
+                        return Some(Msg::CerrarDibujo);
+                    }
+                    // Ctrl+Z / Ctrl+Shift+Z: el deshacer de la pizarra, que es
+                    // el del documento y no el de una caja de texto.
+                    if event.modifiers.ctrl
+                        && matches!(&event.key, Key::Character(s) if s.eq_ignore_ascii_case("z"))
+                    {
+                        return Some(if event.modifiers.shift {
+                            Msg::DibujoRehacer
+                        } else {
+                            Msg::DibujoDeshacer
+                        });
+                    }
+                }
+            }
+            return Some(Msg::Key(event.clone()));
+        }
         // Renombrando una cuerda: Enter confirma, Esc cancela.
         if model.renombrando.is_some() && model.focus == Focus::Nombre {
             if event.state == KeyState::Pressed && !event.repeat {
@@ -1023,3 +1225,220 @@ impl App for KhipuApp {
         (INITIAL_W, INITIAL_H)
     }
 }
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+    use crate::modelo::Embedder;
+    use pizarra_llimphi::{FaseDrag, Herramienta};
+
+    fn h() -> Handle<Msg> {
+        Handle::for_test()
+    }
+
+    /// Un modelo sin disco: `data_path` en `None` hace que `persist` sea un
+    /// no-op, así que el test no toca `$XDG_DATA_HOME`.
+    fn modelo() -> Model {
+        let mut m = Model::blank(Embedder::Local);
+        m.data_path = None;
+        m
+    }
+
+    /// Trazar es: apretar, mover, soltar. Es el gesto mínimo que tiene que
+    /// terminar en un elemento del documento — si el cableado del drag
+    /// estuviera mal, esto queda en cero y el lienzo se ve muerto sin error.
+    fn trazar(mut m: Model) -> Model {
+        // `DragPhase` sólo tiene `Move`/`End`: el primer `Move` abre el gesto.
+        m = KhipuApp::update(m, Msg::DibujoDrag(FaseDrag::Move, 10.0, 10.0, 10.0, 10.0), &h());
+        m = KhipuApp::update(m, Msg::DibujoDrag(FaseDrag::Move, 20.0, 20.0, 30.0, 30.0), &h());
+        KhipuApp::update(m, Msg::DibujoDrag(FaseDrag::End, 0.0, 0.0, 30.0, 30.0), &h())
+    }
+
+    #[test]
+    fn dibujar_un_nudo_y_cerrar_deja_el_trazo_en_la_nota() {
+        let mut m = modelo();
+        let id = m.store.create("flecha", "de A a B", vec![], 100);
+        assert!(!m.store.get(id).unwrap().dibujado());
+
+        m = KhipuApp::update(m, Msg::AbrirDibujo(id), &h());
+        assert!(m.dibujo.is_some(), "el lienzo se abre");
+        // Y se abre con el lápiz: el gesto probable al abrir es trazar.
+        assert_eq!(m.dibujo.as_ref().unwrap().ui.herramienta, Herramienta::Lapiz);
+
+        m = trazar(m);
+        assert_eq!(m.dibujo.as_ref().unwrap().piz.doc.len(), 1, "el trazo entró");
+        assert!(m.dibujo.as_ref().unwrap().sucio);
+
+        m = KhipuApp::update(m, Msg::CerrarDibujo, &h());
+        assert!(m.dibujo.is_none(), "el lienzo se cierra");
+        assert!(m.store.get(id).unwrap().dibujado(), "el trazo quedó EN LA NOTA");
+        assert_eq!(m.store.dibujo(id).map(|d| d.len()), Some(1));
+    }
+
+    #[test]
+    fn reabrir_un_nudo_dibujado_trae_lo_dibujado() {
+        let mut m = modelo();
+        let id = m.store.create("flecha", "", vec![], 100);
+        m = KhipuApp::update(m, Msg::AbrirDibujo(id), &h());
+        m = trazar(m);
+        m = KhipuApp::update(m, Msg::CerrarDibujo, &h());
+
+        m = KhipuApp::update(m, Msg::AbrirDibujo(id), &h());
+        assert_eq!(
+            m.dibujo.as_ref().unwrap().piz.doc.len(),
+            1,
+            "reabrir tiene que traer el dibujo, no un lienzo en blanco"
+        );
+        // Y reabrir no ensucia: cerrar sin tocar nada no corre `updated_at`.
+        assert!(!m.dibujo.as_ref().unwrap().sucio);
+    }
+
+    #[test]
+    fn abrir_y_cerrar_sin_dibujar_no_le_corre_la_fecha_al_nudo() {
+        let mut m = modelo();
+        let id = m.store.create("nada", "", vec![], 100);
+        let antes = m.store.get(id).unwrap().updated_at;
+
+        m = KhipuApp::update(m, Msg::AbrirDibujo(id), &h());
+        m = KhipuApp::update(m, Msg::CerrarDibujo, &h());
+
+        assert_eq!(m.store.get(id).unwrap().updated_at, antes);
+        assert!(!m.store.get(id).unwrap().dibujado());
+    }
+
+    #[test]
+    fn deshacer_saca_el_ultimo_trazo_y_rehacer_lo_devuelve() {
+        let mut m = modelo();
+        let id = m.store.create("flecha", "", vec![], 100);
+        m = KhipuApp::update(m, Msg::AbrirDibujo(id), &h());
+        m = trazar(m);
+        assert_eq!(m.dibujo.as_ref().unwrap().piz.doc.len(), 1);
+
+        m = KhipuApp::update(m, Msg::DibujoDeshacer, &h());
+        assert_eq!(m.dibujo.as_ref().unwrap().piz.doc.len(), 0);
+        m = KhipuApp::update(m, Msg::DibujoRehacer, &h());
+        assert_eq!(m.dibujo.as_ref().unwrap().piz.doc.len(), 1);
+    }
+
+    /// Deshacer hasta vaciar y cerrar tiene que DESDIBUJAR el nudo, no
+    /// dejarlo con un documento vacío que igual pintaría la marca `◇`.
+    #[test]
+    fn deshacer_todo_y_cerrar_desdibuja_el_nudo() {
+        let mut m = modelo();
+        let id = m.store.create("flecha", "", vec![], 100);
+        m = KhipuApp::update(m, Msg::AbrirDibujo(id), &h());
+        m = trazar(m);
+        m = KhipuApp::update(m, Msg::DibujoDeshacer, &h());
+        assert_eq!(m.dibujo.as_ref().unwrap().piz.doc.len(), 0);
+        m = KhipuApp::update(m, Msg::CerrarDibujo, &h());
+        assert!(!m.store.get(id).unwrap().dibujado(), "sin trazos no hay dibujo");
+        assert!(m.store.get(id).unwrap().dibujo.is_none(), "ni un doc vacío guardado");
+    }
+
+    /// El deshacer es **de la sesión de dibujo**, no del cuaderno: reabrir un
+    /// nudo no ofrece deshacer lo que se dibujó la vez anterior. Es lo mismo
+    /// que hace la pizarra standalone (la pila no se persiste), y la barra
+    /// tampoco pinta el botón — pero si el mensaje llegara igual, cerrar no
+    /// puede borrar un dibujo guardado.
+    #[test]
+    fn reabrir_no_ofrece_deshacer_lo_de_la_sesion_anterior() {
+        let mut m = modelo();
+        let id = m.store.create("flecha", "", vec![], 100);
+        m = KhipuApp::update(m, Msg::AbrirDibujo(id), &h());
+        m = trazar(m);
+        m = KhipuApp::update(m, Msg::CerrarDibujo, &h());
+
+        m = KhipuApp::update(m, Msg::AbrirDibujo(id), &h());
+        assert!(
+            !m.dibujo.as_ref().unwrap().piz.hay_para_deshacer(),
+            "la pila de deshacer no viaja al disco"
+        );
+        m = KhipuApp::update(m, Msg::DibujoDeshacer, &h());
+        m = KhipuApp::update(m, Msg::CerrarDibujo, &h());
+        assert!(
+            m.store.get(id).unwrap().dibujado(),
+            "un deshacer sin nada que deshacer no puede borrar el dibujo"
+        );
+    }
+
+    /// El texto sobre el lienzo: la herramienta abre la caja en el punto
+    /// tocado, Enter lo deja como elemento y Esc no deja nada.
+    #[test]
+    fn escribir_sobre_el_lienzo_deja_el_texto_y_cancelar_no() {
+        let mut m = modelo();
+        let id = m.store.create("esquema", "", vec![], 100);
+        m = KhipuApp::update(m, Msg::AbrirDibujo(id), &h());
+        m = KhipuApp::update(m, Msg::DibujoHerramienta(Herramienta::Texto), &h());
+        m = KhipuApp::update(m, Msg::DibujoClick(40.0, 40.0), &h());
+        assert!(m.dibujo.as_ref().unwrap().escribiendo.is_some(), "se abre la caja");
+        assert_eq!(m.focus, Focus::DibujoTexto, "y el teclado va a la caja");
+
+        m.dibujo_texto.set_text("causa");
+        m = KhipuApp::update(m, Msg::DibujoConfirmarTexto, &h());
+        assert_eq!(m.dibujo.as_ref().unwrap().piz.doc.len(), 1);
+        assert!(m.dibujo.as_ref().unwrap().escribiendo.is_none());
+
+        // Cancelar no deja nada.
+        m = KhipuApp::update(m, Msg::DibujoClick(80.0, 80.0), &h());
+        m.dibujo_texto.set_text("esto no va");
+        m = KhipuApp::update(m, Msg::DibujoCancelarTexto, &h());
+        assert_eq!(m.dibujo.as_ref().unwrap().piz.doc.len(), 1, "sigue habiendo uno solo");
+    }
+
+    /// Un texto vacío no ensucia el dibujo: abrir la caja y arrepentirse con
+    /// Enter es lo mismo que con Esc.
+    #[test]
+    fn confirmar_un_texto_vacio_no_deja_elemento() {
+        let mut m = modelo();
+        let id = m.store.create("esquema", "", vec![], 100);
+        m = KhipuApp::update(m, Msg::AbrirDibujo(id), &h());
+        m = KhipuApp::update(m, Msg::DibujoHerramienta(Herramienta::Texto), &h());
+        m = KhipuApp::update(m, Msg::DibujoClick(40.0, 40.0), &h());
+        m = KhipuApp::update(m, Msg::DibujoConfirmarTexto, &h());
+        assert_eq!(m.dibujo.as_ref().unwrap().piz.doc.len(), 0);
+    }
+
+    /// Con el lienzo abierto, Esc cierra el lienzo — no el overlay de atrás.
+    #[test]
+    fn escape_con_lienzo_abierto_cierra_el_lienzo() {
+        let mut m = modelo();
+        let id = m.store.create("flecha", "", vec![], 100);
+        m = KhipuApp::update(m, Msg::AbrirDibujo(id), &h());
+        let ev = KeyEvent {
+            key: Key::Named(NamedKey::Escape),
+            state: KeyState::Pressed,
+            text: None,
+            modifiers: Default::default(),
+            repeat: false,
+        };
+        assert!(matches!(KhipuApp::on_key(&m, &ev), Some(Msg::CerrarDibujo)));
+
+        // Pero si hay un texto en curso, Esc es del texto.
+        m = KhipuApp::update(m, Msg::DibujoHerramienta(Herramienta::Texto), &h());
+        m = KhipuApp::update(m, Msg::DibujoClick(40.0, 40.0), &h());
+        assert!(matches!(
+            KhipuApp::on_key(&m, &ev),
+            Some(Msg::DibujoCancelarTexto)
+        ));
+    }
+
+    /// Abrir el lienzo de un nudo que no existe no puede dejar la app en un
+    /// modo del que no se sale (el panel se pinta contra `model.dibujo`).
+    #[test]
+    fn abrir_el_dibujo_de_un_nudo_inexistente_no_abre_nada() {
+        let mut m = modelo();
+        m = KhipuApp::update(m, Msg::AbrirDibujo(4242), &h());
+        assert!(m.dibujo.is_none());
+    }
+
+    /// Redactar una cuerda sin nudos avisa en vez de escribir un borrador
+    /// vacío. (El camino feliz escribe en disco y lo cubre `redaccion`.)
+    #[test]
+    fn redactar_una_cuerda_vacia_avisa() {
+        let mut m = modelo();
+        let c = m.store.crear_cuerda("nada todavía", 100);
+        m = KhipuApp::update(m, Msg::Redactar(c), &h());
+        let aviso = m.status.clone().unwrap_or_default();
+        assert!(aviso.contains("no tiene nudos"), "aviso: {aviso}");
+    }
+}
diff --git a/00_unanchay/khipu/khipu-app/src/cuerdas.rs b/00_unanchay/khipu/khipu-app/src/cuerdas.rs
index 4005f32..ce8d98d 100644
--- a/00_unanchay/khipu/khipu-app/src/cuerdas.rs
+++ b/00_unanchay/khipu/khipu-app/src/cuerdas.rs
@@ -56,6 +56,10 @@ pub(crate) struct NudoVis {
     pub(crate) y: f32,
     pub(crate) estado: EstadoNudo,
     pub(crate) es_tejido: bool,
+    /// El nudo lleva un dibujo. Se pinta como una marca **al lado** del nudo,
+    /// no cambiándole la forma: el estado (normal / pendiente / hecho) ya usa
+    /// la forma, y un pendiente dibujado tiene que seguir viéndose pendiente.
+    pub(crate) dibujado: bool,
     pub(crate) titulo: String,
 }
 
@@ -119,6 +123,7 @@ pub(crate) fn layout_cuerdas(model: &Model, h: f32, now: u64) -> Vec<CuerdaVis>
                     y: NUDOS_TOP + j as f32 * espacio,
                     estado: n.estado,
                     es_tejido: cuerda.tejido == Some(n.id),
+                    dibujado: n.dibujado(),
                     titulo: short_label(&n.title),
                 })
                 .collect();
@@ -355,6 +360,19 @@ fn paint_cuerdas(
                     }
                 }
             }
+            // La marca del dibujo: un rombito hueco pegado al nudo. Va al lado
+            // y no encima porque la forma del nudo ya dice su estado.
+            if n.dibujado {
+                let r = 3.2_f64;
+                let cx = px - (NUDO_R as f64) - 5.0;
+                let mut marca = BezPath::new();
+                marca.move_to((cx, py - r));
+                marca.line_to((cx + r, py));
+                marca.line_to((cx, py + r));
+                marca.line_to((cx - r, py));
+                marca.close_path();
+                scene.stroke(&Stroke::new(1.2), Affine::IDENTITY, col, None, &marca);
+            }
             if sel_nota == Some(n.id) {
                 let ring = KurboCircle::new((px, py), (NUDO_R + 4.0) as f64);
                 scene.stroke(&Stroke::new(1.8), Affine::IDENTITY, theme.accent, None, &ring);
@@ -410,8 +428,8 @@ pub(crate) fn editor_shell(child: View<Msg>, theme: &Theme) -> View<Msg> {
             height: length(28.0_f32),
         },
         flex_shrink: 0.0,
-        justify_content: Some(JustifyContent::End),
-        align_items: Some(AlignItems::Center),
+        justify_content: Some(JustifyContent::END),
+        align_items: Some(AlignItems::CENTER),
         padding: Rect {
             left: length(6.0_f32),
             right: length(6.0_f32),
diff --git a/00_unanchay/khipu/khipu-app/src/dibujo.rs b/00_unanchay/khipu/khipu-app/src/dibujo.rs
new file mode 100644
index 0000000..cab3bd5
--- /dev/null
+++ b/00_unanchay/khipu/khipu-app/src/dibujo.rs
@@ -0,0 +1,340 @@
+//! `dibujo` — el **nudo dibujado**: capturar un pensamiento que no es una frase.
+//!
+//! khipu nació para tirar ideas en cualquier estado mental. Pero hasta acá
+//! «cualquier estado mental» quería decir *cualquier estado mental que entre en
+//! un renglón*: una flecha entre dos cosas, un esquema de tres cajas, la forma
+//! de un problema, no tenían dónde caer. Un dibujo es una expresión mental más,
+//! igual que un TODO — y por eso vive **adentro del nudo** y no en otra app.
+//!
+//! # Qué se reusa y qué no
+//!
+//! El documento es `pizarra_core::DocPizarra` tal cual, guardado en
+//! `Note.dibujo`: no hay un formato de dibujo de khipu. El editor es
+//! **`pizarra-llimphi`** — el compositor: `PizarraUiState` (el reducer puro de
+//! gestos), `lienzo_view` (el lienzo con selección, zoom y paneo) y
+//! `herramientas_view` (las siete herramientas). Lo que khipu pone es el
+//! **shell**: dónde vive el lienzo, cuándo se guarda y con qué se cierra.
+//!
+//! No se embebe `pizarra-app`, que es el otro shell (con su cajón de archivos,
+//! su panel de sesión y su barra) — el README de pizarra parte los dos crates
+//! justamente así, y swami embebe la app entera porque quiere el pizarrón
+//! completo. khipu quiere un lienzo dentro de un nudo, que es otra cosa.
+//!
+//! # Lo que el dibujo NO hace
+//!
+//! No resuena. Los embeddings leen título y cuerpo — un trazo no tiene texto
+//! que comparar, y fabricar un vector desde la geometría sería inventar una
+//! afinidad que nadie midió. Un nudo dibujado que además tiene texto resuena
+//! por su texto, como cualquier otro.
+//!
+//! Y no viaja: `khipu-share` manda `SharedNote` (título, cuerpo, etiquetas), y
+//! su sobre no está versionado — agregarle un campo rompería en silencio a todo
+//! par que corra la versión anterior. El dibujo se queda en el cuaderno local
+//! hasta que ese sobre tenga versión.
+
+use std::sync::Arc;
+
+use llimphi_ui::llimphi_layout::taffy::{
+    prelude::{length, percent, FlexDirection, Position, Rect, Size, Style},
+    AlignItems, Dimension,
+};
+use llimphi_ui::llimphi_text::Alignment;
+use llimphi_ui::View;
+use llimphi_widget_text_input::{text_input_view_full, TextInputPalette};
+use pizarra_core::{DocPizarra, Elemento, Pizarra, Punto};
+use pizarra_llimphi::{
+    herramientas_view, lienzo_view, Cables, Gesto, Herramienta, PizarraPalette, PizarraUiState,
+};
+
+use crate::modelo::{Focus, Model, Msg};
+use crate::panels::button;
+
+/// El autor con que khipu firma los elementos que dibuja. `DocPizarra` es un
+/// CRDT y cada elemento lleva `(autor, contador)` como identidad: un número
+/// fijo alcanza mientras el dibujo no se comparta, y el día que se comparta
+/// tendrá que salir de la identidad agora del cuaderno, no de acá.
+pub(crate) const AUTOR: u64 = 1;
+
+/// Alto de la barra de herramientas del lienzo.
+const BARRA_H: f32 = 42.0;
+
+/// El dibujo que está abierto sobre un nudo.
+///
+/// Es una **sesión de edición**, no el dato: el dato es el `DocPizarra` que
+/// vive en la nota. `Pizarra` le agrega el autor, el reloj de Lamport y las
+/// pilas de deshacer/rehacer, que son del momento de dibujar y no se guardan.
+pub(crate) struct DibujoAbierto {
+    /// El nudo que se está dibujando.
+    pub(crate) nota: khipu_core::NoteId,
+    /// El documento vivo, con su deshacer.
+    pub(crate) piz: Pizarra,
+    /// El gesto en curso (herramienta, vista, selección).
+    pub(crate) ui: PizarraUiState,
+    /// Escribiendo un texto en ese punto del lienzo (`Herramienta::Texto`).
+    pub(crate) escribiendo: Option<Punto>,
+    /// Última posición del cursor sobre el lienzo, en px de pantalla: es el
+    /// foco del zoom, que la rueda no informa.
+    pub(crate) puntero: Punto,
+    /// Cambió algo desde que se abrió. Cerrar sin cambios no toca la nota (ni
+    /// le corre el `updated_at`, que es lo que ordena el cuaderno).
+    pub(crate) sucio: bool,
+}
+
+impl DibujoAbierto {
+    /// Abre el dibujo de un nudo — el que ya tenga, o uno en blanco.
+    pub(crate) fn abrir(nota: khipu_core::NoteId, doc: Option<DocPizarra>) -> Self {
+        let piz = match doc {
+            Some(d) => Pizarra::desde_doc(AUTOR, d),
+            None => Pizarra::nueva(AUTOR),
+        };
+        // Se abre con el lápiz: el gesto más probable al abrir un lienzo es
+        // trazar, no seleccionar.
+        Self {
+            nota,
+            ui: PizarraUiState::nueva(Herramienta::Lapiz),
+            piz,
+            escribiendo: None,
+            puntero: (0.0, 0.0),
+            sucio: false,
+        }
+    }
+}
+
+/// Aplica un gesto terminado del widget al documento. Espeja `aplicar_gesto` de
+/// `pizarra-app`: el widget describe, el host decide.
+pub(crate) fn aplicar_gesto(dib: &mut DibujoAbierto, g: Gesto) {
+    match g {
+        Gesto::Dibujado(e) => {
+            dib.piz.agregar(e);
+            dib.sucio = true;
+        }
+        Gesto::Movido { id, d } => {
+            dib.piz.mover(id, d);
+            dib.sucio = true;
+        }
+        // La selección y la vista ya viven en el `PizarraUiState` que el
+        // reducer mutó: no son cambios del documento y no ensucian el nudo.
+        Gesto::Selecciono(_) | Gesto::VistaCambio => {}
+        Gesto::Escribir(p) => {
+            dib.escribiendo = Some(p);
+        }
+    }
+}
+
+/// Confirma el texto que se estaba escribiendo sobre el lienzo. Un texto vacío
+/// no deja elemento — abrir el cursor y arrepentirse no ensucia el dibujo.
+pub(crate) fn confirmar_texto(d: &mut DibujoAbierto, texto: &str) {
+    if let (Some(pos), false) = (d.escribiendo, texto.trim().is_empty()) {
+        d.piz.agregar(Elemento::Texto {
+            pos,
+            contenido: texto.trim().to_string(),
+            tamano: 18.0,
+            color: d.ui.color,
+        });
+        d.sucio = true;
+    }
+    d.escribiendo = None;
+}
+
+/// El lienzo del nudo, como panel a pantalla completa sobre el canvas de
+/// cuerdas. Barra de herramientas arriba, lienzo abajo.
+pub(crate) fn dibujo_panel(model: &Model) -> Option<View<Msg>> {
+    let d = model.dibujo.as_ref()?;
+    let theme = &model.theme;
+    let pal = PizarraPalette::from_theme(theme);
+
+    let titulo = model
+        .store
+        .get(d.nota)
+        .map(|n| {
+            let t = n.title.trim();
+            if t.is_empty() {
+                "nudo sin título".to_string()
+            } else {
+                t.to_string()
+            }
+        })
+        .unwrap_or_else(|| "nudo".into());
+
+    let herramientas = herramientas_view(d.ui.herramienta, &pal, Msg::DibujoHerramienta);
+
+    let nombre = View::new(Style {
+        size: Size {
+            width: Dimension::auto(),
+            height: length(30.0_f32),
+        },
+        flex_grow: 1.0,
+        ..Default::default()
+    })
+    .text_aligned(titulo, 12.0, theme.fg_muted, Alignment::Start);
+
+    let mut acciones: Vec<View<Msg>> = Vec::new();
+    if d.piz.hay_para_deshacer() {
+        acciones.push(button("deshacer", theme.bg_button, theme.fg_text, Msg::DibujoDeshacer));
+    }
+    if d.piz.hay_para_rehacer() {
+        acciones.push(button("rehacer", theme.bg_button, theme.fg_text, Msg::DibujoRehacer));
+    }
+    acciones.push(button("listo", theme.bg_button, theme.accent, Msg::CerrarDibujo));
+
+    let barra = View::new(Style {
+        flex_direction: FlexDirection::Row,
+        size: Size {
+            width: percent(1.0_f32),
+            height: length(BARRA_H),
+        },
+        flex_shrink: 0.0,
+        align_items: Some(AlignItems::CENTER),
+        gap: Size {
+            width: length(6.0_f32),
+            height: length(0.0_f32),
+        },
+        padding: Rect {
+            left: length(8.0_f32),
+            right: length(8.0_f32),
+            top: length(0.0_f32),
+            bottom: length(0.0_f32),
+        },
+        ..Default::default()
+    })
+    .fill(theme.bg_panel)
+    .children({
+        let mut v = vec![herramientas, nombre];
+        v.extend(acciones);
+        v
+    });
+
+    let lienzo = lienzo_view(
+        &d.piz.doc,
+        &d.ui,
+        &pal,
+        Cables {
+            drag: Some(Arc::new(|fase, dx, dy, lx, ly| {
+                Some(Msg::DibujoDrag(fase, dx, dy, lx, ly))
+            })),
+            click: Some(Arc::new(|x, y| Some(Msg::DibujoClick(x, y)))),
+            scroll: Some(Arc::new(|_dx, dy| Some(Msg::DibujoRueda(dy)))),
+            mover: Some(Arc::new(|x, y| Some(Msg::DibujoPuntero(x, y)))),
+        },
+    );
+
+    Some(
+        View::new(Style {
+            position: Position::Absolute,
+            inset: Rect {
+                left: length(0.0_f32),
+                right: length(0.0_f32),
+                top: length(0.0_f32),
+                bottom: length(0.0_f32),
+            },
+            flex_direction: FlexDirection::Column,
+            ..Default::default()
+        })
+        .fill(theme.bg_app)
+        .children(vec![barra, lienzo]),
+    )
+}
+
+/// El foco de teclado que corresponde con un dibujo abierto: si hay un texto en
+/// curso, la caja; si no, ninguno (las teclas van al lienzo).
+pub(crate) fn foco_de(d: &DibujoAbierto) -> Focus {
+    if d.escribiendo.is_some() {
+        Focus::DibujoTexto
+    } else {
+        Focus::None
+    }
+}
+
+/// La caja de texto que se abre al tocar el lienzo con `Herramienta::Texto`.
+///
+/// Se dibuja **cerca de donde se tocó** (convertido de coordenadas de lienzo a
+/// pantalla por la misma `Vista` que pinta), no en una esquina fija: escribir
+/// una etiqueta en un diagrama es un gesto local, y una caja lejos del punto
+/// obliga a ir y volver con la mirada para saber qué se está rotulando.
+pub(crate) fn texto_overlay(
+    model: &Model,
+    input_palette: &TextInputPalette,
+) -> Option<View<Msg>> {
+    let d = model.dibujo.as_ref()?;
+    let pos = d.escribiendo?;
+    let theme = &model.theme;
+
+    // De px de lienzo a px de pantalla. La barra de herramientas corre el
+    // lienzo hacia abajo, así que se suma su alto.
+    let (sx, sy) = d.ui.vista.a_pantalla(pos);
+    let left = sx.max(4.0);
+    let top = (sy + BARRA_H).max(4.0);
+
+    let label = View::new(Style {
+        size: Size {
+            width: percent(1.0_f32),
+            height: length(14.0_f32),
+        },
+        flex_shrink: 0.0,
+        ..Default::default()
+    })
+    .text_aligned(
+        "texto — Enter confirma, Esc cancela".to_string(),
+        10.0,
+        theme.fg_muted,
+        Alignment::Start,
+    );
+
+    let input = text_input_view_full(
+        &model.dibujo_texto,
+        "escribí…",
+        model.focus == Focus::DibujoTexto,
+        input_palette,
+        |ev| Msg::Campo(Focus::DibujoTexto, ev),
+    );
+    let input_row = View::new(Style {
+        size: Size {
+            width: percent(1.0_f32),
+            height: length(26.0_f32),
+        },
+        flex_shrink: 0.0,
+        ..Default::default()
+    })
+    .children(vec![input]);
+
+    let card = View::new(Style {
+        flex_direction: FlexDirection::Column,
+        size: Size {
+            width: length(240.0_f32),
+            height: Dimension::auto(),
+        },
+        padding: Rect {
+            left: length(8.0_f32),
+            right: length(8.0_f32),
+            top: length(6.0_f32),
+            bottom: length(8.0_f32),
+        },
+        gap: Size {
+            width: length(0.0_f32),
+            height: length(4.0_f32),
+        },
+        ..Default::default()
+    })
+    .fill(theme.bg_panel)
+    .border(1.0, theme.accent)
+    .radius(6.0)
+    .children(vec![label, input_row]);
+
+    Some(
+        View::new(Style {
+            position: Position::Absolute,
+            inset: Rect {
+                left: length(left),
+                top: length(top),
+                right: llimphi_ui::llimphi_layout::taffy::prelude::auto(),
+                bottom: llimphi_ui::llimphi_layout::taffy::prelude::auto(),
+            },
+            size: Size {
+                width: length(240.0_f32),
+                height: Dimension::auto(),
+            },
+            ..Default::default()
+        })
+        .children(vec![card]),
+    )
+}
diff --git a/00_unanchay/khipu/khipu-app/src/estado.rs b/00_unanchay/khipu/khipu-app/src/estado.rs
index 4ba66c1..20b583a 100644
--- a/00_unanchay/khipu/khipu-app/src/estado.rs
+++ b/00_unanchay/khipu/khipu-app/src/estado.rs
@@ -15,7 +15,7 @@ use std::path::{Path, PathBuf};
 
 use agora_core::Keypair;
 use directories::ProjectDirs;
-use khipu_core::{Note, NoteId, NoteStore};
+use khipu_core::{Cuerda, CuerdaId, EstadoNudo, Note, NoteId, NoteStore, Origen};
 use llimphi_widget_text_editor::EditorState;
 use serde::{Deserialize, Serialize};
 
@@ -26,9 +26,9 @@ use llimphi_ui::Handle;
 // Formatos de persistencia
 // =====================================================================
 
-/// Formato vigente (V4): el store trae nudos **y cuerdas**; las notas ya
-/// no tienen posición de mapa (el lienzo de cuerdas se deriva, no se
-/// persiste) ni las regiones existen.
+/// Formato vigente (V5): como el V4 (nudos **y** cuerdas, sin posición de
+/// mapa ni regiones) más el **dibujo** del nudo — un `DocPizarra` guardado
+/// tal cual, no una conversión.
 #[derive(Serialize, Deserialize)]
 pub(crate) struct PersistedState {
     pub(crate) store: NoteStore,
@@ -101,6 +101,71 @@ struct PersistedV1 {
     order: Vec<NoteId>,
 }
 
+// --- Sombra del V4: el `Note` de antes del dibujo. Todo lo demás es
+// --- idéntico, incluidas las cuerdas, así que la migración es total: no se
+// --- pierde ni un nudo ni un anudado, sólo se estrena el campo en `None`.
+
+#[derive(Deserialize)]
+struct NoteV4 {
+    id: NoteId,
+    title: String,
+    body: String,
+    tags: Vec<String>,
+    created_at: u64,
+    updated_at: u64,
+    last_access: u64,
+    mass: f32,
+    cuerda: Option<CuerdaId>,
+    estado: EstadoNudo,
+    origen: Option<Origen>,
+}
+
+#[derive(Deserialize)]
+struct NoteStoreV4 {
+    notes: BTreeMap<NoteId, NoteV4>,
+    #[allow(dead_code)]
+    next_id: NoteId,
+    cuerdas: BTreeMap<CuerdaId, Cuerda>,
+    #[allow(dead_code)]
+    next_cuerda: CuerdaId,
+}
+
+#[derive(Deserialize)]
+struct PersistedV4 {
+    store: NoteStoreV4,
+    embeddings: Vec<(NoteId, Vec<f32>)>,
+    order: Vec<NoteId>,
+    model: String,
+}
+
+/// Migra un V4 al modelo actual: **nada se pierde**. Los nudos conservan id,
+/// contenido, cuerda y estado; las cuerdas conservan su nombre y su tejido; lo
+/// único nuevo es `dibujo`, que nace vacío porque antes no existía.
+fn migrar_v4(viejo: NoteStoreV4) -> NoteStore {
+    let mut store = NoteStore::new();
+    for (_, n) in viejo.notes {
+        store.insert_raw(Note {
+            id: n.id,
+            title: n.title,
+            body: n.body,
+            tags: n.tags,
+            created_at: n.created_at,
+            updated_at: n.updated_at,
+            last_access: n.last_access,
+            mass: n.mass,
+            cuerda: n.cuerda,
+            estado: n.estado,
+            origen: n.origen,
+            dibujo: None,
+        });
+    }
+    // Las cuerdas se restauran con su identidad: si se perdieran, todos los
+    // nudos anudados quedarían apuntando a una cuerda inexistente y
+    // `coherente()` descartaría el cuaderno entero.
+    store.restaurar_cuerdas(viejo.cuerdas);
+    store
+}
+
 /// Migra un store viejo al modelo actual: mismas identidades, sin
 /// posición; todo nace suelto (las cuerdas se anudan de aquí en más).
 fn migrar_store(viejo: NoteStoreViejo) -> NoteStore {
@@ -118,6 +183,7 @@ fn migrar_store(viejo: NoteStoreViejo) -> NoteStore {
             cuerda: None,
             estado: Default::default(),
             origen: None,
+            dibujo: None,
         });
     }
     store
@@ -159,6 +225,21 @@ pub(crate) fn load_state(path: &PathBuf) -> Option<PersistedState> {
             return Some(state);
         }
     }
+    // V4: el cuaderno de resonancia sin dibujos. Migra entero (nudos,
+    // cuerdas y tejidos) con backup propio — el `v3.bak` es de otra época y
+    // pisarlo borraría el respaldo del mapa muerto.
+    if let Ok(v4) = postcard::from_bytes::<PersistedV4>(&bytes) {
+        let store = migrar_v4(v4.store);
+        if store.coherente() {
+            respaldar_una_vez(path, "v4.bak");
+            return Some(PersistedState {
+                store,
+                embeddings: v4.embeddings,
+                order: v4.order,
+                model: v4.model,
+            });
+        }
+    }
     // Formatos del mapa muerto: migran con backup (notes.v3.bak) para que
     // ninguna migración fallida pueda costar el cuaderno.
     if let Ok(v3) = postcard::from_bytes::<PersistedV3>(&bytes) {
@@ -595,7 +676,7 @@ mod tests {
     }
 
     #[test]
-    fn el_formato_v4_roundtripea_con_cuerdas() {
+    fn el_formato_vigente_roundtripea_con_cuerdas() {
         let mut store = NoteStore::new();
         let a = store.create("nudo a", "", vec![], 10);
         let b = store.create("nudo b", "", vec![], 20);
@@ -623,6 +704,178 @@ mod tests {
         let _ = std::fs::remove_dir_all(&dir);
     }
 
+    // --- V4 → V5: el cuaderno de resonancia SIN dibujos --------------------
+    // Twins serializables con la forma posicional exacta con que el khipu
+    // pre-dibujo escribía notes.bin. El test escribe bytes de verdad: si el
+    // `Deserialize` de arriba divergiera, la migración sería mentira.
+
+    #[derive(serde::Serialize)]
+    struct NoteV4W {
+        id: u64,
+        title: String,
+        body: String,
+        tags: Vec<String>,
+        created_at: u64,
+        updated_at: u64,
+        last_access: u64,
+        mass: f32,
+        cuerda: Option<u64>,
+        estado: EstadoNudo,
+        origen: Option<Origen>,
+    }
+    #[derive(serde::Serialize)]
+    struct CuerdaW {
+        id: u64,
+        nombre: String,
+        created_at: u64,
+        tejido: Option<u64>,
+    }
+    #[derive(serde::Serialize)]
+    struct NoteStoreV4W {
+        notes: BTreeMap<u64, NoteV4W>,
+        next_id: u64,
+        cuerdas: BTreeMap<u64, CuerdaW>,
+        next_cuerda: u64,
+    }
+    #[derive(serde::Serialize)]
+    struct PersistedV4W {
+        store: NoteStoreV4W,
+        embeddings: Vec<(u64, Vec<f32>)>,
+        order: Vec<u64>,
+        model: String,
+    }
+
+    fn nota_v4(id: u64, title: &str, cuerda: Option<u64>) -> NoteV4W {
+        NoteV4W {
+            id,
+            title: title.into(),
+            body: format!("cuerpo de {title}"),
+            tags: vec!["v4".into()],
+            created_at: 100 + id,
+            updated_at: 200 + id,
+            last_access: 300 + id,
+            mass: 0.7,
+            cuerda,
+            estado: EstadoNudo::Pendiente,
+            origen: Some(Origen {
+                app: "pata".into(),
+                referencia: "captura-rapida".into(),
+            }),
+        }
+    }
+
+    #[test]
+    fn migra_v4_sin_perder_nudos_ni_cuerdas_ni_tejidos() {
+        let mut notes = BTreeMap::new();
+        notes.insert(1, nota_v4(1, "Primera", Some(5)));
+        notes.insert(3, nota_v4(3, "Tercera", Some(5)));
+        notes.insert(4, nota_v4(4, "Suelta", None));
+        // El tejido de la cuerda 5 es el nudo 3: si la cuerda se perdiera en
+        // la migración, `coherente()` tiraría el cuaderno entero.
+        let mut cuerdas = BTreeMap::new();
+        cuerdas.insert(
+            5,
+            CuerdaW {
+                id: 5,
+                nombre: "la idea que vuelve".into(),
+                created_at: 999,
+                tejido: Some(3),
+            },
+        );
+        let v4 = PersistedV4W {
+            store: NoteStoreV4W {
+                notes,
+                next_id: 9,
+                cuerdas,
+                next_cuerda: 6,
+            },
+            embeddings: vec![(1, vec![0.5, 0.5]), (3, vec![0.1, 0.9])],
+            order: vec![1, 3, 4],
+            model: "khipu-trigram-16d".into(),
+        };
+        let bytes = postcard::to_allocvec(&v4).unwrap();
+        let dir = std::env::temp_dir().join(format!("khipu-v4mig-{}", std::process::id()));
+        std::fs::create_dir_all(&dir).unwrap();
+        let path = dir.join("notes.bin");
+        std::fs::write(&path, &bytes).unwrap();
+
+        let state = load_state(&path).expect("el V4 migra");
+        assert!(state.store.coherente(), "la cuerda 5 tiene que haber sobrevivido");
+        assert_eq!(state.store.len(), 3);
+        assert_eq!(state.store.cuerdas_len(), 1);
+
+        // La cuerda entera: nombre, fecha y TEJIDO.
+        let c = state.store.cuerda(5).expect("la cuerda conserva su id");
+        assert_eq!(c.nombre, "la idea que vuelve");
+        assert_eq!(c.created_at, 999);
+        assert_eq!(c.tejido, Some(3));
+        // Los anudados, que son la estructura que khipu tardó en juntar.
+        assert_eq!(state.store.nudos_de(5).len(), 2);
+
+        // El nudo entero, campo por campo — nada se cae en el camino.
+        let n1 = state.store.get(1).unwrap();
+        assert_eq!(n1.title, "Primera");
+        assert_eq!(n1.body, "cuerpo de Primera");
+        assert_eq!(n1.tags, vec!["v4".to_string()]);
+        assert_eq!(n1.created_at, 101);
+        assert_eq!(n1.updated_at, 201);
+        assert_eq!(n1.last_access, 301);
+        assert_eq!(n1.estado, EstadoNudo::Pendiente);
+        assert_eq!(n1.origen.as_ref().map(|o| o.app.as_str()), Some("pata"));
+        assert_eq!(n1.cuerda, Some(5));
+        // Lo único nuevo nace vacío: antes no existía.
+        assert!(!n1.dibujado());
+        assert!(state.store.get(4).unwrap().suelto());
+
+        // Contadores: ni un id se reusa, ni de nudo ni de cuerda.
+        let mut store = state.store.clone();
+        assert!(store.create("nueva", "", vec![], 0) >= 5);
+        assert!(store.crear_cuerda("otra", 0) >= 6);
+
+        // Backup propio: el `v3.bak` es de otra época y no se pisa.
+        assert!(path.with_extension("v4.bak").exists());
+        assert!(!path.with_extension("v3.bak").exists());
+        assert_eq!(state.order, vec![1, 3, 4]);
+        assert_eq!(state.model, "khipu-trigram-16d");
+        let _ = std::fs::remove_dir_all(&dir);
+    }
+
+    #[test]
+    fn un_cuaderno_con_dibujo_sobrevive_el_viaje_a_disco() {
+        use pizarra_core::{Elemento, Pizarra};
+        let mut store = NoteStore::new();
+        let a = store.create("flecha", "de A a B", vec![], 10);
+        let mut piz = Pizarra::nueva(1);
+        piz.agregar(Elemento::Flecha {
+            de: (0.0, 0.0),
+            a: (40.0, 12.0),
+            color: None,
+            grosor: 2.5,
+        });
+        assert!(store.set_dibujo(a, piz.doc, 20));
+
+        let state = PersistedState {
+            store,
+            embeddings: vec![],
+            order: vec![a],
+            model: "x".into(),
+        };
+        let bytes = postcard::to_allocvec(&state).unwrap();
+        let dir = std::env::temp_dir().join(format!("khipu-dib-{}", std::process::id()));
+        std::fs::create_dir_all(&dir).unwrap();
+        let path = dir.join("notes.bin");
+        std::fs::write(&path, &bytes).unwrap();
+
+        let cargado = load_state(&path).expect("el V5 con dibujo carga");
+        assert!(cargado.store.coherente());
+        let n = cargado.store.get(a).unwrap();
+        assert!(n.dibujado(), "el trazo tiene que volver del disco");
+        assert_eq!(cargado.store.dibujo(a).map(|d| d.len()), Some(1));
+        // Y no es una migración: nada de backups.
+        assert!(!path.with_extension("v4.bak").exists());
+        let _ = std::fs::remove_dir_all(&dir);
+    }
+
     /// Prueba de fuego opcional: migrar una COPIA del cuaderno real.
     /// `KHIPU_MIGRA_REAL=/ruta/a/notes.bin cargo test -p khipu-app migra_el_cuaderno_real`
     /// No corre en CI (sin la env var es un no-op).
diff --git a/00_unanchay/khipu/khipu-app/src/lib.rs b/00_unanchay/khipu/khipu-app/src/lib.rs
new file mode 100644
index 0000000..84d2af1
--- /dev/null
+++ b/00_unanchay/khipu/khipu-app/src/lib.rs
@@ -0,0 +1,21 @@
+//! `khipu-app` como biblioteca: expone los módulos internos del cuaderno para
+//! que el `example` del showreel headless comparta exactamente la misma
+//! `view()` y el mismo `Model` que el binario. El producto sigue siendo el
+//! binario (`src/main.rs`, self-contained); la biblioteca sólo existe para no
+//! duplicar la vista en el reel — el mismo arreglo que `pluma-app-llimphi`.
+//!
+//! Los módulos se compilan dos veces (una por cada raíz de crate), así que hay
+//! campos que el binario lee y el reel no: acá salen como muertos sin serlo.
+#![allow(dead_code)]
+
+pub mod app;
+pub mod cuerdas;
+pub mod dibujo;
+pub mod estado;
+pub mod menu;
+pub mod modelo;
+pub mod net;
+pub mod panels;
+pub mod redaccion;
+pub mod showreel;
+pub mod tejido;
diff --git a/00_unanchay/khipu/khipu-app/src/main.rs b/00_unanchay/khipu/khipu-app/src/main.rs
index c5bc266..f1783eb 100644
--- a/00_unanchay/khipu/khipu-app/src/main.rs
+++ b/00_unanchay/khipu/khipu-app/src/main.rs
@@ -1,3 +1,6 @@
+// En release sobre Windows: subsistema GUI (sin consola negra detrás).
+// No-op en Linux/otros targets — preserva `cargo check --workspace`.
+#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
 //! `khipu-app` — el cuaderno de resonancia sobre Llimphi.
 //!
 //! La ventana es un khipu: captura arriba (siempre lista), franja de
@@ -17,9 +20,18 @@
 //! (fastembed e5, etc.). Sin daemon caemos al hash trigram → R^16 local:
 //! determinista, offline.
 //!
+//! **El nudo dibujado**: un pensamiento no siempre es una frase. Cualquier
+//! nudo abre un lienzo de la pizarra (`◇` en el cuaderno) y el dibujo se
+//! guarda adentro del nudo, como `pizarra_core::DocPizarra`.
+//!
+//! **La cuerda redactada**: cuando la idea maduró, «redactar» la proyecta a un
+//! haz de pluma —el tejido de apertura y los nudos como párrafos— y abre el
+//! `.pluma` con el editor multilienzo. De ahí en más el borrador es de pluma.
+//!
 //! **Persistencia**: cada mutación graba `$XDG_DATA_HOME/khipu/notes.bin`
-//! con postcard (formato V4: nudos + cuerdas). Los cuadernos del mapa
-//! viejo (V1–V3) migran solos, con backup en `notes.v3.bak`.
+//! con postcard (formato V5: nudos + cuerdas + dibujos). Los cuadernos
+//! anteriores (V1–V4) migran solos, con backup (`notes.v4.bak` para el
+//! cuaderno de resonancia sin dibujos; `notes.v3.bak` para los del mapa viejo).
 //!
 //! ## Organización de módulos
 //!
@@ -32,15 +44,19 @@
 //! | `cuerdas` | el lienzo del khipu: layout, painter, hit-test, pan         |
 //! | `panels`  | captura, sueltos, resonancia, tarjeta de cuerda, cajón, editor |
 //! | `tejido`  | la síntesis LLM de una cuerda (pluma-llm)                   |
+//! | `dibujo`  | el nudo dibujado: lienzo de la pizarra dentro de un nudo    |
+//! | `redaccion` | la cuerda redactada: el haz de pluma y el `.pluma`        |
 //! | `net`     | lado P2P: exportar/importar/publicar/recibir                |
 
 mod app;
 mod cuerdas;
+mod dibujo;
 mod estado;
 mod menu;
 mod modelo;
 mod net;
 mod panels;
+mod redaccion;
 mod tejido;
 
 use app::KhipuApp;
diff --git a/00_unanchay/khipu/khipu-app/src/menu.rs b/00_unanchay/khipu/khipu-app/src/menu.rs
index ca8af2d..f6a8bea 100644
--- a/00_unanchay/khipu/khipu-app/src/menu.rs
+++ b/00_unanchay/khipu/khipu-app/src/menu.rs
@@ -26,6 +26,7 @@ pub(crate) fn focused_editor(model: &Model) -> (Option<&EditorState>, bool) {
         Focus::Search => (Some(model.search.editor()), false),
         Focus::PeerAddr => (Some(model.peer_input.editor()), false),
         Focus::Nombre => (Some(model.nombre_input.editor()), false),
+        Focus::DibujoTexto => (Some(model.dibujo_texto.editor()), false),
         Focus::Passphrase => (Some(model.passphrase.editor()), model.passphrase.is_masked()),
         Focus::None => (None, false),
     }
@@ -84,16 +85,28 @@ pub(crate) fn app_menu(model: &Model) -> app_bus::AppMenu {
     }
 
     let mut delete_note = MenuItem::new("Borrar nudo", "note.delete");
+    let mut dibujar = MenuItem::new("Dibujar el nudo", "note.dibujar");
     if !has_sel_note {
         delete_note = delete_note.disabled();
+        dibujar = dibujar.disabled();
     }
 
     let mut tejer = MenuItem::new("Tejer (encuadre LLM)", "cuerda.tejer");
-    let mut renombrar = MenuItem::new("Renombrar cuerda…", "cuerda.renombrar");
-    let mut desatar = MenuItem::new("Desatar cuerda", "cuerda.desatar").separated();
+    // Redactar exige materia, no tejido: pluma es el paso siguiente del
+    // tejido, no su reemplazo (mismo criterio que el botón de la tarjeta).
+    let mut redactar = MenuItem::new("Redactar en pluma…", "cuerda.redactar");
+    let mut renombrar = MenuItem::new("Renombrar cuerda…", "cuerda.renombrar").separated();
+    let mut desatar = MenuItem::new("Desatar cuerda", "cuerda.desatar");
+    let cuerda_con_nudos = model
+        .cuerda_sel
+        .map(|c| !model.store.nudos_de(c).is_empty())
+        .unwrap_or(false);
     if !has_sel_cuerda || model.tejiendo.is_some() {
         tejer = tejer.disabled();
     }
+    if !cuerda_con_nudos {
+        redactar = redactar.disabled();
+    }
     if !has_sel_cuerda {
         renombrar = renombrar.disabled();
         desatar = desatar.disabled();
@@ -103,6 +116,7 @@ pub(crate) fn app_menu(model: &Model) -> app_bus::AppMenu {
         .menu(
             Menu::new("Archivo")
                 .item(MenuItem::new("Capturar (foco)", "note.captura").shortcut("Ctrl+N"))
+                .item(dibujar)
                 .item(delete_note)
                 .item(MenuItem::new("Exportar sobre…", "share.export").separated())
                 .item(MenuItem::new("Importar sobre…", "share.import")),
@@ -116,7 +130,13 @@ pub(crate) fn app_menu(model: &Model) -> app_bus::AppMenu {
                 .item(paste)
                 .item(sel_all),
         )
-        .menu(Menu::new("Cuerda").item(tejer).item(renombrar).item(desatar))
+        .menu(
+            Menu::new("Cuerda")
+                .item(tejer)
+                .item(redactar)
+                .item(renombrar)
+                .item(desatar),
+        )
         .menu(
             Menu::new("Compartir")
                 .item(MenuItem::new("Publicar (P2P)", "share.publish"))
diff --git a/00_unanchay/khipu/khipu-app/src/modelo.rs b/00_unanchay/khipu/khipu-app/src/modelo.rs
index 87b38ba..72f4eea 100644
--- a/00_unanchay/khipu/khipu-app/src/modelo.rs
+++ b/00_unanchay/khipu/khipu-app/src/modelo.rs
@@ -136,7 +136,7 @@ impl Embedder {
 /// Foco activo del teclado. Cualquier `KeyEvent` se rutea al input
 /// correspondiente; sin foco las teclas se ignoran.
 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
-pub(crate) enum Focus {
+pub enum Focus {
     None,
     /// La barra de captura — el gesto raíz de khipu.
     Captura,
@@ -148,6 +148,9 @@ pub(crate) enum Focus {
     PeerAddr,
     /// Input del nombre de una cuerda (bautizo o renombre).
     Nombre,
+    /// La caja de texto que se abre al escribir SOBRE el lienzo de un nudo
+    /// dibujado (`Herramienta::Texto`).
+    DibujoTexto,
 }
 
 // =====================================================================
@@ -190,7 +193,7 @@ pub(crate) struct P2p {
 
 /// Un par descubierto en la red, en forma lista para la UI.
 #[derive(Clone)]
-pub(crate) struct PeerInfo {
+pub struct PeerInfo {
     /// Dirección TCP de fetch, como string (`ip:puerto`).
     pub(crate) addr: String,
     /// Etiqueta para la fila: nombre · de:autor · dirección.
@@ -202,7 +205,7 @@ pub(crate) struct PeerInfo {
 // =====================================================================
 
 #[derive(Clone)]
-pub(crate) enum Msg {
+pub enum Msg {
     // --- captura y resonancia ---
     /// Enter en la barra de captura: bota el nudo y dispara la resonancia.
     Capturar,
@@ -237,6 +240,32 @@ pub(crate) enum Msg {
     BorrarCuerda(CuerdaId),
     /// Teje la cuerda: pide al LLM el encuadre de sus nudos.
     Tejer(CuerdaId),
+    /// Redactar la cuerda: proyectarla a un haz de pluma, escribir el `.pluma`
+    /// y abrirlo con quien el AppBus diga (ver `crate::redaccion`).
+    Redactar(CuerdaId),
+
+    // --- el nudo dibujado (ver `crate::dibujo`) ---
+    /// Abre el lienzo del nudo (el que tenga, o uno en blanco).
+    AbrirDibujo(NoteId),
+    /// Guarda el dibujo en el nudo y vuelve al cuaderno.
+    CerrarDibujo,
+    /// Elige una de las siete herramientas de la pizarra.
+    DibujoHerramienta(pizarra_llimphi::Herramienta),
+    /// Drag sobre el lienzo: `(fase, dx, dy, x, y)` en px de pantalla.
+    DibujoDrag(pizarra_llimphi::FaseDrag, f32, f32, f32, f32),
+    /// Click sobre el lienzo (px de pantalla).
+    DibujoClick(f32, f32),
+    /// Rueda sobre el lienzo: zoom al cursor.
+    DibujoRueda(f32),
+    /// El cursor se movió sobre el lienzo — es el foco del zoom, que la rueda
+    /// no informa.
+    DibujoPuntero(f32, f32),
+    DibujoDeshacer,
+    DibujoRehacer,
+    /// Enter en la caja de texto del lienzo: deja el texto como elemento.
+    DibujoConfirmarTexto,
+    /// Escape en la caja de texto del lienzo: no deja nada.
+    DibujoCancelarTexto,
     /// Resultado async del tejido (`Ok(texto)` o `Err(mensaje)`).
     TejidoListo(CuerdaId, Result<String, String>),
     /// Pan horizontal del lienzo de cuerdas (delta de arrastre en px).
@@ -318,7 +347,7 @@ pub(crate) enum Msg {
 // Model
 // =====================================================================
 
-pub(crate) struct Model {
+pub struct Model {
     pub(crate) store: NoteStore,
     pub(crate) field: SemanticField,
     /// Orden de inserción (estable).
@@ -389,6 +418,11 @@ pub(crate) struct Model {
     pub(crate) show_list: bool,
     /// Último tamaño conocido del lienzo `(w, h)` en pixels.
     pub(crate) canvas_size: (f32, f32),
+    /// El dibujo abierto sobre un nudo, si hay uno. `Some` = el lienzo ocupa
+    /// la ventana (ver `crate::dibujo`).
+    pub(crate) dibujo: Option<crate::dibujo::DibujoAbierto>,
+    /// La caja de texto que se escribe sobre el lienzo.
+    pub(crate) dibujo_texto: TextInputState,
 }
 
 impl Model {
@@ -436,6 +470,8 @@ impl Model {
             clipboard: SystemClipboard::new(),
             show_list: false,
             canvas_size: (1280.0, 640.0),
+            dibujo: None,
+            dibujo_texto: TextInputState::new(),
         }
     }
 }
diff --git a/00_unanchay/khipu/khipu-app/src/panels.rs b/00_unanchay/khipu/khipu-app/src/panels.rs
index 116d739..acf4ced 100644
--- a/00_unanchay/khipu/khipu-app/src/panels.rs
+++ b/00_unanchay/khipu/khipu-app/src/panels.rs
@@ -45,7 +45,7 @@ pub(crate) fn header_view(model: &Model) -> View<Msg> {
             top: length(0.0_f32),
             bottom: length(0.0_f32),
         },
-        align_items: Some(AlignItems::Center),
+        align_items: Some(AlignItems::CENTER),
         ..Default::default()
     })
     .text_aligned(title, 14.0, model.theme.fg_text, Alignment::Start);
@@ -100,7 +100,7 @@ pub(crate) fn header_view(model: &Model) -> View<Msg> {
             top: length(4.0_f32),
             bottom: length(4.0_f32),
         },
-        align_items: Some(AlignItems::Center),
+        align_items: Some(AlignItems::CENTER),
         gap: Size {
             width: length(8.0_f32),
             height: length(0.0_f32),
@@ -127,8 +127,8 @@ pub(crate) fn button(label: &str, bg: Color, fg: Color, msg: Msg) -> View<Msg> {
             top: length(0.0_f32),
             bottom: length(0.0_f32),
         },
-        align_items: Some(AlignItems::Center),
-        justify_content: Some(JustifyContent::Center),
+        align_items: Some(AlignItems::CENTER),
+        justify_content: Some(JustifyContent::CENTER),
         flex_shrink: 0.0,
         ..Default::default()
     })
@@ -176,7 +176,7 @@ pub(crate) fn captura_bar(model: &Model, input_palette: &TextInputPalette) -> Vi
             top: length(6.0_f32),
             bottom: length(6.0_f32),
         },
-        align_items: Some(AlignItems::Center),
+        align_items: Some(AlignItems::CENTER),
         gap: Size {
             width: length(8.0_f32),
             height: length(0.0_f32),
@@ -207,7 +207,7 @@ pub(crate) fn sueltos_strip(model: &Model) -> View<Msg> {
             height: percent(1.0_f32),
         },
         flex_shrink: 0.0,
-        align_items: Some(AlignItems::Center),
+        align_items: Some(AlignItems::CENTER),
         ..Default::default()
     })
     .text_aligned("sueltos:".to_string(), 11.0, model.theme.fg_muted, Alignment::Start);
@@ -220,7 +220,10 @@ pub(crate) fn sueltos_strip(model: &Model) -> View<Msg> {
             EstadoNudo::Normal => "",
         };
         let texto: String = n.title.chars().take(18).collect();
-        let sufijo = if n.title.chars().count() > 18 { "…" } else { "" };
+        let mut sufijo = if n.title.chars().count() > 18 { "…" } else { "" }.to_string();
+        if n.dibujado() {
+            sufijo.push_str(" ◇");
+        }
         let fg = if model.selected == Some(n.id) {
             model.theme.accent
         } else {
@@ -256,7 +259,7 @@ pub(crate) fn sueltos_strip(model: &Model) -> View<Msg> {
                     width: Dimension::auto(),
                     height: percent(1.0_f32),
                 },
-                align_items: Some(AlignItems::Center),
+                align_items: Some(AlignItems::CENTER),
                 ..Default::default()
             })
             .text_aligned("ninguno — todo anudado".to_string(), 11.0, model.theme.fg_muted, Alignment::Start),
@@ -276,7 +279,7 @@ pub(crate) fn sueltos_strip(model: &Model) -> View<Msg> {
             top: length(4.0_f32),
             bottom: length(4.0_f32),
         },
-        align_items: Some(AlignItems::Center),
+        align_items: Some(AlignItems::CENTER),
         gap: Size {
             width: length(6.0_f32),
             height: length(0.0_f32),
@@ -513,6 +516,14 @@ pub(crate) fn tarjeta_cuerda(model: &Model) -> Option<View<Msg>> {
     } else {
         button(tejer_label, theme.bg_button, theme.accent, Msg::Tejer(cid))
     };
+    // Una cuerda sin nudos no tiene qué redactar: el botón queda apagado en
+    // vez de abrir una ventana en blanco (`ErrorRedaccion::SinNudos` dice lo
+    // mismo del otro lado; acá se evita el viaje).
+    let redactar_btn = if nudos.is_empty() {
+        button("¶ redactar", theme.bg_button, theme.fg_muted, Msg::Tick)
+    } else {
+        button("¶ redactar", theme.bg_button, theme.accent, Msg::Redactar(cid))
+    };
     let botones = View::new(Style {
         flex_direction: FlexDirection::Row,
         size: Size {
@@ -528,6 +539,10 @@ pub(crate) fn tarjeta_cuerda(model: &Model) -> Option<View<Msg>> {
     })
     .children(vec![
         tejer_btn,
+        // Redactar: de la idea que volvió al borrador que se escribe. Se
+        // ofrece siempre que haya materia — pluma es el paso siguiente del
+        // tejido, no su reemplazo, así que no se exige haber tejido.
+        redactar_btn,
         button("renombrar", theme.bg_button, theme.fg_muted, Msg::RenombrarCuerda(cid)),
         button("desatar", theme.bg_button, theme.fg_muted, Msg::BorrarCuerda(cid)),
         button("×", theme.bg_button, theme.fg_muted, Msg::SelectCuerda(None)),
@@ -825,7 +840,7 @@ pub(crate) fn receive_panel(
             top: length(4.0_f32),
             bottom: length(4.0_f32),
         },
-        align_items: Some(AlignItems::Center),
+        align_items: Some(AlignItems::CENTER),
         gap: Size {
             width: length(6.0_f32),
             height: length(0.0_f32),
@@ -853,7 +868,7 @@ pub(crate) fn receive_panel(
             top: length(2.0_f32),
             bottom: length(4.0_f32),
         },
-        align_items: Some(AlignItems::Center),
+        align_items: Some(AlignItems::CENTER),
         ..Default::default()
     })
     .fill(model.theme.bg_panel_alt)
@@ -918,7 +933,9 @@ pub(crate) fn note_matches(n: &Note, query: &str) -> bool {
 }
 
 /// Fila del cajón: una marca de estado + el título. `◉` anudado, `○`
-/// suelto fresco, `·` sedimento, `☐/☑` pendiente/hecho, `◈` tejido.
+/// suelto fresco, `·` sedimento, `☐/☑` pendiente/hecho, `◈` tejido. Un nudo
+/// con dibujo lleva además `◇` al final — la marca del estado no se toca,
+/// porque un pendiente dibujado sigue siendo un pendiente.
 pub(crate) fn row_label(model: &Model, n: &Note, now: u64) -> String {
     let es_tejido = n
         .cuerda
@@ -947,7 +964,8 @@ pub(crate) fn row_label(model: &Model, n: &Note, now: u64) -> String {
     } else {
         n.title.as_str()
     };
-    format!("{marca}  {title}")
+    let dibujo = if n.dibujado() { "  ◇" } else { "" };
+    format!("{marca}  {title}{dibujo}")
 }
 
 // =====================================================================
@@ -965,8 +983,8 @@ pub(crate) fn editor_panel(
                 width: percent(1.0_f32),
                 height: percent(1.0_f32),
             },
-            align_items: Some(AlignItems::Center),
-            justify_content: Some(JustifyContent::Center),
+            align_items: Some(AlignItems::CENTER),
+            justify_content: Some(JustifyContent::CENTER),
             ..Default::default()
         })
         .fill(model.theme.bg_panel)
@@ -1080,6 +1098,14 @@ fn acciones_nudo(model: &Model, id: NoteId) -> View<Msg> {
             Msg::Resonar(id),
         )),
     }
+    // El lienzo del nudo. El rótulo dice si ya hay algo dibujado: «dibujar»
+    // sobre un nudo que YA tiene un dibujo haría creer que se empieza de cero.
+    botones.push(button(
+        if n.dibujado() { "◇ dibujo" } else { "◇ dibujar" },
+        theme.bg_button,
+        if n.dibujado() { theme.accent } else { theme.fg_muted },
+        Msg::AbrirDibujo(id),
+    ));
     botones.push(button("borrar", theme.bg_button, theme.fg_muted, Msg::DeleteSelected));
 
     View::new(Style {
@@ -1314,7 +1340,7 @@ pub(crate) fn status_bar(model: &Model) -> Option<View<Msg>> {
                 top: length(2.0_f32),
                 bottom: length(2.0_f32),
             },
-            align_items: Some(AlignItems::Center),
+            align_items: Some(AlignItems::CENTER),
             ..Default::default()
         })
         .fill(model.theme.bg_panel_alt)
@@ -1425,8 +1451,8 @@ pub(crate) fn unlock_view(model: &Model, input_palette: &TextInputPalette) -> Vi
             height: Dimension::auto(),
         },
         flex_grow: 1.0,
-        align_items: Some(AlignItems::Center),
-        justify_content: Some(JustifyContent::Center),
+        align_items: Some(AlignItems::CENTER),
+        justify_content: Some(JustifyContent::CENTER),
         ..Default::default()
     })
     .fill(model.theme.bg_app)
diff --git a/00_unanchay/khipu/khipu-app/src/redaccion.rs b/00_unanchay/khipu/khipu-app/src/redaccion.rs
new file mode 100644
index 0000000..c891504
--- /dev/null
+++ b/00_unanchay/khipu/khipu-app/src/redaccion.rs
@@ -0,0 +1,219 @@
+//! `redaccion` — el gesto de **redactar una cuerda**: de la idea que volvió al
+//! borrador que se escribe.
+//!
+//! khipu junta y deja resonar; pluma escribe. Este módulo es la bisagra: toma
+//! la cuerda seleccionada, la proyecta a un haz con
+//! [`khipu_redaccion::redactar`] (puro y testeado aparte), la envasa en un
+//! proyecto `.pluma` y se la pasa a pluma **out-of-process** por el AppBus.
+//!
+//! Por qué out-of-process y no embebida como la pizarra: son dos pesos
+//! distintos. El dibujo de un nudo es un pedazo del nudo y vive adentro del
+//! cuaderno, así que su editor se embebe (ver [`crate::dibujo`]). Un borrador,
+//! en cambio, deja de ser de khipu en el momento en que se abre: tiene su
+//! propio archivo, su propia historia y su propia ventana. Meter el editor
+//! multilienzo entero adentro de khipu sería mudar pluma, no integrarla.
+
+use std::path::PathBuf;
+
+use khipu_core::CuerdaId;
+use khipu_redaccion::{nombre_de_archivo, redactar, ErrorRedaccion, Redaccion};
+use pluma_proyecto::Proyecto;
+
+use crate::estado::khipu_dir;
+use crate::modelo::Model;
+
+/// El mime del proyecto `.pluma` en el AppBus. Es el que `shuma-discern` saca
+/// de la magia `PLUMAPRY`, y el que la entrada `pluma` declara manejar.
+const MIME_PLUMA: &str = "application/x-pluma";
+
+/// Dónde quedan los borradores: `$XDG_DATA_HOME/khipu/redacciones/`.
+///
+/// Una carpeta propia y no el directorio raíz de khipu: los `.pluma` son de
+/// pluma de acá en adelante, y mezclarlos con `notes.bin` invitaría a
+/// respaldar o borrar unos creyendo que son los otros.
+pub(crate) fn redacciones_dir() -> Option<PathBuf> {
+    let dir = khipu_dir()?.join("redacciones");
+    std::fs::create_dir_all(&dir).ok()?;
+    Some(dir)
+}
+
+/// Cómo le salió al operador el gesto de redactar. El mensaje es el que va a
+/// la barra de estado — por eso está en español y cuenta **números medidos**
+/// (cuántos párrafos entraron), no una promesa.
+pub(crate) enum Resultado {
+    /// El borrador quedó escrito y pluma se está abriendo.
+    Abierto { ruta: PathBuf, aviso: String },
+    /// El borrador quedó escrito pero nadie lo abrió (no hay pluma instalada,
+    /// o falló el spawn). El archivo **existe**: se dice dónde, que es lo
+    /// único accionable.
+    Escrito { ruta: PathBuf, aviso: String },
+    /// No se pudo ni escribir.
+    Fallo(String),
+}
+
+impl Resultado {
+    pub(crate) fn aviso(&self) -> String {
+        match self {
+            Resultado::Abierto { aviso, .. } | Resultado::Escrito { aviso, .. } => aviso.clone(),
+            Resultado::Fallo(e) => e.clone(),
+        }
+    }
+}
+
+/// Cuenta en palabras qué entró al borrador. Sale de la [`Redaccion`], que lo
+/// midió; no se estima.
+fn detalle(r: &Redaccion) -> String {
+    let nudos = match r.nudos {
+        1 => "1 nudo".to_string(),
+        n => format!("{n} nudos"),
+    };
+    if r.con_tejido {
+        format!("{nudos} + el tejido")
+    } else {
+        nudos
+    }
+}
+
+/// Redacta la cuerda `cid`: arma el haz, lo guarda como `.pluma` y se lo pasa
+/// a pluma por el AppBus.
+///
+/// El archivo se **pisa** si ya existe: el nombre lleva el id de la cuerda, así
+/// que redactar dos veces la misma cuerda apunta al mismo borrador. Es
+/// deliberado — la alternativa (un archivo nuevo por gesto) llena la carpeta de
+/// copias casi iguales y ninguna es «la» del operador.
+pub(crate) fn redactar_cuerda(model: &Model, cid: CuerdaId, ahora: u64) -> Resultado {
+    let r = match redactar(&model.store, cid, ahora) {
+        Ok(r) => r,
+        Err(ErrorRedaccion::SinCuerda) => return Resultado::Fallo("esa cuerda ya no está".into()),
+        Err(ErrorRedaccion::SinNudos) => {
+            return Resultado::Fallo("la cuerda no tiene nudos: no hay qué redactar".into())
+        }
+    };
+
+    let Some(dir) = redacciones_dir() else {
+        return Resultado::Fallo("no pude abrir la carpeta de redacciones".into());
+    };
+    let base = nombre_de_archivo(&r.doc.nombre, cid);
+    let ruta = dir.join(format!("{base}.pluma"));
+
+    let mut proyecto = Proyecto::nuevo(r.doc.nombre.clone());
+    let doc_id = proyecto.nuevo_documento(r.doc.nombre.clone());
+    proyecto.set_documento(doc_id, r.doc.clone());
+    if let Err(e) = proyecto.guardar(&ruta) {
+        return Resultado::Fallo(format!("no pude escribir el borrador: {e:?}"));
+    }
+
+    let cuenta = detalle(&r);
+    let nombre_archivo = ruta
+        .file_name()
+        .map(|s| s.to_string_lossy().to_string())
+        .unwrap_or_default();
+
+    // El AppBus decide QUIÉN abre un `.pluma` — khipu no invoca un binario por
+    // nombre. Si nadie declara el mime, el archivo igual quedó escrito.
+    let reg = app_bus::AppRegistry::discover_merged();
+    match reg.open_with(MIME_PLUMA, &ruta.to_string_lossy()) {
+        Ok(Some((entry, _))) => Resultado::Abierto {
+            aviso: format!("redactada en {}: {cuenta} → {nombre_archivo}", entry.label),
+            ruta,
+        },
+        Ok(None) => Resultado::Escrito {
+            aviso: format!("borrador escrito ({cuenta}), pero no hay app para .pluma: {nombre_archivo}"),
+            ruta,
+        },
+        Err(e) => Resultado::Escrito {
+            aviso: format!("borrador escrito ({cuenta}) en {nombre_archivo}, pero no abrió: {e}"),
+            ruta,
+        },
+    }
+}
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+    use khipu_core::NoteStore;
+
+    fn cuerda_con_nudos() -> (NoteStore, CuerdaId) {
+        let mut s = NoteStore::new();
+        let c = s.crear_cuerda("la idea que vuelve", 100);
+        let a = s.create("uno", "lo primero", vec![], 100);
+        let b = s.create("dos", "lo segundo", vec![], 200);
+        s.anudar(a, c);
+        s.anudar(b, c);
+        (s, c)
+    }
+
+    #[test]
+    fn el_detalle_cuenta_lo_que_midio_la_redaccion() {
+        let (s, c) = cuerda_con_nudos();
+        let r = redactar(&s, c, 1).unwrap();
+        assert_eq!(detalle(&r), "2 nudos");
+
+        let mut s2 = NoteStore::new();
+        let c2 = s2.crear_cuerda("x", 0);
+        let n = s2.create("uno", "solo", vec![], 10);
+        let t = s2.create("tejido", "la síntesis", vec![], 20);
+        s2.anudar(n, c2);
+        s2.anudar(t, c2);
+        s2.cuerda_mut(c2).unwrap().tejido = Some(t);
+        let r2 = redactar(&s2, c2, 1).unwrap();
+        assert_eq!(detalle(&r2), "1 nudo + el tejido");
+    }
+
+    /// El borrador que khipu escribe tiene que ser un `.pluma` que pluma
+    /// **relea**: si el envase no round-trippea, el gesto entrega un archivo
+    /// que abre en una ventana vacía y el operador se entera en pantalla.
+    #[test]
+    fn el_pluma_escrito_lo_vuelve_a_leer_pluma() {
+        let (s, c) = cuerda_con_nudos();
+        let r = redactar(&s, c, 555).unwrap();
+
+        let dir = std::env::temp_dir().join(format!("khipu-red-{}", std::process::id()));
+        std::fs::create_dir_all(&dir).unwrap();
+        let ruta = dir.join(format!("{}.pluma", nombre_de_archivo(&r.doc.nombre, c)));
+
+        let mut proyecto = Proyecto::nuevo(r.doc.nombre.clone());
+        let doc_id = proyecto.nuevo_documento(r.doc.nombre.clone());
+        proyecto.set_documento(doc_id, r.doc.clone());
+        proyecto.guardar(&ruta).expect("se escribe");
+
+        // La magia es la que `shuma-discern` reconoce como `application/x-pluma`.
+        let bytes = std::fs::read(&ruta).unwrap();
+        assert!(bytes.starts_with(b"PLUMAPRY"), "sin magia nadie sabe qué es");
+
+        let leido = Proyecto::abrir(&ruta).expect("pluma lo relee");
+        let docs = leido.documentos();
+        assert_eq!(docs.len(), 1);
+        assert_eq!(docs[0].1, "la idea que vuelve");
+        let estado = leido.documento(docs[0].0).expect("el haz está");
+        assert_eq!(estado.cuerpos.len(), 1);
+        assert_eq!(estado.cuerpos[0].orden.len(), 2);
+        assert_eq!(estado.atoms.len(), 2);
+        // Y los párrafos son los nudos, en cronología.
+        let textos: Vec<&str> = estado.cuerpos[0]
+            .orden
+            .iter()
+            .map(|id| {
+                estado
+                    .atoms
+                    .iter()
+                    .find(|a| a.id == *id)
+                    .map(|a| a.content.as_str())
+                    .unwrap()
+            })
+            .collect();
+        assert_eq!(textos, vec!["uno\nlo primero", "dos\nlo segundo"]);
+
+        let _ = std::fs::remove_dir_all(&dir);
+    }
+
+    #[test]
+    fn dos_redacciones_de_la_misma_cuerda_apuntan_al_mismo_archivo() {
+        let (s, c) = cuerda_con_nudos();
+        let r = redactar(&s, c, 1).unwrap();
+        let a = nombre_de_archivo(&r.doc.nombre, c);
+        let r2 = redactar(&s, c, 2).unwrap();
+        let b = nombre_de_archivo(&r2.doc.nombre, c);
+        assert_eq!(a, b);
+    }
+}
diff --git a/00_unanchay/khipu/khipu-app/src/showreel.rs b/00_unanchay/khipu/khipu-app/src/showreel.rs
new file mode 100644
index 0000000..4d116ef
--- /dev/null
+++ b/00_unanchay/khipu/khipu-app/src/showreel.rs
@@ -0,0 +1,622 @@
+//! **Showreel** de khipu — el cuaderno de resonancia, para la ficha de
+//! tawasuyu.net. NO es eye-candy abstracto: cada frame monta la **vista real**
+//! de la app (`<KhipuApp as App>::view`) sobre un `Model` sintético construido
+//! con las MISMAS operaciones del cuaderno de verdad (`NoteStore::create` /
+//! `anudar` / `crear_cuerda`), y la resonancia que muestra el panel sale de
+//! `estado::resonancia_de` — afinidades calculadas de verdad sobre el campo
+//! semántico, no números escritos a mano.
+//!
+//! El embebedor es el **local** (hash-trigram 16d): determinista y offline, así
+//! que el reel no depende de que haya un `verbo-daemon` corriendo. Y nada toca
+//! el disco: el modelo nace en memoria con `data_path = None`, de modo que
+//! renderizar el reel jamás pisa el cuaderno del usuario.
+//!
+//! Render **headless y determinista** (sin reloj de UI, sin runtime, sin
+//! winit): frame `i` de `N` → `t = i/(N-1)` → se ajusta el ESTADO del modelo →
+//! `view()` → layout (taffy + parley) → `vello::Scene` → wgpu → PNG.
+//!
+//! Timeline (beats):
+//!   1. 0–12%   cold-open: la viga se traza y de ella cuelgan las cuerdas, con
+//!              sus nudos apareciendo en cronología.
+//!   2. 12–26%  el chrome real entra: menubar, cabecera, barra de captura,
+//!              franja de sueltos y el lienzo de cuerdas.
+//!   3. 26–44%  se tipea una idea en la barra de captura (el input REAL).
+//!   4. 44–58%  se bota el nudo: el panel de resonancia aparece «calculando» y
+//!              después con los vecinos que de verdad resuenan.
+//!   5. 58–72%  el nudo se anuda: la cuerda gana su nudo del día y su tarjeta
+//!              se abre.
+//!   6. 72–86%  el tejido: la síntesis se cuelga al final de la cuerda.
+//!   7. 86–100% wordmark «khipu» + «el nudo que recuerda».
+
+use std::fs::{create_dir_all, File};
+use std::io::BufWriter;
+
+use llimphi_theme::motion;
+use llimphi_ui::llimphi_compositor::{measure_text_node, mount, paint, PaintRect};
+use llimphi_ui::llimphi_hal::{wgpu, Hal};
+use llimphi_ui::llimphi_layout::taffy;
+use llimphi_ui::llimphi_layout::taffy::prelude::{length, percent, Position, Size, Style};
+use llimphi_ui::llimphi_layout::taffy::Rect as TaffyRect;
+use llimphi_ui::llimphi_layout::LayoutTree;
+use llimphi_ui::llimphi_raster::peniko::{self, Color};
+use llimphi_ui::llimphi_raster::vello::kurbo::{Affine, Circle, Line, Point, Stroke};
+use llimphi_ui::llimphi_raster::{vello, Renderer};
+use llimphi_ui::llimphi_text::{draw_layout_brush_xf, measurement, Alignment, Typesetter};
+use llimphi_ui::{App, View};
+
+use khipu_core::{CuerdaId, NoteId};
+
+use crate::app::KhipuApp;
+use crate::estado::{embed_now, now_secs, resonancia_de};
+use crate::modelo::{Embedder, Focus, Model, Msg, Resonancia};
+
+const FMT: wgpu::TextureFormat = wgpu::TextureFormat::Rgba8Unorm;
+
+/// Acento «firma» del reel (teal) — el mismo del resto de la suite.
+fn accent() -> Color {
+    Color::from_rgba8(0x2B, 0xD9, 0xA6, 0xFF)
+}
+
+fn fondo() -> Color {
+    Color::from_rgba8(8, 10, 13, 255)
+}
+
+fn with_alpha(c: Color, a: f32) -> Color {
+    let [r, g, b, _] = c.components;
+    Color::new([r, g, b, a.clamp(0.0, 1.0)])
+}
+
+fn lerp(a: f64, b: f64, t: f64) -> f64 {
+    a + (b - a) * t
+}
+
+/// Reescala `t` desde el subintervalo `[lo,hi]` a `[0,1]`, clampado.
+fn seg(t: f32, lo: f32, hi: f32) -> f32 {
+    ((t - lo) / (hi - lo)).clamp(0.0, 1.0)
+}
+
+// ───────────────────────── el cuaderno del reel ─────────────────────────
+
+/// La idea que se tipea en cámara. Resuena a propósito con la cuerda «khipu»
+/// del cuaderno: es lo que hace que el panel de resonancia tenga algo que decir.
+const IDEA: &str = "la estructura no se ordena a mano: emerge cuando dos notas vuelven a tocarse";
+
+/// El tejido que aparece al final — el encuadre que la app le pide al LLM.
+/// En el reel lo escribimos nosotros: el reel no llama a ningún backend, y
+/// fingir una llamada de red en una animación sería afirmar de más.
+const TEJIDO: &str = "Lo que se repite: querés capturar sin decidir dónde va. \
+Lo que ya está claro: la organización es un efecto de la recurrencia, no un paso previo. \
+Lo que falta: qué pasa cuando una cuerda deja de resonar.";
+
+/// Lo que el reel necesita recordar entre frames: los ids que va creando, para
+/// que cada beat se dispare una sola vez aunque `t` avance de a poco.
+struct Guion {
+    cuerda_madre: CuerdaId,
+    nudo_nuevo: Option<NoteId>,
+    tejido: Option<NoteId>,
+    anudado: bool,
+}
+
+/// Arma el cuaderno del reel: tres cuerdas con sus nudos en cronología y dos
+/// sueltos frescos. Mismas operaciones que el cuaderno real; cero disco.
+fn cuaderno() -> (Model, Guion) {
+    let mut model = Model::blank(Embedder::Local);
+    model.theme = llimphi_theme::Theme::dark();
+    model.data_path = None; // el reel NUNCA persiste sobre el cuaderno del usuario
+    let now = now_secs();
+    let dia = 24 * 3600;
+
+    // ── la cuerda madre: la idea de khipu misma ──────────────────────────
+    let madre = model.store.crear_cuerda("khipu", now.saturating_sub(21 * dia));
+    let nudos_madre: [(&str, u64); 4] = [
+        (
+            "quiero un lugar donde botar ideas sueltas, en cualquier estado mental",
+            now.saturating_sub(21 * dia),
+        ),
+        (
+            "que las ideas se refuercen entre sí al volver, en vez de decaer por no leerlas",
+            now.saturating_sub(14 * dia),
+        ),
+        (
+            "no otro cuaderno cuadrado: la estructura tiene que emerger de la recurrencia",
+            now.saturating_sub(6 * dia),
+        ),
+        (
+            "lo que nadie vuelve a mirar sedimenta: sigue ahí, buscable, sin estorbar",
+            now.saturating_sub(2 * dia),
+        ),
+    ];
+
+    // ── una cuerda de trabajo ────────────────────────────────────────────
+    let taller = model
+        .store
+        .crear_cuerda("el taller", now.saturating_sub(17 * dia));
+    let nudos_taller: [(&str, u64); 3] = [
+        ("el tejido de una cuerda lo redacta el LLM que haya en el entorno", now.saturating_sub(17 * dia)),
+        ("sin daemon de embeddings caemos al trigram local: determinista, offline", now.saturating_sub(11 * dia)),
+        ("el nudo dibujado: un pensamiento no siempre es una frase", now.saturating_sub(4 * dia)),
+    ];
+
+    // ── una cuerda que empieza ───────────────────────────────────────────
+    let entre = model
+        .store
+        .crear_cuerda("entre pares", now.saturating_sub(8 * dia));
+    let nudos_entre: [(&str, u64); 2] = [
+        ("las notas no tienen que vivir en la casa de nadie", now.saturating_sub(8 * dia)),
+        ("publicar el cuaderno firmado y que otro lo reciba entero", now.saturating_sub(3 * dia)),
+    ];
+
+    // ── una cuerda larga: la que ya sedimentó ────────────────────────────
+    // El lienzo se lee como un khipu recién cuando hay varias cuerdas de
+    // largos distintos colgando de la viga. Con tres, y cortas, el cuadro
+    // queda vacío de la mitad para abajo.
+    let sedimento = model
+        .store
+        .crear_cuerda("lo que se decantó", now.saturating_sub(40 * dia));
+    let nudos_sedimento: [(&str, u64); 6] = [
+        ("una nota no vale por estar guardada: vale por volver", now.saturating_sub(40 * dia)),
+        ("las carpetas son una decisión que se toma antes de entender", now.saturating_sub(33 * dia)),
+        ("etiquetar es prometer que uno se va a acordar del vocabulario", now.saturating_sub(27 * dia)),
+        ("buscar por palabra exacta falla justo cuando más hace falta", now.saturating_sub(19 * dia)),
+        ("el orden que sirve es el que aparece después, no el del formulario", now.saturating_sub(12 * dia)),
+        ("nada se borra: lo que no resuena se apaga y sigue estando", now.saturating_sub(5 * dia)),
+    ];
+
+    // ── una cuerda del margen: el nudo dibujado ─────────────────────────
+    let trazo = model
+        .store
+        .crear_cuerda("el trazo", now.saturating_sub(15 * dia));
+    let nudos_trazo: [(&str, u64); 4] = [
+        ("hay pensamientos que son una forma, no una frase", now.saturating_sub(15 * dia)),
+        ("el lienzo vive adentro del nudo, no en un archivo aparte", now.saturating_sub(10 * dia)),
+        ("un diagrama a mano alzada envejece mejor que su versión prolija", now.saturating_sub(7 * dia)),
+        ("dibujar es capturar sin tener que nombrar todavía", now.saturating_sub(2 * dia)),
+    ];
+
+    for (cuerda, nudos) in [
+        (madre, &nudos_madre[..]),
+        (taller, &nudos_taller[..]),
+        (entre, &nudos_entre[..]),
+        (sedimento, &nudos_sedimento[..]),
+        (trazo, &nudos_trazo[..]),
+    ] {
+        for (texto, ts) in nudos {
+            let id = model.store.create(*texto, "", vec![], *ts);
+            model.order.push(id);
+            model.store.anudar(id, cuerda);
+            embed_now(&mut model, id);
+        }
+    }
+
+    // ── los sueltos frescos, que todavía no se anudaron a nada ───────────
+    for (texto, ts) in [
+        ("probar el tejido con el daemon de verbo corriendo", now.saturating_sub(dia)),
+        ("la barra de captura de pata podría botar nudos acá", now.saturating_sub(3600)),
+    ] {
+        let id = model.store.create(texto, "", vec![], ts);
+        model.order.push(id);
+        embed_now(&mut model, id);
+    }
+
+    let guion = Guion {
+        cuerda_madre: madre,
+        nudo_nuevo: None,
+        tejido: None,
+        anudado: false,
+    };
+    (model, guion)
+}
+
+// ───────────────────────── la coreografía ─────────────────────────
+
+/// Ajusta el estado del modelo para el instante `t`. Los beats que crean cosas
+/// (botar el nudo, anudarlo, tejer) se disparan **una sola vez**: `t` avanza
+/// monótono a lo largo del reel, así que alcanza con mirar si ya se hizo.
+fn aplicar(model: &mut Model, g: &mut Guion, t: f32) {
+    // ── beat 3: la idea se tipea en la barra de captura ──────────────────
+    if t < 0.44 {
+        let avance = seg(t, 0.27, 0.42);
+        let letras = (IDEA.chars().count() as f32 * avance).round() as usize;
+        let escrito: String = IDEA.chars().take(letras).collect();
+        model.captura.set_text(escrito);
+        model.focus = Focus::Captura;
+        model.resonancia = None;
+        model.cuerda_sel = None;
+        return;
+    }
+
+    // ── beat 4: se bota el nudo y la resonancia contesta ─────────────────
+    if g.nudo_nuevo.is_none() {
+        let id = model.store.create(IDEA, "", vec![], now_secs());
+        model.order.push(id);
+        embed_now(model, id);
+        model.captura.clear();
+        g.nudo_nuevo = Some(id);
+    }
+    let nuevo = g.nudo_nuevo.expect("el nudo del reel ya nació");
+
+    // El eco arranca «calculando» —igual que en la app, mientras el embedding
+    // viaja al worker— y recién después muestra a quién toca.
+    let calculando = t < 0.49;
+    model.resonancia = Some(Resonancia {
+        nudo: nuevo,
+        vecinos: if calculando {
+            Vec::new()
+        } else {
+            resonancia_de(model, nuevo)
+        },
+        calculando,
+    });
+
+    if t < 0.58 {
+        return;
+    }
+
+    // ── beat 5: el nudo se anuda a la cuerda madre y se abre su tarjeta ──
+    if !g.anudado {
+        model.store.anudar(nuevo, g.cuerda_madre);
+        g.anudado = true;
+    }
+    model.resonancia = None;
+    model.cuerda_sel = Some(g.cuerda_madre);
+    model.status = Some("anudado a «khipu» · 5 nudos".to_string());
+
+    if t < 0.72 {
+        return;
+    }
+
+    // ── beat 6: el tejido se cuelga al final de la cuerda ────────────────
+    if g.tejido.is_none() {
+        let id = model.store.create(TEJIDO, "", vec![], now_secs());
+        model.order.push(id);
+        model.store.anudar(id, g.cuerda_madre);
+        if let Some(c) = model.store.cuerda_mut(g.cuerda_madre) {
+            c.tejido = Some(id);
+        }
+        embed_now(model, id);
+        g.tejido = Some(id);
+    }
+    model.status = Some("tejido · el encuadre de la cuerda".to_string());
+}
+
+// ───────────────────────── los overlays del reel ─────────────────────────
+
+/// El cold-open: la viga del khipu se traza de izquierda a derecha y de ella
+/// cuelgan las cuerdas, con sus nudos apareciendo en cronología. Es el objeto
+/// que le da nombre a la app, dibujado con la misma escena que todo lo demás.
+fn dibujar_khipu(scene: &mut vello::Scene, t: f32, cw: f64, ch: f64) {
+    let acc = accent();
+    let vis = 1.0 - seg(t, 0.13, 0.21);
+    if vis <= 0.001 {
+        return;
+    }
+
+    let cx = cw / 2.0;
+    let vy = ch * 0.37; // la viga arriba, y las cuerdas cuelgan hasta 0.63: el grupo queda centrado
+    let ancho = cw * 0.46;
+
+    // La viga: se traza de izquierda a derecha.
+    let traza = motion::ease_out_cubic(seg(t, 0.01, 0.09)) as f64;
+    scene.stroke(
+        &Stroke::new(3.0),
+        Affine::IDENTITY,
+        with_alpha(acc, 0.92 * vis),
+        None,
+        &Line::new(
+            Point::new(cx - ancho / 2.0, vy),
+            Point::new(cx - ancho / 2.0 + ancho * traza, vy),
+        ),
+    );
+
+    // Las cuerdas cuelgan detrás de la cabeza del trazo, y en cada una los
+    // nudos aparecen de arriba abajo: lo viejo primero, como en el lienzo.
+    // Cinco cuerdas de largos distintos, las mismas que trae el cuaderno del
+    // reel: el cold-open promete lo que el lienzo muestra dos segundos después.
+    let cuerdas: [(f64, usize); 5] = [(0.10, 5), (0.30, 3), (0.50, 2), (0.72, 6), (0.90, 4)];
+    for (k, (frac, nudos)) in cuerdas.iter().enumerate() {
+        if traza < *frac {
+            continue;
+        }
+        let x = cx - ancho / 2.0 + ancho * frac;
+        let nacio = seg(t, 0.04 + k as f32 * 0.012, 0.10 + k as f32 * 0.012);
+        let largo = ch * 0.26 * motion::ease_out_cubic(nacio) as f64;
+        scene.stroke(
+            &Stroke::new(1.6),
+            Affine::IDENTITY,
+            with_alpha(acc, 0.42 * vis),
+            None,
+            &Line::new(Point::new(x, vy), Point::new(x, vy + largo)),
+        );
+        for i in 0..*nudos {
+            let paso = (i as f64 + 1.0) / (*nudos as f64 + 1.0);
+            let ny = vy + ch * 0.26 * paso;
+            if ny > vy + largo {
+                continue;
+            }
+            let pop = motion::ease_out_back(seg(t, 0.06 + i as f32 * 0.008, 0.12 + i as f32 * 0.008));
+            let r = 4.2 * pop as f64;
+            if r <= 0.0 {
+                continue;
+            }
+            scene.fill(
+                peniko::Fill::NonZero,
+                Affine::IDENTITY,
+                with_alpha(acc, 0.16 * vis),
+                None,
+                &Circle::new(Point::new(x, ny), r * 3.0),
+            );
+            scene.fill(
+                peniko::Fill::NonZero,
+                Affine::IDENTITY,
+                with_alpha(acc, 0.95 * vis),
+                None,
+                &Circle::new(Point::new(x, ny), r),
+            );
+        }
+    }
+}
+
+fn dibujar_overlays(
+    scene: &mut vello::Scene,
+    ts: &mut Typesetter,
+    t: f32,
+    cw: f64,
+    ch: f64,
+    fg: Color,
+    fg_muted: Color,
+) {
+    let acc = accent();
+
+    // ── velo de entrada: el chrome espera debajo del cold-open ──────────
+    let velo = 1.0 - seg(t, 0.11, 0.19);
+    if velo > 0.001 {
+        scene.fill(
+            peniko::Fill::NonZero,
+            Affine::IDENTITY,
+            with_alpha(fondo(), 0.94 * velo),
+            None,
+            &vello::kurbo::Rect::new(0.0, 0.0, cw, ch),
+        );
+    }
+    dibujar_khipu(scene, t, cw, ch);
+
+    // ── WORDMARK (86–100%): «khipu» + «el nudo que recuerda» ────────────
+    let cierre = seg(t, 0.85, 0.92);
+    if cierre > 0.001 {
+        scene.fill(
+            peniko::Fill::NonZero,
+            Affine::IDENTITY,
+            with_alpha(fondo(), 0.94 * cierre),
+            None,
+            &vello::kurbo::Rect::new(0.0, 0.0, cw, ch),
+        );
+    }
+    let palabra = motion::ease_out_cubic(seg(t, 0.87, 0.96));
+    if palabra > 0.001 {
+        let size = 150.0_f32;
+        let layout = ts.layout(
+            "khipu", size, None, Alignment::Start, 1.0, false, None, 800.0, false, false, 0.0, 0.0,
+        );
+        let mm = measurement(&layout);
+        let sube = lerp(24.0, 0.0, palabra as f64);
+        let ox = (cw - mm.width as f64) / 2.0;
+        let oy = (ch - mm.height as f64) / 2.0 - 18.0 + sube;
+        let brush = peniko::Brush::Solid(with_alpha(fg, palabra));
+        draw_layout_brush_xf(scene, &layout, &brush, Affine::translate((ox, oy)));
+
+        let sub_a = motion::ease_out_cubic(seg(t, 0.90, 1.0));
+        if sub_a > 0.001 {
+            let ssz = 26.0_f32;
+            let sub = ts.layout(
+                "el nudo que recuerda", ssz, None, Alignment::Start, 1.0, false, None, 400.0,
+                false, false, 0.0, 0.0,
+            );
+            let sm = measurement(&sub);
+            let punto = 6.0_f64;
+            let bloque = sm.width as f64 + punto * 2.0 + 14.0;
+            let sx = (cw - bloque) / 2.0;
+            let sy = oy + mm.height as f64 + 18.0;
+            scene.fill(
+                peniko::Fill::NonZero,
+                Affine::IDENTITY,
+                with_alpha(acc, sub_a),
+                None,
+                &Circle::new(Point::new(sx + punto, sy + ssz as f64 * 0.42), punto),
+            );
+            let sbrush = peniko::Brush::Solid(with_alpha(fg_muted, sub_a));
+            draw_layout_brush_xf(
+                scene,
+                &sub,
+                &sbrush,
+                Affine::translate((sx + punto * 2.0 + 14.0, sy)),
+            );
+        }
+    }
+
+    // ── punto teal de firma (esquina inferior derecha) ──────────────────
+    let esquina = seg(t, 0.22, 0.32) * (1.0 - seg(t, 0.82, 0.88));
+    if esquina > 0.001 {
+        let x = cw - 54.0;
+        let y = ch - 54.0;
+        scene.fill(
+            peniko::Fill::NonZero,
+            Affine::IDENTITY,
+            with_alpha(acc, 0.16 * esquina),
+            None,
+            &Circle::new(Point::new(x, y), 18.0),
+        );
+        scene.fill(
+            peniko::Fill::NonZero,
+            Affine::IDENTITY,
+            with_alpha(acc, 0.9 * esquina),
+            None,
+            &Circle::new(Point::new(x, y), 6.0),
+        );
+    }
+}
+
+// ───────────────────────── el árbol del frame ─────────────────────────
+
+/// La vista REAL del modelo (con su estado ya puesto en `t`) bajo un fade/slide
+/// de entrada, más un overlay a pantalla completa con el cold-open y el cierre.
+fn arbol(model: &Model, t: f32, cw: f64, ch: f64, fg: Color, fg_muted: Color) -> View<Msg> {
+    let entra = motion::ease_out_cubic(seg(t, 0.13, 0.26));
+    let alpha = (entra * (1.0 - seg(t, 0.84, 0.90))).clamp(0.0, 1.0);
+    let dy = lerp(18.0, 0.0, entra as f64);
+
+    let lleno = TaffyRect {
+        top: length(0.0_f32),
+        left: length(0.0_f32),
+        right: length(0.0_f32),
+        bottom: length(0.0_f32),
+    };
+
+    let chrome = View::new(Style {
+        position: Position::Absolute,
+        inset: lleno,
+        size: Size { width: percent(1.0_f32), height: percent(1.0_f32) },
+        ..Default::default()
+    })
+    .transform(Affine::translate((0.0, dy)))
+    .alpha(alpha)
+    .children(vec![<KhipuApp as App>::view(model)]);
+
+    let overlay = View::new(Style {
+        position: Position::Absolute,
+        inset: lleno,
+        size: Size { width: percent(1.0_f32), height: percent(1.0_f32) },
+        ..Default::default()
+    })
+    .paint_with(move |scene, ts, _rect: PaintRect| {
+        dibujar_overlays(scene, ts, t, cw, ch, fg, fg_muted);
+    });
+
+    View::new(Style {
+        position: Position::Relative,
+        size: Size { width: percent(1.0_f32), height: percent(1.0_f32) },
+        ..Default::default()
+    })
+    .fill(fondo())
+    .children(vec![chrome, overlay])
+}
+
+// ───────────────────────── el render ─────────────────────────
+
+/// Renderiza los `n` frames del reel a `out_dir/frame_%04d.png`.
+pub fn render_frames(out_dir: &str, n: usize, w: u32, h: u32) {
+    create_dir_all(out_dir).expect("mkdir out_dir");
+
+    let (mut model, mut guion) = cuaderno();
+    let fg = model.theme.fg_text;
+    let fg_muted = model.theme.fg_muted;
+
+    let hal = pollster::block_on(Hal::new(None)).expect("hal");
+    let mut renderer = Renderer::new(&hal).expect("renderer");
+    let target = hal.device.create_texture(&wgpu::TextureDescriptor {
+        label: Some("showreel-khipu"),
+        size: wgpu::Extent3d { width: w, height: h, depth_or_array_layers: 1 },
+        mip_level_count: 1,
+        sample_count: 1,
+        dimension: wgpu::TextureDimension::D2,
+        format: FMT,
+        usage: wgpu::TextureUsages::STORAGE_BINDING
+            | wgpu::TextureUsages::RENDER_ATTACHMENT
+            | wgpu::TextureUsages::COPY_SRC,
+        view_formats: &[],
+    });
+    let vista = target.create_view(&wgpu::TextureViewDescriptor::default());
+
+    let mut ts = Typesetter::new();
+    let cw = w as f64;
+    let ch = h as f64;
+
+    for i in 0..n {
+        let t = if n <= 1 { 0.0 } else { i as f32 / (n as f32 - 1.0) };
+        aplicar(&mut model, &mut guion, t);
+        let root = arbol(&model, t, cw, ch, fg, fg_muted);
+
+        let mut layout = LayoutTree::new();
+        let mounted = mount(&mut layout, root);
+        let computed = {
+            let tmap = &mounted.text_measures;
+            layout
+                .compute_with_measure(mounted.root, (w as f32, h as f32), |nid, known, avail| {
+                    match tmap.get(&nid) {
+                        Some(tm) => measure_text_node(&mut ts, tm, known, avail),
+                        None => taffy::Size::ZERO,
+                    }
+                })
+                .expect("layout")
+        };
+        let mut scene = vello::Scene::new();
+        paint(&mut scene, &mounted, &computed, &mut ts, None, None);
+
+        renderer
+            .render_to_view(&hal, &scene, &vista, w, h, fondo())
+            .expect("render_to_view");
+        escribir_png(&hal, &target, &format!("{out_dir}/frame_{i:04}.png"), w, h);
+        if i % 30 == 0 || i == n - 1 {
+            eprintln!("showreel-khipu: frame {}/{} (t={:.3})", i + 1, n, t);
+        }
+    }
+    eprintln!("showreel-khipu: {n} frames en {out_dir}/ ({w}x{h})");
+}
+
+fn escribir_png(hal: &Hal, target: &wgpu::Texture, path: &str, w: u32, h: u32) {
+    let sin_pad = (w * 4) as usize;
+    let align = wgpu::COPY_BYTES_PER_ROW_ALIGNMENT as usize;
+    let con_pad = sin_pad.div_ceil(align) * align;
+    let buf = hal.device.create_buffer(&wgpu::BufferDescriptor {
+        label: Some("readback"),
+        size: (con_pad * h as usize) as u64,
+        usage: wgpu::BufferUsages::MAP_READ | wgpu::BufferUsages::COPY_DST,
+        mapped_at_creation: false,
+    });
+    let mut enc = hal
+        .device
+        .create_command_encoder(&wgpu::CommandEncoderDescriptor { label: None });
+    enc.copy_texture_to_buffer(
+        wgpu::TexelCopyTextureInfo {
+            texture: target,
+            mip_level: 0,
+            origin: wgpu::Origin3d::ZERO,
+            aspect: wgpu::TextureAspect::All,
+        },
+        wgpu::TexelCopyBufferInfo {
+            buffer: &buf,
+            layout: wgpu::TexelCopyBufferLayout {
+                offset: 0,
+                bytes_per_row: Some(con_pad as u32),
+                rows_per_image: Some(h),
+            },
+        },
+        wgpu::Extent3d { width: w, height: h, depth_or_array_layers: 1 },
+    );
+    hal.queue.submit(std::iter::once(enc.finish()));
+
+    let slice = buf.slice(..);
+    let (tx, rx) = std::sync::mpsc::channel();
+    slice.map_async(wgpu::MapMode::Read, move |r| {
+        let _ = tx.send(r);
+    });
+    let _ = hal.device.poll(wgpu::PollType::wait_indefinitely());
+    rx.recv().expect("map_async").expect("mapeo");
+
+    let datos = slice.get_mapped_range();
+    let mut pixeles = Vec::with_capacity(sin_pad * h as usize);
+    for fila in 0..h as usize {
+        let desde = fila * con_pad;
+        pixeles.extend_from_slice(&datos[desde..desde + sin_pad]);
+    }
+    drop(datos);
+    buf.unmap();
+
+    let archivo = File::create(path).expect("crear png");
+    let mut cod = png::Encoder::new(BufWriter::new(archivo), w, h);
+    cod.set_color(png::ColorType::Rgba);
+    cod.set_depth(png::BitDepth::Eight);
+    cod.write_header()
+        .expect("cabecera png")
+        .write_image_data(&pixeles)
+        .expect("datos png");
+}
diff --git a/00_unanchay/khipu/khipu-brahman/tests/p2p_roundtrip.rs b/00_unanchay/khipu/khipu-brahman/tests/p2p_roundtrip.rs
index fed520f..f754c92 100644
--- a/00_unanchay/khipu/khipu-brahman/tests/p2p_roundtrip.rs
+++ b/00_unanchay/khipu/khipu-brahman/tests/p2p_roundtrip.rs
@@ -7,6 +7,7 @@ use std::time::{Duration, Instant};
 
 use agora_core::Keypair;
 use card_net::Multiaddr;
+use card_net::aislar_de_la_lan;
 use khipu_brahman::KhipuNode;
 use khipu_share::{open, seal, SharedNote};
 
@@ -14,6 +15,7 @@ use khipu_share::{open, seal, SharedNote};
 /// `fetch_addr_str` la consume (dial + reintento + fetch).
 #[tokio::test]
 async fn jalar_por_direccion_str_como_la_app() {
+    aislar_de_la_lan();
     let autor = Keypair::from_seed([32u8; 32]);
     let sobre = seal(
         &autor,
@@ -44,6 +46,7 @@ async fn jalar_por_direccion_str_como_la_app() {
 /// dirección de antemano.
 #[tokio::test]
 async fn descubrir_por_dht_y_jalar() {
+    aislar_de_la_lan();
     let autor = Keypair::from_seed([34u8; 32]);
     let sobre = seal(
         &autor,
@@ -102,6 +105,7 @@ async fn descubrir_por_dht_y_jalar() {
 /// dirección directa a A. Verifica la maquinaria relay/dcutr de card-net.
 #[tokio::test]
 async fn jalar_a_traves_de_un_relay() {
+    aislar_de_la_lan();
     let autor = Keypair::from_seed([33u8; 32]);
     let sobre = seal(
         &autor,
@@ -147,6 +151,7 @@ async fn jalar_a_traves_de_un_relay() {
 
 #[tokio::test]
 async fn jalar_un_sobre_entre_dos_nodos_libp2p() {
+    aislar_de_la_lan();
     // Sellar el cuaderno a servir.
     let autor = Keypair::from_seed([31u8; 32]);
     let sobre = seal(
diff --git a/00_unanchay/khipu/khipu-core/Cargo.toml b/00_unanchay/khipu/khipu-core/Cargo.toml
index 5597d79..b197e13 100644
--- a/00_unanchay/khipu/khipu-core/Cargo.toml
+++ b/00_unanchay/khipu/khipu-core/Cargo.toml
@@ -9,3 +9,8 @@ description = "khipu-app — núcleo de toma de notas: el modelo Note, el almac
 
 [dependencies]
 serde = { workspace = true }
+# El dibujo de un nudo es un documento de la pizarra, no un formato paralelo:
+# `DocPizarra` es el CRDT vectorial que `02_ruway/pizarra` ya define y que
+# cualquier dominio compone (pizarra/README.md). Guardarlo tal cual es lo que
+# permite abrir el mismo dibujo con el widget de la pizarra sin convertir nada.
+pizarra-core = { workspace = true }
diff --git a/00_unanchay/khipu/khipu-core/README.md b/00_unanchay/khipu/khipu-core/README.md
index 4338775..7d0b5a5 100644
--- a/00_unanchay/khipu/khipu-core/README.md
+++ b/00_unanchay/khipu/khipu-core/README.md
@@ -2,9 +2,10 @@
 
 > The agnostic core of [khipu](../README.md): knots, cords and the link graph. No UI, no disk, no network.
 
-- **`Note` (the knot)**: title, body, tags, timestamps; `cuerda: Option<CuerdaId>` (at most one), `estado` (Normal/Pendiente/Hecho — TODOs without due-dates) and `origen` (stamp of the capturing app, phase 2). Keeps `mass`/`last_access` for consumers with temporal physics (pata, iniy-derive).
+- **`Note` (the knot)**: title, body, tags, timestamps; `cuerda: Option<CuerdaId>` (at most one), `estado` (Normal/Pendiente/Hecho — TODOs without due-dates), `origen` (stamp of the capturing app, phase 2) and `dibujo: Option<DocPizarra>` — a thought isn't always a sentence. Keeps `mass`/`last_access` for consumers with temporal physics (pata, iniy-derive).
+- **The drawing is pizarra's document, not a khipu format.** `dibujo` holds `pizarra_core::DocPizarra` (the vector CRDT) verbatim, so the same drawing opens with pizarra's widget without any conversion, and `dibujado()` only counts a document with at least one element — opening a canvas and closing it doesn't make a knot drawn.
 - **`Cuerda`**: a recurrent idea — name, date, and its `tejido: Option<NoteId>` (the synthesis knot). It doesn't store its knot list: it's derived (`nudos_de`) so there are never two sources of truth.
-- **`NoteStore`**: deterministic CRUD (`BTreeMap`), cords (`crear_cuerda`/`anudar`/`desanudar`/`sueltos`/`borrar_cuerda`), wiki-link graph `[[Title]]` (forward/back/orphans/dangling), `insert_raw` + `coherente()` for safe migrations.
+- **`NoteStore`**: deterministic CRUD (`BTreeMap`), cords (`crear_cuerda`/`anudar`/`desanudar`/`sueltos`/`borrar_cuerda`), drawings (`set_dibujo`/`dibujo`), wiki-link graph `[[Title]]` (forward/back/orphans/dangling), and for safe migrations `insert_raw` + `restaurar_cuerdas` (both preserve ids and bump the counters) + `coherente()`.
 - **`nombre::propose_cuerda_name`**: the christening — the most shared, salient term across the group's titles/tags.
 
 ## API
@@ -25,3 +26,4 @@ assert!(s.sueltos().is_empty());
 ## Deps
 
 - `serde` (types travel as postcard/JSON depending on the caller)
+- `pizarra-core` (`DocPizarra`: the knot's drawing — reused, not re-modelled)
diff --git a/00_unanchay/khipu/khipu-core/src/nombre.rs b/00_unanchay/khipu/khipu-core/src/nombre.rs
index 8187195..23393b7 100644
--- a/00_unanchay/khipu/khipu-core/src/nombre.rs
+++ b/00_unanchay/khipu/khipu-core/src/nombre.rs
@@ -102,6 +102,7 @@ mod tests {
             cuerda: None,
             estado: EstadoNudo::Normal,
             origen: None,
+            dibujo: None,
         }
     }
 
diff --git a/00_unanchay/khipu/khipu-core/src/note.rs b/00_unanchay/khipu/khipu-core/src/note.rs
index 5ba5ba5..69087ea 100644
--- a/00_unanchay/khipu/khipu-core/src/note.rs
+++ b/00_unanchay/khipu/khipu-core/src/note.rs
@@ -5,6 +5,7 @@
 //! [`crate::Cuerda`]. No hay carpetas ni jerarquías previas — la
 //! estructura emerge de la recurrencia.
 
+use pizarra_core::DocPizarra;
 use serde::{Deserialize, Serialize};
 
 use crate::links::parse_links;
@@ -75,6 +76,17 @@ pub struct Note {
     /// capturado en khipu mismo.
     #[serde(default)]
     pub origen: Option<Origen>,
+    /// El dibujo del nudo, si lo tiene. Un pensamiento no siempre es una
+    /// frase: una flecha entre dos cosas es una expresión mental más, como
+    /// lo es un TODO. Es el documento de la pizarra tal cual
+    /// (`pizarra_core::DocPizarra`), no una conversión — así el mismo dibujo
+    /// se edita con el widget de la pizarra y no hay un formato paralelo.
+    ///
+    /// El cuerpo del nudo **no** se reemplaza: un nudo dibujado puede además
+    /// llevar texto, y el texto es lo que resuena (los embeddings leen
+    /// título y cuerpo, no trazos).
+    #[serde(default)]
+    pub dibujo: Option<DocPizarra>,
 }
 
 fn default_mass() -> f32 {
@@ -103,6 +115,13 @@ impl Note {
     pub fn suelto(&self) -> bool {
         self.cuerda.is_none()
     }
+
+    /// `true` si el nudo lleva un dibujo con al menos un elemento. Un
+    /// documento vacío no cuenta: abrir la pizarra y cerrarla sin trazar
+    /// nada no convierte al nudo en dibujado.
+    pub fn dibujado(&self) -> bool {
+        self.dibujo.as_ref().is_some_and(|d| !d.is_empty())
+    }
 }
 
 /// Una cuerda: una idea recurrente. No se crea por adelantado — nace
@@ -139,6 +158,7 @@ mod tests {
             cuerda: None,
             estado: EstadoNudo::Normal,
             origen: None,
+            dibujo: None,
         }
     }
 
diff --git a/00_unanchay/khipu/khipu-core/src/store.rs b/00_unanchay/khipu/khipu-core/src/store.rs
index 2dddb66..4566647 100644
--- a/00_unanchay/khipu/khipu-core/src/store.rs
+++ b/00_unanchay/khipu/khipu-core/src/store.rs
@@ -12,7 +12,9 @@ use std::collections::BTreeMap;
 
 use serde::{Deserialize, Serialize};
 
-use crate::note::{Cuerda, CuerdaId, Note, NoteId};
+use pizarra_core::DocPizarra;
+
+use crate::note::{Cuerda, CuerdaId, Note, NoteId, Origen};
 
 /// El almacén de nudos y cuerdas de khipu.
 #[derive(Debug, Clone, Default, Serialize, Deserialize)]
@@ -67,11 +69,56 @@ impl NoteStore {
                 cuerda: None,
                 estado: Default::default(),
                 origen: None,
+                dibujo: None,
             },
         );
         id
     }
 
+    /// Como [`NoteStore::create`], **sellando de dónde vino**.
+    ///
+    /// `Note::origen` existía en el modelo desde la fase 2 del cuaderno y
+    /// **nada podía ponerlo**: el campo estaba declarado, la app lo mostraba, y
+    /// el único lugar del árbol que lo escribía era un fixture de test de la
+    /// migración V4. Un campo que nadie puede llenar no es una capacidad
+    /// pendiente: es una promesa. Esto lo hace alcanzable.
+    ///
+    /// El primer usuario es `qhali` —un escrito del diario que se proyecta a un
+    /// nudo—, pero la firma no lo nombra: la estampa dice qué app y qué
+    /// referencia, y khipu no la reinterpreta.
+    pub fn crear_con_origen(
+        &mut self,
+        title: impl Into<String>,
+        body: impl Into<String>,
+        tags: Vec<String>,
+        now: u64,
+        origen: Origen,
+    ) -> NoteId {
+        let id = self.create(title, body, tags, now);
+        if let Some(n) = self.notes.get_mut(&id) {
+            n.origen = Some(origen);
+        }
+        id
+    }
+
+    /// Guarda el dibujo de un nudo y le corre la marca de edición. Un
+    /// documento sin elementos se guarda como `None`: abrir la pizarra y
+    /// cerrarla sin trazar nada no deja al nudo dibujado (`Note::dibujado`).
+    /// Devuelve `false` si el nudo no existe.
+    pub fn set_dibujo(&mut self, id: NoteId, doc: DocPizarra, now: u64) -> bool {
+        let Some(n) = self.notes.get_mut(&id) else {
+            return false;
+        };
+        n.dibujo = if doc.is_empty() { None } else { Some(doc) };
+        n.updated_at = now;
+        true
+    }
+
+    /// El dibujo de un nudo, si lo tiene y no está vacío.
+    pub fn dibujo(&self, id: NoteId) -> Option<&DocPizarra> {
+        self.notes.get(&id).and_then(|n| n.dibujo.as_ref()).filter(|d| !d.is_empty())
+    }
+
     /// Inserta una nota **preservando su id** (reemplaza si ya existe) y
     /// ajusta `next_id` para nunca reusarlo. Para migraciones de formato
     /// e importaciones que deben conservar identidades — el camino normal
@@ -81,6 +128,19 @@ impl NoteStore {
         self.notes.insert(note.id, note);
     }
 
+    /// Restaura cuerdas **preservando sus ids** y ajusta el contador para no
+    /// reusarlos. Es el gemelo de [`NoteStore::insert_raw`] para el otro lado
+    /// del modelo, y existe por la misma razón: una migración de formato tiene
+    /// que devolver las identidades intactas. Si las cuerdas se perdieran,
+    /// cada nudo anudado quedaría apuntando a una cuerda que no existe y
+    /// [`NoteStore::coherente`] descartaría el cuaderno entero.
+    pub fn restaurar_cuerdas(&mut self, cuerdas: BTreeMap<CuerdaId, Cuerda>) {
+        for (id, c) in cuerdas {
+            self.next_cuerda = self.next_cuerda.max(id + 1);
+            self.cuerdas.insert(id, c);
+        }
+    }
+
     /// Chequeo de coherencia interna: cada nota vive bajo su propio id,
     /// los contadores no van a reusar ids vivos, y toda cuerda referida
     /// por una nota (o por un tejido) existe. Lo usa la carga desde disco
@@ -370,6 +430,64 @@ impl NoteStore {
     }
 }
 
+#[cfg(test)]
+mod tests_dibujo {
+    use super::*;
+    use pizarra_core::{Elemento, Pizarra};
+
+    fn con_un_trazo() -> DocPizarra {
+        let mut p = Pizarra::nueva(7);
+        p.agregar(Elemento::Trazo {
+            puntos: vec![(0.0, 0.0), (10.0, 10.0)],
+            color: None,
+            grosor: 2.5,
+        });
+        p.doc
+    }
+
+    #[test]
+    fn un_nudo_nace_sin_dibujo_y_lo_guarda_marcando_la_edicion() {
+        let mut s = NoteStore::new();
+        let id = s.create("flecha", "de A a B", vec![], 100);
+        assert!(!s.get(id).unwrap().dibujado());
+        assert!(s.dibujo(id).is_none());
+
+        assert!(s.set_dibujo(id, con_un_trazo(), 200));
+        assert!(s.get(id).unwrap().dibujado());
+        assert_eq!(s.dibujo(id).map(|d| d.len()), Some(1));
+        // El dibujo es una edición del nudo: corre `updated_at`.
+        assert_eq!(s.get(id).unwrap().updated_at, 200);
+        // Y no toca el texto: lo que resuena sigue siendo la frase.
+        assert_eq!(s.get(id).unwrap().body, "de A a B");
+    }
+
+    #[test]
+    fn un_lienzo_vacio_no_deja_al_nudo_dibujado() {
+        let mut s = NoteStore::new();
+        let id = s.create("nada", "", vec![], 100);
+        assert!(s.set_dibujo(id, DocPizarra::new(), 200));
+        assert!(!s.get(id).unwrap().dibujado());
+        assert!(s.get(id).unwrap().dibujo.is_none());
+    }
+
+    #[test]
+    fn borrar_el_ultimo_trazo_desdibuja_el_nudo() {
+        let mut s = NoteStore::new();
+        let id = s.create("flecha", "", vec![], 100);
+        let mut p = Pizarra::desde_doc(7, con_un_trazo());
+        let (&eid, _) = p.elementos().next().unwrap();
+        p.borrar(eid);
+        assert!(s.set_dibujo(id, p.doc, 300));
+        assert!(!s.get(id).unwrap().dibujado());
+    }
+
+    #[test]
+    fn set_dibujo_sobre_un_nudo_que_no_existe_no_miente() {
+        let mut s = NoteStore::new();
+        assert!(!s.set_dibujo(42, con_un_trazo(), 100));
+    }
+}
+
 #[cfg(test)]
 mod tests {
     use super::*;
@@ -586,6 +704,7 @@ mod tests {
             cuerda: None,
             estado: Default::default(),
             origen: None,
+            dibujo: None,
         };
         s.insert_raw(n.clone());
         assert_eq!(s.get(7).unwrap().title, "migrada");
diff --git a/00_unanchay/khipu/khipu-redaccion/Cargo.toml b/00_unanchay/khipu/khipu-redaccion/Cargo.toml
new file mode 100644
index 0000000..71d7b69
--- /dev/null
+++ b/00_unanchay/khipu/khipu-redaccion/Cargo.toml
@@ -0,0 +1,22 @@
+#  khipu :: khipu-redaccion — la cuerda proyectada a un haz de pluma.
+#
+#  El precedente exacto es `ende-core`, que proyecta el haz de pluma a una
+#  `Pelicula` y vive en ende, no en pluma: la proyección es del dominio que
+#  consume, no del que presta el modelo. Acá pasa lo mismo al revés — khipu
+#  arma el haz y pluma lo abre sin saber que khipu existe.
+[package]
+name = "khipu-redaccion"
+version.workspace = true
+edition.workspace = true
+rust-version.workspace = true
+license.workspace = true
+authors.workspace = true
+publish.workspace = true
+description = "khipu-redaccion — proyección de una cuerda de khipu a un haz de pluma: el tejido de apertura y los nudos como átomos narrativos, listo para abrir en el editor multilienzo."
+
+[dependencies]
+khipu-core = { path = "../khipu-core" }
+pluma-core = { workspace = true }
+pluma-cuerpo = { workspace = true }
+pluma-proyecto = { workspace = true }
+uuid = { workspace = true }
diff --git a/00_unanchay/khipu/khipu-redaccion/LEEME.md b/00_unanchay/khipu/khipu-redaccion/LEEME.md
new file mode 100644
index 0000000..2c203d5
--- /dev/null
+++ b/00_unanchay/khipu/khipu-redaccion/LEEME.md
@@ -0,0 +1,85 @@
+# khipu-redaccion
+
+*Read this in English: [README.md](README.md).*
+
+> La **cuerda redactada**: una idea recurrente de khipu proyectada a un haz de
+> pluma.
+
+khipu junta pensamientos sueltos y los deja resonar hasta que una idea se repite
+lo suficiente como para merecer una cuerda. Después `tejer` le pide al LLM una
+síntesis y la deja como nudo-diamante al final. Y ahí se terminaba: la idea
+madura seguía viviendo como una lista de nudos, que es un buen lugar para
+**pensar** y uno malo para **escribir**.
+
+Este crate es el paso siguiente. Toma una cuerda y arma con ella el documento
+que pluma sabe abrir.
+
+## Qué produce
+
+Un `pluma_proyecto::DocEstado` con **un** `Cuerpo` `Original` (rol `Base`, rama
+`khipu`) cuyo orden es:
+
+1. **el tejido**, si la cuerda ya se tejió — es la síntesis, y en un borrador la
+   síntesis va arriba;
+2. **los nudos en cronología**, uno por párrafo.
+
+El tejido no se repite abajo aunque sea un nudo más de la cuerda, que es lo que
+es. Cada párrafo es un `NarrativeAtom` con su hash al día, y las secciones se
+normalizan **después** del volcado entero del orden — hacerlo por inserción
+sería cuadrático y destruiría las etiquetas en el estado intermedio
+(`SDD-MULTILIENZO.md` §12).
+
+El texto de un nudo es `título\ncuerpo`, salvo cuando el cuerpo ya arranca con
+el título: khipu propone el título desde el contenido, así que en los nudos
+cortos los dos son la misma frase y repetirla se lee como un error de la
+máquina.
+
+## Dónde vive y por qué acá
+
+El precedente es `ende-core`, que proyecta el haz de pluma a una `Pelicula` y
+vive en **ende**, no en pluma. La proyección pertenece al dominio que
+**consume**, no al que presta el modelo. Acá pasa lo mismo en la otra dirección:
+khipu arma el haz y pluma lo abre sin enterarse de que khipu existe.
+
+Por eso el crate es **puro**: sin UI, sin disco, sin reloj propio — el `ahora`
+lo pasa el caller, igual que en `pluma-cuerpo`. El envase (`Proyecto::guardar`
+al `.pluma`) y el gesto de abrirlo por el AppBus viven en `khipu-app`.
+
+## Lo que NO hace
+
+**No sincroniza de vuelta.** Redactar es un **corte**: se lleva una copia del
+estado de la cuerda en ese momento, y de ahí en más el documento es de pluma.
+Los nudos se siguen capturando y anudando en khipu, y volver a redactar arma un
+haz nuevo (que pisa el mismo archivo, porque el nombre lleva el id de la
+cuerda). Un documento editado por khipu y pluma a la vez sería un CRDT
+compartido entre dos modelos distintos — otro trabajo, y su lugar es
+`pluma-cuerpo-crdt`, no acá.
+
+**No redacta una cuerda vacía.** `ErrorRedaccion::SinNudos`: un documento sin
+párrafos no es un borrador, es una ventana en blanco con un nombre.
+
+## Uso
+
+```rust
+use khipu_redaccion::{redactar, nombre_de_archivo};
+
+let r = redactar(&store, cuerda_id, ahora)?;
+println!("{} nudos, tejido: {}", r.nudos, r.con_tejido);
+
+let mut proyecto = pluma_proyecto::Proyecto::nuevo(r.doc.nombre.clone());
+let doc = proyecto.nuevo_documento(r.doc.nombre.clone());
+proyecto.set_documento(doc, r.doc);
+proyecto.guardar(format!("{}.pluma", nombre_de_archivo("la idea", cuerda_id)))?;
+```
+
+`nombre_de_archivo` existe porque el nombre de una cuerda lo escribe una persona
+(«facturación 2026/2027») y de ahí sale un archivo en el disco: una barra en el
+medio lo escribiría en otra carpeta.
+
+## Tests
+
+`cargo test -p khipu-redaccion` — 9 tests: el orden cronológico (no el de
+anudado), el tejido que encabeza y no se repite, la validez del haz para pluma
+(consistencia, átomos sin huérfanos, una sección por nudo), los hashes al día,
+el título que no se duplica, los nudos sin título o sin cuerpo, el tejido que
+apunta a un nudo borrado, y el nombre de archivo que no se escapa de la carpeta.
diff --git a/00_unanchay/khipu/khipu-redaccion/README.md b/00_unanchay/khipu/khipu-redaccion/README.md
new file mode 100644
index 0000000..e06837d
--- /dev/null
+++ b/00_unanchay/khipu/khipu-redaccion/README.md
@@ -0,0 +1,84 @@
+# khipu-redaccion
+
+> The **drafted cord**: a recurrent khipu idea projected into a pluma bundle.
+
+khipu gathers loose thoughts and lets them resonate until an idea repeats often
+enough to deserve a cord. Then `tejer` asks the LLM for a synthesis and leaves it
+as the diamond knot at the end. And there it stopped: the matured idea went on
+living as a list of knots — a good place to **think**, a bad one to **write**.
+
+This crate is the next step. It takes a cord and builds the document pluma knows
+how to open.
+
+## What it produces
+
+A `pluma_proyecto::DocEstado` with **one** `Original` `Cuerpo` (role `Base`,
+branch `khipu`) ordered as:
+
+1. **the weave**, if the cord was already woven — it is the synthesis, and in a
+   draft the synthesis goes on top;
+2. **the knots in chronological order**, one per paragraph.
+
+The weave is not repeated below even though it is one more knot of the cord,
+which is what it is. Each paragraph is a `NarrativeAtom` with its hash up to
+date, and sections are normalised **after** dumping the whole order — doing it
+per insertion would be quadratic and would destroy the labels in the intermediate
+state (`SDD-MULTILIENZO.md` §12).
+
+A knot's text is `title\nbody`, except when the body already starts with the
+title: khipu proposes the title from the content, so in short knots both are the
+same sentence and repeating it reads as a machine error.
+
+## Where it lives, and why here
+
+The precedent is `ende-core`, which projects the pluma bundle into a `Pelicula`
+and lives in **ende**, not in pluma. The projection belongs to the domain that
+**consumes**, not to the one that lends the model. Here it is the same in the
+other direction: khipu builds the bundle and pluma opens it without ever learning
+that khipu exists.
+
+That is why the crate is **pure**: no UI, no disk, no clock of its own — `ahora`
+is passed by the caller, as in `pluma-cuerpo`. The envelope (`Proyecto::guardar`
+to the `.pluma`) and the AppBus gesture live in `khipu-app`.
+
+## What it does not do
+
+**It does not sync back.** Drafting is a **cut**: it takes a copy of the cord's
+state at that moment, and from there on the document belongs to pluma. Knots go
+on being captured and tied in khipu, and drafting again builds a fresh bundle
+(overwriting the same file, because the name carries the cord id). A document
+edited by khipu and pluma at once would be a CRDT shared between two different
+models — another job, and its place is `pluma-cuerpo-crdt`, not here.
+
+**It does not draft an empty cord.** `ErrorRedaccion::SinNudos`: a document with
+no paragraphs is not a draft, it is a blank window with a name.
+
+## Use
+
+```rust
+use khipu_redaccion::{redactar, nombre_de_archivo};
+
+let r = redactar(&store, cuerda_id, ahora)?;
+println!("{} knots, weave: {}", r.nudos, r.con_tejido);
+
+let mut proyecto = pluma_proyecto::Proyecto::nuevo(r.doc.nombre.clone());
+let doc = proyecto.nuevo_documento(r.doc.nombre.clone());
+proyecto.set_documento(doc, r.doc);
+proyecto.guardar(format!("{}.pluma", nombre_de_archivo("the idea", cuerda_id)))?;
+```
+
+`nombre_de_archivo` exists because a cord's name is written by a person
+("billing 2026/2027") and a file on disk comes out of it: a slash in the middle
+would write it into another folder.
+
+## Tests
+
+`cargo test -p khipu-redaccion` — 9 tests: chronological order (not tying order),
+the weave that leads and is not repeated, the bundle's validity for pluma
+(consistency, atoms without orphans, one section per knot), up-to-date hashes,
+the title that is not duplicated, knots without title or body, a weave pointing
+at a deleted knot, and the file name that cannot escape the folder.
+
+---
+
+Part of **khipu** — see [khipu](../README.md).
diff --git a/00_unanchay/khipu/khipu-redaccion/src/lib.rs b/00_unanchay/khipu/khipu-redaccion/src/lib.rs
new file mode 100644
index 0000000..3e0095b
--- /dev/null
+++ b/00_unanchay/khipu/khipu-redaccion/src/lib.rs
@@ -0,0 +1,377 @@
+//! `khipu-redaccion` — la **cuerda redactada**: una idea recurrente de khipu
+//! proyectada a un haz de pluma.
+//!
+//! # Por qué existe
+//!
+//! khipu junta pensamientos sueltos y los deja resonar hasta que una idea se
+//! repite lo suficiente como para merecer una cuerda. Después `tejer` le pide
+//! al LLM una síntesis y la deja como nudo-diamante al final. Y ahí se
+//! terminaba: la idea madura seguía viviendo como una lista de nudos, que es
+//! un buen lugar para pensar y uno malo para **escribir**.
+//!
+//! Este crate es el paso siguiente. Toma una cuerda y arma con ella el
+//! documento que pluma sabe abrir: un [`Cuerpo`] `Original` cuyo orden son los
+//! nudos en cronología, cada uno un [`NarrativeAtom`]. Desde ahí la idea entra
+//! al multilienzo con todo lo que pluma ya trae — traducir, cambiar el tono,
+//! resumir, versionar, exportar a docx.
+//!
+//! # Dónde vive y por qué acá
+//!
+//! El precedente es `ende-core`, que proyecta el haz de pluma a una `Pelicula`
+//! y vive en **ende**, no en pluma. La proyección pertenece al dominio que
+//! consume, no al que presta el modelo. Acá pasa lo mismo en la otra
+//! dirección: khipu arma el haz y pluma lo abre sin enterarse de que khipu
+//! existe. Por eso el crate es **puro** — sin UI, sin disco, sin reloj propio:
+//! el `ahora` lo pasa el caller, igual que en `pluma-cuerpo`.
+//!
+//! # Lo que NO hace
+//!
+//! No sincroniza de vuelta. Redactar es un **corte**: se lleva una copia del
+//! estado de la cuerda al momento de redactar, y a partir de ahí el documento
+//! es de pluma. Los nudos se siguen pudiendo capturar y anudar en khipu, y
+//! volver a redactar arma un haz nuevo. Un documento que edita khipu y pluma a
+//! la vez sería un CRDT compartido entre dos modelos distintos, que es otro
+//! trabajo (y el lugar donde vivirá es `pluma-cuerpo-crdt`, no acá).
+
+use khipu_core::{Cuerda, CuerdaId, Note, NoteStore};
+use pluma_core::NarrativeAtom;
+use pluma_cuerpo::{Cuerpo, Intencion};
+use pluma_proyecto::DocEstado;
+
+/// La rama del haz que nace de khipu. Es la convención de `branch_id` que
+/// pluma usa para nombrar la línea temporal de un cuerpo (`"es"`, `"qu"`,
+/// `"borrador-2"`…): lo que sale de un cuaderno de resonancia se llama así
+/// para que se note de dónde vino.
+pub const RAMA: &str = "khipu";
+
+/// Qué salió de redactar una cuerda: el haz listo para pluma y el recuento de
+/// lo que entró.
+///
+/// El recuento no es decorativo: es lo que la app le dice al operador al
+/// terminar (`«3 nudos + el tejido»`), y lo que un test mira para certificar
+/// que no se perdió nada por el camino.
+///
+/// No es `PartialEq` porque `DocEstado` no lo es: cada redacción estrena
+/// `Uuid`s, así que comparar dos haces por igualdad estructural nunca daría
+/// `true` y la tentación de compararlos es un error esperando pasar.
+#[derive(Debug, Clone)]
+pub struct Redaccion {
+    /// El haz, listo para `Proyecto::set_documento` o para el handoff.
+    pub doc: DocEstado,
+    /// Cuántos nudos entraron como párrafos (sin contar el tejido).
+    pub nudos: usize,
+    /// Si el tejido de la cuerda encabeza el documento.
+    pub con_tejido: bool,
+}
+
+/// Por qué no se pudo redactar una cuerda.
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+pub enum ErrorRedaccion {
+    /// La cuerda no existe en el almacén.
+    SinCuerda,
+    /// La cuerda existe pero no tiene ni un nudo. Un documento vacío no es un
+    /// borrador: es una ventana en blanco con un nombre, y abrirla sería
+    /// mentirle al operador sobre que había algo para escribir.
+    SinNudos,
+}
+
+/// El texto con que un nudo entra al documento.
+///
+/// Un nudo tiene título y cuerpo; un párrafo de pluma es una cadena sola. Se
+/// juntan con el título como primera línea **sólo si el cuerpo no lo repite ya**
+/// — khipu propone el título desde el contenido, así que en muchos nudos
+/// cortos título y cuerpo son la misma frase y duplicarla se ve como un error
+/// de la máquina.
+///
+/// Un nudo sin cuerpo entra con su título; un nudo sin título, con su cuerpo.
+/// Nunca devuelve una cadena vacía si el nudo tiene algo escrito.
+pub fn texto_de(n: &Note) -> String {
+    let titulo = n.title.trim();
+    let cuerpo = n.body.trim();
+    if titulo.is_empty() {
+        return cuerpo.to_string();
+    }
+    if cuerpo.is_empty() {
+        return titulo.to_string();
+    }
+    // El cuerpo ya arranca con el título (el caso del nudo corto): no se repite.
+    let primera = cuerpo.lines().next().unwrap_or("").trim();
+    if primera == titulo {
+        return cuerpo.to_string();
+    }
+    format!("{titulo}\n{cuerpo}")
+}
+
+/// Proyecta una cuerda a un haz de pluma.
+///
+/// El orden es el de la cuerda tal como khipu la muestra: **el tejido primero**
+/// —es la síntesis, y en un borrador la síntesis va arriba— y después los
+/// nudos en cronología. El tejido no se repite abajo aunque sea un nudo más de
+/// la cuerda, que es lo que es.
+///
+/// `ahora` es el segundo Unix que el caller decide; el crate no toca el reloj.
+pub fn redactar(
+    store: &NoteStore,
+    cuerda: CuerdaId,
+    ahora: u64,
+) -> Result<Redaccion, ErrorRedaccion> {
+    let c: &Cuerda = store.cuerda(cuerda).ok_or(ErrorRedaccion::SinCuerda)?;
+
+    // Los nudos en cronología. `nudos_de` ya los devuelve así, pero el orden
+    // es lo único que el lector va a leer como argumento — se fija acá y no
+    // se confía en el orden de iteración de un mapa.
+    let mut nudos: Vec<&Note> = store.nudos_de(cuerda);
+    nudos.sort_by_key(|n| (n.created_at, n.id));
+
+    // El tejido sale de la lista de abajo: encabeza, no se repite.
+    let tejido = c.tejido.filter(|id| store.get(*id).is_some());
+    if let Some(t) = tejido {
+        nudos.retain(|n| n.id != t);
+    }
+    if nudos.is_empty() && tejido.is_none() {
+        return Err(ErrorRedaccion::SinNudos);
+    }
+
+    let nombre = if c.nombre.trim().is_empty() {
+        format!("cuerda {}", c.id)
+    } else {
+        c.nombre.trim().to_string()
+    };
+
+    let mut cuerpo = Cuerpo::nuevo(RAMA, nombre.clone(), Intencion::Original, ahora);
+    let mut atoms: Vec<NarrativeAtom> = Vec::with_capacity(nudos.len() + 1);
+
+    let empujar = |texto: String, cuerpo: &mut Cuerpo, atoms: &mut Vec<NarrativeAtom>| {
+        let a = NarrativeAtom::new(texto, RAMA);
+        cuerpo.agregar(a.id, ahora);
+        atoms.push(a);
+    };
+
+    if let Some(t) = tejido {
+        // `retain` de arriba garantiza que el tejido no vuelva abajo.
+        let n = store.get(t).expect("filtrado por existencia unas líneas antes");
+        empujar(texto_de(n), &mut cuerpo, &mut atoms);
+    }
+    let contados = nudos.len();
+    for n in nudos {
+        empujar(texto_de(n), &mut cuerpo, &mut atoms);
+    }
+
+    // La sección se calcula DESPUÉS del volcado entero del orden, no adentro
+    // del bucle: normalizar por inserción sería cuadrático y además destruiría
+    // las etiquetas en el estado intermedio (`SDD-MULTILIENZO.md` §12, y el
+    // doc de `Cuerpo::secciones` lo anota como trampa).
+    cuerpo.normalizar_secciones();
+
+    let mut doc = DocEstado::vacio(nombre);
+    doc.cuerpos = vec![cuerpo];
+    doc.atoms = atoms;
+
+    Ok(Redaccion {
+        doc,
+        nudos: contados,
+        con_tejido: tejido.is_some(),
+    })
+}
+
+/// Un nombre de archivo seguro derivado del nombre de la cuerda, **sin** la
+/// extensión. Minúsculas, sin acentos raros ni separadores de ruta: lo que
+/// sobrevive es `[a-z0-9-]`.
+///
+/// Existe porque el nombre de una cuerda lo escribe una persona («la idea que
+/// vuelve», «facturación 2026/2027») y de ahí sale un archivo en el disco: una
+/// barra en el medio lo escribiría en otra carpeta, y un nombre vacío lo
+/// dejaría sin nombre.
+pub fn nombre_de_archivo(nombre: &str, id: CuerdaId) -> String {
+    let mut s = String::new();
+    let mut guion = false;
+    for ch in nombre.chars() {
+        let c = ch.to_lowercase().next().unwrap_or(ch);
+        if c.is_ascii_alphanumeric() {
+            s.push(c);
+            guion = false;
+        } else if !s.is_empty() && !guion {
+            s.push('-');
+            guion = true;
+        }
+    }
+    while s.ends_with('-') {
+        s.pop();
+    }
+    s.truncate(48);
+    while s.ends_with('-') {
+        s.pop();
+    }
+    if s.is_empty() {
+        format!("cuerda-{id}")
+    } else {
+        format!("{s}-{id}")
+    }
+}
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+
+    /// Una cuerda con tres nudos en desorden de captura y un tejido.
+    fn cuaderno() -> (NoteStore, CuerdaId) {
+        let mut s = NoteStore::new();
+        let c = s.crear_cuerda("la idea que vuelve", 100);
+        let a = s.create("primero", "lo primero que pensé", vec![], 300);
+        let b = s.create("segundo", "y después esto", vec![], 100);
+        let d = s.create("tercero", "y esto al final", vec![], 200);
+        s.anudar(a, c);
+        s.anudar(b, c);
+        s.anudar(d, c);
+        (s, c)
+    }
+
+    #[test]
+    fn los_nudos_entran_en_cronologia_no_en_orden_de_anudado() {
+        let (s, c) = cuaderno();
+        let r = redactar(&s, c, 999).expect("hay nudos");
+        assert_eq!(r.nudos, 3);
+        assert!(!r.con_tejido);
+        let cuerpo = &r.doc.cuerpos[0];
+        assert_eq!(cuerpo.orden.len(), 3);
+        let textos: Vec<&str> = cuerpo
+            .orden
+            .iter()
+            .map(|id| {
+                r.doc
+                    .atoms
+                    .iter()
+                    .find(|a| a.id == *id)
+                    .map(|a| a.content.as_str())
+                    .unwrap()
+            })
+            .collect();
+        assert_eq!(
+            textos,
+            vec![
+                "segundo\ny después esto",
+                "tercero\ny esto al final",
+                "primero\nlo primero que pensé",
+            ]
+        );
+    }
+
+    #[test]
+    fn el_tejido_encabeza_y_no_se_repite_abajo() {
+        let (mut s, c) = cuaderno();
+        let t = s.create("síntesis", "lo que se repite es X", vec![], 400);
+        s.anudar(t, c);
+        s.cuerda_mut(c).unwrap().tejido = Some(t);
+
+        let r = redactar(&s, c, 999).expect("hay nudos");
+        assert!(r.con_tejido);
+        // 4 nudos anudados, pero el tejido no cuenta como párrafo del cuerpo.
+        assert_eq!(r.nudos, 3);
+        let cuerpo = &r.doc.cuerpos[0];
+        assert_eq!(cuerpo.orden.len(), 4, "el tejido + los tres nudos");
+        let primero = r
+            .doc
+            .atoms
+            .iter()
+            .find(|a| a.id == cuerpo.orden[0])
+            .unwrap();
+        assert_eq!(primero.content.as_str(), "síntesis\nlo que se repite es X");
+        // Y una sola vez: el diamante no aparece dos veces en el borrador.
+        let veces = r
+            .doc
+            .atoms
+            .iter()
+            .filter(|a| a.content.contains("lo que se repite"))
+            .count();
+        assert_eq!(veces, 1);
+    }
+
+    #[test]
+    fn el_haz_es_valido_para_pluma() {
+        let (s, c) = cuaderno();
+        let r = redactar(&s, c, 999).unwrap();
+        let cuerpo = &r.doc.cuerpos[0];
+        assert!(cuerpo.valida_consistencia().is_ok());
+        assert_eq!(cuerpo.branch_id, RAMA);
+        assert_eq!(cuerpo.metadatos.intencion, Intencion::Original);
+        assert_eq!(cuerpo.metadatos.rol, pluma_cuerpo::Rol::Base);
+        assert_eq!(r.doc.nombre, "la idea que vuelve");
+        // Todo id del orden tiene su átomo, que es lo que el caller debe
+        // garantizar (el cuerpo no conoce el grafo y no lo valida).
+        for id in &cuerpo.orden {
+            assert!(r.doc.atoms.iter().any(|a| a.id == *id), "átomo huérfano");
+        }
+        // Y toda sección quedó asignada tras el volcado: con el corte por
+        // defecto (`Corte::Parrafo`), un nudo = una sección. Si esto diera
+        // menos, habría nudos fundidos en una sola zona sin que nadie lo pida.
+        assert_eq!(cuerpo.lista_secciones().len(), 3);
+        for id in &cuerpo.orden {
+            assert!(cuerpo.seccion_de(*id).is_some(), "átomo sin sección");
+        }
+    }
+
+    #[test]
+    fn los_atomos_llevan_su_hash_al_dia() {
+        let (s, c) = cuaderno();
+        let r = redactar(&s, c, 999).unwrap();
+        assert!(r.doc.atoms.iter().all(|a| a.hash_matches()));
+    }
+
+    #[test]
+    fn una_cuerda_vacia_no_abre_una_ventana_en_blanco() {
+        let mut s = NoteStore::new();
+        let c = s.crear_cuerda("nada todavía", 100);
+        assert_eq!(redactar(&s, c, 999).unwrap_err(), ErrorRedaccion::SinNudos);
+        assert_eq!(redactar(&s, 4242, 999).unwrap_err(), ErrorRedaccion::SinCuerda);
+    }
+
+    #[test]
+    fn el_titulo_no_se_repite_cuando_el_cuerpo_ya_lo_dice() {
+        let mut s = NoteStore::new();
+        let c = s.crear_cuerda("x", 0);
+        // El caso del nudo corto: khipu propone el título desde el contenido.
+        let a = s.create("comprar pan", "comprar pan", vec![], 10);
+        s.anudar(a, c);
+        let r = redactar(&s, c, 1).unwrap();
+        assert_eq!(r.doc.atoms[0].content.as_str(), "comprar pan");
+    }
+
+    #[test]
+    fn un_nudo_sin_titulo_o_sin_cuerpo_entra_igual() {
+        let mut s = NoteStore::new();
+        let c = s.crear_cuerda("x", 0);
+        let a = s.create("", "sólo cuerpo", vec![], 10);
+        let b = s.create("sólo título", "", vec![], 20);
+        s.anudar(a, c);
+        s.anudar(b, c);
+        let r = redactar(&s, c, 1).unwrap();
+        let textos: Vec<&str> = r.doc.atoms.iter().map(|a| a.content.as_str()).collect();
+        assert!(textos.contains(&"sólo cuerpo"));
+        assert!(textos.contains(&"sólo título"));
+        assert!(!textos.iter().any(|t| t.trim().is_empty()));
+    }
+
+    #[test]
+    fn un_tejido_que_apunta_a_un_nudo_borrado_no_rompe_la_redaccion() {
+        let (mut s, c) = cuaderno();
+        s.cuerda_mut(c).unwrap().tejido = Some(9999);
+        let r = redactar(&s, c, 1).expect("los nudos siguen ahí");
+        assert!(!r.con_tejido);
+        assert_eq!(r.nudos, 3);
+    }
+
+    #[test]
+    fn el_nombre_de_archivo_no_se_escapa_de_la_carpeta() {
+        assert_eq!(nombre_de_archivo("la idea que vuelve", 5), "la-idea-que-vuelve-5");
+        // Lo que no es `[a-z0-9]` se vuelve separador — la ó incluida. Feo,
+        // pero es un nombre de archivo, no el título: el título vive en el haz.
+        assert_eq!(nombre_de_archivo("facturación 2026/2027", 7), "facturaci-n-2026-2027-7");
+        assert_eq!(nombre_de_archivo("../../etc/passwd", 1), "etc-passwd-1");
+        assert_eq!(nombre_de_archivo("", 3), "cuerda-3");
+        assert_eq!(nombre_de_archivo("!!!", 3), "cuerda-3");
+        // Largo acotado y sin guion colgando.
+        let largo = nombre_de_archivo(&"palabra ".repeat(40), 2);
+        assert!(largo.len() <= 51, "{largo}");
+        assert!(!largo.contains("--"));
+    }
+}
diff --git a/Cargo.lock b/Cargo.lock
index 975569a..f32e55b 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -170,28 +170,28 @@ dependencies = [
 [[package]]
 name = "agora-core"
 version = "0.1.0"
-source = "git+https://git.tawasuyu.net/tawasuyu/tawasuyu.git?tag=v0.2.0#a446bb3a73a0e022158cc0ec7deb65173859644b"
+source = "git+https://git.tawasuyu.net/tawasuyu/tawasuyu.git?tag=v0.3.0#36a1eabf32eb77f901869f516a9be352f3d17d30"
 dependencies = [
  "blake3",
  "ed25519-dalek",
  "format",
  "serde",
- "thiserror 2.0.19",
+ "thiserror 2.0.20",
  "umbral",
 ]
 
 [[package]]
 name = "agora-keystore"
 version = "0.1.0"
-source = "git+https://git.tawasuyu.net/tawasuyu/tawasuyu.git?tag=v0.2.0#a446bb3a73a0e022158cc0ec7deb65173859644b"
+source = "git+https://git.tawasuyu.net/tawasuyu/tawasuyu.git?tag=v0.3.0#36a1eabf32eb77f901869f516a9be352f3d17d30"
 dependencies = [
  "aead",
  "agora-core",
  "argon2",
  "chacha20poly1305",
  "directories",
- "rand 0.8.7",
- "thiserror 2.0.19",
+ "rand 0.8.8",
+ "thiserror 2.0.20",
 ]
 
 [[package]]
@@ -209,9 +209,9 @@ dependencies = [
 
 [[package]]
 name = "aho-corasick"
-version = "1.1.4"
+version = "1.1.5"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301"
+checksum = "c982642fa9e8606056828ee9a8505737230110bb1099153c79efe865c59d12ba"
 dependencies = [
  "memchr",
 ]
@@ -238,7 +238,7 @@ dependencies = [
  "ndk-context",
  "ndk-sys",
  "num_enum",
- "thiserror 2.0.19",
+ "thiserror 2.0.20",
 ]
 
 [[package]]
@@ -247,15 +247,82 @@ version = "0.2.2"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "fc7eb209b1518d6bb87b283c20095f5228ecda460da70b44f0802523dea6da04"
 
+[[package]]
+name = "android_log-sys"
+version = "0.3.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "84521a3cf562bc62942e294181d9eef17eb38ceb8c68677bc49f144e4c3d4f8d"
+
+[[package]]
+name = "android_logger"
+version = "0.14.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "05b07e8e73d720a1f2e4b6014766e6039fd2e96a4fa44e2a78d0e1fa2ff49826"
+dependencies = [
+ "android_log-sys",
+ "env_filter",
+ "log",
+]
+
 [[package]]
 name = "android_system_properties"
-version = "0.1.5"
+version = "0.1.6"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311"
+checksum = "ae221649c9976a6f6c56ae1facf410f3ddb33cc661c4b7b61020a912d4237fbc"
 dependencies = [
  "libc",
 ]
 
+[[package]]
+name = "anstream"
+version = "1.0.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "824a212faf96e9acacdbd09febd34438f8f711fb84e09a8916013cd7815ca28d"
+dependencies = [
+ "anstyle",
+ "anstyle-parse",
+ "anstyle-query",
+ "anstyle-wincon",
+ "colorchoice",
+ "is_terminal_polyfill",
+ "utf8parse",
+]
+
+[[package]]
+name = "anstyle"
+version = "1.0.14"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000"
+
+[[package]]
+name = "anstyle-parse"
+version = "1.0.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "52ce7f38b242319f7cabaa6813055467063ecdc9d355bbb4ce0c68908cd8130e"
+dependencies = [
+ "utf8parse",
+]
+
+[[package]]
+name = "anstyle-query"
+version = "1.1.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc"
+dependencies = [
+ "windows-sys 0.61.2",
+]
+
+[[package]]
+name = "anstyle-wincon"
+version = "3.0.11"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d"
+dependencies = [
+ "anstyle",
+ "once_cell_polyfill",
+ "windows-sys 0.61.2",
+]
+
 [[package]]
 name = "anyhow"
 version = "1.0.104"
@@ -265,7 +332,7 @@ checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470"
 [[package]]
 name = "app-bus"
 version = "0.1.0"
-source = "git+https://git.tawasuyu.net/tawasuyu/tawasuyu.git?tag=v0.2.0#a446bb3a73a0e022158cc0ec7deb65173859644b"
+source = "git+https://git.tawasuyu.net/tawasuyu/tawasuyu.git?tag=v0.3.0#36a1eabf32eb77f901869f516a9be352f3d17d30"
 dependencies = [
  "directories",
  "serde",
@@ -344,7 +411,7 @@ dependencies = [
  "nom 7.1.3",
  "num-traits",
  "rusticata-macros",
- "thiserror 2.0.19",
+ "thiserror 2.0.20",
  "time",
 ]
 
@@ -493,13 +560,13 @@ checksum = "8b75356056920673b02621b35afd0f7dda9306d03c79a30f5c56c44cf256e3de"
 
 [[package]]
 name = "async-trait"
-version = "0.1.91"
+version = "0.1.92"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "ae36dc4177970ef04fde5178d3e2429882def40e57a451f919c098f72baa6cec"
+checksum = "82f6aeea286b8eb4dd3431a1be1b59d290ace00f5bfd8e2a159bc2a05e2c1667"
 dependencies = [
  "proc-macro2",
  "quote",
- "syn 3.0.3",
+ "syn 3.0.5",
 ]
 
 [[package]]
@@ -621,18 +688,18 @@ checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06"
 
 [[package]]
 name = "bit-set"
-version = "0.8.0"
+version = "0.9.1"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "08807e080ed7f9d5433fa9b275196cfc35414f66a0c79d864dc51a0d825231a3"
+checksum = "34ddef2995421ab6a5c779542c81ee77c115206f4ad9d5a8e05f4ff49716a3dd"
 dependencies = [
  "bit-vec",
 ]
 
 [[package]]
 name = "bit-vec"
-version = "0.8.0"
+version = "0.9.1"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "5e764a1d40d510daf35e07be9eb06e75770908c27d411ee6c92109c9840eaaf7"
+checksum = "b71798fca2c1fe1086445a7258a4bc81e6e49dcd24c8d0dd9a1e57395b603f51"
 
 [[package]]
 name = "bitflags"
@@ -657,24 +724,17 @@ dependencies = [
 
 [[package]]
 name = "blake3"
-version = "1.8.5"
+version = "1.8.7"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "0aa83c34e62843d924f905e0f5c866eb1dd6545fc4d719e803d9ba6030371fce"
+checksum = "6d9e454fc11f76977dc803893aff6304ed33d6a26efae8696573bea74baa27ae"
 dependencies = [
- "arrayref",
  "arrayvec",
  "cc",
  "cfg-if",
  "constant_time_eq",
- "cpufeatures 0.3.0",
+ "cpufeatures 0.3.1",
 ]
 
-[[package]]
-name = "block"
-version = "0.1.6"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "0d8c1fef690941d3e7788d328517591fecc684c084084702d6ff1641e993699a"
-
 [[package]]
 name = "block-buffer"
 version = "0.10.4"
@@ -693,11 +753,20 @@ dependencies = [
  "objc2 0.5.2",
 ]
 
+[[package]]
+name = "block2"
+version = "0.6.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "cdeb9d870516001442e364c5220d3574d2da8dc765554b4a617230d33fa58ef5"
+dependencies = [
+ "objc2 0.6.4",
+]
+
 [[package]]
 name = "blocking"
-version = "1.6.2"
+version = "1.7.0"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "e83f8d02be6967315521be875afa792a316e28d57b5a2d401897e2a7921b7f21"
+checksum = "a70e4329df6cb94385eed412ec92375c3cdd8a6e502493d1229b6414e4036dfa"
 dependencies = [
  "async-channel",
  "async-task",
@@ -706,6 +775,30 @@ dependencies = [
  "piper",
 ]
 
+[[package]]
+name = "borsh"
+version = "1.8.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "553c5d846a6ba5150c65e3b1b8ec073bcf1abc20f9b7220de384a4443ea4e20a"
+dependencies = [
+ "borsh-derive",
+ "bytes",
+ "cfg_aliases",
+]
+
+[[package]]
+name = "borsh-derive"
+version = "1.8.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "12cdfe656708a01f89b451a7d36466e6fe6c414de0aa18fc54f864f6f9ca9f56"
+dependencies = [
+ "once_cell",
+ "proc-macro-crate",
+ "proc-macro2",
+ "quote",
+ "syn 3.0.5",
+]
+
 [[package]]
 name = "bs58"
 version = "0.5.1"
@@ -732,13 +825,13 @@ dependencies = [
 
 [[package]]
 name = "bytemuck_derive"
-version = "1.11.0"
+version = "1.12.0"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "f65693059b6b9c588b9f62fed1cedbf0a8b805631457ea162d68f0de186f3de5"
+checksum = "fc0e56a716f1e132ff6bf4bdac1c944a3fcdc1cae65f70a4a2a1ac3b401d2d1f"
 dependencies = [
  "proc-macro2",
  "quote",
- "syn 2.0.119",
+ "syn 3.0.5",
 ]
 
 [[package]]
@@ -788,7 +881,7 @@ dependencies = [
 [[package]]
 name = "card-net"
 version = "0.1.0"
-source = "git+https://git.tawasuyu.net/tawasuyu/tawasuyu.git?tag=v0.2.0#a446bb3a73a0e022158cc0ec7deb65173859644b"
+source = "git+https://git.tawasuyu.net/tawasuyu/tawasuyu.git?tag=v0.3.0#36a1eabf32eb77f901869f516a9be352f3d17d30"
 dependencies = [
  "blake3",
  "futures",
@@ -796,15 +889,15 @@ dependencies = [
  "libp2p-allow-block-list",
  "libp2p-stream",
  "serde",
- "thiserror 2.0.19",
+ "thiserror 2.0.20",
  "tokio",
 ]
 
 [[package]]
 name = "cc"
-version = "1.4.0"
+version = "1.4.5"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "5add81bb678e6cb321aff7fa0dc7689ad82b112dbc032cea19f91d6b8e3582b9"
+checksum = "005ec2760ca554fae18df7a11195552ec576cd665632a881bc011d5bb2fd4d80"
 dependencies = [
  "find-msvc-tools",
  "jobserver",
@@ -837,12 +930,12 @@ dependencies = [
 
 [[package]]
 name = "chacha20"
-version = "0.10.1"
+version = "0.10.2"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "d524456ba66e72eb8b115ff89e01e497f8e6d11d78b70b1aa13c0fbd97540a81"
+checksum = "65c35e4b699c7e15ccbe7ee35c005e4fc0a278d22238a2857e6ce2dadeda1b06"
 dependencies = [
  "cfg-if",
- "cpufeatures 0.3.0",
+ "cpufeatures 0.3.1",
  "rand_core 0.10.1",
 ]
 
@@ -870,6 +963,46 @@ dependencies = [
  "zeroize",
 ]
 
+[[package]]
+name = "clap"
+version = "4.6.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "473c7e07f409a8d772161724aa8db6a765a2532a70f9667eeb7b49d3d02fbdca"
+dependencies = [
+ "clap_builder",
+ "clap_derive",
+]
+
+[[package]]
+name = "clap_builder"
+version = "4.6.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7b48fea5a88e9ae728a2dcbedbfc0e730f7d60da42e1cb049a83c9fb8b789889"
+dependencies = [
+ "anstream",
+ "anstyle",
+ "clap_lex",
+ "strsim",
+]
+
+[[package]]
+name = "clap_derive"
+version = "4.6.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d012d2b9d65aca7f18f4d9878a045bc17899bba951561ba5ec3c2ba1eed9a061"
+dependencies = [
+ "heck",
+ "proc-macro2",
+ "quote",
+ "syn 3.0.5",
+]
+
+[[package]]
+name = "clap_lex"
+version = "1.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9"
+
 [[package]]
 name = "clipboard-win"
 version = "5.4.1"
@@ -885,14 +1018,14 @@ version = "0.3.0"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "0fa961b519f0b462e3a3b4a34b64d119eeaca1d59af726fe450bbba07a9fc0a1"
 dependencies = [
- "thiserror 2.0.19",
+ "thiserror 2.0.20",
 ]
 
 [[package]]
 name = "codespan-reporting"
-version = "0.12.0"
+version = "0.13.1"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "fe6d2e5af09e8c8ad56c969f2157a3d4238cebc7c55f0a517728c38f7b200f81"
+checksum = "af491d569909a7e4dee0ad7db7f5341fef5c614d5b8ec8cf765732aba3cff681"
 dependencies = [
  "serde",
  "termcolor",
@@ -904,12 +1037,22 @@ name = "color"
 version = "0.3.3"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "2ec7c5eb7a16992b1904d76c517d170ab353b0e0b3d5a0c81a8a0cd1037893cf"
+dependencies = [
+ "bytemuck",
+ "libm",
+]
+
+[[package]]
+name = "colorchoice"
+version = "1.0.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570"
 
 [[package]]
 name = "combine"
-version = "4.6.7"
+version = "4.6.8"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "ba5a308b75df32fe02788e748662718f03fde005016435c444eea572398219fd"
+checksum = "cfc320937d09e6de266b31b9afb480f197d7a861be86be7cb2ea7e5d1bfffc5e"
 dependencies = [
  "bytes",
  "memchr",
@@ -952,16 +1095,6 @@ dependencies = [
  "libc",
 ]
 
-[[package]]
-name = "core-foundation"
-version = "0.10.1"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "b2a6cd9ae233e7f62ba4e9353e81a88df7fc8a5987b8d445b4d90c879bd156f6"
-dependencies = [
- "core-foundation-sys",
- "libc",
-]
-
 [[package]]
 name = "core-foundation-sys"
 version = "0.8.7"
@@ -975,9 +1108,9 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "c07782be35f9e1140080c6b96f0d44b739e2278479f64e02fdab4e32dfd8b081"
 dependencies = [
  "bitflags 1.3.2",
- "core-foundation 0.9.4",
- "core-graphics-types 0.1.3",
- "foreign-types 0.5.0",
+ "core-foundation",
+ "core-graphics-types",
+ "foreign-types",
  "libc",
 ]
 
@@ -988,18 +1121,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "45390e6114f68f718cc7a830514a96f903cccd70d02a8f6d9f643ac4ba45afaf"
 dependencies = [
  "bitflags 1.3.2",
- "core-foundation 0.9.4",
- "libc",
-]
-
-[[package]]
-name = "core-graphics-types"
-version = "0.2.0"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "3d44a101f213f6c4cdc1853d4b78aef6db6bdfa3468798cc1d9912f4735013eb"
-dependencies = [
- "bitflags 2.13.1",
- "core-foundation 0.10.1",
+ "core-foundation",
  "libc",
 ]
 
@@ -1023,22 +1145,31 @@ dependencies = [
 
 [[package]]
 name = "cpufeatures"
-version = "0.3.0"
+version = "0.3.1"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "8b2a41393f66f16b0823bb79094d54ac5fbd34ab292ddafb9a0456ac9f87d201"
+checksum = "5ca28b0ae3115b884660db4118d803791fd6756b6e88f39c0f3f7859060d7566"
 dependencies = [
  "libc",
 ]
 
 [[package]]
 name = "crc32fast"
-version = "1.5.0"
+version = "1.5.1"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "9481c1c90cbf2ac953f07c8d4a58aa3945c425b7185c9154d67a65e4230da511"
+checksum = "8498c871161e1742aaa9d52551b2d6ebdd4c3d45a3be423e3728f33b955be550"
 dependencies = [
  "cfg-if",
 ]
 
+[[package]]
+name = "crdt"
+version = "0.1.0"
+source = "git+https://git.tawasuyu.net/tawasuyu/tawasuyu.git?tag=v0.3.0#36a1eabf32eb77f901869f516a9be352f3d17d30"
+dependencies = [
+ "postcard",
+ "serde",
+]
+
 [[package]]
 name = "critical-section"
 version = "1.2.0"
@@ -1047,27 +1178,27 @@ checksum = "790eea4361631c5e7d22598ecd5723ff611904e3344ce8720784c93e3d83d40b"
 
 [[package]]
 name = "crossbeam-channel"
-version = "0.5.16"
+version = "0.5.17"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "d85363c37faeca707aef026efa9f3b34d077bce547e48f770770625c6013679e"
+checksum = "98b0cc327b5bc766e7fda9c9260cc0fa81b43a8e240440422dff70788e3f9ef1"
 dependencies = [
  "crossbeam-utils",
 ]
 
 [[package]]
 name = "crossbeam-epoch"
-version = "0.9.20"
+version = "0.9.21"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "2d6914041f254d6e9176c01941b21115dcfb7089e55135a35411081bd106ef3f"
+checksum = "dc74980687109a3b14c72fd458107bf0baa1da1a1a805e178d15501ba9b86d9d"
 dependencies = [
  "crossbeam-utils",
 ]
 
 [[package]]
 name = "crossbeam-utils"
-version = "0.8.22"
+version = "0.8.23"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "61803da095bee82a81bb1a452ecc25d3b2f1416d1897eb86430c6159ef717c17"
+checksum = "a31eee39dddec8330830986fcd7625edb5a24ec90ea038215273bbc3adb08ac6"
 
 [[package]]
 name = "crunchy"
@@ -1130,15 +1261,15 @@ dependencies = [
 
 [[package]]
 name = "data-encoding"
-version = "2.11.0"
+version = "2.11.1"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "a4ae5f15dda3c708c0ade84bfee31ccab44a3da4f88015ed22f63732abe300c8"
+checksum = "4583a4551df46e2792f82ceeac45e850d2e2d5debba0b91f102385cda5b11f06"
 
 [[package]]
 name = "data-encoding-macro"
-version = "0.1.20"
+version = "0.1.21"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "3259c913752a86488b501ed8680446a5ed2d5aeac6e596cb23ba3800768ea32c"
+checksum = "c6a127ecbb3c4632e1525380e04c0c3fcf8dcb44d32a79ea290d8a36906edcd8"
 dependencies = [
  "data-encoding",
  "data-encoding-macro-internal",
@@ -1146,14 +1277,19 @@ dependencies = [
 
 [[package]]
 name = "data-encoding-macro-internal"
-version = "0.1.18"
+version = "0.1.19"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "ccc2776f0c61eca1ca32528f85548abd1a4be8fb53d1b21c013e4f18da1e7090"
+checksum = "c54e03a951783e8b327515db3f2a2fd0e3bed362a96b066f341ce66ed49b4ead"
 dependencies = [
  "data-encoding",
- "syn 2.0.119",
+ "syn 3.0.5",
 ]
 
+[[package]]
+name = "dataflow"
+version = "0.1.0"
+source = "git+https://git.tawasuyu.net/tawasuyu/tawasuyu.git?tag=v0.3.0#36a1eabf32eb77f901869f516a9be352f3d17d30"
+
 [[package]]
 name = "der"
 version = "0.7.10"
@@ -1234,13 +1370,13 @@ dependencies = [
 
 [[package]]
 name = "displaydoc"
-version = "0.2.6"
+version = "0.2.7"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "1ac70aa55017e108007fbaf5aa0f54b021c98f92ff8af59d42eda9da96e3dd4f"
+checksum = "c6232dd377dcc64799954cbd3a9bb882e9cdc1308ccd87b1c098f1fb2eaf82a8"
 dependencies = [
  "proc-macro2",
  "quote",
- "syn 2.0.119",
+ "syn 3.0.5",
 ]
 
 [[package]]
@@ -1291,9 +1427,9 @@ dependencies = [
 
 [[package]]
 name = "ed25519-compact"
-version = "2.3.1"
+version = "2.4.2"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "5c24599140dc39d7a81e4476e7573d41bbc18e07c803900298e522a5fbcfbfb6"
+checksum = "f05391a505666bdf2b5d2626f41b7f0f49052b1e33cceac960eaa818008141da"
 
 [[package]]
 name = "ed25519-dalek"
@@ -1311,9 +1447,9 @@ dependencies = [
 
 [[package]]
 name = "either"
-version = "1.17.0"
+version = "1.18.0"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "9e5e8f6c15a24b9a3ee5efec809ccd006d3b30e8b3bb63c39af737c7f87daa1d"
+checksum = "252afb9ae5eaa683babdc6a068b3f5726eb19e05070c731f9b2a23a7c3e8ed34"
 
 [[package]]
 name = "embedded-io"
@@ -1375,6 +1511,16 @@ dependencies = [
  "syn 2.0.119",
 ]
 
+[[package]]
+name = "env_filter"
+version = "0.1.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1bf3c259d255ca70051b30e2e95b5446cdb8949ac4cd22c0d7fd634d89f568e2"
+dependencies = [
+ "log",
+ "regex",
+]
+
 [[package]]
 name = "equivalent"
 version = "1.0.2"
@@ -1393,9 +1539,9 @@ dependencies = [
 
 [[package]]
 name = "error-code"
-version = "3.3.2"
+version = "3.4.0"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "dea2df4cf52843e0452895c455a1a2cfbb842a1e7329671acf418fdc53ed4c59"
+checksum = "0b5343afd4a8365a643ac588dab4cf234a190c7f6c88c9f6dd6ffe00837661b7"
 
 [[package]]
 name = "euclid"
@@ -1408,11 +1554,10 @@ dependencies = [
 
 [[package]]
 name = "event-listener"
-version = "5.4.1"
+version = "5.4.2"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "e13b66accf52311f30a0db42147dadea9850cb48cd070028831ae5f5d4b856ab"
+checksum = "5a23add41df1562121a9393cb065eab5146a1242410f23a644851e90cfd669d2"
 dependencies = [
- "concurrent-queue",
  "parking",
  "pin-project-lite",
 ]
@@ -1460,17 +1605,33 @@ dependencies = [
  "simd-adler32",
 ]
 
+[[package]]
+name = "fearless_simd"
+version = "0.4.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b97b65636e5b9ef369943878ac74335ba1c55c1cb6adbf1e2c293c624248d693"
+
 [[package]]
 name = "fiat-crypto"
 version = "0.2.9"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "28dea519a9695b9977216879a3ebfddf92f1c08c05d984f8996aecd6ecdc811d"
 
+[[package]]
+name = "filetime"
+version = "0.2.29"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5c287a33c7f0a620c38e641e7f60827713987b3c0f26e8ddc9462cc69cf75759"
+dependencies = [
+ "cfg-if",
+ "libc",
+]
+
 [[package]]
 name = "find-msvc-tools"
-version = "0.1.9"
+version = "0.1.12"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582"
+checksum = "3e0f1c7c3a72c66fd80abe965175f7523475c0489a87d3ff9d6e8c87d87a9d2d"
 
 [[package]]
 name = "fixedbitset"
@@ -1480,12 +1641,13 @@ checksum = "1d674e81391d1e1ab681a28d99df07927c6d4aa5b027d7da16ba32d1d21ecd99"
 
 [[package]]
 name = "flate2"
-version = "1.1.9"
+version = "1.1.10"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "843fba2746e448b37e26a819579957415c8cef339bf08564fe8b7ddbd959573c"
+checksum = "6e634e2e0ebac1ee034020da1ca582e17ffe4e0f5e985823721e168928136dcb"
 dependencies = [
  "crc32fast",
- "miniz_oxide",
+ "miniz_oxide 0.9.1",
+ "zlib-rs",
 ]
 
 [[package]]
@@ -1526,9 +1688,9 @@ dependencies = [
 
 [[package]]
 name = "font-types"
-version = "0.12.2"
+version = "0.12.4"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "0a7299a780854a6d391be2ae1c8521c9368471b559dbfd6a8dbd9f407eaff100"
+checksum = "e64eb721ca85a34323425f4041adc5d82704d3782d5f8f03793bc012419dce23"
 dependencies = [
  "bytemuck",
 ]
@@ -1556,15 +1718,6 @@ dependencies = [
  "yeslogic-fontconfig-sys",
 ]
 
-[[package]]
-name = "foreign-types"
-version = "0.3.2"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "f6f339eb8adc052cd2ca78910fda869aefa38d22d5cb648e6485e4d3fc06f3b1"
-dependencies = [
- "foreign-types-shared 0.1.1",
-]
-
 [[package]]
 name = "foreign-types"
 version = "0.5.0"
@@ -1572,7 +1725,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "d737d9aa519fb7b749cbc3b962edcf310a8dd1f4b67c91c4f83975dbdd17d965"
 dependencies = [
  "foreign-types-macros",
- "foreign-types-shared 0.3.1",
+ "foreign-types-shared",
 ]
 
 [[package]]
@@ -1583,15 +1736,9 @@ checksum = "ea5190182e6915eb873ddbc16e23b711b6eb1f9c00a0d0a3a91b5f6228475225"
 dependencies = [
  "proc-macro2",
  "quote",
- "syn 3.0.3",
+ "syn 3.0.5",
 ]
 
-[[package]]
-name = "foreign-types-shared"
-version = "0.1.1"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "00b0228411908ca8685dba7fc2cdd70ec9990a6e753e89b6ac91a84c40fbaf4b"
-
 [[package]]
 name = "foreign-types-shared"
 version = "0.3.1"
@@ -1601,7 +1748,7 @@ checksum = "aa9a19cbb55df58761df49b23516a86d432839add4af60fc256da840f66ed35b"
 [[package]]
 name = "fork-proof"
 version = "0.1.0"
-source = "git+https://git.tawasuyu.net/tawasuyu/tawasuyu.git?tag=v0.2.0#a446bb3a73a0e022158cc0ec7deb65173859644b"
+source = "git+https://git.tawasuyu.net/tawasuyu/tawasuyu.git?tag=v0.3.0#36a1eabf32eb77f901869f516a9be352f3d17d30"
 dependencies = [
  "ed25519-compact",
  "format",
@@ -1619,7 +1766,7 @@ dependencies = [
 [[package]]
 name = "format"
 version = "0.1.0"
-source = "git+https://git.tawasuyu.net/tawasuyu/tawasuyu.git?tag=v0.2.0#a446bb3a73a0e022158cc0ec7deb65173859644b"
+source = "git+https://git.tawasuyu.net/tawasuyu/tawasuyu.git?tag=v0.3.0#36a1eabf32eb77f901869f516a9be352f3d17d30"
 dependencies = [
  "blake3",
  "postcard",
@@ -1627,11 +1774,20 @@ dependencies = [
  "serde-big-array",
 ]
 
+[[package]]
+name = "fsevent-sys"
+version = "4.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "76ee7a02da4d231650c7cea31349b889be2f45ddb3ef3032d2ec8185f6313fd2"
+dependencies = [
+ "libc",
+]
+
 [[package]]
 name = "futures"
-version = "0.3.33"
+version = "0.3.34"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "a88cf1f829d945f548cf8fec32c61b1f202b6d93b45848602fc02af4b12ad218"
+checksum = "9a31d2a3fbaaeb2af2368bbdd904aa8e812d3c04a1ee10d3171f52d556e5d0a3"
 dependencies = [
  "futures-channel",
  "futures-core",
@@ -1654,9 +1810,9 @@ dependencies = [
 
 [[package]]
 name = "futures-channel"
-version = "0.3.33"
+version = "0.3.34"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "262590f4fe6afeb0bc83be1daa64e52657fe185690a958af7f3ad0e92085c5ae"
+checksum = "b1f9e3d69d39e4862ffed03ed071a76f9a13ba1d9109d355b0f0aa6b15e393c4"
 dependencies = [
  "futures-core",
  "futures-sink",
@@ -1664,15 +1820,15 @@ dependencies = [
 
 [[package]]
 name = "futures-core"
-version = "0.3.33"
+version = "0.3.34"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "2cd50c473c80f6d7c3670a752354b8e569b1a7cbfdc0419ec88e5edad85e0dc7"
+checksum = "92d699e522242e69e3003b94ecc1f960f3a5e015aa7c5d7486e65ad01dd94f5e"
 
 [[package]]
 name = "futures-executor"
-version = "0.3.33"
+version = "0.3.34"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "6754879cc9f2c66f88c6e5c35344bb0bdb0708b0352b1201815667c7eabc7458"
+checksum = "031b47cf1a3c6cc8bc2fc76cd437f521619387907d469316e7c0bc278f1f5432"
 dependencies = [
  "futures-core",
  "futures-task",
@@ -1692,9 +1848,9 @@ dependencies = [
 
 [[package]]
 name = "futures-io"
-version = "0.3.33"
+version = "0.3.34"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "4577ecaa3c4f96589d473f679a71b596316f6641bc350038b962a5daf0085d7a"
+checksum = "53c0fa8157de1303bfffdaa1cc2a673bfffb60102f76b0ef4441659124373fed"
 
 [[package]]
 name = "futures-lite"
@@ -1711,13 +1867,13 @@ dependencies = [
 
 [[package]]
 name = "futures-macro"
-version = "0.3.33"
+version = "0.3.34"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "2d6d3cde68c518367be28956066ddfef33813991b77a55005a69dae04bf3b10b"
+checksum = "9fb9654ba8355388abeb8dcb4fc62f511300867002afc858860463bdd9fe0c44"
 dependencies = [
  "proc-macro2",
  "quote",
- "syn 2.0.119",
+ "syn 3.0.5",
 ]
 
 [[package]]
@@ -1733,15 +1889,15 @@ dependencies = [
 
 [[package]]
 name = "futures-sink"
-version = "0.3.33"
+version = "0.3.34"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "e34418ac499d6305c2fb5ad0ed2f6ac998c5f8ca209b4510f7f94242c647e307"
+checksum = "1944426bf7d03f1d14f708785e4b33efd750b36d48a157b836b3efc15ede8e1d"
 
 [[package]]
 name = "futures-task"
-version = "0.3.33"
+version = "0.3.34"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "b231ed28831efb4a61a08580c4bc233ec56bc009f4cd8f52da2c3cb97df0c109"
+checksum = "cd417de3d1d015fc3bfd2b1ea46dfc7bab72ef86f1cc7cc9c78e728b34a6d1fd"
 
 [[package]]
 name = "futures-timer"
@@ -1751,9 +1907,9 @@ checksum = "af43fadb8a98512d547e37b4e92e0ced13e205c061b87b4623eff01d918d6968"
 
 [[package]]
 name = "futures-util"
-version = "0.3.33"
+version = "0.3.34"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "a77a90a256fce34da66415271e30f94ee91c57b04b8a2c042d9cf3220179deaa"
+checksum = "0d50a92467f8ba5dd6e3ee5d4bd04d73ab2e4e1c44474a0674821dfce14b79bc"
 dependencies = [
  "futures-channel",
  "futures-core",
@@ -1846,11 +2002,27 @@ dependencies = [
  "xml-rs",
 ]
 
+[[package]]
+name = "glifo"
+version = "0.1.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d99fc21d493812643aae86d53b7bbd02f376434a90317e8a790bc209fdd6605e"
+dependencies = [
+ "bytemuck",
+ "foldhash 0.2.0",
+ "hashbrown 0.17.1",
+ "log",
+ "peniko",
+ "skrifa 0.42.1",
+ "smallvec",
+ "vello_common",
+]
+
 [[package]]
 name = "glow"
-version = "0.16.0"
+version = "0.17.0"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "c5e5ea60d70410161c8bf5da3fdfeaa1c72ed2c15f8bbb9d19fe3a4fad085f08"
+checksum = "29038e1c483364cc6bb3cf78feee1816002e127c331a1eec55a4d202b9e1adb5"
 dependencies = [
  "js-sys",
  "slotmap",
@@ -1867,35 +2039,18 @@ dependencies = [
  "gl_generator",
 ]
 
-[[package]]
-name = "gpu-alloc"
-version = "0.6.2"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "45cf04b2726f02df5508c6de726acdc90cdf97ac771a9a0ffd8ba10a6e696bf9"
-dependencies = [
- "bitflags 2.13.1",
- "gpu-alloc-types",
-]
-
-[[package]]
-name = "gpu-alloc-types"
-version = "0.3.1"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "b2bbed164dd10ed526c2e4fe3e721ca4a71c61730e5aafac6844b417b3227058"
-dependencies = [
- "bitflags 2.13.1",
-]
-
 [[package]]
 name = "gpu-allocator"
-version = "0.27.0"
+version = "0.28.0"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "c151a2a5ef800297b4e79efa4f4bec035c5f51d5ae587287c9b952bdf734cacd"
+checksum = "51255ea7cfaadb6c5f1528d43e92a82acb2b96c43365989a28b2d44ee38f8795"
 dependencies = [
+ "ash",
+ "hashbrown 0.16.1",
  "log",
  "presser",
- "thiserror 1.0.69",
- "windows 0.58.0",
+ "thiserror 2.0.20",
+ "windows 0.62.2",
 ]
 
 [[package]]
@@ -1918,6 +2073,11 @@ dependencies = [
  "bitflags 2.13.1",
 ]
 
+[[package]]
+name = "grafo-nav"
+version = "0.1.0"
+source = "git+https://git.tawasuyu.net/tawasuyu/tawasuyu.git?tag=v0.3.0#36a1eabf32eb77f901869f516a9be352f3d17d30"
+
 [[package]]
 name = "grid"
 version = "1.0.1"
@@ -1926,9 +2086,9 @@ checksum = "b40ca9252762c466af32d0b1002e91e4e1bc5398f77455e55474deb466355ff5"
 
 [[package]]
 name = "guillotiere"
-version = "0.6.2"
+version = "0.7.0"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "b62d5865c036cb1393e23c50693df631d3f5d7bcca4c04fe4cc0fd592e74a782"
+checksum = "6b17e70c989c36bad147b27a58d148c0741c51448aa5653436547323e524d0ab"
 dependencies = [
  "euclid",
  "svg_fmt",
@@ -1936,9 +2096,9 @@ dependencies = [
 
 [[package]]
 name = "h2"
-version = "0.4.15"
+version = "0.4.19"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "6cb093c84e8bd9b188d4c4a8cb6579fc016968d14c99882163cd3ff402a4f155"
+checksum = "ef8e5e5a340588f4452631496976cf8636d4a7ecf600239fdc27615d2530bc16"
 dependencies = [
  "atomic-waker",
  "bytes",
@@ -2004,6 +2164,8 @@ version = "0.16.1"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100"
 dependencies = [
+ "allocator-api2",
+ "equivalent",
  "foldhash 0.2.0",
 ]
 
@@ -2012,6 +2174,9 @@ name = "hashbrown"
 version = "0.17.1"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a"
+dependencies = [
+ "foldhash 0.2.0",
+]
 
 [[package]]
 name = "hashlink"
@@ -2044,9 +2209,9 @@ checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea"
 
 [[package]]
 name = "hermit-abi"
-version = "0.5.2"
+version = "0.5.3"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "fc0fef456e4baa96da950455cd02c081ca953b141298e41db3fc7e36b1da849c"
+checksum = "e17592d60ebacc7d5e169f4663c5f84f9161cc90328abcfe8456f41e4dfcb284"
 
 [[package]]
 name = "hex"
@@ -2079,7 +2244,7 @@ dependencies = [
  "rand 0.9.5",
  "ring",
  "socket2 0.5.10",
- "thiserror 2.0.19",
+ "thiserror 2.0.20",
  "tinyvec",
  "tokio",
  "tracing",
@@ -2102,7 +2267,7 @@ dependencies = [
  "rand 0.9.5",
  "resolv-conf",
  "smallvec",
- "thiserror 2.0.19",
+ "thiserror 2.0.20",
  "tokio",
  "tracing",
 ]
@@ -2127,9 +2292,9 @@ dependencies = [
 
 [[package]]
 name = "http"
-version = "1.4.2"
+version = "1.5.0"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "6970f50e31d6fc17d3fa27329444bfa74e196cf62e95052a3f6fee181dba6425"
+checksum = "918d3568bebf352712bc2ef3d46a8bcf1a75b373be6539de198e9105cbbf9ce0"
 dependencies = [
  "bytes",
  "itoa",
@@ -2147,9 +2312,9 @@ dependencies = [
 
 [[package]]
 name = "http-body-util"
-version = "0.1.4"
+version = "0.1.5"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "e9f41fd6a08e4d4ec69df65976da761afd5ad5e58a9d4acb46bd1c953a9e3ff2"
+checksum = "23169fe34a5fbcdd3f3862e78fb9b6fccd5f02a6dc6f732547005d45631ce71c"
 dependencies = [
  "bytes",
  "futures-core",
@@ -2166,9 +2331,9 @@ checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87"
 
 [[package]]
 name = "hyper"
-version = "1.11.0"
+version = "1.11.1"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "d22053281f852e11534f5198498373cbb59295120a20771d90f7ed1897490a72"
+checksum = "27b501faa50e7a26c3d3560ca625132f4078a17771f4810baf70475ae48cbe43"
 dependencies = [
  "atomic-waker",
  "bytes",
@@ -2201,22 +2366,6 @@ dependencies = [
  "webpki-roots",
 ]
 
-[[package]]
-name = "hyper-tls"
-version = "0.6.0"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "70206fc6890eaca9fde8a0bf71caa2ddfc9fe045ac9e5c70df101a7dbde866e0"
-dependencies = [
- "bytes",
- "http-body-util",
- "hyper",
- "hyper-util",
- "native-tls",
- "tokio",
- "tokio-native-tls",
- "tower-service",
-]
-
 [[package]]
 name = "hyper-util"
 version = "0.1.20"
@@ -2242,11 +2391,16 @@ dependencies = [
  "windows-registry",
 ]
 
+[[package]]
+name = "ia-latido"
+version = "0.1.0"
+source = "git+https://git.tawasuyu.net/tawasuyu/tawasuyu.git?tag=v0.3.0#36a1eabf32eb77f901869f516a9be352f3d17d30"
+
 [[package]]
 name = "icu_collections"
-version = "2.2.0"
+version = "2.3.0"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "2984d1cd16c883d7935b9e07e44071dca8d917fd52ecc02c04d5fa0b5a3f191c"
+checksum = "fa68d21081c4a05d5a901a1c62add574c77048b6a1c67be3b50ce0b60d4ca513"
 dependencies = [
  "displaydoc",
  "potential_utf",
@@ -2258,9 +2412,9 @@ dependencies = [
 
 [[package]]
 name = "icu_locale_core"
-version = "2.2.0"
+version = "2.3.0"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "92219b62b3e2b4d88ac5119f8904c10f8f61bf7e95b640d25ba3075e6cac2c29"
+checksum = "d56e28588da92eee5c3201a6eff33fabdd49b62269c8938d4ff050ce4d900deb"
 dependencies = [
  "displaydoc",
  "litemap",
@@ -2272,9 +2426,9 @@ dependencies = [
 
 [[package]]
 name = "icu_normalizer"
-version = "2.2.0"
+version = "2.3.0"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "c56e5ee99d6e3d33bd91c5d85458b6005a22140021cc324cea84dd0e72cff3b4"
+checksum = "12f9cf5f235641ed274641dd81c3f28d870e276763d0797aeeab72317b1c646f"
 dependencies = [
  "icu_collections",
  "icu_normalizer_data",
@@ -2286,16 +2440,17 @@ dependencies = [
 
 [[package]]
 name = "icu_normalizer_data"
-version = "2.2.0"
+version = "2.3.0"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "da3be0ae77ea334f4da67c12f149704f19f81d1adf7c51cf482943e84a2bad38"
+checksum = "1563da1ed3e0b3bf3d74c9b85917ac9c56464d2f57242270c09c9e752f8021a0"
 
 [[package]]
 name = "icu_properties"
-version = "2.2.0"
+version = "2.3.0"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "bee3b67d0ea5c2cca5003417989af8996f8604e34fb9ddf96208a033901e70de"
+checksum = "7e7ca276ad3145661a65914e6daf131ca5120cd3dcee8f8f3214b8875184a148"
 dependencies = [
+ "displaydoc",
  "icu_collections",
  "icu_locale_core",
  "icu_properties_data",
@@ -2306,15 +2461,15 @@ dependencies = [
 
 [[package]]
 name = "icu_properties_data"
-version = "2.2.0"
+version = "2.3.0"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "8e2bbb201e0c04f7b4b3e14382af113e17ba4f63e2c9d2ee626b720cbce54a14"
+checksum = "e590f038c1464a96894fd6d10127e90a8be4509f56ff7ecef851b15cee0b7caa"
 
 [[package]]
 name = "icu_provider"
-version = "2.2.0"
+version = "2.3.1"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "139c4cf31c8b5f33d7e199446eff9c1e02decfc2f0eec2c8d71f65befa45b421"
+checksum = "d27bbb9d3abbefac45d55f647c9de1d44aafcd1186eb91879afef17c396c3e73"
 dependencies = [
  "displaydoc",
  "icu_locale_core",
@@ -2363,7 +2518,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "71c02a5161c313f0cbdbadc511611893584a10a7b6153cb554bdf83ddce99ec2"
 dependencies = [
  "async-io",
- "core-foundation 0.9.4",
+ "core-foundation",
  "fnv",
  "futures",
  "if-addrs",
@@ -2410,20 +2565,40 @@ dependencies = [
  "byteorder-lite",
  "moxcms",
  "num-traits",
- "png 0.18.1",
+ "png",
  "tiff",
 ]
 
 [[package]]
 name = "indexmap"
-version = "2.14.0"
+version = "2.14.2"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9"
+checksum = "cc4e190f5d26ca7051642629da2c52fc03bde85a03197c99408dcd291734c855"
 dependencies = [
  "equivalent",
  "hashbrown 0.17.1",
 ]
 
+[[package]]
+name = "inotify"
+version = "0.9.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f8069d3ec154eb856955c1c0fbffefbf5f3c40a104ec912d4797314c1801abff"
+dependencies = [
+ "bitflags 1.3.2",
+ "inotify-sys",
+ "libc",
+]
+
+[[package]]
+name = "inotify-sys"
+version = "0.1.8"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c033f80b2c113cdf91ab7a33faa9cbc014726dcad99880c8609af2a370edf37d"
+dependencies = [
+ "libc",
+]
+
 [[package]]
 name = "inout"
 version = "0.1.4"
@@ -2448,9 +2623,15 @@ dependencies = [
 
 [[package]]
 name = "ipnet"
-version = "2.12.0"
+version = "2.12.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "791930b43c0d5973160d90a8f3894509f2b273430f5c5c73b668636d0287c5c0"
+
+[[package]]
+name = "is_terminal_polyfill"
+version = "1.70.2"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "d98f6fed1fde3f8c21bc40a1abb88dd75e67924f9cffc3ef95607bad8017f8e2"
+checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695"
 
 [[package]]
 name = "itertools"
@@ -2479,7 +2660,7 @@ dependencies = [
  "jni-sys 0.4.1",
  "log",
  "simd_cesu8",
- "thiserror 2.0.19",
+ "thiserror 2.0.20",
  "walkdir",
  "windows-link",
 ]
@@ -2537,15 +2718,27 @@ dependencies = [
 
 [[package]]
 name = "js-sys"
-version = "0.3.103"
+version = "0.3.105"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "53b44bfcdb3f8d5837a46dae1ca9660a837176eee74a28b229bc626816589102"
+checksum = "ce57d20d1ea864ce2ac172ab472d409214f4fd359f0b2a2775abdf522e2af99e"
 dependencies = [
  "cfg-if",
  "futures-util",
  "wasm-bindgen",
 ]
 
+[[package]]
+name = "khipu-android"
+version = "0.1.0"
+dependencies = [
+ "android-activity",
+ "android_logger",
+ "khipu-app",
+ "llimphi-ui",
+ "log",
+ "winit",
+]
+
 [[package]]
 name = "khipu-app"
 version = "0.1.0"
@@ -2557,6 +2750,7 @@ dependencies = [
  "khipu-brahman",
  "khipu-core",
  "khipu-gravity",
+ "khipu-redaccion",
  "khipu-share",
  "llimphi-clipboard",
  "llimphi-motion",
@@ -2568,7 +2762,12 @@ dependencies = [
  "llimphi-widget-menubar",
  "llimphi-widget-text-editor",
  "llimphi-widget-text-input",
+ "pizarra-core",
+ "pizarra-llimphi",
  "pluma-llm",
+ "pluma-proyecto",
+ "png",
+ "pollster",
  "postcard",
  "rimay-verbo",
  "serde",
@@ -2586,7 +2785,7 @@ dependencies = [
  "futures",
  "khipu-share",
  "libp2p",
- "thiserror 2.0.19",
+ "thiserror 2.0.20",
  "tokio",
  "tokio-util",
 ]
@@ -2595,6 +2794,7 @@ dependencies = [
 name = "khipu-core"
 version = "0.1.0"
 dependencies = [
+ "pizarra-core",
  "serde",
 ]
 
@@ -2609,6 +2809,17 @@ dependencies = [
  "serde",
 ]
 
+[[package]]
+name = "khipu-redaccion"
+version = "0.1.0"
+dependencies = [
+ "khipu-core",
+ "pluma-core",
+ "pluma-cuerpo",
+ "pluma-proyecto",
+ "uuid",
+]
+
 [[package]]
 name = "khipu-share"
 version = "0.1.0"
@@ -2618,10 +2829,10 @@ dependencies = [
  "blake3",
  "khipu-core",
  "postcard",
- "rand 0.8.7",
+ "rand 0.8.8",
  "serde",
  "serde-big-array",
- "thiserror 2.0.19",
+ "thiserror 2.0.20",
 ]
 
 [[package]]
@@ -2641,6 +2852,36 @@ version = "3.1.0"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "e2db585e1d738fc771bf08a151420d3ed193d9d895a36df7f6f8a9456b911ddc"
 
+[[package]]
+name = "kqueue"
+version = "1.2.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8d763e5b24120b4ddf50de6c92308156765aabfbbccebf401da7cff2d70a41ea"
+dependencies = [
+ "kqueue-sys",
+ "libc",
+]
+
+[[package]]
+name = "kqueue-sys"
+version = "1.1.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "07293a4e297ac234359b510362495713f75ea345d5307140414f20c69ffeb087"
+dependencies = [
+ "bitflags 2.13.1",
+ "libc",
+]
+
+[[package]]
+name = "krama-expr"
+version = "0.1.0"
+source = "git+https://git.tawasuyu.net/tawasuyu/tawasuyu.git?tag=v0.3.0#36a1eabf32eb77f901869f516a9be352f3d17d30"
+dependencies = [
+ "libm",
+ "rust_decimal",
+ "serde",
+]
+
 [[package]]
 name = "kurbo"
 version = "0.13.1"
@@ -2649,6 +2890,7 @@ checksum = "4b60dfc32f652b926df6192e55525b16d186c69d47876c3ead4da5cc9f8450e2"
 dependencies = [
  "arrayvec",
  "euclid",
+ "libm",
  "polycool",
  "smallvec",
 ]
@@ -2713,7 +2955,7 @@ dependencies = [
  "multiaddr",
  "pin-project",
  "rw-stream-sink",
- "thiserror 2.0.19",
+ "thiserror 2.0.20",
 ]
 
 [[package]]
@@ -2745,9 +2987,9 @@ dependencies = [
  "libp2p-swarm",
  "quick-protobuf",
  "quick-protobuf-codec",
- "rand 0.8.7",
+ "rand 0.8.8",
  "rand_core 0.6.4",
- "thiserror 2.0.19",
+ "thiserror 2.0.20",
  "tracing",
  "web-time",
 ]
@@ -2780,9 +3022,9 @@ dependencies = [
  "parking_lot",
  "pin-project",
  "quick-protobuf",
- "rand 0.8.7",
+ "rand 0.8.8",
  "rw-stream-sink",
- "thiserror 2.0.19",
+ "thiserror 2.0.20",
  "tracing",
  "unsigned-varint 0.8.0",
  "web-time",
@@ -2805,7 +3047,7 @@ dependencies = [
  "libp2p-swarm",
  "quick-protobuf",
  "quick-protobuf-codec",
- "thiserror 2.0.19",
+ "thiserror 2.0.20",
  "tracing",
  "web-time",
 ]
@@ -2843,7 +3085,7 @@ dependencies = [
  "quick-protobuf",
  "quick-protobuf-codec",
  "smallvec",
- "thiserror 2.0.19",
+ "thiserror 2.0.20",
  "tracing",
 ]
 
@@ -2858,9 +3100,9 @@ dependencies = [
  "hkdf",
  "multihash",
  "prost",
- "rand 0.8.7",
+ "rand 0.8.8",
  "sha2",
- "thiserror 2.0.19",
+ "thiserror 2.0.20",
  "tracing",
  "zeroize",
 ]
@@ -2883,10 +3125,10 @@ dependencies = [
  "libp2p-swarm",
  "quick-protobuf",
  "quick-protobuf-codec",
- "rand 0.8.7",
+ "rand 0.8.8",
  "sha2",
  "smallvec",
- "thiserror 2.0.19",
+ "thiserror 2.0.20",
  "tracing",
  "uint",
  "web-time",
@@ -2904,7 +3146,7 @@ dependencies = [
  "libp2p-core",
  "libp2p-identity",
  "libp2p-swarm",
- "rand 0.8.7",
+ "rand 0.8.8",
  "smallvec",
  "socket2 0.5.10",
  "tokio",
@@ -2944,10 +3186,10 @@ dependencies = [
  "multiaddr",
  "multihash",
  "quick-protobuf",
- "rand 0.8.7",
+ "rand 0.8.8",
  "snow",
  "static_assertions",
- "thiserror 2.0.19",
+ "thiserror 2.0.20",
  "tracing",
  "x25519-dalek",
  "zeroize",
@@ -2967,11 +3209,11 @@ dependencies = [
  "libp2p-tls",
  "quinn",
  "quinn-proto",
- "rand 0.8.7",
+ "rand 0.8.8",
  "ring",
  "rustls",
  "socket2 0.5.10",
- "thiserror 2.0.19",
+ "thiserror 2.0.20",
  "tokio",
  "tracing",
 ]
@@ -2993,9 +3235,9 @@ dependencies = [
  "libp2p-swarm",
  "quick-protobuf",
  "quick-protobuf-codec",
- "rand 0.8.7",
+ "rand 0.8.8",
  "static_assertions",
- "thiserror 2.0.19",
+ "thiserror 2.0.20",
  "tracing",
  "web-time",
 ]
@@ -3012,7 +3254,7 @@ dependencies = [
  "libp2p-core",
  "libp2p-identity",
  "libp2p-swarm",
- "rand 0.8.7",
+ "rand 0.8.8",
  "smallvec",
  "tracing",
 ]
@@ -3027,7 +3269,7 @@ dependencies = [
  "libp2p-core",
  "libp2p-identity",
  "libp2p-swarm",
- "rand 0.8.7",
+ "rand 0.8.8",
  "tracing",
 ]
 
@@ -3046,7 +3288,7 @@ dependencies = [
  "libp2p-identity",
  "libp2p-swarm-derive",
  "multistream-select",
- "rand 0.8.7",
+ "rand 0.8.8",
  "smallvec",
  "tokio",
  "tracing",
@@ -3094,7 +3336,7 @@ dependencies = [
  "ring",
  "rustls",
  "rustls-webpki",
- "thiserror 2.0.19",
+ "thiserror 2.0.20",
  "x509-parser",
  "yasna",
 ]
@@ -3123,7 +3365,7 @@ dependencies = [
  "either",
  "futures",
  "libp2p-core",
- "thiserror 2.0.19",
+ "thiserror 2.0.20",
  "tracing",
  "yamux 0.12.1",
  "yamux 0.13.10",
@@ -3131,14 +3373,14 @@ dependencies = [
 
 [[package]]
 name = "libredox"
-version = "0.1.18"
+version = "0.1.23"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "c943259e342f1e06ff2da7a83eabdfe7f92ce10262688dbf1895ff0b3e6e4652"
+checksum = "8d8f1ea3f21fd3405dcaf6c9b5c1630af9afc422d9073ea39c5f6d6c772e08ed"
 dependencies = [
  "bitflags 2.13.1",
  "libc",
  "plain",
- "redox_syscall 0.9.0",
+ "redox_syscall 0.9.4",
 ]
 
 [[package]]
@@ -3161,9 +3403,9 @@ checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53"
 
 [[package]]
 name = "litemap"
-version = "0.8.2"
+version = "0.8.3"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "92daf443525c4cce67b150400bc2316076100ce0b3686209eb8cf3c31612e6f0"
+checksum = "47d9d19d1d6efa0109d2f65ff4c85cddd50bd572e5a00127ab10987290bcefae"
 
 [[package]]
 name = "litrs"
@@ -3174,7 +3416,7 @@ checksum = "11d3d7f243d5c5a8b9bb5d6dd2b1602c0cb0b9db1621bafc7ed66e35ff9fe092"
 [[package]]
 name = "llimphi-clipboard"
 version = "0.1.0"
-source = "git+https://git.tawasuyu.net/tawasuyu/tawasuyu.git?tag=v0.2.0#a446bb3a73a0e022158cc0ec7deb65173859644b"
+source = "git+https://git.tawasuyu.net/tawasuyu/tawasuyu.git?tag=v0.3.0#36a1eabf32eb77f901869f516a9be352f3d17d30"
 dependencies = [
  "arboard",
  "llimphi-widget-text-editor",
@@ -3183,21 +3425,46 @@ dependencies = [
 [[package]]
 name = "llimphi-compositor"
 version = "0.1.0"
-source = "git+https://git.tawasuyu.net/tawasuyu/tawasuyu.git?tag=v0.2.0#a446bb3a73a0e022158cc0ec7deb65173859644b"
+source = "git+https://git.tawasuyu.net/tawasuyu/tawasuyu.git?tag=v0.3.0#36a1eabf32eb77f901869f516a9be352f3d17d30"
 dependencies = [
+ "foldhash 0.2.0",
+ "hashbrown 0.17.1",
+ "llimphi-entrada",
  "llimphi-layout",
  "llimphi-text",
  "vello",
+ "web-time",
  "wgpu",
 ]
 
+[[package]]
+name = "llimphi-cpu"
+version = "0.1.0"
+source = "git+https://git.tawasuyu.net/tawasuyu/tawasuyu.git?tag=v0.3.0#36a1eabf32eb77f901869f516a9be352f3d17d30"
+dependencies = [
+ "fearless_simd",
+ "vello_cpu",
+]
+
+[[package]]
+name = "llimphi-despacho"
+version = "0.1.0"
+source = "git+https://git.tawasuyu.net/tawasuyu/tawasuyu.git?tag=v0.3.0#36a1eabf32eb77f901869f516a9be352f3d17d30"
+
+[[package]]
+name = "llimphi-entrada"
+version = "0.1.0"
+source = "git+https://git.tawasuyu.net/tawasuyu/tawasuyu.git?tag=v0.3.0#36a1eabf32eb77f901869f516a9be352f3d17d30"
+
 [[package]]
 name = "llimphi-hal"
 version = "0.1.0"
-source = "git+https://git.tawasuyu.net/tawasuyu/tawasuyu.git?tag=v0.2.0#a446bb3a73a0e022158cc0ec7deb65173859644b"
+source = "git+https://git.tawasuyu.net/tawasuyu/tawasuyu.git?tag=v0.3.0#36a1eabf32eb77f901869f516a9be352f3d17d30"
 dependencies = [
+ "libc",
  "pollster",
  "raw-window-handle",
+ "web-sys",
  "wgpu",
  "winit",
 ]
@@ -3205,100 +3472,115 @@ dependencies = [
 [[package]]
 name = "llimphi-icons"
 version = "0.1.0"
-source = "git+https://git.tawasuyu.net/tawasuyu/tawasuyu.git?tag=v0.2.0#a446bb3a73a0e022158cc0ec7deb65173859644b"
+source = "git+https://git.tawasuyu.net/tawasuyu/tawasuyu.git?tag=v0.3.0#36a1eabf32eb77f901869f516a9be352f3d17d30"
 dependencies = [
- "llimphi-ui",
+ "llimphi-compositor",
 ]
 
 [[package]]
 name = "llimphi-layout"
 version = "0.1.0"
-source = "git+https://git.tawasuyu.net/tawasuyu/tawasuyu.git?tag=v0.2.0#a446bb3a73a0e022158cc0ec7deb65173859644b"
+source = "git+https://git.tawasuyu.net/tawasuyu/tawasuyu.git?tag=v0.3.0#36a1eabf32eb77f901869f516a9be352f3d17d30"
 dependencies = [
+ "hashbrown 0.17.1",
  "taffy",
 ]
 
 [[package]]
 name = "llimphi-motion"
 version = "0.1.0"
-source = "git+https://git.tawasuyu.net/tawasuyu/tawasuyu.git?tag=v0.2.0#a446bb3a73a0e022158cc0ec7deb65173859644b"
+source = "git+https://git.tawasuyu.net/tawasuyu/tawasuyu.git?tag=v0.3.0#36a1eabf32eb77f901869f516a9be352f3d17d30"
 dependencies = [
+ "llimphi-despacho",
  "llimphi-theme",
- "llimphi-ui",
 ]
 
 [[package]]
 name = "llimphi-raster"
 version = "0.1.0"
-source = "git+https://git.tawasuyu.net/tawasuyu/tawasuyu.git?tag=v0.2.0#a446bb3a73a0e022158cc0ec7deb65173859644b"
+source = "git+https://git.tawasuyu.net/tawasuyu/tawasuyu.git?tag=v0.3.0#36a1eabf32eb77f901869f516a9be352f3d17d30"
 dependencies = [
+ "foldhash 0.2.0",
  "llimphi-hal",
  "pollster",
  "vello",
+ "vello_encoding",
 ]
 
 [[package]]
 name = "llimphi-text"
 version = "0.1.0"
-source = "git+https://git.tawasuyu.net/tawasuyu/tawasuyu.git?tag=v0.2.0#a446bb3a73a0e022158cc0ec7deb65173859644b"
+source = "git+https://git.tawasuyu.net/tawasuyu/tawasuyu.git?tag=v0.3.0#36a1eabf32eb77f901869f516a9be352f3d17d30"
 dependencies = [
+ "hashbrown 0.17.1",
+ "llimphi-cpu",
+ "llimphi-wire-escena",
  "parley",
+ "peniko",
  "vello",
 ]
 
 [[package]]
 name = "llimphi-theme"
 version = "0.1.0"
-source = "git+https://git.tawasuyu.net/tawasuyu/tawasuyu.git?tag=v0.2.0#a446bb3a73a0e022158cc0ec7deb65173859644b"
+source = "git+https://git.tawasuyu.net/tawasuyu/tawasuyu.git?tag=v0.3.0#36a1eabf32eb77f901869f516a9be352f3d17d30"
 dependencies = [
- "llimphi-raster",
+ "peniko",
 ]
 
 [[package]]
 name = "llimphi-ui"
 version = "0.1.0"
-source = "git+https://git.tawasuyu.net/tawasuyu/tawasuyu.git?tag=v0.2.0#a446bb3a73a0e022158cc0ec7deb65173859644b"
+source = "git+https://git.tawasuyu.net/tawasuyu/tawasuyu.git?tag=v0.3.0#36a1eabf32eb77f901869f516a9be352f3d17d30"
 dependencies = [
  "accesskit",
  "accesskit_winit",
  "arboard",
+ "libc",
  "llimphi-compositor",
+ "llimphi-despacho",
  "llimphi-hal",
  "llimphi-layout",
  "llimphi-raster",
  "llimphi-text",
  "log",
  "pollster",
- "uuid",
+ "wasm-bindgen",
+ "wasm-bindgen-futures",
+ "wayland-backend",
+ "wayland-client",
+ "wayland-protocols",
+ "web-sys",
+ "web-time",
 ]
 
 [[package]]
 name = "llimphi-widget-button"
 version = "0.1.0"
-source = "git+https://git.tawasuyu.net/tawasuyu/tawasuyu.git?tag=v0.2.0#a446bb3a73a0e022158cc0ec7deb65173859644b"
+source = "git+https://git.tawasuyu.net/tawasuyu/tawasuyu.git?tag=v0.3.0#36a1eabf32eb77f901869f516a9be352f3d17d30"
 dependencies = [
+ "llimphi-compositor",
  "llimphi-theme",
- "llimphi-ui",
 ]
 
 [[package]]
 name = "llimphi-widget-context-menu"
 version = "0.1.0"
-source = "git+https://git.tawasuyu.net/tawasuyu/tawasuyu.git?tag=v0.2.0#a446bb3a73a0e022158cc0ec7deb65173859644b"
+source = "git+https://git.tawasuyu.net/tawasuyu/tawasuyu.git?tag=v0.3.0#36a1eabf32eb77f901869f516a9be352f3d17d30"
 dependencies = [
+ "llimphi-compositor",
  "llimphi-icons",
  "llimphi-theme",
- "llimphi-ui",
  "llimphi-widget-panel",
 ]
 
 [[package]]
 name = "llimphi-widget-edit-menu"
 version = "0.1.0"
-source = "git+https://git.tawasuyu.net/tawasuyu/tawasuyu.git?tag=v0.2.0#a446bb3a73a0e022158cc0ec7deb65173859644b"
+source = "git+https://git.tawasuyu.net/tawasuyu/tawasuyu.git?tag=v0.3.0#36a1eabf32eb77f901869f516a9be352f3d17d30"
 dependencies = [
+ "llimphi-compositor",
  "llimphi-theme",
- "llimphi-ui",
  "llimphi-widget-context-menu",
  "llimphi-widget-text-editor",
 ]
@@ -3306,20 +3588,21 @@ dependencies = [
 [[package]]
 name = "llimphi-widget-list"
 version = "0.1.0"
-source = "git+https://git.tawasuyu.net/tawasuyu/tawasuyu.git?tag=v0.2.0#a446bb3a73a0e022158cc0ec7deb65173859644b"
+source = "git+https://git.tawasuyu.net/tawasuyu/tawasuyu.git?tag=v0.3.0#36a1eabf32eb77f901869f516a9be352f3d17d30"
 dependencies = [
+ "llimphi-compositor",
  "llimphi-theme",
- "llimphi-ui",
+ "llimphi-widget-scroll",
 ]
 
 [[package]]
 name = "llimphi-widget-menubar"
 version = "0.1.0"
-source = "git+https://git.tawasuyu.net/tawasuyu/tawasuyu.git?tag=v0.2.0#a446bb3a73a0e022158cc0ec7deb65173859644b"
+source = "git+https://git.tawasuyu.net/tawasuyu/tawasuyu.git?tag=v0.3.0#36a1eabf32eb77f901869f516a9be352f3d17d30"
 dependencies = [
  "app-bus",
+ "llimphi-compositor",
  "llimphi-theme",
- "llimphi-ui",
  "llimphi-widget-button",
  "llimphi-widget-context-menu",
 ]
@@ -3327,26 +3610,36 @@ dependencies = [
 [[package]]
 name = "llimphi-widget-panel"
 version = "0.1.0"
-source = "git+https://git.tawasuyu.net/tawasuyu/tawasuyu.git?tag=v0.2.0#a446bb3a73a0e022158cc0ec7deb65173859644b"
+source = "git+https://git.tawasuyu.net/tawasuyu/tawasuyu.git?tag=v0.3.0#36a1eabf32eb77f901869f516a9be352f3d17d30"
 dependencies = [
+ "llimphi-compositor",
+ "llimphi-theme",
+]
+
+[[package]]
+name = "llimphi-widget-scroll"
+version = "0.1.0"
+source = "git+https://git.tawasuyu.net/tawasuyu/tawasuyu.git?tag=v0.3.0#36a1eabf32eb77f901869f516a9be352f3d17d30"
+dependencies = [
+ "llimphi-compositor",
  "llimphi-theme",
- "llimphi-ui",
 ]
 
 [[package]]
 name = "llimphi-widget-text-editor"
 version = "0.1.0"
-source = "git+https://git.tawasuyu.net/tawasuyu/tawasuyu.git?tag=v0.2.0#a446bb3a73a0e022158cc0ec7deb65173859644b"
+source = "git+https://git.tawasuyu.net/tawasuyu/tawasuyu.git?tag=v0.3.0#36a1eabf32eb77f901869f516a9be352f3d17d30"
 dependencies = [
+ "llimphi-compositor",
  "llimphi-theme",
- "llimphi-ui",
+ "llimphi-widget-scroll",
  "llimphi-widget-text-editor-core",
 ]
 
 [[package]]
 name = "llimphi-widget-text-editor-core"
 version = "0.1.0"
-source = "git+https://git.tawasuyu.net/tawasuyu/tawasuyu.git?tag=v0.2.0#a446bb3a73a0e022158cc0ec7deb65173859644b"
+source = "git+https://git.tawasuyu.net/tawasuyu/tawasuyu.git?tag=v0.3.0#36a1eabf32eb77f901869f516a9be352f3d17d30"
 dependencies = [
  "peniko",
  "ropey",
@@ -3358,11 +3651,22 @@ dependencies = [
 [[package]]
 name = "llimphi-widget-text-input"
 version = "0.1.0"
-source = "git+https://git.tawasuyu.net/tawasuyu/tawasuyu.git?tag=v0.2.0#a446bb3a73a0e022158cc0ec7deb65173859644b"
+source = "git+https://git.tawasuyu.net/tawasuyu/tawasuyu.git?tag=v0.3.0#36a1eabf32eb77f901869f516a9be352f3d17d30"
 dependencies = [
+ "llimphi-compositor",
  "llimphi-theme",
- "llimphi-ui",
  "llimphi-widget-text-editor",
+ "spin",
+]
+
+[[package]]
+name = "llimphi-wire-escena"
+version = "0.1.0"
+source = "git+https://git.tawasuyu.net/tawasuyu/tawasuyu.git?tag=v0.3.0#36a1eabf32eb77f901869f516a9be352f3d17d30"
+dependencies = [
+ "kurbo",
+ "peniko",
+ "serde",
 ]
 
 [[package]]
@@ -3376,9 +3680,9 @@ dependencies = [
 
 [[package]]
 name = "log"
-version = "0.4.33"
+version = "0.4.34"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad"
+checksum = "f9f8bd3e56ce4dfc153cf470fffbfa98c7620958b312ca5c3a4b8d5181fd13c6"
 
 [[package]]
 name = "lru-slab"
@@ -3386,20 +3690,11 @@ version = "0.1.2"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154"
 
-[[package]]
-name = "malloc_buf"
-version = "0.0.6"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "62bb907fe88d54d8d9ce32a3cceab4218ed2f6b7d35617cafe9adf84e43919cb"
-dependencies = [
- "libc",
-]
-
 [[package]]
 name = "match-lookup"
-version = "0.1.2"
+version = "0.1.3"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "757aee279b8bdbb9f9e676796fd459e4207a1f986e87886700abf589f5abf771"
+checksum = "549e39695cc0b640f3cb378053832db3d2133422d49e8dcae5c866a2aaf1f730"
 dependencies = [
  "proc-macro2",
  "quote",
@@ -3430,21 +3725,6 @@ dependencies = [
  "autocfg",
 ]
 
-[[package]]
-name = "metal"
-version = "0.32.0"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "00c15a6f673ff72ddcc22394663290f870fb224c1bfce55734a75c414150e605"
-dependencies = [
- "bitflags 2.13.1",
- "block",
- "core-graphics-types 0.2.0",
- "foreign-types 0.5.0",
- "log",
- "objc",
- "paste",
-]
-
 [[package]]
 name = "mime"
 version = "0.3.17"
@@ -3467,11 +3747,33 @@ dependencies = [
  "simd-adler32",
 ]
 
+[[package]]
+name = "miniz_oxide"
+version = "0.9.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b63fbc4a50860e98e7b2aa7804ded1db5cbc3aff9193adaff57a6931bf7c4b4c"
+dependencies = [
+ "adler2",
+ "simd-adler32",
+]
+
 [[package]]
 name = "mio"
-version = "1.2.2"
+version = "0.8.11"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a4a650543ca06a924e8b371db273b2756685faae30f8487da1b56505a8f78b0c"
+dependencies = [
+ "libc",
+ "log",
+ "wasi",
+ "windows-sys 0.48.0",
+]
+
+[[package]]
+name = "mio"
+version = "1.2.3"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "30d65c71f1ce40ab09135ce117d742b9f8a19ff91a41a8b57ed50bc2de59c427"
+checksum = "4b18443e9c262bfe8fa82f51666e2642c53393f7e5c27b3e1aeab922cff5b9d8"
 dependencies = [
  "libc",
  "wasi",
@@ -3480,9 +3782,9 @@ dependencies = [
 
 [[package]]
 name = "moka"
-version = "0.12.15"
+version = "0.12.16"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "957228ad12042ee839f93c8f257b62b4c0ab5eaae1d4fa60de53b27c9d7c5046"
+checksum = "4293f18e7567a1caf3c584855554377025c65e0aa445344d04171f5ad63d19b9"
 dependencies = [
  "crossbeam-channel",
  "crossbeam-epoch",
@@ -3562,9 +3864,9 @@ dependencies = [
 
 [[package]]
 name = "naga"
-version = "27.0.3"
+version = "29.0.4"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "066cf25f0e8b11ee0df221219010f213ad429855f57c494f995590c861a9a7d8"
+checksum = "b2bf919621e7975acb27d881bae2fb993e0d45c8e0446e85e6272971e00dc8df"
 dependencies = [
  "arrayvec",
  "bit-set",
@@ -3582,27 +3884,10 @@ dependencies = [
  "once_cell",
  "rustc-hash 1.1.0",
  "spirv",
- "thiserror 2.0.19",
+ "thiserror 2.0.20",
  "unicode-ident",
 ]
 
-[[package]]
-name = "native-tls"
-version = "0.2.18"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "465500e14ea162429d264d44189adc38b199b62b1c21eea9f69e4b73cb03bbf2"
-dependencies = [
- "libc",
- "log",
- "openssl",
- "openssl-probe",
- "openssl-sys",
- "schannel",
- "security-framework",
- "security-framework-sys",
- "tempfile",
-]
-
 [[package]]
 name = "ndk"
 version = "0.9.0"
@@ -3635,9 +3920,9 @@ dependencies = [
 
 [[package]]
 name = "netlink-packet-core"
-version = "0.8.1"
+version = "0.8.2"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "3463cbb78394cb0141e2c926b93fc2197e473394b761986eca3b9da2c63ae0f4"
+checksum = "b897d7bd4f0af82e68d40d0344cf37e97f9c97ddf74a098de3e4da05e96ca395"
 dependencies = [
  "paste",
 ]
@@ -3656,9 +3941,9 @@ dependencies = [
 
 [[package]]
 name = "netlink-proto"
-version = "0.12.1"
+version = "0.12.2"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "e6f7398dddf5f152d2a91a2921a134c6097056e292c0d4b9906007855e7cece6"
+checksum = "93af8261786086024cd5e96e0a991dd65ced07bbf7c233a487bbc96b971d5539"
 dependencies = [
  "bytes",
  "futures-channel",
@@ -3666,7 +3951,7 @@ dependencies = [
  "log",
  "netlink-packet-core",
  "netlink-sys",
- "thiserror 2.0.19",
+ "thiserror 2.0.20",
 ]
 
 [[package]]
@@ -3719,6 +4004,25 @@ dependencies = [
  "memchr",
 ]
 
+[[package]]
+name = "notify"
+version = "6.1.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6205bd8bb1e454ad2e27422015fb5e4f2bcc7e08fa8f27058670d208324a4d2d"
+dependencies = [
+ "bitflags 2.13.1",
+ "crossbeam-channel",
+ "filetime",
+ "fsevent-sys",
+ "inotify",
+ "kqueue",
+ "libc",
+ "log",
+ "mio 0.8.11",
+ "walkdir",
+ "windows-sys 0.48.0",
+]
+
 [[package]]
 name = "num-bigint"
 version = "0.4.8"
@@ -3737,9 +4041,9 @@ checksum = "521739c6d2bac4aa25192232afe6841231376b2b26d4d9fae5ecf8ca5772e441"
 
 [[package]]
 name = "num-integer"
-version = "0.1.46"
+version = "0.1.47"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "7969661fd2958a5cb096e56c8e1ad0444ac2bbcd0061bd28660485a44879858f"
+checksum = "7ce2d95d4b3734dc35aa2f45e1aa22cd416814592a4f9d9205e11affd5b8e10b"
 dependencies = [
  "num-traits",
 ]
@@ -3776,15 +4080,6 @@ dependencies = [
  "syn 2.0.119",
 ]
 
-[[package]]
-name = "objc"
-version = "0.2.7"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "915b1b472bc21c53464d6c8461c9d3af805ba1ef837e1cac254428f4a77177b1"
-dependencies = [
- "malloc_buf",
-]
-
 [[package]]
 name = "objc-sys"
 version = "0.3.5"
@@ -3817,13 +4112,13 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "e4e89ad9e3d7d297152b17d39ed92cd50ca8063a89a9fa569046d41568891eff"
 dependencies = [
  "bitflags 2.13.1",
- "block2",
+ "block2 0.5.1",
  "libc",
  "objc2 0.5.2",
  "objc2-core-data",
  "objc2-core-image",
  "objc2-foundation 0.2.2",
- "objc2-quartz-core",
+ "objc2-quartz-core 0.2.2",
 ]
 
 [[package]]
@@ -3845,7 +4140,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "74dd3b56391c7a0596a295029734d3c1c5e7e510a4cb30245f8221ccea96b009"
 dependencies = [
  "bitflags 2.13.1",
- "block2",
+ "block2 0.5.1",
  "objc2 0.5.2",
  "objc2-core-location",
  "objc2-foundation 0.2.2",
@@ -3857,7 +4152,7 @@ version = "0.2.2"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "a5ff520e9c33812fd374d8deecef01d4a840e7b41862d849513de77e44aa4889"
 dependencies = [
- "block2",
+ "block2 0.5.1",
  "objc2 0.5.2",
  "objc2-foundation 0.2.2",
 ]
@@ -3869,7 +4164,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "617fbf49e071c178c0b24c080767db52958f716d9eabdf0890523aeae54773ef"
 dependencies = [
  "bitflags 2.13.1",
- "block2",
+ "block2 0.5.1",
  "objc2 0.5.2",
  "objc2-foundation 0.2.2",
 ]
@@ -3904,10 +4199,10 @@ version = "0.2.2"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "55260963a527c99f1819c4f8e3b47fe04f9650694ef348ffd2227e8196d34c80"
 dependencies = [
- "block2",
+ "block2 0.5.1",
  "objc2 0.5.2",
  "objc2-foundation 0.2.2",
- "objc2-metal",
+ "objc2-metal 0.2.2",
 ]
 
 [[package]]
@@ -3916,7 +4211,7 @@ version = "0.2.2"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "000cfee34e683244f284252ee206a27953279d370e309649dc3ee317b37e5781"
 dependencies = [
- "block2",
+ "block2 0.5.1",
  "objc2 0.5.2",
  "objc2-contacts",
  "objc2-foundation 0.2.2",
@@ -3945,7 +4240,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "0ee638a5da3799329310ad4cfa62fbf045d5f56e3ef5ba4149e7452dcf89d5a8"
 dependencies = [
  "bitflags 2.13.1",
- "block2",
+ "block2 0.5.1",
  "dispatch",
  "libc",
  "objc2 0.5.2",
@@ -3979,7 +4274,7 @@ version = "0.2.2"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "a1a1ae721c5e35be65f01a03b6d2ac13a54cb4fa70d8a5da293d7b0020261398"
 dependencies = [
- "block2",
+ "block2 0.5.1",
  "objc2 0.5.2",
  "objc2-app-kit 0.2.2",
  "objc2-foundation 0.2.2",
@@ -3992,11 +4287,23 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "dd0cba1276f6023976a406a14ffa85e1fdd19df6b0f737b063b95f6c8c7aadd6"
 dependencies = [
  "bitflags 2.13.1",
- "block2",
+ "block2 0.5.1",
  "objc2 0.5.2",
  "objc2-foundation 0.2.2",
 ]
 
+[[package]]
+name = "objc2-metal"
+version = "0.3.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a0125f776a10d00af4152d74616409f0d4a2053a6f57fa5b7d6aa2854ac04794"
+dependencies = [
+ "bitflags 2.13.1",
+ "block2 0.6.2",
+ "objc2 0.6.4",
+ "objc2-foundation 0.3.2",
+]
+
 [[package]]
 name = "objc2-quartz-core"
 version = "0.2.2"
@@ -4004,10 +4311,23 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "e42bee7bff906b14b167da2bac5efe6b6a07e6f7c0a21a7308d40c960242dc7a"
 dependencies = [
  "bitflags 2.13.1",
- "block2",
+ "block2 0.5.1",
  "objc2 0.5.2",
  "objc2-foundation 0.2.2",
- "objc2-metal",
+ "objc2-metal 0.2.2",
+]
+
+[[package]]
+name = "objc2-quartz-core"
+version = "0.3.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "96c1358452b371bf9f104e21ec536d37a650eb10f7ee379fff67d2e08d537f1f"
+dependencies = [
+ "bitflags 2.13.1",
+ "objc2 0.6.4",
+ "objc2-core-foundation",
+ "objc2-foundation 0.3.2",
+ "objc2-metal 0.3.2",
 ]
 
 [[package]]
@@ -4027,7 +4347,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "b8bb46798b20cd6b91cbd113524c490f1686f4c4e8f49502431415f3512e2b6f"
 dependencies = [
  "bitflags 2.13.1",
- "block2",
+ "block2 0.5.1",
  "objc2 0.5.2",
  "objc2-cloud-kit",
  "objc2-core-data",
@@ -4035,7 +4355,7 @@ dependencies = [
  "objc2-core-location",
  "objc2-foundation 0.2.2",
  "objc2-link-presentation",
- "objc2-quartz-core",
+ "objc2-quartz-core 0.2.2",
  "objc2-symbols",
  "objc2-uniform-type-identifiers",
  "objc2-user-notifications",
@@ -4047,7 +4367,7 @@ version = "0.2.2"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "44fa5f9748dbfe1ca6c0b79ad20725a11eca7c2218bceb4b005cb1be26273bfe"
 dependencies = [
- "block2",
+ "block2 0.5.1",
  "objc2 0.5.2",
  "objc2-foundation 0.2.2",
 ]
@@ -4059,7 +4379,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "76cfcbf642358e8689af64cee815d139339f3ed8ad05103ed5eaf73db8d84cb3"
 dependencies = [
  "bitflags 2.13.1",
- "block2",
+ "block2 0.5.1",
  "objc2 0.5.2",
  "objc2-core-location",
  "objc2-foundation 0.2.2",
@@ -4085,53 +4405,16 @@ dependencies = [
 ]
 
 [[package]]
-name = "opaque-debug"
-version = "0.3.1"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381"
-
-[[package]]
-name = "openssl"
-version = "0.10.81"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "77823a27f0babb03091cb9ed9ef80af3b39dbc82f97e8fa530374b7dafd87a45"
-dependencies = [
- "bitflags 2.13.1",
- "cfg-if",
- "foreign-types 0.3.2",
- "libc",
- "openssl-macros",
- "openssl-sys",
-]
-
-[[package]]
-name = "openssl-macros"
-version = "0.1.1"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "a948666b637a0f465e8564c73e89d4dde00d72d4d473cc972f390fc3dcee7d9c"
-dependencies = [
- "proc-macro2",
- "quote",
- "syn 2.0.119",
-]
-
-[[package]]
-name = "openssl-probe"
-version = "0.2.1"
+name = "once_cell_polyfill"
+version = "1.70.2"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe"
+checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe"
 
 [[package]]
-name = "openssl-sys"
-version = "0.9.117"
+name = "opaque-debug"
+version = "0.3.1"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "b47e7e6bb2c38cd930d25a23b40fa52e068c10e85f3e03a7f5ba5aaca5713695"
-dependencies = [
- "cc",
- "libc",
- "pkg-config",
- "vcpkg",
-]
+checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381"
 
 [[package]]
 name = "option-ext"
@@ -4151,9 +4434,9 @@ dependencies = [
 
 [[package]]
 name = "ordered-float"
-version = "5.3.0"
+version = "5.5.0"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "b7d950ca161dc355eaf28f82b11345ed76c6e1f6eb1f4f4479e0323b9e2fbd0e"
+checksum = "8c7c9e0d9b23589f26070720bac724174bfec1083e82f7854cdd0267518343c0"
 dependencies = [
  "num-traits",
 ]
@@ -4263,6 +4546,7 @@ version = "0.6.1"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "839c8299360d2e998bdb106dc0a6cd71dcc5f4df51df1b620361bf50e283cca6"
 dependencies = [
+ "bytemuck",
  "color",
  "kurbo",
  "linebender_resource_handle",
@@ -4367,32 +4651,151 @@ dependencies = [
 ]
 
 [[package]]
-name = "pkcs8"
-version = "0.10.2"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "f950b2377845cebe5cf8b5165cb3cc1a5e0fa5cfa3e1f7f55707d8fd82e0a7b7"
-dependencies = [
- "der",
- "spki",
+name = "pizarra-core"
+version = "0.1.0"
+source = "git+https://git.tawasuyu.net/tawasuyu/tawasuyu.git?tag=v0.3.0#36a1eabf32eb77f901869f516a9be352f3d17d30"
+dependencies = [
+ "crdt",
+ "krama-expr",
+ "postcard",
+ "serde",
+]
+
+[[package]]
+name = "pizarra-llimphi"
+version = "0.1.0"
+source = "git+https://git.tawasuyu.net/tawasuyu/tawasuyu.git?tag=v0.3.0#36a1eabf32eb77f901869f516a9be352f3d17d30"
+dependencies = [
+ "llimphi-compositor",
+ "llimphi-theme",
+ "pizarra-core",
+]
+
+[[package]]
+name = "pkcs8"
+version = "0.10.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f950b2377845cebe5cf8b5165cb3cc1a5e0fa5cfa3e1f7f55707d8fd82e0a7b7"
+dependencies = [
+ "der",
+ "spki",
+]
+
+[[package]]
+name = "pkg-config"
+version = "0.3.34"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f6b464fbc74e149a392436b17d523f769e057cb6877f6a5c4618bc6f11800548"
+
+[[package]]
+name = "plain"
+version = "0.2.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b4596b6d070b27117e987119b4dac604f3c58cfb0b191112e24771b2faeac1a6"
+
+[[package]]
+name = "pluma-align"
+version = "0.1.0"
+source = "git+https://git.tawasuyu.net/tawasuyu/tawasuyu.git?tag=v0.3.0#36a1eabf32eb77f901869f516a9be352f3d17d30"
+dependencies = [
+ "pluma-cuerpo",
+ "pluma-fidelidad-tipos",
+ "postcard",
+ "serde",
+ "uuid",
+]
+
+[[package]]
+name = "pluma-biblioteca"
+version = "0.1.0"
+source = "git+https://git.tawasuyu.net/tawasuyu/tawasuyu.git?tag=v0.3.0#36a1eabf32eb77f901869f516a9be352f3d17d30"
+dependencies = [
+ "blake3",
+ "pluma-catalogo",
+ "pluma-cuerpo",
+ "pluma-dimension",
+ "pluma-seccion",
+ "serde",
+ "uuid",
+]
+
+[[package]]
+name = "pluma-catalogo"
+version = "0.1.0"
+source = "git+https://git.tawasuyu.net/tawasuyu/tawasuyu.git?tag=v0.3.0#36a1eabf32eb77f901869f516a9be352f3d17d30"
+dependencies = [
+ "pluma-align",
+ "pluma-core",
+ "pluma-cuerpo",
+ "pluma-outline",
+ "pluma-seccion",
+ "uuid",
+]
+
+[[package]]
+name = "pluma-core"
+version = "0.1.0"
+source = "git+https://git.tawasuyu.net/tawasuyu/tawasuyu.git?tag=v0.3.0#36a1eabf32eb77f901869f516a9be352f3d17d30"
+dependencies = [
+ "serde",
+ "sha2",
+ "uuid",
+]
+
+[[package]]
+name = "pluma-cuerpo"
+version = "0.1.0"
+source = "git+https://git.tawasuyu.net/tawasuyu/tawasuyu.git?tag=v0.3.0#36a1eabf32eb77f901869f516a9be352f3d17d30"
+dependencies = [
+ "grafo-nav",
+ "pluma-seccion",
+ "postcard",
+ "serde",
+ "uuid",
+]
+
+[[package]]
+name = "pluma-dimension"
+version = "0.1.0"
+source = "git+https://git.tawasuyu.net/tawasuyu/tawasuyu.git?tag=v0.3.0#36a1eabf32eb77f901869f516a9be352f3d17d30"
+dependencies = [
+ "pluma-align",
+ "pluma-catalogo",
+ "pluma-cuerpo",
+ "pluma-reactor",
+ "pluma-recorte",
+ "pluma-seccion",
+ "pluma-transform",
+ "pluma-urdimbre",
+ "serde",
+ "uuid",
 ]
 
 [[package]]
-name = "pkg-config"
-version = "0.3.33"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e"
+name = "pluma-estilo"
+version = "0.1.0"
+source = "git+https://git.tawasuyu.net/tawasuyu/tawasuyu.git?tag=v0.3.0#36a1eabf32eb77f901869f516a9be352f3d17d30"
+dependencies = [
+ "serde",
+ "uuid",
+]
 
 [[package]]
-name = "plain"
-version = "0.2.3"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "b4596b6d070b27117e987119b4dac604f3c58cfb0b191112e24771b2faeac1a6"
+name = "pluma-fidelidad-tipos"
+version = "0.1.0"
+source = "git+https://git.tawasuyu.net/tawasuyu/tawasuyu.git?tag=v0.3.0#36a1eabf32eb77f901869f516a9be352f3d17d30"
+dependencies = [
+ "postcard",
+ "serde",
+]
 
 [[package]]
 name = "pluma-llm"
 version = "0.1.0"
-source = "git+https://git.tawasuyu.net/tawasuyu/tawasuyu.git?tag=v0.2.0#a446bb3a73a0e022158cc0ec7deb65173859644b"
+source = "git+https://git.tawasuyu.net/tawasuyu/tawasuyu.git?tag=v0.3.0#36a1eabf32eb77f901869f516a9be352f3d17d30"
 dependencies = [
+ "async-trait",
+ "ia-latido",
  "pluma-llm-anthropic",
  "pluma-llm-claude-cli",
  "pluma-llm-cohere",
@@ -4401,13 +4804,14 @@ dependencies = [
  "pluma-llm-mock",
  "pluma-llm-openai-compatible",
  "serde",
- "thiserror 2.0.19",
+ "thiserror 2.0.20",
+ "wawa-config",
 ]
 
 [[package]]
 name = "pluma-llm-anthropic"
 version = "0.1.0"
-source = "git+https://git.tawasuyu.net/tawasuyu/tawasuyu.git?tag=v0.2.0#a446bb3a73a0e022158cc0ec7deb65173859644b"
+source = "git+https://git.tawasuyu.net/tawasuyu/tawasuyu.git?tag=v0.3.0#36a1eabf32eb77f901869f516a9be352f3d17d30"
 dependencies = [
  "async-trait",
  "pluma-llm-core",
@@ -4419,7 +4823,7 @@ dependencies = [
 [[package]]
 name = "pluma-llm-claude-cli"
 version = "0.1.0"
-source = "git+https://git.tawasuyu.net/tawasuyu/tawasuyu.git?tag=v0.2.0#a446bb3a73a0e022158cc0ec7deb65173859644b"
+source = "git+https://git.tawasuyu.net/tawasuyu/tawasuyu.git?tag=v0.3.0#36a1eabf32eb77f901869f516a9be352f3d17d30"
 dependencies = [
  "async-trait",
  "base64",
@@ -4431,7 +4835,7 @@ dependencies = [
 [[package]]
 name = "pluma-llm-cohere"
 version = "0.1.0"
-source = "git+https://git.tawasuyu.net/tawasuyu/tawasuyu.git?tag=v0.2.0#a446bb3a73a0e022158cc0ec7deb65173859644b"
+source = "git+https://git.tawasuyu.net/tawasuyu/tawasuyu.git?tag=v0.3.0#36a1eabf32eb77f901869f516a9be352f3d17d30"
 dependencies = [
  "async-trait",
  "pluma-llm-core",
@@ -4443,18 +4847,18 @@ dependencies = [
 [[package]]
 name = "pluma-llm-core"
 version = "0.1.0"
-source = "git+https://git.tawasuyu.net/tawasuyu/tawasuyu.git?tag=v0.2.0#a446bb3a73a0e022158cc0ec7deb65173859644b"
+source = "git+https://git.tawasuyu.net/tawasuyu/tawasuyu.git?tag=v0.3.0#36a1eabf32eb77f901869f516a9be352f3d17d30"
 dependencies = [
  "async-trait",
  "base64",
  "serde",
- "thiserror 2.0.19",
+ "thiserror 2.0.20",
 ]
 
 [[package]]
 name = "pluma-llm-gemini"
 version = "0.1.0"
-source = "git+https://git.tawasuyu.net/tawasuyu/tawasuyu.git?tag=v0.2.0#a446bb3a73a0e022158cc0ec7deb65173859644b"
+source = "git+https://git.tawasuyu.net/tawasuyu/tawasuyu.git?tag=v0.3.0#36a1eabf32eb77f901869f516a9be352f3d17d30"
 dependencies = [
  "async-trait",
  "pluma-llm-core",
@@ -4467,7 +4871,7 @@ dependencies = [
 [[package]]
 name = "pluma-llm-mock"
 version = "0.1.0"
-source = "git+https://git.tawasuyu.net/tawasuyu/tawasuyu.git?tag=v0.2.0#a446bb3a73a0e022158cc0ec7deb65173859644b"
+source = "git+https://git.tawasuyu.net/tawasuyu/tawasuyu.git?tag=v0.3.0#36a1eabf32eb77f901869f516a9be352f3d17d30"
 dependencies = [
  "async-trait",
  "pluma-llm-core",
@@ -4476,7 +4880,7 @@ dependencies = [
 [[package]]
 name = "pluma-llm-openai-compatible"
 version = "0.1.0"
-source = "git+https://git.tawasuyu.net/tawasuyu/tawasuyu.git?tag=v0.2.0#a446bb3a73a0e022158cc0ec7deb65173859644b"
+source = "git+https://git.tawasuyu.net/tawasuyu/tawasuyu.git?tag=v0.3.0#36a1eabf32eb77f901869f516a9be352f3d17d30"
 dependencies = [
  "async-trait",
  "pluma-llm-core",
@@ -4486,16 +4890,83 @@ dependencies = [
 ]
 
 [[package]]
-name = "png"
-version = "0.17.16"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "82151a2fc869e011c153adc57cf2789ccb8d9906ce52c0b39a6b5697749d7526"
+name = "pluma-outline"
+version = "0.1.0"
+source = "git+https://git.tawasuyu.net/tawasuyu/tawasuyu.git?tag=v0.3.0#36a1eabf32eb77f901869f516a9be352f3d17d30"
 dependencies = [
- "bitflags 1.3.2",
- "crc32fast",
- "fdeflate",
- "flate2",
- "miniz_oxide",
+ "uuid",
+]
+
+[[package]]
+name = "pluma-proyecto"
+version = "0.1.0"
+source = "git+https://git.tawasuyu.net/tawasuyu/tawasuyu.git?tag=v0.3.0#36a1eabf32eb77f901869f516a9be352f3d17d30"
+dependencies = [
+ "format",
+ "pluma-align",
+ "pluma-biblioteca",
+ "pluma-core",
+ "pluma-cuerpo",
+ "pluma-dimension",
+ "pluma-estilo",
+ "pluma-recorte",
+ "pluma-seccion",
+ "pluma-transform",
+ "pluma-urdimbre",
+ "postcard",
+ "serde",
+ "uuid",
+]
+
+[[package]]
+name = "pluma-reactor"
+version = "0.1.0"
+source = "git+https://git.tawasuyu.net/tawasuyu/tawasuyu.git?tag=v0.3.0#36a1eabf32eb77f901869f516a9be352f3d17d30"
+dependencies = [
+ "dataflow",
+ "yupay-core",
+]
+
+[[package]]
+name = "pluma-recorte"
+version = "0.1.0"
+source = "git+https://git.tawasuyu.net/tawasuyu/tawasuyu.git?tag=v0.3.0#36a1eabf32eb77f901869f516a9be352f3d17d30"
+dependencies = [
+ "serde",
+ "uuid",
+]
+
+[[package]]
+name = "pluma-seccion"
+version = "0.1.0"
+source = "git+https://git.tawasuyu.net/tawasuyu/tawasuyu.git?tag=v0.3.0#36a1eabf32eb77f901869f516a9be352f3d17d30"
+dependencies = [
+ "serde",
+ "uuid",
+]
+
+[[package]]
+name = "pluma-transform"
+version = "0.1.0"
+source = "git+https://git.tawasuyu.net/tawasuyu/tawasuyu.git?tag=v0.3.0#36a1eabf32eb77f901869f516a9be352f3d17d30"
+dependencies = [
+ "async-trait",
+ "pluma-align",
+ "pluma-core",
+ "pluma-cuerpo",
+ "pluma-fidelidad-tipos",
+ "serde",
+ "uuid",
+]
+
+[[package]]
+name = "pluma-urdimbre"
+version = "0.1.0"
+source = "git+https://git.tawasuyu.net/tawasuyu/tawasuyu.git?tag=v0.3.0#36a1eabf32eb77f901869f516a9be352f3d17d30"
+dependencies = [
+ "pluma-align",
+ "serde",
+ "uuid",
 ]
 
 [[package]]
@@ -4508,7 +4979,7 @@ dependencies = [
  "crc32fast",
  "fdeflate",
  "flate2",
- "miniz_oxide",
+ "miniz_oxide 0.8.9",
 ]
 
 [[package]]
@@ -4549,6 +5020,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "50596ddc09eb5ad5f75cacd40209568e66df71baf86e1499a0e99c4cff12a5a6"
 dependencies = [
  "arrayvec",
+ "libm",
 ]
 
 [[package]]
@@ -4565,15 +5037,15 @@ dependencies = [
 
 [[package]]
 name = "portable-atomic"
-version = "1.14.0"
+version = "1.15.0"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "3d20d5497ef88037a52ff98267d066e7f11fcc5e99bbfbd58a42336193aacec3"
+checksum = "05c8b63e8d9609db387f0324918f81d68fe27748f084ef092fb35954d0539a85"
 
 [[package]]
 name = "portable-atomic-util"
-version = "0.2.7"
+version = "0.2.8"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "c2a106d1259c23fac8e543272398ae0e3c0b8d33c88ed73d0cc71b0f1d902618"
+checksum = "10ab3eb7f3becc3a1cbc4f2c6f20267996cfc1a6467a873763411b136a122715"
 dependencies = [
  "portable-atomic",
 ]
@@ -4593,9 +5065,9 @@ dependencies = [
 
 [[package]]
 name = "potential_utf"
-version = "0.1.5"
+version = "0.1.6"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "0103b1cef7ec0cf76490e969665504990193874ea05c85ff9bab8b911d0a0564"
+checksum = "d83eb9bc6d8e5cf568e7a1101d60ee05e81ed50ea106026f3d18deeb046d7661"
 dependencies = [
  "zerovec",
 ]
@@ -4749,7 +5221,7 @@ dependencies = [
  "rustc-hash 2.1.3",
  "rustls",
  "socket2 0.6.5",
- "thiserror 2.0.19",
+ "thiserror 2.0.20",
  "tokio",
  "tracing",
  "web-time",
@@ -4757,9 +5229,9 @@ dependencies = [
 
 [[package]]
 name = "quinn-proto"
-version = "0.11.16"
+version = "0.11.17"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "2f4bfc015262b9df63c8845072ce59068853ff5872180c2ce2f13038b970e560"
+checksum = "04759210543be93709136e28212294a659ef5001836ff4eab4d663e4529bba83"
 dependencies = [
  "bytes",
  "fastbloom",
@@ -4772,7 +5244,7 @@ dependencies = [
  "rustls",
  "rustls-pki-types",
  "slab",
- "thiserror 2.0.19",
+ "thiserror 2.0.20",
  "tinyvec",
  "tracing",
  "web-time",
@@ -4815,9 +5287,9 @@ checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf"
 
 [[package]]
 name = "rand"
-version = "0.8.7"
+version = "0.8.8"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "22f6172bdec972074665ed81ed53b71da00bfc44b65a753cfde883ec4c702a1a"
+checksum = "e058c7de0b26af77780c769414d6257830bb240f3c38477dbc2c16e5f54d6d4c"
 dependencies = [
  "libc",
  "rand_chacha 0.3.1",
@@ -4840,7 +5312,7 @@ version = "0.10.2"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "c7f5fa3a058cd35567ef9bfa5e75732bee0f9e4c55fa90477bef2dfcdbc4be80"
 dependencies = [
- "chacha20 0.10.1",
+ "chacha20 0.10.2",
  "getrandom 0.4.3",
  "rand_core 0.10.1",
 ]
@@ -4910,6 +5382,18 @@ version = "0.6.2"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "20675572f6f24e9e76ef639bc5552774ed45f1c30e2951e1e99c59888861c539"
 
+[[package]]
+name = "raw-window-metal"
+version = "1.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "40d213455a5f1dc59214213c7330e074ddf8114c9a42411eb890c767357ce135"
+dependencies = [
+ "objc2 0.6.4",
+ "objc2-core-foundation",
+ "objc2-foundation 0.3.2",
+ "objc2-quartz-core 0.3.2",
+]
+
 [[package]]
 name = "rcgen"
 version = "0.13.2"
@@ -4936,9 +5420,9 @@ dependencies = [
 
 [[package]]
 name = "read-fonts"
-version = "0.37.0"
+version = "0.39.2"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "7b634fabf032fab15307ffd272149b622260f55974d9fad689292a5d33df02e5"
+checksum = "c4ed38b89c2c77ff968c524145ad65fb010f38af5c7a224b53b81d47ac2daa81"
 dependencies = [
  "bytemuck",
  "font-types 0.11.3",
@@ -4951,7 +5435,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "046a7d674daf459825b32f5062056d6882db0d2f5a479fbd76ccfc870ac18709"
 dependencies = [
  "bytemuck",
- "font-types 0.12.2",
+ "font-types 0.12.4",
  "once_cell",
 ]
 
@@ -4975,9 +5459,9 @@ dependencies = [
 
 [[package]]
 name = "redox_syscall"
-version = "0.9.0"
+version = "0.9.4"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "c5102a6aaa05aa011a238e178e6bca86d2cb56fc9f586d37cb80f5bca6e07759"
+checksum = "737970939a87c6fa31e7acad13307bccbb017a073b695b6089a2c484f929e20e"
 dependencies = [
  "bitflags 2.13.1",
 ]
@@ -5007,9 +5491,9 @@ dependencies = [
 
 [[package]]
 name = "regex-automata"
-version = "0.4.16"
+version = "0.4.18"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "8fcfdb36bda0c880c5931cdc7a2bcdc8ba4556847b9d912bca70bc94708711ad"
+checksum = "ad8553b9b26413251cbf30e620595c7a41b3887f03da04579c0e6b0d6a06b4b2"
 dependencies = [
  "aho-corasick",
  "memchr",
@@ -5044,12 +5528,10 @@ dependencies = [
  "http-body-util",
  "hyper",
  "hyper-rustls",
- "hyper-tls",
  "hyper-util",
  "js-sys",
  "log",
  "mime",
- "native-tls",
  "percent-encoding",
  "pin-project-lite",
  "quinn",
@@ -5060,7 +5542,6 @@ dependencies = [
  "serde_urlencoded",
  "sync_wrapper",
  "tokio",
- "tokio-native-tls",
  "tokio-rustls",
  "tower",
  "tower-http",
@@ -5081,7 +5562,7 @@ checksum = "1e061d1b48cb8d38042de4ae0a7a6401009d6143dc80d2e2d6f31f0bdd6470c7"
 [[package]]
 name = "rimay-verbo"
 version = "0.1.0"
-source = "git+https://git.tawasuyu.net/tawasuyu/tawasuyu.git?tag=v0.2.0#a446bb3a73a0e022158cc0ec7deb65173859644b"
+source = "git+https://git.tawasuyu.net/tawasuyu/tawasuyu.git?tag=v0.3.0#36a1eabf32eb77f901869f516a9be352f3d17d30"
 dependencies = [
  "rimay-verbo-core",
  "rimay-verbo-daemon",
@@ -5091,17 +5572,17 @@ dependencies = [
 [[package]]
 name = "rimay-verbo-core"
 version = "0.1.0"
-source = "git+https://git.tawasuyu.net/tawasuyu/tawasuyu.git?tag=v0.2.0#a446bb3a73a0e022158cc0ec7deb65173859644b"
+source = "git+https://git.tawasuyu.net/tawasuyu/tawasuyu.git?tag=v0.3.0#36a1eabf32eb77f901869f516a9be352f3d17d30"
 dependencies = [
  "async-trait",
  "serde",
- "thiserror 2.0.19",
+ "thiserror 2.0.20",
 ]
 
 [[package]]
 name = "rimay-verbo-daemon"
 version = "0.1.0"
-source = "git+https://git.tawasuyu.net/tawasuyu/tawasuyu.git?tag=v0.2.0#a446bb3a73a0e022158cc0ec7deb65173859644b"
+source = "git+https://git.tawasuyu.net/tawasuyu/tawasuyu.git?tag=v0.3.0#36a1eabf32eb77f901869f516a9be352f3d17d30"
 dependencies = [
  "async-trait",
  "postcard",
@@ -5113,18 +5594,18 @@ dependencies = [
 [[package]]
 name = "rimay-verbo-index"
 version = "0.1.0"
-source = "git+https://git.tawasuyu.net/tawasuyu/tawasuyu.git?tag=v0.2.0#a446bb3a73a0e022158cc0ec7deb65173859644b"
+source = "git+https://git.tawasuyu.net/tawasuyu/tawasuyu.git?tag=v0.3.0#36a1eabf32eb77f901869f516a9be352f3d17d30"
 dependencies = [
  "postcard",
  "rimay-verbo-core",
  "serde",
- "thiserror 2.0.19",
+ "thiserror 2.0.20",
 ]
 
 [[package]]
 name = "rimay-verbo-mock"
 version = "0.1.0"
-source = "git+https://git.tawasuyu.net/tawasuyu/tawasuyu.git?tag=v0.2.0#a446bb3a73a0e022158cc0ec7deb65173859644b"
+source = "git+https://git.tawasuyu.net/tawasuyu/tawasuyu.git?tag=v0.3.0#36a1eabf32eb77f901869f516a9be352f3d17d30"
 dependencies = [
  "async-trait",
  "rimay-verbo-core",
@@ -5178,6 +5659,23 @@ dependencies = [
  "tokio",
 ]
 
+[[package]]
+name = "rust_decimal"
+version = "1.43.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7653272e75dcac41dc199fbea6f5797633994fafd339943c06c9af16bf29cd3a"
+dependencies = [
+ "arrayvec",
+ "borsh",
+ "bytes",
+ "num-traits",
+ "rand 0.8.8",
+ "rand 0.9.5",
+ "serde",
+ "serde_json",
+ "wasm-bindgen",
+]
+
 [[package]]
 name = "rustc-hash"
 version = "1.1.0"
@@ -5236,9 +5734,9 @@ dependencies = [
 
 [[package]]
 name = "rustls"
-version = "0.23.42"
+version = "0.23.43"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "3c54fcab019b409d04215d3a17cb438fd7fbf192ee61461f20f4fe18704bc138"
+checksum = "0283386ce02abc0151e1761d08802dfe86c173b0b494af5cbc086574e453da06"
 dependencies = [
  "once_cell",
  "ring",
@@ -5260,9 +5758,9 @@ dependencies = [
 
 [[package]]
 name = "rustls-webpki"
-version = "0.103.13"
+version = "0.103.15"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "61c429a8649f110dddef65e2a5ad240f747e85f7758a6bccc7e5777bd33f756e"
+checksum = "f3c3cf1d8b1e7d4927e2d154c3fcb02979afb9939629c62cd9048d4f07b60ac2"
 dependencies = [
  "ring",
  "rustls-pki-types",
@@ -5301,15 +5799,6 @@ dependencies = [
  "winapi-util",
 ]
 
-[[package]]
-name = "schannel"
-version = "0.1.29"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "91c1b7e4904c873ef0710c1f407dde2e6287de2bebc1bbbf7d430bb7cbffd939"
-dependencies = [
- "windows-sys 0.61.2",
-]
-
 [[package]]
 name = "scoped-tls"
 version = "1.0.1"
@@ -5335,29 +5824,6 @@ dependencies = [
  "tiny-skia",
 ]
 
-[[package]]
-name = "security-framework"
-version = "3.7.0"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d"
-dependencies = [
- "bitflags 2.13.1",
- "core-foundation 0.10.1",
- "core-foundation-sys",
- "libc",
- "security-framework-sys",
-]
-
-[[package]]
-name = "security-framework-sys"
-version = "2.17.0"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "6ce2691df843ecc5d231c0b14ece2acc3efb62c0a398c7e1d875f3983ce020e3"
-dependencies = [
- "core-foundation-sys",
- "libc",
-]
-
 [[package]]
 name = "semver"
 version = "1.0.28"
@@ -5400,7 +5866,7 @@ checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348"
 dependencies = [
  "proc-macro2",
  "quote",
- "syn 3.0.3",
+ "syn 3.0.5",
 ]
 
 [[package]]
@@ -5409,6 +5875,7 @@ version = "1.0.151"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14"
 dependencies = [
+ "indexmap",
  "itoa",
  "memchr",
  "serde",
@@ -5424,7 +5891,7 @@ checksum = "8d3b1629de253c70a0508c3899572da79ca359fdab27c7920ff00406df418906"
 dependencies = [
  "proc-macro2",
  "quote",
- "syn 3.0.3",
+ "syn 3.0.5",
 ]
 
 [[package]]
@@ -5524,12 +5991,12 @@ dependencies = [
 
 [[package]]
 name = "skrifa"
-version = "0.40.0"
+version = "0.42.1"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "7fbdfe3d2475fbd7ddd1f3e5cf8288a30eb3e5f95832829570cd88115a7434ac"
+checksum = "0c34617370ae968efb7161bb2beb517d9084659aae19e24b89e3db25b46e4564"
 dependencies = [
  "bytemuck",
- "read-fonts 0.37.0",
+ "read-fonts 0.39.2",
 ]
 
 [[package]]
@@ -5559,9 +6026,9 @@ dependencies = [
 
 [[package]]
 name = "smallvec"
-version = "1.15.2"
+version = "1.16.0"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "8ed6a63f02c8539c91a8685a86f4099661ba3da017932f6ebbea6de3f0fa7c90"
+checksum = "b9be42f50aa861c555654aa3a37f52f4b1074bacf4e48fe0ef7fa584e80f1f0f"
 
 [[package]]
 name = "smithay-client-toolkit"
@@ -5614,6 +6081,11 @@ dependencies = [
  "subtle",
 ]
 
+[[package]]
+name = "socket-local"
+version = "0.1.0"
+source = "git+https://git.tawasuyu.net/tawasuyu/tawasuyu.git?tag=v0.3.0#36a1eabf32eb77f901869f516a9be352f3d17d30"
+
 [[package]]
 name = "socket2"
 version = "0.5.10"
@@ -5645,9 +6117,9 @@ dependencies = [
 
 [[package]]
 name = "spirv"
-version = "0.3.0+sdk-1.3.268.0"
+version = "0.4.0+sdk-1.4.341.0"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "eda41003dc44290527a59b13432d4a0379379fa074b70174882adfbdfd917844"
+checksum = "d9571ea910ebd84c86af4b3ed27f9dbdc6ad06f17c5f96146b2b671e2976744f"
 dependencies = [
  "bitflags 2.13.1",
 ]
@@ -5692,6 +6164,12 @@ version = "0.1.1"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "6637bab7722d379c8b41ba849228d680cc12d0a45ba1fa2b48f2a30577a06731"
 
+[[package]]
+name = "strsim"
+version = "0.11.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f"
+
 [[package]]
 name = "subtle"
 version = "2.6.1"
@@ -5728,9 +6206,9 @@ dependencies = [
 
 [[package]]
 name = "syn"
-version = "3.0.3"
+version = "3.0.5"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "53e9bae58849f64dfa4f5d5ae372c8341f7305f82a3868709269343628b659a3"
+checksum = "12df2e0110f65b775f769bb17ef989067a1d931b2eb822bd4346631eeada89f9"
 dependencies = [
  "proc-macro2",
  "quote",
@@ -5764,7 +6242,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "a13f3d0daba03132c0aa9767f98351b3488edc2c100cda2d2ec2b04f3d8d3c8b"
 dependencies = [
  "bitflags 2.13.1",
- "core-foundation 0.9.4",
+ "core-foundation",
  "system-configuration-sys",
 ]
 
@@ -5780,9 +6258,9 @@ dependencies = [
 
 [[package]]
 name = "taffy"
-version = "0.9.2"
+version = "0.12.2"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "41ba83ebaf2954d31d05d67340fd46cebe99da2b7133b0dd68d70c65473a437b"
+checksum = "340a09581f29809fc0df82a3955501dc7f2a21f887e5d1c13dbe288fe1c0bef4"
 dependencies = [
  "arrayvec",
  "grid",
@@ -5829,11 +6307,11 @@ dependencies = [
 
 [[package]]
 name = "thiserror"
-version = "2.0.19"
+version = "2.0.20"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "09a43598840e33d5b0331f38c5e30d13bb11c11210a4b58f0d9b18a5a5eefcd9"
+checksum = "ec86235f5fcc2a73650310756d2ac5b138a5780bbbdfae3eeccec992c435ba4f"
 dependencies = [
- "thiserror-impl 2.0.19",
+ "thiserror-impl 2.0.20",
 ]
 
 [[package]]
@@ -5849,13 +6327,13 @@ dependencies = [
 
 [[package]]
 name = "thiserror-impl"
-version = "2.0.19"
+version = "2.0.20"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "43cbfe0cf76104d42a574802844187e84a305e531ed54455f11fbde0f10541cd"
+checksum = "bc04cd3e1236dd4a98afca4569f2deb3f120e5422a4023be2cb683f8486292af"
 dependencies = [
  "proc-macro2",
  "quote",
- "syn 3.0.3",
+ "syn 3.0.5",
 ]
 
 [[package]]
@@ -5874,9 +6352,9 @@ dependencies = [
 
 [[package]]
 name = "time"
-version = "0.3.54"
+version = "0.3.55"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "3e1d5e639ff6bab73cb6885cc7e7b1de96c3f32c68ec55f3952614bec1092244"
+checksum = "cdb87b95ec50ddfa440816d227a17b2ccbdda963a316a727fda0fc4334f7d134"
 dependencies = [
  "deranged",
  "num-conv",
@@ -5929,9 +6407,9 @@ dependencies = [
 
 [[package]]
 name = "tinystr"
-version = "0.8.3"
+version = "0.8.4"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "c8323304221c2a851516f22236c5722a72eaa19749016521d6dff0824447d96d"
+checksum = "b1e27c91459209c2986af3dcf603a5a74a4368754ce37414f59acc971167f643"
 dependencies = [
  "displaydoc",
  "serde_core",
@@ -5940,9 +6418,9 @@ dependencies = [
 
 [[package]]
 name = "tinyvec"
-version = "1.12.0"
+version = "1.13.2"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "bb4ebadaa0af04fab11ae01eb5f9fdb5f9c5b875506e210e71c07873528baa7f"
+checksum = "4cf0ded5c4e56918d8f8a339e1bb67d038d3bc6d144ac407904015ba2e4cde9b"
 dependencies = [
  "tinyvec_macros",
 ]
@@ -5961,7 +6439,7 @@ checksum = "202caea871b69668250d242070849eb495be178ed697a3e98aebce5bc81a0bed"
 dependencies = [
  "bytes",
  "libc",
- "mio",
+ "mio 1.2.3",
  "parking_lot",
  "pin-project-lite",
  "signal-hook-registry",
@@ -5972,30 +6450,20 @@ dependencies = [
 
 [[package]]
 name = "tokio-macros"
-version = "2.7.1"
+version = "2.7.2"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "6328af13490e73a9b4694030fafd93f8c8c6a9dede33e821c3fc63eddf8042ba"
+checksum = "78773a2a397f451582ce068015985c33193cf6dea8b74d2a639fe457b2f07b0e"
 dependencies = [
  "proc-macro2",
  "quote",
- "syn 2.0.119",
-]
-
-[[package]]
-name = "tokio-native-tls"
-version = "0.3.1"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "bbae76ab933c85776efabc971569dd6119c580d8f5d448769dec1764bf796ef2"
-dependencies = [
- "native-tls",
- "tokio",
+ "syn 3.0.5",
 ]
 
 [[package]]
 name = "tokio-rustls"
-version = "0.26.4"
+version = "0.26.5"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "1729aa945f29d91ba541258c8df89027d5792d85a8841fb65e8bf0f4ede4ef61"
+checksum = "b0c85f2c3ef0b1cd58b36682f4b17aaa995f0e5db534d85692b4903abce21f67"
 dependencies = [
  "rustls",
  "tokio",
@@ -6074,9 +6542,9 @@ dependencies = [
 
 [[package]]
 name = "toml_parser"
-version = "1.1.2+spec-1.1.0"
+version = "1.1.3+spec-1.1.0"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "a2abe9b86193656635d2411dc43050282ca48aa31c2451210f4202550afb7526"
+checksum = "1d38ac1cf9b95face32296c0a3ede1fdc270627c9d9c02a7274dd6d960dc4d56"
 dependencies = [
  "winnow 1.0.4",
 ]
@@ -6166,22 +6634,23 @@ dependencies = [
 
 [[package]]
 name = "tree-sitter"
-version = "0.24.7"
+version = "0.25.10"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "a5387dffa7ffc7d2dae12b50c6f7aab8ff79d6210147c6613561fc3d474c6f75"
+checksum = "78f873475d258561b06f1c595d93308a7ed124d9977cb26b148c2084a4a3cc87"
 dependencies = [
  "cc",
  "regex",
  "regex-syntax",
+ "serde_json",
  "streaming-iterator",
  "tree-sitter-language",
 ]
 
 [[package]]
 name = "tree-sitter-language"
-version = "0.1.7"
+version = "0.1.8"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "009994f150cc0cd50ff54917d5bc8bffe8cad10ca10d81c34da2ec421ae61782"
+checksum = "ca0d1bf6fdd806e43ae5198f82f527056d359def39e54e67a0f478ac09dac081"
 
 [[package]]
 name = "tree-sitter-python"
@@ -6195,9 +6664,9 @@ dependencies = [
 
 [[package]]
 name = "tree-sitter-rust"
-version = "0.23.3"
+version = "0.24.2"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "ca8ccb3e3a3495c8a943f6c3fd24c3804c471fd7f4f16087623c7fa4c0068e8a"
+checksum = "439e577dbe07423ec2582ac62c7531120dbfccfa6e5f92406f93dd271a120e45"
 dependencies = [
  "cc",
  "tree-sitter-language",
@@ -6245,9 +6714,9 @@ dependencies = [
 
 [[package]]
 name = "uint"
-version = "0.10.0"
+version = "0.10.1"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "909988d098b2f738727b161a106cfc7cab00c539c2687a8836f8e565976fb53e"
+checksum = "6f9227a75a5a540a464c832ad4a4195dbdbecd8787610a56262721fde6f04f90"
 dependencies = [
  "byteorder",
  "crunchy",
@@ -6258,7 +6727,7 @@ dependencies = [
 [[package]]
 name = "umbral"
 version = "0.1.0"
-source = "git+https://git.tawasuyu.net/tawasuyu/tawasuyu.git?tag=v0.2.0#a446bb3a73a0e022158cc0ec7deb65173859644b"
+source = "git+https://git.tawasuyu.net/tawasuyu/tawasuyu.git?tag=v0.3.0#36a1eabf32eb77f901869f516a9be352f3d17d30"
 
 [[package]]
 name = "unicode-ident"
@@ -6324,66 +6793,104 @@ version = "1.0.4"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be"
 
+[[package]]
+name = "utf8parse"
+version = "0.2.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821"
+
 [[package]]
 name = "uuid"
-version = "1.24.0"
+version = "1.26.0"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "bf3923a6f5c4c6382e0b653c4117f48d631ea17f38ed86e2a828e6f7412f5239"
+checksum = "b5772d71c9be8a8a6ac2117d949c5b224c1b72241bb611d9a3012edcf8af7812"
 dependencies = [
  "getrandom 0.4.3",
  "js-sys",
  "serde_core",
+ "uuid-rng-internal",
  "wasm-bindgen",
 ]
 
 [[package]]
-name = "vcpkg"
-version = "0.2.15"
+name = "uuid-rng-internal"
+version = "1.26.0"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426"
+checksum = "ee9ad27c58031a66157ac3b5c93e65068357eeeaceaa658a6163d4e5befb4a06"
+dependencies = [
+ "getrandom 0.4.3",
+]
 
 [[package]]
 name = "vello"
-version = "0.7.0"
+version = "0.9.0"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "72fef40773530322d5c2ffe3c1107e9874bd8239ac137d1c2b6c1edad695146e"
+checksum = "261359dbef879f8110ef7e1c442246c838d33d3d91cb05e0ea9288d432760c9f"
 dependencies = [
  "bytemuck",
  "futures-intrusive",
  "log",
  "peniko",
- "png 0.17.16",
- "skrifa 0.40.0",
+ "png",
+ "skrifa 0.42.1",
  "static_assertions",
- "thiserror 2.0.19",
+ "thiserror 2.0.20",
  "vello_encoding",
  "vello_shaders",
  "wgpu",
 ]
 
+[[package]]
+name = "vello_common"
+version = "0.0.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "19d672facaa2d697285a786cd9d44d614cd2ce54cdc022504bf339f8fff3b750"
+dependencies = [
+ "bytemuck",
+ "fearless_simd",
+ "guillotiere",
+ "hashbrown 0.17.1",
+ "log",
+ "peniko",
+ "smallvec",
+ "thiserror 2.0.20",
+]
+
+[[package]]
+name = "vello_cpu"
+version = "0.0.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "588691169aed86b5c8fb487266afee01323234e6fd0a3f2aaec0eaa8e4007f23"
+dependencies = [
+ "bytemuck",
+ "glifo",
+ "hashbrown 0.17.1",
+ "vello_common",
+]
+
 [[package]]
 name = "vello_encoding"
-version = "0.7.0"
+version = "0.9.0"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "24c91203ec4b483440614a9a5c7c2d991932af72c5349659a63ec49476f0b79c"
+checksum = "2346f5f0d7dccb3582fcd397b4a57b43165209f1424e0d76e85dd814db164af7"
 dependencies = [
  "bytemuck",
  "guillotiere",
  "peniko",
- "skrifa 0.40.0",
+ "skrifa 0.42.1",
  "smallvec",
 ]
 
 [[package]]
 name = "vello_shaders"
-version = "0.7.0"
+version = "0.9.0"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "7a765d44d4bd354146e44f9a860f4e92effd91a97302549be9e47f0a18d8128c"
+checksum = "9dd38937516fa4b47423d9255bb5e4a65e839ec9d57c38c4af6189ce56bf46b7"
 dependencies = [
  "bytemuck",
  "log",
  "naga",
- "thiserror 2.0.19",
+ "thiserror 2.0.20",
  "vello_encoding",
 ]
 
@@ -6429,22 +6936,23 @@ dependencies = [
 
 [[package]]
 name = "wasm-bindgen"
-version = "0.2.126"
+version = "0.2.128"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "4b067c0c11094aef6b7a801c1e34a26affafdf3d051dba08456b868789aaf9a4"
+checksum = "aecb87a33d3b0c5e3b7aa46336eaf486cffafbd281b195e4c8b80d50df2351bf"
 dependencies = [
  "cfg-if",
  "once_cell",
  "rustversion",
+ "serde",
  "wasm-bindgen-macro",
  "wasm-bindgen-shared",
 ]
 
 [[package]]
 name = "wasm-bindgen-futures"
-version = "0.4.76"
+version = "0.4.78"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "c62df1340f32221cb9c54d6a27b030e3dba64361d4a95bed55f9aacb44da291d"
+checksum = "6ef4c5d3d2cdf5c54f4231181768f5510842e350db025faf1f7163b1030ed928"
 dependencies = [
  "js-sys",
  "wasm-bindgen",
@@ -6452,9 +6960,9 @@ dependencies = [
 
 [[package]]
 name = "wasm-bindgen-macro"
-version = "0.2.126"
+version = "0.2.128"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "167ce5e579f6bcf889c4f7175a8a5a585de84e8ff93976ce393efa5f2837aab1"
+checksum = "a690d511e3c1a8b3a55e33511e3c2c00c78415cd23650f32b808627f5696b9ed"
 dependencies = [
  "quote",
  "wasm-bindgen-macro-support",
@@ -6462,31 +6970,44 @@ dependencies = [
 
 [[package]]
 name = "wasm-bindgen-macro-support"
-version = "0.2.126"
+version = "0.2.128"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "f3997c7839262f4ef12cf90b818d6340c18e80f263f1a94bf157d0ec4420380e"
+checksum = "411e4887f0071ef2d2164a9d5fdf2d20efbef78fccd3a78b0c10a1dc5295e48a"
 dependencies = [
  "bumpalo",
  "proc-macro2",
  "quote",
- "syn 2.0.119",
+ "syn 3.0.5",
  "wasm-bindgen-shared",
 ]
 
 [[package]]
 name = "wasm-bindgen-shared"
-version = "0.2.126"
+version = "0.2.128"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "dc1b4cb0cc549fcf58d7dfc081778139b3d283a081644e833e84682ad71cea24"
+checksum = "81941cd78d0c92026c33e5e01312845a4cb1e9af3407f9134b100dd03144103e"
 dependencies = [
  "unicode-ident",
 ]
 
+[[package]]
+name = "wawa-config"
+version = "0.1.0"
+source = "git+https://git.tawasuyu.net/tawasuyu/tawasuyu.git?tag=v0.3.0#36a1eabf32eb77f901869f516a9be352f3d17d30"
+dependencies = [
+ "directories",
+ "notify",
+ "serde",
+ "serde_json",
+ "thiserror 2.0.20",
+ "tracing",
+]
+
 [[package]]
 name = "wayland-backend"
-version = "0.3.16"
+version = "0.3.17"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "016ccf01d1c58b6f8999612813e17c9b2390f7d70671428869913310f83f54b8"
+checksum = "38a91b4eaddff87b1cd1074985e3713da4af2c49742d1b356b2c01670a67a078"
 dependencies = [
  "cc",
  "downcast-rs",
@@ -6593,9 +7114,9 @@ dependencies = [
 
 [[package]]
 name = "web-sys"
-version = "0.3.103"
+version = "0.3.105"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "8622dcb61c0bcc9fffa6938bed81210af2da9a7e4a1a834b2e37a59b6dfb6141"
+checksum = "9fbddc4a036f00ec4f18c83445bd3115cb306a91da554919a099d9222fe4a7f8"
 dependencies = [
  "js-sys",
  "wasm-bindgen",
@@ -6628,12 +7149,13 @@ checksum = "a28ac98ddc8b9274cb41bb4d9d4d5c425b6020c50c46f25559911905610b4a88"
 
 [[package]]
 name = "wgpu"
-version = "27.0.1"
+version = "29.0.4"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "bfe68bac7cde125de7a731c3400723cadaaf1703795ad3f4805f187459cd7a77"
+checksum = "76e8840e1ba2881d4cbb18d2147627a56af426ff064c0401eb0c8410c6325d07"
 dependencies = [
  "arrayvec",
  "bitflags 2.13.1",
+ "bytemuck",
  "cfg-if",
  "cfg_aliases",
  "document-features",
@@ -6657,9 +7179,9 @@ dependencies = [
 
 [[package]]
 name = "wgpu-core"
-version = "27.0.3"
+version = "29.0.4"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "27a75de515543b1897b26119f93731b385a19aea165a1ec5f0e3acecc229cae7"
+checksum = "2f519832254e56965a9940c4af57dcb75f702b6f6fa4a0b172f685395843a4d7"
 dependencies = [
  "arrayvec",
  "bit-set",
@@ -6679,60 +7201,69 @@ dependencies = [
  "raw-window-handle",
  "rustc-hash 1.1.0",
  "smallvec",
- "thiserror 2.0.19",
+ "thiserror 2.0.20",
  "wgpu-core-deps-apple",
  "wgpu-core-deps-emscripten",
+ "wgpu-core-deps-wasm",
  "wgpu-core-deps-windows-linux-android",
  "wgpu-hal",
+ "wgpu-naga-bridge",
  "wgpu-types",
 ]
 
 [[package]]
 name = "wgpu-core-deps-apple"
-version = "27.0.0"
+version = "29.0.4"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "0772ae958e9be0c729561d5e3fd9a19679bcdfb945b8b1a1969d9bfe8056d233"
+checksum = "f5e39e26c4c0e07589e67d18546cf79ff45383659fc72fca4dd293358a0347f3"
 dependencies = [
  "wgpu-hal",
 ]
 
 [[package]]
 name = "wgpu-core-deps-emscripten"
-version = "27.0.0"
+version = "29.0.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "01e09be551dc939498bdd5f6b2c66e55ab275dad25825267a08605a80fc9f0af"
+dependencies = [
+ "wgpu-hal",
+]
+
+[[package]]
+name = "wgpu-core-deps-wasm"
+version = "29.0.4"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "b06ac3444a95b0813ecfd81ddb2774b66220b264b3e2031152a4a29fda4da6b5"
+checksum = "af1fb1798be2a912497d4c224f72d39bb0cb34af50e8bcc29865bc339c943059"
 dependencies = [
  "wgpu-hal",
 ]
 
 [[package]]
 name = "wgpu-core-deps-windows-linux-android"
-version = "27.0.0"
+version = "29.0.4"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "71197027d61a71748e4120f05a9242b2ad142e3c01f8c1b47707945a879a03c3"
+checksum = "4e592c1bbef6ad047647ae6e666ebd8cee7a32bb4544d9700ec96cbf73230257"
 dependencies = [
  "wgpu-hal",
 ]
 
 [[package]]
 name = "wgpu-hal"
-version = "27.0.4"
+version = "29.0.4"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "5b21cb61c57ee198bc4aff71aeadff4cbb80b927beb912506af9c780d64313ce"
+checksum = "97ace1c17727311c22a46e4e3faf56ea6de81af99dcc839bdfb54857b94d448d"
 dependencies = [
  "android_system_properties",
  "arrayvec",
  "ash",
  "bit-set",
  "bitflags 2.13.1",
- "block",
+ "block2 0.6.2",
  "bytemuck",
  "cfg-if",
  "cfg_aliases",
- "core-graphics-types 0.2.0",
  "glow",
  "glutin_wgl_sys",
- "gpu-alloc",
  "gpu-allocator",
  "gpu-descriptor",
  "hashbrown 0.16.1",
@@ -6741,10 +7272,13 @@ dependencies = [
  "libc",
  "libloading",
  "log",
- "metal",
  "naga",
  "ndk-sys",
- "objc",
+ "objc2 0.6.4",
+ "objc2-core-foundation",
+ "objc2-foundation 0.3.2",
+ "objc2-metal 0.3.2",
+ "objc2-quartz-core 0.3.2",
  "once_cell",
  "ordered-float",
  "parking_lot",
@@ -6753,27 +7287,41 @@ dependencies = [
  "profiling",
  "range-alloc",
  "raw-window-handle",
+ "raw-window-metal",
  "renderdoc-sys",
  "smallvec",
- "thiserror 2.0.19",
+ "thiserror 2.0.20",
  "wasm-bindgen",
+ "wayland-sys",
  "web-sys",
+ "wgpu-naga-bridge",
+ "wgpu-types",
+ "windows 0.62.2",
+ "windows-core 0.62.2",
+ "windows-result 0.4.1",
+]
+
+[[package]]
+name = "wgpu-naga-bridge"
+version = "29.0.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "95226013f547544b223281cd16a4fb549aa9dcb562adbda0faae4c73ffbbc161"
+dependencies = [
+ "naga",
  "wgpu-types",
- "windows 0.58.0",
- "windows-core 0.58.0",
 ]
 
 [[package]]
 name = "wgpu-types"
-version = "27.0.1"
+version = "29.0.4"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "afdcf84c395990db737f2dd91628706cb31e86d72e53482320d368e52b5da5eb"
+checksum = "84bf84cd9ca8ca45e2b223a3868f1adf9bfc0c66aeac212e76ee7e40fdadf8f5"
 dependencies = [
  "bitflags 2.13.1",
  "bytemuck",
  "js-sys",
  "log",
- "thiserror 2.0.19",
+ "raw-window-handle",
  "web-sys",
 ]
 
@@ -6786,7 +7334,7 @@ checksum = "72069c3113ab32ab29e5584db3c6ec55d416895e60715417b5b883a357c3e471"
 [[package]]
 name = "willay-checkpoint"
 version = "0.1.0"
-source = "git+https://git.tawasuyu.net/tawasuyu/tawasuyu.git?tag=v0.2.0#a446bb3a73a0e022158cc0ec7deb65173859644b"
+source = "git+https://git.tawasuyu.net/tawasuyu/tawasuyu.git?tag=v0.3.0#36a1eabf32eb77f901869f516a9be352f3d17d30"
 dependencies = [
  "fork-proof",
  "format",
@@ -6798,19 +7346,22 @@ dependencies = [
 [[package]]
 name = "willay-core"
 version = "0.1.0"
-source = "git+https://git.tawasuyu.net/tawasuyu/tawasuyu.git?tag=v0.2.0#a446bb3a73a0e022158cc0ec7deb65173859644b"
+source = "git+https://git.tawasuyu.net/tawasuyu/tawasuyu.git?tag=v0.3.0#36a1eabf32eb77f901869f516a9be352f3d17d30"
 dependencies = [
  "blake3",
+ "postcard",
  "serde",
 ]
 
 [[package]]
 name = "willay-emit"
 version = "0.1.0"
-source = "git+https://git.tawasuyu.net/tawasuyu/tawasuyu.git?tag=v0.2.0#a446bb3a73a0e022158cc0ec7deb65173859644b"
+source = "git+https://git.tawasuyu.net/tawasuyu/tawasuyu.git?tag=v0.3.0#36a1eabf32eb77f901869f516a9be352f3d17d30"
 dependencies = [
  "anyhow",
+ "clap",
  "postcard",
+ "socket-local",
  "willay-core",
 ]
 
@@ -7249,12 +7800,12 @@ dependencies = [
  "android-activity",
  "atomic-waker",
  "bitflags 2.13.1",
- "block2",
+ "block2 0.5.1",
  "bytemuck",
  "calloop",
  "cfg_aliases",
  "concurrent-queue",
- "core-foundation 0.9.4",
+ "core-foundation",
  "core-graphics",
  "cursor-icon",
  "dpi",
@@ -7325,7 +7876,7 @@ dependencies = [
  "log",
  "os_pipe",
  "rustix 1.1.4",
- "thiserror 2.0.19",
+ "thiserror 2.0.20",
  "tree_magic_mini",
  "wayland-backend",
  "wayland-client",
@@ -7335,9 +7886,9 @@ dependencies = [
 
 [[package]]
 name = "writeable"
-version = "0.6.3"
+version = "0.6.4"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4"
+checksum = "3ad82d2a33cdc9674dc7465672f271e096168fcdbe0f799d9e6db8c5892679dc"
 
 [[package]]
 name = "x11-dl"
@@ -7396,15 +7947,15 @@ dependencies = [
  "nom 7.1.3",
  "oid-registry",
  "rusticata-macros",
- "thiserror 2.0.19",
+ "thiserror 2.0.20",
  "time",
 ]
 
 [[package]]
 name = "xcursor"
-version = "0.3.10"
+version = "0.3.11"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "bec9e4a500ca8864c5b47b8b482a73d62e4237670e5b5f1d6b9e3cae50f28f2b"
+checksum = "163b33ed8786455e2fa5d72f554057ce3f3182425434f756cd39c99839d88e23"
 
 [[package]]
 name = "xkbcommon-dl"
@@ -7427,9 +7978,9 @@ checksum = "b9cc00251562a284751c9973bace760d86c0276c471b4be569fe6b068ee97a56"
 
 [[package]]
 name = "xml-rs"
-version = "0.8.28"
+version = "0.8.29"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "3ae8337f8a065cfc972643663ea4279e04e7256de865aa66fe25cec5fb912d3f"
+checksum = "e450f9b2ed1dff33c94c12589a87338689467b9c4f5d8a5710bd09a847d2c8a7"
 
 [[package]]
 name = "xmltree"
@@ -7451,7 +8002,7 @@ dependencies = [
  "nohash-hasher",
  "parking_lot",
  "pin-project",
- "rand 0.8.7",
+ "rand 0.8.8",
  "static_assertions",
 ]
 
@@ -7520,11 +8071,22 @@ dependencies = [
  "synstructure",
 ]
 
+[[package]]
+name = "yupay-core"
+version = "0.1.0"
+source = "git+https://git.tawasuyu.net/tawasuyu/tawasuyu.git?tag=v0.3.0#36a1eabf32eb77f901869f516a9be352f3d17d30"
+dependencies = [
+ "krama-expr",
+ "rust_decimal",
+ "serde",
+ "thiserror 2.0.20",
+]
+
 [[package]]
 name = "zbus"
-version = "5.18.0"
+version = "5.19.0"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "fe18fb60dc696039e738717b76eaea21e7a4489bbb1885020b43c94236d7e98a"
+checksum = "5db4be7c075cb421e4b7ee645541604239bd243ba7c357511f4ff3a74b555907"
 dependencies = [
  "async-broadcast",
  "async-executor",
@@ -7581,14 +8143,14 @@ dependencies = [
 
 [[package]]
 name = "zbus_macros"
-version = "5.18.0"
+version = "5.19.0"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "fe96480bed92df2b442a1a30df364e12d08eed03aeb061f2b8dc6afb2be91119"
+checksum = "2990635d09ade6df1868f72f8cac69a876a90981e8bd3c40b1be413f8dc88f40"
 dependencies = [
  "proc-macro-crate",
  "proc-macro2",
  "quote",
- "syn 2.0.119",
+ "syn 3.0.5",
  "zbus_names",
  "zvariant",
  "zvariant_utils",
@@ -7617,6 +8179,15 @@ dependencies = [
  "zvariant",
 ]
 
+[[package]]
+name = "zcheapstr"
+version = "1.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d1afec51604565183aeb5c54c20aeab286120d4e4460f7f76e3e8bb8c0d99473"
+dependencies = [
+ "serde",
+]
+
 [[package]]
 name = "zeno"
 version = "0.3.3"
@@ -7625,18 +8196,18 @@ checksum = "6df3dc4292935e51816d896edcd52aa30bc297907c26167fec31e2b0c6a32524"
 
 [[package]]
 name = "zerocopy"
-version = "0.8.55"
+version = "0.8.56"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "b5a105cd7b140f6eeec8acff2ea38135d3cab283ada58540f629fe51e46696eb"
+checksum = "556764e583adb45a9f8d413c2a147fa7e8d821e48e12b14fd560b607998b75eb"
 dependencies = [
  "zerocopy-derive",
 ]
 
 [[package]]
 name = "zerocopy-derive"
-version = "0.8.55"
+version = "0.8.56"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "0fe976fb70c78cd64cccfe3a6fc142244e8a77b70959b30faf9d0ac37ee228eb"
+checksum = "f2ab42fc20575779bd240faa45f94a74256f755c0fa9e89f0ede20d91d0cdfc1"
 dependencies = [
  "proc-macro2",
  "quote",
@@ -7686,9 +8257,9 @@ dependencies = [
 
 [[package]]
 name = "zerotrie"
-version = "0.2.4"
+version = "0.2.5"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "0f9152d31db0792fa83f70fb2f83148effb5c1f5b8c7686c3459e361d9bc20bf"
+checksum = "4ea269c3bd32f0a32c321907a2ae912ba6f4649bb0fc764a15627e99a7095a3f"
 dependencies = [
  "displaydoc",
  "yoke",
@@ -7697,9 +8268,9 @@ dependencies = [
 
 [[package]]
 name = "zerovec"
-version = "0.11.6"
+version = "0.11.8"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "90f911cbc359ab6af17377d242225f4d75119aec87ea711a880987b18cd7b239"
+checksum = "bb0464e17806c1d976d5cba29399c7f08e516e279e2ba493f63123b5fca67dd8"
 dependencies = [
  "serde",
  "yoke",
@@ -7709,15 +8280,21 @@ dependencies = [
 
 [[package]]
 name = "zerovec-derive"
-version = "0.11.3"
+version = "0.11.6"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "625dc425cab0dca6dc3c3319506e6593dcb08a9f387ea3b284dbd52a92c40555"
+checksum = "34df6fc39dbd26ddc9c10e6a2984476e13acce22e64e4487636ef494369225da"
 dependencies = [
  "proc-macro2",
  "quote",
- "syn 2.0.119",
+ "syn 3.0.5",
 ]
 
+[[package]]
+name = "zlib-rs"
+version = "0.6.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "34b31d188d9d685a4f9c7b46d6e36631b07058d2cfe190267adce54dc230bf12"
+
 [[package]]
 name = "zmij"
 version = "1.0.23"
@@ -7726,9 +8303,9 @@ checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b"
 
 [[package]]
 name = "zune-core"
-version = "0.5.1"
+version = "0.5.3"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "cb8a0807f7c01457d0379ba880ba6322660448ddebc890ce29bb64da71fb40f9"
+checksum = "d56377fd46368984a170bc5aac5567e52ca5da874caa60bea39fcbca78fb658b"
 
 [[package]]
 name = "zune-jpeg"
@@ -7741,40 +8318,41 @@ dependencies = [
 
 [[package]]
 name = "zvariant"
-version = "5.13.1"
+version = "5.15.0"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "bee2a0bcd2a907786a456fff45aaaaf54c9ba5f50b71ae9ec1a4edd200c94911"
+checksum = "c1d34c27cc6cdd1f458427519dd6b8612f7b7e3f7b9a0b2355d041dda9869147"
 dependencies = [
  "endi",
  "enumflags2",
  "serde",
  "winnow 1.0.4",
+ "zcheapstr",
  "zvariant_derive",
  "zvariant_utils",
 ]
 
 [[package]]
 name = "zvariant_derive"
-version = "5.13.1"
+version = "5.15.0"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "38a708216a18780796770bfe3f4739c7c83a3e8f789b755534bbbc06e4e23e12"
+checksum = "864155e69b4352db0c7f374917bf45d1e0c8d17659c8b3dbf9795f3673f8c497"
 dependencies = [
  "proc-macro-crate",
  "proc-macro2",
  "quote",
- "syn 2.0.119",
+ "syn 3.0.5",
  "zvariant_utils",
 ]
 
 [[package]]
 name = "zvariant_utils"
-version = "3.5.0"
+version = "4.2.0"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "90cb9383f9b45290407a1258b202d3f8f01db719eb60b4e4055c6375af4fc7c7"
+checksum = "bad0294361a320b694a328460dc73add56c306150f5cb6bfafc44446120008a3"
 dependencies = [
  "proc-macro2",
  "quote",
  "serde",
- "syn 2.0.119",
+ "syn 3.0.5",
  "winnow 1.0.4",
 ]
diff --git a/Cargo.toml b/Cargo.toml
index b7931d2..e5778e7 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -1,6 +1,6 @@
 # Cargo.toml raíz STANDALONE de khipu — front-door público.
 # GENERADO por scripts/actualizar-standalone.py desde el monorepo tawasuyu.git
-# (v0.2.0). No editar a mano: se regenera. Lo fundacional se consume por
+# (v0.3.0). No editar a mano: se regenera. Lo fundacional se consume por
 # git-dep al tag; sólo el código del dominio vive en este repo.
 [workspace]
 resolver = "2"
@@ -9,10 +9,12 @@ resolver = "2"
 # Cuadrantes — agregar a medida que se migran
 # ============================================================
 members = [
+    "00_unanchay/khipu/khipu-android",
     "00_unanchay/khipu/khipu-app",
     "00_unanchay/khipu/khipu-brahman",
     "00_unanchay/khipu/khipu-core",
     "00_unanchay/khipu/khipu-gravity",
+    "00_unanchay/khipu/khipu-redaccion",
     "00_unanchay/khipu/khipu-share",
 ]
 
@@ -20,43 +22,88 @@ members = [
 # `x86_64-unknown-none` y `panic = "abort"`, incompatibles con los
 # perfiles globales. Los crates compartidos se referencian por `path`
 # cruzando la frontera.
+# `llimphi-wawa` está EXCLUIDA, no ausente por olvido. Enciende
+# `llimphi-text/cpu`, que es el backend EXCLUYENTE del `vello` que todo el resto
+# del workspace usa por default — y la unificación de features de cargo los
+# junta, disparando el `compile_error!` que llimphi-text tiene justo para
+# delatarlo. Vive en el mismo mundo que el kernel: se compila aparte, con su
+# propio lock. Sus tests se corren así:
+#   cargo test --manifest-path 02_ruway/llimphi/llimphi-wawa/Cargo.toml \
+#     --no-default-features --features std
+#
+# `llimphi-jaula` está excluida por lo MISMO, del otro lado: enciende
+# `llimphi-text/wire`, el tercer backend excluyente (W4). Es la cáscara de una
+# app dentro de la jaula WASM — el gemelo de `llimphi-wawa`—, y que las dos estén
+# afuera del workspace no es una molestia sino el diseño: son dos procesos en dos
+# lados de una frontera, y forzarlos a compartir un lock sería fingir lo
+# contrario. Sus tests:
+#   cargo test --manifest-path 02_ruway/llimphi/llimphi-jaula/Cargo.toml \
+#     --no-default-features --features std
+#
+# `llimphi-hybrid-android` está excluida **por contagio**: consume `llimphi-wawa`
+# y por lo tanto arrastra el mismo `llimphi-text/cpu`. Es la tercera cáscara de
+# la familia —kernel, jaula, teléfono— y cae del mismo lado de la frontera por
+# el mismo motivo. Se compila así:
+#   cargo check --manifest-path \
+#     02_ruway/llimphi/android/llimphi-hybrid-android/Cargo.toml
+# y se empaca con `02_ruway/llimphi/android/scripts/build-android.sh`.
 
 [workspace.package]
 version = "0.1.0"
 edition = "2021"
 rust-version = "1.80"
 license = "MIT"
-authors = ["Sergio <gerencia@jlsoltech.com>"]
+authors = ["Sergio <sergio@gioser.net>"]
 publish = false
 repository = "https://git.tawasuyu.net/tawasuyu/khipu"
 
 [workspace.dependencies]
 # === Configuración declarativa (vocabulario de esquemas) ===
-allichay = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.2.0" }
-marca = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.2.0" }
+allichay = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.3.0" }
+allichay-plano = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.3.0" }
+marca = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.3.0" }
+# Escala única de `nice` de la sesión (compositor · marco · app · segundo plano
+# · lote) + las tres formas de aplicarla (propia / pid vivo / pre_exec).
+prioridad = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.3.0" }
+ron-ralo = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.3.0" }
 # Telemetría local de desarrollo (stderr+panics+tracing → $XDG_STATE_HOME/tawasuyu/<dominio>/<bin>.log)
-bitacora = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.2.0" }
+bitacora = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.3.0" }
 # default = [] (núcleo liviano sin vello); `bake` es opt-in. Declaramos
 # default-features = false aquí para que el `default-features = false` de los
 # consumidores (splash/greeter/compositor) se honre y no quede el warning.
-mirada-fondo = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.2.0" }
-mirada-teclado-core = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.2.0" }
-mirada-teclado-widget = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.2.0" }
+mirada-fondo = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.3.0" }
+mirada-teclado-core = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.3.0" }
+mirada-teclado-widget = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.3.0" }
 # === Registro de apps / menú global ===
-app-bus = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.2.0" }
-app-iconset = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.2.0" }
+app-bus = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.3.0" }
+app-iconset = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.3.0" }
 # === Centro de eventos (notificaciones + capturas + clipboard + …) ===
-willay-core = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.2.0" }
-willay-checkpoint = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.2.0" }
-willay-hilo = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.2.0" }
-willay-store = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.2.0" }
-willay-emit = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.2.0" }
-rag-motor = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.2.0" }
-churay-core = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.2.0" }
-churay-welcome = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.2.0" }
-churay-welcome-runner = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.2.0" }
+willay-core = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.3.0" }
+willay-checkpoint = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.3.0" }
+willay-hilo = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.3.0" }
+willay-store = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.3.0" }
+socket-local = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.3.0" }
+willay-emit = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.3.0" }
+sandokan-seguridad-core = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.3.0" }
+sandokan-salud-core = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.3.0" }
+sandokan-monitor-core = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.3.0" }
+# El censo de red de la máquina (netstat completo: socket → proceso → unidad) +
+# huella de red + drift de puertos + contabilidad por unidad. Ver `shared/PLAN-RED.md`.
+sandokan-red-core = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.3.0" }
+# La baliza de LAN: el descubridor por multicast, con el payload opaco. Promovido
+# de `uya-app::lan`, que es el de los tres del arbol que resuelve los tres modos
+# de fallo reales. Ver `shared/PLAN-RED.md` §F1.
+baliza = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.3.0" }
+matilda-apply = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.3.0" }
+matilda-plan = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.3.0" }
+matilda-core = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.3.0" }
+matilda-discover = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.3.0" }
+rag-motor = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.3.0" }
+churay-core = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.3.0" }
+churay-welcome = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.3.0" }
+churay-welcome-runner = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.3.0" }
 # === Puente de drag-and-drop compositor → app (suple winit en Wayland) ===
-drop-bridge = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.2.0" }
+drop-bridge = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.3.0" }
 # === Serialización ===
 serde = { version = "1", features = ["derive"] }
 serde_json = "1"
@@ -82,6 +129,10 @@ futures = "0.3"
 tracing = "0.1"
 tracing-subscriber = { version = "0.3", features = ["env-filter", "fmt"] }
 
+# Sonda de librerías del sistema en build.rs. La usa `auth-huellita` para
+# detectar `libfprint` sin volverla una dependencia dura del workspace.
+pkg-config = "0.3"
+
 # === Linux primitives (arje) ===
 nix = { version = "0.29", features = ["signal", "process", "sched", "mount", "fs", "socket", "net", "user", "reboot"] }
 libc = "0.2"
@@ -97,9 +148,16 @@ sha2 = "0.10"
 blake3 = "1.5"
 ed25519-dalek = "2"
 aes-gcm = "0.10"
+chacha20 = "0.9"
 chacha20poly1305 = "0.10"
 argon2 = "0.5"
 rand = "0.8"
+# TOTP (RFC 6238) del segundo factor de nakui: HMAC-SHA1 es lo que hablan
+# Google Authenticator, Aegis y los tokens de hardware — no es elección
+# nuestra, es el formato del ecosistema. base32 para el secreto del QR.
+hmac = "0.12"
+sha1 = "0.10"
+data-encoding = "2"
 
 # === WASM (arje) ===
 # wasmi 1.0: unifica la versión con wawa (su kernel ya corre 1.0), para
@@ -132,6 +190,10 @@ unrar = "0.5"
 
 # === HTTP server (iniy-server) ===
 axum = "0.7"
+# WebSocket pelado (sin framework HTTP): lo usa `shuma-ide-host`, que sirve el
+# protocolo de integración de IDE de Claude Code en loopback. Ya estaba en el
+# lock por transitividad; acá se declara para poder usarlo directo.
+tokio-tungstenite = "0.24"
 tower = "0.5"
 
 # === ANN sobre embeddings (iniy nli --ann) ===
@@ -159,12 +221,30 @@ midly = "0.5"
 # zwlr_data_control_manager_v1 (que mirada expone) y cae a X11 si no hay Wayland.
 arboard = { version = "3", features = ["wayland-data-control"] }
 ropey = "1.6"
-tree-sitter = "0.24"
-tree-sitter-rust = "0.23"
+# El runtime lee gramáticas por ABI, y ahí está el acople: 0.24 lee hasta la 14,
+# 0.25 lee 13–15. Se subió el 2026-09-06 porque `tree-sitter-rust` 0.23.3 (ABI 14)
+# lee `&raw` como el arranque de una referencia cruda, así que **una variable
+# llamada `raw` prestada con `&`** rompe el archivo entero para la gramática:
+# 69 de 6.428 archivos de este repo (1,1 %). La 0.24.2 lo arregla, pero es ABI 15
+# y por eso arrastra al runtime. Las otras gramáticas siguen en ABI 14 y cargan
+# igual — 0.25 es compatible hacia atrás.
+tree-sitter = "0.25"
+tree-sitter-rust = "0.24"
 tree-sitter-python = "0.23"
 tree-sitter-typescript = "0.23"
 tree-sitter-javascript = "0.23"
 tree-sitter-go = "0.23"
+# C: el quinto dialecto que minga versiona por AST (el kernel y todo lo ajeno
+# que la suite absorbe está en C).
+tree-sitter-c = "0.23"
+# Dart entra por zorge (SDD-SERVIDOR-AJENO §A.1, RA-2): el otro proyecto es
+# Flutter, y Dart 3 es lo más parecido a Rust que hay del otro lado —tipado
+# estático, `sealed` con `switch` exhaustivo, y `_nombre` privado de verdad—.
+# El fork `orchard` y no `tree-sitter-dart` a secas. El motivo original era el
+# ABI: `tree-sitter-dart` se generó con la 15 y el runtime del workspace leía
+# hasta la 14. Con el runtime en 0.25 ese motivo **ya no aplica**; el fork se
+# conserva porque es lo que está probado, no porque haga falta.
+tree-sitter-dart-orchard = "0.3"
 
 # === FS notify ===
 notify = "6.1"
@@ -210,164 +290,226 @@ tempfile = "3"
 
 # === Llimphi (motor gráfico soberano) ===
 # wgpu sobre Vulkan/Metal/DX12, winit para ventana en dev Linux.
-# raw-window-handle 0.6 alinea winit 0.30 con wgpu 27.
-# vello 0.7 = rasterizador vectorial sobre wgpu 27 (renderer GPU "wgpu" + opt-in "hybrid" CPU+GPU).
-# taffy 0.9 = motor Flexbox/Grid puro Rust (ya pulled por transitivos, lo alineamos).
+# raw-window-handle 0.6 alinea winit 0.30 con wgpu 29.
+# vello 0.9 = rasterizador vectorial sobre wgpu 29 (renderer GPU "wgpu" + opt-in "hybrid" CPU+GPU).
+#
+# **El 27→29 no fue por gusto** (2026-07-28): `vello_hybrid 0.0.9` —el ÚNICO
+# rasterizador que el driver Mali del teléfono digiere, ver
+# `02_ruway/llimphi/android/DIAGNOSTICO.md`— vive en wgpu 29, y dos versiones de
+# wgpu no comparten un `Device`. La migración salió barata porque `vello 0.9`
+# usa el mismo peniko 0.6.1 y no movió su API de `Scene`: el bump de vello dio
+# CERO errores; todos los que hubo fueron deriva de wgpu, y concentrada en los
+# cuatro crates que tienen código wgpu propio.
+# taffy 0.12 = motor Flexbox/Grid puro Rust (0.9→0.12 en 2026-07-27: arregla el percent de ítem de grid en track sizing).
 # parley 0.6 = shaping/layout de texto compatible con peniko 0.6 (que vello 0.7 expone).
-wgpu = "27"
+wgpu = "29"
+# El compilador de shaders que wgpu lleva adentro. Se declara acá para que los
+# tests que validan WGSL headless (llimphi-3d, supay-render-llimphi) usen la
+# MISMA versión que después compila esos shaders en la GPU. Con dos versiones,
+# un test puede dar verde sobre un shader que el runtime rechaza — o al revés.
+naga = "29"
 winit = "0.30"
 raw-window-handle = "0.6"
 pollster = "0.4"
-vello = "0.7"
+# `default-features = false` acá arriba y no en cada consumidor: el default de
+# vello es ["wgpu","wgpu_default"], y `llimphi-text`/`llimphi-compositor` sólo
+# quieren sus TIPOS (Scene, peniko), no el motor de GPU. Declararlo abajo no
+# alcanza — cargo IGNORA `default-features` en un dep heredado si la raíz no lo
+# fijó, y lo avisa con un warning fácil de pasar por alto. Quien necesita el
+# motor lo pide por feature (ver llimphi-raster).
+vello = { version = "0.9", default-features = false }
 # Renderer hybrid CPU+GPU sin compute shaders (mejor compat WebGL2/Adreno viejas).
 # Opt-in; el renderer "wgpu" sigue siendo el default.
 vello_hybrid = "0.0.9"
 # foreign-lottie = fork vendorizado de velato 0.9 (Lottie → vello::Scene),
 # completado para no paniquear ante features no soportadas. Lo consume
 # llimphi-lottie. Ver shared/foreign-lottie.
-foreign-lottie = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.2.0" }
+foreign-lottie = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.3.0" }
 # Árbol de accesibilidad para lectores de pantalla (NVDA/VoiceOver/Orca/TalkBack).
 # Lo consume el runtime de Llimphi (iter 2/3 del plan AccessKit); el modelo
 # `SemanticsSpec` del compositor es independiente de estas crates.
 accesskit = "0.24"
 accesskit_winit = "0.33"
-taffy = "0.9"
+taffy = "0.12"
 # parley = shaping completo (bidi, ligatures, fallback CJK/emoji vía fontique, line break).
 parley = "0.6"
 # Bucle Elm (input→update→view→layout→raster→present). Lo consumen las apps.
-llimphi-ui = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.2.0" }
+# `config-wawa`: el sentido de la rueda (ratón / touchpad, por separado) sale
+# del bus `wawa-config` y se fija al arrancar cada app, con watcher para el
+# cambio en caliente. Va acá y no app por app a propósito: es una preferencia
+# del HUMANO, no de cada app, y ninguna app del monorepo debería poder
+# desobedecerla por olvido. Ver `llimphi-ui/src/rueda.rs`.
+llimphi-ui = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.3.0" }
+# El compositor DIRECTO, para que el catálogo de widgets cuelgue de él y no del
+# shell de escritorio (W4.6). Un widget que dependa de `llimphi-ui` arrastra
+# winit + accesskit + arboard + vello, y con eso queda fuera del alcance de una
+# app enjaulada; colgando de acá sirve igual en Linux, en el kernel y en la jaula.
+#
+# `default-features = false` VA ACÁ Y NO EN CADA WIDGET. Es la trampa recurrente
+# de cargo: apagar los defaults desde un miembro NO HACE NADA si el workspace
+# declaró la dep sin ellos (cargo lo avisa y lo ignora). Ya pasó con taffy, vello
+# y peniko. Sin esto, cada widget encendería el backend `vello` del compositor y
+# volvería a quedar fuera del alcance de una app enjaulada — que es justamente lo
+# que W4.6 vino a arreglar. Quien ensambla la app elige el backend: `llimphi-ui`
+# en Linux, `llimphi-jaula` en la jaula, `llimphi-wawa` en el kernel.
+llimphi-compositor = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.3.0" }
 # Tier 3 — apps WASM con UI Llimphi real. El guest pinta un WireNode (IR
 # serializable), el host (runner) lo materializa en View<Msg> y rebota los
 # eventos al update del guest. SDK = lado guest, runner = lado host.
-llimphi-wire-view = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.2.0" }
-llimphi-wasm-app-sdk = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.2.0" }
-llimphi-wasm-runner = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.2.0" }
+llimphi-wire-view = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.3.0" }
+llimphi-wire-escena = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.3.0" }
+llimphi-wasm-app-sdk = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.3.0" }
+llimphi-wasm-runner = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.3.0" }
 # Núcleo puro de distribución (CAS + verificación + resolve), SIN runner/GPU.
-llimphi-wasm-core = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.2.0" }
+llimphi-wasm-core = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.3.0" }
 # Cara con baterías: re-exporta core + puente al runner para correr la app.
-llimphi-wasm-dist = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.2.0" }
-llimphi-wasm-open = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.2.0" }
-llimphi-wasm-registry = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.2.0" }
-llimphi-wasm-wasi = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.2.0" }
+llimphi-wasm-dist = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.3.0" }
+llimphi-wasm-open = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.3.0" }
+llimphi-wasm-registry = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.3.0" }
+llimphi-wasm-wasi = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.3.0" }
 # Transporte P2P del bytecode por hash sobre BrahmanNet (card-net).
-llimphi-wasm-net = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.2.0" }
-llimphi-3d = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.2.0" }
+llimphi-wasm-net = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.3.0" }
+llimphi-3d = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.3.0" }
 # Runner wlr-layer-shell: corre un `App` de Llimphi como barra anclada a un
 # borde (no como ventana), reusando la plumbing sctk+wgpu que pata probó.
-llimphi-layer = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.2.0" }
+llimphi-layer = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.3.0" }
 # Paleta semántica compartida por las apps y los widgets.
-llimphi-theme = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.2.0" }
+llimphi-theme = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.3.0" }
 # Tweens y helpers de animación sobre el bucle Elm.
-llimphi-motion = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.2.0" }
+llimphi-motion = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.3.0" }
 # Máquina de estados de animación (estilo Rive) clip-agnóstica. Núcleo puro;
 # el render lo cablea el consumidor (llimphi-lottie). Escalón sobre el playback.
-llimphi-anim = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.2.0" }
+llimphi-anim = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.3.0" }
 # Studio de animación «rive»: además del editor (bin), expone como lib los
 # documentos serializables (Doc + RigDoc + Project) que carga mirada-fondo.
-llimphi-anim-studio = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.2.0" }
+llimphi-anim-studio = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.3.0" }
 # Render de mallas deformables (skel::Mesh) a vello Scene: vectorial y texturizada.
-llimphi-mesh = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.2.0" }
+llimphi-mesh = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.3.0" }
 # Iconos vectoriales (BezPath en grid 24×24) compartidos por todas las apps.
-llimphi-icons = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.2.0" }
+llimphi-icons = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.3.0" }
 # Puente fino vello_svg → Llimphi para SVG arbitrario (íconos .desktop, logos).
-llimphi-svg = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.2.0" }
+llimphi-svg = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.3.0" }
 # Puente fino velato (Lottie) → Llimphi para animación vectorial autorada (.json).
-llimphi-lottie = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.2.0" }
-llimphi-image = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.2.0" }
+llimphi-lottie = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.3.0" }
+llimphi-image = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.3.0" }
 # Decodificador de protocolos de gráficos de terminal (kitty/sixel) → RGBA.
-llimphi-term-graphics = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.2.0" }
+llimphi-term-graphics = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.3.0" }
 # Widgets reusables sobre llimphi-ui — uno por crate.
-llimphi-widget-app-header = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.2.0" }
-llimphi-widget-banner = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.2.0" }
-llimphi-widget-button = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.2.0" }
-llimphi-widget-rive-button = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.2.0" }
-llimphi-widget-card = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.2.0" }
-llimphi-clipboard = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.2.0" }
-llimphi-widget-context-menu = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.2.0" }
-llimphi-widget-edit-menu = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.2.0" }
-llimphi-widget-menubar = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.2.0" }
-llimphi-widget-list = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.2.0" }
-llimphi-widget-grid = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.2.0" }
-llimphi-widget-table = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.2.0" }
-llimphi-widget-color-picker = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.2.0" }
-llimphi-widget-slider = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.2.0" }
-llimphi-widget-scroll = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.2.0" }
-llimphi-widget-lazy-list = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.2.0" }
-llimphi-widget-splitter = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.2.0" }
-llimphi-widget-stat-card = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.2.0" }
-llimphi-widget-tabs = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.2.0" }
-llimphi-module-allichay = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.2.0" }
-llimphi-module-command-palette = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.2.0" }
-llimphi-module-diff-viewer = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.2.0" }
-llimphi-module-fif = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.2.0" }
-llimphi-module-file-picker = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.2.0" }
-llimphi-module-bookmarks = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.2.0" }
-llimphi-module-mini-map = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.2.0" }
-llimphi-module-shuma-term = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.2.0" }
-llimphi-module-symbol-outline = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.2.0" }
-llimphi-plugin-host = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.2.0" }
-llimphi-widget-theme-switcher = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.2.0" }
-llimphi-widget-text-area = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.2.0" }
-llimphi-widget-text-editor-core = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.2.0" }
-llimphi-widget-text-editor = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.2.0" }
-llimphi-widget-text-editor-lsp = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.2.0" }
-llimphi-widget-text-input = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.2.0" }
-llimphi-widget-tiled = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.2.0" }
-llimphi-widget-nodegraph = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.2.0" }
-llimphi-widget-tree = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.2.0" }
-grafo-nav = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.2.0" }
-llimphi-widget-navigator = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.2.0" }
-llimphi-widget-detail-table = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.2.0" }
-llimphi-widget-select = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.2.0" }
-llimphi-widget-terminal = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.2.0" }
+llimphi-widget-app-header = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.3.0" }
+llimphi-widget-banner = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.3.0" }
+llimphi-widget-button = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.3.0" }
+llimphi-widget-rive-button = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.3.0" }
+llimphi-widget-card = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.3.0" }
+llimphi-clipboard = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.3.0" }
+llimphi-widget-context-menu = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.3.0" }
+llimphi-widget-edit-menu = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.3.0" }
+llimphi-widget-menubar = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.3.0" }
+llimphi-widget-list = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.3.0" }
+llimphi-widget-grid = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.3.0" }
+llimphi-widget-table = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.3.0" }
+llimphi-widget-color-picker = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.3.0" }
+llimphi-widget-slider = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.3.0" }
+llimphi-widget-app-icon = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.3.0" }
+llimphi-widget-scroll = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.3.0" }
+llimphi-widget-lazy-list = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.3.0" }
+llimphi-widget-splitter = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.3.0" }
+llimphi-widget-stat-card = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.3.0" }
+llimphi-widget-tabs = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.3.0" }
+llimphi-module-allichay = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.3.0" }
+llimphi-module-command-palette = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.3.0" }
+llimphi-module-diff-viewer = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.3.0" }
+llimphi-module-fif = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.3.0" }
+llimphi-module-file-picker = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.3.0" }
+llimphi-module-bookmarks = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.3.0" }
+llimphi-module-mini-map = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.3.0" }
+llimphi-module-shuma-term = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.3.0" }
+llimphi-module-symbol-outline = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.3.0" }
+llimphi-plugin-host = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.3.0" }
+llimphi-widget-theme-switcher = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.3.0" }
+llimphi-widget-text-area = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.3.0" }
+llimphi-widget-text-editor-core = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.3.0" }
+llimphi-widget-text-editor = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.3.0" }
+llimphi-widget-text-editor-lsp = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.3.0" }
+llimphi-widget-text-input = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.3.0" }
+llimphi-widget-tiled = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.3.0" }
+llimphi-widget-nodegraph = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.3.0" }
+llimphi-widget-tree = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.3.0" }
+grafo-nav = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.3.0" }
+# La lengua de expresiones compartida (PLAN-CRUCES §A5). `default-features =
+# false` acá NO: los consumidores Linux (maskay, pizarra, yupay, krama) la
+# quieren con `std`; el kernel y las jaulas la piden sin default desde su propio
+# manifiesto, como hacen con `format` y `dataflow`.
+krama-expr = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.3.0" }
+krama-core = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.3.0" }
+krama-store = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.3.0" }
+krama-app-llimphi = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.3.0" }
+llimphi-widget-navigator = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.3.0" }
+llimphi-widget-detail-table = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.3.0" }
+llimphi-widget-select = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.3.0" }
+# `gpu` explícito: el camino GPU-directo del modo grilla pasó a ser una feature
+# (para que el terminal pueda salir del proceso), y este `default-features =
+# false` la apagaba para TODOS los que lo toman por el workspace — dejando a
+# `shuma-module-shell` sin `CellPipeline`. Pedirla acá devuelve exactamente el
+# comportamiento de antes; quien la quiera apagar lo hace en su manifiesto.
+llimphi-widget-terminal = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.3.0" }
 # Sello vectorial wawa (rombo + W implícita + Merkle Core).
-llimphi-widget-wawa-mark = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.2.0" }
+llimphi-widget-wawa-mark = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.3.0" }
 # Widgets de elegancia transversal (tooltip, spinner, progress, toast,
 # modal, empty, status-bar, shortcuts-help, splash).
-llimphi-widget-tooltip = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.2.0" }
-llimphi-widget-spinner = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.2.0" }
-llimphi-widget-progress = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.2.0" }
-llimphi-widget-toast = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.2.0" }
-llimphi-widget-modal = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.2.0" }
-llimphi-widget-empty = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.2.0" }
-llimphi-widget-status-bar = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.2.0" }
-llimphi-widget-shortcuts-help = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.2.0" }
-llimphi-widget-timeline = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.2.0" }
-llimphi-widget-transport = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.2.0" }
-llimphi-widget-waveform = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.2.0" }
-llimphi-widget-splash = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.2.0" }
+llimphi-widget-tooltip = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.3.0" }
+llimphi-widget-spinner = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.3.0" }
+llimphi-widget-progress = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.3.0" }
+llimphi-widget-toast = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.3.0" }
+llimphi-widget-modal = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.3.0" }
+llimphi-widget-empty = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.3.0" }
+llimphi-widget-status-bar = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.3.0" }
+llimphi-widget-shortcuts-help = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.3.0" }
+llimphi-widget-timeline = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.3.0" }
+llimphi-widget-transport = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.3.0" }
+llimphi-widget-waveform = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.3.0" }
+llimphi-widget-splash = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.3.0" }
 # Controles de formulario y signaling (switch, segmented, breadcrumb,
 # badge, avatar, skeleton, field).
-llimphi-widget-switch = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.2.0" }
-llimphi-widget-segmented = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.2.0" }
-llimphi-widget-rag-sidebar = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.2.0" }
-llimphi-widget-dock-rail = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.2.0" }
-llimphi-widget-toolbar = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.2.0" }
-llimphi-widget-breadcrumb = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.2.0" }
-llimphi-widget-badge = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.2.0" }
-llimphi-widget-avatar = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.2.0" }
-llimphi-widget-skeleton = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.2.0" }
-llimphi-widget-field = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.2.0" }
+llimphi-widget-switch = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.3.0" }
+llimphi-widget-segmented = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.3.0" }
+llimphi-widget-rag-sidebar = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.3.0" }
+llimphi-widget-dock-rail = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.3.0" }
+llimphi-widget-toolbar = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.3.0" }
+llimphi-widget-breadcrumb = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.3.0" }
+llimphi-widget-badge = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.3.0" }
+llimphi-widget-avatar = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.3.0" }
+llimphi-widget-skeleton = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.3.0" }
+llimphi-widget-field = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.3.0" }
 # Firma visual transversal (gradient sutil + hairline accent).
-llimphi-widget-panel = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.2.0" }
-llimphi-widget-panes = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.2.0" }
+# `default-features = false` acá y no en `card`: los defaults de un widget sólo
+# se pueden apagar desde la declaración del WORKSPACE (la trampa recurrente de
+# cargo). Sin esto, `card` arrastraría a `panel` con backend `vello` y volvería a
+# quedar fuera de una jaula, aunque `card` pidiera `wire`.
+llimphi-widget-panel = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.3.0" }
+llimphi-widget-panes = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.3.0" }
 # Widgets Flutter-like — composición sobre primitivas Tier 1/3 ya
 # expuestas (sombra, gradient, animated, ripple).
-llimphi-widget-chip = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.2.0" }
-llimphi-widget-fab = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.2.0" }
-llimphi-widget-wrap = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.2.0" }
-llimphi-widget-range-slider = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.2.0" }
-llimphi-widget-calendar = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.2.0" }
-llimphi-widget-fitted-box = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.2.0" }
-llimphi-widget-carousel = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.2.0" }
-llimphi-widget-rating = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.2.0" }
-llimphi-widget-gauge = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.2.0" }
-llimphi-widget-scaffold = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.2.0" }
-llimphi-widget-hero = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.2.0" }
-llimphi-widget-router = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.2.0" }
-llimphi-workspace = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.2.0" }
+llimphi-widget-chip = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.3.0" }
+llimphi-widget-fab = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.3.0" }
+llimphi-widget-fugaces = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.3.0" }
+llimphi-widget-wrap = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.3.0" }
+llimphi-widget-range-slider = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.3.0" }
+llimphi-widget-calendar = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.3.0" }
+llimphi-widget-datetime-picker = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.3.0" }
+llimphi-widget-form = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.3.0" }
+llimphi-widget-accordion = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.3.0" }
+llimphi-widget-stepper = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.3.0" }
+llimphi-widget-fitted-box = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.3.0" }
+llimphi-widget-carousel = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.3.0" }
+llimphi-widget-rating = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.3.0" }
+llimphi-widget-gauge = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.3.0" }
+llimphi-widget-seven-segment = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.3.0" }
+llimphi-widget-scaffold = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.3.0" }
+llimphi-widget-hero = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.3.0" }
+llimphi-widget-router = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.3.0" }
+llimphi-workspace = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.3.0" }
 # Abstracción Selector — host (paths) + wawa (khipus).
-llimphi-module-selector = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.2.0" }
+llimphi-module-selector = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.3.0" }
 
 # === Filesystem helpers ===
 directories = "5"
@@ -426,92 +568,96 @@ ttf-parser = "0.25"
 # ============================================================
 # Intra-workspace deps de nahual (referenciadas por workspace = true)
 # ============================================================
-nahual-text-viewer-llimphi = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.2.0" }
-nahual-image-viewer-llimphi = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.2.0" }
-nahual-pdf-viewer-llimphi = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.2.0" }
-nahual-cbz-viewer-llimphi = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.2.0" }
-nahual-thumb-core = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.2.0" }
-nahual-gallery-llimphi = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.2.0" }
-nahual-video-viewer-llimphi = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.2.0" }
-nahual-card-viewer-llimphi = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.2.0" }
-nahual-audio-viewer-llimphi = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.2.0" }
-media-module = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.2.0" }
-tullpu-module = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.2.0" }
-tullpu-core = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.2.0" }
-tullpu-icon-core = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.2.0" }
-tullpu-icon-cli = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.2.0" }
-tullpu-icon-llimphi = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.2.0" }
-tullpu-icon-llm = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.2.0" }
-foreign-svg = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.2.0" }
-foreign-pdf = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.2.0" }
-tullpu-render = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.2.0" }
-tullpu-render-gpu = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.2.0" }
-tullpu-ops = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.2.0" }
-tullpu-paint = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.2.0" }
-nahual-tree-viewer-llimphi = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.2.0" }
-nahual-hex-viewer-llimphi = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.2.0" }
-nahual-svg-viewer-llimphi = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.2.0" }
-nahual-sheet-viewer-llimphi = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.2.0" }
-nahual-dbf-viewer-llimphi = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.2.0" }
-nahual-table-viewer-llimphi = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.2.0" }
-nahual-markdown-viewer-llimphi = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.2.0" }
-nahual-pluma-viewer-llimphi = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.2.0" }
-nahual-cotejo-llimphi = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.2.0" }
-nahual-deck-viewer-llimphi = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.2.0" }
-nahual-archive-viewer-llimphi = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.2.0" }
-nahual-font-viewer-llimphi = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.2.0" }
-nahual-map-viewer-llimphi = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.2.0" }
-nahual-geo-core = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.2.0" }
-nahual-geo-voxel = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.2.0" }
-nahual-iconos = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.2.0" }
-nahual-viewer-core = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.2.0" }
-nahual-file-explorer-llimphi = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.2.0" }
+nahual-text-viewer-llimphi = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.3.0" }
+nahual-image-viewer-llimphi = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.3.0" }
+nahual-pdf-viewer-llimphi = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.3.0" }
+nahual-cbz-viewer-llimphi = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.3.0" }
+nahual-thumb-core = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.3.0" }
+nahual-vistazo-core = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.3.0" }
+nahual-gallery-llimphi = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.3.0" }
+nahual-video-viewer-llimphi = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.3.0" }
+nahual-card-viewer-llimphi = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.3.0" }
+nahual-audio-viewer-llimphi = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.3.0" }
+media-module = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.3.0" }
+tullpu-module = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.3.0" }
+tullpu-core = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.3.0" }
+tullpu-icon-core = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.3.0" }
+tullpu-icon-cli = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.3.0" }
+tullpu-icon-llimphi = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.3.0" }
+tullpu-icon-llm = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.3.0" }
+foreign-svg = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.3.0" }
+foreign-pdf = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.3.0" }
+tullpu-render = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.3.0" }
+tullpu-tiles = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.3.0" }
+tullpu-render-gpu = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.3.0" }
+tullpu-ops = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.3.0" }
+tullpu-paint = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.3.0" }
+nahual-tree-viewer-llimphi = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.3.0" }
+nahual-hex-viewer-llimphi = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.3.0" }
+nahual-svg-viewer-llimphi = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.3.0" }
+nahual-sheet-viewer-llimphi = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.3.0" }
+nahual-dbf-viewer-llimphi = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.3.0" }
+nahual-table-viewer-llimphi = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.3.0" }
+nahual-markdown-viewer-llimphi = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.3.0" }
+nahual-pluma-viewer-llimphi = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.3.0" }
+nahual-cotejo-llimphi = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.3.0" }
+nahual-deck-viewer-llimphi = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.3.0" }
+nahual-archive-viewer-llimphi = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.3.0" }
+nahual-font-viewer-llimphi = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.3.0" }
+nahual-map-viewer-llimphi = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.3.0" }
+nahual-geo-core = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.3.0" }
+nahual-geo-voxel = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.3.0" }
+nahual-iconos = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.3.0" }
+nahual-viewer-core = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.3.0" }
+nahual-file-explorer-llimphi = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.3.0" }
 
 # ============================================================
 # Intra-workspace deps de pineal (módulo de gráficos)
 # ============================================================
-pineal-core = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.2.0" }
-pineal-render = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.2.0" }
-pineal-cartesian = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.2.0" }
-pineal-stream = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.2.0" }
-pineal-mesh = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.2.0" }
-pineal-financial = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.2.0" }
-pineal-polar = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.2.0" }
-pineal-heatmap = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.2.0" }
-pineal-treemap = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.2.0" }
-pineal-flow = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.2.0" }
-pineal-phosphor = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.2.0" }
-pineal-export = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.2.0" }
-pineal-hexbin = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.2.0" }
-pineal-contour = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.2.0" }
-pineal-bars = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.2.0" }
-pineal = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.2.0" }
+pineal-core = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.3.0" }
+pineal-render = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.3.0" }
+pineal-cartesian = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.3.0" }
+pineal-stream = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.3.0" }
+pineal-mesh = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.3.0" }
+pineal-financial = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.3.0" }
+pineal-polar = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.3.0" }
+pineal-heatmap = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.3.0" }
+pineal-treemap = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.3.0" }
+pineal-flow = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.3.0" }
+pineal-phosphor = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.3.0" }
+pineal-export = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.3.0" }
+pineal-hexbin = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.3.0" }
+pineal-contour = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.3.0" }
+pineal-bars = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.3.0" }
+pineal-quiver = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.3.0" }
+pineal-yupana = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.3.0" }
+pineal = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.3.0" }
 
 # ============================================================
 # Intra-workspace deps de iniy (laboratorio semántico de creencias)
 # ============================================================
-iniy-core = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.2.0" }
-iniy-evidencia = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.2.0" }
-iniy-derive = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.2.0" }
-iniy-emisores = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.2.0" }
-iniy-cache = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.2.0" }
-iniy-daemon = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.2.0" }
-iniy-ingest = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.2.0" }
-iniy-extract = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.2.0" }
-iniy-nli = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.2.0" }
-iniy-nli-llm = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.2.0" }
-iniy-graph = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.2.0" }
-iniy-store = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.2.0" }
+iniy-core = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.3.0" }
+iniy-evidencia = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.3.0" }
+iniy-derive = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.3.0" }
+iniy-emisores = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.3.0" }
+iniy-cache = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.3.0" }
+iniy-daemon = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.3.0" }
+iniy-ingest = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.3.0" }
+iniy-extract = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.3.0" }
+iniy-nli = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.3.0" }
+iniy-nli-llm = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.3.0" }
+iniy-graph = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.3.0" }
+iniy-store = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.3.0" }
 # la física de decaimiento de khipu la importa iniy-derive (SDD iniy §3)
 khipu-gravity = { path = "00_unanchay/khipu/khipu-gravity" }
 
 # === auto: declarados por crates internos faltantes ===
-cosmos-coords = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.2.0" }
-cosmos-cities = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.2.0" }
-cosmos-core = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.2.0" }
-cosmos-ephemeris = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.2.0" }
-cosmos-time = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.2.0" }
-cosmos-wcs = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.2.0" }
+cosmos-coords = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.3.0" }
+cosmos-cities = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.3.0" }
+cosmos-core = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.3.0" }
+cosmos-ephemeris = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.3.0" }
+cosmos-time = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.3.0" }
+cosmos-wcs = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.3.0" }
 
 # === auto: externas de eternal ===
 celestial-eop-data = { version = "0.1"}
@@ -533,6 +679,18 @@ memmap2 = "0.9"
 wayland-client = "0.31"
 wayland-protocols-wlr = { version = "0.3", features = ["client"] }
 wayland-protocols-misc = { version = "0.3", features = ["client"] }
+# PipeWire (bindings a libpipewire-0.3, MIT) — el transporte de video que
+# `xdg-desktop-portal` exige para compartir pantalla. Lo usa
+# `mirada-screencast`; no entra al compositor (ver su LEEME: el productor es
+# un cliente wlr-screencopy, no el compositor mismo).
+#
+# `v0_3_34` es el piso que habilita `pw_stream_trigger_process` — sin él un
+# nodo DRIVER no se puede disparar y el consumidor recibe CERO cuadros, sin
+# error. Se pide el mínimo que hace falta, no la última: la feature declara
+# contra qué libpipewire se compila, y pedir de más rompería en una distro con
+# la biblioteca más vieja.
+pipewire = { version = "0.10", features = ["v0_3_34"] }
+libspa = "0.10"
 mockito = "1.0"
 ndarray = "0.15"
 num-traits = "0.2"
@@ -543,7 +701,27 @@ proptest = "1.4"
 quick-xml = "0.31"
 rayon = "1.8"
 regex = "1.11"
-reqwest = "0.12"
+# TLS por **rustls**, no por el `default-tls` de reqwest (que es `native-tls`
+# y arrastra `openssl-sys`). Dos razones, y la segunda es la que muerde:
+#  1. `openssl-sys` NO cross-compila a `aarch64-linux-android` — su build
+#     script no encuentra la libreria del NDK. Con el default puesto, toda
+#     app que toque HTTP queda fuera del porte a Android (khipu fue la
+#     primera en chocar).
+#  2. Seis de los doce consumidores YA pedian `rustls-tls` a mano. Como las
+#     features de cargo son aditivas, eso no apagaba `default-tls`: estaban
+#     linkeando **las dos** pilas de TLS creyendo que usaban una. Esto hace
+#     verdadero lo que esos crates ya decian.
+# El resto de los defaults de reqwest se conservan uno por uno.
+# ⚠️ Cambio de comportamiento a nombrar: rustls valida contra las raices
+# empaquetadas (webpki-roots), no contra el almacen de certificados del SO.
+# Para las APIs publicas que consume la suite da igual; un servidor con CA
+# propia habria que agregarlo explicitamente.
+reqwest = { version = "0.12", default-features = false, features = [
+    "rustls-tls",
+    "charset",
+    "http2",
+    "macos-system-configuration",
+] }
 tiff = "0.11"
 wide = "0.7"
 wiremock = "0.6"
@@ -565,7 +743,12 @@ markup5ever = "0.39"
 markup5ever_rcdom = "0.39"
 cssparser = "0.35"
 url = "2"
-ureq = { version = "2", default-features = false, features = ["tls"] }
+# `gzip`: sin ella ureq ni anuncia `Accept-Encoding` ni decodifica la respuesta.
+# python.org (detrás de Fastly) devuelve `content-encoding: gzip` AUNQUE no se lo
+# pidan, y el cuerpo comprimido se decodificaba como UTF-8: 3.911 caracteres
+# U+FFFD y una página de 16 cajas en vez de 1.489. Además baja lo que se
+# descarga en todo el resto.
+ureq = { version = "2", default-features = false, features = ["tls", "gzip"] }
 # Charset de la web real: no todo es UTF-8 (google.com sirve ISO-8859-1).
 # Ya venía transitivo por el stack de Servo — aquí se hace explícito.
 encoding_rs = "0.8"
@@ -619,13 +802,18 @@ matroska-demuxer = "0.7"
 # `panic = "unwind"` se mantiene a propósito: mirada-plugin-host aísla plugins
 # con `catch_unwind`, así que `abort` rompería el sandbox.
 # === Crates del monorepo consumidos por git-dep (pin del tag) ===
-agora-core = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.2.0" }
-agora-keystore = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.2.0" }
-card-net = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.2.0" }
-pluma-llm = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.2.0" }
-rimay-verbo = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.2.0" }
-rimay-verbo-core = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.2.0" }
-rimay-verbo-index = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.2.0" }
+agora-core = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.3.0" }
+agora-keystore = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.3.0" }
+card-net = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.3.0" }
+pizarra-core = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.3.0" }
+pizarra-llimphi = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.3.0" }
+pluma-core = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.3.0" }
+pluma-cuerpo = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.3.0" }
+pluma-llm = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.3.0" }
+pluma-proyecto = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.3.0" }
+rimay-verbo = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.3.0" }
+rimay-verbo-core = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.3.0" }
+rimay-verbo-index = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.3.0" }
 
 [profile.dist]
 inherits = "release"