Compare commits

...

10 Commits

Author SHA1 Message Date
374f70c76f docs(backlog): re-measure #146 — zero genuine hypridle zombies post-fix
All checks were successful
Check / eval (push) Successful in 4m15s
Lock bump / bump (push) Successful in 1m28s
Re-ran the measurement per the entry's own recipe on the dev box (user
journal persistent to 2026-05-01, full window). Post-linger-fix
(2026-07-16 ~18:00 on) every `Disconnected from pollfd id 1` is a clean
teardown bracketed by a logind `Session N logged out` + a systemd
`Stopped hypridle` within <=11s (07-16 20:13, 07-17 17:04, 07-18 13:35,
07-19 16:46). Zero genuine zombies. Live daemon healthy (ss Recv-Q 0,
NRestarts=0, active, ExecMainPID==pid). Caveat: all four were deliberate
logouts, no compositor crash post-fix yet, so the crash path is confirmed
by argument not observation, and 4 days < the "few weeks" bar. Set an
explicit close condition (~2026-08-10 if still 0). Not closing today.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-20 19:48:00 +01:00
3536abc656 test(display): de-flake the dock harness re-dock assertion (#154)
All checks were successful
Check / eval (push) Successful in 4m16s
tools/monitor-fallback.nix flaked at the #148 reload-mid-dock step: after
a lid-closed `hyprctl reload` re-lights the disabled Virtual-1 panel, the
watcher's enforce_dock_intent re-disables it — but only on the loop's 1s
idle tick, because re-enabling an existing connector emits no
`monitoradded` and the chatty IPC events `continue` past reconcile. Under
this softGL VM's load (swaync crash-loops against the absent Vulkan
device) that idle tick is starved, so the re-dock lands ~15-20s out —
right on the old timeout=15.

An instrumented run confirmed the mechanism is correct: dockMode=true is
read, `action=re-dock` fires, the panel is re-disabled (it just missed the
bound). Fix is the bound, not the watcher: 15s → 45s, in line with the
test's own 20-30s waits. Harness now green end-to-end (~61s).

Not a product bug and independent of the #150 extraction (the built
transition/keyboard tools have byte-identical store paths across it).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-20 19:40:28 +01:00
baf2cd2e13 refactor(display): extract the shared display-transition + keyboard tools (#150)
All checks were successful
Check / eval (push) Successful in 4m29s
The full nomarchy-display-transition (dock/undock/enable — logging, the
lid-inhibitor gate, restore_keyboards, the failure dump) and the
nomarchy-keyboard-layout helper it calls move out of hyprland.nix into
leaf files (modules/home/display-transition.nix, keyboard-tool.nix),
imported by both hyprland.nix and idle.nix.

This kills the lossy ~22-line mini idle.nix carried under the same binary
name to dodge a circular import. The hypridle wake path (after_sleep /
DPMS on-resume) now runs the real undock — restore_keyboards after a
reload and the evidence dump on enable-failure — instead of a bare
keyword+reload, and the two copies can no longer drift.

Behaviour on the hyprland side is unchanged: the built
nomarchy-display-transition and nomarchy-keyboard-layout have
byte-identical store paths on HEAD vs this change (verbatim extraction).

Verify: all four files parse; home activationPackage + system toplevel
build; checks.docking-ux green (bash -n + safety-feature greps on the
extracted scripts). The tools/monitor-fallback.nix dock harness is red on
main already (the #148 reload-mid-dock assertion — identical failure on
clean HEAD; filed BACKLOG #154), so it could not gate this. The idle
wake-path behaviour change (full undock, incl. dock-intent clearing) is
V3 pending: on-hardware suspend/resume + zero-output rescue queued in
HARDWARE-QUEUE.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-20 19:17:57 +01:00
nomarchy-bump
dbe07ea429 chore(lock): scheduled upstream bump
• Updated input 'nixpkgs-unstable':
    'github:NixOS/nixpkgs/61b7c44c4073f0b827768aff0049561b5110ea5a?narHash=sha256-12KrbMiWLcf8m7pCvAtZh1ZrgF85ZXDXvfR/fWTKy84%3D' (2026-07-18)
  → 'github:NixOS/nixpkgs/241313f4e8e508cb9b13278c2b0fa25b9ca27163?narHash=sha256-vlHUuqAcbcH2RKmHbPiuQzbv1pnzzavXnI62RD0bqCU%3D' (2026-07-19)

Gate: nix flake check --no-build (eval tier — see bump.yml header).
2026-07-20 17:49:55 +00:00
fdf9790a73 chore(ci): restore bump schedule to 05:17 UTC
All checks were successful
Check / eval (push) Successful in 4m18s
Revert the temporary 17:39 test time now that the manual run has been
kicked off; keep the daily cadence.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-20 18:38:55 +01:00
5b95f6444f chore(ci): run the lock bump daily instead of weekly
Some checks failed
Check / eval (push) Has been cancelled
Move bump.yml's schedule from Mondays to every day (temporarily 17:39
UTC to trigger a run shortly). Sync the ROADMAP cadence note + shipped
log to match.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-20 18:37:27 +01:00
nomarchy-bump
757fa873b8 chore(lock): scheduled upstream bump
• Updated input 'nixpkgs':
    'github:NixOS/nixpkgs/293d6abedf0478e681a4dfcfcb35b30fc796a32f?narHash=sha256-DtoqIqM7VkR6NxAkcLpMwmi02USwWb3JdmNGLyhthc0%3D' (2026-07-17)
  → 'github:NixOS/nixpkgs/fd1462031fdee08f65fd0b4c6b64e22239a77870?narHash=sha256-n3gKTBIV4ZA5VQpUakffBe3KGu4%2BmhPoA34rrqS0GkA%3D' (2026-07-19)

Gate: nix flake check --no-build (eval tier — see bump.yml header).
2026-07-20 05:23:31 +00:00
0cd9ace669 flake bumo
All checks were successful
Check / eval (push) Successful in 4m22s
Lock bump / bump (push) Successful in 6m57s
2026-07-19 17:27:50 +01:00
3abeccf071 fix(gtk): scope the #98 button-label pin to dialogs — it was repainting the bar
All checks were successful
Check / eval (push) Successful in 4m26s
The bare `button, button label` selectors in the #98 safety net load
into every GTK3 process, including Waybar, and a rule matching the label
node outranks the color waybar.css inherits onto `#workspaces button` —
so workspace digits always rendered the gtk.css text color captured at
bar start, never the theme's @muted dots / @base-on-accent active pill
(the pair check-theme-contrast.py gates). With the bar never restarting
on switch (previous commit) the pin also went stale: all three 2026-07-18
screenshots carried kiln-clay's @text #39302A across three dark themes.
Scoped the enabled+disabled pins to the dialog selectors #98 actually
targeted; forcing adw-gtk3-dark remains the primary #98 fix, and the
sheet's own bare `label` rule sets only caret-color, so inheritance
carries the bar colors again.

Verified live on Newton (V3-grade): under the scoped gtk.css, boreal
renders the designed digits — @base #21272F on the @accent pill, @muted
#5E6A78 inactives — pixel-sampled from a bar spawned on a headless
output. V1: HM generation builds; bare selectors absent from the built
gtk.css, dialog-scoped ones present. Journal, hardware-queue (deploy
round-trip) and ROADMAP follow-up ride along.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-19 11:04:49 +01:00
76941c55b7 fix(waybar): bar pokes missed the wrapped process — switches never touched the bar
nixpkgs wraps waybar, so the running comm is `.waybar-wrapped`:
`pkill -x waybar` (state-sync restart/SIGUSR2 branches, tz-watch,
updates) matched nothing — a theme switch neither restarted nor reloaded
the bar, which then kept the gtk.css of whatever theme it started under
(the stale workspace-digit colors reported 2026-07-18; companion gtk fix
in the next commit). The unanchored pokes (recording, airplane,
theme-shot) had the opposite bug: `waybar` substring-matches the
supervisor's `nomarchy-waybar` comm too, and an unhandled RTMIN
terminates its bash — the pokes were killing the crash guard. All seven
sites now use `pkill … -x 'waybar|\.waybar-wrapped'`.
state-sync 0.5.1 → 0.5.2.

Verified live on Newton (V3-grade): new pattern restarts the bar under
the supervisor (fresh pid, supervisor alive); RTMIN+10 poke reaches the
bar, supervisor survives. V0 `nix flake check --no-build` + py_compile;
V1 HM generation + system toplevel build.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-19 11:04:08 +01:00
20 changed files with 475 additions and 337 deletions

View File

@@ -1,11 +1,12 @@
# Nomarchy scheduled lock bump — the automated half of "rock-stable
# without rotting": once a week, update flake.lock (inputs track pinned
# without rotting": once a day, update flake.lock (inputs track pinned
# release branches, so this never jumps a NixOS release — that's a
# deliberate hand-edited v2, see agent/GOALS.md), gate it, and land it
# on main only on green. `v1` promotion stays human, always.
#
# Fast lane: workflow_dispatch. For a security fix upstream, run this
# workflow manually from the Actions tab instead of waiting for Monday.
# workflow manually from the Actions tab instead of waiting for the
# next daily run.
#
# Gate scope: `nix flake check --no-build` (eval tier) followed by a V1 build
# (home-manager activation package + nixos toplevel) to catch compilation errors.
@@ -18,7 +19,7 @@
#
# Failure mode: a red gate fails this run visibly and pushes nothing;
# next schedule retries. A push race (someone landed on main mid-run)
# also just fails the final push — rerun or wait a week.
# also just fails the final push — rerun or wait a day.
#
# Container recipe (nixbld users, sandbox=false, pinned Nix, plain-shell
# install) inherited from check.yml — the gotchas are documented there.
@@ -27,7 +28,7 @@ name: Lock bump
on:
schedule:
- cron: '17 5 * * 1' # Mondays 05:17 UTC
- cron: '17 5 * * *' # daily 05:17 UTC
workflow_dispatch:
jobs:

View File

@@ -128,6 +128,26 @@ v1+ polish, not a v1 blocker.
### 146. `[watch]` hypridle hangs instead of exiting when its compositor vanishes
**Re-measured 2026-07-20 (dev box; user journal is persistent back to 2026-05-01,
so this is the full window, not a fragment).** The linger fix landed between 17:25
and 20:13 on 2026-07-16: the last zombie-shaped disconnect is the known one
(17:25:39, PID 1698 — logout→greeter with **no** following `Stopped hypridle`), and
**every** `Disconnected from pollfd id 1` since is a clean teardown bracketed by both
a logind `Session N logged out` and a systemd `Stopped hypridle` within ≤11s
(07-16 20:13:04, 07-17 17:04:38, 07-18 13:35:25, 07-19 16:46:49). **Genuine zombies
post-fix: 0.** The live daemon is healthy too — PID 1707: `active`, `NRestarts=0`,
`ExecMainPID`==pid, `ss` Recv-Q 0 on its D-Bus socket (reading, not the 48 KB-unread
wedge), `WCHAN futex_do_wait`, logged activity <10 min before inspection. **Why this
stays open a little longer, not closed today:** all four post-fix disconnects were
*deliberate* logouts — no Hyprland *crash* (the zombie's actual trigger; the nearby
coredumps were xdg-desktop-portal/swww/swayosd during teardown, not the compositor)
has occurred since the fix, so the crash path is confirmed by argument (a crash ends
the logind session too → no-linger manager teardown reaps hypridle) but not yet by an
observed crash, and ~4 days is under the "few weeks" bar. **Close condition:** if
genuine zombies stay 0 by ~2026-08-10 (≈3 weeks post-fix), close unbuilt; one wedged
process (`ps` alive + `ss` Recv-Q high + `NRestarts=0` while `active`) reopens it as
real work. Everything below is prior context, unchanged.
**Rewritten 2026-07-16, hours after filing: the premise was disproven and almost
nothing survives.** As filed it said hypridle "dies silently ~2×/week — 19 times
in 11 days", with Jul 14 as the clean example ("`CRITICAL` at 13:08:17, then
@@ -175,25 +195,6 @@ If that count is zero over a few weeks post-linger, **close this unbuilt**. If i
is not, the upstream issue has sat ten months on vaxerski's "that will cause a
segfault no?" and a reproducer would be worth more than a local guard.
### 150. Extract the display-transition tool — idle.nix carries a lossy copy under the same name
Found by the 2026-07-17 structural audit. `modules/home/idle.nix:23-53` defines
`miniTransition`, a ~22-line `writeShellScriptBin` named
**`nomarchy-display-transition`** — the same binary name as the real ~175-line
tool in `modules/home/hyprland.nix:136-310` — implementing only
`undock|enable`, with none of the full tool's safety: no logging, no
lid-inhibitor gate, no `restore_keyboards`, no workspace moves, no
failure dump. The comment says why: "a mini transition avoids a circular
import on hyprland.nix". No PATH collision today (the mini is never installed;
it is baked by store path into hypridle's wake tool via `display-tools.nix`),
but nothing asserts the two stay behaviourally consistent, and this is the
subsystem where #100/#142/#148 all lived. Pitch: extract the full tool into
its own module file (e.g. `modules/home/display-transition.nix`) imported by
both hyprland.nix and idle.nix, delete the mini, re-run the
`tools/monitor-fallback.nix` harness + `checks.docking-ux` as the V2 gate.
Cost: a focused refactor of safety-critical code — deliberately NOT done as
hygiene drive-by; deserves its own iteration.
### 149. `[watch]` Waybar's exec-once workaround may be defending against nothing post-linger
Spun out of #147 (shipped 2026-07-17) so its loose thread survives the entry:

View File

@@ -534,6 +534,21 @@ Everything else below stays open; order is convenience, not a gate.
bar, notifications, and clipboard history (SUPER+V) work. This is
the V3 close for the stale `graphical-session.target` /
`start-limit-hit` relogin breakage (BACKLOG #149 note).
- [ ] **#150 hypridle wake path now uses the FULL undock** — after pulling
the #150 commit + `nomarchy-home`: the hyprland-side transition/keyboard
tools are byte-identical (proven by store-path equality), so only the
hypridle wake behaviour changed. Two checks: **(a) suspend/resume while
docked** (lid closed, external on) — suspend, resume; pass = you land
back docked on the external with the panel still off and remembered
external-keyboard layouts intact (the wake now runs the full undock,
which `restore_keyboards` after any reload; the old mini did not). **(b)
zero-output rescue** — the #127 case (dock mode, external gone/black,
eDP disabled): pass = the internal panel comes back lit, workspaces move
to it, and `~/nomarchy-display-dump-*.txt` is written iff it stays dark.
Watch for a regression: the full undock clears dock-intent, so confirm a
*transient* zero-output blip on resume while genuinely still docked does
not leave you undocked with the laptop panel on (the external re-add
should re-dock via the watcher — verify it does).
## AMD dev box only
- [ ] **#118 smartd still runs where drives DO have SMART** (this commit) — the
@@ -616,6 +631,15 @@ Everything else below stays open; order is convenience, not a gate.
Acer's own fontconfig/DPI resolving `ch` the same way — try it under a
**JetBrainsMono 14 theme** (summer-day/night, kanagawa), the widest case.
- [ ] **Theme-switch bar round-trip (2026-07-19 waybar-poke + gtk-pin fix)**
after `nomarchy-pull` + `nomarchy-home`, switch dark → light → dark
(e.g. boreal → kiln-clay → kiln). Pass = the bar visibly restarts on
each switch and the workspace digits land per theme (dark-on-accent
active pill, dimmed inactives on dark themes) with no color carried
over from the previous theme; then start/stop a screen recording and
toggle airplane mode — the supervisor must survive both pokes
(`pgrep -lx nomarchy-waybar` still lists it).
## Latitude 5310 / 5410 only
- [ ] **v1 QA batch on-hardware pass** (583708d batch was QEMU-verified) —
general smoke before the next `main → v1` promotion (broader than

View File

@@ -19,6 +19,26 @@ Template:
---
## 2026-07-19 — stale workspace-digit colors after theme switch (prev commit + this)
- **Task:** Bernardo live: bar digit colors wrong for boreal /
executive-slate / kiln after switching; a reboot "fixed" it.
- **Did:** Root-caused on Newton. (1) Every bar poke used `pkill -x
waybar`, but the nixpkgs wrap renamed the comm `.waybar-wrapped` —
switches never restarted/reloaded the bar (and unanchored RTMIN pokes
were killing the supervisor). (2) Stylix's #98 bare `button label` pin
outranks the colors waybar.css inherits onto `#workspaces button`,
frozen at bar start — all three screenshots' digits were kiln-clay's
@text. Fixed the pattern in 7 sites; scoped the #98 pin to dialogs.
- **Verified:** V0 flake check + py_compile; V1 HM generation + system
toplevel; live on Newton (V3-grade): restart via the new pattern,
RTMIN poke with the supervisor surviving, pixel-verified boreal digits
(@base-on-accent active, @muted inactive) under the scoped gtk.css.
- **Pending:** HARDWARE-QUEUE: deploy round-trip after nomarchy-home.
NB: waybar 0.15.0 `reload_style_on_change` is inert with HM symlinks
(upstream); harmless now that the restart path works.
- **Next suggestion:** close the queue entry once Bernardo's deploy
round-trip passes.
## 2026-07-17 — #143 closed via upstream's own knob; executive-slate gets the icon grid (f899b19 + this)
- **Task:** Bernardo live: rofi#2317 got a maintainer reply → adopt; plus
"make executive-slate's app menu the boreal-style icon grid".

View File

@@ -527,6 +527,39 @@ Design/decision records and a running log of shipped work (items marked
V1: template HM activation built with `unstable.hello`, closure carries
the unstable store path; `checks.unstable-seam` (eval-only) guards the
seam permanently; lock diff added exactly the one node.
- ✓ **Display-transition tool de-duplicated (#150, 2026-07-20):** the full
`nomarchy-display-transition` (dock/undock/enable — logging, the lid-inhibitor
gate, `restore_keyboards`, the failure dump) moved out of `hyprland.nix` into
its own leaf `modules/home/display-transition.nix`, and the keyboard helper it
calls into `modules/home/keyboard-tool.nix`; `hyprland.nix` and `idle.nix` now
import the ONE copy. This kills the lossy ~22-line mini `idle.nix` carried
under the same binary name to dodge a circular import — so the hypridle wake
path (`after_sleep` / DPMS `on-resume`) gets the real undock, i.e.
`restore_keyboards` after a reload and the evidence dump on enable-failure,
not a bare keyword+reload. Behaviour on the hyprland side is unchanged —
**proven** by byte-identical store paths for the built
`nomarchy-display-transition` / `nomarchy-keyboard-layout` on HEAD vs the
refactor. Gates: parse + home/toplevel builds + `checks.docking-ux`
(structural, `bash -n` + safety-feature greps) all green. The
`tools/monitor-fallback.nix` dock VM harness was **red on `main` at the
time** — a flaky 15s bound on the #148 re-dock (identical failure on clean
HEAD), diagnosed and fixed the same day (see below), so it did not gate this
landing; the dock transition inside it always ran `result=ok`. The idle
wake-path behaviour change (full undock, incl. dock-intent clearing +
`restore_keyboards`) is V3 — HARDWARE-QUEUE.
- ✓ **Dock harness de-flaked (#154, 2026-07-20):** `tools/monitor-fallback.nix`
flaked at the #148 reload-mid-dock assertion — after a lid-closed
`hyprctl reload` re-lights the disabled `Virtual-1` panel, the watcher's
`enforce_dock_intent` re-disables it, but only on the loop's 1s **idle
tick** (a reload re-enables an existing connector, emitting no
`monitoradded`, and chatty IPC events `continue` past `reconcile`). Under
the softGL VM's load — swaync crash-loops against the absent Vulkan device —
that idle tick is starved and the (correct, prompt-on-hardware) re-dock
lands ~15-20s out, right on the old `timeout=15`. Instrumented run proved
the mechanism fires with `dock-intent=true` and re-disables the panel; fix
is the bound (15→45s, matching the test's other 20-30s waits), not the
watcher. Harness now green end-to-end (61s). Not a product bug and unrelated
to #150 (byte-identical tool store paths).
- ✓ **The idle brick's CAUSE found (#127 closed, 2026-07-17):** the incident
was misdated — it did not happen "before 12:51:34 Jul 15" (that boot was
healthy all day and ended in a routine clean reboot); it ended the
@@ -1840,8 +1873,9 @@ Design/decision records and a running log of shipped work (items marked
builds `system.build.toplevel`, and on green lands on `main` — which nobody
tracks, so a bad bump costs a debug session, not a user outage; `v1` stays
the manual on-hardware gate (it only ever fast-forwards, never force-pushed,
or a user's locked rev can vanish from history). **Cadence weekly**
(release-26.05 doesn't move fast; nightly is just churn), and `nix flake
or a user's locked rev can vanish from history). **Cadence daily**
(2026-07-20; was weekly — release-26.05 doesn't move fast, but daily
shrinks security-patch latency and a no-op bump is cheap), and `nix flake
update` stays *within* the pinned release branches — no surprise major bump
(a 26.11 jump is a deliberate `v2`, hand-edited in flake.nix). Side benefit:
shrinks the security-patch latency the single-input model otherwise imposes
@@ -1849,8 +1883,9 @@ Design/decision records and a running log of shipped work (items marked
already eval/build/VM-tested and one hardware promotion away from `v1` —
worth fast-laning lock-only/security bumps ahead of feature batches.
**Shipped since:** `.gitea/workflows/check.yml` (eval tier on every main
push) and `bump.yml` (Monday 05:17 UTC scheduled lock bump, eval + V1
build gate, auto-lands on green — first landing 8fded63, 2026-07-06).
push) and `bump.yml` (daily 05:17 UTC scheduled lock bump, eval + V1
build gate, auto-lands on green — first landing 8fded63, 2026-07-06;
cadence weekly→daily 2026-07-20).
2026-07-12: both gates moved from one big `nix flake check --no-build`
(~6.0 GB peak RSS — OOM'd the 4 GB VPS, then could never fit the
runner's 2 GB container cap) to `tools/ci-eval.sh`, one output per nix
@@ -1947,6 +1982,12 @@ Design/decision records and a running log of shipped work (items marked
unsupervised/custom bars; `reload_style_on_change` stays for manual
`home-update` restyles. VM-verified on the headless software-GL
desktop: SIGKILL → new pid; clean kill → respawn; SIGUSR2 → alive.
**Follow-up (2026-07-19):** a later waybar package wrap renamed the
comm to `.waybar-wrapped`, silently no-oping every `pkill -x waybar`
(switches stopped touching the bar → the stale workspace-digit-color
bug), while the *unanchored* RTMIN pokes substring-matched the
supervisor's `nomarchy-waybar` comm and killed it. All pokes now use
`-x 'waybar|\.waybar-wrapped'`.
- ✓ **`sys-rebuild`** (Latitude QA request): the no-update twin of
`sys-update` — snapshot-first system rebuild against the *current*
lock, for config-only changes; `home-update` was already lock-free.

38
flake.lock generated
View File

@@ -145,11 +145,11 @@
]
},
"locked": {
"lastModified": 1783740085,
"narHash": "sha256-qajyHfZY29G2oEQk+uHxmsJcRoBUBXP9maTpFlwP/dI=",
"lastModified": 1784350909,
"narHash": "sha256-ZWyzLbS1yKUTeFJLmdVuWNnHttL333/ldJbEE+KzCrM=",
"owner": "nix-community",
"repo": "home-manager",
"rev": "3cd22efe6471dc7365c822bd9ad73a21e55f38fb",
"rev": "4ce190229c73d44536caa7072f6308fb2d8feeb3",
"type": "github"
},
"original": {
@@ -166,11 +166,11 @@
]
},
"locked": {
"lastModified": 1783792734,
"narHash": "sha256-50rvY9GdFvpYDcMLcD/4cWSi0hVxArT5wsGlVsHy8eY=",
"lastModified": 1784310968,
"narHash": "sha256-rkSPTePrKqs4dg+i7ZFCq93+HrClac6oSwXX927SVjA=",
"owner": "NixOS",
"repo": "nixos-hardware",
"rev": "8efb4337e857949f4cfac86d12ef1066f417f31f",
"rev": "779c32a00155994c86cde8213a8dd4df139d4355",
"type": "github"
},
"original": {
@@ -182,11 +182,11 @@
},
"nixpkgs": {
"locked": {
"lastModified": 1783703440,
"narHash": "sha256-O3/YajjWo001VUIgD8BwaRdSNLUFe7nZ1qV5TwhRBcw=",
"lastModified": 1784432872,
"narHash": "sha256-n3gKTBIV4ZA5VQpUakffBe3KGu4+mhPoA34rrqS0GkA=",
"owner": "NixOS",
"repo": "nixpkgs",
"rev": "8f0500b9660505dc3cb647775fe9a978a74b5283",
"rev": "fd1462031fdee08f65fd0b4c6b64e22239a77870",
"type": "github"
},
"original": {
@@ -198,11 +198,11 @@
},
"nixpkgs-unstable": {
"locked": {
"lastModified": 1784120854,
"narHash": "sha256-KesHgItiZPgGX740axSiQLcIQ8D24MDqNpkKYWIek8k=",
"lastModified": 1784497964,
"narHash": "sha256-vlHUuqAcbcH2RKmHbPiuQzbv1pnzzavXnI62RD0bqCU=",
"owner": "NixOS",
"repo": "nixpkgs",
"rev": "753cc8a3a87467296ddd1fa93f0cc3e81120ee46",
"rev": "241313f4e8e508cb9b13278c2b0fa25b9ca27163",
"type": "github"
},
"original": {
@@ -215,11 +215,11 @@
"nomarchy-wallpapers": {
"flake": false,
"locked": {
"lastModified": 1784377824,
"narHash": "sha256-XPzT9w+xr0bEn3W2R+pYo4z10hKT1E10QB5TAu/UrIM=",
"lastModified": 1784478251,
"narHash": "sha256-sEBx2ce6g0398FlclNwfv5FuwZ1WzLYNHK6jX/2kV88=",
"ref": "main",
"rev": "d4f5fbfff2228c7725be5a1adbc9b325621d3c94",
"revCount": 4,
"rev": "5c361e17b524b6ef27fb7ba948269d0e0c6e954e",
"revCount": 5,
"type": "git",
"url": "https://git.bemagri.xyz/bernardo/Nomarchy-Wallpapers.git"
},
@@ -284,11 +284,11 @@
"tinted-zed": "tinted-zed"
},
"locked": {
"lastModified": 1783359251,
"narHash": "sha256-HUiCnEVlJ4n+qJlZojiz/zv+P0cqM5zsg1dxpz2J7Mg=",
"lastModified": 1784060273,
"narHash": "sha256-14rIy2kTs5CufmDpgJrwkR+7IzuCAXyLYDAx6ixfRFc=",
"owner": "nix-community",
"repo": "stylix",
"rev": "e602ad042f00409f33c8ad2829cd8d59ba345c7e",
"rev": "2245fa9e16034149b6501834b99863a486e94725",
"type": "github"
},
"original": {

View File

@@ -0,0 +1,191 @@
# The full dock / undock / enable display-transition primitive
# (nomarchy-display-transition) — the one safety-critical copy. Extracted
# from hyprland.nix (#150) so hyprland.nix and idle.nix share it instead of
# idle.nix carrying a lossy mini that lacked logging, the lid-inhibitor
# gate, restore_keyboards, and the failure dump.
#
# One transition primitive for the interactive Dock mode, abrupt undock,
# and named profiles that disable the internal panel. Dock is one Hyprland
# batch: enable the target, move every internal workspace, focus it, then
# disable the panel. Undock deliberately enables the panel FIRST, waits
# for it to become an active target, then restores every workspace.
#
# sync — nomarchy-state-sync exe (dock-intent writes)
# keyboardTool — nomarchy-keyboard-layout (restore after reload)
# displayDumpTool — nomarchy-display-dump (evidence on enable failure)
{ pkgs, sync, keyboardTool, displayDumpTool }:
pkgs.writeShellScriptBin "nomarchy-display-transition" ''
set -u
LOGGER=${pkgs.util-linux}/bin/logger
log() { "$LOGGER" -t nomarchy-display-transition -- "$*"; }
valid_output() {
case "$1" in *[!A-Za-z0-9_.:-]*|"") return 1 ;; *) return 0 ;; esac
}
workspaces_on() {
hyprctl workspaces -j 2>/dev/null \
| jq -r --arg m "$1" '.[] | select(.monitor == $m and .id > 0) | .id'
}
all_workspaces() {
hyprctl workspaces -j 2>/dev/null | jq -r '.[] | select(.id > 0) | .id'
}
lid_inhibitor_held() {
${pkgs.systemd}/bin/systemd-inhibit --list --json=short 2>/dev/null \
| ${pkgs.jq}/bin/jq -e 'any(.[]; .what == "handle-lid-switch" and .why == "Safe dock/undock display transition" and .mode == "block")' \
>/dev/null
}
batch_moves() { # stdin workspace ids, $1 target
target="$1"; commands=
while IFS= read -r ws; do
case "$ws" in *[!0-9]*|"") continue ;; esac
commands="$commands dispatch moveworkspacetomonitor $ws $target;"
done
printf '%s' "$commands"
}
# `monitors` (enabled-only) listing the output is the ONLY proof it is
# really on: every path here issues keywords hyprctl answers "ok" to
# without acting. $1 = output, $2 = polls of 0.2s.
output_enabled() {
tries=0
while [ "$tries" -lt "$2" ]; do
hyprctl monitors -j 2>/dev/null \
| jq -e --arg m "$1" 'any(.[]; .name == $m)' >/dev/null && return 0
tries=$((tries+1)); sleep 0.2
done
return 1
}
# `hyprctl reload` re-reads the config, and every runtime keyword dies
# with it including the `device[<name>]:kb_layout` the keyboard watcher
# applies to an external board. Measured on hardware 2026-07-14: a bare
# reload took a keyboard remembered as `us` straight back to the session's
# `gb`, so without this the undock's escalation would silently break the
# layout of any keyboard that stays behind when the dock leaves (a
# wireless receiver in the laptop; only boards on the dock's own hub are
# unaffected, and only because they leave with it). The remembered
# choices live in the in-flake state, so `restore` is authoritative.
restore_keyboards() {
hyprctl devices -j 2>/dev/null | jq -r '.keyboards[].name' 2>/dev/null \
| while IFS= read -r kb; do
[ -n "$kb" ] || continue
${keyboardTool}/bin/nomarchy-keyboard-layout restore "$kb" \
>/dev/null 2>&1 || true
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:-}"
rule="''${4:-$external, preferred, auto, 1}"
valid_output "$internal" && valid_output "$external" \
|| { echo "invalid monitor name" >&2; exit 64; }
# Fail closed: disabling the panel without this lock recreates the
# exact cable-removal logind suspend race #100 is fixing.
lid_inhibitor_held \
|| { log "transition=dock internal=$internal external=$external result=no-lid-inhibitor"; exit 1; }
moves=$(workspaces_on "$internal" | batch_moves "$external")
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"
exit 1
fi ;;
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.
#
# With ZERO enabled outputs an abrupt undock, the panel already
# disabled by the dock Hyprland 0.55.4 accepts `keyword monitor`
# (prints "ok", exits 0) and then never flushes it: the rule is held
# until some DRM event arrives, and the panel stays dark. This is
# deterministic, not a race. Probed on hardware 2026-07-14: the
# keyword was inert across 4s and 5s windows in two separate runs,
# `dispatch forcerendererreload` did not flush it either, and only
# `hyprctl reload` did in 99ms and 289ms, with the cable out.
#
# Round 6 read the same symptom as a teardown race and re-issued the
# keyword 25 times a poll. Retrying an inert command cannot work: it
# bought 30s of black screen and every `result=ok` in that journal was
# really the user plugging the cable back in, whose hotplug flushed
# the queued rule and let the next poll take the credit.
#
# So issue the keyword once enough whenever another output is still
# enabled, e.g. the menu's Dock mode toggle and escalate to the
# reload hammer only when it proves inert.
hyprctl keyword monitor "$internal,preferred,auto,1" >/dev/null 2>&1
if ! output_enabled "$internal" 5; then
log "transition=undock internal=$internal keyword=inert escalate=reload"
hyprctl reload >/dev/null 2>&1
if ! output_enabled "$internal" 25; then
log "transition=undock internal=$internal result=enable-failed"
# #127: leave evidence on disk without SSH (TTY or next login).
${displayDumpTool}/bin/nomarchy-display-dump undock-enable-failed \
>/dev/null 2>&1 || true
exit 1
fi
# The config's monitor rules own the panel now. Re-assert the
# runtime rule which flushes normally, an output being enabled
# again so a config that parks the panel off cannot undo the
# undock, and give the keyboards back what the reload took.
hyprctl keyword monitor "$internal,preferred,auto,1" >/dev/null 2>&1
restore_keyboards
log "transition=undock internal=$internal enable=via-reload"
fi
moves=$(all_workspaces | batch_moves "$internal")
hyprctl --batch "$moves dispatch focusmonitor $internal" >/dev/null 2>&1 \
|| { log "transition=undock internal=$internal result=move-failed"; exit 1; }
# #127: undock after idle DPMS left the seat black even when the
# panel was re-enabled always light the chain after enable.
hyprctl dispatch dpms on >/dev/null 2>&1 || true
log "transition=undock internal=$internal result=ok" ;;
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
# for a keyword hyprctl merely said "ok" to.
hyprctl keyword monitor "$internal,preferred,auto,1" >/dev/null 2>&1
if output_enabled "$internal" 10; then
hyprctl dispatch dpms on >/dev/null 2>&1 || true
true
else
false
fi ;;
*)
echo "usage: nomarchy-display-transition [dock <internal> <external> [external-rule]|undock <internal>|enable <internal>]" >&2
exit 64 ;;
esac
''

View File

@@ -128,186 +128,11 @@ let
displayDumpTool =
(import ./display-tools.nix { inherit pkgs; }).displayDumpTool;
# One transition primitive for the interactive Dock mode, abrupt undock,
# and named profiles that disable the internal panel. Dock is one Hyprland
# batch: enable the target, move every internal workspace, focus it, then
# disable the panel. Undock deliberately enables the panel FIRST, waits
# for it to become an active target, then restores every workspace.
displayTransitionTool = pkgs.writeShellScriptBin "nomarchy-display-transition" ''
set -u
LOGGER=${pkgs.util-linux}/bin/logger
log() { "$LOGGER" -t nomarchy-display-transition -- "$*"; }
valid_output() {
case "$1" in *[!A-Za-z0-9_.:-]*|"") return 1 ;; *) return 0 ;; esac
}
workspaces_on() {
hyprctl workspaces -j 2>/dev/null \
| jq -r --arg m "$1" '.[] | select(.monitor == $m and .id > 0) | .id'
}
all_workspaces() {
hyprctl workspaces -j 2>/dev/null | jq -r '.[] | select(.id > 0) | .id'
}
lid_inhibitor_held() {
${pkgs.systemd}/bin/systemd-inhibit --list --json=short 2>/dev/null \
| ${pkgs.jq}/bin/jq -e 'any(.[]; .what == "handle-lid-switch" and .why == "Safe dock/undock display transition" and .mode == "block")' \
>/dev/null
}
batch_moves() { # stdin workspace ids, $1 target
target="$1"; commands=
while IFS= read -r ws; do
case "$ws" in *[!0-9]*|"") continue ;; esac
commands="$commands dispatch moveworkspacetomonitor $ws $target;"
done
printf '%s' "$commands"
}
# `monitors` (enabled-only) listing the output is the ONLY proof it is
# really on: every path here issues keywords hyprctl answers "ok" to
# without acting. $1 = output, $2 = polls of 0.2s.
output_enabled() {
tries=0
while [ "$tries" -lt "$2" ]; do
hyprctl monitors -j 2>/dev/null \
| jq -e --arg m "$1" 'any(.[]; .name == $m)' >/dev/null && return 0
tries=$((tries+1)); sleep 0.2
done
return 1
}
# `hyprctl reload` re-reads the config, and every runtime keyword dies
# with it including the `device[<name>]:kb_layout` the keyboard watcher
# applies to an external board. Measured on hardware 2026-07-14: a bare
# reload took a keyboard remembered as `us` straight back to the session's
# `gb`, so without this the undock's escalation would silently break the
# layout of any keyboard that stays behind when the dock leaves (a
# wireless receiver in the laptop; only boards on the dock's own hub are
# unaffected, and only because they leave with it). The remembered
# choices live in the in-flake state, so `restore` is authoritative.
restore_keyboards() {
hyprctl devices -j 2>/dev/null | jq -r '.keyboards[].name' 2>/dev/null \
| while IFS= read -r kb; do
[ -n "$kb" ] || continue
${keyboardTool}/bin/nomarchy-keyboard-layout restore "$kb" \
>/dev/null 2>&1 || true
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:-}"
rule="''${4:-$external, preferred, auto, 1}"
valid_output "$internal" && valid_output "$external" \
|| { echo "invalid monitor name" >&2; exit 64; }
# Fail closed: disabling the panel without this lock recreates the
# exact cable-removal logind suspend race #100 is fixing.
lid_inhibitor_held \
|| { log "transition=dock internal=$internal external=$external result=no-lid-inhibitor"; exit 1; }
moves=$(workspaces_on "$internal" | batch_moves "$external")
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"
exit 1
fi ;;
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.
#
# With ZERO enabled outputs an abrupt undock, the panel already
# disabled by the dock Hyprland 0.55.4 accepts `keyword monitor`
# (prints "ok", exits 0) and then never flushes it: the rule is held
# until some DRM event arrives, and the panel stays dark. This is
# deterministic, not a race. Probed on hardware 2026-07-14: the
# keyword was inert across 4s and 5s windows in two separate runs,
# `dispatch forcerendererreload` did not flush it either, and only
# `hyprctl reload` did in 99ms and 289ms, with the cable out.
#
# Round 6 read the same symptom as a teardown race and re-issued the
# keyword 25 times a poll. Retrying an inert command cannot work: it
# bought 30s of black screen and every `result=ok` in that journal was
# really the user plugging the cable back in, whose hotplug flushed
# the queued rule and let the next poll take the credit.
#
# So issue the keyword once enough whenever another output is still
# enabled, e.g. the menu's Dock mode toggle and escalate to the
# reload hammer only when it proves inert.
hyprctl keyword monitor "$internal,preferred,auto,1" >/dev/null 2>&1
if ! output_enabled "$internal" 5; then
log "transition=undock internal=$internal keyword=inert escalate=reload"
hyprctl reload >/dev/null 2>&1
if ! output_enabled "$internal" 25; then
log "transition=undock internal=$internal result=enable-failed"
# #127: leave evidence on disk without SSH (TTY or next login).
${displayDumpTool}/bin/nomarchy-display-dump undock-enable-failed \
>/dev/null 2>&1 || true
exit 1
fi
# The config's monitor rules own the panel now. Re-assert the
# runtime rule which flushes normally, an output being enabled
# again so a config that parks the panel off cannot undo the
# undock, and give the keyboards back what the reload took.
hyprctl keyword monitor "$internal,preferred,auto,1" >/dev/null 2>&1
restore_keyboards
log "transition=undock internal=$internal enable=via-reload"
fi
moves=$(all_workspaces | batch_moves "$internal")
hyprctl --batch "$moves dispatch focusmonitor $internal" >/dev/null 2>&1 \
|| { log "transition=undock internal=$internal result=move-failed"; exit 1; }
# #127: undock after idle DPMS left the seat black even when the
# panel was re-enabled always light the chain after enable.
hyprctl dispatch dpms on >/dev/null 2>&1 || true
log "transition=undock internal=$internal result=ok" ;;
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
# for a keyword hyprctl merely said "ok" to.
hyprctl keyword monitor "$internal,preferred,auto,1" >/dev/null 2>&1
if output_enabled "$internal" 10; then
hyprctl dispatch dpms on >/dev/null 2>&1 || true
true
else
false
fi ;;
*)
echo "usage: nomarchy-display-transition [dock <internal> <external> [external-rule]|undock <internal>|enable <internal>]" >&2
exit 64 ;;
esac
'';
# The full dock/undock/enable transition primitive lives in its own file
# so idle.nix shares this exact safety-critical copy (#150), not a mini.
displayTransitionTool = import ./display-transition.nix {
inherit pkgs sync keyboardTool displayDumpTool;
};
displayWakeTool =
(import ./display-tools.nix {
@@ -786,78 +611,7 @@ ${lib.concatStringsSep "\n" (lib.mapAttrsToList
done
'';
# Candidate layouts offered by the new-keyboard picker: the session
# layout(s) (nomarchy.keyboard.layout, comma-split for a multi-layout
# session) plus the extra nomarchy.keyboard.layouts pool. The pool is
# deliberately NOT merged into input.kb_layout — those candidates are for
# *external* keyboards and get applied per-device by the watcher's apply().
# Loading them onto the session keyboard is what let a global switch flip
# the built-in board to the wrong layout.
pickerLayouts = lib.unique (
(lib.splitString "," config.nomarchy.keyboard.layout)
++ config.nomarchy.keyboard.layouts
);
# Per-device keyboard helper shared by the hotplug watcher and the manual
# System Keyboard menu. The configured candidates are listed first, then
# every XKB layout known to the system, so the feature works on a default
# install instead of silently disappearing until home.nix is edited.
keyboardTool = pkgs.writeShellScriptBin "nomarchy-keyboard-layout" ''
set -u
sync=${sync}
layouts() {
printf '%s\n' ${lib.concatMapStringsSep " " lib.escapeShellArg pickerLayouts}
${pkgs.gawk}/bin/awk '
/^! layout/ { in_layouts=1; next }
/^!/ && in_layouts { exit }
in_layouts && NF { print $1 }
' ${pkgs.xkeyboard_config}/share/X11/xkb/rules/base.lst
}
saved_map() { "$sync" get settings.keyboard.devices 2>/dev/null || echo '{}'; }
saved_for() { saved_map | jq -r --arg k "$1" '.[$k] // empty'; }
apply_runtime() { hyprctl keyword "device[$1]:kb_layout" "$2" >/dev/null 2>&1; }
case "''${1:-}" in
layouts)
layouts | ${pkgs.gawk}/bin/awk 'NF && !seen[$0]++' ;;
devices)
hyprctl devices -j 2>/dev/null \
| jq -r '.keyboards[] | "\(.name)\t\(.active_keymap // \"unknown\")"' ;;
saved)
[ -n "''${2:-}" ] || exit 64
saved_for "$2" ;;
restore)
[ -n "''${2:-}" ] || exit 64
layout=$(saved_for "$2")
[ -n "$layout" ] && apply_runtime "$2" "$layout" ;;
apply)
[ -n "''${2:-}" ] && [ -n "''${3:-}" ] || exit 64
layouts | ${pkgs.gnugrep}/bin/grep -Fxq "$3" \
|| { notify-send "Keyboard" "Unknown XKB layout: $3"; exit 64; }
if apply_runtime "$2" "$3"; then
map=$(saved_map | jq -c --arg k "$2" --arg v "$3" '. + {($k): $v}') || exit 1
if "$sync" --quiet set settings.keyboard.devices "$map" --no-switch; then
notify-send "Keyboard" "$2 $3"
else
notify-send "Keyboard" "$2 $3 for this session, but the choice could not be saved."
exit 1
fi
else
notify-send "Keyboard" "Could not apply $3 to $2."
exit 1
fi ;;
forget)
[ -n "''${2:-}" ] || exit 64
map=$(saved_map | jq -c --arg k "$2" 'del(.[$k])') || exit 1
"$sync" --quiet set settings.keyboard.devices "$map" --no-switch || exit 1
apply_runtime "$2" ${lib.escapeShellArg config.nomarchy.keyboard.layout} || true
notify-send "Keyboard" "$2 now follows the session layout." ;;
*)
echo "usage: nomarchy-keyboard-layout [layouts|devices|saved <device>|restore <device>|apply <device> <layout>|forget <device>]" >&2
exit 64 ;;
esac
'';
keyboardTool = import ./keyboard-tool.nix { inherit pkgs lib config; };
# On a keyboard that connects after login (not declared in
# keyboard.devices, not already remembered), ask for a layout and persist it

View File

@@ -21,35 +21,23 @@ let
'';
# Absolute store paths for hypridle (thin PATH). Wake reuses the shared
# helper; a mini transition avoids a circular import on hyprland.nix.
# helper AND the full display-transition tool (#150): idle.nix used to
# carry a lossy mini copy to dodge a circular import on hyprland.nix, but
# both live in leaf files now, so the wake path gets the real undock —
# logging, the enable-failure dump, and restore_keyboards after a reload.
displayWake =
let
miniTransition = pkgs.writeShellScriptBin "nomarchy-display-transition" ''
set -u
case "''${1:-}" in
undock|enable)
internal="''${2:-}"
[ -n "$internal" ] || exit 64
hyprctl keyword monitor "$internal,preferred,auto,1" >/dev/null 2>&1 || true
if ! hyprctl monitors -j 2>/dev/null \
| ${pkgs.jq}/bin/jq -e --arg m "$internal" 'any(.[]; .name == $m)' \
>/dev/null 2>&1; then
hyprctl reload >/dev/null 2>&1 || true
sleep 0.5
fi
hyprctl keyword monitor "$internal,preferred,auto,1" >/dev/null 2>&1 || true
hyprctl dispatch dpms on >/dev/null 2>&1 || true
hyprctl monitors -j 2>/dev/null \
| ${pkgs.jq}/bin/jq -e --arg m "$internal" 'any(.[]; .name == $m)' \
>/dev/null 2>&1
;;
*) exit 64 ;;
esac
'';
keyboardTool = import ./keyboard-tool.nix { inherit pkgs lib config; };
displayDumpTool =
(import ./display-tools.nix { inherit pkgs; }).displayDumpTool;
displayTransition = import ./display-transition.nix {
inherit pkgs keyboardTool displayDumpTool;
sync = lib.getExe config.nomarchy.package;
};
in
(import ./display-tools.nix {
inherit pkgs;
displayTransition = miniTransition;
inherit displayTransition;
}).displayWakeTool;
in
{

View File

@@ -0,0 +1,81 @@
# The per-device keyboard-layout helper (nomarchy-keyboard-layout).
# Extracted from hyprland.nix (#150) so it is built ONCE and shared: the
# Hyprland module puts it on PATH and drives it from the hotplug watcher,
# while the display-transition tool calls it (restore_keyboards) after a
# config reload. Pass the module's { pkgs, lib, config }.
{ pkgs, lib, config }:
let
sync = lib.getExe config.nomarchy.package;
# Candidate layouts offered by the new-keyboard picker: the session
# layout(s) (nomarchy.keyboard.layout, comma-split for a multi-layout
# session) plus the extra nomarchy.keyboard.layouts pool. The pool is
# deliberately NOT merged into input.kb_layout — those candidates are for
# *external* keyboards and get applied per-device by the watcher's apply().
# Loading them onto the session keyboard is what let a global switch flip
# the built-in board to the wrong layout.
pickerLayouts = lib.unique (
(lib.splitString "," config.nomarchy.keyboard.layout)
++ config.nomarchy.keyboard.layouts
);
in
# Per-device keyboard helper shared by the hotplug watcher and the manual
# System Keyboard menu. The configured candidates are listed first, then
# every XKB layout known to the system, so the feature works on a default
# install instead of silently disappearing until home.nix is edited.
pkgs.writeShellScriptBin "nomarchy-keyboard-layout" ''
set -u
sync=${sync}
layouts() {
printf '%s\n' ${lib.concatMapStringsSep " " lib.escapeShellArg pickerLayouts}
${pkgs.gawk}/bin/awk '
/^! layout/ { in_layouts=1; next }
/^!/ && in_layouts { exit }
in_layouts && NF { print $1 }
' ${pkgs.xkeyboard_config}/share/X11/xkb/rules/base.lst
}
saved_map() { "$sync" get settings.keyboard.devices 2>/dev/null || echo '{}'; }
saved_for() { saved_map | jq -r --arg k "$1" '.[$k] // empty'; }
apply_runtime() { hyprctl keyword "device[$1]:kb_layout" "$2" >/dev/null 2>&1; }
case "''${1:-}" in
layouts)
layouts | ${pkgs.gawk}/bin/awk 'NF && !seen[$0]++' ;;
devices)
hyprctl devices -j 2>/dev/null \
| jq -r '.keyboards[] | "\(.name)\t\(.active_keymap // \"unknown\")"' ;;
saved)
[ -n "''${2:-}" ] || exit 64
saved_for "$2" ;;
restore)
[ -n "''${2:-}" ] || exit 64
layout=$(saved_for "$2")
[ -n "$layout" ] && apply_runtime "$2" "$layout" ;;
apply)
[ -n "''${2:-}" ] && [ -n "''${3:-}" ] || exit 64
layouts | ${pkgs.gnugrep}/bin/grep -Fxq "$3" \
|| { notify-send "Keyboard" "Unknown XKB layout: $3"; exit 64; }
if apply_runtime "$2" "$3"; then
map=$(saved_map | jq -c --arg k "$2" --arg v "$3" '. + {($k): $v}') || exit 1
if "$sync" --quiet set settings.keyboard.devices "$map" --no-switch; then
notify-send "Keyboard" "$2 $3"
else
notify-send "Keyboard" "$2 $3 for this session, but the choice could not be saved."
exit 1
fi
else
notify-send "Keyboard" "Could not apply $3 to $2."
exit 1
fi ;;
forget)
[ -n "''${2:-}" ] || exit 64
map=$(saved_map | jq -c --arg k "$2" 'del(.[$k])') || exit 1
"$sync" --quiet set settings.keyboard.devices "$map" --no-switch || exit 1
apply_runtime "$2" ${lib.escapeShellArg config.nomarchy.keyboard.layout} || true
notify-send "Keyboard" "$2 now follows the session layout." ;;
*)
echo "usage: nomarchy-keyboard-layout [layouts|devices|saved <device>|restore <device>|apply <device> <layout>|forget <device>]" >&2
exit 64 ;;
esac
''

View File

@@ -18,7 +18,10 @@ lib.mkIf config.nomarchy.rofi.enable {
filefile="$run/nomarchy-record.file"
alive() { [ -f "$pidfile" ] && kill -0 "$(cat "$pidfile")" 2>/dev/null; }
poke_bar() { pkill -RTMIN+8 waybar 2>/dev/null || true; }
# -x, both comm names: nixpkgs wraps the binary as `.waybar-wrapped`,
# and an unanchored `waybar` also matches (and kills) the
# nomarchy-waybar supervisor, whose bash dies on an unhandled RTMIN.
poke_bar() { pkill -RTMIN+8 -x 'waybar|\.waybar-wrapped' 2>/dev/null || true; }
case "''${1:-}" in
start)

View File

@@ -128,8 +128,18 @@ in
color: #${base16.base00};
}
/* Dialog / message-box action buttons (#98). */
button, button label,
/* Dialog / message-box action buttons (#98) dialog-scoped
ONLY, never a bare `button` / `button label`. This file is
loaded by every GTK3 process including Waybar, and a direct
`button label` rule beats the color waybar.css puts on
`#workspaces button` (a rule matching the label node always
outranks color inherited from the button), so the old bare
pin painted the workspace digits with whatever palette was
live when the bar process started the 2026-07-19
stale-digit bug: switch themes, digits keep the previous
gtk.css color until the bar restarts. The real #98 fix is
forcing adw-gtk3-dark (below); these pins are only the
dialog safety net for a light sheet slipping in. */
.dialog-action-area button,
.dialog-action-area button label,
messagedialog button,
@@ -138,7 +148,9 @@ in
.message-dialog button label {
color: #${base16.base05};
}
button:disabled, button:disabled label {
.dialog-action-area button:disabled, .dialog-action-area button:disabled label,
messagedialog button:disabled, messagedialog button:disabled label,
.message-dialog button:disabled, .message-dialog button:disabled label {
color: alpha(#${base16.base05}, 0.5);
}
/* Accent buttons: the palette's base as the label, on a chip

View File

@@ -24,7 +24,8 @@ let
cur=$(${pkgs.systemd}/bin/timedatectl show -p Timezone --value 2>/dev/null || true)
[ "$cur" = "$last" ] && continue
last=$cur
${pkgs.procps}/bin/pkill -SIGUSR2 -x waybar 2>/dev/null || true
# Both comm names: nixpkgs wraps the binary as `.waybar-wrapped`.
${pkgs.procps}/bin/pkill -SIGUSR2 -x 'waybar|\.waybar-wrapped' 2>/dev/null || true
done
'';
in

View File

@@ -71,7 +71,8 @@ let
echo 0
}
refresh_bar() { ${pkgs.procps}/bin/pkill -RTMIN+9 -x waybar 2>/dev/null || true; }
# Both comm names: nixpkgs wraps the binary as `.waybar-wrapped`.
refresh_bar() { ${pkgs.procps}/bin/pkill -RTMIN+9 -x 'waybar|\.waybar-wrapped' 2>/dev/null || true; }
case "''${1:-status}" in
check)

View File

@@ -36,9 +36,10 @@ let
# Waybar supervisor — exec-once has no restart, so a crashed bar used to
# leave the session bar-less until relogin (seen on hardware: a theme
# switch crashed waybar mid-reload). Respawns on ANY exit — a plain
# `pkill -x waybar` is now a clean restart, which nomarchy-state-sync
# uses instead of the crash-prone in-place SIGUSR2 reload when it sees
# this supervisor running. Crash-loop guard: 5 exits within 10s of
# `pkill -x 'waybar|\.waybar-wrapped'` (nixpkgs wraps the binary, so
# the comm is `.waybar-wrapped`) is now a clean restart, which
# nomarchy-state-sync uses instead of the crash-prone in-place SIGUSR2
# reload when it sees this supervisor running. Crash-loop guard: 5 exits within 10s of
# their start → give up with a critical notification instead of
# spinning. Stop the bar for real: pkill -f nomarchy-waybar (TERM is
# trapped to take the child down too).

View File

@@ -22,8 +22,11 @@ writeShellScriptBin "nomarchy-airplane" ''
networkmanager util-linux bluez coreutils gnugrep gawk libnotify procps
]}
# -x, both comm names: nixpkgs wraps the binary as `.waybar-wrapped`,
# and an unanchored `waybar` also matches (and kills) the
# nomarchy-waybar supervisor, whose bash dies on an unhandled RTMIN.
poke_bar() {
pkill -RTMIN+11 waybar 2>/dev/null || true
pkill -RTMIN+11 -x 'waybar|\.waybar-wrapped' 2>/dev/null || true
}
wifi_enabled() {

View File

@@ -16,7 +16,7 @@
stdenvNoCC.mkDerivation {
pname = "nomarchy-state-sync";
version = "0.5.1";
version = "0.5.2";
src = ./.;

View File

@@ -447,14 +447,23 @@ def run_switch() -> None:
# reload_style_on_change while the symlinks flip), so prefer the
# restart whenever the supervisor is there to catch it; fall back to
# SIGUSR2 for unsupervised/custom bars. No-ops if nothing is running.
# The pattern matches both comm names: nixpkgs wraps the binary, so
# the process is `.waybar-wrapped` — a bare `pkill -x waybar` matched
# nothing, theme switches never touched the bar, and it kept the
# gtk.css of whatever theme it was started under (the 2026-07-19
# stale-digit-color bug). Keep -x: unanchored `waybar` also matches
# the supervisor's own `nomarchy-waybar` comm and would kill it.
if shutil.which("pkill"):
supervised = subprocess.run(
["pgrep", "-f", "nomarchy-waybar"], capture_output=True
).returncode == 0
if supervised:
subprocess.run(["pkill", "-x", "waybar"], check=False)
subprocess.run(["pkill", "-x", r"waybar|\.waybar-wrapped"], check=False)
else:
subprocess.run(["pkill", "--signal", "SIGUSR2", "-x", "waybar"], check=False)
subprocess.run(
["pkill", "--signal", "SIGUSR2", "-x", r"waybar|\.waybar-wrapped"],
check=False,
)
# ─── Theme assets (wallpapers) ────────────────────────────────────────────

View File

@@ -140,8 +140,15 @@ pkgs.testers.runNixOSTest {
# re-dock action.
machine.succeed("printf 'state: closed\\n' > /run/nomarchy-test-lid-state")
machine.succeed(hy + "reload'")
# A reload re-lights the *already-present* (disabled) Virtual-1 connector,
# which emits no `monitoradded` so the watcher only re-disables it on its
# 1s idle tick (chatty IPC events `continue` past reconcile). Under this
# softGL VM's load (swaync crash-loops on the absent Vulkan device, see the
# journal) that idle tick is badly starved and the re-dock lands ~15-20s
# out a real, prompt re-dock on hardware, but right on the old 15s bound,
# so this flaked. Bounded generously, in line with the 20-30s waits above.
machine.wait_until_succeeds(
hy + "-j monitors' | jq -e 'all(.[]; .name != \"Virtual-1\")'", timeout=15
hy + "-j monitors' | jq -e 'all(.[]; .name != \"Virtual-1\")'", timeout=45
)
machine.succeed("journalctl -t nomarchy-display-watch | grep -q 'action=re-dock'")
print("reload-mid-dock: panel re-disabled by enforce_dock_intent; re-dock logged (#148)")

View File

@@ -79,7 +79,7 @@ pkgs.testers.runNixOSTest {
machine.succeed("grep -q 'theme\\[main_bg\\]' /home/nomarchy/.config/btop/themes/nomarchy.theme")
# Poke custom/doctor (signal 10) so a first-poll miss at session start
# doesn't leave the tripwire blank for the full 300s interval.
machine.execute("su - nomarchy -c 'pkill -RTMIN+10 waybar' >/dev/null 2>&1 || true")
machine.execute("su - nomarchy -c \"pkill -RTMIN+10 -x 'waybar|\\.waybar-wrapped'\" >/dev/null 2>&1 || true")
machine.sleep(3)
# Park the cursor in the bottom-right corner so it doesn't sit in the
# middle of the desktop shot (that frame doubles as the theme-picker