theme: uniform chip height, fix hamburger radius cascade, spotlight dot spec, first-paint trail bug

Decision #1 (one chip highlight height per bar, vertically centred): the
.stat-pair/.stat-pair.icon-only/.media-widget/workspace-btn CSS all sized
to their own content box before this (min-height: 0, or a stale 32/20px
figure), which is why battery sat high and wifi/menu ran taller than their
neighbours. Added a hardcoded approved_chip_height() (26/22/22 by
WorkspaceStyle, matching the demo spec) since bread-ecosystem's own
chip_height token (32/20/36) predates this pass and disagrees with it, and
that repo is a sibling agent's this cycle.

Also found and fixed a second copy of the hamburger-corner-mismatch bug:
.control-panel-btn (hamburger only) hardcoded its own border-radius/
min-width/min-height *later* in the cascade than .stat-pair, silently
winning over chip_radius/chip_height regardless of the icon-only fix
already in place. Dropped the four conflicting properties so .stat-pair
cascades through unchanged.

Spotlight workspace dots: bumped to the approved Option B spec (10px
tall, widths 8/13/17/22 by open-window count) — the prior pass landed
9px/[6,10,14,18], one generation behind. dot_widths is likewise
theme.toml-derived and stale, so overridden locally with a comment
rather than edited upstream.

Workspace row "sits low on first paint" bug: root cause was the ws-in
entrance animation (row-in keyframe, margin-top 8px -> 0) playing on
every button during the very first rebuild_buttons call, racing
WorkspaceTrail::place()'s single-shot geometry sample and freezing the
trail pill a few px low until the next real switch re-measured settled
layout. The demo never animates the initial row in at all — only
subsequently-added workspaces should. Suppressed ws-in specifically on
the first build (button_map starts empty exactly once).

Also fixed: make_button's set_size_request was still pinning the stale
tokens().chip_height() as a hard GTK minimum, which would have out-ranked
the CSS min-height fix above for Trail/Pill workspace pills.
This commit is contained in:
Breadway 2026-08-26 09:25:48 +08:00
parent dd5618a5ea
commit 70fe814593
3 changed files with 123 additions and 27 deletions

View file

@ -142,7 +142,13 @@ pub fn make_button(
btn.set_halign(gtk4::Align::Center);
btn.set_vexpand(false);
btn.set_hexpand(false);
btn.set_size_request(-1, crate::theme::shell_theme().tokens().chip_height() as i32);
// `crate::theme::approved_chip_height`, not `tokens().chip_height()`:
// the latter is the stale pre-demo `breadbar::CHIP_HEIGHT` token (32
// for this Trail/Pill style's theme) and, as a hard `set_size_request`
// minimum, would out-rank the CSS `min-height` the demo actually wants
// (26px Trail / 22px Pill) — see that function's doc comment.
let style = crate::theme::shell_theme().modules().workspaces.style;
btn.set_size_request(-1, crate::theme::approved_chip_height(style) as i32);
if let Some(child) = btn.child() {
child.set_halign(gtk4::Align::Center);
child.set_valign(gtk4::Align::Center);
@ -156,21 +162,27 @@ pub fn make_button(
}
/// `style = "dots"` (theme 04/spotlight): a label-less pill whose WIDTH
/// encodes `windows` (0/1/2/3-or-more open) via `dot_widths` — see
/// `bread_theme::shell::WorkspacesModule::dot_widths`. Distinct from
/// [`make_button`] (Trail/Pill) rather than a variant of it because dots
/// carry no text at all (`04-spotlight.html`'s `.dots button` has no label);
/// reusing `Button::with_label("")` would still measure/lay out an empty
/// label box that a genuinely childless button doesn't. Width is a hard
/// encodes `windows` (0/1/2/3-or-more open). Distinct from [`make_button`]
/// (Trail/Pill) rather than a variant of it because dots carry no text at
/// all (`04-spotlight.html`'s `.dots button` has no label); reusing
/// `Button::with_label("")` would still measure/lay out an empty label box
/// that a genuinely childless button doesn't. Width is a hard
/// `set_size_request` snap, not animated — GTK CSS min-width transitions
/// don't participate in a directly-set size request the way an opacity/
/// background-color transition does, and the plan only calls out the
/// capsule's own expand/collapse as worth the `anim::spring_to` treatment.
///
/// `_dot_widths` (the manifest's `modules.workspaces.dot_widths`) is
/// accepted but deliberately unused — see `APPROVED_DOT_WIDTHS` below,
/// which overrides it with the approved Option B numbers the manifest's
/// own value predates. Kept in the signature rather than dropped so the
/// call site still documents where a real per-theme width would flow from
/// once `theme.toml` catches up.
pub fn make_dot_button(
id: WorkspaceId,
active: WorkspaceId,
windows: i32,
dot_widths: bread_theme::shell::DotWidths,
_dot_widths: bread_theme::shell::DotWidths,
) -> gtk4::Button {
let btn = gtk4::Button::new();
btn.add_css_class("workspace-dot");
@ -186,17 +198,26 @@ pub fn make_dot_button(
btn.set_hexpand(false);
// Height is a deliberate departure from `04-spotlight.html`'s own 6px
// (see the demo's `.dots button { height: 6px }`): reported as "too
// small and hard to click" — the click-target enlargement is a
// separate, non-visual fix elsewhere, but 6px is also genuinely hard
// to *see* on a real display, not just hard to hit. 9px keeps the
// dots reading as slim pills rather than growing into little chips
// (which would fight the capsule's minimal, text-first look), while
// being clearly perceptible against the 36px-tall bar. Widths are left
// exactly as `dot_widths` (the manifest's own per-occupancy encoding,
// e.g. `[6, 10, 14, 18]`) specifies — only the height, which has no
// manifest token of its own, is breadbar's call to make.
const DOT_HEIGHT: i32 = 9;
btn.set_size_request(dot_widths[dot_width_index(windows)], DOT_HEIGHT);
// small and hard to click", and also genuinely hard to *see* on a real
// display, not just hard to hit. Option B (approved): 10px tall — up
// from an earlier 9px pass that undershot the approved number by 1px.
// Keeps the dots reading as slim pills rather than growing into little
// chips (which would fight the capsule's minimal, text-first look),
// while being clearly perceptible against the 36px-tall bar.
const DOT_HEIGHT: i32 = 10;
// Option B widths (approved): 8/13/17/22px for 0/1/2/3-or-more open
// windows — `[8, 13, 17, 22]`, not the `dot_widths` parameter's own
// manifest value. `theme.toml`'s `modules.workspaces.dot_widths =
// [6, 10, 14, 18]` predates this pass and was never updated to match;
// hardcoded here (ignoring the passed-in `dot_widths`) rather than
// edited upstream, since bread-ecosystem is a sibling agent's repo
// this pass. Flagged in the task report — `dot_widths` should become
// `[8, 13, 17, 22]` in `assets/shell/spotlight/theme.toml`.
const APPROVED_DOT_WIDTHS: bread_theme::shell::DotWidths = [8, 13, 17, 22];
btn.set_size_request(
APPROVED_DOT_WIDTHS[dot_width_index(windows)],
DOT_HEIGHT,
);
btn.connect_clicked(move |_| {
relm4::spawn(async move {
switch_workspace(id).await;

View file

@ -1995,6 +1995,13 @@ impl App {
self.workspace_trail.cancel();
let prev: std::collections::HashSet<WorkspaceId> =
self.button_map.keys().copied().collect();
// `button_map` only starts empty once — the very first call this
// App instance ever makes, before any workspace has ever been
// synced from Hyprland. A fully-emptied bar never happens after
// that (the active workspace's own row is always kept), so this
// doubles as a clean "is this the initial paint" signal without a
// dedicated flag — see its one use below.
let is_first_build = prev.is_empty();
while let Some(child) = self.workspace_box.first_child() {
self.workspace_box.remove(&child);
}
@ -2038,7 +2045,20 @@ impl App {
bar::workspaces::make_button(ws.id, &ws.name, self.active_ws, ws.windows > 0)
}
};
if !prev.contains(&ws.id) {
// Never on the very first build (bug: "the [Trail] row sits
// ~5px low on first paint and only corrects after the first
// switch"). Root cause: `ws-in`'s `row-in` keyframe animates
// `margin-top` 8px → 0 over 320ms; `WorkspaceTrail::place`
// (called once, synchronously-ish, right after this loop for
// the initial row) samples each button's geometry via a
// single-shot tick callback that can fire while that margin
// is still mid-animation, freezing the trail pill a few px
// low until the next `place`/`stretch` call (the first real
// workspace switch) re-samples the by-then-settled layout.
// The demo itself never animates the initial row in at all
// (`OCC.forEach` builds plainly, only `place(0)` runs) — only
// *subsequently added* workspaces should ever play this.
if !is_first_build && !prev.contains(&ws.id) {
play_once(&btn, "ws-in", 360);
}
self.workspace_box.append(&btn);

View file

@ -35,6 +35,21 @@ pub fn set_shell_theme(theme: ShellTheme) {
SHELL_THEME.with(|cell| *cell.borrow_mut() = Rc::new(theme));
}
/// The one bar-chip height every chip in the row shares (vol/wifi/battery/
/// menu/media, and the workspace pills for Trail/Pill styles) — see the
/// `chip_h` local in `load_css` for why this is a hardcoded per-`
/// WorkspaceStyle` override rather than `Tokens::chip_height()`. Used both
/// by that CSS and by `bar::workspaces::make_button`'s `set_size_request`,
/// which otherwise still forces the stale token value as a hard GTK
/// minimum that a CSS `min-height` alone cannot out-rank.
pub fn approved_chip_height(style: bread_theme::shell::WorkspaceStyle) -> i64 {
use bread_theme::shell::WorkspaceStyle::*;
match style {
Trail => 26,
Pill | Dots => 22,
}
}
fn load_css() -> String {
// breadbar-specific rules only — fonts, base colours, and generic widgets
// come from the shared ecosystem stylesheet (applied first in `apply()`).
@ -118,6 +133,24 @@ fn load_css() -> String {
_ => radius_sm.clone(),
};
// ONE chip highlight height per bar, vertically centred — every chip
// (vol/wifi/battery/menu/media, icon-only and labelled alike) shares
// it so their fills align, instead of each sizing to its own content
// box (reported: battery sits high, wifi/menu are taller than their
// neighbours). Liquid Motion 26px / Glass Workbench 22px / Spotlight
// 22px, per the approved demo spec.
//
// HARDCODED, not `tokens.chip_height()`: that token is `breadbar::
// CHIP_HEIGHT` (32) carried over from before this design pass and was
// never updated for the three builtin `theme.toml`s (32/20/36) — it
// predates and disagrees with the demo numbers above. bread-ecosystem
// is owned by a sibling agent this pass, so this stays a local
// override (same `WorkspaceStyle` this file already keys `chip_radius`
// off) rather than an edit to that repo's schema/manifests. Flagged in
// the task report: `chip_height` should become 26/22/22 upstream.
let chip_h = approved_chip_height(theme.modules().workspaces.style);
let chip_height_px = format!("{chip_h}px");
// `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
@ -140,7 +173,7 @@ fn load_css() -> String {
background-color: @accent; border-radius: {radius_sm}; }}\
.workspace-btn {{ background: transparent; opacity: 0.36; color: @on-bg;\
border-radius: {radius_sm}; border: none; outline: none; box-shadow: none;\
min-width: 28px; min-height: 26px; margin: 0; padding: 0 7px;\
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}; }}\
@ -155,7 +188,7 @@ fn load_css() -> String {
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;\
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}; }}\
@ -253,7 +286,13 @@ fn load_css() -> String {
radius_pill for spotlight, so every theme's stat chips round\
the way that theme's *other* rounded chrome already does,\
instead of all three sharing one borrowed hardcoded number. */\
.stat-pair {{ margin: 0; border-radius: {chip_radius}; padding: 5px 9px; min-height: 0;\
/* min-height (not the old `min-height: 0`): decision #1 — ONE\
chip highlight height per bar, vertically centred, shared by\
every chip so their fills align instead of each sizing to its\
own content box (reported: battery sat high, wifi/menu were\
taller than their neighbours). See `chip_height_px` above. */\
.stat-pair {{ margin: 0; border-radius: {chip_radius}; padding: 5px 9px;\
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); }}\
@ -271,7 +310,7 @@ fn load_css() -> String {
out (spotlight has no icon-only chip today, but would get the\
same pill radius as its one `.stat-pair` sibling if it ever did). */\
.stat-pair.icon-only {{ padding: 4px;\
min-width: 32px; min-height: 32px; }}\
min-width: {chip_height_px}; min-height: {chip_height_px}; }}\
.stat-icon {{ margin-right: 6px; }}\
.stat-pair.icon-only .stat-icon {{ margin: 0; }}\
.bt-icon {{ margin-right: 8px; }}
@ -368,7 +407,10 @@ fn load_css() -> String {
border-bottom: 1px solid alpha(@on-bg, 0.10); box-shadow: none; }}\
.confirm-button {{ background-color: @accent; color: @on-accent; }}\
.confirm-button:hover {{ background-color: alpha(@accent, 0.85); }}\
.media-widget {{ border-radius: 10px; padding: 4px 8px; min-height: 0;\
/* min-height: decision #1 — the media chip is a bar chip like\
any other, so it shares the same row height instead of sizing\
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.media-in {{ animation: row-in 0.4s {spring} both; }}\
@ -385,8 +427,20 @@ fn load_css() -> String {
.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); }}\
.control-panel-btn {{ padding: 5px 8px; margin: 0; border-radius: 10px;\
opacity: 0.92; font-size: 18px; line-height: 1; min-width: 0; min-height: 0;\
/* 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\
`.stat-pair.icon-only` AND `.control-panel-btn`, and because\
this rule sits later in the cascade its hardcoded 10px radius\
and 0 min-size were silently winning over `.stat-pair`'s own\
`chip_radius`/`chip_height_px` the exact hamburger-corner-\
mismatch bug decision #2 describes, and a second copy of\
decision #1's height bug, both reintroduced by\
this one class alone. Dropping the four properties lets\
`.stat-pair`/`.stat-pair.icon-only` cascade through unchanged,\
same fix shape as the icon-only border-radius removal above. */\
.control-panel-btn {{ margin: 0;\
opacity: 0.92; font-size: 18px; line-height: 1;\
background: transparent; border: none; outline: none; box-shadow: none;\
transition: background-color 0.22s {spring_settle},\
opacity 0.18s ease; }}\
@ -508,6 +562,7 @@ fn load_css() -> String {
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,