diff --git a/crates/window/README.md b/crates/window/README.md
--- a/crates/window/README.md
+++ b/crates/window/README.md
@@ -8,53 +8,65 @@ window per document.
 
 ## Why
 
-egui apps either keep the desktop's window frame (titlebar, borders, window buttons) or turn it off
-and draw their own. Across the suite this had drifted:
+egui apps either keep the desktop's window frame (title bar, borders, window buttons) or turn it
+off and draw their own. This crate makes that decision in one place, so every Craft app wears the
+same title bar on a given desktop.
 
-- some apps always drew their own frame on Linux (so KDE's titlebar was replaced by the app's),
-- some always kept the desktop's,
-- some probed the compositor and only drew their own on GNOME.
+## The rule
 
-So two Craft apps open side by side on KDE could wear different titlebars. This crate makes the
-decision in one place, matching each desktop.
+The app's own bar is the title bar by default (`Frame::Integrated`). The user setting "Use system
+title bar" (`TitleBarPref::System`) gives the frame to the OS, window manager or compositor
+(`Frame::Native`).
 
-## The rule
+| Platform, desktop, backend | Default | With "Use system title bar" |
+| --- | --- | --- |
+| macOS | `Inset`: full-size content view, hidden title, traffic lights over the app's bar | `Native` |
+| Windows | `Integrated(Windows)`: minimize, maximize, close at the right | `Native` |
+| GNOME, Wayland or XWayland | `Integrated(Adwaita)`, in the order of `button-layout` | `Native` |
+| KDE, Wayland or X11 | `Integrated(Breeze)`, in the order of KWin's `ButtonsOnLeft` and `ButtonsOnRight` | `Native` |
+| COSMIC, Xfce, Cinnamon, MATE, other floating desktops | `Integrated(Generic)` in the order of `gtk-decoration-layout`; Adwaita or Breeze when the GTK theme is one of those | `Native` |
+| Tiling compositors and window managers (Sway, Hyprland, i3, niri, river, from `XDG_CURRENT_DESKTOP`) | `Native` | `Native` |
+| Wayland without server-side decorations, not GNOME | `Integrated(Generic)` | `Native` |
+| Web | `Native`; no frame code runs | `Native` |
 
-| Platform | Frame |
-| --- | --- |
-| macOS, Windows | desktop (the OS) |
-| Linux/BSD on X11 | desktop (the window manager) |
-| Linux/BSD on Wayland **with** `zxdg_decoration_manager_v1` (KDE, Sway, Hyprland, COSMIC) | desktop (the compositor) |
-| Linux/BSD on Wayland **without** it (GNOME's Mutter) | the app |
+The backend is the one the app opens its window on: an app that forces XWayland on a Wayland
+session passes `Backend::X11`. The Wayland registry is probed (500 ms at most) only for
+`Backend::Wayland`.
 
-When the app draws the frame, the decision carries the window-button `Layout` the desktop names
-(GNOME's `button-layout`), so the app's header bar puts minimize/maximize/close where that desktop
-puts them. This crate decides *whether* and *where*; drawing the buttons stays in each app, whose
-title bars differ.
+`policy::decide` is pure and takes the OS, backend, desktop, setting and override. `decide_for`
+reads the platform once and calls it.
 
 ### Override
 
-`<PREFIX>_TITLEBAR` forces the choice. With the default prefix `CRAFT`: `CRAFT_TITLEBAR=app` forces
-the app frame, `=system` (or `=desktop`) forces the desktop frame. Apps pass their own prefix
-(`Prefix::new("PDFCRAFT")` → `PDFCRAFT_TITLEBAR`) so each keeps its own variable name.
+`<PREFIX>_TITLEBAR` wins over the setting. With the default prefix `CRAFT`: `CRAFT_TITLEBAR=app`
+forces the app's bar (also on tiling compositors), `=system` or `=desktop` forces the native frame.
+Apps pass their own prefix (`Prefix::new("PDFCRAFT")` reads `PDFCRAFT_TITLEBAR`).
+When `<PREFIX>_TITLEBAR` is unset, `CRAFT_TITLEBAR` applies, so one variable covers every app. `Decision::overridden` reports `Some(true)` or `Some(false)` when the variable decided.
 
 ## Use
 
 ```rust,ignore
-use craft_window::{Choice, Prefix, decorate};
+use craft_window::Prefix;
+use craft_window::policy::{Backend, Frame, TitleBarPref, apply, decide_for};
 
 // Once at startup, before the window is created:
-let choice = Choice::probe(Prefix::new("PHOTOCRAFT"));
-let viewport = decorate(egui::ViewportBuilder::default().with_title("PhotoCraft"), choice.frame());
+let pref = if settings.system_title_bar { TitleBarPref::System } else { TitleBarPref::Integrated };
+// Backend::from_env() is what winit opens; an app that forces XWayland passes Backend::X11.
+let decision = decide_for(Prefix::new("PHOTOCRAFT"), Backend::from_env(), pref);
+let viewport = apply(egui::ViewportBuilder::default().with_title("PhotoCraft"), &decision.frame);
 
-// If the app draws the frame, lay the header-bar buttons out by the desktop's order:
-if let Some(layout) = choice.frame().layout() {
-    // layout.left / layout.right -> draw Minimize/Maximize/Close there
+if let Frame::Integrated(captions) = &decision.frame {
+    // captions.family picks the button look; captions.layout.left / .right place the buttons.
 }
 ```
 
-An app that offers a "System title bar" preference checks `choice.overridden()` and its own setting,
-and can override the probe by setting the frame to `Frame::Desktop`.
+This crate decides *whether* and *where*; drawing the buttons stays in each app.
+
+## Older API
+
+`Choice::probe`, the root `Frame::{Desktop, App}` and `decorate` follow the earlier two-way rule
+(the desktop draws the frame, except on Wayland without server-side decorations). They still work;
+`policy` supersedes them.
 
 The `egui` feature adds [`decorate`], the [`title_bar`](#the-apps-own-title-bar) module and
 `Viewports`. Without it, the crate is pure decision and bookkeeping logic with no egui dependency.
diff --git a/crates/window/src/desktop.rs b/crates/window/src/desktop.rs
new file mode 100644
--- /dev/null
+++ b/crates/window/src/desktop.rs
@@ -0,0 +1,130 @@
+//! Which desktop and backend the window opens on, read from the environment and, on Wayland, the
+//! compositor's registry.
+
+/// The windowing backend the app opens its window on. An app that forces XWayland on a Wayland
+/// session passes [`Backend::X11`].
+#[derive(Clone, Copy, Debug, PartialEq, Eq)]
+pub enum Backend {
+    Wayland,
+    X11,
+}
+
+/// The kind of desktop, as far as the frame decision cares.
+#[derive(Clone, Copy, Debug, PartialEq, Eq)]
+pub enum DesktopKind {
+    Gnome,
+    Kde,
+    /// A tiling compositor or window manager (Sway, Hyprland, i3, niri, river, ...).
+    Tiling,
+    /// Any other desktop: COSMIC, Xfce, Cinnamon, MATE, an unknown one. Budgie counts as GNOME.
+    Other,
+}
+
+/// What the running desktop is and offers.
+#[derive(Clone, Debug, PartialEq, Eq)]
+pub struct Desktop {
+    pub kind: DesktopKind,
+    /// Whether the compositor offers server-side decorations (`zxdg_decoration_manager_v1`).
+    /// `None` when not probed (X11, or the probe failed).
+    pub ssd: Option<bool>,
+    /// The GTK theme name, when `GTK_THEME` or GTK's `settings.ini` names one.
+    pub gtk_theme: Option<String>,
+    /// The icon theme: KDE's (`kdeglobals`) or GTK's setting, else `breeze` on KDE and `Adwaita`
+    /// elsewhere. `None` off Linux and the BSDs.
+    pub icon_theme: Option<String>,
+}
+
+impl Backend {
+    /// The backend winit opens by default: Wayland when `WAYLAND_DISPLAY` or `WAYLAND_SOCKET` is
+    /// set, else X11. An app that forces X11 (XWayland) passes [`Backend::X11`] instead.
+    pub fn from_env() -> Backend {
+        let set = |k| std::env::var_os(k).is_some_and(|v| !v.is_empty());
+        if set("WAYLAND_DISPLAY") || set("WAYLAND_SOCKET") { Backend::Wayland } else { Backend::X11 }
+    }
+}
+
+impl Desktop {
+    /// Read the desktop. Reads environment variables and small config files; the Wayland registry
+    /// is probed (500 ms at most) only when `backend` is [`Backend::Wayland`].
+    pub fn detect(backend: Backend) -> Desktop {
+        #[cfg(any(target_os = "linux", target_os = "freebsd", target_os = "openbsd", target_os = "netbsd"))]
+        {
+            let var = |k: &str| std::env::var(k).ok();
+            let ssd = if backend == Backend::Wayland { crate::platform::wayland_offers_ssd() } else { None };
+            let kind = kind_from_env(&var);
+            Desktop { kind, ssd, gtk_theme: crate::layout::read_gtk_theme(&var), icon_theme: Some(crate::layout::read_icon_theme(kind, &var)) }
+        }
+        #[cfg(not(any(target_os = "linux", target_os = "freebsd", target_os = "openbsd", target_os = "netbsd")))]
+        {
+            let _ = backend;
+            Desktop { kind: DesktopKind::Other, ssd: None, gtk_theme: None, icon_theme: None }
+        }
+    }
+}
+
+#[cfg(any(target_os = "linux", target_os = "freebsd", target_os = "openbsd", target_os = "netbsd", test))]
+/// Compositors and window managers that tile, matched against the `XDG_CURRENT_DESKTOP` entries.
+const TILING: &[&str] = &["sway", "hyprland", "i3", "niri", "river", "bspwm", "awesome", "dwl", "qtile", "herbstluftwm", "leftwm", "xmonad", "dwm"];
+
+#[cfg(any(target_os = "linux", target_os = "freebsd", target_os = "openbsd", target_os = "netbsd", test))]
+/// The desktop kind from the first of its variables that is set (`XDG_CURRENT_DESKTOP`,
+/// `XDG_SESSION_DESKTOP`, `DESKTOP_SESSION`). `ubuntu:GNOME` and `Budgie:GNOME` count as GNOME.
+pub(crate) fn kind_from_env(var: &dyn Fn(&str) -> Option<String>) -> DesktopKind {
+    // A compositor's own socket variable names it even when the desktop variables are missing.
+    if ["SWAYSOCK", "I3SOCK", "HYPRLAND_INSTANCE_SIGNATURE", "NIRI_SOCKET"].iter().any(|k| var(k).is_some_and(|v| !v.is_empty())) {
+        return DesktopKind::Tiling;
+    }
+    for key in ["XDG_CURRENT_DESKTOP", "XDG_SESSION_DESKTOP", "DESKTOP_SESSION"] {
+        let Some(v) = var(key).map(|v| v.to_ascii_lowercase()).filter(|v| !v.trim().is_empty()) else { continue };
+        if v.split(':').map(str::trim).any(|entry| TILING.contains(&entry)) {
+            return DesktopKind::Tiling;
+        }
+        return if v.contains("gnome") {
+            DesktopKind::Gnome
+        } else if v.contains("kde") || v.contains("plasma") {
+            DesktopKind::Kde
+        } else {
+            DesktopKind::Other
+        };
+    }
+    if var("KDE_FULL_SESSION").is_some() {
+        DesktopKind::Kde
+    } else if var("GNOME_DESKTOP_SESSION_ID").is_some() {
+        DesktopKind::Gnome
+    } else {
+        DesktopKind::Other
+    }
+}
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+
+    fn env(pairs: &[(&str, &str)]) -> impl Fn(&str) -> Option<String> + use<> {
+        let pairs: Vec<(String, String)> = pairs.iter().map(|(k, v)| (k.to_string(), v.to_string())).collect();
+        move |k| pairs.iter().find(|(key, _)| key == k).map(|(_, v)| v.clone())
+    }
+
+    #[test]
+    fn the_desktop_comes_from_the_first_variable_set() {
+        assert_eq!(kind_from_env(&env(&[("XDG_CURRENT_DESKTOP", "ubuntu:GNOME")])), DesktopKind::Gnome);
+        assert_eq!(kind_from_env(&env(&[("XDG_CURRENT_DESKTOP", "KDE")])), DesktopKind::Kde);
+        assert_eq!(kind_from_env(&env(&[("XDG_CURRENT_DESKTOP", "X-Cinnamon"), ("KDE_FULL_SESSION", "true")])), DesktopKind::Other);
+        assert_eq!(kind_from_env(&env(&[("XDG_CURRENT_DESKTOP", " "), ("DESKTOP_SESSION", "plasma")])), DesktopKind::Kde);
+        assert_eq!(kind_from_env(&env(&[("KDE_FULL_SESSION", "true")])), DesktopKind::Kde);
+        assert_eq!(kind_from_env(&env(&[("GNOME_DESKTOP_SESSION_ID", "x")])), DesktopKind::Gnome);
+        assert_eq!(kind_from_env(&env(&[])), DesktopKind::Other);
+    }
+
+    #[test]
+    fn tiling_compositors_are_recognised() {
+        for name in ["sway", "Hyprland", "i3", "niri", "river", "bspwm"] {
+            assert_eq!(kind_from_env(&env(&[("XDG_CURRENT_DESKTOP", name)])), DesktopKind::Tiling, "{name}");
+        }
+        assert_eq!(kind_from_env(&env(&[("XDG_CURRENT_DESKTOP", "i3:GNOME-Flashback")])), DesktopKind::Tiling);
+        assert_eq!(kind_from_env(&env(&[("XDG_CURRENT_DESKTOP", "COSMIC")])), DesktopKind::Other);
+        assert_eq!(kind_from_env(&env(&[("SWAYSOCK", "/run/sway.sock")])), DesktopKind::Tiling);
+        assert_eq!(kind_from_env(&env(&[("NIRI_SOCKET", "/run/niri")])), DesktopKind::Tiling);
+        assert_eq!(kind_from_env(&env(&[("XDG_CURRENT_DESKTOP", "Budgie:GNOME")])), DesktopKind::Gnome);
+    }
+}
diff --git a/crates/window/src/layout.rs b/crates/window/src/layout.rs
new file mode 100644
--- /dev/null
+++ b/crates/window/src/layout.rs
@@ -0,0 +1,234 @@
+//! Where the desktop puts the window buttons, and which look they get. The parsers are pure
+//! functions over strings; the file reads at the bottom are thin.
+
+use crate::{Layout, policy::CaptionFamily};
+
+use crate::desktop::DesktopKind;
+
+#[cfg(any(target_os = "linux", target_os = "freebsd", target_os = "openbsd", target_os = "netbsd", test))]
+/// The value of `key` in `[section]` of an INI-style file (GTK's `settings.ini`, KDE's `kwinrc`).
+pub(crate) fn ini_value(text: &str, section: &str, key: &str) -> Option<String> {
+    let mut inside = false;
+    for line in text.lines().map(str::trim) {
+        if let Some(name) = line.strip_prefix('[').and_then(|l| l.strip_suffix(']')) {
+            inside = name == section;
+        } else if inside
+            && let Some((k, v)) = line.split_once('=')
+            && k.trim() == key
+        {
+            return Some(v.trim().to_string());
+        }
+    }
+    None
+}
+
+#[cfg(any(target_os = "linux", target_os = "freebsd", target_os = "openbsd", target_os = "netbsd", test))]
+/// KWin's button order (`[org.kde.kdecoration2]` `ButtonsOnLeft` / `ButtonsOnRight`, one letter a
+/// button: `I` minimize, `A` maximize, `X` close) as a GTK layout string. Missing keys keep KWin's
+/// defaults: the window menu on the left, minimize, maximize and close on the right.
+pub(crate) fn kwin_layout(kwinrc: &str) -> String {
+    let side = |key: &str, default: &str| {
+        let letters = ini_value(kwinrc, "org.kde.kdecoration2", key).unwrap_or_else(|| default.to_string());
+        letters
+            .chars()
+            .filter_map(|c| match c {
+                'I' => Some("minimize"),
+                'A' => Some("maximize"),
+                'X' => Some("close"),
+                _ => None,
+            })
+            .collect::<Vec<_>>()
+            .join(",")
+    };
+    format!("{}:{}", side("ButtonsOnLeft", "MS"), side("ButtonsOnRight", "HIAX"))
+}
+
+#[cfg(any(target_os = "linux", target_os = "freebsd", target_os = "openbsd", target_os = "netbsd", test))]
+/// The `gtk-decoration-layout` and `gtk-theme-name` of a GTK 3 `settings.ini`.
+pub(crate) fn gtk_settings(settings_ini: &str) -> (Option<String>, Option<String>) {
+    (ini_value(settings_ini, "Settings", "gtk-decoration-layout"), ini_value(settings_ini, "Settings", "gtk-theme-name"))
+}
+
+/// The caption look. GNOME's apps are libadwaita's whatever the GTK theme, and KWin draws Breeze;
+/// elsewhere the GTK theme says (Adwaita, adw-gtk3, Yaru, Breeze), and any other theme gets the
+/// plain buttons.
+pub(crate) fn family(kind: DesktopKind, gtk_theme: Option<&str>) -> CaptionFamily {
+    match kind {
+        DesktopKind::Gnome => CaptionFamily::Adwaita,
+        DesktopKind::Kde => CaptionFamily::Breeze,
+        DesktopKind::Tiling | DesktopKind::Other => family_from_gtk_theme(gtk_theme),
+    }
+}
+
+/// The family a GTK theme name stands for, else [`CaptionFamily::Generic`].
+pub(crate) fn family_from_gtk_theme(gtk_theme: Option<&str>) -> CaptionFamily {
+    let theme = gtk_theme.unwrap_or_default().trim().to_ascii_lowercase();
+    if ["adwaita", "adw-gtk3", "yaru"].iter().any(|p| theme.starts_with(p)) {
+        CaptionFamily::Adwaita
+    } else if theme.starts_with("breeze") {
+        CaptionFamily::Breeze
+    } else {
+        CaptionFamily::Generic
+    }
+}
+
+/// What a desktop that names no layout gets: GNOME's default shows Close only, GTK's all three.
+pub(crate) fn default_layout(kind: DesktopKind) -> Layout {
+    if kind == DesktopKind::Gnome { Layout::parse("appmenu:close") } else { Layout::default() }
+}
+
+/// A parsed layout, or the default when the text names no usable button.
+#[cfg(any(target_os = "linux", target_os = "freebsd", target_os = "openbsd", target_os = "netbsd", test))]
+fn parse_or_default(text: Option<&str>, kind: DesktopKind) -> Layout {
+    text.map(Layout::parse).filter(Layout::is_usable).unwrap_or_else(|| default_layout(kind))
+}
+
+/// The layout `kind` uses, read from the desktop's files (`kwinrc` on KDE, GTK's `settings.ini`
+/// elsewhere). GNOME's comes from `gsettings`, then GTK's `settings.ini`, then Close only.
+#[cfg(any(target_os = "linux", target_os = "freebsd", target_os = "openbsd", target_os = "netbsd"))]
+pub(crate) fn read_layout(kind: DesktopKind, var: &dyn Fn(&str) -> Option<String>) -> Layout {
+    match kind {
+        DesktopKind::Gnome => {
+            let from_gsettings = crate::platform::gsettings_button_layout().map(|v| Layout::parse(&v)).filter(Layout::is_usable);
+            from_gsettings.unwrap_or_else(|| {
+                let ini = read_config(var, "gtk-3.0/settings.ini").unwrap_or_default();
+                parse_or_default(gtk_settings(&ini).0.as_deref(), kind)
+            })
+        }
+        DesktopKind::Kde => {
+            let rc = read_config(var, "kwinrc").unwrap_or_default();
+            parse_or_default(Some(&kwin_layout(&rc)), kind)
+        }
+        DesktopKind::Tiling | DesktopKind::Other => {
+            let ini = read_config(var, "gtk-3.0/settings.ini").unwrap_or_default();
+            parse_or_default(gtk_settings(&ini).0.as_deref(), kind)
+        }
+    }
+}
+
+/// The GTK theme name from GTK 3's `settings.ini`.
+#[cfg(any(target_os = "linux", target_os = "freebsd", target_os = "openbsd", target_os = "netbsd"))]
+pub(crate) fn read_gtk_theme(var: &dyn Fn(&str) -> Option<String>) -> Option<String> {
+    let from_env = var("GTK_THEME").map(|t| t.split(':').next().unwrap_or_default().to_string()).filter(|t| !t.is_empty());
+    from_env.or_else(|| read_config(var, "gtk-3.0/settings.ini").and_then(|ini| gtk_settings(&ini).1))
+}
+
+#[cfg(any(target_os = "linux", target_os = "freebsd", target_os = "openbsd", target_os = "netbsd", test))]
+/// The icon theme name for `kind`'s own setting (`kdeglobals` `[Icons] Theme` on KDE, GTK's
+/// `gtk-icon-theme-name` elsewhere), else the desktop's default theme.
+pub(crate) fn icon_theme(kind: DesktopKind, kdeglobals: &str, settings_ini: &str) -> String {
+    let found =
+        if kind == DesktopKind::Kde { ini_value(kdeglobals, "Icons", "Theme") } else { ini_value(settings_ini, "Settings", "gtk-icon-theme-name") };
+    found.filter(|t| !t.is_empty()).unwrap_or_else(|| if kind == DesktopKind::Kde { "breeze" } else { "Adwaita" }.to_string())
+}
+
+/// [`icon_theme`] from the user's config files.
+#[cfg(any(target_os = "linux", target_os = "freebsd", target_os = "openbsd", target_os = "netbsd"))]
+pub(crate) fn read_icon_theme(kind: DesktopKind, var: &dyn Fn(&str) -> Option<String>) -> String {
+    let kdeglobals = if kind == DesktopKind::Kde { read_config(var, "kdeglobals").unwrap_or_default() } else { String::new() };
+    let ini = read_config(var, "gtk-3.0/settings.ini").unwrap_or_default();
+    icon_theme(kind, &kdeglobals, &ini)
+}
+
+/// A small file under the user's config directory (`XDG_CONFIG_HOME`, else `~/.config`).
+#[cfg(any(target_os = "linux", target_os = "freebsd", target_os = "openbsd", target_os = "netbsd"))]
+fn read_config(var: &dyn Fn(&str) -> Option<String>, relative: &str) -> Option<String> {
+    use std::io::Read;
+    let config = var("XDG_CONFIG_HOME")
+        .filter(|d| std::path::Path::new(d).is_absolute())
+        .map(std::path::PathBuf::from)
+        .or_else(|| var("HOME").map(|h| std::path::Path::new(&h).join(".config")))?;
+    let mut bytes = Vec::new();
+    // Config files are small; the cap keeps a stray huge file from stalling startup.
+    std::fs::File::open(config.join(relative)).ok()?.take(1 << 20).read_to_end(&mut bytes).ok()?;
+    Some(String::from_utf8_lossy(&bytes).into_owned())
+}
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+    use crate::Button::{Close, Maximize, Minimize};
+
+    #[test]
+    fn the_family_follows_the_desktop_then_the_gtk_theme() {
+        assert_eq!(family(DesktopKind::Gnome, Some("Arc")), CaptionFamily::Adwaita);
+        assert_eq!(family(DesktopKind::Kde, None), CaptionFamily::Breeze);
+        assert_eq!(family(DesktopKind::Other, Some("adw-gtk3-dark")), CaptionFamily::Adwaita);
+        assert_eq!(family(DesktopKind::Other, Some("Yaru-blue")), CaptionFamily::Adwaita);
+        assert_eq!(family(DesktopKind::Other, Some("Breeze-Dark")), CaptionFamily::Breeze);
+        assert_eq!(family(DesktopKind::Other, Some("Mint-Y")), CaptionFamily::Generic);
+        assert_eq!(family(DesktopKind::Other, None), CaptionFamily::Generic);
+    }
+
+    #[test]
+    fn kwin_button_letters_become_a_gtk_layout() {
+        assert_eq!(kwin_layout(""), ":minimize,maximize,close", "KWin's defaults");
+        let rc = "[Desktops]\nButtonsOnLeft=X\n[org.kde.kdecoration2]\nButtonsOnLeft=XIA\nButtonsOnRight=\nlibrary=org.kde.breeze\n";
+        assert_eq!(kwin_layout(rc), "close,minimize,maximize:");
+        assert_eq!(ini_value(rc, "org.kde.kdecoration2", "library").as_deref(), Some("org.kde.breeze"));
+        assert_eq!(ini_value(rc, "nope", "library"), None);
+        // Garbage is just no buttons on that side, never a panic.
+        assert_eq!(kwin_layout("[org.kde.kdecoration2]\nButtonsOnRight=\u{ff}\u{20ac}=X\n"), ":close");
+    }
+
+    #[test]
+    fn the_icon_theme_comes_from_the_setting_or_the_desktop_default() {
+        let globals = "[Icons]\nTheme=breeze-dark\n";
+        let ini = "[Settings]\ngtk-icon-theme-name=Papirus\n";
+        assert_eq!(icon_theme(DesktopKind::Kde, globals, ini), "breeze-dark");
+        assert_eq!(icon_theme(DesktopKind::Other, globals, ini), "Papirus");
+        assert_eq!(icon_theme(DesktopKind::Gnome, "", ""), "Adwaita");
+        assert_eq!(icon_theme(DesktopKind::Kde, "", ini), "breeze");
+    }
+
+    #[test]
+    fn kwin_order_parses_into_a_layout() {
+        let rc = "[org.kde.kdecoration2]\nButtonsOnLeft=XIA\nButtonsOnRight=\n";
+        let layout = Layout::parse(&kwin_layout(rc));
+        assert_eq!(layout.left, vec![Close, Minimize, Maximize]);
+        assert!(layout.right.is_empty());
+    }
+
+    #[test]
+    fn gtk_settings_give_the_layout_and_theme() {
+        let ini = "[Settings]\ngtk-theme-name=Yaru\ngtk-decoration-layout=close,maximize:\n";
+        assert_eq!(gtk_settings(ini), (Some("close,maximize:".into()), Some("Yaru".into())));
+        assert_eq!(gtk_settings(""), (None, None));
+        let layout = Layout::parse("close,maximize:");
+        assert_eq!((layout.left, layout.right), (vec![Close, Maximize], vec![]));
+    }
+
+    #[test]
+    fn missing_or_unusable_layouts_fall_back_to_the_desktops_default() {
+        assert_eq!(parse_or_default(None, DesktopKind::Gnome).right, vec![Close], "GNOME's default shows Close only");
+        assert_eq!(parse_or_default(Some("appmenu:spacer"), DesktopKind::Other).right, vec![Minimize, Maximize, Close]);
+        assert_eq!(parse_or_default(Some("close:"), DesktopKind::Other).left, vec![Close]);
+    }
+
+    #[cfg(any(target_os = "linux", target_os = "freebsd", target_os = "openbsd", target_os = "netbsd"))]
+    #[test]
+    fn settings_files_are_read_from_the_config_dir() {
+        let dir = std::env::temp_dir().join(format!("craft-window-layout-{}", std::process::id()));
+        std::fs::create_dir_all(dir.join("gtk-3.0")).unwrap_or_default();
+        std::fs::write(dir.join("gtk-3.0/settings.ini"), "[Settings]\ngtk-theme-name=Yaru\ngtk-decoration-layout=close,maximize:\n")
+            .unwrap_or_default();
+        std::fs::write(dir.join("kwinrc"), "[org.kde.kdecoration2]\nButtonsOnRight=AX\n").unwrap_or_default();
+        let path = dir.to_string_lossy().into_owned();
+        let var = move |k: &str| (k == "XDG_CONFIG_HOME").then(|| path.clone());
+        assert_eq!(read_gtk_theme(&var).as_deref(), Some("Yaru"));
+        std::fs::write(dir.join("kdeglobals"), "[Icons]\nTheme=breeze-dark\n").unwrap_or_default();
+        assert_eq!(read_icon_theme(DesktopKind::Kde, &var), "breeze-dark");
+        let path2 = dir.to_string_lossy().into_owned();
+        let with_env = move |k: &str| match k {
+            "XDG_CONFIG_HOME" => Some(path2.clone()),
+            "GTK_THEME" => Some("Breeze:dark".to_string()),
+            _ => None,
+        };
+        assert_eq!(read_gtk_theme(&with_env).as_deref(), Some("Breeze"), "GTK_THEME wins over settings.ini");
+        assert_eq!(read_layout(DesktopKind::Other, &var).left, vec![Close, Maximize]);
+        assert_eq!(read_layout(DesktopKind::Kde, &var).right, vec![Maximize, Close]);
+        // A relative XDG_CONFIG_HOME is ignored, as the XDG spec says; with no HOME nothing is found.
+        assert_eq!(read_gtk_theme(&|k: &str| (k == "XDG_CONFIG_HOME").then(|| "rel".to_string())), None);
+        let _ = std::fs::remove_dir_all(dir);
+    }
+}
diff --git a/crates/window/src/lib.rs b/crates/window/src/lib.rs
--- a/crates/window/src/lib.rs
+++ b/crates/window/src/lib.rs
@@ -10,7 +10,11 @@
 //! compositor — so on KDE, say, one app wore KWin's title bar while another wore its own. This
 //! crate makes the decision one place.
 //!
-//! The rule, by platform:
+//! This page describes the first-generation rule ([`Choice::probe`], [`Frame`], [`decorate`]),
+//! which stays for apps that have not moved. The current rule is in [`policy`]: the app's own bar
+//! is the title bar by default, and the user setting "Use system title bar" turns that off.
+//!
+//! The rule of the first-generation API, by platform:
 //!
 //! - **macOS and Windows:** the OS always draws the frame ([`Frame::Desktop`]). (An app that insets
 //!   its content under the traffic lights, or draws its own caption buttons on Windows, still asks
@@ -128,9 +132,12 @@
 //! }
 //! ```
 
+mod desktop;
+mod layout;
 mod list;
 mod menu;
 mod place;
+pub mod policy;
 #[cfg(feature = "egui")]
 pub mod title_bar;
 #[cfg(feature = "egui")]
@@ -168,7 +175,7 @@ impl Prefix {
     }
 }
 
-/// Who draws the window frame.
+/// Who draws the window frame. Superseded by [`policy::Frame`]; kept for apps that have not moved.
 #[derive(Clone, Debug, PartialEq, Eq)]
 pub enum Frame {
     /// The desktop draws it: the OS on macOS and Windows, the window manager on X11, the
@@ -196,7 +203,7 @@ impl Frame {
 }
 
 /// How the frame was decided, so an app can log it or offer a "System title bar" preference that
-/// knows the current state.
+/// knows the current state. Superseded by [`policy::Decision`] and [`policy::decide_for`].
 #[derive(Clone, Debug, PartialEq, Eq)]
 pub struct Choice {
     frame: Frame,
@@ -311,10 +318,15 @@ impl Layout {
         Self { left, right }
     }
 
+    /// The layout with the two sides swapped, for right-to-left UIs.
+    pub fn mirrored(&self) -> Self {
+        Self { left: self.right.clone(), right: self.left.clone() }
+    }
+
     /// Whether the layout has at least one button, so the window can be closed by mouse. A layout
     /// with none (a desktop that named only buttons this crate skips) falls back to the default.
     #[cfg(any(target_os = "linux", target_os = "freebsd", target_os = "openbsd", target_os = "netbsd", test))]
-    fn is_usable(&self) -> bool {
+    pub(crate) fn is_usable(&self) -> bool {
         !self.left.is_empty() || !self.right.is_empty()
     }
 }
@@ -345,7 +357,7 @@ fn app_draws_frame() -> bool {
     }
 }
 
-/// Apply a [`Frame`] to an [`egui::ViewportBuilder`]: decorations on for the desktop frame, off for
+/// Apply a [`Frame`] to an [`egui::ViewportBuilder`] (superseded by [`policy::apply`]): decorations on for the desktop frame, off for
 /// the app frame. Call before the window is created. The app still draws its header bar and, for
 /// [`Frame::App`], lays its window buttons out by [`Frame::layout`].
 #[cfg(feature = "egui")]
@@ -377,6 +389,12 @@ mod platform {
         bounded(registry_leaves_frame_to_app).flatten().unwrap_or(false)
     }
 
+    /// Whether the Wayland compositor offers server-side decorations: `None` when there is no
+    /// answer (no compositor, an inherited socket, a stalled registry), same deadline as above.
+    pub(super) fn wayland_offers_ssd() -> Option<bool> {
+        bounded(registry_leaves_frame_to_app).flatten().map(|leaves| !leaves)
+    }
+
     /// Run `f` on a short-lived worker thread with a 500 ms deadline, so a stalled dependency can't
     /// block window startup. `None` when the thread can't be spawned or `f` doesn't finish in time;
     /// otherwise `f`'s return value. For a child process, prefer [`gsettings_button_layout`], which
@@ -518,6 +536,12 @@ mod tests {
         assert!(layout.right.is_empty());
     }
 
+    #[test]
+    fn mirrored_swaps_the_sides() {
+        let layout = Layout::parse("close:minimize,maximize").mirrored();
+        assert_eq!((layout.left, layout.right), (vec![Button::Minimize, Button::Maximize], vec![Button::Close]));
+    }
+
     #[test]
     fn a_layout_naming_no_known_button_is_not_usable() {
         assert!(!Layout::parse("appmenu:spacer").is_usable());
diff --git a/crates/window/src/policy.rs b/crates/window/src/policy.rs
new file mode 100644
--- /dev/null
+++ b/crates/window/src/policy.rs
@@ -0,0 +1,345 @@
+//! The frame policy: who draws the title bar, decided from the OS, desktop, backend, the user's
+//! "Use system title bar" setting and the `<PREFIX>_TITLEBAR` override.
+//!
+//! The app's own bar is the title bar by default ([`Frame::Integrated`]); the setting turns that
+//! off ([`Frame::Native`]).
+//!
+//! | Platform, desktop, backend | Default | With "Use system title bar" |
+//! |---|---|---|
+//! | macOS | `Inset` | `Native` |
+//! | Windows | `Integrated(Windows)` | `Native` |
+//! | GNOME, Wayland or XWayland | `Integrated(Adwaita)`, `button-layout` order | `Native` |
+//! | KDE, Wayland or X11 | `Integrated(Breeze)`, KWin's order | `Native` |
+//! | COSMIC, Xfce, Cinnamon, MATE, other floating desktops | `Integrated(Generic)`, or Adwaita or Breeze when the GTK theme is one of those | `Native` |
+//! | Tiling compositors and window managers | `Native` | `Native` |
+//! | Wayland without server-side decorations, not GNOME | `Integrated(Generic)` | `Native` |
+//! | Web | `Native` (no frame code runs) | `Native` |
+//!
+//! ```ignore
+//! let d = craft_window::policy::decide_for(Prefix::default(), Backend::Wayland, TitleBarPref::Integrated);
+//! let viewport = craft_window::policy::apply(egui::ViewportBuilder::default(), &d.frame);
+//! ```
+
+pub use crate::desktop::{Backend, Desktop, DesktopKind};
+use crate::{Layout, Prefix, layout};
+
+/// Whether KDE, COSMIC, Xfce, Cinnamon, MATE and X11 window managers, which draw their own frame,
+/// get the app's bar by default. Set to `false` for [`Frame::Native`] on those desktops instead;
+/// nothing else changes.
+const INTEGRATED_ON_SSD_DESKTOPS: bool = true;
+
+/// The user setting "Use system title bar".
+#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
+pub enum TitleBarPref {
+    /// The app's bar is the title bar.
+    #[default]
+    Integrated,
+    /// The OS, window manager or compositor draws it.
+    System,
+}
+
+/// The operating system family. Linux stands for Linux and the BSDs.
+#[derive(Clone, Copy, Debug, PartialEq, Eq)]
+pub enum Os {
+    Mac,
+    Windows,
+    Linux,
+    Web,
+}
+
+impl Os {
+    /// The OS this build runs on.
+    pub const fn current() -> Os {
+        if cfg!(target_os = "macos") {
+            Os::Mac
+        } else if cfg!(target_os = "windows") {
+            Os::Windows
+        } else if cfg!(target_arch = "wasm32") {
+            Os::Web
+        } else {
+            Os::Linux
+        }
+    }
+}
+
+/// The look of the caption buttons.
+#[derive(Clone, Copy, Debug, PartialEq, Eq)]
+pub enum CaptionFamily {
+    Windows,
+    Adwaita,
+    Breeze,
+    Generic,
+}
+
+impl CaptionFamily {
+    /// A family by name (`windows`, `adwaita`, `breeze`, `generic`), case-insensitive.
+    pub fn parse(name: &str) -> Option<Self> {
+        match name.trim().to_ascii_lowercase().as_str() {
+            "windows" => Some(Self::Windows),
+            "adwaita" => Some(Self::Adwaita),
+            "breeze" => Some(Self::Breeze),
+            "generic" => Some(Self::Generic),
+            _ => None,
+        }
+    }
+}
+
+/// Caption buttons the app draws: their look and which end of the bar they sit at.
+#[derive(Clone, Debug, PartialEq, Eq)]
+pub struct Captions {
+    pub family: CaptionFamily,
+    pub layout: Layout,
+}
+
+/// Where macOS puts the traffic lights over the app's bar, in points.
+#[derive(Clone, Copy, Debug, PartialEq)]
+pub struct MacInset {
+    /// Width reserved at the leading edge for the lights.
+    pub leading: f32,
+    /// Distance from the window's top to the lights' vertical centre.
+    pub centre_from_top: f32,
+}
+
+impl Default for MacInset {
+    /// The standard lights' geometry.
+    fn default() -> Self {
+        Self { leading: 78.0, centre_from_top: 14.0 }
+    }
+}
+
+/// Who draws the title bar.
+#[derive(Clone, Debug, PartialEq)]
+pub enum Frame {
+    /// The OS, window manager or compositor draws it; the app's strip sits below it.
+    Native,
+    /// macOS: full-size content view and hidden title; the traffic lights sit over the app's bar.
+    Inset(MacInset),
+    /// Decorations are off; the app's bar is the title bar, with these caption buttons.
+    Integrated(Captions),
+}
+
+/// The decided frame and how it was decided.
+#[derive(Clone, Debug, PartialEq)]
+pub struct Decision {
+    pub frame: Frame,
+    /// `Some(true)` when `<PREFIX>_TITLEBAR` forced the app's bar, `Some(false)` when it forced the
+    /// system's, `None` when the platform and setting decided.
+    pub overridden: Option<bool>,
+    /// The desktop the decision was made for, so an app can hide the "Use system title bar" row
+    /// where it changes nothing (tiling compositors, web) without probing again.
+    pub desktop: Desktop,
+}
+
+/// The decision, pure. `env` is the value of `<PREFIX>_TITLEBAR` (`app`, `system` or `desktop`);
+/// it wins over `pref`. Caption layouts are each family's default; [`decide_for`] fills in the
+/// desktop's own.
+pub fn decide(os: Os, backend: Backend, desktop: &Desktop, pref: TitleBarPref, env: Option<&str>) -> Decision {
+    decide_with(os, backend, desktop, pref, env, &layout::default_layout)
+}
+
+/// `layout` supplies the caption layout for a desktop kind; it is called only when the frame comes
+/// out integrated.
+fn decide_with(
+    os: Os,
+    backend: Backend,
+    desktop: &Desktop,
+    pref: TitleBarPref,
+    env: Option<&str>,
+    layout: &dyn Fn(DesktopKind) -> Layout,
+) -> Decision {
+    let overridden = crate::parse_override(env);
+    let integrated = match (overridden, pref) {
+        (Some(app), _) => app,
+        (None, pref) => pref == TitleBarPref::Integrated,
+    };
+    let frame =
+        if integrated { integrated_frame(os, backend, desktop, overridden.is_some(), INTEGRATED_ON_SSD_DESKTOPS, layout) } else { Frame::Native };
+    Decision { frame, overridden, desktop: desktop.clone() }
+}
+
+/// The frame when the app's bar is wanted. `forced` is set when the override asked for it, which
+/// also applies on tiling compositors.
+/// `integrated_on_ssd` says whether desktops that draw their own frame get the app's bar.
+fn integrated_frame(
+    os: Os,
+    backend: Backend,
+    desktop: &Desktop,
+    forced: bool,
+    integrated_on_ssd: bool,
+    layout: &dyn Fn(DesktopKind) -> Layout,
+) -> Frame {
+    let captions = |family: CaptionFamily| {
+        let kind = if family == CaptionFamily::Windows { DesktopKind::Other } else { desktop.kind };
+        Frame::Integrated(Captions { family, layout: layout(kind) })
+    };
+    match os {
+        Os::Web => Frame::Native,
+        Os::Mac => Frame::Inset(MacInset::default()),
+        Os::Windows => captions(CaptionFamily::Windows),
+        Os::Linux => {
+            let no_ssd = backend == Backend::Wayland && desktop.ssd == Some(false);
+            match desktop.kind {
+                DesktopKind::Tiling if !forced => Frame::Native,
+                DesktopKind::Tiling => captions(layout::family_from_gtk_theme(desktop.gtk_theme.as_deref())),
+                DesktopKind::Gnome => captions(CaptionFamily::Adwaita),
+                // Open decision 1: the line below is the only place that picks the app's bar on
+                // desktops that draw their own frame. `false` gives `Native` there.
+                _ if !no_ssd && !integrated_on_ssd && !forced => Frame::Native,
+                DesktopKind::Kde => captions(CaptionFamily::Breeze),
+                DesktopKind::Other if no_ssd => captions(CaptionFamily::Generic),
+                DesktopKind::Other => captions(layout::family(DesktopKind::Other, desktop.gtk_theme.as_deref())),
+            }
+        }
+    }
+}
+
+/// Read the platform once (500 ms at most, as for [`crate::Choice::probe`]) and decide.
+/// `prefix` names the override variable `<PREFIX>_TITLEBAR`; when it is unset, `CRAFT_TITLEBAR`
+/// applies. `backend` is the one the app opens its window on, [`Backend::from_env`]
+/// unless the app forces X11.
+pub fn decide_for(prefix: Prefix, backend: Backend, pref: TitleBarPref) -> Decision {
+    let os = Os::current();
+    let env = std::env::var(prefix.var_name()).or_else(|_| std::env::var(Prefix::default().var_name())).ok();
+    let desktop =
+        if os == Os::Linux { Desktop::detect(backend) } else { Desktop { kind: DesktopKind::Other, ssd: None, gtk_theme: None, icon_theme: None } };
+    decide_with(os, backend, &desktop, pref, env.as_deref(), &|kind| desktop_layout(os, kind))
+}
+
+/// The desktop's window-button layout, where it names one.
+fn desktop_layout(os: Os, kind: DesktopKind) -> Layout {
+    #[cfg(any(target_os = "linux", target_os = "freebsd", target_os = "openbsd", target_os = "netbsd"))]
+    if os == Os::Linux {
+        return layout::read_layout(kind, &|k: &str| std::env::var(k).ok());
+    }
+    let _ = os;
+    layout::default_layout(kind)
+}
+
+/// Apply a [`Frame`] to an [`egui::ViewportBuilder`] before the window is created: decorations off
+/// for [`Frame::Integrated`], a full-size content view with hidden title and transparent title bar
+/// for [`Frame::Inset`], decorations on for [`Frame::Native`].
+#[cfg(feature = "egui")]
+pub fn apply(builder: egui::ViewportBuilder, frame: &Frame) -> egui::ViewportBuilder {
+    match frame {
+        Frame::Native => builder.with_decorations(true),
+        Frame::Inset(_) => builder.with_decorations(true).with_fullsize_content_view(true).with_title_shown(false).with_titlebar_shown(false),
+        Frame::Integrated(_) => builder.with_decorations(false),
+    }
+}
+
+impl From<&crate::Frame> for Frame {
+    /// The old two-way frame as a new one: `Desktop` is `Native`, `App(layout)` is integrated
+    /// with generic captions.
+    fn from(old: &crate::Frame) -> Self {
+        match old {
+            crate::Frame::Desktop => Frame::Native,
+            crate::Frame::App(layout) => Frame::Integrated(Captions { family: CaptionFamily::Generic, layout: layout.clone() }),
+        }
+    }
+}
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+    use CaptionFamily::{Adwaita, Breeze, Generic, Windows};
+    use TitleBarPref::{Integrated as Int, System};
+
+    fn desk(kind: DesktopKind, ssd: Option<bool>, theme: Option<&str>) -> Desktop {
+        Desktop { kind, ssd, gtk_theme: theme.map(str::to_string), icon_theme: None }
+    }
+
+    fn family_of(d: &Decision) -> Option<CaptionFamily> {
+        match &d.frame {
+            Frame::Integrated(c) => Some(c.family),
+            _ => None,
+        }
+    }
+
+    #[test]
+    fn every_row_of_the_table() {
+        use DesktopKind::{Gnome, Kde, Other, Tiling};
+        // (os, backend, desktop, expected family; None = Native)
+        let rows: Vec<(Os, Backend, Desktop, Option<CaptionFamily>)> = vec![
+            (Os::Windows, Backend::Wayland, desk(Other, None, None), Some(Windows)),
+            (Os::Linux, Backend::Wayland, desk(Gnome, Some(false), None), Some(Adwaita)),
+            (Os::Linux, Backend::X11, desk(Gnome, None, Some("Arc")), Some(Adwaita)),
+            (Os::Linux, Backend::Wayland, desk(Kde, Some(true), None), Some(Breeze)),
+            (Os::Linux, Backend::X11, desk(Kde, None, None), Some(Breeze)),
+            (Os::Linux, Backend::Wayland, desk(Other, Some(true), Some("Adwaita")), Some(Adwaita)),
+            (Os::Linux, Backend::Wayland, desk(Other, Some(true), Some("Breeze-Dark")), Some(Breeze)),
+            (Os::Linux, Backend::X11, desk(Other, None, Some("Mint-Y")), Some(Generic)),
+            (Os::Linux, Backend::Wayland, desk(Other, Some(true), None), Some(Generic)),
+            (Os::Linux, Backend::Wayland, desk(Tiling, Some(true), None), None),
+            (Os::Linux, Backend::X11, desk(Tiling, None, None), None),
+            (Os::Linux, Backend::Wayland, desk(Other, Some(false), Some("Adwaita")), Some(Generic)),
+            (Os::Web, Backend::X11, desk(Other, None, None), None),
+        ];
+        for (os, backend, d, want) in rows {
+            let got = decide(os, backend, &d, Int, None);
+            assert_eq!(family_of(&got), want, "{os:?} {backend:?} {d:?}");
+            assert_eq!(got.overridden, None);
+            if want.is_none() {
+                assert_eq!(got.frame, Frame::Native);
+            }
+            let system = decide(os, backend, &d, System, None);
+            assert_eq!(system.frame, Frame::Native, "system bar: {os:?} {d:?}");
+        }
+    }
+
+    #[test]
+    fn native_on_ssd_desktops_when_the_flag_is_off() {
+        use DesktopKind::{Gnome, Kde, Other};
+        let frame = |kind, backend, ssd, forced| integrated_frame(Os::Linux, backend, &desk(kind, ssd, None), forced, false, &layout::default_layout);
+        assert_eq!(frame(Kde, Backend::Wayland, Some(true), false), Frame::Native);
+        assert_eq!(frame(Other, Backend::X11, None, false), Frame::Native);
+        assert!(matches!(frame(Kde, Backend::Wayland, Some(true), true), Frame::Integrated(_)), "the override still forces the bar");
+        assert!(matches!(frame(Gnome, Backend::Wayland, Some(false), false), Frame::Integrated(_)));
+        assert!(matches!(frame(Other, Backend::Wayland, Some(false), false), Frame::Integrated(_)), "no SSD: nothing else draws a frame");
+    }
+
+    #[test]
+    fn the_decision_carries_the_desktop() {
+        let d = desk(DesktopKind::Tiling, Some(true), None);
+        assert_eq!(decide(Os::Linux, Backend::Wayland, &d, Int, None).desktop, d);
+    }
+
+    #[test]
+    fn macos_gets_the_inset() {
+        let d = decide(Os::Mac, Backend::X11, &desk(DesktopKind::Other, None, None), Int, None);
+        assert_eq!(d.frame, Frame::Inset(MacInset::default()));
+        assert_eq!(decide(Os::Mac, Backend::X11, &desk(DesktopKind::Other, None, None), System, None).frame, Frame::Native);
+    }
+
+    #[test]
+    fn the_override_wins_over_the_setting_both_ways() {
+        let kde = desk(DesktopKind::Kde, Some(true), None);
+        let system = decide(Os::Linux, Backend::Wayland, &kde, Int, Some("system"));
+        assert_eq!((system.frame, system.overridden), (Frame::Native, Some(false)));
+        let desktop = decide(Os::Linux, Backend::Wayland, &kde, Int, Some("desktop"));
+        assert_eq!((desktop.frame, desktop.overridden), (Frame::Native, Some(false)));
+        let app = decide(Os::Linux, Backend::Wayland, &kde, System, Some("app"));
+        assert_eq!(app.overridden, Some(true));
+        assert_eq!(family_of(&app), Some(Breeze));
+        // `app` also forces the bar on a tiling compositor, which would otherwise be native.
+        let tiling = desk(DesktopKind::Tiling, Some(true), None);
+        assert_eq!(family_of(&decide(Os::Linux, Backend::Wayland, &tiling, Int, Some("app"))), Some(Generic));
+        // An unknown value is no override.
+        let unknown = decide(Os::Linux, Backend::Wayland, &kde, System, Some("maybe"));
+        assert_eq!((unknown.frame, unknown.overridden), (Frame::Native, None));
+    }
+
+    #[test]
+    fn default_layouts_follow_the_family() {
+        let gnome = decide(Os::Linux, Backend::Wayland, &desk(DesktopKind::Gnome, Some(false), None), Int, None);
+        assert!(matches!(gnome.frame, Frame::Integrated(c) if c.layout.right == vec![crate::Button::Close]));
+        let win = decide(Os::Windows, Backend::X11, &desk(DesktopKind::Other, None, None), Int, None);
+        assert!(matches!(win.frame, Frame::Integrated(c) if c.layout == Layout::default()));
+    }
+
+    #[test]
+    fn the_old_frame_converts() {
+        assert_eq!(Frame::from(&crate::Frame::Desktop), Frame::Native);
+        assert!(matches!(Frame::from(&crate::Frame::App(Layout::default())), Frame::Integrated(_)));
+        assert_eq!(CaptionFamily::parse(" Breeze "), Some(Breeze));
+    }
+}
