Compare commits

..

2 Commits

Author SHA1 Message Date
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
8 changed files with 178 additions and 49 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.
- [ ] **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

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` |
| `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`

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

View File

@@ -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
@@ -2204,10 +2246,14 @@
# `home-manager switch` is stubbed via NOMARCHY_REBUILD (that
# generic apply path is exercised by every manual theme change);
# 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 {
name = "nomarchy-auto-theme";
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
};
testScript = ''
@@ -2220,6 +2266,14 @@
machine.succeed(
"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(
"printf '#!/bin/sh\\necho switch >> /root/rebuilds\\n' > /root/rebuild"
" && chmod +x /root/rebuild"
@@ -2229,14 +2283,24 @@
"NOMARCHY_DEFAULT_THEMES=${pkgs.nomarchy-default-themes} "
"NOMARCHY_REBUILD=/root/rebuild ")
# Pair + schedule (setup writes only, no rebuild).
for kv in ["settings.autoTheme.enable true",
# Pair + schedule (setup writes only, no rebuild). autoCommit
# 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.night summer-night",
"settings.autoTheme.sunrise 07:00",
"settings.autoTheme.sunset 20:00"]:
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():
return machine.succeed(env + "nomarchy-state-sync get slug").strip()
def rebuilds():
@@ -2253,6 +2317,11 @@
machine.succeed(env + "nomarchy-state-sync auto")
assert slug() == "summer-night", f"post-sunset should be night, got {slug()}"
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.
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
# 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" ];

View File

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

View File

@@ -16,7 +16,7 @@
stdenvNoCC.mkDerivation {
pname = "nomarchy-state-sync";
version = "0.5.0";
version = "0.5.1";
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:
return
git = ["git", "-C", str(FLAKE_DIR)]
# Pathspec: preferred name plus any leftover legacy file still staged.
paths = [STATE_NAME, *LEGACY_STATE_NAMES]
# Pathspec: preferred name plus any legacy file git still knows — in the
# 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).
# On a repo with no commits yet this diff errors — then just commit.
if subprocess.run(git + ["diff", "--quiet", "HEAD", "--"] + paths,