Files
Nomarchy/docs/ROADMAP.md
Bernardo Magri e324876eb7
Some checks failed
Check / eval (push) Has been cancelled
feat(doctor): #147 — flag machine state the flake never set; linger is the worked example
/var/lib survives a migration untouched, and a stray pre-Nomarchy
Linger=yes marker shaped the QA box's session behavior for six months
with nothing in the flake to show for it. Ship the class as guards:

- nomarchy-doctor: new machine-state-drift check after the flake-checkout
  check — loginctl show-user -p Linger; "yes" warns (nothing is broken —
  it is drift) and prints the disable-linger fix; unknown-to-logind skips.
- docs/MIGRATION.md § Post-migration cleanup: "/var/lib machine state
  survives migration" — the worked example and the general suspicion rule.
- checks.doctor: linger sub-case — warn fires under enable-linger, exit
  stays 0, warning clears after disable-linger.

Deliberately NOT hardening the session wiring against the portal-gtk
restart race linger enabled: a clean install cannot reach that state.
The Waybar exec-once loose thread survives as BACKLOG #149.

Verification: V2 — shellcheck clean, nix flake check --no-build green,
nix build .#checks.x86_64-linux.doctor green (both branches exercised).
Implementation by a Sonnet subagent; design and review on Fable.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-17 07:44:43 +01:00

134 KiB
Raw Blame History

Roadmap & changelog

Design/decision records and a running log of shipped work (items marked ✓). Split out of the README so that stays a focused entry point.

Where to look

Need File
Product themes toward v1.0 (not a queue) docs/VISION.md
What agents execute next agent/BACKLOG.md
Docs map docs/README.md
Agent loop protocol agent/LOOP.md

Backlog items may reference this file as ROADMAP § <item>. When something ships, lasting design notes get a ✓ entry here; the BACKLOG line is deleted.

Roadmap

  • Menu system (apps launcher + theme switching + system actions), built on rofi 2.0 (native Wayland on 26.05) — its .rasi theme is baked from state.json like every other app, with rich per-element styling:

    • ✓ shipped: modules/home/rofi.nix (per-element theme generated from the palette, themes/<slug>/rofi.rasi whole-swap) and the nomarchy-menu dispatcher: root picker (no args) · power (lock/logout/suspend/hibernate/reboot/shutdown, SUPER+X) · theme (SUPER+T) · clipboard (cliphist, SUPER+CTRL+V) · calc (rofi-calc, live, SUPER+CTRL+C) · files (fd → xdg-open, SUPER+CTRL+F) · emoji (rofi-emoji, SUPER+CTRL+E) · web (Google). SUPER+Space is rofi -show drun.
    • ✓ shipped modules: network (nmtui in $TERMINAL) · bluetooth (blueman-manager) · capture (grim/slurp submenu: region/full → clipboard/file, saved to ~/Pictures/Screenshots) · keybinds (the cheatsheet, see below) · ask (free-text → the provider's web chat in the default browser; provider from settings.ask.provider, Preferences ▸ Ask provider — see the #140 ✓ entry below for what it used to be and why it changed)
    • ✓ keybindings cheatsheet: modules/home/keybinds.nix is the single source for both the Hyprland binds and the SUPER+? rofi list, so they can't drift; nomarchy-menu keybinds renders the padded two-column sheet (generated/mouse binds carried in its extra rows)
    • ✓ shipped binds: SUPER+Spacerofi -show drun (app launcher) · SUPER+Mnomarchy-menu (main menu) · SUPER+? → the cheatsheet (SUPER+D dropped — Space is the only launcher bind)
    • launcher icons: ✓ show-icons on, drawing from the theme's icon set (Papirus, via the icon-themes work below)
    • decision record: resolves the old Walker/Lua question — no GTK4 launcher, no second theming pipeline; the dispatcher owns the menu structure, so the renderer stays swappable (we moved fuzzel → rofi 2.0 once mainline gained native Wayland, for its richer theming)
    • Organize the picker into category submenus: the root picker had grown into a long flat list of 16 entries. It's now six: Apps · Theme · Tools · System · Power · Keybindings. Tools (Calculator, Clipboard, Emoji, Files, Web, Capture, Ask) and System (Network, Bluetooth, DND, and the self-gated Snapshots / Power-profile) are submenus the dispatcher routes via nomarchy-menu tools|system, each ending in a ← Back entry that re-opens the root. The direct SUPER+CTRL+<mnemonic> binds still hit the leaves straight (bypassing the menu), and the self-gated entries still hide when unavailable. Remaining (optional): a Look & Feel category once there are more appearance toggles to group with Theme (night-light, wallpaper). General pattern (ongoing): the grouping is a standing convention, not a one-off — any new feature that earns a menu entry must be placed in the right submenu (don't let the root creep back to a flat list), with its direct SUPER+CTRL+<mnemonic> bind and self-gating as applicable.
    • Back everywhere: every list menu now ends with a ↩ Back entry that returns one level up (power/theme/power-profile/clipboard/files/capture, not just Tools/System), so you never have to Esc out and reopen. A back helper + a shared BACK label keep it uniform; matched exactly so it can't collide with clipboard/filename content. Esc still quits instantly.
  • Menu modules from rofi plugins: the old calc flow committed the expression blind (result only in the next menu's -mesg) and qalc -t misparsed common phrasings (15% of 200rem(15, 1 B), the natural- language "of" tripping the CLI). Resolved by adopting purpose-built rofi modi (programs.rofi.plugins), all themed through the same .rasi:

    • calcrofi-calc: live results as you type via libqalculate directly (dodging the qalc-CLI "of" bug), Enter copies, menu persists to chain calculations.
    • emojirofi-emoji (new module, SUPER+CTRL+E): glyph picker, copies via the plugin's Wayland clipboard adapter.
    • files stays the hand-rolled fdrofi -dmenu → xdg-open fuzzy search: rofi-file-browser-extended was tried and dropped — its navigate-a-tree model felt worse than flat fuzzy-find for a quick launcher, and yazi (SUPER+E) already covers real browsing.
  • More menu modules from rofi tools: the script-based counterparts (run via rofi -dmenu, like the hand-rolled modules), each a deliberate replacement of an existing flow.

    • Network (networkmanager_dmenu): a native rofi wifi/VPN picker replacing the old nmtui-in-terminal network flow. Configured (xdg networkmanager-dmenu/config.ini) to drive rofi with rofi_highlight for connected/available rows; editing a connection drops to nmtui in the terminal. Inherits the generated theme like every other module.
    • Audio (rofi-pulse-select): a PipeWire/pulse sink/source switcher under System → Audio (Output/Input), self-gated on the pulse socket.
    • Deferred: rofi-rbw / rofi-pass secrets module — no Bitwarden/pass setup here today (secrets are gpg/ssh + gnome-keyring, see keys.nix), so it'd mean adopting a new secret manager. Revisit if that changes.
    • Also: menu search is now case-insensitive fuzzy (matching = fuzzy, sorting-method = fzf) across every module + the launcher.
  • Printer setup menu module: a nomarchy-menu printers entry in the System submenu opens system-config-printer (the CUPS admin GUI — discovery, drivers/PPDs, options, test page), mirroring the bluetooth/blueman pattern: the printing service ships the package (environment.systemPackages) and the menu execs it, self-gated on the system-config-printer binary so the entry appears only when nomarchy.services.printing is on. No direct SUPER+CTRL bind — printer setup is a rare one-off, not a frequent utility (left out deliberately; easy to add). Chose the GUI over a rofi-native lpadmin flow (driver/PPD picking is impractical in rofi) and the CUPS web UI (an unthemed browser page). Validated: the generated menu script's bash -n build check passes, and an eval confirms printing-on puts system-config-printer in systemPackages. Pending an on-machine check.

  • VPN setup & management menu: a dedicated System → VPN flow (nomarchy-vpn, modules/home/rofi.nix) that goes past the Network module (networkmanager_dmenu only connects/disconnects existing VPNs) to a guided setup + management surface across the three common kinds:

    • WireGuard: import a .conf into NetworkManager (nmcli connection import type wireguard file … — NM handles wg tunnels natively, no plugin) and toggle it up/down.
    • OpenVPN: import an .ovpn (nmcli connection import type openvpn file …); the networkmanager-openvpn plugin ships system-side (networking.networkmanager.plugins, mkDefault) so the openvpn type is available — import type is chosen by file extension.
    • Tailscale: status (read-only) + up/down + exit-node selection. It lives outside NetworkManager, so the menu drives the tailscale CLI directly, self-gated on the CLI being present (= nomarchy.services.tailscale, which makes the login user the operator via extraSetFlags). So up/down/exit-node run inline without sudo, falling back to a sudo terminal only if the operator grant is absent; the first interactive login uses a terminal (the auth URL is visible). Shape: a nomarchy-menu vpn rofi submenu under System — NM VPN/WireGuard connections shown ● active / ○ inactive and toggled on select (networkmanager-group users need no sudo), Import via the Files/fd picker (*.conf/*.ovpn), the Tailscale block when present — ending in ↩ Back. A self-gating Waybar custom/vpn shield (nomarchy-vpn-status: shown only while a NM tunnel or Tailscale is up; @good tone; click opens the submenu), wired into the generated bar and the summer whole-swaps. Secrets stay in the connection manager's own store (NetworkManager / Tailscale) — no new secret manager (cf. the deferred rofi-rbw/pass note above). Decided: import-first — a from-scratch WireGuard keypair/peer editor is too much rofi surface, so creation is deferred to nm-connection-editor. Eval + build green; pending an on-machine check (the nmcli import/up-down + Tailscale paths need a live session with real configs). Remaining (optional): richer exit-node display (country/city).
  • Theme parity with legacy: summer-day/night now carry their legacy bar layouts as waybar.jsonc whole-swaps (adapted: dead legacy script modules dropped, Nerd-Fonts-v2 codepoints remapped to FontAwesome/v3, logo button opens nomarchy-menu); the other four identity themes are palette recolors and already match. Remaining: a visual pass over all six on the live ISO

  • Per-theme rofi identity: the themes/<slug>/rofi.rasi whole-swap ships, and all six identity themes now carry a designed .rasi. summer-day/ night keep their legacy ports (inverted window, green inputbar, yellow bottom-border); the other four were authored from each theme's character (no legacy layout to port — their waybar whole-swaps are palette-only): nord a soft rounded "frost panel" (frost border, brighter-frost selection, aurora-purple prompt); retro-82 a sharp CRT terminal (square corners, amber-on-navy, a teal scanline underline, mono); lumon a clinical cyan "bezel" (thick cyan frame, a framed-not-filled readout inputbar, mono); kanagawa ink-and-paper (warm washi-paper frame, a deeper ink-well inputbar, crystal-blue wave reserved for the selection). Each is self-contained (it replaces the generated theme) and keeps the element structure the theme-grid picker's per-invocation -theme-str layers onto; all four parse clean under rofi -dump-theme. The generated palette theme stays the default for the other 15 presets. Remaining: a visual pass over the four on hardware (the parse check confirms syntax, not aesthetics).

  • Visual theme picker (preview thumbnails): nomarchy-menu theme is now a rofi icon grid of real desktop previews instead of a plain-text list — each theme a screenshot of its themed desktop (waybar + floating terminal), pretty name beneath, grouped dark-first then light in one scrollable grid (the previews make the mode obvious, so no light/dark submenu split), the active theme marked , ending in ↩ Back. The grid, the Name→slug map and the active mark are all generated at eval time from the preset JSONs in rofi.nix (builtins.readDir + fromJSON); "active" is just t.slug, since every switch rebuilds the menu. Grid layout via a per-invocation -theme-str (listview { columns: 3; flow: horizontal; } so Down scrolls row-by-row, + vertical element cards, name centred below). Sizing gotcha (learned the hard way): rofi's element-icon size is a single value → a square cell (a two-value WxH is silently collapsed), and the icon is contained in that square. So a 16:9 preview letterboxes (theme-coloured bands top/ bottom), and a cell can't be shorter-than-square without the bands returning. The fix: a build-time imagemagick step centre-crops each preview to a square so it fills the cell edge-to-edge; the window width is derived so a column is exactly the icon side (no slack margins). One knob, themeGridIconW (240px), drives both icon and window. Workflow: Bernardo captures previews on real hardware and commits them as themes/<slug>/preview.png, already downscaled to 480×270 (~2.4 MB total for all 21, vs ~28 MB full-res) — the source stays 16:9 and untouched; only the displayed thumb is squared at build, so the crop is reversible. Graceful fallback when a theme has no preview.png: a plain-name row, so it degrades cleanly. (A headless VM-render route — runNixOSTest + software-GL Hyprland (LIBGL_ALWAYS_SOFTWARE on virtio-gpu) + machine.screenshot() QMP framebuffer dump — was prototyped 2026-06-19 and works (themed waybar rendered + captured); real-hardware capture was chosen for fidelity + simplicity, with the VM route as a documented fallback if hand-capturing all themes gets tedious.)

  • Faster switches: move backgrounds/ out of the flake source. Diagnosis (confirmed): themes/ is 86 MB and backgrounds/ is all of it — the palette JSONs + per-theme overrides are only ~208 KB, and the wallpapers are never read at Nix eval (only the Python swww path uses them). But state.json is git-tracked, so every apply rewrites it → the flake tree changes → Nix re-copies the whole 86 MB source before home-manager switch can evaluate. You pay an 86 MB copy to change a 1 KB file. Decided approach (deferred — don't want the extra moving part yet): Option 1, a separate pinned wallpapers artifact — a Nomarchy-wallpapers repo or release tarball, pulled once via a flake input / fetchurl (pinned by hash → content-addressed, never re-copied on a state write), with nomarchy-state-sync reading wallpapers from that stable store path (the NOMARCHY_DEFAULT_THEMES env hook already anticipates external theme assets). Keeps eval pure; the live ISO still bakes them in (fetched at build). A state write then re-copies only ~208 KB. Rejected alternative: moving state.json out to ~/.config + --impure eval (one repo, no second artifact, but trades away the in-tree-pinned-state reproducibility). Follow-on if home-manager switch itself is still the bottleneck after the copy is gone: pre-built theme variants (build each theme's generation ahead of time so a switch just activates a cached one).

  • Greeter (tuigreet/SDDM) theming from the same JSON (Plymouth ships since v1: nomarchy.system.plymouth.*, background tinted from the state file)

  • Installer round 2: multi-disk BTRFS RAID, impermanence, BIOS/legacy boot (v1 nomarchy-install is single-disk UEFI — see pkgs/nomarchy-install)

  • Installer no-keyboard-variant safety (#91): gum's display-only (none) row is normalized at the common interactive/unattended boundary, and the template patcher independently refuses to serialize that sentinel into either Home Manager or system XKB configuration. A permanent check patches the real downstream template for us + no variant and builds the resulting console keymap; the full offline installer VM also carries the literal sentinel through LUKS+swap installation and first boot.

  • Catalog-only installer keyboard selection (#92): the short default confirms US English with standard keys; non-default layouts and variants are searchable human-readable rows from the pinned XKB catalog. Gum runs in strict mode and a shared exact-membership boundary validates interactive and unattended values before hardware detection, disk changes, or generated Nix. The review panel shows the resolved layout and key behaviour.

  • launch-or-focus UX scripts (swayosd volume/brightness OSD ships since v1: nomarchy.osd.*, media keys drive swayosd-client, themed from the JSON)

  • Distro branding, round 2: distroName = "Nomarchy" ships (os-release PRETTY_NAME, systemd-boot entries, ISO menu label). ✓ tuigreet greeting (Welcome to <distroName>) and a branded users.motd (doubling as a helper cheat sheet), both keyed off distroName. ✓ isoImage.splashImage — the vendored vector logo (modules/nixos/branding/logo.svg, from legacy) recolored to the palette accent on the theme base, built at ISO-build time (hosts/live.nix). ✓ isoImage.grubTheme (UEFI boot matches BIOS): hosts/live.nix now builds a nomarchyGrubTheme dir whose background is the same composed splash image as the isolinux splash (accent logo on base), with a palette-coloured boot menu in the lower third (clear of the centred logo) and an accent timeout bar. Derived from nixos-grub2-theme only to reuse its bundled DejaVu .pf2 (grub loadfonts every .pf2 in the dir); the stock NixOS logo.png is dropped since ours is in the background. Built + structure-verified (theme.txt palette colours, 1920×1080 background, font present). Remaining: a UEFI ISO-boot render check on hardware (the file wiring is confirmed; the visual is not CI-testable, same as the splash). ✓ distroId = "nomarchy": os-release is now honest — ID=nomarchy, ID_LIKE=nixos (the standard derivative-distro lineage marker, cf. Ubuntu→debian), DEFAULT_HOSTNAME=nomarchy, lsb DISTRIB_ID/CPE_NAME follow. Verified safe: switch-to-configuration builds its "is this NixOS?" guard from the configured distroId (and /etc/NIXOS remains as the fallback), so rebuilds keep working — a new checks.distro-id VM-test boots such a system and runs switch-to-configuration dry-activate green; nixos-* CLI tools are package names, untouched. The one side effect (isNixos→false blanks the upstream nixos.org URLs) is handled by extraOSReleaseArgs restoring HOME_URL/DOCUMENTATION_URL/SUPPORT_URL/ BUG_REPORT_URL to the project. os-release output eval-verified from the real downstream config.

  • fastfetch branding: modules/home/fastfetch.nix (nomarchy.fastfetch.enable) — the vendored vector logo, recolored to the palette accent and rendered to compact block-art via chafa at build time (tracks the theme), fronting a curated module list. Replaces the oversized legacy ASCII with a themed, sized logo.

  • Nomarchy logo font in Waybar: vendored Nomarchy.ttf (modules/nixos/branding/), installed via fonts.packages, and the summer-day/night menu buttons now use its U+F000 glyph with font-family: Nomarchy pinned in their CSS (Nerd Fonts also occupy U+F000, so the pin is required). The other themes have no logo button.

  • Quality-of-life command aliases: a curated set ships on by default in modules/home/shell.nix — navigation (../.../....), a git block (g, gst, ga/gaa, gc/gcm, gco/gsw, gb, gd/gds, gl/glg, gp/gpl, gf), and a nix block (ns, nr, nfu, nfc, nsearch, ngc), plus path and reload. Scope decision: short where it helps but never shadow a real binary (same rule as rg/fd — the git block deliberately avoids gs/ghostscript); lifecycle is nomarchy-pull / nomarchy-rebuild / nomarchy-home (full names on PATH, not aliases). The set is documented in README §5 (Day-to-day), and alias lists shell shortcuts.

  • Theme-switch feedback: ✓ the "rebuilding…" notification is now persistent (timeout 0) and replaced in place by "applied ✓" / failure via a synchronous tag, so a multi-minute switch never reads as a failed selection. (An honest indeterminate indicator — HM gives no % — a literal progress widget would be cosmetic; revisit only if wanted.)

  • Icon themes: ✓ ships papirus-icon-theme; the resolved name lives on nomarchy.theme.iconTheme (the JSON's optional icons field, else Papirus-Dark/Light by mode) and feeds both Stylix's gtk.iconTheme (Thunar/GTK apps) and rofi's show-icons. ✓ Opt-in cross-pack overrides (no default bloat): theme.nix now resolves nomarchy.theme.iconThemePackage from a small pack registry (iconPacks: Papirus·Tela·Qogir·Reversal·Numix). Default themes name Papirus-*, so the resolved package is the plain papirus-icon-theme derivation — byte-identical closure, zero added MB. Only when a theme's icons names a set from another pack is that pack (and only it) symlinkJoined alongside papirus. Adding a pack = one iconPacks row. Deliberately NOT shipping more packs by default (Bernardo 2026-07-10): papirus alone is already ~1 GiB and each extra pack adds ~hundreds of MB, so variety is opt-in per install, not a default closure cost.

  • Auto time-of-day theme (#79, VISION § D): an automatic light/dark theme switch through the same engine as a manual apply — no second pipeline. settings.autoTheme = {enable, day, night, sunrise, sunset} drives nomarchy-state-sync auto, which picks day vs night by the local clock and applies the preset only if it differs (idempotent). modules/home/autotheme.nix installs a user timer (OnStartupSec + OnCalendar=*:0/15 + Persistent, gated on the state flag) that ticks auto; Look & Feel Auto theme toggles it and picks the pair/times. cmd_apply's core was factored into apply_named so auto reuses it verbatim. Periodic (not exact sunrise/sunset timers) for robustness to suspend/DST; the idempotent auto keeps ticks cheap. Enable rebuilds once (installs the timer + auto --force); disable is instant (the flag flips and auto self-gates). V2: checks.auto-theme moves the VM clock across a configured sunset/sunrise and asserts the theme flips day→night→day with the right rebuild count (idempotent between) — the home-manager switch itself is stubbed via NOMARCHY_REBUILD (that generic apply path is exercised by every manual theme change). The only on-hardware residual is the live user timer firing on its OnCalendar schedule (the schedule is systemd-analyze-validated).

  • NixOS-side state bridges (#116): nomarchy.settings is declared in exactly one place — modules/home/options.nix, the Home Manager side. On the NixOS side the attribute does not exist, so four options that defaulted from config.nomarchy.settings.… or <fallback> silently got the fallback on every machine ever built: or swallows the missing-attribute error, which makes a dead bridge and a live one look identical in the source. Two of them were user-facing toggles that reported success and did nothing — Control Center's Bluetooth and Printing wrote settings.{bluetooth,printing}.enable and printed "requires rebuild", and the rebuild changed nothing, for as long as those toggles had existed. The pattern, now uniform: the option declares a static default and the implementing module reads the state via modules/state-read.nix (fails closed on bad JSON) and mkDefaults it — lib.mkIf (stateX != null) (lib.mkDefault stateX), so an absent key leaves the option default as the single source of the fallback and a hand-set value in system.nix still pins it. Done for greeter.autoLogin (eb38008), bluetooth.enable (modules/nixos/default.nix) and services.printing.enable (modules/nixos/services.nix). power.batteryChargeLimit deliberately has no eval bridge: power.nix's oneshot reads the key out of the live state with jq at runtime and prefers it over the baked value, which is why its menu worked all along — the phantom read was dead but harmless, and is simply gone. The guard is the point: nothing in a build fails when a bridge dies, so checks.state-bridges asserts at eval that a flipped state file reaches the config (and that a hand-set value still outranks it), and checks.printing-from-state boots a VM whose only input is the state file and waits for a running cups.service. Adding a bridge = copy the shape and add a case. Lesson: or <fallback> on a config read is an error-swallower — prefer an explicit != null test over a fallback expression, and grep the whole of modules/nixos/, not just options.nix (the first pass at this item did the latter and undercounted).

  • Nicer shell out of the box: ✓ zsh is the default login shell, with a starship prompt themed from the JSON, autosuggestions + syntax highlighting, and modern-CLI ergonomics — cat→bat (theme "ansi", so it tracks the palette), ls→eza, cd→zoxide, plus lt/tree. nomarchy.shell.enable. (Deliberately did NOT alias grep→rg / find→fd: their flags differ enough to surprise; rg/fd ship as themselves.)

  • Default application suite: a starter complete-workstation set — libreoffice-fresh, vscode, gimp, inkscape — ships active in the downstream templates/downstream/home.nix home.packages, with the heavier opt-ins (texliveFull — multi-GB; texliveMedium is lighter — plus browser/email) commented just below — Chromium is active by default (2026-07-10); other browsers stay commented. Decision: no nomarchy.apps.* option surface. Unlike nomarchy.services.* (real config behind each toggle — systemd units, subuid ranges, the Flathub oneshot), a bare package install has nothing behind the toggle: mkIf x { environment.systemPackages = [p]; } just reinholds what a package list already gives, and the honest opt-out for a package is deleting the line. So the suite is the user's own curated list (extending the existing # firefox convention), not a distro-module feature — and it respects that editor/office/graphics are personal-taste choices, not defaults to impose. Unfree vscode evaluates fine — mkFlake and the system both set allowUnfree = true. Amended 2026-07-14 (#103): this design also kept the apps off the live ISO automatically (it never starts from the template) — which was read as a feature here, and was in fact the bug: the live session had no browser and no office at all. The live medium now names its own baseline set explicitly (see below); "the template is the app list" still holds for installed machines.

  • Live-ISO baseline apps (#103): the live session shipped the distro modules only — no browser, no office — for as long as it existed. That is the thing a user judges the distro by before installing, and what they boot to rescue a machine that will not start; it could do neither. The live HM user in flake.nix now names its own set: Chromium, libreoffice-fresh, gnome-text-editor, amberol, mpv (#119), snapshot (Bernardo confirmed the gap on real hardware 2026-07-14). Firefox deliberately excluded (Bernardo's call): Chromium already owns the HTTP mime default and ships in the installed template, so it is the browser that matches what a user gets after installing — and it was the one item on #103's original list that would have cost a real closure. The size finding, which inverted the trade: system.extraDependencies already pins the template's HM closure into the ISO for offline installs, so chromium/libreoffice/amberol were already in the image's store — merely absent from the live user's profile, so nothing put them on PATH or in the launcher. Reusing the template's exact derivations (including chromium.override { enableWideVine = true; } — a plain chromium would be a second full browser build) makes those three cost zero. Only gnome-text-editor and snapshot are new: 9 store paths, 133 MiB uncompressed (99 MiB of it gst-plugins-rs, Snapshot's media stack). Measured ISO delta: 8.038 → 8.078 GiB = +41.2 MiB (+0.50%), both built from the same tree. Guard: checks.live-baseline-apps asserts each app is on PATH and has a .desktop the launcher can see (a binary without one is invisible, which is the failure that matters), that HTTPS resolves to a chromium entry which is actually present — #94's exact trap, a mime default naming a package nothing ships — and that Firefox has not reappeared, since that is a size decision rather than a drive-by. Proved to fail: dropping snapshot makes it name the missing entry. V3 pending — that the apps launch needs real hardware (HARDWARE-QUEUE, Acer M5-481T).

  • Keybinds cheatsheet groups (#108): SUPER+? sections Window / Workspace / Menu / Media from keybinds.nix group; checks.keybinds-cheatsheet fails if a bind lacks a group or a desc is missing from the sheet.

  • Fix batch #112 / #106 / #113: installer disk picker drops fd0/loop/ optical/<8GiB and lists largest-first (OVMF no longer defaults to floppy); checks.menu-back guards every internal rofi list emits ↩ Back; offline theme-switch promise scoped to the live-pinned (default) theme with a clear error when a non-pinned preset needs network.

  • Install fix batch (#123#126): (123) HM pre-activate creates /run/user/$UID + dbus-run-session so dconf no longer aborts the bake; (124) main-built ISOs seed ?ref=main and installer actually rewrites inputs.nomarchy.url from NOMARCHY_FLAKE_URL (was set but unused); (125) image.baseNamenomarchy-live-….iso; (126) Display mode list sorted by pixel area + toast reports hyprctl size; TESTING notes QEMU fixed-window scaling. V3: reinstall on Acer/XPS for #123.

  • Kitty is the only terminal (#95 complete): Ghosttys OpenGL 4.3 floor broke SUPER+Return (and classed doctor/calendar windows) on Ivy Bridge HD 4000 (Acer M5-481T). Maintaining two terminals was pure complexity. Kitty only: always installed, themed from theme-state (ANSI/font/opacity), default $TERMINAL / SUPER+Return, doctor + calendar + what-changed use kitty --class=com.nomarchy.*. Ghostty module and install-time GL probe removed. V3: Acer SUPER+Return + doctor float.

  • The floating sheets got their size back (#139, 2026-07-16): Bernardo: calcurse and the doctor sheet float, but full-screen — the reduced size Ghostty used to give them was gone. Two independent faults, stacked, which is why it read as "the float rules are dead" when they were not. (1) Hyprland 0.55.4 silently ignores a percentage size rule. Matrix on hardware, both rule orders, Kitty's memory disabled so nothing could mask it: size 60% 65% never applies, size 1536 936 applies in either order, and hyprctl configerrors is empty both ways — which is how it slipped through the post-0.53 rule rewrite. Order turned out to be a red herring. (2) Kitty defaults to remember_window_size yes and replays the last OS window's size (~/.cache/kitty/main.json) into the next one, so with (1) applying nothing, every sheet inherited whatever the last terminal was — after a tiled one, the whole screen. This is the Ghostty regression: it kept no such memory, so (1) was invisible until #95 made Kitty the only terminal. Fix: remember_window_size no (floats must be deterministic; the compositor sizes tiled windows anyway) and the sheets now ask for their own size — modules/home/term-sheet.nix reads the focused monitor and hands Kitty the px, because px in a rule cannot mean "a fraction of this screen" and one pair cannot serve a 2560×1440 desk monitor and the 1366×768 Acer (#131). Those windows therefore carry float/center rules and deliberately no size rule. Measured after: calendar 1536×936 = exactly 60%×65%, doctor 1408×1008 = 55%×70%, both centred in the Waybar work area.

  • Splash: positions follow the canvas, + a keyboard hint on the LUKS prompt (#137/#145, 2026-07-16): Bernardo's logo was off-centre on the external when booting/shutting down docked. The first two diagnoses were both wrong, and the record matters more than the fix: it is not that Window.GetWidth() returns head 0 (it returns max_width), and not that per-head sprites were needed (they would have duplicated the logo). The script plugin builds one canvas of max_width × max_height, centres every display inside it (display->x = (max_width - width)/2, script-lib-sprite.c:536) and draws each sprite at sprite.x - display.x — so a canvas-centred sprite is centred on every head and the theme's arithmetic was right all along. The bug was time: every position was a top-level statement evaluated once at parse time, so a head arriving or leaving mid-splash resized the canvas, the plugin re-centred the displays, and the frozen sprites ended up off by (new_max - old_max)/2 on every head. One monitor never resizes the canvas — hence "only when docked". Fix: one layout(), re-run from the existing refresh callback on canvas change. The trap that cost two attempts, now a comment in the script: in plymouth script a bare assignment inside a function writes the global if that name already exists globally (global.foo and foo are one variable). So canvas_width = Window.GetWidth() updated global.canvas_width before the guard compared against it — always false, body never ran, whole splash a bare background, no error logged. Proven with a 20-line probe: [after assign: global.canvas_width=800][guard SKIPPED] vs a distinctly named local firing correctly. Hence cw/ch. #145 rides along: a VT loads exactly one keymap and knows nothing of per-device layouts, so the passphrase box types with the console layout — worth stating before three wrong tries on a disk nobody can read yet (the same gap as #114, one step earlier in the boot — #114 closed 2026-07-17 as document-only: README's "Greeter keyboard layout" note). Fedora's own mechanism does not port: its label is ply_keymap_icon, a C widget in the two-step plugin (the script plugin has no keyboard API), fed from XKBLAYOUT in /etc/vconsole.conf — which NixOS never writes (this box has only KEYMAP=<store path>; plymouth's trace literally says XKBLAYOUT: (null)). So @LAYOUT@ is baked from services.xserver.xkb.layout exactly like the palette already is — first of a comma list, because that is what the VT loads and the raw string would lie in the multi-layout case the label exists for. The icon is plymouth's own keyboard.png (literally Federa's glyph), copied at build time so no GPL bytes enter this repo, recoloured to subtext. chmod +w after that copy: store files are 444 and recolor rewrites in place — without it magick fails, the phase aborts, every later sed silently does not run, and the theme ships with @BG_R@ in it. V2 by render, both heads + the password dialog, via the new tools/plymouth-preview.sh. V3 (a real docked boot/shutdown) is queued: the fake heads exist from the start, so the rig cannot resize a canvas.

  • Machine state the flake can't see (#147, 2026-07-17): the QA box ran six months with Linger=yes from a pre-migration marker in /var/lib/systemd/linger — set by nothing in the repo, visible to no flake audit. The class ships as guards, not session hardening: nomarchy-doctor now warns on linger drift (loginctl show-user -p Linger vs. the flake's implicit "no", fix command printed), and MIGRATION.md § Post-migration cleanup states plainly that /var/lib survives a migration and what to sweep. The portal-gtk restart race that linger enabled was deliberately NOT hardened against — a clean install cannot reach that state. V2: checks.doctor grew a linger sub-case (warn fires, exit stays 0, clears after disable-linger). The Waybar exec-once loose thread became #149.

  • Dock mode survives a rebuild (#142, 2026-07-16): Bernardo: every rebuild while docked re-lit the laptop panel and threw a workspace onto it, and he re-picked Dock mode by hand. It was not forgetfulness — a rebuild performed an undock, by construction. Dock mode existed only inside the compositor (keyword monitor <internal>, disable, nothing written down), the generated config's only monitor line is the catch-all monitor=,preferred,auto,1, and a rewritten config makes Hyprland reload — which re-enables every output. We did not have to theorise that: the undock path already uses hyprctl reload as precisely that hammer, hardware-proven 2026-07-14. And the watcher could not rescue it, because reconcile diffs monitors all, which lists disabled outputs too — so the connected set is identical across the flip and the watcher is structurally blind to it (the blind spot of rounds 45's "the output set, not the event, is the truth"). Fix: dock mode is an intent now, recorded where every other setting lives — settings.display.dockMode, in-flake and menu-writable — written at the one choke point both the menu and the watcher pass through (nomarchy-display-transition: dock → true, undock → false, and enable of an internal → false, since "Screen on" is leaving dock mode and must not be undone a second later). The watcher then re-asserts it as a tick invariant rather than an event reaction, which is what makes the reload case work at all; auto_dock was already idempotent (it returns early unless the panel is up), so the cost is one hyprctl monitors per tick. The trap avoided was baking the disable into the config (state → monitor=eDP-1,disable), which would survive a reload beautifully and, with no external attached, hand the user a zero-output session at login — the black brick of #100/#127. The config cannot know what is plugged in, so it keeps defaulting to "everything on" and the re-assert is gated on an external actually being present. Verified by unit-testing the real generated function against stubs — 9/9, including no external → never re-dock — plus the state write/read round-trip on a throwaway checkout.

  • MIGRATION.md: Mozilla profiles.ini (#144, 2026-07-16): a real migration ate Bernardo's ~/.thunderbird/profiles.ini, so Thunderbird did what it does on any new machine — made an empty profile and opened it — while 30 GB of mail (19 GB ImapMail, 64 account prefs) sat untouched in the directory it had stopped looking at. Recovered in eight lines. Documented as its own bullet next to the Chromium one because it is the inverse failure and the inverse danger: Chromium silently deletes extension data and looks minor; Thunderbird loses nothing and looks catastrophic — so the reflex it provokes (restore a snapshot over a perfectly good profile) is itself the way to actually lose mail. The doc therefore leads with ls/du, not with a restore.

  • Ask hands off to a web chat (#140, 2026-07-16): Bernardo: "opening claude-code is too disruptive." SUPER+CTRL+A used to spend a terminal, an npm fetch and a REPL on "what's the syntax for…" — the prompt was the right surface, the destination was not. Now the free-text line is URL-encoded into the provider's web chat and handed to xdg-open (Chromium, per Decisions). Provider is settings.ask.provider (claude · chatgpt · gemini · grok, default claude) with a Preferences row, read from state at run time, so switching takes effect on the next question with no rebuild — and an unknown or absent value falls back rather than opening nothing. The claude-code path is deleted and pkgs.nodejs left with it (it was carried only for that npx); the keybind is "Ask AI" now that the destination is the user's. The interesting bit is the failure mode. Prefill is undocumented on all four vendors — each can drop ?q= unilaterally, and it fails silently: you land in an empty chat having already typed the question. So the query is also copied to the clipboard, which turns a vendor's breaking change from "your question is gone" into one paste; and the URLs live in a single attrset (askProviders) so the repair is one line. Verified with the generated script: every bad state value falls back to claude, and a hostile query (&, ?, #, quotes, $(whoami), unicode) encodes correctly — %26, %3F, %23, caf%C3%A9. Unverified on purpose: whether each vendor's page fills the box or auto-submits — testing that means posting into Bernardo's own accounts.

  • No codename in the branding (2026-07-16): Bernardo — the distro is "Nomarchy 26.05", not "Nomarchy 26.05 (Yarara)". Yarara is nixpkgs' release name; presenting it as ours told users something true about our upstream and nothing about our product, in the one string (PRETTY_NAME) every fetch tool and greeter reads. Upstream hardcodes the codename into VERSION, PRETTY_NAME and VERSION_CODENAME (misc/version.nix) with no switch, and system.nixos.codeName = "" is the wrong lever — it renders 26.05 (). So the assembled strings are overridden through the merge hooks upstream does provide (extraOSReleaseArgs / extraLSBReleaseArgs), which is also where our project URLs already live. Empty VERSION_CODENAME/DISTRIB_CODENAME is deliberate: // cannot remove a key, os-release(5) makes every field optional, and the file already ships ANSI_COLOR="". nixos-version still prints "(Yarara)" by design — that command answers "which nixpkgs was this built from?", the one question the codename genuinely answers.

  • Accent buttons: the chip moves, not the label (#129/#130, 2026-07-16): Light-theme suggested/destructive labels sat at 2.72:1 — under AA and under even the 3:1 large-text floor. Bernardo: fix it; matching upstream is no defence when upstream is illegible. The plan on file — "darken the label" — turned out to be impossible, and measuring the whole palette set is what showed it: on a saturated mid-tone accent nothing clears 4.5, neither the cream base (2.72) nor the dark text (1.65). That is why adw-gtk3 ships white-on-accent at ~2.7 and calls it done. The chip has to move. So the chip is now the accent pulled away from the label: toward white when the label is dark, toward black when it is cream — one factor (0.70), only the anchor flips, and "solid accent button, base label" survives as the design. Measured across all 24 palettes: worst dark 4.86 (nord/bad), worst light 5.59 (summer-day). The old 0.90 chip failed seven themes — miasma/bad 3.43, rose-pine 2.70 — so this was never only a light-theme bug, which is what the item assumed. #129 rides along, and the two items turn out to be one idea. #98 shipped an invisible button with every check green because the background was not a palette role: adw-gtk3 computed it from currentColor. The guard is therefore not a CSS lint (which would flag the harmless generic-button rule and get muted) but arithmetic: pin both ends and the pair becomes checkable. check-theme-contrast.py now models GTK's own mix(a,b,f) = a + (b-a)·f — read from gtkcsscolorvalue.c:234, not assumed — and asserts base-on-chip ≥ 4.5 for every theme. Its own negative test reproduces the item's reported figures exactly (summer-day 2.72/2.74) from an independent model, which is the strongest evidence available that the arithmetic matches the pixels. The invariant to keep: pin the pair, or pin neither — a pinned label on a sheet-derived background is unknowable, and that is precisely how #98 passed review. (tools/dialog-shot.nix deliberately not built: the checker covers the numbers, and a render harness that only ever confirms arithmetic is a VM to maintain for nothing. Render if a new widget class appears.)

  • A clamshell undock latched the next one (2026-07-16, ff5017e): Bernardo undocked and the panel stayed dark; re-docking and undocking again brought it back. Re-docking was not the cure — opening the lid was. awaiting_lid_open is set by every successful undock and cleared only when the external is gone and the lid is open, so a clamshell undock latches it; the added_ext branch clears its sibling undock_pending ("a new external supersedes any undock still queued for an older departure") and forgot this one, and the clear cannot fire while an external is present. The next departure then hits [ -z "$awaiting_lid_open" ], queues nothing, and the panel never returns. His journal is the proof: the first undock logged nothing at all, then lid-inhibitor=released the moment he lifted the lid, then a clean undock. Fix: clear the latch where undock_pending already is — a new external ends the departure the latch describes. Replaying the sequence with the lid never opened: 1 of 3 undocks ran; now 3 of 3. Two lessons. A latch that outlives what it describes is a bug waiting for a rare-looking sequence — and "dock/undock twice without opening the lid" only looks rare until you notice it is what a week at a desk does. And this is very likely #127's second symptom ("unplugging did not restore the panel"), which means the brick may have been an ordinary blank plus a recovery that could not run — two mundane bugs, not one exotic DRM state. The shipped DPMS mitigation may therefore be treating the wrong cause; #127's TEST B is what decides, and it is far safer now that an undock works again. It did decide, the same day — half of the hunch was right. The mitigation was treating the wrong cause and is now reverted (blanking while clamshell is provably innocent). But "an ordinary blank + a recovery that could not run" is not established: the wake path had a real single point of failure, which is fixed, yet nothing shows it had failed during the incident. The brick's cause is still open. See "#127's WAKE PATH fixed" below.

  • The #107 compat shim is pinned (#133, 2026-07-16): mkFlake prefers state.json and still accepts the legacy theme-state.json, because a machine only migrates on its next menu write — but nothing evaluated a checkout carrying only the legacy name, so the shim could rot with every check green, breaking rebuilds for exactly the users who have not touched the menu since #107 (i.e. the least likely to notice). checks.state-legacy-name now evaluates two fixtures: legacy-only must resolve, and no state at all must still throw — the fail-closed gate is half the shim's contract and was equally unpinned. Cheap by construction: mkFlake wraps its return in builtins.seq _themeState, so forcing the attrset forces the state read and nothing else — no module system, no home.nix — which is why the fixture is one file and the check costs no measurable time. That file is a symlink to the shipped template's state.json: a copy would drift and the check would quietly start testing a fossil. Both assertions were proven by breaking lib.nix — deleting the fallback, then defeating the gate — and watching each fail by name. Delete it with the shim and the nomarchy-theme-sync alias.

  • The VM's verdict on menu geometry is worthless — and so is measuring (#132, 2026-07-16): the guest draws the picker with icons at ~¼ of ui.iconSize and rows short enough to clip a 6-row root, so screenshots are evidence for which rows appear and nothing else. Already documented; what this closes is the fallback advice, which #131 proved was the actual trap: "measure text against the 40% window width instead" assumed a width and a font that no theme shipped (boreal pinned 620px in GeistMono 11; others 40% in JetBrainsMono 14), concluded "only truncates below 1920", and was contradicted by truncation on a 2560 panel. TESTING.md §5 now says to judge by rendering — hardware, or rofi -dmenu -theme <built rasi> + hyprctl layers for the real width — and notes checks.rofi-text-width guards the invariant that makes it fit, never how it looks. Fixing the guest's fontconfig was declined: the screenshots earn their keep as content checks, and a guest taught to lie convincingly about spacing would be worse than one that obviously cannot.

  • Menus are sized in ch, not in % of the monitor (#131, 2026-07-16): Bernardo saw Recovery's labels ellipsize on a 2560×1440 external — which falsified the item's own model ("width: 40% + Inter 11 → only below ~1920; confirm on the Acer"). Two wrong assumptions, and the diagnosis is the interesting part: text menus render through themes/<slug>/rofi.rasi, where boreal and neon-glass pinned width: 620px — fixed, so a wide panel buys nothing — while the other seven used 40%; and the font is whatever that file says (boreal GeistMono 11, summer/kanagawa JetBrainsMono 14, …), mostly monospace, far wider than the Inter that was measured. The modelled combination was shipped by no theme at all. The insight: a menu must fit its longest label, which is a count of characters — so the window has to scale with the font, not the screen. 40% of a 1366 panel and of a 2560 one give the same row 546px and 1024px, and a 14pt mono theme needs ~25% more room than an 11pt one on both; a percentage cannot see either fact. Rofi has the right unit — ch, the width of one digit in the theme's own font — and calc( … min … ) to cap it. So every text menu (generated + all nine whole-swaps) is now width: calc( 84ch min 65% ): 84ch fits the longest row we ship (69 chars) plus icon and padding, and the cap answers Bernardo's condition — a big font on a small panel is clamped rather than sprawling. Measured on hardware: 84ch = 756px in GeistMono 11 and 924px in JetBrainsMono 14, calc( 84ch min 300px ) → 300px (the cap really clamps). V3 by screenshot, both ends: the real Recovery menu at 756px (29% of 2560) with every label complete, and the Acer's worst case reproduced exactly — 888px (=65% of 1366) with JetBrainsMono 14 — also complete. Grid views (theme picker, drun) override width per-invocation and are untouched: they are sized by icons, not text. checks.rofi-text-width guards the class — a whole-swap that sizes its text menu without ch + a min cap fails, proven by pinning boreal back to 620px and watching it name boreal.

  • Waybar whole-swap on-clicks lost their environment (#141, 2026-07-16): clicking the updates module did nothing. Not an updates bug — a parity break: the generated module bakes the terminal in at eval time, but the four whole-swaps hand-wrote sh -c '$TERMINAL -e nomarchy-updates upgrade', and Waybar has no TERMINAL. It is a home.sessionVariables entry, so login shells get it and the Hyprland-spawned bar does not; the click expanded to sh -c ' -e nomarchy-updates upgrade' and failed silently, with no error and no toast. Fix at the root rather than in five places: nomarchy-updates upgrade-window opens its own terminal, so every caller names one env-free command and a theme file stops having an opinion about terminals. checks.waybar-swap-env guards the class — no whole-swap exec/on-click may read an env var ($name; $1 positionals stay legal) — and was proven by reintroducing the bug and watching it fail. The lesson generalises past Waybar: anything Hyprland spawns has no login shell, so a hand-written command that works in your terminal proves nothing about the bar. Bernardo asked for the window to be a small float while this was in flight, so it is a classed sheet (com.nomarchy.updates, 45%×50%) like the calendar and doctor — the third caller of #139's helper, which is what made it a one-line ask rather than a fourth copy of the sizing logic.

  • GTK dialog button labels (#98): Stylix always set gtk-theme-name= adw-gtk3 (light sheet) while recoloring tokens for dark palettes — light sheet hardcodes near-black button text → black-on-dark in GTK dialogs (Boreal). Fix: force adw-gtk3-dark / adw-gtk3 from palette mode, prefer-dark, and pin dialog button colours in Stylix extraCss. Follow-up (same day): pinning the label colour silently broke destructive-action — adw-gtk3 derives only that background from currentColor (mix(@destructive_color, alpha(currentColor,…)), where suggested-action uses an opaque mix(white,@accent_bg,…)), so the dark label dragged the background dark with it: "Delete" at 1.03:1, invisible, in both polarities. Destructive backgrounds are now pinned to the same opaque pastel ladder (enabled-only). V2: dialog rendered under boreal, summer-night and summer-day. Lesson: a config-level assertion ("settings.ini says adw-gtk3-dark") cannot see a colour bug — only a render can.

  • Suspend-then-hibernate (#115): human decision — 1h delay, battery only, Preferences enable/disable. Mechanism: suspend-then-hibernate + HibernateDelaySec=1h + HibernateOnACPower=false; lid HandleLidSwitch=suspend-then-hibernate / HandleLidSwitchExternalPower=suspend when on and boot.resumeDevice set. State: settings.power.suspendThenHibernate (default on). Live path: nomarchy-suspend (hypridle + Power menu). LUKS: unlock session before encrypted hibernate phase so resume is LUKS-only (not hyprlock twice). Menu row self-gates on CanHibernate. V3: HARDWARE-QUEUE bag-carry test.

  • #127's WAKE PATH fixed — but the brick's cause is still open (2026-07-16, AMD dev box). Read the title carefully: the fix below is proven and shipped; the diagnosis published alongside it on the day was wrong and is retracted in place (see "What was claimed, and why it was wrong" below). The clamshell DPMS-off skip (060bf52, and its #136 wording fix) is reverted; idle blanks again in every dock/lid state. What is true and load-bearing. Hyprland ships misc:key_press_enables_dpms and misc:mouse_move_enables_dpms off, and nothing else in the session calls dpms on — so hypridle's on-resume was the only thing that could un-blank a screen: an idle daemon with the display held hostage behind it. That is a real single point of failure, and it is now gone. It needed no story about why the daemon might fail to justify removing it — which is exactly why the fix survived its own diagnosis being wrong. Evidence, on the incident hardware. With hypridle driving the blank, input woke it in all three configurations — including clamshell-docked on the sole live output (nomarchy-display-wake done enabled=1 first=DP-2), which is the incident's exact geometry. So blanking-while-clamshell is innocent and the gate guarded nothing. Then the decisive run: same box, still clamshell, hypridle deliberately stopped (i.e. the brick condition manufactured on purpose), dpms off → a keypress woke it in 6 s with the two options on. The identical test before them stayed black the full 45 s. Why the first TEST B said the opposite, and the lesson. It ran hyprctl dispatch dpms off by hand, which no wake path watches, so "input did not wake it" was guaranteed on any hardware, in any dock state. The tell was the undocked control returning the same result where the theory demanded a different one. A test must put the mechanism under test inside the circuit — this one measured a wake path that was never armed, and nearly bought a permanent behaviour cost with the answer. Why the fix is structural. The wake now lives in the compositor, where no daemon can lose it; a dead hypridle costs auto-lock, not the machine. #135 (an option to opt back into DPMS-when-clamshell) is therefore retired unbuilt — there is nothing left to opt into, and the 27" panels stop being held lit all night under a static lock screen. What was claimed, and why it was wrong (retracted the same day). This entry, hyprland.nix's comment and two commit messages all said the brick was "blank + hypridle deadlock": hypridle hitting hyprwm/hypridle#171, hanging without exiting so Restart=always never fires, leaving a blanked seat with nothing left to call dpms on. Cited evidence: "19 disconnects in 11 days", and Jul 14 as the clean example — "locking normally at 13:02, CRITICAL at 13:08:17, then eleven hours of silence". All of it was an artifact of how it was measured. 18 of the 19 Disconnected from pollfd id 1 lines are hypridle's noisy goodbye while systemd deliberately stops it — each one bracketed by Stopped hypridle / Started hypridle, with a healthy replacement seconds later. The "eleven hours of silence" came from grepping a single PID (hypridle[1679]): systemd logged Stopped hypridle at 13:08:17 and Started hypridle at 13:09:00, and PID 1678 ran fine all evening where the filter could not see it. Only one genuine zombie has ever existed here (2026-07-16 17:25:39, verified by process inspection — ps, ss showing unread D-Bus queues, NRestarts=0 — not by grep), and it needed stray pre-migration linger to persist (BACKLOG #147). The decisive fact: at the time of the brick (before the 12:51:34 mitigation commit) hypridle had no disconnect at all — it had been running healthily since Jul 14 13:09. It should have fired on-resume normally. The deadlock theory never had a case. The lesson is the same one this entry teaches about TEST B, which is what makes it worth the space: the invalid test measured its own scaffolding, and so did the diagnosis that replaced it — a grep that could only see one PID, and a rate that counted normal shutdown noise. Both times the error was inferring where checking was available and cheap. Verify the instrument before believing the reading, especially when the reading confirms the theory. Still open — #127's cause, now the lead question (BACKLOG #127). We know the wake path had a single point of failure and we removed it; we do not know what actually bricked the machine. The dead Ctrl+Alt+F3 is the strongest clue and points away from everything examined so far: neither DPMS-off nor a dead hypridle can break VT switching, so something was wedged below the compositor — DRM master, most likely. Also unknown: whether the machine had been relogged-in between Jul 14 13:09 and the incident (Bernardo does not recall), which is the one fact that would say whether hypridle was ever a suspect worth having. Tooling kept: nomarchy-display-wake / nomarchy-display-dump.

  • Generation prune (#128): weekly nomarchy-gen-prune (system + HM): delete only gens older than 14 days that are also beyond the 3 most recent past gens (current always kept). Stock nix.gc no longer uses --delete-older-than (floor-free). checks.gen-prune self-test + unit wiring. Manual: sudo nomarchy-gen-prune / --dry-run.

  • Recovery + System IA + Control Center retired (#111 / #105 / #110): System menu is Connectivity / Devices / Recovery / Preferences (root still six entries). Recovery is scope-first: Desktop generation (HM), System boot generation (how), Files BTRFS, What changed. Control Center package removed; blur/gaps (presets) in Look & Feel; update checks + Bluetooth/Printing package toggles in Preferences (system toggles run nomarchy-rebuild). #117 absorbed (rebuild no longer left to the user for those rows).

  • Airplane mode (#104): System Airplane mode + SUPER+CTRL+R + Waybar plane glyph (self-hides when off). Runtime nomarchy-airplane softs Wi-Fi (nmcli) + Bluetooth (rfkill/bluetoothctl), remembers prior radio state in $XDG_RUNTIME_DIR, restores on disengage. Whole-swap waybar parity.

  • Crisper fastfetch logo (#122): chafa --symbols sextant at 24×12 (was block 20×10), rsvg width 360. V2 kitty screenshots boreal + summer-night accent; no tofu (fontconfig fallback as today). Diagonals still staircase a little — logo SVG limit, not chafa.

  • State file rename (#107): theme-state.jsonstate.json; CLI nomarchy-theme-syncnomarchy-state-sync (old binary name kept as a symlink). Eval/read shims accept the legacy file name; the next write migrates. Documented in MIGRATION.md. Drop the CLI alias after the next stable release notes call it out.

  • Installed default browser path (#94 residual): live half closed by #103 + checks.live-baseline-apps. Installed path V2 (2026-07-15): re-boot of offline test-install disk — chromium --version on PATH (150.x), xdg-mime https/html → chromium-browser.desktop, Chromium window opens (about:blank). Artifacts /tmp/nomarchy-v2-94/{probe,chromium}.png.

  • No Hyprland update-news on first boot: ecosystem.no_update_news + no_donation_nag so the first session is Nomarchys, not upstreams “Hyprland updated to …!” dialog (seen on every #94/#123 install re-boot).

  • Mime defaults must name a present .desktop (#119): same bug class as #94, one key over. text/plain pointed at code.desktop (vscode), which the live ISO never ships — GIO skips the missing entry and leaves no handler, so a double-clicked .txt did nothing. Fix: ordered fallback code.desktoporg.gnome.TextEditor.desktop (HM/GIO try the next when preferred is missing); live gains mpv too (video defaults had the same hole, free via the template pin). Guard extended: every [Default Applications] key must have at least one listed .desktop in the live HM path or system path (thunar is system-side) and on the default template install — proved to fail when text/plain is a vscode singleton. Template still prefers vscode when present.

  • smartd self-gates on the hardware (#118): Bernardo booted the live ISO and the Waybar health icon was red. Everything downstream was working correctly, which is what made it worth writing down: smartd's config is DEVICESCAN, and where no drive answers SMART it exits 17 ("Unable to monitor any SMART enabled devices"), systemd marks the unit failed, nomarchy-doctor honestly reports a failed system unit, and Waybar paints @bad. The doctor was right; smartd was the bug. Scope was never live-only — services.smartd.enable mkDefaults true on every machine and QEMU virtio exposes no SMART, so every VM install booted to a health warning and every V2 run had been showing it as noise. Fixed by the distro's own self-gate convention: an ExecCondition running smartctl --scan (which prints nothing exactly when smartd would find nothing) leaves the unit inactive instead of failed. Deliberately not SuccessExitStatus = 17 — that would also swallow exit 17 from a machine that does have drives, which is the entire reason the daemon ships. V2: checks.smartd-gate boots the real distro module and asserts both halves, because they pull in opposite directions — a gate that never skips leaves the red icon; a gate that always skips silently disables drive-health monitoring on real hardware, the failure nobody notices until a disk dies quietly. So: no-SMART node → ActiveState=inactive, not failed, absent from systemctl --failed (what the doctor reads); and the gate's logic driven against a scan that finds a device → exit 0, since QEMU cannot answer SMART honestly. Proved to fail by unwiring the condition. V3 pending: on real drives smartd still runs (HARDWARE-QUEUE, dev box).

  • One chromium, not two (#121) — investigated, measured, DECIDED AGAINST (Bernardo, 2026-07-14): "too much work for a negligible gain". The bug is real and the fix worked; the gain is what failed to survive measurement. Kept as a record because the measurement is the valuable part and the next person to spot the duplicate deserves the numbers rather than the discovery. Found by measuring #103's ISO delta rather than by anything failing — nothing was failing, which is the point. nixpkgs' chromium wrapper, with enableWideVine = true (what the template ships, for Netflix/Spotify DRM), runs the -wv copy but links its share/* from the plain unwrapped build: for f in '${chromium.browser}'/share/*; do ln -s …. A symlink is a store reference, so a whole second 687 MiB chromium rode along for a directory of .desktop files nothing executes — on every installed machine, not just the ISO, for as long as chromium has been the default browser. A fix is possible, and was proven (prototyped in 0b464af, reverted in the commit that carries this entry — recover it from there rather than rediscovering it): the -wv copy is cp -a of the browser and is self-contained — it does not reference the plain build (had it done so, no wrapper change could drop the duplicate; check this first if you revisit). Its share/ is byte-identical, .desktop included, so an overlays.default entry that repoints the wrapper's share/* symlinks at the copy we already run removes the duplicate and nothing else — DRM payload verified still present, .desktop byte-identical to stock, and .override { enableWideVine = true; } composes with the overlay's overrideAttrs, so the template needs no change. What it actually saves — and the lesson, which is worth more than the fix (the first pass at this claimed "687 MiB on every install"; measuring the artifact instead of the closure killed that):

    Metric Saving
    Nominal closure (nix path-info -S) 687 MiB (9.38 → 8.71 GiB)
    ISO image ~0 — 8 KiB of 8.078 GiB
    Installed disk ~19 MiB
    Download, cache-based install 195 MiB

    The duplicate was nearly free everywhere it was already stored, because two mechanisms dedupe it and closure arithmetic sees neither: mksquashfs detects duplicate files, so the ISO had stored those blocks once (the -wv copy is cp -a of the plain build — near-identical by construction); and nix.settings.auto-optimise-store is on (modules/nixos/default.nix), which hardlinks identical files, so the pair costs 639 MiB of disk against ~620 MiB for one — verified by inode, not inferred. The one place dedup cannot help is the wire: a binary-cache install fetches the extra path as its own NAR, 195 MiB compressed (cache.nixos.org downloadSize). So this is a download fix, which is exactly what #120's netinstall cares about, and a no-op for the offline ISO that is the default today. Rule this establishes: closure size is not disk size and is not image size. Measure the artifact. #120's size table is closure arithmetic and inherits this caveat — it is stamped there. Why it was reverted rather than kept, though it worked: the fix is a string substitution into a buildCommand nixpkgs owns, so it couples us to wrapper internals and its failure mode is a silent no-op — which meant it also needed a permanent checks.* to assert the invariant, since nothing about a returning duplicate breaks a build. That is a standing maintenance cost against ~195 MiB of download and ~19 MiB of disk. Boring beats clever when the gain is this small (CONVENTIONS: prefer boring, reproducible mechanisms) — so the duplicate stays, honestly, and overlays.default says so in a comment where the next person will look. Revisit if the netinstall (#120) ships, since download is the one axis this actually moves; or better, fix it upstream — the wrapper should take its desktop entry from the variant it wraps, which helps every NixOS user with enableWideVine, not just us.

  • Plymouth logo contrast: the shipped art was a fixed navy that vanished on dark bases. modules/nixos/plymouth.nix now recolors every element from the palette at build time (flat fill, alpha kept): logo/lock/bullet → text, entry/progress-box → surface, progress-bar → accent. Reads on light and dark; follows the theme as of the last system rebuild (like the background tint).

  • Hibernation + zram by default (BACKLOG #76): two memory layers ship on by default. modules/nixos/oom.nix enables zramSwap (zstd, 50% RAM, priority 100) as the compressed-RAM layer before earlyoom; the installer defaults the disk swap to a RAM-sized hibernation-ready swapfile on an @swap btrfs subvolume inside the encrypted volume, with patch-template.py wiring swapDevices + boot.resumeDevice + resume_offset (from btrfs inspect-internal map-swapfile -r). The priority split is the point: day-to-day paging stays in zram, so the disk swapfile is reserved for the hibernate image. swapSize=0 stays a clean no-swap opt-out; the Power-menu Hibernate row is kept unconditional and notifies on failure (no-swap machines) rather than silently no-op'ing. Existing machines get an enable-hibernation runbook in docs/MIGRATION.md (no tool). Guarded by three VM checks — checks.zram-swap (device/algo/ priority), checks.hibernate (a full hibernate→resume→crash cycle on a swap partition that proves the resume mechanism + that zram at priority 100 doesn't swallow the image), and checks.hibernate-swapfile (the installer's btrfs @swap swapfile build — NOCOW mkswapfile, file-type swapon, a valid map-swapfile -r offset the kernel accepts, zram above it). A full hibernate from a runtime swapfile isn't headless-reachable (systemd CanHibernate=no for undeclared swap; and a runtime-computed resume_offset can't be fed into the boot cmdline), so the literal encrypted-swapfile power-cycle + LUKS-initrd unlock stayed a V3 laptop check — V3 PASSED 2026-07-12 on TuringMachine (T14s, LUKS+@swap swapfile layout): hibernate → power off → single LUKS unlock → session restored. ✓ Doctor hibernate/sleep section (#77, VISION § C): nomarchy-doctor gained a read-only hibernate row group — resume device on the cmdline (+ resume_offset for a swapfile), disk swap ≥ RAM, zram active, and any suspend/hibernate error in the previous boot's kernel log. Self-gates (skip on swap=0); only ok/warn/skip, never fails the sheet. The checks.doctor VM test asserts the hibernate row renders. (Gotcha recorded: the script runs set -euo pipefail, so a no-match grep inside $(…) or a cond && action aborts it — use || true and if.) ✓ Doctor battery health (#80, VISION § C): report-only cycle_count + retained capacity (charge_full/charge_full_design or energy_*) as % of design, system batteries only (not Device-scope peripherals). Self-gates when attrs missing; soft warn below 70% of design. NOMARCHY_POWER_SUPPLY_ROOT for the checks.doctor fixture (test_power has no cycle/design attrs). ✓ First-boot welcome toast (#81, VISION § B): oneshot user unit nomarchy-first-boot fires a dismissible “You're set” notify (SUPER+M / SUPER+T / SUPER+? + Network + Doctor pointer) once, then writes settings.firstBootShown into the flake checkout (--no-switch). Skips hostname == nomarchy-live (live ISO keeps its own welcome). Toggle: nomarchy.firstBootWelcome.enable. checks.first-boot gates toast / marker / re-run silence / live skip. ✓ Generation readability (#82, VISION § B): nomarchy-what-changed wraps nvd into plain-language counts (added/removed/updated); post-switch toast from nomarchy-rebuild / nomarchy-home; System What changed? (toast + floating full report). checks.what-changed fixtures nvd via NOMARCHY_NVD. ✓ Doctor first-boot pre-activate (#83, VISION § A): if /var/log/nomarchy-hm-preactivate.log exists and no Home Manager generation is present, doctor ✖ with home-manager switch --flake ~/.nomarchy -b bak. Skips when log absent; ok once a generation exists. checks.doctor covers both arms. ✓ Hardware hall of fame (#85, VISION § F): HARDWARE.md §9 install-tested table (T14s Gen 4, Latitude 5410/5310) + “send your DMI line” PR invite; §10 points contributors at the table. ✓ Clamshell logind (#86, VISION § C): explicit services.logind.settings.Login.HandleLidSwitchDocked = "ignore" when power management is on (systemd docked = external display present). Undocked lid suspend left to upstream. checks.clamshell-logind asserts logind.conf + active logind. Real lid+external V3 in HARDWARE-QUEUE. ✓ Dock audio sink priority (#87, VISION § C): WirePlumber 90-nomarchy-dock-audio raises priority.session for HDMI/DP and USB ALSA sinks so they become default when present; unplug falls back to built-in. Rules in modules/nixos/dock-audio-rules.nix; checks.dock-audio pure + conf-file VM. Real hotplug V3 in HARDWARE-QUEUE. ✓ Dock audio round 2 — auto-follow watcher (2026-07-12, superseded by #100 below): the T14s dock test showed priority rules alone can't deliver "just works": WirePlumber's stored default (default-nodes state, written by any past explicit device pick — every real machine has one) outranks priority.session, so the default never moves on plug. Fix: nomarchy-dock-audio (modules/home/dock-audio.nix, nomarchy.dockAudio.enable, default on) — initially a pactl subscribe watcher that selected new/card-changed dock-class sinks (regex data imported from dock-audio-rules.nix, single source) + toasts; startup sweep for login-while-docked (only when the default isn't already dock-class, so restarts don't override a manual pick). EasyEffects needs nothing: EE 8 useDefaultOutputDevice defaults true and verified live — with a stream playing, EE's output links moved to a fake dock-named null sink and back on unload. Unplug = WirePlumber priority fallback to built-in. Docked menu rows shipped alongside (the original panel-disable action is superseded by the hardware-QA correction below); Audio has a self-gated "Send output → " quick row plus the full output picker. ✓ Undock blackout rescue (2026-07-12): Bernardo asked whether the panel comes back if you undock while "Laptop screen off" — VM answer (tools/monitor-fallback.nix, full softGL desktop): no, Hyprland 0.55.4 leaves zero active outputs and never re-enables a soft-disabled monitor. Fix: rescue_blackout in the display hotplug watcher (hyprland.nix) — on monitorremoved with zero active outputs, immediately (and with retries) keyword monitor <name>,preferred,auto, auto every disabled output + toast; independent of the profile auto-switch gate (safety invariant, not a preference). Sharp edge found while testing: the zero-output state itself crashes Hyprland in the VM ~4/5 runs (ABRT in aquamarine CBackend::dispatchIdle, ~0.5s — faster than any userland rescue; control run with the panel active survives 5/5, so it's the state, not headless removal; monitor- disconnect crashes are a known upstream class). Posture: rescue ships as defense-in-depth (recovers every surviving case; worst case on real DRM is session-to-greeter on the re-lit panel, not a black brick). Also fixed in the rescue: grep -c . exits 1 at count 0 — an ||-guard on that pipeline silently disabled the rescue exactly when it mattered. ✓ Docking hardware-QA correction (2026-07-13, superseded by #100): real clamshell QA confirmed every warned residual: Rofi's default monitor=-5 followed the mouse pointer onto the hidden laptop workspace; the profile watcher (and blackout rescue) was not launched with default displayProfiles = {}; disabling eDP then unplugging failed to restore it, with application loss consistent with the known zero-output crash; audio did not arrive as a new sink; and the keyboard picker was disabled by an empty optional-candidate list. Correction: Rofi targets the focused output (monitor=-1); the first correction's interactive Dock mode moved eDP workspaces/focus but kept eDP logically active as a fallback; the reconnecting display watcher runs unconditionally and watches through the racy removal snapshot; keyboard hotplug runs by default, searches the pinned XKB catalog without D-Bus, persists per-device, and has a manual System Keyboard path; that correction's dock audio reacted to new sinks and card/profile/port changes (replaced by #100's monitor-event boundary); and monitor-added re-applies the current awww wallpaper after the output settles (new outputs do not inherit the existing image). checks.docking-ux guards the generated default-session artifacts; the corrected real sequence remains V3. ✓ Dock transition recovery (#100, 2026-07-13): the second T14s pass exposed why keeping eDP active was not safe: Hyprland requires an active workspace on every active output, so one workspace necessarily dangled. Cable removal with the lid closed was independently proven in the previous boot journal to make logind enter s2idle, explaining the apparent crash. nomarchy-display-transition now follows the old working flake's ordering: dock is one batch (enable external, move all internal workspaces, focus external, disable internal); undock enables internal first, waits boundedly, then moves every workspace back. Named profiles that disable the internal panel use the same transition when they have an external target. nomarchy-display-profile-watch owns a block-mode, low-level handle-lid-switch inhibitor from external detection through internal restoration and physical lid-open; it uses process substitution so state stays in the parent shell, verifies acquisition through logind, kills a private process group on cleanup, and validates/cleans a stale runtime PID after an unclean exit. Normal undocked lid-close suspend resumes after release. Audio now uses monitoradded as the fresh-plug intent boundary: after a debounce and two-second hardware settle it restarted PipeWire, PipeWire Pulse, and WirePlumber, then explicitly selected the highest-priority available sink using the original WirePlumber regex data (the restart was demoted to a last resort by #138 below — the selection is the part that works). Generic change:sink/card events never select, so a manual speaker choice sticks; the subscription loop reconnects after graph restarts. Journal lines record the trigger and selected/no-candidate result. checks.docking-ux syntax- and invariant-checks generated artifacts; tools/monitor-fallback.nix is now a bounded KVM test for workspace/output/inhibitor transitions and passed V2. QEMU's headless backend aborts Hyprland if its final active synthetic output is deleted while the DRM output is disabled, so the harness explicitly runs the internal-first recovery before deletion and leaves real cable-removal timing to V3. V3 is queued as round 4; no speculative audio power policy was added. ✓ Docking rounds 45 — the hotplug event is not the truth (2026-07-14): the dev-box dock pass failed on wallpaper, dock mode, docked audio and a four-times keyboard prompt. Three causes, and a process lesson. socat -T 1 closed Hyprland's IPC socket after a second of inactivity — a dock is normally plugged into an idle desktop, so monitoradded was routinely lost in the reconnect gap, and wallpaper, profile and audio all hung off that one event. The connection is now long-lived with the tick coming from a 1s read timeout, and the output set is the source of truth: reconcile diffs it on ticks and events alike, so an event only makes the reaction instant. A newly-present external is as unambiguous a plug as the event, so it may still override a manual sink choice. Automatic dock mode never existed — the watcher auto-undocked but on plug only matched display profiles, so with none saved it did nothing while Dock mode stayed menu-only; the pair is now symmetric, with a matched profile winning as the more specific layout. Hyprland calls every key-capable evdev node a "keyboard" (lid/power buttons, a monitor's control channel, each extra HID collection), so udev's ID_INPUT_KEYBOARD is now the filter and the prompt is once per physical keyboard, applied to every node it brings. Audio to the BenQ was unreachable by any means because WirePlumber had the GPU's DP card pinned to pro-audio, whose raw pro-output-N sinks have no ports, no routing and no volume — and card profiles persist, so it survived forever; a plug that finds nothing routable now repairs cards carrying an available HDMI/DP port (the monitor asking for audio over the cable), which keeps the internal analog card and considered pro-audio setups untouched. On UCM cards headphones are a sink rather than a route, and a stored default outranks every priority rule, so the jack is now followed explicitly; unplug needs no undo since the pin goes stale by itself. The process lesson: both watchers are exec-once, so nomarchy-home leaves the old processes running and two of the four reports were never testing the fix — relogin before testing a watcher. V3 is queued as round 6. ✓ Dock audio round 7 — the graph restart was never the fix (#138, 2026-07-16): Bernardo's Meet calls kept losing their microphone; the cause was our own reprobe restarting PipeWire on every monitor plug. Chromium's audio service does not reconnect when the server goes away under it, so it runs on enumerating nothing — and Zoom, started fresh into a settled graph, looked fine and made it read like a browser bug. Ordering across four boots was the proof: restart-after-Chromium in both broken sessions, restart 24s before Chromium in the one healthy session; a dock/undock on demand then reproduced it live. reprobe now escalates cheapest-first — select → repair a parked card's profile → restart the graph — so the common plug never touches the graph and no client loses its connection. Measured on the dev box: selection alone picks the same sink the restart-first path picked, in 2.0s instead of ~3.5s, with PipeWire/Pulse/WirePlumber PIDs unchanged. The restart survives as rung 3 for the codec that only publishes its route after a re-probe — the one claim the original comment made and never demonstrated. The lesson is the shape of the bug, not the audio: the restart was inherited from "the old working flake" as a recovery, ran first for a session-day, and its collateral was never priced. A cure carried over from a working system is still an unproven cure — and a sledgehammer's cost lands on whoever was already holding a connection, which is nobody at login and everybody at 11am. A slow-appearing sink is polled for (wait_for_dock_sink) rather than read as "nothing to select", so the ordinary plug cannot fall through to rung 3. ✓ Conditional layout indicator + swap parity (#109, 2026-07-14): the indicator answers "which layout am I typing in?", so it earns bar space exactly when that has more than one answer — not when a device merely exists. Counting devices was the trap: the junk rows a keyboard's extra HID collections leave behind are all remembered at the session layout and added no second answer. The gate reads the runtime-remembered settings.keyboard.devices alongside the declared option, since the watcher writes them without a rebuild and they only graduate later — reading the option alone was wrong for exactly as long as the memory was fresh. keyboard.layouts is excluded on purpose: hyprland.nix keeps it as the picker's candidate pool and never merges it into kb_layout, so it cannot make the current layout ambiguous by itself. All four whole-swaps list the module unconditionally and so used to show what the generated bar hid; they are filtered through the same answer rather than re-deriving it by hand (the parity rule). Logic is pure in modules/home/waybar-language.nix; checks.waybar-language unit-tests both halves against boreal's real swap. ✓ Parallel fingerprint-or-password (2026-07-12, Bernardo promoted from PROPOSED): nomarchy.hardware.fingerprint.parallel (default true — the better UX is what opting into fingerprint PAM gets you; false restores stock sequential pam_fprintd). Implementation: packaged pam-fprint-grosshack v0.3.0 (pkgs/, GitLab pin — the field-standard fprintd fork; stock PAM cannot express parallel factors, linux-pam#301; the cleaner-concept pam-any stays the fallback candidate if grosshack fights greetd/hyprlock). Source reviewed before packaging: every failure path returns PAM_AUTHINFO_UNAVAIL (falls through to password) and a typed password is only ferried via PAM_AUTHTOK to the stock auth sufficient pam_unix.so … try_first_pass — the hack never validates passwords itself, so password login cannot be locked out by it. Wiring reuses stock fprintd's rule slot (mkForce'd modulePath) so ordering is inherited, not recomputed. checks.hardware-toggles grew to three nodes: parallel default (stack shape + password-works/ wrong-password-fails with no reader), parallel=false (stock pam_fprintd), pam=false (neither). Real-reader race → HARDWARE-QUEUE (AMD dev box). Accepted quirks (documented in the package): process- wide SIGUSR1 handler, pthread_cancel'd prompt on fingerprint win, prompt buffer not zeroized. ✓ neon-glass finish (#88, VISION § D): rewrote whole-swap rofi.rasi to rofi-native @color glass panel (cyan rim, soft selection wash — same hex-alpha glass pattern as Boreal); btop title/ selection in accent cyan; ui rounding/opacity tuned; preview.png regenerated from theme-shot. Bar remains generated from JSON (no waybar.css reintro). Contrast + theme-wholeswap green; V2 theme-shot neon-glass + boreal viewed.

  • Hibernate double-unlock: on resume from hibernate the LUKS passphrase already gates the machine, but locking hyprlock before sleep meant a second password. Fixed by not locking before an encrypted hibernate: a nomarchy-lock-before-sleep systemd unit (modules/nixos/default.nix) takes over from hypridle's before_sleep_cmd and locks on the RAM-resume sleeps (suspend / hybrid-sleep / suspend-then- hibernate) always, but skips hibernate.target when the disk is LUKS- encrypted. (A first attempt that dismissed hyprlock after resume was wrong — killing a Wayland session-lock client trips its "go to a tty" crash failsafe instead of unlocking, which is the error screen it caused.)

  • Key agents & pinentry: ships modules/home/keys.nix (nomarchy.keys.enable): one agent — services.gpg-agent with enableSshSupport fronts SSH, so a single pinentry-qt (native-Wayland, Stylix-themed Qt) handles GPG and SSH passphrases alike. SSH_AUTH_SOCK comes from the agent's zsh integration; cache TTLs (30 min / 2 h) govern re-prompting. gnome-keyring stays the Secret Service (modern versions run no SSH agent, so no socket contention); screen lock doesn't flush the cache. ✓ session-level SSH_AUTH_SOCK: besides the zsh integration, a home.sessionVariables export now covers GUI clients launched outside a shell (rofi launcher, autostarted apps) that never inherit the interactive shell's copy — resolved with gpgconf --list-dirs agent-ssh-socket at session-init (the same lookup the shell integration uses, so the two can't drift), reaching GUI apps via the login shell that starts Hyprland the same way NIXOS_OZONE_WL does. Verified by building the home generation and inspecting the rendered hm-session-vars.sh — it carries export SSH_AUTH_SOCK="$(…/gpgconf --list-dirs agent-ssh-socket)" with the command substitution intact (unescaped, resolved at session-init). Pending an on-machine check that a GUI git/ssh client picks up the agent.

  • Sanitize & organize the repo: a housekeeping pass for consistency and clarity.

    • ✓ pruned now-redundant config: the installer no longer writes console.useXkbConfig / boot.initrd.systemd.enable into system.nix (distro-wide defaults since the LUKS-keymap fix), nor the offline-pin test fixture in flake.nix.
    • ✓ reconciled the README option tables with the live nomarchy.* surface (added keys/fastfetch/snapper/greeter.autoLogin).
    • ✓ untracked .claude/settings.local.json (machine-local) + gitignored.
    • branch/release model: main is the development default; v1 is the release pointer downstreams pin (?ref=v1), advanced to main once a batch is tested — rolling, no tags. Brought main current (it was a stale legacy-merge), and deleted the dead refactor / wave/nixos-26.05 branches (kept legacy for history). See the comment in flake.nix.
    • ✓ re-checked that every file earns its place under the modules/hosts/themes/pkgs/tools rule of thumb: no orphaned modules (keybinds.nix is a data module import-ed by hyprland/rofi, so correctly absent from default.nix), no dead-code markers, no editor/backup cruft, each themes/<slug>.json palette pairs with its optional <slug>/ overrides dir, and hosts/{default,live} are the documented reference + ISO hosts. Structure is clean as-is.
    • Open decision (deferred): the repo declares no formatter (no flake formatter, no .editorconfig); .nix files use deliberate aligned hand-formatting, so nixfmt-rfc-style --check flags ~33 files. Adopting a formatter would be a one-time repo-wide reformat that flattens that alignment — a maintainer call, not a silent cleanup, so left untouched.
  • Full docs review & restructure (completed 2026-07-04 in four slices): the roadmap/backlog split (this file + agent/BACKLOG.md); option tables reconciled with the live surface and guarded permanently by checks.option-docs; drift pass over OVERRIDES/TESTING/template README (claims held; two gaps fixed); the install/first-run story now hands off README §3 → template README explicitly; and a new docs/RECOVERY.md runbook (generations → journals → older boot generation → snapper → nixos-enter). Still open, deliberately: the docs-site-vs-Markdown call (BACKLOG § Decisions).

  • Laptop power / battery management: a real power story behind a nomarchy.system.power.* surface (modules/nixos/power.nix), replacing the old "only services.upower for Waybar reporting" baseline:

    • profiles: power-profiles-daemon ships by default (the upstream- aligned choice — power-saver/balanced/performance via powerprofilesctl, switched through polkit so no root prompt). A power-profile menu module (in the SUPER+M picker) and the first Waybar custom/* indicator (click to cycle) both self-gate on a battery being present + PPD running, so they hide on desktops and under TLP — no system→home wiring. TLP is the opt-in (backend = "tlp") for deeper battery tuning; the two are mutually exclusive (asserted).
    • thermal: thermald behind power.thermal.enable; the installer turns it on for a GenuineIntel CPU.
    • longevity: power.batteryChargeLimit (e.g. 80) — a backend- independent sysfs oneshot writes charge_control_end_threshold. Off by default; the installer scaffolds it commented-out on laptops. ✓ the threshold is now re-applied on AC state changes (a services.udev rule on SUBSYSTEM=="power_supply", ATTR{type}=="Mains" starts the oneshot via systemctl --no-block), closing the firmware-resets-on- unplug gap — the match is by adapter type, not kernel name (AC/AC0/ADP1/ACAD vary). The successful oneshot returns inactive and repeated start requests coalesce while it is applying; this avoids a USB-C dock's event burst repeatedly terminating restart jobs and tripping systemd's start limit. Eval-verified both ways (rule present with charge limit on / absent off), and VM-verified the trigger (checks.battery-charge-limit: the test_power module fakes a Mains adapter, toggling ac_online emits a real power_supply uevent, and the udev rule starts the oneshot — a burst during an intentionally extended active run stays successful and a changed InvocationID proves reapply). The sysfs write itself needs a real charge_control_end_threshold, so a final on-hardware check (a sudo nixos-rebuild + a physical unplug) remains — the dev box has both an AC Mains adapter and a BAT0 charge_control_end_threshold, so it can exercise it.
    • installer: writes power.laptop = true (battery probe) and power.thermal.enable (Intel) into the generated system.nix.
    • idle cohesion: modules/home/idle.nix now suspends only on battery (and sooner — 15 min vs the old fixed 30), gating the hypridle suspend listener on a nomarchy-on-ac probe so a plugged-in machine stays up mid-idle; lock/screen-off are unchanged on both sources. logind's lid handling is left at its defaults (suspend on lid close, ignore when docked) — an explicit "I'm done" that coheres with the idle behaviour.
    • Waybar parity: the custom/powerprofile indicator now shows in the summer-day/night whole-swap themes too. powerProfileStatus/Cycle are named writeShellScriptBins on PATH (waybar.nix home.packages), so the generated config and the static waybar.jsonc both exec them by bare name (the same parity the DND bell has). General pattern: any new generated-config module needs adding to the whole-swap themes too.
  • Full hardware enablement (beyond nixos-hardware): nixos-hardware profiles cover basic per-model functionality (quirks, drivers), but the advanced features of a machine — keyed more on the CPU/GPU generation than the model — are left for the user to wire up by hand. Close that gap without making it finnicky: extend the installer's existing autodetection (DMI → nixos-hardware profile today; add CPU vendor/arch, GPU, NPU, fingerprint reader, SSD) to switch on the safe, broadly-beneficial features automatically, and expose a small nomarchy.hardware.* surface (per-vendor where it helps, e.g. nomarchy.hardware.amd.*) for the heavy or experimental bits. Goal: full utilisation out of the box — opt-out for the safe defaults, opt-in for the multi-GB / experimental extras. Complements nixos-hardware (model quirks) rather than replacing it; keep the detection in the installer's hardware-db so an installed machine bakes the right defaults, all overridable through nomarchy.hardware.*.

    • Mechanism + broad seed shipped (modules/nixos/hardware.nix): a vendor-keyed nomarchy.hardware.* surface (intel, amd, fingerprint, npu), hardware-db.sh extended to detect Intel/AMD, a fingerprint reader (libfprint USB vendor IDs) and an NPU (Intel VPU / AMD XDNA PCI IDs), and the installer bakes the matching toggles into system.nix — safe defaults active (GuC/HuC, amd-pstate, AMD VA-API env, fprintd), heavy/experimental opt-ins commented (Intel compute-runtime, ROCm, fingerprint PAM, NPU driver). Audited against the nixos-hardware commons so it only fills the gap above them — microcode / the media-driver VA-API stack / weekly fstrim already come from common-cpu-* / common-gpu-* / common-pc-ssd. Exercised live on the dev machine's detection (an AMD Ryzen-AI laptop: AMD
      • fingerprint + NPU all detected) and a toggles-on build (amd_pstate=active
      • i915.enable_guc=3 in kernel-params, fprintd.service present). Remaining: on-hardware verification of the runtime bits (AMD/NPU/Intel GPU-compute) — none are testable in CI or on the Intel Latitudes for the AMD paths. SSD TRIM is intentionally left to common-pc-ssd.
    • Hardening (driver-gen + kernel awareness): intel.guc emits the i915 param, so the installer turns it off when the GPU is on the newer xe driver (Lunar Lake / Battlemage / Panther Lake — GuC is default-on there); the NPU detector matches the PCI accelerator class (not just a device-ID list) so new gens are caught without a code change; and a nomarchy.hardware.latestKernel escape hatch (+ a build-time warning when npu.enable predates the shipped kernel) covers very-new hardware whose drivers only just landed. A config-assertion VM test guards the wiring (checks.hardware-toggles: kernel cmdline + fprintd + PAM, booted in a VM). Worked example — ThinkPad T14s (Ryzen 7 PRO 7840U + Radeon 780M):
    • GPU compute — ROCm: the 780M is an RDNA3 iGPU (gfx1103) ROCm doesn't officially list, so it needs HSA_OVERRIDE_GFX_VERSION=11.0.0. Multi-GB closure → opt-in (nomarchy.hardware.amd.rocm.enable); unlocks GPU PyTorch / Ollama.
    • Ryzen AI NPU (XDNA): the amdxdna kernel driver (mainline 6.14+) + the XRT/xdna userspace runtime + firmware. Niche/experimental → opt-in.
    • AMD P-State EPP (Zen 4 power): amd_pstate=active for the EPP governor, which power-profiles-daemon (already shipped, power.nix) drives per profile. Broadly beneficial on Zen 2+ → default on when the installer sees an AMD CPU.
    • Hardware video acceleration (VA-API): mesa's radeonsi exposes VA-API on AMD (LIBVA_DRIVER_NAME=radeonsi, libva + drivers in hardware.graphics.extraPackages); Intel uses intel-media-driver. Broadly beneficial → default on, vendor-detected.
    • LVFS firmware & biometrics: fwupd already ships (✓, services.fwupd). Add fingerprint: services.fprintd when a reader is detected, with opt-in PAM integration for login/sudo (password-only stays the default for the cautious).
    • SSD periodic TRIM: services.fstrim.enable (weekly) — safe and beneficial, so default on (trivial enough it may warrant being distro-wide regardless). Sub-items here can graduate into their own roadmap entries as they're scoped; the unifying work is the detection + nomarchy.hardware.* surface.
  • Webcam support & tuning: improve out-of-the-box webcam behaviour. Motivating case — on the ThinkPad T14s AMD Gen 4 the camera shows a dark, low-quality image. Diagnosed on hardware (2026-06-26), and it's not what the first cut of this item guessed (UVC default-controls tuning vs a MIPI/ libcamera gap):

    • The camera is a USB UVC module (Chicony 04f2:b7c0, uvcvideo) and is dual-sensorvideo0 Color (MJPG, up to 2592×1944 / 1080p) + video2 IR (8-bit GREY only, the face-unlock sensor). The AMD IPU [1022:1502] is present on PCI but unused (the camera enumerates over USB, not the MIPI/ISP path), so the libcamera-software-ISP branch is moot on this machine.
    • The raw color capture is fine: at factory defaults with auto-exposure, /dev/video0 measures luma ≈130/255 from the first frame (no AE ramp, not dark); forcing manual exposure made it worse. So there is no v4l2 control default to bake — the speculated v4l2-ctl-tuning / udev-oneshot fix is the wrong tree.
    • Real cause is the consumption path. PipeWire/WirePlumber exposes the device through both backends at once — two [v4l2] sources (node 144 = /dev/video0 Color, node 146 = /dev/video2 IR) plus two [libcamera] nodes (Color + IR) — and the IR sensor is presented as an indistinguishable "Integrated Camera". So an app's camera picker shows up to four identical entries, and choosing the IR one yields a black/dark monochrome frame; the libcamera path can also negotiate the low-res YUYV 640×480 mode ("bad quality"). The default source is the color node, so apps that don't let you choose are fine — the breakage is selecting (or an app auto-selecting) the wrong node.
    • Fix — validated live on hardware (2026-06-26). Two WirePlumber 0.5 drop-ins collapse the four entries to one clean color camera, confirmed via wpctl status (before: 2 v4l2 + 2 libcamera incl. both IR nodes → after: 1 v4l2 source = /dev/video0 color, 0 libcamera):
      1. Hide the IR nodemonitor.v4l2.rules matching the IR sensor → node.disabled = true. (Tested by card name ~.*Integrated I; the shipping match should key on the more robust, vendor-neutral heuristic of a GREY-only / no-color-format node, since other vendors' IR cards are named differently.)
      2. Drop the duplicate backendwireplumber.profiles.main.monitor.libcamera = disabled, since a plain UVC cam is fully covered by v4l2 (also kills the libcamera low-res-YUYV path). Drawbacks / design constraints for the module:
      • Rule 2 (libcamera off) is only safe when a UVC camera exists — on a MIPI/IPU-only machine (no UVC fallback) it would kill the camera entirely, so it must be conditional on detection, not blanket. Rule 1 (IR-hide) is broadly safe. Exactly the nomarchy.hardware.*-gated targeting the parent item calls for.
      • Face-unlock is not broken: node.disabled only hides the PipeWire node; the kernel /dev/video2 stays openable, so Howdy (which reads the IR device directly, bypassing PipeWire) still works. A true MIPI/IPU software-ISP camera with no UVC fallback stays a separate future item.
    • Shipped (2026-06-27): nomarchy.hardware.camera.hideIrSensor (+ an overridable irMatch regex) in modules/nixos/hardware.nix emits rule 1 via services.pipewire.wireplumber.extraConfig; the installer's hardware-db.sh auto-detects a paired RGB+IR webcam (from /sys/class/video4linux/*/name) and bakes the toggle into system.nix, with a commented example in the downstream template. Decision: v4l2 IR-hide only — libcamera is left untouched so an external camera you plug in is never affected. Surgical internal-only libcamera scoping proved impossible: the distinguishing device props (api.libcamera.location, device.product.name) bind after the monitor rule runs, and the only early-matchable prop (device.api) is all-or-nothing — so a broad libcamera-off was the only option and was rejected as the blunt instrument it is (external USB cams are UVC and keep working via v4l2 regardless). Verified: installer detection fires on the T14s; the generated drop-in's serialized content is valid WP-0.5 config; and the exact shipped irMatch was re-confirmed live (1 V4L2 source = the colour /dev/video0, libcamera untouched, IR node disabled in the WirePlumber log). Remaining: an on-Nomarchy end-to-end check (HARDWARE-QUEUE T14s); optional v4l-utils + cameractrls already commented in the template.
    • Portal/Flatpak IR gap documented (#71, docs only): the shipped v4l2 IR-hide does not cover libcamera / xdg-desktop-portal / Flatpak camera pickers (IR can still appear there). User-facing note in HARDWARE.md §7. Engineering options (b) WirePlumber libcamera GREY-only rule and (c) libcamera/udev-layer hide stay deferred — both need a T14s-class dual-sensor machine to verify; no libcamera rules in-tree.
  • Memory-pressure protection (earlyoom, default-on): modules/nixos/oom.nix — running out of memory kills the offending process instead of freezing the desktop. earlyoom over systemd-oomd, deliberately: oomd kills whole cgroups, and a Hyprland session is ONE scope (no per-app systemd scopes here, unlike GNOME) — under pressure it would take out the entire desktop; earlyoom kills the single largest process before the thrash point. nixpkgs default-enables oomd inert (no slices monitored) — disabled outright so there's one owner. Session plumbing is --avoid-listed (Hyprland/hyprlock/greetd/waybar/pipewire/ wireplumber/Xwayland/nix-daemon/systemd — unanchored, since NixOS wrappers rename comm to .foo-wrapped); no --prefer tuning (largest- RSS selection already finds the hog); kills raise a desktop notification (systembus-notify). All mkDefault — opt out with services.earlyoom.enable = false. VM-verified (checks.oom-protection): a chunked allocator peaked at ~686 MB in a 1 GB VM, earlyoom SIGTERM'd it in 0.1 s, a bystander unit survived, and oomd is asserted off.

  • Opt-in services & integrations: the counterpart to the opt-out application suite above — heavier or more personal integrations shipped off by default, each a nomarchy.services.<name>.enable toggle a downstream flips on in one line. Keeps the base lean while making common additions trivial. ✓ Seventeen shipped in modules/nixos/services.nix (system-side), each with a commented example in the downstream system.nix template: tailscale, syncthing (runs as the login user, GUI on 127.0.0.1:8384), podman (rootless, docker aliased, user subuid/subgid), flatpak (+ Flathub remote added by a oneshot), pika (Pika Backup GUI), steam (programs.steam — the 32-bit stack, controller udev, Remote-Play ports a bare package can't), libvirt (libvirtd + virt-manager, user added to the libvirtd group), obs (OBS Studio + a v4l2loopback virtual camera usable as a webcam in Zoom/Teams), docker (rootful; asserts against the podman docker-compat), kdeconnect, gamemode, adb (android-tools; systemd handles the udev rules), wireshark (Qt GUI + the wireshark group), ollama (local LLM API on 127.0.0.1:11434), printing (CUPS + Avahi mDNS), openrgb (RGB lighting daemon), and restic (scheduled daily backup — a small option surface: repository/passwordFile/paths, 7/4/6 retention). More candidates by area:

    • cloud/sync: Syncthing (services.syncthing); Nextcloud client is an upstream-HM opt-in (services.nextcloud-client, commented in the template — installs the app + starts it with the session; #102)
    • local AI: Ollama (nomarchy.services.ollama, optional GPU accel) ships; LM Studio is a (commented) app-suite package (a bare GUI, no service) — pairs with the menu's Ask-Claude philosophy
    • containers/VMs: Podman (virtualisation.podman); libvirt + virt-manager (nomarchy.services.libvirt); Docker (nomarchy.services.docker)
    • networking: Tailscale (services.tailscale); WireGuard needs no toggle — NetworkManager (on by default) imports .conf tunnels natively; the wireguard-tools CLI is in the app-suite menu
    • gaming/media: Steam (nomarchy.services.steam); OBS Studio (nomarchy.services.obs, with the v4l2loopback virtual camera); GameMode (nomarchy.services.gamemode)
    • devices: printing (CUPS + Avahi, nomarchy.services.printing); KDE Connect (nomarchy.services.kdeconnect); OpenRGB (nomarchy.services.openrgb). Plus dev tooling — Android adb and wireshark toggles
    • backup: Pika Backup (pika-backup, GUI over Borg); scheduled headless restic (nomarchy.services.restic)
    • escape hatch: Flatpak (services.flatpak) + Flathub, for apps outside nixpkgs

    Surface decided: nomarchy.services.*, system-side for system services (home-side ones can extend the same prefix later). Remaining: curate the rest (Ollama, containers, Steam/OBS, printing, backups, Flatpak …). The opt-out application suite is deliberately not a parallel nomarchy.apps.* surface — it's a curated home.packages list in the downstream template (see "Default application suite" above), since a bare package install has no config to put behind a toggle. Unfree entries are already covered (allowUnfree = true).

  • Display / monitor management: declarative nomarchy.monitors (a per-output schema — name/resolution/position/scale/transform/mirror/ bitdepth/vrr/disable) generates Hyprland monitor rules, keeping the ,preferred,auto,1 wildcard as the fallback; Hyprland applies them on hotplug, so a declared external/dock output arranges itself on connect (no kanshi — it fights Hyprland's own output management). nwg-displays ships behind nomarchy.displays.enable as an interactive arranger (a helper to find values; the declarative config stays the source of truth — its output file isn't sourced). The System ▸ Display menu picks an output's resolution from its advertised modes: applied live via hyprctl keyword monitor (current position + scale kept) and persisted to the in-flake state (settings.monitors.<name>, no rebuild), overlaid onto nomarchy.monitors by name so the next rebuild bakes it in — the monitor twin of the keyboard-layout graduation. Remaining: true docked/undocked profile switching of the same outputs (Hyprland's per-output rules cover the common "external connects → arrange" case, not multi-layout toggles), and optionally workspace-to-monitor binding.

  • Night light / blue-light filter: nomarchy.nightlight (opt-in) — a scheduled colour-temperature shift via hyprsunset (Hyprland-native), warm at night, identity (no shift) by day. modules/home/nightlight.nix drives the HM services.hyprsunset with two time-based profiles (sunrise → identity, sunsettemperature), so hyprsunset handles the schedule and the on-login state. Active-profile-at-session-start was confirmed on hardware (2026-06-18). ✓ Menu + Waybar toggle (opt-in, instant, in-flake state): two git-tracked keys in the state file, both menu-written (exposed via the new nomarchy.settings option) — settings.nightlight.installed (sticky: gates the hyprsunset unit; nomarchy.nightlight.enable mkDefault-reads it) and settings.nightlight.on (runtime on/off). Off by default. The first enable from the menu writes installed and rebuilds to create the unit (the one accepted rebuild); every toggle after is instantnomarchy-state-sync set settings.nightlight.on … --no-switch (atomic + git add -N, no rebuild) plus a systemctl start/stop. Splitting the sticky flag from the on/off is what avoids a decay bug: an instant-off writes only on, so an unrelated later rebuild (e.g. sys-update) never drops the unit and "on" stays instant. Persistence across logout/reboot comes from an ExecCondition on the unit (nomarchy-nightlight should-start) that reads the live working-tree on/off at start time — not the eval-frozen store copy — so an off survives a reboot with no rebuild; a later rebuild bakes the same value. No more ~/.local/state marker, no marker ConditionPathExists. The nomarchy-menu System-submenu entry is always shown (current on/off) so the feature can be enabled from the menu; the self-gating Waybar custom/nightlight indicator shows the moon while running and hides otherwise. This is the first cut of a broader principle — any user-settable config gets a menu writer that lands it in the downstream flake; no state lives outside the checkout (Phase 1 night-light + Phase 2 the per-device keyboard-layout memory below are done — both now in the git-tracked state file; next target: opt-in auto-commit of the flake on each mutation, owned files only). The on-machine check PASSed 2026-07-04 (enable/disable on hardware). Geo mode shipped 2026-07-11: set BOTH nomarchy.nightlight.latitude/.longitude and wlsunset replaces hyprsunset — sunrise/sunset computed from the location daily, the fixed times ignored; same toggle script, moon indicator, and live-state ExecCondition gate (the backing unit name is a single let-binding). Declarative-only on purpose (coords are hardware-independent machine config, like the keyboard layout — no menu writer). Guarded by checks.nightlight-geo (pure eval over the rendered units); the visible warm shift is hardware-tier, queued.

  • Automatic timezone (location-following clock): nomarchy.system.autoTimezone (opt-in, off by default) — geoclue + services.automatic-timezoned drive /etc/localtime from your location, so travelling to another zone updates the Waybar clock on its own. Menu-driven, in-flake state (the night-light / keyboard philosophy): the flag is settings.autoTimezone in state.json, git-tracked; the System-menu entry (nomarchy-menu autotimezonenomarchy-autotimezone) writes it and rebuilds. Not instant like night-light — it's a system service (not a user unit you can start/stop), so the toggle drives a sudo nixos-rebuild (bakes the service + the time.timeZone override) plus a home-manager switch (the Waybar-refresh watcher), both off the one flag. time.timeZone handling: a runtime zone needs /etc/localtime writable; automatic-timezoned sets time.timeZone = null itself, but the installer's static value would collide (a hard eval error), so the module forces it null (mkForce) over the installer's value when enabled, and reverts to it when disabled. Live clock refresh: Waybar's clock module captures the zone at construction, so modules/home/timezone.nix runs a tiny user service that watches timedate1's change signal and reloads Waybar (SIGUSR2) on a real zone change (also catches a manual timedatectl set-timezone). Eval-verified both ways (on: geoclue+daemon on, tz forced null over a static installer value, watcher present; off: static tz intact, no service/watcher). Pending an on-hardware check — geoclue detection and the SIGUSR2 clock refresh aren't testable in CI (if SIGUSR2 proves insufficient, fall back to restarting waybar). Remaining (optional): a Waybar tooltip line showing the detected zone.

  • Keyboard layouts (per-device + switching):

    • Per-device declarative layout: nomarchy.keyboard.devices ({ "<hyprctl-device-name>" = { layout; variant; }; }) generates Hyprland device blocks that override the session nomarchy.keyboard.layout for a named keyboard — e.g. an external board that's physically a different layout than the laptop's. Hyprland applies it whenever that device connects (the "external keyboard remembers its layout" case, done the declarative way). A Waybar hyprland/language indicator shows the active layout, gated on more than one layout being in play (comma in the session layout, or any per-device override) so single-layout bars stay clean.
    • Interactive new-keyboard prompt (runtime + persist): set nomarchy.keyboard.layouts (candidate layouts) and a nomarchy-keyboard- watch daemon runs (exec-once): it polls hyprctl devices, and when a keyboard connects after login that isn't in keyboard.devices and hasn't been chosen before, it pops a rofi layout picker, applies the choice as a per-device hyprctl keyword device[<name>]:kb_layout (the runtime twin of the declarative device{} blocks — so it isolates that one keyboard and never touches the built-in board), and remembers it. The boot-time set (incl. the built-in keyboard) is never prompted. The runtime-remember complement to the declarative keyboard.devices. Picker verified on hardware (2026-06-18); the first cut applied the choice with switchxkblayout (a global layout-index flip) and merged the candidate pool into input.kb_layout, so a selection leaked onto the laptop keyboard and stuck after unplug — fixed by the per-device keyword + keeping the pool out of the session layout.
    • In-flake state + graduation (2026-06-23): the remembered picks moved out of ~/.local/state/nomarchy/keyboard-layouts into the git-tracked state file (settings.keyboard.devices, a device-name → layout map) — Phase 2 of the in-flake-state principle, the same path night-light took. The watcher reads the live working tree (a pick is honoured at once) and writes instantly with --no-switch (no rebuild), merging the whole map back at the dot-free parent path so a device name containing a dot can't corrupt the dotted set-path. Each remembered device graduates into nomarchy.keyboard.devices on the next rebuild (a generated Hyprland device{} block, mkDefault so a hand-written entry still wins), after which the watcher sees it as declared and steps back. Eval-verified (graduation + precedence) and the writer round-trips; still needs the on-hardware hotplug re-verify (the picker path isn't testable in CI).
    • Multi-layout cycle bind + summer parity (2026-07-04): SUPER+SHIFT+Khyprctl switchxkblayout current next, rendered only when the session layout has a comma (same gate as the Waybar language indicator) — data lives in keybinds.nix as a separate multiLayoutBinds list, so hyprland.nix and the cheatsheet consume one source and gate identically (verified both ways: absent on a single layout; bind + cheatsheet row render under us,de via extendModules). current targets the focused keyboard, so a per-device-overridden board (one layout) is a no-op, never a leak. hyprland/language (󰌌 {short}) added to both summer waybar.jsonc whole-swaps + #language in their CSS — static JSON can't eval-gate, so on summer themes the module shows even with one layout (a deliberate parity-over-minimalism call; it's small). Noticed en route: summer-night carries an idle_inhibitor the generated bar lacks — folded into the idle-inhibit backlog item.
    • Remaining: keep the system (console/initrd) and session layouts in sync (the LUKS-keymap work).
  • Do-Not-Disturb: swaync DND toggle wired into the menu (nomarchy-menu dnd, in the picker, SUPER+CTRL+D) and a Waybar bell indicator (custom/notification via swaync-client -swb: shows the notification count + DND state, left-click toggles the panel, right-click toggles DND). Turning DND off confirms with a toast; turning it on is silent (notifications are suppressed — the bell-off glyph is the cue).

  • Snapshot browse/restore UX: nomarchy-menu snapshot (in the SUPER+M picker, self-gated on snapper) launches btrfs-assistant — the desktop browse/diff/restore/rollback UI, elevating via polkit (the safe choice for a destructive, root-only op vs a fat-fingerable rofi rollback). Shipped system-side gated on nomarchy.system.snapper. Also extended snapper to snapshot /home by default (hourly 5 / daily 7 / weekly 4) when it's its own BTRFS subvolume; a boot oneshot creates the required /home/.snapshots subvolume if missing (covers existing installs, no disko change). The backend is VM-verified (a full BTRFS+LUKS install: both root+home configs, a /home timeline snapshot taken, /home/.snapshots a real subvolume, the oneshot Result=success).

    • Resolved — the "btrfs-assistant 2.2 segfault" was unprivileged-only (re-diagnosed 2026-07-04): gdb puts the crash in btrfs_util_subvolume_ iterator_next() — libbtrfsutil's unprivileged subvolume-iteration path in btrfs-progs 6.17.1 (upstream-fixed after 6.17.1, kdave/btrfs-progs 886571653 "re-enable tree search v2 ioctl"; symbol versions were checked and match, so not an ABI/link issue). As root it runs fine — VM-proven (root --version exits 0, unprivileged exits 139) — and the pkexec launcher runs it as root, so the GUI was never actually broken when launched right. What WAS missing distro-wide: no polkit authentication agent, so every pkexec prompt in the session failed silently — that's the root cause of "the GUI doesn't open". Fixes: hyprpolkitagent (Hyprland's Qt agent, Stylix-themed) now ships via exec-once in hyprland.nix; the menu prefers btrfs-assistant-launcher again with nomarchy-snapshots as fallback; checks.snapshot-gui guards the root path on a real btrfs volume (and the GUI event loop offscreen) against lock-bump regressions. No btrfs-progs patch: our flows are all root-side, so the unprivileged fix rides in with a future lock bump. Original diagnosis kept below for the record. ✓ Fallback shipped: the menu now launches nomarchy-snapshots (system-side, gated on nomarchy.system.snapper) instead — a keyboard-driven snapper browser/restore with no btrfs-assistant dependency. snapper is root-only here (no ALLOW_USERS) and rofi can't run as root under Wayland, so it runs in a terminal via sudo (one password prompt) and uses fzf to pick: browse/diff (read-only), restore files (undochange), or roll back (root config only) — each behind a typed-yes confirmation, which is safer than the fat-fingerable one-click rofi rollback this entry originally avoided. btrfs-assistant stays installed for when nixpkgs fixes it. Pending an on-machine check of the restore/rollback paths.
    • Remaining (optional): boot-from-snapshot needs a systemd-boot equivalent of grub-btrfs.
  • Update awareness: nomarchy.updates.enable (opt-in, modules/home/ updates.nix) — a nomarchy-updates checker on a systemd user timer (.interval, default daily) compares each direct branch-tracking flake input (nixpkgs, the Nomarchy input, home-manager, stylix … via git ls-remote vs the locked rev; offline → skipped, no false alarm) and, when the flatpak CLI is present (.flatpak), counts Flatpak updates. A self-gating Waybar custom/updates indicator (hidden until something's available; accent 󰚰 N, signal-refreshed) + a notification that fires only when the count grows (so a daily timer never nags). Click → the upgrade flow in a terminal (sys-update / flatpak update, each confirmed). Purely passive — it never changes anything, augmenting the explicit rebuild flow. Indicator is in the generated bar and both summer whole-swaps. Pending an on-machine check (the ls-remote/notify/timer path needs a live session).

  • Automated upstream lock bumps (maintainer CI): the upstream twin of the user-side checker above — automate advancing Nomarchy's own flake.lock, which is the entire delivery channel (a downstream takes a single input, ?ref=v1, so nixpkgs/home-manager/stylix/nixos-hardware reach it transitively pinned through that lock; users can't bump them independently). Tiered by what CI can't do: the hardware-QA gate (the Latitude 5410 + the AMD dev box — AMD/NPU/Intel-GPU-compute runtime bits aren't testable in CI or on the Intel Latitudes for the AMD paths) can't be automated, so a bot drives main and a human still promotes main → v1. A scheduled job (Forgejo Actions, or Renovate's Nix manager opening a reviewable lock-diff PR) runs nix flake updatenix flake check + the existing runNixOSTest suite + builds system.build.toplevel, and on green lands on main — which nobody tracks, so a bad bump costs a debug session, not a user outage; v1 stays the manual on-hardware gate (it only ever fast-forwards, never force-pushed, or a user's locked rev can vanish from history). Cadence weekly (release-26.05 doesn't move fast; nightly is just churn), and nix flake update stays within the pinned release branches — no surprise major bump (a 26.11 jump is a deliberate v2, hand-edited in flake.nix). Side benefit: shrinks the security-patch latency the single-input model otherwise imposes (users are gated on the maintainer for nixpkgs CVEs), since a fix is usually already eval/build/VM-tested and one hardware promotion away from v1 — worth fast-laning lock-only/security bumps ahead of feature batches. Shipped since: .gitea/workflows/check.yml (eval tier on every main push) and bump.yml (Monday 05:17 UTC scheduled lock bump, eval + V1 build gate, auto-lands on green — first landing 8fded63, 2026-07-06). 2026-07-12: both gates moved from one big nix flake check --no-build (~6.0 GB peak RSS — OOM'd the 4 GB VPS, then could never fit the runner's 2 GB container cap) to tools/ci-eval.sh, one output per nix process. nomarchy-live is excluded by decision (Bernardo, same day): the ISO is built/distributed manually, so its guard rides that manual flow — and its eval alone is 2.69 GB, the one output that can't fit the cap (Actions run 315: everything else green, it OOM-killed). With it out, the worst CI output is 0.99 GB — no server change needed. Explicitly not a binary cache: compile-from-source is a deliberate values call (Gentoo-style), so this automates the config/lock channel, not artifact distribution.

  • Opt-in auto-commit of state mutations (in-flake state, Phase 4): settings.autoCommit (menu: System Auto-commit, self-gated on the flake being a git repo) makes every apply/set also git commit state.json in the downstream flake. Design decisions: the flag is live-read by the tool on each write — nothing in Nix consumes it, so the toggle is instant, no rebuild, no option-surface addition; the commit is pathspec-limited (git commit -- state.json) so unrelated dirty files are never swept up; it fires when the flag is on before or after the write, so the disable-toggle itself lands in history instead of staying forever-dirty; a same-value set no-ops (diff against HEAD); a missing git identity falls back to Nomarchy <nomarchy@localhost>; bg next is deliberately excluded (runtime wallpaper churn — the path rides along with the next real commit). Verified: V1 (HM generation builds, generated menu bash -n green) + a 7-assertion sandbox-repo round-trip incl. the apply path; on-machine check queued. Rider fix: get now prints booleans JSON-style (true, not Python's True) — which also un-sticks the System menu's "Auto timezone (on/off)" label, whose = true comparison could never match before.

  • Auto-commit sweep before switch (2026-07-12): the pathspec limit above left hand edits (system.nix/home.nix, lock bumps) forever-dirty even with settings.autoCommit on. Now nomarchy-pull/-rebuild/ -home run nomarchy-autocommit (internal, in nomarchy-lifecycle) first: same live-read flag, commits everything dirty with an honest nomarchy: auto-commit before <pull|rebuild|home switch> message whose body lists the swept files — so git log mirrors the generation list and hand edits still never ride a settings-named commit. Same fallback identity; never fatal (|| true at the call sites — a git hiccup can't block a rebuild); before the ff-only pull, it also un-dirties the tree. Guarded by checks.lifecycle-autocommit (sandbox-repo round trip of the real binary: sweep, no-op on clean/off/non-repo).

  • "nomarchy" control center: a single TUI/GUI front-end over the common toggles — theme, power profile, opt-in services, display, DND — built on the same nomarchy-state-sync / nomarchy.* surface the menu already uses. Plus a first-boot welcome that lands new installs in a guided "pick your theme / essentials" flow (ties into the branding work).

Known issues & follow-ups

  • Signal (and some Electron apps) tray icon cannot be themed (BACKLOG #89, 2026-07-12): Signal Desktop hardcodes a dynamically generated, full-color pixmap (Signal_status_icon_1) via its Electron wrapper and pushes it directly over SNI. It ignores GTK icon themes and provides no built-in toggle for a monochrome icon. We will not use brittle app.asar repacking overlays to override this due to maintenance overhead and scope creep. It is expected that its tray icon will not match Nomarchy's monochrome bar.
  • README option tables drift from the live surface (docs review slice (a), 2026-07-04): a fresh reconcile found 9 undocumented options (autoTimezone, camera.hideIrSensor/irMatch, intel.guc, amd.pstate/ vaapi, package, system.stateFile, restic.paths) — rows/mentions added, and the diff is now permanent: checks.option-docs walks the option names out of the four option-declaring modules at eval time (their lazy config halves never evaluate, so dummy args suffice) and requires each a table row — or a backticked .leaf mention for non-enable sub-knobs — plus flags table rows whose option no longer exists (tools/check-option-docs.py).
  • swaync text invisible on summer-day (Latitude QA, 2026-07-04): the themed CSS paired @subtext on @base, but palette roles are not uniform across themes — summer-day/flexoki-light use subtext as text-on-surface (== base) and surface as a dark chip (== text), so body text and widget buttons self-colored. Fix: swaync uses only pairings safe in every palette — @text on @base for text (survey: worst ratio 5.18 across 21 themes) and alpha(@text, 0.1) tints for chips/hovers — guarded permanently by checks.theme-contrast (tools/check-theme-contrast.py, WCAG ratios over themes/*.json). The same audit found generated waybar/rofi CSS shared the bug class, exposed on flexoki-light (no whole-swap) — fixed in the follow-on: waybar dim/secondary shades are alpha(@text) tints, rofi @dim/@surface are fg-derived #RRGGBBAA tints, and the contrast check's pairings now cover all three generated surfaces. Status glyph accents (good/warn/bad on base, 2.02.7 in some palettes) were left as-is — raising them is palette design, a human call.
  • Identity audit exemptions + import hierarchy (#69/#70): tools/audit-theme-design.py tags expected hue/CVD/ANSI-family noise on white/vantablack/lumon/hackerman/matte-black/miasma as [identity] (do not retune into traffic lights). tools/import-palettes.py keeps surface≠overlay when ANSI color0==color8, derives light chips from base instead of ANSI black, and documents that roles are first-class — no bulk re-import of shipped themes without a hierarchy pass.
  • Waybar crash on theme switch left the session bar-less (Latitude hardware QA, 2026-07-04): exec-once has no supervisor, so any crash orphaned the session until relogin — and the switch path itself was crash-prone: reload_style_on_change (inotify on style.css) and state-sync's SIGUSR2 both fired while the HM symlinks flipped, a double-reload race in waybar's in-place reload. Fix: (1) the bar runs under a nomarchy-waybar supervisor (waybar.nix) — ANY exit respawns it, with a crash-loop guard (5 fast exits → critical notification, stop); (2) state-sync now prefers a clean pkill -x waybar when it sees the supervisor (a restart with fresh config+style — no reload code path at all), keeping SIGUSR2 only as the fallback for unsupervised/custom bars; reload_style_on_change stays for manual home-update restyles. VM-verified on the headless software-GL desktop: SIGKILL → new pid; clean kill → respawn; SIGUSR2 → alive.
  • sys-rebuild (Latitude QA request): the no-update twin of sys-update — snapshot-first system rebuild against the current lock, for config-only changes; home-update was already lock-free. README §3/§5 + the motd list all three.
  • Yazi TOML parse error on startup: yazi 26.x made fetchers' group field required, so [[plugin.prepend_fetchers]] failed to parse and yazi fell back to presets. Fixed by adding group = "git" to both git fetcher entries in modules/home/yazi.nix (the git plugin's setup() only renders the linemode; the fetcher still needs registering).
  • Starship prompt styling: dropped the powerline look (the [] separator glyph + bg: fills) for a flat prompt — bold-accent directory, warn git branch, subtext status/duration, character, all plain colored text (modules/home/shell.nix). Also wired in cmd_duration, which was configured but never referenced in format.
  • xfce deprecation warnings: moved xfce.exopkgs.xfce4-exo and the three Thunar plugins to their new top-level names in modules/nixos/file-manager.nix; the eval warnings are gone.
  • Rofi function keybindings: direct SUPER+CTRL+<mnemonic> binds jump straight to each nomarchy-menu module — V clipboard · C calc · W web · F files · N network · B bluetooth · S capture · A ask — added to modules/home/keybinds.nix, so they also show in the SUPER+? cheatsheet.
  • Enable nix-ld by default: programs.nix-ld.enable is on distro-wide (modules/nixos/default.nix), so prebuilt/foreign dynamically-linked binaries run out of the box.
  • Hyprland border colors off for some themes: the cause was v1 forcing an accent→accentAlt gradient on every theme; legacy used a solid border (accent for nord/retro-82/lumon, the text tone for kanagawa/summer-day/summer-night). Added a palette-resolved border field ({active, inactive}) to the theme schema (theme.nix), consumed solid in hyprland.nix, and declared it in every preset so a switch always replaces it. All six identity themes now match legacy exactly.
  • Waybar shows non-existent workspaces: the v1 summer port "fixed" legacy's deprecated persistent_workspaces (underscore — silently ignored by current Waybar, so legacy only ever showed existing workspaces) into the modern persistent-workspaces (hyphen), which Waybar honours → all 10 rendered as phantoms. Dropped the block from themes/summer-{day,night}/waybar.jsonc; the other themes use the generated waybar.nix, which never had it, so they already match.
  • GTK/Qt ignore the theme's light/dark mode: Stylix set polarity but not the org.freedesktop.appearance color-scheme that GTK4/libadwaita and Qt6 read via the portal, so a light theme could still render apps dark. stylix.nix now sets dconf org/gnome/desktop/interface color-scheme = prefer-light/prefer-dark from t.mode (xdg-desktop-portal-gtk already ships, and programs.dconf is on system-side), so libadwaita/Qt follow the palette. Confirmed in a live session (headless QEMU): the portal's org.freedesktop.appearance color-scheme reads 1 on a dark theme and flips to 2 after switching to a light one (catppuccin-latte) — so xdg-desktop-portal-gtk does relay the dconf value GTK4/Qt apps read.
  • Laptop docking & Multi-monitor UX:
    • Rewrote the display watcher (nomarchy-display-profile-watch) to listen immediately to Hyprland's IPC socket via socat, removing the 3-second polling delay.
    • Added a fallback to the base layout when unplugging an external monitor to guarantee the laptop lid turns back on.
    • Added explicit SUPER+ALT+arrow keybindings for moving active workspaces across physical monitors.
  • Workstation Polish Pack (Security & Stability):
    • Enabled security.apparmor.enable system-wide for defense-in-depth confinement.
    • Added boot.kernelParams = [ "panic=10" "oops=panic" ] to auto-reboot upon catastrophic kernel/driver failures.
    • Integrated hyprpicker (SUPER+SHIFT+C) for instant Wayland-native color picking.
  • Theme UI Review completed:
    • Preserved the full-color Papirus icon set for Rofi menus to provide a colorful layer independent of the Waybar's monochrome style.
    • Enforced correct typography scales across swaync (notifications) and Rofi menus, natively binding to the active theme's t.fonts.size and t.fonts.ui rather than hardcoding sizes.