shell themes: add Daylight — bottom-anchored, segmented, light builtin

Fourth compiled-in shell theme (bread-theme/src/shell/builtin.rs). Stresses
four axes no existing theme touched:

- light surfaces (new tokens.light flag, swaps which of the fixed @bg/
  @on-bg pair plays paper-surface vs ink — see Tokens::light's doc comment)
- bottom anchoring (bar.window.anchors = ["bottom", ...])
- segmented bar chrome (new bar_border = "segmented" value: the bar window
  itself draws no fill/border, three slot-group pills draw their own)
- blur disabled per-theme ([compositor.*] blur = false everywhere)

Also: a new bottom_right surface anchor (manifest.rs + types.rs), since the
three existing anchor shapes all assume a top-anchored bar's satellites
belong in the top-right corner. tokens.accent2() gives the equaliser its
own accent independent of accent_from/accent_to.
This commit is contained in:
Breadway 2026-08-26 20:14:42 +08:00
parent 80e25f23b3
commit 6d9912af56
6 changed files with 665 additions and 22 deletions

View file

@ -47,6 +47,18 @@ const SPOTLIGHT_CSS: &str = include_str!(concat!(
"/assets/shell/spotlight/spotlight.css"
));
pub const DAYLIGHT_ID: &str = "daylight";
const DAYLIGHT_TOML: &str = include_str!(concat!(
env!("CARGO_MANIFEST_DIR"),
"/assets/shell/daylight/theme.toml"
));
const DAYLIGHT_CSS: &str = include_str!(concat!(
env!("CARGO_MANIFEST_DIR"),
"/assets/shell/daylight/daylight.css"
));
/// One compiled-in theme's identity plus its two `include_str!`ed assets.
/// `id`/`name` are also duplicated inside `toml`'s own `id =`/`name =`
/// fields — kept here too so [`all`]/[`find`] can list/look up a builtin
@ -86,6 +98,12 @@ pub const ALL: &[BuiltinTheme] = &[
toml: SPOTLIGHT_TOML,
css: SPOTLIGHT_CSS,
},
BuiltinTheme {
id: DAYLIGHT_ID,
name: "Daylight",
toml: DAYLIGHT_TOML,
css: DAYLIGHT_CSS,
},
];
/// Looks up a compiled-in theme by id — `None` means "not a builtin",

View file

@ -451,11 +451,11 @@ fn resolve_surfaces(
let Some(raw) = raw else { return Ok(out) };
for (namespace, s) in raw {
let anchor = match s.anchor.as_deref() {
Some(a @ ("top_right" | "bottom_centre" | "fill")) => a.to_string(),
Some(a @ ("top_right" | "bottom_right" | "bottom_centre" | "fill")) => a.to_string(),
Some(other) => bail!(
"theme '{theme_id}': surfaces.{namespace}.anchor = \"{other}\" is not \
top_right|bottom_centre|fill (the only shapes breadbar's satellite \
windows implement see breadbar/src/surface.rs)"
top_right|bottom_right|bottom_centre|fill (the only shapes breadbar's \
satellite windows implement see breadbar/src/surface.rs)"
),
None => bail!(
"theme '{theme_id}': surfaces.{namespace} has no anchor set \

View file

@ -938,6 +938,166 @@ mod tests {
);
}
// ---- daylight builtin (plan §11 phase 7) -------------------------
#[test]
fn daylight_loads_and_appears_in_list_alongside_the_other_three() {
let theme = load_named(builtin::DAYLIGHT_ID).expect("daylight builtin should resolve");
assert_eq!(theme.id(), "daylight");
assert_eq!(theme.name(), "Daylight");
let summaries = list();
assert!(
summaries
.iter()
.any(|s| s.id == "daylight" && s.source == ThemeSource::Builtin),
"daylight missing from list(): {summaries:?}"
);
// All four builtins must be listed side by side.
for id in ["liquid-motion", "glass-workbench", "spotlight"] {
assert!(
summaries
.iter()
.any(|s| s.id == id && s.source == ThemeSource::Builtin),
"{id} missing from list() after adding daylight: {summaries:?}"
);
}
}
#[test]
fn daylight_is_bottom_anchored_unlike_every_other_builtin() {
let theme = load_named(builtin::DAYLIGHT_ID).expect("daylight should resolve");
let w = theme.window();
assert_eq!(
w.anchors,
vec!["bottom", "left", "right"],
"daylight must anchor bottom, not top like its three siblings"
);
assert_eq!(w.height, 40);
assert_eq!(
w.margin,
Margin {
top: 0,
left: 0,
right: 0,
bottom: 12,
},
"the floating gap must be a BOTTOM margin, not a top one"
);
assert!(matches!(w.width, Width::Fill));
assert!(matches!(w.exclusive, Exclusive::Auto));
}
#[test]
fn daylight_compositor_rules_disable_blur_everywhere() {
let theme = load_named(builtin::DAYLIGHT_ID).expect("daylight should resolve");
let rules = theme.compositor_rules();
for ns in [
"breadbar",
"breadbar-osd",
"breadbar-notif",
"breadbar-panel",
"breadbox",
] {
assert!(
!rules[ns].blur,
"compositor.{ns}.blur must be false under daylight — a theme must be able \
to DISABLE a compositor rule, not just tune it"
);
}
assert!(!rules["breadbar"].blur_popups);
assert_eq!(rules["breadbar"].animation.as_deref(), Some("slide bottom"));
// No ignore_alpha anywhere: that key only means something to a
// blurred surface, and setting it on an unblurred one would be a
// never-consumed value.
for ns in ["breadbar", "breadbar-osd", "breadbar-notif", "breadbar-panel"] {
assert!(
rules[ns].ignore_alpha.is_none(),
"compositor.{ns}.ignore_alpha should be unset when blur is off"
);
}
}
#[test]
fn daylight_satellites_anchor_bottom_right_not_top_right() {
let theme = load_named(builtin::DAYLIGHT_ID).expect("daylight should resolve");
let surfaces = theme.surfaces();
for ns in ["breadbar-notif", "breadbar-panel"] {
assert_eq!(
surfaces[ns].anchor, "bottom_right",
"{ns} must sit near the bottom-anchored dock, not the top-right corner \
every top-anchored sibling theme uses"
);
}
assert_eq!(surfaces["breadbar-osd"].anchor, "bottom_centre");
// "fill" (breadbar-dismiss) now carries a [top, bottom] pair: 0 at
// the top (nothing to clear there) and 52 at the bottom (the dock's
// own edge), the inverse of every top-anchored sibling's single
// top-only offset.
assert_eq!(surfaces["breadbar-dismiss"].offset, vec![0.0, 52.0]);
}
#[test]
fn daylight_is_light_and_segmented() {
let theme = load_named(builtin::DAYLIGHT_ID).expect("daylight should resolve");
assert!(theme.tokens().light(), "daylight must set tokens.light = true");
assert_eq!(theme.tokens().bar_border(), "segmented");
}
#[test]
fn daylight_accent_is_teal_and_a_palette_token_not_hex_with_a_distinct_amber_accent2() {
let theme = load_named(builtin::DAYLIGHT_ID).expect("daylight should resolve");
let t = theme.tokens();
assert_eq!(t.accent_from(), "teal");
assert_eq!(t.accent_to(), "teal");
assert_eq!(t.accent2(), "yellow");
let css = theme.css(&crate::Palette::default());
assert!(
css.contains("@teal"),
"accent_from/accent_to must resolve to the @teal palette token, not a hex literal:\n{css}"
);
assert!(
css.contains("@yellow"),
"accent2 must resolve to the @yellow palette token, not a hex literal:\n{css}"
);
assert!(
!css.contains("#2f6d7a") && !css.contains("#c2683c"),
"the demo's teal/amber hex must never leak into the manifest — pywal theming \
depends on this staying palette token names:\n{css}"
);
}
#[test]
fn light_token_defaults_false_for_every_other_builtin() {
// The other three builtins must render unchanged — this flag must
// not flip anything unless a theme explicitly opts in.
for id in [
builtin::LIQUID_MOTION_ID,
builtin::GLASS_WORKBENCH_ID,
builtin::SPOTLIGHT_ID,
] {
let theme = load_named(id).unwrap_or_else(|e| panic!("{id} should resolve: {e:#}"));
assert!(!theme.tokens().light(), "{id} must default tokens.light to false");
}
}
#[test]
fn bottom_right_surface_anchor_resolves_like_its_siblings() {
let xdg = isolated_xdg();
write_theme(
&xdg,
"bottomright",
r#"
id = "bottomright"
[surfaces."breadbar-notif"]
anchor = "bottom_right"
offset = [16, 64]
"#,
);
let theme = load_named("bottomright").expect("bottom_right anchor should resolve");
assert_eq!(theme.surfaces()["breadbar-notif"].anchor, "bottom_right");
}
// ---- extends merge ------------------------------------------------
#[test]
@ -1147,7 +1307,7 @@ mod tests {
#[test]
fn every_known_surface_anchor_shape_resolves() {
for anchor in ["top_right", "bottom_centre", "fill"] {
for anchor in ["top_right", "bottom_right", "bottom_centre", "fill"] {
let xdg = isolated_xdg();
write_theme(
&xdg,

View file

@ -277,13 +277,23 @@ pub struct Launcher {
/// one lookup.
#[derive(Debug, Clone, PartialEq)]
pub struct Surface {
/// One of `top_right`/`bottom_centre`/`fill` — validated in
/// `manifest.rs::resolve_surfaces` against the shapes
/// One of `top_right`/`bottom_right`/`bottom_centre`/`fill` — validated
/// in `manifest.rs::resolve_surfaces` against the shapes
/// `breadbar/src/surface.rs::apply` actually implements, the same
/// "typo'd key is a hard error" policy this field's siblings
/// (`width`, `layer`) already got. Required, not defaulted: a surface
/// entry with no anchor at all is as much a hard `theme.toml` error as
/// an unrecognized one.
///
/// `bottom_right` (daylight, plan §11 phase 7) added alongside the
/// original three: every built-in theme before it anchored its bar to
/// the TOP, so `breadbar-notif`/`breadbar-panel` popping up from
/// `top_right` always sat naturally close to the bar. A bottom-anchored
/// bar has no shape in the original three that keeps those satellites
/// near it — `top_right` would put them at the opposite corner of the
/// screen from the dock they visually belong to. `offset` is
/// `[right, bottom]` for this anchor, the same two-element convention
/// `top_right`'s `[right, top]` already uses.
pub anchor: String,
pub offset: Vec<f64>,
pub width: SurfaceWidth,
@ -404,6 +414,19 @@ impl Tokens {
}
}
/// Same fallback shape as [`Self::str_or`]/[`Self::int_or`]/
/// [`Self::float_or`] for a `TokenValue::Bool`. First consumer: `light`
/// below (theme 05/daylight, plan §11 phase 7) — every existing token is
/// a string/number, this is the first bool-shaped one, hence the new
/// helper rather than reusing one of the three above.
fn bool_or(&self, key: &str, default: bool) -> bool {
match self.map.get(key) {
Some(TokenValue::Bool(b)) => *b,
Some(TokenValue::Str(s)) => s.parse().unwrap_or(default),
_ => default,
}
}
/// Consumed by `breadbox::main::build_css` for the launcher panel's
/// `font-family` (the `entry.search`/`row` rule) as of the
/// liquid-motion/glass-workbench redesign — combined with
@ -465,24 +488,62 @@ impl Tokens {
pub fn accent_from(&self) -> String {
self.str_or("accent_from", "accent")
}
/// Declared-but-not-yet-consumed in production, for a subtler reason
/// than most of this file's other "never read" fields: this crate's own
/// CSS templates DO read `{accent_to}` (the `WorkspaceStyle::Trail`
/// gradient stop in `assets/shell/liquid-motion/liquid-motion.css`,
/// exercised by `super::ShellTheme::css` and this module's own tests),
/// but breadbar
/// never calls that method — its hand-rolled Trail CSS
/// (`breadbar::theme::load_css`) hardcodes the literal gradient
/// `linear-gradient(90deg, @accent, @teal)` instead of substituting
/// `accent_from`/`accent_to`, so a theme that set a *different*
/// `accent_to` than liquid-motion's "teal" would see no change in the
/// running bar. Every built-in theme still sets this key so the
/// manifest states its actual design intent; see each `theme.toml`'s
/// own note next to it.
/// Was declared-but-not-yet-consumed in production through theme 04
/// (spotlight): breadbar's hand-rolled Trail CSS hardcoded the literal
/// gradient `linear-gradient(90deg, @accent, @teal)` instead of
/// substituting `accent_from`/`accent_to`, silently correct only because
/// liquid-motion's own `accent_from`/`accent_to` happened to be
/// `"accent"`/`"teal"` — the exact two names the hardcode already spelled
/// out. Theme 05 (daylight, plan §11 phase 7) is the first Trail-style
/// theme to set a *different* pair (`accent_from = accent_to = "teal"`,
/// a flat fill, not a gradient), which is what finally forced
/// `breadbar::theme::load_css`'s Trail branch to read this method for
/// real instead of the two literal names — see that function's own note
/// next to `.workspace-trail`.
pub fn accent_to(&self) -> String {
let from = self.accent_from();
self.str_or("accent_to", &from)
}
/// A second, independent accent for chrome that shouldn't track the
/// primary `accent_from`/`accent_to` pair — daylight (plan §11 phase 7)
/// is the first theme that needs one: its media-widget equaliser bars
/// are warm amber while the workspace trail/active-fill accent is deep
/// teal, so one `accent_from` value can no longer describe both.
/// Defaults to `accent_from` itself, which reproduces every earlier
/// theme's actual rendering byte-for-byte (liquid-motion/glass-
/// workbench/spotlight all paint their equaliser with the same accent
/// as everything else, and none of them sets this key). Consumed by
/// `breadbar::theme::load_css`'s `.media-eq-bar` rule.
pub fn accent2(&self) -> String {
let from = self.accent_from();
self.str_or("accent2", &from)
}
/// Whether this theme's surfaces are painted ink-on-paper (near-opaque
/// LIGHT fills with dark ink) rather than the glass-on-dark look every
/// earlier theme assumed — daylight (plan §11 phase 7) is the first
/// theme to set this `true`. Defaults `false`, reproducing every
/// existing theme's rendering unchanged.
///
/// Exists because the palette's `bg`/`surface`/`overlay`/`fg` slots are
/// NEVER pywal-derived (`bread_theme::palette`'s `FIXED_*` constants —
/// deliberately, so a bright wallpaper can't turn the whole UI an
/// unreadable light colour by accident) — only the six accent slots
/// vary. That means there is no palette token whose value is a light
/// "paper" surface for a theme to reference by name; `@bg` is always
/// dark, `@on-bg` is always its computed-legible near-white ink.
/// `breadbar::theme::load_css` reads this flag to swap which of those
/// two *fixed, anti-correlated* tokens plays "surface fill" versus
/// "ink" for every translucent card/panel/hover-wash in the stylesheet
/// (`@on-bg` as the near-white fill, `@bg` as the near-black ink,
/// otherwise the reverse) — see that function's own `panel`/`ink`
/// locals. This works only because `@bg`/`@on-bg` are pinned opposite
/// constants by construction; it is not a general "pick any light
/// surface colour" mechanism, and this doc comment is the canonical
/// place that fact is written down (see the task report for the full
/// reasoning: the palette schema has no dedicated light-surface token).
pub fn light(&self) -> bool {
self.bool_or("light", false)
}
/// Workspace-pill / chip height. Not in the plan §4 schema, but
/// `breadbar::CHIP_HEIGHT` (32) today. See [`Tokens`] doc.
pub fn chip_height(&self) -> i64 {
@ -498,8 +559,14 @@ impl Tokens {
/// edge-to-edge bar, plan §1) draws only the bottom hairline the demo's
/// `.bar { border-bottom: 1px solid #ffffff12 }` calls for — a floating
/// island's full border would otherwise render as a stray top/side line
/// flush against the screen edge. See [`Tokens`] doc; consumed by
/// `breadbar::theme::load_css`, not by [`crate::shell::ShellTheme::css`].
/// flush against the screen edge. `"segmented"` (daylight, plan §11
/// phase 7) is a third value: `window.breadbar` itself gets NO fill,
/// border, or radius at all (fully transparent), and the bar's three
/// slot-group containers each carry their own pill surface instead (see
/// `breadbar::theme::load_css`'s `segmented`/`.bar-segment` locals) —
/// this is what lets one bar surface look like three detached floating
/// pills rather than one continuous strip. See [`Tokens`] doc; consumed
/// by `breadbar::theme::load_css`, not by [`crate::shell::ShellTheme::css`].
pub fn bar_border(&self) -> String {
self.str_or("bar_border", "full")
}