Update PKGBUILD version / update-pkgver (push) Successful in 5s
gtklock shows its input form on one monitor, chosen from its own -M priority list, while niri assigns the lock screen's keyboard focus by cursor position. Locking from any other monitor left form and focus apart, so typing went nowhere until the mouse was moved. moonarch-lock resolves the focused output and passes it as -M. All five call sites go through it; "$@" is forwarded unchanged, so -d stays the caller's decision.
112 KiB
112 KiB
Decisions
2026-09-14 – moonarch-lock: the input form follows the monitor you are on
- Who: Dominik, ClaudeCode
- Why: Locking from any monitor but the first one left the session untypable. gtklock puts its input form on one monitor, chosen from its own
-Mpriority list; niri assigns the lock screen's keyboard focus by cursor position —lock_surface_focus()inniri.rsreadsoutput_under_cursor()and falls back toactive_output()only when the cursor is over no output, which never happens. Form and focus therefore land apart, and keystrokes go nowhere until the mouse is moved onto the monitor holding the form. Neither side is wrong on its own: with a single monitor the case does not exist, and niri has no other signal in the lock state, since there are no windows. Not a new observation either — the same report sits unanswered in gtklock issue #89, where a niri user asks whether the clock-only window could accept keyboard input too, and notes that swaylock accepted input from either monitor. The issue was closed for the Hyprland case it was filed under. - Tradeoffs: (1)
--follow-focusis not the fix. It exists and it works, but gtklock hangs it onenter-notify-event(window.c:298) — a mouse-only signal. It reverses the direction (the form comes to the cursor instead of the cursor going to the form) without removing the mouse from the loop. Left off. (2) No keybind route exists. niri executes binds while locked if they carryallow-when-locked=true, but theFocusMonitor*actions setactive_output, which lock focus only consults as a fallback — and the IPC action list has nothing that moves the cursor. Verified ininput/mod.rsandniri-ipc; the idea was dropped after checking, not before. (3)-Mwith the focused output is what remains, and it is enough:focus-follows-mouseis active inconfig.kdl:47, so the focused output already is the one under the cursor. (4) A wrapper, not five call sites.PowerPopout.qmlspawns an argv array with no shell, so command substitution cannot live at the call site; a wrapper also keeps the next change in one file. (5)-dstays the caller's. The wrapper forwards"$@"untouched rather than fixing-deither way — stasis waits for the locker on itslock_screensteps (lock: gtklock (await exit)in the journal) and must not get it,lid_close_actionand the power menu must not block and do. (6) Fails open. Ifniri msgorjqis unavailable the wrapper drops to plain gtklock. A lock screen that does not appear is worse than one on the wrong monitor. - How:
defaults/bin/moonarch-lockadded — resolvesniri msg --json focused-outputto a name, execsgtklock -M "$name" "$@", falls back togtklock "$@". It rides the existinginstall -Dm755 defaults/bin/moonarch-* -t "$pkgdir/usr/bin/"glob, so the PKGBUILD is unchanged. All five call sites moved:stasis.rune(lid_close_actionplus the threelock_screenplans) andPowerPopout.qml:56.jqwas already inpackages/official.txt. - The part that fails silently if forgotten:
moonarch.install'spre_upgraderewrite chain, the same one that carried swaylock→gtklock.stasis.runeis seeded into the home once and never overwritten, so an existing install keeps callinggtklockdirectly and the fix reaches nobody who already has the file — on this machine the seeded copy even differs from the repo template (lid_close_actioncommented out, different line numbers). The chain now mapsgtklock→moonarch-lock, with the-dvariant ordered before the bare one: the bare rule would otherwise rewrite the inner quotes and the-dform would never match. - NixOS had to follow in the same breath. moonix deploys
stasis.runeandPowerPopout.qmlstraight out ofmoonarchSrc, so the new command reached it the moment this landed — whilepkgs/moonarch-scripts.nixinstalls an explicit whitelist thatmoonarch-lockwas not on. Changing these two files alone would have left NixOS calling a binary that does not exist there, silently. Fixed on that side in the same change:moonix/DECISIONS.md(2026-09-14). - The swaylock and moonlock rules were dropped with it. Neither locker is in use on any system, and the rewrite to gtklock has shipped in every update since 2026-09-11, so both rules only ran into the void. The
moonlockone also carried a defect nobody would have noticed: it resolved togtklock -d, and-dis wrong for alock_screenstep, where stasis waits for the locker and would have treated the lock as finished immediately.
2026-09-12 – GTK theme: the package was never installed, and it was never Mocha
- Who: Dominik, ClaudeCode
- Why: Two separate defects, found while porting moonix's Mocha switch back. (1) Nothing installed a GTK theme at all.
settings.ininamedColloid-Grey-Dark-Catppuccinand the PKGBUILD symlinked/usr/share/themes/Colloid-Grey-Dark-Catppuccin/gtk-4.0/into/etc/xdg, but no package in either list ships it:colloid-catppuccin-theme-gitis a split package ofcolloid-icon-theme-gitrunninginstall.sh -s catppuccin, which installs icons only, andkvantum-theme-catppuccin-gitis Qt. A fresh install therefore got Adwaita and three dangling symlinks.post-install.sheven had the error branch for it — "GTK4 theme not found … libadwaita apps will use fallback theme" — which fires on every fresh machine and was read as noise. (2) The theme was never Mocha. Colloid'scatppuccintweak produces Frappé with a grey accent (base#292c3c, selection#ccd0da);#1e1e2eand#b4befeappear nowhere in itsgtk.css. Kvantum, Zed and the lock screen were all Mocha, so GTK was the one layer out of step — invisible because the two dark bases sit close together. - Tradeoffs: (1) Which package —
catppuccin-gtk-theme-mocha, maintained by Catppuccin itself, shipping the upstream 1.0.3 release zips for all fourteen accents. The alternative was building Colloid from source with the catppuccin tweak, which is what produced Frappé in the first place. No loss in shape either way: Catppuccin's GTK theme is Colloid — it carriesvinceliuice/Colloid-gtk-themeas a git submodule and only recolors it, so widget shapes, radii and spacing are unchanged. (2) Icons stay Colloid —colloid-catppuccin-theme-gitkeeps its entry; it is the icon set and has no Mocha counterpart. Its name reads like a GTK theme, which is what hid defect (1); the list now says so in a comment. (3) The name will not match moonix, ever — Arch installs the release zips, whose directories carry the full build name including the+defaulttweak marker:catppuccin-mocha-lavender-standard+default. nixpkgs builds the same 1.0.3 from source with no tweaks and names itcatppuccin-mocha-lavender-standard. Neither side can take the other's name without going out of its way, so moonix keeps rewriting this one value when it deployssettings.ini— no longer a migration measure but a permanent difference, and documented as such on both sides. (4) Why the rewrite matters at all — GTK3 readssettings.inidirectly, and the dconf entry only reaches applications that ask GSettings, which on Wayland the GTK3 ones do not. A name that does not resolve puts every GTK3 application on Adwaita silently, which is precisely defect (1). - How:
packages/aur.txtgainscatppuccin-gtk-theme-mocha, with comments recording what each of the two theme packages actually provides. Theme name updated indefaults/xdg/gtk-3.0/settings.ini,gtk-4.0/settings.ini,defaults/etc/greetd/regreet.toml,scripts/post-install.sh(THEME_NAME) and the three GTK4 symlinks in the PKGBUILD.README.mdandCLAUDE.mdfollow. In moonix,gtkSettingsnow rewrites the new name instead of the Colloid one, with the comment rewritten to say the rewrite is permanent. - Worth checking on the next fresh install: that
post-install.shno longer takes its "GTK4 theme not found" branch. That message firing is the signal that the theme package is missing again.
2026-09-11 – gtklock replaces swaylock
- Who: Dominik, ClaudeCode
- Why: moonix switched first (
moonix/DECISIONS.md2026-09-11) and this follows, so the two distributions agree again and moonix can drop the twosubstitutederivations that were rewriting these configs at deploy time. Two things made the switch worth it. gtklock's modules show an avatar, a clock and power buttons, none of which swaylock can do at all. And the old objection is gone: gtklock 4.0.0 linkslibgtk-session-lock.so.0, so it speaks ext-session-lock-v1 exactly like swaylock and the compositor still holds the lock if the process dies. Only the ≤2.x versions used layer-shell, where a crash unlocked the screen. - Tradeoffs: (1) Toolkit in the unlock path — swaylock was picked in 2026-08-14 as "one protocol, no toolkit, least likely to break". gtklock adds GTK3 and two
dlopened modules to the path that decides whether the machine unlocks. Accepted because the part that actually mattered, crash safety, is unchanged. (2) Config location — the real prize. gtklock searches$XDG_CONFIG_DIRS, sodefaults/xdg/gtklock/config.inirides the generic sweep and takes effect through the package alone. The whole apparatus from 2026-08-18 goes: the explicitinstallline in the PKGBUILD, thepre_upgraderetirement of seeded home copies,check_swaylock_configinmoonarch-doctor, and the comment inpost-install.sh. swaylock needed all of it because its sysconfdir was not an XDG path; gtklock needs none. (3) Blur — no change and no gain. GTK3 has nofilterCSS property at all and gtklock takes no screenshot of the session, so there is nothing to blur at lock time.wallpaper-blur.jpgstays exactly as it was. (4) Modules —userinfoandpowerbaronly.playerctlneeds playerctld, which nothing here starts. Named bare inmodules=because gtklock's compiled-in module directory really is/usr/lib/gtklockon Arch; moonix has to spell out store paths instead, which is why it generates its own config rather than shipping this one. (5) Two callers, both spawning directly —stasis.runeandPowerPopout.qmleach start the locker themselves. There is no Wayland protocol for "lock the session": ext-session-lock-v1 is how a client becomes the lock, not a request a third party sends, and no portal covers it. logind'sSession.Lockonly emits a signal someone must be subscribed to, and nothing here is — niri ownsorg.freedesktop.ScreenSaverwithInhibit/UnInhibitand noLock, and stasis ignores the signal deliberately.xdg-screensaver lockis no answer either; it is an X11 script whose generic path runsxautolock -locknow. (6)stasis trigger lock-screenfor the popout — rejected. It would name the locker once instead of twice, at the price of a Lock button that does nothing, silently, whenever stasis is down. The reason to want it turned out not to exist: the plan continues after a manual lock anyway, because stasis takes the state from logind'sLockedHintand does not need to have started the locker. Verified by locking by hand and watching DPMS fire on schedule. (7)-dis not-f— swaylock forked once the screen was locked, gtklock forks inmain()before it has read its config, so a returnedgtklock -dmeans "forked", not "locked". Nothing chains off the exit today. - How:
defaults/etc/swaylock/configdeleted,defaults/xdg/gtklock/config.iniadded (background, two modules, nothing else).defaults/xdg/stasis/stasis.rune:lid_close_actionand the threelock_screencommands, plus@description.PowerPopout.qml:56→["gtklock", "-d"].packages/official.txt:swaylock→gtklock gtklock-userinfo-module gtklock-powerbar-module, all three inextra.moonarch-doctorlosescheck_swaylock_configand its call — an owned/etc/xdgconfig has no/usr/share/moonarchreference copy to compare against, so likeniri/config.kdland the GTKsettings.iniit simply gets no check.post-install.shloses the swaylock comment. Inmoonarch-pkgbuilds/moonarch-git: thedependsentry, theinstallline for the swaylock config, and both obsoletepre_upgradecleanup blocks. - The part that fails silently if forgotten:
moonarch.install'spre_upgradealready carried asedpointing seeded~/.config/stasis/stasis.runeat swaylock, because that file is copied into the home once and never overwritten. It now rewritesswaylock/swaylock -f/moonlocktogtklock. Without it an existing install keeps calling a locker that is no longer installed, and stasis fails silently on a missing binary: it logs the action as started, sees the immediate exit, treats the step as finished and stops there. Rule order matters — theswaylock -frule runs before the bare one, or the bare rule rewrites the inner quotes first and-fnever matches. This exact failure was observed on the NixOS machine during the switch.
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 underamd-pstatein 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.cset EPP,ppd-driver-platform-profile.cthe ACPIplatform_profilethat auto-cpufreq never touched — and its polkit policy allowsswitch-profilefor 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 nowatchsubcommand, so the widget keeps its 60s fallback poll instead of becoming event-driven. moonix switched with it (itsservices.power-profiles-daemonmodule 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.qmlcallspowerprofilesctl get/set(no pkexec); indicator and popout follow the profile names —powersave→power-saver,Auto (reset)→balanced. Same switch inmoonarch-cpugovandmoonarch-waybar-cpugov, the latter now reading the profile instead ofscaling_governor, which under amd-pstate says nothing about it.power-profiles-daemonmoves fromaur.txttoofficial.txt(it is inextra), and the service list inpost-install.shandmoonarch-doctornames 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()returningDesktopselectsPlanSource::Desktop(src/daemon/mod.rs:76-85), and that plan is built from the blocks directly underdefault:with theac:/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_sourceonly ever falls back onto the desktop plan, never from it ontoac:(src/core/config.rs:288-306), and the last-resortrebuild_plan_default_order()builds fromcfg.startup/brightness/dpms/suspend/lock_screen, which the same parser had just set todisabled()(src/config/mod.rs:173-177) — a plan of disabled steps. - Tradeoffs: The desktop plan gets only two of the four steps.
brightnessis out becausebrightnessctldrives an internal panel and external monitors need DDC/CI, which it does not speak.suspendis 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 theac: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 todefault: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) andlock_screen:(180s,swaylock -f) added directly underdefault:indefaults/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 frompost-install.shand the.installhook. 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 throwawayHOME: it rejects an invalid option from~/.config/swaylock/configbefore 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 toconfig.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/configwas 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.md2026-08-14, tradeoff 4) was NixOS, where nixpkgs leaves meson'ssysconfdirat$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_upgradedeletes those copies, but only wherecmpagainst the still-old/etc/xdg/swaylock/configproves 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-effectswould 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. Agrim+ 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 newdefaults/backgrounds/wallpaper-blur.jpg(magick wallpaper.jpg -resize 25% -blur 0x12 -resize 4717x2984! -quality 88 -strip). The PKGBUILD installs both with explicitinstalllines — the config deliberately outside the genericdefaults/xdg/→/etc/xdg/sweep that caused the wrong path in the first place.moonarch.installloses its swaylock seeding block and gains thepre_upgraderetirement of the home copies;post-install.shloses its seeding block. Newcheck_swaylock_configinmoonarch-doctornames the file that actually wins and fails when none exists.moonix/modules/desktop.nixsubstitutes from the new source path and points at the blurred wallpaper; its tmpfiles seed stays, since/etc/swaylock/configis 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.activecan, but Quickshell'sPanelWindowdoes not export focus state, and_backingWindowis private. Measured with a throwawayqs -pconfig: anOnDemandoverlay surface reportsactivefalse while mapping, true once niri hands it the keyboard, false the instant another window takes it. (2) One signal or three —Window.activealone misses a workspace switch that does not take the keyboard away, andfocusedWindowIdalone misses a focus grab by another layer surface. Both were kept, plus the workspace id, rather than picking a single "cleanest" one. (3) The>= 0filter — 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 becausegrepin a pipe is block-buffered andtimeout'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. TheOnDemandkeyboard focus stays as it is — it is what lets those prompts take focus at all (see the comment inPopoutHost.qml). - How:
PopoutHost.qml—panel.surfaceActiveon Qt'sWindow.active, closing when it goes false, with asawFocusflag so the mapped-but-not-yet-focused moment does not count.PopoutState.qml— two derived properties over theNirisingleton:focusedWindow(closes only for an id>= 0) andfocusedWorkspace.Niri.qmlgainedfocusedWorkspaceIdnext toactiveOutput, read from theis_focusedworkspace; ids are unique across outputs, so it covers a monitor switch too. Verified against an instrumented copy of the config run as a secondqs -pinstance: 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 — theWindow.activepath 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),
moonsetwas 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 callingmoonlock, 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 ofUbuntuSansNerdFont-Regular.ttf(md-lock0xf033e,md-logout0xf0343,md-power_sleep0xf0904,md-restart0xf0709,md-power0xf0425) 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 inmoonset/DECISIONS.md(2026-03-27). (4) Logout — first written asloginctl 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".autois 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 plumbingXDG_SESSION_IDinto the shell, which moonset needed. (5) Reserve Waybar —Alt+Wkills Quickshell and starts Waybar, where a Quickshell IPC call cannot work. Thecustom/powerclick 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— aPopoutPanelwith fiveListRows over a model of (name, label, icon, confirm, cmd). Registered fromLauncher.qmlunderpower, the same borrowed-anchor trick the clipboard popout uses, soshell.qmland every user copy of it stay unchanged.Mod+Escapeindefaults/xdg/niri/config.kdlnow firesqs -c moonarch ipc call popout toggle powerinstead of spawning moonset.qmldirregenerated.
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.md2026-07-08), so the toolkit switch removes a defect we cannot fix ourselves. Both replacements come fromextra:greetd-regreetandswaylock. 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), soextraCss/CSS can restyle but not restructure. SDDM was the alternative: its QML theme is freely editable andcatppuccin-sddmships 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 (nohyprland-*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:fprintdis in no package list and was never deployed. (3) Greeter host — ReGreet is normally started under cage; the existing niri wrapper was kept instead, becauseniri-greeter.kdlcarrieslayout "de",numlock,Sweet-cursorsand an emptybinds {}. Under cage the layout would come fromXKB_DEFAULT_LAYOUT, which nothing sets, so the greeter would come up US — a password withy/zin it would simply fail. (4) swaylock config location — swaylock searches~/.swaylock/config,$XDG_CONFIG_HOME/swaylock/configandSYSCONFDIR/swaylock/config, never/etc/xdg/. It is therefore seeded into the user home likestasis.rune, which also keeps the file valid on NixOS, where nixpkgs leaves meson'ssysconfdirat$out/etcand a system-wide/etc/swaylock/configis never read. - How:
defaults/etc/greetd/niri-greeter.kdlspawnsregreetinstead ofmoongreet, retry loop unchanged. Newdefaults/etc/greetd/regreet.tomlandregreet.css— both recovered fromf79128b^, 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. Newdefaults/xdg/swaylock/configwith the Catppuccin Mocha palette (ring lavender, verifying blue, wrong red, cleared yellow);-fstays with the callers rather than in the file.defaults/xdg/stasis/stasis.runenow callsswaylock -fforlid_close_actionand bothlock_screenplans.packages/official.txtgainedgreetd-regreetandswaylock;moonarch-doctorchecks the two regreet files instead ofmoongreet.toml;post-install.shseeds the swaylock config next to the stasis one. - Remaining exception:
moonlockstays installed.moonset/src/power.rsinvokes it by name and has nolock_commandoption, 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 —
Delwas the ask, but focus sits in the search field whereDelis a character delete. Options were dropping the search field entirely (Delfree, but no filtering over up to 750 entries),Del-when-query-is-empty (silently mode-dependent), or a modifier. PickedCtrl+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 fromLauncher.qmland borrows that anchor, which also leavesshell.qmland 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 anImageat 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.heightcaps 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,findmntconfirms tmpfs) andcliphist-text.servicerunscliphist wipeasExecStartPre. (5) walker stays installed — it has no keybind left, butmoonarch-sink-switcher,moonarch-cpugovandmoonarch-vpnuse it as a dmenu, and those are live in the Waybar reserve config. Removing the package would break the reserve bar. - How: New
ClipboardPopout.qmlbuilt on theLauncherPopout.qmlskeleton (fixed size, search field, keyboard-navigableListView,focusFirst), with cliphist processes instead ofDesktopEntries:liston every open (cliphist has no change signal, so the open is the refresh point),decode <id> | wl-copyon Enter/click,printf '%s\n' <line> | cliphist deleteon Ctrl+D, all with-db-path $XDG_RUNTIME_DIR/cliphist/dband 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:deletereads 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 readablepng · 128x128 · 6 KiBlabel.Launcher.qmlregistersclipboardalongsidelauncher;qmldirregenerated;Super+Cinconfig.kdlnow firesqs -c moonarch ipc call popout toggle clipboardand gained a hotkey-overlay title. Verified on a repo instance (qs -p ., loads clean, qmllint clean apart from the pre-existingQProcess::ExitStatuswarning class): list matchescliphist 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 (nowtype/ydotoolinstalled): 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.
barHeightwas the primary geometry token andModuleBoxderived 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 against30c9b55, 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
barHeightprimary and re-tunemoduleMarginVon every height change (fragile, the exact trap that caused the shrinking), vs. invert it somoduleHeightis primary andbarHeight = 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' ownimplicitHeight: Theme.barHeightlooked redundant next to a 24 px pill and was briefly repointed tomoduleHeight; reverted, becauseModuleBoxhas noclipand 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). OnlyModuleBoxitself takesmoduleHeight. (3) Centering mechanism —anchors.verticalCentercenters the Text layout box (ascent + descent) and rounds down on an odd layout height;verticalAlignment: Text.AlignVCenterinside an explicitheightcenters 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. ApixelSizehypothesis was tested first and refuted (13 vs 14 made no difference). - How:
Theme.qml—moduleHeight: 24+moduleMarginV: 6as inputs,barHeightderived (36).ModuleBox.qmltakesimplicitHeight: Theme.moduleHeight; all widgetimplicitHeights stay onbarHeight.Workspaces.qmldot size from hardcoded 22 toTheme.moduleHeight. Centering unified across 13 widgets onto one pattern —height: parent.height+verticalAlignment: Text.AlignVCenter(Launcher and the workspace dots useanchors.fill+ both alignments as they center horizontally too);WindowTitle's rootRowgainedheight: Theme.barHeightso 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 onrows 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) areImage, whereverticalAlignmentdoes 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 upfails ("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
--indicatorto 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 indefaults/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-lockglyph 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 usessecurity-network(f0484). (3) VPN state freshness — always-on 2s poll is wasteful for a bar widget;nmcli monitoras 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 fromCpuGovService) 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.qmlsingleton — VPN list/parse/toggle moved verbatim fromNetworkPopout, plusvpnActive/activeVpnNamesand thenmcli monitor→ debounce → refresh pipeline; the popout binds to the service and refreshes once on open.NetworkIndicator.qml:connectingfromwifiDev.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.qmldirregenerated. 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+Spacestays onwalker; 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 underlauncherso 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, ownsLauncherPopoutas aComponent, registerslauncher, click →toggleByName) added first inshell.qmlleftContent. NewLauncherPopout.qml— fixed-size (so the frame doesn't morph per keystroke)ItemwithfocusFirst(); aTextInputsearch field overDesktopEntries.applications.values(filter on name/genericName/comment/keywords, dropnoDisplay, sort by name), aListViewof icon+name rows (Quickshell.iconPathfor icons); ↑/↓ move selection, Enter/clickentry.execute()+ close, Esc close. Side-aware anchor:PopoutHost.qmlframe.leftAnchored(anchorMid < root.width/2) picks left/right x;PopoutState.openByNameandTray.qml's directopen()now pass the trigger's left-edge+width box.qmldirregenerated. Verified via a repo test instance and a throwaway harness that instantiatesLauncherPopoutdirectly (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 viaopacity: 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 anaccenthover as it carries no status color). Applied per widget as there is no shared icon parent;Workspaces/Traykeep 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 pureMouseArea, 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
PopoutPaneltracking 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 setsactiveFocusOnTab, Qt links them in tree order (handles the deep nesting inDeviceControl/Switcher/Repeaterfor free), Tab/Shift+Tab work out of the box. Picked B;PopoutPanelonly adds initial focus on open and maps ↑/↓ onto the same chain vianextItemInFocusChain+ anactiveFocusOnTabfilter. 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 sharedIconButtonrather 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/VolumeSlidergainactiveFocusOnTab, a focus ring, andKeyshandlers (Space/Enter activate; slider Left/Right nudge ±5%, emitting bothmovedandreleased). NewIconButton.qml— focusable glyph withbaseColor/activeColor, Space/Enter →activated().PopoutPanelgainsfocusFirst/moveFocus/nextFocusable+ Up/Down keys + initial focus on completion.PopoutHostredirects the initial focus from the panel shape to the first control (content.item.focusFirst). Popouts:AudioPopoutmute,MprisPopoutprev/play/next,NotifCenterdismiss andBluetoothPopoutforget →IconButton(destructive onesactiveColor: Theme.red);NotifCenterclear-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
Componentand calledpopouts.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 oneopenByName(), 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 (workspaceis_focused→output), window-independent so it resolves on an empty workspace; matches Quickshellscreen.name(same conventionWorkspaces.qmlalready uses). Tray excluded — it is per-icon with a menu payload, not a sensible keybind target. - How: New
PopoutRouter.qmlsingleton (mirrorsNotifService's singleton-hosts-IpcHandler pattern) exposesIpcHandler { target: "popout" }withtoggle/open/close; resolves the active monitor'sPopoutStateviaNiri.activeOutput(new readonly property), falls back to any registered state.PopoutState.qmlgainsscreenName, aregistry(name → {component, anchorItem, payloadFn}),currentName, andregister/openByName/toggleByName; self-registers with the router on completion.Bar.qmlpassesscreenName.PopoutHost.qmlkeyboard focus is nowwantOpen ? Exclusive : None+ deferredforceActiveFocus()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 calltoggleByName(popoutName);MprisWidgetpasses() => root.playeras payloadFn (live player at open time).qmldirregenerated for the new singleton. Niri keybinds callqs -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.confadditionally pinned sans-serif/monospace to Hack — a stale relic. - Tradeoffs:
local.conf(loaded at 51 via51-local.conf) is structurally too early —60-latin.confprepends 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.confmaps sans-serif→UbuntuSans Nerd Font, monospace→UbuntuSansMono Nerd Font; installed by moonarch-git PKGBUILD. Stale/etc/fonts/local.confremoved. Waybarstyle.cssfont-family corrected "Ubuntu Nerd Font"→"UbuntuSans Nerd Font" (the bar wants the explicit proportional font, not a generic). The aliases needbinding="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/PKGBUILDlooped over every user service indefaults/etc/systemd/user/*.serviceand dropped a WantedBy symlink into/etc/systemd/user/graphical-session.target.wants/. That path is global scope.moonarch-nightlightrunssystemctl --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-enabledstayedenabled, root symlink untouched. Additionally,scripts/post-install.shenabledwlsunsetby default in itsUSER_SERVICESarray, 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-usersystemctl --user --global enablefrom the .install hook, but that fights the "this is a UI toggle" framing. - How:
moonarch-pkgbuilds/moonarch-git/PKGBUILD— symlink loop now skips askip_enablelist (currentlywlsunset.service); skipped services are still installed under/etc/systemd/user/but not wanted bygraphical-session.targetat the global level.moonarch-pkgbuilds/moonarch-git/moonarch.install—pre_upgrade()deletes any pre-existing/etc/systemd/user/graphical-session.target.wants/wlsunset.serviceto clean up systems that received the old packaging.moonarch/scripts/post-install.sh—wlsunsetremoved fromUSER_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_thresholdhad been broken since 2026-04-08 (commitac2b210, "audit remediation Q-W3"). That commit addedACTION=="add"to90-moonarch-battery.rulesto "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 theaddevent (the driver creates it slightly later); the rule fires,chmodfails silently because2>/dev/nullswallows the error, and permissions are never set. The unfiltered original rule worked by accident:addfailed silently as well, but a subsequentchangeevent 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-togglereturnedPermission denied. Verified via journalctl + manual chmod: rule fires for hidpp_battery_0 (visible exit-1 errors), no trace for BAT0; manualchmod g+won 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, dispatchespkexec /usr/bin/moonarch-batsaver-apply $NEW, then signals waybar.defaults/etc/udev/rules.d/90-moonarch-battery.rulesdeleted (and the now-emptydefaults/etc/udev/parent removed). PKGBUILD: udev install line removed.moonarch-doctor: removed the udev-effectiveness check (no longer relevant).moonarch-batsaver.serviceandmoonarch-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.mdBattery-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/*.zshsnippet loop,~/.zshrc.localfallback) was wired intodefaults/shell/zshrcandscripts/post-install.sh. Not a zsh convention, not documented, redundant to the user's own~/.zshrc.post-install.shcreated the~/.zshrc.ddirectory unconditionally on every fresh install — leaving an empty directory in every user's home. (2)moonarch-doctorhad only existence checks (/etc/zsh/zshrc.moonarchexists?,/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 forcharge_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/zshrcand 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 hardcodedcheck_config_matchentries 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/*.zshsource loop and~/.zshrc.localfallback removed; second ABOUTME line that referenced them removed.scripts/post-install.sh— Zsh-block now writes~/.zshrcwith onlysource /etc/zsh/zshrc.moonarch(no mkdir, no~/.zshrc.dreference); stale "rustup default stable" hint and "User overrides in~/.zshrc.d/" hint removed from next-steps.scripts/lib.sh— deadconfirm()(orphaned since transform.sh deletion 2026-04-21) removed.scripts/moonarch-doctor— user-services and helper-scripts lists now derived frompacman -Qql moonarch-gitplus an explicit list of post-install-enabled externals (currentlystasis); useless existence checks for/etc/zsh/zshrc.moonarchand/usr/share/moonarch/removed; new udev-effectiveness check forcharge_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 viaorg.freedesktop.ScreenSaver.Inhibit. Verified against browser behavior: both browsers only raise that inhibit during fullscreen video playback (Firefox also requiresdom.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 listsr"firefox.*"ininhibit_appsfor exactly this reason; Moonarch removed it without a working substitute. - Tradeoffs: Three options. (A) Put
firefox/chromiumback intoinhibit_apps— works but inhibits whenever the browser process is running, even with zero tabs playing; wrong shape. (B) Tell users to flipdom.screenwakelock.enabledper browser — pushes per-user config, fragile across browsers. (C) Let browser MPRIS count as a media player by settingignore_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
moondocon a healthy system that it reportedParu [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 themoonarch-gitinstall 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 skippedwalker.serviceandnautilus.service, even though the PKGBUILD ships both in/etc/systemd/user/and enables them viagraphical-session.target.wantssymlinks. A silently missing walker or nautilus would not show up in diagnostics. - How: Removed the
[moonarch-pkgbuilds]check fromscripts/moonarch-doctor. Addedwalkerandnautilusto the user-service loop. UpdatedCLAUDE.mduser-services listing to match (also filled in the missingwlsunset). 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.runeby 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, noxdg/). 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 withswaylockas 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.shbefore the user-services-enable step: if~/.config/stasis/stasis.runedoes not exist and/etc/xdg/stasis/stasis.runedoes,install -Dm644copies 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-gitcannot depend on AUR packages, so every AUR package inaur.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 -siforparu(AUR source build) needsrustas makedep; neither archinstall nor post-install.sh installed it, so the restored paru bootstrap would have crashed on rust-less systems. (3)rustupsat inofficial.txt"for something we don't remember" — turned out to be a leftover;rustsuffices 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. Alternativeparu-binavoids the compile step entirely; rejected because Dominik's documented workflow usesparu(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 --neededafterparu -S moonarch-git. (2)sudo pacman -S base-devel rustbefore the paru git-clone. (3)rustupremoved fromofficial.txt(remains in PKGBUILDoptdependsfor discoverability).
2026-04-21 – Restore AUR bootstrap for paru in post-install.sh
- Who: Dominik, ClaudeCode
- Why: Commit
0726451(2026-03-29) replaced the workinggit clone https://aur.archlinux.org/paru.git && makepkg -sibootstrap withsudo 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). Alternativeparu-binwould skip the compile step; choseparuto 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 paruline inpost-install.shwith 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/moonarchclone. 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, thenparu -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.shandscripts/update.sh.lib.shstays (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-gitwas 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 (seemoonarch-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.shandtransform.shno longer writeMode = arpor a[moonarch-pkgbuilds]section to/etc/paru.conf, and no longer callparu -Syu --pkgbuilds. They runpacman -Sy+paru -S moonarch-gitagainst the registry. Themoonarch-gitinstall 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/.mobuild-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=pacmanfor strings that match upstream msgids (prompts likeProceed with installation?,Do you want to remove these packages?,[Y/n],Starting full system upgrade...), inline_t()helper for moonarch-specific strings. - How:
moonarch-updatesetsTEXTDOMAIN=pacman/TEXTDOMAINDIR=/usr/share/locale._t "en" "de"picks by${LANG%%.*}matchingde_*.confirm()now uses::prefix, default Y, acceptsy/Y/j/J. No PKGBUILD change —gettextis inbase. 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_thresholdsupport 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.confinstead of~/.config/paru/paru.confis 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.confto 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:
evalin 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_DATAin btnote. (3)--guard before$connectionin vpn nmcli calls. (4)install -dm700for moongreet cache dirs in PKGBUILD. (5)else errlogging 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 gitfor 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-mesgflags. 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/vpntype). - Tradeoffs: nm-applet adds a tray indicator (mitigated with
--indicatormode 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
nmcliactive 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-Catppuccinwhile config files hadColloid-Catppuccin— inconsistent. Grey accent matches the icon theme (Colloid-Grey-Catppuccin-Dark). Explicit-Darkvariant is more reliable than depending onprefer-darkcolor-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). CatppuccinThemesingleton, 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 aNotificationServer-based daemon (toasts + center + DND + IPC). Runs manually viaquickshell -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-managedwlsunsetProcess (toggle = process running, temperature via restart, state persisted to an XDG state file) — cleaner and loop-proof; the systemdwlsunset.serviceis 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/Privacyindicators,Backlight{,Indicator,Popout},Mpris{Widget,Popout}).PopoutPanel.panelWidthmade per-popout overridable;VolumeSlidergained areleasedsignal (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
ClientCountvia abusctlfork every 5s,runningregardless 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 monitoron GameMode'sGameRegistered/GameUnregisteredsignals + 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 withwlr/taskbar+custom/poweralready dropped from the pilot. - How: Removed
GamemodeIndicator.qmland its reference inBar.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 ashell.qmldoes 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 aqmldirin the system dir +import "file:///etc/xdg/quickshell"exposes singletons and components natively (verified:Theme.accentresolves; property-injection viaLoader.setSourceis the fallback). Scope kept to toggle + add — no per-widget override (expensive: per-slot loader + the hardcodedPopouts.qmlregistry) and no reordering/user-popouts. Toggles use JSON (Waybar-familiar, reuses theNightlightServiceFileView+JSON pattern); own widgets stay QML (unavoidable). A user widget rooted at/containingModuleBoxmust not anchor its direct child withcenterIn: parent— that hits a pre-existingchildrenRectbinding loop inModuleBox(documented in the bar's CLAUDE.md). - How: New
UserConfig.qmlsingleton reads~/.config/quickshell/moonarch.json({"widgets":{"<key>":false}}, absent/broken = all on) and probes forUserWidgets.qml. Newqmldir(generated from the.qmlfiles) declares all singletons + components for cross-dir import.Bar.qmlgates each of the 16 widgets onUserConfig.enabled("<key>")(self-hiding widgets combine it into their internalvisible; groupModuleBoxes hide when all their keys are off) and adds aLoaderslot (right cluster) bound toUserWidgets.qmlwhen 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-barswitch wrapper (config-driven bar selection) but rejected as over-engineering — Waybar is only a reserve, so a persistent setting isn't worth it; niri spawnsquickshelldirectly and Alt+W toggles quickshell ⇄ Waybar. Notifications move to quickshell's ownNotificationServer; swaync'sspawn-at-startupis commented out (not deleted) so it releasesorg.freedesktop.Notifications— flip the comment back (and drop quickshell'sNotifToastHost) to return to swaync. The quickshell config needs no PKGBUILD change (moonarch-git's genericfindloop overdefaults/xdg/already installs/etc/xdg/quickshell/,qmldirincluded); only thequickshellruntime package was added. - How:
defaults/xdg/niri/config.kdl—spawn-at-startup "quickshell"(wasmoonarch-waybar), swaync spawn commented out, Alt+W bar toggle.quickshelladded topackages/official.txtand moonarch-gitPKGBUILDdepends(Waybar + swaync stay in both — reserves, not removed). quickshell's notification stack stays active (NotifToastHostinshell.qml,NotificationIndicatorinBar.qml,notificationspopout). Verified: config loads viaqs -pwith zero binding loops;niri validatepasses.
2026-07-06 – Quickshell bar: toggle-layer → composition (named config)
- Who: Dominik, ClaudeCode
- Why: The 2026-07-05 user-extension mechanism (JSON
moonarch.json+UserConfigsingleton + per-widgetenabled()gates +UserWidgets.qmlslot) 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_HOMEis searched before$XDG_CONFIG_DIRS(src/launch/command.cpp:configBaseDirs()prepends config-home, firstshell.qmlfound 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 bindpopouts: bar.popoutsexplicitly (slightly verbose). Once a user owns theirshell.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.qmlstays a hardcoded switch; user-added popouts remain a follow-on. - How: All
defaults/xdg/quickshell/*moved into amoonarch/subfolder (generic PKGBUILD find-loop installs to/etc/xdg/quickshell/moonarch/, no PKGBUILD edit).Bar.qml→ generic container withleftContent/centerContent/rightContentslots +popoutsalias.shell.qml→ the default composition filling those slots. DeletedUserConfig.qml+moonarch.jsonhandling +UserWidgetsslot; stripped&& UserConfig.enabled(...)from the 7 self-hiding widgets (self-hide logic kept).qmldirregenerated (noUserConfig).config.kdlspawn + Alt+W →quickshell -c moonarch. Verified:qmllintclean (only quickshell C++ type-resolution artifacts), throwawayquickshell -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.qmla hardcodedswitch— 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 nativePopupWindowwould 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 centralPanels.qml+DrawerVisibilitieswas 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 aComponentinstead 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 gainspragma ComponentBehavior: Boundso the inline popoutComponentresolvesrootwhen the shared host instantiates it. - How:
PopoutState.currentName→currentComponent;open(component, rect, payload).PopoutHostrendersLoader { sourceComponent: popouts.currentComponent }.Popouts.qmldeleted,qmldirregenerated. Each of the 10 widgets declares an inlineComponent { XxxPopout { popouts: root.popouts } }(Tray also wiresmenuHandle: popouts.payload) and callsopen(thatComponent, …). Verified:qmllintclean, 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
Barowns a per-screenPopoutState, so on a multi-monitor setup a popout could stay open on screen 1 while another opened on screen 2. Worse, popoutshowndrives 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 throughopen()/PopoutHost). Added only the minimal cross-screen coordination — oneownertoken. 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.qmlsingleton withproperty var owner.PopoutState.open()setsPopoutBus.owner = root;close()clears it if still owned. Areadonly property bool ownsSlot: PopoutBus.owner === root+onOwnsSlotChangedfolds up any state that loses the slot (no loop: a foreign-close leavesowneruntouched).qmldirregenerated (addssingleton PopoutBus). Verified: throwawayquickshell -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 upfires 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'sWlrKeyboardFocus.Exclusivewhile 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 withis_focused=falseandfocused-windowstaysnull— noWindowFocusChangedfires, 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(wasExclusive), 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=qt6ctwas exported butqt6ctwas 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_PLATFORMTHEMEcannot theme both Qt versions at once (one value selects either the Qt5qt5ctplugin or the Qt6qt6ctplugin, never both).QT_STYLE_OVERRIDE=kvantumis honoured by both Qt5 and Qt6, so it is the primary mechanism;qt6ctis kept only as a Qt6 bonus for icons/palette, andqt5ctis 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 indefaults/shell/zshrc. Verified empirically (/proc/<pid>/environof 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'senvironment {}block;TERMINAL/EDITOR/XDG_*stay in zshrc. (4) Theme delivery — vendor the one Mocha-Lavender theme intodefaults/vs. the AUR package. Picked the AUR packagekvantum-theme-catppuccin-gitfor 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. Newenvironment {}block indefaults/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 fromdefaults/shell/zshrc. Newdefaults/xdg/qt6ct/qt6ct.conf(style=kvantum, icon_theme=Colloid-Grey-Catppuccin-Dark) anddefaults/xdg/Kvantum/kvantum.kvconfig(theme=catppuccin-mocha-lavender, matching the Lavender accent#b4befe). Both deploy to/etc/xdg/automatically via the moonarch-git PKGBUILD'sdefaults/xdgglob (no PKGBUILD change); not added tomoonarch-doctor— likeniri/config.kdland the GTKsettings.ini, owned/etc/xdgconfigs have no/usr/share/moonarchreference copy to compare against. Verified after rebuild (r152): Kvantum readskvantum.kvconfigfrom/etc/xdg/Kvantum/— fresh-path test (~/.config/Kvantummoved aside, a niri-spawned VLC still rendered Catppuccin), so the glob deploy is sufficient for fresh installs; no post-install /.installuser-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 — everyPwLinkGroupreportsstate = -1(PwLinkState.Unlinked;Active = 4), including a link that was demonstrably carrying audio, so thestate !== Activeguard rejected everything; tracking onlydefaultAudioSink/Source(whatAudioIndicatordoes) 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), sotype & AudioInStreamis non-zero for any playback stream; verified live: browser audio alone setmicActive. (3) Video streams are not classified at all — a pipewire camera consumer reportstype = Untracked(0),isStream = falsewhile itsmedia.classisStream/Input/Video, so both branches of the check (which requireisStream) can never match a screencast or camera. - Tradeoffs: Matching
media.classstrings is what waybar's privacy module does (Stream/Input/Audio,Stream/Input/Video,stream.monitorare literals in its binary), so this is the established signal rather than an invention — but it needsPwObjectTrackerover nodes and groups, because a bound group givesstateand only a bound node givesproperties. Excluding desktop-audio recording could not use pipewire'sstream.monitor: quickshell does not expose that property (measuredundefinedon a live monitor capture), so the peer'smedia.class(Audio/Sink) is the discriminator instead. Exact string equality also drops the…/Internalvariants (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'smedia.class, somicActive = capturing("Stream/Input/Audio", "Audio/Sink")andscreenActive = 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-recordfrom the default source → mic glyph (rendered, screenshot-checked) while browser playback ran throughout, i.e. the playback false-positive is gone;pw-recordfrom a sink monitor → still hidden;gst pipewiresrcon the camera → video glyph; back to hidden. The screencast path shares theStream/Input/Videoclass 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_WRITEdo 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 notVIDIOC_STREAMON. Together with per-node filtering (video0/video2 are capture, video1/video3 metadata), watch re-arming after unplug, and thevideogroup 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:
inotifyfiresIN_OPENfor 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 —
authorizedon the USB device beatsmodprobe -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=0drops the/dev/videoNnodes immediately, udev emitsremoveper node, the PipeWire node disappears;authorized=1brings the nodes back,uvcvideorebinds 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-1carries01/01,01/02) and the internal camera's DFU interface too (5-1carriesfe/01, so fwupd cannot update it while off) — surfaced in the popout. USB-only by construction: MIPI/IPU6 cameras have noauthorized, 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 whileauthorized=0, whereas the interface directories drop from 5 to 0 — so they cannot be the gate.bDeviceClassis useless as well (0xefon every composite camera).moonarch-camera-listcorrectly reported the switched-off camera asoff, i.e. it stays listable and switchable with no state of our own. Verified across 21 USB devices that interface class0x0eselects exactly the two cameras (hubs09, HID03, audio01, AV adapter11). Gotcha:wc -creports 65553 for that file on every device — the real length is 1420 / 1332 bytes here, so the TLV walk must stop onbLength == 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 bywheel, so the privileged helper would have been validating its target against a caller-writable allowlist. It now trusts only kernel data:descriptors,DEVTYPE=usb_devicefromuevent, and theauthorizedsymlink 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 onaddwould 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 settleonly waits for already-queued events, so a camera enumerating very late behind a dock can stay on until switched. (5) Auth — plainpkexecwith the standard password prompt, asmoonarch-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 theOnDemandkeyboard focus fix (2026-07-09). - How: Four scripts in
defaults/bin/—moonarch-camera-list(unprivileged; printsid|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,authorizedis 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 unitdefaults/etc/systemd/system/moonarch-camera.service(Type=oneshot,ConditionPathExistsGlob=/sys/class/video4linux/video*, batsaver's hardening block, enabled frommoonarch.install;Type=oneshotalso satisfiesmoonarch-doctor's oneshot handling). QML trioCameraService/CameraIndicator/CameraPopout: the service keeps aprobedflag so a failed list never empties the bar widget, an optimisticpendingmap so a row reacts before the password prompt returns, a single in-flightpkexecso prompts cannot stack, and treats exit 126 as a silent cancel. Itsudevadm monitorwatches the usb subsystem, notvideo4linux— 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, whileauthorized=1re-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 at3-2.2reports01,01,01,01,01,03), so a class filter would lock out legitimate hardware, while the attack needs physical access to a port. Insteadmoonarch-camera-listnow 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.PlainTexton the device-supplied label and in the sharedTooltip(matchingNotifToast/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) underLC_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 ofCapabilityBoundingSet/SystemCallFilterin the unit, which is inherited frommoonarch-batsaver.serviceand 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+DayOfWeekRowfromQtQuick.Controlsrather than a hand-rolled grid: they carry the locale rules (week start, ISO week numbers, weekday names) that would otherwise be re-implemented, andQtQuick.Controlsis already imported byTrayMenu(StackView), so no new dependency. Only the delegates are ours, so the Catppuccin look is unaffected. (2) Click action — theshowYeartoggle 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(), sode_DEgives Monday-first + ISO weeks and anen_USinstall gets its own convention; the title usesstandaloneMonthNamebecause 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 fillcontentWidthexactly; header actions ‹ / calendar-today / ›, ←/→ page months,onShownChangedsnaps back to today since the instance survives reopens).Clock.qmlgains the standard popout wiring (popoutName: "calendar",register,toggleByName) pluspragma ComponentBehavior: Bound, losesshowYear, and takes the full bar height onheight—Text.implicitHeightis read-only (content-derived) and assigning it fails the config load.shell.qmlpassespopouts: bar.popoutstoClock;qmldirregenerated. No niri keybind added (the name is IPC-addressable, a binding is a separate call). - Verified:
qmllintclean; throwawayquickshell -p …/moonarchreachedConfiguration Loadedwith no binding loops (only the expectedorg.freedesktop.Notificationscollision 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. Glyph0xf00f6confirmed as calendar-today by rendering the MDI range with ImageMagick. That a derivedComponent.onCompleteddoes not replace the base handler (soPopoutPanel.focusFirststill runs) was verified with a separate exit-code probe.