diff --git a/agent/HARDWARE-QUEUE.md b/agent/HARDWARE-QUEUE.md index ba675bf..f8ad2b1 100644 --- a/agent/HARDWARE-QUEUE.md +++ b/agent/HARDWARE-QUEUE.md @@ -52,11 +52,18 @@ Everything else below stays open; order is convenience, not a gate. summer-day; this is the on-hardware confirmation of the report. - [ ] **Auto-theme pair flip on hardware** — set `settings.autoTheme.{day,night}` to one of the new pairs (e.g. - kiln-clay / kiln), force sunrise/sunset around the current time, - let the timer flip once each way. Pass = 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. + kiln-clay / kiln), set sunset (or sunrise) a few minutes ahead via + Look & Feel › Auto theme (the time edit itself rebuilds — since the + exact-time revision the timer fires AT the configured minute, no + 15-min poll), let the timer flip once each way. Pass = (1) + `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)** — after pulling the #148 commit: **log out and back in first** — the watcher is `exec-once`, so `nomarchy-home` alone leaves the old one diff --git a/docs/OVERRIDES.md b/docs/OVERRIDES.md index 4b7aa44..ada6851 100644 --- a/docs/OVERRIDES.md +++ b/docs/OVERRIDES.md @@ -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` | | `sunrise` / `sunset` | switch times, `"HH:MM"` (24-hour) | -A timer re-checks every ~15 minutes: the day theme applies around sunrise, the -night theme around sunset — but it only rebuilds when the active theme -actually needs to change, so most checks do nothing. Enabling rebuilds once -(to install the timer); disabling is instant. Preview the current decision -without switching with `nomarchy-state-sync auto --which`. +A timer fires exactly at the configured sunrise and sunset (plus once shortly +after login, and it catches up on a transition missed while the machine was +off or asleep) — it only rebuilds when the active theme actually needs to +change. The times are baked into the timer, so editing them (or enabling) +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 stay owned by the theme system. If you *insist* on pinning one in `home.nix` diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index 2af917f..4339ee6 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -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 clock and applies the preset **only if it differs** (idempotent). `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 pair/times. `cmd_apply`'s core was factored into `apply_named` so `auto` - reuses it verbatim. Periodic (not exact sunrise/sunset timers) for - robustness to suspend/DST; the idempotent `auto` keeps ticks cheap. Enable + reuses it verbatim. Originally a 15-min poll (`OnCalendar=*:0/15`) for + 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 (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 diff --git a/flake.nix b/flake.nix index 7936d57..48cbdfc 100644 --- a/flake.nix +++ b/flake.nix @@ -342,6 +342,48 @@ pkgs.runCommand "nomarchy-state-bridges" { } "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 # proves the option flips at eval, this proves a state key alone # reaches a RUNNING service. The node sets no diff --git a/modules/home/autotheme.nix b/modules/home/autotheme.nix index 92d0676..fa107c1 100644 --- a/modules/home/autotheme.nix +++ b/modules/home/autotheme.nix @@ -1,18 +1,37 @@ # Auto time-of-day theme switch (BACKLOG #79, VISION § D). A user timer -# periodically runs `nomarchy-state-sync auto`, which reads -# settings.autoTheme = { enable, day, night, sunrise, sunset } from the -# state file and applies the day or night preset for the current clock — -# through the SAME one engine as a manual `apply` (no second pipeline). +# runs `nomarchy-state-sync auto` at the configured sunrise and sunset, +# which reads settings.autoTheme = { enable, day, night, sunrise, sunset } +# from the state file and applies the day or night preset for the current +# 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 -# rebuilds when the active theme actually needs to change), so the timer -# can tick freely. 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. +# The trigger times are BAKED into OnCalendar at rebuild (originally this +# was a 15-min poll; exact times won on wasted wakeups — Bernardo +# 2026-07-18), so a time edit must rebuild: the menu's Sunrise/Sunset +# writes run `auto --force`, whose apply is that rebuild. Missed +# transitions are covered without polling: Persistent=true catches ones +# 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, ... }: -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 = { Unit.Description = "Apply the day/night theme for the current time"; Service = { @@ -25,10 +44,13 @@ lib.mkIf (config.nomarchy.settings.autoTheme.enable or false) { }; systemd.user.timers.nomarchy-auto-theme = { - Unit.Description = "Periodic day/night theme check"; + Unit.Description = "Day/night theme switch at sunrise and sunset"; Timer = { 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 }; Install.WantedBy = [ "timers.target" ]; diff --git a/modules/home/rofi.nix b/modules/home/rofi.nix index 5b75894..737196c 100644 --- a/modules/home/rofi.nix +++ b/modules/home/rofi.nix @@ -962,13 +962,16 @@ ${lib.concatStringsSep "\n" (lib.mapAttrsToList autotheme) # Look & Feel › Auto theme — automatic day/night theme switch # (settings.autoTheme, applied by the nomarchy-auto-theme timer from - # autotheme.nix). The enable flag GATES the timer's install, so - # turning it *on* must rebuild; day/night/times are read live by - # `nomarchy-state-sync auto`, so those writes are instant - # (--no-switch). Enabling writes the flag then `auto --force`, so a - # single rebuild both installs the timer and applies the right theme - # now. Disabling is instant: the flag flips and `auto` self-gates to - # a no-op, so the lingering timer does nothing until the next rebuild. + # autotheme.nix). The enable flag GATES the timer's install and the + # sunrise/sunset times are BAKED into its OnCalendar, so turning it + # *on* or editing a time must rebuild — both run `auto --force`, so + # one rebuild re-bakes the timer AND lands on the right theme now. + # Day/night slugs are read live by `nomarchy-state-sync auto`, so + # those writes are instant (--no-switch); when the edited slot is the + # 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; } en=$(at_get enable); day=$(at_get day); night=$(at_get night) 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 notify-send "Auto theme" "On — applying the theme for now…" exec nomarchy-state-sync auto --force ;; # one rebuild: install timer + apply - "Day theme"*) - slug=$( { nomarchy-state-sync list; printf '%s\n' "$BACK"; } | rofi_menu -p "Day theme") || exec "$0" autotheme - [ "$slug" != "$BACK" ] && [ -n "$slug" ] \ - && nomarchy-state-sync --quiet set settings.autoTheme.day "$slug" --no-switch - exec "$0" autotheme ;; - "Night theme"*) - slug=$( { nomarchy-state-sync list; printf '%s\n' "$BACK"; } | rofi_menu -p "Night theme") || exec "$0" autotheme - [ "$slug" != "$BACK" ] && [ -n "$slug" ] \ - && nomarchy-state-sync --quiet set settings.autoTheme.night "$slug" --no-switch + "Day theme"*|"Night theme"*) + key=day; label="Day theme" + case "$choice" in "Night theme"*) key=night; label="Night theme" ;; esac + slug=$( { nomarchy-state-sync list; printf '%s\n' "$BACK"; } | rofi_menu -p "$label") || exec "$0" autotheme + if [ "$slug" != "$BACK" ] && [ -n "$slug" ]; then + nomarchy-state-sync --quiet set "settings.autoTheme.$key" "$slug" --no-switch + # Edited the slot that is on screen right now? Apply it — the + # timer won't fire again until the next sunrise/sunset. + 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 ;; "Sunrise"*|"Sunset"*) key=sunrise; label=Sunrise; cur="''${sunrise:-07:00}" @@ -1014,7 +1022,14 @@ ${lib.concatStringsSep "\n" (lib.mapAttrsToList case "$t" in ""|"$BACK") : ;; [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)." ;; esac exec "$0" autotheme ;;