gioser

tawasuyu/brahman

Rust 582TOML 222Markdown 83JSON 198635 Definitions

feat(theme): exportación a GTK + inyección de entorno en el compositor

Segunda mitad de la uniformización del tema. nahual-theme::toolkit
traduce el Theme activo a gtk-3.0/gtk.css y gtk-4.0/gtk.css con overrides
@define-color (acento exacto + neutro claro/oscuro sintetizado).
Theme::set/install_default exportan best-effort; guarda de no-pisar
respeta un gtk.css ajeno. El compositor inyecta XDG_CURRENT_DESKTOP=mirada
y QT_QPA_PLATFORMTHEME=gtk3 a cada hijo, así GTK y Qt siguen el tema.

8 tests nuevos en toolkit; ejemplo dump-toolkit-css.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

af3be482a948797a0e88036f3dbd87a1de44d6a1 · by sergio · (5 months ago) · parent 5369c307e · browse the tree at this commit

Semantic delta · 30

crates/apps/mirada-compositor/src/main.rs

crates/modules/nahual/libs/theme/examples/dump-toolkit-css.rs

crates/modules/nahual/libs/theme/src/lib.rs

6 files · +439 −7

diff --git a/crates/apps/mirada-compositor/src/main.rs b/crates/apps/mirada-compositor/src/main.rs
index b313563..4bb4602 100644
--- a/crates/apps/mirada-compositor/src/main.rs
+++ b/crates/apps/mirada-compositor/src/main.rs
@@ -722,16 +722,33 @@ fn cursor_hotspot(surface: &WlSurface) -> (i32, i32) {
     })
 }
 
+/// Variables de entorno de tema que el compositor inyecta a cada hijo,
+/// para uniformizar GTK y Qt:
+/// - `XDG_CURRENT_DESKTOP=mirada` hace que `xdg-desktop-portal` enrute
+///   hacia `mirada-portal` (el backend de `org.freedesktop.appearance`).
+/// - `QT_QPA_PLATFORMTHEME=gtk3` hace que las apps Qt sigan el tema GTK,
+///   y por tanto el `gtk.css` que genera `nahual-theme`.
+const THEME_ENV: &[(&str, &str)] = &[
+    ("XDG_CURRENT_DESKTOP", "mirada"),
+    ("QT_QPA_PLATFORMTHEME", "gtk3"),
+];
+
 /// Lanza un comando como proceso hijo, vía `sh -c`. El hijo hereda el
 /// entorno —`WAYLAND_DISPLAY` incluido—, así que el cliente que abra se
-/// conecta a este compositor. Lo usan la acción `spawn:…` del keymap, la
-/// variable `MIRADA_STARTUP` y el autoarranque.
+/// conecta a este compositor; además se le inyecta [`THEME_ENV`] para
+/// que GTK y Qt adopten el tema del escritorio. Lo usan la acción
+/// `spawn:…` del keymap, la variable `MIRADA_STARTUP` y el autoarranque.
 fn spawn_command(cmd: &str) {
     let cmd = cmd.trim();
     if cmd.is_empty() {
         return;
     }
-    match std::process::Command::new("sh").arg("-c").arg(cmd).spawn() {
+    match std::process::Command::new("sh")
+        .arg("-c")
+        .arg(cmd)
+        .envs(THEME_ENV.iter().copied())
+        .spawn()
+    {
         Ok(child) => println!("mirada-compositor · lanzado (pid {}): {cmd}", child.id()),
         Err(e) => eprintln!("mirada-compositor · no pude lanzar «{cmd}»: {e}"),
     }
diff --git a/crates/modules/nahual/libs/theme/examples/dump-toolkit-css.rs b/crates/modules/nahual/libs/theme/examples/dump-toolkit-css.rs
new file mode 100644
index 0000000..ea40bb3
--- /dev/null
+++ b/crates/modules/nahual/libs/theme/examples/dump-toolkit-css.rs
@@ -0,0 +1,17 @@
+//! Vuelca a stdout el `gtk.css` que `nahual-theme` generaría para cada
+//! preset. Útil para inspeccionar o depurar la exportación a toolkits
+//! sin tener que cambiar de tema en una app real.
+//!
+//! `cargo run -p nahual-theme --example dump-toolkit-css`
+
+use nahual_theme::{toolkit, Theme};
+
+fn main() {
+    for theme in Theme::all() {
+        println!("\n================= {} =================", theme.name);
+        println!("--- gtk-4.0/gtk.css ---");
+        print!("{}", toolkit::gtk4_css(&theme));
+        println!("--- gtk-3.0/gtk.css ---");
+        print!("{}", toolkit::gtk3_css(&theme));
+    }
+}
diff --git a/crates/modules/nahual/libs/theme/src/lib.rs b/crates/modules/nahual/libs/theme/src/lib.rs
index 87e18f3..5b6f17a 100644
--- a/crates/modules/nahual/libs/theme/src/lib.rs
+++ b/crates/modules/nahual/libs/theme/src/lib.rs
@@ -8,7 +8,9 @@
 //! ningún widget concreto. Cada widget pide slots semánticos (panel_bg,
 //! row_hover, accent…) sin acoplarse a colores hex específicos.
 
-use gpui::{Background, Global, Hsla, hsla, linear_color_stop, linear_gradient};
+use gpui::{hsla, linear_color_stop, linear_gradient, Background, Global, Hsla};
+
+pub mod toolkit;
 
 /// Paleta semántica del theme. Cada slot tiene un nombre funcional, no
 /// cromático — así los widgets piden "fondo de panel" sin acoplarse a
@@ -121,6 +123,8 @@ impl Theme {
     /// theme cambia (típicamente vía `theme_switcher`).
     pub fn install_default(cx: &mut gpui::App) {
         let theme = load_persisted().unwrap_or_else(Self::nebula);
+        // Asegura que GTK/Qt arranquen con el `gtk.css` del tema activo.
+        let _ = toolkit::export_toolkit_configs(&theme);
         cx.set_global(theme);
     }
 
@@ -134,6 +138,8 @@ impl Theme {
     /// por un I/O secundario.
     pub fn set(cx: &mut gpui::App, theme: Self) {
         let _ = persist(&theme);
+        // Reexporta `gtk.css` para que las apps GTK/Qt sigan el cambio.
+        let _ = toolkit::export_toolkit_configs(&theme);
         cx.set_global(theme);
     }
 
@@ -484,7 +490,15 @@ const CONFIG_FILE: &str = "theme";
 /// sino `$HOME/.config/nahual/theme`. `None` si ni `XDG_CONFIG_HOME`
 /// ni `HOME` están definidos (típicamente en sandboxes / CI).
 pub fn config_path() -> Option<PathBuf> {
-    let base = std::env::var("XDG_CONFIG_HOME")
+    Some(config_home()?.join(CONFIG_SUBDIR).join(CONFIG_FILE))
+}
+
+/// Directorio base de configuración del usuario: `$XDG_CONFIG_HOME` si
+/// está definido, sino `$HOME/.config`. `None` si ninguno está set
+/// (típicamente en sandboxes / CI). Lo usan [`config_path`] y el módulo
+/// [`toolkit`] para ubicar `gtk-3.0/gtk.css` y `gtk-4.0/gtk.css`.
+pub(crate) fn config_home() -> Option<PathBuf> {
+    std::env::var("XDG_CONFIG_HOME")
         .ok()
         .filter(|s| !s.is_empty())
         .map(PathBuf::from)
@@ -493,8 +507,7 @@ pub fn config_path() -> Option<PathBuf> {
                 .ok()
                 .filter(|s| !s.is_empty())
                 .map(|h| PathBuf::from(h).join(".config"))
-        })?;
-    Some(base.join(CONFIG_SUBDIR).join(CONFIG_FILE))
+        })
 }
 
 /// Lee el theme persistido. `None` si: no hay config dir, file no
diff --git a/crates/modules/nahual/libs/theme/src/toolkit.rs b/crates/modules/nahual/libs/theme/src/toolkit.rs
new file mode 100644
index 0000000..f1c68cc
--- /dev/null
+++ b/crates/modules/nahual/libs/theme/src/toolkit.rs
@@ -0,0 +1,350 @@
+//! Exportación del tema a los toolkits del escritorio (GTK 3 y 4).
+//!
+//! `nahual` pinta sus apps con GPUI, pero el escritorio carmen también
+//! corre apps GTK y Qt. Para que no desentonen, este módulo traduce el
+//! [`Theme`] activo a archivos `gtk.css` con overrides `@define-color`:
+//! GTK adopta el acento y el neutro claro/oscuro del tema. Las apps Qt
+//! se enganchan vía `QT_QPA_PLATFORMTHEME=gtk3` (lo inyecta el
+//! compositor a cada hijo), así que siguen a GTK sin config aparte.
+//!
+//! La paleta de `nahual` usa gradientes (`Background`) que GTK no puede
+//! reproducir en una ventana sólida; por eso el neutro GTK se **deriva**
+//! de `is_dark` + los slots `Hsla` accesibles (`accent`, `fg_text`,
+//! `border`). El acento sí se traslada exacto.
+//!
+//! [`Theme::set`](super::Theme::set) y
+//! [`Theme::install_default`](super::Theme::install_default) llaman a
+//! [`export_toolkit_configs`] best-effort.
+
+use std::io;
+use std::path::{Path, PathBuf};
+
+use gpui::{hsla, Hsla, Rgba};
+
+use super::{config_home, Theme};
+
+/// Marca en la cabecera de los archivos generados. Si un `gtk.css` ya
+/// existe **sin** esta marca, lo dejamos intacto: es del usuario.
+const MARKER: &str = "generado por nahual-theme";
+
+/// Resultado de [`export_toolkit_configs`]: qué archivos se (re)escribieron
+/// y cuáles se respetaron por ser ajenos al control de nahual.
+#[derive(Debug, Default, Clone)]
+pub struct ExportReport {
+    /// Archivos `gtk.css` (re)escritos por nahual.
+    pub written: Vec<PathBuf>,
+    /// Archivos saltados: existían y no llevan la marca de nahual, así
+    /// que son del usuario y no se tocan.
+    pub skipped: Vec<PathBuf>,
+}
+
+/// Escribe `gtk-3.0/gtk.css` y `gtk-4.0/gtk.css` bajo el directorio de
+/// config del usuario, derivados del `theme`. Best-effort: el caller
+/// (`Theme::set`) ignora el resultado. No pisa un `gtk.css` ajeno —
+/// ver [`ExportReport::skipped`].
+pub fn export_toolkit_configs(theme: &Theme) -> io::Result<ExportReport> {
+    let base = config_home().ok_or_else(|| {
+        io::Error::new(
+            io::ErrorKind::NotFound,
+            "no se pudo determinar config dir (HOME/XDG_CONFIG_HOME no set)",
+        )
+    })?;
+    export_toolkit_configs_to(&base, theme)
+}
+
+/// Variante de [`export_toolkit_configs`] con directorio base de config
+/// explícito. Útil para tests y para apps con su propio config root.
+pub fn export_toolkit_configs_to(base: &Path, theme: &Theme) -> io::Result<ExportReport> {
+    let targets = [
+        (base.join("gtk-3.0").join("gtk.css"), gtk3_css(theme)),
+        (base.join("gtk-4.0").join("gtk.css"), gtk4_css(theme)),
+    ];
+    let mut report = ExportReport::default();
+    for (path, content) in targets {
+        if is_foreign(&path) {
+            report.skipped.push(path);
+            continue;
+        }
+        if let Some(parent) = path.parent() {
+            std::fs::create_dir_all(parent)?;
+        }
+        std::fs::write(&path, content)?;
+        report.written.push(path);
+    }
+    Ok(report)
+}
+
+/// `true` si el archivo existe y NO lleva la marca de nahual — es un
+/// `gtk.css` propio del usuario y no debemos pisarlo. Si no existe (o no
+/// se puede leer), `false`: campo libre para escribir.
+fn is_foreign(path: &Path) -> bool {
+    match std::fs::read_to_string(path) {
+        Ok(existing) => !existing.contains(MARKER),
+        Err(_) => false,
+    }
+}
+
+// ============================================================================
+// Generación de CSS
+// ============================================================================
+
+/// CSS para `~/.config/gtk-4.0/gtk.css` — nombres de color de libadwaita.
+pub fn gtk4_css(theme: &Theme) -> String {
+    let p = gtk_palette(theme);
+    let mut s = header(theme, "GTK 4 / libadwaita");
+    define(&mut s, "accent_color", &p.accent);
+    define(&mut s, "accent_bg_color", &p.accent);
+    define(&mut s, "accent_fg_color", &p.accent_fg);
+    define(&mut s, "window_bg_color", &p.window_bg);
+    define(&mut s, "window_fg_color", &p.window_fg);
+    define(&mut s, "view_bg_color", &p.view_bg);
+    define(&mut s, "view_fg_color", &p.window_fg);
+    define(&mut s, "headerbar_bg_color", &p.headerbar_bg);
+    define(&mut s, "headerbar_fg_color", &p.window_fg);
+    define(&mut s, "card_bg_color", &p.card_bg);
+    define(&mut s, "card_fg_color", &p.window_fg);
+    define(&mut s, "popover_bg_color", &p.popover_bg);
+    define(&mut s, "popover_fg_color", &p.window_fg);
+    define(&mut s, "dialog_bg_color", &p.dialog_bg);
+    define(&mut s, "dialog_fg_color", &p.window_fg);
+    define(&mut s, "sidebar_bg_color", &p.sidebar_bg);
+    define(&mut s, "sidebar_fg_color", &p.window_fg);
+    define(&mut s, "destructive_color", &p.destructive);
+    define(&mut s, "destructive_bg_color", &p.destructive);
+    define(&mut s, "destructive_fg_color", &p.accent_fg);
+    define(&mut s, "borders", &p.border);
+    s
+}
+
+/// CSS para `~/.config/gtk-3.0/gtk.css` — nombres de color de Adwaita 3.
+pub fn gtk3_css(theme: &Theme) -> String {
+    let p = gtk_palette(theme);
+    let mut s = header(theme, "GTK 3");
+    define(&mut s, "theme_bg_color", &p.window_bg);
+    define(&mut s, "theme_fg_color", &p.window_fg);
+    define(&mut s, "theme_base_color", &p.view_bg);
+    define(&mut s, "theme_text_color", &p.window_fg);
+    define(&mut s, "theme_selected_bg_color", &p.accent);
+    define(&mut s, "theme_selected_fg_color", &p.accent_fg);
+    define(&mut s, "theme_tooltip_bg_color", &p.popover_bg);
+    define(&mut s, "theme_tooltip_fg_color", &p.window_fg);
+    define(&mut s, "insensitive_bg_color", &p.window_bg);
+    define(&mut s, "insensitive_fg_color", &p.fg_disabled);
+    define(&mut s, "borders", &p.border);
+    define(&mut s, "warning_color", &p.destructive);
+    define(&mut s, "error_color", &p.destructive);
+    s
+}
+
+/// Cabecera común: lleva la [`MARKER`] (la guarda de no-pisar) y avisa
+/// que el archivo es regenerado.
+fn header(theme: &Theme, toolkit: &str) -> String {
+    format!(
+        "/* {MARKER} · tema «{}» · {toolkit} */\n\
+         /* Lo reescribe nahual al cambiar de tema. Para usar tu propio\n\
+         \x20  gtk.css, borra esta cabecera y nahual respetará el archivo. */\n\n",
+        theme.name
+    )
+}
+
+/// Agrega una línea `@define-color <name> <value>;`.
+fn define(out: &mut String, name: &str, value: &str) {
+    out.push_str("@define-color ");
+    out.push_str(name);
+    out.push(' ');
+    out.push_str(value);
+    out.push_str(";\n");
+}
+
+/// Paleta intermedia: los colores GTK ya resueltos a hex `#rrggbb`.
+struct GtkPalette {
+    accent: String,
+    accent_fg: String,
+    window_bg: String,
+    window_fg: String,
+    view_bg: String,
+    headerbar_bg: String,
+    card_bg: String,
+    popover_bg: String,
+    sidebar_bg: String,
+    dialog_bg: String,
+    fg_disabled: String,
+    border: String,
+    destructive: String,
+}
+
+/// Deriva la paleta GTK del `Theme`. El acento y los foregrounds son
+/// directos; los fondos neutros se sintetizan con un ramp de luminancia
+/// (GTK pinta ventanas sólidas, no gradientes), tintado con el matiz del
+/// borde del tema para que conserve su "temperatura".
+fn gtk_palette(t: &Theme) -> GtkPalette {
+    let nh = t.border.h;
+    let ns = (t.border.s * 0.7).min(0.5);
+    let neutral = |l: f32| hex(hsla(nh, ns, l, 1.0));
+
+    // (window, view, headerbar, card, popover, sidebar, dialog)
+    let lum = if t.is_dark {
+        [0.11, 0.07, 0.14, 0.15, 0.17, 0.09, 0.13]
+    } else {
+        [0.95, 0.99, 0.90, 0.98, 0.99, 0.93, 0.96]
+    };
+
+    GtkPalette {
+        accent: hex(t.accent),
+        accent_fg: hex(contrast_fg(t.accent)),
+        window_bg: neutral(lum[0]),
+        window_fg: hex(t.fg_text),
+        view_bg: neutral(lum[1]),
+        headerbar_bg: neutral(lum[2]),
+        card_bg: neutral(lum[3]),
+        popover_bg: neutral(lum[4]),
+        sidebar_bg: neutral(lum[5]),
+        dialog_bg: neutral(lum[6]),
+        fg_disabled: hex(t.fg_disabled),
+        border: hex(t.border),
+        destructive: hex(t.accent_destructive()),
+    }
+}
+
+/// Color de texto legible sobre `bg`: casi-negro si el fondo es claro,
+/// casi-blanco si es oscuro. Decide por luma perceptual.
+fn contrast_fg(bg: Hsla) -> Hsla {
+    let rgba: Rgba = bg.into();
+    let luma = 0.299 * rgba.r + 0.587 * rgba.g + 0.114 * rgba.b;
+    if luma > 0.55 {
+        hsla(0.0, 0.0, 0.08, 1.0)
+    } else {
+        hsla(0.0, 0.0, 0.98, 1.0)
+    }
+}
+
+/// `Hsla` → `#rrggbb`. Descarta el alfa (GTK lo maneja aparte).
+fn hex(c: Hsla) -> String {
+    let rgba: Rgba = c.into();
+    let q = |f: f32| (f.clamp(0.0, 1.0) * 255.0).round() as u8;
+    format!("#{:02x}{:02x}{:02x}", q(rgba.r), q(rgba.g), q(rgba.b))
+}
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+
+    #[test]
+    fn hex_of_pure_colors() {
+        assert_eq!(hex(hsla(0.0, 0.0, 0.0, 1.0)), "#000000");
+        assert_eq!(hex(hsla(0.0, 0.0, 1.0, 1.0)), "#ffffff");
+        assert_eq!(hex(hsla(0.0, 1.0, 0.5, 1.0)), "#ff0000");
+    }
+
+    #[test]
+    fn gtk4_css_carries_marker_and_accent() {
+        let css = gtk4_css(&Theme::nebula());
+        assert!(css.contains(MARKER), "falta la marca de no-pisar");
+        assert!(css.contains("@define-color accent_color #"));
+        assert!(css.contains("@define-color window_bg_color #"));
+    }
+
+    #[test]
+    fn gtk3_css_carries_selection() {
+        let css = gtk3_css(&Theme::aurora());
+        assert!(css.contains(MARKER));
+        assert!(css.contains("@define-color theme_selected_bg_color #"));
+    }
+
+    #[test]
+    fn every_preset_yields_well_formed_hex() {
+        for theme in Theme::all() {
+            let p = gtk_palette(&theme);
+            for color in [&p.accent, &p.window_bg, &p.view_bg, &p.border] {
+                assert_eq!(color.len(), 7, "{}: hex mal formado: {color}", theme.name);
+                assert!(color.starts_with('#'), "{}: sin #: {color}", theme.name);
+                assert!(
+                    color[1..].chars().all(|c| c.is_ascii_hexdigit()),
+                    "{}: dígitos no-hex: {color}",
+                    theme.name
+                );
+            }
+        }
+    }
+
+    #[test]
+    fn light_theme_has_light_window_bg() {
+        // Solarized Light es claro: su window_bg debe ser luminoso.
+        let p = gtk_palette(&Theme::solarized_light());
+        let v = u8::from_str_radix(&p.window_bg[1..3], 16).unwrap();
+        assert!(
+            v > 200,
+            "fondo de tema claro demasiado oscuro: {}",
+            p.window_bg
+        );
+    }
+
+    #[test]
+    fn dark_theme_has_dark_window_bg() {
+        let p = gtk_palette(&Theme::nebula());
+        let v = u8::from_str_radix(&p.window_bg[1..3], 16).unwrap();
+        assert!(
+            v < 60,
+            "fondo de tema oscuro demasiado claro: {}",
+            p.window_bg
+        );
+    }
+
+    #[test]
+    fn export_writes_both_and_respects_foreign() {
+        let mut dir = std::env::temp_dir();
+        dir.push(format!(
+            "nahual-toolkit-export-{}-{}",
+            std::process::id(),
+            std::time::SystemTime::now()
+                .duration_since(std::time::UNIX_EPOCH)
+                .map(|d| d.as_nanos())
+                .unwrap_or(0),
+        ));
+        let gtk3 = dir.join("gtk-3.0").join("gtk.css");
+        let gtk4 = dir.join("gtk-4.0").join("gtk.css");
+
+        // Primera exportación: crea los dos archivos.
+        let r1 = export_toolkit_configs_to(&dir, &Theme::nebula()).unwrap();
+        assert_eq!(r1.written.len(), 2);
+        assert!(r1.skipped.is_empty());
+        assert!(gtk3.exists() && gtk4.exists());
+
+        // Re-exportar otro tema: nuestros archivos llevan la marca, se
+        // reescriben sin problema.
+        let r2 = export_toolkit_configs_to(&dir, &Theme::aurora()).unwrap();
+        assert_eq!(r2.written.len(), 2);
+        assert!(std::fs::read_to_string(&gtk4).unwrap().contains("Aurora"));
+
+        // Un `gtk.css` ajeno (sin marca) se respeta: no se pisa.
+        std::fs::write(&gtk3, "/* css del usuario */\n").unwrap();
+        let r3 = export_toolkit_configs_to(&dir, &Theme::sunset()).unwrap();
+        assert_eq!(r3.written, vec![gtk4.clone()]);
+        assert_eq!(r3.skipped, vec![gtk3.clone()]);
+        assert_eq!(
+            std::fs::read_to_string(&gtk3).unwrap(),
+            "/* css del usuario */\n"
+        );
+
+        let _ = std::fs::remove_dir_all(&dir);
+    }
+
+    #[test]
+    fn is_foreign_detects_user_file() {
+        let mut path = std::env::temp_dir();
+        path.push(format!("nahual-toolkit-test-{}.css", std::process::id()));
+
+        // Archivo ajeno (sin marca) → foreign.
+        std::fs::write(&path, "/* mi css */\n").unwrap();
+        assert!(is_foreign(&path));
+
+        // Archivo nuestro (con marca) → no foreign.
+        std::fs::write(&path, gtk4_css(&Theme::nebula())).unwrap();
+        assert!(!is_foreign(&path));
+
+        let _ = std::fs::remove_file(&path);
+
+        // Archivo inexistente → no foreign (campo libre).
+        assert!(!is_foreign(&path));
+    }
+}
diff --git a/docs/changelog/mirada.md b/docs/changelog/mirada.md
index d4876c0..b60ea44 100644
--- a/docs/changelog/mirada.md
+++ b/docs/changelog/mirada.md
@@ -5,6 +5,20 @@ Cerebro (GPUI) ↔ Cuerpo (smithay) en dos procesos. El historial previo
 vive en `git log` (`feat(mirada): …`); este archivo arranca con el
 trabajo de escritorio (tema, DM).
 
+### feat(mirada): inyección de entorno de tema a los hijos del compositor
+
+`spawn_command` del compositor inyecta `THEME_ENV` a cada proceso hijo:
+
+- `XDG_CURRENT_DESKTOP=mirada` → `xdg-desktop-portal` enruta hacia el
+  backend `mirada-portal`.
+- `QT_QPA_PLATFORMTHEME=gtk3` → las apps Qt siguen el tema GTK, y por
+  tanto el `gtk.css` que genera `nahual-theme`.
+
+Cierra la segunda mitad de la uniformización del tema: portal
+(claro/oscuro + acento por protocolo) + `gtk.css` generado por
+`nahual-theme::toolkit` + esta inyección → GTK y Qt alineados con el
+tema de nahual.
+
 ### feat(mirada-portal): backend de tema — org.freedesktop.appearance
 
 App nueva `crates/apps/mirada-portal`: backend de `xdg-desktop-portal`
diff --git a/docs/changelog/nahual.md b/docs/changelog/nahual.md
index 5a4895b..34d81d4 100644
--- a/docs/changelog/nahual.md
+++ b/docs/changelog/nahual.md
@@ -2,6 +2,27 @@
 
 Motor GPUI: libs + widgets. Renombrado de `yahweh` el 2026-05-19.
 
+### feat(nahual-theme): exportación del tema a GTK (módulo toolkit)
+
+Módulo nuevo `nahual-theme/src/toolkit.rs`: traduce el `Theme` activo a
+`~/.config/gtk-3.0/gtk.css` y `gtk-4.0/gtk.css` con overrides
+`@define-color`. Las apps GTK adoptan el acento exacto del tema + un
+neutro claro/oscuro coherente. Los fondos en gradiente de nahual no se
+pueden reproducir en ventanas GTK sólidas, así que el neutro se sintetiza
+con un ramp de luminancia tintado por el matiz del borde del tema.
+
+- `gtk4_css` / `gtk3_css` — generadores puros (nombres de color de
+  libadwaita y de Adwaita 3 respectivamente).
+- `export_toolkit_configs` + `export_toolkit_configs_to(base)` —
+  escritura; el segundo con directorio base explícito para tests.
+- **Guarda de no-pisar**: si un `gtk.css` ya existe sin la marca de
+  nahual, es del usuario y se respeta (`ExportReport.skipped`).
+- `Theme::set` y `Theme::install_default` exportan best-effort: cambiar
+  de tema en cualquier app GPUI actualiza GTK al instante.
+- `config_path` refactorizado sobre un nuevo `config_home()`.
+- Ejemplo `dump-toolkit-css` para inspeccionar el CSS generado.
+- 8 tests nuevos en `toolkit`.
+
 ### feat(yahweh-launcher): F3 — extracción del shell standard de explorers
 Iter 19. Patrón con 4 consumers idénticos (nakui-explorer,
 nouser-explorer, minga-explorer, brahman-broker-explorer) declaraban