All checks were successful
Check / eval (push) Successful in 4m26s
The bare `button, button label` selectors in the #98 safety net load into every GTK3 process, including Waybar, and a rule matching the label node outranks the color waybar.css inherits onto `#workspaces button` — so workspace digits always rendered the gtk.css text color captured at bar start, never the theme's @muted dots / @base-on-accent active pill (the pair check-theme-contrast.py gates). With the bar never restarting on switch (previous commit) the pin also went stale: all three 2026-07-18 screenshots carried kiln-clay's @text #39302A across three dark themes. Scoped the enabled+disabled pins to the dialog selectors #98 actually targeted; forcing adw-gtk3-dark remains the primary #98 fix, and the sheet's own bare `label` rule sets only caret-color, so inheritance carries the bar colors again. Verified live on Newton (V3-grade): under the scoped gtk.css, boreal renders the designed digits — @base #21272F on the @accent pill, @muted #5E6A78 inactives — pixel-sampled from a bar spawned on a headless output. V1: HM generation builds; bare selectors absent from the built gtk.css, dialog-scoped ones present. Journal, hardware-queue (deploy round-trip) and ROADMAP follow-up ride along. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2016 lines
142 KiB
Markdown
2016 lines
142 KiB
Markdown
# 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/<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.
|
||
- ✓ **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 weekly**
|
||
(release-26.05 doesn't move fast; nightly is just churn), and `nix flake
|
||
update` stays *within* the pinned release branches — no surprise major bump
|
||
(a 26.11 jump is a deliberate `v2`, hand-edited in flake.nix). Side benefit:
|
||
shrinks the security-patch latency the single-input model otherwise imposes
|
||
(users are gated on the maintainer for nixpkgs CVEs), since a fix is usually
|
||
already eval/build/VM-tested and one hardware promotion away from `v1` —
|
||
worth fast-laning lock-only/security bumps ahead of feature batches.
|
||
**Shipped since:** `.gitea/workflows/check.yml` (eval tier on every main
|
||
push) and `bump.yml` (Monday 05:17 UTC scheduled lock bump, eval + V1
|
||
build gate, auto-lands on green — first landing 8fded63, 2026-07-06).
|
||
2026-07-12: both gates moved from one big `nix flake check --no-build`
|
||
(~6.0 GB peak RSS — OOM'd the 4 GB VPS, then could never fit the
|
||
runner's 2 GB container cap) to `tools/ci-eval.sh`, one output per nix
|
||
process. **nomarchy-live is excluded by decision** (Bernardo, same
|
||
day): the ISO is built/distributed manually, so its guard rides that
|
||
manual flow — and its eval alone is 2.69 GB, the one output that can't
|
||
fit the cap (Actions run 315: everything else green, it OOM-killed).
|
||
With it out, the worst CI output is 0.99 GB — no server change needed. Explicitly
|
||
*not* a binary cache: compile-from-source is a deliberate values call
|
||
(Gentoo-style), so this automates the *config/lock* channel, not
|
||
artifact distribution.
|
||
- ✓ **Opt-in auto-commit of state mutations (in-flake state, Phase 4):**
|
||
`settings.autoCommit` (menu: **System › Auto-commit**, self-gated on the
|
||
flake being a git repo) makes every `apply`/`set` also `git commit`
|
||
state.json in the downstream flake. Design decisions: the flag is
|
||
**live-read by the tool** on each write — nothing in Nix consumes it, so
|
||
the toggle is instant, no rebuild, no option-surface addition; the commit
|
||
is **pathspec-limited** (`git commit -- state.json`) so unrelated
|
||
dirty files are never swept up; it fires when the flag is on before *or*
|
||
after the write, so the disable-toggle itself lands in history instead of
|
||
staying forever-dirty; a same-value `set` no-ops (diff against HEAD); a
|
||
missing git identity falls back to `Nomarchy <nomarchy@localhost>`;
|
||
`bg next` is deliberately excluded (runtime wallpaper churn — the path
|
||
rides along with the next real commit). Verified: V1 (HM generation
|
||
builds, generated menu `bash -n` green) + a 7-assertion sandbox-repo
|
||
round-trip incl. the apply path; on-machine check queued. Rider fix:
|
||
`get` now prints booleans JSON-style (`true`, not Python's `True`) —
|
||
which also un-sticks the System menu's "Auto timezone (on/off)" label,
|
||
whose `= true` comparison could never match before.
|
||
- ✓ **Auto-commit sweep before switch (2026-07-12):** the pathspec limit
|
||
above left hand edits (system.nix/home.nix, lock bumps) forever-dirty
|
||
even with `settings.autoCommit` on. Now `nomarchy-pull`/`-rebuild`/
|
||
`-home` run `nomarchy-autocommit` (internal, in nomarchy-lifecycle)
|
||
first: same live-read flag, commits *everything* dirty with an honest
|
||
`nomarchy: auto-commit before <pull|rebuild|home switch>` message whose
|
||
body lists the swept files — so `git log` mirrors the generation list
|
||
and hand edits still never ride a settings-named commit. Same fallback
|
||
identity; never fatal (`|| true` at the call sites — a git hiccup can't
|
||
block a rebuild); before the ff-only pull, it also un-dirties the tree.
|
||
Guarded by `checks.lifecycle-autocommit` (sandbox-repo round trip of
|
||
the real binary: sweep, no-op on clean/off/non-repo).
|
||
- **"nomarchy" control center:** a single TUI/GUI front-end over the common
|
||
toggles — theme, power profile, opt-in services, display, DND — built on
|
||
the same `nomarchy-state-sync` / `nomarchy.*` surface the menu already
|
||
uses. Plus a first-boot welcome that lands new installs in a guided "pick
|
||
your theme / essentials" flow (ties into the branding work).
|
||
|
||
## Known issues & follow-ups
|
||
- **Signal (and some Electron apps) tray icon cannot be themed** (BACKLOG #89, 2026-07-12): Signal Desktop hardcodes a dynamically generated, full-color pixmap (`Signal_status_icon_1`) via its Electron wrapper and pushes it directly over SNI. It ignores GTK icon themes and provides no built-in toggle for a monochrome icon. We will not use brittle `app.asar` repacking overlays to override this due to maintenance overhead and scope creep. It is expected that its tray icon will not match Nomarchy's monochrome bar.
|
||
- ✓ **README option tables drift from the live surface** (docs review
|
||
slice (a), 2026-07-04): a fresh reconcile found 9 undocumented options
|
||
(autoTimezone, camera.hideIrSensor/irMatch, intel.guc, amd.pstate/
|
||
vaapi, package, system.stateFile, restic.paths) — rows/mentions added,
|
||
and the diff is now permanent: `checks.option-docs` walks the option
|
||
names out of the four option-declaring modules at eval time (their
|
||
lazy `config` halves never evaluate, so dummy args suffice) and
|
||
requires each a table row — or a backticked `.leaf` mention for
|
||
non-enable sub-knobs — plus flags table rows whose option no longer
|
||
exists (tools/check-option-docs.py).
|
||
- ✓ **swaync text invisible on summer-day** (Latitude QA, 2026-07-04):
|
||
the themed CSS paired @subtext on @base, but palette roles are not
|
||
uniform across themes — summer-day/flexoki-light use subtext as
|
||
text-*on*-surface (== base) and surface as a dark chip (== text), so
|
||
body text and widget buttons self-colored. Fix: swaync uses only
|
||
pairings safe in every palette — @text on @base for text (survey:
|
||
worst ratio 5.18 across 21 themes) and alpha(@text, 0.1) tints for
|
||
chips/hovers — guarded permanently by `checks.theme-contrast`
|
||
(tools/check-theme-contrast.py, WCAG ratios over themes/*.json).
|
||
The same audit found generated waybar/rofi CSS shared the bug class,
|
||
exposed on flexoki-light (no whole-swap) — fixed in the follow-on:
|
||
waybar dim/secondary shades are alpha(@text) tints, rofi @dim/@surface
|
||
are fg-derived #RRGGBBAA tints, and the contrast check's pairings now
|
||
cover all three generated surfaces. Status glyph accents
|
||
(good/warn/bad on base, 2.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.
|