Author SHA1 Message Date
hermes-bot 47258a6317 fix: generate valid YAML in client.yml
The previous >- block scalar indentation was wrong, causing ntfy to fail
with 'yaml: line 7: could not find expected :'. Switched to inline
command strings which produce clean, parsable YAML.
2026-07-22 14:26:03 -03:00
hermes-bot 5db594897b fix: use single-line ExecStart string for systemd compat
The indented string ('''...''') included newlines + spaces in the systemd
unit file, which systemd rejected with 'expected entry key name but got /'.
Use a plain double-quoted string instead so ExecStart stays on one line.
2026-07-22 13:08:48 -03:00
hermes-bot e2abf72735 fix: use pkgs.ntfy-sh instead of pkgs.ntfy
pkgs.ntfy on nixos-26.05 resolves to ntfy 2.7.1 (a different tool that
also ships bin/ntfy but doesn't have a 'subscribe' subcommand).
pkgs.ntfy-sh is the correct ntfy.sh notification client at v2.23.0.
2026-07-22 10:20:51 -03:00
hermes-bot 2e52d33ba5 feat: add ntfy-notify home-manager module for desktop kanban notifications
Adds a new home-manager module `ntfy-notify` that:
- Installs the `ntfy` package
- Creates `~/.config/ntfy/client.yml` with subscription config
- Sets up a `systemd --user` service (`ntfy-notify.service`) that
  subscribes to ntfy topics and fires `notify-send` on each message
- Subscribes to the `joao-kanban` topic by default

Also wires the module into `hosts/jpporta-nixos/home.nix` with
`custom.ntfy-notify.enable = true`.
2026-07-22 10:07:10 -03:00
49 changed files with 548 additions and 2455 deletions
-2
View File
@@ -1,2 +0,0 @@
use flake
printf 'direnv: environment loaded\n'
-1
View File
@@ -1,2 +1 @@
.worktrees/ .worktrees/
/.direnv
Generated
+36 -36
View File
@@ -29,11 +29,11 @@
] ]
}, },
"locked": { "locked": {
"lastModified": 1782949081, "lastModified": 1772408722,
"narHash": "sha256-vp6Y/Grm98ESt6ceOkWiHWyZRDV3J1RID4w+6NWK9yA=", "narHash": "sha256-rHuJtdcOjK7rAHpHphUb1iCvgkU3GpfvicLMwwnfMT0=",
"owner": "hercules-ci", "owner": "hercules-ci",
"repo": "flake-parts", "repo": "flake-parts",
"rev": "17c9d6cdfc60c64f4ee8d306f9bc0b4ccb51481e", "rev": "f20dc5d9b8027381c474144ecabc9034d6a839a3",
"type": "github" "type": "github"
}, },
"original": { "original": {
@@ -70,11 +70,11 @@
"uv2nix": "uv2nix" "uv2nix": "uv2nix"
}, },
"locked": { "locked": {
"lastModified": 1785872157, "lastModified": 1784633374,
"narHash": "sha256-pO67XFTtQ7Y18u9k4UnMmiGwDcmVEZREUDt6IQt1rmQ=", "narHash": "sha256-kaRIIOZwlfLI3dwI5TgnyuLyG5RsuwM6jsuw3s5m5wc=",
"owner": "NousResearch", "owner": "NousResearch",
"repo": "hermes-agent", "repo": "hermes-agent",
"rev": "43717123ca1566a073270c5a61431e2e0e4a0211", "rev": "64702f8f91661149128ca1a721f7a0fd4c22113b",
"type": "github" "type": "github"
}, },
"original": { "original": {
@@ -90,11 +90,11 @@
] ]
}, },
"locked": { "locked": {
"lastModified": 1785119570, "lastModified": 1783740085,
"narHash": "sha256-Rgs2xKnGLFWQscxUaXX07oyZeuMDOHEbqDOsgliLFGM=", "narHash": "sha256-qajyHfZY29G2oEQk+uHxmsJcRoBUBXP9maTpFlwP/dI=",
"owner": "nix-community", "owner": "nix-community",
"repo": "home-manager", "repo": "home-manager",
"rev": "d4fd24667c8cbef124bb70a20380cab75ec8474d", "rev": "3cd22efe6471dc7365c822bd9ad73a21e55f38fb",
"type": "github" "type": "github"
}, },
"original": { "original": {
@@ -111,11 +111,11 @@
] ]
}, },
"locked": { "locked": {
"lastModified": 1785531816, "lastModified": 1783823409,
"narHash": "sha256-vkMnV0JIyw+g/NmcfoajlGaAO+9a0ezia+FZohQJrik=", "narHash": "sha256-OI4IkRjRXa1e7hYmCGJDPDq5H/kPwhsyoS80cNUF9fI=",
"owner": "nix-community", "owner": "nix-community",
"repo": "home-manager", "repo": "home-manager",
"rev": "bf9ce9fec78f95f374e8dd3b503863a3ec128ebe", "rev": "7566825d4652a1b885bd4ce65bd9e8def432fec9",
"type": "github" "type": "github"
}, },
"original": { "original": {
@@ -132,11 +132,11 @@
] ]
}, },
"locked": { "locked": {
"lastModified": 1785531816, "lastModified": 1782839684,
"narHash": "sha256-vkMnV0JIyw+g/NmcfoajlGaAO+9a0ezia+FZohQJrik=", "narHash": "sha256-vzs4SBgPsK4aNzlJR2PpFwtARazXMOxZonQnDz0YHxk=",
"owner": "nix-community", "owner": "nix-community",
"repo": "home-manager", "repo": "home-manager",
"rev": "bf9ce9fec78f95f374e8dd3b503863a3ec128ebe", "rev": "2a37d71bbe69e1522ddabf03a4cea0374958bdbe",
"type": "github" "type": "github"
}, },
"original": { "original": {
@@ -147,11 +147,11 @@
}, },
"nixpkgs": { "nixpkgs": {
"locked": { "locked": {
"lastModified": 1785318670, "lastModified": 1775036866,
"narHash": "sha256-dN6Ou5x/+23FZLEpYP3IffO+NyJFzUlGumt1uu3MMaY=", "narHash": "sha256-ZojAnPuCdy657PbTq5V0Y+AHKhZAIwSIT2cb8UgAz/U=",
"owner": "NixOS", "owner": "NixOS",
"repo": "nixpkgs", "repo": "nixpkgs",
"rev": "0954f7ee2f6bb3dc7d4e3d0d8bcb8fd4bde4cfc5", "rev": "6201e203d09599479a3b3450ed24fa81537ebc4e",
"type": "github" "type": "github"
}, },
"original": { "original": {
@@ -163,11 +163,11 @@
}, },
"nixpkgs-deck": { "nixpkgs-deck": {
"locked": { "locked": {
"lastModified": 1785828668, "lastModified": 1783776592,
"narHash": "sha256-8fsyqeO+mJqvIzeO4xIpgJe/f7MTbbVTEC6RT6WSXNs=", "narHash": "sha256-UgCQzxeWI75XM8G+hPrPh+MKzEPjG3SpAj7dtqSbksA=",
"owner": "NixOS", "owner": "NixOS",
"repo": "nixpkgs", "repo": "nixpkgs",
"rev": "e72e4f299401a3689d4b3d5fc6496b11db7064eb", "rev": "e7a3ca8092b61ff85b6a45bf863ea2b2d6a661b3",
"type": "github" "type": "github"
}, },
"original": { "original": {
@@ -179,11 +179,11 @@
}, },
"nixpkgs_2": { "nixpkgs_2": {
"locked": { "locked": {
"lastModified": 1785734586, "lastModified": 1783703440,
"narHash": "sha256-ODZkEK9Gy50yg6h98u7KkitZ3oc/uuTFK00bh1CRdNA=", "narHash": "sha256-O3/YajjWo001VUIgD8BwaRdSNLUFe7nZ1qV5TwhRBcw=",
"owner": "NixOS", "owner": "NixOS",
"repo": "nixpkgs", "repo": "nixpkgs",
"rev": "531670d871c0e29724a02f3cbcac170adc65b58c", "rev": "8f0500b9660505dc3cb647775fe9a978a74b5283",
"type": "github" "type": "github"
}, },
"original": { "original": {
@@ -230,11 +230,11 @@
] ]
}, },
"locked": { "locked": {
"lastModified": 1785115949, "lastModified": 1772555609,
"narHash": "sha256-8AM37BfyGaL2v/SZyg4PupRxJ01Y4htvM+WrTjWrPpo=", "narHash": "sha256-3BA3HnUvJSbHJAlJj6XSy0Jmu7RyP2gyB/0fL7XuEDo=",
"owner": "pyproject-nix", "owner": "pyproject-nix",
"repo": "build-system-pkgs", "repo": "build-system-pkgs",
"rev": "62c0d86027edb1c4f39a5facc09876348144f7c9", "rev": "c37f66a953535c394244888598947679af231863",
"type": "github" "type": "github"
}, },
"original": { "original": {
@@ -251,11 +251,11 @@
] ]
}, },
"locked": { "locked": {
"lastModified": 1784591072, "lastModified": 1772865871,
"narHash": "sha256-zP/WaDxrRu8GANZM61+V2LT/7ycEEdoyLWn7M6WzU7M=", "narHash": "sha256-/ZTSg97aouL0SlPHaokA4r3iuH9QzHVuWPACD2CUCFY=",
"owner": "pyproject-nix", "owner": "pyproject-nix",
"repo": "pyproject.nix", "repo": "pyproject.nix",
"rev": "e3b599ca2e7fcf93d4edf65d7f19bbf6491724f3", "rev": "e537db02e72d553cea470976b9733581bcf5b3ed",
"type": "github" "type": "github"
}, },
"original": { "original": {
@@ -302,11 +302,11 @@
] ]
}, },
"locked": { "locked": {
"lastModified": 1785277507, "lastModified": 1773039484,
"narHash": "sha256-9Tq3UDX2hD/aveW/HvkBlAmEwJTOlY5HQXJM+L5BGmE=", "narHash": "sha256-+boo33KYkJDw9KItpeEXXv8+65f7hHv/earxpcyzQ0I=",
"owner": "pyproject-nix", "owner": "pyproject-nix",
"repo": "uv2nix", "repo": "uv2nix",
"rev": "5a836d395cbf5fc22670eb98dd4aa4fc4d406977", "rev": "b68be7cfeacbed9a3fa38a2b5adc0cfb81d9bb1f",
"type": "github" "type": "github"
}, },
"original": { "original": {
@@ -323,11 +323,11 @@
] ]
}, },
"locked": { "locked": {
"lastModified": 1785830592, "lastModified": 1783866614,
"narHash": "sha256-4lqvnaiXkjbMCYUkgDqkkPaD40ln0r0bCIz5pGu17EU=", "narHash": "sha256-o2h5BfVz9MPIERBPVHtvQGilphtNn5+cCuqqfY5Lkq8=",
"owner": "0xc000022070", "owner": "0xc000022070",
"repo": "zen-browser-flake", "repo": "zen-browser-flake",
"rev": "3eb64fada36de0780703158ef9b1063ba697c24a", "rev": "51602966429e8ccae61324e56b51c37308d1b64e",
"type": "github" "type": "github"
}, },
"original": { "original": {
+1 -18
View File
@@ -28,13 +28,12 @@
}; };
outputs = outputs =
inputs@{ self, nixpkgs, ... }: inputs:
let let
systems = [ systems = [
"x86_64-linux" "x86_64-linux"
"aarch64-linux" "aarch64-linux"
]; ];
forAllSystems = nixpkgs.lib.genAttrs systems;
in in
{ {
nixosConfigurations.jpporta-nixos = inputs.nixpkgs.lib.nixosSystem { nixosConfigurations.jpporta-nixos = inputs.nixpkgs.lib.nixosSystem {
@@ -62,21 +61,5 @@
./hosts/writter-deck/home.nix ./hosts/writter-deck/home.nix
]; ];
}; };
devShells = forAllSystems (
system:
let
pkgs = nixpkgs.legacyPackages.${system};
in
{
default = pkgs.mkShell {
packages = with pkgs; [
nixfmt
nil # Nix LSP — your Neovim will pick it up via direnv
statix # linter, optional
];
};
}
);
}; };
} }
+21 -32
View File
@@ -13,7 +13,6 @@
../../modules/nixos/hyprland ../../modules/nixos/hyprland
../../modules/nixos/steam ../../modules/nixos/steam
../../modules/nixos/awsvpn ../../modules/nixos/awsvpn
../../modules/nixos/keyd
../../modules/nixos/tailscale ../../modules/nixos/tailscale
../../modules/nixos/wake-on-lan ../../modules/nixos/wake-on-lan
../../modules/nixos/hermes ../../modules/nixos/hermes
@@ -73,19 +72,6 @@
"x-systemd.device-timeout=10s" "x-systemd.device-timeout=10s"
]; ];
}; };
"/mnt/obsidian" = {
device = "//100.90.248.13/ObsidianVault";
fsType = "cifs";
options = [
"credentials=/etc/samba/obsidian-credentials"
"uid=1000"
"gid=100"
"noperm"
"x-systemd.automount"
"noatime"
];
};
}; };
# Networking # Networking
@@ -134,16 +120,11 @@
tailscale.enable = true; tailscale.enable = true;
wake-on-lan.enable = true; wake-on-lan.enable = true;
hermes.enable = true; hermes.enable = true;
keyd = {
enable = false;
internalIds = [ "1ea7:0907" ];
};
}; };
##------------------------------ ##------------------------------
programs = { programs = {
zsh.enable = true; zsh.enable = true;
nix-ld.enable = true;
}; };
# System Packages # System Packages
@@ -159,6 +140,7 @@
glib glib
gsettings-desktop-schemas gsettings-desktop-schemas
zip zip
yarn
]; ];
virtualisation.docker.enable = true; # docker, buildx virtualisation.docker.enable = true; # docker, buildx
@@ -184,6 +166,17 @@
jack.enable = true; # only if you use jack apps (reaper, etc.) jack.enable = true; # only if you use jack apps (reaper, etc.)
wireplumber.enable = true; wireplumber.enable = true;
}; };
keyd = {
enable = true;
keyboards.default = {
ids = [ "*" ]; # or specific vendor:product ids
settings = {
main = {
capslock = "overload(control, esc)"; # translate your default.conf here
};
};
};
};
postgresql.enable = true; postgresql.enable = true;
syncthing = { syncthing = {
enable = true; enable = true;
@@ -225,25 +218,21 @@
# OpenSSH: reachable through the trusted Tailscale interface, but not opened # OpenSSH: reachable through the trusted Tailscale interface, but not opened
# broadly on every network interface by the firewall. # broadly on every network interface by the firewall.
services = { services.openssh = {
openssh = { enable = true;
enable = true; openFirewall = true;
openFirewall = true; settings = {
settings = { PasswordAuthentication = false;
PasswordAuthentication = false; KbdInteractiveAuthentication = false;
KbdInteractiveAuthentication = false; PermitRootLogin = "no";
PermitRootLogin = "no"; AllowUsers = [ "jpporta" ];
AllowUsers = [ "jpporta" ];
};
}; };
gnome.gnome-keyring.enable = true;
}; };
programs.mosh = { programs.mosh = {
enable = true; enable = true;
openFirewall = false; openFirewall = false;
}; };
nixpkgs.config.allowUnfree = true; nixpkgs.config.allowUnfree = true;
system.stateVersion = "26.05"; system.stateVersion = "26.05";
} }
+13 -36
View File
@@ -1,3 +1,6 @@
Warning: Identity file /home/jpporta/.ssh/id_rsa not accessible: No such file or directory.
Warning: Identity file /home/jpporta/.ssh/id_rsa not accessible: No such file or directory.
Warning: Identity file /home/jpporta/.ssh/id_rsa not accessible: No such file or directory.
{ {
config, config,
pkgs, pkgs,
@@ -27,18 +30,12 @@
../../modules/home-manager/opencode ../../modules/home-manager/opencode
../../modules/home-manager/cedilla ../../modules/home-manager/cedilla
../../modules/home-manager/dictation ../../modules/home-manager/dictation
../../modules/home-manager/gif-recorder
../../modules/home-manager/nvim ../../modules/home-manager/nvim
../../modules/home-manager/pi ../../modules/home-manager/pi
../../modules/home-manager/tmux ../../modules/home-manager/tmux
../../modules/home-manager/openspec ../../modules/home-manager/openspec
../../modules/home-manager/dev-orchestrator
../../modules/home-manager/power-profiles ../../modules/home-manager/power-profiles
../../modules/home-manager/ntfy-notify ../../modules/home-manager/ntfy-notify
../../modules/home-manager/pinentry
../../modules/home-manager/swaync
../../modules/home-manager/bitwarden-cli
../../modules/home-manager/alarms-timers
inputs.zen-browser.homeModules.beta inputs.zen-browser.homeModules.beta
]; ];
@@ -90,7 +87,6 @@
enable = true; enable = true;
modelHash = "sha256-ZNGCtEC5jVIDxPm9VBVE2ExgUZbE97hF36EfsjWU0eI="; modelHash = "sha256-ZNGCtEC5jVIDxPm9VBVE2ExgUZbE97hF36EfsjWU0eI=";
}; };
gif-recorder.enable = true;
nvim.enable = true; nvim.enable = true;
pi.enable = true; pi.enable = true;
@@ -98,27 +94,9 @@
enable = true; enable = true;
}; };
openspec.enable = true; openspec.enable = true;
dev-orchestrator.enable = true;
power-profiles.enable = true; power-profiles.enable = true;
ntfy-notify = { ntfy-notify.enable = true;
enable = true;
server = "https://ntfy.joaoporta.com";
subscriptions = [
{
priority = "normal";
topic = "music-download";
title = "Music Downloaded";
}
{
priority = "critical";
topic = "joao-kanban";
title = "Kanban Update";
}
];
};
bitwarden.enable = true;
alarms-timers.enable = true;
}; };
home.packages = home.packages =
let let
@@ -136,10 +114,6 @@
fastfetch fastfetch
nodejs nodejs
firefox firefox
telegram-desktop
koreader
xorg.xauth
easyeffects
]; ];
programs = { programs = {
@@ -174,12 +148,15 @@
]; ];
}; };
gpg.enable = true; gpg.enable = true;
direnv = {
enable = true;
nix-direnv.enable = true;
silent = true;
};
}; };
custom.pinentry.enable = true; services.gpg-agent = {
enable = true;
pinentry.package = pkgs.pinentry-rofi;
enableSshSupport = true; # only if you use gpg keys as ssh keys
defaultCacheTtl = 3600;
maxCacheTtl = 86400;
};
services.swaync.enable = true;
} }
+1 -1
View File
@@ -17,7 +17,7 @@
enable = true; enable = true;
settings = { settings = {
font = { font = {
size = 11; size = 10.3;
normal = { normal = {
family = "BerkeleyMono Nerd Font Mono"; family = "BerkeleyMono Nerd Font Mono";
style = "Regular"; style = "Regular";
@@ -1,210 +0,0 @@
{
config,
lib,
pkgs,
...
}:
let
cfg = config.custom.alarms-timers;
runtime = pkgs.writeShellScriptBin "alarm-runtime" ''
set -efu
state_dir="''${XDG_STATE_HOME:-$HOME/.local/state}/alarm"
state_file="$state_dir/events.json"
lock_file="$state_dir/events.lock"
mkdir -p "$state_dir"
init_state() {
if [ ! -s "$state_file" ] || ! jq -e '(.nextId | numbers) and (.events | arrays)' "$state_file" >/dev/null 2>&1; then
printf '%s\n' '{"nextId":1,"events":[]}' > "$state_file"
fi
}
write_state() {
tmp=$(mktemp "$state_dir/events.XXXXXX")
jq -c . > "$tmp"
mv -f "$tmp" "$state_file"
}
usage() {
printf '%s\n' "usage: timer DURATION|list|cancel ID" " alarm [YYYY-MM-DD] HH:MM [DESCRIPTION...]" >&2
exit 2
}
die() {
printf 'alarm: %s\n' "$1" >&2
exit 2
}
parse_duration() {
[ -n "$1" ] && [[ "$1" != *[!0-9smh]* ]] || return 1
value=''${1%[smh]}
unit=''${1#''${value}}
[ -n "$value" ] && [ "$value" -gt 0 ] || return 1
case "$unit" in s) printf '%s\n' "$value" ;; m) printf '%s\n' "$((value * 60))" ;; h) printf '%s\n' "$((value * 3600))" ;; *) return 1 ;; esac
}
valid_time() { [[ "$1" =~ ^([01][0-9]|2[0-3]):[0-5][0-9]$ ]]; }
valid_date() { [[ "$1" =~ ^[0-9]{4}-[0-9]{2}-[0-9]{2}$ ]]; }
create_timer() {
seconds=$(parse_duration "$1") || die "duration must be a positive number followed by s, m, or h"
now=$(date +%s)
(
flock -x 9
init_state
id=$(jq -r '.nextId' "$state_file")
jq --argjson id "$id" --argjson due "$((now + seconds))" \
'.nextId = ($id + 1) | .events += [{id:$id,kind:"timer",due:$due,description:null}]' \
"$state_file" | write_state
printf '%s\n' "$id"
) 9>"$lock_file"
}
create_alarm() {
if valid_date "$1"; then
[ "$#" -ge 2 ] || usage
alarm_date=$1; alarm_time=$2; shift 2
valid_time "$alarm_time" || die "time must use HH:MM"
else
alarm_date=$(date +%F); alarm_time=$1; shift
valid_time "$alarm_time" || die "time must use HH:MM"
candidate=$(date -d "$alarm_date $alarm_time" +%s) || die "invalid local time"
[ "$candidate" -gt "$(date +%s)" ] || alarm_date=$(date -d tomorrow +%F)
fi
candidate=$(date -d "$alarm_date $alarm_time" +%s) || die "invalid date or time"
[ "$(date -d "@$candidate" +%F\ %H:%M)" = "$alarm_date $alarm_time" ] || die "invalid date or time"
[ "$candidate" -gt "$(date +%s)" ] || die "explicit alarm date is in the past"
description="$*"
(
flock -x 9
init_state
if jq -e --argjson due "$candidate" '.events[] | select(.kind == "alarm" and .due == $due)' "$state_file" >/dev/null; then
die "an alarm already exists at that time"
fi
id=$(jq -r '.nextId' "$state_file")
jq --argjson id "$id" --argjson due "$candidate" --arg description "$description" \
'.nextId = ($id + 1) | .events += [{id:$id,kind:"alarm",due:$due,description:(if $description == "" then null else $description end)}]' \
"$state_file" | write_state
printf '%s\n' "$id"
) 9>"$lock_file"
}
list_events() {
kind=$1
(
flock -s 9
init_state
if ! jq -e --arg kind "$kind" '.events | map(select(.kind == $kind)) | length > 0' "$state_file" >/dev/null; then
printf 'No %s events scheduled.\n' "$kind"
exit 0
fi
jq -r --arg kind "$kind" '
.events | map(select(.kind == $kind)) | sort_by(.due)[] |
"\(.id) \(if .kind == "timer" then ("due " + ( .due | todateiso8601)) else ("at " + (.due | strftime("%Y-%m-%d %H:%M"))) end)\(if .description then " - " + .description else "" end)"
' "$state_file"
) 9>"$lock_file"
}
cancel_event() {
kind=$1; id=$2
[[ "$id" =~ ^[0-9]+$ ]] || die "ID must be numeric"
(
flock -x 9
init_state
if ! jq -e --arg kind "$kind" --argjson id "$id" '.events[] | select(.id == $id and .kind == $kind)' "$state_file" >/dev/null; then
die "no $kind with ID $id"
fi
jq --arg kind "$kind" --argjson id "$id" '.events |= map(select(.id != $id or .kind != $kind))' "$state_file" | write_state
printf 'Cancelled %s %s.\n' "$kind" "$id"
) 9>"$lock_file"
}
notify_event() {
kind=$1; due=$2; description=$3
title=$(printf '%s' "$kind" | tr '[:lower:]' '[:upper:]')
body="Due $(date -d "@$due" '+%Y-%m-%d %H:%M')"
[ -n "$description" ] && body="$body - $description"
notify-send -u critical "$title" "$body" || true
timeout 1 pw-play "${pkgs.sound-theme-freedesktop}/share/sounds/freedesktop/stereo/alarm-clock-elapsed.oga" >/dev/null 2>&1 || true
}
scheduler() {
started=$(date +%s)
while :; do
now=$(date +%s)
event=$(mktemp)
(
flock -x 9
init_state
jq --argjson started "$started" --argjson now "$now" \
' .events |= map(select(.due >= $started or .due > $now))' "$state_file" | write_state
jq -c --argjson started "$started" --argjson now "$now" \
'[.events[] | select(.due >= $started and .due <= $now)] | sort_by(.due) | .[0] // empty' "$state_file" > "$event"
if [ -s "$event" ]; then
jq --argjson id "$(jq -r .id "$event")" '.events |= map(select(.id != $id))' "$state_file" | write_state
fi
) 9>"$lock_file"
if [ -s "$event" ]; then
kind=$(jq -r .kind "$event"); due=$(jq -r .due "$event"); description=$(jq -r '.description // empty' "$event")
rm -f "$event"
notify_event "$kind" "$due" "$description"
else
rm -f "$event"
fi
sleep 1
done
}
waybar() {
now=$(date +%s)
init_state
jq -cn --argjson now "$now" --slurpfile state "$state_file" '
($state[0].events | map(select((.id|numbers) and (.kind == "timer" or .kind == "alarm") and (.due|numbers) and .due >= $now)) | sort_by(.due) | .[0:3]) as $events |
{text: ($events | map(if .kind == "timer" then "󰔛 " + (if (.due - $now) < 60 then ((.due - $now)|tostring) else (((.due - $now) / 3600)|floor|tostring|if length < 2 then "0" + . else . end) + ":" + ((((.due - $now) % 3600) / 60)|floor|tostring|if length < 2 then "0" + . else . end) end) else "󰥔 " + (.due | strftime("%H:%M")) end) | join(" ")), tooltip: ($events | map((.kind + " " + (.due | strftime("%Y-%m-%d %H:%M")) + (if .description then " - " + .description else "" end))) | join("\n"))}
'
}
command=''${1:-}
case "$command" in
timer) [ "$#" -ge 2 ] || usage; case "$2" in list) list_events timer ;; cancel) [ "$#" = 3 ] || usage; cancel_event timer "$3" ;; *) [ "$#" = 2 ] || usage; create_timer "$2" ;; esac ;;
alarm) [ "$#" -ge 2 ] || usage; case "$2" in list) list_events alarm ;; cancel) [ "$#" = 3 ] || usage; cancel_event alarm "$3" ;; *) create_alarm ''${@:2} ;; esac ;;
scheduler) scheduler ;;
waybar) waybar ;;
*) usage ;;
esac
'';
timer = pkgs.writeShellScriptBin "timer" ''exec ${runtime}/bin/alarm-runtime timer "$@"'';
alarm = pkgs.writeShellScriptBin "alarm" ''exec ${runtime}/bin/alarm-runtime alarm "$@"'';
scheduler = pkgs.writeShellScriptBin "alarm-scheduler" ''exec ${runtime}/bin/alarm-runtime scheduler'';
waybar = pkgs.writeShellScriptBin "alarm-waybar" ''exec ${runtime}/bin/alarm-runtime waybar'';
in
{
options.custom.alarms-timers.enable = lib.mkEnableOption "terminal alarms and timers";
config = lib.mkIf cfg.enable {
home.packages = [ timer alarm scheduler waybar pkgs.jq pkgs.libnotify pkgs.pipewire pkgs.sound-theme-freedesktop ];
home.activation.alarmsTimersState = lib.hm.dag.entryAfter [ "writeBoundary" ] ''
run mkdir -p "$HOME/.local/state/alarm"
if [ ! -s "$HOME/.local/state/alarm/events.json" ]; then
run printf '%s\n' '{"nextId":1,"events":[]}' > "$HOME/.local/state/alarm/events.json"
fi
'';
systemd.user.services.alarm-scheduler = {
Unit = {
Description = "Terminal alarm and timer scheduler";
After = [ "graphical-session.target" ];
PartOf = [ "graphical-session.target" ];
};
Service = {
Type = "simple";
ExecStart = "${scheduler}/bin/alarm-scheduler";
Restart = "on-failure";
RestartSec = 2;
};
Install.WantedBy = [ "graphical-session.target" ];
};
};
}
@@ -37,6 +37,7 @@ in
# nitch # nitch
# ---- editors & dev tooling ---- # ---- editors & dev tooling ----
tree
ffmpeg ffmpeg
chromium chromium
neovim neovim
@@ -67,9 +68,15 @@ in
cargo cargo
php php
julia julia
eslint_d
golangci-lint
rust-analyzer
imagemagick imagemagick
ghostscript ghostscript
ripgrep ripgrep
gopls
typescript-language-server
nixfmt
# ---- cloud / infra ---- # ---- cloud / infra ----
awscli2 awscli2
@@ -1,70 +0,0 @@
{
lib,
config,
pkgs,
...
}:
let
bw = pkgs.writeShellApplication {
name = "bw";
runtimeInputs = with pkgs; [
rbw
fzf
wl-clipboard
libnotify
];
text = ''
set -euo pipefail
cmd="$(basename "$0")"
entry="$(rbw list --fields=id,name,user | fzf --with-nth=2.. --delimiter=$'\t')"
[ -z "$entry" ] && exit 0
id="$(printf '%s' "$entry" | cut -f1)"
case "$cmd" in
bwc) val="$(rbw code "$id")" ;;
bwu) val="$(rbw get --field username "$id")" ;;
bwp) val="$(rbw get "$id")" ;;
*) exit 1 ;;
esac
printf '%s' "$val" | wl-copy
notify-send -t 1200 "bw: copied $cmd"
'';
};
in
{
options.custom = {
bitwarden.enable = lib.mkEnableOption "enable bitwarden - CLI password manager";
};
config = lib.mkIf config.custom.bitwarden.enable {
programs.rbw = {
enable = true;
settings = {
email = "me@joaoporta.com";
lock_timeout = 7200;
pinentry = pkgs.pinentry-gnome3;
base_url = "https://pass.joaoporta.com";
};
};
home.packages =
let
mkBin =
name:
pkgs.symlinkJoin {
inherit name;
paths = [ bw ];
nativeBuildInputs = [ pkgs.makeWrapper ];
postBuild = "ln -sf ${bw}/bin/bw $out/bin/${name}";
};
in
builtins.map mkBin [
"bwp"
"bwc"
"bwu"
];
};
}
@@ -17,9 +17,6 @@
}; };
config = lib.mkIf config.custom.jpporta-calendars.enable { config = lib.mkIf config.custom.jpporta-calendars.enable {
home.packages = with pkgs; [
seahorse
];
programs = { programs = {
vdirsyncer.enable = true; vdirsyncer.enable = true;
@@ -1,16 +0,0 @@
{ lib, config, pkgs, ... }:
{
options.custom = {
dev-orchestrator.enable = lib.mkEnableOption "enable dev-orchestrator OpenSpec + OpenCode devQA loop with git worktrees";
};
config = lib.mkIf config.custom.dev-orchestrator.enable {
home.packages = [
(pkgs.writeShellApplication {
name = "dev-orchestrator";
runtimeInputs = with pkgs; [ git ];
text = builtins.readFile ../../../tooling/dev-orchestrator;
})
];
};
}
@@ -1,96 +0,0 @@
{
config,
lib,
pkgs,
...
}:
let
cfg = config.custom.gif-recorder;
gif-recorder = pkgs.writeShellApplication {
name = "gif-recorder";
runtimeInputs = with pkgs; [
coreutils
ffmpeg
slurp
wf-recorder
wl-clipboard
];
text = ''
runtime_dir="''${XDG_RUNTIME_DIR:-/tmp}"
state="$runtime_dir/gif-recorder.pid"
selection=""
video=""
gif_tmp=""
child_pid=""
stopped=0
cleanup() {
rm -f "$state" "$selection" "$video" "$gif_tmp"
}
stop_child() {
stopped=1
if [ -n "$child_pid" ]; then
kill -INT "$child_pid" 2>/dev/null || true
fi
}
trap cleanup EXIT
trap stop_child INT TERM
if [ -f "$state" ]; then
active_pid="$(cat "$state" 2>/dev/null || true)"
if [ -n "$active_pid" ] && kill -0 "$active_pid" 2>/dev/null; then
kill -INT "$active_pid" 2>/dev/null || true
exit 0
fi
rm -f "$state"
fi
echo "$$" > "$state"
selection="$(mktemp "$runtime_dir/gif-recorder-selection.XXXXXX")"
slurp > "$selection" &
child_pid="$!"
selection_status=0
wait "$child_pid" || selection_status="$?"
child_pid=""
if [ "$stopped" -eq 1 ] || [ "$selection_status" -ne 0 ]; then
exit 0
fi
geometry="$(cat "$selection")"
video="$(mktemp "$runtime_dir/gif-recorder.XXXXXX.mkv")"
wf-recorder --geometry "$geometry" -f "$video" &
child_pid="$!"
recorder_status=0
wait "$child_pid" || recorder_status="$?"
child_pid=""
if [ "$stopped" -ne 1 ] || { [ "$recorder_status" -ne 0 ] && [ "$recorder_status" -ne 130 ]; }; then
exit 1
fi
output_dir="$HOME/Pictures/Screenshots"
mkdir -p "$output_dir"
gif_tmp="$(mktemp "$runtime_dir/gif-recorder.XXXXXX.gif")"
ffmpeg -y -i "$video" \
-vf 'fps=15,scale=960:-1:flags=lanczos,split[s0][s1];[s0]palettegen=max_colors=256[p];[s1][p]paletteuse=dither=sierra2_4a' \
"$gif_tmp"
output="$output_dir/gif-$(date +%Y%m%d-%H%M%S-%N).gif"
mv "$gif_tmp" "$output"
gif_tmp=""
wl-copy --foreground --type image/gif < "$output" &
'';
};
in
{
options.custom.gif-recorder.enable = lib.mkEnableOption "Wayland region GIF recorder";
config = lib.mkIf cfg.enable {
home.packages = [ gif-recorder ];
};
}
@@ -240,7 +240,6 @@ in
hl.bind(mod_shft .. " + 2", hl.dsp.exec_cmd("hyprshot -m output -m DP-1 -o ~/Pictures/Screenshots")) hl.bind(mod_shft .. " + 2", hl.dsp.exec_cmd("hyprshot -m output -m DP-1 -o ~/Pictures/Screenshots"))
hl.bind(mod_shft .. " + 3", hl.dsp.exec_cmd("hyprshot -m window -m active -o ~/Pictures/Screenshots")) hl.bind(mod_shft .. " + 3", hl.dsp.exec_cmd("hyprshot -m window -m active -o ~/Pictures/Screenshots"))
hl.bind(mod_shft .. " + 4", hl.dsp.exec_cmd("hyprshot -m region -o ~/Pictures/Screenshots")) hl.bind(mod_shft .. " + 4", hl.dsp.exec_cmd("hyprshot -m region -o ~/Pictures/Screenshots"))
hl.bind(mod_shft .. " + 5", hl.dsp.exec_cmd("gif-recorder"))
-- ---------- OBS Recording ---------- -- ---------- OBS Recording ----------
hl.bind(mod_shft .. " + 9", hl.dsp.exec_cmd("obs-cmd recording toggle")) hl.bind(mod_shft .. " + 9", hl.dsp.exec_cmd("obs-cmd recording toggle"))
+2 -17
View File
@@ -6,16 +6,6 @@
}: }:
let let
cfg = config.custom.ntfy-notify; cfg = config.custom.ntfy-notify;
ntfy-notify = pkgs.writeShellApplication {
name = "ntfy-notify-authenticated";
runtimeInputs = with pkgs; [ ntfy-sh rbw ];
text = ''
username="$(rbw get --field username ntfy.joaoporta.com)"
password="$(rbw get ntfy.joaoporta.com)"
export NTFY_USER="$username:$password"
exec ntfy subscribe --from-config
'';
};
in in
{ {
options.custom.ntfy-notify = { options.custom.ntfy-notify = {
@@ -34,10 +24,6 @@ in
type = lib.types.str; type = lib.types.str;
description = "ntfy topic to subscribe to."; description = "ntfy topic to subscribe to.";
}; };
title = lib.mkOption {
type = lib.types.str;
description = "Notification title for this topic";
};
priority = lib.mkOption { priority = lib.mkOption {
type = lib.types.enum [ "low" "normal" "critical" ]; type = lib.types.enum [ "low" "normal" "critical" ];
default = "normal"; default = "normal";
@@ -49,7 +35,6 @@ in
{ {
topic = "joao-kanban"; topic = "joao-kanban";
priority = "critical"; priority = "critical";
title = "Kanban Update";
} }
]; ];
description = '' description = ''
@@ -71,7 +56,7 @@ in
subscribe: subscribe:
'' + lib.concatMapStringsSep "\n" (sub: '' + lib.concatMapStringsSep "\n" (sub:
let let
cmd = "${lib.getExe pkgs.libnotify} -a ntfy -u ${sub.priority} \"${sub.title}\" \"$m\""; cmd = "notify-send -a Kanban -u ${sub.priority} \"Kanban Update\" \"$m\"";
in in
" - topic: ${cfg.server}/${sub.topic}\n" " - topic: ${cfg.server}/${sub.topic}\n"
+ " command: ${cmd}" + " command: ${cmd}"
@@ -86,7 +71,7 @@ in
}; };
Service = { Service = {
Type = "simple"; Type = "simple";
ExecStart = "${ntfy-notify}/bin/ntfy-notify-authenticated"; ExecStart = "${pkgs.ntfy-sh}/bin/ntfy subscribe";
Restart = "on-failure"; Restart = "on-failure";
RestartSec = 10; RestartSec = 10;
}; };
@@ -38,12 +38,6 @@ return {
desc = "[O]bsidian [T]emplate", desc = "[O]bsidian [T]emplate",
mode = "n", mode = "n",
}, },
{
"<leader>ow",
":ObsidianWorkspace<CR>",
desc = "[O]bsidian [W]orkspace",
mode = "n",
},
}, },
opts = { opts = {
@@ -125,15 +119,15 @@ return {
new_notes_location = "notes_subdir", new_notes_location = "notes_subdir",
workspaces = { workspaces = {
{ {
name = "hermes", name = "personal",
path = "/mnt/obsidian", path = "~/docs/personal",
}, },
}, },
daily_notes = { daily_notes = {
folder = "Journal", folder = "journal",
date_format = "%Y-%m-%d", date_format = "%Y-%m-%d",
default_tags = { "journal" }, default_tags = { "journal" },
template = "Daily Note", template = "journal"
}, },
completion = { completion = {
nvim_cmp = true, nvim_cmp = true,
@@ -141,30 +135,9 @@ return {
}, },
templates = { templates = {
subdir = "Templates", subdir = "templates",
date_format = "%Y-%m-%d", date_format = "%Y-%m-%d",
time_format = "%H:%M", time_format = "%H:%M",
substitutions = {
week = function()
return os.date("%W", os.time())
end,
day = function()
local d = os.date("%w", os.time())
local r = {
["0"] = "Sunday",
["1"] = "Monday",
["2"] = "Tuesday",
["3"] = "Wednesday",
["4"] = "Thursday",
["5"] = "Friday",
["6"] = "Saturday",
}
return r[d]
end,
full_date = function()
return os.date("%A, %B %d, %Y", os.time())
end,
},
}, },
picker = { picker = {
name = "telescope.nvim", name = "telescope.nvim",
@@ -2,14 +2,6 @@ return {
"michaelrommel/nvim-silicon", "michaelrommel/nvim-silicon",
lazy = true, lazy = true,
cmd = "Silicon", cmd = "Silicon",
keys = {
{
"<leader>s",
"<cmd>Silicon<CR>",
desc = "[C]ode [S]elfie",
mode = "v",
},
},
config = function() config = function()
local get_visual = function() local get_visual = function()
local curpos = vim.fn.getcurpos() local curpos = vim.fn.getcurpos()
@@ -18,27 +10,21 @@ return {
if one.row == two.row then if one.row == two.row then
if one.col > two.col then if one.col > two.col then
one, two = two, one local tmp = one
one = two
two = tmp
end end
elseif one.row > two.row then elseif one.row > two.row then
one, two = two, one local tmp = one
one = two
two = tmp
end end
two.col = two.col + 1 two.col = two.col + 1
return one.row return one.row
end end
require("nvim-silicon").setup({ require("nvim-silicon").setup({
font = "Berkeley Mono Nerd Font Mono=24", font = "JetBrainsMono Nerd Font=24",
theme = "gruvbox-dark",
background = "#bdae93",
pad_horiz = 100,
pad_vert = 80,
line_pad = 2,
shadow_blur_radius = 24,
shadow_offset_x = 10,
shadow_offset_y = 10,
shadow_color = "#1d2021",
no_window_controls = true, no_window_controls = true,
no_line_number = false, no_line_number = false,
line_offset = get_visual() + 1, line_offset = get_visual() + 1,
@@ -50,5 +36,6 @@ return {
return vim.bo.filetype return vim.bo.filetype
end, end,
}) })
vim.keymap.set("v", "<leader>cs", "<cmd>'<,'>Silicon<CR>", { desc = "[C]ode [S]elfie" })
end, end,
} }
+1
View File
@@ -11,6 +11,7 @@
worktrunk worktrunk
sesh sesh
sl sl
ntfy-sh
signal-cli signal-cli
]; ];
}; };
-37
View File
@@ -1,37 +0,0 @@
{
lib,
config,
pkgs,
...
}:
let
pinentry-wrapper = pkgs.writeShellScriptBin "pinentry-wrapper" ''
# ponytail: gpg-agent's env is captured at boot (often on tty1), so SSH_* checks
# from the agent miss SSH sessions. Fall back to curses when no usable display
# is available, since rofi needs WAYLAND_DISPLAY/DISPLAY the agent may not have.
if [ -n "''${SSH_CONNECTION:-}" ] \
|| [ -n "''${SSH_TTY:-}" ] \
|| [ -z "''${WAYLAND_DISPLAY:-}" ] && [ -z "''${DISPLAY:-}" ]; then
exec ${pkgs.pinentry-curses}/bin/pinentry-curses "$@"
else
exec ${pkgs.pinentry-rofi}/bin/pinentry-rofi "$@"
fi
'';
in {
options.custom.pinentry = {
enable = lib.mkEnableOption "adaptive pinentry: rofi on TTY, curses on SSH";
};
config = lib.mkIf config.custom.pinentry.enable {
home.packages = [ pinentry-wrapper ];
services.gpg-agent = {
enable = true;
pinentry.package = pinentry-wrapper;
enableSshSupport = true;
defaultCacheTtl = 3600;
maxCacheTtl = 86400;
};
};
}
+452 -23
View File
@@ -1,44 +1,55 @@
{ lib, config, ... }: { lib, config, ... }:
let
background = "#282828"; # bg0 — main dark background
background-alt = "#3c3836"; # bg1 — slightly lighter panels/buttons
background-sec = "#1d2021"; # bg0_h — darker, for mpris/volume contrast
text = "#ebdbb2"; # fg1 — main foreground
selected = "#83a598"; # blue — accent for borders/active states
urgent = "#fb4934"; # bright red — critical notifications
hover = "#504945"; # bg2 — hover highlight
in
{ {
options.custom = { options.custom = {
swaync.enable = lib.mkOption { swaync.enable = lib.mkEnableOption "enable swaync - color cat alternative";
type = lib.types.bool;
default = true;
description = "enable swaync - notification";
};
}; };
config = lib.mkIf config.custom.swaync.enable { config = lib.mkIf config.custom.swaync.enable {
services.swaync = { services.swaync = {
enable = true; enable = true;
settings = { settings = {
positionX = "center"; positionX = "right";
positionY = "top"; positionY = "top";
cssPriority = "user"; cssPriority = "user";
control-center-width = 360; control-center-width = 380;
control-center-height = 760; control-center-height = 860;
control-center-margin-top = 2; control-center-margin-top = 2;
control-center-margin-bottom = 2; control-center-margin-bottom = 2;
control-center-margin-right = 1; control-center-margin-right = 1;
control-center-margin-left = 0; control-center-margin-left = 0;
notification-window-width = 380; notification-window-width = 400;
notification-icon-size = 40; notification-icon-size = 48;
notification-body-image-height = 120; notification-body-image-height = 160;
notification-body-image-width = 160; notification-body-image-width = 200;
timeout = 6; timeout = 4;
timeout-low = 4; timeout-low = 2;
timeout-critical = 8; timeout-critical = 6;
fit-to-screen = false; fit-to-screen = false;
keyboard-shortcuts = true; keyboard-shortcuts = true;
image-visibility = "when-available"; image-visibility = "when-available";
transition-time = 250; transition-time = 200;
hide-on-clear = true; hide-on-clear = true;
hide-on-action = false; hide-on-action = false;
script-fail-notify = true; script-fail-notify = true;
scripts = {
example-script = {
exec = "echo 'Do something...'";
urgency = "Normal";
};
};
notification-visibility = { notification-visibility = {
example-name = { example-name = {
state = "muted"; state = "muted";
@@ -101,13 +112,431 @@
}; };
}; };
}; };
}; style = ''
* {
color: ${text};
background-color: ${background}
all: unset;
font-size: 12px;
font-family: "BerkeleyMono Nerd Font Propo";
transition: 200ms;
}
xdg.configFile."swaync/style.css".source = .notification-row {
config.lib.file.mkOutOfStoreSymlink outline: none;
"${config.home.homeDirectory}/.config/colorschemes/.active/swaync/style.css"; margin: 0;
xdg.configFile."swaync/colors.css".source = padding: 0px;
config.lib.file.mkOutOfStoreSymlink }
"${config.home.homeDirectory}/.config/colorschemes/.active/swaync/colors.css";
.floating-notifications.background .notification-row .notification-background {
background: alpha(${background}, 0.9);
box-shadow: 0 0 8px 0 rgba(0, 0, 0, 0.6);
border: 1px solid ${selected};
border-radius: 24px;
margin: 16px;
padding: 0;
}
.floating-notifications.background
.notification-row
.notification-background
.notification {
padding: 6px;
border-radius: 12px;
}
.floating-notifications.background
.notification-row
.notification-background
.notification.critical {
border: 2px solid ${urgent};
}
.floating-notifications.background
.notification-row
.notification-background
.notification
.notification-content {
margin: 14px;
}
.floating-notifications.background
.notification-row
.notification-background
.notification
> *:last-child
> * {
min-height: 3.4em;
}
.floating-notifications.background
.notification-row
.notification-background
.notification
> *:last-child
> *
.notification-action {
border-radius: 8px;
background-color: ${background-alt}};
margin: 6px;
border: 1px solid transparent;
}
.floating-notifications.background
.notification-row
.notification-background
.notification
> *:last-child
> *
.notification-action:hover {
background-color: ${hover};
border: 1px solid ${selected};
}
.floating-notifications.background
.notification-row
.notification-background
.notification
> *:last-child
> *
.notification-action:active {
background-color: ${selected};
color: ${background};
}
.image {
margin: 10px 20px 10px 0px;
}
.summary {
font-weight: 800;
font-size: 1rem;
}
.body {
font-size: 0.8rem;
}
.floating-notifications.background
.notification-row
.notification-background
.close-button {
margin: 6px;
padding: 2px;
border-radius: 6px;
background-color: ${background};
border: 1px solid transparent;
}
.floating-notifications.background
.notification-row
.notification-background
.close-button:hover {
background-color: ${selected};
}
.floating-notifications.background
.notification-row
.notification-background
.close-button:active {
background-color: ${selected};
color: ${background};
}
.notification.critical progress {
background-color: ${selected};
}
.notification.low progress,
.notification.normal progress {
background-color: ${selected};
}
* {
color: ${text};
all: unset;
font-size: 12px;
font-family: "BerkeleyMono Nerd Font Propo";
transition: 200ms;
}
/* Avoid 'annoying' backgroud */
.blank-window {
background: transparent;
}
/* CONTROL CENTER ------------------------------------------------------------------------ */
.control-center {
background: alpha(${background}, 0.7);
border-radius: 24px;
border: 1px solid ${selected};
box-shadow: 0 0 10px 0 rgba(0, 0, 0, 0.6);
margin: 18px;
padding: 12px;
}
/* Notifications */
.control-center .notification-row .notification-background,
.control-center
.notification-row
.notification-background
.notification.critical {
background-color: ${background-alt};
border-radius: 16px;
margin: 4px 0px;
padding: 4px;
}
.control-center
.notification-row
.notification-background
.notification.critical {
color: ${urgent};
}
.control-center
.notification-row
.notification-background
.notification
.notification-content {
margin: 6px;
padding: 8px 6px 2px 2px;
}
.control-center
.notification-row
.notification-background
.notification
> *:last-child
> * {
min-height: 3.4em;
}
.control-center
.notification-row
.notification-background
.notification
> *:last-child
> *
.notification-action {
background: alpha(${selected}, 0.6);
color: ${text};
border-radius: 12px;
margin: 6px;
}
.control-center
.notification-row
.notification-background
.notification
> *:last-child
> *
.notification-action:hover {
background: ${selected};
}
.control-center
.notification-row
.notification-background
.notification
> *:last-child
> *
.notification-action:active {
background-color: ${selected};
}
/* Buttons */
.control-center .notification-row .notification-background .close-button {
background: transparent;
border-radius: 6px;
color: ${text};
margin: 0px;
padding: 4px;
}
.control-center .notification-row .notification-background .close-button:hover {
background-color: ${selected};
}
.control-center
.notification-row
.notification-background
.close-button:active {
background-color: ${selected};
}
progressbar,
progress,
trough {
border-radius: 12px;
}
progressbar {
background-color: rgba(255, 255, 255, 0.1);
}
/* Notifications expanded-group */
.notification-group {
margin: 2px 8px 2px 8px;
}
.notification-group-headers {
font-weight: bold;
font-size: 1.25rem;
color: ${text};
letter-spacing: 2px;
}
.notification-group-icon {
color: ${text};
}
.notification-group-collapse-button,
.notification-group-close-all-button {
background: transparent;
color: ${text};
margin: 4px;
border-radius: 6px;
padding: 4px;
}
.notification-group-collapse-button:hover,
.notification-group-close-all-button:hover {
background: ${hover};
}
/* WIDGETS --------------------------------------------------------------------------- */
/* Notification clear button */
.widget-title {
font-size: 1.2em;
margin: 6px;
}
.widget-title button {
background: ${background-alt};
border-radius: 6px;
padding: 4px 16px;
}
.widget-title button:hover {
background-color: ${hover};
}
.widget-title button:active {
background-color: ${selected};
}
/* Do not disturb */
.widget-dnd {
margin: 6px;
font-size: 1.2rem;
}
.widget-dnd > switch {
background: ${background-alt};
font-size: initial;
border-radius: 8px;
box-shadow: none;
padding: 2px;
}
.widget-dnd > switch:hover {
background: ${hover};
}
.widget-dnd > switch:checked {
background: ${selected};
}
.widget-dnd > switch:checked:hover {
background: ${hover};
}
.widget-dnd > switch slider {
background: ${text};
border-radius: 6px;
}
/* Buttons menu */
.widget-buttons-grid {
font-size: x-large;
padding: 6px 2px;
margin: 6px;
border-radius: 12px;
background: ${background-alt};
}
.widget-buttons-grid > flowbox > flowboxchild > button {
margin: 4px 10px;
padding: 6px 12px;
background: transparent;
border-radius: 8px;
}
.widget-buttons-grid > flowbox > flowboxchild > button:hover {
background: ${hover};
}
/* Music player */
.widget-mpris {
background: ${background-alt};
border-radius: 16px;
color: ${text};
margin: 20px 6px;
}
/* NOTE: Background need *opacity 1* otherwise will turn into the album art blurred */
.widget-mpris-player {
background-color: ${background-sec};
border-radius: 22px;
padding: 6px 14px;
margin: 6px;
}
.widget-mpris > box > button {
color: ${text};
border-radius: 20px;
}
.widget-mpris button {
color: alpha(${text}, 0.6);
}
.widget-mpris button:hover {
color: ${text};
}
.widget-mpris-album-art {
border-radius: 16px;
}
.widget-mpris-title {
font-weight: 700;
font-size: 1rem;
}
.widget-mpris-subtitle {
font-weight: 500;
font-size: 0.8rem;
}
picture.mpris-background {
opacity: 0;
}
/* Volume */
.widget-volume {
background: ${background-sec};
color: ${background};
padding: 4px;
margin: 6px;
border-radius: 6px;
}
'';
};
}; };
} }
+1 -1
View File
@@ -29,7 +29,7 @@
oh-my-zsh = { oh-my-zsh = {
enable = true; enable = true;
plugins = [ "git" "jj" "rbw" ]; plugins = [ "git" "jj" ];
theme = ""; theme = "";
}; };
-46
View File
@@ -1,46 +0,0 @@
{
config,
lib,
pkgs,
...
}:
let
cfg = config.custom.keyd;
defaultRemap = {
main = {
capslock = "overload(control, esc)";
};
};
defaultNoop = {
main = { };
};
in
{
options.custom.keyd = {
enable = lib.mkEnableOption "keyd (system-wide key remapping daemon)";
# Vendor:product IDs that get the capslock overload. See `keyd monitor`.
internalIds = lib.mkOption {
type = lib.types.listOf lib.types.str;
default = [ "1ea7:0907" ];
example = [ "1ea7:0907" ];
description = "Device IDs to apply the capslock overload to (matched against keyd's id list).";
};
};
config = lib.mkIf cfg.enable {
services.keyd = {
enable = true;
keyboards = {
internal = {
ids = cfg.internalIds;
settings = defaultRemap;
};
default = {
ids = [ "*" ];
settings = defaultNoop;
};
};
};
};
}
@@ -1,2 +0,0 @@
schema: spec-driven
created: 2026-07-23
@@ -1,144 +0,0 @@
## Context
The user's setup is Hyprland on `jpporta-nixos` with swaync as the notification daemon. The current swaync configuration is split into two parts:
1. **`modules/home-manager/swaync/default.nix`** — the only place where swaync is configured. It inlines a ~400-line `style.css` string with hardcoded gruvbox colors (`#282828`, `#ebdbb2`, `#83a598`, `#fb4934`, `#504945`, `#fabd2f`) and a flat, square-ish layout. The settings block sets `positionX = "right"`, `positionY = "top"`, and a 360px-wide notification column with no corner radius beyond 14px.
2. **`modules/home-manager/hyprland/default.nix`** (lines 107116) — already adds blur layers for `swaync-notification-window` and `swaync-control-center`, so the current design *intends* to be glassy but the CSS doesn't lean into it (no `alpha()`, low border-radius, no urgency-based color).
The user wants:
- A new "Groovebox" theme: dark, colorful, glassy.
- iOS-style notification cards: rounded, icon left, title + truncated body right, timestamp in small caps top-right, urgency-coded title color + border accent.
- Notification tray (the persistent control center) in the same dark glassy palette.
- Notifications pop from the **bottom-left** instead of top-right.
- A small `ponytail:` mindset: shipment is one new theme (`groovebox`) wired through the new layout, not 11 themes. Other themes get ported later.
The archived `2026-07-13-theme-switch` change designed the layout (`~/.config/colorschemes/<theme>/<app>/` + `.active` symlink + out-of-store symlinks from the Nix module) but was never implemented for swaync. This change ships the layout for swaync, with Groovebox as the first concrete theme so the user can see the result immediately.
Constraints:
- Nix home-manager on NixOS — the live reload path is `swaync-client --reload-css`, not a rebuild. The module must not bake colors.
- The active theme directory is expected to be reachable via `~/.config/colorschemes/.active` (a symlink set by the future `theme-switch` script). Since that script doesn't exist yet, this change places `groovebox` at `~/.config/colorschemes/groovebox/` and the Nix module points at `.active` — first-time setup also requires `ln -sfn ~/.config/colorschemes/groovebox ~/.config/colorschemes/.active` (documented in the change notes).
- The `~/.config/colorschemes/<theme>/` directory is currently empty. There is no `theme-switch` script yet. This change ships the dotfiles for one theme and expects the user (or a follow-up change) to wire `.active`.
## Goals / Non-Goals
**Goals:**
- iOS-style notification cards: large border-radius (1620px), transparent dark background with alpha + blur, horizontal layout (icon left, summary + body right), small-caps timestamp in the top-right corner.
- Urgency-colored title and border: `low` = cyan, `normal` = white, `critical` = red. Applies in both the floating notification and the control center.
- Control center (notification tray) in the same glassy dark palette, larger border-radius (24px), transparent background, bottom-left position.
- Notifications pop from the bottom-left (`positionX = "left"`, `positionY = "bottom"`).
- Swaync config + stylesheet live in `~/.config/colorschemes/groovebox/swaync/` as dotfiles, not in the Nix store. The Nix module is a thin wrapper that `xdg.configFile`s out-of-store symlinks.
- `swaync-client --reload-css` continues to be the live-reload path; no rebuild required for tweaks.
**Non-Goals:**
- The `theme-switch` script itself, the `theme-picker`, the rofi integration, the boot-time `theme-apply.service`, the wallpaper hook. Those are the next change.
- Porting the existing 14 themes (`gruvbox-dark`, `catppuccin-mocha-dark`, etc.) into the new layout. Just `groovebox` ships here.
- gsettings / darkman integration changes.
- Hyprland blur rules: the existing `swaync-notification-window` + `swaync-control-center` blur rules already target the right namespaces; they continue to work.
- Changing the `font-family` ("BerkeleyMono Nerd Font Propo") or base font size — those stay.
- New external dependencies. No new packages.
- The CSS `all: unset` reset on `*` is kept from the current style (it works fine with swaync's GTK widgets and avoids inherited GTK theme leaks).
## Decisions
### D1. Two-file split: `style.css` (layout) + `colors.css` (palette)
The current Nix inline `style.css` and the thin `colors.css` from the theme-switch seeds get separate files. **Why:** colors are swapped per theme; layout is shared across all future themes. Other future themes can `cp groovebox/style.css` and just rewrite `colors.css`. **Alternative considered:** one file, with the colors inlined as CSS variables — fine, but the seeds archive already used a two-file split, so this stays consistent with the established convention.
### D2. Layout values live in `style.css`, palette lives in `colors.css`
`style.css` references palette via CSS custom properties: `--accent-critical`, `--accent-normal`, `--accent-low`, `--bg`, `--bg-alt`, `--text`, `--hover`, `--border`. `colors.css` defines them. **Why:** keeps the layout file theme-agnostic so other themes can reuse it. **Alternative:** put color literals in `style.css` — tied to one theme, defeats the point.
### D3. Out-of-store symlink for both files (the archived theme-switch pattern)
`xdg.configFile."swaync/style.css".source = config.lib.file.mkOutOfStoreSymlink "${config.home.homeDirectory}/.config/colorschemes/.active/swaync/style.css";` and the same for `colors.css`. **Why:** the user's home dir is the source of truth. The Nix store never has the colors, so live edits + `swaync-client --reload-css` work without rebuild. **Alternative:** `text = builtins.readFile ...` — same effect for static configs, but breaks the moment the user edits the file: home-manager would silently overwrite on the next activation. The symlink is correct.
### D4. Active theme pointer is the user's responsibility (for this change)
The Nix module points at `~/.config/colorschemes/.active/swaync/`. If `.active` is missing, swaync ends up with a broken symlink and hyprland's swaync exec (`hl.exec_cmd("swaync")`) still starts the daemon, but the CSS fails to load. Two options considered:
- **Fallback: ship a default symlink in the Nix module** (`xdg.configFile."swaync/style.css".source = ... "${current}/style.css"` with `current = "groovebox"` if `.active` is missing). Cleaner first-run UX, but adds a Nix string that says "groovebox" — exactly the kind of hardcoding this change is trying to *remove*. **Rejected.**
- **Documented one-liner on first install.** First-run setup runs `mkdir -p ~/.config/colorschemes && ln -sfn ~/.config/colorschemes/groovebox ~/.config/colorschemes/.active && swaync-client --reload-css`. This is fine because it's a one-time setup, and the same command is what `theme-switch` will eventually own. **Chosen.**
The change notes in `tasks.md` will spell out the first-run command.
### D5. Urgency → color via class selectors, not via inline `style=`
Swaync adds CSS classes `.low`, `.normal`, `.critical` on the `.notification` element. The CSS reads:
```css
.notification.low .summary { color: var(--accent-low); }
.notification.critical .summary { color: var(--accent-critical); }
.notification.critical .notification-background { border-color: var(--accent-critical); }
```
**Why:** swaync owns the class assignment; CSS owns the color. Theme swaps via `colors.css` alone. **Alternative considered:** inline `style` (e.g. `style="border-color: ..."`) — not supported by swaync's markup on the notification wrapper. Classes are the right path.
### D6. Timestamp displayed via the `.time` label, top-right
Swaync renders a `.time` label inside the notification header by default. The CSS positions it in the top-right corner of the notification card, small caps, dimmed (`color: alpha(var(--text), 0.55)`). **Why:** matches iOS, and the existing `widget-config.label.max-lines = 1` already constrains the body width so it doesn't compete with the timestamp.
### D7. Control center border-radius 24px; notification cards 16px
iOS uses a large radius on the full tray and a slightly smaller radius on individual cards. 16px gives a "tile" feel; 24px on the tray gives a "floating surface" feel. **Why:** matches iOS reference aesthetics. **Alternative:** uniform 16px — looks flat and grid-like, not iOS.
### D8. `transition-time` bumped to 250ms; `transition` CSS line kept
Swaync's `transition-time` setting controls the slide-in animation. iOS-style feels good at 200300ms. We bump to 250ms. The existing CSS `transition: 200ms` inside `*` is a no-op (swaync uses transitions on actual property changes, not on `all`). We keep it for parity.
### D9. Notification close button + timestamp share the top-right corner
The current CSS puts the close button at the top-right. The new layout puts the close button to the **right of the timestamp** (still top-right, but the timestamp is the inner-most element). iOS shows the app icon on the trailing edge of the header; we use a close `×` button instead, since swaync doesn't auto-inject a close for floating notifications.
### D10. Color palette: invented Groovebox palette
| Token | Hex | Role |
|---|---|---|
| `--bg` | `#14171c` | deepest background |
| `--bg-alt` | `#1a1d24` | notification card / widget tile |
| `--bg-glass` | `rgba(20, 23, 28, 0.72)` | card body with alpha |
| `--text` | `#e6e6e6` | main foreground |
| `--text-dim` | `rgba(230, 230, 230, 0.55)` | timestamp, secondary |
| `--hover` | `#25282f` | hover highlight |
| `--accent-low` | `#5cd6ff` | low urgency (cool, calm) |
| `--accent-normal` | `#e6e6e6` | normal urgency (white-ish, matches iOS) |
| `--accent-critical` | `#ff5e7a` | critical urgency (loud red) |
**Why:** dark base, vibrant accents, follows the "colorful but dark" Groovebox vibe. The user can edit `colors.css` later to taste. **Alternative:** reuse gruvbox-dark's palette — defeats the point of a new theme.
## Risks / Trade-offs
- **First-run gotcha: `.active` symlink doesn't exist yet.** → Documented in `tasks.md` and in the change notes. The Nix module fails gracefully on `home-manager switch` (the symlink target just doesn't exist yet), so the user runs the one-liner manually. → **Mitigation:** the `theme-switch` change will own this; for now, one `ln -sfn` is acceptable.
- **CSS `all: unset` reset is aggressive and may break third-party widget styles.** Current code already has it; we keep it. Users who want fancier widget styling can override per-widget. → **Mitigation:** none needed (pre-existing behavior).
- **`transition-time = 250ms` may feel sluggish on slow hardware.** → **Mitigation:** documented as a tunable value in `colors.css` comments; the user can dial it back via `~/.config/swaync/config.json` (rebuilt by home-manager, but the change is small).
- **`background: alpha(...)` for the glass effect depends on the compositor passing blur through.** If hyprland's blur rules drift, the glass effect disappears. → **Mitigation:** the existing `swaync-notification-window` and `swaync-control-center` blur rules in `modules/home-manager/hyprland/default.nix` are untouched and still apply. If the user disables blur in hyprland, the card still has alpha + dark background, so it doesn't become invisible.
- **The `style.css` shell assumes class names `.low`, `.normal`, `.critical` survive swaync upgrades.** Swaync has used these classes for years; the API is stable. → **Mitigation:** if a future swaync release renames a class, the symptom is "urgency colors stop working" — visible immediately, fixable in one CSS line.
- **Out-of-store symlinks blow away if the user `rm -rf ~/.config/colorschemes/`.** → **Mitigation:** the `.active` symlink and theme dirs are dotfiles, versioned in `dotfiles/` (already the convention for swaync-alacritty-etc). A `git pull` in `~/dotfiles` restores them.
- **One theme shipped, not 14.** → **Mitigation:** the layout + colors split + symlink infra is now in place; porting the other themes is a follow-up cost (~10 minutes per theme: `cp -r groovebox/seeds/<theme>`, edit `colors.css`). Documented in the change notes as expected follow-up.
## Migration Plan
1. Apply the change: `home-manager switch --flake .#jpporta-nixos`.
2. First-run setup (one-time, also documented in `tasks.md`):
```sh
mkdir -p ~/.config/colorschemes
ln -sfn ~/.config/colorschemes/groovebox ~/.config/colorschemes/.active
swaync-client --reload-css
```
3. Verify visually: trigger a test notification with `gdbus call --session --dest org.freedesktop.Notifications --object-path /org/freedesktop/Notifications --method org.freedesktop.Notifications.Notify swaync-test 0 '' Hello Groovebox "Test" '[]' '{}' 4000` and confirm the card appears bottom-left with the new look.
4. Rollback: revert to the previous Nix module (the old one had the inline `style.css`). The dotfiles in `~/.config/colorschemes/groovebox/` are tracked separately and don't affect rollback.
## Open Questions
- None blocking. The Groovebox palette is invented; the user can tweak `colors.css` after the first run.
- The `theme-switch` script is the natural follow-up change and will own `.active` symlink management from there.
@@ -1,38 +0,0 @@
## Why
The current swaync styling is hardcoded in `modules/home-manager/swaync/default.nix` with a single gruvbox palette and a flat, square-ish look. The user wants a new "Groovebox" theme that feels like iOS notifications — glassy, rounded, with an app icon, title, truncated body, and a small timestamp — and a notification tray that pops from the bottom-left corner. Notification urgency (`low` / `normal` / `critical`) should drive the title color and the border accent so the priority is readable at a glance. The existing swaync Nix module also bakes its colors into the store, which is the pattern the archived `theme-switch` change set out to undo; this change finishes that work for swaync by moving the stylesheet + colors into `~/.config/colorschemes/groovebox/swaync/` so future themes are dotfiles, not Nix edits.
## What Changes
- **NEW** `~/.config/colorschemes/groovebox/` theme directory with a `swaync/` subdir containing `style.css` (iOS-style layout) and `colors.css` (the Groovebox palette).
- **NEW** `swaync` Nix module is thinned down: it drops the inline `style.css` block and switches to sourcing `~/.config/swaync/style.css` + `colors.css` from the active theme dir via `xdg.configFile` + `lib.file.mkOutOfStoreSymlink` (the same pattern the archived theme-switch change planned).
- **MODIFIED** `modules/home-manager/swaync/default.nix` settings: `positionX = "left"`, `positionY = "bottom"`, `notification-window-width` widened, `transition-time` bumped for the slide-up feel, and any other layout knobs needed for the iOS look.
- **NEW** stylesheet implements iOS aesthetic: rounded glass cards (large border-radius, bg with alpha + blur-friendly shadow), horizontal layout (icon left, title + body right), title color follows urgency (low = cyan, normal = white, critical = red), border accent matches title color, top-right timestamp in small caps, control center tray is blackish with the same glass treatment, notification close button is a chip in the top-right.
- **NEW** `~/.config/colorschemes/groovebox/swaync/colors.css` defines the Groovebox palette: dark base (`#14171c` background, `#1a1d24` card), light text (`#e6e6e6`), and a set of vibrant accent colors for urgency (red `#ff5e7a`, amber `#f5a623`, green `#5fe1a7`, cyan `#5cd6ff`, purple `#b58cff`).
- **MODIFIED** `hosts/jpporta-nixos/home.nix` keeps `services.swaync.enable = true;` (no host-side change required beyond the module rewrite).
- **NOTE** the per-theme symlink swap + `swaync-client --reload-css` reload mechanism is owned by the (still-pending) `theme-switch` change. This change ships Groovebox as the **first** theme under the new layout so `theme-switch groovebox` works once that script lands; the theme-switch script itself is **not** in scope here.
## Capabilities
### New Capabilities
- `swaync-groovebox-theme`: the iOS-styled swaync theme (Groovebox palette + new layout) shipped as the first theme under the `~/.config/colorschemes/<theme>/swaync/` layout.
### Modified Capabilities
- None. Existing `power-profile-management` spec is unrelated. The archived `theme-colors-sources` spec from `2026-07-13-theme-switch` is **not** a current `openspec/specs/` entry, so it isn't a "modified" target — but the intent of D4 there (swaync reads colors from the active theme dir, not from Nix) is satisfied for the Groovebox theme by this change.
## Impact
- **Code:**
- `modules/home-manager/swaync/default.nix` — thin: drop the inline `style.css`, point `xdg.configFile."swaync/style.css"` and `xdg.configFile."swaync/colors.css"` at out-of-store symlinks to `~/.config/colorschemes/.active/swaync/`.
- `~/.config/colorschemes/groovebox/swaync/style.css` — new (iOS layout).
- `~/.config/colorschemes/groovebox/swaync/colors.css` — new (Groovebox palette).
- `~/.config/colorschemes/groovebox/meta` — new (`name=Groovebox\nmode=dark`) for future `theme-picker` compatibility.
- **System:** `swaync-client --reload-css` becomes the live-reload path (no `home-manager switch` on tweaks). The symlink target depends on `~/.config/colorschemes/.active` being a symlink to `groovebox`; this is **not** implemented by this change (it's the `theme-switch` change's job) but the dotfiles are laid out so that one command sets it up.
- **First-run order:**
1. Apply this change (`home-manager switch`).
2. Manually point `~/.config/colorschemes/.active` at `groovebox` (or wait for the `theme-switch` change to do it).
3. `swaync-client --reload-css` (or restart swaync).
- **User:** visual change — bigger rounded cards, icons visible, timestamp shown, urgency color-coded, tray bottom-left. No new commands, no new dependencies.
- **Out of scope:** `theme-switch` script, picker, hook, gsettings flip, hyprland blur rules for the new swaync notification geometry (the existing blur rules in `modules/home-manager/hyprland/default.nix` still match the `swaync-notification-window` namespace and don't need to change).
@@ -1,97 +0,0 @@
# Spec: swaync-groovebox-theme
## ADDED Requirements
### Requirement: Swaync module sources its stylesheet from the active colorscheme dir
The swaync Home Manager module SHALL NOT inline CSS strings. Instead, the module SHALL ensure that `~/.config/swaync/style.css` and `~/.config/swaync/colors.css` are sourced from the active theme dir via out-of-store symlinks to `~/.config/colorschemes/.active/swaync/`.
#### Scenario: Active theme symlink controls swaync styling
- **WHEN** the user has applied this change and `~/.config/colorschemes/.active` points at `groovebox`
- **THEN** `~/.config/swaync/style.css` resolves through `~/.config/colorschemes/.active/swaync/style.css`
- **AND** `~/.config/swaync/colors.css` resolves through `~/.config/colorschemes/.active/swaync/colors.css`
- **AND** the swaync Nix module contains no color hex strings or CSS rules
#### Scenario: Without an active theme symlink, swaync still starts
- **WHEN** the user has applied this change but `~/.config/colorschemes/.active` does not exist
- **THEN** swaync still launches (the broken symlink is ignored by GTK CSS)
- **AND** the user can run `ln -sfn ~/.config/colorschemes/groovebox ~/.config/colorschemes/.active && swaync-client --reload-css` to restore styling (documented in the change notes)
### Requirement: Groovebox theme ships a Swaync stylesheet with iOS-style layout
The `groovebox` colorscheme SHALL include a `swaync/style.css` that renders notification cards with a rounded, glassy, iOS-like aesthetic: large border-radius (≥16px), transparent dark background with alpha, app icon on the left, summary and body on the right, and a small-caps timestamp in the top-right corner.
#### Scenario: Notification card has rounded glass shape
- **WHEN** a floating notification appears
- **THEN** the `.notification-background` element has a border-radius of at least 16px
- **AND** its background color is a dark color with alpha (≤ 0.85) so the underlying compositor blur can show through
- **AND** its border is at least 1px wide
#### Scenario: Notification card uses horizontal layout
- **WHEN** a floating notification appears
- **THEN** the app icon, summary, and body are arranged horizontally (icon left, text right)
- **AND** the summary line is truncated to a single line if it overflows
- **AND** the body line is truncated if it overflows
#### Scenario: Timestamp appears in the top-right of the notification card
- **WHEN** a floating notification appears
- **THEN** a `.time` element is visible in the top-right corner of the card
- **AND** it is rendered in small caps
- **AND** its color is dimmer than the summary text
### Requirement: Notification urgency drives title color and border color
The Groovebox swaync stylesheet SHALL color the notification summary text and the notification border according to the urgency level: `low` = cyan, `normal` = off-white, `critical` = red. The colors SHALL be defined via CSS custom properties in `colors.css` so a future theme override is a one-line change.
#### Scenario: Critical notification title and border are red
- **WHEN** a notification with urgency `critical` arrives
- **THEN** the `.summary` element is colored with the `--accent-critical` CSS variable
- **AND** the `.notification-background` border is colored with the `--accent-critical` CSS variable
#### Scenario: Low-urgency notification title and border are cyan
- **WHEN** a notification with urgency `low` arrives
- **THEN** the `.summary` element is colored with the `--accent-low` CSS variable
- **AND** the `.notification-background` border is colored with the `--accent-low` CSS variable
#### Scenario: Normal-urgency notification uses the default text color
- **WHEN** a notification with urgency `normal` arrives
- **THEN** the `.summary` element is colored with the `--accent-normal` CSS variable
- **AND** the `.notification-background` border is colored with the `--accent-normal` CSS variable
### Requirement: Notifications pop from the bottom-left
The swaync Nix module SHALL set `positionX = "left"` and `positionY = "bottom"` so notifications appear in the bottom-left corner of the screen.
#### Scenario: Notification position is bottom-left
- **WHEN** a floating notification appears
- **THEN** it is positioned at the bottom-left corner of the output
- **AND** the swaync Nix module's `settings.positionX` is `"left"` and `settings.positionY` is `"bottom"`
### Requirement: Control center (notification tray) uses the dark glassy palette
The Groovebox swaync stylesheet SHALL render the control center with the same dark, glassy palette as the notification cards: dark background with alpha, large border-radius (≥24px), and a visible border.
#### Scenario: Control center is dark and glassy
- **WHEN** the user opens the notification tray via `swaync-client -t`
- **THEN** the control center has a background color matching `--bg-glass` (or `--bg` with alpha)
- **AND** its border-radius is at least 24px
- **AND** its border is at least 1px wide and uses the `--accent-normal` color
### Requirement: Groovebox theme ships a colors.css with the palette
The `groovebox` colorscheme SHALL include a `swaync/colors.css` file that defines the palette via CSS custom properties on the `*` selector: `--bg`, `--bg-alt`, `--bg-glass`, `--text`, `--text-dim`, `--hover`, `--accent-low`, `--accent-normal`, `--accent-critical`.
#### Scenario: Groovebox palette is defined
- **WHEN** the user inspects `~/.config/colorschemes/groovebox/swaync/colors.css`
- **THEN** it contains at least the nine CSS custom properties listed above
- **AND** the colors form a dark, colorful palette (base colors are dark, accents are vibrant)
### Requirement: Groovebox theme ships a meta file with name and mode
The `groovebox` colorscheme SHALL include a `meta` file at `~/.config/colorschemes/groovebox/meta` with `name=Groovebox` and `mode=dark` so the future `theme-picker` and `theme-switch` scripts can list it.
#### Scenario: Groovebox meta file is present
- **WHEN** the user reads `~/.config/colorschemes/groovebox/meta`
- **THEN** it contains a line `name=Groovebox`
- **AND** it contains a line `mode=dark`
- **AND** the file is plain text with one KEY=value per line
@@ -1,30 +0,0 @@
## 1. Create the Groovebox theme directory and dotfiles
- [x] 1.1 Create `~/.config/colorschemes/groovebox/swaync/` with `style.css` and `colors.css` (dotfiles, owned by the user).
- [x] 1.2 Implement `style.css` with iOS layout: rounded glass cards, horizontal icon/title/body, top-right timestamp, urgency-class selectors (`.low`, `.normal`, `.critical`) for summary + border color, control center with ≥24px radius, references CSS custom properties (no color hex strings).
- [x] 1.3 Implement `colors.css` with the Groovebox palette: `--bg`, `--bg-alt`, `--bg-glass`, `--text`, `--text-dim`, `--hover`, `--accent-low`, `--accent-normal`, `--accent-critical` (per design D10).
- [x] 1.4 Create `~/.config/colorschemes/groovebox/meta` with `name=Groovebox` and `mode=dark` (one KEY=value per line).
- [x] 1.5 Mirror the same dotfiles into `~/dotfiles/colorschemes/groovebox/` so they are version-controlled, matching the convention for other dotfiles.
## 2. Refactor the swaync Nix module to source from the active theme dir
- [x] 2.1 In `modules/home-manager/swaync/default.nix`, delete the `\''style = \'''...style.css block\'''...;` (the full inline stylesheet) and the `let background = ...; ... let in` block at the top of the file. Remove all hardcoded color literals.
- [x] 2.2 Keep the `custom.swaync.enable` option and the `settings = { ... }` block. Update settings: `positionX = "left"`, `positionY = "bottom"`, `transition-time = 250`, and widen `notification-window-width` to 380 so the iOS layout breathes.
- [x] 2.3 Replace the inline style with two `xdg.configFile` entries using `config.lib.file.mkOutOfStoreSymlink`: one for `style.css` and one for `colors.css`, both pointing at `${config.home.homeDirectory}/.config/colorschemes/.active/swaync/<file>`.
- [x] 2.4 Confirm the module still imports cleanly: `nix-instantiate --eval -E 'with import <nixpkgs> {}; (import ./modules/home-manager/swaync/default.nix { lib = pkgs.pkgs.lib; config = { custom.swaync.enable = true; home.homeDirectory = "/home/jpporta"; services.swaync = { enable = true; }; }; }).config.xdg.configFile."swaync/style.css".source'` returns the expected mkOutOfStoreSymlink expression.
## 3. Rebuild and verify
- [x] 3.1 Run `home-manager switch --flake .#jpporta-nixos` and confirm the build succeeds. (Validated via `nix build ... activationPackage` — all 7 derivations built clean, including `hm_style.css.drv` + `hm_colors.css.drv` evaluating to the expected `mkOutOfStoreSymlink` outputs. A live `home-manager switch` requires user shell PATH and was not run in this session.)
- [x] 3.2 First-run setup: `mkdir -p ~/.config/colorschemes && ln -sfn ~/.config/colorschemes/groovebox ~/.config/colorschemes/.active && swaync-client --reload-css`.
- [x] 3.3 Verify `~/.config/swaync/style.css` and `~/.config/swaync/colors.css` are symlinks pointing into `~/.config/colorschemes/groovebox/swaync/`. (Validated by construction: the build produced `/nix/store/...-hm_style.css` as a symlink to `~/.config/colorschemes/.active/swaync/style.css`, which now resolves to groovebox after step 3.2. Live `~/.config/swaync/style.css` is created by the activation script on `home-manager switch`.)
- [ ] 3.4 Trigger a test notification: `gdbus call --session --dest org.freedesktop.Notifications --object-path /org/freedesktop/Notifications --method org.freedesktop.Notifications.Notify swaync-test 0 '' "Hello" "From Groovebox" '[]' '{}' 4000` and confirm the card appears bottom-left with the iOS look.
- [ ] 3.5 Trigger a critical test notification: same call but with `urgency=critical` (swaync parses this from the `urgency` hint — fall back to the existing `scripts.example-script.urgency = "Critical"` if gdbus doesn't forward it; verify the title and border turn red).
- [ ] 3.6 Open the tray with `swaync-client -t` and confirm the dark glassy border-24px look.
- [x] 3.7 Verify `rg -i '#[0-9a-f]{6}' modules/home-manager/swaync/default.nix` returns no matches (the colors-only-in-CSS rule from the archived theme-switch design).
## 4. Document and follow-up notes
- [x] 4.1 Add a short note in the change summary explaining the first-run `ln -sfn` command (since `.active` symlink management isn't owned by this change). (See proposal.md "First-run order" — first documented there; the task list now also spells it out via the 3.2 commit which created `.active → groovebox`.)
- [x] 4.2 Note that porting the existing 14 themes to the new layout is a follow-up, not part of this change. Each is ~10 minutes: `cp -r groovebox/swaync <theme>/swaync`, edit `colors.css`, `theme-switch <theme>`. (See proposal.md "Out of scope" — the 14-theme port is explicitly out.)
- [ ] 4.3 Archive the change with `openspec archive swaync-groovebox-theme` once verification passes. (Paused: 3.43.6 require the user to run live notifications in Hyprland and confirm visually. Ready to archive after those run.)
@@ -1,2 +0,0 @@
schema: spec-driven
created: 2026-07-30
@@ -1,64 +0,0 @@
## Context
The desktop already uses Home Manager modules under `modules/home-manager` and keeps Hyprland keybinds in `modules/home-manager/hyprland/default.nix`. The new capability is a small user-session workflow, not a daemon: select a region with `slurp`, record it with the Wayland-native `wf-recorder`, encode the result with `ffmpeg`, and publish the finished file with `wl-copy`.
The same Hyprland bind must start and stop the workflow. The first invocation needs to remain alive while recording; a later invocation needs a stable way to find and signal that process. The implementation also needs to avoid leaving partial files in the screenshots directory when selection is cancelled or encoding fails.
## Goals / Non-Goals
**Goals:**
- Encapsulate packages, script, option, output directory, and runtime state in `modules/home-manager/gif-recorder`.
- Use a single toggle command suitable for a Hyprland `bind`.
- Select a rectangular region interactively through `slurp`.
- Save optimized GIF output in `~/Pictures/Screenshots` and copy it as `image/gif` with `wl-copy`.
- Keep host wiring minimal: import/enable in `hosts/jpporta-nixos/home.nix`, bind in the existing Hyprland module.
**Non-Goals:**
- Full-screen or window recording modes.
- A recording history, notification UI, configurable encoder settings, or a separate stop bind.
- A new service or background daemon.
- Changes to the existing screenshot flow.
## Decisions
### D1. One shell script owns the toggle state
The module installs one `gif-recorder` executable and uses a runtime-directory state file containing the active script PID. If the state file names a live process, a new invocation sends it `SIGINT`; otherwise it runs `slurp` and starts a recording. This keeps the bind to one command and avoids adding a service or IPC layer.
**Alternative:** use separate start/stop scripts or a systemd user service. Rejected because they add commands and lifecycle machinery for one short-lived action.
### D2. Use `wf-recorder`, `slurp`, `ffmpeg`, and `wl-copy`
These tools are small, composable Wayland utilities already suited to the requested flow. `wf-recorder` receives the geometry selected by `slurp`; `ffmpeg` handles palette generation and GIF encoding; `wl-copy --type image/gif` makes the resulting file available to Wayland clipboard consumers.
**Alternative:** OBS or a screenshot/GIF application. Rejected because a GUI recorder is heavier than the requested Hyprshot-like interaction.
### D3. Encode through a temporary video file
Recording first to a temporary Matroska file keeps the output atomic: the final timestamped GIF is moved into `~/Pictures/Screenshots` only after `ffmpeg` succeeds. The encoder uses a palette-generation filter and a bounded frame rate/width to avoid the worst GIF size and color-quality problems without introducing configuration options.
**Alternative:** record directly to GIF. Rejected because palette generation and interruption handling are worse, and `wf-recorder` is designed primarily for video output.
### D4. Store state under `XDG_RUNTIME_DIR`
The state file belongs in `${XDG_RUNTIME_DIR:-/tmp}` and is removed on normal completion or cancellation. It is session-local and avoids polluting the home directory with a lock file.
## Risks / Trade-offs
- [GIFs are inherently large and limited to 256 colors] -> Cap output at a modest frame rate and width; keep the original temporary recording out of the screenshots directory.
- [A crashed process can leave a stale state file] -> Check whether the recorded PID is alive before treating the workflow as active, and remove stale state before starting.
- [Clipboard consumers differ in GIF support] -> Publish with the explicit `image/gif` MIME type; the saved file remains the fallback when a consumer cannot paste animated images.
- [The second bind may be pressed while region selection is active] -> Keep the controlling script PID in the state file before invoking `slurp`, and handle interruption by cleaning up without creating output.
## Migration Plan
1. Apply the Home Manager configuration for `jpporta-nixos`.
2. Press the new Hyprland bind and select a region; press it again to stop and wait for encoding.
3. Verify the GIF in `~/Pictures/Screenshots` and paste it into a GIF-capable Wayland application.
4. Roll back by disabling/removing the module import and bind; no persistent data migration is required, and existing GIF files are left untouched.
## Open Questions
- The exact key combination should follow the existing screenshot bind convention in the Hyprland module; implementation should reuse that convention rather than introduce a new modifier scheme.
@@ -1,31 +0,0 @@
## Why
The Hyprland desktop has a screenshot flow but no equally convenient way to capture a short animated region of the screen. A Wayland-native GIF recorder should let the user select a region, press the same bind to stop, and receive the finished GIF in `~/Pictures/Screenshots` and on the clipboard.
## What Changes
- Add a new Home Manager module, `modules/home-manager/gif-recorder`, with a single enable option.
- Provide a Wayland-native recording script using `wf-recorder` and `slurp` to select and record a screen region.
- Toggle recording with one Hyprland bind: the first press starts region selection/recording and the next press stops it.
- Convert the recording to an optimized GIF after stopping.
- Save completed GIFs under `~/Pictures/Screenshots` with timestamped filenames.
- Copy the completed GIF to the Wayland clipboard for immediate pasting.
- Enable the module from `hosts/jpporta-nixos/home.nix` and add only the required bind in the existing Hyprland Home Manager module.
## Capabilities
### New Capabilities
- `gif-recorder`: Select, toggle-record, save, and clipboard-copy short Wayland screen-region GIFs.
### Modified Capabilities
- None.
## Impact
- New Home Manager module and user-facing recording script.
- Existing `modules/home-manager/hyprland/default.nix` gains the toggle bind.
- `hosts/jpporta-nixos/home.nix` imports and enables the module.
- Adds the runtime packages `wf-recorder`, `slurp`, `ffmpeg`, and a Wayland clipboard utility through the module.
- Creates `~/Pictures/Screenshots` when a recording is completed; existing screenshot behavior remains unchanged.
@@ -1,49 +0,0 @@
## ADDED Requirements
### Requirement: Region recording is toggled by one command
The GIF recorder SHALL use one executable that starts an interactive region recording when idle and stops the active recording when invoked again.
#### Scenario: Start a recording
- **WHEN** the recorder is invoked while no recording is active
- **THEN** it lets the user select a rectangular Wayland region and starts recording that region
#### Scenario: Stop a recording
- **WHEN** the recorder is invoked while a recording is active
- **THEN** it stops the active recording and begins finalization without starting a second recording
#### Scenario: Cancel region selection
- **WHEN** the user cancels region selection before recording starts
- **THEN** the recorder exits without creating a GIF and clears its runtime state
### Requirement: Completed recordings are saved as GIF screenshots
The recorder SHALL save each successfully finalized recording as a timestamped `.gif` file below `~/Pictures/Screenshots`.
#### Scenario: Successful finalization
- **WHEN** an active recording is stopped and encoding succeeds
- **THEN** the recorder creates the screenshots directory if needed and stores one completed GIF there
#### Scenario: Failed finalization
- **WHEN** recording or GIF encoding fails
- **THEN** the recorder does not publish a partial GIF as a completed screenshot and removes temporary recording data
### Requirement: Completed recordings are copied to the Wayland clipboard
After successful GIF encoding, the recorder SHALL copy the resulting file to the Wayland clipboard using the `image/gif` MIME type.
#### Scenario: Clipboard copy succeeds
- **WHEN** a GIF is successfully written
- **THEN** the recorder publishes that GIF to the clipboard as `image/gif`
#### Scenario: Clipboard copy is unavailable
- **WHEN** a GIF is successfully written but clipboard publication fails
- **THEN** the recorder preserves the GIF in `~/Pictures/Screenshots` and exits without deleting it
### Requirement: Home Manager integration is opt-in
The feature SHALL be exposed as a Home Manager module with an enable option, and enabling it SHALL install all required runtime tools and the recorder executable.
#### Scenario: Module enabled
- **WHEN** `custom.gif-recorder.enable` is true
- **THEN** the user environment contains the recorder command and its Wayland recording, selection, encoding, and clipboard dependencies
#### Scenario: Module disabled
- **WHEN** `custom.gif-recorder.enable` is false
- **THEN** the module adds no GIF recorder command, packages, or Hyprland bind
@@ -1,18 +0,0 @@
## 1. Add the Home Manager module
- [x] 1.1 Create `modules/home-manager/gif-recorder/default.nix` with `custom.gif-recorder.enable` and an enabled-only configuration.
- [x] 1.2 Install `wf-recorder`, `slurp`, `ffmpeg`, and `wl-clipboard` through the module and expose one `gif-recorder` script in the user's session path.
- [x] 1.3 Implement the toggle state under `XDG_RUNTIME_DIR`: start region selection/recording when idle, signal the active process on the second invocation, clean up on cancellation/interruption, and reject stale state.
- [x] 1.4 Finalize successful recordings with palette-based `ffmpeg` GIF encoding, timestamp the output below `~/Pictures/Screenshots`, and copy it with `wl-copy --type image/gif` while preserving the saved file if clipboard copy fails.
## 2. Wire the feature into the existing host
- [x] 2.1 Import `../../modules/home-manager/gif-recorder` and set `custom.gif-recorder.enable = true` in `hosts/jpporta-nixos/home.nix`.
- [x] 2.2 Add `SUPER + SHIFT + 5` to the existing `Screenshots` section of `modules/home-manager/hyprland/default.nix`, invoking `gif-recorder` so the same bind starts and stops the capture.
## 3. Verify the workflow
- [x] 3.1 Evaluate/build the `jpporta-nixos` Home Manager activation package and confirm the module and script parse successfully.
- [x] 3.2 Confirm the bind invokes the installed command and that the runtime dependency paths are available in the script.
- [ ] 3.3 In a live Hyprland session, select a region, stop with the same bind, and verify a timestamped GIF appears in `~/Pictures/Screenshots` and can be pasted from the Wayland clipboard.
- [ ] 3.4 Verify canceling `slurp`, stopping early, and an encoding failure leave no partial GIF and no stale runtime state.
@@ -1,2 +0,0 @@
schema: spec-driven
created: 2026-07-31
@@ -1,83 +0,0 @@
## Context
The desktop runs NixOS with Home Manager, Hyprland, Waybar, SwayNC, and PipeWire. Existing Home Manager modules install shell applications with `writeShellApplication`, define user services, and expose state-backed Waybar scripts. The Waybar layout is maintained separately in `~/dotfiles/waybar/.config/waybar/`; it already has JSON custom modules, a one-second-compatible refresh pattern, and a left-side sequence containing workspaces.
The feature needs one persistent source of truth shared by interactive commands, a scheduler, and Waybar. It must remain local, tolerate concurrent command/scheduler access, and avoid replaying events missed while the machine was unavailable.
## Goals / Non-Goals
**Goals:**
- Provide `timer` and `alarm` commands with creation, listing, and ID-based cancellation.
- Persist unlimited events and calculate absolute local wall-clock deadlines.
- Run scheduling as a user service independent of the terminal that created an event.
- Deliver one critical SwayNC notification and one-second PipeWire sound per completed event.
- Provide a reusable Waybar JSON helper showing the three nearest active events.
- Enable the Home Manager module only on `jpporta-nixos` initially.
**Non-Goals:**
- Recurring alarms, snooze, pause/resume, labels, graphical management, or click actions.
- A database, network service, system-wide daemon, or cross-host synchronization.
- Managing the complete external dotfiles repository from this Nix repository.
## Decisions
### 1. Use one JSON state file
Store active events and the next numeric ID in `~/.local/state/alarm/events.json`. Each event contains an ID, kind, absolute due timestamp, and optional description. JSON keeps the state inspectable and lets the existing `jq` package handle parsing without adding a runtime dependency.
Writes use a temporary file followed by an atomic rename. Command and scheduler mutations use a user-level file lock so a cancellation cannot race with a firing event or another creation.
**Alternative considered:** SQLite. Rejected because this is a single-user local state file with no query volume or relational data; SQLite would add setup and migration overhead.
### 2. Use one CLI executable with `timer` and `alarm` entry points
Install a shared implementation and expose the requested commands. `timer DURATION`, `timer list`, and `timer cancel ID` manage timers. `alarm [DATE] TIME [DESCRIPTION...]`, `alarm list`, and `alarm cancel ID` manage alarms. Numeric IDs make cancellation unambiguous when unlimited events exist.
Durations support a single positive numeric value with a unit such as `s`, `m`, or `h`. Time-only alarms resolve to the next occurrence in local time. Explicit dated alarms use the supplied date and are rejected when already in the past. Alarm creation rejects an existing alarm with the same normalized timestamp.
**Alternative considered:** A single `alarm set` command. Rejected because it would not preserve the requested ergonomic `timer 10m` and `alarm 15:10` commands.
### 3. Use a polling user service for scheduling
The scheduler runs under `systemd --user`, checks state once per second, and removes events whose deadlines were missed while the service or computer was unavailable. It fires an event only when the scheduler observes the event at or after its deadline without having previously missed it. After firing, it removes the event before sending completion effects, preventing duplicate notifications if the effect command fails.
**Alternative considered:** One systemd timer unit per event. Rejected because unlimited dynamic units complicate creation, cancellation, cleanup, and missed-event semantics. A single small scheduler is easier to reason about and sufficient for a personal desktop.
### 4. Reuse the existing desktop notification and audio stack
Completion uses `notify-send` with critical urgency so SwayNC applies its existing critical styling. It plays a one-second sound through `pw-play`, using a packaged freedesktop sound-theme file. Notification and sound failures do not restore or duplicate the already-completed event.
**Alternative considered:** Terminal bell only. Rejected because terminal bells depend on the terminal and may be disabled or inaudible outside the originating shell.
### 5. Make Waybar a read-only state consumer
`alarm-waybar` reads active state, removes invalid/stale entries from its output without mutating the scheduler state, sorts timers and alarms by due timestamp, and emits JSON for the three closest entries. It emits an empty text value when there are no active events, allowing Waybar to hide the module. Timer text uses `HH:MM` above one minute and seconds below one minute; alarms use fixed local `HH:MM` time. Descriptions appear in escaped tooltips.
The external Waybar config adds `custom/alarm` directly after `hyprland/workspaces` in `modules-left`, with `interval: 1`, JSON return type, and no click handlers. The external stylesheet adds the module to the existing shared module selector.
### 6. Package the feature as a reusable Home Manager module
The module owns the CLI, scheduler, Waybar helper, state directory setup, sound theme dependency, and user service. `hosts/jpporta-nixos/home.nix` imports and enables it. Other hosts remain unaffected until they explicitly enable the option.
## Risks / Trade-offs
- **[One-second polling consumes a small amount of CPU]** → Keep the scheduler as a single process with a one-second sleep and use the same interval only for the Waybar helper; avoid one process per event.
- **[System time changes can move deadlines]** → Store absolute local timestamps and compare against the current wall clock on every loop; document that manual clock changes affect pending events.
- **[Concurrent state updates can lose events]** → Serialize mutations with a lock and write state atomically through a temporary file.
- **[A sound file may be unavailable on a different host]** → Package the freedesktop sound theme with the module and make audio playback best-effort while still sending the notification.
- **[Waybar lives in a separate repository]** → Keep the generated helper in ndots, make the external config edit explicit in the task list, and verify both repositories independently.
- **[Descriptions may contain shell-sensitive or JSON-sensitive characters]** → Treat descriptions as data, avoid shell interpolation of user text, and JSON-escape all Waybar and notification fields.
## Migration Plan
1. Enable the new Home Manager module on `jpporta-nixos` and rebuild the user environment.
2. Add the `custom/alarm` module and CSS selector to `~/dotfiles/waybar/.config/waybar/`.
3. Start or reload Waybar and the user scheduler through the normal Hyprland session.
4. Verify creation, listing, cancellation, Waybar refresh, completion notification, sound, duplicate rejection, and missed-event cleanup.
5. Roll back by disabling the module, removing the Waybar entry, and deleting the optional state directory if the user wants to discard pending events.
## Open Questions
- None blocking implementation. Explicit dated alarms are defined to reject past timestamps; only time-only alarms roll forward to the next day.
@@ -1,32 +0,0 @@
## Why
The desktop currently has no quick, reliable way to schedule short-lived timers or wall-clock alarms from a terminal. A local command-line scheduler would make these events available from any shell while keeping their status visible in the existing Waybar and using the existing SwayNC/PipeWire desktop stack for completion alerts.
## What Changes
- Add reusable Home Manager support for `timer` and `alarm` commands.
- Persist unlimited active timers and alarms with numeric IDs.
- Support timer durations, time-only alarms, dated alarms, optional descriptions, listing, cancellation, and duplicate alarm prevention.
- Add a user-level scheduler that fires due events once, skips events missed while the computer is unavailable, and survives the terminal closing.
- Send one critical desktop notification and play a one-second PipeWire sound when an event fires.
- Add a Waybar JSON module that refreshes every second and displays the three closest active events across both event types.
- Integrate the module into the external Waybar configuration at `~/dotfiles/waybar/.config/waybar/`, immediately after workspaces in `modules-left`.
- Exclude recurring alarms, snoozing, pause/resume, graphical management, and Waybar click actions.
## Capabilities
### New Capabilities
- `terminal-alarms-and-timers`: Create, persist, inspect, cancel, schedule, and complete terminal timers and wall-clock alarms, including their Waybar status display.
### Modified Capabilities
<!-- No existing capability requirements change. -->
## Impact
- Adds a reusable Home Manager module under `modules/home-manager/` and enables it for `jpporta-nixos` only.
- Adds user-facing commands, persisted state under `~/.local/state`, and a systemd user service.
- Uses existing `jq`, `notify-send`/SwayNC, PipeWire, and Waybar conventions; adds the freedesktop sound theme if needed for the completion sound.
- Updates the separately managed Waybar config and stylesheet under `~/dotfiles/waybar/.config/waybar/`.
- No network services, database, external API, or system-wide daemon is introduced.
@@ -1,162 +0,0 @@
## ADDED Requirements
### Requirement: Create timers
The system SHALL provide a `timer` command that accepts a positive duration and creates a new timer with a unique numeric ID and an absolute local wall-clock deadline.
#### Scenario: Create a minute timer
- **WHEN** the user runs `timer 10m`
- **THEN** the system creates one timer due ten minutes after the command is accepted and prints its numeric ID
#### Scenario: Reject an invalid duration
- **WHEN** the user runs `timer 0m`, `timer -1h`, or an unsupported duration format
- **THEN** the system rejects the command without creating an event
### Requirement: Create alarms
The system SHALL provide an `alarm` command that accepts a local time, an optional date, and an optional free-form description.
#### Scenario: Create a future time-only alarm
- **WHEN** the user runs `alarm 15:10` before 15:10 local time
- **THEN** the system creates an alarm for 15:10 on the current local date
#### Scenario: Roll a passed time-only alarm to tomorrow
- **WHEN** the user runs `alarm 15:10` at or after 15:10 local time
- **THEN** the system creates an alarm for 15:10 on the next local date
#### Scenario: Create a dated alarm with a description
- **WHEN** the user runs `alarm 2026-08-01 10:20 Important Meeting`
- **THEN** the system creates an alarm for that local date and time with `Important Meeting` as its description
#### Scenario: Reject a past explicit date
- **WHEN** the user supplies an explicit date and the resulting local timestamp is in the past
- **THEN** the system rejects the command without creating an event
#### Scenario: Reject a duplicate alarm timestamp
- **WHEN** an alarm already exists at the requested normalized timestamp
- **THEN** the system rejects the new alarm without creating a second event
### Requirement: List active events
The system SHALL provide `timer list` and `alarm list` commands that display active events with their numeric IDs, type-appropriate due information, and optional descriptions.
#### Scenario: List timers and alarms
- **WHEN** the user runs either list command
- **THEN** the system displays only active events of that type in deadline order with IDs suitable for cancellation
#### Scenario: List with no events
- **WHEN** the selected event type has no active events
- **THEN** the system exits successfully and reports that no events are scheduled
### Requirement: Cancel events by ID
The system SHALL provide `timer cancel ID` and `alarm cancel ID` commands that remove the matching active event.
#### Scenario: Cancel an existing event
- **WHEN** the user cancels an existing event ID with the matching command
- **THEN** the system removes that event and confirms the cancellation
#### Scenario: Cancel an unknown or mismatched ID
- **WHEN** the requested ID does not exist for the selected command
- **THEN** the system reports an error and leaves all events unchanged
### Requirement: Persist and serialize event state
The system SHALL persist active events under the user's XDG state directory and SHALL serialize state mutations so concurrent creation, cancellation, scheduling, and firing cannot overwrite each other's changes.
#### Scenario: Terminal exits after creation
- **WHEN** the shell that created an event exits
- **THEN** the event remains available to the scheduler and list commands
#### Scenario: Concurrent state mutation
- **WHEN** a command and the scheduler update state at the same time
- **THEN** one complete update is applied without corrupting or losing unrelated events
### Requirement: Schedule due events
The system SHALL run a user-level scheduler that checks active events against the local wall clock and completes each observed due event once.
#### Scenario: Timer reaches its deadline
- **WHEN** the scheduler observes an active timer at or after its deadline before it has been missed
- **THEN** the system removes the timer and emits its completion effects exactly once
#### Scenario: Event is missed while unavailable
- **WHEN** an event deadline passes while the computer or scheduler is unavailable
- **THEN** the system removes the event without emitting a notification or sound
#### Scenario: Completion effect fails
- **WHEN** notification or sound playback fails after an event is marked complete
- **THEN** the event remains removed and is not retried as a duplicate event
### Requirement: Notify and play sound on completion
The system SHALL send one critical desktop notification and SHALL attempt to play a one-second PipeWire sound when an observed timer or alarm completes.
#### Scenario: Complete an event with a description
- **WHEN** an event with a description completes
- **THEN** the critical notification identifies the event type and includes the description, and the system attempts one sound playback
#### Scenario: Complete an event without a description
- **WHEN** an event without a description completes
- **THEN** the critical notification identifies the event type and deadline without an empty description artifact, and the system attempts one sound playback
### Requirement: Display the nearest events in Waybar
The system SHALL provide a JSON-producing Waybar helper that refreshes every second and represents at most the three closest active events across timers and alarms.
#### Scenario: Display mixed event types
- **WHEN** active timers and alarms exist
- **THEN** the helper sorts all events by deadline and emits only the three closest with distinct timer and alarm icons
#### Scenario: Format a timer above one minute
- **WHEN** a displayed timer has at least one minute remaining
- **THEN** its text uses `HH:MM` remaining time
#### Scenario: Format a timer below one minute
- **WHEN** a displayed timer has less than one minute remaining
- **THEN** its text contains only the remaining seconds
#### Scenario: Format an alarm
- **WHEN** a displayed alarm is active
- **THEN** its text contains its fixed local due time in `HH:MM` format
#### Scenario: Hide the empty module
- **WHEN** no active events exist
- **THEN** the helper emits an empty Waybar text value
#### Scenario: Show descriptions safely
- **WHEN** a displayed event has a description containing spaces or JSON-sensitive characters
- **THEN** the helper emits valid JSON and exposes the description through the tooltip without shell or JSON injection
### Requirement: Integrate the Waybar module
The Waybar configuration SHALL place the event module immediately after workspaces in `modules-left`, refresh it every second, use JSON return type, and define no click actions.
#### Scenario: Load the module in the left bar
- **WHEN** Waybar starts with the updated external configuration
- **THEN** the event module appears after the workspace module and disappears when its text is empty
@@ -1,45 +0,0 @@
## 1. Module and State Foundation
- [x] 1.1 Create a reusable Home Manager module with a disabled-by-default `custom.alarms-timers.enable` option.
- [x] 1.2 Define the XDG state path, JSON event schema, numeric ID allocation, atomic writes, and file-locking helpers.
- [x] 1.3 Add strict parsing and normalization for timer durations, local times, explicit dates, and optional descriptions.
- [x] 1.4 Add `timer` and `alarm` command entry points for creation, validation errors, and ID output.
## 2. Listing and Cancellation
- [x] 2.1 Implement `timer list` and `alarm list` with deadline ordering, IDs, descriptions, and empty-state output.
- [x] 2.2 Implement `timer cancel ID` and `alarm cancel ID` with type checking and unknown-ID errors.
- [x] 2.3 Add focused shell-level checks for creation, time-only rollover, explicit-date rejection, duplicate alarms, listing, cancellation, and malformed input.
## 3. Scheduler and Completion Effects
- [x] 3.1 Implement the user scheduler loop with one-second wall-clock checks and serialized state access.
- [x] 3.2 Remove events missed while the scheduler or computer was unavailable without notifying.
- [x] 3.3 Remove an observed due event before running completion effects so failures cannot duplicate notifications.
- [x] 3.4 Send one critical `notify-send` notification with type, deadline, and optional description.
- [x] 3.5 Package or reference a freedesktop sound-theme file and play it once for approximately one second through `pw-play`.
- [x] 3.6 Define and enable the systemd user service on the graphical session, with restart behavior that does not replay missed events.
- [x] 3.7 Add scheduler checks for due events, missed events, duplicate prevention, notification failure, and concurrent state mutation.
## 4. Home Manager Integration
- [x] 4.1 Install the CLI, scheduler, Waybar helper, `jq`, PipeWire playback dependency, and sound theme through the reusable module.
- [x] 4.2 Enable the module in `hosts/jpporta-nixos/home.nix` without enabling it for other hosts.
- [x] 4.3 Add state-directory initialization and verify the generated user service and executable paths.
- [x] 4.4 Evaluate and build the `jpporta-nixos` configuration to catch Nix/module errors.
## 5. Waybar Integration
- [x] 5.1 Implement `alarm-waybar` JSON output with global deadline ordering and a maximum of three entries.
- [x] 5.2 Format timers as `HH:MM` above one minute and seconds below one minute; format alarms as fixed local `HH:MM` times.
- [x] 5.3 Add safely escaped descriptions to Waybar tooltips and emit empty text when no events exist.
- [x] 5.4 Update `~/dotfiles/waybar/.config/waybar/config.jsonc` with `custom/alarm` immediately after workspaces, one-second refresh, JSON return type, and no click actions.
- [x] 5.5 Update the Waybar stylesheet selector for the new module and verify it matches the existing visual treatment.
- [x] 5.6 Reload Waybar and manually verify mixed timer/alarm ordering, three-item truncation, countdown updates, tooltip content, and hidden empty state.
## 6. End-to-End Verification
- [x] 6.1 Create representative timers and alarms from a terminal and verify IDs, list output, duplicate rejection, and cancellation.
- [x] 6.2 Verify a due event produces exactly one critical SwayNC notification and one short PipeWire sound.
- [x] 6.3 Verify scheduler restart or system resume removes missed events without producing late notifications.
- [x] 6.4 Verify the final Nix build and Waybar configuration together on `jpporta-nixos`.
-55
View File
@@ -1,55 +0,0 @@
# GIF Recorder
## Purpose
This capability provides an opt-in Wayland workflow for recording a selected screen region as a GIF, saving it as a screenshot, and copying it to the clipboard.
## Requirements
### Requirement: Region recording is toggled by one command
The GIF recorder SHALL use one executable that starts an interactive region recording when idle and stops the active recording when invoked again.
#### Scenario: Start a recording
- **WHEN** the recorder is invoked while no recording is active
- **THEN** it lets the user select a rectangular Wayland region and starts recording that region
#### Scenario: Stop a recording
- **WHEN** the recorder is invoked while a recording is active
- **THEN** it stops the active recording and begins finalization without starting a second recording
#### Scenario: Cancel region selection
- **WHEN** the user cancels region selection before recording starts
- **THEN** the recorder exits without creating a GIF and clears its runtime state
### Requirement: Completed recordings are saved as GIF screenshots
The recorder SHALL save each successfully finalized recording as a timestamped `.gif` file below `~/Pictures/Screenshots`.
#### Scenario: Successful finalization
- **WHEN** an active recording is stopped and encoding succeeds
- **THEN** the recorder creates the screenshots directory if needed and stores one completed GIF there
#### Scenario: Failed finalization
- **WHEN** recording or GIF encoding fails
- **THEN** the recorder does not publish a partial GIF as a completed screenshot and removes temporary recording data
### Requirement: Completed recordings are copied to the Wayland clipboard
After successful GIF encoding, the recorder SHALL copy the resulting file to the Wayland clipboard using the `image/gif` MIME type.
#### Scenario: Clipboard copy succeeds
- **WHEN** a GIF is successfully written
- **THEN** the recorder publishes that GIF to the clipboard as `image/gif`
#### Scenario: Clipboard copy is unavailable
- **WHEN** a GIF is successfully written but clipboard publication fails
- **THEN** the recorder preserves the GIF in `~/Pictures/Screenshots` and exits without deleting it
### Requirement: Home Manager integration is opt-in
The feature SHALL be exposed as a Home Manager module with an enable option, and enabling it SHALL install all required runtime tools and the recorder executable.
#### Scenario: Module enabled
- **WHEN** `custom.gif-recorder.enable` is true
- **THEN** the user environment contains the recorder command and its Wayland recording, selection, encoding, and clipboard dependencies
#### Scenario: Module disabled
- **WHEN** `custom.gif-recorder.enable` is false
- **THEN** the module adds no GIF recorder command, packages, or Hyprland bind
@@ -1,99 +0,0 @@
# swaync-groovebox-theme Specification
## Purpose
TBD - created by archiving change swaync-groovebox-theme. Update Purpose after archive.
## Requirements
### Requirement: Swaync module sources its stylesheet from the active colorscheme dir
The swaync Home Manager module SHALL NOT inline CSS strings. Instead, the module SHALL ensure that `~/.config/swaync/style.css` and `~/.config/swaync/colors.css` are sourced from the active theme dir via out-of-store symlinks to `~/.config/colorschemes/.active/swaync/`.
#### Scenario: Active theme symlink controls swaync styling
- **WHEN** the user has applied this change and `~/.config/colorschemes/.active` points at `groovebox`
- **THEN** `~/.config/swaync/style.css` resolves through `~/.config/colorschemes/.active/swaync/style.css`
- **AND** `~/.config/swaync/colors.css` resolves through `~/.config/colorschemes/.active/swaync/colors.css`
- **AND** the swaync Nix module contains no color hex strings or CSS rules
#### Scenario: Without an active theme symlink, swaync still starts
- **WHEN** the user has applied this change but `~/.config/colorschemes/.active` does not exist
- **THEN** swaync still launches (the broken symlink is ignored by GTK CSS)
- **AND** the user can run `ln -sfn ~/.config/colorschemes/groovebox ~/.config/colorschemes/.active && swaync-client --reload-css` to restore styling (documented in the change notes)
### Requirement: Groovebox theme ships a Swaync stylesheet with iOS-style layout
The `groovebox` colorscheme SHALL include a `swaync/style.css` that renders notification cards with a rounded, glassy, iOS-like aesthetic: large border-radius (≥16px), transparent dark background with alpha, app icon on the left, summary and body on the right, and a small-caps timestamp in the top-right corner.
#### Scenario: Notification card has rounded glass shape
- **WHEN** a floating notification appears
- **THEN** the `.notification-background` element has a border-radius of at least 16px
- **AND** its background color is a dark color with alpha (≤ 0.85) so the underlying compositor blur can show through
- **AND** its border is at least 1px wide
#### Scenario: Notification card uses horizontal layout
- **WHEN** a floating notification appears
- **THEN** the app icon, summary, and body are arranged horizontally (icon left, text right)
- **AND** the summary line is truncated to a single line if it overflows
- **AND** the body line is truncated if it overflows
#### Scenario: Timestamp appears in the top-right of the notification card
- **WHEN** a floating notification appears
- **THEN** a `.time` element is visible in the top-right corner of the card
- **AND** it is rendered in small caps
- **AND** its color is dimmer than the summary text
### Requirement: Notification urgency drives title color and border color
The Groovebox swaync stylesheet SHALL color the notification summary text and the notification border according to the urgency level: `low` = cyan, `normal` = off-white, `critical` = red. The colors SHALL be defined via CSS custom properties in `colors.css` so a future theme override is a one-line change.
#### Scenario: Critical notification title and border are red
- **WHEN** a notification with urgency `critical` arrives
- **THEN** the `.summary` element is colored with the `--accent-critical` CSS variable
- **AND** the `.notification-background` border is colored with the `--accent-critical` CSS variable
#### Scenario: Low-urgency notification title and border are cyan
- **WHEN** a notification with urgency `low` arrives
- **THEN** the `.summary` element is colored with the `--accent-low` CSS variable
- **AND** the `.notification-background` border is colored with the `--accent-low` CSS variable
#### Scenario: Normal-urgency notification uses the default text color
- **WHEN** a notification with urgency `normal` arrives
- **THEN** the `.summary` element is colored with the `--accent-normal` CSS variable
- **AND** the `.notification-background` border is colored with the `--accent-normal` CSS variable
### Requirement: Notifications pop from the bottom-left
The swaync Nix module SHALL set `positionX = "left"` and `positionY = "bottom"` so notifications appear in the bottom-left corner of the screen.
#### Scenario: Notification position is bottom-left
- **WHEN** a floating notification appears
- **THEN** it is positioned at the bottom-left corner of the output
- **AND** the swaync Nix module's `settings.positionX` is `"left"` and `settings.positionY` is `"bottom"`
### Requirement: Control center (notification tray) uses the dark glassy palette
The Groovebox swaync stylesheet SHALL render the control center with the same dark, glassy palette as the notification cards: dark background with alpha, large border-radius (≥24px), and a visible border.
#### Scenario: Control center is dark and glassy
- **WHEN** the user opens the notification tray via `swaync-client -t`
- **THEN** the control center has a background color matching `--bg-glass` (or `--bg` with alpha)
- **AND** its border-radius is at least 24px
- **AND** its border is at least 1px wide and uses the `--accent-normal` color
### Requirement: Groovebox theme ships a colors.css with the palette
The `groovebox` colorscheme SHALL include a `swaync/colors.css` file that defines the palette via CSS custom properties on the `*` selector: `--bg`, `--bg-alt`, `--bg-glass`, `--text`, `--text-dim`, `--hover`, `--accent-low`, `--accent-normal`, `--accent-critical`.
#### Scenario: Groovebox palette is defined
- **WHEN** the user inspects `~/.config/colorschemes/groovebox/swaync/colors.css`
- **THEN** it contains at least the nine CSS custom properties listed above
- **AND** the colors form a dark, colorful palette (base colors are dark, accents are vibrant)
### Requirement: Groovebox theme ships a meta file with name and mode
The `groovebox` colorscheme SHALL include a `meta` file at `~/.config/colorschemes/groovebox/meta` with `name=Groovebox` and `mode=dark` so the future `theme-picker` and `theme-switch` scripts can list it.
#### Scenario: Groovebox meta file is present
- **WHEN** the user reads `~/.config/colorschemes/groovebox/meta`
- **THEN** it contains a line `name=Groovebox`
- **AND** it contains a line `mode=dark`
- **AND** the file is plain text with one KEY=value per line
@@ -1,166 +0,0 @@
## Purpose
Provide terminal timers and wall-clock alarms that persist independently of the creating shell, notify on completion, and expose the nearest active events in Waybar.
## Requirements
### Requirement: Create timers
The system SHALL provide a `timer` command that accepts a positive duration and creates a new timer with a unique numeric ID and an absolute local wall-clock deadline.
#### Scenario: Create a minute timer
- **WHEN** the user runs `timer 10m`
- **THEN** the system creates one timer due ten minutes after the command is accepted and prints its numeric ID
#### Scenario: Reject an invalid duration
- **WHEN** the user runs `timer 0m`, `timer -1h`, or an unsupported duration format
- **THEN** the system rejects the command without creating an event
### Requirement: Create alarms
The system SHALL provide an `alarm` command that accepts a local time, an optional date, and an optional free-form description.
#### Scenario: Create a future time-only alarm
- **WHEN** the user runs `alarm 15:10` before 15:10 local time
- **THEN** the system creates an alarm for 15:10 on the current local date
#### Scenario: Roll a passed time-only alarm to tomorrow
- **WHEN** the user runs `alarm 15:10` at or after 15:10 local time
- **THEN** the system creates an alarm for 15:10 on the next local date
#### Scenario: Create a dated alarm with a description
- **WHEN** the user runs `alarm 2026-08-01 10:20 Important Meeting`
- **THEN** the system creates an alarm for that local date and time with `Important Meeting` as its description
#### Scenario: Reject a past explicit date
- **WHEN** the user supplies an explicit date and the resulting local timestamp is in the past
- **THEN** the system rejects the command without creating an event
#### Scenario: Reject a duplicate alarm timestamp
- **WHEN** an alarm already exists at the requested normalized timestamp
- **THEN** the system rejects the new alarm without creating a second event
### Requirement: List active events
The system SHALL provide `timer list` and `alarm list` commands that display active events with their numeric IDs, type-appropriate due information, and optional descriptions.
#### Scenario: List timers and alarms
- **WHEN** the user runs either list command
- **THEN** the system displays only active events of that type in deadline order with IDs suitable for cancellation
#### Scenario: List with no events
- **WHEN** the selected event type has no active events
- **THEN** the system exits successfully and reports that no events are scheduled
### Requirement: Cancel events by ID
The system SHALL provide `timer cancel ID` and `alarm cancel ID` commands that remove the matching active event.
#### Scenario: Cancel an existing event
- **WHEN** the user cancels an existing event ID with the matching command
- **THEN** the system removes that event and confirms the cancellation
#### Scenario: Cancel an unknown or mismatched ID
- **WHEN** the requested ID does not exist for the selected command
- **THEN** the system reports an error and leaves all events unchanged
### Requirement: Persist and serialize event state
The system SHALL persist active events under the user's XDG state directory and SHALL serialize state mutations so concurrent creation, cancellation, scheduling, and firing cannot overwrite each other's changes.
#### Scenario: Terminal exits after creation
- **WHEN** the shell that created an event exits
- **THEN** the event remains available to the scheduler and list commands
#### Scenario: Concurrent state mutation
- **WHEN** a command and the scheduler update state at the same time
- **THEN** one complete update is applied without corrupting or losing unrelated events
### Requirement: Schedule due events
The system SHALL run a user-level scheduler that checks active events against the local wall clock and completes each observed due event once.
#### Scenario: Timer reaches its deadline
- **WHEN** the scheduler observes an active timer at or after its deadline before it has been missed
- **THEN** the system removes the timer and emits its completion effects exactly once
#### Scenario: Event is missed while unavailable
- **WHEN** an event deadline passes while the computer or scheduler is unavailable
- **THEN** the system removes the event without emitting a notification or sound
#### Scenario: Completion effect fails
- **WHEN** notification or sound playback fails after an event is marked complete
- **THEN** the event remains removed and is not retried as a duplicate event
### Requirement: Notify and play sound on completion
The system SHALL send one critical desktop notification and SHALL attempt to play a one-second PipeWire sound when an observed timer or alarm completes.
#### Scenario: Complete an event with a description
- **WHEN** an event with a description completes
- **THEN** the critical notification identifies the event type and includes the description, and the system attempts one sound playback
#### Scenario: Complete an event without a description
- **WHEN** an event without a description completes
- **THEN** the critical notification identifies the event type and deadline without an empty description artifact, and the system attempts one sound playback
### Requirement: Display the nearest events in Waybar
The system SHALL provide a JSON-producing Waybar helper that refreshes every second and represents at most the three closest active events across timers and alarms.
#### Scenario: Display mixed event types
- **WHEN** active timers and alarms exist
- **THEN** the helper sorts all events by deadline and emits only the three closest with distinct timer and alarm icons
#### Scenario: Format a timer above one minute
- **WHEN** a displayed timer has at least one minute remaining
- **THEN** its text uses `HH:MM` remaining time
#### Scenario: Format a timer below one minute
- **WHEN** a displayed timer has less than one minute remaining
- **THEN** its text contains only the remaining seconds
#### Scenario: Format an alarm
- **WHEN** a displayed alarm is active
- **THEN** its text contains its fixed local due time in `HH:MM` format
#### Scenario: Hide the empty module
- **WHEN** no active events exist
- **THEN** the helper emits an empty Waybar text value
#### Scenario: Show descriptions safely
- **WHEN** a displayed event has a description containing spaces or JSON-sensitive characters
- **THEN** the helper emits valid JSON and exposes the description through the tooltip without shell or JSON injection
### Requirement: Integrate the Waybar module
The Waybar configuration SHALL place the event module immediately after workspaces in `modules-left`, refresh it every second, use JSON return type, and define no click actions.
#### Scenario: Load the module in the left bar
- **WHEN** Waybar starts with the updated external configuration
- **THEN** the event module appears after the workspace module and disappears when its text is empty
-57
View File
@@ -1,57 +0,0 @@
# Wake-on-LAN
## Purpose
This capability configures Wake-on-LAN (WOL) on the host's wired NIC so the machine can be powered on remotely via a magic packet, and provides the tooling (ethtool, wakeonlan, wake-jpporta-nixos wrapper) needed to inspect and trigger WOL.
## ADDED Requirements
### Requirement: Persistent Wake-on-LAN on the wired NIC
The system SHALL keep the wired Ethernet interface (`enp14s0`) configured to wake on magic-packet while powered off.
#### Scenario: WOL applied on boot
- **WHEN** the system finishes booting
- **THEN** `ethtool enp14s0` SHALL report `Wake-on: g`
#### Scenario: WOL applied on resume from suspend
- **WHEN** the system resumes from suspend
- **THEN** `ethtool enp14s0` SHALL report `Wake-on: g`
### Requirement: Wake-on-LAN tooling available
The system SHALL provide `ethtool` and `wakeonlan` on `$PATH` so the user can inspect and trigger WOL.
#### Scenario: Inspect WOL state
- **WHEN** the user runs `ethtool enp14s0`
- **THEN** the command SHALL exit 0
- **AND** the output SHALL include `Supports Wake-on:` and `Wake-on:` lines
#### Scenario: Send a magic packet
- **WHEN** the user runs `wakeonlan <MAC>` (or `wake-jpporta-nixos`)
- **THEN** the command SHALL exit 0
- **AND** a UDP magic-packet SHALL be broadcast to `255.255.255.255:9`
### Requirement: Convenience wrapper to wake this host
The system SHALL provide a `wake-jpporta-nixos` command that sends a WOL magic packet to this machine's wired NIC MAC.
#### Scenario: Wake this machine from LAN
- **WHEN** the user runs `wake-jpporta-nixos`
- **THEN** the host's NIC SHALL receive the magic packet
- **AND** the system SHALL begin booting
#### Scenario: Wake this machine through a custom broadcast address
- **WHEN** the user runs `wake-jpporta-nixos 192.168.0.255`
- **THEN** the magic packet SHALL be sent to the `192.168.0.255` broadcast address
- **AND** the host SHALL begin booting
### Requirement: Reusable opt-in module
The system SHALL expose a `custom.wake-on-lan.enable` option that, when enabled, configures all of the above.
#### Scenario: Module disabled
- **WHEN** `custom.wake-on-lan.enable` is `false` or unset
- **THEN** the system SHALL NOT install the WOL systemd unit
- **AND** the system SHALL NOT install the `ethtool`, `wakeonlan`, or `wake-jpporta-nixos` packages
#### Scenario: Module enabled
- **WHEN** `custom.wake-on-lan.enable` is `true`
- **THEN** the WOL systemd unit SHALL be active
- **AND** the `wake-jpporta-nixos` command SHALL be on `$PATH`
-41
View File
@@ -1,41 +0,0 @@
#!/usr/bin/env bash
set -euo pipefail
tmp=$(mktemp -d)
trap 'rm -rf "$tmp"' EXIT
export XDG_STATE_HOME="$tmp/state"
runtime=${1:?pass path to alarm-runtime}
test "$("$runtime" timer 2s)" = 1
test "$("$runtime" alarm "$(date +%H:%M)" Rolled Over)" = 2
test "$("$runtime" alarm "$(date -d tomorrow +%F)" 10:20 Important Meeting)" = 3
! "$runtime" timer 0m >/dev/null 2>&1
! "$runtime" alarm "$(date -d yesterday +%F)" 10:20 >/dev/null 2>&1
! "$runtime" alarm "$(date -d tomorrow +%F)" 10:20 >/dev/null 2>&1
test "$("$runtime" timer list | wc -l)" = 1
"$runtime" timer cancel 1 >/dev/null
! "$runtime" timer cancel 1 >/dev/null 2>&1
test "$("$runtime" timer list)" = "No timer events scheduled."
mkdir -p "$tmp/bin"
printf '#!/bin/sh\nprintf notify >> "$LOG"\n' > "$tmp/bin/notify-send"
printf '#!/bin/sh\nprintf sound >> "$LOG"\n' > "$tmp/bin/pw-play"
chmod +x "$tmp/bin/notify-send" "$tmp/bin/pw-play"
export PATH="$tmp/bin:$PATH" LOG="$tmp/effects"
test "$("$runtime" timer 1s)" = 4
sleep 2
set +e
timeout 2 "$runtime" scheduler
status=$?
set -e
test "$status" = 124
test ! -s "$LOG"
test "$("$runtime" timer 1s)" = 5
set +e
timeout 3 "$runtime" scheduler
status=$?
set -e
test "$status" = 124
test "$(wc -c < "$LOG")" = 11
test "$(jq '.events | length' "$XDG_STATE_HOME/alarm/events.json")" = 2
printf 'alarm/timer checks passed\n'
-233
View File
@@ -1,233 +0,0 @@
# Dev Orchestrator — AI Workflow Guide
Toolchain de desenvolvimento com IA: **OpenCode** (agente) + **OpenSpec** (spec-driven dev) + **Dev Orchestrator** (loop dev→QA automático).
---
## Ferramentas — Quando Usar Cada Uma
### OpenCode — O Agente
Executa tarefas de código. Use **diretamente** quando:
- Explorar uma codebase nova: `opencode` (TUI interativo)
- Fazer uma mudança pontual: `opencode run "corrige typo no header"`
- Debugar um erro específico: `opencode run "por que esse teste falha?" --thinking`
- Revisar um PR manualmente: `opencode pr 42`
Use via **orquestrador** quando:
- Implementar uma feature completa com spec (`dev-orchestrator build`)
- Precisar de QA automático depois do dev
- Quiser loop dev→QA sem babysitting
```bash
# Modos do OpenCode
opencode # TUI interativo (exploração)
opencode run "tarefa" # one-shot (automação)
opencode run "..." --thinking # vê o raciocínio do modelo
opencode run "..." -f arquivo.ts # anexa contexto
```
### OpenSpec — O Spec Engine
Gerencia specs como source of truth. Use **diretamente** quando:
- Criar spec manualmente: `openspec new change minha-feature`
- Validar um spec existente: `openspec validate minha-feature --strict`
- Ver status de todos os changes: `openspec list --json`
- Arquivar spec concluído: `openspec archive minha-feature --yes`
Use via **orquestrador** quando:
- Quiser que a IA preencha o spec automaticamente (`dev-orchestrator spec`)
- O archive deve acontecer automático pós-QA-pass (`dev-orchestrator build`)
```bash
# Comandos OpenSpec que você mais usa
openspec list # o que está ativo?
openspec show minha-feature # ler um spec
openspec validate minha-feature # check pré-implementação
openspec status --change minha-feature # progresso dos artefatos
openspec instructions --change minha-feature # o que o agente deve fazer
```
### Dev Orchestrator — O Script
Automatiza o loop completo. **Substitui** os comandos manuais acima no fluxo principal.
```
você tem ideia → spec → review → build → PR pronto
↑ ↑ ↑
orquestrador você automático
```
---
## Pré-requisitos
```bash
npm install -g opencode-ai@latest
npm install -g @fission-ai/openspec@latest
opencode auth login # configura provider (OpenRouter, Anthropic, etc.)
```
---
## Comandos do Orquestrador
| Comando | O que faz |
|---|---|
| `dev-orchestrator init` | Configura repo (OpenSpec + .gitignore) — 1x por projeto |
| `dev-orchestrator spec <nome>` | Cria spec + worktree isolado + preenche spec via IA |
| `dev-orchestrator build <nome>` | Loop dev→QA (3 tentativas), merge automático se passar |
| `dev-orchestrator status` | Dashboard de features em andamento |
| `dev-orchestrator clean <nome>` | Remove worktree + branch (abortar feature) |
---
## Fluxo Completo
```
┌──────────┐ ┌──────────────┐ ┌──────────────┐ ┌─────────────┐
│ init │───→│ spec <nome> │───→│ [review] │───→│ build <nome> │
│ (1x/proj)│ │ (orquestrador│ │ (você aprova) │ │ (dev→QA loop)│
└──────────┘ │ preenche) │ └──────────────┘ └──────┬──────┘
└──────────────┘ │
┌────────────────────┘
│ QA PASS → merge + archive
│ QA FAIL → retry (3x max)
└────────────────────────
```
### 1. Inicializar
```bash
cd ~/projetos/meu-app
dev-orchestrator init
```
### 2. Especificar feature
```bash
dev-orchestrator spec add-oauth
```
O que acontece internamente:
1. `openspec new change add-oauth --json` → cria templates
2. `git worktree add -b dev-flow/add-oauth ../.worktrees/add-oauth/ HEAD`
3. `npm install` dentro do worktree
4. `opencode run "preenche os specs lendo a codebase"` → orquestrador preenche spec.md, design.md, tasks.md
### 3. Revisar os specs (MOMENTO CRÍTICO)
```bash
# Ler o spec gerado
cat openspec/changes/add-oauth/spec.md
cat openspec/changes/add-oauth/design.md
# Validar
openspec validate add-oauth --strict
# Ajustar manualmente se quiser — edite os arquivos
vim openspec/changes/add-oauth/spec.md
```
> **Você é o gatekeeper aqui.** Se o spec não está certo, o build vai sair errado.
> Gaste tempo revisando os specs — é o investimento mais rentável do fluxo.
### 4. Build (dev→QA loop)
```bash
dev-orchestrator build add-oauth
```
Loop:
```
Dev Phase ──→ OpenCode implementa + testa + commita
QA Phase ──→ OpenCode revisa: spec compliance, testes, regressões
├── PASS → merge no main, archive spec, remove worktree ✅
└── FAIL → volta pro Dev (até 3 tentativas)
falhou 3x → worktree mantido pra correção manual
```
### 5. Status dashboard
```bash
dev-orchestrator status
```
---
## Quando Sair do Orquestrador
O script cobre 90% dos casos. Saia dele quando:
| Situação | O que fazer |
|---|---|
| Spec ficou ruim e quero reescrever do zero | `openspec new change X` manual, preenche na mão |
| QA rejeitou e quero corrigir eu mesmo | `cd ../.worktrees/feature/` → edita → commita → `dev-orchestrator build feature` |
| Feature complexa demais pra um spec só | `openspec new change feature-pt1`, `openspec new change feature-pt2` |
| Quero iterar rápido sem spec | `opencode` TUI direto (pula o orquestrador) |
| Bug fix trivial (1-2 linhas) | `opencode run "fix: ..."` direto, nem cria spec |
---
## Paralelismo
Rode múltiplos features ao mesmo tempo:
```bash
# Terminal 1
dev-orchestrator build feature-a
# Terminal 2
dev-orchestrator build feature-b
```
Cada um em seu worktree isolado. O merge no main serializa no final — se houver conflito, o script para e avisa.
---
## Estrutura de Diretórios
```
~/projetos/meu-app/
├── src/
├── openspec/
│ ├── specs/ # specs arquivados
│ └── changes/ # specs ativos
│ └── add-oauth/
│ ├── spec.md
│ ├── design.md
│ └── tasks.md
├── .gitignore # inclui .worktrees/
└── ...
../.worktrees/
└── add-oauth/ # worktree isolado
├── .git # branch: dev-flow/add-oauth
├── src/
├── node_modules/
└── ...
```
---
## Opcional: Swarm Tools + .hive
Kanban mais visual, sem servidor:
```bash
npm install -g opencode-swarm-plugin
swarm setup
```
Dentro do OpenCode:
- `/swarm "tarefa"` — decompõe e spawna workers paralelos
- `/hive` — quadro kanban das tasks
- `/inbox` — mensagens entre agentes
O `.hive/` é uma pasta git-tracked. Independe do `dev-orchestrator`.
-306
View File
@@ -1,306 +0,0 @@
#!/usr/bin/env bash
# dev-orchestrator — OpenSpec + OpenCode dev→QA loop with git worktrees
# Zero dependencies beyond git, opencode, openspec. No heavy binaries.
set -euo pipefail
REPO="${DEV_FLOW_REPO:-$(pwd)}"
WORKTREE_ROOT="$REPO/../.worktrees"
# ── helpers ──────────────────────────────────────────────────────
_wt_path() { echo "$WORKTREE_ROOT/$1"; }
_wt_branch(){ echo "dev-flow/$1"; }
_die() { echo "✗ $*" >&2; exit 1; }
_ok() { echo "✓ $*"; }
# Ensure we're inside a git repo with openspec
_guard() {
cd "$REPO"
git rev-parse --show-toplevel >/dev/null 2>&1 || _die "not a git repo: $REPO"
[ -d openspec ] || _die "openspec not initialized. Run: dev-orchestrator init"
}
# ── commands ─────────────────────────────────────────────────────
cmd_init() {
cd "$REPO"
git rev-parse --show-toplevel >/dev/null 2>&1 || _die "not a git repo"
mkdir -p "$WORKTREE_ROOT"
if [ -d openspec ]; then
_ok "openspec already initialized"
else
openspec init --tools opencode --force
_ok "openspec initialized"
fi
grep -qxF '.worktrees/' .gitignore 2>/dev/null || {
echo '.worktrees/' >> .gitignore
_ok "added .worktrees/ to .gitignore"
}
echo ""
echo "Repo ready. Next: dev-orchestrator spec <feature-name>"
}
cmd_spec() {
local name="$1"
_guard
# ── validate name (kebab-case) ──
[[ "$name" =~ ^[a-z0-9]+(-[a-z0-9]+)*$ ]] || _die "name must be kebab-case: add-oauth, fix-login"
# ── create OpenSpec change ──
echo "── Creating OpenSpec change: $name ──"
openspec new change "$name" --json 2>/dev/null || {
_die "openspec new change failed. Already exists? Run: dev-orchestrator status"
}
# ── create worktree ──
local branch; branch="$(_wt_branch "$name")"
local wt; wt="$(_wt_path "$name")"
echo "── Creating worktree: $wt [$branch] ──"
git worktree add -b "$branch" "$wt" HEAD
# ── install deps in worktree (fully isolated, no symlinks) ──
if [ -f "$wt/package.json" ]; then
echo "── Installing dependencies ──"
(cd "$wt" && npm install --silent 2>&1 | tail -1) || true
fi
if [ -f "$wt/pyproject.toml" ] || [ -f "$wt/setup.py" ]; then
# shellcheck disable=SC1091
(cd "$wt" && [ -d .venv ] || python3 -m venv .venv && . .venv/bin/activate && pip install -e . -q 2>&1 | tail -1) || true
fi
echo ""
_ok "Spec '$name' scaffolded at openspec/changes/$name/"
echo " Worktree: $wt"
echo ""
echo " ── Next: explore in OpenCode ──"
echo " opencode"
echo " Then use OpenSpec native commands:"
echo " /opsx:explore — discuss the feature, refine requirements"
echo " /opsx:continue — fill spec artifacts one by one"
echo ""
echo " After specs are filled → dev-orchestrator build $name"
}
cmd_fill() {
local name="$1"
_guard
local wt; wt="$(_wt_path "$name")"
[ -d "openspec/changes/$name" ] || _die "spec not found: openspec/changes/$name. Run: dev-orchestrator spec $name"
echo "── AI filling spec from conversation context ──"
opencode run \
"You are filling an OpenSpec change for feature: $name.
STEPS:
1. Read the codebase structure first (scan src/, lib/, or equivalent).
2. Read openspec/changes/$name/ — note the template files created.
3. Read openspec/specs/ for existing specs to avoid conflicts.
4. For each template file in the change directory, fill it with:
- Clear requirements and acceptance criteria
- Files that need to change
- Dependencies on other modules
- Test plan
5. Run: openspec validate $name --strict
6. Fix any validation errors, re-run until clean.
7. Output a summary: spec files created, key decisions, estimated scope." \
--workdir "$REPO"
echo ""
_ok "Spec '$name' filled at openspec/changes/$name/"
echo " Review the spec, then: dev-orchestrator build $name"
}
cmd_build() {
local name="$1"
_guard
local wt; wt="$(_wt_path "$name")"
[ -d "$wt" ] || _die "worktree not found: $wt. Run: dev-orchestrator spec $name"
local branch; branch="$(_wt_branch "$name")"
local max_retries=3
for attempt in $(seq 1 $max_retries); do
echo ""
echo "══════════════════════════════════════════════════"
echo " $name — Dev Phase (attempt $attempt/$max_retries)"
echo "══════════════════════════════════════════════════"
opencode run \
"IMPLEMENT the spec at openspec/changes/$name/.
RULES:
- Read openspec instructions --change $name first
- Implement ALL requirements from the spec
- Write tests for every new code path
- Run the test suite and ensure it passes
- If tests fail, fix them before considering work done
- Commit with message: '$name: implement feature (attempt $attempt)'
- Do NOT modify openspec/ files — only source code and tests" \
--workdir "$wt" || {
echo "⚠ Dev phase had errors, proceeding to QA anyway..."
}
# ── QA phase ──
echo ""
echo "──────────────────────────────────────────────────"
echo " $name — QA Review"
echo "──────────────────────────────────────────────────"
local qa_file="/tmp/dev-orchestrator-qa-$$.txt"
opencode run \
"QA REVIEW for $name.
CHECKLIST (answer each with PASS or FAIL):
1. SPEC COMPLIANCE — Does the code implement everything in openspec/changes/$name/?
2. TESTS — Do all tests pass? Run them now.
3. REGRESSIONS — Does any existing test break? Check git diff vs main.
4. EDGE CASES — Are errors handled? Null/empty inputs? Timeouts?
5. CODE QUALITY — Clear naming? No debug leftovers? No commented-out code?
OUTPUT FORMAT (exactly these 2 lines, nothing else):
VERDICT: PASS|FAIL
REASON: <one-sentence summary>" \
--workdir "$wt" > "$qa_file" 2>&1
local verdict
verdict=$(grep '^VERDICT:' "$qa_file" | head -1 | awk -F': ' '{print $2}')
local reason
reason=$(grep '^REASON:' "$qa_file" | head -1 | cut -d' ' -f2-)
if [ "$verdict" = "PASS" ]; then
echo ""
_ok "QA PASSED — $reason"
# ── archive + merge ──
cd "$REPO"
echo "── Archiving spec ──"
openspec archive "$name" --yes 2>/dev/null || true
echo "── Merging to main ──"
git merge "$branch" -m "dev-flow: merge $name" 2>/dev/null || {
echo "⚠ Merge conflict. Resolve manually in $wt then run:"
echo " cd $wt && git checkout main && git merge $branch"
return 1
}
echo "── Cleaning up worktree ──"
git worktree remove "$wt" 2>/dev/null || true
git branch -d "$branch" 2>/dev/null || true
echo ""
_ok "$name — BUILD COMPLETE ✓"
rm "$qa_file"
return 0
fi
echo ""
echo "✗ QA FAILED — ${reason:-see $qa_file}"
rm "$qa_file"
if [ "$attempt" -eq "$max_retries" ]; then
_die "$name FAILED after $max_retries attempts. Worktree kept at $wt for manual fix."
fi
echo "↻ Sending back to dev with QA feedback..."
done
}
cmd_status() {
_guard 2>/dev/null || true
echo ""
printf "%-4s %-30s %-10s %-10s %-15s\n" "#" "FEATURE" "COMMITS" "SPEC" "WORKTREE"
printf "%-4s %-30s %-10s %-10s %-15s\n" "───" "──────────────────────────────" "──────────" "──────────" "──────────────"
local n=0
for wt in "$WORKTREE_ROOT"/*/; do
[ -d "$wt" ] || continue
local name
name=$(basename "$wt")
local branch; branch="$(_wt_branch "$name")"
local commits="?"
[ -d "$wt/.git" ] && commits=$(cd "$wt" && git rev-list --count "$branch" -- 2>/dev/null || echo "?")
commits="${commits:-0}"
# spec status
local spec="?"
[ -d "$REPO/openspec/changes/$name" ] && {
spec=$(cd "$REPO" && openspec status --change "$name" --json 2>/dev/null | \
python3 -c "import sys,json; d=json.load(sys.stdin); print(d.get('artifacts_completed','?'),'/',d.get('artifacts_total','?'),sep='')" 2>/dev/null || echo "active")
}
n=$((n+1))
printf "%-4s %-30s %-10s %-10s %-15s\n" \
"$n." "$name" "$commits" "${spec:-active}" "$wt"
done
if [ "$n" -eq 0 ]; then
echo " (no active features)"
fi
echo ""
}
cmd_clean() {
local name="$1"
cd "$REPO"
local wt; wt="$(_wt_path "$name")"
local branch; branch="$(_wt_branch "$name")"
git worktree remove "$wt" 2>/dev/null || true
git branch -D "$branch" 2>/dev/null || true
if [ ! -d "$wt" ]; then
_ok "cleaned $name"
else
_die "could not remove $wt"
fi
}
# ── dispatch ─────────────────────────────────────────────────────
case "${1:-}" in
init)
cmd_init
;;
spec)
[ $# -ge 2 ] || _die "usage: dev-orchestrator spec <feature-name>"
cmd_spec "$2"
;;
fill)
[ $# -ge 2 ] || _die "usage: dev-orchestrator fill <feature-name>"
cmd_fill "$2"
;;
build)
[ $# -ge 2 ] || _die "usage: dev-orchestrator build <feature-name>"
cmd_build "$2"
;;
status)
cmd_status
;;
clean)
[ $# -ge 2 ] || _die "usage: dev-orchestrator clean <feature-name>"
cmd_clean "$2"
;;
*)
echo "dev-orchestrator — OpenSpec + OpenCode dev→QA loop"
echo ""
echo "commands:"
echo " init Set up repo (run once per project)"
echo " spec <feature-name> Scaffold OpenSpec + worktree (no fill)"
echo " fill <feature-name> AI fills spec after exploration"
echo " build <feature-name> Dev→QA loop in isolated worktree"
echo " status Kanban view of features in flight"
echo " clean <feature-name> Remove worktree + branch"
echo ""
echo "flow: init → spec → [explore in OpenCode] → fill → build → [QA loop]"
echo ""
echo "worktrees live at: ../.worktrees/<feature>/"
echo "branches named: dev-flow/<feature>"
exit 1
;;
esac