Compare commits

...

267 Commits

Author SHA1 Message Date
Bernardo Magri
53f75f6e1b polish(nightlight): geo-mode tooltip says it follows the location
Some checks failed
Check / eval (push) Has been cancelled
"Warm on schedule" was wrong in geo mode — the schedule is computed
from lat/long. Tooltip now reads "follows your location" when geo is
active.

Verification: V1 — rode the same flake check + template-home build as
the exit-node commit.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-11 09:41:34 +01:00
Bernardo Magri
656ebc9735 feat(menu): Tailscale exit-node rows show country/city
The Exit node picker listed bare hostnames; Mullvad-style nodes are
meaningless without their location. Rows are now
"hostname  — Country City", sliced from `tailscale exit-node list` by
the header's fixed-width column offsets (COUNTRY/CITY can be
multi-word, so field splitting is wrong by construction); nodes without
a location stay a bare hostname, and the pick strips back to the
hostname at the first space before `tailscale set --exit-node`.

Closes BACKLOG LATER "VPN exit-node richer display"; the VPN
HARDWARE-QUEUE entry now covers the location rows.

Verification: V1 — the awk was proven against a fabricated fixture
(one-word city, multi-word "United States / New York, NY", bare
self-hosted node), flake check exit 0, downstream-template-home builds
(writeShellScriptBin syntax-gates the script). Real `tailscale
exit-node list` output stays V3 (existing VPN menu live-paths entry).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-11 09:41:34 +01:00
Bernardo Magri
4ebd6770ec feat(nightlight): geo mode — lat/long-computed sunset via wlsunset
All checks were successful
Check / eval (push) Successful in 4m20s
Set BOTH nomarchy.nightlight.latitude/.longitude and the night light
switches backing unit: wlsunset computes sunrise/sunset from the
location daily (the fixed .sunrise/.sunset are ignored;
.temperature feeds the night temp). Everything user-facing is
unchanged — same nomarchy-nightlight toggle script, Waybar moon, and
live-state ExecCondition gate; the unit name is a single let-binding
so all three follow together. Declarative-only on purpose: coordinates
are machine config like the keyboard layout, so home.nix (commented
template example), not a menu writer.

checks.nightlight-geo guards the wiring at pure eval: rendered
wlsunset ExecStart carries the coords + night temperature, the
ExecCondition gate landed on the swapped unit, hyprsunset stays off in
geo mode.

Verification: V1 — nix flake check --no-build exit 0 (evals the new
asserts + option-docs sync); built downstream-template-home (default
hyprsunset path unregressed). V3 pending: visible warm shift at the
location's night + instant toggle/off-persistence on the swapped unit
(HARDWARE-QUEUE § Any machine).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-11 09:30:52 +01:00
Bernardo Magri
4c656b1e73 chore(agent): sync-sweep rule on task completion + apply it; zram user docs
All checks were successful
Check / eval (push) Successful in 4m45s
New standing rule (LOOP.md §5 step 4, echoed in AGENTS.md): when a task
ships, grep its number/feature name across agent/ and docs/ and update
or delete every cross-reference it made stale, in the same commit —
including pruning checked-off [x] HARDWARE-QUEUE entries whose outcome
is recorded (journal/ROADMAP; git history is the archive).

First application: BACKLOG PROPOSED groomed (the auto time-of-day theme
pitch was stale — shipped as #79; shipped/promoted placeholder lines
removed; v1.0 pointer rewritten; FUTURE promoted-stub dropped);
HARDWARE-QUEUE pruned of 29 checked entries (363→253 lines) with
dangling references reworded and the prune rule documented in its
header.

Also closes the zram user-doc gap: README §3 now documents the zram
default (zstd/50%/prio 100), hibernation working out of the box on new
installs, and the MIGRATION.md runbook for existing machines.

Verification: V0 (docs/meta only).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-11 09:20:54 +01:00
Bernardo Magri
5ef66c242e chore(agent): drop .claude/skills — AGENTS.md carries the routing
All checks were successful
Check / eval (push) Successful in 4m44s
The skill shims became redundant once the auto-loaded entry point
(CLAUDE.md → AGENTS.md) explicitly instructs reading VERIFICATION.md /
THEME-DESIGN.md; their only remaining value was explicit /nomarchy and
/theme invocation. The adapter is now permissions + subagent defs only.
References updated (AGENTS.md, agent/README.md, docs/README.md, README
tree, MEMORY.md hygiene note).

Verification: V0 (docs/meta only).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-11 09:06:32 +01:00
Bernardo Magri
dafa83e922 docs(agent): AGENTS.md explicitly requires reading VERIFICATION.md first
Some checks failed
Check / eval (push) Has been cancelled
The enforcement rules must not depend on any harness's skill/trigger
mechanism — the auto-loaded entry point itself now instructs every
agent to read agent/VERIFICATION.md before its first change (and
THEME-DESIGN.md / TESTING.md where relevant), instead of merely
pointing at them.

Verification: V0 (docs-only).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-11 09:04:14 +01:00
Bernardo Magri
c0fc16e25c docs(agent): vendor-neutral agent docs — AGENTS.md entry, agent/ SoT, .claude shims
All checks were successful
Check / eval (push) Successful in 4m28s
The repo is maintained by agents from multiple vendors, so agent
instructions move out of vendor-specific locations into shared,
git-tracked markdown:

- AGENTS.md is the new entry point for any harness; CLAUDE.md becomes a
  symlink to it (Claude Code keeps working unchanged).
- Skill bodies relocate to agent/: VERIFICATION.md (the enforcement
  rules, ex .claude/skills/nomarchy), DELEGATION.md (capability tiers
  light/standard/frontier, scout/runner role contracts, token economy,
  parallel fan-out — consolidates the CLAUDE.md model table, LOOP.md's
  economy section, and skill §6.5 into one place; vendor model names
  survive only in the per-harness mapping table), THEME-DESIGN.md
  (ex .claude/skills/theme, which previously lacked frontmatter).
- .claude/ shrinks to a thin Claude Code adapter: settings, subagent
  defs, and skill shims that route into agent/.
- Maps updated: agent/README.md (instructions vs state vs adapters),
  docs/README.md, README.md layout tree.

Cleanup: stale old_distro .gitignore entry dropped (dir long gone);
local result*/__pycache__ artifacts removed; JOURNAL.md rotated (29
recent entries kept, 120 older moved to agent/JOURNAL-ARCHIVE.md,
rotation rule documented in the header).

Verification: V0 (docs/meta only) — nix flake check --no-build exit 0
with the tracked symlink; grep sweep confirms no vendor model names
outside DELEGATION.md's mapping table.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-11 08:58:25 +01:00
Bernardo Magri
37615c85a4 test(hibernate): #76 checks.hibernate-swapfile — verify swapfile+offset in a VM
All checks were successful
Check / eval (push) Successful in 4m18s
Push #76's swapfile-resume residual into a VM as far as headlessly possible.
A full hibernate→resume from a RUNTIME swapfile turned out not to be
headless-reachable: systemd reports CanHibernate=no for a swap that isn't
declared in the boot config, and a runtime-computed resume_offset can't be
fed into the boot cmdline (the genuine chicken-and-egg). So the check now
verifies, bounded and deterministically, what the partition test
(checks.hibernate) can't and what a VM CAN prove: the installer's btrfs
@swap swapfile is built correctly and its offset is valid — NOCOW
mkswapfile, file-type swapon, a good `map-swapfile -r` offset the kernel
accepts (/sys/power/resume_offset), and zram (prio 100) above it.

The literal encrypted-swapfile power-cycle + LUKS-initrd unlock stays a V3
laptop check (the dev box runs exactly this layout, /proc/cmdline verified).

(First cut had an unbounded wait_for_shutdown that hung on the CanHibernate
refusal; rewritten with a thread-bounded wait + a CanHibernate pre-check,
which gave the clean verdict. Device is /dev/vdb without useNixStoreImage.)

Verification: V2 PASS (nix build .#checks.x86_64-linux.hibernate-swapfile,
fresh, exit 0); flake check --no-build green. #76 residual narrowed in
BACKLOG/ROADMAP.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-11 08:34:17 +01:00
Bernardo Magri
2fa5231215 fix(theme): #79 review fixes — wrap-around schedule + HH:MM guard
All checks were successful
Check / eval (push) Successful in 4m6s
Two low-severity edge bugs from a fresh-eyes review of the session batch:

- cmd_auto: an inverted schedule (sunrise > sunset, i.e. a day window that
  crosses midnight) stayed perpetually on the day theme — the old
  `not between` shortcut is an empty interval when sunrise > sunset.
  Replaced with a real wrap-around: daytime = mins >= sunrise or
  mins < sunset, so both directions switch. Normal sunrise < sunset path
  is unchanged (checks.auto-theme still holds).
- rofi autotheme HH:MM validator: `[0-2][0-9]` accepted 24:00–29:59.
  Tightened the case pattern to `[01][0-9]:[0-5][0-9]|2[0-3]:[0-5][0-9]`
  so out-of-range hours are rejected (they'd otherwise skew the boundary).

(Two other review findings dismissed: papirus DOES ship breeze/breeze-dark
so that iconPacks prefix is correct; the doctor RAM-unknown row is cosmetic
since /proc/meminfo is always readable.)

Verification: py_compile; boundary truth-table (normal + wrap-around);
HH:MM case table (23:00/19:59/20:00 accept, 24/25/30:00 reject); auto
--which normal-case unchanged; menu bash -n; flake check green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-11 08:19:05 +01:00
Bernardo Magri
808990592d docs(overrides): user guide for auto day/night theme (#79)
All checks were successful
Check / eval (push) Successful in 3m51s
Add an "Auto theme (day/night)" subsection to docs/OVERRIDES.md § 1
(Appearance), after the Icon pack section. Covers what it does (light/dark
switch on a schedule, same one engine), how to turn it on (Look & Feel ›
Auto theme, or the shell set/auto sequence), the settings.autoTheme
fields, and behavior (periodic ~15-min re-check, idempotent rebuilds,
instant disable). Names/fields/flags verified against rofi.nix,
nomarchy-theme-sync.py, and the icon section's style.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-10 22:31:21 +01:00
Bernardo Magri
49e0061dbb test(auto-theme): #79 V2 — simulate sunset in a VM (was mis-framed V3)
All checks were successful
Check / eval (push) Successful in 3m53s
Bernardo rightly pushed back on deferring #79 to a real session: a
simulated sunset IS VM-testable. Add checks.auto-theme, a runNixOSTest
that installs nomarchy-theme-sync, points NOMARCHY_DEFAULT_THEMES at the
presets, stubs the rebuild via NOMARCHY_REBUILD (a marker), configures
the day/night pair + schedule, then WALKS THE VM CLOCK across the
boundary: 19:55 -> 20:05 -> 20:06 -> next-day 07:30, asserting the active
theme goes summer-day -> night (1 rebuild) -> night (idempotent, still 1)
-> day (2 rebuilds).

This isolates the auto decision + apply + rebuild-trigger; the actual
home-manager switch is stubbed (that generic apply path is exercised by
every manual theme change). The only on-hardware residual is the user
timer firing on its OnCalendar schedule, which systemd-analyze validates.

#79 drops its [blocked:hw] tag — it's V2-verified. ROADMAP updated;
lesson recorded in MEMORY (don't default timer/session features to V3:
stub the generic step, simulate the clock).

Verification: V2 PASS (nix build .#checks.x86_64-linux.auto-theme, fresh,
exit 0); V0 flake check green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-10 22:04:38 +01:00
Bernardo Magri
860c70466f feat(menu): #79 slice 3 — Look & Feel › Auto theme (completes #79)
All checks were successful
Check / eval (push) Successful in 3m46s
Adds an "Auto theme (on/off)" row to Look & Feel and an `autotheme`
submenu: toggle the feature, pick the day/night themes (from
`nomarchy-theme-sync list`), and set the sunrise/sunset times (HH:MM,
validated). This is the menu surface over settings.autoTheme, completing
the auto time-of-day theme feature (slices: CLI / timer / menu).

Enabling writes the flag and defaults the summer-day/summer-night pair
--no-switch, then runs `auto --force`, so a single rebuild both installs
the timer and applies the correct theme for now. Disabling is instant
(--no-switch): the flag flips and `auto` self-gates, so the lingering
timer no-ops until the next rebuild. Day/night/time writes are live
--no-switch (read by `auto` at tick time).

Verification: V1 — HM generation builds (bash -n on nomarchy-menu at
build) + bash -n on the built menu. Logic test with stubbed
rofi/theme-sync/notify: selecting the toggle from off runs exactly `set
day/night/enable ... --no-switch` then `auto --force` — the intended
one-rebuild path. V0 flake check green. All three #79 slices shipped;
the live timer switch across sunset is a real-session (V3) check on a
Nomarchy machine.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-10 21:53:37 +01:00
Bernardo Magri
c44616aeb0 feat(autotheme): #79 slice 2 — systemd timer runs auto
All checks were successful
Check / eval (push) Successful in 3m53s
modules/home/autotheme.nix installs a user service + timer
(nomarchy-auto-theme) that periodically runs `nomarchy-theme-sync auto`,
so the day/night theme is applied on the current schedule through the
same one engine. Install gates on settings.autoTheme.enable (read from
state, the way updates.nix gates on its enable), so a machine not using
the feature carries no timer.

Timer: OnStartupSec=1min (settle after login) + OnCalendar=*:0/15 +
Persistent (catch a transition missed while off). Periodic rather than
exact sunrise/sunset timers — robust to suspend/DST, and `auto` is
idempotent (no rebuild unless the theme must change) so ticks are cheap.

Verification: V1 — real-config eval (state temporarily enabled, then
restored) confirms the units land when enabled (OnCalendar *:0/15,
ExecStart `... nomarchy-theme-sync auto`) and are absent when disabled;
systemd-analyze validates the schedule. V0 flake check green. The live
timer -> home-manager switch on a real Nomarchy session is pending a
Nomarchy machine (same blocker as #76).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-10 21:34:56 +01:00
Bernardo Magri
429de59b52 feat(theme-sync): #79 slice 1 — auto day/night theme primitive
All checks were successful
Check / eval (push) Successful in 3m58s
First slice of the auto time-of-day theme pair (VISION § D, split into
CLI / timer / menu). `nomarchy-theme-sync auto` reads
settings.autoTheme.{enable,day,night,sunrise,sunset}, picks day vs night
from the local clock, and applies the matching preset — but only if it
differs from the current one, so the slice-2 timer can fire freely
without triggering needless rebuilds. `--which` prints the decision
without switching; `--force` and `--no-switch` included.

cmd_apply's core is factored into apply_named() so `auto` drives the
exact same one engine (no second pipeline). apply behavior is unchanged.

Verification: V1 (package builds) + functional tests on the built binary
against scratch state — wide-day window → day slug, a 00:00–00:01 day
span → night, disabled → no-op, missing day/night → clear die, auto
--no-switch writes state, a re-run no-ops as "already on", and plain
apply still writes (refactor intact). V0 py_compile + flake check +
checks.theme-sync-validate green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-10 21:20:55 +01:00
Bernardo Magri
79d73cd623 docs(omarchy): #78 Omarchy → Nomarchy migrant one-pager
All checks were successful
Check / eval (push) Successful in 3m47s
Add docs/OMARCHY.md for users coming from Omarchy (VISION § F). Leads
with the one real difference — imperative dotfiles vs a declarative flake
built via the menu, with rollback through NixOS generations — then maps
the deltas: install (ISO/nomarchy-install or MIGRATION.md), a familiar
keybindings table, theming (SUPER+T / nomarchy-theme-sync), installing
software (home.packages / nix shell, no AUR), and update+rollback
(nomarchy-pull/-rebuild/-home, boot-menu generations, nomarchy-doctor).
Registered in docs/README.md; VISION § F marked shipped.

The Nomarchy side is grounded in the code: keybindings pulled from
keybinds.nix, and every cited command (7), the nsearch alias, and the
named theme slugs (gruvbox/rose-pine/nord) verified to exist. Omarchy-side
claims kept to known facts (Arch, dotfiles, pacman/AUR).

Verification: V0 (docs-only); commands/slugs/binds checked against the tree.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-10 21:15:36 +01:00
Bernardo Magri
e841251399 feat(doctor): #77 hibernate/sleep section (VISION § C)
All checks were successful
Check / eval (push) Successful in 3m41s
nomarchy-doctor gains a read-only hibernate/sleep section (the highest-ROI
Day-2 gap, a natural #76 follow-on): resume device on the kernel cmdline
(+ resume_offset for a swapfile), disk swap >= RAM, zram active, and any
suspend/hibernate error in the previous boot's kernel log. Self-gates
(skip on swap=0) and emits only ok/warn/skip, so it never fails the sheet.
Adds `hibernate` to the checks.doctor probe list so the row is asserted.

Fixes a set -euo pipefail hazard the first VM run caught (exit 1, no
verdict): a no-match grep inside $(...) and a `cond && action` both abort
under set -e — now guarded with || true / || echo 0 / if. Recorded in
MEMORY as a reusable gotcha for writeShellScriptBin scripts.

Verification: V2 PASS — checks.doctor VM test green (fresh build, exit 0;
asserts the hibernate row renders, healthy run stays 0). V1 — ran on real
LUKS+@swap hardware: all three ok rows render (resume set, swap 34G >= RAM,
zram 16G) and the sheet reaches its verdict. V0 bash -n + flake check.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-10 21:06:45 +01:00
Bernardo Magri
cf97f5605d docs(agent): clean QA sweep + VISION-gap pass (3 PROPOSED)
All checks were successful
Check / eval (push) Successful in 3m44s
QA/drift sweep: deterministic checks + bash -n/py_compile all pass
(the 4 "needs-args" scripts pass via their flake derivations); drift
recon found none (dead code, stale refs, TODOs, changed-area
contradictions, counts all clean — 24 presets/previews consistent);
eval sweep green. No fixes needed.

VISION-gap pass: verified candidates against the tree first, which
killed two false gaps (nomarchy-rebuild/home already tail logs + point
at doctor on failure = §A; nvd diff = §B; Firmware menu already exists).
Filed three verified-open PROPOSED items: doctor hibernate/sleep section
(§C, natural #76 follow-on), Omarchy migrant one-pager (§F), auto
time-of-day theme pair (§D, big). Await human triage.

Verification: V0 (agent-notes/docs only); flake check --no-build green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-10 20:51:36 +01:00
Bernardo Magri
dc33694a56 docs(overrides): user guide for opt-in icon packs
All checks were successful
Check / eval (push) Successful in 3m24s
The only doc for the icon-pack override was a comment in the downstream
template. Add an "Icon pack" subsection to docs/OVERRIDES.md § Appearance
(the user-facing appearance-customization doc): the
`nomarchy-theme-sync set icons "<name>"` command, why only Papirus ships
by default (~1 GiB; extra packs opt-in), a known-packs table, and that
the choice is sticky across `apply` (presets carry no icon field).

Example names verified by building all four packs and listing their real
theme directories — corrected Qogir to its capitalized Qogir-Dark/Light
(a lowercase name silently falls back to hicolor). Hibernation needs no
new doc (README swapfile note + MIGRATION.md runbook + Power-menu row).

Verification: V0 (docs-only); command + variant names checked live.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-10 20:34:00 +01:00
Bernardo Magri
7eb9b8ad4f feat(theme): opt-in cross-pack icon overrides, no default bloat
All checks were successful
Check / eval (push) Successful in 3m39s
theme.nix now resolves nomarchy.theme.iconThemePackage from a small
icon-pack registry (Papirus/Tela/Qogir/Reversal/Numix, matched by name
prefix). Default themes name Papirus-*, so the resolved package is the
plain papirus-icon-theme derivation — byte-identical closure, zero added
MB. Only when a theme's `icons` names a set from another pack is that
pack (and only it) symlinkJoined alongside papirus. stylix.nix consumes
it via mkDefault. Adding a pack is one iconPacks row.

Deliberately NOT shipping more packs by default: papirus alone is already
~1 GiB and each extra pack adds ~hundreds of MB, so variety is opt-in per
install (set `icons` in theme-state.json), not a default closure cost.
Documented in templates/downstream/home.nix + ROADMAP § Icon themes.

Verification: V1 (HM activationPackage builds). Eval proofs — default
resolves to plain papirus (zero delta); icons="Tela-dark" ->
nomarchy-icon-themes symlinkJoin (papirus+tela) with the name flowing,
without building tela; all 5 pack attrs exist in the pinned nixpkgs;
template-sot + option-docs + flake check green. No default visual change
(same papirus), so no screenshot; the opt-in render is downstream.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-10 20:12:52 +01:00
Bernardo Magri
aff50e6070 docs(agent): correct #76 V3 scope — dev box isn't a Nomarchy install
All checks were successful
Check / eval (push) Successful in 4m20s
The dev box runs Bernardo's own NixOS (TuringMachine, 25.11), not
Nomarchy — so hibernating it tests that config's hibernate, not
Nomarchy's oom.nix/patch-template/rofi wiring, and can't close #76's V3.
Its layout (LUKS cryptroot + btrfs @ + /swap/swapfile + resume_offset)
does resume, which corroborates the MIGRATION.md runbook, but nothing
more. Rescope the two #76 HARDWARE-QUEUE entries to require a machine
actually running Nomarchy (a Latitude install, or the dev box after
migration), and fix the matching reference in the BACKLOG #76 stub.

Verification: V0 (docs/agent-notes only).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-10 19:53:23 +01:00
Bernardo Magri
ba8963a385 test(hibernate): #76 slice 3 — V2 hibernate→resume VM check (PASS)
All checks were successful
Check / eval (push) Successful in 4m11s
Add checks.hibernate: a runNixOSTest modeled on nixpkgs' hibernate.nix,
wired to the actual risk #76 introduces — coexistence with the zram swap
that oom.nix now enables by default. It imports oom.nix, puts the disk
swap at a lower priority than zram with an explicit boot.resumeDevice,
and uses systemd-initrd. The test drives a full
hibernate -> power-off -> resume -> crash cycle: a volatile ramfs marker
survives resume but NOT a crash-boot (proving genuine resume), and it
asserts zram sits at priority 100 while the disk swap is lower, so the
hibernate image lands on disk rather than volatile zram.

What QEMU genuinely can't do — feed a boot-time resume_offset for a btrfs
swapfile (offset only exists after first-boot allocation) or drive a
LUKS-initrd unlock — stays a V3 laptop check (HARDWARE-QUEUE, dev box
already runs that exact layout), plus the no-swap notify in a real
session. With this, all V0/V1/V2 work on #76 is done; #76 collapses to a
[blocked:hw] stub and the design record moves to ROADMAP.

Verification: V2 PASS — nix build .#checks.x86_64-linux.hibernate built
the driver fresh and exited 0. nix flake check --no-build green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-10 19:45:47 +01:00
Bernardo Magri
2d0cb48d5c feat(menu): #76 slice 2 — Hibernate notifies on failure
All checks were successful
Check / eval (push) Successful in 4m7s
Per the settled #76 UX call: keep the Power-menu Hibernate row
unconditional, but when hibernate fails (typically a swap=0 machine with
no disk swap), surface a notification pointing at the enable-hibernation
runbook instead of a silent no-op. On success `systemctl hibernate`
blocks until resume, so the notify only fires on a real failure.

Verification: V1 — homeConfigurations.nomarchy.activationPackage builds
(rebuilds nomarchy-menu; writeShellScriptBin runs bash -n). Control-flow
proved on the arm extracted from the built script: failing systemctl ->
notify fires, succeeding -> no notify. flake check --no-build green.
V2 hibernate->resume VM test + V3 laptop remain open (#76 slice 3).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-10 19:36:52 +01:00
Bernardo Magri
8c048a2692 docs(migration): #76 slice 1 — enable-hibernation runbook
Some checks failed
Check / eval (push) Has been cancelled
Add a self-contained "Enabling hibernation on an existing machine (no
reinstall)" section to docs/MIGRATION.md, per the settled #76 call to
document migration rather than ship a tool. Covers: @swap subvolume
creation (subvolid=5), btrfs mkswapfile (NOCOW), reading the resume
offset via map-swapfile, and wiring fileSystems."/swap" + swapDevices +
resumeDevice + resume_offset into system.nix. Flags the /swap mount as
required on the hand-edit path (a fresh install inherits it from
disko-generated hardware-config). Notes the zram-priority reservation,
a no-LUKS variant, and the swap=0 opt-out.

Verification: V0. All read-only commands run live against this machine's
LUKS(cryptroot)+btrfs(@)+/swap/swapfile layout; nix flake check
--no-build green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-10 19:34:14 +01:00
Bernardo Magri
858362cfcc docs(agent): #76 hibernation design calls settled
All checks were successful
Check / eval (push) Successful in 4m8s
Bernardo's three design calls on the hibernation half of #76:
- swap sizing = exactly RAM (installer default, no change)
- migration for existing machines = docs runbook (MIGRATION.md), not a tool
- no-swap Hibernate = keep menu row, notify on failure

Recon confirmed new installs are already hibernation-ready (installer +
patch-template resume wiring + Power-menu Hibernate row). Folded decisions
into BACKLOG #76; re-sliced remainder to MIGRATION.md runbook + notify-on-
failure menu; recorded in Decisions.

Verification: V0 (docs-only) nix flake check --no-build green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-10 19:28:26 +01:00
Bernardo Magri
2055842c33 feat(memory): enable zram compressed-RAM swap by default (#76 slice)
All checks were successful
Check / eval (push) Successful in 4m1s
First slice of BACKLOG #76 (hibernation + zram, split): zramSwap on by
default in modules/nixos/oom.nix — the memory-pressure layer before
earlyoom. zstd @ 50% RAM (nixpkgs defaults, explicit for reproducibility),
priority 100 so day-to-day paging fills zram first and any disk swapfile
stays reserved for #76's hibernate image (hibernation can't resume from
volatile zram). Follows the earlyoom mkDefault precedent — no toggle.

Adds checks.zram-swap runNixOSTest importing oom.nix, asserting the device
is an active swap, uses [zstd], and carries priority 100.

Verified: V2 checks.zram-swap PASS (/dev/zram0 in /proc/swaps, zstd,
prio 100); V1 full distro toplevel builds; V0 flake check --no-build green.
#76 remainder (disk swapfile default + resume + installer) still open.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-10 16:07:55 +01:00
Bernardo Magri
59975a6a59 docs(agent): close #41 — OpenFile portal float confirmed on hardware
All checks were successful
Check / eval (push) Successful in 3m1s
Bernardo verified the GTK file-chooser (OpenFile) portal floats on real
hardware; the xdg-desktop-portal-gtk float/center rule already shipped in
hyprland.nix. Drop the "OpenFile not re-probed / optional residual"
caveats in HARDWARE-QUEUE and journal the V3 close. Bookkeeping only, no
code change.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-10 15:58:36 +01:00
5c5e1f797c docs(agent): #76 require VM hibernate verify; LUKS+@swap reference
All checks were successful
Check / eval (push) Successful in 2m58s
Agent must V2-test hibernation in the VM (encrypted BTRFS @swap
swapfile). Reference layout from live Latitude: no zram yet; swap
inside LUKS. MEMORY pointer for implementers.
2026-07-10 14:51:59 +01:00
6356f9b408 docs(agent): NEXT #76 hibernation default + zram
All checks were successful
Check / eval (push) Successful in 2m51s
Promote hibernation+zram product intent to the queue head; [big] with
design/module/installer/V3 slices. FUTURE stub points at #76.
2026-07-10 14:46:26 +01:00
f0f568f8d5 docs(agent): FUTURE formatter; hibernation intent + zram note
Some checks failed
Check / eval (push) Has been cancelled
Formatter yes-but-later. Hibernation wanted by default (disk swap);
zram compatible alongside for live pressure, not as sole swap.
2026-07-10 14:43:36 +01:00
ac3b75b018 feat(template): ship Chromium by default; record product decisions
All checks were successful
Check / eval (push) Successful in 2m49s
- home.packages: chromium active; mime → chromium-browser.desktop
- Decisions: markdown docs stay in-repo; PPD default confirmed
- Formatter + zram still open (explained in session, not closed)

V0: flake check --no-build green.
2026-07-10 14:37:52 +01:00
b37a50c779 docs(agent): complete FUTURE #20 section; fix nomarchy-pull README line
All checks were successful
Check / eval (push) Successful in 2m54s
FUTURE block with #20 deferred decision; README day-to-day list
matches pull = flake inputs only.
2026-07-10 14:29:52 +01:00
d5ee59fcb1 docs(agent): defer #20 KVM CI to FUTURE; keep eval-only VPS
Some checks failed
Check / eval (push) Has been cancelled
Standing decision: IONOS Gitea/act_runner docker stays eval-tier.
vm-checks needs a separate KVM host later — not the 4G compose box.
2026-07-10 14:29:37 +01:00
8002757ca3 docs(agent): tick HARDWARE-QUEUE for Latitude 5310 2026-07-10 QA
All checks were successful
Check / eval (push) Successful in 2m57s
Mark PASSes from the session: charge Custom type, bar/menus/capture,
polkit, combined power, mixer, arrows, SUPER+?, caffeine, doctor,
network blanks, lifecycle CLIs. Leave residual theme/ISO/greeter/etc.
2026-07-10 14:20:30 +01:00
c41c8abaa7 fix(cli): lifecycle tools in HM so stale pull can self-heal
All checks were successful
Check / eval (push) Successful in 2m57s
Extract nomarchy-pull/rebuild/home to pkgs/nomarchy-lifecycle; install
via system + home. Home profile wins on PATH so home-manager switch
installs the fixed pull (skip git when no upstream) without waiting
for a system rebuild that needed a working pull first.

V0: flake check --no-build green.
2026-07-10 14:13:00 +01:00
45c584db26 fix(power): Dell charge limit needs Custom type, not Adaptive
All checks were successful
Check / eval (push) Successful in 2m58s
charge_control_end_threshold was written (80) but charge_types stayed
[Adaptive], which ignores the cap while still reporting it — battery
kept charging past 80% on Latitude. Oneshot now selects Custom when a
limit is set, Adaptive/Standard when off; sets start hysteresis; doctor
warns if type ≠ Custom while limited.

V0: flake check --no-build green. V3: re-apply limit, confirm
charge_types shows [Custom] and status stops Charging above cap.
2026-07-10 14:01:19 +01:00
44264428af feat(hyprland): float GTK portal file chooser; close #41
All checks were successful
Check / eval (push) Successful in 2m50s
Class xdg-desktop-portal-gtk (+ Nix .…-wrapped) from package desktop/
libexec. Polkit already V3 PASS on Latitude. NEXT only #20 remains.

V0: flake check --no-build green.
2026-07-10 13:57:22 +01:00
84c145467e docs(agent): clock tooltip V3 PASS on Latitude
Some checks failed
Check / eval (push) Has been cancelled
2026-07-10 13:54:35 +01:00
0834038072 fix(waybar): clock tooltip uses single chrono format block
All checks were successful
Check / eval (push) Successful in 2m57s
Two bare `{:%…}` specs emptied the hover tooltip (box with no text).
One strftime block with an embedded newline shows date + zone; ensure
tooltip label colors on generated + summer bars.
2026-07-10 13:50:15 +01:00
869ca6b16c fix(cli): nomarchy-pull skips git when no upstream
All checks were successful
Check / eval (push) Successful in 2m54s
Machine flakes often have no remote (local auto-commits only). Distro
updates come from nix flake update of the nomarchy input. Only git pull
--ff-only when @{u} is set; always update the lock; print nomarchy rev.
2026-07-10 13:41:34 +01:00
1e1d568947 fix(hyprland): stay_focused not stayfocused (0.55)
Some checks failed
Check / eval (push) Has been cancelled
Hyprland 0.55 renamed the effect; stayfocused 1 → invalid field type
on line 158 (polkit rules). Keep float/center/workspace current.
2026-07-10 13:38:54 +01:00
d7427ae5da docs: note SUPER+D launcher bind dropped
All checks were successful
Check / eval (push) Successful in 2m56s
2026-07-10 13:33:12 +01:00
e2ede3d813 refactor(binds): app launcher is SUPER+Space only
Some checks failed
Check / eval (push) Has been cancelled
Drop the duplicate SUPER+D drun bind; menu Apps hint shows SUPER+Space.
2026-07-10 13:33:06 +01:00
3d5cb21302 feat(cli): nomarchy-pull / nomarchy-rebuild / nomarchy-home
Some checks failed
Check / eval (push) Has been cancelled
Split lifecycle into three clear commands (user pick):
  nomarchy-pull     git pull (if checkout) + flake.lock update
  nomarchy-rebuild  system switch on current lock (snap + nvd)
  nomarchy-home     Home Manager switch

Full upgrade: pull && rebuild && home. Legacy sys-update / sys-rebuild /
home-update remain as wrappers. nixos-rebuild-snap uses NOMARCHY_PATH.

V0: flake check --no-build green.
2026-07-10 13:32:50 +01:00
910f357f08 fix(desktop): hardware findings — charge limit, polkit ws, bar deps
All checks were successful
Check / eval (push) Successful in 3m3s
Latitude 5310 QA:
- Clock tooltip: drop empty {calendar} popup; plain date+zone
- Charge limit: restart oneshot via polkit + dual write/delay for
  Dell unplug race; toast verifies sysfs; looser menu match
- pwvucontrol ships with waybar (right-click volume)
- Print/region→clipboard toasts success
- Polkit (and pinentry) open on current workspace + stayfocused

V0: flake check --no-build green. V3: retest A3/A4/B1/tooltip/polkit.
2026-07-10 13:29:44 +01:00
3bc8a46927 fix(menu): case-insensitive filter on all rofi -dmenu menus
All checks were successful
Check / eval (push) Successful in 2m53s
dmenu mode is case-sensitive by default (dmenu compat), so config.rasi
case-sensitive=false fixed apps/drun but not nomarchy-menu pickers.
rofi_menu helper always passes -i; networkmanager_dmenu gets rofi -i.

V0: flake check --no-build green. V3: type sys in root Menu → System.
2026-07-10 13:08:33 +01:00
79630314aa docs(agent): mark calendar V3 PASS on Latitude
Some checks failed
Check / eval (push) Has been cancelled
2026-07-10 13:06:52 +01:00
b5263bcef8 fix(desktop): tighter bar icon pad + case-insensitive rofi match
Some checks failed
Check / eval (push) Has been cancelled
- Double-space pad on volume (esp. high), battery, keyboard layout glyphs
- rofi: matching=normal,fuzzy; case-smart off (ste finds Steam)
- Queue: unfree+calendar PASS; retest spacing + rofi search

V0: nix flake check --no-build green. V3: bar/rofi on session.
2026-07-10 13:06:47 +01:00
d547393e7b fix(desktop): unfree CLI default, bar icon spacing, unified clock
All checks were successful
Check / eval (push) Successful in 3m0s
Hardware findings on Latitude: nix-shell blocked unfree; whole-swap
bars collided icon+percentage; clock needed calcurse + one module.

- Export NIXPKGS_ALLOW_UNFREE=1 + ~/.config/nixpkgs/config.nix
- Mono Nerd face + trailing spaces on battery/volume icons (all bars)
- Single clock `HH:MM · Day DD Mon`; calcurse ships with waybar
- V3 queue entries for laptop re-check

Verification: V0 — nix flake check --no-build green; V2/V3 pending on
session (user-visible bar + CLI unfree).
2026-07-10 12:58:37 +01:00
Bernardo Magri
eb951569cf feat: live charge limit, identity retunes, btop assets (product calls)
All checks were successful
Check / eval (push) Successful in 3m0s
- Charge limit: udev GROUP=users on threshold; menu applies sysfs live
  and persists state; boot/AC oneshot prefers live theme-state.json.
- Look & Feel: Reset wallpaper (auto); Theme/next/night light already there.
- NVIDIA first-class wrappers deferred past v1 (keep #59 comments).
- Identity taste: white, lumon, hackerman, matte-black, miasma.
- Hand btop.theme for identity + boreal/executive-slate/neon-glass.

Verified: V0 contrast; V1 battery-charge-limit VM. V3 live sysfs on laptop.
2026-07-10 12:12:13 +01:00
Bernardo Magri
099d214529 feat(themes): summer pair JSON SoT + status CSS + recapture previews
All checks were successful
Check / eval (push) Successful in 2m58s
Align summer-day/night waybar tokens with JSON roles (hierarchy split
on night; day overlay≠muted), unify bar font 13px + battery 25/10, add
VPN pill and status states (.on/.available/.recording/…). Recapture
executive-slate and neon-glass picker previews from theme-shot.

Verified: V0 contrast+wholeswap; V2 theme-shot four slugs.
2026-07-10 12:03:51 +01:00
Bernardo Magri
2fff3ad628 docs(agent): close #74 matrix + #75 V2 install (themed first boot)
All checks were successful
Check / eval (push) Successful in 3m8s
Unattended env matrix flags shipped earlier; default arm V2: live ISO
rebuild + offline install + first-boot themed Boreal desktop.
2026-07-10 10:09:06 +01:00
Bernardo Magri
3eeba0611d test(install): fall back to nixpkgs OVMF.fd for headless ISO tests
All checks were successful
Check / eval (push) Successful in 2m59s
2026-07-10 09:40:55 +01:00
Bernardo Magri
a95cf49ddf test(install): #74 wire matrix flags into go.sh + first-boot
Some checks failed
Check / eval (push) Has been cancelled
Generate unattended go.sh from NOMARCHY_TEST_SWAP_GB / NO_LUKS; skip
LUKS passphrase typing on first boot when cleartext.
2026-07-10 09:40:38 +01:00
Bernardo Magri
3760e39a54 test(install): #74 unattended matrix env flags (swap / no-LUKS)
Some checks failed
Check / eval (push) Has been cancelled
NOMARCHY_TEST_SWAP_GB and NOMARCHY_TEST_NO_LUKS drive go.sh for the
headless install harness. Document default / no-swap / no-LUKS matrix.
First-boot path skips LUKS typing when NO_LUKS=1.
2026-07-10 09:40:25 +01:00
Bernardo Magri
5b93b97191 docs(agent): close NEXT #64–#73 after parallel land
Some checks failed
Check / eval (push) Has been cancelled
Landed worktree batch: migration docs, installer chown, theme-state
reader, theme fidelity, audit/import tools, IR docs, template-sot check,
hardware MOTD tips. Verified contrast + template-sot + flake check.
2026-07-10 09:39:59 +01:00
Bernardo Magri
ad6b76e1eb feat(hints): #73 fingerprint + doctor MOTD/first-boot tips
#43 shipped Firmware MOTD + control-center --first-boot tip. Residual:
- MOTD always lists nomarchy-doctor; Fingerprint line only when
  nomarchy.hardware.fingerprint.enable (no nag without a reader).
- first-boot tips self-gate on fprintd-list / nomarchy-doctor (same
  gum style as Firmware). No dismiss state file — first-boot is once.

V0 flake check --no-build + bash -n; V1 build control-center + eval
MOTD with/without fingerprint.enable. V3 enroll stays hardware-queue.
2026-07-10 09:39:10 +01:00
Bernardo Magri
3a874dccc8 test(install): #72 installer ↔ template SoT parity check
Guard that nomarchy-install share/template ships byte-identical
flake.nix/system.nix/home.nix/theme-state.json to templates/downstream
(the single machine-seed SoT; default.nix copy list).

- tools/check-template-sot.py — share vs SoT file-set + content
- checks.template-sot — pure python + package path (theme-contrast pattern)
- BACKLOG #72 removed; JOURNAL entry

Verified (V0):
  python3 tools/check-template-sot.py <share/template> templates/downstream
  nix build .#checks.x86_64-linux.template-sot
2026-07-10 09:39:10 +01:00
Bernardo Magri
82776d7da4 docs(webcam): portal/Flatpak IR gap is v4l2-only (#71)
Ship the recommended document-only path: WirePlumber hideIrSensor acts
on the v4l2 monitor only; libcamera / xdg-desktop-portal / Flatpak
camera pickers can still list the internal IR sensor.

- HARDWARE.md §7: dual-sensor subsection (scope, workaround, deferred)
- ROADMAP § Webcam: ✓ #71; (b)/(c) stay hardware-gated
- README option row + HARDWARE-QUEUE T14s check for (b)/(c)
- Close BACKLOG #71; no libcamera rules

Verified: V0 (docs only).
2026-07-10 09:39:10 +01:00
Bernardo Magri
34362d6a92 fix(tools): identity audit exemptions + import hierarchy (#69 #70)
#69: audit-theme-design tags hue/CVD/ANSI-family noise on white,
vantablack, lumon, hackerman, matte-black, miasma as [identity] with a
legend — do not retune identity palettes into traffic lights.

#70: import-palettes keeps surface≠overlay when color0==color8, derives
light UI chips from base (ANSI-black trap), gentler light mantle; roles
are first-class. No bulk re-import of shipped theme JSON.

Verified: V0 (py_compile + synthetic import fixtures + audit report).
2026-07-10 09:39:10 +01:00
Bernardo Magri
a640de4fd4 fix(theme): #66 fail-closed theme-state errors at mkFlake/read
Missing, empty, or non-object theme-state.json now throws a short
message pointing at the template and `nomarchy-theme-sync validate`
instead of a raw readFile/fromJSON stack. Shared reader:
modules/theme-state-read.nix — wired in theme.nix, plymouth/timezone/
hardware, and lib.mkFlake (seq-forced early gate). Field-level schema
in theme.nix kept; unknown border roles still fail there; resolveColor
no longer falls through on missing palette keys. Defaults added for
settings.displayProfile / displayProfileAuto.

Verified: V0 nix flake check --no-build; negative evals (missing,
empty, [], bad border role); good-path template mode + displayProfile
defaults.
2026-07-10 09:39:10 +01:00
Bernardo Magri
bd6d94f973 fix(themes): fidelity hierarchy nits + #52 residual ANSI (#67, #68)
#67 hierarchy: nord subtext dimmer than text; tokyo-night btop Night
blues; ethereal surface raise + muted darker than overlay; retro-82
surface raised; ristretto subtext distinct + btop main_bg align;
flexoki-light distinct bright-black; gruvbox overlay/subtext ladder
(no rename).

#68 residual ANSI: vantablack ansi[8]=muted; osaka-jade ansi[3]=warn.

Verified: python3 tools/check-theme-contrast.py themes/ → 24×7 all pass.
2026-07-10 09:39:03 +01:00
Bernardo Magri
3d324982b9 fix(install): chown flake dir with real install-user uid/gid (#65)
Hard-coded 1000:100 assumed the first free normal uid and users(100).
After nixos-install, resolve id -u/-g for USERNAME on the target and
chown FLAKE_DIR (and the HM-fail hint file) to those ids.
2026-07-10 09:39:03 +01:00
Bernardo Magri
61d9ee1577 docs(migration): clarify installer vs @home-snapshots layout (#64)
Installer: disko @snapshots → /.snapshots + first-boot nested
/home/.snapshots. Migration machines may use top-level
@home-snapshots (TuringMachine); either works for snapper.
2026-07-10 09:39:03 +01:00
Bernardo Magri
cb659ebb4c docs(agent): PROPOSED breadcrumbs for #64–#75 promotion
All checks were successful
Check / eval (push) Successful in 2m56s
2026-07-10 09:29:34 +01:00
Bernardo Magri
6b488d69ef docs(agent): promote agent-actionable PROPOSED → NEXT #64–#75
Some checks failed
Check / eval (push) Has been cancelled
Category 1 (no hw/human): MIGRATION, chown, mkFlake errors, theme
fidelity nits, residual ANSI, audit exemptions, import pipeline, IR
docs, SoT CI, hardware-hints residual. Category 2 (KVM): unattended
matrix + SoT V2 install. Queue head is #64.
2026-07-10 09:29:01 +01:00
Bernardo Magri
1d6b25b96a docs(agent): clean stale PROPOSED backlog residue
All checks were successful
Check / eval (push) Successful in 2m59s
Drop shipped #52 high-ROI theme bullets, neon-glass quarantine, empty
installer audit sections, resolved template seed drift, and #50 soft
gaps. PROPOSED is open work only (day-2, installer follow-ups, theme
polish residuals, tooling).
2026-07-10 09:25:15 +01:00
Bernardo Magri
14558ad296 test+fix: close #14, V2 #57 live install entry, #41 float slice
All checks were successful
Check / eval (push) Successful in 2m57s
- #14: first scheduled lock bump confirmed (8fded63 nomarchy-bump Mon
  05:20 UTC 2026-07-06); remove queue item.
- #57 V2: checks.live-install-entry builds nomarchy-live HM gen and
  asserts Install Nomarchy.desktop + installer bin + menu gate.
- #41: float/center hyprpolkitagent + pinentry-qt from package app-ids;
  capture-float-classes.nix harness (softGL empty); residual portal
  class stays NEXT [blocked:hw]. HARDWARE-QUEUE confirm steps.

Verified: live-install-entry + windowrule-syntax green.
2026-07-10 09:20:15 +01:00
Bernardo Magri
5f3124d160 docs(agent): note A/B/C batch #55–#63 shipped
All checks were successful
Check / eval (push) Successful in 2m57s
2026-07-10 08:45:41 +01:00
Bernardo Magri
a102dff508 feat(rebuild): #56 human rebuild errors point at doctor
Some checks failed
Check / eval (push) Has been cancelled
sys-update / sys-rebuild / home-update tee output and on failure print
the last 40 log lines plus nomarchy-doctor + RECOVERY.md. theme-sync HM
switch failure notify/die text matches. Close #56.

Verified: V1 (HM rebuild; failure-footer shell smoke).
2026-07-10 08:45:33 +01:00
Bernardo Magri
e81529c1bb feat(menu): #55 System › Fingerprint enroll/list/PAM
Self-gated System › Fingerprint when fprintd is on PATH: enroll, list,
verify, delete-all (terminal), and Use for login writing
settings.fingerprint.pam (hardware.nix default from theme-state). Close #55.

Verified: V1 (HM menu rebuild). V3 pending: real-reader enroll (HARDWARE-QUEUE).
2026-07-10 08:45:33 +01:00
Bernardo Magri
2ef0a8b710 feat(hw): #58 nomarchy-detect-hw post-install re-probe CLI
All checks were successful
Check / eval (push) Successful in 2m52s
Package the installer’s pure hardware-db probe as nomarchy-detect-hw:
prints suggested hardwareProfile and system.nix snippets (or --raw
protocol lines). Does not rewrite the flake. On PATH via the distro
module; docs/HARDWARE.md §8 updated. Close #58.

Verified: V1 (build + run on AMD laptop with fingerprint/NPU/IR cam).
2026-07-10 08:41:18 +01:00
Bernardo Magri
35699f170f feat(live): #57 Install Nomarchy desktop entry + Tools row
Some checks failed
Check / eval (push) Has been cancelled
Always-visible install surface on the live ISO: xdg.desktopEntries
(Terminal=true) plus Tools › Install Nomarchy (self-gated on
nomarchy-install on PATH). Welcome toast mentions the app. Close #57.

Verified: V1 (live eval + HM menu rebuild). V2 pending: ISO smoke.
2026-07-10 08:39:47 +01:00
Bernardo Magri
a504f35b41 feat(waybar): #63 generated bar logo + powermenu
Some checks failed
Check / eval (push) Has been cancelled
Add custom/nomarchy (left, Nomarchy monogram → nomarchy-menu) and
custom/powermenu (right → nomarchy-menu power) to the generated bar so
default-theme users match whole-swap affordances. Pin font-family:
Nomarchy; use literal UTF-8 glyphs (Nix has no \u escapes).

Verified: V2 (THEME=tokyo-night theme-shot — monogram + power glyph).
Close #63.
2026-07-10 08:38:45 +01:00
Bernardo Magri
c962d07841 fix(power): #60 name-agnostic system-battery scan
All checks were successful
Check / eval (push) Successful in 2m53s
Replace BAT* globs with type=Battery + !scope=Device (same filter as
nomarchy-battery-notify) in charge-limit oneshot, menu gates, Waybar
power-profile self-gate, doctor, and install chassis detection. CMB0-
style names get charge-cap and laptop modules consistently.

V3 pending: confirm on non-BAT* hardware (HARDWARE-QUEUE). Close #60.

Verified: V1 (option-docs, hardware-db-modules, battery-charge-limit VM).
2026-07-10 08:32:24 +01:00
Bernardo Magri
09388196a9 feat(install): #59 NVIDIA commented system.nix guidance
Some checks failed
Check / eval (push) Has been cancelled
When hardwareProfile includes common-gpu-nvidia, emit a comment-only
plain NixOS block (PRIME / powerManagement / open-module) in the
installer region of system.nix — same pattern as ROCm/NPU opt-ins.
HARDWARE.md §6 product direction marked shipped. Close #59.

Verified: V0 (patch-template smoke + negative + bash -n).
2026-07-10 08:30:51 +01:00
Bernardo Magri
f09042adbe feat(themes): #62 whole-swap CSS self-containment CI gate
Some checks failed
Check / eval (push) Has been cancelled
Add tools/check-theme-wholeswap.py and checks.theme-wholeswap: every
themes/*/waybar.css must @define-color each @name it references (raw
readFile, no palette prepend — neon-glass class), and every preset needs
preview.png + non-empty backgrounds/. README: four waybar.css themes
(drop quarantined neon-glass). Close #62.

Verified: V0 (script + negative fixture + checks.theme-wholeswap + flake check --no-build).
2026-07-10 08:29:30 +01:00
Bernardo Magri
11d6a3df0f feat(install): #61 review Source line tracks offline/online
Some checks failed
Check / eval (push) Has been cancelled
Gate the Review panel Source wording on the same OFFLINE flag as the
cache.nixos.org probe (ISO-only vs may use network binary caches).
Extend installer-safety pure contract so both phrases and $SOURCE_NET
cannot regress.

Also promote Bernardo-accepted A/B/C batch → NEXT #55–#63 and close #61.

Verified: V0 (check-install-safety + bash -n + checks.installer-safety).
2026-07-10 08:28:20 +01:00
Bernardo Magri
9aa8f250d6 feat: close #54 V2 install safety, #33 rofi scrollbar, #43 MOTD tip
All checks were successful
Check / eval (push) Successful in 3m0s
#54: permanent checks.installer-safety; test-install-safety harness with
offline compose-fail, LUKS-signature disk warn, and HM-hint FORCE paths.
#33: enable themed rofi scrollbars (generated + all whole-swap .rasi).
#43: MOTD + first-boot tip for System › Firmware (V3 LVFS stays hw-queue).

V2: compose-fail/disk-warn/hm-hint logs green offline; theme-shot
boreal+summer-night menus OK. V0 flake/installer-safety.
2026-07-09 23:02:05 +01:00
Bernardo Magri
be4efd38ea fix(waybar): #51 doctor glyph renders + #52 V2 bake asserts
All checks were successful
Check / eval (push) Successful in 3m6s
Doctor tripwire was self-hiding under waybar (PATH miss / weak glyph).
Call nomarchy-doctor by store path, strip ANSI tooltips, use 󰀨 + format
+ signal 10 (generated and whole-swap). theme-shot asserts class:bad,
guest btop.theme, and pokes RTMIN+10 before desktop shots.

V2: theme-shot green for rose-pine, summer-night, catppuccin, everforest,
vantablack; bar crops show the red alert-circle. btop TUI visual is
softGL-blocked (Ghostty) → HARDWARE-QUEUE. Closes #51 and #52.
2026-07-09 20:56:51 +01:00
Bernardo Magri
d8a796b6ee docs(agent): record #43 V2 (Firmware menu render) + #44 close
All checks were successful
Check / eval (push) Successful in 3m2s
#43: theme-shot render confirms the System submenu renders themed and
self-gates correctly; the Firmware row's presence on a real install is
verified via /sw/bin/fwupdmgr existing in the live toplevel (same
mechanism as the working Bluetooth row). A filter render didn't surface
the row in the minimal capture VM — render artifact, not a bug. V3 (real
LVFS flash) stays queued.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-09 20:11:23 +01:00
Bernardo Magri
f606c78fcf test(checks): #44 doctor hardware section — headless guard
All checks were successful
Check / eval (push) Successful in 3m24s
Extend checks.doctor: the bare test VM lacks every probe tool, so the
healthy nomarchy-doctor run drives all six hardware checks in skip mode.
Assert the self-gated skip rows appear (NetworkManager, fingerprint,
fwupd, VA-API) and the sheet still exits 0 — proving the section runs
without erroring under set -euo pipefail. No node changes.

Verified V2: nix build .#checks.x86_64-linux.doctor passes. Closes #44.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-09 19:59:32 +01:00
Bernardo Magri
c40c74e640 feat(themes): #53 last previews + quarantine neon-glass broken bar
All checks were successful
Check / eval (push) Successful in 2m58s
Capture executive-slate + neon-glass preview.png (480x270, theme-shot) —
all 24 themes now have picker previews. Quarantine neon-glass's broken
whole-swap: its waybar.css referenced @accent/@base/… with no
@define-color block, and waybar.nix reads a whole-swap css raw (no palette
prepend), so applying it rendered broken bar colours. Removing it falls
back to the generated bar from the theme's JSON palette.

Verified: V2 theme-shot renders viewed — executive-slate clean; neon-glass
bar now renders correctly, no config-error banner. V0 flake check green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-09 19:52:09 +01:00
Bernardo Magri
f94772de2c docs(skill): track the theme designer skill
All checks were successful
Check / eval (push) Successful in 2m59s
First commit of .claude/skills/theme/SKILL.md (skills were un-ignored in
7d52d4b); content unchanged.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-09 19:45:35 +01:00
Bernardo Magri
40c38dc4f5 fix(install): #54 safety guards + recovery hint
All checks were successful
Check / eval (push) Successful in 3m7s
Offline compose-lock failure now fail-closes (reusing the cache.nixos.org
probe) instead of falling back to a network `nix flake lock`; a disk-review
warn fires when the target already carries Windows/BitLocker/NTFS/LUKS
(before the wipe); account password requires ≥8 chars like LUKS; on HM
pre-activate failure the installer drops a NOMARCHY-DESKTOP-NOT-THEMED.txt
recovery hint in the target home so first login still shows the fix.

Worktree agent; diff reviewed + cherry-picked. V0 flake check + V1
nomarchy-install build. Password ≥8 is V0-complete; the offline/NTFS/
pre-activate scenarios are V2-pending (crafted install runs).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-09 19:39:08 +01:00
Bernardo Magri
def6e9dcbe fix(themes): #52 broken/high-ROI fidelity fixes
Some checks failed
Check / eval (push) Has been cancelled
Six per-theme defects: rose-pine btop rewritten to Dawn (was Main-on-Dawn,
invisible hi_fg); everforest + summer-night btop inactive_fg off main_bg;
vantablack role inversion (surface/overlay/muted + btop selection);
osaka-jade text↔subtext swap + warn→yellow; catppuccin-latte
mantle/accentAlt/ANSI axis; catppuccin Mocha btop (was Frappé). accentAlt
uses Latte mauve #8839ef, not the audit's pink #ea76cb (which fails the
3.0 contrast floor).

Worktree agent; diff reviewed + contrast conflict adjudicated. V0:
check-theme-contrast 24x7 all pass + flake check green. btop V2 render
pending (batched). Residual ansi[8]/ansi[3] drift noted.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-09 19:36:58 +01:00
Bernardo Magri
4a99b64532 docs(skill): fan-out token-economy guidance + track nomarchy skill
Some checks failed
Check / eval (push) Has been cancelled
Add a "Fanning out worktree agents" subsection to §6.5: match model to
task (sonnet for spec'd work, opus for judgment), point briefs at the
backlog spec, disjoint file lanes, worktree isolation with the parent
owning landing, lean on scriptable checks as evidence, batch V2 in one
VM pass. First commit of the tracked skill (skills were un-ignored in
7d52d4b). MEMORY note updated: skills are now tracked.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-09 19:35:01 +01:00
Bernardo Magri
75d76fabd7 fix(waybar,menu): #51 doctor indicator glyph + CC rebuild hints + scroll
Some checks failed
Check / eval (push) Has been cancelled
Doctor Waybar indicator now emits a non-empty glyph (󰒡) + class:bad when
unhealthy (was "text":"" → self-hidden, so the tripwire never showed);
Control Center appearance toggles (blur/gaps) print "(requires rebuild)"
matching the other CC toggles; summer-day workspace scroll e±1 → r±1 to
match every other bar.

Worktree agent; diff reviewed + cherry-picked. V0 flake check + V1
(HM generation, nomarchy-control-center build). V2 render folded into #44.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-09 19:32:31 +01:00
Bernardo Magri
f6975a9797 docs(agent): promote V2-capable PROPOSED items to NEXT #51–54
All checks were successful
Check / eval (push) Successful in 2m57s
Bernardo-directed triage (VM now free post-#46): #51 Waybar/menu
behaviour bugs, #52 theme fidelity Broken/high-ROI fixes, #53 missing
previews + neon-glass whole-swap, #54 installer safety + polish. Promoted
pitches removed from PROPOSED (breadcrumbs left); non-selected V2 items
stay in PROPOSED.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-09 19:22:38 +01:00
Bernardo Magri
7a5284b15a feat(menu,install): #50 one-liners + record #46 V2 install pass
All checks were successful
Check / eval (push) Successful in 2m51s
#50: Bluetooth menu row + handler self-gate on `command -v
blueman-manager` (no-op when bluetooth is off, like Printers); Control
Center rollback list /tmp/nomarchy-gens → mktemp; usbutils on the
nomarchy-install PATH (fingerprint lsusb probe); export
nomarchy-doctor/control-center/battery-notify as flake packages.
Verified V0 (flake check) + V1 (packages, installer, HM generation build).

Also records #46: the swap=0 + unattended-LUKS fail-closed contracts
(208b8d4) V2-verified via a headless two-variant install — fail-closed
exits before wipe with the disk blank; swap=0 installs with no @swap
subvol and boots to a themed desktop. Both items removed from NEXT.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-09 19:16:17 +01:00
Bernardo Magri
e01303851d test(checks): pure-contract guards batch (#49)
All checks were successful
Check / eval (push) Successful in 3m6s
Four new no-VM checks.* + CI py_compile expansion:
- hardware-db-modules: installer DB module names ∈ pinned
  nixos-hardware.nixosModules (a lock bump can rename a module and break
  profiled installs on just the matching DMI, invisible to any VM).
- installer-compose-lock: offline lock-composer contract on fixtures.
- installer-disko: pure Nix assert — swapSize "0"/"0G" → no @swap, "2G"
  → sized, withLuks wraps root (permanently guards the #46 install fix).
- windowrule-syntax: builds the generated hyprland.conf and fails on the
  pre-0.53 grammar / windowrulev2 keyword (guards ed7fd93).
CI py_compile now covers all tracked *.py; docs/TESTING.md §1 synced.

Implemented by a worktree agent; diff reviewed. Verified V0 (flake check)
+ V1 (each checks.x86_64-linux.* built on main, exit 0); agent's negative
tests confirm each guard fails on a regression.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-09 19:10:45 +01:00
Bernardo Magri
97bf26a23f docs: factual-drift pass + i2c option docs (#47, #48)
Some checks failed
Check / eval (push) Has been cancelled
Fix documentation drift (each claim verified against code): theme count
21→24; waybar whole-swap lists (waybar.css=5 incl. neon-glass vs
waybar.jsonc parity=4 + boreal); idle "suspend 30"→"15 min battery-only";
Tailscale sudo→operator; hosts/live.nix "No installer yet" header; link
docs/MIGRATION.md + fix its placeholder URL; README layout tree
(+doctor/control-center/battery-notify); OVERRIDES swappy→satty; document
always-on home pieces. String fixes: keybind cheatsheet
nmtui→networkmanager_dmenu; complete nomarchy-menu usage string
(+doctor/firmware/controlcenter/rollback). Add README rows for
nomarchy.hardware.i2c.* (#48) — closes checks.option-docs.

Implemented by a worktree agent; diff reviewed and cherry-picked.
Verified V0 (flake check) + checks.option-docs green + V1 (HM build).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-09 19:09:00 +01:00
Bernardo Magri
1ee17f6799 docs(agent): promote V0/V1 PROPOSED items to NEXT #47–50
All checks were successful
Check / eval (push) Successful in 3m3s
Bernardo-directed triage: bump the no-VM (V0/V1) PROPOSED items into NEXT
so parallel worktree agents can take them without contending for the VM.
Grouped into #47 docs+string drift pass, #48 i2c option-docs, #49
pure-contract checks.* batch, #50 small code one-liners. Removed the
promoted pitches from PROPOSED (breadcrumbs left); V2-wanting items stay
in PROPOSED.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-09 18:55:29 +01:00
Bernardo Magri
eba7924b0f feat(doctor): read-only hardware health section (#44)
All checks were successful
Check / eval (push) Successful in 3m4s
Six self-gating hardware checks in nomarchy-doctor, same ok/bad/warn/skip
+ one-fix-command contract: NetworkManager connectivity, default PipeWire
sink, VA-API/OpenGL smoke (only if vainfo/glxinfo present), fprintd unit +
enroll nudge, fwupd daemon + a soft warn when get-updates is non-empty,
and the laptop charge_control_end_threshold when a limit is set. Each
skips cleanly when its tool/service/device is absent, so the section
shrinks to the machine and never hard-fails on a missing feature.

Implemented by a worktree-isolated agent in parallel with the #46 install
VM; diff reviewed and cherry-picked here. Verified V0 (flake check) + V1
(nix build .#…pkgs.nomarchy-doctor — build-time bash -n + shellcheck),
re-run on main after cherry-pick; plus a read-only run on real hardware
(all six checks fired + self-gated). V2 (checks.doctor fakes) pending.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-09 18:50:37 +01:00
Bernardo Magri
c89cace149 feat(menu): System ▸ Firmware — fwupd/LVFS update surface (#43)
All checks were successful
Check / eval (push) Successful in 3m3s
Add a self-gated Firmware row to the System submenu (present whenever
fwupdmgr is on PATH, i.e. services.fwupd.enable — default-on), its
dispatch, and a `firmware` handler that opens a terminal and runs
fwupdmgr refresh → get-updates → y/N confirm → fwupdmgr update. Never
auto-flashes: fwupdmgr update confirms each device and prompts for the
reboot a capsule needs (pillar 1 — no silent BIOS writes). HARDWARE.md
§4 updated to "shipped".

Verified V0 (nix flake check --no-build) + V1 (HM generation builds;
generated nomarchy-menu passes bash -n; firmware handler emitted
correctly). V2 (menu render in a session) queued in #43 and V3 (real
LVFS flash) in HARDWARE-QUEUE — the VM was held by the #46 install test.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-09 18:43:46 +01:00
Bernardo Magri
41cd350a52 feat(theme): capture Boreal picker preview + V2-verify default
All checks were successful
Check / eval (push) Successful in 3m6s
Close the honesty gap on #45. Rendered the full Boreal desktop
headlessly (tools/theme-shot.nix, softGL 1920x1080) and confirmed the
session reads as Boreal — aurora wallpaper, Waybar violet accent
(#B79BE8) workspace pill, GeistMono clock, rofi launcher violet
prompt/selection — not Tokyo Night. Added themes/boreal/preview.png
(480x270, matches the existing 21) so the picker row is no longer
plain-name.

theme-shot: park the cursor bottom-right before the desktop shot so the
frame (which doubles as the preview source) is clean; reusable for the
other two missing previews.

Verified: V0 flake check; V2 theme-shot desktop/menu shots viewed;
contrast 24x7 pass. Preview is a headless bare-desktop capture (not the
floating-terminal composition of the others) — hardware recapture noted.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-09 18:29:27 +01:00
7d52d4b5e4 Update .gitignore
All checks were successful
Check / eval (push) Successful in 3m6s
2026-07-09 18:25:45 +01:00
Bernardo Magri
ed7fd93e16 fix(hyprland): migrate window rules to Hyprland 0.53+ syntax
Some checks failed
Check / eval (push) Has been cancelled
The default desktop showed a red Hyprland config-error banner on every
boot, occluding the Waybar. Root cause: Hyprland 0.53 rewrote window
rules — the `windowrulev2` keyword is a hard error and the old
rule-first order (`float, class:^…$`) no longer parses, so the float
rules were not applying either. Confirmed against the Hyprland 0.55.4
source (`handleWindowrule`): each comma field is now `<keyword> <value>`,
so effects carry a value and matchers take a `match:` prefix.

Migrated the 7-rule block to `float 1, match:class ^…$` form.

Verified: V0 nix flake check --no-build; built the generated
hyprland.conf; V2 theme-shot re-render — banner gone, themed Waybar
renders. Journal + MEMORY gotcha added.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-09 18:24:55 +01:00
Bernardo Magri
5c875542d0 docs(agent): NEXT #45–46 for VM verification of recent V0 work
All checks were successful
Check / eval (push) Successful in 2m55s
Queue headless-incomplete checks for a VM-capable agent: Boreal default
(live/HM apply, preview.png, flake check) and install contracts
(swap=0 + unattended LUKS fail-closed).
2026-07-09 09:58:08 +01:00
Bernardo Magri
351b7adb8e feat(theme): make Boreal the distro default
Some checks failed
Check / eval (push) Has been cancelled
Seed theme-state.json and the downstream template from themes/boreal.json
(including border + empty settings). Point eval/schema fallbacks at Boreal
(theme.nix preset, plymouth and live-ISO splash colors, theme-shot, sync
error examples). Docs/TESTING and RECOVERY follow the new default.

Verified: state matches boreal palette; theme-contrast all pass.
preview.png for boreal still missing (picker plain-name; V3 capture).
2026-07-09 09:57:32 +01:00
Bernardo Magri
70501b566a docs(agent): promote v1.0 slices 42–44 to NEXT
All checks were successful
Check / eval (push) Successful in 2m55s
Boreal default theme, System › Firmware (fwupd), and doctor hardware
section — human triage from PROPOSED for agents to execute.
2026-07-09 09:45:31 +01:00
Bernardo Magri
208b8d4444 fix(install): swap=0 and unattended LUKS fail-closed
All checks were successful
Check / eval (push) Successful in 3m4s
Pass bare swapSize "0" when the user wants no swap (was "0G", which
still created @swap). Accept "0"/"0G" in disko-config. Unattended
installs require NOMARCHY_LUKS_PASSPHRASE or explicit NOMARCHY_NO_LUKS=1
instead of silently installing cleartext.

Verified: bash -n; nix-instantiate subvolume sets for 0/0G/16G.
VISION § v1.0 install golden path (workflow test iteration).
2026-07-09 09:40:07 +01:00
Bernardo Magri
02d7baeb7c docs: VISION for v1.0 + clarify docs/ vs agent/ vs .claude
All checks were successful
Check / eval (push) Successful in 3m7s
Add docs/VISION.md (product themes, v1.0 bar, agent slice hints) and
README maps under docs/ and agent/. Point ROADMAP, BACKLOG, LOOP,
CLAUDE, HARDWARE, and the root README at the split: VISION = intent,
BACKLOG = executable queue, ROADMAP = design history, .claude = harness
only. Mark v1.0 track in BACKLOG PROPOSED for human triage.
2026-07-09 09:33:51 +01:00
Bernardo Magri
caaac88da9 feat(install): copy templates/downstream and patch install values
All checks were successful
Check / eval (push) Successful in 3m4s
Make the downstream template the single source of truth for machine
flakes: nomarchy-install copies flake/system/home/theme-state and
patch-template.py only fills hostname, user, keyboard, detected
hardware, snapper, resume, and password. Install and flake-init users
now share the same commented opt-ins and starter home.packages.

Also: BACKLOG boreal-as-default proposal; installer audit notes;
HARDWARE.md from prior work if uncommitted.
2026-07-09 09:23:50 +01:00
Bernardo Magri
896b41faa3 docs: hardware support guide + product backlog
All checks were successful
Check / eval (push) Successful in 3m10s
Add docs/HARDWARE.md (layers, install path, firmware/fingerprint/NVIDIA
deep dives, unsupported machines, contributing a DMI line). Link from
README and MIGRATION; queue firmware UI, doctor checks, detect-hw CLI,
and related PROPOSED items in BACKLOG.
2026-07-09 09:04:27 +01:00
Bernardo Magri
d09c978b9b docs(agent): backlog codebase exam + per-theme design audit
All checks were successful
Check / eval (push) Successful in 3m16s
Record PROPOSED findings from a full tree exam (installer, modules,
docs drift, CI) and a 24-theme design pass (contrast green; btop
fidelity, identity semantics, whole-swap gaps). No code changes —
await human triage into NOW/NEXT.
2026-07-09 08:46:13 +01:00
Bernardo Magri
5273493c20 fix(hyprland): use windowrulev2 for class matching and update docs
All checks were successful
Check / eval (push) Successful in 3m0s
- Fixed invalid `windowrule` syntax when matching by class regex
  which caused Hyprland parse errors.
- Documented IPC socket watcher for display profiles in README.
- Updated agent/JOURNAL.md.
2026-07-08 23:07:51 +01:00
Bernardo Magri
9c21aa64b1 docs: document AppArmor and Kernel panic defaults
All checks were successful
Check / eval (push) Successful in 3m3s
2026-07-08 22:50:48 +01:00
Bernardo Magri
6b7f2b4ce8 docs: add new keybinds to README
All checks were successful
Check / eval (push) Successful in 3m5s
2026-07-08 22:47:20 +01:00
Bernardo Magri
2ef56eae88 docs: sync ROADMAP with shipped features
Some checks failed
Check / eval (push) Has been cancelled
2026-07-08 22:44:47 +01:00
Bernardo Magri
d6b5b344fa feat(desktop): complete theme UI review
All checks were successful
Check / eval (push) Successful in 3m0s
2026-07-08 22:37:25 +01:00
Bernardo Magri
60c7878a6a feat(desktop): workstation polish pack (apparmor, kernel panic, hyprpicker)
All checks were successful
Check / eval (push) Successful in 3m5s
2026-07-08 22:30:36 +01:00
Bernardo Magri
8a5714f330 feat(desktop): overhaul multi-monitor docking UX
Some checks failed
Check / eval (push) Has been cancelled
2026-07-08 22:29:44 +01:00
Bernardo Magri
05c7c7b54f ci: add V1 build tier to automated lock bumps
All checks were successful
Check / eval (push) Successful in 2m55s
2026-07-08 22:25:42 +01:00
Bernardo Magri
3322db7caf feat(system): enable ddcci for external monitor brightness control
Some checks failed
Check / eval (push) Has been cancelled
2026-07-08 22:24:49 +01:00
Bernardo Magri
fbd4e0503e feat(system): enable smartd for drive health monitoring
All checks were successful
Check / eval (push) Successful in 3m1s
2026-07-08 22:21:17 +01:00
Bernardo Magri
f97acda158 feat(home): enable easyeffects for microphone noise cancellation
All checks were successful
Check / eval (push) Successful in 3m2s
2026-07-08 22:16:50 +01:00
Bernardo Magri
ff76781a97 feat(home): enable udiskie for zero-click USB automounting
All checks were successful
Check / eval (push) Successful in 2m52s
2026-07-08 22:09:04 +01:00
Bernardo Magri
856445c505 feat(nixos): add file-roller as GUI backend for thunar archive plugin 2026-07-08 22:08:27 +01:00
Bernardo Magri
c6b759e19e feat(nixos): enable hardware security key support (pcscd)
Some checks failed
Check / eval (push) Has been cancelled
2026-07-08 22:07:12 +01:00
Bernardo Magri
eb8f9fad88 feat(nixos): enable Magic SysRq keys for safe crash recovery
Some checks failed
Check / eval (push) Failing after 57s
2026-07-08 22:06:04 +01:00
Bernardo Magri
d41e5c18d2 docs(agent): propose 3 even deeper-dive workstation tasks
All checks were successful
Check / eval (push) Successful in 2m56s
- [usefulness] Screen Sharing for Legacy X11 Apps (xwaylandvideobridge)
- [stability] Magic SysRq Keys for Safe Recovery (kernel.sysrq)
- [usefulness] File Manager Archive Backend (file-roller)
2026-07-08 22:01:25 +01:00
Bernardo Magri
452bb9d75f docs(agent): propose 3 more deep-dive workstation tasks
All checks were successful
Check / eval (push) Successful in 3m2s
- [usefulness] Hardware security key support (pcscd + u2f)
- [usefulness] Microphone noise cancellation (easyeffects)
- [stability] Drive health monitoring (smartd)
2026-07-08 21:56:59 +01:00
Bernardo Magri
2bad7c524f docs(agent): propose three new stability and usefulness tasks
All checks were successful
Check / eval (push) Successful in 2m51s
- [stability] BTRFS auto-scrub
- [usefulness] Automount removable media (udiskie)
- [usefulness] External monitor brightness control (ddcutil)
2026-07-08 21:53:12 +01:00
Bernardo Magri
1d8c1a4314 feat(home): add satty for screenshot annotation
All checks were successful
Check / eval (push) Successful in 3m1s
Added satty (Wayland-native screenshot annotation tool) and integrated it with the Capture flow.
Wired `$mod SHIFT, Print` to slurp a region and open satty.
Added "Annotate region" to the rofi Capture menu (Tools > Capture).
Added satty's configuration via home-manager to inject the active theme's palette colors into its UI.

Verified: V1 (flake check --no-build, home activation package build).
Pending: V3 hardware/visual check for the satty UI (queued in HARDWARE-QUEUE.md).
2026-07-08 21:49:06 +01:00
Bernardo Magri
3dcbb2b0b6 fix(home): make stateVersion overridable
All checks were successful
Check / eval (push) Successful in 3m1s
Changed home.stateVersion assignment to use lib.mkDefault instead of a plain assignment.
This fixes downstream flakes that need to migrate existing NixOS installations without bumping their state version.

Verified: V1 (flake check --no-build, home activation package build).
Pending: nothing.
2026-07-08 21:44:31 +01:00
Bernardo Magri
b1a9d2ea66 docs(agent): stability+usefulness survey #71 — 2 PROPOSED items
All checks were successful
Check / eval (push) Successful in 2m58s
Research iteration (LOOP §1). Findings: the state-file path is robust;
the real stability hole is the eval-only automated lock-bump gate (#14)
with no binary cache, whose true fix is the KVM-blocked #20. Usefulness:
the capture suite lacks a screenshot annotate step. Filed 2 PROPOSED —
an interim build-tier lock gate (no KVM) and a satty annotate step.
V0 (docs-only).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-08 21:31:34 +01:00
Bernardo Magri
db48bb85d5 docs(agent): journal QA sweep #70 — clean tree, no actionable drift
All checks were successful
Check / eval (push) Successful in 2m52s
Headless-actionable backlog is empty (NEXT is all human/hw/V3-visual).
Ran the full scripted suite: flake check green; option-docs and
theme-contrast checks built+passed; theme-design audit shows only
pre-curated identity findings; templates in sync with the live surface.
No churn manufactured. V0-verified.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-08 21:20:07 +01:00
Bernardo Magri
6bd03747c9 fix(waybar): inhibited notification state reads by shape, not color alone
All checks were successful
Check / eval (push) Successful in 2m59s
Item 28 color-only accessibility sweep. Swept every Waybar status
indicator for shape/glyph redundancy: all already carry non-color
distinction (updates shows a count + self-hides; vpn/doctor/nightlight/
recording self-gate; battery/idle/muted are glyph-distinct) except the
swaync notification module — its `inhibited-*` states reused the normal
bells (󰂜/󰂚), so an app-inhibited (suppressed) state was distinguished
from a normal one by color alone.

Route every suppressed state (DND + inhibited) to the bell-off glyph 󰂛
+ @muted, so "notifications are off right now" reads by SHAPE. Applied
to the generated bar and all four whole-swaps (summer-day/night use
their own bell-off glyph; executive-slate/boreal use 󰂛) — parity rule.
Reuses the glyph DND already ships, so no new codepoint / tofu risk.

Verified: V0 (flake check --no-build; 4 jsoncs valid JSON) + V1 (built
homeConfigurations.nomarchy.activationPackage — the built config has
inhibited-* = U+F009B and style.css routes .inhibited-* → @muted).
V3 pending: live inhibited-state render with an app holding an inhibitor
(HARDWARE-QUEUE). MEMORY gains the "status is never color-only" invariant.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-08 21:07:10 +01:00
Bernardo Magri
850dc310df added skills
All checks were successful
Check / eval (push) Successful in 3m0s
2026-07-08 20:50:14 +01:00
Bernardo Magri
565f66372a docs(migration): guide for migrating an existing NixOS machine to Nomarchy
Some checks failed
Check / eval (push) Has been cancelled
Bernardo asked whether TuringMachine (NixOS 25.11) could adopt Nomarchy
without a reinstall, keeping all files. It can — it's a config switch that
reuses the existing hardware-configuration.nix, not a reformat. Adds
docs/MIGRATION.md: a generic "existing NixOS → Nomarchy" walkthrough with
TuringMachine as the worked example — candidacy checklist, the two
data-safety rules (never bump system.stateVersion; /home untouched), a
reversible Phase 0→4 (btrfs snapshot → build-only gate → nixos-rebuild
test → reconcile → cutover) and a triple rollback net. Bakes his decisions:
Nomarchy PPD (drop all ryzenadj), no Secure Boot (drop lanzaboote), full
cutover. Flake/system/home examples verified against lib.nix mkFlake +
templates/downstream, not guessed.

Also files a PROPOSED note: home.stateVersion should be mkDefault so
migrating users don't need mkForce.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-08 20:48:33 +01:00
Bernardo Magri
be8d8a7d9b feat(waybar): open a calendar (calcurse) on the clock click
All checks were successful
Check / eval (push) Successful in 2m54s
Item 42, de-scoped by Bernardo to app-only — no account sync (the user
configures that themselves locally). Clicking the Waybar date/clock now
runs nomarchy-calendar → calcurse in a floating, centered ghostty window
tagged --class=com.nomarchy.calendar (+ --gtk-single-instance=false for a
fresh standalone window), matched by a float+size+center windowrule. The
launcher self-gates on calcurse. Dropped the clock's format-alt date
toggle (conflicts with on-click) and moved the long date into the tooltip.
Parity: on-click added to all four whole-swap jsoncs.

Ships calcurse + pwvucontrol uncommented in the downstream template
(opt-out). Enforces ghostty always installed (ghostty.nix: enable = true
unconditional; nomarchy.ghostty.enable now gates only the theming/config)
so the --class launcher can rely on it.

Verified: V0 green; four jsoncs valid JSON; V1 HM built — launcher ships,
generated clock renders on-click=nomarchy-calendar (format-alt gone, date
in tooltip), hyprland has the 3 calendar windowrules, ghostty in the HM
profile. Click→float→calcurse month view is V3 (queued). Sync is
deliberately the user's own config, not a distro feature.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-08 20:19:30 +01:00
Bernardo Magri
2954283e23 docs(backlog): add item 42 — synced system calendar on the clock click
All checks were successful
Check / eval (push) Successful in 3m2s
Bernardo wants clicking the Waybar date module to open a calendar showing
events synced from the user's accounts, opening instantly (not Thunderbird-
slow). Investigated (2026-07-08): recommend the split model — khal (TUI in
a floating, palette-themed ghostty window) + vdirsyncer for CalDAV/Google
sync — over calcurse (local-first, weaker sync) or GNOME Calendar (GUI,
best Google onboarding but not instant). Flags the two design wrinkles:
credentials are the one legit out-of-flake secret, and Google OAuth needs a
self-registered client_id. Placed in NEXT as [big] with slices (a) viewer
+ click wiring, (b) sync + accounts, (c) events-in-tooltip.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-08 14:56:47 +01:00
Bernardo Magri
ce83a8e655 docs(backlog): record PPD-vs-TLP default power backend as a Decision
All checks were successful
Check / eval (push) Successful in 3m13s
Bernardo asked whether TLP would be more power-efficient and worth making
the default. Logged in § Decisions with the evidence gathered 2026-07-08:
TLP's gain is idle-only and unquantified (its own maintainer won't cite
numbers), the sibling distro Omarchy tried TLP-for-battery and reverted to
PPD, and TLP-default would rework the powerprofilesctl-based profile UX.
Agent recommendation: keep PPD, chase battery via targeted low-risk tweaks
+ keep TLP the one-line opt-in. Decision itself stays with Bernardo.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-08 14:52:40 +01:00
Bernardo Magri
1143e67f95 feat(waybar): combined power menu on the battery + profile icons
All checks were successful
Check / eval (push) Successful in 3m5s
Item 36(b). Adds `nomarchy-menu powermgmt` — one flat rofi menu of power
Profile rows (→ powerprofilesctl set) + Charge limit rows (→ the same
theme-sync writer batterylimit uses), each half self-gating on its
hardware. No backend duplication; Custom charge stays in the dedicated
batterylimit picker.

Opened two ways (Bernardo's call): the Waybar battery icon AND the
power-profile icon — the profile icon's click flips from cycle → menu.
Wired in waybar.nix and all four whole-swaps (parity). Named `powermgmt`
to avoid the whole-swaps' existing `custom/powermenu` shutdown button
(→ nomarchy-menu power), which is left untouched. The granular System ▸
Power profile / Battery limit rows stay ("keep both").
nomarchy-powerprofile-cycle kept as a standalone bin, now unbound by
default.

Verified: V0 green; V1 HM built (bash -n) — powermgmt case ships, the
generated waybar-config.json wires both icons (2 hits), all four jsoncs
re-wired and still parse, shutdown buttons intact. Click→menu→apply is
V3 (queued).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-08 14:40:40 +01:00
Bernardo Magri
714fbd1daf feat(rofi): show the Print-key hints on the Capture menu rows
All checks were successful
Check / eval (push) Successful in 3m2s
Iteration #66 (direct request: "make sure menu items that have a keybind
display it"). Audit found every SUPER(+CTRL) module-opening row already
hinted (item 28c); the one gap was Tools ▸ Capture — its Region/Full
screen rows run the exact grim commands of the Print / SHIFT+Print /
CTRL+Print binds but showed nothing. Adds `hintForBind mods key` (matches
keybinds.nix by mods+key, so no drift and no brittle full-action compare)
and `-markup-rows` on the Capture menu so the spans render.

Verified: V0 green; V1 HM built — shipped nomarchy-menu shows
`Region → clipboard {{Print}}`, `Region → file {{SHIFT + Print}}`,
`Full screen → file {{CTRL + Print}}`. Substring case-matches precede the
appended span, so dispatch is unchanged. V3 (pango dim render) queued.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-08 14:16:45 +01:00
Bernardo Magri
b18980f642 docs(backlog): fold Bernardo's power-menu mockup into item 36(b)
He supplied a reference `power-menu` rofi script (battery charge + power
profile rows, icon protocol, notify-send). Un-gates the design hold on
36(b): records the shape + the Nomarchy adaptation (reuse the existing
batterylimit/power-profile writers instead of raw TLP; wire the Waybar
battery module on-click). One [human] call left: replace the separate
System rows or keep both surfaces.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-08 14:16:45 +01:00
Bernardo Magri
b1cf10ff06 fix(keybinds): SUPER+? bind uses base keysym slash, not question
All checks were successful
Check / eval (push) Successful in 3m10s
Item 32. Item 26 added SHIFT to the modmask but kept the shifted keysym
`question`, so the bind stayed dead. Hyprland 0.55 resolves a bind's
keysym with SHIFT consumed (issue #7750, behaviour since 0.43): with
Shift held, `$mod SHIFT, question` is matched against `slash` and never
fires. The base keysym is what works — same pattern as this file's own
`$mod SHIFT, 1` workspace binds. Also updates rofi.nix prettyKeys to
collapse "SHIFT + /" → "?" so the cheatsheet row still reads "SUPER + ?".

Verified: V0 `nix flake check --no-build` green; V1 HM activation built,
built hyprland.conf carries `bind=$mod SHIFT, slash, exec, nomarchy-menu
keybinds` (kb_layout=us → slash is `?`). The keypress opening the menu is
V3 — queued (HARDWARE-QUEUE, existing SUPER+? entry updated).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-08 13:59:02 +01:00
60c6f14c08 feat(rofi): battery charge-limit toggle in the System menu
All checks were successful
Check / eval (push) Successful in 2m54s
Add a `batterylimit` nomarchy-menu subcommand — a preset picker
(80/90/60/Off/Custom…) that writes settings.power.batteryChargeLimit via
theme-sync (--no-switch; lands on the next sys-rebuild, power.nix's oneshot
applies it). Surfaced as a first-class System row, self-gated on the
charge_control_end_threshold sysfs node like Power profile.

Moved out of the gum control-center (removed its Battery Limit case +
choose entry) so the toggle lives in one place — the rofi menu Bernardo
actually uses, per his request.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-07 22:50:26 +01:00
a8391c381d feat(hyprland): float+center Bluetooth + printer config dialogs
All checks were successful
Check / eval (push) Successful in 3m8s
Conservative first cut of the floating-window audit (item 41): float and
center the two shipped, unambiguous config dialogs — blueman
(manager/adapters) and system-config-printer — alongside the mixer. Uses
(?i) class regexes tolerant of the XWayland `.…-wrapped` form, consistent
with the existing (?i) usage in this file.

Deliberately conservative: network is dmenu/nmtui (no GUI editor), and the
polkit prompt + GTK file portals are deferred because their window class
can't be verified headlessly — a guessed class is a dead rule. Those wait
for a hardware `hyprctl clients` read (queued).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-07 22:39:43 +01:00
28a28e05d3 feat(waybar): right-click volume opens a floating pwvucontrol mixer
All checks were successful
Check / eval (push) Successful in 2m55s
Right-clicking the Waybar volume module now opens pwvucontrol (per-app
mixer); left-click still mutes. Adds:

- on-click-right in waybar.nix + all four whole-swap jsoncs (parity)
- hyprland.nix: the config's first windowrule block — float + center the
  mixer (pwvucontrol, with pavucontrol as a fallback class); normal-
  priority list so a downstream windowrule concatenates
- pwvucontrol in the template home.packages

pwvucontrol is PipeWire-native + GTK4/libadwaita, so it themes via the
portal color-scheme like Amberol. app-id verified from the built package.
This settles the windowrule pattern/placement; item 41 is now just
broadening the float set. Right-click→float→center is a V3 session check.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-07 22:31:00 +01:00
239c3c4551 docs(backlog): gate item 35 on 41 (no windowrule infra yet); rec pwvucontrol
All checks were successful
Check / eval (push) Successful in 2m59s
Investigating the right-click mixer showed the config has zero
windowrules today, so the float rule needs the infrastructure item 41 is
meant to design list-first. Recorded the gate + the mixer recommendation
(pwvucontrol, app-id com.saivert.pwvucontrol) so the research is not lost.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-07 22:18:51 +01:00
92b3c1a1e3 feat(keybinds): move window focus to SUPER+arrows
All checks were successful
Check / eval (push) Successful in 3m4s
Swap the vim-style SUPER+H/J/K/L focus binds for SUPER+arrow keys
(movefocus l/r/u/d). SUPER+arrows were unbound and there are no vim
move-window binds, so no collision. prettyKeys renders the arrow keysyms
as glyphs so the SUPER+? cheatsheet shows "SUPER + <-" not "SUPER + left".

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-07 22:07:21 +01:00
8961fd6936 docs(backlog): scope item 33 (rofi scrollbar) as a ~10-file visual slice
All checks were successful
Check / eval (push) Successful in 3m2s
Found every authored .rasi deliberately hides the scrollbar (width:0), so
the overflow hint needs per-theme tuning across ~10 files with a V3-only
payoff — recorded so it is picked up in a themed session, not blind.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-07 22:03:36 +01:00
1928cd94f6 feat(mime): open audio files in Amberol, not mpv
All checks were successful
Check / eval (push) Successful in 2m59s
mpv (video) and amberol (audio) are both already in the template suite;
the gap was mime wiring — audio/* still resolved to mpv.desktop. Repoint
the audio types to io.bassi.Amberol.desktop (id verified against the
built package) and broaden coverage to mp3/flac/ogg/opus/wav/m4a/aac,
both canonical and x- names. Video stays on mpv. Degrades to mpv if a
downstream drops Amberol from the suite.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-07 21:59:35 +01:00
21c0c58ea2 feat(keybinds): capture region/screen to file
All checks were successful
Check / eval (push) Successful in 2m54s
Add SHIFT+Print (region → file) and CTRL+Print (screen → file) next to
the existing bare Print (region → clipboard). Each saves a timestamped
PNG under ~/Pictures/Screenshots and toasts the path — the same grim/slurp
plumbing and directory the Capture menu's "→ file" rows already use.
Single-source keybinds.nix, so the SUPER+? cheatsheet lists both new rows.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-07 21:55:31 +01:00
926be22fd4 fix(rofi): normal sorter for fuzzy search + power-profile icons
All checks were successful
Check / eval (push) Successful in 3m8s
Two rofi fixes:

- Search (item 31): rofi 2.0.0's fzf sorter mis-ranks fuzzy matches —
  typing "steam" left ghostty selected instead of Steam. Bernardo's live
  A/B confirmed `-sorting-method normal` and `-matching normal` both fix
  it while `-no-sort` does not. Switch sorting-method fzf → normal, keep
  matching=fuzzy + case-sensitive=false.

- Power profile menu (item 39): list profiles with per-profile icons from
  Papirus' colored battery-profile-{performance,balanced,powersave} family
  (the -symbolic variants are #444 grey, invisible on dark). Row text
  stays the bare profile name so `powerprofilesctl set` is unaffected.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-07 21:51:07 +01:00
cd5d3b51e2 fix(rofi): Display menu Back returns to System on single-monitor
All checks were successful
Check / eval (push) Successful in 3m0s
The mode picker's Back did `exec "$0" display`. On a single-monitor
machine with no display profiles the output chooser is skipped, so
re-running `display` dropped straight back into the same mode picker — an
apparent no-op. Track modeBack (display when the chooser was shown, else
system) and dispatch Back to it, so single-monitor Back leaves Display.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-07 21:45:11 +01:00
6c61b51d55 fix(rofi): drop the go-previous icon from menu Back rows
Some checks failed
Check / eval (push) Has been cancelled
Every submenu's "↩ Back" row rendered two arrows: the ↩ glyph in the
label plus a themed go-previous icon. back() now emits the plain label
only — identical to what pick-lists already append — so Back shows a
single glyph everywhere. BACK="↩ Back" stays the universal label/match,
so no case-branch comparison changes.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-07 21:42:56 +01:00
28cbaf6f5e docs(backlog): queue 11 rofi/menu/power items from Bernardo (31–41)
All checks were successful
Check / eval (push) Successful in 3m5s
Bugs: 31 rofi search broken under rofi 2.0.0 (lock bump 8fded63 pulled a
major version; case-insensitive fuzzy is configured + honoured yet
matching behaves case-sensitively and Enter launches row 0), 32 SUPER+?
still not opening, 34 Back row shows double arrows, 40 Display menu Back
doesn't return. Plus 36 battery/power quick menu (move the threshold
toggle into the rofi System submenu + a battery-icon power menu, mockup
pending), 39 power-profile menu icons. Features (NEXT tail): 33 rofi
page-overflow scrollbar, 35 right-click volume → mixer, 37 default
media players + mime, 38 capture-to-file binds, 41 floating-window audit.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-07 21:36:19 +01:00
d078ba2a82 feat(control-center): battery charge-limit as a preset toggle
All checks were successful
Check / eval (push) Successful in 3m10s
The "Battery Limit" entry was a raw number prompt; make it a proper
toggle picker — Off / 80% (recommended) / 90% / 60% / Custom… — writing
settings.power.batteryChargeLimit. Kept in the control-center, where the
baked rebuild-valued System Toggles live (the rofi nomarchy-menu is the
instant surface; the CC holds config values), and reachable from the menu
via System › Control Center. Base machinery (the sysfs oneshot + AC-replug
udev in power.nix) is unchanged, so it lands on the next sys-rebuild like
its siblings.

Instant-effect (no rebuild) is refiled in BACKLOG § PROPOSED as
[blocked:hw]: it needs a udev-writable threshold node, confirmable only on
a laptop with the control.

Verified: V0 (bash -n; nix flake check --no-build, green) + V1
(nomarchy-control-center package builds — the writeShellApplication
shellcheck gate passes). Session spot-check queued in HARDWARE-QUEUE.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-07 19:06:15 +01:00
ce89fddab6 docs(backlog): propose a quick toggle for the battery charge threshold
All checks were successful
Check / eval (push) Successful in 3m4s
Raised by Bernardo. The charge-limit base already ships (batteryChargeLimit
option + systemd sysfs writer + udev re-trigger + control-center entry +
VM check); the gap is UX — it's a type-a-number system option needing a
rebuild. Filed under PROPOSED as a one-click conservation-mode toggle
(~80% ↔ 100%), ideally instant-effect, referencing the existing mechanism
so the base isn't re-implemented.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-07 18:52:52 +01:00
cfecb612a6 fix(waybar): drop the bar to the bottom layer so fullscreen covers it
Some checks failed
Check / eval (push) Has been cancelled
On Hyprland the `top` layer renders above every window, including a real
fullscreen surface — so a browser video gone fullscreen sat *under* the
bar (BACKLOG item 30). Move the bar to `layer: bottom` in the generated
waybar.nix and all four whole-swap jsoncs (parity rule); the exclusive
zone still reserves its space in normal tiling, but a fullscreen window
now covers it. Rationale comment kept in waybar.nix; data jsoncs left
comment-free (they carry none).

Verified: V0 (nix flake check --no-build, green) + V1
(homeConfigurations.nomarchy.activationPackage builds; built
.config/waybar/config shows "layer": "bottom"). V3 queued in
HARDWARE-QUEUE (fullscreen covers bar + returns on exit; floating-overlap
trade-off).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-07 18:51:38 +01:00
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
190 changed files with 17471 additions and 1465 deletions

View File

@@ -0,0 +1,28 @@
---
name: nomarchy-runner
description: Mechanical execution of the Nomarchy headless test harness. Use PROACTIVELY when a build or VM run just needs to be executed and its artifacts collected — nix build, tools/test-live-iso.sh, tools/test-install.sh, screenshot capture via tools/vm/vncshot.py and tools/vm/qmp.py, and the scripted checks (check-theme-contrast.py, audit-theme-design.py, check-option-docs.py). Never use for interpreting ambiguous failures, judging visual quality, or deciding what to test.
tools: Read, Bash, Glob, Grep
model: haiku
---
You are a deterministic test executor for the Nomarchy repository. You run
exactly the commands you are asked to run, headlessly, and hand back the
evidence. You do not decide what to test, do not interpret ambiguous
results, and do not judge whether something "looks fine".
Rules:
- Run everything headless and unattended. Never launch a graphical VM
window; never wait on human input. Wrap long-running commands in
timeouts as instructed by the caller, and treat a timeout as a recorded
failure, not something to silently retry.
- Collect artifacts to the working directory the caller specifies: full
command lines, exit codes, the tail of stdout/stderr (plus the complete
logs as files), serial console output, and screenshot file paths.
- Report format: for each command — command, exit code, wall time,
artifact paths, and verbatim error lines if the exit code was nonzero.
Nothing else. No summaries of what the results "mean".
- Never mark anything as passed or verified. You return evidence; the
caller makes the verification claim.
- If a command fails in a way that prevents collecting artifacts, report
exactly what happened and stop — do not improvise recovery steps beyond
what the caller authorized.

View File

@@ -0,0 +1,20 @@
---
name: nomarchy-scout
description: Read-only reconnaissance inside the Nomarchy repo. Use PROACTIVELY for any task that is pure information gathering — locating where an option/module/theme is defined, mapping which files touch a subsystem, scanning build logs or serial-console output for errors, checking docs against code for drift, summarizing a directory. Never use for anything requiring judgment about design, correctness, or visual quality.
tools: Read, Grep, Glob
model: haiku
---
You are a fast, cheap scout for the Nomarchy NixOS distribution repository.
Your only job is to find things and report facts. You never edit, never
judge design quality, and never draw conclusions beyond what the files
literally say.
Rules:
- Report file paths and line numbers, quote the minimum relevant snippet.
- If asked to scan logs, extract the error/warning lines verbatim with
enough surrounding context to locate them, and note timestamps.
- If you cannot find something, say so plainly — never guess or infer that
something "probably" exists.
- Keep output terse and structured: the caller pays for every token you
produce. Facts first, no prose padding, no recommendations.

88
.claude/settings.json Normal file
View File

@@ -0,0 +1,88 @@
{
"permissions": {
"allow": [
"Read",
"Edit",
"Write",
"Glob",
"Grep",
"Bash(ls *)",
"Bash(cat *)",
"Bash(head *)",
"Bash(tail *)",
"Bash(grep *)",
"Bash(rg *)",
"Bash(find *)",
"Bash(tree *)",
"Bash(wc *)",
"Bash(file *)",
"Bash(stat *)",
"Bash(du *)",
"Bash(df *)",
"Bash(which *)",
"Bash(diff *)",
"Bash(jq *)",
"Bash(sha256sum *)",
"Bash(mkdir *)",
"Bash(cp *)",
"Bash(mv *)",
"Bash(touch *)",
"Bash(ln -s *)",
"Bash(tar *)",
"Bash(nix *)",
"Bash(nix-store *)",
"Bash(nix-instantiate *)",
"Bash(./tools/test-live-iso.sh *)",
"Bash(./tools/test-install.sh *)",
"Bash(./tools/audit-theme-design.py *)",
"Bash(./tools/check-option-docs.py *)",
"Bash(./tools/check-theme-contrast.py *)",
"Bash(./tools/import-palettes.py *)",
"Bash(./tools/vm/qmp.py *)",
"Bash(./tools/vm/vncshot.py *)",
"Bash(./tools/vm/gap-analysis.py *)",
"Bash(python3 tools/*)",
"Bash(bash tools/*)",
"Bash(qemu-system-x86_64 *)",
"Bash(qemu-img *)",
"Bash(git status *)",
"Bash(git diff *)",
"Bash(git log *)",
"Bash(git show *)",
"Bash(git add *)",
"Bash(git commit *)",
"Bash(git branch *)",
"Bash(git switch *)",
"Bash(git checkout *)",
"Bash(git restore *)",
"Bash(git stash *)",
"Bash(git worktree *)",
"Bash(systemctl --user status *)",
"Bash(journalctl --user *)",
"Bash(pgrep *)",
"Bash(pkill -f qemu*)"
],
"ask": [
"Bash(git push *)",
"Bash(rm *)",
"Bash(git reset --hard *)",
"Bash(git clean *)",
"Bash(nix-collect-garbage *)",
"Bash(curl *)",
"Bash(wget *)"
],
"deny": [
"Bash(sudo *)",
"Bash(git push --force *)",
"Bash(git push -f *)"
],
"defaultMode": "default"
}
}

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

@@ -0,0 +1,97 @@
# 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` (eval tier) followed by a V1 build
# (home-manager activation package + nixos toplevel) to catch compilation errors.
# (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 and compilation, 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: Gate — V1 build (toplevel + home-manager)
if: steps.update.outputs.changed == '1'
run: |
nix build .#homeConfigurations.nomarchy.activationPackage --no-link
nix build .#nixosConfigurations.nomarchy.config.system.build.toplevel --no-link
- 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

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

@@ -0,0 +1,102 @@
# 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 (all tracked scripts)
# Every tracked *.py — theme-sync, the installer composers
# (compose-lock, patch-template) and the maintainer tools
# (tools/, tools/vm/). Via nix shell so the step doesn't depend on
# the runner image preinstalling python3.
run: |
nix shell nixpkgs#python3 --command \
bash -c 'git ls-files "*.py" | xargs -r python3 -m py_compile'
- 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 — FUTURE #20, not NEXT) ─────────────────────
# Eval-only CI is the standing decision: Gitea act_runner stays docker
# compose (no /dev/kvm). Full checks.* VMs need a *separate* host with
# nix + KVM (label nix-kvm); see agent/BACKLOG.md FUTURE #20. Do NOT
# uncomment until that label is online — Gitea queues forever otherwise.
#
# 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

4
.gitignore vendored
View File

@@ -1,7 +1,3 @@
# Study material from the previous iteration — not part of the flake.
# Delete the directory once everything worth porting has been ported.
old_distro/
# Nix build artifacts # Nix build artifacts
result result
result-* result-*

65
AGENTS.md Normal file
View File

@@ -0,0 +1,65 @@
# 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.
This file is the entry point for **any** AI coding agent, whatever the
vendor or harness. Everything agents need lives in vendor-neutral,
git-tracked markdown (`agent/`, `docs/`); harness-specific config is a
thin *adapter* (see bottom) and never holds shared content.
## Where things live (read this first)
| Path | Role |
|------|------|
| **`agent/`** | Agent instructions + loop state — **only executable queue is `BACKLOG.md`** |
| **`agent/README.md`** | Map of the agent files |
| **`docs/VISION.md`** | Product themes toward **v1.0** (not a queue) |
| **`docs/ROADMAP.md`** | Design history + shipped log |
| **`docs/README.md`** | Full documentation map |
| **`.claude/`** | Claude Code adapter only (permissions, subagent defs) — not the backlog |
## If you're here to work autonomously (the loop)
Follow **`agent/LOOP.md`** — one iteration: orient → pick one BACKLOG
item → work → verify → commit+push on `main` → record. All loop state is
git-tracked in `agent/`.
## Rules that apply to every session, loop or not
- **Before your first change, read `agent/VERIFICATION.md`** and follow
it — every change, however small, climbs its V0V3 ladder. For theme
or visual work, also read `agent/THEME-DESIGN.md`; before any VM test,
`docs/TESTING.md`.
- **Honesty rule** (`agent/VERIFICATION.md`, `docs/TESTING.md`): for
visual/interactive changes, evaluation is not rendering — state the
tier you reached. Cheap first: `nix flake check --no-build`.
- **Conventions** (`agent/CONVENTIONS.md`): in-flake state, menu
placement, Waybar parity with whole-swaps, toggle-vs-package discipline,
template as SoT for opt-in comments (`templates/downstream`).
- **Sync sweep on completion** (`agent/LOOP.md` §5): when a task ships,
update or delete every cross-reference it made stale — BACKLOG
pitches/pointers, HARDWARE-QUEUE entries, docs — in the same commit.
No stale items, no unsynced information.
- **Git:** direct commits on `main`, pushed; **`v1` is human-only** —
never touch it. Never `nix flake update` unless the task is a lock bump.
No formatter — match aligned hand-formatting.
- Layout: `hosts/` machine · `modules/` distro · `themes/` data ·
`pkgs/` code · `tools/` maintainer · `agent/` loop · `docs/` human docs.
## Delegation & escalation
Push mechanical work down to cheaper models; keep judgment on the
strongest model available. Capability tiers, standing roles (scout /
runner), and per-harness model mappings: **`agent/DELEGATION.md`**.
Work above your tier? Return it — don't burn tokens.
## Harness adapters
One directory per harness, holding only what that harness *requires* in
its own format (permissions, subagent/skill registration). Adapters point
into `agent/`; they never carry policy, queue items, or vision text.
| Harness | Adapter |
|---------|---------|
| Claude Code | `.claude/``CLAUDE.md` is a symlink to this file |
| others | add a sibling dir + a row here; keep it a thin shim |

1
CLAUDE.md Symbolic link
View File

@@ -0,0 +1 @@
AGENTS.md

217
README.md
View File

@@ -36,7 +36,7 @@ Flat on purpose. Two module trees, one options file each, no hidden layers.
├── flake.nix # inputs + the downstream API (exports below) ├── flake.nix # inputs + the downstream API (exports below)
├── lib.nix # nomarchy.lib.mkFlake — one-call downstream wrapper ├── lib.nix # nomarchy.lib.mkFlake — one-call downstream wrapper
├── theme-state.json # ★ THE single source of truth (git-tracked!) ├── theme-state.json # ★ THE single source of truth (git-tracked!)
├── themes/ # 21 presets: <slug>.json + optional <slug>/ assets ├── themes/ # 24 presets: <slug>.json + optional <slug>/ assets
│ ├── nord.json # palette (required, works alone) │ ├── nord.json # palette (required, works alone)
│ └── nord/ # assets (optional, fixed filenames) │ └── nord/ # assets (optional, fixed filenames)
│ ├── backgrounds/ # wallpapers (auto-picked, SUPER+SHIFT+T cycles) │ ├── backgrounds/ # wallpapers (auto-picked, SUPER+SHIFT+T cycles)
@@ -62,6 +62,7 @@ Flat on purpose. Two module trees, one options file each, no hidden layers.
│ ├── keybinds.nix # single source: Hyprland binds + SUPER+? sheet │ ├── keybinds.nix # single source: Hyprland binds + SUPER+? sheet
│ ├── swaync.nix # notifications, same JSON │ ├── swaync.nix # notifications, same JSON
│ ├── idle.nix # hyprlock + hypridle, same JSON │ ├── idle.nix # hyprlock + hypridle, same JSON
│ ├── battery-notify.nix # low-battery toasts at the bar's thresholds
│ ├── yazi.nix # flagship TUI file manager + plugins │ ├── yazi.nix # flagship TUI file manager + plugins
│ ├── osd.nix # swayosd volume/brightness OSD │ ├── osd.nix # swayosd volume/brightness OSD
│ ├── shell.nix # zsh + starship + bat/eza/zoxide │ ├── shell.nix # zsh + starship + bat/eza/zoxide
@@ -72,11 +73,20 @@ Flat on purpose. Two module trees, one options file each, no hidden layers.
│ └── live.nix # bootable live ISO (try the distro, no install) │ └── live.nix # bootable live ISO (try the distro, no install)
├── pkgs/ ├── pkgs/
│ ├── nomarchy-theme-sync/ # state writer + rebuild dispatcher (Python) │ ├── nomarchy-theme-sync/ # state writer + rebuild dispatcher (Python)
── nomarchy-install/ # live-ISO installer (gum + disko + mkFlake) ── nomarchy-install/ # live-ISO installer (gum + disko + mkFlake)
├── templates/downstream/ # `nix flake init -t` starter for users │ ├── nomarchy-doctor/ # read-only health sheet, one command per failure
├── docs/TESTING.md # how to verify changes (incl. AI-agent rules) │ ├── nomarchy-control-center/ # TUI settings front-end over nomarchy-theme-sync (gum)
├── docs/OVERRIDES.md # how downstream users override defaults │ └── nomarchy-battery-notify/ # low-battery toast watcher backing battery-notify.nix
├── docs/ROADMAP.md # forward-looking plans + shipped-fixes log ├── templates/downstream/ # machine flake SoT (`flake init` + installer copy/patch)
├── docs/ # human docs — map: docs/README.md
│ ├── VISION.md # product themes toward v1.0 (not a queue)
│ ├── ROADMAP.md # design/decision records + shipped log
│ ├── HARDWARE.md # firmware, profiles, drivers
│ ├── TESTING.md · RECOVERY.md · OVERRIDES.md · MIGRATION.md
├── agent/ # agent instructions + loop state — map: agent/README.md
│ # BACKLOG (executable queue), LOOP, VERIFICATION, …
├── AGENTS.md # agent entry, any vendor (CLAUDE.md symlinks here)
├── .claude/ # Claude Code adapter: permissions + subagent defs
└── 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
@@ -109,7 +119,8 @@ Like what you see? **`nomarchy-install`** (in a terminal) walks you through
installing to disk: pick a disk, LUKS2 full-disk encryption **by default** installing to disk: pick a disk, LUKS2 full-disk encryption **by default**
(in exchange the desktop logs in passwordless — the passphrase already (in exchange the desktop logs in passwordless — the passphrase already
gates the machine), user + hostname + timezone, hardware autodetection gates the machine), user + hostname + timezone, hardware autodetection
(DMI → nixos-hardware profile), a hibernation-ready swapfile sized to RAM, (DMI → nixos-hardware profile — see **[docs/HARDWARE.md](docs/HARDWARE.md)**),
a hibernation-ready swapfile sized to RAM,
then disko partitions (GPT + ESP + BTRFS subvolumes incl. `@snapshots` then disko partitions (GPT + ESP + BTRFS subvolumes incl. `@snapshots`
snapper timeline snapshots are on) and `nixos-install` runs — **without a snapper timeline snapshots are on) and `nixos-install` runs — **without a
network** when the ISO was built from a clean tree (the target's network** when the ISO was built from a clean tree (the target's
@@ -128,6 +139,16 @@ 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.)
Already running NixOS on this machine? No reinstall needed — adopt
Nomarchy in place, reusing your existing `hardware-configuration.nix`:
**[docs/MIGRATION.md](docs/MIGRATION.md)**.
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:
@@ -161,20 +182,35 @@ home-manager switch --flake .#me # desktop: every theme change, no
Day-to-day you'll use the shipped shortcuts instead: Day-to-day you'll use the shipped shortcuts instead:
```sh ```sh
sys-update # nix flake update + system rebuild (BTRFS snapshot first when available) nomarchy-pull # nix flake update — refresh inputs (nomarchy, nixpkgs, …);
home-update # home-manager switch (no flake update, no sudo) # optional git pull only if ~/.nomarchy tracks a remote
nomarchy-rebuild # system rebuild against the CURRENT lock (sudo inside;
# BTRFS snapshot first when snapper is on; nvd diff after)
nomarchy-home # home-manager switch — desktop only, no sudo, no lock bump
``` ```
**Order matters when pulling distro updates.** `home-update` does *not* `~/.nomarchy` is **your machine flake** (`system.nix` / `home.nix`). Newer
touch the lock — it rebuilds the desktop against the **current** Nomarchy code arrives when `flake.lock` updates the `nomarchy` input
`flake.lock`. A new Nomarchy revision (new keybinds, theming, modules) (`nomarchy-pull`), not by `git pull` of that directory (many installs have
arrives only when the lock is updated, which `sys-update` does no remote there — only local commits from auto-commit).
(`nix flake update`). So to pull an update that affects the desktop layer:
run `sys-update` **first** (updates the lock + rebuilds the system), **then** | When | Run |
`home-update` (re-applies the desktop against the new lock). Doing them in |---|---|
the other order rebuilds the desktop against the *old* inputs and silently | Pull newer Nomarchy / nixpkgs / inputs | `nomarchy-pull` |
skips the new home-side changes. After a home-side keybind/config change, | You changed `system.nix` (or after pull) | `nomarchy-rebuild` |
also `hyprctl reload` (or relogin) so the running session re-reads it. | You changed `home.nix` / theme / desktop | `nomarchy-home` |
| Full upgrade (inputs + both layers) | `nomarchy-pull && nomarchy-rebuild && nomarchy-home` |
**Order matters for distro updates.** `nomarchy-home` rebuilds against the
**current** `flake.lock` — it never updates inputs. A new Nomarchy revision
lands only when the lock is updated (`nomarchy-pull`). So for an upstream
desktop change: **pull → rebuild → home**. Home before pull rebuilds against
the old inputs and silently skips new home-side changes. After a home-side
keybind/config change, also `hyprctl reload` (or relogin) so the session
re-reads it.
Legacy aliases still work: `sys-update` → pull+rebuild, `sys-rebuild`
`nomarchy-rebuild`, `home-update``nomarchy-home`.
Override anything via the `nomarchy.*` surface or plain NixOS/HM options: Override anything via the `nomarchy.*` surface or plain NixOS/HM options:
appearance (gaps/colors/fonts) changes through `nomarchy-theme-sync`, appearance (gaps/colors/fonts) changes through `nomarchy-theme-sync`,
@@ -182,6 +218,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,12 +233,14 @@ 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; set BOTH `.latitude`/`.longitude` for **geo mode** (wlsunset computes sunrise/sunset from your location; the fixed times are ignored); 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.idle.enable` | `true` | hyprlock + hypridle (idle lock 5 min, display off 10, suspend 30) | | `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 15 min — battery-only) |
| `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 |
| `nomarchy.shell.enable` | `true` | zsh + starship prompt + bat/eza/zoxide (zsh is the default login shell) | | `nomarchy.shell.enable` | `true` | zsh + starship prompt + bat/eza/zoxide (zsh is the default login shell) |
@@ -205,21 +250,52 @@ 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 instantly (driven by a dedicated Hyprland IPC socket watcher, no polling). 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 |
**Always-on, no toggle by design:** `services.cliphist`, `services.udiskie`
(automount + safe-removal toasts) and `services.easyeffects` (mic noise
cancellation) ship unconditionally — small, low-risk pieces with no
Nomarchy-specific config behind them (the toggle-vs-package rule in
`agent/CONVENTIONS.md`). Disable one from `home.nix` with the plain HM
option, e.g. `services.easyeffects.enable = lib.mkForce false;` — see
**[docs/OVERRIDES.md](docs/OVERRIDES.md)**.
**`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.services.tailscale.enable` | `false` | Opt-in: Tailscale mesh VPN (then `sudo tailscale up`) | | `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's **v4l2** path 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. Does **not** hide IR from libcamera / portal / Flatpak pickers — see [HARDWARE.md §7](docs/HARDWARE.md) |
| `nomarchy.hardware.i2c.enable` | `false` | I2C devices support — access to `/dev/i2c-*` (RGB controllers, sensors, DDC/CI monitor control) |
| `nomarchy.hardware.i2c.ddcci` | `false` (distro default: **`true`**) | the ddcci-driver kernel module, exposing external monitors as standard backlight devices via DDC/CI so brightness keys and swayosd natively control them |
| `nomarchy.services.tailscale.enable` | `false` | Opt-in: Tailscale mesh VPN — the login user is made the operator, so `tailscale up/down/set` and the System VPN menu work without sudo |
| `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) |
| `nomarchy.services.flatpak.enable` | `false` | Opt-in: Flatpak + the Flathub remote | | `nomarchy.services.flatpak.enable` | `false` | Opt-in: Flatpak + the Flathub remote |
@@ -235,14 +311,33 @@ 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) and enforces
calling out: **`services.fwupd.enable`** is on by default for firmware baseline security/stability defaults. A few worth calling out:
**`security.apparmor.enable`** is on by default for mandatory access
control confinement. **Kernel panics** are configured to auto-reboot the
machine after 10 seconds (`panic=10 oops=panic`) rather than hanging
indefinitely. **`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). Full hardware
story (profiles, fingerprint, NVIDIA, unsupported machines, contributing
a DMI line): **[docs/HARDWARE.md](docs/HARDWARE.md)**. 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;`.
**`zramSwap`** is also on by default (zstd, 50% of RAM, priority 100):
day-to-day memory pressure pages into compressed RAM instead of the
disk, keeping the disk swapfile free for **hibernation** — the installer
creates a RAM-sized encrypted swapfile and wires `resume`, so
Power Hibernate works out of the box (swap size 0 at install = no
swap). Opt out with `zramSwap.enable = false;`; to add hibernation to a
machine installed before it existed, follow
**[docs/MIGRATION.md](docs/MIGRATION.md)** § Enabling hibernation.
## 4. How theming works ## 4. How theming works
@@ -271,6 +366,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
@@ -284,29 +404,35 @@ per theme — a single place to look, unlike the old distro's split:
| `waybar.jsonc` | whole-swap for the bar *layout* (must be plain JSON) | | `waybar.jsonc` | whole-swap for the bar *layout* (must be plain JSON) |
| `rofi.rasi` | **whole-swap**: replaces the generated launcher/menu theme entirely | | `rofi.rasi` | **whole-swap**: replaces the generated launcher/menu theme entirely |
Six ported themes ship a `waybar.css` identity (catppuccin, lumon, nord, Four themes ship a `waybar.css` identity (summer-day, summer-night,
retro-82, summer-day, summer-night). Custom user themes can live in executive-slate, boreal). Custom user themes can live in
`$NOMARCHY_PATH/themes/` (preset lookup) and `nomarchy.themesDir` (eval-time `$NOMARCHY_PATH/themes/` (preset lookup) and `nomarchy.themesDir` (eval-time
asset probe). asset probe).
## 5. Day-to-day ## 5. Day-to-day
```sh ```sh
nomarchy-theme-sync list # 21 presets (nord, gruvbox, rose-pine, …) nomarchy-theme-sync list # 24 presets (nord, gruvbox, rose-pine, …)
nomarchy-theme-sync apply kanagawa # whole desktop, one generation (~a switch) nomarchy-theme-sync apply kanagawa # whole desktop, one generation (~a switch)
nomarchy-theme-sync set ui.gapsOut 16 # tweak one knob (also a switch) nomarchy-theme-sync set ui.gapsOut 16 # tweak one knob (also a switch)
nomarchy-theme-sync bg next # cycle wallpapers — instant, no rebuild nomarchy-theme-sync bg next # cycle wallpapers — instant, no rebuild
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) nomarchy-pull # flake input update (no rebuild)
home-update # rebuild just the desktop layer nomarchy-rebuild # rebuild the system, current lock
nomarchy-home # rebuild just the desktop layer
nomarchy-doctor # read-only health sheet (also: menu System Doctor)
``` ```
Keybinds: `SUPER+Return` terminal · `SUPER+D` launcher · `SUPER+T` theme 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+Space` 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`
clipboard history · `SUPER+Q` clipboard history · `SUPER+SHIFT+C` color picker (hyprpicker) · `SUPER+Q`
close · `SUPER+1..9` workspaces · `Print` region screenshot. close · `SUPER+1..9` workspaces · `SUPER+ALT+arrow` move workspace to monitor · `Print` region screenshot.
Shell aliases (zsh, gated on `nomarchy.shell.enable`) — `alias` lists them Shell aliases (zsh, gated on `nomarchy.shell.enable`) — `alias` lists them
all; the curated set: all; the curated set:
@@ -324,9 +450,9 @@ gd gds # diff · diff --staged
gl glg # log graph (last 20 · all branches) gl glg # log graph (last 20 · all branches)
gp gpl gf # push · pull · fetch --all --prune gp gpl gf # push · pull · fetch --all --prune
# nix (system/home rebuilds keep their full sys-update / home-update names) # nix (lifecycle: nomarchy-pull / nomarchy-rebuild / nomarchy-home — full names)
ns nr # nix shell · nix run (e.g. ns nixpkgs#ripgrep) ns nr # nix shell · nix run (e.g. ns nixpkgs#ripgrep)
nfu nfc # nix flake update · check nfu nfc # nix flake update · check (prefer nomarchy-pull for day-to-day)
nsearch ngc # nix search nixpkgs · nix-collect-garbage -d nsearch ngc # nix search nixpkgs · nix-collect-garbage -d
# misc # misc
@@ -341,7 +467,10 @@ reload # exec zsh (reload the shell)
- **New themed value:** add the key to `theme-state.json` and consume it in - **New themed value:** add the key to `theme-state.json` and consume it in
the Nix modules. One place — there is no second renderer to keep in sync. the Nix modules. One place — there is no second renderer to keep in sync.
- **Importing more old-distro palettes:** - **Importing more old-distro palettes:**
`tools/import-palettes.py <palettes-dir> themes/`. `tools/import-palettes.py <palettes-dir> themes/` (roles are first-class:
when ANSI color0==color8 the tool derives an overlay step; light themes
do not use dark ANSI black as surface — hand-tune after import; do not
bulk-reimport shipped JSON without a hierarchy pass).
- **New opt-in feature (convention):** when a feature is off by default and - **New opt-in feature (convention):** when a feature is off by default and
needs the user to set `nomarchy.*` options (e.g. night light, per-device needs the user to set `nomarchy.*` options (e.g. night light, per-device
keyboard layouts, monitor layout, power management), ship a **commented** keyboard layouts, monitor layout, power management), ship a **commented**
@@ -357,6 +486,12 @@ reload # exec zsh (reload the shell)
## Roadmap & known issues ## Roadmap & known issues
See **[docs/ROADMAP.md](docs/ROADMAP.md)** — forward-looking plans plus the | Doc | Role |
log of shipped fixes. Kept out of the README so this stays a focused entry |-----|------|
point. | **[agent/BACKLOG.md](agent/BACKLOG.md)** | What to do next (agent queue) |
| **[docs/VISION.md](docs/VISION.md)** | Product themes toward **v1.0** |
| **[docs/ROADMAP.md](docs/ROADMAP.md)** | Design history + shipped log |
| **[docs/README.md](docs/README.md)** · **[agent/README.md](agent/README.md)** | Maps |
| **[agent/LOOP.md](agent/LOOP.md)** | Autonomous iteration protocol |
Kept out of the README body so this stays a focused entry point.

150
agent/BACKLOG.md Normal file
View File

@@ -0,0 +1,150 @@
# Backlog — the prioritized task queue
**This is the only executable work list for agents.** Product themes and
v1.0 intent live in [`docs/VISION.md`](../docs/VISION.md); design history
in [`docs/ROADMAP.md`](../docs/ROADMAP.md); map in
[`docs/README.md`](../docs/README.md) and [`agent/README.md`](README.md).
**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 (and/or a note in VISION)
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 (include
`VISION § …` or `ROADMAP § …` when relevant); only Bernardo moves items
*out* of PROPOSED into the tiers.
---
## NOW
*(empty — NEXT's top item is the queue head)*
## NEXT
### 76. Hibernation default — all agent work shipped `[blocked:hw]`
Hibernation + zram on by default (product intent, Bernardo 2026-07-10).
**All V0/V1/V2 slices done** (2026-07-10); only the V3 check remains — on a
machine **actually running Nomarchy** (the dev box runs Bernardo's own NixOS,
not Nomarchy, so it can't close it). Steps in HARDWARE-QUEUE. Design record in
**ROADMAP § Hibernation + zram by default**. Shipped:
- zram default (`modules/nixos/oom.nix`, zstd/50%/prio 100) — `checks.zram-swap`.
- Installer/template already default new installs to a RAM-sized encrypted
`@swap` swapfile + `resume_offset` wiring; `swapSize=0` = no-swap opt-out.
- `docs/MIGRATION.md` enable-hibernation runbook (existing machines).
- `rofi.nix` Power → Hibernate: kept unconditional, **notifies on failure**.
- `checks.hibernate` VM test (V2 PASS) — hibernate→resume→crash round-trip
(swap partition); zram (prio 100) doesn't swallow the image, disk carries it.
- `checks.hibernate-swapfile` VM test (V2 PASS) — the installer's btrfs `@swap`
**swapfile** setup: NOCOW `mkswapfile`, file-type swapon, a valid
`map-swapfile -r` offset the kernel accepts (`/sys/power/resume_offset`),
zram above it. (Bounded proof of the swapfile+offset the partition test
can't reach; a full runtime-swapfile hibernate isn't headless-reachable —
systemd `CanHibernate=no` for undeclared swap.)
**V3 remaining (narrowed):** the literal encrypted-swapfile power-cycle —
`Hibernate → power off → unlock LUKS → resume session` on the LUKS+@swap
layout — plus the no-swap notify in a real session. QEMU can't feed a
runtime-computed `resume_offset` into the boot cmdline, and the dev box runs
exactly this layout (`/proc/cmdline` verified) but isn't Nomarchy. Steps in
HARDWARE-QUEUE. Close #76 on PASS.
## 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.
- **MIPI/IPU software-ISP camera** support (no-UVC machines).
- **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).
## FUTURE (decided deferred — not the agent queue head)
Work we **intend** someday but explicitly **not** NEXT. Agents do not
pick these unless Bernardo promotes one into NEXT/NOW.
### 20. KVM runner → VM suite in CI `[human]`
**Status (2026-07-10):** keep **eval-only** CI on the current Gitea
stack (act_runner in docker-compose on the 4c/4GB IONOS VPS). Nested
KVM + RAM headroom on that host are a poor fit next to Gitea; full
`checks.*` VMs stay local / promotion-time until a **separate**
KVM-capable machine exists.
**When ready:** register a second runner (host-mode nix + `/dev/kvm`,
label `nix-kvm` — not the existing docker eval runner), then uncomment
the `vm-checks` job in `.gitea/workflows/check.yml` (`runs-on: nix-kvm`,
`nix flake check` + toplevel/HM builds). Do not enable the job until
that label is online (Gitea queues forever otherwise).
### Formatter — adopt later `[human]`
**Intent:** add a Nix formatter (likely `nixfmt-rfc-style`) in a dedicated
pass: reformat the tree once, document in CONVENTIONS, optional CI
check. **Not** the queue head — no drive-by reformats until that pass.
## 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.*
*Open work only. Shipped exam/AC items (#47#63, #14, #52 theme
high-ROI, etc.) live in the journal + ROADMAP — not here.*
### Product / day-2
- **NVIDIA first-class options** — **deferred past v1** (Bernardo
2026-07-10). Keep #59 commented install guidance; no
`nomarchy.hardware.nvidia.*` until a hybrid maintainer + queue.
### v1.0 pointer
See **VISION**. Open PROPOSED: NVIDIA wrappers deferred past v1; IR
portal (b)/(c) need T14s hardware (HARDWARE-QUEUE § T14s). Standing
calls: browser default = Chromium; power backend = PPD.
## Decisions `[human]`
Open calls only Bernardo can make; agents add options/evidence but never
decide. **Resolved** entries stay for history; agents treat them as closed.
### Resolved (2026-07-10)
- **Docs site vs Markdown-in-repo** — **markdown in-repo for now**
(`docs/`, README). A rendered docs site is FUTURE if wanted.
- **Default browser** — **ship Chromium** in
`templates/downstream/home.nix`; mime → `chromium-browser.desktop`.
Opt out: delete the line / override mime.
- **Default power backend** — **keep PPD** (`nomarchy.system.power.backend`
default). TLP remains the one-line opt-in. Rationale: stability + live
profile API for menu/Waybar; Omarchys TLP experiment reverted.
### Resolved (2026-07-10, more)
- **Formatter adoption** — **yes, but not now.** Tracked as FUTURE
(below). Nix-source style only (`nixfmt-rfc-style` or similar); one
bulk reformat + CI/check when promoted. Until then: hand-aligned
style per CONVENTIONS.
- **Hibernation** — **want by default** (product intent). Needs a
disk-backed swap (file or partition) sized for resume; not zram alone.
Implementation is a future/NEXT item when designed (installer +
resume device + encrypted-root story).
### Resolved (2026-07-10, #76 design)
- **Swap sizing** — **exactly RAM** (installer default, unchanged). Hibernate
image ≤ RAM; zram takes day-to-day paging. **`swapSize=0`** stays no-swap.
- **Migration** — **docs runbook** (`docs/MIGRATION.md`), not a tool.
- **No-swap Hibernate** — keep the menu row; **notify on failure**.

72
agent/CONVENTIONS.md Normal file
View File

@@ -0,0 +1,72 @@
# 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`. That template is the
single source of truth for machine files: `nomarchy-install` copies it
and patches install-time values only (never a thinner second catalog).
- **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,
executive-slate and boreal (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.

112
agent/DELEGATION.md Normal file
View File

@@ -0,0 +1,112 @@
# Delegation — capability tiers, roles, token economy
How to spend model capacity in this repo, for **any** agent harness.
Tasks are matched to capability *tiers*, not vendor model names; each
harness maps tiers to its own models (table at the bottom). GOALS says
what to build, CONVENTIONS how to write it — this says who does which
part.
## The dividing line: evidence vs judgment
Cheap models gather evidence; they never make verification claims or
design decisions. The following always stay on the **frontier** tier and
are never delegated downward:
- deciding what to test and the regression scope (VERIFICATION.md)
- interpreting an ambiguous or flaky failure
- viewing screenshots and judging visual quality — aesthetic judgment is
exactly what small models do badly, and it's load-bearing here
- writing non-trivial Nix (module structure, overlays, cross-cutting
refactors)
- the final diff review and report
Product calls ("finish vs quarantine", promoting PROPOSED items) sit
above even that: they belong to the human.
## Tiers
| Tier | Best for | Delegate to it? |
|------|----------|-----------------|
| **light** | bulk mechanical: search, summarize, audit sweeps, running the harness | freely — never for judgment |
| **standard** | well-specified scoped edits, routine research | when the spec is already written |
| **frontier** | design, novel code, ambiguous failures, taste | this is the loop's own tier |
Reasoning effort: default moderate; go maximum only for the hardest
calls. A frontier parent may spawn a frontier child for one hard call.
Work above your tier gets returned, not attempted.
## Standing roles
Two mechanical roles exist for any harness that supports subagents
(Claude Code implementations: `.claude/agents/`; other harnesses
implement the same contracts in their own format):
- **scout** (light, read-only): locate where things are defined, map
which files touch a subsystem, scan build logs / serial output for
error lines, docs-vs-code drift sweeps. Reports facts with paths and
line numbers, quotes the minimum snippet, says "not found" plainly —
never guesses, never recommends.
- **runner** (light, executes): builds, VM boots, screenshot capture,
the scripted `tools/` checks. Headless and unattended, every wait
bounded by a timeout. Returns commands, exit codes, wall time, and
artifact paths — never marks anything passed or verified; the caller
makes the verification claim.
Use them for pure information-gathering or pure execution instead of
pulling bulk (log files, wide scans) into the main context. When a
result surprises you, spot-check it yourself before building on it —
cheap models are allowed to be wrong about hard things, which is
precisely why they're not allowed to make claims.
## Economy rules
- **Only delegate when writing the spec is cheaper than doing the
work** — a spawned agent starts cold and must re-derive context. A
one-file read is cheaper done directly.
- **Brief every child cold; point at the spec, don't restate it.** "The
spec is in `agent/BACKLOG.md` #NN — implement it" plus only the
*constraints* (scope files, branch, no-VM, no-push).
- **Match the tier to the task, not the prestige.** An item whose spec
is already written (exact files, exact fixes) is standard-tier work;
reserve frontier children for genuine multi-step reasoning. This is
the single biggest saving.
- The strong model writes the spec, reviews the result, and owns the
commit.
## Fanning out parallel work (V0/V1, no VM)
When several NEXT items are independent and don't need the VM, spread
them across worktree-isolated subagents in parallel:
- **Disjoint file lanes.** Partition items so no two agents touch the
same file (map the touched files first). If two items must share a
file (README, flake.nix, rofi.nix), give both to one agent or keep
one for yourself.
- **Isolation + you own landing.** Each agent works in an isolated
worktree, commits to its own branch, and **never pushes or touches
`main`/`v1`**. You review each diff, cherry-pick onto `main`, and do
the bookkeeping — a single landing agent can't race itself. Clean up
worktrees and branches after landing.
- **Lean on scriptable checks as primary evidence.** Where a
deterministic `tools/` check or `checks.*` guard already proves the
property, that near-free run *is* the V0/V1 evidence.
- **Batch V2 at the end, once.** Delegated visual/behavioural items come
back "V2 pending"; collect the landed changes and do **one** VM pass
covering all of them — the VM render + screenshot review is the most
expensive step in the loop; amortise it.
- **Re-verify on `main`, but leanly.** After landing, confirm the
agent's V0/V1 on the merged tree with a targeted build, not a full
re-run. Trust-but-spot-check scales; blind re-running doesn't.
The judgment list above still holds: *you* review every diff before it
lands.
## Per-harness model mapping
| Tier | Claude Code |
|------|-------------|
| light | `haiku` |
| standard | `sonnet` |
| frontier | the session's top model (`opus` and up) |
Other harnesses: add a column when one is actually used on this repo.

60
agent/GOALS.md Normal file
View File

@@ -0,0 +1,60 @@
# Goals — what "done" looks like for Nomarchy
The north star every loop iteration serves. When two options conflict,
the earlier pillar wins. Product *themes* toward a **v1.0** ship (day-2
confidence, default identity, release bar) live in
[`docs/VISION.md`](../docs/VISION.md) — still subordinate to these pillars.
## 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.

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

@@ -0,0 +1,270 @@
# 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. Checked-off entries are **pruned**
on the next sync sweep (LOOP.md §5) — outcomes live in the journal/ROADMAP;
git history is the archive. Machines: the **AMD dev box** (Ryzen AI
laptop: AMD + fingerprint + NPU), the **Latitude 5310/5410** (Intel QA),
the **T14s** (webcam case).
## Any machine (dev box is fine)
- [ ] **#55 fingerprint enroll on real reader** — with
`nomarchy.hardware.fingerprint.enable` and a physical reader: System
Fingerprint Enroll a finger; List shows it; Verify succeeds; optional
Use for login (on) → `nomarchy-rebuild` → login/sudo accept fingerprint.
Menu surface is V1/V2 without a reader (self-gate + dry paths).
- [ ] **#60 non-BAT* battery name (if available)** — on a machine whose
system battery is **not** named `BAT*` (e.g. `CMB0`): confirm
charge-limit oneshot writes the threshold, System Battery limit
and Power profile rows appear, Waybar power-profile module shows,
doctor charge-limit section runs. On `BAT0`-only machines this is
a no-op (charge-limit re-apply passed on `BAT0` — Latitude
2026-07-10).
- [ ] **btop theme fidelity (#52 residual)** — softGL theme-shot cannot
open Ghostty, so the hand `btop.theme` assets were only guest-file
asserted (main_bg / inactive_fg keys) + desktop/bar rendered for
rose-pine, everforest, summer-night, vantablack, catppuccin. On a
real session: `nomarchy-theme-sync apply <slug>` then `btop` and
confirm backgrounds/text match the theme (esp. rose-pine Dawn light
bg `#faf4ed`, vantablack near-black `#0d0d0d`, catppuccin Mocha
`#1E1E2E`). One pass cycling those five is enough.
- [ ] **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.
- [ ] **Notification inhibited-state glyph** (item 28 color-only sweep,
iteration #69) — have an app hold a notification inhibitor (e.g. run
a fullscreen video, or `busctl --user call org.freedesktop.Notifications
… Inhibit`); the Waybar bell must switch to the muted **bell-off** 󰂛
(same glyph as DND), NOT the normal bell 󰂜/󰂚. Then release it → the
bell returns. Repeat on a whole-swap bar (summer-day/night use their
own bell-off glyph, executive-slate/boreal use 󰂛). The glyph itself
already renders (DND uses it); this only confirms swaync emits the
`inhibited-*` class and the bar routes it.
- [ ] **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 was a real bug → BACKLOG item 26 (fix shipped; SUPER+?
cheatsheet PASS on Latitude 2026-07-10).
- [ ] **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.
- [ ] **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.
- [ ] **Snapshots restore + rollback exercise** (residual — the
GUI/polkit half passed on hardware 2026-07-04, a47aa3a): in
`sudo nomarchy-snapshots`, restore a single file (`undochange`)
and walk a root-config rollback up to (or through) the
typed-`yes` gate.
- [ ] **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
`nomarchy-home`: 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; a link → **Chromium**
(`chromium-browser.desktop` — template default as of 2026-07-10).
- [ ] **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).
Exit-node rows now show "hostname — Country City" (2026-07-11,
header-offset parsing): confirm Mullvad-style nodes carry their
location, a locationless self-hosted node stays a bare hostname,
and picking either still sets the node (`tailscale status` shows it).
- [ ] **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.
- [ ] **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.
- [ ] **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.)
- [ ] **Audio opens in Amberol** (iteration #60, item 37) — after
`nomarchy-home`: double-click an mp3/flac/ogg (or `xdg-open song.mp3`)
→ it opens in Amberol, not mpv; video files still open in mpv.
(`xdg-mime query default audio/mpeg` → io.bassi.Amberol.desktop.)
- [ ] **rofi menu polish** (iteration #58, item 39 + #56/#57 34/40) —
after `nomarchy-home`: (a) menu System Power profile shows a
colored icon per profile (performance/balanced/power-saver), not
just text — icons *render* (not blank); (b) every submenu's ↩ Back
shows a single arrow, no double; (c) on this single-monitor box,
System Display → pick a resolution → Back returns to System
(not back into the same resolution list).
- [ ] **Screenshot annotation (satty)** (iteration #73) — after `nomarchy-home`:
hit SUPER+SHIFT+Print (or Tools ▸ Capture ▸ Annotate region) → a region
select (slurp) appears, then the `satty` UI opens in fullscreen with the
screenshot loaded. Check that the UI draws on the current theme palette
(tools colored properly) and hitting save places the screenshot in
`~/Pictures/Screenshots/` while hitting copy places it in the clipboard.
- [ ] **#76 hibernate→resume on a real Nomarchy install** — the V2 VM test
proves the resume mechanism + zram coexistence, but QEMU can't feed a
boot-time `resume_offset` for a btrfs *swapfile* nor drive the LUKS
initrd unlock, so this triple needs hardware. **Requires a machine
actually running Nomarchy** (installer default swap = RAM), NOT the dev
box — that box runs Bernardo's own NixOS (TuringMachine, 25.11); its
layout resumes fine (verified 2026-07-10) but that only corroborates
the *layout*, not Nomarchy's oom.nix/patch-template/rofi wiring.
Candidate: a fresh Nomarchy install on a Latitude, or the dev box
*after* migrating to Nomarchy (docs/MIGRATION.md).
1. Confirm the wiring on the Nomarchy box: `swapon --show` lists
`/swap/swapfile` (Type file) **and** `/dev/zram0` at priority 100;
`cat /proc/cmdline` shows `resume_offset=…` + `resume=` the btrfs UUID.
2. Open apps, note an unsaved in-RAM state. `nomarchy-menu → Power →
Hibernate` (or `systemctl hibernate`). Machine writes image, **powers
off**.
3. Power on: unlock LUKS **once** in the themed prompt; land back in the
**same session** — state restored, not a fresh login.
4. **Pass** = session restored after full power-off + single unlock.
Fail (fresh boot / stuck initrd) → NOW bug in BACKLOG (capture
`journalctl -b -1 | grep -i -E 'hibernat|resume|swap'`).
- [ ] **#76 no-swap Hibernate notify** — on a **Nomarchy** machine installed
with `swap = 0` (or temporarily `sudo swapoff -a` on one): `nomarchy-menu
→ Power → Hibernate` must surface a desktop notification ("Couldn't
hibernate — likely no swap is configured. See docs/MIGRATION.md →
Enabling hibernation."), **not** a silent no-op. (`swapon` after, if you
swapoff'd for the test.)
- [ ] **Night-light geo mode (2026-07-11)** — in `home.nix` set
`nomarchy.nightlight = { enable = true; latitude = "<lat>";
longitude = "<long>"; }` (your real coordinates), `nomarchy-home`,
relogin. Then: `systemctl --user status wlsunset` is running (and
`hyprsunset` is NOT); after local sunset the screen visibly warms
(or test by setting coordinates where it's currently night); the
Waybar moon + menu toggle still flip it instantly and an *off*
survives relogin (the ExecCondition gate on the swapped unit —
the eval check proves the wiring, not the runtime gate). **Pass** =
warm shift at the location's night + toggle/persistence intact.
## 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.
- [ ] **System ▸ Firmware menu on real LVFS hardware** (item #43,
`nomarchy-menu firmware`) — on a machine whose firmware/SSD/dock is
on LVFS: open Menu ▸ System ▸ **Firmware**; confirm the terminal runs
`fwupdmgr refresh` → lists real `get-updates` → the y/N confirm gates
correctly → `fwupdmgr update` applies and prints the reboot note when a
capsule needs one. Verify **no** flash happens on "N"/cancel. (VM only
proves the menu row + flow renders; a real capsule write is
hardware-only.)
## Latitude 5310 / 5410 only
- [ ] **v1 QA batch on-hardware pass** (583708d batch was QEMU-verified) —
general smoke before the next `main → v1` promotion (broader than
the 2026-07-10 session; include theme/ISO/greeter leftovers).
## 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.
- [ ] **Portal/Flatpak libcamera IR (b)/(c)** — after #71 docs: on hardware,
confirm a Flatpak/portal picker still lists the internal IR node;
only then investigate (b) WirePlumber libcamera GREY-only monitor
rule or (c) libcamera/udev-layer hide. Do not ship either without a
live dual-sensor check (see HARDWARE.md §7 / ROADMAP § Webcam).

1884
agent/JOURNAL-ARCHIVE.md Normal file

File diff suppressed because it is too large Load Diff

546
agent/JOURNAL.md Normal file
View File

@@ -0,0 +1,546 @@
# Journal — append-only iteration log
One entry per iteration, **newest first**. This is the loop's short-term
memory: the next session reads the last 35 entries to orient. Keep an
entry under ~15 lines; durable lessons go to MEMORY.md, not here. When
this file grows past ~25 entries, rotate the tail to
[JOURNAL-ARCHIVE.md](JOURNAL-ARCHIVE.md) (history, never edited).
Template:
```markdown
## YYYY-MM-DD — <task title> (<commit sha, or "no commit">)
- **Task:** which BACKLOG item (or QA sweep / bootstrap / escalation)
- **Did:** what changed, in 24 lines
- **Verified:** tier reached (V0/V1/V2) + the actual commands/checks run
- **Pending:** V3 queued? follow-ups filed? anything [stuck]?
- **Next suggestion:** what the following iteration should probably take
```
---
## 2026-07-11 — Quick wins: exit-node locations + geo tooltip
- **Task:** BACKLOG LATER "VPN exit-node richer display" + a geo-mode
tooltip nit from the previous iteration (human asked for quick wins).
- **Did:** Tailscale Exit node picker rows now "hostname — Country City",
sliced by the header's fixed-width column offsets (COUNTRY/CITY are
multi-word; never field-split), locationless nodes stay bare, pick strips
back to hostname at the first space. Nightlight Waybar tooltip says
"follows your location" in geo mode. Queue's VPN entry updated.
- **Verified:** V1 — awk proven against a fabricated 3-case fixture
(Mullvad 1-word + multi-word city + bare node); flake check exit 0;
downstream-template-home builds (writeShellScriptBin syntax-gates both
scripts). Real tailscale output is V3 (existing VPN queue entry).
- **Pending:** V3 VPN menu live paths (extended, not new).
- **Next suggestion:** wallpapers artifact split (LATER, decided).
## 2026-07-11 — Night-light geo mode (lat/long → wlsunset)
- **Task:** BACKLOG LATER "Night-light geo mode" (human-picked).
- **Did:** `nomarchy.nightlight.latitude`/`.longitude` (both set = geo mode):
`services.wlsunset` (location-computed sunrise/sunset, `.temperature`
night temp) replaces hyprsunset; the toggle script's `unit=` and the
live-state ExecCondition follow via one `unit` let-binding. Template
example lines, README row, ROADMAP ✓. New `checks.nightlight-geo`:
pure-eval asserts on the rendered units (coords + temp in ExecStart,
ExecCondition present, hyprsunset off). Declarative-only (no menu writer —
coords are machine config like keyboard layout).
- **Verified:** V1 — `nix flake check --no-build` exit 0 (evals the new
asserts + option-docs); built `downstream-template-home` (default
hyprsunset path unregressed) + the check drv. Caught en route: HM renders
`ExecStart` as a list (`toString` needed in eval asserts).
- **Pending:** V3 queued (HARDWARE-QUEUE § Any machine): visible warm shift
at location night + toggle/persistence on the swapped unit.
- **Next suggestion:** wallpapers artifact split (LATER, decided).
## 2026-07-11 — Sync-sweep rule + grooming + zram user docs (human-requested)
- **Task:** Bernardo: groom stale items and codify the check so they don't
recur; close the zram user-doc gap.
- **Did:** LOOP.md §5 gains step 4 **Sync sweep** (grep item/feature across
agent/+docs/ on ship; prune recorded `[x]` queue entries) + an AGENTS.md
rule line. Applied it: BACKLOG PROPOSED pruned (auto-theme pitch was
stale — shipped as #79; shipped/promoted placeholders removed; v1.0
pointer rewritten; FUTURE promoted-stub dropped). HARDWARE-QUEUE pruned
29 checked `[x]` entries (363→~250 lines), dangling refs reworded,
prune rule in header. README §3 now documents the zram default +
hibernation out-of-the-box + MIGRATION pointer.
- **Verified:** V0 (docs/meta only) — grep sweep for dangling refs clean.
- **Pending:** night-light geo mode next (same session).
- **Next suggestion:** wallpapers artifact split (LATER, decided).
## 2026-07-11 — Vendor-neutral agent docs restructure (human-requested)
- **Task:** Bernardo: de-vendor the agent instructions (multi-model repo),
clean stale files, better docs/agent/.claude structure.
- **Did:** New root `AGENTS.md` entry (CLAUDE.md → symlink). Skill bodies
moved to `agent/`: VERIFICATION.md (enforcement, ex-nomarchy skill),
DELEGATION.md (tiers light/standard/frontier + scout/runner contracts +
fan-out; consolidates CLAUDE.md table + LOOP §economy + skill §6.5),
THEME-DESIGN.md (ex-theme skill, now with frontmatterd shim). `.claude/`
is a thin adapter (shims route into `agent/`). Maps updated (agent/,
docs/, README tree). Stale: `.gitignore` old_distro entry dropped; local
result*/__pycache__ removed. JOURNAL rotated (29 kept, 120 →
JOURNAL-ARCHIVE.md).
- **Verified:** V0 — `nix flake check --no-build` exit 0 with the symlink
tracked; vendor-name grep clean outside DELEGATION's mapping table.
- **Pending:** nothing; other harnesses add their own adapter row when used.
- **Next suggestion:** #76/#79 remain [blocked:hw]; otherwise groom PROPOSED.
## 2026-07-11 — #76 checks.hibernate-swapfile (VM-verify the swapfile+offset)
- **Task:** Bernardo — push #76's swapfile-resume residual into a VM (like
the auto-theme sunset trick).
- **Did:** Tried a full runtime-swapfile hibernate→resume test; it hit a
bounded, definitive **`CanHibernate=no`** — systemd won't hibernate to a
swap that isn't declared in the boot config, and a runtime-computed
`resume_offset` can't reach the boot cmdline (the genuine chicken-and-egg).
First cut also had an **unbounded `wait_for_shutdown`** that hung → killed
it, bounded the wait in a thread (180s) + a `CanHibernate` pre-check, which
is what surfaced the clean verdict. Pivoted `checks.hibernate-swapfile` to
what a VM *can* prove deterministically and the partition test can't: the
installer swapfile **build + offset** — NOCOW `mkswapfile`, file-type
swapon, valid `map-swapfile -r` offset accepted by `/sys/power/resume_offset`,
zram above it. (Device fix: `/dev/vdb` once `useNixStoreImage` was dropped.)
- **Verified:** **V2 PASS** (fresh build, exit 0). BACKLOG/ROADMAP updated;
#76 residual narrowed to the literal encrypted power-cycle (dev box runs it).
- **Lesson:** the sunset-trick spirit applies but not always the full cycle —
when systemd/kernel gates the behaviour (CanHibernate), verify the
*construction* deterministically and leave only the physical cycle to HW.
Always bound `wait_for_shutdown` (no timeout param → thread + join).
- **Next suggestion:** #76/#79 both `[blocked:hw]`; QA / PROPOSED otherwise.
## 2026-07-11 — Code-review fixes (parallel review of the session batch)
- **Task:** Bernardo asked for parallel work; spawned a fresh-eyes opus
review of `2055842..HEAD` (static). It found the batch sound — 2 real LOW
edge bugs, 1 false positive, 1 cosmetic.
- **Did:** (1) `cmd_auto` — inverted sunrise/sunset (sunrise > sunset) went
perpetually day; replaced the `not between` shortcut with a real
wrap-around `mins >= sunrise or mins < sunset`, so a cross-midnight day
window switches both ways. (2) `rofi.nix` autotheme HH:MM guard tightened
`[0-2][0-9]``[01][0-9]|2[0-3]` so 2429 are rejected. Dismissed the
`breeze` finding (papirus DOES ship breeze/breeze-dark — verified) and the
cosmetic doctor-RAM one.
- **Verified:** py_compile; boundary truth-table (normal + wrap-around);
HH:MM case table (23:00/19:59/20:00 accept, 24/25/30:00 reject); `auto
--which` normal-case unchanged; menu bash -n; flake check green. Normal
case unchanged so `checks.auto-theme` still holds.
- **Pending:** hibernate-swapfile VM test still running (uncommitted flake.nix).
- **Next suggestion:** land hibernate-swapfile once its VM test returns.
## 2026-07-10 — #79 V2: checks.auto-theme (simulated sunset) — was mis-framed V3
- **Task:** Bernardo challenged the "V3 real-session" framing — a simulated
sunset IS VM-testable. He's right; I'd conflated "needs a real desktop"
with "needs the generic home-manager switch."
- **Did:** Added `checks.auto-theme` runNixOSTest: installs nomarchy-theme-sync,
points `NOMARCHY_DEFAULT_THEMES` at presets, stubs the switch via
`NOMARCHY_REBUILD` (a marker), configures the pair+schedule, then **walks
the VM clock** 19:55→20:05→20:06→next-day 07:30 asserting slug goes
summer-day → night (1 rebuild) → night (idempotent, still 1) → day (2). The
self-gating I relied on: presets via env, wallpaper/notify no-op headless,
run_switch honors NOMARCHY_REBUILD + waybar nudge is check=False.
- **Verified:** **V2 PASS**`nix build .#checks…auto-theme` fresh, exit 0.
Dropped #79's `[blocked:hw]`; it's done (V2). Updated ROADMAP.
- **Lesson:** don't default "timer/session feature" to V3 — stub only the
generic step (the real HM switch, covered elsewhere) and simulate the rest
(clock) headlessly. Only the literal timer-fires-on-OnCalendar is on-HW,
and its schedule is systemd-analyze-validated.
- **Next suggestion:** NEXT head #76 `[blocked:hw]`; else QA / PROPOSED.
## 2026-07-10 — #79 slice 3: Look & Feel Auto theme menu (#79 complete)
- **Task:** BACKLOG #79 slice 3 — the menu; completes the feature.
- **Did:** `rofi.nix` — "Auto theme (on/off)" row in Look & Feel + an
`autotheme)` submenu: toggle, pick day/night (from
`nomarchy-theme-sync list`), set sunrise/sunset (HH:MM input, validated).
Enable writes the flag + defaults the summer pair `--no-switch`, then
`exec auto --force` — one rebuild installs the timer AND applies the right
theme. Disable is instant `--no-switch` (`auto` self-gates; the lingering
timer no-ops). Day/night/times are live `--no-switch`.
- **Verified:** **V1** — HM generation builds (`writeShellScriptBin` bash -n
on nomarchy-menu passes) + bash -n on the built menu. **Logic test**
(stubbed rofi/theme-sync/notify): selecting "Auto theme (off)" runs exactly
`set day summer-day`, `set night summer-night`, `set enable true`
(all `--no-switch`), then `auto --force` — the intended one-rebuild path.
Disable path is static-clear (set false `--no-switch`, no `auto`); a
stateful-stub dynamic test would loop (stub doesn't reflect the flip). V0
flake check green. **#79 collapsed to `[blocked:hw]`** (all 3 slices done;
only live-session switch remains, needs a Nomarchy machine).
- **Pending:** V3 real-session (timer switches day↔night across sunset).
- **Next suggestion:** NEXT head #76/#79 both `[blocked:hw]`; QA/PROPOSED.
## 2026-07-10 — #79 slice 2: auto-theme systemd timer
- **Task:** BACKLOG #79 slice 2 — the timer/hook.
- **Did:** `modules/home/autotheme.nix` (imported in home default.nix):
`systemd.user.{service,timer}.nomarchy-auto-theme`, install gated on
`config.nomarchy.settings.autoTheme.enable or false` (state-driven, like
updates.nix gates on its enable). Service runs `nomarchy-theme-sync auto`
with a PATH for `home-manager`; timer `OnStartupSec=1min` +
`OnCalendar=*:0/15` + `Persistent`. Chose periodic over exact
sunrise/sunset timers — robust to suspend/DST, and `auto` is idempotent so
ticks are cheap (rebuild only at the transition).
- **Verified:** **V1** — real-config eval (state temp-enabled, restored):
`settings.autoTheme.enable`→true, timer `OnCalendar`=`*:0/15`, service
ExecStart=`… nomarchy-theme-sync auto`; disabled→no units (isolation eval
condition=false). `systemd-analyze calendar '*:0/15'` valid. **V0** flake
check green. (Gotcha: a new .nix file is invisible to the flake until
`git add` — flake eval only sees tracked files.)
- **Pending:** live timer→`home-manager switch` on a real Nomarchy session
(needs a Nomarchy machine, like #76). Slice 3 (menu) next.
- **Next suggestion:** #79 slice 3 — Look & Feel Auto theme menu.
## 2026-07-10 — #79 slice 1: auto time-of-day theme CLI primitive
- **Task:** Split BACKLOG #79 (`VISION § D`, `[big]`) into 3 slices (CLI /
timer / menu); took slice 1.
- **Did:** `nomarchy-theme-sync auto` — reads
`settings.autoTheme.{enable,day,night,sunrise,sunset}`, computes day/night
by the local clock, and applies the matching preset **only if different**
(idempotent → the slice-2 timer won't rebuild needlessly). `--which`
prints the decision without switching; `--force`/`--no-switch` too.
Factored `cmd_apply`'s core into `apply_named` so `auto` reuses the exact
same one engine (GOALS: no second pipeline).
- **Verified:** **V1** package builds; functional tests on the built bin
against scratch state: wide-day→day slug, 00:0000:01 window→night,
disabled→no-op, missing day/night→die, `auto --no-switch` writes state,
re-run→"already on" no-op, plain `apply` still writes (refactor intact).
**V0** py_compile + flake check + `checks.theme-sync-validate` green.
- **Pending:** slices 2 (systemd sunrise/sunset timers + login apply, V2)
and 3 (Look & Feel menu) remain under #79.
- **Next suggestion:** #79 slice 2 — the timer/hook (V2 runNixOSTest).
## 2026-07-10 — #78 Omarchy migrant doc (docs/OMARCHY.md)
- **Task:** BACKLOG #78 (promoted from PROPOSED) — Omarchy→Nomarchy
one-pager (`VISION § F`).
- **Did:** New `docs/OMARCHY.md`: leads with the config-model shift
(imperative dotfiles → declarative flake + menu, rollback via
generations), then install (ISO/`nomarchy-install` or MIGRATION.md),
a familiar-bindings table, theming (`SUPER+T`/`nomarchy-theme-sync`),
packages (home.packages / `nix shell`, no AUR), update+rollback
(`nomarchy-pull`/`-rebuild`/`-home`, boot-menu generations,
`nomarchy-doctor`). Added to `docs/README.md` map + VISION § F ✓.
- **Verified:** V0 (docs). Grounded Nomarchy side in code: pulled real
binds from `keybinds.nix`; confirmed all 7 cited commands, the `nsearch`
alias, and the named slugs (gruvbox/rose-pine/nord) exist. Kept
Omarchy-side claims to known facts (Arch, dotfiles, pacman/AUR).
- **Pending:** none.
- **Next suggestion:** NEXT head #76 `[blocked:hw]`; last open PROPOSED is
auto time-of-day theme pair (`§D`, `[big]` — needs splitting).
## 2026-07-10 — #77 doctor hibernate/sleep section (V2 PASS)
- **Task:** BACKLOG #77 (promoted from PROPOSED by Bernardo) — read-only
hibernate section in nomarchy-doctor (`VISION § C`, #76 follow-on).
- **Did:** Added a hibernate/sleep row group to `nomarchy-doctor.sh`: resume
device on cmdline (+ `resume_offset` for a swapfile), disk swap ≥ RAM,
zram active, previous-boot suspend/hibernate error. Self-gates (`skip` on
swap=0); only ok/warn/skip → never fails the sheet. Added `hibernate` to
the `checks.doctor` probe list.
- **Verified:** **V2 PASS**`checks.doctor` VM test green (fresh build,
exit 0; asserts the hibernate row renders + healthy run still 0). **V1**
ran on this box's real LUKS+@swap layout: all three `ok` rows render
(resume set, swap 34G ≥ RAM, zram 16G) and the sheet reaches its verdict.
**V0** bash -n + flake check.
- **Gotcha (fixed + MEMORY):** the doctor runs `set -euo pipefail`; my first
cut aborted before the verdict because a no-match `grep` inside `$(…)` and
a `cond && action` both trip set -e. Fixed with `|| true` / `|| echo 0` and
an `if`. First VM run caught it (exit 1, no verdict) — the test earned its keep.
- **Pending:** none — fully agent-verifiable (read-only checks, no V3).
- **Next suggestion:** NEXT head is #76 `[blocked:hw]`; remaining PROPOSED
(Omarchy doc §F, auto time-of-day theme §D) await triage.
## 2026-07-10 — QA sweep (clean) + VISION-gap pass → 3 PROPOSED
- **Task:** Bernardo — QA/drift sweep, then a VISION-gap pass with proposals.
- **Did (sweep):** Fanned out two subagents (Bernardo OK'd spawning for
efficiency): nomarchy-runner ran the deterministic checks + `bash -n`/
`py_compile` (all PASS; the 4 "needs-args" scripts pass via their flake
derivations); nomarchy-scout hunted fuzzy drift (dead code, stale refs,
TODOs, changed-area contradictions, count/version) — **all clean**.
Spot-checked scout's count claim myself: 24 theme JSONs / 24 previews /
README "24 presets" — consistent. Eval sweep green. **No fixes needed.**
- **Did (VISION):** Verified candidate gaps against the tree before
proposing (killed two false gaps — nomarchy-rebuild/home already tail logs
+ point at doctor on failure = §A shipped; `nvd diff` = §B covered; System
Firmware menu already exists). Filed 3 **PROPOSED** (verified open):
doctor hibernate/sleep section (§C, top Day-2 pick, #76 follow-on),
Omarchy migrant one-pager (§F), auto time-of-day theme pair (§D, `[big]`).
- **Verified:** V0 (agent-notes/docs only). Sweep evidence above.
- **Pending:** none. Proposals await Bernardo's triage into a tier.
- **Next suggestion:** if a PROPOSED is promoted, the doctor hibernate
section is the cleanest next build; else NEXT #76 stays `[blocked:hw]`.
## 2026-07-10 — Icon overrides: user-facing docs (OVERRIDES.md)
- **Task:** Bernardo asked whether the session's features need user docs.
- **Did:** Hibernation already covered (README swapfile line, MIGRATION.md
runbook, discoverable Power-menu row) — no gap. Icons had one: the only
doc was the template comment. Added an "Icon pack" subsection to
`docs/OVERRIDES.md` § Appearance — `nomarchy-theme-sync set icons "<name>"`,
the known-packs table, the ~1 GiB/opt-in rationale, and that the choice is
sticky across `apply` (presets carry no icon field; verified `deep_merge`).
- **Verified:** command path confirmed (`cmd_set` writes any dotted key;
`icons` is a schema key). **Built all 4 packs and listed real theme dirs**
to fix the examples — caught Qogir using capital `Qogir-Dark`/`Light`
(lowercase would silently fall back to hicolor). Docs-only (V0).
- **Pending:** none.
- **Next suggestion:** NEXT #76 `[blocked:hw]`; LATER/QA-sweep next.
## 2026-07-10 — Icon themes: opt-in cross-pack overrides (no default bloat)
- **Task:** LATER "per-theme icon overrides / more icon packs". Recon found
papirus is already ~1 GiB and ships only Papirus/Dark/Light (no free
variety); real variety needs extra packs at ~hundreds of MB each. Bernardo
chose the **opt-in, no-default-bloat** shape (over shipping packs).
- **Did:** `theme.nix` resolves `nomarchy.theme.iconThemePackage` from a
small `iconPacks` registry (papirus·tela·qogir·reversal·numix, by name
prefix). Default themes name `Papirus-*` → resolves to the *plain* papirus
derivation (no symlinkJoin, identical closure); only a theme naming another
pack's set symlinkJoins that one pack in. `stylix.nix` uses it
(mkDefault). Opt-in note in `templates/downstream/home.nix`; ROADMAP §
Icon themes updated (incl. the deliberate no-default-packs decision).
- **Verified:** **V1** — HM activationPackage builds (exit 0). Eval proofs:
default `iconThemePackage` = plain `papirus-icon-theme-*` (zero delta);
with `icons="Tela-dark"``nomarchy-icon-themes` symlinkJoin (papirus+tela)
and name flows, **without building tela**. All 5 pack attrs exist in the
pinned nixpkgs. `template-sot` + `option-docs` + `flake check` green.
- **Pending:** none. No default visual change (identical papirus), so no
screenshot needed; the opt-in render is the downstream user's to see.
- **Next suggestion:** NEXT still #76 `[blocked:hw]`; LATER/QA-sweep next.
## 2026-07-10 — #76 slice 3: hibernate VM test (V2 PASS) → #76 agent-complete
- **Task:** BACKLOG #76 slice 3 — the mandatory V2 hibernate test + V3 queue.
- **Did:** Added `checks.hibernate` (flake.nix), modeled on nixpkgs'
hibernate.nix but wired to Nomarchy's #76 risk: imports oom.nix (zram
default-on), disk swap at lower priority + explicit `boot.resumeDevice`,
systemd-initrd. Test drives hibernate→power-off→resume→crash: ramfs marker
survives resume, a crash-boot loses it (real resume, not a survivor), and
asserts zram@100 + disk-swap<100 so the image lands on disk not volatile
zram. Queued the V3 laptop steps (real LUKS+@swap+offset resume + no-swap
notify) in HARDWARE-QUEUE. Collapsed #76 to a `[blocked:hw]` stub; design
record → ROADMAP § "Hibernation + zram by default".
- **Verified:** **V2 PASS**`nix build .#checks.x86_64-linux.hibernate`
built the driver fresh and exited 0 (all assertions held). `nix flake
check --no-build` green.
- **Pending:** only V3 (dev box / laptop) — #76's last check; close on PASS.
- **Next suggestion:** #76 is now blocked:hw; NEXT head falls to LATER/
PROPOSED triage or a QA sweep — pick per LOOP.md orientation next session.
## 2026-07-10 — #76 slice 2: Hibernate notify-on-failure
- **Task:** BACKLOG #76 remaining slice 2 — per the settled "keep row, notify
on failure" call.
- **Did:** `modules/home/rofi.nix` power menu Hibernate arm now runs
`systemctl hibernate 2>/dev/null || notify-send "Hibernate" "…see
docs/MIGRATION.md"`. On success the call blocks until resume, so the notify
only fires on a genuine failure (e.g. swap=0 no-swap machine). Row stays
unconditional (no hiding).
- **Verified:** **V1**`nix build …homeConfigurations.nomarchy.activationPackage`
green (rebuilds `nomarchy-menu.drv`; `writeShellScriptBin` runs bash -n at
build). Control-flow proof on the arm extracted from the built script:
failing `systemctl` → notify fires; succeeding → no notify. `nix flake
check --no-build` green.
- **Pending:** slice 3 = the V2 hibernate→resume VM test + V3 laptop
(Hibernate→power off→resume on the LUKS+@swap machine; plus confirm the
no-swap notify surfaces in a real session). Both remain open on #76.
- **Next suggestion:** slice 3 — add a `checks.*` hibernate VM test (as far
as QEMU allows) and queue the V3 laptop steps in HARDWARE-QUEUE.
## 2026-07-10 — #76 slice 1: MIGRATION.md hibernation runbook
- **Task:** BACKLOG #76 remaining slice 1 — docs runbook for enabling
hibernation on an existing machine (per the settled "docs not tool" call).
- **Did:** Added § "Enabling hibernation on an existing machine (no reinstall)"
to `docs/MIGRATION.md`: create `@swap` subvol (subvolid=5) + `mkswapfile`
(NOCOW), read `map-swapfile -r` offset, wire `fileSystems."/swap"` +
`swapDevices` + `resumeDevice` + `resume_offset` into system.nix. Flagged
the mount as required on the hand-edit path (installer gets it from
disko-gen hardware-config). Notes zram-priority reservation, no-LUKS
variant, `swap=0` opt-out. Worked example = this dev machine.
- **Verified:** **V0** — read-only commands run live against this machine's
real LUKS(`cryptroot`)+btrfs(`@`)+`/swap/swapfile` layout (RAM 31G, fsuuid,
`findmnt SOURCE`, `mkswapfile` flags, zram@100 vs swapfile@-1 confirms the
design intent). `nix flake check --no-build` green.
- **Pending:** slice 2 — Hibernate row notify-on-failure (V1/V2); then the
V2 hibernate VM test + V3 laptop.
- **Next suggestion:** slice 2 (menu notify-on-failure).
## 2026-07-10 — #76 hibernation design calls (no commit yet → this bookkeeping)
- **Task:** BACKLOG #76 design slice — Bernardo made the open calls.
- **Did:** Recon showed the hibernation half is **already built for new
installs** (installer defaults swap=RAM hibernation-ready `@swap` swapfile;
`patch-template.py` writes swapDevices/resumeDevice/resume_offset; Power
menu Hibernate row + LUKS/hyprlock handling exist). Settled 3 calls:
(1) swap sizing = **exactly RAM** (no change); (2) migration = **docs
runbook in MIGRATION.md**, not a tool; (3) no-swap Hibernate = **keep row,
notify on failure**. Folded into BACKLOG #76 (design ✓, re-sliced remaining:
MIGRATION.md runbook + notify-on-failure menu) and Decisions.
- **Verified:** **V0**`nix flake check --no-build` green (docs-only).
- **Pending:** two remaining slices below; V2 hibernate VM test + V3 laptop.
- **Next suggestion:** slice 1 — `docs/MIGRATION.md` hibernation-enable runbook
(V0), then slice 2 — Hibernate notify-on-failure (V1/V2).
## 2026-07-10 — #76 zram slice: compressed-RAM swap default
- **Task:** BACKLOG #76 (`[big]`) — split; took the low-risk zram-default slice.
- **Did:** `zramSwap` on by default in `modules/nixos/oom.nix` (zstd, 50% RAM,
priority 100) as the memory-pressure layer before earlyoom, following the
earlyoom mkDefault precedent (no toggle). High priority keeps day-to-day
paging in zram so a disk swapfile stays reserved for #76's hibernate image.
Added `checks.zram-swap` runNixOSTest (imports oom.nix). Split #76 in
BACKLOG → remainder is the hibernation half (swapfile/resume/installer).
- **Verified:** **V2**`nix build .#checks…zram-swap` PASS: /dev/zram0 active
in /proc/swaps, `[zstd]` comp_algorithm, priority 100. **V1** full distro
toplevel builds. **V0** flake check --no-build green.
- **Pending:** hibernation half of #76 still open (needs hibernate→resume VM
test + V3 laptop). No V3 for zram (under-load tuning is secondary).
- **Next suggestion:** #76 hibernation half — design default swapSize + the
resume/installer wiring; it's the boot-critical, V2-hibernate + V3 part.
## 2026-07-10 — #41 hardware-confirmed: OpenFile portal floats (V3 close)
- **Task:** BACKLOG #41 — final on-hardware confirmation (Bernardo reported PASS).
- **Did:** #41 already out of NEXT + portal rule shipped
(`hyprland.nix` xdg-desktop-portal-gtk float/center). Bernardo verified the
GTK OpenFile file-picker floats on real hardware; dropped the "OpenFile not
re-probed / optional residual" caveats in HARDWARE-QUEUE — #41 fully closed.
- **Verified:** **V3** (Bernardo, Latitude). No code change. Bookkeeping only;
QA-sweep guards run green this session: `nix flake check --no-build`,
option-docs/template-sot/theme-sync-validate builds, contrast 24×7.
- **Pending:** none for #41. NEXT head is #76 (hibernation + zram, `[big]`).
- **Next suggestion:** split #76 — take the low-risk **zram-default** slice
(V2-testable headlessly) before the boot-critical hibernate/resume work.
## 2026-07-10 — Close #41 GTK portal float rule
- **Task:** BACKLOG #41 residual (GTK file-chooser portal class).
- **Did:** Added float+center for `xdg-desktop-portal-gtk` /
`.xdg-desktop-portal-gtk-wrapped` (desktop + libexec name from package).
Polkit already PASS on Latitude. Removed #41 from NEXT; only #20 remains.
- **Verified:** **V0** windowrule-syntax check path unchanged; class from
package `xdg-desktop-portal-gtk.desktop` / community configs. SoftGL
still cannot materialize portal dialogs.
- **Pending:** optional hardware spot-check OpenFile floats (HARDWARE-QUEUE).
- **Next suggestion:** #20 is `[human]` only — or LATER/PROPOSED triage.
## 2026-07-10 — Product calls: charge live, identity, btop, NVIDIA skip
- **Task:** Bernardo decisions on remaining PROPOSED.
- **Did:** (1) Instant charge-limit: udev GROUP=users on threshold,
menu/powermgmt live sysfs + state, oneshot reads live theme-state +
AC re-apply. (2) Look & Feel already had Theme/wallpaper/night light;
added Reset wallpaper (auto). (3) NVIDIA first-class deferred past v1
(HARDWARE.md). (4) Identity retunes: white L-steps, lumon subtext,
hackerman surface+UI warn/bad, matte-black subtext, miasma bad.
(5) Hand `btop.theme` for 9 identity/whole-swap slugs from palette map.
- **Verified:** **V0** contrast 24×7; **V1** battery-charge-limit VM
(udev restart). **V3** live charge write on real laptop (HARDWARE-QUEUE).
- **Next suggestion:** only #20/#41 blocked, or Decisions.
## 2026-07-10 — Summer pair polish + status CSS + preview recapture
- **Task:** finish agent-able PROPOSED theme leftovers.
- **Did:** summer-day/night — JSON hierarchy (night subtext/muted/overlay
ladder; day overlay≠muted); waybar CSS tokens track JSON roles; font
13px both; day battery 25/10; VPN pill + full status states
(.recording/.available/.on/.activated/battery warn). Recaptured
executive-slate + neon-glass `preview.png` via theme-shot desktop
(480×270). Closed those PROPOSED lines.
- **Verified:** **V0** contrast 24×7 + wholeswap; **V2** theme-shot
summer-day/night/executive-slate/neon-glass — viewed bar crops +
previews. Identity taste retunes stay PROPOSED.
- **Next suggestion:** only blocked NEXT (#20/#41) or product Decisions.
## 2026-07-10 — #74 matrix flags + #75 V2 install (default arm)
- **Task:** NEXT #74 unattended matrix · #75 SoT V2 install after ISO.
- **Did:** `NOMARCHY_TEST_SWAP_GB` / `NOMARCHY_TEST_NO_LUKS` drive
`tools/test-install.sh` go.sh + first-boot LUKS skip; nixpkgs OVMF
fallback. Full offline install V2 (SWAP=2 LUKS) after live ISO rebuild
with starter packages — first-boot themed Boreal, no red banner.
Closed #74/#75. Other matrix arms scripted (not full-ISO re-run).
- **Verified:** **V2** — install powered off ~460s; first-boot.png
viewed (`/tmp/nomarchy-vm-75/first-boot.png`): themed wallpaper +
waybar (logo, clock, modules), doctor glyph, no autogen banner.
- **Next suggestion:** NEXT only #20 [human] + #41 [blocked:hw].
## 2026-07-10 — Loop batch land #64#73 (parallel worktrees)
- **Task:** agent-actionable NEXT #64#73 via worktree agents; land on main.
- **Did:** #64 MIGRATION snapshots · #65 real-uid chown · #66 theme-state
fail-closed reader · #67/#68 theme fidelity+ANSI · #69/#70 audit+import
tools · #71 IR portal docs · #72 template-sot check · #73 MOTD/first-boot
fingerprint+doctor tips. Agents did not push; parent cherry-picked
(stripped agent BACKLOG races). Closed #64#73.
- **Verified:** **V0/V1** — theme-contrast 24×7 pass; template-sot green;
bash -n install; flake check --no-build green.
- **Next suggestion:** #74 unattended matrix + #75 V2 install (KVM).
## 2026-07-10 — Promote agent-actionable PROPOSED → NEXT #64#75
- **Task:** Bernardo: bump non-hw/non-human PROPOSED categories 1+2.
- **Did:** **#64** MIGRATION snapshots · **#65** installer chown ·
**#66** mkFlake theme-state errors · **#67** theme fidelity nits ·
**#68** #52 residual ANSI · **#69** audit identity exemptions ·
**#70** import-pipeline hierarchy · **#71** IR portal docs (a) ·
**#72** template SoT CI · **#73** hardware hints residual · **#74**
unattended install matrix · **#75** SoT V2 install. Breadcrumbs in
PROPOSED; actionable items sit above #20/#41 in NEXT.
- **Verified:** V0 (backlog only).
- **Next suggestion:** queue head **#64** (docs) or batch #67/#68 themes.
## 2026-07-10 — PROPOSED cleanup (stale exam residue)
- **Task:** Bernardo: clean stale PROPOSED items after A/B/C + #52 ship.
- **Did:** Rewrote PROPOSED to open work only — dropped #52 shipped
high-ROI bullets, neon-glass quarantine, empty/shipped installer
audit sections, template seed drift (keys already present), soft
gaps fixed by #50, and “no action” kanagawa/boreal notes. Kept
product day-2, installer follow-ups, theme polish residuals, tooling.
- **Verified:** V0 (backlog hygiene); spot-checked template keys,
latte/mocha #52 fixes, usbutils, package exports.
- **Next suggestion:** triage the lean PROPOSED list or HARDWARE-QUEUE.
## 2026-07-10 — Pending closeout: #14, #57 V2, #41 slice
- **Task:** pending V2/CI/float leftovers after A/B/C batch.
- **Did:** **#14 closed** — first scheduled lock bump landed
`8fded63` (2026-07-06 Mon 05:20 UTC) as `nomarchy-bump` /
`chore(lock): scheduled upstream bump` (Actions token push works).
**#57 V2** — `checks.live-install-entry` asserts live HM gen has
`nomarchy-install.desktop` (Name/Terminal/Exec) + installer bin +
Tools menu gate in `nomarchy-menu`. **#41 slice** — float/center rules
for hyprpolkitagent + pinentry-qt from package app-id strings;
`tools/capture-float-classes.nix` harness (softGL empty clients);
residual GTK portal stays NEXT `#41 [blocked:hw]`. HARDWARE-QUEUE
float confirm steps added.
- **Verified:** **V2** live-install-entry green; windowrule-syntax green.
SoftGL class capture inconclusive (empty clients). **V3** float confirm
+ fingerprint/#60 still HARDWARE-QUEUE. **#20** still `[human]`.
- **Next suggestion:** Bernardo: #20 KVM runner, or HARDWARE-QUEUE pass.
## 2026-07-10 — #55 fingerprint menu + #56 human rebuild errors
- **Task:** NEXT #55 (System Fingerprint) + #56 (rebuild failure UX).
- **Did:** #55 — System row self-gated on fprintd-list; enroll/list/verify/
delete-all terminal flows; Use for login writes
`settings.fingerprint.pam` (hardware.nix default from stateFile);
HARDWARE.md §5 + HARDWARE-QUEUE V3 enroll. #56 — sys-update /
sys-rebuild / home-update tee last 40 lines + nomarchy-doctor pointer;
theme-sync run_switch notify/die text. Closed #55/#56.
- **Verified:** **V1** — HM template rebuild (menu + theme-sync);
option-docs green; failure-footer shell smoke. **V3 pending:** real
fingerprint enroll (queue). **V2 pending:** forced bad sys-rebuild in
VM (footer text pure/logic covered).
- **Next suggestion:** queue was AC; remaining NEXT is blocked items
(#14/#20/#41) or PROPOSED triage.
## 2026-07-10 — #58 nomarchy-detect-hw CLI
- **Task:** NEXT #58 — post-install re-probe package (HARDWARE.md §8).
- **Did:** `pkgs/nomarchy-detect-hw` wrapping installers hardware-db;
human report + snippets + `--raw` protocol; overlay + systemPackages;
HARDWARE.md §8 updated. Closed #58.
- **Verified:** **V1** — package builds; ran on this AMD laptop: correct
MODULE/NOMARCHY lines (amd cpu/gpu, laptop, ssd, fingerprint, IR cam,
NPU) and printable system.nix snippets.
- **Next suggestion:** #55 fingerprint menu or #56 human rebuild errors.
## 2026-07-10 — #57 live Install Nomarchy affordance
- **Task:** NEXT #57 — durable live-ISO install surface (not only toast).
- **Did:** `xdg.desktopEntries.nomarchy-install` (Terminal=true) on live
HM; Tools Install Nomarchy self-gated on `command -v nomarchy-install`;
welcome toast wording updated. Closed #57.
- **Verified:** **V1** — live config eval exposes entry name/exec;
`downstream-template-home` rebuilds with menu gate. **V2 pending:**
ISO rebuild + smoke that the .desktop appears in the session.
- **Next suggestion:** #58 `nomarchy-detect-hw` CLI.
## 2026-07-10 — #63 generated Waybar logo + powermenu
- **Task:** NEXT #63 — reverse parity: generated bar lacked whole-swap
logo → menu and power icon → power menu.
- **Did:** `custom/nomarchy` (left, Nomarchy.ttf U+F000) + `custom/powermenu`
(right, U+F011 → `nomarchy-menu power`) in generated settings/style.
Fixed Nix `\uf000` pitfall (no `\u` escapes — use literal UTF-8).
Whole-swaps already had both; no JSON/CSS edits. Closed #63.
- **Verified:** **V2**`THEME=tokyo-night` theme-shot: left monogram N,
right power glyph, accent styling; config modules-left/right correct.
Crop paths under `/tmp/theme-shot-tn2-*.png`.
- **Next suggestion:** product path #55 fingerprint menu (or #57 live install).

161
agent/LOOP.md Normal file
View File

@@ -0,0 +1,161 @@
# 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
harness- and vendor-agnostic; the same iteration works under any of:
- **An interactive self-paced loop** in any agent harness (e.g. `/loop`
in Claude Code) — the agent iterates until stopped.
- **Headless** (a one-shot CLI invocation, 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) |
Instructions live next to the state: `VERIFICATION.md` (enforcement),
`DELEGATION.md` (tiers/roles/economy), `GOALS.md`, `THEME-DESIGN.md`.
## Model & token economy
Spend expensive tokens on judgment, not mechanics. Tiers, roles, and the
full delegation rules are in **`DELEGATION.md`**; the loop-specific
habits:
- **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
frontier-tier model running the loop.
- **Delegate mechanical subtasks down.** 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
light/standard-tier subagent per `DELEGATION.md`. The strong model
writes the spec, reviews the result, and owns the commit.
- **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`. If the top task is product-shaped
(UX, release bar, day-2 confidence), also read the matching section of
**`docs/VISION.md`** — do not invent work from VISION; only execute
BACKLOG items. Map of docs vs agent state: `docs/README.md`,
`agent/README.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`.
4. **Sync sweep.** Grep the item's number and feature name across
`agent/` and `docs/` and update or delete every cross-reference the
ship made stale: the PROPOSED pitch that spawned it (and BACKLOG's
v1.0 pointer), HARDWARE-QUEUE entries it supersedes, ROADMAP/README/
docs mentions of the old behavior. While there, prune checked-off
`[x]` HARDWARE-QUEUE entries whose outcome is already recorded
(journal/ROADMAP — git history is the archive). A shipped item must
leave no stale pointer behind; the sweep rides in the same commit.
### 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.

170
agent/MEMORY.md Normal file
View File

@@ -0,0 +1,170 @@
# 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
- **theme-shot softGL cannot start Ghostty** — `btop.png` is best-effort
(usually identical to desktop). Guest asserts on
`~/.config/btop/themes/nomarchy.theme` prove baking; the TUI look is
hardware/GL tier (HARDWARE-QUEUE).
- **Waybar `custom/doctor` tripwire:** status helper must invoke
`nomarchy-doctor` by **absolute store path** (waybar's env can miss
system PATH → `command -v … || exit 0` self-hides forever); empty
`"text"` also self-hides; strip ANSI before packing the tooltip; use
signal 10 + `format = "{}"`. theme-shot asserts class:bad + glyph and
pokes RTMIN+10 before the desktop shot.
- **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.
- **Don't default a "timer/session" feature to V3 — most of it is VM-testable.**
A scheduled/session behaviour usually decomposes into a *generic* step
already covered elsewhere (e.g. `home-manager switch`, exercised by every
theme apply) and a *specific* decision (which theme, when). Stub the generic
step (`NOMARCHY_REBUILD=<marker>` for theme-sync) and **simulate time by
moving the VM clock** (`date -s`, `timedatectl set-ntp false`, `time.timeZone
= "UTC"`), then assert the decision + state change headlessly. `checks.auto-theme`
does exactly this for #79's sunset/sunrise. Only the literal
timer-fires-on-`OnCalendar` is truly on-hardware, and `systemd-analyze
calendar` validates that schedule. (I first mis-framed #79 as V3 — it's V2.)
- **`writeShellScriptBin` scripts run `set -euo pipefail`** (nomarchy-doctor,
the menu, lifecycle CLIs). So a **no-match `grep` inside `$(…)`** (grep exits
1 → command-sub fails → abort) and a **standalone `cond && action`** (false
cond → abort) both kill the script mid-run — the tell is output that stops
before the final/verdict line with no error. Guard: `… | grep … || true`
inside `$()`, `cmd 2>/dev/null || echo 0` for captures, and `if` instead of
`&& action`. (#77 doctor hibernate section; caught by the checks.doctor VM
test on first run.)
- 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).
- **Hibernation reference (Latitude / Newton, BACKLOG #76):** LUKS whole
root BTRFS; `@swap``/swap`; file `/swap/swapfile`;
`boot.resumeDevice` = LUKS root UUID; `resume_offset` from
`btrfs inspect-internal map-swapfile -r`. Swap is encrypted with root
(not a cleartext partition). No zram on that box yet — zram is additive
for live pressure only. Installer already creates this when swapSize>0.
- CI (`.gitea/workflows/check.yml`) is **eval-tier only** (standing
decision 2026-07-10): act_runner docker-compose on the Gitea VPS; no
KVM there. Full VM suite is BACKLOG **FUTURE #20**, not NEXT — needs a
separate nix+/dev/kvm runner. Container gotchas are 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).
- 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).
- **Friendly theme-state load** (`modules/theme-state-read.nix`, #66):
`builtins.tryEval` does **not** catch `readFile`/`fromJSON` failures —
gate with `pathExists` + empty/non-object checks before `fromJSON`.
Subtle JSON syntax errors still surface from nlohmann (line/col);
field schema stays in `theme.nix`. mkFlake must `builtins.seq` the
check onto the whole return set or lazy attr access skips it.
## Design invariants
- **Waybar status is never color-only** (item 28 sweep, iteration #69):
every status module must distinguish its states by SHAPE (glyph) or
presence (self-hide), never color alone — good/warn/bad collapse under
color-blindness. When adding a state, give it a distinct glyph or gate
the module on it; a new `class` that only recolors an existing glyph is
a regression. Suppressed notification states (DND *and* app-inhibited)
all use the bell-off glyph + @muted.
- **Identity themes are not traffic lights** (#69): white, vantablack,
lumon, hackerman, matte-black, miasma — monochrome / mono-hue / earthy
status by design. `audit-theme-design.py` tags their hue/CVD/ANSI-family
findings `[identity]`; do not "fix" them into R/Y/G.
- **Import hierarchy ≠ ANSI** (#70): `import-palettes.py` must not set
surface==overlay when color0==color8; light color0 is often ANSI black
(not a chip). Roles are first-class — never bulk-reimport shipped JSON
without a hierarchy pass.
## Gotchas (cost a debugging session once)
- Waybar `layer: top` renders above **even real-fullscreen windows** — the
bar draws over a fullscreen video. `layer: bottom` lets the fullscreen
surface cover it while the exclusive zone still reserves the bar's space
in normal tiling (trade-off: floating windows can now overlap the bar
strip). Set in both waybar.nix and every whole-swap jsonc (item 30).
- 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: `nomarchy-pull` (lock) then
`nomarchy-rebuild` then `nomarchy-home`, or desktop changes are silently
skipped against the old
lock (README § 3).
- Hyprland 0.55 renames `stayfocused``stay_focused` (and similar
underscore effects); `stayfocused 1` is `invalid field type` at parse
(§ polkit workspace rules, 2026-07-10 hardware).
- Hyprland 0.53 rewrote window rules: `windowrulev2` is a hard error and
the old rule-first `float, class:^…$` no longer parses — both surface a
red config-error banner on the default desktop. Hyprlang legacy form is
now `<effect> <value>, match:<prop> ^…$` (e.g. `float 1, match:class ^…$`);
effects carry a value, matchers take `match:` (§ windowrule migration).
- grub `loadfont`s every `.pf2` in a theme dir — reuse a bundled DejaVu
rather than shipping fonts (§ Distro branding).
- Agent instructions live vendor-neutrally in `agent/` (VERIFICATION,
DELEGATION, THEME-DESIGN; entry AGENTS.md) — `.claude/` is a thin
adapter (permissions + subagent defs only; skills were removed
2026-07-11). Never `git add -A` blindly: check `git status --short`
for genuine strangers first (`settings.local.json`, harness-dropped
files) and commit with explicit pathspecs (§ loop hygiene).

54
agent/README.md Normal file
View File

@@ -0,0 +1,54 @@
# Agent instructions + loop state
Everything an AI agent needs to work on Nomarchy, vendor-neutral and
git-tracked. Protocol: **[LOOP.md](LOOP.md)**. Entry point for every
harness: repo-root **[AGENTS.md](../AGENTS.md)**.
## Instructions (how to work)
| File | Who writes | Role |
|------|------------|------|
| [LOOP.md](LOOP.md) | Human | One-iteration protocol (orient → pick → work → verify → commit → record) + the V0V3 ladder |
| [VERIFICATION.md](VERIFICATION.md) | Human (agents propose) | Enforcement: preflight, honesty rules, visual protocol, hardware-blocked checks, reporting |
| [DELEGATION.md](DELEGATION.md) | Human (agents propose) | Capability tiers, scout/runner roles, token economy, parallel fan-out |
| [GOALS.md](GOALS.md) | Human (agents propose) | Pillars, quality bars, non-goals |
| [CONVENTIONS.md](CONVENTIONS.md) | Human (agents propose) | How to write code/menu/state while shipping |
| [THEME-DESIGN.md](THEME-DESIGN.md) | Human (agents propose) | Theme/visual design instructions |
## State (what's happening)
| File | Who writes | Role |
|------|------------|------|
| [BACKLOG.md](BACKLOG.md) | Both | **Prioritized queue** — only executable work list |
| [JOURNAL.md](JOURNAL.md) | Agents | Append-only iteration log (read last 35 entries; older → [JOURNAL-ARCHIVE.md](JOURNAL-ARCHIVE.md)) |
| [MEMORY.md](MEMORY.md) | Agents | Curated durable gotchas |
| [HARDWARE-QUEUE.md](HARDWARE-QUEUE.md) | Agents append, human checks | On-hardware V3 tests only Bernardo can run |
## Product / design docs (not a queue)
| File | Role |
|------|------|
| [../docs/VISION.md](../docs/VISION.md) | v1.0 product themes — agents slice into BACKLOG PROPOSED |
| [../docs/ROADMAP.md](../docs/ROADMAP.md) | Design history + shipped log |
| [../docs/README.md](../docs/README.md) | Full docs map |
## Harness adapters (vendor-specific, thin)
Shared content never lives in an adapter — adapters only register/route
into the files above, in whatever format their harness requires.
| Path | Harness | Role |
|------|---------|------|
| [../AGENTS.md](../AGENTS.md) | any | Entry point (`CLAUDE.md` is a symlink to it) |
| [../.claude/settings.json](../.claude/settings.json) | Claude Code | Tool permissions |
| [../.claude/agents/](../.claude/agents/) | Claude Code | `nomarchy-scout` / `nomarchy-runner` role defs (contracts in [DELEGATION.md](DELEGATION.md)) |
Do **not** put backlog items, vision text, or policy under an adapter
directory — it is not shared with other agent runners.
## Rules of thumb
1. **Execute** from BACKLOG only (NOW → NEXT; never PROPOSED without human triage).
2. **Orient** with GOALS + CONVENTIONS + MEMORY + last journal + BACKLOG; when the task is product-shaped, also read the relevant **VISION §**.
3. **Record** lasting design in ROADMAP ✓ when something ships that future humans should know; delete the BACKLOG line.
4. **v1 branch** is human-only — never advance from an agent session.

49
agent/THEME-DESIGN.md Normal file
View File

@@ -0,0 +1,49 @@
# Theme design — instructions for visual work
For creating, updating, refining, or troubleshooting Nomarchy themes and
visual design. Act as an experienced UI/UX designer and Linux ricing
expert for Wayland environments: themes here must be cohesive,
intentional, and visually striking — aesthetics are a load-bearing
feature of this distro (`agent/GOALS.md` pillar 4).
## Context & architecture
All theme data lives in `themes/` at the repo root — one JSON palette per
theme plus per-app assets (whole-swap `waybar.jsonc`/CSS, btop themes,
wallpapers). Before generating any new configuration, read the existing
files there: learn how current themes are structured, how the syntax is
formatted for each application, and how they integrate into the NixOS/HM
modules. Always match the established pattern — there is no second
theming pipeline (`agent/GOALS.md` non-goals), so a new visual surface
consumes the palette from the state JSON, never a side file.
## Design responsibilities
1. **Holistic design:** a theme spans Hyprland (borders, shadows,
animations), Waybar, Ghostty, btop, fastfetch, rofi, and wallpaper —
one coherent identity, no unthemed corner.
2. **Color theory:** create or adapt advanced palettes. Draw inspiration
from established aesthetics (Everforest, Nord, Gruvbox) or r/unixporn
trends, but innovate. Ensure harmony between background, foreground,
accents, and warning/error colors.
3. **Typography & iconography:** pair UI and monospace fonts and icon
themes to the specific vibe of the palette.
4. **Accessibility:** high contrast for text readability; status
information is never color-only (see `agent/MEMORY.md` design
invariants — glyph/shape carries state, and identity themes are
deliberately not traffic-lights).
## Process
1. Read `themes/` to understand the current structure.
2. State the "vibe", the primary palette (hex codes), and typography
choices of the proposed design before implementing.
3. Implement by generating or updating files within the `themes/`
structure, following `agent/CONVENTIONS.md` (Waybar parity rule:
whole-swap `waybar.jsonc` files stay in sync with the generated
config).
4. Verify per `agent/VERIFICATION.md` §3 — scripted checks first
(`tools/check-theme-contrast.py`, `tools/audit-theme-design.py`), then
headless before/after screenshots you actually view, under at least
two themes. New themes are imported via `tools/import-palettes.py` and
round-tripped through the theme switcher.

178
agent/VERIFICATION.md Normal file
View File

@@ -0,0 +1,178 @@
# Verification — the enforcement rules
Nomarchy's promise to its user: a rock-stable, fully functional, beautiful
workstation that is reproducible, easy to recover, and never requires the
user to become a Nix expert. Every rule below exists to protect that
promise. A change that works but degrades stability, aesthetics, or
user-simplicity is a regression, not a feature.
This document is an **enforcer**, not the workflow itself: the ladder and
iteration protocol live in `agent/LOOP.md`, VM instructions and gotchas in
`docs/TESTING.md`. It applies to **every** change to this repo — features,
fixes, theming, module changes, lock bumps, docs, backlog grooming.
"Small" or "obvious" changes are exactly where verification gets skipped,
so they trigger it too. If this file and those docs ever disagree, fix the
discrepancy in the same or a follow-up commit so they can't disagree
twice.
## 1. Preflight (once per session)
Before starting work, establish what verification tier this environment
can reach, so you never promise verification you can't deliver:
1. Linux x86_64 host? `/dev/kvm` present and readable?
2. Enough free disk for an image/ISO build (multi-GB)?
3. Network access for a cold Nix store?
If the environment cannot reach V2 (no KVM, no disk, etc.): say so
immediately, do the V0/V1 work honestly, mark the change **"V2 pending"**
exactly as you would mark a hardware-blocked change "V3 pending" (§4),
and stop short of claiming the change is done. Never simulate, guess, or
describe what a VM test "would" show.
## 2. The verification ladder (enforcement rules)
Climb the V0V3 ladder as defined in `agent/LOOP.md`. Four non-negotiable
enforcement rules on top:
1. **V2 is mandatory for anything user-visible.** If a user of the
installed system could perceive the change — behavior, layout, colors,
keybinds, timing, error messages — it must be exercised in the local
VM before commit. Docs-only, comment-only, or agent-notes changes may
stop at the tier LOOP.md assigns them; user-visible changes may not.
2. **Every "done" report names the tier reached and shows the evidence.**
Evidence means: the command run and its relevant output, the checklist
items exercised, and for visual work the screenshots viewed (§3). "It
builds" is a V1 claim, not a V2 claim. Never let a report imply a
higher tier than was actually reached.
3. **A failed or flaky test is a result, not an obstacle.** Distinguish
real failures from environment flakes using the known-gotchas section
of `docs/TESTING.md` (e.g. no-KVM slowness, missing guest GL). If you
cannot confidently classify a failure, report it as unresolved — do
not retry until green and report only the green run.
4. **VM runs are headless and unattended.** Use the repo's headless
harness — `tools/test-live-iso.sh` and `tools/test-install.sh` for
boot/install runs, `tools/vm/qmp.py` for programmatic VM control and
`tools/vm/vncshot.py` for screen capture — never a graphical VM window
or any flow that needs a human at the console. The human is not part
of the test loop: do not pause mid-run to ask them to look at the VM,
click something, or confirm what is on screen. A run must complete on
its own and leave auditable artifacts behind (logs, serial console
output, exit codes, screenshots), with every wait bounded by a timeout
so a hang becomes a recorded failure instead of a stalled session.
Prefer scripted assertions (process up, file exists, service/D-Bus
state, the checks in `tools/`) over eyeballing; where judgment is
genuinely needed — visual quality — *you* view the captured
screenshots (§3), not the human. The human reviews evidence in the
final report, never the live run.
### Regression scope after a change
Re-running the full checklist for every change wastes VM time; running
nothing invites regressions. Default rule:
- Always: the session-sanity items (boot to session, bar renders).
- Plus: every checklist item touching the layer you changed.
- Plus: the theming end-to-end item whenever theming plumbing changed,
even indirectly (palette generation, symlinks, reload hooks).
- Lock bumps and toolchain changes: run the full checklist — their blast
radius is unknowable by construction.
## 3. Visual verification protocol
Visual quality is a core feature of Nomarchy, so "it probably looks fine"
is never verification. A visual/UI change is not V2-verified until all of
the following are true:
1. **Before/after screenshots** of the changed surface were captured
headlessly — `tools/theme-shot.nix` for reproducible theme renders,
`tools/vm/vncshot.py` (driven via `tools/vm/qmp.py`) for captures from
a running VM. No VM window, no human interaction. Capture the "before"
from the base branch or prior generation, not from memory.
2. **Scripted checks first**: run `tools/check-theme-contrast.py` and
`tools/audit-theme-design.py` against the affected theme(s) before any
eyeballing — machine-checkable legibility/design violations should
never survive to the judgment stage. Use `tools/vm/gap-analysis.py`
where it applies.
3. **Two themes**: repeat the "after" capture under at least two themes,
one with a generated palette and one whole-swap theme (e.g.
summer-night). These exercise different code paths in the bar/launcher
theming; a change that looks right under one can silently break the
other.
4. **You actually viewed the images** — open the screenshot files and
look at them. State concretely what you inspected: alignment, spacing,
contrast/legibility against the palette, icon rendering, no clipped or
overlapping elements, and that the change looks intentional next to
the "before".
5. Keep the screenshots in the run's working area and reference their
paths in the report, so the human can audit the same evidence.
If the VM cannot render the surface faithfully (known GL/compositor gaps
in the guest — see `docs/TESTING.md`), that specific visual aspect is
hardware-blocked: verify everything the VM *can* show, and queue the rest
per §4.
## 4. Hardware-blocked checks
Some checks genuinely require real hardware (GPU behavior, multi-monitor
hotplug, audio devices, power/suspend, firmware). For those:
1. Add an entry to `agent/HARDWARE-QUEUE.md` with: what changed, **exact**
reproduction steps a human can follow verbatim, the expected
observation (what "pass" looks like), and the commit hash once known.
2. Mark the commit body **"V3 pending: <one-line summary>"**.
3. Say it plainly in your report. A hardware-blocked check is not a
failure and not something to hide — hiding it is the failure.
4. When the human reports back, close the queue entry in the next commit
and record the outcome; if it failed on hardware, that's a new bug at
the top of the backlog.
Do not use the hardware queue as an escape hatch: if a check *can* be
done in the VM, it must be. "The VM is slow" does not qualify.
## 5. Maintenance work
Maintenance follows the same ladder:
- **Flake lock bumps**: treat as maximum-blast-radius changes. Build,
boot the VM, run the full regression checklist, and do a visual
spot-check of the session (themes can shift with upstream package
changes). Never merge a lock bump on "it evaluates".
- **Theme imports / new themes**: import via `tools/import-palettes.py`,
then the full §3 visual protocol; additionally verify the theme-switch
round trip (into the new theme and back out).
- **Docs drift**: run `tools/check-option-docs.py` after any change that
adds or modifies options, and fix drift in the same commit as the code
change that created it. Doc-only fixes are V0 — but verify any command
you document by actually running it.
- **Backlog grooming / agent-notes**: V0; keep entries consistent with
the conventions in `agent/`.
## 6. Guarding the philosophy
Before committing, check the change against the distro's promises
(`agent/GOALS.md` is the full statement):
- **User is not a Nix expert.** If the change requires the user to write
or read Nix to use the feature day-to-day, redesign it. Configuration
the user touches must stay in the simple, documented surface the repo
defines.
- **Rock-stable and recoverable.** Prefer boring, reproducible mechanisms
over clever ones. Any change that could break boot or the session must
have an obvious rollback story (NixOS generations count, but say so).
- **Aesthetics are load-bearing.** A functionally correct but visually
regressive change fails review by definition — that's what §3 is for.
When a requested change conflicts with these promises, stop and raise the
conflict instead of implementing it quietly.
## 7. Reporting format
End every unit of work with a short report containing:
1. What changed (one paragraph, plain language).
2. Verification tier reached, with evidence (commands + key output,
checklist items run, screenshot paths viewed).
3. Anything pending: "V2 pending" (environment) or "V3 pending"
(hardware, with queue entry reference).
4. Follow-ups added to the backlog, if any.

437
docs/HARDWARE.md Normal file
View File

@@ -0,0 +1,437 @@
# Hardware support
How Nomarchy enables CPUs, GPUs, laptops, firmware, and peripherals — and
what to do when your machine is not in the happy path.
> **Queue:** [`agent/BACKLOG.md`](../agent/BACKLOG.md) (PROPOSED Hardware
> product). Product framing: [`VISION.md`](VISION.md) § A. Design history:
> [`ROADMAP.md`](ROADMAP.md). Docs map: [`README.md`](README.md).
> Migration: [`MIGRATION.md`](MIGRATION.md).
## 1. Architecture (three layers)
Nomarchy does **not** reimplement [nixos-hardware](https://github.com/NixOS/nixos-hardware).
It stacks:
```
┌─────────────────────────────────────────────────────────────────────┐
│ A. Always-on desktop floor │
│ modules/nixos/default.nix + power.nix + audio/BT │
│ redistributable firmware · fwupd · NM · PipeWire · PPD · ddcci │
└───────────────────────────────┬─────────────────────────────────────┘
┌───────────────────────────────▼─────────────────────────────────────┐
│ B. nixos-hardware profiles (via mkFlake hardwareProfile) │
│ common-cpu-* · common-gpu-* · common-pc(-laptop|-ssd) · model │
│ microcode · GPU media · fstrim · vendor/model quirks │
└───────────────────────────────┬─────────────────────────────────────┘
┌───────────────────────────────▼─────────────────────────────────────┐
│ C. nomarchy.hardware.* (modules/nixos/hardware.nix) │
│ gap above commons: GuC · amd-pstate · VA-API env · fprintd · │
│ IR-webcam hide · ROCm/NPU/latestKernel · I2C/DDC/CI │
└─────────────────────────────────────────────────────────────────────┘
```
| Layer | Who turns it on | Where it lives |
|-------|-----------------|----------------|
| **A** | Distro defaults (`mkDefault`) | Any machine importing `nomarchy.nixosModules.nomarchy` |
| **B** | Installer DMI/`lspci``hardwareProfile = [ … ]` | Downstream `flake.nix` (`mkFlake`) |
| **C** | Installer probes → `system.nix`, or hand-edit | Downstream `system.nix` |
`hardware-configuration.nix` (from `nixos-generate-config`) still owns
initrd modules, filesystems, and the usual generate-config flags. The
installer writes a real one; the template ships a placeholder you must
replace.
Unknown `hardwareProfile` names **fail at eval** with Levenshtein
suggestions (`lib.nix`) — never silently ignored.
## 2. What works without thinking about it
These ship on every Nomarchy system unless you override them:
| Capability | Default | Notes |
|------------|---------|--------|
| WiFi / BT firmware blobs | `hardware.enableRedistributableFirmware` | iwlwifi, ath, rtw, brcm, SOF, … |
| NetworkManager | on | WiFi UI: System Network |
| PipeWire + WirePlumber | on | |
| Bluetooth + blueman | on | System Bluetooth |
| power-profiles-daemon | on | Menu + Waybar; TLP via `nomarchy.system.power.backend = "tlp"` |
| External monitor brightness | `nomarchy.hardware.i2c.ddcci` **on** | DDC/CI → backlight devices |
| Firmware updates (LVFS) | `services.fwupd.enable` | **Never auto-flashes** — see §4 |
| SMART / UPower / pcscd (FIDO) | on | |
| earlyoom | on | Process-level OOM, not cgroup kill of the whole session |
**Not** default-on (need detect or uncomment): fprintd, GuC/HuC,
amd-pstate, ROCm, NPU, thermald, charge limit, snapper (installer enables
snapper), `latestKernel`.
## 3. Install path (best experience)
`nomarchy-install` sources `pkgs/nomarchy-install/hardware-db.sh` and:
1. Probes CPU vendor, GPUs (`lspci`), battery, SSD, fingerprint USB VIDs,
RGB+IR webcam names, NPU PCI class.
2. Looks up DMI `sys_vendor` × `product_name` in a ~60-entry model table
(Framework, Dell XPS/Latitude, ThinkPad, Surface, ASUS ROG, Apple T2,
System76).
3. Emits `MODULE …` lines → `hardwareProfile` list in `flake.nix`.
4. Emits `NOMARCHY hardware.*=…` → active + commented blocks in
`system.nix`.
5. Asks you to confirm profiles (or pick manually from the full
nixos-hardware attr list baked into the ISO). Override with
`NOMARCHY_HW=auto|none|"mod1 mod2"`.
**Live ISO** is intentionally *generic* (no model profile): broad GPU
modules as *available*, redistributable firmware on, no baked
`nomarchy.hardware.*`. Tuning happens at install time.
Out of scope for the installer today: aarch64 (Pi, Snapdragon X), Steam
Deck (Jovian), Apple Silicon (T2 Intel Macs only in the DB).
## 4. Firmware updates (deep dive)
### What we ship
```nix
# modules/nixos/default.nix
services.fwupd.enable = lib.mkDefault true;
```
fwupd talks to the [LVFS](https://fwupd.org/): UEFI capsule (BIOS), some
SSDs, docks, Thunderbolt controllers, peripherals. It **only refreshes
metadata** on its own. Applying an update is always an explicit user
action.
### What you do today (CLI)
```sh
# After first boot (and occasionally later):
fwupdmgr refresh # optional; daemon often has metadata
fwupdmgr get-devices # what LVFS can see
fwupdmgr get-updates # pending
fwupdmgr update # apply (may need reboot / battery / AC)
```
Disable on VMs/headless: `services.fwupd.enable = false;` in `system.nix`.
### Why this is under-discovered
| Present | Missing |
|---------|---------|
| Daemon + package | Doctor check (“updates available”) |
| README one-liner | Waybar / updates panel integration |
| **System ▸ Firmware menu** (`nomarchy-menu firmware`) | |
| MOTD + first-boot tip (#43) | |
**System ▸ Firmware** (shipped): a self-gated System-submenu row (present
whenever `fwupdmgr` is on PATH, i.e. `services.fwupd.enable`, default-on)
that opens a terminal and runs `fwupdmgr refresh``get-updates`
confirm → `fwupdmgr update`. It never auto-flashes — `fwupdmgr update`
confirms each device and prompts for the reboot a capsule needs; pillar 1
(rock-stable) forbids silent BIOS writes.
**Hints (#43 / #73):** MOTD cheat-sheet line + `nomarchy-control-center
--first-boot` tip when `fwupdmgr` is on PATH. Fingerprint / doctor tips
follow the same pattern (fingerprint MOTD only when
`nomarchy.hardware.fingerprint.enable`).
**Still queued:** a Doctor “updates available” check, and
Waybar/updates-panel integration.
### Thunderbolt
Firmware for TB devices may appear via LVFS. There is **no** first-class
`services.hardware.bolt` / security-level UI in Nomarchy — use plain
NixOS options if you need bolt.
### Microcode / kernel firmware
- **CPU microcode:** from nixos-hardware `common-cpu-*` and
`nixos-generate-config` once redistributable firmware is on — not a
separate `nomarchy.*` toggle.
- **GPU GuC/HuC (Intel i915):** `nomarchy.hardware.intel.guc`
`i915.enable_guc=3`. Installer turns this **off** when the bound driver
is `xe` (GuC is default there; the param is ignored).
- **`hardware.enableAllFirmware`:** not set. Oddball non-redistributable
WiFi still needs a manual NixOS fix (or a broader policy decision).
## 5. Fingerprint (deep dive)
### Detect → enable
Installer scans USB vendor IDs common to libfprint (Goodix, Synaptics,
Elan, …) via `lsusb` or `/sys/bus/usb/.../idVendor`. On hit:
```nix
nomarchy.hardware.fingerprint.enable = true; # services.fprintd
# nomarchy.hardware.fingerprint.pam = true; # login + sudo (opt-in)
```
### Enroll (menu or CLI)
**Shipped #55:** System Fingerprint (self-gated when `fprintd-list` is
on PATH) — Enroll / List / Verify / Delete all, plus **Use for login**
which writes `settings.fingerprint.pam` and applies on the next
`sys-rebuild` (option default follows theme-state.json).
**Hints (#73):** MOTD line when `fingerprint.enable` is on; first-boot
tip when `fprintd-list` is on PATH (`SUPER+M → System Fingerprint` /
`fprintd-enroll`). No permanent nag without a reader.
```sh
# CLI still works:
fprintd-enroll
fprintd-list "$USER"
# or: System Fingerprint Use for login (on) → sys-rebuild
```
PAM stays opt-in on purpose: password-only remains the cautious default
until a finger is enrolled. Full enroll on a real reader is V3/hardware.
### Doctor
`nomarchy-doctor` reports fprintd unit + enroll status when present.
## 6. NVIDIA (deep dive)
### What happens at install
```
lspci → NVIDIA ⇒ MODULE common-gpu-nvidia
```
That is the **entire** Nomarchy surface. Unfree packages are allowed
(`allowUnfree = true` in the module and in `mkFlake`), so the proprietary
path from nixos-hardware can evaluate.
There is **no** `nomarchy.hardware.nvidia.*` for:
- hybrid / PRIME (Intel+NVIDIA, AMD+NVIDIA)
- open vs closed kernel module
- power management / udev / suspend quirks
- CUDA / container toolkit packages
### What a hybrid laptop user must do today
1. Confirm `common-gpu-nvidia` (and often `common-gpu-intel` or
`common-gpu-amd`) are in `hardwareProfile`.
2. Read the relevant nixos-hardware module and [NixOS NVIDIA wiki](https://wiki.nixos.org/wiki/Nvidia).
3. Add plain NixOS options in `system.nix`, for example (illustrative —
hardware-specific):
```nix
# Example only — verify against your generation and nixos-hardware module.
# hardware.nvidia.prime = { ... };
# hardware.nvidia.powerManagement.enable = true;
# hardware.nvidia.open = false; # or true for open modules on newer cards
```
4. Rebuild the system. Home Manager does not own the driver.
### Live ISO note
The ISO lists `nouveau` among *available* initrd modules and does **not**
force-load every GPU driver (avoids multi-driver panics). Proprietary
NVIDIA on the live session is not the product focus; installed systems
use the profile.
**Product direction (shipped #59; first-class wrappers deferred for v1):**
when `common-gpu-nvidia` is in `hardwareProfile`, the installer emits a
**commented** `system.nix` block with PRIME / power / open-module pointers
(same pattern as ROCm / NPU comments). No `nomarchy.hardware.nvidia.*`
stack for v1 — hybrid PRIME remains wiki/plain NixOS until a maintainer
commits to hardware testing.
## 7. Day-2: “my laptop is mostly fine, make it fully fine”
| Goal | How | Rebuild? |
|------|-----|----------|
| Power profile (perf/balanced/saver) | System menu / Waybar | No (PPD D-Bus) |
| Charge limit 80% | Menu / CC — live sysfs + state; boot oneshot re-applies | No rebuild for live; rebuild bakes Nix option |
| Thermald (Intel) | Installer on for Intel laptops; else `power.thermal.enable` | System |
| Fingerprint enroll | `fprintd-enroll` | No |
| Fingerprint login | `fingerprint.pam = true` | System |
| ROCm / Intel compute | Uncomment opt-ins in `system.nix` | System (large) |
| NPU driver | Uncomment `npu` (+ often `latestKernel`) | System; userspace BYO |
| Newer kernel for brand-new silicon | `nomarchy.hardware.latestKernel = true` | System |
| Hide IR “dark camera” | Installer or `camera.hideIrSensor` | System |
| Firmware | `fwupdmgr update` | Reboot if capsule requires |
| Model quirks missing | Set better `hardwareProfile` (see §8) | System |
| OpenRGB / printing / Steam | `nomarchy.services.*` | System |
### Dual-sensor webcam / IR hide
Many dual-sensor modules (e.g. ThinkPad T14s) expose colour + IR as two
identically named “Integrated Camera” nodes. Picking the IR node yields a
black/dark greyscale frame — the classic “my webcam is dark” symptom.
`nomarchy.hardware.camera.hideIrSensor` (installer-on when RGB+IR names
are detected; overridable `irMatch`) drops the IR node from **WirePlumbers
v4l2 monitor** so native PipeWire pickers only offer the colour camera.
The kernel `/dev/video*` node stays open, so Howdy-style face unlock still
works. Design history: [ROADMAP § Webcam](ROADMAP.md).
**What it does not cover:** apps that list cameras via **libcamera** or
the **xdg-desktop-portal** / **Flatpak** camera path still see both
sensors — a Flatpak Zoom (or similar) user can still pick the black IR
“camera”. That is intentional: a blanket libcamera disable would risk
external USB cams that need the libcamera path, and surgical internal-only
libcamera rules could not match early enough (only `device.api` binds
before the WirePlumber monitor rule).
**Workaround:** prefer the colour device in the picker (or any non-IR
name). Apps that default to the first v4l2 colour source without a picker
are fine.
**Further engineering (needs T14s-class hardware):** (b) a WirePlumber
*libcamera* monitor rule disabling GREY-only nodes; (c) a libcamera/udev
quirk at the libcamera layer. Neither is implemented — the recommended
path is document-only until those can be verified on real dual-sensor
hardware (see `agent/HARDWARE-QUEUE.md` T14s).
Theme switches never touch drivers (Home Manager only).
## 8. Unsupported or unlisted machines
### Still good floor
Even with **no** DMI hit you usually get:
- `common-cpu-{intel,amd}`
- `common-gpu-{intel,amd,nvidia}` as applicable
- `common-pc` or `common-pc-laptop` + optional `common-pc-ssd`
- `nomarchy.hardware.intel` or `.amd` when vendor matches
- Layer A (firmware, audio, BT, PPD, fwupd)
You **lose** model-specific EC/suspend/keyboard/audio quirks that only
exist in a named nixos-hardware module.
### Pick a profile after install
1. List candidates:
```sh
nix eval github:NixOS/nixos-hardware#nixosModules \
--apply builtins.attrNames
# or: the ISO ships hardware-modules.txt for the same list
```
2. Set in your flake (installer already uses a list):
```nix
hardwareProfile = [
"common-cpu-amd"
"common-gpu-amd"
"common-pc-laptop"
"common-pc-ssd"
"lenovo-thinkpad-t14-amd-gen3" # if it matches
];
```
3. `sudo nixos-rebuild switch --flake ~/.nomarchy#default`
### Template / migration (no installer)
See [MIGRATION.md](MIGRATION.md): reuse `hardware-configuration.nix`, set
`hardwareProfile`, uncomment `nomarchy.hardware` / power in `system.nix`.
Post-install re-probe (**shipped #58**):
```sh
nomarchy-detect-hw # human report + suggested snippets
nomarchy-detect-hw --raw # MODULE / NOMARCHY / DETAIL protocol lines
```
Prints suggested `hardwareProfile` and `system.nix` lines; **does not**
rewrite the flake. Paste after review, then `sudo nixos-rebuild switch`.
## 9. Adding your model to the distro
For contributors (and power users who will PR):
1. On the machine:
```sh
cat /sys/class/dmi/id/sys_vendor
cat /sys/class/dmi/id/product_name
```
2. Find a matching attr in
[nixos-hardware](https://github.com/NixOS/nixos-hardware) (or add one
upstream first).
3. Append to `pkgs/nomarchy-install/hardware-db.sh` in `HARDWARE_DB`:
```bash
"VendorRegex|Product regex|nixos-hardware-module-name"
```
First match wins — put specific lines above broad fallbacks.
4. Verify the name exists in the **pinned** nixos-hardware input
(`mkFlake` will throw if not). A CI check that every DB name ∈
`nixosModules` is queued so lock bumps cannot silently break installs.
5. Optional: note on-hardware QA steps in `agent/HARDWARE-QUEUE.md`.
## 10. Doctor and hardware health (current vs target)
**Today** (`nomarchy-doctor`): failed units, disk space, theme-state
validity/git, generation age, snapper timer. **No** WiFi, GPU, fwupd,
or fingerprint checks.
**Target checks** (queued, all read-only, each failure prints one fix
command — same doctor contract):
| Check | Pass condition | Suggested fix line |
|-------|----------------|--------------------|
| NetworkManager | device connected or wifi radio on | open System Network |
| Audio sink | default sink exists | System Audio |
| GPU accel | `glxinfo`/`vainfo` smoke (if installed) | check `hardwareProfile` / drivers |
| Fingerprint | if USB VID matched / fprintd unit | `fprintd-enroll` or enable option |
| fwupd | daemon active; optional “updates pending” warn | `fwupdmgr get-updates` |
| Battery threshold | if laptop + limit set, sysfs writable | power docs |
## 11. Option quick reference
Full tables: [README § options](../README.md). Hardware-shaped surface:
| Option | Role |
|--------|------|
| `mkFlake.hardwareProfile` | nixos-hardware name or list |
| `nomarchy.hardware.intel.enable` / `.guc` / `.computeRuntime` | Intel gap layer |
| `nomarchy.hardware.amd.enable` / `.pstate` / `.vaapi` / `.rocm.*` | AMD gap layer |
| `nomarchy.hardware.fingerprint.enable` / `.pam` | fprintd + PAM |
| `nomarchy.hardware.npu.enable` | in-kernel NPU only |
| `nomarchy.hardware.latestKernel` | `linuxPackages_latest` |
| `nomarchy.hardware.camera.hideIrSensor` / `.irMatch` | dual-sensor webcams (v4l2 only; §7) |
| `nomarchy.hardware.i2c.enable` / `.ddcci` | I2C + external backlight |
| `nomarchy.system.power.*` | PPD/TLP, laptop, thermald, charge limit |
| `nomarchy.system.bluetooth.enable` | BT stack |
| `services.fwupd.enable` | LVFS (native NixOS, default on) |
## 12. Ease summary
| Situation | Effort |
|-----------|--------|
| Known model in DB (Framework, many ThinkPads, …) | Low — install and go |
| Unknown modern Intel/AMD laptop | Lowmedium — commons cover a lot |
| Firmware updates | Medium — CLI only until product work lands |
| Fingerprint for login | Medium — enroll CLI + PAM uncomment |
| Hybrid NVIDIA | High — plain NixOS/wiki territory |
| ROCm / NPU userspace | High — opt-in + expert |
| Contribute a new DMI line | Medium for someone who can PR |
## 13. Related files
| Path | Role |
|------|------|
| `modules/nixos/hardware.nix` | `nomarchy.hardware.*` |
| `modules/nixos/power.nix` | PPD/TLP, charge limit, thermald |
| `modules/nixos/default.nix` | firmware, fwupd, ddcci default |
| `pkgs/nomarchy-install/hardware-db.sh` | DMI DB + probes |
| `pkgs/nomarchy-install/nomarchy-install.sh` | writes flake + system.nix |
| `lib.nix` | `hardwareProfile` resolution |
| `templates/downstream/system.nix` | commented opt-ins |
| `agent/HARDWARE-QUEUE.md` | on-machine V3 checks |

415
docs/MIGRATION.md Normal file
View File

@@ -0,0 +1,415 @@
# Migrating an existing NixOS machine to Nomarchy (no reinstall)
If your machine already runs NixOS, you do **not** need the installer or a
reformat to adopt Nomarchy. Nomarchy is a flake; "installing" it onto an
existing NixOS box means pointing `nixos-rebuild` at a Nomarchybased flake
that reuses your current `hardware-configuration.nix`. Nothing repartitions,
and your `/home` is never written to by an activation.
This guide is written generically, with **TuringMachine** — a Lenovo AMD
Ryzen 7840U / Radeon 780M laptop, LUKS + btrfs, systemdboot — as the
concrete worked example. Substitute your own values where called out.
> **The promise:** every step below is reversible. You keep three
> independent rollback nets (NixOS generations, a btrfs snapshot, and the
> fact that `nixos-rebuild test` never changes the boot default), and your
> files live on a separate subvolume that no config switch touches.
---
## 0. Is your machine a good candidate?
Migration is cleanest when your machine already matches Nomarchy's
assumptions. Check each:
| Nomarchy expects | Check it | If it differs |
|---|---|---|
| **systemdboot** | `bootctl status` → "Product: systemdboot" | GRUB works too; keep your loader config in `system.nix` |
| **btrfs** with `@`/`@home` subvolumes | `findmnt -t btrfs` | ext4/xfs boot fine — you just don't get snapshots |
| `@snapshots` + `@home-snapshots` subvols | in `findmnt` output | snapper features need them; create them or skip snapshots |
| **LUKS** (optional, themed prompt) | `lsblk -f` shows `crypto_LUKS` | none — LUKS is optional |
| Already a **flake** config | `test -f /etc/nixos/flake.nix` | fine either way; you'll write a fresh flake regardless |
**Installer vs migration snapshot layout.** A fresh Nomarchy install
(disko) creates a top-level `@snapshots` subvolume mounted at
`/.snapshots`, then a first-boot oneshot makes a *nested*
`/home/.snapshots` under `@home` for snapper's home timeline — it does
**not** create a separate top-level `@home-snapshots`. Migration machines
(e.g. TuringMachine) may already use top-level `@snapshots` **and**
`@home-snapshots`; that is fine. Snapper only needs a `.snapshots` path
under each tracked subvolume (`/` and `/home`), so either layout works —
reuse what you have, or create the missing pieces if you want snapper
without reformatting.
TuringMachine matches all of these, including the `@snapshots` /
`@home-snapshots` subvolumes — so snapper works with zero disk work.
**Version note.** Nomarchy pins `nixos-26.05`. If you're on an older release
(TuringMachine is on **25.11**), the migration folds a onerelease upgrade
into the switch. That's normal and supported — read the
[NixOS 26.05 release notes](https://nixos.org/manual/nixos/stable/release-notes)
for option/package renames, and lean on generations if something regresses.
---
## 1. The two rules that protect your data
1. **Never bump `system.stateVersion`.** It is an ondisk/service
compatibility marker tied to when the machine was *first installed* — not
the nixpkgs version. Nomarchy's template ships `26.05`; you **must** change
it back to your machine's original value. Find yours:
```console
$ nixos-option system.stateVersion # or: nix eval .#nixosConfigurations.<host>.config.system.stateVersion
"24.11"
```
*(TuringMachine: `24.11`.)*
2. **`/home` is never touched by an activation.** A `nixos-rebuild switch`
swaps the system generation; your data subvolume is untouched. The Phase 0
snapshot is beltandsuspenders, not a necessity for file safety.
---
## Phase 0 — Safety net (nothing changes yet)
```bash
# Read-only btrfs snapshots of root and home — instant rollback targets.
sudo btrfs subvolume snapshot -r / /.snapshots/pre-nomarchy-root
sudo btrfs subvolume snapshot -r /home /home/.snapshots/pre-nomarchy-home
# Freeze your current config as a clean git baseline.
cd /etc/nixos && git add -A && git commit -m "pre-nomarchy baseline" || true
```
You are currently booted in a knowngood generation; it remains in the
systemdboot menu throughout. Worst case at any later step: reboot and pick
it.
---
## Phase 1 — Build the Nomarchy flake alongside (no switch)
Stand the new config up in a working directory and **build** it without
activating. This is the real safety line: iterate here until it builds green
before anything touches the running system.
```bash
git clone https://git.bemagri.xyz/bernardo/nomarchy.git ~/nomarchy-migrate
cd ~/nomarchy-migrate
# Or start from the downstream template:
# nix flake init -t "git+https://git.bemagri.xyz/bernardo/nomarchy.git?ref=v1"
# (produces flake.nix/system.nix/home.nix/…)
```
A Nomarchy downstream flake owns exactly five files. Assemble them:
### `hardware-configuration.nix` — reuse yours unchanged
Copy your **existing** hardware config in verbatim. This is what preserves
your disks, LUKS, btrfs subvolumes and swap — the reason no reinstall is
needed.
```bash
cp /etc/nixos/hosts/TuringMachine/hardware-configuration.nix ./hardware-configuration.nix
```
### `flake.nix` — one `mkFlake` call (from the template)
```nix
{
description = "TuringMachine — Nomarchy";
inputs.nomarchy.url = "git+https://git.bemagri.xyz/bernardo/nomarchy.git?ref=v1";
outputs = { nomarchy, ... }:
nomarchy.lib.mkFlake {
src = ./.;
username = "bernardo"; # <- your login name
# Optional nixos-hardware profile(s) for your model. For an AMD laptop:
# hardwareProfile = [ "common-cpu-amd-pstate" "common-gpu-amd" "common-pc-laptop-ssd" ];
# Names: https://github.com/NixOS/nixos-hardware (verify before use)
# Full hardware story (firmware, fingerprint, unsupported machines):
# docs/HARDWARE.md in the Nomarchy repo
};
}
```
### `system.nix` — machine specifics + your decisions
This is where your three migration decisions land: **PPD power (no
ryzenadj)**, **no Secure Boot**, and the **stateVersion override**.
```nix
{ pkgs, username, ... }:
{
# Plain systemd-boot — no lanzaboote / Secure Boot.
boot.loader.systemd-boot.enable = true;
boot.loader.efi.canTouchEfiVariables = true;
networking.hostName = "TuringMachine";
time.timeZone = "Europe/London"; # your zone
i18n.defaultLocale = "en_US.UTF-8";
users.users.${username} = {
isNormalUser = true;
extraGroups = [ "wheel" "networkmanager" "video" "input" ];
};
# ── Power: Nomarchy's PPD (drops all custom ryzenadj/TLP tuning) ──────
nomarchy.system.power = {
enable = true;
backend = "ppd"; # power-profiles-daemon
laptop = true;
batteryChargeLimit = 80; # optional longevity cap
};
# ── AMD 7840U / Radeon 780M ──────────────────────────────────────────
nomarchy.hardware.amd.enable = true; # amd-pstate + radeonsi VA-API
# nomarchy.hardware.amd.rocm.enable = true; # opt-in GPU compute (multi-GB)
# CRITICAL: keep your ORIGINAL install's value — never Nomarchy's 26.05.
system.stateVersion = "24.11";
}
```
> **Dropped on purpose (decisions 1 & 2):** your old
> `modules/services/power-management.nix` ryzenadj stack, the `lanzaboote`
> input, and the `power-max`/`power-stealth` scripts. PPD's
> performance/balanced/powersaver profiles (switchable from the Waybar
> battery/profile icons and `nomarchy-menu`) replace them.
### `home.nix` — your apps on top of Nomarchy's desktop
Start from the template's `home.nix` (it ships the default app set) and add
your personal packages/config. Carry over anything you still want (e.g. your
emacs setup).
```nix
{ pkgs, lib, ... }:
{
# Nomarchy hardcodes home.stateVersion = "26.05". Moving home-manager's
# stateVersion is low-risk, but if you want to pin your original:
home.stateVersion = lib.mkForce "24.11";
home.packages = with pkgs; [
# your extras — e.g. emacs, language toolchains, …
];
}
```
### `theme-state.json`
Copy the template's `theme-state.json` (or let `nomarchy-menu theme` write
it after the switch). Your old `nomarchy-state.nix` prototype (schema
`nomarchy.theme = "nord"` …) is **retired** — the current distro uses this
JSON. `nord` is a shipped Nomarchy theme, so you lose nothing.
### The build gate
```bash
nixos-rebuild build --flake ~/nomarchy-migrate#default
```
Zero activation — this only evaluates and builds the system closure. Fix any
eval/build error here, in isolation, before touching the running machine.
Expect to resolve a few 25.11→26.05 option renames.
---
## Phase 2 — Reversible activation
```bash
# Activates now, but does NOT become the boot default. If the session
# breaks, REBOOT and you are back in your old generation, untouched.
sudo nixos-rebuild test --flake ~/nomarchy-migrate#default
# Bring the desktop (home-manager) up:
home-manager switch --flake ~/nomarchy-migrate#bernardo
```
Log into Hyprland and sanitycheck: Waybar renders, `SUPER+M` opens the
menu, theming is coherent, `SUPER+?` shows the cheatsheet. Confirm the
machinespecific things you care about still work — suspend/hibernate, the
AMD GPU (`vainfo` → radeonsi), display brightness.
If anything is wrong: **reboot → old generation.** Nothing is committed as
default yet.
---
## Phase 3 — Reconcile
- **Power:** verify `powerprofilesctl get` works and the Waybar battery /
powerprofile icons open the power menu. Your ryzenadj scripts are gone;
if you miss a specific TDP behaviour, that's a followup, not a blocker.
- **Theme:** `nomarchy-menu theme` → pick **nord** (writes
`theme-state.json`).
- **Snapshots:** `nomarchy-menu` → System → Snapshots should see your
existing `@snapshots` subvolume.
- **Secrets/services:** if you relied on agenixmanaged secrets for a
service, layer `agenix` back into `system.nix` as a machinespecific
import (Nomarchy doesn't manage secrets). If you don't need them, leave
them out — this is a full cutover.
---
## Phase 4 — Cutover
Once a test boot is solid:
```bash
# Move the flake to its canonical home and make it the boot default.
mv ~/nomarchy-migrate ~/.nomarchy
sudo nixos-rebuild switch --flake ~/.nomarchy#default
home-manager switch --flake ~/.nomarchy#bernardo
# Point /etc/nixos at the flake (optional but conventional).
sudo mv /etc/nixos /etc/nixos.pre-nomarchy
sudo ln -s ~/.nomarchy /etc/nixos
```
From here you're on the standard Nomarchy update flow: `sys-update` (lock +
system) then `home-update` (desktop) — always in that order (a lock bump
before the home switch, or desktop changes are skipped against the old
lock).
---
## Rollback, at any point
| Net | How |
|---|---|
| **NixOS generation** | Reboot → pick the previous entry in the systemdboot menu. |
| **`nixos-rebuild test`** | Never sets the boot default; a reboot reverts it. |
| **btrfs snapshot** | Restore `/.snapshots/pre-nomarchy-root` (see `docs/RECOVERY.md`). |
| **git baseline** | `/etc/nixos.pre-nomarchy` (and the prenomarchy commit) is your old config verbatim. |
`/home` is untouched by all of the above.
---
## Postmigration cleanup (once you're confident)
- Delete the safety snapshots: `sudo btrfs subvolume delete /.snapshots/pre-nomarchy-root` (and the home one).
- Remove `/etc/nixos.pre-nomarchy` and the old perhost modules you cut
(ryzenadj powermanagement, lanzaboote, the `nomarchy-state.nix`
prototype).
- Prune old generations: `sudo nix-collect-garbage -d`.
---
## Enabling hibernation on an existing machine (no reinstall)
New Nomarchy installs are hibernation-ready out of the box — the installer
defaults the swapfile to **= RAM** on its own `@swap` subvolume and wires the
resume offset. This section is for machines that have **no hibernate swap**:
one installed with `swap = 0`, one migrated here whose reused
`hardware-configuration.nix` carries no swapfile, or an older install
predating the default. If `swapon --show` already lists `/swap/swapfile`,
you have nothing to do.
Hibernation writes RAM to disk, and zram (compressed *RAM*) can't hold that
image across a power-off — so you need a real disk swap ≥ the RAM you use.
Nomarchy puts it on an `@swap` subvolume *inside* the encrypted volume, so
the image is encrypted at rest and the initrd LUKS unlock gates resume.
> **Reversible:** this is one subvolume plus four config lines. Remove them
> and rebuild — or just boot the previous generation — to undo. `/home` is
> never touched.
Worked example below is **this machine**: LUKS mapper `cryptroot`, btrfs `@`.
Substitute your own device/UUID/offset where shown.
### 1. Size and locate
```bash
# Swap = RAM, rounded up to whole GiB (matches the installer default).
ram_gb=$(awk '/MemTotal/ {print int(($2 + 1048575) / 1048576)}' /proc/meminfo)
# The decrypted BTRFS device backing / (strip the [subvol] suffix) and its
# filesystem UUID — the same UUID resolves to the mapper once initrd unlocks.
dev=$(findmnt -no SOURCE / | sed 's/\[.*//') # e.g. /dev/mapper/cryptroot
fsuuid=$(findmnt -no UUID /) # e.g. d8e2b02d-…
echo "swap=${ram_gb}G dev=$dev uuid=$fsuuid"
```
### 2. Create the `@swap` subvolume + swapfile
```bash
# Create the subvolume at the BTRFS top level (subvolid=5), beside @, @home…
sudo mkdir -p /mnt/btrfs-top
sudo mount -o subvolid=5 "$dev" /mnt/btrfs-top
sudo btrfs subvolume create /mnt/btrfs-top/@swap
sudo umount /mnt/btrfs-top && sudo rmdir /mnt/btrfs-top
# Mount it and create the swapfile. `mkswapfile` applies the BTRFS NOCOW
# requirements automatically (a copy-on-write swapfile would corrupt).
sudo mkdir -p /swap
sudo mount -o subvol=@swap,noatime "$dev" /swap
sudo btrfs filesystem mkswapfile --size "${ram_gb}g" --uuid clear /swap/swapfile
```
### 3. Read the resume offset
```bash
sudo btrfs inspect-internal map-swapfile -r /swap/swapfile # prints the offset
```
### 4. Wire it into `system.nix`
Add these to your machine's `system.nix`, substituting your `$fsuuid` and the
offset from step 3. The `fileSystems."/swap"` mount is **required** on this
path — a fresh install inherits it from disko-generated
`hardware-configuration.nix`, but a hand edit must declare it so `/swap` is
mounted before swap activates:
```nix
# Hibernation: encrypted swapfile on the @swap subvolume.
fileSystems."/swap" = {
device = "/dev/disk/by-uuid/<fsuuid>";
fsType = "btrfs";
options = [ "subvol=@swap" "noatime" ];
};
swapDevices = [{ device = "/swap/swapfile"; }];
boot.resumeDevice = "/dev/disk/by-uuid/<fsuuid>";
boot.kernelParams = [ "resume_offset=<offset>" ];
```
zram stays on by default (Nomarchy sets `zramSwap` at priority 100 in
`modules/nixos/oom.nix`); this disk swapfile sits lower, so day-to-day
paging stays in compressed RAM and the file is reserved for the hibernate
image — exactly the intent.
### 5. Rebuild and test
```bash
sudo nixos-rebuild switch --flake ~/.nomarchy#default
systemctl hibernate # or nomarchy-menu → Power → Hibernate
```
The machine powers off; on the next boot you unlock LUKS once and land back
in your session. If you get a *fresh* boot instead, the usual cause is a
wrong `resume_offset` or a swapfile smaller than in-use RAM — re-read the
offset (step 3) and confirm `swap ≥ RAM`.
**No LUKS?** Same steps; `dev`/`$fsuuid` point at the plain BTRFS partition
and the image is unencrypted at rest. **`swap = 0` opt-out** stays valid — if
you don't want hibernation, skip all of this; the Power-menu Hibernate row
just reports that no swap is configured.
---
## TuringMachine — the decisions, at a glance
| Item | Choice |
|---|---|
| Power | Nomarchy **PPD** (`backend = "ppd"`); **all ryzenadj/TLP tuning dropped** |
| Secure Boot | **Off** — plain systemdboot, `lanzaboote` dropped |
| Scope | **Full cutover** to Nomarchy's structure |
| `system.stateVersion` | **`24.11`** (preserved from original install) |
| Hardware | `nomarchy.hardware.amd.enable = true` (7840U / Radeon 780M) |
| Bootloader | systemdboot (unchanged — already matched) |
| Filesystem | LUKS + btrfs, existing subvolumes reused (incl. snapshot subvols) |

125
docs/OMARCHY.md Normal file
View File

@@ -0,0 +1,125 @@
# Coming from Omarchy
Nomarchy and [Omarchy](https://omarchy.org) are cousins: the same
Hyprland + Waybar desktop feel, the same opinionated "beautiful out of the
box," one-command theming, and a keyboard-driven menu. If you liked
Omarchy, you'll be at home here in minutes.
The one real difference is the **foundation**:
| | Omarchy | Nomarchy |
|------------|---------|----------|
| Base | Arch Linux | NixOS |
| Config | **imperative** dotfiles you edit in `~/.config`, live | **declarative** — built from a flake; changed via the menu or your `home.nix`/`system.nix` |
| Packages | `pacman` / `yay` / AUR, anytime | `nixpkgs` — added to your flake (no AUR) |
| Recovery | reinstall / restore dotfiles | **every rebuild is a bootable generation** — roll back from the boot menu |
This page maps the day-to-day deltas. Nothing about the *desktop* has to
be relearned — only *how you change it*.
---
## The one mental shift: declarative, not dotfiles
In Omarchy you open a file under `~/.config`, save it, and the change is
live. In Nomarchy the whole desktop is **built from your flake**, so you
change it one of two ways:
1. **The menu** (`SUPER+M`) — the everyday path. It writes the change into
your flake's state and rebuilds for you. You never hand-edit a config
for theming, night light, wallpaper, keyboard layout, power, etc.
2. **Your flake** — for anything the menu doesn't cover, edit
`home.nix` / `system.nix` (a handful of `nomarchy.*` options and plain
Home-Manager/NixOS), then run a rebuild.
The payoff for the extra indirection: **a bad change never bricks you.**
Every `nomarchy-rebuild` is a new NixOS generation; if one misbehaves,
reboot and pick the previous entry from the boot menu — your files
(`/home`) are never touched. See [RECOVERY.md](RECOVERY.md).
> Editing `~/.config/hypr/*` by hand won't stick — Home-Manager owns those
> files and rewrites them on the next rebuild. Change the source (menu or
> flake) instead.
---
## Install
| | |
|---|---|
| **Omarchy** | Run the bootstrap on a fresh Arch install. |
| **Nomarchy** | Boot the ISO and run **`nomarchy-install`** (single-disk UEFI, optional LUKS, hibernation-ready swap). |
| **Already on NixOS?** | Convert in place, no reformat — [MIGRATION.md](MIGRATION.md). |
---
## Keybindings (they'll feel familiar)
The full, always-current list is one keypress away: **`SUPER+?`** opens the
live cheatsheet — you never have to memorize this table.
| Key | Action |
|-----|--------|
| `SUPER+Return` | Terminal |
| `SUPER+Space` | App launcher |
| `SUPER+M` | Main menu (theme, network, audio, power, tools…) |
| `SUPER+T` | Theme picker |
| `SUPER+SHIFT+T` | Next wallpaper |
| `SUPER+E` | File manager (yazi) |
| `SUPER+X` | Power menu (lock / suspend / hibernate / reboot …) |
| `SUPER+?` | Keybindings cheatsheet |
| `Print` | Screenshot region → clipboard |
| `SUPER+CTRL+<key>` | Jump straight to a menu module (clipboard `V`, calc `C`, emoji `E`, capture `S`, …) |
---
## Theming
Both distros reskin everything from one place. In Nomarchy:
- **`SUPER+T`** (or `nomarchy-menu theme`) — pick from 24 presets with live
previews; it rebuilds and applies to Hyprland, Waybar, the terminal,
btop and GTK/Qt at once.
- Or from a shell: `nomarchy-theme-sync apply <slug>` (e.g. `gruvbox`,
`rose-pine`, `nord`).
- Tweak gaps / rounding / fonts / opacity / icon pack without leaving the
theme system — see [OVERRIDES.md](OVERRIDES.md). One git-tracked JSON
(`theme-state.json`) is the single source of truth.
---
## Installing software
There is no AUR. Two paths:
- **Keep it:** add the package to `home.packages` in your flake's
`home.nix`, then `nomarchy-home`. It's now declared, reproducible, and
part of your rollback history. A curated default app set already ships —
you extend the list.
- **Just this once:** `nix shell nixpkgs#<pkg>` drops you into a shell with
it on `PATH`, gone when you exit — the equivalent of a throwaway install.
Search with `nsearch <name>` (`nix search nixpkgs`).
---
## Update & roll back
| | Omarchy | Nomarchy |
|---|---|---|
| **Update** | update command | `nomarchy-pull` (bump inputs), then `nomarchy-rebuild` (system) + `nomarchy-home` (desktop) |
| **Roll back** | restore dotfiles | reboot → pick the previous **generation** in the boot menu (or a snapper snapshot) |
| **Health check** | — | `nomarchy-doctor` (System Doctor) — read-only pass/fail sheet, each ✖ shows its fix |
On a failed rebuild, `nomarchy-rebuild` / `nomarchy-home` print the last
log lines and point you at `nomarchy-doctor` and [RECOVERY.md](RECOVERY.md)
— you're never left guessing.
---
## What's exactly the same
Hyprland tiling and feel, Waybar, the rofi-driven menu, the blur/rounding
aesthetic, keyboard-first everything, and strong opinionated defaults. The
desktop is the part you already know — this page is only about the NixOS
plumbing underneath.

View File

@@ -21,6 +21,61 @@ nomarchy-theme-sync set fonts.mono "FiraCode Nerd Font"
nomarchy-theme-sync apply gruvbox # whole palette nomarchy-theme-sync apply gruvbox # whole palette
``` ```
### Icon pack
Icons follow the active theme's light/dark mode using **Papirus** — the only
icon pack shipped by default (Papirus alone is ~1 GiB, so extra packs are
opt-in rather than a cost every install pays). To switch to another pack:
```sh
nomarchy-theme-sync set icons "Tela-dark" # or "" to return to Papirus-by-mode
```
Only the pack you name is pulled into your system (the first switch downloads
it). Known packs and an example theme name from each:
| Set `icons` to… | Pack |
|---|---|
| `Papirus`, `Papirus-Dark`, `Papirus-Light` | Papirus (default; `""` auto-picks Dark/Light by mode) |
| `Tela`, `Tela-dark`, `Tela-<color>[-dark]` | Tela (colors: blue, green, red, purple, nord, dracula, …) |
| `Qogir`, `Qogir-Dark`, `Qogir-Light` | Qogir (note the capital D/L) |
| `Reversal`, `Reversal-dark` | Reversal |
| `Numix-Circle`, `Numix-Circle-Light` | Numix Circle |
The choice is a **sticky global override** — it survives `apply <palette>`
switches (presets don't carry an icon field). To add a pack that isn't listed,
append a row to `iconPacks` in `modules/home/theme.nix`. Set `icons` to `""`
any time to drop back to Papirus with automatic Dark/Light.
### Auto theme (day/night)
Switch automatically between a light **day** theme and a dark **night** theme
on a schedule — the same one engine as a manual `apply`, no second pipeline.
Turn it on from **Look & Feel Auto theme** (`SUPER+M`): toggle it, pick the
day and night themes, and set the sunrise/sunset times. The same from a shell:
```sh
nomarchy-theme-sync set settings.autoTheme.day summer-day --no-switch
nomarchy-theme-sync set settings.autoTheme.night summer-night --no-switch
nomarchy-theme-sync set settings.autoTheme.sunset 20:00 --no-switch
nomarchy-theme-sync set settings.autoTheme.enable true --no-switch
nomarchy-theme-sync auto --force # one rebuild: installs the timer + applies now
```
State lives in `settings.autoTheme`:
| Field | Meaning |
|---|---|
| `enable` | on / off |
| `day` / `night` | theme slugs — e.g. `summer-day` / `summer-night` (see `nomarchy-theme-sync list`) |
| `sunrise` / `sunset` | switch times, `"HH:MM"` (24-hour) |
A timer re-checks every ~15 minutes: the day theme applies around sunrise, the
night theme around sunset — but it only rebuilds when the active theme
actually needs to change, so most checks do nothing. Enabling rebuilds once
(to install the timer); disabling is instant. Preview the current decision
without switching with `nomarchy-theme-sync auto --which`.
These values are deliberately kept at normal priority in the modules, so they These values are deliberately kept at normal priority in the modules, so they
stay owned by the theme system. If you *insist* on pinning one in `home.nix` stay owned by the theme system. If you *insist* on pinning one in `home.nix`
regardless of the active theme, use `lib.mkForce` (see §4) — but then the CLI regardless of the active theme, use `lib.mkForce` (see §4) — but then the CLI
@@ -39,7 +94,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 +105,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
@@ -61,7 +121,7 @@ autostarts run *alongside* the defaults:
wayland.windowManager.hyprland.settings = { wayland.windowManager.hyprland.settings = {
bind = [ bind = [
"$mod, B, exec, firefox" "$mod, B, exec, firefox"
"$mod SHIFT, S, exec, grim -g \"$(slurp)\" - | swappy -f -" "$mod SHIFT, S, exec, grim -g \"$(slurp)\" - | satty --filename -"
]; ];
exec-once = [ "nm-applet --indicator" ]; exec-once = [ "nm-applet --indicator" ];
}; };
@@ -140,6 +200,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` |

41
docs/README.md Normal file
View File

@@ -0,0 +1,41 @@
# Docs map
Where human and agent documentation lives. **Do not** invent a third
tree for the same facts.
| Path | Audience | Role |
|------|----------|------|
| [../README.md](../README.md) | Everyone | What Nomarchy is, install, options tables |
| [VISION.md](VISION.md) | Maintainers + agents | Product north star toward **v1.0** and beyond — themes, not a task queue |
| [ROADMAP.md](ROADMAP.md) | Maintainers + agents | Design/decision records + shipped log (historical ✓) |
| [HARDWARE.md](HARDWARE.md) | Users + agents | Firmware, profiles, drivers, unsupported machines |
| [TESTING.md](TESTING.md) | Maintainers + agents | Verification ladder, honesty rule, ISO/VM recipes |
| [RECOVERY.md](RECOVERY.md) | Users | Broken theme/desktop/boot → undo |
| [OVERRIDES.md](OVERRIDES.md) | Users | Downstream Nix overrides |
| [MIGRATION.md](MIGRATION.md) | Users | Existing NixOS → Nomarchy without reinstall |
| [OMARCHY.md](OMARCHY.md) | Users | Coming from Omarchy — bindings/theme/install/config map |
## Related (not under `docs/`)
| Path | Role |
|------|------|
| [../AGENTS.md](../AGENTS.md) | Agent entry point, any vendor/harness (`CLAUDE.md` symlinks to it) |
| [../agent/README.md](../agent/README.md) | Agent instructions + **executable** loop state: BACKLOG, LOOP, VERIFICATION, … |
| [../.claude/](../.claude/) | Claude Code adapter only: permissions + subagent defs |
## How work flows
```
VISION (what we want the product to feel like)
▼ human triages slices into…
BACKLOG (what's next, ordered — agents execute only this)
▼ lasting design notes after ship →
ROADMAP ✓ entries
```
Agents **do not** implement directly from VISION or ROADMAP. They take
the top actionable item in `agent/BACKLOG.md` (see `agent/LOOP.md`).
They **may** append PROPOSED pitches that reference `VISION § …` or
`ROADMAP § …`.

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
boreal` (or any preset). 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 `nomarchy-home`).
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 `nomarchy-home`.
## 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 `nomarchy-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

@@ -1,9 +1,20 @@
# Roadmap & changelog # Roadmap & changelog
Forward-looking plans, plus a running log of shipped fixes (the Design/decision records and a running log of shipped work (items marked
"Known issues & follow-ups" section). Split out of the README so that ). Split out of the README so that stays a focused entry point.
stays a focused entry point — what Nomarchy is, how to install it, and
how to override it. Items marked ✓ are shipped. > **Where to look**
>
> | Need | File |
> |------|------|
> | Product themes toward **v1.0** (not a queue) | [`docs/VISION.md`](VISION.md) |
> | **What agents execute next** | [`agent/BACKLOG.md`](../agent/BACKLOG.md) |
> | Docs map | [`docs/README.md`](README.md) |
> | Agent loop protocol | [`agent/LOOP.md`](../agent/LOOP.md) |
>
> Backlog items may reference this file as **ROADMAP § \<item\>**. When
> something ships, lasting design notes get a ✓ entry here; the BACKLOG
> line is deleted.
## Roadmap ## Roadmap
- **Menu system** (apps launcher + theme switching + system actions), built - **Menu system** (apps launcher + theme switching + system actions), built
@@ -16,7 +27,7 @@ how to override it. Items marked ✓ are shipped.
`theme` (SUPER+T) · `clipboard` (cliphist, SUPER+CTRL+V) · `calc` `theme` (SUPER+T) · `clipboard` (cliphist, SUPER+CTRL+V) · `calc`
(rofi-calc, live, SUPER+CTRL+C) · `files` (fd → xdg-open, (rofi-calc, live, SUPER+CTRL+C) · `files` (fd → xdg-open,
SUPER+CTRL+F) · `emoji` (rofi-emoji, SUPER+CTRL+E) · `web` (Google). SUPER+CTRL+F) · `emoji` (rofi-emoji, SUPER+CTRL+E) · `web` (Google).
SUPER+D is `rofi -show drun`. SUPER+Space is `rofi -show drun`.
- ✓ shipped modules: `network` (nmtui in `$TERMINAL`) · `bluetooth` - ✓ shipped modules: `network` (nmtui in `$TERMINAL`) · `bluetooth`
(blueman-manager) · `capture` (grim/slurp submenu: region/full → (blueman-manager) · `capture` (grim/slurp submenu: region/full →
clipboard/file, saved to `~/Pictures/Screenshots`) · `keybinds` (the clipboard/file, saved to `~/Pictures/Screenshots`) · `keybinds` (the
@@ -28,9 +39,9 @@ how to override it. Items marked ✓ are shipped.
source** for both the Hyprland binds and the SUPER+? rofi list, so they source** for both the Hyprland binds and the SUPER+? rofi list, so they
can't drift; `nomarchy-menu keybinds` renders the padded two-column can't drift; `nomarchy-menu keybinds` renders the padded two-column
sheet (generated/mouse binds carried in its `extra` rows) sheet (generated/mouse binds carried in its `extra` rows)
- ✓ shipped binds: `SUPER+Space``rofi -show drun` (quick launch) · - ✓ shipped binds: `SUPER+Space``rofi -show drun` (app launcher) ·
`SUPER+M``nomarchy-menu` (main menu) · `SUPER+?` → the cheatsheet; `SUPER+M``nomarchy-menu` (main menu) · `SUPER+?` → the cheatsheet
`SUPER+D` stays `-show drun` (SUPER+D dropped — Space is the only launcher bind)
- launcher icons: ✓ `show-icons` on, drawing from the theme's icon set - launcher icons: ✓ `show-icons` on, drawing from the theme's icon set
(Papirus, via the icon-themes work below) (Papirus, via the icon-themes work below)
- decision record: resolves the old Walker/Lua question — no GTK4 - decision record: resolves the old Walker/Lua question — no GTK4
@@ -51,6 +62,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 +83,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 +226,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
@@ -119,9 +264,9 @@ how to override it. Items marked ✓ are shipped.
`gl`/`glg`, `gp`/`gpl`, `gf`), and a nix block (`ns`, `nr`, `nfu`, `nfc`, `gl`/`glg`, `gp`/`gpl`, `gf`), and a nix block (`ns`, `nr`, `nfu`, `nfc`,
`nsearch`, `ngc`), plus `path` and `reload`. Scope decision: short where it `nsearch`, `ngc`), plus `path` and `reload`. Scope decision: short where it
helps but **never shadow a real binary** (same rule as rg/fd — the git block helps but **never shadow a real binary** (same rule as rg/fd — the git block
deliberately avoids `gs`/ghostscript); system/home rebuilds keep their full deliberately avoids `gs`/ghostscript); lifecycle is `nomarchy-pull` /
`sys-update`/`home-update` names rather than getting cryptic aliases. The set `nomarchy-rebuild` / `nomarchy-home` (full names on PATH, not aliases). The
is documented in README §5 (Day-to-day), and `alias` lists them in-shell. set is documented in README §5 (Day-to-day), and `alias` lists shell shortcuts.
- **Theme-switch feedback:** ✓ the "rebuilding…" notification is now - **Theme-switch feedback:** ✓ the "rebuilding…" notification is now
persistent (timeout 0) and replaced in place by "applied ✓" / failure persistent (timeout 0) and replaced in place by "applied ✓" / failure
via a synchronous tag, so a multi-minute switch never reads as a failed via a synchronous tag, so a multi-minute switch never reads as a failed
@@ -130,8 +275,36 @@ how to override it. Items marked ✓ are shipped.
- **Icon themes:** ✓ ships `papirus-icon-theme`; the resolved name lives on - **Icon themes:** ✓ ships `papirus-icon-theme`; the resolved name lives on
`nomarchy.theme.iconTheme` (the JSON's optional `icons` field, else `nomarchy.theme.iconTheme` (the JSON's optional `icons` field, else
Papirus-Dark/Light by `mode`) and feeds both Stylix's `gtk.iconTheme` Papirus-Dark/Light by `mode`) and feeds both Stylix's `gtk.iconTheme`
(Thunar/GTK apps) and rofi's `show-icons`. Remaining (optional): per-theme (Thunar/GTK apps) and rofi's `show-icons`.
`icons` overrides for the presets, or shipping more icon packs. ✓ **Opt-in cross-pack overrides (no default bloat):** `theme.nix` now
resolves `nomarchy.theme.iconThemePackage` from a small pack registry
(`iconPacks`: Papirus·Tela·Qogir·Reversal·Numix). Default themes name
`Papirus-*`, so the resolved package is the *plain* `papirus-icon-theme`
derivation — byte-identical closure, zero added MB. Only when a theme's
`icons` names a set from another pack is that pack (and only it)
symlinkJoined alongside papirus. Adding a pack = one `iconPacks` row.
**Deliberately NOT shipping more packs by default** (Bernardo 2026-07-10):
papirus alone is already ~1 GiB and each extra pack adds ~hundreds of MB,
so variety is opt-in per install, not a default closure cost.
- ✓ **Auto time-of-day theme (#79, `VISION § D`):** an automatic light/dark
theme switch through the **same** engine as a manual apply — no second
pipeline. `settings.autoTheme = {enable, day, night, sunrise, sunset}`
drives `nomarchy-theme-sync auto`, which picks day vs night by the local
clock and applies the preset **only if it differs** (idempotent).
`modules/home/autotheme.nix` installs a user timer
(`OnStartupSec` + `OnCalendar=*:0/15` + `Persistent`, gated on the state
flag) that ticks `auto`; Look & Feel Auto theme toggles it and picks the
pair/times. `cmd_apply`'s core was factored into `apply_named` so `auto`
reuses it verbatim. Periodic (not exact sunrise/sunset timers) for
robustness to suspend/DST; the idempotent `auto` keeps ticks cheap. Enable
rebuilds once (installs the timer + `auto --force`); disable is instant
(the flag flips and `auto` self-gates). **V2:** `checks.auto-theme` moves
the VM clock across a configured sunset/sunrise and asserts the theme flips
day→night→day with the right rebuild count (idempotent between) — the
`home-manager switch` itself is stubbed via `NOMARCHY_REBUILD` (that
generic apply path is exercised by every manual theme change). The only
on-hardware residual is the live user timer firing on its `OnCalendar`
schedule (the schedule is `systemd-analyze`-validated).
- **Nicer shell out of the box:** ✓ zsh is the default login shell, with a - **Nicer shell out of the box:** ✓ zsh is the default login shell, with a
starship prompt themed from the JSON, autosuggestions + syntax starship prompt themed from the JSON, autosuggestions + syntax
highlighting, and modern-CLI ergonomics — `cat`→bat (theme "ansi", so it highlighting, and modern-CLI ergonomics — `cat`→bat (theme "ansi", so it
@@ -142,7 +315,8 @@ how to override it. Items marked ✓ are shipped.
`libreoffice-fresh`, `vscode`, `gimp`, `inkscape` — ships *active* in the `libreoffice-fresh`, `vscode`, `gimp`, `inkscape` — ships *active* in the
downstream `templates/downstream/home.nix` `home.packages`, with the heavier downstream `templates/downstream/home.nix` `home.packages`, with the heavier
opt-ins (`texliveFull` — multi-GB; `texliveMedium` is lighter — plus opt-ins (`texliveFull` — multi-GB; `texliveMedium` is lighter — plus
browser/email) commented just below. **Decision: no `nomarchy.apps.*` option browser/email) commented just below **Chromium is active by default**
(2026-07-10); other browsers stay commented. **Decision: no `nomarchy.apps.*` option
surface.** Unlike `nomarchy.services.*` (real config behind each toggle — surface.** Unlike `nomarchy.services.*` (real config behind each toggle —
systemd units, subuid ranges, the Flathub oneshot), a bare package install systemd units, subuid ranges, the Flathub oneshot), a bare package install
has nothing behind the toggle: `mkIf x { environment.systemPackages = [p]; }` has nothing behind the toggle: `mkIf x { environment.systemPackages = [p]; }`
@@ -159,6 +333,36 @@ how to override it. Items marked ✓ are shipped.
logo/lock/bullet → text, entry/progress-box → surface, progress-bar → logo/lock/bullet → text, entry/progress-box → surface, progress-bar →
accent. Reads on light and dark; follows the theme as of the last system accent. Reads on light and dark; follows the theme as of the last system
rebuild (like the background tint). rebuild (like the background tint).
- ✓ **Hibernation + zram by default (BACKLOG #76):** two memory layers ship
on by default. `modules/nixos/oom.nix` enables `zramSwap` (zstd, 50% RAM,
**priority 100**) as the compressed-RAM layer *before* earlyoom; the
installer defaults the disk swap to a **RAM-sized** hibernation-ready
swapfile on an `@swap` btrfs subvolume *inside* the encrypted volume, with
`patch-template.py` wiring `swapDevices` + `boot.resumeDevice` +
`resume_offset` (from `btrfs inspect-internal map-swapfile -r`). The
priority split is the point: day-to-day paging stays in zram, so the disk
swapfile is reserved for the hibernate image. `swapSize=0` stays a clean
no-swap opt-out; the Power-menu Hibernate row is kept unconditional and
**notifies on failure** (no-swap machines) rather than silently no-op'ing.
Existing machines get an *enable-hibernation* runbook in `docs/MIGRATION.md`
(no tool). Guarded by three VM checks — `checks.zram-swap` (device/algo/
priority), `checks.hibernate` (a full hibernate→resume→crash cycle on a swap
*partition* that proves the resume mechanism + that zram at priority 100
doesn't swallow the image), and `checks.hibernate-swapfile` (the installer's
btrfs `@swap` *swapfile* build — NOCOW `mkswapfile`, file-type swapon, a
valid `map-swapfile -r` offset the kernel accepts, zram above it). A full
hibernate from a *runtime* swapfile isn't headless-reachable (systemd
`CanHibernate=no` for undeclared swap; and a runtime-computed `resume_offset`
can't be fed into the boot cmdline), so the literal encrypted-swapfile
power-cycle + LUKS-initrd unlock stays a V3 laptop check.
✓ **Doctor hibernate/sleep section (#77, `VISION § C`):** `nomarchy-doctor`
gained a read-only hibernate row group — resume device on the cmdline
(+ `resume_offset` for a swapfile), disk swap ≥ RAM, zram active, and any
suspend/hibernate error in the previous boot's kernel log. Self-gates
(`skip` on `swap=0`); only `ok`/`warn`/`skip`, never fails the sheet. The
`checks.doctor` VM test asserts the `hibernate` row renders. (Gotcha
recorded: the script runs `set -euo pipefail`, so a no-match `grep` inside
`$(…)` or a `cond && action` aborts it — use `|| true` and `if`.)
- ✓ **Hibernate double-unlock:** on resume from hibernate the LUKS - ✓ **Hibernate double-unlock:** on resume from hibernate the LUKS
passphrase already gates the machine, but locking hyprlock before sleep passphrase already gates the machine, but locking hyprlock before sleep
meant a second password. Fixed by *not locking* before an encrypted meant a second password. Fixed by *not locking* before an encrypted
@@ -176,8 +380,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 +417,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 +441,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 +465,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 +485,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 +534,96 @@ 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 (HARDWARE-QUEUE T14s);
optional `v4l-utils` + `cameractrls` already commented in the template.
- ✓ **Portal/Flatpak IR gap documented (#71, docs only):** the shipped
v4l2 IR-hide does **not** cover libcamera / xdg-desktop-portal /
Flatpak camera pickers (IR can still appear there). User-facing note
in [`HARDWARE.md` §7](HARDWARE.md). Engineering options (b) WirePlumber
libcamera GREY-only rule and (c) libcamera/udev-layer hide stay
deferred — both need a T14s-class dual-sensor machine to verify; no
libcamera rules in-tree.
- ✓ **Memory-pressure protection (earlyoom, default-on):**
`modules/nixos/oom.nix` — running out of memory kills the offending
process instead of freezing the desktop. **earlyoom over systemd-oomd,
deliberately:** oomd kills whole cgroups, and a Hyprland session is ONE
scope (no per-app systemd scopes here, unlike GNOME) — under pressure it
would take out the entire desktop; earlyoom kills the single largest
process before the thrash point. nixpkgs default-enables oomd *inert*
(no slices monitored) — disabled outright so there's one owner. Session
plumbing is `--avoid`-listed (Hyprland/hyprlock/greetd/waybar/pipewire/
wireplumber/Xwayland/nix-daemon/systemd — unanchored, since NixOS
wrappers rename comm to `.foo-wrapped`); no `--prefer` tuning (largest-
RSS selection already finds the hog); kills raise a desktop notification
(systembus-notify). All `mkDefault` — opt out with
`services.earlyoom.enable = false`. **VM-verified**
(`checks.oom-protection`): a chunked allocator peaked at ~686 MB in a
1 GB VM, earlyoom SIGTERM'd it in 0.1 s, a bystander unit survived, and
oomd is asserted off.
- **Opt-in services & integrations:** the counterpart to the opt-*out* - **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 +681,80 @@ 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). The on-machine check PASSed 2026-07-04 (enable/disable on
hardware). **Geo mode shipped 2026-07-11:** set BOTH
`nomarchy.nightlight.latitude`/`.longitude` and wlsunset replaces
hyprsunset — sunrise/sunset computed from the location daily, the fixed
times ignored; same toggle script, moon indicator, and live-state
`ExecCondition` gate (the backing unit name is a single let-binding).
Declarative-only on purpose (coords are hardware-independent machine
config, like the keyboard layout — no menu writer). Guarded by
`checks.nightlight-geo` (pure eval over the rendered units); the visible
warm shift is hardware-tier, queued.
- ✓ **Automatic timezone (location-following clock):** `nomarchy.system.autoTimezone`
(opt-in, off by default) — geoclue + `services.automatic-timezoned` drive
`/etc/localtime` from your location, so travelling to another zone updates the
Waybar clock on its own. **Menu-driven, in-flake state** (the night-light /
keyboard philosophy): the flag is `settings.autoTimezone` in 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 +770,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 +828,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 +921,57 @@ 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.
- ✓ **Identity audit exemptions + import hierarchy** (#69/#70):
`tools/audit-theme-design.py` tags expected hue/CVD/ANSI-family noise
on white/vantablack/lumon/hackerman/matte-black/miasma as `[identity]`
(do not retune into traffic lights). `tools/import-palettes.py` keeps
surface≠overlay when ANSI color0==color8, derives light chips from base
instead of ANSI black, and documents that roles are first-class — no
bulk re-import of shipped themes without a hierarchy pass.
- ✓ **Waybar crash on theme switch left the session bar-less** (Latitude
hardware QA, 2026-07-04): exec-once has no supervisor, so any crash
orphaned the session until relogin — and the switch path itself was
crash-prone: `reload_style_on_change` (inotify on style.css) and
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
@@ -471,3 +1017,14 @@ how to override it. Items marked ✓ are shipped.
`color-scheme` reads `1` on a dark theme and flips to `2` after switching `color-scheme` reads `1` on a dark theme and flips to `2` after switching
to a light one (catppuccin-latte) — so xdg-desktop-portal-gtk does relay to a light one (catppuccin-latte) — so xdg-desktop-portal-gtk does relay
the dconf value GTK4/Qt apps read. the dconf value GTK4/Qt apps read.
- ✓ **Laptop docking & Multi-monitor UX**:
- Rewrote the display watcher (`nomarchy-display-profile-watch`) to listen immediately to Hyprland's IPC socket via `socat`, removing the 3-second polling delay.
- Added a fallback to the `base` layout when unplugging an external monitor to guarantee the laptop lid turns back on.
- Added explicit `SUPER+ALT+arrow` keybindings for moving active workspaces across physical monitors.
- ✓ **Workstation Polish Pack (Security & Stability)**:
- Enabled `security.apparmor.enable` system-wide for defense-in-depth confinement.
- Added `boot.kernelParams = [ "panic=10" "oops=panic" ]` to auto-reboot upon catastrophic kernel/driver failures.
- Integrated `hyprpicker` (`SUPER+SHIFT+C`) for instant Wayland-native color picking.
- ✓ **Theme UI Review completed**:
- Preserved the full-color Papirus icon set for Rofi menus to provide a colorful layer independent of the Waybar's monochrome style.
- Enforced correct typography scales across `swaync` (notifications) and Rofi menus, natively binding to the active theme's `t.fonts.size` and `t.fonts.ui` rather than hardcoding sizes.

View File

@@ -13,7 +13,8 @@ than claiming success. "All Nix files parse" is not "the bar renders."
```sh ```sh
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 git ls-files '*.py' | xargs python3 -m py_compile # all tracked Python
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
@@ -53,13 +65,13 @@ Work through these in order; each one exercises a different layer.
| # | Check | Verifies | | # | Check | Verifies |
|---|---|---| |---|---|---|
| 1 | Boots to Hyprland with Tokyo Night wallpaper visible | greetd autologin, awww, session start | | 1 | Boots to Hyprland with the default theme wallpaper visible (Boreal) | greetd autologin, awww, session start |
| 2 | Waybar shows at top, themed (blue accent on dark) | HM waybar module, palette baking | | 2 | Waybar shows at top, themed (blue accent on dark) | HM waybar module, palette baking |
| 3 | `SUPER+Return` opens Ghostty with Tokyo Night colors | terminal default, ANSI palette | | 3 | `SUPER+Return` opens Ghostty with the default theme colors (Boreal) | terminal default, ANSI palette |
| 4 | `btop` in the terminal is themed | per-theme asset baking | | 4 | `btop` in the terminal is themed | per-theme asset baking |
| 5 | `nomarchy-theme-sync list` prints 21 presets | package, baked themes dir | | 5 | `nomarchy-theme-sync list` prints 24 presets | package, baked themes dir |
| 6 | `nomarchy-theme-sync apply gruvbox` → state written, `home-manager switch` runs, desktop re-themes, wallpaper changes | the whole engine: state write, pure eval, HM rebuild, wallpaper hook | | 6 | `nomarchy-theme-sync apply gruvbox` → state written, `home-manager switch` runs, desktop re-themes, wallpaper changes | the whole engine: state write, pure eval, HM rebuild, wallpaper hook |
| 7 | `SUPER+SHIFT+T` cycles wallpapers instantly (try `tokyo-night`: 4 of them) | the runtime wallpaper path | | 7 | `SUPER+SHIFT+T` cycles wallpapers instantly (try `tokyo-night` for 4, or `boreal` for its set) | the runtime wallpaper path |
| 8 | `nomarchy-theme-sync apply summer-night` → after the switch the bar has its own identity (light bar, different styling) | whole-swap waybar.css assets | | 8 | `nomarchy-theme-sync apply summer-night` → after the switch the bar has its own identity (light bar, different styling) | whole-swap waybar.css assets |
| 9 | Open a GTK app — dark theme matching the palette | Stylix layer | | 9 | Open a GTK app — dark theme matching the palette | Stylix layer |
| 10 | `home-manager generations` lists one generation per theme change; activating an older one rolls the theme back | atomicity / rollback story | | 10 | `home-manager generations` lists one generation per theme change; activating an older one rolls the theme back | atomicity / rollback story |

167
docs/VISION.md Normal file
View File

@@ -0,0 +1,167 @@
# Product vision — toward Nomarchy v1.0
North star for **what the product should feel like**, not a task queue.
Pillars stay in [`agent/GOALS.md`](../agent/GOALS.md). Executable work
lives only in [`agent/BACKLOG.md`](../agent/BACKLOG.md).
**How agents use this file**
1. Read the relevant section when orienting on product work.
2. Slice concrete work into **PROPOSED** (or wait for human triage into
NOW/NEXT) with a one-paragraph pitch and `VISION § <heading>`.
3. Do **not** implement multi-week themes in one iteration — split.
4. When a slice ships, leave a ✓ note here or in ROADMAP if the design
decision should outlive the backlog line.
**Philosophy reminder:** opinionated and stable over option sprawl. Prefer
safer defaults, one golden path, and menu/doctor surfaces over install-time
questionnaires and `nomarchy.apps.*` toggles for bare packages.
---
## North-star user (v1 window)
> **Framework / modern AMD-or-Intel laptop, little or no Nix, wants a
> beautiful desktop that never bricks, configured from the menu.**
Work that does not serve this user for the v1.0 window should stay LATER
or PROPOSED unless it unblocks stability.
---
## v1.0 release bar
Ship the **v1** branch pointer only when these are honestly true (human
judgment; agents prepare, do not advance `v1`):
| Bar | Done looks like |
|-----|-----------------|
| **Install golden path** | Offline (or documented online) install → first boot themed desktop; swap=0 and unattended LUKS contracts correct |
| **Default identity** | Boreal is the seed theme (shipped 2026-07-09); picker should have `preview.png` |
| **Day-2 confidence** | Doctor covers real failure modes; firmware and fingerprint are discoverable without reading the README |
| **Hardware story** | `docs/HARDWARE.md` matches code; option-docs green; i2c/ddcci documented |
| **Honesty** | No “done” claims past verified tier; HARDWARE-QUEUE cleared or consciously deferred |
| **Docs map** | README + `docs/README.md` + agent README agree on where things live |
Not required for v1.0: dual-boot, Secure Boot, multi-disk RAID, aarch64,
Steam Deck, docs website, binary cache.
---
## Theme A — Day-2 confidence (highest product ROI)
The install is already strong. The gap is **after** first boot.
| Idea | Intent | Likely home |
|------|--------|-------------|
| **System Firmware** | fwupd is on but CLI-only; menu: refresh → list → confirm → update (never auto-flash) | BACKLOG Hardware product |
| **Fingerprint menu** | Enroll/list when fprintd present; optional PAM + rebuild note | BACKLOG Hardware product |
| **Doctor hardware section** | NM, sink, optional GPU smoke, fprintd, fwupd pending, charge threshold | BACKLOG Hardware product |
| **Machine health entry** | One System row → doctor (not five submenus) | Menu / doctor |
| **Human rebuild errors** | On failed switch, point at last log lines + `nomarchy-doctor` | pkgs / menu |
| **HM pre-activate fail flag** | Durable recovery one-liner on target if bake failed | Installer |
Reference: [HARDWARE.md](HARDWARE.md) §4§5, §10.
---
## Theme B — First week experience
| Idea | Intent | Notes |
|------|--------|-------|
| **Silent first-week card** | One dismissible “youre set” (menu, theme, wallpaper, network) — not a wizard | Prefer notify or doctor “first boot” section |
| **Boreal as default** | Identity on first boot | BACKLOG PROPOSED already |
| **Generation readability** | “What changed last rebuild” in plain language | Rollback exists; story is incomplete |
| **Post-install hints** | One-shot MOTD/notify for fwupd / fprintd when relevant | Avoid permanent nag |
---
## Theme C — Laptop daily driver
| Idea | Intent | Notes |
|------|--------|-------|
| **Dock life** | Lid closed + external only, wake, default audio sink on undock | Display profiles exist — polish edge cases |
| **Hibernate/sleep confidence** | Doctor: resume device, swap size, clean suspend journal | Trust > new power UI |
| **Battery health readout** | Cycles / charge limit where sysfs allows | Report-only |
| **Charge-limit instant apply** | Already PROPOSED `[blocked:hw]` | Keep privilege tradeoff explicit |
---
## Theme D — Beauty without theme sprawl
| Idea | Intent | Notes |
|------|--------|-------|
| **Fewer themes, sharper** | Finish or quarantine neon-glass; invest in whole-swap quality | Contrast stays gated |
| **Time-of-day pair** ✓ | Auto light/dark (e.g. summer-day ↔ summer-night) from schedule | ✓ shipped #79 (2026-07-10) — `settings.autoTheme` + timer; still one engine |
| **Theme switch speed** | Wallpapers artifact split (LATER) if switches still feel slow | GOALS: no second pipeline |
---
## Theme E — Menu as the product
| Idea | Intent | Notes |
|------|--------|-------|
| Root stays six entries | New features go Tools / System | CONVENTIONS |
| Secrets module | Only if a vault story is adopted (rofi-rbw deferred) | Dont tease |
| Look & Feel group | Night-light, wallpaper, blur — when enough toggles exist | ROADMAP optional |
---
## Theme F — Narrative & community
| Idea | Intent | Notes |
|------|--------|-------|
| **Omarchy migrant one-pager** ✓ | Bindings/theme/install map | ✓ shipped `docs/OMARCHY.md` (#78, 2026-07-10) |
| **Hardware hall of fame** | Install-tested models; invite DMI PRs | Ties to HARDWARE.md §9 |
| **60s demo** | Live → install → theme → menu | Outside repo OK |
---
## Theme G — Deliberate non-sprawl
| Idea | Intent |
|------|--------|
| Starter apps: curate yearly, dont grow `nomarchy.apps.*` | Template SoT already |
| Gaming/creator: comment blocks in template, not new modules | Steam/OBS already services |
| Installer stays golden-path | No dual-boot/partition wizard for v1 |
---
## Theme H — Quality bar when ideas run dry
1. Burn down [HARDWARE-QUEUE.md](../agent/HARDWARE-QUEUE.md) (V3 only you can close).
2. Fix install contracts (swap=0, unattended LUKS fail-closed).
3. Visual ritual: default-theme screenshots before each `v1` fast-forward.
4. option-docs + theme-contrast always green on `main`.
---
## Out of scope (reaffirm)
From GOALS non-goals and installer audits — do not “fill the roadmap” with:
- Binary cache as a product
- Multi-DE / GTK4 launcher / second theming pipeline
- Repo-wide formatter without a Decision
- nixpkgs major bump on `main` (thats a deliberate `v2`)
- Option sprawl for bare package installs
---
## Suggested agent slices (promote via PROPOSED → NEXT)
Small enough for one iteration each; reference this file:
1. `VISION § v1.0` — default theme → boreal (seed state + fallbacks)
2. `VISION § A` — System Firmware (fwupd wrapper)
3. `VISION § A` — doctor hardware section (read-only checks)
4. `VISION § A` — fingerprint enroll menu (self-gated)
5. `VISION § B` — first-boot dismissible tips (one surface)
6. `VISION § A` — human-facing rebuild failure hint
7. `VISION § v1.0` — install P0 contracts (swap / unattended LUKS)
8. `VISION § D` — neon-glass quarantine or finish
9. `VISION § F` — Omarchy migrant doc (short)
10. `VISION § H` — HARDWARE-QUEUE session notes only Bernardo runs
Larger themes (dock life, time-of-day theme, wallpapers split) stay
`[big]` until split.

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": 1783370751,
"narHash": "sha256-fS7xTi2j2iso5Hj7RNZLv/acDlCT+fgMVkVk40A7Uco=", "narHash": "sha256-E+3MIMvKuo9k+K+qLQ9YXzsBzkgHuyVLnsEbN2DFfuc=",
"owner": "NixOS", "owner": "NixOS",
"repo": "nixos-hardware", "repo": "nixos-hardware",
"rev": "32c2cd9e46286c4eced3dc6b613c659126bf3cca", "rev": "662bd6e312d2c8b212e32cb377abaee190749320",
"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": 1783359251,
"narHash": "sha256-+srjPGNy67nKytYwdlepycL51IG6S34sS4MKRZXK8G0=", "narHash": "sha256-HUiCnEVlJ4n+qJlZojiz/zv+P0cqM5zsg1dxpz2J7Mg=",
"owner": "nix-community", "owner": "nix-community",
"repo": "stylix", "repo": "stylix",
"rev": "54fa19702f4f2c7f6a981a92850678933588af9a", "rev": "e602ad042f00409f33c8ad2829cd8d59ba345c7e",
"type": "github" "type": "github"
}, },
"original": { "original": {

854
flake.nix
View File

@@ -83,6 +83,13 @@
# 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 { };
nomarchy-detect-hw = final.callPackage ./pkgs/nomarchy-detect-hw { };
# pull / rebuild / home (+ legacy aliases) — also on HM so a home
# switch can refresh a broken system-package nomarchy-pull.
nomarchy-lifecycle = final.callPackage ./pkgs/nomarchy-lifecycle { };
}; };
nixosModules.nomarchy = { nixosModules.nomarchy = {
@@ -112,6 +119,12 @@
packages.${system} = { packages.${system} = {
nomarchy-theme-sync = pkgs.nomarchy-theme-sync; nomarchy-theme-sync = pkgs.nomarchy-theme-sync;
nomarchy-install = nomarchyInstall; nomarchy-install = nomarchyInstall;
# Overlay tools exported for `nix build .#nomarchy-doctor` etc.
# (maintainer/CI convenience — modules install them via the overlay).
nomarchy-doctor = pkgs.nomarchy-doctor;
nomarchy-control-center = pkgs.nomarchy-control-center;
nomarchy-battery-notify = pkgs.nomarchy-battery-notify;
nomarchy-detect-hw = pkgs.nomarchy-detect-hw;
default = pkgs.nomarchy-theme-sync; default = pkgs.nomarchy-theme-sync;
}; };
@@ -134,6 +147,847 @@
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
'';
# Whole-swap waybar.css is read raw (no palette prepend) — every
# @color it references must be @define-color'd in the same file
# (neon-glass shipped zero defines; BACKLOG #62). Also gate
# preview.png + non-empty backgrounds/ per preset.
theme-wholeswap = pkgs.runCommand "nomarchy-theme-wholeswap"
{ nativeBuildInputs = [ pkgs.python3 ]; }
''
python3 ${./tools/check-theme-wholeswap.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
'';
# Installer hardware DB ↔ nixos-hardware (item 49): every module
# name the DB table and the CPU/GPU/chassis autodetect reference
# must exist in the PINNED nixos-hardware.nixosModules — a lock
# bump can rename a module upstream (the X1 Carbon → x1-Nth-gen
# churn) and break profiled installs on just the matching DMI,
# invisible to any VM. The available-set is generated here from
# attrNames — the same value fed to nomarchy-install — so it
# tracks the lock automatically.
hardware-db-modules = pkgs.runCommand "nomarchy-hardware-db-modules"
{ nativeBuildInputs = [ pkgs.python3 ]; }
''
python3 ${./tools/check-hardware-db.py} \
${./pkgs/nomarchy-install/hardware-db.sh} \
${pkgs.writeText "nixos-hardware-modules.txt"
(nixpkgs.lib.concatStringsSep "\n"
(builtins.attrNames nixos-hardware.nixosModules))}
touch $out
'';
# compose-lock.py offline-composer contract (item 49): the lock
# the installer writes on the target with no network must root at
# nomarchy, carry every upstream node one level down with its
# `follows` paths rebased, and fail closed on a missing narHash or
# a pre-existing nomarchy node. Pure — runs the script on fixtures.
installer-compose-lock = pkgs.runCommand "nomarchy-installer-compose-lock"
{ nativeBuildInputs = [ pkgs.python3 ]; }
''
python3 ${./tools/check-compose-lock.py} \
${./pkgs/nomarchy-install/compose-lock.py}
touch $out
'';
# Installer safety guards (item 54): offline compose-lock fail-closed
# (no network flake lock), disk-signature warn regex, pre-activate
# recovery hint path, password floors. Pure source+regex contract.
installer-safety = pkgs.runCommand "nomarchy-installer-safety"
{ nativeBuildInputs = [ pkgs.python3 ]; }
''
python3 ${./tools/check-install-safety.py} \
${./pkgs/nomarchy-install/nomarchy-install.sh}
touch $out
'';
# Installer share ↔ templates/downstream SoT (item 72): the four
# files default.nix copies into share/.../template/ must stay
# byte-identical to the checkout template (single machine-seed
# SoT; no thinner second catalog). Cheap package build + cmp.
template-sot = pkgs.runCommand "nomarchy-template-sot"
{ nativeBuildInputs = [ pkgs.python3 ]; }
''
python3 ${./tools/check-template-sot.py} \
${nomarchyInstall}/share/nomarchy-install/template \
${./templates/downstream}
touch $out
'';
# The installer's disko arg contract (item 49): swapSize "0"/"0G"
# (bare "0" is what the swap=0 install passes) must yield NO @swap
# subvolume, a sized value a correctly-sized one, and withLuks must
# gate the LUKS wrapper around the BTRFS root. Pure eval of
# disko-config.nix (a plain function) — no disko, no VM.
installer-disko =
let
diskoFor = args: import ./pkgs/nomarchy-install/disko-config.nix
({ mainDrive = "/dev/vda"; } // args);
rootContent = args:
(diskoFor args).disko.devices.disk.main.content.partitions.root.content;
btrfsOf = c: if c.type == "luks" then c.content else c;
subvols = args: (btrfsOf (rootContent args)).subvolumes;
hasSwap = args: builtins.hasAttr "@swap" (subvols args);
expect = cond: msg: nixpkgs.lib.assertMsg cond "installer-disko: ${msg}";
ok =
expect (! hasSwap { swapSize = "0"; })
''swapSize "0" produced an @swap subvol''
&& expect (! hasSwap { swapSize = "0G"; })
''swapSize "0G" produced an @swap subvol''
&& expect (hasSwap { swapSize = "2G"; })
''swapSize "2G" produced no @swap subvol''
&& expect ((subvols { swapSize = "2G"; })."@swap".swap.swapfile.size == "2G")
''the @swap swapfile size is not "2G"''
&& expect ((rootContent { withLuks = true; }).type == "luks")
"withLuks=true did not wrap root in luks"
&& expect ((rootContent { withLuks = true; }).name == "crypted")
"the luks device is not named 'crypted'"
&& expect ((rootContent { withLuks = false; }).type == "btrfs")
"withLuks=false did not put btrfs directly on root"
&& expect (builtins.hasAttr "@" (subvols { withLuks = false; }))
"the no-luks root lost its @ subvolume";
in
assert ok; pkgs.runCommand "nomarchy-installer-disko" { } "touch $out";
# Hyprland windowrule dead-syntax guard (item 49): build the
# GENERATED hyprland.conf (the ISO's HM output) and fail on a
# regression to the pre-0.53 grammar — the `windowrulev2` keyword,
# rule-first order (`float, class:^…$`), or a bare `<prop>:`
# matcher. That syntax shipped a red config-error banner on every
# boot (fixed in ed7fd93); this locks the correct form. Building
# one config file is a pure derivation, not a VM.
windowrule-syntax =
let
hyprconf = self.nixosConfigurations.nomarchy-live.config
.home-manager.users.${username}.xdg.configFile."hypr/hyprland.conf".source;
in
pkgs.runCommand "nomarchy-windowrule-syntax"
{ nativeBuildInputs = [ pkgs.python3 ]; }
''
python3 ${./tools/check-windowrule-syntax.py} ${hyprconf}
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.
# Live-ISO install affordance (BACKLOG #57): desktop entry baked
# into the live host's HM generation + installer on PATH + Tools
# menu gate in nomarchy-menu. Builds the real nomarchy-live HM
# package (not a full ISO) — V2 file-level smoke of the same
# artifacts the ISO ships.
live-install-entry =
let
liveHm =
self.nixosConfigurations.nomarchy-live.config.home-manager.users.${username};
gen = liveHm.home.activationPackage;
in
pkgs.runCommand "nomarchy-live-install-entry"
{
nativeBuildInputs = [ pkgs.gnugrep pkgs.findutils ];
passAsFile = [ ];
}
''
set -euo pipefail
desk=$(find ${gen}/home-path/share/applications \
-name nomarchy-install.desktop | head -1)
test -n "$desk"
grep -q '^Name=Install Nomarchy$' "$desk"
grep -q '^Terminal=true$' "$desk"
grep -q '^Exec=nomarchy-install$' "$desk"
test -x ${nomarchyInstall}/bin/nomarchy-install
# Tools Install Nomarchy self-gate lives in nomarchy-menu
# (rofi.nix), which is on the live HM path.
menu=$(find ${gen}/home-path/bin -name nomarchy-menu | head -1)
test -n "$menu"
grep -q 'Install Nomarchy' "$menu"
echo "live-install-entry: ok desk=$desk menu=$menu"
touch $out
'';
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")
# The healthy run also exercises the #44 hardware section: in a
# bare VM every probe (nmcli/wpctl/vainfo/fprintd/fwupd) is
# absent, so each check must self-gate to a skip row and the
# sheet must still exit 0 i.e. the section runs without
# erroring under `set -euo pipefail`.
healthy = machine.succeed("nomarchy-doctor 2>&1")
for probe in ["NetworkManager", "fingerprint", "fwupd", "VA-API", "hibernate"]:
assert probe in healthy, f"doctor hardware section missing a self-gated '{probe}' row:\n{healthy}"
'';
};
# 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";
# Night-light geo mode: with BOTH lat/long set the backing unit
# flips hyprsunset → wlsunset (location-computed sunrise/sunset)
# and the live-state ExecCondition gate survives the swap (the
# toggle script's unit= comes from the same let-binding, so the
# gate landing on wlsunset covers the selection logic). Pure
# eval over the rendered HM units — nothing is built. The
# visible warm shift itself stays hardware-tier (HARDWARE-QUEUE),
# exactly like the original night-light full cycle.
nightlight-geo =
let
inherit (nixpkgs) lib;
hm = home-manager.lib.homeManagerConfiguration {
inherit pkgs;
modules = [
self.homeModules.nomarchy
{
nomarchy.stateFile = ./templates/downstream/theme-state.json;
nomarchy.nightlight = {
enable = true;
latitude = "52.52";
longitude = "13.40";
};
home = { username = "geo"; homeDirectory = "/home/geo"; };
}
];
};
units = hm.config.systemd.user.services;
exec = toString units.wlsunset.Service.ExecStart;
cond = toString units.wlsunset.Service.ExecCondition;
expect = c: msg: lib.assertMsg c "nightlight-geo: ${msg}";
ok =
expect (lib.hasInfix "52.52" exec && lib.hasInfix "13.40" exec)
"wlsunset ExecStart lacks the configured coordinates: ${exec}"
&& expect (lib.hasInfix "4000" exec)
"wlsunset ExecStart lacks the night temperature: ${exec}"
&& expect (lib.hasInfix "should-start" cond)
"wlsunset lost the live-state ExecCondition gate"
&& expect (!(hm.config.services.hyprsunset.enable or false))
"geo mode must not also run hyprsunset";
in
assert ok; pkgs.runCommand "nomarchy-nightlight-geo" { } "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")
'';
};
# zram compressed-RAM swap (oom.nix, BACKLOG #76 slice): enabled
# by default as the memory-pressure layer *before* earlyoom. Prove
# the device comes up as an active swap, uses zstd, and carries the
# high priority that keeps day-to-day paging off any disk swapfile
# (which #76 reserves for the hibernate image). Imports oom.nix
# directly so the check tracks the module, not a hand-copy.
zram-swap = pkgs.testers.runNixOSTest {
name = "nomarchy-zram-swap";
nodes.machine = { ... }: {
imports = [ ./modules/nixos/oom.nix ];
virtualisation.memorySize = 1024;
};
testScript = ''
# zram-generator brings the device up as swap shortly after boot.
machine.wait_until_succeeds("grep -q /dev/zram0 /proc/swaps", timeout=60)
# Configured compression algorithm is the selected one ([zstd]).
machine.succeed("grep -q '\\[zstd\\]' /sys/block/zram0/comp_algorithm")
# High priority (100) so the kernel fills zram before any disk
# swap the ordering #76's hibernate image depends on.
machine.succeed(
"awk '$1==\"/dev/zram0\" && $5==100 {ok=1} END{exit !ok}' /proc/swaps"
)
'';
};
# Hibernate → resume (BACKLOG #76): the load-bearing risk the
# hibernation default introduces is coexistence with the zram
# swap that oom.nix now enables by default. Prove, in the VM
# harness, that (a) a machine hibernates and resumes for real —
# a volatile ramfs marker survives resume but NOT a fresh boot —
# and (b) zram (priority 100) does not swallow the hibernate
# image: the disk swap (lower priority, explicit resumeDevice)
# carries it. Modeled on nixpkgs' hibernate.nix (partition swap +
# systemd-initrd resume). What QEMU genuinely can't do is feed a
# boot-time `resume_offset` for a btrfs *swapfile* (the offset
# only exists after the file is allocated on first boot) or drive
# a LUKS-initrd unlock end-to-end — so the encrypted @swap
# swapfile + offset path stays a V3 laptop check (HARDWARE-QUEUE),
# which already runs that exact layout. This test imports oom.nix
# so it tracks the real zram default, not a hand-copy.
hibernate = pkgs.testers.runNixOSTest {
name = "nomarchy-hibernate";
nodes.machine = { config, lib, pkgs, ... }: {
imports = [ ./modules/nixos/oom.nix ]; # zram default-on (#76)
virtualisation.memorySize = 2048;
# Store + EFI must persist across the hibernate/resume/crash
# boots; a disk swap ≥ 2×RAM holds the image.
virtualisation.useNixStoreImage = true;
virtualisation.useEFIBoot = true;
virtualisation.emptyDiskImages = [ (2 * config.virtualisation.memorySize) ];
boot.initrd.systemd.enable = true; # systemd-initrd, like Nomarchy
# Disk swap at a LOWER priority than zram (100); explicit
# resumeDevice — exactly how the installer wires hibernation —
# so the image goes to disk regardless of zram's priority.
swapDevices = lib.mkOverride 0 [
{ device = "/dev/vdc"; options = [ "x-systemd.makefs" ]; priority = 1; }
];
boot.resumeDevice = "/dev/vdc";
# Let the driver's shell reconnect after resume.
systemd.services.backdoor.conflicts = [ "sleep.target" ];
powerManagement.resumeCommands = "systemctl --no-block restart backdoor.service";
};
testScript = ''
machine.wait_for_unit("default.target")
# zram (oom.nix) up at priority 100; disk swap present and lower
# so the hibernate image lands on disk, not volatile zram.
machine.wait_until_succeeds("grep -q /dev/zram0 /proc/swaps", timeout=60)
machine.succeed(
"awk '$1==\"/dev/zram0\"&&$5==100{z=1} "
"$1==\"/dev/vdc\"&&$5<100{d=1} END{exit !(z&&d)}' /proc/swaps"
)
# Marker in volatile RAM: survives a resume, never a fresh boot.
machine.succeed(
"mkdir /run/test",
"mount -t ramfs -o size=1m ramfs /run/test",
"echo not persisted to disk > /run/test/suspended",
)
# Hibernate, wait for real power-off, then resume.
machine.execute("systemctl hibernate >&2 &", check_return=False)
machine.wait_for_shutdown()
machine.start()
machine.succeed("grep 'not persisted to disk' /run/test/suspended")
# Prove it was a genuine resume: a crash-boot must lose the marker.
machine.crash()
machine.wait_for_unit("default.target")
machine.fail("grep 'not persisted to disk' /run/test/suspended")
'';
};
# The installer's BTRFS @swap SWAPFILE setup (#76) — the parts the
# partition hibernate test (checks.hibernate) can't reach. A full
# hibernate→resume from a *runtime* swapfile isn't reachable
# headlessly: systemd reports CanHibernate=no unless the swap is
# declared in the boot config, and feeding a runtime-computed
# resume_offset into the boot cmdline is a genuine chicken-and-egg.
# So the power-cycle stays a real-hardware check (the dev box runs
# exactly this layout — /proc/cmdline verified). What IS verified
# here, bounded and deterministic, is that the swapfile is built
# correctly and its resume offset is valid: NOCOW `mkswapfile`, a
# good `map-swapfile -r` offset the kernel accepts, swapon as a file,
# and zram (prio 100) above it so the image lands on disk, not zram.
hibernate-swapfile = pkgs.testers.runNixOSTest {
name = "nomarchy-hibernate-swapfile";
nodes.machine = { config, lib, pkgs, ... }: {
imports = [ ./modules/nixos/oom.nix ]; # zram default-on (#76)
virtualisation.memorySize = 2048;
# A dedicated disk for the btrfs @swap subvolume.
virtualisation.emptyDiskImages = [ (4 * config.virtualisation.memorySize) ];
environment.systemPackages = [ pkgs.btrfs-progs ];
};
testScript = ''
machine.wait_for_unit("default.target")
machine.wait_until_succeeds("grep -q /dev/zram0 /proc/swaps", timeout=60)
# Reproduce the installer layout: btrfs, @swap subvolume, then a
# NOCOW swapfile via `btrfs filesystem mkswapfile`.
# The lone empty data disk (root is vda; no separate store disk
# here since useNixStoreImage is off).
machine.succeed("mkfs.btrfs -f /dev/vdb")
machine.succeed(
"mkdir -p /mnt-btrfs && mount /dev/vdb /mnt-btrfs "
"&& btrfs subvolume create /mnt-btrfs/@swap && umount /mnt-btrfs"
)
machine.succeed("mkdir -p /swap && mount -o subvol=@swap /dev/vdb /swap")
machine.succeed("btrfs filesystem mkswapfile --size 3g --uuid clear /swap/swapfile")
# NOCOW is mandatory a copy-on-write swapfile corrupts on btrfs.
machine.succeed("lsattr /swap/swapfile | grep -q C")
machine.succeed("swapon /swap/swapfile")
machine.succeed(
"awk '$1==\"/swap/swapfile\" && $2==\"file\"{f=1} END{exit !f}' /proc/swaps"
)
# The Nomarchy-specific offset math (patch-template bakes this
# into resume_offset): a valid physical offset the kernel accepts.
offset = machine.succeed(
"btrfs inspect-internal map-swapfile -r /swap/swapfile"
).strip()
assert offset.isdigit() and int(offset) > 0, f"bad resume offset: {offset!r}"
machine.succeed(f"echo {offset} > /sys/power/resume_offset")
# zram (prio 100) must sit above the disk swapfile, so a hibernate
# image would land on disk, not volatile zram.
machine.succeed(
"awk '$1==\"/dev/zram0\"&&$5==100{z=1} "
"$1==\"/swap/swapfile\"&&$5<100{d=1} END{exit !(z&&d)}' /proc/swaps"
)
'';
};
# Auto time-of-day theme (#79, VISION § D): simulate crossing
# sunset and sunrise by moving the VM clock, and assert
# `nomarchy-theme-sync auto` switches the active theme accordingly
# — day stays day (no needless rebuild), sunset flips to night,
# a later tick is idempotent, sunrise flips back. The actual
# `home-manager switch` is stubbed via NOMARCHY_REBUILD (that
# generic apply path is exercised by every manual theme change);
# this isolates the auto decision + apply + rebuild-trigger.
auto-theme = pkgs.testers.runNixOSTest {
name = "nomarchy-auto-theme";
nodes.machine = { pkgs, ... }: {
environment.systemPackages = [ pkgs.nomarchy-theme-sync ];
time.timeZone = "UTC"; # naive HH:MM == the clock we set
};
testScript = ''
machine.wait_for_unit("multi-user.target")
machine.succeed("timedatectl set-ntp false || true")
# Writable flake state (start on the day theme) + a rebuild stub
# that records each invocation instead of running home-manager.
machine.succeed("mkdir -p /root/.nomarchy")
machine.succeed(
"echo '{\"slug\":\"summer-day\"}' > /root/.nomarchy/theme-state.json"
)
machine.succeed(
"printf '#!/bin/sh\\necho switch >> /root/rebuilds\\n' > /root/rebuild"
" && chmod +x /root/rebuild"
)
env = ("NOMARCHY_PATH=/root/.nomarchy "
"NOMARCHY_DEFAULT_THEMES=${./themes} "
"NOMARCHY_REBUILD=/root/rebuild ")
# Pair + schedule (setup writes only, no rebuild).
for kv in ["settings.autoTheme.enable true",
"settings.autoTheme.day summer-day",
"settings.autoTheme.night summer-night",
"settings.autoTheme.sunrise 07:00",
"settings.autoTheme.sunset 20:00"]:
machine.succeed(env + "nomarchy-theme-sync --quiet set " + kv + " --no-switch")
def slug():
return machine.succeed(env + "nomarchy-theme-sync get slug").strip()
def rebuilds():
return int(machine.succeed("wc -l < /root/rebuilds 2>/dev/null || echo 0").strip())
# Before sunset: already the day theme no switch, no rebuild.
machine.succeed("date -s '2026-01-02 19:55:00'")
machine.succeed(env + "nomarchy-theme-sync auto")
assert slug() == "summer-day", f"pre-sunset should stay day, got {slug()}"
assert rebuilds() == 0, f"no needless rebuild, got {rebuilds()}"
# Cross sunset night theme, exactly one rebuild triggered.
machine.succeed("date -s '2026-01-02 20:05:00'")
machine.succeed(env + "nomarchy-theme-sync auto")
assert slug() == "summer-night", f"post-sunset should be night, got {slug()}"
assert rebuilds() == 1, f"sunset should trigger one rebuild, got {rebuilds()}"
# Another tick still after sunset idempotent, no extra rebuild.
machine.succeed("date -s '2026-01-02 20:06:00'")
machine.succeed(env + "nomarchy-theme-sync auto")
assert slug() == "summer-night"
assert rebuilds() == 1, f"idempotent tick must not rebuild, got {rebuilds()}"
# Cross sunrise the next morning back to day, second rebuild.
machine.succeed("date -s '2026-01-03 07:30:00'")
machine.succeed(env + "nomarchy-theme-sync auto")
assert slug() == "summer-day", f"post-sunrise should be day, got {slug()}"
assert rebuilds() == 2, f"sunrise should trigger a rebuild, got {rebuilds()}"
'';
};
# 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

@@ -1,29 +1,89 @@
# Live ISO host — boot the full Nomarchy desktop from a USB stick or QEMU # Live ISO host — boot the full Nomarchy desktop from a USB stick or QEMU
# without touching the disk. No installer yet (see roadmap); this target # without touching the disk. The live session bundles nomarchy-install
# exists to test the distro end-to-end on real hardware. # (gum + disko + mkFlake) for a real install; this target also lets you
# test the distro end-to-end on real hardware before committing to disk.
{ 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 (Boreal 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" "B79BE8";
base = isoColor "base" "21272F";
subtext = isoColor "subtext" "97A3B2";
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
@@ -112,6 +172,20 @@ in
# regression). Installed systems keep idle management. # regression). Installed systems keep idle management.
nomarchy.idle.enable = false; nomarchy.idle.enable = false;
# Durable install affordance (BACKLOG #57): always-visible desktop
# entry, not only the 3s toast / getty helpLine / tribal knowledge.
# Tools Install Nomarchy is self-gated on this package in rofi.nix.
xdg.desktopEntries.nomarchy-install = {
name = "Install Nomarchy";
genericName = "Installer";
comment = "Install Nomarchy to this machine's disk";
exec = "nomarchy-install";
terminal = true;
icon = "system-software-install";
categories = [ "System" "Settings" ];
startupNotify = true;
};
wayland.windowManager.hyprland.settings = { wayland.windowManager.hyprland.settings = {
# QEMU (and some panels) report a tiny "preferred" mode; ask for # QEMU (and some panels) report a tiny "preferred" mode; ask for
# the highest resolution instead. # the highest resolution instead.
@@ -119,7 +193,7 @@ in
# Welcome toast once the session is up (concatenated onto the # Welcome toast once the session is up (concatenated onto the
# base exec-once list). # base exec-once list).
exec-once = [ exec-once = [
"sh -c 'sleep 3; notify-send -a Nomarchy \"Welcome to Nomarchy\" \"SUPER+Return terminal · SUPER+T themes · install with nomarchy-install\"'" "sh -c 'sleep 3; notify-send -a Nomarchy \"Welcome to Nomarchy\" \"SUPER+Return terminal · SUPER+T themes · Install Nomarchy app or nomarchy-install\"'"
]; ];
}; };
}; };

26
lib.nix
View File

@@ -5,7 +5,16 @@
# in flake.nix remain the escape hatch for power users. # in flake.nix remain the escape hatch for power users.
{ nixpkgs, home-manager, nixos-hardware, nomarchy }: { nixpkgs, home-manager, nixos-hardware, nomarchy }:
let
inherit (nixpkgs) lib;
# Shared pure reader (modules/theme-state-read.nix) — re-exported so
# power users composing without mkFlake can call it too.
readThemeState = import ./modules/theme-state-read.nix { inherit lib; };
in
{ {
inherit readThemeState;
mkFlake = mkFlake =
{ src # the downstream flake directory (./.) { src # the downstream flake directory (./.)
, username # login name; also the homeConfigurations attr , username # login name; also the homeConfigurations attr
@@ -13,8 +22,6 @@
, system ? "x86_64-linux" , system ? "x86_64-linux"
}: }:
let let
inherit (nixpkgs) lib;
# One profile or several (the installer's autodetection emits a few # One profile or several (the installer's autodetection emits a few
# common-* modules alongside the model-specific one). # common-* modules alongside the model-specific one).
profileNames = profileNames =
@@ -51,8 +58,17 @@
# and the standalone HM desktop see the same package set. # and the standalone HM desktop see the same package set.
config.allowUnfree = true; config.allowUnfree = true;
}; };
# Early fail-closed gate: missing/empty/non-object theme-state.json
# throws here with a template + validate pointer, before module
# evaluation buries a raw readFile/fromJSON stack. Field-level
# schema still runs in modules/home/theme.nix after defaults merge.
# Forced via seq on the whole return set — attrNames alone must not
# skip the check (Nix is lazy on unused let bindings and attr values).
statePath = src + "/theme-state.json";
_themeState = readThemeState statePath;
in in
{ builtins.seq _themeState {
# System layer — rebuilt rarely: # System layer — rebuilt rarely:
# sudo nixos-rebuild switch --flake .#default # sudo nixos-rebuild switch --flake .#default
nixosConfigurations.default = nixpkgs.lib.nixosSystem { nixosConfigurations.default = nixpkgs.lib.nixosSystem {
@@ -67,7 +83,7 @@
{ environment.systemPackages = [ home-manager.packages.${system}.home-manager ]; } { environment.systemPackages = [ home-manager.packages.${system}.home-manager ]; }
# System-side theme consumers (Plymouth splash background) # System-side theme consumers (Plymouth splash background)
# read the same JSON the desktop does. # read the same JSON the desktop does.
{ nomarchy.system.stateFile = src + "/theme-state.json"; } { nomarchy.system.stateFile = statePath; }
] ]
++ hardwareModules ++ hardwareModules
++ [ ++ [
@@ -87,7 +103,7 @@
{ {
# Written by nomarchy-theme-sync; reading it is pure — the # Written by nomarchy-theme-sync; reading it is pure — the
# file is part of the downstream flake's source. # file is part of the downstream flake's source.
nomarchy.stateFile = src + "/theme-state.json"; nomarchy.stateFile = statePath;
home = { home = {
inherit username; inherit username;
homeDirectory = "/home/${username}"; homeDirectory = "/home/${username}";

View File

@@ -0,0 +1,36 @@
# Auto time-of-day theme switch (BACKLOG #79, VISION § D). A user timer
# periodically runs `nomarchy-theme-sync auto`, which reads
# settings.autoTheme = { enable, day, night, sunrise, sunset } from the
# state file and applies the day or night preset for the current clock —
# through the SAME one engine as a manual `apply` (no second pipeline).
#
# The command self-gates (no-op when disabled) and is idempotent (it only
# rebuilds when the active theme actually needs to change), so the timer
# can tick freely. Install is gated on the state flag (like nomarchy.updates
# gates on its enable), so a machine not using the feature carries no timer;
# enabling it from the menu (slice 3) writes the flag + rebuilds, which is
# what brings the timer into being.
{ config, lib, pkgs, ... }:
lib.mkIf (config.nomarchy.settings.autoTheme.enable or false) {
systemd.user.services.nomarchy-auto-theme = {
Unit.Description = "Apply the day/night theme for the current time";
Service = {
Type = "oneshot";
# The rebuild (`home-manager switch`) and its tools resolve from the
# system + per-user profiles — same PATH shape nomarchy-updates uses.
Environment = "PATH=/run/current-system/sw/bin:/etc/profiles/per-user/${config.home.username}/bin";
ExecStart = "${pkgs.nomarchy-theme-sync}/bin/nomarchy-theme-sync auto";
};
};
systemd.user.timers.nomarchy-auto-theme = {
Unit.Description = "Periodic day/night theme check";
Timer = {
OnStartupSec = "1min"; # settle on the right theme shortly after login
OnCalendar = "*:0/15"; # re-check every 15 min; auto no-ops if unchanged
Persistent = true; # catch a transition missed while powered off
};
Install.WantedBy = [ "timers.target" ];
};
}

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,30 +18,62 @@
./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
./autotheme.nix # auto day/night theme switch (settings.autoTheme), 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
./satty.nix # satty screenshot annotation tool, themed
]; ];
# Clipboard history (wl-paste watcher); browsed via the SUPER+CTRL+V # Clipboard history (wl-paste watcher); browsed via the SUPER+CTRL+V
# menu module. # menu module.
services.cliphist.enable = true; services.cliphist.enable = true;
# Automount removable media (USB drives) and provide safe-removal notifications.
services.udiskie.enable = true;
# Microphone noise cancellation (rnnoise) and audio EQ.
services.easyeffects.enable = true;
# Wifi from the bar: nm-applet lives in waybar's tray (SNI flag via # Wifi from the bar: nm-applet lives in waybar's tray (SNI flag via
# preferStatusNotifierItems — without it there is no tray icon). # preferStatusNotifierItems — without it there is no tray icon).
services.network-manager-applet.enable = true; services.network-manager-applet.enable = true;
xsession.preferStatusNotifierItems = true; xsession.preferStatusNotifierItems = true;
home.stateVersion = "26.05"; # 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 = lib.mkDefault "26.05";
home.packages = with pkgs; [ home.packages = with pkgs; [
awww # wallpaper daemon with animated transitions (the swww fork) awww # wallpaper daemon with animated transitions (the swww fork)
libnotify libnotify
hyprpicker
# Lifecycle CLIs (pull/rebuild/home). HM profile usually precedes
# /run/current-system on PATH, so nomarchy-home can replace a broken
# system-generation nomarchy-pull without a full sys rebuild.
nomarchy-lifecycle
]; ];
home.sessionVariables = { home.sessionVariables = {
TERMINAL = config.nomarchy.terminal; TERMINAL = config.nomarchy.terminal;
NIXOS_OZONE_WL = "1"; # Electron/Chromium native Wayland NIXOS_OZONE_WL = "1"; # Electron/Chromium native Wayland
# Same gate as modules/nixos: CLI nix-shell/shell/run for unfree pkgs
# (system nixpkgs.config alone does not cover those entry points).
NIXPKGS_ALLOW_UNFREE = "1";
# Where the Nomarchy flake (and therefore theme-state.json) lives on # Where the Nomarchy flake (and therefore theme-state.json) lives on
# disk. nomarchy-theme-sync writes its state here; rebuilds read from # disk. nomarchy-theme-sync writes its state here; rebuilds read from
@@ -49,5 +81,12 @@
NOMARCHY_PATH = "$HOME/.nomarchy"; NOMARCHY_PATH = "$HOME/.nomarchy";
}; };
# Classic nix-shell / nix-env read this; pairs with NIXPKGS_ALLOW_UNFREE
# above so "allow unfree" is the default desktop experience, not a
# per-command export the user has to remember.
xdg.configFile."nixpkgs/config.nix".text = ''
{ allowUnfree = true; }
'';
programs.home-manager.enable = true; programs.home-manager.enable = true;
} }

View File

@@ -8,11 +8,16 @@ let
c = t.colors; c = t.colors;
in in
{ {
programs.ghostty = lib.mkIf config.nomarchy.ghostty.enable { # Ghostty is ALWAYS installed — it's Nomarchy's default terminal and is
# load-bearing (SUPER+E file manager, the calendar launcher's classed
# window, etc.), so the distro enforces it. `nomarchy.ghostty.enable` now
# gates only whether Nomarchy's theming/config is applied — a user can
# keep ghostty but drop our config, they just can't remove ghostty itself.
programs.ghostty = {
enable = true; enable = true;
enableBashIntegration = true; enableBashIntegration = true;
settings = { settings = lib.mkIf config.nomarchy.ghostty.enable {
# ── Typography (from theme-state.json) ──────────────────────── # ── Typography (from theme-state.json) ────────────────────────
font-family = t.fonts.mono; font-family = t.fonts.mono;
font-size = t.fonts.size; font-size = t.fonts.size;

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,169 @@ 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; }
check_monitors() {
cur=$(outputs)
[ "$cur" = "$1" ] && return
[ -n "$cur" ] || return
auto_on || return
target=$(nomarchy-display-profile match $cur) || target="base"
if [ "$target" = "base" ]; then
[ "$(nomarchy-display-profile active)" = "none" ] && return
nomarchy-display-profile base
else
[ "$target" = "$(nomarchy-display-profile active)" ] && return
nomarchy-display-profile apply "$target"
fi
echo "$cur"
}
prev=$(outputs)
# Listen to Hyprland's IPC socket for instant hotplug reaction
${pkgs.socat}/bin/socat -U - UNIX-CONNECT:$XDG_RUNTIME_DIR/hypr/$HYPRLAND_INSTANCE_SIGNATURE/.socket2.sock | while read -r line; do
case "$line" in
monitoradded*|monitorremoved*)
res=$(check_monitors "$prev")
[ -n "$res" ] && prev="$res"
;;
esac
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 +246,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 +304,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 +333,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 +348,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 +417,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 +449,79 @@ 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;
}; };
# Window rules — float + center small config/utility dialogs that
# tile awkwardly. Normal-priority list like `bind`/`exec-once` below,
# so a downstream `windowrule = [...]` concatenates rather than
# replaces. Syntax is the post-0.53 rule rewrite (hyprlang legacy
# parser, `handleWindowrule`): each comma field is `<keyword>
# <value>`, so effects carry a value (`float 1`, `center 1`, `size
# W H`) and matchers take a `match:` prefix (`match:class ^…$`).
# The old rule-first form (`float, class:^…$`) and the
# `windowrulev2` key are both hard errors now (red config-error
# banner on every session start — seen in the capture harness).
# Class regexes use `(?i)` and tolerate the XWayland
# `.…-wrapped` binary-name form.
#
# Conservative float set (item 41 closed): only dialogs we ship or can
# name from package strings / desktop files. Classes: mixer,
# blueman, system-config-printer, calendar, hyprpolkitagent,
# pinentry-qt, and xdg-desktop-portal-gtk (desktop file + libexec
# name; .…-wrapped for the Nix wrapper). SoftGL could not materialize
# portal windows; polkit float confirmed on Latitude 2026-07-10.
windowrule = [
# Audio mixer (item 35): right-click the Waybar volume module.
"float 1, match:class ^(com\\.saivert\\.pwvucontrol|org\\.pulseaudio\\.pavucontrol|pavucontrol)$"
"center 1, match:class ^(com\\.saivert\\.pwvucontrol|org\\.pulseaudio\\.pavucontrol|pavucontrol)$"
# Bluetooth manager (blueman) + CUPS printer admin.
"float 1, match:class (?i)^(\\.?blueman-(manager|adapters)(-wrapped)?|\\.?system-config-printer(-wrapped)?)$"
"center 1, match:class (?i)^(\\.?blueman-(manager|adapters)(-wrapped)?|\\.?system-config-printer(-wrapped)?)$"
# Calendar popup (item 42): the Waybar clock's on-click runs
# nomarchy-calendar → calcurse in a ghostty window tagged with a
# distinct --class, so it floats centered instead of tiling.
"float 1, match:class ^(com\\.nomarchy\\.calendar)$"
"size 60% 65%, match:class ^(com\\.nomarchy\\.calendar)$"
"center 1, match:class ^(com\\.nomarchy\\.calendar)$"
# Polkit auth (hyprpolkitagent — app id / binary name in the package)
# and pinentry-qt (keys.nix default; org.gnupg.pinentry-qt desktop).
# workspace current: agent was started on ws1 at login; without this
# the dialog opens on that workspace and the current one looks dead
# (hardware: Snapshots menu from ws≠1). stay_focused (0.55 name;
# was stayfocused) so it grabs attention when it lands.
"float 1, match:class (?i)^(hyprpolkitagent|\\.hyprpolkitagent-wrapped)$"
"center 1, match:class (?i)^(hyprpolkitagent|\\.hyprpolkitagent-wrapped)$"
"workspace current, match:class (?i)^(hyprpolkitagent|\\.hyprpolkitagent-wrapped)$"
"stay_focused 1, match:class (?i)^(hyprpolkitagent|\\.hyprpolkitagent-wrapped)$"
"float 1, match:class (?i)^(pinentry(-qt)?|org\\.gnupg\\.pinentry-qt)$"
"center 1, match:class (?i)^(pinentry(-qt)?|org\\.gnupg\\.pinentry-qt)$"
"workspace current, match:class (?i)^(pinentry(-qt)?|org\\.gnupg\\.pinentry-qt)$"
# GTK file-chooser portal (xdg-desktop-portal-gtk) — class is the
# binary/desktop name; Nix wrap = .xdg-desktop-portal-gtk-wrapped.
"float 1, match:class (?i)^(\\.?xdg-desktop-portal-gtk(-wrapped)?)$"
"center 1, match:class (?i)^(\\.?xdg-desktop-portal-gtk(-wrapped)?)$"
];
# 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 +550,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,7 @@
{ {
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 = "App launcher"; }
{ mods = "$mod"; key = "D"; action = "exec, rofi -show drun"; 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 +32,13 @@
{ 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 Shift+/. SHIFT stays in the modmask (you hold it), but
{ mods = "$mod"; key = "question"; action = "exec, nomarchy-menu keybinds"; desc = "Keybindings cheatsheet"; } # the keysym must be the BASE key `slash`, not `question`: Hyprland
# resolves the sym with Shift consumed, so `question` never matches while
# Shift is down — same as the `$mod SHIFT, 1` workspace binds. (item 26
# fixed the modmask but kept the shifted keysym → still dead; item 32.)
# The cheatsheet still renders this row as SUPER + ? (rofi.nix).
{ mods = "$mod SHIFT"; key = "slash"; 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).
@@ -43,20 +47,43 @@
{ mods = "$mod CTRL"; key = "W"; action = "exec, nomarchy-menu web"; desc = "Web search"; } { mods = "$mod CTRL"; key = "W"; action = "exec, nomarchy-menu web"; desc = "Web search"; }
{ mods = "$mod CTRL"; key = "F"; action = "exec, nomarchy-menu files"; desc = "File search"; } { mods = "$mod CTRL"; key = "F"; action = "exec, nomarchy-menu files"; desc = "File search"; }
{ mods = "$mod CTRL"; key = "E"; action = "exec, nomarchy-menu emoji"; desc = "Emoji picker"; } { mods = "$mod CTRL"; key = "E"; action = "exec, nomarchy-menu emoji"; desc = "Emoji picker"; }
{ 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 (networkmanager_dmenu)"; }
{ 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"; }
{ mods = "$mod SHIFT"; key = "C"; action = "exec, hyprpicker -a"; desc = "Color picker ( clipboard)"; }
# Focus # Focus — SUPER + arrow keys.
{ mods = "$mod"; key = "H"; action = "movefocus, l"; desc = "Focus left"; } { mods = "$mod"; key = "left"; action = "movefocus, l"; desc = "Focus left"; }
{ mods = "$mod"; key = "L"; action = "movefocus, r"; desc = "Focus right"; } { mods = "$mod"; key = "right"; action = "movefocus, r"; desc = "Focus right"; }
{ mods = "$mod"; key = "K"; action = "movefocus, u"; desc = "Focus up"; } { mods = "$mod"; key = "up"; action = "movefocus, u"; desc = "Focus up"; }
{ mods = "$mod"; key = "J"; action = "movefocus, d"; desc = "Focus down"; } { mods = "$mod"; key = "down"; action = "movefocus, d"; desc = "Focus down"; }
# Screenshot region to clipboard (the menu's Capture module has more). # Multi-monitor workspace movement — SUPER + ALT + arrow keys.
{ mods = ""; key = "Print"; action = "exec, grim -g \"$(slurp)\" - | wl-copy"; desc = "Screenshot region clipboard"; } { mods = "$mod ALT"; key = "left"; action = "movecurrentworkspacetomonitor, l"; desc = "Move workspace to left monitor"; }
{ mods = "$mod ALT"; key = "right"; action = "movecurrentworkspacetomonitor, r"; desc = "Move workspace to right monitor"; }
{ mods = "$mod ALT"; key = "up"; action = "movecurrentworkspacetomonitor, u"; desc = "Move workspace to upper monitor"; }
{ mods = "$mod ALT"; key = "down"; action = "movecurrentworkspacetomonitor, d"; desc = "Move workspace to lower monitor"; }
# Screenshots (the menu's Capture module has the rest: OCR, recording).
# Bare Print → region to clipboard; the two → file binds save a
# timestamped PNG under ~/Pictures/Screenshots and toast the path, the
# same plumbing the Capture menu's "→ file" rows use.
{ mods = ""; key = "Print"; action = "exec, grim -g \"$(slurp)\" - | wl-copy && notify-send Screenshot \"Region copied to clipboard.\""; desc = "Screenshot region clipboard"; }
{ mods = "SHIFT"; key = "Print"; action = "exec, f=$HOME/Pictures/Screenshots/$(date +%Y%m%d-%H%M%S).png; mkdir -p $HOME/Pictures/Screenshots && grim -g \"$(slurp)\" \"$f\" && notify-send \"Screenshot saved\" \"$f\""; desc = "Screenshot region file"; }
{ mods = "CTRL"; key = "Print"; action = "exec, f=$HOME/Pictures/Screenshots/$(date +%Y%m%d-%H%M%S).png; mkdir -p $HOME/Pictures/Screenshots && grim \"$f\" && notify-send \"Screenshot saved\" \"$f\""; desc = "Screenshot screen file"; }
{ mods = "$mod SHIFT"; key = "Print"; action = "exec, f=$HOME/Pictures/Screenshots/$(date +%Y%m%d-%H%M%S).png; mkdir -p $HOME/Pictures/Screenshots && grim -g \"$(slurp)\" - | satty --filename - --fullscreen --output-filename \"$f\""; desc = "Annotate region"; }
];
# 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 = [
@@ -64,5 +91,6 @@
{ 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)";
}; };
} }

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

@@ -0,0 +1,61 @@
# 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 → mpv (the template's media player).
"video/mp4" = "mpv.desktop";
"video/webm" = "mpv.desktop";
"video/x-matroska" = "mpv.desktop";
"video/quicktime" = "mpv.desktop";
# Audio → Amberol (the template's GTK4 music player). Amberol
# registers these types itself, so this only sets the preference over
# mpv, which also claims them. Both x- and canonical names because
# files report either. Degrades to mpv/whatever if Amberol is dropped.
"audio/mpeg" = "io.bassi.Amberol.desktop";
"audio/flac" = "io.bassi.Amberol.desktop";
"audio/x-flac" = "io.bassi.Amberol.desktop";
"audio/ogg" = "io.bassi.Amberol.desktop";
"audio/x-vorbis+ogg" = "io.bassi.Amberol.desktop";
"audio/opus" = "io.bassi.Amberol.desktop";
"audio/wav" = "io.bassi.Amberol.desktop";
"audio/x-wav" = "io.bassi.Amberol.desktop";
"audio/mp4" = "io.bassi.Amberol.desktop";
"audio/x-m4a" = "io.bassi.Amberol.desktop";
"audio/aac" = "io.bassi.Amberol.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";
# Template ships chromium (chromium-browser.desktop). Entry is
# skipped by GIO if the package is deleted; install another browser
# and/or override these keys to retarget.
"text/html" = "chromium-browser.desktop";
"x-scheme-handler/http" = "chromium-browser.desktop";
"x-scheme-handler/https" = "chromium-browser.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,84 @@
# 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 # Geo mode: when BOTH nomarchy.nightlight.latitude and .longitude are set,
# configures it. Override anything with plain services.hyprsunset.* options. # wlsunset replaces hyprsunset — it computes sunrise/sunset from the
{ config, lib, ... }: # location itself (the fixed .sunrise/.sunset times are ignored). Same
# toggle script, same Waybar moon, same live-state ExecCondition gate;
# only the unit underneath changes.
#
# Off by default and opt-in. Two git-tracked flags in the state file, both
# menu-written (no ~/.local/state):
# 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;
# Geo mode flips the backing unit; everything user-facing stays the same.
geo = cfg.latitude != null && cfg.longitude != null;
unit = if geo then "wlsunset.service" else "hyprsunset.service";
# 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=${unit}
# 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 ${if geo then "follows your location" else "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 && !geo) {
enable = true; enable = true;
settings.profile = [ settings.profile = [
# Daytime: identity = no colour change. # Daytime: identity = no colour change.
@@ -21,5 +87,22 @@ in
{ time = cfg.sunset; temperature = cfg.temperature; } { time = cfg.sunset; temperature = cfg.temperature; }
]; ];
}; };
# Geo mode: wlsunset owns the schedule — it recomputes sunrise/sunset
# from the coordinates daily (no fixed profile to bake).
services.wlsunset = lib.mkIf (cfg.enable && geo) {
enable = true;
latitude = cfg.latitude;
longitude = cfg.longitude;
temperature.night = 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.${lib.removeSuffix ".service" unit}.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.
''; '';
}; };
@@ -159,8 +192,9 @@ in
nightlight = { nightlight = {
enable = lib.mkEnableOption '' enable = lib.mkEnableOption ''
a scheduled blue-light filter (hyprsunset): warm at night, no shift a scheduled blue-light filter (hyprsunset; wlsunset in geo mode):
by day. Opt-in; tune the temperature + sunrise/sunset below''; warm at night, no shift by day. Opt-in; tune the temperature +
sunrise/sunset (or latitude/longitude) below'';
temperature = lib.mkOption { temperature = lib.mkOption {
type = lib.types.int; type = lib.types.int;
@@ -173,14 +207,57 @@ in
type = lib.types.str; type = lib.types.str;
default = "07:00"; default = "07:00";
example = "06:30"; example = "06:30";
description = "Time (HH:MM) the filter turns OFF daytime, no colour shift."; description = "Time (HH:MM) the filter turns OFF daytime, no colour shift. Ignored in geo mode.";
}; };
sunset = lib.mkOption { sunset = lib.mkOption {
type = lib.types.str; type = lib.types.str;
default = "20:00"; default = "20:00";
example = "21:00"; example = "21:00";
description = "Time (HH:MM) the filter turns ON warm."; description = "Time (HH:MM) the filter turns ON warm. Ignored in geo mode.";
};
latitude = lib.mkOption {
type = lib.types.nullOr lib.types.str;
default = null;
example = "52.52";
description = ''
Geo mode: set BOTH latitude and longitude and sunrise/sunset are
computed from your location every day (wlsunset replaces
hyprsunset; the fixed .sunrise/.sunset times are ignored).'';
};
longitude = lib.mkOption {
type = lib.types.nullOr lib.types.str;
default = null;
example = "13.40";
description = "Geo mode longitude set together with latitude.";
};
};
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 nomarchy-pull /
nomarchy-rebuild / nomarchy-home / 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.
'';
}; };
}; };
@@ -205,11 +282,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 +374,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 +384,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
'')
];
}

File diff suppressed because it is too large Load Diff

31
modules/home/satty.nix Normal file
View File

@@ -0,0 +1,31 @@
{ config, lib, pkgs, ... }:
let
cfg = config.nomarchy;
t = cfg.theme;
c = t.colors;
in
{
config = lib.mkIf (pkgs.lib.meta.availableOn pkgs.stdenv.hostPlatform pkgs.satty) {
home.packages = [ pkgs.satty ];
xdg.configFile."satty/config.toml".text = ''
[general]
# Set the default tool to pointer or arrow?
initial-tool = "arrow"
copy-command = "wl-copy"
# Hitting copy also saves? Let the user hit save if they want to save.
# save-after-copy = false
[color-palette]
palette = [
"${c.accent}ff",
"${c.bad}ff",
"${c.warn}ff",
"${c.good}ff",
"${c.text}ff",
"${c.base}ff"
]
'';
};
}

View File

@@ -69,11 +69,11 @@ in
gpl = "git pull"; gpl = "git pull";
gf = "git fetch --all --prune"; gf = "git fetch --all --prune";
# Nix — the flake/store verbs (system/home rebuilds already have the # Nix — store/flake verbs. Lifecycle is nomarchy-pull /
# sys-update / home-update commands, so they're not re-aliased here). # nomarchy-rebuild / nomarchy-home (full names on PATH, not aliases).
ns = "nix shell"; # ns nixpkgs#ripgrep ns = "nix shell"; # ns nixpkgs#ripgrep
nr = "nix run"; # nr nixpkgs#cowsay nr = "nix run"; # nr nixpkgs#cowsay
nfu = "nix flake update"; nfu = "nix flake update"; # prefer nomarchy-pull day-to-day
nfc = "nix flake check"; nfc = "nix flake check";
nsearch = "nix search nixpkgs"; nsearch = "nix search nixpkgs";
ngc = "nix-collect-garbage -d"; # user generations; sudo for system roots ngc = "nix-collect-garbage -d"; # user generations; sudo for system roots

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 = {
@@ -58,10 +60,12 @@ in
# GTK/file-manager/rofi icon theme, resolved from the JSON in # GTK/file-manager/rofi icon theme, resolved from the JSON in
# theme.nix (per-theme `icons`, else Papirus-Dark/Light by mode). # theme.nix (per-theme `icons`, else Papirus-Dark/Light by mode).
# Stylix sets gtk.iconTheme from this; both dark/light point at the # Stylix sets gtk.iconTheme from this; both dark/light point at the
# already-mode-resolved name. # already-mode-resolved name. The package is papirus by default and
# only unions in another pack when a theme's `icons` names one
# (theme.nix — opt-in, no default closure bloat).
icons = { icons = {
enable = true; enable = true;
package = lib.mkDefault pkgs.papirus-icon-theme; package = lib.mkDefault t.iconThemePackage;
dark = t.iconTheme; dark = t.iconTheme;
light = t.iconTheme; light = t.iconTheme;
}; };

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};
@@ -42,6 +45,8 @@ in
border: ${toString t.ui.borderSize}px solid alpha(@accent, 0.4); border: ${toString t.ui.borderSize}px solid alpha(@accent, 0.4);
border-radius: ${r}px; border-radius: ${r}px;
color: @text; color: @text;
font-family: "${t.fonts.ui}", "${t.fonts.mono}";
font-size: ${toString t.fonts.size}pt;
} }
.notification-content .summary { .notification-content .summary {
@@ -50,7 +55,7 @@ in
} }
.notification-content .body { .notification-content .body {
color: @subtext; color: @text;
} }
.notification.critical { .notification.critical {
@@ -62,11 +67,13 @@ in
border: ${toString t.ui.borderSize}px solid alpha(@accent, 0.4); border: ${toString t.ui.borderSize}px solid alpha(@accent, 0.4);
border-radius: ${r}px; border-radius: ${r}px;
color: @text; color: @text;
font-family: "${t.fonts.ui}", "${t.fonts.mono}";
font-size: ${toString t.fonts.size}pt;
} }
.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 +83,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

@@ -18,14 +18,18 @@
let let
cfg = config.nomarchy; cfg = config.nomarchy;
themeState = builtins.fromJSON (builtins.readFile cfg.stateFile); # Fail-closed load: missing / empty / non-object get a short pointer at
# the template + `nomarchy-theme-sync validate`, not a raw readFile stack
# from deep inside a consumer (nightlight, hyprland, …). Field-level
# checks below still run on the merged result.
themeState = import ../theme-state-read.nix { inherit lib; } cfg.stateFile;
# Defaults guarantee evaluation succeeds on a sparse or older state # Defaults guarantee evaluation succeeds on a sparse or older state
# file (e.g. one written before a schema field was added). The shipped # file (e.g. one written before a schema field was added). The shipped
# Tokyo Night preset provides the color/ansi fallbacks; the path is # Boreal preset provides the color/ansi fallbacks; the path is
# relative to this module, so it resolves inside Nomarchy's own flake # relative to this module, so it resolves inside Nomarchy's own flake
# source even when consumed downstream. # source even when consumed downstream.
preset = builtins.fromJSON (builtins.readFile ../../themes/tokyo-night.json); preset = builtins.fromJSON (builtins.readFile ../../themes/boreal.json);
defaults = preset // { defaults = preset // {
fonts = { fonts = {
@@ -54,16 +58,103 @@ 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.
# settings.displayProfile / displayProfileAuto: active named layout +
# auto-switch on plug events (hyprland.nix / Display menu).
# 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;
displayProfile = "";
displayProfileAuto = 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 boreal`.'';
# 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 roles are rejected by the field checks
resolveColor = v: if lib.hasPrefix "#" v then v else parsed.colors.${v} or v; # above; resolve only runs on a validated state.
resolveColor = v:
if lib.hasPrefix "#" v then v
else parsed.colors.${v} or (throw ''
Nomarchy: border role "${v}" is not in the palette (colors.*).
Use one of: ${lib.concatStringsSep ", " colorRoles}
or a literal "#RRGGBB". Validate with: nomarchy-theme-sync validate'');
border = { border = {
active = resolveColor parsed.border.active; active = resolveColor checked.border.active;
inactive = resolveColor parsed.border.inactive; inactive = resolveColor checked.border.inactive;
}; };
# Resolve the icon theme once and expose it on nomarchy.theme so both # Resolve the icon theme once and expose it on nomarchy.theme so both
@@ -73,10 +164,48 @@ let
if parsed.icons != "" then parsed.icons if parsed.icons != "" then parsed.icons
else if parsed.mode == "light" then "Papirus-Light" else if parsed.mode == "light" then "Papirus-Light"
else "Papirus-Dark"; else "Papirus-Dark";
# Icon-pack resolution — opt-in, no default bloat. Papirus is the shipped
# default and the ONLY icon pack in the closure unless a theme's `icons`
# names a set from another pack; then that pack (and only it) is
# union-joined alongside papirus. Because the default themes name
# `Papirus-*`, the resolved package below is byte-identical to
# papirus-icon-theme (no symlinkJoin, no added MB). To offer another set:
# add one row to `iconPacks` and set `icons = "<Name>"` in a theme JSON
# (see templates/downstream/home.nix). Referencing `pkgs.*` here is
# eval-only — a pack enters the closure solely when it is the matched one.
papirusPkg = pkgs.papirus-icon-theme;
iconPacks = [
# pkg + the theme-name prefixes it provides (packs ship many named
# variants under one prefix, e.g. Tela / Tela-dark / Tela-blue).
{ pkg = papirusPkg; prefixes = [ "Papirus" "breeze" ]; }
{ pkg = pkgs.tela-icon-theme; prefixes = [ "Tela" ]; }
{ pkg = pkgs.qogir-icon-theme; prefixes = [ "Qogir" ]; }
{ pkg = pkgs.reversal-icon-theme; prefixes = [ "Reversal" ]; }
{ pkg = pkgs.numix-icon-theme-circle; prefixes = [ "Numix" ]; }
];
# The pack whose prefix matches the resolved name (null = unknown name →
# GTK falls back gracefully; papirus is still present).
matchedPack =
let m = lib.findFirst
(p: lib.any (pre: lib.hasPrefix pre iconTheme) p.prefixes) null iconPacks;
in if m == null then null else m.pkg;
iconThemePackage =
if matchedPack == null || matchedPack == papirusPkg
then papirusPkg # single pack → identical store path, zero closure delta
else pkgs.symlinkJoin {
name = "nomarchy-icon-themes";
paths = [ papirusPkg matchedPack ];
};
in in
{ {
config = { config = {
nomarchy.theme = parsed // { inherit iconTheme border; }; nomarchy.theme = checked // { inherit iconTheme border iconThemePackage; };
# 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" ];
};
}

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

@@ -0,0 +1,145 @@
# 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 nomarchy-pull /
# nomarchy-rebuild / nomarchy-home / 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: nomarchy-pull && nomarchy-rebuild && nomarchy-home"
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) nomarchy-pull && nomarchy-rebuild && nomarchy-home"
[ "$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 nomarchy-pull >/dev/null 2>&1; then
read -rp "Run nomarchy-pull && nomarchy-rebuild && nomarchy-home? [y/N] " a
if [ "$a" = y ]; then
nomarchy-pull
nomarchy-rebuild
command -v nomarchy-home >/dev/null 2>&1 && nomarchy-home
fi
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,8 +28,46 @@ 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 # Name-agnostic system battery (BAT0, CMB0, ) BACKLOG #60.
has_bat=
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
has_bat=1; break
done
[ -n "$has_bat" ] || exit 0
command -v powerprofilesctl >/dev/null 2>&1 || exit 0 command -v powerprofilesctl >/dev/null 2>&1 || exit 0
prof=$(powerprofilesctl get 2>/dev/null) || exit 0 prof=$(powerprofilesctl get 2>/dev/null) || exit 0
case "$prof" in case "$prof" in
@@ -51,6 +89,54 @@ let
[ -n "$next" ] && powerprofilesctl set "$next" [ -n "$next" ] && powerprofilesctl set "$next"
''; '';
# Opens calcurse (a lightweight TUI calendar, month view by default) in a
# floating, centered ghostty window — bound to the Waybar clock's on-click.
# A distinct --class gives the window a matchable app-id for the
# float+center+size windowrule (hyprland.nix); --gtk-single-instance=false
# forces a fresh standalone window (ghostty defaults single-instance on).
# calcurse is installed with the bar (home.packages below); the gate is
# only for someone who force-removed it. ghostty is always installed
# (ghostty.nix), so it can be relied on for the --class window.
calendarLauncher = pkgs.writeShellScriptBin "nomarchy-calendar" ''
if ! command -v calcurse >/dev/null 2>&1; then
notify-send "Calendar" "calcurse isn't installed (removed from home.packages?)." 2>/dev/null
exit 0
fi
exec ghostty --class=com.nomarchy.calendar --gtk-single-instance=false -e calcurse
'';
# 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.
# Absolute path to the doctor binary: waybar's custom-module env can
# miss system PATH, and `command -v … || exit 0` then self-hides forever.
doctorStatus = pkgs.writeShellScriptBin "nomarchy-doctor-status" ''
out=$(${pkgs.nomarchy-doctor}/bin/nomarchy-doctor 2>/dev/null) && exit 0
# Strip ANSI so the tooltip is plain text (and waybar never chokes on
# control chars); keep only the lines.
tip=$(printf '%s\n' "$out" \
| ${pkgs.gnused}/bin/sed 's/\x1b\[[0-9;]*m//g' \
| grep '' | head -5 \
| ${pkgs.jq}/bin/jq -Rs 'rtrimstr("\n") + "\n(click for the full sheet)"')
# 󰀨 (md-alert-circle): alert shape that survives incomplete Nerd Font
# cuts better than 󰒡; class:bad still paints it @bad.
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";
@@ -63,7 +149,12 @@ let
(lib.mapAttrsToList (name: value: "@define-color ${name} ${value};") t.colors); (lib.mapAttrsToList (name: value: "@define-color ${name} ${value};") t.colors);
generatedSettings = { generatedSettings = {
layer = "top"; # `bottom`, not `top`: on Hyprland the `top` layer renders above every
# window — so a real fullscreen surface (a browser video gone
# fullscreen) sits *under* the bar. On `bottom` the fullscreen window
# covers the bar, while the exclusive zone still reserves its space in
# normal tiling. Keep in sync with the whole-swap jsoncs (parity rule).
layer = "bottom";
position = "top"; position = "top";
height = 34; height = 34;
margin-top = t.ui.gapsOut; margin-top = t.ui.gapsOut;
@@ -75,11 +166,28 @@ let
# home-manager switch restyles the running bar without a restart. # home-manager switch restyles the running bar without a restart.
reload_style_on_change = true; reload_style_on_change = true;
modules-left = [ "hyprland/workspaces" "hyprland/window" ]; # Logo + powermenu: whole-swaps already ship these (parity was reverse
# — BACKLOG #63). Click targets are existing nomarchy-menu entry points.
modules-left = [ "custom/nomarchy" "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" "custom/powermenu" ];
"custom/nomarchy" = {
interval = "once";
# U+F000 — Nomarchy monogram (literal UTF-8; Nix has no \u escapes).
# CSS pins font-family: Nomarchy so Nerd Fonts' glass glyph does not win.
format = "";
on-click = "nomarchy-menu";
tooltip-format = "Nomarchy menu";
};
"custom/powermenu" = {
format = ""; # U+F011 power symbol
on-click = "nomarchy-menu power";
tooltip = false;
};
"hyprland/workspaces" = { "hyprland/workspaces" = {
format = "{icon}"; format = "{icon}";
@@ -92,56 +200,147 @@ let
}; };
clock = { clock = {
format = "{:%H:%M}"; # Single module: time + short date (whole-swaps used to split these
format-alt = "{:%A %d %B %Y}"; # into clock + clock#date — one click surface for the calendar).
tooltip-format = "<tt><small>{calendar}</small></tt>"; format = "{:%H:%M · %a %d %b}";
# Left-click → the calendar (nomarchy-calendar → calcurse in a floating
# ghostty). Tooltip is plain date/zone only — embedding {calendar}
# produced an empty popup on hardware (2026-07-10); month view is
# one click away.
on-click = "nomarchy-calendar";
tooltip = true;
# One chrono block only: waybar/fmt empties the tooltip when two
# bare `{:%…}` specs are concatenated (bar format works because it
# is a single block). Newline + zone live under auto-timezone
# (tz-watch SIGUSR2 reload).
tooltip-format = "{:%A, %d %B %Y\n%Z (UTC%z)}";
}; };
# 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}"; # Two spaces after the glyph: the keyboard icon's right bearing
# otherwise kisses the layout code (esp. mono Nerd faces).
format = "<span size='${toString (t.fonts.size + 2)}pt'>󰌌 </span>{short}";
tooltip = false; tooltip = false;
}; };
pulseaudio = { pulseaudio = {
format = "{icon} {volume}%"; # Two trailing spaces on every icon level: high-volume glyphs are the
# widest and still kissed the % with a single pad (hardware report).
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";
# Right-click → the full mixer (per-app volumes) in a floating window
# (item 35). pwvucontrol ships in the template suite; the Hyprland
# windowrule floats it. Keep in sync with the whole-swap jsoncs.
on-click-right = "pwvucontrol";
}; };
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}%"; # Same double-pad as pulseaudio (single space still tight on some glyphs).
format-charging = "󰂄 {capacity}%"; format = "<span size='${toString (t.fonts.size + 2)}pt'>{icon}</span>{capacity}%";
format-icons = [ "󰁺" "󰁼" "󰁾" "󰂀" "󰁹" ]; format-charging = "<span size='${toString (t.fonts.size + 2)}pt'>󰂄 </span>{capacity}%";
format-icons = [ "󰁺 " "󰁼 " "󰁾 " "󰂀 " "󰁹 " ];
# Click either the battery or the power-profile icon → the combined
# power menu (profile + charge cap). The granular System rows stay.
on-click = "nomarchy-menu powermgmt";
}; };
"custom/powerprofile" = { "custom/powerprofile" = {
exec = "nomarchy-powerprofile-status"; exec = "nomarchy-powerprofile-status";
return-type = "json"; return-type = "json";
interval = 5; interval = 5;
on-click = "nomarchy-powerprofile-cycle"; # Opens the same combined power menu as the battery icon (item 36b).
# nomarchy-powerprofile-cycle stays a standalone bin for downstream
# rebinding — it's just no longer the default click.
on-click = "nomarchy-menu powermgmt";
};
# 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";
format = "{}";
interval = 300;
# signal 10: poke after a fix (or from theme-shot) so the tripwire
# doesn't wait a full interval after a first-poll miss.
signal = 10;
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.
# The `dnd-*` classes (bell-off glyph) are styled muted below. # Every *suppressed* state — DND or app-inhibited — maps to the bell-off
# glyph 󰂛 and the muted color below, so "notifications are off right now"
# reads by SHAPE, not color alone (color-blind sweep, item 28): inhibited
# used to reuse the normal 󰂜/󰂚 bells, distinguishable only by color.
"custom/notification" = { "custom/notification" = {
exec = "swaync-client -swb"; exec = "swaync-client -swb";
return-type = "json"; return-type = "json";
@@ -152,8 +351,8 @@ let
notification = "󰂚"; notification = "󰂚";
dnd-none = "󰂛"; dnd-none = "󰂛";
dnd-notification = "󰂛"; dnd-notification = "󰂛";
inhibited-none = "󰂜"; inhibited-none = "󰂛";
inhibited-notification = "󰂚"; inhibited-notification = "󰂛";
dnd-inhibited-none = "󰂛"; dnd-inhibited-none = "󰂛";
dnd-inhibited-notification = "󰂛"; dnd-inhibited-notification = "󰂛";
}; };
@@ -183,6 +382,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 +404,7 @@ let
} }
#window { #window {
color: @subtext; color: alpha(@text, 0.85);
padding: 0 12px; padding: 0 12px;
} }
@@ -209,26 +413,90 @@ let
font-weight: bold; font-weight: bold;
} }
#tray, #pulseaudio, #network, #cpu, #memory, #custom-powerprofile, #language, #battery, #custom-notification { #custom-nomarchy {
color: @subtext; color: @accent;
font-family: Nomarchy;
font-size: ${toString (t.fonts.size + 4)}pt;
padding: 0 10px; padding: 0 10px;
} }
#custom-nomarchy:hover { color: @accentAlt; }
/* notifications waiting accent; Do-Not-Disturb muted bell-off */ #tray, #pulseaudio, #custom-powerprofile, #custom-nightlight, #custom-updates, #custom-vpn, #custom-recording, #idle_inhibitor, #language, #battery, #custom-doctor, #custom-notification, #custom-powermenu {
color: alpha(@text, 0.85);
padding: 0 10px;
}
#custom-powermenu:hover { color: @bad; }
/* 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, #custom-powermenu { 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; suppressed (DND or app-inhibited)
muted bell-off, so it reads by shape+color, never color alone. */
#custom-notification.notification { color: @accent; } #custom-notification.notification { color: @accent; }
#custom-notification.dnd-none, #custom-notification.dnd-none,
#custom-notification.dnd-notification { color: @muted; } #custom-notification.dnd-notification,
#custom-notification.inhibited-none,
#custom-notification.inhibited-notification { color: @muted; }
#pulseaudio.muted { color: @muted; } #pulseaudio.muted { color: @muted; }
#battery.warning { color: @warn; } #battery.warning { color: @warn; }
#battery.critical { color: @bad; } #battery.critical { color: @bad; }
#battery.charging { color: @good; } #battery.charging { color: @good; }
/* Clock (and other) hover tooltips explicit colors so an empty
looking box is never just text same as background. */
tooltip {
background: @surface;
border: ${toString t.ui.borderSize}px solid alpha(@accent, 0.4);
border-radius: ${toString t.ui.rounding}px;
}
tooltip label { color: @text; }
''; '';
in 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
@@ -248,8 +516,12 @@ in
); );
}; };
# The power-profile helpers on PATH, so both the generated bar and the # Bar helpers on PATH (whole-swap jsoncs exec them by bare name) +
# whole-swap themes' static waybar.jsonc can exec them by name. # feature deps of bar clicks: calcurse (clock), pwvucontrol (volume
# right-click) — not opt-in template packages.
home.packages = lib.optionals config.nomarchy.waybar.enable home.packages = lib.optionals config.nomarchy.waybar.enable
[ powerProfileStatus powerProfileCycle ]; [ waybarSupervisor powerProfileStatus powerProfileCycle vpnStatus doctorStatus calendarLauncher
pkgs.calcurse
pkgs.pwvucontrol
];
} }

View File

@@ -22,33 +22,58 @@ 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
# as a cheat sheet for the distro's own helpers. # as a cheat sheet for the distro's own helpers. Fingerprint line only
users.motd = lib.mkDefault '' # when fprintd is enabled (no permanent nag on machines without a
# reader); doctor is always on PATH via systemPackages.
users.motd = lib.mkDefault (''
${distroName} a NixOS desktop, themed from one JSON. ${distroName} a NixOS desktop, themed from one JSON.
sys-update update inputs + rebuild the system nomarchy-pull update flake inputs (nomarchy, nixpkgs, )
home-update apply home/theme changes (no sudo) nomarchy-rebuild rebuild the system (current lock)
nomarchy-home rebuild the desktop / Home Manager
nomarchy-theme-sync apply <theme> switch the whole palette nomarchy-theme-sync apply <theme> switch the whole palette
nomarchy-doctor read-only health check
SUPER+? keybindings cheatsheet SUPER+? keybindings cheatsheet
''; SUPER+M System Firmware check LVFS firmware updates (fwupd)
'' + lib.optionalString config.nomarchy.hardware.fingerprint.enable ''
SUPER+M System Fingerprint enroll a finger (fprintd)
'');
# Unfree allowed distro-wide: pragmatic-desktop territory (vendor # Unfree allowed distro-wide: pragmatic-desktop territory (vendor
# GPU/wifi drivers, firmware, fonts, …). The custom nixpkgs-config # GPU/wifi drivers, firmware, fonts, …). The custom nixpkgs-config
# type can't carry a nested mkDefault; disagree with # type can't carry a nested mkDefault; disagree with
# `nixpkgs.config = lib.mkForce { allowUnfree = false; }`. # `nixpkgs.config = lib.mkForce { allowUnfree = false; }`.
# `NIXPKGS_ALLOW_UNFREE` covers CLI `nix-shell` / `nix shell` /
# `nix run` (those ignore the system `nixpkgs.config`); pure flake
# eval still needs `--impure` when the env var is the only gate.
nixpkgs.config.allowUnfree = true; nixpkgs.config.allowUnfree = true;
environment.variables.NIXPKGS_ALLOW_UNFREE = "1";
# ── One keyboard layout everywhere, incl. the LUKS prompt ──────── # ── One keyboard layout everywhere, incl. the LUKS prompt ────────
# services.xserver.xkb.layout is the single source of truth for the # services.xserver.xkb.layout is the single source of truth for the
@@ -69,6 +94,15 @@ 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;
# Magic SysRq Keys: safety net for Wayland lockups. Alt+SysRq+REISUB allows
# safe reboot without data loss when the compositor hangs.
boot.kernel.sysctl."kernel.sysrq" = lib.mkDefault 1;
# ── 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 +113,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;
@@ -113,6 +133,10 @@ in
services.dbus.enable = lib.mkDefault true; services.dbus.enable = lib.mkDefault true;
services.upower.enable = lib.mkDefault true; services.upower.enable = lib.mkDefault true;
# Hardware security key (FIDO2/U2F/GPG) support. Without pcscd, tokens like
# YubiKeys often silently fail in WebAuthn (browsers), SSH, and GPG.
services.pcscd.enable = lib.mkDefault true;
# Firmware updates via LVFS. Ships the daemon + its metadata-refresh # Firmware updates via LVFS. Ships the daemon + its metadata-refresh
# timer only — it never flashes anything on its own; applying an update # timer only — it never flashes anything on its own; applying an update
# is an explicit `fwupdmgr update`. On by default as desktop security # is an explicit `fwupdmgr update`. On by default as desktop security
@@ -121,7 +145,33 @@ in
# `services.fwupd.enable = false`. # `services.fwupd.enable = false`.
services.fwupd.enable = lib.mkDefault true; services.fwupd.enable = lib.mkDefault true;
# Drive health monitoring (SMART).
services.smartd = {
enable = lib.mkDefault true;
notifications.x11.enable = lib.mkDefault true;
notifications.wall.enable = lib.mkDefault true;
};
# Core security: enable AppArmor to confine desktop apps and services.
security.apparmor.enable = true;
security.apparmor.killUnconfinedConfinables = false;
# Core stability: reboot automatically after 10s on a kernel panic
# (prevents the system from hanging indefinitely on a black screen).
boot.kernelParams = [ "panic=10" "oops=panic" ];
# Explicitly enable systembus-notify to resolve a mkDefault conflict
# between earlyoom (true) and smartd (false).
services.systembus-notify.enable = true;
# Enable I2C and DDC/CI by default for external monitor brightness control.
nomarchy.hardware.i2c.ddcci = 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
@@ -231,6 +281,13 @@ in
}; };
}; };
# Background filesystem health checks. Scans all BTRFS filesystems to detect
# and (if RAID/dup) correct bitrot before it propagates.
services.btrfs.autoScrub = lib.mkIf ((config.fileSystems."/".fsType or "") == "btrfs") {
enable = lib.mkDefault true;
interval = lib.mkDefault "monthly";
};
# ── Fonts ──────────────────────────────────────────────────────── # ── Fonts ────────────────────────────────────────────────────────
# The ten most popular Nerd Fonts ship by default, so any of them # The ten most popular Nerd Fonts ship by default, so any of them
# can be named in the theme state's fonts.mono and actually resolve # can be named in the theme state's fonts.mono and actually resolve
@@ -249,6 +306,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,30 +329,15 @@ 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
nomarchy-detect-hw # post-install hardware re-probe (HARDWARE.md §8)
# Friendly wrappers for the two rebuild paths (README §3). Run as # Day-to-day lifecycle (README §3): nomarchy-pull / -rebuild / -home
# your user: `nix flake update` must NOT run as root (libgit2 # (+ legacy sys-update / sys-rebuild / home-update). Defined once in
# refuses the user-owned flake repo) — sudo happens inside, only # pkgs/nomarchy-lifecycle; also installed via HM so a home switch can
# for the system switch. # refresh a stale system-package pull script.
(pkgs.writeShellScriptBin "sys-update" '' pkgs.nomarchy-lifecycle
set -e
if [ "$(id -u)" -eq 0 ]; then
echo "sys-update: run as your normal user (it sudos the rebuild itself)" >&2
exit 1
fi
flake="''${NOMARCHY_PATH:-$HOME/.nomarchy}"
echo "sys-update: updating flake inputs in $flake"
nix flake update --flake "$flake"
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
'')
(pkgs.writeShellScriptBin "home-update" ''
set -e
exec home-manager switch --flake "''${NOMARCHY_PATH:-$HOME/.nomarchy}" "$@"
'')
git git
vim vim
@@ -304,6 +350,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).
@@ -312,16 +359,84 @@ in
echo "This script must be run as root (use sudo)" >&2 echo "This script must be run as root (use sudo)" >&2
exit 1 exit 1
fi fi
# Prefer the path nomarchy-rebuild passes through sudo; fall back
# for hand invocations.
flake="''${NOMARCHY_PATH:-/etc/nixos}"
echo "Creating pre-rebuild snapshot..." echo "Creating pre-rebuild snapshot..."
${pkgs.snapper}/bin/snapper -c root create \ ${pkgs.snapper}/bin/snapper -c root create \
-d "Pre-rebuild $(date +'%Y-%m-%d %H:%M:%S')" \ -d "Pre-rebuild $(date +'%Y-%m-%d %H:%M:%S')" \
--cleanup-algorithm number --cleanup-algorithm number
echo "Rebuilding..." echo "Rebuilding $flake#default ..."
nixos-rebuild switch --flake /etc/nixos#default "$@" nixos-rebuild switch --flake "$flake#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;

View File

@@ -25,6 +25,7 @@ in
services.udisks2.enable = lib.mkDefault true; # mount/unmount removable media services.udisks2.enable = lib.mkDefault true; # mount/unmount removable media
environment.systemPackages = with pkgs; [ environment.systemPackages = with pkgs; [
file-roller # GUI archive manager (backend for thunar-archive-plugin)
ffmpegthumbnailer # video thumbnails (Thunar + yazi) ffmpegthumbnailer # video thumbnails (Thunar + yazi)
libgsf # ODF thumbnails libgsf # ODF thumbnails
poppler-utils # PDF thumbnails / pdftoppm (yazi PDF preview too) poppler-utils # PDF thumbnails / pdftoppm (yazi PDF preview too)

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;
};
};
};
};
}

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

@@ -0,0 +1,281 @@
# 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;
# Fingerprint PAM can follow theme-state.json (menu toggle → next
# sys-rebuild), same bridge as autoTimezone (BACKLOG #55). Missing or
# invalid JSON fails closed (theme-state-read.nix) instead of a raw stack.
hwState =
if config.nomarchy.system.stateFile != null
then import ../theme-state-read.nix { inherit lib; } config.nomarchy.system.stateFile
else { };
pamFromState = (hwState.settings or { }).fingerprint.pam or false;
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.mkOption {
type = lib.types.bool;
default = pamFromState;
defaultText = lib.literalExpression
"(settings.fingerprint.pam from theme-state.json) or false";
description = ''
Use the fingerprint for login and sudo (PAM). Opt-in password-only
stays the default for the cautious; enroll a finger first. Defaults
from theme-state.json `settings.fingerprint.pam` (System Fingerprint
menu) when set; otherwise false.
'';
};
};
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.
'';
};
};
i2c = {
enable = lib.mkEnableOption ''
I2C devices support. Enables access to /dev/i2c-* (useful for RGB
controllers, sensors, and DDC/CI monitor control)'';
ddcci = lib.mkEnableOption ''
the ddcci-driver kernel module to expose external monitors as standard
backlight devices via DDC/CI. This allows brightness keys and swayosd
to natively control external displays'';
};
};
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;
}
];
};
})
# ── I2C / DDC/CI ───────────────────────────────────────────────────
(lib.mkIf cfg.i2c.enable {
hardware.i2c.enable = true;
})
(lib.mkIf cfg.i2c.ddcci {
# The driver needs I2C underneath it
hardware.i2c.enable = true;
boot.extraModulePackages = [ config.boot.kernelPackages.ddcci-driver ];
boot.kernelModules = [ "ddcci_backlight" ];
})
# ── 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.";
}
];
}
];
}

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

@@ -0,0 +1,61 @@
# 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.
#
# zram sits one layer earlier: a compressed RAM swap that absorbs
# day-to-day memory pressure (a browser's idle tabs, a big eval's cold
# pages) before it ever reaches the earlyoom threshold — more headroom
# on the same RAM, no disk. High swap priority so the kernel fills zram
# first; any real disk swapfile stays reserved for the hibernate image
# (BACKLOG #76's other half — hibernation can't resume from volatile
# zram, so day-to-day paging must not consume the disk swap). zstd @ 50%
# RAM are the nixpkgs defaults, kept explicit for reproducibility.
{ lib, ... }:
{
zramSwap = {
enable = lib.mkDefault true;
algorithm = lib.mkDefault "zstd";
memoryPercent = lib.mkDefault 50;
# Beats any disk swapfile's auto-assigned (negative) priority, so
# day-to-day paging lands in zram and the disk swap is left for
# hibernation. See the header note and BACKLOG #76.
priority = lib.mkDefault 100;
};
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
@@ -33,7 +33,7 @@
description = '' description = ''
theme-state.json for the system-side consumers (currently the theme-state.json for the system-side consumers (currently the
Plymouth splash background). lib.mkFlake wires it automatically Plymouth splash background). lib.mkFlake wires it automatically
from your flake; null falls back to the Tokyo Night base color. from your flake; null falls back to the Boreal base color.
''; '';
}; };
@@ -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,22 @@
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;
# Dell Adaptive charge mode ignores the end threshold unless we
# also select Custom (power.nix oneshot); see Latitude 5310 QA.
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 charge_control_end_threshold on a
(/sys/class/power_supply/BAT*/charge_control_end_threshold). system battery (type=Battery under /sys/class/power_supply;
null leaves charging at the firmware default. Backend-independent name-agnostic BAT0, CMB0, ).
(a small systemd unit writes the sysfs knob at boot), so it null leaves charging at the firmware default (menu writes 100).
works under PPD too; needs nomarchy.system.power.laptop. Backend-independent: the menu applies live via sysfs (udev
GROUP=users on the threshold node) and persists settings in
theme-state; a oneshot re-applies on boot and AC replug. On
Dell (and similar) the oneshot also selects charge type Custom
Adaptive ignores the threshold while still reporting it.
Needs nomarchy.system.power.laptop.
''; '';
}; };
}; };

View File

@@ -12,17 +12,18 @@ let
state = state =
if cfg.stateFile != null if cfg.stateFile != null
then builtins.fromJSON (builtins.readFile cfg.stateFile) then import ../theme-state-read.nix { inherit lib; } cfg.stateFile
else { }; else { };
colorOf = key: fallback: lib.removePrefix "#" ((state.colors or { }).${key} or fallback); colorOf = key: fallback: lib.removePrefix "#" ((state.colors or { }).${key} or fallback);
base = colorOf "base" "#1a1b26"; # Fallbacks match the distro default theme (Boreal) when stateFile is null.
base = colorOf "base" "#21272F";
# Splash elements are recolored from the palette so they read on any # Splash elements are recolored from the palette so they read on any
# base, light or dark: foreground glyphs → text, field/track boxes → # base, light or dark: foreground glyphs → text, field/track boxes →
# surface (raised from base in both polarities), the progress fill → # surface (raised from base in both polarities), the progress fill →
# accent. The shipped art is a fixed navy that vanished on dark themes. # accent. The shipped art is a fixed navy that vanished on dark themes.
text = colorOf "text" "#a9b1d6"; text = colorOf "text" "#D3DAE0";
surface = colorOf "surface" "#32344a"; surface = colorOf "surface" "#303A46";
accent = colorOf "accent" "#7aa2f7"; accent = colorOf "accent" "#B79BE8";
magick = lib.getExe' pkgs.imagemagick "magick"; magick = lib.getExe' pkgs.imagemagick "magick";
# Plymouth's Window.SetBackgroundTopColor takes three floats in # Plymouth's Window.SetBackgroundTopColor takes three floats in

View File

@@ -5,13 +5,12 @@
# gate on powerprofilesctl being present, so there's no system→home wiring # gate on powerprofilesctl being present, so there's no system→home wiring
# to keep in sync (the same way the Waybar battery widget auto-hides on # to keep in sync (the same way the Waybar battery widget auto-hides on
# desktops). See the roadmap in README.md. # desktops). See the roadmap in README.md.
{ config, lib, ... }: { config, lib, pkgs, ... }:
let let
cfg = config.nomarchy.system.power; cfg = config.nomarchy.system.power;
ppd = cfg.backend == "ppd"; ppd = cfg.backend == "ppd";
tlp = cfg.backend == "tlp"; tlp = cfg.backend == "tlp";
chargeLimit = cfg.laptop && cfg.batteryChargeLimit != null;
in in
{ {
config = lib.mkIf cfg.enable { config = lib.mkIf cfg.enable {
@@ -38,23 +37,138 @@ 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).
# is a clean no-op on hardware without the control. #
systemd.services.nomarchy-battery-charge-limit = lib.mkIf chargeLimit { # Instant menu path (user decision 2026-07-10): the threshold node is
description = "Cap battery charging at ${toString cfg.batteryChargeLimit}%"; # group-writable for `users` so nomarchy-menu can echo live without
# rebuild; this oneshot still owns boot + AC-replug re-apply. Prefer
# live theme-state.json under /home/*/.nomarchy so a menu change
# survives reboot before the next sys-rebuild bakes the Nix option.
systemd.services.nomarchy-battery-charge-limit = lib.mkIf cfg.laptop {
description = "Apply battery charge end threshold from state or config";
wantedBy = [ "multi-user.target" ]; wantedBy = [ "multi-user.target" ];
path = [ pkgs.jq pkgs.coreutils ];
serviceConfig = { serviceConfig = {
Type = "oneshot"; Type = "oneshot";
RemainAfterExit = true; RemainAfterExit = true;
}; };
script = '' script = ''
for thresh in /sys/class/power_supply/BAT*/charge_control_end_threshold; do set -euo pipefail
[ -w "$thresh" ] && echo ${toString cfg.batteryChargeLimit} > "$thresh" # Baked generation default (empty = full charge / no cap).
limit=${if cfg.batteryChargeLimit != null then toString cfg.batteryChargeLimit else ""}
# Prefer the newest live state write (menu path, no rebuild yet).
for st in /home/*/.nomarchy/theme-state.json; do
[ -r "$st" ] || continue
v=$(jq -r '.settings.power.batteryChargeLimit // empty' "$st" 2>/dev/null || true)
case "$v" in
""|null|Null) ;;
*[!0-9]*) ;;
*) limit=$v ;;
esac
done
[ -n "$limit" ] || limit=100
# Name-agnostic system batteries (BACKLOG #60).
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
thresh="$d/charge_control_end_threshold"
[ -e "$thresh" ] || continue
# Keep nodes group-writable for the menu's live path (udev
# sets this on add; re-assert after firmware recreates attrs).
for node in charge_control_end_threshold charge_control_start_threshold charge_types charge_type; do
[ -e "$d$node" ] || continue
chgrp users "$d$node" 2>/dev/null || true
chmod 0664 "$d$node" 2>/dev/null || true
done
# Dell (and some others): charge_control_* thresholds are only
# honoured in Custom mode. Adaptive/Standard ignore end_threshold
# while still reporting the written value battery keeps charging
# past the cap (Latitude 5310: end=80, type=[Adaptive], capacity 96%+).
# Off (100): restore Adaptive if listed, else leave type alone.
ctypes="$d/charge_types"
ctype="$d/charge_type"
if [ -e "$ctypes" ] || [ -e "$ctype" ]; then
listed=$(cat "$ctypes" 2>/dev/null || cat "$ctype" 2>/dev/null || true)
write_type() {
local t="$1"
[ -n "$t" ] || return 0
if [ -w "$ctypes" ]; then echo "$t" > "$ctypes" 2>/dev/null || true
elif [ -w "$ctype" ]; then echo "$t" > "$ctype" 2>/dev/null || true
fi
}
if [ "$limit" -lt 100 ] 2>/dev/null; then
# listed looks like: "Trickle Fast Standard [Adaptive] Custom"
case "$listed" in *Custom*) write_type Custom ;; esac
else
case "$listed" in
*Adaptive*) write_type Adaptive ;;
*Standard*) write_type Standard ;;
esac
fi
fi
# Start threshold: must be < end. Keep a ~10% hysteresis band when
# the node exists (firmware may reject start >= end).
start_node="$d/charge_control_start_threshold"
if [ -w "$start_node" ] && [ "$limit" -lt 100 ] 2>/dev/null; then
start=$(( limit > 15 ? limit - 10 : 0 ))
echo "$start" > "$start_node" 2>/dev/null || true
fi
# Some firmwares (Dell) reset the threshold on unplug *after*
# the udev event write once, wait, write again (type first).
echo "$limit" > "$thresh" 2>/dev/null || true
sleep 1
if [ -e "$ctypes" ] || [ -e "$ctype" ]; then
listed=$(cat "$ctypes" 2>/dev/null || cat "$ctype" 2>/dev/null || true)
if [ "$limit" -lt 100 ] 2>/dev/null; then
case "$listed" in
*Custom*)
if [ -w "$ctypes" ]; then echo Custom > "$ctypes" 2>/dev/null || true
elif [ -w "$ctype" ]; then echo Custom > "$ctype" 2>/dev/null || true
fi
;;
esac
fi
fi
echo "$limit" > "$thresh" 2>/dev/null || true
done done
exit 0 exit 0
''; '';
}; };
# Writable threshold for the logged-in user (menu live apply) + AC
# re-apply of the oneshot. Laptop only; no-op when the attr is absent.
# Immediate + delayed restart: Dell/Latitude firmware often stomps the
# threshold to 100 right after unplug; a single immediate oneshot
# loses the race (hardware: unplug→100, plug→80). systemd-run hands
# the delayed restart off so udev's short RUN budget is not held.
services.udev.extraRules = lib.mkIf cfg.laptop (
let
systemctl = "${config.systemd.package}/bin/systemctl";
systemdRun = "${config.systemd.package}/bin/systemd-run";
in ''
SUBSYSTEM=="power_supply", ATTR{type}=="Battery", TEST=="charge_control_end_threshold", GROUP="users", MODE="0664"
SUBSYSTEM=="power_supply", ATTR{type}=="Battery", TEST=="charge_types", GROUP="users", MODE="0664"
SUBSYSTEM=="power_supply", ATTR{type}=="Battery", TEST=="charge_type", GROUP="users", MODE="0664"
SUBSYSTEM=="power_supply", ATTR{type}=="Battery", TEST=="charge_control_start_threshold", GROUP="users", MODE="0664"
SUBSYSTEM=="power_supply", ATTR{type}=="Mains", RUN+="${systemctl} --no-block restart nomarchy-battery-charge-limit.service"
SUBSYSTEM=="power_supply", ATTR{type}=="Mains", RUN+="${systemdRun} --no-block --collect --on-active=2s --timer-property=AccuracySec=200ms ${systemctl} --no-block restart nomarchy-battery-charge-limit.service"
''
);
# Unprivileged restart of the oneshot so the menu can re-apply as
# root when the threshold node is not (yet) user-writable.
security.polkit.extraConfig = lib.mkIf cfg.laptop ''
polkit.addRule(function(action, subject) {
if (action.id == "org.freedesktop.systemd1.manage-units" &&
subject.isInGroup("users")) {
var unit = action.lookup("unit");
if (unit == "nomarchy-battery-charge-limit.service") {
return polkit.Result.YES;
}
}
});
'';
}; };
} }

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,88 @@
# 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 stateFile is null;
# a set-but-missing/invalid path fails closed via theme-state-read.nix.
state =
if cfg.stateFile != null
then import ../theme-state-read.nix { inherit lib; } 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,84 @@
# Pure theme-state.json loader — fail closed with a short, actionable
# message instead of a raw `readFile` / `fromJSON` stack buried in a
# consumer. Used by modules/home/theme.nix (required path), the NixOS
# stateFile consumers, and lib.mkFlake (early gate).
#
# Field-level schema checks stay in theme.nix (post-defaults). This file
# only gates *existence* and *JSON-shape* so the first failure the user
# sees points at the state file, not at nightlight.nix.
{ lib }:
path:
let
pathStr = toString path;
tip = ''
Fix:
Missing file copy the template next to your flake.nix:
templates/downstream/theme-state.json
or regenerate from a preset:
nomarchy-theme-sync apply boreal
Bad syntax / wrong shape edit theme-state.json (trailing commas
are the usual culprit) or reset with `apply` as above.
Field-level schema (colors, ui, border, ):
nomarchy-theme-sync validate
Eval-time field checks live in modules/home/theme.nix.'';
missingMsg = ''
Nomarchy: theme-state.json is missing:
${pathStr}
This file is required (appearance + menu settings). Add it to your
flake checkout so evaluation stays pure.
${tip}'';
emptyMsg = ''
Nomarchy: theme-state.json is empty:
${pathStr}
${tip}'';
notObjectMsg = ''
Nomarchy: theme-state.json must be a JSON object `{ ... }`:
${pathStr}
${tip}'';
# lib.trim / lib.strings.trim — strip leading/trailing whitespace so an
# all-whitespace file counts as empty and a BOM-less `{` is recognized.
strip = s:
let
# Prefer lib.trim when present (nixpkgs ≥ 23.11); fall back so a
# very old pin still gates missing/non-object.
trim =
if lib ? trim then lib.trim
else if lib.strings ? trim then lib.strings.trim
else (x: x);
in trim s;
in
if !(builtins.pathExists path) then
throw missingMsg
else
let
raw = builtins.readFile path;
stripped = strip raw;
in
if stripped == "" then
throw emptyMsg
# Reject non-objects before fromJSON where we can (null / array / string
# literals). Subtle syntax errors still surface from fromJSON itself —
# those messages already include line/column; the path tip above is the
# part a raw stack used to bury.
else if builtins.match "[[:space:]]*\\{.*" stripped == null then
throw notObjectMsg
else
let
value = builtins.fromJSON raw;
in
if !(builtins.isAttrs value) then
throw notObjectMsg
else
value

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,249 @@
#!/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
# Post-install hardware/day-2 tips (#43 Firmware; #73 fingerprint +
# doctor). Self-gated on the same CLI presence the System menu uses;
# first-boot is once, so no dismiss state file is needed.
if command -v fwupdmgr >/dev/null 2>&1; then
gum style --foreground 245 \
"Tip: SUPER+M → System Firmware checks LVFS updates (never auto-flashes)."
fi
if command -v fprintd-list >/dev/null 2>&1; then
gum style --foreground 245 \
"Tip: SUPER+M → System Fingerprint to enroll a finger (or fprintd-enroll)."
fi
if command -v nomarchy-doctor >/dev/null 2>&1; then
gum style --foreground 245 \
"Tip: run nomarchy-doctor (or SUPER+M → System Doctor) for a read-only health check."
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
tmp=$(mktemp)
printf "%s\n" "$gens" | awk '{ printf "Desktop gen %s — %s %s%s\n", $5, $1, $2, (NR==1 ? " (current)" : "") }' > "$tmp"
sel=$(gum choose < "$tmp" || true)
rm -f "$tmp"
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
echo "Blur setting saved (requires rebuild)."
sleep 1
;;
"Set Gaps")
gaps=$(gum input --prompt "Enter gaps in pixels: " --placeholder "$(get_state ui.gapsOut)")
if [ -n "$gaps" ]; then
set_state "ui.gapsOut" "$gaps"
echo "Gaps set to $gaps (requires rebuild)."
sleep 1
fi
;;
"Back"|*) break ;;
esac
done
}
function toggles_menu() {
while true; do
choice=$(gum choose "Auto-commit" "Auto-timezone" "Night Light" "Updates" "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
;;
"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,43 @@
# Post-install hardware re-probe (BACKLOG #58 / HARDWARE.md §8).
# Same pure nomarchy_detect_hw protocol as the installer; prints suggested
# hardwareProfile + system.nix snippets. Never rewrites the flake.
{ lib
, stdenvNoCC
, makeWrapper
, bash
, coreutils
, pciutils
, usbutils
, util-linux
}:
stdenvNoCC.mkDerivation {
pname = "nomarchy-detect-hw";
version = "0.1.0";
src = ./.;
nativeBuildInputs = [ makeWrapper ];
installPhase = ''
runHook preInstall
share=$out/share/nomarchy-detect-hw
install -Dm644 ${../nomarchy-install/hardware-db.sh} "$share/hardware-db.sh"
install -Dm755 nomarchy-detect-hw.sh $out/bin/nomarchy-detect-hw
patchShebangs $out/bin/nomarchy-detect-hw
wrapProgram $out/bin/nomarchy-detect-hw \
--prefix PATH : ${lib.makeBinPath [ bash coreutils pciutils usbutils util-linux ]} \
--set NOMARCHY_DETECT_HW_SHARE "$share"
runHook postInstall
'';
meta = {
description = "Probe hardware and print Nomarchy hardwareProfile / system.nix suggestions";
license = lib.licenses.mit;
mainProgram = "nomarchy-detect-hw";
platforms = lib.platforms.linux;
};
}

View File

@@ -0,0 +1,134 @@
#!/usr/bin/env bash
# nomarchy-detect-hw — post-install re-probe (BACKLOG #58 / HARDWARE.md §8).
#
# Runs the same pure nomarchy_detect_hw protocol as the installer and
# prints suggested hardwareProfile + system.nix snippets. Never rewrites
# the flake (no --apply).
#
# Usage:
# nomarchy-detect-hw # human-readable report + snippets
# nomarchy-detect-hw --raw # MODULE / NOMARCHY / DETAIL lines only
set -euo pipefail
SHARE="${NOMARCHY_DETECT_HW_SHARE:?nomarchy-detect-hw: not run via the packaged wrapper}"
# shellcheck source=/dev/null
source "$SHARE/hardware-db.sh"
raw=false
case "${1:-}" in
--raw|-r) raw=true ;;
-h|--help)
cat <<'EOF'
Usage: nomarchy-detect-hw [--raw]
Probe this machine the same way the live installer does and print
suggested flake hardwareProfile and system.nix hardware lines.
--raw emit only MODULE / NOMARCHY / NOMARCHY-NPU / DETAIL lines
(installer protocol). Default is a human report + snippets.
Does not modify any files. Paste the snippets into ~/.nomarchy after review.
EOF
exit 0
;;
"") ;;
*)
echo "nomarchy-detect-hw: unknown option: $1 (try --help)" >&2
exit 2
;;
esac
if $raw; then
nomarchy_detect_hw
exit 0
fi
profiles=()
nomarchy_lines=()
npu=""
details=()
while IFS= read -r line; do
case "$line" in
MODULE\ *) profiles+=("${line#MODULE }") ;;
NOMARCHY\ *) nomarchy_lines+=("${line#NOMARCHY }") ;;
NOMARCHY-NPU\ *) npu="${line#NOMARCHY-NPU }" ;;
DETAIL\ *) details+=("${line#DETAIL }") ;;
esac
done < <(nomarchy_detect_hw)
echo "nomarchy-detect-hw — suggestions for this machine"
echo "(read-only; paste into ~/.nomarchy after review)"
echo
if ((${#details[@]})); then
echo "## Detected"
for d in "${details[@]}"; do
printf ' · %s\n' "$d"
done
echo
fi
echo "## flake.nix — hardwareProfile"
if ((${#profiles[@]})); then
echo " hardwareProfile = ["
for p in "${profiles[@]}"; do
printf ' "%s"\n' "$p"
done
echo " ];"
else
echo " # (no modules detected — leave hardwareProfile unset or null)"
fi
echo
echo "## system.nix — nomarchy.hardware / power (safe defaults active;"
echo "## heavier opt-ins stay commented, same as the installer)"
if ((${#profiles[@]})) && printf '%s\n' "${profiles[@]}" | grep -qx 'common-pc-laptop'; then
echo " nomarchy.system.power.laptop = true;"
echo " # nomarchy.system.power.batteryChargeLimit = 80;"
fi
for nm in "${nomarchy_lines[@]:-}"; do
case "$nm" in
hardware.intel.enable=true)
echo " nomarchy.hardware.intel.enable = true; # GuC/HuC (i915)"
echo " # nomarchy.hardware.intel.computeRuntime = true; # OpenCL/oneVPL (opt-in)"
;;
hardware.intel.guc=false)
echo " nomarchy.hardware.intel.guc = false; # xe driver → GuC default-on"
;;
hardware.amd.enable=true)
echo " nomarchy.hardware.amd.enable = true; # amd-pstate + VA-API"
echo " # nomarchy.hardware.amd.rocm.enable = true; # ROCm (multi-GB, opt-in)"
echo ' # nomarchy.hardware.amd.rocm.gfxOverride = ""; # e.g. "11.0.0" for unlisted iGPU'
;;
hardware.fingerprint.enable=true)
echo " nomarchy.hardware.fingerprint.enable = true; # fprintd (enroll: fprintd-enroll)"
echo " # nomarchy.hardware.fingerprint.pam = true; # login + sudo (opt-in)"
;;
hardware.camera.hideIrSensor=true)
echo " nomarchy.hardware.camera.hideIrSensor = true; # dual-sensor: hide IR node"
;;
*)
echo " # (unmapped NOMARCHY line: $nm)"
;;
esac
done
if [[ -n "$npu" ]]; then
echo " # nomarchy.hardware.npu.enable = true; # $npu NPU (experimental; userspace BYO)"
echo " # nomarchy.hardware.latestKernel = true; # if the NPU driver needs a newer kernel"
fi
if printf '%s\n' "${profiles[@]:-}" | grep -qx 'common-gpu-nvidia'; then
echo " # NVIDIA: common-gpu-nvidia is in hardwareProfile (flake.nix)."
echo " # Hybrid/PRIME, power, open-module — plain NixOS; see docs/HARDWARE.md §6"
echo " # hardware.nvidia.prime = { ... };"
echo " # hardware.nvidia.powerManagement.enable = true;"
echo " # hardware.nvidia.open = false; # or true on newer cards"
fi
if ((${#nomarchy_lines[@]} == 0)) && [[ -z "$npu" ]] \
&& ! printf '%s\n' "${profiles[@]:-}" | grep -qx 'common-gpu-nvidia' \
&& ! printf '%s\n' "${profiles[@]:-}" | grep -qx 'common-pc-laptop'; then
echo " # (no nomarchy.hardware lines suggested)"
fi
echo
echo "Apply with: sudo nixos-rebuild switch --flake \${NOMARCHY_PATH:-\$HOME/.nomarchy}#default"
echo "Docs: docs/HARDWARE.md §8"

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,330 @@
# 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
# ══ hardware ═════════════════════════════════════════════════════════
# Every check below self-gates: it skips cleanly when the tool, service,
# or device isn't present, so the section shrinks to fit the machine and
# never fails just because a feature is absent. Still read-only.
# ── network (NetworkManager) ─────────────────────────────────────────
if command -v nmcli >/dev/null 2>&1; then
nmstate=$(nmcli -t -f STATE general status 2>/dev/null || true)
case "$nmstate" in
connected*) ok "NetworkManager: $nmstate" ;;
"") skip "NetworkManager (no state reported)" ;;
*) warn "NetworkManager state: $nmstate" \
"connect in System Network (or: nmcli device)" ;;
esac
else
skip "NetworkManager (nmcli not present)"
fi
# ── default audio sink (PipeWire) ────────────────────────────────────
# wpctl/pactl talk to the user's PipeWire session — only meaningful with
# a user bus (not over bare SSH/root).
if [ -n "$(systemctl --user is-system-running 2>/dev/null || true)" ]; then
if command -v wpctl >/dev/null 2>&1; then
if wpctl inspect @DEFAULT_AUDIO_SINK@ >/dev/null 2>&1; then
ok "default audio sink present"
else
warn "no default PipeWire sink" "pick one in System Audio (or: wpctl status)"
fi
elif command -v pactl >/dev/null 2>&1; then
defsink=$(pactl get-default-sink 2>/dev/null || true)
if [ -n "$defsink" ] && [ "$defsink" != "@DEFAULT_SINK@" ]; then
ok "default audio sink: $defsink"
else
warn "no default audio sink" "pick one in System Audio"
fi
else
skip "audio sink (no wpctl/pactl)"
fi
else
skip "audio sink (no user session bus here)"
fi
# ── GPU acceleration smoke (only if the probes are installed) ────────
if command -v vainfo >/dev/null 2>&1; then
if vainfo >/dev/null 2>&1; then
ok "VA-API acceleration works (vainfo)"
else
warn "vainfo present but VA-API probe failed" \
"check mkFlake.hardwareProfile / GPU drivers (or run: vainfo)"
fi
else
skip "VA-API smoke (vainfo not installed)"
fi
if command -v glxinfo >/dev/null 2>&1; then
if [ -n "${WAYLAND_DISPLAY:-}${DISPLAY:-}" ]; then
if glxinfo -B >/dev/null 2>&1; then
ok "OpenGL renderer responds (glxinfo)"
else
warn "glxinfo present but GL probe failed" \
"check mkFlake.hardwareProfile / GPU drivers (or run: glxinfo -B)"
fi
else
skip "OpenGL smoke (no display attached)"
fi
else
skip "OpenGL smoke (glxinfo not installed)"
fi
# ── fingerprint (only when fprintd is enabled) ───────────────────────
if [ -n "$(systemctl list-unit-files fprintd.service --no-legend --plain 2>/dev/null || true)" ]; then
enrolled=""
if command -v fprintd-list >/dev/null 2>&1 && [ -n "${USER:-}" ]; then
enrolled=$(fprintd-list "$USER" 2>/dev/null || true)
fi
if [ -n "$enrolled" ] && ! printf '%s' "$enrolled" | grep -qi 'no fingers'; then
ok "fprintd enabled, fingerprint(s) enrolled for ${USER:-user}"
elif [ -n "$enrolled" ]; then
warn "fprintd enabled but no fingerprints enrolled" "enroll one: fprintd-enroll"
else
ok "fprintd unit installed (fingerprint enabled)"
fi
else
skip "fingerprint (fprintd not enabled)"
fi
# ── firmware updates (fwupd) ─────────────────────────────────────────
# fwupd is D-Bus-activated, so an idle daemon is normal, not a fault.
# get-updates reads local metadata (no flashing); a hit is a soft warn.
if command -v fwupdmgr >/dev/null 2>&1 \
&& [ -n "$(systemctl list-unit-files fwupd.service --no-legend --plain 2>/dev/null || true)" ]; then
if systemctl is-active --quiet fwupd.service; then
ok "fwupd daemon active"
else
skip "fwupd daemon (idle — D-Bus-activated on demand)"
fi
if timeout 20 fwupdmgr get-updates >/dev/null 2>&1; then
warn "firmware updates are available" "review, then apply: fwupdmgr update"
else
ok "firmware up to date (no pending updates)"
fi
else
skip "fwupd (not enabled / fwupdmgr absent)"
fi
# ── laptop battery charge threshold (only when a limit is set) ───────
# Name-agnostic (BAT0, CMB0, …) — same type/scope filter as notify (#60).
# Dell: Adaptive mode ignores end_threshold while still reporting the
# written value — warn if type is not Custom when a limit is active.
bat_seen=0; bat_limited=0
for bat in /sys/class/power_supply/*/; do
[ "$(cat "$bat/type" 2>/dev/null)" = Battery ] || continue
[ "$(cat "$bat/scope" 2>/dev/null || echo System)" = Device ] && continue
f="$bat/charge_control_end_threshold"
[ -r "$f" ] || continue
bat_seen=1
lim=$(cat "$f" 2>/dev/null || true)
case "$lim" in
''|*[!0-9]*) continue ;;
esac
if [ "$lim" -lt 100 ]; then
bat_limited=1
ctype=$(cat "$bat/charge_types" 2>/dev/null || cat "$bat/charge_type" 2>/dev/null || true)
active=$(printf '%s' "$ctype" | sed -n 's/.*\[\([^]]*\)\].*/\1/p')
name=$(basename "$bat")
if [ -n "$active" ] && [ "$active" != Custom ]; then
warn "$name charge limit ${lim}% but type is $active (not Custom)" \
"thresholds only apply in Custom — run: systemctl restart nomarchy-battery-charge-limit"
else
ok "$name charge limit active at ${lim}%${active:+ (type $active)}"
fi
fi
done
if [ "$bat_seen" -eq 1 ] && [ "$bat_limited" -eq 0 ]; then
skip "battery charge limit (none set — charges to 100%)"
fi
# ── hibernate / sleep (BACKLOG #76) ──────────────────────────────────
# Read-only: is there a working hibernate path? zram is RAM-only and can't
# hold a resume image, so hibernation needs a disk swap (partition or file)
# PLUS resume= wiring; a swapfile additionally needs resume_offset. A
# swap=0 machine opts out cleanly and this section skips. Never fails the
# sheet — an absent or under-sized swap is advisory, not a running fault.
# (|| echo 0 keeps set -euo pipefail happy if a proc file is unreadable.)
disk_swap_kb=$(awk 'NR>1 && $1 !~ /zram/ {s+=$3} END{print s+0}' /proc/swaps 2>/dev/null || echo 0)
zram_kb=$(awk 'NR>1 && $1 ~ /zram/ {s+=$3} END{print s+0}' /proc/swaps 2>/dev/null || echo 0)
ram_kb=$(awk '/^MemTotal:/ {print $2}' /proc/meminfo 2>/dev/null || echo 0)
if [ "${disk_swap_kb:-0}" -gt 0 ]; then
if grep -q 'resume=' /proc/cmdline 2>/dev/null; then
ok "hibernate: resume device set on the kernel cmdline"
# A swapfile also needs resume_offset to locate the image within it.
if awk 'NR>1 && $1 !~ /zram/ && $2=="file"{f=1} END{exit !f}' /proc/swaps 2>/dev/null \
&& ! grep -q 'resume_offset=' /proc/cmdline 2>/dev/null; then
warn "swapfile in use but no resume_offset= on the cmdline — resume will fail" \
"add boot.kernelParams resume_offset (docs/MIGRATION.md → Enabling hibernation)"
fi
else
warn "disk swap present but no resume= on the kernel cmdline — hibernate won't resume" \
"set boot.resumeDevice (docs/MIGRATION.md → Enabling hibernation)"
fi
if [ "${ram_kb:-0}" -gt 0 ] && [ "$disk_swap_kb" -lt "$ram_kb" ]; then
warn "disk swap ($(numfmt --to=iec $((disk_swap_kb * 1024)))) is smaller than RAM ($(numfmt --to=iec $((ram_kb * 1024)))) — a full hibernate image may not fit" \
"size swap ≥ RAM (docs/MIGRATION.md → Enabling hibernation)"
else
ok "hibernate: disk swap ≥ RAM ($(numfmt --to=iec $((disk_swap_kb * 1024))))"
fi
else
skip "hibernate (no disk swap — swap=0 opt-out; zram alone can't resume)"
fi
# zram compressed-RAM swap — the memory-pressure layer oom.nix ships on
# by default (#76). Its absence on a Nomarchy box means something is off.
if [ "${zram_kb:-0}" -gt 0 ]; then
ok "zram compressed-RAM swap active ($(numfmt --to=iec $((zram_kb * 1024))))"
else
warn "zram swap not active (the default memory-pressure layer)" \
"expected on by default — check modules/nixos/oom.nix, then sys-rebuild"
fi
# Best-effort: a suspend/hibernate failure recorded in the previous boot's
# kernel log. Needs journal read access; silently no-ops without it. The
# `|| true` keeps a no-match grep / missing -1 boot from tripping set -e.
if command -v journalctl >/dev/null 2>&1; then
pmerr=$(journalctl -b -1 -k --no-pager 2>/dev/null \
| grep -iE 'PM: .*(hibernat|suspend).*(fail|error)|Failed to (hibernate|suspend)' \
| tail -n1 || true)
if [ -n "$pmerr" ]; then
warn "a suspend/hibernate error is in the previous boot's log" \
"review: journalctl -b -1 -k -g 'hibernat|suspend'"
fi
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

@@ -13,6 +13,7 @@
, cryptsetup , cryptsetup
, lvm2 # dmsetup , lvm2 # dmsetup
, pciutils # lspci , pciutils # lspci
, usbutils # lsusb (fingerprint-reader VID probe; sysfs is the fallback)
, btrfs-progs # inspect-internal map-swapfile (hibernation offset) , btrfs-progs # inspect-internal map-swapfile (hibernation offset)
# Baked metadata — what this installer installs and where it came from. # Baked metadata — what this installer installs and where it came from.
, templateDir # templates/downstream (home.nix, theme-state.json) , templateDir # templates/downstream (home.nix, theme-state.json)
@@ -50,14 +51,18 @@ stdenvNoCC.mkDerivation {
# Empty flake registry: no network lookups for indirect refs. # Empty flake registry: no network lookups for indirect refs.
echo '{"flakes":[],"version":2}' > "$share/registry.json" echo '{"flakes":[],"version":2}' > "$share/registry.json"
mkdir -p "$share/template" mkdir -p "$share/template"
cp ${templateDir}/home.nix ${templateDir}/theme-state.json "$share/template/" # Full downstream template is the SoT; install script copies + patches.
cp ${templateDir}/flake.nix ${templateDir}/system.nix \
${templateDir}/home.nix ${templateDir}/theme-state.json \
"$share/template/"
install -Dm644 patch-template.py "$share/patch-template.py"
# nixos-install / nixos-generate-config / nixos-enter / nix / systemd # nixos-install / nixos-generate-config / nixos-enter / nix / systemd
# tools come from the live system on purpose they must match it. # tools come from the live system on purpose they must match it.
wrapProgram $out/bin/nomarchy-install \ wrapProgram $out/bin/nomarchy-install \
--prefix PATH : ${lib.makeBinPath [ --prefix PATH : ${lib.makeBinPath [
bash gum disko whois git python3 bash gum disko whois git python3
util-linux gptfdisk parted cryptsetup lvm2 pciutils btrfs-progs util-linux gptfdisk parted cryptsetup lvm2 pciutils usbutils btrfs-progs
]} \ ]} \
--set NOMARCHY_INSTALL_SHARE "$share" \ --set NOMARCHY_INSTALL_SHARE "$share" \
--set NOMARCHY_NIXPKGS ${nixpkgsPath} \ --set NOMARCHY_NIXPKGS ${nixpkgsPath} \

View File

@@ -15,12 +15,14 @@
# proved fragile twice before. # proved fragile twice before.
{ mainDrive { mainDrive
, withLuks ? true , withLuks ? true
, swapSize ? "0" # "0" = no swap; otherwise e.g. "16G" (sized for hibernation) , swapSize ? "0" # "0" or "0G" = no swap; otherwise e.g. "16G" (hibernation-sized)
, ... , ...
}: }:
let let
btrfsMountOptions = [ "compress=zstd" "noatime" ]; btrfsMountOptions = [ "compress=zstd" "noatime" ];
# Installer historically always appended "G"; accept both "0" and "0G".
noSwap = swapSize == "0" || swapSize == "0G";
rootBtrfs = { rootBtrfs = {
type = "btrfs"; type = "btrfs";
@@ -32,7 +34,7 @@ let
"@log" = { mountpoint = "/var/log"; mountOptions = btrfsMountOptions; }; "@log" = { mountpoint = "/var/log"; mountOptions = btrfsMountOptions; };
# snapper timeline snapshots (nomarchy.system.snapper.enable) # snapper timeline snapshots (nomarchy.system.snapper.enable)
"@snapshots" = { mountpoint = "/.snapshots"; mountOptions = btrfsMountOptions; }; "@snapshots" = { mountpoint = "/.snapshots"; mountOptions = btrfsMountOptions; };
} // (if swapSize == "0" then { } else { } // (if noSwap then { } else {
# Hibernation-ready swapfile on its own subvolume; disko's # Hibernation-ready swapfile on its own subvolume; disko's
# mkswapfile handles the BTRFS NOCOW requirements. # mkswapfile handles the BTRFS NOCOW requirements.
"@swap" = { "@swap" = {

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,10 +141,102 @@ 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
# Chassis (glob test, not compgen — nixpkgs' non-interactive bash # ── nomarchy.hardware.* enablement — the gap ABOVE the nixos-hardware
# is built without the completion builtins) # commons (GuC/HuC, amd-pstate, the AMD VA-API env, GPU-compute runtimes,
local bats=(/sys/class/power_supply/BAT*) # fprintd, the NPU driver). Emitted as NOMARCHY lines the installer turns
if [[ -e "${bats[0]}" ]]; then # 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: any system battery (type=Battery, not scope=Device) —
# name-agnostic BAT0/CMB0/… (BACKLOG #60). Not a bare BAT* glob.
local has_bat=0 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
has_bat=1
break
done
if (( has_bat )); then
echo "MODULE common-pc-laptop" echo "MODULE common-pc-laptop"
echo "DETAIL chassis: laptop (battery present)" echo "DETAIL chassis: laptop (battery present)"
else else

View File

@@ -18,8 +18,11 @@
# [NOMARCHY_TIMEZONE=UTC] [NOMARCHY_LUKS_PASSPHRASE=...] \ # [NOMARCHY_TIMEZONE=UTC] [NOMARCHY_LUKS_PASSPHRASE=...] \
# [NOMARCHY_LOCALE=en_US.UTF-8] [NOMARCHY_KB_LAYOUT=us] [NOMARCHY_KB_VARIANT=] \ # [NOMARCHY_LOCALE=en_US.UTF-8] [NOMARCHY_KB_LAYOUT=us] [NOMARCHY_KB_VARIANT=] \
# [NOMARCHY_SWAP_GB=N (default: RAM size; 0 = none)] \ # [NOMARCHY_SWAP_GB=N (default: RAM size; 0 = none)] \
# [NOMARCHY_LUKS_PASSPHRASE=... | NOMARCHY_NO_LUKS=1] \
# [NOMARCHY_HW="auto"|"none"|"mod1 mod2"] [NOMARCHY_FINISH=none|reboot|poweroff] # [NOMARCHY_HW="auto"|"none"|"mod1 mod2"] [NOMARCHY_FINISH=none|reboot|poweroff]
# nomarchy-install # nomarchy-install
# Unattended encryption is fail-closed: set a passphrase, or explicit
# NOMARCHY_NO_LUKS=1 — never silently install cleartext.
set -euo pipefail set -euo pipefail
@@ -54,6 +57,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 ]] \
@@ -77,7 +89,9 @@ export NIX_CONFIG="flake-registry = $SHARE/registry.json"
# substituter resolves to the TARGET store (i.e. itself), so without # substituter resolves to the TARGET store (i.e. itself), so without
# this nothing flows from the ISO and nix bootstraps gcc from source. # this nothing flows from the ISO and nix bootstraps gcc from source.
NIXOS_INSTALL_OPTS=() NIXOS_INSTALL_OPTS=()
OFFLINE=false
if ! timeout 3 bash -c '</dev/tcp/cache.nixos.org/443' 2>/dev/null; then if ! timeout 3 bash -c '</dev/tcp/cache.nixos.org/443' 2>/dev/null; then
OFFLINE=true
info "No network — substituting from the ISO store only." info "No network — substituting from the ISO store only."
NIX_CONFIG+=$'\nsubstituters =\nextra-substituters = daemon?trusted=1\nbuilders =' NIX_CONFIG+=$'\nsubstituters =\nextra-substituters = daemon?trusted=1\nbuilders ='
# Must ALSO go through nixos-install as a flag: it passes its own # Must ALSO go through nixos-install as a flag: it passes its own
@@ -110,6 +124,15 @@ fi
[[ -b "$TARGET_DISK" ]] || fail "$TARGET_DISK is not a block device." [[ -b "$TARGET_DISK" ]] || fail "$TARGET_DISK is not a block device."
info "Target: $TARGET_DISK" info "Target: $TARGET_DISK"
# Single whole-disk install only (no dual-boot path) — if the chosen disk
# already carries a recognizable OS/filesystem signature, call it out
# explicitly before the pre-wipe below destroys it.
existing_sig="$(lsblk -no FSTYPE,LABEL "$TARGET_DISK" 2>/dev/null || true
blkid "$TARGET_DISK"* 2>/dev/null || true)"
if grep -qiE 'ntfs|bitlocker|microsoft|crypto_luks' <<< "$existing_sig"; then
warn "$TARGET_DISK has existing data (Windows/BitLocker/NTFS or LUKS) — it will be destroyed."
fi
# ─── Encryption ───────────────────────────────────────────────────────── # ─── Encryption ─────────────────────────────────────────────────────────
section "Disk encryption" section "Disk encryption"
@@ -117,7 +140,15 @@ section "Disk encryption"
# logs you straight into the desktop (the passphrase already gates access). # logs you straight into the desktop (the passphrase already gates access).
LUKS_PASSPHRASE="" LUKS_PASSPHRASE=""
if [[ "$UNATTENDED" == "1" ]]; then if [[ "$UNATTENDED" == "1" ]]; then
LUKS_PASSPHRASE="${NOMARCHY_LUKS_PASSPHRASE:-}" # Fail-closed: unattended without a passphrase used to install
# cleartext (easy CI footgun). Require an explicit opt-out.
if [[ "${NOMARCHY_NO_LUKS:-}" == "1" ]]; then
LUKS_PASSPHRASE=""
elif [[ -n "${NOMARCHY_LUKS_PASSPHRASE:-}" ]]; then
LUKS_PASSPHRASE="$NOMARCHY_LUKS_PASSPHRASE"
else
fail "Unattended install needs NOMARCHY_LUKS_PASSPHRASE or NOMARCHY_NO_LUKS=1"
fi
elif gum confirm --default=yes "Encrypt the disk with LUKS? (default — also enables passwordless desktop login)"; then elif gum confirm --default=yes "Encrypt the disk with LUKS? (default — also enables passwordless desktop login)"; then
while true; do while true; do
p1=$(gum input --password --placeholder "LUKS passphrase (min 8 chars)") p1=$(gum input --password --placeholder "LUKS passphrase (min 8 chars)")
@@ -161,8 +192,8 @@ else
warn "Invalid username (lowercase letters, digits, - and _)." warn "Invalid username (lowercase letters, digits, - and _)."
done done
while true; do while true; do
PASSWORD=$(gum input --password --placeholder "password for $USERNAME") PASSWORD=$(gum input --password --placeholder "password for $USERNAME (min 8 chars)")
[[ -n "$PASSWORD" ]] || { warn "Empty password."; continue; } [[ ${#PASSWORD} -ge 8 ]] || { warn "Too short (min 8 chars)."; continue; }
p2=$(gum input --password --placeholder "repeat password") p2=$(gum input --password --placeholder "repeat password")
[[ "$PASSWORD" == "$p2" ]] && break [[ "$PASSWORD" == "$p2" ]] && break
warn "Passwords don't match." warn "Passwords don't match."
@@ -210,6 +241,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."
@@ -220,8 +253,10 @@ else
if [[ -n "$detection" ]]; then if [[ -n "$detection" ]]; then
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 }") ;;
DETAIL\ *) info "${line#DETAIL }" ;; NOMARCHY-NPU\ *) NPU_VENDOR="${line#NOMARCHY-NPU }" ;;
NOMARCHY\ *) HW_NOMARCHY+=("${line#NOMARCHY }") ;;
DETAIL\ *) info "${line#DETAIL }" ;;
esac esac
done <<< "$detection" done <<< "$detection"
fi fi
@@ -241,6 +276,14 @@ info "Profiles: ${HW_PROFILES[*]:-(none)}"
# ─── Review & point of no return ──────────────────────────────────────── # ─── Review & point of no return ────────────────────────────────────────
section "Review" section "Review"
# Match the Source line to the same cache.nixos.org probe that sets OFFLINE
# (above): offline = ISO store only; online may still hit substituters.
if [[ "$OFFLINE" == true ]]; then
SOURCE_NET="pinned into the ISO, no network needed"
else
SOURCE_NET="pinned into the ISO; may use network binary caches"
fi
gum style --border normal --padding "0 2" \ gum style --border normal --padding "0 2" \
"Disk: $TARGET_DISK (WILL BE ERASED)" \ "Disk: $TARGET_DISK (WILL BE ERASED)" \
"Encryption: $([[ $WITH_LUKS == true ]] && echo "LUKS2 + desktop auto-login" || echo none)" \ "Encryption: $([[ $WITH_LUKS == true ]] && echo "LUKS2 + desktop auto-login" || echo none)" \
@@ -250,7 +293,7 @@ gum style --border normal --padding "0 2" \
"Timezone: $TIMEZONE" \ "Timezone: $TIMEZONE" \
"Hardware: ${HW_PROFILES[*]:-none}" \ "Hardware: ${HW_PROFILES[*]:-none}" \
"Snapshots: snapper timeline on /" \ "Snapshots: snapper timeline on /" \
"Source: nomarchy ${NOMARCHY_REV:0:12}${NOMARCHY_REV:+ }$([[ -z "${NOMARCHY_REV:-}" ]] && echo "(dirty tree) ")pinned into the ISO, no network needed" "Source: nomarchy ${NOMARCHY_REV:0:12}${NOMARCHY_REV:+ }$([[ -z "${NOMARCHY_REV:-}" ]] && echo "(dirty tree) ")$SOURCE_NET"
if [[ "$UNATTENDED" != "1" ]]; then if [[ "$UNATTENDED" != "1" ]]; then
typed=$(gum input --placeholder "type the disk name ($(basename "$TARGET_DISK")) to confirm the wipe") typed=$(gum input --placeholder "type the disk name ($(basename "$TARGET_DISK")) to confirm the wipe")
@@ -304,11 +347,19 @@ if [[ $WITH_LUKS == true ]]; then
unset LUKS_PASSPHRASE unset LUKS_PASSPHRASE
fi fi
# disko-config treats exact "0" as no-swap; "${SWAP_GB}G" would pass "0G"
# and still create a useless @swap subvolume (layout vs resume disagreed).
if [[ "$SWAP_GB" == "0" ]]; then
DISKO_SWAP_SIZE="0"
else
DISKO_SWAP_SIZE="${SWAP_GB}G"
fi
disko_log=$(mktemp --suffix=.disko.log) disko_log=$(mktemp --suffix=.disko.log)
if ! disko --mode destroy,format,mount --yes-wipe-all-disks \ if ! disko --mode destroy,format,mount --yes-wipe-all-disks \
--argstr mainDrive "$TARGET_DISK" \ --argstr mainDrive "$TARGET_DISK" \
--arg withLuks "$WITH_LUKS" \ --arg withLuks "$WITH_LUKS" \
--argstr swapSize "${SWAP_GB}G" \ --argstr swapSize "$DISKO_SWAP_SIZE" \
"$SHARE/disko-config.nix" >"$disko_log" 2>&1; then "$SHARE/disko-config.nix" >"$disko_log" 2>&1; then
tail -n 30 "$disko_log" tail -n 30 "$disko_log"
fail "disko failed — full log: $disko_log" fail "disko failed — full log: $disko_log"
@@ -317,21 +368,14 @@ rm -f "$LUKS_KEY_PATH" "$disko_log"
success "Disk partitioned and mounted at /mnt" success "Disk partitioned and mounted at /mnt"
# Hibernation plumbing: the swapfile's physical offset goes into the # Hibernation plumbing: the swapfile's physical offset goes into the
# kernel cmdline. Deactivate swap first so nixos-generate-config doesn't # kernel cmdline (patched into system.nix). Deactivate swap first so
# also emit a swapDevices entry (we write our own, with resume wiring). # nixos-generate-config doesn't also emit a swapDevices entry.
RESUME_CONFIG="" resume_offset=""
root_uuid=""
if [[ "$SWAP_GB" != "0" ]]; then if [[ "$SWAP_GB" != "0" ]]; then
swapoff -a 2>/dev/null || true swapoff -a 2>/dev/null || true
resume_offset=$(btrfs inspect-internal map-swapfile -r /mnt/swap/swapfile) resume_offset=$(btrfs inspect-internal map-swapfile -r /mnt/swap/swapfile)
root_uuid=$(findmnt -no UUID /mnt) root_uuid=$(findmnt -no UUID /mnt)
RESUME_CONFIG=$(cat <<NIX
# Swapfile (hibernation-ready: resume points into it).
swapDevices = [{ device = "/swap/swapfile"; }];
boot.resumeDevice = "/dev/disk/by-uuid/$root_uuid";
boot.kernelParams = [ "resume_offset=$resume_offset" ];
NIX
)
success "Swapfile created (resume offset $resume_offset)" success "Swapfile created (resume offset $resume_offset)"
fi fi
@@ -345,133 +389,100 @@ nixos-generate-config --root /mnt
mv /mnt/etc/nixos/hardware-configuration.nix "$FLAKE_DIR/" mv /mnt/etc/nixos/hardware-configuration.nix "$FLAKE_DIR/"
rm -rf /mnt/etc/nixos rm -rf /mnt/etc/nixos
cp "$SHARE/template/theme-state.json" "$FLAKE_DIR/" # templates/downstream is the single source of truth (same files as
# `nix flake init -t`). Copy, then patch install-time values only.
cp "$SHARE/template/flake.nix" \
"$SHARE/template/system.nix" \
"$SHARE/template/home.nix" \
"$SHARE/template/theme-state.json" \
"$FLAKE_DIR/"
# home.nix is generated (not copied from the template) so the chosen # Detected hardware → flags for the patcher (safe defaults active).
# keyboard layout reaches the Hyprland session — standalone HM cannot has_intel=false; has_amd=false; has_fp=false
# read system.nix. intel_guc_off=false; has_camera_ir=false
cat > "$FLAKE_DIR/home.nix" <<EOF if [[ ${#HW_NOMARCHY[@]} -gt 0 ]]; then
# Your user environment. The Nomarchy desktop (Hyprland, Waybar, for nm in "${HW_NOMARCHY[@]}"; do
# Ghostty, theming engine, Stylix) comes from homeModules.nomarchy; case "$nm" in
# tune it via the nomarchy.* options, add your own packages and hardware.intel.enable=true) has_intel=true ;;
# programs below. hardware.intel.guc=false) intel_guc_off=true ;;
{ pkgs, ... }: hardware.amd.enable=true) has_amd=true ;;
hardware.fingerprint.enable=true) has_fp=true ;;
hardware.camera.hideIrSensor=true) has_camera_ir=true ;;
esac
done
fi
# NVIDIA is a nixos-hardware MODULE only (no nomarchy.hardware.nvidia.*) —
# still emit commented plain-NixOS guidance in system.nix (BACKLOG #59).
has_nvidia=false
[[ " ${HW_PROFILES[*]:-} " == *" common-gpu-nvidia "* ]] && has_nvidia=true
is_laptop=false
[[ " ${HW_PROFILES[*]:-} " == *" common-pc-laptop "* ]] && is_laptop=true
thermald=false
[[ $is_laptop == true ]] && grep -q GenuineIntel /proc/cpuinfo 2>/dev/null && thermald=true
{ # JSON for patch-template.py (stdin). Hardware profiles as a JSON array.
# Keyboard for the desktop session; console + LUKS prompt get the hw_json="["
# same layout from system.nix (xkb + console.useXkbConfig). first=1
nomarchy.keyboard.layout = "$KB_LAYOUT";
nomarchy.keyboard.variant = "$KB_VARIANT";
# Examples:
# nomarchy.terminal = "kitty"; # swap the default terminal
# nomarchy.waybar.enable = false; # bring your own bar
# nomarchy.stylix.enable = false; # opt out of GTK/Qt theming
home.packages = with pkgs; [
# firefox
];
}
EOF
hw_nix=""
for p in "${HW_PROFILES[@]:-}"; do for p in "${HW_PROFILES[@]:-}"; do
[[ -n "$p" ]] && hw_nix+=" \"$p\"" [[ -z "$p" ]] && continue
if [[ $first -eq 1 ]]; then first=0; else hw_json+=","; fi
hw_json+=$(printf '%s' "$p" | python3 -c 'import json,sys; print(json.dumps(sys.stdin.read().rstrip("\n")))')
done done
hw_json+="]"
cat > "$FLAKE_DIR/flake.nix" <<EOF resume_json="null"
{ root_uuid_json="null"
description = "$HOSTNAME_ — my Nomarchy machine"; if [[ -n "$resume_offset" && "$SWAP_GB" != "0" ]]; then
resume_json=$(printf '%s' "$resume_offset" | python3 -c 'import json,sys; print(json.dumps(int(sys.stdin.read().strip())))')
# The only input. nixpkgs, home-manager etc. come pinned through it — root_uuid_json=$(printf '%s' "$root_uuid" | python3 -c 'import json,sys; print(json.dumps(sys.stdin.read().rstrip("\n")))')
# tested together upstream. Generated by nomarchy-install; your machine
# lives in system.nix and home.nix, this file is never hand-edited.
inputs.nomarchy.url = "${NOMARCHY_FLAKE_URL}";
outputs = { nomarchy, ... }:
nomarchy.lib.mkFlake {
src = ./.;
username = "$USERNAME";
hardwareProfile = [$hw_nix ];
};
}
EOF
AUTOLOGIN_CONFIG=""
if [[ $WITH_LUKS == true ]]; then
AUTOLOGIN_CONFIG=$(cat <<NIX
# The LUKS passphrase already gates this machine — skip the second
# password prompt and boot straight into the desktop.
nomarchy.system.greeter.autoLogin = "$USERNAME";
NIX
)
fi fi
# Laptop power: power-profiles-daemon ships by default; mark this a laptop python3 "$SHARE/patch-template.py" "$FLAKE_DIR" <<PYJSON
# so battery-only features apply, and enable thermald on Intel. Keyed off
# the same battery probe that chose the common-pc-laptop hardware profile.
POWER_CONFIG=""
if [[ " ${HW_PROFILES[*]:-} " == *" common-pc-laptop "* ]]; then
power_lines=" # Laptop power management (power-profiles-daemon + menu/Waybar
# switcher). Uncomment to stop charging at 80% to extend battery life.
nomarchy.system.power.laptop = true;
# nomarchy.system.power.batteryChargeLimit = 80;"
if grep -q GenuineIntel /proc/cpuinfo 2>/dev/null; then
power_lines+="
nomarchy.system.power.thermal.enable = true; # thermald (Intel)"
fi
POWER_CONFIG=$(cat <<NIX
$power_lines
NIX
)
fi
# initialHashedPassword is safe to template: mkpasswd's alphabet is
# [a-zA-Z0-9./$] — no Nix string metacharacters.
cat > "$FLAKE_DIR/system.nix" <<EOF
# Your machine: hostname, users, services. The distro itself comes from
# Nomarchy (via flake.nix); override its defaults here with plain NixOS
# options, or the nomarchy.system.* toggles.
{ pkgs, username, ... }:
{ {
boot.loader.systemd-boot.enable = true; "hostname": $(printf '%s' "$HOSTNAME_" | python3 -c 'import json,sys; print(json.dumps(sys.stdin.read().rstrip("\n")))'),
boot.loader.efi.canTouchEfiVariables = true; "username": $(printf '%s' "$USERNAME" | python3 -c 'import json,sys; print(json.dumps(sys.stdin.read().rstrip("\n")))'),
"timezone": $(printf '%s' "$TIMEZONE" | python3 -c 'import json,sys; print(json.dumps(sys.stdin.read().rstrip("\n")))'),
networking.hostName = "$HOSTNAME_"; "locale": $(printf '%s' "$LOCALE" | python3 -c 'import json,sys; print(json.dumps(sys.stdin.read().rstrip("\n")))'),
time.timeZone = "$TIMEZONE"; "keyboardLayout": $(printf '%s' "$KB_LAYOUT" | python3 -c 'import json,sys; print(json.dumps(sys.stdin.read().rstrip("\n")))'),
i18n.defaultLocale = "$LOCALE"; "keyboardVariant": $(printf '%s' "$KB_VARIANT" | python3 -c 'import json,sys; print(json.dumps(sys.stdin.read().rstrip("\n")))'),
"hashedPassword": $(printf '%s' "$HASHED_PASSWORD" | python3 -c 'import json,sys; print(json.dumps(sys.stdin.read().rstrip("\n")))'),
# One keyboard layout everywhere: xkb is the source of truth, and the "autoLogin": $([[ $WITH_LUKS == true ]] && echo true || echo false),
# distro defaults (console.useXkbConfig + the systemd initrd, set in "laptop": $is_laptop,
# the nomarchy module) derive the virtual console and the LUKS "thermald": $thermald,
# passphrase prompt from it — so only the chosen layout is written here. "hardwareProfiles": $hw_json,
# The Hyprland session reads the same layout from nomarchy.keyboard.* in "hardware": {
# home.nix. "intel": $has_intel,
services.xserver.xkb.layout = "$KB_LAYOUT"; "intelGucOff": $intel_guc_off,
services.xserver.xkb.variant = "$KB_VARIANT"; "amd": $has_amd,
"fingerprint": $has_fp,
# Your login user — \`username\` flows in from flake.nix automatically. "cameraIr": $has_camera_ir,
users.users.\${username} = { "nvidia": $has_nvidia,
isNormalUser = true; "npu": $(if [[ -n "$NPU_VENDOR" ]]; then printf '%s' "$NPU_VENDOR" | python3 -c 'import json,sys; print(json.dumps(sys.stdin.read().rstrip("\n")))'; else echo null; fi)
extraGroups = [ "wheel" "networkmanager" "video" "input" ]; },
initialHashedPassword = "$HASHED_PASSWORD"; "resumeOffset": $resume_json,
}; "rootUuid": $root_uuid_json
$AUTOLOGIN_CONFIG$POWER_CONFIG$RESUME_CONFIG
# Hourly/daily BTRFS timeline snapshots + nixos-rebuild-snap.
nomarchy.system.snapper.enable = true;
system.stateVersion = "26.05";
} }
EOF PYJSON
# The flake.lock: composed offline — nomarchy is path-locked to the very # The flake.lock: composed offline — nomarchy is path-locked to the very
# source the ISO carries (original stays the forge URL, so a later # source the ISO carries (original stays the forge URL, so a later
# `nix flake update` on the installed machine re-resolves normally). # `nix flake update` on the installed machine re-resolves normally).
if ! python3 "$SHARE/compose-lock.py" "$SHARE/flake.lock" "$FLAKE_DIR/flake.lock" \ # NOMARCHY_TEST_FORCE_COMPOSE_FAIL=1 — unattended harness only (#54 V2):
# pretends compose-lock failed so the offline fail-closed arm is exercised
# without poisoning the ISO store.
compose_ok=0
if [[ "${NOMARCHY_TEST_FORCE_COMPOSE_FAIL:-}" == 1 ]]; then
compose_ok=1
elif python3 "$SHARE/compose-lock.py" "$SHARE/flake.lock" "$FLAKE_DIR/flake.lock" \
"$NOMARCHY_LOCKED_JSON" "$NOMARCHY_ORIGINAL_JSON"; then "$NOMARCHY_LOCKED_JSON" "$NOMARCHY_ORIGINAL_JSON"; then
compose_ok=0
else
compose_ok=1
fi
if (( compose_ok != 0 )); then
if [[ "$OFFLINE" == true ]]; then
fail "Offline lock composition failed and there is no network to fall back to — cannot finish an offline install."
fi
warn "Offline lock composition failed — resolving over the network." warn "Offline lock composition failed — resolving over the network."
(cd "$FLAKE_DIR" && nix --extra-experimental-features "nix-command flakes" flake lock) (cd "$FLAKE_DIR" && nix --extra-experimental-features "nix-command flakes" flake lock)
fi fi
@@ -485,13 +496,9 @@ fi
commit -qm "Initial Nomarchy configuration" commit -qm "Initial Nomarchy configuration"
) )
# The user must own their flake — libgit2 refuses repositories owned by
# someone else, which breaks `home-manager switch` (and theme switching)
# outright. The first normal NixOS user is always 1000:users(100); the
# account doesn't exist in the target yet, so numeric ids it is.
chown -R 1000:100 "$FLAKE_DIR"
# The templates come out of the nix store mode 0444 and cp preserves # The templates come out of the nix store mode 0444 and cp preserves
# that — without this the user owns home.nix but can't edit it. # that — without this the user can't edit home.nix after they own it.
# Ownership is applied after nixos-install (real uid/gid; see below).
chmod -R u+w "$FLAKE_DIR" chmod -R u+w "$FLAKE_DIR"
# /etc/nixos on the installed system points at the user-owned flake. # /etc/nixos on the installed system points at the user-owned flake.
@@ -543,6 +550,17 @@ fi
nixos-install --no-root-passwd "${NIXOS_INSTALL_OPTS[@]}" --flake "path:$FLAKE_DIR#default" nixos-install --no-root-passwd "${NIXOS_INSTALL_OPTS[@]}" --flake "path:$FLAKE_DIR#default"
success "System installed (bootloader in place)" success "System installed (bootloader in place)"
# The user must own their flake — libgit2 refuses repositories owned by
# someone else, which breaks `home-manager switch` (and theme switching)
# outright. Resolve real uid/gid from the target after nixos-install
# created the account (do not hard-code 1000:100 — first free uid or
# primary group can differ).
USER_UID=$(nixos-enter --root /mnt -- id -u "$USERNAME") \
|| fail "Could not resolve uid for install user '$USERNAME' on target"
USER_GID=$(nixos-enter --root /mnt -- id -g "$USERNAME") \
|| fail "Could not resolve gid for install user '$USERNAME' on target"
chown -R "$USER_UID:$USER_GID" "$FLAKE_DIR"
# Pre-activate the Home Manager generation so the FIRST boot lands in the # Pre-activate the Home Manager generation so the FIRST boot lands in the
# fully themed desktop, not bare Hyprland. Best-effort: a failure here # fully themed desktop, not bare Hyprland. Best-effort: a failure here
# only costs the user one `home-manager switch` after logging in. # only costs the user one `home-manager switch` after logging in.
@@ -573,6 +591,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.
@@ -595,13 +615,39 @@ sleep 2
runuser -u "$USERNAME" -- bash -lc \ runuser -u "$USERNAME" -- bash -lc \
"USER=$USERNAME HOME=/home/$USERNAME NIX_REMOTE=daemon HOME_MANAGER_BACKUP_EXT=bak \$out/activate" "USER=$USERNAME HOME=/home/$USERNAME NIX_REMOTE=daemon HOME_MANAGER_BACKUP_EXT=bak \$out/activate"
EOF EOF
if nixos-enter --root /mnt -- bash /root/nomarchy-hm-activate.sh; then # NOMARCHY_TEST_FORCE_HM_FAIL=1 — unattended harness only (#54 V2): take
# the failure arm so the durable recovery hint is exercised without a
# real activation breakage.
hm_activate_ok=0
if [[ "${NOMARCHY_TEST_FORCE_HM_FAIL:-}" == 1 ]]; then
hm_activate_ok=1
warn "NOMARCHY_TEST_FORCE_HM_FAIL=1 — skipping real pre-activate (test harness)"
elif nixos-enter --root /mnt -- bash /root/nomarchy-hm-activate.sh; then
hm_activate_ok=0
else
hm_activate_ok=1
fi
if (( hm_activate_ok == 0 )); then
success "Desktop pre-activated — first boot is fully themed" success "Desktop pre-activated — first boot is fully themed"
else else
warn "Desktop pre-activation failed (see /var/log/nomarchy-hm-preactivate.log" warn "Desktop pre-activation failed (see /var/log/nomarchy-hm-preactivate.log"
warn "on the installed system); after first login run:" warn "on the installed system); after first login run:"
warn " home-manager switch --flake ~/.nomarchy -b bak" warn " home-manager switch --flake ~/.nomarchy -b bak"
tail -n 5 /mnt/var/log/nomarchy-hm-preactivate.log 2>/dev/null || true tail -n 5 /mnt/var/log/nomarchy-hm-preactivate.log 2>/dev/null || true
# The live session (and this warning) ends with this install — drop a
# durable hint on the TARGET so the fix still surfaces on first login.
# Numeric ids from the target account (USER_UID/USER_GID above) — the
# name does not exist in the live ISO's passwd.
hint_file="/mnt/home/$USERNAME/NOMARCHY-DESKTOP-NOT-THEMED.txt"
cat > "$hint_file" <<HINT
Desktop pre-activation failed during install — see
/var/log/nomarchy-hm-preactivate.log for details. Finish it with:
home-manager switch --flake ~/.nomarchy -b bak
(delete this file once done)
HINT
chown "$USER_UID:$USER_GID" "$hint_file"
fi fi
rm -f /mnt/root/nomarchy-hm-activate.sh rm -f /mnt/root/nomarchy-hm-activate.sh

View File

@@ -0,0 +1,265 @@
#!/usr/bin/env python3
"""Patch a copied templates/downstream machine flake with install-time values.
The template is the single source of truth for commented opt-ins and the
starter app suite. The installer copies it, then this script only:
* replaces known placeholders (hostname, username, locale, keyboard, …)
* fills the __NOMARCHY_INSTALLER__ region with detected/active config
* sets hardwareProfile on flake.nix
Usage:
patch-template.py <flake-dir> # reads a JSON object from stdin
"""
from __future__ import annotations
import json
import re
import sys
from pathlib import Path
BEGIN = " # __NOMARCHY_INSTALLER_BEGIN__"
END = " # __NOMARCHY_INSTALLER_END__"
def nix_str(s: str) -> str:
"""Escape a string for a Nix double-quoted literal."""
return (
s.replace("\\", "\\\\")
.replace('"', '\\"')
.replace("${", "\\${")
.replace("\n", "\\n")
)
def replace_once(text: str, old: str, new: str, label: str) -> str:
if old not in text:
sys.exit(f"patch-template: missing placeholder for {label}: {old!r}")
return text.replace(old, new, 1)
def patch_flake(text: str, v: dict) -> str:
text = replace_once(
text,
'description = "My Nomarchy machine";',
f'description = "{nix_str(v["hostname"])} — my Nomarchy machine";',
"flake description",
)
text = replace_once(
text,
'username = "me"; # <- your login name',
f'username = "{nix_str(v["username"])}"; # <- your login name',
"flake username",
)
profiles = v.get("hardwareProfiles") or []
if profiles:
items = " ".join(f'"{nix_str(p)}"' for p in profiles)
hw_line = f" hardwareProfile = [ {items} ];"
else:
hw_line = " # hardwareProfile = null; # no nixos-hardware profiles selected"
# Replace the optional hardwareProfile comment block with the install choice.
text, n = re.subn(
r"\n # Optional: a nixos-hardware module name for your machine, e\.g\.\n"
r" # hardwareProfile = \"framework-13-7040-amd\";\n"
r" # Names: https://github.com/NixOS/nixos-hardware\n"
r" # \(the future installer fills this in automatically from DMI data\)\n",
f"\n{hw_line}\n"
f" # Names: https://github.com/NixOS/nixos-hardware\n",
text,
count=1,
)
if n != 1:
sys.exit("patch-template: could not patch hardwareProfile block in flake.nix")
return text
def patch_home(text: str, v: dict) -> str:
layout = nix_str(v["keyboardLayout"])
variant = nix_str(v.get("keyboardVariant") or "")
text = replace_once(
text,
' nomarchy.keyboard.layout = "us";',
f' nomarchy.keyboard.layout = "{layout}";',
"home keyboard layout",
)
text = replace_once(
text,
' nomarchy.keyboard.variant = "";',
f' nomarchy.keyboard.variant = "{variant}";',
"home keyboard variant",
)
return text
def build_installer_region(v: dict) -> str:
lines: list[str] = [
BEGIN,
" # Written by nomarchy-install from live detection. Safe defaults are",
" # active; heavier opt-ins stay in the commented catalog below.",
]
if v.get("autoLogin"):
user = nix_str(v["username"])
lines += [
" # LUKS passphrase already gates this machine — skip the greeter password.",
f' nomarchy.system.greeter.autoLogin = "{user}";',
]
if v.get("laptop"):
lines += [
" # Laptop power (PPD + menu/Waybar). Uncomment to cap charge at 80%.",
" nomarchy.system.power.laptop = true;",
" # nomarchy.system.power.batteryChargeLimit = 80;",
]
if v.get("thermald"):
lines.append(
" nomarchy.system.power.thermal.enable = true; # thermald (Intel)"
)
hw = v.get("hardware") or {}
if any(
hw.get(k)
for k in ("intel", "amd", "fingerprint", "cameraIr", "npu", "nvidia")
):
lines.append(" # Hardware enablement (auto-detected).")
if hw.get("intel"):
lines.append(
" nomarchy.hardware.intel.enable = true; # GuC/HuC (i915)"
)
if hw.get("intelGucOff"):
lines.append(
" nomarchy.hardware.intel.guc = false; # xe driver → GuC default-on"
)
lines.append(
" # nomarchy.hardware.intel.computeRuntime = true; # OpenCL/oneVPL (opt-in)"
)
if hw.get("amd"):
lines += [
" nomarchy.hardware.amd.enable = true; # amd-pstate + VA-API",
" # nomarchy.hardware.amd.rocm.enable = true; # ROCm (multi-GB, opt-in)",
' # nomarchy.hardware.amd.rocm.gfxOverride = ""; # e.g. "11.0.0" for unlisted iGPU',
]
if hw.get("fingerprint"):
lines += [
" nomarchy.hardware.fingerprint.enable = true; # fprintd (enroll: fprintd-enroll)",
" # nomarchy.hardware.fingerprint.pam = true; # login + sudo (opt-in)",
]
if hw.get("cameraIr"):
lines.append(
" nomarchy.hardware.camera.hideIrSensor = true; # dual-sensor: hide IR node"
)
if hw.get("npu"):
vendor = nix_str(hw["npu"])
lines += [
f" # nomarchy.hardware.npu.enable = true; # {vendor} NPU (experimental; userspace BYO)",
" # nomarchy.hardware.latestKernel = true; # if the NPU driver needs a newer kernel",
]
# NVIDIA: profile is in flake.nix (common-gpu-nvidia). Hybrid/PRIME
# knobs are plain NixOS — comment-only guidance, same pattern as ROCm.
if hw.get("nvidia"):
lines += [
" # NVIDIA: common-gpu-nvidia is in hardwareProfile (flake.nix).",
" # Hybrid/PRIME, power, open-module — plain NixOS; see docs/HARDWARE.md §6",
" # and https://wiki.nixos.org/wiki/Nvidia (bus IDs are machine-specific).",
" # hardware.nvidia.prime = { ... }; # offload/sync",
" # hardware.nvidia.powerManagement.enable = true; # suspend/resume",
" # hardware.nvidia.open = false; # true = open module (newer cards)",
]
if v.get("resumeOffset") is not None:
root_uuid = nix_str(v["rootUuid"])
offset = v["resumeOffset"]
lines += [
" # Swapfile (hibernation-ready: resume points into it).",
' swapDevices = [{ device = "/swap/swapfile"; }];',
f' boot.resumeDevice = "/dev/disk/by-uuid/{root_uuid}";',
f' boot.kernelParams = [ "resume_offset={offset}" ];',
]
# Always on for installer layout (BTRFS + @snapshots).
lines += [
" # Hourly/daily BTRFS timeline snapshots + nixos-rebuild-snap.",
" nomarchy.system.snapper.enable = true;",
END,
]
return "\n".join(lines) + "\n"
def patch_system(text: str, v: dict) -> str:
text = replace_once(
text,
' networking.hostName = "my-nomarchy";',
f' networking.hostName = "{nix_str(v["hostname"])}";',
"hostName",
)
text = replace_once(
text,
' time.timeZone = "UTC";',
f' time.timeZone = "{nix_str(v["timezone"])}";',
"timeZone",
)
text = replace_once(
text,
' i18n.defaultLocale = "en_US.UTF-8";',
f' i18n.defaultLocale = "{nix_str(v["locale"])}";',
"locale",
)
text = replace_once(
text,
' services.xserver.xkb.layout = "us";',
f' services.xserver.xkb.layout = "{nix_str(v["keyboardLayout"])}";',
"xkb layout",
)
text = replace_once(
text,
' services.xserver.xkb.variant = "";',
f' services.xserver.xkb.variant = "{nix_str(v.get("keyboardVariant") or "")}";',
"xkb variant",
)
# Inject password into the user attrset (template has no password for flake-init).
user_block = """ users.users.${username} = {
isNormalUser = true;
extraGroups = [ "wheel" "networkmanager" "video" "input" ];
};"""
# HASHED_PASSWORD is sha-512 crypt; alphabet is safe in Nix double quotes.
hashed = nix_str(v["hashedPassword"])
user_patched = f""" users.users.${{username}} = {{
isNormalUser = true;
extraGroups = [ "wheel" "networkmanager" "video" "input" ];
initialHashedPassword = "{hashed}";
}};"""
text = replace_once(text, user_block, user_patched, "user password")
if BEGIN not in text or END not in text:
sys.exit("patch-template: system.nix missing __NOMARCHY_INSTALLER__ markers")
region = build_installer_region(v)
text = re.sub(
re.escape(BEGIN) + r".*?" + re.escape(END) + r"\n?",
region,
text,
count=1,
flags=re.DOTALL,
)
return text
def main() -> None:
if len(sys.argv) != 2:
sys.exit("usage: patch-template.py <flake-dir>")
flake_dir = Path(sys.argv[1])
vals = json.load(sys.stdin)
mapping = {
"flake.nix": patch_flake,
"home.nix": patch_home,
"system.nix": patch_system,
}
for name, fn in mapping.items():
path = flake_dir / name
path.write_text(fn(path.read_text(), vals))
print(f"patch-template: patched {', '.join(mapping)} in {flake_dir}")
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,133 @@
# Day-to-day lifecycle CLIs for a Nomarchy machine flake (~/.nomarchy).
# Installed on both NixOS (systemPackages) and Home Manager so a home
# switch can refresh them without waiting for a full system rebuild —
# otherwise a broken nomarchy-pull can never update itself.
{ lib, writeShellScriptBin, nvd, jq, symlinkJoin }:
let
# Shared preamble: refuse root, resolve flake path.
preamble = name: ''
set -euo pipefail
if [ "$(id -u)" -eq 0 ]; then
echo "${name}: run as your normal user" >&2
exit 1
fi
flake="''${NOMARCHY_PATH:-$HOME/.nomarchy}"
'';
nomarchy-pull = writeShellScriptBin "nomarchy-pull" ''
${preamble "nomarchy-pull"}
if [ ! -e "$flake/flake.nix" ]; then
echo "nomarchy-pull: no flake.nix at $flake" >&2
echo " Set NOMARCHY_PATH or put your machine flake in ~/.nomarchy." >&2
exit 1
fi
# Optional: pull *your* machine config only if this checkout tracks a
# remote. Distro updates come from the nomarchy flake *input* below
# many installs have no upstream on ~/.nomarchy (local auto-commits).
if [ -d "$flake/.git" ] \
&& git -C "$flake" rev-parse --abbrev-ref '@{u}' >/dev/null 2>&1; then
echo "nomarchy-pull: git pull --ff-only (machine flake tracks upstream)"
git -C "$flake" pull --ff-only
elif [ -d "$flake/.git" ]; then
echo "nomarchy-pull: machine flake has no upstream branch skipping git pull"
echo " (normal for a local-only ~/.nomarchy; distro updates use flake inputs)"
fi
echo "nomarchy-pull: nix flake update in $flake"
nix flake update --flake "$flake"
nom=$(nix flake metadata "$flake" --json 2>/dev/null \
| ${jq}/bin/jq -r '
.locks.nodes.nomarchy.locked
| if . == null then empty
elif .rev then "nomarchy @ \(.rev[0:12])"
else empty end
' 2>/dev/null || true)
[ -n "''${nom:-}" ] && echo "nomarchy-pull: $nom"
echo "nomarchy-pull: done next: nomarchy-rebuild then nomarchy-home"
'';
nomarchy-rebuild = writeShellScriptBin "nomarchy-rebuild" ''
${preamble "nomarchy-rebuild"}
if [ ! -e "$flake/flake.nix" ]; then
echo "nomarchy-rebuild: no flake.nix at $flake" >&2
exit 1
fi
before=$(readlink -f /run/current-system)
log=$(mktemp)
trap 'rm -f "$log"' EXIT
set +e
if command -v nixos-rebuild-snap >/dev/null 2>&1; then
sudo env NOMARCHY_PATH="$flake" nixos-rebuild-snap "$@" 2>&1 | tee "$log"
else
sudo nixos-rebuild switch --flake "$flake#default" "$@" 2>&1 | tee "$log"
fi
rc=''${PIPESTATUS[0]}
set -e
if [ "$rc" -ne 0 ]; then
echo
echo "nomarchy-rebuild: FAILED (exit $rc). Last lines:"
tail -n 40 "$log" || true
echo
echo "Diagnose: nomarchy-doctor"
echo "Recovery: docs/RECOVERY.md (boot menu generations / snapper)"
exit "$rc"
fi
after=$(readlink -f /run/current-system)
if [ "$before" = "$after" ]; then
echo "nomarchy-rebuild: no changes the system is identical."
else
echo "nomarchy-rebuild: what changed:"
${nvd}/bin/nvd diff "$before" "$after" || true
fi
'';
nomarchy-home = writeShellScriptBin "nomarchy-home" ''
${preamble "nomarchy-home"}
if [ ! -e "$flake/flake.nix" ]; then
echo "nomarchy-home: no flake.nix at $flake" >&2
exit 1
fi
log=$(mktemp)
trap 'rm -f "$log"' EXIT
set +e
home-manager switch --flake "$flake" "$@" 2>&1 | tee "$log"
rc=''${PIPESTATUS[0]}
set -e
if [ "$rc" -ne 0 ]; then
echo
echo "nomarchy-home: FAILED (exit $rc). Last lines:"
tail -n 40 "$log" || true
echo
echo "Diagnose: nomarchy-doctor"
echo "Recovery: home-manager generations (or docs/RECOVERY.md)"
exit "$rc"
fi
echo "nomarchy-home: desktop applied."
'';
# Legacy aliases
sys-update = writeShellScriptBin "sys-update" ''
nomarchy-pull && nomarchy-rebuild "$@"
'';
sys-rebuild = writeShellScriptBin "sys-rebuild" ''
exec nomarchy-rebuild "$@"
'';
home-update = writeShellScriptBin "home-update" ''
exec nomarchy-home "$@"
'';
in
symlinkJoin {
name = "nomarchy-lifecycle";
paths = [
nomarchy-pull
nomarchy-rebuild
nomarchy-home
sys-update
sys-rebuild
home-update
];
meta = {
description = "Nomarchy machine-flake lifecycle: pull, rebuild, home";
mainProgram = "nomarchy-pull";
};
}

View File

@@ -18,6 +18,8 @@ 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)
auto [--which] apply the day/night theme for the current time
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 +27,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 +93,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 +131,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 +174,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 boreal")
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. "boreal"')
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 +359,35 @@ 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") # BACKLOG #56: point at doctor, not a silent wall of Nix noise.
die("rebuild failed (state file already updated; fix and re-run)") notify(
notify("Theme applied ✓") "Rebuild FAILED — scroll up for last lines; run nomarchy-doctor",
urgency="critical",
)
die(
"rebuild failed (state already written; fix and re-run). "
"Diagnose: nomarchy-doctor. Recovery: docs/RECOVERY.md"
)
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) ────────────────────────────────────────────
@@ -250,24 +467,85 @@ def cmd_list(_args) -> None:
print("\n".join(names)) print("\n".join(names))
def cmd_apply(args) -> None: def apply_named(theme: str, no_switch: bool = False) -> None:
candidate = Path(args.theme).expanduser() """Merge a preset (name or JSON file) into the state and rebuild. Shared
preset_path = candidate if candidate.is_file() else find_preset(args.theme) by `apply` and `auto` so the day/night switch is the *same* one engine."""
candidate = Path(theme).expanduser()
preset_path = candidate if candidate.is_file() else find_preset(theme)
if preset_path is None: if preset_path is None:
die(f"unknown theme '{args.theme}' (try `nomarchy-theme-sync list`)") die(f"unknown theme '{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)
log(f"theme: {state.get('name', args.theme)}") if auto_commit_enabled(old) or auto_commit_enabled(state):
auto_commit(f"nomarchy: apply theme {state.get('name', theme)}")
log(f"theme: {state.get('name', theme)}")
check_fonts(state) check_fonts(state)
if not args.no_switch: if not no_switch:
run_switch() run_switch()
apply_wallpaper(state) apply_wallpaper(state)
def cmd_apply(args) -> None:
apply_named(args.theme, no_switch=args.no_switch)
def _hhmm_to_min(s: str, default: str) -> int:
"""'HH:MM' → minutes since midnight; falls back to `default` on garbage."""
for candidate in (s, default):
try:
h, m = str(candidate).split(":")
return int(h) * 60 + int(m)
except (ValueError, AttributeError):
continue
return 0
def cmd_auto(args) -> None:
"""Apply the day or night theme for the current local time, per
settings.autoTheme = {enable, day, night, sunrise, sunset}. Slice 1 of
the auto time-of-day pair (BACKLOG #79): the timer (slice 2) and menu
(slice 3) drive this; it can also be run by hand or from a user cron."""
state = load_state()
at = (state.get("settings") or {}).get("autoTheme") or {}
if not at.get("enable"):
if not args.which:
log("auto-theme: off (enable via settings.autoTheme.enable)")
return
day, night = at.get("day"), at.get("night")
if not day or not night:
die("auto-theme needs settings.autoTheme.day and .night (theme slugs)")
sunrise = _hhmm_to_min(at.get("sunrise", "07:00"), "07:00")
sunset = _hhmm_to_min(at.get("sunset", "20:00"), "20:00")
now = time.localtime()
mins = now.tm_hour * 60 + now.tm_min
# Daytime is the span between sunrise and sunset. Also handle a
# wrap-around pair (sunrise after sunset → the day window crosses
# midnight), so an inverted schedule still switches both ways.
if sunrise <= sunset:
daytime = sunrise <= mins < sunset
else:
daytime = mins >= sunrise or mins < sunset
target = day if daytime else night
if args.which:
print(target)
return
if state.get("slug") == target and not args.force:
log(f"auto-theme: already on {target} ({'day' if daytime else 'night'})")
return
apply_named(target, no_switch=args.no_switch)
def cmd_set(args) -> None: def cmd_set(args) -> None:
state = load_state() state = load_state()
try: try:
@@ -275,6 +553,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,12 +563,18 @@ 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()
apply_wallpaper(state) # 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)
def cmd_get(args) -> None: def cmd_get(args) -> None:
@@ -301,7 +586,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 +645,14 @@ 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)
p = sub.add_parser("auto", help="apply the day/night theme for the current time (settings.autoTheme)")
p.add_argument("--which", action="store_true", help="print the theme that WOULD apply, don't switch")
p.add_argument("--force", action="store_true", help="apply even if already on the target theme")
p.add_argument("--no-switch", action="store_true", help="write state only, skip the rebuild")
p.set_defaults(func=cmd_auto)
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

@@ -1,5 +1,11 @@
# My Nomarchy machine # My Nomarchy machine
This directory is the **single source of truth** for a Nomarchy machine
flake. `nix flake init -t …` copies it as-is; **`nomarchy-install` copies
the same files and only patches** install-time values (username, hostname,
keyboard, detected hardware, password hash, …). Keep commented opt-ins and
`home.packages` here — do not invent a second catalog in the installer.
1. Overwrite the placeholder hardware config with the real one: 1. Overwrite the placeholder hardware config with the real one:
`nixos-generate-config --show-hardware-config > hardware-configuration.nix` `nixos-generate-config --show-hardware-config > hardware-configuration.nix`
2. Set `flake.nix` up **once** (Nomarchy repo URL, your username, optionally 2. Set `flake.nix` up **once** (Nomarchy repo URL, your username, optionally
@@ -19,7 +25,7 @@
Day-to-day: Day-to-day:
```sh ```sh
nomarchy-theme-sync list # 21 shipped presets nomarchy-theme-sync list # 24 shipped presets
nomarchy-theme-sync apply gruvbox # writes state + home-manager switch nomarchy-theme-sync apply gruvbox # writes state + home-manager switch
nomarchy-theme-sync bg next # cycle wallpapers (instant, no rebuild) nomarchy-theme-sync bg next # cycle wallpapers (instant, no rebuild)
``` ```

View File

@@ -5,6 +5,7 @@
# tested together upstream. This file is written once (by you or the # tested together upstream. This file is written once (by you or the
# installer) and never hand-edited afterwards; your machine lives in # installer) and never hand-edited afterwards; your machine lives in
# system.nix and home.nix. v1 is the release branch. # system.nix and home.nix. v1 is the release branch.
# Installer copies this template and patches username + hardwareProfile.
inputs.nomarchy.url = "git+https://git.bemagri.xyz/bernardo/nomarchy.git?ref=v1"; inputs.nomarchy.url = "git+https://git.bemagri.xyz/bernardo/nomarchy.git?ref=v1";
outputs = { nomarchy, ... }: outputs = { nomarchy, ... }:

View File

@@ -2,24 +2,45 @@
# Ghostty, theming engine, Stylix) comes from homeModules.nomarchy; # Ghostty, theming engine, Stylix) comes from homeModules.nomarchy;
# tune it via the nomarchy.* options, add your own packages and # tune it via the nomarchy.* options, add your own packages and
# programs below. # programs below.
#
# Source of truth for install and flake-init: nomarchy-install copies this
# file and only patches keyboard layout/variant.
{ pkgs, ... }: { pkgs, ... }:
{ {
# Session keyboard — match services.xserver.xkb.* in system.nix (console
# + LUKS prompt follow xkb via the distro module). Installer patches these.
nomarchy.keyboard.layout = "us";
nomarchy.keyboard.variant = "";
# ── Overrides (the desktop is on by default; tweak or opt out) ────── # ── Overrides (the desktop is on by default; tweak or opt out) ──────
# nomarchy.terminal = "kitty"; # swap the default terminal # nomarchy.terminal = "kitty"; # swap the default terminal
# nomarchy.waybar.enable = false; # bring your own bar # nomarchy.waybar.enable = false; # bring your own bar
# nomarchy.stylix.enable = false; # opt out of GTK/Qt theming # nomarchy.stylix.enable = false; # opt out of GTK/Qt theming
# nomarchy.keyboard.layout = "de"; # session keyboard (set the matching #
# # services.xserver.xkb.layout in # Icon pack: Papirus ships by default (auto Dark/Light by theme mode) and
# # system.nix too; the console + LUKS # is the ONLY icon pack in your closure. To use another, set the `icons`
# # prompt follow it automatically) # field in your theme-state.json to a theme name from a known pack — only
# that pack is then added (no bloat otherwise). Known packs: Papirus,
# Tela, Qogir, Reversal, Numix (see modules/home/theme.nix `iconPacks`).
# e.g. theme-state.json: "icons": "Tela-dark"
# ── 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";
# latitude = "52.52"; # geo mode: set BOTH lat/long and sunrise/
# longitude = "13.40"; # sunset follow your location (wlsunset);
# # the fixed times above are then ignored
# };
#
# 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
@@ -27,12 +48,33 @@
# { 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
@@ -49,15 +91,16 @@
home.packages = with pkgs; [ home.packages = with pkgs; [
libreoffice-fresh # office suite (documents, sheets, slides) libreoffice-fresh # office suite (documents, sheets, slides)
vscode # code editor (unfree; allowUnfree is on) vscode # code editor (unfree; allowUnfree is on)
chromium # web browser (mime defaults → chromium-browser.desktop)
gimp # raster image editor gimp # raster image editor
inkscape # vector graphics inkscape # vector graphics
mpv # media player mpv # media player
amberol # music player (local library; streaming → spotify below) amberol # music player (local library; streaming → spotify below)
# pwvucontrol + calcurse ship with the Waybar module (volume right-click / clock)
# ── More apps — uncomment to add ───────────────────────────────── # ── More apps — uncomment to add ─────────────────────────────────
# Web browsers # Web browsers (chromium ships above — swap/delete freely)
# firefox # firefox
# chromium
# ungoogled-chromium # ungoogled-chromium
# brave # (unfree) # brave # (unfree)
# google-chrome # (unfree) # google-chrome # (unfree)
@@ -74,7 +117,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 +148,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

@@ -2,6 +2,9 @@
# The distro itself comes from nomarchy.nixosModules.nomarchy — override # The distro itself comes from nomarchy.nixosModules.nomarchy — override
# any of its defaults here with plain NixOS options (they use mkDefault), # any of its defaults here with plain NixOS options (they use mkDefault),
# or via the nomarchy.system.* toggles. # or via the nomarchy.system.* toggles.
#
# Source of truth for install and flake-init: nomarchy-install copies this
# file and only patches placeholders + the __NOMARCHY_INSTALLER__ region.
{ pkgs, username, ... }: { pkgs, username, ... }:
{ {
@@ -12,12 +15,28 @@
time.timeZone = "UTC"; time.timeZone = "UTC";
i18n.defaultLocale = "en_US.UTF-8"; i18n.defaultLocale = "en_US.UTF-8";
# One keyboard layout everywhere: xkb is the source of truth; the distro
# defaults (console.useXkbConfig + systemd initrd) derive the virtual
# console and the LUKS passphrase prompt from it. Match home.nix
# nomarchy.keyboard.*.
services.xserver.xkb.layout = "us";
services.xserver.xkb.variant = "";
# Your login user — `username` flows in from flake.nix automatically. # Your login user — `username` flows in from flake.nix automatically.
# The installer adds initialHashedPassword here; flake-init: set a password
# (or users.mutableUsers) before first boot if needed.
users.users.${username} = { users.users.${username} = {
isNormalUser = true; isNormalUser = true;
extraGroups = [ "wheel" "networkmanager" "video" "input" ]; extraGroups = [ "wheel" "networkmanager" "video" "input" ];
}; };
# __NOMARCHY_INSTALLER_BEGIN__
# Installer fills this region (power, hardware, snapper, resume, autoLogin).
# Flake-init: leave empty and use the commented catalog below, or enable
# snapper when on BTRFS with a /.snapshots subvolume:
# nomarchy.system.snapper.enable = true;
# __NOMARCHY_INSTALLER_END__
# ── Overrides (uncomment to change) ───────────────────────────────── # ── Overrides (uncomment to change) ─────────────────────────────────
# nomarchy.system.greeter.enable = false; # bring your own login manager # nomarchy.system.greeter.enable = false; # bring your own login manager
# environment.systemPackages = [ pkgs.htop ]; # environment.systemPackages = [ pkgs.htop ];
@@ -31,7 +50,29 @@
# 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)
# i2c.enable = true; # /dev/i2c-* (RGB, sensors); ddcci is distro-default for external brightness
# i2c.ddcci = true; # already mkDefault true — set false to opt out
# };
#
# 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

@@ -1,55 +1,61 @@
{ {
"version": 1, "version": 1,
"name": "Tokyo Night", "name": "Boreal",
"slug": "tokyo-night", "slug": "boreal",
"mode": "dark", "mode": "dark",
"wallpaper": "", "wallpaper": "",
"colors": { "colors": {
"base": "#1a1b26", "base": "#21272F",
"mantle": "#161720", "mantle": "#1A1F26",
"surface": "#32344a", "surface": "#303A46",
"overlay": "#444b6a", "overlay": "#404D5C",
"text": "#a9b1d6", "text": "#D3DAE0",
"subtext": "#787c99", "subtext": "#97A3B2",
"muted": "#444b6a", "muted": "#5E6A78",
"accent": "#7aa2f7", "accent": "#B79BE8",
"accentAlt": "#ad8ee6", "accentAlt": "#86C7C0",
"good": "#9ece6a", "good": "#A3CF88",
"warn": "#e0af68", "warn": "#E6C384",
"bad": "#f7768e" "bad": "#E2818A"
},
"border": {
"active": "accent",
"inactive": "overlay"
}, },
"ansi": [ "ansi": [
"#32344a", "#2E3742",
"#f7768e", "#E2818A",
"#9ece6a", "#A3CF88",
"#e0af68", "#E6C384",
"#7aa2f7", "#86A9E0",
"#ad8ee6", "#B79BE8",
"#449dab", "#86C7C0",
"#787c99", "#D3DAE0",
"#444b6a", "#5E6A78",
"#ff7a93", "#EC98A0",
"#b9f27c", "#B4DB9C",
"#ff9e64", "#EFCF98",
"#7da6ff", "#9DBBEC",
"#bb9af7", "#C6AEEF",
"#0db9d7", "#99D6CF",
"#acb0d0" "#E8EDF2"
], ],
"fonts": { "fonts": {
"mono": "JetBrainsMono Nerd Font", "mono": "GeistMono Nerd Font",
"ui": "Inter", "ui": "Inter",
"size": 11 "size": 11
}, },
"ui": { "ui": {
"gapsIn": 5, "gapsIn": 6,
"gapsOut": 12, "gapsOut": 14,
"borderSize": 2, "borderSize": 3,
"rounding": 10, "rounding": 12,
"iconSize": 44,
"activeOpacity": 1.0, "activeOpacity": 1.0,
"inactiveOpacity": 0.95, "inactiveOpacity": 0.93,
"terminalOpacity": 0.96, "terminalOpacity": 0.9,
"blur": true, "blur": true,
"shadow": true "shadow": true
} },
"settings": {}
} }

View File

@@ -1,59 +1,61 @@
{ {
"version": 1, "version": 1,
"name": "Tokyo Night", "name": "Boreal",
"slug": "tokyo-night", "slug": "boreal",
"mode": "dark", "mode": "dark",
"wallpaper": "", "wallpaper": "",
"colors": { "colors": {
"base": "#1a1b26", "base": "#21272F",
"mantle": "#161720", "mantle": "#1A1F26",
"surface": "#32344a", "surface": "#303A46",
"overlay": "#444b6a", "overlay": "#404D5C",
"text": "#a9b1d6", "text": "#D3DAE0",
"subtext": "#787c99", "subtext": "#97A3B2",
"muted": "#444b6a", "muted": "#5E6A78",
"accent": "#7aa2f7", "accent": "#B79BE8",
"accentAlt": "#ad8ee6", "accentAlt": "#86C7C0",
"good": "#9ece6a", "good": "#A3CF88",
"warn": "#e0af68", "warn": "#E6C384",
"bad": "#f7768e" "bad": "#E2818A"
}, },
"border": { "border": {
"active": "accent", "active": "accent",
"inactive": "overlay" "inactive": "overlay"
}, },
"ansi": [ "ansi": [
"#32344a", "#2E3742",
"#f7768e", "#E2818A",
"#9ece6a", "#A3CF88",
"#e0af68", "#E6C384",
"#7aa2f7", "#86A9E0",
"#ad8ee6", "#B79BE8",
"#449dab", "#86C7C0",
"#787c99", "#D3DAE0",
"#444b6a", "#5E6A78",
"#ff7a93", "#EC98A0",
"#b9f27c", "#B4DB9C",
"#ff9e64", "#EFCF98",
"#7da6ff", "#9DBBEC",
"#bb9af7", "#C6AEEF",
"#0db9d7", "#99D6CF",
"#acb0d0" "#E8EDF2"
], ],
"fonts": { "fonts": {
"mono": "JetBrainsMono Nerd Font", "mono": "GeistMono Nerd Font",
"ui": "Inter", "ui": "Inter",
"size": 11 "size": 11
}, },
"ui": { "ui": {
"gapsIn": 5, "gapsIn": 6,
"gapsOut": 12, "gapsOut": 14,
"borderSize": 2, "borderSize": 3,
"rounding": 10, "rounding": 12,
"iconSize": 44,
"activeOpacity": 1.0, "activeOpacity": 1.0,
"inactiveOpacity": 0.95, "inactiveOpacity": 0.93,
"terminalOpacity": 0.96, "terminalOpacity": 0.9,
"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

25
themes/boreal/btop.theme Normal file
View File

@@ -0,0 +1,25 @@
theme[main_bg]="#21272F"
theme[main_fg]="#D3DAE0"
theme[title]="#D3DAE0"
theme[hi_fg]="#B79BE8"
theme[selected_bg]="#404D5C"
theme[selected_fg]="#D3DAE0"
theme[inactive_fg]="#5E6A78"
theme[graph_text]="#97A3B2"
theme[meter_bg]="#303A46"
theme[proc_misc]="#86C7C0"
theme[cpu_box]="#B79BE8"
theme[mem_box]="#A3CF88"
theme[net_box]="#86C7C0"
theme[proc_box]="#303A46"
theme[div_line]="#404D5C"
theme[temp_start]="#A3CF88"
theme[temp_mid]="#E6C384"
theme[temp_end]="#E2818A"
theme[cpu_start]="#A3CF88"
theme[cpu_mid]="#E6C384"
theme[cpu_end]="#E2818A"
theme[free_start]="#A3CF88"
theme[used_start]="#E6C384"
theme[download_start]="#B79BE8"
theme[upload_start]="#86C7C0"

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;
}

BIN
themes/boreal/preview.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 53 KiB

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

@@ -0,0 +1,122 @@
/**
* 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: true;
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: 4px;
handle-width: 4px;
handle-color: @accent;
background-color: @surface;
border: 0px;
}

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

@@ -0,0 +1,169 @@
/*
* 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;
* {
/* Mono Nerd face for status glyphs (tight right bearings on the
proportional Symbols face make icon+percentage collide). */
font-family: "GeistMono Nerd Font", "Symbols Nerd Font Mono";
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 — unified time · date ─────────────────────────────────────*/
#clock {
color: @text;
font-weight: 600;
padding-left: 4px;
}
/* ── 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,
#custom-notification.inhibited-none,
#custom-notification.inhibited-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; }

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

@@ -0,0 +1,174 @@
{
"margin-top": 8,
"margin-left": 14,
"margin-right": 14,
"margin-bottom": 0,
"height": 38,
"layer": "bottom",
"position": "top",
"spacing": 4,
"reload_style_on_change": true,
"modules-left": [
"custom/nomarchy",
"clock"
],
"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 · %a %d %b}",
"on-click": "nomarchy-calendar",
"tooltip": true,
"tooltip-format": "{:%A, %d %B %Y\n%Z (UTC%z)}"
},
"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",
"format": "{}",
"interval": 300,
"signal": 10,
"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",
"on-click-right": "pwvucontrol",
"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 powermgmt",
"tooltip-format": "Battery status"
},
"custom/powerprofile": {
"return-type": "json",
"interval": 5,
"exec": "nomarchy-powerprofile-status",
"on-click": "nomarchy-menu powermgmt"
},
"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

@@ -6,38 +6,55 @@
"wallpaper": "", "wallpaper": "",
"colors": { "colors": {
"base": "#eff1f5", "base": "#eff1f5",
"mantle": "#cbcdd0", "mantle": "#e6e9ef",
"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": "#8839ef",
"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",
"#d20f39",
"#40a02b",
"#df8e1d",
"#1e66f5",
"#ea76cb",
"#179299",
"#5c5f77", "#5c5f77",
"#acb0be",
"#d20f39", "#d20f39",
"#40a02b", "#40a02b",
"#df8e1d", "#df8e1d",
"#1e66f5", "#1e66f5",
"#ea76cb", "#ea76cb",
"#179299", "#179299",
"#6c6f85" "#acb0be",
"#6c6f85",
"#d20f39",
"#40a02b",
"#df8e1d",
"#1e66f5",
"#ea76cb",
"#179299",
"#bcc0cc"
] ]
} }

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",

View File

@@ -2,82 +2,82 @@
theme[main_bg]="#1E1E2E" theme[main_bg]="#1E1E2E"
# Main text color # Main text color
theme[main_fg]="#c6d0f5" theme[main_fg]="#cdd6f4"
# Title color for boxes # Title color for boxes
theme[title]="#c6d0f5" theme[title]="#cdd6f4"
# Highlight color for keyboard shortcuts # Highlight color for keyboard shortcuts
theme[hi_fg]="#8caaee" theme[hi_fg]="#89b4fa"
# Background color of selected item in processes box # Background color of selected item in processes box
theme[selected_bg]="#51576d" theme[selected_bg]="#585b70"
# Foreground color of selected item in processes box # Foreground color of selected item in processes box
theme[selected_fg]="#8caaee" theme[selected_fg]="#89b4fa"
# Color of inactive/disabled text # Color of inactive/disabled text
theme[inactive_fg]="#838ba7" theme[inactive_fg]="#7f849c"
# Color of text appearing on top of graphs, i.e uptime and current network graph scaling # Color of text appearing on top of graphs, i.e uptime and current network graph scaling
theme[graph_text]="#f2d5cf" theme[graph_text]="#f5e0dc"
# Background color of the percentage meters # Background color of the percentage meters
theme[meter_bg]="#51576d" theme[meter_bg]="#585b70"
# Misc colors for processes box including mini cpu graphs, details memory graph and details status text # Misc colors for processes box including mini cpu graphs, details memory graph and details status text
theme[proc_misc]="#f2d5cf" theme[proc_misc]="#f5e0dc"
# CPU, Memory, Network, Proc box outline colors # CPU, Memory, Network, Proc box outline colors
theme[cpu_box]="#ca9ee6" #Mauve theme[cpu_box]="#cba6f7" #Mauve
theme[mem_box]="#a6d189" #Green theme[mem_box]="#a6e3a1" #Green
theme[net_box]="#ea999c" #Maroon theme[net_box]="#eba0ac" #Maroon
theme[proc_box]="#8caaee" #Blue theme[proc_box]="#89b4fa" #Blue
# Box divider line and small boxes line color # Box divider line and small boxes line color
theme[div_line]="#737994" theme[div_line]="#6c7086"
# Temperature graph color (Green -> Yellow -> Red) # Temperature graph color (Green -> Yellow -> Red)
theme[temp_start]="#a6d189" theme[temp_start]="#a6e3a1"
theme[temp_mid]="#e5c890" theme[temp_mid]="#f9e2af"
theme[temp_end]="#e78284" theme[temp_end]="#f38ba8"
# CPU graph colors (Teal -> Lavender) # CPU graph colors (Teal -> Lavender)
theme[cpu_start]="#81c8be" theme[cpu_start]="#94e2d5"
theme[cpu_mid]="#85c1dc" theme[cpu_mid]="#74c7ec"
theme[cpu_end]="#babbf1" theme[cpu_end]="#b4befe"
# Mem/Disk free meter (Mauve -> Lavender -> Blue) # Mem/Disk free meter (Mauve -> Lavender -> Blue)
theme[free_start]="#ca9ee6" theme[free_start]="#cba6f7"
theme[free_mid]="#babbf1" theme[free_mid]="#b4befe"
theme[free_end]="#8caaee" theme[free_end]="#89b4fa"
# Mem/Disk cached meter (Sapphire -> Lavender) # Mem/Disk cached meter (Sapphire -> Lavender)
theme[cached_start]="#85c1dc" theme[cached_start]="#74c7ec"
theme[cached_mid]="#8caaee" theme[cached_mid]="#89b4fa"
theme[cached_end]="#babbf1" theme[cached_end]="#b4befe"
# Mem/Disk available meter (Peach -> Red) # Mem/Disk available meter (Peach -> Red)
theme[available_start]="#ef9f76" theme[available_start]="#fab387"
theme[available_mid]="#ea999c" theme[available_mid]="#eba0ac"
theme[available_end]="#e78284" theme[available_end]="#f38ba8"
# Mem/Disk used meter (Green -> Sky) # Mem/Disk used meter (Green -> Sky)
theme[used_start]="#a6d189" theme[used_start]="#a6e3a1"
theme[used_mid]="#81c8be" theme[used_mid]="#94e2d5"
theme[used_end]="#99d1db" theme[used_end]="#89dceb"
# Download graph colors (Peach -> Red) # Download graph colors (Peach -> Red)
theme[download_start]="#ef9f76" theme[download_start]="#fab387"
theme[download_mid]="#ea999c" theme[download_mid]="#eba0ac"
theme[download_end]="#e78284" theme[download_end]="#f38ba8"
# Upload graph colors (Green -> Sky) # Upload graph colors (Green -> Sky)
theme[upload_start]="#a6d189" theme[upload_start]="#a6e3a1"
theme[upload_mid]="#81c8be" theme[upload_mid]="#94e2d5"
theme[upload_end]="#99d1db" theme[upload_end]="#89dceb"
# Process box color gradient for threads, mem and cpu usage (Sapphire -> Mauve) # Process box color gradient for threads, mem and cpu usage (Sapphire -> Mauve)
theme[process_start]="#85c1dc" theme[process_start]="#74c7ec"
theme[process_mid]="#babbf1" theme[process_mid]="#b4befe"
theme[process_end]="#ca9ee6" theme[process_end]="#cba6f7"

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