diff --git a/recipes/cadcraft.toml b/recipes/cadcraft.toml
new file mode 100644
index 00000000..89e5fd8c
--- /dev/null
+++ b/recipes/cadcraft.toml
@@ -0,0 +1,115 @@
+# cadcraft — el CAD 2D estilo AutoCAD: DWG/DXF
+#
+# ── DE DÓNDE SALE ESTA FAMILIA ─────────────────────────────────────────────────────────────────
+# ArtCraft publica doce aplicaciones de escritorio en **Rust puro sobre egui + wgpu**, Apache-2.0 o
+# MIT, cada una en su propio repo de `github.com/storytold`. Para esta casa son interesantes por una
+# razón concreta y medible: **no tienen ni una línea de GTK ni de Qt**, que es el muro que deja a
+# `libreoffice` en «59 recetas y tres muros ⇒ no es un ticket, es un año»
+# (`docs/plan-apps-usuario-final.md:48`). Acá cada app es UNA receta `cargo_vendor`.
+#
+# La primera que se midió fue `gridcraft`, y la medición está en su receta: sella en 12 min, abre
+# ventana en el perfil `escritorio-sway` y **pinta** — `wgpu` toma el backend **GL** sobre EGL/GLES
+# con llvmpipe (GL 4.5), sin Vulkan y sin ninguna variable de entorno, porque el default de
+# `egui-wgpu` es `Backends::from_env().unwrap_or(PRIMARY | GL)`.
+#
+# ⚠ **Son 0.x y se mueven solas.** Portar esto es comprar su cinta de correr (SDD 26 §2). Se pinea
+# el **sha del tag**, no `main`, y por eso la versión de arriba es una release y no «lo de hoy».
+#
+# ── X11: NO SE PARCHEA, Y ESO ES UNA DECISIÓN MEDIDA ──────────────────────────────────────────
+# Las doce declaran `eframe` con los features `wayland` **y** `x11`, y la primera versión de esto
+# arrancaba el feature `x11` con un `sed`. Se quitó, por tres cosas que se midieron al hacerlo:
+# 1. **No hay muro de build**: lo que `x11` arrastra es `x11rb` (protocolo X en Rust puro) y
+# `x11-dl` (que hace `dlopen`, no enlaza). Ninguna receta nueva, ni un `.so` de X en el link.
+# 2. **No hay muro de runtime**: winit elige Wayland cuando `WAYLAND_DISPLAY` está puesto, y en
+# esta distro `libX11` directamente no existe ⇒ el camino X es inerte, no roto.
+# 3. **Quitarlo SÍ cuesta**: cambia el grafo ⇒ el `Cargo.lock` deja de cerrar y hay que soltar el
+# `--locked` del compile (medido: `error: cannot update the lock file … because --locked`).
+# Y varias de estas apps declaran `winit` con `x11` por su cuenta y lo USAN en el código
+# (`photocraft` abre por Xwayland si el usuario lo elige en Preferencias), así que un `sed`
+# ciego rompe el build en unas y es un no-op en otras.
+# ⇒ Se deja el feature y se conserva el `--locked`, que es el pin fuerte. La distro sigue siendo
+# Wayland-only por lo que NO lleva en la imagen, no por lo que esta receta le recorta al upstream.
+#
+# ── LA FORMA ───────────────────────────────────────────────────────────────────────────────────
+# `link = "dynamic"` + `-crt-static` como `boveda`/`shuma-pregunta`/`mirada-greeter`: winit y wgpu
+# cargan `libwayland-client`, `libEGL`/`libvulkan` por `dlopen`, y un binario `crt-static` no puede.
+# El lanzador, el MIME y los iconos son de UPSTREAM — lo contrario de `boveda.toml`, donde hay que
+# autorar el `.desktop` — y están enhebrados: `Icon=`/`StartupWMClass=` del `.desktop`, el nombre de
+# los iconos hicolor y el `with_app_id()` de la app coinciden en `ai.storyteller.cadcraft`, así que
+# el emparejamiento ventana↔lanzador funciona en Wayland sin parchear nada.
+#
+name = "cadcraft"
+version = "0.6.2"
+license = "MIT OR Apache-2.0"
+
+[source]
+# `v0.6.2`, el tag vigente al 2026-10-12. Se pinea el SHA: un tag se mueve.
+repo = "https://github.com/storytold/cadcraft"
+commit = "00c7613f1af3c73f7157fa91d9fe2635c6f23c06"
+# ⚠ `vendor_cargo_deps` **sobrescribe** `<src>/.cargo/config.toml` con `std::fs::write`
+# (`crates/takana-build/src/fetch.rs:852`), y estos repos traen uno propio. Hoy es inocuo —dentro
+# sólo hay `[alias] xtask`, que estas recetas no usan— pero si un bump mete ahí un `rustflags` o un
+# `linker`, desaparece sin un solo aviso y el fallo no va a nombrar al config.
+# El `Cargo.lock` está committeado ⇒ el vendoreo va con `--locked` y queda pineado en el tiempo.
+
+[build]
+compiler = "zig-cc"
+target = "x86_64-linux-musl"
+link = "dynamic"
+flags = ["-p", "cadcraft", "--bin", "cadcraft"]
+
+[build.phases]
+compile = '''
+set -e
+# LOCKSTEP_XML_PATH — la causa entera está en `mirada-greeter.toml` y repetida en `boveda.toml`:
+# `accesskit` → `atspi` → `atspi-common`, que trae `zbus-lockstep-macros` como dep NORMAL y cuyo
+# `#[validate]` recorre el dir `xml/` llamando `.extension().expect()` en CADA entrada ⇒ PANICA al
+# toparse con el subdirectorio `xml/schemas/`. Apuntando la variable a un dir con sólo los `.xml` se
+# evita. La comprobación es a gritos: un `cp` mudo dejaría la variable apuntando a un dir VACÍO y el
+# panic volvería con el error lejos de la causa.
+XMLDIR="$PWD/.atspi-lockstep-xml"
+mkdir -p "$XMLDIR"
+ls vendor/atspi-common/xml/*.xml >/dev/null 2>&1 || { echo "no hay vendor/atspi-common/xml/*.xml — ¿cambió el vendoreo o la versión de atspi? sin esto zbus-lockstep-macros panica en xml/schemas/" >&2; exit 1; }
+cp vendor/atspi-common/xml/*.xml "$XMLDIR"/
+export LOCKSTEP_XML_PATH="$XMLDIR"
+
+# El envoltorio de zig-cc, igual que en las otras winit+wgpu del corpus.
+printf '%s\n' '#!/bin/sh' 'for a do' 'case "$a" in --target=*) a=--target=x86_64-linux-musl ;; esac' 'set -- "$@" "$a"' 'shift' 'done' 'exec zig cc -mcpu=baseline "$@"' > "$PWD/.hammer-zig-cc"
+chmod +x "$PWD/.hammer-zig-cc"
+printf '%s\n' '#!/bin/sh' 'exec zig ar "$@"' > "$PWD/.hammer-zig-ar"
+chmod +x "$PWD/.hammer-zig-ar"
+RF="-C linker=$PWD/.hammer-zig-cc"
+rustc -vV | grep -q 'host: .*-alpine-' || RF="$RF -C link-self-contained=no"
+CC="$PWD/.hammer-zig-cc" AR="$PWD/.hammer-zig-ar" RUSTFLAGS="$RF" cargo rustc --release --locked --offline -p cadcraft --bin cadcraft -- -C target-feature=-crt-static
+'''
+install = '''
+set -e
+test -x target/release/cadcraft || { echo "no hay target/release/cadcraft — ¿cambió el nombre del bin?" >&2; exit 1; }
+install -Dm755 target/release/cadcraft /out/usr/bin/cadcraft
+
+install -Dm644 packaging/linux/ai.storyteller.cadcraft.desktop /out/usr/share/applications/ai.storyteller.cadcraft.desktop
+install -Dm644 packaging/linux/ai.storyteller.cadcraft.mime.xml /out/usr/share/mime/packages/ai.storyteller.cadcraft.xml
+# Los iconos se recorren con un glob y NO con una lista de tamaños: `effectcraft` y `filmcraft`
+# traen el doble de entradas que las demás, y una lista fija los habría instalado a medias en
+# silencio. Se cuenta lo instalado y se falla si es cero.
+n=0
+for p in assets/app-icon/hicolor/*/apps/ai.storyteller.cadcraft.*; do
+ [ -f "$p" ] || continue
+ install -Dm644 "$p" "/out/usr/share/icons/hicolor/${p#assets/app-icon/hicolor/}"
+ n=$((n+1))
+done
+[ "$n" -gt 0 ] || { echo "ningún icono instalado — ¿cambió assets/app-icon/hicolor/?" >&2; exit 1; }
+install -Dm644 LICENSE-MIT /out/usr/share/licenses/cadcraft/LICENSE-MIT
+install -Dm644 LICENSE-APACHE /out/usr/share/licenses/cadcraft/LICENSE-APACHE
+
+# Un `.desktop` vacío o un binario de 0 bytes son la forma de fallo de la casa: artefacto presente,
+# función ausente (CLAUDE.md §3).
+test -s /out/usr/share/applications/ai.storyteller.cadcraft.desktop
+test -s /out/usr/bin/cadcraft
+'''
+
+[deps]
+# El mismo juego que `boveda`/`shuma-pregunta`/`mirada-greeter`: el stack gráfico se consulta en
+# build-time y se carga por `dlopen` en runtime. Las fuentes vienen EMBEBIDAS (`default_fonts` de
+# eframe), así que no hace falta fontconfig ni freetype.
+build = ["mesa", "wayland", "wayland-protocols", "libdrm", "expat", "zlib", "zstd", "libffi", "pkgconf"]
diff --git a/recipes/deckcraft.toml b/recipes/deckcraft.toml
new file mode 100644
index 00000000..b421989f
--- /dev/null
+++ b/recipes/deckcraft.toml
@@ -0,0 +1,118 @@
+# deckcraft — las presentaciones: `.pptx` de ida y vuelta
+#
+# ── DE DÓNDE SALE ESTA FAMILIA ─────────────────────────────────────────────────────────────────
+# ArtCraft publica doce aplicaciones de escritorio en **Rust puro sobre egui + wgpu**, Apache-2.0 o
+# MIT, cada una en su propio repo de `github.com/storytold`. Para esta casa son interesantes por una
+# razón concreta y medible: **no tienen ni una línea de GTK ni de Qt**, que es el muro que deja a
+# `libreoffice` en «59 recetas y tres muros ⇒ no es un ticket, es un año»
+# (`docs/plan-apps-usuario-final.md:48`). Acá cada app es UNA receta `cargo_vendor`.
+#
+# La primera que se midió fue `gridcraft`, y la medición está en su receta: sella en 12 min, abre
+# ventana en el perfil `escritorio-sway` y **pinta** — `wgpu` toma el backend **GL** sobre EGL/GLES
+# con llvmpipe (GL 4.5), sin Vulkan y sin ninguna variable de entorno, porque el default de
+# `egui-wgpu` es `Backends::from_env().unwrap_or(PRIMARY | GL)`.
+#
+# ⚠ **Son 0.x y se mueven solas.** Portar esto es comprar su cinta de correr (SDD 26 §2). Se pinea
+# el **sha del tag**, no `main`, y por eso la versión de arriba es una release y no «lo de hoy».
+#
+# ── X11: NO SE PARCHEA, Y ESO ES UNA DECISIÓN MEDIDA ──────────────────────────────────────────
+# Las doce declaran `eframe` con los features `wayland` **y** `x11`, y la primera versión de esto
+# arrancaba el feature `x11` con un `sed`. Se quitó, por tres cosas que se midieron al hacerlo:
+# 1. **No hay muro de build**: lo que `x11` arrastra es `x11rb` (protocolo X en Rust puro) y
+# `x11-dl` (que hace `dlopen`, no enlaza). Ninguna receta nueva, ni un `.so` de X en el link.
+# 2. **No hay muro de runtime**: winit elige Wayland cuando `WAYLAND_DISPLAY` está puesto, y en
+# esta distro `libX11` directamente no existe ⇒ el camino X es inerte, no roto.
+# 3. **Quitarlo SÍ cuesta**: cambia el grafo ⇒ el `Cargo.lock` deja de cerrar y hay que soltar el
+# `--locked` del compile (medido: `error: cannot update the lock file … because --locked`).
+# Y varias de estas apps declaran `winit` con `x11` por su cuenta y lo USAN en el código
+# (`photocraft` abre por Xwayland si el usuario lo elige en Preferencias), así que un `sed`
+# ciego rompe el build en unas y es un no-op en otras.
+# ⇒ Se deja el feature y se conserva el `--locked`, que es el pin fuerte. La distro sigue siendo
+# Wayland-only por lo que NO lleva en la imagen, no por lo que esta receta le recorta al upstream.
+#
+# ── LA FORMA ───────────────────────────────────────────────────────────────────────────────────
+# `link = "dynamic"` + `-crt-static` como `boveda`/`shuma-pregunta`/`mirada-greeter`: winit y wgpu
+# cargan `libwayland-client`, `libEGL`/`libvulkan` por `dlopen`, y un binario `crt-static` no puede.
+# El lanzador, el MIME y los iconos son de UPSTREAM — lo contrario de `boveda.toml`, donde hay que
+# autorar el `.desktop` — y están enhebrados: `Icon=`/`StartupWMClass=` del `.desktop`, el nombre de
+# los iconos hicolor y el `with_app_id()` de la app coinciden en `ai.storyteller.deckcraft`, así que
+# el emparejamiento ventana↔lanzador funciona en Wayland sin parchear nada.
+#
+name = "deckcraft"
+version = "0.6.1"
+license = "MIT OR Apache-2.0"
+
+[source]
+# `v0.6.1`, el tag vigente al 2026-10-12. Se pinea el SHA: un tag se mueve.
+repo = "https://github.com/storytold/deckcraft"
+commit = "688c7ef592c901ca4541c25e210c1b302e4dc066"
+# ⚠ `vendor_cargo_deps` **sobrescribe** `<src>/.cargo/config.toml` con `std::fs::write`
+# (`crates/takana-build/src/fetch.rs:852`), y estos repos traen uno propio. Hoy es inocuo —dentro
+# sólo hay `[alias] xtask`, que estas recetas no usan— pero si un bump mete ahí un `rustflags` o un
+# `linker`, desaparece sin un solo aviso y el fallo no va a nombrar al config.
+# El `Cargo.lock` está committeado ⇒ el vendoreo va con `--locked` y queda pineado en el tiempo.
+
+[build]
+compiler = "zig-cc"
+target = "x86_64-linux-musl"
+link = "dynamic"
+flags = ["-p", "deckcraft", "--bin", "deckcraft"]
+
+[build.phases]
+compile = '''
+set -e
+# LOCKSTEP_XML_PATH — la causa entera está en `mirada-greeter.toml` y repetida en `boveda.toml`:
+# `accesskit` → `atspi` → `atspi-common`, que trae `zbus-lockstep-macros` como dep NORMAL y cuyo
+# `#[validate]` recorre el dir `xml/` llamando `.extension().expect()` en CADA entrada ⇒ PANICA al
+# toparse con el subdirectorio `xml/schemas/`. Apuntando la variable a un dir con sólo los `.xml` se
+# evita. La comprobación es a gritos: un `cp` mudo dejaría la variable apuntando a un dir VACÍO y el
+# panic volvería con el error lejos de la causa.
+XMLDIR="$PWD/.atspi-lockstep-xml"
+mkdir -p "$XMLDIR"
+ls vendor/atspi-common/xml/*.xml >/dev/null 2>&1 || { echo "no hay vendor/atspi-common/xml/*.xml — ¿cambió el vendoreo o la versión de atspi? sin esto zbus-lockstep-macros panica en xml/schemas/" >&2; exit 1; }
+cp vendor/atspi-common/xml/*.xml "$XMLDIR"/
+export LOCKSTEP_XML_PATH="$XMLDIR"
+
+# El envoltorio de zig-cc, igual que en las otras winit+wgpu del corpus.
+printf '%s\n' '#!/bin/sh' 'for a do' 'case "$a" in --target=*) a=--target=x86_64-linux-musl ;; esac' 'set -- "$@" "$a"' 'shift' 'done' 'exec zig cc -mcpu=baseline "$@"' > "$PWD/.hammer-zig-cc"
+chmod +x "$PWD/.hammer-zig-cc"
+printf '%s\n' '#!/bin/sh' 'exec zig ar "$@"' > "$PWD/.hammer-zig-ar"
+chmod +x "$PWD/.hammer-zig-ar"
+RF="-C linker=$PWD/.hammer-zig-cc"
+rustc -vV | grep -q 'host: .*-alpine-' || RF="$RF -C link-self-contained=no"
+CC="$PWD/.hammer-zig-cc" AR="$PWD/.hammer-zig-ar" RUSTFLAGS="$RF" cargo rustc --release --locked --offline -p deckcraft --bin deckcraft -- -C target-feature=-crt-static
+'''
+install = '''
+set -e
+test -x target/release/deckcraft || { echo "no hay target/release/deckcraft — ¿cambió el nombre del bin?" >&2; exit 1; }
+install -Dm755 target/release/deckcraft /out/usr/bin/deckcraft
+
+install -Dm644 packaging/linux/ai.storyteller.deckcraft.desktop /out/usr/share/applications/ai.storyteller.deckcraft.desktop
+install -Dm644 packaging/linux/ai.storyteller.deckcraft.mime.xml /out/usr/share/mime/packages/ai.storyteller.deckcraft.xml
+# Los iconos se recorren con un glob y NO con una lista de tamaños: `effectcraft` y `filmcraft`
+# traen el doble de entradas que las demás, y una lista fija los habría instalado a medias en
+# silencio. Se cuenta lo instalado y se falla si es cero.
+n=0
+for p in assets/app-icon/hicolor/*/apps/ai.storyteller.deckcraft.*; do
+ [ -f "$p" ] || continue
+ install -Dm644 "$p" "/out/usr/share/icons/hicolor/${p#assets/app-icon/hicolor/}"
+ n=$((n+1))
+done
+[ "$n" -gt 0 ] || { echo "ningún icono instalado — ¿cambió assets/app-icon/hicolor/?" >&2; exit 1; }
+install -Dm644 LICENSE-MIT /out/usr/share/licenses/deckcraft/LICENSE-MIT
+install -Dm644 LICENSE-APACHE /out/usr/share/licenses/deckcraft/LICENSE-APACHE
+
+# Un `.desktop` vacío o un binario de 0 bytes son la forma de fallo de la casa: artefacto presente,
+# función ausente (CLAUDE.md §3).
+test -s /out/usr/share/applications/ai.storyteller.deckcraft.desktop
+test -s /out/usr/bin/deckcraft
+'''
+
+[deps]
+# Igual que las demás de la familia MÁS `alsa-lib`, y ésta no es decorativa: la app trae `cpal`
+# **sin condicionar por plataforma**, que en Linux es `alsa-sys` y pregunta por `alsa.pc` con
+# pkg-config ⇒ sin esta dep el build muere en el `build.rs` de `alsa-sys`, no en el link.
+# ⚠ Y el corpus hoy NO publica `alsa`: `scripts/provee.py --desde corpus alsa` sale «NADIE LO
+# PUBLICA en ninguna cola» aunque `recipes/alsa-lib.toml` exista — la receta está escrita y sin
+# sellar. Declararla acá la construye; no hay que hacer nada aparte.
+build = ["mesa", "wayland", "wayland-protocols", "libdrm", "expat", "zlib", "zstd", "libffi", "pkgconf", "alsa-lib"]
diff --git a/recipes/designcraft.toml b/recipes/designcraft.toml
new file mode 100644
index 00000000..515a33c7
--- /dev/null
+++ b/recipes/designcraft.toml
@@ -0,0 +1,115 @@
+# designcraft — la maquetación de página — el InDesign de la familia
+#
+# ── DE DÓNDE SALE ESTA FAMILIA ─────────────────────────────────────────────────────────────────
+# ArtCraft publica doce aplicaciones de escritorio en **Rust puro sobre egui + wgpu**, Apache-2.0 o
+# MIT, cada una en su propio repo de `github.com/storytold`. Para esta casa son interesantes por una
+# razón concreta y medible: **no tienen ni una línea de GTK ni de Qt**, que es el muro que deja a
+# `libreoffice` en «59 recetas y tres muros ⇒ no es un ticket, es un año»
+# (`docs/plan-apps-usuario-final.md:48`). Acá cada app es UNA receta `cargo_vendor`.
+#
+# La primera que se midió fue `gridcraft`, y la medición está en su receta: sella en 12 min, abre
+# ventana en el perfil `escritorio-sway` y **pinta** — `wgpu` toma el backend **GL** sobre EGL/GLES
+# con llvmpipe (GL 4.5), sin Vulkan y sin ninguna variable de entorno, porque el default de
+# `egui-wgpu` es `Backends::from_env().unwrap_or(PRIMARY | GL)`.
+#
+# ⚠ **Son 0.x y se mueven solas.** Portar esto es comprar su cinta de correr (SDD 26 §2). Se pinea
+# el **sha del tag**, no `main`, y por eso la versión de arriba es una release y no «lo de hoy».
+#
+# ── X11: NO SE PARCHEA, Y ESO ES UNA DECISIÓN MEDIDA ──────────────────────────────────────────
+# Las doce declaran `eframe` con los features `wayland` **y** `x11`, y la primera versión de esto
+# arrancaba el feature `x11` con un `sed`. Se quitó, por tres cosas que se midieron al hacerlo:
+# 1. **No hay muro de build**: lo que `x11` arrastra es `x11rb` (protocolo X en Rust puro) y
+# `x11-dl` (que hace `dlopen`, no enlaza). Ninguna receta nueva, ni un `.so` de X en el link.
+# 2. **No hay muro de runtime**: winit elige Wayland cuando `WAYLAND_DISPLAY` está puesto, y en
+# esta distro `libX11` directamente no existe ⇒ el camino X es inerte, no roto.
+# 3. **Quitarlo SÍ cuesta**: cambia el grafo ⇒ el `Cargo.lock` deja de cerrar y hay que soltar el
+# `--locked` del compile (medido: `error: cannot update the lock file … because --locked`).
+# Y varias de estas apps declaran `winit` con `x11` por su cuenta y lo USAN en el código
+# (`photocraft` abre por Xwayland si el usuario lo elige en Preferencias), así que un `sed`
+# ciego rompe el build en unas y es un no-op en otras.
+# ⇒ Se deja el feature y se conserva el `--locked`, que es el pin fuerte. La distro sigue siendo
+# Wayland-only por lo que NO lleva en la imagen, no por lo que esta receta le recorta al upstream.
+#
+# ── LA FORMA ───────────────────────────────────────────────────────────────────────────────────
+# `link = "dynamic"` + `-crt-static` como `boveda`/`shuma-pregunta`/`mirada-greeter`: winit y wgpu
+# cargan `libwayland-client`, `libEGL`/`libvulkan` por `dlopen`, y un binario `crt-static` no puede.
+# El lanzador, el MIME y los iconos son de UPSTREAM — lo contrario de `boveda.toml`, donde hay que
+# autorar el `.desktop` — y están enhebrados: `Icon=`/`StartupWMClass=` del `.desktop`, el nombre de
+# los iconos hicolor y el `with_app_id()` de la app coinciden en `ai.storyteller.designcraft`, así que
+# el emparejamiento ventana↔lanzador funciona en Wayland sin parchear nada.
+#
+name = "designcraft"
+version = "0.7.1"
+license = "MIT OR Apache-2.0"
+
+[source]
+# `v0.7.1`, el tag vigente al 2026-10-12. Se pinea el SHA: un tag se mueve.
+repo = "https://github.com/storytold/designcraft"
+commit = "3624f488192a8eeade11bd27609c9e5056214e75"
+# ⚠ `vendor_cargo_deps` **sobrescribe** `<src>/.cargo/config.toml` con `std::fs::write`
+# (`crates/takana-build/src/fetch.rs:852`), y estos repos traen uno propio. Hoy es inocuo —dentro
+# sólo hay `[alias] xtask`, que estas recetas no usan— pero si un bump mete ahí un `rustflags` o un
+# `linker`, desaparece sin un solo aviso y el fallo no va a nombrar al config.
+# El `Cargo.lock` está committeado ⇒ el vendoreo va con `--locked` y queda pineado en el tiempo.
+
+[build]
+compiler = "zig-cc"
+target = "x86_64-linux-musl"
+link = "dynamic"
+flags = ["-p", "designcraft", "--bin", "designcraft"]
+
+[build.phases]
+compile = '''
+set -e
+# LOCKSTEP_XML_PATH — la causa entera está en `mirada-greeter.toml` y repetida en `boveda.toml`:
+# `accesskit` → `atspi` → `atspi-common`, que trae `zbus-lockstep-macros` como dep NORMAL y cuyo
+# `#[validate]` recorre el dir `xml/` llamando `.extension().expect()` en CADA entrada ⇒ PANICA al
+# toparse con el subdirectorio `xml/schemas/`. Apuntando la variable a un dir con sólo los `.xml` se
+# evita. La comprobación es a gritos: un `cp` mudo dejaría la variable apuntando a un dir VACÍO y el
+# panic volvería con el error lejos de la causa.
+XMLDIR="$PWD/.atspi-lockstep-xml"
+mkdir -p "$XMLDIR"
+ls vendor/atspi-common/xml/*.xml >/dev/null 2>&1 || { echo "no hay vendor/atspi-common/xml/*.xml — ¿cambió el vendoreo o la versión de atspi? sin esto zbus-lockstep-macros panica en xml/schemas/" >&2; exit 1; }
+cp vendor/atspi-common/xml/*.xml "$XMLDIR"/
+export LOCKSTEP_XML_PATH="$XMLDIR"
+
+# El envoltorio de zig-cc, igual que en las otras winit+wgpu del corpus.
+printf '%s\n' '#!/bin/sh' 'for a do' 'case "$a" in --target=*) a=--target=x86_64-linux-musl ;; esac' 'set -- "$@" "$a"' 'shift' 'done' 'exec zig cc -mcpu=baseline "$@"' > "$PWD/.hammer-zig-cc"
+chmod +x "$PWD/.hammer-zig-cc"
+printf '%s\n' '#!/bin/sh' 'exec zig ar "$@"' > "$PWD/.hammer-zig-ar"
+chmod +x "$PWD/.hammer-zig-ar"
+RF="-C linker=$PWD/.hammer-zig-cc"
+rustc -vV | grep -q 'host: .*-alpine-' || RF="$RF -C link-self-contained=no"
+CC="$PWD/.hammer-zig-cc" AR="$PWD/.hammer-zig-ar" RUSTFLAGS="$RF" cargo rustc --release --locked --offline -p designcraft --bin designcraft -- -C target-feature=-crt-static
+'''
+install = '''
+set -e
+test -x target/release/designcraft || { echo "no hay target/release/designcraft — ¿cambió el nombre del bin?" >&2; exit 1; }
+install -Dm755 target/release/designcraft /out/usr/bin/designcraft
+
+install -Dm644 packaging/linux/ai.storyteller.designcraft.desktop /out/usr/share/applications/ai.storyteller.designcraft.desktop
+install -Dm644 packaging/linux/ai.storyteller.designcraft.mime.xml /out/usr/share/mime/packages/ai.storyteller.designcraft.xml
+# Los iconos se recorren con un glob y NO con una lista de tamaños: `effectcraft` y `filmcraft`
+# traen el doble de entradas que las demás, y una lista fija los habría instalado a medias en
+# silencio. Se cuenta lo instalado y se falla si es cero.
+n=0
+for p in assets/app-icon/hicolor/*/apps/ai.storyteller.designcraft.*; do
+ [ -f "$p" ] || continue
+ install -Dm644 "$p" "/out/usr/share/icons/hicolor/${p#assets/app-icon/hicolor/}"
+ n=$((n+1))
+done
+[ "$n" -gt 0 ] || { echo "ningún icono instalado — ¿cambió assets/app-icon/hicolor/?" >&2; exit 1; }
+install -Dm644 LICENSE-MIT /out/usr/share/licenses/designcraft/LICENSE-MIT
+install -Dm644 LICENSE-APACHE /out/usr/share/licenses/designcraft/LICENSE-APACHE
+
+# Un `.desktop` vacío o un binario de 0 bytes son la forma de fallo de la casa: artefacto presente,
+# función ausente (CLAUDE.md §3).
+test -s /out/usr/share/applications/ai.storyteller.designcraft.desktop
+test -s /out/usr/bin/designcraft
+'''
+
+[deps]
+# El mismo juego que `boveda`/`shuma-pregunta`/`mirada-greeter`: el stack gráfico se consulta en
+# build-time y se carga por `dlopen` en runtime. Las fuentes vienen EMBEBIDAS (`default_fonts` de
+# eframe), así que no hace falta fontconfig ni freetype.
+build = ["mesa", "wayland", "wayland-protocols", "libdrm", "expat", "zlib", "zstd", "libffi", "pkgconf"]
diff --git a/recipes/effectcraft.toml b/recipes/effectcraft.toml
new file mode 100644
index 00000000..2b3fb5bb
--- /dev/null
+++ b/recipes/effectcraft.toml
@@ -0,0 +1,118 @@
+# effectcraft — composición y gráficos en movimiento — el After Effects de la familia
+#
+# ── DE DÓNDE SALE ESTA FAMILIA ─────────────────────────────────────────────────────────────────
+# ArtCraft publica doce aplicaciones de escritorio en **Rust puro sobre egui + wgpu**, Apache-2.0 o
+# MIT, cada una en su propio repo de `github.com/storytold`. Para esta casa son interesantes por una
+# razón concreta y medible: **no tienen ni una línea de GTK ni de Qt**, que es el muro que deja a
+# `libreoffice` en «59 recetas y tres muros ⇒ no es un ticket, es un año»
+# (`docs/plan-apps-usuario-final.md:48`). Acá cada app es UNA receta `cargo_vendor`.
+#
+# La primera que se midió fue `gridcraft`, y la medición está en su receta: sella en 12 min, abre
+# ventana en el perfil `escritorio-sway` y **pinta** — `wgpu` toma el backend **GL** sobre EGL/GLES
+# con llvmpipe (GL 4.5), sin Vulkan y sin ninguna variable de entorno, porque el default de
+# `egui-wgpu` es `Backends::from_env().unwrap_or(PRIMARY | GL)`.
+#
+# ⚠ **Son 0.x y se mueven solas.** Portar esto es comprar su cinta de correr (SDD 26 §2). Se pinea
+# el **sha del tag**, no `main`, y por eso la versión de arriba es una release y no «lo de hoy».
+#
+# ── X11: NO SE PARCHEA, Y ESO ES UNA DECISIÓN MEDIDA ──────────────────────────────────────────
+# Las doce declaran `eframe` con los features `wayland` **y** `x11`, y la primera versión de esto
+# arrancaba el feature `x11` con un `sed`. Se quitó, por tres cosas que se midieron al hacerlo:
+# 1. **No hay muro de build**: lo que `x11` arrastra es `x11rb` (protocolo X en Rust puro) y
+# `x11-dl` (que hace `dlopen`, no enlaza). Ninguna receta nueva, ni un `.so` de X en el link.
+# 2. **No hay muro de runtime**: winit elige Wayland cuando `WAYLAND_DISPLAY` está puesto, y en
+# esta distro `libX11` directamente no existe ⇒ el camino X es inerte, no roto.
+# 3. **Quitarlo SÍ cuesta**: cambia el grafo ⇒ el `Cargo.lock` deja de cerrar y hay que soltar el
+# `--locked` del compile (medido: `error: cannot update the lock file … because --locked`).
+# Y varias de estas apps declaran `winit` con `x11` por su cuenta y lo USAN en el código
+# (`photocraft` abre por Xwayland si el usuario lo elige en Preferencias), así que un `sed`
+# ciego rompe el build en unas y es un no-op en otras.
+# ⇒ Se deja el feature y se conserva el `--locked`, que es el pin fuerte. La distro sigue siendo
+# Wayland-only por lo que NO lleva en la imagen, no por lo que esta receta le recorta al upstream.
+#
+# ── LA FORMA ───────────────────────────────────────────────────────────────────────────────────
+# `link = "dynamic"` + `-crt-static` como `boveda`/`shuma-pregunta`/`mirada-greeter`: winit y wgpu
+# cargan `libwayland-client`, `libEGL`/`libvulkan` por `dlopen`, y un binario `crt-static` no puede.
+# El lanzador, el MIME y los iconos son de UPSTREAM — lo contrario de `boveda.toml`, donde hay que
+# autorar el `.desktop` — y están enhebrados: `Icon=`/`StartupWMClass=` del `.desktop`, el nombre de
+# los iconos hicolor y el `with_app_id()` de la app coinciden en `ai.storyteller.effectcraft`, así que
+# el emparejamiento ventana↔lanzador funciona en Wayland sin parchear nada.
+#
+name = "effectcraft"
+version = "0.9.2"
+license = "MIT OR Apache-2.0"
+
+[source]
+# `v0.9.2`, el tag vigente al 2026-10-12. Se pinea el SHA: un tag se mueve.
+repo = "https://github.com/storytold/effectcraft"
+commit = "470ea7a3af82ee1dd1d2f86c777fe6891bd0c37b"
+# ⚠ `vendor_cargo_deps` **sobrescribe** `<src>/.cargo/config.toml` con `std::fs::write`
+# (`crates/takana-build/src/fetch.rs:852`), y estos repos traen uno propio. Hoy es inocuo —dentro
+# sólo hay `[alias] xtask`, que estas recetas no usan— pero si un bump mete ahí un `rustflags` o un
+# `linker`, desaparece sin un solo aviso y el fallo no va a nombrar al config.
+# El `Cargo.lock` está committeado ⇒ el vendoreo va con `--locked` y queda pineado en el tiempo.
+
+[build]
+compiler = "zig-cc"
+target = "x86_64-linux-musl"
+link = "dynamic"
+flags = ["-p", "effectcraft", "--bin", "effectcraft"]
+
+[build.phases]
+compile = '''
+set -e
+# LOCKSTEP_XML_PATH — la causa entera está en `mirada-greeter.toml` y repetida en `boveda.toml`:
+# `accesskit` → `atspi` → `atspi-common`, que trae `zbus-lockstep-macros` como dep NORMAL y cuyo
+# `#[validate]` recorre el dir `xml/` llamando `.extension().expect()` en CADA entrada ⇒ PANICA al
+# toparse con el subdirectorio `xml/schemas/`. Apuntando la variable a un dir con sólo los `.xml` se
+# evita. La comprobación es a gritos: un `cp` mudo dejaría la variable apuntando a un dir VACÍO y el
+# panic volvería con el error lejos de la causa.
+XMLDIR="$PWD/.atspi-lockstep-xml"
+mkdir -p "$XMLDIR"
+ls vendor/atspi-common/xml/*.xml >/dev/null 2>&1 || { echo "no hay vendor/atspi-common/xml/*.xml — ¿cambió el vendoreo o la versión de atspi? sin esto zbus-lockstep-macros panica en xml/schemas/" >&2; exit 1; }
+cp vendor/atspi-common/xml/*.xml "$XMLDIR"/
+export LOCKSTEP_XML_PATH="$XMLDIR"
+
+# El envoltorio de zig-cc, igual que en las otras winit+wgpu del corpus.
+printf '%s\n' '#!/bin/sh' 'for a do' 'case "$a" in --target=*) a=--target=x86_64-linux-musl ;; esac' 'set -- "$@" "$a"' 'shift' 'done' 'exec zig cc -mcpu=baseline "$@"' > "$PWD/.hammer-zig-cc"
+chmod +x "$PWD/.hammer-zig-cc"
+printf '%s\n' '#!/bin/sh' 'exec zig ar "$@"' > "$PWD/.hammer-zig-ar"
+chmod +x "$PWD/.hammer-zig-ar"
+RF="-C linker=$PWD/.hammer-zig-cc"
+rustc -vV | grep -q 'host: .*-alpine-' || RF="$RF -C link-self-contained=no"
+CC="$PWD/.hammer-zig-cc" AR="$PWD/.hammer-zig-ar" RUSTFLAGS="$RF" cargo rustc --release --locked --offline -p effectcraft --bin effectcraft -- -C target-feature=-crt-static
+'''
+install = '''
+set -e
+test -x target/release/effectcraft || { echo "no hay target/release/effectcraft — ¿cambió el nombre del bin?" >&2; exit 1; }
+install -Dm755 target/release/effectcraft /out/usr/bin/effectcraft
+
+install -Dm644 packaging/linux/ai.storyteller.effectcraft.desktop /out/usr/share/applications/ai.storyteller.effectcraft.desktop
+install -Dm644 packaging/linux/ai.storyteller.effectcraft.mime.xml /out/usr/share/mime/packages/ai.storyteller.effectcraft.xml
+# Los iconos se recorren con un glob y NO con una lista de tamaños: `effectcraft` y `filmcraft`
+# traen el doble de entradas que las demás, y una lista fija los habría instalado a medias en
+# silencio. Se cuenta lo instalado y se falla si es cero.
+n=0
+for p in assets/app-icon/hicolor/*/apps/ai.storyteller.effectcraft.*; do
+ [ -f "$p" ] || continue
+ install -Dm644 "$p" "/out/usr/share/icons/hicolor/${p#assets/app-icon/hicolor/}"
+ n=$((n+1))
+done
+[ "$n" -gt 0 ] || { echo "ningún icono instalado — ¿cambió assets/app-icon/hicolor/?" >&2; exit 1; }
+install -Dm644 LICENSE-MIT /out/usr/share/licenses/effectcraft/LICENSE-MIT
+install -Dm644 LICENSE-APACHE /out/usr/share/licenses/effectcraft/LICENSE-APACHE
+
+# Un `.desktop` vacío o un binario de 0 bytes son la forma de fallo de la casa: artefacto presente,
+# función ausente (CLAUDE.md §3).
+test -s /out/usr/share/applications/ai.storyteller.effectcraft.desktop
+test -s /out/usr/bin/effectcraft
+'''
+
+[deps]
+# Igual que las demás de la familia MÁS `alsa-lib`, y ésta no es decorativa: la app trae `cpal`
+# **sin condicionar por plataforma**, que en Linux es `alsa-sys` y pregunta por `alsa.pc` con
+# pkg-config ⇒ sin esta dep el build muere en el `build.rs` de `alsa-sys`, no en el link.
+# ⚠ Y el corpus hoy NO publica `alsa`: `scripts/provee.py --desde corpus alsa` sale «NADIE LO
+# PUBLICA en ninguna cola» aunque `recipes/alsa-lib.toml` exista — la receta está escrita y sin
+# sellar. Declararla acá la construye; no hay que hacer nada aparte.
+build = ["mesa", "wayland", "wayland-protocols", "libdrm", "expat", "zlib", "zstd", "libffi", "pkgconf", "alsa-lib"]
diff --git a/recipes/filmcraft.toml b/recipes/filmcraft.toml
new file mode 100644
index 00000000..7162178a
--- /dev/null
+++ b/recipes/filmcraft.toml
@@ -0,0 +1,122 @@
+# filmcraft — el editor de vídeo no lineal
+#
+# ── DE DÓNDE SALE ESTA FAMILIA ─────────────────────────────────────────────────────────────────
+# ArtCraft publica doce aplicaciones de escritorio en **Rust puro sobre egui + wgpu**, Apache-2.0 o
+# MIT, cada una en su propio repo de `github.com/storytold`. Para esta casa son interesantes por una
+# razón concreta y medible: **no tienen ni una línea de GTK ni de Qt**, que es el muro que deja a
+# `libreoffice` en «59 recetas y tres muros ⇒ no es un ticket, es un año»
+# (`docs/plan-apps-usuario-final.md:48`). Acá cada app es UNA receta `cargo_vendor`.
+#
+# La primera que se midió fue `gridcraft`, y la medición está en su receta: sella en 12 min, abre
+# ventana en el perfil `escritorio-sway` y **pinta** — `wgpu` toma el backend **GL** sobre EGL/GLES
+# con llvmpipe (GL 4.5), sin Vulkan y sin ninguna variable de entorno, porque el default de
+# `egui-wgpu` es `Backends::from_env().unwrap_or(PRIMARY | GL)`.
+#
+# ⚠ **Son 0.x y se mueven solas.** Portar esto es comprar su cinta de correr (SDD 26 §2). Se pinea
+# el **sha del tag**, no `main`, y por eso la versión de arriba es una release y no «lo de hoy».
+#
+# ── X11: NO SE PARCHEA, Y ESO ES UNA DECISIÓN MEDIDA ──────────────────────────────────────────
+# Las doce declaran `eframe` con los features `wayland` **y** `x11`, y la primera versión de esto
+# arrancaba el feature `x11` con un `sed`. Se quitó, por tres cosas que se midieron al hacerlo:
+# 1. **No hay muro de build**: lo que `x11` arrastra es `x11rb` (protocolo X en Rust puro) y
+# `x11-dl` (que hace `dlopen`, no enlaza). Ninguna receta nueva, ni un `.so` de X en el link.
+# 2. **No hay muro de runtime**: winit elige Wayland cuando `WAYLAND_DISPLAY` está puesto, y en
+# esta distro `libX11` directamente no existe ⇒ el camino X es inerte, no roto.
+# 3. **Quitarlo SÍ cuesta**: cambia el grafo ⇒ el `Cargo.lock` deja de cerrar y hay que soltar el
+# `--locked` del compile (medido: `error: cannot update the lock file … because --locked`).
+# Y varias de estas apps declaran `winit` con `x11` por su cuenta y lo USAN en el código
+# (`photocraft` abre por Xwayland si el usuario lo elige en Preferencias), así que un `sed`
+# ciego rompe el build en unas y es un no-op en otras.
+# ⇒ Se deja el feature y se conserva el `--locked`, que es el pin fuerte. La distro sigue siendo
+# Wayland-only por lo que NO lleva en la imagen, no por lo que esta receta le recorta al upstream.
+#
+# ── LA FORMA ───────────────────────────────────────────────────────────────────────────────────
+# `link = "dynamic"` + `-crt-static` como `boveda`/`shuma-pregunta`/`mirada-greeter`: winit y wgpu
+# cargan `libwayland-client`, `libEGL`/`libvulkan` por `dlopen`, y un binario `crt-static` no puede.
+# El lanzador, el MIME y los iconos son de UPSTREAM — lo contrario de `boveda.toml`, donde hay que
+# autorar el `.desktop` — y están enhebrados: `Icon=`/`StartupWMClass=` del `.desktop`, el nombre de
+# los iconos hicolor y el `with_app_id()` de la app coinciden en `ai.storyteller.filmcraft`, así que
+# el emparejamiento ventana↔lanzador funciona en Wayland sin parchear nada.
+#
+# ⚠ `gtk-sys`/`gdk-sys`/`pango-sys` aparecen en el lock y **no se compilan en Linux**: entran por
+# `muda`, que es el menú nativo de macOS. Mismo caso que `photocraft`; se deja escrito porque un
+# triaje por lock lo lee como «GTK3» y marcaría la app como bloqueada sin estarlo.
+#
+name = "filmcraft"
+version = "0.7.2"
+license = "MIT OR Apache-2.0"
+
+[source]
+# `v0.7.2`, el tag vigente al 2026-10-12. Se pinea el SHA: un tag se mueve.
+repo = "https://github.com/storytold/filmcraft"
+commit = "71438c627b5cbcfb550526c3971f4d0037733c5c"
+# ⚠ `vendor_cargo_deps` **sobrescribe** `<src>/.cargo/config.toml` con `std::fs::write`
+# (`crates/takana-build/src/fetch.rs:852`), y estos repos traen uno propio. Hoy es inocuo —dentro
+# sólo hay `[alias] xtask`, que estas recetas no usan— pero si un bump mete ahí un `rustflags` o un
+# `linker`, desaparece sin un solo aviso y el fallo no va a nombrar al config.
+# El `Cargo.lock` está committeado ⇒ el vendoreo va con `--locked` y queda pineado en el tiempo.
+
+[build]
+compiler = "zig-cc"
+target = "x86_64-linux-musl"
+link = "dynamic"
+flags = ["-p", "filmcraft", "--bin", "filmcraft"]
+
+[build.phases]
+compile = '''
+set -e
+# LOCKSTEP_XML_PATH — la causa entera está en `mirada-greeter.toml` y repetida en `boveda.toml`:
+# `accesskit` → `atspi` → `atspi-common`, que trae `zbus-lockstep-macros` como dep NORMAL y cuyo
+# `#[validate]` recorre el dir `xml/` llamando `.extension().expect()` en CADA entrada ⇒ PANICA al
+# toparse con el subdirectorio `xml/schemas/`. Apuntando la variable a un dir con sólo los `.xml` se
+# evita. La comprobación es a gritos: un `cp` mudo dejaría la variable apuntando a un dir VACÍO y el
+# panic volvería con el error lejos de la causa.
+XMLDIR="$PWD/.atspi-lockstep-xml"
+mkdir -p "$XMLDIR"
+ls vendor/atspi-common/xml/*.xml >/dev/null 2>&1 || { echo "no hay vendor/atspi-common/xml/*.xml — ¿cambió el vendoreo o la versión de atspi? sin esto zbus-lockstep-macros panica en xml/schemas/" >&2; exit 1; }
+cp vendor/atspi-common/xml/*.xml "$XMLDIR"/
+export LOCKSTEP_XML_PATH="$XMLDIR"
+
+# El envoltorio de zig-cc, igual que en las otras winit+wgpu del corpus.
+printf '%s\n' '#!/bin/sh' 'for a do' 'case "$a" in --target=*) a=--target=x86_64-linux-musl ;; esac' 'set -- "$@" "$a"' 'shift' 'done' 'exec zig cc -mcpu=baseline "$@"' > "$PWD/.hammer-zig-cc"
+chmod +x "$PWD/.hammer-zig-cc"
+printf '%s\n' '#!/bin/sh' 'exec zig ar "$@"' > "$PWD/.hammer-zig-ar"
+chmod +x "$PWD/.hammer-zig-ar"
+RF="-C linker=$PWD/.hammer-zig-cc"
+rustc -vV | grep -q 'host: .*-alpine-' || RF="$RF -C link-self-contained=no"
+CC="$PWD/.hammer-zig-cc" AR="$PWD/.hammer-zig-ar" RUSTFLAGS="$RF" cargo rustc --release --locked --offline -p filmcraft --bin filmcraft -- -C target-feature=-crt-static
+'''
+install = '''
+set -e
+test -x target/release/filmcraft || { echo "no hay target/release/filmcraft — ¿cambió el nombre del bin?" >&2; exit 1; }
+install -Dm755 target/release/filmcraft /out/usr/bin/filmcraft
+
+install -Dm644 packaging/linux/ai.storyteller.filmcraft.desktop /out/usr/share/applications/ai.storyteller.filmcraft.desktop
+install -Dm644 packaging/linux/ai.storyteller.filmcraft.mime.xml /out/usr/share/mime/packages/ai.storyteller.filmcraft.xml
+# Los iconos se recorren con un glob y NO con una lista de tamaños: `effectcraft` y `filmcraft`
+# traen el doble de entradas que las demás, y una lista fija los habría instalado a medias en
+# silencio. Se cuenta lo instalado y se falla si es cero.
+n=0
+for p in assets/app-icon/hicolor/*/apps/ai.storyteller.filmcraft.*; do
+ [ -f "$p" ] || continue
+ install -Dm644 "$p" "/out/usr/share/icons/hicolor/${p#assets/app-icon/hicolor/}"
+ n=$((n+1))
+done
+[ "$n" -gt 0 ] || { echo "ningún icono instalado — ¿cambió assets/app-icon/hicolor/?" >&2; exit 1; }
+install -Dm644 LICENSE-MIT /out/usr/share/licenses/filmcraft/LICENSE-MIT
+install -Dm644 LICENSE-APACHE /out/usr/share/licenses/filmcraft/LICENSE-APACHE
+
+# Un `.desktop` vacío o un binario de 0 bytes son la forma de fallo de la casa: artefacto presente,
+# función ausente (CLAUDE.md §3).
+test -s /out/usr/share/applications/ai.storyteller.filmcraft.desktop
+test -s /out/usr/bin/filmcraft
+'''
+
+[deps]
+# Igual que las demás de la familia MÁS `alsa-lib`, y ésta no es decorativa: la app trae `cpal`
+# **sin condicionar por plataforma**, que en Linux es `alsa-sys` y pregunta por `alsa.pc` con
+# pkg-config ⇒ sin esta dep el build muere en el `build.rs` de `alsa-sys`, no en el link.
+# ⚠ Y el corpus hoy NO publica `alsa`: `scripts/provee.py --desde corpus alsa` sale «NADIE LO
+# PUBLICA en ninguna cola» aunque `recipes/alsa-lib.toml` exista — la receta está escrita y sin
+# sellar. Declararla acá la construye; no hay que hacer nada aparte.
+build = ["mesa", "wayland", "wayland-protocols", "libdrm", "expat", "zlib", "zstd", "libffi", "pkgconf", "alsa-lib"]
diff --git a/recipes/gridcraft.toml b/recipes/gridcraft.toml
index 5758113e..019cccb7 100644
--- a/recipes/gridcraft.toml
+++ b/recipes/gridcraft.toml
@@ -1,142 +1,133 @@
-# gridcraft — la hoja de cálculo de ArtCraft, y la PRIMERA MEDICIÓN de si esta casa puede tener
-# ofimática nativa sin pagar el muro de LibreOffice.
+# gridcraft — la hoja de cálculo: `.xlsx`/`.ods`/`.csv`, fórmulas, tablas dinámicas y los GRÁFICOS de la familia
#
-# ── POR QUÉ ESTA RECETA (2026-10-12) ───────────────────────────────────────────────────────────
-# `docs/plan-apps-usuario-final.md:48` pone a `libreoffice` en **59 recetas que faltan + GTK3 +
-# Qt6 + X11**, con el veredicto «no es un ticket, es un año», y lo saca del montón A. ArtCraft
-# publica el mismo trío de funciones (WordCraft / GridCraft / DeckCraft) en **Rust puro sobre
-# egui 0.36 + wgpu**, MIT OR Apache-2.0, sin una sola línea de GTK ni de Qt: medido en su
-# `Cargo.lock`, que tiene **455 crates y CERO** `gtk*`/`gdk*`/`pango*`/`cairo*`/`fontconfig*`/
-# `cups*`/`openssl*`. O sea que lo que allá son 59 recetas y tres decisiones de distro, acá es
-# **una receta `cargo_vendor`**.
+# ── DE DÓNDE SALE ESTA FAMILIA ─────────────────────────────────────────────────────────────────
+# ArtCraft publica doce aplicaciones de escritorio en **Rust puro sobre egui + wgpu**, Apache-2.0 o
+# MIT, cada una en su propio repo de `github.com/storytold`. Para esta casa son interesantes por una
+# razón concreta y medible: **no tienen ni una línea de GTK ni de Qt**, que es el muro que deja a
+# `libreoffice` en «59 recetas y tres muros ⇒ no es un ticket, es un año»
+# (`docs/plan-apps-usuario-final.md:48`). Acá cada app es UNA receta `cargo_vendor`.
#
-# Se eligió `gridcraft` de las tres y no `wordcraft` por dos razones: es el lock más chico (455
-# contra 554) y es **la que trae el graficador** — `crates/chart`, propio, descrito por upstream
-# como «data resolution, layout into toolkit-free primitives, sparklines and a small rasteriser»,
-# con dependencias `gridcraft-{core,model,calc,numfmt}` + `serde` y nada más. No hay `plotters`,
-# no hay `egui_plot`, no hay KChart: los gráficos se maquetan a primitivas sin toolkit y se
-# rasterizan a mano, así que el graficador **no agrega ni un muro**.
+# La primera que se midió fue `gridcraft`, y la medición está en su receta: sella en 12 min, abre
+# ventana en el perfil `escritorio-sway` y **pinta** — `wgpu` toma el backend **GL** sobre EGL/GLES
+# con llvmpipe (GL 4.5), sin Vulkan y sin ninguna variable de entorno, porque el default de
+# `egui-wgpu` es `Backends::from_env().unwrap_or(PRIMARY | GL)`.
#
-# ⚠ **Esta receta es un INSTRUMENTO, no un compromiso.** Upstream va en 0.6.1 y pasó de 0.5.0 a
-# 0.6.1 en el mismo mes: portar esto es comprar su cinta de correr (SDD 26 §2, «el costo de Zen no
-# es el motor: es el rebase cada cuatro semanas»). Lo que se mide acá es si la CADENA cierra —musl
-# + zig-cc + wayland-only + wgpu sin Vulkan—, no si la app sirve para trabajar. Por eso **no entra
-# en ningún perfil** todavía ([[paquete-opcional-por-construccion]]: la Etapa F la publica igual).
+# ⚠ **Son 0.x y se mueven solas.** Portar esto es comprar su cinta de correr (SDD 26 §2). Se pinea
+# el **sha del tag**, no `main`, y por eso la versión de arriba es una release y no «lo de hoy».
#
-# ── LOS DOS MUROS QUE ESTA RECETA TIENE QUE SORTEAR, Y CÓMO ────────────────────────────────────
-# 1. **X11 está enchufado A MANO en el manifiesto de la app**, no por default:
-# `eframe = { features = ["default_fonts", "wgpu", "accesskit", "wayland", "x11"] }`. La distro
-# es Wayland-only (X11 al tacho para todo el corpus), así que el feature se saca en el
-# `compile` con un `sed` que FALLA A GRITOS si el patrón cambia. Sacarlo no toca el
-# `Cargo.lock` —un feature no cambia el grafo de deps, sólo qué se compila— así que el
-# `--locked` sigue en pie. Nota: `x11-dl`/`x11rb` son Rust puro y hacen `dlopen`, o sea que el
-# build pasaría igual sin el `sed`; lo que se evita es un camino de runtime que en esta distro
-# **no puede resolver nunca** y queda de peso muerto en la imagen.
-# 2. **wgpu sin Vulkan.** El lock trae `ash` (Vulkan) y `khronos-egl`/`glutin_egl_sys` (GL), y acá
-# **Vulkan no existe en ninguna imagen**: las tres mesa van `-Dvulkan-drivers=` vacío y
-# `vulkan-loader` sólo está en `incoming-kde` sin perfil que lo declare. Es exactamente donde
-# llimphi se comió el `index out of bounds: the len is 0 but the index is 0` de
-# `caps.formats[0]` (SDD 26 §7.septies). **Diferencia a favor, y es la que hay que medir:**
-# `apps/gridcraft/src/main.rs` lee `Backends::from_env()` y, si el primer backend no arranca,
-# **reintenta con los otros** (`graphics: the renderer didn't start on … trying …`), y eframe sí
-# le pasa el display handle a wgpu —lo que a llimphi le faltaba—. Con `WGPU_BACKEND=gl` sobre
-# `mesa-llvmpipe` + `llvm18` el camino debería cerrar por EGL/GLES. **Eso se mide, no se supone.**
+# ── X11: NO SE PARCHEA, Y ESO ES UNA DECISIÓN MEDIDA ──────────────────────────────────────────
+# Las doce declaran `eframe` con los features `wayland` **y** `x11`, y la primera versión de esto
+# arrancaba el feature `x11` con un `sed`. Se quitó, por tres cosas que se midieron al hacerlo:
+# 1. **No hay muro de build**: lo que `x11` arrastra es `x11rb` (protocolo X en Rust puro) y
+# `x11-dl` (que hace `dlopen`, no enlaza). Ninguna receta nueva, ni un `.so` de X en el link.
+# 2. **No hay muro de runtime**: winit elige Wayland cuando `WAYLAND_DISPLAY` está puesto, y en
+# esta distro `libX11` directamente no existe ⇒ el camino X es inerte, no roto.
+# 3. **Quitarlo SÍ cuesta**: cambia el grafo ⇒ el `Cargo.lock` deja de cerrar y hay que soltar el
+# `--locked` del compile (medido: `error: cannot update the lock file … because --locked`).
+# Y varias de estas apps declaran `winit` con `x11` por su cuenta y lo USAN en el código
+# (`photocraft` abre por Xwayland si el usuario lo elige en Preferencias), así que un `sed`
+# ciego rompe el build en unas y es un no-op en otras.
+# ⇒ Se deja el feature y se conserva el `--locked`, que es el pin fuerte. La distro sigue siendo
+# Wayland-only por lo que NO lleva en la imagen, no por lo que esta receta le recorta al upstream.
+#
+# ── LA FORMA ───────────────────────────────────────────────────────────────────────────────────
+# `link = "dynamic"` + `-crt-static` como `boveda`/`shuma-pregunta`/`mirada-greeter`: winit y wgpu
+# cargan `libwayland-client`, `libEGL`/`libvulkan` por `dlopen`, y un binario `crt-static` no puede.
+# El lanzador, el MIME y los iconos son de UPSTREAM — lo contrario de `boveda.toml`, donde hay que
+# autorar el `.desktop` — y están enhebrados: `Icon=`/`StartupWMClass=` del `.desktop`, el nombre de
+# los iconos hicolor y el `with_app_id()` de la app coinciden en `ai.storyteller.gridcraft`, así que
+# el emparejamiento ventana↔lanzador funciona en Wayland sin parchear nada.
+#
+# ── LA MEDICIÓN, QUE ES LA RAZÓN DE QUE ESTA FAMILIA ENTRE ─────────────────────────────────────
+# **Sellada el 2026-10-12: `b3:b45312ca`, 12 min 31 s de compile (455 crates, 6 cores).** Y la
+# parte que no se puede falsear, con el arnés de `scripts/test-craft-ventana.sh` sobre el
+# rootfs de `escritorio-sway` + `llvm18` + `mesa-llvmpipe`:
+# · la ventana aparece en el árbol de sway **al segundo**, con `app_id` `ai.storyteller.gridcraft`
+# · la captura tiene **1.274 colores** y el fondo del compositor DESAPARECE de la foto: 62 % de
+# la pantalla es el blanco de la hoja y 19 % el gris de la cinta. Pinta de verdad, no abre y
+# se queda en negro (CLAUDE.md §3: un vacío llega hasta el final diciendo que todo fue bien).
+# · `egui_wgpu` elige `backend: Gl` sobre `llvmpipe … 4.5 (Core Profile) Mesa 24.0.9`
+# ⚠ Y el CONTROL es lo que vuelve útil a la medición: la segunda corrida fue **sin** `WGPU_BACKEND`,
+# que es como arranca en la imagen de verdad, y dio idéntico (1.264 colores, mismo reparto). O sea
+# que **no hace falta ningún envoltorio ni variable**: sin Vulkan, wgpu cae solo al camino GL.
+#
+# El GRAFICADOR de la suite vive acá y es propio: `crates/chart` (`gridcraft-chart`), que upstream
+# describe como «layout into toolkit-free primitives, sparklines and a small rasteriser» y cuyas
+# deps son cuatro crates internos + `serde`. Ni `plotters`, ni `egui_plot`, ni KChart: por eso el
+# graficador no agrega un solo muro.
#
-# ── LO QUE NO SE COMPRA ACÁ ────────────────────────────────────────────────────────────────────
-# Fidelidad. El propio README de upstream pone a la beta a prueba «against real-world .docx files,
-# keeping charts and SmartArt», o sea que la preservación de lo que no entiende es trabajo abierto.
-# LibreOffice sigue siendo el patrón de fidelidad y **no se autora**: si hace falta de verdad, sale
-# por qorpa (ADR 0015), igual que se decidió para `genoffice`.
name = "gridcraft"
version = "0.6.1"
license = "MIT OR Apache-2.0"
[source]
-# `v0.6.1` = el tag, que hoy coincide con `main`. Se pinea el sha, no el tag: un tag se mueve.
+# `v0.6.1`, el tag vigente al 2026-10-12. Se pinea el SHA: un tag se mueve.
repo = "https://github.com/storytold/gridcraft"
commit = "fc45b7f6d8ab79ef20f74e3c8347a2fd731e6af5"
# ⚠ `vendor_cargo_deps` **sobrescribe** `<src>/.cargo/config.toml` con `std::fs::write`
-# (`crates/takana-build/src/fetch.rs:852`), y este repo trae uno propio. Hoy es inocuo —dentro sólo
-# hay `[alias] xtask = "run --package xtask --"`, que esta receta no usa— pero si un bump mete ahí
-# un `rustflags` o un `linker`, **desaparece sin un solo aviso** y el fallo no va a nombrar al
-# config. Comprobar ese fichero en cada subida de pin.
-# El `Cargo.lock` SÍ está committeado ⇒ el vendoreo va con `--locked` y el build queda pineado en
-# el tiempo. Una de las deps es git (`craft-print`, de `storytold/craft-libs`, con `rev` fijo):
-# `cargo vendor` la baja igual, porque el vendoreo corre FUERA del sandbox, con red.
+# (`crates/takana-build/src/fetch.rs:852`), y estos repos traen uno propio. Hoy es inocuo —dentro
+# sólo hay `[alias] xtask`, que estas recetas no usan— pero si un bump mete ahí un `rustflags` o un
+# `linker`, desaparece sin un solo aviso y el fallo no va a nombrar al config.
+# El `Cargo.lock` está committeado ⇒ el vendoreo va con `--locked` y queda pineado en el tiempo.
[build]
compiler = "zig-cc"
target = "x86_64-linux-musl"
-# `dynamic` + `-crt-static` como `boveda`/`shuma-pregunta`/`mirada-greeter`, y por la misma razón:
-# winit y wgpu cargan `libwayland-client`, `libEGL`/`libvulkan` por `dlopen` en runtime, y un
-# binario `crt-static` no puede hacerlo.
link = "dynamic"
flags = ["-p", "gridcraft", "--bin", "gridcraft"]
[build.phases]
compile = '''
set -e
-
-# ── 1. Wayland-only: fuera el feature `x11` de eframe ─────────────────────────────────────────
-M=apps/gridcraft/Cargo.toml
-grep -q '"x11"' "$M" || { echo "ERROR: $M ya no pide el feature x11 de eframe. Si upstream lo quitó, borrá este bloque; si lo movió, seguilo. No sigo a ciegas." >&2; exit 1; }
-sed -i 's/, "x11"//g; s/"x11", //g' "$M"
-if grep -q '"x11"' "$M"; then echo "ERROR: quedó un \"x11\" en $M tras el sed" >&2; exit 1; fi
-grep -q '"wayland"' "$M" || { echo "ERROR: se fue el feature wayland de eframe junto con x11 — el sed se comió de más" >&2; exit 1; }
-
-# ── 2. LOCKSTEP_XML_PATH ──────────────────────────────────────────────────────────────────────
-# La causa entera está escrita en `mirada-greeter.toml` y repetida en `boveda.toml`/
-# `shuma-pregunta.toml`, y es LA MISMA acá: `accesskit` → `atspi` → `atspi-common` 0.13, que trae
-# `zbus-lockstep-macros` como dep NORMAL (no dev-dep: está en su lista de `dependencies` del lock),
-# y cuyo `#[validate]` recorre el dir `xml/` llamando `.extension().expect()` en CADA entrada ⇒
-# PANICA al toparse con el subdirectorio `xml/schemas/`. Apuntando la variable a un dir que sólo
-# tiene los `.xml` se evita.
-# La comprobación es a gritos a propósito: un `cp` que fallara en silencio dejaría la variable
-# apuntando a un directorio VACÍO y el panic volvería con el error lejos de la causa.
+# LOCKSTEP_XML_PATH — la causa entera está en `mirada-greeter.toml` y repetida en `boveda.toml`:
+# `accesskit` → `atspi` → `atspi-common`, que trae `zbus-lockstep-macros` como dep NORMAL y cuyo
+# `#[validate]` recorre el dir `xml/` llamando `.extension().expect()` en CADA entrada ⇒ PANICA al
+# toparse con el subdirectorio `xml/schemas/`. Apuntando la variable a un dir con sólo los `.xml` se
+# evita. La comprobación es a gritos: un `cp` mudo dejaría la variable apuntando a un dir VACÍO y el
+# panic volvería con el error lejos de la causa.
XMLDIR="$PWD/.atspi-lockstep-xml"
mkdir -p "$XMLDIR"
-ls vendor/atspi-common/xml/*.xml >/dev/null 2>&1 || { echo "ERROR: no hay vendor/atspi-common/xml/*.xml — ¿cambió el vendoreo o la versión de atspi? sin esto zbus-lockstep-macros panica en xml/schemas/" >&2; exit 1; }
+ls vendor/atspi-common/xml/*.xml >/dev/null 2>&1 || { echo "no hay vendor/atspi-common/xml/*.xml — ¿cambió el vendoreo o la versión de atspi? sin esto zbus-lockstep-macros panica en xml/schemas/" >&2; exit 1; }
cp vendor/atspi-common/xml/*.xml "$XMLDIR"/
export LOCKSTEP_XML_PATH="$XMLDIR"
-# ── 3. El envoltorio de zig-cc, igual que en las otras llimphi/winit del corpus ───────────────
+# El envoltorio de zig-cc, igual que en las otras winit+wgpu del corpus.
printf '%s\n' '#!/bin/sh' 'for a do' 'case "$a" in --target=*) a=--target=x86_64-linux-musl ;; esac' 'set -- "$@" "$a"' 'shift' 'done' 'exec zig cc -mcpu=baseline "$@"' > "$PWD/.hammer-zig-cc"
chmod +x "$PWD/.hammer-zig-cc"
+printf '%s\n' '#!/bin/sh' 'exec zig ar "$@"' > "$PWD/.hammer-zig-ar"
+chmod +x "$PWD/.hammer-zig-ar"
RF="-C linker=$PWD/.hammer-zig-cc"
rustc -vV | grep -q 'host: .*-alpine-' || RF="$RF -C link-self-contained=no"
-CC="$PWD/.hammer-zig-cc" RUSTFLAGS="$RF" cargo rustc --release --locked --offline -p gridcraft --bin gridcraft -- -C target-feature=-crt-static
+CC="$PWD/.hammer-zig-cc" AR="$PWD/.hammer-zig-ar" RUSTFLAGS="$RF" cargo rustc --release --locked --offline -p gridcraft --bin gridcraft -- -C target-feature=-crt-static
'''
install = '''
set -e
test -x target/release/gridcraft || { echo "no hay target/release/gridcraft — ¿cambió el nombre del bin?" >&2; exit 1; }
install -Dm755 target/release/gridcraft /out/usr/bin/gridcraft
-# El lanzador, el MIME y los iconos son de UPSTREAM — no hay que autorarlos, que es lo contrario
-# de `boveda.toml`, donde el `.desktop` se escribe acá. Y están enhebrados de verdad: el `.desktop`
-# dice `Icon=ai.storyteller.gridcraft` y `StartupWMClass=ai.storyteller.gridcraft`, los iconos
-# hicolor se llaman así, y `main.rs` arma la ventana con `.with_app_id("ai.storyteller.gridcraft")`
-# ⇒ en Wayland el `app_id` del xdg-toplevel coincide con el basename del lanzador y el
-# emparejamiento estándar funciona sin parchear nada.
install -Dm644 packaging/linux/ai.storyteller.gridcraft.desktop /out/usr/share/applications/ai.storyteller.gridcraft.desktop
install -Dm644 packaging/linux/ai.storyteller.gridcraft.mime.xml /out/usr/share/mime/packages/ai.storyteller.gridcraft.xml
-for d in 16 24 32 48 64 128 256 512; do
- install -Dm644 "assets/app-icon/hicolor/${d}x${d}/apps/ai.storyteller.gridcraft.png" "/out/usr/share/icons/hicolor/${d}x${d}/apps/ai.storyteller.gridcraft.png"
+# Los iconos se recorren con un glob y NO con una lista de tamaños: `effectcraft` y `filmcraft`
+# traen el doble de entradas que las demás, y una lista fija los habría instalado a medias en
+# silencio. Se cuenta lo instalado y se falla si es cero.
+n=0
+for p in assets/app-icon/hicolor/*/apps/ai.storyteller.gridcraft.*; do
+ [ -f "$p" ] || continue
+ install -Dm644 "$p" "/out/usr/share/icons/hicolor/${p#assets/app-icon/hicolor/}"
+ n=$((n+1))
done
-install -Dm644 assets/app-icon/hicolor/scalable/apps/ai.storyteller.gridcraft.svg /out/usr/share/icons/hicolor/scalable/apps/ai.storyteller.gridcraft.svg
+[ "$n" -gt 0 ] || { echo "ningún icono instalado — ¿cambió assets/app-icon/hicolor/?" >&2; exit 1; }
install -Dm644 LICENSE-MIT /out/usr/share/licenses/gridcraft/LICENSE-MIT
install -Dm644 LICENSE-APACHE /out/usr/share/licenses/gridcraft/LICENSE-APACHE
# Un `.desktop` vacío o un binario de 0 bytes son la forma de fallo de la casa: artefacto presente,
-# función ausente. Se comprueban los dos (CLAUDE.md §3).
+# función ausente (CLAUDE.md §3).
test -s /out/usr/share/applications/ai.storyteller.gridcraft.desktop
test -s /out/usr/bin/gridcraft
'''
[deps]
# El mismo juego que `boveda`/`shuma-pregunta`/`mirada-greeter`: el stack gráfico se consulta en
-# build-time y se carga por `dlopen` en runtime. No hay ni un sys-crate de C en el lock —ni
-# openssl, ni fontconfig, ni freetype, ni cups: las fuentes vienen EMBEBIDAS por el feature
-# `default_fonts` de eframe (`epaint_default_fonts`), y `image` va con `png`+`jpeg` en Rust puro—
-# así que esta lista es para que el entorno de build sea el mismo que el de las otras winit+wgpu
-# del corpus, no porque un `.pc` concreto haga falta.
+# build-time y se carga por `dlopen` en runtime. Las fuentes vienen EMBEBIDAS (`default_fonts` de
+# eframe), así que no hace falta fontconfig ni freetype.
build = ["mesa", "wayland", "wayland-protocols", "libdrm", "expat", "zlib", "zstd", "libffi", "pkgconf"]
diff --git a/recipes/lightcraft.toml b/recipes/lightcraft.toml
new file mode 100644
index 00000000..e79449ab
--- /dev/null
+++ b/recipes/lightcraft.toml
@@ -0,0 +1,118 @@
+# lightcraft — el revelado no destructivo de RAW y la fototeca — el Lightroom de la familia
+#
+# ── DE DÓNDE SALE ESTA FAMILIA ─────────────────────────────────────────────────────────────────
+# ArtCraft publica doce aplicaciones de escritorio en **Rust puro sobre egui + wgpu**, Apache-2.0 o
+# MIT, cada una en su propio repo de `github.com/storytold`. Para esta casa son interesantes por una
+# razón concreta y medible: **no tienen ni una línea de GTK ni de Qt**, que es el muro que deja a
+# `libreoffice` en «59 recetas y tres muros ⇒ no es un ticket, es un año»
+# (`docs/plan-apps-usuario-final.md:48`). Acá cada app es UNA receta `cargo_vendor`.
+#
+# La primera que se midió fue `gridcraft`, y la medición está en su receta: sella en 12 min, abre
+# ventana en el perfil `escritorio-sway` y **pinta** — `wgpu` toma el backend **GL** sobre EGL/GLES
+# con llvmpipe (GL 4.5), sin Vulkan y sin ninguna variable de entorno, porque el default de
+# `egui-wgpu` es `Backends::from_env().unwrap_or(PRIMARY | GL)`.
+#
+# ⚠ **Son 0.x y se mueven solas.** Portar esto es comprar su cinta de correr (SDD 26 §2). Se pinea
+# el **sha del tag**, no `main`, y por eso la versión de arriba es una release y no «lo de hoy».
+#
+# ── X11: NO SE PARCHEA, Y ESO ES UNA DECISIÓN MEDIDA ──────────────────────────────────────────
+# Las doce declaran `eframe` con los features `wayland` **y** `x11`, y la primera versión de esto
+# arrancaba el feature `x11` con un `sed`. Se quitó, por tres cosas que se midieron al hacerlo:
+# 1. **No hay muro de build**: lo que `x11` arrastra es `x11rb` (protocolo X en Rust puro) y
+# `x11-dl` (que hace `dlopen`, no enlaza). Ninguna receta nueva, ni un `.so` de X en el link.
+# 2. **No hay muro de runtime**: winit elige Wayland cuando `WAYLAND_DISPLAY` está puesto, y en
+# esta distro `libX11` directamente no existe ⇒ el camino X es inerte, no roto.
+# 3. **Quitarlo SÍ cuesta**: cambia el grafo ⇒ el `Cargo.lock` deja de cerrar y hay que soltar el
+# `--locked` del compile (medido: `error: cannot update the lock file … because --locked`).
+# Y varias de estas apps declaran `winit` con `x11` por su cuenta y lo USAN en el código
+# (`photocraft` abre por Xwayland si el usuario lo elige en Preferencias), así que un `sed`
+# ciego rompe el build en unas y es un no-op en otras.
+# ⇒ Se deja el feature y se conserva el `--locked`, que es el pin fuerte. La distro sigue siendo
+# Wayland-only por lo que NO lleva en la imagen, no por lo que esta receta le recorta al upstream.
+#
+# ── LA FORMA ───────────────────────────────────────────────────────────────────────────────────
+# `link = "dynamic"` + `-crt-static` como `boveda`/`shuma-pregunta`/`mirada-greeter`: winit y wgpu
+# cargan `libwayland-client`, `libEGL`/`libvulkan` por `dlopen`, y un binario `crt-static` no puede.
+# El lanzador, el MIME y los iconos son de UPSTREAM — lo contrario de `boveda.toml`, donde hay que
+# autorar el `.desktop` — y están enhebrados: `Icon=`/`StartupWMClass=` del `.desktop`, el nombre de
+# los iconos hicolor y el `with_app_id()` de la app coinciden en `ai.storyteller.lightcraft`, así que
+# el emparejamiento ventana↔lanzador funciona en Wayland sin parchear nada.
+#
+# `rav1e` (AV1) está en el lock y es **Rust puro**: no es la libaom de C. `libfuzzer-sys` es
+# dev-dependency y no se compila acá.
+#
+name = "lightcraft"
+version = "0.7.1"
+license = "MIT OR Apache-2.0"
+
+[source]
+# `v0.7.1`, el tag vigente al 2026-10-12. Se pinea el SHA: un tag se mueve.
+repo = "https://github.com/storytold/lightcraft"
+commit = "701f78ea2e586d4748705f10fdf5c4181dc1fd49"
+# ⚠ `vendor_cargo_deps` **sobrescribe** `<src>/.cargo/config.toml` con `std::fs::write`
+# (`crates/takana-build/src/fetch.rs:852`), y estos repos traen uno propio. Hoy es inocuo —dentro
+# sólo hay `[alias] xtask`, que estas recetas no usan— pero si un bump mete ahí un `rustflags` o un
+# `linker`, desaparece sin un solo aviso y el fallo no va a nombrar al config.
+# El `Cargo.lock` está committeado ⇒ el vendoreo va con `--locked` y queda pineado en el tiempo.
+
+[build]
+compiler = "zig-cc"
+target = "x86_64-linux-musl"
+link = "dynamic"
+flags = ["-p", "lightcraft", "--bin", "lightcraft"]
+
+[build.phases]
+compile = '''
+set -e
+# LOCKSTEP_XML_PATH — la causa entera está en `mirada-greeter.toml` y repetida en `boveda.toml`:
+# `accesskit` → `atspi` → `atspi-common`, que trae `zbus-lockstep-macros` como dep NORMAL y cuyo
+# `#[validate]` recorre el dir `xml/` llamando `.extension().expect()` en CADA entrada ⇒ PANICA al
+# toparse con el subdirectorio `xml/schemas/`. Apuntando la variable a un dir con sólo los `.xml` se
+# evita. La comprobación es a gritos: un `cp` mudo dejaría la variable apuntando a un dir VACÍO y el
+# panic volvería con el error lejos de la causa.
+XMLDIR="$PWD/.atspi-lockstep-xml"
+mkdir -p "$XMLDIR"
+ls vendor/atspi-common/xml/*.xml >/dev/null 2>&1 || { echo "no hay vendor/atspi-common/xml/*.xml — ¿cambió el vendoreo o la versión de atspi? sin esto zbus-lockstep-macros panica en xml/schemas/" >&2; exit 1; }
+cp vendor/atspi-common/xml/*.xml "$XMLDIR"/
+export LOCKSTEP_XML_PATH="$XMLDIR"
+
+# El envoltorio de zig-cc, igual que en las otras winit+wgpu del corpus.
+printf '%s\n' '#!/bin/sh' 'for a do' 'case "$a" in --target=*) a=--target=x86_64-linux-musl ;; esac' 'set -- "$@" "$a"' 'shift' 'done' 'exec zig cc -mcpu=baseline "$@"' > "$PWD/.hammer-zig-cc"
+chmod +x "$PWD/.hammer-zig-cc"
+printf '%s\n' '#!/bin/sh' 'exec zig ar "$@"' > "$PWD/.hammer-zig-ar"
+chmod +x "$PWD/.hammer-zig-ar"
+RF="-C linker=$PWD/.hammer-zig-cc"
+rustc -vV | grep -q 'host: .*-alpine-' || RF="$RF -C link-self-contained=no"
+CC="$PWD/.hammer-zig-cc" AR="$PWD/.hammer-zig-ar" RUSTFLAGS="$RF" cargo rustc --release --locked --offline -p lightcraft --bin lightcraft -- -C target-feature=-crt-static
+'''
+install = '''
+set -e
+test -x target/release/lightcraft || { echo "no hay target/release/lightcraft — ¿cambió el nombre del bin?" >&2; exit 1; }
+install -Dm755 target/release/lightcraft /out/usr/bin/lightcraft
+
+install -Dm644 packaging/linux/ai.storyteller.lightcraft.desktop /out/usr/share/applications/ai.storyteller.lightcraft.desktop
+install -Dm644 packaging/linux/ai.storyteller.lightcraft.mime.xml /out/usr/share/mime/packages/ai.storyteller.lightcraft.xml
+# Los iconos se recorren con un glob y NO con una lista de tamaños: `effectcraft` y `filmcraft`
+# traen el doble de entradas que las demás, y una lista fija los habría instalado a medias en
+# silencio. Se cuenta lo instalado y se falla si es cero.
+n=0
+for p in assets/app-icon/hicolor/*/apps/ai.storyteller.lightcraft.*; do
+ [ -f "$p" ] || continue
+ install -Dm644 "$p" "/out/usr/share/icons/hicolor/${p#assets/app-icon/hicolor/}"
+ n=$((n+1))
+done
+[ "$n" -gt 0 ] || { echo "ningún icono instalado — ¿cambió assets/app-icon/hicolor/?" >&2; exit 1; }
+install -Dm644 LICENSE-MIT /out/usr/share/licenses/lightcraft/LICENSE-MIT
+install -Dm644 LICENSE-APACHE /out/usr/share/licenses/lightcraft/LICENSE-APACHE
+
+# Un `.desktop` vacío o un binario de 0 bytes son la forma de fallo de la casa: artefacto presente,
+# función ausente (CLAUDE.md §3).
+test -s /out/usr/share/applications/ai.storyteller.lightcraft.desktop
+test -s /out/usr/bin/lightcraft
+'''
+
+[deps]
+# El mismo juego que `boveda`/`shuma-pregunta`/`mirada-greeter`: el stack gráfico se consulta en
+# build-time y se carga por `dlopen` en runtime. Las fuentes vienen EMBEBIDAS (`default_fonts` de
+# eframe), así que no hace falta fontconfig ni freetype.
+build = ["mesa", "wayland", "wayland-protocols", "libdrm", "expat", "zlib", "zstd", "libffi", "pkgconf"]
diff --git a/recipes/pdfcraft.toml b/recipes/pdfcraft.toml
new file mode 100644
index 00000000..4f7bf56f
--- /dev/null
+++ b/recipes/pdfcraft.toml
@@ -0,0 +1,121 @@
+# pdfcraft — el banco de trabajo de PDF: leer, anotar, reordenar, firmar
+#
+# ── DE DÓNDE SALE ESTA FAMILIA ─────────────────────────────────────────────────────────────────
+# ArtCraft publica doce aplicaciones de escritorio en **Rust puro sobre egui + wgpu**, Apache-2.0 o
+# MIT, cada una en su propio repo de `github.com/storytold`. Para esta casa son interesantes por una
+# razón concreta y medible: **no tienen ni una línea de GTK ni de Qt**, que es el muro que deja a
+# `libreoffice` en «59 recetas y tres muros ⇒ no es un ticket, es un año»
+# (`docs/plan-apps-usuario-final.md:48`). Acá cada app es UNA receta `cargo_vendor`.
+#
+# La primera que se midió fue `gridcraft`, y la medición está en su receta: sella en 12 min, abre
+# ventana en el perfil `escritorio-sway` y **pinta** — `wgpu` toma el backend **GL** sobre EGL/GLES
+# con llvmpipe (GL 4.5), sin Vulkan y sin ninguna variable de entorno, porque el default de
+# `egui-wgpu` es `Backends::from_env().unwrap_or(PRIMARY | GL)`.
+#
+# ⚠ **Son 0.x y se mueven solas.** Portar esto es comprar su cinta de correr (SDD 26 §2). Se pinea
+# el **sha del tag**, no `main`, y por eso la versión de arriba es una release y no «lo de hoy».
+#
+# ── X11: NO SE PARCHEA, Y ESO ES UNA DECISIÓN MEDIDA ──────────────────────────────────────────
+# Las doce declaran `eframe` con los features `wayland` **y** `x11`, y la primera versión de esto
+# arrancaba el feature `x11` con un `sed`. Se quitó, por tres cosas que se midieron al hacerlo:
+# 1. **No hay muro de build**: lo que `x11` arrastra es `x11rb` (protocolo X en Rust puro) y
+# `x11-dl` (que hace `dlopen`, no enlaza). Ninguna receta nueva, ni un `.so` de X en el link.
+# 2. **No hay muro de runtime**: winit elige Wayland cuando `WAYLAND_DISPLAY` está puesto, y en
+# esta distro `libX11` directamente no existe ⇒ el camino X es inerte, no roto.
+# 3. **Quitarlo SÍ cuesta**: cambia el grafo ⇒ el `Cargo.lock` deja de cerrar y hay que soltar el
+# `--locked` del compile (medido: `error: cannot update the lock file … because --locked`).
+# Y varias de estas apps declaran `winit` con `x11` por su cuenta y lo USAN en el código
+# (`photocraft` abre por Xwayland si el usuario lo elige en Preferencias), así que un `sed`
+# ciego rompe el build en unas y es un no-op en otras.
+# ⇒ Se deja el feature y se conserva el `--locked`, que es el pin fuerte. La distro sigue siendo
+# Wayland-only por lo que NO lleva en la imagen, no por lo que esta receta le recorta al upstream.
+#
+# ── LA FORMA ───────────────────────────────────────────────────────────────────────────────────
+# `link = "dynamic"` + `-crt-static` como `boveda`/`shuma-pregunta`/`mirada-greeter`: winit y wgpu
+# cargan `libwayland-client`, `libEGL`/`libvulkan` por `dlopen`, y un binario `crt-static` no puede.
+# El lanzador, el MIME y los iconos son de UPSTREAM — lo contrario de `boveda.toml`, donde hay que
+# autorar el `.desktop` — y están enhebrados: `Icon=`/`StartupWMClass=` del `.desktop`, el nombre de
+# los iconos hicolor y el `with_app_id()` de la app coinciden en `ai.storyteller.pdfcraft`, así que
+# el emparejamiento ventana↔lanzador funciona en Wayland sin parchear nada.
+#
+# ⚠ `aws-lc-sys` aparece en el lock y sería un muro de verdad (C + cmake + perl, y en musl es
+# pelea). Lo que la app USA es `ureq` con `rustls-no-provider` + `_ring`, o sea el proveedor de
+# Rust puro; `aws-lc-sys` queda en el lock sin compilarse. **El lock lista lo posible, no lo que
+# se construye** — si esta receta falla ahí, la salida es un `--no-default-features` con el
+# feature de `ring`, no abandonar la app.
+#
+name = "pdfcraft"
+version = "0.7.1"
+license = "MIT OR Apache-2.0"
+
+[source]
+# `v0.7.1`, el tag vigente al 2026-10-12. Se pinea el SHA: un tag se mueve.
+repo = "https://github.com/storytold/pdfcraft"
+commit = "7163ba3dee8becfff6871c9a65d0d8314314742b"
+# ⚠ `vendor_cargo_deps` **sobrescribe** `<src>/.cargo/config.toml` con `std::fs::write`
+# (`crates/takana-build/src/fetch.rs:852`), y estos repos traen uno propio. Hoy es inocuo —dentro
+# sólo hay `[alias] xtask`, que estas recetas no usan— pero si un bump mete ahí un `rustflags` o un
+# `linker`, desaparece sin un solo aviso y el fallo no va a nombrar al config.
+# El `Cargo.lock` está committeado ⇒ el vendoreo va con `--locked` y queda pineado en el tiempo.
+
+[build]
+compiler = "zig-cc"
+target = "x86_64-linux-musl"
+link = "dynamic"
+flags = ["-p", "pdfcraft", "--bin", "pdfcraft"]
+
+[build.phases]
+compile = '''
+set -e
+# LOCKSTEP_XML_PATH — la causa entera está en `mirada-greeter.toml` y repetida en `boveda.toml`:
+# `accesskit` → `atspi` → `atspi-common`, que trae `zbus-lockstep-macros` como dep NORMAL y cuyo
+# `#[validate]` recorre el dir `xml/` llamando `.extension().expect()` en CADA entrada ⇒ PANICA al
+# toparse con el subdirectorio `xml/schemas/`. Apuntando la variable a un dir con sólo los `.xml` se
+# evita. La comprobación es a gritos: un `cp` mudo dejaría la variable apuntando a un dir VACÍO y el
+# panic volvería con el error lejos de la causa.
+XMLDIR="$PWD/.atspi-lockstep-xml"
+mkdir -p "$XMLDIR"
+ls vendor/atspi-common/xml/*.xml >/dev/null 2>&1 || { echo "no hay vendor/atspi-common/xml/*.xml — ¿cambió el vendoreo o la versión de atspi? sin esto zbus-lockstep-macros panica en xml/schemas/" >&2; exit 1; }
+cp vendor/atspi-common/xml/*.xml "$XMLDIR"/
+export LOCKSTEP_XML_PATH="$XMLDIR"
+
+# El envoltorio de zig-cc, igual que en las otras winit+wgpu del corpus.
+printf '%s\n' '#!/bin/sh' 'for a do' 'case "$a" in --target=*) a=--target=x86_64-linux-musl ;; esac' 'set -- "$@" "$a"' 'shift' 'done' 'exec zig cc -mcpu=baseline "$@"' > "$PWD/.hammer-zig-cc"
+chmod +x "$PWD/.hammer-zig-cc"
+printf '%s\n' '#!/bin/sh' 'exec zig ar "$@"' > "$PWD/.hammer-zig-ar"
+chmod +x "$PWD/.hammer-zig-ar"
+RF="-C linker=$PWD/.hammer-zig-cc"
+rustc -vV | grep -q 'host: .*-alpine-' || RF="$RF -C link-self-contained=no"
+CC="$PWD/.hammer-zig-cc" AR="$PWD/.hammer-zig-ar" RUSTFLAGS="$RF" cargo rustc --release --locked --offline -p pdfcraft --bin pdfcraft -- -C target-feature=-crt-static
+'''
+install = '''
+set -e
+test -x target/release/pdfcraft || { echo "no hay target/release/pdfcraft — ¿cambió el nombre del bin?" >&2; exit 1; }
+install -Dm755 target/release/pdfcraft /out/usr/bin/pdfcraft
+
+install -Dm644 packaging/linux/ai.storyteller.pdfcraft.desktop /out/usr/share/applications/ai.storyteller.pdfcraft.desktop
+install -Dm644 packaging/linux/ai.storyteller.pdfcraft.mime.xml /out/usr/share/mime/packages/ai.storyteller.pdfcraft.xml
+# Los iconos se recorren con un glob y NO con una lista de tamaños: `effectcraft` y `filmcraft`
+# traen el doble de entradas que las demás, y una lista fija los habría instalado a medias en
+# silencio. Se cuenta lo instalado y se falla si es cero.
+n=0
+for p in assets/app-icon/hicolor/*/apps/ai.storyteller.pdfcraft.*; do
+ [ -f "$p" ] || continue
+ install -Dm644 "$p" "/out/usr/share/icons/hicolor/${p#assets/app-icon/hicolor/}"
+ n=$((n+1))
+done
+[ "$n" -gt 0 ] || { echo "ningún icono instalado — ¿cambió assets/app-icon/hicolor/?" >&2; exit 1; }
+install -Dm644 LICENSE-MIT /out/usr/share/licenses/pdfcraft/LICENSE-MIT
+install -Dm644 LICENSE-APACHE /out/usr/share/licenses/pdfcraft/LICENSE-APACHE
+
+# Un `.desktop` vacío o un binario de 0 bytes son la forma de fallo de la casa: artefacto presente,
+# función ausente (CLAUDE.md §3).
+test -s /out/usr/share/applications/ai.storyteller.pdfcraft.desktop
+test -s /out/usr/bin/pdfcraft
+'''
+
+[deps]
+# El mismo juego que `boveda`/`shuma-pregunta`/`mirada-greeter`: el stack gráfico se consulta en
+# build-time y se carga por `dlopen` en runtime. Las fuentes vienen EMBEBIDAS (`default_fonts` de
+# eframe), así que no hace falta fontconfig ni freetype.
+build = ["mesa", "wayland", "wayland-protocols", "libdrm", "expat", "zlib", "zstd", "libffi", "pkgconf"]
diff --git a/recipes/photocraft.toml b/recipes/photocraft.toml
new file mode 100644
index 00000000..5fb030de
--- /dev/null
+++ b/recipes/photocraft.toml
@@ -0,0 +1,126 @@
+# photocraft — el editor de imágenes de la familia — el «photoshop», y con 53 mil estrellas el repo más grande de los doce
+#
+# ── DE DÓNDE SALE ESTA FAMILIA ─────────────────────────────────────────────────────────────────
+# ArtCraft publica doce aplicaciones de escritorio en **Rust puro sobre egui + wgpu**, Apache-2.0 o
+# MIT, cada una en su propio repo de `github.com/storytold`. Para esta casa son interesantes por una
+# razón concreta y medible: **no tienen ni una línea de GTK ni de Qt**, que es el muro que deja a
+# `libreoffice` en «59 recetas y tres muros ⇒ no es un ticket, es un año»
+# (`docs/plan-apps-usuario-final.md:48`). Acá cada app es UNA receta `cargo_vendor`.
+#
+# La primera que se midió fue `gridcraft`, y la medición está en su receta: sella en 12 min, abre
+# ventana en el perfil `escritorio-sway` y **pinta** — `wgpu` toma el backend **GL** sobre EGL/GLES
+# con llvmpipe (GL 4.5), sin Vulkan y sin ninguna variable de entorno, porque el default de
+# `egui-wgpu` es `Backends::from_env().unwrap_or(PRIMARY | GL)`.
+#
+# ⚠ **Son 0.x y se mueven solas.** Portar esto es comprar su cinta de correr (SDD 26 §2). Se pinea
+# el **sha del tag**, no `main`, y por eso la versión de arriba es una release y no «lo de hoy».
+#
+# ── X11: NO SE PARCHEA, Y ESO ES UNA DECISIÓN MEDIDA ──────────────────────────────────────────
+# Las doce declaran `eframe` con los features `wayland` **y** `x11`, y la primera versión de esto
+# arrancaba el feature `x11` con un `sed`. Se quitó, por tres cosas que se midieron al hacerlo:
+# 1. **No hay muro de build**: lo que `x11` arrastra es `x11rb` (protocolo X en Rust puro) y
+# `x11-dl` (que hace `dlopen`, no enlaza). Ninguna receta nueva, ni un `.so` de X en el link.
+# 2. **No hay muro de runtime**: winit elige Wayland cuando `WAYLAND_DISPLAY` está puesto, y en
+# esta distro `libX11` directamente no existe ⇒ el camino X es inerte, no roto.
+# 3. **Quitarlo SÍ cuesta**: cambia el grafo ⇒ el `Cargo.lock` deja de cerrar y hay que soltar el
+# `--locked` del compile (medido: `error: cannot update the lock file … because --locked`).
+# Y varias de estas apps declaran `winit` con `x11` por su cuenta y lo USAN en el código
+# (`photocraft` abre por Xwayland si el usuario lo elige en Preferencias), así que un `sed`
+# ciego rompe el build en unas y es un no-op en otras.
+# ⇒ Se deja el feature y se conserva el `--locked`, que es el pin fuerte. La distro sigue siendo
+# Wayland-only por lo que NO lleva en la imagen, no por lo que esta receta le recorta al upstream.
+#
+# ── LA FORMA ───────────────────────────────────────────────────────────────────────────────────
+# `link = "dynamic"` + `-crt-static` como `boveda`/`shuma-pregunta`/`mirada-greeter`: winit y wgpu
+# cargan `libwayland-client`, `libEGL`/`libvulkan` por `dlopen`, y un binario `crt-static` no puede.
+# El lanzador, el MIME y los iconos son de UPSTREAM — lo contrario de `boveda.toml`, donde hay que
+# autorar el `.desktop` — y están enhebrados: `Icon=`/`StartupWMClass=` del `.desktop`, el nombre de
+# los iconos hicolor y el `with_app_id()` de la app coinciden en `ai.storyteller.photocraft`, así que
+# el emparejamiento ventana↔lanzador funciona en Wayland sin parchear nada.
+#
+# ⚠ **Dos deps que parecen muros en el `Cargo.lock` y NO lo son, y conviene dejarlo escrito**
+# porque un triaje que mire el lock y no los `[target.…]` las cuenta mal:
+# · `pipewire-sys`/`libspa-sys` (+ `bindgen`/`clang-sys`) entran por `xcap`, que está bajo
+# `[target.'cfg(target_os = "windows")'.dependencies]` ⇒ **no se compila en Linux**. Y menos
+# mal: `libpipewire` vive sólo en las colas de escritorio, no en el corpus.
+# · `muda` (que arrastra `gtk-sys`) está bajo `cfg(target_os = "macos")`.
+# Lo que SÍ compila en Linux es `x11rb` en Rust puro y `zbus` para el portal de color.
+# ⚠ Y una de runtime: la app sabe abrir su ventana **por Xwayland** —`Preferencias › Rendimiento
+# › Linux display server = X11`— para la pluma de tableta (#639). Es opt-in Y comprueba que las
+# librerías X estén; en esta distro no están, así que se queda en Wayland por construcción.
+#
+name = "photocraft"
+version = "0.8.2"
+license = "MIT OR Apache-2.0"
+
+[source]
+# `v0.8.2`, el tag vigente al 2026-10-12. Se pinea el SHA: un tag se mueve.
+repo = "https://github.com/storytold/photocraft"
+commit = "d43db3e153c76aefce9b32cef9099e458eaf17a8"
+# ⚠ `vendor_cargo_deps` **sobrescribe** `<src>/.cargo/config.toml` con `std::fs::write`
+# (`crates/takana-build/src/fetch.rs:852`), y estos repos traen uno propio. Hoy es inocuo —dentro
+# sólo hay `[alias] xtask`, que estas recetas no usan— pero si un bump mete ahí un `rustflags` o un
+# `linker`, desaparece sin un solo aviso y el fallo no va a nombrar al config.
+# El `Cargo.lock` está committeado ⇒ el vendoreo va con `--locked` y queda pineado en el tiempo.
+
+[build]
+compiler = "zig-cc"
+target = "x86_64-linux-musl"
+link = "dynamic"
+flags = ["-p", "photocraft", "--bin", "photocraft"]
+
+[build.phases]
+compile = '''
+set -e
+# LOCKSTEP_XML_PATH — la causa entera está en `mirada-greeter.toml` y repetida en `boveda.toml`:
+# `accesskit` → `atspi` → `atspi-common`, que trae `zbus-lockstep-macros` como dep NORMAL y cuyo
+# `#[validate]` recorre el dir `xml/` llamando `.extension().expect()` en CADA entrada ⇒ PANICA al
+# toparse con el subdirectorio `xml/schemas/`. Apuntando la variable a un dir con sólo los `.xml` se
+# evita. La comprobación es a gritos: un `cp` mudo dejaría la variable apuntando a un dir VACÍO y el
+# panic volvería con el error lejos de la causa.
+XMLDIR="$PWD/.atspi-lockstep-xml"
+mkdir -p "$XMLDIR"
+ls vendor/atspi-common/xml/*.xml >/dev/null 2>&1 || { echo "no hay vendor/atspi-common/xml/*.xml — ¿cambió el vendoreo o la versión de atspi? sin esto zbus-lockstep-macros panica en xml/schemas/" >&2; exit 1; }
+cp vendor/atspi-common/xml/*.xml "$XMLDIR"/
+export LOCKSTEP_XML_PATH="$XMLDIR"
+
+# El envoltorio de zig-cc, igual que en las otras winit+wgpu del corpus.
+printf '%s\n' '#!/bin/sh' 'for a do' 'case "$a" in --target=*) a=--target=x86_64-linux-musl ;; esac' 'set -- "$@" "$a"' 'shift' 'done' 'exec zig cc -mcpu=baseline "$@"' > "$PWD/.hammer-zig-cc"
+chmod +x "$PWD/.hammer-zig-cc"
+printf '%s\n' '#!/bin/sh' 'exec zig ar "$@"' > "$PWD/.hammer-zig-ar"
+chmod +x "$PWD/.hammer-zig-ar"
+RF="-C linker=$PWD/.hammer-zig-cc"
+rustc -vV | grep -q 'host: .*-alpine-' || RF="$RF -C link-self-contained=no"
+CC="$PWD/.hammer-zig-cc" AR="$PWD/.hammer-zig-ar" RUSTFLAGS="$RF" cargo rustc --release --locked --offline -p photocraft --bin photocraft -- -C target-feature=-crt-static
+'''
+install = '''
+set -e
+test -x target/release/photocraft || { echo "no hay target/release/photocraft — ¿cambió el nombre del bin?" >&2; exit 1; }
+install -Dm755 target/release/photocraft /out/usr/bin/photocraft
+
+install -Dm644 packaging/linux/ai.storyteller.photocraft.desktop /out/usr/share/applications/ai.storyteller.photocraft.desktop
+install -Dm644 packaging/linux/ai.storyteller.photocraft.mime.xml /out/usr/share/mime/packages/ai.storyteller.photocraft.xml
+# Los iconos se recorren con un glob y NO con una lista de tamaños: `effectcraft` y `filmcraft`
+# traen el doble de entradas que las demás, y una lista fija los habría instalado a medias en
+# silencio. Se cuenta lo instalado y se falla si es cero.
+n=0
+for p in assets/app-icon/hicolor/*/apps/ai.storyteller.photocraft.*; do
+ [ -f "$p" ] || continue
+ install -Dm644 "$p" "/out/usr/share/icons/hicolor/${p#assets/app-icon/hicolor/}"
+ n=$((n+1))
+done
+[ "$n" -gt 0 ] || { echo "ningún icono instalado — ¿cambió assets/app-icon/hicolor/?" >&2; exit 1; }
+install -Dm644 LICENSE-MIT /out/usr/share/licenses/photocraft/LICENSE-MIT
+install -Dm644 LICENSE-APACHE /out/usr/share/licenses/photocraft/LICENSE-APACHE
+
+# Un `.desktop` vacío o un binario de 0 bytes son la forma de fallo de la casa: artefacto presente,
+# función ausente (CLAUDE.md §3).
+test -s /out/usr/share/applications/ai.storyteller.photocraft.desktop
+test -s /out/usr/bin/photocraft
+'''
+
+[deps]
+# El mismo juego que `boveda`/`shuma-pregunta`/`mirada-greeter`: el stack gráfico se consulta en
+# build-time y se carga por `dlopen` en runtime. Las fuentes vienen EMBEBIDAS (`default_fonts` de
+# eframe), así que no hace falta fontconfig ni freetype.
+build = ["mesa", "wayland", "wayland-protocols", "libdrm", "expat", "zlib", "zstd", "libffi", "pkgconf"]
diff --git a/recipes/soundcraft.toml b/recipes/soundcraft.toml
new file mode 100644
index 00000000..071aae93
--- /dev/null
+++ b/recipes/soundcraft.toml
@@ -0,0 +1,123 @@
+# soundcraft — la estación de audio multipista — reimplementación limpia de Pro Tools
+#
+# ── DE DÓNDE SALE ESTA FAMILIA ─────────────────────────────────────────────────────────────────
+# ArtCraft publica doce aplicaciones de escritorio en **Rust puro sobre egui + wgpu**, Apache-2.0 o
+# MIT, cada una en su propio repo de `github.com/storytold`. Para esta casa son interesantes por una
+# razón concreta y medible: **no tienen ni una línea de GTK ni de Qt**, que es el muro que deja a
+# `libreoffice` en «59 recetas y tres muros ⇒ no es un ticket, es un año»
+# (`docs/plan-apps-usuario-final.md:48`). Acá cada app es UNA receta `cargo_vendor`.
+#
+# La primera que se midió fue `gridcraft`, y la medición está en su receta: sella en 12 min, abre
+# ventana en el perfil `escritorio-sway` y **pinta** — `wgpu` toma el backend **GL** sobre EGL/GLES
+# con llvmpipe (GL 4.5), sin Vulkan y sin ninguna variable de entorno, porque el default de
+# `egui-wgpu` es `Backends::from_env().unwrap_or(PRIMARY | GL)`.
+#
+# ⚠ **Son 0.x y se mueven solas.** Portar esto es comprar su cinta de correr (SDD 26 §2). Se pinea
+# el **sha del tag**, no `main`, y por eso la versión de arriba es una release y no «lo de hoy».
+#
+# ── X11: NO SE PARCHEA, Y ESO ES UNA DECISIÓN MEDIDA ──────────────────────────────────────────
+# Las doce declaran `eframe` con los features `wayland` **y** `x11`, y la primera versión de esto
+# arrancaba el feature `x11` con un `sed`. Se quitó, por tres cosas que se midieron al hacerlo:
+# 1. **No hay muro de build**: lo que `x11` arrastra es `x11rb` (protocolo X en Rust puro) y
+# `x11-dl` (que hace `dlopen`, no enlaza). Ninguna receta nueva, ni un `.so` de X en el link.
+# 2. **No hay muro de runtime**: winit elige Wayland cuando `WAYLAND_DISPLAY` está puesto, y en
+# esta distro `libX11` directamente no existe ⇒ el camino X es inerte, no roto.
+# 3. **Quitarlo SÍ cuesta**: cambia el grafo ⇒ el `Cargo.lock` deja de cerrar y hay que soltar el
+# `--locked` del compile (medido: `error: cannot update the lock file … because --locked`).
+# Y varias de estas apps declaran `winit` con `x11` por su cuenta y lo USAN en el código
+# (`photocraft` abre por Xwayland si el usuario lo elige en Preferencias), así que un `sed`
+# ciego rompe el build en unas y es un no-op en otras.
+# ⇒ Se deja el feature y se conserva el `--locked`, que es el pin fuerte. La distro sigue siendo
+# Wayland-only por lo que NO lleva en la imagen, no por lo que esta receta le recorta al upstream.
+#
+# ── LA FORMA ───────────────────────────────────────────────────────────────────────────────────
+# `link = "dynamic"` + `-crt-static` como `boveda`/`shuma-pregunta`/`mirada-greeter`: winit y wgpu
+# cargan `libwayland-client`, `libEGL`/`libvulkan` por `dlopen`, y un binario `crt-static` no puede.
+# El lanzador, el MIME y los iconos son de UPSTREAM — lo contrario de `boveda.toml`, donde hay que
+# autorar el `.desktop` — y están enhebrados: `Icon=`/`StartupWMClass=` del `.desktop`, el nombre de
+# los iconos hicolor y el `with_app_id()` de la app coinciden en `ai.storyteller.soundcraft`, así que
+# el emparejamiento ventana↔lanzador funciona en Wayland sin parchear nada.
+#
+# ⚠ El audio de esta casa es **PipeWire**, no ALSA, y esta app habla ALSA por `cpal`. No es
+# contradicción: PipeWire sirve el dispositivo ALSA por su capa de compatibilidad (`pipewire-alsa`,
+# que las tres recetas de pipewire ya llevan encendida). Lo que NO hay es cliente nativo: si
+# hiciera falta latencia baja de verdad, eso es su propia unidad de trabajo.
+#
+name = "soundcraft"
+version = "0.6.1"
+license = "MIT OR Apache-2.0"
+
+[source]
+# `v0.6.1`, el tag vigente al 2026-10-12. Se pinea el SHA: un tag se mueve.
+repo = "https://github.com/storytold/soundcraft"
+commit = "6cf896e76c0ec82a11bf2494f209cfe7c8507f74"
+# ⚠ `vendor_cargo_deps` **sobrescribe** `<src>/.cargo/config.toml` con `std::fs::write`
+# (`crates/takana-build/src/fetch.rs:852`), y estos repos traen uno propio. Hoy es inocuo —dentro
+# sólo hay `[alias] xtask`, que estas recetas no usan— pero si un bump mete ahí un `rustflags` o un
+# `linker`, desaparece sin un solo aviso y el fallo no va a nombrar al config.
+# El `Cargo.lock` está committeado ⇒ el vendoreo va con `--locked` y queda pineado en el tiempo.
+
+[build]
+compiler = "zig-cc"
+target = "x86_64-linux-musl"
+link = "dynamic"
+flags = ["-p", "soundcraft", "--bin", "soundcraft"]
+
+[build.phases]
+compile = '''
+set -e
+# LOCKSTEP_XML_PATH — la causa entera está en `mirada-greeter.toml` y repetida en `boveda.toml`:
+# `accesskit` → `atspi` → `atspi-common`, que trae `zbus-lockstep-macros` como dep NORMAL y cuyo
+# `#[validate]` recorre el dir `xml/` llamando `.extension().expect()` en CADA entrada ⇒ PANICA al
+# toparse con el subdirectorio `xml/schemas/`. Apuntando la variable a un dir con sólo los `.xml` se
+# evita. La comprobación es a gritos: un `cp` mudo dejaría la variable apuntando a un dir VACÍO y el
+# panic volvería con el error lejos de la causa.
+XMLDIR="$PWD/.atspi-lockstep-xml"
+mkdir -p "$XMLDIR"
+ls vendor/atspi-common/xml/*.xml >/dev/null 2>&1 || { echo "no hay vendor/atspi-common/xml/*.xml — ¿cambió el vendoreo o la versión de atspi? sin esto zbus-lockstep-macros panica en xml/schemas/" >&2; exit 1; }
+cp vendor/atspi-common/xml/*.xml "$XMLDIR"/
+export LOCKSTEP_XML_PATH="$XMLDIR"
+
+# El envoltorio de zig-cc, igual que en las otras winit+wgpu del corpus.
+printf '%s\n' '#!/bin/sh' 'for a do' 'case "$a" in --target=*) a=--target=x86_64-linux-musl ;; esac' 'set -- "$@" "$a"' 'shift' 'done' 'exec zig cc -mcpu=baseline "$@"' > "$PWD/.hammer-zig-cc"
+chmod +x "$PWD/.hammer-zig-cc"
+printf '%s\n' '#!/bin/sh' 'exec zig ar "$@"' > "$PWD/.hammer-zig-ar"
+chmod +x "$PWD/.hammer-zig-ar"
+RF="-C linker=$PWD/.hammer-zig-cc"
+rustc -vV | grep -q 'host: .*-alpine-' || RF="$RF -C link-self-contained=no"
+CC="$PWD/.hammer-zig-cc" AR="$PWD/.hammer-zig-ar" RUSTFLAGS="$RF" cargo rustc --release --locked --offline -p soundcraft --bin soundcraft -- -C target-feature=-crt-static
+'''
+install = '''
+set -e
+test -x target/release/soundcraft || { echo "no hay target/release/soundcraft — ¿cambió el nombre del bin?" >&2; exit 1; }
+install -Dm755 target/release/soundcraft /out/usr/bin/soundcraft
+
+install -Dm644 packaging/linux/ai.storyteller.soundcraft.desktop /out/usr/share/applications/ai.storyteller.soundcraft.desktop
+install -Dm644 packaging/linux/ai.storyteller.soundcraft.mime.xml /out/usr/share/mime/packages/ai.storyteller.soundcraft.xml
+# Los iconos se recorren con un glob y NO con una lista de tamaños: `effectcraft` y `filmcraft`
+# traen el doble de entradas que las demás, y una lista fija los habría instalado a medias en
+# silencio. Se cuenta lo instalado y se falla si es cero.
+n=0
+for p in assets/app-icon/hicolor/*/apps/ai.storyteller.soundcraft.*; do
+ [ -f "$p" ] || continue
+ install -Dm644 "$p" "/out/usr/share/icons/hicolor/${p#assets/app-icon/hicolor/}"
+ n=$((n+1))
+done
+[ "$n" -gt 0 ] || { echo "ningún icono instalado — ¿cambió assets/app-icon/hicolor/?" >&2; exit 1; }
+install -Dm644 LICENSE-MIT /out/usr/share/licenses/soundcraft/LICENSE-MIT
+install -Dm644 LICENSE-APACHE /out/usr/share/licenses/soundcraft/LICENSE-APACHE
+
+# Un `.desktop` vacío o un binario de 0 bytes son la forma de fallo de la casa: artefacto presente,
+# función ausente (CLAUDE.md §3).
+test -s /out/usr/share/applications/ai.storyteller.soundcraft.desktop
+test -s /out/usr/bin/soundcraft
+'''
+
+[deps]
+# Igual que las demás de la familia MÁS `alsa-lib`, y ésta no es decorativa: la app trae `cpal`
+# **sin condicionar por plataforma**, que en Linux es `alsa-sys` y pregunta por `alsa.pc` con
+# pkg-config ⇒ sin esta dep el build muere en el `build.rs` de `alsa-sys`, no en el link.
+# ⚠ Y el corpus hoy NO publica `alsa`: `scripts/provee.py --desde corpus alsa` sale «NADIE LO
+# PUBLICA en ninguna cola» aunque `recipes/alsa-lib.toml` exista — la receta está escrita y sin
+# sellar. Declararla acá la construye; no hay que hacer nada aparte.
+build = ["mesa", "wayland", "wayland-protocols", "libdrm", "expat", "zlib", "zstd", "libffi", "pkgconf", "alsa-lib"]
diff --git a/recipes/vectorcraft.toml b/recipes/vectorcraft.toml
new file mode 100644
index 00000000..6e2ce00a
--- /dev/null
+++ b/recipes/vectorcraft.toml
@@ -0,0 +1,109 @@
+# vectorcraft — el dibujo vectorial — el equivalente de LibreOffice Draw, que la terna ofimática no trae
+#
+# ── DE DÓNDE SALE ESTA FAMILIA ─────────────────────────────────────────────────────────────────
+# ArtCraft publica doce aplicaciones de escritorio en **Rust puro sobre egui + wgpu**, Apache-2.0 o
+# MIT, cada una en su propio repo de `github.com/storytold`. Para esta casa son interesantes por una
+# razón concreta y medible: **no tienen ni una línea de GTK ni de Qt**, que es el muro que deja a
+# `libreoffice` en «59 recetas y tres muros ⇒ no es un ticket, es un año»
+# (`docs/plan-apps-usuario-final.md:48`). Acá cada app es UNA receta `cargo_vendor`.
+#
+# La primera que se midió fue `gridcraft`, y la medición está en su receta: sella en 12 min, abre
+# ventana en el perfil `escritorio-sway` y **pinta** — `wgpu` toma el backend **GL** sobre EGL/GLES
+# con llvmpipe (GL 4.5), sin Vulkan y sin ninguna variable de entorno, porque el default de
+# `egui-wgpu` es `Backends::from_env().unwrap_or(PRIMARY | GL)`.
+#
+# ⚠ **Son 0.x y se mueven solas.** Portar esto es comprar su cinta de correr (SDD 26 §2). Se pinea
+# el **sha del tag**, no `main`, y por eso la versión de arriba es una release y no «lo de hoy».
+#
+# ── X11: NO SE PARCHEA, Y ESO ES UNA DECISIÓN MEDIDA ──────────────────────────────────────────
+# Las doce declaran `eframe` con los features `wayland` **y** `x11`, y la primera versión de esto
+# arrancaba el feature `x11` con un `sed`. Se quitó, por tres cosas que se midieron al hacerlo:
+# 1. **No hay muro de build**: lo que `x11` arrastra es `x11rb` (protocolo X en Rust puro) y
+# `x11-dl` (que hace `dlopen`, no enlaza). Ninguna receta nueva, ni un `.so` de X en el link.
+# 2. **No hay muro de runtime**: winit elige Wayland cuando `WAYLAND_DISPLAY` está puesto, y en
+# esta distro `libX11` directamente no existe ⇒ el camino X es inerte, no roto.
+# 3. **Quitarlo SÍ cuesta**: cambia el grafo ⇒ el `Cargo.lock` deja de cerrar y hay que soltar el
+# `--locked` del compile (medido: `error: cannot update the lock file … because --locked`).
+# Y varias de estas apps declaran `winit` con `x11` por su cuenta y lo USAN en el código
+# (`photocraft` abre por Xwayland si el usuario lo elige en Preferencias), así que un `sed`
+# ciego rompe el build en unas y es un no-op en otras.
+# ⇒ Se deja el feature y se conserva el `--locked`, que es el pin fuerte. La distro sigue siendo
+# Wayland-only por lo que NO lleva en la imagen, no por lo que esta receta le recorta al upstream.
+#
+# ── LA FORMA ───────────────────────────────────────────────────────────────────────────────────
+# `link = "dynamic"` + `-crt-static` como `boveda`/`shuma-pregunta`/`mirada-greeter`: winit y wgpu
+# cargan `libwayland-client`, `libEGL`/`libvulkan` por `dlopen`, y un binario `crt-static` no puede.
+# El lanzador, el MIME y los iconos son de UPSTREAM — lo contrario de `boveda.toml`, donde hay que
+# autorar el `.desktop` — y están enhebrados: `Icon=`/`StartupWMClass=` del `.desktop`, el nombre de
+# los iconos hicolor y el `with_app_id()` de la app coinciden en `ai.storyteller.vectorcraft`, así que
+# el emparejamiento ventana↔lanzador funciona en Wayland sin parchear nada.
+#
+# ⚠ Es la ÚNICA de las doce que declara `eframe` **sin el feature `wgpu` y sin `accesskit`**: va
+# por `glow` (GL por glutin/EGL) y no arrastra la torre de `atspi`, así que esta receta no
+# necesita el `LOCKSTEP_XML_PATH` que llevan sus hermanas. Si un bump le enciende `accesskit`, el
+# build va a morir con un panic en `xml/schemas/` que no nombra nada de esto: copiar el bloque de
+# `gridcraft.toml`.
+#
+name = "vectorcraft"
+version = "0.10.0"
+license = "MIT OR Apache-2.0"
+
+[source]
+# `v0.10.0`, el tag vigente al 2026-10-12. Se pinea el SHA: un tag se mueve.
+repo = "https://github.com/storytold/vectorcraft"
+commit = "71174477cc056020fe712ae59901d76b870ca9d9"
+# ⚠ `vendor_cargo_deps` **sobrescribe** `<src>/.cargo/config.toml` con `std::fs::write`
+# (`crates/takana-build/src/fetch.rs:852`), y estos repos traen uno propio. Hoy es inocuo —dentro
+# sólo hay `[alias] xtask`, que estas recetas no usan— pero si un bump mete ahí un `rustflags` o un
+# `linker`, desaparece sin un solo aviso y el fallo no va a nombrar al config.
+# El `Cargo.lock` está committeado ⇒ el vendoreo va con `--locked` y queda pineado en el tiempo.
+
+[build]
+compiler = "zig-cc"
+target = "x86_64-linux-musl"
+link = "dynamic"
+flags = ["-p", "vectorcraft", "--bin", "vectorcraft"]
+
+[build.phases]
+compile = '''
+set -e
+# El envoltorio de zig-cc, igual que en las otras winit+wgpu del corpus.
+printf '%s\n' '#!/bin/sh' 'for a do' 'case "$a" in --target=*) a=--target=x86_64-linux-musl ;; esac' 'set -- "$@" "$a"' 'shift' 'done' 'exec zig cc -mcpu=baseline "$@"' > "$PWD/.hammer-zig-cc"
+chmod +x "$PWD/.hammer-zig-cc"
+printf '%s\n' '#!/bin/sh' 'exec zig ar "$@"' > "$PWD/.hammer-zig-ar"
+chmod +x "$PWD/.hammer-zig-ar"
+RF="-C linker=$PWD/.hammer-zig-cc"
+rustc -vV | grep -q 'host: .*-alpine-' || RF="$RF -C link-self-contained=no"
+CC="$PWD/.hammer-zig-cc" AR="$PWD/.hammer-zig-ar" RUSTFLAGS="$RF" cargo rustc --release --locked --offline -p vectorcraft --bin vectorcraft -- -C target-feature=-crt-static
+'''
+install = '''
+set -e
+test -x target/release/vectorcraft || { echo "no hay target/release/vectorcraft — ¿cambió el nombre del bin?" >&2; exit 1; }
+install -Dm755 target/release/vectorcraft /out/usr/bin/vectorcraft
+
+install -Dm644 packaging/linux/ai.storyteller.vectorcraft.desktop /out/usr/share/applications/ai.storyteller.vectorcraft.desktop
+install -Dm644 packaging/linux/ai.storyteller.vectorcraft.mime.xml /out/usr/share/mime/packages/ai.storyteller.vectorcraft.xml
+# Los iconos se recorren con un glob y NO con una lista de tamaños: `effectcraft` y `filmcraft`
+# traen el doble de entradas que las demás, y una lista fija los habría instalado a medias en
+# silencio. Se cuenta lo instalado y se falla si es cero.
+n=0
+for p in assets/app-icon/hicolor/*/apps/ai.storyteller.vectorcraft.*; do
+ [ -f "$p" ] || continue
+ install -Dm644 "$p" "/out/usr/share/icons/hicolor/${p#assets/app-icon/hicolor/}"
+ n=$((n+1))
+done
+[ "$n" -gt 0 ] || { echo "ningún icono instalado — ¿cambió assets/app-icon/hicolor/?" >&2; exit 1; }
+install -Dm644 LICENSE-MIT /out/usr/share/licenses/vectorcraft/LICENSE-MIT
+install -Dm644 LICENSE-APACHE /out/usr/share/licenses/vectorcraft/LICENSE-APACHE
+
+# Un `.desktop` vacío o un binario de 0 bytes son la forma de fallo de la casa: artefacto presente,
+# función ausente (CLAUDE.md §3).
+test -s /out/usr/share/applications/ai.storyteller.vectorcraft.desktop
+test -s /out/usr/bin/vectorcraft
+'''
+
+[deps]
+# El mismo juego que `boveda`/`shuma-pregunta`/`mirada-greeter`: el stack gráfico se consulta en
+# build-time y se carga por `dlopen` en runtime. Las fuentes vienen EMBEBIDAS (`default_fonts` de
+# eframe), así que no hace falta fontconfig ni freetype.
+build = ["mesa", "wayland", "wayland-protocols", "libdrm", "expat", "zlib", "zstd", "libffi", "pkgconf"]
diff --git a/recipes/wordcraft.toml b/recipes/wordcraft.toml
new file mode 100644
index 00000000..1bf89ed8
--- /dev/null
+++ b/recipes/wordcraft.toml
@@ -0,0 +1,122 @@
+# wordcraft — el procesador de textos: `.docx` (OOXML) de ida y vuelta, `.odt`, `.rtf`, `.md`, y PDF con texto seleccionable
+#
+# ── DE DÓNDE SALE ESTA FAMILIA ─────────────────────────────────────────────────────────────────
+# ArtCraft publica doce aplicaciones de escritorio en **Rust puro sobre egui + wgpu**, Apache-2.0 o
+# MIT, cada una en su propio repo de `github.com/storytold`. Para esta casa son interesantes por una
+# razón concreta y medible: **no tienen ni una línea de GTK ni de Qt**, que es el muro que deja a
+# `libreoffice` en «59 recetas y tres muros ⇒ no es un ticket, es un año»
+# (`docs/plan-apps-usuario-final.md:48`). Acá cada app es UNA receta `cargo_vendor`.
+#
+# La primera que se midió fue `gridcraft`, y la medición está en su receta: sella en 12 min, abre
+# ventana en el perfil `escritorio-sway` y **pinta** — `wgpu` toma el backend **GL** sobre EGL/GLES
+# con llvmpipe (GL 4.5), sin Vulkan y sin ninguna variable de entorno, porque el default de
+# `egui-wgpu` es `Backends::from_env().unwrap_or(PRIMARY | GL)`.
+#
+# ⚠ **Son 0.x y se mueven solas.** Portar esto es comprar su cinta de correr (SDD 26 §2). Se pinea
+# el **sha del tag**, no `main`, y por eso la versión de arriba es una release y no «lo de hoy».
+#
+# ── X11: NO SE PARCHEA, Y ESO ES UNA DECISIÓN MEDIDA ──────────────────────────────────────────
+# Las doce declaran `eframe` con los features `wayland` **y** `x11`, y la primera versión de esto
+# arrancaba el feature `x11` con un `sed`. Se quitó, por tres cosas que se midieron al hacerlo:
+# 1. **No hay muro de build**: lo que `x11` arrastra es `x11rb` (protocolo X en Rust puro) y
+# `x11-dl` (que hace `dlopen`, no enlaza). Ninguna receta nueva, ni un `.so` de X en el link.
+# 2. **No hay muro de runtime**: winit elige Wayland cuando `WAYLAND_DISPLAY` está puesto, y en
+# esta distro `libX11` directamente no existe ⇒ el camino X es inerte, no roto.
+# 3. **Quitarlo SÍ cuesta**: cambia el grafo ⇒ el `Cargo.lock` deja de cerrar y hay que soltar el
+# `--locked` del compile (medido: `error: cannot update the lock file … because --locked`).
+# Y varias de estas apps declaran `winit` con `x11` por su cuenta y lo USAN en el código
+# (`photocraft` abre por Xwayland si el usuario lo elige en Preferencias), así que un `sed`
+# ciego rompe el build en unas y es un no-op en otras.
+# ⇒ Se deja el feature y se conserva el `--locked`, que es el pin fuerte. La distro sigue siendo
+# Wayland-only por lo que NO lleva en la imagen, no por lo que esta receta le recorta al upstream.
+#
+# ── LA FORMA ───────────────────────────────────────────────────────────────────────────────────
+# `link = "dynamic"` + `-crt-static` como `boveda`/`shuma-pregunta`/`mirada-greeter`: winit y wgpu
+# cargan `libwayland-client`, `libEGL`/`libvulkan` por `dlopen`, y un binario `crt-static` no puede.
+# El lanzador, el MIME y los iconos son de UPSTREAM — lo contrario de `boveda.toml`, donde hay que
+# autorar el `.desktop` — y están enhebrados: `Icon=`/`StartupWMClass=` del `.desktop`, el nombre de
+# los iconos hicolor y el `with_app_id()` de la app coinciden en `ai.storyteller.wordcraft`, así que
+# el emparejamiento ventana↔lanzador funciona en Wayland sin parchear nada.
+#
+# ⚠ **Es el que más cerca está de ser ÚTIL y el que más cuidado pide**: su propio README pone a
+# la beta a prueba «against real-world .docx files, keeping charts and SmartArt», o sea que la
+# preservación de lo que todavía no entiende es trabajo abierto. LibreOffice sigue siendo el
+# patrón de fidelidad y no se autora: si hace falta de verdad, sale por qorpa (ADR 0015).
+# Trae además `crates/mcp`: habla el protocolo de herramientas, así que enchufa al `llama-cpp`
+# que ya viaja en los cuatro escritorios sin pasar por ningún servicio de nadie.
+#
+name = "wordcraft"
+version = "0.6.1"
+license = "MIT OR Apache-2.0"
+
+[source]
+# `v0.6.1`, el tag vigente al 2026-10-12. Se pinea el SHA: un tag se mueve.
+repo = "https://github.com/storytold/wordcraft"
+commit = "7dfa6b0ee2aeeafec004c51fdd2ab9a697da3cc4"
+# ⚠ `vendor_cargo_deps` **sobrescribe** `<src>/.cargo/config.toml` con `std::fs::write`
+# (`crates/takana-build/src/fetch.rs:852`), y estos repos traen uno propio. Hoy es inocuo —dentro
+# sólo hay `[alias] xtask`, que estas recetas no usan— pero si un bump mete ahí un `rustflags` o un
+# `linker`, desaparece sin un solo aviso y el fallo no va a nombrar al config.
+# El `Cargo.lock` está committeado ⇒ el vendoreo va con `--locked` y queda pineado en el tiempo.
+
+[build]
+compiler = "zig-cc"
+target = "x86_64-linux-musl"
+link = "dynamic"
+flags = ["-p", "wordcraft", "--bin", "wordcraft"]
+
+[build.phases]
+compile = '''
+set -e
+# LOCKSTEP_XML_PATH — la causa entera está en `mirada-greeter.toml` y repetida en `boveda.toml`:
+# `accesskit` → `atspi` → `atspi-common`, que trae `zbus-lockstep-macros` como dep NORMAL y cuyo
+# `#[validate]` recorre el dir `xml/` llamando `.extension().expect()` en CADA entrada ⇒ PANICA al
+# toparse con el subdirectorio `xml/schemas/`. Apuntando la variable a un dir con sólo los `.xml` se
+# evita. La comprobación es a gritos: un `cp` mudo dejaría la variable apuntando a un dir VACÍO y el
+# panic volvería con el error lejos de la causa.
+XMLDIR="$PWD/.atspi-lockstep-xml"
+mkdir -p "$XMLDIR"
+ls vendor/atspi-common/xml/*.xml >/dev/null 2>&1 || { echo "no hay vendor/atspi-common/xml/*.xml — ¿cambió el vendoreo o la versión de atspi? sin esto zbus-lockstep-macros panica en xml/schemas/" >&2; exit 1; }
+cp vendor/atspi-common/xml/*.xml "$XMLDIR"/
+export LOCKSTEP_XML_PATH="$XMLDIR"
+
+# El envoltorio de zig-cc, igual que en las otras winit+wgpu del corpus.
+printf '%s\n' '#!/bin/sh' 'for a do' 'case "$a" in --target=*) a=--target=x86_64-linux-musl ;; esac' 'set -- "$@" "$a"' 'shift' 'done' 'exec zig cc -mcpu=baseline "$@"' > "$PWD/.hammer-zig-cc"
+chmod +x "$PWD/.hammer-zig-cc"
+printf '%s\n' '#!/bin/sh' 'exec zig ar "$@"' > "$PWD/.hammer-zig-ar"
+chmod +x "$PWD/.hammer-zig-ar"
+RF="-C linker=$PWD/.hammer-zig-cc"
+rustc -vV | grep -q 'host: .*-alpine-' || RF="$RF -C link-self-contained=no"
+CC="$PWD/.hammer-zig-cc" AR="$PWD/.hammer-zig-ar" RUSTFLAGS="$RF" cargo rustc --release --locked --offline -p wordcraft --bin wordcraft -- -C target-feature=-crt-static
+'''
+install = '''
+set -e
+test -x target/release/wordcraft || { echo "no hay target/release/wordcraft — ¿cambió el nombre del bin?" >&2; exit 1; }
+install -Dm755 target/release/wordcraft /out/usr/bin/wordcraft
+
+install -Dm644 packaging/linux/ai.storyteller.wordcraft.desktop /out/usr/share/applications/ai.storyteller.wordcraft.desktop
+install -Dm644 packaging/linux/ai.storyteller.wordcraft.mime.xml /out/usr/share/mime/packages/ai.storyteller.wordcraft.xml
+# Los iconos se recorren con un glob y NO con una lista de tamaños: `effectcraft` y `filmcraft`
+# traen el doble de entradas que las demás, y una lista fija los habría instalado a medias en
+# silencio. Se cuenta lo instalado y se falla si es cero.
+n=0
+for p in assets/app-icon/hicolor/*/apps/ai.storyteller.wordcraft.*; do
+ [ -f "$p" ] || continue
+ install -Dm644 "$p" "/out/usr/share/icons/hicolor/${p#assets/app-icon/hicolor/}"
+ n=$((n+1))
+done
+[ "$n" -gt 0 ] || { echo "ningún icono instalado — ¿cambió assets/app-icon/hicolor/?" >&2; exit 1; }
+install -Dm644 LICENSE-MIT /out/usr/share/licenses/wordcraft/LICENSE-MIT
+install -Dm644 LICENSE-APACHE /out/usr/share/licenses/wordcraft/LICENSE-APACHE
+
+# Un `.desktop` vacío o un binario de 0 bytes son la forma de fallo de la casa: artefacto presente,
+# función ausente (CLAUDE.md §3).
+test -s /out/usr/share/applications/ai.storyteller.wordcraft.desktop
+test -s /out/usr/bin/wordcraft
+'''
+
+[deps]
+# El mismo juego que `boveda`/`shuma-pregunta`/`mirada-greeter`: el stack gráfico se consulta en
+# build-time y se carga por `dlopen` en runtime. Las fuentes vienen EMBEBIDAS (`default_fonts` de
+# eframe), así que no hace falta fontconfig ni freetype.
+build = ["mesa", "wayland", "wayland-protocols", "libdrm", "expat", "zlib", "zstd", "libffi", "pkgconf"]
diff --git a/scripts/png-colores.py b/scripts/png-colores.py
new file mode 100644
index 00000000..3f7a5d61
--- /dev/null
+++ b/scripts/png-colores.py
@@ -0,0 +1,58 @@
+#!/usr/bin/env python3
+"""Cuenta colores únicos de un PNG sin PIL (no hay PIL ni en el hub ni en el worker).
+
+ python3 png-colores.py captura.png [antes.png ...]
+
+La medida que no se puede falsear (scripts/wlr/sway-headless.sh): 1 color = sólo el fondo del
+swaybg ⇒ NINGÚN cliente dibujó, aunque el árbol del compositor liste la ventana.
+"""
+import sys, zlib, struct
+from collections import Counter
+
+
+def leer_png(ruta):
+ d = open(ruta, "rb").read()
+ assert d[:8] == b"\x89PNG\r\n\x1a\n", "no es un PNG"
+ i, idat, ihdr = 8, b"", None
+ while i < len(d):
+ ln, tipo = struct.unpack(">I4s", d[i:i + 8])
+ cuerpo = d[i + 8:i + 8 + ln]
+ if tipo == b"IHDR":
+ ihdr = struct.unpack(">IIBBBBB", cuerpo)
+ elif tipo == b"IDAT":
+ idat += cuerpo
+ elif tipo == b"IEND":
+ break
+ i += 12 + ln
+ w, h, prof, color, _, _, entre = ihdr
+ assert prof == 8 and entre == 0, f"sólo 8 bits sin entrelazado (prof={prof} entrelazado={entre})"
+ canales = {0: 1, 2: 3, 4: 2, 6: 4}[color]
+ cruda, bpp, salida, prev = zlib.decompress(idat), canales, [], bytearray(w * canales)
+ pos = 0
+ for _ in range(h):
+ filtro = cruda[pos]; pos += 1
+ linea = bytearray(cruda[pos:pos + w * canales]); pos += w * canales
+ for x in range(len(linea)):
+ a = linea[x - bpp] if x >= bpp else 0
+ b = prev[x]
+ c = prev[x - bpp] if x >= bpp else 0
+ if filtro == 1: linea[x] = (linea[x] + a) & 255
+ elif filtro == 2: linea[x] = (linea[x] + b) & 255
+ elif filtro == 3: linea[x] = (linea[x] + (a + b) // 2) & 255
+ elif filtro == 4:
+ p = a + b - c
+ pa, pb, pc = abs(p - a), abs(p - b), abs(p - c)
+ linea[x] = (linea[x] + (a if pa <= pb and pa <= pc else b if pb <= pc else c)) & 255
+ salida.append(bytes(linea)); prev = linea
+ return w, h, canales, salida
+
+
+for ruta in sys.argv[1:]:
+ w, h, canales, filas = leer_png(ruta)
+ c = Counter()
+ for f in filas:
+ for x in range(0, len(f), canales):
+ c[bytes(f[x:x + 3])] += 1
+ print(f"{ruta}: {w}x{h} colores únicos = {len(c)}")
+ for px, n in c.most_common(6):
+ print(f" #{px.hex()} {n * 100.0 / (w * h):5.1f} %")
diff --git a/scripts/test-craft-ventana.sh b/scripts/test-craft-ventana.sh
new file mode 100755
index 00000000..9b744b7c
--- /dev/null
+++ b/scripts/test-craft-ventana.sh
@@ -0,0 +1,99 @@
+#!/bin/sh
+# test-craft-ventana.sh <app> — ¿ABRE VENTANA Y PINTA una app de la suite ArtCraft acá, sin Vulkan?
+#
+# Copia la mecánica de `scripts/test-atuq-boveda-metal.py` (SDD 26 §7.sexies): rootfs del perfil
+# `escritorio-sway` hidratado, capas de `bwrap` con la ÚLTIMA ganando, sway headless con el
+# renderer `pixman` (el compositor no necesita GL) y el CLIENTE por software con **llvmpipe, no
+# softpipe** — softpipe se queda en GL 3.3 y wgpu exige compute shaders.
+#
+# La evidencia no es el log: es la captura y el árbol del compositor.
+# · `app_id` en el árbol de sway ⇒ la ventana EXISTE
+# · nº de colores de la captura ⇒ la ventana PINTÓ (1 color = sólo el fondo de swaybg)
+#
+# uso: scripts/test-craft-ventana.sh gridcraft # y el control, que es el que vale:
+# BACKEND=auto scripts/test-craft-ventana.sh gridcraft # sin WGPU_BACKEND, como en la imagen
+set -e
+APP=${1:-gridcraft}
+ROOT=${ROOT:-/opt/takana}
+STORE=${STORE:-$ROOT/store}
+RFS=${RFS:-$ROOT/work/sway-rootfs}
+ALPINE=${ALPINE:-$ROOT/.dev-fs/alpine}
+SAL=${SAL:-$ROOT/work/$APP-ventana}
+BACKEND=${BACKEND:-gl} # `gl` fuerza el camino EGL/GLES · `auto` deja el default de egui-wgpu (PRIMARY|GL)
+ESPERA=${ESPERA:-120}
+
+hash_de() { "$ROOT/target/release/takana" --store "$STORE" hash "recipes/$1.toml" | tail -1 | sed 's/^b3://'; }
+capa() {
+ d="$STORE/$(cd "$ROOT" && hash_de "$1")-$1"
+ [ -d "$d" ] || { echo "!! falta el artefacto vigente de $1 ($d)" >&2; exit 1; }
+ echo "$d"
+}
+
+[ -d "$RFS" ] || { echo "!! no está el rootfs de sway en $RFS — hidratalo con scripts/hydrate-profile.py escritorio-sway --into work/sway-rootfs --allow-missing" >&2; exit 1; }
+
+GC=$(capa "$APP")
+L18=$(capa llvm18)
+MLP=$(capa mesa-llvmpipe)
+echo "capas: alpine + sway-rootfs + $(basename "$L18") + $(basename "$MLP") + $(basename "$GC")"
+
+rm -rf "$SAL"; mkdir -p "$SAL"
+
+cat > "$SAL/dentro.sh" <<EOF
+#!/bin/sh
+export XDG_RUNTIME_DIR=/run/user/0 HOME=/salida/hogar LANG=C XDG_DATA_DIRS=/usr/share
+export WLR_BACKENDS=headless WLR_HEADLESS_OUTPUTS=1 WLR_RENDERER=pixman
+export LIBGL_ALWAYS_SOFTWARE=1 LIBGL_DRIVERS_PATH=/usr/lib/dri GALLIUM_DRIVER=llvmpipe
+[ "$BACKEND" = auto ] || export WGPU_BACKEND=$BACKEND # auto = como en la imagen de verdad: egui-wgpu pide PRIMARY|GL
+# OJO: este heredoc va SIN comillas (necesita expandir $BACKEND), asi que NADA de backticks aca dentro:
+# en un heredoc sin comillas son SUSTITUCION DE COMANDOS, hasta en un comentario. Ya mordio una vez.
+export RUST_BACKTRACE=1 RUST_LOG=info,wgpu_core=info,wgpu_hal=info
+mkdir -p "\$XDG_RUNTIME_DIR" /salida/hogar; chmod 700 "\$XDG_RUNTIME_DIR"
+(
+ i=0
+ while [ ! -S /run/user/0/wayland-1 ] && [ \$i -lt 40 ]; do sleep 1; i=\$((i+1)); done
+ [ -S /run/user/0/wayland-1 ] || { echo "@@ sin socket wayland" >> /salida/log.txt; exit 1; }
+ export WAYLAND_DISPLAY=wayland-1
+ export SWAYSOCK=\$(ls /run/user/0/sway-ipc.* 2>/dev/null | head -1)
+ echo "@@ wayland listo" >> /salida/log.txt
+ /usr/bin/swaybg -c '#1a4b8c' >> /salida/swaybg.log 2>&1 &
+ sleep 2
+ /usr/bin/grim /salida/antes.png 2>>/salida/log.txt
+ echo "@@ lanzo $APP (backend=$BACKEND)" >> /salida/log.txt
+ /usr/bin/$APP > /salida/app.log 2>&1 &
+ PID=\$!
+ j=0
+ while [ \$j -lt $ESPERA ]; do
+ /usr/bin/swaymsg -t get_tree -r 2>/dev/null | grep -q '"app_id": *"ai.storyteller.$APP"' && break
+ kill -0 \$PID 2>/dev/null || { echo "@@ $APP MURIÓ a los \${j}s" >> /salida/log.txt; break; }
+ sleep 1; j=\$((j+1))
+ done
+ echo "@@ ventana en el árbol a los \${j}s" >> /salida/log.txt
+ /usr/bin/swaymsg -t get_tree -r > /salida/arbol.json 2>/dev/null
+ # Margen para el PRIMER FRAME: bajo llvmpipe aparecer en el árbol y haber pintado no es lo mismo.
+ sleep 10
+ /usr/bin/grim /salida/captura.png 2>>/salida/log.txt && echo "@@ grim ok" >> /salida/log.txt
+ sleep 1
+ /usr/bin/swaymsg exit 2>/dev/null
+) &
+exec /usr/bin/sway -d > /salida/sway.log 2>&1
+EOF
+
+bwrap \
+ --overlay-src "$ALPINE" --overlay-src "$RFS" \
+ --overlay-src "$L18" --overlay-src "$MLP" --overlay-src "$GC" \
+ --tmp-overlay / \
+ --dev-bind /dev /dev --proc /proc --bind /sys /sys \
+ --tmpfs /tmp --tmpfs /run --unshare-pid \
+ --bind "$SAL" /salida \
+ --uid 0 --gid 0 \
+ --setenv PATH /usr/bin:/bin:/usr/sbin:/sbin \
+ /bin/sh /salida/dentro.sh || echo "(bwrap salió con $?)"
+
+echo "── veredicto ──────────────────────────────────────────────"
+grep -c "\"app_id\": *\"ai.storyteller.$APP\"" "$SAL/arbol.json" 2>/dev/null \
+ | sed 's/^/ventanas con el app_id: /' || echo "ventanas con el app_id: 0 (no hay árbol)"
+cat "$SAL/log.txt" 2>/dev/null | sed 's/^/ /'
+echo "── log de la app (últimas 25) ─────────────────────────────"
+tail -25 "$SAL/app.log" 2>/dev/null | sed 's/^/ /'
+echo "── colores (1 color = sólo el fondo de swaybg ⇒ NADIE dibujó) ──"
+python3 "$(dirname "$0")/png-colores.py" "$SAL/antes.png" "$SAL/captura.png" 2>/dev/null || ls -l "$SAL"/*.png