bar: implement glass-workbench's three module variants (Phase 5)

Wires modules.workspaces.style/modules.clock.style into the bar for real
(Phase 3 shipped the schema but only ever consumed trail/flip), and adds
cpu/ram as bar modules, so glass-workbench (bread-theme) renders correctly
while liquid-motion's default path is untouched:

- workspaces: "pill" never calls WorkspaceTrail::place/stretch (the trail
  overlay stays invisible) and honours modules.workspaces.show_empty for
  real, rendering unoccupied non-active workspaces dimmed via CSS instead
  of filtering them out of the row. "trail" keeps the exact pre-existing
  filter/place/stretch behaviour regardless of show_empty.
- clock: "plain" registers a plain date+time label (date_lbl, built but
  never parented until now, plus a new clock_plain_lbl) instead of the
  per-digit flip box. "flip" is untouched.
- cpu/ram: two new bar chips (bar_cpu_pair/bar_ram_pair, bar_cpu_lbl/
  bar_ram_lbl) fed by the same AppInput::StatsUpdate data as the control
  panel's cpu_pair/mem_pair. Separate instances rather than reparenting
  the panel's own pair — a GTK widget can only have one parent, and
  reparenting would pull them out of the control panel's sys-grid, which
  no theme asked to change.

theme.rs::load_css() now branches on tokens.bar_border() (full-border
island vs. flush bar's bottom-only hairline) and modules.workspaces.style
(dimmed/translucent trail-fill buttons vs. solid-accent-fill pills), and
unconditionally gains .clock-plain/.clock-plain-time rules. The trail/flip
branches reproduce today's CSS byte-for-byte.

Verification (bread-capture, isolated headless-Sway harness):
- Noise floor: same baseline binary against itself varies from AE=0 (same
  capture minute) up to ~1900px (an in-flight digit-flip/clock-tick or a
  live Wi-Fi scan straddling two captures) and ~39000px on control-panel
  (live hardware sensors) — all pre-existing, not introduced by this change.
- Regression: this binary vs. the pre-Phase-5 baseline, captured in the
  same clock-minute to remove the dominant noise source, is AE=0 (bit-for-
  bit identical) on the bar view under the default liquid-motion theme.
- glass-workbench (BREAD_SHELL_THEME=glass-workbench): bar view is
  1920x36 (flush, no margin) vs. liquid-motion's 1920x56 (12px margin +
  44px island), square corners vs. liquid-motion's rounded island, plain
  "Mon 24/08 23:24" clock, and cpu/ram/wifi/battery/control chips with no
  media widget — matches demo 02. The isolated Sway capture harness has no
  live Hyprland IPC, so it reports zero workspaces under both themes
  (pre-existing, unrelated to this change) — the pill fill/dim CSS itself
  is exercised by code review and the trail-untouched regression result,
  not by a captured pixel with visible buttons.

cargo test: 18/18 passing (breadbar), no new clippy/cargo-check warnings
beyond the two pre-existing ones this task named up front.
This commit is contained in:
Breadway 2026-08-24 23:26:34 +08:00
parent 8d80a05d9b
commit df6b568cc1
3 changed files with 213 additions and 28 deletions

View file

@ -18,6 +18,19 @@ pub fn current() -> String {
format!("{} {}", date(), time()) format!("{} {}", date(), time())
} }
/// `modules.clock.format` rendered against GLib's own `DateTime::format`
/// (a strftime subset — `%H`, `%M`, `%a`, `%d`, `%m`, ... all work). Falls
/// back to [`time`]'s hardcoded "HH:MM" on a malformed format string rather
/// than propagating an error — a broken theme's clock format must degrade,
/// not crash the bar, same as every other "malformed theme" fallback in
/// this system.
pub fn formatted(format: &str) -> String {
now()
.format(format)
.map(|s| s.to_string())
.unwrap_or_else(|_| time())
}
pub fn spawn_ticker(sender: ComponentSender<App>) { pub fn spawn_ticker(sender: ComponentSender<App>) {
relm4::spawn(async move { relm4::spawn(async move {
loop { loop {

View file

@ -13,7 +13,7 @@ mod surface;
mod theme; mod theme;
mod widgets; mod widgets;
use bread_theme::shell::{Exclusive, Keyboard}; use bread_theme::shell::{ClockStyle, Exclusive, Keyboard, WorkspaceStyle};
use gtk4::prelude::*; use gtk4::prelude::*;
use gtk4_layer_shell::{Edge, KeyboardMode, Layer, LayerShell}; use gtk4_layer_shell::{Edge, KeyboardMode, Layer, LayerShell};
use hyprland::data::Workspace; use hyprland::data::Workspace;
@ -45,6 +45,12 @@ pub struct App {
time_str: String, time_str: String,
clock_digits: Vec<gtk4::Label>, clock_digits: Vec<gtk4::Label>,
date_lbl: gtk4::Label, date_lbl: gtk4::Label,
// `modules.clock.style = "plain"` (glass-workbench, Phase 5): a plain
// "HH:MM" label with no per-digit flip. Built alongside `clock_digits`
// regardless of the active theme's style so switching styles needs no
// recompile; only one of the two ever lands in a `[bar.slots]` module
// registration (see the "Assemble" section).
clock_plain_lbl: gtk4::Label,
// ── Stats bar ───────────────────────────────────────────────────────── // ── Stats bar ─────────────────────────────────────────────────────────
// Island chrome matches the Liquid Motion demo: volume / wifi / battery // Island chrome matches the Liquid Motion demo: volume / wifi / battery
@ -58,6 +64,14 @@ pub struct App {
cpu_lbl: gtk4::Label, cpu_lbl: gtk4::Label,
mem_lbl: gtk4::Label, mem_lbl: gtk4::Label,
pwr_lbl: gtk4::Label, pwr_lbl: gtk4::Label,
// `[bar.slots].right = [..., "cpu", "ram", ...]` (glass-workbench, Phase
// 5): separate instances from `cpu_pair`/`mem_pair` above, which stay
// parented in the control panel's sys-grid — a GTK widget can only have
// one parent, so reusing those here would mean reparenting them out of
// the panel, changing panel behaviour no theme asked to change. Fed by
// the same `AppInput::StatsUpdate` data.
bar_cpu_lbl: gtk4::Label,
bar_ram_lbl: gtk4::Label,
gpu_lbl: gtk4::Label, gpu_lbl: gtk4::Label,
vol_lbl: gtk4::Label, vol_lbl: gtk4::Label,
bat_lbl: gtk4::Label, bat_lbl: gtk4::Label,
@ -388,6 +402,22 @@ impl SimpleComponent for App {
date_lbl.add_css_class("date-label"); date_lbl.add_css_class("date-label");
date_lbl.set_visible(false); date_lbl.set_visible(false);
// `modules.clock.style = "plain"` (glass-workbench): date_lbl above
// plus one plain "HH:MM" label, no per-digit flip markup at all.
// Built unconditionally alongside the flip clock so a theme's style
// choice is just which of the two gets registered into the "clock"
// slot below — see "Assemble".
let clock_plain_lbl = gtk4::Label::new(Some(&bar::clock::time()));
clock_plain_lbl.add_css_class("clock-plain-time");
clock_plain_lbl.set_valign(gtk4::Align::Center);
clock_plain_lbl.set_vexpand(false);
let clock_plain_box = gtk4::Box::new(gtk4::Orientation::Horizontal, 8);
clock_plain_box.add_css_class("clock-plain");
clock_plain_box.set_valign(gtk4::Align::Center);
clock_plain_box.set_vexpand(false);
clock_plain_box.append(&date_lbl);
clock_plain_box.append(&clock_plain_lbl);
// Center area: [media_widget · widgets · clock · widgets] // Center area: [media_widget · widgets · clock · widgets]
let center_area = gtk4::Box::new(gtk4::Orientation::Horizontal, 12); let center_area = gtk4::Box::new(gtk4::Orientation::Horizontal, 12);
center_area.add_css_class("center-area"); center_area.add_css_class("center-area");
@ -420,6 +450,18 @@ impl SimpleComponent for App {
pair.set_hexpand(true); pair.set_hexpand(true);
} }
gpu_pair.set_visible(false); gpu_pair.set_visible(false);
// `[bar.slots].right = [..., "cpu", "ram", ...]` (glass-workbench,
// Phase 5): separate chip instances from `cpu_pair`/`mem_pair`
// above, which stay parented in the control panel's sys-grid below
// — reusing them here would mean reparenting them out of the panel.
// Same icons, same `.stat-pair` chip styling every other bar chip
// (volume/battery) already uses; fed by the same `StatsUpdate` data.
let bar_cpu_lbl = stat_label();
let bar_ram_lbl = stat_label();
let bar_cpu_pair = stat_pair(asset!("CPU.svg"), &bar_cpu_lbl);
let bar_ram_pair = stat_pair(asset!("RAM Usage.svg"), &bar_ram_lbl);
let system_stats_box = gtk4::Box::new(gtk4::Orientation::Vertical, 4); let system_stats_box = gtk4::Box::new(gtk4::Orientation::Vertical, 4);
system_stats_box.add_css_class("sys-grid"); system_stats_box.add_css_class("sys-grid");
let sys_row1 = gtk4::Box::new(gtk4::Orientation::Horizontal, 8); let sys_row1 = gtk4::Box::new(gtk4::Orientation::Horizontal, 8);
@ -721,11 +763,32 @@ impl SimpleComponent for App {
let mut bar_modules = bar::slots::ModuleRegistry::new(); let mut bar_modules = bar::slots::ModuleRegistry::new();
bar_modules.register("workspaces", &workspace_trail.overlay); bar_modules.register("workspaces", &workspace_trail.overlay);
bar_modules.register("media", &media_widget); bar_modules.register("media", &media_widget);
bar_modules.register("clock", &clock_box); // `modules.clock.style`: "flip" (default, liquid-motion) registers
// the existing per-digit clock_box unchanged; "plain" (glass-
// workbench) registers clock_plain_box instead and reveals date_lbl
// per `show_date` — clock_box/clock_digits are still fully built in
// that case, just never placed in any slot. "none" (Phase 6+,
// unused today) registers neither.
match bar_shell_theme.modules().clock.style {
ClockStyle::Plain => {
date_lbl.set_visible(bar_shell_theme.modules().clock.show_date);
bar_modules.register("clock", &clock_plain_box);
}
ClockStyle::Flip => bar_modules.register("clock", &clock_box),
ClockStyle::None => {}
}
bar_modules.register("volume", &vol_box); bar_modules.register("volume", &vol_box);
bar_modules.register("wifi", &connectivity_pair); bar_modules.register("wifi", &connectivity_pair);
bar_modules.register("battery", &bat_box); bar_modules.register("battery", &bat_box);
bar_modules.register("control", &hamburger_btn); bar_modules.register("control", &hamburger_btn);
// `[bar.slots].right = [..., "cpu", "ram", ...]` (glass-workbench):
// registered unconditionally, same as every other module — a theme
// that never names "cpu"/"ram" in a slot (liquid-motion) just never
// walks these entries in `for_each_in_slot` below, so they stay
// built but unparented, exactly like `media_widget` does for
// glass-workbench (which omits "media" entirely).
bar_modules.register("cpu", &bar_cpu_pair);
bar_modules.register("ram", &bar_ram_pair);
// `tray` never appears in a bar slot — it stays inside the // `tray` never appears in a bar slot — it stays inside the
// control-panel popover (built above, next to the SNI tray) — but // control-panel popover (built above, next to the SNI tray) — but
@ -791,6 +854,7 @@ impl SimpleComponent for App {
time_str: bar::clock::current(), time_str: bar::clock::current(),
clock_digits, clock_digits,
date_lbl, date_lbl,
clock_plain_lbl,
system_stats_box, system_stats_box,
system_sep, system_sep,
cpu_pair, cpu_pair,
@ -800,6 +864,8 @@ impl SimpleComponent for App {
cpu_lbl, cpu_lbl,
mem_lbl, mem_lbl,
pwr_lbl, pwr_lbl,
bar_cpu_lbl,
bar_ram_lbl,
gpu_lbl, gpu_lbl,
vol_lbl, vol_lbl,
bat_lbl, bat_lbl,
@ -938,7 +1004,14 @@ impl SimpleComponent for App {
self.active_ws = new_active; self.active_ws = new_active;
if let Some(btn) = self.button_map.get(&self.active_ws).cloned() { if let Some(btn) = self.button_map.get(&self.active_ws).cloned() {
btn.add_css_class("active"); btn.add_css_class("active");
self.workspace_trail.stretch(from.as_ref(), &btn); // Trail style only: pill/dots never call place()/
// stretch() at all — the "active" CSS class above is
// the whole of their active-workspace treatment
// (solid accent fill, no trail overlay).
if theme::shell_theme().modules().workspaces.style == WorkspaceStyle::Trail
{
self.workspace_trail.stretch(from.as_ref(), &btn);
}
} }
} }
} }
@ -958,8 +1031,25 @@ impl SimpleComponent for App {
} }
AppInput::ClockTick => { AppInput::ClockTick => {
self.time_str = bar::clock::current(); self.time_str = bar::clock::current();
flip_clock_digits(&self.clock_digits, &bar::clock::time());
self.date_lbl.set_label(&bar::clock::date()); self.date_lbl.set_label(&bar::clock::date());
let clock_module = theme::shell_theme().modules().clock.clone();
match clock_module.style {
// Plain (glass-workbench): one label, no flip animation
// — `flip_clock_digits` would just be wasted work (and
// a pointless 450ms `play_once` timer) on digits that
// are never on screen.
ClockStyle::Plain => {
self.clock_plain_lbl
.set_label(&bar::clock::formatted(&clock_module.format));
}
// Flip (default, liquid-motion) and None both keep
// exactly today's per-digit-flip update — None has no
// module in a slot to display it, but there's no reason
// to special-case skipping the (cheap, idempotent) work.
ClockStyle::Flip | ClockStyle::None => {
flip_clock_digits(&self.clock_digits, &bar::clock::time());
}
}
} }
AppInput::StatsUpdate(stats) => { AppInput::StatsUpdate(stats) => {
let cpu = match stats.cpu_temp { let cpu = match stats.cpu_temp {
@ -969,6 +1059,11 @@ impl SimpleComponent for App {
self.cpu_lbl.set_label(&cpu); self.cpu_lbl.set_label(&cpu);
self.mem_lbl.set_label(&stats.mem); self.mem_lbl.set_label(&stats.mem);
self.pwr_lbl.set_label(&stats.power); self.pwr_lbl.set_label(&stats.power);
// `[bar.slots].right = [..., "cpu", "ram", ...]` (glass-
// workbench): same formatted text, separate chip instances
// (see the App struct field docs for why).
self.bar_cpu_lbl.set_label(&cpu);
self.bar_ram_lbl.set_label(&stats.mem);
match stats.gpu_usage { match stats.gpu_usage {
Some(g) => { Some(g) => {
let gpu = match stats.gpu_temp { let gpu = match stats.gpu_temp {
@ -1306,14 +1401,33 @@ impl App {
self.workspace_box.remove(&child); self.workspace_box.remove(&child);
} }
self.button_map.clear(); self.button_map.clear();
let modules = theme::shell_theme().modules().clone();
let ws_style = modules.workspaces.style;
let show_empty = modules.workspaces.show_empty;
for ws in &self.workspaces { for ws in &self.workspaces {
if ws.monitor != self.monitor { if ws.monitor != self.monitor {
continue; continue;
} }
// Persistent empty Hyprland workspaces stay off the bar unless let empty = ws.windows == 0 && ws.id != self.active_ws;
// this output is actually looking at them. if empty {
if ws.windows == 0 && ws.id != self.active_ws { match ws_style {
continue; // Trail (default, liquid-motion): unconditionally off
// the bar, exactly as before this change — regardless
// of `show_empty`, which liquid-motion's own manifest
// declares `true` but this style has never consumed.
// Changing that now would be a real, undesired
// liquid-motion regression, not a Phase 5 fix.
WorkspaceStyle::Trail => continue,
// Pill/Dots: honour `show_empty` for real — demo 02's
// pills render an unoccupied, non-active workspace at
// reduced opacity via the `.workspace-btn:not(.occupied)
// :not(.active)` CSS rule rather than hiding it.
_ => {
if !show_empty {
continue;
}
}
}
} }
let btn = bar::workspaces::make_button(ws.id, &ws.name, self.active_ws, ws.windows > 0); let btn = bar::workspaces::make_button(ws.id, &ws.name, self.active_ws, ws.windows > 0);
if !prev.contains(&ws.id) { if !prev.contains(&ws.id) {
@ -1322,10 +1436,15 @@ impl App {
self.workspace_box.append(&btn); self.workspace_box.append(&btn);
self.button_map.insert(ws.id, btn); self.button_map.insert(ws.id, btn);
} }
match self.button_map.get(&self.active_ws).cloned() { // Trail style only: pill/dots never call place()/stretch()/clear()
Some(btn) if animate => self.workspace_trail.stretch(None, &btn), // at all — the "active" CSS class `make_button` already applies is
Some(btn) => self.workspace_trail.place(&btn), // the whole of their active-workspace treatment.
None => self.workspace_trail.clear(), if ws_style == WorkspaceStyle::Trail {
match self.button_map.get(&self.active_ws).cloned() {
Some(btn) if animate => self.workspace_trail.stretch(None, &btn),
Some(btn) => self.workspace_trail.place(&btn),
None => self.workspace_trail.clear(),
}
} }
} }

View file

@ -69,6 +69,66 @@ fn load_css() -> String {
let spring = tokens.spring(); let spring = tokens.spring();
let spring_settle = tokens.spring_settle(); let spring_settle = tokens.spring_settle();
// `tokens.bar_border()` (plan §11 Phase 5): "full" (default, liquid-
// motion's floating island) draws a border on all four edges; "bottom"
// (glass-workbench's flush edge-to-edge bar) draws only the hairline
// the demo's `.bar { border-bottom: 1px solid #ffffff12 }` calls for —
// a full border on a bar flush against the screen's top/left/right
// edges would otherwise show as a stray line along those edges an
// island never has to worry about. Reused below for the centerbox's
// 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()
} else {
"border: 1px solid alpha(@on-bg, 0.08);".to_string()
};
let centerbox_padding = if flush { "0 12px" } else { "0 8px 0 6px" };
// `modules.workspaces.style` (plan §11 Phase 5): "trail" (default,
// liquid-motion) is exactly today's CSS, unchanged byte-for-byte —
// dimmed/translucent buttons with the gradient trail overlay supplying
// the active fill. "pill"/"dots" (glass-workbench, Phase 6+) render the
// active state as a solid accent fill on the button itself instead,
// since neither style ever calls `WorkspaceTrail::place`/`stretch`
// (see `App::rebuild_buttons`) — the trail's own `.workspace-trail`
// pill CSS is therefore irrelevant for them (it's never made visible).
let workspace_css = match theme.modules().workspaces.style {
bread_theme::shell::WorkspaceStyle::Trail => format!(
".workspace-trail {{ background-image: linear-gradient(90deg, @accent, @teal);\
background-color: @accent; border-radius: 12px; }}\
.workspace-btn {{ background: transparent; opacity: 0.36; color: @on-bg;\
border-radius: 12px; border: none; outline: none; box-shadow: none;\
min-width: 28px; min-height: 28px; 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.occupied {{ opacity: 0.78; }}\
.workspace-btn.active {{ background: transparent; color: @on-accent; opacity: 1; }}\
.workspace-btn.active:hover {{ background: transparent; }}\
.workspace-btn.ws-in {{ animation: row-in 0.32s {spring_settle} both; }}",
),
_ => {
let accent = theme.tokens().accent_from();
format!(
".workspace-btn {{ background: transparent; opacity: 1; color: alpha(@on-bg, 0.4);\
border-radius: {radius_sm}; border: none; outline: none; box-shadow: none;\
min-width: 22px; min-height: 20px; 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:not(.occupied):not(.active) {{ opacity: 0.35; }}\
.workspace-btn.active {{ background: @{accent}; color: @on-accent; opacity: 1; }}\
.workspace-btn.active:hover {{ background: @{accent}; }}\
.workspace-btn.ws-in {{ animation: row-in 0.32s {spring_settle} both; }}",
)
}
};
format!( format!(
"@keyframes notif-in {{ from {{ opacity: 0; margin-right: -16px; }} }}\ "@keyframes notif-in {{ from {{ opacity: 0; margin-right: -16px; }} }}\
@keyframes osd-in {{ from {{ opacity: 0; margin-bottom: -8px; }} }}\ @keyframes osd-in {{ from {{ opacity: 0; margin-bottom: -8px; }} }}\
@ -79,22 +139,10 @@ fn load_css() -> String {
@keyframes digit-flip {{ from {{ opacity: 0; margin-top: 7px; }} to {{ opacity: 1; margin-top: 0; }} }}\ @keyframes digit-flip {{ from {{ opacity: 0; margin-top: 7px; }} to {{ opacity: 1; margin-top: 0; }} }}\
@keyframes caret-draw {{ from {{ margin-right: 200px; opacity: 0.2; }} to {{ margin-right: 4px; opacity: 1; }} }}\ @keyframes caret-draw {{ from {{ margin-right: 200px; opacity: 0.2; }} to {{ margin-right: 4px; opacity: 1; }} }}\
window.breadbar {{ background-color: alpha(@bg, 0.72); color: @on-bg;\ window.breadbar {{ background-color: alpha(@bg, 0.72); color: @on-bg;\
border-radius: {radius_bar}; border: 1px solid alpha(@on-bg, 0.08); }}\ border-radius: {radius_bar}; {window_border} }}\
window.breadbar > centerbox {{ padding: 0 8px 0 6px; }}\ window.breadbar > centerbox {{ padding: {centerbox_padding}; }}\
window.breadbar button {{ min-height: 0; min-width: 0; }}\ window.breadbar button {{ min-height: 0; min-width: 0; }}\
.workspace-trail {{ background-image: linear-gradient(90deg, @accent, @teal);\ {workspace_css}\
background-color: @accent; border-radius: 12px; }}\
.workspace-btn {{ background: transparent; opacity: 0.36; color: @on-bg;\
border-radius: 12px; border: none; outline: none; box-shadow: none;\
min-width: 28px; min-height: 28px; 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.occupied {{ opacity: 0.78; }}\
.workspace-btn.active {{ background: transparent; color: @on-accent; opacity: 1; }}\
.workspace-btn.active:hover {{ background: transparent; }}\
.workspace-btn.ws-in {{ animation: row-in 0.32s {spring_settle} both; }}\
.clock-box {{ padding: 0 4px; }}\ .clock-box {{ padding: 0 4px; }}\
.clock-label {{ font-size: 24px; font-weight: bold; letter-spacing: 0.04em;\ .clock-label {{ font-size: 24px; font-weight: bold; letter-spacing: 0.04em;\
min-height: 0; padding: 0; margin-top: 3px; }}\ min-height: 0; padding: 0; margin-top: 3px; }}\
@ -102,7 +150,9 @@ fn load_css() -> String {
min-width: 15px; min-height: 0; padding: 0; margin: 0; }}\ min-width: 15px; min-height: 0; padding: 0; margin: 0; }}\
.clock-colon {{ min-width: 10px; opacity: 0.7; }}\ .clock-colon {{ min-width: 10px; opacity: 0.7; }}\
.clock-digit.flip {{ animation: digit-flip 0.45s {spring} both; }}\ .clock-digit.flip {{ animation: digit-flip 0.45s {spring} both; }}\
.date-label {{ font-size: 14px; opacity: 0.52; letter-spacing: 0.04em; }}\ .clock-plain {{ padding: 0 4px; }}\
.clock-plain-time {{ font-size: 15px; font-weight: 600; letter-spacing: 0.04em; }}\
.date-label {{ font-size: 12px; opacity: 0.48; letter-spacing: 0.04em; }}\
.stat-label {{ font-size: 14px; letter-spacing: 0.02em; opacity: 0.92; }}\ .stat-label {{ font-size: 14px; letter-spacing: 0.02em; opacity: 0.92; }}\
.stat-label.tick {{ animation: digit-flip 0.35s {spring} both; }}\ .stat-label.tick {{ animation: digit-flip 0.35s {spring} both; }}\
.stats-box {{ margin-right: 0; }}\ .stats-box {{ margin-right: 0; }}\
@ -321,6 +371,9 @@ fn load_css() -> String {
pad = pad, pad = pad,
spring = spring, spring = spring,
spring_settle = spring_settle, spring_settle = spring_settle,
window_border = window_border,
centerbox_padding = centerbox_padding,
workspace_css = workspace_css,
) )
} }