Files
moonarch/DECISIONS.md
T
nevaforget 9689845936 fix(stasis): give the desktop plan its own dpms and lock steps
A machine without a battery never blanked and never locked. stasis builds
the desktop plan from the blocks directly under `default:` and excludes
the ac:/battery: containers (src/config/mod.rs:180-183, :230-233), so a
config that keeps every step inside those containers leaves the desktop
plan empty. select_plan_source only falls back onto the desktop plan,
never from it onto ac: (src/core/config.rs:288-306), and the last-resort
rebuild_plan_default_order() builds from blocks the parser has already
disabled — a plan of disabled steps.

dpms (120s) and lock_screen (180s) now sit directly under `default:`,
timeouts matching the ac: plan. Deliberately only those two: brightnessctl
cannot drive external monitors, and suspending a desktop is a separate
decision from idle screen handling.
2026-08-25 09:45:10 +02:00

99 KiB
Raw Blame History

Decisions

2026-08-24 – power-profiles-daemon replaces auto-cpufreq

  • Who: Dominik, ClaudeCode
  • Why: auto-cpufreq was the CPU widget's backend, chosen for the widget rather than for the hardware. Upstream describes it as a laptop tool and, for AMD trouble, points at amd_pstate.enable=0 — back to the old driver. But under amd-pstate in active mode the kernel documents that the "platform ignores the desired performance level request and takes into account only the values set to the minimum, maximum and energy performance preference registers": the governor auto-cpufreq switches is not the knob that decides anything, EPP is.
  • Tradeoffs: ppd writes exactly those knobs — ppd-driver-amd-pstate.c / ppd-driver-intel-pstate.c set EPP, ppd-driver-platform-profile.c the ACPI platform_profile that auto-cpufreq never touched — and its polkit policy allows switch-profile for the active session, so the popout no longer loses its focus to a pkexec prompt. What is given up is auto-cpufreq's load-dependent automatic: ppd offers three fixed profiles and leaves the reacting to the firmware. It also has no watch subcommand, so the widget keeps its 60s fallback poll instead of becoming event-driven. moonix switched with it (its services.power-profiles-daemon module asserts against auto-cpufreq, so the choice is one or the other) — and since moonix deploys this repo's quickshell config, the widget could not stay on the old backend either.
  • How: CpuGovService.qml calls powerprofilesctl get / set (no pkexec); indicator and popout follow the profile names — powersave → power-saver, Auto (reset) → balanced. Same switch in moonarch-cpugov and moonarch-waybar-cpugov, the latter now reading the profile instead of scaling_governor, which under amd-pstate says nothing about it. power-profiles-daemon moves from aur.txt to official.txt (it is in extra), and the service list in post-install.sh and moonarch-doctor names it instead.

2026-08-21 – stasis gets a desktop plan, not just ac/battery

  • Who: Dominik, ClaudeCode
  • Why: On a machine without a battery the screen never blanked and never locked — stasis sat at "waiting for idle" forever. Cause is in stasis, not in the timeouts: detect_chassis() returning Desktop selects PlanSource::Desktop (src/daemon/mod.rs:76-85), and that plan is built from the blocks directly under default: with the ac:/battery: containers explicitly excluded (src/config/mod.rs:180-183 + :230-233). Our config had every step inside those two containers, so the desktop plan came out empty. select_plan_source only ever falls back onto the desktop plan, never from it onto ac: (src/core/config.rs:288-306), and the last-resort rebuild_plan_default_order() builds from cfg.startup/brightness/dpms/suspend/lock_screen, which the same parser had just set to disabled() (src/config/mod.rs:173-177) — a plan of disabled steps.
  • Tradeoffs: The desktop plan gets only two of the four steps. brightness is out because brightnessctl drives an internal panel and external monitors need DDC/CI, which it does not speak. suspend is out because suspending a desktop is a separate decision from idle screen handling, not something to inherit silently from the laptop plan. Timeouts are copied from the ac: plan so a docked laptop and a desktop behave the same. Duplicating those two blocks was accepted over restructuring the config: stasis offers no include or anchor mechanism, so the alternative would be moving all steps up to default: and overriding per container, which makes the laptop plans harder to read for no gain.
  • How: dpms: (120s, niri msg action power-off-monitors / power-on-monitors) and lock_screen: (180s, swaylock -f) added directly under default: in defaults/xdg/stasis/stasis.rune, with a comment recording why they sit there and not in a container.

2026-08-18 – swaylock config moves to /etc/swaylock, background is pre-blurred

  • Who: Dominik, ClaudeCode
  • Why: A second machine showed no lock screen background. The config was installed to /etc/xdg/swaylock/config, which swaylock never reads (only ~/.swaylock/config, $XDG_CONFIG_HOME/swaylock/config, SYSCONFDIR/swaylock/config), and was made effective by seeding it into user homes from post-install.sh and the .install hook. Both seeds are conditional — the hook fires only on a moonarch-git install/upgrade and only under [ ! -f ] — so a machine that pulled no update since swaylock arrived (2026-08-14) ran with no config at all. Verified by running swaylock against a throwaway HOME: it rejects an invalid option from ~/.config/swaylock/config before connecting to the compositor, and reports nothing when the same file sits in /etc/xdg/. The machine this was investigated on had the same gap unnoticed — its home copy had been renamed to config.bak, so neither machine was actually reading a config. Second request: the background should be blurred.
  • Tradeoffs: (1) Where the config lives — owning /etc/swaylock/config was chosen over keeping the seed. It takes effect through the package alone, with no home state and no [ ! -f ] window to miss. Keeping both paths was rejected as redundant, and repairing only the seeding was rejected because it needs a manual step per machine and repeats on the next one. The original reason for seeding (DECISIONS.md 2026-08-14, tradeoff 4) was NixOS, where nixpkgs leaves meson's sysconfdir at $out/etc — but moonix runs its own tmpfiles seed anyway, so the Arch path does not have to carry that constraint. (2) The shadowing problem — home paths are searched first, so every already-seeded copy would hide the new system config forever, blur included. pre_upgrade deletes those copies, but only where cmp against the still-old /etc/xdg/swaylock/config proves them pristine; an edited copy stays and keeps winning. Only one config version was ever shipped (a17474f), so that comparison is unambiguous. (3) How to blur — swaylock-effects would blur the live screen contents (screenshots + effect-blur, as an older config of Dominik's did), but the fork replaces the swaylock package and its AUR release sits on 1.7 while upstream is at 1.8.6. A grim + ImageMagick wrapper would blur the real screen without a fork, at the price of lock latency and a blurred copy of the screen briefly on disk. Dominik chose the third option: a blurred copy of the wallpaper, shipped as an image. No fork, no wrapper, identical on Arch and NixOS — the price is that it blurs the wallpaper, not what is on screen. (4) Blur method — downsample, blur, upsample instead of a large-radius blur at full resolution. Same result to the eye, and the file compresses to 167 KB against the plain wallpaper's 374 KB.
  • How: defaults/xdg/swaylock/config → defaults/etc/swaylock/config, image= pointing at the new defaults/backgrounds/wallpaper-blur.jpg (magick wallpaper.jpg -resize 25% -blur 0x12 -resize 4717x2984! -quality 88 -strip). The PKGBUILD installs both with explicit install lines — the config deliberately outside the generic defaults/xdg/ → /etc/xdg/ sweep that caused the wrong path in the first place. moonarch.install loses its swaylock seeding block and gains the pre_upgrade retirement of the home copies; post-install.sh loses its seeding block. New check_swaylock_config in moonarch-doctor names the file that actually wins and fails when none exists. moonix/modules/desktop.nix substitutes from the new source path and points at the blurred wallpaper; its tmpfiles seed stays, since /etc/swaylock/config is not read there.

2026-08-18 – Popouts close when the focus leaves them

  • Who: Dominik, ClaudeCode
  • Why: An open popout only closed on a backdrop click, Escape, a toggle, or another screen claiming the system-wide slot. Anything that moved the focus without a click — Alt+Tab, a focus keybind, a workspace switch, a newly mapped window — left the popout standing over the desktop.
  • Tradeoffs: (1) Which signal — niri's event stream has none for layer-surface focus (verified: opening a popout emits no event about the popout itself), so the compositor alone cannot answer the question. Qt's Window.active can, but Quickshell's PanelWindow does not export focus state, and _backingWindow is private. Measured with a throwaway qs -p config: an OnDemand overlay surface reports active false while mapping, true once niri hands it the keyboard, false the instant another window takes it. (2) One signal or three — Window.active alone misses a workspace switch that does not take the keyboard away, and focusedWindowId alone misses a focus grab by another layer surface. Both were kept, plus the workspace id, rather than picking a single "cleanest" one. (3) The >= 0 filter — niri drops window focus to none while the popout holds the keyboard (Window focus changed: None), so a naive "focused window changed → close" closes every popout immediately after opening it. Found by capturing the raw event stream; the first capture run hid it because grep in a pipe is block-buffered and timeout's SIGTERM discarded the buffer. (4) Auth prompts — a popout action that raises a pkexec/keyring prompt (camera, cpugov, batsaver, VPN) hands the focus to that prompt, so the popout closes and the new state is only visible on the next open. Suppressing the rule while such a helper runs was offered and rejected: Dominik chose one rule with no special case. The OnDemand keyboard focus stays as it is — it is what lets those prompts take focus at all (see the comment in PopoutHost.qml).
  • How: PopoutHost.qml — panel.surfaceActive on Qt's Window.active, closing when it goes false, with a sawFocus flag so the mapped-but-not-yet-focused moment does not count. PopoutState.qml — two derived properties over the Niri singleton: focusedWindow (closes only for an id >= 0) and focusedWorkspace. Niri.qml gained focusedWorkspaceId next to activeOutput, read from the is_focused workspace; ids are unique across outputs, so it covers a monitor switch too. Verified against an instrumented copy of the config run as a second qs -p instance: focusing a window closes it, a workspace switch closes it, an untouched popout stays open for the whole wait, and — with the two compositor triggers disabled in the copy — the Window.active path closes it on its own, so no branch is dead code.

2026-08-14 – Power menu becomes a Quickshell popout; moonset and moonlock retired

  • Who: Dominik, ClaudeCode
  • Why: With the greeter and the locker gone (entry below), moonset was the last self-maintained GTK4 program — a keybind-invoked overlay with five buttons, which the Quickshell config can do with the popout machinery it already has. It was also the only thing still calling moonlock, so replacing it is what finally retires that too.
  • Tradeoffs: (1) Form — moonset is a fullscreen overlay with wallpaper, blur and an avatar; a popout is a small panel anchored under a bar element. Dominik chose the popout. The fullscreen look, the avatar and the DE/EN string tables are dropped rather than reimplemented; qsTr() is in place, so German can come back through Qt translations if it is ever wanted. (2) Icons — the codepoints were read out of UbuntuSansNerdFont-Regular.ttf (md-lock 0xf033e, md-logout 0xf0343, md-power_sleep 0xf0904, md-restart 0xf0709, md-power 0xf0425) instead of taken from an MDI cheat sheet, because a wrong codepoint renders as a box and nothing warns about it. (3) Confirmation — moonset had an inline confirm step; kept, as arming the row on the first click and running on the second, with the label turning red. Lock still acts immediately, as decided in moonset/DECISIONS.md (2026-03-27). (4) Logout — first written as loginctl terminate-session self, which does not work: the calling process is not always a session member and loginctl answers "Caller does not belong to any known session". auto is the right special ID — current session if there is one, otherwise the user's graphical session — verified on systemd 261 to resolve to session 3, wayland, class user. Both avoid plumbing XDG_SESSION_ID into the shell, which moonset needed. (5) Reserve Waybar — Alt+W kills Quickshell and starts Waybar, where a Quickshell IPC call cannot work. The custom/power click now fires that call anyway and the tooltip says so: a dead command with an honest label beats a button that silently does nothing, and the reserve bar is a fallback, not a second desktop.
  • How: defaults/xdg/quickshell/moonarch/PowerPopout.qml — a PopoutPanel with five ListRows over a model of (name, label, icon, confirm, cmd). Registered from Launcher.qml under power, the same borrowed-anchor trick the clipboard popout uses, so shell.qml and every user copy of it stay unchanged. Mod+Escape in defaults/xdg/niri/config.kdl now fires qs -c moonarch ipc call popout toggle power instead of spawning moonset. qmldir regenerated.

2026-08-14 – Greeter and lock screen move to upstream packages: regreet + swaylock

  • Who: Dominik, ClaudeCode
  • Why: moongreet (4372 lines of Rust) and moonlock (2386) were self-maintained for a login screen and a lock screen — a cost Dominik no longer wants to carry. moonlock additionally sits on an unfixed unlock SIGSEGV that is a GTK 4.22 regression in gtk4-layer-shell, not our code (moonlock/DECISIONS.md 2026-07-08), so the toolkit switch removes a defect we cannot fix ourselves. Both replacements come from extra: greetd-regreet and swaylock. greetd stays.
  • Tradeoffs: (1) Greeter — ReGreet's UI is fixed in Rust (relm4 templates, power buttons are set_label: "Reboot"/"Power Off", English, no icon), so extraCss/CSS can restyle but not restructure. SDDM was the alternative: its QML theme is freely editable and catppuccin-sddm ships avatars and a clock ready-made, but it drops greetd, needs a second compositor for the greeter (weston kiosk or kwin_wayland), and nixpkgs still calls its Wayland path experimental. Chose the smaller diff over the freer layout. dms-greeter (Quickshell) was rejected as the youngest option, pulling the whole DankMaterialShell. (2) Lock screen — hyprlock covers moonlock's feature list declaratively, fingerprint included, and runs on niri (no hyprland-* protocol in its build), at the price of three hyprwm libraries on the version cadence of Hyprland. swaylock has exactly one protocol (ext-session-lock-v1), no GL, no D-Bus, and comes from the sway project — but no blur, no avatar, no fingerprint, just a ring on the wallpaper. Dominik picked the thing least likely to break. Fingerprint was no loss: fprintd is in no package list and was never deployed. (3) Greeter host — ReGreet is normally started under cage; the existing niri wrapper was kept instead, because niri-greeter.kdl carries layout "de", numlock, Sweet-cursors and an empty binds {}. Under cage the layout would come from XKB_DEFAULT_LAYOUT, which nothing sets, so the greeter would come up US — a password with y/z in it would simply fail. (4) swaylock config location — swaylock searches ~/.swaylock/config, $XDG_CONFIG_HOME/swaylock/config and SYSCONFDIR/swaylock/config, never /etc/xdg/. It is therefore seeded into the user home like stasis.rune, which also keeps the file valid on NixOS, where nixpkgs leaves meson's sysconfdir at $out/etc and a system-wide /etc/swaylock/config is never read.
  • How: defaults/etc/greetd/niri-greeter.kdl spawns regreet instead of moongreet, retry loop unchanged. New defaults/etc/greetd/regreet.toml and regreet.css — both recovered from f79128b^, where they had been deleted when moongreet arrived, with the theme names updated to Colloid-Grey-Dark-Catppuccin / Colloid-Grey-Catppuccin-Dark and the font to UbuntuSans Nerd Font. defaults/etc/moongreet/ deleted. New defaults/xdg/swaylock/config with the Catppuccin Mocha palette (ring lavender, verifying blue, wrong red, cleared yellow); -f stays with the callers rather than in the file. defaults/xdg/stasis/stasis.rune now calls swaylock -f for lid_close_action and both lock_screen plans. packages/official.txt gained greetd-regreet and swaylock; moonarch-doctor checks the two regreet files instead of moongreet.toml; post-install.sh seeds the swaylock config next to the stasis one.
  • Remaining exception: moonlock stays installed. moonset/src/power.rs invokes it by name and has no lock_command option, so its Lock action would otherwise die. Idle and lid-close already go to swaylock, which means two lockers coexist until moonset is released with that call changed.

2026-08-10 – Clipboard history moves from walker to a quickshell popout

  • Who: Dominik, ClaudeCode
  • Why: Super+C (spawn "walker" "-s" "clipboard") was the last keybind still opening walker; every other menu had already become a quickshell popout, and moonix had dropped walker entirely. Requirements set with it: entries deletable from the picker, history must not survive a session, images shown as images if possible.
  • Tradeoffs: (1) Delete key — Del was the ask, but focus sits in the search field where Del is a character delete. Options were dropping the search field entirely (Del free, but no filtering over up to 750 entries), Del-when-query-is-empty (silently mode-dependent), or a modifier. Picked Ctrl+D — what walker's clipboard provider already used. A footer hint spells the keys out, since a destructive shortcut is not guessable; no other popout needs one. (2) Bar widget — a clipboard glyph next to the launcher would have given the popout its own anchor item (every other popout is a widget), but Dominik chose keybind-only: it registers from Launcher.qml and borrows that anchor, which also leaves shell.qml and therefore every user copy of it untouched. Cost: the popout unfolds under the launcher glyph, which is not its own trigger. (3) Images — the only path to a thumbnail is decoding the entry to a file and pointing an Image at it; QML has no load-from-bytes. Chosen over base64 data URIs (unverified in this Qt build, and a multi-MB screenshot becomes a multi-MB string). One decode process per row rather than a shared queue, so rows don't serialize; sourceSize.height caps the decode. Files land in $XDG_RUNTIME_DIR/moonarch-clip — tmpfs, so they expire with the session like the db, and a popout-driven delete drops the thumbnail with its entry. (4) No persistence work at all — requirement 3 was already satisfied and was verified rather than rebuilt: the db is at %t/cliphist/db (/run/user/1000, findmnt confirms tmpfs) and cliphist-text.service runs cliphist wipe as ExecStartPre. (5) walker stays installed — it has no keybind left, but moonarch-sink-switcher, moonarch-cpugov and moonarch-vpn use it as a dmenu, and those are live in the Waybar reserve config. Removing the package would break the reserve bar.
  • How: New ClipboardPopout.qml built on the LauncherPopout.qml skeleton (fixed size, search field, keyboard-navigable ListView, focusFirst), with cliphist processes instead of DesktopEntries: list on every open (cliphist has no change signal, so the open is the refresh point), decode <id> | wl-copy on Enter/click, printf '%s\n' <line> | cliphist delete on Ctrl+D, all with -db-path $XDG_RUNTIME_DIR/cliphist/db and all shell arguments passed positionally so no clipboard content becomes part of a script. Delete gets the raw list line — measured against cliphist 0.7.0: delete reads that line from stdin, and previews are safe to split at the single tab because cliphist collapses newlines and tabs inside them. Image rows parse the [[ binary data 6 KiB png 128x128 ]] placeholder into both the thumbnail extension and a readable png · 128x128 · 6 KiB label. Launcher.qml registers clipboard alongside launcher; qmldir regenerated; Super+C in config.kdl now fires qs -c moonarch ipc call popout toggle clipboard and gained a hotkey-overlay title. Verified on a repo instance (qs -p ., loads clean, qmllint clean apart from the pre-existing QProcess::ExitStatus warning class): list matches cliphist list, refresh-on-open picks up a newly copied entry, a copied PNG renders as a thumbnail with the parsed label. Not machine-verifiable here (no wtype/ydotool installed): typing/filtering, Enter-copy and Ctrl+D-delete needed a human at the keyboard.

2026-07-28 – Quickshell bar: moduleHeight as the primary vertical token, unified glyph centering

  • Who: Dominik, ClaudeCode
  • Why: Bar height was to drop from 40 to 36. barHeight was the primary geometry token and ModuleBox derived its pill height from it (barHeight - moduleMarginV * 2), so lowering the bar shrank every module pill by the same amount — the margin is an absolute value and does not scale. At 30 px that left 14 px pills for a 13 px font. Investigating the result surfaced a second, pre-existing defect: module glyphs were not vertically centered. Measured against 30c9b55, the network and bluetooth glyphs sat 1 px above the bar mid, and the nightlight/cpugov/idle/mpris/volume/backlight glyphs were off by 0.5 px.
  • Tradeoffs: (1) Geometry coupling — keep barHeight primary and re-tune moduleMarginV on every height change (fragile, the exact trap that caused the shrinking), vs. invert it so moduleHeight is primary and barHeight = moduleHeight + moduleMarginV * 2. Picked the inversion: pill height is now the invariant, bar height is set via the margin, and the pills cannot silently follow the bar again. (2) Widget hit areas — the widgets' own implicitHeight: Theme.barHeight looked redundant next to a 24 px pill and was briefly repointed to moduleHeight; reverted, because ModuleBox has no clip and those items intentionally overflow the pill to keep the whole bar height clickable (screen-edge targeting: the display edge stops the pointer, so the topmost pixels must be live). Only ModuleBox itself takes moduleHeight. (3) Centering mechanism — anchors.verticalCenter centers the Text layout box (ascent + descent) and rounds down on an odd layout height; verticalAlignment: Text.AlignVCenter inside an explicit height centers by font metrics. Verified by experiment, both glyphs in the same pill in one screenshot: converted network landed on mid 17.50, untouched bluetooth stayed at 16.50. A pixelSize hypothesis was tested first and refuted (13 vs 14 made no difference).
  • How: Theme.qml — moduleHeight: 24 + moduleMarginV: 6 as inputs, barHeight derived (36). ModuleBox.qml takes implicitHeight: Theme.moduleHeight; all widget implicitHeights stay on barHeight. Workspaces.qml dot size from hardcoded 22 to Theme.moduleHeight. Centering unified across 13 widgets onto one pattern — height: parent.height + verticalAlignment: Text.AlignVCenter (Launcher and the workspace dots use anchors.fill + both alignments as they center horizontally too); WindowTitle's root Row gained height: Theme.barHeight so its Text has an independent height to align in. Verified by pixel measurement (grim + ImageMagick bounding boxes over full element width, not a single column): all 7 pills on rows 6-29 (mid 17.50 = bar mid) and all 14 glyphs with even ink height exactly on 17.50. The remaining 7 have odd ink height (13/15 px), where 17.50 is not representable on the pixel grid — they sit on 17.0/18.0 by rounding; two of those (window icon, tray icon) are Image, where verticalAlignment does not apply and the asymmetry lives in the icon file.

2026-07-08 – nm-applet stays in autostart (secret agent, not just tray icon)

  • Who: Dominik, ClaudeCode
  • Why: With the quickshell network indicator/popout covering display and control, nm-applet's tray icon became redundant and was slated for removal from the niri autostart. Investigation stopped that: nm-applet is the session's NM secret agent — agent-owned VPN passwords (password-flags = 1, e.g. the wwwe profile, GUI-created) live in the user keyring and are delivered to NetworkManager by nm-applet on connect. Without it, nmcli connection up fails ("secrets were required, but not provided") from the popout, moonarch-vpn, and the CLI alike. See also 2026-03-30 (nm-applet introduced as VPN secret agent).
  • Tradeoffs: (1) Drop --indicator to hide the icon — refuted live: Arch's nm-applet registers a StatusNotifierItem regardless of the flag (ayatana-appindicator), icon stays. (2) Hide the item in quickshell's Tray via an id filter — rejected by Dominik (papering over a running app). (3) Move the password to system-owned (password-flags = 0, NM stores it like the wifi PSK) — rejected: nm-connection-editor defaults imported VPN passwords to agent-owned, and saving agent-owned secrets also runs through the agent (SaveSecrets), so every future GUI import would need a manual storage-dropdown flip or silently produce a broken profile. (4) Replace with a standalone agent — researched (GitHub, AUR, web): existing agents (nm-file-secret-agent, nm-pass) are headless-oriented (secrets pre-placed in files/pass, no dialogs, no SaveSecrets); the desktop combination keyring + first-connect dialog + save-on-import exists only in DE-bound agents (nm-applet, GNOME Shell, plasma-nm). A moonarch-built agent would effectively reimplement nm-applet.
  • How: Status quo kept — spawn-at-startup "nm-applet" "--indicator" stays in defaults/xdg/niri/config.kdl. The duplicate network icon (quickshell widget + tray) is the accepted price for a foolproof GUI VPN-import workflow.

2026-07-08 – Quickshell network indicator: connecting/VPN/wired states, VPN state as shared service

  • Who: Dominik, ClaudeCode
  • Why: The bar's network icon showed only off / no-connection / signal strength. While NetworkManager (re)connects, the icon claimed "no connection"; an active VPN was invisible; a wired-only machine showed the misleading no-connection wifi glyph. VPN state existed only inside NetworkPopout (nmcli, 2s poll while shown) — the indicator needs it permanently.
  • Tradeoffs: (1) Connecting display — cycling strength glyphs (nm-applet style) vs. pulsing outline vs. static glyph; picked cycling (clearly reads as "in progress"). (2) VPN display — MDI wifi-strength-N-lock glyph variants vs. a separate lock glyph (wider indicator) vs. accent color (color already encodes on/off state); picked the lock variants — one glyph, no layout change. No ethernet-lock glyph exists in MDI, so wired+VPN uses security-network (f0484). (3) VPN state freshness — always-on 2s poll is wasteful for a bar widget; nmcli monitor as a long-running process is event-driven and verified to emit line bursts on VPN up/down (hence a 300ms debounce), backed by a 60s fallback poll (pattern from CpuGovService) in case the monitor dies. quickshell's Networking API exposes no VPN (DeviceType = None/Wifi/Wired only), so nmcli stays the source.
  • How: New NetworkService.qml singleton — VPN list/parse/toggle moved verbatim from NetworkPopout, plus vpnActive/activeVpnNames and the nmcli monitor → debounce → refresh pipeline; the popout binds to the service and refreshes once on open. NetworkIndicator.qml: connecting from wifiDev.state === ConnectionState.Connecting (state transitions Disconnecting→Disconnected→Connecting→Connected verified with a throwaway probe config), 250ms timer cycles the strength ramp; icon priority connecting → wifi(+VPN lock ramp) → wired(+VPN security-network / ethernet) → wifi-off → no-connection; tooltip gains "Connecting…", "Wired" and active VPN names. Icon-priority deviation from the first sketch: wired now beats wifi-off, otherwise a desktop with wifi disabled would never show its ethernet connection. qmldir regenerated. Verified via repo instance (quickshell -p, loads clean) and live VPN toggle + wifi reconnect.

2026-07-08 – Quickshell launcher popout + side-aware popout anchoring

  • Who: Dominik, ClaudeCode
  • Why: Wanted an application launcher living in the quickshell bar rather than as a separate overlay. Explored the options together: a bar popout (like the existing audio/network popouts) vs. a keybind-only centered overlay. Chose a bar-popout launcher with a click icon as the first widget in the left cluster.
  • Tradeoffs: (1) Placement — keybind-only + centered was deferred as the "complicated" path (centering needs a host change because the body width isn't known until content loads); icon + bar popout reuses the existing register/host/keyboard machinery for free. (2) Keybind — Mod+Space stays on walker; the quickshell launcher is icon-click only for now. walker remains the full dmenu/run/calc launcher; the quickshell one is app-launch (drun) only. It still registers under launcher so wiring a key later is one line. (3) Side-aware anchoring (forced correctness fix) — every existing popout sits in the right cluster and the host anchored the body right-aligned to the trigger (x = anchorRect.x - width + flareR). A left-cluster trigger anchored that way grows leftward off-screen regardless of width. So the far-left placement required generalizing the host anchor: it now derives the side from the trigger's on-screen box (left-align when the trigger is in the left screen half, right-align otherwise). Right-cluster popouts are pixel-identical (recomputed); only the anchor-rect semantics changed (now left-edge + width instead of right-edge). Considered avoiding the host change — impossible, a right-aligned left-edge trigger is always off-screen.
  • How: New Launcher.qml (apps glyph, owns LauncherPopout as a Component, registers launcher, click → toggleByName) added first in shell.qml leftContent. New LauncherPopout.qml — fixed-size (so the frame doesn't morph per keystroke) Item with focusFirst(); a TextInput search field over DesktopEntries.applications.values (filter on name/genericName/comment/keywords, drop noDisplay, sort by name), a ListView of icon+name rows (Quickshell.iconPath for icons); ↑/↓ move selection, Enter/click entry.execute() + close, Esc close. Side-aware anchor: PopoutHost.qml frame.leftAnchored (anchorMid < root.width/2) picks left/right x; PopoutState.openByName and Tray.qml's direct open() now pass the trigger's left-edge+width box. qmldir regenerated. Verified via a repo test instance and a throwaway harness that instantiates LauncherPopout directly (quickshell -p): config + popout load clean, result list + delegates + icon lookup evaluate without QML errors (a few apps lack a themed icon → blank icon, cosmetic). Follow-on UX tweak in the same commit: clickable bar widgets now give a hover affordance via opacity: 0.8 (not a color change — icon color is the status signal, so a color hover would clobber muted/active/critical state; the Launcher keeps an accent hover as it carries no status color). Applied per widget as there is no shared icon parent; Workspaces/Tray keep their existing per-element hover.

2026-07-08 – Quickshell popouts: keyboard-navigable controls

  • Who: Dominik, ClaudeCode
  • Why: Popouts open by keybind (Mod+A) and grab the keyboard, but nothing inside was operable without a mouse — the shared controls (Toggle/ListRow/VolumeSlider) and ad-hoc glyph buttons were pure MouseArea, took no focus, reacted to no key. Opening by key is pointless if you then need the mouse. Goal: full mouse-free operation.
  • Tradeoffs: Two focus mechanisms weighed. (A) A central focus controller in PopoutPanel tracking a list of focusable children and moving an index — full control, but must collect dynamic children (Repeater delegates) and reimplements what Qt already provides. (B) Qt-native Tab chain: each control sets activeFocusOnTab, Qt links them in tree order (handles the deep nesting in DeviceControl/Switcher/Repeater for free), Tab/Shift+Tab work out of the box. Picked B; PopoutPanel only adds initial focus on open and maps ↑/↓ onto the same chain via nextItemInFocusChain + an activeFocusOnTab filter. Keyboard grab is "whenever open" (decided with the keybind feature), so mouse- and keybind-opened popouts behave identically. Ad-hoc glyph buttons (mute, media transport, dismiss, forget) folded into a shared IconButton rather than wiring focus/keys per site; Rectangle-shaped buttons (clear-all, notification actions) made focusable inline — too few and too varied for a shared type.
  • How: Toggle/ListRow/VolumeSlider gain activeFocusOnTab, a focus ring, and Keys handlers (Space/Enter activate; slider Left/Right nudge ±5%, emitting both moved and released). New IconButton.qml — focusable glyph with baseColor/activeColor, Space/Enter → activated(). PopoutPanel gains focusFirst/moveFocus/nextFocusable + Up/Down keys + initial focus on completion. PopoutHost redirects the initial focus from the panel shape to the first control (content.item.focusFirst). Popouts: AudioPopout mute, MprisPopout prev/play/next, NotifCenter dismiss and BluetoothPopout forget → IconButton (destructive ones activeColor: Theme.red); NotifCenter clear-all + notification action pills made focusable inline. Verified via a repo test instance (quickshell -p . + qs ipc call popout toggle): all popouts load/open clean; interactive Tab/arrow/Space/Esc confirmed by Dom.

2026-07-08 – Quickshell popouts: keybind-addressable via registry + IPC router

  • Who: Dominik, ClaudeCode
  • Why: Bar popouts (audio, network, battery, …) could only be opened by clicking their bar widget. Goal: open/toggle them via niri keybind too. The pilot deliberately shipped without a central registry — each widget held its own popout Component and called popouts.open(component, rect, payload) directly (composable design, see 2026-07-06). A keybind has no widget to click, so it needs a way to address a popout by name, place it on the active monitor, and give it keyboard focus.
  • Tradeoffs: (A) Leave registry-free, hardcode per-popout IPC handlers — repetitive, each popout its own plumbing, no shared anchor/rect logic. (B) Add a per-screen registry + one router — small architectural addition against the original registry-free stance, but folds the duplicated mapToItem+Qt.rect+open() block (repeated verbatim in 9 widgets) into one openByName(), so it removes duplication rather than adding it. Picked B. Keyboard-grab scope: chose "grab whenever open" (Exclusive keyboard focus while any popout is open, mouse or keybind) over "only on keybind" — consistent behavior, Escape always closes; cost is that typing goes nowhere while a popout is open, accepted. Screen routing via niri's focused output (workspace is_focused → output), window-independent so it resolves on an empty workspace; matches Quickshell screen.name (same convention Workspaces.qml already uses). Tray excluded — it is per-icon with a menu payload, not a sensible keybind target.
  • How: New PopoutRouter.qml singleton (mirrors NotifService's singleton-hosts-IpcHandler pattern) exposes IpcHandler { target: "popout" } with toggle/open/close; resolves the active monitor's PopoutState via Niri.activeOutput (new readonly property), falls back to any registered state. PopoutState.qml gains screenName, a registry (name → {component, anchorItem, payloadFn}), currentName, and register/openByName/toggleByName; self-registers with the router on completion. Bar.qml passes screenName. PopoutHost.qml keyboard focus is now wantOpen ? Exclusive : None + deferred forceActiveFocus() so Escape works on keybind-open. The 9 keybind-addressable widgets (AudioIndicator, BacklightIndicator, NetworkIndicator, BluetoothIndicator, NightlightIndicator, CpuGovIndicator, Battery, NotificationIndicator, MprisWidget) register on completion and their click handlers call toggleByName(popoutName); MprisWidget passes () => root.player as payloadFn (live player at open time). qmldir regenerated for the new singleton. Niri keybinds call qs -c moonarch ipc call popout toggle <name>.

2026-06-08 – Fontconfig generic-family defaults via owned conf.d (65)

  • Who: Dominik, ClaudeCode
  • Why: Waybar rendered in Hack instead of Ubuntu after the Nerd Fonts "Ubuntu"→"UbuntuSans" rename (Canonical rebrand). Waybar's style.css referenced the now-dead family "Ubuntu Nerd Font"; fontconfig token-matched it to "Hack Nerd Font". An unowned hand-written /etc/fonts/local.conf additionally pinned sans-serif/monospace to Hack — a stale relic.
  • Tradeoffs: local.conf (loaded at 51 via 51-local.conf) is structurally too early — 60-latin.conf prepends Noto/DejaVu afterwards and wins, so its sans-serif pin never took effect. local.conf is also reserved for local admin overrides, and packaging it as owned would collide with pre-existing unowned files on pacman update. A conf.d file at 65 loads after 60-latin (convention: 60–69 = generic→family) and wins cleanly without conflict.
  • How: New owned defaults/etc/fonts/conf.d/65-moonarch-fonts.conf maps sans-serif→UbuntuSans Nerd Font, monospace→UbuntuSansMono Nerd Font; installed by moonarch-git PKGBUILD. Stale /etc/fonts/local.conf removed. Waybar style.css font-family corrected "Ubuntu Nerd Font"→"UbuntuSans Nerd Font" (the bar wants the explicit proportional font, not a generic). The aliases need binding="strong" — verified that a weak <prefer> (fontconfig's default for <alias>) ranks behind the system's effective generic fallback and does not take effect; strong is required for it to apply.

2026-05-04 – Nightlight default OFF, no global enablement

  • Who: Dominik, ClaudeCode
  • Why: Filter survived reboots even after the user toggled it off. Root cause: moonarch-pkgbuilds/moonarch-git/PKGBUILD looped over every user service in defaults/etc/systemd/user/*.service and dropped a WantedBy symlink into /etc/systemd/user/graphical-session.target.wants/. That path is global scope. moonarch-nightlight runs systemctl --user disable wlsunset, which can only remove user-scope symlinks under ~/.config/. Systemd's own warning during disable spelled it out: "The following unit files have been enabled in global scope. This means they will still be started automatically after a successful disablement in user scope." Verified empirically — is-enabled stayed enabled, root symlink untouched. Additionally, scripts/post-install.sh enabled wlsunset by default in its USER_SERVICES array, so even without the global symlink the filter would default ON.
  • Tradeoffs: Three options weighed. (1) Default OFF + user-scope toggle — minimal change, fresh installs start without filter, toggle creates ~/.config/.../wants/ symlink that user-disable can actually remove. (2) Default ON + user-scope toggle — same plumbing, post-install enables in user scope; filter on by default but disable persists. (3) Status-file gate inside the unit — service stays enabled, ExecStartPre checks a file and exits when off. Picked (1): no behavioral default imposed on fresh installs, no extra plumbing, the toggle stays the single source of truth. Could have moved enablement to a per-user systemctl --user --global enable from the .install hook, but that fights the "this is a UI toggle" framing.
  • How: moonarch-pkgbuilds/moonarch-git/PKGBUILD — symlink loop now skips a skip_enable list (currently wlsunset.service); skipped services are still installed under /etc/systemd/user/ but not wanted by graphical-session.target at the global level. moonarch-pkgbuilds/moonarch-git/moonarch.install — pre_upgrade() deletes any pre-existing /etc/systemd/user/graphical-session.target.wants/wlsunset.service to clean up systems that received the old packaging. moonarch/scripts/post-install.sh — wlsunset removed from USER_SERVICES; comment explains why. moonarch/CLAUDE.md — Nightlight section reflects "Default OFF" and the global-scope-symlink hazard.

2026-05-04 – Battery threshold permissions: udev rule → pkexec helper

  • Who: Dominik, ClaudeCode
  • Why: The wheel-write-via-udev approach for /sys/class/power_supply/BAT0/charge_control_end_threshold had been broken since 2026-04-08 (commit ac2b210, "audit remediation Q-W3"). That commit added ACTION=="add" to 90-moonarch-battery.rules to "avoid firing on every battery event" — but that filter is precisely what the rule needs not to have. On Lenovo, the threshold attribute does not exist yet at the add event (the driver creates it slightly later); the rule fires, chmod fails silently because 2>/dev/null swallows the error, and permissions are never set. The unfiltered original rule worked by accident: add failed silently as well, but a subsequent change event on the same device caught the now-existing attribute and set permissions. After the audit commit, change events stopped re-firing the rule and the toggle was permanently broken — moonarch-batsaver-toggle returned Permission denied. Verified via journalctl + manual chmod: rule fires for hidpp_battery_0 (visible exit-1 errors), no trace for BAT0; manual chmod g+w on BAT0's threshold succeeds (sysfs accepts the change), so the permission model itself works — only the rule path failed.
  • Tradeoffs: Three approaches considered. (A) Restore the original unfiltered rule — fixes the symptom by accident, leaves the failure mode intact (silent fail at add, retry hopefully at change). (B) Switch to tmpfiles.d — Arch Wiki explicitly warns this can run before driver modules load, undefined for sysfs. (C) pkexec helper with polkit-rule — standard pattern (Battery-Health-Charging GNOME extension uses exactly this). Picked C with default Standard-pkexec prompt rather than no-password polkit rule: minor UX cost (password once per pkauth session, ≈5min cache), eliminates the entire sysfs-permission problem class, no privilege-escalation surface from a misvalidated helper. The wheel-can-write-sysfs design was a moonarch-specific deviation from common Linux practice — bringing it in line with the standard root-orientiert helper pattern.
  • How: defaults/bin/moonarch-batsaver-apply (new): privileged helper invoked via pkexec; strictly validates argument (digits only, range 1-100), writes sysfs (idempotent — skips kernel write when value already matches to avoid Lenovo EINVAL on same-value writes), writes state file. defaults/bin/moonarch-batsaver-toggle (rewritten): user-side reads current threshold, picks 80↔100, dispatches pkexec /usr/bin/moonarch-batsaver-apply $NEW, then signals waybar. defaults/etc/udev/rules.d/90-moonarch-battery.rules deleted (and the now-empty defaults/etc/udev/ parent removed). PKGBUILD: udev install line removed. moonarch-doctor: removed the udev-effectiveness check (no longer relevant). moonarch-batsaver.service and moonarch-batsaver-restore (also new in this commit, extracted from the old inline ExecStart for readability) keep root-owned boot-time restore — no permission concerns there. CLAUDE.md Battery-Conservation-Mode section updated to describe the new flow.

2026-05-04 – Cleanup: remove invented zsh override layer, harden moondoc

  • Who: Dominik, ClaudeCode
  • Why: Audit revealed two classes of cruft introduced by earlier ClaudeCode sessions without explicit decision or DECISIONS.md entry. (1) An invented user-override mechanism (~/.zshrc.d/*.zsh snippet loop, ~/.zshrc.local fallback) was wired into defaults/shell/zshrc and scripts/post-install.sh. Not a zsh convention, not documented, redundant to the user's own ~/.zshrc. post-install.sh created the ~/.zshrc.d directory unconditionally on every fresh install — leaving an empty directory in every user's home. (2) moonarch-doctor had only existence checks (/etc/zsh/zshrc.moonarch exists?, /usr/share/moonarch/ exists?) which are redundant with the package check, and hardcoded service/script lists that drift silently when moonarch-git's payload changes. The udev rule for charge_control_end_threshold (battery conservation) had no effectiveness check at all — a broken rule would not show up.
  • Tradeoffs: Could have left the invented override layer alone (no active harm) but it muddies defaults/shell/zshrc and produces empty directories on every fresh install. Could have kept the existence checks (cosmetic noise, no harm) but they create false positives — doctor reports "pass" while the actual mechanism may be broken. Kept the 7 hardcoded check_config_match entries for /etc/xdg/foot/, /etc/greetd/, /etc/moongreet/ etc. — the source-to-destination mapping is not 1:1 (foot → /etc/xdg/foot/, greetd → /etc/greetd/, moongreet → /etc/moongreet/), so dynamic discovery would need a manifest. Acceptable hardcoding for now.
  • How: defaults/shell/zshrc — ~/.zshrc.d/*.zsh source loop and ~/.zshrc.local fallback removed; second ABOUTME line that referenced them removed. scripts/post-install.sh — Zsh-block now writes ~/.zshrc with only source /etc/zsh/zshrc.moonarch (no mkdir, no ~/.zshrc.d reference); stale "rustup default stable" hint and "User overrides in ~/.zshrc.d/" hint removed from next-steps. scripts/lib.sh — dead confirm() (orphaned since transform.sh deletion 2026-04-21) removed. scripts/moonarch-doctor — user-services and helper-scripts lists now derived from pacman -Qql moonarch-git plus an explicit list of post-install-enabled externals (currently stasis); useless existence checks for /etc/zsh/zshrc.moonarch and /usr/share/moonarch/ removed; new udev-effectiveness check for charge_control_end_threshold (group=wheel + group-writable). defaults/bin/moonarch-waybar-cpugov, moonarch-waybar-gpustat — German ABOUTME comments translated to English for consistency.

2026-04-24 – Stasis: flip ignore_remote_media to false for browser video

  • Who: Dominik, ClaudeCode
  • Why: Idle was firing on a second machine even while a video was playing in the browser. Original config carried the comment "browser uses D-Bus inhibit" and set ignore_remote_media true, deliberately excluding browser MPRIS on the assumption that Firefox/Chromium would keep the session alive via org.freedesktop.ScreenSaver.Inhibit. Verified against browser behavior: both browsers only raise that inhibit during fullscreen video playback (Firefox also requires dom.screenwakelock.enabled, default off on Linux). Windowed playback — the common case, YouTube in a tab — sends no inhibit, so stasis saw zero inhibitors and zero media players and ran the full idle plan to suspend. Upstream example config lists r"firefox.*" in inhibit_apps for exactly this reason; Moonarch removed it without a working substitute.
  • Tradeoffs: Three options. (A) Put firefox/chromium back into inhibit_apps — works but inhibits whenever the browser process is running, even with zero tabs playing; wrong shape. (B) Tell users to flip dom.screenwakelock.enabled per browser — pushes per-user config, fragile across browsers. (C) Let browser MPRIS count as a media player by setting ignore_remote_media false — stasis already tracks playback state, so it only inhibits during actual playback. Picked C. Cost: any MPRIS source stasis classifies as "remote" (e.g. a Chromecast bridge) now also inhibits; acceptable — that is usually what a user wants anyway, and the inhibit releases the moment playback stops.
  • How: defaults/xdg/stasis/stasis.rune — ignore_remote_media true → false, comment rewritten to document the fullscreen-only D-Bus behavior. PKGBUILD deploys to /etc/xdg/stasis/stasis.rune; existing users still need the 2026-04-22 seed mechanism (or a manual merge) to pick it up in ~/.config/stasis/stasis.rune.

2026-04-22 – moonarch-doctor housekeeping: drop stale check, add missing services

  • Who: Dominik, ClaudeCode
  • Why: Noticed while running moondoc on a healthy system that it reported Paru [moonarch-pkgbuilds] repo missing from /etc/paru.conf — a false failure. The paru PKGBUILD-repo mechanism was retired on 2026-04-20 in favor of the registry-only flow, and the moonarch-git install hook strips the legacy paru.conf section on upgrade; the doctor script was not updated in lockstep. Audit of the rest of the script surfaced two related gaps: the user-services loop skipped walker.service and nautilus.service, even though the PKGBUILD ships both in /etc/systemd/user/ and enables them via graphical-session.target.wants symlinks. A silently missing walker or nautilus would not show up in diagnostics.
  • How: Removed the [moonarch-pkgbuilds] check from scripts/moonarch-doctor. Added walker and nautilus to the user-service loop. Updated CLAUDE.md user-services listing to match (also filled in the missing wlsunset). The [moonarch] pacman-repo check stays — that is the path that matters now.

2026-04-22 – Seed Stasis user config from post-install.sh

  • Who: Dominik, ClaudeCode
  • Why: Moonarch shipped defaults/xdg/stasis/stasis.rune (deployed to /etc/xdg/stasis/stasis.rune by the PKGBUILD) on the assumption that stasis honors the XDG system config hierarchy. It does not. Verified against upstream source (v1.1.0, src/config/mod.rs:30 + src/config/bootstrap.rs): stasis only reads ~/.config/stasis/stasis.rune (primary) or /etc/stasis/stasis.rune (fallback, no xdg/). On every start with no user config, ensure_user_config_exists() writes its own hardcoded default (laptop/desktop template compiled into the binary) to ~/.config/stasis/stasis.rune. Net effect: Moonarch's Idle-Manager tuning (AC/battery plans, moonlock integration, inhibit apps, niri DPMS commands) was never active on fresh installs — users got the upstream defaults with swaylock as locker.
  • Tradeoffs: Three options were considered. (A) Service drop-in with --config /etc/stasis/stasis.rune — cleanest, upgrades propagate via package, no user-home touching; rejected because it takes stasis out of the standard config path users expect when they want to customize, and requires packaging a drop-in unit. (B) Seed once from post-install.sh into ~/.config/stasis/stasis.rune — chosen: user-owned file, immediately editable, no magic. Cost: package updates to the template never reach users who already ran post-install; they have to merge manually. (C) Recurring systemd user service that keeps seeding — ruled out, too clever, user edits would race with it. Stasis has no config hierarchy / merging, so "system default + user override" cannot be modeled at all.
  • How: Added a block to scripts/post-install.sh before the user-services-enable step: if ~/.config/stasis/stasis.rune does not exist and /etc/xdg/stasis/stasis.rune does, install -Dm644 copies the template into the user home. Order is correct — moonarch-git is installed earlier in the script (deploys /etc/xdg/…), and stasis.service is only enabled (not started) later, so the seed is in place before stasis ever runs. The /etc/xdg/stasis/ payload stays as the canonical template source inside the package.

2026-04-21 – post-install.sh pulls aur.txt, rust for paru build, rustup out of official

  • Who: Dominik, ClaudeCode
  • Why: Three related gaps uncovered while fixing the paru bootstrap: (1) moonarch-git cannot depend on AUR packages, so every AUR package in aur.txt (walker-bin, elephant-*-bin, awww's theme, waypaper, stasis, …) was silently never installed by post-install.sh — a fresh install would have a working pacman but no launcher, no idle manager, no theming. (2) makepkg -si for paru (AUR source build) needs rust as makedep; neither archinstall nor post-install.sh installed it, so the restored paru bootstrap would have crashed on rust-less systems. (3) rustup sat in official.txt "for something we don't remember" — turned out to be a leftover; rust suffices for the paru build, and rustup is only needed for dev toolchain management which is a per-user concern, not a Moonarch default.
  • Tradeoffs: Installing rust (~350MB) just to bootstrap paru feels heavy. Alternative paru-bin avoids the compile step entirely; rejected because Dominik's documented workflow uses paru (source) and consistency with that matters more than ~30s of build time. rustup can be re-added to official.txt if the dev workflow grows that demand.
  • How: (1) post-install.sh now runs read_packages "$AUR_PACKAGES" | paru -S --needed after paru -S moonarch-git. (2) sudo pacman -S base-devel rust before the paru git-clone. (3) rustup removed from official.txt (remains in PKGBUILD optdepends for discoverability).

2026-04-21 – Restore AUR bootstrap for paru in post-install.sh

  • Who: Dominik, ClaudeCode
  • Why: Commit 0726451 (2026-03-29) replaced the working git clone https://aur.archlinux.org/paru.git && makepkg -si bootstrap with sudo pacman -S paru, on the (wrong) assumption that paru had landed in [extra]. Verified against archlinux.org API: paru and paru-bin are AUR-only, not in any official repo. Fresh installs have been silently broken since — the command fails, the installer aborts before the moonarch registry is configured.
  • Tradeoffs: Bootstrap builds paru from source, which needs base-devel (already pulled by archinstall). Alternative paru-bin would skip the compile step; chose paru to match the upstream recommendation the user follows. Alternative "bundle paru in the Moonarch registry" would be internally consistent but adds a dependency on a running Gitea during install.
  • How: Replaced the pacman -S paru line in post-install.sh with the original bootstrap — mktemp -d, git clone, makepkg -si --noconfirm, rm -rf. Wrapped in an EXIT trap so the tempdir gets cleaned up even on failure.

2026-04-21 – Drop transform.sh and legacy update.sh shim

  • Who: Dominik, ClaudeCode
  • Why: transform.sh was added 2026-03-29 to onboard users from existing Arch+Wayland systems, but was never actually used — Moonarch is installed fresh via archinstall. Maintaining it meant duplicated paru/repo/key setup, a second entry point with its own backup/pre-flight logic, and a second reason for the /opt/moonarch clone. scripts/update.sh was a deprecation shim from before moonarch-update moved into the moonarch-git package; obsolete now that the package ships moonup/moondoc.
  • Tradeoffs: Existing Arch+Wayland users now have to install paru, add the [moonarch] repo + key manually, then paru -S moonarch-git. That's three commands instead of one script — acceptable since nobody actually uses that path. If the need returns, transform can be resurrected from git history.
  • How: Deleted scripts/transform.sh and scripts/update.sh. lib.sh stays (still sourced by post-install.sh). README "Transform" section removed, project-structure listing trimmed. Fresh-install flow via archinstall + post-install.sh is unchanged.

2026-04-20 – Registry-only install: drop paru --pkgbuilds from setup scripts

  • Who: Dominik, ClaudeCode
  • Why: Two parallel paths for finding moonarch packages (Arch registry via [moonarch] in pacman.conf, and paru's PKGBUILD-repo via [moonarch-pkgbuilds] in paru.conf) caused ambiguity during debugging: paru -S moonarch-git was resolving against the registry's stale DB (zombie r99 entry) while the PKGBUILD source would have had r105. Also: the PKGBUILD-repo path triggered a local build on every client, while the registry ships prebuilt binaries. With the registry DB now stable (see moonarch-pkgbuilds/DECISIONS.md, same date), the second mechanism is redundant.
  • Tradeoffs: If the registry is down or broken, clients have no local-build fallback. Acceptable — a broken registry is a server-side bug to fix, not something to mask with a parallel mechanism that complicates diagnostics.
  • How: post-install.sh and transform.sh no longer write Mode = arp or a [moonarch-pkgbuilds] section to /etc/paru.conf, and no longer call paru -Syu --pkgbuilds. They run pacman -Sy + paru -S moonarch-git against the registry. The moonarch-git install hook now strips the legacy paru.conf entries on upgrade (see moonarch-pkgbuilds repo).

2026-04-19 – moonup i18n: reuse pacman gettext catalog + inline fallback

  • Who: Dominik, ClaudeCode
  • Why: moonup prompts looked foreign next to pacman/paru output ([y/N] vs [J/n], English strings on a German system). User wanted consistency with the rest of the pacman UX.
  • Tradeoffs: Full .po/.mo build-chain for moonarch was overkill for ~15 strings. gettext-only approach misses most moonarch-specific strings (no matching msgids in pacman catalog). Chose hybrid: TEXTDOMAIN=pacman for strings that match upstream msgids (prompts like Proceed with installation?, Do you want to remove these packages?, [Y/n], Starting full system upgrade...), inline _t() helper for moonarch-specific strings.
  • How: moonarch-update sets TEXTDOMAIN=pacman/TEXTDOMAINDIR=/usr/share/locale. _t "en" "de" picks by ${LANG%%.*} matching de_*. confirm() now uses :: prefix, default Y, accepts y/Y/j/J. No PKGBUILD change — gettext is in base. pacman msgids with trailing \n (e.g. Starting full system upgrade...\n) require ANSI-C quoting $'...\n' to match.

2026-04-08 – Battery conservation mode: udev + sysfs + Waybar toggle

  • Who: Dominik, ClaudeCode
  • Why: Laptops with charge_control_end_threshold support benefit from limiting charge to 80% to extend battery lifespan. Needed a user-friendly toggle without requiring sudo.
  • Tradeoffs: udev RUN approach for permissions (group wheel gets write access) vs polkit/pkexec (password prompt on every toggle). Chose udev for seamless UX. State persisted in /var/lib/moonarch/ (system-wide, not per-user) — acceptable since charge threshold is a hardware setting, not a user preference. Fixed 80% threshold instead of configurable — KISS, matches industry standard.
  • How: udev rule grants wheel group write on charge_control_end_threshold. Toggle script writes sysfs + state file. systemd oneshot service restores on boot. Waybar shows ♥ icon when active, hidden when inactive. Click on battery module toggles.

2026-04-07 – Walker theme: gtk-inherit → moonarch with fixed colors

  • Who: Dominik, ClaudeCode
  • Why: gtk-inherit theme relied on GTK4 color inheritance which works but doesn't update live when switching GTK themes (Walker service needs restart). Explicit color definitions make the theme self-contained and predictable.
  • Tradeoffs: Colors no longer auto-follow GTK theme changes. Acceptable since moonarch uses a fixed Catppuccin Mocha palette anyway.
  • How: Renamed theme to moonarch, added @define-color with Colloid-Grey-Dark-Catppuccin values, reduced border/shadow weight.

2026-04-07 – Migrate archinstall config to v4 format

  • Who: Dominik, ClaudeCode
  • Why: archinstall v4.1 introduced new canonical key names. Old keys (audio_config, bootloader, custom-commands) are soft-deprecated and auto-mapped, but custom-commands (hyphen) vs custom_commands (underscore) was risky.
  • Tradeoffs: Config now requires archinstall v4+. Older ISOs with v2/v3 may not parse the new keys.
  • How: Migrated audio_config into app_config, bootloader into bootloader_config, custom-commands to custom_commands, gfx_driver value updated.

2026-04-07 – kanshi config: no ABOUTME, no overwrite on transform

  • Who: Dominik, ClaudeCode
  • Why: ABOUTME comments in kanshi config broke the profile parser in wdisplays-persistent store.c, preventing config saves. Additionally, transform.sh was overwriting user display profiles on every run.
  • Tradeoffs: kanshi default template is now empty (no comments). Users get no guidance in the seed file, but wdisplays-persistent provides the GUI for config management.
  • How: Removed ABOUTME from defaults/xdg/kanshi/config. Added skip logic in transform.sh to preserve existing kanshi user configs.

2026-04-07 – Move paru repo config into moonarch.install hook

  • Who: Dominik, ClaudeCode
  • Why: paru PKGBUILD repo config was only set up by post-install.sh and transform.sh. If paru updated and overwrote /etc/paru.conf, or the config was missing on existing systems, moonarch-git couldn't update itself — bootstrap loop where the fix requires the package that delivers the fix.
  • Tradeoffs: Config setup is now in two places: moonarch.install (for updates) and post-install.sh (for first install before moonarch-git exists). Acceptable duplication to break the circular dependency.
  • How: Added paru repo config (Mode=arp + [moonarch-pkgbuilds] section) to moonarch.install post_install/post_upgrade hook. Kept post-install.sh setup for bootstrap. Removed redundant setup from transform.sh.

2026-04-02 – Rename paru PKGBUILD repo, move config to /etc/paru.conf

  • Who: Dominik, ClaudeCode
  • Why: paru PKGBUILD repo and pacman package registry both used [moonarch] as section name. When pkgver-bot pushed version bumps, paru tried to resolve PKGBUILD targets (moongreet-git, moonlock-git) against the pacman repo — which only contains moonarch-git — causing "nicht alle benötigten Pakete gefunden" errors.
  • Tradeoffs: Renaming the PKGBUILD repo section means existing installations need a one-time manual fix. Using /etc/paru.conf instead of ~/.config/paru/paru.conf is consistent with moonarch's system-wide config philosophy.
  • How: Renamed PKGBUILD repo section from [moonarch] to [moonarch-pkgbuilds]. Moved paru config (Mode + repo) from user-level ~/.config/paru/paru.conf to system-wide /etc/paru.conf. Updated post-install.sh accordingly.

2026-04-01 – Replace dunst with swaync as notification daemon

  • Who: Dominik, ClaudeCode
  • Why: Dunst lacks wp_fractional_scale_v1 support, causing aliased/jagged font rendering on external monitors in mixed-DPI setups (laptop eDP-1 at 2.5x, externals at 1x). Confirmed by testing: removing fractional scaling fixed the issue. swaync uses GTK4 which handles fractional scaling natively.
  • Tradeoffs: swaync is heavier than dunst (GTK4 + libadwaita dependency). Loses dunstctl CLI (replaced by swaync-client). Gains notification center panel with DnD toggle, grouping, MPRIS widget support. Waybar already had swaync-client integration with exec-if guard.
  • How: Replaced dunst with swaync in packages/official.txt and archinstall config. Niri spawn-at-startup updated. Waybar dunstctl widget removed (swaync-client widget already present). New swaync config.json and style.css based on catppuccin/swaync upstream theme with Lavender accent instead of Blue.

2026-03-31 – Audit: shell script quoting fixes, PKGBUILD permissions

  • Who: ClaudeCode, Dom
  • Why: Security audit found command injection risk in moonarch-cpugov (unquoted array expansion with pkexec), word-splitting in moonarch-btnote (upower output from Bluetooth devices), and nmcli argument injection in moonarch-vpn. PKGBUILD for moongreet had world-readable cache dir.
  • Tradeoffs: eval in cpugov is safe because COMMANDS values are hardcoded string literals, not user input. Alternative (function dispatch) would be cleaner but over-engineered for 3 fixed entries. moonarch-btnote switched from for-loop to while+read with process substitution to avoid subshell.
  • How: (1) eval "${COMMANDS[$choice]}" in cpugov. (2) while IFS= read -r + process substitution + quoted $DEVICE_DATA in btnote. (3) -- guard before $connection in vpn nmcli calls. (4) install -dm700 for moongreet cache dirs in PKGBUILD. (5) else err logging in post-install.sh when USER_DEFAULTS missing.

2026-03-29 – /opt/moonarch stays root-owned, no chown to user

  • Who: Dominik, ClaudeCode
  • Why: Multi-user system — chown to UID 1000 locks out other users from moonarch-update
  • Tradeoffs: sudo required for git operations in update.sh vs. simpler user-owned repo
  • How: Repo stays at /opt/moonarch owned by root:root. update.sh uses sudo git for fetch/pull. All scripts already use sudo for system-level operations, so this is consistent.

2026-03-29 – Add transform.sh for existing Arch+Wayland systems

  • Who: Dominik, ClaudeCode
  • Why: Users with existing Arch+Wayland setups should be able to adopt Moonarch without reinstalling
  • Tradeoffs: Hard overwrite of all configs (user + system) vs. selective/merge approach — chose hard overwrite for simplicity and consistency
  • How: New transform.sh with pre-flight summary, backup, DM conflict resolution, and --dry-run flag. Shared helpers extracted to lib.sh.

2026-03-29 – Package moonarch as moonarch-git PKGBUILD

  • Who: Dominik, ClaudeCode
  • Why: System artifacts (XDG configs, helper scripts, zsh config, wallpaper) should be managed by pacman for clean deployment, versioning, rollback, and deinstallation
  • Tradeoffs: /etc/xdg/ configs NOT in backup= (moonarch philosophy: system defaults flow through, users override in ~/.config/). /etc/greetd/ and /etc/moongreet/ NOT owned by package (owned by greetd/moongreet-git, overwritten via .install hook). Helper scripts move from /usr/local/bin/ to /usr/bin/ (FHS for package-managed files)
  • How: moonarch-git PKGBUILD in moonarch-pkgbuilds repo. sweet-cursors-git as separate package. moonarch-update simplified (no git-sync, pacman handles file deployment). Installer scripts (post-install.sh, transform.sh) remain for orchestration.

2026-03-30 – Replace Rofi with Walker as application launcher

  • Who: Dominik, ClaudeCode
  • Why: Walker is Wayland-native (GTK4 + gtk4-layer-shell), has built-in providers for clipboard, bluetooth, audio (wireplumber), and Niri integration. Reduces custom shell scripts from 8 to 3. Rofi required a Wayland fork (rofi-lbonn-wayland-git) and every applet was a custom bash script.
  • Tradeoffs: Walker is newer/less battle-tested than Rofi. Requires separate Elephant daemon with per-provider packages. Dmenu mode lacks Rofi's -a/-u (active/urgent) and -mesg flags. Settings menu (moonarch-setmen) dropped entirely — apps are findable via Walker's app search.
  • How: Walker + Elephant as systemd user services. Native providers replace 5 rofi scripts (launcher, clipboard, bluetooth, volume, sink-switcher). 3 scripts ported to walker dmenu (vpn, cpugov, sink-switcher). Walker theme inherits GTK4 system theme colors (gtk-inherit). Old rofi configs preserved in legacy/rofi/.

2026-03-30 – Use nm-applet as VPN secret agent, add WireGuard support

  • Who: Dominik, ClaudeCode
  • Why: VPN auth previously spawned a foot terminal for nmcli --ask, which was fragile and ugly. WireGuard connections were invisible to the VPN script and Waybar indicator because both only checked for OpenVPN (tun0 / vpn type).
  • Tradeoffs: nm-applet adds a tray indicator (mitigated with --indicator mode which is minimal). Requires nm-applet running at session start. Alternative was gnome-keyring or a custom secret agent — nm-applet is simpler and handles all NM secret types.
  • How: nm-applet started via niri spawn-at-startup. moonarch-vpn rewritten to support both vpn and wireguard types, uses nm-applet for auth instead of foot terminal, sends notify-send for connect/disconnect results. Waybar VPN module uses nmcli active connection check instead of /proc/sys/net/ipv4/conf/tun0, plus RTMIN+9 signal for instant updates after toggle.

2026-03-30 – Standardize GTK theme to Colloid-Grey-Dark-Catppuccin

  • Who: Dominik, ClaudeCode
  • Why: gsettings had Colloid-Dark-Catppuccin while config files had Colloid-Catppuccin — inconsistent. Grey accent matches the icon theme (Colloid-Grey-Catppuccin-Dark). Explicit -Dark variant is more reliable than depending on prefer-dark color-scheme setting.
  • Tradeoffs: Explicit dark locks out light mode toggle — acceptable since Moonarch is dark-only by design.
  • How: Updated transform.sh, post-install.sh, gtk-3.0/settings.ini, and gsettings to Colloid-Grey-Dark-Catppuccin. GTK4 symlinks updated accordingly.

2026-07-04 – Add quickshell bar pilot (parallel to Waybar, not yet deployed)

  • Who: Dominik, ClaudeCode
  • Why: Explore consolidating the shell stack (bar, popouts, notifications) onto quickshell/QML — one toolkit and one Catppuccin theme instead of Waybar plus separate daemons. The bar is the first, highest-ROI, fully reversible step.
  • Tradeoffs: Runs in parallel to the deployed Waybar during evaluation (two theming worlds temporarily). Kept OUT of the deploy path — not niri-spawned, not packaged in moonarch-git, not in the package lists — until daily-drive acceptance. The notification daemon cannot coexist with swaync (only one owner of org.freedesktop.Notifications); swaync remains the deployed daemon, quickshell's is opt-in for testing. Greeter (moongreet) deliberately stays Rust (login-critical).
  • How: defaults/xdg/quickshell/ — flat QML (quickshell auto-registers same-directory singletons). Catppuccin Theme singleton, Niri IPC service, shared popout framework (PopoutHost/PopoutState/PopoutPanel), bar widgets (clock, workspaces, window title, tray drill-down menu, network/bluetooth/audio/battery incl. conservation toggle, idle inhibitor), themed tooltips, and a NotificationServer-based daemon (toasts + center + DND + IPC). Runs manually via quickshell -p, hot-reloads from the repo.

2026-07-05 – Quickshell bar: port remaining Waybar modules, native nightlight

  • Who: Dominik, ClaudeCode
  • Why: Bring the quickshell pilot to Waybar parity (minus wlr/taskbar + custom/power, dropped) after an audit + remediation pass.
  • Tradeoffs: Reuse the existing moonarch helper scripts where they carry privileged/cached/stateful logic (cpugov → pkexec auto-cpufreq, updates → moonarch-waybar-updates + moonarch-update); native QML where quickshell owns the data (mpris, privacy, backlight). Nightlight moved OFF the systemd service to a quickshell-managed wlsunset Process (toggle = process running, temperature via restart, state persisted to an XDG state file) — cleaner and loop-proof; the systemd wlsunset.service is disabled for quickshell sessions (kept for Waybar). Privacy gated on active pipewire links (not mere stream presence) to avoid false "mic in use" from idle rnnoise / mic-split streams. Gamemode + updates poll on timers (no native D-Bus/API in quickshell). Still a pilot — not deployed/packaged/niri-spawned.
  • How: New indicators + popouts under defaults/xdg/quickshell/ (Nightlight{Service,Indicator,Popout}, CpuGov{Indicator,Popout}, Gamemode/Updates/Privacy indicators, Backlight{,Indicator,Popout}, Mpris{Widget,Popout}). PopoutPanel.panelWidth made per-popout overridable; VolumeSlider gained a released signal (apply-on-release for the nightlight temperature). Bar right cluster regrouped by function (media · tray · alerts · connectivity · output · modes · battery).

2026-07-05 – Quickshell bar: drop the gamemode indicator

  • Who: Dominik, ClaudeCode
  • Why: The gamemode indicator polled feralinteractive GameMode's ClientCount via a busctl fork every 5s, running regardless of visibility — i.e. 24/7 on every machine, including while gaming, for a value that is 0 almost all the time. On a gaming machine the poll lands exactly where a fork must not: mid-game (risk of a periodic frame hitch).
  • Tradeoffs: The overhead is objectively small (a busctl fork every 5s ≈ a fraction of one core), so "performance hog" overstates it — but it is pure waste. Considered rebuilding it event-driven (one persistent busctl --user monitor on GameMode's GameRegistered/GameUnregistered signals + a single initial read → zero poll), which is the clean fix, but the effort isn't worth it for an indicator that only shows while a fullscreen game is running (i.e. when the bar usually isn't even visible). No native D-Bus in quickshell rules out a cheap signal subscription. Consistent with wlr/taskbar + custom/power already dropped from the pilot.
  • How: Removed GamemodeIndicator.qml and its reference in Bar.qml. GameMode status remains available on the CLI (gamemoded -s).

2026-07-05 – Quickshell bar: user extension mechanism (toggle + add widgets)

  • Who: Dominik, ClaudeCode
  • Why: quickshell has no native config merge/drop-in/plugin system — it picks a single config root by XDG precedence. We want a system-provided bar (/etc/xdg/quickshell/) that a user can configure (toggle widgets) and extend (own widgets) without forking the whole config and losing system updates for their part.
  • Tradeoffs: Two behaviours were spiked against quickshell 0.3.0 first, not guessed: (1) a ~/.config/quickshell/ dir without a shell.qml does not displace the system config — so the user's files may live at the natural path. (2) User QML in a foreign dir cannot see our singletons implicitly, but a qmldir in the system dir + import "file:///etc/xdg/quickshell" exposes singletons and components natively (verified: Theme.accent resolves; property-injection via Loader.setSource is the fallback). Scope kept to toggle + add — no per-widget override (expensive: per-slot loader + the hardcoded Popouts.qml registry) and no reordering/user-popouts. Toggles use JSON (Waybar-familiar, reuses the NightlightService FileView+JSON pattern); own widgets stay QML (unavoidable). A user widget rooted at/containing ModuleBox must not anchor its direct child with centerIn: parent — that hits a pre-existing childrenRect binding loop in ModuleBox (documented in the bar's CLAUDE.md).
  • How: New UserConfig.qml singleton reads ~/.config/quickshell/moonarch.json ({"widgets":{"<key>":false}}, absent/broken = all on) and probes for UserWidgets.qml. New qmldir (generated from the .qml files) declares all singletons + components for cross-dir import. Bar.qml gates each of the 16 widgets on UserConfig.enabled("<key>") (self-hiding widgets combine it into their internal visible; group ModuleBoxes hide when all their keys are off) and adds a Loader slot (right cluster) bound to UserWidgets.qml when present.

2026-07-05 – Quickshell bar: promoted from pilot to default (Waybar + swaync kept as reserves)

  • Who: Dominik, ClaudeCode
  • Why: The pilot proved out in daily use. Make quickshell the session's default bar and notification daemon. "Remove nothing" means no hard deletes — Waybar and swaync stay installed, just no longer autostarted.
  • Tradeoffs: Considered a moonarch-bar switch wrapper (config-driven bar selection) but rejected as over-engineering — Waybar is only a reserve, so a persistent setting isn't worth it; niri spawns quickshell directly and Alt+W toggles quickshell ⇄ Waybar. Notifications move to quickshell's own NotificationServer; swaync's spawn-at-startup is commented out (not deleted) so it releases org.freedesktop.Notifications — flip the comment back (and drop quickshell's NotifToastHost) to return to swaync. The quickshell config needs no PKGBUILD change (moonarch-git's generic find loop over defaults/xdg/ already installs /etc/xdg/quickshell/, qmldir included); only the quickshell runtime package was added.
  • How: defaults/xdg/niri/config.kdl — spawn-at-startup "quickshell" (was moonarch-waybar), swaync spawn commented out, Alt+W bar toggle. quickshell added to packages/official.txt and moonarch-git PKGBUILD depends (Waybar + swaync stay in both — reserves, not removed). quickshell's notification stack stays active (NotifToastHost in shell.qml, NotificationIndicator in Bar.qml, notifications popout). Verified: config loads via qs -p with zero binding loops; niri validate passes.

2026-07-06 – Quickshell bar: toggle-layer → composition (named config)

  • Who: Dominik, ClaudeCode
  • Why: The 2026-07-05 user-extension mechanism (JSON moonarch.json + UserConfig singleton + per-widget enabled() gates + UserWidgets.qml slot) is not quickshell-conforming. Verified against docs + source: quickshell has no native settings/toggle/override/merge/drop-in concept — a config is composable QML, and $XDG_CONFIG_HOME is searched before $XDG_CONFIG_DIRS (src/launch/command.cpp: configBaseDirs() prepends config-home, first shell.qml found wins). The toggle layer was a bespoke graft mimicking composition on a system-owned config, built without sign-off.
  • Tradeoffs: Hiding a widget now means owning + editing your own shell.qml (more friction than a one-line JSON edit) — but conform, and reorder / add-own / replace-a-system-widget all fall out for free (the three deferred "spekulativ" items become moot). Composer widgets bind popouts: bar.popouts explicitly (slightly verbose). Once a user owns their shell.qml, structural default updates no longer reach them (only imported component updates). Adopted the official named-config distribution pattern (/etc/xdg/quickshell/moonarch/, -c moonarch) over the unnamed default — one extra migration (niri spawn, Alt+W, file move) for the documented-correct form. Popouts.qml stays a hardcoded switch; user-added popouts remain a follow-on.
  • How: All defaults/xdg/quickshell/* moved into a moonarch/ subfolder (generic PKGBUILD find-loop installs to /etc/xdg/quickshell/moonarch/, no PKGBUILD edit). Bar.qml → generic container with leftContent/centerContent/rightContent slots + popouts alias. shell.qml → the default composition filling those slots. Deleted UserConfig.qml + moonarch.json handling + UserWidgets slot; stripped && UserConfig.enabled(...) from the 7 self-hiding widgets (self-hide logic kept). qmldir regenerated (no UserConfig). config.kdl spawn + Alt+W → quickshell -c moonarch. Verified: qmllint clean (only quickshell C++ type-resolution artifacts), throwaway quickshell -p …/moonarch → Configuration Loaded, no binding loop/error.

2026-07-06 – Quickshell bar: composable popouts (widget owns its popout)

  • Who: Dominik, ClaudeCode
  • Why: The composition switch left Popouts.qml a hardcoded switch — a user-composed widget could join the bar but couldn't open a popout in the shared animated overlay, so it was second-class (a widget without a popout is useless for most cases). quickshell's native PopupWindow would be composable by construction but drops moonarch's unfold/morph animation (one shared overlay) — decided: keep the animation, make the registry composable instead. caelestia's central Panels.qml + DrawerVisibilities was considered and rejected: it fits a handful of author-owned edge drawers, not many user-composable per-widget popouts (it reintroduces central registration).
  • Tradeoffs: open() takes a Component instead of a name — less conventional, but it removes the central registry entirely: a widget carries its own popout and renders it in the shared morphing frame. Churn across the 10 trigger widgets + open() signature; each widget file gains pragma ComponentBehavior: Bound so the inline popout Component resolves root when the shared host instantiates it.
  • How: PopoutState.currentName → currentComponent; open(component, rect, payload). PopoutHost renders Loader { sourceComponent: popouts.currentComponent }. Popouts.qml deleted, qmldir regenerated. Each of the 10 widgets declares an inline Component { XxxPopout { popouts: root.popouts } } (Tray also wires menuHandle: popouts.payload) and calls open(thatComponent, …). Verified: qmllint clean, and an end-to-end throwaway config where a user widget opens its own popout on startup instantiates in the shared host with no errors/binding loops. Visual/animation unverified (needs a live eyeball).

2026-07-06 – Quickshell bar: one popout system-wide (cross-screen exclusivity)

  • Who: Dominik, ClaudeCode
  • Why: Each Bar owns a per-screen PopoutState, so on a multi-monitor setup a popout could stay open on screen 1 while another opened on screen 2. Worse, popout shown drives global singleton services (adapter.discovering, wifiDev.scannerEnabled), so closing one screen's popout could stop the service under another screen's still-open popout. A popout is a single focused interaction — only one should exist at a time.
  • Tradeoffs: Kept the per-screen PopoutState (rendering + anchor coordinates are screen-local; a single global state would either mirror the popout onto every monitor or need a screen field threaded through open()/PopoutHost). Added only the minimal cross-screen coordination — one owner token. Rejected a central registry (the composable-popouts decision just removed one). Not a compositor grab: pure QML state, so it never preempts other windows.
  • How: New PopoutBus.qml singleton with property var owner. PopoutState.open() sets PopoutBus.owner = root; close() clears it if still owned. A readonly property bool ownsSlot: PopoutBus.owner === root + onOwnsSlotChanged folds up any state that loses the slot (no loop: a foreign-close leaves owner untouched). qmldir regenerated (adds singleton PopoutBus). Verified: throwaway quickshell -p …/moonarch → Configuration Loaded, no binding loop/error; after deploy (moonarch-git-r142.c34d1d2) the running instance's log stayed error/warning-free through popout interaction, and the cross-screen fold was confirmed live on a multi-monitor setup.

2026-07-09 – Quickshell popout: OnDemand keyboard focus (prompts spawned by a popout action can steal focus)

  • Who: Dominik, ClaudeCode
  • Why: A popout action that triggers an external auth toplevel left focus stuck on quickshell — reproduced with the network popout: select a VPN → Enter → nmcli connection up fires nm-applet's secret agent → gnome-keyring/gcr shows a GTK unlock prompt, but keyboard focus stayed on the popout, so the prompt was untypeable. Same class for Wi-Fi PSK, polkit, any dialog spawned by any popout. Not VPN-specific — the cause is the popout's keyboard-focus grab.
  • Tradeoffs: Root cause is PopoutHost's WlrKeyboardFocus.Exclusive while open. Per wlr-layer-shell, Exclusive on the overlay layer sits above all normal windows, so the compositor never focuses a newly-mapped toplevel while it holds. Empirically verified on niri 26.04 (niri msg focused-window): under Exclusive, a prompt maps with is_focused=false and focused-window stays null — no WindowFocusChanged fires, so a listen-and-close approach is impossible (there is no focus event to react to). Under OnDemand, a keybind-opened popout still grabs the keyboard on open (focused-window: null → Escape/Tab/arrow nav preserved) and an externally-mapped toplevel takes focus (nm-connection-editor is_focused=true). So OnDemand fixes it with no loss of the keyboard-nav feature and no per-action handler code. Rejected: closing the popout on the VPN toggle (symptom fix, per-action, doesn't generalise).
  • How: PopoutHost.qml — keyboardFocus: root.wantOpen ? WlrKeyboardFocus.OnDemand : WlrKeyboardFocus.None (was Exclusive), comment updated with the verified niri behaviour. One line; covers every popout and every prompt type.

2026-07-15 – Qt apps get the Catppuccin look via Kvantum; GUI-toolkit env moved to niri

  • Who: Dominik, ClaudeCode
  • Why: Qt apps (concretely VLC 3) ignored the system theme — they rendered in the default Fusion style while everything else is Catppuccin Mocha. moonarch themes GTK system-wide (Colloid) but had no Qt story. Diagnosis found the existing setup half-wired: QT_QPA_PLATFORMTHEME=qt6ct was exported but qt6ct was never installed, so the variable pointed at nothing.
  • Tradeoffs: (1) Approach — Qt-follows-GTK (QT_QPA_PLATFORMTHEME=gtk3 / qgnomeplatform) vs. a native Qt Catppuccin theme. GTK-follow was rejected: it fails on the reported app — VLC is Qt5, and the Qt5 GTK-style plugin (qt5-styleplugins) is unmaintained and absent from the official repos; GTK-follow is also unreliable on non-GNOME Wayland. Picked Kvantum — a maintained Catppuccin theme, best visual match, covers Qt5 + Qt6. (2) Style wiring — QT_QPA_PLATFORMTHEME cannot theme both Qt versions at once (one value selects either the Qt5 qt5ct plugin or the Qt6 qt6ct plugin, never both). QT_STYLE_OVERRIDE=kvantum is honoured by both Qt5 and Qt6, so it is the primary mechanism; qt6ct is kept only as a Qt6 bonus for icons/palette, and qt5ct is skipped (the style override covers the Qt5 look; qt5ct would add only Qt5 icon control). (3) Env location — the Qt/GTK/SDL/Clutter vars lived in defaults/shell/zshrc. Verified empirically (/proc/<pid>/environ of niri and a niri-spawned quickshell) that niri is systemd-started and its children never inherit zsh exports — so those vars only ever reached terminal-launched apps, never the app launcher / spawn. Rejected keeping them in zshrc (would not fix VLC). Moved all GUI-toolkit vars to niri's environment {} block; TERMINAL/EDITOR/XDG_* stay in zshrc. (4) Theme delivery — vendor the one Mocha-Lavender theme into defaults/ vs. the AUR package. Picked the AUR package kvantum-theme-catppuccin-git for consistency with how the GTK theme ships (colloid-catppuccin-theme-git); it installs to /usr/share/Kvantum/, a Kvantum search path.
  • How: packages/official.txt += kvantum kvantum-qt5 qt6ct; packages/aur.txt += kvantum-theme-catppuccin-git. New environment {} block in defaults/xdg/niri/config.kdl (QT_QPA_PLATFORM, QT_QPA_PLATFORMTHEME=qt6ct, QT_STYLE_OVERRIDE=kvantum, QT_AUTO_SCREEN_SCALE_FACTOR, QT_WAYLAND_DISABLE_WINDOWDECORATION, GDK_BACKEND, SDL_VIDEODRIVER, CLUTTER_BACKEND); the same GUI vars removed from defaults/shell/zshrc. New defaults/xdg/qt6ct/qt6ct.conf (style=kvantum, icon_theme=Colloid-Grey-Catppuccin-Dark) and defaults/xdg/Kvantum/kvantum.kvconfig (theme=catppuccin-mocha-lavender, matching the Lavender accent #b4befe). Both deploy to /etc/xdg/ automatically via the moonarch-git PKGBUILD's defaults/xdg glob (no PKGBUILD change); not added to moonarch-doctor — like niri/config.kdl and the GTK settings.ini, owned /etc/xdg configs have no /usr/share/moonarch reference copy to compare against. Verified after rebuild (r152): Kvantum reads kvantum.kvconfig from /etc/xdg/Kvantum/ — fresh-path test (~/.config/Kvantum moved aside, a niri-spawned VLC still rendered Catppuccin), so the glob deploy is sufficient for fresh installs; no post-install / .install user-home seed needed.

2026-07-29 – Privacy indicator: media.class over quickshell's node type flags

  • Who: Dominik, ClaudeCode
  • Why: The indicator never appeared. Measured with throwaway probe configs (quickshell -p) against quickshell 0.3.0: (1) link state is never populated without a tracker — every PwLinkGroup reports state = -1 (PwLinkState.Unlinked; Active = 4), including a link that was demonstrably carrying audio, so the state !== Active guard rejected everything; tracking only defaultAudioSink/Source (what AudioIndicator does) does not help, and node properties stay empty when only the groups are tracked. (2) The type flags are composed bits and cannot express direction — AudioInStream = 13 (Audio|Stream|Source), AudioOutStream = 21 (Audio|Stream|Sink), so type & AudioInStream is non-zero for any playback stream; verified live: browser audio alone set micActive. (3) Video streams are not classified at all — a pipewire camera consumer reports type = Untracked(0), isStream = false while its media.class is Stream/Input/Video, so both branches of the check (which require isStream) can never match a screencast or camera.
  • Tradeoffs: Matching media.class strings is what waybar's privacy module does (Stream/Input/Audio, Stream/Input/Video, stream.monitor are literals in its binary), so this is the established signal rather than an invention — but it needs PwObjectTracker over nodes and groups, because a bound group gives state and only a bound node gives properties. Excluding desktop-audio recording could not use pipewire's stream.monitor: quickshell does not expose that property (measured undefined on a live monitor capture), so the peer's media.class (Audio/Sink) is the discriminator instead. Exact string equality also drops the …/Internal variants (the Focusrite mic-split plumbing), which is intended. Camera use through raw V4L2 (/dev/video* without the portal) stays undetectable — no pipewire node appears at all, verified with an ffmpeg capture; a /dev/video* observer was scoped out (see below).
  • How: PrivacyIndicator.qml — PwObjectTracker { objects: nodes.concat(groups) }; capturing(streamClass, ignoredPeer) walks active link groups and matches either endpoint's media.class, so micActive = capturing("Stream/Input/Audio", "Audio/Sink") and screenActive = capturing("Stream/Input/Video", ""). Tooltip for the video glyph is now "Screen or camera in use" (the class covers both). Verified end-to-end against the repo component in a probe panel: idle → hidden; pw-record from the default source → mic glyph (rendered, screenshot-checked) while browser playback ran throughout, i.e. the playback false-positive is gone; pw-record from a sink monitor → still hidden; gst pipewiresrc on the camera → video glyph; back to hidden. The screencast path shares the Stream/Input/Video class but was not exercised (no screen-recording tool installed, and the portal picker needs interaction).
  • Rejected: Detecting raw V4L2 camera use. fuser-style polling was rejected as a 24/7 process spawn for an event that occurs rarely. inotify was measured as a viable event-driven alternative (IN_OPEN/IN_CLOSE_WRITE do fire on /dev/video0), but a pure device query without any streaming (v4l2-ctl --list-formats) raises identical events, so device enumeration — browsers on a camera permission prompt, conference apps at startup — would flash the glyph; open() is not VIDIOC_STREAMON. Together with per-node filtering (video0/video2 are capture, video1/video3 metadata), watch re-arming after unplug, and the video group requirement, the accuracy was not worth it. waybar has the same blind spot for the same reason.

2026-07-29 – Camera switch: USB authorization instead of usage detection

  • Who: Dominik, ClaudeCode
  • Why: The privacy indicator can only see PipeWire capture, so raw V4L2 access stays invisible (entry above). Detecting it was rejected: inotify fires IN_OPEN for a pure device query (v4l2-ctl --list-formats) exactly as for a real capture, so device enumeration by a browser would flash the glyph. Switching cameras off by default inverts the problem — an unauthorized camera cannot be opened at all, which is a stronger guarantee than any heuristic and is exactly answerable.
  • Tradeoffs: (1) Mechanism — authorized on the USB device beats modprobe -r uvcvideo (per device rather than all cameras at once, and no reboot after 59 toggles as the Codeberg camera-control tool reports for the module path) and beats interface-level tricks (there is no per-interface authorize for non-wireless USB). Measured: authorized=0 drops the /dev/videoN nodes immediately, udev emits remove per node, the PipeWire node disappears; authorized=1 brings the nodes back, uvcvideo rebinds itself, capture works, and PipeWire re-exposes the camera a few seconds later under a new node id. Cost: the switch cuts the whole USB function, so a webcam's built-in microphone dies with it (3-1 carries 01/01,01/02) and the internal camera's DFU interface too (5-1 carries fe/01, so fwupd cannot update it while off) — surfaced in the popout. USB-only by construction: MIPI/IPU6 cameras have no authorized, the widget self-hides there. (2) Identification — descriptors (world-readable, -r--r--r--) carries every interface class and is the only source that works in both states. Measured across a full deauthorize cycle of the internal camera: the blob stays at 1420 bytes with identical interface classes while authorized=0, whereas the interface directories drop from 5 to 0 — so they cannot be the gate. bDeviceClass is useless as well (0xef on every composite camera). moonarch-camera-list correctly reported the switched-off camera as off, i.e. it stays listable and switchable with no state of our own. Verified across 21 USB devices that interface class 0x0e selects exactly the two cameras (hubs 09, HID 03, audio 01, AV adapter 11). Gotcha: wc -c reports 65553 for that file on every device — the real length is 1420 / 1332 bytes here, so the TLV walk must stop on bLength == 0. (3) No state file — an earlier draft kept a registry of known cameras under /var/lib/moonarch/; rejected because that directory is group-writable by wheel, so the privileged helper would have been validating its target against a caller-writable allowlist. It now trusts only kernel data: descriptors, DEVTYPE=usb_device from uevent, and the authorized symlink resolved into /sys/devices/. Note /sys/bus/usb/devices/<id> is itself a symlink, so confinement cannot be checked against that prefix. (4) Boot enforcement — a systemd oneshot, not a udev rule. A rule on add would also disable hot-plugged cameras, and plugging one in is a deliberate act; a fresh webcam that is dead until a password prompt is the wrong default. Accepted gap: udevadm settle only waits for already-queued events, so a camera enumerating very late behind a dock can stay on until switched. (5) Auth — plain pkexec with the standard password prompt, as moonarch-batsaver-* (2026-05-04); no polkit rule, since a no-password rule would let any process in the session re-arm the camera unnoticed and defeat the switch. Popout-spawned prompts are typeable thanks to the OnDemand keyboard focus fix (2026-07-09).
  • How: Four scripts in defaults/bin/ — moonarch-camera-list (unprivileged; prints id|vid:pid|label|on/off|video[+audio], sanitizes device-supplied product strings so they cannot forge columns), moonarch-camera-toggle (unprivileged pkexec dispatch, passes the exit code through), moonarch-camera-apply (privileged; validation ladder = value ∈ {0,1}, bus-port id shape, device exists, authorized is a real file, resolved path under /sys/devices/, DEVTYPE=usb_device, video interface class present, then an idempotence skip before the write), moonarch-camera-boot (enumerates via -list, writes 0 to each). New unit defaults/etc/systemd/system/moonarch-camera.service (Type=oneshot, ConditionPathExistsGlob=/sys/class/video4linux/video*, batsaver's hardening block, enabled from moonarch.install; Type=oneshot also satisfies moonarch-doctor's oneshot handling). QML trio CameraService / CameraIndicator / CameraPopout: the service keeps a probed flag so a failed list never empties the bar widget, an optimistic pending map so a row reacts before the password prompt returns, a single in-flight pkexec so prompts cannot stack, and treats exit 126 as a silent cancel. Its udevadm monitor watches the usb subsystem, not video4linux — unplugging an already switched-off camera emits no video4linux event, and the widget would keep listing a device that is gone.
  • Follow-up (same day, after audit): A security audit of the finished code found that the camera gate accepts a device as soon as any interface reports class 0x0e, while authorized=1 re-authorizes the whole device — so a composite device with an alibi video interface plus a hidden keyboard would appear in the popout as a camera and go live on the first switch-on. Rejected blocking it by allow-list or deny-list: the internal camera itself carries a DFU interface (0xfe) and multimedia devices commonly expose HID for their buttons (measured: the USB audio device at 3-2.2 reports 01,01,01,01,01,03), so a class filter would lock out legitimate hardware, while the attack needs physical access to a port. Instead moonarch-camera-list now reports every notable interface class and the popout names them per row, in red for classes that have no business on a camera. Also fixed from the same audit: textFormat: Text.PlainText on the device-supplied label and in the shared Tooltip (matching NotifToast/NotifCenter, which already guard foreign text), and the label sanitizer now strips the invisible/directional Unicode range (U+200B..U+200F, U+202A..U+202E, U+2066..U+2069) under LC_ALL=C, so a device cannot make its name render as another one's — verified that |, control characters, RLO and zero-width characters are removed while umlauts survive. Left as known and accepted: a TOCTOU window between the checks and the write that would need dedicated switching hardware inside a millisecond gap after authentication, and the absence of CapabilityBoundingSet/SystemCallFilter in the unit, which is inherited from moonarch-batsaver.service and would be a shared change.

2026-08-03 – Calendar popout on the clock (Qt calendar controls, no backend)

  • Who: Dominik, ClaudeCode
  • Why: Clicking the clock did nothing useful — it toggled the date between a short and a long form, which the tooltip already showed in full. A calendar is the expected thing behind a clock, and every other bar widget already opens a popout on click.
  • Tradeoffs: (1) Grid — MonthGrid + WeekNumberColumn + DayOfWeekRow from QtQuick.Controls rather than a hand-rolled grid: they carry the locale rules (week start, ISO week numbers, weekday names) that would otherwise be re-implemented, and QtQuick.Controls is already imported by TrayMenu (StackView), so no new dependency. Only the delegates are ours, so the Catppuccin look is unaffected. (2) Click action — the showYear toggle was dropped rather than moved to right-click: the popout header carries month + year and the tooltip carries the full date, so the toggle was redundant, and right-click in this bar means "tray context menu" everywhere else. (3) Leading/trailing days shown dimmed (Theme.surface2) instead of hidden as Qt's default delegate does — the grid always renders six weeks, so hiding them leaves the week-number column labelling blank rows. (4) Locale, not a hardcoded week start — Qt.locale(), so de_DE gives Monday-first + ISO weeks and an en_US install gets its own convention; the title uses standaloneMonthName because a month inflected for a full date reads wrong on its own. (5) No appointments — nothing on the system provides them (no khal/vdirsyncer/Evolution), so a source would have to be chosen first; scoped out.
  • How: New CalendarPopout.qml (PopoutPanel, panelWidth: 300; cell size derived so the week column plus seven day columns fill contentWidth exactly; header actions ‹ / calendar-today / ›, ←/→ page months, onShownChanged snaps back to today since the instance survives reopens). Clock.qml gains the standard popout wiring (popoutName: "calendar", register, toggleByName) plus pragma ComponentBehavior: Bound, loses showYear, and takes the full bar height on height — Text.implicitHeight is read-only (content-derived) and assigning it fails the config load. shell.qml passes popouts: bar.popouts to Clock; qmldir regenerated. No niri keybind added (the name is IPC-addressable, a binding is a separate call).
  • Verified: qmllint clean; throwaway quickshell -p …/moonarch reached Configuration Loaded with no binding loops (only the expected org.freedesktop.Notifications collision with the running instance). Screenshot of the live popout for August 2026: Monday-first, week 31 on the 27.07.–02.08. row, today's cell measured at #B3BEFD = Theme.accent (the colour fringes on thin digits are subpixel antialiasing, confirmed by sampling). A throwaway probe config that imports the repo component stepped December 2026 → shiftMonth(1) → January 2027, i.e. the year rollover and the dimmed neighbour-month days behave. Glyph 0xf00f6 confirmed as calendar-today by rendering the MDI range with ImageMagick. That a derived Component.onCompleted does not replace the base handler (so PopoutPanel.focusFirst still runs) was verified with a separate exit-code probe.