diff --git a/agent/BACKLOG.md b/agent/BACKLOG.md index 3a76e5f..c3cfb60 100644 --- a/agent/BACKLOG.md +++ b/agent/BACKLOG.md @@ -28,6 +28,23 @@ in [`docs/ROADMAP.md`](../docs/ROADMAP.md); map in ## NEXT +### 79. Auto time-of-day theme pair (`VISION § D`) `[big]` — split +Auto light/dark theme switch through the **existing** engine (a timer runs +`nomarchy-theme-sync apply`), one pipeline. State: `settings.autoTheme` +`{enable, day, night, sunrise, sunset}` in theme-state.json. Slices: +1. ~~**CLI primitive + schema**~~ ✓ shipped 2026-07-10 — + `nomarchy-theme-sync auto` resolves day/night from + `settings.autoTheme.{enable,day,night,sunrise,sunset}` and applies (only + if different — idempotent, so the timer won't needlessly rebuild); + `--which` prints the decision, `--force`/`--no-switch` too. `cmd_apply` + core factored into `apply_named` (behavior unchanged). V1 + functional + tests (day/night/disabled/missing-slugs, apply writes state, idempotent). +2. **Timer/hook** — systemd user timers at sunrise+sunset running + `nomarchy-theme-sync auto`, gated on `settings.autoTheme.enable`; apply + the correct theme at session start too. V2 (runNixOSTest). +3. **Menu** — Look & Feel › Auto theme: enable/disable, pick day/night + themes, set times. Writes state. + ### 76. Hibernation default — all agent work shipped `[blocked:hw]` Hibernation + zram on by default (product intent, Bernardo 2026-07-10). **All V0/V1/V2 slices done** (2026-07-10); only the V3 check remains — on a diff --git a/agent/JOURNAL.md b/agent/JOURNAL.md index 4478f08..f9f3b78 100644 --- a/agent/JOURNAL.md +++ b/agent/JOURNAL.md @@ -17,6 +17,25 @@ Template: --- +## 2026-07-10 — #79 slice 1: auto time-of-day theme CLI primitive +- **Task:** Split BACKLOG #79 (`VISION § D`, `[big]`) into 3 slices (CLI / + timer / menu); took slice 1. +- **Did:** `nomarchy-theme-sync auto` — reads + `settings.autoTheme.{enable,day,night,sunrise,sunset}`, computes day/night + by the local clock, and applies the matching preset **only if different** + (idempotent → the slice-2 timer won't rebuild needlessly). `--which` + prints the decision without switching; `--force`/`--no-switch` too. + Factored `cmd_apply`'s core into `apply_named` so `auto` reuses the exact + same one engine (GOALS: no second pipeline). +- **Verified:** **V1** package builds; functional tests on the built bin + against scratch state: wide-day→day slug, 00:00–00:01 window→night, + disabled→no-op, missing day/night→die, `auto --no-switch` writes state, + re-run→"already on" no-op, plain `apply` still writes (refactor intact). + **V0** py_compile + flake check + `checks.theme-sync-validate` green. +- **Pending:** slices 2 (systemd sunrise/sunset timers + login apply, V2) + and 3 (Look & Feel menu) remain under #79. +- **Next suggestion:** #79 slice 2 — the timer/hook (V2 runNixOSTest). + ## 2026-07-10 — #78 Omarchy migrant doc (docs/OMARCHY.md) - **Task:** BACKLOG #78 (promoted from PROPOSED) — Omarchy→Nomarchy one-pager (`VISION § F`). diff --git a/pkgs/nomarchy-theme-sync/nomarchy-theme-sync.py b/pkgs/nomarchy-theme-sync/nomarchy-theme-sync.py index 15e1723..9c6b490 100644 --- a/pkgs/nomarchy-theme-sync/nomarchy-theme-sync.py +++ b/pkgs/nomarchy-theme-sync/nomarchy-theme-sync.py @@ -18,6 +18,7 @@ Commands: apply merge a preset into the state + rebuild set tweak one key (e.g. `set ui.gapsOut 16`) + rebuild get [dotted.path] print the current state (or one key) + auto [--which] apply the day/night theme for the current time validate check the state file against the schema (read-only) wallpaper apply the current wallpaper via swww bg [next|auto] cycle the theme's wallpapers (instant, no rebuild) @@ -466,11 +467,13 @@ def cmd_list(_args) -> None: print("\n".join(names)) -def cmd_apply(args) -> None: - candidate = Path(args.theme).expanduser() - preset_path = candidate if candidate.is_file() else find_preset(args.theme) +def apply_named(theme: str, no_switch: bool = False) -> None: + """Merge a preset (name or JSON file) into the state and rebuild. Shared + by `apply` and `auto` so the day/night switch is the *same* one engine.""" + candidate = Path(theme).expanduser() + preset_path = candidate if candidate.is_file() else find_preset(theme) if preset_path is None: - die(f"unknown theme '{args.theme}' (try `nomarchy-theme-sync list`)") + die(f"unknown theme '{theme}' (try `nomarchy-theme-sync list`)") preset = json.loads(preset_path.read_text()) # Merge over current state. Presets carry a full appearance block — @@ -483,14 +486,63 @@ def cmd_apply(args) -> None: state = deep_merge(old, preset) write_state(state) if auto_commit_enabled(old) or auto_commit_enabled(state): - auto_commit(f"nomarchy: apply theme {state.get('name', args.theme)}") - log(f"theme: {state.get('name', args.theme)}") + auto_commit(f"nomarchy: apply theme {state.get('name', theme)}") + log(f"theme: {state.get('name', theme)}") check_fonts(state) - if not args.no_switch: + if not no_switch: run_switch() apply_wallpaper(state) +def cmd_apply(args) -> None: + apply_named(args.theme, no_switch=args.no_switch) + + +def _hhmm_to_min(s: str, default: str) -> int: + """'HH:MM' → minutes since midnight; falls back to `default` on garbage.""" + for candidate in (s, default): + try: + h, m = str(candidate).split(":") + return int(h) * 60 + int(m) + except (ValueError, AttributeError): + continue + return 0 + + +def cmd_auto(args) -> None: + """Apply the day or night theme for the current local time, per + settings.autoTheme = {enable, day, night, sunrise, sunset}. Slice 1 of + the auto time-of-day pair (BACKLOG #79): the timer (slice 2) and menu + (slice 3) drive this; it can also be run by hand or from a user cron.""" + state = load_state() + at = (state.get("settings") or {}).get("autoTheme") or {} + if not at.get("enable"): + if not args.which: + log("auto-theme: off (enable via settings.autoTheme.enable)") + return + day, night = at.get("day"), at.get("night") + if not day or not night: + die("auto-theme needs settings.autoTheme.day and .night (theme slugs)") + + sunrise = _hhmm_to_min(at.get("sunrise", "07:00"), "07:00") + sunset = _hhmm_to_min(at.get("sunset", "20:00"), "20:00") + now = time.localtime() + mins = now.tm_hour * 60 + now.tm_min + # Normal case sunrise < sunset: daytime is the span between them. If a + # user inverts them, treat the between-span as night (still coherent). + between = sunrise <= mins < sunset + daytime = between if sunrise <= sunset else not between + target = day if daytime else night + + if args.which: + print(target) + return + if state.get("slug") == target and not args.force: + log(f"auto-theme: already on {target} ({'day' if daytime else 'night'})") + return + apply_named(target, no_switch=args.no_switch) + + def cmd_set(args) -> None: state = load_state() try: @@ -590,6 +642,12 @@ def main() -> None: p.add_argument("path", nargs="?") p.set_defaults(func=cmd_get) + p = sub.add_parser("auto", help="apply the day/night theme for the current time (settings.autoTheme)") + p.add_argument("--which", action="store_true", help="print the theme that WOULD apply, don't switch") + p.add_argument("--force", action="store_true", help="apply even if already on the target theme") + p.add_argument("--no-switch", action="store_true", help="write state only, skip the rebuild") + p.set_defaults(func=cmd_auto) + sub.add_parser("validate", help="check theme-state.json against the schema (read-only)").set_defaults(func=cmd_validate) sub.add_parser("wallpaper", help="apply the current wallpaper via swww").set_defaults(func=cmd_wallpaper)