tawasuyu

tawasuyu/brahman

Rust 492TOML 194Markdown 53JSON 176939 Definiciones

feat(mirada): mirada-portal — backend de tema org.freedesktop.appearance

Backend de xdg-desktop-portal para carmen: implementa
org.freedesktop.impl.portal.Settings y publica color-scheme,
accent-color y contrast desde el tema activo de nahual. GTK4, Qt6,
Firefox y Chromium voltean claro/oscuro + acento por protocolo, sin
tocar sus configs. Watcher con notify del archivo de nahual-theme →
emite SettingChanged en vivo. 13 tests; smoke verificado sobre un bus
de sesión efímero.

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

5369c307e411a8c8dd5e1f49bb37a53b98a83123 · por sergio · (hace 5 meses) · padre 2bd6aaad0 · ver el árbol en este commit

Delta semántico · 46

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

crates/apps/mirada-portal/src/theme_facts.rs

10 archivos · +789 −0

diff --git a/Cargo.lock b/Cargo.lock
index 256ff4b..72d26f9 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -7759,6 +7759,18 @@ dependencies = [
  "serde",
 ]
 
+[[package]]
+name = "mirada-portal"
+version = "0.1.0"
+dependencies = [
+ "anyhow",
+ "notify",
+ "tokio",
+ "tracing",
+ "tracing-subscriber",
+ "zbus 4.4.0",
+]
+
 [[package]]
 name = "mirada-protocol"
 version = "0.1.0"
diff --git a/Cargo.toml b/Cargo.toml
index 99ea498..01d39d6 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -289,6 +289,7 @@ members = [
     "crates/apps/mirada-compositor",
     "crates/apps/mirada-ctl",
     "crates/apps/mirada-launcher",
+    "crates/apps/mirada-portal",
 ]
 
 [workspace.package]
diff --git a/crates/apps/mirada-portal/Cargo.toml b/crates/apps/mirada-portal/Cargo.toml
new file mode 100644
index 0000000..7ecf3fd
--- /dev/null
+++ b/crates/apps/mirada-portal/Cargo.toml
@@ -0,0 +1,21 @@
+[package]
+name = "mirada-portal"
+version.workspace = true
+edition.workspace = true
+rust-version.workspace = true
+license.workspace = true
+authors.workspace = true
+publish.workspace = true
+description = "mirada-portal — backend xdg-desktop-portal de carmen: implementa org.freedesktop.impl.portal.Settings y publica el tema activo de nahual (claro/oscuro + acento + contraste) a GTK, Qt, Firefox y Chromium por protocolo."
+
+[[bin]]
+name = "mirada-portal"
+path = "src/main.rs"
+
+[dependencies]
+zbus = { workspace = true }
+notify = { workspace = true }
+tokio = { workspace = true }
+tracing = { workspace = true }
+tracing-subscriber = { workspace = true }
+anyhow = { workspace = true }
diff --git a/crates/apps/mirada-portal/README.md b/crates/apps/mirada-portal/README.md
new file mode 100644
index 0000000..cee38ae
--- /dev/null
+++ b/crates/apps/mirada-portal/README.md
@@ -0,0 +1,76 @@
+# mirada-portal
+
+Backend de `xdg-desktop-portal` para el escritorio **carmen**. Implementa
+`org.freedesktop.impl.portal.Settings` y publica un único namespace:
+`org.freedesktop.appearance`.
+
+## Qué resuelve
+
+GTK y Qt leen su configuración de sitios incompatibles entre sí. Pero
+**ambos** —además de Firefox y Chromium— consultan el portal de
+FreeDesktop para saber:
+
+- `color-scheme` — claro (`2`) u oscuro (`1`),
+- `accent-color` — el color de acento como `(ddd)` RGB,
+- `contrast` — contraste alto (`1`) o normal (`0`).
+
+`mirada-portal` responde esas tres claves a partir del tema activo de
+`nahual` y, cuando el tema cambia, emite `SettingChanged`: todo el
+ecosistema voltea en vivo, **sin tocar un solo archivo de config de las
+apps**.
+
+## Fuente del tema
+
+El daemon lee `$XDG_CONFIG_HOME/nahual/theme` (el archivo que persiste
+`nahual-theme`, con el nombre del preset activo) y lo vigila con
+`notify`. La traducción nombre → hechos del portal está en
+[`src/theme_facts.rs`], que espeja `nahual_theme::Theme::all()` sin
+enlazar GPUI.
+
+## Arquitectura
+
+Esto es el **backend** del portal. El frontend genérico
+`xdg-desktop-portal` (paquete agnóstico, liviano) enruta las llamadas de
+las apps hacia este backend según el archivo `mirada.portal`. No hay que
+implementar el frontend.
+
+## Instalación de los archivos de `data/`
+
+```sh
+install -Dm644 data/mirada.portal \
+    /usr/share/xdg-desktop-portal/portals/mirada.portal
+install -Dm644 data/mirada-portals.conf \
+    /usr/share/xdg-desktop-portal/mirada-portals.conf
+install -Dm644 data/org.freedesktop.impl.portal.desktop.mirada.service \
+    /usr/share/dbus-1/services/org.freedesktop.impl.portal.desktop.mirada.service
+install -Dm755 target/release/mirada-portal /usr/bin/mirada-portal
+```
+
+El frontend casa `UseIn=mirada` contra `XDG_CURRENT_DESKTOP`, así que
+carmen debe exportar `XDG_CURRENT_DESKTOP=mirada`. Alternativamente, el
+`mirada-portals.conf` lo fuerza con `default=mirada`.
+
+`mirada-portal` se puede arrancar desde `~/.config/mirada/autostart` o
+dejar que el frontend lo active por D-Bus (de ahí el `.service`).
+
+## Smoke test (sin frontend ni apps GTK)
+
+Con un bus de sesión vivo, el backend se puede interrogar directo:
+
+```sh
+busctl --user introspect org.freedesktop.impl.portal.desktop.mirada \
+    /org/freedesktop/portal/desktop
+busctl --user call org.freedesktop.impl.portal.desktop.mirada \
+    /org/freedesktop/portal/desktop \
+    org.freedesktop.impl.portal.Settings ReadAll as 0
+```
+
+Cambiar `~/.config/nahual/theme` debe disparar una señal `SettingChanged`
+(observable con `busctl --user monitor`).
+
+## Límite conocido (v1)
+
+El portal `org.freedesktop.appearance` sólo lleva claro/oscuro + acento +
+contraste. **No** lleva la paleta completa de `nahual`. Para recolorear
+GTK/Qt a los colores exactos del tema hace falta, además, inyección de
+entorno + CSS generado en el `spawn` de carmen — siguiente paso del plan.
diff --git a/crates/apps/mirada-portal/data/mirada-portals.conf b/crates/apps/mirada-portal/data/mirada-portals.conf
new file mode 100644
index 0000000..44099de
--- /dev/null
+++ b/crates/apps/mirada-portal/data/mirada-portals.conf
@@ -0,0 +1,3 @@
+[preferred]
+default=mirada
+org.freedesktop.impl.portal.Settings=mirada
diff --git a/crates/apps/mirada-portal/data/mirada.portal b/crates/apps/mirada-portal/data/mirada.portal
new file mode 100644
index 0000000..700cdfa
--- /dev/null
+++ b/crates/apps/mirada-portal/data/mirada.portal
@@ -0,0 +1,4 @@
+[portal]
+DBusName=org.freedesktop.impl.portal.desktop.mirada
+Interfaces=org.freedesktop.impl.portal.Settings
+UseIn=mirada
diff --git a/crates/apps/mirada-portal/data/org.freedesktop.impl.portal.desktop.mirada.service b/crates/apps/mirada-portal/data/org.freedesktop.impl.portal.desktop.mirada.service
new file mode 100644
index 0000000..1483f32
--- /dev/null
+++ b/crates/apps/mirada-portal/data/org.freedesktop.impl.portal.desktop.mirada.service
@@ -0,0 +1,3 @@
+[D-BUS Service]
+Name=org.freedesktop.impl.portal.desktop.mirada
+Exec=/usr/bin/mirada-portal
diff --git a/crates/apps/mirada-portal/src/main.rs b/crates/apps/mirada-portal/src/main.rs
new file mode 100644
index 0000000..10ba734
--- /dev/null
+++ b/crates/apps/mirada-portal/src/main.rs
@@ -0,0 +1,430 @@
+//! `mirada-portal` — backend de `xdg-desktop-portal` para el escritorio
+//! carmen.
+//!
+//! Implementa la interfaz `org.freedesktop.impl.portal.Settings` y
+//! publica un único namespace: `org.freedesktop.appearance`. Con eso,
+//! GTK4/libadwaita, Qt6, Firefox y Chromium leen del sistema —
+//! **por protocolo, sin tocar sus archivos de config** — si el
+//! escritorio está en modo claro u oscuro, su color de acento y si pide
+//! contraste alto. Cuando el tema de `nahual` cambia, el portal emite
+//! `SettingChanged` y todas esas apps voltean en vivo.
+//!
+//! Fuente del tema: el archivo que persiste `nahual-theme`
+//! (`$XDG_CONFIG_HOME/nahual/theme`, contiene el nombre del preset
+//! activo). El portal lo vigila con `notify` y reexpone sus hechos —
+//! ver [`theme_facts`].
+//!
+//! Este crate es el **backend** del portal: el frontend genérico
+//! `xdg-desktop-portal` lo enruta vía el archivo `mirada.portal`. Ver
+//! el README para la instalación de los archivos de `data/`.
+
+use std::collections::HashMap;
+use std::path::{Path, PathBuf};
+use std::sync::{Arc, Mutex};
+
+use tokio::signal::unix::{signal, SignalKind};
+use tracing::{info, warn};
+use tracing_subscriber::EnvFilter;
+use zbus::zvariant::{OwnedValue, Value};
+use zbus::{fdo, interface, SignalContext};
+
+mod theme_facts;
+use theme_facts::ThemeFacts;
+
+/// Nombre de bus del backend. El patrón `org.freedesktop.impl.portal.
+/// desktop.<id>` es el que espera el frontend `xdg-desktop-portal`; el
+/// `<id>` (`mirada`) tiene que coincidir con el `DBusName` del archivo
+/// `mirada.portal`.
+const BUS_NAME: &str = "org.freedesktop.impl.portal.desktop.mirada";
+
+/// Ruta de objeto canónica de los portales del escritorio.
+const OBJ_PATH: &str = "/org/freedesktop/portal/desktop";
+
+/// Único namespace que servimos. El estándar moderno que leen GTK, Qt,
+/// Firefox y Chromium para claro/oscuro + acento.
+const APPEARANCE_NS: &str = "org.freedesktop.appearance";
+
+#[tokio::main(flavor = "current_thread")]
+async fn main() -> anyhow::Result<()> {
+    init_tracing();
+    info!("mirada-portal: arrancando backend org.freedesktop.impl.portal.Settings");
+
+    let theme_path = theme_config_path();
+    let initial = read_facts(theme_path.as_deref());
+    info!(
+        ?theme_path,
+        color_scheme = initial.color_scheme(),
+        contrast = initial.contrast(),
+        "tema inicial resuelto"
+    );
+
+    let facts = Arc::new(Mutex::new(initial));
+    let portal = SettingsPortal {
+        facts: Arc::clone(&facts),
+    };
+
+    // El portal vive en el bus de **sesión** (no el de sistema): es un
+    // servicio del escritorio del usuario, no del sistema.
+    let conn_result = zbus::connection::Builder::session()
+        .and_then(|b| b.name(BUS_NAME))
+        .and_then(|b| b.serve_at(OBJ_PATH, portal));
+
+    match conn_result {
+        Ok(builder) => match builder.build().await {
+            Ok(conn) => {
+                info!(name = BUS_NAME, "name adquirido en el bus de sesión");
+                run(conn, facts, theme_path).await
+            }
+            Err(e) => {
+                warn!(?e, "no se pudo construir la conexión D-Bus — modo idle");
+                wait_for_term().await
+            }
+        },
+        Err(e) => {
+            warn!(?e, "builder D-Bus falló (¿hay bus de sesión?) — modo idle");
+            wait_for_term().await
+        }
+    }
+}
+
+/// Conectado al bus: monta el watcher del tema y espera la señal de
+/// término. El watcher se guarda en `_watcher` para que no se dropee
+/// (al dropearse dejaría de vigilar).
+async fn run(
+    conn: zbus::Connection,
+    facts: Arc<Mutex<ThemeFacts>>,
+    theme_path: Option<PathBuf>,
+) -> anyhow::Result<()> {
+    let _watcher = match &theme_path {
+        Some(path) => match spawn_theme_watcher(conn.clone(), Arc::clone(&facts), path.clone()) {
+            Ok(w) => Some(w),
+            Err(e) => {
+                warn!(
+                    ?e,
+                    "watcher del tema no disponible — el portal no actualizará en vivo"
+                );
+                None
+            }
+        },
+        None => {
+            warn!("sin ruta de config de tema — el portal sirve un valor fijo");
+            None
+        }
+    };
+    wait_for_term().await
+}
+
+// ============================================================================
+// Interfaz D-Bus: org.freedesktop.impl.portal.Settings
+// ============================================================================
+
+struct SettingsPortal {
+    /// Hechos del tema activo. El watcher los reescribe cuando cambia.
+    facts: Arc<Mutex<ThemeFacts>>,
+}
+
+#[interface(name = "org.freedesktop.impl.portal.Settings")]
+impl SettingsPortal {
+    /// Versión de la interfaz impl. `ReadOne` se agregó en la 2.
+    #[zbus(property, name = "version")]
+    fn version(&self) -> u32 {
+        2
+    }
+
+    /// `ReadAll(namespaces) -> a{sa{sv}}`. Los `namespaces` son patrones
+    /// (sufijo `*` = prefijo); lista vacía = todos. Sólo respondemos
+    /// `org.freedesktop.appearance`.
+    async fn read_all(
+        &self,
+        namespaces: Vec<String>,
+    ) -> fdo::Result<HashMap<String, HashMap<String, OwnedValue>>> {
+        let mut out = HashMap::new();
+        if namespace_requested(&namespaces, APPEARANCE_NS) {
+            let facts = *self.facts.lock().unwrap();
+            out.insert(APPEARANCE_NS.to_string(), appearance_map(&facts)?);
+        }
+        Ok(out)
+    }
+
+    /// `ReadOne(namespace, key) -> v`. Lee un único valor.
+    async fn read_one(&self, namespace: String, key: String) -> fdo::Result<OwnedValue> {
+        let facts = *self.facts.lock().unwrap();
+        lookup(&facts, &namespace, &key)
+    }
+
+    /// `Read(namespace, key) -> v`. Deprecado a favor de `ReadOne` desde
+    /// la versión 2 del portal, pero apps viejas lo siguen llamando.
+    async fn read(&self, namespace: String, key: String) -> fdo::Result<OwnedValue> {
+        let facts = *self.facts.lock().unwrap();
+        lookup(&facts, &namespace, &key)
+    }
+
+    /// `SettingChanged(namespace, key, value)`. Lo emite el watcher
+    /// cuando el tema persistido cambia.
+    #[zbus(signal)]
+    async fn setting_changed(
+        ctxt: &SignalContext<'_>,
+        namespace: &str,
+        key: &str,
+        value: Value<'_>,
+    ) -> zbus::Result<()>;
+}
+
+// ============================================================================
+// Mapeo tema → valores del portal
+// ============================================================================
+
+/// Construye el mapa `a{sv}` del namespace `org.freedesktop.appearance`.
+fn appearance_map(facts: &ThemeFacts) -> fdo::Result<HashMap<String, OwnedValue>> {
+    Ok(HashMap::from([
+        (
+            "color-scheme".to_string(),
+            into_owned(Value::U32(facts.color_scheme()))?,
+        ),
+        (
+            "contrast".to_string(),
+            into_owned(Value::U32(facts.contrast()))?,
+        ),
+        ("accent-color".to_string(), into_owned(accent_value(facts))?),
+    ]))
+}
+
+/// Resuelve una clave concreta dentro de `org.freedesktop.appearance`.
+fn lookup(facts: &ThemeFacts, namespace: &str, key: &str) -> fdo::Result<OwnedValue> {
+    if namespace != APPEARANCE_NS {
+        return Err(fdo::Error::Failed(format!(
+            "namespace no servido por mirada-portal: {namespace}"
+        )));
+    }
+    let value = match key {
+        "color-scheme" => Value::U32(facts.color_scheme()),
+        "contrast" => Value::U32(facts.contrast()),
+        "accent-color" => accent_value(facts),
+        other => {
+            return Err(fdo::Error::Failed(format!(
+                "clave desconocida en {APPEARANCE_NS}: {other}"
+            )));
+        }
+    };
+    into_owned(value)
+}
+
+/// El acento como structure `(ddd)` — tres dobles RGB en 0..1.
+fn accent_value(facts: &ThemeFacts) -> Value<'static> {
+    let (r, g, b) = facts.accent_rgb();
+    Value::from((r, g, b))
+}
+
+/// `Value` → `OwnedValue`. Sólo falla con valores que llevan fds; los
+/// nuestros (enteros y dobles) nunca lo hacen.
+fn into_owned(value: Value<'_>) -> fdo::Result<OwnedValue> {
+    OwnedValue::try_from(value).map_err(|e| fdo::Error::Failed(format!("zvariant: {e}")))
+}
+
+/// ¿El patrón de namespaces de un `ReadAll` pide `ns`? Lista vacía =
+/// todos. Un patrón con sufijo `*` matchea por prefijo; sino, exacto.
+fn namespace_requested(patterns: &[String], ns: &str) -> bool {
+    if patterns.is_empty() {
+        return true;
+    }
+    patterns.iter().any(|p| match p.strip_suffix('*') {
+        Some(prefix) => ns.starts_with(prefix),
+        None => p == ns,
+    })
+}
+
+// ============================================================================
+// Watcher del tema persistido
+// ============================================================================
+
+/// Vigila el archivo de tema de `nahual`; cuando cambia, recomputa los
+/// hechos y emite `SettingChanged`. Devuelve el watcher, que el caller
+/// debe mantener vivo.
+fn spawn_theme_watcher(
+    conn: zbus::Connection,
+    facts: Arc<Mutex<ThemeFacts>>,
+    path: PathBuf,
+) -> notify::Result<notify::RecommendedWatcher> {
+    use notify::{RecursiveMode, Watcher};
+
+    // Canal acotado: el callback de notify (en su propio hilo) sólo
+    // hace `try_send`; si el buffer está lleno ya hay un evento
+    // pendiente y da igual perder éste — coalescencia natural.
+    let (tx, mut rx) = tokio::sync::mpsc::channel::<()>(8);
+    let mut watcher = notify::recommended_watcher(move |res: notify::Result<notify::Event>| {
+        if res.is_ok() {
+            let _ = tx.try_send(());
+        }
+    })?;
+
+    // Vigilamos el **directorio padre**: así captamos también la
+    // creación del archivo si aún no existe.
+    let watch_target = path.parent().unwrap_or(&path).to_path_buf();
+    watcher.watch(&watch_target, RecursiveMode::NonRecursive)?;
+    info!(dir = ?watch_target, "vigilando el directorio del tema");
+
+    tokio::spawn(async move {
+        while rx.recv().await.is_some() {
+            let fresh = read_facts(Some(&path));
+            let changed = {
+                let mut guard = facts.lock().unwrap();
+                let differs = *guard != fresh;
+                *guard = fresh;
+                differs
+            };
+            if changed {
+                info!(
+                    color_scheme = fresh.color_scheme(),
+                    contrast = fresh.contrast(),
+                    "el tema cambió — emitiendo SettingChanged"
+                );
+                if let Err(e) = emit_appearance_changed(&conn, &fresh).await {
+                    warn!(?e, "no se pudo emitir SettingChanged");
+                }
+            }
+        }
+    });
+
+    Ok(watcher)
+}
+
+/// Emite `SettingChanged` para las tres claves de `appearance`.
+async fn emit_appearance_changed(conn: &zbus::Connection, facts: &ThemeFacts) -> zbus::Result<()> {
+    let ctxt = SignalContext::new(conn, OBJ_PATH)?;
+    SettingsPortal::setting_changed(
+        &ctxt,
+        APPEARANCE_NS,
+        "color-scheme",
+        Value::U32(facts.color_scheme()),
+    )
+    .await?;
+    SettingsPortal::setting_changed(
+        &ctxt,
+        APPEARANCE_NS,
+        "contrast",
+        Value::U32(facts.contrast()),
+    )
+    .await?;
+    SettingsPortal::setting_changed(&ctxt, APPEARANCE_NS, "accent-color", accent_value(facts))
+        .await?;
+    Ok(())
+}
+
+// ============================================================================
+// Lectura del tema persistido
+// ============================================================================
+
+/// Lee el nombre de tema del archivo y resuelve sus hechos. Si el
+/// archivo falta o está vacío, asume `Nebula` — el default de
+/// `nahual_theme::install_default`.
+fn read_facts(path: Option<&Path>) -> ThemeFacts {
+    let name = path
+        .and_then(|p| std::fs::read_to_string(p).ok())
+        .map(|s| s.trim().to_string())
+        .filter(|s| !s.is_empty())
+        .unwrap_or_else(|| "Nebula".to_string());
+    theme_facts::facts_for(&name)
+}
+
+/// Ruta del archivo donde `nahual-theme` persiste el nombre del tema
+/// activo. Réplica de `nahual_theme::config_path()` — `mirada-portal`
+/// no enlaza GPUI, así que no puede llamarla directamente. Convención
+/// XDG: `$XDG_CONFIG_HOME/nahual/theme`, sino `$HOME/.config/...`.
+fn theme_config_path() -> Option<PathBuf> {
+    let base = std::env::var("XDG_CONFIG_HOME")
+        .ok()
+        .filter(|s| !s.is_empty())
+        .map(PathBuf::from)
+        .or_else(|| {
+            std::env::var("HOME")
+                .ok()
+                .filter(|s| !s.is_empty())
+                .map(|h| PathBuf::from(h).join(".config"))
+        })?;
+    Some(base.join("nahual").join("theme"))
+}
+
+// ============================================================================
+// Plomería
+// ============================================================================
+
+async fn wait_for_term() -> anyhow::Result<()> {
+    let mut term = signal(SignalKind::terminate())?;
+    let mut int_ = signal(SignalKind::interrupt())?;
+    tokio::select! {
+        _ = term.recv() => info!("SIGTERM"),
+        _ = int_.recv() => info!("SIGINT"),
+    }
+    Ok(())
+}
+
+fn init_tracing() {
+    let filter =
+        EnvFilter::try_from_default_env().unwrap_or_else(|_| EnvFilter::new("mirada_portal=info"));
+    tracing_subscriber::fmt()
+        .with_env_filter(filter)
+        .with_target(true)
+        .init();
+}
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+
+    #[test]
+    fn namespace_empty_matches_all() {
+        assert!(namespace_requested(&[], APPEARANCE_NS));
+    }
+
+    #[test]
+    fn namespace_exact_match() {
+        assert!(namespace_requested(
+            &[APPEARANCE_NS.to_string()],
+            APPEARANCE_NS
+        ));
+        assert!(!namespace_requested(
+            &["org.example".to_string()],
+            APPEARANCE_NS
+        ));
+    }
+
+    #[test]
+    fn namespace_wildcard_prefix() {
+        assert!(namespace_requested(
+            &["org.freedesktop.*".to_string()],
+            APPEARANCE_NS
+        ));
+        assert!(!namespace_requested(
+            &["org.gnome.*".to_string()],
+            APPEARANCE_NS
+        ));
+    }
+
+    #[test]
+    fn appearance_map_has_three_keys() {
+        let facts = theme_facts::facts_for("Nebula");
+        let m = appearance_map(&facts).unwrap();
+        assert_eq!(m.len(), 3);
+        assert!(m.contains_key("color-scheme"));
+        assert!(m.contains_key("accent-color"));
+        assert!(m.contains_key("contrast"));
+    }
+
+    #[test]
+    fn lookup_unknown_namespace_errs() {
+        let facts = theme_facts::facts_for("Nebula");
+        assert!(lookup(&facts, "org.gnome.desktop.interface", "color-scheme").is_err());
+    }
+
+    #[test]
+    fn lookup_unknown_key_errs() {
+        let facts = theme_facts::facts_for("Nebula");
+        assert!(lookup(&facts, APPEARANCE_NS, "no-such-key").is_err());
+    }
+
+    #[test]
+    fn lookup_color_scheme_ok() {
+        let facts = theme_facts::facts_for("Solarized Light");
+        assert!(lookup(&facts, APPEARANCE_NS, "color-scheme").is_ok());
+    }
+}
diff --git a/crates/apps/mirada-portal/src/theme_facts.rs b/crates/apps/mirada-portal/src/theme_facts.rs
new file mode 100644
index 0000000..e375cfb
--- /dev/null
+++ b/crates/apps/mirada-portal/src/theme_facts.rs
@@ -0,0 +1,199 @@
+//! Tabla de hechos del tema relevantes para el portal.
+//!
+//! El portal sólo necesita tres hechos de cada tema: si es oscuro, su
+//! color de acento, y si es de alto contraste. La fuente de verdad de
+//! la paleta completa es `nahual_theme::Theme` (crate `nahual-theme`);
+//! esta tabla la **espeja deliberadamente** para que el daemon del
+//! portal no tenga que enlazar GPUI (que `nahual-theme` arrastra por
+//! sus tipos `Hsla`/`Background`).
+//!
+//! Si se agrega un preset nuevo a `nahual_theme::Theme::all()`, hay que
+//! reflejarlo aquí. Un nombre desconocido cae a [`FALLBACK`] — el
+//! portal degrada a "oscuro, sin acento marcado" en vez de romperse.
+
+/// Hechos de un tema que el portal expone por `org.freedesktop.appearance`.
+#[derive(Clone, Copy, Debug, PartialEq)]
+pub struct ThemeFacts {
+    /// `true` → el tema es oscuro (`color-scheme` = 1).
+    pub is_dark: bool,
+    /// `true` → alto contraste (`contrast` = 1).
+    pub high_contrast: bool,
+    /// Color de acento en HSL: `(matiz 0..360, saturación 0..1, luz 0..1)`.
+    /// Se guarda en HSL porque así está escrito en `nahual-theme` — la
+    /// conversión a RGB se hace al servir el valor.
+    pub accent_hsl: (f64, f64, f64),
+}
+
+impl ThemeFacts {
+    /// `color-scheme` de `org.freedesktop.appearance`: 0 = sin
+    /// preferencia, 1 = oscuro, 2 = claro. El escritorio siempre tiene
+    /// un tema activo, así que nunca devolvemos 0.
+    pub fn color_scheme(&self) -> u32 {
+        if self.is_dark {
+            1
+        } else {
+            2
+        }
+    }
+
+    /// `contrast` de `org.freedesktop.appearance`: 0 = normal,
+    /// 1 = contraste alto.
+    pub fn contrast(&self) -> u32 {
+        u32::from(self.high_contrast)
+    }
+
+    /// Acento como RGB en 0..1, el formato `(ddd)` que pide el portal.
+    pub fn accent_rgb(&self) -> (f64, f64, f64) {
+        let (h, s, l) = self.accent_hsl;
+        hsl_to_rgb(h, s, l)
+    }
+}
+
+/// Tema por defecto si el nombre persistido no se reconoce: oscuro, sin
+/// acento marcado (gris neutro). Degradación segura ante un preset
+/// futuro que esta tabla aún no conozca.
+pub const FALLBACK: ThemeFacts = ThemeFacts {
+    is_dark: true,
+    high_contrast: false,
+    accent_hsl: (0.0, 0.0, 0.5),
+};
+
+/// Mapea el nombre persistido de un tema a sus hechos. Espeja
+/// `nahual_theme::Theme::all()` (8 presets al 2026-05-21). Los números
+/// de acento están copiados literalmente de `nahual-theme/src/lib.rs`.
+pub fn facts_for(name: &str) -> ThemeFacts {
+    match name.trim() {
+        "Nebula" => ThemeFacts {
+            is_dark: true,
+            high_contrast: false,
+            accent_hsl: (280.0, 0.65, 0.65),
+        },
+        "Aurora" => ThemeFacts {
+            is_dark: true,
+            high_contrast: false,
+            accent_hsl: (150.0, 0.70, 0.55),
+        },
+        "Sunset" => ThemeFacts {
+            is_dark: true,
+            high_contrast: false,
+            accent_hsl: (15.0, 0.78, 0.62),
+        },
+        "Flat Dark" => ThemeFacts {
+            is_dark: true,
+            high_contrast: false,
+            accent_hsl: (210.0, 0.70, 0.55),
+        },
+        "Solarized Light" => ThemeFacts {
+            is_dark: false,
+            high_contrast: false,
+            accent_hsl: (205.0, 0.69, 0.42),
+        },
+        "High Contrast" => ThemeFacts {
+            is_dark: true,
+            high_contrast: true,
+            accent_hsl: (60.0, 1.00, 0.60),
+        },
+        "Print Color" => ThemeFacts {
+            is_dark: false,
+            high_contrast: false,
+            accent_hsl: (15.0, 0.70, 0.40),
+        },
+        "Print B&W" => ThemeFacts {
+            is_dark: false,
+            high_contrast: false,
+            accent_hsl: (0.0, 0.00, 0.20),
+        },
+        _ => FALLBACK,
+    }
+}
+
+/// HSL → RGB. `h` en grados 0..360, `s` y `l` en 0..1. Devuelve RGB en
+/// 0..1. Algoritmo estándar (croma + segmento del matiz).
+fn hsl_to_rgb(h: f64, s: f64, l: f64) -> (f64, f64, f64) {
+    let c = (1.0 - (2.0 * l - 1.0).abs()) * s;
+    let h_prime = h.rem_euclid(360.0) / 60.0;
+    let x = c * (1.0 - (h_prime % 2.0 - 1.0).abs());
+    let (r1, g1, b1) = match h_prime as u32 {
+        0 => (c, x, 0.0),
+        1 => (x, c, 0.0),
+        2 => (0.0, c, x),
+        3 => (0.0, x, c),
+        4 => (x, 0.0, c),
+        _ => (c, 0.0, x),
+    };
+    let m = l - c / 2.0;
+    (r1 + m, g1 + m, b1 + m)
+}
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+
+    fn approx(a: f64, b: f64) -> bool {
+        (a - b).abs() < 1e-6
+    }
+
+    fn rgb_eq(got: (f64, f64, f64), want: (f64, f64, f64)) -> bool {
+        approx(got.0, want.0) && approx(got.1, want.1) && approx(got.2, want.2)
+    }
+
+    #[test]
+    fn hsl_primaries() {
+        assert!(rgb_eq(hsl_to_rgb(0.0, 1.0, 0.5), (1.0, 0.0, 0.0)));
+        assert!(rgb_eq(hsl_to_rgb(120.0, 1.0, 0.5), (0.0, 1.0, 0.0)));
+        assert!(rgb_eq(hsl_to_rgb(240.0, 1.0, 0.5), (0.0, 0.0, 1.0)));
+    }
+
+    #[test]
+    fn hsl_grays() {
+        assert!(rgb_eq(hsl_to_rgb(0.0, 0.0, 0.0), (0.0, 0.0, 0.0)));
+        assert!(rgb_eq(hsl_to_rgb(0.0, 0.0, 1.0), (1.0, 1.0, 1.0)));
+        // Acento de "Print B&W": gris medio-oscuro.
+        assert!(rgb_eq(hsl_to_rgb(0.0, 0.0, 0.2), (0.2, 0.2, 0.2)));
+    }
+
+    #[test]
+    fn known_themes_map_color_scheme() {
+        assert_eq!(facts_for("Nebula").color_scheme(), 1);
+        assert_eq!(facts_for("Aurora").color_scheme(), 1);
+        assert_eq!(facts_for("Solarized Light").color_scheme(), 2);
+        assert_eq!(facts_for("Print Color").color_scheme(), 2);
+    }
+
+    #[test]
+    fn high_contrast_only_for_high_contrast_theme() {
+        assert!(facts_for("High Contrast").high_contrast);
+        assert_eq!(facts_for("High Contrast").contrast(), 1);
+        assert!(!facts_for("Nebula").high_contrast);
+        assert_eq!(facts_for("Nebula").contrast(), 0);
+    }
+
+    #[test]
+    fn unknown_theme_falls_back() {
+        let f = facts_for("NoSuchTheme");
+        assert_eq!(f, FALLBACK);
+        assert_eq!(f.color_scheme(), 1, "FALLBACK es oscuro");
+    }
+
+    #[test]
+    fn accent_rgb_in_range() {
+        for name in [
+            "Nebula",
+            "Aurora",
+            "Sunset",
+            "Flat Dark",
+            "Solarized Light",
+            "High Contrast",
+            "Print Color",
+            "Print B&W",
+        ] {
+            let (r, g, b) = facts_for(name).accent_rgb();
+            for ch in [r, g, b] {
+                assert!(
+                    (0.0..=1.0).contains(&ch),
+                    "{name}: canal fuera de rango: {ch}"
+                );
+            }
+        }
+    }
+}
diff --git a/docs/changelog/mirada.md b/docs/changelog/mirada.md
new file mode 100644
index 0000000..d4876c0
--- /dev/null
+++ b/docs/changelog/mirada.md
@@ -0,0 +1,40 @@
+# Changelog — mirada
+
+Compositor Wayland del monorepo (nombre de proyecto: carmen). Arquitectura
+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-portal): backend de tema — org.freedesktop.appearance
+
+App nueva `crates/apps/mirada-portal`: backend de `xdg-desktop-portal`
+para carmen. Implementa `org.freedesktop.impl.portal.Settings` y publica
+un único namespace, `org.freedesktop.appearance`, con tres claves:
+
+- `color-scheme` — `1` oscuro / `2` claro,
+- `accent-color` — `(ddd)` RGB,
+- `contrast` — `1` alto / `0` normal.
+
+GTK4/libadwaita, Qt6, Firefox y Chromium leen ese namespace por
+protocolo: con el portal activo, todos respetan claro/oscuro + acento del
+escritorio **sin tocar sus archivos de config**. Es la primera mitad de
+la uniformización del tema (la segunda es inyección de entorno + CSS
+generado para la paleta completa).
+
+- **Fuente del tema**: el archivo que persiste `nahual-theme`
+  (`$XDG_CONFIG_HOME/nahual/theme`). El daemon lo vigila con `notify`;
+  al cambiar, recomputa y emite `SettingChanged` — las apps voltean en
+  vivo.
+- **`theme_facts.rs`**: tabla pura nombre-de-tema → hechos del portal
+  (oscuro, acento HSL, contraste) + conversión HSL→RGB. Espeja
+  `nahual_theme::Theme::all()` sin enlazar GPUI, para que el daemon
+  quede liviano. Nombre desconocido → `FALLBACK` (oscuro, acento gris).
+- **D-Bus**: bus de **sesión**, `org.freedesktop.impl.portal.desktop.
+  mirada` en `/org/freedesktop/portal/desktop`. Mismo patrón `zbus 4`
+  que los shims de `compat/`.
+- **`data/`**: `mirada.portal` + `mirada-portals.conf` + el `.service`
+  de activación D-Bus. El frontend genérico `xdg-desktop-portal` enruta
+  hacia este backend; ver el README del crate.
+- 13 tests (`theme_facts` + helpers del protocolo). Smoke verificado
+  sobre un bus de sesión efímero (`dbus-run-session`): `ReadAll`,
+  `ReadOne`, `Read` y emisión de `SettingChanged` al cambiar el tema.