fix(display): #142 — dock mode is an intent, and survives a rebuild
All checks were successful
Check / eval (push) Successful in 3m48s

Every rebuild while docked re-lit the panel and threw a workspace onto
it. Not forgetfulness: a rebuild PERFORMS an undock. Dock mode lived only
in the compositor (`keyword monitor <internal>, disable`, nothing written
down), the config's only monitor line is the catch-all
`monitor=,preferred,auto,1`, and rewriting the config makes Hyprland
reload it — re-enabling every output. Our own undock path already uses
`hyprctl reload` as exactly that hammer (hardware-proven 2026-07-14).

The watcher could not rescue it: reconcile diffs `monitors all`, which
lists disabled outputs, so the connected set is identical across the flip
— structurally blind, the blind spot of rounds 4-5's "the output set is
the truth".

So the intent is recorded where every other setting lives —
settings.display.dockMode, in-flake, menu-writable — written at the one
choke point both menu and watcher pass through (transition: dock → true,
undock → false, `enable` of an internal → false, because "Screen on" IS
leaving dock mode and must not be undone a second later). The watcher
re-asserts it as a tick invariant rather than an event reaction, which is
what makes the reload case work; auto_dock was already idempotent, so it
costs one `hyprctl monitors` per tick.

The trap avoided: baking the disable into the config would survive a
reload and hand a user with no external a zero-output session at login —
the #100/#127 brick. The config cannot know what is plugged in, so it
keeps defaulting to "everything on" and the re-assert is gated on an
external being present.

Verified: 9/9 gates unit-tested by driving the REAL generated function
against stubs (incl. no-external → never re-dock, profile active → yield,
already-docked → no churn), plus the state write/read round-trip on a
throwaway checkout so the live flake was untouched. V3 queued — needs a
relogin, the watcher is exec-once.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-16 11:51:52 +01:00
parent 02ec0f10cd
commit 30e632ff65
4 changed files with 111 additions and 47 deletions

View File

@@ -253,53 +253,6 @@ Recommend (a) + (b) together if the patch stays small: file it upstream, carry
it until it lands, delete on the release that fixes it. `[human]` for whether it until it lands, delete on the release that fixes it. `[human]` for whether
a source-built rofi is a price worth paying. a source-built rofi is a price worth paying.
### 142. A rebuild while docked undoes dock mode (the panel comes back, a workspace jumps to it)
Bernardo 2026-07-16: every rebuild while docked lands in the undocked state —
laptop panel back on, a workspace moved onto it — and he re-picks **Dock mode**
in the Display menu by hand. Diagnosed statically on the dev box (no session
disruption needed; our own code already proves each step):
1. **Dock mode is runtime-only.** `nomarchy-display-transition dock` ends with
`hyprctl keyword monitor <internal>, disable` (`hyprland.nix:205`) — a live
compositor command. Nothing writes it down: his `state.json` has no display
key at all, so dock mode is the one display setting outside the in-flake
state, which is exactly why it cannot survive anything.
2. **Any config reload re-enables everything.** The generated config's only
monitor line is the catch-all `monitor=,preferred,auto,1` (live
hyprland.conf:175 — he has no `displayProfiles`), so a reload turns every
output back on, `eDP-1` included, and Hyprland puts a workspace on the
output that just appeared. We do not have to guess that a reload does this:
the **undock** path uses `hyprctl reload` deliberately, as the hammer that
re-enables an inert disabled panel, hardware-proven 2026-07-14
(`hyprland.nix:225`). A rebuild reloads the config and therefore *performs
an undock*, precisely and by construction.
3. **The watcher cannot see it happen.** `reconcile` diffs `outputs()` =
`hyprctl monitors all` (`:505`), which lists **disabled** monitors too. So
the connected set is byte-identical before and after the flip, `cur !=
known_outputs` is false, and reconcile returns having done nothing. The
watcher tracks connect/disconnect and is structurally blind to
enable/disable — the blind spot in round 4/5's "the output set, not the
event, is the truth" (ROADMAP § Docking rounds 45).
**Shape, and the trap in the obvious fix.** The tempting move is to bake the
disable into the config (state → `monitor=eDP-1,disable`) so a reload preserves
it. **Do not** without a guard: a persisted disable with no external attached
is a zero-output session at login — the black brick #100 and #127 are both
about. The config is evaluated with no knowledge of what is plugged in, so it
must keep defaulting to "everything on". Better: persist the *intent*
(`settings.display.dockMode`, in-flake, menu-writable — the philosophy this
currently violates) and let the **watcher** re-assert it, since only it knows
what is live. That needs reconcile to also track the *enabled* set, so
"internal came back on while an external is present and the state says docked"
becomes an event it can act on — which fixes the reload case and any other
path that silently re-enables the panel. Gate re-assertion on an external
actually being present, never on the state key alone.
Pass = docked, `nomarchy-home` (and `nomarchy-rebuild`) → panel stays off, no
workspace moves, no menu trip; undocked rebuild unchanged; unplug while the
state says docked still restores the panel.
### 120. A netinstall ISO, next to the fat offline one ### 120. A netinstall ISO, next to the fat offline one
Bernardo 2026-07-14, after seeing the measured size: **keep the current ISO Bernardo 2026-07-14, after seeing the measured size: **keep the current ISO

View File

@@ -509,6 +509,22 @@ Everything else below stays open; order is convenience, not a gate.
active and PPD switching governors; opt-ins: ROCm (`rocminfo`, a GPU active and PPD switching governors; opt-ins: ROCm (`rocminfo`, a GPU
PyTorch/Ollama smoke) and the XDNA NPU driver loading. PyTorch/Ollama smoke) and the XDNA NPU driver loading.
- [ ] **Fingerprint**`fprintd-enroll` + (opt-in PAM) login/sudo. - [ ] **Fingerprint**`fprintd-enroll` + (opt-in PAM) login/sudo.
- [ ] **#142 dock mode survives a rebuild** — **relogin first** (the watcher is
`exec-once`; `nomarchy-home` alone leaves the old one running and you
would be testing nothing — round 6's lesson). Then, docked: run
`nomarchy-home`. Pass = the laptop panel stays off, no workspace lands on
it, no menu trip; `journalctl --user -t nomarchy-display-watch` shows
`dock-intent=true panel=eDP-1 state=re-enabled action=re-dock` (a rebuild
re-lights the panel and the watcher takes it back within ~1s, so a brief
flicker is expected — the panel staying *up* is the failure).
Then the three gates that must NOT fire, since this code can disable a
panel: (a) Menu ▸ Display ▸ **Screen on** → the panel stays on (intent
cleared; if it snaps off again within a second, that is the bug);
(b) **undock** → panel returns and stays; (c) boot/relogin **undocked**
with `settings.display.dockMode` still `true` in state.json → panel stays
on (no external ⇒ never re-dock). (c) is the one that matters: it is the
#127 brick if it is wrong. Gates are unit-tested against stubs, but only
hardware proves the wiring.
- [ ] **#138 dock audio does not break a running browser** — the plug event - [ ] **#138 dock audio does not break a running browser** — the plug event
itself is the only thing V2 could not do (the tool was driven by hand; itself is the only thing V2 could not do (the tool was driven by hand;
Hyprland calls the same entry point). **Relogin first** — the watcher is Hyprland calls the same entry point). **Relogin first** — the watcher is

View File

@@ -449,6 +449,36 @@ 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.
- ✓ **Dock mode survives a rebuild (#142, 2026-07-16):** Bernardo: every rebuild
while docked re-lit the laptop panel and threw a workspace onto it, and he
re-picked Dock mode by hand. It was not forgetfulness — **a rebuild performed
an undock, by construction**. Dock mode existed only inside the compositor
(`keyword monitor <internal>, disable`, nothing written down), the generated
config's only monitor line is the catch-all `monitor=,preferred,auto,1`, and a
rewritten config makes Hyprland reload — which re-enables every output. We did
not have to theorise that: the **undock** path already uses `hyprctl reload`
as precisely that hammer, hardware-proven 2026-07-14. And the watcher could
not rescue it, because `reconcile` diffs `monitors all`, which lists disabled
outputs too — so the connected set is identical across the flip and the
watcher is structurally blind to it (the blind spot of rounds 45's "the
output set, not the event, is the truth").
Fix: dock mode is an **intent** now, recorded where every other setting lives
— `settings.display.dockMode`, in-flake and menu-writable — written at the one
choke point both the menu and the watcher pass through
(`nomarchy-display-transition`: dock → true, undock → false, and `enable` of
an internal → false, since "Screen on" *is* leaving dock mode and must not be
undone a second later). The watcher then re-asserts it as a **tick invariant**
rather than an event reaction, which is what makes the reload case work at
all; `auto_dock` was already idempotent (it returns early unless the panel is
up), so the cost is one `hyprctl monitors` per tick.
**The trap avoided** was baking the disable into the config (state →
`monitor=eDP-1,disable`), which would survive a reload beautifully and, with
no external attached, hand the user a zero-output session at login — the black
brick of #100/#127. The config cannot know what is plugged in, so it keeps
defaulting to "everything on" and the re-assert is gated on an external
actually being present. Verified by unit-testing the real generated function
against stubs — 9/9, including *no external → never re-dock* — plus the state
write/read round-trip on a throwaway checkout.
- ✓ **MIGRATION.md: Mozilla `profiles.ini` (#144, 2026-07-16):** a real - ✓ **MIGRATION.md: Mozilla `profiles.ini` (#144, 2026-07-16):** a real
migration ate Bernardo's `~/.thunderbird/profiles.ini`, so Thunderbird did migration ate Bernardo's `~/.thunderbird/profiles.ini`, so Thunderbird did
what it does on any new machine — made an empty profile and opened it — what it does on any new machine — made an empty profile and opened it —

View File

@@ -190,6 +190,23 @@ let
done done
} }
# Dock mode is an *intent*, and until #142 it lived only in the compositor:
# `keyword monitor <internal>, disable` and nothing written down. Any config
# reload re-applies the catch-all `monitor=,preferred,auto,1` and re-lights
# the panel which is exactly why `undock` below uses `hyprctl reload` as
# its hammer so every `nomarchy-home` silently performed an undock and the
# user re-picked Dock mode by hand.
#
# So record the intent where every other setting lives (the in-flake state,
# menu-writable), and let the watcher re-assert it. `--no-switch`: the
# watcher reads this at run time; a rebuild here would be both pointless and
# recursive. Failure to write is never fatal a session that cannot reach
# the state file must still dock.
set_dock_intent() { # $1 = true|false
${sync} --quiet set settings.display.dockMode "$1" --no-switch \
>/dev/null 2>&1 || log "dock-intent=$1 result=state-write-failed"
}
case "''${1:-}" in case "''${1:-}" in
dock) dock)
internal="''${2:-}"; external="''${3:-}" internal="''${2:-}"; external="''${3:-}"
@@ -204,6 +221,7 @@ let
if hyprctl --batch \ if hyprctl --batch \
"keyword monitor $rule; $moves dispatch focusmonitor $external; keyword monitor $internal, disable" \ "keyword monitor $rule; $moves dispatch focusmonitor $external; keyword monitor $internal, disable" \
>/dev/null 2>&1; then >/dev/null 2>&1; then
set_dock_intent true
log "transition=dock internal=$internal external=$external result=ok" log "transition=dock internal=$internal external=$external result=ok"
else else
log "transition=dock internal=$internal external=$external result=failed" log "transition=dock internal=$internal external=$external result=failed"
@@ -212,6 +230,10 @@ let
undock) undock)
internal="''${2:-}" internal="''${2:-}"
valid_output "$internal" || { echo "invalid monitor name" >&2; exit 64; } valid_output "$internal" || { echo "invalid monitor name" >&2; exit 64; }
# Clear the intent *first*: this path runs when the external is already
# gone, and if anything below fails the last thing the session needs is
# a watcher that keeps trying to dock to a monitor that left.
set_dock_intent false
# Safety-critical ordering: never remove/move away from an external # Safety-critical ordering: never remove/move away from an external
# before the internal panel is active and can receive workspaces. # before the internal panel is active and can receive workspaces.
# #
@@ -262,6 +284,14 @@ let
enable) enable)
internal="''${2:-}" internal="''${2:-}"
valid_output "$internal" || exit 64 valid_output "$internal" || exit 64
# Menu Display "Screen on". Turning the laptop panel back on *is*
# leaving dock mode, and saying so is what stops the watcher from
# re-docking the panel out from under the user a second later (#142).
# Only the internal clears the intent: lighting some third monitor is
# not a statement about the lid.
case "$internal" in
eDP*|LVDS*|DSI*) set_dock_intent false ;;
esac
# Reachable only from the menu, i.e. with a screen already on, so the # Reachable only from the menu, i.e. with a screen already on, so the
# keyword flushes and no reload escalation is needed but still make # keyword flushes and no reload escalation is needed but still make
# `monitors` the proof, or the menu's "back on" notification cheers # `monitors` the proof, or the menu's "back on" notification cheers
@@ -558,6 +588,37 @@ ${lib.concatStringsSep "\n" (lib.mapAttrsToList
fi fi
} }
# Dock mode has to be re-asserted, not just entered (#142). `reconcile`
# below diffs the *connected* set (`monitors all`, which lists disabled
# outputs too), so it is blind by construction to a panel being switched
# back **on**: the connected set does not change, and every rebuild does
# exactly that Hyprland reloads the rewritten config, the catch-all
# `monitor=,preferred,auto,1` re-lights the internal, and a workspace lands
# on it. So the invariant is checked on every tick instead of on events:
# if the user's recorded intent is dock and the panel is up while an
# external is present, put it back.
#
# `auto_dock` is already idempotent it returns early unless the internal
# is enabled so this costs one `hyprctl monitors` per tick and does
# nothing in the overwhelmingly common case. Gated on the same display
# profile check as the event path: a profile is a deliberate, more specific
# layout and outranks the dock heuristic.
enforce_dock_intent() {
[ "$(${sync} get settings.display.dockMode 2>/dev/null)" = true ] || return
auto_on || return
[ "$(nomarchy-display-profile active 2>/dev/null || echo none)" = "none" ] || return
internal=$(internal_output)
[ -n "$internal" ] || return
# Only with an external actually present: intent must never be able to
# black out a laptop that has nowhere else to draw (#127's brick).
[ -n "$(external_outputs)" ] || return
hyprctl monitors -j 2>/dev/null \
| jq -e --arg m "$internal" 'any(.[]; .name == $m)' >/dev/null || return
log "dock-intent=true panel=$internal state=re-enabled action=re-dock"
acquire_inhibitor
auto_dock
}
# The set of connected outputs not the IPC event stream is the source # The set of connected outputs not the IPC event stream is the source
# of truth. Hyprland's socket can miss or drop events (see the loop # of truth. Hyprland's socket can miss or drop events (see the loop
# below), and a lost monitoradded used to mean no docking profile, no # below), and a lost monitoradded used to mean no docking profile, no
@@ -656,6 +717,10 @@ ${lib.concatStringsSep "\n" (lib.mapAttrsToList
release_inhibitor release_inhibitor
awaiting_lid_open= awaiting_lid_open=
fi fi
# Last, because it re-derives `internal` and the invariants above must
# see reconcile's own value: a queued undock outranks a stale dock
# intent, and this returns early anyway while no external is present.
enforce_dock_intent
} }
# Login/relogin while already docked still needs protection. Do not # Login/relogin while already docked still needs protection. Do not