This commit is contained in:
2026-07-14 10:12:37 -03:00
parent 234a303bac
commit cf287d4990
23 changed files with 2147 additions and 79 deletions
@@ -0,0 +1,2 @@
schema: spec-driven
created: 2026-07-13
@@ -0,0 +1,160 @@
## Context
The user runs jpporta-nixos (Hyprland on Wayland) and jpporta-deck (a OrangePi running cage + foot as a "writer deck"). They maintain 11 hand-curated palettes under `~/.config/colorschemes/<theme>/` with per-app config files (hyprland, waybar, swaync, wlogout, kitty, ghostty, nvim, rofi, gtk-4.0, plus a `wallpapers/` directory). Currently:
- **Hyprland, awww, waybar, kitty, ghostty** are reachable from the themed dirs (with manual edits).
- **swaync, wlogout, alacritty** have their colors hardcoded as Nix strings in `modules/home-manager/*/default.nix`, so swapping themes requires `home-manager switch`.
- **darkman** runs a dark/light switch (geolocation-based) that flips GTK theme and hyprsunset temperature — its `gtk-theme` step overlaps with what theme-switch will own.
- **No canonical "current theme" pointer** exists. The `~/.config/colorschemes/wallpapers` symlink is a one-off pointing at gruvbox-dark.
- **No live propagation contract** — every themed app needs its own reload mechanism (file watch, IPC, signal, or restart-on-next-launch).
The user wants: one command to swap themes, no rebuild, every themed app reflects the new palette, dark/light mode is owned by the theme (not by darkman), darkman keeps doing what it's good at (time-of-day temperature), and the writer deck can adopt the same workflow.
Constraints discovered during exploration:
- All per-theme assets must remain editable in `~/.config/colorschemes/<theme>/` without rebuild — this is the precedent set by the `awww` module (reads wallpaper dir live, never copies to store).
- Theme colors live in dotfiles, not Nix. Anything baked into Nix is a second source of truth and must be removed.
- The `.active` symlink pattern is preferred over a sentinel file because it's atomic (`ln -sfn`) and serves as the directory apps follow directly.
- Darkman should keep its sunset/sunrise logic for `hyprsunset`, but stop touching GTK (theme owns that).
## Goals / Non-Goals
**Goals:**
- Atomic theme swap with one shell command (`theme-switch <name>`) — no Nix rebuild, no per-app scripting by the user.
- Single canonical pointer (`~/.config/colorschemes/.active` symlink) that every themed app can follow.
- Per-app live reload: each app gets exactly the reload signal it natively supports, scripted in one place.
- gsettings `color-scheme` flip driven by `meta` (theme declares its mode).
- Hook point for user-owned wallpaper logic (`~/.config/colorschemes/.hook <theme-name>`), exec'd at the end of every switch.
- Boot-time application: a systemd user oneshot re-applies `.current` on graphical-session start so login is consistent.
- Rofi-driven picker (`theme-picker`) wired to a Hyprland keybind (`SUPER+T`).
- Same module works on the writer deck; foot has no live reload but a relaunch in cage is fine.
**Non-Goals:**
- Generating palettes from images (this is what matugen/pywal do — user wants named curated palettes).
- Per-app templating engines. Each app already has its own config format; we hand-write the swap for each.
- Supporting themes that live outside `~/.config/colorschemes/`.
- Backwards compatibility for the existing `~/.config/colorschemes/wallpapers` symlink (it becomes redundant once `.active/wallpapers/` exists; the script handles the migration).
- Replacing darkman. darkman stays for `hyprsunset` time-of-day control.
- Auto-detecting new themes. The `meta` file must exist per theme; the user adds them by hand.
- Adding new external dependencies (no new packages).
## Decisions
### D1. `.active` symlink as the single source of truth, not a sentinel file
**Choice:** `~/.config/colorschemes/.active` is a symlink pointing at the current theme's directory. A `.current` text file mirrors the name for tools that can't follow symlinks (some app loaders, the boot-time applier).
**Why:** Atomic swap with one syscall (`ln -sfn`). Apps that support symlink-following read `~/.config/colorschemes/.active/<app>/` directly. Apps that need a name read `.current`. Both can coexist; the cost is two writes per switch.
**Alternatives considered:**
- *Plain `.current` text file only*: requires every app to maintain its own per-app link/symlink. Doesn't scale.
- *Environment variable*: lost across reboots, hard to introspect from running processes, fights with systemd user services.
- *dconf / gsettings schema*: forces every app into a GTK-ish world; waybar/kitty/ghostty/hyprland don't read it.
### D2. Per-app reload handled inside `theme-switch`, not via inotify watchers
**Choice:** `theme-switch` is the orchestrator. It runs each per-app reload step explicitly. No daemon watches `~/.config/colorschemes/.active` for changes.
**Why:** Hyprland auto-watches its config (free). Other apps don't, and inventing an inotify daemon to *poke* apps adds a new failure surface. The user's stance: "if the app watches its own config, fine; otherwise, send the signal." That's exactly what `theme-switch` does.
**Alternatives considered:**
- *inotifywait daemon*: one more systemd service, one more race condition, no benefit over the explicit script.
- *DBus signal + per-app subscribers*: significant scaffolding for what amounts to ~10 commands.
### D3. Theme metadata via per-theme `meta` file, not Nix
**Choice:** Each theme gets a one-line `~/.config/colorschemes/<theme>/meta` file with `name=Display Name` and `mode=dark|light` (KEY=value, simple parser).
**Why:** Keeps the colorscheme dir fully self-describing. The `theme-picker` script reads these for its rofi menu. `theme-switch` reads the mode field to flip gsettings. No Nix expression, no rebuild.
**Alternatives considered:**
- *Filename convention (`gruvbox-dark/`, `e-ink-light/`) — infer mode from name*: fragile, requires a registry.
- *First-line comment in `colors.conf`*: mixes concerns (colors.conf is for hyprland, not for metadata).
### D4. Cleanup pass on swaync/wlogout/alacritty is required, not optional
**Choice:** Remove the hardcoded gruvbox colors from the three Nix modules. They become thin wrappers that `xdg.configFile` the active theme dir via out-of-store symlinks (same pattern as the existing `nvim` module).
**Why:** Without this, three of the user's themed apps simply won't switch (their colors are baked at build time). The cleanup is a one-time `home-manager switch`; theme switches thereafter are runtime only.
**Alternatives considered:**
- *Leave Nix modules alone, accept that swaync/wlogout/alacritty don't switch*: violates the user's goal of "every app picks up the new theme."
- *Template them at activation time*: requires `home-manager switch` on every theme change — explicitly rejected.
### D5. Darkman narrowed to hyprsunset only
**Choice:** `darkman` stays enabled. Its scripts lose the `gtk-theme` step (theme-switch owns that now) and keep the `hyprsunset` step (time-of-day temperature). `theme-switch` does not poke darkman.
**Why:** Theme owns *which palette* (dark vs light). Darkman owns *when to warm the screen* (sunset/sunrise). They're orthogonal and shouldn't fight. Conflating them — e.g., theme-switch telling darkman what mode to be in — creates two writers for the same gsettings keys.
**Alternatives considered:**
- *Theme-switch sets the darkman mode file*: simple but couples two systems that don't otherwise need to know about each other.
- *Kill darkman*: loses the automatic sunset temperature shift, which the user said they want to keep.
### D6. Wallpaper hook is user-owned, executed at end of switch
**Choice:** If `~/.config/colorschemes/.hook` exists and is executable, `theme-switch` runs it as `~/.config/colorschemes/.hook <theme-name>` at the very end of the switch. If it doesn't exist, the switch still completes.
**Why:** Wallpaper policy is personal (random pick? curated list? match a subdirectory?). The change shouldn't ship a wallpaper-rotation script the user didn't ask for. The hook is a single, documented extension point.
**Alternatives considered:**
- *Bundle a default random-wallpaper script in the Nix module*: scope creep, and the user explicitly said "you can leave that part to me."
- *awww integration baked into theme-switch*: same as above; also, awww already has its own `awww-cycle` systemd timer that picks from `~/Wallpapers`, and the `wallpapers` symlink today points at the theme's dir — so all theme-switch needs to do is repoint the symlink.
### D7. Boot-time re-apply via systemd user oneshot
**Choice:** `theme-switch` (no args) is idempotent: it reads `.current` and re-applies. A `theme-apply.service` systemd user oneshot is `WantedBy=graphical-session.target` and runs it on session start.
**Why:** Ensures the desktop matches `.current` after login even if a previous session crashed mid-switch. Cheap (the script no-ops if the symlink already points there).
**Alternatives considered:**
- *Home Manager activation hook*: runs only on `home-manager switch`, not on plain reboots.
- *Hyprland autostart command*: works but mixes init logic into compositor config; systemd target is cleaner.
### D8. Rofi picker is a thin wrapper, not a replacement for the CLI
**Choice:** `theme-picker` is a small script that reads each theme's `meta`, pipes them through `rofi -dmenu`, and `exec`s `theme-switch` on the chosen name. The CLI is the source of truth; the picker is sugar.
**Why:** Scriptability (cron, hyprland keybind action, shell aliases) belongs in the CLI. The picker is one Hyprland keybind away.
**Alternatives considered:**
- *Waybar module*: cute but adds a waybar dependency to a feature that doesn't need it.
- *TUI (fzf, gum)*: extra dependency, no gain.
### D9. Writer deck adopts the same script, with foot handled as "relaunch on switch"
**Choice:** Same Nix module imports into `hosts/writter-deck/home.nix`. The `theme-switch` script on the deck has a foot-specific step: `pkill foot && foot --server &` (foot's server-mode makes this near-instant for a writer deck).
**Why:** Zero new concepts on the deck. The user's deck is already gruvbox; this just lets them pick another.
**Alternatives considered:**
- *Separate `theme-switch-foot` script*: duplication for one app.
- *Skip the deck*: explicit non-goal of the user — they want it if cheap.
## Risks / Trade-offs
- **Atomicity of multi-app reload** → Mitigation: order the steps so data lands before signals (`hyprland` config swap is just a file rewrite; signals come after). If one app fails mid-sequence, the user can re-run `theme-switch <name>` — it's idempotent.
- **Hyprland's `source =` directive points at a path that must exist before hyprland reads it** → Mitigation: on first install, the activation script creates `.active` from `custom.theme.current` *before* hyprland starts. Hyprland only loads on graphical-session start (after `theme-apply.service`).
- **Waybar `@import` and symlink interplay** → Mitigation: waybar's `style.css` will `@import` from `themes/current.css` (a symlink the script updates); SIGUSR2 forces re-eval.
- **nvim running sessions won't recolor** → Mitigation: the script tries `nvim --remote-expr "colorscheme <name>"` if `$NVIM` is set; if not, only new nvim windows pick up the new theme. Documented as expected.
- **alacritty has no live color reload** → Mitigation: next-launched alacritty instances pick up the new config; existing windows keep their colors until restarted. Documented. (This matches alacritty's own behavior; not a regression.)
- **ghostty live reload is `SIGHUP`** → Mitigation: send `SIGHUP` to the running ghostty; on some versions this re-reads the config. If ghostty is unresponsive, the user restarts the terminal. Acceptable.
- **First-time switch after the cleanup rebuild may visually flash old-then-new** → Mitigation: the boot-time oneshot re-applies `.current` *before* the user sees the desktop; the flash only happens if the user runs `theme-switch` interactively, which is the point.
- **Adding a new theme requires writing a `meta` file** → Mitigation: documented in the theme module's option description. The `meta` file is two lines.
- **`darkman`'s gsettings writes could fight `theme-switch`'s** → Mitigation: the cleanup pass removes darkman's `gtk-theme` script entirely. After this change, darkman writes only to `hyprsunset`. No overlap.
- **Nix modules becoming "thin" can feel like over-abstraction to a reader who expects colors there** → Mitigation: the modules keep their `enable` options and any non-color config (font, opacity, layout). Only color strings leave. Comment in each module notes "colors come from `~/.config/colorschemes/.active/<app>/`."
- **Hook script runs as the user with no sandboxing** → Mitigation: it lives in the user's home dir, owned by the user. This is the same trust level as a shell alias. Documented in `theme-switch`'s usage output.
- **`ln -sfn` over an existing `.active` symlink is correct, but if `.active` exists as a *directory* (corruption), the symlink will be created inside it** → Mitigation: the script first checks `[ -L .active ] || [ ! -e .active ]` and bails with a clear error if `.active` is a directory. Documented in the script header.
@@ -0,0 +1,50 @@
## Why
Today, switching color themes on jpporta-nixos requires manual editing of files scattered across `~/.config/`, plus a `nixos-rebuild switch` whenever a themed Nix module (swaync, wlogout) is involved. The user already maintains 11 curated palettes under `~/.config/colorschemes/<theme>/` (catppuccin, gruvbox-dark, tokyo-night, nord-darker, rose-pine, kanagawa, nightfox, everforest-dark, e-ink, noir, and one more), but there is no canonical "current theme" pointer, no atomic swap, no live propagation to running apps, and no keybind-driven picker. The user wants to switch themes in one command — fast, with no rebuild — and have every themed app update.
## What Changes
- **New module `modules/home-manager/theme/`** that installs `theme-switch` (CLI) and `theme-picker` (rofi wrapper) into `~/.local/bin`, adds a `SUPER+T` Hyprland keybind for the picker, declares `custom.theme.current` as the boot-time default, and registers a systemd user oneshot that re-applies the current theme on graphical-session start.
- **New script `theme-switch <name>`** that performs an atomic theme swap by rewriting a single `~/.config/colorschemes/.active` symlink, then emits per-app reload signals (waybar SIGUSR2, swaync-client `--reload-css`, kitty `@ set-colors`, ghostty SIGUSR1, gsettings color-scheme flip, etc.) so live apps reflect the new palette without restart. Idempotent: invoked with no args, it re-applies whatever `.current` says (used on boot).
- **New script `theme-picker`** — a rofi-driven wrapper around `theme-switch` that lists available themes from `~/.config/colorschemes/*/meta` and invokes the switcher on selection.
- **New per-theme `meta` file** (one per theme): `name=...`, `mode=dark|light`. Drives gsettings and the darkman integration.
- **Hook point `~/.config/colorschemes/.hook <theme-name>`**: `theme-switch` execs this at the end of every switch. This is where the user drops their wallpaper-rotation script. The change does NOT write the wallpaper hook — that is owned by the user.
- **Cleanup pass** on `swaync`, `wlogout`, `alacritty`, and `darkman` Nix modules: drop the hardcoded gruvbox color strings (Nix store), point their configs at the active theme dir via out-of-store symlinks, and reduce `darkman` to only drive `hyprsunset` (time-of-day temperature). After this one-time rebuild, theme switching never touches Nix again.
- **Bonus (writer-deck)**: same Nix module imported by `hosts/writter-deck/home.nix` so the foot terminal on the Steam Deck can switch themes the same way (foot has no live reload, but a relaunch in cage is acceptable for a writer deck).
No app configuration is being deleted from `dotfiles/colorschemes/`; the cleanup pass only removes the *duplicate* hardcoded gruvbox strings that currently live in Nix modules while leaving the themed files in `~/.config/colorschemes/<theme>/` untouched.
## Capabilities
### New Capabilities
- `theme-switch`: the runtime CLI + rofi picker + boot-time applier; defines the contract for what "switching themes" means in this system (atomic `.active` symlink, per-app reload signals, hook execution, gsettings flip driven by `meta` mode).
- `theme-colors-sources`: the convention that every themed app on this system reads its colors from `~/.config/colorschemes/.active/<app>/...` rather than from Nix-store-baked strings; covers the cleanup pass on swaync/wlogout/alacritty so future theme additions don't require module edits.
### Modified Capabilities
<!-- No existing specs in openspec/specs/ yet, so nothing to list. The cleanup pass on swaync/wlogout/alacritty/darkman is implementation-level, not a requirement change for any existing spec. -->
## Impact
- **New files**:
- `modules/home-manager/theme/default.nix`
- `modules/home-manager/theme/theme-switch.sh` (script template embedded in Nix)
- `modules/home-manager/theme/theme-picker.sh` (script template embedded in Nix)
- `~/.config/colorschemes/.current` (runtime, owned by script)
- `~/.config/colorschemes/.active` (symlink, runtime)
- `~/.config/colorschemes/.hook` (user-owned, optional)
- `~/.config/colorschemes/<theme>/meta` (one per theme)
- **Modified files**:
- `modules/home-manager/swaync/default.nix` — drop inline gruvbox, point at out-of-store config
- `modules/home-manager/wlogout/default.nix` — same
- `modules/home-manager/alacritty/default.nix` — add `import` for external colors file (font/opacity stay Nix)
- `modules/home-manager/darkman/default.nix` — remove `gtk-theme` scripts, keep `hyprsunset`
- `modules/home-manager/hyprland/default.nix` — add `SUPER+T` keybind for `theme-picker` (or document the user adding it)
- `hosts/jpporta-nixos/home.nix` — import new theme module
- `hosts/writter-deck/home.nix` — import new theme module (bonus)
- `~/.config/hypr/hyprland.conf` (or the lua-generated version) — point hyprland at `colorschemes/.active/hypr/colors.conf` via `source =`
- `~/.config/waybar/style.css` — switch from `@import "./themes/gruvbox-dark.css"` to `@import` from a symlinked `themes/current.css` (or equivalent)
- **Affected apps** (live-reload behavior changes): swaync (CSS reload instead of restart), wlogout (next-invocation reload, no live signal), waybar (SIGUSR2), kitty (IPC), ghostty (SIGUSR1), alacritty (next-launch), nvim (new windows; optional remote-expr for running session), gtk-4 apps (gsettings flip).
- **No new external dependencies**: rofi, swaync-client, kitty, ghostty, waybar, gsettings, notify-send (libnotify) are already installed.
- **No breaking changes** for users not using the new command — existing gruvbox-dark setup continues to work; the first `theme-switch` after rebuild is a no-op if `.current` already matches.
@@ -0,0 +1,85 @@
# Spec: theme-colors-sources
## ADDED Requirements
### Requirement: Swaync reads colors from the active theme dir, not from Nix
The swaync Home Manager module SHALL NOT inline color strings. Instead, the module SHALL ensure that `~/.config/swaync/style.css` (and any related color file) is sourced from the active theme dir via an out-of-store symlink to `~/.config/colorschemes/.active/swaync/`.
#### Scenario: swaync config is symlinked to active theme
- **WHEN** the user has applied this change
- **THEN** `~/.config/swaync/colors.css` resolves through `~/.config/colorschemes/.active/swaync/colors.css`
- **AND** the swaync Nix module contains no color hex strings
#### Scenario: Theme switch reflects in swaync without rebuild
- **WHEN** the user runs `theme-switch catppuccin`
- **THEN** swaync restyles via `swaync-client --reload-css` after the symlink swap
- **AND** no `home-manager switch` is required
### Requirement: Wlogout reads colors from the active theme dir, not from Nix
The wlogout Home Manager module SHALL NOT inline color strings. Instead, the module SHALL ensure that `~/.config/wlogout/style.css` is sourced from the active theme dir via an out-of-store symlink to `~/.config/colorschemes/.active/wlogout/`.
#### Scenario: wlogout config is symlinked to active theme
- **WHEN** the user has applied this change
- **THEN** `~/.config/wlogout/style.css` resolves through `~/.config/colorschemes/.active/wlogout/style.css`
- **AND** the wlogout Nix module contains no color hex strings
#### Scenario: Next wlogout invocation uses new colors
- **WHEN** the user runs `theme-switch` and then invokes wlogout
- **THEN** wlogout uses the new palette (no live reload required; wlogout reads its config at launch)
### Requirement: Alacritty imports colors from an external file
The alacritty Home Manager module SHALL configure alacritty to import colors from `~/.config/alacritty/colors.toml` (or equivalent), which is symlinked from the active theme dir.
#### Scenario: alacritty config imports external colors file
- **WHEN** the user has applied this change
- **THEN** `~/.config/alacritty/alacritty.toml` contains an `import = [...]` directive that includes the colors file
- **AND** `~/.config/alacritty/colors.toml` resolves through `~/.config/colorschemes/.active/alacritty/colors.toml`
- **AND** the alacritty Nix module contains no color hex strings (font, opacity, padding remain in Nix)
#### Scenario: Next alacritty launch uses new colors
- **WHEN** the user runs `theme-switch` and then launches a new alacritty window
- **THEN** the new window uses the new palette
### Requirement: Darkman is narrowed to hyprsunset only
The darkman Home Manager module SHALL retain its `hyprsunset` script (sunset/sunrise temperature) and SHALL NOT contain any `gtk-theme` script. Theme-driven dark/light preference is owned by `theme-switch` via gsettings, not by darkman.
#### Scenario: darkman scripts contain no gtk-theme step
- **WHEN** the user has applied this change
- **THEN** `services.darkman.darkModeScripts` does not include a `gtk-theme` key
- **AND** `services.darkman.lightModeScripts` does not include a `gtk-theme` key
#### Scenario: darkman still drives hyprsunset
- **WHEN** the time crosses the darkman sunset/sunrise boundary
- **THEN** the `hyprsunset` script runs and adjusts temperature/gamma
- **AND** gsettings `color-scheme` is NOT modified by darkman
### Requirement: Hyprland sources its colors from the active theme dir
The hyprland Home Manager module (or its user-facing config file) SHALL be configured so that hyprland's color directives come from a `source =` directive pointing at `~/.config/colorschemes/.active/hypr/colors.conf`. The Nix module SHALL NOT inline color hex strings for theme-affected values (active/inactive borders, etc.).
#### Scenario: Hyprland config sources from active theme
- **WHEN** the user has applied this change
- **THEN** `~/.config/hypr/hyprland.conf` (or its lua-generated form) contains `source = ~/.config/colorschemes/.active/hypr/colors.conf`
- **AND** the Hyprland module contains no inline color hex strings for theme-affected directives
#### Scenario: Theme switch reflects in hyprland without rebuild
- **WHEN** the user runs `theme-switch`
- **THEN** hyprland automatically reloads its config (it watches the file)
- **AND** no `home-manager switch` is required
### Requirement: Every themed app reads from a single source of truth
Every app whose colors are themed on this host SHALL read its palette from `~/.config/colorschemes/.active/<app>/...` (directly or via symlink). No themed app SHALL have its colors baked into a Nix-managed file.
#### Scenario: Audit: no color hex strings in themed-app Nix modules
- **WHEN** the user runs `rg -i '#[0-9a-f]{6}' modules/home-manager/{swaync,wlogout,alacritty,hyprland,darkman}` after applying this change
- **THEN** the only matches are in non-theme-affected contexts (e.g., alacritty font color is fine; swaync/wlogout/hyprland theme colors are gone)
#### Scenario: Adding a new theme requires no Nix change
- **WHEN** the user creates a new `~/.config/colorschemes/<new-theme>/` directory with the same per-app subdirs as existing themes, plus a `meta` file
- **THEN** `theme-switch <new-theme>` works without any `home-manager switch`
- **AND** the new theme appears in the rofi picker on next invocation
@@ -0,0 +1,155 @@
# Spec: theme-switch
## ADDED Requirements
### Requirement: Atomic theme swap via a single canonical pointer
The system SHALL provide a single shell command, `theme-switch <theme-name>`, that switches the active theme by atomically rewriting `~/.config/colorschemes/.active` (a symlink) to point at `~/.config/colorschemes/<theme-name>/`.
#### Scenario: Successful switch to a valid theme
- **WHEN** the user runs `theme-switch catppuccin` and `~/.config/colorschemes/catppuccin/` exists
- **THEN** `~/.config/colorschemes/.active` is a symlink whose target resolves to `~/.config/colorschemes/catppuccin`
- **AND** `~/.config/colorschemes/.current` contains the text `catppuccin`
#### Scenario: Switch with an unknown theme name
- **WHEN** the user runs `theme-switch does-not-exist` and the directory does not exist
- **THEN** the command exits non-zero
- **AND** prints a list of valid theme names to stderr
- **AND** does not modify `.active` or `.current`
#### Scenario: Re-applying the current theme is a no-op
- **WHEN** the user runs `theme-switch` with no arguments
- **THEN** the command reads `.current`
- **AND** re-applies every per-app reload step (idempotent)
### Requirement: Per-theme metadata drives picker and gsettings
The system SHALL read each theme's mode (dark or light) and display name from a `meta` file at `~/.config/colorschemes/<theme>/meta`.
#### Scenario: meta file format
- **WHEN** the user runs `theme-switch` against any theme
- **THEN** the script reads `<theme>/meta`
- **AND** parses lines of the form `key=value`
- **AND** uses `mode=dark` or `mode=light` to drive gsettings
- **AND** uses `name=...` for the rofi picker label
#### Scenario: Missing meta file defaults to dark
- **WHEN** a theme directory has no `meta` file
- **THEN** `theme-switch` treats the theme as `mode=dark`
- **AND** uses the directory name as the display name
- **AND** emits a warning to stderr
### Requirement: Per-app live reload covers every themed app on jpporta-nixos
`theme-switch` SHALL emit the appropriate reload signal for each themed app on the host, in an order that places file/data changes before signals.
#### Scenario: Hyprland picks up new colors via file watch
- **WHEN** `theme-switch` rewrites `~/.config/hypr/hyprland.conf` so that its `source =` directive points at `~/.config/colorschemes/.active/hypr/colors.conf`
- **THEN** the running Hyprland instance reloads automatically (it watches its config file)
#### Scenario: waybar picks up new colors via SIGUSR2
- **WHEN** `theme-switch` swaps the waybar CSS symlink and the colors.css file under it
- **THEN** the running waybar instance receives SIGUSR2
- **AND** restyles with the new palette
#### Scenario: swaync picks up new CSS via swaync-client
- **WHEN** `theme-switch` swaps `~/.config/swaync/colors.css`
- **THEN** the script invokes `swaync-client --reload-css`
- **AND** swaync restyles without restart
#### Scenario: kitty picks up new colors via IPC
- **WHEN** `theme-switch` invokes `kitty @ set-colors --all ~/.config/colorschemes/.active/kitty/colors.conf`
- **THEN** every running kitty window restyles without restart
#### Scenario: ghostty picks up new colors via SIGHUP
- **WHEN** `theme-switch` sends SIGHUP to the running ghostty process
- **THEN** ghostty re-reads its config (including the colors block)
#### Scenario: alacritty uses the new palette on next launch
- **WHEN** `theme-switch` swaps the alacritty colors file that `alacritty.toml` imports
- **THEN** running alacritty windows keep their current colors until restarted
- **AND** any newly launched alacritty instance uses the new palette
#### Scenario: nvim recolors in-session when $NVIM is set
- **WHEN** `theme-switch` runs and the user has `$NVIM` set (a running nvim server)
- **THEN** the script invokes `nvim --remote-expr 'lua vim.cmd.colorscheme("<name>")'`
- **AND** the running nvim recolors immediately
- **WHEN** `$NVIM` is not set
- **THEN** the script takes no action for nvim
- **AND** newly launched nvim instances pick up the theme from the symlinked config
#### Scenario: GTK dark/light preference follows theme meta
- **WHEN** `theme-switch` reads `mode=dark` from the new theme's meta
- **THEN** the script invokes `gsettings set org.gnome.desktop.interface color-scheme prefer-dark`
- **WHEN** `mode=light`
- **THEN** the script invokes `gsettings set org.gnome.desktop.interface color-scheme prefer-light`
### Requirement: Rofi-driven theme picker is a thin wrapper over theme-switch
The system SHALL provide a `theme-picker` script that lists available themes (display name + mode) via `rofi -dmenu` and invokes `theme-switch` on the user's selection.
#### Scenario: Picker shows all available themes
- **WHEN** the user runs `theme-picker`
- **THEN** rofi displays one entry per theme directory under `~/.config/colorschemes/`
- **AND** each entry shows the display name from `meta` and a `(dark)` or `(light)` suffix
#### Scenario: Picker invokes theme-switch on selection
- **WHEN** the user selects an entry in rofi
- **THEN** `theme-picker` invokes `theme-switch <selected-theme-name>`
- **AND** exits with the same status code
### Requirement: Boot-time re-apply guarantees a consistent desktop on login
The system SHALL provide a systemd user oneshot service that runs `theme-switch` (no args) on graphical-session start, ensuring `.current` is applied before the user sees the desktop.
#### Scenario: Service runs on graphical-session start
- **WHEN** the user's graphical session (Hyprland on jpporta-nixos; cage on jpporta-deck) starts
- **THEN** `theme-apply.service` runs `theme-switch`
- **AND** the service has no effect if `.current` already matches the running state
#### Scenario: Service does not run before graphical session
- **WHEN** the user's session is not yet at graphical-session.target
- **THEN** `theme-apply.service` is not started
- **AND** waits for the target
### Requirement: Wallpaper hook is user-owned and exec'd at end of switch
`theme-switch` SHALL execute `~/.config/colorschemes/.hook <theme-name>` at the end of every successful switch if that file exists and is executable.
#### Scenario: Hook is invoked after all app reloads
- **WHEN** `theme-switch` finishes the per-app reload block
- **AND** `~/.config/colorschemes/.hook` exists and is executable
- **THEN** the script executes `~/.config/colorschemes/.hook <theme-name>`
- **AND** any non-zero exit status is logged but does not fail the switch
#### Scenario: Missing hook does not fail the switch
- **WHEN** `~/.config/colorschemes/.hook` does not exist or is not executable
- **THEN** the switch still completes successfully
### Requirement: Writer-deck adopts the same mechanism with foot handled at switch time
The same `theme-switch` script SHALL work on jpporta-deck (cage + foot). Foot has no live color reload, so the deck profile SHALL include a foot-specific step that sends `SIGUSR1` (footserver reload) or, as a fallback, restarts the foot server.
#### Scenario: Foot recolors via SIGUSR1 to footserver
- **WHEN** `theme-switch` runs on jpporta-deck
- **THEN** the script sends `SIGUSR1` to the footserver process if running
- **AND** foot reloads its config without losing the running instance
#### Scenario: footserver not running
- **WHEN** `theme-switch` runs on jpporta-deck and no footserver is running
- **THEN** the script takes no action for foot
- **AND** the next foot launch picks up the new theme
### Requirement: First-install migration creates .active from custom.theme.current
On a fresh `home-manager switch` after this change is applied, the theme module SHALL ensure `~/.config/colorschemes/.active` exists and points at the theme named in `custom.theme.current`.
#### Scenario: First install with default theme
- **WHEN** the user applies this change for the first time and `custom.theme.current = "gruvbox-dark"`
- **THEN** the activation creates `~/.config/colorschemes/.active` as a symlink to `~/.config/colorschemes/gruvbox-dark/`
- **AND** writes `gruvbox-dark` to `~/.config/colorschemes/.current`
#### Scenario: Existing wallpapers symlink is left in place during migration
- **WHEN** `~/.config/colorschemes/wallpapers` is already a symlink (legacy)
- **THEN** the migration does not remove it
- **AND** documents in a comment that it is now redundant
@@ -0,0 +1,91 @@
# Tasks: theme-switch
## 1. Per-theme metadata
- [ ] 1.1 Create `~/.config/colorschemes/<theme>/meta` for each of the 11 existing themes with `name=<Display Name>` and `mode=dark` (or `mode=light` for `e-ink` and `noir`).
## 2. Theme module scaffold
- [ ] 2.1 Create `modules/home-manager/theme/default.nix` declaring `options.custom.theme.current` (string, default `"gruvbox-dark"`) and `options.custom.theme.enable` (bool).
- [ ] 2.2 Embed the `theme-switch` shell script in the module via `pkgs.writeShellScriptBin` so it's installed to `~/.local/bin/theme-switch`.
- [ ] 2.3 Embed the `theme-picker` shell script in the module via `pkgs.writeShellScriptBin` so it's installed to `~/.local/bin/theme-picker`.
- [ ] 2.4 Add a systemd user oneshot `theme-apply.service` that runs `theme-switch` (no args) on `graphical-session.target`.
- [ ] 2.5 Add a `home.activation` block that creates `~/.config/colorschemes/.active` (symlink to `custom.theme.current`) and `.current` (text file with the name) if missing.
## 3. theme-switch script
- [ ] 3.1 Implement argument parsing: `<theme-name>` switches; no args reads `.current` and re-applies.
- [ ] 3.2 Validate `<theme-name>` exists under `~/.config/colorschemes/`; print list and exit non-zero otherwise.
- [ ] 3.3 Refuse to proceed if `~/.config/colorschemes/.active` is a non-symlink directory (defensive guard).
- [ ] 3.4 Atomically swap `.active` with `ln -sfn`.
- [ ] 3.5 Write `<theme-name>` to `~/.config/colorschemes/.current`.
- [ ] 3.6 Parse the new theme's `meta` for `name=` and `mode=`.
- [ ] 3.7 Emit `gsettings set org.gnome.desktop.interface color-scheme prefer-{dark,light}` based on `mode=`.
- [ ] 3.8 Update hyprland config: rewrite `~/.config/hypr/hyprland.conf` so its `source =` directive points at `~/.config/colorschemes/.active/hypr/colors.conf`. No signal (hyprland watches).
- [ ] 3.9 Update waybar: atomic symlink swap for `~/.config/waybar/themes/current.css``<theme>/waybar/colors.css`, then `pkill -USR2 waybar`.
- [ ] 3.10 Update swaync: atomic swap for `~/.config/swaync/colors.css``<theme>/swaync/colors.css`, then `swaync-client --reload-css`.
- [ ] 3.11 Update wlogout: atomic swap for `~/.config/wlogout/style.css``<theme>/wlogout/style.css`. No live signal (next-launch reload).
- [ ] 3.12 Update kitty: `kitty @ set-colors --all ~/.config/colorschemes/.active/kitty/colors.conf`.
- [ ] 3.13 Update ghostty: `pkill -SIGUSR1 ghostty`.
- [ ] 3.14 Update alacritty: atomic swap for `~/.config/alacritty/colors.toml``<theme>/alacritty/colors.toml`. No live signal (next-launch reload).
- [ ] 3.15 Update nvim: if `$NVIM` is set, `nvim --remote-expr 'lua vim.cmd.colorscheme("<name>")'`. Else no-op.
- [ ] 3.16 If `~/.config/colorschemes/.hook` exists and is executable, run `~/.config/colorschemes/.hook <theme-name>` at the end. Tolerate non-zero exit.
- [ ] 3.17 Notify via `notify-send "Theme: <name>"` if libnotify is available.
## 4. theme-picker script
- [ ] 4.1 List `~/.config/colorschemes/*/meta` files.
- [ ] 4.2 Parse each `meta` for `name=` and `mode=`.
- [ ] 4.3 Pipe the formatted entries (`<name> (<mode>)`) into `rofi -dmenu -p "Theme"`.
- [ ] 4.4 On selection, invoke `theme-switch <selected-theme-name>` and exit with its status.
## 5. Cleanup pass: swaync
- [ ] 5.1 Strip the inline gruvbox color strings from `modules/home-manager/swaync/default.nix` (the `let` block at the top with `background`, `text`, etc.).
- [ ] 5.2 Replace the `style = ''...''` block with `xdg.configFile."swaync/style.css".source = config.lib.file.mkOutOfStoreSymlink "${config.home.homeDirectory}/.config/swaync/style.css"` (so the file lives in dotfiles and is theme-driven).
- [ ] 5.3 Verify `~/.config/colorschemes/<theme>/swaync/colors.css` exists for all 11 themes; if any are missing, copy from `gruvbox-dark/swaync/colors.css` as a placeholder.
## 6. Cleanup pass: wlogout
- [ ] 6.1 Strip the inline gruvbox color strings from `modules/home-manager/wlogout/default.nix`.
- [ ] 6.2 Replace the `style = ''...''` block with `xdg.configFile."wlogout/style.css".source = config.lib.file.mkOutOfStoreSymlink ...`.
- [ ] 6.3 Verify `~/.config/colorschemes/<theme>/wlogout/colors.css` exists for all 11 themes.
## 7. Cleanup pass: alacritty
- [ ] 7.1 Keep font, opacity, padding, blur in `modules/home-manager/alacritty/default.nix`.
- [ ] 7.2 Add `import = [ "~/.config/alacritty/colors.toml" ]` to `programs.alacritty.settings`.
- [ ] 7.3 Ensure `~/.config/colorschemes/<theme>/alacritty/colors.toml` exists for all 11 themes (or document a one-time port if the existing files are in a different format).
- [ ] 7.4 Add `xdg.configFile."alacritty/colors.toml".source = out-of-store symlink to ~/.config/colorschemes/.active/alacritty/colors.toml` (or document leaving it as a mutable file the script swaps).
## 8. Cleanup pass: darkman
- [ ] 8.1 Remove the `gtk-theme` key from both `services.darkman.darkModeScripts` and `services.darkman.lightModeScripts`.
- [ ] 8.2 Leave the `hyprsunset` key in both modes (still time-of-day driven).
- [ ] 8.3 Add a comment in the module noting that gsettings `color-scheme` is now owned by `theme-switch`.
## 9. Hyprland integration
- [ ] 9.1 In `modules/home-manager/hyprland/default.nix` (or the lua config it writes), ensure `~/.config/hypr/hyprland.conf` `source`s `~/.config/colorschemes/.active/hypr/colors.conf`.
- [ ] 9.2 Strip inline color hex strings (active/inactive border) from the lua config.
- [ ] 9.3 Add the `SUPER+T` keybind to launch `theme-picker` (in the `hl.bind` block).
## 10. Wire into hosts
- [ ] 10.1 Add `../../modules/home-manager/theme` to `imports` in `hosts/jpporta-nixos/home.nix`.
- [ ] 10.2 Set `custom.theme.current = "gruvbox-dark";` (or another default) in the same file.
- [ ] 10.3 (Bonus, deck) Add `../../modules/home-manager/theme` to `imports` in `hosts/writter-deck/home.nix` and set `custom.theme.current`.
- [ ] 10.4 (Bonus, deck) Add a footserver-specific step to `theme-switch` that sends SIGUSR1 to the running footserver, or skips if none.
## 11. Verification
- [ ] 11.1 `home-manager switch` succeeds with no errors after all module changes.
- [ ] 11.2 `~/.config/colorschemes/.active` exists and resolves to the configured theme on first boot.
- [ ] 11.3 `theme-switch catppuccin` (or any other valid theme) swaps `.active`, updates `.current`, and emits each per-app reload signal.
- [ ] 11.4 Each themed app reflects the new palette within ~2 seconds (manual eyeball test).
- [ ] 11.5 `theme-switch does-not-exist` prints the list of valid themes and exits non-zero without modifying `.active`.
- [ ] 11.6 `theme-picker` opens rofi, lists all 11 themes with their mode suffix, and switches on selection.
- [ ] 11.7 `SUPER+T` in Hyprland opens the rofi picker.
- [ ] 11.8 After a logout/login, the desktop matches `.current` (the systemd oneshot ran).
- [ ] 11.9 Drop a placeholder `~/.config/colorschemes/.hook` that logs the theme name; verify it runs after a switch.
- [ ] 11.10 (Deck, if wired) `theme-switch` works on the deck and footserver reloads colors (or relaunches).