diff --git a/Cargo.lock b/Cargo.lock
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -287,10 +287,13 @@ dependencies = [
  "objc2 0.6.5",
  "objc2-app-kit 0.3.2",
  "objc2-foundation 0.3.2",
+ "raw-window-handle",
  "rfd",
  "ron",
  "serde",
  "tray-icon",
+ "wayland-client",
+ "winit",
  "winreg",
  "winresource",
  "zbus",
@@ -4180,6 +4183,17 @@ version = "1.2.0"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49"
 
+[[package]]
+name = "sctk-adwaita"
+version = "0.10.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b6277f0217056f77f1d8f49f2950ac6c278c0d607c45f5ee99328d792ede24ec"
+dependencies = [
+ "log",
+ "smithay-client-toolkit 0.19.2",
+ "tiny-skia",
+]
+
 [[package]]
 name = "security-framework"
 version = "3.7.0"
@@ -5877,6 +5891,7 @@ dependencies = [
  "raw-window-handle",
  "redox_syscall 0.4.1",
  "rustix 0.38.44",
+ "sctk-adwaita",
  "smithay-client-toolkit 0.19.2",
  "smol_str",
  "tracing",
diff --git a/NOTICE b/NOTICE
--- a/NOTICE
+++ b/NOTICE
@@ -25,6 +25,37 @@ Craft App Suite app icons (assets/app-icons/*.png) — from each app's repositor
 (github.com/storytold/<app>), MIT OR Apache-2.0, Copyright (c) 2026 ArtCraft Team and the <App>
 contributors.
 
+Linux Wayland window decorations
+-------------------------------
+sctk-adwaita 0.10.1 (https://github.com/PolyMeilex/sctk-adwaita,
+commit d5f343c45c04d4a026db57f76760819b7325db00) is used by winit on Linux
+for vector window controls. Its title-font features are disabled: no Cantarell
+font is embedded by this dependency and no font file is added to this repo.
+
+sctk-adwaita's MIT licence:
+
+MIT License
+
+Copyright (c) 2022 Bartłomiej Maryńczak
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.
+
 
 egui icon font subset (dependency asset)
 ----------------------------------------
diff --git a/apps/artcraft-launcher/Cargo.toml b/apps/artcraft-launcher/Cargo.toml
--- a/apps/artcraft-launcher/Cargo.toml
+++ b/apps/artcraft-launcher/Cargo.toml
@@ -33,6 +33,11 @@ serde = { workspace = true }
 craft-quit = { git = "https://github.com/storytold/craft-libs", version = "0.1.0", rev = "25da1314d443171b4adeaad2cd9454b273f35c6f", features = ["egui"] }
 
 [target.'cfg(target_os = "linux")'.dependencies]
+raw-window-handle = "0.6"
+wayland-client = "0.31"
+# GNOME/Wayland window controls without an embedded title font.
+winit = { version = "0.30", default-features = false, features = ["wayland-csd-adwaita-notitle"] }
+
 zbus = "5"
 
 # The tray (menu bar) icon. On Linux and the BSDs the StatusNotifierItem backend talks D-Bus
diff --git a/apps/artcraft-launcher/src/linux_window.rs b/apps/artcraft-launcher/src/linux_window.rs
new file mode 100644
--- /dev/null
+++ b/apps/artcraft-launcher/src/linux_window.rs
@@ -0,0 +1,112 @@
+//! Request winit's frame only on Wayland compositors without xdg-decoration.
+//! Desktop/session names cannot identify the backend of an individual window.
+
+use std::os::unix::net::UnixStream;
+use std::path::PathBuf;
+use std::sync::mpsc;
+use std::time::Duration;
+
+use raw_window_handle::{HasDisplayHandle, RawDisplayHandle};
+use wayland_client::globals::{GlobalListContents, registry_queue_init};
+use wayland_client::protocol::wl_registry;
+use wayland_client::{Connection, Dispatch, QueueHandle};
+
+/// Capture the socket path before winit consumes an inherited WAYLAND_SOCKET.
+/// Never consume, duplicate or query that inherited connection: there may be no
+/// independently addressable display, or WAYLAND_DISPLAY may name another server.
+pub fn display_socket() -> Option<PathBuf> {
+    if std::env::var_os("WAYLAND_SOCKET").is_some_and(|s| !s.is_empty()) {
+        return None;
+    }
+    let display = std::env::var_os("WAYLAND_DISPLAY").filter(|s| !s.is_empty())?;
+    let path = PathBuf::from(display);
+    if path.is_absolute() {
+        Some(path)
+    } else {
+        std::env::var_os("XDG_RUNTIME_DIR").filter(|s| !s.is_empty()).map(|dir| PathBuf::from(dir).join(path))
+    }
+}
+
+pub fn needs_native_frame(cc: &eframe::CreationContext<'_>, socket: Option<PathBuf>) -> bool {
+    let wayland = cc.display_handle().is_ok_and(|h| matches!(h.as_raw(), RawDisplayHandle::Wayland(_)));
+    if !wayland {
+        log::debug!("window controls: non-Wayland window, retaining custom controls");
+        return false;
+    }
+    let server_decorations = socket.and_then(probe);
+    log::debug!("window controls: Wayland xdg-decoration available: {server_decorations:?}");
+    native_frame(wayland, server_decorations)
+}
+
+fn native_frame(wayland: bool, server_decorations: Option<bool>) -> bool {
+    wayland && server_decorations == Some(false)
+}
+
+fn probe(socket: PathBuf) -> Option<bool> {
+    // The compositor must not be able to block startup on a registry roundtrip.
+    // On failure/timeout retain the app's own working title bar and buttons.
+    let (send, receive) = mpsc::sync_channel(1);
+    std::thread::Builder::new()
+        .name("wayland-decorations".into())
+        .spawn(move || {
+            let _ = send.send(registry_has_decorations(socket));
+        })
+        .ok()?;
+    receive.recv_timeout(Duration::from_millis(500)).ok().flatten()
+}
+
+fn registry_has_decorations(socket: PathBuf) -> Option<bool> {
+    // A fresh connection leaves winit's event queue and inherited descriptors alone.
+    let stream = UnixStream::connect(socket).ok()?;
+    let connection = Connection::from_socket(stream).ok()?;
+    let (globals, _queue) = registry_queue_init::<Registry>(&connection).ok()?;
+    Some(globals.contents().with_list(|list| list.iter().any(|g| g.interface == "zxdg_decoration_manager_v1")))
+}
+
+struct Registry;
+
+impl Dispatch<wl_registry::WlRegistry, GlobalListContents> for Registry {
+    fn event(
+        _state: &mut Self,
+        _registry: &wl_registry::WlRegistry,
+        _event: wl_registry::Event,
+        _data: &GlobalListContents,
+        _connection: &Connection,
+        _queue: &QueueHandle<Self>,
+    ) {
+    }
+}
+
+#[cfg(test)]
+mod tests {
+    #[test]
+    fn only_wayland_without_server_decorations_uses_the_frame() {
+        for (wayland, server_decorations, expected) in [
+            (false, None, false),
+            (false, Some(false), false),
+            (false, Some(true), false),
+            (true, Some(true), false),
+            (true, Some(false), true),
+            (true, None, false),
+        ] {
+            assert_eq!(super::native_frame(wayland, server_decorations), expected);
+        }
+    }
+
+    #[test]
+    fn an_unavailable_display_keeps_the_custom_controls() {
+        assert_eq!(super::probe("/nonexistent/artcraft-launcher-wayland-test".into()), None);
+    }
+
+    #[test]
+    fn an_unresponsive_display_cannot_block_window_startup() -> std::io::Result<()> {
+        let socket = std::env::temp_dir().join(format!("craft-launcher-decoration-timeout-{}", std::process::id()));
+        let listener = std::os::unix::net::UnixListener::bind(&socket)?;
+        let start = std::time::Instant::now();
+        assert_eq!(super::probe(socket.clone()), None);
+        assert!(start.elapsed() < std::time::Duration::from_secs(2));
+        drop(listener);
+        let _ = std::fs::remove_file(socket);
+        Ok(())
+    }
+}
diff --git a/apps/artcraft-launcher/src/main.rs b/apps/artcraft-launcher/src/main.rs
--- a/apps/artcraft-launcher/src/main.rs
+++ b/apps/artcraft-launcher/src/main.rs
@@ -5,6 +5,8 @@
 #![cfg_attr(windows, windows_subsystem = "windows")]
 #![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
 
+#[cfg(target_os = "linux")]
+mod linux_window;
 mod logging;
 mod motion;
 mod settings_file;
@@ -123,16 +125,26 @@ fn main() -> ExitCode {
         launcher.start(settings, &waker);
         window = launcher.tray.is_none();
     }
+    #[cfg(target_os = "linux")]
+    let decoration_socket = linux_window::display_socket();
+
     // One pass per window: where a window can't hide, closing to the tray ends its pass and the
     // launcher carries on without one until the window is asked for again.
     loop {
         if window {
             let result = {
                 let (waker, launcher) = (waker.clone(), &mut launcher);
+                #[cfg(target_os = "linux")]
+                let decoration_socket = decoration_socket.clone();
                 eframe::run_native(
                     "ArtCraft Launcher",
                     native_options(hidden),
                     Box::new(move |cc| {
+                        #[cfg(target_os = "linux")]
+                        {
+                            launcher.host.title_bar = if linux_window::needs_native_frame(cc, decoration_socket) { TitleBar::Native } else { TitleBar::Custom };
+                            cc.egui_ctx.send_viewport_cmd(egui::ViewportCommand::Decorations(launcher.host.title_bar == TitleBar::Native));
+                        }
                         launcher.open(cc, &waker);
                         Ok(Box::new(Shell::new(cc, launcher, hidden)))
                     }),
@@ -170,11 +182,11 @@ fn native_options(hidden: bool) -> eframe::NativeOptions {
         .with_inner_size([1120.0, 760.0])
         .with_min_inner_size([720.0, 500.0])
         .with_visible(!hidden);
-    // The header is the title bar (see `DesktopHost::title_bar`).
-    viewport = if cfg!(target_os = "macos") {
-        viewport.with_fullsize_content_view(true).with_titlebar_shown(false).with_title_shown(false)
-    } else {
-        viewport.with_decorations(false)
+    // Keep window decorations and the in-app header consistent.
+    viewport = match desktop_title_bar() {
+        TitleBar::MacOs => viewport.with_fullsize_content_view(true).with_titlebar_shown(false).with_title_shown(false),
+        TitleBar::Native => viewport.with_decorations(true),
+        TitleBar::Custom => viewport.with_decorations(false),
     };
     // On macOS the bundle's .icns is the icon. Any icon given to eframe (its egui logo when none
     // is) replaces it in the Dock at runtime with a raw bitmap, which skips the system's icon
@@ -476,13 +488,18 @@ fn claim_notification_identity() -> &'static Result<(), String> {
 }
 
 struct DesktopHost {
+    title_bar: TitleBar,
     tray: bool,
     quit: bool,
 }
 
+fn desktop_title_bar() -> TitleBar {
+    if cfg!(target_os = "macos") { TitleBar::MacOs } else { TitleBar::Custom }
+}
+
 impl Host for DesktopHost {
     fn title_bar(&self) -> TitleBar {
-        if cfg!(target_os = "macos") { TitleBar::MacOs } else { TitleBar::Custom }
+        self.title_bar
     }
 
     fn notify(&mut self, title: &str, body: &str) {
@@ -576,7 +593,7 @@ impl Launcher {
             system_theme: system_theme::Monitor::default(),
             ui: LauncherUi::new(&windowless, backend, Settings::default()),
             tray: None,
-            host: DesktopHost { tray: false, quit: false },
+            host: DesktopHost { title_bar: desktop_title_bar(), tray: false, quit: false },
             starts,
             windowless,
             run_again,
@@ -859,6 +876,13 @@ mod tests {
 
     use super::*;
 
+    #[cfg(target_os = "linux")]
+    #[test]
+    fn linux_defaults_to_custom_frame_until_backend_is_known() {
+        assert_eq!(desktop_title_bar(), TitleBar::Custom);
+        assert_eq!(native_options(false).viewport.decorations, Some(false));
+    }
+
     const NOW: i64 = 1_791_500_000;
 
     /// The bundle id is set once: asking again gives the first outcome, not the library's "already set" error.
diff --git a/packaging/linux/flatpak/ai.storyteller.artcraft-launcher.yml b/packaging/linux/flatpak/ai.storyteller.artcraft-launcher.yml
--- a/packaging/linux/flatpak/ai.storyteller.artcraft-launcher.yml
+++ b/packaging/linux/flatpak/ai.storyteller.artcraft-launcher.yml
@@ -62,6 +62,7 @@ modules:
       - cargo --offline build --release --locked -p artcraft-launcher
       - install -Dm755 target/release/artcraft-launcher /app/bin/artcraft-launcher
       - install -Dm644 NOTICE /app/share/doc/artcraft-launcher/NOTICE
+      - install -Dm644 NOTICE /app/share/doc/artcraft-launcher/NOTICE
       - install -Dm644 packaging/linux/ai.storyteller.artcraft-launcher.desktop /app/share/applications/ai.storyteller.artcraft-launcher.desktop
       - mkdir -p /app/share/icons && cp -R assets/app-icon/hicolor /app/share/icons/
       - >-
diff --git a/packaging/linux/package.sh b/packaging/linux/package.sh
--- a/packaging/linux/package.sh
+++ b/packaging/linux/package.sh
@@ -135,6 +135,8 @@ if has appimage; then
   fi
   APPDIR="$WORK/ArtCraftLauncher.AppDir"
   cp -R "$STAGE" "$APPDIR"
+  # Preserve sctk-adwaita MIT license after removing package documentation.
+  install -Dm644 "$ROOT/NOTICE" "$APPDIR/usr/share/doc/$PKG/NOTICE"
   # AppRun is a script, not a symlink: it installs the .desktop entry and icons into
   # $XDG_DATA_HOME before launching, so Wayland compositors can resolve the window's
   # app_id to our icon instead of the generic Wayland logo (opt-out env in the
