Files
moonarch/DECISIONS.md
T
nevaforget e307028b1c fix(quickshell): let popout-spawned prompts take keyboard focus
The popout used Exclusive layer-shell keyboard focus while open. On the overlay layer that sits above all normal windows, so a keyring/PSK/polkit prompt spawned by a popout action (e.g. selecting a VPN) mapped unfocused and stayed untypeable — focus was stuck on quickshell.

Switch to OnDemand. Verified on niri 26.04: a keybind-opened popout still grabs the keyboard on open (Escape/Tab/arrow navigation preserved) while an externally-mapped toplevel can steal focus. General fix — covers every popout and every prompt type, no per-action handler.
2026-07-09 09:17:09 +02:00

59 KiB
Raw Blame History

Decisions

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.