Compare commits

...

12 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
f658b0087e feat(autotheme): fire at the configured sunrise/sunset, not a 15-min poll
All checks were successful
Check / eval (push) Successful in 4m14s
The timer's OnCalendar now bakes the configured times (falling back
like _hhmm_to_min on a hand-edited state), so the switch lands at the
configured minute instead of up to 15 minutes late. The original
poll's robustness case is covered without it: Persistent catches
transitions missed while powered off, systemd fires elapsed calendar
timers on resume, and OnStartupSec settles the theme after login.

Baked times mean a time edit must rebuild: the menu's Sunrise/Sunset
writes now run `auto --force` when enabled (one rebuild re-bakes the
timer and lands on the right theme, same as the enable flow). Day and
night slugs stay live reads — but with no poll to catch them up,
editing the slot currently on screen applies immediately via a plain
`auto`. New eval check `auto-theme-timer` asserts the bake
state-bridges-style: configured times reach OnCalendar, garbage falls
back to 07:00/20:00, disabled installs no unit. Docs + hardware-queue
entry updated to the new semantics.

V2: checks.auto-theme (KVM VM, passed) covers the auto engine the
timer ticks; checks.auto-theme-timer (eval, passed) covers the unit;
OnCalendar strings systemd-analyze-validated; the rebuilt menu script
passes bash -n with the new branches in place. V3 pending: live timer
firing at the exact configured minute on hardware (existing
"Auto-theme pair flip" queue entry, refreshed).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-18 22:31:02 +01:00
ac24ac7c34 fix(state): auto-commit died on dead legacy pathspecs after #107
Since d8e1a13 removed theme-state.json, auto_commit's fixed pathspec
(state.json + both legacy names) made `git commit` abort — unlike
`git diff`, commit errors on a pathspec that matches nothing — so every
per-mutation settings commit was silently "skipped" and state.json
stayed dirty until a lifecycle sweep caught it. Filter the legacy names
down to those git still knows (index or HEAD — a migration's staged
`git rm` still rides along). checks.auto-theme now runs its state dir
as a git repo with autoCommit on and asserts each set/apply lands as
its own commit leaving nothing dirty — the exact post-#107 repo shape
that used to abort. state-sync 0.5.0 → 0.5.1.

V2: nix build .#checks.x86_64-linux.auto-theme (KVM VM run, passed,
including the new git assertions); the built tool was also exercised
against a clone of a real post-#107 machine flake — the set
auto-committed and the tree stayed clean.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-18 22:30:50 +01:00
24 changed files with 652 additions and 385 deletions

View File

@@ -1,11 +1,12 @@
# Nomarchy scheduled lock bump — the automated half of "rock-stable # 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 # 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 # deliberate hand-edited v2, see agent/GOALS.md), gate it, and land it
# on main only on green. `v1` promotion stays human, always. # on main only on green. `v1` promotion stays human, always.
# #
# Fast lane: workflow_dispatch. For a security fix upstream, run this # 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 # Gate scope: `nix flake check --no-build` (eval tier) followed by a V1 build
# (home-manager activation package + nixos toplevel) to catch compilation errors. # (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; # Failure mode: a red gate fails this run visibly and pushes nothing;
# next schedule retries. A push race (someone landed on main mid-run) # 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 # Container recipe (nixbld users, sandbox=false, pinned Nix, plain-shell
# install) inherited from check.yml — the gotchas are documented there. # install) inherited from check.yml — the gotchas are documented there.
@@ -27,7 +28,7 @@ name: Lock bump
on: on:
schedule: schedule:
- cron: '17 5 * * 1' # Mondays 05:17 UTC - cron: '17 5 * * *' # daily 05:17 UTC
workflow_dispatch: workflow_dispatch:
jobs: jobs:

View File

@@ -128,6 +128,26 @@ v1+ polish, not a v1 blocker.
### 146. `[watch]` hypridle hangs instead of exiting when its compositor vanishes ### 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 **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 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 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 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. 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 ### 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: Spun out of #147 (shipped 2026-07-17) so its loose thread survives the entry:

View File

@@ -52,11 +52,18 @@ Everything else below stays open; order is convenience, not a gate.
summer-day; this is the on-hardware confirmation of the report. summer-day; this is the on-hardware confirmation of the report.
- [ ] **Auto-theme pair flip on hardware** — set - [ ] **Auto-theme pair flip on hardware** — set
`settings.autoTheme.{day,night}` to one of the new pairs (e.g. `settings.autoTheme.{day,night}` to one of the new pairs (e.g.
kiln-clay / kiln), force sunrise/sunset around the current time, kiln-clay / kiln), set sunset (or sunrise) a few minutes ahead via
let the timer flip once each way. Pass = the whole desktop switches Look & Feel Auto theme (the time edit itself rebuilds — since the
in one generation, and nothing leaks from the previous theme — exact-time revision the timer fires AT the configured minute, no
fonts, rounding, terminal opacity all reset (the per-theme 15-min poll), let the timer flip once each way. Pass = (1)
appearance block), wallpaper follows. `systemctl --user list-timers nomarchy-auto-theme` shows the two
configured times as the trigger; (2) the switch lands within ~a
minute of the configured time; (3) the whole desktop switches in
one generation, and nothing leaks from the previous theme — fonts,
rounding, terminal opacity all reset (the per-theme appearance
block), wallpaper follows; (4) with autoCommit on, `git -C
~/.nomarchy log` shows an `apply theme` commit and state.json is
not left dirty (the 0.5.1 pathspec fix).
- [ ] **#148 dock-intent enforcement without auto-profiles (dev box, docked)** — - [ ] **#148 dock-intent enforcement without auto-profiles (dev box, docked)** —
after pulling the #148 commit: **log out and back in first** — the after pulling the #148 commit: **log out and back in first** — the
watcher is `exec-once`, so `nomarchy-home` alone leaves the old one watcher is `exec-once`, so `nomarchy-home` alone leaves the old one
@@ -527,6 +534,21 @@ Everything else below stays open; order is convenience, not a gate.
bar, notifications, and clipboard history (SUPER+V) work. This is bar, notifications, and clipboard history (SUPER+V) work. This is
the V3 close for the stale `graphical-session.target` / the V3 close for the stale `graphical-session.target` /
`start-limit-hit` relogin breakage (BACKLOG #149 note). `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 ## AMD dev box only
- [ ] **#118 smartd still runs where drives DO have SMART** (this commit) — the - [ ] **#118 smartd still runs where drives DO have SMART** (this commit) — the
@@ -609,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 Acer's own fontconfig/DPI resolving `ch` the same way — try it under a
**JetBrainsMono 14 theme** (summer-day/night, kanagawa), the widest case. **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 ## Latitude 5310 / 5410 only
- [ ] **v1 QA batch on-hardware pass** (583708d batch was QEMU-verified) — - [ ] **v1 QA batch on-hardware pass** (583708d batch was QEMU-verified) —
general smoke before the next `main → v1` promotion (broader than 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) ## 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 - **Task:** Bernardo live: rofi#2317 got a maintainer reply → adopt; plus
"make executive-slate's app menu the boreal-style icon grid". "make executive-slate's app menu the boreal-style icon grid".

View File

@@ -70,11 +70,12 @@ State lives in `settings.autoTheme`:
| `day` / `night` | theme slugs (see `nomarchy-state-sync list`) — the exclusive pairs are made for this: `boreal-dawn`/`boreal`, `executive-ivory`/`executive-slate`, `kiln-clay`/`kiln`, `summer-day`/`summer-night` | | `day` / `night` | theme slugs (see `nomarchy-state-sync list`) — the exclusive pairs are made for this: `boreal-dawn`/`boreal`, `executive-ivory`/`executive-slate`, `kiln-clay`/`kiln`, `summer-day`/`summer-night` |
| `sunrise` / `sunset` | switch times, `"HH:MM"` (24-hour) | | `sunrise` / `sunset` | switch times, `"HH:MM"` (24-hour) |
A timer re-checks every ~15 minutes: the day theme applies around sunrise, the A timer fires exactly at the configured sunrise and sunset (plus once shortly
night theme around sunset — but it only rebuilds when the active theme after login, and it catches up on a transition missed while the machine was
actually needs to change, so most checks do nothing. Enabling rebuilds once off or asleep) — it only rebuilds when the active theme actually needs to
(to install the timer); disabling is instant. Preview the current decision change. The times are baked into the timer, so editing them (or enabling)
without switching with `nomarchy-state-sync auto --which`. rebuilds once; disabling is instant. Preview the current decision without
switching with `nomarchy-state-sync auto --which`.
These values are deliberately kept at normal priority in the modules, so they These values are deliberately kept at normal priority in the modules, so they
stay owned by the theme system. If you *insist* on pinning one in `home.nix` stay owned by the theme system. If you *insist* on pinning one in `home.nix`

View File

@@ -316,11 +316,17 @@ Design/decision records and a running log of shipped work (items marked
drives `nomarchy-state-sync auto`, which picks day vs night by the local drives `nomarchy-state-sync auto`, which picks day vs night by the local
clock and applies the preset **only if it differs** (idempotent). clock and applies the preset **only if it differs** (idempotent).
`modules/home/autotheme.nix` installs a user timer `modules/home/autotheme.nix` installs a user timer
(`OnStartupSec` + `OnCalendar=*:0/15` + `Persistent`, gated on the state (`OnStartupSec` + `OnCalendar` + `Persistent`, gated on the state
flag) that ticks `auto`; Look & Feel Auto theme toggles it and picks the flag) that ticks `auto`; Look & Feel Auto theme toggles it and picks the
pair/times. `cmd_apply`'s core was factored into `apply_named` so `auto` pair/times. `cmd_apply`'s core was factored into `apply_named` so `auto`
reuses it verbatim. Periodic (not exact sunrise/sunset timers) for reuses it verbatim. Originally a 15-min poll (`OnCalendar=*:0/15`) for
robustness to suspend/DST; the idempotent `auto` keeps ticks cheap. Enable robustness to suspend/DST; **revised** (Bernardo 2026-07-18) to fire
exactly at the configured sunrise/sunset — `Persistent` covers powered-off
gaps, systemd fires elapsed calendar timers on resume, and `OnStartupSec`
settles login, so the poll bought nothing but a ≤15-min switch lag. The
times are now baked into `OnCalendar`, so the menu's Sunrise/Sunset edits
rebuild via `auto --force` (day/night slugs stay live reads; editing the
on-screen slot applies immediately since no poll catches it up). Enable
rebuilds once (installs the timer + `auto --force`); disable is instant rebuilds once (installs the timer + `auto --force`); disable is instant
(the flag flips and `auto` self-gates). **V2:** `checks.auto-theme` moves (the flag flips and `auto` self-gates). **V2:** `checks.auto-theme` moves
the VM clock across a configured sunset/sunrise and asserts the theme flips the VM clock across a configured sunset/sunrise and asserts the theme flips
@@ -521,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 V1: template HM activation built with `unstable.hello`, closure carries
the unstable store path; `checks.unstable-seam` (eval-only) guards the the unstable store path; `checks.unstable-seam` (eval-only) guards the
seam permanently; lock diff added exactly the one node. 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 - ✓ **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 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 healthy all day and ended in a routine clean reboot); it ended the
@@ -1834,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 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 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, 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** or a user's locked rev can vanish from history). **Cadence daily**
(release-26.05 doesn't move fast; nightly is just churn), and `nix flake (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 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: (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 shrinks the security-patch latency the single-input model otherwise imposes
@@ -1843,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` — already eval/build/VM-tested and one hardware promotion away from `v1` —
worth fast-laning lock-only/security bumps ahead of feature batches. worth fast-laning lock-only/security bumps ahead of feature batches.
**Shipped since:** `.gitea/workflows/check.yml` (eval tier on every main **Shipped since:** `.gitea/workflows/check.yml` (eval tier on every main
push) and `bump.yml` (Monday 05:17 UTC scheduled lock bump, eval + V1 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). 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` 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 (~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 runner's 2 GB container cap) to `tools/ci-eval.sh`, one output per nix
@@ -1941,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 unsupervised/custom bars; `reload_style_on_change` stays for manual
`home-update` restyles. VM-verified on the headless software-GL `home-update` restyles. VM-verified on the headless software-GL
desktop: SIGKILL → new pid; clean kill → respawn; SIGUSR2 → alive. 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-rebuild`** (Latitude QA request): the no-update twin of
`sys-update` — snapshot-first system rebuild against the *current* `sys-update` — snapshot-first system rebuild against the *current*
lock, for config-only changes; `home-update` was already lock-free. lock, for config-only changes; `home-update` was already lock-free.

38
flake.lock generated
View File

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

View File

@@ -342,6 +342,48 @@
pkgs.runCommand "nomarchy-state-bridges" { } pkgs.runCommand "nomarchy-state-bridges" { }
"touch $out"; # all ${toString (builtins.length cases)} assertions held at eval "touch $out"; # all ${toString (builtins.length cases)} assertions held at eval
# The baked auto-theme timer (#79 revision): sunrise/sunset land in
# the unit's OnCalendar at rebuild, and nothing in a build fails
# when that bake goes stale or produces a calendar systemd rejects
# — so assert it at eval, state-bridges style.
auto-theme-timer =
let
homeWith = stateFile:
(downstream.homeConfigurations.me.extendModules {
modules = [{
nomarchy.stateFile = nixpkgs.lib.mkForce stateFile;
}];
}).config;
atState = name: at: stateFileOf name { settings.autoTheme = at; };
on = homeWith (atState "state-autotheme-on.json" {
enable = true; day = "summer-day"; night = "summer-night";
sunrise = "06:30"; sunset = "21:45";
});
garbage = homeWith (atState "state-autotheme-garbage.json" {
enable = true; day = "summer-day"; night = "summer-night";
sunrise = "sometime"; sunset = "25:99";
});
off = homeWith absentState; # template carries no autoTheme key
cal = c: c.systemd.user.timers.nomarchy-auto-theme.Timer.OnCalendar;
cases = [
{ n = "configured times are baked into OnCalendar";
ok = cal on == [ "*-*-* 06:30:00" "*-*-* 21:45:00" ]; }
{ n = "garbage times fall back to 07:00/20:00";
ok = cal garbage == [ "*-*-* 07:00:00" "*-*-* 20:00:00" ]; }
{ n = "disabled no timer unit installed";
ok = !(off.systemd.user.timers ? nomarchy-auto-theme); }
];
failed = builtins.filter (c: !c.ok) cases;
in
if failed != [ ] then
throw ''
Auto-theme timer bake broken settings.autoTheme no longer
reaches the unit as expected:
${nixpkgs.lib.concatMapStringsSep "\n" (c: " ${c.n}") failed}''
else
pkgs.runCommand "nomarchy-auto-theme-timer" { }
"touch $out"; # all ${toString (builtins.length cases)} assertions held at eval
# The bridge, end to end on a booted machine: checks.state-bridges # The bridge, end to end on a booted machine: checks.state-bridges
# proves the option flips at eval, this proves a state key alone # proves the option flips at eval, this proves a state key alone
# reaches a RUNNING service. The node sets no # reaches a RUNNING service. The node sets no
@@ -2204,10 +2246,14 @@
# `home-manager switch` is stubbed via NOMARCHY_REBUILD (that # `home-manager switch` is stubbed via NOMARCHY_REBUILD (that
# generic apply path is exercised by every manual theme change); # generic apply path is exercised by every manual theme change);
# this isolates the auto decision + apply + rebuild-trigger. # this isolates the auto decision + apply + rebuild-trigger.
# The state dir is a git repo with autoCommit on, so every set/apply
# also exercises auto_commit against a repo that has ONLY state.json
# — the post-#107 shape whose dead legacy pathspecs once aborted
# every commit silently.
auto-theme = pkgs.testers.runNixOSTest { auto-theme = pkgs.testers.runNixOSTest {
name = "nomarchy-auto-theme"; name = "nomarchy-auto-theme";
nodes.machine = { pkgs, ... }: { nodes.machine = { pkgs, ... }: {
environment.systemPackages = [ pkgs.nomarchy-state-sync ]; environment.systemPackages = [ pkgs.nomarchy-state-sync pkgs.git ];
time.timeZone = "UTC"; # naive HH:MM == the clock we set time.timeZone = "UTC"; # naive HH:MM == the clock we set
}; };
testScript = '' testScript = ''
@@ -2220,6 +2266,14 @@
machine.succeed( machine.succeed(
"echo '{\"slug\":\"summer-day\"}' > /root/.nomarchy/state.json" "echo '{\"slug\":\"summer-day\"}' > /root/.nomarchy/state.json"
) )
# Git-tracked like a real downstream flake no legacy
# theme-state.json/theme.json anywhere in its history.
machine.succeed(
"git -C /root/.nomarchy init -q"
" && git -C /root/.nomarchy add state.json"
" && git -C /root/.nomarchy -c user.name=t -c user.email=t@t"
" commit -qm init"
)
machine.succeed( machine.succeed(
"printf '#!/bin/sh\\necho switch >> /root/rebuilds\\n' > /root/rebuild" "printf '#!/bin/sh\\necho switch >> /root/rebuilds\\n' > /root/rebuild"
" && chmod +x /root/rebuild" " && chmod +x /root/rebuild"
@@ -2229,14 +2283,24 @@
"NOMARCHY_DEFAULT_THEMES=${pkgs.nomarchy-default-themes} " "NOMARCHY_DEFAULT_THEMES=${pkgs.nomarchy-default-themes} "
"NOMARCHY_REBUILD=/root/rebuild ") "NOMARCHY_REBUILD=/root/rebuild ")
# Pair + schedule (setup writes only, no rebuild). # Pair + schedule (setup writes only, no rebuild). autoCommit
for kv in ["settings.autoTheme.enable true", # first, so every following write must land as its own commit.
for kv in ["settings.autoCommit true",
"settings.autoTheme.enable true",
"settings.autoTheme.day summer-day", "settings.autoTheme.day summer-day",
"settings.autoTheme.night summer-night", "settings.autoTheme.night summer-night",
"settings.autoTheme.sunrise 07:00", "settings.autoTheme.sunrise 07:00",
"settings.autoTheme.sunset 20:00"]: "settings.autoTheme.sunset 20:00"]:
machine.succeed(env + "nomarchy-state-sync --quiet set " + kv + " --no-switch") machine.succeed(env + "nomarchy-state-sync --quiet set " + kv + " --no-switch")
# Every `set` above auto-committed (the regression: dead legacy
# pathspecs made `git commit` abort, skipping ALL of these).
machine.succeed(
"git -C /root/.nomarchy log --format=%s"
" | grep -qF 'set settings.autoTheme.sunset'"
)
machine.succeed("git -C /root/.nomarchy diff --quiet HEAD -- state.json")
def slug(): def slug():
return machine.succeed(env + "nomarchy-state-sync get slug").strip() return machine.succeed(env + "nomarchy-state-sync get slug").strip()
def rebuilds(): def rebuilds():
@@ -2253,6 +2317,11 @@
machine.succeed(env + "nomarchy-state-sync auto") machine.succeed(env + "nomarchy-state-sync auto")
assert slug() == "summer-night", f"post-sunset should be night, got {slug()}" assert slug() == "summer-night", f"post-sunset should be night, got {slug()}"
assert rebuilds() == 1, f"sunset should trigger one rebuild, got {rebuilds()}" assert rebuilds() == 1, f"sunset should trigger one rebuild, got {rebuilds()}"
# The switch auto-committed too nothing left dirty behind it.
machine.succeed(
"git -C /root/.nomarchy log --format=%s | grep -qF 'apply theme'"
)
machine.succeed("git -C /root/.nomarchy diff --quiet HEAD -- state.json")
# Another tick still after sunset idempotent, no extra rebuild. # Another tick still after sunset idempotent, no extra rebuild.
machine.succeed("date -s '2026-01-02 20:06:00'") machine.succeed("date -s '2026-01-02 20:06:00'")

View File

@@ -1,18 +1,37 @@
# Auto time-of-day theme switch (BACKLOG #79, VISION § D). A user timer # Auto time-of-day theme switch (BACKLOG #79, VISION § D). A user timer
# periodically runs `nomarchy-state-sync auto`, which reads # runs `nomarchy-state-sync auto` at the configured sunrise and sunset,
# settings.autoTheme = { enable, day, night, sunrise, sunset } from the # which reads settings.autoTheme = { enable, day, night, sunrise, sunset }
# state file and applies the day or night preset for the current clock — # from the state file and applies the day or night preset for the current
# through the SAME one engine as a manual `apply` (no second pipeline). # clock — through the SAME one engine as a manual `apply` (no second
# pipeline).
# #
# The command self-gates (no-op when disabled) and is idempotent (it only # The trigger times are BAKED into OnCalendar at rebuild (originally this
# rebuilds when the active theme actually needs to change), so the timer # was a 15-min poll; exact times won on wasted wakeups — Bernardo
# can tick freely. Install is gated on the state flag (like nomarchy.updates # 2026-07-18), so a time edit must rebuild: the menu's Sunrise/Sunset
# gates on its enable), so a machine not using the feature carries no timer; # writes run `auto --force`, whose apply is that rebuild. Missed
# enabling it from the menu (slice 3) writes the flag + rebuilds, which is # transitions are covered without polling: Persistent=true catches ones
# what brings the timer into being. # that pass while powered off, systemd fires elapsed OnCalendar timers on
# resume from suspend, and OnStartupSec settles the theme just after
# login. The command self-gates (no-op when disabled) and is idempotent
# (it only rebuilds when the active theme actually needs to change), so
# every extra firing is cheap.
#
# Install is gated on the state flag (like nomarchy.updates gates on its
# enable), so a machine not using the feature carries no timer; enabling
# it from the menu (slice 3) writes the flag + rebuilds, which is what
# brings the timer into being.
{ config, lib, pkgs, ... }: { config, lib, pkgs, ... }:
lib.mkIf (config.nomarchy.settings.autoTheme.enable or false) { let
at = config.nomarchy.settings.autoTheme or { };
# "HH:MM" or the fallback — mirrors _hhmm_to_min in nomarchy-state-sync,
# so a hand-edited state can't bake an OnCalendar systemd rejects.
hhmm = v: fallback:
if builtins.isString v
&& builtins.match "([01][0-9]|2[0-3]):[0-5][0-9]" v != null
then v else fallback;
in
lib.mkIf (at.enable or false) {
systemd.user.services.nomarchy-auto-theme = { systemd.user.services.nomarchy-auto-theme = {
Unit.Description = "Apply the day/night theme for the current time"; Unit.Description = "Apply the day/night theme for the current time";
Service = { Service = {
@@ -25,10 +44,13 @@ lib.mkIf (config.nomarchy.settings.autoTheme.enable or false) {
}; };
systemd.user.timers.nomarchy-auto-theme = { systemd.user.timers.nomarchy-auto-theme = {
Unit.Description = "Periodic day/night theme check"; Unit.Description = "Day/night theme switch at sunrise and sunset";
Timer = { Timer = {
OnStartupSec = "1min"; # settle on the right theme shortly after login OnStartupSec = "1min"; # settle on the right theme shortly after login
OnCalendar = "*:0/15"; # re-check every 15 min; auto no-ops if unchanged OnCalendar = [
"*-*-* ${hhmm (at.sunrise or null) "07:00"}:00" # sunrise → day
"*-*-* ${hhmm (at.sunset or null) "20:00"}:00" # sunset → night
];
Persistent = true; # catch a transition missed while powered off Persistent = true; # catch a transition missed while powered off
}; };
Install.WantedBy = [ "timers.target" ]; Install.WantedBy = [ "timers.target" ];

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 = displayDumpTool =
(import ./display-tools.nix { inherit pkgs; }).displayDumpTool; (import ./display-tools.nix { inherit pkgs; }).displayDumpTool;
# One transition primitive for the interactive Dock mode, abrupt undock, # The full dock/undock/enable transition primitive lives in its own file
# and named profiles that disable the internal panel. Dock is one Hyprland # so idle.nix shares this exact safety-critical copy (#150), not a mini.
# batch: enable the target, move every internal workspace, focus it, then displayTransitionTool = import ./display-transition.nix {
# disable the panel. Undock deliberately enables the panel FIRST, waits inherit pkgs sync keyboardTool displayDumpTool;
# 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
'';
displayWakeTool = displayWakeTool =
(import ./display-tools.nix { (import ./display-tools.nix {
@@ -786,78 +611,7 @@ ${lib.concatStringsSep "\n" (lib.mapAttrsToList
done done
''; '';
# Candidate layouts offered by the new-keyboard picker: the session keyboardTool = import ./keyboard-tool.nix { inherit pkgs lib config; };
# 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
'';
# On a keyboard that connects after login (not declared in # On a keyboard that connects after login (not declared in
# keyboard.devices, not already remembered), ask for a layout and persist it # 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 # 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 = displayWake =
let let
miniTransition = pkgs.writeShellScriptBin "nomarchy-display-transition" '' keyboardTool = import ./keyboard-tool.nix { inherit pkgs lib config; };
set -u displayDumpTool =
case "''${1:-}" in (import ./display-tools.nix { inherit pkgs; }).displayDumpTool;
undock|enable) displayTransition = import ./display-transition.nix {
internal="''${2:-}" inherit pkgs keyboardTool displayDumpTool;
[ -n "$internal" ] || exit 64 sync = lib.getExe config.nomarchy.package;
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
'';
in in
(import ./display-tools.nix { (import ./display-tools.nix {
inherit pkgs; inherit pkgs;
displayTransition = miniTransition; inherit displayTransition;
}).displayWakeTool; }).displayWakeTool;
in 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" filefile="$run/nomarchy-record.file"
alive() { [ -f "$pidfile" ] && kill -0 "$(cat "$pidfile")" 2>/dev/null; } 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 case "''${1:-}" in
start) start)

View File

@@ -962,13 +962,16 @@ ${lib.concatStringsSep "\n" (lib.mapAttrsToList
autotheme) autotheme)
# Look & Feel Auto theme automatic day/night theme switch # Look & Feel Auto theme automatic day/night theme switch
# (settings.autoTheme, applied by the nomarchy-auto-theme timer from # (settings.autoTheme, applied by the nomarchy-auto-theme timer from
# autotheme.nix). The enable flag GATES the timer's install, so # autotheme.nix). The enable flag GATES the timer's install and the
# turning it *on* must rebuild; day/night/times are read live by # sunrise/sunset times are BAKED into its OnCalendar, so turning it
# `nomarchy-state-sync auto`, so those writes are instant # *on* or editing a time must rebuild both run `auto --force`, so
# (--no-switch). Enabling writes the flag then `auto --force`, so a # one rebuild re-bakes the timer AND lands on the right theme now.
# single rebuild both installs the timer and applies the right theme # Day/night slugs are read live by `nomarchy-state-sync auto`, so
# now. Disabling is instant: the flag flips and `auto` self-gates to # those writes are instant (--no-switch); when the edited slot is the
# a no-op, so the lingering timer does nothing until the next rebuild. # one on screen, a plain `auto` applies it right away (the timer only
# fires at the next transition there is no poll to catch it up).
# Disabling is instant: the flag flips and `auto` self-gates to a
# no-op, so the lingering timer does nothing until the next rebuild.
at_get() { nomarchy-state-sync get "settings.autoTheme.$1" 2>/dev/null; } at_get() { nomarchy-state-sync get "settings.autoTheme.$1" 2>/dev/null; }
en=$(at_get enable); day=$(at_get day); night=$(at_get night) en=$(at_get enable); day=$(at_get day); night=$(at_get night)
sunrise=$(at_get sunrise); sunset=$(at_get sunset) sunrise=$(at_get sunrise); sunset=$(at_get sunset)
@@ -997,15 +1000,20 @@ ${lib.concatStringsSep "\n" (lib.mapAttrsToList
nomarchy-state-sync --quiet set settings.autoTheme.enable true --no-switch nomarchy-state-sync --quiet set settings.autoTheme.enable true --no-switch
notify-send "Auto theme" "On applying the theme for now" notify-send "Auto theme" "On applying the theme for now"
exec nomarchy-state-sync auto --force ;; # one rebuild: install timer + apply exec nomarchy-state-sync auto --force ;; # one rebuild: install timer + apply
"Day theme"*) "Day theme"*|"Night theme"*)
slug=$( { nomarchy-state-sync list; printf '%s\n' "$BACK"; } | rofi_menu -p "Day theme") || exec "$0" autotheme key=day; label="Day theme"
[ "$slug" != "$BACK" ] && [ -n "$slug" ] \ case "$choice" in "Night theme"*) key=night; label="Night theme" ;; esac
&& nomarchy-state-sync --quiet set settings.autoTheme.day "$slug" --no-switch slug=$( { nomarchy-state-sync list; printf '%s\n' "$BACK"; } | rofi_menu -p "$label") || exec "$0" autotheme
exec "$0" autotheme ;; if [ "$slug" != "$BACK" ] && [ -n "$slug" ]; then
"Night theme"*) nomarchy-state-sync --quiet set "settings.autoTheme.$key" "$slug" --no-switch
slug=$( { nomarchy-state-sync list; printf '%s\n' "$BACK"; } | rofi_menu -p "Night theme") || exec "$0" autotheme # Edited the slot that is on screen right now? Apply it the
[ "$slug" != "$BACK" ] && [ -n "$slug" ] \ # timer won't fire again until the next sunrise/sunset.
&& nomarchy-state-sync --quiet set settings.autoTheme.night "$slug" --no-switch if [ "$en" = true ] && [ "$(nomarchy-state-sync auto --which)" = "$slug" ] \
&& [ "$(nomarchy-state-sync get slug)" != "$slug" ]; then
notify-send "Auto theme" "$label: $slug applying"
exec nomarchy-state-sync auto
fi
fi
exec "$0" autotheme ;; exec "$0" autotheme ;;
"Sunrise"*|"Sunset"*) "Sunrise"*|"Sunset"*)
key=sunrise; label=Sunrise; cur="''${sunrise:-07:00}" key=sunrise; label=Sunrise; cur="''${sunrise:-07:00}"
@@ -1014,7 +1022,14 @@ ${lib.concatStringsSep "\n" (lib.mapAttrsToList
case "$t" in case "$t" in
""|"$BACK") : ;; ""|"$BACK") : ;;
[01][0-9]:[0-5][0-9]|2[0-3]:[0-5][0-9]) [01][0-9]:[0-5][0-9]|2[0-3]:[0-5][0-9])
nomarchy-state-sync --quiet set "settings.autoTheme.$key" "$t" --no-switch ;; nomarchy-state-sync --quiet set "settings.autoTheme.$key" "$t" --no-switch
# The time is baked into the timer's OnCalendar, so a change
# must rebuild; `auto --force` is that rebuild AND lands on
# the right theme for the new schedule in the same pass.
if [ "$en" = true ]; then
notify-send "Auto theme" "$label $t rebuilding the schedule"
exec nomarchy-state-sync auto --force
fi ;;
*) notify-send "Auto theme" "Ignored '$t' use HH:MM (24-hour)." ;; *) notify-send "Auto theme" "Ignored '$t' use HH:MM (24-hour)." ;;
esac esac
exec "$0" autotheme ;; exec "$0" autotheme ;;

View File

@@ -128,8 +128,18 @@ in
color: #${base16.base00}; color: #${base16.base00};
} }
/* Dialog / message-box action buttons (#98). */ /* Dialog / message-box action buttons (#98) dialog-scoped
button, button label, 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,
.dialog-action-area button label, .dialog-action-area button label,
messagedialog button, messagedialog button,
@@ -138,7 +148,9 @@ in
.message-dialog button label { .message-dialog button label {
color: #${base16.base05}; 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); color: alpha(#${base16.base05}, 0.5);
} }
/* Accent buttons: the palette's base as the label, on a chip /* 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=$(${pkgs.systemd}/bin/timedatectl show -p Timezone --value 2>/dev/null || true)
[ "$cur" = "$last" ] && continue [ "$cur" = "$last" ] && continue
last=$cur 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 done
''; '';
in in

View File

@@ -71,7 +71,8 @@ let
echo 0 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 case "''${1:-status}" in
check) check)

View File

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

View File

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

View File

@@ -189,8 +189,17 @@ def auto_commit(message: str) -> None:
if not (FLAKE_DIR / ".git").exists() or shutil.which("git") is None: if not (FLAKE_DIR / ".git").exists() or shutil.which("git") is None:
return return
git = ["git", "-C", str(FLAKE_DIR)] git = ["git", "-C", str(FLAKE_DIR)]
# Pathspec: preferred name plus any leftover legacy file still staged. # Pathspec: preferred name plus any legacy file git still knows — in the
paths = [STATE_NAME, *LEGACY_STATE_NAMES] # index or in HEAD (a migration's staged `git rm` needs the pathspec to
# ride along). Names known to neither MUST be dropped: unlike `git diff`,
# `git commit` aborts on a pathspec that matches nothing, which silently
# killed every auto-commit once the #107 rename removed the legacy files.
def known(name: str) -> bool:
return subprocess.run(git + ["ls-files", "--error-unmatch", "--", name],
capture_output=True).returncode == 0 \
or subprocess.run(git + ["cat-file", "-e", f"HEAD:{name}"],
capture_output=True).returncode == 0
paths = [STATE_NAME] + [n for n in LEGACY_STATE_NAMES if known(n)]
# No-op when the file already matches HEAD (a `set` to the same value). # No-op when the file already matches HEAD (a `set` to the same value).
# On a repo with no commits yet this diff errors — then just commit. # On a repo with no commits yet this diff errors — then just commit.
if subprocess.run(git + ["diff", "--quiet", "HEAD", "--"] + paths, if subprocess.run(git + ["diff", "--quiet", "HEAD", "--"] + paths,
@@ -438,14 +447,23 @@ def run_switch() -> None:
# reload_style_on_change while the symlinks flip), so prefer the # reload_style_on_change while the symlinks flip), so prefer the
# restart whenever the supervisor is there to catch it; fall back to # restart whenever the supervisor is there to catch it; fall back to
# SIGUSR2 for unsupervised/custom bars. No-ops if nothing is running. # 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"): if shutil.which("pkill"):
supervised = subprocess.run( supervised = subprocess.run(
["pgrep", "-f", "nomarchy-waybar"], capture_output=True ["pgrep", "-f", "nomarchy-waybar"], capture_output=True
).returncode == 0 ).returncode == 0
if supervised: if supervised:
subprocess.run(["pkill", "-x", "waybar"], check=False) subprocess.run(["pkill", "-x", r"waybar|\.waybar-wrapped"], check=False)
else: 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) ──────────────────────────────────────────── # ─── Theme assets (wallpapers) ────────────────────────────────────────────

View File

@@ -140,8 +140,15 @@ pkgs.testers.runNixOSTest {
# re-dock action. # re-dock action.
machine.succeed("printf 'state: closed\\n' > /run/nomarchy-test-lid-state") machine.succeed("printf 'state: closed\\n' > /run/nomarchy-test-lid-state")
machine.succeed(hy + "reload'") 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( 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'") 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)") 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") 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 # Poke custom/doctor (signal 10) so a first-poll miss at session start
# doesn't leave the tripwire blank for the full 300s interval. # 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) machine.sleep(3)
# Park the cursor in the bottom-right corner so it doesn't sit in the # 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 # middle of the desktop shot (that frame doubles as the theme-picker