Qt apps (e.g. VLC) rendered in the default Fusion style — GTK theming
does not cover Qt. Add the Kvantum style engine + Catppuccin Mocha
Lavender theme, wired via QT_STYLE_OVERRIDE, which covers both Qt5 and
Qt6 (QT_QPA_PLATFORMTHEME can select only one plugin at a time).
Also move all GUI-toolkit env vars (QT_*/GDK/SDL/CLUTTER) from zshrc to
niri's environment{} block: niri is systemd-started, so zsh exports
never reached niri-spawned GUI apps (verified via /proc/environ), only
terminal-launched ones — the app launcher included.
- packages: kvantum, kvantum-qt5, qt6ct + kvantum-theme-catppuccin-git (AUR)
- niri config.kdl: environment{} block; zshrc: GUI-toolkit vars removed
- qt6ct.conf (style=kvantum, icon_theme=Colloid), Kvantum/kvantum.kvconfig
(theme=catppuccin-mocha-lavender, the Lavender accent)
- CLAUDE.md + DECISIONS.md
17 KiB
Moonarch
Reproducible Arch Linux setup based on archinstall + post-install automation.
Project Structure
config/— archinstall configuration (incl. custom-commands that clone the repo to /opt/moonarch, root-owned)scripts/— post-install and helper scriptspackages/— package lists (official + AUR), maintained separatelydefaults/— XDG configs, shell config, helper binaries, systemd services, udev rules, greetd/moongreet config, wallpaper
Battery Conservation Mode
Laptops with charge_control_end_threshold support (ThinkPad, Framework, etc.) get a Waybar toggle:
- Clicking the battery module toggles the charge limit between 80% and 100%
- When conservation is active, a ♥ icon appears next to the battery indicator
- State is persisted in
/var/lib/moonarch/batsaver-thresholdand restored on boot via a systemd service (moonarch-batsaver-restore) - Toggle flow:
moonarch-batsaver-toggle(user script) reads sysfs, decides 80↔100, callspkexec /usr/bin/moonarch-batsaver-apply $NEWfor the privileged sysfs+state write step. Standard pkexec prompt (password once per session cache) - On desktops without battery support the feature is hidden entirely
Nightlight (Blue Light Filter)
Waybar toggle for wlsunset (Wayland-native blue light filter), persistent state via systemd:
wlsunset.service(systemd user service) withAfter=kanshi.service— starts only once all outputs are configured- Default OFF — fresh installs start without the filter. The PKGBUILD deliberately creates NO symlink for
wlsunsetin/etc/systemd/user/graphical-session.target.wants/, and post-install.sh does not enable the service. - Clicking the nightlight module in
group/brightnesstoggles wlsunset on/off (enable --now/disable --now) - State survives reboots (user-scope symlink in
~/.config/systemd/user/...wants/) - Active state shows in Catppuccin Yellow, inactive in the default text color
- Signal SIGRTMIN+11 for immediate Waybar refresh — the
ExecStartPostpkill has a leading-(ExecStartPost=-/usr/bin/pkill …) so a missing Waybar (e.g. a quickshell-only session) doesn't fail the unit. Without it, pkill's exit 1 tripsRestart=on-failureand wlsunset loops, flickering the screen. - Scripts:
moonarch-nightlight(toggle),moonarch-waybar-nightlight(status JSON) - Important: Never create a global-scope symlink at
/etc/systemd/user/...wants/wlsunset.service— it overrides any userdisableand makes the filter effectively impossible to turn off.
Waybar Config Merger (moonarch-waybar)
Waybar is started via moonarch-waybar (not directly). The wrapper merges an optional user config (~/.config/waybar/userconfig) with the system config (/etc/xdg/waybar/config):
prepend/appendkeys in the userconfig extend themodules-left/modules-center/modules-rightarrays- All other top-level keys are inserted as module definitions via object merge
- The merge runs only on changes (timestamp comparison)
- On error:
notify-send+logger, Waybar starts with the system config - Generates
~/.config/waybar/style.csswith an@importof the system styles if not present - Requires
jq(declared as a dependency in the PKGBUILD) - The system config must be valid JSON (no JSONC)
Quickshell Bar
quickshell is the default bar and notification daemon, spawned by niri as quickshell -c moonarch (defaults/xdg/niri/config.kdl). It is shipped as a named config at /etc/xdg/quickshell/moonarch/ — the officially recommended way to distribute a quickshell config as a distro package ($XDG_CONFIG_DIRS/quickshell/<name>). Both Waybar and swaync stay fully installed as reserves (no hard deletes) but are no longer autostarted: Waybar toggles via Alt+W (pkill quickshell ⇄ moonarch-waybar, restart is quickshell -c moonarch); swaync's spawn-at-startup is commented out so it releases org.freedesktop.Notifications for quickshell's own NotificationServer. To switch back: re-enable swaync's spawn and drop quickshell's NotifToastHost from shell.qml.
Customization = composition, not a toggle layer. quickshell has no native settings/toggle/override/merge concept (verified) — a config is composable QML. moonarch ships the widgets as an importable component library; the composition lives in shell.qml.
-
Customize — copy
/etc/xdg/quickshell/moonarch/shell.qmlto~/.config/quickshell/moonarch/shell.qmland edit it: add/remove/reorder widgets. The user copy wins by XDG precedence ($XDG_CONFIG_HOMEis searched before$XDG_CONFIG_DIRS; verified in quickshellsrc/launch/command.cpp—configBaseDirs()prepends config-home, firstshell.qmlfound wins). System component updates still flow via the import; structural updates to your ownshell.qmldo not. -
Bar.qmlis a generic container exposing three composition slots —leftContent/centerContent/rightContent(assign a list of widgets; they reparent into the cluster) — plus apopoutsalias so composed widgets bindpopouts: bar.popouts.shell.qmlfills the slots; that is the whole composition:import QtQuick import QtQuick.Layouts import Quickshell // ShellRoot, Variants, Quickshell.screens import "file:///etc/xdg/quickshell/moonarch" // Theme + Bar/ModuleBox/… via the qmldir ShellRoot { Variants { model: Quickshell.screens Bar { id: bar rightContent: [ ModuleBox { Layout.alignment: Qt.AlignVCenter; Clock {} } ] } } }ModuleBoxwraps its content in aRow(and self-hides when its content collapses to zero width). Do not anchor a direct child withanchors.centerIn: parent— aRowrejects horizontal child anchors with a runtime warning (QML Row: Cannot specify … centerIn anchors for items inside Row) and ignores them. There is no binding loop (theRowsizes from children's implicit sizes, notchildrenRect), but the centering silently does nothing. Center vertically withanchors.verticalCenter: parent.verticalCenterinstead. -
qmldir exposes the singletons/components for the cross-dir
import. It is generated from the.qmlfiles; regenerate when adding components:cd defaults/xdg/quickshell/moonarch && for f in *.qml; do [ "$f" = shell.qml ] && continue; n=${f%.qml}; grep -q 'pragma Singleton' "$f" && echo "singleton $n $f" || echo "$n $f"; done | sort > qmldir -
Own popouts: a composed widget carries its own popout as a
Componentand renders it in the shared morphing host. Pattern (see any indicator, e.g.AudioIndicator.qml): an inlineComponent { AudioPopout { popouts: root.popouts } }; the widget declares apopoutNameand registers on completion (popouts.register(popoutName, popoutComponent, root, payloadFn)), then its click handler callspopouts.toggleByName(popoutName). The widget file needspragma ComponentBehavior: Boundso the popout resolvesrootwhen the shared host instantiates it. The low-levelpopouts.open(component, rect, payload)still exists for non-registered popouts (Tray). The host anchors the popout body to the trigger's on-screen box and picks the side from it: left-aligned when the trigger sits in the left half of the screen (so left-cluster popouts grow rightward and stay on-screen), right-aligned otherwise (PopoutHost.qmlframe.leftAnchored). The anchor rect carries the trigger's left edge + width; bothopenByNameand Tray's directopen()pass that box. -
Popouts by keybind: registered popouts are addressable by name through the
PopoutRoutersingleton, which exposes anIpcHandler { target: "popout" }withtoggle/open/close. A niri keybind firesqs -c moonarch ipc call popout toggle <name>(e.g.Mod+A→audio, indefaults/xdg/niri/config.kdl). The router routes to the focused monitor'sPopoutStateviaNiri.activeOutput(workspaceis_focused→output, matched againstscreen.name); an open popout grabs the keyboard (Escape closes). Names:audio backlight network bluetooth nightlight cpugov battery notifications media launcher. Tray is not keybind-addressable. -
Launcher:
Launcher.qmlis the first widget in the left cluster (an apps glyph) and ownsLauncherPopout.qml— an application launcher overDesktopEntries.applications(search field + keyboard-navigable icon+name list; ↑/↓ select, Enter/click launch viaentry.execute(), Esc close; app icons viaQuickshell.iconPath). It registers underlauncher(so it is IPC-addressable) and is bound toMod+Space(qs -c moonarch ipc call popout toggle launcher, inconfig.kdl) in addition to icon-click. Scope is app-launch (drun) only;walkerstays installed and keeps the other modes —Super+Cstill opens walker's clipboard, and walker remains the run-command/calc launcher (no longer on a keybind). -
Hover affordance: clickable bar widgets dim to
opacity: 0.8on hover (110msanimDurationShortfade) so they read as interactive. Deliberately opacity, not color — the icon color already encodes status (muted/off →subtext0, active →accent, battery critical → red), so a color-based hover would clobber the state signal. The Launcher is the exception: no status color, so it uses anaccenthover instead. Applied per widget (no shared parent to hoist it to);Workspaces/Traykeep their own per-element hover, non-clickablePrivacyIndicator/WindowTitleget none. -
Network indicator + VPN: quickshell's Networking API exposes no VPN, so
NetworkService.qml(singleton) holds the VPN connection list via nmcli — event-driven through a long-runningnmcli monitorprocess (debounced refresh) plus a 60s fallback poll; shared byNetworkIndicatorandNetworkPopout. The indicator shows connecting (cycling strength glyphs), an active VPN (MDIwifi-strength-N-lockvariants; wired+VPN →security-network), and wired (ethernetglyph, beats wifi-off in priority so ethernet-only machines don't show a dead wifi icon). -
Keyboard navigation: an open popout grabs the keyboard, so it is fully operable without the mouse. Controls (
Toggle,ListRow,VolumeSlider,IconButton) setactiveFocusOnTaband show a focus ring;PopoutPanelfocuses the first control on open and maps ↑/↓ onto Qt's native Tab chain (nextItemInFocusChain+ anactiveFocusOnTabfilter). Keys: Tab/↑/↓ move focus, ←/→ nudge a slider ±5%, Space/Enter activate, Esc closes. Icon actions use the sharedIconButton(destructive ones setactiveColor: Theme.red); Rectangle-shaped buttons (clear-all, notification actions) are made focusable inline.
Rationale + spike results: DECISIONS.md (2026-07-05 pilot + user-extension, 2026-07-06 composition + composable popouts, 2026-07-08 keybind registry + IPC router, keyboard-navigable controls; launcher widget + side-aware popout anchoring).
mpv + ModernZ OSC
The video player is mpv with ModernZ as the OSC, thumbnails via thumbfast:
mpv-modernz-gitprovidesmodernz.lua+ font + default config to/etc/mpv/mpv-thumbfast-gitprovidesthumbfast.luato/etc/mpv/scripts/(auto-detected by ModernZ)defaults/etc/mpv/mpv.confis installed directly to/etc/mpv/mpv.confby moonarch-git (owned)- Stock OSC + title bar disabled,
autofit-larger=80%x80%caps oversized windows - ModernZ overrides via
script-opts-appendin mpv.conf: orange accent → Catppuccin Lavender (#b4befe), OSC scale 0.75,window_title_font_size=18,ontop_button=no - Important: mpv treats
#as a mid-line comment; hex colors must be quoted:script-opts-append="modernz-seekbarfg_color=#b4befe"(not\#, which only escapes and swallows the rest) - Niri opens mpv floating (
window-ruleindefaults/xdg/niri/config.kdl)
System Health Check (moonarch-doctor / moondoc)
Diagnostic script that checks the system state against moonarch defaults:
- Packages (official.txt + aur.txt installed? Orphans?)
- System services (NetworkManager, bluetooth, greetd, ufw, auto-cpufreq, etc.)
- User services (kanshi, wlsunset, stasis, walker, nautilus, cliphist-text, cliphist-image)
- Config files (SHA256 comparison deployed vs. moonarch default)
- Helper scripts + symlinks (moonup, moondoc)
- System config (UFW, pacman/paru repos, default shell)
- Directories + permissions
Fontconfig Defaults
System-wide generic-family defaults via defaults/etc/fonts/conf.d/65-moonarch-fonts.conf (owned by moonarch-git):
sans-serif→ UbuntuSans Nerd Font,monospace→ UbuntuSansMono Nerd Font- Number 65: loads after
60-latin.conf, so the moonarch prefs beat the stock default (Noto/DejaVu).local.conf(at 51 via51-local.conf) loads too early and is overridden by 60-latin — therefore do not use it (it is also reserved for local user overrides). - Aliases need
binding="strong"— a weak<prefer>(fontconfig default) ranks behind the effective generic fallback and does not take effect. - Only applies to apps that use generic families (e.g. Firefox web fallback). moonarch apps (Waybar, foot, GTK, walker, swaync) set the font explicitly.
Qt Theming (Catppuccin via Kvantum)
Qt apps (e.g. VLC) get the Catppuccin Mocha look via the Kvantum style engine — GTK theming (Colloid) does not cover Qt, and the Qt5 GTK-follow path (qt5-styleplugins) is unmaintained and not in the official repos.
- Style —
QT_STYLE_OVERRIDE=kvantumis the workhorse. A single value themes both Qt5 (VLC 3) and Qt6.QT_QPA_PLATFORMTHEMEcannot: one value selects either the Qt5qt5ctplugin or the Qt6qt6ctplugin, never both. The style override sidesteps that, so we shipkvantum+kvantum-qt5(Qt6 + Qt5 style plugins) and skipqt5ct. - Theme:
kvantum-theme-catppuccin-git(AUR) installs all flavors to/usr/share/Kvantum/;defaults/xdg/Kvantum/kvantum.kvconfigselectscatppuccin-mocha-lavender(matches the ecosystem Lavender accent#b4befe). - Qt6 icons/palette:
QT_QPA_PLATFORMTHEME=qt6ct+defaults/xdg/qt6ct/qt6ct.conf(icon_theme=Colloid-Grey-Catppuccin-Dark). Qt5/VLC relies on the style override alone (no platform theme). - Env location — must be niri, not zshrc. niri is started by systemd, so niri-spawned GUI apps (app launcher,
spawn,spawn-at-startup) never inherit zsh exports — only terminal-launched apps would (verified via/proc/<pid>/environ). All GUI-toolkit vars therefore live in niri'senvironment {}block (defaults/xdg/niri/config.kdl), notdefaults/shell/zshrc; zshrc keeps only shell-oriented (TERMINAL/EDITOR) +XDG_*session vars. - Fonts come from the fontconfig generic-family defaults (see below); qt6ct leaves fonts unset so they resolve through fontconfig.
Rationale + rejected alternatives: DECISIONS.md (2026-07-15).
Browser Idle-Inhibit (xdg-desktop-portal)
So that windowed browser video (Firefox/Waterfox) keeps the screen awake, via defaults/etc/xdg-desktop-portal/niri-portals.conf (owned by moonarch-git, higher priority than niri's /usr/share/xdg-desktop-portal/niri-portals.conf):
xdg-desktop-portal-gtkreports theInhibitinterface as success even though nobody implements it under Niri → Firefox believes the idle-inhibit went through the portal and does not use the native Waylandidle-inhibit. Result: no inhibitor, screen sleeps.- Fix:
org.freedesktop.impl.portal.Inhibit=none→ Firefox falls back tozwp_idle_inhibit, which Niri honors. The remaining[preferred]lines are taken 1:1 from niri's default (portals.conf is not merged — the highest-priority file applies in full). - stasis is uninvolved here:
monitor_media(pactl) does not capture browser audio by design (non-browser players only); browsers go through the inhibit path. - Activation: restart xdg-desktop-portal + the browser — Firefox queries portal support at startup.
Windowed / Muted Video: wayland-pipewire-idle-inhibit
The portal fix only applies to fullscreen video — Firefox/Waterfox send the idle-inhibit only in fullscreen. Windowed video remains unprotected: stasis ignores browser audio (pactl, browser-excluded, no MPRIS backend), Niri gets no inhibitor, the screen sleeps mid-video.
Solution: wayland-pipewire-idle-inhibit (AUR, systemd user service; in packages/aur.txt + post-install.sh USER_SERVICES + moonarch-doctor). Holds a Wayland zwp_idle_inhibit inhibitor while PipeWire outputs audio (default threshold 5s). Niri then suppresses idle → stasis stays awake, daemon-independent. Releases automatically when audio stops.
- Catches windowed video with sound. Misses muted video (no audio signal) — edge case, accepted.
- Never put
waterfox/a browser in stasisinhibit_apps: that inhibits unconditionally as long as the browser process runs → the system never idles. This exact wrong fix caused the "no idle" bug.
Conventions
- Package lists are plain text files, one package per line, comments with
# - Shell scripts must be POSIX-compatible or explicitly bash/zsh
- All paths in the archinstall config are relative to the install target