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>
This commit is contained in:
2026-07-18 22:31:02 +01:00
parent ac24ac7c34
commit f658b0087e
6 changed files with 136 additions and 43 deletions

View File

@@ -52,11 +52,18 @@ Everything else below stays open; order is convenience, not a gate.
summer-day; this is the on-hardware confirmation of the report. summer-day; this is the on-hardware confirmation of the report.
- [ ] **Auto-theme pair flip on hardware** — set - [ ] **Auto-theme pair flip on hardware** — set
`settings.autoTheme.{day,night}` to one of the new pairs (e.g. `settings.autoTheme.{day,night}` to one of the new pairs (e.g.
kiln-clay / kiln), force sunrise/sunset around the current time, kiln-clay / kiln), set sunset (or sunrise) a few minutes ahead via
let the timer flip once each way. Pass = the whole desktop switches Look & Feel Auto theme (the time edit itself rebuilds — since the
in one generation, and nothing leaks from the previous theme — exact-time revision the timer fires AT the configured minute, no
fonts, rounding, terminal opacity all reset (the per-theme 15-min poll), let the timer flip once each way. Pass = (1)
appearance block), wallpaper follows. `systemctl --user list-timers nomarchy-auto-theme` shows the two
configured times as the trigger; (2) the switch lands within ~a
minute of the configured time; (3) the whole desktop switches in
one generation, and nothing leaks from the previous theme — fonts,
rounding, terminal opacity all reset (the per-theme appearance
block), wallpaper follows; (4) with autoCommit on, `git -C
~/.nomarchy log` shows an `apply theme` commit and state.json is
not left dirty (the 0.5.1 pathspec fix).
- [ ] **#148 dock-intent enforcement without auto-profiles (dev box, docked)** — - [ ] **#148 dock-intent enforcement without auto-profiles (dev box, docked)** —
after pulling the #148 commit: **log out and back in first** — the after pulling the #148 commit: **log out and back in first** — the
watcher is `exec-once`, so `nomarchy-home` alone leaves the old one watcher is `exec-once`, so `nomarchy-home` alone leaves the old one

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

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

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

@@ -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 ;;