tawasuyu

tawasuyu/khipu

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

Rust 23Markdown 14TOML 6409 Definitions

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

Regenerado con scripts/actualizar-standalone.py: código del dominio al día,
git-deps repineadas a v0.2.0 y doble-fuente resuelta con [patch] al source git.
cargo check --workspace verde.

48e5a861a0ccd73f813828603c35346697befb9d · by Sergio · (2 months ago) · parent 6f4d19383 · browse the tree at this commit

Semantic delta · 304

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

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

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

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

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

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

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

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

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

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

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

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

35 files · +7150 −3772

diff --git a/00_unanchay/khipu/LEEME.md b/00_unanchay/khipu/LEEME.md
index ebc6d31..8d73d00 100644
--- a/00_unanchay/khipu/LEEME.md
+++ b/00_unanchay/khipu/LEEME.md
@@ -1,78 +1,76 @@
 # khipu
 
-> `khipu` (quechua: nudos de cuerdas para registrar memoria). Notas con gravedad temporal.
+> `khipu` (quechua: nudos de cuerdas para registrar memoria). El cuaderno de resonancia: las ideas se refuerzan entre sí, no se olvidan.
 
-Captura de notas rápidas donde el olvido es parte del modelo: cada nota tiene una masa que decae con el tiempo y se refuerza con cada acceso. Lo recurrente queda visible; lo que no se vuelve a tocar se va difuminando hasta caer del horizonte.
+Un lugar donde botar ideas sueltas en cualquier estado mental — pensamientos, TODOs, citas — y que la estructura **emerja de la recurrencia** en vez de imponerse por adelantado. La inversa de un gestor de notas con carpetas y la inversa del khipu viejo (que olvidaba por decay): aquí cada idea que vuelve **refuerza** a las que ya estaban. El objetivo es cerrar las fugas cerebrales: la idea que se te va y vuelves a pensar desde cero.
 
-## Instalación
-
-```sh
-cargo run --release -p khipu-app
-```
+## El modelo: nudos, cuerdas, tejido
 
-## Compatibilidad
+- **Nudo** — la unidad de captura. Escribes, Enter, listo: cero organización en el momento. Un nudo puede marcarse pendiente/hecho (TODO sin due-dates) y lleva estampa de origen si vino de otra app (fase 2: chasqui, pata, pacha).
+- **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.
 
-- **Linux / macOS / Windows** — UI Llimphi (Wayland/X11/Win32 vía `winit`).
-- Persistencia local en `$XDG_DATA_HOME/khipu/`.
+## La ventana es un khipu
 
-## Crates
+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.
 
-| Crate | Rol |
-|---|---|
-| [`khipu-core`](khipu-core/README.md) | Modelo de nota + store; sin UI. |
-| [`khipu-gravity`](khipu-gravity/README.md) | Algoritmo de masa/decay; refuerzo por acceso. |
-| `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 sobre el core. |
+## Instalación
 
-## Gravedad semántica (embeddings)
+```sh
+cargo run --release -p khipu-app
+```
 
-El canvas de la derecha agrupa las notas por afinidad. Los vectores salen del `verbo-daemon` si está corriendo; si no, de un hash-trigram local.
+### Embeddings reales (resonancia semántica de verdad)
 
 ```sh
-# Embeddings reales (clústeres y vecinos semánticos de verdad):
 cargo run -p rimay-verbo-daemon-bin -- --provider fastembed   # escucha en $XDG_RUNTIME_DIR/verbo.sock
 cargo run --release -p khipu-app                              # lo detecta solo al arrancar
 ```
 
-Sin daemon, khipu cae al embebedor trigram de 16d — determinista y offline, idéntico al comportamiento histórico. El cálculo nunca bloquea la UI: viaja a un worker y reentra al bucle cuando termina. Si el espacio vectorial cambia entre dos arranques (arrancó/cayó el daemon, otro modelo), los vectores se recalculan automáticamente.
+Sin daemon, khipu cae al embebedor trigram de 16d — determinista y offline. Si el espacio vectorial cambia entre dos arranques, los vectores se recalculan solos.
 
-## Compartir (agora)
-
-`exportar` sella en `compartido.khipu` un sobre firmado Ed25519 con la identidad del cuaderno y direccionado por su hash BLAKE3 de contenido.
+### Tejido (LLM)
 
-La identidad vive **cifrada** (Argon2id + ChaCha20-Poly1305, vía `agora-keystore`) en `<datos>/keys/` — la semilla privada nunca queda en claro en disco. Al primer intento de compartir, khipu pide una passphrase: la primera vez la crea, después la usa para descifrar. `KHIPU_PASSPHRASE` en el entorno desbloquea sin prompt (headless). Una `identidad.seed` en claro de versiones viejas se migra al keystore (y se borra el claro) automáticamente. Comparte **lo que el buscador esté filtrando** (vacío = todo el cuaderno), así que escribir en la búsqueda y exportar manda sólo ese subconjunto. `importar` verifica firma + hash y, si cuadra, ingiere las notas, marcándolas con una etiqueta de procedencia `de:<autor>` (visible en el editor como «✎ de: …»).
+El backend sale de `pluma-llm::from_env()`: `ANTHROPIC_API_KEY` / `GEMINI_API_KEY` / `DEEPSEEK_API_KEY` / `COHERE_API_KEY` / Ollama, o `PLUMA_LLM_BACKEND` explícito. **Sin credenciales cae a Mock** — el botón siempre responde, aunque sea un eco de prueba.
 
-Lo que viaja es el **contenido** (título, cuerpo, etiquetas), nunca la física temporal: al importar, cada nota nace fresca (masa plena, acceso = ahora) — su gravedad arranca en el cuaderno que la recibe. Los wiki-links `[[Título]]` se rearman solos porque khipu resuelve enlaces por título. Reimportar el mismo sobre no duplica (se omiten títulos ya presentes). Un sobre alterado o con firma ajena se rechaza entero, sin autoridad central.
+## Persistencia y migración
 
-Para compartir en vivo sin copiar archivos: `publicar` levanta un servidor TCP que sirve el cuaderno (puerto `KHIPU_BIND`, default `127.0.0.1:7700`) **y anuncia una baliza UDP** para que lo descubran en la LAN. `recibir` abre un panel con un **campo de dirección** (`host:puerto`, prellenado y editable) y, debajo, los **pares descubiertos en la LAN** (nombre · autor · dirección): click en uno para jalarle el cuaderno, o escribí una dirección y «jalar». El transporte es `std::net` puro y **no necesita ser confiable** — el receptor verifica firma + hash antes de ingerir; la baliza sólo dice *dónde*, no *qué*.
+`$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`.
 
-**WAN / libp2p**: el campo de dirección de `recibir` acepta dos formas, autodetectadas: `host:puerto` (TCP directo) o una **multiaddr libp2p** —directa `/ip4/…/p2p/<id>` o de circuito `/ip4/…/p2p/<relay>/p2p-circuit/p2p/<id>`. Al `publicar`, khipu sirve por libp2p (stream cifrado Noise sobre `BrahmanNet`, protocolo `/khipu/sobre/1.0.0`, vía `khipu-brahman`) y muestra tu dirección de marcado.
+## Crates
 
-**NAT traversal**: `BrahmanNet` ahora trae **Circuit Relay v2 + DCUtR** (`card-net`). Un nodo alcanzable hace de relay; uno detrás de NAT reserva un circuito ahí y queda accesible vía la dirección de circuito. Configurá `KHIPU_RELAY=/ip4/…/tcp/…/p2p/<relay-id>` antes de `publicar` y khipu reserva el circuito y muestra la dirección para compartir. Las direcciones externas no se confían a ciegas: **AutoNAT** las confirma pidiendo dial-backs a otros peers, y sólo las confirmadas se anuncian (y entran en las reservas de relay). En la malla Brahman AutoNAT corre con `only_global_ips=false` para confirmar también en LAN/loopback.
+| Crate | Rol |
+|---|---|
+| [`khipu-core`](khipu-core/README.md) | Nudos + cuerdas + grafo de enlaces + propuesta de nombre; sin UI. |
+| [`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. |
 
-**Descubrimiento por DHT**: con `KHIPU_BOOTSTRAP=/ip4/…/p2p/<id>` (un nodo de la malla), khipu se une a la DHT Kademlia al arrancar; `publicar` se anuncia bajo la clave khipu y `recibir` lista —además de los pares LAN— los pares hallados por DHT (filas `DHT · …<id>`), que se jalan por peer-id. Así dos khipu se encuentran sin compartir IP ni multiaddr a mano, sólo conociendo un bootstrap común. Verificado end-to-end en localhost (rendezvous + publicador + receptor; 4 tests en `khipu-brahman`).
+## Compartir (agora)
 
-La lógica vive en `khipu-share`: `net` (transporte TCP) y `discovery` (baliza UDP). 15 tests + un test de integración que recorre la cadena completa descubrir→jalar→verificar en loopback.
+Sin cambios de fondo respecto del khipu viejo — viaja el **contenido** (título, cuerpo, etiquetas), nunca la estructura local: al importar, cada nota nace suelta y fresca; las cuerdas son atención de quien las anudó y no se transfieren. `exportar` sella un sobre firmado Ed25519 direccionado por BLAKE3 (filtrado por la búsqueda si hay texto); `importar` verifica firma + hash, deduplica por título y marca procedencia `de:<autor>`. `publicar`/`recibir`: TCP + baliza UDP en LAN, y libp2p (Noise sobre BrahmanNet, Circuit Relay v2 + DCUtR + AutoNAT, DHT Kademlia) para WAN — `KHIPU_BIND`, `KHIPU_RELAY`, `KHIPU_BOOTSTRAP`, `KHIPU_PASSPHRASE` como siempre. La identidad vive cifrada (Argon2id + ChaCha20-Poly1305) en `<datos>/keys/`.
 
-## Estado (2026-05-31)
+## Estado (2026-07-13)
 
 ### Hecho
 
-- `khipu-core` (modelo de nota + store) + `khipu-gravity` (masa/decay con refuerzo por acceso).
-- `khipu-app`: UI Llimphi sobre el core, con menú principal y contextual.
-- Gravedad semántica: clustering por embeddings del `verbo-daemon` (rimay), con fallback trigram 16d offline; cálculo en worker que no bloquea la UI.
-- Compartir vía agora (`khipu-share`): sobres firmados Ed25519 + direccionados BLAKE3, identidad cifrada (Argon2id + ChaCha20-Poly1305 en keystore), compartir selectivo + procedencia del autor; transporte TCP/LAN + descubrimiento por baliza UDP (15 tests + integración loopback).
-- WAN/P2P (`khipu-brahman` sobre libp2p/BrahmanNet): stream cifrado Noise, NAT traversal (Circuit Relay v2 + DCUtR), AutoNAT, descubrimiento por DHT Kademlia (4 tests e2e localhost).
+- **Rediseño completo**: se invirtió la física (resonancia en vez de olvido) y murió el mapa gravitatorio como interfaz. Vista raíz = captura + cuerdas.
+- `khipu-core`: modelo nudo/cuerda (`Note.cuerda/estado/origen`, `Cuerda.tejido`), store con anudar/desanudar/sueltos, propuesta de nombre.
+- `khipu-gravity`: `masa_cuerda` + `frescura` (puras); el decay temporal sigue para sus otros consumidores.
+- `khipu-app`: lienzo de cuerdas (painter vello + hit-test + pan), panel de resonancia al capturar, sedimentación, tejido LLM vía `pluma-llm`, migración V1–V3 → V4 con backup.
+- Compartir agora/libp2p intacto (19 tests + 4 e2e).
 
-### Pendiente
+### Pendiente (fase 2)
 
-- Sincronización bidireccional / resolución de conflictos entre cuadernos (hoy es import unidireccional de sobres).
-- Transferir física temporal opcional al compartir (hoy el contenido nace fresco en el receptor — decisión de diseño, no bug).
-- Endurecimiento de la malla DHT en WAN real (probado en localhost/LAN).
+- **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).
+- Sincronización bidireccional / conflictos entre cuadernos (hoy import unidireccional).
 
 ## Consideraciones
 
-- **No es un sistema de "todo"** — no hay due-dates ni recordatorios; es un cuaderno con física propia.
-- El decay es transparente: cada nota expone su masa actual; el usuario decide si la salva.
-- La gravedad es local y no transferible: compartir mueve el contenido, no la atención.
+- **Los TODOs son nudos** — expresión mental como cualquier otra; sin due-dates ni recordatorios.
+- **Nada se borra solo**: el sedimento es reposo, no papelera. Borrar es siempre un gesto explícito.
+- La cuerda es estructura, no contenido: desatarla suelta los nudos, no los pierde.
diff --git a/00_unanchay/khipu/README.md b/00_unanchay/khipu/README.md
index f85f02e..b2283d6 100644
--- a/00_unanchay/khipu/README.md
+++ b/00_unanchay/khipu/README.md
@@ -1,8 +1,19 @@
 # khipu
 
-> `khipu` (Quechua: knotted cord recordkeeping). Notes with temporal gravity.
+> `khipu` (Quechua: knotted cord recordkeeping). The resonance notebook: ideas reinforce each other instead of being forgotten.
 
-Quick-note capture where forgetting is part of the model: each note has a mass that decays with time and reinforces with each access. What's recurrent stays visible; what isn't touched fades until it falls off the horizon.
+A place to dump loose ideas in any mental state — thoughts, TODOs, quotes — where structure **emerges from recurrence** instead of being imposed up front. The inverse of a folder-based note manager, and the inverse of the old khipu (which forgot by decay): here every idea that comes back **reinforces** the ones already there. The goal is closing brain leaks: the idea that slips away and gets re-thought from scratch.
+
+## The model: knots, cords, weaving
+
+- **Knot (nudo)** — the capture unit. Type, Enter, done: zero organizing in the moment. A knot can be marked pending/done (TODOs without due-dates) and carries an origin stamp when it comes from another app (phase 2: chasqui, pata, pacha).
+- **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.
+
+## 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.
 
 ## Install
 
@@ -10,21 +21,56 @@ Quick-note capture where forgetting is part of the model: each note has a mass t
 cargo run --release -p khipu-app
 ```
 
-## Compatibility
+### Real embeddings (true semantic resonance)
+
+```sh
+cargo run -p rimay-verbo-daemon-bin -- --provider fastembed   # listens on $XDG_RUNTIME_DIR/verbo.sock
+cargo run --release -p khipu-app                              # auto-detects it on startup
+```
+
+Without the daemon, khipu falls back to the 16d trigram embedder — deterministic and offline. If the vector space changes between two launches, vectors are recomputed automatically.
+
+### Weaving (LLM)
 
-- **Linux / macOS / Windows** — Llimphi UI (Wayland/X11/Win32 via `winit`).
-- Local persistence in `$XDG_DATA_HOME/khipu/`.
+The backend comes from `pluma-llm::from_env()`: `ANTHROPIC_API_KEY` / `GEMINI_API_KEY` / `DEEPSEEK_API_KEY` / `COHERE_API_KEY` / Ollama, or an explicit `PLUMA_LLM_BACKEND`. **Without credentials it falls back to Mock** — the button always answers, even if it's a test echo.
+
+## 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`.
 
 ## Crates
 
 | Crate | Role |
 |---|---|
-| [`khipu-core`](khipu-core/README.md) | Note model + store; no UI. |
-| [`khipu-gravity`](khipu-gravity/README.md) | Mass/decay algorithm; reinforcement on access. |
-| [`khipu-app`](khipu-app/README.md) | Llimphi UI over the core. |
+| [`khipu-core`](khipu-core/README.md) | Knots + cords + link graph + name proposal; no UI. |
+| [`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. |
+
+## Sharing (agora)
+
+Unchanged at its core from the old khipu — what travels is the **content** (title, body, tags), never the local structure: imported notes are born loose and fresh; cords are the attention of whoever tied them and don't transfer. `export` seals a signed envelope (filtered by the search box if non-empty); `import` verifies signature + hash, dedups by title and marks provenance `de:<author>`. `publicar`/`recibir`: TCP + UDP beacon on LAN, and libp2p (Noise over BrahmanNet, Circuit Relay v2 + DCUtR + AutoNAT, Kademlia DHT) for WAN — `KHIPU_BIND`, `KHIPU_RELAY`, `KHIPU_BOOTSTRAP`, `KHIPU_PASSPHRASE` as always. The identity lives encrypted (Argon2id + ChaCha20-Poly1305) in `<data>/keys/`.
+
+## Status (2026-07-13)
+
+### Done
+
+- **Full redesign**: the physics was inverted (resonance instead of forgetting) and the gravity map died as the interface. Root view = capture + cords.
+- `khipu-core`: knot/cord model (`Note.cuerda/estado/origen`, `Cuerda.tejido`), store with tie/untie/loose, name proposal.
+- `khipu-gravity`: `masa_cuerda` + `frescura` (pure); temporal decay stays for its other consumers.
+- `khipu-app`: cord canvas (vello painter + hit-test + pan), resonance panel on capture, sedimentation, LLM weaving via `pluma-llm`, V1–V3 → V4 migration with backup.
+- Sharing via agora/libp2p intact (19 tests + 4 e2e).
+
+### Pending (phase 2)
+
+- **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).
+- Bidirectional sync / conflict resolution between notebooks (today import is unidirectional).
 
 ## Considerations
 
-- **It's not a "todo" system** — no due dates, no reminders; it's a notebook with its own physics.
-- Decay is transparent: each note shows its current mass; the user decides whether to save it.
-- Plays well with the [agora](../../03_ukupacha/agora/README.md) network: notes can be shared without losing their local gravity.
+- **TODOs are knots** — mental expression like any other; no due dates, no reminders.
+- **Nothing deletes itself**: sediment is rest, not a trash can. Deleting is always an explicit gesture.
+- The cord is structure, not content: untying it frees the knots, it doesn't lose them.
diff --git a/00_unanchay/khipu/README.qu.md b/00_unanchay/khipu/README.qu.md
index 0f2f69a..411863f 100644
--- a/00_unanchay/khipu/README.qu.md
+++ b/00_unanchay/khipu/README.qu.md
@@ -2,9 +2,9 @@
 
 # khipu
 
-> `khipu` (runa-simi: watasqa wask'akuna yuyanapaq). Qillqakuna pacha tinkuywan.
+> `khipu` (runa-simi: watasqa wask'akuna yuyanapaq). Yuyaykunaq tinkunan qillqana: yuyaykuna purapura kallpachanakun, manan qunqakunchu.
 
-Usqhay nota hap'iy. Qunqaytaqa modelopa ukhunpi: sapanka nota llasayuq, pachawan miran, sapa kuti rikuptin kallpachayuq. Sapa kuti rikuq nota qhipanqa; mana takyaqkuna chinkanqaku, ñawi hawamanta wikch'urunqakama.
+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.
 
 ## Churay
 
@@ -15,18 +15,19 @@ 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.
+- Allpa-yana waqaychay `$XDG_DATA_HOME/khipu/`-pi (ñawpaq khipuq qillqankuna kikinmanta migrasqa, `notes.v3.bak` copiayuq).
 
 ## Crateskuna
 
 | Crate | Ima ruwan |
 |---|---|
-| [`khipu-core`](khipu-core/README.md) | Nota modelo + waqaychay; mana UI. |
-| [`khipu-gravity`](khipu-gravity/README.md) | Llasay/chinkay algoritmo, rikuq kallpachay. |
-| [`khipu-app`](khipu-app/README.md) | Llimphi UI core patapi. |
+| [`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. |
 
 ## Yuyaykunaq
 
-- **Manan "todo" sistemachu** — mana fechakuna, mana willaqkuna; pi-físikayuq qillqana kanmi.
-- Chinkayqa rikukullan: sapanka nota llasayninta rikuchin; runa salvanaqa rikun.
-- [agora](../../03_ukupacha/agora/README.md) ayllu-redwan tinkuy atin: nota willanakuyqa mana lokal llasayninmanta hark'aqasqa.
+- **TODOkunapas nudokunam** — mana fechakuna, mana willaqkuna.
+- Manan imapas sapallanmanta chinkanchu: allpaqa samaymi, manan wikch'unachu.
+- Wask'aqa awasqa kaymi, manan qillqachu: paskaptiyki nudokunaqa qhipakullanku.
diff --git a/00_unanchay/khipu/khipu-app/Cargo.toml b/00_unanchay/khipu/khipu-app/Cargo.toml
index 7d2580b..de09504 100644
--- a/00_unanchay/khipu/khipu-app/Cargo.toml
+++ b/00_unanchay/khipu/khipu-app/Cargo.toml
@@ -6,7 +6,7 @@ rust-version.workspace = true
 license.workspace = true
 authors.workspace = true
 publish.workspace = true
-description = "khipu-app — cuaderno de notas sobre Llimphi: lista editable in-situ + canvas de gravedad semántica con persistencia en $XDG_DATA_HOME/khipu/."
+description = "khipu-app — cuaderno de resonancia sobre Llimphi: captura + lienzo de cuerdas (ideas recurrentes con nudos cronológicos) + tejido LLM, con persistencia en $XDG_DATA_HOME/khipu/."
 
 [[bin]]
 name = "khipu-app"
@@ -23,13 +23,15 @@ khipu-share = { path = "../khipu-share" }
 khipu-brahman = { path = "../khipu-brahman" }
 agora-core = { workspace = true }
 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 }
 tokio = { workspace = true }
 llimphi-ui = { workspace = true }
 llimphi-theme = { workspace = true }
 llimphi-widget-list = { workspace = true }
 llimphi-widget-text-editor = { workspace = true }
 llimphi-widget-text-input = { workspace = true }
-llimphi-widget-tiled = { workspace = true }
 llimphi-widget-menubar = { workspace = true }
 llimphi-widget-edit-menu = { workspace = true }
 llimphi-widget-context-menu = { workspace = true }
@@ -39,3 +41,8 @@ app-bus = { workspace = true }
 directories = { workspace = true }
 postcard = { workspace = true }
 serde = { workspace = true }
+# Timeline universal (#6): al capturar un nudo se emite un checkpoint cuyo raíz
+# es el BLAKE3 del contenido serializado de la nota.
+blake3 = { workspace = true }
+willay-checkpoint = { workspace = true }
+willay-emit = { workspace = true }
diff --git a/00_unanchay/khipu/khipu-app/LEEME.md b/00_unanchay/khipu/khipu-app/LEEME.md
index 0cf0ecf..c5affec 100644
--- a/00_unanchay/khipu/khipu-app/LEEME.md
+++ b/00_unanchay/khipu/khipu-app/LEEME.md
@@ -1,17 +1,23 @@
 # khipu-app
 
-> UI Llimphi sobre el core de [khipu](../README.md). Binario del usuario.
+*Read this in English: [README.md](README.md).*
 
-App de escritorio: lista de notas ordenadas por masa actual, editor inline (Markdown ligero), captura rápida (`Ctrl+N`), búsqueda fuzzy. Cada redibujo recalcula masa con [`khipu-gravity`](../khipu-gravity/README.md) y muestra notas con `mass > umbral`; las que cayeron se acceden por menú "archivo".
+> UI Llimphi del cuaderno de resonancia [khipu](../LEEME.md). Binario del usuario.
+
+La ventana es un khipu: **captura** siempre lista arriba (Enter bota el nudo), franja de **sueltos** frescos, y el lienzo de **cuerdas** colgadas de la viga — grosor por masa, brillo por frescura, nudos en cronología, tejido ◈ al final. Al capturar, el panel de **resonancia** ofrece anudar con lo que ya pensaste; **tejer** (tarjeta de cuerda o menú Cuerda) pide al LLM el encuadre de la idea. Lista y editor son overlays (`☰ nudos`, click en un nudo); `Esc` cierra en cascada; `Ctrl+N` captura, `Ctrl+F` busca.
 
 ## Uso
 
 ```sh
 cargo run --release -p khipu-app
+# resonancia semántica real:      cargo run -p rimay-verbo-daemon-bin -- --provider fastembed
+# tejido con LLM real:            ANTHROPIC_API_KEY=… (o Gemini/DeepSeek/Cohere/Ollama; sin nada → Mock)
 ```
 
+Persistencia: `$XDG_DATA_HOME/khipu/notes.bin` (postcard V4); los cuadernos del khipu viejo migran solos con backup `notes.v3.bak`.
+
 ## Deps
 
-- [`khipu-core`](../khipu-core/README.md), [`khipu-gravity`](../khipu-gravity/README.md)
-- [`llimphi-ui`](../../../02_ruway/llimphi/) + widgets `text-editor`, `text-input`, `list`
-- [`wawa-config-llimphi`](../../../shared/wawa-config-llimphi/) para preferencias compartidas
+- [`khipu-core`](../khipu-core/LEEME.md), [`khipu-gravity`](../khipu-gravity/LEEME.md), `khipu-share`, `khipu-brahman`
+- [`llimphi-ui`](../../../02_ruway/llimphi/) + widgets `text-editor`, `text-input`, `list`, `menubar`, `edit-menu`
+- `pluma-llm` (el tejido), `rimay-verbo` (embeddings), `agora-core` (identidad), `willay-*` (timeline)
diff --git a/00_unanchay/khipu/khipu-app/README.md b/00_unanchay/khipu/khipu-app/README.md
index afb78d4..3d5b905 100644
--- a/00_unanchay/khipu/khipu-app/README.md
+++ b/00_unanchay/khipu/khipu-app/README.md
@@ -1,17 +1,21 @@
 # khipu-app
 
-> Llimphi UI over the core of [khipu](../README.md). The user binary.
+> Llimphi UI of the [khipu](../README.md) resonance notebook. The user binary.
 
-Desktop app: list of notes sorted by current mass, inline editor (lightweight Markdown), quick capture (`Ctrl+N`), fuzzy search. Each redraw recomputes mass via [`khipu-gravity`](../khipu-gravity/README.md) and shows notes with `mass > threshold`; fallen ones are accessed via the "archive" menu.
+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.
 
 ## Usage
 
 ```sh
 cargo run --release -p khipu-app
+# real semantic resonance:   cargo run -p rimay-verbo-daemon-bin -- --provider fastembed
+# 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.
+
 ## Deps
 
-- [`khipu-core`](../khipu-core/README.md), [`khipu-gravity`](../khipu-gravity/README.md)
-- [`llimphi-ui`](../../../02_ruway/llimphi/) + widgets `text-editor`, `text-input`, `list`
-- [`wawa-config-llimphi`](../../../shared/wawa-config-llimphi/) for shared prefs
+- [`khipu-core`](../khipu-core/README.md), [`khipu-gravity`](../khipu-gravity/README.md), `khipu-share`, `khipu-brahman`
+- [`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)
diff --git a/00_unanchay/khipu/khipu-app/src/app.rs b/00_unanchay/khipu/khipu-app/src/app.rs
new file mode 100644
index 0000000..521dc18
--- /dev/null
+++ b/00_unanchay/khipu/khipu-app/src/app.rs
@@ -0,0 +1,1025 @@
+//! `app` — implementación de [`llimphi_ui::App`] para khipu.
+//!
+//! Contiene la estructura `KhipuApp` y los métodos del ciclo Elm:
+//! `init`, `update`, `view`, `view_overlay` y `on_key`.
+//! La lógica de negocio se delega a los submódulos `estado`, `menu`,
+//! `cuerdas`, `panels`, `tejido` y `net`.
+
+use llimphi_motion::{animate, motion, Tween};
+use llimphi_ui::llimphi_hal::winit::keyboard::{Key, NamedKey};
+use llimphi_ui::llimphi_layout::taffy::{
+    prelude::{length, percent, FlexDirection, Size, Style},
+    Dimension,
+};
+use llimphi_ui::{App, Handle, KeyEvent, KeyState, View};
+use llimphi_widget_context_menu::{context_menu_view_ex, ContextMenuExtras};
+use llimphi_widget_edit_menu::{self as editmenu, EditAction};
+use llimphi_widget_list::ListPalette;
+use llimphi_widget_menubar::{
+    menubar_command_at, menubar_nav, menubar_overlay_animated, menubar_view,
+};
+use llimphi_widget_text_editor::{EditorMetrics, EditorPalette, PointerEvent};
+use llimphi_widget_text_input::{TextInputEvent, TextInputPalette};
+
+use crate::cuerdas::{cuerdas_panel, max_pan, overlay_left, overlay_right, pick, Pick};
+use crate::estado::{
+    commit_edits, data_file_path, deselect, emitir_checkpoint_nota, from_state, load_state,
+    nombre_propuesto, now_secs, persist, resonancia_de, schedule_embedding, seeded_model, select,
+    start_unlock, unlock_identity,
+};
+use crate::menu::{app_menu, focused_edit_flags, menubar_spec};
+use crate::modelo::{
+    Embedder, Focus, Model, Msg, Resonancia, EDITOR_OVERLAY_W, LIST_WIDTH,
+};
+use crate::net::{ensure_p2p, export_notebook, import_notebook, peer_addr, start_publishing};
+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;
+
+/// 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;
+
+// =====================================================================
+// Helpers locales de menú (no pueden vivir en menu.rs por ciclo de módulos)
+// =====================================================================
+
+/// Traduce el `command` del menú principal al `Msg` real y lo redespacha
+/// por el `update`. Cierra el menú antes de actuar.
+fn handle_menu_command(mut model: Model, command: String, h: &Handle<Msg>) -> Model {
+    model.menu_open = None;
+    let target = match command.as_str() {
+        "note.captura" => Some(Msg::Focus(Focus::Captura)),
+        "note.delete" => Some(Msg::DeleteSelected),
+        "share.export" => Some(Msg::Export),
+        "share.import" => Some(Msg::Import),
+        "share.publish" => Some(Msg::Publish),
+        "share.receive" => Some(Msg::Receive),
+        "view.search" => Some(Msg::Focus(Focus::Search)),
+        "cuerda.tejer" => model.cuerda_sel.map(Msg::Tejer),
+        "cuerda.renombrar" => model.cuerda_sel.map(Msg::RenombrarCuerda),
+        "cuerda.desatar" => model.cuerda_sel.map(Msg::BorrarCuerda),
+        "edit.undo" => Some(Msg::EditMenuAction(EditAction::Undo)),
+        "edit.redo" => Some(Msg::EditMenuAction(EditAction::Redo)),
+        "edit.cut" => Some(Msg::EditMenuAction(EditAction::Cut)),
+        "edit.copy" => Some(Msg::EditMenuAction(EditAction::Copy)),
+        "edit.paste" => Some(Msg::EditMenuAction(EditAction::Paste)),
+        "edit.selectall" => Some(Msg::EditMenuAction(EditAction::SelectAll)),
+        "help.about" => {
+            model.status = Some("khipu · cuaderno de resonancia P2P soberano".into());
+            None
+        }
+        _ => None,
+    };
+    match target {
+        Some(msg) => KhipuApp::update(model, msg, h),
+        None => model,
+    }
+}
+
+/// Aplica una acción del menú de edición al editor del campo focuseado,
+/// usando el portapapeles del sistema. Cierra el menú de edición.
+fn apply_edit_menu_action(mut model: Model, action: EditAction, h: &Handle<Msg>) -> Model {
+    model.edit_menu = None;
+    let focus = model.focus;
+    let clip = &mut model.clipboard;
+    let result = match focus {
+        Focus::Body => Some(editmenu::apply(&mut model.body, action, clip)),
+        Focus::Captura => Some(editmenu::apply(model.captura.editor_mut(), action, clip)),
+        Focus::Title => Some(editmenu::apply(model.title.editor_mut(), action, clip)),
+        Focus::Tags => Some(editmenu::apply(model.tags.editor_mut(), action, clip)),
+        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::Passphrase => Some(editmenu::apply(model.passphrase.editor_mut(), action, clip)),
+        Focus::None => None,
+    };
+    // Si la acción cambió un campo persistente de la nota, corremos el commit.
+    if let Some(r) = result {
+        if r.changed() && matches!(focus, Focus::Body | Focus::Title | Focus::Tags) {
+            commit_edits(&mut model, h);
+        }
+    }
+    model
+}
+
+/// Divide el texto de una captura: la primera línea es el título; el
+/// resto (si lo hay) cae al cuerpo.
+fn split_captura(texto: &str) -> (String, String) {
+    match texto.split_once('\n') {
+        Some((titulo, cuerpo)) => (titulo.trim().to_string(), cuerpo.trim().to_string()),
+        None => (texto.trim().to_string(), String::new()),
+    }
+}
+
+// =====================================================================
+// App impl
+// =====================================================================
+
+impl App for KhipuApp {
+    type Model = Model;
+    type Msg = Msg;
+
+    fn init(handle: &Handle<Msg>) -> Model {
+        // Conectamos al daemon una sola vez al arrancar.
+        let embedder = Embedder::connect();
+        let data_path = data_file_path();
+        let mut model = match data_path.as_ref().and_then(load_state) {
+            Some(state) => from_state(state, embedder),
+            None => {
+                // Un archivo presente pero ilegible NUNCA se pisa con el
+                // seed sin dejar copia: el cuaderno vale más que la app.
+                if let Some(p) = data_path.as_ref().filter(|p| p.exists()) {
+                    let _ = std::fs::copy(p, p.with_extension("ilegible.bak"));
+                }
+                seeded_model(embedder)
+            }
+        };
+        model.data_path = data_path;
+        // Identidad: si `KHIPU_PASSPHRASE` está en el entorno, desbloqueamos
+        // sin prompt — útil headless.
+        model.keypair = std::env::var("KHIPU_PASSPHRASE")
+            .ok()
+            .and_then(|p| unlock_identity(&p));
+        model.theme = llimphi_theme::Theme::dark();
+        // Con bootstrap configurado, arrancamos el nodo libp2p ya.
+        if std::env::var("KHIPU_BOOTSTRAP").is_ok() {
+            ensure_p2p(&mut model);
+        }
+        persist(&model);
+        // Latido lento — la frescura pintada de las cuerdas se actualiza
+        // sola aunque nadie toque nada. (60 s: es brillo, no física.)
+        handle.spawn_periodic(std::time::Duration::from_secs(60), || Msg::Tick);
+        model
+    }
+
+    fn update(mut model: Model, msg: Msg, h: &Handle<Msg>) -> Model {
+        match msg {
+            // ------------------------------------------------ captura
+            Msg::Capturar => {
+                let texto = model.captura.text().trim().to_string();
+                if texto.is_empty() {
+                    return model;
+                }
+                let (titulo, cuerpo) = split_captura(&texto);
+                let now = now_secs();
+                let id = model.store.create(titulo, cuerpo, Vec::new(), now);
+                model.order.push(id);
+                model.captura.clear();
+                model.focus = Focus::Captura;
+                // El eco arranca "calculando": el panel aparece apenas el
+                // embedding vuelve del worker (Msg::EmbeddingReady).
+                model.resonancia = Some(Resonancia {
+                    nudo: id,
+                    vecinos: Vec::new(),
+                    calculando: true,
+                });
+                schedule_embedding(&mut model, id, h);
+                persist(&model);
+                // Timeline universal (#6): checkpoint al capturar un nudo
+                // (límite discreto — no en cada tecla).
+                emitir_checkpoint_nota(&model, id);
+            }
+            Msg::EmbeddingReady(id, seq, v) => {
+                if model.embed_latest.get(&id) == Some(&seq) && model.store.get(id).is_some() {
+                    model.field.insert_etiquetado(id, v, &model.embedder.label());
+                    // ¿Era la captura cuyo eco esperamos? Resolvemos la
+                    // resonancia ahora que su vector existe.
+                    if model.resonancia.as_ref().map(|r| r.nudo) == Some(id) {
+                        let vecinos = resonancia_de(&model, id);
+                        if vecinos.is_empty() {
+                            model.resonancia = None;
+                            model.status =
+                                Some("quedó suelto — no resonó con nada todavía".into());
+                        } else {
+                            model.resonancia = Some(Resonancia {
+                                nudo: id,
+                                vecinos,
+                                calculando: false,
+                            });
+                        }
+                    }
+                    persist(&model);
+                }
+            }
+            Msg::Anudar(nota, cuerda) => {
+                if model.store.anudar(nota, cuerda) {
+                    if model.resonancia.as_ref().map(|r| r.nudo) == Some(nota) {
+                        model.resonancia = None;
+                    }
+                    model.cuerda_sel = Some(cuerda);
+                    let nombre = model
+                        .store
+                        .cuerda(cuerda)
+                        .map(|c| c.nombre.clone())
+                        .unwrap_or_default();
+                    model.status = Some(format!("anudado a «{nombre}»"));
+                    persist(&model);
+                }
+            }
+            Msg::AnudarJuntos(a, b) => {
+                let now = now_secs();
+                let nombre = nombre_propuesto(&model, &[a, b]);
+                let cuerda = model.store.crear_cuerda(&nombre, now);
+                model.store.anudar(a, cuerda);
+                model.store.anudar(b, cuerda);
+                if model.resonancia.as_ref().map(|r| r.nudo) == Some(a) {
+                    model.resonancia = None;
+                }
+                model.cuerda_sel = Some(cuerda);
+                model.status = Some(format!(
+                    "cuerda nueva «{nombre}» — renombrala si el nombre no te dice nada"
+                ));
+                persist(&model);
+            }
+            Msg::NuevaCuerda(nota) => {
+                let now = now_secs();
+                let nombre = nombre_propuesto(&model, &[nota]);
+                let cuerda = model.store.crear_cuerda(&nombre, now);
+                model.store.anudar(nota, cuerda);
+                if model.resonancia.as_ref().map(|r| r.nudo) == Some(nota) {
+                    model.resonancia = None;
+                }
+                model.cuerda_sel = Some(cuerda);
+                model.status = Some(format!("cuerda nueva «{nombre}»"));
+                persist(&model);
+            }
+            Msg::DescartarResonancia => {
+                model.resonancia = None;
+            }
+            Msg::Resonar(id) => {
+                let vecinos = resonancia_de(&model, id);
+                if vecinos.is_empty() {
+                    model.status = Some("no resuena con nada todavía".into());
+                } else {
+                    model.resonancia = Some(Resonancia {
+                        nudo: id,
+                        vecinos,
+                        calculando: false,
+                    });
+                }
+            }
+            Msg::Desanudar(id) => {
+                if model.store.desanudar(id) {
+                    model.status = Some("nudo suelto de nuevo".into());
+                    persist(&model);
+                }
+            }
+            Msg::CiclarEstado(id) => {
+                use khipu_core::EstadoNudo::*;
+                if let Some(n) = model.store.get_mut(id) {
+                    n.estado = match n.estado {
+                        Normal => Pendiente,
+                        Pendiente => Hecho,
+                        Hecho => Normal,
+                    };
+                    persist(&model);
+                }
+            }
+
+            // ------------------------------------------------ cuerdas
+            Msg::SelectCuerda(sel) => {
+                model.cuerda_sel = sel;
+            }
+            Msg::RenombrarCuerda(cid) => {
+                if let Some(c) = model.store.cuerda(cid) {
+                    model.nombre_input.set_text(c.nombre.clone());
+                    model.renombrando = Some(cid);
+                    model.focus = Focus::Nombre;
+                }
+            }
+            Msg::CommitNombre => {
+                if let Some(cid) = model.renombrando.take() {
+                    let nombre = model.nombre_input.text().trim().to_string();
+                    if !nombre.is_empty() {
+                        if let Some(c) = model.store.cuerda_mut(cid) {
+                            c.nombre = nombre;
+                        }
+                        persist(&model);
+                    }
+                }
+                model.nombre_input.clear();
+                model.focus = Focus::None;
+            }
+            Msg::CancelNombre => {
+                model.renombrando = None;
+                model.nombre_input.clear();
+                model.focus = Focus::None;
+            }
+            Msg::BorrarCuerda(cid) => {
+                if model.store.borrar_cuerda(cid).is_some() {
+                    if model.cuerda_sel == Some(cid) {
+                        model.cuerda_sel = None;
+                    }
+                    model.status =
+                        Some("cuerda desatada — sus nudos quedaron sueltos".into());
+                    persist(&model);
+                }
+            }
+            Msg::Tejer(cid) => {
+                if model.tejiendo.is_some() {
+                    return model;
+                }
+                let Some(c) = model.store.cuerda(cid) else {
+                    return model;
+                };
+                let materia = model
+                    .store
+                    .nudos_de(cid)
+                    .iter()
+                    .filter(|n| c.tejido != Some(n.id))
+                    .count();
+                if materia < 2 {
+                    model.status =
+                        Some("una cuerda se teje con al menos dos nudos de materia".into());
+                    return model;
+                }
+                model.tejiendo = Some(cid);
+                model.status = Some(format!("tejiendo «{}»…", c.nombre));
+                tejido::tejer(&model, cid, h);
+            }
+            Msg::TejidoListo(cid, resultado) => {
+                model.tejiendo = None;
+                match resultado {
+                    Err(e) => model.status = Some(e),
+                    Ok(texto) => {
+                        let now = now_secs();
+                        let Some(c) = model.store.cuerda(cid) else {
+                            return model;
+                        };
+                        let nombre = c.nombre.clone();
+                        let tid = match c.tejido {
+                            // Re-tejer: el mismo nudo-síntesis, cuerpo nuevo.
+                            Some(tid) if model.store.get(tid).is_some() => {
+                                model.store.update_body(tid, texto, now);
+                                tid
+                            }
+                            _ => {
+                                let tid = model.store.create(
+                                    format!("tejido · {nombre}"),
+                                    texto,
+                                    vec!["tejido".into()],
+                                    now,
+                                );
+                                model.order.push(tid);
+                                model.store.anudar(tid, cid);
+                                if let Some(c) = model.store.cuerda_mut(cid) {
+                                    c.tejido = Some(tid);
+                                }
+                                tid
+                            }
+                        };
+                        schedule_embedding(&mut model, tid, h);
+                        // El encuadre se abre para leer/editar de una.
+                        select(&mut model, tid);
+                        model.status = Some(format!(
+                            "tejida «{nombre}» — el encuadre es un nudo editable"
+                        ));
+                        persist(&model);
+                    }
+                }
+            }
+            Msg::CuerdasPan(dx) => {
+                let tope = max_pan(&model, model.canvas_size.0);
+                model.pan_x = (model.pan_x + dx).clamp(0.0, tope);
+            }
+            Msg::CuerdasClick(lx, ly, w, h_px) => {
+                model.canvas_size = (w, h_px);
+                match pick(&model, lx, ly, h_px) {
+                    Pick::Nudo(nid, cid) => {
+                        commit_edits(&mut model, h);
+                        select(&mut model, nid);
+                        model.cuerda_sel = Some(cid);
+                    }
+                    Pick::Cabeza(cid) => {
+                        model.cuerda_sel = if model.cuerda_sel == Some(cid) {
+                            None
+                        } else {
+                            Some(cid)
+                        };
+                    }
+                    Pick::Nada => {}
+                }
+            }
+
+            // ------------------------------------------------ notas
+            Msg::SelectNote(id) => {
+                commit_edits(&mut model, h);
+                select(&mut model, id);
+                // Elegir un nudo enfoca también a su cuerda (contexto).
+                model.cuerda_sel = model.store.get(id).and_then(|n| n.cuerda);
+            }
+            Msg::DeleteSelected => {
+                if let Some(id) = model.selected {
+                    model.store.remove(id);
+                    model.order.retain(|x| *x != id);
+                    model.field.remove(id);
+                    if model.resonancia.as_ref().map(|r| r.nudo) == Some(id) {
+                        model.resonancia = None;
+                    }
+                    deselect(&mut model);
+                    persist(&model);
+                }
+            }
+            Msg::Deselect => {
+                commit_edits(&mut model, h);
+                deselect(&mut model);
+                persist(&model);
+            }
+            Msg::ToggleList => {
+                model.show_list = !model.show_list;
+            }
+            Msg::Tick => {
+                // No muta nada: el Tick existe sólo para repintar la
+                // frescura de las cuerdas.
+            }
+            Msg::EscapeMapa => {
+                if model.renombrando.is_some() {
+                    model.renombrando = None;
+                    model.nombre_input.clear();
+                    model.focus = Focus::None;
+                } else if model.resonancia.is_some() {
+                    model.resonancia = None;
+                } else if model.selected.is_some() {
+                    commit_edits(&mut model, h);
+                    deselect(&mut model);
+                    persist(&model);
+                } else if model.show_list {
+                    model.show_list = false;
+                } else if model.cuerda_sel.is_some() {
+                    model.cuerda_sel = None;
+                } else {
+                    model.focus = Focus::None;
+                }
+            }
+
+            // ------------------------------------------------ campos
+            Msg::Focus(f) => {
+                commit_edits(&mut model, h);
+                model.focus = f;
+            }
+            Msg::Campo(f, ev) => {
+                // Un `Press` enfoca el campo (y confirma la edición previa);
+                // el arrastre extiende la selección. El teclado sigue por
+                // `Msg::Key`.
+                if matches!(ev, TextInputEvent::Press(_)) && model.focus != f {
+                    commit_edits(&mut model, h);
+                    model.focus = f;
+                }
+                let clip = &mut model.clipboard;
+                match f {
+                    Focus::Captura => {
+                        model.captura.handle(ev, clip);
+                    }
+                    Focus::Title => {
+                        model.title.handle(ev, clip);
+                    }
+                    Focus::Tags => {
+                        model.tags.handle(ev, clip);
+                    }
+                    Focus::Search => {
+                        model.search.handle(ev, clip);
+                    }
+                    Focus::Passphrase => {
+                        model.passphrase.handle(ev, clip);
+                    }
+                    Focus::PeerAddr => {
+                        model.peer_input.handle(ev, clip);
+                    }
+                    Focus::Nombre => {
+                        model.nombre_input.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 => {}
+                }
+            }
+            Msg::Key(ev) => {
+                // El teclado va al campo enfocado. `handle` cubre copiar/
+                // cortar/pegar (Ctrl+C/X/V) contra el portapapeles del
+                // sistema; el cuerpo es un editor multilínea aparte.
+                let changed = match model.focus {
+                    Focus::Captura => {
+                        model.captura.handle(TextInputEvent::Key(ev), &mut model.clipboard);
+                        false
+                    }
+                    Focus::Title => model.title.handle(TextInputEvent::Key(ev), &mut model.clipboard),
+                    Focus::Body => model.body.apply_key(&ev).touched(),
+                    Focus::Tags => model.tags.handle(TextInputEvent::Key(ev), &mut model.clipboard),
+                    Focus::Search => {
+                        model.search.handle(TextInputEvent::Key(ev), &mut model.clipboard);
+                        false
+                    }
+                    Focus::Passphrase => {
+                        model.passphrase.handle(TextInputEvent::Key(ev), &mut model.clipboard);
+                        false
+                    }
+                    Focus::PeerAddr => {
+                        model.peer_input.handle(TextInputEvent::Key(ev), &mut model.clipboard);
+                        false
+                    }
+                    Focus::Nombre => {
+                        model.nombre_input.handle(TextInputEvent::Key(ev), &mut model.clipboard);
+                        false
+                    }
+                    Focus::None => false,
+                };
+                if changed {
+                    commit_edits(&mut model, h);
+                }
+            }
+            Msg::EditorPointer(ev) => {
+                let metrics = EditorMetrics::for_font_size(13.0);
+                match ev {
+                    PointerEvent::Click { x, y } => {
+                        let (line, col) = metrics.screen_to_pos(x, y, model.body.scroll_offset);
+                        model.body.set_caret_at(line, col);
+                    }
+                    PointerEvent::Drag { initial_x, initial_y, dx, dy } => {
+                        let (l0, c0) = metrics.screen_to_pos(
+                            initial_x,
+                            initial_y,
+                            model.body.scroll_offset,
+                        );
+                        let (l1, c1) = metrics.screen_to_pos(
+                            initial_x + dx,
+                            initial_y + dy,
+                            model.body.scroll_offset,
+                        );
+                        model.body.set_caret_at(l0, c0);
+                        model.body.extend_selection_to(l1, c1);
+                    }
+                }
+                model.focus = Focus::Body;
+            }
+
+            // ------------------------------------------------ compartir
+            Msg::Export => {
+                if model.keypair.is_none() {
+                    start_unlock(&mut model, Msg::Export);
+                } else {
+                    commit_edits(&mut model, h);
+                    model.status = Some(export_notebook(&model));
+                }
+            }
+            Msg::Import => {
+                let report = import_notebook(&mut model, h);
+                persist(&model);
+                model.status = Some(report);
+            }
+            Msg::Publish => {
+                if model.keypair.is_none() {
+                    start_unlock(&mut model, Msg::Publish);
+                } else {
+                    commit_edits(&mut model, h);
+                    let _ = export_notebook(&model);
+                    model.status = Some(start_publishing(&mut model, h));
+                }
+            }
+            Msg::Receive => {
+                let my_key = model.keypair.as_ref().map(|k| k.public_key());
+                model.receiving = true;
+                model.peers.clear();
+                if model.peer_input.is_empty() {
+                    model.peer_input.set_text(peer_addr());
+                }
+                model.focus = Focus::PeerAddr;
+                model.status = Some("buscando pares (LAN + DHT)… o escribe una dirección".into());
+                let dht = model.p2p.as_ref().map(|p| (p.rt.clone(), p.node.clone()));
+                h.spawn(move || {
+                    let mut infos: Vec<crate::modelo::PeerInfo> =
+                        khipu_share::discovery::descubrir(std::time::Duration::from_secs(3))
+                            .unwrap_or_default()
+                            .into_iter()
+                            .filter(|p| Some(p.beacon.author) != my_key)
+                            .map(|p| crate::modelo::PeerInfo {
+                                addr: p.fetch_addr.to_string(),
+                                label: format!(
+                                    "LAN · {} · de:{} · {}",
+                                    p.beacon.name,
+                                    khipu_share::hex8(&p.beacon.author),
+                                    p.fetch_addr
+                                ),
+                            })
+                            .collect();
+                    if let Some((rt, node)) = dht {
+                        let me = node.peer_id();
+                        for pid in rt.block_on(node.descubrir()) {
+                            if pid == me {
+                                continue;
+                            }
+                            let s = pid.to_string();
+                            let corto: String = s.chars().rev().take(8).collect::<Vec<_>>()
+                                .into_iter().rev().collect();
+                            infos.push(crate::modelo::PeerInfo {
+                                label: format!("DHT · …{corto}"),
+                                addr: s,
+                            });
+                        }
+                    }
+                    Msg::PeersFound(infos)
+                });
+            }
+            Msg::PeersFound(peers) => {
+                if model.receiving {
+                    model.status = Some(if peers.is_empty() {
+                        "ningún par en la LAN — escribe una dirección y jala".into()
+                    } else {
+                        format!("{} pares en la red — elige uno o escribe una dirección", peers.len())
+                    });
+                    model.peers = peers;
+                }
+            }
+            Msg::FetchManual => {
+                let addr = model.peer_input.text().trim().to_string();
+                if addr.is_empty() {
+                    model.status = Some("escribe una dirección host:puerto".into());
+                } else {
+                    h.dispatch(Msg::FetchFrom(addr));
+                }
+            }
+            Msg::FetchFrom(addr) => {
+                model.receiving = false;
+                model.peers.clear();
+                model.focus = Focus::None;
+                let destino = addr.trim().to_string();
+                if destino.starts_with('/') || !destino.contains(':') {
+                    // Vía libp2p: multiaddr o peer-id pelado.
+                    if ensure_p2p(&mut model) {
+                        let p = model.p2p.as_ref().expect("p2p recién armado");
+                        let (rt, node) = (p.rt.clone(), p.node.clone());
+                        let es_multiaddr = destino.starts_with('/');
+                        model.status = Some(format!("jalando por libp2p de {destino}…"));
+                        h.spawn(move || {
+                            let res = if es_multiaddr {
+                                rt.block_on(node.fetch_addr_str(&destino))
+                            } else {
+                                rt.block_on(node.fetch_peer_str(&destino))
+                            };
+                            match res {
+                                Ok(s) => Msg::Received(Ok(s)),
+                                Err(e) => Msg::Received(Err(format!("p2p: {e}"))),
+                            }
+                        });
+                    } else {
+                        model.status = Some("no se pudo iniciar el nodo libp2p".into());
+                    }
+                } else {
+                    // Dirección TCP `host:puerto` (LAN/WAN directa).
+                    model.status = Some(format!("jalando de {destino}…"));
+                    h.spawn(move || match khipu_share::net::fetch(&destino) {
+                        Ok(s) => Msg::Received(Ok(s)),
+                        Err(e) => Msg::Received(Err(format!("no se pudo recibir de {destino}: {e}"))),
+                    });
+                }
+            }
+            Msg::CancelPeers => {
+                model.receiving = false;
+                model.peers.clear();
+                model.focus = Focus::None;
+                model.status = Some("recibir cancelado".into());
+            }
+            Msg::Received(res) => {
+                model.receiving = false;
+                model.peers.clear();
+                model.status = Some(match res {
+                    Ok(sobre) => match khipu_share::open(&sobre) {
+                        Ok(bundle) => {
+                            let now = now_secs();
+                            let outcome =
+                                khipu_share::import_into(&mut model.store, bundle, now);
+                            for id in &outcome.created {
+                                model.order.push(*id);
+                                schedule_embedding(&mut model, *id, h);
+                            }
+                            persist(&model);
+                            format!(
+                                "recibidas {} · omitidas {} (ya existían)",
+                                outcome.created.len(),
+                                outcome.skipped
+                            )
+                        }
+                        Err(_) => "firma inválida — sobre rechazado".into(),
+                    },
+                    Err(e) => e,
+                });
+            }
+            Msg::Unlock => {
+                let pass = model.passphrase.text();
+                match unlock_identity(&pass) {
+                    Some(kp) => {
+                        let id = khipu_share::hex8(&kp.public_key());
+                        model.keypair = Some(kp);
+                        model.unlocking = false;
+                        model.passphrase.clear();
+                        model.focus = Focus::None;
+                        model.status = Some(format!("identidad desbloqueada · {id}"));
+                        if let Some(accion) = model.pending.take() {
+                            h.dispatch(*accion);
+                        }
+                    }
+                    None => {
+                        model.status =
+                            Some("passphrase incorrecta o sin acceso al keystore".into());
+                    }
+                }
+            }
+            Msg::CancelUnlock => {
+                model.unlocking = false;
+                model.pending = None;
+                model.passphrase.clear();
+                model.focus = Focus::None;
+                model.status = Some("desbloqueo cancelado".into());
+            }
+            Msg::RelayReady(addr) => {
+                model.status = Some(format!("alcanzable vía relay: {addr}"));
+            }
+
+            // ------------------------------------------------ menús
+            Msg::MenuOpen(idx) => {
+                model.menu_open = idx;
+                model.menu_active = usize::MAX;
+                model.edit_menu = None;
+                if idx.is_some() {
+                    model.menu_anim = Tween::new(0.0, 1.0, motion::FAST, motion::ease_out_cubic);
+                    animate(h, motion::FAST, || Msg::MenuTick);
+                }
+            }
+            Msg::MenuCommand(cmd) => {
+                return handle_menu_command(model, cmd, h);
+            }
+            Msg::MenuNav(dir) => {
+                if let Some(mi) = model.menu_open {
+                    let menu = app_menu(&model);
+                    model.menu_active = menubar_nav(&menu, mi, model.menu_active, dir);
+                }
+            }
+            Msg::MenuActivate => {
+                if let Some(mi) = model.menu_open {
+                    let menu = app_menu(&model);
+                    if let Some(cmd) = menubar_command_at(&menu, mi, model.menu_active) {
+                        return handle_menu_command(model, cmd, h);
+                    }
+                }
+            }
+            Msg::MenuTick => {}
+            Msg::EditNav(dir) => {
+                let flags = focused_edit_flags(&model);
+                model.edit_active = editmenu::edit_menu_step(flags, model.edit_active, dir);
+            }
+            Msg::EditActivate => {
+                let flags = focused_edit_flags(&model);
+                if let Some(action) = editmenu::edit_menu_action_at(flags, model.edit_active) {
+                    return apply_edit_menu_action(model, action, h);
+                }
+            }
+            Msg::EditMenuOpen(x, y) => {
+                model.edit_menu = Some((x, y));
+                model.edit_active = usize::MAX;
+                model.menu_open = None;
+                model.edit_anim = Tween::new(0.0, 1.0, motion::FAST, motion::ease_out_cubic);
+                animate(h, motion::FAST, || Msg::MenuTick);
+            }
+            Msg::EditMenuAction(action) => {
+                return apply_edit_menu_action(model, action, h);
+            }
+            Msg::CloseMenus => {
+                model.menu_open = None;
+                model.menu_active = usize::MAX;
+                model.edit_menu = None;
+                model.edit_active = usize::MAX;
+            }
+        }
+        model
+    }
+
+    fn view(model: &Model) -> View<Msg> {
+        let palette = ListPalette::from_theme(&model.theme);
+        let input_palette = TextInputPalette::from_theme(&model.theme);
+        let editor_palette = EditorPalette::from_theme(&model.theme);
+        let viewport = (INITIAL_W as f32, INITIAL_H as f32);
+
+        // Prompt de passphrase: ocupa toda la ventana hasta resolverse.
+        if model.unlocking {
+            let mut children = vec![header_view(model), unlock_view(model, &input_palette)];
+            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);
+
+        // Overlays que viven encima del lienzo: la tarjeta de la cuerda
+        // seleccionada, el eco de la captura y el input de renombre.
+        let mut injected: Vec<View<Msg>> = Vec::new();
+        if let Some(card) = tarjeta_cuerda(model) {
+            injected.push(card);
+        }
+        if let Some(res) = resonancia_panel(model) {
+            injected.push(res);
+        }
+        if let Some(nombre) = nombre_overlay(model, &input_palette) {
+            injected.push(nombre);
+        }
+        let lienzo = cuerdas_panel(model, injected);
+        let mut layers: Vec<View<Msg>> = vec![lienzo];
+
+        if model.show_list || model.receiving {
+            let drawer = if model.receiving {
+                receive_panel(model, &palette, &input_palette)
+            } else {
+                list_panel(model, &palette, &input_palette)
+            };
+            layers.push(overlay_left(drawer, LIST_WIDTH));
+        }
+
+        if model.selected.is_some() {
+            let editor = editor_panel(model, &input_palette, &editor_palette);
+            layers.push(overlay_right(editor, EDITOR_OVERLAY_W, &model.theme));
+        }
+
+        let body = 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(layers);
+
+        let menu = app_menu(model);
+        let menubar = menubar_view(&menubar_spec(&menu, model, &model.theme, viewport));
+
+        let mut children = vec![menubar, header, captura, sueltos, body];
+        if let Some(bar) = status_bar(model) {
+            children.push(bar);
+        }
+
+        View::new(Style {
+            flex_direction: FlexDirection::Column,
+            size: Size {
+                width: percent(1.0_f32),
+                height: percent(1.0_f32),
+            },
+            ..Default::default()
+        })
+        .fill(model.theme.bg_app)
+        .on_right_click_at(|x, y, _w, _h| Some(Msg::EditMenuOpen(x, y)))
+        .children(children)
+    }
+
+    fn view_overlay(model: &Model) -> Option<View<Msg>> {
+        let viewport = (INITIAL_W as f32, INITIAL_H as f32);
+        if model.unlocking {
+            return None;
+        }
+        if let Some((x, y)) = model.edit_menu {
+            let flags = focused_edit_flags(model);
+            let mut spec = editmenu::edit_context_menu(
+                (x, y),
+                viewport,
+                &model.theme,
+                flags,
+                Msg::EditMenuAction,
+                Msg::CloseMenus,
+            );
+            spec.active = model.edit_active;
+            return Some(context_menu_view_ex(
+                spec,
+                ContextMenuExtras {
+                    appear: model.edit_anim.value(),
+                    ..Default::default()
+                },
+            ));
+        }
+        let menu = app_menu(model);
+        menubar_overlay_animated(
+            &menubar_spec(&menu, model, &model.theme, viewport),
+            model.menu_active,
+            model.menu_anim.value(),
+        )
+    }
+
+    fn on_key(model: &Model, event: &KeyEvent) -> Option<Msg> {
+        // Menús abiertos: las flechas navegan y tienen prioridad sobre todo.
+        if event.state == KeyState::Pressed {
+            if let Some(mi) = model.menu_open {
+                let n = app_menu(model).menus.len().max(1);
+                return match &event.key {
+                    Key::Named(NamedKey::Escape) => Some(Msg::CloseMenus),
+                    Key::Named(NamedKey::ArrowLeft) => Some(Msg::MenuOpen(Some((mi + n - 1) % n))),
+                    Key::Named(NamedKey::ArrowRight) => Some(Msg::MenuOpen(Some((mi + 1) % n))),
+                    Key::Named(NamedKey::ArrowDown) => Some(Msg::MenuNav(1)),
+                    Key::Named(NamedKey::ArrowUp) => Some(Msg::MenuNav(-1)),
+                    Key::Named(NamedKey::Enter) => Some(Msg::MenuActivate),
+                    _ => None,
+                };
+            }
+            if model.edit_menu.is_some() {
+                return match &event.key {
+                    Key::Named(NamedKey::Escape) => Some(Msg::CloseMenus),
+                    Key::Named(NamedKey::ArrowDown) => Some(Msg::EditNav(1)),
+                    Key::Named(NamedKey::ArrowUp) => Some(Msg::EditNav(-1)),
+                    Key::Named(NamedKey::Enter) => Some(Msg::EditActivate),
+                    _ => None,
+                };
+            }
+        }
+        // Con el prompt de passphrase abierto, las teclas son sólo suyas.
+        if model.unlocking {
+            if event.state == KeyState::Pressed && !event.repeat {
+                if matches!(&event.key, Key::Named(NamedKey::Enter)) {
+                    return Some(Msg::Unlock);
+                }
+                if matches!(&event.key, Key::Named(NamedKey::Escape)) {
+                    return Some(Msg::CancelUnlock);
+                }
+            }
+            return Some(Msg::Key(event.clone()));
+        }
+        // En modo recibir con foco en la dirección: Enter jala, Esc cancela.
+        if model.receiving && model.focus == Focus::PeerAddr {
+            if event.state == KeyState::Pressed && !event.repeat {
+                if matches!(&event.key, Key::Named(NamedKey::Enter)) {
+                    return Some(Msg::FetchManual);
+                }
+                if matches!(&event.key, Key::Named(NamedKey::Escape)) {
+                    return Some(Msg::CancelPeers);
+                }
+            }
+            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 {
+                if matches!(&event.key, Key::Named(NamedKey::Enter)) {
+                    return Some(Msg::CommitNombre);
+                }
+                if matches!(&event.key, Key::Named(NamedKey::Escape)) {
+                    return Some(Msg::CancelNombre);
+                }
+            }
+            return Some(Msg::Key(event.clone()));
+        }
+        // En la captura: Enter bota el nudo.
+        if model.focus == Focus::Captura
+            && event.state == KeyState::Pressed
+            && !event.repeat
+            && matches!(&event.key, Key::Named(NamedKey::Enter))
+        {
+            return Some(Msg::Capturar);
+        }
+        // Atajos globales: Ctrl+N va a la captura, Ctrl+F a la búsqueda.
+        // Esc cierra en cascada.
+        if event.state == KeyState::Pressed && !event.repeat {
+            if event.modifiers.ctrl
+                && matches!(&event.key, Key::Character(s) if s.eq_ignore_ascii_case("n"))
+            {
+                return Some(Msg::Focus(Focus::Captura));
+            }
+            if event.modifiers.ctrl
+                && matches!(&event.key, Key::Character(s) if s.eq_ignore_ascii_case("f"))
+            {
+                return Some(Msg::Focus(Focus::Search));
+            }
+            if matches!(&event.key, Key::Named(NamedKey::Escape)) {
+                return Some(Msg::EscapeMapa);
+            }
+        }
+        Some(Msg::Key(event.clone()))
+    }
+
+    fn title() -> &'static str {
+        "khipu"
+    }
+
+    fn app_id() -> Option<&'static str> {
+        Some("tawasuyu.khipu")
+    }
+
+    fn initial_size() -> (u32, u32) {
+        (INITIAL_W, INITIAL_H)
+    }
+}
diff --git a/00_unanchay/khipu/khipu-app/src/cuerdas.rs b/00_unanchay/khipu/khipu-app/src/cuerdas.rs
new file mode 100644
index 0000000..4005f32
--- /dev/null
+++ b/00_unanchay/khipu/khipu-app/src/cuerdas.rs
@@ -0,0 +1,578 @@
+//! `cuerdas` — el lienzo raíz de khipu: un khipu literal.
+//!
+//! Una **viga** horizontal (la cuerda madre) cruza la parte alta; de ella
+//! cuelgan las **cuerdas** (ideas recurrentes), ordenadas por masa — la
+//! más gorda a la izquierda. A lo largo de cada cuerda, sus **nudos** en
+//! orden cronológico (arriba lo primero que se pensó); el **tejido** (la
+//! síntesis) se pinta como rombo. El grosor viene de cuántos nudos tiene
+//! la cuerda; el brillo, de cuán reciente fue su último crecimiento
+//! (`khipu_gravity::{masa_cuerda, frescura}`) — lo que crece arde, lo
+//! quieto reposa, nada desaparece.
+//!
+//! La geometría es una proyección pura del store (nada se persiste): el
+//! layout se deriva en [`layout_cuerdas`], el painter lo pinta y el
+//! hit-test ([`pick`]) lo consulta. Pan horizontal con arrastre o rueda.
+
+use khipu_core::{CuerdaId, EstadoNudo, NoteId};
+use khipu_gravity::{frescura, masa_cuerda, FRESCURA_MEDIA_VIDA_SECS};
+use llimphi_theme::Theme;
+use llimphi_ui::llimphi_layout::taffy::{
+    prelude::{auto, length, percent, FlexDirection, Position, Rect, Size, Style},
+    AlignItems, Dimension, JustifyContent,
+};
+use llimphi_ui::llimphi_raster::kurbo::{Affine, BezPath, Circle as KurboCircle, Stroke};
+use llimphi_ui::llimphi_raster::peniko::{Color, Fill};
+use llimphi_ui::llimphi_text::{draw_block, TextBlock};
+use llimphi_ui::{DragPhase, View};
+
+use crate::estado::now_secs;
+use crate::modelo::{Model, Msg};
+use crate::panels::button;
+
+// =====================================================================
+// Geometría
+// =====================================================================
+
+/// Margen izquierdo de la primera cuerda.
+const MARGEN_X: f32 = 90.0;
+/// Separación horizontal entre cuerdas.
+pub(crate) const ESPACIO_CUERDA: f32 = 150.0;
+/// Altura de la viga (cuerda madre) dentro del lienzo.
+const VIGA_Y: f32 = 34.0;
+/// Donde empiezan los nudos (deja lugar al nombre bajo la viga).
+const NUDOS_TOP: f32 = 84.0;
+/// Espacio que se reserva al pie del lienzo.
+const FONDO_PAD: f32 = 30.0;
+/// Separación vertical máxima entre nudos (se comprime si no entran).
+const ESPACIO_NUDO_MAX: f32 = 40.0;
+/// Radio de un nudo.
+const NUDO_R: f32 = 5.0;
+/// Tolerancia de click sobre un nudo (px).
+const TOL_NUDO: f32 = 14.0;
+
+/// Un nudo ya proyectado a coordenadas locales del lienzo.
+pub(crate) struct NudoVis {
+    pub(crate) id: NoteId,
+    pub(crate) y: f32,
+    pub(crate) estado: EstadoNudo,
+    pub(crate) es_tejido: bool,
+    pub(crate) titulo: String,
+}
+
+/// Una cuerda proyectada: su columna, su físico y sus nudos.
+pub(crate) struct CuerdaVis {
+    pub(crate) id: CuerdaId,
+    pub(crate) x: f32,
+    pub(crate) nombre: String,
+    /// Grosor de trazo (deriva de la masa — cantidad de nudos).
+    pub(crate) grosor: f32,
+    /// Alpha base (deriva de la frescura del último crecimiento).
+    pub(crate) brillo: f32,
+    pub(crate) color: Color,
+    pub(crate) nudos: Vec<NudoVis>,
+}
+
+/// Proyecta el store al lienzo: cuerdas ordenadas por masa descendente
+/// (la más gorda a la izquierda, desempate por id — estable), nudos en
+/// cronología descendente hacia abajo, tejido al final. Determinista.
+pub(crate) fn layout_cuerdas(model: &Model, h: f32, now: u64) -> Vec<CuerdaVis> {
+    let theme = &model.theme;
+    let mut cuerdas: Vec<(CuerdaId, usize, u64)> = model
+        .store
+        .cuerdas()
+        .map(|c| {
+            let nudos = model.store.nudos_de(c.id);
+            let ultimo = nudos.iter().map(|n| n.created_at).max().unwrap_or(c.created_at);
+            (c.id, nudos.len(), ultimo)
+        })
+        .collect();
+    cuerdas.sort_by(|a, b| {
+        masa_cuerda(b.1)
+            .partial_cmp(&masa_cuerda(a.1))
+            .unwrap_or(core::cmp::Ordering::Equal)
+            .then(a.0.cmp(&b.0))
+    });
+
+    cuerdas
+        .into_iter()
+        .enumerate()
+        .map(|(i, (cid, n_nudos, ultimo))| {
+            let x = MARGEN_X + i as f32 * ESPACIO_CUERDA - model.pan_x;
+            let cuerda = model.store.cuerda(cid).expect("recién listada");
+            let nudos = model.store.nudos_de(cid);
+            // El tejido se pinta al final aunque su fecha sea vieja:
+            // es el remate de la cuerda, no un nudo más de la cronología.
+            let (mut cuerpo, tejido): (Vec<_>, Vec<_>) =
+                nudos.into_iter().partition(|n| cuerda.tejido != Some(n.id));
+            cuerpo.extend(tejido);
+            let total = cuerpo.len();
+            let espacio = if total <= 1 {
+                ESPACIO_NUDO_MAX
+            } else {
+                ((h - NUDOS_TOP - FONDO_PAD) / (total as f32 - 1.0)).clamp(14.0, ESPACIO_NUDO_MAX)
+            };
+            let nudos_vis: Vec<NudoVis> = cuerpo
+                .iter()
+                .enumerate()
+                .map(|(j, n)| NudoVis {
+                    id: n.id,
+                    y: NUDOS_TOP + j as f32 * espacio,
+                    estado: n.estado,
+                    es_tejido: cuerda.tejido == Some(n.id),
+                    titulo: short_label(&n.title),
+                })
+                .collect();
+            CuerdaVis {
+                id: cid,
+                x,
+                nombre: cuerda.nombre.clone(),
+                grosor: 1.4 + masa_cuerda(n_nudos) * 1.5,
+                brillo: 0.45 + 0.55 * frescura(ultimo, now, FRESCURA_MEDIA_VIDA_SECS),
+                color: color_cuerda(cid, *theme),
+                nudos: nudos_vis,
+            }
+        })
+        .collect()
+}
+
+/// Qué hay bajo un click en el lienzo, en coordenadas locales.
+pub(crate) enum Pick {
+    Nudo(NoteId, CuerdaId),
+    Cabeza(CuerdaId),
+    Nada,
+}
+
+/// Resuelve el click: primero nudos (radio de tolerancia), después la
+/// cabeza de la cuerda (nombre + tramo sobre los nudos), después nada.
+pub(crate) fn pick(model: &Model, lx: f32, ly: f32, h: f32) -> Pick {
+    let now = now_secs();
+    let layout = layout_cuerdas(model, h, now);
+    for c in &layout {
+        for n in &c.nudos {
+            let d2 = (lx - c.x).powi(2) + (ly - n.y).powi(2);
+            if d2 <= TOL_NUDO * TOL_NUDO {
+                return Pick::Nudo(n.id, c.id);
+            }
+        }
+    }
+    for c in &layout {
+        if (lx - c.x).abs() < ESPACIO_CUERDA * 0.45 && ly > VIGA_Y - 12.0 && ly < NUDOS_TOP {
+            return Pick::Cabeza(c.id);
+        }
+    }
+    Pick::Nada
+}
+
+/// Máximo pan horizontal: que la última cuerda pueda acercarse al margen.
+pub(crate) fn max_pan(model: &Model, w: f32) -> f32 {
+    let n = model.store.cuerdas_len() as f32;
+    (MARGEN_X + n * ESPACIO_CUERDA - w * 0.6).max(0.0)
+}
+
+// =====================================================================
+// El panel del lienzo
+// =====================================================================
+
+/// El lienzo de cuerdas: canvas `paint_with` + pan por arrastre/rueda +
+/// click con hit-test. `injected` son overlays hijos (tarjeta de cuerda,
+/// etc.) que viajan encima del pintado.
+pub(crate) fn cuerdas_panel(model: &Model, injected: Vec<View<Msg>>) -> View<Msg> {
+    let theme = model.theme;
+    let now = now_secs();
+    // La proyección se materializa aquí (h con el último tamaño conocido)
+    // para viajar plana dentro de la closure de pintura.
+    let layout = layout_cuerdas(model, model.canvas_size.1, now);
+    let sel_nota = model.selected;
+    let sel_cuerda = model.cuerda_sel;
+    let vacio = model.store.cuerdas_len() == 0;
+
+    let canvas = View::new(Style {
+        size: Size {
+            width: percent(1.0_f32),
+            height: percent(1.0_f32),
+        },
+        ..Default::default()
+    })
+    .fill(theme.bg_panel_alt)
+    .paint_with(move |scene, ts, rect| {
+        paint_cuerdas(scene, ts, rect, &layout, sel_nota, sel_cuerda, vacio, theme);
+    })
+    .draggable_at(|phase, dx, _dy, _lx, _ly| match phase {
+        DragPhase::Move => Some(Msg::CuerdasPan(-dx)),
+        DragPhase::End => None,
+    })
+    .on_scroll(|_dx, dy| Some(Msg::CuerdasPan(dy * 36.0)))
+    .on_click_at(|lx, ly, w, h| Some(Msg::CuerdasClick(lx, ly, w, h)))
+    .children(injected);
+
+    View::new(Style {
+        size: Size {
+            width: Dimension::auto(),
+            height: percent(1.0_f32),
+        },
+        flex_grow: 1.0,
+        flex_basis: length(0.0_f32),
+        min_size: Size {
+            width: length(0.0_f32),
+            height: length(0.0_f32),
+        },
+        padding: Rect {
+            left: length(4.0_f32),
+            right: length(4.0_f32),
+            top: length(4.0_f32),
+            bottom: length(4.0_f32),
+        },
+        ..Default::default()
+    })
+    .fill(theme.bg_panel)
+    .children(vec![canvas])
+}
+
+#[allow(clippy::too_many_arguments)]
+fn paint_cuerdas(
+    scene: &mut llimphi_ui::llimphi_raster::vello::Scene,
+    ts: &mut llimphi_ui::llimphi_text::Typesetter,
+    rect: llimphi_ui::PaintRect,
+    layout: &[CuerdaVis],
+    sel_nota: Option<NoteId>,
+    sel_cuerda: Option<CuerdaId>,
+    vacio: bool,
+    theme: Theme,
+) {
+    if rect.w <= 0.0 || rect.h <= 0.0 {
+        return;
+    }
+    let sx = |x: f32| (rect.x + x) as f64;
+    let sy = |y: f32| (rect.y + y) as f64;
+
+    // La viga: la cuerda madre de la que cuelga todo.
+    let mut viga = BezPath::new();
+    viga.move_to((sx(0.0), sy(VIGA_Y)));
+    viga.line_to((sx(rect.w), sy(VIGA_Y)));
+    scene.stroke(
+        &Stroke::new(3.0),
+        Affine::IDENTITY,
+        with_alpha(theme.fg_muted, 0.45),
+        None,
+        &viga,
+    );
+
+    if vacio {
+        draw_block(
+            scene,
+            ts,
+            &TextBlock::simple(
+                "las cuerdas nacen cuando dos nudos resuenan — bota una idea arriba",
+                13.0,
+                with_alpha(theme.fg_muted, 0.8),
+                (sx(rect.w * 0.5 - 240.0), sy(rect.h * 0.42)),
+            ),
+        );
+        return;
+    }
+
+    for c in layout {
+        // Culling: fuera del lienzo no se pinta.
+        if c.x < -ESPACIO_CUERDA || c.x > rect.w + ESPACIO_CUERDA {
+            continue;
+        }
+        let seleccionada = sel_cuerda == Some(c.id);
+        let brillo = if seleccionada { 1.0 } else { c.brillo };
+        let col = with_alpha(c.color, brillo);
+
+        // Amarre a la viga.
+        let amarre = KurboCircle::new((sx(c.x), sy(VIGA_Y)), 3.6);
+        scene.fill(Fill::NonZero, Affine::IDENTITY, col, None, &amarre);
+        if seleccionada {
+            let ring = KurboCircle::new((sx(c.x), sy(VIGA_Y)), 6.5);
+            scene.stroke(&Stroke::new(1.6), Affine::IDENTITY, theme.accent, None, &ring);
+        }
+
+        // El nombre, colgado bajo la viga.
+        let size = 11.5_f32;
+        let est_w = c.nombre.chars().count() as f64 * size as f64 * 0.52;
+        let name_col = if seleccionada {
+            theme.accent
+        } else {
+            with_alpha(theme.fg_text, (brillo + 0.15).clamp(0.0, 1.0))
+        };
+        draw_block(
+            scene,
+            ts,
+            &TextBlock::simple(
+                &c.nombre,
+                size,
+                name_col,
+                (sx(c.x) - est_w * 0.5, sy(VIGA_Y + 12.0)),
+            ),
+        );
+
+        // La cuerda: de la viga al último nudo (o un colgajo corto si
+        // está vacía).
+        let y_fin = c.nudos.last().map(|n| n.y + 14.0).unwrap_or(NUDOS_TOP);
+        let mut linea = BezPath::new();
+        linea.move_to((sx(c.x), sy(VIGA_Y)));
+        linea.line_to((sx(c.x), sy(y_fin)));
+        scene.stroke(
+            &Stroke::new(c.grosor as f64),
+            Affine::IDENTITY,
+            with_alpha(c.color, brillo * 0.8),
+            None,
+            &linea,
+        );
+
+        // Los nudos.
+        for n in &c.nudos {
+            let (px, py) = (sx(c.x), sy(n.y));
+            if n.es_tejido {
+                // El tejido: rombo lleno, el remate brillante de la cuerda.
+                let r = (NUDO_R + 2.5) as f64;
+                let mut rombo = BezPath::new();
+                rombo.move_to((px, py - r));
+                rombo.line_to((px + r, py));
+                rombo.line_to((px, py + r));
+                rombo.line_to((px - r, py));
+                rombo.close_path();
+                scene.fill(Fill::NonZero, Affine::IDENTITY, theme.accent, None, &rombo);
+            } else {
+                let circle = KurboCircle::new((px, py), NUDO_R as f64);
+                match n.estado {
+                    EstadoNudo::Normal => {
+                        scene.fill(Fill::NonZero, Affine::IDENTITY, col, None, &circle);
+                    }
+                    EstadoNudo::Pendiente => {
+                        // Anillo hueco: un nudo por hacer.
+                        scene.stroke(&Stroke::new(1.8), Affine::IDENTITY, col, None, &circle);
+                    }
+                    EstadoNudo::Hecho => {
+                        scene.fill(
+                            Fill::NonZero,
+                            Affine::IDENTITY,
+                            with_alpha(c.color, brillo * 0.35),
+                            None,
+                            &circle,
+                        );
+                    }
+                }
+            }
+            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);
+            }
+            // Etiquetas sólo en la cuerda seleccionada (o el nudo elegido):
+            // de lejos el khipu se lee por forma, de cerca por texto.
+            if seleccionada || sel_nota == Some(n.id) {
+                let lbl_col = with_alpha(theme.fg_text, (brillo + 0.2).clamp(0.0, 1.0));
+                draw_block(
+                    scene,
+                    ts,
+                    &TextBlock::simple(&n.titulo, 10.0, lbl_col, (px + 10.0, py - 6.0)),
+                );
+            }
+        }
+    }
+}
+
+// =====================================================================
+// Overlays de flujo (compartidos con app.rs)
+// =====================================================================
+
+/// Envuelve `child` como cajón absoluto pegado al borde izquierdo, alto
+/// completo con un margen. El lienzo de fondo sigue recibiendo eventos en
+/// el resto de la ventana; sólo los clicks sobre el cajón los come él.
+pub(crate) fn overlay_left(child: View<Msg>, width: f32) -> View<Msg> {
+    View::new(Style {
+        position: Position::Absolute,
+        inset: Rect {
+            left: length(8.0_f32),
+            top: length(8.0_f32),
+            bottom: length(8.0_f32),
+            right: auto(),
+        },
+        size: Size {
+            width: length(width),
+            height: auto(),
+        },
+        flex_direction: FlexDirection::Column,
+        ..Default::default()
+    })
+    .children(vec![child])
+}
+
+/// Columna interna del editor: barra de cierre (× ⇒ deselecciona) arriba +
+/// el editor abajo, sobre `bg_panel`.
+pub(crate) fn editor_shell(child: View<Msg>, theme: &Theme) -> View<Msg> {
+    let close = button("× cerrar", theme.bg_button, theme.fg_muted, Msg::Deselect);
+    let close_row = View::new(Style {
+        flex_direction: FlexDirection::Row,
+        size: Size {
+            width: percent(1.0_f32),
+            height: length(28.0_f32),
+        },
+        flex_shrink: 0.0,
+        justify_content: Some(JustifyContent::End),
+        align_items: Some(AlignItems::Center),
+        padding: Rect {
+            left: length(6.0_f32),
+            right: length(6.0_f32),
+            top: length(2.0_f32),
+            bottom: length(2.0_f32),
+        },
+        ..Default::default()
+    })
+    .fill(theme.bg_panel_alt)
+    .children(vec![close]);
+
+    View::new(Style {
+        flex_direction: FlexDirection::Column,
+        size: Size {
+            width: percent(1.0_f32),
+            height: percent(1.0_f32),
+        },
+        ..Default::default()
+    })
+    .fill(theme.bg_panel)
+    .children(vec![close_row, child])
+}
+
+/// Envuelve `child` como panel absoluto pegado al borde derecho, alto
+/// completo, con barra de cierre — el editor del nudo abierto.
+pub(crate) fn overlay_right(child: View<Msg>, width: f32, theme: &Theme) -> View<Msg> {
+    View::new(Style {
+        position: Position::Absolute,
+        inset: Rect {
+            left: auto(),
+            top: length(8.0_f32),
+            bottom: length(8.0_f32),
+            right: length(8.0_f32),
+        },
+        size: Size {
+            width: length(width),
+            height: auto(),
+        },
+        flex_direction: FlexDirection::Column,
+        ..Default::default()
+    })
+    .children(vec![editor_shell(child, theme)])
+}
+
+// =====================================================================
+// Color y texto
+// =====================================================================
+
+pub(crate) fn with_alpha(c: Color, alpha: f32) -> Color {
+    let [r, g, b, _] = c.components;
+    Color::new([r, g, b, alpha])
+}
+
+/// Color estable de una cuerda: matices por ángulo áureo sobre el hue del
+/// accent — determinista por id, sin paleta que se agote.
+pub(crate) fn color_cuerda(id: CuerdaId, theme: Theme) -> Color {
+    rotate_hue(theme.accent, (id as f32 * 0.381_966) % 1.0)
+}
+
+pub(crate) fn rotate_hue(c: Color, dh: f32) -> Color {
+    // RGB → HSV → rota H → RGB. Aproximación, alpha fijo.
+    let [r, g, b, a] = c.components;
+    let max = r.max(g).max(b);
+    let min = r.min(g).min(b);
+    let v = max;
+    let s = if max <= 0.0 { 0.0 } else { (max - min) / max };
+    let h = if (max - min).abs() < 1e-6 {
+        0.0
+    } else if max == r {
+        ((g - b) / (max - min)) % 6.0
+    } else if max == g {
+        (b - r) / (max - min) + 2.0
+    } else {
+        (r - g) / (max - min) + 4.0
+    };
+    let h2 = ((h / 6.0) + dh).rem_euclid(1.0) * 6.0;
+    let c2 = v * s;
+    let x = c2 * (1.0 - ((h2 % 2.0) - 1.0).abs());
+    let (r2, g2, b2) = match h2 as i32 {
+        0 => (c2, x, 0.0),
+        1 => (x, c2, 0.0),
+        2 => (0.0, c2, x),
+        3 => (0.0, x, c2),
+        4 => (x, 0.0, c2),
+        _ => (c2, 0.0, x),
+    };
+    let m = v - c2;
+    Color::new([r2 + m, g2 + m, b2 + m, a])
+}
+
+pub(crate) fn short_label(s: &str) -> String {
+    let s = if s.trim().is_empty() { "(sin título)" } else { s };
+    let mut out: String = s.chars().take(26).collect();
+    if s.chars().count() > 26 {
+        out.push('…');
+    }
+    out
+}
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+    use crate::modelo::{Embedder, Model};
+
+    /// Modelo con dos cuerdas: «gorda» (3 nudos, vieja) y «flaca»
+    /// (1 nudo, recién crecida), más un suelto.
+    fn modelo() -> (Model, CuerdaId, CuerdaId) {
+        let mut m = Model::blank(Embedder::Local);
+        let gorda = m.store.crear_cuerda("gorda", 0);
+        for (i, t) in ["a", "b", "c"].iter().enumerate() {
+            let id = m.store.create(*t, "", vec![], 100 + i as u64);
+            m.order.push(id);
+            m.store.anudar(id, gorda);
+        }
+        let flaca = m.store.crear_cuerda("flaca", 0);
+        let id = m.store.create("d", "", vec![], 900);
+        m.order.push(id);
+        m.store.anudar(id, flaca);
+        let suelto = m.store.create("suelto", "", vec![], 950);
+        m.order.push(suelto);
+        (m, gorda, flaca)
+    }
+
+    #[test]
+    fn layout_ordena_por_masa_y_cronologia() {
+        let (m, gorda, flaca) = modelo();
+        let l = layout_cuerdas(&m, 600.0, 1_000);
+        assert_eq!(l.len(), 2);
+        // La gorda a la izquierda (más masa), la flaca después.
+        assert_eq!(l[0].id, gorda);
+        assert_eq!(l[1].id, flaca);
+        assert!(l[0].x < l[1].x);
+        assert!(l[0].grosor > l[1].grosor);
+        // La flaca creció más recién → arde más.
+        assert!(l[1].brillo > l[0].brillo);
+        // Nudos en cronología descendente hacia abajo.
+        let ys: Vec<f32> = l[0].nudos.iter().map(|n| n.y).collect();
+        assert_eq!(l[0].nudos.len(), 3);
+        assert!(ys.windows(2).all(|w| w[0] < w[1]));
+        // Los sueltos no cuelgan de la viga.
+        assert!(l.iter().all(|c| c.nudos.iter().all(|n| n.titulo != "suelto")));
+    }
+
+    #[test]
+    fn pick_encuentra_nudo_cabeza_y_nada() {
+        let (m, gorda, _) = modelo();
+        let l = layout_cuerdas(&m, 600.0, 1_000);
+        let (cx, ny) = (l[0].x, l[0].nudos[0].y);
+        assert!(matches!(pick(&m, cx, ny, 600.0), Pick::Nudo(_, c) if c == gorda));
+        // Sobre el nombre, entre viga y nudos: la cabeza.
+        assert!(matches!(pick(&m, cx, 50.0, 600.0), Pick::Cabeza(c) if c == gorda));
+        // Lejos de todo: nada.
+        assert!(matches!(pick(&m, 5_000.0, 300.0, 600.0), Pick::Nada));
+    }
+
+    #[test]
+    fn max_pan_crece_con_las_cuerdas() {
+        let (m, ..) = modelo();
+        // Dos cuerdas entran holgadas en 1280 px: sin pan.
+        assert_eq!(max_pan(&m, 1280.0), 0.0);
+        // En una ventana angosta sí hay tela que correr.
+        assert!(max_pan(&m, 200.0) > 0.0);
+    }
+}
diff --git a/00_unanchay/khipu/khipu-app/src/estado.rs b/00_unanchay/khipu/khipu-app/src/estado.rs
new file mode 100644
index 0000000..4ba66c1
--- /dev/null
+++ b/00_unanchay/khipu/khipu-app/src/estado.rs
@@ -0,0 +1,664 @@
+//! `estado` — persistencia, embeddings y helpers de modelo.
+//!
+//! Cubre:
+//! - Formatos de serialización (`PersistedState` V4 + sombras de los
+//!   formatos viejos) y su migración.
+//! - `load_state` / `persist`: I/O de disco con postcard.
+//! - `from_state` / `seeded_model`: inicialización del `Model`.
+//! - `embed_now` / `schedule_embedding`: vectores semánticos.
+//! - Helpers de modelo: `select`, `deselect`, `commit_edits`,
+//!   `resonancia_de`, `es_sedimento`, `now_secs`, `parse_tags`,
+//!   `unlock_identity`, `start_unlock`, `khipu_dir`, `data_file_path`.
+
+use std::collections::BTreeMap;
+use std::path::{Path, PathBuf};
+
+use agora_core::Keypair;
+use directories::ProjectDirs;
+use khipu_core::{Note, NoteId, NoteStore};
+use llimphi_widget_text_editor::EditorState;
+use serde::{Deserialize, Serialize};
+
+use crate::modelo::{Embedder, Focus, Model, Msg, RESONANCIA_MAX, SEDIMENTO_SECS};
+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.
+#[derive(Serialize, Deserialize)]
+pub(crate) struct PersistedState {
+    pub(crate) store: NoteStore,
+    pub(crate) embeddings: Vec<(NoteId, Vec<f32>)>,
+    pub(crate) order: Vec<NoteId>,
+    /// Etiqueta del espacio vectorial con que se guardaron los embeddings.
+    pub(crate) model: String,
+}
+
+// --- Sombras de los formatos viejos (postcard es posicional: hay que
+// --- replicar la forma exacta con que se escribieron). El `Note` viejo
+// --- tenía `pos` y no tenía cuerda/estado/origen; el store no tenía
+// --- cuerdas; el V3 arrastraba las regiones del mapa.
+
+#[derive(Deserialize)]
+struct NoteVieja {
+    id: NoteId,
+    title: String,
+    body: String,
+    tags: Vec<String>,
+    created_at: u64,
+    updated_at: u64,
+    last_access: u64,
+    mass: f32,
+    /// La posición ancla del mapa muerto. Se descarta al migrar.
+    #[allow(dead_code)]
+    pos: Option<(f32, f32)>,
+}
+
+#[derive(Deserialize)]
+struct NoteStoreViejo {
+    notes: BTreeMap<NoteId, NoteVieja>,
+    #[allow(dead_code)]
+    next_id: NoteId,
+}
+
+#[derive(Deserialize)]
+#[allow(dead_code)]
+struct RegionVieja {
+    name: String,
+    x: f32,
+    y: f32,
+}
+
+/// Formato V3: con `model` y `regions` (los topónimos del mapa muerto).
+#[derive(Deserialize)]
+struct PersistedV3 {
+    store: NoteStoreViejo,
+    embeddings: Vec<(NoteId, Vec<f32>)>,
+    order: Vec<NoteId>,
+    model: String,
+    #[allow(dead_code)]
+    regions: Vec<RegionVieja>,
+}
+
+/// Formato V2: como V3 sin regiones.
+#[derive(Deserialize)]
+struct PersistedV2 {
+    store: NoteStoreViejo,
+    embeddings: Vec<(NoteId, Vec<f32>)>,
+    order: Vec<NoteId>,
+    model: String,
+}
+
+/// Formato V1 histórico, sin `model` (previo a enchufar `verbo`).
+#[derive(Deserialize)]
+struct PersistedV1 {
+    store: NoteStoreViejo,
+    embeddings: Vec<(NoteId, Vec<f32>)>,
+    order: Vec<NoteId>,
+}
+
+/// 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 {
+    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: None,
+            estado: Default::default(),
+            origen: None,
+        });
+    }
+    store
+}
+
+// =====================================================================
+// I/O de disco
+// =====================================================================
+
+/// Directorio de datos de khipu (`$XDG_DATA_HOME/khipu/`), creándolo si
+/// hace falta. Raíz de `notes.bin`, `keys/` y `compartido.khipu`.
+pub(crate) fn khipu_dir() -> Option<PathBuf> {
+    let dirs = ProjectDirs::from("org", "tawasuyu", "khipu")?;
+    let dir = dirs.data_dir().to_path_buf();
+    std::fs::create_dir_all(&dir).ok()?;
+    Some(dir)
+}
+
+pub(crate) fn data_file_path() -> Option<PathBuf> {
+    Some(khipu_dir()?.join("notes.bin"))
+}
+
+/// Copia de respaldo única antes de migrar/pisar un formato viejo. Si el
+/// backup ya existe no se toca (conserva el estado pre-migración).
+fn respaldar_una_vez(path: &Path, sufijo: &str) {
+    let bak = path.with_extension(sufijo);
+    if !bak.exists() {
+        let _ = std::fs::copy(path, &bak);
+    }
+}
+
+pub(crate) fn load_state(path: &PathBuf) -> Option<PersistedState> {
+    let bytes = std::fs::read(path).ok()?;
+    // Formato vigente primero. postcard es posicional (no self-describing),
+    // así que un archivo viejo puede "parsear" con basura — el chequeo de
+    // coherencia del store descarta esos falsos positivos.
+    if let Ok(state) = postcard::from_bytes::<PersistedState>(&bytes) {
+        if state.store.coherente() {
+            return Some(state);
+        }
+    }
+    // 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) {
+        respaldar_una_vez(path, "v3.bak");
+        return Some(PersistedState {
+            store: migrar_store(v3.store),
+            embeddings: v3.embeddings,
+            order: v3.order,
+            model: v3.model,
+        });
+    }
+    if let Ok(v2) = postcard::from_bytes::<PersistedV2>(&bytes) {
+        respaldar_una_vez(path, "v3.bak");
+        return Some(PersistedState {
+            store: migrar_store(v2.store),
+            embeddings: v2.embeddings,
+            order: v2.order,
+            model: v2.model,
+        });
+    }
+    let v1: PersistedV1 = postcard::from_bytes(&bytes).ok()?;
+    respaldar_una_vez(path, "v3.bak");
+    Some(PersistedState {
+        store: migrar_store(v1.store),
+        embeddings: v1.embeddings,
+        order: v1.order,
+        model: String::new(),
+    })
+}
+
+pub(crate) fn persist(model: &Model) {
+    let Some(path) = model.data_path.as_ref() else {
+        return;
+    };
+    let state = PersistedState {
+        store: model.store.clone(),
+        embeddings: model
+            .field
+            .iter()
+            .map(|(id, v)| (id, v.to_vec()))
+            .collect(),
+        order: model.order.clone(),
+        model: model.embedder.label(),
+    };
+    if let Ok(bytes) = postcard::to_allocvec(&state) {
+        let tmp = path.with_extension("bin.tmp");
+        if std::fs::write(&tmp, &bytes).is_ok() {
+            let _ = std::fs::rename(&tmp, path);
+        }
+    }
+}
+
+/// Emite al centro de eventos (willay) un checkpoint de una nota — la entrada de
+/// khipu en la línea de tiempo universal (PLAN-FRONTERA #6). La raíz es el BLAKE3
+/// del contenido serializado de la nota (`postcard` determinista sobre un `Note`,
+/// que es un `BTreeMap`-free struct estable). Se llama en límites discretos
+/// (capturar un nudo), **no** en `persist` — que corre por tecla. **Nunca falla:**
+/// `emitir_silencioso` no-opea sin el daemon willay.
+pub(crate) fn emitir_checkpoint_nota(model: &Model, id: NoteId) {
+    let Some(note) = model.store.get(id) else {
+        return;
+    };
+    let Ok(bytes) = postcard::to_allocvec(note) else {
+        return;
+    };
+    let etiqueta = if note.title.trim().is_empty() {
+        "nudo sin título".to_string()
+    } else {
+        note.title.clone()
+    };
+    let cp = willay_checkpoint::Checkpoint {
+        app: "khipu".to_string(),
+        cosa: format!("nota:{id}"),
+        etiqueta,
+        raiz: *blake3::hash(&bytes).as_bytes(),
+        padre: None,
+        ts_usec: willay_emit::ahora_usec(),
+        autor: willay_checkpoint::Autor::Humano,
+    };
+    willay_emit::emitir_silencioso(&cp.a_evento());
+}
+
+// =====================================================================
+// Inicialización del Model
+// =====================================================================
+
+pub(crate) fn from_state(state: PersistedState, embedder: Embedder) -> Model {
+    // ¿Los vectores guardados son del mismo espacio que el embebedor
+    // activo? Si cambió el modelo o la dimensión, son incomparables:
+    // se descartan y se recalcula todo el cuaderno.
+    let same_space = !state.model.is_empty() && state.model == embedder.label();
+    let mut model = Model::blank(embedder);
+    model.store = state.store;
+    model.order = state.order;
+
+    if same_space {
+        let restored: std::collections::HashSet<NoteId> =
+            state.embeddings.iter().map(|(id, _)| *id).collect();
+        for (id, v) in &state.embeddings {
+            if !v.is_empty() {
+                model.field.insert_etiquetado(*id, v.clone(), &model.embedder.label());
+            }
+        }
+        // Notas sin vector persistido: recalcular sólo esas.
+        let missing: Vec<NoteId> = model
+            .order
+            .iter()
+            .copied()
+            .filter(|id| !restored.contains(id))
+            .collect();
+        for id in missing {
+            embed_now(&mut model, id);
+        }
+    } else {
+        let ids: Vec<NoteId> = model.order.clone();
+        for id in ids {
+            embed_now(&mut model, id);
+        }
+    }
+    model
+}
+
+/// El cuaderno demo de un khipu recién nacido: la cuerda «khipu» — la
+/// idea que originó la app, contada en sus propios nudos — más dos
+/// sueltos esperando resonar. Meta y didáctico a la vez: se ve una
+/// cuerda con masa, un pendiente, y material listo para tejer.
+pub(crate) fn seeded_model(embedder: Embedder) -> Model {
+    let mut model = Model::blank(embedder);
+    let now = now_secs();
+    let dia = 24 * 3600;
+
+    let cuerda = model.store.crear_cuerda("khipu", now.saturating_sub(9 * dia));
+    let nudos_cuerda: [(&str, u64); 3] = [
+        (
+            "quiero un lugar donde botar mis ideas sueltas, en cualquier estado mental",
+            now.saturating_sub(9 * dia),
+        ),
+        (
+            "que las ideas se refuercen entre sí al volver, en vez de decaer por no leerlas",
+            now.saturating_sub(5 * dia),
+        ),
+        (
+            "no otro keep u obsidian cuadrado: la estructura tiene que emerger de la recurrencia",
+            now.saturating_sub(2 * dia),
+        ),
+    ];
+    for (texto, ts) in nudos_cuerda {
+        let id = model.store.create(texto, "", vec![], ts);
+        model.order.push(id);
+        model.store.anudar(id, cuerda);
+        embed_now(&mut model, id);
+    }
+
+    let sueltos: [(&str, u64); 2] = [
+        ("probar el tejido de una cuerda con el daemon de verbo corriendo", now.saturating_sub(dia)),
+        ("la barra de captura de pata podría botar nudos aquí (fase 2)", now),
+    ];
+    for (texto, ts) in sueltos {
+        let id = model.store.create(texto, "", vec![], ts);
+        model.order.push(id);
+        embed_now(&mut model, id);
+    }
+    // El primero de los sueltos es un pendiente — muestra el TODO.
+    if let Some(primero) = model.store.sueltos().last().map(|n| n.id) {
+        if let Some(n) = model.store.get_mut(primero) {
+            n.estado = khipu_core::EstadoNudo::Pendiente;
+        }
+    }
+    model
+}
+
+// =====================================================================
+// Embeddings
+// =====================================================================
+
+// El embedder local de fallback (hash trigram → vector) vive en el core
+// `khipu-gravity` como `local_embed`, junto a los vectores que alimenta (Regla
+// 2). `modelo::Embedder::Local` lo llama directamente desde ahí.
+
+/// Versión síncrona para el arranque (seed y migración de formato):
+/// calcula el vector en línea y lo inserta. En init todavía no hay nada
+/// que repintar, así que bloquear un instante es lo correcto.
+pub(crate) fn embed_now(model: &mut Model, id: NoteId) {
+    let Some(note) = model.store.get(id) else {
+        return;
+    };
+    let combined = format!("{} {}", note.title, note.body);
+    let v = model.embedder.embed_blocking(&combined);
+    model.field.insert_etiquetado(id, v, &model.embedder.label());
+}
+
+/// Pide el embedding de `id` en segundo plano. Asigna una secuencia
+/// nueva, la marca como vigente, y dispara un worker (`Handle::spawn`)
+/// que al terminar reentra al `update` con [`Msg::EmbeddingReady`]. Así
+/// el `block_on` del arm remoto nunca corre en el hilo de UI.
+pub(crate) fn schedule_embedding(model: &mut Model, id: NoteId, h: &Handle<Msg>) {
+    let Some(note) = model.store.get(id) else {
+        return;
+    };
+    let combined = format!("{} {}", note.title, note.body);
+    model.embed_seq += 1;
+    let seq = model.embed_seq;
+    model.embed_latest.insert(id, seq);
+    let embedder = model.embedder.clone();
+    h.spawn(move || {
+        let v = embedder.embed_blocking(&combined);
+        Msg::EmbeddingReady(id, seq, v)
+    });
+}
+
+// =====================================================================
+// Resonancia y sedimento
+// =====================================================================
+
+/// Los vecinos que un nudo toca: afinidad coseno sobre el umbral del
+/// espacio vectorial activo, de mayor a menor, a lo sumo
+/// [`RESONANCIA_MAX`]. Excluye al propio nudo (lo hace `nearest`) y al
+/// tejido de las cuerdas (una síntesis siempre "resuena" con su materia —
+/// no informa).
+pub(crate) fn resonancia_de(model: &Model, id: NoteId) -> Vec<(NoteId, f32)> {
+    let umbral = model.embedder.umbral_resonancia();
+    let tejidos: std::collections::BTreeSet<NoteId> =
+        model.store.cuerdas().filter_map(|c| c.tejido).collect();
+    model
+        .field
+        .nearest(id, RESONANCIA_MAX + tejidos.len())
+        .into_iter()
+        .filter(|(nid, aff)| *aff >= umbral && !tejidos.contains(nid))
+        .take(RESONANCIA_MAX)
+        .collect()
+}
+
+/// `true` si un nudo suelto ya sedimentó: sin cuerda y sin toques desde
+/// hace [`SEDIMENTO_SECS`]. El sedimento no se borra ni se esconde del
+/// buscador — sólo deja la franja de sueltos frescos.
+pub(crate) fn es_sedimento(n: &Note, now: u64) -> bool {
+    n.suelto() && now.saturating_sub(n.updated_at.max(n.created_at)) > SEDIMENTO_SECS
+}
+
+/// Nombre propuesto para una cuerda que nace de estos nudos, con
+/// fallback genérico si no hay señal en los títulos/etiquetas.
+pub(crate) fn nombre_propuesto(model: &Model, ids: &[NoteId]) -> String {
+    let notas: Vec<&Note> = ids.iter().filter_map(|id| model.store.get(*id)).collect();
+    khipu_core::propose_cuerda_name(&notas).unwrap_or_else(|| "idea".to_string())
+}
+
+// =====================================================================
+// Helpers de modelo
+// =====================================================================
+
+pub(crate) fn now_secs() -> u64 {
+    std::time::SystemTime::now()
+        .duration_since(std::time::UNIX_EPOCH)
+        .map(|d| d.as_secs())
+        .unwrap_or(0)
+}
+
+pub(crate) fn parse_tags(raw: &str) -> Vec<String> {
+    raw.split(',')
+        .map(|t| t.trim().to_string())
+        .filter(|t| !t.is_empty())
+        .collect()
+}
+
+/// Carga la nota `id` en los campos del editor y foca el cuerpo.
+pub(crate) fn select(model: &mut Model, id: NoteId) {
+    let Some(note) = model.store.get(id) else {
+        return;
+    };
+    model.selected = Some(id);
+    model.title.set_text(note.title.clone());
+    model.body = EditorState::default();
+    model.body.set_text(&note.body);
+    model.tags.set_text(note.tags.join(", "));
+    model.focus = Focus::Body;
+}
+
+/// Suelta la nota seleccionada y limpia los campos del editor.
+pub(crate) fn deselect(model: &mut Model) {
+    model.selected = None;
+    model.title.set_text(String::new());
+    model.body = EditorState::default();
+    model.tags.set_text(String::new());
+    model.focus = Focus::None;
+}
+
+/// Sincroniza inputs/editor → store/field + persiste si cambió algo.
+pub(crate) fn commit_edits(model: &mut Model, h: &Handle<Msg>) {
+    let Some(id) = model.selected else {
+        return;
+    };
+    let mut changed = false;
+    let new_title = model.title.text();
+    let new_body = model.body.text();
+    let new_tags = parse_tags(&model.tags.text());
+    let now = now_secs();
+    if let Some(note) = model.store.get_mut(id) {
+        if note.title != new_title {
+            note.title = new_title;
+            note.updated_at = now;
+            changed = true;
+        }
+        if note.body != new_body {
+            note.body = new_body;
+            note.updated_at = now;
+            changed = true;
+        }
+        if note.tags != new_tags {
+            note.tags = new_tags;
+            note.updated_at = now;
+            changed = true;
+        }
+    }
+    if changed {
+        // El texto ya está en el store: persistimos de inmediato para no
+        // perderlo. El embedding viaja a un worker y persistirá de nuevo
+        // cuando llegue (`Msg::EmbeddingReady`).
+        persist(model);
+        schedule_embedding(model, id, h);
+    }
+}
+
+/// Desbloquea (o crea, o migra) la identidad del cuaderno con `passphrase`,
+/// vía [`khipu_share::identity::unlock`]. La semilla vive cifrada en
+/// `<datos>/keys/`; si existe un `identidad.seed` en claro de versiones
+/// viejas, se migra al keystore y se borra el claro.
+pub(crate) fn unlock_identity(passphrase: &str) -> Option<Keypair> {
+    let dir = khipu_dir()?;
+    let legacy = dir.join("identidad.seed");
+    khipu_share::identity::unlock(&dir.join("keys"), Some(&legacy), passphrase).ok()
+}
+
+/// Arranca el prompt de passphrase y memoriza la acción a reanudar.
+pub(crate) fn start_unlock(model: &mut Model, accion: Msg) {
+    model.unlocking = true;
+    model.pending = Some(Box::new(accion));
+    model.focus = Focus::Passphrase;
+    model.passphrase.clear();
+    model.status = Some("ingresa tu passphrase para desbloquear la identidad".into());
+}
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+
+    // Twins serializables de los formatos viejos, con la MISMA forma
+    // posicional con que el khipu del mapa escribía notes.bin. Si estos
+    // structs divergen de los `Deserialize` de arriba, la migración es
+    // mentira — por eso el test escribe bytes de verdad y los carga.
+    #[derive(serde::Serialize)]
+    struct NoteViejaW {
+        id: u64,
+        title: String,
+        body: String,
+        tags: Vec<String>,
+        created_at: u64,
+        updated_at: u64,
+        last_access: u64,
+        mass: f32,
+        pos: Option<(f32, f32)>,
+    }
+    #[derive(serde::Serialize)]
+    struct NoteStoreViejoW {
+        notes: BTreeMap<u64, NoteViejaW>,
+        next_id: u64,
+    }
+    #[derive(serde::Serialize)]
+    struct RegionViejaW {
+        name: String,
+        x: f32,
+        y: f32,
+    }
+    #[derive(serde::Serialize)]
+    struct PersistedV3W {
+        store: NoteStoreViejoW,
+        embeddings: Vec<(u64, Vec<f32>)>,
+        order: Vec<u64>,
+        model: String,
+        regions: Vec<RegionViejaW>,
+    }
+
+    fn nota_vieja(id: u64, title: &str) -> NoteViejaW {
+        NoteViejaW {
+            id,
+            title: title.into(),
+            body: format!("cuerpo de {title}"),
+            tags: vec!["vieja".into()],
+            created_at: 100 + id,
+            updated_at: 200 + id,
+            last_access: 300 + id,
+            mass: 0.7,
+            pos: Some((10.0 * id as f32, -5.0)),
+        }
+    }
+
+    #[test]
+    fn migra_v3_conservando_ids_y_contenido_con_backup() {
+        let mut notes = BTreeMap::new();
+        // Ids con hueco (el 2 fue borrado en su día) — deben conservarse.
+        notes.insert(1, nota_vieja(1, "Primera"));
+        notes.insert(3, nota_vieja(3, "Tercera"));
+        let v3 = PersistedV3W {
+            store: NoteStoreViejoW { notes, next_id: 4 },
+            embeddings: vec![(1, vec![0.5, 0.5]), (3, vec![0.1, 0.9])],
+            order: vec![1, 3],
+            model: "khipu-trigram-16d".into(),
+            regions: vec![RegionViejaW { name: "Zona".into(), x: 1.0, y: 2.0 }],
+        };
+        let bytes = postcard::to_allocvec(&v3).unwrap();
+        let dir = std::env::temp_dir().join(format!("khipu-migra-{}", 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 V3 migra");
+        assert!(state.store.coherente());
+        assert_eq!(state.store.len(), 2);
+        let n1 = state.store.get(1).unwrap();
+        assert_eq!(n1.title, "Primera");
+        assert_eq!(n1.body, "cuerpo de Primera");
+        assert_eq!(n1.created_at, 101);
+        assert!(n1.suelto()); // todo migra suelto: las cuerdas se anudan de aquí en más
+        assert!(state.store.get(3).is_some());
+        assert_eq!(state.order, vec![1, 3]);
+        assert_eq!(state.model, "khipu-trigram-16d");
+        assert_eq!(state.embeddings.len(), 2);
+        // El hueco no se reusa: el próximo create no pisa nada.
+        let mut store = state.store.clone();
+        let nuevo = store.create("nueva", "", vec![], 0);
+        assert!(nuevo >= 4);
+        // Backup pre-migración en su lugar.
+        assert!(path.with_extension("v3.bak").exists());
+        let _ = std::fs::remove_dir_all(&dir);
+    }
+
+    #[test]
+    fn el_formato_v4_roundtripea_con_cuerdas() {
+        let mut store = NoteStore::new();
+        let a = store.create("nudo a", "", vec![], 10);
+        let b = store.create("nudo b", "", vec![], 20);
+        let c = store.crear_cuerda("idea", 20);
+        store.anudar(a, c);
+        store.anudar(b, c);
+        let state = PersistedState {
+            store,
+            embeddings: vec![(a, vec![1.0]), (b, vec![0.9])],
+            order: vec![a, b],
+            model: "x".into(),
+        };
+        let bytes = postcard::to_allocvec(&state).unwrap();
+        let dir = std::env::temp_dir().join(format!("khipu-v4-{}", 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 V4 carga directo");
+        assert!(cargado.store.coherente());
+        assert_eq!(cargado.store.cuerdas_len(), 1);
+        assert_eq!(cargado.store.nudos_de(c).len(), 2);
+        // Sin migración no hay backup.
+        assert!(!path.with_extension("v3.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).
+    #[test]
+    fn migra_el_cuaderno_real_si_esta() {
+        let Ok(origen) = std::env::var("KHIPU_MIGRA_REAL") else {
+            return;
+        };
+        let dir = std::env::temp_dir().join(format!("khipu-real-{}", std::process::id()));
+        std::fs::create_dir_all(&dir).unwrap();
+        let path = dir.join("notes.bin");
+        std::fs::copy(&origen, &path).expect("copiar el cuaderno real");
+        let state = load_state(&path).expect("el cuaderno real carga o migra");
+        assert!(state.store.coherente());
+        assert!(state.store.len() > 0, "un cuaderno real trae notas");
+        println!(
+            "cuaderno real: {} notas · {} cuerdas · modelo «{}»",
+            state.store.len(),
+            state.store.cuerdas_len(),
+            state.model
+        );
+        let _ = std::fs::remove_dir_all(&dir);
+    }
+
+    #[test]
+    fn es_sedimento_respeta_el_umbral_y_las_cuerdas() {
+        let mut store = NoteStore::new();
+        let viejo = store.create("viejo", "", vec![], 0);
+        let now = SEDIMENTO_SECS + 10;
+        assert!(es_sedimento(store.get(viejo).unwrap(), now));
+        // Un nudo anudado nunca sedimenta.
+        let c = store.crear_cuerda("idea", 0);
+        store.anudar(viejo, c);
+        assert!(!es_sedimento(store.get(viejo).unwrap(), now));
+        // Uno fresco tampoco.
+        let fresco = store.create("fresco", "", vec![], now);
+        assert!(!es_sedimento(store.get(fresco).unwrap(), now));
+    }
+}
diff --git a/00_unanchay/khipu/khipu-app/src/main.rs b/00_unanchay/khipu/khipu-app/src/main.rs
index 19366fd..c5bc266 100644
--- a/00_unanchay/khipu/khipu-app/src/main.rs
+++ b/00_unanchay/khipu/khipu-app/src/main.rs
@@ -1,1927 +1,49 @@
-//! `khipu-app` — cuaderno de notas sobre Llimphi.
+//! `khipu-app` — el cuaderno de resonancia sobre Llimphi.
 //!
-//! Tres regiones, todas en la misma ventana, sin modal:
-//! - **Lista** (izquierda, 240 px): notas en orden de creación.
-//!   Click selecciona. Botón `+ nueva` arriba.
-//! - **Editor** (centro): título (input), cuerpo (text-editor con
-//!   wiki-links `[[...]]`), etiquetas (input). Edición directa — la
-//!   nota seleccionada se modifica al teclear, sin botón guardar.
-//! - **Gravedad** (derecha): canvas vello que pinta las posiciones
-//!   2D del [`SemanticField::gravity_layout`]. Color por clúster
-//!   (umbral 0.55), la seleccionada va resaltada con borde acento.
+//! La ventana es un khipu: captura arriba (siempre lista), franja de
+//! nudos sueltos, y el lienzo de cuerdas — las ideas recurrentes colgadas
+//! de la viga, con sus nudos en cronología y su tejido (síntesis LLM) al
+//! final. Lista y editor son overlays que aparecen sólo cuando hacen
+//! falta.
+//!
+//! **El ciclo**: botás una idea (Enter) → la resonancia muestra qué nudos
+//! previos toca (embeddings) → un click la anuda a una cuerda (o nace una
+//! cuerda nueva) → cuando la cuerda engorda, «tejer» le pide al LLM el
+//! encuadre: qué se repite, qué está claro, qué falta. Nada se olvida:
+//! lo que no resuena sedimenta (buscable, sin estorbar).
 //!
 //! **Embeddings**: si hay un `verbo-daemon` corriendo en el socket por
 //! defecto (`$XDG_RUNTIME_DIR/verbo.sock`) los vectores son reales
-//! (fastembed e5, etc.) — clústeres y vecinos se vuelven semánticos de
-//! verdad. Sin daemon caemos al hash trigram → R^16 local (random
-//! projection 1-bit signed, normalizado): determinista, offline,
-//! idéntico al comportamiento histórico. Ver [`Embedder`]. El cálculo
-//! es async, así que viaja a un worker (`Handle::spawn`) y reentra al
-//! `update` con [`Msg::EmbeddingReady`] — la UI nunca se bloquea.
+//! (fastembed e5, etc.). Sin daemon caemos al hash trigram → R^16 local:
+//! determinista, offline.
 //!
 //! **Persistencia**: cada mutación graba `$XDG_DATA_HOME/khipu/notes.bin`
-//! con postcard, anotando la etiqueta del espacio vectorial usado. Al
-//! arrancar, si el archivo existe se carga; si el espacio cambió (otro
-//! modelo o dimensión) los vectores se recalculan. Sin archivo se
-//! siembra el cuaderno demo (siete notas en español).
-
-use std::collections::BTreeMap;
-use std::path::PathBuf;
-use std::sync::Arc;
-use std::time::{SystemTime, UNIX_EPOCH};
-
-use agora_core::Keypair;
-use directories::ProjectDirs;
-use khipu_share::SignedBundle;
-use rimay_verbo::Provider;
-use khipu_core::{Note, NoteId, NoteStore};
-use khipu_gravity::{Gravity, Params, SemanticField};
-use llimphi_theme::Theme;
-use llimphi_ui::llimphi_hal::winit::keyboard::{Key, NamedKey};
-use llimphi_ui::llimphi_layout::taffy::{
-    prelude::{length, percent, FlexDirection, Rect, Size, Style},
-    AlignItems, Dimension, JustifyContent,
-};
-use llimphi_ui::llimphi_text::Alignment;
-use llimphi_ui::{App, Handle, KeyEvent, KeyState, View};
-use llimphi_widget_list::ListPalette;
-use llimphi_widget_text_editor::{EditorMetrics, EditorPalette, EditorState, PointerEvent};
-use llimphi_widget_text_input::{text_input_view, TextInputPalette, TextInputState};
-use llimphi_widget_menubar::{
-    menubar_command_at, menubar_nav, menubar_overlay_animated, menubar_view, MenuBarSpec,
-    DEFAULT_HEIGHT as MENU_H,
-};
-use llimphi_widget_edit_menu::{self as editmenu, EditAction, EditFlags};
-use llimphi_widget_context_menu::{context_menu_view_ex, ContextMenuExtras};
-use llimphi_motion::{animate, motion, Tween};
-use llimphi_clipboard::SystemClipboard;
-use serde::{Deserialize, Serialize};
-
-mod map;
+//! con postcard (formato V4: nudos + cuerdas). Los cuadernos del mapa
+//! viejo (V1–V3) migran solos, con backup en `notes.v3.bak`.
+//!
+//! ## Organización de módulos
+//!
+//! | módulo    | contenido                                                   |
+//! |-----------|-------------------------------------------------------------|
+//! | `modelo`  | tipos (`Embedder`, `Focus`, `Msg`, `Model`, constantes)     |
+//! | `estado`  | persistencia + migración, embeddings, resonancia, helpers   |
+//! | `menu`    | menú principal + menú de edición contextual                 |
+//! | `app`     | `KhipuApp` + implementación `App` (init/update/view/on_key) |
+//! | `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)                   |
+//! | `net`     | lado P2P: exportar/importar/publicar/recibir                |
+
+mod app;
+mod cuerdas;
+mod estado;
+mod menu;
+mod modelo;
 mod net;
 mod panels;
-use map::*;
-use net::*;
-use panels::*;
-
-/// Dimensión del embebedor local (fallback sin daemon).
-const EMBED_DIM: usize = 16;
-const CLUSTER_THRESHOLD: f32 = 0.55;
-const EDITOR_VISIBLE_LINES: usize = 24;
-const LIST_WIDTH: f32 = 240.0;
-/// Ancho del editor flotante (overlay derecho sobre el mapa).
-const EDITOR_OVERLAY_W: f32 = 420.0;
-/// Zoom a partir del cual el nodo seleccionado deja de editarse en el panel
-/// lateral y pasa a abrirse como tarjeta anclada a su coordenada en el mapa
-/// (zoom semántico). Por debajo, el editor vuelve al overlay derecho.
-const ZOOM_INJECT: f32 = 1.6;
-const HEADER_H: f32 = 36.0;
-const ROW_H: f32 = 26.0;
-const FIELD_LABEL_SIZE: f32 = 10.0;
-
-/// Fuente de vectores semánticos. Con un `verbo-daemon` en el socket por
-/// defecto usa embeddings reales; si no hay daemon cae al hash-trigram
-/// local de 16d — determinista, offline, sin runtime.
-///
-/// El arm remoto guarda el `Runtime` de tokio para resolver las llamadas
-/// async del `Provider` con `block_on` desde el hilo worker que las
-/// dispara (nunca el de UI). Es `Clone` (todo tras `Arc`) para viajar
-/// barato dentro de la closure de `Handle::spawn`.
-#[derive(Clone)]
-enum Embedder {
-    /// Daemon `rimay-verbo` por socket Unix.
-    Remote {
-        provider: Arc<dyn Provider>,
-        rt: Arc<tokio::runtime::Runtime>,
-        dim: usize,
-        label: String,
-    },
-    /// Fallback local: hash-trigram → R^EMBED_DIM, sin red ni runtime.
-    Local,
-}
-
-impl Embedder {
-    /// Conecta al `verbo-daemon` en el socket por defecto. Si no hay
-    /// ninguno (o no se pudo armar el runtime), devuelve el embebedor
-    /// local — los demos arrancan igual, sin red.
-    fn connect() -> Self {
-        let rt = match tokio::runtime::Builder::new_multi_thread()
-            .worker_threads(2)
-            .enable_all()
-            .build()
-        {
-            Ok(rt) => rt,
-            Err(_) => return Embedder::Local,
-        };
-        match rt.block_on(rimay_verbo::conectar()) {
-            Ok(client) => {
-                let id = client.model_id();
-                let dim = id.dimension;
-                let label = id.to_string();
-                Embedder::Remote {
-                    provider: Arc::new(client),
-                    rt: Arc::new(rt),
-                    dim,
-                    label,
-                }
-            }
-            Err(_) => Embedder::Local,
-        }
-    }
-
-    /// Etiqueta del espacio vectorial. Si cambia entre dos arranques, los
-    /// vectores persistidos son incomparables (otro modelo o dimensión) y
-    /// hay que recalcularlos — ver [`from_state`].
-    fn label(&self) -> String {
-        match self {
-            Embedder::Remote { label, .. } => label.clone(),
-            Embedder::Local => format!("khipu-trigram-{EMBED_DIM}d"),
-        }
-    }
-
-    /// Embebe `text` de forma bloqueante. En el arm remoto resuelve el
-    /// future con `block_on`; ante un error del backend devuelve un
-    /// vector de ceros (afinidad nula con todo, nunca panic).
-    fn embed_blocking(&self, text: &str) -> Vec<f32> {
-        match self {
-            Embedder::Local => embed(text, EMBED_DIM),
-            Embedder::Remote { provider, rt, dim, .. } => rt
-                .block_on(provider.embed(text))
-                .map(|v| v.values)
-                .unwrap_or_else(|_| vec![0.0; *dim]),
-        }
-    }
-}
-
-/// Foco activo del teclado. Cualquier `KeyEvent` se rutea al input
-/// correspondiente; sin foco las teclas se ignoran.
-#[derive(Debug, Clone, Copy, PartialEq, Eq)]
-enum Focus {
-    None,
-    Search,
-    Title,
-    Body,
-    Tags,
-    Passphrase,
-    PeerAddr,
-    /// Input del nombre de una región emergente (bautizo de un clúster).
-    Region,
-}
-
-/// Una región del mapa: un nombre pinchado en una coordenada de mundo. No
-/// es una carpeta — es un topónimo. Nace cuando el usuario bautiza un
-/// clúster denso que el mapa detectó, y de ahí queda como landmark fijo;
-/// los pensamientos cercanos "pertenecen" a esa zona por vecindad, no por
-/// asignación. Las placas tectónicas emergen del caos, no se imponen.
-#[derive(Debug, Clone, Serialize, Deserialize)]
-struct Region {
-    name: String,
-    x: f32,
-    y: f32,
-}
-
-/// Nodo libp2p del cuaderno + su runtime tokio (que lo mantiene vivo). Se
-/// arma perezosamente la primera vez que se usa P2P (`ensure_p2p`).
-struct P2p {
-    rt: Arc<tokio::runtime::Runtime>,
-    node: Arc<khipu_brahman::KhipuNode>,
-    /// Nuestra dirección para compartir (`/ip4/.../tcp/.../p2p/<id>`).
-    dial_addr: String,
-    /// `true` cuando ya estamos sirviendo el cuaderno por libp2p.
-    serving: bool,
-}
-
-/// Un par descubierto en la red, en forma lista para la UI: dónde jalarle
-/// el cuaderno y una etiqueta legible. Datos planos (no `PeerVisto`) para
-/// viajar dentro de un `Msg`.
-#[derive(Clone)]
-struct PeerInfo {
-    /// Dirección TCP de fetch, como string (`ip:puerto`).
-    addr: String,
-    /// Etiqueta para la fila: nombre · de:autor · dirección.
-    label: String,
-}
-
-#[derive(Clone)]
-enum Msg {
-    SelectNote(NoteId),
-    NewNote,
-    DeleteSelected,
-    ToggleArchive,
-    Focus(Focus),
-    Key(KeyEvent),
-    EditorPointer(PointerEvent),
-    /// Latido — fuerza el rerender para que la masa decaiga
-    /// visiblemente aunque el usuario no esté tocando nada.
-    Tick,
-    /// Resultado async de un embed: `(nota, secuencia, vector)`. Se
-    /// aplica sólo si `secuencia` sigue siendo la más reciente para esa
-    /// nota — descarta cálculos que ediciones posteriores dejaron viejos.
-    EmbeddingReady(NoteId, u64, Vec<f32>),
-    /// Sella todo el cuaderno en un sobre firmado (`compartido.khipu`).
-    Export,
-    /// Verifica e ingiere `compartido.khipu` como notas nuevas.
-    Import,
-    /// Empieza a servir el cuaderno por TCP para que un par lo jale.
-    Publish,
-    /// Busca pares en la LAN para jalarles el cuaderno.
-    Receive,
-    /// Resultado del descubrimiento: los pares vistos (ya sin uno mismo).
-    PeersFound(Vec<PeerInfo>),
-    /// Jala el cuaderno del par en esta dirección TCP.
-    FetchFrom(String),
-    /// Jala de la dirección escrita a mano (input) — habilita WAN: cualquier
-    /// `host:puerto` alcanzable, no sólo pares descubiertos en la LAN.
-    FetchManual,
-    /// Cierra el panel de recibir sin jalar nada.
-    CancelPeers,
-    /// Resultado async de un fetch: el sobre recibido o un error.
-    Received(Result<SignedBundle, String>),
-    /// Intenta desbloquear la identidad con la passphrase tipeada.
-    Unlock,
-    /// Cierra el prompt de passphrase sin desbloquear.
-    CancelUnlock,
-    /// Resultado async de reservar un circuito en un relay: la dirección de
-    /// marcado vía circuito (o un mensaje de error) para mostrar.
-    RelayReady(String),
-    /// Abre/cierra un dropdown de la barra de menú principal (índice del menú).
-    MenuOpen(Option<usize>),
-    /// Comando elegido en la barra de menú (`command` de cada `MenuItem`).
-    MenuCommand(String),
-    /// Navegación por teclado en el menú principal (`+1` baja, `-1` sube).
-    MenuNav(i32),
-    /// Enter en el menú principal: ejecuta la fila activa.
-    MenuActivate,
-    /// Tick de animación de menús (sólo re-render).
-    MenuTick,
-    /// Navegación por teclado en el menú de edición.
-    EditNav(i32),
-    /// Enter en el menú de edición: ejecuta la fila activa.
-    EditActivate,
-    /// Abre el menú de edición contextual (right-click) en coords de ventana.
-    EditMenuOpen(f32, f32),
-    /// Acción de edición (undo/redo/cut/copy/paste/delete/selectall) sobre
-    /// el campo focuseado.
-    EditMenuAction(EditAction),
-    /// Cierra cualquier menú abierto (principal o de edición).
-    CloseMenus,
-    /// Pan del mapa: delta de arrastre en pixels de pantalla.
-    MapPan(f32, f32),
-    /// Zoom del mapa: delta de rueda (líneas, signo winit). Acerca/aleja.
-    MapZoom(f32),
-    /// Click en el lienzo en coords locales `(lx, ly)` sobre un rect
-    /// `(w, h)`: selecciona la nota más cercana bajo el cursor, si hay.
-    MapClick(f32, f32, f32, f32),
-    /// Abre/cierra el cajón de notas (overlay izquierdo).
-    ToggleList,
-    /// Cierra el editor flotante: deselecciona y vuelve al mapa limpio.
-    Deselect,
-    /// Escape en el mapa: cierra lo de más arriba (editor → cajón → foco).
-    EscapeMap,
-    /// Empieza a bautizar una región en la coordenada de mundo `(x, y)`
-    /// (el centroide del clúster denso que se ofreció nombrar).
-    BeginNaming(f32, f32),
-    /// Confirma el bautizo: crea la región con el texto tipeado.
-    CommitNaming,
-    /// Cancela el bautizo sin crear región.
-    CancelNaming,
-}
-
-struct Model {
-    store: NoteStore,
-    field: SemanticField,
-    /// Orden de inserción (estable). La presentación se reordena por
-    /// masa decreciente al renderizar.
-    order: Vec<NoteId>,
-    selected: Option<NoteId>,
-    title: TextInputState,
-    body: EditorState,
-    tags: TextInputState,
-    search: TextInputState,
-    focus: Focus,
-    theme: Theme,
-    data_path: Option<PathBuf>,
-    /// Física temporal: vida media + boost + horizonte.
-    gravity: Gravity,
-    /// `true` cuando el usuario quiere ver también las notas que
-    /// cayeron del horizonte. Default `false`.
-    show_archive: bool,
-    /// Fuente de embeddings: daemon `verbo` o fallback trigram local.
-    embedder: Embedder,
-    /// Última secuencia de embedding pedida por nota. Un resultado async
-    /// (`Msg::EmbeddingReady`) sólo se aplica si su secuencia coincide
-    /// con la vigente aquí; así una edición rápida invalida el cálculo
-    /// de la anterior sin condición de carrera.
-    embed_latest: BTreeMap<NoteId, u64>,
-    /// Contador monótono de pedidos de embedding.
-    embed_seq: u64,
-    /// Identidad Ed25519 del cuaderno, para firmar/exportar sobres
-    /// (`khipu-share`). `None` si no hay directorio de datos.
-    keypair: Option<Keypair>,
-    /// Última línea de estado (export/import/red). Se pinta en una barra
-    /// al pie cuando es `Some`.
-    status: Option<String>,
-    /// `true` cuando ya hay un servidor TCP sirviendo el cuaderno. Evita
-    /// rebindear el puerto si se pulsa «publicar» dos veces.
-    publishing: bool,
-    /// `true` mientras el panel izquierdo está en modo "recibir": input de
-    /// dirección + lista de pares descubiertos.
-    receiving: bool,
-    /// Pares descubiertos en la última búsqueda (filas clickeables).
-    peers: Vec<PeerInfo>,
-    /// Dirección manual del par para recibir (habilita WAN). Prellenada
-    /// con `KHIPU_PEER` o el default; editable.
-    peer_input: TextInputState,
-    /// Input de la passphrase para desbloquear la identidad.
-    passphrase: TextInputState,
-    /// `true` mientras se muestra el prompt de passphrase (modal).
-    unlocking: bool,
-    /// Acción a reanudar tras desbloquear (lo que el usuario quiso hacer
-    /// y disparó el prompt). Se redispatcha al lograr el unlock.
-    pending: Option<Box<Msg>>,
-    /// Nodo libp2p (perezoso): `Some` una vez que se usó P2P.
-    p2p: Option<P2p>,
-    /// Dropdown abierto de la barra de menú (índice), o `None` si cerrada.
-    menu_open: Option<usize>,
-    /// Fila resaltada por teclado en el menú principal (`usize::MAX` = ninguna).
-    menu_active: usize,
-    /// Animación de aparición/swap del dropdown del menú principal (0→1).
-    menu_anim: Tween<f32>,
-    /// Posición (coords de ventana) del menú de edición contextual, si abierto.
-    edit_menu: Option<(f32, f32)>,
-    /// Fila resaltada por teclado en el menú de edición (`usize::MAX` = ninguna).
-    edit_active: usize,
-    /// Animación de aparición del menú de edición (0→1).
-    edit_anim: Tween<f32>,
-    /// Portapapeles del sistema, compartido por todas las acciones de edición.
-    clipboard: SystemClipboard,
-    /// Desplazamiento de la cámara del mapa, en coordenadas de mundo. El
-    /// lienzo es infinito; arrastrar el fondo desplaza este vector.
-    cam_pan: (f32, f32),
-    /// Escala de la cámara del mapa (1.0 = mundo:pantalla). La rueda la
-    /// cambia; el zoom semántico futuro decidirá qué se inyecta según ella.
-    cam_zoom: f32,
-    /// `true` mientras el cajón de notas (overlay izquierdo) está abierto.
-    /// El mapa es la interfaz; la lista es un cajón invocable, no un panel
-    /// permanente. Default `true` para no perder al usuario en el primer
-    /// arranque.
-    show_list: bool,
-    /// Último tamaño conocido del lienzo `(w, h)` en pixels. Lo aprende de
-    /// cada click (`on_click_at` lo trae) y sirve para anclar la tarjeta
-    /// del nodo en su coordenada de pantalla durante el zoom semántico,
-    /// que se calcula en `view()` antes de que corra el layout. Se corrige
-    /// solo en el siguiente click tras un resize.
-    canvas_size: (f32, f32),
-    /// Topónimos del mapa: regiones bautizadas (landmarks persistidos).
-    regions: Vec<Region>,
-    /// Coordenada de mundo de la región que se está bautizando ahora mismo
-    /// (input abierto), o `None`.
-    naming: Option<(f32, f32)>,
-    /// Input del nombre de la región en curso.
-    region_input: TextInputState,
-}
-
-struct KhipuApp;
-
-impl App for KhipuApp {
-    type Model = Model;
-    type Msg = Msg;
-
-    fn init(handle: &Handle<Msg>) -> Model {
-        // Conectamos al daemon una sola vez al arrancar; el embebedor
-        // resultante (remoto o local) se clona barato a cada worker.
-        let embedder = Embedder::connect();
-        let data_path = data_file_path();
-        let mut model = match data_path.as_ref().and_then(load_state) {
-            Some(state) => from_state(state, embedder),
-            None => seeded_model(embedder),
-        };
-        model.data_path = data_path;
-        // Identidad: si `KHIPU_PASSPHRASE` está en el entorno, desbloqueamos
-        // (o creamos/migramos) sin prompt — útil headless. Si no, queda
-        // bloqueada y se pide la passphrase al primer intento de compartir.
-        model.keypair = std::env::var("KHIPU_PASSPHRASE")
-            .ok()
-            .and_then(|p| unlock_identity(&p));
-        model.theme = Theme::dark();
-        // Con bootstrap configurado, arrancamos el nodo libp2p ya, para que
-        // la malla DHT esté caliente cuando el usuario quiera descubrir.
-        if std::env::var("KHIPU_BOOTSTRAP").is_ok() {
-            ensure_p2p(&mut model);
-        }
-        // Elegimos la primera nota más pesada (decayendo on-the-fly);
-        // si todo el cuaderno está en archivo, caemos al orden de
-        // inserción para no abrir vacío.
-        let first = first_visible(&model).or_else(|| model.order.first().copied());
-        if let Some(id) = first {
-            reinforce_and_touch(&mut model, id);
-            select(&mut model, id);
-        }
-        persist(&model);
-        // Latido cada 30 s — la masa decae en disco como en pantalla.
-        handle.spawn_periodic(std::time::Duration::from_secs(30), || Msg::Tick);
-        model
-    }
-
-    fn update(mut model: Model, msg: Msg, h: &Handle<Msg>) -> Model {
-        match msg {
-            Msg::SelectNote(id) => {
-                commit_edits(&mut model, h);
-                reinforce_and_touch(&mut model, id);
-                select(&mut model, id);
-                persist(&model);
-            }
-            Msg::NewNote => {
-                commit_edits(&mut model, h);
-                let now = now_secs();
-                let id = model.store.create("Nota nueva", "", Vec::new(), now);
-                model.order.push(id);
-                schedule_embedding(&mut model, id, h);
-                select(&mut model, id);
-                persist(&model);
-            }
-            Msg::ToggleArchive => {
-                model.show_archive = !model.show_archive;
-            }
-            Msg::Tick => {
-                // No muta nada: la masa vive en `current_mass` (decay
-                // contra `last_access`). El Tick existe sólo para
-                // pedirle al event loop un redraw.
-            }
-            Msg::EmbeddingReady(id, seq, v) => {
-                // Aplicamos el vector sólo si sigue siendo el cálculo más
-                // reciente para esa nota y la nota no fue borrada entre
-                // medio. Tras insertarlo, persistimos para que el campo
-                // semántico en disco quede al día.
-                if model.embed_latest.get(&id) == Some(&seq)
-                    && model.store.get(id).is_some()
-                {
-                    model.field.insert(id, v);
-                    // Recién ahora la nota tiene vector: le damos domicilio
-                    // en el mapa una sola vez, cerca de sus parientes. Si ya
-                    // tenía posición (re-embed por edición), no se mueve.
-                    place_note(&mut model, id);
-                    persist(&model);
-                }
-            }
-            Msg::Export => {
-                // Firmar requiere identidad: si está bloqueada, pedimos la
-                // passphrase y reanudamos el export al desbloquear.
-                if model.keypair.is_none() {
-                    start_unlock(&mut model, Msg::Export);
-                } else {
-                    commit_edits(&mut model, h);
-                    model.status = Some(export_notebook(&model));
-                }
-            }
-            Msg::Import => {
-                let report = import_notebook(&mut model, h);
-                persist(&model);
-                model.status = Some(report);
-            }
-            Msg::Publish => {
-                if model.keypair.is_none() {
-                    start_unlock(&mut model, Msg::Publish);
-                } else {
-                    // Asegura que el sobre en disco refleje lo editado, luego
-                    // levanta (una vez) el servidor TCP que lo sirve.
-                    commit_edits(&mut model, h);
-                    let _ = export_notebook(&model);
-                    model.status = Some(start_publishing(&mut model, h));
-                }
-            }
-            Msg::Receive => {
-                let my_key = model.keypair.as_ref().map(|k| k.public_key());
-                // Abrimos el panel de recibir ya: input de dirección
-                // (prellenado, editable para WAN) + lista que se irá
-                // poblando con lo que aparezca en la LAN.
-                model.receiving = true;
-                model.peers.clear();
-                if model.peer_input.is_empty() {
-                    model.peer_input.set_text(peer_addr());
-                }
-                model.focus = Focus::PeerAddr;
-                model.status = Some("buscando pares (LAN + DHT)… o escribí una dirección".into());
-                // Si hay nodo libp2p (bootstrap configurado), también
-                // consultamos la DHT; lo capturamos para el worker.
-                let dht = model.p2p.as_ref().map(|p| (p.rt.clone(), p.node.clone()));
-                // El descubrimiento bloquea: va a un worker y reentra con la
-                // lista de pares (LAN por UDP + DHT por libp2p, sin uno mismo).
-                h.spawn(move || {
-                    let mut infos: Vec<PeerInfo> =
-                        khipu_share::discovery::descubrir(std::time::Duration::from_secs(3))
-                            .unwrap_or_default()
-                            .into_iter()
-                            .filter(|p| Some(p.beacon.author) != my_key)
-                            .map(|p| PeerInfo {
-                                addr: p.fetch_addr.to_string(),
-                                label: format!(
-                                    "LAN · {} · de:{} · {}",
-                                    p.beacon.name,
-                                    khipu_share::hex8(&p.beacon.author),
-                                    p.fetch_addr
-                                ),
-                            })
-                            .collect();
-                    if let Some((rt, node)) = dht {
-                        let me = node.peer_id();
-                        for pid in rt.block_on(node.descubrir()) {
-                            if pid == me {
-                                continue;
-                            }
-                            let s = pid.to_string();
-                            let corto: String = s.chars().rev().take(8).collect::<Vec<_>>()
-                                .into_iter().rev().collect();
-                            infos.push(PeerInfo {
-                                label: format!("DHT · …{corto}"),
-                                addr: s,
-                            });
-                        }
-                    }
-                    Msg::PeersFound(infos)
-                });
-            }
-            Msg::PeersFound(peers) => {
-                // Sólo aplica si seguimos en modo recibir (no cancelado).
-                if model.receiving {
-                    model.status = Some(if peers.is_empty() {
-                        "ningún par en la LAN — escribí una dirección y jalá".into()
-                    } else {
-                        format!("{} pares en la red — elegí uno o escribí una dirección", peers.len())
-                    });
-                    model.peers = peers;
-                }
-            }
-            Msg::FetchManual => {
-                let addr = model.peer_input.text().trim().to_string();
-                if addr.is_empty() {
-                    model.status = Some("escribí una dirección host:puerto".into());
-                } else {
-                    h.dispatch(Msg::FetchFrom(addr));
-                }
-            }
-            Msg::FetchFrom(addr) => {
-                model.receiving = false;
-                model.peers.clear();
-                model.focus = Focus::None;
-                let destino = addr.trim().to_string();
-                if destino.starts_with('/') || !destino.contains(':') {
-                    // Vía libp2p: multiaddr (`/ip4/…/p2p/<id>`, incluido
-                    // circuito) o un peer-id pelado (descubierto por DHT).
-                    // Arma el nodo si hace falta.
-                    if ensure_p2p(&mut model) {
-                        let p = model.p2p.as_ref().expect("p2p recién armado");
-                        let (rt, node) = (p.rt.clone(), p.node.clone());
-                        let es_multiaddr = destino.starts_with('/');
-                        model.status = Some(format!("jalando por libp2p de {destino}…"));
-                        h.spawn(move || {
-                            let res = if es_multiaddr {
-                                rt.block_on(node.fetch_addr_str(&destino))
-                            } else {
-                                rt.block_on(node.fetch_peer_str(&destino))
-                            };
-                            match res {
-                                Ok(s) => Msg::Received(Ok(s)),
-                                Err(e) => Msg::Received(Err(format!("p2p: {e}"))),
-                            }
-                        });
-                    } else {
-                        model.status = Some("no se pudo iniciar el nodo libp2p".into());
-                    }
-                } else {
-                    // Dirección TCP `host:puerto` (LAN/WAN directa).
-                    model.status = Some(format!("jalando de {destino}…"));
-                    h.spawn(move || match khipu_share::net::fetch(&destino) {
-                        Ok(s) => Msg::Received(Ok(s)),
-                        Err(e) => {
-                            Msg::Received(Err(format!("no se pudo recibir de {destino}: {e}")))
-                        }
-                    });
-                }
-            }
-            Msg::CancelPeers => {
-                model.receiving = false;
-                model.peers.clear();
-                model.focus = Focus::None;
-                model.status = Some("recibir cancelado".into());
-            }
-            Msg::Received(res) => {
-                model.receiving = false;
-                model.peers.clear();
-                model.status = Some(match res {
-                    Ok(sobre) => match khipu_share::open(&sobre) {
-                        Ok(bundle) => {
-                            let now = now_secs();
-                            let outcome =
-                                khipu_share::import_into(&mut model.store, bundle, now);
-                            for id in &outcome.created {
-                                model.order.push(*id);
-                                schedule_embedding(&mut model, *id, h);
-                            }
-                            persist(&model);
-                            format!(
-                                "recibidas {} · omitidas {} (ya existían)",
-                                outcome.created.len(),
-                                outcome.skipped
-                            )
-                        }
-                        Err(_) => "firma inválida — sobre rechazado".into(),
-                    },
-                    Err(e) => e,
-                });
-            }
-            Msg::Unlock => {
-                let pass = model.passphrase.text();
-                match unlock_identity(&pass) {
-                    Some(kp) => {
-                        let id = khipu_share::hex8(&kp.public_key());
-                        model.keypair = Some(kp);
-                        model.unlocking = false;
-                        model.passphrase.clear();
-                        model.focus = Focus::None;
-                        model.status = Some(format!("identidad desbloqueada · {id}"));
-                        // Reanudar lo que el usuario quería hacer.
-                        if let Some(accion) = model.pending.take() {
-                            h.dispatch(*accion);
-                        }
-                    }
-                    None => {
-                        model.status =
-                            Some("passphrase incorrecta o sin acceso al keystore".into());
-                    }
-                }
-            }
-            Msg::CancelUnlock => {
-                model.unlocking = false;
-                model.pending = None;
-                model.passphrase.clear();
-                model.focus = Focus::None;
-                model.status = Some("desbloqueo cancelado".into());
-            }
-            Msg::RelayReady(addr) => {
-                model.status = Some(format!("alcanzable vía relay: {addr}"));
-            }
-            Msg::DeleteSelected => {
-                if let Some(id) = model.selected {
-                    model.store.remove(id);
-                    model.order.retain(|x| *x != id);
-                    model.field.remove(id);
-                    let next = model.order.first().copied();
-                    model.selected = None;
-                    model.title.clear();
-                    model.body = EditorState::default();
-                    model.tags.clear();
-                    if let Some(n) = next {
-                        select(&mut model, n);
-                    }
-                    persist(&model);
-                }
-            }
-            Msg::Focus(f) => {
-                commit_edits(&mut model, h);
-                model.focus = f;
-            }
-            Msg::Key(ev) => {
-                let changed = match model.focus {
-                    Focus::Title => model.title.apply_key(&ev),
-                    Focus::Body => model.body.apply_key(&ev).touched(),
-                    Focus::Tags => model.tags.apply_key(&ev),
-                    Focus::Search => {
-                        // El search no muta el store: filtramos al
-                        // renderizar. Sólo consumimos el evento.
-                        let _ = model.search.apply_key(&ev);
-                        false
-                    }
-                    Focus::Passphrase => {
-                        // La passphrase no toca el store; sólo el input.
-                        let _ = model.passphrase.apply_key(&ev);
-                        false
-                    }
-                    Focus::PeerAddr => {
-                        let _ = model.peer_input.apply_key(&ev);
-                        false
-                    }
-                    Focus::Region => {
-                        let _ = model.region_input.apply_key(&ev);
-                        false
-                    }
-                    Focus::None => false,
-                };
-                if changed {
-                    commit_edits(&mut model, h);
-                }
-            }
-            Msg::EditorPointer(ev) => {
-                let metrics = EditorMetrics::for_font_size(13.0);
-                match ev {
-                    PointerEvent::Click { x, y } => {
-                        let (line, col) = metrics.screen_to_pos(x, y, model.body.scroll_offset);
-                        model.body.set_caret_at(line, col);
-                    }
-                    PointerEvent::Drag { initial_x, initial_y, dx, dy } => {
-                        let (l0, c0) = metrics.screen_to_pos(
-                            initial_x,
-                            initial_y,
-                            model.body.scroll_offset,
-                        );
-                        let (l1, c1) = metrics.screen_to_pos(
-                            initial_x + dx,
-                            initial_y + dy,
-                            model.body.scroll_offset,
-                        );
-                        model.body.set_caret_at(l0, c0);
-                        model.body.extend_selection_to(l1, c1);
-                    }
-                }
-                model.focus = Focus::Body;
-            }
-            Msg::MenuOpen(idx) => {
-                model.menu_open = idx;
-                model.menu_active = usize::MAX;
-                model.edit_menu = None;
-                if idx.is_some() {
-                    model.menu_anim = Tween::new(0.0, 1.0, motion::FAST, motion::ease_out_cubic);
-                    animate(h, motion::FAST, || Msg::MenuTick);
-                }
-            }
-            Msg::MenuCommand(cmd) => {
-                return handle_menu_command(model, cmd, h);
-            }
-            Msg::MenuNav(dir) => {
-                if let Some(mi) = model.menu_open {
-                    let menu = app_menu(&model);
-                    model.menu_active = menubar_nav(&menu, mi, model.menu_active, dir);
-                }
-            }
-            Msg::MenuActivate => {
-                if let Some(mi) = model.menu_open {
-                    let menu = app_menu(&model);
-                    if let Some(cmd) = menubar_command_at(&menu, mi, model.menu_active) {
-                        return handle_menu_command(model, cmd, h);
-                    }
-                }
-            }
-            Msg::MenuTick => {}
-            Msg::EditNav(dir) => {
-                let flags = focused_edit_flags(&model);
-                model.edit_active = editmenu::edit_menu_step(flags, model.edit_active, dir);
-            }
-            Msg::EditActivate => {
-                let flags = focused_edit_flags(&model);
-                if let Some(action) = editmenu::edit_menu_action_at(flags, model.edit_active) {
-                    return apply_edit_menu_action(model, action, h);
-                }
-            }
-            Msg::EditMenuOpen(x, y) => {
-                model.edit_menu = Some((x, y));
-                model.edit_active = usize::MAX;
-                model.menu_open = None;
-                model.edit_anim = Tween::new(0.0, 1.0, motion::FAST, motion::ease_out_cubic);
-                animate(h, motion::FAST, || Msg::MenuTick);
-            }
-            Msg::EditMenuAction(action) => {
-                return apply_edit_menu_action(model, action, h);
-            }
-            Msg::CloseMenus => {
-                model.menu_open = None;
-                model.menu_active = usize::MAX;
-                model.edit_menu = None;
-                model.edit_active = usize::MAX;
-            }
-            Msg::MapPan(dx, dy) => {
-                // El delta viene en pixels de pantalla; lo llevamos a
-                // mundo dividiendo por el zoom para que el arrastre se
-                // sienta 1:1 con el cursor a cualquier escala.
-                let z = model.cam_zoom.max(0.01);
-                model.cam_pan.0 += dx / z;
-                model.cam_pan.1 += dy / z;
-            }
-            Msg::MapZoom(dy) => {
-                // dy>0 = rueda hacia el usuario (winit invierte el signo en
-                // el event loop) → alejar. Factor multiplicativo, clamp para
-                // no perder el mapa.
-                let factor = (1.0 - dy * 0.12).clamp(0.5, 2.0);
-                model.cam_zoom = (model.cam_zoom * factor).clamp(0.15, 6.0);
-            }
-            Msg::MapClick(lx, ly, rw, rh) => {
-                model.canvas_size = (rw, rh);
-                if let Some(id) = pick_note(&model, lx, ly, rw, rh) {
-                    commit_edits(&mut model, h);
-                    reinforce_and_touch(&mut model, id);
-                    select(&mut model, id);
-                    persist(&model);
-                }
-            }
-            Msg::ToggleList => {
-                model.show_list = !model.show_list;
-            }
-            Msg::Deselect => {
-                commit_edits(&mut model, h);
-                deselect(&mut model);
-                persist(&model);
-            }
-            Msg::BeginNaming(x, y) => {
-                model.naming = Some((x, y));
-                model.region_input.clear();
-                model.focus = Focus::Region;
-            }
-            Msg::CommitNaming => {
-                if let Some((x, y)) = model.naming.take() {
-                    let name = model.region_input.text().trim().to_string();
-                    if !name.is_empty() {
-                        model.regions.push(Region { name, x, y });
-                        persist(&model);
-                    }
-                }
-                model.region_input.clear();
-                model.focus = Focus::None;
-            }
-            Msg::CancelNaming => {
-                model.naming = None;
-                model.region_input.clear();
-                model.focus = Focus::None;
-            }
-            Msg::EscapeMap => {
-                // Cierra la capa más cercana al usuario, en orden.
-                if model.naming.is_some() {
-                    model.naming = None;
-                    model.region_input.clear();
-                    model.focus = Focus::None;
-                } else if model.selected.is_some() {
-                    commit_edits(&mut model, h);
-                    deselect(&mut model);
-                    persist(&model);
-                } else if model.show_list {
-                    model.show_list = false;
-                } else {
-                    model.focus = Focus::None;
-                }
-            }
-        }
-        model
-    }
-
-    fn view(model: &Model) -> View<Msg> {
-        let palette = ListPalette::from_theme(&model.theme);
-        let input_palette = TextInputPalette::from_theme(&model.theme);
-        let editor_palette = EditorPalette::from_theme(&model.theme);
-
-        // Prompt de passphrase: ocupa toda la ventana hasta resolverse.
-        if model.unlocking {
-            let mut children = vec![header_view(model), unlock_view(model, &input_palette)];
-            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);
-
-        // Zoom semántico: con una nota seleccionada y el mapa lo bastante
-        // cerca, el nodo se "abre" como tarjeta anclada a su coordenada
-        // (in-situ). Lejos, el editor cae al panel lateral — un fallback
-        // para editar sin tener que acercarse.
-        let inplace = model
-            .selected
-            .filter(|_| model.cam_zoom >= ZOOM_INJECT)
-            .and_then(|id| node_screen_pos(model, id).map(|p| (id, p)));
-
-        // El mapa es la interfaz: ocupa todo el cuerpo como capa de fondo.
-        // Sobre él viajan, como hijos del canvas: la tarjeta del nodo
-        // abierto (zoom semántico), los chips para bautizar clústeres
-        // densos, y el input del bautizo en curso.
-        let mut injected: Vec<View<Msg>> = Vec::new();
-        if let Some((_, (nx, ny))) = inplace {
-            let editor = editor_panel(model, &input_palette, &editor_palette);
-            injected.push(node_card(editor, nx, ny, model.canvas_size, &model.theme));
-        }
-        // Sugerencias de bautizo (sólo si no estamos editando in-situ, para
-        // no encimar la tarjeta).
-        if inplace.is_none() {
-            for (wx, wy) in unnamed_cluster_centroids(model) {
-                let (sx, sy) = world_screen(model, wx, wy);
-                injected.push(pinned(
-                    name_region_chip(wx, wy, &model.theme),
-                    sx,
-                    sy,
-                    132.0,
-                    24.0,
-                    model.canvas_size,
-                ));
-            }
-        }
-        // Input del bautizo en curso, anclado al centroide elegido.
-        if let Some((wx, wy)) = model.naming {
-            let (sx, sy) = world_screen(model, wx, wy);
-            injected.push(pinned(
-                naming_input(model, &input_palette),
-                sx,
-                sy,
-                220.0,
-                34.0,
-                model.canvas_size,
-            ));
-        }
-        let map = gravity_panel(model, injected);
-        let mut layers: Vec<View<Msg>> = vec![map];
-
-        // Cajón de notas (izquierda): abierto a pedido, o forzado en modo
-        // recibir (muestra los pares en vez de las notas).
-        if model.show_list || model.receiving {
-            let drawer = if model.receiving {
-                receive_panel(model, &palette, &input_palette)
-            } else {
-                list_panel(model, &palette, &input_palette)
-            };
-            layers.push(overlay_left(drawer, LIST_WIDTH));
-        }
-
-        // Editor lateral: sólo si hay selección y NO se abrió in-situ.
-        if model.selected.is_some() && inplace.is_none() {
-            let editor = editor_panel(model, &input_palette, &editor_palette);
-            layers.push(overlay_right(editor, EDITOR_OVERLAY_W, &model.theme));
-        }
-
-        let body = 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(layers);
-
-        // Barra de menú principal: primer hijo del column raíz.
-        let menu = app_menu(model);
-        let menubar = menubar_view(&menubar_spec(&menu, model, &model.theme));
-
-        let mut children = vec![menubar, header, body];
-        if let Some(bar) = status_bar(model) {
-            children.push(bar);
-        }
-
-        // El right-click se engancha en la raíz (origen 0,0 → coords
-        // locales == coords de ventana) y abre el menú de edición sobre
-        // el campo focuseado.
-        View::new(Style {
-            flex_direction: FlexDirection::Column,
-            size: Size {
-                width: percent(1.0_f32),
-                height: percent(1.0_f32),
-            },
-            ..Default::default()
-        })
-        .fill(model.theme.bg_app)
-        .on_right_click_at(|x, y, _w, _h| Some(Msg::EditMenuOpen(x, y)))
-        .children(children)
-    }
-
-    fn view_overlay(model: &Model) -> Option<View<Msg>> {
-        // El prompt modal de passphrase no convive con menús.
-        if model.unlocking {
-            return None;
-        }
-        // Prioridad: menú de edición contextual sobre el menú principal.
-        if let Some((x, y)) = model.edit_menu {
-            let flags = focused_edit_flags(model);
-            let (w, h) = Self::initial_size();
-            let mut spec = editmenu::edit_context_menu(
-                (x, y),
-                (w as f32, h as f32),
-                &model.theme,
-                flags,
-                Msg::EditMenuAction,
-                Msg::CloseMenus,
-            );
-            spec.active = model.edit_active;
-            return Some(context_menu_view_ex(
-                spec,
-                ContextMenuExtras {
-                    appear: model.edit_anim.value(),
-                    ..Default::default()
-                },
-            ));
-        }
-        // Si no, el dropdown del menú principal.
-        let menu = app_menu(model);
-        menubar_overlay_animated(
-            &menubar_spec(&menu, model, &model.theme),
-            model.menu_active,
-            model.menu_anim.value(),
-        )
-    }
-
-    fn on_key(model: &Model, event: &KeyEvent) -> Option<Msg> {
-        // Menús abiertos: las flechas navegan y tienen prioridad sobre todo.
-        if event.state == KeyState::Pressed {
-            if let Some(mi) = model.menu_open {
-                let n = app_menu(model).menus.len().max(1);
-                return match &event.key {
-                    Key::Named(NamedKey::Escape) => Some(Msg::CloseMenus),
-                    Key::Named(NamedKey::ArrowLeft) => Some(Msg::MenuOpen(Some((mi + n - 1) % n))),
-                    Key::Named(NamedKey::ArrowRight) => Some(Msg::MenuOpen(Some((mi + 1) % n))),
-                    Key::Named(NamedKey::ArrowDown) => Some(Msg::MenuNav(1)),
-                    Key::Named(NamedKey::ArrowUp) => Some(Msg::MenuNav(-1)),
-                    Key::Named(NamedKey::Enter) => Some(Msg::MenuActivate),
-                    _ => None,
-                };
-            }
-            if model.edit_menu.is_some() {
-                return match &event.key {
-                    Key::Named(NamedKey::Escape) => Some(Msg::CloseMenus),
-                    Key::Named(NamedKey::ArrowDown) => Some(Msg::EditNav(1)),
-                    Key::Named(NamedKey::ArrowUp) => Some(Msg::EditNav(-1)),
-                    Key::Named(NamedKey::Enter) => Some(Msg::EditActivate),
-                    _ => None,
-                };
-            }
-        }
-        // Con el prompt de passphrase abierto, las teclas son sólo suyas:
-        // Enter desbloquea, Esc cancela, el resto va al input.
-        if model.unlocking {
-            if event.state == KeyState::Pressed && !event.repeat {
-                if matches!(&event.key, Key::Named(NamedKey::Enter)) {
-                    return Some(Msg::Unlock);
-                }
-                if matches!(&event.key, Key::Named(NamedKey::Escape)) {
-                    return Some(Msg::CancelUnlock);
-                }
-            }
-            return Some(Msg::Key(event.clone()));
-        }
-        // En modo recibir con foco en la dirección: Enter jala, Esc cancela.
-        if model.receiving && model.focus == Focus::PeerAddr {
-            if event.state == KeyState::Pressed && !event.repeat {
-                if matches!(&event.key, Key::Named(NamedKey::Enter)) {
-                    return Some(Msg::FetchManual);
-                }
-                if matches!(&event.key, Key::Named(NamedKey::Escape)) {
-                    return Some(Msg::CancelPeers);
-                }
-            }
-            return Some(Msg::Key(event.clone()));
-        }
-        // Bautizando una región: Enter confirma, Esc cancela, resto al input.
-        if model.naming.is_some() && model.focus == Focus::Region {
-            if event.state == KeyState::Pressed && !event.repeat {
-                if matches!(&event.key, Key::Named(NamedKey::Enter)) {
-                    return Some(Msg::CommitNaming);
-                }
-                if matches!(&event.key, Key::Named(NamedKey::Escape)) {
-                    return Some(Msg::CancelNaming);
-                }
-            }
-            return Some(Msg::Key(event.clone()));
-        }
-        // Atajo global: Ctrl+N (sin foco en input necesario) crea
-        // nota. Esc libera el foco. Cualquier otra tecla la dispatcha
-        // como `Key` al input/editor focado.
-        if event.state == KeyState::Pressed && !event.repeat {
-            if event.modifiers.ctrl
-                && matches!(&event.key, Key::Character(s) if s.eq_ignore_ascii_case("n"))
-            {
-                return Some(Msg::NewNote);
-            }
-            if matches!(&event.key, Key::Named(NamedKey::Escape)) {
-                return Some(Msg::EscapeMap);
-            }
-        }
-        Some(Msg::Key(event.clone()))
-    }
-
-    fn title() -> &'static str {
-        "khipu"
-    }
-
-    fn app_id() -> Option<&'static str> {
-        Some("gioser.khipu")
-    }
-
-    fn initial_size() -> (u32, u32) {
-        (1280, 760)
-    }
-}
-
-
-
-/// Sincroniza inputs/editor → store/field + persiste si cambió algo.
-fn commit_edits(model: &mut Model, h: &Handle<Msg>) {
-    let Some(id) = model.selected else {
-        return;
-    };
-    let mut changed = false;
-    let new_title = model.title.text();
-    let new_body = model.body.text();
-    let new_tags = parse_tags(&model.tags.text());
-    let now = now_secs();
-    if let Some(note) = model.store.get_mut(id) {
-        if note.title != new_title {
-            note.title = new_title;
-            note.updated_at = now;
-            changed = true;
-        }
-        if note.body != new_body {
-            note.body = new_body;
-            note.updated_at = now;
-            changed = true;
-        }
-        if note.tags != new_tags {
-            note.tags = new_tags;
-            note.updated_at = now;
-            changed = true;
-        }
-    }
-    if changed {
-        // El texto ya está en el store: persistimos de inmediato para no
-        // perderlo. El embedding viaja a un worker y persistirá de nuevo
-        // cuando llegue (`Msg::EmbeddingReady`).
-        persist(model);
-        schedule_embedding(model, id, h);
-    }
-}
-
-// =====================================================================
-// Menú principal + menú de edición contextual
-// =====================================================================
-
-/// Devuelve el `EditorState` del campo focuseado (referencia inmutable) y
-/// si está enmascarado (passphrase). Search/PeerAddr/Title/Tags son
-/// `TextInputState` (su `.editor()`); Body es el `EditorState` directo.
-/// Sin foco editable devuelve `None`.
-fn focused_editor(model: &Model) -> (Option<&EditorState>, bool) {
-    match model.focus {
-        Focus::Body => (Some(&model.body), false),
-        Focus::Title => (Some(model.title.editor()), false),
-        Focus::Tags => (Some(model.tags.editor()), false),
-        Focus::Search => (Some(model.search.editor()), false),
-        Focus::PeerAddr => (Some(model.peer_input.editor()), false),
-        Focus::Region => (Some(model.region_input.editor()), false),
-        Focus::Passphrase => (Some(model.passphrase.editor()), model.passphrase.is_masked()),
-        Focus::None => (None, false),
-    }
-}
-
-/// `EditFlags` del campo focuseado, para nav/ejecución por teclado del
-/// menú de edición. Sin campo focuseado, flags vacíos (todo gris).
-fn focused_edit_flags(model: &Model) -> EditFlags {
-    let (editor, masked) = focused_editor(model);
-    match editor {
-        Some(ed) => EditFlags::from_editor(ed, masked),
-        None => EditFlags::default(),
-    }
-}
-
-/// Construye el menú principal de khipu reflejando el estado del campo
-/// focuseado (ítems de Editar grises sin selección / historial).
-fn app_menu(model: &Model) -> app_bus::AppMenu {
-    use app_bus::{AppMenu, Menu, MenuItem};
-    let (editor, _masked) = focused_editor(model);
-    let has_sel = editor.map(|e| e.has_selection()).unwrap_or(false);
-    let can_undo = editor.map(|e| e.can_undo()).unwrap_or(false);
-    let can_redo = editor.map(|e| e.can_redo()).unwrap_or(false);
-    let has_field = editor.is_some();
-    let has_sel_note = model.selected.is_some();
-
-    let mut undo = MenuItem::new("Deshacer", "edit.undo").shortcut("Ctrl+Z");
-    if !can_undo {
-        undo = undo.disabled();
-    }
-    let mut redo = MenuItem::new("Rehacer", "edit.redo").shortcut("Ctrl+Y");
-    if !can_redo {
-        redo = redo.disabled();
-    }
-    let mut cut = MenuItem::new("Cortar", "edit.cut").shortcut("Ctrl+X").separated();
-    let mut copy = MenuItem::new("Copiar", "edit.copy").shortcut("Ctrl+C");
-    if !has_sel {
-        cut = cut.disabled();
-        copy = copy.disabled();
-    }
-    let mut paste = MenuItem::new("Pegar", "edit.paste").shortcut("Ctrl+V");
-    if !has_field {
-        paste = paste.disabled();
-    }
-    let mut sel_all = MenuItem::new("Seleccionar todo", "edit.selectall")
-        .shortcut("Ctrl+A")
-        .separated();
-    if !has_field {
-        sel_all = sel_all.disabled();
-    }
-
-    let mut delete_note = MenuItem::new("Borrar nota", "note.delete");
-    if !has_sel_note {
-        delete_note = delete_note.disabled();
-    }
-    let archive_label = if model.show_archive {
-        "Ocultar archivadas"
-    } else {
-        "Ver archivadas"
-    };
+mod tejido;
 
-    AppMenu::new()
-        .menu(
-            Menu::new("Archivo")
-                .item(MenuItem::new("Nueva nota", "note.new").shortcut("Ctrl+N"))
-                .item(delete_note)
-                .item(MenuItem::new(archive_label, "note.archive").separated())
-                .item(MenuItem::new("Exportar sobre…", "share.export"))
-                .item(MenuItem::new("Importar sobre…", "share.import")),
-        )
-        .menu(
-            Menu::new("Editar")
-                .item(undo)
-                .item(redo)
-                .item(cut)
-                .item(copy)
-                .item(paste)
-                .item(sel_all),
-        )
-        .menu(
-            Menu::new("Compartir")
-                .item(MenuItem::new("Publicar (P2P)", "share.publish"))
-                .item(MenuItem::new("Recibir de un par…", "share.receive")),
-        )
-        .menu(
-            Menu::new("Ayuda")
-                .item(MenuItem::new("Buscar (foco)", "view.search").shortcut("Ctrl+F"))
-                .item(MenuItem::new("Acerca de khipu", "help.about")),
-        )
-}
-
-/// Arma el `MenuBarSpec` compartido por `menubar_view` y `menubar_overlay`.
-fn menubar_spec<'a>(
-    menu: &'a app_bus::AppMenu,
-    model: &Model,
-    theme: &'a Theme,
-) -> MenuBarSpec<'a, Msg> {
-    let (w, h) = KhipuApp::initial_size();
-    MenuBarSpec {
-        menu,
-        open: model.menu_open,
-        theme,
-        viewport: (w as f32, h as f32),
-        height: MENU_H,
-        on_open: std::sync::Arc::new(Msg::MenuOpen),
-        on_command: std::sync::Arc::new(|c: &str| Msg::MenuCommand(c.to_string())),
-    }
-}
-
-/// Traduce el `command` del menú principal al `Msg` real y lo redespacha
-/// por el `update`. Cierra el menú antes de actuar.
-fn handle_menu_command(mut model: Model, command: String, h: &Handle<Msg>) -> Model {
-    model.menu_open = None;
-    let target = match command.as_str() {
-        "note.new" => Some(Msg::NewNote),
-        "note.delete" => Some(Msg::DeleteSelected),
-        "note.archive" => Some(Msg::ToggleArchive),
-        "share.export" => Some(Msg::Export),
-        "share.import" => Some(Msg::Import),
-        "share.publish" => Some(Msg::Publish),
-        "share.receive" => Some(Msg::Receive),
-        "view.search" => Some(Msg::Focus(Focus::Search)),
-        "edit.undo" => Some(Msg::EditMenuAction(EditAction::Undo)),
-        "edit.redo" => Some(Msg::EditMenuAction(EditAction::Redo)),
-        "edit.cut" => Some(Msg::EditMenuAction(EditAction::Cut)),
-        "edit.copy" => Some(Msg::EditMenuAction(EditAction::Copy)),
-        "edit.paste" => Some(Msg::EditMenuAction(EditAction::Paste)),
-        "edit.selectall" => Some(Msg::EditMenuAction(EditAction::SelectAll)),
-        "help.about" => {
-            model.status = Some("khipu · cuaderno de notas P2P soberano".into());
-            None
-        }
-        _ => None,
-    };
-    match target {
-        Some(msg) => KhipuApp::update(model, msg, h),
-        None => model,
-    }
-}
-
-/// Aplica una acción del menú de edición al editor del campo focuseado,
-/// usando el portapapeles del sistema, y replica el bookkeeping que khipu
-/// hace tras editar (commit al store + embedding si cambió un campo que
-/// vive en la nota). Cierra el menú de edición.
-fn apply_edit_menu_action(mut model: Model, action: EditAction, h: &Handle<Msg>) -> Model {
-    model.edit_menu = None;
-    let focus = model.focus;
-    let clip = &mut model.clipboard;
-    let result = match focus {
-        Focus::Body => Some(editmenu::apply(&mut model.body, action, clip)),
-        Focus::Title => Some(editmenu::apply(model.title.editor_mut(), action, clip)),
-        Focus::Tags => Some(editmenu::apply(model.tags.editor_mut(), action, clip)),
-        Focus::Search => Some(editmenu::apply(model.search.editor_mut(), action, clip)),
-        Focus::PeerAddr => Some(editmenu::apply(model.peer_input.editor_mut(), action, clip)),
-        Focus::Region => Some(editmenu::apply(model.region_input.editor_mut(), action, clip)),
-        Focus::Passphrase => Some(editmenu::apply(model.passphrase.editor_mut(), action, clip)),
-        Focus::None => None,
-    };
-    // Si la acción cambió un campo persistente de la nota (título, cuerpo o
-    // tags), corremos el mismo commit que las teclas. Search/PeerAddr/
-    // Passphrase no tocan el store.
-    if let Some(r) = result {
-        if r.changed() && matches!(focus, Focus::Body | Focus::Title | Focus::Tags) {
-            commit_edits(&mut model, h);
-        }
-    }
-    model
-}
-
-fn parse_tags(raw: &str) -> Vec<String> {
-    raw.split(',')
-        .map(|t| t.trim().to_string())
-        .filter(|t| !t.is_empty())
-        .collect()
-}
-
-fn select(model: &mut Model, id: NoteId) {
-    let Some(note) = model.store.get(id) else {
-        return;
-    };
-    model.selected = Some(id);
-    model.title.set_text(note.title.clone());
-    model.body = EditorState::default();
-    model.body.set_text(&note.body);
-    model.tags.set_text(note.tags.join(", "));
-    model.focus = Focus::Body;
-}
-
-/// Suelta la nota seleccionada y limpia los campos del editor. El editor
-/// flotante desaparece y el mapa queda libre — el equivalente a alejarse
-/// del nodo (precursor del zoom semántico).
-fn deselect(model: &mut Model) {
-    model.selected = None;
-    model.title.set_text(String::new());
-    model.body = EditorState::default();
-    model.tags.set_text(String::new());
-    model.focus = Focus::None;
-}
-
-/// Pide el embedding de `id` en segundo plano. Asigna una secuencia
-/// nueva, la marca como vigente, y dispara un worker (`Handle::spawn`)
-/// que al terminar reentra al `update` con [`Msg::EmbeddingReady`]. Así
-/// el `block_on` del arm remoto nunca corre en el hilo de UI.
-fn schedule_embedding(model: &mut Model, id: NoteId, h: &Handle<Msg>) {
-    let Some(note) = model.store.get(id) else {
-        return;
-    };
-    let combined = format!("{} {}", note.title, note.body);
-    model.embed_seq += 1;
-    let seq = model.embed_seq;
-    model.embed_latest.insert(id, seq);
-    let embedder = model.embedder.clone();
-    h.spawn(move || {
-        let v = embedder.embed_blocking(&combined);
-        Msg::EmbeddingReady(id, seq, v)
-    });
-}
-
-/// Versión síncrona para el arranque (seed y migración de formato):
-/// calcula el vector en línea y lo inserta. En init todavía no hay nada
-/// que repintar, así que bloquear un instante es lo correcto — y deja el
-/// campo semántico listo antes del primer layout.
-fn embed_now(model: &mut Model, id: NoteId) {
-    let Some(note) = model.store.get(id) else {
-        return;
-    };
-    let combined = format!("{} {}", note.title, note.body);
-    let v = model.embedder.embed_blocking(&combined);
-    model.field.insert(id, v);
-}
-
-/// Hash trigram → R^EMBED_DIM con signos +/-1 (random projection
-/// 1-bit signed), normalizado por L2. Determinista, independiente de
-/// idioma, sin red.
-fn embed(text: &str, dim: usize) -> Vec<f32> {
-    let mut v = vec![0.0f32; dim];
-    let lower = text.to_lowercase();
-    let bytes = lower.as_bytes();
-    if bytes.len() < 3 {
-        for (i, b) in bytes.iter().enumerate() {
-            v[i % dim] += *b as f32 / 255.0;
-        }
-    } else {
-        for w in bytes.windows(3) {
-            let mut h: u64 = 0xcbf29ce484222325;
-            for b in w {
-                h ^= *b as u64;
-                h = h.wrapping_mul(0x100000001b3);
-            }
-            let idx = (h as usize) % dim;
-            let sign = if h & 1 == 0 { 1.0 } else { -1.0 };
-            v[idx] += sign;
-        }
-    }
-    let n: f32 = v.iter().map(|x| x * x).sum::<f32>().sqrt();
-    if n > 0.0 {
-        for x in &mut v {
-            *x /= n;
-        }
-    }
-    v
-}
-
-#[derive(Serialize, Deserialize)]
-struct PersistedState {
-    store: NoteStore,
-    embeddings: Vec<(NoteId, Vec<f32>)>,
-    order: Vec<NoteId>,
-    /// Etiqueta del espacio vectorial con que se guardaron los
-    /// `embeddings` (ver [`Embedder::label`]). Si al cargar no coincide
-    /// con el embebedor activo, los vectores se recalculan.
-    model: String,
-    /// Topónimos bautizados. Trailing → archivos previos a las regiones
-    /// no parsean como esta forma y caen al fallback `PersistedStateV2`.
-    #[serde(default)]
-    regions: Vec<Region>,
-}
-
-/// Formato previo a las regiones (postcard no es self-describing, así que
-/// un campo trailing rompe el parseo y hay que intentar la forma vieja).
-#[derive(Deserialize)]
-struct PersistedStateV2 {
-    store: NoteStore,
-    embeddings: Vec<(NoteId, Vec<f32>)>,
-    order: Vec<NoteId>,
-    model: String,
-}
-
-/// Formato histórico, sin `model`. Fallback cuando ni el actual ni el V2
-/// parsean (archivos escritos antes de enchufar `verbo`).
-#[derive(Deserialize)]
-struct PersistedStateV1 {
-    store: NoteStore,
-    embeddings: Vec<(NoteId, Vec<f32>)>,
-    order: Vec<NoteId>,
-}
-
-/// Directorio de datos de khipu (`$XDG_DATA_HOME/khipu/`), creándolo si
-/// hace falta. Raíz de `notes.bin`, `identidad.seed` y `compartido.khipu`.
-fn khipu_dir() -> Option<PathBuf> {
-    let dirs = ProjectDirs::from("org", "gioser", "khipu")?;
-    let dir = dirs.data_dir().to_path_buf();
-    std::fs::create_dir_all(&dir).ok()?;
-    Some(dir)
-}
-
-fn data_file_path() -> Option<PathBuf> {
-    Some(khipu_dir()?.join("notes.bin"))
-}
-
-/// Desbloquea (o crea, o migra) la identidad del cuaderno con `passphrase`,
-/// vía [`khipu_share::identity::unlock`]. La semilla vive cifrada en
-/// `<datos>/keys/`; si existe un `identidad.seed` en claro de versiones
-/// viejas, se migra al keystore y se borra el claro. `None` si no hay
-/// directorio de datos o la passphrase no descifra.
-fn unlock_identity(passphrase: &str) -> Option<Keypair> {
-    let dir = khipu_dir()?;
-    let legacy = dir.join("identidad.seed");
-    khipu_share::identity::unlock(&dir.join("keys"), Some(&legacy), passphrase).ok()
-}
-
-/// Arranca el prompt de passphrase y memoriza la acción a reanudar.
-fn start_unlock(model: &mut Model, accion: Msg) {
-    model.unlocking = true;
-    model.pending = Some(Box::new(accion));
-    model.focus = Focus::Passphrase;
-    model.passphrase.clear();
-    model.status = Some("ingresá tu passphrase para desbloquear la identidad".into());
-}
-
-/// Prompt modal de passphrase: tarjeta centrada con el input (enmascarado
-/// con •) y dos botones. Enter desbloquea, Esc cancela (ver `on_key`).
-fn unlock_view(model: &Model, input_palette: &TextInputPalette) -> View<Msg> {
-    let titulo = View::new(Style {
-        size: Size {
-            width: percent(1.0_f32),
-            height: length(22.0_f32),
-        },
-        ..Default::default()
-    })
-    .text_aligned(
-        "Desbloqueá tu identidad para firmar".to_string(),
-        14.0,
-        model.theme.fg_text,
-        Alignment::Start,
-    );
-
-    let hint = View::new(Style {
-        size: Size {
-            width: percent(1.0_f32),
-            height: length(30.0_f32),
-        },
-        ..Default::default()
-    })
-    .text_aligned(
-        "La semilla vive cifrada (Argon2id). La primera vez, esta passphrase la crea."
-            .to_string(),
-        11.0,
-        model.theme.fg_muted,
-        Alignment::Start,
-    );
-
-    let input = text_input_view(
-        &model.passphrase,
-        "passphrase",
-        model.focus == Focus::Passphrase,
-        input_palette,
-        Msg::Focus(Focus::Passphrase),
-    );
-    let input_row = View::new(Style {
-        size: Size {
-            width: percent(1.0_f32),
-            height: length(30.0_f32),
-        },
-        flex_shrink: 0.0,
-        ..Default::default()
-    })
-    .children(vec![input]);
-
-    let unlock_btn = button(
-        "desbloquear (Enter)",
-        model.theme.bg_button,
-        model.theme.accent,
-        Msg::Unlock,
-    );
-    let cancel_btn = button(
-        "cancelar (Esc)",
-        model.theme.bg_button,
-        model.theme.fg_muted,
-        Msg::CancelUnlock,
-    );
-    let buttons = View::new(Style {
-        flex_direction: FlexDirection::Row,
-        size: Size {
-            width: percent(1.0_f32),
-            height: length(30.0_f32),
-        },
-        gap: Size {
-            width: length(8.0_f32),
-            height: length(0.0_f32),
-        },
-        ..Default::default()
-    })
-    .children(vec![unlock_btn, cancel_btn]);
-
-    let card = View::new(Style {
-        flex_direction: FlexDirection::Column,
-        size: Size {
-            width: length(420.0_f32),
-            height: Dimension::auto(),
-        },
-        padding: Rect {
-            left: length(18.0_f32),
-            right: length(18.0_f32),
-            top: length(16.0_f32),
-            bottom: length(16.0_f32),
-        },
-        gap: Size {
-            width: length(0.0_f32),
-            height: length(10.0_f32),
-        },
-        ..Default::default()
-    })
-    .fill(model.theme.bg_panel)
-    .radius(6.0)
-    .children(vec![titulo, hint, input_row, buttons]);
-
-    View::new(Style {
-        size: Size {
-            width: percent(1.0_f32),
-            height: Dimension::auto(),
-        },
-        flex_grow: 1.0,
-        align_items: Some(AlignItems::Center),
-        justify_content: Some(JustifyContent::Center),
-        ..Default::default()
-    })
-    .fill(model.theme.bg_app)
-    .children(vec![card])
-}
-
-/// Barra de estado al pie: muestra el último mensaje de export/import.
-fn status_bar(model: &Model) -> Option<View<Msg>> {
-    let text = model.status.as_ref()?;
-    Some(
-        View::new(Style {
-            size: Size {
-                width: percent(1.0_f32),
-                height: length(22.0_f32),
-            },
-            flex_shrink: 0.0,
-            padding: Rect {
-                left: length(12.0_f32),
-                right: length(12.0_f32),
-                top: length(2.0_f32),
-                bottom: length(2.0_f32),
-            },
-            align_items: Some(AlignItems::Center),
-            ..Default::default()
-        })
-        .fill(model.theme.bg_panel_alt)
-        .text_aligned(text.clone(), 11.0, model.theme.fg_muted, Alignment::Start),
-    )
-}
-
-fn load_state(path: &PathBuf) -> Option<PersistedState> {
-    let bytes = std::fs::read(path).ok()?;
-    // Formato actual primero; si no parsea (payload viejo sin `model`)
-    // caemos al V1 y lo migramos con `model` vacío → fuerza recálculo.
-    if let Ok(state) = postcard::from_bytes::<PersistedState>(&bytes) {
-        return Some(state);
-    }
-    // Archivo previo a las regiones: misma forma sin el campo trailing.
-    if let Ok(v2) = postcard::from_bytes::<PersistedStateV2>(&bytes) {
-        return Some(PersistedState {
-            store: v2.store,
-            embeddings: v2.embeddings,
-            order: v2.order,
-            model: v2.model,
-            regions: Vec::new(),
-        });
-    }
-    let v1: PersistedStateV1 = postcard::from_bytes(&bytes).ok()?;
-    Some(PersistedState {
-        store: v1.store,
-        embeddings: v1.embeddings,
-        order: v1.order,
-        model: String::new(),
-        regions: Vec::new(),
-    })
-}
-
-fn persist(model: &Model) {
-    let Some(path) = model.data_path.as_ref() else {
-        return;
-    };
-    let state = PersistedState {
-        store: model.store.clone(),
-        embeddings: model
-            .field
-            .iter()
-            .map(|(id, v)| (id, v.to_vec()))
-            .collect(),
-        order: model.order.clone(),
-        model: model.embedder.label(),
-        regions: model.regions.clone(),
-    };
-    if let Ok(bytes) = postcard::to_allocvec(&state) {
-        let tmp = path.with_extension("bin.tmp");
-        if std::fs::write(&tmp, &bytes).is_ok() {
-            let _ = std::fs::rename(&tmp, path);
-        }
-    }
-}
-
-fn from_state(state: PersistedState, embedder: Embedder) -> Model {
-    // ¿Los vectores guardados son del mismo espacio que el embebedor
-    // activo? Si cambió el modelo o la dimensión (p. ej. arrancó el
-    // daemon, o se cayó y volvimos al trigram local), son incomparables:
-    // se descartan y se recalcula todo el cuaderno.
-    let same_space = !state.model.is_empty() && state.model == embedder.label();
-    let regions = state.regions;
-    let mut model = Model {
-        store: state.store,
-        field: SemanticField::new(),
-        order: state.order,
-        selected: None,
-        title: TextInputState::new(),
-        body: EditorState::default(),
-        tags: TextInputState::new(),
-        search: TextInputState::new(),
-        focus: Focus::None,
-        theme: Theme::dark(),
-        data_path: None,
-        gravity: Gravity::new(Params::default()),
-        show_archive: false,
-        embedder,
-        embed_latest: BTreeMap::new(),
-        embed_seq: 0,
-        keypair: None,
-        status: None,
-        publishing: false,
-        receiving: false,
-        peers: Vec::new(),
-        peer_input: TextInputState::new(),
-        passphrase: TextInputState::masked(),
-        unlocking: false,
-        pending: None,
-        p2p: None,
-        menu_open: None,
-        menu_active: usize::MAX,
-        menu_anim: Tween::idle(1.0),
-        edit_menu: None,
-        edit_active: usize::MAX,
-        edit_anim: Tween::idle(1.0),
-        clipboard: SystemClipboard::new(),
-        cam_pan: (0.0, 0.0),
-        cam_zoom: 1.0,
-        show_list: true,
-        canvas_size: (1280.0, 640.0),
-        regions,
-        naming: None,
-        region_input: TextInputState::new(),
-    };
-    if same_space {
-        let restored: std::collections::HashSet<NoteId> =
-            state.embeddings.iter().map(|(id, _)| *id).collect();
-        for (id, v) in &state.embeddings {
-            if !v.is_empty() {
-                model.field.insert(*id, v.clone());
-            }
-        }
-        // Notas sin vector persistido (nota nueva que no alcanzó a
-        // guardar su embedding async): recalcular sólo esas.
-        let missing: Vec<NoteId> = model
-            .order
-            .iter()
-            .copied()
-            .filter(|id| !restored.contains(id))
-            .collect();
-        for id in missing {
-            embed_now(&mut model, id);
-            place_note(&mut model, id);
-        }
-    } else {
-        let ids: Vec<NoteId> = model.order.clone();
-        for id in ids {
-            embed_now(&mut model, id);
-        }
-    }
-    // Notas cargadas de disco sin posición (payloads viejos previos al
-    // anclaje) reciben domicilio ahora, en orden, contra las ya asentadas.
-    let unplaced: Vec<NoteId> = model
-        .order
-        .iter()
-        .copied()
-        .filter(|id| model.store.get(*id).map(|n| n.pos.is_none()).unwrap_or(false))
-        .collect();
-    for id in unplaced {
-        place_note(&mut model, id);
-    }
-    model
-}
-
-fn seeded_model(embedder: Embedder) -> Model {
-    let mut model = Model {
-        store: NoteStore::new(),
-        field: SemanticField::new(),
-        order: Vec::new(),
-        selected: None,
-        title: TextInputState::new(),
-        body: EditorState::default(),
-        tags: TextInputState::new(),
-        search: TextInputState::new(),
-        focus: Focus::None,
-        theme: Theme::dark(),
-        data_path: None,
-        gravity: Gravity::new(Params::default()),
-        show_archive: false,
-        embedder,
-        embed_latest: BTreeMap::new(),
-        embed_seq: 0,
-        keypair: None,
-        status: None,
-        publishing: false,
-        receiving: false,
-        peers: Vec::new(),
-        peer_input: TextInputState::new(),
-        passphrase: TextInputState::masked(),
-        unlocking: false,
-        pending: None,
-        p2p: None,
-        menu_open: None,
-        menu_active: usize::MAX,
-        menu_anim: Tween::idle(1.0),
-        edit_menu: None,
-        edit_active: usize::MAX,
-        edit_anim: Tween::idle(1.0),
-        clipboard: SystemClipboard::new(),
-        cam_pan: (0.0, 0.0),
-        cam_zoom: 1.0,
-        show_list: true,
-        canvas_size: (1280.0, 640.0),
-        regions: Vec::new(),
-        naming: None,
-        region_input: TextInputState::new(),
-    };
-    let now = now_secs();
-    let seed: [(&str, &str, &[&str]); 7] = [
-        (
-            "Índice",
-            "mi cuaderno: [[Recetas de la abuela]], [[Jardín]] y [[Oficina]]",
-            &["meta"],
-        ),
-        (
-            "Recetas de la abuela",
-            "sopa de auyama; ver también [[Lista del mercado]]",
-            &["cocina"],
-        ),
-        (
-            "Lista del mercado",
-            "auyama, cilantro, pan; vuelve al [[Índice]]",
-            &["cocina"],
-        ),
-        (
-            "Jardín",
-            "riego semanal; las [[Semillas de cilantro]] van en marzo",
-            &["jardín"],
-        ),
-        (
-            "Semillas de cilantro",
-            "germinan en diez días",
-            &["jardín"],
-        ),
-        (
-            "Oficina",
-            "[[Reunión del lunes]] y pendientes varios",
-            &["trabajo"],
-        ),
-        (
-            "Diario sin enlaces",
-            "una nota suelta, no la enlaza nadie",
-            &["personal"],
-        ),
-    ];
-    for (title, body, tags) in seed {
-        let tags: Vec<String> = tags.iter().map(|s| s.to_string()).collect();
-        let id = model.store.create(title, body, tags, now);
-        model.order.push(id);
-        embed_now(&mut model, id);
-        place_note(&mut model, id);
-    }
-    model
-}
-
-fn now_secs() -> u64 {
-    SystemTime::now()
-        .duration_since(UNIX_EPOCH)
-        .map(|d| d.as_secs())
-        .unwrap_or(0)
-}
-
-/// La masa "vivida" de una nota en `now`: la guardada decae contra
-/// el tiempo transcurrido desde `last_access`. Las notas con
-/// `last_access == 0` (payloads viejos sin el campo) toman su `mass`
-/// tal cual — equivale a tratar `now` como su primer acceso.
-fn current_mass(gravity: &Gravity, n: &Note, now: u64) -> f32 {
-    if n.last_access == 0 {
-        return n.mass;
-    }
-    let dt = if now > n.last_access {
-        (now - n.last_access) as f32
-    } else {
-        0.0
-    };
-    gravity.decay(n.mass, dt)
-}
-
-/// Refuerza la masa de `id` y marca `last_access`. El gesto canónico
-/// cuando el usuario selecciona o abre una nota: primero decaemos el
-/// valor guardado al "ahora" y sobre ese decaído sumamos el boost.
-fn reinforce_and_touch(model: &mut Model, id: NoteId) {
-    let now = now_secs();
-    let Some(n) = model.store.get(id) else {
-        return;
-    };
-    let lived = current_mass(&model.gravity, n, now);
-    let reinforced = model.gravity.reinforce(lived);
-    model.store.set_mass(id, reinforced);
-    model.store.touch(id, now);
-}
-
-/// Primera nota sobre el horizonte, ordenada por masa "viva".
-fn first_visible(model: &Model) -> Option<NoteId> {
-    let now = now_secs();
-    let mut visible: Vec<(NoteId, f32)> = model
-        .order
-        .iter()
-        .filter_map(|id| {
-            model.store.get(*id).and_then(|n| {
-                let m = current_mass(&model.gravity, n, now);
-                model.gravity.is_visible(m).then_some((*id, m))
-            })
-        })
-        .collect();
-    visible.sort_by(|a, b| {
-        b.1.partial_cmp(&a.1)
-            .unwrap_or(core::cmp::Ordering::Equal)
-            .then(a.0.cmp(&b.0))
-    });
-    visible.first().map(|(id, _)| *id)
-}
+use app::KhipuApp;
 
 fn main() {
     llimphi_ui::run::<KhipuApp>();
diff --git a/00_unanchay/khipu/khipu-app/src/map.rs b/00_unanchay/khipu/khipu-app/src/map.rs
deleted file mode 100644
index a378c83..0000000
--- a/00_unanchay/khipu/khipu-app/src/map.rs
+++ /dev/null
@@ -1,655 +0,0 @@
-//! `map` — el lienzo de pensamientos: geometría de cámara, colocación
-//! determinista de notas (anclaje), detección de regiones emergentes y el
-//! pintado del mapa (nodos que respiran por masa, filamentos, topónimos).
-//!
-//! El resto de la app le pide vistas y consultas; los helpers compartidos
-//! (`current_mass`, `now_secs`, `button`, `CLUSTER_THRESHOLD`) viven en la
-//! raíz y se alcanzan por `crate::`.
-
-use llimphi_theme::Theme;
-use llimphi_ui::llimphi_layout::taffy::{
-    prelude::{auto, length, percent, FlexDirection, Position, Rect, Size, Style},
-    AlignItems, Dimension, JustifyContent,
-};
-use llimphi_ui::llimphi_raster::kurbo::{Affine, BezPath, Circle as KurboCircle, Stroke};
-use llimphi_ui::llimphi_raster::peniko::{Color, Fill};
-use llimphi_ui::llimphi_text::{draw_block, Alignment, TextBlock};
-use llimphi_ui::{DragPhase, View};
-use llimphi_widget_text_input::{text_input_view, TextInputPalette};
-use khipu_core::NoteId;
-
-use crate::panels::button;
-use crate::{current_mass, now_secs, Focus, Model, Msg, CLUSTER_THRESHOLD};
-
-/// Un nodo del mapa, ya resuelto a coordenadas de mundo + su masa viva.
-/// Datos planos para viajar dentro de la closure de pintura.
-pub(crate) struct MapNode {
-    id: NoteId,
-    /// Coordenadas de mundo (el domicilio fijo de la nota).
-    x: f32,
-    y: f32,
-    /// Masa "vivida" en el instante del render: enciende el brillo y el
-    /// tamaño. Decae con el tiempo → el mapa respira sin que toques nada.
-    mass: f32,
-    /// `false` si cayó bajo el horizonte (sólo se ve con archivo activo).
-    visible: bool,
-    color: Color,
-    label: String,
-}
-
-/// Mundo → pantalla local (relativa al rect del lienzo). El centro del
-/// rect es el ancla del zoom; `pan` se suma en mundo, luego se escala.
-pub(crate) fn world_to_local(wx: f32, wy: f32, w: f32, h: f32, pan: (f32, f32), zoom: f32) -> (f32, f32) {
-    (w * 0.5 + (wx + pan.0) * zoom, h * 0.5 + (wy + pan.1) * zoom)
-}
-
-/// Inversa de [`world_to_local`]: pantalla local → mundo. Para resolver
-/// qué nota cae bajo un click.
-pub(crate) fn local_to_world(lx: f32, ly: f32, w: f32, h: f32, pan: (f32, f32), zoom: f32) -> (f32, f32) {
-    let z = zoom.max(1e-3);
-    ((lx - w * 0.5) / z - pan.0, (ly - h * 0.5) / z - pan.1)
-}
-
-/// La nota colocada más cercana a un click en coords locales, dentro de un
-/// radio de tolerancia (~18 px de pantalla). `None` si el click cae en el
-/// vacío — así arrastrar el fondo no cambia la selección.
-pub(crate) fn pick_note(model: &Model, lx: f32, ly: f32, w: f32, h: f32) -> Option<NoteId> {
-    let (wx, wy) = local_to_world(lx, ly, w, h, model.cam_pan, model.cam_zoom);
-    let now = now_secs();
-    let mut best: Option<(NoteId, f32)> = None;
-    for id in &model.order {
-        let Some(n) = model.store.get(*id) else { continue };
-        let Some((px, py)) = n.pos else { continue };
-        if !model.show_archive {
-            let m = current_mass(&model.gravity, n, now);
-            if !model.gravity.is_visible(m) {
-                continue;
-            }
-        }
-        let d2 = (px - wx).powi(2) + (py - wy).powi(2);
-        if best.map(|(_, bd)| d2 < bd).unwrap_or(true) {
-            best = Some((*id, d2));
-        }
-    }
-    let tol = (18.0 / model.cam_zoom.max(1e-3)).powi(2);
-    best.filter(|(_, d2)| *d2 <= tol).map(|(id, _)| id)
-}
-
-/// Separación mínima entre nodos al colocarlos (coordenadas de mundo).
-pub(crate) const MAP_MIN_SEP: f32 = 30.0;
-/// Ángulo áureo en radianes — reparte determinísticamente lo que no tiene
-/// parentela semántica sin amontonarlo.
-pub(crate) const GOLDEN_ANGLE: f32 = 2.399_963_2;
-
-/// Le da a `id` un domicilio fijo en el mapa, **una sola vez**: cae en el
-/// baricentro de sus parientes semánticos (ponderado por afinidad) y, si
-/// quedó pegada a otra nota, se separa apenas. Determinista y dependiente
-/// sólo de las notas ya asentadas, así el orden de inserción es estable y
-/// el mapa nunca se reacomoda solo.
-pub(crate) fn place_note(model: &mut Model, id: NoteId) {
-    if model.store.get(id).map(|n| n.pos.is_some()).unwrap_or(true) {
-        return; // ya tiene domicilio (o no existe): no se mueve.
-    }
-    // Vecinos ya colocados: su afinidad con la nota nueva y su posición.
-    let mut kin: Vec<(f32, (f32, f32))> = Vec::new();
-    for other in &model.order {
-        if *other == id {
-            continue;
-        }
-        let Some(pos) = model.store.get(*other).and_then(|n| n.pos) else { continue };
-        let aff = model.field.affinity(id, *other).unwrap_or(0.0).max(0.0);
-        kin.push((aff, pos));
-    }
-
-    let target = if kin.is_empty() {
-        (0.0, 0.0) // primera nota del cuaderno: centro del mundo.
-    } else {
-        let wsum: f32 = kin.iter().map(|(w, _)| *w).sum();
-        if wsum > 1e-3 {
-            // Cae junto a su parentela: baricentro ponderado por afinidad.
-            let (mut tx, mut ty) = (0.0_f32, 0.0_f32);
-            for (w, (x, y)) in &kin {
-                tx += w * x;
-                ty += w * y;
-            }
-            (tx / wsum, ty / wsum)
-        } else {
-            // Ortogonal a todo: anillo determinista por id, lejos del núcleo.
-            let ang = id as f32 * GOLDEN_ANGLE;
-            let rad = 180.0 + 14.0 * (id as f32).sqrt();
-            (rad * ang.cos(), rad * ang.sin())
-        }
-    };
-
-    // Separación: empuja el target hasta despegarlo de cada vecino cercano.
-    let mut p = target;
-    for _ in 0..12 {
-        let mut moved = false;
-        for (_, q) in &kin {
-            let dx = p.0 - q.0;
-            let dy = p.1 - q.1;
-            let d = (dx * dx + dy * dy).sqrt();
-            if d < MAP_MIN_SEP {
-                let (ux, uy) = if d > 1e-3 {
-                    (dx / d, dy / d)
-                } else {
-                    let a = id as f32 * GOLDEN_ANGLE;
-                    (a.cos(), a.sin())
-                };
-                let push = MAP_MIN_SEP - d;
-                p.0 += ux * push;
-                p.1 += uy * push;
-                moved = true;
-            }
-        }
-        if !moved {
-            break;
-        }
-    }
-
-    model.store.set_pos(id, p.0, p.1);
-}
-
-/// Envuelve `child` como cajón absoluto pegado al borde izquierdo, alto
-/// completo con un margen. El mapa de fondo sigue recibiendo pan/zoom en
-/// el resto de la ventana; sólo los clicks sobre el cajón los come él.
-pub(crate) fn overlay_left(child: View<Msg>, width: f32) -> View<Msg> {
-    View::new(Style {
-        position: Position::Absolute,
-        inset: Rect {
-            left: length(8.0_f32),
-            top: length(8.0_f32),
-            bottom: length(8.0_f32),
-            right: auto(),
-        },
-        size: Size {
-            width: length(width),
-            height: auto(),
-        },
-        flex_direction: FlexDirection::Column,
-        ..Default::default()
-    })
-    .children(vec![child])
-}
-
-/// Columna interna del editor: barra de cierre (× ⇒ deselecciona) arriba +
-/// el editor abajo, sobre `bg_panel`. La comparten el overlay lateral y la
-/// tarjeta anclada del zoom semántico.
-pub(crate) fn editor_shell(child: View<Msg>, theme: &Theme) -> View<Msg> {
-    let close = button("× cerrar", theme.bg_button, theme.fg_muted, Msg::Deselect);
-    let close_row = View::new(Style {
-        flex_direction: FlexDirection::Row,
-        size: Size {
-            width: percent(1.0_f32),
-            height: length(28.0_f32),
-        },
-        flex_shrink: 0.0,
-        justify_content: Some(JustifyContent::End),
-        align_items: Some(AlignItems::Center),
-        padding: Rect {
-            left: length(6.0_f32),
-            right: length(6.0_f32),
-            top: length(2.0_f32),
-            bottom: length(2.0_f32),
-        },
-        ..Default::default()
-    })
-    .fill(theme.bg_panel_alt)
-    .children(vec![close]);
-
-    View::new(Style {
-        flex_direction: FlexDirection::Column,
-        size: Size {
-            width: percent(1.0_f32),
-            height: percent(1.0_f32),
-        },
-        ..Default::default()
-    })
-    .fill(theme.bg_panel)
-    .children(vec![close_row, child])
-}
-
-/// Envuelve `child` como panel absoluto pegado al borde derecho, alto
-/// completo, con barra de cierre. El editor del nodo abierto cuando se lo
-/// edita de lejos (zoom bajo): un fallback práctico al anclaje in-situ.
-pub(crate) fn overlay_right(child: View<Msg>, width: f32, theme: &Theme) -> View<Msg> {
-    View::new(Style {
-        position: Position::Absolute,
-        inset: Rect {
-            left: auto(),
-            top: length(8.0_f32),
-            bottom: length(8.0_f32),
-            right: length(8.0_f32),
-        },
-        size: Size {
-            width: length(width),
-            height: auto(),
-        },
-        flex_direction: FlexDirection::Column,
-        ..Default::default()
-    })
-    .children(vec![editor_shell(child, theme)])
-}
-
-/// Mundo → pantalla local usando el último tamaño de lienzo conocido + la
-/// cámara. La versión de `view()`, donde el rect real aún no se sabe.
-pub(crate) fn world_screen(model: &Model, wx: f32, wy: f32) -> (f32, f32) {
-    let (w, h) = model.canvas_size;
-    world_to_local(wx, wy, w, h, model.cam_pan, model.cam_zoom)
-}
-
-/// Posición de pantalla (local al lienzo) del nodo `id`. `None` si la nota
-/// no tiene domicilio todavía.
-pub(crate) fn node_screen_pos(model: &Model, id: NoteId) -> Option<(f32, f32)> {
-    let (wx, wy) = model.store.get(id).and_then(|n| n.pos)?;
-    Some(world_screen(model, wx, wy))
-}
-
-/// Mínimo de notas para que un clúster cuente como región candidata.
-pub(crate) const REGION_MIN_MEMBERS: usize = 3;
-/// Distancia de mundo dentro de la cual una región ya "posee" un clúster:
-/// si hay un topónimo así de cerca del centroide, no se vuelve a ofrecer.
-pub(crate) const REGION_MATCH_DIST: f32 = 140.0;
-
-/// Centroides (mundo) de los clústeres densos que todavía no tienen una
-/// región cerca — los lugares que el mapa ofrece bautizar. Sólo cuentan
-/// miembros colocados y visibles.
-pub(crate) fn unnamed_cluster_centroids(model: &Model) -> Vec<(f32, f32)> {
-    let now = now_secs();
-    let mut out = Vec::new();
-    for cluster in model.field.clusters(CLUSTER_THRESHOLD) {
-        let pts: Vec<(f32, f32)> = cluster
-            .iter()
-            .filter_map(|id| {
-                let n = model.store.get(*id)?;
-                let p = n.pos?;
-                let m = current_mass(&model.gravity, n, now);
-                (model.show_archive || model.gravity.is_visible(m)).then_some(p)
-            })
-            .collect();
-        if pts.len() < REGION_MIN_MEMBERS {
-            continue;
-        }
-        let (sx, sy) = pts.iter().fold((0.0, 0.0), |(ax, ay), (x, y)| (ax + x, ay + y));
-        let c = (sx / pts.len() as f32, sy / pts.len() as f32);
-        let d2 = REGION_MATCH_DIST * REGION_MATCH_DIST;
-        let near_named = model
-            .regions
-            .iter()
-            .any(|r| (r.x - c.0).powi(2) + (r.y - c.1).powi(2) <= d2);
-        let naming_here = model
-            .naming
-            .map(|(nx, ny)| (nx - c.0).powi(2) + (ny - c.1).powi(2) <= d2)
-            .unwrap_or(false);
-        if !near_named && !naming_here {
-            out.push(c);
-        }
-    }
-    out
-}
-
-/// Chip clickeable "✛ nombrar zona" que ofrece bautizar el clúster denso
-/// en `(wx, wy)`. Al click abre el input de bautizo en esa coordenada.
-pub(crate) fn name_region_chip(wx: f32, wy: f32, theme: &Theme) -> View<Msg> {
-    View::new(Style {
-        size: Size {
-            width: percent(1.0_f32),
-            height: percent(1.0_f32),
-        },
-        align_items: Some(AlignItems::Center),
-        justify_content: Some(JustifyContent::Center),
-        ..Default::default()
-    })
-    .fill(theme.bg_button)
-    .radius(12.0)
-    .hover_fill(theme.bg_button_hover)
-    .text_aligned("✛ nombrar zona", 11.0, theme.fg_muted, Alignment::Center)
-    .on_click(Msg::BeginNaming(wx, wy))
-}
-
-/// Mini-input del bautizo en curso: una tarjeta con el campo de texto
-/// enfocado. Enter confirma, Esc cancela (en `on_key`).
-pub(crate) fn naming_input(model: &Model, input_palette: &TextInputPalette) -> View<Msg> {
-    let input = text_input_view(
-        &model.region_input,
-        "nombre de la zona…",
-        model.focus == Focus::Region,
-        input_palette,
-        Msg::Focus(Focus::Region),
-    );
-    View::new(Style {
-        size: Size {
-            width: percent(1.0_f32),
-            height: percent(1.0_f32),
-        },
-        align_items: Some(AlignItems::Center),
-        padding: Rect {
-            left: length(6.0_f32),
-            right: length(6.0_f32),
-            top: length(4.0_f32),
-            bottom: length(4.0_f32),
-        },
-        ..Default::default()
-    })
-    .fill(model.theme.bg_panel)
-    .radius(8.0)
-    .children(vec![input])
-}
-
-/// Posiciona `child` como vista absoluta de tamaño `(w, h)` centrada en la
-/// pantalla `(sx, sy)`, clampeada al lienzo. Para chips y mini-inputs que
-/// viven en el mapa (sugerencia de bautizo, input de nombre).
-pub(crate) fn pinned(child: View<Msg>, sx: f32, sy: f32, w: f32, h: f32, canvas: (f32, f32)) -> View<Msg> {
-    let left = (sx - w * 0.5).clamp(4.0, (canvas.0 - w - 4.0).max(4.0));
-    let top = (sy - h * 0.5).clamp(4.0, (canvas.1 - h - 4.0).max(4.0));
-    View::new(Style {
-        position: Position::Absolute,
-        inset: Rect {
-            left: length(left),
-            top: length(top),
-            right: auto(),
-            bottom: auto(),
-        },
-        size: Size {
-            width: length(w),
-            height: length(h),
-        },
-        ..Default::default()
-    })
-    .children(vec![child])
-}
-
-/// La tarjeta del nodo abierto, anclada a su coordenada `(nx, ny)` de
-/// pantalla: el zoom semántico hecho carne — el editor vive EN el lugar del
-/// pensamiento, no en un panel aparte. Se clampea para no salirse del
-/// lienzo. Hija del canvas, así pan/zoom la arrastran con el nodo.
-pub(crate) fn node_card(child: View<Msg>, nx: f32, ny: f32, canvas: (f32, f32), theme: &Theme) -> View<Msg> {
-    let (cw_max, ch_max) = canvas;
-    let cw = 380.0_f32.min((cw_max - 16.0).max(220.0));
-    let ch = 440.0_f32.min((ch_max - 16.0).max(200.0));
-    // Anclada bajo el nodo, centrada en X, clampeada a la ventana.
-    let left = (nx - cw * 0.5).clamp(8.0, (cw_max - cw - 8.0).max(8.0));
-    let top = (ny + 16.0).clamp(8.0, (ch_max - ch - 8.0).max(8.0));
-
-    View::new(Style {
-        position: Position::Absolute,
-        inset: Rect {
-            left: length(left),
-            top: length(top),
-            right: auto(),
-            bottom: auto(),
-        },
-        size: Size {
-            width: length(cw),
-            height: length(ch),
-        },
-        flex_direction: FlexDirection::Column,
-        ..Default::default()
-    })
-    .radius(8.0)
-    .children(vec![editor_shell(child, theme)])
-}
-
-pub(crate) fn gravity_panel(model: &Model, injected: Vec<View<Msg>>) -> View<Msg> {
-    let theme = model.theme;
-    let now = now_secs();
-    let clusters = model.field.clusters(CLUSTER_THRESHOLD);
-    let selected = model.selected;
-    let pan = model.cam_pan;
-    let zoom = model.cam_zoom;
-
-    // Nodos colocados (los que ya tienen domicilio), con su masa viva.
-    let mut nodes: Vec<MapNode> = Vec::new();
-    for id in &model.order {
-        let Some(n) = model.store.get(*id) else { continue };
-        let Some((x, y)) = n.pos else { continue };
-        let mass = current_mass(&model.gravity, n, now);
-        let visible = model.gravity.is_visible(mass);
-        if !visible && !model.show_archive {
-            continue;
-        }
-        nodes.push(MapNode {
-            id: *id,
-            x,
-            y,
-            mass,
-            visible,
-            color: cluster_color(*id, &clusters, theme),
-            label: short_label(&n.title),
-        });
-    }
-
-    // Topónimos: las regiones bautizadas, para pintarlas como rótulos de
-    // continente detrás de los nodos.
-    let regions: Vec<(String, f32, f32)> = model
-        .regions
-        .iter()
-        .map(|r| (r.name.clone(), r.x, r.y))
-        .collect();
-
-    // Filamentos del nodo seleccionado: sus parientes más afines ya
-    // colocados. Elegir un pensamiento enciende sus vecinos (activación
-    // por difusión) — el motor de serendipia.
-    let mut links: Vec<((f32, f32), (f32, f32), f32)> = Vec::new();
-    if let Some(sel) = selected {
-        if let Some(sp) = model.store.get(sel).and_then(|n| n.pos) {
-            for (nid, aff) in model.field.nearest(sel, 6) {
-                if aff < 0.20 {
-                    continue;
-                }
-                if let Some(np) = model.store.get(nid).and_then(|n| n.pos) {
-                    links.push((sp, np, aff));
-                }
-            }
-        }
-    }
-
-    let canvas = View::new(Style {
-        size: Size {
-            width: percent(1.0_f32),
-            height: percent(1.0_f32),
-        },
-        ..Default::default()
-    })
-    .fill(theme.bg_panel_alt)
-    .paint_with(move |scene, ts, rect| {
-        paint_map(scene, ts, rect, &nodes, &links, &regions, selected, pan, zoom, theme);
-    })
-    .draggable_at(|phase, dx, dy, _lx, _ly| match phase {
-        DragPhase::Move => Some(Msg::MapPan(dx, dy)),
-        DragPhase::End => None,
-    })
-    .on_scroll(|_dx, dy| Some(Msg::MapZoom(dy)))
-    .on_click_at(|lx, ly, w, h| Some(Msg::MapClick(lx, ly, w, h)))
-    // La tarjeta del nodo abierto (zoom semántico) viaja como hija del
-    // canvas: se pinta encima de los nodos y la cámara la arrastra con el
-    // pensamiento al que pertenece.
-    .children(injected);
-
-    View::new(Style {
-        size: Size {
-            width: Dimension::auto(),
-            height: percent(1.0_f32),
-        },
-        flex_grow: 1.0,
-        flex_basis: length(0.0_f32),
-        min_size: Size {
-            width: length(0.0_f32),
-            height: length(0.0_f32),
-        },
-        padding: Rect {
-            left: length(4.0_f32),
-            right: length(4.0_f32),
-            top: length(4.0_f32),
-            bottom: length(4.0_f32),
-        },
-        ..Default::default()
-    })
-    .fill(theme.bg_panel)
-    .children(vec![canvas])
-}
-
-#[allow(clippy::too_many_arguments)]
-pub(crate) fn paint_map(
-    scene: &mut llimphi_ui::llimphi_raster::vello::Scene,
-    ts: &mut llimphi_ui::llimphi_text::Typesetter,
-    rect: llimphi_ui::PaintRect,
-    nodes: &[MapNode],
-    links: &[((f32, f32), (f32, f32), f32)],
-    regions: &[(String, f32, f32)],
-    selected: Option<NoteId>,
-    pan: (f32, f32),
-    zoom: f32,
-    theme: Theme,
-) {
-    if rect.w <= 0.0 || rect.h <= 0.0 {
-        return;
-    }
-    // Pantalla absoluta = origen del rect + pantalla local.
-    let to_screen = |wx: f32, wy: f32| -> (f64, f64) {
-        let (lx, ly) = world_to_local(wx, wy, rect.w, rect.h, pan, zoom);
-        ((rect.x + lx) as f64, (rect.y + ly) as f64)
-    };
-
-    // Topónimos al fondo: el nombre de cada región, grande y tenue, como
-    // rótulo de continente; un halo suave insinúa su territorio.
-    for (name, rx, ry) in regions {
-        let (cx, cy) = to_screen(*rx, *ry);
-        let blob = KurboCircle::new((cx, cy), (96.0 * zoom as f64).max(34.0));
-        scene.fill(
-            Fill::NonZero,
-            Affine::IDENTITY,
-            with_alpha(theme.accent, 0.05),
-            None,
-            &blob,
-        );
-        let size = (15.0 * zoom).clamp(11.0, 28.0);
-        // Centrado aproximado: `simple` alinea a la izquierda en (x, y).
-        let est_w = name.chars().count() as f64 * size as f64 * 0.52;
-        draw_block(
-            scene,
-            ts,
-            &TextBlock::simple(
-                name,
-                size,
-                with_alpha(theme.fg_text, 0.30),
-                (cx - est_w * 0.5, cy - size as f64 * 0.6),
-            ),
-        );
-    }
-
-    // Filamentos primero (debajo de los nodos). Más opacos cuanto más afín.
-    for (a, b, aff) in links {
-        let (ax, ay) = to_screen(a.0, a.1);
-        let (bx, by) = to_screen(b.0, b.1);
-        let mut path = BezPath::new();
-        path.move_to((ax, ay));
-        path.line_to((bx, by));
-        let alpha = (0.18 + aff * 0.55).clamp(0.0, 0.85);
-        scene.stroke(
-            &Stroke::new((0.8 + *aff as f64 * 1.6).max(0.6)),
-            Affine::IDENTITY,
-            with_alpha(theme.accent, alpha),
-            None,
-            &path,
-        );
-    }
-
-    // Nodos: tamaño y brillo crecen con la masa viva (el mapa respira).
-    for n in nodes {
-        let (px, py) = to_screen(n.x, n.y);
-        let m = n.mass.clamp(0.0, 2.0);
-        // Radio base por masa, escalado apenas por zoom para no inflarse.
-        let r = (3.0 + m * 4.5) * (0.6 + 0.4 * zoom.clamp(0.5, 1.5));
-        // Brillo: las notas frescas arden; las que se enfrían se apagan
-        // hacia el fondo. Bajo el horizonte (archivo) van casi transparentes.
-        let glow = if n.visible {
-            (0.35 + m * 0.45).clamp(0.0, 1.0)
-        } else {
-            0.18
-        };
-        let color = with_alpha(n.color, glow);
-        // Halo tenue alrededor de las notas más encendidas.
-        if n.visible && m > 0.6 {
-            let halo = KurboCircle::new((px, py), (r + 5.0) as f64);
-            scene.fill(Fill::NonZero, Affine::IDENTITY, with_alpha(n.color, 0.10), None, &halo);
-        }
-        let circle = KurboCircle::new((px, py), r as f64);
-        scene.fill(Fill::NonZero, Affine::IDENTITY, color, None, &circle);
-
-        if selected == Some(n.id) {
-            let ring = KurboCircle::new((px, py), (r + 3.0) as f64);
-            scene.stroke(&Stroke::new(2.0), Affine::IDENTITY, theme.accent, None, &ring);
-        }
-
-        // Etiqueta: sólo si el zoom da espacio o es la seleccionada — para
-        // no saturar el mapa lejano. El texto sale del Typesetter.
-        if (zoom >= 0.9 || selected == Some(n.id)) && n.visible {
-            let lbl_col = with_alpha(theme.fg_text, (glow + 0.25).clamp(0.0, 1.0));
-            draw_block(
-                scene,
-                ts,
-                &TextBlock::simple(&n.label, 10.0, lbl_col, (px + r as f64 + 4.0, py - 7.0)),
-            );
-        }
-    }
-}
-
-pub(crate) fn cluster_color(id: NoteId, clusters: &[Vec<NoteId>], theme: Theme) -> Color {
-    let idx = clusters.iter().position(|c| c.contains(&id)).unwrap_or(0);
-    // Paleta tomada del theme + matices generados por golden-ratio
-    // sobre el hue del accent. Determinista por índice.
-    let palette: [Color; 6] = [
-        theme.accent,
-        with_alpha(rotate_hue(theme.accent, 0.16), 1.0),
-        with_alpha(rotate_hue(theme.accent, 0.33), 1.0),
-        with_alpha(rotate_hue(theme.accent, 0.50), 1.0),
-        with_alpha(rotate_hue(theme.accent, 0.66), 1.0),
-        with_alpha(rotate_hue(theme.accent, 0.83), 1.0),
-    ];
-    palette[idx % palette.len()]
-}
-
-pub(crate) fn with_alpha(c: Color, alpha: f32) -> Color {
-    let [r, g, b, _] = c.components;
-    Color::new([r, g, b, alpha])
-}
-
-pub(crate) fn rotate_hue(c: Color, dh: f32) -> Color {
-    // RGB → HSV → rota H → RGB. Aproximación, alpha fijo.
-    let [r, g, b, a] = c.components;
-    let max = r.max(g).max(b);
-    let min = r.min(g).min(b);
-    let v = max;
-    let s = if max <= 0.0 { 0.0 } else { (max - min) / max };
-    let h = if (max - min).abs() < 1e-6 {
-        0.0
-    } else if max == r {
-        ((g - b) / (max - min)) % 6.0
-    } else if max == g {
-        (b - r) / (max - min) + 2.0
-    } else {
-        (r - g) / (max - min) + 4.0
-    };
-    let h2 = ((h / 6.0) + dh).rem_euclid(1.0) * 6.0;
-    let c2 = v * s;
-    let x = c2 * (1.0 - ((h2 % 2.0) - 1.0).abs());
-    let (r2, g2, b2) = match h2 as i32 {
-        0 => (c2, x, 0.0),
-        1 => (x, c2, 0.0),
-        2 => (0.0, c2, x),
-        3 => (0.0, x, c2),
-        4 => (x, 0.0, c2),
-        _ => (c2, 0.0, x),
-    };
-    let m = v - c2;
-    Color::new([r2 + m, g2 + m, b2 + m, a])
-}
-
-pub(crate) fn short_label(s: &str) -> String {
-    let mut out: String = s.chars().take(24).collect();
-    if s.chars().count() > 24 {
-        out.push('…');
-    }
-    out
-}
diff --git a/00_unanchay/khipu/khipu-app/src/menu.rs b/00_unanchay/khipu/khipu-app/src/menu.rs
new file mode 100644
index 0000000..ca8af2d
--- /dev/null
+++ b/00_unanchay/khipu/khipu-app/src/menu.rs
@@ -0,0 +1,149 @@
+//! `menu` — menú principal de khipu y menú de edición contextual.
+//!
+//! Construye el `AppMenu` reflejando el estado del campo focuseado e
+//! informa los `EditFlags` del campo activo. El despacho de comandos a
+//! `Msg` reales vive en `app.rs` para evitar un ciclo de módulos.
+
+use llimphi_theme::Theme;
+use llimphi_widget_edit_menu::EditFlags;
+use llimphi_widget_menubar::{MenuBarSpec, DEFAULT_HEIGHT as MENU_H};
+use llimphi_widget_text_editor::EditorState;
+
+use crate::modelo::{Focus, Model, Msg};
+
+// =====================================================================
+// Helpers de foco
+// =====================================================================
+
+/// Devuelve el `EditorState` del campo focuseado (referencia inmutable) y
+/// si está enmascarado (passphrase). Sin foco editable devuelve `None`.
+pub(crate) fn focused_editor(model: &Model) -> (Option<&EditorState>, bool) {
+    match model.focus {
+        Focus::Body => (Some(&model.body), false),
+        Focus::Captura => (Some(model.captura.editor()), false),
+        Focus::Title => (Some(model.title.editor()), false),
+        Focus::Tags => (Some(model.tags.editor()), false),
+        Focus::Search => (Some(model.search.editor()), false),
+        Focus::PeerAddr => (Some(model.peer_input.editor()), false),
+        Focus::Nombre => (Some(model.nombre_input.editor()), false),
+        Focus::Passphrase => (Some(model.passphrase.editor()), model.passphrase.is_masked()),
+        Focus::None => (None, false),
+    }
+}
+
+/// `EditFlags` del campo focuseado, para nav/ejecución por teclado del
+/// menú de edición. Sin campo focuseado, flags vacíos (todo gris).
+pub(crate) fn focused_edit_flags(model: &Model) -> EditFlags {
+    let (editor, masked) = focused_editor(model);
+    match editor {
+        Some(ed) => EditFlags::from_editor(ed, masked),
+        None => EditFlags::default(),
+    }
+}
+
+// =====================================================================
+// Menú principal
+// =====================================================================
+
+/// Construye el menú principal de khipu reflejando el estado del campo
+/// focuseado (ítems de Editar grises sin selección / historial) y de la
+/// cuerda seleccionada (verbos de Cuerda grises sin una).
+pub(crate) fn app_menu(model: &Model) -> app_bus::AppMenu {
+    use app_bus::{AppMenu, Menu, MenuItem};
+    let (editor, _masked) = focused_editor(model);
+    let has_sel = editor.map(|e| e.has_selection()).unwrap_or(false);
+    let can_undo = editor.map(|e| e.can_undo()).unwrap_or(false);
+    let can_redo = editor.map(|e| e.can_redo()).unwrap_or(false);
+    let has_field = editor.is_some();
+    let has_sel_note = model.selected.is_some();
+    let has_sel_cuerda = model.cuerda_sel.is_some();
+
+    let mut undo = MenuItem::new("Deshacer", "edit.undo").shortcut("Ctrl+Z");
+    if !can_undo {
+        undo = undo.disabled();
+    }
+    let mut redo = MenuItem::new("Rehacer", "edit.redo").shortcut("Ctrl+Y");
+    if !can_redo {
+        redo = redo.disabled();
+    }
+    let mut cut = MenuItem::new("Cortar", "edit.cut").shortcut("Ctrl+X").separated();
+    let mut copy = MenuItem::new("Copiar", "edit.copy").shortcut("Ctrl+C");
+    if !has_sel {
+        cut = cut.disabled();
+        copy = copy.disabled();
+    }
+    let mut paste = MenuItem::new("Pegar", "edit.paste").shortcut("Ctrl+V");
+    if !has_field {
+        paste = paste.disabled();
+    }
+    let mut sel_all = MenuItem::new("Seleccionar todo", "edit.selectall")
+        .shortcut("Ctrl+A")
+        .separated();
+    if !has_field {
+        sel_all = sel_all.disabled();
+    }
+
+    let mut delete_note = MenuItem::new("Borrar nudo", "note.delete");
+    if !has_sel_note {
+        delete_note = delete_note.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();
+    if !has_sel_cuerda || model.tejiendo.is_some() {
+        tejer = tejer.disabled();
+    }
+    if !has_sel_cuerda {
+        renombrar = renombrar.disabled();
+        desatar = desatar.disabled();
+    }
+
+    AppMenu::new()
+        .menu(
+            Menu::new("Archivo")
+                .item(MenuItem::new("Capturar (foco)", "note.captura").shortcut("Ctrl+N"))
+                .item(delete_note)
+                .item(MenuItem::new("Exportar sobre…", "share.export").separated())
+                .item(MenuItem::new("Importar sobre…", "share.import")),
+        )
+        .menu(
+            Menu::new("Editar")
+                .item(undo)
+                .item(redo)
+                .item(cut)
+                .item(copy)
+                .item(paste)
+                .item(sel_all),
+        )
+        .menu(Menu::new("Cuerda").item(tejer).item(renombrar).item(desatar))
+        .menu(
+            Menu::new("Compartir")
+                .item(MenuItem::new("Publicar (P2P)", "share.publish"))
+                .item(MenuItem::new("Recibir de un par…", "share.receive")),
+        )
+        .menu(
+            Menu::new("Ayuda")
+                .item(MenuItem::new("Buscar (foco)", "view.search").shortcut("Ctrl+F"))
+                .item(MenuItem::new("Acerca de khipu", "help.about")),
+        )
+}
+
+/// Arma el `MenuBarSpec` compartido por `menubar_view` y `menubar_overlay`.
+/// Recibe el tamaño del viewport para no crear una dependencia de `KhipuApp`.
+pub(crate) fn menubar_spec<'a>(
+    menu: &'a app_bus::AppMenu,
+    model: &Model,
+    theme: &'a Theme,
+    viewport: (f32, f32),
+) -> MenuBarSpec<'a, Msg> {
+    MenuBarSpec {
+        menu,
+        open: model.menu_open,
+        theme,
+        viewport,
+        height: MENU_H,
+        on_open: std::sync::Arc::new(Msg::MenuOpen),
+        on_command: std::sync::Arc::new(|c: &str| Msg::MenuCommand(c.to_string())),
+    }
+}
diff --git a/00_unanchay/khipu/khipu-app/src/modelo.rs b/00_unanchay/khipu/khipu-app/src/modelo.rs
new file mode 100644
index 0000000..87b38ba
--- /dev/null
+++ b/00_unanchay/khipu/khipu-app/src/modelo.rs
@@ -0,0 +1,441 @@
+//! `modelo` — tipos centrales de khipu-app: constantes, enums de foco y
+//! mensajes, estructuras de modelo y estado P2P.
+//!
+//! Todo lo que `update`, `view` y los submódulos necesitan conocer para
+//! tipar sus firmas; cero lógica de negocio aquí.
+
+use std::collections::BTreeMap;
+use std::path::PathBuf;
+use std::sync::Arc;
+
+use agora_core::Keypair;
+use khipu_core::{CuerdaId, NoteId, NoteStore};
+use khipu_gravity::SemanticField;
+use khipu_share::SignedBundle;
+use llimphi_clipboard::SystemClipboard;
+use llimphi_motion::Tween;
+use llimphi_theme::Theme;
+use llimphi_widget_text_editor::EditorState;
+use llimphi_widget_text_input::{TextInputEvent, TextInputState};
+use rimay_verbo::Provider;
+
+// =====================================================================
+// Constantes globales
+// =====================================================================
+
+/// Dimensión del embebedor local (fallback sin daemon).
+pub(crate) const EMBED_DIM: usize = 16;
+pub(crate) const EDITOR_VISIBLE_LINES: usize = 24;
+pub(crate) const LIST_WIDTH: f32 = 260.0;
+/// Ancho del editor flotante (overlay derecho sobre el lienzo de cuerdas).
+pub(crate) const EDITOR_OVERLAY_W: f32 = 420.0;
+pub(crate) const HEADER_H: f32 = 36.0;
+pub(crate) const ROW_H: f32 = 26.0;
+pub(crate) const FIELD_LABEL_SIZE: f32 = 10.0;
+/// Cuántos vecinos como máximo ofrece el panel de resonancia.
+pub(crate) const RESONANCIA_MAX: usize = 4;
+/// Umbral de afinidad para que un vecino cuente como resonancia, según
+/// el espacio vectorial: los embeddings reales (e5) viven con baseline
+/// alto; el trigram local es mucho más disperso.
+pub(crate) const UMBRAL_RESONANCIA_REMOTO: f32 = 0.78;
+pub(crate) const UMBRAL_RESONANCIA_LOCAL: f32 = 0.22;
+/// Un nudo suelto sin tocar durante este lapso sedimenta: sigue ahí,
+/// buscable, pero deja la franja de sueltos frescos. 14 días.
+pub(crate) const SEDIMENTO_SECS: u64 = 14 * 24 * 3600;
+/// Máximo de chips en la franja de sueltos frescos.
+pub(crate) const SUELTOS_MAX: usize = 10;
+
+// =====================================================================
+// Embedder
+// =====================================================================
+
+/// Fuente de vectores semánticos. Con un `verbo-daemon` en el socket por
+/// defecto usa embeddings reales; si no hay daemon cae al hash-trigram
+/// local de 16d — determinista, offline, sin runtime.
+///
+/// El arm remoto guarda el `Runtime` de tokio para resolver las llamadas
+/// async del `Provider` con `block_on` desde el hilo worker que las
+/// dispara (nunca el de UI). Es `Clone` (todo tras `Arc`) para viajar
+/// barato dentro de la closure de `Handle::spawn`.
+#[derive(Clone)]
+pub(crate) enum Embedder {
+    /// Daemon `rimay-verbo` por socket Unix.
+    Remote {
+        provider: Arc<dyn Provider>,
+        rt: Arc<tokio::runtime::Runtime>,
+        dim: usize,
+        label: String,
+    },
+    /// Fallback local: hash-trigram → R^EMBED_DIM, sin red ni runtime.
+    Local,
+}
+
+impl Embedder {
+    /// Conecta al `verbo-daemon` en el socket por defecto. Si no hay
+    /// ninguno (o no se pudo armar el runtime), devuelve el embebedor
+    /// local — los demos arrancan igual, sin red.
+    pub(crate) fn connect() -> Self {
+        let rt = match tokio::runtime::Builder::new_multi_thread()
+            .worker_threads(2)
+            .enable_all()
+            .build()
+        {
+            Ok(rt) => rt,
+            Err(_) => return Embedder::Local,
+        };
+        match rt.block_on(rimay_verbo::conectar()) {
+            Ok(client) => {
+                let id = client.model_id();
+                let dim = id.dimension;
+                let label = id.to_string();
+                Embedder::Remote {
+                    provider: Arc::new(client),
+                    rt: Arc::new(rt),
+                    dim,
+                    label,
+                }
+            }
+            Err(_) => Embedder::Local,
+        }
+    }
+
+    /// Etiqueta del espacio vectorial. Si cambia entre dos arranques, los
+    /// vectores persistidos son incomparables y hay que recalcularlos.
+    pub(crate) fn label(&self) -> String {
+        match self {
+            Embedder::Remote { label, .. } => label.clone(),
+            Embedder::Local => format!("khipu-trigram-{EMBED_DIM}d"),
+        }
+    }
+
+    /// Umbral de afinidad para la resonancia en este espacio vectorial.
+    pub(crate) fn umbral_resonancia(&self) -> f32 {
+        match self {
+            Embedder::Remote { .. } => UMBRAL_RESONANCIA_REMOTO,
+            Embedder::Local => UMBRAL_RESONANCIA_LOCAL,
+        }
+    }
+
+    /// Embebe `text` de forma bloqueante. En el arm remoto resuelve el
+    /// future con `block_on`; ante un error devuelve un vector de ceros.
+    pub(crate) fn embed_blocking(&self, text: &str) -> Vec<f32> {
+        match self {
+            Embedder::Local => khipu_gravity::local_embed(text, EMBED_DIM),
+            Embedder::Remote { provider, rt, dim, .. } => rt
+                .block_on(provider.embed(text))
+                .map(|v| v.values)
+                .unwrap_or_else(|_| vec![0.0; *dim]),
+        }
+    }
+}
+
+// =====================================================================
+// Focus
+// =====================================================================
+
+/// 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 {
+    None,
+    /// La barra de captura — el gesto raíz de khipu.
+    Captura,
+    Search,
+    Title,
+    Body,
+    Tags,
+    Passphrase,
+    PeerAddr,
+    /// Input del nombre de una cuerda (bautizo o renombre).
+    Nombre,
+}
+
+// =====================================================================
+// Resonancia
+// =====================================================================
+
+/// El eco de una captura: el nudo recién botado y los nudos previos que
+/// tocó (afinidad sobre el umbral del espacio vectorial). El panel de
+/// resonancia lo muestra apenas llega el embedding — ese instante es el
+/// cierre de la fuga: anudar en vez de duplicar.
+#[derive(Clone)]
+pub(crate) struct Resonancia {
+    /// El nudo recién capturado.
+    pub(crate) nudo: NoteId,
+    /// Vecinos que resuenan: `(id, afinidad)`, de mayor a menor.
+    /// Vacío mientras el embedding viaja al worker.
+    pub(crate) vecinos: Vec<(NoteId, f32)>,
+    /// `true` hasta que el embedding vuelve del worker.
+    pub(crate) calculando: bool,
+}
+
+// =====================================================================
+// P2p
+// =====================================================================
+
+/// Nodo libp2p del cuaderno + su runtime tokio. Se arma perezosamente la
+/// primera vez que se usa P2P (`ensure_p2p`).
+pub(crate) struct P2p {
+    pub(crate) rt: Arc<tokio::runtime::Runtime>,
+    pub(crate) node: Arc<khipu_brahman::KhipuNode>,
+    /// Nuestra dirección para compartir (`/ip4/.../tcp/.../p2p/<id>`).
+    pub(crate) dial_addr: String,
+    /// `true` cuando ya estamos sirviendo el cuaderno por libp2p.
+    pub(crate) serving: bool,
+}
+
+// =====================================================================
+// PeerInfo
+// =====================================================================
+
+/// Un par descubierto en la red, en forma lista para la UI.
+#[derive(Clone)]
+pub(crate) struct PeerInfo {
+    /// Dirección TCP de fetch, como string (`ip:puerto`).
+    pub(crate) addr: String,
+    /// Etiqueta para la fila: nombre · de:autor · dirección.
+    pub(crate) label: String,
+}
+
+// =====================================================================
+// Msg
+// =====================================================================
+
+#[derive(Clone)]
+pub(crate) enum Msg {
+    // --- captura y resonancia ---
+    /// Enter en la barra de captura: bota el nudo y dispara la resonancia.
+    Capturar,
+    /// Anuda el nudo a una cuerda existente (acción del panel de resonancia
+    /// o del editor).
+    Anudar(NoteId, CuerdaId),
+    /// Anuda dos nudos sueltos en una cuerda nueva (nombre propuesto del
+    /// contenido).
+    AnudarJuntos(NoteId, NoteId),
+    /// Crea una cuerda nueva con este único nudo.
+    NuevaCuerda(NoteId),
+    /// Cierra el panel de resonancia dejando el nudo suelto.
+    DescartarResonancia,
+    /// Reabre la resonancia de un nudo suelto (desde el editor): vuelve a
+    /// preguntar con quién resuena, con su embedding vigente.
+    Resonar(NoteId),
+    /// Suelta un nudo de su cuerda.
+    Desanudar(NoteId),
+    /// Cicla el estado accionable del nudo: Normal → Pendiente → Hecho.
+    CiclarEstado(NoteId),
+
+    // --- cuerdas ---
+    /// Selecciona (o deselecciona con `None`) una cuerda en el lienzo.
+    SelectCuerda(Option<CuerdaId>),
+    /// Abre el input de renombre de una cuerda, precargado.
+    RenombrarCuerda(CuerdaId),
+    /// Confirma el renombre con el texto tipeado.
+    CommitNombre,
+    /// Cancela el renombre.
+    CancelNombre,
+    /// Borra una cuerda: sus nudos vuelven a estar sueltos.
+    BorrarCuerda(CuerdaId),
+    /// Teje la cuerda: pide al LLM el encuadre de sus nudos.
+    Tejer(CuerdaId),
+    /// 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).
+    CuerdasPan(f32),
+    /// Click en el lienzo en coords locales `(lx, ly)` sobre `(w, h)`.
+    CuerdasClick(f32, f32, f32, f32),
+
+    // --- notas / edición ---
+    SelectNote(NoteId),
+    DeleteSelected,
+    Focus(Focus),
+    /// Evento de mouse de un campo de texto (click/arrastre). El `Focus`
+    /// identifica cuál; un `Press` además lo enfoca (y confirma la edición
+    /// previa). Ver [`llimphi_widget_text_input::text_input_view_full`].
+    Campo(Focus, TextInputEvent),
+    Key(llimphi_ui::KeyEvent),
+    EditorPointer(llimphi_widget_text_editor::PointerEvent),
+    /// Latido lento — refresca la frescura pintada de las cuerdas.
+    Tick,
+    /// Resultado async de un embed: `(nota, secuencia, vector)`.
+    EmbeddingReady(NoteId, u64, Vec<f32>),
+    /// Cierra el editor flotante: deselecciona y vuelve al lienzo limpio.
+    Deselect,
+    /// Abre/cierra el cajón de nudos (overlay izquierdo).
+    ToggleList,
+    /// Escape en el lienzo: cierra lo de más arriba.
+    EscapeMapa,
+
+    // --- compartir ---
+    /// Sella todo el cuaderno en un sobre firmado (`compartido.khipu`).
+    Export,
+    /// Verifica e ingiere `compartido.khipu` como notas nuevas.
+    Import,
+    /// Empieza a servir el cuaderno por TCP para que un par lo jale.
+    Publish,
+    /// Busca pares en la LAN para jalarles el cuaderno.
+    Receive,
+    /// Resultado del descubrimiento: los pares vistos (ya sin uno mismo).
+    PeersFound(Vec<PeerInfo>),
+    /// Jala el cuaderno del par en esta dirección TCP.
+    FetchFrom(String),
+    /// Jala de la dirección escrita a mano (input) — habilita WAN.
+    FetchManual,
+    /// Cierra el panel de recibir sin jalar nada.
+    CancelPeers,
+    /// Resultado async de un fetch: el sobre recibido o un error.
+    Received(Result<SignedBundle, String>),
+    /// Intenta desbloquear la identidad con la passphrase tipeada.
+    Unlock,
+    /// Cierra el prompt de passphrase sin desbloquear.
+    CancelUnlock,
+    /// Resultado async de reservar un circuito en un relay.
+    RelayReady(String),
+
+    // --- menús ---
+    /// Abre/cierra un dropdown de la barra de menú principal.
+    MenuOpen(Option<usize>),
+    /// Comando elegido en la barra de menú.
+    MenuCommand(String),
+    /// Navegación por teclado en el menú principal.
+    MenuNav(i32),
+    /// Enter en el menú principal: ejecuta la fila activa.
+    MenuActivate,
+    /// Tick de animación de menús (sólo re-render).
+    MenuTick,
+    /// Navegación por teclado en el menú de edición.
+    EditNav(i32),
+    /// Enter en el menú de edición: ejecuta la fila activa.
+    EditActivate,
+    /// Abre el menú de edición contextual (right-click) en coords de ventana.
+    EditMenuOpen(f32, f32),
+    /// Acción de edición sobre el campo focuseado.
+    EditMenuAction(llimphi_widget_edit_menu::EditAction),
+    /// Cierra cualquier menú abierto (principal o de edición).
+    CloseMenus,
+}
+
+// =====================================================================
+// Model
+// =====================================================================
+
+pub(crate) struct Model {
+    pub(crate) store: NoteStore,
+    pub(crate) field: SemanticField,
+    /// Orden de inserción (estable).
+    pub(crate) order: Vec<NoteId>,
+    pub(crate) selected: Option<NoteId>,
+    /// La barra de captura — siempre lista arriba.
+    pub(crate) captura: TextInputState,
+    /// El eco de la última captura, si el panel sigue abierto.
+    pub(crate) resonancia: Option<Resonancia>,
+    /// Cuerda seleccionada en el lienzo (muestra su tarjeta).
+    pub(crate) cuerda_sel: Option<CuerdaId>,
+    /// Cuerda en renombre, si el input está abierto.
+    pub(crate) renombrando: Option<CuerdaId>,
+    /// Input del nombre de cuerda (bautizo/renombre).
+    pub(crate) nombre_input: TextInputState,
+    /// Cuerda cuyo tejido está viajando al LLM, si hay uno en curso.
+    pub(crate) tejiendo: Option<CuerdaId>,
+    /// Pan horizontal del lienzo de cuerdas (px, ≥ 0).
+    pub(crate) pan_x: f32,
+    pub(crate) title: TextInputState,
+    pub(crate) body: EditorState,
+    pub(crate) tags: TextInputState,
+    pub(crate) search: TextInputState,
+    pub(crate) focus: Focus,
+    pub(crate) theme: Theme,
+    pub(crate) data_path: Option<PathBuf>,
+    /// Fuente de embeddings: daemon `verbo` o fallback trigram local.
+    pub(crate) embedder: Embedder,
+    /// Última secuencia de embedding pedida por nota.
+    pub(crate) embed_latest: BTreeMap<NoteId, u64>,
+    /// Contador monótono de pedidos de embedding.
+    pub(crate) embed_seq: u64,
+    /// Identidad Ed25519 del cuaderno.
+    pub(crate) keypair: Option<Keypair>,
+    /// Última línea de estado (export/import/red/tejido).
+    pub(crate) status: Option<String>,
+    /// `true` cuando ya hay un servidor TCP sirviendo el cuaderno.
+    pub(crate) publishing: bool,
+    /// `true` mientras el panel izquierdo está en modo "recibir".
+    pub(crate) receiving: bool,
+    /// Pares descubiertos en la última búsqueda.
+    pub(crate) peers: Vec<PeerInfo>,
+    /// Dirección manual del par para recibir (habilita WAN).
+    pub(crate) peer_input: TextInputState,
+    /// Input de la passphrase para desbloquear la identidad.
+    pub(crate) passphrase: TextInputState,
+    /// `true` mientras se muestra el prompt de passphrase (modal).
+    pub(crate) unlocking: bool,
+    /// Acción a reanudar tras desbloquear.
+    pub(crate) pending: Option<Box<Msg>>,
+    /// Nodo libp2p (perezoso).
+    pub(crate) p2p: Option<P2p>,
+    /// Dropdown abierto de la barra de menú (índice), o `None`.
+    pub(crate) menu_open: Option<usize>,
+    /// Fila resaltada por teclado en el menú principal.
+    pub(crate) menu_active: usize,
+    /// Animación de aparición/swap del dropdown del menú principal.
+    pub(crate) menu_anim: Tween<f32>,
+    /// Posición del menú de edición contextual, si abierto.
+    pub(crate) edit_menu: Option<(f32, f32)>,
+    /// Fila resaltada por teclado en el menú de edición.
+    pub(crate) edit_active: usize,
+    /// Animación de aparición del menú de edición.
+    pub(crate) edit_anim: Tween<f32>,
+    /// Portapapeles del sistema.
+    pub(crate) clipboard: SystemClipboard,
+    /// `true` mientras el cajón de nudos (overlay izquierdo) está abierto.
+    pub(crate) show_list: bool,
+    /// Último tamaño conocido del lienzo `(w, h)` en pixels.
+    pub(crate) canvas_size: (f32, f32),
+}
+
+impl Model {
+    /// Constructor vacío base; lo usan `from_state` y `seeded_model`
+    /// para no repetir la inicialización de todos los campos.
+    pub(crate) fn blank(embedder: Embedder) -> Self {
+        Model {
+            store: NoteStore::new(),
+            field: SemanticField::new(),
+            order: Vec::new(),
+            selected: None,
+            captura: TextInputState::new(),
+            resonancia: None,
+            cuerda_sel: None,
+            renombrando: None,
+            nombre_input: TextInputState::new(),
+            tejiendo: None,
+            pan_x: 0.0,
+            title: TextInputState::new(),
+            body: EditorState::default(),
+            tags: TextInputState::new(),
+            search: TextInputState::new(),
+            focus: Focus::Captura,
+            theme: Theme::dark(),
+            data_path: None,
+            embedder,
+            embed_latest: BTreeMap::new(),
+            embed_seq: 0,
+            keypair: None,
+            status: None,
+            publishing: false,
+            receiving: false,
+            peers: Vec::new(),
+            peer_input: TextInputState::new(),
+            passphrase: TextInputState::masked(),
+            unlocking: false,
+            pending: None,
+            p2p: None,
+            menu_open: None,
+            menu_active: usize::MAX,
+            menu_anim: Tween::idle(1.0),
+            edit_menu: None,
+            edit_active: usize::MAX,
+            edit_anim: Tween::idle(1.0),
+            clipboard: SystemClipboard::new(),
+            show_list: false,
+            canvas_size: (1280.0, 640.0),
+        }
+    }
+}
diff --git a/00_unanchay/khipu/khipu-app/src/net.rs b/00_unanchay/khipu/khipu-app/src/net.rs
index 6213c1c..830e864 100644
--- a/00_unanchay/khipu/khipu-app/src/net.rs
+++ b/00_unanchay/khipu/khipu-app/src/net.rs
@@ -1,7 +1,7 @@
 //! `net` — el lado P2P del cuaderno: sellar/exportar el sobre firmado,
 //! verificar e ingerir uno ajeno, descubrir pares y publicarse por libp2p.
 //! El transporte (LAN directo + WAN vía `card-net`/Kademlia + relay/NAT)
-//! vive en `khipu-share`/`khipu-brahman`; acá sólo se orquesta.
+//! vive en `khipu-share`/`khipu-brahman`; aquí sólo se orquesta.
 
 use std::sync::Arc;
 
@@ -9,7 +9,8 @@ use khipu_share::{SharedNote, SignedBundle};
 use llimphi_ui::Handle;
 
 use crate::panels::note_matches;
-use crate::{khipu_dir, now_secs, schedule_embedding, Model, Msg, P2p};
+use crate::estado::{khipu_dir, now_secs, schedule_embedding};
+use crate::modelo::{Model, Msg, P2p};
 
 pub(crate) fn export_notebook(model: &Model) -> String {
     let Some(kp) = model.keypair.as_ref() else {
diff --git a/00_unanchay/khipu/khipu-app/src/panels.rs b/00_unanchay/khipu/khipu-app/src/panels.rs
index 9713866..116d739 100644
--- a/00_unanchay/khipu/khipu-app/src/panels.rs
+++ b/00_unanchay/khipu/khipu-app/src/panels.rs
@@ -1,10 +1,12 @@
-//! `panels` — los constructores de vistas en flujo: cabecera, cajón de
-//! notas (lista + búsqueda), panel de recibir (pares P2P), y el editor de
-//! la nota (título/cuerpo/etiquetas + stats). Frontends puros sobre el
-//! `Model`; la lógica vive en la raíz y en `map`.
+//! `panels` — los constructores de vistas en flujo: cabecera, barra de
+//! captura, franja de sueltos, panel de resonancia, tarjeta de cuerda,
+//! cajón de nudos (lista + búsqueda), panel de recibir (pares P2P) y el
+//! editor del nudo. Frontends puros sobre el `Model`; la lógica vive en
+//! la raíz y en `cuerdas`.
 
+use khipu_core::{EstadoNudo, Note, NoteId};
 use llimphi_ui::llimphi_layout::taffy::{
-    prelude::{length, percent, FlexDirection, Rect, Size, Style},
+    prelude::{auto, length, percent, FlexDirection, Position, Rect, Size, Style},
     AlignItems, Dimension, JustifyContent,
 };
 use llimphi_ui::llimphi_raster::peniko::Color;
@@ -12,16 +14,25 @@ use llimphi_ui::llimphi_text::Alignment;
 use llimphi_ui::View;
 use llimphi_widget_list::{list_view, ListPalette, ListRow, ListSpec};
 use llimphi_widget_text_editor::{text_editor_view, EditorMetrics, EditorPalette};
-use llimphi_widget_text_input::{text_input_view, TextInputPalette};
-use khipu_core::{Note, NoteId};
+use llimphi_widget_text_input::{text_input_view_full, TextInputPalette};
 
-use crate::{
-    current_mass, now_secs, Focus, Model, Msg, EDITOR_VISIBLE_LINES, FIELD_LABEL_SIZE, HEADER_H,
-    LIST_WIDTH, ROW_H,
+use crate::cuerdas::short_label;
+use crate::estado::{es_sedimento, now_secs};
+use crate::modelo::{
+    Focus, Model, Msg, EDITOR_VISIBLE_LINES, FIELD_LABEL_SIZE, HEADER_H, LIST_WIDTH, ROW_H,
+    SUELTOS_MAX,
 };
 
+// =====================================================================
+// Cabecera
+// =====================================================================
+
 pub(crate) fn header_view(model: &Model) -> View<Msg> {
-    let title = format!("khipu · {} notas", model.store.len());
+    let title = format!(
+        "khipu · {} nudos · {} cuerdas",
+        model.store.len(),
+        model.store.cuerdas_len()
+    );
     let title_node = View::new(Style {
         size: Size {
             width: Dimension::auto(),
@@ -39,36 +50,13 @@ pub(crate) fn header_view(model: &Model) -> View<Msg> {
     })
     .text_aligned(title, 14.0, model.theme.fg_text, Alignment::Start);
 
-    let list_label = if model.show_list { "ocultar notas" } else { "☰ notas" };
+    let list_label = if model.show_list { "ocultar nudos" } else { "☰ nudos" };
     let list_btn = button(
         list_label,
         model.theme.bg_button,
         if model.show_list { model.theme.accent } else { model.theme.fg_muted },
         Msg::ToggleList,
     );
-    let new_btn = button(
-        "+ nueva  (Ctrl+N)",
-        model.theme.bg_button,
-        model.theme.fg_text,
-        Msg::NewNote,
-    );
-    let archive_label = if model.show_archive {
-        "ocultar archivo"
-    } else {
-        "ver archivo"
-    };
-    let archive_btn = button(
-        archive_label,
-        model.theme.bg_button,
-        model.theme.fg_muted,
-        Msg::ToggleArchive,
-    );
-    let del_btn = button(
-        "borrar",
-        model.theme.bg_button,
-        model.theme.fg_muted,
-        Msg::DeleteSelected,
-    );
     let export_btn = button(
         "exportar",
         model.theme.bg_button,
@@ -81,11 +69,7 @@ pub(crate) fn header_view(model: &Model) -> View<Msg> {
         model.theme.fg_muted,
         Msg::Import,
     );
-    let publish_label = if model.publishing {
-        "publicando"
-    } else {
-        "publicar"
-    };
+    let publish_label = if model.publishing { "publicando" } else { "publicar" };
     let publish_btn = button(
         publish_label,
         model.theme.bg_button,
@@ -124,25 +108,14 @@ pub(crate) fn header_view(model: &Model) -> View<Msg> {
         ..Default::default()
     })
     .fill(model.theme.bg_panel_alt)
-    .children(vec![
-        title_node,
-        list_btn,
-        new_btn,
-        archive_btn,
-        del_btn,
-        export_btn,
-        import_btn,
-        publish_btn,
-        receive_btn,
-    ])
+    .children(vec![title_node, list_btn, export_btn, import_btn, publish_btn, receive_btn])
 }
 
 pub(crate) fn button(label: &str, bg: Color, fg: Color, msg: Msg) -> View<Msg> {
     // El ancho crece con el largo del texto — los labels más
-    // explícitos («+ nueva (Ctrl+N)», «ocultar archivo») piden más
-    // espacio que un «borrar» seco.
+    // explícitos piden más espacio que un «tejer» seco.
     let chars = label.chars().count() as f32;
-    let width = (chars * 7.2 + 22.0).max(86.0);
+    let width = (chars * 7.2 + 22.0).max(72.0);
     View::new(Style {
         size: Size {
             width: length(width),
@@ -156,6 +129,7 @@ pub(crate) fn button(label: &str, bg: Color, fg: Color, msg: Msg) -> View<Msg> {
         },
         align_items: Some(AlignItems::Center),
         justify_content: Some(JustifyContent::Center),
+        flex_shrink: 0.0,
         ..Default::default()
     })
     .fill(bg)
@@ -164,6 +138,527 @@ pub(crate) fn button(label: &str, bg: Color, fg: Color, msg: Msg) -> View<Msg> {
     .on_click(msg)
 }
 
+// =====================================================================
+// Captura + sueltos
+// =====================================================================
+
+/// La barra de captura: el gesto raíz. Un input siempre listo — escribes,
+/// Enter, y el nudo cae; la resonancia hace el resto.
+pub(crate) fn captura_bar(model: &Model, input_palette: &TextInputPalette) -> View<Msg> {
+    let input = text_input_view_full(
+        &model.captura,
+        "bota la idea aquí…  (Enter la anuda al aire)",
+        model.focus == Focus::Captura,
+        input_palette,
+        |ev| Msg::Campo(Focus::Captura, ev),
+    );
+    let input_wrap = View::new(Style {
+        size: Size {
+            width: Dimension::auto(),
+            height: length(28.0_f32),
+        },
+        flex_grow: 1.0,
+        ..Default::default()
+    })
+    .children(vec![input]);
+    let botar = button("⏎ botar", model.theme.bg_button, model.theme.accent, Msg::Capturar);
+
+    View::new(Style {
+        flex_direction: FlexDirection::Row,
+        size: Size {
+            width: percent(1.0_f32),
+            height: length(40.0_f32),
+        },
+        flex_shrink: 0.0,
+        padding: Rect {
+            left: length(10.0_f32),
+            right: length(10.0_f32),
+            top: length(6.0_f32),
+            bottom: length(6.0_f32),
+        },
+        align_items: Some(AlignItems::Center),
+        gap: Size {
+            width: length(8.0_f32),
+            height: length(0.0_f32),
+        },
+        ..Default::default()
+    })
+    .fill(model.theme.bg_panel_alt)
+    .children(vec![input_wrap, botar])
+}
+
+/// La franja de sueltos frescos: chips clickeables con los nudos que
+/// todavía no resonaron con nada, del más nuevo al más viejo. Lo que
+/// sedimentó no estorba aquí — vive en el cajón y en la búsqueda.
+pub(crate) fn sueltos_strip(model: &Model) -> View<Msg> {
+    let now = now_secs();
+    let sueltos = model.store.sueltos();
+    let frescos: Vec<&Note> = sueltos
+        .iter()
+        .copied()
+        .filter(|n| !es_sedimento(n, now))
+        .collect();
+    let sedimento = sueltos.len() - frescos.len();
+
+    let mut children: Vec<View<Msg>> = Vec::new();
+    let label = View::new(Style {
+        size: Size {
+            width: length(64.0_f32),
+            height: percent(1.0_f32),
+        },
+        flex_shrink: 0.0,
+        align_items: Some(AlignItems::Center),
+        ..Default::default()
+    })
+    .text_aligned("sueltos:".to_string(), 11.0, model.theme.fg_muted, Alignment::Start);
+    children.push(label);
+
+    for n in frescos.iter().take(SUELTOS_MAX) {
+        let marca = match n.estado {
+            EstadoNudo::Pendiente => "☐ ",
+            EstadoNudo::Hecho => "☑ ",
+            EstadoNudo::Normal => "",
+        };
+        let texto: String = n.title.chars().take(18).collect();
+        let sufijo = if n.title.chars().count() > 18 { "…" } else { "" };
+        let fg = if model.selected == Some(n.id) {
+            model.theme.accent
+        } else {
+            model.theme.fg_text
+        };
+        children.push(button(
+            &format!("{marca}{texto}{sufijo}"),
+            model.theme.bg_button,
+            fg,
+            Msg::SelectNote(n.id),
+        ));
+    }
+    if frescos.len() > SUELTOS_MAX {
+        children.push(button(
+            &format!("+{}", frescos.len() - SUELTOS_MAX),
+            model.theme.bg_button,
+            model.theme.fg_muted,
+            Msg::ToggleList,
+        ));
+    }
+    if sedimento > 0 {
+        children.push(button(
+            &format!("· sedimento {sedimento}"),
+            model.theme.bg_panel,
+            model.theme.fg_muted,
+            Msg::ToggleList,
+        ));
+    }
+    if frescos.is_empty() && sedimento == 0 {
+        children.push(
+            View::new(Style {
+                size: Size {
+                    width: Dimension::auto(),
+                    height: percent(1.0_f32),
+                },
+                align_items: Some(AlignItems::Center),
+                ..Default::default()
+            })
+            .text_aligned("ninguno — todo anudado".to_string(), 11.0, model.theme.fg_muted, Alignment::Start),
+        );
+    }
+
+    View::new(Style {
+        flex_direction: FlexDirection::Row,
+        size: Size {
+            width: percent(1.0_f32),
+            height: length(34.0_f32),
+        },
+        flex_shrink: 0.0,
+        padding: Rect {
+            left: length(10.0_f32),
+            right: length(10.0_f32),
+            top: length(4.0_f32),
+            bottom: length(4.0_f32),
+        },
+        align_items: Some(AlignItems::Center),
+        gap: Size {
+            width: length(6.0_f32),
+            height: length(0.0_f32),
+        },
+        ..Default::default()
+    })
+    .fill(model.theme.bg_panel)
+    .children(children)
+}
+
+// =====================================================================
+// Resonancia
+// =====================================================================
+
+/// El panel de resonancia: aparece apenas una captura toca nudos previos.
+/// Cada vecino es una fila clickeable — si vive en una cuerda, el click
+/// anuda ahí; si está suelto, el click los anuda juntos en una cuerda
+/// nueva. Abajo: cuerda nueva sólo con este nudo, o dejarlo suelto (Esc).
+pub(crate) fn resonancia_panel(model: &Model) -> Option<View<Msg>> {
+    let res = model.resonancia.as_ref()?;
+    let nudo = model.store.get(res.nudo)?;
+    let theme = &model.theme;
+
+    let mut rows: Vec<View<Msg>> = Vec::new();
+    let titulo = View::new(Style {
+        size: Size {
+            width: percent(1.0_f32),
+            height: length(20.0_f32),
+        },
+        flex_shrink: 0.0,
+        ..Default::default()
+    })
+    .text_aligned(
+        format!("«{}» resuena con:", short_label(&nudo.title)),
+        12.0,
+        theme.fg_text,
+        Alignment::Start,
+    );
+    rows.push(titulo);
+
+    if res.calculando {
+        rows.push(
+            View::new(Style {
+                size: Size {
+                    width: percent(1.0_f32),
+                    height: length(20.0_f32),
+                },
+                ..Default::default()
+            })
+            .text_aligned("buscando ecos…".to_string(), 11.0, theme.fg_muted, Alignment::Start),
+        );
+    }
+
+    for (vid, aff) in &res.vecinos {
+        let Some(v) = model.store.get(*vid) else { continue };
+        let (destino, msg) = match v.cuerda.and_then(|c| model.store.cuerda(c)) {
+            Some(c) => (format!("→ anudar a «{}»", c.nombre), Msg::Anudar(res.nudo, c.id)),
+            None => ("→ anudar juntos (cuerda nueva)".to_string(), Msg::AnudarJuntos(res.nudo, *vid)),
+        };
+        let fila = View::new(Style {
+            flex_direction: FlexDirection::Column,
+            size: Size {
+                width: percent(1.0_f32),
+                height: length(36.0_f32),
+            },
+            flex_shrink: 0.0,
+            padding: Rect {
+                left: length(8.0_f32),
+                right: length(8.0_f32),
+                top: length(3.0_f32),
+                bottom: length(3.0_f32),
+            },
+            ..Default::default()
+        })
+        .fill(theme.bg_button)
+        .radius(4.0)
+        .hover_fill(theme.bg_button_hover)
+        .on_click(msg)
+        .children(vec![
+            View::new(Style {
+                size: Size {
+                    width: percent(1.0_f32),
+                    height: length(15.0_f32),
+                },
+                ..Default::default()
+            })
+            .text_aligned(
+                format!("{}  ({:.2})", short_label(&v.title), aff),
+                11.0,
+                theme.fg_text,
+                Alignment::Start,
+            ),
+            View::new(Style {
+                size: Size {
+                    width: percent(1.0_f32),
+                    height: length(13.0_f32),
+                },
+                ..Default::default()
+            })
+            .text_aligned(destino, 10.0, theme.fg_muted, Alignment::Start),
+        ]);
+        rows.push(fila);
+    }
+
+    let acciones = View::new(Style {
+        flex_direction: FlexDirection::Row,
+        size: Size {
+            width: percent(1.0_f32),
+            height: length(30.0_f32),
+        },
+        flex_shrink: 0.0,
+        gap: Size {
+            width: length(6.0_f32),
+            height: length(0.0_f32),
+        },
+        ..Default::default()
+    })
+    .children(vec![
+        button("cuerda nueva", theme.bg_button, theme.accent, Msg::NuevaCuerda(res.nudo)),
+        button("dejar suelto (Esc)", theme.bg_button, theme.fg_muted, Msg::DescartarResonancia),
+    ]);
+    rows.push(acciones);
+
+    let card = View::new(Style {
+        flex_direction: FlexDirection::Column,
+        size: Size {
+            width: length(340.0_f32),
+            height: Dimension::auto(),
+        },
+        padding: Rect {
+            left: length(12.0_f32),
+            right: length(12.0_f32),
+            top: length(10.0_f32),
+            bottom: length(10.0_f32),
+        },
+        gap: Size {
+            width: length(0.0_f32),
+            height: length(6.0_f32),
+        },
+        ..Default::default()
+    })
+    .fill(theme.bg_panel)
+    .border(1.0, theme.accent)
+    .radius(8.0)
+    .children(rows);
+
+    // Anclado arriba al centro del lienzo, justo bajo la captura.
+    Some(
+        View::new(Style {
+            position: Position::Absolute,
+            inset: Rect {
+                left: percent(0.5_f32),
+                top: length(10.0_f32),
+                right: auto(),
+                bottom: auto(),
+            },
+            size: Size {
+                width: length(0.0_f32),
+                height: Dimension::auto(),
+            },
+            ..Default::default()
+        })
+        .children(vec![View::new(Style {
+            position: Position::Absolute,
+            inset: Rect {
+                left: length(-170.0_f32),
+                top: length(0.0_f32),
+                right: auto(),
+                bottom: auto(),
+            },
+            size: Size {
+                width: length(340.0_f32),
+                height: Dimension::auto(),
+            },
+            ..Default::default()
+        })
+        .children(vec![card])]),
+    )
+}
+
+// =====================================================================
+// Tarjeta de cuerda
+// =====================================================================
+
+/// La tarjeta de la cuerda seleccionada: nombre, conteo, y los verbos —
+/// tejer (el encuadre LLM), renombrar, desatar. Flota abajo a la
+/// izquierda del lienzo.
+pub(crate) fn tarjeta_cuerda(model: &Model) -> Option<View<Msg>> {
+    let cid = model.cuerda_sel?;
+    let cuerda = model.store.cuerda(cid)?;
+    let theme = &model.theme;
+    let nudos = model.store.nudos_de(cid);
+    let pendientes = nudos
+        .iter()
+        .filter(|n| n.estado == EstadoNudo::Pendiente)
+        .count();
+
+    let nombre = View::new(Style {
+        size: Size {
+            width: percent(1.0_f32),
+            height: length(20.0_f32),
+        },
+        flex_shrink: 0.0,
+        ..Default::default()
+    })
+    .text_aligned(cuerda.nombre.clone(), 14.0, theme.accent, Alignment::Start);
+
+    let mut detalle = format!("{} nudos", nudos.len());
+    if pendientes > 0 {
+        detalle.push_str(&format!(" · {pendientes} pendientes"));
+    }
+    if cuerda.tejido.is_some() {
+        detalle.push_str(" · tejida ◈");
+    }
+    let detalle_row = View::new(Style {
+        size: Size {
+            width: percent(1.0_f32),
+            height: length(16.0_f32),
+        },
+        flex_shrink: 0.0,
+        ..Default::default()
+    })
+    .text_aligned(detalle, 11.0, theme.fg_muted, Alignment::Start);
+
+    let tejer_label = if model.tejiendo == Some(cid) {
+        "tejiendo…"
+    } else if cuerda.tejido.is_some() {
+        "re-tejer"
+    } else {
+        "tejer"
+    };
+    let tejer_btn = if model.tejiendo.is_some() {
+        button(tejer_label, theme.bg_button, theme.fg_muted, Msg::Tick)
+    } else {
+        button(tejer_label, theme.bg_button, theme.accent, Msg::Tejer(cid))
+    };
+    let botones = View::new(Style {
+        flex_direction: FlexDirection::Row,
+        size: Size {
+            width: percent(1.0_f32),
+            height: length(30.0_f32),
+        },
+        flex_shrink: 0.0,
+        gap: Size {
+            width: length(6.0_f32),
+            height: length(0.0_f32),
+        },
+        ..Default::default()
+    })
+    .children(vec![
+        tejer_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)),
+    ]);
+
+    let card = View::new(Style {
+        flex_direction: FlexDirection::Column,
+        size: Size {
+            width: length(360.0_f32),
+            height: Dimension::auto(),
+        },
+        padding: Rect {
+            left: length(12.0_f32),
+            right: length(12.0_f32),
+            top: length(10.0_f32),
+            bottom: length(10.0_f32),
+        },
+        gap: Size {
+            width: length(0.0_f32),
+            height: length(6.0_f32),
+        },
+        ..Default::default()
+    })
+    .fill(theme.bg_panel)
+    .border(1.0, theme.border)
+    .radius(8.0)
+    .children(vec![nombre, detalle_row, botones]);
+
+    Some(
+        View::new(Style {
+            position: Position::Absolute,
+            inset: Rect {
+                left: length(12.0_f32),
+                top: auto(),
+                right: auto(),
+                bottom: length(12.0_f32),
+            },
+            size: Size {
+                width: length(360.0_f32),
+                height: Dimension::auto(),
+            },
+            ..Default::default()
+        })
+        .children(vec![card]),
+    )
+}
+
+/// El input de renombre/bautizo de cuerda: tarjeta flotante arriba al
+/// centro. Enter confirma, Esc cancela (en `on_key`).
+pub(crate) fn nombre_overlay(model: &Model, input_palette: &TextInputPalette) -> Option<View<Msg>> {
+    let cid = model.renombrando?;
+    let cuerda = model.store.cuerda(cid)?;
+    let theme = &model.theme;
+
+    let label = View::new(Style {
+        size: Size {
+            width: percent(1.0_f32),
+            height: length(16.0_f32),
+        },
+        flex_shrink: 0.0,
+        ..Default::default()
+    })
+    .text_aligned(
+        format!("nombre de la cuerda «{}» — Enter confirma, Esc cancela", cuerda.nombre),
+        10.0,
+        theme.fg_muted,
+        Alignment::Start,
+    );
+    let input = text_input_view_full(
+        &model.nombre_input,
+        "nombre…",
+        model.focus == Focus::Nombre,
+        input_palette,
+        |ev| Msg::Campo(Focus::Nombre, ev),
+    );
+    let input_row = View::new(Style {
+        size: Size {
+            width: percent(1.0_f32),
+            height: length(28.0_f32),
+        },
+        flex_shrink: 0.0,
+        ..Default::default()
+    })
+    .children(vec![input]);
+
+    let card = View::new(Style {
+        flex_direction: FlexDirection::Column,
+        size: Size {
+            width: length(320.0_f32),
+            height: Dimension::auto(),
+        },
+        padding: Rect {
+            left: length(12.0_f32),
+            right: length(12.0_f32),
+            top: length(8.0_f32),
+            bottom: length(10.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(8.0)
+    .children(vec![label, input_row]);
+
+    Some(
+        View::new(Style {
+            position: Position::Absolute,
+            inset: Rect {
+                left: length(90.0_f32),
+                top: length(10.0_f32),
+                right: auto(),
+                bottom: auto(),
+            },
+            size: Size {
+                width: length(320.0_f32),
+                height: Dimension::auto(),
+            },
+            ..Default::default()
+        })
+        .children(vec![card]),
+    )
+}
+
+// =====================================================================
+// Cajón de nudos (lista + búsqueda)
+// =====================================================================
+
 pub(crate) fn list_panel(
     model: &Model,
     palette: &ListPalette,
@@ -173,11 +668,11 @@ pub(crate) fn list_panel(
     let query = model.search.text();
     let q = query.trim();
 
-    // Particionamos en horizonte vs archivo y ordenamos cada parte por
-    // masa viva decreciente. Si hay query, ambas listas quedan
-    // pre-filtradas por coincidencia en título/cuerpo/etiquetas.
-    let mut visible: Vec<(NoteId, f32, &Note)> = Vec::new();
-    let mut archive: Vec<(NoteId, f32, &Note)> = Vec::new();
+    // Todos los nudos, filtrados por la búsqueda, del más tocado al más
+    // quieto. El sedimento va al final con marca propia — presente pero
+    // sin estorbar.
+    let mut vivos: Vec<&Note> = Vec::new();
+    let mut sedimento: Vec<&Note> = Vec::new();
     let mut hidden_by_query = 0usize;
     for id in &model.order {
         let Some(n) = model.store.get(*id) else {
@@ -187,32 +682,28 @@ pub(crate) fn list_panel(
             hidden_by_query += 1;
             continue;
         }
-        let m = current_mass(&model.gravity, n, now);
-        if model.gravity.is_visible(m) {
-            visible.push((*id, m, n));
+        if es_sedimento(n, now) {
+            sedimento.push(n);
         } else {
-            archive.push((*id, m, n));
+            vivos.push(n);
         }
     }
-    let by_mass_desc = |a: &(NoteId, f32, &Note), b: &(NoteId, f32, &Note)| {
-        b.1.partial_cmp(&a.1)
-            .unwrap_or(core::cmp::Ordering::Equal)
-            .then(a.0.cmp(&b.0))
+    let by_recency = |a: &&Note, b: &&Note| {
+        b.updated_at
+            .max(b.created_at)
+            .cmp(&a.updated_at.max(a.created_at))
+            .then(a.id.cmp(&b.id))
     };
-    visible.sort_by(by_mass_desc);
-    archive.sort_by(by_mass_desc);
+    vivos.sort_by(by_recency);
+    sedimento.sort_by(by_recency);
 
-    let mut chain: Vec<(NoteId, f32, &Note)> = visible.clone();
-    if model.show_archive {
-        chain.extend(archive.iter().cloned());
-    }
-
-    let rows: Vec<ListRow<Msg>> = chain
-        .into_iter()
-        .map(|(id, mass, n)| ListRow {
-            label: row_label(n, mass),
-            selected: Some(id) == model.selected,
-            on_click: Msg::SelectNote(id),
+    let rows: Vec<ListRow<Msg>> = vivos
+        .iter()
+        .chain(sedimento.iter())
+        .map(|n| ListRow {
+            label: row_label(model, n, now),
+            selected: Some(n.id) == model.selected,
+            on_click: Msg::SelectNote(n.id),
         })
         .collect();
 
@@ -220,23 +711,13 @@ pub(crate) fn list_panel(
         format!(
             "buscar «{}» · {}/{} coinciden",
             q,
-            visible.len() + if model.show_archive { archive.len() } else { 0 },
-            visible.len() + archive.len() + hidden_by_query
-        )
-    } else if archive.is_empty() {
-        format!("notas · {}", visible.len())
-    } else if model.show_archive {
-        format!(
-            "notas · {} horizonte + {} archivo",
-            visible.len(),
-            archive.len()
+            vivos.len() + sedimento.len(),
+            vivos.len() + sedimento.len() + hidden_by_query
         )
+    } else if sedimento.is_empty() {
+        format!("nudos · {}", vivos.len())
     } else {
-        format!(
-            "notas · {} horizonte (+{} archivo)",
-            visible.len(),
-            archive.len()
-        )
+        format!("nudos · {} vivos + {} sedimento", vivos.len(), sedimento.len())
     };
 
     let spec = ListSpec {
@@ -248,12 +729,12 @@ pub(crate) fn list_panel(
         palette: *palette,
     };
 
-    let search_input = text_input_view(
+    let search_input = text_input_view_full(
         &model.search,
         "buscar (título, cuerpo, etiquetas)",
         model.focus == Focus::Search,
         input_palette,
-        Msg::Focus(Focus::Search),
+        |ev| Msg::Campo(Focus::Search, ev),
     );
     let search_row = View::new(Style {
         size: Size {
@@ -302,19 +783,19 @@ pub(crate) fn list_panel(
 /// Panel izquierdo en modo "recibir": arriba un input de dirección manual
 /// (`host:puerto`, habilita WAN) con botones jalar/cancelar; debajo, la
 /// lista de pares descubiertos en la LAN (click ⇒ jalar de él). Reemplaza
-/// transitoriamente la lista de notas.
+/// transitoriamente la lista de nudos.
 pub(crate) fn receive_panel(
     model: &Model,
     palette: &ListPalette,
     input_palette: &TextInputPalette,
 ) -> View<Msg> {
     // Fila de dirección manual + jalar.
-    let addr_input = text_input_view(
+    let addr_input = text_input_view_full(
         &model.peer_input,
         "host:puerto  o  /ip4/…/p2p/…",
         model.focus == Focus::PeerAddr,
         input_palette,
-        Msg::Focus(Focus::PeerAddr),
+        |ev| Msg::Campo(Focus::PeerAddr, ev),
     );
     let addr_wrap = View::new(Style {
         size: Size {
@@ -436,21 +917,43 @@ pub(crate) fn note_matches(n: &Note, query: &str) -> bool {
     n.tags.iter().any(|t| t.to_lowercase().contains(&q))
 }
 
-pub(crate) fn row_label(n: &Note, mass: f32) -> String {
+/// Fila del cajón: una marca de estado + el título. `◉` anudado, `○`
+/// suelto fresco, `·` sedimento, `☐/☑` pendiente/hecho, `◈` tejido.
+pub(crate) fn row_label(model: &Model, n: &Note, now: u64) -> String {
+    let es_tejido = n
+        .cuerda
+        .and_then(|c| model.store.cuerda(c))
+        .map(|c| c.tejido == Some(n.id))
+        .unwrap_or(false);
+    let marca = if es_tejido {
+        '◈'
+    } else {
+        match n.estado {
+            EstadoNudo::Pendiente => '☐',
+            EstadoNudo::Hecho => '☑',
+            EstadoNudo::Normal => {
+                if n.cuerda.is_some() {
+                    '◉'
+                } else if es_sedimento(n, now) {
+                    '·'
+                } else {
+                    '○'
+                }
+            }
+        }
+    };
     let title = if n.title.is_empty() {
         "(sin título)"
     } else {
         n.title.as_str()
     };
-    // Una barra de tres bloques visualiza la masa (0..1.5 mapeada a
-    // 0..3). Sobre el horizonte se ve llena; cayendo, se vacía.
-    let bars = (mass.clamp(0.0, 1.5) / 0.5).round() as usize;
-    let glyph: String = (0..3)
-        .map(|i| if i < bars { '▮' } else { '▯' })
-        .collect();
-    format!("{glyph}  {title}")
+    format!("{marca}  {title}")
 }
 
+// =====================================================================
+// Editor
+// =====================================================================
+
 pub(crate) fn editor_panel(
     model: &Model,
     input_palette: &TextInputPalette,
@@ -468,28 +971,28 @@ pub(crate) fn editor_panel(
         })
         .fill(model.theme.bg_panel)
         .text_aligned(
-            "selecciona o crea una nota".to_string(),
+            "elige un nudo".to_string(),
             12.0,
             model.theme.fg_muted,
             Alignment::Center,
         )
     };
 
-    if model.selected.is_none() {
+    let Some(id) = model.selected else {
         return wrap_panel(model, none_view());
-    }
+    };
 
     let metrics = EditorMetrics::for_font_size(13.0);
 
     let title_field = field(
         model,
         "título",
-        text_input_view(
+        text_input_view_full(
             &model.title,
             "(sin título)",
             model.focus == Focus::Title,
             input_palette,
-            Msg::Focus(Focus::Title),
+            |ev| Msg::Campo(Focus::Title, ev),
         ),
     );
 
@@ -505,15 +1008,16 @@ pub(crate) fn editor_panel(
     let tags_field = field(
         model,
         "etiquetas (coma separadas)",
-        text_input_view(
+        text_input_view_full(
             &model.tags,
-            "p. ej. cocina, jardín",
+            "p. ej. salud, wawa",
             model.focus == Focus::Tags,
             input_palette,
-            Msg::Focus(Focus::Tags),
+            |ev| Msg::Campo(Focus::Tags, ev),
         ),
     );
 
+    let acciones = acciones_nudo(model, id);
     let stats = stats_view(model);
 
     let column = View::new(Style {
@@ -535,11 +1039,65 @@ pub(crate) fn editor_panel(
         ..Default::default()
     })
     .fill(model.theme.bg_panel)
-    .children(vec![title_field, body_field, tags_field, stats]);
+    .children(vec![title_field, body_field, tags_field, acciones, stats]);
 
     wrap_panel(model, column)
 }
 
+/// Los verbos del nudo abierto: ciclar estado (TODO), anudar/desanudar,
+/// resonar de nuevo, borrar.
+fn acciones_nudo(model: &Model, id: NoteId) -> View<Msg> {
+    let Some(n) = model.store.get(id) else {
+        return View::new(Style::default());
+    };
+    let theme = &model.theme;
+    let estado_label = match n.estado {
+        EstadoNudo::Normal => "marcar pendiente",
+        EstadoNudo::Pendiente => "marcar hecho",
+        EstadoNudo::Hecho => "quitar marca",
+    };
+    let mut botones = vec![button(
+        estado_label,
+        theme.bg_button,
+        if n.estado == EstadoNudo::Pendiente {
+            theme.accent
+        } else {
+            theme.fg_muted
+        },
+        Msg::CiclarEstado(id),
+    )];
+    match n.cuerda {
+        Some(_) => botones.push(button(
+            "desanudar",
+            theme.bg_button,
+            theme.fg_muted,
+            Msg::Desanudar(id),
+        )),
+        None => botones.push(button(
+            "resonar",
+            theme.bg_button,
+            theme.accent,
+            Msg::Resonar(id),
+        )),
+    }
+    botones.push(button("borrar", theme.bg_button, theme.fg_muted, Msg::DeleteSelected));
+
+    View::new(Style {
+        flex_direction: FlexDirection::Row,
+        size: Size {
+            width: percent(1.0_f32),
+            height: length(30.0_f32),
+        },
+        flex_shrink: 0.0,
+        gap: Size {
+            width: length(6.0_f32),
+            height: length(0.0_f32),
+        },
+        ..Default::default()
+    })
+    .children(botones)
+}
+
 pub(crate) fn wrap_panel(_model: &Model, child: View<Msg>) -> View<Msg> {
     View::new(Style {
         size: Size {
@@ -624,7 +1182,7 @@ pub(crate) fn body_field_view(model: &Model, editor: View<Msg>) -> View<Msg> {
         },
         ..Default::default()
     })
-    .fill(border)
+    .border(1.0, border)
     .radius(4.0)
     .on_click(Msg::Focus(Focus::Body))
     .children(vec![editor]);
@@ -671,15 +1229,24 @@ pub(crate) fn stats_view(model: &Model) -> View<Msg> {
             model
                 .store
                 .get(nid)
-                .map(|n| format!("{} ({:.2})", n.title, score))
+                .map(|n| format!("{} ({:.2})", short_label(&n.title), score))
         })
         .collect();
 
-    let mut lines = vec![
-        format!("→ enlaza a: {}", join_or_dash(&fwd_titles)),
-        format!("← backlinks: {}", join_or_dash(&back_titles)),
-        format!("∼ vecinos: {}", join_or_dash(&nearest)),
-    ];
+    let mut lines = Vec::new();
+    // La pertenencia primero: en qué cuerda vive (o suelto).
+    if let Some(n) = model.store.get(id) {
+        match n.cuerda.and_then(|c| model.store.cuerda(c)) {
+            Some(c) => lines.push(format!("⌇ cuerda: «{}»", c.nombre)),
+            None => lines.push("⌇ suelto — sin cuerda todavía".to_string()),
+        }
+        if let Some(o) = &n.origen {
+            lines.push(format!("⇤ origen: {} · {}", o.app, o.referencia));
+        }
+    }
+    lines.push(format!("→ enlaza a: {}", join_or_dash(&fwd_titles)));
+    lines.push(format!("← backlinks: {}", join_or_dash(&back_titles)));
+    lines.push(format!("∼ vecinos: {}", join_or_dash(&nearest)));
     // Procedencia: si la nota llegó por compartir, lleva una etiqueta
     // `de:<autor>`. La mostramos explícita.
     if let Some(n) = model.store.get(id) {
@@ -726,3 +1293,142 @@ pub(crate) fn join_or_dash(items: &[String]) -> String {
         items.join(", ")
     }
 }
+
+// =====================================================================
+// Vistas de estado y passphrase (usadas desde app.rs)
+// =====================================================================
+
+/// Barra de estado al pie: muestra el último mensaje de export/import/tejido.
+pub(crate) fn status_bar(model: &Model) -> Option<View<Msg>> {
+    let text = model.status.as_ref()?;
+    Some(
+        View::new(Style {
+            size: Size {
+                width: percent(1.0_f32),
+                height: length(22.0_f32),
+            },
+            flex_shrink: 0.0,
+            padding: Rect {
+                left: length(12.0_f32),
+                right: length(12.0_f32),
+                top: length(2.0_f32),
+                bottom: length(2.0_f32),
+            },
+            align_items: Some(AlignItems::Center),
+            ..Default::default()
+        })
+        .fill(model.theme.bg_panel_alt)
+        .text_aligned(text.clone(), 11.0, model.theme.fg_muted, Alignment::Start),
+    )
+}
+
+/// Prompt modal de passphrase: tarjeta centrada con el input (enmascarado
+/// con •) y dos botones. Enter desbloquea, Esc cancela (ver `on_key`).
+pub(crate) fn unlock_view(model: &Model, input_palette: &TextInputPalette) -> View<Msg> {
+    let titulo = View::new(Style {
+        size: Size {
+            width: percent(1.0_f32),
+            height: length(22.0_f32),
+        },
+        ..Default::default()
+    })
+    .text_aligned(
+        "Desbloquea tu identidad para firmar".to_string(),
+        14.0,
+        model.theme.fg_text,
+        Alignment::Start,
+    );
+
+    let hint = View::new(Style {
+        size: Size {
+            width: percent(1.0_f32),
+            height: length(30.0_f32),
+        },
+        ..Default::default()
+    })
+    .text_aligned(
+        "La semilla vive cifrada (Argon2id). La primera vez, esta passphrase la crea."
+            .to_string(),
+        11.0,
+        model.theme.fg_muted,
+        Alignment::Start,
+    );
+
+    let input = text_input_view_full(
+        &model.passphrase,
+        "passphrase",
+        model.focus == Focus::Passphrase,
+        input_palette,
+        |ev| Msg::Campo(Focus::Passphrase, ev),
+    );
+    let input_row = View::new(Style {
+        size: Size {
+            width: percent(1.0_f32),
+            height: length(30.0_f32),
+        },
+        flex_shrink: 0.0,
+        ..Default::default()
+    })
+    .children(vec![input]);
+
+    let unlock_btn = button(
+        "desbloquear (Enter)",
+        model.theme.bg_button,
+        model.theme.accent,
+        Msg::Unlock,
+    );
+    let cancel_btn = button(
+        "cancelar (Esc)",
+        model.theme.bg_button,
+        model.theme.fg_muted,
+        Msg::CancelUnlock,
+    );
+    let buttons = View::new(Style {
+        flex_direction: FlexDirection::Row,
+        size: Size {
+            width: percent(1.0_f32),
+            height: length(30.0_f32),
+        },
+        gap: Size {
+            width: length(8.0_f32),
+            height: length(0.0_f32),
+        },
+        ..Default::default()
+    })
+    .children(vec![unlock_btn, cancel_btn]);
+
+    let card = View::new(Style {
+        flex_direction: FlexDirection::Column,
+        size: Size {
+            width: length(420.0_f32),
+            height: Dimension::auto(),
+        },
+        padding: Rect {
+            left: length(18.0_f32),
+            right: length(18.0_f32),
+            top: length(16.0_f32),
+            bottom: length(16.0_f32),
+        },
+        gap: Size {
+            width: length(0.0_f32),
+            height: length(10.0_f32),
+        },
+        ..Default::default()
+    })
+    .fill(model.theme.bg_panel)
+    .radius(6.0)
+    .children(vec![titulo, hint, input_row, buttons]);
+
+    View::new(Style {
+        size: Size {
+            width: percent(1.0_f32),
+            height: Dimension::auto(),
+        },
+        flex_grow: 1.0,
+        align_items: Some(AlignItems::Center),
+        justify_content: Some(JustifyContent::Center),
+        ..Default::default()
+    })
+    .fill(model.theme.bg_app)
+    .children(vec![card])
+}
diff --git a/00_unanchay/khipu/khipu-app/src/tejido.rs b/00_unanchay/khipu/khipu-app/src/tejido.rs
new file mode 100644
index 0000000..68253cc
--- /dev/null
+++ b/00_unanchay/khipu/khipu-app/src/tejido.rs
@@ -0,0 +1,139 @@
+//! `tejido` — la síntesis de una cuerda: el encuadre que khipu redacta
+//! sobre los nudos de una idea recurrente.
+//!
+//! El tejido existe para el momento en que una idea engordó pero sigue
+//! difusa (el caso canónico: la idea de khipu mismo). Junta los nudos en
+//! cronología y le pide al LLM un encuadre en tres partes: qué se viene
+//! repitiendo, qué ya está claro, y qué falta decidir (las fugas). El
+//! resultado es un nudo-síntesis **editable** anudado al final de la
+//! cuerda; re-tejer lo reemplaza.
+//!
+//! El backend sale de `pluma-llm::from_env()`: Anthropic/Gemini/DeepSeek/
+//! Cohere/Ollama según el entorno, y **Mock sin credenciales** — el botón
+//! siempre hace algo, aunque sea un eco de prueba. La llamada corre en un
+//! worker (`Handle::spawn`) con su propio runtime: nunca bloquea la UI.
+
+use khipu_core::{Cuerda, Note};
+use llimphi_ui::Handle;
+use pluma_llm::pluma_llm_core::ChatRequest;
+
+use crate::modelo::{Model, Msg};
+
+/// Tokens máximos de la respuesta — un encuadre, no un ensayo.
+const MAX_TOKENS: u32 = 1024;
+
+const SYSTEM: &str = "Eres el tejedor de un khipu: recibes los nudos (pensamientos \
+sueltos capturados en distintos momentos y estados mentales) de una cuerda — una \
+idea recurrente de la misma persona. Tejé un encuadre breve en español, directo y \
+sin florituras, con exactamente estas tres secciones:\n\
+\n\
+## Lo que se repite\nla idea central que insiste a través de los nudos.\n\
+## Lo que ya está claro\ndecisiones o certezas que los nudos ya asentaron.\n\
+## Lo que falta\nlas fugas: qué queda por decidir o hacer para concretarla.\n\
+\n\
+Máximo ~250 palabras. No inventes contenido que los nudos no digan; si un nudo \
+contradice a otro, señalalo en «Lo que falta».";
+
+/// Arma el prompt de usuario: los nudos de la cuerda en cronología, con
+/// su fecha, excluyendo el tejido anterior (la síntesis no se cita a sí
+/// misma — se reemplaza).
+pub(crate) fn prompt_de(cuerda: &Cuerda, nudos: &[&Note]) -> String {
+    let mut out = format!("Cuerda: «{}»\n\nNudos, en orden de captura:\n", cuerda.nombre);
+    for n in nudos {
+        if cuerda.tejido == Some(n.id) {
+            continue;
+        }
+        out.push_str(&format!("- [{}] {}", fecha_corta(n.created_at), n.title));
+        if !n.body.trim().is_empty() {
+            out.push_str(&format!("\n  {}", n.body.trim().replace('\n', "\n  ")));
+        }
+        if n.estado == khipu_core::EstadoNudo::Pendiente {
+            out.push_str("  (pendiente)");
+        }
+        out.push('\n');
+    }
+    out
+}
+
+/// Segundo Unix → `aaaa-mm-dd` sin dependencias de calendario (días
+/// civiles desde epoch, algoritmo de Howard Hinnant).
+fn fecha_corta(secs: u64) -> String {
+    let dias = (secs / 86_400) as i64;
+    let z = dias + 719_468;
+    let era = z.div_euclid(146_097);
+    let doe = z.rem_euclid(146_097);
+    let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365;
+    let y = yoe + era * 400;
+    let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
+    let mp = (5 * doy + 2) / 153;
+    let d = doy - (153 * mp + 2) / 5 + 1;
+    let m = if mp < 10 { mp + 3 } else { mp - 9 };
+    let y = if m <= 2 { y + 1 } else { y };
+    format!("{y:04}-{m:02}-{d:02}")
+}
+
+/// Dispara el tejido de `cuerda` en un worker. Reentra al `update` con
+/// [`Msg::TejidoListo`] — texto del encuadre o error legible.
+pub(crate) fn tejer(model: &Model, cuerda: khipu_core::CuerdaId, h: &Handle<Msg>) {
+    let Some(c) = model.store.cuerda(cuerda) else {
+        return;
+    };
+    let nudos = model.store.nudos_de(cuerda);
+    let prompt = prompt_de(c, &nudos);
+    h.spawn(move || {
+        let resultado = (|| -> Result<String, String> {
+            let client = pluma_llm::from_env().map_err(|e| format!("sin backend LLM: {e}"))?;
+            let rt = tokio::runtime::Builder::new_current_thread()
+                .enable_all()
+                .build()
+                .map_err(|e| format!("sin runtime: {e}"))?;
+            let req = ChatRequest::una_vuelta(prompt, MAX_TOKENS).con_sistema(SYSTEM);
+            let resp = rt
+                .block_on(client.complete(&req))
+                .map_err(|e| format!("falló el tejido: {e}"))?;
+            if resp.content.trim().is_empty() {
+                return Err("el modelo devolvió un tejido vacío".into());
+            }
+            Ok(resp.content)
+        })();
+        Msg::TejidoListo(cuerda, resultado)
+    });
+}
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+    use khipu_core::NoteStore;
+
+    #[test]
+    fn prompt_lists_nudos_chronologically_without_tejido() {
+        let mut s = NoteStore::new();
+        let c = s.crear_cuerda("khipu", 0);
+        let a = s.create("primera idea", "con cuerpo", vec![], 100);
+        let b = s.create("segunda idea", "", vec![], 200_000_000);
+        let t = s.create("tejido · khipu", "síntesis vieja", vec![], 300_000_000);
+        for id in [a, b, t] {
+            s.anudar(id, c);
+        }
+        s.cuerda_mut(c).unwrap().tejido = Some(t);
+        let cuerda = s.cuerda(c).unwrap();
+        let nudos = s.nudos_de(c);
+        let p = prompt_de(cuerda, &nudos);
+        assert!(p.contains("«khipu»"));
+        assert!(p.contains("primera idea"));
+        assert!(p.contains("con cuerpo"));
+        assert!(p.contains("segunda idea"));
+        // El tejido anterior no se cita a sí mismo.
+        assert!(!p.contains("síntesis vieja"));
+        // Cronología: la primera antes que la segunda.
+        assert!(p.find("primera").unwrap() < p.find("segunda").unwrap());
+    }
+
+    #[test]
+    fn fecha_corta_maps_known_dates() {
+        assert_eq!(fecha_corta(0), "1970-01-01");
+        assert_eq!(fecha_corta(86_400), "1970-01-02");
+        // 2026-07-14 00:00 UTC (verificado con `date -u -d @1783987200`).
+        assert_eq!(fecha_corta(1_783_987_200), "2026-07-14");
+    }
+}
diff --git a/00_unanchay/khipu/khipu-brahman/Cargo.toml b/00_unanchay/khipu/khipu-brahman/Cargo.toml
index 4f085c3..f6b7793 100644
--- a/00_unanchay/khipu/khipu-brahman/Cargo.toml
+++ b/00_unanchay/khipu/khipu-brahman/Cargo.toml
@@ -12,7 +12,6 @@ description = "khipu-brahman — transporte de sobres khipu sobre libp2p (Brahma
 khipu-share = { path = "../khipu-share" }
 card-net = { workspace = true }
 libp2p = { workspace = true }
-libp2p-stream = { workspace = true }
 tokio = { workspace = true }
 tokio-util = { workspace = true }
 futures = { workspace = true }
diff --git a/00_unanchay/khipu/khipu-brahman/LEEME.md b/00_unanchay/khipu/khipu-brahman/LEEME.md
new file mode 100644
index 0000000..7fa27cc
--- /dev/null
+++ b/00_unanchay/khipu/khipu-brahman/LEEME.md
@@ -0,0 +1,28 @@
+# khipu-brahman
+
+*Read this in English: [README.md](README.md).*
+
+Transporte de sobres khipu sobre libp2p, vía la capa P2P compartida [`card_net::BrahmanNet`] (TCP + Noise + Yamux + Kademlia).
+
+Es el hermano WAN del transporte LAN de `khipu-share::net`: en vez de un
+`TcpStream` directo, abre un stream libp2p sobre el protocolo
+`SOBRE_PROTOCOL` y manda el mismo sobre serializado. Como el sobre va
+firmado y direccionado por contenido, **el transporte no necesita ser
+confiable**: quien recibe verifica con `khipu_share::open` antes de
+ingerir. El cifrado Noise sólo agrega confidencialidad en tránsito.
+
+El descubrimiento es por DHT Kademlia: `KhipuNode::anunciar` publica
+bajo una clave fija y `KhipuNode::descubrir` lista a quién la provee —
+rendezvous sin saber la `Multiaddr` de antemano (hace falta al menos un
+peer bootstrap en la tabla, vía `KhipuNode::add_peer`). La travesía de
+NAT (relay + dcutr + autonat) **sí** está cableada en `BrahmanNet`
+(`shared/card/card-net`); ver el test `jalar_a_traves_de_un_relay` en
+`tests/p2p_roundtrip.rs`, que jala un sobre a través de un circuito relay.
+Detrás de NAT simétrico todavía conviene un relay público alcanzable.
+
+Marco de cable: `u32` big-endian con el largo del sobre + el sobre
+(postcard). Un sobre por stream — espejo del marco de `khipu-share::net`.
+
+---
+
+Parte de **khipu** — ver [khipu](../LEEME.md).
diff --git a/00_unanchay/khipu/khipu-brahman/README.md b/00_unanchay/khipu/khipu-brahman/README.md
new file mode 100644
index 0000000..7217ff4
--- /dev/null
+++ b/00_unanchay/khipu/khipu-brahman/README.md
@@ -0,0 +1,12 @@
+# khipu-brahman
+
+Transport of khipu envelopes over libp2p, through the shared P2P layer
+`card_net::BrahmanNet` (TCP + Noise + Yamux + Kademlia).
+
+It is the WAN sibling of `khipu-share::net`'s LAN transport: instead of a direct
+`TcpStream`, it opens a libp2p stream over the `SOBRE_PROTOCOL` protocol and sends
+the same serialized envelope.
+
+---
+
+Part of **khipu** — see [khipu](../README.md).
diff --git a/00_unanchay/khipu/khipu-core/LEEME.md b/00_unanchay/khipu/khipu-core/LEEME.md
index b84829f..67c6a72 100644
--- a/00_unanchay/khipu/khipu-core/LEEME.md
+++ b/00_unanchay/khipu/khipu-core/LEEME.md
@@ -1,21 +1,29 @@
 # khipu-core
 
-> Modelo de nota + store para [khipu](../README.md). Sin UI.
+*Read this in English: [README.md](README.md).*
 
-`Note` lleva contenido, timestamp de creación, timestamp de último acceso, y `mass: f32`. El store es CRUD simple sobre archivos JSON en `$XDG_DATA_HOME/khipu/`. Cada vez que se lee una nota se actualiza su `last_access` (señal que [khipu-gravity](../khipu-gravity/README.md) usa para reforzar).
+> El núcleo agnóstico de [khipu](../LEEME.md): nudos, cuerdas y el grafo de enlaces. Sin UI, sin disco, sin red.
+
+- **`Note` (el nudo)**: título, cuerpo, etiquetas, timestamps; `cuerda: Option<CuerdaId>` (a lo sumo una), `estado` (Normal/Pendiente/Hecho — TODOs sin due-dates) y `origen` (estampa de la app que lo capturó, fase 2). Conserva `mass`/`last_access` para los consumidores con física temporal (pata, iniy-derive).
+- **`Cuerda`**: una idea recurrente — nombre, fecha, y su `tejido: Option<NoteId>` (el nudo-síntesis). No guarda la lista de nudos: se deriva (`nudos_de`) para que no haya dos fuentes de verdad.
+- **`NoteStore`**: CRUD determinista (`BTreeMap`), cuerdas (`crear_cuerda`/`anudar`/`desanudar`/`sueltos`/`borrar_cuerda`), grafo de wiki-links `[[Título]]` (forward/back/orphans/dangling), `insert_raw` + `coherente()` para migraciones seguras.
+- **`nombre::propose_cuerda_name`**: el bautizo — el término más compartido y saliente de títulos/etiquetas del grupo.
 
 ## API
 
 ```rust
-use khipu_core::{Note, Store};
+use khipu_core::NoteStore;
 
-let store = Store::open()?;
-let id = store.create("nota nueva")?;
-let note = store.read(id)?;
-store.touch(id)?;  // refresca last_access
+let mut s = NoteStore::new();
+let a = s.create("idea que vuelve", "", vec![], now);
+let b = s.create("la misma idea, otra vez", "", vec![], now);
+let c = s.crear_cuerda("idea", now);
+s.anudar(a, c);
+s.anudar(b, c);
+assert_eq!(s.nudos_de(c).len(), 2);
+assert!(s.sueltos().is_empty());
 ```
 
 ## Deps
 
-- `serde`, `serde_json`
-- `directories` para el XDG path
+- `serde` (los tipos viajan por postcard/JSON según el caller)
diff --git a/00_unanchay/khipu/khipu-core/README.md b/00_unanchay/khipu/khipu-core/README.md
index 7355ae1..4338775 100644
--- a/00_unanchay/khipu/khipu-core/README.md
+++ b/00_unanchay/khipu/khipu-core/README.md
@@ -1,21 +1,27 @@
 # khipu-core
 
-> Note model + store for [khipu](../README.md). No UI.
+> The agnostic core of [khipu](../README.md): knots, cords and the link graph. No UI, no disk, no network.
 
-`Note` carries content, creation timestamp, last-access timestamp, and `mass: f32`. The store is simple CRUD over JSON files in `$XDG_DATA_HOME/khipu/`. Every read updates `last_access` (the signal [khipu-gravity](../khipu-gravity/README.md) uses to reinforce).
+- **`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).
+- **`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.
+- **`nombre::propose_cuerda_name`**: the christening — the most shared, salient term across the group's titles/tags.
 
 ## API
 
 ```rust
-use khipu_core::{Note, Store};
+use khipu_core::NoteStore;
 
-let store = Store::open()?;
-let id = store.create("new note")?;
-let note = store.read(id)?;
-store.touch(id)?;  // refresh last_access
+let mut s = NoteStore::new();
+let a = s.create("recurring idea", "", vec![], now);
+let b = s.create("same idea, again", "", vec![], now);
+let c = s.crear_cuerda("idea", now);
+s.anudar(a, c);
+s.anudar(b, c);
+assert_eq!(s.nudos_de(c).len(), 2);
+assert!(s.sueltos().is_empty());
 ```
 
 ## Deps
 
-- `serde`, `serde_json`
-- `directories` for XDG path
+- `serde` (types travel as postcard/JSON depending on the caller)
diff --git a/00_unanchay/khipu/khipu-core/src/lib.rs b/00_unanchay/khipu/khipu-core/src/lib.rs
index 9f1d870..276b875 100644
--- a/00_unanchay/khipu/khipu-core/src/lib.rs
+++ b/00_unanchay/khipu/khipu-core/src/lib.rs
@@ -1,23 +1,29 @@
-//! `khipu_app-core` — el núcleo agnóstico de la toma de notas.
+//! `khipu-core` — el núcleo agnóstico del cuaderno de resonancia.
 //!
-//! Una nota es texto con título, etiquetas y enlaces `[[...]]`. El
-//! [`NoteStore`] las guarda y deriva el grafo: forward-links, backlinks,
-//! huérfanas y enlaces colgantes. Sin UI, sin storage en disco, sin red
-//! — tipos puros y deterministas.
+//! La unidad es el **nudo** ([`Note`]): un pensamiento botado en
+//! cualquier estado mental — una idea, un TODO, una cita. Los nudos que
+//! resuenan entre sí se anudan en una **cuerda** ([`Cuerda`]): una idea
+//! recurrente que crece con cada retorno, en vez de olvidarse. El
+//! [`NoteStore`] guarda ambos y deriva el grafo: forward-links,
+//! backlinks, nudos por cuerda, sueltos. Sin UI, sin storage en disco,
+//! sin red — tipos puros y deterministas.
 //!
-//! - [`note`] — el modelo [`Note`].
+//! - [`note`] — el modelo [`Note`] (nudo), [`Cuerda`], [`EstadoNudo`], [`Origen`].
 //! - [`links`] — el parser de wiki-links `[[...]]`.
-//! - [`store`] — el [`NoteStore`] y el grafo de enlaces.
+//! - [`store`] — el [`NoteStore`]: nudos + cuerdas + grafo de enlaces.
+//! - [`nombre`] — propuesta de nombre para una cuerda naciente.
 //!
-//! La gravedad semántica (clustering por afinidad de embeddings) vive en
-//! `khipu_app-gravity`; las lentes visuales, en los crates de frontend.
+//! La resonancia semántica (afinidad de embeddings) vive en
+//! `khipu-gravity`; las lentes visuales, en los crates de frontend.
 
 #![forbid(unsafe_code)]
 
 pub mod links;
+pub mod nombre;
 pub mod note;
 pub mod store;
 
 pub use links::parse_links;
-pub use note::{Note, NoteId};
+pub use nombre::propose_cuerda_name;
+pub use note::{Cuerda, CuerdaId, EstadoNudo, Note, NoteId, Origen};
 pub use store::NoteStore;
diff --git a/00_unanchay/khipu/khipu-core/src/nombre.rs b/00_unanchay/khipu/khipu-core/src/nombre.rs
new file mode 100644
index 0000000..8187195
--- /dev/null
+++ b/00_unanchay/khipu/khipu-core/src/nombre.rs
@@ -0,0 +1,143 @@
+//! Propuesta de nombre para una cuerda naciente.
+//!
+//! Cuando dos o más nudos resuenan y se anudan, la cuerda necesita un
+//! nombre. No se le pide al usuario que lo invente en frío: se propone el
+//! término más recurrente y saliente de los títulos y etiquetas de sus
+//! nudos, y el bautizo arranca con esa sugerencia ya cargada — se acepta
+//! o se edita. Puro y determinista.
+
+use crate::note::Note;
+
+/// Propone un nombre para un grupo de nudos: el término más recurrente y
+/// saliente de sus títulos y etiquetas. Prefiere lo **compartido** (lo
+/// que aparece en más nudos es lo que los vuelve una idea), luego lo más
+/// frecuente, y desempata alfabético para ser determinista. `None` si no
+/// hay ninguna palabra con señal (todo vacío o palabras vacías).
+pub fn propose_cuerda_name(notes: &[&Note]) -> Option<String> {
+    use std::collections::{BTreeMap, BTreeSet};
+
+    // Por término: (en cuántos nudos aparece, frecuencia total).
+    let mut stats: BTreeMap<String, (u32, u32)> = BTreeMap::new();
+    for n in notes {
+        let mut seen: BTreeSet<String> = BTreeSet::new();
+        for tok in note_tokens(n) {
+            let e = stats.entry(tok.clone()).or_insert((0, 0));
+            e.1 += 1;
+            if seen.insert(tok) {
+                e.0 += 1;
+            }
+        }
+    }
+    if stats.is_empty() {
+        return None;
+    }
+    // max_by: gana doc-freq, luego freq total; en empate, el alfabético
+    // menor (lo invertimos con `kb.cmp(ka)` para que el menor sea "mayor").
+    let best = stats
+        .iter()
+        .max_by(|(ka, va), (kb, vb)| {
+            va.0.cmp(&vb.0).then(va.1.cmp(&vb.1)).then(kb.cmp(ka))
+        })
+        .map(|(k, _)| k.clone())?;
+    Some(capitalize(&best))
+}
+
+/// Tokens significativos de un nudo: palabras del título + etiquetas,
+/// en minúsculas, sin palabras vacías ni fragmentos cortos.
+fn note_tokens(n: &Note) -> Vec<String> {
+    let mut out = Vec::new();
+    let push = |raw: &str, out: &mut Vec<String>| {
+        let t = raw.to_lowercase();
+        if t.chars().count() >= 3 && t.chars().any(|c| c.is_alphabetic()) && !is_stopword(&t) {
+            out.push(t);
+        }
+    };
+    for raw in n.title.split(|c: char| !c.is_alphanumeric()) {
+        push(raw, &mut out);
+    }
+    for tag in &n.tags {
+        for raw in tag.split(|c: char| !c.is_alphanumeric()) {
+            push(raw, &mut out);
+        }
+    }
+    out
+}
+
+/// Mayúscula inicial respetando unicode (para mostrar el nombre).
+fn capitalize(s: &str) -> String {
+    let mut chars = s.chars();
+    match chars.next() {
+        Some(first) => first.to_uppercase().collect::<String>() + chars.as_str(),
+        None => String::new(),
+    }
+}
+
+/// Palabras vacías (español, con algunas de inglés) que no sirven de
+/// nombre aunque sean frecuentes.
+fn is_stopword(t: &str) -> bool {
+    const STOP: &[&str] = &[
+        "los", "las", "una", "unos", "unas", "del", "por", "para", "con", "sin", "que",
+        "como", "más", "mas", "pero", "sus", "este", "esta", "esto", "estos", "estas",
+        "ese", "esa", "eso", "esos", "esas", "the", "and", "for", "with", "you", "este",
+        "muy", "son", "fue", "han", "hay", "sobre", "entre", "cuando", "donde", "porque",
+    ];
+    STOP.contains(&t)
+}
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+    use crate::note::{EstadoNudo, NoteId};
+
+    fn note(id: NoteId, title: &str, tags: &[&str]) -> Note {
+        Note {
+            id,
+            title: title.into(),
+            body: String::new(),
+            tags: tags.iter().map(|s| s.to_string()).collect(),
+            created_at: 0,
+            updated_at: 0,
+            last_access: 0,
+            mass: 1.0,
+            cuerda: None,
+            estado: EstadoNudo::Normal,
+            origen: None,
+        }
+    }
+
+    #[test]
+    fn propose_picks_the_shared_term() {
+        let a = note(1, "Receta de pan", &["cocina"]);
+        let b = note(2, "Horno y pan", &["cocina"]);
+        let c = note(3, "Masa madre", &["cocina"]);
+        // "cocina" está en los tres nudos → gana por doc-freq.
+        let name = propose_cuerda_name(&[&a, &b, &c]).unwrap();
+        assert_eq!(name, "Cocina");
+    }
+
+    #[test]
+    fn propose_prefers_shared_over_merely_frequent() {
+        // "pan" aparece 2 veces pero en un solo nudo; "viaje" en dos.
+        let a = note(1, "Pan pan pan", &[]);
+        let b = note(2, "Viaje a la costa", &[]);
+        let c = note(3, "Viaje largo", &[]);
+        let name = propose_cuerda_name(&[&a, &b, &c]).unwrap();
+        assert_eq!(name, "Viaje");
+    }
+
+    #[test]
+    fn propose_ignores_stopwords_and_short_tokens() {
+        let a = note(1, "El de la y", &[]);
+        let b = note(2, "Por que con", &[]);
+        // Todo son palabras vacías o de <3 letras → sin señal.
+        assert!(propose_cuerda_name(&[&a, &b]).is_none());
+    }
+
+    #[test]
+    fn propose_is_deterministic_on_ties() {
+        // "alpha" y "omega" empatan en doc-freq y freq → gana el alfabético.
+        let a = note(1, "alpha omega", &[]);
+        let b = note(2, "omega alpha", &[]);
+        assert_eq!(propose_cuerda_name(&[&a, &b]).unwrap(), "Alpha");
+    }
+}
diff --git a/00_unanchay/khipu/khipu-core/src/note.rs b/00_unanchay/khipu/khipu-core/src/note.rs
index f44274c..5ba5ba5 100644
--- a/00_unanchay/khipu/khipu-core/src/note.rs
+++ b/00_unanchay/khipu/khipu-core/src/note.rs
@@ -1,18 +1,50 @@
-//! El modelo `Note` — la unidad de khipu_app.
+//! El modelo `Note` — el **nudo**, la unidad de captura de khipu.
+//!
+//! Un nudo es un pensamiento botado en cualquier estado mental: una idea,
+//! un TODO, una cita. Nace suelto; si resuena con otros, se anuda a una
+//! [`crate::Cuerda`]. No hay carpetas ni jerarquías previas — la
+//! estructura emerge de la recurrencia.
 
 use serde::{Deserialize, Serialize};
 
 use crate::links::parse_links;
 
-/// Identificador de una nota. Lo asigna el almacén, monótono y estable.
+/// Identificador de una nota (nudo). Lo asigna el almacén, monótono y estable.
 pub type NoteId = u64;
 
-/// Una nota: título, cuerpo, etiquetas y marcas de tiempo. Los enlaces
-/// no se guardan aparte — se derivan del cuerpo bajo demanda.
+/// Identificador de una cuerda. Lo asigna el almacén, monótono y estable.
+pub type CuerdaId = u64;
+
+/// Estado accionable de un nudo. Un TODO es una expresión mental más —
+/// no hay due-dates ni recordatorios, sólo el gesto de marcarlo.
+#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
+pub enum EstadoNudo {
+    /// Un pensamiento sin más — la mayoría.
+    #[default]
+    Normal,
+    /// Accionable y sin hacer.
+    Pendiente,
+    /// Accionable y hecho. Se conserva: es memoria, no basura.
+    Hecho,
+}
+
+/// De dónde vino un nudo capturado fuera de la app: qué app lo mandó y
+/// una referencia legible (id de correo, nombre de pacha, workspace…).
+/// La estampa es del momento de captura; khipu no la reinterpreta.
+#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
+pub struct Origen {
+    /// App de origen (`chasqui`, `pata`, `pacha`, …).
+    pub app: String,
+    /// Referencia dentro de esa app, legible y opaca para khipu.
+    pub referencia: String,
+}
+
+/// Una nota — el nudo: título, cuerpo, etiquetas y marcas de tiempo.
+/// Los enlaces no se guardan aparte — se derivan del cuerpo bajo demanda.
 ///
-/// `mass` y `last_access` son la señal temporal: la masa decae con el
-/// tiempo y `last_access` registra cuándo fue la última lectura. La
-/// física la aplica el caller (típicamente con `khipu-gravity`).
+/// `mass` y `last_access` son la física temporal *opcional* (la usan
+/// consumidores como la captura rápida de pata vía `khipu-gravity`); la
+/// app khipu ya no olvida — organiza por resonancia y sedimentación.
 #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
 pub struct Note {
     pub id: NoteId,
@@ -28,20 +60,21 @@ pub struct Note {
     /// caller decide cómo arrancar la masa.
     #[serde(default)]
     pub last_access: u64,
-    /// Masa de la nota. 1.0 al crearse; decae con el tiempo, sube con
-    /// cada acceso. Cuando cae bajo el umbral del horizonte la nota
-    /// pasa al archivo (no se borra). En payloads viejos arranca en 1.0.
+    /// Masa de la nota, para los consumidores con física temporal
+    /// (`khipu-gravity::Gravity`). La app khipu no la decae.
     #[serde(default = "default_mass")]
     pub mass: f32,
-    /// Posición ancla en el lienzo del mapa, en coordenadas de mundo.
-    /// Se fija **una sola vez** cuando la nota se coloca (por gravedad
-    /// semántica, contra las notas ya asentadas) y se persiste: el mapa
-    /// es estable — un pensamiento conserva su domicilio para que la
-    /// memoria espacial pueda recordarlo. `None` = todavía sin colocar
-    /// (el caller le asigna lugar la primera vez que la pinta). En
-    /// payloads viejos arranca en `None` y se coloca al cargar.
+    /// Cuerda a la que este nudo está anudado. `None` = suelto (recién
+    /// capturado o nunca resonó — con el tiempo, sedimento).
     #[serde(default)]
-    pub pos: Option<(f32, f32)>,
+    pub cuerda: Option<CuerdaId>,
+    /// Estado accionable (TODO). `Normal` para la mayoría.
+    #[serde(default)]
+    pub estado: EstadoNudo,
+    /// Estampa de origen si el nudo llegó desde otra app. `None` =
+    /// capturado en khipu mismo.
+    #[serde(default)]
+    pub origen: Option<Origen>,
 }
 
 fn default_mass() -> f32 {
@@ -65,6 +98,28 @@ impl Note {
         let q = query.to_lowercase();
         self.title.to_lowercase().contains(&q) || self.body.to_lowercase().contains(&q)
     }
+
+    /// `true` si el nudo está suelto (sin cuerda).
+    pub fn suelto(&self) -> bool {
+        self.cuerda.is_none()
+    }
+}
+
+/// Una cuerda: una idea recurrente. No se crea por adelantado — nace
+/// cuando dos o más nudos resuenan y el usuario los anuda. Su masa no se
+/// guarda: se deriva de sus nudos (`khipu_gravity::masa_cuerda`).
+#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
+pub struct Cuerda {
+    pub id: CuerdaId,
+    /// Nombre de la idea. Editable — se bautiza al nacer con una
+    /// propuesta derivada del contenido.
+    pub nombre: String,
+    /// Segundo Unix en que se anudó por primera vez.
+    pub created_at: u64,
+    /// El nudo-síntesis (tejido) de esta cuerda, si ya se tejió. Vive
+    /// como una nota más, anudada a la cuerda; re-tejer lo reemplaza.
+    #[serde(default)]
+    pub tejido: Option<NoteId>,
 }
 
 #[cfg(test)]
@@ -81,7 +136,9 @@ mod tests {
             updated_at: 0,
             last_access: 0,
             mass: 1.0,
-            pos: None,
+            cuerda: None,
+            estado: EstadoNudo::Normal,
+            origen: None,
         }
     }
 
@@ -105,4 +162,12 @@ mod tests {
         assert!(n.matches("pan"));
         assert!(!n.matches("ausente"));
     }
+
+    #[test]
+    fn new_notes_are_loose_by_default() {
+        let n = note("x", "y");
+        assert!(n.suelto());
+        assert_eq!(n.estado, EstadoNudo::Normal);
+        assert!(n.origen.is_none());
+    }
 }
diff --git a/00_unanchay/khipu/khipu-core/src/store.rs b/00_unanchay/khipu/khipu-core/src/store.rs
index fc6aa20..2dddb66 100644
--- a/00_unanchay/khipu/khipu-core/src/store.rs
+++ b/00_unanchay/khipu/khipu-core/src/store.rs
@@ -1,31 +1,49 @@
-//! `NoteStore` — el almacén de notas y su grafo de enlaces.
+//! `NoteStore` — el almacén de nudos, cuerdas y su grafo de enlaces.
 //!
-//! Guarda las notas en un `BTreeMap` para que toda iteración sea
+//! Guarda notas y cuerdas en `BTreeMap` para que toda iteración sea
 //! determinista (ordenada por id). Los enlaces se resuelven por título
 //! sin distinguir mayúsculas: `[[cocina]]` y `[[Cocina]]` apuntan a la
-//! misma nota.
+//! misma nota. Las cuerdas son la estructura emergente: un nudo pertenece
+//! a lo sumo a una cuerda (`Note::cuerda`), y la cuerda no guarda la
+//! lista — se deriva ([`NoteStore::nudos_de`]) para que no haya dos
+//! fuentes de verdad que desincronizar.
 
 use std::collections::BTreeMap;
 
 use serde::{Deserialize, Serialize};
 
-use crate::note::{Note, NoteId};
+use crate::note::{Cuerda, CuerdaId, Note, NoteId};
 
-/// El almacén de notas de khipu_app.
+/// El almacén de nudos y cuerdas de khipu.
 #[derive(Debug, Clone, Default, Serialize, Deserialize)]
 pub struct NoteStore {
     notes: BTreeMap<NoteId, Note>,
     /// Siguiente id a asignar — monótono, nunca reutiliza huecos.
     next_id: NoteId,
+    /// Las cuerdas: ideas recurrentes bautizadas. Nuevas en el formato
+    /// V4; payloads viejos arrancan sin ninguna.
+    #[serde(default)]
+    cuerdas: BTreeMap<CuerdaId, Cuerda>,
+    #[serde(default = "one")]
+    next_cuerda: CuerdaId,
+}
+
+fn one() -> u64 {
+    1
 }
 
 impl NoteStore {
     pub fn new() -> Self {
-        Self { notes: BTreeMap::new(), next_id: 1 }
+        Self {
+            notes: BTreeMap::new(),
+            next_id: 1,
+            cuerdas: BTreeMap::new(),
+            next_cuerda: 1,
+        }
     }
 
-    /// Crea una nota y devuelve su id. Empieza con `mass = 1.0` y
-    /// `last_access = now` — recién nacida, plenamente visible.
+    /// Crea un nudo suelto y devuelve su id. Empieza con `mass = 1.0` y
+    /// `last_access = now` — recién nacido, sin cuerda.
     pub fn create(
         &mut self,
         title: impl Into<String>,
@@ -46,12 +64,41 @@ impl NoteStore {
                 updated_at: now,
                 last_access: now,
                 mass: 1.0,
-                pos: None,
+                cuerda: None,
+                estado: Default::default(),
+                origen: None,
             },
         );
         id
     }
 
+    /// 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
+    /// de creación es [`NoteStore::create`].
+    pub fn insert_raw(&mut self, note: Note) {
+        self.next_id = self.next_id.max(note.id + 1);
+        self.notes.insert(note.id, note);
+    }
+
+    /// 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
+    /// para descartar decodificaciones corruptas de formatos viejos.
+    pub fn coherente(&self) -> bool {
+        let notas_ok = self.notes.iter().all(|(k, n)| {
+            *k == n.id
+                && n.id < self.next_id
+                && n.cuerda.map(|c| self.cuerdas.contains_key(&c)).unwrap_or(true)
+        });
+        let cuerdas_ok = self.cuerdas.iter().all(|(k, c)| {
+            *k == c.id
+                && c.id < self.next_cuerda
+                && c.tejido.map(|t| self.notes.contains_key(&t)).unwrap_or(true)
+        });
+        notas_ok && cuerdas_ok
+    }
+
     pub fn len(&self) -> usize {
         self.notes.len()
     }
@@ -88,8 +135,8 @@ impl NoteStore {
         }
     }
 
-    /// Marca `last_access = now`. La señal que `khipu-gravity` usa para
-    /// reforzar la masa. No-op si la nota no existe; `true` si tocó.
+    /// Marca `last_access = now` — la señal de acceso para los
+    /// consumidores con física temporal. No-op si la nota no existe.
     pub fn touch(&mut self, id: NoteId, now: u64) -> bool {
         match self.notes.get_mut(&id) {
             Some(n) => {
@@ -112,24 +159,136 @@ impl NoteStore {
         }
     }
 
-    /// Fija la posición ancla de una nota en el lienzo. Se llama una sola
-    /// vez al colocarla; de ahí en más el domicilio del pensamiento no se
-    /// mueve salvo que el usuario lo arrastre. `false` si la nota no existe.
-    pub fn set_pos(&mut self, id: NoteId, x: f32, y: f32) -> bool {
-        match self.notes.get_mut(&id) {
-            Some(n) => {
-                n.pos = Some((x, y));
-                true
+    /// Elimina una nota y la devuelve. Si era el tejido de su cuerda, la
+    /// cuerda queda sin tejido (se puede volver a tejer).
+    pub fn remove(&mut self, id: NoteId) -> Option<Note> {
+        let n = self.notes.remove(&id)?;
+        if let Some(cid) = n.cuerda {
+            if let Some(c) = self.cuerdas.get_mut(&cid) {
+                if c.tejido == Some(id) {
+                    c.tejido = None;
+                }
             }
-            None => false,
         }
+        Some(n)
     }
 
-    /// Elimina una nota y la devuelve.
-    pub fn remove(&mut self, id: NoteId) -> Option<Note> {
-        self.notes.remove(&id)
+    // =================================================================
+    // Cuerdas
+    // =================================================================
+
+    /// Crea una cuerda vacía con `nombre` y devuelve su id. Los nudos se
+    /// suman con [`NoteStore::anudar`].
+    pub fn crear_cuerda(&mut self, nombre: impl Into<String>, now: u64) -> CuerdaId {
+        let id = self.next_cuerda;
+        self.next_cuerda += 1;
+        self.cuerdas.insert(
+            id,
+            Cuerda {
+                id,
+                nombre: nombre.into(),
+                created_at: now,
+                tejido: None,
+            },
+        );
+        id
+    }
+
+    pub fn cuerda(&self, id: CuerdaId) -> Option<&Cuerda> {
+        self.cuerdas.get(&id)
+    }
+
+    pub fn cuerda_mut(&mut self, id: CuerdaId) -> Option<&mut Cuerda> {
+        self.cuerdas.get_mut(&id)
+    }
+
+    /// Itera las cuerdas en orden de id (determinista).
+    pub fn cuerdas(&self) -> impl Iterator<Item = &Cuerda> {
+        self.cuerdas.values()
+    }
+
+    pub fn cuerdas_len(&self) -> usize {
+        self.cuerdas.len()
+    }
+
+    /// Anuda `nota` a `cuerda`. Si estaba en otra cuerda, se muda (y si
+    /// era su tejido, esa cuerda queda sin tejido). `false` si la nota o
+    /// la cuerda no existen.
+    pub fn anudar(&mut self, nota: NoteId, cuerda: CuerdaId) -> bool {
+        if !self.cuerdas.contains_key(&cuerda) {
+            return false;
+        }
+        let Some(previa) = self.notes.get(&nota).map(|n| n.cuerda) else {
+            return false;
+        };
+        if let Some(prev) = previa.filter(|p| *p != cuerda) {
+            if let Some(c) = self.cuerdas.get_mut(&prev) {
+                if c.tejido == Some(nota) {
+                    c.tejido = None;
+                }
+            }
+        }
+        if let Some(n) = self.notes.get_mut(&nota) {
+            n.cuerda = Some(cuerda);
+        }
+        true
+    }
+
+    /// Suelta un nudo de su cuerda (queda suelto). Si era el tejido, la
+    /// cuerda queda sin tejido. `false` si la nota no existe o ya estaba
+    /// suelta.
+    pub fn desanudar(&mut self, nota: NoteId) -> bool {
+        let Some(cid) = self.notes.get(&nota).and_then(|n| n.cuerda) else {
+            return false;
+        };
+        if let Some(c) = self.cuerdas.get_mut(&cid) {
+            if c.tejido == Some(nota) {
+                c.tejido = None;
+            }
+        }
+        if let Some(n) = self.notes.get_mut(&nota) {
+            n.cuerda = None;
+        }
+        true
+    }
+
+    /// Borra una cuerda. Sus nudos NO se borran — vuelven a estar
+    /// sueltos (la cuerda es estructura, no contenido). Devuelve la
+    /// cuerda si existía.
+    pub fn borrar_cuerda(&mut self, id: CuerdaId) -> Option<Cuerda> {
+        let c = self.cuerdas.remove(&id)?;
+        for n in self.notes.values_mut() {
+            if n.cuerda == Some(id) {
+                n.cuerda = None;
+            }
+        }
+        Some(c)
+    }
+
+    /// Los nudos de una cuerda, en orden cronológico de creación
+    /// (desempate por id — determinista). Incluye el tejido si lo hay.
+    pub fn nudos_de(&self, cuerda: CuerdaId) -> Vec<&Note> {
+        let mut out: Vec<&Note> = self
+            .notes
+            .values()
+            .filter(|n| n.cuerda == Some(cuerda))
+            .collect();
+        out.sort_by(|a, b| a.created_at.cmp(&b.created_at).then(a.id.cmp(&b.id)));
+        out
     }
 
+    /// Los nudos sueltos (sin cuerda), del más reciente al más viejo
+    /// (desempate por id descendente — determinista).
+    pub fn sueltos(&self) -> Vec<&Note> {
+        let mut out: Vec<&Note> = self.notes.values().filter(|n| n.suelto()).collect();
+        out.sort_by(|a, b| b.created_at.cmp(&a.created_at).then(b.id.cmp(&a.id)));
+        out
+    }
+
+    // =================================================================
+    // Grafo de enlaces
+    // =================================================================
+
     /// Notas que llevan la etiqueta `tag`, en orden de id.
     pub fn by_tag(&self, tag: &str) -> Vec<&Note> {
         self.notes.values().filter(|n| n.has_tag(tag)).collect()
@@ -278,8 +437,6 @@ mod tests {
     #[test]
     fn orphans_have_no_backlinks() {
         let (s, _, _, jardin) = seeded();
-        // Jardín no recibe enlaces de vuelta... pero el Índice sí lo enlaza.
-        // El único huérfano real sería una nota aislada.
         let mut s2 = s;
         let aislada = s2.create("Aislada", "sin conexiones", vec![], 0);
         let orphan_ids: Vec<_> = s2.orphans().iter().map(|n| n.id).collect();
@@ -301,6 +458,7 @@ mod tests {
         let n = s.get(id).unwrap();
         assert_eq!(n.mass, 1.0);
         assert_eq!(n.last_access, 1_700_000_000);
+        assert!(n.suelto());
     }
 
     #[test]
@@ -313,36 +471,151 @@ mod tests {
     }
 
     #[test]
-    fn update_body_also_marks_last_access() {
+    fn remove_drops_the_note() {
+        let (mut s, indice, ..) = seeded();
+        assert!(s.remove(indice).is_some());
+        assert_eq!(s.len(), 2);
+        assert!(s.get(indice).is_none());
+    }
+
+    // =================================================================
+    // Cuerdas
+    // =================================================================
+
+    #[test]
+    fn crear_cuerda_and_anudar() {
+        let (mut s, indice, cocina, jardin) = seeded();
+        let c = s.crear_cuerda("Casa", 200);
+        assert_eq!(s.cuerdas_len(), 1);
+        assert!(s.anudar(cocina, c));
+        assert!(s.anudar(jardin, c));
+        assert_eq!(s.get(cocina).unwrap().cuerda, Some(c));
+        let nudos: Vec<_> = s.nudos_de(c).iter().map(|n| n.id).collect();
+        assert_eq!(nudos, vec![cocina, jardin]);
+        // El índice sigue suelto.
+        assert!(s.get(indice).unwrap().suelto());
+    }
+
+    #[test]
+    fn anudar_rejects_missing_note_or_cuerda() {
+        let (mut s, indice, ..) = seeded();
+        let c = s.crear_cuerda("Casa", 200);
+        assert!(!s.anudar(9_999, c));
+        assert!(!s.anudar(indice, 9_999));
+    }
+
+    #[test]
+    fn anudar_moves_between_cuerdas() {
+        let (mut s, _, cocina, _) = seeded();
+        let a = s.crear_cuerda("A", 0);
+        let b = s.crear_cuerda("B", 0);
+        assert!(s.anudar(cocina, a));
+        assert!(s.anudar(cocina, b));
+        assert_eq!(s.get(cocina).unwrap().cuerda, Some(b));
+        assert!(s.nudos_de(a).is_empty());
+    }
+
+    #[test]
+    fn desanudar_frees_the_note() {
+        let (mut s, _, cocina, _) = seeded();
+        let c = s.crear_cuerda("Casa", 0);
+        s.anudar(cocina, c);
+        assert!(s.desanudar(cocina));
+        assert!(s.get(cocina).unwrap().suelto());
+        assert!(!s.desanudar(cocina)); // ya estaba suelto
+    }
+
+    #[test]
+    fn nudos_de_sorts_chronologically() {
         let mut s = NoteStore::new();
-        let id = s.create("x", "y", vec![], 100);
-        assert!(s.update_body(id, "z", 700));
-        assert_eq!(s.get(id).unwrap().last_access, 700);
+        let c = s.crear_cuerda("Idea", 0);
+        let tarde = s.create("tarde", "", vec![], 900);
+        let temprano = s.create("temprano", "", vec![], 100);
+        s.anudar(tarde, c);
+        s.anudar(temprano, c);
+        let ids: Vec<_> = s.nudos_de(c).iter().map(|n| n.id).collect();
+        assert_eq!(ids, vec![temprano, tarde]);
     }
 
     #[test]
-    fn set_mass_persists_the_value() {
+    fn sueltos_lists_newest_first() {
         let mut s = NoteStore::new();
-        let id = s.create("x", "y", vec![], 100);
-        assert!(s.set_mass(id, 0.42));
-        assert!((s.get(id).unwrap().mass - 0.42).abs() < 1e-6);
+        let viejo = s.create("viejo", "", vec![], 100);
+        let nuevo = s.create("nuevo", "", vec![], 900);
+        let ids: Vec<_> = s.sueltos().iter().map(|n| n.id).collect();
+        assert_eq!(ids, vec![nuevo, viejo]);
+    }
+
+    #[test]
+    fn borrar_cuerda_frees_its_nudos() {
+        let (mut s, _, cocina, jardin) = seeded();
+        let c = s.crear_cuerda("Casa", 0);
+        s.anudar(cocina, c);
+        s.anudar(jardin, c);
+        assert!(s.borrar_cuerda(c).is_some());
+        assert!(s.get(cocina).unwrap().suelto());
+        assert!(s.get(jardin).unwrap().suelto());
+        assert_eq!(s.cuerdas_len(), 0);
+        // El contenido sobrevive a la estructura.
+        assert_eq!(s.len(), 3);
     }
 
     #[test]
-    fn set_pos_anchors_the_note_once() {
+    fn removing_tejido_clears_the_cuerda_pointer() {
         let mut s = NoteStore::new();
-        let id = s.create("x", "y", vec![], 100);
-        assert_eq!(s.get(id).unwrap().pos, None);
-        assert!(s.set_pos(id, 12.0, -8.0));
-        assert_eq!(s.get(id).unwrap().pos, Some((12.0, -8.0)));
-        assert!(!s.set_pos(9_999, 0.0, 0.0));
+        let c = s.crear_cuerda("Idea", 0);
+        let sintesis = s.create("tejido · Idea", "encuadre", vec![], 100);
+        s.anudar(sintesis, c);
+        s.cuerda_mut(c).unwrap().tejido = Some(sintesis);
+        s.remove(sintesis);
+        assert_eq!(s.cuerda(c).unwrap().tejido, None);
     }
 
     #[test]
-    fn remove_drops_the_note() {
-        let (mut s, indice, ..) = seeded();
-        assert!(s.remove(indice).is_some());
-        assert_eq!(s.len(), 2);
-        assert!(s.get(indice).is_none());
+    fn insert_raw_preserves_ids_and_bumps_counter() {
+        let mut s = NoteStore::new();
+        let mut n = Note {
+            id: 7,
+            title: "migrada".into(),
+            body: String::new(),
+            tags: vec![],
+            created_at: 1,
+            updated_at: 1,
+            last_access: 1,
+            mass: 1.0,
+            cuerda: None,
+            estado: Default::default(),
+            origen: None,
+        };
+        s.insert_raw(n.clone());
+        assert_eq!(s.get(7).unwrap().title, "migrada");
+        // El próximo create no pisa el id preservado.
+        let nuevo = s.create("x", "", vec![], 2);
+        assert_eq!(nuevo, 8);
+        // Reemplazo por id.
+        n.title = "editada".into();
+        s.insert_raw(n);
+        assert_eq!(s.get(7).unwrap().title, "editada");
+        assert!(s.coherente());
+    }
+
+    #[test]
+    fn coherente_detects_dangling_cuerda() {
+        let mut s = NoteStore::new();
+        let id = s.create("x", "", vec![], 0);
+        assert!(s.coherente());
+        s.get_mut(id).unwrap().cuerda = Some(99); // cuerda inexistente
+        assert!(!s.coherente());
+    }
+
+    #[test]
+    fn desanudar_tejido_clears_the_pointer() {
+        let mut s = NoteStore::new();
+        let c = s.crear_cuerda("Idea", 0);
+        let sintesis = s.create("tejido", "", vec![], 100);
+        s.anudar(sintesis, c);
+        s.cuerda_mut(c).unwrap().tejido = Some(sintesis);
+        assert!(s.desanudar(sintesis));
+        assert_eq!(s.cuerda(c).unwrap().tejido, None);
     }
 }
diff --git a/00_unanchay/khipu/khipu-gravity/Cargo.toml b/00_unanchay/khipu/khipu-gravity/Cargo.toml
index 2794715..5d6c0af 100644
--- a/00_unanchay/khipu/khipu-gravity/Cargo.toml
+++ b/00_unanchay/khipu/khipu-gravity/Cargo.toml
@@ -9,7 +9,12 @@ description = "khipu — dos físicas puras: masa temporal (decay/refuerzo con v
 
 [dependencies]
 khipu-core = { path = "../khipu-core" }
+# El almacenamiento e índice kNN de vectores lo provee el índice unificado de la
+# suite (`rimay-verbo-index`): antes había tres copias del mismo coseno lineal.
+# `SemanticField` guarda los vectores aquí dentro y construye grafo/gravedad/
+# clústeres/layout 2D **encima**.
+rimay-verbo-index = { workspace = true }
+rimay-verbo-core = { workspace = true }
 serde = { workspace = true }
-# `libm`: la masa decae con `exp` y queremos mantenernos compatibles
-# con futuros consumidores `no_std` (kernel wawa, WASM bare).
+# `libm`: la masa decae con `exp` (física temporal — desacoplada del índice).
 libm = { workspace = true }
diff --git a/00_unanchay/khipu/khipu-gravity/LEEME.md b/00_unanchay/khipu/khipu-gravity/LEEME.md
index 410c5f1..74e364a 100644
--- a/00_unanchay/khipu/khipu-gravity/LEEME.md
+++ b/00_unanchay/khipu/khipu-gravity/LEEME.md
@@ -1,20 +1,23 @@
 # khipu-gravity
 
-> Algoritmo de masa/decay para [khipu](../README.md).
+*Read this in English: [README.md](README.md).*
 
-Pura física de notas. Cada nota tiene `mass: f32`. Cada tick (configurable, default 1h) la masa decae: `mass *= exp(-dt / half_life)`. Cada acceso la refuerza: `mass += boost`. Cuando `mass < umbral`, la nota cae del horizonte visible (no se borra, queda en archivo). El cálculo es puro — no toca el store; el caller decide qué hacer con el resultado.
+> Las físicas puras de [khipu](../LEEME.md) — tres, deterministas, sin I/O ni reloj.
+
+1. **Masa temporal** (`Gravity` + `Params`): decay exponencial por vida media + refuerzo por acceso + horizonte. La app khipu **ya no la usa** (el rediseño 2026-07 invirtió la física); la consumen la captura rápida de pata e `iniy-derive`.
+2. **Campo semántico** (`SemanticField`): vectores por nota sobre `rimay-verbo-index`, afinidad coseno, `nearest` (la base de la **resonancia** de khipu), clústeres y layout 2D. `local_embed` es el fallback trigram offline.
+3. **Masa de cuerda** (`masa_cuerda` + `frescura`): una cuerda engorda logarítmicamente con cada nudo y arde según cuán reciente fue su último crecimiento. La inversa del olvido: el refuerzo viene de la recurrencia, no de la lectura.
 
 ## API
 
 ```rust
-use khipu_gravity::{Gravity, Params};
+use khipu_gravity::{masa_cuerda, frescura, FRESCURA_MEDIA_VIDA_SECS};
 
-let g = Gravity::new(Params::default());
-let new_mass = g.decay(mass, dt);
-let new_mass = g.reinforce(mass, boost);
+let grosor = masa_cuerda(n_nudos);                        // 0 si vacía; retornos decrecientes
+let brillo = frescura(ultimo_nudo, now, FRESCURA_MEDIA_VIDA_SECS); // 1.0 recién crecida → 0.5 al mes
 ```
 
 ## Deps
 
-- `libm` para `exp` (sin `std::math` cuando se compila a WASM)
-- Cero deps de I/O o tiempo (el caller pasa `dt`)
+- `libm` para `exp`/`log` (apto `no_std` futuro)
+- `khipu-core` (ids), `rimay-verbo-core`/`-index` (vectores)
diff --git a/00_unanchay/khipu/khipu-gravity/README.md b/00_unanchay/khipu/khipu-gravity/README.md
index 275e4c1..ea90655 100644
--- a/00_unanchay/khipu/khipu-gravity/README.md
+++ b/00_unanchay/khipu/khipu-gravity/README.md
@@ -1,20 +1,21 @@
 # khipu-gravity
 
-> Mass/decay algorithm for [khipu](../README.md).
+> The pure physics of [khipu](../README.md) — three of them, deterministic, no I/O, no clock.
 
-Pure physics of notes. Each note has `mass: f32`. Every tick (configurable, default 1h) mass decays: `mass *= exp(-dt / half_life)`. Each access reinforces it: `mass += boost`. When `mass < threshold`, the note falls off the visible horizon (not deleted, kept in archive). Pure computation — doesn't touch the store; the caller decides what to do with the result.
+1. **Temporal mass** (`Gravity` + `Params`): exponential decay by half-life + reinforcement on access + horizon. The khipu app **no longer uses it** (the 2026-07 redesign inverted the physics); pata's quick capture and `iniy-derive` consume it.
+2. **Semantic field** (`SemanticField`): per-note vectors over `rimay-verbo-index`, cosine affinity, `nearest` (the basis of khipu's **resonance**), clusters and 2D layout. `local_embed` is the offline trigram fallback.
+3. **Cord mass** (`masa_cuerda` + `frescura`): a cord fattens logarithmically with each knot and glows by how recently it last grew. The inverse of forgetting: reinforcement comes from recurrence, not from reading.
 
 ## API
 
 ```rust
-use khipu_gravity::{Gravity, Params};
+use khipu_gravity::{masa_cuerda, frescura, FRESCURA_MEDIA_VIDA_SECS};
 
-let g = Gravity::new(Params::default());
-let new_mass = g.decay(mass, dt);
-let new_mass = g.reinforce(mass, boost);
+let thickness = masa_cuerda(n_knots);                       // 0 when empty; diminishing returns
+let glow = frescura(last_knot, now, FRESCURA_MEDIA_VIDA_SECS); // 1.0 just grown → 0.5 after a month
 ```
 
 ## Deps
 
-- `libm` for `exp` (no `std::math` when compiled to WASM)
-- Zero I/O or time deps (caller passes `dt`)
+- `libm` for `exp`/`log` (fit for future `no_std`)
+- `khipu-core` (ids), `rimay-verbo-core`/`-index` (vectors)
diff --git a/00_unanchay/khipu/khipu-gravity/src/lib.rs b/00_unanchay/khipu/khipu-gravity/src/lib.rs
index d97fc3d..394c9f7 100644
--- a/00_unanchay/khipu/khipu-gravity/src/lib.rs
+++ b/00_unanchay/khipu/khipu-gravity/src/lib.rs
@@ -1,22 +1,29 @@
-//! `khipu-gravity` — dos físicas, ambas puras y deterministas.
+//! `khipu-gravity` — tres físicas, todas puras y deterministas.
 //!
 //! 1. **Masa temporal** ([`Gravity`] + [`Params`]): cada nota tiene una
 //!    `mass: f32`. Con el tiempo `decay(mass, dt)` la apaga; con cada
 //!    acceso `reinforce(mass, boost)` la enciende. Bajo un umbral la
 //!    nota cae al «archivo» — no se borra, sólo deja el horizonte
-//!    visible. El cálculo no toca el store; el caller decide qué hacer
-//!    con el resultado.
+//!    visible. La usan los consumidores que sí olvidan (la captura
+//!    rápida de pata, iniy-derive); la app khipu ya no.
 //! 2. **Gravedad semántica** ([`SemanticField`]): afinidad coseno entre
-//!    vectores, vecinos, clústeres por umbral y layout 2D dirigido por
-//!    fuerzas. Las notas afines se atraen, todas se repelen.
+//!    vectores y vecinos ([`SemanticField::nearest`]) — la base de la
+//!    **resonancia**: al capturar un nudo nuevo, sus vecinos son los
+//!    pensamientos que vuelve a tocar.
+//! 3. **Masa de cuerda** ([`masa_cuerda`] + [`frescura`]): una cuerda
+//!    (idea recurrente) engorda con cada nudo que se le anuda y arde
+//!    según cuán reciente fue su último crecimiento. Es la inversa del
+//!    olvido: el refuerzo viene de la recurrencia, no de la lectura.
 //!
-//! Las dos están desacopladas — un caller puede usar sólo una. El
+//! Las tres están desacopladas — un caller puede usar sólo una. El
 //! crate compila sin alcanzar `std::f32::exp` (usa [`libm`]) para
 //! mantenerse apto a futuros consumos `no_std` (kernel wawa, WASM).
 
 #![forbid(unsafe_code)]
 
 use khipu_core::NoteId;
+use rimay_verbo_core::{EmbeddingVector, ModelId};
+use rimay_verbo_index::VectorIndex;
 use serde::{Deserialize, Serialize};
 
 /// Parámetros de la física temporal de una nota.
@@ -78,11 +85,29 @@ impl Gravity {
     }
 }
 
+/// Nombre interno del espacio vectorial de un [`SemanticField`]. Da igual cuál
+/// sea el embebedor real (trigram local, daemon `verbo`, …): dentro del campo
+/// **todos** los vectores comparten índice y por tanto modelo, así que basta un
+/// nombre estable; la dimensión se toma del primer vector insertado. El campo no
+/// mezcla espacios — igual que khipu, que ya recalcula todo si cambia el modelo.
+const FIELD_MODEL_NAME: &str = "khipu-field";
+
 /// Una nube de notas con su vector semántico — el dominio de la gravedad.
+///
+/// El **almacenamiento** de vectores lo delega en [`VectorIndex<NoteId>`] (el
+/// índice unificado de la suite: kNN coseno + persistencia); encima construye
+/// grafo/gravedad/clústeres/layout 2D. El índice nace perezoso en el primer
+/// `insert` (necesita la dimensión del vector para fijar su [`ModelId`]).
+///
+/// `order` mantiene el orden de inserción de las claves — el índice es un
+/// `HashMap` sin orden garantizado, y `iter`/`clusters`/`gravity_layout` deben
+/// ser deterministas. No duplica vectores: sólo las claves.
 #[derive(Debug, Clone, Default, Serialize, Deserialize)]
 pub struct SemanticField {
-    /// `(id, vector)` en orden de inserción.
-    entries: Vec<(NoteId, Vec<f32>)>,
+    /// Vectores, indexados por `NoteId`. `None` hasta el primer `insert`.
+    index: Option<VectorIndex<NoteId>>,
+    /// Claves en orden de inserción, para recorridos deterministas.
+    order: Vec<NoteId>,
 }
 
 /// Posición 2D resultante de una nota tras el layout por gravedad.
@@ -141,40 +166,73 @@ impl SemanticField {
 
     /// Cantidad de notas en el campo.
     pub fn len(&self) -> usize {
-        self.entries.len()
+        self.index.as_ref().map(VectorIndex::len).unwrap_or(0)
     }
 
     pub fn is_empty(&self) -> bool {
-        self.entries.is_empty()
+        self.len() == 0
     }
 
-    /// Inserta o reemplaza el vector de una nota.
+    /// Inserta o reemplaza el vector de una nota. El vector se guarda en el
+    /// [`VectorIndex`]; el índice nace perezoso con la dimensión de este primer
+    /// vector. Un vector vacío o de dimensión incompatible con el índice ya
+    /// fijado se ignora (khipu nunca mezcla dimensiones en una sesión).
     pub fn insert(&mut self, id: NoteId, vector: Vec<f32>) {
-        if let Some(slot) = self.entries.iter_mut().find(|(eid, _)| *eid == id) {
-            slot.1 = vector;
+        self.insert_etiquetado(id, vector, FIELD_MODEL_NAME);
+    }
+
+    /// Como [`Self::insert`], pero el índice adopta la **etiqueta real del
+    /// embebedor** (Arco II de anqara, #11): un campo nacido con la etiqueta
+    /// `"e5-small(384d)"` rechaza vectores de otro embebedor aunque la
+    /// dimensión coincida — antes el `ModelId` ficticio anulaba esa
+    /// salvaguarda y dos espacios incomparables se mezclaban sin detectarlo.
+    /// Devuelve `false` si el vector fue rechazado (etiqueta o dimensión).
+    pub fn insert_etiquetado(&mut self, id: NoteId, vector: Vec<f32>, etiqueta: &str) -> bool {
+        if vector.is_empty() {
+            return false;
+        }
+        let index = self
+            .index
+            .get_or_insert_with(|| VectorIndex::new(ModelId::new(etiqueta, vector.len())));
+        // Etiqueta distinta a la del índice ya fijado = otro espacio
+        // vectorial: se rechaza en vez de corromper.
+        if index.model().name != etiqueta {
+            return false;
+        }
+        let Ok(ev) = EmbeddingVector::new(index.model().clone(), vector) else {
+            return false;
+        };
+        if index.insert(id, ev).is_ok() {
+            if !self.order.contains(&id) {
+                self.order.push(id);
+            }
+            true
         } else {
-            self.entries.push((id, vector));
+            false
         }
     }
 
     /// Saca la nota del campo. No-op si no estaba. `true` si se removió.
     pub fn remove(&mut self, id: NoteId) -> bool {
-        let before = self.entries.len();
-        self.entries.retain(|(eid, _)| *eid != id);
-        self.entries.len() != before
+        let removed = self.index.as_mut().map(|ix| ix.remove(&id)).unwrap_or(false);
+        if removed {
+            self.order.retain(|&eid| eid != id);
+        }
+        removed
     }
 
     /// Itera `(id, &vector)` en orden de inserción. Útil para serializar
-    /// el campo completo (persistencia, exportar).
+    /// el campo completo (persistencia, exportar). El orden lo da `order`
+    /// porque el índice es un `HashMap` sin orden garantizado.
     pub fn iter(&self) -> impl Iterator<Item = (NoteId, &[f32])> {
-        self.entries.iter().map(|(id, v)| (*id, v.as_slice()))
+        let index = self.index.as_ref();
+        self.order
+            .iter()
+            .filter_map(move |id| index.and_then(|ix| ix.get(id)).map(|v| (*id, v)))
     }
 
     fn vector_of(&self, id: NoteId) -> Option<&[f32]> {
-        self.entries
-            .iter()
-            .find(|(eid, _)| *eid == id)
-            .map(|(_, v)| v.as_slice())
+        self.index.as_ref().and_then(|ix| ix.get(&id))
     }
 
     /// Afinidad (similitud coseno) entre dos notas. `None` si alguna no
@@ -185,15 +243,29 @@ impl SemanticField {
 
     /// Las `k` notas más afines a `id`, de mayor a menor afinidad.
     /// Empata por id ascendente para que el orden sea determinista.
+    ///
+    /// El kNN coseno lo hace el [`VectorIndex::search`] unificado; aquí sólo se
+    /// reimpone el desempate determinista por id (el índice no lo garantiza) y
+    /// se excluye la propia `id`.
     pub fn nearest(&self, id: NoteId, k: usize) -> Vec<(NoteId, f32)> {
-        let Some(base) = self.vector_of(id) else {
+        let Some(index) = self.index.as_ref() else {
             return Vec::new();
         };
-        let mut scored: Vec<(NoteId, f32)> = self
-            .entries
-            .iter()
-            .filter(|(eid, _)| *eid != id)
-            .filter_map(|(eid, v)| cosine(base, v).map(|s| (*eid, s)))
+        let Some(base) = index.get(&id) else {
+            return Vec::new();
+        };
+        let Ok(query) = EmbeddingVector::new(index.model().clone(), base.to_vec()) else {
+            return Vec::new();
+        };
+        // top_k = todo (rankeamos todas, luego truncamos con desempate estable);
+        // min_score = -1.0 no filtra nada (coseno ∈ [-1, 1]), como el original.
+        let Ok(hits) = index.search(&query, index.len(), -1.0) else {
+            return Vec::new();
+        };
+        let mut scored: Vec<(NoteId, f32)> = hits
+            .into_iter()
+            .filter(|h| h.key != id)
+            .map(|h| (h.key, h.score))
             .collect();
         scored.sort_by(|a, b| {
             b.1.partial_cmp(&a.1)
@@ -204,11 +276,82 @@ impl SemanticField {
         scored
     }
 
+    /// Le da a `id` un domicilio fijo, **una sola vez**: cae en el baricentro
+    /// de sus parientes semánticos (ponderado por afinidad) y, si quedó pegada
+    /// a otra nota, se separa apenas. `placed` son las notas ya asentadas con su
+    /// posición de mundo. Determinista y dependiente sólo de lo ya colocado, así
+    /// el orden de inserción es estable y el mapa nunca se reacomoda solo.
+    ///
+    /// Es el anclaje incremental del campo semántico: vivía atrapado en
+    /// `khipu-app::map::place_note` (frontend); el algoritmo baja aquí para que
+    /// cualquier frontend lo reuse (Regla 2). El frontend sólo lee posiciones y
+    /// escribe la resultante en su store.
+    pub fn anchor_new(&self, id: NoteId, placed: &[(NoteId, (f32, f32))]) -> (f32, f32) {
+        // Vecinos ya colocados: su afinidad con la nota nueva y su posición.
+        let mut kin: Vec<(f32, (f32, f32))> = Vec::new();
+        for (other, pos) in placed {
+            if *other == id {
+                continue;
+            }
+            let aff = self.affinity(id, *other).unwrap_or(0.0).max(0.0);
+            kin.push((aff, *pos));
+        }
+
+        let target = if kin.is_empty() {
+            (0.0, 0.0) // primera nota del cuaderno: centro del mundo.
+        } else {
+            let wsum: f32 = kin.iter().map(|(w, _)| *w).sum();
+            if wsum > 1e-3 {
+                // Cae junto a su parentela: baricentro ponderado por afinidad.
+                let (mut tx, mut ty) = (0.0_f32, 0.0_f32);
+                for (w, (x, y)) in &kin {
+                    tx += w * x;
+                    ty += w * y;
+                }
+                (tx / wsum, ty / wsum)
+            } else {
+                // Ortogonal a todo: anillo determinista por id, lejos del núcleo.
+                let ang = id as f32 * ANCHOR_GOLDEN_ANGLE;
+                let rad = 180.0 + 14.0 * (id as f32).sqrt();
+                (rad * ang.cos(), rad * ang.sin())
+            }
+        };
+
+        // Separación: empuja el target hasta despegarlo de cada vecino cercano.
+        let mut p = target;
+        for _ in 0..12 {
+            let mut moved = false;
+            for (_, q) in &kin {
+                let dx = p.0 - q.0;
+                let dy = p.1 - q.1;
+                let d = (dx * dx + dy * dy).sqrt();
+                if d < ANCHOR_MIN_SEP {
+                    let (ux, uy) = if d > 1e-3 {
+                        (dx / d, dy / d)
+                    } else {
+                        let a = id as f32 * ANCHOR_GOLDEN_ANGLE;
+                        (a.cos(), a.sin())
+                    };
+                    let push = ANCHOR_MIN_SEP - d;
+                    p.0 += ux * push;
+                    p.1 += uy * push;
+                    moved = true;
+                }
+            }
+            if !moved {
+                break;
+            }
+        }
+        p
+    }
+
     /// Agrupa las notas en clústeres: dos notas quedan en el mismo grupo
     /// si su afinidad alcanza `threshold` (transitivamente). Cada
     /// clúster viene ordenado por id, y la lista de clústeres también.
     pub fn clusters(&self, threshold: f32) -> Vec<Vec<NoteId>> {
-        let n = self.entries.len();
+        // Necesita **todos** los vectores: los toma del índice en orden estable.
+        let entries: Vec<(NoteId, &[f32])> = self.iter().collect();
+        let n = entries.len();
         let mut parent: Vec<usize> = (0..n).collect();
 
         fn find(parent: &mut [usize], i: usize) -> usize {
@@ -227,7 +370,7 @@ impl SemanticField {
 
         for i in 0..n {
             for j in (i + 1)..n {
-                let sim = cosine(&self.entries[i].1, &self.entries[j].1).unwrap_or(0.0);
+                let sim = cosine(entries[i].1, entries[j].1).unwrap_or(0.0);
                 if sim >= threshold {
                     let (ri, rj) = (find(&mut parent, i), find(&mut parent, j));
                     if ri != rj {
@@ -240,7 +383,7 @@ impl SemanticField {
         let mut groups: std::collections::BTreeMap<usize, Vec<NoteId>> = Default::default();
         for i in 0..n {
             let root = find(&mut parent, i);
-            groups.entry(root).or_default().push(self.entries[i].0);
+            groups.entry(root).or_default().push(entries[i].0);
         }
         let mut out: Vec<Vec<NoteId>> = groups.into_values().collect();
         for c in &mut out {
@@ -253,12 +396,14 @@ impl SemanticField {
     /// Layout 2D por gravedad: las notas afines se atraen, todas se
     /// repelen. Determinista — posiciones iniciales en círculo, sin RNG.
     pub fn gravity_layout(&self, cfg: &GravityConfig) -> Vec<NotePlacement> {
-        let n = self.entries.len();
+        // Layout sobre todos los vectores, en orden de inserción estable.
+        let entries: Vec<(NoteId, &[f32])> = self.iter().collect();
+        let n = entries.len();
         if n == 0 {
             return Vec::new();
         }
         if n == 1 {
-            return vec![NotePlacement { id: self.entries[0].0, x: 0.0, y: 0.0 }];
+            return vec![NotePlacement { id: entries[0].0, x: 0.0, y: 0.0 }];
         }
 
         // Posiciones iniciales repartidas en un círculo.
@@ -273,9 +418,7 @@ impl SemanticField {
         let mut aff = vec![0.0f32; n * n];
         for i in 0..n {
             for j in (i + 1)..n {
-                let s = cosine(&self.entries[i].1, &self.entries[j].1)
-                    .unwrap_or(0.0)
-                    .max(0.0);
+                let s = cosine(entries[i].1, entries[j].1).unwrap_or(0.0).max(0.0);
                 aff[i * n + j] = s;
                 aff[j * n + i] = s;
             }
@@ -307,7 +450,7 @@ impl SemanticField {
             }
         }
 
-        self.entries
+        entries
             .iter()
             .zip(pos)
             .map(|((id, _), (x, y))| NotePlacement { id: *id, x, y })
@@ -315,6 +458,76 @@ impl SemanticField {
     }
 }
 
+// =====================================================================
+// Masa de cuerda — la física de la resonancia
+// =====================================================================
+
+/// Vida media por defecto de la frescura de una cuerda: 30 días. Una
+/// cuerda que no crece en un mes arde a la mitad; nunca se apaga del
+/// todo ([`frescura`] tiene piso en el caller, no aquí).
+pub const FRESCURA_MEDIA_VIDA_SECS: f32 = 30.0 * 24.0 * 3600.0;
+
+/// Masa estructural de una cuerda: crece con cada nudo anudado, sin
+/// techo duro pero con retornos decrecientes (logarítmico) — una cuerda
+/// de 10 nudos no aplasta visualmente a una de 4. `0` nudos = masa `0`.
+pub fn masa_cuerda(nudos: usize) -> f32 {
+    libm::logf(1.0 + nudos as f32)
+}
+
+/// Frescura de una cuerda en `[0, 1]`: `1.0` si acaba de crecer
+/// (`ultimo_nudo == now`), decae exponencialmente con `media_vida_secs`.
+/// No es olvido — la cuerda conserva su masa; sólo modula el brillo
+/// («lo que crece arde, lo quieto reposa»). `now` anterior al último
+/// nudo o media vida no positiva devuelven `1.0`.
+pub fn frescura(ultimo_nudo: u64, now: u64, media_vida_secs: f32) -> f32 {
+    if now <= ultimo_nudo || media_vida_secs <= 0.0 {
+        return 1.0;
+    }
+    let dt = (now - ultimo_nudo) as f32;
+    let k = core::f32::consts::LN_2 / media_vida_secs;
+    libm::expf(-k * dt).clamp(0.0, 1.0)
+}
+
+/// Separación mínima entre nodos al anclar uno nuevo (coordenadas de mundo).
+pub const ANCHOR_MIN_SEP: f32 = 30.0;
+/// Ángulo áureo en radianes — reparte determinísticamente lo que no tiene
+/// parentela semántica sin amontonarlo.
+const ANCHOR_GOLDEN_ANGLE: f32 = 2.399_963_2;
+
+/// Embedder local de fallback: hash trigram → R^dim con signos +/-1 (random
+/// projection 1-bit signed), normalizado por L2. Determinista, independiente de
+/// idioma, sin red. Es el vector que alimenta a [`SemanticField`] cuando el
+/// daemon de verbo no está; vivía atrapado en `khipu-app::estado::embed`
+/// (frontend) y baja aquí, junto a los vectores que produce (Regla 2).
+pub fn local_embed(text: &str, dim: usize) -> Vec<f32> {
+    let mut v = vec![0.0f32; dim];
+    let lower = text.to_lowercase();
+    let bytes = lower.as_bytes();
+    if bytes.len() < 3 {
+        for (i, b) in bytes.iter().enumerate() {
+            v[i % dim] += *b as f32 / 255.0;
+        }
+    } else {
+        for w in bytes.windows(3) {
+            let mut h: u64 = 0xcbf29ce484222325;
+            for b in w {
+                h ^= *b as u64;
+                h = h.wrapping_mul(0x100000001b3);
+            }
+            let idx = (h as usize) % dim;
+            let sign = if h & 1 == 0 { 1.0 } else { -1.0 };
+            v[idx] += sign;
+        }
+    }
+    let n: f32 = v.iter().map(|x| x * x).sum::<f32>().sqrt();
+    if n > 0.0 {
+        for x in &mut v {
+            *x /= n;
+        }
+    }
+    v
+}
+
 #[cfg(test)]
 mod tests {
     use super::*;
@@ -328,6 +541,19 @@ mod tests {
         f
     }
 
+    #[test]
+    fn etiqueta_distinta_no_mezcla_espacios() {
+        // El agujero cerrado en el Arco II #11: misma dimensión, otro
+        // embebedor → antes se mezclaba sin detectarlo; ahora se rechaza.
+        let mut f = SemanticField::new();
+        assert!(f.insert_etiquetado(1, vec![1.0, 0.0, 0.0], "e5-small(384d)"));
+        assert!(!f.insert_etiquetado(2, vec![0.0, 1.0, 0.0], "mock-3d"));
+        assert_eq!(f.len(), 1, "el vector del otro espacio no entra");
+        // El mismo espacio sigue entrando.
+        assert!(f.insert_etiquetado(2, vec![0.0, 1.0, 0.0], "e5-small(384d)"));
+        assert_eq!(f.len(), 2);
+    }
+
     #[test]
     fn affinity_is_high_for_aligned_vectors() {
         let f = field();
@@ -431,6 +657,42 @@ mod tests {
     }
 }
 
+#[cfg(test)]
+mod cuerda_tests {
+    use super::*;
+
+    #[test]
+    fn masa_cuerda_grows_with_diminishing_returns() {
+        assert_eq!(masa_cuerda(0), 0.0);
+        let m2 = masa_cuerda(2);
+        let m4 = masa_cuerda(4);
+        let m8 = masa_cuerda(8);
+        assert!(m2 > 0.0 && m4 > m2 && m8 > m4);
+        // Retornos decrecientes: cada nudo nuevo suma menos que el anterior.
+        assert!(masa_cuerda(5) - masa_cuerda(4) < masa_cuerda(2) - masa_cuerda(1));
+    }
+
+    #[test]
+    fn frescura_is_full_at_growth_and_halves_at_half_life() {
+        assert!((frescura(1_000, 1_000, 100.0) - 1.0).abs() < 1e-6);
+        let f = frescura(0, 100, 100.0);
+        assert!((f - 0.5).abs() < 1e-3);
+    }
+
+    #[test]
+    fn frescura_handles_clock_skew_and_bad_half_life() {
+        // Reloj hacia atrás (RTC roto): frescura plena, nunca panic.
+        assert_eq!(frescura(2_000, 1_000, 100.0), 1.0);
+        assert_eq!(frescura(0, 1_000, 0.0), 1.0);
+    }
+
+    #[test]
+    fn frescura_stays_in_unit_range_long_term() {
+        let f = frescura(0, 10_000_000_000, FRESCURA_MEDIA_VIDA_SECS);
+        assert!((0.0..=1.0).contains(&f));
+    }
+}
+
 #[cfg(test)]
 mod mass_tests {
     use super::*;
diff --git a/00_unanchay/khipu/khipu-share/LEEME.md b/00_unanchay/khipu/khipu-share/LEEME.md
new file mode 100644
index 0000000..28fefab
--- /dev/null
+++ b/00_unanchay/khipu/khipu-share/LEEME.md
@@ -0,0 +1,37 @@
+# khipu-share
+
+*Read this in English: [README.md](README.md).*
+
+Compartir notas por la red soberana de tawasuyu sin perder su gravedad local.
+
+Lo que viaja es el **contenido** de la nota (título, cuerpo, etiquetas),
+nunca su física temporal: la masa y el último acceso son la atención
+*de quien tiene la nota*, no una propiedad transferible. Al importar,
+cada nota nace fresca (`mass = 1.0`, `last_access = now`) — su gravedad
+arranca en el cuaderno que la recibe.
+
+El sobre es:
+- **direccionado por contenido**: su identidad es `BLAKE3(postcard(bundle))`,
+  así dos sobres con las mismas notas tienen el mismo hash;
+- **firmado Ed25519** sobre ese hash con la clave del autor, vía
+  `agora_core` — verificable sin autoridad central ni red.
+```
+use agora_core::Keypair;
+use khipu_share::{seal, open, SharedNote};
+
+let kp = Keypair::from_seed([7u8; 32]);
+let notas = vec![SharedNote {
+    title: "Receta".into(),
+    body: "sopa; ver [[Mercado]]".into(),
+    tags: vec!["cocina".into()],
+}];
+let sobre = seal(&kp, notas, 1_700_000_000).unwrap();
+
+// El receptor verifica firma + hash antes de confiar en el contenido.
+let bundle = open(&sobre).unwrap();
+assert_eq!(bundle.notes[0].title, "Receta");
+```
+
+---
+
+Parte de **khipu** — ver [khipu](../LEEME.md).
diff --git a/00_unanchay/khipu/khipu-share/README.md b/00_unanchay/khipu/khipu-share/README.md
new file mode 100644
index 0000000..b98514e
--- /dev/null
+++ b/00_unanchay/khipu/khipu-share/README.md
@@ -0,0 +1,11 @@
+# khipu-share
+
+Sharing notes over tawasuyu's sovereign network without losing their local gravity.
+
+What travels is the note's **content** (title, body, tags), never its temporal
+physics: mass and last access are the attention *of whoever holds the note*, not a
+transferable property. On import, the note starts its own gravity.
+
+---
+
+Part of **khipu** — see [khipu](../README.md).
diff --git a/00_unanchay/khipu/khipu-share/src/lib.rs b/00_unanchay/khipu/khipu-share/src/lib.rs
index 65ab1c0..a1bfb6e 100644
--- a/00_unanchay/khipu/khipu-share/src/lib.rs
+++ b/00_unanchay/khipu/khipu-share/src/lib.rs
@@ -1,4 +1,4 @@
-//! `khipu-share` — compartir notas por la red soberana de gioser sin
+//! `khipu-share` — compartir notas por la red soberana de tawasuyu sin
 //! perder su gravedad local.
 //!
 //! Lo que viaja es el **contenido** de la nota (título, cuerpo, etiquetas),
diff --git a/00_unanchay/khipu/pantallazo.png b/00_unanchay/khipu/pantallazo.png
new file mode 100644
index 0000000..2f74749
Binary files /dev/null and b/00_unanchay/khipu/pantallazo.png differ
diff --git a/Cargo.lock b/Cargo.lock
index af98f3e..975569a 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -18,6 +18,114 @@ version = "0.1.10"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "366ffbaa4442f4684d91e2cd7c5ea7c4ed8add41959a31447066e279e432b618"
 
+[[package]]
+name = "accesskit"
+version = "0.24.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d3b7f7f85a7e5f68090000ed7622545829afd484d210358702ae4cb97dd0c320"
+dependencies = [
+ "uuid",
+]
+
+[[package]]
+name = "accesskit_atspi_common"
+version = "0.19.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "023da0e5097f46df7092d5280b02efb9bbf8d93298daeced42652463e357d636"
+dependencies = [
+ "accesskit",
+ "accesskit_consumer",
+ "atspi-common",
+ "phf",
+ "serde",
+ "zvariant",
+]
+
+[[package]]
+name = "accesskit_consumer"
+version = "0.38.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5d10a236f96f87d70732e44520046785431ef01d5bcd6b041317bfadd2f88245"
+dependencies = [
+ "accesskit",
+ "hashbrown 0.16.1",
+]
+
+[[package]]
+name = "accesskit_ios"
+version = "0.1.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "750c4e9f6ce888dfe8a10c0f1b5ceb646a7854fd46579d74919219d1bb314083"
+dependencies = [
+ "accesskit",
+ "accesskit_consumer",
+ "hashbrown 0.16.1",
+ "objc2 0.5.2",
+ "objc2-foundation 0.2.2",
+ "objc2-ui-kit",
+]
+
+[[package]]
+name = "accesskit_macos"
+version = "0.26.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ce02dc63b43f0c9296af9ac946312a2dc8814427d7a64d2d600971dac55b6076"
+dependencies = [
+ "accesskit",
+ "accesskit_consumer",
+ "hashbrown 0.16.1",
+ "objc2 0.5.2",
+ "objc2-app-kit 0.2.2",
+ "objc2-foundation 0.2.2",
+]
+
+[[package]]
+name = "accesskit_unix"
+version = "0.22.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "03e156ed3802e35eefe894ef2671bc6c889303d8a7e110b5e1b48f504b91362f"
+dependencies = [
+ "accesskit",
+ "accesskit_atspi_common",
+ "async-channel",
+ "async-executor",
+ "async-task",
+ "atspi",
+ "futures-lite",
+ "futures-util",
+ "serde",
+ "zbus",
+]
+
+[[package]]
+name = "accesskit_windows"
+version = "0.34.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "106c2b961215864d1c2e703ee63269c25c4e80a577ffb2c1017b9c17dcdf83a1"
+dependencies = [
+ "accesskit",
+ "accesskit_consumer",
+ "hashbrown 0.16.1",
+ "static_assertions",
+ "windows 0.62.2",
+ "windows-core 0.62.2",
+]
+
+[[package]]
+name = "accesskit_winit"
+version = "0.33.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d5b41e63a69f36d9f1f41e70464c7e5f72eee485ef26aab19f0b4f86e6c0a84c"
+dependencies = [
+ "accesskit",
+ "accesskit_ios",
+ "accesskit_macos",
+ "accesskit_unix",
+ "accesskit_windows",
+ "raw-window-handle",
+ "winit",
+]
+
 [[package]]
 name = "adler2"
 version = "2.0.1"
@@ -62,27 +170,28 @@ dependencies = [
 [[package]]
 name = "agora-core"
 version = "0.1.0"
-source = "git+https://gitea.tawasuyu.net/sergio/gioser.git?tag=v0.1.0#692b40336c1e9cb12ab561764d6943cb65905ab3"
+source = "git+https://git.tawasuyu.net/tawasuyu/tawasuyu.git?tag=v0.2.0#a446bb3a73a0e022158cc0ec7deb65173859644b"
 dependencies = [
  "blake3",
  "ed25519-dalek",
  "format",
  "serde",
- "thiserror 2.0.18",
+ "thiserror 2.0.19",
+ "umbral",
 ]
 
 [[package]]
 name = "agora-keystore"
 version = "0.1.0"
-source = "git+https://gitea.tawasuyu.net/sergio/gioser.git?tag=v0.1.0#692b40336c1e9cb12ab561764d6943cb65905ab3"
+source = "git+https://git.tawasuyu.net/tawasuyu/tawasuyu.git?tag=v0.2.0#a446bb3a73a0e022158cc0ec7deb65173859644b"
 dependencies = [
  "aead",
  "agora-core",
  "argon2",
  "chacha20poly1305",
  "directories",
- "rand 0.8.6",
- "thiserror 2.0.18",
+ "rand 0.8.7",
+ "thiserror 2.0.19",
 ]
 
 [[package]]
@@ -120,16 +229,16 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "0f2a1bb052857d5dd49572219344a7332b31b76405648eabac5bc68978251bcd"
 dependencies = [
  "android-properties",
- "bitflags 2.12.1",
+ "bitflags 2.13.1",
  "cc",
  "jni",
  "libc",
  "log",
  "ndk",
  "ndk-context",
- "ndk-sys 0.6.0+11769913",
+ "ndk-sys",
  "num_enum",
- "thiserror 2.0.18",
+ "thiserror 2.0.19",
 ]
 
 [[package]]
@@ -149,14 +258,14 @@ dependencies = [
 
 [[package]]
 name = "anyhow"
-version = "1.0.102"
+version = "1.0.104"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c"
+checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470"
 
 [[package]]
 name = "app-bus"
 version = "0.1.0"
-source = "git+https://gitea.tawasuyu.net/sergio/gioser.git?tag=v0.1.0#692b40336c1e9cb12ab561764d6943cb65905ab3"
+source = "git+https://git.tawasuyu.net/tawasuyu/tawasuyu.git?tag=v0.2.0#a446bb3a73a0e022158cc0ec7deb65173859644b"
 dependencies = [
  "directories",
  "serde",
@@ -180,6 +289,7 @@ dependencies = [
  "parking_lot",
  "percent-encoding",
  "windows-sys 0.60.2",
+ "wl-clipboard-rs",
  "x11rb",
 ]
 
@@ -203,9 +313,9 @@ checksum = "76a2e8124351fda1ef8aaaa3bbd7ebbcb486bbcd4225aca0aa0d84bb2db8fecb"
 
 [[package]]
 name = "arrayvec"
-version = "0.7.6"
+version = "0.7.8"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "7c02d123df017efcdfbd739ef81735b36c5ba83ec3c59c80a9d7ecc718f92e50"
+checksum = "d3fb67a6e08acf24fdeccbac2cb6ac4305825bd1f117462e0e6f2f193345ad56"
 
 [[package]]
 name = "as-raw-xcb-connection"
@@ -231,10 +341,10 @@ dependencies = [
  "asn1-rs-derive",
  "asn1-rs-impl",
  "displaydoc",
- "nom",
+ "nom 7.1.3",
  "num-traits",
  "rusticata-macros",
- "thiserror 2.0.18",
+ "thiserror 2.0.19",
  "time",
 ]
 
@@ -246,7 +356,7 @@ checksum = "3109e49b1e4909e9db6515a30c633684d68cdeaa252f215214cb4fa1a5bfee2c"
 dependencies = [
  "proc-macro2",
  "quote",
- "syn",
+ "syn 2.0.119",
  "synstructure",
 ]
 
@@ -258,7 +368,45 @@ checksum = "7b18050c2cd6fe86c3a76584ef5e0baf286d038cda203eb6223df2cc413565f7"
 dependencies = [
  "proc-macro2",
  "quote",
- "syn",
+ "syn 2.0.119",
+]
+
+[[package]]
+name = "async-broadcast"
+version = "0.7.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "435a87a52755b8f27fcf321ac4f04b2802e337c8c4872923137471ec39c37532"
+dependencies = [
+ "event-listener",
+ "event-listener-strategy",
+ "futures-core",
+ "pin-project-lite",
+]
+
+[[package]]
+name = "async-channel"
+version = "2.5.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "924ed96dd52d1b75e9c1a3e6275715fd320f5f9439fb5a4a11fa51f4221158d2"
+dependencies = [
+ "concurrent-queue",
+ "event-listener-strategy",
+ "futures-core",
+ "pin-project-lite",
+]
+
+[[package]]
+name = "async-executor"
+version = "1.14.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c96bf972d85afc50bf5ab8fe2d54d1586b4e0b46c97c50a0c9e71e2f7bcd812a"
+dependencies = [
+ "async-task",
+ "concurrent-queue",
+ "fastrand",
+ "futures-lite",
+ "pin-project-lite",
+ "slab",
 ]
 
 [[package]]
@@ -279,15 +427,79 @@ dependencies = [
  "windows-sys 0.61.2",
 ]
 
+[[package]]
+name = "async-lock"
+version = "3.4.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "290f7f2596bd5b78a9fec8088ccd89180d7f9f55b94b0576823bbbdc72ee8311"
+dependencies = [
+ "event-listener",
+ "event-listener-strategy",
+ "pin-project-lite",
+]
+
+[[package]]
+name = "async-process"
+version = "2.5.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "fc50921ec0055cdd8a16de48773bfeec5c972598674347252c0399676be7da75"
+dependencies = [
+ "async-channel",
+ "async-io",
+ "async-lock",
+ "async-signal",
+ "async-task",
+ "blocking",
+ "cfg-if",
+ "event-listener",
+ "futures-lite",
+ "rustix 1.1.4",
+]
+
+[[package]]
+name = "async-recursion"
+version = "1.1.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3b43422f69d8ff38f95f1b2bb76517c91589a924d1559a0e935d7c8ce0274c11"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 2.0.119",
+]
+
+[[package]]
+name = "async-signal"
+version = "0.2.14"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "52b5aaafa020cf5053a01f2a60e8ff5dccf550f0f77ec54a4e47285ac2bab485"
+dependencies = [
+ "async-io",
+ "async-lock",
+ "atomic-waker",
+ "cfg-if",
+ "futures-core",
+ "futures-io",
+ "rustix 1.1.4",
+ "signal-hook-registry",
+ "slab",
+ "windows-sys 0.61.2",
+]
+
+[[package]]
+name = "async-task"
+version = "4.7.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8b75356056920673b02621b35afd0f7dda9306d03c79a30f5c56c44cf256e3de"
+
 [[package]]
 name = "async-trait"
-version = "0.1.89"
+version = "0.1.91"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb"
+checksum = "ae36dc4177970ef04fde5178d3e2429882def40e57a451f919c098f72baa6cec"
 dependencies = [
  "proc-macro2",
  "quote",
- "syn",
+ "syn 3.0.3",
 ]
 
 [[package]]
@@ -318,6 +530,43 @@ version = "1.1.2"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0"
 
+[[package]]
+name = "atspi"
+version = "0.29.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c77886257be21c9cd89a4ae7e64860c6f0eefca799bb79127913052bd0eefb3d"
+dependencies = [
+ "atspi-common",
+ "atspi-proxies",
+]
+
+[[package]]
+name = "atspi-common"
+version = "0.13.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "20c5617155740c98003016429ad13fe43ce7a77b007479350a9f8bf95a29f63d"
+dependencies = [
+ "enumflags2",
+ "serde",
+ "static_assertions",
+ "zbus",
+ "zbus-lockstep",
+ "zbus-lockstep-macros",
+ "zbus_names",
+ "zvariant",
+]
+
+[[package]]
+name = "atspi-proxies"
+version = "0.13.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2230e48787ed3eb4088996eab66a32ca20c0b67bbd4fd6cdfe79f04f1f04c9fc"
+dependencies = [
+ "atspi-common",
+ "serde",
+ "zbus",
+]
+
 [[package]]
 name = "attohttpc"
 version = "0.30.1"
@@ -352,6 +601,12 @@ dependencies = [
  "match-lookup",
 ]
 
+[[package]]
+name = "base45"
+version = "3.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "240e56f4d3c453c36faacb695c535a4d5f8c7d23dac175014f32eb0a71012a03"
+
 [[package]]
 name = "base64"
 version = "0.22.1"
@@ -387,12 +642,9 @@ checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a"
 
 [[package]]
 name = "bitflags"
-version = "2.12.1"
+version = "2.13.1"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "84d7ced0ae9557296835c32bf1b1e02b44c746701f898460fb000d7eaa84f00a"
-dependencies = [
- "serde_core",
-]
+checksum = "b588b76d00fde79687d7646a9b5bdf3cc0f655e0bbd080335a95d7e96f3587da"
 
 [[package]]
 name = "blake2"
@@ -441,6 +693,19 @@ dependencies = [
  "objc2 0.5.2",
 ]
 
+[[package]]
+name = "blocking"
+version = "1.6.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e83f8d02be6967315521be875afa792a316e28d57b5a2d401897e2a7921b7f21"
+dependencies = [
+ "async-channel",
+ "async-task",
+ "futures-io",
+ "futures-lite",
+ "piper",
+]
+
 [[package]]
 name = "bs58"
 version = "0.5.1"
@@ -458,22 +723,22 @@ checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649"
 
 [[package]]
 name = "bytemuck"
-version = "1.25.0"
+version = "1.25.2"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "c8efb64bd706a16a1bdde310ae86b351e4d21550d98d056f22f8a7f7a2183fec"
+checksum = "95832e849adfb21180ccb6826a99da14e5d266ae5c2e668e1602cf234f153797"
 dependencies = [
  "bytemuck_derive",
 ]
 
 [[package]]
 name = "bytemuck_derive"
-version = "1.10.2"
+version = "1.11.0"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "f9abbd1bc6865053c427f7198e6af43bfdedc55ab791faed4fbd361d789575ff"
+checksum = "f65693059b6b9c588b9f62fed1cedbf0a8b805631457ea162d68f0de186f3de5"
 dependencies = [
  "proc-macro2",
  "quote",
- "syn",
+ "syn 2.0.119",
 ]
 
 [[package]]
@@ -490,9 +755,9 @@ checksum = "8f1fe948ff07f4bd06c30984e69f5b4899c516a3ef74f34df92a2df2ab535495"
 
 [[package]]
 name = "bytes"
-version = "1.11.1"
+version = "1.12.1"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33"
+checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04"
 
 [[package]]
 name = "calloop"
@@ -500,7 +765,7 @@ version = "0.13.0"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "b99da2f8558ca23c71f4fd15dc57c906239752dd27ff3c00a1d56b685b7cbfec"
 dependencies = [
- "bitflags 2.12.1",
+ "bitflags 2.13.1",
  "log",
  "polling",
  "rustix 0.38.44",
@@ -523,7 +788,7 @@ dependencies = [
 [[package]]
 name = "card-net"
 version = "0.1.0"
-source = "git+https://gitea.tawasuyu.net/sergio/gioser.git?tag=v0.1.0#692b40336c1e9cb12ab561764d6943cb65905ab3"
+source = "git+https://git.tawasuyu.net/tawasuyu/tawasuyu.git?tag=v0.2.0#a446bb3a73a0e022158cc0ec7deb65173859644b"
 dependencies = [
  "blake3",
  "futures",
@@ -531,15 +796,15 @@ dependencies = [
  "libp2p-allow-block-list",
  "libp2p-stream",
  "serde",
- "thiserror 2.0.18",
+ "thiserror 2.0.19",
  "tokio",
 ]
 
 [[package]]
 name = "cc"
-version = "1.2.63"
+version = "1.4.0"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "556e016178bb5662a08681bbe0f00f8e17631781a4dfc8c45e466e4b185ec27f"
+checksum = "5add81bb678e6cb321aff7fa0dc7689ad82b112dbc032cea19f91d6b8e3582b9"
 dependencies = [
  "find-msvc-tools",
  "jobserver",
@@ -555,9 +820,9 @@ checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
 
 [[package]]
 name = "cfg_aliases"
-version = "0.2.1"
+version = "0.2.2"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724"
+checksum = "f079e83a288787bcd14a6aea84cee5c87a67c5a3e660c30f557a3d24761b3527"
 
 [[package]]
 name = "chacha20"
@@ -570,6 +835,17 @@ dependencies = [
  "cpufeatures 0.2.17",
 ]
 
+[[package]]
+name = "chacha20"
+version = "0.10.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d524456ba66e72eb8b115ff89e01e497f8e6d11d78b70b1aa13c0fbd97540a81"
+dependencies = [
+ "cfg-if",
+ "cpufeatures 0.3.0",
+ "rand_core 0.10.1",
+]
+
 [[package]]
 name = "chacha20poly1305"
 version = "0.10.1"
@@ -577,7 +853,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "10cd79432192d1c0f4e1a0fef9527696cc039165d729fb41b3f4f4f354c2dc35"
 dependencies = [
  "aead",
- "chacha20",
+ "chacha20 0.9.1",
  "cipher",
  "poly1305",
  "zeroize",
@@ -609,15 +885,16 @@ version = "0.3.0"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "0fa961b519f0b462e3a3b4a34b64d119eeaca1d59af726fe450bbba07a9fc0a1"
 dependencies = [
- "thiserror 2.0.18",
+ "thiserror 2.0.19",
 ]
 
 [[package]]
 name = "codespan-reporting"
-version = "0.11.1"
+version = "0.12.0"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "3538270d33cc669650c4b093848450d380def10c331d38c768e34cac80576e6e"
+checksum = "fe6d2e5af09e8c8ad56c969f2157a3d4238cebc7c55f0a517728c38f7b200f81"
 dependencies = [
+ "serde",
  "termcolor",
  "unicode-width",
 ]
@@ -675,6 +952,16 @@ 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"
@@ -688,9 +975,9 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "c07782be35f9e1140080c6b96f0d44b739e2278479f64e02fdab4e32dfd8b081"
 dependencies = [
  "bitflags 1.3.2",
- "core-foundation",
- "core-graphics-types",
- "foreign-types",
+ "core-foundation 0.9.4",
+ "core-graphics-types 0.1.3",
+ "foreign-types 0.5.0",
  "libc",
 ]
 
@@ -701,10 +988,30 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "45390e6114f68f718cc7a830514a96f903cccd70d02a8f6d9f643ac4ba45afaf"
 dependencies = [
  "bitflags 1.3.2",
- "core-foundation",
+ "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",
  "libc",
 ]
 
+[[package]]
+name = "core_maths"
+version = "0.1.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "77745e017f5edba1a9c1d854f6f3a52dac8a12dd5af5d2f54aecf61e43d80d30"
+dependencies = [
+ "libm",
+]
+
 [[package]]
 name = "cpufeatures"
 version = "0.2.17"
@@ -740,27 +1047,27 @@ checksum = "790eea4361631c5e7d22598ecd5723ff611904e3344ce8720784c93e3d83d40b"
 
 [[package]]
 name = "crossbeam-channel"
-version = "0.5.15"
+version = "0.5.16"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "82b8f8f868b36967f9606790d1903570de9ceaf870a7bf9fbbd3016d636a2cb2"
+checksum = "d85363c37faeca707aef026efa9f3b34d077bce547e48f770770625c6013679e"
 dependencies = [
  "crossbeam-utils",
 ]
 
 [[package]]
 name = "crossbeam-epoch"
-version = "0.9.18"
+version = "0.9.20"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "5b82ac4a3c2ca9c3460964f020e1402edd5753411d7737aa39c3714ad1b5420e"
+checksum = "2d6914041f254d6e9176c01941b21115dcfb7089e55135a35411081bd106ef3f"
 dependencies = [
  "crossbeam-utils",
 ]
 
 [[package]]
 name = "crossbeam-utils"
-version = "0.8.21"
+version = "0.8.22"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28"
+checksum = "61803da095bee82a81bb1a452ecc25d3b2f1416d1897eb86430c6159ef717c17"
 
 [[package]]
 name = "crunchy"
@@ -818,7 +1125,7 @@ checksum = "f46882e17999c6cc590af592290432be3bce0428cb0d5f8b6715e4dc7b383eb3"
 dependencies = [
  "proc-macro2",
  "quote",
- "syn",
+ "syn 2.0.119",
 ]
 
 [[package]]
@@ -844,7 +1151,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "ccc2776f0c61eca1ca32528f85548abd1a4be8fb53d1b21c013e4f18da1e7090"
 dependencies = [
  "data-encoding",
- "syn",
+ "syn 2.0.119",
 ]
 
 [[package]]
@@ -865,7 +1172,7 @@ checksum = "07da5016415d5a3c4dd39b11ed26f915f52fc4e0dc197d87908bc916e51bc1a6"
 dependencies = [
  "asn1-rs",
  "displaydoc",
- "nom",
+ "nom 7.1.3",
  "num-bigint",
  "num-traits",
  "rusticata-macros",
@@ -876,9 +1183,6 @@ name = "deranged"
 version = "0.5.8"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c"
-dependencies = [
- "powerfmt",
-]
 
 [[package]]
 name = "digest"
@@ -924,7 +1228,7 @@ version = "0.3.1"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "1e0e367e4e7da84520dedcac1901e4da967309406d1e51017ae1abfb97adbd38"
 dependencies = [
- "bitflags 2.12.1",
+ "bitflags 2.13.1",
  "objc2 0.6.4",
 ]
 
@@ -936,7 +1240,7 @@ checksum = "1ac70aa55017e108007fbaf5aa0f54b021c98f92ff8af59d42eda9da96e3dd4f"
 dependencies = [
  "proc-macro2",
  "quote",
- "syn",
+ "syn 2.0.119",
 ]
 
 [[package]]
@@ -985,6 +1289,12 @@ dependencies = [
  "signature",
 ]
 
+[[package]]
+name = "ed25519-compact"
+version = "2.3.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5c24599140dc39d7a81e4476e7573d41bbc18e07c803900298e522a5fbcfbfb6"
+
 [[package]]
 name = "ed25519-dalek"
 version = "2.2.0"
@@ -1001,9 +1311,9 @@ dependencies = [
 
 [[package]]
 name = "either"
-version = "1.16.0"
+version = "1.17.0"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "91622ff5e7162018101f2fea40d6ebf4a78bbe5a49736a2020649edf9693679e"
+checksum = "9e5e8f6c15a24b9a3ee5efec809ccd006d3b30e8b3bb63c39af737c7f87daa1d"
 
 [[package]]
 name = "embedded-io"
@@ -1017,6 +1327,21 @@ version = "0.6.1"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "edd0f118536f44f5ccd48bcb8b111bdc3de888b58c74639dfb034a357d0f206d"
 
+[[package]]
+name = "encoding_rs"
+version = "0.8.35"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "75030f3c4f45dafd7586dd6780965a8c7e8e285a5ecb86713e63a79c5b2766f3"
+dependencies = [
+ "cfg-if",
+]
+
+[[package]]
+name = "endi"
+version = "1.1.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "66b7e2430c6dff6a955451e2cfc438f09cea1965a9d6f87f7e3b90decc014099"
+
 [[package]]
 name = "enum-as-inner"
 version = "0.6.1"
@@ -1026,7 +1351,28 @@ dependencies = [
  "heck",
  "proc-macro2",
  "quote",
- "syn",
+ "syn 2.0.119",
+]
+
+[[package]]
+name = "enumflags2"
+version = "0.7.12"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1027f7680c853e056ebcec683615fb6fbbc07dbaa13b4d5d9442b146ded4ecef"
+dependencies = [
+ "enumflags2_derive",
+ "serde",
+]
+
+[[package]]
+name = "enumflags2_derive"
+version = "0.7.12"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "67c78a4d8fdf9953a5c9d458f9efe940fd97a0cab0941c075a813ac594733827"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 2.0.119",
 ]
 
 [[package]]
@@ -1060,6 +1406,45 @@ dependencies = [
  "num-traits",
 ]
 
+[[package]]
+name = "event-listener"
+version = "5.4.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e13b66accf52311f30a0db42147dadea9850cb48cd070028831ae5f5d4b856ab"
+dependencies = [
+ "concurrent-queue",
+ "parking",
+ "pin-project-lite",
+]
+
+[[package]]
+name = "event-listener-strategy"
+version = "0.5.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8be9f3dfaaffdae2972880079a491a1a8bb7cbed0b8dd7a347f668b4150a3b93"
+dependencies = [
+ "event-listener",
+ "pin-project-lite",
+]
+
+[[package]]
+name = "fastbloom"
+version = "0.17.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ef975e30683b2d965054bb0a836f8973857c4ebf6acf274fe46617cd285060d8"
+dependencies = [
+ "foldhash 0.2.0",
+ "libm",
+ "portable-atomic",
+ "siphasher",
+]
+
+[[package]]
+name = "fastrand"
+version = "2.5.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "da7c62ceae207dd37ea5b845da6a0696c799f85e97da1ab5b7910be3c1c80223"
+
 [[package]]
 name = "fax"
 version = "0.2.7"
@@ -1087,6 +1472,12 @@ version = "0.1.9"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582"
 
+[[package]]
+name = "fixedbitset"
+version = "0.5.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1d674e81391d1e1ab681a28d99df07927c6d4aa5b027d7da16ba32d1d21ecd99"
+
 [[package]]
 name = "flate2"
 version = "1.1.9"
@@ -1109,11 +1500,17 @@ version = "0.1.5"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2"
 
+[[package]]
+name = "foldhash"
+version = "0.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb"
+
 [[package]]
 name = "font-types"
-version = "0.9.0"
+version = "0.10.1"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "02a596f5713680923a2080d86de50fe472fb290693cf0f701187a1c8b36996b7"
+checksum = "39a654f404bbcbd48ea58c617c2993ee91d1cb63727a37bf2323a4edeed1b8c5"
 dependencies = [
  "bytemuck",
 ]
@@ -1128,36 +1525,44 @@ dependencies = [
 ]
 
 [[package]]
-name = "fontconfig-cache-parser"
-version = "0.2.0"
+name = "font-types"
+version = "0.12.2"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "a7f8afb20c8069fd676d27b214559a337cc619a605d25a87baa90b49a06f3b18"
+checksum = "0a7299a780854a6d391be2ae1c8521c9368471b559dbfd6a8dbd9f407eaff100"
 dependencies = [
  "bytemuck",
- "thiserror 1.0.69",
 ]
 
 [[package]]
 name = "fontique"
-version = "0.4.0"
+version = "0.6.0"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "64763d1f274c8383333851435b6cdf071c31cfcdb39fd5860d20943205a007a7"
+checksum = "ff3336bc0b87fe42305047263fa60d2eabd650d29cbe62fdeb2a66c7a0a595f9"
 dependencies = [
  "bytemuck",
- "fontconfig-cache-parser",
  "hashbrown 0.15.5",
- "icu_locid",
+ "icu_locale_core",
+ "linebender_resource_handle",
  "memmap2",
  "objc2 0.6.4",
  "objc2-core-foundation",
  "objc2-core-text",
  "objc2-foundation 0.3.2",
- "peniko",
- "read-fonts 0.29.3",
+ "read-fonts 0.35.0",
  "roxmltree",
  "smallvec",
  "windows 0.58.0",
  "windows-core 0.58.0",
+ "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]]
@@ -1167,26 +1572,41 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "d737d9aa519fb7b749cbc3b962edcf310a8dd1f4b67c91c4f83975dbdd17d965"
 dependencies = [
  "foreign-types-macros",
- "foreign-types-shared",
+ "foreign-types-shared 0.3.1",
 ]
 
 [[package]]
 name = "foreign-types-macros"
-version = "0.2.3"
+version = "0.2.4"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "1a5c6c585bc94aaf2c7b51dd4c2ba22680844aba4c687be581871a6f518c5742"
+checksum = "ea5190182e6915eb873ddbc16e23b711b6eb1f9c00a0d0a3a91b5f6228475225"
 dependencies = [
  "proc-macro2",
  "quote",
- "syn",
+ "syn 3.0.3",
 ]
 
+[[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"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "aa9a19cbb55df58761df49b23516a86d432839add4af60fc256da840f66ed35b"
 
+[[package]]
+name = "fork-proof"
+version = "0.1.0"
+source = "git+https://git.tawasuyu.net/tawasuyu/tawasuyu.git?tag=v0.2.0#a446bb3a73a0e022158cc0ec7deb65173859644b"
+dependencies = [
+ "ed25519-compact",
+ "format",
+]
+
 [[package]]
 name = "form_urlencoded"
 version = "1.2.2"
@@ -1199,7 +1619,7 @@ dependencies = [
 [[package]]
 name = "format"
 version = "0.1.0"
-source = "git+https://gitea.tawasuyu.net/sergio/gioser.git?tag=v0.1.0#692b40336c1e9cb12ab561764d6943cb65905ab3"
+source = "git+https://git.tawasuyu.net/tawasuyu/tawasuyu.git?tag=v0.2.0#a446bb3a73a0e022158cc0ec7deb65173859644b"
 dependencies = [
  "blake3",
  "postcard",
@@ -1209,9 +1629,9 @@ dependencies = [
 
 [[package]]
 name = "futures"
-version = "0.3.32"
+version = "0.3.33"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "8b147ee9d1f6d097cef9ce628cd2ee62288d963e16fb287bd9286455b241382d"
+checksum = "a88cf1f829d945f548cf8fec32c61b1f202b6d93b45848602fc02af4b12ad218"
 dependencies = [
  "futures-channel",
  "futures-core",
@@ -1234,9 +1654,9 @@ dependencies = [
 
 [[package]]
 name = "futures-channel"
-version = "0.3.32"
+version = "0.3.33"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "07bbe89c50d7a535e539b8c17bc0b49bdb77747034daa8087407d655f3f7cc1d"
+checksum = "262590f4fe6afeb0bc83be1daa64e52657fe185690a958af7f3ad0e92085c5ae"
 dependencies = [
  "futures-core",
  "futures-sink",
@@ -1244,15 +1664,15 @@ dependencies = [
 
 [[package]]
 name = "futures-core"
-version = "0.3.32"
+version = "0.3.33"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d"
+checksum = "2cd50c473c80f6d7c3670a752354b8e569b1a7cbfdc0419ec88e5edad85e0dc7"
 
 [[package]]
 name = "futures-executor"
-version = "0.3.32"
+version = "0.3.33"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "baf29c38818342a3b26b5b923639e7b1f4a61fc5e76102d4b1981c6dc7a7579d"
+checksum = "6754879cc9f2c66f88c6e5c35344bb0bdb0708b0352b1201815667c7eabc7458"
 dependencies = [
  "futures-core",
  "futures-task",
@@ -1272,9 +1692,9 @@ dependencies = [
 
 [[package]]
 name = "futures-io"
-version = "0.3.32"
+version = "0.3.33"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "cecba35d7ad927e23624b22ad55235f2239cfa44fd10428eecbeba6d6a717718"
+checksum = "4577ecaa3c4f96589d473f679a71b596316f6641bc350038b962a5daf0085d7a"
 
 [[package]]
 name = "futures-lite"
@@ -1282,19 +1702,22 @@ version = "2.6.1"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "f78e10609fe0e0b3f4157ffab1876319b5b0db102a2c60dc4626306dc46b44ad"
 dependencies = [
+ "fastrand",
  "futures-core",
+ "futures-io",
+ "parking",
  "pin-project-lite",
 ]
 
 [[package]]
 name = "futures-macro"
-version = "0.3.32"
+version = "0.3.33"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "e835b70203e41293343137df5c0664546da5745f82ec9b84d40be8336958447b"
+checksum = "2d6d3cde68c518367be28956066ddfef33813991b77a55005a69dae04bf3b10b"
 dependencies = [
  "proc-macro2",
  "quote",
- "syn",
+ "syn 2.0.119",
 ]
 
 [[package]]
@@ -1310,15 +1733,15 @@ dependencies = [
 
 [[package]]
 name = "futures-sink"
-version = "0.3.32"
+version = "0.3.33"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "c39754e157331b013978ec91992bde1ac089843443c49cbc7f46150b0fad0893"
+checksum = "e34418ac499d6305c2fb5ad0ed2f6ac998c5f8ca209b4510f7f94242c647e307"
 
 [[package]]
 name = "futures-task"
-version = "0.3.32"
+version = "0.3.33"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393"
+checksum = "b231ed28831efb4a61a08580c4bc233ec56bc009f4cd8f52da2c3cb97df0c109"
 
 [[package]]
 name = "futures-timer"
@@ -1328,9 +1751,9 @@ checksum = "af43fadb8a98512d547e37b4e92e0ced13e205c061b87b4623eff01d918d6968"
 
 [[package]]
 name = "futures-util"
-version = "0.3.32"
+version = "0.3.33"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6"
+checksum = "a77a90a256fce34da66415271e30f94ee91c57b04b8a2c042d9cf3220179deaa"
 dependencies = [
  "futures-channel",
  "futures-core",
@@ -1383,24 +1806,23 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd"
 dependencies = [
  "cfg-if",
- "js-sys",
  "libc",
  "r-efi 5.3.0",
  "wasip2",
- "wasm-bindgen",
 ]
 
 [[package]]
 name = "getrandom"
-version = "0.4.2"
+version = "0.4.3"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "0de51e6874e94e7bf76d726fc5d13ba782deca734ff60d5bb2fb2607c7406555"
+checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099"
 dependencies = [
  "cfg-if",
+ "js-sys",
  "libc",
  "r-efi 6.0.0",
- "wasip2",
- "wasip3",
+ "rand_core 0.10.1",
+ "wasm-bindgen",
 ]
 
 [[package]]
@@ -1447,21 +1869,21 @@ dependencies = [
 
 [[package]]
 name = "gpu-alloc"
-version = "0.6.0"
+version = "0.6.2"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "fbcd2dba93594b227a1f57ee09b8b9da8892c34d55aa332e034a228d0fe6a171"
+checksum = "45cf04b2726f02df5508c6de726acdc90cdf97ac771a9a0ffd8ba10a6e696bf9"
 dependencies = [
- "bitflags 2.12.1",
+ "bitflags 2.13.1",
  "gpu-alloc-types",
 ]
 
 [[package]]
 name = "gpu-alloc-types"
-version = "0.3.0"
+version = "0.3.1"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "98ff03b468aa837d70984d55f5d3f846f6ec31fe34bbb97c4f85219caeee1ca4"
+checksum = "b2bbed164dd10ed526c2e4fe3e721ca4a71c61730e5aafac6844b417b3227058"
 dependencies = [
- "bitflags 2.12.1",
+ "bitflags 2.13.1",
 ]
 
 [[package]]
@@ -1482,7 +1904,7 @@ version = "0.3.2"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "b89c83349105e3732062a895becfc71a8f921bb71ecbbdd8ff99263e3b53a0ca"
 dependencies = [
- "bitflags 2.12.1",
+ "bitflags 2.13.1",
  "gpu-descriptor-types",
  "hashbrown 0.15.5",
 ]
@@ -1493,7 +1915,7 @@ version = "0.2.0"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "fdf242682df893b86f33a73828fb09ca4b2d3bb6cc95249707fc684d27484b91"
 dependencies = [
- "bitflags 2.12.1",
+ "bitflags 2.13.1",
 ]
 
 [[package]]
@@ -1514,9 +1936,9 @@ dependencies = [
 
 [[package]]
 name = "h2"
-version = "0.4.14"
+version = "0.4.15"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "171fefbc92fe4a4de27e0698d6a5b392d6a0e333506bc49133760b3bcf948733"
+checksum = "6cb093c84e8bd9b188d4c4a8cb6579fc016968d14c99882163cd3ff402a4f155"
 dependencies = [
  "atomic-waker",
  "bytes",
@@ -1539,9 +1961,23 @@ checksum = "6ea2d84b969582b4b1864a92dc5d27cd2b77b622a8d79306834f1be5ba20d84b"
 dependencies = [
  "cfg-if",
  "crunchy",
+ "num-traits",
  "zerocopy",
 ]
 
+[[package]]
+name = "harfrust"
+version = "0.3.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "92c020db12c71d8a12a3fe7607873cade3a01a6287e29d540c8723276221b9d8"
+dependencies = [
+ "bitflags 2.13.1",
+ "bytemuck",
+ "core_maths",
+ "read-fonts 0.35.0",
+ "smallvec",
+]
+
 [[package]]
 name = "hash32"
 version = "0.2.1"
@@ -1559,7 +1995,16 @@ checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1"
 dependencies = [
  "allocator-api2",
  "equivalent",
- "foldhash",
+ "foldhash 0.1.5",
+]
+
+[[package]]
+name = "hashbrown"
+version = "0.16.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100"
+dependencies = [
+ "foldhash 0.2.0",
 ]
 
 [[package]]
@@ -1631,10 +2076,10 @@ dependencies = [
  "idna",
  "ipnet",
  "once_cell",
- "rand 0.9.4",
+ "rand 0.9.5",
  "ring",
  "socket2 0.5.10",
- "thiserror 2.0.18",
+ "thiserror 2.0.19",
  "tinyvec",
  "tokio",
  "tracing",
@@ -1654,10 +2099,10 @@ dependencies = [
  "moka",
  "once_cell",
  "parking_lot",
- "rand 0.9.4",
+ "rand 0.9.5",
  "resolv-conf",
  "smallvec",
- "thiserror 2.0.18",
+ "thiserror 2.0.19",
  "tokio",
  "tracing",
 ]
@@ -1682,9 +2127,9 @@ dependencies = [
 
 [[package]]
 name = "http"
-version = "1.4.1"
+version = "1.4.2"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "8be7462df143984c4598a256ef469b251d7d7f9e271135073e78fc535414f3d0"
+checksum = "6970f50e31d6fc17d3fa27329444bfa74e196cf62e95052a3f6fee181dba6425"
 dependencies = [
  "bytes",
  "itoa",
@@ -1692,9 +2137,9 @@ dependencies = [
 
 [[package]]
 name = "http-body"
-version = "1.0.1"
+version = "1.1.0"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "1efedce1fb8e6913f23e0c92de8e62cd5b772a67e7b3946df930a62566c93184"
+checksum = "ca2a8f2913ee65f60facd6a5905613afaa448497a0230cc41ce022d93290bc2c"
 dependencies = [
  "bytes",
  "http",
@@ -1702,9 +2147,9 @@ dependencies = [
 
 [[package]]
 name = "http-body-util"
-version = "0.1.3"
+version = "0.1.4"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "b021d93e26becf5dc7e1b75b1bed1fd93124b374ceb73f43d4d4eafec896a64a"
+checksum = "e9f41fd6a08e4d4ec69df65976da761afd5ad5e58a9d4acb46bd1c953a9e3ff2"
 dependencies = [
  "bytes",
  "futures-core",
@@ -1721,9 +2166,9 @@ checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87"
 
 [[package]]
 name = "hyper"
-version = "1.10.1"
+version = "1.11.0"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "55281c53a1894c864990125767da440a4e630446785086f52523b20033b74498"
+checksum = "d22053281f852e11534f5198498373cbb59295120a20771d90f7ed1897490a72"
 dependencies = [
  "atomic-waker",
  "bytes",
@@ -1740,24 +2185,61 @@ dependencies = [
  "want",
 ]
 
+[[package]]
+name = "hyper-rustls"
+version = "0.27.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "33ca68d021ef39cf6463ab54c1d0f5daf03377b70561305bb89a8f83aab66e0f"
+dependencies = [
+ "http",
+ "hyper",
+ "hyper-util",
+ "rustls",
+ "tokio",
+ "tokio-rustls",
+ "tower-service",
+ "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"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0"
 dependencies = [
+ "base64",
  "bytes",
  "futures-channel",
  "futures-util",
  "http",
  "http-body",
  "hyper",
+ "ipnet",
  "libc",
+ "percent-encoding",
  "pin-project-lite",
- "socket2 0.6.4",
+ "socket2 0.6.5",
+ "system-configuration",
  "tokio",
  "tower-service",
  "tracing",
+ "windows-registry",
 ]
 
 [[package]]
@@ -1781,24 +2263,13 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "92219b62b3e2b4d88ac5119f8904c10f8f61bf7e95b640d25ba3075e6cac2c29"
 dependencies = [
  "displaydoc",
- "litemap 0.8.2",
- "tinystr 0.8.3",
- "writeable 0.6.3",
+ "litemap",
+ "serde",
+ "tinystr",
+ "writeable",
  "zerovec",
 ]
 
-[[package]]
-name = "icu_locid"
-version = "1.5.0"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "13acbb8371917fc971be86fc8057c41a64b521c184808a698c02acc242dbf637"
-dependencies = [
- "displaydoc",
- "litemap 0.7.5",
- "tinystr 0.7.6",
- "writeable 0.5.5",
-]
-
 [[package]]
 name = "icu_normalizer"
 version = "2.2.0"
@@ -1847,19 +2318,13 @@ checksum = "139c4cf31c8b5f33d7e199446eff9c1e02decfc2f0eec2c8d71f65befa45b421"
 dependencies = [
  "displaydoc",
  "icu_locale_core",
- "writeable 0.6.3",
+ "writeable",
  "yoke",
  "zerofrom",
  "zerotrie",
  "zerovec",
 ]
 
-[[package]]
-name = "id-arena"
-version = "2.3.0"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954"
-
 [[package]]
 name = "idna"
 version = "1.1.0"
@@ -1898,7 +2363,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "71c02a5161c313f0cbdbadc511611893584a10a7b6153cb554bdf83ddce99ec2"
 dependencies = [
  "async-io",
- "core-foundation",
+ "core-foundation 0.9.4",
  "fnv",
  "futures",
  "if-addrs",
@@ -1929,7 +2394,7 @@ dependencies = [
  "hyper",
  "hyper-util",
  "log",
- "rand 0.9.4",
+ "rand 0.9.5",
  "tokio",
  "url",
  "xmltree",
@@ -1957,8 +2422,6 @@ checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9"
 dependencies = [
  "equivalent",
  "hashbrown 0.17.1",
- "serde",
- "serde_core",
 ]
 
 [[package]]
@@ -1976,7 +2439,7 @@ version = "0.3.4"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "4d40460c0ce33d6ce4b0630ad68ff63d6661961c48b6dba35e5a4d81cfb48222"
 dependencies = [
- "socket2 0.6.4",
+ "socket2 0.6.5",
  "widestring",
  "windows-registry",
  "windows-result 0.4.1",
@@ -2016,7 +2479,7 @@ dependencies = [
  "jni-sys 0.4.1",
  "log",
  "simd_cesu8",
- "thiserror 2.0.18",
+ "thiserror 2.0.19",
  "walkdir",
  "windows-link",
 ]
@@ -2031,7 +2494,7 @@ dependencies = [
  "quote",
  "rustc_version",
  "simd_cesu8",
- "syn",
+ "syn 2.0.119",
 ]
 
 [[package]]
@@ -2059,28 +2522,27 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "38c0b942f458fe50cdac086d2f946512305e5631e720728f2a61aabcd47a6264"
 dependencies = [
  "quote",
- "syn",
+ "syn 2.0.119",
 ]
 
 [[package]]
 name = "jobserver"
-version = "0.1.34"
+version = "0.1.35"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "9afb3de4395d6b3e67a780b6de64b51c978ecf11cb9a462c66be7d4ca9039d33"
+checksum = "1c00acbd29eabad4a2392fa0e921c874934dbbf4194312ad20f04a0ed67a3cb3"
 dependencies = [
- "getrandom 0.3.4",
+ "getrandom 0.4.3",
  "libc",
 ]
 
 [[package]]
 name = "js-sys"
-version = "0.3.99"
+version = "0.3.103"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "142bc4740e452c1e57ade0cbc129f139c9093e354346f0872ef985f4f5cf5f11"
+checksum = "53b44bfcdb3f8d5837a46dae1ca9660a837176eee74a28b229bc626816589102"
 dependencies = [
  "cfg-if",
  "futures-util",
- "once_cell",
  "wasm-bindgen",
 ]
 
@@ -2090,6 +2552,7 @@ version = "0.1.0"
 dependencies = [
  "agora-core",
  "app-bus",
+ "blake3",
  "directories",
  "khipu-brahman",
  "khipu-core",
@@ -2105,11 +2568,13 @@ dependencies = [
  "llimphi-widget-menubar",
  "llimphi-widget-text-editor",
  "llimphi-widget-text-input",
- "llimphi-widget-tiled",
+ "pluma-llm",
  "postcard",
  "rimay-verbo",
  "serde",
  "tokio",
+ "willay-checkpoint",
+ "willay-emit",
 ]
 
 [[package]]
@@ -2121,8 +2586,7 @@ dependencies = [
  "futures",
  "khipu-share",
  "libp2p",
- "libp2p-stream",
- "thiserror 2.0.18",
+ "thiserror 2.0.19",
  "tokio",
  "tokio-util",
 ]
@@ -2140,6 +2604,8 @@ version = "0.1.0"
 dependencies = [
  "khipu-core",
  "libm",
+ "rimay-verbo-core",
+ "rimay-verbo-index",
  "serde",
 ]
 
@@ -2152,10 +2618,10 @@ dependencies = [
  "blake3",
  "khipu-core",
  "postcard",
- "rand 0.8.6",
+ "rand 0.8.7",
  "serde",
  "serde-big-array",
- "thiserror 2.0.18",
+ "thiserror 2.0.19",
 ]
 
 [[package]]
@@ -2177,12 +2643,13 @@ checksum = "e2db585e1d738fc771bf08a151420d3ed193d9d895a36df7f6f8a9456b911ddc"
 
 [[package]]
 name = "kurbo"
-version = "0.11.3"
+version = "0.13.1"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "c62026ae44756f8a599ba21140f350303d4f08dcdcc71b5ad9c9bb8128c13c62"
+checksum = "4b60dfc32f652b926df6192e55525b16d186c69d47876c3ead4da5cc9f8450e2"
 dependencies = [
  "arrayvec",
  "euclid",
+ "polycool",
  "smallvec",
 ]
 
@@ -2192,17 +2659,11 @@ version = "1.5.0"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe"
 
-[[package]]
-name = "leb128fmt"
-version = "0.1.0"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2"
-
 [[package]]
 name = "libc"
-version = "0.2.186"
+version = "0.2.189"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66"
+checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2"
 
 [[package]]
 name = "libloading"
@@ -2252,7 +2713,7 @@ dependencies = [
  "multiaddr",
  "pin-project",
  "rw-stream-sink",
- "thiserror 2.0.18",
+ "thiserror 2.0.19",
 ]
 
 [[package]]
@@ -2284,9 +2745,9 @@ dependencies = [
  "libp2p-swarm",
  "quick-protobuf",
  "quick-protobuf-codec",
- "rand 0.8.6",
+ "rand 0.8.7",
  "rand_core 0.6.4",
- "thiserror 2.0.18",
+ "thiserror 2.0.19",
  "tracing",
  "web-time",
 ]
@@ -2319,9 +2780,9 @@ dependencies = [
  "parking_lot",
  "pin-project",
  "quick-protobuf",
- "rand 0.8.6",
+ "rand 0.8.7",
  "rw-stream-sink",
- "thiserror 2.0.18",
+ "thiserror 2.0.19",
  "tracing",
  "unsigned-varint 0.8.0",
  "web-time",
@@ -2344,7 +2805,7 @@ dependencies = [
  "libp2p-swarm",
  "quick-protobuf",
  "quick-protobuf-codec",
- "thiserror 2.0.18",
+ "thiserror 2.0.19",
  "tracing",
  "web-time",
 ]
@@ -2382,7 +2843,7 @@ dependencies = [
  "quick-protobuf",
  "quick-protobuf-codec",
  "smallvec",
- "thiserror 2.0.18",
+ "thiserror 2.0.19",
  "tracing",
 ]
 
@@ -2397,9 +2858,9 @@ dependencies = [
  "hkdf",
  "multihash",
  "prost",
- "rand 0.8.6",
+ "rand 0.8.7",
  "sha2",
- "thiserror 2.0.18",
+ "thiserror 2.0.19",
  "tracing",
  "zeroize",
 ]
@@ -2422,10 +2883,10 @@ dependencies = [
  "libp2p-swarm",
  "quick-protobuf",
  "quick-protobuf-codec",
- "rand 0.8.6",
+ "rand 0.8.7",
  "sha2",
  "smallvec",
- "thiserror 2.0.18",
+ "thiserror 2.0.19",
  "tracing",
  "uint",
  "web-time",
@@ -2443,7 +2904,7 @@ dependencies = [
  "libp2p-core",
  "libp2p-identity",
  "libp2p-swarm",
- "rand 0.8.6",
+ "rand 0.8.7",
  "smallvec",
  "socket2 0.5.10",
  "tokio",
@@ -2483,10 +2944,10 @@ dependencies = [
  "multiaddr",
  "multihash",
  "quick-protobuf",
- "rand 0.8.6",
+ "rand 0.8.7",
  "snow",
  "static_assertions",
- "thiserror 2.0.18",
+ "thiserror 2.0.19",
  "tracing",
  "x25519-dalek",
  "zeroize",
@@ -2494,9 +2955,9 @@ dependencies = [
 
 [[package]]
 name = "libp2p-quic"
-version = "0.13.0"
+version = "0.13.1"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "8dc448b2de9f4745784e3751fe8bc6c473d01b8317edd5ababcb0dec803d843f"
+checksum = "9dcc597d70bf7f6f30cbe07081802c836184e48416e89e9ce73a0ba2c56a319e"
 dependencies = [
  "futures",
  "futures-timer",
@@ -2505,11 +2966,12 @@ dependencies = [
  "libp2p-identity",
  "libp2p-tls",
  "quinn",
- "rand 0.8.6",
+ "quinn-proto",
+ "rand 0.8.7",
  "ring",
  "rustls",
  "socket2 0.5.10",
- "thiserror 2.0.18",
+ "thiserror 2.0.19",
  "tokio",
  "tracing",
 ]
@@ -2531,9 +2993,9 @@ dependencies = [
  "libp2p-swarm",
  "quick-protobuf",
  "quick-protobuf-codec",
- "rand 0.8.6",
+ "rand 0.8.7",
  "static_assertions",
- "thiserror 2.0.18",
+ "thiserror 2.0.19",
  "tracing",
  "web-time",
 ]
@@ -2550,7 +3012,7 @@ dependencies = [
  "libp2p-core",
  "libp2p-identity",
  "libp2p-swarm",
- "rand 0.8.6",
+ "rand 0.8.7",
  "smallvec",
  "tracing",
 ]
@@ -2565,7 +3027,7 @@ dependencies = [
  "libp2p-core",
  "libp2p-identity",
  "libp2p-swarm",
- "rand 0.8.6",
+ "rand 0.8.7",
  "tracing",
 ]
 
@@ -2584,7 +3046,7 @@ dependencies = [
  "libp2p-identity",
  "libp2p-swarm-derive",
  "multistream-select",
- "rand 0.8.6",
+ "rand 0.8.7",
  "smallvec",
  "tokio",
  "tracing",
@@ -2599,7 +3061,7 @@ checksum = "dd297cf53f0cb3dee4d2620bb319ae47ef27c702684309f682bdb7e55a18ae9c"
 dependencies = [
  "heck",
  "quote",
- "syn",
+ "syn 2.0.119",
 ]
 
 [[package]]
@@ -2613,7 +3075,7 @@ dependencies = [
  "if-watch",
  "libc",
  "libp2p-core",
- "socket2 0.6.4",
+ "socket2 0.6.5",
  "tokio",
  "tracing",
 ]
@@ -2632,7 +3094,7 @@ dependencies = [
  "ring",
  "rustls",
  "rustls-webpki",
- "thiserror 2.0.18",
+ "thiserror 2.0.19",
  "x509-parser",
  "yasna",
 ]
@@ -2661,7 +3123,7 @@ dependencies = [
  "either",
  "futures",
  "libp2p-core",
- "thiserror 2.0.18",
+ "thiserror 2.0.19",
  "tracing",
  "yamux 0.12.1",
  "yamux 0.13.10",
@@ -2669,14 +3131,14 @@ dependencies = [
 
 [[package]]
 name = "libredox"
-version = "0

The patch goes on, but was cut here: fetch the commit with git to see it whole.