Files
moonarch/CLAUDE.md
T
nevaforget e9e12cc280 fix(swaylock): ship config to /etc/swaylock, blur the background
swaylock reads ~/.swaylock/config, $XDG_CONFIG_HOME/swaylock/config and
SYSCONFDIR/swaylock/config — never /etc/xdg/, where the generic XDG sweep
put it. The config was made effective by seeding it into user homes, but
both seeds are conditional, so a machine that pulled no moonarch-git
update since swaylock arrived ran with no config at all: no wallpaper,
no Catppuccin ring.

The config now lives in defaults/etc/swaylock/config and is owned at
/etc/swaylock/config, which the package alone makes effective. Home
copies still win by search order, so the .install hook retires the
pristine ones; an edited copy stays.

Upstream swaylock has no blur (effect-blur is swaylock-effects only), so
the blur is baked into a second image: wallpaper-blur.jpg, generated with
downsample-blur-upsample. Regenerate it when the wallpaper changes.

moonarch-doctor gains check_swaylock_config, which names the config file
that actually wins and fails when none exists.
2026-08-18 17:33:28 +02:00

31 KiB
Raw Blame History

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 scripts
  • packages/ — package lists (official + AUR), maintained separately
  • defaults/ — XDG configs, shell config, helper binaries, systemd services, udev rules, greetd/regreet config, swaylock config, wallpaper

Lock Screen (swaylock)

  • Config path is not XDG. swaylock searches ~/.swaylock/config, $XDG_CONFIG_HOME/swaylock/config and SYSCONFDIR/swaylock/config (/etc/swaylock/config on Arch) — never /etc/xdg/. Verified by running swaylock against a throwaway HOME containing an invalid option: it rejects the option before it even connects to the compositor, and reports nothing at all when the file sits in /etc/xdg/. The config therefore lives in defaults/etc/swaylock/config and is installed by an explicit install line in the PKGBUILD, deliberately outside the generic defaults/xdg//etc/xdg/ sweep.
  • No home seeding. Owning /etc/swaylock/config makes the config effective through the package alone. The earlier approach seeded it into /etc/skel and existing homes from the .install hook; that hook only fires on a moonarch-git install/upgrade and only under [ ! -f ], so a machine that pulled no update since swaylock arrived ran entirely unconfigured — no wallpaper, no Catppuccin ring. pre_upgrade retires those copies, but only the pristine ones (cmp against the still-old /etc/xdg/swaylock/config); an edited copy stays and keeps winning, which is what the home path is for. moonarch-doctor reports which file actually wins.
  • Blur is baked into the image. Upstream swaylock has no blur — effect-blur exists only in the swaylock-effects fork, which is stuck on 1.7 upstream. So defaults/backgrounds/wallpaper-blur.jpg ships next to the plain wallpaper, generated once with magick wallpaper.jpg -resize 25% -blur 0x12 -resize 4717x2984! -quality 88 -strip wallpaper-blur.jpg. Downsample-blur-upsample rather than a large-radius blur at full resolution: same look, and the file compresses to 167 KB against the original's 374 KB. Regenerate it whenever the wallpaper changes — nothing derives it automatically.
  • NixOS reads a different path. nixpkgs leaves meson's sysconfdir at $out/etc, so /etc/swaylock/config is never consulted there. moonix keeps its own systemd.user.tmpfiles seed into ~/.config/swaylock/config (moonix/modules/desktop.nix) and substitutes the wallpaper path for a store path — that seed is why moonix is unaffected by the change above.

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-threshold and restored on boot via a systemd service (moonarch-batsaver-restore)
  • Toggle flow: moonarch-batsaver-toggle (user script) reads sysfs, decides 80↔100, calls pkexec /usr/bin/moonarch-batsaver-apply $NEW for the privileged sysfs+state write step. Standard pkexec prompt (password once per session cache)
  • On desktops without battery support the feature is hidden entirely

Camera Switch

Cameras are off after every boot and get switched on deliberately, per camera, from the quickshell bar. That inverts the privacy problem instead of chasing it: a camera that is not authorized cannot be opened at all, so no usage heuristic is needed (PrivacyIndicator can only see PipeWire capture — see its own section and DECISIONS.md 2026-07-29).

  • Mechanism: authorized on the USB device (/sys/bus/usb/devices/<id>/authorized). Writing 0 removes the /dev/videoN nodes and the PipeWire node; writing 1 brings them back and uvcvideo rebinds itself. USB-only by construction — a MIPI/IPU6 built-in camera has no authorized attribute, so the module self-hides there and the feature does not exist on such machines.
  • Identification: a camera is a USB device whose descriptor blob carries interface class 0x0e (video). Read from /sys/bus/usb/devices/<id>/descriptors, which is world-readable and — measured — stays complete and unchanged while the device is deauthorized (1420 bytes, same interface classes, on and off). The interface directories are the opposite: they drop to zero while off, which is why they cannot be the source. bDeviceClass is useless here too (every composite camera reports 0xef). wc -c on that file lies (reports 65553 for every device); walk the TLV and stop on bLength == 0.
  • Scripts: moonarch-camera-list (unprivileged, prints id|vid:pid|label|on/off|video[+audio]), moonarch-camera-toggle <id> <0|1> (unprivileged, dispatches via pkexec), moonarch-camera-apply (privileged, re-derives every precondition from kernel data), moonarch-camera-boot (run by moonarch-camera.service at boot).
  • No state file. The absence of persisted state is the feature — nothing to restore, nothing to tamper with. An earlier draft kept a registry of known cameras in /var/lib/moonarch/; that directory is group-writable by wheel, so a privileged helper validating against it would be trusting a caller-writable allowlist. The helper now trusts only descriptors, uevent (DEVTYPE=usb_device) and the resolved path under /sys/devices/.
  • A hot-plugged camera stays on. No udev rule: plugging a camera in is a deliberate act, and a rule on add would leave a fresh webcam dead until a password prompt. The boot service is the only enforcement point. Known gap: udevadm settle only waits for already-queued events, so a camera that enumerates very late (behind a dock) can stay on after boot until switched.
  • A switch cuts the whole USB device, in both directions: a webcam's built-in microphone goes down with its camera (and on the internal ThinkPad camera, its DFU interface — fwupd cannot update it while off), and switching one on authorizes every other function that device carries. moonarch-camera-list therefore reports all notable interface classes, and the popout names them per row ("also audio"), in Theme.red when a device brings something that has no business on a camera — input, storage, network, smartcard, wireless, printer. Deliberately shown, not blocked: a strict allow-list would reject legitimate hardware (the internal camera carries a DFU interface, and multimedia devices commonly expose HID for their buttons), while the deception this defends against needs someone to physically plug a device in — so the honest fix is making the device's real capabilities visible at the moment of arming it.
  • Widget: CameraIndicator sits in the connectivity ModuleBox; md-webcam in Theme.accent when any camera is armed, md-webcam-off in Theme.subtext0 when all are off. Deliberately not Theme.red — red is the PrivacyIndicator's colour for actual capture, and armed-but-idle is the weaker statement. CameraPopout (registered as camera) lists one Toggle per camera; a switch in flight dims the row via opacity rather than enabled: false, which would drop the control out of the Tab chain. Only one pkexec runs at a time so prompts cannot stack.

Rationale + measurements: DECISIONS.md (2026-07-29).

Nightlight (Blue Light Filter)

Waybar toggle for wlsunset (Wayland-native blue light filter), persistent state via systemd:

  • wlsunset.service (systemd user service) with After=kanshi.service — starts only once all outputs are configured
  • Default OFF — fresh installs start without the filter. The PKGBUILD deliberately creates NO symlink for wlsunset in /etc/systemd/user/graphical-session.target.wants/, and post-install.sh does not enable the service.
  • Clicking the nightlight module in group/brightness toggles 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 ExecStartPost pkill 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 trips Restart=on-failure and 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 user disable and 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/append keys in the userconfig extend the modules-left/modules-center/modules-right arrays
  • 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.css with an @import of 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 quickshellmoonarch-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.qml to ~/.config/quickshell/moonarch/shell.qml and edit it: add/remove/reorder widgets. The user copy wins by XDG precedence ($XDG_CONFIG_HOME is searched before $XDG_CONFIG_DIRS; verified in quickshell src/launch/command.cppconfigBaseDirs() prepends config-home, first shell.qml found wins). System component updates still flow via the import; structural updates to your own shell.qml do not.

  • Bar.qml is a generic container exposing three composition slots — leftContent / centerContent / rightContent (assign a list of widgets; they reparent into the cluster) — plus a popouts alias so composed widgets bind popouts: bar.popouts. shell.qml fills 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 {} } ]
            }
        }
    }
    

    ModuleBox wraps its content in a Row (and self-hides when its content collapses to zero width). Do not anchor a direct child with anchors.centerIn: parent — a Row rejects 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 (the Row sizes from children's implicit sizes, not childrenRect), but the centering silently does nothing. Center vertically with the pattern described under Geometry and centering below.

  • Geometry and centering. Theme.moduleHeight (24) is the primary vertical unit and barHeight is derived from it: barHeight = moduleHeight + moduleMarginV * 2. Set the bar height via moduleMarginV (6 → 36 px, 8 → 40 px); the module pills stay put. Do not reintroduce the reverse coupling (pills sized as barHeight - moduleMarginV * 2) — since the margin is absolute, every bar-height change then shrinks the pills by the same amount.

    • Only ModuleBox takes moduleHeight. Widgets keep implicitHeight: Theme.barHeight on purpose: ModuleBox has no clip, so the widget item overflows the pill and keeps the full bar height clickable. The bar sits at the display edge, which stops the pointer — the topmost pixels have to be live, so never size a widget item down to the pill.
    • Center glyphs with height: parent.height + verticalAlignment: Text.AlignVCenter, not anchors.verticalCenter. The latter centers the Text layout box (ascent + descent) and rounds down on an odd layout height, which put several bar glyphs 1 px high; verticalAlignment centers by font metrics. Where a widget also centers horizontally (Launcher, workspace dots) use anchors.fill: parent plus both alignment properties. A widget whose root is a Row needs an explicit height before its children can align inside it (see WindowTitle.qml).
    • Image-based content (tray icons, the window-title icon) has no verticalAlignment; residual asymmetry there lives in the icon file, not in the layout.
    • Verify with pixels, not by eye: grim -g "<x>,0 <w>x<barHeight>" plus an ImageMagick -threshold … -trim bounding box over an element's full width — a single-column sample is valid for a pill rectangle but not for a glyph.
  • qmldir exposes the singletons/components for the cross-dir import. It is generated from the .qml files; 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 Component and renders it in the shared morphing host. Pattern (see any indicator, e.g. AudioIndicator.qml): an inline Component { AudioPopout { popouts: root.popouts } }; the widget declares a popoutName and registers on completion (popouts.register(popoutName, popoutComponent, root, payloadFn)), then its click handler calls popouts.toggleByName(popoutName). The widget file needs pragma ComponentBehavior: Bound so the popout resolves root when the shared host instantiates it. The low-level popouts.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.qml frame.leftAnchored). The anchor rect carries the trigger's left edge + width; both openByName and Tray's direct open() pass that box.

  • Popouts by keybind: registered popouts are addressable by name through the PopoutRouter singleton, which exposes an IpcHandler { target: "popout" } with toggle/open/close. A niri keybind fires qs -c moonarch ipc call popout toggle <name> (e.g. Mod+Aaudio, in defaults/xdg/niri/config.kdl). The router routes to the focused monitor's PopoutState via Niri.activeOutput (workspace is_focusedoutput, matched against screen.name); an open popout grabs the keyboard (Escape closes). Names: audio backlight network bluetooth nightlight cpugov battery notifications media launcher camera calendar clipboard power. Tray is not keybind-addressable.

  • Close on focus loss: an open popout folds up as soon as the focus leaves it. Three signals, all of them needed: (1) the host surface losing the keyboard — PopoutHost reads Qt's Window.active, since Quickshell's PanelWindow exposes no focus state of its own; this is the only signal that catches a focus grab by another layer surface. (2) niri focusing an actual window (Niri.focusedWindowId >= 0). The >= 0 matters: niri sets window focus to none while the popout holds the keyboard (Window focus changed: None on the event stream), so reacting to every change would make each open close itself. (3) The focused workspace changing (Niri.focusedWorkspaceId) — workspace ids are unique across outputs, so this covers a monitor switch as well; a workspace switch does not necessarily take the keyboard off the popout, which is why (1) does not subsume it. PopoutHost.sawFocus gates the first Window.active == false, which arrives in the gap between mapping the surface and the compositor handing focus over.

    • No exception for auth prompts. A popout action that raises a pkexec or keyring prompt (camera, cpugov, batsaver, VPN) loses the focus to it and therefore closes the popout; the resulting state shows on the next open. That is a deliberate choice for one rule over a special case — the OnDemand keyboard focus in PopoutHost still exists so such a prompt can become typeable at all.
  • Launcher: Launcher.qml is the first widget in the left cluster (an apps glyph) and owns LauncherPopout.qml — an application launcher over DesktopEntries.applications (search field + keyboard-navigable icon+name list; ↑/↓ select, Enter/click launch via entry.execute(), Esc close; app icons via Quickshell.iconPath). It registers under launcher (so it is IPC-addressable) and is bound to Mod+Space (qs -c moonarch ipc call popout toggle launcher, in config.kdl) in addition to icon-click. Scope is app-launch (drun) only; walker stays installed and keeps the other modes (run-command, calc, files), but has no keybind of its own left — what still calls it is dmenu duty for the Waybar-reserve scripts (moonarch-sink-switcher, moonarch-cpugov, moonarch-vpn), which is why the package cannot be dropped.

  • Clipboard history: ClipboardPopout.qml (registered as clipboard, bound to Super+C) replaces walker's clipboard provider. Backend stays cliphist — the popout shells out to it: list on every open (cliphist has no change signal, so the open is the refresh point), decode <id> | wl-copy on Enter/click, and delete with the entry's raw list line on stdin for Ctrl+D. Every call carries -db-path $XDG_RUNTIME_DIR/cliphist/db, because the history lives in the session db that cliphist-text.service creates, not in cliphist's ~/.cache default. All shell arguments are passed positionally (sh -c '… "$1" "$2"' sh a b), so no clipboard content ever becomes part of a script.

    • Ctrl+D, not Del: focus sits in the search field, where Del is a character delete. Same binding walker's clipboard provider used. The popout spells its keys out in a footer hint because a destructive shortcut is not guessable.
    • Images are shown as images: cliphist's preview for them is only a [[ binary data 6 KiB png 128x128 ]] placeholder, so the entry is decoded to $XDG_RUNTIME_DIR/moonarch-clip/<id>.<type> (extension from the placeholder's type token, so QImageReader never has to guess) and rendered in a taller row, with the placeholder recombined into a readable png · 128x128 · 6 KiB label. One decode Process per row — a shared one would serialize the rows — started from the delegate, so only rows that exist decode. Deleting an image entry removes its thumbnail with it; the rest goes with the session, since that directory is tmpfs like the db.
    • No bar widget. The popout is keybind-only, but PopoutState.register needs an on-screen item for the anchor rect, so it registers from Launcher.qml and unfolds under the launcher glyph. That also keeps shell.qml — and every user copy of it — unchanged.
    • History does not survive a session, and that is backend, not popout: the db sits in XDG_RUNTIME_DIR (tmpfs) and cliphist-text.service runs cliphist wipe as ExecStartPre.
  • Calendar: clicking the clock opens CalendarPopout.qml (registered as calendar) — a MonthGrid with a WeekNumberColumn, both from QtQuick.Controls (already pulled in by TrayMenu). Today's cell is an accent rectangle with base text; leading/trailing days stay visible in Theme.surface2 because the grid always renders six weeks and hiding them would leave the week-number column pointing at blank rows. Header actions page the month ( / ) and snap back to today (calendar glyph); ←/→ page as well, which no other control in this popout claims. Week start and weekday/month names come from the system locale (Qt.locale()), so de_DE yields Monday-first and ISO week numbers; the title uses standaloneMonthName because some locales inflect the month inside a full date. The popout instance survives reopens, so onShownChanged snaps back to the current month on every open. Two implementation notes: the clock's root is a Text, whose implicitHeight is read-only (derived from content) — the full bar height goes on height, which is what keeps the topmost pixels clickable and anchors the popout below the bar; and a derived Component.onCompleted adds a handler rather than replacing the base one (verified with an exit-code probe), so PopoutPanel's focusFirst still runs alongside the popout's own init. The clock's former click action (long/short date toggle) was dropped — the popout and the tooltip both carry the full date.

  • Hover affordance: clickable bar widgets dim to opacity: 0.8 on hover (110ms animDurationShort fade) 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 an accent hover instead. Applied per widget (no shared parent to hoist it to); Workspaces/Tray keep their own per-element hover, non-clickable PrivacyIndicator/WindowTitle get 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-running nmcli monitor process (debounced refresh) plus a 60s fallback poll; shared by NetworkIndicator and NetworkPopout. The indicator shows connecting (cycling strength glyphs), an active VPN (MDI wifi-strength-N-lock variants; wired+VPN → security-network), and wired (ethernet glyph). Wired wins over wifi in both indicator and popout: when both a wired and a wifi connection are up, NM routes over ethernet (lower metric) and nm-applet shows the wired icon — so the widget checks wiredDev first (also beats wifi-off, so ethernet-only machines don't show a dead wifi icon).

  • Privacy indicator: PrivacyIndicator.qml shows a mic glyph and a video glyph while something captures, both in Theme.red, and hides otherwise. Detection matches the media.class of the endpoints of active link groups (Stream/Input/Audio / Stream/Input/Video) — the same signal waybar's privacy module uses. quickshell's PwNodeType flags are unusable for this: they share bits between in- and out-streams (AudioInStream = 13, AudioOutStream = 21, so a bitwise test matches playback) and leave video streams as Untracked. A PwObjectTracker must bind nodes and groups: an unbound group reports state = Unlinked (-1) forever, an unbound node exposes no properties. Gating on PwLinkState.Active is what keeps the always-present but idle capture nodes (rnnoise, Focusrite mic splits) quiet. Desktop-audio recording is excluded via the peer's media.class (Audio/Sink), because pipewire's stream.monitor property is not exposed through quickshell. Blind spot: camera use through raw V4L2 (/dev/video* without the portal, e.g. Firefox without media.webrtc.camera.allow-pipewire) creates no pipewire node and is not detected — waybar has the same gap; a /dev/video* observer was rejected, see DECISIONS.md (2026-07-29).

  • Keyboard navigation: an open popout grabs the keyboard, so it is fully operable without the mouse. Controls (Toggle, ListRow, VolumeSlider, IconButton) set activeFocusOnTab and show a focus ring; PopoutPanel focuses the first control on open and maps ↑/↓ onto Qt's native Tab chain (nextItemInFocusChain + an activeFocusOnTab filter). Keys: Tab/↑/↓ move focus, ←/→ nudge a slider ±5%, Space/Enter activate, Esc closes. Icon actions use the shared IconButton (destructive ones set activeColor: 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-git provides modernz.lua + font + default config to /etc/mpv/
  • mpv-thumbfast-git provides thumbfast.lua to /etc/mpv/scripts/ (auto-detected by ModernZ)
  • defaults/etc/mpv/mpv.conf is installed directly to /etc/mpv/mpv.conf by moonarch-git (owned)
  • Stock OSC + title bar disabled, autofit-larger=80%x80% caps oversized windows
  • ModernZ overrides via script-opts-append in 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-rule in defaults/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 via 51-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=kvantum is the workhorse. A single value themes both Qt5 (VLC 3) and Qt6. QT_QPA_PLATFORMTHEME cannot: one value selects either the Qt5 qt5ct plugin or the Qt6 qt6ct plugin, never both. The style override sidesteps that, so we ship kvantum + kvantum-qt5 (Qt6 + Qt5 style plugins) and skip qt5ct.
  • Theme: kvantum-theme-catppuccin-git (AUR) installs all flavors to /usr/share/Kvantum/; defaults/xdg/Kvantum/kvantum.kvconfig selects catppuccin-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's environment {} block (defaults/xdg/niri/config.kdl), not defaults/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-gtk reports the Inhibit interface as success even though nobody implements it under Niri → Firefox believes the idle-inhibit went through the portal and does not use the native Wayland idle-inhibit. Result: no inhibitor, screen sleeps.
  • Fix: org.freedesktop.impl.portal.Inhibit=none → Firefox falls back to zwp_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 stasis inhibit_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