# 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`](VISION.md) |
> | **What agents execute next** | [`agent/BACKLOG.md`](../agent/BACKLOG.md) |
> | Docs map | [`docs/README.md`](README.md) |
> | Agent loop protocol | [`agent/LOOP.md`](../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+Space` → `rofi -show drun` (app launcher) ·
    `SUPER+M` → `nomarchy-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 200` → `rem(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`:
  - **calc** → **rofi-calc**: live results as you type via libqalculate
    directly (dodging the qalc-CLI "of" bug), Enter copies, menu persists
    to chain calculations.
  - **emoji** → **rofi-emoji** (new module, SUPER+CTRL+E): glyph picker,
    copies via the plugin's Wayland clipboard adapter.
  - **files** stays the hand-rolled `fd` → `rofi -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).
  **Shipped (2026-07-17):** Option 1, exactly as decided. New `nomarchy-wallpapers`
  flake input (`flake = false`, its own repo, `<slug>/backgrounds/*`); a new
  overlay derivation `nomarchy-default-themes` copies this repo's `./themes`
  (now ~3 MB — JSONs, previews, btop/waybar/rofi overrides) and symlinks in
  `backgrounds/` per slug from the input, failing the build if a theme JSON
  has no matching non-empty backgrounds (drift guard, belt to
  `check-theme-wholeswap.py`'s same assertion). `nomarchy-state-sync`'s
  `themesDir` now points straight at that merged store path (no more
  in-package `cp -r`) — its own closure dropped from ~94 MB to a few hundred
  KB. `themes/*/backgrounds/` removed from this repo's working tree going
  forward (existing git history still carries the old blobs — not rewritten).
- 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 `loadfont`s 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` + `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. Originally a 15-min poll (`OnCalendar=*:0/15`) for
  robustness to suspend/DST; **revised** (Bernardo 2026-07-18) to fire
  exactly at the configured sunrise/sunset — `Persistent` covers powered-off
  gaps, systemd fires elapsed calendar timers on resume, and `OnStartupSec`
  settles login, so the poll bought nothing but a ≤15-min switch lag. The
  times are now baked into `OnCalendar`, so the menu's Sunrise/Sunset edits
  rebuild via `auto --force` (day/night slugs stay live reads; editing the
  on-screen slot applies immediately since no poll catches it up). 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 `mkDefault`s 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/&lt;8 GiB 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.baseName` → `nomarchy-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):** Ghostty’s 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.
- ✓ **`unstable.<pkg>` in the downstream (#134, 2026-07-17):** Nomarchy now
  carries a `nixpkgs-unstable` input and exposes it through
  `overlays.default` as an `unstable` attrset — in the template's
  `home.packages = with pkgs; [ … ]` a user writes `unstable.lmstudio` and
  is done: one downstream input, still locked, still moved by
  `nomarchy-pull`. `lib.nix` needed zero changes (mkFlake's pkgs already
  applies the overlay). A plain non-recursive `import` keeps it lazy —
  configs that never write `unstable.` pay nothing beyond the input fetch;
  `allowUnfree` mirrors the base set so the motivating lmstudio resolves.
  Home packages are the supported surface (documented convention — the
  overlay is technically visible system-side, deliberately undocumented).
  Costs stated in the README, not hidden: you own the combination, second
  toolchain in the closure (~690 of ~4k paths shared), moves with the lock.
  Lock bumps now move two channels — VERIFICATION §5 + CONVENTIONS updated.
  V1: template HM activation built with `unstable.hello`, closure carries
  the unstable store path; `checks.unstable-seam` (eval-only) guards the
  seam permanently; lock diff added exactly the one node.
- ✓ **Display-transition tool de-duplicated (#150, 2026-07-20):** the full
  `nomarchy-display-transition` (dock/undock/enable — logging, the lid-inhibitor
  gate, `restore_keyboards`, the failure dump) moved out of `hyprland.nix` into
  its own leaf `modules/home/display-transition.nix`, and the keyboard helper it
  calls into `modules/home/keyboard-tool.nix`; `hyprland.nix` and `idle.nix` now
  import the ONE copy. This kills the lossy ~22-line mini `idle.nix` carried
  under the same binary name to dodge a circular import — so the hypridle wake
  path (`after_sleep` / DPMS `on-resume`) gets the real undock, i.e.
  `restore_keyboards` after a reload and the evidence dump on enable-failure,
  not a bare keyword+reload. Behaviour on the hyprland side is unchanged —
  **proven** by byte-identical store paths for the built
  `nomarchy-display-transition` / `nomarchy-keyboard-layout` on HEAD vs the
  refactor. Gates: parse + home/toplevel builds + `checks.docking-ux`
  (structural, `bash -n` + safety-feature greps) all green. The
  `tools/monitor-fallback.nix` dock VM harness was **red on `main` at the
  time** — a flaky 15s bound on the #148 re-dock (identical failure on clean
  HEAD), diagnosed and fixed the same day (see below), so it did not gate this
  landing; the dock transition inside it always ran `result=ok`. The idle
  wake-path behaviour change (full undock, incl. dock-intent clearing +
  `restore_keyboards`) is V3 — HARDWARE-QUEUE.
- ✓ **Dock harness de-flaked (#154, 2026-07-20):** `tools/monitor-fallback.nix`
  flaked at the #148 reload-mid-dock assertion — after a lid-closed
  `hyprctl reload` re-lights the disabled `Virtual-1` panel, the watcher's
  `enforce_dock_intent` re-disables it, but only on the loop's 1s **idle
  tick** (a reload re-enables an existing connector, emitting no
  `monitoradded`, and chatty IPC events `continue` past `reconcile`). Under
  the softGL VM's load — swaync crash-loops against the absent Vulkan device —
  that idle tick is starved and the (correct, prompt-on-hardware) re-dock
  lands ~15-20s out, right on the old `timeout=15`. Instrumented run proved
  the mechanism fires with `dock-intent=true` and re-disables the panel; fix
  is the bound (15→45s, matching the test's other 20-30s waits), not the
  watcher. Harness now green end-to-end (61s). Not a product bug and unrelated
  to #150 (byte-identical tool store paths).
- ✓ **The idle brick's CAUSE found (#127 closed, 2026-07-17):** the incident
  was misdated — it did not happen "before 12:51:34 Jul 15" (that boot was
  healthy all day and ended in a routine clean reboot); it ended the
  *previous* boot at **10:54:22** with the power button (unclean end:
  journald corruption + FAT dirty bit on the next boot; final seconds: dock
  unplug/replug storm 10:52:54–10:53:58, lid open 10:54:00). Every earlier
  hypridle timeline was built against the wrong boot. **Proven** from that
  boot's journal (running the pre-fix generation of 07-10): normal idle
  cascade — 10:21 lock, 10:26 `dpms off`, 10:31 suspend correctly skipped
  on AC — then at **10:36:29 hypridle logs `BUG THIS: inhibit locks < 0:
  -1`** (inhibitor-count underflow, its own words), after which its rule
  state is demonstrably corrupted: the 300s lock listener re-arms,
  re-fires (10:41:29) and Resumes (10:43:49), but the fired DPMS
  listener's `on-resume` — then the session's **only** `dpms on` caller —
  never dispatches again, through provable real input (two blind hyprlock
  PAM attempts, 10:44:21 and 10:52:40) and three output remove/re-add
  cycles. **The wedged-compositor/DRM-master lead is dead:** Hyprland
  tracked hotplug live through the whole window, PAM/fprintd worked, the
  kernel is spotless (zero amdgpu/DRM/PSR errors). The dead Ctrl+Alt+F3
  stops being load-bearing — no VT-switch trace exists at all (the
  keystrokes plausibly never left the flapping dock, and nothing would
  have re-lit the panels either way); it was only ever evidence *for* the
  wedge, and the wedge is contradicted. **Inferred, not provable from
  logs:** the underflow caused the corruption — the correlation is tight
  and hypridle flagged the bug itself. Consequence: the shipped fix
  (a2151f4, `key_press/mouse_move_enables_dpms`) closes exactly this
  mechanism — the first blind keystroke would have re-lit the screen.
  Upstream: hyprwm/hypridle#208 (filed 2026-07-17 with the full trace;
  0.1.7, same class as their closed #104/#128). The watch rides in
  BACKLOG #146.
- ✓ **Post-install hardware hints (VISION § B, 2026-07-17):** after the #81
  "you're set" card, at most one additional "Hardware tips" toast — a
  Firmware line when `fwupdmgr` is on PATH, a Fingerprint line when
  `fprintd-list` is — with its own one-shot marker
  (`settings.hardwareHintsShown`, in-checkout like `firstBootShown`). If no
  tooling matches, the marker is deliberately left unset so the hint still
  fires once if fwupd/a reader appears later. Lives inside
  `nomarchy-first-boot` (the card's exit-1-retries-next-login semantics are
  preserved; hints only run once the card has landed). V2:
  `checks.first-boot` grew the silent-bare-node → fwupdmgr-appears →
  fires-once → silent-again → live-ISO-still-skips flow.
- ✓ **Reload re-lit the panel inside a shut lid (#148, 2026-07-17):** every
  `nomarchy-home` (HM activation → `hyprctl reload`) re-applied the catch-all
  monitor rule and left eDP-1 burning in the closed clamshell. The #142 tick
  invariant `enforce_dock_intent` was built for exactly this and never fired:
  it was gated on `settings.displayProfileAuto` — the *named-profile*
  auto-switch toggle, unrelated to dock intent and off for most users.
  Confirmed on the incident machine: `dockMode=true`, `displayProfileAuto`
  unset, watcher journal empty — every tick returned at that guard. Fix:
  drop the gate; `dockMode` alone is the recorded intent, the profile-active
  check stays as the real guard, and undock's ordering (intent cleared
  *before* its rescue reload) keeps the enforcer from fighting the #142
  hammer. V2: `tools/monitor-fallback.nix` grew a reload-mid-dock scenario —
  fake lid closed → `hyprctl reload` → panel re-disabled in ~4s,
  `action=re-dock` logged. V3 queued: real relogin + `nomarchy-home` while
  docked-clamshell (the running watcher is exec-once, so the fix arms at the
  next login).
- ✓ **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.
- ✓ **Rofi highlight lands on an unrelated entry after backspace (#143,
  2026-07-17):** upstream keeps the selected row *index* across a refilter
  (rofi#2317), so backspacing grew the list back under a stationary
  highlight — Enter then launched the wrong app. Bernardo's 07-16 ruling
  ("wait for upstream, no source-built rofi distro-wide") was priced
  against a patch; the maintainer's reply pointed at a knob the item's
  `-dump-config` survey had missed: the `inputchange {}` configuration
  block. Shipped as config, zero build cost: `inputchange { action:
  "kb-row-first"; }` in the generated `config.rasi` (rofi.nix
  extraConfig — home-manager renders nested sections), so every edit of
  the query resets the highlight to row 0, dmenu/launcher/text-menus
  alike — the same "reset on filter change" dmenu and fuzzel ship.
  Trade-off accepted: arrow-down-then-refine no longer holds position,
  but that is precisely the interaction that was broken. V2 headless VM
  (scratch theme-shot variant driving QMP keystrokes): on HEAD the
  repro's highlight stayed on row 3 ("kitty" → "Bulk Rename"); patched,
  backspace snaps it to the first entry — shown in the drun grid
  (executive-slate), boreal's whole-swap grid, and the keyboard-layout
  dmenu picker.
- ✓ **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 4–5'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 was still open at this writing — **found 2026-07-17** (and it *was*
  the wake path failing during the incident, via hypridle state corruption):
  see "The idle brick's CAUSE found" above.
- ✓ **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.
  **Was still open at this writing — resolved 2026-07-17, and the lead below
  did not survive the boot -4 forensics (see "The idle brick's CAUSE found"
  above).** 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.json` → `state.json`; CLI
  `nomarchy-theme-sync` → `nomarchy-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 Nomarchy’s, not upstream’s
  “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.desktop` → `org.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 → <dock sink>" 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 4–5 — 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 `writeShellScriptBin`s 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-sensor** — `video0` 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 node** — `monitor.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 backend** — `wireplumber.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](HARDWARE.md). 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, `sunset` → `temperature`), 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 instant** —
  `nomarchy-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 autotimezone` →
  `nomarchy-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+K` → `hyprctl 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 update` → `nix 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 daily**
  (2026-07-20; was weekly — release-26.05 doesn't move fast, but daily
  shrinks security-patch latency and a no-op bump is cheap), 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` (daily 05:17 UTC scheduled lock bump, eval + V1
  build gate, auto-lands on green — first landing 8fded63, 2026-07-06;
  cadence weekly→daily 2026-07-20).
  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.0–2.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.
  **Follow-up (2026-07-19):** a later waybar package wrap renamed the
  comm to `.waybar-wrapped`, silently no-oping every `pkill -x waybar`
  (switches stopped touching the bar → the stale workspace-digit-color
  bug), while the *unanchored* RTMIN pokes substring-matched the
  supervisor's `nomarchy-waybar` comm and killed it. All pokes now use
  `-x 'waybar|\.waybar-wrapped'`.
- ✓ **`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.exo` → `pkgs.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.
