bread-ecosystem/bread-theme/src/lib.rs
Breadway 8c5e42230a shell: add spotlight builtin (theme 04), dots/placeholder-clock schema, and anim::spring_to
- bread-theme:🐚 WorkspacesModule.dot_widths and ClockModule.placeholder_clock,
  resolved/validated in manifest.rs and modeled in types.rs, so a theme can
  actually configure dot-pill widths and an entry-as-clock placeholder
  instead of those staying schema-only.
- New compiled-in builtin "spotlight" (assets/shell/spotlight/), registered
  in builtin.rs so list() returns three themes: centred capsule window spec
  (anchors=[top] only, width=480, exclusive=none, keyboard=on_demand),
  workspaces.style=dots, clock.style=none+placeholder_clock, launcher.mode=
  embedded, drawer=[launcher_results]. Colours are palette token names
  (pink), never hex.
- bread-theme::anim::spring_to: a reusable TickCallback size-interpolation
  helper (GTK4 has no CSS width/height transition on a widget or layer-shell
  surface), generalized from WorkspaceTrail's existing ease/tick pattern, for
  breadbar's capsule-drawer expand/collapse.
- bread_launcher::LAUNCHER_APP: the launcher's one shared on-disk identity
  (cache/config/history), so breadbar's embedded capsule and breadbox's
  overlay window read and write the SAME cache and launch history rather
  than forking into two rankings of a user's apps.

Tests: +6 spotlight builtin tests (loads/in list/capsule window shape/
embedded mode/dots widths/flat-pink-not-hex), bread-theme --lib 63->69,
bread-launcher --lib unchanged at 18.
2026-08-31 15:14:44 +08:00

597 lines
25 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

#[cfg(feature = "adw")]
pub mod adw;
#[cfg(feature = "gtk")]
pub mod anim;
#[cfg(feature = "gtk")]
pub mod gtk;
mod layerrules;
mod output;
pub mod palette;
pub mod shell;
pub use layerrules::{layerrules_json, layerrules_path, write_layerrules, write_layerrules_active};
pub use output::{
generate_output, load_palette_for, output_css_path, output_palette_path, palette_from_image,
palette_from_json, palettes_dir, sanitize_output, themes_dir, write_output_css,
write_output_palette, write_shared_css_from,
};
pub use palette::{load_palette, Palette};
/// Env-var locks shared by any test module that mutates process-global
/// state (`std::env::set_var`) — `cargo test` runs a crate's tests in
/// parallel by default, so every module touching the *same* env var must
/// serialize through the *same* lock or their mutations race each other's
/// reads. `bread_theme::output`'s own `XDG_ENV_LOCK` guards `XDG_RUNTIME_DIR`
/// specifically and stays where it is; `XDG_CONFIG_HOME_LOCK` here is the
/// one shared by `shell::tests` and `layerrules::tests`, which both point
/// `XDG_CONFIG_HOME` at an isolated temp dir.
#[cfg(test)]
pub(crate) mod test_support {
pub(crate) static XDG_CONFIG_HOME_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
}
/// Design tokens from BREAD_DESIGN_SYSTEM.md.
pub mod tokens {
pub const FONT_FAMILY: &str = "Varela Round, sans-serif";
pub const FONT_SIZE_BASE: u8 = 14;
pub const FONT_SIZE_SECONDARY: u8 = 12;
// Spacing scale (px, 4px units)
pub const SPACE_XS: u8 = 4;
pub const SPACE_SM: u8 = 8;
pub const SPACE_MD: u8 = 12;
pub const SPACE_LG: u8 = 16;
pub const SPACE_XL: u8 = 20;
// Border radius
pub const RADIUS_PRIMARY: u8 = 8;
pub const RADIUS_SECONDARY: u8 = 6;
pub const RADIUS_TERTIARY: u8 = 4;
pub const RADIUS_PILL: u16 = 999;
}
/// CSS `font-family` list: quote the named face, leave the generic fallback
/// unquoted. Wrapping [`tokens::FONT_FAMILY`] in one pair of quotes would
/// make a single family named "Varela Round, sans-serif" and drop sans-serif.
fn css_font_family() -> &'static str {
"'Varela Round', sans-serif"
}
/// Emit the `@define-color` block that all bread apps use, plus the shared
/// font rule.
///
/// Kept for API compatibility with older callers that only want the color
/// variables (not the full [`stylesheet`] component rules). It used to carry
/// its own hand-written `@define-color` block that predated the `accent` and
/// computed-ink (`on-*`) colors — that duplication is exactly what let it
/// drift out of sync and reintroduce the illegible-text bug (light pywal
/// colors + no computed ink meant white-on-white / black-on-black text
/// wherever a caller's own CSS referenced `@on-surface`, `@on-accent`, etc.,
/// since those names simply didn't exist in this block). It now delegates
/// to the same [`define_colors`] the full stylesheet uses, so there is only
/// one color-block implementation and it cannot drift again.
pub fn css_vars(p: &Palette) -> String {
format!(
"{vars}* {{ font-family: {font}; font-size: {size}px; }}\n",
vars = define_colors(p),
font = css_font_family(),
size = tokens::FONT_SIZE_BASE,
)
}
/// Relative luminance (WCAG, sRGB) of a `#rrggbb` colour, 0.0 (black) 1.0 (white).
pub fn luminance(hex: &str) -> f32 {
let h = hex.trim_start_matches('#');
let lin = |i: usize| -> f32 {
let c = u8::from_str_radix(h.get(i..i + 2).unwrap_or("00"), 16).unwrap_or(0) as f32 / 255.0;
if c <= 0.04045 {
c / 12.92
} else {
((c + 0.055) / 1.055).powf(2.4)
}
};
0.2126 * lin(0) + 0.7152 * lin(2) + 0.0722 * lin(4)
}
/// Pick a legible ink (near-black or near-white) for text drawn on `hex`.
/// 0.179 is the WCAG crossover where contrast against black equals contrast
/// against white — so whichever side we pick always wins. This is what keeps
/// text readable no matter how light or dark pywal makes a given palette slot,
/// without altering the palette colours themselves.
pub fn ink_on(hex: &str) -> &'static str {
if luminance(hex) > 0.179 {
"#11111b"
} else {
"#f5f5f5"
}
}
/// Canonical (name, value) list: the single naming all bread apps share.
/// `surface` = color0 (darkest surface), `overlay` = color7 (muted), and
/// `accent` = color4. Apps must use these names, not raw palette slots, so the
/// whole ecosystem recolours together.
///
/// The `on-*` colours are computed ink (black/white) guaranteed to be legible on
/// the matching background — use `on-surface` for text on a `surface` panel,
/// `on-accent` on an `accent` button, etc. They exist because pywal can emit a
/// light value in any slot, and white text on a light surface disappears.
///
/// [`define_colors`] (GTK `@define-color`) and [`css_custom_properties`] (web
/// `:root { --name: ... }`) both format this same list rather than each
/// hand-writing their own — see `css_vars_and_stylesheet_agree_on_color_block`
/// and `css_custom_properties_matches_define_colors_name_set` for the
/// regression tests this exists to satisfy.
fn color_pairs(p: &Palette) -> [(&'static str, String); 16] {
[
("bg", p.background.clone()),
("fg", p.foreground.clone()),
("surface", p.color0.clone()),
("overlay", p.color7.clone()),
("accent", p.color4.clone()),
("red", p.color1.clone()),
("green", p.color2.clone()),
("yellow", p.color3.clone()),
("blue", p.color4.clone()),
("pink", p.color5.clone()),
("teal", p.color6.clone()),
("on-bg", ink_on(&p.background).to_string()),
("on-surface", ink_on(&p.color0).to_string()),
("on-accent", ink_on(&p.color4).to_string()),
("on-red", ink_on(&p.color1).to_string()),
("on-overlay", ink_on(&p.color7).to_string()),
]
}
/// GTK `@define-color` block built from [`color_pairs`].
fn define_colors(p: &Palette) -> String {
color_pairs(p)
.iter()
.map(|(name, value)| format!("@define-color {name} {value};\n"))
.collect()
}
/// CSS custom-properties block (`:root { --bg: ...; --on-accent: ...; }`) for
/// web frontends (Tauri), using the exact same names as [`define_colors`] so
/// the GTK and web outputs cannot drift apart independently — both are
/// generated from [`color_pairs`], not two hand-written copies.
pub fn css_custom_properties(p: &Palette) -> String {
let vars: String = color_pairs(p)
.iter()
.map(|(name, value)| format!(" --{name}: {value};\n"))
.collect();
format!(":root {{\n{vars}}}\n")
}
/// CSS custom-properties for [`tokens`] (font, spacing, radii) — the web
/// counterpart to [`tokens`] being hand-read by GTK code, so a web frontend
/// isn't hand-copying the same numbers into a second source of truth.
pub fn css_tokens() -> String {
use tokens::*;
format!(
":root {{\n\
\x20\x20--font-family: {font};\n\
\x20\x20--font-size-base: {base}px;\n\
\x20\x20--font-size-secondary: {sec}px;\n\
\x20\x20--space-xs: {xs}px;\n\
\x20\x20--space-sm: {sm}px;\n\
\x20\x20--space-md: {md}px;\n\
\x20\x20--space-lg: {lg}px;\n\
\x20\x20--space-xl: {xl}px;\n\
\x20\x20--radius-primary: {r1}px;\n\
\x20\x20--radius-secondary: {r2}px;\n\
\x20\x20--radius-tertiary: {r3}px;\n\
\x20\x20--radius-pill: {pill}px;\n\
}}\n",
font = css_font_family(),
base = FONT_SIZE_BASE,
sec = FONT_SIZE_SECONDARY,
xs = SPACE_XS,
sm = SPACE_SM,
md = SPACE_MD,
lg = SPACE_LG,
xl = SPACE_XL,
r1 = RADIUS_PRIMARY,
r2 = RADIUS_SECONDARY,
r3 = RADIUS_TERTIARY,
pill = RADIUS_PILL,
)
}
/// The full shared component stylesheet — the single source of truth for how
/// every bread GUI (bos-settings, breadbar, breadbox, breadpad, breadman) styles
/// common widgets. Apps load this, then append only their own *layout* rules.
///
/// Built entirely from the design tokens (font, spacing, radii) and the
/// `@define-color` palette, so changing the palette recolours every app.
pub fn stylesheet(p: &Palette) -> String {
use tokens::*;
format!(
"{vars}\
* {{ font-family: {font}; font-size: {base}px; }}\n\
/* Colour is set on containers; labels inherit it, so text on any panel,\
button, or accent is always the legible ink for that background. Bare\
`label {{ color }}` is deliberately avoided — as a type selector it\
would override a container's colour on its own child labels. */\n\
window {{ background-color: @bg; color: @on-bg; }}\n\
.dim-label, .dim {{ opacity: 0.6; font-size: {sec}px; }}\n\
/* Named `.page-title`, not the more obvious `.title` - libadwaita's\
own row/window-title widgets (AdwActionRow, AdwWindowTitle, GtkHeaderBar)\
put a bare `title` CSS class on their internal label, so a generic\
`.title` rule here would inflate every libadwaita row's title text\
to 1.4em too (this is exactly what caused the settings screen's\
~24px row-title bug). Scoping the name avoids the collision instead\
of trying to out-specificity a first-party GTK/libadwaita class. */\n\
.page-title {{ font-size: 1.4em; font-weight: bold; }}\n\
.heading {{ font-weight: bold; opacity: 0.85; }}\n\
/* Same libadwaita-collision reasoning as `.page-title` above - a bare\
`.subtitle` also matches libadwaita's internal row-subtitle labels.\
Unused by any app today, but scoped so a future caller doesn't\
reintroduce the fight. */\n\
.page-subtitle {{ opacity: 0.7; font-size: {sec}px; }}\n\
button {{ background-color: @surface; color: @on-surface; border: none;\
border-radius: {r1}px; padding: {sm}px {lg}px; }}\n\
button:hover {{ background-color: alpha(@on-surface, 0.14); }}\n\
button:active {{ background-color: alpha(@on-surface, 0.20); }}\n\
button:disabled {{ opacity: 0.5; }}\n\
button.flat {{ background-color: transparent; color: @on-bg; }}\n\
button.suggested-action {{ background-color: @accent; color: @on-accent; }}\n\
button.suggested-action:hover {{ background-color: alpha(@accent, 0.85); }}\n\
/* Deliberately NOT @red: pywal can hand `red` any hue depending on\
the wallpaper (a blue-toned wallpaper's \"red\" slot can literally\
render blue), which would make a destructive action indistinguishable\
from a normal accent button - exactly backwards for a warning colour.\
GNOME's own destructive-action is a fixed red for the same reason;\
this is the one button style in the whole system that intentionally\
doesn't follow the palette. */\n\
button.destructive-action {{ background-color: #e01b24; color: #ffffff; }}\n\
button.destructive-action:hover {{ background-color: #c01c28; }}\n\
entry, spinbutton {{ background-color: @surface; color: @on-surface;\
border: 1px solid @overlay; border-radius: {r2}px;\
padding: {xs}px {sm}px; caret-color: @on-surface; }}\n\
entry:focus-within, spinbutton:focus-within {{ border-color: @accent; outline: none; }}\n\
entry image, spinbutton button {{ color: @on-surface; }}\n\
dropdown > button {{ background-color: @surface; color: @on-surface; border-radius: {r2}px; }}\n\
popover > contents {{ background-color: @surface; color: @on-surface; border-radius: {r1}px; }}\n\
switch {{ background-color: @overlay; border-radius: {pill}px; }}\n\
switch:checked {{ background-color: @accent; }}\n\
switch slider {{ background-color: @on-surface; border-radius: {pill}px; }}\n\
/* GtkScale (sliders) render with GTK's own default accent (a fixed\
blue, independent of the app's theme) unless styled explicitly —\
every app with a volume/brightness slider was silently showing\
that default instead of the palette's accent until this rule\
existed. */\n\
scale trough {{ background-color: @overlay; border-radius: {pill}px; min-height: 6px; }}\n\
scale trough highlight {{ background-color: @accent; border-radius: {pill}px; min-height: 6px; }}\n\
scale slider {{ background-color: @on-bg; border-radius: {pill}px; }}\n\
list, listbox {{ background-color: transparent; }}\n\
/* libadwaita's AdwPreferencesGroup wraps its rows in a GtkListBox\
carrying the `boxed-list` class, expecting a surface fill + radius\
to read as a card. The bare-type rule above (needed so plain\
GTK4 sidebars/lists stay transparent) was overriding that with\
equal specificity and no fill ever won, leaving preference groups\
as a bare bordered table instead of a card. This is scoped to the\
class only, so it doesn't touch any non-adw list. */\n\
list.boxed-list, listbox.boxed-list {{ background-color: @surface; border-radius: {r1}px; }}\n\
row {{ border-radius: {r2}px; }}\n\
row:selected, list row:selected {{ background-color: @accent; color: @on-accent; }}\n\
.sidebar {{ background-color: @surface; color: @on-surface; }}\n\
.sidebar row {{ padding: {sm}px {md}px; }}\n\
.sidebar row:selected {{ background-color: @accent; color: @on-accent; }}\n\
.sidebar .section-header {{ padding: {md}px {md}px {xs}px {md}px;\
font-size: {sec}px; font-weight: bold; opacity: 0.55; }}\n\
.card {{ background-color: @surface; color: @on-surface; border-radius: {r1}px; padding: {md}px; }}\n\
.chip, .pill {{ background-color: @overlay; color: @on-overlay; border-radius: {pill}px;\
padding: {xs}px {md}px; font-size: {sec}px; }}\n\
.chip.active, .pill.active {{ background-color: @accent; color: @on-accent; }}\n\
scrollbar {{ background-color: transparent; }}\n\
scrollbar slider {{ background-color: alpha(@on-bg, 0.25); border-radius: {pill}px;\
min-width: 6px; min-height: 6px; }}\n\
scrollbar slider:hover {{ background-color: alpha(@on-bg, 0.45); }}\n\
textview, .mono {{ font-family: monospace; }}\n\
textview text {{ background-color: @surface; color: @on-surface; }}\n",
vars = define_colors(p),
font = css_font_family(),
base = FONT_SIZE_BASE,
sec = FONT_SIZE_SECONDARY,
xs = SPACE_XS, sm = SPACE_SM, md = SPACE_MD, lg = SPACE_LG,
r1 = RADIUS_PRIMARY, r2 = RADIUS_SECONDARY, pill = RADIUS_PILL,
)
}
/// Render the shared stylesheet for the current (pywal) palette. Used by the
/// `bread-theme` generator and as the in-app fallback when the generated file
/// isn't present yet.
pub fn render() -> String {
stylesheet(&load_palette())
}
/// Canonical path of the generated shared stylesheet. Apps load it; the
/// `bread-theme generate` CLI writes it. Per-session under `XDG_RUNTIME_DIR`,
/// falling back to the cache dir.
pub fn shared_css_path() -> std::path::PathBuf {
output::runtime_bread_dir().join("theme.css")
}
/// Write the shared stylesheet to [`shared_css_path`] (atomic rename). Returns
/// the path written. Used by the `bread-theme` CLI.
pub fn write_shared_css() -> std::io::Result<std::path::PathBuf> {
write_shared_css_from(&load_palette())
}
/// `stylesheet()` with `@name` references in rule bodies replaced by hex.
/// Longer names first (`on-surface` before `surface`, `on-bg` before `bg`)
/// so a prefix match cannot half-replace `@on-bg`.
pub fn stylesheet_resolved(p: &Palette) -> String {
resolve_color_names(&stylesheet(p), p)
}
/// Replace `@define-color` names (`@accent`, `@on-bg`, …) with hex values.
/// Used by [`stylesheet_resolved`] and by GTK `bind_window` so display-global
/// named colors cannot leak the wrong monitor's accent.
pub(crate) fn resolve_color_names(css: &str, p: &Palette) -> String {
let mut pairs: Vec<(&str, String)> = color_pairs(p).into_iter().collect();
pairs.sort_by(|a, b| b.0.len().cmp(&a.0.len()));
let mut out = css.to_string();
for (name, value) in pairs {
out = out.replace(&format!("@{name}"), &value);
}
out
}
/// Convert a `#rrggbb` hex colour to `rgba(r, g, b, alpha)`.
pub fn hex_to_rgba(hex: &str, alpha: f32) -> String {
let h = hex.trim_start_matches('#');
let r = u8::from_str_radix(h.get(0..2).unwrap_or("00"), 16).unwrap_or(0);
let g = u8::from_str_radix(h.get(2..4).unwrap_or("00"), 16).unwrap_or(0);
let b = u8::from_str_radix(h.get(4..6).unwrap_or("00"), 16).unwrap_or(0);
format!("rgba({r}, {g}, {b}, {alpha})")
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn css_vars_contains_all_define_color_names() {
let css = css_vars(&Palette::default());
for name in &[
"bg", "fg", "surface", "red", "green", "yellow", "blue", "pink", "teal", "overlay",
] {
assert!(
css.contains(&format!("@define-color {name} ")),
"missing @define-color {name}"
);
}
}
#[test]
fn css_vars_contains_font_rule() {
let css = css_vars(&Palette::default());
assert!(css.contains("font-family: 'Varela Round', sans-serif;"));
assert!(
!css.contains("font-family: 'Varela Round, sans-serif'"),
"named face and generic fallback must not be one quoted family"
);
assert!(css.contains("14px"));
}
#[test]
fn css_vars_includes_accent_and_computed_ink_colors() {
// Regression test: css_vars() used to be a second, hand-written
// @define-color block that predated `accent` and the computed `on-*`
// ink colors. Any caller whose own CSS referenced `@on-surface` /
// `@on-accent` etc. against that older block would hit an undefined
// color name — the illegible-text bug. css_vars() must now emit
// exactly the same color set as the full stylesheet.
let css = css_vars(&Palette::default());
for name in &[
"accent",
"on-bg",
"on-surface",
"on-accent",
"on-red",
"on-overlay",
] {
assert!(
css.contains(&format!("@define-color {name} ")),
"missing @define-color {name}"
);
}
}
#[test]
fn css_vars_and_stylesheet_agree_on_color_block() {
// Both must derive their color variables from the same
// `define_colors` implementation, so they can't drift apart again.
let p = Palette::default();
let vars = css_vars(&p);
let sheet = stylesheet(&p);
for name in &[
"bg",
"fg",
"surface",
"overlay",
"accent",
"on-bg",
"on-surface",
"on-accent",
] {
let needle = format!("@define-color {name} ");
assert!(vars.contains(&needle) && sheet.contains(&needle));
}
}
#[test]
fn stylesheet_defines_canonical_colors_and_components() {
let css = stylesheet(&Palette::default());
for name in &["bg", "fg", "surface", "overlay", "accent", "red", "blue"] {
assert!(
css.contains(&format!("@define-color {name} ")),
"missing @define-color {name}"
);
}
// a representative spread of the shared component selectors
for sel in &[
"button",
"entry",
"switch:checked",
".card",
".sidebar",
"scrollbar slider",
".page-title",
] {
assert!(css.contains(sel), "stylesheet missing selector: {sel}");
}
assert!(css.contains("font-family: 'Varela Round', sans-serif;"));
assert!(
!css.contains("font-family: 'Varela Round, sans-serif'"),
"named face and generic fallback must not be one quoted family"
);
}
#[test]
fn css_custom_properties_matches_define_colors_name_set() {
// Both must derive from the same color_pairs() list, so the web
// output can't drift from the GTK one the way css_vars/stylesheet
// used to (see css_vars_and_stylesheet_agree_on_color_block above).
let p = Palette::default();
let gtk = define_colors(&p);
let web = css_custom_properties(&p);
for (name, _) in color_pairs(&p) {
assert!(
gtk.contains(&format!("@define-color {name} ")),
"gtk missing {name}"
);
assert!(web.contains(&format!("--{name}: ")), "web missing {name}");
}
}
#[test]
fn css_custom_properties_is_valid_root_block() {
let p = Palette::default();
let css = css_custom_properties(&p);
assert!(css.starts_with(":root {\n"));
assert!(css.trim_end().ends_with('}'));
assert!(css.contains(&format!("--accent: {};", p.color4)));
}
#[test]
fn css_tokens_contains_font_and_spacing_vars() {
let css = css_tokens();
assert!(css.contains("--font-family: 'Varela Round', sans-serif;"));
assert!(
!css.contains("--font-family: 'Varela Round, sans-serif'"),
"named face and generic fallback must not be one quoted family"
);
assert!(css.contains("--font-size-base: 14px;"));
assert!(css.contains("--space-md: 12px;"));
assert!(css.contains("--radius-pill: 999px;"));
}
#[test]
fn luminance_black_and_white_are_extremes() {
assert!(luminance("#000000") < 0.01);
assert!(luminance("#ffffff") > 0.99);
}
#[test]
fn ink_on_picks_dark_text_for_light_backgrounds() {
// Light pywal slots (the case that made white text vanish) get dark ink.
assert_eq!(ink_on("#ffffff"), "#11111b");
assert_eq!(ink_on("#f9e2af"), "#11111b"); // pale yellow
assert_eq!(ink_on("#a6e3a1"), "#11111b"); // pale green
}
#[test]
fn ink_on_picks_light_text_for_dark_backgrounds() {
assert_eq!(ink_on("#000000"), "#f5f5f5");
assert_eq!(ink_on("#1e1e2e"), "#f5f5f5"); // catppuccin base
}
#[test]
fn stylesheet_defines_on_colors() {
let css = stylesheet(&Palette::default());
for name in &["on-bg", "on-surface", "on-accent", "on-red", "on-overlay"] {
assert!(
css.contains(&format!("@define-color {name} ")),
"missing @define-color {name}"
);
}
}
#[test]
fn stylesheet_has_no_blanket_label_color_rule() {
// A bare `label { color: ... }` would override container colours on child
// labels — the bug that made coloured-background text illegible.
let css = stylesheet(&Palette::default());
assert!(
!css.contains("label { color:"),
"blanket label colour rule reintroduced"
);
}
#[test]
fn shared_css_path_uses_runtime_dir() {
let _lock = crate::output::XDG_ENV_LOCK
.lock()
.unwrap_or_else(|e| e.into_inner());
std::env::set_var("XDG_RUNTIME_DIR", "/run/user/1234");
assert_eq!(
shared_css_path(),
std::path::PathBuf::from("/run/user/1234/bread/theme.css")
);
}
#[test]
fn stylesheet_resolved_inlines_color4_and_drops_named_refs_in_rules() {
let mut p = Palette::default();
p.color4 = "#7aa2f7".into();
let css = stylesheet_resolved(&p);
assert!(css.contains("#7aa2f7"), "color4 must appear as hex: {css}");
// Rule bodies must not keep named colors — GTK display-global
// @define-color would otherwise leak the wrong monitor's accent.
let rules = css
.lines()
.filter(|l| !l.trim_start().starts_with("@define-color"))
.collect::<Vec<_>>()
.join("\n");
assert!(
!rules.contains("@accent"),
"leftover @accent in rules:\n{rules}"
);
assert!(
!rules.contains("@on-bg"),
"leftover @on-bg in rules:\n{rules}"
);
assert!(
!rules.contains("@on-surface"),
"leftover @on-surface in rules:\n{rules}"
);
assert!(
!rules.contains("@on-accent"),
"leftover @on-accent in rules:\n{rules}"
);
// Longer names first: @on-bg must not become @on-#...
assert!(
!rules.contains("@on-#"),
"half-replaced on-* name:\n{rules}"
);
}
#[test]
fn render_is_nonempty_css() {
assert!(render().contains("@define-color bg "));
}
#[test]
fn hex_to_rgba_known_value() {
assert_eq!(hex_to_rgba("#1e1e2e", 1.0), "rgba(30, 30, 46, 1)");
}
#[test]
fn hex_to_rgba_strips_hash() {
let a = hex_to_rgba("#ffffff", 0.5);
let b = hex_to_rgba("ffffff", 0.5);
assert_eq!(a, b);
}
}