diff --git a/agent/HARDWARE-QUEUE.md b/agent/HARDWARE-QUEUE.md
index b80be40..ac8c898 100644
--- a/agent/HARDWARE-QUEUE.md
+++ b/agent/HARDWARE-QUEUE.md
@@ -20,10 +20,11 @@ QA machine), the **T14s** (webcam case).
Menu: type `sys` → **System** (dmenu path uses `rofi_menu` = `-i`;
apps/drun already worked via config.rasi). Spot-check System and
Tools filters the same way. Network picker inherits `rofi -i`.
-- [ ] **Charge-limit live apply (2026-07-10)** — on a laptop with
- `charge_control_end_threshold`: System › Battery limit → 80%;
- confirm `cat …/charge_control_end_threshold` is 80 without rebuild;
- unplug/replug AC, confirm still 80 (oneshot). User in `users` group.
+- [ ] **Charge-limit live apply (2026-07-10, retest)** — Latitude first
+ pass: menu could not leave 80; unplug jumped to 100 until replug.
+ After fix: System/Power → limit 60 then 90; toast shows “sysfs now
+ N%”; `cat …/charge_control_end_threshold` matches without rebuild.
+ Unplug, wait ~3s, still N% (delayed oneshot); plug still N%.
- [ ] **#41 float classes (polkit / pinentry / portal / blueman)** — after
rebuild: (1) `pkexec true` or mount a disk → polkit dialog should
float+center; if not, note `hyprctl clients` class while open.
diff --git a/modules/home/hyprland.nix b/modules/home/hyprland.nix
index 6a3c006..6418eff 100644
--- a/modules/home/hyprland.nix
+++ b/modules/home/hyprland.nix
@@ -493,10 +493,17 @@ in
# Polkit auth (hyprpolkitagent — app id / binary name in the package)
# and pinentry-qt (keys.nix default; org.gnupg.pinentry-qt desktop).
+ # workspace current: agent was started on ws1 at login; without this
+ # the dialog opens on that workspace and the current one looks dead
+ # (hardware: Snapshots menu from ws≠1). stayfocused so it grabs
+ # attention when it lands.
"float 1, match:class (?i)^(hyprpolkitagent|\\.hyprpolkitagent-wrapped)$"
"center 1, match:class (?i)^(hyprpolkitagent|\\.hyprpolkitagent-wrapped)$"
+ "workspace current, match:class (?i)^(hyprpolkitagent|\\.hyprpolkitagent-wrapped)$"
+ "stayfocused 1, match:class (?i)^(hyprpolkitagent|\\.hyprpolkitagent-wrapped)$"
"float 1, match:class (?i)^(pinentry(-qt)?|org\\.gnupg\\.pinentry-qt)$"
"center 1, match:class (?i)^(pinentry(-qt)?|org\\.gnupg\\.pinentry-qt)$"
+ "workspace current, match:class (?i)^(pinentry(-qt)?|org\\.gnupg\\.pinentry-qt)$"
];
# Rendered from ./keybinds.nix (the cheatsheet reads the same list),
diff --git a/modules/home/keybinds.nix b/modules/home/keybinds.nix
index eb43298..a1ce990 100644
--- a/modules/home/keybinds.nix
+++ b/modules/home/keybinds.nix
@@ -72,7 +72,7 @@
# Bare Print → region to clipboard; the two → file binds save a
# timestamped PNG under ~/Pictures/Screenshots and toast the path, the
# same plumbing the Capture menu's "→ file" rows use.
- { mods = ""; key = "Print"; action = "exec, grim -g \"$(slurp)\" - | wl-copy"; desc = "Screenshot region → clipboard"; }
+ { mods = ""; key = "Print"; action = "exec, grim -g \"$(slurp)\" - | wl-copy && notify-send Screenshot \"Region copied to clipboard.\""; desc = "Screenshot region → clipboard"; }
{ mods = "SHIFT"; key = "Print"; action = "exec, f=$HOME/Pictures/Screenshots/$(date +%Y%m%d-%H%M%S).png; mkdir -p $HOME/Pictures/Screenshots && grim -g \"$(slurp)\" \"$f\" && notify-send \"Screenshot saved\" \"$f\""; desc = "Screenshot region → file"; }
{ mods = "CTRL"; key = "Print"; action = "exec, f=$HOME/Pictures/Screenshots/$(date +%Y%m%d-%H%M%S).png; mkdir -p $HOME/Pictures/Screenshots && grim \"$f\" && notify-send \"Screenshot saved\" \"$f\""; desc = "Screenshot screen → file"; }
{ mods = "$mod SHIFT"; key = "Print"; action = "exec, f=$HOME/Pictures/Screenshots/$(date +%Y%m%d-%H%M%S).png; mkdir -p $HOME/Pictures/Screenshots && grim -g \"$(slurp)\" - | satty --filename - --fullscreen --output-filename \"$f\""; desc = "Annotate region"; }
diff --git a/modules/home/rofi.nix b/modules/home/rofi.nix
index bdb9f5d..20f4e12 100644
--- a/modules/home/rofi.nix
+++ b/modules/home/rofi.nix
@@ -321,26 +321,48 @@ let
fi
back
} | rofi_menu -show-icons -p "Power — profile: ''${curp:-n/a}, limit: ''${curc:-default}") || exit 0
- # Persist in-flake + apply live to sysfs (udev makes the node
- # group-writable for users — Bernardo 2026-07-10).
+ # Persist in-flake, then re-apply via the root oneshot (polkit
+ # allows users to restart it) so a non-writable sysfs node still
+ # updates. Fallback: direct echo if the node is group-writable.
set_limit() {
nomarchy-theme-sync --quiet set settings.power.batteryChargeLimit "$1" --no-switch
n="$1"; [ "$n" = null ] && n=100
+ applied=
+ if systemctl restart nomarchy-battery-charge-limit.service 2>/dev/null; then
+ applied=1
+ else
+ for d in /sys/class/power_supply/*/; do
+ [ "$(cat "$d/type" 2>/dev/null)" = Battery ] || continue
+ [ "$(cat "$d/scope" 2>/dev/null || echo System)" = Device ] && continue
+ thresh="$d/charge_control_end_threshold"
+ if [ -w "$thresh" ] && echo "$n" > "$thresh" 2>/dev/null; then
+ applied=1
+ fi
+ done
+ fi
+ actual=
for d in /sys/class/power_supply/*/; do
[ "$(cat "$d/type" 2>/dev/null)" = Battery ] || continue
[ "$(cat "$d/scope" 2>/dev/null || echo System)" = Device ] && continue
- thresh="$d/charge_control_end_threshold"
- [ -w "$thresh" ] && echo "$n" > "$thresh" 2>/dev/null || true
+ [ -r "$d/charge_control_end_threshold" ] \
+ && actual=$(cat "$d/charge_control_end_threshold" 2>/dev/null) && break
done
- notify-send "Battery limit" "$2 — applied now."
+ if [ -n "$applied" ] && [ -n "$actual" ]; then
+ notify-send "Battery limit" "$2 — sysfs now $actual%."
+ elif [ -n "$applied" ]; then
+ notify-send "Battery limit" "$2 — applied (could not re-read sysfs)."
+ else
+ notify-send -u critical "Battery limit" \
+ "Saved in theme-state but could not write sysfs (need rebuild or users-group on threshold)."
+ fi
}
case "$choice" in
"$BACK") exec "$0" ;;
"Profile: "*) powerprofilesctl set "''${choice#Profile: }" ;;
- "Charge limit: 80%"*) set_limit 80 "Set to 80%" ;;
- "Charge limit: 90%"*) set_limit 90 "Set to 90%" ;;
- "Charge limit: 60%"*) set_limit 60 "Set to 60%" ;;
- "Charge limit: Off"*) set_limit null "Off — charges to 100%" ;;
+ *"Charge limit: 80%"*) set_limit 80 "Set to 80%" ;;
+ *"Charge limit: 90%"*) set_limit 90 "Set to 90%" ;;
+ *"Charge limit: 60%"*) set_limit 60 "Set to 60%" ;;
+ *"Charge limit: Off"*) set_limit null "Off — charges to 100%" ;;
esac ;;
theme)
@@ -521,7 +543,9 @@ ${themeRows}
file="$dir/$(date +%Y%m%d-%H%M%S).png"
case "$choice" in
"$BACK") exec "$0" tools ;;
- *"Region → clipboard"*) grim -g "$(slurp)" - | wl-copy ;;
+ *"Region → clipboard"*)
+ grim -g "$(slurp)" - | wl-copy \
+ && notify-send "Screenshot" "Region copied to clipboard." ;;
*"Region → file"*) mkdir -p "$dir"; grim -g "$(slurp)" "$file" \
&& notify-send "Screenshot saved" "$file" ;;
*"Annotate region"*) mkdir -p "$dir"; grim -g "$(slurp)" - | satty --filename - --fullscreen --output-filename "$file" ;;
@@ -535,7 +559,9 @@ ${themeRows}
|| { notify-send "OCR" "No text recognized in that region."; exit 0; }
printf '%s' "$txt" | wl-copy
notify-send "OCR" "Copied $(printf '%s' "$txt" | wc -w) word(s) to the clipboard." ;;
- *"Full screen → clipboard"*) grim - | wl-copy ;;
+ *"Full screen → clipboard"*)
+ grim - | wl-copy \
+ && notify-send "Screenshot" "Screen copied to clipboard." ;;
*"Full screen → file"*) mkdir -p "$dir"; grim "$file" \
&& notify-send "Screenshot saved" "$file" ;;
*"Stop recording"*) exec nomarchy-record stop ;;
@@ -937,21 +963,43 @@ ${themeRows}
set_limit() {
nomarchy-theme-sync --quiet set settings.power.batteryChargeLimit "$1" --no-switch
n="$1"; [ "$n" = null ] && n=100
+ applied=
+ if systemctl restart nomarchy-battery-charge-limit.service 2>/dev/null; then
+ applied=1
+ else
+ for d in /sys/class/power_supply/*/; do
+ [ "$(cat "$d/type" 2>/dev/null)" = Battery ] || continue
+ [ "$(cat "$d/scope" 2>/dev/null || echo System)" = Device ] && continue
+ thresh="$d/charge_control_end_threshold"
+ if [ -w "$thresh" ] && echo "$n" > "$thresh" 2>/dev/null; then
+ applied=1
+ fi
+ done
+ fi
+ actual=
for d in /sys/class/power_supply/*/; do
[ "$(cat "$d/type" 2>/dev/null)" = Battery ] || continue
[ "$(cat "$d/scope" 2>/dev/null || echo System)" = Device ] && continue
- thresh="$d/charge_control_end_threshold"
- [ -w "$thresh" ] && echo "$n" > "$thresh" 2>/dev/null || true
+ [ -r "$d/charge_control_end_threshold" ] \
+ && actual=$(cat "$d/charge_control_end_threshold" 2>/dev/null) && break
done
- notify-send "Battery limit" "$2 — applied now."
+ if [ -n "$applied" ] && [ -n "$actual" ]; then
+ notify-send "Battery limit" "$2 — sysfs now $actual%."
+ elif [ -n "$applied" ]; then
+ notify-send "Battery limit" "$2 — applied (could not re-read sysfs)."
+ else
+ notify-send -u critical "Battery limit" \
+ "Saved in theme-state but could not write sysfs (need rebuild or users-group on threshold)."
+ fi
}
case "$sel" in
"$BACK") exec "$0" system ;;
- "80%"*) set_limit 80 "Set to 80%" ;;
- "90%"*) set_limit 90 "Set to 90%" ;;
- "60%"*) set_limit 60 "Set to 60%" ;;
- "Off"*) set_limit null "Off — charges to 100%" ;;
- "Custom"*)
+ # Order: Off (100%) before 90/80/60 so "100%" does not hit *0%* patterns.
+ *Off*|*100%*) set_limit null "Off — charges to 100%" ;;
+ *90%*) set_limit 90 "Set to 90%" ;;
+ *80%*) set_limit 80 "Set to 80%" ;;
+ *60%*) set_limit 60 "Set to 60%" ;;
+ *Custom*)
limit=$(rofi_menu -p "Charge limit % (50–100)" < /dev/null) || exit 0
[ -n "$limit" ] || exit 0
case "$limit" in
diff --git a/modules/home/waybar.nix b/modules/home/waybar.nix
index b8b1f4d..1aeb3b3 100644
--- a/modules/home/waybar.nix
+++ b/modules/home/waybar.nix
@@ -204,12 +204,14 @@ let
# into clock + clock#date — one click surface for the calendar).
format = "{:%H:%M · %a %d %b}";
# Left-click → the calendar (nomarchy-calendar → calcurse in a floating
- # ghostty). Long weekday/month name + zone + month grid ride in the
- # tooltip so the bar stays compact.
+ # ghostty). Tooltip is plain date/zone only — embedding {calendar}
+ # produced an empty popup on hardware (2026-07-10); month view is
+ # one click away.
on-click = "nomarchy-calendar";
+ tooltip = true;
# The zone line stays live under auto-timezone (the tz-watch SIGUSR2
# reload keeps it showing the currently *detected* zone).
- tooltip-format = "{:%A %d %B %Y}\n{:%Z (UTC%z)}\n{calendar}";
+ tooltip-format = "{:%A, %d %B %Y}\n{:%Z (UTC%z)}";
};
# Active keyboard layout (per focused device) — only placed in
@@ -503,10 +505,12 @@ in
);
};
- # The power-profile helpers on PATH, so both the generated bar and the
- # whole-swap themes' static waybar.jsonc can exec them by name — plus
- # the supervisor hyprland.nix exec-onces. calcurse is the clock-click
- # calendar (feature dependency of the bar, not an opt-in app).
+ # Bar helpers on PATH (whole-swap jsoncs exec them by bare name) +
+ # feature deps of bar clicks: calcurse (clock), pwvucontrol (volume
+ # right-click) — not opt-in template packages.
home.packages = lib.optionals config.nomarchy.waybar.enable
- [ waybarSupervisor powerProfileStatus powerProfileCycle vpnStatus doctorStatus calendarLauncher pkgs.calcurse ];
+ [ waybarSupervisor powerProfileStatus powerProfileCycle vpnStatus doctorStatus calendarLauncher
+ pkgs.calcurse
+ pkgs.pwvucontrol
+ ];
}
diff --git a/modules/nixos/power.nix b/modules/nixos/power.nix
index 1164d40..c537735 100644
--- a/modules/nixos/power.nix
+++ b/modules/nixos/power.nix
@@ -74,7 +74,16 @@ in
[ "$(cat "$d/type" 2>/dev/null)" = Battery ] || continue
[ "$(cat "$d/scope" 2>/dev/null || echo System)" = Device ] && continue
thresh="$d/charge_control_end_threshold"
- [ -w "$thresh" ] && echo "$limit" > "$thresh" || true
+ [ -e "$thresh" ] || continue
+ # Keep the node group-writable for the menu's live path (udev
+ # sets this on add; re-assert after firmware recreates attrs).
+ chgrp users "$thresh" 2>/dev/null || true
+ chmod 0664 "$thresh" 2>/dev/null || true
+ # Some firmwares (Dell) reset the threshold on unplug *after*
+ # the udev event — write once, wait, write again.
+ echo "$limit" > "$thresh" 2>/dev/null || true
+ sleep 1
+ echo "$limit" > "$thresh" 2>/dev/null || true
done
exit 0
'';
@@ -82,9 +91,33 @@ in
# Writable threshold for the logged-in user (menu live apply) + AC
# re-apply of the oneshot. Laptop only; no-op when the attr is absent.
- services.udev.extraRules = lib.mkIf cfg.laptop ''
- SUBSYSTEM=="power_supply", ATTR{type}=="Battery", TEST=="charge_control_end_threshold", GROUP="users", MODE="0664"
- SUBSYSTEM=="power_supply", ATTR{type}=="Mains", RUN+="${config.systemd.package}/bin/systemctl --no-block restart nomarchy-battery-charge-limit.service"
+ # Immediate + delayed restart: Dell/Latitude firmware often stomps the
+ # threshold to 100 right after unplug; a single immediate oneshot
+ # loses the race (hardware: unplug→100, plug→80). systemd-run hands
+ # the delayed restart off so udev's short RUN budget is not held.
+ services.udev.extraRules = lib.mkIf cfg.laptop (
+ let
+ systemctl = "${config.systemd.package}/bin/systemctl";
+ systemdRun = "${config.systemd.package}/bin/systemd-run";
+ in ''
+ SUBSYSTEM=="power_supply", ATTR{type}=="Battery", TEST=="charge_control_end_threshold", GROUP="users", MODE="0664"
+ SUBSYSTEM=="power_supply", ATTR{type}=="Mains", RUN+="${systemctl} --no-block restart nomarchy-battery-charge-limit.service"
+ SUBSYSTEM=="power_supply", ATTR{type}=="Mains", RUN+="${systemdRun} --no-block --collect --on-active=2s --timer-property=AccuracySec=200ms ${systemctl} --no-block restart nomarchy-battery-charge-limit.service"
+ ''
+ );
+
+ # Unprivileged restart of the oneshot so the menu can re-apply as
+ # root when the threshold node is not (yet) user-writable.
+ security.polkit.extraConfig = lib.mkIf cfg.laptop ''
+ polkit.addRule(function(action, subject) {
+ if (action.id == "org.freedesktop.systemd1.manage-units" &&
+ subject.isInGroup("users")) {
+ var unit = action.lookup("unit");
+ if (unit == "nomarchy-battery-charge-limit.service") {
+ return polkit.Result.YES;
+ }
+ }
+ });
'';
};
}
diff --git a/templates/downstream/home.nix b/templates/downstream/home.nix
index c722e49..7292cc3 100644
--- a/templates/downstream/home.nix
+++ b/templates/downstream/home.nix
@@ -85,8 +85,7 @@
inkscape # vector graphics
mpv # media player
amberol # music player (local library; streaming → spotify below)
- pwvucontrol # PipeWire volume mixer (right-click the Waybar volume)
- # calcurse ships with the Waybar clock (modules/home/waybar.nix)
+ # pwvucontrol + calcurse ship with the Waybar module (volume right-click / clock)
# ── More apps — uncomment to add ─────────────────────────────────
# Web browsers
diff --git a/themes/boreal/waybar.jsonc b/themes/boreal/waybar.jsonc
index 638362e..47706b0 100644
--- a/themes/boreal/waybar.jsonc
+++ b/themes/boreal/waybar.jsonc
@@ -40,7 +40,7 @@
"format": "{:%H:%M · %a %d %b}",
"on-click": "nomarchy-calendar",
"tooltip": true,
- "tooltip-format": "{:%A %d %B %Y}\n{:%Z (UTC%z)}\n{calendar}"
+ "tooltip-format": "{:%A, %d %B %Y}\n{:%Z (UTC%z)}"
},
"hyprland/workspaces": {
"disable-scroll": true,
diff --git a/themes/executive-slate/waybar.jsonc b/themes/executive-slate/waybar.jsonc
index 602b672..11ac2cb 100644
--- a/themes/executive-slate/waybar.jsonc
+++ b/themes/executive-slate/waybar.jsonc
@@ -47,7 +47,7 @@
"format": "{:%H:%M · %a %d %b}",
"on-click": "nomarchy-calendar",
"tooltip": true,
- "tooltip-format": "{:%A %d %B %Y}\n{:%Z (UTC%z)}\n{calendar}"
+ "tooltip-format": "{:%A, %d %B %Y}\n{:%Z (UTC%z)}"
},
"custom/recording": {
"return-type": "json",
diff --git a/themes/summer-day/waybar.jsonc b/themes/summer-day/waybar.jsonc
index 0c7a227..75a3a7a 100644
--- a/themes/summer-day/waybar.jsonc
+++ b/themes/summer-day/waybar.jsonc
@@ -97,7 +97,7 @@
"format": " {:%H:%M · %a %d %b}",
"on-click": "nomarchy-calendar",
"tooltip": true,
- "tooltip-format": "{:%A %d %B %Y}\n{:%Z (UTC%z)}\n{calendar}"
+ "tooltip-format": "{:%A, %d %B %Y}\n{:%Z (UTC%z)}"
},
"tray": {
"spacing": 8
diff --git a/themes/summer-night/waybar.jsonc b/themes/summer-night/waybar.jsonc
index 4e81bfe..cd955dc 100644
--- a/themes/summer-night/waybar.jsonc
+++ b/themes/summer-night/waybar.jsonc
@@ -96,7 +96,7 @@
"format": " {:%H:%M · %a %d %b}",
"on-click": "nomarchy-calendar",
"tooltip": true,
- "tooltip-format": "{:%A %d %B %Y}\n{:%Z (UTC%z)}\n{calendar}"
+ "tooltip-format": "{:%A, %d %B %Y}\n{:%Z (UTC%z)}"
},
"tray": {
"spacing": 8