How this repo works
The model
One repo, managed with GNU Stow. Every top-level
directory except packages/ and docs/ is a stow package: its internal path
structure mirrors where it belongs under $HOME, and stow package symlinks it into
place. tmux/.config/tmux/tmux.conf becomes ~/.config/tmux/tmux.conf -> ../dotfiles/tmux/.config/tmux/tmux.conf.
Nothing here is copied onto the live system — it’s all symlinks, so editing
~/.config/tmux/tmux.conf and editing the file in this repo are the same action.
packages/pacman.txt and packages/aur.txt are the exception: plain package-name
manifests, not something stow ever touches.
As of v1.0.0, this repo follows a real main/development branch and release
process – see docs/VERSIONING.md for the full model (development was
renamed from develop, both locally and on GitHub, together with adding the
Stable/Nightly install-time channel choice – see that file’s own note on
this). Day-to-day work happens on development; main only ever moves via a
tested, tagged release.
Package map
sway ~/.config/sway/
Window manager, keybindings, lock/idle config
waybar ~/.config/waybar/
Top status bar
wofi ~/.config/wofi/
App launcher / dmenu-style prompts
nwg-bar ~/.config/nwg-bar/
Power menu (Lock/Logout/Suspend/Reboot/Shutdown) -- absolute paths only, no shell involved anywhere in its invocation chain.
Full details
One button-grid popup menu now (bar.json: the power button, Lock/Logout/Suspend/Reboot/Shutdown) – docker.json used to live here too, replaced by docker-picker.py for the same reason the Wi-Fi/Bluetooth pickers exist (see the changelog): asked for the docker module to actually show which containers are running, not a static two-button menu, which is exactly the kind of dynamic content nwg-bar cannot do. See the changelog for why this isn’t wofi – its --dmenu mode hit several real, separately-confirmed limits for a button-grid popup specifically (no CSS cursor support, --columns capped at 2, --hide-search breaks rendering, close_on_focus_loss fights focus_follows_mouse) – though wofi’s --dmenu mode works fine for a plain scrolling list, see networkmanager-dmenu/wifi-picker.py/bluetooth-picker.py below. Important, and it bit twice: nothing in the nwg-bar invocation chain goes through a shell – neither its own exec commands (exec.Command() directly, confirmed by reading the source) nor its -t/-s flag values (confirmed by testing env nwg-bar -t '~/...' directly – it doesn’t even detect a non-absolute path the way it does for the icon field, it just concatenates onto its default config dir and fails outright). Never use ~ anywhere in an nwg-bar invocation or its JSON exec fields — always the absolute path. Icons: either an absolute file path, or a name from the system icon theme (createPixbuf() falls back to gtk.IconThemeGetDefault().LoadIcon()) – verify a given icon name actually exists in the installed theme (find /usr/share/icons/<theme> -iname '<name>.svg') before using it, the same discipline as verifying Nerd Font codepoints elsewhere in this repo. volume.json, wifi.json, and bluetooth.json used to exist here too, all three removed – nwg-bar only renders static buttons from a fixed JSON file (confirmed reading its Go source: no scale/slider/dynamic-list widget type exists in its schema at all, and its launch() fires a 150ms auto-gtk.MainQuit() after every click), so anything needing a live scan (Wi-Fi/Bluetooth devices) or continuous adjustment (volume) is architecturally impossible here – looked like broken commands but was really just the wrong tool for the job. Volume/brightness, Wi-Fi, and Bluetooth are all handled below instead.
docker-picker.py ~/.local/bin/docker-picker.py (scripts/ package)
Docker picker for waybar's docker module -- per-container info, Restart/Stop actions, verified against real containers.
Full details
Docker picker for waybar’s docker module – replaced nwg-bar’s static docker.json (Stats / Stop All, no visibility into what’s actually running, the same architectural gap the Wi-Fi/Bluetooth pickers exist for). Same wofi --dmenu approach as the other two pickers, self-contained on the docker CLI directly (no upstream tool to wrap here). Per running container: one Sky info row (name, image, status, ports – view-only, selecting it just re-shows the same info via notify-send, never destructive) plus two action rows right after it, Peach “Restart” and Red “Stop” – deliberately two different colors for these rather than reusing one “command” color the way the other pickers do, since stop and restart have a real severity difference worth signaling (restart comes back on its own; stop stays stopped until someone acts again). “Open docker stats” and “Stop all containers” (Teal, matching the pickers’ existing command-category hue) stay available as top-level shortcuts either way, including when nothing is running. Verified the actual actions, not just that the menu renders: started a real container, restarted it through the picker and confirmed via docker ps that its uptime genuinely reset, then stopped it the same way and confirmed via docker ps -a that it exited – left the system in the same all-stopped state it was already in before testing.
update-check.sh ~/.local/bin/update-check.sh (scripts/ package)
Checks GitHub for new commits once per login, prompts Update Now/Later via nwg-bar if any are found.
Full details
Asked directly for auto-update-checking on every boot with a permission prompt, not a silent auto-update. Runs once per sway login via systemd/.config/systemd/user/update-check.service (Type=exec, WantedBy=sway-session.target, PartOf=graphical-session.target). It used to be WantedBy=default.target with After=network-online.target, and 32 of 37 runs failed: the user manager lingers, so default.target is reached at boot, with no sway for the prompt and ~4 s before Wi-Fi connects, and the user manager’s network-online.target doesn’t track the real network. update-check.sh now waits for NetworkManager (nm-online -t 60) and retries the fetch 3 times, 10 s apart. It’s deliberately not a sway exec/exec_always line, since exec_always would re-run this on every swaymsg reload (several times an hour during normal config editing), not just once per boot.
Compares HEAD against @{u} (whatever the checked-out branch’s own configured upstream is – development’s is origin/development on this machine) rather than a hardcoded branch name, so it keeps checking the right thing whichever of the two real channels (see docs/VERSIONING.md’s “Update channels” section) install.sh set up at first install, or if some other branch entirely is ever checked out instead; a branch with no upstream configured (a throwaway local/feature branch) has nothing to compare against and is skipped cleanly, not treated as an error. git fetch/pull both pinned to ssh -o BatchMode=yes -o ConnectTimeout=8 – this runs headless with no TTY to prompt on, so an SSH agent that isn’t up yet must fail fast rather than hang the whole login on a passphrase prompt nothing will ever answer; confirmed live that the real auth path (gpg-agent’s SSH support, already running as its own user service) works fine non-interactively, so this is a safety net for an edge case, not routinely needed.
The notification/prompt say “Stable”/”Nightly”, not the raw branch name – derived straight from whichever branch is actually checked out (main -> Stable, development -> Nightly, anything else shown as-is) rather than a separate channel-preference file, so there’s nothing that can drift out of sync with what’s genuinely on disk. If @{u} is ahead, sends a notify-send with the channel label, the real commit count, and the latest subject line, then opens nwg-bar/.config/nwg-bar/update-bar.json (Update Now / Later, its own update-bar-style.css – same glass-card recipe as the power menu, Green/Overlay0 accents rather than the power menu’s five-color severity scale, since neither action here is remotely destructive). “Update Now” runs update-apply.sh: git pull --ff-only (refuses and notifies on any real conflict/divergence rather than merging or rebasing on your behalf), stow -R over the same package list install.sh itself discovers (every top-level dir except packages/docs/hidden ones, so a newly-added or removed package is handled the same as an existing one), then swaymsg reload/makoctl reload/systemctl --user daemon-reload for whatever can hot-reload – deliberately does NOT guess which systemd services to restart afterward (too easy to bounce something disruptively mid-notification or mid-suspend-hook); the final success notification says as much, pointing at a re-login/reboot for any unit-file changes to fully take effect. “Later” is a no-op (exec: "true") – nwg-bar closes on any click regardless, and the check simply runs again next login.
Verified without ever touching the real ~/dotfiles checkout’s git state: cloned it into a scratch directory, rolled that clone’s HEAD back two real commits, and ran the actual detection logic, the real git pull --ff-only, and a real stow -R against a scratch fake $HOME – confirmed the pull fast-forwarded correctly and the resulting symlinks (readlink on the scratch .config/sway) pointed exactly where the real ones do. The nwg-bar prompt and its paired notification were both fired for real and screenshotted together. Also confirmed live in nwg-bar’s own package card above (~ breaks silently there, no shell in its exec chain) that both update-bar.json exec fields use either an absolute path or a bare PATH-resolvable command, never ~.
battery-limit-picker.py / battery-limit-watch.sh ~/.local/bin/ (scripts/ package)
Battery charge-limit reminder on waybar's battery module -- a software nudge only, deliberately not a hardware charge cutoff. See the changelog for the full reasoning.
Full details
Asked directly for a waybar battery-icon click to offer a charge-limit picker (70% suggested) for better long-term battery health. Checked the actual hardware first rather than assuming a charge_control_end_threshold-style sysfs knob exists: this laptop (Acer Aspire A315-23) has none, and the only Linux workaround – a community acer-wmi-battery kernel module – is untested on this exact model (its own MODELS.md lists several other A315 variants working/not-working, this one’s in neither list) and only offers a fixed 80% toggle regardless. Acer’s own Community forum also has another A315-23 owner reporting a charger-plug-in boot crash after enabling the equivalent feature in Acer’s own official Windows software, on this same model – a strong signal the EC-level feature itself is unreliable here regardless of OS. See the changelog’s full entry for the sourcing. Landed on a software-only reminder instead of either silently building a fake control or silently dropping the request.
battery-limit-picker.py: wofi popup, same style as docker-picker.py/the other pickers (Sky = selectable option, Red = Off, Green = whatever’s currently set). Writes a plain percentage or off to ~/.local/state/battery-limit/threshold – the same ~/.local/state/<feature>/ convention caffeine-toggle.sh/notification-mode.sh already use, not a new pattern.
battery-limit-watch.sh (battery-limit-watch.service, WantedBy=default.target, added to install.sh’s enable list): reacts to real power_supply uevents (same udevadm monitor pattern as battery-warning-dismiss.service) plus a 60 s tick, so a plug/unplug during suspend is still caught after resume. Nudges once per situation: plug in while already at/above the limit (“charging isn’t needed, unplug” – the preventive case), crossing the limit while charging, and reaching 100% after being nudged. Silent when the reminder is off, on battery, plugging in below the limit, or when the service/boot starts with the charger already in. Changing the limit re-arms it (.notified stores the limit it fired for); the picker itself announces “already past this limit” immediately if you set it while plugged and above. Power-supply devices are found by their type (Battery / anything else with online), not hardcoded BAT1/ACAD. The chosen limit persists in ~/.local/state/battery-limit/threshold across reboots.
Verified live: set_limit() exercised directly (not just the wofi shell), confirmed via makoctl history -j that both real notifications fired with the right icon/text and that the state file matched. The threshold/dedup branching was checked against five real scenarios (first crossing, repeat crossing with flag set, dropped back below, unplugged, reminder disabled) – all five correct. The service was started live on this machine while genuinely discharging (ACAD/online = 0) and confirmed it stays silent and creates no notified-flag in that real state – the one live end-to-end check available without AC connected in-session; the AC-plugged crossing path relies on the branching test above rather than a live charger connection.
bluetooth-picker.py ~/.local/bin/bluetooth-picker.py (scripts/ package)
Bluetooth device picker for waybar's bluetooth module -- self-contained on bluetoothctl, no third-party wofi/rofi tool depended on.
Full details
Bluetooth device picker triggered from waybar’s bluetooth module on-click, replacing nwg-bar’s static bluetooth.json (On/Off/Manager buttons, no device list). Same modal approach as wifi-picker.py deliberately – wofi --dmenu, category colors, active-connection border-stand-in marker – but fully self-contained rather than wrapping a third-party tool: checked first, and the only wofi/dmenu-adjacent Bluetooth picker that exists at all is a 3-year-stale rofi-specific AUR package (rofi-bluetooth-git), not worth depending on over a ~150-line direct script built on bluetoothctl (bluez 5.87 on this machine; confirmed via bluetoothctl --help that devices Paired/devices Connected/info <mac>/--timeout <n> scan on all exist and work as expected before writing anything against them – note paired-devices is not a valid command in this bluez version, devices Paired is). Menu: power toggle, scan (bounded --timeout 8 non-interactive scan, then relaunches itself to show a fresh list – same self-relaunch pattern networkmanager_dmenu uses after its own rescan), Bluetooth Manager shortcut, paired devices (click connects/disconnects, whichever applies), and any device bluez currently knows about but hasn’t paired with yet (bluetoothctl devices bare, minus the already-paired set – populated by a scan; without this “Scan for Devices” would run a scan and then have nothing new to act on). Colors mirror wifi-picker.py’s categories directly: Sky for a paired device, Teal for a nearby-but-unpaired one (same meaning as wifi-picker.py’s “saved” Teal – known/available, not the thing you’re actively connected to), Peach for a command – deliberately does not surface any WiFi-specific action, the same way wifi-picker.py now excludes the Bluetooth toggle it used to inherit from upstream (see the changelog). Selecting a nearby device pairs, trusts, and connects in one go, bounded to a 20s timeout – most consumer audio/BLE gear uses passkey-less “just works” pairing and completes well within that, but a device that genuinely needs an interactive PIN prompt has nowhere for that prompt to go in this flow and will just time out with a message pointing at Bluetooth Manager instead. run() centrally catches subprocess.TimeoutExpired on every bluetoothctl call (connect/disconnect/pair can all hang waiting on a device that never responds) and turns it into an ordinary non-zero return code, rather than an uncaught traceback crashing the picker.
networkmanager-dmenu ~/.config/networkmanager-dmenu/
Wi-Fi picker backend -- official extra-repo package, wofi as its dmenu backend, scan -> connect -> password-prompt-if-needed flow.
Full details
Wi-Fi picker triggered from waybar’s network module on-click – replaced nwg-bar’s static wifi.json (On/Off/Settings buttons, no scan list) with a real scan → click a network → password-prompt-if-needed → connect flow, plus Wi-Fi on/off toggle and a nm-connection-editor shortcut, all in one menu. From the official extra repo, not AUR. Uses wofi --dmenu as its menu backend (dmenu_command = wofi in config.ini) – confirmed via reading the tool’s own source (networkmanager_dmenu, the Python script itself) that it has first-class wofi support, not just generic dmenu-protocol compatibility: it auto-detects wofi and appends wofi’s own -P/--password flag for the passphrase prompt (masks input, confirmed via wofi --help that flag exists locally), and has wofi-specific highlight markup for the currently-connected network. Smoke-tested wofi --dmenu with a piped test list before wiring this up, given this repo’s prior history of real wofi --dmenu bugs (see the nwg-bar row above) – rendered a normal solid popup with no issue, since those earlier bugs were specifically about forcing a button-grid layout through --dmenu, not this tool’s plain vertical list usage. List entries show icon+name only (format = {icon} {name} in config.ini) – no visible signal%/bars/security text, wifi_icons (5 Material Design strength glyphs, U+F092F..U+F0928, verified present in the installed font via fc-query the same way every other icon in this repo is verified) carries the strength instead. Wofi’s dmenu list has no per-item hover-tooltip support at all (checked: no such feature exists in wofi) – so “see full detail on hover” genuinely isn’t achievable with this tool, not a partial implementation of it.
wifi-picker.py ~/.local/bin/wifi-picker.py (scripts/ package)
Themed wrapper around networkmanager-dmenu -- adds category colors via monkeypatching two functions, not a fork of the ~1500-line upstream script.
Full details
Themed wrapper around the installed networkmanager_dmenu – waybar’s network module on-click runs this instead of calling networkmanager_dmenu directly. Adds category-based colors (Sky for a real network, Peach for a command like Rescan/Enable/Disable/Launch-Editor, Teal for a saved connection) so the menu’s different kinds of rows are visually distinguishable at a glance – upstream only colors the single active/connected line, everything else renders identically. Loads /usr/bin/networkmanager_dmenu at runtime via importlib (spec_from_loader with an explicit SourceFileLoader, since spec_from_file_location can’t infer a loader for a file with no .py extension – returns None silently rather than raising, confirmed by hitting it) and monkeypatches exactly two functions (get_wofi_highlight_markup, get_selection) rather than forking the whole ~1500-line script – every actual NetworkManager interaction still runs the real, maintained upstream code. Category is detected via Action.func identity (process_ap == a real network; toggle_wifi/rescan_wifi/launch_connection_editor/delete_connection/show_wifi_password/prompt_saved == a command) plus the ":SAVED" suffix upstream already appends to saved-connection names – wrapped in a try/except AttributeError so an upstream refactor degrades to “no color” instead of crashing the whole picker. A real, confirmed pitfall while verifying this: a background-launched test process reliably vanished between one tool call and the next in this remote session (setsid ... & in one shell invocation, checked with pgrep in a separate one) even though setsid should detach it – the remote execution environment doesn’t appear to preserve backgrounded processes across separate calls the way a persistent terminal would. This produced a false “the colors aren’t rendering” result once, from screenshotting a stale/empty window. The real fix for testing this kind of thing here: launch, wait, and screenshot all within one single tool call – confirmed correct rendering (Sky/Mauve/Teal all found via pixel search in a live window) once tested that way.
mako ~/.config/mako/
Notifications
mpd ~/.config/mpd/
Music Player Daemon -- the actual player/backend, replacing termusic entirely. Runs as `mpd.service` (systemd --user, shipped by the package itself). See its own `README.md`.
rmpc ~/.config/rmpc/
Terminal music player UI, an MPD client -- no playback logic of its own. Paired with `music-search.py` (`scripts/` package) for immediate search/play via `yt-dlp` + `mpv` (or search/download into the library with a flag) -- no Spotify/YouTube Premium needed. See its own `README.md`.
kitty ~/.config/kitty/
Terminal, incl. the Nerd Font setting everything else depends on
tmux ~/.config/tmux/, ~/.tmux/scripts/
Multiplexer config + the docker status-bar script
nvim ~/.config/nvim/
Editor
zed ~/.config/zed/
GUI editor
zsh ~/.zshrc, ~/.p10k.zsh
Shell + prompt (oh-my-zsh and its plugins are bootstrapped by `install.sh`, not vendored — see below)
scripts ~/.local/bin/
Utility scripts wired into sway keybindings and the wallpaper timer
systemd ~/.config/systemd/user/
User units: daily wallpaper fetch, optional detached tmux session
gtk ~/.config/gtk-{3,4}.0/
GTK theme selection (Catppuccin Mocha)
xdg ~/.config/mimeapps.list
Default app associations (browser, image viewer, etc.)
Why some things aren’t here
Not everything under ~/.config is vendored. Deliberately excluded:
- Auth/session state:
gh,github-copilot,.claude*,.gemini, browser profiles (BraveSoftware,chromium,google-chrome,mozilla,zen). These contain credentials or get regenerated the first time you log into each tool — they don’t belong in a public repo andinstall.shdoesn’t try to restore them. - Caches/generated state:
dconf(mostly — see below),go,yarn,npm,pgcli/btop/lazygitapp state,xfce4(only contained Thunar’s stock example custom action, not real configuration). - Secrets: never committed, ever. See “Secrets” below.
If you add a new tool and aren’t sure which bucket it falls into: does deleting it lose your login, or just some cache/history you don’t mind rebuilding? If the former, leave it out.
Secrets
Convention (already the one in the README before this was written up): anything
machine-specific or sensitive goes in a *local file that isn’t part of any stow
package and is gitignored (?*.local in .gitignore — note the ?*, not *; a bare
*.local glob would also match a directory literally named .local, which is exactly
what scripts/.local/bin/ needs to exist as).
Example already in use: zsh/.zshrc ends with
[ -f ~/.zshrc.local ] && source ~/.zshrc.local
and ~/.zshrc.local (not in the repo) holds export GEMINI_API_KEY=.... Follow the
same pattern for anything else that needs a real secret.
install.sh, step by step
Entry point for a from-scratch machine (see README’s Quick Start). Runs as your normal
user, sudo where it needs root. What it installs is all data in the repo, so
reproducing the machine means keeping these in sync with it, not editing the script:
| Where | What |
|---|---|
packages/pacman.txt, packages/aur.txt |
every explicitly wanted package (direct runtime dependencies of the configs too, not left to come in as someone else’s dependency) |
packages/hardware.sh |
what depends on the CPU/GPU, detected on the machine: microcode and GPU driver packages (hw_packages, added to pacman.txt by install.sh and vayu-verify), and whether splash/’s vayu-kms applies (hw_is_raven, also deciding whether system/apply.sh drops the stock kms hook) |
packages/manifest.sh |
system/user units to enable, start, disable or mask; groups; ufw rules; Homebrew formulae; zsh plugin clones; dconf keys. Sourced by both install.sh and vayu-verify |
system/ |
root-owned files, mirrored from / (system/etc/sysctl.d/99-custom.conf -> /etc/sysctl.d/99-custom.conf), installed by system/apply.sh |
greeter/ |
the login screen (greeter/apply.sh) |
splash/ |
the Plymouth boot splash (splash/apply.sh; frames rendered by splash/make-frames.py) |
| every other top-level dir | a stow package (dotfiles-stow-packages lists them) |
Logging. All output goes to the terminal and to
~/.local/state/vayu/install-logs/install-<date>.log (latest.log links the newest),
with colour codes and progress-bar redraws stripped. The log also gets a set -x
trace (file and line of every command), and the ERR trap prints the exact command,
line and exit code of anything that fails. Each step is timed.
Failure handling. Each step runs in its own subshell with errexit, so its first
failing command ends that step only. Critical steps (preflight, prerequisites, repo,
yay, stow) stop the run, since everything after needs them; any other failure is
recorded and the rest still runs. Package steps check every name first (pacman -Si;
one AUR RPC call for aur.txt) and, if the batch install fails, retry one package at a
time, so one renamed package is named in the summary instead of failing all 100+. The
summary lists every step, every failed item and the log path; the exit code is non-zero
if anything failed. A re-run skips whatever is already done.
Steps:
- Ask for the update channel (or check the
stable/nightlyargument) when the repo isn’t cloned yet. - Preflight: not root, Arch, free space, archlinux.org/github.com/AUR reachable, stale
pacman lock removed. Then one
sudo -v, kept fresh in the background so a long AUR build never stalls on a password prompt. - Prerequisites:
archlinux-keyringfirst (install images ship old keyrings), then a fullpacman -Su(Arch doesn’t support partial upgrades), then git, stow, base-devel, curl, jq. - Clone the repo (or
git pull --ff-only; with local changes or a diverged branch it warns and installs from the checkout as it is). - Hand over to the cloned
install.sh: the copycurlfetched is alwaysmain’s, which may not match a Nightly checkout’s lists and layout. The step results and log carry across. - Official packages, then yay (built from the AUR if missing), then AUR packages.
- Stow: dry-run
stow -n -Rto find what’s in the way — stow 2.4 reports a real file as... over existing target X since ...and a symlink it doesn’t own asexisting target is not owned by stow: X. Both get moved to~/.dotfiles-backup/<date>/, thenstow -R(restow also drops links to files removed from the repo). system/apply.sh: install what differs under/, then reload what changed (sysctl --system,daemon-reload, logind HUP), plus pacman’sColor.greeter/apply.sh(greetd + tuigreet-ace, console font and palette), thensplash/apply.sh(Plymouth, the Vayu theme, theplymouthhook and the quiet-boot kernel options; rebuilds the UKI).- zsh as login shell, oh-my-zsh (downloaded to a file first, so a failed download can’t run as an empty script), Powerlevel10k and plugins.
- tmux plugins: every
@pluginintmux.confcloned into~/.tmux/plugins/(not TPM’sinstall_plugins, which asks a running tmux server for its path). - Neovim plugins (
nvim --headless "+Lazy! restore", pinned bylazy-lock.json). corepack enable(thepnpm/pnpx/yarnshims), Homebrew +gh, Claude Code hooks.- Services from
packages/manifest.sh: enable (and start) system units, disabledocker.service/sddm, mask hibernate and the NvPCR units, ufw rules andufw --force enable(ufw.service alone doesn’t turn the firewall on). Groups (docker,inputfor ydotool’s/dev/uinput), then user units. - dconf theme keys, a first wallpaper (the timer’s first run is at midnight, and sway
and swaylock both read
current.jpg). vayu-verify.
vayu-verify: does the machine match the repo?
scripts/.local/bin/vayu-verify (on PATH once stowed) checks, read-only and without
sudo: every listed package installed; a dry-run restow finds nothing to link and no
conflicts; system/apply.sh --check finds no difference; greetd, tuigreet-ace and the
console font in place; every unit in packages/manifest.sh enabled/active/disabled/
masked as listed; ufw on with its rules; groups, login shell, oh-my-zsh and plugins,
TPM, the pnpm shim, Homebrew formulae, the Claude Code hook, dconf keys, a wallpaper.
It prints only failures (-v lists passes too) and exits 1 if there are any. Re-running
install.sh fixes whatever it reports, except packages that left the repos (rename them
in the list). update-apply.sh notifies you to run install.sh when an update touched
packages/ or system/.
The installer was tested end to end in a sandbox (throwaway $HOME with a clone of
the repo; sudo, pacman, yay, systemctl, nvim and dconf stubbed; stow, git, oh-my-zsh,
the AUR RPC and the wallpaper fetch real), including the curl-style hand-over,
planted failures (a package gone from the repos, one failing to install, files in
stow’s way) and an immediate re-run. What a sandbox can’t cover is real root: the
first real check is a run on a blank machine or VM.
exec order matters: dbus-update-activation-environment must come first
sway/config’s very first exec is ~/.local/bin/sway-session.sh, whose first
step is dbus-update-activation-environment --all. This
has to run before anything that starts a systemd --user service (swayidle.service,
sway-audio-idle-inhibit.service) — those services run under the systemd user
manager, a separate process from sway with its own environment, and it has no idea
WAYLAND_DISPLAY/SWAYSOCK exist until this command imports them. Get the order
wrong and those services crash-loop with “Unable to connect to the compositor” —
happened for real once, see the changelog. If you add another systemd --user
service that needs to talk to sway, its exec systemctl --user start ... line needs
to come after this one.
Note this only matters at sway’s actual startup: plain exec lines (unlike
exec_always) don’t re-run on swaymsg reload, so testing an exec-order fix via
reload alone won’t catch an actual ordering bug — it only shows up on a real fresh
login.
sway-session.target: user services stop with sway
sway-session.sh then starts sway-session.target
(systemd/.config/systemd/user/), which binds graphical-session.target. That
target refuses a manual start, and nothing else ever started it. The script then
waits on sway’s shutdown IPC event, stops the target, and unsets
WAYLAND_DISPLAY/SWAYSOCK/I3SOCK/DISPLAY in the user manager.
Why it matters: the user manager lingers (Linger=yes), so it outlives a logout.
Before this, PartOf=graphical-session.target did nothing: swayidle,
swaylock-on-sleep, sway-audio-idle-inhibit, bar-events, kdeconnect, phone-events and
the portals kept running after sway exited. They crash-looped against the dead
compositor (kdeconnect dumped core every 2 s, and xdg-desktop-portal-luminous hit
its start limit and stayed failed into the next login). A new Wayland-client user
unit should get PartOf=graphical-session.target for the same reason.
Idle management: swayidle as a systemd user service, plus caffeine mode
swayidle (screen dimming, lock, suspend timeouts — sway/.config/sway/idle/config)
runs as a systemd user service, systemd/.config/systemd/user/swayidle.service, not a
raw exec line. sway/config starts it with exec ~/.local/bin/swayidle-startup.sh
rather than launching the process directly.
This exists specifically so caffeine mode — the coffee-cup icon in waybar,
custom/caffeine — can be trivial and reliable: on is systemctl --user stop
swayidle.service, off is systemctl --user start swayidle.service. No PID files, no
state tracking for the live state; systemd’s own active/inactive state is the
caffeine state, which caffeine-status.sh just reads back for the waybar icon.
Stopping the whole service (rather than e.g. a systemd-inhibit suspend lock) is
deliberate — an inhibitor wouldn’t stop swayidle’s own timeout 600 swaymsg output *
dpms off line, since that never goes through logind at all. Only actually stopping
swayidle keeps the screen on.
Caffeine mode surviving a reboot
Caffeine mode used to silently reset to off on every reboot, no matter what it was
left at. Root cause: swayidle.service is enabled (WantedBy=default.target –
confirmed via systemctl --user is-enabled swayidle.service), so systemd’s user
manager auto-starts it at every login on its own, completely independent of
sway – the plain exec systemctl --user restart swayidle.service line that used
to be here was reasserting “start” regardless of what caffeine mode had last been
set to, since it had no way to know.
Fixed with a tiny persisted-state file, ~/.local/state/caffeine/enabled (present
= caffeine on, absent = off – a marker file, not a content-parsing exercise).
caffeine-toggle.sh now writes/removes it alongside stopping/starting the service.
scripts/.local/bin/swayidle-startup.sh (what sway’s exec line actually calls
now) is the single source of truth for swayidle’s state at startup: if the marker
is present, it explicitly stops the service systemd’s default.target just
auto-started, overriding it; otherwise it does the same restart this line always
did (still needed for the dbus-update-activation-environment ordering reason
documented right above this exec line – swayidle started by systemd at login
can predate that).
Verified the actual failure mode, not just the fix: toggled caffeine on (marker
created, service stopped), manually re-triggered the auto-start systemd would do
at the next login (systemctl --user start swayidle.service), confirmed the
service was back to active exactly as it would incorrectly be pre-fix, then ran
swayidle-startup.sh the way sway’s exec line would and confirmed it correctly
stopped the service again. Also verified the off case (no marker -> normal
restart, stays active) and that caffeine-status.sh reports the right state
through a full toggle sequence. Couldn’t trigger a literal reboot to verify this
against a real fresh sway startup without disrupting the live session, so this
rests on an exact functional simulation of the real startup sequence rather than
an actual reboot.
An earlier version managed this with raw pkill/background-and-disown instead of
systemd, and hit a real hang: the backgrounded process ended up stuck as a direct
child of the toggle script (visible via pstree and /proc/pid/wchan = do_wait).
Systemd unit start/stop doesn’t have that failure mode.
The gap the entry above flagged (no real reboot to test against) turned out to
matter. A genuine system reboot happened during a later session, and a routine
“check whole-system stability before cutting a release” sweep found
swayidle.service sitting inactive (dead) – on a completely fresh boot, not
some leftover state. journalctl --user -u swayidle.service -b showed exactly
what the ordering comment above already predicted: 4 rapid failures in ~7
seconds, all "Unable to connect to the compositor... check WAYLAND_DISPLAY",
before swayidle-startup.sh (the actual fix, exec‘d later in sway/config)
ever got a chance to run. The part the manual simulation upstream missed: those
4 failures land inside systemd’s default start-limit-burst (5 failures / 10s
interval), so by the time swayidle-startup.sh’s own systemctl --user restart
finally runs, the unit is already in start-limit-hit – and a plain restart
against a unit in that state is a silent no-op, no error, nothing in the
script’s own output to catch. Manually running systemctl --user start once (as
the original verification did, simulating “the next login”) never reproduces
this, since one manual start attempt can’t rack up 4 failures the way systemd’s
own auto-restart loop does in the first few seconds of a real boot – a genuine
example of a simulated test passing while the real trigger it stood in for still
had a live bug.
Fixed by adding systemctl --user reset-failed swayidle.service to the top of
swayidle-startup.sh, before either branch. Confirmed the theory directly before
writing the fix, not just assumed it: reproduced the dead state, ran
systemctl --user restart alone against it (no change, still dead, matching the
no-op theory), then reset-failed immediately followed by restart (came up
active in under a second) – isolated proof before touching the real script.
Verified the actual fixed script afterward too: ran it live in both of its own
branches (caffeine-on -> correctly stops a freshly-reset unit; caffeine-off ->
correctly restarts and stays active), and relaunched rmpc from cold afterward to
confirm the reboot hadn’t quietly broken anything else in the same session
(config/theme loaded correctly, cache_dir resolved, zero errors – see the rmpc
theme entries above).
Superseded 2026-09-30: the root cause was the WantedBy=default.target itself.
swayidle.service, swaylock-on-sleep.service and sway-audio-idle-inhibit.service
now have no [Install] section (like bar-events.service): nothing starts them
before sway exists, sway’s config starts them after dbus-update-activation-environment,
and swayidle-startup.sh is just “start unless the caffeine marker exists”. The
reset-failed workaround is gone. Don’t re-enable them; a Wayland client can’t run
from default.target.
sway-audio-idle-inhibit.service has a real crash history of its own
(coredumpctl list shows several SIGABRTs across separate days, unrelated to
this reboot), but confirmed currently healthy and running since this exact boot
with no crash – not chased further here since it isn’t reproducing right now,
noted for awareness rather than fixed blind.
Caffeine mode was silently disabling the lock-before-sleep security hook
Real bug, found while investigating the lid-close/hibernate incident below:
closed and reopened the lid, the system suspended and resumed fine, but
swaylock never ran – straight back to a logged-in session, no lock
screen at all. Root cause: ~/.local/state/caffeine/enabled had been
stuck present since two days earlier (a forgotten toggle, not a crash),
and per the “Caffeine mode surviving a reboot” section above,
swayidle-startup.sh stops swayidle.service entirely whenever that
marker exists – which also took the before-sleep 'swaylock ...' hook
in sway/idle/config down with it. Confirmed directly:
systemctl --user status swayidle.service showed inactive (dead) the
whole time, so before-sleep never had a service to fire from.
This is a real design gap, not just a stale-flag accident: caffeine
mode’s whole point is “don’t lock/dim/suspend on idle while I’m using
this,” and that should have nothing to do with “lock the screen before
the lid physically closes” – those are different triggers with
different intent, and they shouldn’t have shared one service’s on/off
state. Fixed by splitting before-sleep out of sway/idle/config
into its own file, sway/idle/lock-on-sleep, run by a second, separate
systemd user service, swaylock-on-sleep.service – always
started unconditionally from sway/config right alongside
sway-audio-idle-inhibit.service, and never touched by
caffeine-toggle.sh/swayidle-startup.sh. Caffeine mode still stops
the idle-timeout instance (dim/lock-on-idle/auto-suspend) exactly as
before; lid-close now locks every time regardless of caffeine state.
Verified live across several real lid close/open cycles after the fix
(see the hibernate incident below for the first one, which is what
originally surfaced this): the lock screen came up correctly, and
swaylock-on-sleep.service stayed active (running) throughout,
untouched while swayidle.service was separately toggled on and off
via caffeine mode.
Lid-close, suspend vs hibernate: a real hibernate/amdgpu resume crash
Reported directly: opened the lid, pressed Enter, nothing happened; the
power button did nothing visible either; screen stayed black until a
forced power-cycle. journalctl -b -1 told the real story –
/etc/systemd/logind.conf.d/10-lid-hibernate.conf (a manual override,
predating this incident, not something install.sh ever wrote) had set
HandleLidSwitch=hibernate instead of the base logind.conf’s
suspend. The hibernate cycle itself completed
(PM: hibernation: hibernation exit, Operation 'hibernate' finished),
but seconds into resume the GPU firmware reload failed:
amdgpu 0000:05:00.0: failed to load ucode RLC_RESTORE_LIST_CNTL(0x29)
amdgpu 0000:05:00.0: psp gfx command LOAD_IP_FW(0x6) failed ...
amdgpu 0000:05:00.0: ring gfx timeout, signaled seq=10261, emitted seq=10265
amdgpu 0000:05:00.0: GPU reset begin! ... GPU Recovery Failed: -110
The rest of the OS came back fine (WiFi reconnected, Bluetooth re-registered) – only the display pipeline was dead, which is exactly “lid+Enter does nothing, screen stays black” from the outside, with no clean way to shut down since the compositor couldn’t render a shutdown prompt either.
Fix: suspend only, hibernate removed entirely, not just re-pointed
at the base config’s original suspend setting – confirmed first that
this hardware genuinely supports proper suspend before relying on it:
/sys/power/mem_sleep reports s2idle [deep], i.e. real hardware S3
(deep suspend), not just the shallower s2idle fallback. Removed the
hibernate override file, and additionally masked
systemd-hibernate.service, systemd-hybrid-sleep.service,
systemd-suspend-then-hibernate.service, and their targets, so nothing
– a keybind, a script, a manual systemctl hibernate – can reach
hibernate again. install.sh now does the same from a fresh install
(see its own comment there for the exact commands), so this doesn’t
silently reappear on a reinstall or a new machine.
Masking left the resume setup behind. The 2026-09-29 stability pass
found and removed it: resume=/resume_offset= in /etc/kernel/cmdline
(the UKI’s command line), the resume hook in /etc/mkinitcpio.conf’s
HOOKS, and a 12 GB /swapfile sized to hold a hibernation image (RAM
is 9.6 GB). The swapfile is now 4 GB of plain overflow behind the 4 GB
zram swap, and nothing reads a resume image any more. If any of these
comes back, hibernate is half set up again.
Verified live across multiple real lid close/open cycles afterward:
journalctl showed clean PM: suspend entry (deep) /
PM: suspend exit pairs each time, resuming in roughly a second rather
than hibernate’s ~3.5-minute image-restore round trip. One real, still
only partly understood wrinkle found along the way: systemd-logind
logged Delay lock is active ... but inhibitor timeout is reached when
the lid was closed and reopened within a few seconds – before-sleep’s
delay inhibitor has a 5-second cap (systemd’s default
InhibitDelayMaxSec) before logind forces suspend to proceed regardless
of whether swaylock finished starting. The amdgpu ucode-load
warnings above still print on every suspend resume, not just the
hibernate one – confirmed benign under suspend specifically, though:
no ring gfx timeout, no GPU reset, no coredump in any of the repeat
tests, just the same non-fatal “optional ucode not available”-style
messages.
Known, unresolved: once, after a lid close/open that had also hit
the delay-lock timeout above, the lock screen rendered correctly but
then went black a few seconds later while typing the password – and
recovered on its own shortly after. Neither the system journal nor the
user journal has a single line for that moment: no new suspend, no
crash, no coredump, nothing from systemd-logind. Re-tested the same
lid close/open sequence live under journalctl -f afterward and it
completed cleanly with no repeat. Left as a known, low-frequency,
currently unreproducible glitch rather than a fixed bug – there’s
nothing in the logs to fix yet, and it hasn’t recurred since.
The wallpaper pipeline is the ONLY wallpaper pipeline
There used to be a second, untracked script (~/scripts/fetch-bing.sh) firing on
every sway reload via exec_always, independent of the wallpaper.timer-driven one
below. It’s gone now (see the 2026-08-23 changelog entry for the full story) –
fetch_wallpaper.sh via the daily timer is the only thing that changes the wallpaper.
If you ever see wallpaper-related exec/exec_always lines back in sway/config in
the future, that’s a regression, not a feature.
The wallpaper pipeline, and why it can’t crash sway anymore
systemd/.config/systemd/user/wallpaper.timer fires wallpaper.service once a day,
which runs scripts/.local/bin/fetch_wallpaper.sh. The manual output * bg ... fill
line in sway/.config/sway/config only sets the background once, at sway startup —
this script is what keeps it updated day to day, and what applies it live via
swaymsg without needing a sway restart.
Also triggerable on demand now, not just once a day: waybar’s custom/wallpaper
module (Rosewater icon, left of the docker module) runs this exact same script on
click, no separate wrapper. It’s the same flock-guarded, retry/validate/fallback
pipeline either way — a manual click can’t race the daily timer, and the script
picking up notify-send calls (fetching / success / fallback / failure) benefits
both paths, not just the button, since it used to be silent, log-file-only.
Every click used to fetch the exact same image, though — confirmed directly
(curled the same URL twice, compared md5sums: identical) that Bing’s “wallpaper of
the day” for a fixed index+market is genuinely static for the whole day, so
WALLPAPER_URL being one hardcoded URL (index=0, mkt=en-US) meant every click
within a day just re-downloaded the same bytes. Also confirmed both index (0-7ish,
recent past days) and mkt (country/locale) each independently vary the actual
photo — different markets often get a different image for the same real day (tested
several: en-US/de-DE/fr-FR shared one image, en-GB had a different one, ja-JP/zh-CN
shared a third). Now wallpaper_url() builds the request from a random recent index
(0-3) and a random market out of ~20, re-rolled on every retry attempt too (not just
once per run), so a click is genuinely likely to differ from the last one. Not every
combination is guaranteed distinct — several markets do share images on any given
day — but verified via three real consecutive runs producing three different
md5sums, versus the old behavior’s one fixed hash all day.
It’s built so that no single failure — API downtime, a malformed response, no network,
or a totally fresh machine with no wallpaper history yet — can leave sway without a
background or crash swaybg (the helper process sway delegates background rendering
to; a bad image crashes it, not sway itself, but the visible symptom — background
gone, needs a reload to fix — reads the same from the outside):
- Network check (
nmcli networking connectivity check) skips a doomed attempt outright; not authoritative on its own, since the download’s own timeouts are the real check. - Download with retries (3 attempts,
--connect-timeout 10 --max-time 20so a flaky connection fails fast instead of hanging). - Validate the response is actually an image (
file --mime-type, not just “non-empty”) before it’s allowed anywhere nearcurrent.jpgorswaymsg. This is the fix for the actual historical bug: the API occasionally returns a JSON status blob instead of image bytes, which used to sail straight through toswaybgand crash it. -
Three-tier fallback if 1–3 don’t produce a valid image: today’s download →
.last_good.jpg(a stable copy kept outside theActive//Archive/rotation, updated only after a successful validation, so a bad day can never overwrite or archive it away) → a plain solid color viaswaybg -c/solid_colormode, which has no image to parse and therefore can’t itself fail. Tier 3 is what makes a from-scratch machine safe on day one, before any fetch has ever succeeded.The archiving step’s
find "$ACTIVE_DIR" -maxdepth 1 -type f ...deliberately uses-type f, which excludes symlinks — found and cleaned up 5 dead broken symlinks insideArchive/during a stability pass (dated across Jun–Aug 2026) that had clearly been swept in by an older version of this logic before that guard existed, each pointing at a plain-dateActive/wallpaper-YYYYMMDD.jpgthat no longer exists there (Active/only ever holds the current day’s file). The current-type fguard means this specific kind of debris can’t recur. flockso the timer and a manual run can’t race each other.-
Waits for sway’s IPC socket instead of checking once (
apply_background(), up to 20s, polling every second). Found this auditing the wallpaper/lock setup for boot reliability:wallpaper.timerhasPersistent=true(catches up a missed daily run on boot), but the service is only orderedAfter= network-online.target— nothing ties it to sway actually being up.graphical-session.targetlooked like the obvious fix, but checked first rather than assuming it’d help:systemctl --user status graphical-session.targetshows it’s a real loaded unit that staysinactive (dead)all session, since nothing in this sway config ever activates it – ordering against it would’ve been a no-op. A single instant socket check could lose this race at boot:current.jpggets updated on disk regardless, but the liveswaymsgapply would silently give up, leaving yesterday’s wallpaper showing until a reload that never happens on its own. The retry costs nothing in the normal case (manual click, or the timer’s normal daily run well after login) – the socket’s already there, so it returns on the very first check. After=network-online.targetinwallpaper.serviceis itself a no-op — found while auditing the rest of the repo for uncoordinated config. Checked rather than assumed:systemctl --user list-unit-files network-online.targetreturns zero unit files on this machine —network-online.targetis a system-level target, never instantiated inside the--usermanager’s own namespace, so ordering a user unitAfter=it is syntactically accepted but has nothing to actually order against (same class of silent-no-op as thegraphical-session.targetfinding just above, different unit). Left the line in place rather than deleting it outright — it costs nothing and documents the intent even though it doesn’t enforce it — but the real “don’t run before the network’s up” protection is entirely carried by step 1’s ownnmclicheck plus the download’s own timeouts and three-tier fallback, not by systemd ordering. Worth knowing if you’re ever debugging why a boot-time catch-up run fetched a wallpaper before the network was actually ready: nothing was stopping it from trying, by design of this unit file, not by accident.
Also relevant to “does this survive a truly fresh install”: install.sh does
systemctl --user enable --now wallpaper.timer before sway has ever started
(it’s run from setup, not from inside a running session). Persistent=true’s
standard systemd semantics fire a catch-up run immediately in that case (no prior
trigger recorded), which reaches the ln -sf "$FILEPATH" "$CURRENT" line and
creates a valid current.jpg on disk regardless of whether the live swaymsg
apply succeeds – so sway’s own static output * bg current.jpg fill line
(sway/config) finds a real file the very first time sway itself starts, not a
dangling symlink. Not independently re-verified with an actual from-scratch
install in this pass (same gap the rest of install.sh’s verification already
has, documented above) – this rests on standard, well-documented systemd
Persistent= behavior, not a fresh empirical test.
Logging: ~/.local/state/fetch-wallpaper/fetch-wallpaper.log (rotated at ~1MB,
one previous copy kept) has every attempt, failure reason, and which fallback tier
fired. Also visible live via journalctl --user -u wallpaper.service. If the wallpaper
ever looks stale or wrong, that log is the first thing to check — it will say exactly
which of the three tiers actually ran, rather than leaving you guessing.
The lock screen: one config, four trigger points, one dead alternate removed
sway/.config/sway/lockconfig is the single real swaylock config – Catppuccin
Mocha ring/text/key-highlight colors, a big left-positioned clock/date indicator,
frosted dark backdrop, and image=.../current.jpg (the exact same file the
desktop background itself uses, so the lock screen never shows a different
wallpaper than what you were just looking at). Four places trigger it, all
consistently pointing at this one file:
- Manual keybind:
bindsym Mod1+Shift+semicolon exec swaylock -C ~/.config/sway/lockconfig(sway/config) - Idle timeout:
timeout 300 'swaylock -C ~/.config/sway/lockconfig'(sway/idle/config) - Before sleep/suspend:
before-sleep 'swaylock -C ~/.config/sway/lockconfig'– moved to its own file/service,sway/idle/lock-on-sleeprun byswaylock-on-sleep.service, deliberately notsway/idle/config/swayidle.serviceanymore (see “Idle management” below for why) - The power menu’s Lock button:
"exec": "swaylock -C /home/yash/.config/sway/lockconfig"(nwg-bar/bar.json– absolute path here specifically, not~, for the no-shell-expansion reason documented in thenwg-barrow above)
Auditing this for redundancy found a real one: sway/.config/sway/scripts/lock.sh
existed as a second, completely different swaylock invocation (small 100px plain
ring, no Catppuccin theming, no clock) – and was referenced nowhere (confirmed
with an exact, non-wildcarded grep for the literal filename across the whole repo,
after an initial sloppier grep with an unescaped . falsely matched prose like
“padlock shape”). Dead code left over from an earlier design, silently diverging
from the one lock screen everything else actually uses. Deleted, and re-stowed to
clean up the now-dangling ~/.config/sway/scripts/lock.sh symlink it left behind.
Volume and brightness: real controls, not nwg-bar buttons
Both used to be nwg-bar button popups (volume.json, and no popup at all for
brightness). Neither belongs in a button grid – adjusting volume/brightness is
fundamentally a “drag to a value” interaction, and nwg-bar categorically can’t
render one (buttons only, confirmed reading its Go source). Replaced with:
pulseaudiowaybar module: click = mute/unmute (sway/.config/sway/scripts/volume_osd.sh mute-toggle), right-click =pavucontrol(a real GTK mixer with per-device drag sliders and separate Playback/Recording/Output Devices/Input Devices tabs – this is where output and microphone device selection actually happens, for free, and it already renders in Catppuccin Mocha since the GTK3 theme is applied system-wide, no extra CSS needed), scroll = +-5% (volume_osd.sh +5%/-5%).backlightwaybar module: click =scripts/.local/bin/brightness-slider.sh, azenity --scale --print-partialdialog –--print-partialstreams the live value on every drag frame, piped intobrightnessctl set "${value}%"per line, so it’s a genuine real-time slider rather than a set-once popup. Needszenity(inpackages/pacman.txt, officialextrarepo). Scroll = +-5% (brightness_osd.sh), unchanged.- Both
volume_osd.shandbrightness_osd.sh(sway/.config/sway/scripts/) are shared between theXF86Audio*/XF86MonBrightness*keybindings and the waybar scroll bindings now, so there’s exactly one place that fires the progress-bar mako notification for each, not two copies drifting apart.volume_osd.shalso auto-unmutes before applying a delta (raising/lowering volume should always be audible) and gained amute-togglemode used by both theXF86AudioMutekey and the waybar click.XF86AudioMicMutewas added alongside it (pactl set-source-mute @DEFAULT_SOURCE@ toggle) – there wasn’t a microphone-mute keybinding at all before.
The scroll flood, and why on-scroll needs smooth-scrolling-threshold
Scrolling on the volume/brightness pills looked broken – confirmed via
/tmp/waybar.log, which had 249 lines of Connection failure: Connection
terminated and Value "" of hint "value" could not be parsed as type "int" after
one scroll session. Root cause, found in waybar’s own man pages
(man waybar-pulseaudio, man waybar-backlight): defining on-scroll-up/
on-scroll-down “replaces the default behaviour” and neither module had
smooth-scrolling-threshold set (default is unset/0). A touchpad emits continuous
fractional scroll deltas (kinetic/smooth scrolling), not one discrete tick per
gesture – with the threshold at 0, waybar fired the script on every single delta,
so one real scroll gesture spawned dozens of pactl/brightnessctl/notify-send
processes within milliseconds, flooding mako’s D-Bus connection and racing each
other reading volume/brightness mid-write. Fixed by adding
"smooth-scrolling-threshold": 1 to both modules, so waybar accumulates a full
scroll unit before firing once.
Forcing the same burst manually (30 concurrent invocations) also surfaced a real,
separate danger: pactl set-sink-volume @DEFAULT_SINK@ +5% does not clamp at
100% on its own – the 30-way race drove the sink to 1494% (confirmed no audio was
actively playing at the time, via pactl list sink-inputs short, so nothing was
physically blasted, but it would have been audible/damaging if something had been).
brightnessctl does self-clamp (set 1000% lands at exactly 100%, confirmed by
testing), so no equivalent overdrive risk there, but concurrent calls can still
stack out of order. Both scripts now serialize their read-current -> compute-target
-> apply sequence behind flock (/tmp/volume_osd.lock / /tmp/brightness_osd.lock),
and volume_osd.sh computes and clamps the target to 0-100 itself rather than
trusting pactl’s relative math. Re-ran the identical 30-concurrent-call burst after
the fix: clamps land exactly at 100%/0% instead of overshooting, and the waybar log
stayed at zero errors.
Capslock/numlock: custom modules, and toggling them needs ydotool
Started as waybar’s built-in keyboard-state module, replaced with
custom/capslock/custom/numlock (backed by scripts/.local/bin/
keylock-status.sh and keylock-toggle.sh) once click-to-toggle and a
hover tooltip were wanted – the built-in module has neither on-click
nor tooltip anywhere in its config schema (confirmed: man
waybar-keyboard-state lists every option it accepts, click/tooltip
aren’t among them), so no amount of CSS or config could make it
clickable. A custom module can do both, same {text,class,tooltip} JSON
pattern already used for custom/caffeine/custom/docker.
Reading state (keylock-status.sh capslock|numlock): reads
/sys/class/leds/*::capslock/brightness / *::numlock/brightness
directly – the same real kernel LED state the built-in module read via
libevdev, not a cached or derived value. Confirmed live against actual
state at the time (numlock was genuinely on, capslock genuinely off) and
the script reported both correctly.
Toggling state (keylock-toggle.sh capslock|numlock) is the part
that needed real investigation. wtype -k Caps_Lock – already used
elsewhere in this repo, e.g. the scroll-flood stress test – does not
actually toggle the real lock state on this compositor: confirmed by
checking /sys/class/leds/*::capslock/brightness stayed 0 straight
through a wtype press. wtype injects through the
virtual-keyboard-unstable-v1 Wayland protocol, which apparently doesn’t
drive the same XKB lock-latch state machine a real key event does here.
Fixed with ydotool instead (official extra repo, added to
packages/pacman.txt) – it injects through /dev/uinput, a genuine
kernel-level virtual input device indistinguishable from real hardware to
the same libinput → xkbcommon pipeline that processes actual keypresses,
so it correctly drives the real lock state. Confirmed the package ships
its own systemd user service (ydotool.service, no root needed at
runtime) and its own udev rule for /dev/uinput permissions (checked
the package’s file list on the Arch package page before trusting this,
rather than assuming) – a clean, standard setup, not a security-loose
workaround.
Installed and active on the live machine – confirmed directly
(which ydotool, systemctl --user is-enabled/is-active ydotool.service
both report enabled/active), not assumed from this having been written
once. On a fresh install this still needs:
sudo pacman -S ydotool
sudo udevadm control --reload-rules && sudo udevadm trigger
systemctl --user enable --now ydotool.service
keylock-toggle.sh checks for both ydotool being installed and
ydotool.service being active before attempting anything, and
notify-sends the exact fix (which of the two commands above is
missing) rather than failing silently or with a cryptic ydotool error
if clicked before this setup is done. Verified this fallback path
directly (sh -c invocation matching exactly how waybar calls it) –
exits cleanly with the right message, doesn’t crash.
Keycodes: 58 (capslock), 69 (numlock) – Linux’s standard
input-event-codes.h values, cross-confirmed against
waybar-keyboard-state’s own documented binding-keys default
([58, 69, 70], i.e. capslock/numlock/scrolllock in that order).
Cursor: no explicit CSS needed – waybar shows a pointer cursor
automatically for any module with an on-click handler (standard GTK
EventBox behavior), same as every other already-clickable pill in this
bar (docker, caffeine, network, pulseaudio, bluetooth). This is a
different code path entirely from the wofi popups’ cursor limitation
documented elsewhere in this file – that one is wofi’s own dmenu list
never requesting a cursor-shape change at all, which is unrelated to how
waybar’s own pill modules behave.
System-wide dark mode, a waybar toggle, and an icon pack (Candy, formerly Papirus)
Asked to make sure the desktop is dark by default, add a waybar toggle for
light/dark, and add a well-regarded icon pack. Checked before building
anything: dark mode was already substantially in place –
gtk-application-prefer-dark-theme=1 in both gtk/.config/gtk-{3,4}.0/
settings.ini, and install.sh already wrote color-scheme=prefer-dark +
the dark catppuccin-mocha-mauve-standard+default GTK theme to dconf.
What was actually missing: no icon theme was ever set at all (falls back
to whatever the system default resolves to), and there was no way to
switch modes without hand-running dconf write commands.
Icon pack: originally papirus-icon-theme + papirus-folders-catppuccin-git
(the most widely-used general-purpose Linux icon theme, folder-recolored
to this desktop’s Mauve accent) – later replaced system-wide with
candy-icons-git (packages/aur.txt, github.com/EliverLara/candy-icons)
on request, with Papirus removed entirely (see the 2026-09-15 CHANGELOG
entry for the full migration). Unlike Papirus, candy-icons ships no
official light/dark variant pair – one theme name
(gtk-icon-theme-name=candy-icons in gtk/.config/gtk-{3,4}.0/
settings.ini) covers both palettes, and its symbolic icons already carry
real fills rather than Papirus’s fill:currentColor convention. It also
has real coverage gaps Papirus didn’t (no dialog-*/weather/caffeine/
brightness icons at all) – every script that needed one of those now
falls back to AdwaitaLegacy (adwaita-icon-theme-legacy, real-fill,
non-symbolic PNGs) or the closest real candy-icons concept instead; see
mako/.config/mako/config’s icon-path comment and each affected script
for the specific substitution and why.
catppuccin-gtk-theme-latte remains in packages/aur.txt, the
light-flavor counterpart to the already-installed -mocha GTK theme
package – unrelated to the icon theme itself, still needed so
prefer-light has something other than Adwaita to fall back to.
The toggle: scripts/.local/bin/theme-status.sh (waybar custom/
theme module, positioned left of the wallpaper button in
modules-right) + theme-toggle.sh (its on-click). Pure dconf
reads/writes – no state file, unlike caffeine-toggle.sh’s pattern,
deliberately: dconf already persists across reboots on its own (it’s a
real database), so a second copy of “which mode is active” here would
just be one more thing that could silently drift from the truth, the
same reasoning already applied to keybind-search.py’s live-parsed
config over a maintained list. Verified the actual toggle, not just that
it runs without error: ran it, confirmed via dconf read that
color-scheme/gtk-theme both flipped to the light-mode values
(icon-theme stays candy-icons either way now – see the icon pack
section above for why), screenshotted and found the waybar icon’s color
actually changed (Sapphire moon -> Yellow sun), then toggled back and
confirmed dark again – left the real system in dark mode (the intended
default) afterward, not mid-test.
Scope, stated plainly rather than left implicit: this covers every
app that reads the standard GNOME/GTK dconf keys automatically – file
managers, browsers’ native GTK chrome, settings dialogs, most GTK3/GTK4
apps – which is the actual mechanism behind “apps detect OS dark mode.”
It deliberately does not touch this desktop’s own bar/launcher/
notification styling: waybar, wofi, and mako’s style.css files, and
sway’s window border colors, are all hand-built throughout this entire
repo to one specific Catppuccin Mocha palette. That’s a deliberate design
choice already made and extensively worked on in this session (the wofi
“glass” pass, the notification icon pass, etc.), not an oversight –
making all of it respond to a generic light/dark toggle would mean
maintaining a full parallel light variant of every custom stylesheet in
this repo, well beyond what was asked here. Also disclosed rather than
silently assumed away: dconf changes apply live to already-running apps
that subscribe to gsettings-changed (most modern GTK apps do), but some
only read theme state once at their own startup and won’t visibly change
until relaunched – a standard limitation of this whole mechanism, not
something this script can fix.
Not wired up: Qt app theming (QT_QPA_PLATFORMTHEME/qt6ct). qt6ct is
in packages/pacman.txt, but checked and found nothing in this repo
actually exports QT_QPA_PLATFORMTHEME or has a qt6ct.conf – and
neither beekeeper-studio-bin nor bruno-bin (the only two GUI apps in
packages/aur.txt that might plausibly be Qt) are actually Qt apps, both
are Electron. The installed qt6-* packages are almost certainly pulled
in as dependencies for something else, not for a Qt app in regular use.
Given no real Qt app to verify against, left this alone rather than
hand-configure qt6ct.conf blind – a real, disclosed gap if a Qt app is
ever added to this setup later, not something silently papered over.
Wofi text readability: two dead ends before the real fix
Asked again to make wofi text more readable against the glass background
(“black instead of white, hard to notice”), after the icon investigation
above concluded the actual visible white patch wasn’t text at all. This
time it genuinely was about #text’s color, and two reasonable-looking
attempts to fix it directly failed before finding the real cause:
- Literal black text (
#05050a+ a lighttext-shadowglow for safety): measurably worse, not better. This window’s own base tint (background-color) is already near-black, so near-black text has essentially zero contrast against it specifically, independent of whatever’s blurred behind the window. Confirmed by screenshotting and finding the text pixels barely distinguishable from the window background at all – worse than the original light color, which at least contrasted reliably against the dark base tint even when it struggled against bright patches of the blur. - Subtitle-style outline (bright fill + dark
text-shadowring around it – the actual standard technique for text over a variable- brightness backdrop, e.g. video subtitles): also didn’t work, for a different and more fundamental reason –text-shadowparses without any error in this GTK/wofi build but doesn’t actually render at all. Confirmed by forcing a stress-test bright background (where an outline shadow, if working, would produce plenty of dark pixels against the bright fill and bright forced background) and finding zero dark pixels anywhere in the window.
The real fix attacks the actual cause instead of the symptom: at the
previous 0.62 window background alpha, enough of the blurred wallpaper
showed through to wash out text specifically on brighter parts of it.
Raised to 0.85 – doesn’t remove the blur (SwayFX’s layer_effects blur
on wofi’s layer surface, added earlier, is still genuinely active), just
reduces how much of its brightness variance can bleed through, giving
text a far more consistent backdrop. Paired with moving #text’s color
from #A6ADC8 (Subtext0, fairly muted) to #CDD6F4 (Catppuccin “Text”,
the same bright value mako already uses for its own primary text) for
extra contrast margin. Net effect: less see-through glass than before,
which is a real, disclosed tradeoff of prioritizing readability here –
worth knowing if the balance ever needs to shift back the other way.
App launcher icons: gap, and a dark backing (not a text-color bug)
Asked for a gap between the app icon and name in drun mode (“too close”),
and for wofi to use black instead of white there. The gap was real and
simple: #img (confirmed a real, matching CSS node – tested directly
with a temporary bright background-color that visibly applied, not
assumed from wofi’s docs) had no spacing at all before this;
margin-right: 10px fixes it, same margin-on-the-child technique already
used for entry gutters just above it in the same file.
The color part turned out not to be a text-styling bug at all, worth
recording precisely because it looked like one at first. Found a genuine
near-white patch in a screenshot and assumed it was #text rendering
unstyled – checked with !important on #text’s color (would win
over literally any competing rule, including the system GTK theme’s own
stylesheet) and it had zero effect; checked with a bare label { color:
... } type selector (would catch the label regardless of what ID wofi
actually gives it) and that had zero effect either. Isolated the patch by
its exact shape and position instead of continuing to guess at selectors,
and found it sitting precisely where the icon renders, not the label –
it’s real app icon pixel data. Raster icon images aren’t recolorable by
CSS color at all (that only works for symbolic SVG icons using
currentColor), so no stylesheet change could ever have “fixed” this the
way a text-color bug would be fixed.
What is fixable: many real icons ship with an opaque or lightly-padded
white/light background meant for a light UI, which reads as a jarring
white square floating on this desktop’s dark glass background with
nothing behind it. Gave #img a dark, rounded backing (rgba(17,17,27,
0.5), border-radius: 8px) – the same treatment app grids like GNOME
Activities or macOS Spotlight use for this exact reason. This only helps
icons with real alpha transparency around their edges (a background can’t
paint over pixels the icon itself already drew opaque) – confirmed this
directly rather than assuming full coverage: near-white pixel count in a
screenshot was 206 before and 209 after, i.e. genuinely unchanged for
icons with a fully opaque background baked in, which is a real, disclosed
limitation of this fix rather than something to claim as fully solved.
Wofi color-palette consistency audit
Asked to make sure every wofi popup follows the same color palette, plus a
general look for bugs/improvements. Audited every script that invokes wofi
or embeds Pango markup in what it feeds to wofi
(wifi-picker.py, bluetooth-picker.py, keybind-search.py,
wofi-calc.sh) and the shared style.css/config themselves.
Most of it already checked out: wifi-picker.py and bluetooth-picker.py
already share the exact same three-color Catppuccin Mocha system (Sky
#89DCEB for a connectable/primary entity, Teal #94E2D5 for known-but-
passive, Peach #FAB387 for a command/action) and the exact same
#313244 surface color wofi’s own #input CSS uses (rgba(49,50,68,...)
== #313244 – same underlying color, just expressed differently in each
place) – confirmed by reading through both scripts fully rather than
just grepping for hex codes and assuming they matched.
Two real things found and fixed:
- A duplicate color, not just a stylistic one:
bluetooth-picker.py’s “Enable Bluetooth” entry (the only one shown when Bluetooth is powered off) used a hardcodedforeground="#FAB387"span instead ofmarkup(..., "command")– the same value CATEGORY_COLORS already defines, just copied instead of referenced. Harmless today, but exactly the kind of second copy of a value that silently drifts out of sync if the palette ever changes, the same failure mode documented for the TPM plugin-path split earlier in this file. Fixed to read from the shared dict. -
keybind-search.pyhad zero color-coding at all – the one wofi popup in this desktop with real, distinct categories (Sway vs. Tmux entries) and no visual way to tell them apart at a glance, unlike every other multi-category picker. AddedSOURCE_COLORS(Sky for Sway, Teal for Tmux, same hues as the other two pickers) coloring just the source tag, plus Peach for the[scope]suffix (matching Peach’s established “this needs an extra step/context” meaning from the other pickers’ command category) – colored only the tag, not the whole line like the free-text pickers do, since this tool’s structured columns (tag/keys/description/scope) would lose readability if everything were tinted at once.This addition carried a real risk worth verifying properly rather than assuming:
keybind-search.pyis the only wofi popup in this desktop using--matching fuzzy(the others use wofi’s default “contains”), and fuzzy matching against a string that now contains literal<span foreground="...">markup tags could plausibly search against the tag text itself, not just what’s visually shown. Verified this doesn’t happen with a controlled, unambiguous test rather than eyeballing a screenshot: piped a small set of markup-containing dmenu entries directly into wofi, typed a query that only matches one entry’s visible (non-markup) text viaydotool, and confirmed via the actual selected/ copied output that wofi picked the right entry – fuzzy matching operates on the rendered text, markup tags don’t leak into the search space. Reconfirmed against the real script afterward (ydotool-typed “reload tmux”, correctly selected and copied the Tmux reload entry with its Teal tag intact).
keybind-search.py: truncating descriptions, since wofi can’t wrap or ellipsize them
Reported the keybind search popup’s right side overflows. Real cause: a
couple of the sway descriptions this tool builds are enormous – the
exit-sway confirmation’s full swaynag message came out to 243 characters
end to end, roughly 1750px at this font, nearly 1.5x the entire 1152px
(60%) window width. wofi has no CSS or config-level ellipsize/wrap for
--dmenu entries – checked --help and the binary’s own exported
symbols directly (strings $(which wofi) | grep -i wrap does show
gtk_label_set_line_wrap, so wofi’s own source calls it somewhere, but
nothing exposes control over it here, and it plainly wasn’t applying to
these rows regardless).
Truncates in keybind-search.py itself instead (MAX_DESC_LEN = 78,
appends … when cut) – not just to stop the overflow, but because even
if wofi did wrap instead of overflow, a giant multi-paragraph entry in
a quick-reference list would be its own readability problem. 78 chars
keeps every real description on one line within the window width with
room left for the longest [scope] suffix (resize mode (Super+Ctrl+r
first)), chosen by checking the actual longest entries that needed to
stay intact (the OSD scripts’ descriptions) rather than picking an
arbitrary round number.
Verified two ways: the longest entry after truncation is 123 characters (was 243), and pixel-checked the popup’s right edge in a screenshot – nothing beyond background-level intensity 2px past the window boundary, versus real elevated (border) values right at the edge itself.
A real bug: the left border was invisible on every entry, always
Reported that left and right edges of rows/cells weren’t visible properly.
Real bug, not a perception issue: an earlier revision (the one that first
added a border to every entry) also added border-left: 2px solid
transparent;, declared after the main border shorthand specifically
so it would win on just that one edge – reserving 2px of space for a
selected-state accent bar without a layout jump when it appeared. That
made the left edge invisible on every entry at rest, not just
unselected ones, which is the actual bug: in the app launcher’s grid, one
cell’s visible right border sitting next to the adjacent cell’s invisible
left border reads as a broken, asymmetric gutter rather than a clean
framed gap – exactly “can’t see left and right edges properly.”
Fixed by removing the border-left override entirely and keeping border
width constant (1px) across idle/hover/selected – selected state now
differentiates by color alone (full-strength #CBA6F7 instead of a raised
alpha), which needs no layout-jump guard at all since nothing changes
size, only color. Simpler than what it replaced, not just a bug fix.
Verified by pixel-comparing a cell’s right border against its neighbor’s
left border at the same row: both sides now render the same color
consistently (e.g. (70,62,88) on both, at multiple rows) – symmetric,
not just “present.”
music-search.py: real thumbnails in the results list
Reported directly: “only text doesn’t make that much sense, show thumbnail and title in each row” – a fair point, a title-only list doesn’t actually tell you much about a video the way seeing its thumbnail would.
wofi’s own img: dmenu markup handles this directly (--allow-images,
the exact same mechanism notification-history.py already uses
elsewhere in this repo) – nothing bespoke needed. The only real
question was where to get a thumbnail image cheaply: yt-dlp’s own
--flat-playlist JSON does include a thumbnails array, but every URL
in it carries a signed, per-request query string (confirmed by reading
one directly) – not something worth depending on staying valid or
parsing correctly. YouTube’s plain, unsigned
https://i.ytimg.com/vi/id/mqdefault.jpg URL shape (320x180, ~11-18KB)
resolves reliably without needing any of that (confirmed directly with
a real curl, not assumed from convention), so that’s what’s used
instead – one fixed, well-known pattern, not something parsed out of a
response.
Fetched for all ~10 results concurrently (ThreadPoolExecutor) rather
than one at a time – confirmed directly this matters: ~0.4s for all 10
fetched together versus what would have been several seconds doing them
serially. Downloaded into a per-run temp directory
(tempfile.mkdtemp), cleaned up unconditionally after the picker
closes either way (picked a result or cancelled) – these files only
ever needed to exist long enough for wofi to read them while the list
was open.
Verified live, not just “should render”: checked the temp directory’s
actual contents while the real results wofi popup was open (triggered
through the real $mod+Shift+y keybinding with real typed input, not
the script run manually) – ten real, valid JPEG files, one per result,
confirmed with file directly rather than just trusting they existed.
termusic removed entirely: mpd + rmpc instead
Direct feedback once it was clear termusic’s search was going to stay
broken for the foreseeable future (the public Invidious network it
depends on, see the entries below): “termusic isn’t of any use then,
lets use a better tui or cli local music player then and remove this
damn termusic.” Rather than keep patching around termusic’s one real
problem forever with music-search.py doing all the actual useful
work, the player itself got swapped out too.
termusic/ is gone from this repo – ~/.config/termusic/
unstowed and deleted, package uninstalled
(sudo pacman -R termusic), termusic-toggle.sh removed. Every entry
below this one that talks about termusic is left exactly as it was
written – real history of real bugs found and fixed at the time, not
rewritten to pretend termusic was never here. The Package map
above reflects the current, actual state (mpd/rmpc), not history.
mpd (official extra repo, mpd.service shipped by the package
itself – no unit file needed in this repo) is now the actual player.
This is a real architectural improvement, not just a different UI on
the same risk: MPD has zero third-party API dependency of any kind, it
only ever plays real files already sitting in music_directory –
there’s no equivalent “public instance network could go dark” failure
mode the way termusic’s Invidious-based search had. Confirmed MPD’s own
filesystem watcher (auto_update "yes") actually works, not just
trusted the option’s name: dropped a file into ~/Music with zero
clients connected and watched it get indexed within seconds in MPD’s
own log – a real improvement over termusic, which had no such watcher
at all (confirmed by reading its source, back when it was still here).
rmpc (also official extra repo, 3.3k GitHub stars, actively
maintained, its own tagline: “A beautiful and configurable TUI client
for MPD”) is the new UI. A genuinely validating find while picking it:
rmpc ships its own yt-dlp-based YouTube downloader
(shared/ytdlp/ in its source, confirmed by reading it directly –
real yt-dlp, not Invidious) – independent proof the same approach
music-search.py already took was the architecturally sound one, not
just a workaround invented here. rmpc’s own CLI picker for it uses a
plain text list with no thumbnails though, so music-search.py stays
the actual search tool – rmpc just plays whatever lands in ~/Music
afterward.
Theme, sizing, transparency, toggle – all rebuilt for rmpc, applying what termusic’s own setup already taught rather than rediscovering it:
- Theme built the same “read the tool’s own real defaults, don’t guess
the format” way as termusic’s theme was –
rmpc themedumps rmpc’s actual default (confirmed this is genuinely how it bootstraps a custom theme), which turned out to be one deeply nested RON structure with named colors (“blue”, “yellow”, “black”, …) woven through the entire layout definition, not a separate flat palette the way termusic’s was. Every occurrence systematically substituted for its Catppuccin Mocha hex equivalent –"blue"specifically mapped to Mauve (#cba6f7), not Catppuccin’s own literal Blue, since “blue” is the color rmpc’s own default theme uses for every “this is active/ current/selected” role, exactly Mauve’s existing role everywhere else in this desktop. background_color/header_background_colorleftNonefrom the start this time – applying termusic’s own hard-won lesson (Color::Reset/no explicit paint lets kitty’sbackground_opacityactually blend) immediately rather than shipping an opaque background and rediscovering the exact same bug a second time. Verified live: screenshotted the real running window and found zero pixels of any solid background color, Mauve clearly dominant across borders (~26,000 matching pixels) – the glass effect worked on the very first real launch.- Sizing (
1152x540, 60%/50% of the real1920x1080output) and the scratchpad-based toggle (rmpc-toggle.sh) are close to line-for-line the same mechanism already proven for termusic – no reason to redesign what was already verified working, MPD’s own daemon/client split just makes the “hiding the window never stops playback” guarantee even stronger than termusic’s client/server split was (MPD keeps running with zero clients connected at all, not just a hidden one).
A real image-protocol scare, resolved by finding the actual cause
rather than assuming the setup was broken: rmpc debuginfo, run
directly in this same non-interactive session, reported the album-art
protocol as Block (a crude ASCII fallback) instead of Kitty –
despite $KITTY_WINDOW_ID and $TERM=xterm-kitty both being set.
Traced it to the actual cause before assuming a real bug: tty in this
exact session reports “not a tty” – the environment variables are
inherited from a parent process, but this session’s own shell was never
attached to a genuine terminal device, which is what kitty’s graphics
protocol actually needs regardless of which env vars happen to be set.
Checked rmpc’s own log from the real, actual launch path instead
(kitty --class rmpc -e rmpc, the same command the real keybinding
uses) and found resolved_backend="Kitty", kitty_graphics="true" –
the real graphics protocol is genuinely active for the path that
actually matters, the failed detection was purely an artifact of how
this one diagnostic command happened to be run.
rmpc’s floating window: actually glass, not just technically transparent
Direct feedback right after the migration above: “lets make its
background glass like.” The theme already had background_color: None
(carried over proactively from termusic’s own transparency fix, see
below), and kitty’s global background_opacity 0.85 was blending
against it – technically working, but not perceptibly so on a window
this large (1152x540 at the time, over half this machine’s screen). A
subtle blend that reads fine on a small popup (wofi, mako) is much
easier to miss on something covering that much of the display.
First attempt, since revised: tried lowering opacity per-window
(kitty’s -o background_opacity), reasoning more transparency would
mean more glass. Chasing a trustworthy number this way surfaced a real
methodology trap worth recording: whole-window pixel averaging against
a same-position wallpaper-only screenshot gave noisy, sometimes
backwards-looking deltas between opacity values (skewed by however
much text/UI happened to be on screen at capture time), until narrowed
down to sampling one fixed, confirmed-empty patch of the window at the
real toggle-triggered position instead – that gave a clean, real
signal (a measurable, reproducible shift per opacity value). But even
with a clean signal, the fix was aimed at the wrong variable: at low
opacity, what showed through was the wallpaper completely sharp and
unblurred – correctly measurable as “more transparent”, but reading as
“see-through window”, not glass.
Second, direct follow-up: “lets make its background glass like
blury… increase it size follow the 60 percent width and increase the
height accordingly” – named the missing piece directly: blur.
Confirmed this machine runs SwayFX (sway --version -> swayfx 0.6,
not plain sway), which adds real compositor-level GLES2 blur – already
in use for wofi via layer_effects (see the wofi section below), just
not yet wired up for rmpc. Read SwayFX’s own README (extracted from the
cached AUR build tarball, not guessed) to confirm the exact mechanism
for a plain toplevel window rather than a layer-shell surface: blur,
corner_radius, and shadows are all regular for_window-settable
properties for ordinary windows (no layer_effects block needed –
that’s specifically for layer-shell surfaces like wofi/waybar/mako,
matched by namespace), so blur enable was added directly to rmpc’s
existing for_window [app_id="^rmpc$"] rule in sway/config.
corner_radius/shadows weren’t set there since rmpc already inherits
the global corner_radius 12 / shadows on defaults every ordinary
window gets – only wofi needed its own explicit values, since layer-
shell surfaces are exempt from those per-window toplevel defaults.
Deliberately did not set blur_xray, unlike wofi’s rule – SwayFX’s
own README says so explicitly for regular floating windows (“You
probably want to set this to disable :)”), since blur_xray is for
layer-shell panels that need to blur the true desktop background
specifically; a floating window blurring whatever’s actually behind it
(the wallpaper, since this one is centered above everything) is already
the right result.
With real blur doing the work, the earlier per-window opacity override
became unnecessary and was removed – rmpc-toggle.sh now launches
plain kitty --class rmpc -e rmpc, back on kitty’s global 0.85. That
number turned out to already match the desktop’s own established
“real glass” ratio: wofi/style.css’s own working glass panel also
runs its background at 0.85 alpha, paired with real blur – mostly
opaque, with a blurred (not sharp) wallpaper hint behind it, not a
mostly-see-through window. The earlier low-opacity investigation wasn’t
wasted, though: it’s what proved background_color: None +
background_opacity blending was wired correctly at all, before blur
turned that correct-but-weak signal into an actually convincing one.
Verification, done with a cleaner test than the earlier
whole-window/empty-patch approaches: screenshotted the live window and
the identical screen region with the window hidden (scratchpad show
toggling it away and back), found the highest-texture 40x40 pixel block
in the wallpaper-only shot (real edge detail, luminance swinging ~4 to
~151 – picked programmatically, not by eye, specifically so the test
has real detail to blur), then compared local horizontal-gradient
energy in that identical region between the two shots: 9.85
(wallpaper, sharp) vs. 0.83 (through the window) – a ~92% drop – and
the actual rendered pixels there were a smooth, consistent blended tone
(~24, 33, 46), not the sharp original texture and not a flat theme
color either (confirming it’s genuine alpha-blended blur, not a solid
pane happening to sit there). Text legibility reconfirmed via a direct
ASCII luminance dump of real rendered text with both blur and 0.85
active – glyphs stayed crisp and high-contrast.
Size: the same follow-up request also asked to keep the window at
60% width (already was: 1152 of 1920) and grow the height to match,
rather than the original 50%. Changed resize set 1152 540 to
resize set 1152 648 (1080 * 0.6) in the same for_window rule –
confirmed live via swaymsg -t get_tree showing the real window’s rect
at exactly 1152x648, centered.
Every popup closes on Escape – rmpc’s floating window was the one real gap
Direct feedback: “all popup modals should also hide when esc key is pressed as well.” Audited every popup this desktop has rather than assuming which ones needed fixing:
- Every wofi-based popup (
toggle-popup.sh’s clients: launcher, calc, clipboard, emoji, wifi, bluetooth, docker, notification history, keybind search, music search) – already closed on Escape, confirmed by the comment already intoggle-popup.shfrom when it was built: wofi handles this itself natively, sway was never intercepting or needing to intercept that key for these. - nwg-bar (power menu) – tested live rather than assumed: opened it
via the real
$mod+Shift+ppath, sent a real synthetic Escape keypress (ydotool key 1:1 1:0, keycode 1 = Escape), confirmed the process exited andtoggle-popup.sh’s own marker file was cleaned up – already correct, GTK’s native handling again, no fix needed. - rmpc’s floating window – tested the same way, and this one really
was broken: focused the window, sent the same synthetic Escape, and
it stayed open. Root cause: rmpc’s own
config.ronalready binds its Esc key entry toCloseunderkeybinds.navigation, but that’s rmpc’s own internal action for closing a modal inside rmpc (a save-playlist prompt, etc.) – it has no idea sway’s scratchpad exists, so with no internal modal open, Escape was a no-op as far as the window’s visibility was concerned.
Fixed with a new dedicated script, rmpc-hide.sh (just
swaymsg '[app_id="^rmpc$"] scratchpad show', the same hide rmpc-
toggle.sh’s second press already does), wired in via a per-window
kitty keybinding override on rmpc’s own launch line only:
-o "map escape launch --type=background $HOME/.local/bin/rmpc-hide.sh"
– rebinds Escape for this one kitty instance before it ever reaches
rmpc’s pty, kitty/kitty.conf’s own global map table untouched. A
separate script rather than inlining the swaymsg call directly in
that -o value: the value already needs one layer of shell quoting to
reach kitty as a single argument, and nesting [app_id="^rmpc$"]’s own
quotes inside that is exactly the kind of thing that goes silently
wrong instead of erroring loudly – a real script file sidesteps it.
Verified live end-to-end through the real mechanism, not just that the
override was accepted: killed the running rmpc, relaunched via the real
$mod+Shift+m keybinding, confirmed via swaymsg -t get_tree the
window was visible, focused it, sent a synthetic Escape, confirmed
visible flipped to false – and confirmed the rmpc process itself
was still alive and mpd.service still active (hiding never touches
either, exactly like every other press of the same keybinding). Then
toggled it back open once more to confirm the existing show-path
(rmpc-toggle.sh’s pgrep -x rmpc branch) still worked normally.
Known, deliberate tradeoff: this makes rmpc’s own internal
Close action unreachable via Escape specifically for this window
(kitty eats the key before rmpc ever sees it). Not a real loss, though
– its C-c key entry is bound to the exact same Close action in config.ron
already, so closing an internal rmpc modal still has a working key,
just Ctrl+C instead of Escape from now on.
rmpc’s theme: recolored by role, not by literal string substitution
Direct feedback: “can we improve the theming of the music player here i mean use some colors like our status bar and make it more asthetic.” The original theme build (see the mpd/rmpc migration entry below) was a literal, uniform substitution – every “blue” in rmpc’s own default theme became the same Mauve, every “yellow” the same Yellow, everywhere that color name appeared. Technically themed, but the result was almost monochrome: nearly the whole UI read as Mauve + Yellow, with Red/Green/ Pink only ever showing up in the rarely-seen debug log pane.
Revisited with the actual ask in mind – richness, and specifically
consistency with waybar/style.css’s own palette, not just “has hex
colors already”. Read waybar’s own stylesheet first rather than
guessing which hues it uses where: it deliberately assigns a
different color per module (Sapphire = dark mode, Sky = wifi, Peach =
capslock, Teal = numlock, Maroon = the #pulseaudio volume module,
Lavender = notifications, a literal dim gray #6c7086 for every “off”
state) instead of one accent color carrying every role. rmpc’s theme
had none of that variety.
Recolored rmpc/.config/rmpc/themes/catppuccin-mocha.ron by UI role
this time, picking each hue to match the waybar module it’s most
directly analogous to, not at random:
- Song title – had no color at all before, just Bold on the
default text color, despite being the single most-looked-at line in
the UI. Now Sky
#89dceb. - Artist – was identical Yellow to the
[Playing]state badge right above it. Now Lavender#b4befe, genuinely distinct. - Volume number/% – was generic Mauve. Now Maroon
#eba0ac– not an arbitrary pick, this is literally waybar’s own#pulseaudiomodule color, the most direct “our status bar’s colors” match available for this exact concept (volume level). - Repeat/Random/Consume/Single toggles – all four previously
shared one identical on/off color pair, no way to tell which was
which by color. Now each gets its own waybar-matched hue when active
– Teal (repeat), Peach (random), Sapphire (consume), Lavender
(single) – and every off state moved from a dimmed Mauve to
waybar’s own literal off-state gray
#6c7086, matching that convention exactly instead of approximating it with a dim modifier alone. level_styles.info– was Mauve, now Sapphire#74c7ec, freeing Mauve to stay exactly what it already means everywhere else in this theme and this desktop: the one “selected/active/interactive” accent (current_item_style,borders_style,tab_bar.active_style, waybar’s own universal hover color) – diversifying everything else deliberately left that single role untouched.- Every
#1e1e2e(Catppuccin’s plain “Base”) became#11111b(modal background, all fivelevel_stylesbackgrounds, and the dark contrast-text used against Mauve/Green highlights) – grepped the whole repo first rather than assuming, and confirmed#11111Bis already the one shared dark surface everywhere else in this desktop (waybar, mako, wofi, nwg-bar, even sway’s own window-border color scheme) – rmpc’s theme was the only place still using the lighter Base tone for that role.
Verified live, not eyeballed: relaunched rmpc through the real
$mod+Shift+m keybinding, confirmed its own log showed zero parse
errors for the edited theme (a bad RON edit would either crash rmpc on
launch or silently fall back to defaults – checked the process was
genuinely still alive with the new file, not assumed), screenshotted
the running window, and pixel-matched specific screen coordinates
against the theme’s own target hex values: title, artist, and volume
all came back an exact RGB match with zero color distance from what
the theme specifies. The off-state toggle cluster came back a dim,
low-contrast blue-gray in the right position and color family for
#6c7086 plus the terminal’s “Dim” modifier – not a hex-exact match
on tiny anti-aliased glyph-edge pixels (too fine-grained a target for
this method), but qualitatively confirmed rather than assumed correct.
Immediate follow-up: “can’t we add some more colors to this tui and
make it more colorfull like our status bar?” The first pass only
touched the playback header and the toggle cluster – everything else
(file browser rows, table column headers, preview metadata, tab bar,
scrollbar, the elapsed/bitrate line) was still plain, unstyled default
text. Went through every remaining None/empty style field in the
theme this time, reusing the same waybar-matched hues plus the two
waybar colors nothing had used yet: Blue #89b4fa (waybar’s
#clock.time) and Rosewater #f5e0dc (waybar’s wallpaper-refresh
button).
symbols.song_style/dir_style/playlist_style(the file browser’s per-row-type marker, all three previouslyNone): Sky, Blue, Rosewater respectively.browser_song_format’s Artist/Title fields (actual browser row text, previously unstyled): Lavender and Sky – deliberately matching the playback header’s own Artist/Title colors, so the browser visually ties back to “now playing” instead of being a separate, uncolored world.song_table_format’s column headers (Playlists/Search tabs): Artist Lavender, Title Sky, Album Rosewater, Duration Peach. Header row only – left the actual per-song table rows mostly neutral on purpose, so a long table of songs stays scannable instead of turning into a wall of competing colors row after row.tab_bar.inactive_style(previously empty/default text): waybar’s own off-gray#6c7086– a real legibility improvement alongside the aesthetic one, since active (filled Mauve) vs. inactive tabs previously only differed by which one had a background fill, not by any text-color difference too.preview_metadata_group_style(previously identical Yellow to its ownpreview_label_style): Blue, so a label and its value read as two distinct things instead of one indistinguishable color.elapsed_and_bitrate(previously zero color): Green#a6e3a1.scrollbar.ends_style(previously empty): same off-gray as the inactive tabs.
Verified the same way as the first pass, not just “compiled without
error”: relaunched through the real keybinding, confirmed zero
theme-parse errors and the process still alive, screenshotted, and
pixel-matched. Most of these rendered immediately; dir_style’s Blue
specifically only appears on the Directories tab (rmpc’s default tab on
launch is Queue), so switched tabs with a real simulated 2 keypress
(SwitchToTab("Directories"), per config.ron) before screenshotting
that one rather than accepting a zero-match result as evidence it was
broken. Every color checked came back an exact RGB match; the inactive-
tab off-gray came back the right color family across 575 sampled
pixels once a genuine multi-tab screen was on-screen to sample from.
rmpc’s theme, round three: no more background fill on selection, and a real silent-failure bug found
Direct follow-up: “the active item should not get the background
colorf but instead have a rounded colord border. and that quee
directories artists row elements should be colored each differentlly
right also same goes for that normal text as well.” Pulled rmpc’s own
Rust source directly for this one (ui/dirstack/mod.rs,
ui/panes/queue.rs, config/theme/style.rs) instead of guessing at
what a request like “a rounded border around one row” could even mean
in a ratatui-based TUI.
No per-item border capability exists, confirmed from the source, not
assumed absent. current_item_style is a plain ratatui Style
(fg/bg/modifiers – confirmed the exact fields on StyleFile’s
own struct definition) applied via .style() directly on a
ListItem/Row. There’s no border concept for an individual list row
anywhere in the renderer – borders only ever wrap whole panes. Closest
honest substitute available: dropped bg entirely, changed
current_item_style to fg: "#cba6f7" with Bold | Underlined –
no solid fill block, an accent-colored underline standing in for the
outline that genuinely can’t be drawn here.
A real, serious bug found while building that, not just a cosmetic
miss: first wrote the modifiers as "Bold, Underlined"
(comma-separated) – valid RON syntax, but wrong for how Modifiers
actually deserializes. rmpc uses the bitflags crate (v2) for that
type, and its serde support parses combined flags in the crate’s own
Flag | Flag format, not comma-separated – read the bitflags! macro
invocation directly to get the real answer instead of guessing from the
single-modifier examples already in use elsewhere in this file. The
serious part: this didn’t just fail to apply that one field – it
silently took down the entire config load, not only the theme.
Found this by comparing rmpc debuginfo (run standalone, correctly
resolved every config/theme path) against the actual running instance’s
own log: its Resolved config dump showed cache_dir: None (a
config.ron-level setting that has nothing to do with the theme file
at all) and every color rendered as a plain ANSI name (.yellow(),
rmpc’s own literal built-in default), not any real hex value from this
theme – meaning one malformed field in the theme file doesn’t just
lose its own color, it silently drops the whole Config back to
Default::default(), no error or warning logged anywhere. Fixed by
using the format the crate actually expects, "Bold | Underlined", and
reconfirmed via the same log comparison: real Rgb(...) values back in
Resolved config, cache_dir back to the configured path.
Queue/Playlists/Search tables now genuinely colored per column, row
content included, not just headers. Traced song_table_format’s
actual row-rendering path (queue.rs’s as_line_ellipsized/
as_line_scrolling -> song_ext.rs’s as_line) and confirmed it
does apply each column’s prop.style to the real row text, not only
label_prop headers – so Artist/Title/Album/Duration row content now
matches its own column’s header color (Lavender/Sky/Rosewater/Peach)
instead of only Album ever having had an (redundant) explicit color.
A real, unfixable-via-config limit found for the browser tabs
(Directories/Artists/Album Artists/Albums): the other render path
songs take there (dirstack/mod.rs’s to_list_item) calls
the Song-typed Property::as_string variant, which discards style completely
– confirmed directly in the function body, not inferred. This means
browser_song_format’s per-field colors (set in the very first
theming pass, earlier the same evening) were already genuinely inert
dead configuration for real song rows in these tabs, and no theme-level
fix can change that – only the one-character
type marker before each name
(symbols.song_style/dir_style/playlist_style, already colored
from that first pass) is real there. Left the inert style: fields in
the theme file rather than quietly deleting them, with a comment
explaining exactly why, so a future reader (or future me) doesn’t
waste time re-discovering the same limitation.
Verified live the same way as every theming pass before it, including hitting the actual bug live before finding its cause: relaunched, confirmed via the log comparison above that the config had genuinely fallen back to full defaults, fixed the modifiers syntax, relaunched again, confirmed real hex values back in the resolved config, screenshotted the Queue tab with one real song actually queued (added it through rmpc’s own UI on the real Directories tab, not assumed), and pixel-matched all four column colors – exact RGB matches for each. Ruled out a leftover solid background block on the selected row by checking row-by-row pixel-count distribution rather than a single broad scan – the only full-width Mauve match is the pane’s own horizontal border line, not a fill across the row’s own content.
music-search.py: replacing termusic’s broken search with a direct-to-yt-dlp tool
Follow-up to the search diagnosis below: asked directly to build a replacement rather than just live with the workaround (pasting a raw YouTube URL) – “patch up termusic with our own scraping script… implement our own set of tools that does the same job maybe differently.”
Checked the obvious existing alternative first, not assumed one
didn’t exist: ytfzf (4.1k stars, markets itself as “without
API”) – read its actual source and found its default search path,
scrape_youtube(), is a direct alias for scrape_invidious_search().
Same broken dependency, same dead network already confirmed for
termusic. Ruled out rather than adopted.
Built on the one piece already proven solid: yt-dlp’s own
ytsearchN:query search, confirmed completely independent of
Invidious and fast (~2-3s for 10 results with --flat-playlist, which
reads only the search-results page itself rather than each video’s
full metadata). mpv/ffmpeg were already installed;
music-search.py (new, scripts/.local/bin/) wraps this into the same
wofi-search-then-list shape every other picker in this repo already
uses: $mod+Shift+y opens a wofi prompt for a query, then a second
wofi list of up to 10 results (title, uploader, duration – duration
shown deliberately, after accidentally downloading a 3+ hour video once
during testing when a search matched a long-form background-music
stream instead of an actual song), then downloads the picked one as an
mp3 with embedded cover art and metadata into ~/Music.
A real, concrete reliability problem found and fixed while building
this, not assumed away: 2 of 3 real test downloads (different
videos, not livestreams) hit YouTube’s “Sign in to confirm you’re not
a bot” wall using yt-dlp’s default extraction path. Confirmed the
standard, documented workaround directly rather than trusting it
blind: --extractor-args "youtube:player_client=android" – the same
exact URLs that failed with the default web client downloaded cleanly
every time with this flag added. Baked into every download this script
does.
A second real bug caught by testing the actual downloaded state, not
just “did the command exit 0”: the first version tried to find the
track’s own thumbnail (used as the completion notification’s icon,
same “use the real fetched image as its own icon” pattern
fetch_wallpaper.sh already established) by reconstructing the
expected filename from the search result’s raw title string –
worked in reasoning, failed on a real download: yt-dlp’s own
filesystem sanitization replaced a literal | in the title with a
fullwidth | (U+FF5C) when actually writing the file, so the
reconstructed glob matched nothing, silently leaving the thumbnail
file undeleted and falling back to a generic icon instead of the real
one. Fixed by finding the newest image file in ~/Music by mtime
instead of predicting yt-dlp’s own naming rules at all – confirmed via
makoctl history -j afterward that the completion notification
correctly references the real thumbnail path, and that the leftover
.webp file is gone from ~/Music post-run.
Verified fully live, through the actual keybinding and real typed
input (ydotool key/ydotool type, not the script run directly):
triggered $mod+Shift+y, typed a real query, read the actual search
results back via wofi’s own AT-SPI accessibility tree (confirmed real,
relevant titles/uploaders/durations, not placeholder data), picked a
short result, and confirmed the full download completed: ffprobe
shows a valid mp3-codec audio stream at the expected duration plus an
embedded png cover-art stream, and termusic’s own startup log
(launched fresh afterward) reports Finished Scanning "/home/yash/Music"
with 1 created or updated – the file is genuinely indexed by
termusic’s real library scanner, not just sitting on disk.
termusic’s own s key is left untouched (still bound to its default,
still technically works if a public Invidious instance ever recovers)
– $mod+Shift+y is just the reliable path now.
Every popup toggles now, plus termusic: real glass, correct size, and why search fails
Three asks together: every keybinding-launched popup in this repo should toggle (press the same keybinding again to close it, not just always open a new one) and close on Escape; termusic specifically needs an actual glass/transparent background, resized to 60%/50% of the output; and why doesn’t termusic’s YouTube search work at all.
Toggle + Escape, for every popup
Escape already worked, for the popups where it safely can – wofi’s
own default keybinding closes its popup on Escape (confirmed nothing in
this repo overrides it), and nwg-bar handles Key::Escape internally
too (gtk.MainQuit() on release, confirmed straight from its own Go
source) – neither needed anything added here.
Toggle needed real work: scripts/.local/bin/toggle-popup.sh, a
generic wrapper every wofi-based popup keybinding and the nwg-bar power
menu now route through (sway/config). A marker file per keybinding
(~/.local/state/popup-toggle/name.pid) tracks whether that specific
popup is the one currently open – not just “is a wofi process running
somewhere”, since every wofi-based picker in this repo shows up as a
bare wofi process indistinguishable from any other one by name alone.
Same key while its own popup is open -> close it (same effect as
Escape) and stop. Otherwise -> close whatever other popup might be
open first (so switching between two different pickers never stacks
one on top of another), then launch the one requested. Covers the
clipboard-history popup, the emoji picker, the app launcher, the
calculator, every waybar-click-turned-keybinding picker (Wi-Fi,
Bluetooth, Docker, notification history, keybind search), and the power
menu – ten keybindings in total, all through the one script.
Verified live for two structurally different cases, not just one:
opened the app launcher (a raw wofi process) via a simulated keypress,
confirmed it toggled closed on a second press and closed correctly on
Escape too (with the marker file cleaned up either way – confirmed the
directory ends up genuinely empty after an Escape-close, not just after
the toggle-close path); separately did the same for the power menu
(nwg-bar, an entirely different binary, not wofi at all) to confirm
the script generalizes across processes, not just within one tool.
termusic doesn’t route through this script – deliberately. Killing/relaunching its process on every toggle would kill playback too (it’s a real client/server app, not a transient dmenu popup); see below for what it uses instead.
A real, honest limitation, not silently skipped: Escape for
termusic specifically – making Escape hide termusic only while it’s
the focused window, without eating Escape for every other application
on the system, isn’t something plain sway bindsym can safely do.
bindsym captures a key combo at the compositor level regardless of
which window is focused; there’s no built-in “only when this app_id has
focus” scoping for it (the [criteria] command syntax scopes commands
run via a keybinding, e.g. swaymsg '[app_id=...] scratchpad show',
not whether the keybinding itself fires). Binding a bare Escape
globally would have intercepted it in every terminal, editor, and
dialog on the desktop, not just termusic – a real regression, not a
minor tradeoff. The toggle keybinding ($mod+Shift+m, same key hides
it as opens it) already delivers the same practical outcome without
that risk, so that’s what termusic actually gets.
termusic: real glass background
Reported directly: the window looked flat/opaque despite kitty’s own
background_opacity 0.85 (already set globally, kitty/kitty.conf,
the same value every other glass surface in this desktop already uses).
Root cause, confirmed by reading termusic’s actual source rather than
assumed: kitty’s background_opacity only blends cells using the
terminal’s own default background – a TUI application that
explicitly paints a background color into its cells (which termusic’s
theme did: primary.background = "#1e1e2e", a literal opaque hex) gets
drawn at full opacity regardless of the window’s own setting. This is a
real, known kitty+TUI-app interaction, not a bug in either tool
individually.
Confirmed termusic has a first-class way around this before assuming
one didn’t exist: its theme system supports a "native" value
(ThemeColor::Native) that resolves straight to ratatui::Color::Reset
for Background specifically (ColorTermusic::Background => Color::Reset,
confirmed reading styles.rs directly) – “don’t paint anything
explicit here, let whatever’s already there show through” instead of a
literal hex. Every panel’s own background_color in tui.toml
(library/playlist/lyric/progress/etc.) already referenced this same
field symbolically (background_color = "Background", not a hardcoded
color of its own), so changing the one value
([theme.primary] background = "native") cascaded correctly to the
entire UI in a single edit, confirmed by reading the generated config
rather than guessing it would.
Verified live, not just “should now blend”: screenshotted the actual running window before and after – 0% of its pixels are the old literal Mocha hex now (previously the vast majority were), confirming the explicit paint is genuinely gone. The resulting colors read very dark, which looked suspicious at first – cross-checked directly by hiding the window and screenshotting the exact same screen region with nothing but wallpaper in it: also very dark, confirming this is a correctly-blended, coincidentally-dark patch of the current wallpaper under an 85%-opacity dark terminal background, not a failure to blend at all (the math checks out: kitty’s own near-black default background at 0.85 opacity dominates the blend regardless of what’s behind it, same as it does for every other glass surface in this setup).
termusic: resized to 60%/50%, toggled via scratchpad
for_window [app_id="^termusic$"] now sizes it to 1152x540 (60%
width / 50% height of this machine’s actual 1920x1080 output,
computed directly rather than guessed, and left as fixed pixels rather
than ppt – sway’s own docs are ambiguous on whether percentage units
apply to floating containers the way they unambiguously do for tiled
ones, not worth the risk of a silently-wrong size for one window).
Toggling uses sway’s scratchpad (scripts/.local/bin/termusic-toggle.sh),
not the same kill-and-relaunch toggle-popup.sh every other popup uses
– deliberately, so playback never stops while the window is hidden.
First launch: no window exists yet, so it starts fresh and the
for_window rule (floating + sized + centered + moved into the
scratchpad + shown immediately, all in one rule) makes it appear
correctly. Every press after that: swaymsg '[app_id="^termusic$"]
scratchpad show' alone – confirmed directly, not assumed from sway’s
own slightly ambiguous docs (“cycles through windows… showing and
hiding them”), that calling this on a window already shown from the
scratchpad genuinely hides it again rather than being a no-op, by
toggling it twice and checking which pseudo-workspace (__i3_scratch
when hidden, the real workspace name when shown) the window actually
sits on after each call. Also confirmed directly that hiding it this
way never touches the underlying process at all – the same termusic
client and termusic-server PIDs stay up the entire time across a
hide/show cycle, and the server’s own log shows no disconnect/reconnect
during it.
A real, unrelated bug caught while building this: the first version of
termusic-toggle.sh launched kitty as a plain backgrounded shell job
(kitty ... &) – inheriting this script’s own stdout/stderr meant
whatever invoked the script (sway’s exec, a test shell) could hang
waiting for those file descriptors to close for as long as kitty kept
running, instead of returning immediately the way a background launch
should. Fixed with setsid plus redirected stdio, the same pattern
already used for every other backgrounded GUI launch verified this
session.
Why YouTube search fails: a real, external problem, not this setup
Confirmed directly, not assumed, that this isn’t a local network or
config problem first: yt-dlp itself, called directly, resolves
DNS, reaches youtube.com, and returns real search results with zero
issues. So the network, DNS, and yt-dlp itself are all fine – the
problem is specific to termusic’s own search mechanism.
Root cause, found in termusic’s own TUI log
(/tmp/termusic-tui.log): Youtube search fail: Something is wrong
with your connection or all 7 invidious servers are down. termusic’s
free-text search (the s popup) does not use yt-dlp for
searching at all – only for the download step, once a result is
already picked. Searching goes through the public
Invidious instance network instead (confirmed
reading lib/src/invidious.rs directly): it tries to fetch a live
instance list from api.invidious.io, falls back to five hardcoded
instance URLs if that fails, and needs at least one instance’s
/api/v1/search endpoint to actually respond correctly.
Tested every candidate instance’s actual search endpoint directly,
not just whether the domain responds to a bare request – three of
termusic’s five hardcoded fallbacks answered a plain request fine but
failed the specific /api/v1/search call for three different concrete
reasons: inv.nadeko.net returns 403 Endpoint disabled (search
explicitly turned off by that instance’s own operator),
invidious.nerdvpn.de returns 401 Authorization Required, yewtu.be
serves an anti-bot JavaScript challenge page instead of JSON (blocks
any plain HTTP client, not just termusic). The other two hardcoded
fallbacks (yt.artemislena.eu, y.com.sb) are simply unreachable.
Checked the live, currently-maintained instance directory itself too
(api.invidious.io/instances.json) rather than assuming termusic’s
five were just unlucky picks: zero of the 11 instances it currently
lists have their public API enabled at all. This is Invidious’s
entire public network in its current state, not a handful of bad server
picks – something termusic itself has no control over, and something a
newer termusic version wouldn’t fix either (checked the current
upstream master branch changelog: still Invidious-based, no
alternative search backend has been added).
The actual, working path around it, right now: paste a direct
YouTube video URL into the same s popup instead of typing a text
query – that skips the broken Invidious search step entirely and goes
straight to the yt-dlp download path, which is already confirmed
fully working. Practically: find the song elsewhere (a browser search),
copy its YouTube URL, paste it into termusic. Not the pure “type a song
name and go” experience originally hoped for, but a real, working
mitigation rather than a dead end – and an honest limitation to flag
rather than something to paper over.
termusic: a CLI music player with no local library and no Premium subscription
Asked for a “beautiful CLI” music player, explicit constraints: no local music library to start from, no Spotify or YouTube Premium subscription to lean on – it needs to actually fetch music from the cloud itself.
Picked termusic specifically because it says the quiet part out
loud in its own README: “You can download from YouTube, NetEase,
Migu and KuGou for free. No need to register for monthly paid
memberships.” Ruled out the other well-known “beautiful TUI music
player” options directly against that constraint rather than assuming:
spotify-player/ncspot both require Spotify Premium to actually play
anything (a restriction Spotify itself enforces on third-party
Connect/librespot clients, not something either project can route
around); rmpc/cmus are both local-library-only, no built-in way to
pull anything from the cloud at all. termusic was the only candidate
that actually solves “flashy and no subscription and no existing
library” simultaneously.
Confirmed the actual cloud-fetch mechanic from its own source before
relying on it, not assumed from the README’s marketing copy alone:
s (default library_keys.youtube_search binding) opens a “Download
url or search:” popup – type a song/artist name, pick a result, it
downloads the audio via yt-dlp straight into ~/Music, adds it to
the playlist, and plays. Works from a genuinely empty ~/Music –
nothing needs to exist beforehand, the library builds itself purely
from searching.
Directly in Arch’s official extra repo (packages/pacman.txt) –
no AUR, no building a Rust project from source, which its own MSRV
requirement (1.90.0) would have made a real wait. yt-dlp added
alongside it (the actual thing that lets it reach YouTube at all);
ffmpeg/kitty were already installed.
A real version mismatch, found by checking rather than assuming
current-upstream matches the installed package: upstream master
ships an official Catppuccin-Mocha.yml theme in its own source tree
(exact ANSI-color match to the canonical Catppuccin terminal spec, not
a close approximation) – but the Arch package here is 0.13.2-1,
built before that theme existed upstream. Confirmed directly: launched
termusic once, let it create its own ~/.config/termusic/themes/
(214 bundled files), and it genuinely wasn’t among them. Copied the
file in by hand from upstream’s source tree instead of trusting the
installed version to already have it.
Theme selection baked in, not left as a manual first-run step –
avoided guessing the TOML shape of a nested Rust config struct blind
(a real risk this session has been burned by more than once elsewhere,
e.g. waybar’s broken format-icons threshold form): launched termusic
with no config at all first, let it generate its own real defaults, and
read the actual resulting tui.toml’s [theme]/[theme.primary]/
[theme.cursor]/[theme.normal]/[theme.bright] shape directly before
writing anything, rather than inferring it from the Rust source’s field
names alone. Wrote the Catppuccin Mocha hex values into that exact
confirmed shape.
Config vs. state, same line this repo draws everywhere else: only
tui.toml, server.toml, and the one theme file are stowed into this
repo – ~/.config/termusic/ also holds data.db/library2.db/
playlist.log (a real SQLite library index and playback queue, not
configuration) and termusic’s own 213 other bundled theme files, all
deliberately left as real local files, not symlinked in, the same
config-vs-state boundary already established for
~/.local/state/notification-mode/ and ~/.local/state/caffeine/.
This works cleanly because ~/.config/termusic/ already existed as a
real directory (termusic creates it itself on first run) before this
package was ever stowed – confirmed with stow -n -v first that GNU
Stow’s own tree-folding behavior symlinks the three individual files in
rather than replacing the whole directory with one symlink, which would
have pulled the runtime databases into version control right along with
them.
Launch: $mod+Shift+m opens it in its own floating kitty window
(kitty --class termusic -e termusic, sized/centered via
for_window [app_id="^termusic$"] in sway/config) – floating on
purpose, not tiled, the same reasoning as every other “glance at it,
dismiss it” tool in this setup rather than something that permanently
claims a tiling slot.
Verified live end to end, not just “should work”: triggered the
real $mod+Shift+m keybinding via a simulated keypress (not the launch
command run manually), confirmed via swaymsg -t get_tree that a
900x700 floating window opened at the expected centered position, and
screenshotted the actual running window – 88.6% of its pixels are the
exact Catppuccin Mocha background hex (#1e1e2e), confirming the theme
is genuinely active in the real keybinding-launched instance, not just
a separately-tested one.
Power menu, polish pass: label rename, thinner icons, real icon/label gap
Three small, concrete asks together: rename “Shutdown” to “Power off”,
make the icons thinner, and add real space between icon and label –
nwg-bar stacks them with zero gap of its own between (its own source:
SetImagePosition(gtk.POS_TOP), nothing else).
bar.json’s label changed directly, no icon/exec change needed.- Icon stroke-width dropped from 2 to 1.5 across all five (icons/*.svg) – same Lucide path data as the previous pass, just a thinner line.
- Icon size dropped from nwg-bar’s own default (48px, undeclared until
now) to an explicit
-i 40on both places nwg-bar gets launched (waybar’scustom/poweron-click,sway/config’s$mod+Shift+p) – the icons read as slightly oversized/cramped against the button’s own padding at the default size. - The actual gap:
image { margin-bottom: 10px; }instyle.css–imageis the element sitting directly above the label in nwg-bar’s own POS_TOP stack, so a bottom margin on it is the icon-label gap. No dedicated “icon” or “label” CSS node exists to target more specifically than that (confirmed from nwg-bar’s own source – it just callsbutton.SetImage()/button.SetLabel()and lets GTK’s default button-content box handle layout).
Verified live, measuring the actual before/after gap rather than assuming the margin took effect: read the padlock icon’s own rendered pixels back as ASCII art before and after – icon bottom edge to label top edge went from roughly 9px to roughly 19px, a real, measurable change, not just a CSS value that might not have applied. Confirmed via nwg-bar’s own AT-SPI accessibility tree that the fifth button now reports itself as “Power off”, not “Shutdown”. Reconfirmed the five per-action border colors from every earlier pass were unaffected.
Power menu, third icon pass: lean stroke icons, all five in one family
Direct feedback right after the Adwaita swap below: “older once were better than this. i want lean icons just like the older once but just different.” The Adwaita set fixed meaning (a real moon instead of an ambiguous ring) but lost the look – solid, bold shapes where the originals were thin single-stroke outlines. The actual ask all along was both at once: keep the lean look, fix the shapes that weren’t meaningful.
Landed on Lucide – a modern line-icon set (24x24
grid, consistent 2px stroke, round caps/joins, no fill) that’s exactly
the “lean” language the original nwg-bar icons were reaching for, just
executed properly and with genuinely distinct shapes per action. Found
it already vendored locally as an npm dependency in an unrelated project
on this machine (lucide-react under
~/Codes/claude-remote-main/client/node_modules/) – no network fetch
needed, the exact path data for lock, log-out, moon, rotate-cw,
and power came straight out of that package’s own per-icon source
files. All five rebuilt as plain standalone SVGs from that data, same
stroke weight and style across the board – one coherent family this
time, not a patchwork of old-style-plus-one-new-icon (the previous
pass’s own real shortcoming) or a style mismatch against a set that
used to be lean (the Adwaita pass’s shortcoming).
Lucide’s own default is stroke="currentColor" – swapped for hardcoded
#CDD6F4 before use, the exact same reasoning applied to every other
icon fix in this repo: these load as raw files via an absolute path, not
through GTK’s icon-theme resolution, so currentColor has no reliable
context here. power specifically is functionally identical to what
Shutdown already had (ring + vertical line, the real ISO standby
symbol) – swapped anyway, for stroke-weight consistency with the other
four rather than leaving one differently-drawn icon in an otherwise
uniform set.
Verified live the same way as every icon pass this session: launched
nwg-bar directly, read all five icons’ actual rendered pixels back as
ASCII art – a genuinely hollow padlock, a door-plus-arrow logout, a
thin crescent moon (finally, distinctly not a ring), a circular
refresh arrow, and the power symbol, all at the same visual weight.
Reconfirmed the five per-action border colors from the earlier pass
were unaffected (386-795 matching pixels each, same range as before).
Power menu: better icon shapes, four of five swapped for Adwaita’s
Reported directly, with a specific example: Suspend rendered as “a circle with a vertical rod in the middle” – not a meaningful shape – and asked for better icons generally, not just that one.
Checked Papirus’s own system-suspend-symbolic as the obvious first
candidate and rejected it – it’s the same ring-plus-horizontal-dash
concept already in use, just a slightly different rendering of the
identical idea, so swapping to it wouldn’t have actually addressed the
complaint. Went with a genuinely different, unambiguous concept instead:
a crescent moon (Papirus’s own weather-clear-night-symbolic shape,
recolored) – the universal “sleep” symbol, immediately readable in a
way an abstract ring never was.
While at it, swapped Lock/Logout/Reboot to Adwaita’s own symbolic icon set too (GNOME’s, clean and widely recognized) rather than leaving a patchwork of nwg-bar’s original minimal shapes next to one new moon – one coherent icon family reads better than a mismatched set, and this was explicitly asked for “better icons for those buttons” (plural, not just Suspend). Shutdown untouched – it was already correct from the previous pass (the real ISO power/standby symbol), no reason to change what wasn’t broken.
Every Adwaita source icon ships with a hardcoded fill meant for light
GNOME toolbars (#2e3436) – the exact same “icon designed for the
wrong background” issue already found and fixed for mako’s own
notification icons earlier this session, not a new class of problem.
Recolored to #CDD6F4 (Clean Silver, this bar’s own text color) before
use. Full source/shape mapping and reasoning in icons/README.md.
Verified live, not assumed correct from reading the source SVGs alone:
launched nwg-bar directly and read every one of the five icons’ actual
rendered pixels back as ASCII art – a real padlock (Lock), a clear exit
arrow (Logout), an unambiguous crescent moon (Suspend), a circular
refresh arrow (Reboot), confirmed distinct from each other and each
individually recognizable at the size they actually render at, not just
plausible-looking in isolation. Confirmed the five per-action border
colors from the previous pass were unaffected – 386-780 matching pixels
each, same range as before this change.
Power menu: keybinding, and verifying Tab navigation without ever pressing Enter
Asked for a keybinding to open the power menu (same gap every other
waybar-click-only picker in this repo already had fixed –
$mod+Shift+p, matching the established $mod+Shift+letter
convention for exactly this class of fix) and for Tab to move between
its buttons.
A real constraint shaped how this got tested: every single action in
this menu – Lock, Logout, Suspend, Reboot, Shutdown – is disruptive or
outright hard to reverse. Pressing Enter to check “did Tab land on the
right button” risks actually locking, logging out, suspending,
rebooting, or shutting down the remote session being worked in, which
this whole session has held as a hard line to never do for testing
purposes. Screenshotting for a visible focus indicator wasn’t reliable
either at first – zero pixels differed before Tab was actually styled
for :focus (below), which would have read as “Tab doesn’t work” when
it actually did, just invisibly.
Solved with something genuinely safe and precise instead: nwg-bar
already registers itself on the AT-SPI accessibility bus (confirmed
directly – Atspi.get_desktop(0) lists it alongside waybar and the
system tray applets). Queried its own accessibility tree read-only after
each simulated Tab/Shift+Tab press and asked which button reports
itself FOCUSED – no risk of activating anything, this is the same
mechanism screen readers use, purely observational. Confirmed the full
cycle, both directions, with wraparound: Lock -> Logout -> Suspend ->
Reboot -> Shutdown -> (wraps) -> Lock forward, and the same in reverse
with Shift+Tab. This works because nwg-bar requests exclusive layer-
shell keyboard focus itself (LAYER_SHELL_KEYBOARD_MODE_EXCLUSIVE,
confirmed straight from its own Go source) and real GtkButtons in a
GtkBox get Tab-chain navigation from GTK for free on top of that –
nothing this repo had to build, just verify.
Found a real gap while verifying, not something asked for directly:
focus was genuinely moving the whole time, but nothing was styled to
show it – confirmed by screenshotting before/after a Tab press and
finding the card region byte-for-byte identical. Fixed by giving
:focus the exact same treatment as :hover on every per-action rule
(same nth-child selectors, style.css) – keyboard nav and mouse
hover now read as the same “this one’s about to activate” state, which
is also just the more honest model of what’s actually true. Re-verified
after the style fix: the same before/after screenshot diff that read 0
now reads 35936 differing pixels for a single Tab press, and AT-SPI
confirms the button that’s actually focused matches which one visibly
glows.
Power menu, corrected: color moved to the pill, glass+border on every button, and a real CSS bug
Direct, blunt feedback right after the entry below: the icon-recoloring approach was wrong (“by colors i don’t mean those icons”), and the card itself was missing the glass background/border it was supposed to already have on every button, not just the outer frame.
Color, moved to the right layer: reverted every icon back to plain
(stock fill:none, matching nwg-bar’s own upstream appearance – see
icons/README.md). Per-action color now lives on the button itself,
mirroring exactly how waybar colors its own modules – a colored border
at low opacity, brighter plus a soft glow on hover, the same two-
strength pattern waybar’s own hover states already use everywhere else.
nwg-bar gives buttons no individual CSS name/class (confirmed from its
own source), so the five per-action rules key off :nth-child(N)
against bar.json’s own fixed action order instead of a name selector.
Glass + border on every button, not just on hover: previously
button { background: none; border: none; } until hover added them –
technically the outer card did have its own glass+border the whole
time, but each individual button had no chrome of its own at rest,
which is almost certainly what read as “no glass, no border” even
though the outer frame was there. Gave every button waybar’s own exact
module-pill formula (rgba(17,17,27,...) fill, 16px radius, a subtle
low-opacity Mauve-family hairline border) as its resting state, not just
its hover state – “follow the theme of the status bar” taken literally,
copying the actual formula rather than a vague gesture at “glassy”.
A real bug found in the process, not just a design miss: the very
first attempt at this fix (which added :nth-child selectors and new
per-action comments) silently failed to apply at all – nwg-bar’s
own log said outright: style.css file erroneous or not found, using
GTK styling, meaning every single change in that pass rendered as bare
default GTK chrome instead, worse than before either fix. Diagnosed
precisely with GTK’s own Gtk.CssProvider parsing-error signal
(connected to it directly from Python rather than guessing) –
style.css:14:38 '/*' in comment block. The cause: a comment’s own
prose mentioned icons/*.svg, and CSS comments can’t nest – the literal
/* inside that path broke out of the comment early and confused
everything after it. Fixed by rewording the one line; re-checked with
the same parsing-error signal afterward and got zero errors before
trusting it further. Worth remembering for any future CSS comment in
this repo: never write a literal /* inside prose, even referring to a
glob pattern – GTK’s CSS parser (and CSS in general) has no escape for
it inside a comment.
Verified live end to end, not just “parses now”: launched nwg-bar
directly, confirmed the log now says Using style: ... instead of the
erroneous-fallback message, screenshotted the real card, confirmed zero
pixels matching any of the old icon-fill colors (icons genuinely back to
plain), and found substantial, distinct pixel counts for all five
button-border colors at their expected low-opacity blend within the
card region. Read the Shutdown icon’s own glyph back as ASCII art too –
a clean ring outline with no fill behind it, matching stock exactly.
Power menu icons: invisible colored circles, no per-action distinction
Asked for the power-menu icons (nwg-bar, custom/power’s waybar
on-click) to actually show meaningful, colored icons – specifically
that Shutdown should look like an actual power button.
Root cause, found by reading the actual SVG, not assuming a stock icon
pack just works: nwg-bar’s own stock icons
(/usr/share/nwg-bar/images/*.svg) each have a colored circle
(fill="#c2352a" etc.) with an inline style="fill:none;fill-opacity:1"
on the same element – CSS style always wins over a plain presentation
attribute, so the circle’s color was silently disabled in every single
one of them. Every action rendered as the same flat white ring glyph
with nothing behind it, regardless of theme – not “small” or “wrong
shape”, genuinely invisible color. Separately, Suspend and Shutdown
shipped the exact same red (#c2352a) even with the fill working, so
there’d have been no color distinction between a soft/reversible action
and a final one anyway.
nwg-bar gives buttons no individual CSS name/class to target per action
(confirmed from its own source, already noted in style.css from the
switch away from wofi for this menu) – so per-action color can’t live
at the CSS layer here the way every other module in this bar does it.
Made local copies of the five icons actually used
(nwg-bar/.config/nwg-bar/icons/, bar.json repointed at them instead
of the system path) rather than editing package files directly, removed
the fill:none override, and recolored each to this desktop’s own
Catppuccin Mocha palette instead of the stock pack’s arbitrary colors –
full mapping and reasoning in that directory’s own README.md. Glyph
shapes were already correct and untouched – Shutdown in particular is
already the real ISO power/standby symbol, just needed its color back.
Verified live: launched nwg-bar directly, screenshotted the real card,
and read the pixels back as ASCII art for both Shutdown and Lock –
confirmed a solid colored circle with the white glyph cleanly cut out of
the middle for each, the same ground-truth method the volume icon saga
(below) established as the only one that’s actually caught every real
issue this session, rather than the color-tolerance sampling that missed
problems twice in a row there.
Volume icon, third pass: dropped the glow – the real fix didn’t need it
Asked directly, after the format-icons bug was fixed and the icon was
genuinely rendering again: why does it have a glow, make it normal. Fair
question – the text-shadow on #pulseaudio was added specifically to
make an invisible icon more noticeable, back when the actual problem
was believed to be “too small” rather than “not rendering at all”. Once
the real cause turned out to be waybar’s broken format-icons
threshold-object form (previous entry) and the fix moved to
volume_osd.sh setting real PulseAudio mute at 0%, the glow’s original
justification was already gone – it was solving a problem (“small/hard
to notice”) that had already been fully addressed by the icon simply
rendering correctly again. Removed it; #pulseaudio is back to a plain
resting color like every other module in this bar, nothing left to
explain away.
Verified live with the same ASCII-dump method as the previous two entries (not the color-tolerance sampling that misled the earlier passes on this exact feature): the icon at 80% renders as a tighter, crisper glyph shape with the shadow gone, still clearly present.
Volume icon, second correction: waybar’s threshold-object format-icons is broken here; a real bug, not a config mistake
Reported again, still after the text-shadow fix: “only when its muted does it show the icon” – the size/glow problem was actually fixed, but the icon was invisible at every regular volume level the whole time, not just small. My two previous verification passes on this exact feature (color-tolerance pixel matching, both times) missed it – worth being honest about a third time getting this feature’s diagnosis wrong before it landed correctly.
Stopped trusting color-heuristic sampling entirely this time and
switched to a fundamentally more reliable method: dump the actual
screenshot region as raw ASCII art (luminance-thresholded, # for
“clearly brighter than background”, . otherwise) and read the glyph
shapes directly, the way a person would look at the bar itself, instead
of pre-supposing a hue to filter for. This immediately showed the real
picture: at 80% volume, the bar rendered <backlight icon> 80% with
nothing between the two spaces and the digits – no icon at all, not
a small one. Toggling actual mute, the exact same technique showed a
genuinely new glyph appearing in that same gap. So the earlier
“verified 20x9px glow at every level” and “consistent 12x9px footprint”
claims from the previous two entries were both artifacts of the sampling
method itself – likely picking up backlight’s own nearby icon or
antialiasing noise that happened to pass a Maroon-ish color filter, not
the pulseaudio icon actually rendering. A hard lesson in this session
about verification methods that can look rigorous (measuring exact
pixel counts and bounding boxes) while still being wrong at the premise
level (matching the wrong thing entirely).
Root cause, isolated properly this time: format-icons’s
threshold-object form ({"icon": ..., "max": N}, added specifically to
give 0% its own icon separate from “just quiet”) is real and documented
– straight from waybar’s own source, src/ALabel.cpp. Reverted it back
to the plain 3-item array with nothing else changed, re-ran the same
ASCII-dump test: the icon reappeared immediately, correctly, at every
level. Re-applied the threshold-object form alone, nothing else changed:
gone again. This isolates it cleanly – the threshold-object array
genuinely does not render an icon for the pulseaudio module’s regular
(non-muted) format string in this waybar build (v0.15.0), while the
exact same array shape and the exact same source code path works for
every other reported case in this repo (e.g. format-muted, which
never used format-icons at all – it’s a fully hardcoded string, which
is why the muted case kept working the whole time and looked like
proof the feature worked). Concluded this is a genuine bug or version-
specific incompatibility in waybar itself, not a JSON mistake – filed
under “don’t rely on this” rather than fought further blind.
Fix, moved to where it can actually be guaranteed to work: instead
of trying to make waybar’s icon selection distinguish 0% from “just
quiet” (the broken path), made 0% and actual mute the same real
PulseAudio state instead. volume_osd.sh’s adjust_volume() now sets
the real mute flag whenever a scroll/keypress lands exactly on 0%, and
clears it the moment volume moves back above 0% – so format-muted
(the one path already proven correct this whole time) picks it up
naturally. This also happens to be a more honest model of reality: 0%
and muted really are the same silence to the ear, so making them the
same underlying PulseAudio state is more correct than trying to fake
the same look via two independent, divergent code paths.
Verified with the ASCII-dump method throughout, not the old color
sampling: 15/50/90% each render a real, distinct icon shape again.
Scrolling down to 0% via the actual volume_osd.sh -5% path (not raw
pactl) confirmed Mute: yes at the PulseAudio level and the expected
new glyph + “Muted” text appearing in the bar. Scrolling back up
confirmed Mute: no and the normal icon+percent display returning.
One known, accepted gap: this only auto-mutes when 0% is reached
through volume_osd.sh itself (the OSD script all of this repo’s own
scroll/keybinding paths already go through). Setting volume to exactly
0% through some other tool entirely (pavucontrol’s own slider, a
different script) bypasses this and would still show the old “just
quiet” look rather than “muted” – an accepted gap matching this
session’s general practice of solving the primary, actually-used
interaction path robustly rather than chasing every possible external
tool that could also touch the same PulseAudio state.
Volume icon, correction: the span-size fix broke rendering; text-shadow is what actually worked
Reported right after the entry below: the volume icon wasn’t visible at all anymore, and the muted icon + “Muted” text weren’t on the same line. Both true, and both caused by the fix in the entry below – recorded here rather than editing that entry in place, since being honest about a real regression (and the reasoning that led to it) is worth keeping visible, not quietly erasing.
What actually went wrong, found by isolating variables one at a
time rather than guessing: the previous fix wrapped just {icon} in
its own inline Pango span (<span size='16384'>{icon}</span>) to grow
the icon without touching the percent text next to it. Reverted that
first and re-tested with plain format strings (icon still small, but
present and correctly aligned) – confirming the icon-only span was
the actual cause, not the separate format-icons threshold-object
restructuring done alongside it (that part checked out fine on its own).
Then tried the more conventional alternative, a uniform font-size bump
on the whole #pulseaudio module (same size for icon and text, avoiding
a mixed-size line entirely) – and hit a second, different, genuinely
strange failure: at 15px, the three volume-level glyphs (low/medium/
high) stopped rendering completely (0 matching pixels, confirmed via
screenshot), while the mute glyph and the plain percent digits kept
rendering fine at the exact same size. Reproducible, isolated by testing
font-size on its own with nothing else changed – a real quirk in how
this specific Nerd Font’s icon glyphs rasterize via this Pango/Cairo
stack at that size, not something worth chasing further blind with only
pixel-sampling as a diagnostic (no way to actually look at the render
the way a person can – worth stating plainly rather than continuing to
guess at magic numbers).
Tried font-weight: 700 next, since this exact stylesheet already
proved it safe elsewhere (workspace numbers) – confirmed it changed
nothing at all for these specific glyphs (no visible difference,
but also no breakage) – likely a single-weight icon font with no bold
variant to fall back to differently. Landed on text-shadow instead –
the same paint-only technique this file already used successfully once
before for “make something louder without touching layout”
(#custom-capslock.locked’s own glow) – doesn’t change box size or
font rendering at all, just adds a soft Maroon halo around whatever
already rendered. Verified this one properly: measured the icon’s
visible footprint by color-matching for a Maroon-ish hue (not exact-color
match this time, since a soft glow blends toward the background at its
edges) rather than trusting a loose “anything non-background” check,
which the first pass at this measurement wrongly picked up on unrelated
neighboring modules (backlight’s own Yellow icon bleeding into the same
crop region) before being narrowed down. Result: a consistent ~12x9px
glow footprint at every volume level (versus ~4-5px bare before), icon
and “Muted”/percent text sitting on the same y-range (15-25 and 16-24
respectively – properly aligned, unlike the broken span version), and
the glyphs render at all, which they didn’t at 15px.
Volume icon: too small to notice, and 0% didn’t look muted
Asked two things, framed as one report: at 0% volume (not the actual PulseAudio mute flag, just the slider at zero) the icon didn’t change to look muted, and separately the icon in general is hard to notice at low volumes – asked for my actual design opinion on the first one rather than just doing whatever was asked, and to fix icon size so it stays “consistent and visible” rather than wobbling per level.
Design opinion, given directly rather than silently implementing either way: yes, 0% should look the same as an actual mute. There’s no audible difference between “volume slider at zero” and “muted” – both are silent – so showing the “just quiet” icon at 0% instead of a muted-looking one is actively misleading, not a neutral design choice. Fixed accordingly.
Root cause of “hard to notice”, found by measuring real pixels, not guessed: cropped a live screenshot down to just the icon glyph and measured its exact bounding box at three volume levels – ~4x5px of actual ink, at every level, regardless of which of the three glyphs was showing. That’s tiny at this bar’s 12px base font-size; the icon reading as “too small” wasn’t really about which volume level, the whole glyph set is just small there.
0% fix: waybar’s format-icons isn’t limited to the evenly-bucketed
plain array this repo had been using (["low","med","high"], which
divides 0-100 into three equal thirds – 0% and 20% landed in the exact
same bucket, both showing the “low” glyph). Confirmed directly from
waybar’s own source (src/ALabel.cpp, getIcon()) that format-icons
also accepts an array of {"icon": ..., "max": N} threshold objects,
checked in order, first match wins – gives an exact, standalone 0%
bucket. Reuses format-muted’s own existing crossed-out glyph
(U+F075F, already in the pre-existing config, confirmed present in the
installed Nerd Font via fc-list before reusing it) rather than
inventing a fourth new one – 0%-unmuted and actually-muted are the same
experience for the user, so they should be the same icon, not two
different ones
that both mean “silent.”
Size fix: wrapped {icon} in its own Pango span
(<span size='16384'>{icon}</span>) directly inside the format
string, sizing only the icon, not the percent-text next to it –
Pango spans with attributes already confirmed working in this exact
waybar version elsewhere in this repo (docker-status.sh’s own
<span color=...> usage), so this wasn’t a new, unverified mechanism.
Deliberately not a CSS font-size change, and deliberately
not state-conditional – this exact repo already hit a real failure
mode doing that once before: bumping font-size on
#custom-capslock.locked (a dynamic state that toggles at runtime)
visibly popped the whole bar taller the instant the state activated,
since waybar’s height config doesn’t hard-clip an over-tall label
(documented in that rule’s own comment in style.css). A span size
baked directly into the static format template has neither problem –
it’s the same size on every single render, from waybar’s very first
frame onward, so there’s no “before” state to jump from and nothing
ever pops.
Verified live, not just reasoned about: restarted waybar for real
this time (the previous entry above already found SIGUSR2 doesn’t
reload anything in this config) and measured the icon’s own bounding
box again at 15/50/90% – now a consistent x=92-111, y=16-24 (20x9px)
at every level, identical footprint regardless of which glyph is
showing, versus ~4x5px before. Confirmed 0% (unmuted) renders a visibly
different, narrower glyph in the same y-range – the reused mute icon,
not the “low” one. Confirmed the true-mute case (pactl set-sink-mute)
still renders correctly too. Confirmed no bar-height pop from the
static span size, screenshotting the bar’s own background extent before
and after the restart.
Workspace indicator, follow-up: occupied numbers were too dim, and a real waybar-reload gap
Reported right after the occupancy feature below: the occupied-but-
unfocused number (translucent Red, 0.7 alpha) was “slightly hard to
notice”. Also clarified the intended signal split more precisely: the
number color should say “there’s a window here, or this is where I am”
– deliberately not distinguishing between those two on its own – and
the underline should be the only thing that confirms “this one
specifically is current”. That’s a cleaner design than graduated
color intensity was: bumped the occupied rule’s color from
rgba(243, 139, 168, 0.7) to solid #F38BA8, identical to .focused.
The two states now differ only in font-weight (600 vs 800, a secondary,
non-load-bearing cue) and the underline, which stays exclusively on
.focused.
A real gap found while re-verifying this, not part of what was
reported but worth recording: re-checked the fix by sending
pkill -SIGUSR2 waybar and screenshotting, same as the first pass on
this feature – and initially got ambiguous, inconsistent-looking pixel
counts. Read waybar’s own source (src/main.cpp) to check what SIGUSR2
actually does by default: nothing, unless the bar config sets its own
on-sigusr2-action (ours doesn’t) – it’s a no-op signal here, not a
style/config reload trigger. waybar also doesn’t appear to file-watch
its own CSS for hot-reload (checked client.cpp’s setupCss, only
called at startup). So the first pass’s “live verification” earlier
this session may well have been screenshotting a stale render by
coincidence of the colors happening to look plausible, not an actually-
confirmed reload – worth being honest about here rather than letting it
stand uncorrected. Fixed properly this time by actually restarting
waybar (pkill the old process, launch a fresh detached one via
setsid waybar & disown, since sway/config’s exec waybar isn’t a
supervised/auto-restarting exec) rather than trusting a signal with an
unconfirmed effect. Lesson for next time touching waybar’s CSS:
restart the process outright to verify, don’t rely on SIGUSR1/2 doing a
style reload unless that’s explicitly wired up in the bar’s own config.
Re-verified live after the real restart, at full bar height this time (41px, not the 30px guess used previously) specifically to separate the number glyph’s own pixels from the actual underline: rows 15-23 showed solid Red at both workspace numbers’ x-positions equally (confirming the brightness fix), and rows 32-33 showed a long, contiguous solid-Red run spanning the focused workspace’s full button width only – nothing at the unfocused-but-occupied workspace’s position in those rows. Number brightness: fixed and equal. Underline: still exclusively on the focused one. Exactly the intended design.
Workspace indicator: occupancy as its own signal, separate from focus
Asked for the workspace numbers in waybar to show which workspaces hold at least one window, everywhere – not just at the currently-focused one. Concretely: the focused workspace keeps both its number colored and underlined (unchanged from before); any other workspace holding a window gets its number colored too, just without the underline, so “you are here” (underline) and “something’s running there” (number color) read as two independent signals rather than one conflated one.
waybar’s sway/workspaces module already tracks exactly this itself –
confirmed straight from its own source
(src/modules/sway/workspaces.cpp): every workspace button gets an
.empty CSS class toggled live, added whenever that workspace’s
nodes + floating_nodes are both empty. No IPC polling or extra
script of our own needed – waybar was already computing “does this
workspace hold a window” every single update, just never exposing it as
a distinct visual state in this stylesheet.
New rule: #workspaces button:not(.empty):not(.focused) – number-color
only, explicitly :not(.focused) so the existing .focused rule always
wins outright on the one workspace that’s both occupied and current
(solid color + underline), rather than the two rules fighting over the
same element. Colored the same Red as .focused, just translucent
(rgba(243, 139, 168, 0.7)) rather than solid – deliberately not a
fourth unrelated hue: one color family read at two strengths says
“these are the same kind of signal, at different degrees” far more
clearly than reaching for e.g. Peach or Teal would, and keeps the
underline as the one and only “current” signal instead of also having
to double as an occupancy signal. Placed before the existing :hover
rule in the stylesheet on purpose – same selector specificity as
:hover, so on a tie the later rule wins; hovering an occupied,
unfocused workspace needs to still show hover’s Lavender, not lose to
this new rule.
Verified live, not just reasoned about: two real workspaces existed at
test time, one focused with a window (workspace “2”, kitty) and one
unfocused with a window (workspace “1”, zen) – confirmed via
swaymsg -t get_tree first, then screenshotted the actual bar and
searched for both the solid-Red and translucent-Red-over-background
colors specifically: 42 pixels matched solid Red (the focused number),
25 matched the translucent blend (the unfocused-but-occupied number) –
two distinct numbers, two distinct intensities, exactly as designed.
Notification toast icons, round two: currentColor makes symbolic icons invisible on dark backgrounds
Follow-up to “Notification toast icons” below: reported directly after
that fix, specifically about the notification-history.py copy action –
“it fires the notification that the notification is copied but it does
not show the clipboard icon only text”. The icon-path fix below was
real and necessary, but its own writeup’s claim that it “covers all of
them” was wrong – it fixed resolution (mako can now find every icon
name), not visibility (whether the resolved icon is actually
perceptible once drawn). Those turned out to be two separate bugs.
Root cause, found by reading the actual SVG, not assumed: every
single -symbolic-suffixed icon in Papirus (and several of its plain
actions-category icons too, e.g. edit-copy, audio-volume-high,
media-record, process-stop – the symbolic style, just without the
-symbolic suffix on the name) uses fill:currentColor with a style
block declaring .ColorScheme-Text { color:#444444; } as the default –
a dark charcoal gray, designed for GTK’s own symbolic-icon recoloring
convention on light UI chrome (toolbars, panels). mako just renders
the raw SVG with no theme-aware recoloring – there’s no mechanism in
mako to override currentColor. On this desktop’s near-black
(#11111B) notification background, a #444444 icon is barely
distinguishable from the background it’s sitting on: verified directly
by diffing two real screenshots pixel-by-pixel in the icon’s own region
(one with edit-copy-symbolic, one with a deliberately nonexistent icon
name as a control) – 7114 pixels genuinely differed between them, so
something was drawing, just not anything a person would actually notice
at a glance. This is almost certainly the real shape of “shows the text
but not the icon”: not a missing icon, an invisible one.
Fix: swapped every affected icon reference, across every script in
this repo, from a symbolic/action-style name to Papirus’s status or
apps category equivalent instead – those use real, hardcoded
style="fill:#hexcolor" values (confirmed per-icon with
grep -c currentColor returning 0 before using any of them), the same
“regular”, full-contrast style Docker’s own icon already used
successfully in the first pass. Two icons (process-stop, media-record)
have no real-fill equivalent anywhere in Papirus at all – only ever
defined in the actions category, always currentColor – swapped for
the closest real-fill concept instead: dialog-warning (real amber) for
“cancelled” actions, audio-input-microphone (real fill, devices
category) for “recording”.
Also switched every one of these to an absolute file path rather
than a bare theme name, even where a real-fill icon does exist under a
plain name – several names (e.g. weather-clear, audio-volume-high)
resolve to multiple files across different Papirus categories, one
currentColor and one real-fill, and mako’s own search algorithm picks
by size-match, not by category – nothing in the name itself lets you
choose the good one. An absolute path removes that ambiguity entirely,
matching how this repo’s own wallpaper/screenshot notifications already
pass a real file path rather than a theme name.
Verified live, not just reasoned about, for the two most visually
distinct cases: fired the exact notify-send call
notification-history.py’s copy action uses, screenshotted it, and
found 617/413 pixels at the clipboard icon’s own known fill colors
(#e4e4e4/#d3d3d3 from the SVG source) in the icon region – a
deliberately nonexistent-icon control notification, diffed pixel-by-
pixel against the same region, differed by 4194 pixels, confirming this
wasn’t incidental. Separately fired a critical-urgency notification with
the new dialog-error icon and found 1082 pixels of its own real red
fill (#f44336) in the same region. Every other icon across every
other script in this repo shares the exact same status/apps-category,
real-fill, absolute-path pattern – not independently screenshot-tested
one by one (would be the same test repeated a dozen times for the same
already-proven mechanism), but each one’s source SVG was individually
confirmed currentColor-free with grep before being chosen, the same
discipline as every icon-existence check already established this
session.
One file left untouched on purpose: notification-history.py’s
FALLBACK_ICON (dialog-information-symbolic, used for the small icon
shown next to each row inside the history list itself, via
resolve_icon()+GTK’s own Gtk.IconTheme.lookup_icon(), not mako) is a
genuinely different rendering surface – wofi’s own background is the
light “glass” one from an earlier pass in this session (wofi text
color: black, not white -- hard to notice on the glass background), not
mako’s dark one, so a dark #444444 default there is plausibly correct
contrast rather than the same bug. Left as-is rather than fixing a
problem that may not actually exist there without directly verifying it
first – out of scope for what was actually reported this round.
Notification toast icons: a real mako icon-path bug, not a per-icon problem
Asked for every notification toast to show the associated tool’s own
icon next to its title (a copy event showing a clipboard icon, Docker
showing its icon, etc.) – reported again after an earlier pass this
session already fixed docker-picker.py’s icon name
(utilities-terminal -> docker-desktop). Every notify-send call
across this repo’s scripts already passes a real, correctly-named icon
(docker-desktop, edit-copy-symbolic, dialog-error-symbolic, actual
image paths for the wallpaper/screenshot notifications, etc.), so the
question was why none of them were ever actually appearing.
Root cause, found in mako’s own filesystem behavior, not assumed:
mako does not do GTK-style icon-theme resolution or theme inheritance –
its own docs say plainly it only ever searches /usr/share/icons/
hicolor and /usr/share/pixmaps by default, plus whatever icon-path
is explicitly configured (empty here, until this fix). This desktop’s
actual active icon theme is Papirus-Dark
(gsettings get org.gnome.desktop.interface icon-theme) – confirmed
directly with find that not one of the icon names this repo’s scripts
use (docker-desktop, edit-copy-symbolic, dialog-error-symbolic,
weather-clear-night-symbolic, bluetooth-active-symbolic, …) exists
anywhere under hicolor. Every single notification’s icon was silently
failing to resolve and falling back to no icon at all, regardless of how
correct the icon name passed to notify-send was – this is almost
certainly the real shape of “notifications don’t show icons”, not a
per-script naming issue (the docker-picker.py fix from earlier this
session picked a better name, but it still wouldn’t have rendered
without this).
Also confirmed directly: Papirus-Dark itself is nearly empty (3972
files, mostly dark-tinted folder/places variants) and its own
index.theme declares Inherits=breeze-dark,hicolor – but mako
doesn’t follow icon-theme inheritance either, and breeze-dark isn’t even
installed on this machine. The full Papirus theme (65991 files) is
what actually has every icon this repo’s scripts reference – confirmed
directly, every single icon name used anywhere in this repo resolves
under it. Fix: icon-path=/usr/share/icons/Papirus-Dark:/usr/share/icons/
Papirus:/usr/share/icons/Adwaita in mako/config – Papirus-Dark first
for anything it does override, full Papirus for the actual icon set,
Adwaita last as a broad fallback for anything future scripts reach for
that Papirus doesn’t ship.
Verified live, not just reasoned about: fired a real notification
with -i docker-desktop after the fix and reloaded mako
(makoctl reload, config-only, no restart needed – doesn’t hit the
mode-reset bug documented below since the daemon process itself never
restarts); screenshotted the notification region and searched for
Docker’s own brand blue ((28,144,237)) as an exact pixel match –
716 matching pixels, clearly a real rendered icon, not noise. Negative
control: same test with a deliberately nonexistent icon name –
0 matching pixels, confirming the color match in the first test wasn’t
coincidental and the test methodology itself is sound. Every other icon
name in the repo already confirmed present under the same Papirus tree
via find before this, so this one fix in mako/config covers all of
them – no other file needed to change.
One shape worth stating plainly: mako places a single icon to the
left of the whole notification box (title + body together,
icon-location=left, mako’s own default, unchanged here) – it does not
support an icon embedded inline inside just the title text itself. “The
title should have the clipboard svg in it” reads most naturally as
wanting the tool’s icon visibly associated with that notification at
all, which this fix delivers; it’s not literally inside the title
string, because mako has no mechanism for that.
Three notification modes: normal/silent/dnd, sound, and a persistence gap
Asked for three notification modes (normal: popup + sound; silent: popup, no sound; dnd: neither, but still recorded to history), a pleasant sound, mode-dependent waybar bell icon, and both keybindings and scroll to switch – “soft and flawless”.
mako already has the exact right native mechanism for this – not
something to build from scratch. [mode=name] config sections apply
conditionally based on the current mode (makoctl mode -s name), and
invisible=1 inside one suppresses the popup without touching whether
the notification gets recorded. Confirmed both independently with the
notification’s own distinctive Mauve border color as a screenshot signal
(a much more reliable check than generic “non-background pixel count”,
which turned out to just be picking up wallpaper content at one point,
caught by comparing against a clean baseline with zero mauve pixels
before trusting any of the mode-specific measurements).
Sound: on-notify=exec paplay file – documented directly in
mako’s own man page as the intended mechanism, not invented. Picked
/usr/share/sounds/freedesktop/stereo/message.oga (the standard
freedesktop sound theme’s “new message” chime) by checking every
plausible candidate’s actual duration with ffprobe first rather than
guessing from the filename – 0.3s, the shortest of the real candidates,
appropriate for something that can fire on every single notification;
the next-shortest option runs a full second. It’s also the literal file
mako’s own docs use as their own example.
Three real bugs found and fixed while building this, each caught by testing the actual behavior rather than trusting the config or a plausible-sounding assumption:
- A global option placed after a section header gets silently scoped
to that section. The sound line, first added after
[urgency=high], only ever fired for high-urgency notifications – normal-urgency ones (the vast majority) got no sound at all, with zero parse error to flag it. mako’s config format scopes every option to whichever section most recently opened above it; there’s no way to tell this from the option line itself. Fixed by moving it before any section header, into the true global block. invisible=1does not also cancelon-notify. An early version of[mode=dnd]had onlyinvisible=1, on the assumption that “no popup” would obviously mean “no sound either” for a mode literally named after suppressing everything. It doesn’t – confirmed directly with an unambiguous marker-file side effect onon-notify(touch a uniquely-timestamped file, decoupled from actually needing to hear anything), which fired even indndmode. Needs the same expliciton-notify=noneas[mode=silent]–nonespecifically, not an empty value (on-notify=alone fails to parse at all, confirmed directly:"Failed to parse option 'on-notify='").- Mode state doesn’t survive a mako restart.
makoctl moderesets to mako’s own built-in “default” the moment mako restarts (crash, manual restart, reboot) – confirmed directly by killing and restarting mako mid-test and checkingmakoctl modeimmediately after. Without a fix, choosingdndonce would silently drop back to full popups-and-sound on every login with no indication anything changed. Fixed the same waycaffeine-toggle.sh’s own state survives a reboot elsewhere in this repo: a persisted state file (~/.local/state/notification-mode/current) plus a startup script (notification-mode-restore.sh, run right afterexec makoinsway/config, with the same bounded-retry patternswayidle-startup.sh/fetch_wallpaper.shalready use for the analogous “the thing I need to talk to isn’t up yet” race) that reapplies it every time mako starts.
Mode naming: normal/silent/dnd are plain custom mode names with
no [mode=normal] section of their own for the first one – confirmed
live that an undefined mode name is a clean no-op (every global setting
applies unchanged, verified with the same Mauve-border-plus-marker-file
check as the other two modes), rather than relying on mako’s own
built-in “default” mode, which mako’s own docs flag as deprecated and
due for removal in a future version.
Switching: scripts/.local/bin/notification-mode.sh
normal|silent|dnd|next|prev. Direct-jump keybindings
($mod+Ctrl+n/-s/-d) for “I know exactly which mode I want”, plus
$mod+n and the waybar bell’s scroll wheel (on-scroll-up/-down in
waybar/config) both calling next/prev for quick cycling – verified
the cycling math directly (normal -> silent -> dnd -> normal forward,
correctly symmetric in reverse), not just eyeballed. The waybar custom/
notifications module also got a "signal": 8 entry, triggered via
pkill -RTMIN+8 waybar at the end of every mode switch – the actual
mechanism behind “soft and flawless”: the bell’s icon and color update
the instant you switch, not on the module’s own 5s poll interval.
Confirmed with a real keybinding switch to dnd and finding the bell’s
color had already changed to Red in a screenshot taken immediately after,
not waited out to the next poll.
(Since 2026-09-29 every sender uses ~/.local/bin/waybar-signal N
instead of pkill: an RT signal landing before waybar has its handler
kills the bar – see CHANGELOG.)
A deliberate design choice worth stating plainly: the mode-change
confirmation notification itself follows whatever mode it just switched
into – switching into dnd means that confirmation doesn’t pop up
either, which is consistent (dnd meaning “no popups, ever” shouldn’t have
a built-in exception for its own confirmation) rather than a gap. The
waybar icon’s instant update via the signal above is the actually-
guaranteed-visible confirmation regardless of mode; the notification is
extra, always still landing in history either way.
Waybar bell icon (notification-history-status.sh): reads the
persisted state file, not makoctl mode directly, keeping this the same
single source of truth every other part of this feature already treats
as authoritative rather than a second one that could theoretically read
racy live daemon state mid-switch. Went through three rounds on the icon
itself, each a direct response to feedback: three different glyphs
(fa-bell/fa-bell-slash/md-minus-circle) first -> asked for
white-only, reconsidered mid-request, dropped to one shared fa-bell
with color as the only signal -> asked again for distinct glyphs per
mode, landed on the current design: both shape and color change per
mode, reinforcing rather than duplicating each other. All three stay in
the bell family rather than reaching for an unrelated glyph like the
original md-minus-circle for dnd, so it still reads as “the
notification bell, in a different state” at a glance rather than a
different icon entirely: fa-bell (normal), fa-bell-slash (silent –
the standard “muted” bell), md-bell-sleep (dnd – a bell with “zzz”,
distinct silhouette from bell-slash, and conceptually fits “do not
disturb” better than a generic prohibition glyph). All three codepoints
confirmed present in the installed JetBrainsMono Nerd Font via
fc-list ":charset=hex" before use, same discipline as every other
icon chosen this session, and the PUA-glyph-transport-corruption bug
that’s recurred repeatedly all session hit again writing these three –
caught and fixed the same way as always: patch via Python chr(0xXXXXX)
directly into the file, then read back and check hex(ord(ch)) per
glyph rather than trust the literal in the edit call.
Colors: Lavender for normal, dim gray for silent (matching every other
“quieted, not fully off” state elsewhere in this bar), Red for dnd (the
one state here that’s an active, deliberate choice to suppress
everything, worth a real attention color). Picked specifically checked
against #clock’s own colors (Blue for the time, Maroon for the date,
right next to this module) so the mode color doesn’t get lost against
or mistaken for the clock – confirmed with an exact-pixel-color
screenshot count per mode (a hand-rolled pure-Python PPM parser, since
this machine has neither Pillow nor ImageMagick installed and no sudo to
add them; grim -t ppm was the workaround): each mode’s own color is
the dominant exact match in the bar while active, and none of the three
notification colors share a single exact pixel match with either clock
color in any mode.
Not independently mouse-tested: same disclosed limitation as
notification-history.py’s own waybar click a few passes back –
ydotool’s absolute mouse positioning has an unmet calibration
requirement in this environment, so scroll-over-the-bell-icon couldn’t
be cleanly verified with a real wheel event. What is fully verified:
the underlying next/prev cycling logic directly (both directions,
symmetric wraparound), and both keybinding paths via real simulated
keypresses. The waybar on-scroll-* wiring calls the identical script
with the identical arguments as the verified keyboard path – the same
inference-from-proven-pattern basis as the click path, not an
independent scroll test.
Notification history viewer, and a real docker-picker.py icon bug
Asked for two things: a real docker-branded icon on Docker notifications (reported directly – “only has the docker name in it not the icon of it”), and a notification history viewer (waybar bell, left of the clock, newest-first, showing full details).
The icon bug: docker-picker.py’s notify() used utilities-
terminal for its non-critical case – a generic terminal glyph with no
connection to Docker at all. Papirus (installed since the icon-pack
pass) ships a real docker-desktop icon – confirmed it actually
resolves via the same GTK icon theme lookup every other themed icon on
this system goes through (Gtk.IconTheme.get_default().lookup_icon(...))
before using it, not assumed from the filename (a bare "docker" name
does not resolve to anything in Papirus, "docker-desktop" does).
Fixed; verified live by firing a real notification and confirming
substantial non-background pixel content actually renders in the icon’s
expected screen position, the same discipline already used for every
other icon audit in this repo.
notification-history.py: mako already has a real history buffer
(makoctl history -j) – just needed history=1/max-history=200
turned on in mako/config (off by default, nothing to show without it).
Reads it, resolves each entry’s app_icon through the same GTK icon
theme lookup as above (works for both theme-name icons and the couple of
this desktop’s own notify-send calls that pass an actual image path,
e.g. the wallpaper/screenshot success notifications), and shows it as a
wofi list.
Two real bugs found and fixed while building this, both by testing the actual behavior rather than trusting assumptions:
- mako’s own history ordering isn’t reliable. One
makoctl history -jcall came back newest-first, a later one (same kind of data, same machine) came back oldest-first.idincrements monotonically regardless, so the script now explicitly sorts on it descending rather than trusting whatever order the command hands back – “newest first” was an explicit requirement, not something to leave to chance. - wofi’s dmenu input splits on newline into separate entries –
found by testing the actual selection round-trip, not assumed from
“labels wrap” alone (which is real, but is about one long single
line wrapping at the window edge, a completely different thing from
an embedded
\nstaying inside one logical entry). An embedded\nbetween a notification’s summary and body silently produced two separate dmenu items instead of one two-line one – confirmed directly by piping a two-lineimg:...:text:value in and checking exactly what came back on selection: only the first line, as its own entry. Fixed by keeping one line per notification (summary and body joined with a visible separator, same pattern already used fordocker-picker.py/wifi-picker.py’s own single-line info rows) – long ones still wrap naturally within that one entry, which was confirmed working separately.
“Shows full details on focus”, the literal ask, isn’t architecturally
available in wofi’s dmenu mode – confirmed rather than assumed:
there’s no focus-change hook or live preview pane in how wofi’s own
dmenu list works, just a single “output the selected line on Enter”
model. The honest equivalent built instead: every entry already shows
its full summary and body up front (relying on the confirmed-real label
wrapping for long ones), so there’s nothing further to reveal on focus
in the first place. Selecting an entry copies its text to the clipboard,
matching the same pattern already used elsewhere in this desktop
(wofi-calc.sh, keybind-search.py) instead of being a no-op.
Added $mod+Shift+n (sway/config), matching the same keyboard-path
convention as every other waybar-click picker in this repo, and
confirmed live via a real ydotool-simulated keypress. The waybar
on-click path itself uses the identical JSON structure as every other
already-verified picker module in this config (docker-picker.py,
theme-toggle.sh) – not independently re-verified via a real mouse
click this pass, disclosed rather than silently assumed: ydotool’s
absolute mouse positioning has a documented calibration requirement
(“disable mouse speed acceleration for correct absolute movement”) not
met in this environment, and clicking at a pixel-sampled icon position
reliably landed on a different module instead (confirmed by checking
waybar’s own log for which script actually fired). The keyboard path is
fully verified; the on-click path rests on it being the same proven JSON
pattern as its siblings, not an independent click test.
Theme toggle robustness: verified the whole chain, found a real gap
Reported the toggle fires notifications but Zen Browser (set to follow the system theme) never actually switched to light. Verified every layer of the mechanism directly rather than guessing which one was broken:
dconf writeitself – confirmed the value actually changes (dconf readimmediately after).- The XDG Desktop Portal’s
Settings.Read– queriedorg.freedesktop.appearance/color-schemedirectly viagdbus calland confirmed it echoes back the new value the instant dconf changes, not a stale/cached one. - Whether the portal actually emits a live change signal, not just
answers correctly on-demand – this is the one that matters for an
already-running app to update without a restart. Ran
gdbus monitor --session --dest org.freedesktop.portal.Desktopwhile triggering the toggle:SettingChangedfired correctly, with the right new value, on both the legacyorg.gnome.desktop.interfacenamespace and the newerorg.freedesktop.appearanceone. This layer is genuinely, verifiably working.
The real bug: catppuccin-gtk-theme-latte, papirus-icon-theme, and
papirus-folders-catppuccin-git – the packages added to packages/
aur.txt/packages/pacman.txt when this toggle was first built – were
never actually installed (confirmed: pacman -Q fails for all three; the
handed-off install command hadn’t been run). theme-toggle.sh was
writing gtk-theme/icon-theme dconf keys pointing at those names
unconditionally, light or dark, regardless of whether anything was
actually installed under /usr/share/themes//usr/share/icons for
them. A GTK theme/icon-theme name that doesn’t resolve to anything
installed doesn’t fail loudly – native GTK apps just silently keep
rendering whatever they last successfully loaded, which looks
indistinguishable from “the toggle did nothing.”
Fixed: theme-toggle.sh now checks /usr/share/themes/name and
/usr/share/icons/name directly before writing either dconf key, and
skips writing it (leaving the previous, working value in place) if the
target isn’t actually there – with a clear notification naming what’s
missing and pointing at the install command, rather than silently
pretending everything applied. color-scheme is always written
regardless of any of this, since it’s a pure preference value with no
file dependency, and is the one signal apps like Firefox/Zen actually
need for their own internal dark/light CSS switching – they don’t
render using arbitrary system GTK theme files the way a native GTK app
does. Verified the fix directly: ran the toggle with the packages still
missing, confirmed color-scheme and the already-installed dark GTK
theme switched correctly while the not-yet-installed light GTK
theme/icon theme were left alone (not overwritten with a broken
reference), and confirmed the notification text names exactly what’s
missing.
What’s confirmed working but genuinely outside this repo’s reach:
whether a specific app actually subscribes to the portal’s
SettingChanged signal is up to that app, not anything on the desktop
side. Firefox-based browsers specifically have a
widget.use-xdg-desktop-portal.settings preference (about:config)
gating whether they use the portal for this at all – if a browser stays
on its last-detected appearance after a verified-correct toggle, that
preference is the first thing worth checking, since it’s inside the
browser’s own settings, not reachable from a desktop config.
Follow-up: confirmed live that Zen Browser genuinely wasn’t reacting
After the packages were actually installed and the fix above shipped,
still reported Zen staying dark. Zen was genuinely running at the time
(confirmed real PIDs, not assumed), so this got a direct answer instead
of more theorizing: screenshotted the desktop, pixel-sampled Zen’s own
toolbar/chrome area, and found consistently near-black colors while
dconf read color-scheme reported 'prefer-light' at that exact moment
– concrete, first-hand confirmation Zen’s chrome wasn’t reacting, not
just trusting the report.
Traced it to that same widget.use-xdg-desktop-portal.settings
preference. Its default is 2 (auto-detect: use the portal only if
GTK build + Wayland + portal available), and every one of those
conditions is genuinely true here – yet it evidently wasn’t resolving to
“use it” in this specific Zen build/environment. Applied the fix
directly: user_pref("widget.use-xdg-desktop-portal.settings", 1); (force
always-on) in user.js in Zen’s actual active profile, found via its
lock file pointing at the real running PID rather than guessing which
of the two profile directories under ~/.config/zen/ was in use.
Deliberately not added to this repo’s stowed config: Firefox/Zen
profile directory names are randomly generated per-install (this
machine’s is l23hrxpc.Default (release)), not stable or portable the
way every other config in this repo is – there’s no fixed path a stow
package could symlink into. This fix lives only in that profile’s
user.js on this specific machine; a fresh install (or a new profile)
would need the same fix applied again manually, a real, disclosed
limitation rather than something silently assumed to carry over.
user.js only gets read at Zen’s own startup, not live – needs a
restart to actually take effect, not applied automatically here since
restarting someone’s browser out from under their open tabs isn’t this
repo’s call to make.
A real incident: stow symlinks silently broken across the whole desktop
Reported the docker waybar module’s click had stopped opening the wofi
popup, and asked for a keybinding matching the other pickers. Chasing the
click issue found something much bigger: ~/.config/waybar/config was a
real file, not the symlink stow is supposed to maintain – and
checking further, so were ~/.config/sway/config, both wofi files, mako,
nwg-bar, kitty’s kitty.conf, tmux’s tmux.conf, every tracked file
under ~/.config/nvim/ and ~/.config/zed/, and
~/.config/systemd/user/tmux.service was missing outright (the exact
symlink fixed earlier in an unrelated pass – gone again).
What almost certainly caused it: this is a well-known dotfiles-via-
symlink gotcha, not something specific to this repo’s own scripts.
Editors and tools that save “atomically” (write a temp file, then
rename() it over the target, to avoid partial writes) replace whatever
is at that path – which, for a symlink, means the symlink itself gets
replaced by a real file containing the new content, permanently
disconnecting it from the repo. This happens the moment anything saves
a symlinked config file directly rather than through the repo path, and
explains why files this session never touched (all of nvim, zed) were
affected too – not unique to anything done in this conversation.
Checked before touching anything: diffed every affected file against
its repo counterpart. All content-identical (except the two nvim files
already tracked as long-standing local drift, also identical to the
repo’s own uncommitted copy) – confirmed safe to just re-link, not a
case of real unsaved state that would need reconciling first.
A second, more serious mistake made while fixing this, disclosed in
full rather than glossed over: fixed a few files individually, then
tried to fix the rest faster with one bulk call –
stow -R -t ~ sway waybar wofi mako nwg-bar kitty tmux nvim zed
networkmanager-dmenu systemd covering every affected package at once.
This deleted the repository’s own source files for all of them –
confirmed via git status --short afterward showing 32 files as
deleted in the working tree. Root cause: GNU Stow’s directory-folding
logic, when several of its targets are simultaneously real files instead
of the symlinks it expects, can behave unpredictably on a combined
restow across many packages at once – exactly the situation this bulk
call created by design (mixing several inconsistent live-target states
into a single stow invocation).
No data was actually lost: git history is the real source of truth
here, and none of this touched it – git log was intact throughout,
and git restore . brought every file back exactly as committed in one
command. Verified thoroughly before considering this closed, not just
trusting a clean git status: re-checked file line counts and grepped
for the most recent real content (custom/theme/docker-picker
references in waybar/config) to confirm actual restoration, not just
an empty diff.
Fixed properly the second time, one package at a time instead of a
combined bulk call, verifying after each: rm the broken real files, a
single stow -R -t ~ one-package, confirm the result is a real
symlink before moving to the next package. Final state verified three
ways – every ~/.config/pkg directory is a genuine whole-directory
symlink (ls -la showing lrwxrwxrwx), git status --short clean, and
stow -n -v -t ~ (dry-run) across every package in the repo reporting
zero pending actions.
The lesson, worth keeping in mind going forward: never edit a
symlinked dotfile at its live ~/.config/... path directly – always
through the repo path (~/dotfiles/...), which this session has done
consistently, but doesn’t protect against other tools (editors, plugin
managers, anything with “safe save” behavior) doing it. A periodic
“are all my stow symlinks still symlinks” check (exactly the loop used
to discover this) would catch this early next time, before it silently
accumulates across an entire desktop’s worth of configs.
With everything actually reconnected, the docker click started working
immediately – confirmed live via ydotool-simulated \$mod+d-style
keypresses on the new keybinding below, not just a manual script
invocation. Also added \$mod+Shift+d (sway/config), matching the
exact convention already used for \$mod+Shift+w/\$mod+Shift+b –
docker-picker.py had been waybar-click-only, the same gap Wi-Fi and
Bluetooth had before their own keybindings were added. Automatically
picked up by keybind-search.py with no further work, confirmed live.
Gutters between wofi entries
Asked to increase the gap between rows/columns. wofi has no dedicated
row-spacing/column-spacing option – checked --help and the man page,
neither exposes one – so this is the standard GTK-CSS technique instead:
margin on #entry itself (the FlowBox child), not something set on the
container. 5px on every side, which shows up as a real gutter between
tiles in the app launcher’s grid and as breathing room between rows in
every other wofi-backed tool’s single-column list, since they all share
this one style.css.
Verified with the same real-keypress method the app-launcher-grid bug
above required: simulated $mod+d and $mod+Shift+w with ydotool,
screenshotted, and confirmed two things directly rather than assumed –
the grid still fits 4 real columns with the new margin eating into
available space (found the same 4 evenly-spaced structural boundaries as
before), and a real, consistent ~11px vertical gap now exists between list
rows (close to the expected 5px margin + 1px border on each adjacent
entry, 2*(5+1)=12, matching within normal antialiasing rounding).
The app launcher grid’s real bug: a bare comma inside exec
Reported a second time that $mod+d still showed a single column, after a
fix that looked, by every check run so far, like it should have worked. It
didn’t, and the reason was worth chasing down properly rather than
re-guessing at CSS again: every verification up to this point had been
testing the wrong thing.
Every earlier test ran the wofi command manually, in a plain background
shell (wofi --show drun,run --columns 4 --width 60% &). That never goes
through sway’s own exec command parsing at all – it’s just a shell
directly running a program, and it worked every time. The actual
$mod+d keybinding runs through sway’s exec, a completely different
code path, and had never once been tested directly until this pass –
confirmed by literally simulating the real keypress with ydotool (kernel-
level input injection, indistinguishable from a real key event, already
used elsewhere in this repo for capslock/numlock) and comparing the
actual running process’s command line against what was configured.
The real process launched by a genuine $mod+d press was wofi --show
drun – no --columns 4 at all. Root cause: sway’s config syntax uses a
bare comma to chain multiple commands on one bindsym line (bindsym x
cmd1, cmd2), and exec’s own argument isn’t exempt from that parsing –
a literal comma anywhere in the string handed to exec, even one meant
for the program being launched rather than sway itself, gets treated as a
command separator. wofi --show drun,run --columns 4 was silently
truncated to wofi --show drun the instant exec ran it, dropping
--columns 4 along with the rest. This is exactly why “still shows a
single column” persisted through what looked like a correct fix: the flag
was never reaching wofi in the first place, so no amount of CSS or width
changes could have touched it.
Fixed by dropping ",run" from $menu – --show drun alone has no
comma to trip over. “run” mode (launch an arbitrary $PATH executable by
typed name, separate from the .desktop-entry app database drun reads)
wasn’t actually part of what was asked for (“application search”)
regardless, so this isn’t a workaround masking a lost feature, just
dropping something that was never the point. Reverified with the same
real-keypress-plus-process-inspection method, twice, before trusting it:
the running process now genuinely is wofi --show drun --columns 4.
The methodological lesson, worth keeping in mind for anything bound to
a key in this desktop going forward: a command that behaves correctly
when run directly is not proof that the same command behaves correctly
when run through exec from a bindsym. They are different code paths,
and sway’s own comma-as-separator parsing is exactly the kind of thing
that only shows up in the second one.
Also acted on directly in this pass: moved width=60% into the shared
wofi/config (was previously repeated as --width 60% on $menu and on
keybind-search.py’s own wofi invocation separately) so every wofi popup
in this desktop uses it from one place, per request – removed both of the
now-redundant per-invocation flags rather than leaving three copies of the
same value to drift out of sync with each other.
Wi-Fi/Bluetooth pickers, keybind search sizing, and an “uneven” app grid
Three follow-up requests on the wofi work above:
- Wi-Fi and Bluetooth pickers had no keyboard path at all – both
wifi-picker.pyandbluetooth-picker.pyexisted only as waybaron-clickhandlers (mouse-only). Added$mod+Shift+w/$mod+Shift+binsway/config, same scripts, no changes to either picker itself. Automatically picked up bykeybind-search.pywith no extra work, which is the entire point of that tool reading live config instead of a maintained list – but caught the same comment-structure gotcha its own keybinding hit earlier: a multi-line comment’s last line is what becomes the search description, so leading with the “why” (“previously only reachable by clicking…”) and trailing with a clean one-line description right above thebindsym(matching the fix already documented forkeybind-search.py’s own entry) rather than the reverse. - Keybind search widened to 60% of screen width, height trimmed
moderately:
--width 60%(wofi does support percentage width/height values – confirmed viaswaymsg -t get_outputs’slayer_shell_surfacesextent reporting back exactly1152on a 1920px output, not assumed from a docs description) and--lines 15->12(measured extent 647px -> 536px tall, roughly a 17% reduction – “not much but some”). -
$mod+d’s app launcher in a grid, not a plain list:--columns 4, added only tosway/config’s$menuvariable, not the sharedwofi/configevery other wofi-backed tool in this desktop also reads – a grid would break the readability of a single-column list like the Wi-Fi picker or calculator. wofi’s grid mode is a GtkFlowBox under the hood, which sizes each entry to its own icon+label content rather than a fixed cell width, which is what makes it read as an uneven, organic grid instead of uniform cards.First verification pass here was wrong, and worth recording why: checked for “discrete content blocks with gaps” in a screenshot and called it done – but never actually counted how many columns were rendering. Reported back that the launcher still showed a single column. Re-checked properly this time (clustering content by x-position per row, not just presence of gaps) and found the real cause: at the shared config’s default 600px width,
--columns 4only ever produced 2 real columns – real installed app names (“Beekeeper Studio”, “Bluetooth Adapters”) are wide enough that GTK’smax-children-per-lineacts as a ceiling, not a forced count, and 600px only fits 2 at a readable size. On a machine with even longer app names this could plausibly collapse to 1, matching what was reported. Fixed by widening to--width 60%(same convention as keybind-search.py’s popup) – reverified the same way and found 4 genuine columns, evenly spaced. The lesson: “gaps exist somewhere” is a much weaker check than “N columns actually render,” and this is why the width had to change too, not just be a separate ask. - A real border on every entry, not just the existing left accent bar
reserved for the selected state – asked for specifically so a
multi-column grid row reads as distinct tiles next to each other, not
text floating in shared space. First attempt used a very faint 12%
alpha mauve border; correctly parsed (no CSS errors) but nearly
invisible on screen once actually screenshotted and checked pixel-by-
pixel – not “properly aesthetic” as asked, just technically present.
Raised to 28% at rest (35%/45% on hover/selected), confirmed this time
by finding a real, consistent brightness jump at each entry’s border
row versus its neighbors in a screenshot. Still subtle enough at rest
to work as an ordinary row divider in every other wofi-backed tool’s
single-column list, since this is the one shared
style.cssall of them read – not something that only makes sense in grid mode.
Wofi’s “glass” look: real compositor blur, not just a tinted box
Asked to make wofi look more like glass. The honest first step was
recognizing that a semi-transparent background-color alone (what the
previous style had, at 0.95 alpha – essentially opaque) isn’t glass, it’s
a dark box with a hint of see-through. Real glass needs something behind
it to actually blur, and GTK3’s CSS engine (what wofi’s style.css is
written in) has no backdrop-filter – that’s a much newer CSS Filter
Effects spec feature GTK3 never implemented.
What this desktop already has, unused until now: SwayFX (this repo’s
actual sway build, packages/aur.txt – a sway fork adding real
compositor-level blur/shadow/corner-radius via scenefx0.4, already used
for window shadows and corner rounding, just never wired to any
layer-shell surface). SwayFX’s layer_effects "namespace" { blur enable;
... } applies real background blur to a specific layer-shell surface by
its namespace. Confirmed wofi’s actual namespace live rather than assumed
from the binary name – swaymsg -t get_outputs while wofi was open lists
it under layer_shell_surfaces as "wofi".
layer_effects "wofi" {
blur enable
blur_xray enable
corner_radius 16
shadows enable
}
blur_xray enable blurs the true desktop background behind wofi, not
whatever surface happens to sit immediately behind it (a waybar pill it
overlaps, say) – the difference between authentic frosted glass and
blurring the wrong layer. corner_radius 16 matches wofi’s own CSS
border-radius so the blurred/shadowed shape actually lines up with the
window’s rounded corners instead of showing a square blur behind round
content.
Verified this is real, not just requested, two ways: swaymsg -t
get_outputs’s layer_shell_surfaces entry for wofi echoes back
"blur": true (confirms the compositor accepted and applied it, not just
that the config parsed), and a direct pixel test – screenshotted the same
600x400 screen region with wofi closed vs. open (at a near-zero background
alpha specifically to make any underlying blur visible through it),
measured local pixel-to-pixel variance as a sharpness proxy in a
background-only row (away from wofi’s own sharp UI chrome, which would
confound the measurement), and found it 4x smoother with wofi open
(edge-variance 6.0 vs. 23.9) – consistent with real blur, not a rendering
assumption.
With real blur available, style.css was rebuilt to actually let it show:
- Background alpha dropped from 0.95 to 0.62 – enough tint to keep text legible, translucent enough for the blur to actually read as present.
- A faint top-edge sheen (
background-image: linear-gradient(...), layered on top ofbackground-color– GTK CSS supports both on one element) fading out by mid-height, mimicking the light-catching highlight a real glass/acrylic panel shows at its top edge. #input’s own border made slightly brighter than the outer window border, so the search field reads as sitting a touch closer to you than the glass pane behind it, rather than flush with it.- The GTK default scrollbar (a flat light-gray widget) was left unstyled before – exactly the kind of unstyled-default seam that breaks a glass illusion sitting on top of a dark, blurred, translucent panel. Recolored to the same Mauve family as everything else, transparent at rest so it doesn’t compete with the blur when not needed.
#entry:hoveradded – there was no mouse-hover feedback at all before, only:selected(keyboard navigation). A softer wash than:selected’s keeps the two states visually distinct from each other, not just from idle.#entry:selectedgained a 2px left accent bar in the same Mauve, on top of the existing background wash – both idle and hover reserve the same 2px of transparentborder-leftso the accent appearing on selection doesn’t shift every row’s text over by 2px when it shows up (the same layout-jump guard already used for waybar’s workspace-button hover/focused underline elsewhere in this desktop).wofi/config’simage_sizebumped 24 -> 32 for more visual hierarchy indrunmode’s app-icon grid.
All of this lives in the one shared style.css/config every wofi-backed
tool in this desktop uses – drun/run, the wifi picker, bluetooth
picker, calculator, emoji picker, cliphist popup, networkmanager-dmenu –
so this one change reaches every one of them, not just the app launcher.
Tested both --show drun and --show dmenu directly (not just the
launcher) to confirm neither mode broke – checked stderr for GTK CSS
parse warnings on each (none) before trusting any of this.
Notification icons: every notify-send call, one real icon each
Every notify-send call across this desktop’s own scripts (scripts/.local/
bin/, sway/.config/sway/scripts/) now passes a real icon – either a
freedesktop icon-theme name (looked up through whatever GTK icon theme is
active; this system only has adwaita-icon-theme/hicolor-icon-theme
installed, so every name used was confirmed present there first –
find /usr/share/icons/Adwaita -iname '*volume*' and the same for every
other icon used, same discipline as verifying a Nerd Font codepoint exists
before using it elsewhere in this repo) or, for the two that have one, the
actual resulting image (fetch_wallpaper.sh’s success notification uses the
new wallpaper itself; screenshot.sh’s uses the screenshot itself – both
already existed, untouched here).
Icon choice generally follows the same “urgency/state carries real meaning”
convention already established elsewhere in this desktop (mako’s own
[urgency=high] border-color override; the battery/lock waybar CSS states):
- Volume (
volume_osd.sh): tiered like waybar’s own battery icons –audio-volume-{muted,low,medium,high}-symbolicdepending on the actual percentage, not one static speaker glyph regardless of level. The mute toggle’s notification body also lost a redundant inline Nerd Font glyph it had before (" Muted") now that a real icon carries that signal – showing the same information twice looked cluttered once the icon existed. - Brightness (
brightness_osd.sh):display-brightness-symbolic. - Bluetooth (
bluetooth-picker.py): icon follows urgency in the sharednotify()helper –dialog-error-symbolicfor thecriticalfailure cases,bluetooth-active-symbolicotherwise. - Wallpaper (
fetch_wallpaper.sh):preferences-desktop-wallpaper- symbolicfor the in-progress states (fetching / already fetching),dialog-warning-symbolicfor soft failures (kept last-good, couldn’t apply live),dialog-error-symbolicfor the no-previous-wallpaper hard failure – severity now visible in the icon, not just readable in the text. - Everything else (keylock-toggle’s ydotool setup errors,
wf-recorder/screenshot’s “cancelled” notifications, keybind-search.py’s
own error/copied notifications):
dialog-error-symbolicfor genuine errors,process-stop-symbolicfor a cancelled action,edit-copy- symbolicfor the clipboard-copy confirmation.
mako/.config/mako/config also got max-icon-size=48 and icon-border-
radius=8 – mako’s icons render sharp-cornered and whatever size the source
image happens to be by default, which didn’t match the rounded-pill look
used everywhere else in this desktop (waybar’s 16px, mako’s own 12px outer
border-radius). Verified all of this live, not just by reading the mako
docs: fired real test notifications, screenshotted, and confirmed real
non-background pixel content actually renders in the icon’s expected
on-screen position (mako anchors top-right, so the icon sits in the
bubble’s top-left corner) – catches the same class of “icon name typo’d,
theme lookup silently fails, notification just looks blank” issue this
whole exercise was about avoiding in the first place.
Keybinding search: $mod+Shift+slash, a fuzzy wofi popup over live config
scripts/.local/bin/keybind-search.py. Deliberately excludes Neovim – it
already has its own built-in keymap search – and covers sway + tmux.
Both sources are re-parsed from the real, live config on every invocation rather than kept in a separate hand-maintained cheat-sheet file, which would just be one more thing to remember to update (see the TPM section right below for what happens when two things that should describe the same state drift apart):
- Sway: reads
~/.config/sway/config’s ownbindsymlines directly, paired with whatever comment sits above each one – this file’s own existing convention already, not something invented for this script.$mod/$left/$down/$up/$rightare expanded to their real key names. A comment covering a whole group (one comment above 4 arrow-key binds, for instance) is reused for each key in that group, with the actual command appended in parens to disambiguate which does what – also fixes a real fuzzy-search gap: matching is a subsequence match in query order, so typing “workspace 1” wouldn’t match an entry whose only “1” appeared before the word “workspace” (asSuper+1’s bare comment would have, with no command shown). -
Tmux: reads
tmux list-keys(the raw form, explicit-T tableper line – unambiguous about which key-table a binding is actually on) for the key inventory, and sources descriptions from two places: this repo’s own tmux.conf, which now gives every custom bind an explicit-N "note"(tmux’s own built-in per-binding description mechanism, added specifically for this – see tmux.conf’s own comment on it), and tmux’s stock built-in notes viatmux list-keys -Nfor anything not explicitly customized here. Atmux-resurrect-installed binding (C-a C-r, restore) shows up automatically with no special-casing needed, which is exactly the point of reading live state instead of a maintained list.Two real bugs found and fixed while building this, both from checking actual output instead of trusting the format:
list-keys -N’s compact view prefixes every line withC-a, even root-table binds like-n M-Leftthat need no prefix at all – confirmed via the raw dump’s explicit-T rootbefore trusting it, since taking the compact view at face value would have made this tool actively lie about which keys need the prefix. And an early version keyed custom notes by bare key name alone, which let this repo’s prefix-tablernote (“Reload tmux config”) incorrectly relabel tmux’s unrelated stockcopy-modebinding for the same letter (refresh-from-pane) – fixed by keying on(table, key)instead. Mouse bindings (Mouse*/Wheel*/*Click*) are filtered out entirely – not keybindings, and their commands can run to ~2000 characters (one wofi/waybar-style menu built fromdisplay-menu), which would both clutter the list and render as one absurd line.wl-copy, on selecting an entry: written asPopen+ close stdin, deliberately not waited on. Confirmed directly (cliphist picked up a test string the instant it was sent) thatwl-copysets the clipboard immediately but the process itself lingers afterward, since it has to stay alive to keep serving that selection to whoever reads it next –subprocess.run/.communicate()both block until the child exits, which would hang this script after every single use for no reason.
The tmux status bar’s dependency chain
Worth calling out on its own, since it broke in layers (see changelog): the rounded
separators and the session/docker icons are Nerd Font Private-Use-Area glyphs. They
need both the exact right codepoint in tmux.conf/docker_status.sh and a Nerd
Font actually installed and selected in your terminal (kitty.conf’s font_family).
Missing either one renders as a blank box, and the two failure modes look identical —
if a fresh machine shows boxes, check the font first (fc-query -f '%{charset}' <font
file>, and confirm the codepoint tmux is using is actually in there) before assuming
the config is wrong.
TPM’s plugin path: pinned, not left to auto-detection
install.sh clones TPM to ~/.tmux/plugins/tpm and tmux.conf sources it from
there (run '~/.tmux/plugins/tpm/tpm'), so ~/.tmux/plugins/ looks like the
obvious place TPM manages tmux-resurrect/tmux-continuum too. It isn’t, by
default: TPM’s own bootstrap script auto-detects an XDG-style tmux.conf (i.e.
~/.config/tmux/tmux.conf existing, which it always will here, since that’s
exactly where this repo’s stow layout puts it) and silently redirects plugin
management to ~/.config/tmux/plugins/ instead – a different directory that
happens to sit inside the stowed repo tree itself, untracked, with each plugin’s
own nested .git dir. install_plugins.sh will happily report “Already
installed” for plugins living there while ~/.tmux/plugins/ only ever has TPM
itself in it – confusing unless you know to check
tmux show-environment -g TMUX_PLUGIN_MANAGER_PATH inside a live session.
Fixed by pinning the path explicitly in tmux.conf, before the @plugin lines
(TPM’s own documented override):
set-environment -g TMUX_PLUGIN_MANAGER_PATH "$HOME/.tmux/plugins/"
tmux-resurrect/tmux-continuum were migrated from ~/.config/tmux/plugins/
(repo-tracked, via stow) to ~/.tmux/plugins/ (outside the repo, matching
install.sh’s clone target) to match, and systemd/tmux.service’s
ExecStop was updated to the new tmux-resurrect/scripts/save.sh path.
Verified end-to-end with a live session: TMUX_PLUGIN_MANAGER_PATH now
resolves to ~/.tmux/plugins/, install_plugins.sh finds all three plugins
already there without re-cloning, and save.sh runs cleanly from the new
location.
Gotcha worth remembering: adding a new unit file to an already-stowed package
doesn’t retroactively symlink it — stow only acts when you run it. Found this
during a later stability pass: ~/.config/systemd/user/tmux.service didn’t exist
at all despite the repo’s copy being correct, because stow systemd had last been
run before tmux.service existed in the package. Harmless as long as the unit
stays un-enabled, but systemctl --user enable --now tmux.service would fail
outright with “unit not found” until a stow -R -t ~ systemd fixes it. If you add
a file to an already-stowed package, restow it — don’t assume the symlink appeared
on its own.
The one true font: JetBrainsMono Nerd Font
Every config that renders text with icons — kitty, tmux, sway (window
title/border font), mako, wofi, zed, waybar — uses "JetBrainsMono Nerd Font".
One font, one pacman package (ttf-jetbrains-mono-nerd), zero manual downloads. This
used to be ZedMono Nerd Font, a ~700MB manual download from its own upstream nerd-fonts
release with no package-manager equivalent; migrated everything off it (see
2026-08-23 in the changelog for the full story, including a real mistake made along
the way — the font directory got deleted before every reference to it had been found).
If you’re about to add a new app to this setup and it wants a monospace/icon font,
use this one unless you have a specific reason not to — consistency here is what made
the tmux/waybar glyph debugging tractable in the first place.
Known gaps that need root, not left silently unfixed
Two things identified during an optimization pass that this environment could not
apply itself at the time (no sudo access in that session) — flagged explicitly
rather than either faking a fix or silently skipping them. Both since confirmed
closed on the live machine, checked directly rather than assumed fixed just because
time has passed:
Unused display managers still installed on the live machine– resolved.packages/pacman.txtno longer listsgreetd,greetd-tuigreet, orly, andpacman -Qifor all three now reports “package not found” on the live machine too – actually removed, not just pruned from the fresh-install list.– resolved. Bothsystemd-networkd-wait-online.service/iwd.servicerunning unusediwd.serviceandsystemd-networkd-wait-online.service(plussystemd-networkd.service) now reportdisabled/inactiveviasystemctl.systemd-resolvedis still enabled and in genuine use, untouched by this cleanup.
The remaining item is a deliberate opt-in, not a pending fix:
- Docker can bypass UFW. Docker manipulates
iptablesdirectly for published container ports (-p), which UFW’s rules don’t see. See docs/DOCKER_SECURITY.md for the full mechanism, what was actually checked on this machine (no live exposure as of the last check — nothing published), and a concrete two-layer fix: a zero-config habit (bind to127.0.0.1) plusdocs/harden-docker.sh+docs/docker-user-rules.service, aDOCKER-USER-chain script for when you do need to publish something. Neither script is auto-run byinstall.shor wired into any stow package — deliberately opt-in, since it changes network-filtering behavior and hasn’t been tested against this system (nosudoin this session).
A public docs site, and README.md fixing itself along the way
Asked directly: “i think we don’t have a website for our repo create one with
github i guess there is some setup for it right.” Confirmed there wasn’t –
gh api repos/.../pages returned a plain 404, GitHub Pages was never enabled.
Checked README.md before building anything on top of it, since it was about to
become the site’s actual homepage – found it genuinely stale: its “Desktop
Stack” table and “Repository Structure” tree had no mpd/rmpc at all (the
whole termusic-replacement this session did), and the manual stow example in
Installation was missing them too. Fixed all three rather than build a public
site on top of known-wrong content.
Deliberately did not write a separate docs/index.md as the site’s actual
homepage. GitHub Pages/Jekyll uses README.md as the homepage automatically
when there’s no index.md/index.html at the source root – and README.md was
already a genuinely complete “installation guide + documentation” page (Quick
Start, Features, Desktop Stack, Repository Structure, full Installation walk-
through, Managing Configurations, Local Configuration, Goals) once the stale
parts were fixed. A separate site-only copy would just be a second thing to
keep in sync with the real README forever – one file, reused as-is, avoids that
entirely. CHANGELOG.md and docs/*.md are reachable from the same build for
the same reason (Pages sourced from the repo root, not a /docs subfolder –
CHANGELOG.md lives at the root, not inside docs/, so scoping the build to
/docs alone would have silently left it unreachable from the site).
Added one new file, _config.yml at the repo root: theme: jekyll-theme-cayman
(one of GitHub Pages’ own built-in supported themes – no Gemfile, no build
step beyond that one line, confirmed via pages.github.com/themes before
picking it, not guessed) plus an exclude: list for every actual dotfile
package directory (sway/, waybar/, kitty/, etc.) so Jekyll’s build only
ever processes the real documentation, not several hundred unrelated config
files this repo also happens to contain.
Did not take a live screenshot for the README’s existing placeholder
section, even though the tooling to do it was right there (grim, a real
running desktop matching everything documented). Checked what was actually on
screen first (swaymsg -t get_tree) and found a real browser window with a
personal email address in its title bar and a live ngrok tunnel URL, both part
of the user’s actual live session – a public GitHub Pages screenshot is a
published, hard-to-fully-reverse artifact once it’s live and possibly indexed,
and there wasn’t a way to fully vet or sanitize a live desktop capture (tmux
pane content, other window titles) well enough to publish it without asking
first. Left the existing honest placeholder in place instead of taking that
risk unprompted.
Enabled Pages via the API once the content actually existed on main (source:
main branch, path /, matching the branch this repo already treats as “the
public, stable state” — see docs/VERSIONING.md), then verified the real build
succeeded and the site was actually reachable (curl the resulting URL, not
just trusted the API call returning success) before considering this done.
Since 2026-10-04 the site builds from development, not main. Built
from main, docs written during a nightly (docs/TROUBLESHOOTING.md was the
case that showed it) stayed off the site until the next Stable release, and
the Changelog page’s Stable/Nightly toggle did nothing: main’s changelog
never has unreleased entries on top for it to hide. development is always
even with or ahead of main, so Stable visitors lose nothing; the toggle
defaults to Stable. The install commands are unaffected (each fetches
install.sh straight from its own branch).
Renamed to Vayu, and a real custom dark hero-layout homepage
Direct follow-up, same evening: rename the repo to something from Hindu mythology/history, make the site dark instead of light, and stop using the stacked README-as-homepage layout for something with a name/tagline on the left and a desktop image on the right instead.
Name: arch_sway_setup → vayu, after the Hindu god of wind. The fit is
direct, not decorative — Sway (the window manager this whole repo is built
around) tiles and rearranges windows constantly, the same way air moves, and
“sway” itself literally means to move that way. Renamed via gh repo rename,
then explicitly updated the local origin remote to the new URL and ran a
real git fetch through it to confirm the rename had actually propagated,
not just trusted the API response. Grepped the whole repo for the old name
(arch_sway_setup) to find every real reference rather than guess where it
might appear — found exactly two files (install.sh’s own curl one-liner
and REPO_URL; every GitHub/raw.githubusercontent link in README.md) and
updated both. Also set the repo’s GitHub description and homepage URL to
match, and confirmed GitHub Pages carried the rename over automatically
(gh api repos/.../pages still resolved correctly at the new
Theyashsawarkar/vayu path immediately after).
Homepage: replaced the previous “Pages serves README.md automatically”
approach with a real, hand-written index.html at the repo root. Jekyll/
Pages’ own precedence rule made this possible without extra config: an
index.html with no YAML front matter is served completely untouched
(static passthrough, no theme layout wrapping it) and always wins over
README.md/index.md as the site root when more than one exists — so the
custom homepage and the still-Jekyll-built doc pages (CHANGELOG.html,
docs/ARCHITECTURE.html, docs/VERSIONING.html) coexist in one build with
zero conflict.
README.md itself is deliberately left as a raw passthrough, not a
Jekyll-built page – caught this live, not assumed: README.html 404’d
after the first deploy, traced it to the same “no front matter -> served
untouched” rule used deliberately for index.html above, this time
applying somewhere it wasn’t wanted. The obvious fix (an empty ---\n---\n
front-matter block, the standard Jekyll trick to force processing) was
tried and reverted after checking GitHub’s own README renderer against it
first (gh api repos/.../readme) – it doesn’t strip front matter the way
Jekyll does, and rendered it as two literal hr elements at the very top
of the actual GitHub repo page, the single most-visible view of this
project. Not worth trading that for a themed mirror of a page nothing on
the site even links to (index.html links out to the GitHub-native README
directly, not a local copy) – reverted, left README.md as a plain
passthrough (still fetchable at /README.md on the Pages site, just not
rendered as HTML there).
Layout follows the pattern asked for directly: a hero section with the name,
tagline, install snippet, and CTA buttons on the left, a real screenshot of
the actual desktop on the right — the specific split-hero pattern common to
project landing pages, even though it wasn’t named as such in the request.
Below that, a feature grid and links out to the full docs. Colors are this
desktop’s own Catppuccin Mocha values used directly (same hexes already in
rmpc’s theme, waybar/style.css), not a generic “dark theme” — the site
is meant to read as part of the same thing it’s documenting. The other
pages (still Jekyll-built) switched from jekyll-theme-cayman (light) to
jekyll-theme-slate — one of GitHub Pages’ own built-in themes, dark by
default — specifically so the whole site is consistently dark, not just the
new custom homepage while the doc pages stayed light underneath it.
The screenshot took real care, not just running grim where things stood.
Checked the actual window tree first (swaymsg -t get_tree) rather than
assume the screen was safe to capture: a browser window on another
workspace and a live tmux pane on the focused one, and since a floating
rmpc window doesn’t cover the full 1920x1080 output, capturing it directly
where it was would have left that tmux pane visible in the margins around
it. Switched to a genuinely empty workspace, moved rmpc there via the
scratchpad, confirmed via the tree that it was the only node on that
workspace before capturing anything, then restored the original focused
workspace and hid rmpc back to the scratchpad afterward, leaving the real
session exactly as found. Resized and re-encoded the result (1920x1080 PNG
→ 1600x900 JPEG, ~230KB) rather than ship an untouched multi-megabyte
screenshot as a page asset.
Verified live the same way as the rest of this session’s site work: pushed,
polled the Pages build API until it reported "status":"built" rather than
assuming a quick build had finished, then curl‘d the actual homepage and
every linked doc page for real HTTP 200s and real content.
The doc pages: a real layout, a sidebar TOC, and the Package map table gone
Direct feedback, same evening, pointed at the live docs/ARCHITECTURE.html
page specifically: “take a look at this optimize this ui and make it more
asthetic. instead of putting it in the table find other way to better
structure this page instead.”
The table really was a bad fit for its own content, not just visually
plain. Reread it before changing anything: 20 rows, and their actual
description lengths ranged from a 6-character one-liner (nvim — “Editor”)
to a single cell over 2,400 characters long (bluetooth-picker.py’s row,
genuinely several paragraphs of history and reasoning). A markdown table
forces every row into the same fixed column widths regardless — exactly
wrong for content this uneven, not something better CSS on the table
tag alone could fix.
Replaced it with a responsive card grid + native details for the long
entries, entirely inside docs/ARCHITECTURE.md itself (not a
site-only copy) so both the GitHub-native view and the Pages site benefit,
and there’s still exactly one source of truth. Parsed the original table
with a small Python script rather than retype ~1,800 words of hard-won
documentation by hand — checked first that no row’s content contained a
literal | that could confuse a naive split (grep-counted pipes per line,
all exactly 4, meaning exactly 3 columns, safe to split on) before trusting
the automated pass, then diffed the extracted text against what was
actually in the file to confirm nothing was corrupted or dropped. The five
genuinely long entries (nwg-bar, docker-picker.py,
bluetooth-picker.py, networkmanager-dmenu, wifi-picker.py) get a
hand-written one-line summary visible by default plus their full original
text, verbatim, inside a details; the other fifteen just show their
short description directly, no accordion needed for one sentence.
markdown="1" on the wrapping div/details tells kramdown to still
process the markdown inside a raw HTML block (backticks → real code,
*emphasis* → real em) — verified this actually worked by checking the
built page’s real HTML output for <code class="language-plaintext...">
tags, not literal backtick characters.
Added a real custom layout (_layouts/docs.html) instead of relying on
the built-in Jekyll theme any further — a sidebar table of contents (built
by assets/docs.js from whatever h2 elements are actually on the page
at build/view time, not a hand-maintained list that would drift out of sync
with a 3,500-line, 58-section document) with scroll-based active-section
highlighting via IntersectionObserver, and the same Catppuccin Mocha
variables already used on the homepage (assets/style.css) extended into
assets/docs.css for consistent typography, code blocks, and the new
package cards — one palette across the whole site, not the homepage looking
different from everything else in it.
How the layout gets applied without ever touching the source files’
own front matter — the same GitHub-native-rendering constraint already
hit once for README.md applies here too: docs/ARCHITECTURE.md,
docs/VERSIONING.md, and CHANGELOG.md are all read directly on GitHub
(linked straight from README.md), and adding a --- front-matter block
to any of them would show up as literal horizontal rules on the actual
GitHub page, the same regression already caught and reverted once for
README.md. Used _config.yml’s defaults: key instead — scope: {path:
"docs/ARCHITECTURE.md"} with values: {layout: "docs", title:
"Architecture"}, one block per file — which assigns a layout and title by
path with zero change to the source file itself. Worth noting since it
wasn’t obvious going in: these three files were already building into real
.html pages even before this, with no front matter and no defaults:
entry at all (confirmed live earlier this session) — unlike README.md,
which Jekyll appears to specifically exclude from conversion by filename
convention. defaults: only had to solve “assign the right layout/title”,
never “make these files build at all”.
Considered, then discarded, a more complex alternative first: thin wrapper
pages (pages/architecture.md etc.) with real front matter, transcluding
the actual file’s content via Liquid’s include_relative tag piped
through the markdownify filter. Built and even started testing this
before realizing defaults: solved the exact same problem with no new
files, no transclusion indirection, and no risk of the included copy
ever silently drifting from what the real source file actually says —
deleted it once defaults: was confirmed working live.
Verified all of this the same way as everything else on this site: pushed
straight to main specifically to get a real Pages build to test against
(this was itself the verification step, not a separate “trust the config”
pass), polled for "status":"built", then pulled the real built HTML for
all three doc pages and grepped it directly — confirmed each page’s real
title tag correctly read “Architecture | Vayu” etc. (the per-file defaults: title actually applied),
docs-layout/docs-sidebar/pkg-card present in the real output (not just
written in the source), all 20 package cards accounted for, and the
markdown-inside-details processing working correctly by finding real
code/em tags in the built page rather than literal markdown
characters.
docs/KEYBINDINGS.md, a hero redesign, and the real lesson from a much deeper kramdown bug
Direct feedback, several parts at once: fix real bugs on the CHANGELOG page, add a keybindings page with its own nav entry, fix a pixelated music-player thumbnail, add copy buttons, redesign the hero (multiple screenshots, a scroll-driven rotation, a much shorter tagline), and clean up the header and feature grid.
docs/KEYBINDINGS.md is generated from the same real parsing
scripts/.local/bin/keybind-search.py already does live (real bindsym/
bind lines from sway/config/tmux.conf, paired with their own
comments/-N notes) – not a hand-maintained list kept separately from the
configs that actually define the bindings, which would inevitably drift.
Scoped to Sway’s full 94 bindings plus Tmux’s ~22 custom ones (not all
~260 including stock defaults – those are generic tmux knowledge, not
anything specific to this setup).
music-search.py’s thumbnail pixelation: mqdefault.jpg (320x180) to
hqdefault.jpg (480x360) – confirmed the larger tier is reliably available
at the same fixed, unsigned URL shape before switching (curl’d it directly),
and measured the real cost (10 concurrent fetches: ~0.4s to ~0.5s) rather
than asserting “still fast” unchecked.
The real lesson from this session’s deepest kramdown investigation:
fixing the CHANGELOG page’s broken rendering took eight separate, genuine
wrong turns before landing on the actual fix (full blow-by-blow in
CHANGELOG.md’s own entry for this date) – worth pulling out the single
reusable lesson here, since it’ll matter again the next time a code fence or
inline code span looks broken on this site: kramdown’s HTML-tag detection
appears to run before backtick code-span content is fully isolated from the
rest of the document. A word wrapped in angle brackets, anywhere in the
raw source – even inside backticks, even describing something totally
unrelated to HTML (a placeholder such as the word file wrapped that way, a
Rust generic type parameter, a keybind notation like rmpc’s own Escape-key
entry syntax) – gets provisionally treated as a possible opening HTML tag.
If no matching closing tag exists anywhere later in the document, kramdown
appears to stop processing markdown syntax entirely from that point to
end-of-file, rather than failing loudly or falling back gracefully
per-paragraph. GitHub’s own CommonMark-based renderer doesn’t have this
problem at all, which is exactly why a page can read perfectly on GitHub
natively while being silently, catastrophically broken on the Jekyll-built
site – the two renderers are not just “mostly the same, kramdown is
stricter,” they disagree on a genuinely load-bearing point.
Practical rule going forward, for any future prose in a file this site
builds (CHANGELOG.md, docs/*.md under a defaults: scope, not
README.md, which isn’t Jekyll-built at all): never wrap a bare word in
angle brackets, backticks or not, unless it’s genuine, real,
matched-open-and-closed HTML within the same short span (like this site’s
own package-card markup, or a self-contained Pango markup example showing
a sized icon span with its own matching close right there – confirmed safe
specifically because
the close tag is right there in the same span, not because backticks
protected it). For a real placeholder, just don’t decorate it with brackets
at all (file, name, pid reads fine on its own, especially already
inside backtick code styling). For something that specifically needs to
show a literal angle bracket as part of real syntax being quoted (a
generic type, a keybind notation, literal HTML being described), reword
around it in plain English rather than reach for entity-encoding or
backslash-escaping – both were tried here and both produced their own new,
different, real cosmetic bugs (entities get double-escaped by a working
code-span’s own auto-escaping; backslash escapes aren’t processed inside
code spans per the CommonMark spec at all, so the backslash itself stays
visible).
music-search.py: play by default (mpv), download moved to a flag
music-search.py’s Enter action used to be “download an mp3 into
~/Music”, full stop – the only thing it was ever built to do. Asked for
a real change: Mod+Shift+Y’s Enter should play the picked result
immediately, with a way to choose audio-only vs. a real video window,
and the old download-to-library behavior should still exist somewhere
rather than being deleted.
Player choice: mpv, not MPD/rmpc. MPD’s whole design center is a
curated, persistent local library (that’s what rmpc’s own card above is
for) – repurposing it for “stream this one URL right now, don’t add it
to anything” would fight its actual job. mpv is built for exactly this:
it ships a ytdl_hook.lua that resolves and plays a YouTube URL directly
with zero extra plumbing, confirmed live with nothing more than
mpv <url>, and it’s already installed (v0.41.0) with no dotfiles
config of its own before this. One binary covers both modes needed here
(--no-video --ytdl-format=bestaudio for audio-only, --force-window=yes
for real video) instead of reaching for a second tool.
Verification, not just “it launched”: a live process staying alive
isn’t proof of playback – confirmed via mpv’s own JSON IPC socket
(--input-ipc-server), querying time-pos twice a few seconds apart and
watching it actually advance, for both audio and video. Video specifically
was also confirmed via swaymsg -t get_tree mid-playback: real app_id
mpv (lowercase, confirmed rather than assumed from the binary name),
floating, 1280x722 (the requested 1280 720 plus mpv’s own window
decoration), centered. The whole play path was driven through the actual
sway keybinding’s real code path too, not just raw mpv calls in
isolation – wtype simulated real keystrokes into the live wofi popups
(search query, then picking a result) so the exact command music-search.py
itself constructs was what got verified, args and all.
A real bug caught in that verification: mpv/yt-dlp’s own “best”
format selection picks whatever the highest resolution available is with
zero regard for whether the machine can actually decode it smoothly –
dropped-frame count climbed continuously through a live 4K test result.
Video mode now explicitly caps at 1080p
(bestvideo[height<=?1080]+bestaudio/best[height<=?1080]) instead of
trusting the untouched default.
Mode toggle: media-play-mode.sh (Mod+Ctrl+Y), same persisted-state-file
pattern as notification-mode.sh/theme-toggle.sh – a plain file under
~/.local/state/, not anything transient, since it has to be readable by
whatever separate music-search.py invocation runs next, possibly long
after the toggle itself ran. music-search.py reads it fresh on every
run (no caching), and echoes the active mode straight into the wofi
search prompt itself (“Search YouTube (audio)…” / “(video)…”) so
which mode is active is never a guess at the point it actually matters.
Download preserved, not removed: --download (Mod+Shift+Ctrl+Y)
reuses every bit of the existing search/pick code and does exactly what
this script always did – confirmed with a real end-to-end download
through the same wtype-driven flow, a real mp3 landed in ~/Music with
its thumbnail cleaned up afterward same as before, then removed as
test-only output afterward.
for_window rule: mpv’s video window gets floating enable, resize set
1280 720, move position center – a utility popup like rmpc gets, minus
rmpc’s blur/scratchpad treatment, since this is meant to be watched
immediately, not tucked away and toggled back into view later.
music-search.py, round two: real controllability, direct feedback fixed three real bugs
v1.6.0’s play-by-default redesign got real hands-on use immediately, and surfaced three genuine problems the earlier verification (mpv launched, window appeared, IPC showed time-pos advancing) hadn’t actually caught:
1. Audio mode was genuinely uncontrollable. mpv --no-video
--force-window=no plays real audio with no window, no taskbar entry,
nothing to click – confirmed the hard way: a test-run audio process was
still playing in the background with no way to stop it short of ps aux
and kill by hand. That’s not a rough edge, it’s a real defect. Fixed by
routing music through rmpc addyt instead (rmpc’s own built-in
YouTube-to-queue command) and opening rmpc afterward – every control
(play/pause/next/prev/seek/volume) already exists and already works,
because it’s the exact same player used for the regular library. No
reason to give music its own separate, worse-controlled playback path
when a fully-controllable one already existed one command away.
2. Video mode’s window never got keyboard focus. Confirmed directly:
spawn a video, check swaymsg -t get_tree’s focused field – it stayed
on whatever window was focused before mpv launched, not mpv itself.
Every mpv keybind (space, arrows, everything) was silently going nowhere.
Root cause: a window spawned from a detached background process (Python’s
subprocess.Popen(start_new_session=True), not an already-focused
terminal’s own exec chain) doesn’t get automatic keyboard focus in sway
the way a window opened from the currently-active context normally would.
Fixed with one word: focus added to the for_window rule. Re-verified
properly this time – not just “the rule looks right” – by launching a
real video, confirming focused: true for app_id: mpv, sending a real
spacebar via wtype, and reading mpv’s own IPC pause property back as
true afterward. That last step is what was missing from the first
round of verification: a window existing and video advancing proves
playback works, it says nothing about whether input reaches it.
3. The search felt broken between query and results. wofi’s dmenu
mode closes the instant you press Enter – there’s a real ~2-3s gap while
yt-dlp searches and thumbnails download before the results list opens,
during which nothing on screen indicates anything is happening at all.
Reported directly as “the UI just becomes invisible”. Fixed with a
notify-send fired the moment the query is captured, before search()
even runs – “Searching for ‘…’” bridges the gap with something
visible, cheaper and more robust than trying to keep a wofi window open
across two separate subprocess invocations with nothing to show in it yet.
Also folded in: the persisted audio/video toggle (media-play-mode.sh,
Mod+Ctrl+Y) was removed entirely in favor of two direct keybindings
(Mod+Shift+Y = video, Mod+Ctrl+Shift+Y = music) – reported as simpler to
actually use than “press one key, then remember which mode you left it
in”. music-search.py now takes a required video/music positional
argument instead of reading a state file. The video resolution cap’s
format string was also corrected from height<=?1080 (the ? tolerates
an unknown height field, not “fall back to highest available below
1080”) to plain height<=1080, which is yt-dlp’s actual documented idiom
for “best format satisfying this cap” – functionally these produced the
same result in every case tested, but the corrected string says what it
means instead of working by coincidence.
Known gap, not silently glossed over: rmpc addyt requires
python-mutagen, which this machine didn’t have installed
(rmpc debuginfo already flagged it as missing before any of this work
started). The failure path – addyt exits non-zero, this script surfaces
its real stderr as a notification instead of proceeding to rmpc play
against nothing – was verified live. The success path (a real song
actually queued and playing through rmpc via this script) has not been,
since installing the package needs a sudo password this session doesn’t
have. Added to packages/pacman.txt; first real verification of the
success path is still owed once it’s installed.
music-search.py: real root cause of the wrong-song bug, plus persistent logging
Reported again: music mode kept starting an already-downloaded song
instead of the newly picked one. Every prior fix to this script had been
verified through live testing with a debugger’s worth of manual
swaymsg/IPC/wtype checks each time – useful for finding a bug once,
useless for a user diagnosing a recurrence days later with no
Claude Code session open. Two real, separate problems, both fixed here.
Root cause, finally confirmed: rmpc addyt exits 0 even when the
underlying download genuinely fails. Logged directly, not inferred: a
run with the still-missing python-mutagen dependency printed a real
ERROR: Postprocessing: module mutagen was not found on stderr –
and reported exit=0 in the very same log line. The previous version
of play_music() checked if add.returncode != 0: return before ever
calling rmpc play – a check that, it turns out, could never fire for
this failure mode, so rmpc play 0 ran unconditionally every time and
played whatever already happened to be sitting at queue position 0 (an
older, already-downloaded song), regardless of whether the new one had
actually been added at all. This is exactly, precisely, the reported
symptom – not a position-indexing mismatch (the theory going in), a
plain unreliable exit code.
The fix trusts the queue, not the exit code. play_music() snapshots
rmpc queue’s real file list before and after addyt, and only calls
rmpc play if exactly one new file appears in the after-snapshot –
using that entry’s own real pos value from MPD’s own response, not
a hardcoded 0. Anything else (0 new entries – the confirmed failure
mode; 2+ – genuinely ambiguous) surfaces the real error instead of
guessing. Verified live: the exact same missing-mutagen failure now
correctly finds 0 new entries and leaves the queue/playback completely
untouched, instead of resuming the old song.
Persistent logging, requested directly rather than relying on live
debugging every time: every subprocess this script runs – wofi,
yt-dlp, mpv, rmpc – now goes through one run_logged() choke point
that records the exact command, its exit code, and its stdout/stderr to
~/.local/state/music-search/music-search.log (rotated at 1MB, 3
backups). Output is truncated at 1000 chars per stream in the log
specifically because yt-dlp’s search results alone are tens of KB of
JSON per call – logging that in full would have blown through the
rotation size in a handful of runs and pushed out far more useful
entries (a query, a video id, an error line, all naturally short) long
before they’d age out on their own. main()’s own entry point is
wrapped in a bare try/except that logs any unhandled exception’s full
traceback before exiting – this script is launched from a sway
keybinding via wofi, with no terminal anywhere a stray Python traceback
would otherwise ever be seen.
A second, unrelated real bug found investigating this: mako’s own
config format only ever matches urgency= against "low", "normal",
"critical" (confirmed directly, man mako(5)) – this desktop’s config
had [urgency=high], which is not one of the three and has therefore
never matched a single real notification. Every notify-send -u critical
call across this repo’s scripts – every error notification there is –
has been rendering with the default border and the default 5000ms
timeout the whole time, not the peach border + “never auto-dismiss” that
section was clearly meant to give it. Fixed to [urgency=critical],
verified live via makoctl list -j showing a critical test notification
still present 6 seconds after firing it (past the normal default
timeout), which it wasn’t before the fix.
Known gap, still not glossed over: python-mutagen still isn’t
installed on this machine (needs a sudo password this session doesn’t
have) – the fix to the wrong-song bug is verified against the
confirmed failure mode (0 new entries, correctly detected, playback
correctly untouched), but a real successful add-and-play through this
exact code path is still owed once that dependency is actually installed.
music-search.py: the real success path, finally confirmed end-to-end
python-mutagen got installed, clearing the blocker every prior entry
on this feature had to work around rather than through. That surfaced
one more real bug immediately, then everything actually worked.
The bug: addyt now got past the metadata step but failed
differently – ACK [4@0] {add} Access to local files via TCP is not
allowed. This is a real, deliberate MPD security restriction, not a
misconfiguration: MPD only trusts a client to add a file living
outside its music_directory (exactly what rmpc’s own YouTube cache,
~/.cache/rmpc/youtube/, is) when that client connected over a Unix
socket – a TCP connection is refused even when it’s only ever going to
127.0.0.1. rmpc’s own config (rmpc/.config/rmpc/config.ron) had
address: "127.0.0.1:6600". Confirmed MPD already had a Unix socket
available without needing any mpd.conf changes at all: ss -lx showed
/run/user/1000/mpd/socket already LISTENing, backed by the distro’s
own mpd.socket systemd unit. Pointed rmpc’s address at that socket
path instead – one-line config change, no MPD-side changes needed.
Verified for real this time, not just “exit code 0”: ran the exact
keybinding code path end-to-end (wtype into the live wofi popups, same
as every other verification this feature has gotten) and read the
result back from rmpc queue/rmpc status afterward: a real new
entry (/home/yash/.cache/rmpc/youtube/<id>.opus) at queue position 0,
rmpc play 0 exiting cleanly, and MPD’s own status correctly reporting
it playing. The test video was a deliberately short (10s) tone, so by
the time status was checked again it had already finished and MPD had
auto-advanced to the next queued song, per its own normal (non-repeat,
non-single) behavior – confirmed this was normal advancement, not
another bug, from the elapsed/song-index numbers matching that timeline
exactly. Test queue entry and its cached file were removed afterward;
the real pre-existing library queue was restored to its exact original
state (rmpc clear + re-add by its real library path).
This closes out the “known gap” every one of the three prior entries on this feature had to leave open – v1.6.0 through v1.6.2 each fixed something real but couldn’t verify the actual success path without this dependency. It’s now been run and confirmed working, not just argued to be correct from reading the code.