diff --git a/src/main.rs b/src/main.rs index cf98d3c..66c91b8 100644 --- a/src/main.rs +++ b/src/main.rs @@ -66,6 +66,108 @@ macro_rules! capsule_trace { /// how tall the drawer currently is. const DRAWER_MAX_HEIGHT_PX: i32 = 420; +/// `bar.window.exclusive` resolved to a layer-shell exclusive-zone pixel +/// value (axis 2, daylight). `Exclusive::Auto` reserves `height` plus the +/// margin on whichever edge the bar is actually anchored to — `margin.top` +/// for every theme through spotlight (all top-anchored), `margin.bottom` +/// for daylight's bottom-anchored dock. Before daylight this always read +/// `margin.top` unconditionally, which happened to be correct for every +/// existing theme (a bottom-anchored bar had never shipped, so +/// `margin.bottom` was always 0 and never observed) but reserved 12px too +/// little for daylight (`height` alone, not `height + margin.bottom`) — +/// tiled windows would have run up under the last 12px of the floating +/// dock. Extracted to a pure function so this is unit-testable without a +/// live layer-shell surface. +fn exclusive_zone_for(spec: &bread_theme::shell::WindowSpec) -> i32 { + let anchor_margin = if spec.anchors.iter().any(|a| a == "bottom") { + spec.margin.bottom + } else { + spec.margin.top + }; + match spec.exclusive { + Exclusive::Auto => spec.height + anchor_margin, + Exclusive::None => -1, + Exclusive::Px(px) => px, + } +} + +#[cfg(test)] +mod exclusive_zone_tests { + use super::exclusive_zone_for; + use bread_theme::shell::{Exclusive, Margin, Width, WindowSpec}; + + fn spec(anchors: &[&str], height: i32, margin: Margin, exclusive: Exclusive) -> WindowSpec { + WindowSpec { + anchors: anchors.iter().map(|s| s.to_string()).collect(), + width: Width::Fill, + height, + margin, + exclusive, + keyboard: bread_theme::shell::Keyboard::None, + layer: "top".to_string(), + } + } + + #[test] + fn top_anchored_auto_reserves_height_plus_top_margin() { + let s = spec( + &["top", "left", "right"], + 44, + Margin { + top: 12, + left: 16, + right: 16, + bottom: 0, + }, + Exclusive::Auto, + ); + assert_eq!(exclusive_zone_for(&s), 56); + } + + #[test] + fn bottom_anchored_auto_reserves_height_plus_bottom_margin_not_top() { + // The daylight case: margin.top is 0 (nothing reserved there), the + // real floating gap is margin.bottom. + let s = spec( + &["bottom", "left", "right"], + 40, + Margin { + top: 0, + left: 0, + right: 0, + bottom: 12, + }, + Exclusive::Auto, + ); + assert_eq!(exclusive_zone_for(&s), 52); + } + + #[test] + fn none_is_no_reservation_regardless_of_anchor() { + let s = spec( + &["bottom"], + 40, + Margin::default(), + Exclusive::None, + ); + assert_eq!(exclusive_zone_for(&s), -1); + } + + #[test] + fn explicit_px_wins_over_computed_auto_value() { + let s = spec( + &["bottom", "left", "right"], + 40, + Margin { + bottom: 12, + ..Margin::default() + }, + Exclusive::Px(99), + ); + assert_eq!(exclusive_zone_for(&s), 99); + } +} + pub struct BarInit { pub screenshot: Option, pub monitor: Option, @@ -324,6 +426,13 @@ impl SimpleComponent for App { root.set_margin(Edge::Top, window_spec.margin.top); root.set_margin(Edge::Left, window_spec.margin.left); root.set_margin(Edge::Right, window_spec.margin.right); + // `Margin::bottom` (axis 2, daylight): every builtin before daylight + // anchored top and left this at its struct default of 0, which is + // exactly why this call was missing entirely — see that field's own + // doc comment (bread-theme) for the gap this closes. A bottom- + // anchored theme's floating gap off the screen edge IS this margin, + // the same way a top-anchored theme's is `margin.top`. + root.set_margin(Edge::Bottom, window_spec.margin.bottom); // `Width::Fill` (Island/Edge): unset, exactly as before this // change — the surface stretches to the anchored left/right edges // on its own, with no explicit width request needed. `Width::Px` @@ -342,13 +451,18 @@ impl SimpleComponent for App { // regardless of what the app catalog contains. root.set_size_request(px, -1); } - // "auto" reserves height + top margin so tiled clients sit below the - // gap — see WindowSpec::exclusive's doc comment (bread-theme). - let exclusive_zone = match window_spec.exclusive { - Exclusive::Auto => window_spec.height + window_spec.margin.top, - Exclusive::None => -1, - Exclusive::Px(px) => px, - }; + // "auto" reserves height + the margin on the ANCHORED edge, so + // tiled clients sit clear of the gap — see WindowSpec::exclusive's + // doc comment (bread-theme). Every theme before daylight anchored + // top, so `margin.top` was the only edge that ever mattered here; + // reading it unconditionally under a bottom-anchored bar reserved + // only `height` (40px), 12px short of the dock's real footprint + // (`height` + `margin.bottom`) — tiled windows would have run up + // into the last 12px of the floating dock. `margin_for_exclusive` + // picks whichever edge's margin actually borders the reserved + // strip, same reasoning `exclusive_zone_for`'s own doc comment + // (tested standalone) spells out. + let exclusive_zone = exclusive_zone_for(&window_spec); root.set_exclusive_zone(exclusive_zone); // ANIMATION WORK #3: bar entrance on first map, Liquid Motion // only. The flush glass-workbench bar has no floating margin to @@ -433,6 +547,13 @@ impl SimpleComponent for App { let workspace_trail = bar::workspaces::WorkspaceTrail::new(); let workspace_box = workspace_trail.buttons.clone(); let workspace_row = gtk4::Box::new(gtk4::Orientation::Horizontal, 0); + // `.bar-segment` (axis 3, daylight): a no-op class under every other + // theme (theme.rs only ever emits a `.bar-segment` CSS rule when + // `bar_border == "segmented"` — see its `segment_css` local) — + // added unconditionally here, same as `center_area`/`stats_box` + // below, so the Rust side never has to branch on the active theme + // id to know which boxes are "the three segments". + workspace_row.add_css_class("bar-segment"); workspace_row.set_margin_start(8); workspace_row.set_valign(gtk4::Align::Center); workspace_row.set_vexpand(false); @@ -685,6 +806,7 @@ impl SimpleComponent for App { // Center area: [media_widget · widgets · clock · widgets] let center_area = gtk4::Box::new(gtk4::Orientation::Horizontal, 12); center_area.add_css_class("center-area"); + center_area.add_css_class("bar-segment"); center_area.set_valign(gtk4::Align::Center); center_area.set_vexpand(false); // `media_widget`/`clock_box` and any `widget:*` entries interleaved @@ -695,6 +817,7 @@ impl SimpleComponent for App { // Demo order: [vol 64] [wifi] [bat 83] [☰] let stats_box = gtk4::Box::new(gtk4::Orientation::Horizontal, 2); stats_box.add_css_class("stats-box"); + stats_box.add_css_class("bar-segment"); stats_box.set_margin_end(2); stats_box.set_valign(gtk4::Align::Center); stats_box.set_vexpand(false); diff --git a/src/surface.rs b/src/surface.rs index 7080c3b..88b094f 100644 --- a/src/surface.rs +++ b/src/surface.rs @@ -1,12 +1,16 @@ //! Applies a `[surfaces.]` entry (plan §4/§6, Phase 2) to a //! satellite layer-shell window: anchor, margin (from `offset`), width and -//! layer. Deliberately narrow — it only understands the three anchor shapes +//! layer. Deliberately narrow — it only understands the four anchor shapes //! breadbar's four built-in surfaces actually use today ("breadbar-notif", //! "breadbar-osd", "breadbar-panel", "breadbar-dismiss": `top_right`, -//! `bottom_centre`, `fill`), not a general anchor DSL (the plan's own -//! anti-goal, §2). `exclusive` zone and `keyboard` mode aren't part of the -//! `[surfaces.*]` schema (`bread_theme::shell::Surface` has no such fields) -//! and stay hardcoded at each call site, same as before this refactor. +//! `bottom_right`, `bottom_centre`, `fill`), not a general anchor DSL (the +//! plan's own anti-goal, §2). `bottom_right` was added for daylight (plan +//! §11 phase 7, axis 2) — the first bottom-anchored bar, whose satellites +//! need to hug the bottom-right corner the way every top-anchored theme's +//! already hug the top-right one. `exclusive` zone and `keyboard` mode +//! aren't part of the `[surfaces.*]` schema (`bread_theme::shell::Surface` +//! has no such fields) and stay hardcoded at each call site, same as before +//! this refactor. use bread_theme::shell::SurfaceWidth; use gtk4::prelude::*; @@ -54,6 +58,23 @@ pub fn apply(window: >k4::Window, namespace: &str) { window.set_margin(Edge::Right, right); window.set_margin(Edge::Top, top); } + // Axis 2 (daylight): every theme through spotlight anchors its bar + // to the TOP, so `top_right` always put breadbar-notif/ + // breadbar-panel naturally close to the bar. A bottom-anchored bar + // has nothing in the original three shapes that keeps its + // satellites near it — `top_right` would land them at the opposite + // corner of the screen from the dock they belong to. Mirrors + // `top_right` exactly, just on the bottom edge. + "bottom_right" => { + window.set_anchor(Edge::Bottom, true); + window.set_anchor(Edge::Right, true); + // offset = [right, bottom], same convention as top_right's + // [right, top]. + let right = surf.offset.first().copied().unwrap_or(0.0) as i32; + let bottom = surf.offset.get(1).copied().unwrap_or(0.0) as i32; + window.set_margin(Edge::Right, right); + window.set_margin(Edge::Bottom, bottom); + } "bottom_centre" => { window.set_anchor(Edge::Bottom, true); let bottom = surf.offset.first().copied().unwrap_or(0.0) as i32; @@ -63,15 +84,24 @@ pub fn apply(window: >k4::Window, namespace: &str) { for edge in [Edge::Top, Edge::Bottom, Edge::Left, Edge::Right] { window.set_anchor(edge, true); } - // Only a top margin is meaningful here — a fullscreen click-away - // scrim that starts below the bar rather than covering it. + // offset = [top, bottom] — a fullscreen click-away scrim that + // leaves a gap clear of the bar on whichever edge the bar + // actually anchors to. Every theme through spotlight anchors + // top, so only `offset[0]` (top) was ever meaningful before + // daylight; a single-value `offset` (every existing theme's + // manifest) still means exactly what it always did, since + // `offset.get(1)` falls back to 0 — a bottom-anchored theme is + // the first to give this a real, nonzero bottom value instead. let top = surf.offset.first().copied().unwrap_or(0.0) as i32; + let bottom = surf.offset.get(1).copied().unwrap_or(0.0) as i32; window.set_margin(Edge::Top, top); + window.set_margin(Edge::Bottom, bottom); } other => eprintln!( "breadbar: surfaces.{namespace}.anchor = \"{other}\" is not one of \ - top_right|bottom_centre|fill — breadbar's satellite windows don't \ - understand any other shape yet, leaving this window unanchored" + top_right|bottom_right|bottom_centre|fill — breadbar's satellite \ + windows don't understand any other shape yet, leaving this window \ + unanchored" ), } diff --git a/src/theme.rs b/src/theme.rs index ebf648d..6057e23 100644 --- a/src/theme.rs +++ b/src/theme.rs @@ -90,6 +90,28 @@ fn load_css() -> String { // and unconditional-but-unused block below), so this being "accent" vs // "green" vs "pink" per theme has no visible effect on them. let accent_from = tokens.accent_from(); + // `accent_to` (Trail's own gradient end stop) and `accent2` (a second, + // independent accent — daylight's amber equaliser, distinct from its + // teal workspace-trail) — both palette token names, never hex, same + // reasoning as `accent_from` above. + let accent_to = tokens.accent_to(); + let accent2 = tokens.accent2(); + + // Axis 1 (daylight, plan §11 phase 7): `tokens.light()` — see that + // method's doc comment (bread-theme) for the full reasoning. Every + // surface/ink pair in this stylesheet through spotlight hardcoded + // `@bg` (a FIXED, never-pywal-derived dark hex — see + // `bread_theme::palette`'s `FIXED_BACKGROUND`) as the translucent + // surface fill and `@on-bg` (that fixed dark colour's computed-legible, + // therefore always near-white, ink) as the text/wash colour. That is + // exactly backwards for an ink-on-paper theme: `panel`/`ink` swap which + // of those two FIXED, anti-correlated tokens plays which role. This is + // NOT a general "pick any light surface colour" mechanism — it works + // only because `@bg` is pinned dark and `@on-bg` is its computed + // opposite, by construction, regardless of pywal. See the task report + // for the full inventory of every site this swap had to reach. + let light = tokens.light(); + let (panel, ink): (&str, &str) = if light { ("@on-bg", "@bg") } else { ("@bg", "@on-bg") }; // `[launcher].search_radius` (plan §7 phase 6c) — `LauncherMode:: // Embedded` only (spotlight); `.launcher().radius` itself already // equals `radius_bar` for that theme (see its own theme.toml comment), @@ -108,12 +130,56 @@ fn load_css() -> String { // horizontal padding too: the flush bar's demo padding (`0 12px`, // symmetric) differs from the island's own asymmetric `0 8px 0 6px`. let flush = tokens.bar_border() == "bottom"; - let window_border = if flush { - "border: none; border-bottom: 1px solid alpha(@on-bg, 0.07);".to_string() + // Axis 3 (daylight, plan §11 phase 7): `bar_border == "segmented"` — + // `window.breadbar` itself draws NO fill/border/radius/shadow at all + // (fully transparent); the bar's three slot-group containers + // (`workspace_row`/`center_area`/`stats_box`, each carrying a + // `.bar-segment` class added unconditionally in main.rs) draw their own + // pill surfaces instead — see `segment_css` below. This is what lets + // one GTK window read as three detached floating pills rather than one + // continuous strip; see the task report for exactly what this can and + // can't express (the window is still ONE input-hit-testable surface — + // clicking in a transparent gap between pills does not click through to + // whatever's behind the bar, only true multi-window segmentation would + // do that). + let segmented = tokens.bar_border() == "segmented"; + let window_chrome = if segmented { + "background-color: transparent; border: none; box-shadow: none;".to_string() + } else if flush { + format!( + "background-color: alpha({panel}, {bg_alpha}); border: none; \ + border-bottom: 1px solid alpha({ink}, 0.07);" + ) } else { - "border: 1px solid alpha(@on-bg, 0.08);".to_string() + format!("background-color: alpha({panel}, {bg_alpha}); border: 1px solid alpha({ink}, 0.08);") + }; + let bar_radius = if segmented { + "0px".to_string() + } else { + radius_bar.clone() + }; + let centerbox_padding = if flush || segmented { "0 14px" } else { "0 8px 0 6px" }; + // The three detached pills themselves — a no-op empty rule under every + // non-segmented theme, so `.bar-segment` (added unconditionally to all + // three slot-group boxes in main.rs) renders nothing extra for them. + // Demo: `.seg { background: rgba(255,255,255,.94); border: 1px solid + // rgba(26,29,34,.10); border-radius: 14px; + // box-shadow: 0 2px 10px rgba(26,29,34,.13), 0 0 0 .5px rgba(255,255,255,.7) inset }`. + // Safe to pair a real box-shadow with an unblurred surface (axis 4: + // daylight's own `[compositor.breadbar].blur = false`) — a box-shadow + // above a BLURRED, `ignore_alpha`d surface is the shadow-halo bug fixed + // in breadbox by removing the shadow outright; with blur off there is + // no blur pass to catch this rectangle inside. + let segment_css = if segmented { + format!( + ".bar-segment {{ background-color: alpha({panel}, {bg_alpha}); \ + border: 1px solid alpha({ink}, 0.10); border-radius: {radius_bar}; \ + box-shadow: 0 2px 10px alpha({ink}, 0.13); }}\ + " + ) + } else { + String::new() }; - let centerbox_padding = if flush { "0 12px" } else { "0 8px 0 6px" }; // Radius for `.stat-pair` (vol/wifi/battery/hamburger chips): radius_sm // for liquid-motion (9px) and glass-workbench (6px, exact match to that @@ -169,15 +235,25 @@ fn load_css() -> String { // it's also used but for a different, already-correct reason. // Reported: "the pills on liquid motion just look off". bread_theme::shell::WorkspaceStyle::Trail => format!( - ".workspace-trail {{ background-image: linear-gradient(90deg, @accent, @teal);\ - background-color: @accent; border-radius: {radius_sm}; }}\ - .workspace-btn {{ background: transparent; opacity: 0.36; color: @on-bg;\ + // `@{{accent_from}}`/`@{{accent_to}}`, not the literal + // `@accent`/`@teal` this hardcoded through spotlight: harmless + // while every Trail-style theme's own accent_from/accent_to + // happened to BE "accent"/"teal" (liquid-motion — the only + // other Trail theme so far), but daylight sets + // accent_from = accent_to = "teal" for a FLAT fill, and the old + // hardcode would have painted a stray blue-to-teal gradient + // over it regardless of that setting. See Tokens::accent_to's + // doc comment (bread-theme) — this is the fix that finally + // consumes it for real. + ".workspace-trail {{ background-image: linear-gradient(90deg, @{accent_from}, @{accent_to});\ + background-color: @{accent_from}; border-radius: {radius_sm}; }}\ + .workspace-btn {{ background: transparent; opacity: 0.36; color: {ink};\ border-radius: {radius_sm}; border: none; outline: none; box-shadow: none;\ min-width: 28px; min-height: {chip_height_px}; margin: 0; padding: 0 7px;\ font-size: 22px; font-weight: bold;\ transition: opacity 0.22s {spring_settle},\ background-color 0.22s {spring_settle}; }}\ - .workspace-btn:hover {{ opacity: 0.85; background: alpha(@on-bg, 0.08); }}\ + .workspace-btn:hover {{ opacity: 0.85; background: alpha({ink}, 0.08); }}\ .workspace-btn.occupied {{ opacity: 0.78; }}\ .workspace-btn.active {{ background: transparent; color: @on-accent; opacity: 1; }}\ .workspace-btn.active:hover {{ background: transparent; }}\ @@ -186,14 +262,14 @@ fn load_css() -> String { bread_theme::shell::WorkspaceStyle::Pill => { let accent = theme.tokens().accent_from(); format!( - ".workspace-btn {{ background: transparent; opacity: 1; color: alpha(@on-bg, 0.4);\ + ".workspace-btn {{ background: transparent; opacity: 1; color: alpha({ink}, 0.4);\ border-radius: {radius_sm}; border: none; outline: none; box-shadow: none;\ min-width: 22px; min-height: {chip_height_px}; margin: 0; padding: 0 6px;\ font-size: 12px; font-weight: 600;\ transition: background-color 0.22s {spring_settle},\ color 0.22s {spring_settle}, opacity 0.22s {spring_settle}; }}\ - .workspace-btn:hover {{ background: alpha(@on-bg, 0.08); }}\ - .workspace-btn.occupied {{ color: alpha(@on-bg, 0.8); }}\ + .workspace-btn:hover {{ background: alpha({ink}, 0.08); }}\ + .workspace-btn.occupied {{ color: alpha({ink}, 0.8); }}\ .workspace-btn:not(.occupied):not(.active) {{ opacity: 0.35; }}\ .workspace-btn.active {{ background: @{accent}; color: @on-accent; opacity: 1; }}\ .workspace-btn.active:hover {{ background: @{accent}; }}\ @@ -221,12 +297,12 @@ fn load_css() -> String { // constant's own doc comment for why. This min-height // exists mainly so the property isn't silently absent from // the stylesheet a reader would expect to define it. - ".workspace-dot {{ background-color: alpha(@on-bg, 0.35); color: transparent;\ + ".workspace-dot {{ background-color: alpha({ink}, 0.35); color: transparent;\ border-radius: {radius_pill}; border: none; outline: none; box-shadow: none;\ min-height: 9px; margin: 0; padding: 0;\ transition: background-color 0.25s {spring_settle},\ opacity 0.25s {spring_settle}; }}\ - .workspace-dot:hover {{ background-color: alpha(@on-bg, 0.55); }}\ + .workspace-dot:hover {{ background-color: alpha({ink}, 0.55); }}\ .workspace-dot:not(.occupied):not(.active) {{ opacity: 0.35; }}\ .workspace-dot.active {{ background-color: @{accent}; opacity: 1; }}\ .workspace-dot.active:hover {{ background-color: @{accent}; }}", @@ -254,8 +330,7 @@ fn load_css() -> String { this class at all. */\ @keyframes bar-in {{ from {{ opacity: 0; }} }}\ .bar-entrance {{ animation: bar-in 0.4s {spring_settle} both; }}\ - window.breadbar {{ background-color: alpha(@bg, {bg_alpha}); color: @on-bg;\ - border-radius: {radius_bar}; {window_border}\ + window.breadbar {{ color: {ink}; border-radius: {bar_radius}; {window_chrome}\ transition: border-radius 0.3s {spring_settle}; }}\ /* `[launcher].search_radius` (plan §7 phase 6c, spotlight only —\ `launcher_entry` never gets focus under any other theme, so\ @@ -269,6 +344,7 @@ fn load_css() -> String { identical CSS either way. */\ window.breadbar > box > centerbox {{ padding: {centerbox_padding}; }}\ window.breadbar button {{ min-height: 0; min-width: 0; }}\ + {segment_css}\ {workspace_css}\ .clock-box {{ padding: 0 4px; }}\ .clock-label {{ font-size: 24px; font-weight: bold; letter-spacing: 0.04em;\ @@ -315,8 +391,8 @@ fn load_css() -> String { min-height: {chip_height_px};\ transition: background-color 0.22s {spring_settle},\ opacity 0.18s ease; }}\ - .stat-pair:hover {{ background: alpha(@on-bg, 0.12); }}\ - .stat-pair:active {{ background: alpha(@on-bg, 0.18); }}\ + .stat-pair:hover {{ background: alpha({ink}, 0.12); }}\ + .stat-pair:active {{ background: alpha({ink}, 0.18); }}\ /* No border-radius override here (was a hardcoded 999px, making\ wifi/hamburger — the only two `.icon-only` chips — fully\ circular while their row neighbours vol/battery stayed a\ @@ -335,13 +411,13 @@ fn load_css() -> String { .stat-pair.icon-only .stat-icon {{ margin: 0; }}\ .bt-icon {{ margin-right: 8px; }} separator.bar-sep {{ min-height: 12px; min-width: 1px; margin: 0 10px 0 2px;\ - background: alpha(@on-bg, 0.10); }}\ - window.breadbar-notification {{ background-color: transparent; color: @on-bg; }}\ - window.breadbar-history {{ background-color: alpha(@bg, 0.70); color: @on-bg;\ - border-radius: {radius}; border: 1px solid alpha(@on-bg, 0.10);\ + background: alpha({ink}, 0.10); }}\ + window.breadbar-notification {{ background-color: transparent; color: {ink}; }}\ + window.breadbar-history {{ background-color: alpha({panel}, 0.70); color: {ink};\ + border-radius: {radius}; border: 1px solid alpha({ink}, 0.10);\ animation: pop-in 0.45s {spring} both; }}\ - .notification-card {{ background: alpha(@bg, 0.70); color: @on-bg; border-radius: {radius};\ - padding: {pad}; margin-bottom: 8px; border: 1px solid alpha(@on-bg, 0.10);\ + .notification-card {{ background: alpha({panel}, 0.70); color: {ink}; border-radius: {radius};\ + padding: {pad}; margin-bottom: 8px; border: 1px solid alpha({ink}, 0.10);\ border-left: 3px solid transparent;\ animation: notif-in 0.45s {spring_settle} both; }}\ .notification-card.urgency-critical {{ border-left-color: @red; }}\ @@ -358,19 +434,19 @@ fn load_css() -> String { add vertical bulk the approved demo's own card never has. */\ .notification-dismiss {{ min-width: 18px; min-height: 18px; padding: 0;\ margin: 2px; border-radius: {radius_pill}; background: transparent;\ - color: @on-bg; opacity: 0.45; font-size: 12px; font-weight: bold;\ + color: {ink}; opacity: 0.45; font-size: 12px; font-weight: bold;\ border: none; outline: none; box-shadow: none;\ transition: background-color 0.18s {spring_settle}, opacity 0.18s ease; }}\ - .notification-dismiss:hover {{ opacity: 1; background: alpha(@on-bg, 0.16); }}\ - .notification-dismiss:active {{ background: alpha(@on-bg, 0.24); }}\ + .notification-dismiss:hover {{ opacity: 1; background: alpha({ink}, 0.16); }}\ + .notification-dismiss:active {{ background: alpha({ink}, 0.24); }}\ .history-title {{ font-weight: bold; font-size: 13px; }}\ .history-close {{ padding: 2px 8px; }}\ .history-empty {{ opacity: 0.5; padding: 8px 0; }}\ .history-time {{ opacity: 0.5; font-size: 11px; }}\ .history-body {{ opacity: 0.75; }}\ .history-card {{ margin-bottom: 6px; }}\ - window.breadbar-osd {{ background-color: alpha(@bg, 0.70); color: @on-bg;\ - border-radius: {radius_pill}; border: 1px solid alpha(@on-bg, 0.10);\ + window.breadbar-osd {{ background-color: alpha({panel}, 0.70); color: {ink};\ + border-radius: {radius_pill}; border: 1px solid alpha({ink}, 0.10);\ animation: osd-in 0.4s {spring_settle} both; }}\ .osd-icon {{ opacity: 0.85; margin-right: 8px; }}\ .osd-icon-muted {{ opacity: 0.35; }}\ @@ -379,8 +455,8 @@ fn load_css() -> String { border-radius: 3px; min-height: 6px; }}\ progressbar.osd-bar trough progress {{ background-image: none; background-color: @accent;\ border-radius: 3px; min-height: 6px; }}\ - window.breadbar-panel {{ background-color: alpha(@bg, 0.72); color: @on-bg;\ - border-radius: 14px; border: 1px solid alpha(@on-bg, 0.12); }}\ + window.breadbar-panel {{ background-color: alpha({panel}, 0.72); color: {ink};\ + border-radius: 14px; border: 1px solid alpha({ink}, 0.12); }}\ window.breadbar-dismiss, .breadbar-dismiss-hit {{\ background-color: alpha(#000000, 0.02); }}\ .popover-caret {{ min-height: 2px; margin: 2px 4px 10px; border-radius: 2px;\ @@ -389,9 +465,9 @@ fn load_css() -> String { animation: caret-draw 0.45s {spring} both; }}\ .wifi-popover-inner {{ min-width: 228px; padding: {pad}; }}\ window.wifi-popover button {{ min-height: 0; min-width: 0; }}\ - .popover-tab-row {{ background: alpha(@on-bg, 0.06); border-radius: 10px;\ + .popover-tab-row {{ background: alpha({ink}, 0.06); border-radius: 10px;\ padding: 3px; margin-bottom: 10px; }}\ - .popover-tab {{ background: transparent; color: @on-bg; border: none; box-shadow: none;\ + .popover-tab {{ background: transparent; color: {ink}; border: none; box-shadow: none;\ outline: none; border-radius: 999px; padding: 0 14px; min-height: 32px;\ font-size: 17px; font-weight: bold; opacity: 0.55;\ transition: background-color 0.22s {spring_settle},\ @@ -408,7 +484,7 @@ fn load_css() -> String { outline: none; border-radius: 10px; padding: 0 12px; min-height: 42px;\ transition: background-color 0.18s {spring_settle}; }}\ .wifi-popover-row label {{ font-size: 18px; }}\ - .wifi-popover-row:hover {{ background: alpha(@on-bg, 0.08); }}\ + .wifi-popover-row:hover {{ background: alpha({ink}, 0.08); }}\ .wifi-popover-row-active {{ background: alpha(@accent, 0.14); color: @accent; }}\ .wifi-popover-row-active:hover {{ background: alpha(@accent, 0.20); }}\ .row-in {{ animation: row-in 0.32s {spring} both; }}\ @@ -424,18 +500,18 @@ fn load_css() -> String { switch.bt-switch:checked:hover {{ min-width: 42px; min-height: 24px; padding: 2px;\ border: none; outline: none; box-shadow: none; background-image: none;\ border-radius: 99px; }}\ - switch.bt-switch {{ background-color: alpha(@on-bg, 0.14);\ + switch.bt-switch {{ background-color: alpha({ink}, 0.14);\ transition: background-color 0.25s {spring_settle}; }}\ switch.bt-switch:checked {{ background-color: @accent; }}\ switch.bt-switch slider {{ min-width: 20px; min-height: 20px; margin: 0;\ border-radius: 99px; border: none; outline: none; box-shadow: none;\ - background-image: none; background-color: @on-bg; }}\ - window.wifi-add-dialog {{ background-color: alpha(@bg, 0.70); color: @on-bg; min-width: 240px;\ - border-radius: {radius}; border: 1px solid alpha(@on-bg, 0.10);\ + background-image: none; background-color: {ink}; }}\ + window.wifi-add-dialog {{ background-color: alpha({panel}, 0.70); color: {ink}; min-width: 240px;\ + border-radius: {radius}; border: 1px solid alpha({ink}, 0.10);\ animation: pop-in 0.45s {spring} both; }}\ - window.wifi-add-dialog headerbar {{ background-color: alpha(@bg, 0.70); color: @on-bg;\ + window.wifi-add-dialog headerbar {{ background-color: alpha({panel}, 0.70); color: {ink};\ border-top-left-radius: {radius}; border-top-right-radius: {radius};\ - border-bottom: 1px solid alpha(@on-bg, 0.10); box-shadow: none; }}\ + border-bottom: 1px solid alpha({ink}, 0.10); box-shadow: none; }}\ .confirm-button {{ background-color: @accent; color: @on-accent; }}\ .confirm-button:hover {{ background-color: alpha(@accent, 0.85); }}\ /* min-height: decision #1 — the media chip is a bar chip like\ @@ -443,10 +519,10 @@ fn load_css() -> String { to its own eq-bar/label content. */\ .media-widget {{ border-radius: 10px; padding: 4px 8px; min-height: {chip_height_px};\ transition: background-color 0.22s {spring_settle}; }}\ - .media-widget:hover {{ background: alpha(@on-bg, 0.08); }}\ + .media-widget:hover {{ background: alpha({ink}, 0.08); }}\ .media-widget.media-in {{ animation: row-in 0.4s {spring} both; }}\ .media-eq {{ min-height: 14px; margin-right: 4px; }}\ - .media-eq-bar {{ min-width: 3px; min-height: 5px; background-color: @accent;\ + .media-eq-bar {{ min-width: 3px; min-height: 5px; background-color: @{accent2};\ border-radius: 2px; }}\ .media-widget.playing .media-eq-bar {{\ animation: media-eq 0.85s ease-in-out infinite alternate; }}\ @@ -457,7 +533,7 @@ fn load_css() -> String { .media-controls {{ padding: 4px; }}\ .media-btn {{ min-width: 32px; padding: 4px 8px; border-radius: {radius_sm};\ transition: background-color 0.18s ease; }}\ - .media-btn:hover {{ background: alpha(@on-bg, 0.10); }}\ + .media-btn:hover {{ background: alpha({ink}, 0.10); }}\ /* No padding/border-radius/min-width/min-height here (was\ `padding: 5px 8px; border-radius: 10px; min-width: 0;\ min-height: 0`): the hamburger is the only button carrying both\ @@ -475,8 +551,8 @@ fn load_css() -> String { background: transparent; border: none; outline: none; box-shadow: none;\ transition: background-color 0.22s {spring_settle},\ opacity 0.18s ease; }}\ - .control-panel-btn:hover {{ opacity: 1; background: alpha(@on-bg, 0.10); }}\ - .control-panel-btn:active {{ background: alpha(@on-bg, 0.16); }}\ + .control-panel-btn:hover {{ opacity: 1; background: alpha({ink}, 0.10); }}\ + .control-panel-btn:active {{ background: alpha({ink}, 0.16); }}\ .control-panel {{ }}\ .control-panel-inner {{ min-width: 248px; padding: {pad}; }}\ .sys-grid {{ margin: 2px 0 6px; }}\ @@ -488,7 +564,7 @@ fn load_css() -> String { .control-panel-row-label {{ font-size: 16px; opacity: 0.78; }}\ .control-panel-slider {{ margin: 0; padding: 0; min-height: 18px; }}\ scale.control-panel-slider trough {{ min-height: 6px; border-radius: 99px;\ - background-image: none; background-color: alpha(@on-bg, 0.12);\ + background-image: none; background-color: alpha({ink}, 0.12);\ border: none; outline: none; box-shadow: none; }}\ scale.control-panel-slider highlight {{ min-height: 6px; border-radius: 99px;\ background-image: none; background-color: @accent; }}\ @@ -499,14 +575,14 @@ fn load_css() -> String { .sink-row label {{ font-size: 15px; }}\ .power-row {{ margin-top: 8px; }}\ .power-btn {{ min-width: 0; min-height: 0; padding: 8px 10px; border-radius: 8px;\ - background: alpha(@on-bg, 0.08); font-size: 13px; border: none;\ + background: alpha({ink}, 0.08); font-size: 13px; border: none;\ outline: none; box-shadow: none;\ transition: background-color 0.2s {spring_settle}; }}\ - .power-btn:hover {{ background: alpha(@on-bg, 0.14); }}\ + .power-btn:hover {{ background: alpha({ink}, 0.14); }}\ .power-btn:active {{ background: alpha(@accent, 0.22); }}\ .notification-action {{ transition: background-color 0.18s ease; }}\ .tray-btn {{ transition: opacity 0.2s ease, background-color 0.2s ease; }}\ - separator {{ margin: 4px 0; background: alpha(@on-bg, 0.10); }}\ + separator {{ margin: 4px 0; background: alpha({ink}, 0.10); }}\ /* Lua-declared widgets (see Documentation.md's Widgets §style): the\ slot rule below is what the four inline `.bread-widget-slot`\ containers in main.rs rely on for the same 12px stat-pair rhythm\ @@ -562,20 +638,20 @@ fn load_css() -> String { are built regardless of the active theme (see main.rs's \"Assemble\"\ section), just never placed in a slot outside spotlight, so these\ rules render nothing on liquid-motion/glass-workbench. */\ - .launcher-entry {{ background: transparent; color: @on-bg; border: none;\ + .launcher-entry {{ background: transparent; color: {ink}; border: none;\ outline: none; box-shadow: none; caret-color: @{accent_from};\ font-size: 13px; font-weight: 500; letter-spacing: 0.06em;\ padding: 0; margin: 0; min-height: 0; }}\ .launcher-entry.searching {{ font-size: 15px; letter-spacing: 0; }}\ .bread-drawer {{ min-height: 0; }}\ - .bread-drawer.open {{ border-top: 1px solid alpha(@on-bg, 0.08);\ + .bread-drawer.open {{ border-top: 1px solid alpha({ink}, 0.08);\ margin-top: 6px; padding-top: 2px; }}\ .bread-drawer listbox {{ background: transparent; padding: 2px 0; }}\ .bread-drawer row {{ padding: 8px 14px; border-radius: {radius_sm};\ - color: @on-bg; background-color: transparent; }}\ - .bread-drawer row:hover {{ background-color: alpha(@on-bg, 0.08); }}\ + color: {ink}; background-color: transparent; }}\ + .bread-drawer row:hover {{ background-color: alpha({ink}, 0.08); }}\ .bread-drawer row:selected {{ background-color: alpha(@{accent_from}, 0.18);\ - color: @on-bg; }}\ + color: {ink}; }}\ .bread-drawer .app-name {{ font-size: 14px; font-weight: 500; }}\ .bread-drawer .app-muted {{ opacity: 0.45; font-size: 11px; }}\ /* `[launcher].sections` (plan §7 phase 6c) — the idle drawer's\ @@ -587,21 +663,20 @@ fn load_css() -> String { .section-header-label {{ font-size: 11px; font-weight: 600;\ letter-spacing: 0.08em; text-transform: uppercase;\ opacity: 0.45; }}", - radius = radius, - radius_search = radius_search, - radius_bar = radius_bar, - radius_sm = radius_sm, - radius_pill = radius_pill, - chip_radius = chip_radius, - chip_height_px = chip_height_px, - pad = pad, - spring = spring, - spring_settle = spring_settle, - window_border = window_border, - centerbox_padding = centerbox_padding, - workspace_css = workspace_css, - bg_alpha = bg_alpha, - accent_from = accent_from, + // Implicit capture (2021 edition) for every `{name}` above: each + // matches an in-scope `let` binding of the same name (`radius`, + // `spring`, `ink`, `panel`, `bar_radius`, `window_chrome`, + // `segment_css`, `accent_from`, `accent2`, ...) rather than a hand- + // maintained `name = name,` list — axis 1's `panel`/`ink` swap and + // axis 3's `segment_css`/`window_chrome`/`bar_radius` locals both + // needed a growing, easy-to-desync explicit list to stay in step + // with the string body above; switching the whole call to implicit + // capture removes that failure mode instead of extending it further. + // (`radius_bar`, `flush`, `light`, `segmented`, `window_border` + // itself, and `accent_to` are each read by name ABOVE this literal, + // not inside it, so they're deliberately absent here — an unused + // implicit-capture name is a hard compile error, same discipline + // this crate already applies to unread `theme.toml` keys.) ) }