Five rows over a model, registered from Launcher.qml under "power" with the borrowed anchor the clipboard popout uses, so shell.qml and every user copy of it stay unchanged. Mod+Escape calls the popout by IPC. Logout goes through `loginctl terminate-session self`, which needs no XDG_SESSION_ID in the shell environment. Glyph codepoints were read out of UbuntuSansNerdFont-Regular.ttf. The reserve Waybar's power button keeps the IPC call and says in its tooltip that it needs the Quickshell bar; there is no way to reach a Quickshell popout from a session where Quickshell was killed.
180 lines
28 KiB
Markdown
180 lines
28 KiB
Markdown
# 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
|
||
|
||
## 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.
|
||
|
||
- **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](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, 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
|