Files
moonarch/CLAUDE.md
T
nevaforget 8d31ff26e0 feat(lock): gtklock replaces swaylock
moonix switched first; this follows so the two distributions agree again
and moonix can drop the two substitute derivations that were rewriting
these configs at deploy time.

Two things made it worth doing. 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: 4.0.0 links libgtk-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.

The config location is the real prize. gtklock searches $XDG_CONFIG_DIRS,
so defaults/xdg/gtklock/config.ini rides the generic sweep and takes effect
through the package alone. Everything built for swaylock in 2026-08-18 goes
with it: the explicit install line in the PKGBUILD, the pre_upgrade
retirement of seeded home copies, check_swaylock_config in moonarch-doctor,
the comment in post-install.sh. swaylock needed all of that because its
sysconfdir was not an XDG path.

The blur stays baked into the wallpaper. gtklock cannot blur either — GTK3
has no filter CSS property and it takes no screenshot of the session, so
there is nothing to blur at lock time.

playerctl-module is deliberately not installed; it needs playerctld, which
nothing here starts. The other two are named bare in modules= because
gtklock's compiled-in module directory really is /usr/lib/gtklock on Arch.
All three packages are in extra, so official.txt covers them.

Both callers keep spawning the locker directly. There is no Wayland
protocol for "lock the session" — ext-session-lock-v1 is how a client
becomes the lock — and no portal either; logind's Session.Lock only emits a
signal nothing here subscribes to. Routing the popout through `stasis
trigger lock-screen` would name the locker once instead of twice, at the
price of a Lock button that fails silently whenever stasis is down. The
reason to want it does not exist: the plan continues after a manual lock
anyway, because stasis takes the state from logind's LockedHint rather than
from having started the locker.

The PKGBUILD side of this is a separate commit in moonarch-pkgbuilds, and
it carries the part that fails silently if forgotten: pre_upgrade rewrites
already-seeded ~/.config/stasis/stasis.rune, which is copied once and never
overwritten. Without it an existing install keeps calling a locker that is
no longer there.
2026-09-12 02:25:00 +02:00

192 lines
33 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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, gtklock config, wallpaper
## Lock Screen (gtklock)
- **XDG config, no special casing.** gtklock searches `$XDG_CONFIG_HOME/gtklock/config.ini` and `gtklock/config.ini` under every `$XDG_CONFIG_DIRS` entry, so `defaults/xdg/gtklock/config.ini` rides the generic `defaults/xdg/` → `/etc/xdg/` sweep in the PKGBUILD and takes effect through the package alone. No `install` line of its own, no home seeding, no shadowing problem — all of which swaylock needed, because its sysconfdir was not an XDG path. A user copy in `~/.config/gtklock/config.ini` still wins, which is what a user config is for.
- **Modules are where the value is.** `userinfo-module` (avatar, clock) and `powerbar-module` (suspend/reboot/poweroff) are separate packages in `extra`, named bare in `modules=` because gtklock's compiled-in module directory is `/usr/lib/gtklock`. swaylock could do none of this. `playerctl-module` is deliberately not installed: it needs playerctld, which nothing here starts. The avatar comes from AccountsService, which resolves to `~/.face`; with no such file the module warns once and falls back to `avatar-default-symbolic`, and there is no option for the path.
- **Blur is baked into the image.** gtklock cannot blur — GTK3 has no `filter` CSS property at all, and it takes no screenshot of the session, so there is nothing to blur at lock time in the first place. Same as swaylock before it, whose `effect-blur` existed only in the `swaylock-effects` fork. 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.
- **Two callers, both spawning it directly.** `defaults/xdg/stasis/stasis.rune` (lid close and all three `lock_screen` plans) and `PowerPopout.qml`. 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 either. logind's `Session.Lock` only emits a signal someone has to be subscribed to, and nothing here is: niri owns `org.freedesktop.ScreenSaver` with `Inhibit`/`UnInhibit` and no `Lock`, and stasis ignores the signal on purpose. `stasis trigger lock-screen` would name the locker once instead of twice, and is not used: the button would then fail silently whenever stasis is down. The plan keeps running after a manual lock anyway, because stasis takes the state from logind's `LockedHint` rather than from having started the locker.
- **`-d` is not `-f`.** swaylock forked once the screen was locked; gtklock forks in `main()` before it has read its config, so a returned `gtklock -d` means "forked", not "locked". Nothing chains off the exit, so it does not matter today. `moonarch.install` rewrites `swaylock`/`moonlock` to `gtklock` in already-seeded `~/.config/stasis/stasis.rune` on upgrade — without that an existing install keeps calling a locker that is no longer there, and a missing binary fails silently in stasis.
## 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 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](https://quickshell.org/docs/configuration/intro/)) — 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.cpp` — `configBaseDirs()` 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:
```qml
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:
```sh
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+A` → `audio`, in `defaults/xdg/niri/config.kdl`). The router routes to the focused monitor's `PopoutState` via `Niri.activeOutput` (workspace `is_focused` → `output`, 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, 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.
- **Launching goes through `app2unit`, a hard dependency.** `entry.execute()` spawns the app as a child of quickshell, and a child inherits its parent's cgroup — so every launched app lands inside `quickshell.service` and systemd kills it along with the unit whenever the bar restarts. `app2unit` wraps `systemd-run` and hands each app its own transient `app-*.scope`, the same shape niri gives the windows it spawns itself. It is an **external AUR package** (`app2unit`), not a systemd builtin: `Quickshell.execDetached` fails silently on a missing binary, so without it the launcher looks alive and starts nothing, with no trace in the quickshell log. `NetworkPopout.qml` uses it for `nm-connection-editor` for the same reason. NixOS carries it in the system environment, which is why the switch went unnoticed there.
- **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](https://github.com/Samillion/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, power-profiles-daemon, 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