Compare commits

...

17 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
8f85097140 chore(lock): bump nomarchy-wallpapers a716cf6 → d4f5fbf
All checks were successful
Check / eval (push) Successful in 4m3s
The wallpapers repo's 'updates' commit gave each of the three v1 pair
themes a second background (1-/2-<slug>.png, replacing the single
ember-kiln/clay-kiln era files), but this lock still pinned the
one-wallpaper rev — downstream machines re-lock the transitive
nomarchy-wallpapers input from *this* lock, so SUPER+SHIFT+T
('bg next') cycled a one-item list and visibly did nothing
(hardware report, kiln-clay, 2026-07-18).

Targeted bump of the wallpapers input only — runtime-only content,
never read at eval (see flake.nix). Verified: pkgs.nomarchy-default-themes
builds, which runs the per-slug drift check (every theme json still has a
non-empty backgrounds/), and kiln + kiln-clay now list two files each.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-18 21:24:13 +01:00
036931ac51 fix(themes): light mode — the window glow tightens to a few px
All checks were successful
Check / eval (push) Successful in 4m15s
The shadow color is the palette mantle: near-black on dark themes (a
normal ambient shadow) but a light warm tone on light ones, where the
20px range reads as a wide glow that bleeds into neighbouring windows
and the bar (hardware report, kiln-clay, 2026-07-18). Light mode now
defaults the range to 5px — the glow stays as an identity feature,
just tight; dark themes keep the soft 20px shadow. Still lib.mkDefault,
so downstream overrides keep working.

Verified V1 (generation renders range=5 under kiln-clay); on-hardware
before/after of the live desktop taken in the reporting session.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-18 21:12:11 +01:00
8fdd9dc423 fix(session): recover user services after a broken logout teardown
2026-07-18 dev-box incident: on a warm relogin (user manager kept alive
by the tty1 greetd session — no linger involved) the previous logout's
graphical-session.target stop had been rejected as a destructive
transaction (easyeffects held a queued start job), so the target stayed
active across the logout. Its WantedBy services (cliphist, swaync,
xdg-desktop-portal-*, swayosd, …) crash-looped against the dead Wayland
socket into start-limit-hit, and the next login's plain
'start hyprland-session.target' was a no-op — every session service
stayed failed, doctor red, until a reboot.

Session bring-up (HM's systemd.extraCommands, rendered into the first
exec-once) now stops both hyprland-session.target and a possibly-stale
graphical-session.target, clears failed/start-limit state with
reset-failed, then starts fresh. All three commands are no-ops after a
clean logout or cold boot.

Verified V1: home generation built against this tree renders the new
command chain into hyprland.conf line 1 (nix build of
homeConfigurations.bernardo with the nomarchy input overridden).
V3 pending: warm relogin on hardware — queued in HARDWARE-QUEUE with
exact steps; BACKLOG #149 updated with the incident evidence (the
exec-once posture it watches is defending against a real race).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-18 21:11:39 +01:00
8e8a142465 fix(themes): light themes — readable inactive workspaces + notification hover
All checks were successful
Check / eval (push) Successful in 4m9s
Two hardware-reported contrast bugs, both the same family: surfaces
that fell back to something designed on dark.

1. Inactive workspace numbers unreadable on every light theme.
   - Generated bar: the @muted dim role is floor-gated at only 2.0:1 —
     fine as "dim" on dark, washed out at number size on light. Light
     mode now promotes inactive buttons to @subtext (3.0 floor,
     typically 4-7:1); dark themes keep @muted.
   - boreal-dawn + kiln-clay whole-swaps: same @muted -> @subtext
     (6.4:1 / 6.6:1).
   - summer-day whole-swap: had NO button color rule at all — the
     container's @fg does not inherit into GTK buttons, so the numbers
     took the GTK default over the cream chip. Explicit @fg_dim
     (= the palette's subtext, 4.0:1) added.
   - executive-ivory already used @subtext; untouched.

2. Notification popup unreadable on hover (bg turns dark, text stays
   dark). Root cause found by probing, not guessing: the popup body is
   one big GTK button (.notification-default-action) and swaync's
   DEFAULT stylesheet — still loaded under our style.css — hovers it to
   rgb(56,56,56). Our sheet never overrode that node (only the
   control-center row), so the default bled through; on light themes
   dark @text sat on it. Now the body + action buttons + close button
   are re-pinned to the palette's alpha(@text) tint construction —
   mode-proof by construction. The first fix attempt (row-level hover)
   was probed, SHOWN insufficient, and replaced.

Verification: V2. nix flake check --no-build, theme-wholeswap,
theme-contrast green. §3 evidence viewed: a purpose-built VM hover
probe under summer-day (notify-send -u critical, cursor parked ON the
popup, screenshot) — before: dark card, drowned text (reproduces the
report exactly, /nix/store/q1qf67fl...notify-shot-summer-day); after:
cream card + faint tint, fully readable (shot-notify2). Bar renders
verified under catppuccin-latte (generated light path incl. the mode
branch) and summer-day (edited whole-swap). An inactive-number visual
needs two live workspaces — VM shows one — so the on-hardware
confirmation is queued (HARDWARE-QUEUE light-theme refits entry);
the ratio change itself is machine-checked.

Bookkeeping riding along: BACKLOG #152 deleted (shipped in d79ad35).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-18 13:27:43 +01:00
d79ad3517e fix(waybar): #152 — inset the workspace pill, breathe at the bar's ends
The generated bar (every non-whole-swap theme; whole-swaps untouched):

- The active-workspace highlighter filled the bar's full inner height
  and visually overhung the rounded border. The pill now gets a 3px
  vertical inset margin (the boreal whole-swap's proven pattern) and
  the bar grows 34 -> 36 so the inset doesn't cramp the number.
- The end modules sat hard against the bar's rounded corners (the
  power button ~8px from the right curve vs ~28px inter-icon gaps).
  The Nomarchy mark and the power button get wider outer padding
  (14/16px); the item-28c group rhythm is preserved.

Verification: V2. nix flake check --no-build green. §3 protocol:
before/after theme-shot under kanagawa (generated palette) viewed at
2x crops — before: pill overhangs top+bottom, power glyph kisses the
corner; after: pill fully inside the bar, clear end gap
(before /nix/store/iwrvzzdd...theme-shot-kanagawa, after
/nix/store/za...see scratchpad shot-kanagawa-after.path). No-regression
capture under the summer-night whole-swap viewed — its chip identity
unchanged (whole-swaps replace both style and layout, out of scope
per the item). BACKLOG #152 closed: deleted per the finished-items
rule in the next bookkeeping touch of this push.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-18 13:22:52 +01:00
28 changed files with 778 additions and 415 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

@@ -33,24 +33,6 @@ flake pin are different root causes even when they show up on the same machine.
## NEXT ## NEXT
### 152. Generated waybar polish: bar height vs workspace highlighter, right-edge icon spacing
Bernardo, 2026-07-17. Two visual fixes to the **generated** bar (the one
every theme without a `waybar.jsonc`/`waybar.css` whole-swap gets — the
4 whole-swap themes are out of scope unless the parity rule drags them in):
- **Bar height:** make the bar a bit taller so the selected-workspace
highlighter no longer renders taller than the bar itself (today it
visually overflows/clips the bar edge).
- **Right-module spacing:** the icon distances on the right cluster look
uneven — e.g. the power button sits too close to the screen edge; give
the cluster consistent inter-icon spacing and sane end padding.
This is §3 visual-protocol work (agent/THEME-DESIGN.md + VERIFICATION §3):
before/after screenshots, two themes (one generated palette + one
whole-swap for the no-regression side), scripted checks first, actually
view the captures. Waybar CSS ↔ tray-menu gotchas in agent/MEMORY.md
apply (never scope `*`).
### 151. `[human]` v1 launch plan — devise with Bernardo (includes the GitHub move) ### 151. `[human]` v1 launch plan — devise with Bernardo (includes the GitHub move)
Bernardo, 2026-07-17: before tagging v1, sit down and devise the launch plan. Bernardo, 2026-07-17: before tagging v1, sit down and devise the launch plan.
@@ -146,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
@@ -193,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:
@@ -223,6 +206,18 @@ defending against nothing. Do not rip it out on this note alone: it works, and
the claim needs a real relogin without linger to test (V3 — the same relogin the claim needs a real relogin without linger to test (V3 — the same relogin
that verifies #148's watcher fix would answer it). that verifies #148's watcher fix would answer it).
**2026-07-18 evidence: the warm relogin is real post-linger.** On the dev
box the user manager survived a Hyprland logout (the tty1 greetd session
keeps it alive — no linger involved) and the teardown broke:
`graphical-session.target`'s stop transaction was rejected as destructive
(easyeffects had a queued start job), the target stayed active,
cliphist/swaync/portals/swayosd crash-looped against the dead Wayland
socket into `start-limit-hit`, and the relogin's plain `start` was a no-op
— doctor red for the whole session. Session bring-up now stops stale
targets + `reset-failed` before starting (`hyprland.nix`
systemd.extraCommands); relogin V3 check queued in HARDWARE-QUEUE. The
exec-once posture this entry watches is therefore still earning its keep.
### 120. A netinstall ISO, next to the fat offline one ### 120. A netinstall ISO, next to the fat offline one
**Deferred to PROPOSED 2026-07-16 (Bernardo): not now.** Nothing below is **Deferred to PROPOSED 2026-07-16 (Bernardo): not now.** Nothing below is

View File

@@ -42,13 +42,28 @@ Everything else below stays open; order is convenience, not a gate.
four (the `*`-reset counter-rules). If a VM-rendered preview looks four (the `*`-reset counter-rules). If a VM-rendered preview looks
off in the theme-picker grid next to the hardware-captured ones, off in the theme-picker grid next to the hardware-captured ones,
recapture that preview on hardware into themes/<slug>/preview.png. recapture that preview on hardware into themes/<slug>/preview.png.
- [ ] **Light-theme contrast refits (2026-07-18 report)** — under any
light theme: (1) with two workspaces open, the inactive number is
readable on the bar (subtext, not the washed-out grey); (2) hover a
notification popup — background stays the theme's light card with a
faint tint, text readable (the swaync default's dark
.notification-default-action hover is now overridden); close
button matches the palette. VM-verified with a hover probe under
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
@@ -509,6 +524,31 @@ Everything else below stays open; order is convenience, not a gate.
`~/.nomarchy/flake.nix` has `?ref=main` (not lagging v1). **Pass:** `~/.nomarchy/flake.nix` has `?ref=main` (not lagging v1). **Pass:**
`nomarchy-rebuild` does not die with `nomarchy-rebuild` does not die with
`The option 'nomarchy.hardware' does not exist`. `The option 'nomarchy.hardware' does not exist`.
- [ ] **Relogin session recovery (2026-07-18 incident)** — after pulling
the session-recovery commit and rebuilding: from a full Hyprland
session (bar up, a browser open so easyeffects/tray are busy), log
out (SUPER+SHIFT+E) and log straight back in at the greeter — do
**not** reboot between. **Pass:** `nomarchy-doctor` shows *no failed
user units*; `systemctl --user status cliphist swaync
xdg-desktop-portal-hyprland swayosd` are all `active (running)`; the
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 ## 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
@@ -591,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": 1784374559, "lastModified": 1784478251,
"narHash": "sha256-c05z+PUFbX3gZ7CQfFHtVCWI5WlNCAcFQBC/bBv7G+Y=", "narHash": "sha256-sEBx2ce6g0398FlclNwfv5FuwZ1WzLYNHK6jX/2kV88=",
"ref": "main", "ref": "main",
"rev": "a716cf60a05f187ad1d8baa043dda4e6085c2463", "rev": "5c361e17b524b6ef27fb7ba948269d0e0c6e954e",
"revCount": 3, "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
@@ -970,6 +724,24 @@ in
# (Hyprland then boots into emergency mode with no binds). # (Hyprland then boots into emergency mode with no binds).
configType = "hyprlang"; configType = "hyprlang";
# Session bring-up (HM renders these into the first exec-once, after
# its dbus-update-activation-environment). The default is just
# stop/start of hyprland-session.target — not enough after a broken
# logout: if the graphical-session.target *stop* transaction was
# rejected mid-teardown (seen 2026-07-18: "transaction is destructive"
# while easyeffects had a queued start job), the target stays active
# across the logout, its services crash-loop against the dead Wayland
# socket into start-limit-hit, and the next login's plain `start` is a
# no-op — cliphist/swaync/portals stay failed for the whole session
# (doctor all red). So: tear the stale targets down, clear
# failed/start-limit state, then start fresh. All three are no-ops
# after a clean logout or cold boot.
systemd.extraCommands = [
"systemctl --user stop hyprland-session.target graphical-session.target"
"systemctl --user reset-failed"
"systemctl --user start hyprland-session.target"
];
settings = { settings = {
"$mod" = "SUPER"; "$mod" = "SUPER";
"$terminal" = config.nomarchy.terminal; "$terminal" = config.nomarchy.terminal;
@@ -1042,7 +814,12 @@ in
}; };
shadow = { shadow = {
enabled = t.ui.shadow; enabled = t.ui.shadow;
range = lib.mkDefault 20; # The mantle-tinted shadow reads as a dark halo on dark themes
# but as a warm *glow* on light ones (mantle is light there) —
# kept as an identity feature, just tight (a few px) so it never
# bleeds into neighbouring windows or the bar (hardware report
# 2026-07-18); dark themes keep the soft 20px ambient shadow.
range = lib.mkDefault (if t.mode == "light" then 5 else 20);
render_power = lib.mkDefault 3; render_power = lib.mkDefault 3;
color = rgba c.mantle "aa"; color = rgba c.mantle "aa";
}; };

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

@@ -71,12 +71,47 @@ in
font-size: ${toString t.fonts.size}pt; font-size: ${toString t.fonts.size}pt;
} }
.control-center .notification-row:focus, /* Unscoped on purpose: the floating popup wraps .notification in
.control-center .notification-row:hover { a .notification-row too, and swaync's DEFAULT stylesheet (still
loaded underneath this one) paints that row dark on hover on
light themes the theme's dark @text then sat on the default's
dark hover, unreadable. The same alpha(@text) tint as
everywhere else overrides it in both surfaces. */
.notification-row:focus,
.notification-row:hover {
background: alpha(@text, 0.1); background: alpha(@text, 0.1);
border-radius: ${r}px; border-radius: ${r}px;
} }
/* The REAL popup-hover culprit: the notification body is one big
GTK button (.notification-default-action), and the default
sheet hovers IT to rgb(56,56,56) on light themes that put
dark @text on a dark chip (unreadable, hardware report
2026-07-18). Re-pin body + action buttons to the palette's
tint construction; hover stays a tint of @text, so it works
in both modes by construction. */
.notification-default-action,
.notification-action {
background: transparent;
color: @text;
}
.notification-default-action:hover,
.notification-action:hover {
background: alpha(@text, 0.08);
}
/* Same default-stylesheet leak: swaync ships a dark close-button
chip; re-pin both ends to the palette's tint construction. */
.close-button {
background: alpha(@text, 0.1);
color: @text;
border-radius: ${r}px;
}
.close-button:hover {
background: alpha(@text, 0.2);
color: @text;
}
.widget-title { .widget-title {
color: @text; color: @text;
font-weight: bold; font-weight: bold;

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).
@@ -162,7 +163,10 @@ let
# normal tiling. Keep in sync with the whole-swap jsoncs (parity rule). # normal tiling. Keep in sync with the whole-swap jsoncs (parity rule).
layer = "bottom"; layer = "bottom";
position = "top"; position = "top";
height = 34; # 36, not 34: the active-workspace pill is inset 3px top+bottom (CSS
# below) so it can never overhang the bar's border — the extra height
# keeps the pill's text from cramping inside the inset (#152).
height = 36;
margin-top = t.ui.gapsOut; margin-top = t.ui.gapsOut;
margin-left = t.ui.gapsOut; margin-left = t.ui.gapsOut;
margin-right = t.ui.gapsOut; margin-right = t.ui.gapsOut;
@@ -423,14 +427,20 @@ let
border-radius: ${toString t.ui.rounding}px; border-radius: ${toString t.ui.rounding}px;
} }
/* Dim states use the palette's @muted role: since item 28b it is /* Dim states use the palette's @muted role on DARK themes: since
floor-guaranteed legible on @base in every theme (muted/base >= item 28b it is floor-guaranteed legible on @base (muted/base >=
2.0, gated by tools/check-theme-contrast.py) the palettes that 2.0, gated by tools/check-theme-contrast.py). On LIGHT themes that
once made it vanish (gruvbox mutedbase, item 27) were retuned. 2.0-floor grey washes out at number size inactive workspace
numbers were unreadable on every light palette so light mode
promotes them to @subtext (>= 3.0 floor, typically 4-7:1).
Secondary-but-not-dim stays alpha(@text, 0.85). */ Secondary-but-not-dim stays alpha(@text, 0.85). */
/* 3px vertical margin insets the active pill INSIDE the bar without
it the filled button spans the bar's full inner height and visually
overhangs the rounded border (#152). */
#workspaces button { #workspaces button {
padding: 0 8px; padding: 0 8px;
color: @muted; margin: 3px 0;
color: ${if t.mode == "light" then "@subtext" else "@muted"};
border-radius: ${toString t.ui.rounding}px; border-radius: ${toString t.ui.rounding}px;
} }
@@ -458,7 +468,10 @@ let
color: @accent; color: @accent;
font-family: Nomarchy; font-family: Nomarchy;
font-size: ${toString (t.fonts.size + 4)}pt; font-size: ${toString (t.fonts.size + 4)}pt;
padding: 0 10px; /* Wider outer padding: the bar's rounded corner curves into the
end module's box, so the edge glyphs need more room than the
uniform 10px or they look cramped (#152; power button mirrors). */
padding: 0 10px 0 14px;
} }
#custom-nomarchy:hover { color: @accentAlt; } #custom-nomarchy:hover { color: @accentAlt; }
@@ -466,6 +479,7 @@ let
color: alpha(@text, 0.85); color: alpha(@text, 0.85);
padding: 0 10px; padding: 0 10px;
} }
#custom-powermenu { padding: 0 16px 0 10px; }
#custom-powermenu:hover { color: @bad; } #custom-powermenu:hover { color: @bad; }
/* Group rhythm (item 28c): a wider breath before each functional /* Group rhythm (item 28c): a wider breath before each functional

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

@@ -120,7 +120,9 @@ window#waybar {
#workspaces { padding: 0 2px; } #workspaces { padding: 0 2px; }
#workspaces button { #workspaces button {
color: @muted; /* @subtext, not @muted: on a light pill the 2.0-floor grey washes out
at number size — inactive workspaces must stay readable (6.4:1). */
color: @subtext;
padding: 0 9px; padding: 0 9px;
margin: 3px 1px; margin: 3px 1px;
border-radius: 9px; border-radius: 9px;

View File

@@ -117,7 +117,9 @@ window#waybar {
#workspaces { padding: 0 2px; } #workspaces { padding: 0 2px; }
#workspaces button { #workspaces button {
color: @muted; /* @subtext, not @muted: on the cream plank the 2.0-floor grey washes
out at number size — inactive workspaces must stay readable (6.6:1). */
color: @subtext;
padding: 0 9px; padding: 0 9px;
margin: 3px 1px; margin: 3px 1px;
border-radius: 6px; border-radius: 6px;

View File

@@ -18,6 +18,7 @@
@define-color grey0 #a6b0a0; @define-color grey0 #a6b0a0;
@define-color grey1 #939f91; /* overlay */ @define-color grey1 #939f91; /* overlay */
@define-color grey2 #829181; /* muted */ @define-color grey2 #829181; /* muted */
@define-color fg_dim #6e828a; /* subtext */
/* margin: top right bottom left */ /* margin: top right bottom left */
/* Spacing outside the element */ /* Spacing outside the element */
@@ -79,6 +80,14 @@ window#waybar {
padding: 0px; padding: 0px;
} }
/* Inactive workspace numbers: without an explicit rule the GTK default
button color leaks in (the container's @fg does NOT inherit into
buttons) and the numbers vanish on the cream chip. @fg_dim = the
palette's subtext (4.0:1) — dim but readable. */
#workspaces button {
color: @fg_dim;
}
/* The power-profile speedometer glyph renders small in its em box. */ /* The power-profile speedometer glyph renders small in its em box. */
#custom-powerprofile { #custom-powerprofile {
font-size: 20px; font-size: 20px;

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