diff --git a/dotfiles/noctalia/.local/state/noctalia/settings.toml b/dotfiles/noctalia/.local/state/noctalia/settings.toml
index 8f720a4..0f20afc 100644
--- a/dotfiles/noctalia/.local/state/noctalia/settings.toml
+++ b/dotfiles/noctalia/.local/state/noctalia/settings.toml
@@ -112,8 +112,8 @@ widget_order = [
[lockscreen_widgets.widget."lockscreen-login-box@eDP-1"]
box_height = 196.0
box_width = 810.0
- cx = 960.0
- cy = 852.5
+ cx = 960.000244140625
+ cy = 852.500244140625
output = "eDP-1"
placement_height = 1080.0
placement_width = 1920.0
diff --git a/dotfiles/zz-blankpad/.config/systemd/user/blankpad-desk-autoswitch.service b/dotfiles/zz-blankpad/.config/systemd/user/blankpad-desk-autoswitch.service
index 5d68c7a..4bc7cc4 100644
--- a/dotfiles/zz-blankpad/.config/systemd/user/blankpad-desk-autoswitch.service
+++ b/dotfiles/zz-blankpad/.config/systemd/user/blankpad-desk-autoswitch.service
@@ -5,15 +5,13 @@ ConditionHost=blankpad
[Service]
Type=simple
-User=%u
-Group=%u
-ExecStart=/usr/bin/env bash %h/.omp/scripts/desk-autoswitch.sh watch
+ExecStart=%h/.local/bin/desk-autoswitch.sh watch
Restart=on-failure
RestartSec=5
StandardOutput=journal
StandardError=journal
Environment=DESK_NIRI=/usr/bin/niri
-Environment=DESK_NOTIFY=noctalia msg notification-show
+Environment="DESK_NOTIFY=noctalia msg notification-show"
[Install]
WantedBy=default.target
diff --git a/dotfiles/zz-blankpad/.local/bin/desk-autoswitch.sh b/dotfiles/zz-blankpad/.local/bin/desk-autoswitch.sh
new file mode 120000
index 0000000..e64742a
--- /dev/null
+++ b/dotfiles/zz-blankpad/.local/bin/desk-autoswitch.sh
@@ -0,0 +1 @@
+../../../../scripts/desk-autoswitch.sh
\ No newline at end of file
diff --git a/omp-docs/log.md b/omp-docs/log.md
index b0c23be..0b467fa 100644
--- a/omp-docs/log.md
+++ b/omp-docs/log.md
@@ -15,7 +15,7 @@ timestamp: 2026-08-28
Branch `blankpad-de-redesign` (worktree `wt/de-redesign-20261009`). Full sweep + redesign of the niri/noctalia/Handy stack on blankpad.
-* **Desk profiles.** `cfg/display.kdl` retired to a symlink; per-desk profiles `desk-1-home.kdl` / `desk-2-portable.kdl` declare monitors by **EDID long name** (`// requires:` lines) — connector names reshuffle between docks, identity travels. `scripts/desk.sh` = list/current/switch/cycle/match/capture; apply = relink → `niri validate` (rollback on failure) → `load-config-file` → noctalia notification. `scripts/desk-autoswitch.sh` + `blankpad-desk-autoswitch.service` watch the `niri msg --json event-stream` for `OutputsChanged` and hot-apply the matching profile (debounced 1.5 s). PM1601 EDID still uncaptured (panel disconnected) — connector-keyed placeholder, `desk.sh capture` pending.
+* **Desk profiles.** `cfg/display.kdl` retired to a symlink; per-desk profiles `desk-1-home.kdl` / `desk-2-portable.kdl` declare monitors by **EDID long name** (`// requires:` lines) — connector names reshuffle between docks, identity travels. `scripts/desk.sh` = list/current/switch/cycle/match/capture; apply = relink → `niri validate` (rollback on failure) → `load-config-file` → noctalia notification. `scripts/desk-autoswitch.sh` + `blankpad-desk-autoswitch.service` watch `niri msg --json event-stream`: niri 26.04 has **no hotplug event** (`OutputConfigChanged` is only an ACK that an IPC/config output change was Applied, per niri-ipc); the daemon diffs the sorted output set carried by `WorkspacesChanged` and hot-applies the matching profile (debounced 1.5 s). Proven live with an eDP-1 off/on cycle: `[DP-2 eDP-1] → [DP-2] → [DP-2 eDP-1]`, apply + reload each time. PM1601 EDID still uncaptured (panel disconnected) — connector-keyed placeholder, `desk.sh capture` pending.
* **Keybinds.** `swap-window-left/right` on Mod+CTRL+H/L; move-column demoted to Mod+CTRL+Alt+H/L; 7 column-width presets 12.5–87.5 %; Print trio (full / gui / screen) via noctalia screenshot IPC; launcher Mod+D; Alt+Tab → noctalia window-switcher; Mod+V → clipboard panel (id verified live); Mod+Shift+D → desk cycle; Mod+Shift+Space → `handy --toggle-post-process`; lid-close → `noctalia msg session lock-and-suspend` (pairs with logind `HandleLidSwitch=ignore`). Cheat sheet now GENERATED: `scripts/gen-keybinds.sh` → `omp-docs/tools/niri/keybindings.md` (119 bindings).
* **Noctalia.** `config.toml` + `settings.toml` (lives in `~/.local/state/`, not `~/.config/`) tracked; `taskbar` widget added to bar center, applied live via `noctalia msg config-reload`. Docs corrected to the v5 `noctalia msg` API.
* **Handy.** Self-autostart killed (`~/.config/autostart/Handy.desktop` → `.AGENTTRASH`; `autostart_enabled=false`); niri owns autostart (`spawn-at-startup "handy" "--start-hidden"`). Fleet post-processing wired into the live store: provider `custom` → `http://192.168.0.143:7771/v1`, model `frank-secondary`, prompt `default_improve_transcriptions`. **Open breaker:** frank-secondary answers in `reasoning_content` and leaves `content` empty; per-request cure `chat_template_kwargs.enable_thinking=false` verified, but Handy cannot send it and the server is shared with fleet compaction — dedicated no-think port proposed. 0.9.6→0.9.8 upgrade is operator-owned (`yay -Syu handy-bin`).
diff --git a/omp-docs/tools/niri/displays.md b/omp-docs/tools/niri/displays.md
index 87d43cf..e686f5b 100644
--- a/omp-docs/tools/niri/displays.md
+++ b/omp-docs/tools/niri/displays.md
@@ -56,7 +56,7 @@ the ultrawide wins when present and the laptop panel is the fallback.
```mermaid
graph LR
- P[plug / unplug] --> E[niri event-stream
OutputsChanged]
+ P[plug / unplug] --> E[niri event-stream
WorkspacesChanged → output-set diff]
E --> M[desk-autoswitch.sh
debounced matcher]
M --> R{profile requires
subset of connected?}
R -->|best match| A[desk.sh apply:
validate - relink - reload - notify]
diff --git a/scripts/desk-autoswitch.sh b/scripts/desk-autoswitch.sh
index 5efc57f..29e48e8 100755
--- a/scripts/desk-autoswitch.sh
+++ b/scripts/desk-autoswitch.sh
@@ -1,6 +1,12 @@
#!/usr/bin/env bash
# scripts/desk-autoswitch.sh — hotplug daemon: apply the matching desk profile
-# whenever niri reports an OutputsChanged event.
+# when the SET OF OUTPUTS changes.
+#
+# niri has no dedicated hotplug event (verified on 26.04):
+# - WorkspacesChanged carries every workspace's `output`, so the sorted
+# unique set of outputs changes exactly on connect/disconnect.
+# - OutputConfigChanged is only an ACK that an IPC/config output change was
+# Applied (niri-ipc), NOT a hotplug signal.
#
# Usage: desk-autoswitch.sh watch
#
@@ -8,7 +14,7 @@
# DESK_AUTO_HOSTNAME only run on this hostname (default: blankpad)
# DESK_SCRIPT path to desk.sh (default ~/.local/bin/desk.sh)
# DESK_NIRI niri binary (default niri)
-# DESK_DEBOUNCE_S debounce window in seconds (default 1.5)
+# DESK_DEBOUNCE_S min seconds between applies (default 1.5)
set -euo pipefail
@@ -17,8 +23,10 @@ DESK="${DESK_SCRIPT:-$HOME/.local/bin/desk.sh}"
NIRI="${DESK_NIRI:-niri}"
DEBOUNCE="${DESK_DEBOUNCE_S:-1.5}"
-usage() {
- sed -n '2,12p' "$0" | sed 's/^# \{0,1\}//'
+usage() { sed -n '2,18p' "$0" | sed 's/^# \{0,1\}//'; }
+
+outputs_from_event() { # $1 = one JSON event line
+ jq -r '[.WorkspacesChanged.workspaces[]?.output // empty] | unique | join(" ")' <<<"$1" 2>/dev/null || true
}
watch_mode() {
@@ -26,16 +34,20 @@ watch_mode() {
echo "desk-autoswitch: initial match"
"$DESK" match --apply || true
- local last=0 now
+ local seen="" last_seen="" last=0 now
# One JSON event per line; reconnect forever (niri restart drops the stream).
while "$NIRI" msg --json event-stream 2>/dev/null | while IFS= read -r line; do
case "$line" in
- *OutputsChanged*)
+ *WorkspacesChanged*)
+ seen="$(outputs_from_event "$line")"
+ [ -n "$seen" ] || continue
+ [ "$seen" = "$last_seen" ] && continue
+ last_seen="$seen"
now="$(date +%s.%N)"
# swallow bursts inside the debounce window
if awk -v l="$last" -v n="$now" -v d="$DEBOUNCE" 'BEGIN { exit !(n - l >= d) }'; then
last="$now"
- echo "desk-autoswitch: OutputsChanged"
+ echo "desk-autoswitch: outputs changed [$seen]"
"$DESK" match --apply || true
fi
;;
diff --git a/scripts/desk.sh b/scripts/desk.sh
index 71d355b..4012786 100755
--- a/scripts/desk.sh
+++ b/scripts/desk.sh
@@ -61,7 +61,7 @@ requires() {
notify() {
# shellcheck disable=SC2086 # word splitting of the prefix is intended
- $NOTIFY "Desk" "$1" 2>/dev/null || true
+ $NOTIFY "Desk" "$1" >/dev/null 2>/dev/null || true
}
# Apply a profile: relink, validate live config, roll back on failure,