diff --git a/.github/workflows/pages.yml b/.github/workflows/pages.yml
index 847686d..e92721b 100644
--- a/.github/workflows/pages.yml
+++ b/.github/workflows/pages.yml
@@ -5,7 +5,7 @@ on:
paths:
- "site/**"
- "docs/**"
- - "packaging/icons/ferese.svg"
+ - "assets/branding/**"
- "scripts/build-site.py"
- ".github/workflows/pages.yml"
workflow_dispatch:
diff --git a/README.md b/README.md
index 65e517d..35cd46d 100644
--- a/README.md
+++ b/README.md
@@ -1,5 +1,9 @@
-
+
+
+
+
+
A configurable Wayland desktop
diff --git a/assets/branding/ferese-blue-dark.svg b/assets/branding/ferese-blue-dark.svg
new file mode 100644
index 0000000..489ca4d
--- /dev/null
+++ b/assets/branding/ferese-blue-dark.svg
@@ -0,0 +1,19 @@
+
\ No newline at end of file
diff --git a/assets/branding/ferese-blue-flat.svg b/assets/branding/ferese-blue-flat.svg
new file mode 100644
index 0000000..aeaaab9
--- /dev/null
+++ b/assets/branding/ferese-blue-flat.svg
@@ -0,0 +1,15 @@
+
\ No newline at end of file
diff --git a/assets/branding/ferese-blue-light.svg b/assets/branding/ferese-blue-light.svg
new file mode 100644
index 0000000..c0e59a6
--- /dev/null
+++ b/assets/branding/ferese-blue-light.svg
@@ -0,0 +1,17 @@
+
\ No newline at end of file
diff --git a/assets/wallpapers/ferese-wallpaper-dark.jpg b/assets/wallpapers/ferese-wallpaper-dark.jpg
new file mode 100644
index 0000000..ac9c8c8
Binary files /dev/null and b/assets/wallpapers/ferese-wallpaper-dark.jpg differ
diff --git a/assets/wallpapers/ferese-wallpaper-light.png b/assets/wallpapers/ferese-wallpaper-light.png
new file mode 100644
index 0000000..8835e72
Binary files /dev/null and b/assets/wallpapers/ferese-wallpaper-light.png differ
diff --git a/assets/wallpapers/ferese.png b/assets/wallpapers/ferese.png
index 7c01d20..d352758 100644
Binary files a/assets/wallpapers/ferese.png and b/assets/wallpapers/ferese.png differ
diff --git a/assets/wallpapers/ferese.svg b/assets/wallpapers/ferese.svg
deleted file mode 100644
index 7387314..0000000
--- a/assets/wallpapers/ferese.svg
+++ /dev/null
@@ -1,62 +0,0 @@
-
diff --git a/compositor/src/wallpaper.rs b/compositor/src/wallpaper.rs
index b572623..3f5ed89 100644
--- a/compositor/src/wallpaper.rs
+++ b/compositor/src/wallpaper.rs
@@ -508,7 +508,7 @@ mod tests {
fn default_wallpaper_is_bundled_and_decodable() {
let config: WallpaperConfig = ferese_config::from_str("").unwrap();
let path = config.path.unwrap();
- assert_eq!(image::image_dimensions(&path).unwrap(), (1920, 1080));
+ assert_eq!(image::image_dimensions(&path).unwrap(), (3840, 2160));
assert!(WallpaperState::new(WallpaperConfig::default()).owns_background());
}
}
diff --git a/crates/ferese-config/src/lib.rs b/crates/ferese-config/src/lib.rs
index b2af2a7..264a3b8 100644
--- a/crates/ferese-config/src/lib.rs
+++ b/crates/ferese-config/src/lib.rs
@@ -38,13 +38,26 @@ pub fn config_path() -> Option {
}
pub fn default_wallpaper() -> &'static str {
- static PATH: std::sync::OnceLock = std::sync::OnceLock::new();
- PATH.get_or_init(|| {
+ default_wallpaper_for(theme::Appearance::Dark)
+}
+
+pub fn default_wallpaper_for(appearance: theme::Appearance) -> &'static str {
+ static LIGHT: std::sync::OnceLock = std::sync::OnceLock::new();
+ static DARK: std::sync::OnceLock = std::sync::OnceLock::new();
+ let (slot, name) = match appearance {
+ theme::Appearance::Light => (&LIGHT, "ferese-wallpaper-light.png"),
+ theme::Appearance::Dark => (&DARK, "ferese-wallpaper-dark.jpg"),
+ };
+ slot.get_or_init(|| {
let path = std::env::current_exe()
.ok()
- .and_then(|path| path.parent().map(|dir| dir.join("wallpapers/ferese.png")))
+ .and_then(|path| path.parent().map(|dir| dir.join("wallpapers").join(name)))
.filter(|path| path.is_file())
- .unwrap_or_else(|| PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../../assets/wallpapers/ferese.png"));
+ .unwrap_or_else(|| {
+ PathBuf::from(env!("CARGO_MANIFEST_DIR"))
+ .join("../../assets/wallpapers")
+ .join(name)
+ });
path.canonicalize().unwrap_or(path).to_string_lossy().into_owned()
})
diff --git a/crates/ferese-config/src/theme.rs b/crates/ferese-config/src/theme.rs
index cb64fff..46d191c 100644
--- a/crates/ferese-config/src/theme.rs
+++ b/crates/ferese-config/src/theme.rs
@@ -575,6 +575,7 @@ pub fn preset(name: &str, appearance: Appearance) -> Result {
return Err(format!("Preset {name} does not support {appearance:?}"));
}
let mut tokens = default_tokens();
+ tokens.background.path = Some(crate::default_wallpaper_for(appearance).into());
tokens.colors.surface_base = preset.base.into();
tokens.colors.surface_raised = preset.raised.into();
tokens.colors.application_background = preset.application_background.into();
@@ -601,6 +602,7 @@ pub fn preset(name: &str, appearance: Appearance) -> Result {
return Ok(tokens);
}
let mut t = default_tokens();
+ t.background.path = Some(crate::default_wallpaper_for(appearance).into());
let (base, text, muted, accent, border) = match name {
"monochrome" => ("#101012", "#EDEDF0", "#97979F", "#E5E5E5", "#FFFFFF18"),
"dracula" => ("#282A36", "#F8F8F2", "#A4ADCD", "#BD93F9", "#44475A"),
@@ -962,6 +964,62 @@ mod tests {
)
}
+ #[test]
+ fn wallpapers_follow_appearance_unless_explicitly_selected() {
+ for (mode, appearance) in [("light", Appearance::Light), ("dark", Appearance::Dark)] {
+ let default = candidate(&format!("theme {{ mode \"{mode}\"; }}")).unwrap();
+ assert_eq!(
+ default.theme.tokens.background.path.as_deref(),
+ Some(Path::new(crate::default_wallpaper_for(appearance)))
+ );
+
+ let shared = candidate(&format!(
+ "theme {{ mode \"{mode}\"; background {{ path \"shared.png\"; }}; }}"
+ ))
+ .unwrap();
+ assert_eq!(
+ shared.theme.tokens.background.path.as_deref(),
+ Some(Path::new("/config/ferese/shared.png"))
+ );
+
+ let separate = candidate(&format!("theme {{ mode \"{mode}\"; background {{ path \"shared.png\"; }}; light {{ background {{ path \"light.png\"; }}; }}; dark {{ background {{ path \"dark.jpg\"; }}; }}; }}")).unwrap();
+ assert_eq!(
+ separate.theme.tokens.background.path.as_deref(),
+ Some(Path::new(&format!(
+ "/config/ferese/{}",
+ if appearance == Appearance::Light {
+ "light.png"
+ } else {
+ "dark.jpg"
+ }
+ )))
+ );
+ }
+
+ let auto = candidate("theme { mode \"auto\"; schedule { timezone \"UTC\"; }; }").unwrap();
+ assert_eq!(auto.theme.appearance, Appearance::Light);
+ assert_eq!(
+ auto.theme.tokens.background.path.as_deref(),
+ Some(Path::new(crate::default_wallpaper_for(Appearance::Light)))
+ );
+ }
+
+ #[test]
+ fn imported_theme_wallpaper_is_not_overwritten_by_bundled_defaults() {
+ let source = "theme { mode \"light\"; family \"custom\"; custom-themes { custom { file \"custom.kdl\"; }; }; }";
+ let result = resolve(
+ &Document::parse(source).unwrap(),
+ Path::new("/config/ferese"),
+ "2026-09-30T12:00:00Z".parse().unwrap(),
+ |_| Ok("light { background { path \"authored.png\"; }; }".into()),
+ )
+ .unwrap();
+ assert_eq!(
+ result.theme.tokens.background.path.as_deref(),
+ Some(Path::new("/config/ferese/authored.png"))
+ );
+ }
+
#[test]
fn automatic_gradients_follow_presets_and_preserve_border_alpha() {
for preset in crate::presets::PRESETS {
diff --git a/crates/ferese-theme/assets/icons/ferese.svg b/crates/ferese-theme/assets/icons/ferese.svg
index 49117b3..0529370 100644
--- a/crates/ferese-theme/assets/icons/ferese.svg
+++ b/crates/ferese-theme/assets/icons/ferese.svg
@@ -1,6 +1,6 @@
diff --git a/crates/ferese-theme/src/gallery.rs b/crates/ferese-theme/src/gallery.rs
index ab1e411..a40faeb 100644
--- a/crates/ferese-theme/src/gallery.rs
+++ b/crates/ferese-theme/src/gallery.rs
@@ -32,11 +32,17 @@ pub fn tile(
font,
id,
} = options;
- use cosmic::widget::{button, column, row};
- let mut caption = row([])
- .spacing(6)
- .align_y(cosmic::iced::Alignment::Center)
- .push(crate::text(family.name.clone(), font).size(12));
+ use cosmic::widget::{button, container, row};
+ let label_color = family
+ .palette(variant.unwrap_or(Appearance::Light))
+ .or(family.dark.as_ref())
+ .map(|palette| crate::parse_color(&palette.text).unwrap())
+ .unwrap_or(palette.text);
+ let mut caption = row([]).spacing(6).align_y(cosmic::iced::Alignment::Center).push(
+ crate::text(family.name.clone(), font)
+ .size(12)
+ .class(cosmic::theme::Text::Color(label_color)),
+ );
if family.light.is_none() || family.dark.is_none() {
caption = caption.push(
crate::text(
@@ -48,19 +54,31 @@ pub fn tile(
font,
)
.size(10)
- .class(cosmic::theme::Text::Color(palette.muted)),
+ .class(cosmic::theme::Text::Color(label_color)),
);
}
- let image = preview(family, variant, active, selected);
- let image =
- cosmic::widget::responsive(move |size| image.clone().height(Length::Fixed(size.width * 126. / 200.)).into())
- .height(Length::Shrink);
- let content = column([]).spacing(2).push(image).push(caption);
+ let family_preview = family.clone();
+ let caption: Element<'static, M> = container(caption)
+ .padding(8)
+ .width(Length::Fill)
+ .height(Length::Fill)
+ .align_y(cosmic::iced::alignment::Vertical::Bottom)
+ .into();
+ let image = cosmic::widget::responsive(move |size| {
+ let height = size.width * 126. / 200.;
+ // Match the button's logical radius when scaling the cached SVG artwork.
+ let radius = 10. * 200. / size.width.max(1.);
+ preview_with_radius(&family_preview, variant, active, selected, radius)
+ .height(Length::Fixed(height))
+ .into()
+ })
+ .height(Length::Shrink);
+ let content = cosmic::iced::widget::stack([image.into(), caption]);
let tile = button::custom(content)
.id(id.clone())
.name(format!("{}, {}", family.name, family.availability()))
.width(Length::Fill)
- .padding(8)
+ .padding(0)
.class(tile_style(palette, selected))
.on_press(on_select);
radio_with_tab_stop(tile, id, selected, tab_stop, navigate)
@@ -70,11 +88,17 @@ fn tile_style(palette: crate::Palette, selected: bool) -> cosmic::theme::Button
let style = move |focused: bool, hovered: bool| cosmic::widget::button::Style {
shape: Some(BorderShape::Continuous),
outline: None,
- background: Some(if hovered { palette.sidebar } else { palette.card }.into()),
- border_width: if selected { 2. } else { 0. },
+ background: None,
+ border_width: if selected || focused {
+ 2.
+ } else if hovered {
+ 1.
+ } else {
+ 0.
+ },
border_color: palette.accent,
border_radius: 10.into(),
- outline_width: if focused { 2. } else { 0. },
+ outline_width: 0.,
outline_color: palette.text,
text_color: Some(palette.text),
icon_color: Some(palette.text),
@@ -93,15 +117,26 @@ pub fn preview(
variant: Option,
active: Option,
selected: bool,
+) -> cosmic::widget::icon::Icon {
+ preview_with_radius(family, variant, active, selected, 10.)
+}
+
+fn preview_with_radius(
+ family: &Family,
+ variant: Option,
+ active: Option,
+ selected: bool,
+ radius: f32,
) -> cosmic::widget::icon::Icon {
use cosmic::widget::icon;
- let key = format!("{}/{variant:?}/{active:?}/{selected}", family.id);
+ let key = format!("{}/{variant:?}/{active:?}/{selected}/{}", family.id, radius.to_bits());
static CACHE: std::sync::OnceLock>> =
std::sync::OnceLock::new();
let cache = CACHE.get_or_init(Default::default);
let mut cache = cache.lock().unwrap();
if cache.get(&key).is_none_or(|(cached, _)| cached != family) {
- let handle = icon::from_svg_bytes(svg(family, variant, active, selected).into_bytes()).symbolic(false);
+ let handle = icon::from_svg_bytes(svg_with_radius(family, variant, active, selected, radius).into_bytes())
+ .symbolic(false);
cache.insert(key.clone(), (family.clone(), handle));
}
let handle = cache[&key].1.clone();
@@ -130,11 +165,32 @@ fn desktop(p: &Palette) -> String {
}
pub fn svg(family: &Family, variant: Option, active: Option, selected: bool) -> String {
+ svg_with_radius(family, variant, active, selected, 10.)
+}
+
+fn svg_with_radius(
+ family: &Family,
+ variant: Option,
+ active: Option,
+ selected: bool,
+ radius: f32,
+) -> String {
let mut result = String::from(
r##"");
+ result.push_str("");
result
}
diff --git a/docs/configuration.md b/docs/configuration.md
index 6a83efe..80a1f81 100644
--- a/docs/configuration.md
+++ b/docs/configuration.md
@@ -249,7 +249,7 @@ prevent popup cleanup.
Settings → Appearance offers six paired themes: **Ferese Blue** (default),
**Catppuccin**, **Gruvbox**, **Rosé Pine**, **Tokyo Night**, and **Everforest**.
-Ferese Blue uses the logo accent, `#3D7BE6`, and has light and dark variants.
+Ferese Blue uses the default accent, `#3D7BE6`, and has light and dark variants.
Choose Light, Dark, or Auto in Settings or Control Center. Auto follows local
07:00 and 19:00 boundaries unless you change its schedule.
@@ -278,13 +278,34 @@ one appearance.
| `appearance.inactive-dim.amount` | number 0–1 | `0.15` | Darkening strength |
| `appearance.inactive-dim.duration-ms` | number ≥ 0 | `150` | Dimming transition; 0 snaps |
| `theme.typography.font-family` | string | `"Inter"` | Shell, Settings and overview font |
-| `theme.background.path` | string | bundled Ferese wallpaper | Wallpaper image path; an existing selection overrides the default |
+| `theme.background.path` | string | matching light/dark wallpaper | Shared wallpaper; an explicit path overrides both bundled defaults |
+| `theme.light.background.path`, `theme.dark.background.path` | string | shared path, or matching default | Wallpaper for one appearance; overrides the shared path |
| `theme.background.mode` | `"fill"`, `"fit"` | `"fill"` | Crop or letterbox |
+| `theme.light.background.mode`, `theme.dark.background.mode` | `"fill"`, `"fit"` | shared placement | Image placement for one appearance |
| `theme.material.style` | `"solid"`, `"translucent"` | `"solid"` | Shell background material |
| `theme.material.opacity` | number 0–1 | `0.78` | Shared shell background opacity for bars, menus, popovers, notifications and themed dialogs. Text/icons stay opaque; 0 hides the material. Solid mode is always opaque |
| `theme.material.tint-strength` | number 0–1 | Dark `0.5`, light `1` | Color strength over the blurred backdrop |
| `theme.material.blur-radius` | number ≥ 0 | `12` | Translucent backdrop blur, capped at 32; 0 disables |
+Settings → Wallpaper shows the current image and both bundled defaults. Use
+**Apply to** to change both modes or just one. **Match appearance** restores the
+bundled light and dark pair. Custom images can also be selected separately in KDL:
+
+```kdl
+theme {
+ light {
+ background { path "~/Pictures/day.png"; }
+ }
+ dark {
+ background { path "~/Pictures/night.jpg"; }
+ }
+}
+```
+
+The wallpaper follows manual light/dark changes and automatic appearance changes.
+A shared `theme.background.path` applies to both modes unless a per-mode path
+is set.
+
| `theme.colors` key | Default | Meaning |
| --- | --- | --- |
| `surface-base` | `"#111821"` | Material/card base color |
diff --git a/docs/images/ferese-lockup.svg b/docs/images/ferese-lockup.svg
deleted file mode 100644
index 1f70e2a..0000000
--- a/docs/images/ferese-lockup.svg
+++ /dev/null
@@ -1,24 +0,0 @@
-
diff --git a/docs/screenshots.md b/docs/screenshots.md
index efea528..15f0852 100644
--- a/docs/screenshots.md
+++ b/docs/screenshots.md
@@ -4,9 +4,9 @@ These screenshots were taken on September 30, 2026. Click an image to open it at
## Ferese Blue
-Settings and Control Center with the default wallpaper.
+Settings and Control Center with the previous default wallpaper.
-[](images/screenshots/ferese-main-hero.png)
+[](images/screenshots/ferese-main-hero.png)
## Rosé Pine
diff --git a/packaging/icons/ferese.svg b/packaging/icons/ferese.svg
index 5994494..75d65e2 100644
--- a/packaging/icons/ferese.svg
+++ b/packaging/icons/ferese.svg
@@ -1,18 +1,18 @@
diff --git a/scripts/build-site.py b/scripts/build-site.py
index f0c7604..4de7053 100644
--- a/scripts/build-site.py
+++ b/scripts/build-site.py
@@ -33,7 +33,7 @@ for name in ('index.html', 'styles.css', 'app.js'):
(OUTPUT / 'assets').mkdir(exist_ok=True)
shutil.copytree(ROOT / 'site/assets', OUTPUT / 'assets', dirs_exist_ok=True)
shutil.copytree(ROOT / 'docs/images', OUTPUT / 'assets', dirs_exist_ok=True)
-shutil.copy2(ROOT / 'packaging/icons/ferese.svg', OUTPUT / 'assets/ferese.svg')
+shutil.copytree(ROOT / 'assets/branding', OUTPUT / 'assets/branding')
(OUTPUT / 'docs').mkdir(exist_ok=True)
(OUTPUT / '.nojekyll').touch()
search_index = []
@@ -103,13 +103,13 @@ for page_number, (slug, title, source) in enumerate(PAGES):
doc = f'''
{escape(title)} — Ferese handbook
-
+
Skip to content
-
+
-