git.lucas.co / cce-authenticator
login authentication (PAM + fingerprint)
git clone https://git.lucas.co/cce-authenticator.git

commit6b6003c505de00a6dbe209df99d966d1fc01a4ea
parentec6fd3d017
authorLucas Galante <[email protected]>
date2026-08-22 13:00
feat: paint the dialog as a cce window — plate, carved wells, widget relief

The auth prompt was the last cce-* window drawn entirely in raw quads: a
full-window scrim, a fixed 540x320 "card" centred in it, and four 1.5px
square-cornered blue quads as that card's border — a hard rectangle floating
50px inside a window the compositor clips to a ~35px superellipse, over a fill
barely separable from the scrim behind it.

The window is the dialog now. One Prim::Plate replaces all six quads, the two
columns are recess wells so their captions label something real, and the status
line sits on a bar_wall_width band carved across the plate's foot. Radii are
the SHARED backplate radius via layout::window_corner_radius, passed nominal:
Plate span-widens its own corners, and colors::backplate_corner_radius would
merge a per-app override the compositor never sees.

Controls go through the paint walk — the widget's own Paint impl, so a Button
emits the sunken inset_plate its raised style means and a TextBox its recessed
well, with hover/press/focus. NOT append_widget_plate: that is the designer's
escape hatch and resolves a plate through plate_bevel()/solid_border(), neither
of which Adapted forwards from Button, so every control came out as a bevel
filled with the configured button face — #00000000 in this DE, i.e. invisible.

Two bugs the rework exposed:

- The reader's state colours had never rendered. The state quad (success
  accent, scanning glow, dimmed-inert) was drawn UNDER a widget loop that
  painted quad(w.rect(), w.color()) on the identical rect, so the button's flat
  default overpainted all of it — including the "this reader is PAM's, not
  yours" dimming that is the whole point of the column in polkit mode. The
  state rides on fingerprint_btn.bg now.
- "Verify Password" overran its fixed 100px button on both sides at the DE's
  14pt control font. The label is "Verify" and the buttons size to the column.

fit_column takes the column width instead of a hardcoded 220px, because the
column is sized from the window now — and at its real width PAM's finger
wording fits whole, which the old constant would have clipped early. Both
facts are asserted; the mid-word check moves to the D-Bus error strings, which
are the genuinely unbounded captions.

Default height 360, not 400: at 400 the wells ran ~70px past anything in them.
Sessions with a remembered size keep it (try_restore wins), so the layout is
written to fill either. ContentBg is gone — it was the flat backdrop the plate
now is, and once widgets paint as plates its fill would erase the lit edge.

Verified in the shadow session via --standalone + CCE_AUTH_SIMULATE=1 (no
polkit D-Bus) at 640x400 and a fresh-state 640x360; unit restarted and
re-registered. Polkit mode itself is unverified — it needs a real request.

 src/main.rs | 317 +++++++++++++++++++++++++++++++++++++++---------------------
 1 file changed, 209 insertions(+), 108 deletions(-)

diff --git a/src/main.rs b/src/main.rs
index af3ec8f..bb15c96 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -1,7 +1,7 @@
 use wayland_client::QueueHandle;
 use cce_ui::engine::{Application, EngineState, LogicalPosition, LogicalSize, WindowSettings};
 use cce_ui::widget::{
-    Button, ContentBg, WidgetHost, ElementState, MouseButton, Key, NamedKey, KeyEvent, TextBox,
+    Button, WidgetHost, ElementState, MouseButton, Key, NamedKey, KeyEvent, TextBox,
     MouseScrollDelta
 };
 use futures::StreamExt;
@@ -73,25 +73,38 @@ fn simulate_allowed(polkit_mode: bool, env_requested: bool, uid: u32) -> bool {
     !polkit_mode && (env_requested || uid == 0)
 }
 
-/// Shorten a caption to what the fingerprint column can show, breaking at a word
-/// boundary.
+/// Shorten a caption to what a column `width` logical px wide can show, breaking at
+/// a word boundary.
 ///
-/// The column is 220 logical px, and its captions are arbitrary-length strings from
-/// PAM, fprintd and D-Bus errors (`No reader: <zbus error>`). The paint API clips to a
-/// rect, and a clip rect is not a layout strategy — it cuts mid-word and gives no hint
-/// that anything is missing. There is no cheap shaping call here to measure exactly, so
-/// the budget comes from the advance observed at this size (~4.15 px/char at 9pt) and
-/// is deliberately a few characters short: erring low only moves the ellipsis earlier.
-fn fit_column(text: &str) -> String {
-    const MAX_CHARS: usize = 50;
-    if text.chars().count() <= MAX_CHARS {
+/// The fingerprint column's captions are arbitrary-length strings from PAM, fprintd
+/// and D-Bus errors (`No reader: <zbus error>`). The paint API clips to a rect, and a
+/// clip rect is not a layout strategy — it cuts mid-word and gives no hint that
+/// anything is missing. There is no cheap shaping call here to measure exactly, so the
+/// budget comes from the advance observed at this size (~4.15 px/char at 9pt) and is
+/// deliberately a few characters short: erring low only moves the ellipsis earlier.
+///
+/// The width is a parameter because the column is sized from the window — it was a
+/// hardcoded 220px back when the dialog drew a fixed-size card inside itself.
+fn fit_column(text: &str, width: f32) -> String {
+    const PX_PER_CHAR: f32 = 4.15;
+    let max_chars = ((width / PX_PER_CHAR) as usize).max(8);
+    if text.chars().count() <= max_chars {
         return text.to_string();
     }
-    let head: String = text.chars().take(MAX_CHARS - 1).collect();
+    let head: String = text.chars().take(max_chars - 1).collect();
     let cut = head.rfind(' ').unwrap_or(head.len());
     format!("{}…", head[..cut].trim_end())
 }
 
+/// A toolkit color as the `[u8; 3]` the text prims take.
+fn text_rgb(c: [f32; 4]) -> [u8; 3] {
+    [
+        (c[0] * 255.0).round().clamp(0.0, 255.0) as u8,
+        (c[1] * 255.0).round().clamp(0.0, 255.0) as u8,
+        (c[2] * 255.0).round().clamp(0.0, 255.0) as u8,
+    ]
+}
+
 /// PAM service backing the standalone password check. Polkit mode never reaches it:
 /// `polkit-agent-helper-1` runs its own `polkit-1` service inside the helper process.
 const PAM_SERVICE: &str = "system-local-login";
@@ -123,7 +136,6 @@ fn take_cancelled(cookie: &str) -> bool {
 }
 
 struct AuthenticatorApp {
-    bg: cce_ui::widget::Adapted<ContentBg>,
     password_box: cce_ui::widget::Adapted<TextBox>,
     verify_btn: cce_ui::widget::Adapted<cce_ui::widget::Button>,
     cancel_btn: cce_ui::widget::Adapted<cce_ui::widget::Button>,
@@ -237,13 +249,11 @@ impl Application for AuthenticatorApp {
     }
 
     fn new(_qh: &QueueHandle<EngineState<Self>>, sender: calloop::channel::Sender<Self::Message>) -> Self {
-        let bg = ContentBg::new();
-        
         let password_box = TextBox::new(String::new())
             .with_password(true)
             .with_label("PASSWORD");
             
-        let verify_btn = Button::new(0.0, 0.0, 100.0, 32.0).with_label("Verify Password");
+        let verify_btn = Button::new(0.0, 0.0, 100.0, 32.0).with_label("Verify");
         let cancel_btn = Button::new(0.0, 0.0, 100.0, 32.0).with_label("Cancel");
         let mut fingerprint_btn = Button::new(0.0, 0.0, 120.0, 120.0).with_label("Scan");
         
@@ -308,7 +318,6 @@ impl Application for AuthenticatorApp {
         }
 
         let mut app = Self {
-            bg,
             password_box,
             verify_btn,
             cancel_btn,
@@ -381,9 +390,12 @@ impl Application for AuthenticatorApp {
             title: "CCE Authenticator".to_string(),
             app_id: "cce-authenticator".to_string(),
             width: 640,
-            height: 400,
+            // Sized to the content now that there is no inset card: title band,
+            // two column wells, status shelf. At 400 the wells ran ~70px past
+            // anything in them and the dialog read as half empty.
+            height: 360,
             fullscreen: false,
-            min_size: Some((580, 380)),
+            min_size: Some((560, 340)),
         }
     }
 
@@ -634,36 +646,93 @@ impl Application for AuthenticatorApp {
         self.height = sh;
 
         let mut pc = cce_ui::scene::paint::PaintCtx::new();
-        let quad = |pc: &mut cce_ui::scene::paint::PaintCtx, x: f32, y: f32, w: f32, h: f32, c: [f32; 4]| {
-            pc.quad(Rect { x, y, width: w, height: h }, c);
-        };
 
-        quad(&mut pc, 0.0, 0.0, sw, sh, [0.03, 0.03, 0.05, 0.8]);
-
-        let card_w = 540.0f32;
-        let card_h = 320.0f32;
-        let card_x = (sw - card_w) / 2.0;
-        let card_y = (sh - card_h) / 2.0;
-
-        quad(&mut pc, card_x, card_y, card_w, card_h, [0.07, 0.07, 0.10, 0.95]);
+        // ── The window plate ──
+        //
+        // The window IS the dialog. This used to dim the whole surface and draw a
+        // fixed 540x320 "card" inside it, outlined in four 1.5px square-cornered
+        // quads — a hard blue rectangle floating 50px inside a window the compositor
+        // clips to a ~35px superellipse, with the card's fill barely separable from
+        // the scrim behind it. The plate replaces all six quads: one lit slab whose
+        // rolled perimeter reads as the physical edge the silhouette already implies.
+        //
+        // Nominal radius, NOT span-widened here: `Prim::Plate` widens its own corners
+        // (`plate_push_constants`' `scale_corners`), so pre-multiplying by
+        // `corner_span_factor` would apply it twice and detach the arc from the
+        // silhouette. It must be the SHARED backplate radius via
+        // `layout::window_corner_radius` rather than `colors::backplate_corner_radius`,
+        // which merges a per-app override the compositor never sees.
+        let mut plate_color = cce_ui::color::page_low_color();
+        if plate_color[3] > 0.001 {
+            plate_color[3] = cce_ui::color::active_backplate_opacity();
+        }
+        let wr = cce_ui::layout::window_corner_radius().max(0.0);
+        pc.plate(
+            Rect { x: 0.0, y: 0.0, width: sw, height: sh },
+            (wr, wr, wr, wr),
+            plate_color,
+            cce_ui::layout::bevel_width(),
+        );
 
-        let border_color = [0.20, 0.40, 0.65, 0.6];
-        quad(&mut pc, card_x, card_y, card_w, 1.5, border_color);
-        quad(&mut pc, card_x, card_y + card_h - 1.5, card_w, 1.5, border_color);
-        quad(&mut pc, card_x, card_y, 1.5, card_h, border_color);
-        quad(&mut pc, card_x + card_w - 1.5, card_y, 1.5, card_h, border_color);
+        // ── Layout ──
+        let pad = 24.0f32;
+        let status_h = 40.0f32;
+        let gutter = 22.0f32;
+        let caption_h = 22.0f32;
+
+        let status_y = sh - status_h;
+        let content_y = pad + 34.0;
+        let col_w = ((sw - pad * 2.0 - gutter) / 2.0).max(140.0);
+        let fp_col_x = pad;
+        let pw_col_x = pad + col_w + gutter;
+        let well_y = content_y + caption_h;
+        let well_h = (status_y - pad * 0.5 - well_y).max(90.0);
+        let well_r = cce_ui::layout::plate_corner_radius();
+        let well_depth = cce_ui::layout::bevel_width().min(well_h * 0.2);
+
+        // Both columns are wells carved into the plate — the captions label a real
+        // recess instead of floating over an undifferentiated fill.
+        for x in [fp_col_x, pw_col_x] {
+            pc.recess(
+                Rect { x, y: well_y, width: col_w, height: well_h },
+                (well_r, well_r, well_r, well_r),
+                well_depth,
+            );
+        }
 
-        let fp_col_x = card_x + 30.0;
-        let fp_col_y = card_y + 80.0;
-        let fp_col_w = 220.0;
+        // The status line gets the statusbar treatment: a band carved across the foot
+        // of the plate, top wall only so the seam reads as a shelf rather than a box
+        // inset from edges the window already rounds.
+        pc.recess_edges(
+            Rect { x: 0.0, y: status_y, width: sw, height: status_h },
+            (0.0, 0.0, 0.0, 0.0),
+            cce_ui::layout::bar_wall_width(),
+            (true, false, false, false),
+        );
 
-        let fp_btn_w = 120.0f32;
-        let fp_btn_h = 120.0f32;
-        let fp_btn_x = fp_col_x + (fp_col_w - fp_btn_w) / 2.0;
-        let fp_btn_y = fp_col_y + 10.0;
+        // ── Widget geometry ──
+        //
+        // The two columns fill their wells differently because their contents differ:
+        // the reader is one target, so it centers; the password column is a form, so
+        // it runs input at the top and actions at the foot.
+        let inset = 20.0f32;
+        let cap_h = 34.0f32; // two lines at 9pt, the longest PAM/fprintd captions
+        let fp_btn_w = 150.0f32.min(col_w - inset * 2.0).min(well_h - 78.0).max(64.0);
+        let fp_btn_h = fp_btn_w;
+        let fp_btn_x = fp_col_x + (col_w - fp_btn_w) / 2.0;
+        // Target + caption ride as one block centered in the well. Top-anchored, the
+        // block left a third of the column empty under it and the column read as
+        // unfinished rather than as a target with room around it.
+        let fp_block_h = fp_btn_h + 16.0 + cap_h;
+        let fp_btn_y = well_y + ((well_h - fp_block_h) / 2.0).max(20.0);
         self.fingerprint_btn.set_rect(fp_btn_x, fp_btn_y, fp_btn_w, fp_btn_h);
 
-        let fp_bg = if self.fingerprint_success {
+        // The reader's state color rides on the widget so the plate path paints it.
+        // It used to be a quad drawn UNDER the widget loop's `quad(w.rect(), w.color())`
+        // on the identical rect — so every state (the success accent, the scanning
+        // glow, the dimmed-inert fill) was overpainted by the button's flat default
+        // and none of them ever reached the screen.
+        self.fingerprint_btn.bg = Some(if self.fingerprint_success {
             ACCENT
         } else if self.fingerprint_active {
             let alpha = 0.4 + 0.3 * self.glow_timer.sin();
@@ -674,76 +743,93 @@ impl Application for AuthenticatorApp {
             // PAM owns the reader here, and the click handler drops presses on the
             // floor — so don't paint this like something that responds to one.
             TOGGLE_INERT
-        };
-        quad(&mut pc, fp_btn_x, fp_btn_y, fp_btn_w, fp_btn_h, fp_bg);
-
-        if self.fingerprint_active {
-            let scan_y = fp_btn_y + 10.0 + (50.0 + 50.0 * self.glow_timer.sin()).clamp(0.0, fp_btn_h - 20.0);
-            quad(&mut pc, fp_btn_x + 10.0, scan_y, fp_btn_w - 20.0, 2.0, [0.30, 0.90, 0.32, 0.8]);
-        }
-
-        let pw_col_x = card_x + 290.0;
-        let pw_col_y = card_y + 80.0;
-        let pw_col_w = 220.0;
+        });
 
-        self.password_box.set_rect(pw_col_x, pw_col_y + 20.0, pw_col_w, 36.0);
+        let pw_inner_x = pw_col_x + inset;
+        let pw_inner_w = col_w - inset * 2.0;
+        self.password_box.set_rect(pw_inner_x, well_y + 40.0, pw_inner_w, 36.0);
 
-        let btn_w = 100.0f32;
+        // The two actions split the column. They were a fixed 100px, which "Verify
+        // Password" overran on both sides at the DE's 14pt control font — the label
+        // is "Verify" now, and the width follows the column instead of a constant.
+        let btn_w = ((pw_inner_w - 12.0) / 2.0).max(72.0);
         let btn_h = 32.0f32;
-        let verify_x = pw_col_x;
-        let cancel_x = pw_col_x + pw_col_w - btn_w;
-
-        self.verify_btn.set_rect(verify_x, pw_col_y + 80.0, btn_w, btn_h);
-        self.cancel_btn.set_rect(cancel_x, pw_col_y + 80.0, btn_w, btn_h);
+        let btn_y = well_y + well_h - 24.0 - btn_h;
+        self.verify_btn.set_rect(pw_inner_x, btn_y, btn_w, btn_h);
+        self.cancel_btn.set_rect(pw_inner_x + pw_inner_w - btn_w, btn_y, btn_w, btn_h);
+
+        // Run each control through the real paint walk, which is how every other cce
+        // app draws its widgets: the widget's own `Paint` impl, so a Button emits the
+        // sunken `inset_plate` its `raised` style means and a TextBox its recessed
+        // well, along with hover/press/focus state and its text.
+        //
+        // NOT `append_widget_plate` — that is the designer's escape hatch, and it
+        // resolves a plate through `plate_bevel()`/`solid_border()`, neither of which
+        // `Adapted` forwards from `Button`. Every control came out as a bevel filled
+        // with the configured button face, which this DE sets to #00000000: invisible.
+        for w in self.widgets_iter() {
+            cce_ui::scene::painter::paint_root_into(&self.ui_context, w, &mut pc);
+        }
 
-        for w in &self.widgets_iter() {
-            let (wx, wy, ww, wh) = w.rect();
-            quad(&mut pc, wx, wy, ww, wh, w.color());
-            for (qx, qy, qw, qh, qc) in w.extra_quads() {
-                quad(&mut pc, qx, qy, qw, qh, qc);
-            }
+        if self.fingerprint_active {
+            let scan_y = fp_btn_y + 10.0
+                + (50.0 + 50.0 * self.glow_timer.sin()).clamp(0.0, fp_btn_h - 20.0);
+            pc.quad(
+                Rect { x: fp_btn_x + 10.0, y: scan_y, width: fp_btn_w - 20.0, height: 2.0 },
+                [0.30, 0.90, 0.32, 0.8],
+            );
         }
 
-        // ── Text (the old text_items assembly, now prims shaped by the engine) ──
-        pc.text_with("CCE AUTHENTICATOR".to_string(), card_x + 30.0, card_y + 30.0, 15.0, [0xee, 0xee, 0xf5], None, None);
-        pc.text_with("FINGERPRINT AUTHENTICATION".to_string(), fp_col_x, fp_col_y - 15.0, 10.0, [0x83, 0x83, 0x8a], None, None);
+        // ── Text ──
+        let caption = cce_ui::color::control_label_color_detached_u8();
+        pc.text_with(
+            "CCE AUTHENTICATOR".to_string(),
+            pad,
+            pad,
+            15.0,
+            text_rgb(cce_ui::color::TEXT_HEADER),
+            None,
+            None,
+        );
+        pc.text_with("FINGERPRINT AUTHENTICATION".to_string(), fp_col_x, content_y, 10.0, caption, None, None);
+        pc.text_with("PASSWORD AUTHENTICATION".to_string(), pw_col_x, content_y, 10.0, caption, None, None);
+
         let fp_msg_color = if self.fingerprint_success {
             [0xa0, 0xee, 0xa0]
         } else if self.fingerprint_interactive || self.fingerprint_active {
-            [0xbb, 0xbb, 0xbf]
+            text_rgb(cce_ui::color::TEXT_FG)
         } else {
-            [0x83, 0x83, 0x8a]
+            caption
         };
+        let fp_msg_x = fp_col_x + inset;
+        let fp_msg_w = col_w - inset * 2.0;
+        let fp_msg_y = fp_btn_y + fp_btn_h + 16.0;
         pc.text_with(
-            fit_column(&self.fingerprint_msg),
-            fp_col_x,
-            fp_btn_y + fp_btn_h + 12.0,
+            fit_column(&self.fingerprint_msg, fp_msg_w),
+            fp_msg_x,
+            fp_msg_y,
             9.0,
             fp_msg_color,
             None,
-            Some([fp_col_x, fp_btn_y + fp_btn_h + 12.0, fp_col_x + fp_col_w, fp_btn_y + fp_btn_h + 50.0]),
+            Some([fp_msg_x, fp_msg_y, fp_msg_x + fp_msg_w, fp_msg_y + cap_h]),
         );
-        pc.text_with("PASSWORD AUTHENTICATION".to_string(), pw_col_x, pw_col_y - 15.0, 10.0, [0x83, 0x83, 0x8a], None, None);
-
-        for w in self.widgets_iter() {
-            cce_ui::scene::painter::append_widget_text(&self.ui_context, w, &mut pc);
-        }
 
         let status_color = if self.status_is_success {
             [0xa0, 0xee, 0xa0]
         } else if self.status_is_error {
             [0xee, 0x5c, 0x5c]
         } else {
-            [0xbb, 0xbb, 0xbf]
+            text_rgb(cce_ui::color::TEXT_FG)
         };
+        let status_text_y = status_y + (status_h - 12.0) / 2.0;
         pc.text_with(
             self.status_msg.clone(),
-            card_x + 30.0,
-            card_y + card_h - 40.0,
+            pad,
+            status_text_y,
             10.0,
             status_color,
             None,
-            Some([card_x + 30.0, card_y + card_h - 45.0, card_x + card_w - 30.0, card_y + card_h - 5.0]),
+            Some([pad, status_y, sw - pad, sh]),
         );
 
         Some(pc.finish())
@@ -844,26 +930,20 @@ impl Application for AuthenticatorApp {
 }
 
 impl AuthenticatorApp {
+    /// The dialog's four real controls, in paint order.
+    ///
+    /// A full-window `ContentBg` used to lead this list. It was the flat backdrop the
+    /// window plate now is, and once the widgets paint as plates it became actively
+    /// destructive: `append_widget_plate` would have drawn its fill over the plate,
+    /// erasing the lit edge and every carve under it.
     fn widgets_iter(&self) -> Vec<&dyn WidgetHost> {
         vec![
-            &self.bg,
             &self.password_box,
             &self.verify_btn,
             &self.cancel_btn,
             &self.fingerprint_btn,
         ]
     }
-
-    #[allow(dead_code)]
-    fn widgets_iter_mut(&mut self) -> Vec<&mut dyn WidgetHost> {
-        vec![
-            &mut self.bg,
-            &mut self.password_box,
-            &mut self.verify_btn,
-            &mut self.cancel_btn,
-            &mut self.fingerprint_btn,
-        ]
-    }
 }
 
 fn run_pam_auth(username: &str, password: &str) -> Result<(), String> {
@@ -1204,25 +1284,46 @@ mod tests {
 
     #[test]
     fn column_captions_never_cut_mid_word() {
-        // The message that exposed this: clipping rendered "…on the fingerprint read".
+        // Roughly the interior of a column in the default 640px-wide window.
+        const W: f32 = 245.0;
+
+        // The message that exposed this — clipping rendered "…on the fingerprint
+        // read" — now fits whole: the column grew from a hardcoded 220px to its
+        // share of the window. Asserted, because it is the reason the budget had
+        // to stop being a constant.
         let pam = "Place your right middle finger on the fingerprint reader";
-        let fitted = fit_column(pam);
+        assert_eq!(fit_column(pam, W), pam, "the column is wide enough for PAM's wording now");
+        assert!(fit_column(pam, 220.0).ends_with('…'), "…but not at the old width");
+
+        // The genuinely unbounded captions are the D-Bus errors.
+        let err = "No reader: org.freedesktop.DBus.Error.ServiceUnknown: \
+                   The name net.reactivated.Fprint was not provided by any .service files";
+        let fitted = fit_column(err, W);
         assert!(fitted.ends_with('…'), "long captions must show they were cut");
-        assert!(
-            !fitted.contains("read…"),
+        let kept = fitted.trim_end_matches('…');
+        assert!(err.starts_with(kept), "the kept head must be a real prefix: {fitted}");
+        // A word boundary means the character the cut dropped was a space — that is
+        // the whole difference between this and the clip rect it replaced.
+        assert_eq!(
+            err[kept.len()..].chars().next(),
+            Some(' '),
             "cut fell mid-word: {fitted}"
         );
-        assert!(pam.starts_with(fitted.trim_end_matches('…').trim_end()));
 
         // Short enough to stand as-is, ellipsis included or not.
-        assert_eq!(fit_column("Waiting for finger…"), "Waiting for finger…");
-        assert_eq!(fit_column(""), "");
+        assert_eq!(fit_column("Waiting for finger…", W), "Waiting for finger…");
+        assert_eq!(fit_column("", W), "");
 
         // No spaces to break on, and multi-byte characters: must not panic or slice
         // through a char boundary.
-        let unbroken = "x".repeat(80);
-        assert!(fit_column(&unbroken).ends_with('…'));
-        assert!(fit_column(&"é".repeat(80)).ends_with('…'));
+        let unbroken = "x".repeat(200);
+        assert!(fit_column(&unbroken, W).ends_with('…'));
+        assert!(fit_column(&"é".repeat(200), W).ends_with('…'));
+
+        // A window dragged to its minimum still has to produce something, not panic
+        // on an underflowing budget — the width is a layout value now, not a constant.
+        assert!(!fit_column(pam, 1.0).is_empty());
+        assert!(!fit_column(pam, 0.0).is_empty());
     }
 
     /// The orderings that a single active-cookie slot got wrong. One test, run in