fix(menu): #131 — size text menus in ch with a cap, not in % of the monitor
All checks were successful
Check / eval (push) Successful in 3m49s

Bernardo saw Recovery ellipsize on a 2560x1440 external, which falsified
the item's model (40% + Inter 11 → "only below 1920"). Real cause: text
menus render through themes/<slug>/rofi.rasi, where boreal + neon-glass
pinned width: 620px (fixed — a wide panel buys nothing) while seven used
40%, and the font is whatever that file says — mostly monospace, far
wider than the Inter that was measured. The modelled combination ships in
no theme.

The insight: a menu must fit its longest label, which is a count of
characters in the THEME's font — so the window must scale with the font,
not the screen. 40% gives a 1366 panel 546px and a 2560 one 1024px for
the same row, and a 14pt mono theme needs ~25% more room than an 11pt one
on both; a percentage cannot see either fact.

Rofi has the right unit (`ch` = width of one digit in the current font)
and `calc( a min b )` to cap it, so every text menu — generated and all
nine whole-swaps — is now `width: calc( 84ch min 65% )`. 84ch fits the
longest row we ship (69 chars) plus icon and padding; the cap is
Bernardo's condition (never sprawl on a low-res panel) and it is
measured, not assumed.

Verified on hardware, not by arithmetic: 84ch = 756px in GeistMono 11 and
924px in JetBrainsMono 14; `calc( 84ch min 300px )` → 300px, so the clamp
really clamps. Screenshots at both ends — the real Recovery menu at 756px
(29% of 2560) with every label complete, and the Acer worst case
reproduced pixel-exactly (888px = 65% of 1366, JetBrainsMono 14) also
complete. Grid views override width per-invocation and are untouched.
checks.rofi-text-width guards the class, proven by pinning boreal back to
620px and watching it fail by name. Acer V3 queued for its own fontconfig.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-16 10:20:49 +01:00
parent 39c8b3e0eb
commit 5aad3153ce
15 changed files with 106 additions and 71 deletions

View File

@@ -153,64 +153,6 @@ whether Nomarchy holds itself higher — darker label, or a darker accent mix
for light palettes — and note `tools/check-theme-contrast.py` covers palette for light palettes — and note `tools/check-theme-contrast.py` covers palette
pairings only, never GTK widget surfaces. `[human]` for the aesthetic call. pairings only, never GTK widget surfaces. `[human]` for the aesthetic call.
### 131. Recovery cost labels truncate on 1366-wide panels
#111 gave Recovery scope-first labels that carry the cost inline — "Desktop
generation — themes & home config (instant, reversible)" and "System boot
generation — older NixOS (reboot, then pick in boot menu)". They ellipsize,
**and the cost hint is exactly the part that gets cut**.
**Observed 2026-07-16 (Bernardo, T14s on a 2560×1440 external), which kills
this item's original model.** It said `width: 40%` + Inter 11 → truncation only
below ~1920, "confirm on the Acer". Both halves were wrong, and a wide monitor
is where he saw it:
- **Width does not track the monitor.** Text menus render through
`themes/<slug>/rofi.rasi`, and the whole-swaps disagree: **boreal (his) and
neon-glass pin `width: 620px`** — fixed, so a 2560 panel buys exactly nothing
— while the other seven use `40%`. (`launcher.rasi`'s 780px is a red herring:
it themes `-show drun` only, as its own header says.)
- **The font is not Inter 11.** A whole-swap's text menus use whatever that
file sets: boreal **GeistMono Nerd Font 11**, summer-day/night and kanagawa
**JetBrainsMono 14**, nord/lumon/retro-82 13, executive-slate Inter 13. Most
are **monospace** — far wider per character than the Inter the item measured.
Only the *generated* (non-swap) theme follows `t.fonts.ui`/`size`.
- So the modelled combination — 40% + Inter 11 — **is shipped by no theme**,
and truncation is not an Acer edge case: it is most theme × monitor pairs.
Measured for boreal, matching what he sees (GeistMono 11 ≈ 9.33px/char,
measured off Kitty at the same font/size; text room ≈ 518px = 620 32 window
padding 28 element padding 30 icon 12 spacing):
| row | chars | px | |
|---|---|---|---|
| Desktop generation — themes & home config (instant, reversible) | 63 | 587 | **cut** |
| System boot generation — older NixOS (reboot, then pick in boot menu) | 69 | 643 | **cut** |
| Files (BTRFS) — restore files or roll root (password + confirm) | 63 | 587 | **cut** |
| What changed — the last few generations, side by side | 53 | 494 | fits |
Three of four — "*some* of the submenus", exactly his words.
**The decision this needs (`[human]`-ish), because the cheap fix is a trap.**
Shortening labels to fit means fitting the *narrowest* combination we ship, and
that is not boreal: `40%` at 1366 with JetBrainsMono 14 leaves ~444px ≈ **39
characters** — which cannot hold a scope *and* a cost, so it would gut #111's
whole design for every user on a big screen. Options, and this is Bernardo's
call:
- **(a) Size the picker in `ch`** (rofi supports it) — e.g. `width: 72ch` in
every rofi.rasi + the generated theme. The window then scales with the font,
so a 14pt mono theme gets a wider window and a label that fits by
construction, on any monitor. Fixes the class; costs one line × 9 theme files
(the parity tax, same shape as #141).
- **(b) Keep labels short, move cost to rofi `-mesg`** — one line under the
prompt, full width, no per-row budget. Changes #111's "cost travels with the
row" premise.
- (c) Shorten to ~39 chars — cheapest, and the one that quietly loses the
information #111 added.
Whatever wins, **#132 stands**: the VM cannot judge this, so it needs a look on
real hardware (boreal on the dev box is now a positive control — it truncates).
### 132. VM cannot judge rofi menu geometry ### 132. VM cannot judge rofi menu geometry
The guest renders the picker with icons at roughly a quarter of `ui.iconSize` The guest renders the picker with icons at roughly a quarter of `ui.iconSize`

View File

@@ -538,6 +538,13 @@ Everything else below stays open; order is convenience, not a gate.
computed from the focused monitor now, so this is checking the fallback computed from the focused monitor now, so this is checking the fallback
path and the font, not the arithmetic), fully on-screen, none clipped by path and the font, not the arithmetic), fully on-screen, none clipped by
the bar. Dev box (2560×1440) already measured exact. the bar. Dev box (2560×1440) already measured exact.
- [ ] **#131 menu width on the real narrow panel** — open Menu ▸ Recovery.
Pass = every label complete (no ellipsis) and the picker visibly *not*
hogging the screen (≤65%, the cap). The geometry was already reproduced
pixel-exactly on the dev box (888px + JetBrainsMono 14 = what 65% of 1366
produces) and passed, so what is genuinely unproven here is only the
Acer's own fontconfig/DPI resolving `ch` the same way — try it under a
**JetBrainsMono 14 theme** (summer-day/night, kanagawa), the widest case.
## Latitude 5310 / 5410 only ## Latitude 5310 / 5410 only
- [ ] **v1 QA batch on-hardware pass** (583708d batch was QEMU-verified) — - [ ] **v1 QA batch on-hardware pass** (583708d batch was QEMU-verified) —

View File

@@ -43,9 +43,16 @@ Template:
so it should hold by construction, which is exactly why it is worth one look. so it should hold by construction, which is exactly why it is worth one look.
#131 is **live on 2560** per Bernardo, so its "only at 1366" model is wrong — #131 is **live on 2560** per Bernardo, so its "only at 1366" model is wrong —
next up. next up.
- **Next suggestion:** #131 (recovery labels truncating on a wide panel — - **Next suggestion:** #137 (plymouth per-head) or #142 (rebuild-while-docked).
observed, so the measurement in that item is what needs re-doing), then #137 - **Then #131 shipped too (same session):** the remodel found the real cause —
(plymouth per-head) or #142 (rebuild-while-docked). text menus are sized in % of the *monitor* while a label is a count of
*characters* in the *theme's* font, and the whole-swaps disagreed wildly
(620px fixed vs 40%; GeistMono 11 vs JetBrainsMono 14). Fixed with rofi's own
`ch` unit + a `min` cap: `width: calc( 84ch min 65% )` everywhere. Bernardo's
condition ("not ugly on a low-res monitor") is what the cap is for, and it is
measured, not hoped: 84ch = 756px/924px by font, `min 300px` → 300px. V3 by
screenshot at both ends, incl. the Acer's exact geometry reproduced on the dev
box. `checks.rofi-text-width` guards it.
## 2026-07-16 — #138 dock audio: the graph restart was the bug (44aac0f + this) ## 2026-07-16 — #138 dock audio: the graph restart was the bug (44aac0f + this)
- **Task:** #138 — Meet "no mic or speakers" in Chromium; Zoom fine; no repro - **Task:** #138 — Meet "no mic or speakers" in Chromium; Zoom fine; no repro

View File

@@ -449,6 +449,34 @@ Design/decision records and a running log of shipped work (items marked
(#131). Those windows therefore carry float/center rules and deliberately no (#131). Those windows therefore carry float/center rules and deliberately no
`size` rule. Measured after: calendar 1536×936 = exactly 60%×65%, doctor `size` rule. Measured after: calendar 1536×936 = exactly 60%×65%, doctor
1408×1008 = 55%×70%, both centred in the Waybar work area. 1408×1008 = 55%×70%, both centred in the Waybar work area.
- ✓ **Menus are sized in `ch`, not in % of the monitor (#131, 2026-07-16):**
Bernardo saw Recovery's labels ellipsize **on a 2560×1440 external** — which
falsified the item's own model ("`width: 40%` + Inter 11 → only below ~1920;
confirm on the Acer"). Two wrong assumptions, and the diagnosis is the
interesting part: text menus render through `themes/<slug>/rofi.rasi`, where
**boreal and neon-glass pinned `width: 620px`** — fixed, so a wide panel buys
nothing — while the other seven used `40%`; and the font is whatever that file
says (boreal GeistMono 11, summer/kanagawa JetBrainsMono 14, …), mostly
**monospace**, far wider than the Inter that was measured. The modelled
combination was shipped by no theme at all.
The insight: **a menu must fit its longest label, which is a count of
characters — so the window has to scale with the font, not the screen.** 40%
of a 1366 panel and of a 2560 one give the same row 546px and 1024px, and a
14pt mono theme needs ~25% more room than an 11pt one on both; a percentage
cannot see either fact. Rofi has the right unit — `ch`, the width of one digit
in the theme's own font — and `calc( … min … )` to cap it. So every text menu
(generated + all nine whole-swaps) is now `width: calc( 84ch min 65% )`:
84ch fits the longest row we ship (69 chars) plus icon and padding, and the
cap answers Bernardo's condition — a big font on a small panel is clamped
rather than sprawling. Measured on hardware: 84ch = 756px in GeistMono 11 and
924px in JetBrainsMono 14, `calc( 84ch min 300px )` → 300px (the cap really
clamps). V3 by screenshot, both ends: the real Recovery menu at 756px (29% of
2560) with every label complete, and the Acer's worst case reproduced exactly
— 888px (=65% of 1366) with JetBrainsMono 14 — also complete. Grid views
(theme picker, drun) override width per-invocation and are untouched: they are
sized by icons, not text. `checks.rofi-text-width` guards the class — a
whole-swap that sizes its text menu without `ch` + a `min` cap fails, proven
by pinning boreal back to 620px and watching it name boreal.
- ✓ **Waybar whole-swap on-clicks lost their environment (#141, 2026-07-16):** - ✓ **Waybar whole-swap on-clicks lost their environment (#141, 2026-07-16):**
clicking the updates module did nothing. Not an updates bug — a **parity** clicking the updates module did nothing. Not an updates bug — a **parity**
break: the generated module bakes the terminal in at eval time, but the four break: the generated module bakes the terminal in at eval time, but the four

View File

@@ -1473,6 +1473,44 @@
in in
assert ok; pkgs.runCommand "nomarchy-waybar-swap-env" { } "touch $out"; assert ok; pkgs.runCommand "nomarchy-waybar-swap-env" { } "touch $out";
# A text menu must fit its longest label, which is a count of
# characters — so the picker has to be sized in the theme's own font
# (`ch`), never in % of the monitor or fixed px (#131: boreal pinned
# 620px and truncated Recovery on a 2560 panel; 40% themes truncate at
# 1366 while looking fine at 2560, and a 14pt mono theme needs ~25%
# more room than an 11pt one on both). The `min` cap is the other half
# — without it a big font on a small panel sprawls across the screen.
# Grid views (theme picker, drun) legitimately size by icons and
# override width per-invocation; this only governs the base theme.
rofi-text-width =
let
inherit (nixpkgs) lib;
expect = cond: msg: lib.assertMsg cond "rofi-text-width: ${msg}";
swaps = lib.filter
(n: builtins.pathExists (./themes + "/${n}/rofi.rasi"))
(builtins.attrNames (builtins.readDir ./themes));
widthOf = name:
let
text = builtins.readFile (./themes + "/${name}/rofi.rasi");
m = builtins.match ".*window[[:space:]]*\\{[^}]*width:[[:space:]]*([^;]+);.*" text;
in
if m == null then null else lib.head m;
bad = lib.filter
(name:
let w = widthOf name; in
w == null
|| builtins.match ".*ch.*" w == null
|| builtins.match ".*min.*" w == null)
swaps;
ok =
expect (swaps != [ ])
"fixture drift: no theme ships a rofi.rasi the guard is checking nothing"
&& expect (bad == [ ])
("a whole-swap sizes its text menu without `ch` and a `min` cap, so it "
+ "cannot fit its own font's labels: " + lib.concatStringsSep ", " bad);
in
assert ok; pkgs.runCommand "nomarchy-rofi-text-width" { } "touch $out";
# Docking artifact guard: default/no-profile Home Manager output # Docking artifact guard: default/no-profile Home Manager output
# must still launch both hotplug watchers, put Rofi on the focused # must still launch both hotplug watchers, put Rofi on the focused
# monitor, expose the manual keyboard picker, and never ship the # monitor, expose the manual keyboard picker, and never ship the

View File

@@ -1805,7 +1805,20 @@ in
border = px t.ui.borderSize; border = px t.ui.borderSize;
border-color = mkLiteral "@accent"; border-color = mkLiteral "@accent";
border-radius = px t.ui.rounding; border-radius = px t.ui.rounding;
width = mkLiteral "40%"; # Sized in `ch` — the width of one digit in *this theme's* font —
# not in % of the monitor (#131). A menu has to fit its longest
# label, and that is a count of characters, so the window must
# scale with the font: 40% of a 1366 panel and 40% of a 2560 one
# give the same row 546px and 1024px, while a 14pt monospace theme
# needs ~25% more room than an 11pt one on both. 84ch fits the
# longest row we ship (69 chars) plus icon and padding, measured on
# hardware: 756px in GeistMono 11, 924px in JetBrainsMono 14.
# The `min` cap is what keeps that honest on a small screen — the
# window may never eat more than 65% of the panel, so a big font on
# a 1366 gets a capped 888px rather than sprawling. Grid views
# (theme picker, drun) override this per-invocation; they are sized
# by icons, not text.
width = mkLiteral "calc( 84ch min 65% )";
padding = px 8; padding = px 8;
}; };

View File

@@ -31,7 +31,7 @@ window {
border: 1px; border: 1px;
border-color: @frostEdge; border-color: @frostEdge;
border-radius: 14px; border-radius: 14px;
width: 620px; width: calc( 84ch min 65% );
location: center; location: center;
anchor: center; anchor: center;
padding: 16px; padding: 16px;

View File

@@ -28,7 +28,7 @@ window {
border: 1px; border: 1px;
border-color: @overlay; border-color: @overlay;
border-radius: 4px; border-radius: 4px;
width: 40%; width: calc( 84ch min 65% );
location: center; location: center;
anchor: center; anchor: center;
padding: 12px; padding: 12px;

View File

@@ -26,7 +26,7 @@ window {
border: 2px; border: 2px;
border-color: @paper; /* the warm paper frame (kanagawa's border tone) */ border-color: @paper; /* the warm paper frame (kanagawa's border tone) */
border-radius: 10px; border-radius: 10px;
width: 40%; width: calc( 84ch min 65% );
location: center; location: center;
anchor: center; anchor: center;
padding: 8px; padding: 8px;

View File

@@ -26,7 +26,7 @@ window {
border: 3px; /* a screen bezel */ border: 3px; /* a screen bezel */
border-color: @cyan; border-color: @cyan;
border-radius: 4px; /* boxy, faintly softened */ border-radius: 4px; /* boxy, faintly softened */
width: 40%; width: calc( 84ch min 65% );
location: center; location: center;
anchor: center; anchor: center;
padding: 10px; padding: 10px;

View File

@@ -36,7 +36,7 @@ window {
border: 1px; border: 1px;
border-color: @neonEdge; border-color: @neonEdge;
border-radius: 16px; border-radius: 16px;
width: 620px; width: calc( 84ch min 65% );
location: center; location: center;
anchor: center; anchor: center;
padding: 18px; padding: 18px;

View File

@@ -28,7 +28,7 @@ window {
border: 2px; border: 2px;
border-color: @frost1; border-color: @frost1;
border-radius: 14px; /* Nord is soft and rounded */ border-radius: 14px; /* Nord is soft and rounded */
width: 40%; width: calc( 84ch min 65% );
location: center; location: center;
anchor: center; anchor: center;
padding: 8px; padding: 8px;

View File

@@ -26,7 +26,7 @@ window {
border: 2px; border: 2px;
border-color: @amber; border-color: @amber;
border-radius: 0px; /* sharp: this is a terminal */ border-radius: 0px; /* sharp: this is a terminal */
width: 40%; width: calc( 84ch min 65% );
location: center; location: center;
anchor: center; anchor: center;
padding: 10px; padding: 10px;

View File

@@ -28,7 +28,7 @@ window {
border-bottom: 5px; border-bottom: 5px;
border-color: @yellow; border-color: @yellow;
border-radius: 15px; border-radius: 15px;
width: 40%; width: calc( 84ch min 65% );
/* No fixed height — size to content (fixed-num-lines:false) so every /* No fixed height — size to content (fixed-num-lines:false) so every
* entry shows without scrolling and there's no empty space below. */ * entry shows without scrolling and there's no empty space below. */
location: center; location: center;

View File

@@ -28,7 +28,7 @@ window {
border-bottom: 5px; border-bottom: 5px;
border-color: @yellow; border-color: @yellow;
border-radius: 15px; border-radius: 15px;
width: 40%; width: calc( 84ch min 65% );
/* No fixed height — size to content (fixed-num-lines:false) so every /* No fixed height — size to content (fixed-num-lines:false) so every
* entry shows without scrolling and there's no empty space below. */ * entry shows without scrolling and there's no empty space below. */
location: center; location: center;