fix(display): #142 — dock mode is an intent, and survives a rebuild
All checks were successful
Check / eval (push) Successful in 3m48s
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:
@@ -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
|
||||
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 4–5).
|
||||
|
||||
**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
|
||||
|
||||
Bernardo 2026-07-14, after seeing the measured size: **keep the current ISO
|
||||
|
||||
@@ -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
|
||||
PyTorch/Ollama smoke) and the XDNA NPU driver loading.
|
||||
- [ ] **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
|
||||
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
|
||||
|
||||
@@ -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
|
||||
`size` rule. Measured after: calendar 1536×936 = exactly 60%×65%, doctor
|
||||
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 4–5'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 ate Bernardo's `~/.thunderbird/profiles.ini`, so Thunderbird did
|
||||
what it does on any new machine — made an empty profile and opened it —
|
||||
|
||||
@@ -190,6 +190,23 @@ let
|
||||
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
|
||||
dock)
|
||||
internal="''${2:-}"; external="''${3:-}"
|
||||
@@ -204,6 +221,7 @@ let
|
||||
if hyprctl --batch \
|
||||
"keyword monitor $rule; $moves dispatch focusmonitor $external; keyword monitor $internal, disable" \
|
||||
>/dev/null 2>&1; then
|
||||
set_dock_intent true
|
||||
log "transition=dock internal=$internal external=$external result=ok"
|
||||
else
|
||||
log "transition=dock internal=$internal external=$external result=failed"
|
||||
@@ -212,6 +230,10 @@ let
|
||||
undock)
|
||||
internal="''${2:-}"
|
||||
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
|
||||
# before the internal panel is active and can receive workspaces.
|
||||
#
|
||||
@@ -262,6 +284,14 @@ let
|
||||
enable)
|
||||
internal="''${2:-}"
|
||||
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
|
||||
# keyword flushes and no reload escalation is needed — but still make
|
||||
# `monitors` the proof, or the menu's "back on" notification cheers
|
||||
@@ -558,6 +588,37 @@ ${lib.concatStringsSep "\n" (lib.mapAttrsToList
|
||||
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
|
||||
# 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
|
||||
@@ -656,6 +717,10 @@ ${lib.concatStringsSep "\n" (lib.mapAttrsToList
|
||||
release_inhibitor
|
||||
awaiting_lid_open=
|
||||
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
|
||||
|
||||
Reference in New Issue
Block a user