feat(theme-sync): #79 slice 1 — auto day/night theme primitive
All checks were successful
Check / eval (push) Successful in 3m58s
All checks were successful
Check / eval (push) Successful in 3m58s
First slice of the auto time-of-day theme pair (VISION § D, split into
CLI / timer / menu). `nomarchy-theme-sync auto` reads
settings.autoTheme.{enable,day,night,sunrise,sunset}, picks day vs night
from the local clock, and applies the matching preset — but only if it
differs from the current one, so the slice-2 timer can fire freely
without triggering needless rebuilds. `--which` prints the decision
without switching; `--force` and `--no-switch` included.
cmd_apply's core is factored into apply_named() so `auto` drives the
exact same one engine (no second pipeline). apply behavior is unchanged.
Verification: V1 (package builds) + functional tests on the built binary
against scratch state — wide-day window → day slug, a 00:00–00:01 day
span → night, disabled → no-op, missing day/night → clear die, auto
--no-switch writes state, a re-run no-ops as "already on", and plain
apply still writes (refactor intact). V0 py_compile + flake check +
checks.theme-sync-validate green.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -28,6 +28,23 @@ in [`docs/ROADMAP.md`](../docs/ROADMAP.md); map in
|
|||||||
|
|
||||||
## NEXT
|
## 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]`
|
### 76. Hibernation default — all agent work shipped `[blocked:hw]`
|
||||||
Hibernation + zram on by default (product intent, Bernardo 2026-07-10).
|
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
|
**All V0/V1/V2 slices done** (2026-07-10); only the V3 check remains — on a
|
||||||
|
|||||||
@@ -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)
|
## 2026-07-10 — #78 Omarchy migrant doc (docs/OMARCHY.md)
|
||||||
- **Task:** BACKLOG #78 (promoted from PROPOSED) — Omarchy→Nomarchy
|
- **Task:** BACKLOG #78 (promoted from PROPOSED) — Omarchy→Nomarchy
|
||||||
one-pager (`VISION § F`).
|
one-pager (`VISION § F`).
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ Commands:
|
|||||||
apply <name|file.json> merge a preset into the state + rebuild
|
apply <name|file.json> merge a preset into the state + rebuild
|
||||||
set <dotted.path> <value> tweak one key (e.g. `set ui.gapsOut 16`) + rebuild
|
set <dotted.path> <value> tweak one key (e.g. `set ui.gapsOut 16`) + rebuild
|
||||||
get [dotted.path] print the current state (or one key)
|
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)
|
validate check the state file against the schema (read-only)
|
||||||
wallpaper apply the current wallpaper via swww
|
wallpaper apply the current wallpaper via swww
|
||||||
bg [next|auto] cycle the theme's wallpapers (instant, no rebuild)
|
bg [next|auto] cycle the theme's wallpapers (instant, no rebuild)
|
||||||
@@ -466,11 +467,13 @@ def cmd_list(_args) -> None:
|
|||||||
print("\n".join(names))
|
print("\n".join(names))
|
||||||
|
|
||||||
|
|
||||||
def cmd_apply(args) -> None:
|
def apply_named(theme: str, no_switch: bool = False) -> None:
|
||||||
candidate = Path(args.theme).expanduser()
|
"""Merge a preset (name or JSON file) into the state and rebuild. Shared
|
||||||
preset_path = candidate if candidate.is_file() else find_preset(args.theme)
|
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:
|
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())
|
preset = json.loads(preset_path.read_text())
|
||||||
# Merge over current state. Presets carry a full appearance block —
|
# Merge over current state. Presets carry a full appearance block —
|
||||||
@@ -483,14 +486,63 @@ def cmd_apply(args) -> None:
|
|||||||
state = deep_merge(old, preset)
|
state = deep_merge(old, preset)
|
||||||
write_state(state)
|
write_state(state)
|
||||||
if auto_commit_enabled(old) or auto_commit_enabled(state):
|
if auto_commit_enabled(old) or auto_commit_enabled(state):
|
||||||
auto_commit(f"nomarchy: apply theme {state.get('name', args.theme)}")
|
auto_commit(f"nomarchy: apply theme {state.get('name', theme)}")
|
||||||
log(f"theme: {state.get('name', args.theme)}")
|
log(f"theme: {state.get('name', theme)}")
|
||||||
check_fonts(state)
|
check_fonts(state)
|
||||||
if not args.no_switch:
|
if not no_switch:
|
||||||
run_switch()
|
run_switch()
|
||||||
apply_wallpaper(state)
|
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:
|
def cmd_set(args) -> None:
|
||||||
state = load_state()
|
state = load_state()
|
||||||
try:
|
try:
|
||||||
@@ -590,6 +642,12 @@ def main() -> None:
|
|||||||
p.add_argument("path", nargs="?")
|
p.add_argument("path", nargs="?")
|
||||||
p.set_defaults(func=cmd_get)
|
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("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)
|
sub.add_parser("wallpaper", help="apply the current wallpaper via swww").set_defaults(func=cmd_wallpaper)
|
||||||
|
|||||||
Reference in New Issue
Block a user