Compare commits

..

115 Commits

Author SHA1 Message Date
be5888d354 docs(backlog): queue laptop-docking UX and fullscreen-waybar items
All checks were successful
Check / eval (push) Successful in 2m59s
29 (docking/external-monitor UX): unplug→restore the internal panel,
plug→extend by default, easy HDMI/DP audio routing, and clearer
workspace↔monitor behavior. Raised by Bernardo; [human]/[blocked:hw].

30 (waybar overlaps fullscreen video): a fullscreen YouTube video is
drawn under the bar's overlay layer; hide the bar on true fullscreen.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-07 18:28:35 +01:00
f848e7390f fix(waybar): widen the battery icon-to-text gap when discharging
The discharging `format` put a single space between the icon span and
{capacity}%, but the battery-bar glyphs carry almost no right bearing,
so the state read cramped — while charging/plugged use glyphs with their
own spacing and looked fine. Widen only the discharging line to two
spaces across all four themes that ship their own bar (boreal,
summer-night, summer-day, executive-slate).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-07 18:28:35 +01:00
907d3123ea style(themes): brand the launcher mark and rework waybar icon sizing
All checks were successful
Check / eval (push) Successful in 3m11s
- Pin `font-family: Nomarchy` on #custom-nomarchy (boreal, executive-slate)
  so the branded mark at U+F000 wins over the Nerd Font's glass glyph at
  the same codepoint (summer bars already pinned it).
- Enlarge the icons on icon+text modules via a Pango span, with a gap so
  the glyph no longer crowds the value. The volume module gets a wider
  gap: its level glyph is unusually wide and overlapped the % otherwise.
- Bump icon-only status modules (caffeine, speedometer, …) to match, so
  they don't read smaller than their neighbours. The generated bar sizes
  everything relative to fonts.size (+2/+3pt) so it scales per theme.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-07 17:36:01 +01:00
ddf9b186e4 fix(rofi): resolve whole-swap launcher @import and page the app grid
The Apps launcher (`rofi -show drun -theme launcher`) imported
`custom.rasi` — the name HM gives *generated* themes — but a whole-swap
theme's base is deployed under its source basename (`rofi.rasi`), so the
import resolved against $HOME and rofi errored with "custom.rasi not
found" on every whole-swap theme. Make the default import track the
active base (rofi.rasi for whole-swaps, custom.rasi otherwise) and point
Boreal's own launcher.rasi at rofi.rasi.

Also give Boreal's app grid `flow: horizontal` so Down at a column's foot
pages to the next screen instead of jumping to the next column's top,
mirroring the theme-picker grid.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-07 17:36:01 +01:00
Bernardo Magri
175b877f95 fix(rofi): route the menu's Apps entry through the launcher theme too
All checks were successful
Check / eval (push) Successful in 2m59s
SUPER+Space/D open `rofi -show drun -theme launcher`, but the main menu's
"Apps" entry still ran bare `rofi -show drun`, so that path skipped the
per-theme launcher override (e.g. Boreal's icon grid) and fell back to the
list. Same theme for both launch sites now — consistent launcher whichever
way it's opened.

V0: flake check green; no bare `rofi -show drun` launch sites remain.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-06 21:58:49 +01:00
Bernardo Magri
7b599c5786 fix(themes): restore summer-night's blank notification bell icons
All checks were successful
Check / eval (push) Successful in 2m59s
summer-night shipped with its custom/notification format-icons all empty
(the same Nerd Font glyph-loss seen in executive-slate/boreal), so its bar
showed a blank notification indicator regardless of state — while its
sibling summer-day kept the icons. Filled them in from summer-day so the
FontAwesome bell (U+F0F3) / bell-slash (U+F1F6) match across the pair.
Surgical edit — only the 8 icon values changed, hand-formatting untouched.

V1: builtins.fromJSON parses; icons now byte-identical to summer-day; zero
empty glyph fields remain across all four whole-swap bars.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-06 21:45:11 +01:00
Bernardo Magri
f924d92f1b fix(themes): restore lost Nerd Font glyphs in executive-slate + boreal bars
All checks were successful
Check / eval (push) Successful in 3m13s
The whole-swap waybar.jsonc for both themes shipped with every Nerd Font
private-use glyph dropped to an empty string — the launcher (U+F000), the
power button (U+F011), and all pulseaudio / battery / notification
format-icons — so the bars rendered with those icons blank. The glyphs were
lost when the files were first written; restored here from the known-good
generated bar (waybar.nix) and summer-night, verified by codepoint:
launcher U+F000, power U+F011, notification bells U+F009A-C, full
battery/volume icon sets. (Files re-dumped with ensure_ascii=False, so the
module arrays are now multi-line — content only, no layout change.)

V1: strict JSON + builtins.fromJSON parse both (13 right-modules each);
comprehensive scan reports zero empty glyph fields; flake check green.

Found while sweeping the branch: summer-night/day carry the same empty
notification format-icons (pre-existing, untouched here) — flagged for a
separate fix.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-06 21:24:42 +01:00
Bernardo Magri
f2c815ddbd feat(themes): add Boreal + make theme appearance per-theme
All checks were successful
Check / eval (push) Successful in 3m1s
Boreal — a Nord × Everforest × Dracula dark theme: a boreal forest at
night under the aurora. Nord's frost bones, Everforest's comfort, Dracula's
jewel-tone violet/cyan as the aurora light.

Palette (WCAG-checked, all 24 themes × 7 pairings pass): frost-slate base,
soft-vivid violet accent (#B79BE8, 6.35 on base) + frost-cyan accentAlt
(#86C7C0, 7.84 AAA), sage-green/gold/coral semantics, text 10.66 AAA.

Bespoke, non-standard surfaces:
- Waybar whole-swap (jsonc + css): a floating "aurora frost" bar of three
  frosted-glass pills; launcher+clock left, workspaces centre, and the whole
  right side is ONE continuous pill — the system tray shares its glass with
  the status modules, no separators (the seamless-tray requirement). Full
  module set (parity). GeistMono.
- Rofi: a frosted list base (used by every menu), plus a 4×3 large-icon grid
  for the app launcher only.
- Typography GeistMono Nerd Font (57 MB, new); Iosevka was rejected at 1.1 GB.

Per-theme appearance (new): a preset now carries a full fonts+ui block, like
border, so a theme can ship a bespoke font / terminal opacity / border
thickness / rounding and a switch always replaces it — no leak into the next
theme (verified: boreal→everforest resets mono/rounding/opacity). All 23 other
presets get the current defaults (idempotent — zero visual change). Boreal
runs GeistMono + 0.90 terminal opacity + 3px borders + 12px rounding.

Grid launcher (new, backward-compatible): drun renders via `-theme launcher`;
HM writes ~/.local/share/rofi/themes/launcher.rasi which defaults to
`@import "custom.rasi"` (every other theme's launcher = its list, unchanged),
and a theme may override it (Boreal's grid). Text menus keep the base list.

Wallpaper: an original procedurally-generated aurora-over-treeline (ImageMagick),
palette-matched.

V1: nix flake check --no-build green; strict JSON on every preset; contrast
checker green; the real rofi binary parses the base rasi and resolves
`-theme launcher` + @import + grid overlay; end-to-end eval with boreal active
resolves the waybar whole-swap (tray in the right cluster), the rofi grid
launcher, and the bespoke fonts/opacity/geometry reaching nomarchy.theme, with
no cross-theme contamination. NOT done: on-screen render of the desktop
(V2/V3) and preview.png (deferred to on-hardware capture).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-06 21:03:50 +01:00
Bernardo Magri
16275947ec feat(themes): full revision of executive-slate — palette rebuild + real whole-swap bar
All checks were successful
Check / eval (push) Successful in 3m15s
The executive-slate preset shipped a waybar.css that referenced @base/@accent
with no @define-color block, and no matching waybar.jsonc — so the raw-read
override ran a largely unstyled bar (the item-28 partial-whole-swap defect).
The palette was also flat (base->surface only 1.13x) with a dim accent
(#4D78CC, 3.91 on base).

- Palette: widen surface stepping (base->surface 1.13x -> 1.29x so panels/
  selection/hover separate), brighten the accent (#4D78CC -> #5A8AE6, 3.91 ->
  5.04 on base, now AA text), lift subtext/good/warn/bad a tier, realign ansi,
  soften bright-white (#FFFFFF -> #F0F3F9). text stays 12.5 AAA. Contrast
  checker: all 23 themes x 7 pairings pass.
- Waybar: complete the bespoke bar as a real whole-swap. New waybar.jsonc with
  the full 17-module set (parity with summer-day/night); waybar.css rewritten
  to lead with its own @define-color block and cover every module + state class
  the helper scripts emit (.recording/.activated/.on/.available/.dnd-*/battery).
  Identity: solid, edge-to-edge, sharp corners, accent underline on the active
  workspace.
- Rofi: drop the broken var() idiom for the house *{}/@name convention (split
  border syntax, message/textbox/scrollbar elements for the theme-grid picker).
- CONVENTIONS: Waybar parity rule now names executive-slate as a third
  whole-swap target (was stale at summer-only).

V1: nix flake check --no-build green; strict JSON parse of both files; real
rofi parsed the .rasi cleanly; with executive-slate set active, the module
eval resolves waybar.settings -> the jsonc, waybar.style -> the css, rofi.theme
-> the override rasi, and theme.nix accepts the palette. NOT yet done: on-screen
render of the bar/rofi/desktop (V2/V3) and preview.png (deferred to on-hardware
capture, per the chosen path).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-06 20:14:30 +01:00
7f10a12ec8 style(themes): improve usability scaling for executive-slate
All checks were successful
Check / eval (push) Successful in 2m54s
Increased base font size from 13px to 15px and expanded padding to provide better touch targets and functional proportions.
2026-07-06 18:56:29 +01:00
825b7e25a4 fix(themes): correct rofi border syntax for new themes
All checks were successful
Check / eval (push) Successful in 3m8s
Rofi expects border width and border-color to be separated, the CSS shorthand is not supported.
2026-07-06 18:53:08 +01:00
801ffa4e24 feat(themes): add executive-slate professional dark theme
All checks were successful
Check / eval (push) Successful in 2m56s
Leveraged Nano Banana to generate a 4k functional minimalist wallpaper. Added the new preset with a solid, high-legibility waybar and flat rofi launcher.
2026-07-06 18:40:54 +01:00
ccd896c8dc feat(themes): add neon-glass theme with segmented pill waybar and glassmorphic rofi
Leveraged Nano Banana to generate a 4k wallpaper. Added the new preset with glassmorphism aesthetic.
2026-07-06 18:35:36 +01:00
51e319d357 chore(backlog): remove completed menu parity item 2026-07-06 18:26:17 +01:00
7569b678db feat(system): menu parity for downstream options (slice 2)
Added pickers for terminal, keyboard layout, and auto-login to the Control Center TUI, wiring them directly to Nix defaults via theme-state.json settings. Verified: V1.
2026-07-06 18:25:46 +01:00
28e21af206 feat(system): menu parity for downstream options (slice 1)
Added toggles for updates, battery limit, bluetooth, and printing to the Control Center. Wired default values to read from theme-state.json settings. Verified: V1.
2026-07-06 18:20:00 +01:00
eecc214ca9 style(waybar): remove cpu and memory modules
Simplified waybar based on user feedback. Verified: V1.
2026-07-06 18:14:39 +01:00
93521c8617 feat(system): nomarchy-control-center TUI
Implemented TUI control center using gum. Added to systemPackages and rofi System menu. Verified: V1. Pending: V3 visual check.
2026-07-06 18:09:39 +01:00
3f15f6451f docs(backlog): control center design options
Moved item 18 to Decisions, elaborated form-factor options, and appended journal entry. Verified: V0. Pending: human decision.
2026-07-06 18:01:09 +01:00
nomarchy-bump
8fded63b10 chore(lock): scheduled upstream bump
• Updated input 'home-manager':
    'github:nix-community/home-manager/868d0a692de703c2de98fab61968e4e310b7c28e?narHash=sha256-G1I1gd32F7mp9LAe1DaZ4ZL7NX5gyiKwdCMwro1Vrck%3D' (2026-06-29)
  → 'github:nix-community/home-manager/af2beae5f0fae0a4310cc0e6aef2572f56090353?narHash=sha256-ESQnuNHEDChsB4IxoLRhscVahqkDWkTb%2BqdIz8euYt4%3D' (2026-07-05)
• Updated input 'nixpkgs':
    'github:NixOS/nixpkgs/1f01958ffb5b3545c96d9ef2f4e24c5e5e1eb846?narHash=sha256-i5nw9BYYsMDAaOC4J%2BJmTof6b2GhlyH076awYRNrTV8%3D' (2026-06-29)
  → 'github:NixOS/nixpkgs/a50de1b7d8a586adc18d2395c19de7d6058e6030?narHash=sha256-uslt2pqShTIXDdAHRHv2QkYLsVdY8Oqwz0EA48/RSM8%3D' (2026-07-04)
• Updated input 'stylix':
    'github:nix-community/stylix/3ed763829fc06d32cab3c1f31672379a1f53450e?narHash=sha256-%2B8RpmHKn5n2tYmoRCwiKJ6PeU85q15qnXzGQ2WGMn9Q%3D' (2026-06-29)
  → 'github:nix-community/stylix/a5b35360dd2f9a931e85457c3d6d9688307b939f?narHash=sha256-n%2BOcIYCfHofQgsuDJ5w42BNoGDnJwmd7tHqLNpVaJOU%3D' (2026-07-06)

Gate: nix flake check --no-build (eval tier — see bump.yml header).
2026-07-06 05:20:08 +00:00
Bernardo Magri
edd0bd38ce feat(display): per-profile workspace→output pins (item 15c)
All checks were successful
Check / eval (push) Successful in 3m0s
Lock bump / bump (push) Successful in 2m53s
A display profile can now pin workspaces to outputs:

  docked = {
    monitors   = [ … ];
    workspaces = { "1" = "DP-3"; "9" = "eDP-1"; };
  };

The bare list-of-monitors shape still works (either-type; hyprland.nix
normalizes — coercedTo refuses list-of-submodule sources). Pins bake as
Hyprland `workspace` rules from the active profile and apply instantly
on `nomarchy-display-profile apply`: keyword sets the session rule,
moveworkspacetomonitor moves open workspaces over. Pins from a
previously applied profile linger in-session until reload/rebuild
(hyprctl can only add) — inert if the output is absent, noted in the
applier.

V1: flake check green (checks.display-profiles now asserts
workspaceRule) + scratch-downstream eval: baked settings carry both
rule sets, the generated applier has the keyword+dispatch pairs
(bash -n), match/list correct with mixed shapes. V3 queued in
HARDWARE-QUEUE (rides the dock test). Item 15 complete — deleted.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-05 23:09:14 +01:00
Bernardo Magri
096440a7d7 docs(agent): icon-weight A/B captured + published for the item-28 human call
All checks were successful
Check / eval (push) Successful in 3m0s
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-05 22:33:41 +01:00
Bernardo Magri
7d5f091c29 docs(agent): queue on-hardware check for the stub-bar themes (item 28)
All checks were successful
Check / eval (push) Successful in 3m7s
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-05 22:23:15 +01:00
Bernardo Magri
90a5104f94 fix(themes): drop stub waybar.css whole-swaps — four themes ran an unstyled bar (item 28)
Some checks failed
Check / eval (push) Has been cancelled
catppuccin, lumon, nord and retro-82 carried 2-14 line waybar.css
stubs from the rewrite import (f211ef0). A theme waybar.css replaces
the entire generated stylesheet, so those four shipped a raw default
Waybar: no workspace pill, cramped flush-edge right cluster, none of
the generated polish. Deleting the stubs falls them back to the
generated bar in their own palette; their full ~110-line rofi.rasi
designs are untouched. Authored whole-swap bars remain only
summer-day/night.

V2: lumon captured before/after — stub bar was default-styled,
re-capture renders the generated styling. flake check green.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-05 22:22:44 +01:00
Bernardo Magri
39cfe0fb12 feat(tools): seed guest state at boot so captures render wallpapers (item 28c)
All checks were successful
Check / eval (push) Successful in 3m8s
theme-shot guests had no ~/.nomarchy, so the session's exec-once
`nomarchy-theme-sync wallpaper` died on missing state and captures
showed a bare base-color backdrop. Seed theme-state.json via tmpfiles
before greetd starts the session — the real exec-once path now paints.
(A post-boot testScript seed is too late for exec-once, and a manual
re-run fails without WAYLAND_DISPLAY: awww's client socket is
display-named.)

V2: summer-night capture renders wallpaper + inverted cream bar +
menu + placeholder dim, all coherent.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-05 22:09:52 +01:00
Bernardo Magri
01ee847490 feat(bar): timezone line in the clock tooltip (LATER item)
All checks were successful
Check / eval (push) Successful in 3m3s
Hovering the clock now leads with the zone — "{:%Z (UTC%z)}" above the
calendar — on the generated bar and both summer whole-swaps (parity).
Under auto-timezone the existing tz-watch SIGUSR2 reload keeps it
showing the currently detected zone. %Z is the abbreviation (BST), not
the IANA name — strftime has no code for that, and it satisfies the
"(optional)" nicety.

Verified: V0; V1 — the rendered generated config carries the new
tooltip-format and both summer jsonc parse with it. Hover render
queued for hardware.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-05 21:56:44 +01:00
Bernardo Magri
808a3febdd feat(bar): doctor tripwire indicator (LATER item)
All checks were successful
Check / eval (push) Successful in 3m3s
nomarchy-doctor-status: silent while nomarchy-doctor exits 0 (or is
absent), so the module only exists when something is actually broken —
then a red warning glyph with the first ✖ lines jq-escaped into the
tooltip; clicking opens the full sheet (nomarchy-menu doctor).
Interval 300s: the health check is real work (unit/disk/git sweeps),
and this is a tripwire, not a monitor. Added to the generated bar
(status group, after battery) and to BOTH summer whole-swap bars
(module list + jsonc block + CSS membership + @red rule) per the
parity rule.

Verified: V0; V1 — template-home builds with the module in config +
CSS, the status script passes bash -n, both summer jsonc still parse;
functional — the self-gate exits silently, and a shimmed failing
doctor yields jq-valid JSON with class "bad" and the ✖ lines in the
tooltip. Live render queued for hardware.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-05 21:46:29 +01:00
Bernardo Magri
4ad564bd80 feat(capture): OCR region to clipboard (LATER item)
All checks were successful
Check / eval (push) Successful in 3m6s
Tools › Capture gains "OCR region → clipboard", self-gated on
tesseract: slurp the region (Esc cancels silently, the recording.nix
pattern), grim it, tesseract stdin→stdout, wl-copy. Whitespace-only
recognition toasts "No text recognized" instead of clobbering the
clipboard; success toasts the word count. tesseract ships English-only
through the menu's package set — the unscoped wrapper pulls every
language's traineddata, a real cost on a compile-from-source distro;
overriding with more languages is documented at the package line.

Verified: V0; V1 — template-home builds with the gated row + case and
menu bash -n; functional — the profile's own tesseract read a rendered
test image back verbatim, proving the OCR half of the pipe. The
grim/slurp half needs a session → HARDWARE-QUEUE.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-05 21:29:08 +01:00
Bernardo Magri
3e49481d46 feat(menu): Look & Feel category — theme, wallpaper, night light (item 19)
All checks were successful
Check / eval (push) Successful in 3m3s
The gate was >=3 appearance entries and it's met: the root's Theme row
becomes "Look & Feel" (pango-escaped &amp; — the root passes
-markup-rows; matched on *Look*), a new lookfeel submenu carries the
Theme grid (SUPER+T hint), Next wallpaper (SUPER+SHIFT+T hint via the
new generalized hintForAction, which menuHint is now sugar over), and
the Night light toggle moved out of System — which thins by one row.
The root stays at six entries; both direct binds still hit the leaves.

Verified: V0; V1 — rendered menu passes bash -n with the mode, escaped
label and relocated night-light row; visual — tokyo-night 1080p
capture renders "Look & Feel" correctly with hints intact. Submenu
flow queued for a session test (HARDWARE-QUEUE).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-05 21:04:15 +01:00
Bernardo Magri
8d54eecd67 feat(hyprland): launch-or-focus binds (item 17)
All checks were successful
Check / eval (push) Successful in 3m4s
nomarchy.launchOrFocus (opt-in, commented template example): each
entry gives a key that FOCUSES the app's existing window
(case-insensitive class match via jq + a "(?i)" focuswindow regex) or
launches it when none is open. One shared nomarchy-focus-or-launch
script; a bind whose command was removed from the suite fires a
"not installed" toast instead of failing silently. The same entries
render into the SUPER+? cheatsheet through prettyKeys — bind and
documentation stay one source. README row added.

Verified: V0; V1 — a scratch downstream with two entries renders both
binds (default command = lowercased class; custom mods/command/desc
honored), the script passes bash -n with a usage guard, and the
cheatsheet carries both rows. Focus behaviour needs a session -> V3
(HARDWARE-QUEUE).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-05 20:56:01 +01:00
Bernardo Magri
4e3acbe89c fix(themes): dim the summer rasi search placeholder + latte/backdrop findings (item 28c)
All checks were successful
Check / eval (push) Successful in 2m59s
Both summer whole-swap rasi rendered prompt and placeholder in the
same bg0 on the green inputbar, reading as one run-on phrase — the
placeholder is now bg0 at 60% alpha, matching the generated theme's
hierarchy.

Recorded with it: the catppuccin-latte 1080p capture shows the
generated menu fully coherent on light themes (the audit's "dark
terminal on latte" was a stale preview), and a dedicated VM probe
confirms misc:background_color is applied (hyprctl getoption =
0xFFEFF1F5) — the dark backdrop in captures is awww's empty-state
layer over it, a wallpaper-less-VM artifact, not a defect.

Verified: rasi edits grep-verified (visual rides the next summer
capture / hardware); latte capture + probe reviewed. Item 28 is now
gated on the icon-weight [human] call and the queued hardware
eyeballs.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-05 20:45:36 +01:00
Bernardo Magri
8f2e047f4a feat(menu): dimmed keybind hints on menu rows (item 28c)
All checks were successful
Check / eval (push) Successful in 3m1s
The audit flagged the root picker as six short rows in ~760px of
empty width; the space now carries discoverability. menuHint renders
a dimmed pango span (size small, 55% alpha) from the SAME keybinds.nix
the Hyprland binds and the SUPER+? cheatsheet come from (prettyKeys),
so hints can never drift. Applied to the root picker (Apps special-
cased — it's rofi drun), all Tools leaves, and the System rows with
direct binds; those menus pass -markup-rows. Contains-glob matching
and the exact "↩ Back" row are unaffected.

Verified: V0; V1 (rendered menu bash -n, spans + 3x -markup-rows
present); visual — tokyo-night 1080p capture shows the hints dimmed
and subordinate on normal and selected rows alike.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-05 20:36:04 +01:00
Bernardo Magri
8874a22a37 feat(bar): group rhythm + semantic @muted dim states (item 28c)
All checks were successful
Check / eval (push) Successful in 2m57s
Two polish items from the 1080p review. The right cluster gains a
wider breath (margin-left 14px) before each functional group — media
(pulseaudio/cpu/memory), toggles (powerprofile/nightlight/language),
status (battery/updates/notification); a self-hiding group head
degrades to the uniform spacing. Dim states (inactive workspaces, DND
bell, muted volume) move from alpha(@text, 0.5) tints to the
palette's @muted role — safe now that 28b floor-gates muted/base >=
2.0 in every theme; the CSS comment that warned against @muted
documented the pre-28b palettes and now states the new invariant.
Whole-swap bars untouched (authored identity).

Verified: V0; V1 (rendered CSS carries the rule + 4 @muted uses);
visual — tokyo-night 1080p re-capture shows the themed backdrop
(iteration #35's fix, navy not black), the group gap, and legible
muted workspaces.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-05 20:19:14 +01:00
Bernardo Magri
310614bdd2 feat(hyprland): themed backdrop — misc:background_color from the palette (item 28c)
All checks were successful
Check / eval (push) Successful in 3m2s
The compositor backdrop behind a missing or still-loading wallpaper
was hard black; on light themes that flash reads as a glitch (caught
by the 1080p capture harness on summer-day). Now the theme's base
color, so every wallpaper-less moment stays in-palette.

Also from the summer-day capture review: the authored whole-swap menu
+ island bar survive the 28b retunes as one coherent identity (the
slate menu deliberately mirrors the bar strip — an earlier "polarity"
concern retracted); nit filed for the undimmed placeholder in the two
summer rasi. Punch list updated in item 28.

Verified: V0; V1 — template-home rebuilt, hyprland.conf renders
background_color=rgb(1a1b26). Visual confirmation rides the next
capture run.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-05 20:08:35 +01:00
Bernardo Magri
fb75c3dedf feat(tools): 1080p themed-desktop capture harness + first polish findings (item 28c)
All checks were successful
Check / eval (push) Successful in 3m2s
tools/theme-shot.nix: boots the full themed desktop headlessly
(software-GL Hyprland, THEME env picks the slug) at 1920x1080 and
QMP-dumps desktop.png + menu.png (rofi root open) — the review
artifact for slice (c). Two fixes over the 2026-06-19 spike:
home-manager.useGlobalPkgs (the HM submodule otherwise misses the
overlay) and the menu shot via hyprctl dispatch exec. Maintainer tool,
deliberately not a checks.* gate. Known gap in the header: the
wallpaper needs ~/.nomarchy seeded in the guest.

First full-res review (tokyo-night) added to the item-28 punch list:
rofi root menu is ~760px of mostly empty row space (candidate:
right-aligned SUPER+CTRL hints from keybinds.nix), full-color Papirus
menu icons vs the all-monochrome bar glyphs is a [human] identity
call, bar right-cluster group margins confirmed at full res, and the
bar's alpha(text,.5) dim states should become the @muted role now
that 28b floors guarantee its legibility. Whole-swap bar module
parity verified textually — no drift.

Verified: harness end-to-end green (screenshots read back); evals
from tools/ via the relative flake path.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-05 20:00:28 +01:00
Bernardo Magri
44d5516191 fix(themes): P2 contrast floors retuned + gated across 9 themes (item 28b done)
All checks were successful
Check / eval (push) Successful in 3m10s
18 hex retunes, hue-preserving OKLCH minimal raises except where the
upstream palette defines a canonical value: muted lifted to >= 2.0 on
lumon/white/retro-82/everforest (solved), gruvbox (bg4 #7c6f64), nord
(#616e88, the community comment-brightening), catppuccin-latte
(overlay1 #8c8fa1); text-on-surface >= 4.5 via surface nudges
(ristretto darker, latte lighter); accentAlt >= 3.0 (latte, summer-day
darkened); warn >= 2.5 — the bar's battery tint — darkened on the
light bases (latte, rose-pine dawn, summer-day) and flexoki-light's
four status colors moved to their canonical light-mode 600 series.

check-theme-contrast.py now gates all four adopted floors (7 pairings
x 21 themes, green). good/bad stay audit-only — identity themes sit at
2.0-2.3 there by design, so no exemption mechanism was needed. The
design audit's contrast findings drop 20 -> 1 (miasma's earthy `bad`,
kept as identity). Item 28 slice (b) complete.

Verified: V0, checks.theme-contrast green, flake check green,
re-audit. Rendering is V3 -> HARDWARE-QUEUE eyeball entries.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-05 19:44:46 +01:00
Bernardo Magri
47c8b6f997 fix(themes): P1 palette retunes from the design audit + gated subtext floor (item 28b)
All checks were successful
Check / eval (push) Successful in 3m0s
The audit's P1s, fixed at the palette level (items 25/27 previously
patched the consumers around these):
- summer-day: surface was the TEXT color (slate #5c6a72 on cream) and
  subtext was the BASE (ratio 1.0, invisible) — now a raised cream
  surface #efe7d0 and a slate-teal subtext #6e828a in the theme's
  family.
- flexoki-light: surface/overlay/muted were all the text black and
  subtext the paper — retuned to canonical upstream Flexoki tones
  (ui #E6E4D9, #B7B5AC, tx-2 #6F6E69, base-500 #878580).
- miasma: pure-black surface under a #222 base → raised #2e2e2e.
- kanagawa: surface was sumiInk0 (darker than the bg) → upstream's
  raised sumiInk4 #2a2a37.
check-theme-contrast.py gains the adopted rule: subtext on base >= 3.0
(all 21 themes pass), so the invisible-secondary-text class cannot
ship again. Model note in the backlog: `overlay` is a mid-tone role
(catppuccin semantics), not a third bg tier.

Verified: V0 + checks.theme-contrast green (21x3); re-audit shows all
four P1 findings gone; a scratch downstream with summer-day active
builds and the new surface hex lands in the rendered ghostty config.
Remains: V3 four-theme eyeball (HARDWARE-QUEUE) and 28b's P2 floors +
exemption list.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-05 18:55:44 +01:00
Bernardo Magri
abc953ea84 feat(tools): theme design audit + item 28 punch list (slice a)
All checks were successful
Check / eval (push) Successful in 3m0s
tools/audit-theme-design.py (report-only, maintainer-run): for every
themes/*.json it checks OKLCH lightness architecture (raised bg stack,
fg hierarchy), WCAG contrast beyond the guarded pairs, status-color
hue families, accent harmony, protanopia/deuteranopia
distinguishability (Machado matrices), and ANSI slot semantics the
greeter/tty now rely on. Ran it over all 21 palettes + a visual pass
over representative previews; findings curated identity-aware (lumon/
vantablack/white/hackerman exemptions are identity, not defects) into
the P1-P3 punch list now recorded in BACKLOG item 28: two palettes
with literally invisible subtext (summer-day, flexoki-light — the
item-25/27 trap at its source), two inverted bg stacks (miasma,
kanagawa), muted/warn floors, a systemic "status is never color-only"
rule, the light-theme ANSI convention decision, and bar grouping
rhythm notes.

Verified: V0 (py_compile; the tool is report-only, deliberately not a
checks.* gate until slice b adopts rules + exemptions).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-05 18:45:56 +01:00
Bernardo Magri
d1d6a09d9d docs(backlog): item 28 — theme UI review grounded in design theory (Bernardo)
All checks were successful
Check / eval (push) Successful in 3m4s
Human-requested: a thorough visual review of the whole themed surface
(Waybar incl. whole-swaps, rofi, swaync, hyprlock, terminals, splash,
greeter) with "stunning" as the bar. Grounded in design theory rather
than taste: color-harmony models + perceptual (OKLCH) lightness
ordering of the palette roles, color-blind-safe status colors, an
extended contrast checker encoding the adopted rules, a consistent
type scale + spacing grid, and explicit visual hierarchy. Image-expert
models are available for designed artwork — slices that need graphics
stop and ask instead of shipping programmer-art. Sliced audit-first.

Placed at the head of NEXT for Bernardo to reorder.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-05 18:38:13 +01:00
Bernardo Magri
a360bc87ca feat(greeter): tuigreet + console themed from the state JSON (item 16)
Some checks failed
Check / eval (push) Has been cancelled
New modules/nixos/greeter.nix owns the greetd block (moved out of
default.nix) plus the theming, the Plymouth model — baked at SYSTEM
rebuild from nomarchy.system.stateFile. Two parts, because tuigreet
draws on the VT with the 16 ANSI slots: console.colors becomes the
theme's ansi[] (vt.default_* kernel params — which also themes raw
ttys and the LUKS passphrase prompt), and tuigreet gets a --theme of
NAMED slots (its parser is ratatui Color::from_str, checked in source;
the names map to the standard indexes the retinted palette now
serves). A sparse state without ansi skips theming cleanly.

Verified: V0; V1 — the template-system toplevel bakes the full --theme
spec into greetd.toml and all three vt.default_{red,grn,blu} palettes
into kernel-params. A checks.greeter VM test was attempted and dropped
after bisecting: tuigreet dies under runNixOSTest even without the
flag (harness limit; nixpkgs's own greetd test uses agreety) —
recorded in agent/MEMORY.md. Remains: V3 rendering +
theme-follows-rebuild on hardware (HARDWARE-QUEUE).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-05 18:37:32 +01:00
Bernardo Magri
1921839e0e feat(displays): hotplug auto-switch for display profiles (item 15b)
All checks were successful
Check / eval (push) Successful in 2m57s
nomarchy-display-profile gains `match <outputs>`: an exact set match
wins, else the largest profile whose named outputs are all connected;
any tie prints nothing — the watcher must never flap between ambiguous
layouts. nomarchy-display-profile-watch (exec-once when profiles are
declared, the keyboard-watcher poll pattern — session units race
Hyprland IPC on relogin) reacts to output-set CHANGES only, reads
settings.displayProfileAuto from the live state each time (the menu's
new Auto-switch row toggles it instantly, no rebuild), and applies via
the tool — so an auto-pick persists a concrete profile that the next
rebuild bakes. A separate boolean rather than an "auto" pseudo-profile
for exactly that bake semantics.

Verified: V0 (flake check); V1 — a scratch 4-profile downstream builds;
tool + watcher pass bash -n; the match matrix exercised end-to-end:
three exact picks, an unambiguous-superset pick, a tie -> none, a
no-fit -> none; the watcher is exec-once'd in the rendered config; the
menu renders the Auto-switch row. Remains: V3 real dock/undock
(HARDWARE-QUEUE, rides the slice-a entry). Slice c (workspace binding)
stays, tagged optional.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-05 18:14:07 +01:00
Bernardo Magri
9df18261f9 feat(displays): display profiles — docked/undocked switching (item 15a)
All checks were successful
Check / eval (push) Successful in 3m4s
nomarchy.displayProfiles: named layouts for the same outputs, each a
list of nomarchy.monitors-style entries. The active profile lives in
the in-flake state (settings.displayProfile) on the night-light
pattern: nomarchy-display-profile apply <name> fires the baked hyprctl
rules instantly and writes the state with --no-switch; the next rebuild
bakes the profile's entries over nomarchy.monitors whole-by-name.
Monitor-rule composition is extracted to pure monitor-rules.nix
(renderer + 3-layer resolve, incl. a disable-guard so a stale menu
resolution pick can't resurrect a profile-disabled panel, and junk
state degrading to base config instead of an eval error) — because a
writeText/toFile state fixture is unreadable at flake-check eval time,
the new checks.display-profiles unit-tests the pure function directly.
Menu: System › Display gains a self-gated "Profiles ›" row (●/○ active
mark, Base layout to clear); commented template example + README row.

Verified: V0 (flake check, forcing the new check's asserts); V1 — a
scratch downstream through lib.mkFlake builds: the generated tool
passes bash -n with correct rules (disable/vrr/float scale), the baked
hyprland.conf shows the exact expected overlay, the menu renders, and
list/usage smoke-test clean. Remains: V3 live switching on a dock
(HARDWARE-QUEUE). Slices b (hotplug auto-switch) + c (workspace
binding) stay in item 15.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-05 17:52:19 +01:00
Bernardo Magri
e02b4d8200 docs(backlog): design pass for display profiles (item 15)
All checks were successful
Check / eval (push) Successful in 3m1s
The item required a written plan before coding. Option shape reuses the
existing monitorType submodule (attrsOf (listOf monitorType)); active
profile is in-flake state (settings.displayProfile) on the night-light
instant+bake pattern; whole-entry-by-name rebuild overlay with a
disable-guard so a stale menu resolution pick can't resurrect a
disabled panel; self-gated System > Display profile row; sliced a/b/c
with hotplug auto-switch and workspace binding deferred behind the
core picker. kanshi remains a non-goal.

Verified: V0 (docs-only change).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-05 17:20:26 +01:00
Bernardo Magri
5eadf0cff6 feat(ci): scheduled weekly lock bump + dispatch fast-lane (item 14 b+c)
Some checks failed
Check / eval (push) Has been cancelled
.gitea/workflows/bump.yml: Mondays 05:17 UTC (+ workflow_dispatch for
security bumps), nix flake update — within the pinned release branches
by construction, never a release jump — gated by nix flake check
--no-build, then a pathspec-limited flake.lock commit pushed to main
with the update's change list as the body. That push triggers check.yml
as a second net. Red gate or push race = a visibly failed run, nothing
lands. v1 promotion stays human. py_compile/bash -n deliberately not in
the gate: they don't read the lock. Container recipe inherited from
check.yml (nixbld, sandbox=false, pinned Nix 2.31.5).

Verified: V0 (yq parse, bash -n per run block); the update/commit/push
logic simulated end-to-end in a scratch clone with a real nix flake
update and a bare remote (changed + unchanged branches, bystander file
stays uncommitted, push lands); today's bumped lock evals green.
Remains: the first real run (cron pickup + Actions-token push) is not
confirmable from a session — BACKLOG item 14 slimmed to that watch.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-05 17:18:54 +01:00
Bernardo Magri
26e393b65d feat(menu): hyprpicker color picker — Tools › row + SUPER+CTRL+P (item 13 done)
All checks were successful
Check / eval (push) Successful in 3m0s
The last niceties slice: pick a pixel's color into the clipboard.
hyprpicker joins grim/slurp in systemPackages; a new nomarchy-menu
`colorpicker` mode captures its output, wl-copies it, and toasts the
hex (Esc = silent cancel). Tools › row self-gates on hyprpicker being
present; SUPER+CTRL+P added in keybinds.nix, so the Hyprland bind and
the SUPER+? cheatsheet row come from the one source. Item 13 complete
and deleted from BACKLOG.

Verified: V0 (flake check); V1 — template-home and template-system
build; rendered nomarchy-menu passes bash -n and contains the mode +
gated row; cheatsheet has the SUPER+CTRL+P row; hyprland.conf has the
bind; the system toplevel ships sw/bin/hyprpicker. Remains: V3 — real
loupe → click → wl-paste round-trip in a session (HARDWARE-QUEUE).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-05 15:54:30 +01:00
Bernardo Magri
fb78c814cc feat(battery): low-battery notifications at the bar's 25/10% thresholds (item 13 slice)
All checks were successful
Check / eval (push) Successful in 3m4s
The bar colored the battery at 25/10% but nothing notified. New
shellcheck-gated watcher (pkgs/nomarchy-battery-notify, overlay) polls
the same sysfs the bar reads — type=Battery, scope!=Device — and fires
one toast per downward crossing (normal at 25%, critical at 10%; swaync
keeps critical up until dismissed), re-armed by charging. Self-gates on
battery presence (the powerprofile pattern), so desktops get a silent
no-op. HM user unit behind nomarchy.batteryNotify.enable (default on)
with libnotify on the unit PATH; notify-send resolves from PATH so the
VM check can shim it. poweralertd rejected: its UPower thresholds
(20/5) wouldn't match the bar, and it toasts every plug/unplug.

Verified: V0 (flake check, bash -n); V1 (template-home builds, unit in
the generation); V2 GREEN (new checks.battery-notify, test_power fake
battery: self-gate, silence at 80%, one toast per crossing, critical
urgency, charging re-arms). Remains: V3 — real swaync toast on a
draining laptop (HARDWARE-QUEUE).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-05 15:11:06 +01:00
Bernardo Magri
821032e81c update claude.md
All checks were successful
Check / eval (push) Successful in 3m5s
2026-07-04 22:53:02 +01:00
Bernardo Magri
b7b51e9354 feat(bar): idle-inhibit (caffeine) toggle — generated bar + summer-day parity (item 13 slice)
All checks were successful
Check / eval (push) Successful in 2m55s
Summer-night's whole-swap bar had an idle_inhibitor the generated bar
lacked (reverse parity gap from the 2026-07-04 sweep). The generated
bar now carries it — 󰾪 click → 󰅶, warm tint while active, tooltips
saying what it holds — blocking hypridle's lock/display-off/suspend
for videos and presentations; state deliberately resets with the bar.
summer-day mirrors summer-night's module + CSS, and the keybinds
cheatsheet mentions the bar click (via keybinds.extra).

V1: rendered config has the module with correct glyphs, CSS rules
present, cheatsheet row renders. Inhibit behaviour needs a session →
V3 queued.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-04 21:05:03 +01:00
Bernardo Magri
0fe46221ea feat(capture): screen recording — menu start, bar-click stop (item 12)
All checks were successful
Check / eval (push) Successful in 2m56s
nomarchy-record owns the lifecycle: start prefers wl-screenrec (VAAPI
hardware encode) and falls back to wf-recorder if it dies at startup
(the no-VAAPI case), stop SIGINTs so the file finalizes cleanly, and
status feeds a self-gating Waybar ⏺ REC (alert red) whose click is
the one stop surface — signal 8 pokes make it appear/vanish
instantly. Capture gains Record region/screen (+ audio variants) and
self-swaps to '■ Stop recording' while one runs. Parity: both summer
whole-swap bars carry the same module. Recordings land in
~/Videos/Recordings with the screenshot timestamp naming (deviation
from 'next to Screenshots': an .mp4 in Pictures felt wrong).

V1: rendered bar config + CSS asserted, summer jsonc parse, recorder
dry-run state machine correct (idle/active/usage exits), both
recorders in home-path. Real capture needs a session → V3 queued
(incl. the VAAPI-vs-fallback check on the AMD box).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-04 20:58:40 +01:00
Bernardo Magri
bfb80cb60d feat(state): validation + friendly errors on both write and eval paths (item 11)
All checks were successful
Check / eval (push) Successful in 2m56s
'Never has to master Nix' includes the error messages. The tool grows
validate_state (appearance schema = hard errors; settings.* and
unknown keys = warnings so menu writers and newer schemas keep
working), a read-only `validate` subcommand, and validation BEFORE
every write — an invalid set/apply is refused with the on-disk file
untouched. JSON syntax errors now say line, column, and fix. theme.nix
enforces the same schema at eval time (on the post-defaults state, so
sparse/older files still evaluate) and throws a message naming each
field, its got-value, and the fix paths — the hand-edit escape hatch
no longer ends in a Nix stack trace. Doctor's JSON fix-text points at
validate.

V1: new checks.theme-sync-validate fixture corpus green (good state,
trailing comma, bad hex, bad type, unknown key, refused set with
byte-identical file); negative mkFlake eval of a broken scratch
downstream shows the friendly throw; good-path template-home builds.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-04 20:44:08 +01:00
Bernardo Magri
352c681f48 docs(agent): MEMORY — check git status before add -A (harness drops files)
All checks were successful
Check / eval (push) Successful in 2m54s
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-04 20:35:01 +01:00
Bernardo Magri
d2ac131b75 chore: untrack .claude/skills (harness-dropped, machine-local)
Some checks failed
Check / eval (push) Has been cancelled
A git add -A in 2a23e82 swept in .claude/skills/use-gemini/SKILL.md —
a Claude Code harness-injected tool doc, not part of the distro. Same
treatment as settings.local.json (db6a0ba): untracked + gitignored.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-04 20:34:40 +01:00
Bernardo Magri
2a23e82169 feat(doctor): nomarchy-doctor — read-only health sheet + VM check (item 10)
Some checks failed
Check / eval (push) Has been cancelled
The things that actually break user machines, one command (and menu ›
System › Doctor): failed system/user units, disk space (real
filesystems only — fstype allowlist makes it VM/live safe, device
dedupe collapses / and /nix), theme-state.json parses + is
git-tracked, flake dirty/behind as warnings, generation age (profile
symlink mtime — store paths are epoch-1), snapper timeline when
enabled. Read-only by contract; every ✖ prints the one fixing command;
exit 1 on any problem. Ships as pkgs/nomarchy-doctor so the VM check
runs on a minimal node.

Verified: V1 package build (shellcheck-gated) + a real-hardware smoke
that correctly flagged a genuinely failed user unit on the dev box;
V2 green — checks.doctor VM test: induced failed unit flips the sheet
to exit-1 naming the unit and its fix, reset-failed returns healthy.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-04 20:34:11 +01:00
Bernardo Magri
1b8eccbdca feat(menu): System › Rollback — desktop generations picker (item 9b, item 9 done)
All checks were successful
Check / eval (push) Successful in 3m1s
Undo is one menu away: the new rollback flow lists the last ten Home
Manager generations (the theme/config history is already generations,
newest marked current) and activates the picked one in a terminal —
HM's supported rollback, reversible by re-picking a newer one, so no
typed-yes gate. System-level undo deliberately links out rather than
reimplementing destructive flows in rofi: the Snapshots row opens the
existing gated flow, and 'boot an older generation (how)' fires an
instruction notification pointing at docs/RECOVERY.md §3 (which now
cross-references the menu path).

V1: template-home built, generated menu bash -n clean, row rendering
and pick-parsing verified against fixture generations output. V3
exercise queued.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-04 20:24:37 +01:00
Bernardo Magri
3f5e414341 feat(update): sys-update/sys-rebuild end with an nvd what-changed diff (item 9a)
All checks were successful
Check / eval (push) Successful in 3m0s
The informative half of 'informative + rock-stable': both rebuild
wrappers capture /run/current-system before the switch and print a
package-level nvd diff after it — store-path-pinned so no PATH
dependence, || true so an ornamental diff can never fail a succeeded
rebuild, and an identical-path fast-path says 'no changes'. README §3
notes it. V1: template-system toplevel rebuilt, generated scripts
bash -n clean, nvd smoke-run against the real closure.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-04 20:14:23 +01:00
Bernardo Magri
774bdad6e4 feat(desktop): viewers (zathura+imv) + xdg-mime defaults (item 8)
All checks were successful
Check / eval (push) Successful in 2m52s
The complete-workstation gap: no PDF/image viewer shipped and nothing
set mime defaults, so 'open a photo' fell to GIMP. Two new default-on
toggles: nomarchy.viewers (programs.zathura — Stylix themes it, target
added to the explicit list — plus imv) and nomarchy.mime (mkDefault
xdg.mimeApps: pdf→zathura, image→imv, av→mpv, text→code, dir→thunar;
html/http(s)→firefox as inert entries that only activate when a
browser is installed — the ship-a-browser Decision stays open, now
smaller). Associations for absent apps are skipped by GIO, so the
whole thing degrades as the user curates the suite. Deviation from
the backlog text: viewers are a module toggle, not template package
lines — zathura's theming needs its HM module (the real-config
boundary of the toggle discipline).

V1: template-home generation asserted — mimeapps.list rendered, themed
zathurarc, binaries in home-path. V3 open-a-file smoke queued.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-04 20:06:45 +01:00
Bernardo Magri
e05e3647e6 docs(install): stitch the README → template first-run handoff — item 6 complete
All checks were successful
Check / eval (push) Successful in 2m55s
Slice (c), the last of the docs review: the install/first-run story
read end to end against the tree. Facts held (mkFlake args match the
table, disko's @snapshots + hibernation swapfile, .#default/.#me
naming); the one seam — README §3 never pointed the manual path at the
template README's first-run steps — got a connective paragraph. Item 6
deleted from BACKLOG; ROADMAP § Full docs review flipped to ✓.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-04 19:45:34 +01:00
Bernardo Magri
472d7502b4 docs(recovery): the undo runbook — generations, journals, snapper, nixos-enter (item 6d)
All checks were successful
Check / eval (push) Successful in 3m2s
Slice (d) of the docs review: docs/RECOVERY.md tells the recovery
story that existed only in pieces, ordered by symptom — bad theme/HM
change (generations, apply-known-good, git-revert the flake), desktop
won't start (TTY, greetd + user journals, VM-GL and preactivate-log
pointers), broken system change (older systemd-boot generation, then
make it stick), lost files (btrfs-assistant / nomarchy-snapshots), and
the ISO + nixos-enter last resort. Commands checked against the tree;
linked from the README file tree and §5.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-04 19:41:05 +01:00
Bernardo Magri
b0b8a9a09b docs(review): drift pass over OVERRIDES/TESTING/template README (item 6b)
All checks were successful
Check / eval (push) Successful in 3m3s
Slice (b) of the docs review: every factual claim in the three docs
spot-checked against the tree (mkDefault sites, bind priority, CLI
keys, awww naming, preset/wallpaper counts, summer-night's light bar,
live-ISO monitor rule, CI workflow, QEMU GL flags, template steps) —
all held. Two gaps fixed: OVERRIDES §2 predated nomarchy.monitors and
never routed readers to it (added, plus a quick-reference row);
TESTING §1 didn't list the shell-syntax check that §1b says CI runs
(added bash -n).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-04 19:36:01 +01:00
Bernardo Magri
56f1cc3fa9 docs(options): README tables reconciled + permanent option-docs check (item 6a)
All checks were successful
Check / eval (push) Successful in 2m59s
Slice (a) of the docs review: 9 undocumented nomarchy.* options got
README rows or inline .leaf mentions (autoTimezone, camera IR-hide,
intel.guc, amd.pstate/vaapi, package, system.stateFile, restic.paths).
The diff is now a flake check: option names are eval-walked from the
four option-declaring modules (config halves stay lazy) and compared
to the tables by tools/check-option-docs.py — undocumented options and
stale table rows both fail. Negative-tested against the pre-fix README
(reports exactly the 9 gaps). Item 6 split into slices a–d in BACKLOG.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-04 19:31:20 +01:00
Bernardo Magri
9cd6f5e30c fix(menu): Back audit — cheatsheet gets ↩ Back, exceptions documented (item 24)
All checks were successful
Check / eval (push) Successful in 3m3s
Audited every rofi -dmenu call site in the generated nomarchy-menu +
nomarchy-vpn. The earlier "Back everywhere" pass held up: power,
power-profile, theme, clipboard, files, audio, display (both stages),
capture, tools, system and all four VPN lists already end in ↩ Back.

Fixed: the keybinds cheatsheet — Enter on any row silently closed it;
it now ends in ↩ Back returning to the root picker (its launch point),
and Enter elsewhere stays a deliberate no-op.

Documented (rofi.nix, at the convention comment + call sites): external
modi (rofi-calc, rofi-emoji, networkmanager_dmenu, rofi-pulse-select)
cannot take an injected row — Esc is their back path; free-text
prompts (web, ask) have no list to append to.

Verified: V0; V1 (built nomarchy-menu, bash -n OK; awk audit over the
generated script shows the only back-less lists are the root picker —
by design — and the free-text prompt). V3 spot-check queued.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-04 19:20:08 +01:00
Bernardo Magri
a83edb0d36 fix(menu): network picker blank rows — compact mode for networkmanager_dmenu (item 22)
All checks were successful
Check / eval (push) Successful in 3m13s
Source-read of the pinned networkmanager_dmenu 2.6.2: with
compact = False (what our config.ini set) the script inserts literal
empty rows as separators between the ethernet/wifi/VPN sections —
the nameless entries Bernardo saw on the Latitude. compact = True
drops the separators and uses the unpadded row format, which also
renders better in rofi's proportional font than space-aligned columns.

Known residual, documented in config comment + HARDWARE-QUEUE: a
hidden-SSID AP still renders one nameless row (sec+bars only; the
name dedup keeps exactly one, and no config option filters it). If the
V3 retest still shows it, the follow-up is a small source patch
skipping empty-name APs in create_ap_list.

Verified: V0; V1 (rendered config.ini shows compact = True). Behavior
needs Wi-Fi hardware — V3 queued on the Latitude.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-04 19:15:05 +01:00
Bernardo Magri
fd6e5f60e9 fix(theme): generated waybar/rofi CSS invisible on on-surface palettes (item 27)
All checks were successful
Check / eval (push) Successful in 2m53s
Follow-on to the swaync fix (a643391): the generated Waybar CSS colored
the module row and #window @subtext and dim states @muted, and the
generated rofi theme put @fg text on @surface chips — invisible where
palettes use those roles as "on-surface" (flexoki-light: subtext==base,
surface==text; gruvbox: muted/base = 1.27). flexoki-light has no
whole-swap, so it ran this CSS. Generated surfaces only — the summer
whole-swaps are hand-authored and untouched.

- waybar: secondary text → alpha(@text, 0.85); dim states (inactive
  workspaces, DND bell, muted volume) → alpha(@text, 0.5).
- rofi: @dim → text+"80", @surface → text+"1a" (#RRGGBBAA fg tints).
- check-theme-contrast.py pairings/docs now cover all three generated
  surfaces; status glyphs (good/warn/bad on base, worst 2.05) are
  deliberately not held to a text ratio — raising them is palette
  design, a human decision.

Verified: V0; V1 (checks.theme-contrast green over 21 themes;
downstream-template-home builds; rendered waybar style has zero
@subtext/@muted/@surface; rendered custom.rasi shows fg-derived
tints). Honesty: NOT visually verified — V3 queued (flexoki-light
eyeball + dark-theme regression spot-check).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-04 19:08:18 +01:00
Bernardo Magri
a643391d3d fix(swaync): palette-safe pairings — text was invisible on summer-day (item 25)
All checks were successful
Check / eval (push) Successful in 2m52s
Palette roles aren't uniform: summer-day and flexoki-light use subtext
as text-ON-surface (== base) and surface as a dark chip (== text), so
swaync's @subtext-on-@base body text and @surface/@text buttons were
self-colored (caught on the Latitude).

- body/summary: @text on @base — the one text pairing that passes in
  every palette (survey: worst WCAG ratio 5.18 across all 21 themes;
  subtext/base bottomed at 1.00 twice, muted/base at 1.27).
- hover rows + widget-title buttons: alpha(@text, 0.1) tints —
  contrast by construction; subtext/surface defines dropped.
- NEW checks.theme-contrast (tools/check-theme-contrast.py): asserts
  the hex-on-hex text pairings across themes/*.json, cheap (no VM).

The audit also found the generated waybar/rofi CSS shares the bug
class, live on flexoki-light (no whole-swap) → BACKLOG item 27.

Verified: V0; V1 (checks.theme-contrast builds green, 21 themes;
rendered services.swaync.style has zero @subtext/@surface). Honesty:
NOT visually verified — V3 queued (notify-send on summer-day).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-04 18:53:01 +01:00
Bernardo Magri
63136a8cb1 fix(keybinds): SUPER+? never fired — shifted keysym needs SHIFT in the modmask
All checks were successful
Check / eval (push) Successful in 2m53s
? is typed as Shift+/, so Hyprland saw mods SUPER+SHIFT with keysym
`question` and the exact-modmask match skipped the `$mod, question`
bind — the keypress fell through to the focused window (caught on the
Latitude, BACKLOG item 26; the menu itself was fine from a terminal).

- keybinds.nix: bind is now `$mod SHIFT, question`.
- rofi.nix prettyKeys collapses "SHIFT + ?" back to "?" so the
  cheatsheet row keeps the documented SUPER + ? label.
- MEMORY.md gotcha: shifted keysyms need SHIFT in mods — invisible to
  eval-tier tests.

Verified: V0 (flake check --no-build); V1 both surfaces (eval'd
settings.bind renders `$mod SHIFT, question, exec, nomarchy-menu
keybinds`; built nomarchy-menu's cheatsheet renders `SUPER + ?`).
V3 queued: SUPER+? on the Latitude after home-update + relogin.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-04 18:46:50 +01:00
Bernardo Magri
0d80ab272f docs(agent): sweep #2 late confirms — auto-timezone PASS, item 26 is bind-only
All checks were successful
Check / eval (push) Successful in 2m58s
timedatectl shows Europe/London → auto-timezone checked off (manual
set-timezone re-trigger not exercised). `nomarchy-menu keybinds` opens
fine from a terminal, so item 26 is confirmed to be only the
`$mod, question` bind, not the menu.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-04 18:40:34 +01:00
Bernardo Magri
397dd5991a docs(agent): Latitude sweep #2 booked — 6 passes, SUPER+? bug filed (item 26)
All checks were successful
Check / eval (push) Successful in 3m0s
PASS: waybar supervisor resilience, snapshots polkit+GUI (restore/
rollback residual queued), sys-rebuild, night-light on/off,
auto-commit, hibernate single-unlock (1b0eeea confirmed).
Partial: auto-timezone (enabled; zone check pending).

Item 26: SUPER+? never fires — `$mod, question` can't match because a
real ? carries SHIFT in the modmask; fix is `$mod SHIFT, question`.
SUPER+SHIFT+K no-op was the comma-layout gate working (none
configured) — retest annotated in HARDWARE-QUEUE.

PROPOSED: menu parity for downstream-flake options (Bernardo) — every
hand-edit-the-flake option gets a menu writer, per the in-flake-state
philosophy.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-04 18:36:32 +01:00
Bernardo Magri
274ffc25e1 docs(agent): model & token economy rules for the loop
All checks were successful
Check / eval (push) Successful in 3m1s
Plan/reason/review on the strong model (Fable 5); delegate mechanical,
fully-specified subtasks (grep sweeps, table reconciliation, repeated
edits, log summarization) to cheaper models (haiku/sonnet) via subagent
model overrides — the strong model writes the spec and owns the commit.
Plus narrow-reading and terse-writing rules, and a cheaper --model
allowance for low-stakes headless iterations.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-04 18:07:32 +01:00
Bernardo Magri
0e6c678835 docs(agent): Latitude QA 2026-07-04 — media keys PASS, swaync contrast bug filed
- HARDWARE-QUEUE: media keys + touchpad gestures (dccceb4) checked off
  — verified working on the Latitude 5410 by Bernardo.
- BACKLOG item 25 (NOW): swaync text unreadable on summer-day. Traced:
  swaync.nix pairs @subtext on @base, but summer-day (and flexoki-light)
  use subtext as text-ON-surface (== base), and summer-day also has
  surface == text — self-colored body text and buttons.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-04 18:07:32 +01:00
Bernardo Magri
18b854563b fix(waybar): supervisor respawn + clean-restart switches; add sys-rebuild
Recovers the crashed iteration #7 (items 21+23, Latitude QA findings):

- nomarchy-waybar supervisor (waybar.nix, exec-once'd from
  hyprland.nix): any waybar exit respawns the bar; crash-loop guard
  (5 fast exits -> critical notify + stop); TERM trapped for a real
  stop. Fixes the bar-less session after a theme-switch crash.
- nomarchy-theme-sync prefers a clean `pkill -x waybar` (supervisor
  restart with fresh config+style) over the crash-prone in-place
  SIGUSR2 reload when the supervisor is running; SIGUSR2 stays as the
  unsupervised fallback.
- sys-rebuild: snapshot-first system rebuild against the CURRENT lock
  (no `nix flake update`) — the no-update twin of sys-update;
  README §3/§5 + motd list it.

Verified: V0 re-run green after crash recovery (flake check --no-build
+ py_compile); V1/V2 per the crashed session's journal entry (HM
renders exec-once=nomarchy-waybar; headless software-GL VM:
SIGKILL -> new pid, clean kill -> respawn, SIGUSR2 -> alive).
V3 pending on the Latitude (queued in HARDWARE-QUEUE.md).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-04 18:07:05 +01:00
Bernardo Magri
f70838c5b5 feat(keyboard): multi-layout cycle bind + summer-bar language parity
All checks were successful
Check / eval (push) Successful in 2m59s
SUPER+SHIFT+K → hyprctl switchxkblayout current next, rendered only
when the session layout has a comma — the same gate as the Waybar
language indicator. Bind data is a separate multiLayoutBinds list in
keybinds.nix (still one source); hyprland.nix and the rofi cheatsheet
gate on the identical condition, so bind and cheatsheet cannot drift.
`current` targets the focused keyboard, so per-device-overridden
boards (single layout) are a no-op.

Parity: hyprland/language (with the keyboard glyph) added to both
summer waybar.jsonc whole-swaps + #language in their CSS. Static JSON
cannot eval-gate, so on summer themes the module shows even with one
layout — parity-over-minimalism, deliberate. Found en route:
summer-night has an idle_inhibitor the generated bar lacks (reverse
gap) — folded into backlog item 13.

Also — agent/BACKLOG.md gains items 21-24 from Bernardo's Latitude
5410 hardware QA (waybar crash on theme switch + no respawn; nameless
network-menu rows; a no-update sys-rebuild; Back-everywhere audit),
queued ahead of everything; HARDWARE-QUEUE's snapshot re-test now
carries the "update to >= a47aa3a + relogin" precondition, since
findings 5/6 match the pre-polkit-agent behavior exactly.

Verified: V0 (flake check, both summer JSONs parse) + V1 both
directions (default single-layout build renders no bind; us,de via
extendModules renders bind AND cheatsheet row). V3 cycle check queued.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-04 14:11:12 +01:00
Bernardo Magri
1e4427f6af docs(webcam): tuning tools in the app-suite template + libcamera gap filed
All checks were successful
Check / eval (push) Successful in 3m2s
- templates/downstream/home.nix: commented "Webcam tuning" entries
  (cameractrls, v4l-utils) — bare packages, so template not toggle,
  per the option-surface rule; rarely needed since the IR-hide covers
  the common dual-sensor case.
- agent/BACKLOG.md PROPOSED: the portal/Flatpak camera picker still
  lists the internal IR sensor (libcamera path, untouched by the v4l2
  rule) — three options laid out, recommending document-only until a
  T14s-style machine is available to test a libcamera-side rule.

Closes BACKLOG NOW#7. Verified: V0 (flake check green; template still
evaluates through mkFlake).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-04 10:13:50 +01:00
Bernardo Magri
a47aa3aff5 fix(snapshots): un-break the GUI — polkit agent + launcher, not an override
All checks were successful
Check / eval (push) Successful in 3m4s
Re-diagnosis of the "btrfs-assistant 2.2 segfaults on launch" bug, and
the fix is not the planned nixpkgs override:

1. gdb: the crash is 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).
   Symbol versions were checked and match: not ABI/link drift.
2. VM A/B on real btrfs: as root it works (exit 0); as a user it
   segfaults (139). The pkexec launcher runs it as root — so the GUI
   path was fine all along, except…
3. …the distro shipped NO polkit authentication agent: every pkexec
   prompt in the session failed silently. That is the actual root
   cause of "the GUI doesn't open".

Shipped: hyprpolkitagent (Hyprland's Qt agent — the prompt is
Stylix-themed) via exec-once in hyprland.nix; `nomarchy-menu snapshot`
prefers btrfs-assistant-launcher again, with the nomarchy-snapshots
fzf flow kept as fallback (and for SSH); checks.snapshot-gui guards
the root path + offscreen GUI event loop on a real btrfs volume so a
lock bump can't silently regress it. No btrfs-progs patch: all our
flows are root-side; the unprivileged fix arrives with a lock bump.

Verified: V0 (flake check) + V2 (snapshot-gui check, executed green) +
V1 (HM generation builds; hyprland.conf carries the agent exec-once;
generated menu passes bash -n with the launcher wiring). Remaining V3
queued: first on-hardware themed polkit prompt.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-04 10:07:40 +01:00
Bernardo Magri
baab2d3b88 docs(agent): CI green (run #58) — item 20 reduced to the KVM-runner stretch
All checks were successful
Check / eval (push) Successful in 2m52s
First successful run of .gitea/workflows/check.yml on the live
act_runner: the eval tier is confirmed end to end. What remains of
item 20 is optional infra ([human]): a KVM/nix host-mode runner to
unlock the commented vm-checks job.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-04 09:46:38 +01:00
Bernardo Magri
5ea4f0c9ac feat(stability): default-on memory-pressure protection (earlyoom)
Some checks failed
Check / eval (push) Has been cancelled
modules/nixos/oom.nix — running out of memory now kills the offending
process (with a desktop notification via systembus-notify) instead of
freezing the desktop for minutes until the kernel OOM killer fires.

earlyoom over systemd-oomd, deliberately: oomd kills whole cgroups,
and a Hyprland session runs as ONE scope (nothing here spawns per-app
systemd scopes, unlike GNOME) — under pressure oomd would take out the
entire desktop to save it. earlyoom kills the single largest process
before the thrash point. nixpkgs default-enables oomd in an inert
state (no slices monitored); it is disabled outright so there is one
owner. Session plumbing is --avoid-listed (unanchored — NixOS wrappers
rename comm to .foo-wrapped); no --prefer tuning, largest-RSS
selection already finds the hog. All mkDefault; opt out with
services.earlyoom.enable = false (README note added).

Verified: V0 (flake check) + V2 — new checks.oom-protection
runNixOSTest, executed: a chunked allocator (686 MB peak, 1 GB VM) is
SIGTERM'd by earlyoom in 0.1 s, a bystander unit survives (the
process-level granularity the module exists for), and systemd-oomd is
asserted inactive. 25 s runtime.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-04 09:45:21 +01:00
Bernardo Magri
938753273d fix(ci): move workflow to .gitea/workflows — the server is Gitea, not Forgejo
All checks were successful
Check / eval (push) Successful in 2m50s
The runner was alive all along (gitea/act_runner, ubuntu-latest label);
no run ever appeared because Gitea only reads .gitea/workflows/ (or
.github/workflows/) and ignores the .forgejo/ path the workflow shipped
under. The legacy repo's .gitea/workflows/check.yml was the clue.
References corrected (workflow header, TESTING.md, agent files); this
push doubles as the first live trigger of the workflow.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-04 09:42:15 +01:00
Bernardo Magri
46af2f0632 feat(ci): checks-on-push workflow (Forgejo Actions, eval tier)
.forgejo/workflows/check.yml runs on every push to main/v1 (+ manual
dispatch): nix flake check --no-build (full module-system eval incl.
the downstream template through mkFlake), py_compile of
nomarchy-theme-sync, and bash -n over tracked .sh files. The always-on
net under direct-to-main pushes — first slice of the ROADMAP lock-bump
CI item.

Scoped to the eval tier deliberately: the instance's runner is an
act_runner docker container (no systemd, no /dev/kvm — established
from the legacy repo's .gitea/workflows/check.yml, which ran 57 times
on it), so the checks.* VM suite and real builds can't run there. A
commented vm-checks job documents the KVM-runner upgrade path; the
legacy workflow's container gotchas (nixbld setup for the single-user
installer, sandbox=false for Stylix IFD, Nix pinned 2.31.5 vs
lazy-trees, no JS actions past node20) are carried over verbatim in
the header. docs/TESTING.md §1b documents what a green run does and
does not mean.

Verified: V0 locally (the same check commands, minus the container
Nix install) + YAML parse. A real green run depends on the runner
still being registered — not API-visible unauthenticated, so that is
queued as [human] BACKLOG item 20.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-04 09:20:45 +01:00
Bernardo Magri
bc4e8e1410 feat(state): opt-in auto-commit of state mutations (Phase 4)
settings.autoCommit — toggled from System › Auto-commit (self-gated on
the flake being a git repo) — makes every nomarchy-theme-sync apply/set
also git-commit theme-state.json in the downstream flake, so settings
history is `git log`. Off by default.

Design: the flag is live-read by the tool on each write (nothing in Nix
consumes it → instant toggle, no rebuild, no new option); the commit is
pathspec-limited to theme-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; same-value writes no-op
(diff against HEAD); missing git identity falls back to
Nomarchy <nomarchy@localhost>; `bg next` is deliberately excluded.

Rider fix: `get` now prints booleans JSON-style (true, not Python's
True) — un-sticking the System menu's "Auto timezone (on/off)" label,
whose `= true` comparison could never match. All shell consumers
already normalise true|True, so no other behavior change.

Verified: V0 (py_compile, nix flake check) + V1 (HM generation builds,
generated nomarchy-menu passes bash -n, wiring present in the rendered
script) + a 7-assertion sandbox-repo round-trip covering enable/set/
same-value/disable/post-disable/apply paths and the identity fallback.
Remaining: on-machine check queued in agent/HARDWARE-QUEUE.md.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-04 09:12:29 +01:00
Bernardo Magri
4c2ad38656 docs(agent): backlog revision — fill the workstation gaps
Checked guesses against the tree before adding (GC/auto-optimise/boot
limit and all 21 theme previews already ship — not added). New items,
all confirmed absent:

- NOW#2 CI checks-on-push, promoted because agents push main directly
- NOW#3 memory-pressure protection (systemd-oomd/earlyoom; nothing
  guards the session from OOM today and the release bump died to one)
- NEXT#8 PDF/image viewers + xdg-mime defaults (template ships only mpv)
- NEXT#9 update diff (nvd) + menu-driven rollback
- NEXT#10 nomarchy-doctor health check
- NEXT#11 state-file validation + friendly eval errors
- NEXT#12 screen recording in Capture (+ stop via Waybar indicator)
- NEXT#13 niceties: idle-inhibit toggle, low-battery notify, hyprpicker
- LATER: OCR screenshot-to-text
- Decisions: zram-vs-hibernation, default browser for mime defaults
- docs item now includes a recovery runbook

Verified: V0 — docs-only.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-04 09:00:57 +01:00
Bernardo Magri
6d70bba8e6 feat(agent): autonomous-loop infrastructure + roadmap rework
A git-tracked agent/ directory so AI agents can iterate on the distro
unattended (runner-agnostic: /loop, headless claude -p, or a fresh
manual session — all state lives in the checkout, per the distro's own
philosophy):

- LOOP.md — the iteration protocol: orient → pick one BACKLOG task →
  verify up the V0–V3 ladder → commit+push main → record. Safety rails
  (v1 untouchable, no force-push, no surprise lock bumps) and
  stop-and-escalate conditions.
- BACKLOG.md — the forward half of docs/ROADMAP.md reworked into a
  prioritized queue (5 NOW / 6 NEXT / LATER / PROPOSED / Decisions);
  ROADMAP.md stays the design/decision record + shipped log.
- GOALS.md — the four pillars (stable > reproducible/zero-hidden-state >
  effortless config > beautiful), quality bars, non-goals.
- CONVENTIONS.md — coding/design rules (in-flake state, menu placement,
  Waybar whole-swap parity, toggle-vs-package, no formatter).
- MEMORY.md — curated hard-won lessons (VM recipes, btrfs-assistant
  segfault watch, rofi/WirePlumber/hyprlock gotchas).
- HARDWARE-QUEUE.md — every pending V3 on-hardware check collected from
  the ROADMAP, with exact steps, split by machine.
- JOURNAL.md — append-only iteration log, seeded with this bootstrap.

Plus a root CLAUDE.md entry point and README/ROADMAP pointers.

Verified: V0 — docs-only; nix flake check --no-build green.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-04 08:51:11 +01:00
Bernardo Magri
9726ba3b2f update flake lock 2026-06-30 23:10:09 +01:00
Bernardo Magri
4024da791f feat(display): menu-driven per-output resolution, in-flake
Phase 3 of the in-flake-state philosophy (mirroring night-light + the
keyboard-layout watcher): a System ▸ Display menu item picks an output's
resolution and persists it into the flake instead of a runtime-only tweak.

- rofi.nix: the `display` case reads `hyprctl monitors -j` (auto-skips the
  chooser with one output), lists the output's advertised modes (rounded
  refresh, deduped, highest-first) plus the preferred/highres/highrr
  tokens, applies the pick INSTANTLY via `hyprctl keyword monitor` —
  keeping the output's current position + scale, so only the resolution
  changes — then persists it with `nomarchy-theme-sync set
  settings.monitors.<name> <mode> --no-switch` (no rebuild).
- theme.nix: seed `settings.monitors = {}` (output-name -> "WxH@R") so a
  sparse/older state file still evaluates; exposed via nomarchy.settings.
- hyprland.nix: `resolvedMonitors` overlays settings.monitors onto
  nomarchy.monitors by name — a declared output keeps its other fields
  with only the resolution swapped, an undeclared output (wildcard-only,
  e.g. the laptop panel) becomes a fresh rule. So a later rebuild bakes
  the menu pick into the generated monitor rule — the monitor twin of the
  keyboard graduation. The menu pick wins over a hand-set resolution (it's
  the explicit live action).

Monitor connector names are dot-free, so a direct dotted set-path is safe
(no whole-map read-modify-write like the keyboard watcher needs).

VM-verified via a headless-Hyprland nixosTest (run + removed; too heavy
for a CI gate): the graduation baked the rule into the generated
hyprland.conf, the set/get round-trip persisted, and `hyprctl keyword
monitor` actually changed the reported resolution. Only the interactive
rofi picker isn't headlessly driveable (same caveat as the keyboard watcher).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-30 23:08:39 +01:00
Bernardo Magri
aac678335c feat(branding): GRUB (UEFI) theme + distroId=nomarchy
Completes the remaining "Distro branding, round 2" work.

GRUB theme: hosts/live.nix builds nomarchyGrubTheme whose background is
the same composed splash as the isolinux/BIOS splash (accent logo on
base), with a palette-coloured boot menu and accent timeout bar. Derived
from nixos-grub2-theme only to reuse its bundled DejaVu .pf2; the stock
NixOS logo is dropped (ours is in the background). Wired as
isoImage.grubTheme so UEFI boot matches BIOS.

distroId: set system.nixos.distroId = "nomarchy" so os-release is honest
(ID=nomarchy, ID_LIKE=nixos). Safe — switch-to-configuration builds its
"is this NixOS?" guard from the configured distroId (and /etc/NIXOS is
the fallback), so rebuilds keep working. isNixos→false blanks the
nixos.org URLs, so extraOSReleaseArgs restores them to the project.

Adds checks.distro-id (runNixOSTest): boots distroId=nomarchy and runs
switch-to-configuration dry-activate green; os-release eval-verified from
the real downstream config; grub theme built + structure-verified.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-30 22:02:27 +01:00
Bernardo Magri
7d6d74fd7f test(power): VM check for battery charge-limit udev re-apply
Add checks.battery-charge-limit (runNixOSTest): the test_power module
fakes a Mains adapter, toggling ac_online emits a real power_supply
uevent, and the test asserts the udev rule restarts the oneshot (changed
InvocationID). Verifies the re-apply *trigger* at runtime, beyond the
earlier eval check; the sysfs write still needs real hardware. Also
records the SSH_AUTH_SOCK rendered-artifact verification in the roadmap.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-30 21:41:24 +01:00
Bernardo Magri
cdd1897b14 feat(keys): session-level SSH_AUTH_SOCK for GUI clients
The agent's SSH socket was only exported via the zsh integration, so a
GUI client launched outside a shell (rofi launcher, autostarted apps)
couldn't use gpg-agent for SSH. Add a home.sessionVariables export
resolving the socket with gpgconf --list-dirs agent-ssh-socket — the
same lookup the shell integration uses, so the two can't drift — which
reaches GUI apps via the login shell that starts Hyprland (the path
NIXOS_OZONE_WL already takes).

Eval-verified: the var resolves to the gpgconf substitution; the home
config still builds.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-30 21:27:07 +01:00
Bernardo Magri
9976ea06f5 feat(power): re-apply battery charge limit on AC state change
The threshold was written boot-only, so a firmware that clears
charge_control_end_threshold on unplug would silently lose the cap
until the next reboot. Add a services.udev rule on the mains adapter
(ATTR{type}=="Mains", vendor-neutral) that restarts the oneshot via
systemctl --no-block on every AC state change. Uses restart (not
try-restart) so it re-applies even if the boot run was inactive.

Eval-verified: rule present when the charge limit is on, absent when
off; downstream-template-system still evaluates.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-30 21:10:44 +01:00
Bernardo Magri
cdfe92a089 feat(hardware): hide dual-sensor webcam IR node (fixes dark image)
A built-in RGB+IR webcam (common on recent ThinkPads, e.g. the T14s AMD
Gen 4) exposes its IR face-unlock sensor as a second, identically-named
"Integrated Camera"; apps that pick it show a dark, 8-bit-greyscale
image. nomarchy.hardware.camera.hideIrSensor disables that node on the
V4L2 PipeWire path (WirePlumber monitor.v4l2.rules) so only the colour
camera is offered, matched by card name (overridable irMatch).

- libcamera is left untouched: an external camera you plug in is never
  affected, and surgical internal-only libcamera scoping isn't possible
  (its distinguishing props bind after the monitor rule runs).
- Only the PipeWire node is hidden; the kernel /dev/video* stays open,
  so Howdy face-unlock still reads the IR sensor directly.
- 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; commented example added to the downstream template.

Validated live on a T14s: collapses the four enumerated "Integrated
Camera" entries to one colour source; the exact shipped regex disables
the IR node (confirmed in the WirePlumber log) and leaves libcamera
intact.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-27 00:39:49 +01:00
Bernardo Magri
431af618cc feat(tailscale): operator-set login user — VPN menu skips the sudo terminal
nomarchy.services.tailscale now makes the login user the Tailscale
operator (services.tailscale.extraSetFlags = ["--operator=<username>"],
mkDefault), so `tailscale up/down/set` — and the System → VPN menu's
Tailscale controls — run without sudo. The user is already in wheel, so
the operator grant is no real new privilege, just skips the prompt.

nomarchy-vpn's Tailscale actions are now operator-aware: a ts_priv
helper runs the subcommand inline and only falls back to a sudo terminal
if the operator grant is absent (downstream dropped it). Connect brings
an authed-but-down node up inline; a node that still needs interactive
login goes to a terminal so the auth URL is visible.

Option description + template comment updated; ROADMAP follow-up closed.
System + home builds green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-23 11:26:15 +01:00
Bernardo Magri
47526ae6e2 feat(menu): VPN setup & management submenu (WireGuard/OpenVPN/Tailscale)
A dedicated System → VPN flow (nomarchy-vpn) past the wifi picker:
- NetworkManager VPN/WireGuard connections shown ● active / ○ inactive,
  toggled up/down on select (networkmanager-group users need no sudo).
- Import a *.conf / *.ovpn via the fd picker → nmcli connection import,
  type chosen by extension. networkmanager-openvpn ships system-side
  (networking.networkmanager.plugins, mkDefault) so the openvpn type is
  available; WireGuard is native to NetworkManager.
- Tailscale block, self-gated on the CLI (= nomarchy.services.tailscale):
  read-only status + up/down + exit-node; privileged actions go through a
  sudo terminal (the snapshot pattern), no operator wiring.

Waybar custom/vpn shield (nomarchy-vpn-status): self-hides unless a NM
tunnel or Tailscale is up; @good tone; click opens the submenu. Wired
into the generated bar and both summer whole-swaps. Decided import-first;
creation deferred to nm-connection-editor. ROADMAP marked shipped.

Eval + home/system builds green; both scripts pass bash -n. Pending an
on-machine check (live nmcli import/connect + Tailscale paths).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-23 11:13:03 +01:00
Bernardo Magri
86802f244e docs(roadmap): system VPN setup & management menu (WireGuard/OpenVPN/Tailscale)
A forward-looking item for a dedicated System → VPN flow: import
WireGuard .conf / OpenVPN .ovpn into NetworkManager and toggle them,
plus Tailscale up/down + exit-node (self-gated on nomarchy.services.
tailscale). Notes the plugin dep (networkmanager-openvpn), the Files
picker + Waybar-parity patterns, secrets staying in the connection
manager, and the import-first-vs-creation open question.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-23 10:35:11 +01:00
Bernardo Magri
f3325385c1 feat(timezone): opt-in automatic, location-following timezone
nomarchy.system.autoTimezone (off by default): geoclue + 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 theme-state.json (git-tracked); a
System-menu entry toggles it. Because it's a SYSTEM service — not a user
unit it can start/stop instantly like night-light — the toggle
(nomarchy-autotimezone, run in a terminal) writes the flag --no-switch
then drives `sudo nixos-rebuild` (bakes the service + the time.timeZone
override) plus `home-manager switch` (the Waybar-refresh watcher); both
sides read the one flag.

- modules/nixos/timezone.nix: parses settings.autoTimezone from
  nomarchy.system.stateFile (like Plymouth), gates geoclue2 +
  automatic-timezoned, and mkForce-nulls time.timeZone — automatic-
  timezoned sets it null at normal priority, which collides with the
  installer's static value (a hard eval error), so force wins and
  reverts cleanly when disabled. Ships the nomarchy-autotimezone toggle.
- modules/home/timezone.nix: a tiny user service watching timedate1's
  change signal that reloads Waybar (SIGUSR2) on a real zone change, so
  the clock follows live (Waybar captures the zone at construction).
  Gated on the same flag via nomarchy.settings.autoTimezone.
- rofi menu: "Auto timezone (on/off)" in the System submenu.
- option + template example + ROADMAP entry; theme.nix seeds the flag.

Eval-verified on (geoclue+daemon on, tz forced null over a static
installer value, watcher present) and off (static tz intact, nothing
extra); home + system both build green. Needs an on-hardware check
(geoclue detection + the SIGUSR2 clock refresh aren't CI-testable).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-23 10:34:45 +01:00
Bernardo Magri
aed41793f8 feat(keyboard): per-device layout memory in-flake (no ~/.local/state)
Phase 2 of the in-flake-state philosophy, mirroring night-light: the
nomarchy-keyboard-watch daemon now remembers per-device layouts in the
git-tracked state file (settings.keyboard.devices) instead of
~/.local/state/nomarchy/keyboard-layouts.

- Reads the LIVE working-tree map (theme-sync get → jq) so a pick this
  session is honoured at once; writes instantly with --no-switch (no
  rebuild). Writes the whole map back at the dot-free parent path
  (read-modify-write) so a device name with a dot can't corrupt the
  tool's dotted set-path (Hyprland only sanitises spaces→-).
- Graduation: nomarchy.keyboard.devices now mkDefault-merges entries
  from settings.keyboard.devices, so each remembered device becomes a
  generated Hyprland device{} block on the next rebuild (reproducible,
  hotplug-applied). A hand-written keyboard.devices.<name> still wins;
  once baked, the watcher's is_declared check makes it step back.
- theme.nix seeds settings.keyboard.devices = {} (sparse state still
  evaluates); options/template descriptions updated; ROADMAP marked done.

Eval-verified (graduation + mkDefault precedence) and the set/get
writer round-trips. Still needs the on-hardware hotplug re-verify (the
picker path isn't testable in CI).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-23 09:18:33 +01:00
Bernardo Magri
685126ab47 feat(nightlight): menu-driven, in-flake on/off (no ~/.local/state)
Night light is off by default and configured entirely through the menu,
writing into the downstream flake instead of ~/.local/state — so the
choice is git-tracked and reproducible across a clone+rebuild.

Two flags under a new settings.* section of the state file (exposed as
the nomarchy.settings option):
- settings.nightlight.installed — sticky, gates the hyprsunset unit;
  nomarchy.nightlight.enable mkDefault-reads it. First enable from the
  menu rebuilds to create the unit (the one accepted rebuild).
- settings.nightlight.on — runtime on/off, toggled instantly
  (theme-sync set --no-switch + systemctl), no rebuild. An ExecCondition
  reads the live flag at session start so an off survives reboot;
  splitting it from the sticky flag means a later unrelated rebuild
  never undoes an instant-off (no decay).

Drops the ~/.local/state/nomarchy/nightlight-off marker and the
ConditionPathExists hack. theme-sync set now skips the wallpaper
re-apply for non-wallpaper keys, and its rebuild notifications are
generic ("changes" not "theme").

First cut of the broader principle — any user-settable config gets a
menu writer that lands it in the flake; no state outside the checkout.
Docs (README §4, ROADMAP, template) updated to reflect it.

Eval- and round-trip-verified; on-hardware check pending.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-21 20:24:58 +01:00
Bernardo Magri
c8d0b09044 docs(readme): label which file each nomarchy.* option goes in
Split the options reference into separate home.nix / system.nix tables
and note the prefix rule (system/hardware/services → system.nix, the
rest → home.nix).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-21 16:42:03 +01:00
Bernardo Magri
c2f90c7d0a feat(menu): printer setup — system-config-printer in the System submenu
A `nomarchy-menu printers` entry opens system-config-printer (the CUPS
admin GUI), mirroring the bluetooth/blueman pattern: the printing service
ships the package via 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 keybind (rare one-off).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-20 20:09:35 +01:00
Bernardo Magri
5747dc9839 feat(nightlight): persistent off via state marker
A manual `off` now drops ~/.local/state/nomarchy/nightlight-off and the
hyprsunset unit refuses to (re)start while it exists
(Unit.ConditionPathExists=!%h/...), so the off survives logout/reboot
until toggled back on. `on`/toggle-on remove the marker before starting
(a failed condition would otherwise no-op the start). Without the marker
behaviour is unchanged: the service stays WantedBy=graphical-session.target
and resumes its schedule at login, so persistence is opt-in via the toggle.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-20 20:09:22 +01:00
Bernardo Magri
d1344712b8 docs(roadmap): note automated upstream lock-bump CI
Capture the maintainer-side plan to automate advancing Nomarchy's own
flake.lock (the single-input delivery channel): a bot drives `main`
behind `nix flake check` + the runNixOSTest suite, and a human still
promotes `main -> v1` behind the on-hardware QA gate that can't be CI'd.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-20 19:45:56 +01:00
Bernardo Magri
37204f5f45 feat(themes): per-theme rofi identities for nord/retro-82/lumon/kanagawa
Author a designed rofi.rasi whole-swap for the four remaining identity
themes, so all six now carry a distinct menu look (summer-day/night
already shipped their legacy ports). Each is built from the theme's
character rather than a legacy layout (their waybar overrides are
palette-only, so there was nothing to port):

- nord:     soft rounded "frost panel" — frost border, brighter-frost
            selection, aurora-purple prompt
- retro-82: sharp CRT terminal — square corners, amber-on-navy, a teal
            scanline underline, mono
- lumon:    clinical cyan "bezel" — thick cyan frame, a framed-not-filled
            readout inputbar, mono
- kanagawa: ink & paper — warm washi-paper frame, deep ink-well inputbar,
            crystal-blue wave selection

Each file is self-contained (it replaces the generated theme) and keeps
the element structure the theme-grid picker's per-invocation -theme-str
layers onto. rofi.nix needs no change — its existing themes/<slug>/
rofi.rasi probe whole-swaps when the file is present. All four parse
clean under `rofi -dump-theme`.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-20 19:45:37 +01:00
97b5944dc1 fix(menu): force-own networkmanager-dmenu config.ini
networkmanager_dmenu writes a default config on first run, so an
unmanaged file can already sit at ~/.config/networkmanager-dmenu/
config.ini and abort `home-manager switch` with a clobber error. We
fully generate this file, so set force = true to overwrite it.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-20 18:27:35 +01:00
0c483f9512 feat(menu): network + audio modules; case-insensitive fuzzy search
- network: replace the nmtui-in-a-terminal flow with networkmanager_dmenu
  (native rofi wifi/VPN picker), configured via xdg config.ini to drive
  rofi with rofi_highlight; editing a connection drops to nmtui.
- audio: new System -> Audio sink/source switcher via rofi-pulse-select,
  self-gated on the PipeWire/pulse socket.
- search: case-insensitive fuzzy matching with fzf sorting across every
  menu module and the launcher (so "system" finds "System").

Secrets module (rbw/pass) deferred — no Bitwarden/pass setup here.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-20 18:23:21 +01:00
dfb57c2e34 feat(home): create XDG user directories by default
Enable xdg.userDirs with createDirectories so a fresh install lands with
Downloads/Documents/Pictures/Music/Videos/Desktop/Public/Templates (plus
home-manager's Projects) present and written to user-dirs.dirs, rather
than appearing only on first app use. mkDefault so a downstream home.nix
can flip it off or remap individual paths.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-20 18:23:21 +01:00
995810927d fix(install): pin HOME=/root so root nix never strays into /home/nomarchy
`exec sudo --preserve-env` (needed to carry the NOMARCHY_* vars) also
drags the live session user's HOME=/home/nomarchy into the root install
process. Root-run `nix build` then writes an eval cache + .nix-defexpr
there, and the in-chroot one lands on the TARGET as a stray, orphaned
/home/nomarchy (no such user on the installed system). Pin root's own
HOME after the sudo re-exec, and again inside the nixos-enter chroot
script. The user activation already sets HOME=/home/$USERNAME and is
unaffected.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-20 18:23:21 +01:00
e1cf190dd2 feat(menu): visual theme picker — rofi preview grid
Replace the plain-text `nomarchy-menu theme` list with a grid of real
desktop previews. The grid, the Name→slug map and the active `✓` mark
are generated at eval time from the preset JSONs (`readDir` + `fromJSON`
in rofi.nix); "active" is just `t.slug`, since every switch rebuilds the
menu. Grouped dark-first then light in one scrollable grid (the previews
make the mode obvious — no light/dark submenu split), `flow: horizontal`
so Down scrolls row-by-row.

Previews are committed as themes/<slug>/preview.png at 480×270 (~2.4 MB
for all 21). rofi's element-icon `size` is single-value → a *square*
cell that *contains* the icon, so a 16:9 preview would letterbox; a
build-time imagemagick step centre-crops each to a square so it fills
the cell edge-to-edge (source stays 16:9, crop is reversible). The
window width is derived from `themeGridIconW` so a column is exactly the
icon side — no slack margins. Themes without a preview degrade to a
plain-name row.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-20 17:26:39 +01:00
Bernardo Magri
70334e68bb docs(roadmap): record the faster-switches diagnosis + decided approach
Flesh out the "Faster switches" item with the confirmed diagnosis (themes/
is 86 MB and backgrounds/ is all of it; the config is ~208 KB; wallpapers
are never read at eval; theme-state.json is git-tracked so every apply
re-copies the whole 86 MB source) and the decided-but-deferred approach:
Option 1, a separate pinned wallpapers artifact (repo/tarball via flake
input/fetchurl, read through the existing NOMARCHY_DEFAULT_THEMES hook),
which keeps eval pure. Notes the rejected impure-state alternative and the
pre-built-theme-variants follow-on.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-19 21:13:08 +01:00
Bernardo Magri
6a4af69b0f feat(updates): passive update-awareness indicator + notification
Add opt-in nomarchy.updates (modules/home/updates.nix): a nomarchy-updates
checker on a systemd user timer (interval, default daily) that surfaces a
Waybar indicator + a notification when updates are available, without ever
changing anything (you still run sys-update / flatpak update).

It counts:
- flake inputs behind upstream — only the flake's DIRECT inputs (root.inputs:
  nixpkgs, the Nomarchy input, home-manager, stylix…), not the transitive
  closure, via `git ls-remote` vs the locked rev. Offline → skipped, never a
  false alarm.
- Flatpak updates, when the flatpak CLI is present (.flatpak, gated on
  services.flatpak being on) — Bernardo's suggestion.

Waybar custom/updates self-gates (hidden until the timer finds something;
accent "󰚰 N", signal 9 for instant refresh), in the generated bar and both
summer whole-swaps. The notification fires only when the count grows, so a
daily timer doesn't nag. Click → the upgrade flow in a terminal (sys-update /
flatpak update, each confirmed). nomarchy-updates ships always on PATH and
self-gates so the static whole-swap bars can exec it even when the feature
is off.

Verified: flake check clean; home generation builds; the jq direct-input
filter + status self-gate exercised against the real flake.lock; script
passes bash -n. Pending an on-machine check (ls-remote/notify/timer need a
live session).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-19 20:59:09 +01:00
Bernardo Magri
d9466d6555 docs(roadmap): plan the visual theme picker (preview thumbnails)
Add the agreed approach for prettifying nomarchy-menu theme into a rofi icon
grid of real themed-desktop previews: Bernardo captures previews on real
hardware and commits themes/<slug>/preview.png; a build-time derivation
downscales each to a thumbnail that the picker loads as the rofi element icon
(rofi already renders image files — same as the Papirus menu icons), with a
wallpaper/swatch/plain-name fallback while previews are added incrementally.

Notes the headless VM-render route was prototyped and works (runNixOSTest +
software-GL Hyprland + machine.screenshot QMP dump) but real-hardware capture
was chosen for fidelity; the working VM recipe is parked in memory. The
temporary flake checks.theme-shot spike is removed (too heavy for a CI gate).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-19 20:36:01 +01:00
Bernardo Magri
0e42763aea feat(menu): Back on every submenu + working snapshot fallback
Two menu improvements.

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 no longer have to Esc out and
reopen to back up. 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.

Snapshot fallback: btrfs-assistant 2.2 segfaults on 26.05 (libbtrfsutil
ABI), so `nomarchy-menu snapshot` now launches nomarchy-snapshots instead —
a keyboard-driven snapper browser/restore. snapper is root-only here 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 — safer than a one-click rofi rollback.
btrfs-assistant stays installed for when nixpkgs fixes it.

Verified: flake check clean; home generation builds and the menu passes
bash -n with all Back + snapshot wiring; nomarchy-snapshots passes
shellcheck (writeShellApplication) in a snapper-enabled system build.
Pending an on-machine check of the restore/rollback paths.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-19 19:54:42 +01:00
Bernardo Magri
a6d6860054 docs(roadmap): note night-light toggle is session-scoped; persistent-off follow-up
The menu/Waybar toggle stops hyprsunset for the session only — it's
WantedBy=graphical-session.target, so the next login auto-starts it and the
off-state doesn't persist (deliberate "true colours for now" behaviour).
Record the persistent-off option (a state marker honoured via the unit's
ConditionPathExists) as an optional follow-up so it isn't forgotten.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-18 22:29:54 +01:00
Bernardo Magri
5c43a93285 feat(nightlight): menu + Waybar on/off toggle
Add a runtime toggle for the scheduled blue-light filter, the remaining
optional item on the night-light roadmap entry.

nomarchy-nightlight (nightlight.nix, always on PATH) starts/stops the
hyprsunset *service* rather than poking its runtime gamma: `systemctl
--user is-active` is the single source of truth, the screen restores to
true colours when stopped, and it lets you force the filter off for
colour-sensitive work (even at night) and back on without touching config.
It self-gates — `status` prints nothing when the unit is absent — so the
indicator hides itself when night-light isn't enabled.

- Menu: `nomarchy-menu nightlight` toggles it; a self-gated row in the
  System submenu (shown only when the hyprsunset unit exists).
- Waybar: a self-gating `custom/nightlight` indicator (moon = on, sun =
  off), in the generated bar and both summer whole-swap themes for parity.

Verified: home generation builds, generated waybar config carries
custom/nightlight, the menu has the dispatcher case + System row, both
scripts pass bash -n, and status self-gates to empty with no unit. Pending
an on-machine check that stopping hyprsunset cleanly restores the gamma.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-18 22:23:12 +01:00
Bernardo Magri
09c308b93c chore(boot): default boot.zfs.forceImportRoot = false (silence eval warning)
Nomarchy roots are BTRFS, never ZFS, but the zfs module's `true` default for
forceImportRoot emits a warning on every eval/build ("recommended to set it
to false, the new default from 26.11"). Adopt that default early via
mkDefault — silences the noise, future-proofs for 26.11, and a real ZFS-root
downstream can still force it back on.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-18 22:10:28 +01:00
Bernardo Magri
bdf20f2d8e feat(hardware): xe-aware GuC, class-based NPU detect, latestKernel + VM test
Harden the hardware-enablement broad seed for newer hardware:

- xe vs i915: intel.guc emits the i915 param (i915.enable_guc=3), which the
  newer `xe` driver (Lunar Lake / Battlemage / Panther Lake, recent Xe GPUs)
  ignores — it enables GuC by default. hardware-db.sh now reads the in-use
  GPU driver (lspci -k) and writes `intel.guc = false` on xe hardware, so the
  toggle isn't a misleading no-op there.

- NPU detection by PCI accelerator class [1200] (+ keywords), attributing the
  vendor, instead of a fixed device-ID list — so new NPUs (e.g. Panther Lake)
  are caught without a code change. Known IDs kept as a fallback/reference.

- Kernel awareness: a nomarchy.hardware.latestKernel escape hatch
  (linuxPackages_latest) for very-new hardware whose drivers only just landed,
  and a build-time warning when npu.enable predates the shipped kernel
  (amdxdna needs 6.14+, intel_vpu wants recent). Downstream default is 6.18,
  which already carries amd-pstate + amdxdna; the reference host pins latest.

- VM test (checks.hardware-toggles, pkgs.testers.runNixOSTest): boots a
  minimal VM with the toggles on and asserts the config landed — amd_pstate
  + i915.enable_guc in /proc/cmdline, fprintd.service present, pam_fprintd in
  /etc/pam.d/sudo. Passing. (Hardware behaviour still needs bare metal.)

Docs: README + template note latestKernel; ROADMAP records the hardening.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-18 20:13:31 +01:00
Bernardo Magri
c57d26864e feat(hardware): nomarchy.hardware.* enablement beyond nixos-hardware
The nixos-hardware "common-*" profiles the installer selects cover the
basics (microcode, the Intel/AMD VA-API media stack, weekly fstrim), and
power.nix adds thermald + power-profiles-daemon. This adds the gap above
them, generically and detected at install time.

New modules/nixos/hardware.nix exposes a vendor-keyed nomarchy.hardware.*
surface — broadly-beneficial bits default ON when the vendor is detected
(opt-out), heavy/experimental bits behind opt-in toggles:
  - intel.enable -> GuC/HuC (i915.enable_guc=3); intel.computeRuntime
    (opt-in: intel-compute-runtime + vpl-gpu-rt)
  - amd.enable -> amd_pstate=active + radeonsi VA-API env; amd.rocm.enable
    + amd.rocm.gfxOverride (opt-in: ROCm HIP/OpenCL)
  - fingerprint.enable -> fprintd; fingerprint.pam (opt-in: login + sudo)
  - npu.enable (opt-in/experimental) -> the in-kernel driver
    (amdxdna/intel_vpu) keyed by vendor; userspace runtime is BYO

hardware-db.sh now detects Intel/AMD, a fingerprint reader (libfprint USB
vendor IDs) and an NPU (Intel VPU / AMD XDNA PCI IDs), emitting NOMARCHY
lines the installer bakes into system.nix — safe defaults active, opt-ins
commented. Audited against the commons to avoid double-setting.

Verified: flake check green; a toggles-on build has amd_pstate=active +
i915.enable_guc=3 in kernel-params and ships fprintd.service; detection
runs correctly on the (AMD Ryzen-AI) dev machine. On-hardware verification
of the AMD/NPU/Intel-compute runtime bits is still pending.

Docs: template commented examples, README option table, ROADMAP status.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-18 19:49:39 +01:00
Bernardo Magri
019fdfc8bb feat(waybar): drop redundant network module, enlarge power-profile glyph
The network/wifi indicator duplicated nm-applet, which already sits in the
tray (services.network-manager-applet.enable), so remove it from the bar.
And the power-profile speedometer glyph rendered noticeably smaller than
the neighbouring indicators, so bump its font-size (+3pt generated; 20px
summer-day, 17px summer-night).

Applied to the generated bar (waybar.nix) and both whole-swap themes
(summer-day/night waybar.{jsonc,css}) for parity.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-18 19:13:20 +01:00
Bernardo Magri
c2281dbc61 fix(waybar): launch from exec-once, not a systemd user service
Waybar vanished after a logout→login (no reboot): the bar's systemd user
service landed in `failed`. HM's Hyprland integration cycles
hyprland-session.target on every launch (exec-once stop && start), which
re-pulls WantedBy units — including waybar.service — very early, before
Hyprland's IPC socket is ready. Waybar's hyprland/workspaces & window
modules can't connect, so it exits non-zero and is never retried. A cold
boot's extra latency hid the race, so it only bit on warm relogins. The
non-IPC session services (swaync/hypridle/hyprsunset) survive the same
cycling fine, which is why night-light kept working.

Launch waybar from Hyprland's own exec-once instead (systemd.enable =
false). exec-once entries are dispatched once the compositor is up, so the
IPC socket is ready — the same reliable pattern awww-daemon, the wallpaper
sync and the keyboard watcher already use across relogins. Since the unit
no longer exists to be restarted on a home-manager switch, nudge the
running bar to re-read its rebuilt config/style with SIGUSR2 from
nomarchy-theme-sync so theme switches still reapply the bar live.

Found on the Latitude 5410 hardware sweep (systemctl --user status showed
waybar.service loaded/failed; manual start worked).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-18 19:00:39 +01:00
Bernardo Magri
b4fe52261b fix(keyboard): isolate new-keyboard layout to its own device
The interactive new-keyboard picker applied the chosen layout with
`hyprctl switchxkblayout <device> <index>`, which flips the *shared*
layout for every keyboard without its own per-device config — so picking
a layout for an external board dragged the built-in keyboard along, and
the change stuck after unplug (it's global state, not device-bound).
Compounding it, the candidate `keyboard.layouts` pool was merged into
`input.kb_layout`, loading those layouts onto the session keyboard too.

Apply the choice as a per-device `hyprctl keyword device[<name>]:kb_layout`
instead — the runtime twin of the declarative `keyboard.devices` blocks —
so it isolates that one keyboard and never touches the built-in board.
Keep the candidate pool out of `input.kb_layout` (session keyboard carries
only `keyboard.layout`); the pool is for external boards, applied per-device.

Also put `nomarchy-keyboard-watch` on PATH when enabled (it was store-path
only via exec-once, so undiscoverable) for debugging.

Found on the Latitude 5410 hardware sweep; picker itself verified working.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-18 17:47:19 +01:00
132 changed files with 9579 additions and 385 deletions

90
.gitea/workflows/bump.yml Normal file
View File

@@ -0,0 +1,90 @@
# Nomarchy scheduled lock bump — the automated half of "rock-stable
# without rotting": once a week, update flake.lock (inputs track pinned
# release branches, so this never jumps a NixOS release — that's a
# deliberate hand-edited v2, see agent/GOALS.md), gate it, and land it
# on main only on green. `v1` promotion stays human, always.
#
# Fast lane: workflow_dispatch. For a security fix upstream, run this
# workflow manually from the Actions tab instead of waiting for Monday.
#
# Gate scope: `nix flake check --no-build` — the same eval tier as
# check.yml (this runner has no KVM; see item 20 for the VM-suite
# upgrade path). The py_compile/bash -n steps are skipped on purpose:
# they don't read flake.lock, so a lock bump cannot break them. The
# bump commit's own push then triggers check.yml as a second net.
# A green bump therefore guards evaluation, not VM behaviour — the
# checks.* suite still runs locally / at promotion time.
#
# Failure mode: a red gate fails this run visibly and pushes nothing;
# next schedule retries. A push race (someone landed on main mid-run)
# also just fails the final push — rerun or wait a week.
#
# Container recipe (nixbld users, sandbox=false, pinned Nix, plain-shell
# install) inherited from check.yml — the gotchas are documented there.
name: Lock bump
on:
schedule:
- cron: '17 5 * * 1' # Mondays 05:17 UTC
workflow_dispatch:
jobs:
bump:
runs-on: ubuntu-latest
timeout-minutes: 90
env:
NIX_CONFIG: |
experimental-features = nix-command flakes
sandbox = false
NIX_SSL_CERT_FILE: /etc/ssl/certs/ca-certificates.crt
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Install Nix
run: |
NIX_VERSION=2.31.5
groupadd -r nixbld 2>/dev/null || true
for i in $(seq 1 10); do
useradd -r -g nixbld -G nixbld -d /var/empty \
-s /usr/sbin/nologin -c "Nix build user $i" "nixbld$i" 2>/dev/null || true
done
curl -L "https://releases.nixos.org/nix/nix-${NIX_VERSION}/install" | sh -s -- --no-daemon
echo "$HOME/.nix-profile/bin" >> "$GITHUB_PATH"
- name: Update flake.lock
# Inputs track release branches (nixos-26.05, release-26.05), so
# the update stays within them by construction. The human-readable
# change list goes into the commit body below.
run: |
nix flake update 2>&1 | tee /tmp/bump-log
if git diff --quiet flake.lock; then
echo "Lock already up to date — nothing to do."
echo "changed=0" >> "$GITHUB_OUTPUT"
else
echo "changed=1" >> "$GITHUB_OUTPUT"
fi
id: update
- name: Gate — nix flake check (eval only)
if: steps.update.outputs.changed == '1'
run: nix flake check --no-build
- name: Commit + push on green
if: steps.update.outputs.changed == '1'
# The checkout step persists the Actions token, so this push
# authenticates as the workflow; its push triggers check.yml as
# the second net. Only flake.lock is committed — pathspec-limited,
# nothing else can ride along.
run: |
{
echo "chore(lock): scheduled upstream bump"
echo
grep -E '^(• | )' /tmp/bump-log || true
echo
echo "Gate: nix flake check --no-build (eval tier — see bump.yml header)."
} > /tmp/bump-msg
git -c user.name="nomarchy-bump" -c user.email="actions@git.bemagri.xyz" \
commit --only flake.lock -F /tmp/bump-msg
git push origin HEAD:main

101
.gitea/workflows/check.yml Normal file
View File

@@ -0,0 +1,101 @@
# Nomarchy CI — the always-on net under direct-to-main pushes.
#
# Scope (deliberate): the EVAL tier only. The runner behind this Gitea
# instance is act_runner + docker containers (the legacy repo's check.yml
# ran 57 times on it) — no systemd, no /dev/kvm — so the checks.* VM
# tests and full toplevel builds can't run here. `nix flake check
# --no-build` still catches most breakage (type errors, missing options,
# bad merges, template/mkFlake drift — see docs/TESTING.md §1); the VM
# suite stays a local/promotion gate until a KVM-capable NixOS runner
# exists (see the commented vm-checks job at the bottom).
#
# Inherited-from-legacy gotchas (learned over 57 runs, kept verbatim):
# - Single-user Nix (--no-daemon): no systemd in the container. The
# installer runs as root and honours build-users-group=nixbld from
# its bundled nix.conf, aborting unless the group exists AND has
# members — create nixbld + users first.
# - sandbox=false: Stylix/base16.nix do import-from-derivation; the
# single-user Nix in this container can't set up the build sandbox
# (no user namespaces), which otherwise surfaces as
# "path '…-source' is not valid".
# - Pin the Nix version: 2.34's lazy-trees git cache doesn't
# materialise flake-input `-source` paths into the store, breaking
# the same IFD reads. 2.31.5 matches what wrote flake.lock.
# - Plain-shell Nix install, not a JS action: act_runner's bundled act
# tops out at node20; a `run:` step has no node-runtime coupling.
name: Check
on:
push:
branches: [main, v1]
workflow_dispatch:
jobs:
eval:
runs-on: ubuntu-latest
timeout-minutes: 60
env:
NIX_CONFIG: |
experimental-features = nix-command flakes
sandbox = false
NIX_SSL_CERT_FILE: /etc/ssl/certs/ca-certificates.crt
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Install Nix
run: |
NIX_VERSION=2.31.5
groupadd -r nixbld 2>/dev/null || true
for i in $(seq 1 10); do
useradd -r -g nixbld -G nixbld -d /var/empty \
-s /usr/sbin/nologin -c "Nix build user $i" "nixbld$i" 2>/dev/null || true
done
curl -L "https://releases.nixos.org/nix/nix-${NIX_VERSION}/install" | sh -s -- --no-daemon
echo "$HOME/.nix-profile/bin" >> "$GITHUB_PATH"
- name: nix flake check (eval only)
# Full module-system evaluation of every output — both nixos
# configs, the home config, the checks.* derivations (instantiated,
# not run) and the downstream template through lib.mkFlake.
run: nix flake check --no-build
- name: Python syntax (nomarchy-theme-sync)
# Via nix shell so the step doesn't depend on the runner image
# preinstalling python3.
run: |
nix shell nixpkgs#python3 --command \
python3 -m py_compile pkgs/nomarchy-theme-sync/nomarchy-theme-sync.py
- name: Shell syntax (tracked scripts)
# The distro's user-facing scripts are generated by Nix (their
# syntax is exercised by the eval + local builds); this covers the
# hand-written .sh files: installer helpers and maintainer tools.
run: |
set -e
fail=0
while IFS= read -r script; do
head -1 "$script" | grep -qE '^#!.*\b(bash|sh)\b' || continue
if ! bash -n "$script"; then
echo "::error file=$script::bash syntax error"
fail=1
fi
done < <(git ls-files '*.sh')
exit "$fail"
# ── vm-checks (DISABLED until a KVM runner exists) ────────────────────
# The real prize: running the checks.* VM suite + a toplevel build in
# CI. Needs a runner on a NixOS (or at least nix + /dev/kvm) host —
# register one with a dedicated label, then uncomment and set runs-on
# to that label. Do NOT enable this against a label that doesn't
# exist: Gitea queues such jobs forever instead of failing.
#
# vm-checks:
# runs-on: nix-kvm
# timeout-minutes: 120
# steps:
# - uses: actions/checkout@v4
# - run: nix flake check # builds + runs the VM tests
# - run: nix build .#nixosConfigurations.nomarchy.config.system.build.toplevel --no-link
# - run: nix build .#homeConfigurations.nomarchy.activationPackage --no-link

1
.gitignore vendored
View File

@@ -11,3 +11,4 @@ __pycache__/
# Claude Code machine-local settings (permissions etc.) # Claude Code machine-local settings (permissions etc.)
.claude/settings.local.json .claude/settings.local.json
.claude/skills/

42
CLAUDE.md Normal file
View File

@@ -0,0 +1,42 @@
# Nomarchy — agent entry point
Nomarchy is a NixOS-based distro: rock-stable, fully reproducible, themed
from one JSON, configured through a menu that writes into the user's own
flake checkout. Read the README for the architecture.
## If you're here to work autonomously (the loop)
Follow **`agent/LOOP.md`** — it defines one iteration: orient
(GOALS/BACKLOG/JOURNAL/MEMORY), take the top actionable BACKLOG item,
verify up the V0V3 ladder, commit+push on `main`, record. All loop state
is git-tracked in `agent/`.
## Rules that apply to every session, loop or not
- **Honesty rule** (docs/TESTING.md): for visual/interactive changes,
evaluation is not rendering — state exactly what you verified and at
which tier. Cheap check first, always: `nix flake check --no-build`.
- **Conventions** (`agent/CONVENTIONS.md`): in-flake state, menu
placement, Waybar parity with the summer whole-swaps, toggle-vs-package
discipline, commented template examples for opt-ins.
- **Git:** direct commits on `main`, pushed; `v1` is the human-only
release pointer — never touch it. Never run `nix flake update` unless
the task is a lock bump. No formatter — match the aligned
hand-formatting.
- Machine-specifics live in `hosts/`, the distro in `modules/`, data in
`themes/`, code in `pkgs/`, maintainer tools in `tools/`.
## Delegation
The higher your tier, the more you delegate. Push the work down, keep your own context for judgment. Brief every child: the context, the why, what done looks like. It starts blank and inherits nothing.
| Model | Best for | Delegate? | Effort |
|---|---|---|---|
| Haiku | bulk mechanical | never | low |
| Sonnet | scoped research | when it helps | medium |
| Opus 4.8 | multi-step reasoning | on clear benefit | xhigh |
| Fable 5 | judgment, taste | by default | medium |
Fable goes xhigh only for the hardest calls. Skip high.
## Escalation
The parent doesn't have to be the top model. An Opus parent spawns a Fable child for the one hard call. The child answers and returns. Work above your tier? Return it, don't burn tokens on it.

View File

@@ -76,7 +76,10 @@ Flat on purpose. Two module trees, one options file each, no hidden layers.
├── templates/downstream/ # `nix flake init -t` starter for users ├── templates/downstream/ # `nix flake init -t` starter for users
├── docs/TESTING.md # how to verify changes (incl. AI-agent rules) ├── docs/TESTING.md # how to verify changes (incl. AI-agent rules)
├── docs/OVERRIDES.md # how downstream users override defaults ├── docs/OVERRIDES.md # how downstream users override defaults
├── docs/ROADMAP.md # forward-looking plans + shipped-fixes log ├── docs/RECOVERY.md # runbook: broken theme/desktop/boot → undo it
├── docs/ROADMAP.md # design/decision records + shipped-fixes log
├── agent/ # autonomous-agent loop: protocol (LOOP.md),
│ # prioritized BACKLOG, journal, memory
└── tools/ # maintainer-only └── tools/ # maintainer-only
├── import-palettes.py # converts old-distro themes → JSON + assets ├── import-palettes.py # converts old-distro themes → JSON + assets
├── test-live-iso.sh # build the ISO + boot it in QEMU ├── test-live-iso.sh # build the ISO + boot it in QEMU
@@ -128,6 +131,12 @@ mkdir my-machine && cd my-machine
nix flake init -t "git+https://git.bemagri.xyz/bernardo/nomarchy.git?ref=v1" nix flake init -t "git+https://git.bemagri.xyz/bernardo/nomarchy.git?ref=v1"
``` ```
The template's own README then walks the first-run steps: a real
`hardware-configuration.nix`, `git init` (flakes only see tracked
files), the two first rebuilds, and landing the checkout at
`~/.nomarchy` so the theme CLI finds it. (The installer does all of
this for you.)
You own two files day-to-day: `system.nix` and `home.nix` (plus You own two files day-to-day: `system.nix` and `home.nix` (plus
`theme-state.json`, written by the CLI). Your `flake.nix` is set up once — `theme-state.json`, written by the CLI). Your `flake.nix` is set up once —
later by the installer — and never hand-edited; it's a single call: later by the installer — and never hand-edited; it's a single call:
@@ -162,6 +171,8 @@ Day-to-day you'll use the shipped shortcuts instead:
```sh ```sh
sys-update # nix flake update + system rebuild (BTRFS snapshot first when available) sys-update # nix flake update + system rebuild (BTRFS snapshot first when available)
sys-rebuild # system rebuild against the CURRENT lock (config changes only, no update)
# …both end with a package-level diff of what the rebuild changed (nvd)
home-update # home-manager switch (no flake update, no sudo) home-update # home-manager switch (no flake update, no sudo)
``` ```
@@ -182,6 +193,13 @@ behaviour (input/misc/monitor/chrome) is `mkDefault` so a plain `home.nix`
assignment wins, and bind/exec-once lists concatenate. Full guide with assignment wins, and bind/exec-once lists concatenate. Full guide with
examples: **[docs/OVERRIDES.md](docs/OVERRIDES.md)**. examples: **[docs/OVERRIDES.md](docs/OVERRIDES.md)**.
**Where each option goes.** `nomarchy.system.*`, `nomarchy.hardware.*`, and
`nomarchy.services.*` are NixOS options — set them in `system.nix`. Everything
else under `nomarchy.*` is a Home Manager option — set it in `home.nix`. The
two tables below are split along exactly that line.
**`home.nix`** (Home Manager — the desktop):
| Option | Default | Purpose | | Option | Default | Purpose |
|---|---|---| |---|---|---|
| `nomarchy.stateFile` | — (required) | Path to your theme-state.json | | `nomarchy.stateFile` | — (required) | Path to your theme-state.json |
@@ -190,11 +208,13 @@ examples: **[docs/OVERRIDES.md](docs/OVERRIDES.md)**.
| `nomarchy.keyboard.variant` | `""` | XKB variant for the session | | `nomarchy.keyboard.variant` | `""` | XKB variant for the session |
| `nomarchy.keyboard.devices` | `{}` | Per-device layout overrides (Hyprland `device` blocks keyed by `hyprctl devices` name) — e.g. an external keyboard with its own layout/variant | | `nomarchy.keyboard.devices` | `{}` | Per-device layout overrides (Hyprland `device` blocks keyed by `hyprctl devices` name) — e.g. an external keyboard with its own layout/variant |
| `nomarchy.keyboard.layouts` | `[]` | Extra candidate layouts; when set, a watcher prompts (rofi) for a layout on a newly-connected keyboard and remembers it per-device | | `nomarchy.keyboard.layouts` | `[]` | Extra candidate layouts; when set, a watcher prompts (rofi) for a layout on a newly-connected keyboard and remembers it per-device |
| `nomarchy.nightlight.enable` | `false` | Scheduled blue-light filter (hyprsunset) — warm at night (`.temperature`, default 4000K) between `.sunset`/`.sunrise`, no shift by day | | `nomarchy.nightlight.enable` | `false` | Scheduled blue-light filter (hyprsunset) — warm at night (`.temperature`, default 4000K) between `.sunset`/`.sunrise`, no shift by day; off by default — enable it from the menu (System Night light; the first enable rebuilds), then toggle on/off instantly from the menu or the Waybar moon indicator (writes `settings.nightlight.on` in your flake, no rebuild) and it survives reboot, so it stays reproducible |
| `nomarchy.updates.enable` | `false` | Passive update awareness: a background check (`.interval`, default daily) that flags when flake inputs (nixpkgs, the Nomarchy input, …) are behind upstream — and, with Flatpak on, when apps have updates (`.flatpak`) — via a Waybar indicator + notification. Never changes anything; click the indicator to run the upgrade flow |
| `nomarchy.hyprland.enable` | `true` | Nomarchy's Hyprland config | | `nomarchy.hyprland.enable` | `true` | Nomarchy's Hyprland config |
| `nomarchy.waybar.enable` | `true` | Nomarchy's Waybar | | `nomarchy.waybar.enable` | `true` | Nomarchy's Waybar |
| `nomarchy.rofi.enable` | `true` | Themed rofi launcher + `nomarchy-menu` dispatcher | | `nomarchy.rofi.enable` | `true` | Themed rofi launcher + `nomarchy-menu` dispatcher |
| `nomarchy.swaync.enable` | `true` | swaync notifications, themed | | `nomarchy.swaync.enable` | `true` | swaync notifications, themed |
| `nomarchy.batteryNotify.enable` | `true` | Low-battery toasts at the bar's thresholds — 25% low, 10% critical (stays up until dismissed); silent no-op on machines without a battery |
| `nomarchy.idle.enable` | `true` | hyprlock + hypridle (idle lock 5 min, display off 10, suspend 30) | | `nomarchy.idle.enable` | `true` | hyprlock + hypridle (idle lock 5 min, display off 10, suspend 30) |
| `nomarchy.yazi.enable` | `true` | yazi TUI file manager, themed + curated plugins | | `nomarchy.yazi.enable` | `true` | yazi TUI file manager, themed + curated plugins |
| `nomarchy.osd.enable` | `true` | swayosd on-screen display for volume/brightness/mute | | `nomarchy.osd.enable` | `true` | swayosd on-screen display for volume/brightness/mute |
@@ -205,20 +225,41 @@ examples: **[docs/OVERRIDES.md](docs/OVERRIDES.md)**.
| `nomarchy.fastfetch.enable` | `true` | fastfetch fronted by the themed Nomarchy logo | | `nomarchy.fastfetch.enable` | `true` | fastfetch fronted by the themed Nomarchy logo |
| `nomarchy.keys.enable` | `true` | gpg-agent fronting SSH + pinentry-qt | | `nomarchy.keys.enable` | `true` | gpg-agent fronting SSH + pinentry-qt |
| `nomarchy.displays.enable` | `true` | nwg-displays interactive monitor arranger (helper for `nomarchy.monitors`) | | `nomarchy.displays.enable` | `true` | nwg-displays interactive monitor arranger (helper for `nomarchy.monitors`) |
| `nomarchy.viewers.enable` | `true` | Document/image viewers: zathura (Stylix-themed PDF) + imv |
| `nomarchy.mime.enable` | `true` | Default "open with" associations (PDF/image/video/text/browser/directory); entries for apps you removed are skipped, so it degrades with the suite |
| `nomarchy.monitors` | `[]` | Declarative per-output layout → Hyprland `monitor` rules (applied on hotplug); `,preferred,auto,1` wildcard kept as fallback | | `nomarchy.monitors` | `[]` | Declarative per-output layout → Hyprland `monitor` rules (applied on hotplug); `,preferred,auto,1` wildcard kept as fallback |
| `nomarchy.displayProfiles` | `{}` | Named layouts for the same outputs (docked/undocked/…), switched from System Display Profiles: instant via hyprctl, persisted in-flake (`settings.displayProfile`), baked over `nomarchy.monitors` at the next rebuild. The menu's Auto-switch row (`settings.displayProfileAuto`) makes plugging/unplugging outputs apply the matching profile automatically. A profile can also pin workspaces to outputs (`workspaces = { "1" = "DP-3"; }`) — moved instantly on switch, baked as Hyprland `workspace` rules |
| `nomarchy.launchOrFocus` | `[]` | Launch-or-focus binds: `SUPER+<key>` focuses the app's window (case-insensitive class match) or launches it; entries land in the SUPER+? cheatsheet, and a bind whose app was removed notifies instead of failing silently |
| `nomarchy.themesDir` | Nomarchy's `themes/` | Where per-theme app overrides are probed | | `nomarchy.themesDir` | Nomarchy's `themes/` | Where per-theme app overrides are probed |
| `nomarchy.package` | overlay's `nomarchy-theme-sync` | The theme/state tool package, overridable if you fork it |
**`system.nix`** (NixOS — the machine):
| Option | Default | Purpose |
|---|---|---|
| `nomarchy.system.plymouth.enable` | `true` | Branded boot splash, background from the theme JSON (recolors on system rebuilds) | | `nomarchy.system.plymouth.enable` | `true` | Branded boot splash, background from the theme JSON (recolors on system rebuilds) |
| `nomarchy.system.stateFile` | `null` | theme-state.json for the system-side consumers (the Plymouth tint); `lib.mkFlake` wires it for you — set it only when composing the modules by hand |
| `nomarchy.system.fileManager.enable` | `true` | Thunar GUI + gvfs/tumbler/udisks2 (the "open folder" handler) | | `nomarchy.system.fileManager.enable` | `true` | Thunar GUI + gvfs/tumbler/udisks2 (the "open folder" handler) |
| `nomarchy.system.greeter.enable` | `true` | greetd/tuigreet | | `nomarchy.system.greeter.enable` | `true` | greetd/tuigreet |
| `nomarchy.system.greeter.autoLogin` | `null` | Auto-login this user into Hyprland (installer sets it on LUKS machines) | | `nomarchy.system.greeter.autoLogin` | `null` | Auto-login this user into Hyprland (installer sets it on LUKS machines) |
| `nomarchy.system.audio.enable` | `true` | Pipewire stack | | `nomarchy.system.audio.enable` | `true` | Pipewire stack |
| `nomarchy.system.bluetooth.enable` | `true` | Bluetooth + blueman | | `nomarchy.system.bluetooth.enable` | `true` | Bluetooth + blueman |
| `nomarchy.system.autoTimezone.enable` | `false` | Automatic timezone (geoclue + automatic-timezoned) — the clock follows your location; toggle from System Auto timezone (a menu enable rebuilds: it has to unset the static `time.timeZone`) |
| `nomarchy.system.snapper.enable` | `false` | Hourly/daily BTRFS timeline snapshots + `nixos-rebuild-snap` (installer enables it; no-op unless root is BTRFS) | | `nomarchy.system.snapper.enable` | `false` | Hourly/daily BTRFS timeline snapshots + `nixos-rebuild-snap` (installer enables it; no-op unless root is BTRFS) |
| `nomarchy.system.power.enable` | `true` | Active power management (see below) | | `nomarchy.system.power.enable` | `true` | Active power management (see below) |
| `nomarchy.system.power.backend` | `"ppd"` | `"ppd"` (power-profiles-daemon + menu/Waybar switcher) or `"tlp"` (deeper battery tuning, no switcher) — mutually exclusive | | `nomarchy.system.power.backend` | `"ppd"` | `"ppd"` (power-profiles-daemon + menu/Waybar switcher) or `"tlp"` (deeper battery tuning, no switcher) — mutually exclusive |
| `nomarchy.system.power.laptop` | `false` | Marks a laptop, gating battery-only features; the installer sets it when a battery is present | | `nomarchy.system.power.laptop` | `false` | Marks a laptop, gating battery-only features; the installer sets it when a battery is present |
| `nomarchy.system.power.thermal.enable` | `false` | thermald (Intel-only); the installer enables it on a GenuineIntel CPU | | `nomarchy.system.power.thermal.enable` | `false` | thermald (Intel-only); the installer enables it on a GenuineIntel CPU |
| `nomarchy.system.power.batteryChargeLimit` | `null` | Stop charging at this % (e.g. `80`) where the hardware supports it; needs `power.laptop` | | `nomarchy.system.power.batteryChargeLimit` | `null` | Stop charging at this % (e.g. `80`) where the hardware supports it; needs `power.laptop` |
| `nomarchy.hardware.intel.enable` | `false` | Intel enablement above nixos-hardware (GuC/HuC firmware via `i915.enable_guc=3``.guc` toggles just that; the installer unsets it on `xe`-driver GPUs); the installer sets it on an Intel CPU/GPU |
| `nomarchy.hardware.intel.computeRuntime` | `false` | Opt-in: Intel GPU compute — OpenCL/Level-Zero (`intel-compute-runtime`) + oneVPL (`vpl-gpu-rt`) |
| `nomarchy.hardware.amd.enable` | `false` | AMD enablement above nixos-hardware (amd-pstate EPP + radeonsi VA-API, each toggleable via `.pstate` / `.vaapi`); installer-set on an AMD CPU/GPU |
| `nomarchy.hardware.amd.rocm.enable` | `false` | Opt-in: ROCm HIP/OpenCL GPU compute (multi-GB); pair with `.gfxOverride` (e.g. `"11.0.0"`) for an unlisted iGPU |
| `nomarchy.hardware.fingerprint.enable` | `false` | fprintd for a detected fingerprint reader (installer-set); enroll with `fprintd-enroll` |
| `nomarchy.hardware.fingerprint.pam` | `false` | Opt-in: use the fingerprint for login + sudo (PAM) |
| `nomarchy.hardware.npu.enable` | `false` | Opt-in/experimental: load the on-die NPU driver (`amdxdna`/`intel_vpu`); userspace runtime is BYO |
| `nomarchy.hardware.latestKernel` | `false` | Opt-in: ship `linuxPackages_latest` instead of the default kernel — for very new hardware whose drivers landed recently |
| `nomarchy.hardware.camera.hideIrSensor` | `false` | Hide a dual-sensor webcam's IR node from PipeWire so apps only ever see the colour camera (the "second, dark Integrated Camera"); installer-set on a paired RGB+IR webcam. `/dev/video*` stays open, so Howdy-style face unlock still works; `.irMatch` overrides the IR-name regex |
| `nomarchy.services.tailscale.enable` | `false` | Opt-in: Tailscale mesh VPN (then `sudo tailscale up`) | | `nomarchy.services.tailscale.enable` | `false` | Opt-in: Tailscale mesh VPN (then `sudo tailscale up`) |
| `nomarchy.services.syncthing.enable` | `false` | Opt-in: Syncthing file sync as the login user (GUI at `127.0.0.1:8384`) | | `nomarchy.services.syncthing.enable` | `false` | Opt-in: Syncthing file sync as the login user (GUI at `127.0.0.1:8384`) |
| `nomarchy.services.podman.enable` | `false` | Opt-in: rootless Podman (`docker` aliased to it) | | `nomarchy.services.podman.enable` | `false` | Opt-in: rootless Podman (`docker` aliased to it) |
@@ -235,14 +276,19 @@ examples: **[docs/OVERRIDES.md](docs/OVERRIDES.md)**.
| `nomarchy.services.ollama.enable` | `false` | Opt-in: Ollama local LLM runtime on `127.0.0.1:11434` (CPU; GPU via `services.ollama.acceleration`) | | `nomarchy.services.ollama.enable` | `false` | Opt-in: Ollama local LLM runtime on `127.0.0.1:11434` (CPU; GPU via `services.ollama.acceleration`) |
| `nomarchy.services.printing.enable` | `false` | Opt-in: CUPS + Avahi/mDNS network printer discovery | | `nomarchy.services.printing.enable` | `false` | Opt-in: CUPS + Avahi/mDNS network printer discovery |
| `nomarchy.services.openrgb.enable` | `false` | Opt-in: OpenRGB daemon for RGB peripheral/motherboard lighting | | `nomarchy.services.openrgb.enable` | `false` | Opt-in: OpenRGB daemon for RGB peripheral/motherboard lighting |
| `nomarchy.services.restic.enable` | `false` | Opt-in: scheduled daily restic backup (set `.repository` + `.passwordFile`; 7/4/6 retention; list/restore via the `restic-nomarchy` wrapper) | | `nomarchy.services.restic.enable` | `false` | Opt-in: scheduled daily restic backup (set `.repository` + `.passwordFile`; backs up `.paths`, default `/home`; 7/4/6 retention; list/restore via the `restic-nomarchy` wrapper) |
Beyond the `nomarchy.*` surface, the system layer turns on the usual Beyond the `nomarchy.*` surface, the system layer turns on the usual
desktop services with `lib.mkDefault` (override natively). One worth desktop services with `lib.mkDefault` (override natively). Two worth
calling out: **`services.fwupd.enable`** is on by default for firmware calling out: **`services.fwupd.enable`** is on by default for firmware
updates via LVFS — it only refreshes metadata, never flashes on its own, updates via LVFS — it only refreshes metadata, never flashes on its own,
so run `fwupdmgr update` to apply. Disable with `services.fwupd.enable = so run `fwupdmgr update` to apply; disable with `services.fwupd.enable =
false;` on machines without real firmware (VMs/headless). false;` on machines without real firmware (VMs/headless). And
**`services.earlyoom`** is on by default so running out of memory kills
the offending process (with a desktop notification) instead of freezing
the desktop — process-level on purpose, since a Hyprland session is one
cgroup and systemd-oomd would kill all of it (oomd is disabled
accordingly). Opt out with `services.earlyoom.enable = false;`.
## 4. How theming works ## 4. How theming works
@@ -271,6 +317,31 @@ imperative; nothing in Nix consumes the path): applied at session start and
after every switch via a tiny activation hook, cycled instantly with after every switch via a tiny activation hook, cycled instantly with
`bg next`. `bg next`.
### Config the menu writes (not just themes)
The in-flake-state model isn't only for appearance. **Feature toggles you flip
from the menu are written into a `settings.*` section of the *same* state file**
— git-tracked, reproducible, never stashed in `~/.local/state`. The menu is just
an ergonomic writer for your flake, so version-controlling your downstream
reproduces the machine, settings and all. Where a toggle can take effect without
a rebuild it does: the menu writes the key (`--no-switch`) and flips the running
service, which reads the *live* flake state at session start, so the choice is
both instant and survives reboot.
**Night light** is the first to use this — enable it from the menu (System
Night light; the first enable rebuilds to install hyprsunset), then on/off is
instant. Internally it's two keys: `settings.nightlight.installed` (sticky —
gates the unit, the first enable rebuilds) and `settings.nightlight.on` (the
instant on/off). Expect more `nomarchy.*` toggles to migrate to this pattern.
**Auto-commit (opt-in):** System Auto-commit makes every `apply`/`set`
mutation also `git commit` theme-state.json in your flake — *only* that
file, so unrelated dirty work is never swept up — turning your settings
history into `git log`. Off by default; the toggle is instant (nothing in
Nix consumes the flag), the off-write is itself committed so history stays
consistent, and wallpaper cycling (`bg next`) is deliberately excluded —
the current wallpaper rides along with the next real commit.
### Per-theme app assets (`themes/<slug>/`) ### Per-theme app assets (`themes/<slug>/`)
Recoloring covers 95% of theming; the rest is one optional assets directory Recoloring covers 95% of theming; the rest is one optional assets directory
@@ -299,9 +370,15 @@ nomarchy-theme-sync bg next # cycle wallpapers — instant, no rebuil
nomarchy-theme-sync bg auto # back to the theme's default wallpaper nomarchy-theme-sync bg auto # back to the theme's default wallpaper
nomarchy-theme-sync get colors.accent nomarchy-theme-sync get colors.accent
sys-update # update inputs + rebuild the system (snapshots first) sys-update # update inputs + rebuild the system (snapshots first)
sys-rebuild # rebuild the system, current lock (no update)
home-update # rebuild just the desktop layer home-update # rebuild just the desktop layer
nomarchy-doctor # read-only health sheet (also: menu System Doctor)
``` ```
Something broke anyway? Every rebuild is a generation and (on BTRFS)
every hour is a snapshot — the undo story, from a bad theme to a
machine that won't boot, is **[docs/RECOVERY.md](docs/RECOVERY.md)**.
Keybinds: `SUPER+Return` terminal · `SUPER+D` launcher · `SUPER+T` theme Keybinds: `SUPER+Return` terminal · `SUPER+D` launcher · `SUPER+T` theme
picker · `SUPER+SHIFT+T` next wallpaper · `SUPER+X` power menu · picker · `SUPER+SHIFT+T` next wallpaper · `SUPER+X` power menu ·
`SUPER+E` file manager (yazi) · `SUPER+N` notifications · `SUPER+CTRL+V` `SUPER+E` file manager (yazi) · `SUPER+N` notifications · `SUPER+CTRL+V`
@@ -357,6 +434,9 @@ reload # exec zsh (reload the shell)
## Roadmap & known issues ## Roadmap & known issues
See **[docs/ROADMAP.md](docs/ROADMAP.md)** — forward-looking plans plus the The prioritized queue of what's next lives in
log of shipped fixes. Kept out of the README so this stays a focused entry **[agent/BACKLOG.md](agent/BACKLOG.md)**; the detailed design/decision
point. records and the log of shipped fixes stay in
**[docs/ROADMAP.md](docs/ROADMAP.md)**. Development runs on an
agent-driven loop — see **[agent/LOOP.md](agent/LOOP.md)**. Kept out of
the README so this stays a focused entry point.

294
agent/BACKLOG.md Normal file
View File

@@ -0,0 +1,294 @@
# Backlog — the prioritized task queue
The forward-looking half of the old docs/ROADMAP.md, reworked as a queue
agents can execute. Detailed design notes and decision records stay in
`docs/ROADMAP.md` (referenced as "ROADMAP § <item>"); this file is *what's
next, in what order*.
**Rules:**
- Agents take the topmost actionable item (see LOOP.md). Finished items
are **deleted** here — the journal + git log are the record; durable
design notes get a ✓-entry in docs/ROADMAP.md if worth keeping.
- Item numbers are **stable IDs** — never renumbered or reused. A gap in
the sequence means shipped (or dropped) work; new items take the next
free number regardless of tier.
- Tags: `[blocked:hw]` needs real hardware (see HARDWARE-QUEUE.md) ·
`[human]` needs Bernardo · `[stuck]` two failed attempts, needs help ·
`[big]` must be split before starting.
- Agents may append to **PROPOSED** and **Decisions** freely; only
Bernardo moves items *out* of PROPOSED into the tiers.
---
## NOW
*(empty — NEXT's top item is the queue head)*
## NEXT
### 29. Laptop docking / external-monitor UX `[human]` (needs hardware)
(Raised by Bernardo, 2026-07-07.) The shipped display application is
weak and unintuitive, and Hyprland's model for workspaces across
multiple monitors is unclear to users. Make docking a laptop to
external monitors first-class:
- **Unplug → restore the panel:** when the external monitor is removed
and the laptop's own panel is off (was the only-active output while
docked), turn the internal panel back on *immediately* — never leave
the user staring at a dead lid.
- **Plug → extend by default:** a newly connected external monitor
should *extend* the laptop panel (not mirror), out of the box.
- **HDMI audio:** an easy way to route audio to the monitor when a
display with audio (HDMI/DP) is connected — a menu toggle / quick
action, not manual pavucontrol digging.
- **Workspaces + multi-monitor:** document (cheatsheet + menu) and,
where sensible, sane defaults for how Hyprland binds workspaces to
outputs so multi-monitor behavior is predictable.
Likely a `hyprland`/`kanshi`-style profile layer plus a menu surface;
split into slices before starting. `[blocked:hw]` for the real
dock/undock + HDMI-audio verification (see HARDWARE-QUEUE.md).
### 30. Waybar overlaps fullscreen video
(Raised by Bernardo, 2026-07-07.) A fullscreen YouTube video (browser
fullscreen) leaves Waybar drawn on top of the video instead of being
covered. Waybar sits on an overlay/top layer that outranks the
fullscreen window's surface. Fix so a true fullscreen window hides the
bar — likely a Hyprland `layerrule`/`windowrule` or lowering Waybar's
layer, or wiring the bar to auto-hide on fullscreen. Verify with
browser fullscreen (F on YouTube) AND a native fullscreen app; make
sure the bar returns on exit. Small, agent-doable.
### 28. Theme UI review — make every surface stunning `[big]`
(Raised by Bernardo, 2026-07-05 — placed at the head of NEXT; reorder
freely.) A thorough visual pass over the whole themed UI — Waybar (the
generated bar AND the summer whole-swaps), rofi menus, swaync,
hyprlock, the cheatsheet, ghostty/btop, GTK/Qt, boot splash, greeter —
with the bar set at *stunning*, not merely consistent (pillar 4).
Grounded in design theory, not taste alone:
- **Color:** palette-role relationships that demonstrably work
together — harmony models (analogous/complementary accents),
perceptual lightness ordering of base→surface→overlay→text (check in
OKLCH, not raw hex), accent/warn/bad distinguishable for color-blind
users; extend tools/check-theme-contrast.py to encode the adopted
rules (it currently guards legibility pairs only).
- **Typography & rhythm:** a consistent type scale across bar/menus/
OSD, spacing on the ui.gapsIn/gapsOut grid, aligned paddings (the
bar's per-module padding vs rofi rows vs swaync toasts).
- **Hierarchy:** what reads first — bar module ordering/weights, menu
title vs rows, toast title vs body; icon weights consistent with the
adjacent text.
Bernardo has offered image-expert models for designed graphics
(wallpapers, logos, glyphs, preview/branding art): an agent slice that
needs artwork must STOP and ask ("say the word") instead of shipping
programmer-art.
Slices: (a) ✓ the audit (2026-07-05, iteration #31 — findings below);
(b) palette-level fixes + the extended contrast/harmony checker —
P1s ✓ shipped 2026-07-05 (iteration #32): summer-day + flexoki-light
subtext/surface/overlay/muted retuned (flexoki to its canonical
upstream tones), miasma + kanagawa surfaces raised (kanagawa to
sumiInk4); `subtext/base ≥ 3` now GATED in check-theme-contrast.py
(all 21 pass). P2s ✓ shipped 2026-07-05 (iteration #33): 18 hex
retunes across 9 themes (OKLCH hue-preserving minimal raises;
canonical where upstream defines one — gruvbox bg4, nord #616e88,
latte overlay1, flexoki 600-series statuses for light mode) and the
four floors GATED: muted ≥ 2, text/surface ≥ 4.5, accentAlt ≥ 3,
warn ≥ 2.5 (21×7 pairings green; audit contrast findings 20 → 1 —
miasma's earthy `bad` stays as identity, good/bad floors stay
audit-only so no exemption list was needed). Remaining (b): nothing —
further palette work graduates from slices (c)/(d) findings;
(c) per-surface polish slices — capture harness ✓ shipped 2026-07-05
(iteration #34): `tools/theme-shot.nix` boots the themed desktop
headlessly at 1080p and screenshots desktop + open root menu (proven
on tokyo-night); per-surface punch items from the first full-res
review:
-**rofi keybind hints** (shipped iteration #37): dimmed pango
hints on root/Tools/System rows, generated from keybinds.nix via
the cheatsheet's prettyKeys (single source, can't drift) —
capture-verified on tokyo-night.
- **Icon weight mix `[human]`:** full-color Papirus icons in the
menus vs all-monochrome bar glyphs — pick: symbolic/monochrome
menu icons for one system, or color as the menu's identity.
A/B captured on lumon (iteration #46, side-by-side:
https://claude.ai/code/artifact/1ccd10fe-4b6f-454a-888d-e7fdf2362b03):
B (symbolic) matches the bar's weight but Papirus symbolic
coverage has holes (preferences-desktop-theme-symbolic missing —
Look & Feel lost its icon) and rofi tints symbolics a fixed dim
gray, not the theme text color — going B needs an icon audit +
recolor slice. A costs nothing. Awaiting the call; A stays until
then.
-**Bar right-cluster rhythm** (shipped iteration #36): group
margins before pulseaudio/powerprofile/battery — heads that
self-hide degrade to the uniform spacing.
-**Semantic dim** (shipped iteration #36): workspaces + dnd +
muted-volume now use `@muted` (floor-guaranteed by 28b) instead
of `alpha(@text, 0.5)` tints; the CSS comment now documents the
new invariant.
-**compositor backdrop** (shipped iteration #35): Hyprland
`misc:background_color` now the theme's base — no more black flash
behind a missing/slow wallpaper (glaring on light themes in the
summer-day capture).
- summer-day whole-swap capture reviewed (iteration #35): the
authored slate menu + island-pill bar survive the 28b retunes and
read as one coherent identity — keep. ✓ placeholder-dim nit fixed
(iteration #38: both summer rasi, bg0 at 60% alpha).
- latte capture reviewed (iteration #38): the generated menu is
fully coherent on light themes (zebra rows, retuned surface, blue
selected, dimmed hints) — the audit's "dark terminal on latte"
preview concern was a stale preview, not a defect. Backdrop
probe: misc:background_color IS applied (hyprctl getoption =
0xFFEFF1F5); captures show dark because awww's empty-state layer
paints over it in the wallpaper-less VM — on hardware the fix
covers its intended window (the pre-awww startup gap).
- ✓ Harness gap fixed (iteration #44): guest `~/.nomarchy` seeded
via tmpfiles BEFORE greetd starts the session, so the session's
own exec-once wallpaper paint runs (a post-boot testScript seed
was too late for exec-once, and a manual re-run fails without
WAYLAND_DISPLAY — awww's client socket is display-named).
Wallpaper-complete summer-night capture reviewed: lakeside night
scene + cream inverted bar + menu-over-scene all cohere; the
placeholder dim (#2d353b99) renders. Note: summer-night's cream
bar/menu on dark backdrop is the authored inverted design (fg as
surface), not a rendering bug.
- ✓ Identity-theme capture + stub purge (iteration #45): the lumon
capture exposed that catppuccin/lumon/nord/retro-82 shipped stub
waybar.css files (214 lines, from the f211ef0 rewrite import) —
and a theme waybar.css whole-swaps the ENTIRE generated
stylesheet, so those four ran an unstyled default bar. Stubs
deleted; re-capture confirms lumon falls back to the generated
styled bar (workspace pill, padded right cluster). Their full
~110-line rofi.rasi designs kept. Real whole-swaps remain only
summer-day/night (+ nord/retro-82 lost nothing: the stubs had no
design).
(d) the whole-swap themes brought to the same bar (module parity
verified textually 2026-07-05: whole-swaps carry every functional
module; omitting cpu/memory is identity). Visual work is
V3-bound by nature — screenshots are the review artifact, hardware
confirms.
**Audit findings (slice a — `tools/audit-theme-design.py` over all 21
palettes + visual pass over previews; identity-aware curation):**
- ~~P1 · invisible-secondary-text palettes~~ ✓ fixed iteration #32
(summer-day subtext #6e828a, surface #efe7d0; flexoki-light to
canonical Flexoki: surface #E6E4D9, overlay #B7B5AC, subtext
#6F6E69, muted #878580). Gate added.
- ~~P1 · inverted bg stacks~~ ✓ fixed iteration #32 (miasma surface
#2e2e2e; kanagawa surface #2a2a37 = upstream sumiInk4). Note
learned: in this palette model `overlay` is a MID-TONE
(borders/dim, catppuccin semantics), not a third bg tier — the
dark-theme overlay==muted pattern is intentional.
- **P2 · muted floor:** muted-on-base < 2.0 on gruvbox (1.27),
everforest (1.55), lumon (1.68), nord (1.69), white (1.82), latte
(1.91), retro-82 (1.93) — dimmed workspaces/inactive items barely
render (visible on the lumon preview). Adopt ≥ 2.0 and retune.
- **P2 · warn barely reads:** warn-on-base ≈ 2.02.3 on rose-pine,
summer-day, latte, flexoki-light — this is the bar's 25%-battery
color. Floor 2.5 (or see the color-only rule below).
- **P2 · text-on-surface < 4.5:** ristretto 3.88, latte 4.39 (menus
and chips draw text on surface).
- **P3 · accentAlt-on-base < 3:** latte 2.34, summer-day 2.83.
- **Design rule (systemic, beats palette surgery): status is never
color-only.** good/warn collapse under protanopia on ~half the
themes and good/bad under deuteranopia (59 pair-findings) — that's
inherent to red/green/yellow, not fixable per-palette. Battery and
recording already carry glyph changes; sweep the remaining
color-only indicators (updates count, notification dot, VPN shield
states) for shape/glyph redundancy.
- **Identity exemptions, made explicit:** vantablack + white
(deliberately hueless statuses), lumon (all-blue), hackerman
(matrix green statuses), matte-black/retro-82 (off-family hues) are
identity, not defects — slice (b)'s checker gets a per-theme
exemption list instead of silently lowered floors.
- **ANSI / greeter notes:** latte, rose-pine and white ship
`ansi[0]` LIGHTER than `ansi[15]` — decide the light-theme ANSI
convention (terminals conventionally keep black dark) before the
greeter/tty theming ships to them. tokyo-night/summer-night
`ansi[7]-on-ansi[0]` ≈ 2.83.0 = the greeter's muted text; fine as
muted, watch on hardware.
- **Visual pass (480×270 previews — too small for type/spacing; the
deep per-surface pass needs 1080p captures):** the generated bar's
right cluster runs ~9 modules with uniform spacing — no grouping
rhythm (consider micro-gaps between tray/net · stats · toggles ·
battery/notify groups); latte's preview shows a DARK terminal on
the light desktop — verify ghostty consumes the latte palette (or
the preview is stale); summer-day's island-pill whole-swap bar and
lumon's globe identity are strong — preserve through any polish.
### 14. Automated lock bumps — confirm the first real run
Slices b+c shipped 2026-07-05 (`.gitea/workflows/bump.yml`): weekly
schedule (Mon 05:17 UTC) + `workflow_dispatch` as the security
fast-lane; `nix flake update` → eval gate → pathspec-limited lock
commit pushed to `main` on green (that push triggers check.yml as the
second net). Update/commit/push logic simulated locally end-to-end in
a scratch clone, incl. a real update; today's bumped lock evals green.
Remaining (not confirmable from a session): the first scheduled or
dispatched run must land — watch that the runner picks up the cron and
that the Actions token can push. Then delete this item. Item 20's KVM
runner later upgrades the gate from eval-only to the VM suite.
### 20. KVM runner → VM suite in CI `[human]`
The remaining stretch of the CI item — checks-on-push is live and
**green** (run #58; runner = gitea/act_runner docker, eval tier).
Register a second runner on a host with `/dev/kvm` + nix (host-mode
label `nix-kvm`), then an agent uncomments the workflow's `vm-checks`
job: the `checks.*` VM suite + real toplevel/HM builds on every push
(also upgrades item 14's bump gate from eval-only to the full suite).
## LATER
- **Wallpapers artifact split** (ROADMAP § Faster switches — decided,
deferred): pinned `Nomarchy-wallpapers` input so a state write stops
re-copying 86 MB. Follow-on: pre-built theme variants if switches are
still slow after.
- **Installer round 2** (ROADMAP § Installer): multi-disk BTRFS RAID,
impermanence, BIOS/legacy boot.
- **Boot-from-snapshot**: a systemd-boot equivalent of grub-btrfs.
- **Night-light geo mode**: lat/long auto sunset/sunrise (means wlsunset).
- **Per-theme icon overrides** / more icon packs (ROADMAP § Icon themes).
- **MIPI/IPU software-ISP camera** support (no-UVC machines).
- **VPN exit-node richer display** (country/city) (optional).
- **NixOS release bump → v2** `[human]`: deliberate, hand-edited, never
automated; the previous attempt was discarded (2026-06-22) over a
Hyprland OOM blocker — see MEMORY.md before retrying (NOW#3 should
also soften that blocker class).
## PROPOSED (agent suggestions — await human triage)
*Agents: append here with a one-paragraph pitch (what/why/cost). Do not
implement. Bernardo moves accepted items into a tier.*
- **Portal/Flatpak camera picker still lists the internal IR sensor**
(ROADMAP § Webcam follow-up). The shipped IR-hide is a *WirePlumber
v4l2* rule, but Flatpak/portal apps consume cameras via the
**libcamera** path, where both sensors remain visible — a Flatpak
Zoom user can still pick the black IR "camera". Options, roughly
ascending cost: (a) do nothing — document it (portal camera support
is still rare in practice); (b) a WirePlumber *libcamera* monitor
rule disabling GREY-only nodes — needs verifying that libcamera
monitor rules can match early enough (the v4l2 investigation found
only `device.api` binds pre-rule, which is why surgical scoping
failed before — same wall likely applies); (c) a libcamera
configuration/udev quirk hiding the IR sensor at the libcamera layer
itself. Cost: (b)/(c) need a T14s-style RGB+IR machine to verify →
pairs with a hardware-queue session. Recommend (a) now, (b)
investigated when the T14s is next available.
## Decisions `[human]`
Open calls only Bernardo can make; agents add options/evidence but never
decide.
- **Formatter adoption:** repo deliberately has none; `nixfmt-rfc-style`
would flatten the aligned hand-formatting of ~33 files. Adopt or
declare never?
- **Docs site vs Markdown-in-repo** (from the docs-review item).
- **zram swap:** faster under pressure and pairs with NOW#3, but it
interacts with the hibernation-swapfile story (resume device/priority
ordering) — adopt, adopt-with-hibernation-guard, or skip?
- **Default browser:** the template comments Firefox out. The shipped
mime defaults (item 8, done) point `text/html`/http(s) at
`firefox.desktop` as *inert* entries — they activate the moment
Firefox is installed and are skipped otherwise, so the remaining call
is only: ship a browser active in the suite, or stay
browserless-by-default?

70
agent/CONVENTIONS.md Normal file
View File

@@ -0,0 +1,70 @@
# Conventions — how Nomarchy code is written
The standing rules an agent must follow while coding. GOALS.md says what
we're building; this says how. Details/rationale live in docs/ROADMAP.md's
decision records and the README.
## Repo layout (the rule of thumb)
`modules/` is the distro (reusable, no machine specifics) · `hosts/` is a
machine · `themes/` is data · `pkgs/` is code · `tools/` is maintainer-only
· `agent/` is loop state. If a new file doesn't obviously belong to one of
those, it probably shouldn't exist.
## Nix style
- **No formatter.** Files use deliberate aligned hand-formatting — match
the surrounding style exactly; never reflow a file you're only touching
a line of.
- Distro defaults use `lib.mkDefault` so a plain downstream assignment
wins; bind/exec lists concatenate. Behaviour options overridable,
appearance flows from the state JSON.
- Options live in the existing surfaces: `nomarchy.system.*` /
`nomarchy.hardware.*` / `nomarchy.services.*` (NixOS, `system.nix`),
everything else `nomarchy.*` (HM, `home.nix`). Update the README tables
when the surface changes.
## Feature design
- **In-flake state:** any user-settable config gets a menu writer that
lands it in `theme-state.json` (`settings.*`), git-tracked. No
`~/.local/state`, no side files. Instant-effect where possible
(`--no-switch` + flip the service; the service reads the *live* working
tree at start — the night-light `ExecCondition` pattern). Rebuild-baked
values graduate via `mkDefault` reads of the settings key.
- **Toggle vs package:** a `nomarchy.*` toggle only when there's real
config behind it (units, groups, udev, firewall). A bare package goes
in the downstream template's `home.packages` — opt-out is deleting the
line.
- **Opt-in features** ship a commented example in
`templates/downstream/home.nix` or `system.nix`.
- **Menu:** new entries go in the right submenu (Tools / System ; root
stays six entries), end lists with the shared `↩ Back`, self-gate on
the feature's availability, and add the direct
`SUPER+CTRL+<mnemonic>` bind in `keybinds.nix` (single source — it
feeds both Hyprland and the SUPER+? cheatsheet).
- **Waybar:** new indicators self-gate (hidden when irrelevant), use
named `writeShellScriptBin`s on PATH (so static configs can exec them
by bare name), and are added to **both** the generated `waybar.nix`
config **and** every `waybar.jsonc` whole-swap — summer-day, summer-night
and executive-slate (the parity rule).
- **Theming:** every new visual surface consumes the palette from the
state JSON. There is no second renderer to keep in sync — add the key
to the JSON, consume it in the module.
## Testing
- docs/TESTING.md is canonical; LOOP.md's ladder (V0V3) sets the
required tier. Cheap first: `nix flake check --no-build`, `bash -n`,
`py_compile`.
- Prefer a permanent `checks.*` runNixOSTest over a one-off manual poke;
reusable recipes (headless Hyprland with software GL, QMP screenshots,
udev-event fakes) are indexed in MEMORY.md and demonstrated by the
existing checks (`distro-id`, `hardware-toggles`,
`battery-charge-limit`).
- The honesty rule: report exactly what you verified and at which tier.
## Git
- `main` is development (direct commits, pushed); `v1` is the release
pointer — **human-only, fast-forward-only, never touched by agents**.
- Commit style: `feat|fix|test|docs|chore(scope): summary`, body with
what/why + verification tier + what remains. Bookkeeping (`agent/`
updates) rides in the same commit as the change.
- `flake.lock` moves only when the task *is* a lock bump, and only within
the pinned release branches.

58
agent/GOALS.md Normal file
View File

@@ -0,0 +1,58 @@
# Goals — what "done" looks like for Nomarchy
The north star every loop iteration serves. When two options conflict,
the earlier pillar wins.
## The four pillars (in priority order)
1. **Rock-stable.** A workstation you never fight. Everything is a NixOS/HM
generation — atomic, rollbackable, never partial. `nix flake check` is
green at every commit on `main`. Regressions are caught by the VM-test
suite (`checks.*`), not by users. `v1` only ever advances after human
on-hardware QA.
2. **Reproducible, with zero hidden state.** The downstream flake checkout
*is* the machine. All user-settable config is menu-writable into the
git-tracked state file (`theme-state.json` `settings.*`) — never
`~/.local/state`, never `~/.config` side files. Re-cloning your flake
reproduces the machine, settings and all.
3. **Effortless to configure.** The user never has to learn Nix. Every
common knob is reachable from `nomarchy-menu` (SUPER+M); the menu is an
ergonomic writer for the flake. Where a toggle can take effect without a
rebuild, it must (`--no-switch` + flip the running service).
4. **Beautiful.** One JSON themes the entire desktop coherently — Hyprland,
Waybar, Ghostty, btop, rofi, GTK/Qt, boot splash, greeter. Every new
surface follows the palette. Informative, self-gating Waybar modules
(they hide when irrelevant). No unthemed corner survives contact with
the theme switcher.
## Quality bars (non-negotiable)
- **The honesty rule** (docs/TESTING.md): for anything visual, "the Nix
evaluates" is not "it renders". Verify at the highest tier you can reach
(see LOOP.md's verification ladder) and *state the tier you reached*.
Never claim a check you didn't run.
- **Parity rule:** any module added to the generated Waybar config must
also be added to the summer-day/night `waybar.jsonc` whole-swaps.
- **Menu placement:** new menu entries go in the right category submenu
(Tools / System ), with a direct `SUPER+CTRL+<mnemonic>` bind and
self-gating where applicable. The root picker stays six entries.
- **Opt-in features** ship a commented example in
`templates/downstream/{home,system}.nix`.
- **Option surface discipline:** a toggle exists only when there is real
config behind it. Bare package installs go in the template's
`home.packages` (opt-out = delete the line), never a `nomarchy.apps.*`.
## Non-goals (do not drift into these)
- **No binary cache.** Compile-from-source is a deliberate values call;
automation targets the config/lock channel, not artifact distribution.
- **No second theming pipeline.** The dispatcher owns menu structure; the
renderer (rofi) stays swappable. No GTK4 launcher.
- **No nixpkgs major bump on `main`.** Lock updates stay within the pinned
release branch; a release jump is a deliberate `v2`, hand-edited by the
maintainer (the last attempt was discarded over a Hyprland OOM — see
agent/MEMORY.md).
- **No repo-wide reformat.** The `.nix` files use deliberate aligned
hand-formatting; adopting a formatter is an open maintainer decision
(BACKLOG.md § Decisions), not a cleanup.
- **No multi-DE.** Hyprland is the desktop.

249
agent/HARDWARE-QUEUE.md Normal file
View File

@@ -0,0 +1,249 @@
# Hardware queue — V3 checks only a human can run
Everything shipped at V1/V2 whose final verification needs real hardware.
Agents **append** (newest at the bottom of a section) with exact steps;
Bernardo runs them and either checks off (`[x]` + date + verdict) or files
the failure as a NOW bug in BACKLOG.md. Machines: the **AMD dev box**
(Ryzen AI laptop: AMD + fingerprint + NPU), the **Latitude 5410** (Intel
QA machine), the **T14s** (webcam case).
## Any machine (dev box is fine)
- [ ] **Battery charge limit re-apply on unplug** — `sudo nixos-rebuild
switch` with `power.batteryChargeLimit = 80`, physically unplug/replug
AC, confirm `charge_control_end_threshold` re-reads 80. (udev trigger
already VM-verified.)
- [ ] **SSH_AUTH_SOCK for GUI clients** — after relogin, launch a GUI git
client (or `rofi`-launched terminal-less app) and confirm it reaches
gpg-agent's SSH socket without an interactive shell parent.
- [x] **Night-light full cycle** — first menu enable rebuilds + hyprsunset
starts; later toggles are instant (no rebuild); an *off* survives
reboot (ExecCondition); stopping hyprsunset restores gamma.
— 2026-07-04 Bernardo: PASS enable/disable on hardware
(off-survives-reboot + gamma-restore not explicitly checked).
- [x] **Auto-timezone** — enable from the menu; confirm geoclue finds the
zone, `/etc/localtime` updates, and the Waybar clock refreshes
(SIGUSR2 watcher) — also after a manual `timedatectl set-timezone`.
— 2026-07-04 Bernardo: PASS — enabled from the menu, `timedatectl`
shows Europe/London (the manual set-timezone re-trigger wasn't
exercised).
- [ ] **Keyboard layout cycle bind** — with a comma layout (e.g.
`nomarchy.keyboard.layout = "us,de"`), SUPER+SHIFT+K cycles the
focused keyboard's layout, the Waybar `󰌌` indicator follows, and
the row shows in the SUPER+? cheatsheet. — 2026-07-04 attempt:
no comma layout was configured, so the bind wasn't rendered (the
gate working as designed, not a failure) — retest after setting a
comma layout + rebuild + relogin. The SUPER+? no-op found en
route IS a real bug → BACKLOG item 26 (fix shipped — see the
dedicated SUPER+? entry below).
- [ ] **SUPER+? opens the cheatsheet** (item 26 fix) — after
`home-update` + relogin (or `hyprctl reload`), SUPER+? must open
the keybindings cheatsheet, whose row still reads `SUPER + ?`
(not `SUPER + SHIFT + ?`).
- [ ] **swaync readable on summer-day** (item 25 fix) — on summer-day
after `home-update`: `notify-send "title" "body text"` shows
readable body text; open the control centre (SUPER+N), hover a
notification row and check the Clear button — all text legible
(body/buttons now @text on tinted chips, not subtext/surface).
- [ ] **Bar + rofi legible on flexoki-light** (item 27 fix) — switch to
flexoki-light: every Waybar module readable (window title, tray
row, dimmed inactive workspaces / muted volume visible-but-dim),
rofi inputbar/alternate rows show text. Spot-check one dark theme
(e.g. tokyo-night) for no visual regression in the same spots.
- [ ] **Stub-bar themes got the generated bar** (item 28, 90a5104) —
switch to catppuccin, lumon, nord and retro-82: each now shows
the generated styled bar in its own palette (workspace pill,
padded right cluster) instead of the old raw default Waybar.
Their rofi menus should look unchanged.
- [ ] **Back-audit spot-check** (item 24) — the keybinds cheatsheet
(SUPER+? / root menu Keybindings) now ends in ↩ Back and
returns to the root picker; spot a couple of submenus (Display,
VPN Tailscale) still Back correctly after the audit pass.
- [ ] **Network menu has no blank rows** (item 22 fix, needs Wi-Fi) —
after `home-update`, open Tools Network: no empty separator
rows between the ethernet/wifi/VPN sections (compact = True).
KNOWN RESIDUAL: if exactly one nameless wifi row remains showing
only security+bars, that's a hidden-SSID AP — config can't filter
it; report it and the follow-up is a small source patch skipping
empty-name APs in create_ap_list.
- [ ] **Keyboard hotplug picker (re-verify after in-flake graduation)** —
plug an external keyboard post-login, pick a layout in rofi, confirm
it applies per-device only, persists in `settings.keyboard.devices`,
and graduates into a `device{}` block on the next rebuild.
- [x] **Snapshots restore/rollback** — ⚠ PRECONDITION: update to main ≥
a47aa3a and RELOGIN first (the polkit agent starts with the session).
Bernardo's 2026-07-04 Latitude findings 5/6 ("btrfs-assistant still
crashes", "menu snapshots shows nothing") match the PRE-fix behavior
exactly: no agent → pkexec fails silently (= menu does nothing), and
a direct unprivileged run crashes (= the libbtrfsutil bug). If either
still reproduces ON THE FIXED BUILD after relogin, reopen BACKLOG
item 4 as [stuck]. Then: `nomarchy-menu snapshot` now opens
the **btrfs-assistant GUI**: a *themed* polkit prompt must appear
(hyprpolkitagent — first on-hardware outing) and the GUI must open
as root. Also exercise the fzf fallback in a terminal
(`sudo nomarchy-snapshots`): browse/diff, restore a file
(`undochange`), and a root-config rollback behind the typed-`yes`
gate. Bonus check: any other pkexec flow now prompts instead of
silently failing. — 2026-07-04 Bernardo: PASS on the fixed build —
polkit prompt appears, GUI opens as root, fzf fallback works.
Restore/rollback not exercised → residual item below.
- [ ] **Snapshots restore + rollback exercise** (residual from the item
above — the GUI/polkit half passed 2026-07-04): in
`sudo nomarchy-snapshots`, restore a single file (`undochange`)
and walk a root-config rollback up to (or through) the
typed-`yes` gate.
- [ ] **Caffeine toggle (item 13 slice)** — click 󰾪 in the bar → turns
󰅶 (warm tint on the generated bar) and hypridle must NOT lock /
blank while it's on (wait past the 5-min lock); click again
releases. Also present on both summer bars now.
- [ ] **Screen recording (item 12)** — Tools Capture: Record region
(slurp) and Record screen produce a playable .mp4 in
~/Videos/Recordings; the red ⏺ REC appears in the bar instantly
and CLICKING IT stops + notifies the path; the + audio variants
carry sound. While recording, the Capture menu offers only
"■ Stop recording". On the AMD box wl-screenrec (VAAPI) should
do the work; if it dies at start the wf-recorder fallback kicks
in silently — check the notification says which. Summer bars
have the same ⏺ (parity).
- [ ] **Doctor (item 10)** — menu System Doctor opens the sheet in
a terminal; `nomarchy-doctor` over SSH shows the same minus user
units. On a healthy machine everything is ✔/ (dev-box run
2026-07-04 correctly flagged a genuinely failed user unit).
- [ ] **Rollback menu (item 9b)** — after a couple of theme changes,
menu System Rollback: recent desktop generations listed
(newest marked current); picking an older one opens a terminal,
activates it, and the theme visibly reverts; picking the newer one
again rolls forward. The two System rows: Snapshots opens the
snapshot flow, "boot an older generation (how)" fires an
instruction notification.
- [ ] **Open-a-file smoke (viewers + mime defaults, item 8)** — after
`home-update`: from yazi/Thunar, open a PDF (→ zathura, themed to
the palette), an image (→ imv, NOT GIMP), a video (→ mpv);
`xdg-open .` on a directory → Thunar. With no browser installed,
clicking a link must still fall through to *something sane* (the
firefox.desktop entries are inert); if you've uncommented a
browser, links go there.
- [ ] **Update awareness** — with `nomarchy.updates.enable`, let the timer
fire (or start the unit): indicator appears only when inputs are
behind, notification only on count growth, click opens the upgrade
flow.
- [ ] **VPN menu live paths** — import a real WireGuard `.conf` and an
`.ovpn` via System → VPN, toggle up/down (● / ○ state), and the
Tailscale block: up/down + exit-node without sudo (operator grant).
- [ ] **Printer menu** — with `nomarchy.services.printing`, System →
Printers opens system-config-printer; add a printer, test page.
- [ ] **GRUB UEFI ISO theme render** — boot the ISO on UEFI hardware:
composed splash background, palette menu in the lower third, accent
timeout bar.
- [ ] **Visual theme pass** — live ISO: all six identity themes (bars) +
the four authored rofi `.rasi` (nord/retro-82/lumon/kanagawa) look
right, not just parse.
- [x] **Auto-commit on a real machine** — System Auto-commit toggles on
(row shows state, notification fires); a theme apply from SUPER+T then
shows a `nomarchy: apply theme …` commit in `~/.nomarchy` (`git log`);
unrelated dirty files in the checkout stay uncommitted; toggle off is
itself the last commit. Also: the "Auto timezone (on/off)" row label
now reflects the real state (the `= true` comparison fix).
— 2026-07-04 Bernardo: PASS — theme changes committed and the
toggle self-committed.
- [ ] **Display profiles, slice a (item 15)** — declare two
`nomarchy.displayProfiles` (e.g. docked disables eDP-1 + arranges
the externals; undocked re-enables it), `home-update`, then:
System Display Profiles lists them (● marks active); applying
is instant (no rebuild) and survives a relogin (the baked
overlay); "Base layout" restores; a menu resolution pick made
earlier must NOT re-enable a profile-disabled panel after the
next rebuild.
- [ ] **Display profiles workspace pins, slice c (item 15)** — give the
docked profile `workspaces = { "1" = "<ext>"; "9" = "eDP-1"; }`:
applying the profile moves open workspaces 1/9 to those outputs
immediately, new visits land there too, and the pins survive a
relogin (baked `workspace` rules). Switching to a profile without
pins leaves workspaces where they are (stale session pins are
expected until reload/rebuild — noted in the applier).
- [ ] **Display profiles auto-switch, slice b (item 15)** — same setup,
then menu Profiles Auto-switch on: plugging the dock/monitor
applies the matching profile within ~3s (toast names it);
unplugging switches back; Auto-switch off stops that; with two
profiles naming the same outputs, no flapping (ties do nothing).
- [ ] **P2 retunes eyeball (item 28b)** — spot-check on top of the P1
entry below: gruvbox/nord/lumon/everforest/retro-82/white dimmed
text (inactive workspaces, muted volume) now visible-but-dim;
latte + rose-pine(dawn) + summer-day warn/battery tint reads on
the light bases; flexoki-light statuses are the deeper canonical
600s; latte selected-row/alt-accent pink darker. Anything that
lost its "dim" feel → reopen 28b.
- [ ] **Retuned palettes eyeball (item 28b P1)** — switch through
summer-day, flexoki-light, kanagawa, miasma: chips/menus/toasts
now draw on a *raised* surface (summer-day's was slate-on-cream,
kanagawa's near-black, miasma's pure black), secondary text
(tooltips, fastfetch labels) is visible on summer-day +
flexoki-light, kanagawa floats are lighter than the bg (upstream
sumiInk4). Anything that reads worse than before → reopen 28b.
- [ ] **Clock zone tooltip (LATER item)** — hover the bar clock: the
tooltip's first line shows the zone ("BST (UTC+0100)") above the
calendar, on the generated AND summer bars; with auto-timezone
on, a zone change updates it (the SIGUSR2 reload).
- [ ] **Doctor bar tripwire (LATER item)** — with everything healthy
the bar shows nothing; `systemctl --user start doomed`-style
induced failure → within ~5 min a red appears (tooltip lists
the ✖ lines), clicking opens the sheet in a terminal;
reset-failed → it disappears on the next poll. Also visible on
both summer bars.
- [ ] **OCR region (LATER item)** — Tools Capture "OCR region →
clipboard": select a region with visible text → toast reports
the word count and `wl-paste` yields the text; Esc in slurp
cancels silently; a text-free region toasts "No text recognized"
without clobbering the clipboard.
- [ ] **Look & Feel submenu (item 19)** — root menu: "Look & Feel"
(replacing Theme, root still six rows) → Theme grid opens, Next
wallpaper cycles instantly, Night light toggles (and is GONE
from System). SUPER+T / SUPER+SHIFT+T direct binds unchanged.
- [ ] **Launch-or-focus (item 17)** — uncomment the template's
`nomarchy.launchOrFocus` firefox example (with firefox
installed), `home-update` + reload: SUPER+B launches firefox
when closed, FOCUSES the existing window when open (also from
another workspace); with firefox removed the bind fires a
"not installed" toast; the row shows under SUPER+?.
- [ ] **Themed greeter + console (item 16)** — after `sys-rebuild` +
logout: tuigreet renders in theme colors (dark container, accent
border, themed prompt/time) and a raw tty (CTRL+ALT+F2) shows the
theme's ANSI palette; on a LUKS machine the passphrase prompt
follows too. Then switch theme + `sys-rebuild` + logout → the
greeter follows the new palette. (tuigreet can't run under the
VM harness — see MEMORY.md — so rendering is hardware-tier.)
- [ ] **Color picker (item 13, final slice)** — SUPER+CTRL+P (or Tools
Color picker): hyprpicker's zoom loupe appears; click a pixel →
toast shows the hex and `wl-paste` yields it; Esc cancels with no
toast. Row present in the SUPER+? cheatsheet.
- [ ] **Low-battery toasts (item 13 slice)** — on battery, drain past
25%: one "Battery low" toast; past 10%: a critical "Battery
critical" toast that stays up until dismissed (swaync); plug in,
drain again → it re-notifies; no repeat toasts while it just keeps
draining. (Crossing logic VM-verified — this checks the real
swaync rendering in a session.)
## AMD dev box only
- [ ] **AMD runtime bits** — VA-API (`vainfo` → radeonsi), amd-pstate EPP
active and PPD switching governors; opt-ins: ROCm (`rocminfo`, a GPU
PyTorch/Ollama smoke) and the XDNA NPU driver loading.
- [ ] **Fingerprint** — `fprintd-enroll` + (opt-in PAM) login/sudo.
## Latitude 5410 only
- [x] **Waybar theme-switch resilience** (finding #1 re-test, needs main ≥
the supervisor commit + relogin) — switch themes repeatedly (incl.
summer-day/night whole-swaps): the bar restarts cleanly each time;
if anything kills it, it's back within ~a second. `pgrep -f
nomarchy-waybar` shows the supervisor. — 2026-07-04 Bernardo:
PASS — theme switches clean; pkill respawn so fast the bar never
visibly disappears.
- [x] **Media keys + gestures** (from dccceb4) — volume/brightness keys
drive the OSD; touchpad gestures work. — 2026-07-04 Bernardo:
PASS, volume/brightness keys and touchpad gestures all working.
- [ ] **v1 QA batch on-hardware pass** (583708d batch was QEMU-verified) —
general smoke before the next `main → v1` promotion.
## T14s only
- [ ] **Webcam IR-hide end-to-end on Nomarchy** — installer detects the
RGB+IR pair, bakes `hardware.camera.hideIrSensor`; `wpctl status`
shows one colour source; an app picker lists one camera; Howdy-style
direct `/dev/video2` reads still work.

1024
agent/JOURNAL.md Normal file

File diff suppressed because it is too large Load Diff

147
agent/LOOP.md Normal file
View File

@@ -0,0 +1,147 @@
# The loop — autonomous iteration protocol
How an AI agent works on Nomarchy unattended. One **iteration** = pick one
task, do it, verify it, commit it, record it. The protocol is
runner-agnostic; the same iteration works under any of:
- **Interactive `/loop`** in a Claude Code session in this repo — the agent
self-paces iterations until stopped.
- **Headless** (`claude -p`, cron/systemd-timer) — one invocation runs one
iteration (or a small fixed number) and exits.
- **A fresh manual session** — a human says "do a loop iteration"; the
files below carry all the state, so any session can pick up where the
last left off.
All loop state lives in this directory, git-tracked. There is no state
outside the checkout (the distro's own philosophy, applied to its agents).
## The files
| File | Role | Who writes it |
|---|---|---|
| `GOALS.md` | North star + quality bars + non-goals | Human (agents propose edits) |
| `BACKLOG.md` | Prioritized task queue (NOW/NEXT/LATER/PROPOSED/DECISIONS) | Both — see its header rules |
| `JOURNAL.md` | Append-only iteration log | Agents |
| `MEMORY.md` | Curated durable lessons/gotchas | Agents (curated, not append-only) |
| `HARDWARE-QUEUE.md` | Pending on-hardware checks only Bernardo can run | Agents append, human checks off |
| `CONVENTIONS.md` | Repo/design conventions to follow while coding | Human (agents propose edits) |
## Model & token economy
Spend expensive tokens on judgment, not mechanics.
- **Plan and reason on the strong model.** Orientation, task selection,
design, debugging, Nix eval semantics, verification judgment, and
anything that would land in a commit unreviewed stay with the
top-tier model running the loop (Fable 5).
- **Delegate mechanical subtasks to cheaper models.** When a subtask is
fully specified and needs no design judgment — grep/audit sweeps,
README-option-table reconciliation, a repeated edit applied across
files, summarizing long logs or check output — hand it to a subagent
with a `model` override: `haiku` for search/summarize/audit, `sonnet`
for routine well-specified edits. The strong model writes the spec,
reviews the result, and owns the commit. Only delegate when writing
the spec is cheaper than doing the work — a spawned agent starts cold
and must re-derive context.
- **Read narrowly.** Step 0's list is the whole orientation read (the
*last 35 entries* of the journal, never the full file). Read large
files by section, don't re-read what's already in context, and tail
build/check logs instead of dumping them.
- **Write tersely.** Journal entries follow the template and no more;
commit bodies state what/why/tier, not a narrative.
- **Headless runners** may run whole low-stakes iterations (QA sweeps,
docs-drift passes) on a cheaper `--model`; iterations touching
`modules/` or `pkgs/` behavior keep the strong model.
## One iteration, step by step
### 0. Orient
1. Read `GOALS.md`, `CONVENTIONS.md`, `MEMORY.md`, the **last 35 entries**
of `JOURNAL.md`, and `BACKLOG.md`.
2. `git pull --ff-only` (skip silently if offline). Confirm you are on
`main` with a clean tree. **A dirty tree you didn't create → stop and
report; never stash or discard someone else's work.**
3. Sanity baseline: if the last journal entry reports a red
`nix flake check`, or you have any reason to suspect breakage, run
`nix flake check --no-build` first. **A red baseline preempts the
backlog — fixing it *is* this iteration's task.**
### 1. Pick exactly one task
- Take the **topmost actionable** item: NOW before NEXT; never LATER
unless NOW and NEXT are empty or all blocked.
- *Actionable* means: not `[blocked:hw]` (those wait in
`HARDWARE-QUEUE.md`), not `[human]` (decisions), and small enough to
finish + verify in one iteration. If the top item is too big, **split
it in BACKLOG.md** (that edit is part of the iteration) and take the
first slice.
- Never implement anything from **PROPOSED** — those await human triage.
- If nothing is actionable, do a **QA sweep** instead: run the full check
suite, hunt drift (README option tables vs the live `nomarchy.*`
surface, template drift, dead code), deepen a VM test, or research and
write up a PROPOSED item. An iteration that only improves the backlog
is a valid iteration. If even that yields nothing, journal it and stop
— do not manufacture churn.
### 2. Work
- Keep the diff focused on the task. Unrelated fixes you trip over become
PROPOSED/NOW entries, not scope creep.
- Mechanical, fully-specified sub-steps go to a cheaper model
(see *Model & token economy* above); design and review stay here.
- Follow `CONVENTIONS.md`. Match the surrounding hand-formatting; never
run a formatter.
- New gotcha discovered the hard way → one line in `MEMORY.md` now, while
it's fresh.
### 3. Verify — the ladder
Climb as high as the change warrants and your environment allows; **record
the tier reached** in the commit body and journal entry.
| Tier | What | When required |
|---|---|---|
| **V0** | `nix flake check --no-build` (+ `bash -n` / `py_compile` for scripts) | Every change, no exceptions |
| **V1** | Build the touched output: `system.build.toplevel`, the HM generation, the ISO, or the package | Anything beyond docs/comments |
| **V2** | VM: a `checks.*` runNixOSTest (add one if the change is guardable), or boot `tools/test-live-iso.sh` / `tools/test-install.sh` | Behavioral changes — services, boot, installer, session |
| **V3** | Real hardware | Cannot be done by the agent → append to `HARDWARE-QUEUE.md` with exact test steps |
The honesty rule governs: a visual/interactive change verified only to V1
is **not done** — it ships as "V1-verified, V2/V3 pending" with the pending
check queued. Prefer *adding a permanent `checks.*` test* over a one-off
manual VM poke when the behavior is testable headlessly (see MEMORY.md for
the reusable recipes).
### 4. Commit + push
- Style: match the log — `feat(scope): …`, `fix(scope): …`,
`test(scope): …`, `docs(scope): …`. Body explains what/why + the
verification tier reached and what remains.
- Include the `agent/` bookkeeping updates (backlog/journal/memory/queue)
**in the same commit** as the change they describe.
- Commit directly on `main` and `git push` (Bernardo's standing workflow).
- **Never:** force-push; touch the `v1` branch or any branch/tag you
didn't create; commit secrets or binaries; run `nix flake update`
unless the task is explicitly a lock bump; delete themes, wallpapers,
or user-facing assets without the backlog saying so.
### 5. Record
1. Mark the task in `BACKLOG.md` (move to its ✓ line or delete, per that
file's rules).
2. Append a `JOURNAL.md` entry (template in that file).
3. Queue any V3 checks in `HARDWARE-QUEUE.md`.
### 6. Pace (self-paced runners only)
Under `/loop`, continue to the next iteration while tasks remain
actionable and checks stay green. Stop the loop when: nothing is
actionable, the same task has failed twice (journal the failure analysis
and mark the item `[stuck]`), or a `[human]` decision blocks everything
remaining.
## Stop-and-escalate conditions (any runner)
Write a journal entry + a BACKLOG note, then stop, when:
- A fix would require touching `v1`, force-pushing, or a nixpkgs release
jump.
- The working tree contains uncommitted work you didn't create.
- A task turns out to need a design decision Bernardo hasn't made → move
it to **Decisions** in BACKLOG.md with the options laid out.
- Two consecutive iterations failed on the same task (`[stuck]`).
- Anything would delete or rewrite user data, git history, or the state
file schema in a non-backward-compatible way.

97
agent/MEMORY.md Normal file
View File

@@ -0,0 +1,97 @@
# Memory — durable lessons, learned the hard way
Curated, not append-only: one line per fact, newest at the top of its
section; delete entries that stop being true. Details usually live in a
docs/ROADMAP.md decision record — pointer given as (§ item). Add a fact
here the moment a debugging session teaches you something a future
iteration would otherwise rediscover.
## Testing & VM recipes
- **tuigreet dies silently under runNixOSTest** (even bare, no theme
flag: greetd sits as "(greetd)" with no child, nothing in the
journal — its stderr goes to the VT) — nixpkgs' own greetd test uses
agreety instead. Greeter *rendering* is interactive-ISO/hardware
tier; don't burn another session on a checks.greeter VM test.
- In VM tests `pgrep -f PATTERN` can match the test backdoor's own
`bash -c` wrapper (the pattern is in its cmdline) — use `pgrep -x`
or a `[t]uigreet`-style bracket pattern.
- A checks.* fixture CANNOT be a writeText/toFile state file read at
eval time ("path … is not valid" — flake check's eval store won't
realise it): extract the logic into a pure importable file and
unit-test THAT (monitor-rules.nix / checks.display-profiles is the
pattern).
- CI (`.gitea/workflows/check.yml`) is **eval-tier only**: the act_runner
is a docker container (no systemd, no /dev/kvm). Container gotchas are
documented in the workflow header (single-user Nix + nixbld users,
`sandbox=false` for Stylix IFD, Nix pinned 2.31.5 vs lazy-trees, no JS
actions past node20) — learned over the legacy repo's 57 runs; read
them before touching the workflow.
- The Gitea instance is **1.25.4**`on: schedule` workflows are
supported; bump.yml assumes the Actions token can push to `main`
(standard Gitea behaviour, but unconfirmed until the first run lands).
- The git server is **Gitea** (gitea/act_runner via docker-compose), NOT
Forgejo — workflows are read from `.gitea/workflows/` (or `.github/`),
never `.forgejo/workflows/` (a whole push cycle was lost to that).
- Reusable headless VM harness: `checks.*` via runNixOSTest — existing
examples to crib from: `distro-id` (boots + `switch-to-configuration
dry-activate`), `hardware-toggles` (kernel cmdline/PAM assertions),
`battery-charge-limit` (fake Mains adapter via `test_power`, real udev
uevent, `InvocationID` change proves the restart).
- Themed-desktop screenshots work headlessly: software-GL Hyprland
(`LIBGL_ALWAYS_SOFTWARE` on virtio-gpu) + `machine.screenshot()` QMP
dump — prototyped 2026-06-19, kept as the fallback for theme previews
(§ Visual theme picker).
- Hyprland/Ghostty need guest GL (`virtio-vga-gl`, `gl=on`) in
interactive QEMU or the session won't start; black screen ≈ missing GL
(docs/TESTING.md § gotchas).
- No KVM = slow, not broken; don't read slowness as failure.
## Known-broken / watchlist
- **btrfs-assistant "segfault" was unprivileged-only** (re-diagnosed
2026-07-04): libbtrfsutil's unprivileged subvolume iteration crashes on
btrfs-progs 6.17.1 (upstream-fixed after); **as root it works**, and the
pkexec launcher runs it as root. The real distro bug was **no polkit
agent in the session** (every pkexec failed silently) — hyprpolkitagent
now ships (hyprland.nix exec-once). `checks.snapshot-gui` guards the
root path. Lesson: before "app X is broken", check WHO it runs as — and
whether polkit prompts can render at all (§ Snapshot browse/restore).
- **NixOS release bump is a trap:** the discarded attempt
(branch deleted 2026-06-22) hit a Hyprland OOM blocker; a redo is a
deliberate `v2`, never part of routine lock bumps.
- `theme-state.json` is git-tracked inside an 86 MB flake tree, so every
state write re-copies the source before eval — the wallpapers-artifact
split (BACKLOG LATER) is the decided fix (§ Faster switches).
## Gotchas (cost a debugging session once)
- Hyprland binds match the exact modmask: a shifted keysym (`question`)
needs SHIFT in `mods` or the bind never fires — the keypress falls
through to the focused window (§ item 26; caught on hardware, invisible
to eval-tier tests).
- Never kill a Wayland session-lock client (hyprlock): its crash
failsafe drops to a tty instead of unlocking (§ Hibernate
double-unlock).
- rofi `element-icon size` is one value = a square cell; `WxH` silently
collapses and non-square icons letterbox — pre-crop images square at
build (§ Visual theme picker).
- WirePlumber 0.5 monitor rules can only early-match `device.api`;
`device.product.name` etc. bind *after* the rule runs — surgical
libcamera scoping is impossible (§ Webcam).
- `hyprctl switchxkblayout` is a *global* layout flip; per-device isolation
needs `device[<name>]:kb_layout` keywords (§ Keyboard layouts).
- Waybar's clock captures the timezone at construction — a zone change
needs SIGUSR2 (watcher in `timezone.nix`) (§ Automatic timezone).
- Waybar `persistent_workspaces` (underscore) is dead syntax silently
ignored; the hyphen form is honoured and renders phantom workspaces
(§ Waybar shows non-existent workspaces).
- GTK4/libadwaita/Qt6 read light/dark from the portal's
`org.freedesktop.appearance color-scheme` (dconf), not Stylix polarity
(§ GTK/Qt ignore the theme's mode).
- Update order matters downstream: `sys-update` (lock) before
`home-update`, or desktop changes are silently skipped against the old
lock (README § 3).
- grub `loadfont`s every `.pf2` in a theme dir — reuse a bundled DejaVu
rather than shipping fonts (§ Distro branding).
- The Claude Code harness drops machine-local files into the repo
(`.claude/skills/…`) mid-session — never `git add -A` blindly; check
`git status --short` for strangers first (they're gitignored now, but
the next surprise won't be) (§ loop hygiene).

View File

@@ -39,7 +39,7 @@ in your `home.nix` wins — no `mkForce` needed:
input.follow_mouse = 0; # was 1 input.follow_mouse = 0; # was 1
input.touchpad.natural_scroll = false; input.touchpad.natural_scroll = false;
misc.disable_splash_rendering = false; misc.disable_splash_rendering = false;
monitor = [ "DP-1,2560x1440@144,0x0,1" ]; # replaces the default rule monitor = [ "DP-1,2560x1440@144,0x0,1" ]; # raw rule — replaces the default
animations.enabled = false; animations.enabled = false;
}; };
@@ -50,6 +50,11 @@ in your `home.nix` wins — no `mkForce` needed:
} }
``` ```
For monitor layout, prefer the friendlier **`nomarchy.monitors`** (a list of
per-output submodules — resolution/position/scale/rotation — turned into
Hyprland rules and applied on hotplug; run `nwg-displays` to find the values
interactively). Assigning `settings.monitor` directly, as above, replaces it.
### Adding vs. overriding lists ### Adding vs. overriding lists
`bind`, `bindel`, `bindl`, `bindm` and `exec-once` are lists kept at normal `bind`, `bindel`, `bindl`, `bindm` and `exec-once` are lists kept at normal
@@ -140,6 +145,7 @@ value is theme-owned at normal priority — either change it via the CLI (§1) o
|---|---| |---|---|
| Change gaps / colors / rounding / fonts | `nomarchy-theme-sync set …` or `apply` | | Change gaps / colors / rounding / fonts | `nomarchy-theme-sync set …` or `apply` |
| Change input / misc / monitor / animations / terminal chrome | plain assignment in `home.nix` | | Change input / misc / monitor / animations / terminal chrome | plain assignment in `home.nix` |
| Arrange monitors declaratively | `nomarchy.monitors` (values via `nwg-displays`) |
| Add keybinds / autostarts | add to the `bind` / `exec-once` list (concatenates) | | Add keybinds / autostarts | add to the `bind` / `exec-once` list (concatenates) |
| Replace all keybinds | `bind = lib.mkForce [ … ]` | | Replace all keybinds | `bind = lib.mkForce [ … ]` |
| Hardcode an appearance value against the theme | `lib.mkForce` in `home.nix` | | Hardcode an appearance value against the theme | `lib.mkForce` in `home.nix` |

92
docs/RECOVERY.md Normal file
View File

@@ -0,0 +1,92 @@
# Recovery runbook — when something breaks
Ordered from "the desktop looks wrong" to "the machine won't boot".
Everything here already ships on an installed machine — you don't need a
live USB until the last resort. The theme is always the same: **nothing
in Nomarchy is destroyed by a bad change** — every rebuild is a NixOS /
Home Manager generation you can step back to, and (on BTRFS installs)
snapper keeps file-level history on top.
If the graphical session is unusable, a text console is one keystroke
away: **Ctrl+Alt+F2** gives a TTY login (the session itself runs on
tty1); log in with your normal user.
## 1. A theme or desktop change broke the session
Theme applies and `home-update` are Home Manager switches — one
generation each, so the previous desktop is still on disk:
```sh
home-manager generations # newest first, one per theme/HM change
/nix/store/…-home-manager-generation/activate # run the one you want
```
The same picker lives in the menu: **System Rollback** lists the
recent desktop generations — pick one and it activates.
Or simply apply a theme you know is good: `nomarchy-theme-sync apply
tokyo-night`. If a switch failed halfway, the state file is written
*before* the rebuild — fix the cause and re-run
`home-manager switch --flake ~/.nomarchy` (or `home-update`).
Your flake checkout is a git repo, and with auto-commit enabled every
apply is a commit: `git -C ~/.nomarchy log` to see what changed,
`git revert` the culprit, then `home-update`.
## 2. The desktop won't start at all
Greeter loops, black screen after the password, session exits straight
back to tuigreet — from the Ctrl+Alt+F2 TTY:
```sh
journalctl -b -u greetd # did the session command launch?
journalctl --user -b # Hyprland + the user services
```
- Rolling back the *desktop* half is §1 (works from the TTY).
- If greetd/tuigreet itself is broken, that's system-side → §3.
- In a **VM**, a black screen is almost always missing guest OpenGL,
not your config — see docs/TESTING.md §5.
- First boot after an install came up *unthemed*: read
`/var/log/nomarchy-hm-preactivate.log` on the installed system.
## 3. A system change broke it — boot an older generation
Reboot and pick an older **NixOS generation** in the systemd-boot menu
(hold a key during firmware handoff if the menu doesn't linger; the
last 10 generations are kept). That boots yesterday's system unchanged.
Booting an old generation is temporary — the default entry is still the
broken one. Make the fix stick from the working boot: revert the change
in `~/.nomarchy` (`git -C ~/.nomarchy revert …` or edit `system.nix`
back), then `sys-rebuild`.
## 4. Files went missing or wrong — snapshots (BTRFS installs)
With `nomarchy.system.snapper.enable` (the installer's default on
BTRFS), the root filesystem has hourly/daily history, and
`nixos-rebuild-snap` leaves a snapshot right before a rebuild:
- **GUI:** menu System Snapshots (btrfs-assistant; expects a polkit
password prompt).
- **Terminal/SSH:** `sudo nomarchy-snapshots` — browse a snapshot's
diff, **restore changed files** (snapper `undochange`), or **roll the
whole root back** to a snapshot and reboot. Both destructive actions
sit behind a typed-`yes` confirmation.
Snapshots are the undo for *data on disk*; the Nix config model is
undone by generations (§1/§3) — use each for its half.
## 5. Last resort — from the outside
Boot the Nomarchy ISO (any NixOS ISO works), then:
```sh
sudo mount /dev/<root> /mnt # + /mnt/boot; LUKS: cryptsetup open first
sudo nixos-enter --root /mnt # chroot with nix available
```
From there you have the full toolbox: `nixos-rebuild boot --flake
/home/<you>/.nomarchy#default` after fixing the flake, or snapper from
§4. If you get this far with something Nomarchy shipped broken, please
file it.

View File

@@ -5,6 +5,12 @@ Forward-looking plans, plus a running log of shipped fixes (the
stays a focused entry point — what Nomarchy is, how to install it, and stays a focused entry point — what Nomarchy is, how to install it, and
how to override it. Items marked ✓ are shipped. how to override it. Items marked ✓ are shipped.
> **The live, prioritized queue now lives in [`agent/BACKLOG.md`](../agent/BACKLOG.md)**
> (part of the autonomous-agent loop, `agent/LOOP.md`). This file remains
> the detailed design/decision record and the shipped log — backlog items
> reference it as "ROADMAP § <item>". When a backlog item ships, its
> lasting design notes get a ✓ entry here.
## Roadmap ## Roadmap
- **Menu system** (apps launcher + theme switching + system actions), built - **Menu system** (apps launcher + theme switching + system actions), built
on rofi 2.0 (native Wayland on 26.05) — its `.rasi` theme is baked from on rofi 2.0 (native Wayland on 26.05) — its `.rasi` theme is baked from
@@ -51,6 +57,11 @@ how to override it. Items marked ✓ are shipped.
one-off — any new feature that earns a menu entry must be placed in the 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 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. 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 -**Menu modules from rofi plugins:** the old `calc` flow committed the
expression blind (result only in the *next* menu's `-mesg`) and `qalc -t` 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- misparsed common phrasings (`15% of 200``rem(15, 1 B)`, the natural-
@@ -67,25 +78,136 @@ how to override it. Items marked ✓ are shipped.
launcher, and yazi (SUPER+E) already covers real browsing. launcher, and yazi (SUPER+E) already covers real browsing.
- **More menu modules from rofi tools:** the script-based counterparts - **More menu modules from rofi tools:** the script-based counterparts
(run via `rofi -dmenu`, like the hand-rolled modules), each a deliberate (run via `rofi -dmenu`, like the hand-rolled modules), each a deliberate
replacement of an existing flow: **rofi-network-manager** (a keyboard replacement of an existing flow.
wifi/VPN picker vs today's `nmtui`-in-terminal `network`), **rofi-rbw / -**Network** (`networkmanager_dmenu`): a native rofi wifi/VPN picker
rofi-pass** (a secrets module — Bitwarden via rbw, or `pass` — pairs with replacing the old `nmtui`-in-terminal `network` flow. Configured (xdg
`keys.nix`), and **rofi-pulse-select** (an audio sink/source switcher). `networkmanager-dmenu/config.ini`) to drive rofi with `rofi_highlight`
Decide per-module whether it earns replacing the current path. 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 - **Theme parity with legacy:** summer-day/night now carry their legacy
bar layouts as `waybar.jsonc` whole-swaps (adapted: dead legacy script bar layouts as `waybar.jsonc` whole-swaps (adapted: dead legacy script
modules dropped, Nerd-Fonts-v2 codepoints remapped to FontAwesome/v3, modules dropped, Nerd-Fonts-v2 codepoints remapped to FontAwesome/v3,
logo button opens nomarchy-menu); the other four identity themes are logo button opens nomarchy-menu); the other four identity themes are
palette recolors and already match. Remaining: a visual pass over all palette recolors and already match. Remaining: a visual pass over all
six on the live ISO six on the live ISO
- **Per-theme rofi identity:** the `themes/<slug>/rofi.rasi` whole-swap - ✓ **Per-theme rofi identity:** the `themes/<slug>/rofi.rasi` whole-swap
ships, and summer-day/night carry their legacy designs (inverted window, ships, and all six identity themes now carry a designed `.rasi`. summer-day/
green inputbar, yellow bottom-border). Remaining: author `.rasi` night keep their legacy ports (inverted window, green inputbar, yellow
identities for the other four ported themes if/when they want one (the bottom-border); the other four were authored from each theme's character
generated palette theme is the default and looks fine) (no legacy layout to port — their waybar whole-swaps are palette-only):
- **Faster switches:** move `backgrounds/` out of the flake source (the 86 MB **nord** a soft rounded "frost panel" (frost border, brighter-frost
re-copy on every state write is the main eval tax), then pre-built theme selection, aurora-purple prompt); **retro-82** a sharp CRT terminal (square
variants if still needed 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
`theme-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-theme-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 `theme-state.json` out to `~/.config` + `--impure` eval (one repo, no
second artifact, but trades away the in-tree-pinned-state reproducibility).
Follow-on if `home-manager switch` itself is still the bottleneck after the
copy is gone: **pre-built theme variants** (build each theme's generation
ahead of time so a switch just activates a cached one).
- Greeter (tuigreet/SDDM) theming from the same JSON (Plymouth ships since - Greeter (tuigreet/SDDM) theming from the same JSON (Plymouth ships since
v1: `nomarchy.system.plymouth.*`, background tinted from the state file) v1: `nomarchy.system.plymouth.*`, background tinted from the state file)
- Installer round 2: multi-disk BTRFS RAID, impermanence, BIOS/legacy - Installer round 2: multi-disk BTRFS RAID, impermanence, BIOS/legacy
@@ -99,10 +221,28 @@ how to override it. Items marked ✓ are shipped.
✓ `isoImage.splashImage` — the vendored vector logo ✓ `isoImage.splashImage` — the vendored vector logo
(`modules/nixos/branding/logo.svg`, from legacy) recolored to the palette (`modules/nixos/branding/logo.svg`, from legacy) recolored to the palette
accent on the theme base, built at ISO-build time (`hosts/live.nix`). accent on the theme base, built at ISO-build time (`hosts/live.nix`).
Remaining: `isoImage.grubTheme` so UEFI boot matches the isolinux splash ✓ **`isoImage.grubTheme` (UEFI boot matches BIOS):** `hosts/live.nix` now
(needs a full grub theme dir), and the `distroId` question (it changes builds a `nomarchyGrubTheme` dir whose background is the *same* composed
`DEFAULT_HOSTNAME` and upstream `isNixos` checks — needs a test pass; splash image as the isolinux splash (accent logo on base), with a
nixos-* CLI names stay regardless) 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` - ✓ **fastfetch branding:** `modules/home/fastfetch.nix`
(`nomarchy.fastfetch.enable`) — the vendored vector logo, recolored to (`nomarchy.fastfetch.enable`) — the vendored vector logo, recolored to
the palette accent and rendered to compact block-art via chafa at build the palette accent and rendered to compact block-art via chafa at build
@@ -176,8 +316,17 @@ how to override it. Items marked ✓ are shipped.
comes from the agent's zsh integration; cache TTLs (30 min / 2 h) govern 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 re-prompting. gnome-keyring stays the Secret Service (modern versions run
no SSH agent, so no socket contention); screen lock doesn't flush the no SSH agent, so no socket contention); screen lock doesn't flush the
cache. Remaining (optional): a session-level `SSH_AUTH_SOCK` export so GUI cache. ✓ **session-level `SSH_AUTH_SOCK`:** besides the zsh integration,
clients launched outside a shell also see the agent. 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 - **Sanitize & organize the repo:** a housekeeping pass for consistency
and clarity. and clarity.
- ✓ pruned now-redundant config: the installer no longer writes - ✓ pruned now-redundant config: the installer no longer writes
@@ -204,18 +353,15 @@ how to override it. Items marked ✓ are shipped.
hand-formatting, so `nixfmt-rfc-style --check` flags ~33 files. Adopting a hand-formatting, so `nixfmt-rfc-style --check` flags ~33 files. Adopting a
formatter would be a one-time repo-wide reformat that flattens that formatter would be a one-time repo-wide reformat that flattens that
alignment — a maintainer call, not a silent cleanup, so left untouched. alignment — a maintainer call, not a silent cleanup, so left untouched.
- **Full docs review & restructure:** a dedicated pass over all the prose, - ✓ **Full docs review & restructure** (completed 2026-07-04 in four
not just the code-adjacent cleanup the repo-sanitize item covers. The slices): the roadmap/backlog split (this file + agent/BACKLOG.md);
README has grown to ~540 lines with a ~200-line roadmap inline — the option tables reconciled with the live surface and guarded permanently
biggest single move is likely **splitting the roadmap out** (e.g. by `checks.option-docs`; drift pass over OVERRIDES/TESTING/template
`ROADMAP.md` / `docs/`) so the README stays a focused "what it is / how README (claims held; two gaps fixed); the install/first-run story now
to install / how to override" entry point. Also: reconcile every option hands off README §3 → template README explicitly; and a new
table against the live `nomarchy.*` surface (snapper and others are **docs/RECOVERY.md** runbook (generations → journals → older boot
missing); a pass over `docs/OVERRIDES.md` + `docs/TESTING.md` and the generation → snapper → nixos-enter). Still open, deliberately: the
`templates/downstream/README.md` for drift; check the install/first-run docs-site-vs-Markdown call (BACKLOG § Decisions).
story reads cleanly end to end; and decide whether anything wants a real
docs site vs. staying Markdown-in-repo. Pairs with the first-boot welcome
and control-center items (shared "how do I…" surface).
- **Laptop power / battery management:** a real power story behind a - **Laptop power / battery management:** a real power story behind a
`nomarchy.system.power.*` surface (`modules/nixos/power.nix`), replacing the `nomarchy.system.power.*` surface (`modules/nixos/power.nix`), replacing the
old "only `services.upower` for Waybar reporting" baseline: old "only `services.upower` for Waybar reporting" baseline:
@@ -231,7 +377,21 @@ how to override it. Items marked ✓ are shipped.
turns it on for a `GenuineIntel` CPU. turns it on for a `GenuineIntel` CPU.
- ✓ **longevity:** `power.batteryChargeLimit` (e.g. 80) — a backend- - ✓ **longevity:** `power.batteryChargeLimit` (e.g. 80) — a backend-
independent sysfs oneshot writes `charge_control_end_threshold`. Off by independent sysfs oneshot writes `charge_control_end_threshold`. Off by
default; the installer scaffolds it commented-out on laptops. 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"` restarts 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), and `restart` (not `try-restart`) re-applies
even if the boot run was inactive. 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 restarts the oneshot — confirmed by a changed `InvocationID`).
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 - ✓ **installer:** writes `power.laptop = true` (battery probe) and
`power.thermal.enable` (Intel) into the generated `system.nix`. `power.thermal.enable` (Intel) into the generated `system.nix`.
- ✓ **idle cohesion:** `modules/home/idle.nix` now suspends only on - ✓ **idle cohesion:** `modules/home/idle.nix` now suspends only on
@@ -241,8 +401,6 @@ how to override it. Items marked ✓ are shipped.
logind's lid handling is left at its defaults (suspend on lid close, 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 ignore when docked) — an explicit "I'm done" that coheres with the
idle behaviour. idle behaviour.
- Remaining: a boot-only→event-driven charge-limit re-apply (udev) if a
firmware resets the threshold on unplug.
- ✓ **Waybar parity:** the `custom/powerprofile` indicator now shows in the - ✓ **Waybar parity:** the `custom/powerprofile` indicator now shows in the
summer-day/night whole-swap themes too. `powerProfileStatus`/`Cycle` are summer-day/night whole-swap themes too. `powerProfileStatus`/`Cycle` are
named `writeShellScriptBin`s on PATH (`waybar.nix` home.packages), so the named `writeShellScriptBin`s on PATH (`waybar.nix` home.packages), so the
@@ -263,6 +421,31 @@ how to override it. Items marked ✓ are shipped.
Complements nixos-hardware (model quirks) rather than replacing it; keep Complements nixos-hardware (model quirks) rather than replacing it; keep
the detection in the installer's `hardware-db` so an installed machine the detection in the installer's `hardware-db` so an installed machine
bakes the right defaults, all overridable through `nomarchy.hardware.*`. 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): Worked example — ThinkPad T14s (Ryzen 7 PRO 7840U + Radeon 780M):
- **GPU compute — ROCm:** the 780M is an RDNA3 iGPU (gfx1103) ROCm doesn't - **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 officially list, so it needs `HSA_OVERRIDE_GFX_VERSION=11.0.0`. Multi-GB
@@ -287,6 +470,91 @@ how to override it. Items marked ✓ are shipped.
distro-wide regardless). distro-wide regardless).
Sub-items here can graduate into their own roadmap entries as they're Sub-items here can graduate into their own roadmap entries as they're
scoped; the unifying work is the detection + `nomarchy.hardware.*` surface. 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; optional `v4l-utils` +
`cameractrls` for the rare genuine-tuning case; and a follow-up for
portal/Flatpak apps that consume the libcamera path (where the internal IR is
still listed, since libcamera stays on).
- ✓ **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* - **Opt-in services & integrations:** the counterpart to the opt-*out*
application suite above — heavier or more personal integrations shipped application suite above — heavier or more personal integrations shipped
**off by default**, each a `nomarchy.services.<name>.enable` toggle a **off by default**, each a `nomarchy.services.<name>.enable` toggle a
@@ -344,19 +612,74 @@ how to override it. Items marked ✓ are shipped.
(no kanshi — it fights Hyprland's own output management). `nwg-displays` (no kanshi — it fights Hyprland's own output management). `nwg-displays`
ships behind `nomarchy.displays.enable` as an interactive arranger (a ships behind `nomarchy.displays.enable` as an interactive arranger (a
helper to find values; the declarative config stays the source of truth — helper to find values; the declarative config stays the source of truth —
its output file isn't sourced). Remaining: true docked/undocked **profile its output file isn't sourced). The System ▸ **Display** menu picks an
switching** of the *same* outputs (Hyprland's per-output rules cover the output's resolution from its advertised modes: applied live via `hyprctl
common "external connects → arrange" case, not multi-layout toggles), and keyword monitor` (current position + scale kept) and persisted to the
optionally workspace-to-monitor binding. 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) — - ✓ **Night light / blue-light filter:** `nomarchy.nightlight` (opt-in) —
a scheduled colour-temperature shift via `hyprsunset` (Hyprland-native), a scheduled colour-temperature shift via `hyprsunset` (Hyprland-native),
warm at night, identity (no shift) by day. `modules/home/nightlight.nix` warm at night, identity (no shift) by day. `modules/home/nightlight.nix`
drives the HM `services.hyprsunset` with two time-based profiles drives the HM `services.hyprsunset` with two time-based profiles
(`sunrise` → identity, `sunset` → `temperature`), so hyprsunset handles the (`sunrise` → identity, `sunset` → `temperature`), so hyprsunset handles the
schedule and the on-login state. Needs an on-hardware check that hyprsunset schedule and the on-login state. Active-profile-at-session-start was
applies the active profile at session start. Remaining (optional): a menu + confirmed on hardware (2026-06-18). ✓ **Menu + Waybar toggle (opt-in,
Waybar toggle to force it on/off, and geo (lat/long) auto sunset/sunrise instant, in-flake state):** two git-tracked keys in the state file, both
(would mean wlsunset, which schedules by location). 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-theme-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). Remaining (optional): geo (lat/long) auto
sunset/sunrise (would mean wlsunset, which schedules by location). Pending an
on-machine check (first enable rebuilds + comes on; later toggles instant; off
persists across reboot via ExecCondition; stopping hyprsunset restores gamma).
- ✓ **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 theme-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):** - **Keyboard layouts (per-device + switching):**
- ✓ **Per-device declarative layout:** `nomarchy.keyboard.devices` - ✓ **Per-device declarative layout:** `nomarchy.keyboard.devices`
(`{ "<hyprctl-device-name>" = { layout; variant; }; }`) generates Hyprland (`{ "<hyprctl-device-name>" = { layout; variant; }; }`) generates Hyprland
@@ -372,20 +695,46 @@ how to override it. Items marked ✓ are shipped.
watch` daemon runs (exec-once): it polls `hyprctl devices`, and when a watch` daemon runs (exec-once): it polls `hyprctl devices`, and when a
keyboard connects *after* login that isn't in `keyboard.devices` and keyboard connects *after* login that isn't in `keyboard.devices` and
hasn't been chosen before, it pops a rofi layout picker, applies the hasn't been chosen before, it pops a rofi layout picker, applies the
choice with `hyprctl switchxkblayout` (an index into the candidate set, choice as a **per-device** `hyprctl keyword device[<name>]:kb_layout` (the
which `input.kb_layout` carries), and remembers it per-device in runtime twin of the declarative `device{}` blocks — so it isolates that
`~/.local/state/nomarchy/keyboard-layouts` — re-applied silently on later one keyboard and never touches the built-in board), and remembers it. The
reconnects. The boot-time set (incl. the built-in keyboard) is never boot-time set (incl. the built-in keyboard) is never prompted. The
prompted. The runtime-remember complement to the declarative runtime-remember complement to the declarative `keyboard.devices`.
`keyboard.devices`; a stateful runtime piece by design. **Pending an **Picker verified on hardware (2026-06-18)**; the first cut applied the
on-hardware test** (hotplug isn't verifiable in CI). Remaining (bonus): choice with `switchxkblayout` (a global layout-index flip) and merged the
offer to write a choice into `keyboard.devices` so it graduates to the candidate pool into `input.kb_layout`, so a selection leaked onto the
reproducible config. laptop keyboard and stuck after unplug — fixed by the per-device keyword +
- Remaining: a multi-layout cycle bind (`hyprctl switchxkblayout` / xkb keeping the pool out of the session layout.
`grp:` options) for switching layouts on one keyboard; add the - ✓ **In-flake state + graduation (2026-06-23):** the remembered picks moved
`hyprland/language` module to the summer whole-swap themes for parity out of `~/.local/state/nomarchy/keyboard-layouts` into the git-tracked
(the gating doesn't translate to their static JSON). Keep the system state file (`settings.keyboard.devices`, a device-name → layout map) —
(console/initrd) and session layouts in sync (the LUKS-keymap work). 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 - ✓ **Do-Not-Disturb:** swaync DND toggle wired into the menu
(`nomarchy-menu dnd`, in the picker, SUPER+CTRL+D) and a Waybar bell (`nomarchy-menu dnd`, in the picker, SUPER+CTRL+D) and a Waybar bell
indicator (`custom/notification` via `swaync-client -swb`: shows the indicator (`custom/notification` via `swaync-client -swb`: shows the
@@ -404,21 +753,92 @@ how to override it. Items marked ✓ are shipped.
change). **The backend is VM-verified** (a full BTRFS+LUKS install: both change). **The backend is VM-verified** (a full BTRFS+LUKS install: both
`root`+`home` configs, a `/home` timeline snapshot taken, `/home/.snapshots` `root`+`home` configs, a `/home` timeline snapshot taken, `/home/.snapshots`
a real subvolume, the oneshot `Result=success`). a real subvolume, the oneshot `Result=success`).
-**Known bug — btrfs-assistant 2.2 segfaults on launch** in nixpkgs - ✓ **Resolved — the "btrfs-assistant 2.2 segfault" was unprivileged-only**
26.05: it crashes inside `libbtrfsutil.so.1.4.0` (a library ABI mismatch, (re-diagnosed 2026-07-04): gdb puts the crash in `btrfs_util_subvolume_
confirmed in the VM regardless of GL/Qt platform — so it's not a VM/GL iterator_next()` — libbtrfsutil's *unprivileged* subvolume-iteration path
artifact and will crash on hardware too). The menu wiring is correct and in btrfs-progs 6.17.1 (upstream-fixed after 6.17.1, kdave/btrfs-progs
the app is installed, but the GUI doesn't open. The snapshot *backend* `886571653` "re-enable tree search v2 ioctl"; symbol versions were checked
(the actual snapshots) is unaffected. Fix path: a nixpkgs override aligning and match, so not an ABI/link issue). **As root it runs fine** — VM-proven
its libbtrfsutil, or wait for an upstream bump; meanwhile the menu entry (root `--version` exits 0, unprivileged exits 139) — and the pkexec
is a no-op when the binary crashes. Fallback if it lingers: a rofi-based launcher runs it as root, so the GUI was never actually broken *when
snapshot menu (no btrfs-assistant dependency). launched right*. What WAS missing distro-wide: **no polkit authentication
- Remaining (optional): a keyboard-driven rofi browse for quick glances; agent**, so every pkexec prompt in the session failed silently — that's
boot-from-snapshot needs a systemd-boot equivalent of grub-btrfs. the root cause of "the GUI doesn't open". Fixes: `hyprpolkitagent`
- **Update awareness:** updates are manual today (`sys-update`/`home-update`); (Hyprland's Qt agent, Stylix-themed) now ships via exec-once in
add a Waybar indicator / notification when flake inputs are stale or a new `hyprland.nix`; the menu prefers `btrfs-assistant-launcher` again with
nixpkgs rev is available (optionally with a pending-change count). Augments, `nomarchy-snapshots` as fallback; `checks.snapshot-gui` guards the root
never replaces, the explicit rebuild flow. 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. The repo
has **no CI today** (manual `nix flake update` only). 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`
theme-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 -- theme-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.
- **"nomarchy" control center:** a single TUI/GUI front-end over the common - **"nomarchy" control center:** a single TUI/GUI front-end over the common
toggles — theme, power profile, opt-in services, display, DND — built on toggles — theme, power profile, opt-in services, display, DND — built on
the same `nomarchy-theme-sync` / `nomarchy.*` surface the menu already the same `nomarchy-theme-sync` / `nomarchy.*` surface the menu already
@@ -426,6 +846,50 @@ how to override it. Items marked ✓ are shipped.
your theme / essentials" flow (ties into the branding work). your theme / essentials" flow (ties into the branding work).
## Known issues & follow-ups ## Known issues & follow-ups
- ✓ **README option tables drift from the live surface** (docs review
slice (a), 2026-07-04): a fresh reconcile found 9 undocumented options
(autoTimezone, camera.hideIrSensor/irMatch, intel.guc, amd.pstate/
vaapi, package, system.stateFile, restic.paths) — rows/mentions added,
and the diff is now permanent: `checks.option-docs` walks the option
names out of the four option-declaring modules at eval time (their
lazy `config` halves never evaluate, so dummy args suffice) and
requires each a table row — or a backticked `.leaf` mention for
non-enable sub-knobs — plus flags table rows whose option no longer
exists (tools/check-option-docs.py).
- ✓ **swaync text invisible on summer-day** (Latitude QA, 2026-07-04):
the themed CSS paired @subtext on @base, but palette roles are not
uniform across themes — summer-day/flexoki-light use subtext as
text-*on*-surface (== base) and surface as a dark chip (== text), so
body text and widget buttons self-colored. Fix: swaync uses only
pairings safe in every palette — @text on @base for text (survey:
worst ratio 5.18 across 21 themes) and alpha(@text, 0.1) tints for
chips/hovers — guarded permanently by `checks.theme-contrast`
(tools/check-theme-contrast.py, WCAG ratios over themes/*.json).
The same audit found generated waybar/rofi CSS shared the bug class,
exposed on flexoki-light (no whole-swap) — fixed in the follow-on:
waybar dim/secondary shades are alpha(@text) tints, rofi @dim/@surface
are fg-derived #RRGGBBAA tints, and the contrast check's pairings now
cover all three generated surfaces. Status glyph accents
(good/warn/bad on base, 2.02.7 in some palettes) were left as-is —
raising them is palette design, a human call.
- ✓ **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
theme-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) theme-sync now prefers a clean `pkill -x waybar` when it
sees the supervisor (a restart with fresh config+style — no reload
code path at all), keeping SIGUSR2 only as the fallback for
unsupervised/custom bars; `reload_style_on_change` stays for manual
`home-update` restyles. VM-verified on the headless software-GL
desktop: SIGKILL → new pid; clean kill → respawn; SIGUSR2 → alive.
- ✓ **`sys-rebuild`** (Latitude QA request): the no-update twin of
`sys-update` — snapshot-first system rebuild against the *current*
lock, for config-only changes; `home-update` was already lock-free.
README §3/§5 + the motd list all three.
- ✓ **Yazi TOML parse error on startup:** yazi 26.x made fetchers' - ✓ **Yazi TOML parse error on startup:** yazi 26.x made fetchers'
`group` field required, so `[[plugin.prepend_fetchers]]` failed to parse `group` field required, so `[[plugin.prepend_fetchers]]` failed to parse
and yazi fell back to presets. Fixed by adding `group = "git"` to both and yazi fell back to presets. Fixed by adding `group = "git"` to both

View File

@@ -14,6 +14,7 @@ than claiming success. "All Nix files parse" is not "the bar renders."
nix flake check --no-build # full module-system evaluation, no builds nix flake check --no-build # full module-system evaluation, no builds
nix-instantiate --parse <file> # syntax-only, works even on macOS nix-instantiate --parse <file> # syntax-only, works even on macOS
python3 -m py_compile pkgs/nomarchy-theme-sync/nomarchy-theme-sync.py python3 -m py_compile pkgs/nomarchy-theme-sync/nomarchy-theme-sync.py
bash -n <script>.sh # shell syntax (tools/, installer bits)
``` ```
`nix flake check` needs a Linux machine (or a Linux builder) since all `nix flake check` needs a Linux machine (or a Linux builder) since all
@@ -22,6 +23,17 @@ bad merges — most breakage stops here. It also evaluates the downstream
template through `lib.mkFlake` (including a real nixos-hardware profile), template through `lib.mkFlake` (including a real nixos-hardware profile),
so template/wrapper drift fails fast too. so template/wrapper drift fails fast too.
## 1b. CI (automatic on push)
Every push to `main`/`v1` runs `.gitea/workflows/check.yml`: the §1
cheap checks (flake eval, Python + shell syntax) on the Gitea instance.
That's the **eval tier only** — the runner is a docker container without
KVM, so the `checks.*` VM suite and real builds stay local (this file)
until a KVM-capable runner is registered; the workflow carries a
commented `vm-checks` job ready for that day. A green CI run is *not* "it
renders" (the honesty rule below still applies) — it means "nobody broke
evaluation".
## 2. Build and boot the live ISO ## 2. Build and boot the live ISO
```sh ```sh

24
flake.lock generated
View File

@@ -145,11 +145,11 @@
] ]
}, },
"locked": { "locked": {
"lastModified": 1781063120, "lastModified": 1783221248,
"narHash": "sha256-1UIF/mDJluwJQjmmcZ2j1L2+mjYsefe82QCLj0TYSOg=", "narHash": "sha256-ESQnuNHEDChsB4IxoLRhscVahqkDWkTb+qdIz8euYt4=",
"owner": "nix-community", "owner": "nix-community",
"repo": "home-manager", "repo": "home-manager",
"rev": "baa46aeb6d02e0ba13de67cd35e3d57aedfacf01", "rev": "af2beae5f0fae0a4310cc0e6aef2572f56090353",
"type": "github" "type": "github"
}, },
"original": { "original": {
@@ -166,11 +166,11 @@
] ]
}, },
"locked": { "locked": {
"lastModified": 1781020964, "lastModified": 1782562157,
"narHash": "sha256-fS7xTi2j2iso5Hj7RNZLv/acDlCT+fgMVkVk40A7Uco=", "narHash": "sha256-a7+T6QSeowynwZ1ZJJbP8T8ntAytvrui8kFGJmIZt2c=",
"owner": "NixOS", "owner": "NixOS",
"repo": "nixos-hardware", "repo": "nixos-hardware",
"rev": "32c2cd9e46286c4eced3dc6b613c659126bf3cca", "rev": "a9cf7546a938c737b079e738de73934a13de9784",
"type": "github" "type": "github"
}, },
"original": { "original": {
@@ -182,11 +182,11 @@
}, },
"nixpkgs": { "nixpkgs": {
"locked": { "locked": {
"lastModified": 1780902259, "lastModified": 1783148766,
"narHash": "sha256-q8yYEC5f1mFlQO9RGna4LTc9QrcvWunX6FYp83munkQ=", "narHash": "sha256-uslt2pqShTIXDdAHRHv2QkYLsVdY8Oqwz0EA48/RSM8=",
"owner": "NixOS", "owner": "NixOS",
"repo": "nixpkgs", "repo": "nixpkgs",
"rev": "bd0ff2d3eac24699c3664d5966b9ef36f388e2ca", "rev": "a50de1b7d8a586adc18d2395c19de7d6058e6030",
"type": "github" "type": "github"
}, },
"original": { "original": {
@@ -249,11 +249,11 @@
"tinted-zed": "tinted-zed" "tinted-zed": "tinted-zed"
}, },
"locked": { "locked": {
"lastModified": 1780702455, "lastModified": 1783308448,
"narHash": "sha256-+srjPGNy67nKytYwdlepycL51IG6S34sS4MKRZXK8G0=", "narHash": "sha256-n+OcIYCfHofQgsuDJ5w42BNoGDnJwmd7tHqLNpVaJOU=",
"owner": "nix-community", "owner": "nix-community",
"repo": "stylix", "repo": "stylix",
"rev": "54fa19702f4f2c7f6a981a92850678933588af9a", "rev": "a5b35360dd2f9a931e85457c3d6d9688307b939f",
"type": "github" "type": "github"
}, },
"original": { "original": {

415
flake.nix
View File

@@ -83,6 +83,9 @@
# works on machines that don't check out this repo. # works on machines that don't check out this repo.
themesDir = ./themes; themesDir = ./themes;
}; };
nomarchy-doctor = final.callPackage ./pkgs/nomarchy-doctor { };
nomarchy-control-center = final.callPackage ./pkgs/nomarchy-control-center { };
nomarchy-battery-notify = final.callPackage ./pkgs/nomarchy-battery-notify { };
}; };
nixosModules.nomarchy = { nixosModules.nomarchy = {
@@ -134,6 +137,418 @@
downstream.nixosConfigurations.default.config.system.build.toplevel; downstream.nixosConfigurations.default.config.system.build.toplevel;
downstream-template-home = downstream-template-home =
downstream.homeConfigurations.me.activationPackage; downstream.homeConfigurations.me.activationPackage;
# Every hex-on-hex text pairing the generated swaync CSS uses
# must contrast in EVERY palette — summer-day's body text was
# invisible on hardware (item 25: subtext==base there). Cheap
# (no VM); extend the script's PAIRINGS as more generated
# surfaces move to palette-safe roles (item 27: waybar/rofi).
theme-contrast = pkgs.runCommand "nomarchy-theme-contrast"
{ nativeBuildInputs = [ pkgs.python3 ]; }
''
python3 ${./tools/check-theme-contrast.py} ${./themes}
touch $out
'';
# The README option tables must track the live `nomarchy.*`
# surface. The surface is walked at eval time from the four
# option-declaring modules (their lazy `config` halves stay
# unevaluated), so the live side of the diff can't go stale;
# the script wants a table row per option (inline `.leaf`
# mentions ok for non-enable sub-knobs) and flags stale rows.
option-docs =
let
lib = nixpkgs.lib;
names = prefix: opts: lib.concatLists (lib.mapAttrsToList
(n: v:
if lib.isOption v
then lib.optional (!(v.readOnly or false)) "${prefix}${n}"
else names "${prefix}${n}." v)
opts);
surface = lib.concatLists [
(names "nomarchy."
((import ./modules/home/options.nix { inherit lib pkgs; config = { }; }).options.nomarchy))
(names "nomarchy.system."
((import ./modules/nixos/options.nix { inherit lib; config = { }; }).options.nomarchy.system))
(names "nomarchy.hardware."
((import ./modules/nixos/hardware.nix { inherit lib pkgs; config = { }; }).options.nomarchy.hardware))
(names "nomarchy.services."
((import ./modules/nixos/services.nix { inherit lib pkgs; config = { }; }).options.nomarchy.services))
];
in
pkgs.runCommand "nomarchy-option-docs"
{ nativeBuildInputs = [ pkgs.python3 ]; }
''
python3 ${./tools/check-option-docs.py} \
${pkgs.writeText "option-surface.txt"
(lib.concatStringsSep "\n" surface + "\n")} \
${./README.md}
touch $out
'';
# The state-file friendly-errors contract (item 11): validate
# reports broken fixtures by field with a fix, good state
# passes, and an invalid `set` is refused BEFORE the write —
# the on-disk file must be byte-identical after the attempt.
theme-sync-validate = pkgs.runCommand "nomarchy-theme-sync-validate"
{ nativeBuildInputs = [ pkgs.python3 pkgs.jq ]; }
''
set -eu
tool=${./pkgs/nomarchy-theme-sync/nomarchy-theme-sync.py}
export NOMARCHY_DEFAULT_THEMES=${./themes}
mkdir good && cp ${./templates/downstream/theme-state.json} good/theme-state.json
chmod +w good/theme-state.json
NOMARCHY_PATH=$PWD/good python3 "$tool" validate
mkdir syntax && printf '{ "slug": "x", }' > syntax/theme-state.json
if NOMARCHY_PATH=$PWD/syntax python3 "$tool" validate 2>err; then
echo "FAIL: syntax fixture accepted"; exit 1; fi
grep -q "syntax error at line" err
mkdir badhex && jq '.colors.accent = "7aa2f7"' \
good/theme-state.json > badhex/theme-state.json
if NOMARCHY_PATH=$PWD/badhex python3 "$tool" validate 2>err; then
echo "FAIL: bad hex accepted"; exit 1; fi
grep -q "colors.accent" err && grep -q "fix:" err
mkdir badtype && jq '.ui.gapsOut = "12"' \
good/theme-state.json > badtype/theme-state.json
if NOMARCHY_PATH=$PWD/badtype python3 "$tool" validate 2>err; then
echo "FAIL: bad type accepted"; exit 1; fi
grep -q "ui.gapsOut" err
mkdir unknown && jq '.extraKey = 1' \
good/theme-state.json > unknown/theme-state.json
NOMARCHY_PATH=$PWD/unknown python3 "$tool" validate \
| grep -q "unknown top-level key"
cp good/theme-state.json before.json
if NOMARCHY_PATH=$PWD/good python3 "$tool" --quiet \
set ui.gapsOut '"12"' --no-switch 2>err; then
echo "FAIL: invalid set was written"; exit 1; fi
grep -q "refusing to write" err
cmp good/theme-state.json before.json
touch $out
'';
# nomarchy-doctor's contract: an induced failed unit flips the
# sheet to ✖/exit-1 and names the unit; with the failure
# cleared it reports healthy/exit-0. Minimal node (just the
# package) — the disk/flake/snapper checks self-skip in a VM.
doctor = pkgs.testers.runNixOSTest {
name = "nomarchy-doctor";
nodes.machine = { ... }: {
environment.systemPackages = [ pkgs.nomarchy-doctor ];
systemd.services.doomed = {
description = "deliberately failing unit (doctor test)";
serviceConfig = {
Type = "oneshot";
ExecStart = "${pkgs.coreutils}/bin/false";
};
wantedBy = [ "multi-user.target" ];
};
};
testScript = ''
machine.wait_for_unit("multi-user.target")
machine.wait_until_succeeds("systemctl is-failed doomed.service")
out = machine.fail("nomarchy-doctor 2>&1")
assert "doomed.service" in out, f"doctor did not name the failed unit:\n{out}"
assert "journalctl" in out, f"doctor did not print a fix:\n{out}"
machine.succeed("systemctl reset-failed")
machine.succeed("nomarchy-doctor")
'';
};
# Config-level VM assertions for the nomarchy.hardware.* toggles —
# what they SET (kernel cmdline, fprintd, PAM). Real hardware
# behaviour (firmware/driver/device) needs bare metal and is out of
# scope here. Imports only hardware.nix, so the VM stays minimal.
hardware-toggles = pkgs.testers.runNixOSTest {
name = "nomarchy-hardware-toggles";
nodes.machine = { ... }: {
imports = [ ./modules/nixos/hardware.nix ];
nomarchy.hardware = {
intel.enable = true;
amd.enable = true;
fingerprint = { enable = true; pam = true; };
npu.enable = true;
};
};
testScript = ''
machine.wait_for_unit("multi-user.target")
cmdline = machine.succeed("cat /proc/cmdline")
assert "amd_pstate=active" in cmdline, cmdline
assert "i915.enable_guc=3" in cmdline, cmdline
machine.succeed("systemctl cat fprintd.service")
machine.succeed("grep -q pam_fprintd /etc/pam.d/sudo")
'';
};
# Runtime VM check for the battery-charge-limit re-apply: the udev
# rule must restart the oneshot on an AC state change (firmware can
# clear the threshold on unplug). The `test_power` module fakes a
# Mains adapter we can toggle to emit a real power_supply uevent —
# so this exercises the trigger, not just the config wiring. (The
# sysfs *write* needs a real charge_control_end_threshold and stays
# an on-hardware check.) Imports options.nix (where the power
# options live) + power.nix, so the VM stays minimal.
battery-charge-limit = pkgs.testers.runNixOSTest {
name = "nomarchy-battery-charge-limit";
nodes.machine = { ... }: {
imports = [ ./modules/nixos/options.nix ./modules/nixos/power.nix ];
boot.kernelModules = [ "test_power" ]; # fake Mains + battery
nomarchy.system.power = {
enable = true;
laptop = true;
batteryChargeLimit = 80;
};
};
testScript = ''
machine.wait_for_unit("multi-user.target")
# The oneshot exists and the fake Mains adapter is present.
machine.succeed("systemctl cat nomarchy-battery-charge-limit.service")
machine.succeed("udevadm settle")
machine.succeed("grep -lx Mains /sys/class/power_supply/*/type")
# Let any add-event restart settle, then sample the invocation.
machine.succeed("sleep 2")
before = machine.succeed(
"systemctl show -p InvocationID --value "
"nomarchy-battery-charge-limit.service"
).strip()
# Simulate an AC unplug 'change' uevent on the Mains device.
machine.succeed("echo off > /sys/module/test_power/parameters/ac_online")
machine.succeed("udevadm settle")
# The udev rule must have restarted the oneshot (new InvocationID).
machine.wait_until_succeeds(
'test "$(systemctl show -p InvocationID --value '
'nomarchy-battery-charge-limit.service)" != "' + before + '"',
timeout=30,
)
'';
};
# Unit test of the monitor-rule overlay (modules/home/
# monitor-rules.nix — pure, imported directly, no HM eval): the
# active profile must replace base entries whole by name, leave
# unnamed base outputs alone, and the disable-guard must stop a
# stale menu resolution pick (settings.monitors) resurrecting a
# panel the profile disables. Junk in settings.displayProfile
# degrades to base config, never an eval error.
display-profiles =
let
inherit (nixpkgs) lib;
monitorLib = import ./modules/home/monitor-rules.nix { inherit lib; };
entry = a: monitorLib.defaults // a;
fixture = {
base = [
(entry { name = "eDP-1"; position = "0x0"; })
(entry { name = "HDMI-A-1"; position = "auto-right"; })
];
profiles.docked = [
(entry { name = "eDP-1"; resolution = "disable"; })
(entry { name = "DP-3"; position = "0x0"; })
];
resOverrides."eDP-1" = "1920x1080@60"; # stale pick — must stay dead
};
rulesFor = active: map monitorLib.rule
(monitorLib.resolve (fixture // { inherit active; }));
docked = rulesFor "docked";
junk = rulesFor 42; # hand-edited state (validator only warns)
expect = cond: msg: lib.assertMsg cond
"display-profiles: ${msg} (rendered: ${builtins.toJSON docked})";
ok =
expect (lib.elem "eDP-1, disable" docked)
"the profile did not disable eDP-1"
&& expect (! lib.any (lib.hasInfix "1920x1080") docked)
"a stale resolution pick resurrected the disabled panel"
&& expect (lib.elem "DP-3, preferred, 0x0, 1" docked)
"the profile-only output's rule is missing"
&& expect (lib.elem "HDMI-A-1, preferred, auto-right, 1" docked)
"an unnamed base output lost its rule"
&& expect (lib.elem "eDP-1, 1920x1080@60, 0x0, 1" junk)
"junk active profile must degrade to base config (with the pick applied)"
&& expect (monitorLib.workspaceRule "1" "DP-3" == "1, monitor:DP-3")
"workspace pin did not render as a Hyprland workspace rule";
in
assert ok; pkgs.runCommand "nomarchy-display-profiles" { } "touch $out";
# The low-battery watcher's crossing logic: one toast per downward
# crossing (normal at 25%, critical at 10%), re-armed by charging.
# test_power fakes a battery whose capacity/status we script;
# notify-send is shimmed to a log via PATH (the package resolves
# it from PATH for exactly this reason). What stays on-hardware:
# the real toast rendering through swaync in a session.
battery-notify = pkgs.testers.runNixOSTest {
name = "nomarchy-battery-notify";
nodes.machine = { ... }: {
boot.kernelModules = [ "test_power" ]; # fake battery + Mains
environment.systemPackages = [ pkgs.nomarchy-battery-notify ];
};
testScript = ''
machine.wait_for_unit("multi-user.target")
# notify-send shim: log the calls instead of needing a session bus.
machine.succeed(
"mkdir -p /shim && printf '#!/bin/sh\necho \"$*\" >> /tmp/notifications\n'"
" > /shim/notify-send && chmod +x /shim/notify-send"
)
# Self-gate: with the fake battery gone, the watcher exits 0 at once.
machine.succeed("modprobe -r test_power")
machine.succeed("timeout 5 nomarchy-battery-notify 1")
machine.succeed("modprobe test_power")
# Healthy level while discharging: silence.
machine.succeed("echo discharging > /sys/module/test_power/parameters/battery_status")
machine.succeed("echo 80 > /sys/module/test_power/parameters/battery_capacity")
machine.succeed(
"systemd-run --unit=batwatch --setenv=PATH=/shim:/run/current-system/sw/bin"
" /run/current-system/sw/bin/nomarchy-battery-notify 1"
)
machine.sleep(3)
machine.fail("test -s /tmp/notifications")
# Crossing 25% fires the low toast exactly once, not per poll.
machine.succeed("echo 20 > /sys/module/test_power/parameters/battery_capacity")
machine.wait_until_succeeds("grep -q 'Battery low' /tmp/notifications")
machine.succeed("echo 15 > /sys/module/test_power/parameters/battery_capacity")
machine.sleep(3)
assert machine.succeed("grep -c 'Battery low' /tmp/notifications").strip() == "1"
# Crossing 10% escalates to a critical-urgency toast, once.
machine.succeed("echo 5 > /sys/module/test_power/parameters/battery_capacity")
machine.wait_until_succeeds("grep -q 'Battery critical' /tmp/notifications")
assert "critical" in machine.succeed("grep 'Battery critical' /tmp/notifications")
machine.sleep(3)
assert machine.succeed("grep -c 'Battery critical' /tmp/notifications").strip() == "1"
# Back on the charger re-armed: the next drain notifies again.
machine.succeed("echo charging > /sys/module/test_power/parameters/battery_status")
machine.succeed("echo 80 > /sys/module/test_power/parameters/battery_capacity")
machine.sleep(3)
machine.succeed("echo discharging > /sys/module/test_power/parameters/battery_status")
machine.succeed("echo 20 > /sys/module/test_power/parameters/battery_capacity")
machine.wait_until_succeeds(
"test \"$(grep -c 'Battery low' /tmp/notifications)\" = 2"
)
'';
};
# Memory-pressure protection (oom.nix): earlyoom must kill a
# runaway allocator BEFORE the kernel thrash point — and must not
# take anything else with it. A bystander unit survives the kill,
# proving the process-level granularity that motivated earlyoom
# over cgroup-level systemd-oomd (which would kill the whole
# Hyprland session scope). Imports only oom.nix so the VM stays
# minimal.
oom-protection = pkgs.testers.runNixOSTest {
name = "nomarchy-oom-protection";
nodes.machine = { ... }: {
imports = [ ./modules/nixos/oom.nix ];
environment.systemPackages = [ pkgs.python3 ];
virtualisation.memorySize = 1024;
};
testScript = ''
machine.wait_for_unit("earlyoom.service")
# One owner: oomd (nixpkgs default-on, inert) is disabled.
machine.fail("systemctl is-active --quiet systemd-oomd.service")
# The session avoid-list made it into the daemon invocation.
machine.succeed("systemctl cat earlyoom.service | grep -q -- --avoid")
# A bystander that must survive (stand-in for the session).
machine.succeed("systemd-run --unit=bystander sleep infinity")
# The hog: allocate in 20 MB chunks with a short sleep, so
# earlyoom's poll wins the race against the kernel OOM killer.
machine.succeed(
"systemd-run --unit=hog python3 -c 'import time; "
"exec(\"a=[]\\nwhile True:\\n a.append(bytearray(20*1024*1024)); time.sleep(0.05)\")'"
)
# earlyoom (not the kernel) pulled the trigger
machine.wait_until_succeeds(
"journalctl -u earlyoom.service | grep -Eiq 'sending SIG(TERM|KILL)'",
timeout=120,
)
# the hog is gone
machine.wait_until_succeeds(
'test "$(systemctl show -p ActiveState --value hog.service)" != "active"',
timeout=60,
)
# and the bystander is untouched.
machine.succeed("systemctl is-active --quiet bystander.service")
'';
};
# Snapshot GUI canary: btrfs-assistant crashes in libbtrfsutil's
# UNPRIVILEGED subvolume iteration (btrfs-progs 6.17.1, fixed
# upstream after 6.17.1) but runs fine as root — which is how the
# menu launches it (pkexec launcher). Guard the root path on a
# real btrfs volume so a lock bump that breaks it fails here, not
# on a user's machine. The GUI event loop is exercised offscreen
# (survives a 5s timeout = no startup crash past the Btrfs scan).
snapshot-gui = pkgs.testers.runNixOSTest {
name = "nomarchy-snapshot-gui";
nodes.machine = { pkgs, ... }: {
environment.systemPackages = [ pkgs.btrfs-assistant pkgs.btrfs-progs ];
virtualisation.emptyDiskImages = [ 512 ];
};
testScript = ''
machine.wait_for_unit("multi-user.target")
machine.succeed(
"mkfs.btrfs /dev/vdb && mkdir -p /mnt && mount /dev/vdb /mnt "
"&& btrfs subvolume create /mnt/sub1"
)
# Root (the launcher's effective context): must work.
machine.succeed("QT_QPA_PLATFORM=offscreen btrfs-assistant-bin --version")
# The GUI proper survives startup (timeout kill = alive 124).
machine.succeed(
"set +e; timeout 5 env QT_QPA_PLATFORM=offscreen btrfs-assistant-bin; "
"[ $? -eq 124 ]"
)
'';
};
# The distroId question (roadmap "Distro branding"): set
# distroId = "nomarchy" so /etc/os-release is honest (ID=nomarchy,
# ID_LIKE=nixos). The risk is switch-to-configuration's "is this
# NixOS?" guard — but it builds the check from the *configured*
# distroId (and /etc/NIXOS still exists), so a rebuild must still
# work. This boots such a system and runs `switch-to-configuration
# dry-activate` to prove the guard passes. Inline (not the full
# module) so the VM stays minimal — the real wiring in default.nix
# is covered by the downstream-template-system build.
distro-id = pkgs.testers.runNixOSTest {
name = "nomarchy-distro-id";
nodes.machine = { ... }: {
system.nixos.distroName = "Nomarchy";
system.nixos.distroId = "nomarchy";
# Test base omits switch-to-configuration by default; we need it
# to exercise the "is this NixOS?" guard.
system.switch.enable = true;
};
testScript = { nodes, ... }: ''
machine.wait_for_unit("multi-user.target")
# os-release reflects the rebrand, with the nixos lineage marker.
machine.succeed("grep -q '^ID=nomarchy$' /etc/os-release")
machine.succeed("grep -q '^ID_LIKE=nixos$' /etc/os-release")
machine.succeed("grep -q '^NAME=Nomarchy$' /etc/os-release")
# The rebuild path must still recognise this as a NixOS install
# (the guard is built from the configured distroId, not "nixos").
machine.succeed(
"${nodes.machine.system.build.toplevel}/bin/switch-to-configuration dry-activate"
)
'';
};
}; };
# ─── Reference host ──────────────────────────────────────────── # ─── Reference host ────────────────────────────────────────────

View File

@@ -4,26 +4,85 @@
{ lib, pkgs, username, nomarchySrc, ... }: { lib, pkgs, username, nomarchySrc, ... }:
let let
# ISO boot splash: the Nomarchy monogram recolored to the palette accent, # ISO boot branding: the Nomarchy monogram recolored to the palette accent,
# centred on the theme base. Built from the vendored vector logo and the # centred on the theme base. Built from the vendored vector logo and the
# live theme-state.json (tokyo-night by default). Shows on the isolinux # live theme-state.json (tokyo-night by default). The same composed image
# (BIOS) boot menu; UEFI/GRUB still uses the stock theme (see roadmap). # backs both the isolinux (BIOS) splash and the GRUB (UEFI) theme below, so
# the two boot paths match.
state = builtins.fromJSON (builtins.readFile ../theme-state.json); state = builtins.fromJSON (builtins.readFile ../theme-state.json);
isoColor = key: fallback: lib.removePrefix "#" ((state.colors or { }).${key} or fallback); isoColor = key: fallback: lib.removePrefix "#" ((state.colors or { }).${key} or fallback);
accent = isoColor "accent" "7aa2f7";
base = isoColor "base" "1a1b26";
subtext = isoColor "subtext" "787c99";
isoSplash = pkgs.runCommand "nomarchy-iso-splash.png" isoSplash = pkgs.runCommand "nomarchy-iso-splash.png"
{ nativeBuildInputs = [ pkgs.imagemagick pkgs.librsvg ]; } '' { nativeBuildInputs = [ pkgs.imagemagick pkgs.librsvg ]; } ''
rsvg-convert -h 320 ${../modules/nixos/branding/logo.svg} > logo.png rsvg-convert -h 320 ${../modules/nixos/branding/logo.svg} > logo.png
magick logo.png -fill "#${isoColor "accent" "7aa2f7"}" -colorize 100 logo-c.png magick logo.png -fill "#${accent}" -colorize 100 logo-c.png
magick -size 1920x1080 xc:"#${isoColor "base" "1a1b26"}" \ magick -size 1920x1080 xc:"#${base}" \
logo-c.png -gravity center -composite $out logo-c.png -gravity center -composite $out
''; '';
# GRUB (UEFI) theme matched to the BIOS splash: the same accent-logo-on-base
# image as the background, plus a palette-coloured boot menu in the lower
# third (clear of the centred logo). Derived from nixos-grub2-theme only to
# reuse its bundled DejaVu .pf2 font — we overwrite the background and
# theme.txt and drop the stock NixOS logo (ours is in the background). grub
# loads every .pf2 in the dir, so "DejaVu Regular" resolves.
grubThemeTxt = pkgs.writeText "nomarchy-grub-theme.txt" ''
title-text: ""
desktop-image: "background.png"
desktop-color: "#${base}"
message-font: "DejaVu Regular"
message-color: "#${subtext}"
terminal-font: "DejaVu Regular"
+ boot_menu {
left = 50%-300
width = 600
top = 64%
height = 26%
item_font = "DejaVu Regular"
item_color = "#${subtext}"
item_height = 36
item_spacing = 6
selected_item_font = "DejaVu Regular"
selected_item_color = "#${accent}"
scrollbar = false
}
+ progress_bar {
id = "__timeout__"
left = 50%-300
top = 92%
width = 600
height = 16
show_text = true
text = "@TIMEOUT_NOTIFICATION_MIDDLE@"
font = "DejaVu Regular"
text_color = "#${subtext}"
border_color = "#${accent}"
bg_color = "#${base}"
fg_color = "#${accent}"
}
'';
nomarchyGrubTheme = pkgs.runCommand "nomarchy-grub-theme" { } ''
cp -r ${pkgs.nixos-grub2-theme} $out
chmod -R u+w $out
cp ${isoSplash} $out/background.png
cp ${grubThemeTxt} $out/theme.txt
rm -f $out/logo.png
'';
in in
{ {
networking.hostName = "nomarchy-live"; networking.hostName = "nomarchy-live";
isoImage.volumeID = lib.mkForce "NOMARCHY_LIVE"; isoImage.volumeID = lib.mkForce "NOMARCHY_LIVE";
isoImage.edition = lib.mkForce "live"; isoImage.edition = lib.mkForce "live";
isoImage.splashImage = isoSplash; isoImage.splashImage = isoSplash; # isolinux / BIOS
isoImage.grubTheme = nomarchyGrubTheme; # GRUB / UEFI
# The minimal-CD profile slims the image for a CONSOLE installer; this # The minimal-CD profile slims the image for a CONSOLE installer; this
# ISO is the desktop, so re-enable what it strips. Above all # ISO is the desktop, so re-enable what it strips. Above all

View File

@@ -0,0 +1,29 @@
# Low-battery notifications (nomarchy.batteryNotify) — the bar colors the
# battery @warn/@bad at 25/10% (waybar.nix battery.states) but nothing
# *notified*; this watcher fires a toast at those same thresholds. Session-
# side and self-gating on a battery being present (the powerprofile-script
# pattern — no system→home coupling), so it's a silent no-op on desktops.
# swaync shows critical toasts until dismissed (timeout-critical = 0), so
# the 10% one can't slip by unseen. Logic lives in pkgs/nomarchy-battery-
# notify (overlay), where checks.battery-notify exercises it.
{ config, lib, pkgs, ... }:
{
config = lib.mkIf config.nomarchy.batteryNotify.enable {
systemd.user.services.nomarchy-battery-notify = {
Unit = {
Description = "Low-battery notifications (25/10%, the bar's thresholds)";
PartOf = [ "graphical-session.target" ];
After = [ "graphical-session.target" ];
};
Service = {
# The script resolves notify-send from PATH (that's what the VM
# check shims); here libnotify provides the real one.
Environment = "PATH=${lib.makeBinPath [ pkgs.libnotify ]}";
ExecStart = "${pkgs.nomarchy-battery-notify}/bin/nomarchy-battery-notify";
Restart = "on-failure";
};
Install.WantedBy = [ "graphical-session.target" ];
};
};
}

View File

@@ -1,7 +1,7 @@
# Nomarchy — Home Manager entry point. # Nomarchy — Home Manager entry point.
# Consume this via homeModules.nomarchy (flake.nix), which also pulls in # Consume this via homeModules.nomarchy (flake.nix), which also pulls in
# the stylix home module that stylix.nix configures. # the stylix home module that stylix.nix configures.
{ config, pkgs, ... }: { config, lib, pkgs, ... }:
{ {
imports = [ imports = [
@@ -18,9 +18,15 @@
./yazi.nix # flagship TUI file manager, themed + plugins ./yazi.nix # flagship TUI file manager, themed + plugins
./osd.nix # swayosd volume/brightness OSD, themed ./osd.nix # swayosd volume/brightness OSD, themed
./nightlight.nix # scheduled blue-light filter (hyprsunset), opt-in ./nightlight.nix # scheduled blue-light filter (hyprsunset), opt-in
./timezone.nix # keep the Waybar clock in step with auto-timezone changes
./updates.nix # passive update-awareness indicator + notification, opt-in
./shell.nix # zsh + starship + bat/eza/zoxide, themed ./shell.nix # zsh + starship + bat/eza/zoxide, themed
./keys.nix # gpg-agent (fronts SSH) + pinentry-qt ./keys.nix # gpg-agent (fronts SSH) + pinentry-qt
./fastfetch.nix # system info with the themed Nomarchy logo ./fastfetch.nix # system info with the themed Nomarchy logo
./viewers.nix # zathura (Stylix-themed) + imv — PDF/image viewing
./mime.nix # default applications (mimeapps.list), degrades with the suite
./recording.nix # nomarchy-record: screen recording behind Capture + the bar ⏺
./battery-notify.nix # low-battery toasts at the bar's 25/10% thresholds
]; ];
# Clipboard history (wl-paste watcher); browsed via the SUPER+CTRL+V # Clipboard history (wl-paste watcher); browsed via the SUPER+CTRL+V
@@ -32,6 +38,16 @@
services.network-manager-applet.enable = true; services.network-manager-applet.enable = true;
xsession.preferStatusNotifierItems = true; xsession.preferStatusNotifierItems = true;
# Standard XDG user directories (Downloads, Documents, Pictures, Music,
# Videos, Desktop, Public, Templates): written to user-dirs.dirs so file
# pickers/browsers resolve them, and created on activation so a fresh
# install lands with them present (not just on first app use). mkDefault
# so a downstream home.nix can flip it off or remap individual paths.
xdg.userDirs = {
enable = lib.mkDefault true;
createDirectories = lib.mkDefault true;
};
home.stateVersion = "26.05"; home.stateVersion = "26.05";
home.packages = with pkgs; [ home.packages = with pkgs; [

View File

@@ -8,6 +8,10 @@ let
c = t.colors; c = t.colors;
inherit (config.nomarchy.lib) rgb rgba; inherit (config.nomarchy.lib) rgb rgba;
# nomarchy-theme-sync — the in-flake state writer the keyboard watcher uses
# to remember per-device layouts (same path night-light's toggle takes).
sync = lib.getExe config.nomarchy.package;
# swayosd's `--input-volume mute-toggle` draws the OSD but never flips the # swayosd's `--input-volume mute-toggle` draws the OSD but never flips the
# source mute (verified on hardware: wpctl toggles the state, swayosd's # source mute (verified on hardware: wpctl toggles the state, swayosd's
# input-mute path is a no-op — unlike its working output-mute path). Do the # input-mute path is a no-op — unlike its working output-mute path). Do the
@@ -27,6 +31,34 @@ let
${pkgs.swayosd}/bin/swayosd-client --custom-icon "$icon" --custom-progress "$2" ${pkgs.swayosd}/bin/swayosd-client --custom-icon "$icon" --custom-progress "$2"
''; '';
# Launch-or-focus (nomarchy.launchOrFocus, item 17): focus the app's
# window if one exists (case-insensitive class match), launch it
# otherwise. Self-gates: an uninstalled command notifies instead of
# failing silently, so a bind survives the app being removed from the
# suite. rofi.nix renders the same entries into the SUPER+? cheatsheet.
focusOrLaunch = pkgs.writeShellScriptBin "nomarchy-focus-or-launch" ''
class="''${1:-}"; shift || true
[ -n "$class" ] && [ $# -gt 0 ] || {
echo "usage: nomarchy-focus-or-launch <class> <command...>" >&2; exit 64
}
if hyprctl clients -j 2>/dev/null \
| ${pkgs.jq}/bin/jq -e --arg c "$class" \
'any(.[]; (.class | ascii_downcase) == ($c | ascii_downcase))' >/dev/null; then
exec hyprctl dispatch focuswindow "class:(?i)^$class\$"
fi
command -v "$1" >/dev/null 2>&1 \
|| { notify-send "Launch" "$1 is not installed."; exit 0; }
exec "$@"
'';
lofEntries = map
(e: e // {
command = if e.command == "" then lib.toLower e.class else e.command;
})
config.nomarchy.launchOrFocus;
lofBinds = map
(e: "${e.mods}, ${e.key}, exec, ${focusOrLaunch}/bin/nomarchy-focus-or-launch ${e.class} ${e.command}")
lofEntries;
# SUPER+1..9 / SUPER+SHIFT+1..9 workspace binds, generated. # SUPER+1..9 / SUPER+SHIFT+1..9 workspace binds, generated.
workspaceBinds = builtins.concatLists (builtins.genList workspaceBinds = builtins.concatLists (builtins.genList
(i: (i:
@@ -42,23 +74,151 @@ let
keybinds = import ./keybinds.nix; keybinds = import ./keybinds.nix;
mkBind = b: "${b.mods}, ${b.key}, ${b.action}"; mkBind = b: "${b.mods}, ${b.key}, ${b.action}";
# A nomarchy.monitors entry -> a Hyprland `monitor` rule. Unset optional # Monitor-rule composition (rendering + the display-profile/resolution
# fields are omitted; `resolution = "disable"` collapses to the short form. # overlays) lives in ./monitor-rules.nix — pure, so
monitorRule = m: # checks.display-profiles unit-tests the overlay semantics directly.
if m.resolution == "disable" then "${m.name}, disable" # The layering story (profiles replace whole-by-name, menu resolution
else lib.concatStringsSep ", " ( # picks apply field-level, disable-guard) is documented there.
[ m.name m.resolution (toString m.position) (toString m.scale) ] monitorLib = import ./monitor-rules.nix { inherit lib; };
++ lib.optionals (m.transform != null) [ "transform" (toString m.transform) ] monitorRule = monitorLib.rule;
++ lib.optionals (m.mirror != null) [ "mirror" m.mirror ]
++ lib.optionals (m.bitdepth != null) [ "bitdepth" (toString m.bitdepth) ]
++ lib.optionals (m.vrr != null) [ "vrr" (toString m.vrr) ]
++ lib.optional (m.extra != "") m.extra
);
# Keyboard layout candidates: the primary nomarchy.keyboard.layout (split on # The night-light pattern: nomarchy-display-profile applies rules live
# commas) plus any nomarchy.keyboard.layouts. Drives input.kb_layout so all # via hyprctl and writes settings.displayProfile with --no-switch;
# of them are switchable, and feeds the watcher's picker. # resolve bakes the same choice here at the next rebuild.
kbLayouts = lib.unique ( # Profiles normalized to { monitors; workspaces; } — the option also
# accepts the original bare-list-of-monitors shape.
profiles = lib.mapAttrs
(_: p: if builtins.isList p then { monitors = p; workspaces = { }; } else p)
config.nomarchy.displayProfiles;
resolvedMonitors = monitorLib.resolve {
base = config.nomarchy.monitors;
profiles = lib.mapAttrs (_: p: p.monitors) profiles;
active = config.nomarchy.settings.displayProfile or "";
resOverrides = config.nomarchy.settings.monitors;
};
# The active profile's workspace→output pins, baked as `workspace`
# rules below (same rebuild path as the monitor overlay; the applier
# covers the instant half). Same junk-state guard as resolve.
activeProfile =
let a = config.nomarchy.settings.displayProfile or "";
in profiles.${if builtins.isString a then a else ""} or { workspaces = { }; };
profileWorkspaceRules =
lib.mapAttrsToList monitorLib.workspaceRule activeProfile.workspaces;
# Profile applier — on PATH only when profiles are declared (the menu
# row self-gates on it). apply: the profile's rules live via hyprctl +
# the in-flake state write; base: clear the state, then hyprctl reload
# re-applies the generated config's rules.
displayProfileTool = pkgs.writeShellScriptBin "nomarchy-display-profile" ''
case "''${1:-}" in
list)
${lib.concatMapStringsSep "\n" (n: " echo ${lib.escapeShellArg n}") (lib.attrNames profiles)} ;;
active)
cur=$(${sync} get settings.displayProfile 2>/dev/null) || cur=
echo "''${cur:-none}" ;;
apply)
case "''${2:-}" in
${lib.concatStringsSep "\n" (lib.mapAttrsToList
(name: p:
" ${lib.escapeShellArg name})\n"
+ lib.concatMapStringsSep "\n"
(m: " hyprctl keyword monitor ${lib.escapeShellArg (monitorRule m)} >/dev/null 2>&1")
p.monitors
# Workspace pins: the keyword sets the session rule, the dispatch
# moves an already-open workspace over. Pins from a previously
# applied profile linger until reload/rebuild (hyprctl can only
# add) — harmless, a rule naming an absent output is inert.
+ lib.concatStrings (lib.mapAttrsToList
(ws: out:
"\n hyprctl keyword workspace ${lib.escapeShellArg (monitorLib.workspaceRule ws out)} >/dev/null 2>&1"
+ "\n hyprctl dispatch moveworkspacetomonitor ${lib.escapeShellArg ws} ${lib.escapeShellArg out} >/dev/null 2>&1")
p.workspaces)
+ " ;;")
profiles)}
*) echo "unknown profile '$2' try: nomarchy-display-profile list" >&2; exit 64 ;;
esac
${sync} --quiet set settings.displayProfile "$2" --no-switch
notify-send "Display profile" "Applied: $2" ;;
base)
${sync} --quiet set settings.displayProfile "" --no-switch
hyprctl reload >/dev/null 2>&1
notify-send "Display profile" "Base layout restored." ;;
match)
# Which profile fits these connected outputs? (args = output names,
# e.g. `match $(hyprctl monitors all -j | jq -r '.[].name')`.)
# An exact set match wins; else the largest profile whose named
# outputs are all connected; any tie prints nothing (exit 1) the
# auto-switch watcher must never flap between ambiguous layouts.
shift
[ "$#" -gt 0 ] || exit 1
con=" $* " ncon=$#
exact= exactdup= best= bestn=0 dup=
while IFS=: read -r p names; do
[ -n "$p" ] || continue
n=0 ok=1
for o in $names; do
case "$con" in *" $o "*) n=$((n+1)) ;; *) ok=; break ;; esac
done
[ -n "$ok" ] || continue
if [ "$n" -eq "$ncon" ]; then
if [ -n "$exact" ]; then exactdup=1; else exact=$p; fi
fi
if [ "$n" -gt "$bestn" ]; then best=$p bestn=$n dup=
elif [ "$n" -eq "$bestn" ] && [ "$n" -gt 0 ]; then dup=1
fi
done < <(printf '%s\n' ${lib.concatMapStringsSep " " lib.escapeShellArg
(lib.mapAttrsToList
(name: p: "${name}:${lib.concatMapStringsSep " " (m: m.name) p.monitors}")
profiles)})
if [ -n "$exact" ] && [ -z "$exactdup" ]; then echo "$exact"; exit 0; fi
if [ -n "$best" ] && [ -z "$dup" ]; then echo "$best"; exit 0; fi
exit 1 ;;
*)
echo "usage: nomarchy-display-profile [list|active|apply <name>|base|match <output>...]" >&2
exit 64 ;;
esac
'';
# Hotplug auto-switch (opt-in via the menu's Auto-switch row →
# settings.displayProfileAuto, read LIVE on every output change so the
# toggle is instant, no rebuild). Polls like the keyboard watcher —
# exec-once, not a systemd unit (graphical-session-bound units raced
# Hyprland's IPC on relogin; see the waybar note below). Only reacts to
# CHANGES after session start: the baked config already encodes the
# last choice, and login must not fight a deliberate manual pick.
# `match` picks deterministically (exact set, else unambiguous largest
# subset); applying via the tool persists the concrete profile, so the
# next rebuild bakes what auto chose.
displayProfileWatch = pkgs.writeShellScriptBin "nomarchy-display-profile-watch" ''
set -u
auto_on() {
v=$(${sync} get settings.displayProfileAuto 2>/dev/null) || v=false
case "$v" in true|True) return 0 ;; *) return 1 ;; esac
}
outputs() { hyprctl monitors all -j 2>/dev/null | jq -r '.[].name' | sort; }
prev=$(outputs)
while :; do
sleep 3
cur=$(outputs)
[ "$cur" = "$prev" ] && continue
prev=$cur
[ -n "$cur" ] || continue
auto_on || continue
target=$(nomarchy-display-profile match $cur) || continue
[ "$target" = "$(nomarchy-display-profile active)" ] && continue
nomarchy-display-profile apply "$target"
done
'';
# Candidate layouts offered by the new-keyboard picker: the session
# layout(s) (nomarchy.keyboard.layout, comma-split for a multi-layout
# session) plus the extra nomarchy.keyboard.layouts pool. The pool is
# deliberately NOT merged into input.kb_layout — those candidates are for
# *external* keyboards and get applied per-device by the watcher's apply().
# Loading them onto the session keyboard is what let a global switch flip
# the built-in board to the wrong layout.
pickerLayouts = lib.unique (
(lib.splitString "," config.nomarchy.keyboard.layout) (lib.splitString "," config.nomarchy.keyboard.layout)
++ config.nomarchy.keyboard.layouts ++ config.nomarchy.keyboard.layouts
); );
@@ -68,21 +228,41 @@ let
# keyboard.devices, not already remembered), ask for a layout and persist it # keyboard.devices, not already remembered), ask for a layout and persist it
# per-device, re-applying silently on later reconnects. Stateful runtime # per-device, re-applying silently on later reconnects. Stateful runtime
# piece — the complement to the declarative keyboard.devices. Reliable # piece — the complement to the declarative keyboard.devices. Reliable
# primitives only: poll hyprctl devices, apply via switchxkblayout (an index # primitives only: poll hyprctl devices, apply with a per-device
# into kbLayouts). NOTE: needs an on-hardware test (hotplug isn't verifiable # `device[<name>]:kb_layout` keyword — the runtime twin of the declarative
# in CI). # device blocks, so it isolates that one keyboard and never disturbs the
# built-in board (switchxkblayout flipped the *shared* layout, which leaked
# onto the laptop and stuck after unplug).
#
# The remembered choices live in the git-tracked in-flake state
# (settings.keyboard.devices), NOT ~/.local/state — read live from the
# working tree (so a pick this session is honoured at once) and written
# instantly with `--no-switch` (no rebuild). A later rebuild graduates them
# into nomarchy.keyboard.devices (generated device{} blocks), after which the
# watcher treats them as declared and steps back. NOTE: needs an on-hardware
# test (hotplug isn't verifiable in CI).
keyboardWatch = pkgs.writeShellScriptBin "nomarchy-keyboard-watch" '' keyboardWatch = pkgs.writeShellScriptBin "nomarchy-keyboard-watch" ''
set -u set -u
layouts="${lib.concatStringsSep " " kbLayouts}" layouts="${lib.concatStringsSep " " pickerLayouts}"
declared="${lib.concatStringsSep " " (builtins.attrNames config.nomarchy.keyboard.devices)}" declared="${lib.concatStringsSep " " (builtins.attrNames config.nomarchy.keyboard.devices)}"
state="''${XDG_STATE_HOME:-$HOME/.local/state}/nomarchy/keyboard-layouts" sync=${sync}
mkdir -p "$(dirname "$state")"; [ -f "$state" ] || : > "$state"
layout_index() { i=0; for l in $layouts; do [ "$l" = "$1" ] && { printf %s "$i"; return; }; i=$((i + 1)); done; printf %s -1; } apply() { hyprctl keyword "device[$1]:kb_layout" "$2" >/dev/null 2>&1; }
apply() { idx=$(layout_index "$2"); [ "$idx" -ge 0 ] && hyprctl switchxkblayout "$1" "$idx" >/dev/null 2>&1; }
keyboards() { hyprctl devices -j | jq -r '.keyboards[].name'; } keyboards() { hyprctl devices -j | jq -r '.keyboards[].name'; }
is_declared() { case " $declared " in *" $1 "*) return 0 ;; *) return 1 ;; esac; } is_declared() { case " $declared " in *" $1 "*) return 0 ;; *) return 1 ;; esac; }
saved_for() { while IFS='=' read -r k v; do [ "$k" = "$1" ] && { printf '%s' "$v"; return; }; done < "$state"; }
# The remembered map (device-name -> layout) from the LIVE in-flake state;
# an absent key (sparse state) reads as an empty object.
saved_map() { "$sync" get settings.keyboard.devices 2>/dev/null || echo '{}'; }
saved_for() { saved_map | jq -r --arg k "$1" '.[$k] // empty'; }
# Persist a pick INSTANTLY: merge it into the map and write the whole object
# back at the dot-free parent path, so a device name containing a dot can't
# corrupt the dotted set-path. --no-switch = write only, no rebuild.
remember() {
map=$(saved_map | jq -c --arg k "$1" --arg v "$2" '. + {($k): $v}') || return
"$sync" --quiet set settings.keyboard.devices "$map" --no-switch
}
# Startup: re-apply remembered layouts, never prompt so the built-in # Startup: re-apply remembered layouts, never prompt so the built-in
# keyboard just stays on the session default. # keyboard just stays on the session default.
@@ -106,7 +286,7 @@ let
apply "$kb" "$s" apply "$kb" "$s"
else else
choice=$(printf '%s\n' $layouts | rofi -dmenu -p "Layout · $kb") choice=$(printf '%s\n' $layouts | rofi -dmenu -p "Layout · $kb")
[ -n "$choice" ] && { printf '%s=%s\n' "$kb" "$choice" >> "$state"; apply "$kb" "$choice"; } [ -n "$choice" ] && { remember "$kb" "$choice"; apply "$kb" "$choice"; }
fi fi
done done
prev="$cur" prev="$cur"
@@ -135,7 +315,11 @@ in
# and Hyprland applies them on hotplug. mkDefault so a downstream # and Hyprland applies them on hotplug. mkDefault so a downstream
# `monitor = [...]` replaces the lot (the live ISO uses mkForce too). # `monitor = [...]` replaces the lot (the live ISO uses mkForce too).
monitor = lib.mkDefault monitor = lib.mkDefault
([ ",preferred,auto,1" ] ++ map monitorRule config.nomarchy.monitors); ([ ",preferred,auto,1" ] ++ map monitorRule resolvedMonitors);
# The active display profile's workspace→output pins (a list, so
# downstream `workspace = [...]` rules concatenate).
workspace = profileWorkspaceRules;
# exec-once is a list at normal priority, so downstream additions # exec-once is a list at normal priority, so downstream additions
# CONCATENATE (your autostart runs alongside ours) rather than # CONCATENATE (your autostart runs alongside ours) rather than
@@ -146,7 +330,21 @@ in
# Paint the wallpaper as soon as the session is up (waits for # Paint the wallpaper as soon as the session is up (waits for
# the daemon internally). # the daemon internally).
"nomarchy-theme-sync wallpaper" "nomarchy-theme-sync wallpaper"
] ++ lib.optional kbAutoSwitch "${keyboardWatch}/bin/nomarchy-keyboard-watch"; # Polkit authentication agent — without one, EVERY pkexec/polkit
# prompt in the session fails silently (btrfs-assistant-launcher
# was the discovery case). hyprpolkitagent is Hyprland's own Qt
# agent, so the prompt is Stylix-themed like every other Qt
# surface. Its binary lives in libexec (not bin), hence the path.
"${pkgs.hyprpolkitagent}/libexec/hyprpolkitagent"
# Waybar is launched here rather than as a systemd user service: bound
# to graphical-session.target the unit raced Hyprland's IPC on relogin
# and landed in `failed`; exec-once only fires once the compositor is
# up. Via the nomarchy-waybar supervisor (waybar.nix): a crash — or
# the clean pkill a theme switch now does — respawns the bar instead
# of orphaning the session bar-less.
] ++ lib.optional config.nomarchy.waybar.enable "nomarchy-waybar"
++ lib.optional kbAutoSwitch "${keyboardWatch}/bin/nomarchy-keyboard-watch"
++ lib.optional (profiles != { }) "${displayProfileWatch}/bin/nomarchy-display-profile-watch";
# ── Theme-driven look ────────────────────────────────────────── # ── Theme-driven look ──────────────────────────────────────────
# These flow from theme-state.json and stay at NORMAL priority: # These flow from theme-state.json and stay at NORMAL priority:
@@ -201,8 +399,10 @@ in
input = { input = {
# kb_layout/variant come from nomarchy.keyboard.* — set that # kb_layout/variant come from nomarchy.keyboard.* — set that
# option (the installer writes it; it also drives tty/LUKS). # option (the installer writes it; it also drives tty/LUKS). Only the
kb_layout = lib.concatStringsSep "," kbLayouts; # session layout(s) land here; the keyboard.layouts pool is applied
# per-device to external boards, never onto the session keyboard.
kb_layout = config.nomarchy.keyboard.layout;
kb_variant = config.nomarchy.keyboard.variant; kb_variant = config.nomarchy.keyboard.variant;
follow_mouse = lib.mkDefault 1; follow_mouse = lib.mkDefault 1;
touchpad.natural_scroll = lib.mkDefault true; touchpad.natural_scroll = lib.mkDefault true;
@@ -231,13 +431,24 @@ in
disable_hyprland_logo = lib.mkDefault true; disable_hyprland_logo = lib.mkDefault true;
disable_splash_rendering = lib.mkDefault true; disable_splash_rendering = lib.mkDefault true;
force_default_wallpaper = lib.mkDefault 0; force_default_wallpaper = lib.mkDefault 0;
# The backdrop when no wallpaper is painted (awww still starting,
# a slow first paint, or a wallpaper-less setup): the theme's base
# instead of compositor black — on light themes the black flash
# reads as a glitch (item 28c, seen in the capture harness).
background_color = rgb c.base;
}; };
# Rendered from ./keybinds.nix (the cheatsheet reads the same list), # Rendered from ./keybinds.nix (the cheatsheet reads the same list),
# plus the generated per-workspace binds. Like exec-once above this # plus the generated per-workspace binds. Like exec-once above this
# stays a normal-priority list, so a downstream `bind = [...]` # stays a normal-priority list, so a downstream `bind = [...]`
# concatenates rather than replaces. # concatenates rather than replaces. The layout-cycle bind only
bind = map mkBind keybinds.binds ++ workspaceBinds; # exists when there is something to cycle (same comma condition
# the Waybar language indicator gates on).
bind = map mkBind (keybinds.binds
++ lib.optionals (lib.hasInfix "," config.nomarchy.keyboard.layout)
keybinds.multiLayoutBinds)
++ workspaceBinds
++ lofBinds;
# Media keys via swayosd-client: it performs the action AND shows # Media keys via swayosd-client: it performs the action AND shows
# the on-screen display (the nomarchy.osd module). e = repeat, # the on-screen display (the nomarchy.osd module). e = repeat,
@@ -266,11 +477,28 @@ in
}; };
}; };
# Runtime-remembered per-device layouts (settings.keyboard.devices, written
# by the watcher into the in-flake state) graduate into the declarative
# keyboard.devices: each becomes a generated Hyprland device{} block —
# reproducible and applied natively on hotplug — after which the watcher sees
# it as declared and steps back. mkDefault so a hand-written
# keyboard.devices.<name> in home.nix still wins for the same keyboard.
nomarchy.keyboard.devices = lib.mapAttrs
(_name: layout: { layout = lib.mkDefault layout; })
config.nomarchy.settings.keyboard.devices;
# nwg-displays: interactive monitor arranger (applies live via hyprctl and # nwg-displays: interactive monitor arranger (applies live via hyprctl and
# writes a config file). A helper to find values for nomarchy.monitors — # writes a config file). A helper to find values for nomarchy.monitors —
# the declarative config stays the source of truth (we don't source its # the declarative config stays the source of truth (we don't source its
# output). Gated on its toggle; pointless without the Hyprland session. # output). Gated on its toggle; pointless without the Hyprland session.
home.packages = lib.optionals home.packages =
(config.nomarchy.hyprland.enable && config.nomarchy.displays.enable) lib.optionals (config.nomarchy.hyprland.enable && config.nomarchy.displays.enable)
[ pkgs.nwg-displays ]; [ pkgs.nwg-displays ]
# The new-keyboard watcher on PATH (when enabled) so it's discoverable and
# runnable by name for debugging — Hyprland still exec-once's it by store path.
++ lib.optional (config.nomarchy.hyprland.enable && kbAutoSwitch) keyboardWatch
# The display-profile switcher (the menu's Profiles row gates on it)
# + its hotplug watcher, on PATH for debugging like the keyboard one.
++ lib.optionals (config.nomarchy.hyprland.enable && profiles != { })
[ displayProfileTool displayProfileWatch ];
} }

View File

@@ -13,8 +13,8 @@
{ {
binds = [ binds = [
{ mods = "$mod"; key = "Return"; action = "exec, $terminal"; desc = "Open terminal"; } { mods = "$mod"; key = "Return"; action = "exec, $terminal"; desc = "Open terminal"; }
{ mods = "$mod"; key = "Space"; action = "exec, rofi -show drun"; desc = "Quick launch (apps)"; } { mods = "$mod"; key = "Space"; action = "exec, rofi -show drun -theme launcher"; desc = "Quick launch (apps)"; }
{ mods = "$mod"; key = "D"; action = "exec, rofi -show drun"; desc = "App launcher"; } { mods = "$mod"; key = "D"; action = "exec, rofi -show drun -theme launcher"; desc = "App launcher"; }
{ mods = "$mod"; key = "M"; action = "exec, nomarchy-menu"; desc = "Main menu"; } { mods = "$mod"; key = "M"; action = "exec, nomarchy-menu"; desc = "Main menu"; }
{ mods = "$mod"; key = "E"; action = "exec, $terminal -e yazi"; desc = "File manager (yazi)"; } { mods = "$mod"; key = "E"; action = "exec, $terminal -e yazi"; desc = "File manager (yazi)"; }
{ mods = "$mod"; key = "Q"; action = "killactive"; desc = "Close window"; } { mods = "$mod"; key = "Q"; action = "killactive"; desc = "Close window"; }
@@ -33,8 +33,11 @@
{ mods = "$mod"; key = "X"; action = "exec, nomarchy-menu power"; desc = "Power menu"; } { mods = "$mod"; key = "X"; action = "exec, nomarchy-menu power"; desc = "Power menu"; }
{ mods = "$mod"; key = "N"; action = "exec, swaync-client -t"; desc = "Notification centre"; } { mods = "$mod"; key = "N"; action = "exec, swaync-client -t"; desc = "Notification centre"; }
# SUPER+? (the "question" keysym already implies Shift on most layouts). # SUPER+? — ? is typed as Shift+/, so SHIFT must be in the bind's
{ mods = "$mod"; key = "question"; action = "exec, nomarchy-menu keybinds"; desc = "Keybindings cheatsheet"; } # modmask or Hyprland's exact-modifier match never fires (the key then
# falls through to the focused window; caught on hardware, item 26).
# The cheatsheet still renders this row as SUPER + ? (rofi.nix).
{ mods = "$mod SHIFT"; key = "question"; action = "exec, nomarchy-menu keybinds"; desc = "Keybindings cheatsheet"; }
# Menu functions — SUPER+CTRL+<mnemonic> jumps straight to a # Menu functions — SUPER+CTRL+<mnemonic> jumps straight to a
# nomarchy-menu module (all also reachable from the SUPER+M picker). # nomarchy-menu module (all also reachable from the SUPER+M picker).
@@ -46,6 +49,7 @@
{ mods = "$mod CTRL"; key = "N"; action = "exec, nomarchy-menu network"; desc = "Network (nmtui)"; } { mods = "$mod CTRL"; key = "N"; action = "exec, nomarchy-menu network"; desc = "Network (nmtui)"; }
{ mods = "$mod CTRL"; key = "B"; action = "exec, nomarchy-menu bluetooth"; desc = "Bluetooth"; } { mods = "$mod CTRL"; key = "B"; action = "exec, nomarchy-menu bluetooth"; desc = "Bluetooth"; }
{ mods = "$mod CTRL"; key = "S"; action = "exec, nomarchy-menu capture"; desc = "Screenshot / capture"; } { mods = "$mod CTRL"; key = "S"; action = "exec, nomarchy-menu capture"; desc = "Screenshot / capture"; }
{ mods = "$mod CTRL"; key = "P"; action = "exec, nomarchy-menu colorpicker"; desc = "Color picker ( clipboard)"; }
{ mods = "$mod CTRL"; key = "A"; action = "exec, nomarchy-menu ask"; desc = "Ask Claude"; } { mods = "$mod CTRL"; key = "A"; action = "exec, nomarchy-menu ask"; desc = "Ask Claude"; }
{ mods = "$mod CTRL"; key = "D"; action = "exec, nomarchy-menu dnd"; desc = "Do Not Disturb toggle"; } { mods = "$mod CTRL"; key = "D"; action = "exec, nomarchy-menu dnd"; desc = "Do Not Disturb toggle"; }
@@ -59,10 +63,20 @@
{ mods = ""; key = "Print"; action = "exec, grim -g \"$(slurp)\" - | wl-copy"; desc = "Screenshot region clipboard"; } { mods = ""; key = "Print"; action = "exec, grim -g \"$(slurp)\" - | wl-copy"; desc = "Screenshot region clipboard"; }
]; ];
# Rendered only when the session has >1 layout (a comma in
# nomarchy.keyboard.layout) — hyprland.nix and rofi.nix both gate on
# that same condition, so the bind and its cheatsheet row stay in
# step. `current` targets the focused keyboard, so a board with its
# own per-device layout (a single one) is a no-op, never a leak.
multiLayoutBinds = [
{ mods = "$mod SHIFT"; key = "K"; action = "exec, hyprctl switchxkblayout current next"; desc = "Cycle keyboard layout"; }
];
extra = [ extra = [
{ keys = "SUPER + 1-9"; desc = "Switch to workspace 1-9"; } { keys = "SUPER + 1-9"; desc = "Switch to workspace 1-9"; }
{ keys = "SUPER + SHIFT + 1-9"; desc = "Move window to workspace 1-9"; } { keys = "SUPER + SHIFT + 1-9"; desc = "Move window to workspace 1-9"; }
{ keys = "SUPER + drag"; desc = "Move (LMB) / resize (RMB) window"; } { keys = "SUPER + drag"; desc = "Move (LMB) / resize (RMB) window"; }
{ keys = "Volume / Brightness"; desc = "Hardware keys, shown via the OSD"; } { keys = "Volume / Brightness"; desc = "Hardware keys, shown via the OSD"; }
{ keys = "Bar: 󰾪 click"; desc = "Caffeine hold the screen awake (idle inhibitor)"; }
]; ];
} }

View File

@@ -8,10 +8,12 @@
# GNOME session to lean on) and themed by Stylix's Qt config, so the # GNOME session to lean on) and themed by Stylix's Qt config, so the
# passphrase dialog tracks the palette. # passphrase dialog tracks the palette.
# #
# SSH_AUTH_SOCK is exported by the agent's shell integration (zsh, on via # SSH_AUTH_SOCK reaches both terminal and GUI clients: the agent's zsh
# home.shell.enableZshIntegration in shell.nix). Terminal git/ssh is the # integration sets it in interactive shells (home.shell.enableZshIntegration
# supported path; a GUI client launched outside a shell won't inherit the # in shell.nix), and a session-level home.sessionVariables export (below)
# socket — revisit with a session-level export if that's ever wanted. # covers GUI clients launched outside a shell — e.g. from the rofi launcher —
# which never inherit the interactive shell's copy. Both resolve the same
# socket via gpgconf, so they can't drift.
# #
# gnome-keyring (system side) stays the Secret Service for application # gnome-keyring (system side) stays the Secret Service for application
# secrets; modern gnome-keyring no longer runs an SSH agent, so there is no # secrets; modern gnome-keyring no longer runs an SSH agent, so there is no
@@ -38,5 +40,16 @@ in
defaultCacheTtlSsh = 1800; defaultCacheTtlSsh = 1800;
maxCacheTtlSsh = 7200; maxCacheTtlSsh = 7200;
}; };
# Session-level SSH_AUTH_SOCK so GUI clients launched outside a shell
# (rofi launcher, autostarted apps) reach the agent — the shell
# integration only covers interactive shells. Resolved with gpgconf at
# session-init time (the same lookup the shell integration uses), so it
# tracks the agent's real socket rather than a hardcoded path; valid
# before first use since the socket is systemd-activated. Reaches GUI
# apps the way NIXOS_OZONE_WL does — sourced into the login shell that
# starts Hyprland, so every spawned client inherits it.
home.sessionVariables.SSH_AUTH_SOCK =
"$(${pkgs.gnupg}/bin/gpgconf --list-dirs agent-ssh-socket)";
}; };
} }

48
modules/home/mime.nix Normal file
View File

@@ -0,0 +1,48 @@
# Default applications (xdg mimeapps.list) — without this, "open a
# PDF/photo" falls to whatever GTK guesses first (GIMP for images).
# Every association is mkDefault AND degrades gracefully by design: an
# entry whose .desktop file isn't installed is skipped by GIO/xdg-open,
# which then falls through to whatever else claims the type — so
# deleting an app from the template suite (or never uncommenting the
# browser) leaves no broken "open" behaviour, just the old guessing.
{ config, lib, ... }:
lib.mkIf config.nomarchy.mime.enable {
xdg.mimeApps = {
enable = lib.mkDefault true;
defaultApplications = lib.mapAttrs (_: v: lib.mkDefault v) {
"application/pdf" = "org.pwmt.zathura.desktop";
"image/png" = "imv.desktop";
"image/jpeg" = "imv.desktop";
"image/gif" = "imv.desktop";
"image/webp" = "imv.desktop";
"image/avif" = "imv.desktop";
"image/bmp" = "imv.desktop";
"image/tiff" = "imv.desktop";
"image/svg+xml" = "imv.desktop";
"video/mp4" = "mpv.desktop";
"video/webm" = "mpv.desktop";
"video/x-matroska" = "mpv.desktop";
"video/quicktime" = "mpv.desktop";
"audio/mpeg" = "mpv.desktop";
"audio/flac" = "mpv.desktop";
"audio/ogg" = "mpv.desktop";
# The template's active editor; degrades if you drop vscode.
"text/plain" = "code.desktop";
# The system-side Thunar (nomarchy.system.fileManager).
"inode/directory" = "thunar.desktop";
# The template ships no browser by default (open Decision) — these
# are inert until one is installed; firefox is the template's first
# suggestion. A different browser registers its own handler and
# wins once these entries stay dead.
"text/html" = "firefox.desktop";
"x-scheme-handler/http" = "firefox.desktop";
"x-scheme-handler/https" = "firefox.desktop";
};
};
}

View File

@@ -0,0 +1,72 @@
# Pure monitor-rule composition, split out of hyprland.nix so
# checks.display-profiles can exercise the overlay semantics directly —
# no Home Manager evaluation, no state-file fixture.
{ lib }:
rec {
# Skeleton for an entry built outside the option type (mirrors the
# monitorType defaults in options.nix).
defaults = {
resolution = "preferred"; position = "auto"; scale = 1;
transform = null; mirror = null; bitdepth = null; vrr = null; extra = "";
};
# An entry -> a Hyprland `monitor` rule. Unset optional fields are
# omitted; `resolution = "disable"` collapses to the short form.
rule = m:
if m.resolution == "disable" then "${m.name}, disable"
else lib.concatStringsSep ", " (
[ m.name m.resolution (toString m.position) (toString m.scale) ]
++ lib.optionals (m.transform != null) [ "transform" (toString m.transform) ]
++ lib.optionals (m.mirror != null) [ "mirror" m.mirror ]
++ lib.optionals (m.bitdepth != null) [ "bitdepth" (toString m.bitdepth) ]
++ lib.optionals (m.vrr != null) [ "vrr" (toString m.vrr) ]
++ lib.optional (m.extra != "") m.extra
);
# A profile's workspace pinning ({ "1" = "DP-3"; }) -> a Hyprland
# `workspace` rule. Only the active profile's pins are rendered
# (hyprland.nix); a workspace rule naming an absent output is inert.
workspaceRule = ws: out: "${ws}, monitor:${out}";
# The full overlay, three layers:
#
# 1. base (nomarchy.monitors) with the ACTIVE display profile's entries
# (settings.displayProfile naming a nomarchy.displayProfiles key)
# replacing base entries WHOLE, by name — a profile entry is a
# complete statement about that output. Outputs a profile doesn't
# name keep their base rules; a cleared/unknown/non-string value
# ("" after `nomarchy-display-profile base`, or hand-edited junk —
# the validator only warns) means base config only.
#
# 2. Menu-remembered per-output resolutions (settings.monitors:
# output-name -> "WxH@R", written instantly by the Display menu)
# overlaid field-level by name: a declared output keeps its
# position/scale/etc and only its resolution changes; an output
# covered solely by the `,preferred,auto,1` wildcard (e.g. the
# laptop's built-in panel) becomes a new rule with default
# position/scale. The menu pick wins over a hand-set resolution
# (it's the explicit live action) — EXCEPT onto disabled entries: a
# stale pick, made while the output was enabled, must not resurrect
# a panel the active profile (or the base config) disables.
#
# 3. Anything still uncovered falls to the wildcard (hyprland.nix
# prepends it).
resolve = { base, profiles, active, resOverrides }:
let
activeName = if builtins.isString active then active else "";
profileEntries = profiles.${activeName} or [ ];
profileNames = map (m: m.name) profileEntries;
baseMonitors =
lib.filter (m: ! lib.elem m.name profileNames) base
++ profileEntries;
declaredNames = map (m: m.name) baseMonitors;
in
map (m: if resOverrides ? ${m.name} && m.resolution != "disable"
then m // { resolution = resOverrides.${m.name}; }
else m)
baseMonitors
++ lib.mapAttrsToList
(name: res: defaults // { inherit name; resolution = res; })
(lib.filterAttrs (name: _: ! lib.elem name declaredNames) resOverrides);
}

View File

@@ -1,18 +1,75 @@
# Night light — a scheduled blue-light filter via hyprsunset (Hyprland's own # Night light — a scheduled blue-light filter via hyprsunset (Hyprland's own
# gamma/temperature tool). Warm at night, identity (no shift) by day; # gamma/temperature tool). Warm at night, identity (no shift) by day; the
# hyprsunset's time-based `profile` entries handle the schedule and pick the # schedule (temperature/sunrise/sunset) is tuned via nomarchy.nightlight.* in
# right state on session start. Opt-in via nomarchy.nightlight.enable. # home.nix and baked into the unit's time-based `profile`.
# #
# The hyprsunset HM service module is provided by home-manager; this only # Off by default and opt-in. Two git-tracked flags in the state file, both
# configures it. Override anything with plain services.hyprsunset.* options. # menu-written (no ~/.local/state):
{ config, lib, ... }: # settings.nightlight.installed — does the hyprsunset unit exist? Sticky; the
# option mkDefault-reads it, so the FIRST enable from the menu rebuilds (to
# create the unit) and an instant-off is never undone by a later rebuild.
# settings.nightlight.on — runtime on/off. Toggled INSTANTLY (write + systemctl,
# no rebuild); read by the unit's ExecCondition (should-start) at session
# start so the choice survives logout/reboot via the *live* state, not the
# eval-frozen store copy. A later rebuild bakes the same value (no divergence).
{ config, lib, pkgs, ... }:
let let
cfg = config.nomarchy.nightlight; cfg = config.nomarchy.nightlight;
s = config.nomarchy.settings.nightlight;
sync = lib.getExe config.nomarchy.package;
# Runtime-on default for when the `on` key hasn't been written yet (e.g. right
# after the first enable). Baked at eval; only used when the live key is absent.
onDefault = lib.boolToString s.on;
nomarchy-nightlight = pkgs.writeShellScriptBin "nomarchy-nightlight" ''
unit=hyprsunset.service
# Instant runtime on/off: write the in-flake state WITHOUT a rebuild.
write_on() { ${sync} --quiet set settings.nightlight.on "$1" --no-switch; }
# First enable: mark the feature installed and REBUILD to create the unit
# (the one rebuild we accept; every toggle after is instant).
install_feature() { ${sync} --quiet set settings.nightlight.installed true; }
# Read the LIVE working-tree on/off (~/.nomarchy via $NOMARCHY_PATH), not the
# store copy baked into this generation; fall back to the eval-time default
# when absent. Normalise Python's True/False bool rendering.
is_on() {
v=$(${sync} get settings.nightlight.on 2>/dev/null) || v=${onDefault}
case "$v" in true|True) return 0 ;; *) return 1 ;; esac
}
installed() { systemctl --user cat "$unit" >/dev/null 2>&1; }
start() { systemctl --user start "$unit" 2>/dev/null || true; }
stop() { systemctl --user stop "$unit" 2>/dev/null || true; }
case "''${1:-toggle}" in
should-start) is_on ;; # ExecCondition gate (login/reboot)
status)
# Waybar (polls every 3s): moon while running; print nothing otherwise so
# the module self-hides enable / re-enable from the System menu.
systemctl --user is-active --quiet "$unit" \
&& printf '{"text":"󰖔","tooltip":"Night light on warm on schedule (click to disable)","class":"on"}\n'
exit 0 ;;
on)
if installed; then write_on true; start; else install_feature; start; fi ;;
off) write_on false; stop ;;
toggle)
if systemctl --user is-active --quiet "$unit"; then
write_on false; stop # on -> off (instant)
elif installed; then
write_on true; start # installed, off -> on (instant)
else
install_feature; start # first enable (rebuilds)
fi ;;
*) echo "usage: nomarchy-nightlight [toggle|status|on|off|should-start]" >&2; exit 64 ;;
esac
'';
in in
{ {
config = lib.mkIf cfg.enable { config = {
services.hyprsunset = { # Unit presence tracks the sticky `installed` flag the menu writes (first
# enable rebuilds). mkDefault so a hand-set nomarchy.nightlight.enable in
# home.nix also works as a declarative opt-in.
nomarchy.nightlight.enable = lib.mkDefault s.installed;
services.hyprsunset = lib.mkIf cfg.enable {
enable = true; enable = true;
settings.profile = [ settings.profile = [
# Daytime: identity = no colour change. # Daytime: identity = no colour change.
@@ -21,5 +78,13 @@ in
{ time = cfg.sunset; temperature = cfg.temperature; } { time = cfg.sunset; temperature = cfg.temperature; }
]; ];
}; };
# Gate the unit on the LIVE on/off state at start time (login/reboot), not
# at eval time — so a menu toggle (written without a rebuild) is honoured on
# the next session. A failed condition skips the unit (inactive, not failed).
systemd.user.services.hyprsunset.Service.ExecCondition =
lib.mkIf cfg.enable "${nomarchy-nightlight}/bin/nomarchy-nightlight should-start";
home.packages = [ nomarchy-nightlight ];
}; };
} }

View File

@@ -1,6 +1,6 @@
# User-level `nomarchy.*` options — the full surface downstream users # User-level `nomarchy.*` options — the full surface downstream users
# configure in their home.nix. Kept small on purpose. # configure in their home.nix. Kept small on purpose.
{ lib, pkgs, ... }: { config, lib, pkgs, ... }:
let let
# One output's layout — turned into a Hyprland `monitor` rule in # One output's layout — turned into a Hyprland `monitor` rule in
@@ -55,6 +55,33 @@ let
}; };
}; };
}; };
# One display profile: a monitor layout plus optional workspace→output
# pinning. A bare list of monitor entries still works (the original
# shape) — hyprland.nix normalizes it to { monitors = […]; }.
# (either, not coercedTo: coercedTo refuses list-of-submodule sources.)
displayProfileType = lib.types.either
(lib.types.listOf monitorType)
(lib.types.submodule {
options = {
monitors = lib.mkOption {
type = lib.types.listOf monitorType;
default = [ ];
description = "nomarchy.monitors-style entries; each replaces the base entry for the same output whole.";
};
workspaces = lib.mkOption {
type = lib.types.attrsOf lib.types.str;
default = { };
example = { "1" = "DP-3"; "9" = "eDP-1"; };
description = ''
Workspace output pinning while this profile is active
(Hyprland `workspace = <ws>, monitor:<output>` rules).
Applied instantly on profile switch (existing workspaces are
moved over) and baked at the next rebuild.
'';
};
};
});
in in
{ {
options.nomarchy = { options.nomarchy = {
@@ -73,13 +100,13 @@ in
# ── Preferences ──────────────────────────────────────────────── # ── Preferences ────────────────────────────────────────────────
terminal = lib.mkOption { terminal = lib.mkOption {
type = lib.types.str; type = lib.types.str;
default = "ghostty"; default = config.nomarchy.settings.terminal or "ghostty";
description = "Terminal emulator command, used by keybinds and $TERMINAL."; description = "Terminal emulator command, used by keybinds and $TERMINAL.";
}; };
keyboard.layout = lib.mkOption { keyboard.layout = lib.mkOption {
type = lib.types.str; type = lib.types.str;
default = "us"; default = config.nomarchy.settings.keyboard.layout or "us";
example = "de"; example = "de";
description = '' description = ''
XKB layout for the Hyprland session. The console (and the LUKS XKB layout for the Hyprland session. The console (and the LUKS
@@ -105,10 +132,14 @@ in
picker. When non-empty, a small watcher runs in the session: when a picker. When non-empty, a small watcher runs in the session: when a
keyboard connects that isn't covered by nomarchy.keyboard.devices and keyboard connects that isn't covered by nomarchy.keyboard.devices and
hasn't been chosen before, it pops a rofi picker (these layouts plus hasn't been chosen before, it pops a rofi picker (these layouts plus
the primary nomarchy.keyboard.layout), applies the choice, and the primary nomarchy.keyboard.layout), applies the choice to that
remembers it per-device (~/.local/state) re-applying automatically keyboard only (a per-device kb_layout, so the built-in board is left
on later reconnects. The runtime-remember complement to the alone), and remembers it in the git-tracked in-flake state
declarative keyboard.devices; a stateful runtime piece by design. (settings.keyboard.devices, not ~/.local/state) re-applied
automatically on later reconnects and across reboots. Each remembered
choice graduates into nomarchy.keyboard.devices on the next rebuild
(a generated device block). The runtime-remember complement to the
declarative keyboard.devices.
''; '';
}; };
@@ -135,7 +166,9 @@ in
nomarchy.keyboard.layout for that keyboard only e.g. an external nomarchy.keyboard.layout for that keyboard only e.g. an external
keyboard that's physically a different layout than the laptop's keyboard that's physically a different layout than the laptop's
built-in one. Hyprland applies it automatically whenever that built-in one. Hyprland applies it automatically whenever that
keyboard is connected. keyboard is connected. The interactive watcher
(nomarchy.keyboard.layouts) also writes its remembered picks here on
the next rebuild, so a runtime choice graduates into reproducible config.
''; '';
}; };
@@ -184,6 +217,32 @@ in
}; };
}; };
updates = {
enable = lib.mkEnableOption ''
passive update awareness: a background check (systemd user timer) that
compares the flake's locked inputs (nixpkgs, the Nomarchy input, )
against upstream and when Flatpak is enabled counts Flatpak
updates, surfacing a Waybar indicator + a notification when something
is available. It never changes anything; you still run sys-update /
home-update / flatpak update yourself'' // { default = config.nomarchy.settings.updates.enable or false; };
interval = lib.mkOption {
type = lib.types.str;
default = "daily";
example = "6h";
description = "How often to check, as a systemd OnCalendar expression.";
};
flatpak = lib.mkOption {
type = lib.types.bool;
default = true;
description = ''
Also count available Flatpak updates when the `flatpak` CLI is
present (i.e. nomarchy.services.flatpak is on). No effect otherwise.
'';
};
};
monitors = lib.mkOption { monitors = lib.mkOption {
type = lib.types.listOf monitorType; type = lib.types.listOf monitorType;
default = [ ]; default = [ ];
@@ -205,11 +264,88 @@ in
''; '';
}; };
launchOrFocus = lib.mkOption {
type = lib.types.listOf (lib.types.submodule {
options = {
key = lib.mkOption {
type = lib.types.str;
example = "B";
description = "The key (with `mods`) that focuses-or-launches the app.";
};
mods = lib.mkOption {
type = lib.types.str;
default = "$mod";
description = "Modifier string, Hyprland syntax (\"$mod\", \"$mod SHIFT\").";
};
class = lib.mkOption {
type = lib.types.str;
example = "firefox";
description = "Window class to focus (case-insensitive; see `hyprctl clients`).";
};
command = lib.mkOption {
type = lib.types.str;
default = "";
description = "Command to launch when no window matches (defaults to the class).";
};
desc = lib.mkOption {
type = lib.types.str;
default = "";
description = "Cheatsheet label (defaults to \"Focus or launch <command>\").";
};
};
});
default = [ ];
example = lib.literalExpression ''
[
{ key = "B"; class = "firefox"; }
{ key = "O"; class = "obsidian"; }
]
'';
description = ''
Launch-or-focus binds: the key focuses the app's existing window
(case-insensitive class match) or launches it if none is open. Each
entry generates a Hyprland bind AND a SUPER+? cheatsheet row. The
launcher self-gates: a bind whose command isn't installed notifies
instead of failing silently.
'';
};
displayProfiles = lib.mkOption {
type = lib.types.attrsOf displayProfileType;
default = { };
example = lib.literalExpression ''
{
docked = {
monitors = [
{ name = "eDP-1"; resolution = "disable"; }
{ name = "DP-3"; position = "0x0"; }
{ name = "DP-4"; position = "auto-right"; }
];
workspaces = { "1" = "DP-3"; "9" = "DP-4"; };
};
undocked = [ { name = "eDP-1"; position = "0x0"; } ];
}
'';
description = ''
Named display layouts for the same outputs (docked, undocked, ):
a list of nomarchy.monitors-style entries, or an attrset with
`monitors` plus optional `workspaces` (workspace output pinning
while the profile is active). Switch from the menu (System
Display Profiles) or `nomarchy-display-profile apply <name>`:
the profile's rules apply instantly via hyprctl and the choice
persists in the in-flake state (settings.displayProfile), so the
next rebuild bakes the active profile's entries over
nomarchy.monitors by name. Outputs a profile doesn't name keep
their base rules.
'';
};
# ── Component toggles ────────────────────────────────────────── # ── Component toggles ──────────────────────────────────────────
hyprland.enable = lib.mkEnableOption "Nomarchy's Hyprland configuration" // { default = true; }; hyprland.enable = lib.mkEnableOption "Nomarchy's Hyprland configuration" // { default = true; };
waybar.enable = lib.mkEnableOption "Nomarchy's Waybar configuration" // { default = true; }; waybar.enable = lib.mkEnableOption "Nomarchy's Waybar configuration" // { default = true; };
rofi.enable = lib.mkEnableOption "Nomarchy's themed rofi launcher + the nomarchy-menu dispatcher" // { default = true; }; rofi.enable = lib.mkEnableOption "Nomarchy's themed rofi launcher + the nomarchy-menu dispatcher" // { default = true; };
swaync.enable = lib.mkEnableOption "swaync notifications, themed from the state file" // { default = true; }; swaync.enable = lib.mkEnableOption "swaync notifications, themed from the state file" // { default = true; };
batteryNotify.enable = lib.mkEnableOption "low-battery notifications at the bar's thresholds (25% low, 10% critical that one stays up until dismissed); self-gating, a silent no-op on machines without a battery" // { default = true; };
idle.enable = lib.mkEnableOption "hyprlock + hypridle (idle lock, display off, suspend)" // { default = true; }; idle.enable = lib.mkEnableOption "hyprlock + hypridle (idle lock, display off, suspend)" // { default = true; };
yazi.enable = lib.mkEnableOption "the yazi TUI file manager, themed with a curated plugin set" // { default = true; }; yazi.enable = lib.mkEnableOption "the yazi TUI file manager, themed with a curated plugin set" // { default = true; };
osd.enable = lib.mkEnableOption "swayosd on-screen display for volume/brightness/mute" // { default = true; }; osd.enable = lib.mkEnableOption "swayosd on-screen display for volume/brightness/mute" // { default = true; };
@@ -220,6 +356,8 @@ in
btop.enable = lib.mkEnableOption "btop with the per-theme nomarchy theme" // { default = true; }; btop.enable = lib.mkEnableOption "btop with the per-theme nomarchy theme" // { default = true; };
stylix.enable = lib.mkEnableOption "Stylix theming for the long tail of apps (GTK, Qt, cursors)" // { default = true; }; stylix.enable = lib.mkEnableOption "Stylix theming for the long tail of apps (GTK, Qt, cursors)" // { default = true; };
displays.enable = lib.mkEnableOption "the nwg-displays interactive monitor arranger (a helper to find nomarchy.monitors values; the declarative config stays the source of truth)" // { default = true; }; displays.enable = lib.mkEnableOption "the nwg-displays interactive monitor arranger (a helper to find nomarchy.monitors values; the declarative config stays the source of truth)" // { default = true; };
viewers.enable = lib.mkEnableOption "the document/image viewers (zathura, Stylix-themed, + imv)" // { default = true; };
mime.enable = lib.mkEnableOption "default file associations (xdg mimeapps.list: PDF/image/video/text/browser/directory); entries for absent apps are skipped, so it degrades with the package suite" // { default = true; };
# ── Computed (read-only) ─────────────────────────────────────── # ── Computed (read-only) ───────────────────────────────────────
theme = lib.mkOption { theme = lib.mkOption {
@@ -228,6 +366,17 @@ in
description = "The parsed theme state (stateFile merged over defaults)."; description = "The parsed theme state (stateFile merged over defaults).";
}; };
settings = lib.mkOption {
type = lib.types.attrs;
readOnly = true;
description = ''
Parsed feature settings the `settings` section of the state file,
what the menu/Waybar toggles write (e.g. settings.nightlight.enable).
Feature options mkDefault-read from here, so a menu toggle lands in the
in-flake state (git-tracked, reproducible) rather than ~/.local/state.
'';
};
lib = lib.mkOption { lib = lib.mkOption {
type = lib.types.attrs; type = lib.types.attrs;
readOnly = true; readOnly = true;

View File

@@ -0,0 +1,99 @@
# Screen recording (menu Tools Capture). One helper owns the whole
# lifecycle: `start` launches wl-screenrec (VAAPI hardware encode) and
# falls back to wf-recorder (software x264) if it dies on the spot —
# e.g. no usable VAAPI device; `status` feeds the self-gating Waybar
# indicator (waybar.nix, signal 8), whose click is the ONE stop surface
# (`stop` SIGINTs the recorder so it finalizes the file cleanly). State
# is a runtime pidfile — nothing persists across sessions.
{ config, lib, pkgs, ... }:
lib.mkIf config.nomarchy.rofi.enable {
home.packages = [
pkgs.wl-screenrec
pkgs.wf-recorder
(pkgs.writeShellScriptBin "nomarchy-record" ''
run="''${XDG_RUNTIME_DIR:-/run/user/$(id -u)}"
pidfile="$run/nomarchy-record.pid"
filefile="$run/nomarchy-record.file"
alive() { [ -f "$pidfile" ] && kill -0 "$(cat "$pidfile")" 2>/dev/null; }
poke_bar() { pkill -RTMIN+8 waybar 2>/dev/null || true; }
case "''${1:-}" in
start)
if alive; then
notify-send "Screen recording" "Already recording stop it via the in the bar."
exit 1
fi
target="''${2:-screen}"; audio="''${3:-}"
geo=""
if [ "$target" = region ]; then
geo=$(slurp) || exit 0 # Esc in slurp = cancel, silently
fi
dir="$HOME/Videos/Recordings"
mkdir -p "$dir"
file="$dir/$(date +%Y%m%d-%H%M%S).mp4"
launch() { # launch <recorder> -> 0 if it survived startup
case "$1" in
wl-screenrec)
setsid wl-screenrec ''${geo:+-g "$geo"} \
''${audio:+--audio} -f "$file" >/dev/null 2>&1 & ;;
wf-recorder)
setsid wf-recorder ''${geo:+-g "$geo"} \
''${audio:+-a} -f "$file" >/dev/null 2>&1 & ;;
esac
echo $! > "$pidfile"
sleep 0.7
alive
}
started=""
if command -v wl-screenrec >/dev/null 2>&1 && launch wl-screenrec; then
started=wl-screenrec
elif command -v wf-recorder >/dev/null 2>&1 && launch wf-recorder; then
started=wf-recorder # software fallback (no VAAPI device)
fi
if [ -z "$started" ]; then
rm -f "$pidfile"
notify-send "Screen recording" "Recorder failed to start (no wl-screenrec/wf-recorder able to run)."
exit 1
fi
printf '%s' "$file" > "$filefile"
poke_bar
notify-send "Screen recording" "Recording ($started)''${audio:+ with audio} click the in the bar to stop." ;;
stop)
if ! alive; then
rm -f "$pidfile" "$filefile"; poke_bar
notify-send "Screen recording" "No recording is running."
exit 0
fi
pid=$(cat "$pidfile")
kill -INT "$pid" 2>/dev/null # graceful: both recorders finalize on INT
for _ in $(seq 1 50); do kill -0 "$pid" 2>/dev/null || break; sleep 0.1; done
kill -0 "$pid" 2>/dev/null && kill "$pid" 2>/dev/null
file=$(cat "$filefile" 2>/dev/null || echo "?")
rm -f "$pidfile" "$filefile"
poke_bar
notify-send "Screen recording saved" "$file" ;;
active)
alive ;;
status)
# Waybar JSON while recording; nothing => module hidden.
if alive; then
file=$(cat "$filefile" 2>/dev/null || echo "")
printf '{"text":" REC","class":"recording","tooltip":"Recording to %s click to stop"}\n' "''${file##*/}"
fi
exit 0 ;;
*)
echo "usage: nomarchy-record start [region|screen] [audio] | stop | active | status" >&2
exit 2 ;;
esac
'')
];
}

View File

@@ -19,6 +19,102 @@ let
rasiOverride = cfg.themesDir + "/${t.slug}/rofi.rasi"; rasiOverride = cfg.themesDir + "/${t.slug}/rofi.rasi";
hasRasiOverride = builtins.pathExists rasiOverride; hasRasiOverride = builtins.pathExists rasiOverride;
# App-launcher theme (SUPER+Space/D → `rofi -show drun -theme launcher`).
# Kept separate from the base theme so a theme can give the launcher its
# own layout (e.g. Boreal's icon grid) WITHOUT that layout leaking into
# the text menus, which share the base theme and need a list. The default
# just re-imports the active base theme (custom.rasi for generated themes,
# rofi.rasi for whole-swaps — see the dataFile below), so every other
# theme's launcher is byte-for-byte its list.
launcherOverride = cfg.themesDir + "/${t.slug}/launcher.rasi";
hasLauncherOverride = builtins.pathExists launcherOverride;
# ── Visual theme picker ──────────────────────────────────────────────
# Every preset (themes/<slug>.json) is read at eval time to build a grid
# of real desktop previews. Grouped dark-first then light — the previews
# make the mode obvious at a glance, so no light/dark submenu split — and
# the active theme is marked ✓. "Active" is just t.slug: every switch
# rebuilds this script, so the baked-in mark always tracks the live theme.
themeFiles = lib.filterAttrs
(n: ty: ty == "regular" && lib.hasSuffix ".json" n)
(builtins.readDir cfg.themesDir);
themeData = lib.mapAttrsToList (fname: _:
let
j = builtins.fromJSON (builtins.readFile (cfg.themesDir + "/${fname}"));
slug = j.slug or (lib.removeSuffix ".json" fname);
in {
inherit slug;
name = j.name or slug;
mode = j.mode or "dark";
hasPreview = builtins.pathExists (cfg.themesDir + "/${slug}/preview.png");
}) themeFiles;
byName = lib.sort (a: b: a.name < b.name);
orderedThemes =
byName (lib.filter (th: th.mode != "light") themeData) # dark group
++ byName (lib.filter (th: th.mode == "light") themeData); # then light
# rofi's element-icon cells are SQUARE, so a 16:9 preview would letterbox
# (theme-coloured bands top/bottom). Build-time crop each committed 480×270
# preview to a centred square so it fills the cell edge-to-edge. The source
# preview.png stays 480×270 (untouched) — only the displayed thumb is square.
themeThumbs = pkgs.runCommand "nomarchy-theme-thumbs"
{ nativeBuildInputs = [ pkgs.imagemagick ]; } ''
mkdir -p $out
${lib.concatMapStringsSep "\n"
(th: lib.optionalString th.hasPreview ''
magick ${cfg.themesDir + "/${th.slug}/preview.png"} \
-resize 360x360^ -gravity center -extent 360x360 -strip $out/${th.slug}.png
'')
orderedThemes}
'';
# One grid row per theme (square thumb + name, ✓ on the active one), and a
# Name→slug map so the picked label resolves back to the preset that
# nomarchy-theme-sync applies (pretty names ≠ slugs, hence the map). Themes
# with no preview degrade to a plain-name row.
themeRows = lib.concatMapStringsSep "\n" (th:
let label = th.name + lib.optionalString (th.slug == t.slug) " ";
in if th.hasPreview
then "row ${lib.escapeShellArg label} ${themeThumbs}/${th.slug}.png"
else "printf '%s\\n' ${lib.escapeShellArg label}")
orderedThemes;
themeSlugMap = lib.concatMapStringsSep "\n" (th:
" [${lib.escapeShellArg th.name}]=${lib.escapeShellArg th.slug}")
orderedThemes;
# Per-invocation grid layout (cards: a 16:9 preview above a centered name),
# overriding the single-column list theme just for this menu.
# · The icon box is sized 16:9 (rofi 2.0 takes a two-value `size`) to
# match the 480×270 previews exactly — they fill it with no letterbox.
# · The window is sized to the *content* (3 × icon + paddings), not a
# percentage, so a column is no wider than its card — otherwise the
# selection highlight balloons out around the image.
# · flow: horizontal lays cards out row-by-row (rofi's default Vertical
# fills column-by-column, so Down at a column's foot jumped to the top
# of the next column instead of scrolling the page).
# Grid dial — the previews are as big as the icon px allows; screen *height*
# caps it, so showing 2 rows (not 3) lets each card grow a lot. Scrolling
# reaches the rest. Tune themeGridIconW for size; the window resizes to fit.
# rofi's element-icon `size` is a SINGLE value (the manual only documents
# one) — a two-value "WxH" is silently collapsed, which is why the preview
# used to render tiny inside a too-wide column. So: size = the card *width*,
# rofi fits the 16:9 image tight to it (no square letterbox), and the window
# is sized so a column is exactly that width — the preview fills the cell.
themeGridCols = 3;
themeGridLines = 3;
themeGridIconW = 240; # square preview card side (px) — the size knob
themeGridPad = 0; # margin around the preview inside its cell (px)
themeGridGap = 8; # gap between cards (px)
themeGridThemeStr = lib.escapeShellArg (lib.concatStringsSep " " [
# window width = cards + their padding + inter-card gaps + chrome (~20px)
"window { width: ${toString (themeGridCols * (themeGridIconW + 2 * themeGridPad) + (themeGridCols - 1) * themeGridGap + 20)}px; }"
"listview { columns: ${toString themeGridCols}; lines: ${toString themeGridLines}; spacing: ${toString themeGridGap}px; flow: horizontal; }"
"element { orientation: vertical; padding: ${toString themeGridPad}px; spacing: 2px; }"
"element-icon { size: ${toString themeGridIconW}px; }"
"element-text { horizontal-align: 0.5; }"
]);
# Keybindings cheatsheet (SUPER+? → the `keybinds` menu module). Built # Keybindings cheatsheet (SUPER+? → the `keybinds` menu module). Built
# from the SAME ./keybinds.nix that hyprland.nix binds, so it can never # from the SAME ./keybinds.nix that hyprland.nix binds, so it can never
# drift from the live shortcuts. "$mod" reads as SUPER; rows are padded # drift from the live shortcuts. "$mod" reads as SUPER; rows are padded
@@ -31,13 +127,36 @@ let
let m = lib.replaceStrings [ "$mod" ] [ "SUPER" ] b.mods; let m = lib.replaceStrings [ "$mod" ] [ "SUPER" ] b.mods;
prefix = if m == "" then "" else lib.concatStringsSep " + " (lib.splitString " " m) + " + "; prefix = if m == "" then "" else lib.concatStringsSep " + " (lib.splitString " " m) + " + ";
key = lib.replaceStrings [ "question" "slash" ] [ "?" "/" ] b.key; key = lib.replaceStrings [ "question" "slash" ] [ "?" "/" ] b.key;
in prefix + key; # ? already implies Shift — the bind needs SHIFT in its modmask
# (keybinds.nix), but showing it would read double, so collapse
# "SHIFT + ?" back to the documented "?".
in lib.replaceStrings [ "SHIFT + ?" ] [ "?" ] (prefix + key);
cheatRows = cheatRows =
map (b: padRight 22 (prettyKeys b) + b.desc) keybinds.binds map (b: padRight 22 (prettyKeys b) + b.desc) (keybinds.binds
++ lib.optionals (lib.hasInfix "," cfg.keyboard.layout)
keybinds.multiLayoutBinds)
# Launch-or-focus binds (nomarchy.launchOrFocus, generated in
# hyprland.nix) — same renderer, so they land in the cheatsheet too.
++ map (e: padRight 22 (prettyKeys e)
+ (if e.desc != "" then e.desc
else "Focus or launch ${if e.command == "" then lib.toLower e.class else e.command}"))
cfg.launchOrFocus
++ map (e: padRight 22 e.keys + e.desc) keybinds.extra; ++ map (e: padRight 22 e.keys + e.desc) keybinds.extra;
cheatsheetFile = pkgs.writeText "nomarchy-keybinds.txt" cheatsheetFile = pkgs.writeText "nomarchy-keybinds.txt"
(lib.concatStringsSep "\n" cheatRows + "\n"); (lib.concatStringsSep "\n" cheatRows + "\n");
# Dimmed keybind hints on menu rows (item 28c) — the same keybinds.nix
# + prettyKeys the cheatsheet uses, so they can never drift from the
# live binds. Pango spans; the menus that show them pass -markup-rows.
# Empty (no hint) when nothing binds the action.
hintForAction = action:
let m = lib.filter (b: b.action == action) keybinds.binds;
in lib.optionalString (m != [ ])
" <span size='small' alpha='55%'>${prettyKeys (lib.head m)}</span>";
menuHint = module: hintForAction "exec, nomarchy-menu ${module}";
# Apps opens rofi drun directly, not a nomarchy-menu module.
appsHint = " <span size='small' alpha='55%'>SUPER + D</span>";
nomarchy-menu = pkgs.writeShellScriptBin "nomarchy-menu" '' nomarchy-menu = pkgs.writeShellScriptBin "nomarchy-menu" ''
# Nomarchy menu dispatcher thin presentation layer over # Nomarchy menu dispatcher thin presentation layer over
# `rofi -dmenu`; actions delegate to nomarchy-theme-sync, systemctl, # `rofi -dmenu`; actions delegate to nomarchy-theme-sync, systemctl,
@@ -64,6 +183,18 @@ let
# rofi has show-icons (on globally; passed explicitly on the icon menus). # rofi has show-icons (on globally; passed explicitly on the icon menus).
row() { printf '%s\0icon\x1f%s\n' "$1" "$2"; } row() { printf '%s\0icon\x1f%s\n' "$1" "$2"; }
# Back-audit exceptions (item 24): external modi/tools (calc, emoji,
# networkmanager_dmenu, rofi-pulse-select) can't take an injected row
# Esc is their back path; free-text prompts (web, ask) have no list to
# append to Esc cancels. Everything hand-rolled gets the row below.
# General menu convention: every list menu ends with a " Back" entry that
# returns one level up (so you never have to Esc out and reopen). `back`
# emits it with an icon; plain pick-lists append the bare label instead.
# Always matched EXACTLY (" Back") so it can't collide with clipboard or
# filename content. The arrow + go-previous icon read as "go back".
BACK=" Back"
back() { row "$BACK" go-previous; }
case "''${1:-}" in case "''${1:-}" in
power) power)
choice=$( { choice=$( {
@@ -73,8 +204,10 @@ let
row "Hibernate" system-hibernate row "Hibernate" system-hibernate
row "Reboot" system-reboot row "Reboot" system-reboot
row "Shutdown" system-shutdown row "Shutdown" system-shutdown
back
} | rofi -dmenu -show-icons -p Power) || exit 0 } | rofi -dmenu -show-icons -p Power) || exit 0
case "$choice" in case "$choice" in
"$BACK") exec "$0" ;;
*Lock) loginctl lock-session ;; *Lock) loginctl lock-session ;;
*Logout) hyprctl dispatch exit ;; *Logout) hyprctl dispatch exit ;;
*Suspend) systemctl suspend ;; *Suspend) systemctl suspend ;;
@@ -92,17 +225,30 @@ let
exit 0 exit 0
fi fi
cur=$(powerprofilesctl get 2>/dev/null) cur=$(powerprofilesctl get 2>/dev/null)
choice=$(powerprofilesctl list 2>/dev/null \ choice=$( { powerprofilesctl list 2>/dev/null | sed -nE 's/^[* ] ([a-z-]+):$/\1/p'; printf '%s\n' "$BACK"; } \
| sed -nE 's/^[* ] ([a-z-]+):$/\1/p' \
| rofi -dmenu -p "profile (now: $cur)") || exit 0 | rofi -dmenu -p "profile (now: $cur)") || exit 0
[ "$choice" = "$BACK" ] && exec "$0" system
[ -n "$choice" ] && powerprofilesctl set "$choice" ;; [ -n "$choice" ] && powerprofilesctl set "$choice" ;;
theme) theme)
choice=$(nomarchy-theme-sync list | rofi -dmenu -p theme) || exit 0 # Visual picker: a grid of real desktop previews (rows + Nameslug
[ -n "$choice" ] && exec nomarchy-theme-sync apply "$choice" ;; # map generated from the presets at build time). Pick a card apply
# that slug. The grid layout is a per-invocation -theme-str override.
declare -A THEME_SLUG=(
${themeSlugMap}
)
choice=$( {
${themeRows}
back
} | rofi -dmenu -show-icons -p Theme -theme-str ${themeGridThemeStr}) || exit 0
[ "$choice" = "$BACK" ] && exec "$0"
choice="''${choice% }" # drop the active marker if present
slug="''${THEME_SLUG[$choice]:-}"
[ -n "$slug" ] && exec nomarchy-theme-sync apply "$slug" ;;
clipboard) clipboard)
sel=$(cliphist list | rofi -dmenu -p clip) || exit 0 sel=$( { cliphist list; printf '%s\n' "$BACK"; } | rofi -dmenu -p clip) || exit 0
[ "$sel" = "$BACK" ] && exec "$0" tools
printf '%s' "$sel" | cliphist decode | wl-copy ;; printf '%s' "$sel" | cliphist decode | wl-copy ;;
calc) calc)
@@ -114,10 +260,10 @@ let
-calc-command "echo -n '{result}' | wl-copy" ;; -calc-command "echo -n '{result}' | wl-copy" ;;
files) files)
sel=$(fd . "$HOME" --type f --hidden --exclude .git --exclude .cache 2>/dev/null \ sel=$( { fd . "$HOME" --type f --hidden --exclude .git --exclude .cache 2>/dev/null \
| head -n 50000 \ | head -n 50000 | sed "s|^$HOME/||"; printf '%s\n' "$BACK"; } \
| sed "s|^$HOME/||" \
| rofi -dmenu -p file) || exit 0 | rofi -dmenu -p file) || exit 0
[ "$sel" = "$BACK" ] && exec "$0" tools
[ -n "$sel" ] && exec xdg-open "$HOME/$sel" ;; [ -n "$sel" ] && exec xdg-open "$HOME/$sel" ;;
emoji) emoji)
@@ -132,34 +278,223 @@ let
exec xdg-open "https://www.google.com/search?q=$(urlencode "$q")" ;; exec xdg-open "https://www.google.com/search?q=$(urlencode "$q")" ;;
network) network)
# nmtui in a terminal (NetworkManager is the system network stack). # Native rofi wifi/VPN picker (networkmanager_dmenu) over the system
exec ${cfg.terminal} -e nmtui ;; # NetworkManager replaces the old nmtui-in-a-terminal flow. Reads
# its config from xdg.configFile below (told to drive rofi).
# External tool: no injected Back row possible Esc closes.
exec networkmanager_dmenu ;;
vpn)
# VPN setup + management (nomarchy-vpn): NM VPN/WireGuard connect +
# config import + Tailscale. Distinct from `network` (the wifi picker).
exec nomarchy-vpn ;;
bluetooth) bluetooth)
# blueman-manager GUI (services.blueman.enable, system-side). # blueman-manager GUI (services.blueman.enable, system-side).
exec blueman-manager ;; exec blueman-manager ;;
printers)
# system-config-printer GUI (the CUPS admin app), installed by
# nomarchy.services.printing. Self-gated in the System menu; guard
# here too in case it's invoked directly.
command -v system-config-printer >/dev/null 2>&1 \
&& exec system-config-printer
notify-send "Printers" "Not available (nomarchy.services.printing off?)."; exit 0 ;;
audio)
# PipeWire (pulse) sink/source switcher via rofi-pulse-select. Self-
# gated in the System menu on the pulse socket; guarded here too.
if [ ! -e "''${XDG_RUNTIME_DIR:-/run/user/$(id -u)}/pulse/native" ]; then
notify-send "Audio" "No PipeWire/PulseAudio socket (nomarchy.audio off?)."; exit 0
fi
choice=$( {
row "Output device" audio-volume-high
row "Input device" audio-input-microphone
back
} | rofi -dmenu -show-icons -p Audio) || exit 0
case "$choice" in
"$BACK") exec "$0" system ;;
*Output*) exec rofi-pulse-select sink ;;
*Input*) exec rofi-pulse-select source ;;
esac ;;
display)
# Per-output resolution picker. Applies the chosen mode INSTANTLY via
# `hyprctl keyword monitor` keeping the output's current position and
# scale, so only the resolution changes and PERSISTS it to the in-flake
# state (settings.monitors.<name>, --no-switch = no rebuild), so the
# choice survives logout/reboot. A later rebuild bakes the same
# resolution into the generated monitor rule (hyprland.nix overlays
# settings.monitors onto nomarchy.monitors by name). Self-gated to the
# Hyprland session; guard here too in case it's invoked directly.
command -v hyprctl >/dev/null 2>&1 \
|| { notify-send "Display" "Hyprland is not running."; exit 0; }
mons=$(hyprctl monitors -j)
# Choose the output (skip the chooser when only one is connected and
# there are no profiles the Profiles row needs the chooser to live in).
profs=; command -v nomarchy-display-profile >/dev/null 2>&1 && profs=1
if [ -n "$profs" ] || [ "$(printf '%s' "$mons" | jq -r '.[].name' | grep -c .)" -gt 1 ]; then
name=$( {
[ -n "$profs" ] && printf 'Profiles \n'
printf '%s' "$mons" | jq -r '.[] | "\(.name) · \(.width)x\(.height)@\(.refreshRate|round)Hz"'
printf '%s\n' "$BACK"
} | rofi -dmenu -p Display ) || exit 0
[ "$name" = "$BACK" ] && exec "$0" system
[ "$name" = "Profiles " ] && exec "$0" display-profile
name=''${name%% ·*} # strip the " · WxH@R" hint
else
name=$(printf '%s' "$mons" | jq -r '.[0].name')
fi
[ -n "$name" ] || exit 0
# Pick a mode: a few Hyprland resolution tokens, then the output's
# advertised modes (rounded refresh rate, deduped, highest first). The
# prompt shows the current mode.
cur=$(printf '%s' "$mons" | jq -r --arg n "$name" \
'.[]|select(.name==$n)|"\(.width)x\(.height)@\(.refreshRate|round)"')
mode=$( {
printf 'preferred\nhighres\nhighrr\n'
printf '%s' "$mons" | jq -r --arg n "$name" '.[]|select(.name==$n)|.availableModes[]' \
| sed 's/Hz$//' | awk -F@ '{ printf "%s@%d\n", $1, $2 + 0.5 }' | sort -rV -u
printf '%s\n' "$BACK"
} | rofi -dmenu -p "$name (now $cur)" ) || exit 0
[ "$mode" = "$BACK" ] && exec "$0" display
[ -n "$mode" ] || exit 0
# Apply live (keep the output's current position + scale), then persist.
pos=$(printf '%s' "$mons" | jq -r --arg n "$name" '.[]|select(.name==$n)|"\(.x)x\(.y)"')
scale=$(printf '%s' "$mons" | jq -r --arg n "$name" '.[]|select(.name==$n)|.scale')
if hyprctl keyword monitor "$name,$mode,$pos,$scale" >/dev/null 2>&1; then
nomarchy-theme-sync --quiet set "settings.monitors.$name" "$mode" --no-switch
notify-send "Display" "$name $mode"
else
notify-send "Display" "Could not set $name to $mode."
fi ;;
capture) capture)
# Screenshots + screen recording. Recording rows self-swap: while
# one runs, the only offer is Stop (same surface as the bar's ).
choice=$( { choice=$( {
row "Region clipboard" applets-screenshooter row "Region clipboard" applets-screenshooter
row "Region file" applets-screenshooter row "Region file" applets-screenshooter
row "Full screen clipboard" camera-photo row "Full screen clipboard" camera-photo
row "Full screen file" camera-photo row "Full screen file" camera-photo
command -v tesseract >/dev/null 2>&1 && row "OCR region clipboard" scanner
if command -v nomarchy-record >/dev/null 2>&1; then
if nomarchy-record active; then
row " Stop recording" media-playback-stop
else
row "Record region" media-record
row "Record screen" media-record
row "Record region + audio" audio-input-microphone
row "Record screen + audio" audio-input-microphone
fi
fi
back
} | rofi -dmenu -show-icons -p Capture) || exit 0 } | rofi -dmenu -show-icons -p Capture) || exit 0
dir="$HOME/Pictures/Screenshots" dir="$HOME/Pictures/Screenshots"
file="$dir/$(date +%Y%m%d-%H%M%S).png" file="$dir/$(date +%Y%m%d-%H%M%S).png"
case "$choice" in case "$choice" in
"$BACK") exec "$0" tools ;;
*"Region clipboard"*) grim -g "$(slurp)" - | wl-copy ;; *"Region clipboard"*) grim -g "$(slurp)" - | wl-copy ;;
*"Region file"*) mkdir -p "$dir"; grim -g "$(slurp)" "$file" \ *"Region file"*) mkdir -p "$dir"; grim -g "$(slurp)" "$file" \
&& notify-send "Screenshot saved" "$file" ;; && notify-send "Screenshot saved" "$file" ;;
*"OCR region"*)
# Select screenshot tesseract clipboard. Esc in slurp
# cancels silently (the recording.nix pattern); recognizing
# nothing toasts instead of copying an empty clipboard.
geo=$(slurp) || exit 0
txt=$(grim -g "$geo" - | tesseract stdin stdout 2>/dev/null)
[ -n "''${txt//[[:space:]]/}" ] \
|| { notify-send "OCR" "No text recognized in that region."; exit 0; }
printf '%s' "$txt" | wl-copy
notify-send "OCR" "Copied $(printf '%s' "$txt" | wc -w) word(s) to the clipboard." ;;
*"Full screen clipboard"*) grim - | wl-copy ;; *"Full screen clipboard"*) grim - | wl-copy ;;
*"Full screen file"*) mkdir -p "$dir"; grim "$file" \ *"Full screen file"*) mkdir -p "$dir"; grim "$file" \
&& notify-send "Screenshot saved" "$file" ;; && notify-send "Screenshot saved" "$file" ;;
*"Stop recording"*) exec nomarchy-record stop ;;
*"Record region + audio"*) exec nomarchy-record start region audio ;;
*"Record screen + audio"*) exec nomarchy-record start screen audio ;;
*"Record region"*) exec nomarchy-record start region ;;
*"Record screen"*) exec nomarchy-record start screen ;;
esac ;; esac ;;
lookfeel)
# Look & Feel appearance grouped (item 19): the theme picker,
# wallpaper cycling, night light. The root stays six rows; the
# SUPER+T / SUPER+SHIFT+T direct binds still hit the leaves.
choice=$( {
row "Theme${menuHint "theme"}" preferences-desktop-theme
row "Next wallpaper${hintForAction "exec, nomarchy-theme-sync bg next"}" preferences-desktop-wallpaper
if systemctl --user is-active --quiet hyprsunset.service 2>/dev/null
then row "Night light (on)" weather-clear-night
else row "Night light (off)" weather-clear-night
fi
back
} | rofi -dmenu -show-icons -markup-rows -p "Look & Feel") || exit 0
case "$choice" in
"$BACK") exec "$0" ;;
*Theme*) exec "$0" theme ;;
*wallpaper*) exec nomarchy-theme-sync bg next ;;
*"Night light"*) exec "$0" nightlight ;;
esac ;;
display-profile)
# Named multi-output layouts (nomarchy.displayProfiles). Applying is
# instant (hyprctl per entry, via nomarchy-display-profile) and
# persisted in-flake (settings.displayProfile) so the next rebuild
# bakes it. "Base layout" clears the state and hyprctl-reloads.
command -v nomarchy-display-profile >/dev/null 2>&1 \
|| { notify-send "Display profiles" "None declared (nomarchy.displayProfiles)."; exit 0; }
active=$(nomarchy-display-profile active)
choice=$( {
nomarchy-display-profile list | while IFS= read -r p; do
if [ "$p" = "$active" ]; then printf ' %s\n' "$p"; else printf ' %s\n' "$p"; fi
done
printf 'Base layout\n'
if [ "$(nomarchy-theme-sync get settings.displayProfileAuto 2>/dev/null)" = true ]
then printf 'Auto-switch (on)\n'
else printf 'Auto-switch (off)\n'
fi
printf '%s\n' "$BACK"
} | rofi -dmenu -p Profiles ) || exit 0
case "$choice" in
"$BACK") exec "$0" display ;;
"Base layout") exec nomarchy-display-profile base ;;
*"Auto-switch"*)
# Instant in-flake flag; the watcher reads it live on the next
# output change no rebuild, no unit juggling.
if [ "$(nomarchy-theme-sync get settings.displayProfileAuto 2>/dev/null)" = true ]; then
nomarchy-theme-sync --quiet set settings.displayProfileAuto false --no-switch
notify-send "Display profiles" "Auto-switch off profiles change only from this menu."
else
nomarchy-theme-sync --quiet set settings.displayProfileAuto true --no-switch
notify-send "Display profiles" "Auto-switch on plugging/unplugging outputs picks the matching profile."
fi
exec "$0" display-profile ;;
*) exec nomarchy-display-profile apply "''${choice#[] }" ;;
esac ;;
colorpicker)
# Pick a pixel's color (hyprpicker's zoom loupe) clipboard, hex
# in the confirmation toast. Esc/cancel exits silently. Self-gated
# in the Tools row; guarded here too for direct invocation.
command -v hyprpicker >/dev/null 2>&1 \
|| { notify-send "Color picker" "hyprpicker is not installed."; exit 0; }
color=$(hyprpicker) || exit 0
[ -n "$color" ] || exit 0
printf '%s' "$color" | wl-copy
notify-send "Color picker" "$color copied to the clipboard." ;;
keybinds) keybinds)
# Read-only cheatsheet, generated from keybinds.nix at build time. # Read-only cheatsheet, generated from keybinds.nix at build time.
rofi -dmenu -i -p keys < ${cheatsheetFile} >/dev/null || exit 0 ;; # Enter on a row is a deliberate no-op (nothing to launch); Back
# returns to the root picker, which is also where it's launched.
sel=$( { cat ${cheatsheetFile}; printf '%s\n' "$BACK"; } \
| rofi -dmenu -i -p keys ) || exit 0
[ "$sel" = "$BACK" ] && exec "$0"
exit 0 ;;
ask) ask)
# Free-text question claude CLI in a terminal (OAuth, no API key). # Free-text question claude CLI in a terminal (OAuth, no API key).
@@ -178,36 +513,128 @@ let
&& notify-send "Do Not Disturb off" "Notifications resumed." && notify-send "Do Not Disturb off" "Notifications resumed."
exit 0 ;; exit 0 ;;
nightlight)
# Force the scheduled blue-light filter on/off for the session by
# starting/stopping hyprsunset (nomarchy-nightlight, from nightlight.nix).
# Self-gated in the menu; guard here too in case it's invoked directly.
command -v nomarchy-nightlight >/dev/null 2>&1 \
&& exec nomarchy-nightlight toggle
notify-send "Night light" "Not available (nomarchy.nightlight off?)."; exit 0 ;;
autotimezone)
# Toggle automatic timezone detection (geoclue + automatic-timezoned).
# A SYSTEM service, so flipping it writes the in-flake flag and runs a
# system rebuild (sudo) + a home switch in a terminal, like Snapshots.
command -v nomarchy-autotimezone >/dev/null 2>&1 \
|| { notify-send "Auto timezone" "Unavailable on this machine."; exit 0; }
exec ${cfg.terminal} -e nomarchy-autotimezone toggle ;;
autocommit)
# Toggle opt-in auto-commit: every menu/theme mutation also commits
# theme-state.json (that file only) in the downstream flake, so
# settings history is `git log`. Nothing in Nix consumes the flag
# the tool reads the live state on each write so the toggle is
# instant, no rebuild. The off-write commits too (the tool fires
# when the flag was on before OR after), keeping history consistent.
if [ "$(nomarchy-theme-sync get settings.autoCommit 2>/dev/null)" = true ]; then
nomarchy-theme-sync --quiet set settings.autoCommit false --no-switch
notify-send "Auto-commit" "Off menu changes stay uncommitted in your flake."
else
nomarchy-theme-sync --quiet set settings.autoCommit true --no-switch
notify-send "Auto-commit" "On each change commits theme-state.json in your flake."
fi
exit 0 ;;
snapshot) snapshot)
# btrfs-assistant: snapshot browse / diff / restore / rollback over # Snapshot browse/restore the btrfs-assistant GUI via its pkexec
# snapper, elevating via polkit. Shipped system-side with # launcher. The "2.2 segfaults on 26.05" diagnosis was HALF right:
# nomarchy.system.snapper; self-gate so the entry no-ops elsewhere. # only UNPRIVILEGED runs crash (libbtrfsutil unprivileged subvolume
command -v btrfs-assistant >/dev/null 2>&1 \ # iteration, btrfs-progs 6.17.1; fixed upstream after) as root it
|| { notify-send "Snapshots" "btrfs-assistant isn't installed (BTRFS snapshots off?)."; exit 0; } # runs fine, and the launcher runs it as root. The prompt needs the
exec btrfs-assistant ;; # session polkit agent (hyprpolkitagent, exec-once in hyprland.nix).
# Fallback: nomarchy-snapshots, the keyboard-driven fzf browser in a
# terminal (kept installed also nice over SSH).
# Self-gated to nomarchy.system.snapper, so it no-ops elsewhere.
command -v btrfs-assistant-launcher >/dev/null 2>&1 \
&& exec btrfs-assistant-launcher
command -v nomarchy-snapshots >/dev/null 2>&1 \
|| { notify-send "Snapshots" "Snapshot tools unavailable (nomarchy.system.snapper off?)."; exit 0; }
exec ${cfg.terminal} -e sudo nomarchy-snapshots ;;
doctor)
# Read-only health sheet in a terminal; exit code doesn't matter
# here the sheet itself is the product.
exec ${cfg.terminal} -e sh -c "nomarchy-doctor
printf '\nEnter to close.'; read -r _" ;;
controlcenter)
# Nomarchy TUI Control Center.
exec ${cfg.terminal} -e nomarchy-control-center ;;
rollback)
# Undo, one menu away (informative + rock-stable). Desktop =
# Home Manager generations: theme/config history is already one
# generation per change, and running an older generation's
# activate script is the supported HM rollback reversible by
# activating a newer one again, so no typed-yes gate here.
# System-level undo deliberately LINKS OUT instead of putting
# destructive flows in rofi: boot-menu generations and snapper
# rollback have their own homes and gates (docs/RECOVERY.md,
# nomarchy-snapshots).
gens=$(home-manager generations 2>/dev/null | head -10)
choice=$( {
if [ -n "$gens" ]; then
printf '%s\n' "$gens" | awk '{
printf "Desktop gen %s %s %s%s\n", $5, $1, $2, (NR==1 ? " (current)" : "")
}' | while IFS= read -r line; do row "$line" document-open-recent; done
fi
command -v nomarchy-snapshots >/dev/null 2>&1 \
&& row "System files Snapshots" timeshift
row "System config boot an older generation (how)" help-about
back
} | rofi -dmenu -show-icons -p Rollback) || exit 0
case "$choice" in
"$BACK") exec "$0" system ;;
"Desktop gen "*)
num=''${choice#Desktop gen }; num=''${num%% *}
path=$(printf '%s\n' "$gens" | awk -v n="$num" '$5 == n { print $7 }')
[ -x "$path/activate" ] \
|| { notify-send "Rollback" "Generation $num not found on disk."; exit 1; }
exec ${cfg.terminal} -e sh -c "
echo \"Activating Home Manager generation $num\"; echo
\"$path/activate\"
echo; echo \"Desktop rolled back to generation $num.\"
echo \"(Roll forward the same way newer generations stay listed.)\"
printf 'Enter to close.'; read -r _" ;;
*Snapshots*) exec "$0" snapshot ;;
*"older generation"*)
notify-send "System rollback" "Reboot and pick an older NixOS generation in the boot menu (the last 10 are kept). Make it stick: revert the change in ~/.nomarchy, then sys-rebuild. Details: docs/RECOVERY.md §3." ;;
esac ;;
tools) tools)
# Tools submenu utilities you invoke. Each leaf is also reachable # Tools submenu utilities you invoke. Each leaf is also reachable
# directly (SUPER+CTRL+<mnemonic>); this just keeps the root short. # directly (SUPER+CTRL+<mnemonic>); this just keeps the root short.
choice=$( { choice=$( {
row "Calculator" accessories-calculator row "Calculator${menuHint "calc"}" accessories-calculator
row "Clipboard" edit-paste row "Clipboard${menuHint "clipboard"}" edit-paste
row "Emoji" face-smile-big row "Emoji${menuHint "emoji"}" face-smile-big
row "Files" system-file-manager row "Files${menuHint "files"}" system-file-manager
row "Web search" system-search row "Web search${menuHint "web"}" system-search
row "Capture" applets-screenshooter row "Capture${menuHint "capture"}" applets-screenshooter
row "Ask Claude" internet-chat command -v hyprpicker >/dev/null 2>&1 && row "Color picker${menuHint "colorpicker"}" preferences-desktop-color
row "Back" go-previous row "Ask Claude${menuHint "ask"}" internet-chat
} | rofi -dmenu -show-icons -p Tools) || exit 0 back
} | rofi -dmenu -show-icons -markup-rows -p Tools) || exit 0
case "$choice" in case "$choice" in
"$BACK") exec "$0" ;;
*Calc*) exec "$0" calc ;; *Calc*) exec "$0" calc ;;
*Clipboard*) exec "$0" clipboard ;; *Clipboard*) exec "$0" clipboard ;;
*Emoji*) exec "$0" emoji ;; *Emoji*) exec "$0" emoji ;;
*Files*) exec "$0" files ;; *Files*) exec "$0" files ;;
*Web*) exec "$0" web ;; *Web*) exec "$0" web ;;
*Capture*) exec "$0" capture ;; *Capture*) exec "$0" capture ;;
*Color*) exec "$0" colorpicker ;;
*Ask*) exec "$0" ask ;; *Ask*) exec "$0" ask ;;
*Back*) exec "$0" ;;
esac ;; esac ;;
system) system)
@@ -216,22 +643,49 @@ let
# the submenu shrinks to match the hardware, like the Waybar modules. # the submenu shrinks to match the hardware, like the Waybar modules.
bats=(/sys/class/power_supply/BAT*) bats=(/sys/class/power_supply/BAT*)
choice=$( { choice=$( {
row "Network" network-wireless row "Network${menuHint "network"}" network-wireless
row "Bluetooth" bluetooth row "VPN" network-vpn
row "Do Not Disturb" notification-disabled row "Bluetooth${menuHint "bluetooth"}" bluetooth
command -v btrfs-assistant >/dev/null 2>&1 && row "Snapshots" timeshift [ -e "''${XDG_RUNTIME_DIR:-/run/user/$(id -u)}/pulse/native" ] \
&& row "Audio" audio-volume-high
row "Display" video-display
command -v system-config-printer >/dev/null 2>&1 && row "Printers" printer
row "Do Not Disturb${menuHint "dnd"}" notification-disabled
if [ "$(nomarchy-theme-sync get settings.autoTimezone 2>/dev/null)" = true ]
then row "Auto timezone (on)" preferences-system-time
else row "Auto timezone (off)" preferences-system-time
fi
if [ -e "''${NOMARCHY_PATH:-$HOME/.nomarchy}/.git" ]; then
if [ "$(nomarchy-theme-sync get settings.autoCommit 2>/dev/null)" = true ]
then row "Auto-commit (on)" git
else row "Auto-commit (off)" git
fi
fi
command -v nomarchy-snapshots >/dev/null 2>&1 && row "Snapshots" timeshift
row "Rollback" edit-undo
command -v nomarchy-doctor >/dev/null 2>&1 && row "Doctor" utilities-system-monitor
command -v nomarchy-control-center >/dev/null 2>&1 && row "Control Center" preferences-desktop
if [ -e "''${bats[0]}" ] && command -v powerprofilesctl >/dev/null 2>&1; then if [ -e "''${bats[0]}" ] && command -v powerprofilesctl >/dev/null 2>&1; then
row "Power profile" preferences-system-power row "Power profile" preferences-system-power
fi fi
row "Back" go-previous back
} | rofi -dmenu -show-icons -p System) || exit 0 } | rofi -dmenu -show-icons -markup-rows -p System) || exit 0
case "$choice" in case "$choice" in
"$BACK") exec "$0" ;;
*Network*) exec "$0" network ;; *Network*) exec "$0" network ;;
*VPN*) exec "$0" vpn ;;
*Bluetooth*) exec "$0" bluetooth ;; *Bluetooth*) exec "$0" bluetooth ;;
*Audio*) exec "$0" audio ;;
*Display*) exec "$0" display ;;
*Printers*) exec "$0" printers ;;
*"Do Not Disturb"*) exec "$0" dnd ;; *"Do Not Disturb"*) exec "$0" dnd ;;
*"Auto timezone"*) exec "$0" autotimezone ;;
*"Auto-commit"*) exec "$0" autocommit ;;
*Snapshots*) exec "$0" snapshot ;; *Snapshots*) exec "$0" snapshot ;;
*Rollback*) exec "$0" rollback ;;
*Doctor*) exec "$0" doctor ;;
*"Control Center"*) exec "$0" controlcenter ;;
*"Power profile"*) exec "$0" power-profile ;; *"Power profile"*) exec "$0" power-profile ;;
*Back*) exec "$0" ;;
esac ;; esac ;;
"") "")
@@ -239,16 +693,16 @@ let
# short and scannable. Apps/Theme/Power/Keybindings are frequent enough # short and scannable. Apps/Theme/Power/Keybindings are frequent enough
# to keep at the top; everything else lives under Tools or System. # to keep at the top; everything else lives under Tools or System.
choice=$( { choice=$( {
row "Apps" applications-all row "Apps${appsHint}" applications-all
row "Theme" preferences-desktop-theme row "Look &amp; Feel" preferences-desktop-theme
row "Tools" applications-utilities row "Tools" applications-utilities
row "System" preferences-system row "System" preferences-system
row "Power" system-shutdown row "Power${menuHint "power"}" system-shutdown
row "Keybindings" preferences-desktop-keyboard row "Keybindings${menuHint "keybinds"}" preferences-desktop-keyboard
} | rofi -dmenu -show-icons -p Menu) || exit 0 } | rofi -dmenu -show-icons -markup-rows -p Menu) || exit 0
case "$choice" in case "$choice" in
*Apps*) exec rofi -show drun ;; *Apps*) exec rofi -show drun -theme launcher ;;
*Theme*) exec "$0" theme ;; *Look*) exec "$0" lookfeel ;;
*Tools*) exec "$0" tools ;; *Tools*) exec "$0" tools ;;
*System*) exec "$0" system ;; *System*) exec "$0" system ;;
*Power*) exec "$0" power ;; *Power*) exec "$0" power ;;
@@ -256,20 +710,164 @@ let
esac ;; esac ;;
*) *)
echo "usage: nomarchy-menu [tools|system|power|power-profile|theme|clipboard|calc|files|emoji|web|network|bluetooth|capture|keybinds|ask|dnd|snapshot]" >&2 echo "usage: nomarchy-menu [lookfeel|tools|system|power|power-profile|theme|clipboard|calc|files|emoji|web|network|bluetooth|audio|display|display-profile|printers|capture|colorpicker|keybinds|ask|dnd|nightlight|autotimezone|autocommit|vpn|snapshot]" >&2
exit 64 ;; exit 64 ;;
esac esac
''; '';
# VPN setup + management — a dedicated System submenu (and the custom/vpn
# Waybar indicator's click target). Goes past networkmanager_dmenu (which
# only connects existing VPNs) to importing configs and driving Tailscale:
# · NetworkManager VPN/WireGuard connections, ● active / ○ inactive, toggled
# up/down (networkmanager-group users need no sudo).
# · Import a WireGuard .conf or OpenVPN .ovpn (nmcli import; OpenVPN needs the
# networkmanager-openvpn plugin, shipped system-side).
# · Tailscale (self-gated on the CLI being present = nomarchy.services.tailscale,
# which makes the login user the operator): status is read-only; up/down/
# exit-node run inline without sudo thanks to the operator grant, falling
# back to a sudo terminal if it's absent. First login goes to a terminal.
# Secrets stay in NetworkManager's / Tailscale's own store — no new manager.
nomarchy-vpn = pkgs.writeShellScriptBin "nomarchy-vpn" ''
set -u
BACK=" Back"
conn_rows() {
nmcli -t -f NAME,TYPE,STATE connection show 2>/dev/null \
| awk -F: '$2=="vpn"||$2=="wireguard"{ printf "%s %s\n", ($3=="activated"?"":""), $1 }'
}
toggle_conn() {
mark=''${1%% *}; name=''${1#* }
if [ "$mark" = "" ]; then
nmcli connection down "$name" >/dev/null 2>&1 \
&& notify-send "VPN" "Disconnected $name" || notify-send "VPN" "Could not disconnect $name"
else
nmcli connection up "$name" >/dev/null 2>&1 \
&& notify-send "VPN" "Connected $name" || notify-send "VPN" "Could not connect $name (auth or bad config?)"
fi
}
import_config() {
file=$( { fd -e conf -e ovpn . "$HOME" --type f --hidden \
--exclude .git --exclude .cache 2>/dev/null \
| sed "s|^$HOME/||"; printf '%s\n' "$BACK"; } \
| rofi -dmenu -p "Import VPN config" ) || return
{ [ -z "$file" ] || [ "$file" = "$BACK" ]; } && return
case "$file" in *.ovpn) type=openvpn ;; *) type=wireguard ;; esac
if nmcli connection import type "$type" file "$HOME/$file" >/dev/null 2>&1; then
notify-send "VPN" "Imported $type config: $(basename "$file")"
else
notify-send "VPN" "Import failed ($type plugin missing, or bad file?)"
fi
}
ts_state() { tailscale status --json 2>/dev/null | jq -r '.BackendState // "Unknown"'; }
# Run a privileged tailscale subcommand. nomarchy.services.tailscale makes
# the login user the operator, so this runs inline (no sudo); if the operator
# grant is absent (downstream dropped it) it falls back to a sudo terminal.
ts_priv() {
if tailscale "$@" >/dev/null 2>&1; then notify-send "Tailscale" "tailscale $*"
else ${cfg.terminal} -e sudo tailscale "$@"; fi
}
ts_exit_node() {
node=$( { printf 'none\n'; tailscale exit-node list 2>/dev/null | awk 'NR>1 && $2 {print $2}'; \
printf '%s\n' "$BACK"; } | rofi -dmenu -p "Exit node" ) || return
{ [ -z "$node" ] || [ "$node" = "$BACK" ]; } && return
[ "$node" = none ] && node=""
ts_priv set --exit-node="$node"
}
tailscale_menu() {
while :; do
st=$(ts_state)
choice=$( {
if [ "$st" = Running ]; then printf 'Disconnect\n'; else printf 'Connect\n'; fi
printf 'Exit node\n'
printf '%s\n' "$BACK"
} | rofi -dmenu -p "Tailscale ($st)" ) || return
case "$choice" in
"$BACK"|"") return ;;
# Authed-but-down bring it up inline; otherwise it needs an
# interactive login, so show it in a terminal (URL visible).
Connect) if [ "$st" = Stopped ]; then ts_priv up; else ${cfg.terminal} -e sudo tailscale up; fi ;;
Disconnect) ts_priv down ;;
"Exit node") ts_exit_node ;;
esac
done
}
while :; do
have_ts=""
command -v tailscale >/dev/null 2>&1 && have_ts=1
choice=$( {
conn_rows
printf 'Import config\n'
[ -n "$have_ts" ] && printf 'Tailscale \n'
printf '%s\n' "$BACK"
} | rofi -dmenu -p VPN ) || exit 0
case "$choice" in
"$BACK"|"") exec nomarchy-menu system ;;
"Import config") import_config ;;
"Tailscale ") tailscale_menu ;;
" "*|" "*) toggle_conn "$choice" ;;
esac
done
'';
in in
{ {
config = lib.mkIf cfg.rofi.enable { config = lib.mkIf cfg.rofi.enable {
home.packages = [ home.packages = [
nomarchy-menu nomarchy-menu
nomarchy-vpn # VPN submenu (NM connect/import + Tailscale)
pkgs.fd # files module (fuzzy search over $HOME) pkgs.fd # files module (fuzzy search over $HOME)
pkgs.xdg-utils # xdg-open for the files + web modules pkgs.xdg-utils # xdg-open for the files + web modules
pkgs.nodejs # npx, for the Ask Claude module (claude-code from npm) pkgs.nodejs # npx, for the Ask Claude module (claude-code from npm)
pkgs.networkmanager_dmenu # network module: rofi wifi/VPN picker
pkgs.rofi-pulse-select # audio module: sink/source switcher
# Capture OCR region: English only — the unscoped wrapper drags
# in every language's traineddata, a real cost on a compile-from-
# source distro. More languages: override in your home.nix.
(pkgs.tesseract.override { enableLanguages = [ "eng" ]; })
]; ];
# networkmanager_dmenu drives rofi (not bare dmenu) and uses rofi's
# active/urgent row styling for the connected/available networks, so it
# inherits the generated theme like every other menu module. Editing a
# connection drops to nmtui in the configured terminal. force: the tool
# writes a default config on first run, so an unmanaged file may already
# sit at this path — we fully own it, so overwrite rather than abort.
# compact: with False the script inserts EMPTY rows as section
# separators (eth/wifi/vpn) — blank entries in a themed picker read as
# breakage (item 22). True drops them and uses the unpadded row format,
# which also sits better in rofi's proportional font than the
# space-aligned columns. (A hidden-SSID AP still renders one nameless
# row — sec+bars only; not filterable via config, dedup keeps one.)
xdg.configFile."networkmanager-dmenu/config.ini" = {
force = true;
text = ''
[dmenu]
dmenu_command = rofi
rofi_highlight = True
compact = True
wifi_chars =
[editor]
terminal = ${cfg.terminal}
gui_if_available = False
'';
};
# The launcher theme lives beside HM's active base theme, which it
# @imports so the default launcher is byte-for-byte that theme's list.
# HM names that base by its SOURCE: a generated (attrset) theme lands as
# custom.rasi, but a whole-swap theme (a path — themes/<slug>/rofi.rasi)
# keeps its basename, rofi.rasi. Import must match, or `-theme launcher`
# dies with "custom.rasi not found" (rofi resolves the missing import
# against $HOME) on every whole-swap theme.
xdg.dataFile."rofi/themes/launcher.rasi".text =
if hasLauncherOverride
then builtins.readFile launcherOverride
else ''@import "${if hasRasiOverride then "rofi.rasi" else "custom.rasi"}"'';
programs.rofi = { programs.rofi = {
enable = true; enable = true;
terminal = cfg.terminal; terminal = cfg.terminal;
@@ -292,6 +890,14 @@ in
# reserving `lines` (8) — so a 6-entry menu doesn't leave empty space, # reserving `lines` (8) — so a 6-entry menu doesn't leave empty space,
# while the app launcher still fills + scrolls past `lines`. # while the app launcher still fills + scrolls past `lines`.
fixed-num-lines = false; fixed-num-lines = false;
# Search: case-insensitive fuzzy across every menu + the launcher, so
# "system" finds "System" and "fzr" finds "Firefox". fzf sorting ranks
# the closest match to the top (the per-keystroke modules — calc/emoji
# — opt out per-invocation with -no-sort).
matching = "fuzzy";
case-sensitive = false;
sort = true;
sorting-method = "fzf";
}; };
# Whole-swap themes bring their own rofi.rasi; otherwise the theme # Whole-swap themes bring their own rofi.rasi; otherwise the theme
@@ -302,8 +908,13 @@ in
"*" = { "*" = {
bg = mkLiteral c.base; bg = mkLiteral c.base;
fg = mkLiteral c.text; fg = mkLiteral c.text;
dim = mkLiteral c.subtext; # @dim/@surface are fg-derived #RRGGBBAA tints, NOT the
surface = mkLiteral c.surface; # palette's subtext/surface roles — "on-surface" palettes
# (flexoki-light: subtext==base, surface==text) made the
# inputbar, textbox and alternate rows invisible (item 27;
# guarded by tools/check-theme-contrast.py).
dim = mkLiteral (c.text + "80");
surface = mkLiteral (c.text + "1a");
accent = mkLiteral c.accent; accent = mkLiteral c.accent;
background-color = mkLiteral "transparent"; background-color = mkLiteral "transparent";

View File

@@ -47,6 +47,8 @@ in
targets = { targets = {
gtk.enable = true; gtk.enable = true;
qt.enable = true; qt.enable = true;
# No-op unless programs.zathura is on (viewers.nix enables it).
zathura.enable = true;
}; };
cursor = { cursor = {

View File

@@ -29,11 +29,14 @@ in
}; };
style = '' style = ''
/* Palette baked from theme-state.json */ /* Palette baked from theme-state.json. Only roles guaranteed to
contrast @base in EVERY palette are used: subtext/surface mean
"on-surface" in some light themes (summer-day: subtext==base,
surface==text body text was invisible on hardware, item 25).
Chips/hovers are alpha(@text) tints contrast by construction.
tools/check-theme-contrast.py guards the hex-on-hex pairings. */
@define-color base ${c.base}; @define-color base ${c.base};
@define-color surface ${c.surface};
@define-color text ${c.text}; @define-color text ${c.text};
@define-color subtext ${c.subtext};
@define-color accent ${c.accent}; @define-color accent ${c.accent};
@define-color bad ${c.bad}; @define-color bad ${c.bad};
@@ -50,7 +53,7 @@ in
} }
.notification-content .body { .notification-content .body {
color: @subtext; color: @text;
} }
.notification.critical { .notification.critical {
@@ -66,7 +69,7 @@ in
.control-center .notification-row:focus, .control-center .notification-row:focus,
.control-center .notification-row:hover { .control-center .notification-row:hover {
background: alpha(@surface, 0.6); background: alpha(@text, 0.1);
border-radius: ${r}px; border-radius: ${r}px;
} }
@@ -76,7 +79,7 @@ in
} }
.widget-title > button { .widget-title > button {
background: @surface; background: alpha(@text, 0.1);
color: @text; color: @text;
border: none; border: none;
border-radius: ${r}px; border-radius: ${r}px;

View File

@@ -54,10 +54,86 @@ let
# tone for kanagawa/summer-*). Each preset declares its own so a theme # tone for kanagawa/summer-*). Each preset declares its own so a theme
# switch always replaces it (deep_merge would otherwise leave it stuck). # switch always replaces it (deep_merge would otherwise leave it stuck).
border = { active = "accent"; inactive = "overlay"; }; border = { active = "accent"; inactive = "overlay"; };
# Non-appearance feature settings the menu/watchers write into this same
# in-flake state. nomarchy.nightlight: `installed` (sticky — gates the unit,
# so the first enable rebuilds) and `on` (instant runtime on/off).
# settings.keyboard.devices: per-device layouts the new-keyboard watcher
# remembers (device-name -> XKB layout), git-tracked instead of
# ~/.local/state; they graduate into nomarchy.keyboard.devices on the next
# rebuild. settings.monitors: per-output resolutions the Display menu
# remembers (output-name -> "WxH@R"), overlaid onto nomarchy.monitors by
# name in hyprland.nix — the monitor twin of the keyboard graduation.
# Defaulted so a sparse/older state file still evaluates; nomarchy.settings
# exposes them.
settings = {
nightlight = { installed = false; on = true; };
keyboard.devices = { };
monitors = { };
# Automatic timezone detection (nomarchy.system.autoTimezone): a system
# service, but the flag lives here so both sides read one source — the
# home side gates the Waybar-refresh watcher (timezone.nix) on it.
autoTimezone = false;
};
}; };
parsed = lib.recursiveUpdate defaults themeState; parsed = lib.recursiveUpdate defaults themeState;
# ── Friendly eval-time validation ───────────────────────────────────
# The same schema nomarchy-theme-sync enforces before every write. A
# HAND-edited state file (the one path that bypasses the tool) must
# fail with the field, the problem, and the fix — not a Nix stack
# trace deep in some consumer. Checks run on `parsed` (after the
# defaults), so missing fields are fine; only wrong values throw.
isHex = v: builtins.isString v && builtins.match "#[0-9a-fA-F]{6}" v != null;
isNum = v: builtins.isInt v || builtins.isFloat v;
colorRoles = [ "base" "mantle" "surface" "overlay" "text" "subtext"
"muted" "accent" "accentAlt" "good" "warn" "bad" ];
got = v: "got: ${builtins.toJSON v}";
problems = lib.concatLists [
(map (k: "colors.${k} must be \"#RRGGBB\" (${got (parsed.colors.${k} or null)})")
(builtins.filter (k: !isHex (parsed.colors.${k} or null)) colorRoles))
(lib.optional (!(parsed.mode == "dark" || parsed.mode == "light"))
"mode must be \"dark\" or \"light\" (${got parsed.mode})")
(map (k: "ui.${k} must be a non-negative whole number (${got (parsed.ui.${k} or null)})")
(builtins.filter
(k: let v = parsed.ui.${k} or null; in !(builtins.isInt v && v >= 0))
[ "gapsIn" "gapsOut" "borderSize" "rounding" "iconSize" ]))
(map (k: "ui.${k} must be a number between 0 and 1 (${got (parsed.ui.${k} or null)})")
(builtins.filter
(k: let v = parsed.ui.${k} or null; in !(isNum v && v >= 0 && v <= 1))
[ "activeOpacity" "inactiveOpacity" "terminalOpacity" ]))
(map (k: "ui.${k} must be true or false (${got (parsed.ui.${k} or null)})")
(builtins.filter (k: !builtins.isBool (parsed.ui.${k} or null))
[ "blur" "shadow" ]))
(map (k: "fonts.${k} must be a font-family string (${got (parsed.fonts.${k} or null)})")
(builtins.filter (k: !builtins.isString (parsed.fonts.${k} or null))
[ "mono" "ui" ]))
(lib.optional (!(isNum (parsed.fonts.size or null) && parsed.fonts.size > 0))
"fonts.size must be a positive number (${got (parsed.fonts.size or null)})")
(lib.optional (!(builtins.isList parsed.ansi
&& builtins.length parsed.ansi == 16
&& lib.all isHex parsed.ansi))
"ansi must be a list of exactly 16 \"#RRGGBB\" strings")
(map (k: "border.${k} must be a palette role or \"#RRGGBB\" (${got (parsed.border.${k} or null)})")
(builtins.filter
(k: let v = parsed.border.${k} or null;
in !(builtins.isString v && (builtins.elem v colorRoles || isHex v)))
[ "active" "inactive" ]))
];
checked =
if problems == [ ] then parsed
else throw ''
Nomarchy: your theme-state.json is invalid:
${lib.concatMapStrings (p: " ${p}\n") problems}
Fix the named field(s) in the theme-state.json of your flake
checkout (usually ~/.nomarchy/theme-state.json the store copy at
${toString cfg.stateFile} is a snapshot of it). The tool prints
the same report with per-field fixes: `nomarchy-theme-sync
validate`. Re-applying any preset resets all appearance fields:
`nomarchy-theme-sync apply tokyo-night`.'';
# A border value is a palette key (look it up in colors) unless it's # A border value is a palette key (look it up in colors) unless it's
# already a literal hex; unknown keys fall through to the raw string. # already a literal hex; unknown keys fall through to the raw string.
resolveColor = v: if lib.hasPrefix "#" v then v else parsed.colors.${v} or v; resolveColor = v: if lib.hasPrefix "#" v then v else parsed.colors.${v} or v;
@@ -76,7 +152,12 @@ let
in in
{ {
config = { config = {
nomarchy.theme = parsed // { inherit iconTheme border; }; nomarchy.theme = checked // { inherit iconTheme border; };
# Feature toggles the menu writes (settings.nightlight.enable, …), exposed
# alongside the appearance state. Feature modules mkDefault-read from here
# so a menu toggle lands in the flake instead of in ~/.local/state.
nomarchy.settings = checked.settings;
nomarchy.lib = { nomarchy.lib = {
# "#7aa2f7" -> "rgb(7aa2f7)" (Hyprland color syntax) # "#7aa2f7" -> "rgb(7aa2f7)" (Hyprland color syntax)

44
modules/home/timezone.nix Normal file
View File

@@ -0,0 +1,44 @@
# Auto-timezone, home side: keep the Waybar clock in step with the system
# timezone. Waybar's clock module captures the zone once at construction, so a
# runtime timezone change (automatic-timezoned, nomarchy.system.autoTimezone)
# would NOT show until a relogin. A tiny watcher subscribes to timedate1's
# change signal and reloads Waybar (SIGUSR2 = the same reload theme-sync uses),
# so the clock follows your location live. Also catches a manual
# `timedatectl set-timezone`.
#
# Gated on the same in-flake flag the system side reads (settings.autoTimezone,
# exposed via nomarchy.settings) — so it only runs when the feature is on. The
# menu toggle (nomarchy-autotimezone) rebuilds both sides off that one flag.
{ config, lib, pkgs, ... }:
let
cfg = config.nomarchy;
enabled = cfg.waybar.enable && (cfg.settings.autoTimezone or false);
tzWatch = pkgs.writeShellScript "nomarchy-tz-watch" ''
last=$(${pkgs.systemd}/bin/timedatectl show -p Timezone --value 2>/dev/null || true)
${pkgs.dbus}/bin/dbus-monitor --system \
"type='signal',interface='org.freedesktop.DBus.Properties',path='/org/freedesktop/timedate1',member='PropertiesChanged'" \
2>/dev/null |
while read -r _; do
cur=$(${pkgs.systemd}/bin/timedatectl show -p Timezone --value 2>/dev/null || true)
[ "$cur" = "$last" ] && continue
last=$cur
${pkgs.procps}/bin/pkill -SIGUSR2 -x waybar 2>/dev/null || true
done
'';
in
{
systemd.user.services.nomarchy-tz-watch = lib.mkIf enabled {
Unit = {
Description = "Reload Waybar on timezone change (auto-timezone)";
PartOf = [ "graphical-session.target" ];
After = [ "graphical-session.target" ];
};
Service = {
ExecStart = "${tzWatch}";
Restart = "on-failure";
};
Install.WantedBy = [ "graphical-session.target" ];
};
}

141
modules/home/updates.nix Normal file
View File

@@ -0,0 +1,141 @@
# Update awareness (opt-in, nomarchy.updates.enable) — a passive background
# check that surfaces a Waybar indicator + a notification when updates are
# available, without ever changing anything (you still run sys-update /
# home-update / flatpak update). It counts:
# • flake inputs whose locked rev is behind upstream (nixpkgs, the Nomarchy
# input, home-manager …) — via `git ls-remote` on each branch-tracking
# github/git input in flake.lock; offline → skipped, never a false alarm.
# • Flatpak updates, when the `flatpak` CLI is present (services.flatpak on).
#
# nomarchy-updates is always on PATH and self-gates (status prints nothing
# until the timer has found something), so the Waybar module — generated and
# whole-swap — can exec it by name even when the feature is off.
{ config, lib, pkgs, ... }:
let
cfg = config.nomarchy.updates;
nomarchy-updates = pkgs.writeShellScriptBin "nomarchy-updates" ''
set -u
# System + user profiles, so flatpak / sys-update resolve from a timer-run
# service too (build-time tools below use absolute store paths regardless).
export PATH="$PATH:/run/current-system/sw/bin:/etc/profiles/per-user/$USER/bin"
GIT=${pkgs.git}/bin/git
JQ=${pkgs.jq}/bin/jq
cache="''${XDG_CACHE_HOME:-$HOME/.cache}/nomarchy"
state="$cache/updates.json"
flake="''${NOMARCHY_PATH:-$HOME/.nomarchy}"
mkdir -p "$cache"
count_nix() {
local lock="$flake/flake.lock" n=0 name kind owner repo url ref locked giturl up
[ -f "$lock" ] || { echo 0; return; }
while IFS=$'\t' read -r name kind owner repo url ref locked; do
[ -n "$locked" ] || continue
case "$kind" in
github) giturl="https://github.com/$owner/$repo" ;;
git) giturl="$url" ;;
*) continue ;;
esac
up=$("$GIT" ls-remote "$giturl" "$ref" 2>/dev/null | ${pkgs.gawk}/bin/awk 'NR==1{print $1}')
[ -n "$up" ] || continue # offline / unknown skip (no false alarm)
[ "$up" != "$locked" ] && n=$((n + 1))
done < <(
# Only the flake's DIRECT inputs (root.inputs) not the transitive
# closure so a deep dependency bump doesn't nag as an "update".
"$JQ" -r '
.nodes as $nodes
| ($nodes.root.inputs | [ .[] | if type == "array" then .[0] else . end ]) as $direct
| $nodes | to_entries[]
| .key as $k | .value as $v
| select($direct | index($k))
| select(($v.original.type? == "github") or ($v.original.type? == "git"))
| select(($v.original.rev? // "") == "") # branch-tracking only
| [ $k, $v.original.type,
($v.original.owner? // ""), ($v.original.repo? // ""),
($v.original.url? // ""), ($v.original.ref? // "HEAD"),
($v.locked.rev? // "") ] | @tsv
' "$lock"
)
echo "$n"
}
count_flatpak() {
${lib.optionalString cfg.flatpak ''
if command -v flatpak >/dev/null 2>&1; then
flatpak remote-ls --updates --columns=application 2>/dev/null | ${pkgs.gnugrep}/bin/grep -c . || true
return
fi
''}
echo 0
}
refresh_bar() { ${pkgs.procps}/bin/pkill -RTMIN+9 -x waybar 2>/dev/null || true; }
case "''${1:-status}" in
check)
nix=$(count_nix); fp=$(count_flatpak); total=$((nix + fp))
prev=$("$JQ" -r '.total // 0' "$state" 2>/dev/null || echo 0)
printf '{"nix":%d,"flatpak":%d,"total":%d,"ts":%d}\n' \
"$nix" "$fp" "$total" "$(${pkgs.coreutils}/bin/date +%s)" > "$state"
# Notify only when NEW updates appear, so a daily timer doesn't nag.
if [ "$total" -gt 0 ] && [ "$total" -gt "$prev" ]; then
msg="$nix flake input(s)"
[ "$fp" -gt 0 ] && msg="$msg · $fp Flatpak(s)"
${pkgs.libnotify}/bin/notify-send -a Nomarchy "Updates available" \
"$msg click the bar icon, or run sys-update."
fi
refresh_bar ;;
status)
total=$("$JQ" -r '.total // 0' "$state" 2>/dev/null || echo 0)
[ "$total" -gt 0 ] 2>/dev/null || exit 0 # up to date / unchecked hide
nix=$("$JQ" -r '.nix // 0' "$state"); fp=$("$JQ" -r '.flatpak // 0' "$state")
tip="Updates available"
[ "$nix" -gt 0 ] && tip="$tip\n $nix flake input(s) sys-update"
[ "$fp" -gt 0 ] && tip="$tip\n $fp Flatpak(s) flatpak update"
printf '{"text":"󰚰 %d","tooltip":"%s","class":"available"}\n' "$total" "$tip" ;;
upgrade)
echo "Checking"; "$0" check
nix=$("$JQ" -r '.nix // 0' "$state" 2>/dev/null || echo 0)
fp=$("$JQ" -r '.flatpak // 0' "$state" 2>/dev/null || echo 0)
echo "Pending: $nix flake input(s), $fp Flatpak(s)."
if [ "$nix" -gt 0 ] && command -v sys-update >/dev/null 2>&1; then
read -rp "Run sys-update (flake update + system rebuild)? [y/N] " a
[ "$a" = y ] && sys-update
fi
if [ "$fp" -gt 0 ] && command -v flatpak >/dev/null 2>&1; then
read -rp "Run flatpak update? [y/N] " a
[ "$a" = y ] && flatpak update
fi
"$0" check
echo "Done press enter."; read -r _ || true ;;
*) echo "usage: nomarchy-updates [check|status|upgrade]" >&2; exit 64 ;;
esac
'';
in
{
config = lib.mkMerge [
# Always on PATH so the Waybar module (incl. the static whole-swap themes)
# can exec it; it self-gates at runtime.
{ home.packages = [ nomarchy-updates ]; }
(lib.mkIf cfg.enable {
systemd.user.services.nomarchy-updates = {
Unit.Description = "Check for Nomarchy / nixpkgs / Flatpak updates";
Service = {
Type = "oneshot";
ExecStart = "${nomarchy-updates}/bin/nomarchy-updates check";
};
};
systemd.user.timers.nomarchy-updates = {
Unit.Description = "Periodic update-awareness check";
Timer = {
OnStartupSec = "2min";
OnCalendar = cfg.interval;
Persistent = true;
};
Install.WantedBy = [ "timers.target" ];
};
})
];
}

15
modules/home/viewers.nix Normal file
View File

@@ -0,0 +1,15 @@
# Document & image viewers — the "open a PDF / open a photo" half of a
# complete workstation. zathura goes through its HM module (not a bare
# package) because Stylix themes it via programs.zathura.options — the
# reason it's a component toggle here rather than a template package
# line. imv rides along: wayland-native, and an image viewer is a
# borderless dark surface — nothing to theme. The heavier editors
# (GIMP, Inkscape) stay template packages; mime.nix points the default
# associations at these viewers.
{ config, lib, pkgs, ... }:
lib.mkIf config.nomarchy.viewers.enable {
programs.zathura.enable = lib.mkDefault true;
home.packages = [ pkgs.imv ];
}

View File

@@ -28,6 +28,37 @@ let
# Named writeShellScriptBins (put on PATH via home.packages below) rather # Named writeShellScriptBins (put on PATH via home.packages below) rather
# than bare writeShellScript store paths, so the whole-swap themes' static # than bare writeShellScript store paths, so the whole-swap themes' static
# waybar.jsonc can exec them by name too — same as the swaync bell. # waybar.jsonc can exec them by name too — same as the swaync bell.
# Waybar supervisor — exec-once has no restart, so a crashed bar used to
# leave the session bar-less until relogin (seen on hardware: a theme
# switch crashed waybar mid-reload). Respawns on ANY exit — a plain
# `pkill -x waybar` is now a clean restart, which nomarchy-theme-sync
# uses instead of the crash-prone in-place SIGUSR2 reload when it sees
# this supervisor running. Crash-loop guard: 5 exits within 10s of
# their start → give up with a critical notification instead of
# spinning. Stop the bar for real: pkill -f nomarchy-waybar (TERM is
# trapped to take the child down too).
waybarSupervisor = pkgs.writeShellScriptBin "nomarchy-waybar" ''
child=
trap '[ -n "$child" ] && kill "$child" 2>/dev/null; exit 0' TERM INT
fails=0
while :; do
start=$(date +%s)
waybar & child=$!
wait "$child"; code=$?
if [ $(( $(date +%s) - start )) -lt 10 ]; then
fails=$((fails + 1))
if [ "$fails" -ge 5 ]; then
notify-send -u critical "Waybar" \
"Crashing on start (exit $code) check ~/.config/waybar. Giving up." 2>/dev/null
exit 1
fi
else
fails=0
fi
sleep 1
done
'';
powerProfileStatus = pkgs.writeShellScriptBin "nomarchy-powerprofile-status" '' powerProfileStatus = pkgs.writeShellScriptBin "nomarchy-powerprofile-status" ''
case "$(ls /sys/class/power_supply/ 2>/dev/null)" in *BAT*) ;; *) exit 0 ;; esac case "$(ls /sys/class/power_supply/ 2>/dev/null)" in *BAT*) ;; *) exit 0 ;; esac
command -v powerprofilesctl >/dev/null 2>&1 || exit 0 command -v powerprofilesctl >/dev/null 2>&1 || exit 0
@@ -51,6 +82,31 @@ let
[ -n "$next" ] && powerprofilesctl set "$next" [ -n "$next" ] && powerprofilesctl set "$next"
''; '';
# VPN indicator — shows a shield when a NetworkManager VPN/WireGuard
# connection is active OR Tailscale is up; prints nothing otherwise so the
# module self-hides (like nightlight/updates). Click opens the VPN submenu.
vpnStatus = pkgs.writeShellScriptBin "nomarchy-vpn-status" ''
active=$(nmcli -t -f TYPE connection show --active 2>/dev/null | grep -Exm1 'vpn|wireguard')
ts=""
if command -v tailscale >/dev/null 2>&1; then
[ "$(tailscale status --json 2>/dev/null | jq -r '.BackendState // empty')" = Running ] && ts=1
fi
[ -n "$active" ] || [ -n "$ts" ] || exit 0
printf '{"text":"󰦝","tooltip":"VPN active (click to manage)","class":"on"}\n'
'';
# Health warning — self-gates: prints nothing while nomarchy-doctor
# exits 0, so the module only appears when the sheet has a ✖ (the
# same self-hide discipline as vpn/nightlight/updates). The tooltip
# carries the first failing lines; click opens the full sheet.
doctorStatus = pkgs.writeShellScriptBin "nomarchy-doctor-status" ''
command -v nomarchy-doctor >/dev/null 2>&1 || exit 0
out=$(nomarchy-doctor 2>/dev/null) && exit 0
tip=$(printf '%s\n' "$out" | grep '' | head -5 \
| ${pkgs.jq}/bin/jq -Rs 'rtrimstr("\n") + "\n(click for the full sheet)"')
printf '{"text":"","tooltip":%s,"class":"bad"}\n' "$tip"
'';
# Per-theme override probe. # Per-theme override probe.
assetDir = config.nomarchy.themesDir + "/${t.slug}"; assetDir = config.nomarchy.themesDir + "/${t.slug}";
styleOverride = assetDir + "/waybar.css"; styleOverride = assetDir + "/waybar.css";
@@ -77,9 +133,9 @@ let
modules-left = [ "hyprland/workspaces" "hyprland/window" ]; modules-left = [ "hyprland/workspaces" "hyprland/window" ];
modules-center = [ "clock" ]; modules-center = [ "clock" ];
modules-right = [ "tray" "pulseaudio" "network" "cpu" "memory" "custom/powerprofile" ] modules-right = [ "custom/recording" "idle_inhibitor" "tray" "custom/vpn" "pulseaudio" "custom/powerprofile" "custom/nightlight" ]
++ lib.optional showLanguage "hyprland/language" ++ lib.optional showLanguage "hyprland/language"
++ [ "battery" "custom/notification" ]; ++ [ "battery" "custom/doctor" "custom/updates" "custom/notification" ];
"hyprland/workspaces" = { "hyprland/workspaces" = {
format = "{icon}"; format = "{icon}";
@@ -94,40 +150,45 @@ let
clock = { clock = {
format = "{:%H:%M}"; format = "{:%H:%M}";
format-alt = "{:%A %d %B %Y}"; format-alt = "{:%A %d %B %Y}";
tooltip-format = "<tt><small>{calendar}</small></tt>"; # Zone line first (LATER nicety): under auto-timezone the tz-watch
# SIGUSR2 reload keeps it showing the currently *detected* zone.
tooltip-format = "{:%Z (UTC%z)}\n<tt><small>{calendar}</small></tt>";
}; };
# Active keyboard layout (per focused device) — only placed in # Active keyboard layout (per focused device) — only placed in
# modules-right when showLanguage (see above). # modules-right when showLanguage (see above).
"hyprland/language" = { "hyprland/language" = {
format = "󰌌 {short}"; format = "<span size='${toString (t.fonts.size + 2)}pt'>󰌌</span> {short}";
tooltip = false; tooltip = false;
}; };
pulseaudio = { pulseaudio = {
format = "{icon} {volume}%"; format = "<span size='${toString (t.fonts.size + 2)}pt'>{icon}</span> {volume}%";
format-muted = "󰝟"; format-muted = "󰝟";
format-icons.default = [ "󰕿" "󰖀" "󰕾" ]; format-icons.default = [ "󰕿" "󰖀" "󰕾" ];
on-click = "wpctl set-mute @DEFAULT_AUDIO_SINK@ toggle"; on-click = "wpctl set-mute @DEFAULT_AUDIO_SINK@ toggle";
}; };
network = { # Idle inhibitor (caffeine): click 󰾪 → 󰅶 to hold the screen awake —
format-wifi = "󰤨 {essid}"; # blocks hypridle's lock / display-off / suspend during video or a
format-ethernet = "󰈀"; # presentation. Waybar holds a Wayland idle-inhibit while activated;
format-disconnected = "󰤭"; # the state resets with the bar (deliberate — caffeine shouldn't
tooltip-format = "{ipaddr} via {gwaddr}"; # survive a relogin). Summer-night's whole-swap bar had this first
# nm-applet sits in the tray for the GUI path; this is the # (reverse parity gap, 2026-07-04).
# keyboard-friendly one. idle_inhibitor = {
on-click = "${config.nomarchy.terminal} -e nmtui"; format = "{icon}";
format-icons = { activated = "󰅶"; deactivated = "󰾪"; };
tooltip-format-activated = "Screen held awake click to release";
tooltip-format-deactivated = "Keep the screen awake (caffeine)";
}; };
cpu.format = "󰍛 {usage}%"; # No network module: nm-applet lives in the tray (the GUI path), so the
memory.format = "󰾆 {percentage}%"; # bar's wifi/ethernet indicator would just duplicate it.
battery = { battery = {
states = { warning = 25; critical = 10; }; states = { warning = 25; critical = 10; };
format = "{icon} {capacity}%"; format = "<span size='${toString (t.fonts.size + 2)}pt'>{icon}</span> {capacity}%";
format-charging = "󰂄 {capacity}%"; format-charging = "<span size='${toString (t.fonts.size + 2)}pt'>󰂄</span> {capacity}%";
format-icons = [ "󰁺" "󰁼" "󰁾" "󰂀" "󰁹" ]; format-icons = [ "󰁺" "󰁼" "󰁾" "󰂀" "󰁹" ];
}; };
@@ -138,6 +199,61 @@ let
on-click = "nomarchy-powerprofile-cycle"; on-click = "nomarchy-powerprofile-cycle";
}; };
# VPN shield — self-hides unless a NM VPN/WireGuard tunnel or Tailscale is
# up. Click opens the VPN submenu (nomarchy-vpn). 5s poll like powerprofile.
"custom/vpn" = {
exec = "nomarchy-vpn-status";
return-type = "json";
interval = 5;
on-click = "nomarchy-vpn";
};
# Health check is real work (systemctl/disk/git sweeps), so a long
# interval — this is a tripwire, not a monitor.
"custom/doctor" = {
exec = "nomarchy-doctor-status";
return-type = "json";
interval = 300;
on-click = "nomarchy-menu doctor";
};
# Screen recording indicator. Self-gates: visible only while
# nomarchy-record runs (status prints nothing otherwise), and the
# click IS the stop surface — the menu only starts recordings.
# signal 8: the recorder pokes the bar on start/stop so the ⏺
# appears/vanishes instantly (the interval is just a safety net).
"custom/recording" = {
exec = "nomarchy-record status";
return-type = "json";
interval = 10;
signal = 8;
on-click = "nomarchy-record stop";
};
# Night-light (hyprsunset) indicator. Self-gates: the moon shows only while
# the schedule runs; otherwise the status helper prints nothing => hidden
# (enable / re-enable from the System menu). Click toggles instantly — writes
# the in-flake on/off (settings.nightlight.on, no rebuild) and flips the unit
# with systemctl, so the choice lands in the flake and survives reboot.
"custom/nightlight" = {
exec = "nomarchy-nightlight status";
return-type = "json";
interval = 3;
on-click = "nomarchy-nightlight toggle";
};
# Update awareness. Self-gates: hidden unless nomarchy.updates is enabled
# AND the periodic check found something (the helper prints nothing then).
# signal 9 lets the checker refresh it instantly; click opens the upgrade
# flow in a terminal.
"custom/updates" = {
exec = "nomarchy-updates status";
return-type = "json";
interval = 1800;
signal = 9;
on-click = "${config.nomarchy.terminal} -e nomarchy-updates upgrade";
};
# swaync notification bell + Do-Not-Disturb state. `-swb` streams JSON # swaync notification bell + Do-Not-Disturb state. `-swb` streams JSON
# (text/tooltip/class) on every change, so it tracks count and DND with # (text/tooltip/class) on every change, so it tracks count and DND with
# no polling. Left-click toggles the panel; right-click toggles DND. # no polling. Left-click toggles the panel; right-click toggles DND.
@@ -183,6 +299,11 @@ let
border-radius: ${toString t.ui.rounding}px; border-radius: ${toString t.ui.rounding}px;
} }
/* Dim states use the palette's @muted role: since item 28b it is
floor-guaranteed legible on @base in every theme (muted/base >=
2.0, gated by tools/check-theme-contrast.py) the palettes that
once made it vanish (gruvbox mutedbase, item 27) were retuned.
Secondary-but-not-dim stays alpha(@text, 0.85). */
#workspaces button { #workspaces button {
padding: 0 8px; padding: 0 8px;
color: @muted; color: @muted;
@@ -200,7 +321,7 @@ let
} }
#window { #window {
color: @subtext; color: alpha(@text, 0.85);
padding: 0 12px; padding: 0 12px;
} }
@@ -209,11 +330,47 @@ let
font-weight: bold; font-weight: bold;
} }
#tray, #pulseaudio, #network, #cpu, #memory, #custom-powerprofile, #language, #battery, #custom-notification { #tray, #pulseaudio, #custom-powerprofile, #custom-nightlight, #custom-updates, #custom-vpn, #custom-recording, #idle_inhibitor, #language, #battery, #custom-doctor, #custom-notification {
color: @subtext; color: alpha(@text, 0.85);
padding: 0 10px; padding: 0 10px;
} }
/* Group rhythm (item 28c): a wider breath before each functional
group of the right cluster media/stats · toggles · status
on top of the uniform module spacing. A group head can self-hide
(e.g. no battery on desktops); grouping then degrades to the
uniform spacing, never breaks. */
#pulseaudio, #custom-powerprofile, #battery { margin-left: 14px; }
/* Caffeine engaged warm tone (the screen is being held awake). */
#idle_inhibitor.activated { color: @warn; }
/* Recording in progress the alert red; the is also the stop button. */
#custom-recording.recording { color: @bad; }
/* VPN active accent green, reading as "connected / protected". */
#custom-vpn.on { color: @good; }
/* Night-light active warm tone, matching the filter it represents. */
#custom-nightlight.on { color: @warn; }
/* Updates pending accent, to draw the eye. */
#custom-updates.available { color: @accent; }
/* Doctor tripwire only rendered when something is , so it is
always the alert color. */
#custom-doctor { color: @bad; }
/* Icon-only status modules carry no text, so they don't get the
(size+2)pt Pango icon span the icon+text modules use bump their
font-size to match, or these glyphs read smaller than the volume /
battery / language icons beside them. */
#custom-recording, #custom-updates, #custom-vpn, #custom-nightlight, #custom-doctor, #custom-notification { font-size: ${toString (t.fonts.size + 2)}pt; }
/* The speedometer + caffeine glyphs render small in their em box
size them up a touch more so they read at a glance. */
#custom-powerprofile, #idle_inhibitor { font-size: ${toString (t.fonts.size + 3)}pt; }
/* notifications waiting accent; Do-Not-Disturb muted bell-off */ /* notifications waiting accent; Do-Not-Disturb muted bell-off */
#custom-notification.notification { color: @accent; } #custom-notification.notification { color: @accent; }
#custom-notification.dnd-none, #custom-notification.dnd-none,
@@ -228,7 +385,14 @@ in
{ {
programs.waybar = lib.mkIf config.nomarchy.waybar.enable { programs.waybar = lib.mkIf config.nomarchy.waybar.enable {
enable = true; enable = true;
systemd.enable = true; # started/stopped with graphical-session.target # Launched from Hyprland's exec-once (hyprland.nix), NOT a systemd user
# service. Bound to graphical-session.target the unit raced Hyprland's IPC
# on a warm relogin — it started before the socket was up, exited, landed
# in `failed`, and was never retried, so the bar vanished. exec-once only
# fires once Hyprland is up, dodging the race; theme switches reload the
# running bar via SIGUSR2 (nomarchy-theme-sync). No uwsm here to manage the
# session target, so we don't depend on its lifecycle.
systemd.enable = false;
# mkDefault so downstream can replace the whole bar config/style with # mkDefault so downstream can replace the whole bar config/style with
# a plain home.nix assignment. For per-theme identity, prefer the # a plain home.nix assignment. For per-theme identity, prefer the
@@ -249,7 +413,8 @@ in
}; };
# The power-profile helpers on PATH, so both the generated bar and the # The power-profile helpers on PATH, so both the generated bar and the
# whole-swap themes' static waybar.jsonc can exec them by name. # whole-swap themes' static waybar.jsonc can exec them by name — plus
# the supervisor hyprland.nix exec-onces.
home.packages = lib.optionals config.nomarchy.waybar.enable home.packages = lib.optionals config.nomarchy.waybar.enable
[ powerProfileStatus powerProfileCycle ]; [ waybarSupervisor powerProfileStatus powerProfileCycle vpnStatus doctorStatus ];
} }

View File

@@ -22,14 +22,28 @@ let
''; '';
in in
{ {
imports = [ ./options.nix ./plymouth.nix ./file-manager.nix ./power.nix ./services.nix ]; imports = [ ./options.nix ./plymouth.nix ./greeter.nix ./file-manager.nix ./power.nix ./services.nix ./hardware.nix ./timezone.nix ./oom.nix ];
config = { config = {
# The safe half of distro branding: distroName flows into # Distro branding. distroName flows into /etc/os-release PRETTY_NAME,
# /etc/os-release PRETTY_NAME, systemd-boot entry titles and the # systemd-boot entry titles and the ISO boot-menu label; distroId is the
# ISO boot-menu label. distroId stays "nixos" on purpose — it feeds # machine-readable ID (DEFAULT_HOSTNAME, lsb DISTRIB_ID, CPE name).
# DEFAULT_HOSTNAME and upstream isNixos checks (see roadmap). # distroId = "nomarchy" makes os-release honest — ID=nomarchy with
# ID_LIKE=nixos, the standard derivative-distro lineage marker (cf.
# Ubuntu→debian) — and is safe: switch-to-configuration builds its
# "is this NixOS?" guard from the *configured* distroId (and /etc/NIXOS
# still exists as the fallback), so rebuilds keep working; the nixos-*
# CLI tools are package names, untouched. The one side effect is that
# isNixos goes false and blanks the upstream nixos.org URLs, so we
# restore them pointing at the project instead.
system.nixos.distroName = lib.mkDefault "Nomarchy"; system.nixos.distroName = lib.mkDefault "Nomarchy";
system.nixos.distroId = lib.mkDefault "nomarchy";
system.nixos.extraOSReleaseArgs = lib.mkDefault {
HOME_URL = "https://git.bemagri.xyz/bernardo/Nomarchy";
DOCUMENTATION_URL = "https://git.bemagri.xyz/bernardo/Nomarchy";
SUPPORT_URL = "https://git.bemagri.xyz/bernardo/Nomarchy";
BUG_REPORT_URL = "https://git.bemagri.xyz/bernardo/Nomarchy/issues";
};
# MOTD on TTY/SSH login (the desktop auto-logs into Hyprland, so this # MOTD on TTY/SSH login (the desktop auto-logs into Hyprland, so this
# is mostly seen over SSH or on a bare console). Branded, and doubles # is mostly seen over SSH or on a bare console). Branded, and doubles
@@ -39,6 +53,7 @@ in
${distroName} a NixOS desktop, themed from one JSON. ${distroName} a NixOS desktop, themed from one JSON.
sys-update update inputs + rebuild the system sys-update update inputs + rebuild the system
sys-rebuild rebuild the system, no input update
home-update apply home/theme changes (no sudo) home-update apply home/theme changes (no sudo)
nomarchy-theme-sync apply <theme> switch the whole palette nomarchy-theme-sync apply <theme> switch the whole palette
SUPER+? keybindings cheatsheet SUPER+? keybindings cheatsheet
@@ -69,6 +84,11 @@ in
console.earlySetup = lib.mkDefault true; console.earlySetup = lib.mkDefault true;
boot.initrd.systemd.enable = lib.mkDefault true; boot.initrd.systemd.enable = lib.mkDefault true;
# Nomarchy roots are BTRFS, not ZFS, so adopt the 26.11 default early and
# silence the eval warning the old `true` default emits. mkDefault, so a
# genuine ZFS-root downstream can still force it back on.
boot.zfs.forceImportRoot = lib.mkDefault false;
# ── Wayland session: Hyprland ──────────────────────────────────── # ── Wayland session: Hyprland ────────────────────────────────────
# Installs the binary, registers the session, wires up # Installs the binary, registers the session, wires up
# xdg-desktop-portal-hyprland. Configuration is Home Manager's job. # xdg-desktop-portal-hyprland. Configuration is Home Manager's job.
@@ -79,22 +99,8 @@ in
extraPortals = [ pkgs.xdg-desktop-portal-gtk ]; # file pickers, etc. extraPortals = [ pkgs.xdg-desktop-portal-gtk ]; # file pickers, etc.
}; };
services.greetd = lib.mkIf cfg.greeter.enable { # The greetd/tuigreet login screen lives in ./greeter.nix — themed
enable = lib.mkDefault true; # from the state JSON (console.colors + --theme) at system rebuild.
settings = {
default_session = {
# start-hyprland is Hyprland 0.55's watchdog launcher; running
# the bare binary makes every session print a warning.
command = lib.mkDefault "${pkgs.tuigreet}/bin/tuigreet --time --remember --greeting 'Welcome to ${distroName}' --cmd start-hyprland";
user = "greeter";
};
# Boot straight into the session once; logout → normal greeter.
initial_session = lib.mkIf (cfg.greeter.autoLogin != null) {
command = "start-hyprland";
user = cfg.greeter.autoLogin;
};
};
};
# ── Audio: Pipewire ────────────────────────────────────────────── # ── Audio: Pipewire ──────────────────────────────────────────────
security.rtkit.enable = lib.mkDefault cfg.audio.enable; security.rtkit.enable = lib.mkDefault cfg.audio.enable;
@@ -122,6 +128,11 @@ in
services.fwupd.enable = lib.mkDefault true; services.fwupd.enable = lib.mkDefault true;
networking.networkmanager.enable = lib.mkDefault true; networking.networkmanager.enable = lib.mkDefault true;
# OpenVPN support for the VPN menu's import/connect flow (nomarchy-vpn).
# WireGuard needs no plugin (NetworkManager imports wg .conf natively);
# this adds the openvpn type so `nmcli connection import type openvpn` works.
# mkDefault so a downstream can drop it to slim the closure.
networking.networkmanager.plugins = lib.mkDefault [ pkgs.networkmanager-openvpn ];
# No double-unlock on hibernate. Locking the session before sleep is # No double-unlock on hibernate. Locking the session before sleep is
# right for suspend (resumes from RAM, no other gate), but an encrypted # right for suspend (resumes from RAM, no other gate), but an encrypted
@@ -249,6 +260,10 @@ in
# (every weight × variant) — too heavy for the ISO and closures. # (every weight × variant) — too heavy for the ISO and closures.
nerd-fonts.mononoki nerd-fonts.mononoki
nerd-fonts.inconsolata nerd-fonts.inconsolata
# GeistMono (57 MB) — the Boreal theme's mono face; modern and
# geometric, unlike the ten defaults. Cheap enough for the ISO
# (Iosevka above was rejected at 1.1 GB; this is a compact family).
nerd-fonts.geist-mono
inter inter
noto-fonts noto-fonts
noto-fonts-color-emoji noto-fonts-color-emoji
@@ -268,6 +283,8 @@ in
# ── Essential packages ─────────────────────────────────────────── # ── Essential packages ───────────────────────────────────────────
environment.systemPackages = with pkgs; [ environment.systemPackages = with pkgs; [
nomarchy-theme-sync # provided by overlays.default nomarchy-theme-sync # provided by overlays.default
nomarchy-doctor # read-only health check (System Doctor)
nomarchy-control-center # TUI control center
# Friendly wrappers for the two rebuild paths (README §3). Run as # Friendly wrappers for the two rebuild paths (README §3). Run as
# your user: `nix flake update` must NOT run as root (libgit2 # your user: `nix flake update` must NOT run as root (libgit2
@@ -282,11 +299,48 @@ in
flake="''${NOMARCHY_PATH:-$HOME/.nomarchy}" flake="''${NOMARCHY_PATH:-$HOME/.nomarchy}"
echo "sys-update: updating flake inputs in $flake" echo "sys-update: updating flake inputs in $flake"
nix flake update --flake "$flake" nix flake update --flake "$flake"
before=$(readlink -f /run/current-system)
if command -v nixos-rebuild-snap >/dev/null 2>&1; then if command -v nixos-rebuild-snap >/dev/null 2>&1; then
sudo nixos-rebuild-snap "$@" # BTRFS snapshot first sudo nixos-rebuild-snap "$@" # BTRFS snapshot first
else else
sudo nixos-rebuild switch --flake "$flake#default" "$@" sudo nixos-rebuild switch --flake "$flake#default" "$@"
fi fi
# What did that update actually change? Package-level diff of the
# old vs new generation (the informative half of "informative +
# rock-stable"); never fails the run.
after=$(readlink -f /run/current-system)
if [ "$before" = "$after" ]; then
echo "sys-update: no changes the system is identical."
else
echo "sys-update: what changed:"
${pkgs.nvd}/bin/nvd diff "$before" "$after" || true
fi
'')
# The no-update twin (hardware-QA request): rebuild the system
# against the CURRENT lock — config changes only, no `nix flake
# update` — mirroring how home-update never touches the lock.
# Same snapshot-first path when available.
(pkgs.writeShellScriptBin "sys-rebuild" ''
set -e
if [ "$(id -u)" -eq 0 ]; then
echo "sys-rebuild: run as your normal user (it sudos the rebuild itself)" >&2
exit 1
fi
flake="''${NOMARCHY_PATH:-$HOME/.nomarchy}"
before=$(readlink -f /run/current-system)
if command -v nixos-rebuild-snap >/dev/null 2>&1; then
sudo nixos-rebuild-snap "$@" # BTRFS snapshot first
else
sudo nixos-rebuild switch --flake "$flake#default" "$@"
fi
# Same what-changed diff as sys-update (the twins stay twins).
after=$(readlink -f /run/current-system)
if [ "$before" = "$after" ]; then
echo "sys-rebuild: no changes the system is identical."
else
echo "sys-rebuild: what changed:"
${pkgs.nvd}/bin/nvd diff "$before" "$after" || true
fi
'') '')
(pkgs.writeShellScriptBin "home-update" '' (pkgs.writeShellScriptBin "home-update" ''
set -e set -e
@@ -304,6 +358,7 @@ in
wl-clipboard wl-clipboard
grim grim
slurp slurp
hyprpicker
] ++ lib.optional (cfg.snapper.enable && (config.fileSystems."/".fsType or "") == "btrfs") ] ++ lib.optional (cfg.snapper.enable && (config.fileSystems."/".fsType or "") == "btrfs")
# Snapshot, then rebuild — rollback material for system changes # Snapshot, then rebuild — rollback material for system changes
# (theme changes don't need it; HM generations already roll back). # (theme changes don't need it; HM generations already roll back).
@@ -320,8 +375,73 @@ in
nixos-rebuild switch --flake /etc/nixos#default "$@" nixos-rebuild switch --flake /etc/nixos#default "$@"
'') '')
# The desktop snapshot manager (browse / diff / restore / rollback over # The desktop snapshot manager (browse / diff / restore / rollback over
# snapper, elevating via polkit) — what `nomarchy-menu snapshot` launches. # snapper, elevating via polkit) — the primary `nomarchy-menu snapshot`
++ lib.optional cfg.snapper.enable pkgs.btrfs-assistant; # target. The "2.2 segfault" is unprivileged-only (libbtrfsutil
# unprivileged subvolume iteration, btrfs-progs 6.17.1, fixed upstream
# after); the pkexec launcher runs it as root, where it works —
# VM-proven, guarded by checks.snapshot-gui.
++ lib.optional cfg.snapper.enable pkgs.btrfs-assistant
# Keyboard-driven snapper browser/restore — the menu's fallback when the
# GUI is absent, and handy over SSH. Runs as root (snapper is root-only
# here; the menu opens it in a terminal via sudo, one password prompt),
# fzf to pick, with browse/diff (read-only) and typed-`yes` confirmation
# before any write.
++ lib.optional cfg.snapper.enable (pkgs.writeShellApplication {
name = "nomarchy-snapshots";
runtimeInputs = with pkgs; [ snapper fzf gawk gnugrep less coreutils systemd ];
text = ''
if [ "$(id -u)" -ne 0 ]; then
echo "nomarchy-snapshots must run as root (snapper needs it) use sudo." >&2
exit 1
fi
mapfile -t configs < <(snapper list-configs | awk 'NR>2 {print $1}' | grep .)
if [ "''${#configs[@]}" -eq 0 ]; then
echo "No snapper configs found." >&2; exit 1
elif [ "''${#configs[@]}" -eq 1 ]; then
config="''${configs[0]}"
else
config=$(printf '%s\n' "''${configs[@]}" | fzf --prompt="snapper config> ") || exit 0
fi
while :; do
snap=$(snapper -c "$config" list \
| fzf --header-lines=2 --prompt="[$config] pick a snapshot (Esc quits)> ") || exit 0
num=$(awk '{print $1}' <<<"$snap")
case "$num" in ""|*[!0-9]*) continue ;; esac
action=$(printf '%s\n' \
"Browse changes since #$num (read-only)" \
"Restore changed files to #$num (undochange)" \
"Roll the system back to #$num (reboot)" \
" Back to the snapshot list" \
| fzf --prompt="snapshot #$num> ") || exit 0
case "$action" in
Browse*)
snapper -c "$config" status "$num..0" | less -R || true ;;
Restore*)
read -rp "Revert files in '$config' to snapshot #$num? Type yes to confirm: " ans || continue
if [ "$ans" = yes ]; then
snapper -c "$config" undochange "$num..0"
echo "Files restored. Press enter."; read -r _ || true
fi ;;
Roll*)
if [ "$config" != root ]; then
echo "Rollback applies to the 'root' config only; use Restore for '$config'. Press enter."
read -r _ || true
else
read -rp "Roll the SYSTEM back to #$num and REBOOT now? Type yes to confirm: " ans || continue
if [ "$ans" = yes ]; then
snapper -c root rollback "$num"
echo "Rolled back rebooting"; systemctl reboot
fi
fi ;;
*) continue ;;
esac
done
'';
});
# Don't let boot entries fill the ESP over the years. # Don't let boot entries fill the ESP over the years.
boot.loader.systemd-boot.configurationLimit = lib.mkDefault 10; boot.loader.systemd-boot.configurationLimit = lib.mkDefault 10;

70
modules/nixos/greeter.nix Normal file
View File

@@ -0,0 +1,70 @@
# Greeter — greetd/tuigreet, themed from the same theme-state.json that
# drives the desktop (nomarchy.system.stateFile; the Plymouth model:
# baked at SYSTEM rebuild, so it follows the theme as of the last
# sys-update, not the last instant apply).
#
# tuigreet draws on the virtual console with the 16 ANSI slots, so the
# theming is two-part:
# 1. console.colors — the VT palette becomes the theme's ansi[] hexes
# (which also themes raw ttys and the LUKS passphrase prompt: the
# same JSON reaches every pre-session surface).
# 2. --theme — tuigreet components on NAMED slots (its parser is
# ratatui Color::from_str; names map to the standard indexes, e.g.
# blue=4, gray=7, white=15, so the palette above hands them the
# theme's colors). ANSI "black" stays dark even in light themes —
# the greeter reads terminal-dark there, the same convention every
# terminal applies to ANSI colors.
{ config, lib, pkgs, ... }:
let
cfg = config.nomarchy.system;
distroName = config.system.nixos.distroName;
state =
if cfg.stateFile != null
then builtins.fromJSON (builtins.readFile cfg.stateFile)
else { };
# A sparse/hand-rolled state without a proper ansi block just skips the
# theming (stock tuigreet grey) — never an eval error.
ansi = state.ansi or [ ];
themed = builtins.isList ansi && builtins.length ansi == 16;
tuigreetTheme = lib.concatStringsSep ";" [
"container=black" # ansi[0] — the theme's terminal background
"border=blue" # ansi[4] — the accent family in every shipped palette
"title=cyan"
"greet=cyan"
"prompt=green"
"input=white" # ansi[15] — bright foreground
"action=blue"
"button=yellow"
"time=cyan"
"text=gray" # ansi[7] — muted foreground
];
in
{
config = {
# VT palette from the theme (RRGGBB, no #; lands as vt.default_* kernel
# params). mkDefault so a downstream console.colors wins.
console.colors = lib.mkIf themed (lib.mkDefault (map (lib.removePrefix "#") ansi));
services.greetd = lib.mkIf cfg.greeter.enable {
enable = lib.mkDefault true;
settings = {
default_session = {
# start-hyprland is Hyprland 0.55's watchdog launcher; running
# the bare binary makes every session print a warning.
command = lib.mkDefault ("${pkgs.tuigreet}/bin/tuigreet --time --remember --greeting 'Welcome to ${distroName}'"
+ lib.optionalString themed " --theme '${tuigreetTheme}'"
+ " --cmd start-hyprland");
user = "greeter";
};
# Boot straight into the session once; logout → normal greeter.
initial_session = lib.mkIf (cfg.greeter.autoLogin != null) {
command = "start-hyprland";
user = cfg.greeter.autoLogin;
};
};
};
};
}

242
modules/nixos/hardware.nix Normal file
View File

@@ -0,0 +1,242 @@
# Hardware enablement beyond nixos-hardware.
#
# The nixos-hardware "common-*" profiles the installer selects cover the
# BASICS (microcode, the Intel/AMD VA-API media stack, weekly fstrim), and
# power.nix adds thermald + power-profiles-daemon. This module fills the GAP
# above those: broadly-beneficial bits that default ON when the installer
# detects the vendor (opt-OUT), and heavier/experimental bits behind opt-IN
# toggles. The installer's hardware-db.sh probes what's present and writes the
# matching nomarchy.hardware.* into the generated system.nix.
#
# Audited against the commons so we don't double-set: we add GuC/HuC, the
# amd-pstate governor, the AMD VA-API env, GPU-compute runtimes, fprintd, and
# the NPU driver — none of which the commons turn on.
{ config, lib, pkgs, ... }:
let
cfg = config.nomarchy.hardware;
in
{
options.nomarchy.hardware = {
intel = {
enable = lib.mkEnableOption ''
Intel CPU/GPU enablement (the installer turns this on when it detects
an Intel CPU or GPU). Complements nixos-hardware's common-gpu-intel'';
guc = lib.mkOption {
type = lib.types.bool;
default = cfg.intel.enable;
defaultText = lib.literalExpression "config.nomarchy.hardware.intel.enable";
description = ''
Load the GPU's GuC/HuC firmware via the i915 param
(i915.enable_guc=3) better power management and HuC-accelerated
media. On by default with intel.enable. NOTE: this is the *i915*
driver's param; the newer `xe` driver (Lunar Lake / Battlemage /
Panther Lake and other recent Xe GPUs) enables GuC by default and
ignores it, so the installer turns this off on xe-driver hardware.
'';
};
computeRuntime = lib.mkEnableOption ''
Intel GPU compute: the OpenCL / Level Zero (intel-compute-runtime) and
oneVPL (vpl-gpu-rt) runtimes for GPU compute and transcode. Opt-in (a
few hundred MB) the Intel counterpart to AMD ROCm'';
};
amd = {
enable = lib.mkEnableOption ''
AMD CPU/GPU enablement (installer-set on an AMD CPU or GPU).
Complements nixos-hardware's common-cpu-amd / common-gpu-amd'';
pstate = lib.mkOption {
type = lib.types.bool;
default = cfg.amd.enable;
defaultText = lib.literalExpression "config.nomarchy.hardware.amd.enable";
description = ''
Use the amd-pstate EPP driver (amd_pstate=active) the modern Zen
power/perf governor that power-profiles-daemon drives per profile.
On by default with amd.enable (broadly beneficial on Zen 2+).
'';
};
vaapi = lib.mkOption {
type = lib.types.bool;
default = cfg.amd.enable;
defaultText = lib.literalExpression "config.nomarchy.hardware.amd.enable";
description = ''
Point VA-API at mesa's radeonsi (LIBVA_DRIVER_NAME=radeonsi) for
hardware video decode/encode. On by default with amd.enable.
'';
};
rocm = {
enable = lib.mkEnableOption ''
AMD ROCm: the HIP / OpenCL GPU-compute stack (multi-GB closure).
Opt-in unlocks GPU PyTorch / Ollama on Radeon'';
gfxOverride = lib.mkOption {
type = lib.types.str;
default = "";
example = "11.0.0";
description = ''
HSA_OVERRIDE_GFX_VERSION for GPUs ROCm doesn't officially list
(e.g. an RDNA3 780M iGPU, gfx1103, needs "11.0.0"). Empty = no
override.
'';
};
};
};
fingerprint = {
enable = lib.mkEnableOption ''
a fingerprint reader via fprintd (the installer turns this on when it
detects a known reader). Enroll with `fprintd-enroll`'';
pam = lib.mkEnableOption ''
using the fingerprint for login and sudo (PAM). Opt-in password-only
stays the default for the cautious; enroll a finger first'';
};
npu.enable = lib.mkEnableOption ''
the on-die NPU (AI accelerator) kernel driver amdxdna on AMD (Ryzen
AI), intel_vpu on Intel (Core Ultra and newer). Opt-in and experimental:
this loads the in-kernel driver only; the userspace runtime (AMD XRT /
oneAPI Level Zero NPU) is yours to add. Needs a recent kernel (see
latestKernel)'';
latestKernel = lib.mkEnableOption ''
the latest mainline kernel (pkgs.linuxPackages_latest) instead of the
distro default for very new hardware whose drivers (a fresh NPU, the
`xe` GPU driver, new-platform enablement) only landed recently. Off by
default; the default kernel already carries amd-pstate and amdxdna (6.14+)'';
camera = {
hideIrSensor = lib.mkEnableOption ''
hiding a dual-sensor webcam's IR (face-unlock) node from PipeWire so
apps only ever see the colour camera. Such modules (common on recent
ThinkPads) expose the IR sensor as a SECOND, identically-named
"Integrated Camera"; selecting it gives a dark, 8-bit-greyscale image
the classic "my webcam is dark" symptom. The installer turns this on
when it detects a paired RGB+IR webcam. Only the PipeWire node is
disabled the kernel /dev/video* device stays open, so face-unlock
(Howdy) still works. Acts on the V4L2 path only; the libcamera monitor
is left untouched, so an external camera you plug in is never affected.
See irMatch'';
irMatch = lib.mkOption {
type = lib.types.str;
default = "~.*(Integrated I|IR Camera|Infrared).*";
description = ''
WirePlumber regex (matched against a V4L2 node's api.v4l2.cap.card)
selecting the IR sensor to hide. The default catches the common
dual-sensor naming (" Integrated I", "IR Camera", "Infrared"); set it
to your camera's IR card name if it differs find it with
`v4l2-ctl --list-devices` or `wpctl inspect`. Only consulted when
camera.hideIrSensor is on.
'';
};
};
};
config = lib.mkMerge [
# ── Intel ──────────────────────────────────────────────────────────
(lib.mkIf cfg.intel.guc {
boot.kernelParams = [ "i915.enable_guc=3" ];
})
(lib.mkIf cfg.intel.computeRuntime {
hardware.graphics.extraPackages = with pkgs; [ intel-compute-runtime vpl-gpu-rt ];
})
# ── AMD ────────────────────────────────────────────────────────────
(lib.mkIf cfg.amd.pstate {
boot.kernelParams = [ "amd_pstate=active" ];
})
(lib.mkIf cfg.amd.vaapi {
# radeonsi itself comes from mesa (via common-gpu-amd); this just steers
# libva at it. mkDefault so a hand-set value or another module wins.
environment.sessionVariables.LIBVA_DRIVER_NAME = lib.mkDefault "radeonsi";
hardware.graphics.extraPackages = [ pkgs.libva ];
})
(lib.mkIf cfg.amd.rocm.enable {
hardware.graphics.extraPackages = [ pkgs.rocmPackages.clr pkgs.rocmPackages.clr.icd ];
environment.sessionVariables = lib.optionalAttrs (cfg.amd.rocm.gfxOverride != "") {
HSA_OVERRIDE_GFX_VERSION = cfg.amd.rocm.gfxOverride;
};
})
# ── Fingerprint ────────────────────────────────────────────────────
(lib.mkIf cfg.fingerprint.enable {
services.fprintd.enable = true;
})
(lib.mkIf (cfg.fingerprint.enable && cfg.fingerprint.pam) {
security.pam.services.login.fprintAuth = true;
security.pam.services.sudo.fprintAuth = true;
})
# ── Newest kernel for very-new hardware (opt-in escape hatch) ──────
(lib.mkIf cfg.latestKernel {
boot.kernelPackages = lib.mkDefault pkgs.linuxPackages_latest;
})
# ── NPU (in-kernel driver only; userspace runtime is BYO) ──────────
# The driver has to actually be in the running kernel — warn (don't fail)
# when it predates the shipped one, pointing at latestKernel.
(lib.mkIf (cfg.npu.enable && cfg.amd.enable) {
boot.kernelModules = [ "amdxdna" ];
warnings = lib.optional
(!lib.versionAtLeast config.boot.kernelPackages.kernel.version "6.14")
''
nomarchy.hardware.npu: the amdxdna driver needs kernel >= 6.14, but
this config ships ${config.boot.kernelPackages.kernel.version}. Set
nomarchy.hardware.latestKernel = true.'';
})
(lib.mkIf (cfg.npu.enable && cfg.intel.enable) {
boot.kernelModules = [ "intel_vpu" ];
warnings = lib.optional
(!lib.versionAtLeast config.boot.kernelPackages.kernel.version "6.11")
''
nomarchy.hardware.npu: the intel_vpu driver (especially for newer
NPUs) wants a recent kernel, but this config ships
${config.boot.kernelPackages.kernel.version}. Consider
nomarchy.hardware.latestKernel = true.'';
})
# ── Webcam: hide a dual-sensor module's IR node ────────────────────
# A built-in RGB+IR webcam exposes its IR (face-unlock) sensor as a second,
# identically-named camera; an app that picks it gets a dark greyscale
# image. Disable that node on the V4L2 PipeWire path so only the colour
# camera is offered. Matched by card name (irMatch). libcamera is left
# alone on purpose — an external camera may rely on it, and surgical
# internal-only libcamera scoping isn't possible (the distinguishing
# device props bind after the monitor rule runs). The kernel /dev/video*
# stays open, so Howdy face-unlock still reads the IR sensor directly.
(lib.mkIf (cfg.camera.hideIrSensor && config.services.pipewire.wireplumber.enable) {
services.pipewire.wireplumber.extraConfig."90-nomarchy-hide-ir-camera" = {
"monitor.v4l2.rules" = [
{
matches = [ { "api.v4l2.cap.card" = cfg.camera.irMatch; } ];
actions."update-props"."node.disabled" = true;
}
];
};
})
# ── Sanity ─────────────────────────────────────────────────────────
{
assertions = [
{
assertion = cfg.amd.rocm.enable -> cfg.amd.enable;
message = "nomarchy.hardware.amd.rocm.enable needs nomarchy.hardware.amd.enable.";
}
{
assertion = cfg.intel.computeRuntime -> cfg.intel.enable;
message = "nomarchy.hardware.intel.computeRuntime needs nomarchy.hardware.intel.enable.";
}
{
assertion = cfg.npu.enable -> (cfg.amd.enable || cfg.intel.enable);
message = "nomarchy.hardware.npu.enable needs a detected Intel or AMD platform.";
}
];
}
];
}

42
modules/nixos/oom.nix Normal file
View File

@@ -0,0 +1,42 @@
# Memory-pressure protection: keep the desktop alive when RAM runs out.
#
# A workstation that compiles from source WILL exhaust memory eventually —
# a big `nix build`, a runaway eval, a browser tab. The kernel's own OOM
# killer acts only after the system has thrashed itself unresponsive
# (often minutes of frozen desktop) and then picks by badness score,
# which can land on the compositor.
#
# earlyoom over systemd-oomd — a deliberate choice: oomd kills whole
# cgroups, and a Hyprland session runs as ONE scope (nothing spawns
# per-app systemd scopes here, unlike GNOME), so under pressure oomd
# would take out the entire desktop to save it. earlyoom kills a single
# process (highest oom_score ≈ the hog) BEFORE the thrash point — "kill
# the build step, keep the session". nixpkgs default-enables oomd in an
# inert state (no slices monitored); it's disabled outright below so
# there is exactly one owner of the OOM story.
{ lib, ... }:
{
services.earlyoom = {
enable = lib.mkDefault true;
# Desktop toast when something is killed (relayed via
# systembus-notify), so a vanished build/tab is explained rather
# than mysterious.
enableNotifications = lib.mkDefault true;
# Never pick the session plumbing: losing the compositor or the lock
# screen IS the outage this module exists to prevent (and killing a
# Wayland session-lock client trips its go-to-a-tty failsafe). No
# --prefer tuning: highest-memory selection already targets the hog.
# Matched unanchored — NixOS wrappers rename comm to ".foo-wrapped".
extraArgs = lib.mkDefault [
"--avoid"
"(Hyprland|hyprlock|greetd|waybar|pipewire|wireplumber|Xwayland|nix-daemon|systemd)"
];
};
# One owner (see header): oomd ships default-on but inert; make the
# earlyoom choice explicit and total.
systemd.oomd.enable = lib.mkDefault false;
}

View File

@@ -3,7 +3,7 @@
# Deliberately small: only things a downstream user plausibly disagrees # Deliberately small: only things a downstream user plausibly disagrees
# with get a toggle. Everything else in the system module is set with # with get a toggle. Everything else in the system module is set with
# lib.mkDefault, so plain NixOS options override it natively. # lib.mkDefault, so plain NixOS options override it natively.
{ lib, ... }: { config, lib, ... }:
{ {
options.nomarchy.system = { options.nomarchy.system = {
@@ -11,7 +11,7 @@
greeter.autoLogin = lib.mkOption { greeter.autoLogin = lib.mkOption {
type = lib.types.nullOr lib.types.str; type = lib.types.nullOr lib.types.str;
default = null; default = config.nomarchy.settings.greeter.autoLogin or null;
example = "ada"; example = "ada";
description = '' description = ''
Log this user straight into Hyprland on boot (greetd Log this user straight into Hyprland on boot (greetd
@@ -44,7 +44,17 @@
option'' // { default = true; }; option'' // { default = true; };
audio.enable = lib.mkEnableOption "the Pipewire audio stack" // { default = true; }; audio.enable = lib.mkEnableOption "the Pipewire audio stack" // { default = true; };
bluetooth.enable = lib.mkEnableOption "Bluetooth support with blueman" // { default = true; }; bluetooth.enable = lib.mkEnableOption "Bluetooth support with blueman" // { default = config.nomarchy.settings.bluetooth.enable or true; };
autoTimezone.enable = lib.mkEnableOption ''
automatic timezone detection (geoclue + automatic-timezoned): the
system timezone and so the Waybar clock follows your location, so
travelling to another zone updates the time on its own. Off by default
(it's a location service and needs the network); toggle it from the
System menu, which lands the choice in the in-flake state file. Enabling
it unsets the static time.timeZone for you (a runtime timezone needs
/etc/localtime writable), so the menu toggle drives a system rebuild''
// { default = false; };
snapper.enable = lib.mkEnableOption '' snapper.enable = lib.mkEnableOption ''
hourly/daily BTRFS timeline snapshots of / via snapper, plus the hourly/daily BTRFS timeline snapshots of / via snapper, plus the
@@ -92,15 +102,16 @@
batteryChargeLimit = lib.mkOption { batteryChargeLimit = lib.mkOption {
type = lib.types.nullOr (lib.types.ints.between 50 100); type = lib.types.nullOr (lib.types.ints.between 50 100);
default = null; default = config.nomarchy.settings.power.batteryChargeLimit or null;
example = 80; example = 80;
description = '' description = ''
Stop charging at this percentage to extend battery lifespan, Stop charging at this percentage to extend battery lifespan,
where the hardware exposes a charge threshold where the hardware exposes a charge threshold
(/sys/class/power_supply/BAT*/charge_control_end_threshold). (/sys/class/power_supply/BAT*/charge_control_end_threshold).
null leaves charging at the firmware default. Backend-independent null leaves charging at the firmware default. Backend-independent
(a small systemd unit writes the sysfs knob at boot), so it (a small systemd unit writes the sysfs knob, re-applied on AC
works under PPD too; needs nomarchy.system.power.laptop. state changes), so it works under PPD too; needs
nomarchy.system.power.laptop.
''; '';
}; };
}; };

View File

@@ -38,10 +38,10 @@ in
# Battery charge limit via sysfs. PPD can't cap charge at all, and # Battery charge limit via sysfs. PPD can't cap charge at all, and
# TLP's own knob only applies under TLP — so a tiny oneshot writes # TLP's own knob only applies under TLP — so a tiny oneshot writes
# the threshold directly, independent of the backend. Boot-time only: # the threshold directly, independent of the backend. Re-applied on
# some firmwares reset the threshold on unplug; revisit with a udev # AC state changes by the udev rule below (some firmwares reset the
# hook if that bites. `-` paths that don't exist are skipped, so this # threshold when the charger is unplugged). `[ -w ]` skips paths that
# is a clean no-op on hardware without the control. # don't exist, so this is a clean no-op on hardware without the control.
systemd.services.nomarchy-battery-charge-limit = lib.mkIf chargeLimit { systemd.services.nomarchy-battery-charge-limit = lib.mkIf chargeLimit {
description = "Cap battery charging at ${toString cfg.batteryChargeLimit}%"; description = "Cap battery charging at ${toString cfg.batteryChargeLimit}%";
wantedBy = [ "multi-user.target" ]; wantedBy = [ "multi-user.target" ];
@@ -56,5 +56,16 @@ in
exit 0 exit 0
''; '';
}; };
# Re-apply the threshold whenever the mains adapter changes state: the
# boot oneshot above runs once, but some firmwares clear the limit when
# the charger is unplugged, so it must be re-asserted on the event.
# Matched by ATTR{type}=="Mains" (vendor-neutral — the kernel name
# AC/AC0/ADP1/ACAD varies); --no-block so the RUN+= returns at once
# (systemd kills long-running udev workers); restart (not try-restart)
# so it re-applies even if the boot run was inactive.
services.udev.extraRules = lib.mkIf chargeLimit ''
SUBSYSTEM=="power_supply", ATTR{type}=="Mains", RUN+="${config.systemd.package}/bin/systemctl --no-block restart nomarchy-battery-charge-limit.service"
'';
}; };
} }

View File

@@ -11,8 +11,9 @@ in
{ {
options.nomarchy.services = { options.nomarchy.services = {
tailscale.enable = lib.mkEnableOption '' tailscale.enable = lib.mkEnableOption ''
Tailscale, the mesh VPN ships the daemon; authenticate once with Tailscale, the mesh VPN ships the daemon and makes the login user its
`sudo tailscale up`''; operator, so `tailscale up/down/set` and the System VPN menu work
without sudo. Authenticate once (the menu's Connect, or `tailscale up`)'';
syncthing.enable = lib.mkEnableOption '' syncthing.enable = lib.mkEnableOption ''
Syncthing continuous file sync, running as the login user add Syncthing continuous file sync, running as the login user add
@@ -75,7 +76,8 @@ in
printing.enable = lib.mkEnableOption '' printing.enable = lib.mkEnableOption ''
CUPS printing with Avahi/mDNS, so network printers are auto-discovered CUPS printing with Avahi/mDNS, so network printers are auto-discovered
(add vendor drivers via `services.printing.drivers`)''; (add vendor drivers via `services.printing.drivers`); the menu's
System Printers entry opens the system-config-printer GUI'' // { default = config.nomarchy.settings.printing.enable or false; };
openrgb.enable = lib.mkEnableOption '' openrgb.enable = lib.mkEnableOption ''
the OpenRGB daemon and GUI for controlling RGB lighting on peripherals the OpenRGB daemon and GUI for controlling RGB lighting on peripherals
@@ -124,6 +126,12 @@ in
config = lib.mkMerge [ config = lib.mkMerge [
(lib.mkIf cfg.tailscale.enable { (lib.mkIf cfg.tailscale.enable {
services.tailscale.enable = true; services.tailscale.enable = true;
# Let the login user drive tailscale (up/down/set — and so the VPN menu's
# Tailscale controls) without sudo. It's already in wheel, so the operator
# grant is no real new privilege, just skips the password prompt. The
# module's tailscaled-set unit applies this after the daemon starts.
# mkDefault so a downstream can drop or replace it (e.g. extraSetFlags = []).
services.tailscale.extraSetFlags = lib.mkDefault [ "--operator=${args.username}" ];
}) })
(lib.mkIf cfg.syncthing.enable { (lib.mkIf cfg.syncthing.enable {
@@ -246,6 +254,9 @@ in
nssmdns4 = true; nssmdns4 = true;
openFirewall = true; openFirewall = true;
}; };
# The CUPS admin GUI — the menu's System ▸ Printers entry execs it
# (self-gated on this binary), so it ships with the printing service.
environment.systemPackages = [ pkgs.system-config-printer ];
}) })
(lib.mkIf cfg.openrgb.enable { (lib.mkIf cfg.openrgb.enable {

View File

@@ -0,0 +1,87 @@
# Automatic timezone detection (opt-in) — the system timezone, and so the
# Waybar clock, follows your location, so travelling to another zone updates
# the time on its own. Geoclue feeds `automatic-timezoned`, which drives
# /etc/localtime at runtime.
#
# In-flake state, menu-driven (the keyboard/night-light philosophy): the on/off
# flag lives in the same theme-state.json under `settings.autoTimezone`
# (git-tracked, reproducible), written by the System-menu toggle
# (nomarchy-autotimezone). Because this is a SYSTEM service — not a user unit it
# can start/stop instantly like night-light — the toggle drives a system rebuild
# (plus a home switch for the Waybar-refresh watcher in timezone.nix home-side).
{ config, lib, pkgs, ... }:
let
cfg = config.nomarchy.system;
# Read the same state file the rest of the system side uses (Plymouth too),
# wired by lib.mkFlake. The flag defaults off when the file is absent/sparse.
state =
if cfg.stateFile != null
then builtins.fromJSON (builtins.readFile cfg.stateFile)
else { };
stateEnabled = (state.settings or { }).autoTimezone or false;
sync = lib.getExe pkgs.nomarchy-theme-sync;
# Menu/CLI toggle. Runs as the normal user (it owns the flake checkout +
# writes the state); sudos only the system switch, like sys-update. Writes
# the in-flake flag, then rebuilds: the system rebuild bakes the service +
# the time.timeZone override, the home switch installs/removes the Waybar
# refresh watcher — both read the same flag we just wrote.
nomarchy-autotimezone = pkgs.writeShellScriptBin "nomarchy-autotimezone" ''
set -e
if [ "$(id -u)" -eq 0 ]; then
echo "nomarchy-autotimezone: run as your normal user (it sudos the rebuild itself)" >&2
exit 1
fi
flake="''${NOMARCHY_PATH:-$HOME/.nomarchy}"
cur=$(${sync} get settings.autoTimezone 2>/dev/null) || cur=false
case "''${1:-toggle}" in
on) new=true ;;
off) new=false ;;
toggle) case "$cur" in true|True) new=false ;; *) new=true ;; esac ;;
status) echo "$cur"; exit 0 ;;
*) echo "usage: nomarchy-autotimezone [toggle|on|off|status]" >&2; exit 64 ;;
esac
${sync} --quiet set settings.autoTimezone "$new" --no-switch
if [ "$new" = true ]; then
notify-send "Auto timezone" "Enabling rebuilding the system" 2>/dev/null || true
else
notify-send "Auto timezone" "Disabling rebuilding the system" 2>/dev/null || true
fi
sudo nixos-rebuild switch --flake "$flake#default"
home-manager switch --flake "$flake"
if [ "$new" = true ]; then
notify-send "Auto timezone on" "The clock now follows your location." 2>/dev/null || true
else
notify-send "Auto timezone off" "Back to the fixed timezone in system.nix." 2>/dev/null || true
fi
'';
in
{
config = lib.mkMerge [
{
# Shipped unconditionally so the menu can enable the feature even while
# it's off. Track the in-flake flag; mkDefault so a hand-set
# nomarchy.system.autoTimezone.enable in system.nix still wins.
environment.systemPackages = [ nomarchy-autotimezone ];
nomarchy.system.autoTimezone.enable = lib.mkDefault stateEnabled;
}
(lib.mkIf cfg.autoTimezone.enable {
services.geoclue2.enable = true;
services.automatic-timezoned.enable = true;
# A runtime timezone needs /etc/localtime writable. automatic-timezoned
# sets time.timeZone = null itself, but the installer writes a static
# value at normal priority, which would collide (a hard eval error) —
# mkForce null overrides both and resolves cleanly.
time.timeZone = lib.mkForce null;
})
];
}

View File

@@ -0,0 +1,12 @@
# Low-battery notification watcher (nomarchy.batteryNotify). A package
# (not an inline script) so the VM check can exercise the crossing logic
# on a minimal node against a test_power fake battery — same reasoning
# as nomarchy-doctor. libnotify is deliberately NOT a runtimeInput: the
# user unit puts it on PATH; the check shims notify-send instead.
{ writeShellApplication, coreutils }:
writeShellApplication {
name = "nomarchy-battery-notify";
runtimeInputs = [ coreutils ];
text = builtins.readFile ./nomarchy-battery-notify.sh;
}

View File

@@ -0,0 +1,58 @@
# Low-battery notifications — the session-side complement to the bar's
# battery colors: Waybar paints the module @warn at 25% and @bad at 10%
# (waybar.nix battery.states) but nothing *notified*. This watcher polls
# the same sysfs state the bar reads and fires exactly one notification
# per downward crossing — normal at 25%, critical at 10% (swaync keeps
# critical toasts up until dismissed) — re-arming once the charger lands.
#
# notify-send is resolved from PATH on purpose (not a runtimeInput): the
# unit (battery-notify.nix) provides libnotify; the VM check shims it to
# capture the calls.
#
# usage: nomarchy-battery-notify [poll-interval-seconds]
interval="${1:-30}"
warn=25
crit=10
# System batteries only: type Battery, and not scope=Device (how
# peripheral batteries — mice, headsets — report). Name-agnostic on
# purpose: BAT0, dual-battery BAT0+BAT1, CMB0, test_battery all match.
batteries() {
local d
for d in /sys/class/power_supply/*/; do
[ "$(cat "$d/type" 2>/dev/null)" = Battery ] || continue
[ "$(cat "$d/scope" 2>/dev/null || echo System)" = Device ] && continue
printf '%s\n' "$d"
done
}
# Self-gate: no battery, not a laptop — a clean no-op on desktops.
[ -n "$(batteries)" ] || exit 0
state=ok # ok → warn → crit, one notification per downward crossing
while :; do
sum=0 n=0 discharging=
while IFS= read -r d; do
cap=$(cat "$d/capacity" 2>/dev/null) || continue
sum=$((sum + cap)); n=$((n + 1))
[ "$(cat "$d/status" 2>/dev/null)" = Discharging ] && discharging=1
done < <(batteries)
if [ "$n" -gt 0 ] && [ -n "$discharging" ]; then
cap=$((sum / n))
if [ "$cap" -le "$crit" ] && [ "$state" != crit ]; then
notify-send -u critical -a Nomarchy "Battery critical: ${cap}%" \
"Plug in now."
state=crit
elif [ "$cap" -le "$warn" ] && [ "$state" = ok ]; then
notify-send -u normal -a Nomarchy "Battery low: ${cap}%" \
"Consider plugging in."
state=warn
fi
else
# On the charger (or nothing readable): re-arm for the next drain.
state=ok
fi
sleep "$interval"
done

View File

@@ -0,0 +1,7 @@
{ writeShellApplication, coreutils, gawk, jq, gum }:
writeShellApplication {
name = "nomarchy-control-center";
runtimeInputs = [ coreutils gawk jq gum ];
text = builtins.readFile ./nomarchy-control-center.sh;
}

View File

@@ -0,0 +1,235 @@
#!/usr/bin/env bash
set -euo pipefail
# Nomarchy Control Center — TUI frontend over nomarchy-theme-sync and tools
# Uses charmbracelet/gum for rendering.
trap 'exit 0' SIGINT
function get_state() {
nomarchy-theme-sync get "$1" 2>/dev/null || echo ""
}
function set_state() {
nomarchy-theme-sync --quiet set "$1" "$2" --no-switch
}
function first_boot() {
gum style --border normal --margin "1" --padding "1 2" --border-foreground 212 "Welcome to Nomarchy!"
echo "Pick a Theme Preset:"
themes=$(nomarchy-theme-sync list)
chosen_theme=$(printf "%s" "$themes" | gum choose)
if [ -n "$chosen_theme" ]; then
echo "Applying theme $chosen_theme..."
nomarchy-theme-sync apply "$chosen_theme"
fi
if gum confirm "Enable auto-commit for settings?"; then
set_state "settings.autoCommit" "true"
else
set_state "settings.autoCommit" "false"
fi
if command -v nomarchy-autotimezone >/dev/null 2>&1; then
if gum confirm "Enable automatic timezone detection?"; then
set_state "settings.autoTimezone" "true"
else
set_state "settings.autoTimezone" "false"
fi
fi
gum style --foreground 212 "First boot configuration complete!"
echo "You can always change these later in the Control Center."
read -r -n 1 -s -p "Press any key to exit..."
echo
}
function main_menu() {
while true; do
choice=$(gum choose "Appearance" "System Toggles" "Health (Doctor)" "Rollback" "Exit")
case "$choice" in
"Appearance") appearance_menu ;;
"System Toggles") toggles_menu ;;
"Health (Doctor)")
if command -v nomarchy-doctor >/dev/null 2>&1; then
nomarchy-doctor || true
else
echo "nomarchy-doctor not found."
fi
echo
read -r -n 1 -s -p "Press any key to return..."
echo
;;
"Rollback")
gens=$(home-manager generations 2>/dev/null | head -10)
if [ -z "$gens" ]; then
echo "No Home Manager generations found."
else
printf "%s\n" "$gens" | awk '{ printf "Desktop gen %s — %s %s%s\n", $5, $1, $2, (NR==1 ? " (current)" : "") }' > /tmp/nomarchy-gens
sel=$(gum choose < /tmp/nomarchy-gens || true)
if [ -n "$sel" ]; then
num=${sel#Desktop gen }
num=${num%% *}
path=$(printf "%s\n" "$gens" | awk -v n="$num" '$5 == n { print $7 }')
if [ -x "$path/activate" ]; then
echo "Activating Home Manager generation $num..."
"$path/activate"
echo "Desktop rolled back to generation $num."
else
echo "Generation $num not found."
fi
fi
fi
echo
read -r -n 1 -s -p "Press any key to return..."
echo
;;
"Exit"|*) break ;;
esac
done
}
function appearance_menu() {
while true; do
choice=$(gum choose "Pick Theme" "Toggle Blur" "Set Gaps" "Back")
case "$choice" in
"Pick Theme")
themes=$(nomarchy-theme-sync list)
chosen=$(printf "%s" "$themes" | gum choose)
if [ -n "$chosen" ]; then
nomarchy-theme-sync apply "$chosen"
fi
;;
"Toggle Blur")
blur=$(get_state "ui.blur")
if [ "$blur" = "true" ]; then
set_state "ui.blur" "false"
else
set_state "ui.blur" "true"
fi
;;
"Set Gaps")
gaps=$(gum input --prompt "Enter gaps in pixels: " --placeholder "$(get_state ui.gapsOut)")
if [ -n "$gaps" ]; then
set_state "ui.gapsOut" "$gaps"
fi
;;
"Back"|*) break ;;
esac
done
}
function toggles_menu() {
while true; do
choice=$(gum choose "Auto-commit" "Auto-timezone" "Night Light" "Updates" "Battery Limit" "Bluetooth" "Printing" "Terminal" "Keyboard Layout" "Auto-Login" "Back")
case "$choice" in
"Auto-commit")
cur=$(get_state "settings.autoCommit")
if [ "$cur" = "true" ]; then
set_state "settings.autoCommit" "false"
echo "Auto-commit disabled."
else
set_state "settings.autoCommit" "true"
echo "Auto-commit enabled."
fi
sleep 1
;;
"Auto-timezone")
cur=$(get_state "settings.autoTimezone")
if [ "$cur" = "true" ]; then
set_state "settings.autoTimezone" "false"
echo "Auto-timezone disabled."
else
set_state "settings.autoTimezone" "true"
echo "Auto-timezone enabled."
fi
sleep 1
;;
"Night Light")
if command -v nomarchy-nightlight >/dev/null 2>&1; then
nomarchy-nightlight toggle
sleep 1
else
echo "nomarchy-nightlight not found."
sleep 1
fi
;;
"Updates")
cur=$(get_state "settings.updates.enable")
if [ "$cur" = "true" ]; then
set_state "settings.updates.enable" "false"
echo "Updates checker disabled (requires rebuild)."
else
set_state "settings.updates.enable" "true"
echo "Updates checker enabled (requires rebuild)."
fi
sleep 1
;;
"Battery Limit")
limit=$(gum input --prompt "Enter battery charge limit % (or empty for default): " --placeholder "$(get_state settings.power.batteryChargeLimit)")
if [ -n "$limit" ]; then
set_state "settings.power.batteryChargeLimit" "$limit"
else
set_state "settings.power.batteryChargeLimit" "null"
fi
;;
"Bluetooth")
cur=$(get_state "settings.bluetooth.enable")
if [ "$cur" = "true" ]; then
set_state "settings.bluetooth.enable" "false"
echo "Bluetooth disabled (requires rebuild)."
else
set_state "settings.bluetooth.enable" "true"
echo "Bluetooth enabled (requires rebuild)."
fi
sleep 1
;;
"Printing")
cur=$(get_state "settings.printing.enable")
if [ "$cur" = "true" ]; then
set_state "settings.printing.enable" "false"
echo "Printing services disabled (requires rebuild)."
else
set_state "settings.printing.enable" "true"
echo "Printing services enabled (requires rebuild)."
fi
sleep 1
;;
"Terminal")
term=$(gum choose "ghostty" "kitty" "alacritty" "wezterm" "foot")
if [ -n "$term" ]; then
set_state "settings.terminal" "\"$term\""
echo "Terminal set to $term (requires rebuild)."
fi
sleep 1
;;
"Keyboard Layout")
layout=$(gum input --prompt "Enter keyboard layout (e.g. us, de, es): " --placeholder "$(get_state settings.keyboard.layout | tr -d '"')")
if [ -n "$layout" ]; then
set_state "settings.keyboard.layout" "\"$layout\""
echo "Keyboard layout set to $layout (requires rebuild)."
fi
sleep 1
;;
"Auto-Login")
user=$(gum input --prompt "Enter auto-login username (or empty to disable): " --placeholder "$(get_state settings.greeter.autoLogin | tr -d '"')")
if [ -n "$user" ]; then
set_state "settings.greeter.autoLogin" "\"$user\""
echo "Auto-login set to $user (requires rebuild)."
else
set_state "settings.greeter.autoLogin" "null"
echo "Auto-login disabled (requires rebuild)."
fi
sleep 1
;;
"Back"|*) break ;;
esac
done
}
if [ "${1:-}" = "--first-boot" ]; then
first_boot
else
main_menu
fi

View File

@@ -0,0 +1,10 @@
# One-shot read-only health check (System Doctor / `nomarchy-doctor`).
# A package (not an inline script) so the VM check can exercise it on a
# minimal node without pulling in the whole distro module.
{ writeShellApplication, coreutils, gawk, git, gnugrep, jq, systemd }:
writeShellApplication {
name = "nomarchy-doctor";
runtimeInputs = [ coreutils gawk git gnugrep jq systemd ];
text = builtins.readFile ./nomarchy-doctor.sh;
}

View File

@@ -0,0 +1,138 @@
# nomarchy-doctor — one-shot, READ-ONLY health check: the things that
# actually break user machines, as a pass/fail sheet where every
# failure prints the one command that fixes it. It never changes
# anything itself. Exit 0 = no failures (warnings allowed), 1 = at
# least one ✖.
flake="${NOMARCHY_PATH:-$HOME/.nomarchy}"
fails=0
warns=0
grn=$'\033[32m'; red=$'\033[31m'; ylw=$'\033[33m'; dim=$'\033[2m'; rst=$'\033[0m'
ok() { printf ' %b✔%b %s\n' "$grn" "$rst" "$1"; }
bad() { printf ' %b✖%b %s\n' "$red" "$rst" "$1"
printf ' %bfix: %s%b\n' "$dim" "$2" "$rst"
fails=$((fails + 1)); }
warn() { printf ' %b●%b %s\n' "$ylw" "$rst" "$1"
if [ $# -gt 1 ]; then printf ' %b%s%b\n' "$dim" "$2" "$rst"; fi
warns=$((warns + 1)); }
skip() { printf ' %b %s%b\n' "$dim" "$1" "$rst"; }
printf 'nomarchy-doctor — %s\n\n' "$(date '+%Y-%m-%d %H:%M')"
# ── systemd units ────────────────────────────────────────────────────
mapfile -t sysfailed < <(systemctl --failed --no-legend --plain 2>/dev/null | awk '{print $1}')
if [ "${#sysfailed[@]}" -eq 0 ]; then
ok "no failed system units"
else
bad "failed system unit(s): ${sysfailed[*]}" \
"journalctl -b -u <unit> (read why; sys-rebuild after fixing)"
fi
# A user bus only exists inside a session (not over bare SSH/root).
if [ -n "$(systemctl --user is-system-running 2>/dev/null || true)" ]; then
mapfile -t usrfailed < <(systemctl --user --failed --no-legend --plain 2>/dev/null | awk '{print $1}')
if [ "${#usrfailed[@]}" -eq 0 ]; then
ok "no failed user units"
else
bad "failed user unit(s): ${usrfailed[*]}" \
"journalctl --user -b -u <unit>"
fi
else
skip "user units (no user session bus here)"
fi
# ── disk space ───────────────────────────────────────────────────────
# Only real on-disk filesystems; skips the tmpfs/9p/overlay mounts of
# live systems and test VMs (where usage numbers mean nothing).
seen=""
for mp in / /boot /nix; do
[ -d "$mp" ] || continue
line=$(df -PT "$mp" 2>/dev/null | awk 'NR==2 {gsub("%","",$6); print $1, $2, $6, $5}')
[ -n "$line" ] || continue
read -r dev fstype use availkb <<<"$line"
case "$fstype" in
ext2|ext3|ext4|btrfs|xfs|vfat|f2fs|zfs) ;;
*) continue ;;
esac
case " $seen " in *" $dev "*) continue ;; esac
seen="$seen $dev"
avail=$(numfmt --to=iec $((availkb * 1024)))
if [ "$use" -ge 90 ]; then
bad "$mp is ${use}% full (${avail} free)" \
"sudo nix-collect-garbage --delete-older-than 14d (then sys-rebuild to prune old boot entries)"
else
ok "$mp has space (${use}% used, ${avail} free)"
fi
done
# ── the flake + state file ───────────────────────────────────────────
if [ -f "$flake/theme-state.json" ]; then
if jq empty "$flake/theme-state.json" 2>/dev/null; then
ok "theme-state.json parses"
else
bad "theme-state.json is not valid JSON (rebuilds will fail)" \
"nomarchy-theme-sync validate (names the spot; fix it, or re-apply a theme)"
fi
if git -C "$flake" ls-files --error-unmatch theme-state.json >/dev/null 2>&1; then
ok "theme-state.json is git-tracked"
else
bad "theme-state.json is NOT git-tracked (flake evaluation can't see it)" \
"git -C $flake add theme-state.json"
fi
else
skip "theme-state.json (no flake checkout at $flake)"
fi
if [ -d "$flake/.git" ]; then
dirty=$(git -C "$flake" status --porcelain 2>/dev/null | wc -l)
if [ "$dirty" -gt 0 ]; then
warn "$dirty uncommitted change(s) in $flake (normal — theme writes land there)" \
"commit when happy: git -C $flake add -A && git -C $flake commit -m settings"
else
ok "flake checkout is clean"
fi
behind=$(git -C "$flake" rev-list --count 'HEAD..@{u}' 2>/dev/null || echo 0)
if [ "$behind" -gt 0 ]; then
warn "flake is $behind commit(s) behind its upstream" "git -C $flake pull, then sys-rebuild + home-update"
fi
fi
# ── generation age ───────────────────────────────────────────────────
# The profile SYMLINK's own mtime is the generation's creation time
# (store paths themselves are all epoch-1).
link=$(readlink /nix/var/nix/profiles/system 2>/dev/null || true)
if [ -n "$link" ] && [ -e "/nix/var/nix/profiles/$link" ]; then
ts=$(stat -c %Y "/nix/var/nix/profiles/$link" 2>/dev/null || echo 0)
days=$(( ($(date +%s) - ts) / 86400 ))
if [ "$days" -gt 30 ]; then
warn "last system rebuild was $days days ago" \
"sys-update when convenient (security fixes arrive with input bumps)"
else
ok "system generation is $days day(s) old"
fi
else
skip "system generation age (no system profile)"
fi
# ── snapper timeline (when enabled) ──────────────────────────────────
if [ -n "$(systemctl list-unit-files snapper-timeline.timer --no-legend --plain 2>/dev/null)" ]; then
if systemctl is-active --quiet snapper-timeline.timer; then
ok "snapper timeline snapshots are running"
else
bad "snapper is enabled but the timeline timer is not active" \
"systemctl status snapper-timeline.timer (then journalctl -u snapper-timeline.service)"
fi
else
skip "snapper (not enabled on this machine)"
fi
# ── verdict ──────────────────────────────────────────────────────────
echo
if [ "$fails" -eq 0 ]; then
printf '%b✔ healthy%b — %d warning(s)\n' "$grn" "$rst" "$warns"
exit 0
else
printf '%b✖ %d problem(s)%b, %d warning(s) — each ✖ above shows its fix\n' "$red" "$fails" "$rst" "$warns"
exit 1
fi

View File

@@ -112,6 +112,7 @@ HARDWARE_DB=(
# ---------------------------------------------------------------------------- # ----------------------------------------------------------------------------
nomarchy_detect_hw() { nomarchy_detect_hw() {
local sys_vendor product_name cpu_vendor local sys_vendor product_name cpu_vendor
local nvidia=0 amdgpu=0 intelgpu=0
sys_vendor=$(cat /sys/class/dmi/id/sys_vendor 2>/dev/null || echo "") sys_vendor=$(cat /sys/class/dmi/id/sys_vendor 2>/dev/null || echo "")
product_name=$(cat /sys/class/dmi/id/product_name 2>/dev/null || echo "") product_name=$(cat /sys/class/dmi/id/product_name 2>/dev/null || echo "")
cpu_vendor=$(lscpu 2>/dev/null | awk -F: '/Vendor ID/{gsub(/ /,"",$2); print $2; exit}') cpu_vendor=$(lscpu 2>/dev/null | awk -F: '/Vendor ID/{gsub(/ /,"",$2); print $2; exit}')
@@ -126,7 +127,7 @@ nomarchy_detect_hw() {
# GPU (lspci may list several; report all) # GPU (lspci may list several; report all)
if command -v lspci >/dev/null 2>&1; then if command -v lspci >/dev/null 2>&1; then
local gpu_line nvidia=0 amdgpu=0 intelgpu=0 local gpu_line
while IFS= read -r gpu_line; do while IFS= read -r gpu_line; do
case "$gpu_line" in case "$gpu_line" in
*"[10de:"*|*"NVIDIA"*) nvidia=1 ;; *"[10de:"*|*"NVIDIA"*) nvidia=1 ;;
@@ -140,6 +141,92 @@ nomarchy_detect_hw() {
(( intelgpu )) && { echo "MODULE common-gpu-intel"; echo "DETAIL gpu: Intel"; detected=1; } (( intelgpu )) && { echo "MODULE common-gpu-intel"; echo "DETAIL gpu: Intel"; detected=1; }
fi fi
# ── nomarchy.hardware.* enablement — the gap ABOVE the nixos-hardware
# commons (GuC/HuC, amd-pstate, the AMD VA-API env, GPU-compute runtimes,
# fprintd, the NPU driver). Emitted as NOMARCHY lines the installer turns
# into nomarchy.hardware.* in system.nix; safe bits active, heavy opt-ins
# commented.
if [[ "$cpu_vendor" == "GenuineIntel" || $intelgpu -eq 1 ]]; then
echo "NOMARCHY hardware.intel.enable=true"
echo "DETAIL nomarchy.hardware.intel: GuC/HuC on; GPU-compute runtime opt-in"
# i915.enable_guc applies to the i915 driver only. The newer `xe` driver
# (Lunar Lake / Battlemage / Panther Lake, recent Xe GPUs) enables GuC by
# default and ignores the param — so turn the toggle off when xe is bound.
if lspci -k 2>/dev/null | grep -qiE 'driver in use: xe\b'; then
echo "NOMARCHY hardware.intel.guc=false"
echo "DETAIL Intel GPU on the xe driver → GuC default-on (i915 param skipped)"
fi
fi
if [[ "$cpu_vendor" == "AuthenticAMD" || $amdgpu -eq 1 ]]; then
echo "NOMARCHY hardware.amd.enable=true"
echo "DETAIL nomarchy.hardware.amd: amd-pstate + VA-API on; ROCm opt-in"
fi
# Fingerprint reader — libfprint's common USB vendor IDs (Goodix,
# Synaptics/Validity, Elan, EgisTec, Upek, AuthenTec, FocalTech, NB).
local have_fp=0
if command -v lsusb >/dev/null 2>&1; then
local vid
for vid in 27c6 06cb 138a 04f3 1c7a 147e 08ff 2808 1fae; do
lsusb 2>/dev/null | grep -qiE "ID ${vid}:" && { have_fp=1; break; }
done
else
local f
for f in /sys/bus/usb/devices/*/idVendor; do
[[ -e "$f" ]] || continue
case "$(cat "$f" 2>/dev/null)" in
27c6|06cb|138a|04f3|1c7a|147e|08ff|2808|1fae) have_fp=1; break ;;
esac
done
fi
if [[ $have_fp -eq 1 ]]; then
echo "NOMARCHY hardware.fingerprint.enable=true"
echo "DETAIL fingerprint reader detected → fprintd (PAM login/sudo opt-in)"
fi
# Dual-sensor webcam (RGB + IR face-unlock). Such modules expose the IR
# sensor as a SECOND, identically-named "Integrated Camera"; an app that
# picks it shows a dark, 8-bit-greyscale image. When an IR-companion node
# sits alongside a normal camera node, enable hiding the IR one from
# PipeWire (the colour camera stays; the kernel /dev/video* is untouched, so
# Howdy can still read the IR sensor directly). Read from /sys — no
# v4l2-ctl needed in the installer env.
local cam_name have_ir_cam=0 have_color_cam=0 vf
local ir_re='Integrated I|IR Camera|Infrared'
for vf in /sys/class/video4linux/video*/name; do
[[ -e "$vf" ]] || continue
cam_name=$(cat "$vf" 2>/dev/null)
shopt -s nocasematch
if [[ "$cam_name" =~ $ir_re ]]; then
have_ir_cam=1
elif [[ "$cam_name" =~ (Camera|Webcam) ]]; then
have_color_cam=1
fi
shopt -u nocasematch
done
if [[ $have_ir_cam -eq 1 && $have_color_cam -eq 1 ]]; then
echo "NOMARCHY hardware.camera.hideIrSensor=true"
echo "DETAIL dual-sensor webcam (RGB+IR) → hide the IR node so apps get the colour camera"
fi
# NPU (detect-only → a commented, experimental opt-in). Match the PCI
# "Processing accelerators" class [1200] (or accelerator keywords) and
# attribute by vendor — future-proof vs a per-device-ID list, so new gens
# (e.g. Panther Lake) are caught without a code change. Known IDs kept for
# reference: Intel VPU 8086:{7d1d,643e,ad1d,b03e}, AMD XDNA 1022:1502.
if command -v lspci >/dev/null 2>&1; then
local npu_line
npu_line=$(lspci -nn 2>/dev/null \
| grep -iE '\[1200\]|processing accelerat|neural|\[8086:(7d1d|643e|ad1d|b03e)\]|\[1022:1502\]' \
| head -1)
case "$npu_line" in
*"[8086:"*|*Intel*)
echo "NOMARCHY-NPU intel"; echo "DETAIL Intel NPU detected (opt-in, experimental)" ;;
*"[1022:"*|*"Advanced Micro Devices"*|*AMD*)
echo "NOMARCHY-NPU amd"; echo "DETAIL AMD XDNA NPU detected (opt-in, experimental)" ;;
esac
fi
# Chassis (glob test, not compgen — nixpkgs' non-interactive bash # Chassis (glob test, not compgen — nixpkgs' non-interactive bash
# is built without the completion builtins) # is built without the completion builtins)
local bats=(/sys/class/power_supply/BAT*) local bats=(/sys/class/power_supply/BAT*)

View File

@@ -54,6 +54,15 @@ if [[ $EUID -ne 0 ]]; then
exec sudo --preserve-env "$0" "$@" exec sudo --preserve-env "$0" "$@"
fi fi
# `sudo --preserve-env` (needed to carry the NOMARCHY_* vars) also drags in
# the live session user's HOME=/home/nomarchy. Root-run `nix` calls below
# would then scribble an eval cache + .nix-defexpr into /home/nomarchy —
# and the in-chroot one lands on the TARGET disk as a stray, orphaned
# /home/nomarchy (no such user on the installed system). Pin root's own
# HOME so every root nix invocation stays in /root; the user activation
# sets HOME=/home/$USERNAME explicitly and is unaffected.
export HOME=/root
header "Nomarchy installer" "NixOS, themed and ready to go." header "Nomarchy installer" "NixOS, themed and ready to go."
[[ -d /sys/firmware/efi ]] \ [[ -d /sys/firmware/efi ]] \
@@ -210,6 +219,8 @@ section "Hardware detection"
source "$SHARE/hardware-db.sh" source "$SHARE/hardware-db.sh"
HW_PROFILES=() HW_PROFILES=()
HW_NOMARCHY=() # NOMARCHY hardware.* assignments from detection
NPU_VENDOR="" # "intel" | "amd" if an NPU was detected (commented opt-in)
hw_mode="${NOMARCHY_HW:-auto}" hw_mode="${NOMARCHY_HW:-auto}"
if [[ "$hw_mode" == "none" ]]; then if [[ "$hw_mode" == "none" ]]; then
info "Hardware profiles skipped." info "Hardware profiles skipped."
@@ -221,6 +232,8 @@ else
while IFS= read -r line; do while IFS= read -r line; do
case "$line" in case "$line" in
MODULE\ *) HW_PROFILES+=("${line#MODULE }") ;; MODULE\ *) HW_PROFILES+=("${line#MODULE }") ;;
NOMARCHY-NPU\ *) NPU_VENDOR="${line#NOMARCHY-NPU }" ;;
NOMARCHY\ *) HW_NOMARCHY+=("${line#NOMARCHY }") ;;
DETAIL\ *) info "${line#DETAIL }" ;; DETAIL\ *) info "${line#DETAIL }" ;;
esac esac
done <<< "$detection" done <<< "$detection"
@@ -428,6 +441,62 @@ NIX
) )
fi fi
# Hardware enablement (nomarchy.hardware.*): what hardware-db.sh detected
# above the nixos-hardware commons. Safe defaults active; the heavier or
# experimental opt-ins written commented for the user to flip on.
HARDWARE_CONFIG=""
if [[ ${#HW_NOMARCHY[@]} -gt 0 || -n "$NPU_VENDOR" ]]; then
has_intel=0; has_amd=0; has_fp=0; intel_guc_off=0; has_camera_ir=0
if [[ ${#HW_NOMARCHY[@]} -gt 0 ]]; then
for nm in "${HW_NOMARCHY[@]}"; do
case "$nm" in
hardware.intel.enable=true) has_intel=1 ;;
hardware.intel.guc=false) intel_guc_off=1 ;;
hardware.amd.enable=true) has_amd=1 ;;
hardware.fingerprint.enable=true) has_fp=1 ;;
hardware.camera.hideIrSensor=true) has_camera_ir=1 ;;
esac
done
fi
hw_lines=" # Hardware enablement (auto-detected). Safe defaults are active;
# the heavier opt-ins are commented — uncomment to turn them on."
if [[ $has_intel -eq 1 ]]; then
hw_lines+="
nomarchy.hardware.intel.enable = true; # GuC/HuC firmware (i915.enable_guc=3)"
if [[ $intel_guc_off -eq 1 ]]; then
hw_lines+="
nomarchy.hardware.intel.guc = false; # GPU on the xe driver → GuC is default-on"
fi
hw_lines+="
# nomarchy.hardware.intel.computeRuntime = true; # OpenCL/oneVPL GPU compute (opt-in)"
fi
if [[ $has_amd -eq 1 ]]; then
hw_lines+="
nomarchy.hardware.amd.enable = true; # amd-pstate EPP + radeonsi VA-API
# nomarchy.hardware.amd.rocm.enable = true; # ROCm GPU compute (multi-GB, opt-in)
# nomarchy.hardware.amd.rocm.gfxOverride = \"\"; # e.g. \"11.0.0\" for an unlisted iGPU"
fi
if [[ $has_fp -eq 1 ]]; then
hw_lines+="
nomarchy.hardware.fingerprint.enable = true; # fprintd (enroll: fprintd-enroll)
# nomarchy.hardware.fingerprint.pam = true; # use it for login + sudo (opt-in)"
fi
if [[ $has_camera_ir -eq 1 ]]; then
hw_lines+="
nomarchy.hardware.camera.hideIrSensor = true; # dual-sensor webcam: hide the IR node (color cam only)"
fi
if [[ -n "$NPU_VENDOR" ]]; then
hw_lines+="
# nomarchy.hardware.npu.enable = true; # $NPU_VENDOR NPU driver (experimental; userspace runtime BYO)
# nomarchy.hardware.latestKernel = true; # newest kernel if the NPU driver isn't in the shipped one"
fi
HARDWARE_CONFIG=$(cat <<NIX
$hw_lines
NIX
)
fi
# initialHashedPassword is safe to template: mkpasswd's alphabet is # initialHashedPassword is safe to template: mkpasswd's alphabet is
# [a-zA-Z0-9./$] — no Nix string metacharacters. # [a-zA-Z0-9./$] — no Nix string metacharacters.
cat > "$FLAKE_DIR/system.nix" <<EOF cat > "$FLAKE_DIR/system.nix" <<EOF
@@ -459,7 +528,7 @@ cat > "$FLAKE_DIR/system.nix" <<EOF
extraGroups = [ "wheel" "networkmanager" "video" "input" ]; extraGroups = [ "wheel" "networkmanager" "video" "input" ];
initialHashedPassword = "$HASHED_PASSWORD"; initialHashedPassword = "$HASHED_PASSWORD";
}; };
$AUTOLOGIN_CONFIG$POWER_CONFIG$RESUME_CONFIG $AUTOLOGIN_CONFIG$POWER_CONFIG$HARDWARE_CONFIG$RESUME_CONFIG
# Hourly/daily BTRFS timeline snapshots + nixos-rebuild-snap. # Hourly/daily BTRFS timeline snapshots + nixos-rebuild-snap.
nomarchy.system.snapper.enable = true; nomarchy.system.snapper.enable = true;
@@ -573,6 +642,8 @@ cat > /mnt/root/nomarchy-hm-activate.sh <<EOF
set -ex set -ex
exec > /var/log/nomarchy-hm-preactivate.log 2>&1 exec > /var/log/nomarchy-hm-preactivate.log 2>&1
export PATH=/run/current-system/sw/bin:\$PATH export PATH=/run/current-system/sw/bin:\$PATH
# Keep root's nix state in /root, not a stray /home/nomarchy on the target.
export HOME=/root
# Normally pre-built in the live env and copied over; the in-chroot # Normally pre-built in the live env and copied over; the in-chroot
# build (default substituters — the live-side build already proved the # build (default substituters — the live-side build already proved the
# no-network case) is a last-resort fallback. # no-network case) is a last-resort fallback.

View File

@@ -18,6 +18,7 @@ Commands:
apply <name|file.json> merge a preset into the state + rebuild apply <name|file.json> merge a preset into the state + rebuild
set <dotted.path> <value> tweak one key (e.g. `set ui.gapsOut 16`) + rebuild set <dotted.path> <value> tweak one key (e.g. `set ui.gapsOut 16`) + rebuild
get [dotted.path] print the current state (or one key) get [dotted.path] print the current state (or one key)
validate check the state file against the schema (read-only)
wallpaper apply the current wallpaper via swww wallpaper apply the current wallpaper via swww
bg [next|auto] cycle the theme's wallpapers (instant, no rebuild) bg [next|auto] cycle the theme's wallpapers (instant, no rebuild)
""" """
@@ -25,6 +26,7 @@ Commands:
import argparse import argparse
import json import json
import os import os
import re
import shlex import shlex
import shutil import shutil
import subprocess import subprocess
@@ -90,11 +92,23 @@ def load_state(path: Path = STATE_FILE) -> dict:
except FileNotFoundError: except FileNotFoundError:
die(f"state file not found: {path} (set $NOMARCHY_PATH to your flake checkout)") die(f"state file not found: {path} (set $NOMARCHY_PATH to your flake checkout)")
except json.JSONDecodeError as e: except json.JSONDecodeError as e:
die(f"invalid JSON in {path}: {e}") die(f"theme-state.json has a JSON syntax error at line {e.lineno}, "
f"column {e.colno}: {e.msg}\n"
f" file: {path}\n"
f" fix: correct the syntax by hand (a trailing comma is the "
f"usual culprit) — nothing was changed")
def write_state(state: dict) -> None: def write_state(state: dict) -> None:
"""Atomic write: render to a temp file in the same dir, then rename.""" """Atomic write: render to a temp file in the same dir, then rename.
Validates first — an invalid state never reaches disk (the old file
stays untouched), so a bad `set` can't brick the next rebuild."""
errors, warnings = validate_state(state)
for w in warnings:
log(f"warning: {w}")
if errors:
die("refusing to write an invalid state (nothing changed):\n"
+ "\n".join(errors))
STATE_FILE.parent.mkdir(parents=True, exist_ok=True) STATE_FILE.parent.mkdir(parents=True, exist_ok=True)
fd, tmp = tempfile.mkstemp(dir=STATE_FILE.parent, prefix=".theme-state.", suffix=".json") fd, tmp = tempfile.mkstemp(dir=STATE_FILE.parent, prefix=".theme-state.", suffix=".json")
try: try:
@@ -116,6 +130,39 @@ def write_state(state: dict) -> None:
) )
def auto_commit_enabled(state: dict) -> bool:
return bool((state.get("settings") or {}).get("autoCommit"))
def auto_commit(message: str) -> None:
"""Opt-in (settings.autoCommit): commit theme-state.json — and nothing
else — after a mutation, so settings history is `git log`. The pathspec
keeps unrelated dirty files out of the commit; a missing git identity
falls back to a Nomarchy one so a fresh machine never errors. Callers
fire this when the flag is on before OR after the write, so the
disable-toggle itself lands in history instead of staying dirty.
`bg` is deliberately excluded (runtime wallpaper churn); the wallpaper
path rides along with the next apply/set commit."""
if not (FLAKE_DIR / ".git").exists() or shutil.which("git") is None:
return
git = ["git", "-C", str(FLAKE_DIR)]
# No-op when the file already matches HEAD (a `set` to the same value).
# On a repo with no commits yet this diff errors — then just commit.
if subprocess.run(git + ["diff", "--quiet", "HEAD", "--", "theme-state.json"],
capture_output=True).returncode == 0:
return
if not subprocess.run(git + ["config", "user.email"],
capture_output=True, text=True).stdout.strip():
git += ["-c", "user.name=Nomarchy", "-c", "user.email=nomarchy@localhost"]
result = subprocess.run(
git + ["commit", "--quiet", "-m", message, "--", "theme-state.json"],
capture_output=True, text=True)
if result.returncode == 0:
log(f"auto-committed: {message}")
else:
log(f"auto-commit skipped: {(result.stderr or result.stdout).strip()}")
def deep_merge(base: dict, override: dict) -> dict: def deep_merge(base: dict, override: dict) -> dict:
out = dict(base) out = dict(base)
for k, v in override.items(): for k, v in override.items():
@@ -126,6 +173,152 @@ def deep_merge(base: dict, override: dict) -> dict:
return out return out
# ─── Schema validation ─────────────────────────────────────────────────────
# The friendly-errors contract (with theme.nix, which enforces the same
# rules at eval time): a hand-edited state file fails HERE, before it is
# written, with the field, the problem, and the fix — never as a Nix
# stack trace at rebuild. Hard errors cover the appearance schema the
# modules consume; settings.* (menu-writer territory) and unknown keys
# only warn, so custom keys and newer/older schemas keep working.
HEX_RE = re.compile(r"^#[0-9a-fA-F]{6}$")
COLOR_ROLES = ("base", "mantle", "surface", "overlay", "text", "subtext",
"muted", "accent", "accentAlt", "good", "warn", "bad")
TOP_KEYS = {"version", "name", "slug", "mode", "wallpaper", "colors", "ansi",
"fonts", "ui", "icons", "border", "settings"}
UI_UINTS = ("gapsIn", "gapsOut", "borderSize", "rounding", "iconSize")
UI_OPACITIES = ("activeOpacity", "inactiveOpacity", "terminalOpacity")
UI_BOOLS = ("blur", "shadow")
def _is_hex(v) -> bool:
return isinstance(v, str) and bool(HEX_RE.match(v))
def _is_num(v) -> bool:
return isinstance(v, (int, float)) and not isinstance(v, bool)
def validate_state(state) -> tuple:
"""Return (errors, warnings) — friendly strings, one problem each."""
errors, warnings = [], []
def err(field, problem, fix):
errors.append(f"{field}: {problem}\n fix: {fix}")
if not isinstance(state, dict):
err("top level", "must be a JSON object",
"re-apply a preset: nomarchy-theme-sync apply tokyo-night")
return errors, warnings
for k in state:
if k not in TOP_KEYS:
warnings.append(f"unknown top-level key '{k}' — a typo? (it is ignored)")
for field, want in (("name", "the theme's display name"),
("slug", "the theme's directory name"),
("wallpaper", "a filename in the theme's backgrounds/ (or \"\")"),
("icons", "an icon theme name (or \"\" for pick-by-mode)")):
v = state.get(field)
if v is not None and not isinstance(v, str):
err(field, f"must be a string ({want}), got {v!r}", 'quote it, e.g. "tokyo-night"')
mode = state.get("mode")
if mode is not None and mode not in ("dark", "light"):
err("mode", f'must be "dark" or "light", got {mode!r}', 'set it to "dark" or "light"')
colors = state.get("colors", {})
if not isinstance(colors, dict):
err("colors", "must be an object of palette roles",
"re-apply a preset to restore the palette")
else:
for k, v in colors.items():
if k not in COLOR_ROLES:
warnings.append(f"colors.{k}: not a Nomarchy palette role (it is ignored)")
elif not _is_hex(v):
err(f"colors.{k}", f'must be "#RRGGBB", got {v!r}',
'use a 6-digit hex color like "#7aa2f7"')
ansi = state.get("ansi")
if ansi is not None and not (isinstance(ansi, list) and len(ansi) == 16
and all(_is_hex(c) for c in ansi)):
err("ansi", 'must be a list of exactly 16 "#RRGGBB" strings',
"re-apply a preset to restore the terminal palette")
fonts = state.get("fonts", {})
if isinstance(fonts, dict):
for k in ("mono", "ui"):
v = fonts.get(k)
if v is not None and not isinstance(v, str):
err(f"fonts.{k}", f"must be a font family name (string), got {v!r}",
'quote it, e.g. "JetBrainsMono Nerd Font"')
size = fonts.get("size")
if size is not None and not (_is_num(size) and size > 0):
err("fonts.size", f"must be a positive number (points), got {size!r}",
"use a plain number like 11 (no quotes)")
else:
err("fonts", "must be an object", 're-add: {"mono": "...", "ui": "...", "size": 11}')
ui = state.get("ui", {})
if isinstance(ui, dict):
for k in UI_UINTS:
v = ui.get(k)
if v is not None and not (isinstance(v, int) and not isinstance(v, bool) and v >= 0):
err(f"ui.{k}", f"must be a non-negative whole number (pixels), got {v!r}",
"use a plain number like 12 (no quotes)")
for k in UI_OPACITIES:
v = ui.get(k)
if v is not None and not (_is_num(v) and 0 <= v <= 1):
err(f"ui.{k}", f"must be a number between 0 and 1, got {v!r}",
"use e.g. 0.95 (no quotes)")
for k in UI_BOOLS:
v = ui.get(k)
if v is not None and not isinstance(v, bool):
err(f"ui.{k}", f"must be true or false, got {v!r}",
"use true or false (no quotes)")
else:
err("ui", "must be an object", "re-apply a preset to restore the UI block")
border = state.get("border")
if border is not None:
if not isinstance(border, dict):
err("border", "must be an object", 're-add: {"active": "accent", "inactive": "overlay"}')
else:
for k in ("active", "inactive"):
v = border.get(k)
if v is not None and not (isinstance(v, str)
and (v in COLOR_ROLES or _is_hex(v))):
err(f"border.{k}",
f'must be a palette role ({", ".join(COLOR_ROLES)}) or "#RRGGBB", got {v!r}',
'use e.g. "accent" or "#7aa2f7"')
settings = state.get("settings")
if settings is not None and not isinstance(settings, dict):
err("settings", "must be an object (the menu writes feature flags here)",
're-add: "settings": {}')
slug = state.get("slug")
if isinstance(slug, str) and slug and find_preset(slug) is None:
warnings.append(f"slug '{slug}' matches no shipped/custom preset — "
f"fine for a hand-rolled theme, but per-theme assets won't resolve")
return errors, warnings
def cmd_validate(_args) -> None:
state = load_state() # dies with the friendly syntax error on bad JSON
errors, warnings = validate_state(state)
for w in warnings:
print(f"{w}")
if errors:
print(f"nomarchy-theme-sync: {STATE_FILE} has "
f"{len(errors)} problem(s):", file=sys.stderr)
for e in errors:
print(f"{e}", file=sys.stderr)
sys.exit(1)
log(f"{STATE_FILE} is valid ({len(warnings)} warning(s))")
# ─── Font verification ──────────────────────────────────────────────────── # ─── Font verification ────────────────────────────────────────────────────
def check_fonts(state: dict) -> None: def check_fonts(state: dict) -> None:
@@ -165,12 +358,28 @@ def run_switch() -> None:
# Persistent (timeout 0): stays up for the whole rebuild — replaced # Persistent (timeout 0): stays up for the whole rebuild — replaced
# in place by the success/failure notification below — so a multi- # in place by the success/failure notification below — so a multi-
# minute switch never looks like it silently failed. # minute switch never looks like it silently failed.
notify("Applying theme — rebuilding the desktop…", persistent=True) notify("Applying changes — rebuilding the desktop…", persistent=True)
result = subprocess.run(argv) # stream output to the caller's terminal result = subprocess.run(argv) # stream output to the caller's terminal
if result.returncode != 0: if result.returncode != 0:
notify("Theme rebuild FAILED — see terminal / journal", urgency="critical") notify("Rebuild FAILED — see terminal / journal", urgency="critical")
die("rebuild failed (state file already updated; fix and re-run)") die("rebuild failed (state file already updated; fix and re-run)")
notify("Theme applied ✓") notify("Changes applied ✓")
# Waybar runs from Hyprland's exec-once (not a systemd unit HM would
# restart on switch), so nudge the running bar onto the freshly rebuilt
# config/style. Under the nomarchy-waybar supervisor a plain kill IS a
# clean restart with the new files — waybar's in-place SIGUSR2 reload
# is what crashed the bar on hardware (double-reload race with
# reload_style_on_change while the symlinks flip), so prefer the
# restart whenever the supervisor is there to catch it; fall back to
# SIGUSR2 for unsupervised/custom bars. No-ops if nothing is running.
if shutil.which("pkill"):
supervised = subprocess.run(
["pgrep", "-f", "nomarchy-waybar"], capture_output=True
).returncode == 0
if supervised:
subprocess.run(["pkill", "-x", "waybar"], check=False)
else:
subprocess.run(["pkill", "--signal", "SIGUSR2", "-x", "waybar"], check=False)
# ─── Theme assets (wallpapers) ──────────────────────────────────────────── # ─── Theme assets (wallpapers) ────────────────────────────────────────────
@@ -257,10 +466,17 @@ def cmd_apply(args) -> None:
die(f"unknown theme '{args.theme}' (try `nomarchy-theme-sync list`)") die(f"unknown theme '{args.theme}' (try `nomarchy-theme-sync list`)")
preset = json.loads(preset_path.read_text()) preset = json.loads(preset_path.read_text())
# Merge over current state: presets define palette/name/wallpaper, # Merge over current state. Presets carry a full appearance block —
# user tweaks (gaps, fonts) outside the preset survive. # palette, border, fonts and ui — so a switch always replaces the last
state = deep_merge(load_state(), preset) # theme's look (a theme can ship a bespoke font/opacity/geometry, e.g.
# Boreal, without it leaking into the next). settings.* (feature state,
# keyboard/monitor memory) live outside any preset and survive. A
# per-key `set ui.<k>`/`fonts.<k>` tweak holds until the next apply.
old = load_state()
state = deep_merge(old, preset)
write_state(state) write_state(state)
if auto_commit_enabled(old) or auto_commit_enabled(state):
auto_commit(f"nomarchy: apply theme {state.get('name', args.theme)}")
log(f"theme: {state.get('name', args.theme)}") log(f"theme: {state.get('name', args.theme)}")
check_fonts(state) check_fonts(state)
if not args.no_switch: if not args.no_switch:
@@ -275,6 +491,7 @@ def cmd_set(args) -> None:
except json.JSONDecodeError: except json.JSONDecodeError:
value = args.value # bare string ("#7aa2f7", font names, paths) value = args.value # bare string ("#7aa2f7", font names, paths)
was_on = auto_commit_enabled(state)
node = state node = state
keys = args.path.split(".") keys = args.path.split(".")
for key in keys[:-1]: for key in keys[:-1]:
@@ -284,11 +501,17 @@ def cmd_set(args) -> None:
node[keys[-1]] = value node[keys[-1]] = value
write_state(state) write_state(state)
if was_on or auto_commit_enabled(state):
auto_commit(f"nomarchy: set {args.path} = {json.dumps(value)}")
log(f"set {args.path} = {value!r}") log(f"set {args.path} = {value!r}")
if args.path.startswith("fonts."): if args.path.startswith("fonts."):
check_fonts(state) check_fonts(state)
if not args.no_switch: if not args.no_switch:
run_switch() run_switch()
# Only wallpaper/theme keys change what swww shows; skip the re-apply
# (and its transition) for unrelated keys like ui.* or settings.* so a
# gaps tweak or a feature toggle doesn't flash the background.
if args.path.split(".")[0] in ("wallpaper", "slug"):
apply_wallpaper(state) apply_wallpaper(state)
@@ -301,7 +524,10 @@ def cmd_get(args) -> None:
node = node[key] node = node[key]
except (KeyError, TypeError): except (KeyError, TypeError):
die(f"no such key: {args.path}") die(f"no such key: {args.path}")
print(json.dumps(node, indent=2) if isinstance(node, (dict, list)) else node) # Booleans print JSON-style (true/false, not Python's True/False) so
# shell consumers can compare against the same literal they `set`.
print(json.dumps(node, indent=2)
if isinstance(node, (dict, list, bool)) else node)
else: else:
print(json.dumps(state, indent=2)) print(json.dumps(state, indent=2))
@@ -357,6 +583,8 @@ def main() -> None:
p.add_argument("path", nargs="?") p.add_argument("path", nargs="?")
p.set_defaults(func=cmd_get) p.set_defaults(func=cmd_get)
sub.add_parser("validate", help="check theme-state.json against the schema (read-only)").set_defaults(func=cmd_validate)
sub.add_parser("wallpaper", help="apply the current wallpaper via swww").set_defaults(func=cmd_wallpaper) sub.add_parser("wallpaper", help="apply the current wallpaper via swww").set_defaults(func=cmd_wallpaper)
p = sub.add_parser("bg", help="wallpaper control: `bg next` cycles, `bg auto` resets") p = sub.add_parser("bg", help="wallpaper control: `bg next` cycles, `bg auto` resets")

View File

@@ -16,23 +16,51 @@
# ── Opt-in features — uncomment and tweak to enable ───────────────── # ── Opt-in features — uncomment and tweak to enable ─────────────────
# nomarchy.nightlight = { # scheduled blue-light filter (hyprsunset) # nomarchy.nightlight = { # scheduled blue-light filter (hyprsunset)
# enable = true; # enable = true; # declarative opt-in (or just enable it from
# # the menu: System Night light)
# temperature = 4000; # night warmth in K — lower is warmer # temperature = 4000; # night warmth in K — lower is warmer
# sunset = "20:00"; # sunset = "20:00";
# sunrise = "07:00"; # sunrise = "07:00";
# }; # };
# #
# nomarchy.updates = { # passive update-awareness indicator + notification
# enable = true;
# interval = "daily"; # how often to check (systemd OnCalendar)
# flatpak = true; # also count Flatpak updates when flatpak is on
# };
#
# nomarchy.monitors = [ # declarative per-output layout, hotplug-applied # nomarchy.monitors = [ # declarative per-output layout, hotplug-applied
# { name = "eDP-1"; position = "0x0"; } # { name = "eDP-1"; position = "0x0"; }
# { name = "HDMI-A-1"; position = "auto-right"; } # { name = "HDMI-A-1"; position = "auto-right"; }
# ]; # names from `hyprctl devices` # ]; # names from `hyprctl devices`
# #
# nomarchy.launchOrFocus = [ # SUPER+<key> focuses the app's window,
# { key = "B"; class = "firefox"; } # or launches it if none is open;
# { key = "O"; class = "obsidian"; }# rows appear in the SUPER+? cheatsheet
# ]; # (class from `hyprctl clients`)
#
# nomarchy.displayProfiles = { # named layouts for the SAME outputs —
# docked = { # switch: System Display Profiles
# monitors = [
# { name = "eDP-1"; resolution = "disable"; }
# { name = "DP-3"; position = "0x0"; }
# { name = "DP-4"; position = "auto-right"; }
# ];
# workspaces = { "1" = "DP-3"; "9" = "DP-4"; }; # optional ws→output pins
# };
# undocked = [ { name = "eDP-1"; position = "0x0"; } ]; # bare list = monitors only
# }; # applied instantly + remembered in the
# # flake state (settings.displayProfile);
# # the menu's Auto-switch row makes dock/
# # undock pick the matching profile
#
# nomarchy.keyboard.devices = { # a specific keyboard's own layout # nomarchy.keyboard.devices = { # a specific keyboard's own layout
# "keychron-keychron-k2" = { layout = "de"; }; # "keychron-keychron-k2" = { layout = "de"; };
# }; # };
# #
# nomarchy.keyboard.layouts = [ "de" "fr" ]; # rofi-prompt for a layout when a # nomarchy.keyboard.layouts = [ "de" "fr" ]; # rofi-prompt for a layout when a new
# # new keyboard connects + remember it # # keyboard connects + remember it in the
# # flake state (graduates to .devices above)
# ── Application suite ─────────────────────────────────────────────── # ── Application suite ───────────────────────────────────────────────
# A starter complete-workstation set, installed for your user — yours to # A starter complete-workstation set, installed for your user — yours to
@@ -74,7 +102,7 @@
# Office & documents # Office & documents
# onlyoffice-desktopeditors # stronger MS-Office fidelity # onlyoffice-desktopeditors # stronger MS-Office fidelity
# kdePackages.okular # PDF viewer # kdePackages.okular # heavier PDF viewer (zathura ships by default)
# xournalpp # PDF annotation # xournalpp # PDF annotation
# pdfarranger # pdfarranger
# calibre # ebook library # calibre # ebook library
@@ -105,6 +133,11 @@
# kdePackages.kdenlive # video editor (pulls in KDE libs) # kdePackages.kdenlive # video editor (pulls in KDE libs)
# handbrake # transcoder # handbrake # transcoder
# Webcam tuning (rarely needed — the distro's IR-hide covers the
# common dual-sensor problem; these are for genuine tuning cases)
# cameractrls # GUI for exposure/focus/zoom, saves per-camera presets
# v4l-utils # v4l2-ctl CLI: list formats, poke controls directly
# Media # Media
# vlc # vlc
# spotify # (unfree) # spotify # (unfree)

View File

@@ -31,7 +31,27 @@
# thermal.enable = true; # thermald (Intel CPUs) # thermal.enable = true; # thermald (Intel CPUs)
# }; # };
# #
# nomarchy.services.tailscale.enable = true; # mesh VPN — then `sudo tailscale up` # nomarchy.system.autoTimezone.enable = true; # clock follows your location
# # (geoclue); travelling updates the
# # time on its own. Unsets time.timeZone
# # above. Easier toggled from System menu.
#
# nomarchy.hardware = { # enablement above nixos-hardware (installer-detected)
# intel.enable = true; # GuC/HuC firmware; installer sets this on Intel
# intel.computeRuntime = true; # OpenCL/oneVPL GPU compute (opt-in)
# amd.enable = true; # amd-pstate + radeonsi VA-API; installer-set on AMD
# amd.rocm.enable = true; # ROCm GPU compute (multi-GB, opt-in)
# amd.rocm.gfxOverride = "11.0.0"; # HSA override for an unlisted iGPU
# fingerprint.enable = true; # fprintd; installer-set when a reader is detected
# fingerprint.pam = true; # use the fingerprint for login + sudo
# npu.enable = true; # on-die NPU driver (experimental; userspace runtime BYO)
# latestKernel = true; # newest kernel for very-new hardware (drivers not yet in the default)
# camera.hideIrSensor = true; # dual-sensor webcam: hide the IR node so apps get the color cam
# # (installer-set when a paired RGB+IR webcam is detected)
# };
#
# nomarchy.services.tailscale.enable = true; # mesh VPN — connect from System VPN
# # (login user is operator, no sudo)
# nomarchy.services.syncthing.enable = true; # file sync — GUI at http://127.0.0.1:8384 # nomarchy.services.syncthing.enable = true; # file sync — GUI at http://127.0.0.1:8384
# nomarchy.services.podman.enable = true; # rootless containers (docker → podman) # nomarchy.services.podman.enable = true; # rootless containers (docker → podman)
# nomarchy.services.flatpak.enable = true; # Flatpak + the Flathub remote # nomarchy.services.flatpak.enable = true; # Flatpak + the Flathub remote

View File

@@ -55,5 +55,6 @@
"terminalOpacity": 0.96, "terminalOpacity": 0.96,
"blur": true, "blur": true,
"shadow": true "shadow": true
} },
"settings": {}
} }

60
themes/boreal.json Normal file
View File

@@ -0,0 +1,60 @@
{
"version": 1,
"name": "Boreal",
"slug": "boreal",
"mode": "dark",
"wallpaper": "",
"colors": {
"base": "#21272F",
"mantle": "#1A1F26",
"surface": "#303A46",
"overlay": "#404D5C",
"text": "#D3DAE0",
"subtext": "#97A3B2",
"muted": "#5E6A78",
"accent": "#B79BE8",
"accentAlt": "#86C7C0",
"good": "#A3CF88",
"warn": "#E6C384",
"bad": "#E2818A"
},
"border": {
"active": "accent",
"inactive": "overlay"
},
"fonts": {
"mono": "GeistMono Nerd Font",
"ui": "Inter",
"size": 11
},
"ui": {
"gapsIn": 6,
"gapsOut": 14,
"borderSize": 3,
"rounding": 12,
"iconSize": 44,
"activeOpacity": 1.0,
"inactiveOpacity": 0.93,
"terminalOpacity": 0.90,
"blur": true,
"shadow": true
},
"ansi": [
"#2E3742",
"#E2818A",
"#A3CF88",
"#E6C384",
"#86A9E0",
"#B79BE8",
"#86C7C0",
"#D3DAE0",
"#5E6A78",
"#EC98A0",
"#B4DB9C",
"#EFCF98",
"#9DBBEC",
"#C6AEEF",
"#99D6CF",
"#E8EDF2"
]
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.3 MiB

View File

@@ -0,0 +1,42 @@
/**
* Boreal — app-launcher override (themes/<slug>/launcher.rasi).
* Installed to ~/.local/share/rofi/themes/launcher.rasi and used only by
* `rofi -show drun` (the SUPER+Space / SUPER+D launcher). It inherits the
* frosted aurora panel from the sibling rofi.rasi (this whole-swap theme,
* which HM deploys under its source basename — rofi.rasi — not custom.rasi),
* then reshapes the list into a 4×3 grid of large application icons.
* Text menus keep the list — they don't use this theme.
*/
@import "rofi.rasi"
window { width: 780px; }
listview {
columns: 4;
lines: 3;
fixed-columns: true;
spacing: 10px;
padding: 6px 0px 0px 0px;
/* Fill row-by-row so Down at a column's foot pages to the next screen
instead of jumping to the top of the next column (rofi's default
Vertical flow). Mirrors the theme-picker grid in rofi.nix. */
flow: horizontal;
}
element {
orientation: vertical;
padding: 16px 8px;
spacing: 10px;
border-radius: 12px;
}
element-icon {
size: 56px;
horizontal-align: 0.5;
}
element-text {
horizontal-align: 0.5;
vertical-align: 0.5;
}

116
themes/boreal/rofi.rasi Normal file
View File

@@ -0,0 +1,116 @@
/**
* Boreal — rofi whole-swap (themes/<slug>/rofi.rasi).
* The base theme: a frosted aurora panel used by every menu (and, as a
* list, the fallback launcher). The app launcher reshapes this into a
* grid via themes/boreal/launcher.rasi, which @imports this file. The
* `configuration {}` block is omitted on purpose — it comes from
* modules/home/rofi.nix. Element structure mirrors the generated theme
* so the theme-grid picker's per-invocation -theme-str still lays out.
*/
* {
base: #21272F;
glassBg: #21272FE6;
surface: #303A46;
overlay: #404D5C;
text: #D3DAE0;
subtext: #97A3B2;
muted: #5E6A78;
accent: #B79BE8;
accentAlt: #86C7C0;
accentSoft: #B79BE83B;
frostEdge: #86C7C05C;
font: "GeistMono Nerd Font 11";
background-color: transparent;
text-color: @text;
}
window {
background-color: @glassBg;
border: 1px;
border-color: @frostEdge;
border-radius: 14px;
width: 620px;
location: center;
anchor: center;
padding: 16px;
}
mainbox {
background-color: transparent;
children: [ inputbar, message, listview ];
spacing: 12px;
}
inputbar {
children: [ prompt, entry ];
background-color: @surface;
text-color: @text;
border-radius: 10px;
padding: 12px 16px;
spacing: 10px;
}
prompt { text-color: @accent; }
entry {
text-color: @text;
cursor: text;
placeholder: "Search…";
placeholder-color: @muted;
}
listview {
background-color: transparent;
columns: 1;
lines: 8;
dynamic: true;
scrollbar: false;
spacing: 6px;
padding: 4px 0px 0px 0px;
}
element {
background-color: transparent;
text-color: @text;
orientation: horizontal;
border-radius: 10px;
padding: 10px 14px;
spacing: 12px;
}
element normal.normal,
element alternate.normal {
background-color: transparent;
}
/* Selected row lifts on a soft aurora-violet wash. */
element selected.normal {
background-color: @accentSoft;
text-color: @text;
}
element-icon {
background-color: transparent;
size: 30px;
cursor: inherit;
}
element-text {
background-color: transparent;
text-color: inherit;
highlight: bold;
vertical-align: 0.5;
}
message { padding: 0px; }
textbox {
background-color: @surface;
text-color: @text;
border-radius: 10px;
padding: 10px 14px;
}
scrollbar { width: 0px; }

166
themes/boreal/waybar.css Normal file
View File

@@ -0,0 +1,166 @@
/*
* Boreal — Waybar whole-swap (themes/<slug>/waybar.css).
*
* A theme waybar.css replaces the ENTIRE generated stylesheet and is read
* raw (no palette is prepended), so this file MUST define its own colors.
* Identity: a floating "aurora frost" bar — three frosted-glass pills
* (launcher+clock · workspaces · status) over the wallpaper's blur. The
* whole right side is ONE continuous pill: the system tray shares its
* glass with the status modules, no separators, no boxes. Companion:
* waybar.jsonc.
*/
@define-color base #21272F;
@define-color mantle #1A1F26;
@define-color surface #303A46;
@define-color overlay #404D5C;
@define-color text #D3DAE0;
@define-color subtext #97A3B2;
@define-color muted #5E6A78;
@define-color accent #B79BE8;
@define-color accentAlt #86C7C0;
@define-color good #A3CF88;
@define-color warn #E6C384;
@define-color bad #E2818A;
* {
font-family: "GeistMono Nerd Font", "Symbols Nerd Font";
font-size: 13px;
font-weight: 500;
border: none;
border-radius: 0;
min-height: 0;
}
/* The bar itself is invisible — the pills float over the wallpaper. */
window#waybar {
background: transparent;
color: @text;
}
/* ── The three frosted-glass pills ────────────────────────────────────
Global blur turns the translucent @base into frosted glass; a faint
frost-cyan edge lifts each pill off the wallpaper. */
.modules-left,
.modules-center,
.modules-right {
background: alpha(@base, 0.72);
border: 1px solid alpha(@accentAlt, 0.14);
border-radius: 14px;
padding: 0 6px;
margin: 0;
}
/* Every module sits ON its pill: transparent, so nothing boxes the tray
off from the status icons — the seamless-cluster requirement. */
#custom-nomarchy,
#clock,
#custom-recording,
#custom-updates,
#custom-doctor,
#idle_inhibitor,
#custom-nightlight,
#language,
#custom-vpn,
#pulseaudio,
#battery,
#custom-powerprofile,
#tray,
#custom-notification,
#custom-powermenu {
background: transparent;
color: alpha(@text, 0.9);
padding: 0 8px;
margin: 0;
}
/* ── Launcher — the aurora-violet Nomarchy mark opens the pill ────────
The mark lives at U+F000 in the dedicated "Nomarchy" font; pin the
family or the Nerd Font's glass glyph at the same codepoint wins. */
#custom-nomarchy {
color: @accent;
font-family: Nomarchy;
font-size: 18px;
padding: 0 12px 0 14px;
}
#custom-nomarchy:hover { color: @accentAlt; }
/* ── Clock — mono digits align; date recedes to frost-grey ───────────*/
#clock {
color: @text;
font-weight: 600;
padding-left: 4px;
}
#clock.date { color: @subtext; font-weight: 500; }
/* ── Workspaces — dots that bloom to an aurora pill when active ───────*/
#workspaces { padding: 0 2px; }
#workspaces button {
color: @muted;
padding: 0 9px;
margin: 3px 1px;
border-radius: 9px;
transition: color 0.2s ease, background 0.2s ease;
}
#workspaces button:hover {
color: @text;
background: alpha(@overlay, 0.5);
}
#workspaces button.active {
color: @base;
background: @accent;
}
#workspaces button.urgent {
color: @base;
background: @bad;
}
/* ── Status semantics (neutral until a state fires) ──────────────────*/
#custom-recording.recording { color: @bad; }
#custom-updates.available { color: @accent; }
#custom-doctor { color: @bad; }
#idle_inhibitor.activated { color: @warn; }
#custom-nightlight.on { color: @warn; }
#custom-vpn.on { color: @good; }
#pulseaudio.muted { color: @muted; }
#battery.charging { color: @good; }
#battery.warning:not(.charging) { color: @warn; }
#battery.critical:not(.charging) { color: @bad; }
/* Icon-only status modules carry no text, so they don't get the 13pt
Pango icon span the icon+text modules (volume/battery/language) use —
bump their font-size to match, or these glyphs read noticeably smaller
than their neighbours. */
#custom-recording,
#custom-updates,
#custom-doctor,
#custom-nightlight,
#custom-vpn,
#custom-notification { font-size: 17px; }
/* Speedometer + caffeine cup render small in their em box — a touch more. */
#custom-powerprofile,
#idle_inhibitor { font-size: 18px; }
#custom-notification.notification { color: @accent; }
#custom-notification.dnd-none,
#custom-notification.dnd-notification { color: @muted; }
/* Tray shares the pill's glass — same transparent background, its own
modest padding so nm-applet & friends breathe with the status icons. */
#tray { padding: 0 6px; }
#tray > .passive { -gtk-icon-effect: dim; }
#tray > .needs-attention { -gtk-icon-effect: highlight; }
/* Power button closes the pill on the right; warms to alert on hover. */
#custom-powermenu { color: @subtext; padding: 0 12px 0 8px; }
#custom-powermenu:hover { color: @bad; }
/* ── Tooltips — frosted, frost-cyan edge ─────────────────────────────*/
tooltip {
background: alpha(@base, 0.96);
border: 1px solid alpha(@accentAlt, 0.3);
border-radius: 10px;
}
tooltip label { color: @text; }

175
themes/boreal/waybar.jsonc Normal file
View File

@@ -0,0 +1,175 @@
{
"margin-top": 8,
"margin-left": 14,
"margin-right": 14,
"margin-bottom": 0,
"height": 38,
"layer": "top",
"position": "top",
"spacing": 4,
"reload_style_on_change": true,
"modules-left": [
"custom/nomarchy",
"clock",
"clock#date"
],
"modules-center": [
"hyprland/workspaces"
],
"modules-right": [
"custom/recording",
"custom/updates",
"custom/doctor",
"idle_inhibitor",
"custom/nightlight",
"hyprland/language",
"custom/vpn",
"pulseaudio",
"battery",
"custom/powerprofile",
"tray",
"custom/notification",
"custom/powermenu"
],
"custom/nomarchy": {
"interval": "once",
"format": "",
"on-click": "nomarchy-menu",
"tooltip-format": "Nomarchy menu"
},
"clock": {
"format": "{:%H:%M}",
"tooltip": true,
"tooltip-format": "{:%Z (UTC%z)}\n<tt><small>{calendar}</small></tt>"
},
"clock#date": {
"format": "{:%a %d %b}",
"tooltip": false
},
"hyprland/workspaces": {
"disable-scroll": true,
"all-outputs": true,
"on-click": "activate",
"on-scroll-up": "hyprctl dispatch workspace r+1",
"on-scroll-down": "hyprctl dispatch workspace r-1"
},
"custom/recording": {
"return-type": "json",
"interval": 10,
"signal": 8,
"exec": "nomarchy-record status",
"on-click": "nomarchy-record stop"
},
"custom/updates": {
"return-type": "json",
"interval": 1800,
"signal": 9,
"exec": "nomarchy-updates status",
"on-click": "sh -c '$TERMINAL -e nomarchy-updates upgrade'"
},
"custom/doctor": {
"return-type": "json",
"interval": 300,
"exec": "nomarchy-doctor-status",
"on-click": "nomarchy-menu doctor"
},
"idle_inhibitor": {
"format": "{icon}",
"format-icons": {
"activated": " 󰅶 ",
"deactivated": " 󰾪 "
}
},
"custom/nightlight": {
"return-type": "json",
"interval": 3,
"exec": "nomarchy-nightlight status",
"on-click": "nomarchy-nightlight toggle"
},
"hyprland/language": {
"format": "<span size='13pt'>󰌌</span> {short}",
"tooltip": false
},
"custom/vpn": {
"return-type": "json",
"interval": 5,
"exec": "nomarchy-vpn-status",
"on-click": "nomarchy-vpn"
},
"pulseaudio": {
"scroll-step": 5,
"format": "<span size='13pt'>{icon}</span> {volume}%",
"format-bluetooth": "<span size='13pt'>{icon}</span> {volume}%",
"format-bluetooth-muted": " {icon}",
"format-muted": "<span size='13pt'></span> muted",
"format-icons": {
"headphone": "",
"hands-free": "",
"headset": "",
"phone": "",
"portable": "",
"car": "",
"default": [
"",
"",
""
]
},
"on-click": "wpctl set-mute @DEFAULT_AUDIO_SINK@ toggle",
"tooltip-format": "{desc} | {volume}%"
},
"battery": {
"interval": 30,
"states": {
"warning": 25,
"critical": 10
},
"format": "<span size='13pt'>{icon}</span> {capacity}%",
"format-charging": "<span size='13pt'></span> {capacity}%",
"format-plugged": "<span size='13pt'></span> {capacity}%",
"format-icons": [
"",
"",
"",
"",
""
],
"on-click": "nomarchy-menu power",
"tooltip-format": "Battery status"
},
"custom/powerprofile": {
"return-type": "json",
"interval": 5,
"exec": "nomarchy-powerprofile-status",
"on-click": "nomarchy-powerprofile-cycle"
},
"tray": {
"icon-size": 15,
"spacing": 10
},
"custom/notification": {
"format": "{icon}",
"return-type": "json",
"exec": "swaync-client -swb",
"exec-if": "which swaync-client",
"escape": true,
"tooltip": true,
"on-click": "swaync-client -t -sw",
"on-click-right": "swaync-client -d -sw",
"format-icons": {
"none": "󰂜",
"notification": "󰂚",
"dnd-none": "󰂛",
"dnd-notification": "󰂛",
"inhibited-none": "󰂜",
"inhibited-notification": "󰂚",
"dnd-inhibited-none": "󰂛",
"dnd-inhibited-notification": "󰂛"
}
},
"custom/powermenu": {
"format": "",
"on-click": "nomarchy-menu power",
"tooltip": false
}
}

View File

@@ -7,21 +7,38 @@
"colors": { "colors": {
"base": "#eff1f5", "base": "#eff1f5",
"mantle": "#cbcdd0", "mantle": "#cbcdd0",
"surface": "#bcc0cc", "surface": "#bfc4d0",
"overlay": "#acb0be", "overlay": "#acb0be",
"text": "#4c4f69", "text": "#4c4f69",
"subtext": "#5c5f77", "subtext": "#5c5f77",
"muted": "#acb0be", "muted": "#8c8fa1",
"accent": "#1e66f5", "accent": "#1e66f5",
"accentAlt": "#ea76cb", "accentAlt": "#d260b5",
"good": "#40a02b", "good": "#40a02b",
"warn": "#df8e1d", "warn": "#d6860a",
"bad": "#d20f39" "bad": "#d20f39"
}, },
"border": { "border": {
"active": "accent", "active": "accent",
"inactive": "overlay" "inactive": "overlay"
}, },
"fonts": {
"mono": "JetBrainsMono Nerd Font",
"ui": "Inter",
"size": 11
},
"ui": {
"gapsIn": 5,
"gapsOut": 12,
"borderSize": 2,
"rounding": 10,
"iconSize": 36,
"activeOpacity": 1.0,
"inactiveOpacity": 0.95,
"terminalOpacity": 0.96,
"blur": true,
"shadow": true
},
"ansi": [ "ansi": [
"#bcc0cc", "#bcc0cc",
"#d20f39", "#d20f39",

Binary file not shown.

After

Width:  |  Height:  |  Size: 107 KiB

View File

@@ -22,6 +22,23 @@
"active": "accent", "active": "accent",
"inactive": "overlay" "inactive": "overlay"
}, },
"fonts": {
"mono": "JetBrainsMono Nerd Font",
"ui": "Inter",
"size": 11
},
"ui": {
"gapsIn": 5,
"gapsOut": 12,
"borderSize": 2,
"rounding": 10,
"iconSize": 36,
"activeOpacity": 1.0,
"inactiveOpacity": 0.95,
"terminalOpacity": 0.96,
"blur": true,
"shadow": true
},
"ansi": [ "ansi": [
"#45475a", "#45475a",
"#f38ba8", "#f38ba8",

Binary file not shown.

After

Width:  |  Height:  |  Size: 39 KiB

View File

@@ -1,2 +0,0 @@
@define-color foreground #cdd6f4;
@define-color background #181824;

View File

@@ -22,6 +22,23 @@
"active": "accent", "active": "accent",
"inactive": "overlay" "inactive": "overlay"
}, },
"fonts": {
"mono": "JetBrainsMono Nerd Font",
"ui": "Inter",
"size": 11
},
"ui": {
"gapsIn": 5,
"gapsOut": 12,
"borderSize": 2,
"rounding": 10,
"iconSize": 36,
"activeOpacity": 1.0,
"inactiveOpacity": 0.95,
"terminalOpacity": 0.96,
"blur": true,
"shadow": true
},
"ansi": [ "ansi": [
"#060B1E", "#060B1E",
"#ED5B5A", "#ED5B5A",

BIN
themes/ethereal/preview.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 152 KiB

View File

@@ -11,7 +11,7 @@
"overlay": "#475258", "overlay": "#475258",
"text": "#d3c6aa", "text": "#d3c6aa",
"subtext": "#d3c6aa", "subtext": "#d3c6aa",
"muted": "#475258", "muted": "#59646a",
"accent": "#7fbbb3", "accent": "#7fbbb3",
"accentAlt": "#d699b6", "accentAlt": "#d699b6",
"good": "#a7c080", "good": "#a7c080",
@@ -22,6 +22,23 @@
"active": "accent", "active": "accent",
"inactive": "overlay" "inactive": "overlay"
}, },
"fonts": {
"mono": "JetBrainsMono Nerd Font",
"ui": "Inter",
"size": 11
},
"ui": {
"gapsIn": 5,
"gapsOut": 12,
"borderSize": 2,
"rounding": 10,
"iconSize": 36,
"activeOpacity": 1.0,
"inactiveOpacity": 0.95,
"terminalOpacity": 0.96,
"blur": true,
"shadow": true
},
"ansi": [ "ansi": [
"#475258", "#475258",
"#e67e80", "#e67e80",

Binary file not shown.

After

Width:  |  Height:  |  Size: 92 KiB

View File

@@ -0,0 +1,60 @@
{
"version": 1,
"name": "Executive Slate",
"slug": "executive-slate",
"mode": "dark",
"wallpaper": "",
"colors": {
"base": "#191C24",
"mantle": "#111319",
"surface": "#2A3040",
"overlay": "#3E4759",
"text": "#D7DDEA",
"subtext": "#9AA5BB",
"muted": "#5C6578",
"accent": "#5A8AE6",
"accentAlt": "#86A9F2",
"good": "#6FAE72",
"warn": "#DBA85A",
"bad": "#D06B71"
},
"border": {
"active": "accent",
"inactive": "overlay"
},
"fonts": {
"mono": "JetBrainsMono Nerd Font",
"ui": "Inter",
"size": 11
},
"ui": {
"gapsIn": 5,
"gapsOut": 12,
"borderSize": 2,
"rounding": 10,
"iconSize": 36,
"activeOpacity": 1.0,
"inactiveOpacity": 0.95,
"terminalOpacity": 0.96,
"blur": true,
"shadow": true
},
"ansi": [
"#2A3040",
"#D06B71",
"#6FAE72",
"#DBA85A",
"#5A8AE6",
"#8C79D6",
"#55A5AE",
"#D7DDEA",
"#5C6578",
"#DE7C82",
"#82BE85",
"#E7BB6E",
"#86A9F2",
"#A594E8",
"#6FBEC6",
"#F0F3F9"
]
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 661 KiB

View File

@@ -0,0 +1,119 @@
/**
* Executive Slate — rofi whole-swap (themes/<slug>/rofi.rasi).
* Identity: a flat, sharp-cornered "control panel". No zebra, no glow —
* a solid slate window, a recessed surface inputbar, and one accent edge
* marking the selected row. The `configuration {}` block is omitted on
* purpose — modi/terminal/show-icons come from modules/home/rofi.nix.
* Element structure mirrors the generated theme so the theme-grid
* picker's per-invocation -theme-str (vertical cards) still lays out.
*/
* {
base: #191C24;
surface: #2A3040;
overlay: #3E4759;
text: #D7DDEA;
subtext: #9AA5BB;
muted: #5C6578;
accent: #5A8AE6;
accentAlt: #86A9F2;
font: "Inter 13";
background-color: transparent;
text-color: @text;
}
window {
background-color: @base;
border: 1px;
border-color: @overlay;
border-radius: 4px;
width: 40%;
location: center;
anchor: center;
padding: 12px;
}
mainbox {
background-color: transparent;
children: [ inputbar, message, listview ];
spacing: 10px;
}
inputbar {
children: [ prompt, entry ];
background-color: @surface;
text-color: @text;
border: 1px;
border-color: @overlay;
border-radius: 4px;
padding: 10px 14px;
spacing: 10px;
}
prompt { text-color: @accent; }
entry {
text-color: @text;
cursor: text;
placeholder: "Type to filter…";
placeholder-color: @muted;
}
listview {
background-color: transparent;
columns: 1;
lines: 8;
dynamic: true;
scrollbar: false;
spacing: 4px;
padding: 4px 0px 0px 0px;
}
element {
background-color: transparent;
text-color: @text;
orientation: horizontal;
border-radius: 4px;
padding: 9px 14px;
spacing: 12px;
}
element normal.normal,
element alternate.normal {
background-color: transparent;
}
/* The one highlight: a solid surface row with an accent edge. */
element selected.normal {
background-color: @surface;
text-color: @text;
border: 0px 0px 0px 3px;
border-color: @accent;
}
element-icon {
background-color: transparent;
size: 36px;
cursor: inherit;
}
element-text {
background-color: transparent;
text-color: inherit;
highlight: bold;
vertical-align: 0.5;
}
message { padding: 0px; }
textbox {
background-color: @surface;
text-color: @text;
border: 1px;
border-color: @overlay;
border-radius: 4px;
padding: 9px 14px;
}
scrollbar { width: 0px; }

View File

@@ -0,0 +1,180 @@
/*
* Executive Slate — Waybar whole-swap (themes/<slug>/waybar.css).
*
* A theme waybar.css replaces the ENTIRE generated stylesheet and is read
* raw (no palette is prepended), so this file MUST define its own colors.
* Identity: a solid, non-transparent, edge-to-edge status strip — no
* floating island, sharp corners, tight modules, and a single accent
* underline marking the active workspace. Companion: waybar.jsonc.
*/
@define-color base #191C24;
@define-color mantle #111319;
@define-color surface #2A3040;
@define-color overlay #3E4759;
@define-color text #D7DDEA;
@define-color subtext #9AA5BB;
@define-color muted #5C6578;
@define-color accent #5A8AE6;
@define-color accentAlt #86A9F2;
@define-color good #6FAE72;
@define-color warn #DBA85A;
@define-color bad #D06B71;
* {
font-family: "Inter", "Symbols Nerd Font", "JetBrainsMono Nerd Font", sans-serif;
font-size: 13px;
font-weight: 500;
border: none;
border-radius: 0;
min-height: 0;
}
/* Solid bar, crisp edge against the content below. */
window#waybar {
background: @base;
border-bottom: 1px solid @overlay;
color: @text;
}
.modules-left,
.modules-center,
.modules-right {
margin: 0;
padding: 0;
}
/* Uniform module rhythm; groups get a wider breath below. */
#custom-nomarchy,
#clock,
#custom-recording,
#idle_inhibitor,
#custom-nightlight,
#language,
#custom-updates,
#pulseaudio,
#custom-powerprofile,
#battery,
#custom-vpn,
#custom-doctor,
#custom-notification,
#tray,
#custom-powermenu {
color: @text;
padding: 0 11px;
margin: 0;
}
/* Group heads — a wider gap before each functional cluster of the
right side (media/toggles · stats · status). Self-hiding heads
(e.g. no battery on a desktop) degrade to the uniform spacing. */
#pulseaudio,
#battery,
#custom-vpn { margin-left: 6px; }
/* ── Menu launcher — the branded Nomarchy mark ───────────────────────
The mark lives at U+F000 in the dedicated "Nomarchy" font; pin the
family or the Nerd Font's glass glyph at the same codepoint wins. */
#custom-nomarchy {
color: @accent;
font-family: Nomarchy;
font-size: 16px;
padding: 0 14px;
}
#custom-nomarchy:hover {
background: @surface;
color: @accentAlt;
}
/* ── Workspaces — flat cells, active carries an accent underline ──── */
#workspaces { padding: 0; margin: 0; }
#workspaces button {
color: @subtext;
padding: 0 11px;
margin: 0;
border-bottom: 2px solid transparent;
transition: color 0.15s ease-in-out, background 0.15s ease-in-out;
}
#workspaces button:hover {
background: @surface;
color: @text;
border-bottom: 2px solid @overlay;
}
#workspaces button.active {
color: @text;
background: @surface;
border-bottom: 2px solid @accent;
}
#workspaces button.urgent {
color: @bad;
border-bottom: 2px solid @bad;
}
/* ── Clock — time (center) and date (left) share the #clock id ───── */
#clock {
color: @text;
font-weight: 600;
}
/* ── Status cluster: neutral by default, semantic on state ───────── */
#custom-recording.recording { color: @bad; }
#idle_inhibitor.activated { color: @warn; }
#custom-nightlight.on { color: @warn; }
#custom-updates.available { color: @accent; }
/* Icon-only status modules carry no text, so they don't get the 13pt
Pango icon span the icon+text modules (volume/battery/language) use —
bump their font-size to match, or these glyphs read smaller than their
neighbours. */
#custom-recording,
#custom-nightlight,
#custom-updates,
#custom-vpn,
#custom-doctor,
#custom-notification { font-size: 17px; }
/* Speedometer + caffeine cup render small in their em box — a touch more. */
#custom-powerprofile,
#idle_inhibitor { font-size: 18px; }
#pulseaudio.muted { color: @muted; }
#battery.charging { color: @good; }
#battery.warning:not(.charging) { color: @warn; }
#battery.critical:not(.charging) {
color: @bad;
border-bottom: 2px solid @bad;
}
#custom-vpn.on { color: @good; }
/* Doctor is only rendered when something is ✖, so always the alert. */
#custom-doctor { color: @bad; }
#custom-notification.notification { color: @accent; }
#custom-notification.dnd-none,
#custom-notification.dnd-notification { color: @muted; }
#tray { color: @text; }
#custom-powermenu { color: @subtext; }
#custom-powermenu:hover {
background: @surface;
color: @bad;
}
/* ── Tooltips ─────────────────────────────────────────────────────── */
tooltip {
background: @surface;
border: 1px solid @overlay;
border-radius: 4px;
}
tooltip label {
color: @text;
}

View File

@@ -0,0 +1,174 @@
{
"margin-top": 0,
"margin-left": 0,
"margin-bottom": 0,
"margin-right": 0,
"height": 34,
"layer": "top",
"position": "top",
"spacing": 0,
"reload_style_on_change": true,
"modules-left": [
"custom/nomarchy",
"hyprland/workspaces",
"clock#date"
],
"modules-center": [
"clock"
],
"modules-right": [
"custom/recording",
"idle_inhibitor",
"custom/nightlight",
"hyprland/language",
"custom/updates",
"pulseaudio",
"custom/powerprofile",
"battery",
"custom/vpn",
"custom/doctor",
"custom/notification",
"tray",
"custom/powermenu"
],
"custom/nomarchy": {
"interval": "once",
"format": "",
"on-click": "nomarchy-menu",
"tooltip-format": "Nomarchy menu"
},
"hyprland/workspaces": {
"disable-scroll": true,
"all-outputs": true,
"on-click": "activate",
"on-scroll-up": "hyprctl dispatch workspace r+1",
"on-scroll-down": "hyprctl dispatch workspace r-1"
},
"clock": {
"format": "{:%H:%M}",
"tooltip": true,
"tooltip-format": "{:%Z (UTC%z)}\n<tt><small>{calendar}</small></tt>"
},
"clock#date": {
"format": "{:%a %d %b}",
"tooltip": false
},
"custom/recording": {
"return-type": "json",
"interval": 10,
"signal": 8,
"exec": "nomarchy-record status",
"on-click": "nomarchy-record stop"
},
"idle_inhibitor": {
"format": "{icon}",
"format-icons": {
"activated": " 󰅶 ",
"deactivated": " 󰾪 "
}
},
"custom/nightlight": {
"return-type": "json",
"interval": 3,
"exec": "nomarchy-nightlight status",
"on-click": "nomarchy-nightlight toggle"
},
"hyprland/language": {
"format": "<span size='13pt'>󰌌</span> {short}",
"tooltip": false
},
"custom/updates": {
"return-type": "json",
"interval": 1800,
"signal": 9,
"exec": "nomarchy-updates status",
"on-click": "sh -c '$TERMINAL -e nomarchy-updates upgrade'"
},
"pulseaudio": {
"scroll-step": 5,
"format": "<span size='13pt'>{icon}</span> {volume}%",
"format-bluetooth": "<span size='13pt'>{icon}</span> {volume}%",
"format-bluetooth-muted": " {icon}",
"format-muted": "<span size='13pt'></span> muted",
"format-icons": {
"headphone": "",
"hands-free": "",
"headset": "",
"phone": "",
"portable": "",
"car": "",
"default": [
"",
"",
""
]
},
"on-click": "wpctl set-mute @DEFAULT_AUDIO_SINK@ toggle",
"tooltip-format": "{desc} | {volume}%"
},
"custom/powerprofile": {
"return-type": "json",
"interval": 5,
"exec": "nomarchy-powerprofile-status",
"on-click": "nomarchy-powerprofile-cycle"
},
"battery": {
"interval": 30,
"states": {
"warning": 25,
"critical": 10
},
"format": "<span size='13pt'>{icon}</span> {capacity}%",
"format-charging": "<span size='13pt'></span> {capacity}%",
"format-plugged": "<span size='13pt'></span> {capacity}%",
"format-icons": [
"",
"",
"",
"",
""
],
"on-click": "nomarchy-menu power",
"tooltip-format": "Battery status"
},
"custom/vpn": {
"return-type": "json",
"interval": 5,
"exec": "nomarchy-vpn-status",
"on-click": "nomarchy-vpn"
},
"custom/doctor": {
"return-type": "json",
"interval": 300,
"exec": "nomarchy-doctor-status",
"on-click": "nomarchy-menu doctor"
},
"custom/notification": {
"format": "{icon}",
"return-type": "json",
"exec": "swaync-client -swb",
"exec-if": "which swaync-client",
"escape": true,
"tooltip": true,
"on-click": "swaync-client -t -sw",
"on-click-right": "swaync-client -d -sw",
"format-icons": {
"none": "󰂜",
"notification": "󰂚",
"dnd-none": "󰂛",
"dnd-notification": "󰂛",
"inhibited-none": "󰂜",
"inhibited-notification": "󰂚",
"dnd-inhibited-none": "󰂛",
"dnd-inhibited-notification": "󰂛"
}
},
"tray": {
"spacing": 8
},
"custom/powermenu": {
"format": "",
"on-click": "nomarchy-menu power",
"tooltip": false
}
}

View File

@@ -7,21 +7,38 @@
"colors": { "colors": {
"base": "#FFFCF0", "base": "#FFFCF0",
"mantle": "#d9d6cc", "mantle": "#d9d6cc",
"surface": "#100F0F", "surface": "#E6E4D9",
"overlay": "#100F0F", "overlay": "#B7B5AC",
"text": "#100F0F", "text": "#100F0F",
"subtext": "#FFFCF0", "subtext": "#6F6E69",
"muted": "#100F0F", "muted": "#878580",
"accent": "#205EA6", "accent": "#205EA6",
"accentAlt": "#CE5D97", "accentAlt": "#A02F6F",
"good": "#879A39", "good": "#66800B",
"warn": "#D0A215", "warn": "#AD8301",
"bad": "#D14D41" "bad": "#AF3029"
}, },
"border": { "border": {
"active": "accent", "active": "accent",
"inactive": "overlay" "inactive": "overlay"
}, },
"fonts": {
"mono": "JetBrainsMono Nerd Font",
"ui": "Inter",
"size": 11
},
"ui": {
"gapsIn": 5,
"gapsOut": 12,
"borderSize": 2,
"rounding": 10,
"iconSize": 36,
"activeOpacity": 1.0,
"inactiveOpacity": 0.95,
"terminalOpacity": 0.96,
"blur": true,
"shadow": true
},
"ansi": [ "ansi": [
"#100F0F", "#100F0F",
"#D14D41", "#D14D41",

Binary file not shown.

After

Width:  |  Height:  |  Size: 31 KiB

View File

@@ -11,7 +11,7 @@
"overlay": "#3c3836", "overlay": "#3c3836",
"text": "#d4be98", "text": "#d4be98",
"subtext": "#d4be98", "subtext": "#d4be98",
"muted": "#3c3836", "muted": "#7c6f64",
"accent": "#7daea3", "accent": "#7daea3",
"accentAlt": "#d3869b", "accentAlt": "#d3869b",
"good": "#a9b665", "good": "#a9b665",
@@ -22,6 +22,23 @@
"active": "accent", "active": "accent",
"inactive": "overlay" "inactive": "overlay"
}, },
"fonts": {
"mono": "JetBrainsMono Nerd Font",
"ui": "Inter",
"size": 11
},
"ui": {
"gapsIn": 5,
"gapsOut": 12,
"borderSize": 2,
"rounding": 10,
"iconSize": 36,
"activeOpacity": 1.0,
"inactiveOpacity": 0.95,
"terminalOpacity": 0.96,
"blur": true,
"shadow": true
},
"ansi": [ "ansi": [
"#3c3836", "#3c3836",
"#ea6962", "#ea6962",

BIN
themes/gruvbox/preview.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 221 KiB

View File

@@ -22,6 +22,23 @@
"active": "accent", "active": "accent",
"inactive": "overlay" "inactive": "overlay"
}, },
"fonts": {
"mono": "JetBrainsMono Nerd Font",
"ui": "Inter",
"size": 11
},
"ui": {
"gapsIn": 5,
"gapsOut": 12,
"borderSize": 2,
"rounding": 10,
"iconSize": 36,
"activeOpacity": 1.0,
"inactiveOpacity": 0.95,
"terminalOpacity": 0.96,
"blur": true,
"shadow": true
},
"ansi": [ "ansi": [
"#0B0C16", "#0B0C16",
"#50f872", "#50f872",

Binary file not shown.

After

Width:  |  Height:  |  Size: 112 KiB

View File

@@ -7,7 +7,7 @@
"colors": { "colors": {
"base": "#1f1f28", "base": "#1f1f28",
"mantle": "#1a1a22", "mantle": "#1a1a22",
"surface": "#090618", "surface": "#2a2a37",
"overlay": "#727169", "overlay": "#727169",
"text": "#dcd7ba", "text": "#dcd7ba",
"subtext": "#c8c093", "subtext": "#c8c093",
@@ -22,6 +22,23 @@
"active": "text", "active": "text",
"inactive": "overlay" "inactive": "overlay"
}, },
"fonts": {
"mono": "JetBrainsMono Nerd Font",
"ui": "Inter",
"size": 11
},
"ui": {
"gapsIn": 5,
"gapsOut": 12,
"borderSize": 2,
"rounding": 10,
"iconSize": 36,
"activeOpacity": 1.0,
"inactiveOpacity": 0.95,
"terminalOpacity": 0.96,
"blur": true,
"shadow": true
},
"ansi": [ "ansi": [
"#090618", "#090618",
"#c34043", "#c34043",

BIN
themes/kanagawa/preview.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 229 KiB

107
themes/kanagawa/rofi.rasi Normal file
View File

@@ -0,0 +1,107 @@
/**
* Nomarchy Kanagawa — rofi whole-swap (themes/<slug>/rofi.rasi).
* Ink-and-paper identity: a sumi-ink canvas inside a warm washi-paper frame
* (the same text-toned border the theme uses on windows — the only warm
* frame of the four), the inputbar a deeper ink well, and the crystal-blue
* wave reserved for the selected row. Soft rounding, an unhurried feel.
* The `configuration {}` block is omitted on purpose — modi/terminal/
* show-icons come from modules/home/rofi.nix.
*/
* {
sumi: #1f1f28; /* sumi ink — the canvas */
well: #090618; /* deeper ink — inputbar + zebra */
paper: #dcd7ba; /* washi paper — text + frame */
parch: #c8c093; /* parchment — the prompt */
grey: #727169;
wave: #7e9cd8; /* crystal-blue wave — the selection */
font: "JetBrainsMono Nerd Font 14";
background-color: transparent;
text-color: @paper;
}
window {
background-color: @sumi;
border: 2px;
border-color: @paper; /* the warm paper frame (kanagawa's border tone) */
border-radius: 10px;
width: 40%;
location: center;
anchor: center;
padding: 8px;
}
mainbox {
background-color: transparent;
children: [ inputbar, message, listview ];
spacing: 0px;
}
inputbar {
children: [ prompt, entry ];
background-color: @well; /* the ink well */
text-color: @paper;
border-radius: 8px;
padding: 10px 12px;
spacing: 8px;
margin: 0px 0px 8px 0px;
}
prompt { text-color: @parch; }
entry {
text-color: @paper;
cursor: text;
placeholder: "search…";
placeholder-color: @grey;
}
listview {
background-color: transparent;
columns: 1;
lines: 8;
dynamic: true;
scrollbar: false;
spacing: 4px;
padding: 8px 0px 0px 0px;
}
element {
background-color: transparent;
text-color: @paper;
orientation: horizontal;
border-radius: 8px;
padding: 10px 14px;
spacing: 12px;
}
element alternate.normal {
background-color: @well;
}
element selected.normal {
background-color: @wave;
text-color: @sumi;
}
element-icon {
background-color: transparent;
size: 36px;
cursor: inherit;
}
element-text {
background-color: transparent;
text-color: inherit;
highlight: bold;
vertical-align: 0.5;
}
message { padding: 8px 0px 0px 0px; }
textbox {
background-color: @well;
text-color: @paper;
border-radius: 8px;
padding: 8px 12px;
}
scrollbar { width: 0px; }

View File

@@ -11,7 +11,7 @@
"overlay": "#304860", "overlay": "#304860",
"text": "#d6e2ee", "text": "#d6e2ee",
"subtext": "#d6e2ee", "subtext": "#d6e2ee",
"muted": "#304860", "muted": "#3c556e",
"accent": "#f2fcff", "accent": "#f2fcff",
"accentAlt": "#8bc9eb", "accentAlt": "#8bc9eb",
"good": "#5e95bc", "good": "#5e95bc",
@@ -22,6 +22,23 @@
"active": "accent", "active": "accent",
"inactive": "overlay" "inactive": "overlay"
}, },
"fonts": {
"mono": "JetBrainsMono Nerd Font",
"ui": "Inter",
"size": 11
},
"ui": {
"gapsIn": 5,
"gapsOut": 12,
"borderSize": 2,
"rounding": 10,
"iconSize": 36,
"activeOpacity": 1.0,
"inactiveOpacity": 0.95,
"terminalOpacity": 0.96,
"blur": true,
"shadow": true
},
"ansi": [ "ansi": [
"#1b2d40", "#1b2d40",
"#4d86b0", "#4d86b0",

BIN
themes/lumon/preview.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 30 KiB

110
themes/lumon/rofi.rasi Normal file
View File

@@ -0,0 +1,110 @@
/**
* Nomarchy Lumon — rofi whole-swap (themes/<slug>/rofi.rasi).
* Clinical-terminal identity (the MDR readout): a thick cyan bezel around a
* cold teal screen, the inputbar FRAMED rather than filled (a data field,
* not a button), near-white headings, cyan selection. Faintly softened
* corners keep it boxy without going razor-sharp like Retro 82.
* The `configuration {}` block is omitted on purpose — modi/terminal/
* show-icons come from modules/home/rofi.nix.
*/
* {
base: #16242d;
surface: #1b2d40;
overlay: #304860;
text: #d6e2ee;
ice: #f2fcff; /* near-white — the prompt */
cyan: #8bc9eb; /* the Lumon glow — bezel + select */
font: "JetBrainsMono Nerd Font 13";
background-color: transparent;
text-color: @text;
}
window {
background-color: @base;
border: 3px; /* a screen bezel */
border-color: @cyan;
border-radius: 4px; /* boxy, faintly softened */
width: 40%;
location: center;
anchor: center;
padding: 10px;
}
mainbox {
background-color: transparent;
children: [ inputbar, message, listview ];
spacing: 0px;
}
/* Framed, not filled — a readout field rather than a button. */
inputbar {
children: [ prompt, entry ];
background-color: transparent;
text-color: @text;
border: 1px;
border-color: @overlay;
border-radius: 4px;
padding: 10px 12px;
spacing: 8px;
margin: 0px 0px 8px 0px;
}
prompt { text-color: @ice; }
entry {
text-color: @text;
cursor: text;
placeholder: "refine…";
placeholder-color: @overlay;
}
listview {
background-color: transparent;
columns: 1;
lines: 8;
dynamic: true;
scrollbar: false;
spacing: 4px;
padding: 8px 0px 0px 0px;
}
element {
background-color: transparent;
text-color: @text;
orientation: horizontal;
border-radius: 4px;
padding: 10px 14px;
spacing: 12px;
}
element alternate.normal {
background-color: @surface;
}
element selected.normal {
background-color: @cyan;
text-color: @base;
}
element-icon {
background-color: transparent;
size: 36px;
cursor: inherit;
}
element-text {
background-color: transparent;
text-color: inherit;
highlight: bold;
vertical-align: 0.5;
}
message { padding: 8px 0px 0px 0px; }
textbox {
background-color: @surface;
text-color: @text;
border-radius: 4px;
padding: 8px 12px;
}
scrollbar { width: 0px; }

View File

@@ -1,2 +0,0 @@
@define-color foreground #d6e2ee;
@define-color background #213442;

View File

@@ -22,6 +22,23 @@
"active": "accent", "active": "accent",
"inactive": "overlay" "inactive": "overlay"
}, },
"fonts": {
"mono": "JetBrainsMono Nerd Font",
"ui": "Inter",
"size": 11
},
"ui": {
"gapsIn": 5,
"gapsOut": 12,
"borderSize": 2,
"rounding": 10,
"iconSize": 36,
"activeOpacity": 1.0,
"inactiveOpacity": 0.95,
"terminalOpacity": 0.96,
"blur": true,
"shadow": true
},
"ansi": [ "ansi": [
"#333333", "#333333",
"#D35F5F", "#D35F5F",

Binary file not shown.

After

Width:  |  Height:  |  Size: 172 KiB

View File

@@ -7,7 +7,7 @@
"colors": { "colors": {
"base": "#222222", "base": "#222222",
"mantle": "#1d1d1d", "mantle": "#1d1d1d",
"surface": "#000000", "surface": "#2e2e2e",
"overlay": "#666666", "overlay": "#666666",
"text": "#c2c2b0", "text": "#c2c2b0",
"subtext": "#d7c483", "subtext": "#d7c483",
@@ -22,6 +22,23 @@
"active": "accent", "active": "accent",
"inactive": "overlay" "inactive": "overlay"
}, },
"fonts": {
"mono": "JetBrainsMono Nerd Font",
"ui": "Inter",
"size": 11
},
"ui": {
"gapsIn": 5,
"gapsOut": 12,
"borderSize": 2,
"rounding": 10,
"iconSize": 36,
"activeOpacity": 1.0,
"inactiveOpacity": 0.95,
"terminalOpacity": 0.96,
"blur": true,
"shadow": true
},
"ansi": [ "ansi": [
"#000000", "#000000",
"#685742", "#685742",

BIN
themes/miasma/preview.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 101 KiB

60
themes/neon-glass.json Normal file
View File

@@ -0,0 +1,60 @@
{
"version": 1,
"name": "Neon Glass",
"slug": "neon-glass",
"mode": "dark",
"wallpaper": "",
"colors": {
"base": "#0B0E14",
"mantle": "#07090D",
"surface": "#151A22",
"overlay": "#1E2530",
"text": "#E0E6F0",
"subtext": "#8F9BB3",
"muted": "#4B566B",
"accent": "#00F0FF",
"accentAlt": "#B026FF",
"good": "#00FFA3",
"warn": "#FFD000",
"bad": "#FF003C"
},
"border": {
"active": "accent",
"inactive": "overlay"
},
"fonts": {
"mono": "JetBrainsMono Nerd Font",
"ui": "Inter",
"size": 11
},
"ui": {
"gapsIn": 5,
"gapsOut": 12,
"borderSize": 2,
"rounding": 10,
"iconSize": 36,
"activeOpacity": 1.0,
"inactiveOpacity": 0.95,
"terminalOpacity": 0.96,
"blur": true,
"shadow": true
},
"ansi": [
"#151A22",
"#FF003C",
"#00FFA3",
"#FFD000",
"#00F0FF",
"#B026FF",
"#00E5FF",
"#E0E6F0",
"#4B566B",
"#FF3366",
"#33FFB8",
"#FFE033",
"#33F3FF",
"#C44DFF",
"#33EBFF",
"#FFFFFF"
]
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 702 KiB

View File

@@ -0,0 +1,85 @@
/*
* Neon Glass Rofi Theme
* Glassmorphic, transparent, blurred background with glowing accents.
*/
* {
/* Using standard theme palette via env variables injected by home-manager,
or fallback if testing independently. The whole-swap still gets the
colors injected into the RASI by the module! */
background-color: transparent;
text-color: var(text, #E0E6F0);
font: "Inter 13";
}
window {
width: 600px;
/* transparent base with slight tint */
background-color: rgba(11, 14, 20, 0.45);
border: 1px;
border-color: rgba(0, 240, 255, 0.3);
border-radius: 24px;
padding: 24px;
/* Blur would be applied via hyprland layerrule for rofi */
}
mainbox {
spacing: 16px;
}
inputbar {
background-color: rgba(21, 26, 34, 0.5);
border: 1px;
border-color: rgba(0, 240, 255, 0.5);
border-radius: 12px;
padding: 12px 16px;
spacing: 12px;
children: [ prompt, entry ];
}
prompt {
text-color: var(accent, #00F0FF);
}
entry {
placeholder: "Search...";
placeholder-color: var(subtext, #8F9BB3);
}
listview {
lines: 7;
spacing: 8px;
scrollbar: false;
border: 0px;
}
element {
padding: 10px 16px;
border-radius: 12px;
spacing: 12px;
}
element normal.normal, element alternate.normal {
background-color: transparent;
}
element selected.normal {
background-color: rgba(0, 240, 255, 0.15);
border: 1px;
border-color: rgba(0, 240, 255, 0.4);
text-color: var(accent, #00F0FF);
}
element-icon {
size: 24px;
background-color: transparent;
}
element-text {
background-color: transparent;
vertical-align: 0.5;
}
element-text selected {
text-color: var(accent, #00F0FF);
}

View File

@@ -0,0 +1,108 @@
/* Neon Glass Waybar — Segmented floating pills with glassmorphism */
* {
font-family: "Inter", "Symbols Nerd Font", sans-serif;
font-size: 13px;
font-weight: 500;
border: none;
border-radius: 0;
min-height: 0;
}
window#waybar {
/* The main bar background is transparent so the wallpaper shows through */
background: transparent;
color: @text;
}
/*
* The pill containers: left, center, right.
* Glassmorphism: semi-transparent base + blur (blur requires hyprland layerrule if waybar doesn't natively blur, but alpha base helps).
*/
.modules-left,
.modules-center,
.modules-right {
background: alpha(@base, 0.45);
border: 1px solid alpha(@accent, 0.2);
border-radius: 20px;
margin: 4px 6px;
padding: 2px 8px;
box-shadow: 0 4px 10px rgba(0, 0, 0, 0.3);
}
/* Add a glowing top border effect on hover or generally to accent the glass */
.modules-left {
border-top: 1px solid alpha(@accent, 0.6);
}
.modules-center {
border-top: 1px solid alpha(@text, 0.3);
}
.modules-right {
border-top: 1px solid alpha(@accentAlt, 0.6);
}
/* Remove default padding from individual modules so they fit snug in the pill */
#workspaces button,
#window,
#clock,
#tray,
#pulseaudio,
#battery,
#custom-notification,
#custom-vpn,
#custom-powerprofile,
#custom-updates,
#custom-doctor {
padding: 0 8px;
margin: 0 2px;
}
/* Workspaces */
#workspaces button {
color: @subtext;
border-radius: 12px;
transition: all 0.2s cubic-bezier(0.25, 1, 0.5, 1);
}
#workspaces button:hover {
background: alpha(@surface, 0.5);
color: @text;
}
#workspaces button.active {
color: @accent;
background: alpha(@accent, 0.15);
box-shadow: 0 0 8px alpha(@accent, 0.4);
}
#workspaces button.urgent {
color: @bad;
background: alpha(@bad, 0.15);
}
/* Indicators */
#battery.charging { color: @good; }
#battery.warning:not(.charging) { color: @warn; }
#battery.critical:not(.charging) {
color: @bad;
animation: blink 1s steps(2, start) infinite;
}
#pulseaudio.muted { color: @muted; }
#custom-updates { color: @accentAlt; }
#custom-doctor { color: @bad; }
#custom-vpn { color: @accent; }
@keyframes blink {
to { color: @text; }
}
tooltip {
background: alpha(@base, 0.85);
border: 1px solid alpha(@accent, 0.4);
border-radius: 12px;
}
tooltip label {
color: @text;
}

View File

@@ -11,7 +11,7 @@
"overlay": "#4c566a", "overlay": "#4c566a",
"text": "#d8dee9", "text": "#d8dee9",
"subtext": "#e5e9f0", "subtext": "#e5e9f0",
"muted": "#4c566a", "muted": "#616e88",
"accent": "#81a1c1", "accent": "#81a1c1",
"accentAlt": "#b48ead", "accentAlt": "#b48ead",
"good": "#a3be8c", "good": "#a3be8c",
@@ -22,6 +22,23 @@
"active": "accent", "active": "accent",
"inactive": "overlay" "inactive": "overlay"
}, },
"fonts": {
"mono": "JetBrainsMono Nerd Font",
"ui": "Inter",
"size": 11
},
"ui": {
"gapsIn": 5,
"gapsOut": 12,
"borderSize": 2,
"rounding": 10,
"iconSize": 36,
"activeOpacity": 1.0,
"inactiveOpacity": 0.95,
"terminalOpacity": 0.96,
"blur": true,
"shadow": true
},
"ansi": [ "ansi": [
"#3b4252", "#3b4252",
"#bf616a", "#bf616a",

BIN
themes/nord/preview.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 187 KiB

Some files were not shown because too many files have changed in this diff Show More