Compare commits

..

443 Commits

Author SHA1 Message Date
8f85097140 chore(lock): bump nomarchy-wallpapers a716cf6 → d4f5fbf
All checks were successful
Check / eval (push) Successful in 4m3s
The wallpapers repo's 'updates' commit gave each of the three v1 pair
themes a second background (1-/2-<slug>.png, replacing the single
ember-kiln/clay-kiln era files), but this lock still pinned the
one-wallpaper rev — downstream machines re-lock the transitive
nomarchy-wallpapers input from *this* lock, so SUPER+SHIFT+T
('bg next') cycled a one-item list and visibly did nothing
(hardware report, kiln-clay, 2026-07-18).

Targeted bump of the wallpapers input only — runtime-only content,
never read at eval (see flake.nix). Verified: pkgs.nomarchy-default-themes
builds, which runs the per-slug drift check (every theme json still has a
non-empty backgrounds/), and kiln + kiln-clay now list two files each.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-18 21:24:13 +01:00
036931ac51 fix(themes): light mode — the window glow tightens to a few px
All checks were successful
Check / eval (push) Successful in 4m15s
The shadow color is the palette mantle: near-black on dark themes (a
normal ambient shadow) but a light warm tone on light ones, where the
20px range reads as a wide glow that bleeds into neighbouring windows
and the bar (hardware report, kiln-clay, 2026-07-18). Light mode now
defaults the range to 5px — the glow stays as an identity feature,
just tight; dark themes keep the soft 20px shadow. Still lib.mkDefault,
so downstream overrides keep working.

Verified V1 (generation renders range=5 under kiln-clay); on-hardware
before/after of the live desktop taken in the reporting session.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-18 21:12:11 +01:00
8fdd9dc423 fix(session): recover user services after a broken logout teardown
2026-07-18 dev-box incident: on a warm relogin (user manager kept alive
by the tty1 greetd session — no linger involved) the previous logout's
graphical-session.target stop had been rejected as a destructive
transaction (easyeffects held a queued start job), so the target stayed
active across the logout. Its WantedBy services (cliphist, swaync,
xdg-desktop-portal-*, swayosd, …) crash-looped against the dead Wayland
socket into start-limit-hit, and the next login's plain
'start hyprland-session.target' was a no-op — every session service
stayed failed, doctor red, until a reboot.

Session bring-up (HM's systemd.extraCommands, rendered into the first
exec-once) now stops both hyprland-session.target and a possibly-stale
graphical-session.target, clears failed/start-limit state with
reset-failed, then starts fresh. All three commands are no-ops after a
clean logout or cold boot.

Verified V1: home generation built against this tree renders the new
command chain into hyprland.conf line 1 (nix build of
homeConfigurations.bernardo with the nomarchy input overridden).
V3 pending: warm relogin on hardware — queued in HARDWARE-QUEUE with
exact steps; BACKLOG #149 updated with the incident evidence (the
exec-once posture it watches is defending against a real race).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-18 21:11:39 +01:00
8e8a142465 fix(themes): light themes — readable inactive workspaces + notification hover
All checks were successful
Check / eval (push) Successful in 4m9s
Two hardware-reported contrast bugs, both the same family: surfaces
that fell back to something designed on dark.

1. Inactive workspace numbers unreadable on every light theme.
   - Generated bar: the @muted dim role is floor-gated at only 2.0:1 —
     fine as "dim" on dark, washed out at number size on light. Light
     mode now promotes inactive buttons to @subtext (3.0 floor,
     typically 4-7:1); dark themes keep @muted.
   - boreal-dawn + kiln-clay whole-swaps: same @muted -> @subtext
     (6.4:1 / 6.6:1).
   - summer-day whole-swap: had NO button color rule at all — the
     container's @fg does not inherit into GTK buttons, so the numbers
     took the GTK default over the cream chip. Explicit @fg_dim
     (= the palette's subtext, 4.0:1) added.
   - executive-ivory already used @subtext; untouched.

2. Notification popup unreadable on hover (bg turns dark, text stays
   dark). Root cause found by probing, not guessing: the popup body is
   one big GTK button (.notification-default-action) and swaync's
   DEFAULT stylesheet — still loaded under our style.css — hovers it to
   rgb(56,56,56). Our sheet never overrode that node (only the
   control-center row), so the default bled through; on light themes
   dark @text sat on it. Now the body + action buttons + close button
   are re-pinned to the palette's alpha(@text) tint construction —
   mode-proof by construction. The first fix attempt (row-level hover)
   was probed, SHOWN insufficient, and replaced.

Verification: V2. nix flake check --no-build, theme-wholeswap,
theme-contrast green. §3 evidence viewed: a purpose-built VM hover
probe under summer-day (notify-send -u critical, cursor parked ON the
popup, screenshot) — before: dark card, drowned text (reproduces the
report exactly, /nix/store/q1qf67fl...notify-shot-summer-day); after:
cream card + faint tint, fully readable (shot-notify2). Bar renders
verified under catppuccin-latte (generated light path incl. the mode
branch) and summer-day (edited whole-swap). An inactive-number visual
needs two live workspaces — VM shows one — so the on-hardware
confirmation is queued (HARDWARE-QUEUE light-theme refits entry);
the ratio change itself is machine-checked.

Bookkeeping riding along: BACKLOG #152 deleted (shipped in d79ad35).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-18 13:27:43 +01:00
d79ad3517e fix(waybar): #152 — inset the workspace pill, breathe at the bar's ends
The generated bar (every non-whole-swap theme; whole-swaps untouched):

- The active-workspace highlighter filled the bar's full inner height
  and visually overhung the rounded border. The pill now gets a 3px
  vertical inset margin (the boreal whole-swap's proven pattern) and
  the bar grows 34 -> 36 so the inset doesn't cramp the number.
- The end modules sat hard against the bar's rounded corners (the
  power button ~8px from the right curve vs ~28px inter-icon gaps).
  The Nomarchy mark and the power button get wider outer padding
  (14/16px); the item-28c group rhythm is preserved.

Verification: V2. nix flake check --no-build green. §3 protocol:
before/after theme-shot under kanagawa (generated palette) viewed at
2x crops — before: pill overhangs top+bottom, power glyph kisses the
corner; after: pill fully inside the bar, clear end gap
(before /nix/store/iwrvzzdd...theme-shot-kanagawa, after
/nix/store/za...see scratchpad shot-kanagawa-after.path). No-regression
capture under the summer-night whole-swap viewed — its chip identity
unchanged (whole-swaps replace both style and layout, out of scope
per the item). BACKLOG #152 closed: deleted per the finished-items
rule in the next bookkeeping touch of this push.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-18 13:22:52 +01:00
e0fba56ee6 feat(themes): executive-ivory gets the launcher grid — pair parity with 26dc6f6
All checks were successful
Check / eval (push) Successful in 4m18s
Upstream gave executive-slate the boreal-style 4x3 icon-grid launcher
while this batch was in flight; the ivory day sibling follows in the
same push so the pair stays shape-invariant. Geometry identical to the
slate grid (pure layout over the ivory rofi.rasi panel; the selected
card keeps the accent-edge highlight).

Verification: V1 here (layout-only file, colors inherited) + the
re-run checks on the rebased tree in this push; launcher grid on real
hardware is already in the batch HARDWARE-QUEUE entry.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-18 13:05:55 +01:00
786a8ad30e feat(themes): kiln + kiln-clay — the third exclusive pair, brand new
Completes the three dark/light pairs for v1 (batch: 0ba1762, e51c8a7).
Kiln is a potter's workshop at night, lit only by the kiln: warm
near-black (#1D1815), copper accent (#D68C5C, 6.5:1), brass accentAlt,
CaskaydiaCove, rounding 8. Kiln Clay is the same studio at midday:
cream plaster (#F4EDE3), espresso text (11.1:1 AAA), fired-copper
accent (#A34F1F, 4.89:1), brick-red bad. Both waybars are a NEW
whole-swap identity — 'the workbench': ONE continuous floating plank
(window#waybar carries the surface, modules transparent) — deliberately
distinct from boreal's three pills and slate's edge-to-edge strip;
module set verbatim-parity with the generated config. Matched rofi,
btop themes. The wallpapers share identical ring geometry — banked-coal
heat rings at night, terracotta glaze rings at noon — so the autoTheme
flip visibly rhymes.

Sync sweep in this commit (LOOP §5): README 24->28 presets + the
whole-swap list now names the three pairs; CONVENTIONS waybar parity
list gains the four new slugs; OVERRIDES autoTheme table points at the
pairs; HARDWARE-QUEUE gains the batch V3 entry (real-GL blur, btop
visual, launcher grid, tray menus, pair flip); BACKLOG files #153
(PROPOSED: per-theme desktop widgets — the conky idea, engine spike
needed, not v1).

Verification: V2. Full targeted set green on the final tree:
theme-contrast, theme-wholeswap (previews + backgrounds + CSS
self-containment), waybar-language, waybar-swap-env, rofi-text-width,
state-sync-validate, auto-theme VM; nix flake check --no-build green.
Visual protocol: theme-shot VM desktop + menu viewed for BOTH themes
(plank renders as one island, copper/terracotta semantics, legible
menus; /nix/store/2fizd945...theme-shot-kiln and
/nix/store/0126yd48...theme-shot-kiln-clay). audit-theme-design:
CVD-only findings (≤ executive-slate's own baseline); ANSI slots
in-family after the magenta retune. V3 pending: HARDWARE-QUEUE entry
above. btop visual is VM-blocked (GL terminal never starts under
softGL) — file-level assertions only, listed for hardware.

Design + wallpapers on Fable; assets by a Sonnet subagent to a written
identity spec; reviewed on Fable.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-18 13:05:24 +01:00
bcdd3e14ee feat(themes): executive-ivory — the light half of the executive pair
Second exclusive pair (see 0ba1762 for the batch). Executive Ivory is
the slate boardroom at 9 AM: the night skyline traded for paper, print
and daylight. Warm ivory base (#F2F0E9), ink text (12.1:1 AAA), the
steel blue deepened to letterhead weight (#2F5FBF, 5.25:1 as text),
JetBrainsMono + slate's exact 5/12/2/10 geometry. Assets: the same
solid edge-to-edge strip identity (ink on ivory, accent underline on
the active workspace), rofi, btop, and a line-web wallpaper that
mirrors slate's geometry in silver-on-paper with sparse blue threads.

Verification: V2, same evidence set as 0ba1762 (all targeted checks
green on this tree; theme-shot VM desktop + menu viewed — strip,
underline, paper panel all render legibly; screenshots in
/nix/store/5883sqfz...theme-shot-executive-ivory). Preview is the
480x270 VM render. V3 pending per the batch HARDWARE-QUEUE entry.

Design on Fable; asset port by a Sonnet subagent from the
executive-slate sources; reviewed on Fable.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-18 13:05:24 +01:00
0347156315 feat(themes): boreal-dawn — the light half of the boreal day/night pair
First of three Nomarchy-exclusive dark/light pairs for v1 (with
executive-ivory and kiln/kiln-clay in the next commits), built for
settings.autoTheme: same identity in two lights, so the sunrise flip
feels like the same room brightening.

Boreal Dawn is the boreal forest six hours later — the aurora faded
into a cold clear dawn. Frost-paper base (#EDF2F6), deep-slate text
(10.9:1 AAA), the aurora violet deepened to text-grade (#7458C8,
4.69:1), spruce teal accentAlt (#22766E), GeistMono + the same 6/14/3/12
geometry as boreal so the pair swaps shape-invariant. Assets: whole-swap
waybar (the same three frosted pills, light glass), rofi + 4x3 launcher
grid, btop theme, dawn-treeline wallpaper mirroring aurora-boreal's
composition (same spiky silhouette, dawn gradient, fading stars).

flake.lock: nomarchy-wallpapers 803b48d -> a716cf6 — the single-input
bump carrying backgrounds for all four new slugs (this task is the lock
bump). Preview is a real render: theme-shot VM desktop at 480x270.

Verification: V2. nix flake check --no-build green; checks
theme-contrast (28x7+chips), theme-wholeswap, waybar-language,
waybar-swap-env, rofi-text-width, state-sync-validate, auto-theme (VM
day/night engine) all green. Visual protocol: theme-shot VM renders
viewed for desktop + menu (frosted pills, wash selection, legibility —
screenshots in /nix/store/l26kfdcf...theme-shot-boreal-dawn). audit
findings at/below the boreal baseline (CVD-only + the same
accent-pair Δhue as boreal). V3 pending: real-GL blur, btop visual,
launcher grid on hardware (HARDWARE-QUEUE, this batch's entry).

Design by Fable (palette lab + wallpaper SVGs); asset port by a Sonnet
subagent from the boreal sources; reviewed file-by-file on Fable.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-18 13:05:24 +01:00
26dc6f605e feat(themes): executive-slate app launcher becomes the boreal-style icon grid
All checks were successful
Check / eval (push) Successful in 4m16s
New themes/executive-slate/launcher.rasi: @import the sibling rofi.rasi
whole-swap, reshape drun into the 4x3 horizontal-flow grid of 56px icons
(mirrors boreal's launcher), cards at the theme's 4px radius so the flat
control-panel identity holds; the selected card keeps the accent-edge
highlight. Text menus are untouched. Picked up automatically by
hasLauncherOverride in rofi.nix.

V2: headless VM capture (before: vertical list; after: grid renders,
filter/arrow/backspace drive it correctly). Boreal grid re-captured, no
regression. Guest rendering caveat per docs/TESTING.md section 5 applies
to polish judgments; layout and highlight behavior are the claims here.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-17 20:38:26 +01:00
f899b192fe fix(rofi): #143 — reset selection to row 0 on query edits (inputchange)
Upstream keeps the selected row *index* across a refilter (rofi#2317),
so backspacing grew the list back under a stationary highlight and Enter
launched the wrong app. The maintainer's reply pointed at the
inputchange {} config block — adopted as pure config in the generated
config.rasi (kb-row-first on every query edit), no patch, no source
build. Covers every rofi surface: drun, text menus, dmenu pickers.

V2: headless VM (theme-shot variant driving QMP keystrokes). On HEAD the
repro left the highlight on row 3 (kitty -> Bulk Rename); patched, the
highlight snaps to the first entry after backspace — verified in the
executive-slate drun grid, boreal's whole-swap grid, and the
keyboard-layout dmenu picker. nix flake check --no-build passes; the
rendered config.rasi carries the nested block.

Backlog #143 deleted (watch closed), decision recorded as Resolved,
ROADMAP checkmark added.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-17 20:37:54 +01:00
8a4914c22e docs(backlog): file #152 — generated waybar height + right-edge icon spacing
All checks were successful
Check / eval (push) Successful in 4m1s
Bernardo, filed live: the generated bar (non-whole-swap themes) should be
a bit taller so the selected-workspace highlighter stops overflowing it,
and the right module cluster needs consistent icon spacing + end padding
(power currently sits too close to the screen edge). Full §3 visual
protocol when taken.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-17 17:02:18 +01:00
6671a2b95d docs(hw-queue): wallpapers-split V3 PASSED — live render from the input store path
Some checks failed
Check / eval (push) Has been cancelled
Bernardo pulled, rebuilt and switched themes on the dev box; awww query
shows both outputs rendering summer-night from the nomarchy-wallpapers
store path. Queue entry closed; custom-override precedence is code-order
(THEMES_DIRS), not re-queued. Side catch during the same rebuild: the
reload re-lit eDP-1 inside the shut lid — expected until the relogin
that arms the #148 watcher fix; re-docked manually via the transition
tool.

Verification: V3 (human-in-the-loop on the incident hardware + CLI
evidence).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-17 16:58:34 +01:00
7df7563051 feat(themes): wallpapers split — 94 MB of runtime-only images leave the flake source
All checks were successful
Check / eval (push) Successful in 4m1s
The split line is consumption time: palette JSONs, previews and
per-theme overrides are read at Nix eval and stay in-repo (~3 MB);
wallpapers are runtime-only (swww via nomarchy-state-sync, never read
at eval) and move to the pinned nomarchy-wallpapers non-flake input
(git.bemagri.xyz/bernardo/Nomarchy-Wallpapers @ 803b48d, all 24 slugs).

New overlay drv nomarchy-default-themes merges the two: repo themes
copied, <slug>/backgrounds symlinked from the input, build FAILS if any
theme JSON lacks non-empty backgrounds — the drift guard for the
two-repo dance. nomarchy-state-sync's wrapper now points straight at
the merged store path (no in-package copy): the package drops from
~94 MB to 32.6 KiB and the wallpapers source appears exactly once in
any system closure, ISO included — offline promise intact. Downstream
custom wallpapers still win ($NOMARCHY_PATH/themes is checked first),
and an explicit state.json wallpaper path still works.

checks re-pointed at the merged tree (theme-wholeswap,
state-sync-validate, auto-theme); theme-contrast and airplane stay on
./themes (JSON/text only). import-palettes.py + README updated;
ROADMAP § Faster switches marked shipped; LATER now carries only the
pre-built-variants follow-on. Also files #151 (v1 launch plan, [human])
with measured history numbers: the 107 MiB pack is 94 MB wallpaper
blobs; all 848 text commits pack to ~15-20 MiB — recommendation
recorded to filter-repo at the GitHub move, not fresh-start.

Verification: V2 — nix flake check --no-build green; theme-wholeswap,
state-sync-validate, auto-theme VM check, downstream-template-home all
green; wallpapers store path exactly once in the state-sync closure;
`list` works from a scratch state dir. V3 queued: live wallpaper render
+ bg-next + custom-override precedence after next pull (HARDWARE-QUEUE).
Lock gained exactly the one input node. Implementation by a Sonnet
subagent; design and review on Fable.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-17 16:52:51 +01:00
a818c19a4e chore: v1-prep hygiene sweep — LICENSE (MIT), fixtures under tools/, doc staleness
All checks were successful
Check / eval (push) Successful in 4m21s
Three-way audit ahead of v1 (structure/necessity, docs-vs-code drift,
mechanical checkers) came back clean: zero dead files, zero doc hard
errors, all 92 options documented, all consistency checkers green, no
untracked files. What it did surface, fixed here:

- LICENSE was missing entirely — the one hard launch blocker. MIT
  (Bernardo's call), pointer at the README foot.
- checks/ was the only top-level dir outside the documented layout rule;
  its two state-legacy-name fixtures moved to tools/fixtures/ (Bernardo
  chose move over documenting an exception), flake.nix refs + drift
  messages updated.
- Doc staleness: two leftover "theme-state" leads (HARDWARE.md,
  REQUIREMENTS.md) now say state.json; VISION Theme C charge-limit row
  gets its ✓; VISION agent-slices datestamp bumped.
- The idle.nix miniTransition duplication (same binary name, lossy
  subset of the real display-transition tool) is deliberately NOT
  refactored here — filed as PROPOSED #150 with the audit evidence.

Wallpapers-in-repo (97 MB) is the known LATER artifact-split item, not
re-filed.

Verification: V0 — nix flake check --no-build green after the fixture
move (state-legacy-name's eval asserts exercise the new paths); doc
edits prose-reviewed. Audits by three Sonnet passes; triage on Fable.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-17 16:20:39 +01:00
07400ab4f9 feat(flake): #134 — unstable.<pkg> in the downstream, one input, no hand pins
All checks were successful
Check / eval (push) Successful in 4m16s
Nomarchy now carries a nixpkgs-unstable input and exposes it through
overlays.default as an `unstable` attrset: in the template's
`home.packages = with pkgs; [ … ]` a user writes `unstable.lmstudio`
and is done. Downstream stays one locked input; nomarchy-pull moves it.
lib.nix needed zero changes — mkFlake's pkgs already applies the
overlay. The import is plain and non-recursive, so configs that never
reference unstable. pay nothing beyond the input fetch; allowUnfree
mirrors the base set so the motivating lmstudio resolves.

Home packages are the supported surface (documented convention). Costs
stated in the README, not hidden: you own the pinned+unstable
combination; a second toolchain lands in your closure (~690 of ~4k
paths shared, measured on lmstudio); it moves when the lock moves.
Lock bumps now move two channels — VERIFICATION §5 and CONVENTIONS
updated accordingly. flake.lock gained exactly one node
(nixos-unstable @ 753cc8a); nothing else moved.

Verification: V1 — nix flake check --no-build green; new eval-only
checks.unstable-seam green (26.05pre vs 26.11pre lib.version, no
builds); template HM activation built via mkFlake with unstable.hello
enabled and its closure carries the unstable store path (pg2zfr… vs
pinned nm7p8w…). Implementation by a Sonnet subagent; design and
review on Fable.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-17 15:16:29 +01:00
2f5c1c8b99 docs: #127 CLOSED — the brick was hypridle eating its own resume path
All checks were successful
Check / eval (push) Successful in 3m41s
The incident was misdated: it did not happen before 12:51:34 Jul 15
(that boot was healthy all day) — it ended the previous boot at 10:54:22
with the power button. In that boot, running the pre-fix generation:
idle cascade on schedule (lock 10:21, dpms off 10:26, suspend correctly
skipped on AC), then hypridle logs "BUG THIS: inhibit locks < 0: -1" at
10:36:29 and its rule state is corrupted from there — the fired DPMS
listener's on-resume, then the session's ONLY dpms-on caller, never
dispatches again despite provable real input (two blind hyprlock PAM
attempts) and three output remove/re-add cycles. The wedged-compositor/
DRM-master lead is contradicted: Hyprland tracked hotplug live through
the whole window, PAM worked, kernel spotless. The shipped fix (a2151f4,
input wakes DPMS compositor-side) closes exactly this mechanism.

Filed upstream with the full trace: hyprwm/hypridle#208 (0.1.7; their
closed #104/#128 are the same class). Watch folded into #146.

Sweep: BACKLOG #127 deleted (NOW keeps only the install-findings context
stub, flagged to Bernardo); ROADMAP gains "The idle brick's CAUSE found"
and both stale "cause still open" tails patched; checked-off #127
HARDWARE-QUEUE blocks pruned, their method lessons moved to MEMORY
§ Gotchas.

Verification: V0 (docs/backlog only). The evidence is journal forensics
on the incident hardware (two Sonnet passes, verdict on Fable), quoted
in the ROADMAP entry; the fix itself was V3-proven 2026-07-16.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-17 08:05:09 +01:00
bbdc329ed6 feat(first-boot): post-install hardware hints — one self-gated toast, once
All checks were successful
Check / eval (push) Successful in 3m39s
After the #81 "you're set" card, at most one additional "Hardware tips"
toast: a Firmware line when fwupdmgr is on PATH, a Fingerprint line when
fprintd-list is (VISION § B). One-shot marker settings.hardwareHintsShown,
in-checkout like firstBootShown. When no tooling matches, the marker is
deliberately left unset — the re-check costs a command -v per session,
and a later-enabled fwupd or newly-added reader still gets its hint once.
Never a permanent nag; live-ISO gate covers the new stage too.

Lives inside nomarchy-first-boot: the card's semantics are unchanged
(retry loop, exit 1 so a failed notify retries next login, marker only
after success); hints run only once the card has landed. Docs: README
option row, HARDWARE.md hint paragraphs, VISION § B checked off.

Verification: V2 — shellcheck clean, nix flake check --no-build green,
nix build .#checks.x86_64-linux.first-boot green (bare node silent →
fwupdmgr appears → fires once → silent again → live-ISO still skips).
Real toast rendering is the same session surface as the proven #81 card.
Implementation by a Sonnet subagent; design and review on Fable.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-17 07:56:03 +01:00
5479ade702 fix(display): #148 — enforce dock intent without the auto-profiles toggle
All checks were successful
Check / eval (push) Successful in 3m37s
Every nomarchy-home re-lit eDP-1 inside the shut lid: HM activation fires
hyprctl reload, the catch-all monitor rule re-enables the panel, and the
tick invariant built for exactly this (#142, enforce_dock_intent) never
fired — it was gated on settings.displayProfileAuto, the *named-profile*
auto-switch toggle, which is unrelated to dock intent and off unless
opted into. Confirmed live on the incident machine: dockMode=true,
displayProfileAuto unset, watcher journal empty — every tick returned at
that guard.

Fix: drop the auto_on gate. settings.display.dockMode alone is the
user's recorded intent (written by the menu and the dock transition);
the profile-active check remains as the real guard (a named profile
outranks the dock heuristic). No fight with the #142 undock hammer:
undock clears the intent before its rescue reload, so the enforcer
stands down by ordering.

tools/monitor-fallback.nix grows the reload-mid-dock scenario: fake lid
closed, hyprctl reload, panel re-disabled within ~4s, action=re-dock
logged — under displayProfileAuto's default false, i.e. the exact
condition the bug shipped under.

Verification: V2 — nix flake check --no-build green, checks.docking-ux
green, monitor-fallback harness green three times (implementer twice,
reviewer once, ~71s VM). V3 pending: relogin + nomarchy-home while
docked-clamshell (HARDWARE-QUEUE; the watcher is exec-once, the fix
arms at next login). Implementation by a Sonnet subagent; diagnosis,
design and review on Fable.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-17 07:48:05 +01:00
e324876eb7 feat(doctor): #147 — flag machine state the flake never set; linger is the worked example
Some checks failed
Check / eval (push) Has been cancelled
/var/lib survives a migration untouched, and a stray pre-Nomarchy
Linger=yes marker shaped the QA box's session behavior for six months
with nothing in the flake to show for it. Ship the class as guards:

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

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

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

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-17 07:44:43 +01:00
8fc0a9bc29 docs: #114 ships as document-only; record 2026-07-17 triage rulings
All checks were successful
Check / eval (push) Successful in 3m50s
Live triage with Bernardo. #114 (greeter ignores per-device layouts):
ruled document-only — a kernel VT has exactly one keymap, so tuigreet
structurally cannot honour settings.keyboard.devices. README gets the
"Greeter keyboard layout" note after the home.nix options table,
RECOVERY.md §2 a one-line pointer for the password-rejected case,
ROADMAP's #145 cross-ref updated, BACKLOG entry deleted.

Also recorded: #120 stays deferred (cachix likely first step when
promoted); #134 decided — Nomarchy carries nixpkgs-unstable, unstable.*
via overlay, home-scope only — rewritten into NEXT; #143 upstream check
(rofi#2317 still open, no response) noted in the item.

Verification: V0 — docs and backlog only, prose reviewed in place.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-17 07:39:17 +01:00
bdc86d0f41 docs: RETRACT the #127 cause — the diagnosis was a measurement artifact; fix stands
All checks were successful
Check / eval (push) Successful in 3m30s
The "blank + hypridle deadlock = the brick" cause published this morning is
retracted everywhere it was asserted: ROADMAP, hyprland.nix's comment, #146,
HARDWARE-QUEUE and two commit messages (a2151f4, 321ccf7). It rested on two
pieces of evidence and both were artifacts of how I measured:

- "19 disconnects in 11 days": 18 of 19 are hypridle's noisy goodbye while
  systemd DELIBERATELY stops it — each bracketed by `Stopped hypridle` /
  `Started hypridle`, healthy replacement seconds later.
- "Jul 14: CRITICAL at 13:08:17, then eleven hours of silence": an artifact of
  grepping ONE PID. systemd logged `Stopped hypridle` 13:08:17 and `Started
  hypridle` 13:09:00; PID 1678 ran fine all evening, invisible to the filter.

Decisive: at the incident (before the 12:51:34 mitigation commit) hypridle had
NO disconnect at all and had been healthy since Jul 14 13:09. It should have
fired on-resume normally. The deadlock theory never had a case.

The FIX stands on its own evidence and is untouched: the options really were
off, hypridle's on-resume really was the only caller of `dpms on`, and input now
wakes the screen with hypridle deliberately stopped (4s docked / 6s clamshell).
Removing a proven single point of failure never needed a story about why the
daemon might fail — which is exactly why it survived the story being wrong.

#127 is RESTORED to NOW (deleted this morning as "shipped"; only the fix
shipped). Its cause is open and the lead is the dead Ctrl+Alt+F3 — neither
DPMS-off nor a dead hypridle can break VT switching, so something was wedged
below the compositor holding DRM master. Every theory so far has been about the
wake path, i.e. a symptom of the screen already being black.

#146 → [watch], near-dead: one genuine zombie ever (verified by ps/ss, not
grep); upstream #171 is real but its impact path closed when #147's stray linger
went. Re-measure properly before building anything; close unbuilt if zero.

Also closes the #127 hardware-queue entry: Bernardo ran the real 10-min idle —
19:27:58 dpms off (genuine timeout, shipped config, and it BLANKED = the revert
working) -> mouse -> 19:32:14 `done enabled=1 first=DP-2`, clamshell, the
incident geometry, one healthy hypridle throughout. All 4 steps pass.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-16 19:39:27 +01:00
90f0faf87b docs(backlog): #147 was misdiagnosed — the wiring exists; the bug was pre-migration linger
All checks were successful
Check / eval (push) Successful in 4m2s
Investigated #147 and the filing was wrong in the same direction as the 321ccf7
claim: it asserted something missing that is present. The repo DOES have the
session wiring — HM's wayland.windowManager.hyprland.systemd.enable defaults on
and is line 1 of the generated hyprland.conf — and it fired correctly. Rewritten
so nobody "fixes" it by adding what is already there.

What actually happened, per systemd at 17:25:54: hyprland-session.target
stopped, StopWhenUnneeded correctly tried to take graphical-session.target with
it, and systemd REFUSED the stop — xdg-desktop-portal-gtk had a 'start' job
queued from its own post-crash restart loop, making the stop a destructive
transaction. graphical-session stayed up from the morning; hyprland-session
returned 8ms later to find it active; nothing WantedBy it was re-pulled.

None of which a Nomarchy install can reach: it needs the user manager to survive
logout, i.e. Linger=yes — and that came from an empty marker dated 2026-01-24,
PRE-DATING Bernardo's migration to Nomarchy (custom flake at the time). Nothing
here sets linger; git log -S says it was never shipped, ever. Removed via
loginctl disable-linger (no restart needed, 0 failed units after); nothing wanted
it, as nomarchy-updates.timer is Persistent=yes and catches up on login.

The item is rewritten to the class that IS real: /var/lib is machine state, a
migration reconciles the flake and leaves the rest, so Nomarchy ran six months on
its own primary V3 box with a setting it never chose and could not see. That hits
the in-flake-state promise and V3's trustworthiness at once. Pitch is now a
doctor check for drift of this class + a MIGRATION.md note — explicitly NOT
hardening the session wiring against a race a clean install cannot reach.

Also corrects #146, which claimed an ordering dependency on #147 ("fail-loops
like cliphist"). Void — cliphist's fail-loop was the same stray state. #146 has
no dependency and can be taken first; its 19-in-11-days rate was measured with
linger on and should be re-measured before a guard is built.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-16 19:13:37 +01:00
fe6697bd22 docs: correct a false claim in 321ccf7, and file #148 (reload re-lights a shut lid)
All checks were successful
Check / eval (push) Successful in 3m20s
321ccf7 claimed the 4s step-4 run was "docked, lid OPEN" and that it retired
the old "docked lid-open is untested" caveat. Both wrong, per Bernardo: the
lid was SHUT throughout. eDP-1 was lit inside a closed clamshell because a
reload had re-enabled it, and "lid open" was inferred from an output being
enabled — which is not evidence of a lid position. /proc/acpi/button/lid/*/
state is, and it was never checked; it reads `closed` with eDP-1 enabled and
dpms_on=true right now. Docked-lid-open stays untested (moot — the gate is
gone — but untested). The step-4 result itself stands: input woke it in 4s
with hypridle stopped, with two outputs enabled rather than one.

That correction exposes a real bug, filed as #148: `hyprctl reload` re-enables
every output (the #142 hammer the undock rescue relies on), but
nomarchy-display-watch only reacts to monitoradded/monitorremoved — a reload
emits neither, so nothing re-asserts the dock profile. Every HM activation
reloads Hyprland, so every rebuild while docked-clamshell lights the panel and
leaves it lit. Bernardo hit it twice in ten minutes today: once from
nomarchy-home, once from an agent's reload that silently undid the fix he had
just made by hand in the Display menu.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-16 19:00:07 +01:00
321ccf7905 docs(hw-queue): #127 steps 1+4 pass from config — wake proven in all 3 geometries
Some checks failed
Check / eval (push) Has been cancelled
a2151f4 applied on the dev box (nomarchy-pull + nomarchy-home). Step 1: both
misc options read int: 1 after `hyprctl reload` — which discards runtime
keywords — so our Nix supplies them, not the keyword used to prove the fix
originally; nomarchy-dpms-off is gone from the closure and the live
hypridle.conf reads `on-timeout=hyprctl dispatch dpms off`.

Step 4, the one that matters: hypridle stopped on purpose (the brick
condition), `dpms off` -> keypress woke it in 4s from config alone. That run
happened to be docked with the lid OPEN (DP-2,eDP-1), which also retires the
old "docked lid-open is untested, not exempted" caveat. With the clamshell
6s run the same day, input-wake is now proven in all three geometries:
undocked lid-open, clamshell-docked, docked lid-open.

Steps 2+3 stay open deliberately: nothing yet has watched a genuine 10-minute
hypridle timeout fire — only forced blanks — so the real idle path is still
unobserved. Recorded that step 1 used reload, not a literal relogin.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-16 18:56:46 +01:00
a2151f424b fix(idle): #127 — input wakes the screen compositor-side; revert the clamshell skip
All checks were successful
Check / eval (push) Successful in 3m33s
The idle brick was never DPMS. Hyprland ships misc:key_press_enables_dpms
and misc:mouse_move_enables_dpms OFF, so hypridle's on-resume was the ONLY
caller of `dpms on` in the session — an idle daemon with the display held
hostage behind it. hypridle then deadlocks on an unfixed upstream bug
(hyprwm/hypridle#171, "Disconnected from pollfd id 1", open since 2025-09,
absent from 0.1.7 and main) and hangs WITHOUT exiting, so Restart=always
never fires and systemd still reports it active (running). Blank + deadlock
= a black seat no keypress can escape. That was the brick: two mundane bugs,
not one exotic DRM state. 19 disconnects in 11 days on the dev box, incl.
the incident day; the compositor is healthy through all of them.

Both options on, so the wake lives in the compositor where no daemon can
lose it — a dead hypridle now costs auto-lock, not the machine. The #127
clamshell DPMS-off skip (060bf52) is therefore reverted: idle blanks again
in every dock/lid state, and the 27" panels stop being held lit all night
under a static lock screen. #135 (an option to opt back in) is retired
unbuilt — there is nothing left to opt into.

TEST B's first run was invalid and nearly bought the wrong answer: it ran
`hyprctl dispatch dpms off` by hand, which no wake path watches, so "input
did not wake it" was guaranteed on any hardware. The undocked control
returning the SAME result — where the theory demanded a difference — is what
exposed it. Driven through hypridle instead, input wakes it in all three
configurations, including clamshell-docked on the sole live output.

Filed from the diagnosis: #146 (hypridle dies silently, systemd calls it
healthy — the surviving bug) and #147 (a re-login leaves session units dead;
graphical-session.target never re-enters, which is why Waybar already runs
from exec-once).

V3 on the incident hardware (AMD dev box, clamshell): hypridle deliberately
stopped, `dpms off` -> keypress woke it in 6s; the identical test before the
options stayed black the full 45s. V1: nix flake check, checks.option-docs,
checks.clamshell-logind, checks.docking-ux, checks.display-profiles; read the
emitted hyprland.conf/hypridle.conf rather than the source.

V3 pending: the fix is proven via runtime `hyprctl keyword`, not yet from our
own Nix — HARDWARE-QUEUE re-checks it after nomarchy-home + relogin.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-16 18:48:22 +01:00
c32b51897e fix(theme): #130 the chip moves, not the label; #129 pins the pair so it is checkable
All checks were successful
Check / eval (push) Successful in 3m44s
Light-theme accent buttons sat at 2.72:1 — under AA and under even the 3:1
large-text floor. Bernardo: fix it. The plan on file ("darken the label")
turned out to be IMPOSSIBLE, and measuring all 24 palettes is what showed
it: on a saturated mid-tone accent nothing clears 4.5 — cream base 2.72,
dark text 1.65. That is why adw-gtk3 ships white-on-accent at ~2.7. The
chip has to move.

The chip is now the accent pulled AWAY from the label: toward white when
the label is dark, toward black when it is cream. One factor (0.70), only
the anchor flips, so "solid accent button + base label" stays the design
and just shifts tone. Worst dark 4.86 (nord/bad), worst light 5.59
(summer-day). The old 0.90 chip failed SEVEN themes (miasma/bad 3.43,
rose-pine 2.70) — never only a light-theme bug, contrary to the item.

#129 rides along, and the two are one idea. #98 shipped an invisible button
with every check green because the background was not a palette role —
adw-gtk3 computed it from currentColor. So the guard is not a CSS lint
(which would flag the harmless generic-button rule and get muted) but
arithmetic: pin both ends and the pair becomes checkable.
check-theme-contrast.py now models GTK's own mix(a,b,f) = a + (b-a)*f —
READ from gtkcsscolorvalue.c:234, not assumed — and asserts base-on-chip
>= 4.5 for all 24 themes. Its negative test reproduces the item's reported
2.72/2.74 exactly from an independent model, which is the best evidence
available that the arithmetic matches the pixels.

Invariant to keep: pin the pair, or pin neither. Both chips are pinned now
(suggested + .default + destructive), where #98 pinned only destructive.

tools/dialog-shot.nix deliberately not built: the checker covers the
numbers, and a VM that only ever confirms arithmetic is maintenance for
nothing. Render if a new widget class appears.

Also sweeps #127 with the latch finding (ff5017e): symptom 2 solved, not
DPMS — which raises the odds the mitigation treats the wrong cause.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-16 14:55:17 +01:00
ff5017ea5d fix(display): a clamshell undock latched, so the NEXT undock never ran
All checks were successful
Check / eval (push) Successful in 3m47s
Bernardo, live, right after #127 TEST A: undocked → the panel stayed dark;
re-docked and undocked again → it came back. Re-docking is not what fixed
it. OPENING THE LID was.

awaiting_lid_open is set by every successful undock and cleared only when
the external is gone AND the lid is open — so a clamshell undock latches
it. Re-docking clears its sibling undock_pending ("a new external
supersedes any undock still queued for an older departure") but not this
one, and the clear cannot fire while an external is present. The next
departure then hits the `[ -z "$awaiting_lid_open" ]` guard, queues
nothing, and the panel never returns.

His journal is unambiguous — the first undock logged NOTHING (no
outputs-changed removed, no transition), then:
  14:42:31 lid-inhibitor=released      <- he opened the lid: latch cleared
  14:42:40 outputs-changed added= DP-2 <- re-dock
  14:42:47 outputs-changed removed= DP-2 transition=undock-queued
  14:42:49 transition=undock result=ok

Fix: clear the latch where undock_pending is already cleared — a new
external ends the departure the latch describes. Replaying his sequence
with the lid never opened: old = 1 of 3 undocks run, new = 3 of 3.

This is very likely #127's SECOND symptom ("unplugging did not turn the
laptop panel back on") and it has nothing to do with DPMS. Which raises the
odds that the shipped mitigation is treating the wrong cause — TEST B now
decides that, and with this fix an undock is a real escape hatch during it.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-16 14:47:08 +01:00
d31bdf05cd docs(agent): Bernardo's calls — #136 settled, #130 decided, #143 waits, #120 deferred
All checks were successful
Check / eval (push) Successful in 3m49s
#136 CLOSED: he confirms the incident was clamshell (laptop closed), so the
gate already matched the evidence and only the words overclaimed. Swept:
idle.nix's comment + its journal line (`dpms-off skipped: docked` →
`clamshell (internal $m present but off)`), the ROADMAP entry, and #127's
own text. Docked-with-lid-open still blanks and is now explicitly untested
rather than implicitly covered.

#127 gains the exact protocol he asked for — two tests, separated by risk:
A) the mitigation holds (safe, unattended, journal line to grep); B) the
decisive one, whether DPMS-off was ever the trigger, run by hand because the
shipped mitigation would otherwise skip it. B self-recovers after 45s
(dpms on + hyprctl reload — #142's hammer re-lights eDP) because this box
has no sshd. All three outcomes are written down with what each implies,
including "input wakes it" → revert the mitigation and #135 never exists.

#130 decided: fix the contrast, we hold ourselves higher than GNOME —
matching upstream is no defence at 2.7:1, under even the large-text floor.
Recorded with the two candidate shapes and a preference for darkening our
label over the user's accent, plus the note that check-theme-contrast.py
covers palette pairings only, so it needs its own assertion or it rots
exactly like #98.

#143 decided: wait for rofi#2317, do not carry a patch — a patched rofi
means every machine compiles rofi from source, on every lock bump, to fix a
highlight. Now a watch, not work.

#120 deferred to PROPOSED intact: the measurements stand, and the no-binary-
cache gotcha probably makes cachix the real first step anyway.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-16 14:24:47 +01:00
4a0b76cc97 feat(branding): drop the nixpkgs codename — "Nomarchy 26.05", not "(Yarara)"
All checks were successful
Check / eval (push) Successful in 3m43s
Yarara is nixpkgs' release name. Carrying it in PRETTY_NAME told users
something true about our upstream and nothing about our product, in the one
string every fetch tool and greeter reads.

Upstream hardcodes the codename into VERSION, PRETTY_NAME and
VERSION_CODENAME (misc/version.nix) with no switch, and
`system.nixos.codeName = ""` is the wrong lever — it renders "26.05 ()".
So the assembled strings are overridden through the merge hooks upstream
does provide (extraOSReleaseArgs / extraLSBReleaseArgs), which is already
where our project URLs live. lsb-release gets the same treatment; it
carried the codename in three fields of its own.

Empty VERSION_CODENAME/DISTRIB_CODENAME is deliberate: `//` cannot remove a
key, os-release(5) makes every field optional, and the file already ships
ANSI_COLOR="" and IMAGE_ID="".

nixos-version still prints "(Yarara)" and is left alone on purpose: that
command reports which nixpkgs the system was built from — the one question
the codename actually answers. Reaching it would mean patching a nixpkgs
script for no gain.

Verified on the built system: PRETTY_NAME="Nomarchy 26.05", VERSION="26.05",
VERSION_ID="26.05"; lsb DISTRIB_DESCRIPTION="Nomarchy 26.05",
LSB_VERSION="26.05". fastfetch's os line reads PRETTY_NAME, so it follows.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-16 14:09:00 +01:00
a6f86cf575 test(state): #133 — pin the #107 legacy-name shim (and its fail-closed gate)
All checks were successful
Check / eval (push) Successful in 4m9s
mkFlake prefers state.json and still accepts the legacy theme-state.json,
because a machine only migrates on its next menu write. Nothing evaluated a
checkout carrying ONLY the legacy name, so the shim could rot with every
check green — breaking rebuilds for precisely the users who have not opened
the menu since #107, i.e. the ones least likely to see it coming.

checks.state-legacy-name evaluates two fixtures: legacy-only must resolve,
and no-state-at-all must still throw. The gate is half the shim's contract
(it turns "no state file" into one readable error instead of a readFile
stack from inside the module system) and was equally unpinned.

Cheap by construction: mkFlake wraps its return in `builtins.seq
_themeState`, so forcing the attrset forces the state read and nothing else
— no module system, no home.nix. Hence a one-file fixture and no measurable
cost (flake check still ~35s). That file is a SYMLINK to the shipped
template's state.json: a copy would drift and the check would quietly start
testing a fossil. The fixture also asserts it has no state.json, or it would
pass while testing nothing.

Both assertions proven by breaking lib.nix — deleting the fallback, then
defeating the gate — and watching each fail by name. Delete this check
together with the shim and the nomarchy-theme-sync alias.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-16 14:04:16 +01:00
3cc644edd9 docs(testing): #132 — judge menu geometry by rendering; "just measure" was the trap
All checks were successful
Check / eval (push) Successful in 4m7s
The guest's unfaithful rofi geometry was already documented in TESTING.md
§5. What was not, and what this closes, is that the bullet's fallback
advice — "measure text against the 40% window width instead" — is exactly
how #131 stayed wrong for weeks, and my own #131 fix made it stale on top
of that (the width is calc( 84ch min 65% ) now).

Measuring needs a width and a font, and both are per-theme: text menus
render through themes/<slug>/rofi.rasi, where boreal + neon-glass pinned a
fixed 620px while the rest used 40%, in fonts from Inter 11 to
JetBrainsMono 14. The item measured one combination no theme ships,
concluded "only truncates below 1920", and the truncation was live on a
2560 panel.

So §5 now says: judge by rendering — hardware, or `rofi -dmenu -theme <the
built rasi>` + `hyprctl layers` for the real width — and notes
checks.rofi-text-width guards the invariant that makes it fit, never how it
looks. Declined the other half (fixing the guest's fontconfig/icon cache):
the screenshots are useful as content checks, and a guest that lied
convincingly about spacing would be worse than one that obviously cannot.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-16 13:32:49 +01:00
ff0f9d6359 fix(plymouth): #137 splash follows the canvas; #145 keyboard hint on the LUKS prompt
All checks were successful
Check / eval (push) Successful in 3m59s
The logo was off-centre on the external when booting/shutting down docked.
Both earlier diagnoses in this repo were WRONG, and that is the part worth
keeping: Window.GetWidth() returns max_width (not head 0), and per-head
sprites would have DUPLICATED the logo — the script plugin already builds
one max_width x max_height canvas, centres every display inside it
(script-lib-sprite.c:536) and draws sprites at (sprite.x - display.x), so a
canvas-centred sprite is centred on every head. The arithmetic was right.

The bug was TIME: every position was a top-level statement evaluated once
at parse time. A head arriving/leaving mid-splash resizes the canvas, the
plugin re-centres the displays, and frozen sprites end up off by
(new_max - old_max)/2 on every head. One monitor never resizes the canvas —
hence "only when docked". Fix: one layout(), re-run from the existing
refresh callback on canvas change.

The trap that cost two attempts (now a comment): in plymouth script a bare
assignment inside a function writes the GLOBAL if that name already exists
globally. So `canvas_width = Window.GetWidth()` updated global.canvas_width
BEFORE the guard compared against it — always false, body never ran, splash
rendered as a bare background, NO error logged. Isolated with a 20-line
probe. Hence cw/ch.

#145 rides along: a VT loads one keymap and knows nothing of per-device
layouts, so the passphrase box types with the console layout — worth saying
before three wrong tries on a disk you cannot read (same gap as #114, one
step earlier). Fedora's mechanism does not port: ply_keymap_icon is a C
widget in the two-step plugin (the script plugin has no keyboard API), fed
from XKBLAYOUT in /etc/vconsole.conf, which NixOS never writes (plymouth's
trace says `XKBLAYOUT: (null)`). So @LAYOUT@ is baked from
services.xserver.xkb.layout like the palette — the FIRST of a comma list,
since that is what the VT loads. Icon is plymouth's own keyboard.png (the
same glyph Fedora shows), copied at build time so no GPL bytes enter the
repo, recoloured to subtext.

chmod +w after that copy: store files are 444 and recolor rewrites in
place. Without it magick fails, the phase aborts, and EVERY LATER SED
SILENTLY DOES NOT RUN — the theme shipped with @BG_R@ literals and I only
caught it by reading the build log.

Verified by render (tools/plymouth-preview.sh, real built theme): both
heads centred, password dialog showing padlock + entry + keyboard icon and
"us". V3 queued — only a real docked boot can resize a canvas.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-16 13:14:05 +01:00
121b91f69c docs(agent): file #145 — show the keyboard layout on the LUKS prompt
All checks were successful
Check / eval (push) Successful in 3m47s
Bernardo, and it is the right ask for this machine: a VT has one keymap and
no per-device layouts, so settings.keyboard.devices (his external board
remembered as `us`) does NOT apply at the passphrase box — it follows
xkb.layout via console.useXkbConfig. Same structural gap as #114
(tuigreet), one step earlier in the boot, with worse stakes: three wrong
tries on a disk you cannot read yet.

Fedora's mechanism does not port, twice over: its label is ply_keymap_icon,
a C widget in the two-step plugin (the script plugin has no keyboard API at
all), and it is fed from XKBLAYOUT in /etc/vconsole.conf — which NixOS does
not write. This box has only KEYMAP=<store path>, and plymouth's own trace
says `XKBLAYOUT: (null)`.

So bake it like the palette already is: @LAYOUT@ sed into nomarchy.script
from config.nomarchy.keyboard.layout, drawn with Image.Text (already used by
display_message_callback) inside display_password_callback. Label and
console keymap then come from one option in one rebuild.

Flagged for whoever takes it: keyboard.layout can be a comma list and the VT
loads the FIRST — print that, or the label lies in exactly the case it
exists for. Bundle with #137: it is one more sprite to reposition on canvas
change, and tools/plymouth-preview.sh already renders ask-for-password —
this very screen.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-16 12:51:16 +01:00
0cd6212b9c tools: preview the plymouth splash unprivileged; #137 attempt 1 reverted
All checks were successful
Check / eval (push) Successful in 3m49s
Answering "how do I give you root": you don't. tools/plymouth-preview.sh
renders the splash — LUKS dialog included — into X11 windows in ~10s with
no reboot, no root and no DRM:

- unshare -rm = user + mount namespace; every mount dies with it.
- /run/plymouth is BOTH the socket dir and the compiled-in plugin path
  (`strings plymouthd` → /run/plymouth/plugins/), so we bind a writable dir
  over it AND recreate its symlinks — miss those and plymouth can't find
  x11.so, silently skips X11 and reaches for a tty.
- never pass --tty (main.c:2133 takes the X11 path only while default_tty
  is unset); /dev/dri is masked so a preview physically cannot mode-set a
  real output — which matters on a docked session (#127).
- PLY_CREATE_FAKE_MULTI_HEAD_SETUP fakes 800x600 + 640x480 heads.
- debug wants plymouth.debug=stream: — `file:` buffers and yields nothing.

What it proved immediately: with a STABLE two-head canvas the current theme
centres correctly on both heads. So the geometry is fine and #137 really is
the timing bug (positions frozen at parse time vs a canvas that resizes
when a head arrives) — recorded in the item.

And it caught my own fix: hoisting positions into layout() rendered the
background on both heads and nothing else — no logo, no bar — with NO parse
error logged. Reverted rather than shipped, because that script draws the
passphrase box on an encrypted machine. #137 carries the failure, the prime
suspect (plymouth script scope: assignment lands in `local` unless the name
already exists) and the fast edit/render loop for the next attempt.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-16 12:42:31 +01:00
3e6cc6fe84 docs(agent): #137 re-diagnosed from plymouth's source — my first read was wrong
All checks were successful
Check / eval (push) Successful in 3m49s
The entry claimed Window.GetWidth() returns head 0 and that the fix was
per-head sprites. Both false, and per-head sprites would have REGRESSED it
into duplicate logos. From plymouth.src (the build nixpkgs ships):

- Window.GetWidth() with no index returns data->max_width — the widest
  head (script-lib-sprite.c:227), not head 0;
- the plugin builds a max_width x max_height canvas and CENTRES every
  display inside it (display->x = (max_width - width)/2, :536), drawing
  sprites per display at sprite->x - display->x (:452);
- so a canvas-centred sprite IS centred on every head, and today's math is
  correct at the instant it runs. (The DRM renderer's area.x = 0 for every
  head is a red herring — the offsets that matter are the plugin's.)

The real bug is TIME: nomarchy.script computes every position as a
top-level statement at parse time, against the canvas that existed then. A
display arriving or leaving mid-splash — exactly "boot/shutdown with an
external" — changes max_*, the plugin re-centres each display, and the
frozen sprite coords are then off by (new_max - old_max)/2 on every head.
A single monitor never changes the canvas, which is why it was never seen
undocked.

Fix shape recorded (reposition from the existing refresh callback on
canvas change). Not shipped: the same script draws the LUKS passphrase box
on an encrypted machine, and the render rig — plymouth ships x11.so plus a
built-in PLY_CREATE_FAKE_MULTI_HEAD_SETUP — needs root.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-16 11:57:54 +01:00
30e632ff65 fix(display): #142 — dock mode is an intent, and survives a rebuild
All checks were successful
Check / eval (push) Successful in 3m48s
Every rebuild while docked re-lit the panel and threw a workspace onto
it. Not forgetfulness: a rebuild PERFORMS an undock. Dock mode lived only
in the compositor (`keyword monitor <internal>, disable`, nothing written
down), the config's only monitor line is the catch-all
`monitor=,preferred,auto,1`, and rewriting the config makes Hyprland
reload it — re-enabling every output. Our own undock path already uses
`hyprctl reload` as exactly that hammer (hardware-proven 2026-07-14).

The watcher could not rescue it: reconcile diffs `monitors all`, which
lists disabled outputs, so the connected set is identical across the flip
— structurally blind, the blind spot of rounds 4-5's "the output set is
the truth".

So the intent is recorded where every other setting lives —
settings.display.dockMode, in-flake, menu-writable — written at the one
choke point both menu and watcher pass through (transition: dock → true,
undock → false, `enable` of an internal → false, because "Screen on" IS
leaving dock mode and must not be undone a second later). The watcher
re-asserts it as a tick invariant rather than an event reaction, which is
what makes the reload case work; auto_dock was already idempotent, so it
costs one `hyprctl monitors` per tick.

The trap avoided: baking the disable into the config would survive a
reload and hand a user with no external a zero-output session at login —
the #100/#127 brick. The config cannot know what is plugged in, so it
keeps defaulting to "everything on" and the re-assert is gated on an
external being present.

Verified: 9/9 gates unit-tested by driving the REAL generated function
against stubs (incl. no-external → never re-dock, profile active → yield,
already-docked → no churn), plus the state write/read round-trip on a
throwaway checkout so the live flake was untouched. V3 queued — needs a
relogin, the watcher is exec-once.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-16 11:51:52 +01:00
02ec0f10cd docs(migration): #144 — Mozilla profiles.ini, and why NOT to restore a snapshot
All checks were successful
Check / eval (push) Successful in 3m45s
A real migration (2026-07-16) lost ~/.thunderbird/profiles.ini, so
Thunderbird made an empty profile and opened it while 30 GB of mail —
19 GB ImapMail, 64 account prefs — sat in the directory it had stopped
looking at. The fix was eight lines.

MIGRATION.md already warned about this class for Chromium extensions.
The Mozilla case gets its own bullet because it is the inverse failure
and the inverse danger: Chromium silently deletes extension data and
looks minor; Thunderbird loses nothing and looks catastrophic — so the
reflex it provokes, restoring a snapshot over a good profile, is the one
thing that can actually cost you mail. The bullet therefore leads with
`ls`/`du` ("a multi-GB dir = your data is fine"), then gives the working
profiles.ini shape and the Path=/Default= semantics.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-16 11:41:28 +01:00
207b6f3c64 feat(menu): #140 — Ask opens a web chat, provider is in-flake state
All checks were successful
Check / eval (push) Successful in 3m57s
Bernardo: "opening claude-code is too disruptive". SUPER+CTRL+A spent a
terminal, an npm fetch and a REPL on "what's the syntax for…" — the prompt
was the right surface, the destination was not. The query is now
URL-encoded into the provider's web chat and handed to xdg-open.

settings.ask.provider (claude|chatgpt|gemini|grok, default claude) with a
Preferences row, read from state at RUN TIME so switching needs no
rebuild, and any unknown/absent value falls back rather than opening
nothing. He asked for chatgpt + grok alongside claude/gemini, and for the
CLI to go: claude-code is deleted and pkgs.nodejs leaves with it (carried
only for that npx). Keybind is "Ask AI" now the destination is the user's.

The failure mode is the design point: prefill is undocumented on all four
vendors, each can drop `?q=` unilaterally, and it fails SILENTLY — you
land in an empty chat having already typed the question. So the query is
also copied to the clipboard (a vendor breaking change costs one paste,
not the question), and the URLs sit in one attrset so the repair is one
line.

Verified against the generated script: bash -n clean; every bad state
value (unset/null/garbage) falls back to claude; a hostile query encodes
correctly (& → %26, ? → %3F, # → %23, $(whoami) neutralised, café →
caf%C3%A9). NOT verified on purpose: whether each vendor fills the box or
auto-submits — that means posting into Bernardo's own accounts.

Sweep: ROADMAP's shipped-modules line described the npx REPL; nix-ld's
comment cited claude-code as its example; both fixed. Menu row + keybind
renamed.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-16 11:37:15 +01:00
48e73ab6d3 docs(agent): #143 reported upstream — rofi#2317
All checks were successful
Check / eval (push) Successful in 3m53s
Filed as bemagri against davatorium/rofi with the 2.0.0 line numbers
(view.c:861 → listview.c:622-631 → :646-651) read from the exact source
nixpkgs builds, not the next branch. No duplicate: the nearest, #1064,
is the opposite request (keep the selection after EXECUTING an entry —
closed, milestone 1.7.4), which is also why the report scopes itself to
the filter-changed path and sends no patch: that behaviour is deliberate
for reloads and the scoping call is the maintainer's.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-16 11:25:19 +01:00
2e433ad088 docs(agent): file #143 (rofi keeps the row number) and #144 (migration: profiles.ini)
All checks were successful
Check / eval (push) Successful in 3m47s
#143 — Bernardo: type `ca`, move onto ChatGPT, backspace once, and the
highlight sits on GHex while Slack is row 1. Upstream rofi 2.0.0, not our
config: listview_set_num_elements() re-asserts the OLD index —
`listview_set_selected(lv, lv->selected)` — and set_selected only clamps
(`MIN(selected, req_elements - 1)`). So the ROW NUMBER survives a
refilter, never the entry, and nothing checks it still matches the query.
Typing more hides it; backspacing exposes it. No option controls it
(auto-select is a different feature). Correction to the report: it is the
listview widget, so every rofi surface does it — drun is just where the
list is long and (sort = true) reorders each keystroke.

#144 — "I lost all my thunderbird accounts and emails" was a missing
profiles.ini. The profile was untouched: 30 GB, 64 account prefs, 19 GB
ImapMail, last used 2026-06-26; TB found no profile list and made an
empty one. Fixed live in ~8 lines (backup kept). MIGRATION.md already
warns about this exact class for Chromium extensions but never names
Mozilla's profiles.ini — which deserves its own bullet because it is the
inverse: no data lost, looks catastrophic, and the tempting fix (restore
a snapshot over a good 30 GB profile) is the actual risk. Firefox here
has the same fingerprint (hm-bak from Jun 2025 names a different profile
than today's ini) — left alone; repointing it is Bernardo's call.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-16 10:29:09 +01:00
5aad3153ce fix(menu): #131 — size text menus in ch with a cap, not in % of the monitor
All checks were successful
Check / eval (push) Successful in 3m49s
Bernardo saw Recovery ellipsize on a 2560x1440 external, which falsified
the item's model (40% + Inter 11 → "only below 1920"). Real cause: text
menus render through themes/<slug>/rofi.rasi, where boreal + neon-glass
pinned width: 620px (fixed — a wide panel buys nothing) while seven used
40%, and the font is whatever that file says — mostly monospace, far
wider than the Inter that was measured. The modelled combination ships in
no theme.

The insight: a menu must fit its longest label, which is a count of
characters in the THEME's font — so the window must scale with the font,
not the screen. 40% gives a 1366 panel 546px and a 2560 one 1024px for
the same row, and a 14pt mono theme needs ~25% more room than an 11pt one
on both; a percentage cannot see either fact.

Rofi has the right unit (`ch` = width of one digit in the current font)
and `calc( a min b )` to cap it, so every text menu — generated and all
nine whole-swaps — is now `width: calc( 84ch min 65% )`. 84ch fits the
longest row we ship (69 chars) plus icon and padding; the cap is
Bernardo's condition (never sprawl on a low-res panel) and it is
measured, not assumed.

Verified on hardware, not by arithmetic: 84ch = 756px in GeistMono 11 and
924px in JetBrainsMono 14; `calc( 84ch min 300px )` → 300px, so the clamp
really clamps. Screenshots at both ends — the real Recovery menu at 756px
(29% of 2560) with every label complete, and the Acer worst case
reproduced pixel-exactly (888px = 65% of 1366, JetBrainsMono 14) also
complete. Grid views override width per-invocation and are untouched.
checks.rofi-text-width guards the class, proven by pinning boreal back to
620px and watching it fail by name. Acer V3 queued for its own fontconfig.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-16 10:20:49 +01:00
39c8b3e0eb docs(agent): #131 remodelled — truncation is not the Acer's problem
All checks were successful
Check / eval (push) Successful in 7m20s
Bernardo saw the Recovery labels ellipsize on a 2560x1440 external, which
falsifies both halves of this item's model (width: 40% + Inter 11 →
"only below ~1920, confirm on the Acer"):

- text menus render through themes/<slug>/rofi.rasi, and boreal (his) +
  neon-glass pin `width: 620px` — fixed, so a wide panel buys nothing;
  the other seven use 40%. launcher.rasi's 780px is a red herring (it
  themes -show drun only).
- the font is whatever that file sets — boreal GeistMono 11, summer/
  kanagawa JetBrainsMono 14, nord/lumon/retro-82 13 — mostly MONOSPACE,
  much wider than the Inter the item measured. Only the generated theme
  follows fonts.ui.
- so the modelled 40% + Inter 11 is shipped by no theme, and truncation
  is most theme x monitor pairs, not an edge case.

Measured for boreal (9.33px/char off kitty at the same font/size, 518px
room): 3 of 4 rows cut, "What changed" fits — "some of the submenus",
exactly as reported.

Names the decision instead of taking it: fitting the narrowest combo we
ship (40% @ 1366 + JBMono 14) is ~39 chars, which cannot hold scope AND
cost — so "just shorten the labels" would gut #111 for everyone on a big
screen. Options: (a) width in `ch` so the window scales with the font
(fixes the class, 9 theme files), (b) cost to a rofi -mesg line, (c)
shorten and lose the hint.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-16 10:07:43 +01:00
55a516e1c7 fix(ui): #139 sheets size themselves; #141 waybar clicks stop reading the env
Some checks failed
Check / eval (push) Has been cancelled
#139 — calcurse/doctor floated full-screen. Two faults stacked, which is
why it looked like the float rules were dead:
  1. Hyprland 0.55.4 silently ignores percentage `size` rules. Matrix on
     hardware (both orders, kitty memory off): `size 60% 65%` never
     applies, `size 1536 936` applies either way, configerrors empty both
     times. Rule order was a red herring.
  2. Kitty defaults to remember_window_size=yes and replays the last OS
     window's size into every float — so after a tiled terminal, a sheet
     opens maximized. This is the Ghostty regression: Ghostty had no such
     memory, so fault 1 stayed invisible until #95.

Fix: remember_window_size=no (floats must be deterministic) and the sheets
ask for their own size — term-sheet.nix reads the focused monitor and
hands kitty the px, because px in a rule cannot mean "a fraction of *this*
screen" and one pair cannot serve 2560x1440 and the 1366x768 Acer (#131).
Those windows keep float/center and deliberately carry no size rule.

#141 — the updates click did nothing: whole-swaps hand-wrote
`sh -c '$TERMINAL …'` and Waybar has no TERMINAL (home.sessionVariables →
login shells only; the bar is spawned by Hyprland). Fixed at the root:
`nomarchy-updates upgrade-window` opens its own window, so all five call
sites name one env-free command and a theme file stops having an opinion
about terminals. checks.waybar-swap-env guards the class.

Bernardo asked mid-task for the update window to float — it is now the
third caller of the sheet helper (com.nomarchy.updates, 45%x50%).

V3 on the dev box: calendar 1536x936, doctor 1408x1008, updates 1152x720
— exactly the intended fractions, floating and centred; #141 driven under
`env -i` with no TERMINAL, matching Waybar's real environment; the guard
proven by reintroducing the bug. Acer V3 queued.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-16 10:04:28 +01:00
4a63f27bf4 docs(agent): file #142 — a rebuild while docked performs an undock
All checks were successful
Check / eval (push) Successful in 3m49s
Bernardo: every rebuild while docked re-lights the panel and throws a
workspace onto it; he re-picks Dock mode by hand. Static diagnosis, three
steps, each already proven by our own code:

- dock mode is runtime-only (`keyword monitor <internal>, disable`,
  hyprland.nix:205) and absent from state.json — the one display setting
  outside the in-flake state;
- the config's only monitor line is the catch-all `,preferred,auto,1`, so
  a reload re-enables every output. The undock path already uses `hyprctl
  reload` AS the hammer that re-enables a disabled panel (hardware-proven
  2026-07-14) — so a rebuild performs an undock by construction;
- reconcile diffs `monitors all`, which lists disabled monitors, so the
  set is unchanged across the flip and the watcher never sees it. Blind
  spot of round 4/5's "the output set is the truth".

Notes the trap: baking the disable into the config is a zero-output
session at login (the #100/#127 brick). Persist intent, re-assert from
the watcher, gate on an external being present.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-16 09:49:49 +01:00
9e64358225 fix(audio): #138 — reprobe escalates to a graph restart, never opens with one
All checks were successful
Check / eval (push) Successful in 3m53s
Bernardo's Meet calls lost their microphone because our own dock-audio
reprobe restarted PipeWire/Pulse/WirePlumber on every monitoradded (#100).
Chromium's audio service does not reconnect when the server disappears
under it, so it kept running and enumerated nothing; Zoom, started fresh
into a settled graph, looked fine and made it read like a browser bug.

reprobe now escalates cheapest-first — select → repair a parked card's
profile → restart the graph — so an ordinary plug never touches the graph
and no client loses its connection. wait_for_dock_sink polls for the sink
the plug is about to publish, so "not yet" is not mistaken for "not ever"
and cannot fall through to the restart. The restart stays as rung 3 for
the codec that only publishes its route after a re-probe: the one claim
the original comment made, and the only one it never demonstrated.

Verified on the dev box against the live graph: rung 1 selects the same
sink the restart-first path picked (HiFi__HDMI1__sink) in 2.0s instead of
~3.5s with pipewire/pulse/wireplumber MainPIDs unchanged; rung 3 reachable
and restarts+retries. Diagnosis proof was ordering across four boots —
restart-after-Chromium in both broken sessions, 24s before it in the
healthy one — and Bernardo reproduced on demand with a dock/undock.

Sweep: BACKLOG #138 deleted, ROADMAP gets the design record (incl. the
stale restart-first sentence in the #100 entry), V3 queued on the dev box
(relogin first — the watcher is exec-once), journal entry.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-16 09:43:14 +01:00
44aac0fcde docs(agent): #138 root-caused — our own reprobe restarts the graph under Chromium
All checks were successful
Check / eval (push) Successful in 3m49s
Bernardo's hypothesis (pipewire restarts, Chromium never reconnects) is
right, and the journal names the restarter: dock-audio.nix:153 runs
`systemctl --user restart pipewire pipewire-pulse wireplumber` on every
monitoradded (#100). Chromium's audio service does not reconnect, so it
enumerates nothing — Meet's "no mic or speakers".

Ordering across four boots is decisive, and Bernardo asked for exactly
this check: restart-after-Chromium in both broken sessions (boot -3
14:06:49 → 14:07:52; boot -2 11:10:34 → 11:31:22), restart-BEFORE in
today's working one (08:04:19 vs 08:03:55, a 24s miss). Hibernate is not
the cause, only how a Chromium lives long enough to meet a plug event.

Retiers off [human] — no repro needed, and it predicts a live one:
dock/undock now should break Meet in this session. Fix is ours, not
Chromium's: the graph restart is inherited from "the old working flake",
never shown necessary, and repair_dock_cards already handles the parked-
card case without it.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-16 09:32:28 +01:00
a40f99e417 docs(agent): file #137–#141 from the T14s round (three root-caused live)
All checks were successful
Check / eval (push) Successful in 4m12s
Bernardo's 2026-07-16 observations, filed with the diagnosis done on the
machine rather than as symptoms:

- #137 plymouth: unindexed Window.Get* → whole splash placed on head 0's
  geometry; off-centre on any second monitor (LUKS prompt included).
- #138 [human] Meet claims no mic/speakers: audio graph + Flatpak sandbox
  ruled out live; needs the browser named and the failure in front of us.
- #139 terminal floats full-screen: % size rules silently no-op on
  Hyprland 0.55.4 (absolute px applies), and Kitty replays its remembered
  window size — the second half is the Ghostty regression from #95.
- #141 waybar updates click dead: whole-swaps use $TERMINAL, which is
  absent from Waybar's env; 4 jsonc lines, a parity break not a bug.
- #140 PROPOSED: Ask Claude → web chat instead of the claude-code REPL.

Also gives #127 its "if diagnosis fails" branch: a screensaver while
docked, which is the panel-protection consolation prize, not a new item.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-16 09:23:10 +01:00
64f7379982 docs(agent): file #135/#136 — give the #127 DPMS mitigation an owner
All checks were successful
Check / eval (push) Successful in 3m53s
Reviewing 060bf52 at Bernardo's request. The call is right — the failure
mode is a brick (no wake, no VT, hard power-off with the btrfs risk that
carries), so prevent-over-recover beats a lit panel. Three things it leaves
open, now tracked instead of remembered:

#135 — the mitigation is hardcoded policy with no way back. Docked externals
never blank and hyprlock has no timeout, so they stay lit indefinitely:
~60-100W nightly and OLED burn-in from a near-static lock screen. Add
settings.idle.dpmsWhenDocked (default false = today's behaviour), mostly so
the mitigation has a named surface to DELETE once wake is proven, rather
than a LATER note that quietly becomes the product. Ordered after the
hardware test: if the mitigation is withdrawn, the key never exists.

#136 — the gate is clamshell, not "docked". It skips only when an internal
is present-but-not-enabled; lid-open docked still falls through to dpms off.
Commit body, BACKLOG and journal all claim the broader "docked". Either the
scope is deliberate (fix the wording) or the mitigation misses half the
docked cases. Bernardo's call; the hardware test settles it.

Also recorded in #127 the caveat that outranks both: the fix is a cure for
an UNCONFIRMED cause. If DPMS-off is not the trigger, we hold the cost and
still have the brick.

No code change — #127 stays [blocked:hw] pending the hardware run.
2026-07-15 18:25:47 +01:00
be636e14b9 docs(agent): PROPOSED #134 — unstable.<pkg> in the downstream
All checks were successful
Check / eval (push) Successful in 3m45s
Bernardo wants a newer LM Studio and rejected the only mechanism that works
today (a hand-pinned fetchTarball rev+sha256 in home.nix — verified working,
but a second unlocked pin and exactly the Nix expertise the distro exists to
spare its users). He is taking stable 0.4.15-2 for now.

Records why there is no seam (mkFlake takes src/username/hardwareProfile/
system; homeConfigurations uses mkFlake's own pkgs, so a system.nix overlay
cannot reach a home package), the sketch he asked for (unstable.lmstudio,
maybe stable. as an explicit synonym), and the shape that keeps the one-input
promise: carry nixpkgs-unstable upstream and expose it via overlays.default,
so downstream stays single-input and locked.

Costs left open for triage, not decided: two-channel lock churn against the
full-checklist rule, closure duplication (measured: the two lmstudio build
closures share 690 of ~3.7-4k paths), the weakening of "tested together
upstream", whether an unreferenced input should lock at all, and whether the
seam covers system.nix too. Cheaper fallback noted (mkFlake `overlays ? []`).

PROPOSED per BACKLOG rules — awaiting triage, not implemented.
2026-07-15 18:00:09 +01:00
9a280b2e18 fix(state): the "rename it" tip led into the error it was fixing
All checks were successful
Check / eval (push) Successful in 3m51s
Verifying Bernardo's #107 upgrade path (legacy theme-state.json checkout →
new code) turned up a footgun in state-read.nix's own help text. It said:

  • Still on theme-state.json? Rename it to state.json (…)

On a git flake only tracked files exist, so a hand-rename leaves state.json
untracked and therefore invisible — producing exactly the "state file is
missing" error the tip hangs off. Point at the menu write (which stages the
rename via git add --intent-to-add + git rm), name `git add state.json` for
the manual path, and add a bullet for "file is on disk but eval says
missing", which is otherwise a genuinely confusing dead end.

Message verified by rendering the real failure, not by reading the source.

The rename itself is safe and needs no code change: with identical content,
theme-state.json and state.json produce byte-identical derivations (HM
2yqqy8h…, system 9qp9l3g…), so it cannot change what boots. Legacy eval,
the migration write, and re-eval on a dirty/intent-to-add tree all pass.
#133 filed: no check pins the compat shim, so it can rot before we drop it.
2026-07-15 17:41:21 +01:00
c05988c6d1 docs(agent): V2 the menu IA (#105/#111/#110) — content passes, file #131/#132
All checks were successful
Check / eval (push) Successful in 3m48s
856a9d1 restructured the picker and shipped at "V0 menu-back; HM gen builds",
which §2.1 does not allow for a user-visible change. Rendered all eight
screens under the real HM session and viewed them.

IA verified: six-entry root; System → Connectivity/Devices/Recovery/
Preferences; Recovery lists each scope once; Preferences carries the migrated
update-checks/Bluetooth/Printing toggles; Control Center gone from the
dispatch (asserted, not eyeballed); no stderr on any screen. No code change.

Two findings, both filed rather than fixed:

#131 — the VM showed the long Recovery labels ellipsized. That specific
symptom is a guest artifact (they measure 462px/509px in Inter 11 against
~668px of room at 1920), but the same measurement says they DO truncate at
1366, where the picker is only 546px wide — cutting exactly the cost hint
#111 added them to carry. The Acer Aspire M5-481T is 1366x768 and in the QA
fleet. Shortening vs -mesg vs widening is a design call.

#132 — the guest draws icons at ~1/4 of ui.iconSize and clips a 6-row root
behind a scrollbar despite lines=8. Root predates #105, so this is the test
environment, not a regression — but it means menu screenshots are evidence
for content and never for geometry. Documented in docs/TESTING.md §5 so the
next agent doesn't chase it.
2026-07-15 17:09:43 +01:00
b4fe4985bf fix(theme): destructive buttons were invisible after #98 (1.03:1)
All checks were successful
Check / eval (push) Successful in 3m54s
e9dd3d1 pinned dialog button labels to base00 for dark palettes. That is
correct for suggested-action, whose background adw-gtk3 builds opaquely as
mix(white,@accent_bg_color,0.9) — but destructive-action is the one class
whose background is derived from currentColor:

  background-color: mix(@destructive_color, alpha(currentColor, 0.15), 0.9)

so pinning the label dragged the background down with it. On Boreal the
"Delete" button of a GtkMessageDialog rendered #21272F on #252931 —
1.03:1, invisible — and light palettes hit the same 1.03:1. The most
safety-critical button in a confirmation dialog was the one that vanished.

Pin the destructive background to the same opaque pastel ladder
suggested-action uses. Enabled-only (:not(.flat):not(:disabled)): the
sheet's disabled chrome already renders legibly and must keep it.

Boreal 1.03 -> 6.19:1, summer-day 1.03 -> 2.74:1 (= upstream's
white-on-accent convention; the residual light-mode weakness is #130).

V2: GtkMessageDialog rendered under the real HM session and viewed —
boreal before/after, summer-night (whole-swap), summer-day (light).
Scripted checks stayed green throughout and never saw this; only the
render did. Follow-ups #129 (permanent guard) and #130 filed.
2026-07-15 16:56:39 +01:00
060bf524af fix(idle): skip DPMS-off when docked (#127 mitigation)
All checks were successful
Check / eval (push) Successful in 5m24s
Blanking the only live output in dock mode led to a brick where wake and
even VT switch failed. Skip hypridle's 10m dpms off when any laptop
internal (eDP/LVDS/DSI) is present but not enabled; lock at 5m remains.
Undocked still blanks. Document LATER revisit for intentional
DPMS-when-docked once wake is proven.

V1: nix flake check --no-build. V3: docked idle stays lit under lock.
2026-07-15 12:51:34 +01:00
e9ab0d8d64 fix(display): docked idle wake + dump without SSH (#127)
All checks were successful
Check / eval (push) Successful in 3m44s
Recover from DPMS/lock blackout without a second machine: undock/enable
always issue dpms on; hypridle on-resume and after_sleep run
nomarchy-display-wake (zero-output undock rescue + auto-dump).
nomarchy-display-dump writes ~/nomarchy-display-dump-*.txt (SUPER+SHIFT+D
or Ctrl+Alt+F3 TTY). HARDWARE-QUEUE #127 no longer requires SSH.

V1: nix flake check --no-build. V3: docked idle on AMD box still open.
2026-07-15 12:37:00 +01:00
52d1581131 feat(power): suspend-then-hibernate 1h on battery (#115)
All checks were successful
Check / eval (push) Successful in 6m16s
Human decision: 1h delay, battery only, Preferences enable/disable.
Wire systemd suspend-then-hibernate + HibernateDelaySec=1h +
HibernateOnACPower=false when settings.power.suspendThenHibernate is on
and boot.resumeDevice is set. Lid: s2h undocked, plain suspend on AC.
nomarchy-suspend for hypridle + Power menu (live state). LUKS unlock
before encrypted hibernate so s2h→disk is not double-password.
state-bridges assert the policy; HARDWARE-QUEUE #115 + suggested order.

V1: nix flake check --no-build. V3: bag-carry on hardware.
2026-07-15 12:13:23 +01:00
e9dd3d14c5 fix(theme): dark adw-gtk3 for dark palettes — GTK dialog buttons (#98)
Some checks failed
Check / eval (push) Has been cancelled
Stylix always used the light adw-gtk3 sheet with recolored tokens; that
sheet hardcodes near-black button labels, which became black-on-dark in
Boreal GTK dialogs. Force adw-gtk3-dark (and prefer-dark) for dark mode,
and pin dialog button label colours in Stylix extraCss.
2026-07-15 12:06:38 +01:00
edc1415521 docs(RECOVERY): fix snapper cross-ref after renumber (§5)
All checks were successful
Check / eval (push) Successful in 4m21s
2026-07-15 12:00:31 +01:00
c9dd7575bf docs: explain automatic generation prune for users (#128)
Some checks failed
Check / eval (push) Has been cancelled
RECOVERY §4 documents the 14-day / keep-≥3-past policy for system and
Home Manager gens, with dry-run and timer commands. README and
REQUIREMENTS point here so install and day-2 hygiene match the product.
2026-07-15 12:00:17 +01:00
e4800d7d8e feat(nixos): prune system+HM gens after 14d, keep ≥3 past (#128)
Some checks failed
Check / eval (push) Has been cancelled
Weekly nomarchy-gen-prune deletes only generations that are both older
than 14 days and beyond the three most recent past gens (current always
kept), for system and Home Manager profiles. Stock nix.gc no longer uses
--delete-older-than. checks.gen-prune covers selection self-test and
unit wiring; doctor points at the new tool when disk is tight.
2026-07-15 11:59:26 +01:00
1f6f21249e docs(agent): #128 — 14d prune for system and Home Manager gens
Some checks failed
Check / eval (push) Has been cancelled
Same floor (keep current + ≥3 past); age window 14 days not 30; apply
independently to system and HM profiles.
2026-07-15 11:55:58 +01:00
9dac8a8b23 docs(agent): file #128 — auto-prune NixOS gens (30d, keep ≥3 past)
Some checks failed
Check / eval (push) Has been cancelled
Policy: remove system generations older than 30 days only when they are
also beyond the three most recent past gens, so rare rebuilds never lose
rollback depth. Notes existing 14d nix.gc has no count floor.
2026-07-15 11:53:50 +01:00
856a9d1a49 feat(menu): Recovery scopes (#111), System IA (#105), drop Control Center (#110)
Some checks failed
Check / eval (push) Has been cancelled
System is Connectivity / Devices / Recovery / Preferences under the same
six-entry root. Recovery lists desktop (HM), system boot, and BTRFS files
once each with cost labels. Control Center is gone: blur/gaps live in Look
& Feel; update checks and Bluetooth/Printing package toggles in Preferences
(with rebuilds). docs/RECOVERY paths updated.
2026-07-15 11:49:57 +01:00
0ba9633728 test: permanent V2 checks for airplane (#104) and fastfetch logo (#122)
Some checks failed
Check / eval (push) Has been cancelled
Move nomarchy-airplane to pkgs/ so checks can import it. checks.airplane
covers the session state machine, menu/keybind surfaces, and whole-swap
waybar parity (mocked nmcli; live radios stay V3). checks.fastfetch-logo
asserts the baked mark is sextant-based with the module flags locked in.
2026-07-15 11:42:18 +01:00
875dae9c58 feat: airplane mode (#104) + crisper fastfetch logo (#122)
Some checks failed
Check / eval (push) Has been cancelled
Airplane: runtime Wi-Fi+Bluetooth kill-switch with prior-state restore,
System menu + SUPER+CTRL+R, Waybar plane glyph (self-hides, whole-swap
parity). Fastfetch: chafa sextants at 24×12 (was block 20×10) for a
finer monogram; V2 kitty screenshots under boreal and summer-night.
2026-07-15 11:33:59 +01:00
d8e1a13d50 refactor(#107): theme-state.json → state.json, theme-sync → state-sync
Some checks failed
Check / eval (push) Has been cancelled
The machine flake's git-tracked settings file is system state, not
"theme" only — rename it to state.json. CLI becomes nomarchy-state-sync
with a nomarchy-theme-sync symlink for scripts and muscle memory.

Eval (mkFlake, doctor, lifecycle) still accepts theme-state.json; the
next write migrates to state.json and removes the legacy file.
Documented in MIGRATION.md; drop the CLI alias after release notes.
2026-07-15 11:26:59 +01:00
013403deb4 feat: close #94 installed chromium; silence Hyprland update-news
Some checks failed
Check / eval (push) Has been cancelled
Re-boot of the offline install disk proves the residual #94 pass:
chromium on PATH, https/html mime → chromium-browser.desktop, and the
browser window actually opens. Kill ecosystem update-news / donation nag
so first boot is not Hyprland's "updated to …!" dialog.
2026-07-15 11:23:07 +01:00
46adcc9989 docs(agent): file #127 — docked idle blackout, no wake, undock no panel
All checks were successful
Check / eval (push) Successful in 3m47s
Bernardo 2026-07-15: docked idle → black screens; external KB/mouse did
not wake; unplugging the external did not restore eDP; Caps Lock still
worked. Captures the "session crash" as a seat brick. HARDWARE-QUEUE
repro + journal steps for the next controlled run.
2026-07-15 11:11:57 +01:00
e08426880d docs(agent): V2 results for doctor-float + offline test-install
All checks were successful
Check / eval (push) Successful in 3m46s
Doctor-float PASS (kitty classed window). Offline test-install PASS:
nomarchy-live ISO name, first-boot themed desktop without autogenerated
banner (pre-activate bake). Disk-filter fixture for fd0 exclusion.
2026-07-15 10:36:20 +01:00
be2d0d200b feat(menu): group keybinds cheatsheet Window/Workspace/Menu/Media (#108)
All checks were successful
Check / eval (push) Successful in 3m39s
Every bind in keybinds.nix carries a group; SUPER+? renders section
headers. checks.keybinds-cheatsheet asserts all groups are valid and
every description appears in the sheet (same source as Hyprland binds).

Verified: V0 flake check; built cheatsheet shows ── Window ── / etc.
2026-07-15 10:04:33 +01:00
e6be5a5770 fix: disk picker fd0 (#112), menu Back guard (#106), offline theme (#113)
Some checks failed
Check / eval (push) Has been cancelled
- #112: list_installable_disks drops fd/loop/sr/zram/&lt;8GiB, largest-first
  so OVMF no longer offers /dev/fd0 as the default wipe target
- #106: tools/check-menu-back.py + checks.menu-back (proved to fail)
- #113: offline theme-switch contract documented (default/pinned only);
  theme-sync run_switch adds offline-oriented hint when no network

Verified: V0 flake check; installer-safety; menu-back; py_compile.
2026-07-15 10:02:19 +01:00
748d4af414 fix(install): bake dconf, seed main, name ISO, display modes (#123–#126)
Some checks failed
Check / eval (push) Has been cancelled
Batch of existing-bug fixes from Acer/XPS install findings:

- #123: HM pre-activate creates /run/user/$UID and prefers
  dbus-run-session so dconf no longer aborts the desktop bake
- #124: main-built ISOs seed ?ref=main; patch-template rewrites
  inputs.nomarchy.url from NOMARCHY_FLAKE_URL (was set but unused)
- #125: image.baseName → nomarchy-live-….iso (not nixos-live-…)
- #126: Display mode list sorted by pixel area; toast shows hyprctl
  size; TESTING notes QEMU fixed-window scaling illusion

Verified: V0 flake check, installer-safety, baseName eval, patch-template
flakeUrl rewrite, mode-sort fixture.
2026-07-15 09:58:53 +01:00
25e498a5ef docs(agent): file #125 ISO still named nixos, #126 display res invert
Some checks failed
Check / eval (push) Has been cancelled
Bernardo: live/install ISO artifact is nixos-live-….iso (image.baseName);
live VM Display picker appeared to invert higher/lower modes — investigate
sort/apply vs QEMU scaling before fixing.
2026-07-15 09:53:14 +01:00
545e40f4d9 feat(terminal): Kitty only — drop Ghostty (#95)
Some checks failed
Check / eval (push) Has been cancelled
Ghostty's OpenGL 4.3 floor broke the Acer (HD 4000 / 4.2). Dual-terminal
support (default Ghostty + install-time Kitty fallback) was more complexity
than it was worth.

- Remove ghostty.nix / nomarchy.ghostty.enable
- Kitty always installed and themed from theme-state
- Default nomarchy.terminal = kitty; SUPER+Return, doctor, calendar,
  what-changed use kitty --class=com.nomarchy.*
- Drop install-time glxinfo probe and mesa-demos on the installer
- Docs/REQUIREMENTS no longer list OpenGL 4.3 as a terminal floor

Verified: V0 flake check; option-docs; installer-safety; template HM has
kitty, no ghostty; menu/calendar emit classed kitty launches.
2026-07-15 09:51:27 +01:00
1e648b6c49 feat(terminal): themed Kitty when OpenGL < 4.3 (#95)
All checks were successful
Check / eval (push) Successful in 3m41s
Ghostty needs OpenGL 4.3; older GPUs (Intel HD 4000) top out at 4.2.
Installer probes glxinfo and writes settings.terminal=kitty into
theme-state when below the floor. modules/home/kitty.nix enables Kitty
with the same palette, mono font, and opacity as Ghostty so the fallback
still looks like Nomarchy.

Default machines stay Ghostty-only (no kitty in the HM closure). Doctor
and calendar still invoke Ghostty by class — V3 on Acer for SUPER+Return.

Verified: V0 flake check; installer-safety; HM with terminal=kitty builds
themed kitty.conf + TERMINAL=kitty; default template has no kitty bin.
2026-07-15 09:45:32 +01:00
a09399702d docs: REQUIREMENTS.md — OpenGL 4.3 floor, disk/RAM planning
All checks were successful
Check / eval (push) Successful in 3m44s
Acer M5-481T fails Ghostty because Ivy Bridge HD 4000 tops out at
OpenGL 4.2 in Mesa; Ghostty needs 4.3 — a generation ceiling, not a
missing driver. Document that plus UEFI/x86_64, RAM, and storage floors
(Nix store generations + BTRFS snapper + hibernate swap≈RAM). Linked
from README, docs map, HARDWARE.md; #95 points here.
2026-07-15 09:40:17 +01:00
2c9df329dc docs(agent): #95 both Ghostty fails are Acer pre/post HM, XPS is fine
Some checks failed
Check / eval (push) Has been cancelled
Bernardo: XPS starts Ghostty. Aspire only fails — first attempt before
home built (OpenGLOutdated), second after home built (quiet GDK_DISABLE
exit). Kitty was the manual working shell used to run both.
2026-07-15 09:36:54 +01:00
70f2d4d54d docs(agent): #95 is two Ghostty failures, not one OpenGL floor
Some checks failed
Check / eval (push) Has been cancelled
Photo A (Acer): OpenGL 4.2 → OpenGLOutdated / SurfaceError.
Photo B (XPS): silent exit after GDK_DISABLE=gles-api,vulkan with no
OpenGL line — different machine, different root cause. A fix for only
one is not done.
2026-07-15 09:34:17 +01:00
fbc93159a6 docs(agent): queue #123–#124 + diagnose #95 from Acer/XPS install photos
Some checks failed
Check / eval (push) Has been cancelled
Real installs on Acer Aspire M5-481T and Dell XPS 9350 (2026-07-14):

- #123: HM pre-activate dies at dconf without XDG_RUNTIME_DIR on both
  machines — partial first boot (weird GTK + missing nm-applet are
  symptoms of the incomplete bake).
- #124: install seeds ?ref=v1 while writing main-era nomarchy.hardware.*,
  and v1 lags hard enough that rebuilds die with "option does not exist".
- #95: Ghostty on the Acer is OpenGL 4.2 < required 4.3 (OpenGLOutdated);
  XPS starts Ghostty fine.

HARDWARE-QUEUE V3 rows; photos stay local (.gitignore WhatsApp Image*).
2026-07-15 09:33:37 +01:00
355d8cb1a4 fix(mime): text/plain falls through to Text Editor on live (#119)
All checks were successful
Check / eval (push) Successful in 4m5s
A singleton default naming code.desktop left the live ISO with no text
handler — GIO skips missing .desktop files and offers nothing. Prefer
vscode when present, then org.gnome.TextEditor.desktop. Live also gains
mpv (video defaults had the same hole; free via the template pin).

checks.live-baseline-apps now requires every Default Applications key
to resolve to a present .desktop on live (HM + system path) and on the
default template install. Proved to fail on a vscode-only text/plain.

Verified: V2 (live-baseline-apps green + prove-to-fail); V0 flake check.
2026-07-15 09:24:48 +01:00
6fbad46bca docs(agent): #122 — sextants DO work; chase the fallback, not one font's charset
All checks were successful
Check / eval (push) Successful in 3m27s
Bernardo asked whether #122 was quick, and then caught the reason my first
answer was wrong: the mono face is per THEME (ghostty.nix:22 reads
t.fonts.mono from the theme JSON), and Boreal — the default theme and the base
state — is GeistMono Nerd Font, not stylix's JetBrains Mono. I had probed the
wrong font, then drawn a confident conclusion from a single charset.

Both conclusions inverted on measurement, so the item now says the opposite of
what it said an hour ago:

  * GeistMono covers no sextants, no braille — and no block elements or box
    drawing either. So the CURRENT logo already renders through fontconfig
    fallback on the default theme, and always has. Fallback is the normal case
    here, not the hazard I painted it as.
  * Fallback supplies sextants fine: fc-list :charset=1fb00 finds 37 families
    on a real Nomarchy box, including the shipped nerd-fonts.caskaydia-cove and
    noto-fonts' Symbols 2; fc-match under GeistMono picks CaskaydiaCove. No
    tofu. Braille: 59 families.

So --symbols sextant is available and is roughly a one-flag edit for 6x the
detail — rendered both variants to confirm the shape genuinely sharpens. The
item is re-scoped accordingly: the edit is minutes, the WORK is verification.
Two things can still make it worse and only a real terminal can say — fallback
metrics (CaskaydiaCove's sextants drawn in GeistMono's cell may misalign or
differ in stroke weight, exactly the seam that looks fine in a VM and wrong on
a laptop), and the mark being diagonal-heavy, since diagonals staircase under
any cell symbol: more resolution buys a finer staircase, not a straight line.
If the mark itself wants straight strokes that is a logo.svg design change
hitting all three surfaces, not a chafa flag — settle that first.

V0: nix flake check --no-build passes. Backlog only.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-14 17:47:44 +01:00
347f6544aa docs(agent): file #122 — a crisper fastfetch logo, with the trap named up front
All checks were successful
Check / eval (push) Successful in 3m27s
Bernardo 2026-07-14: the fastfetch logo should be crisper, with straight lines.

Filed with the cause rather than the wish. modules/home/fastfetch.nix converts
the vector mark with `chafa --symbols block --size 20x10`, and that symbol class
is the chunkiness: restricted to full/half blocks, a 20x10 grid carries ~20x20
pixels of detail, so every diagonal staircases. chafa 1.18.2 ships sextant (2x3
subcells), quad and wedge — verified present today, so the cheap lever exists.

Named the trap that decides the approach, because trying it first is what
prevents a wasted round: sextants are Unicode 13 Symbols for Legacy Computing,
and if the shipped terminal font lacks them the logo renders as tofu — strictly
worse than chunky. So font coverage in the real Ghostty gets checked before a
symbol class is chosen, and if it is missing, hand-authored box-drawing art is
the honest alternative (and the only way to get genuinely straight lines; any
image→symbol converter approximates).

Also recorded that the three logo surfaces are different renderers — fastfetch,
the ISO splash, the Waybar glyph font — so this item touches one of them, while
a change to the shared logo.svg would hit all three and should be split.

Visual change, so THEME-DESIGN and the honesty rule apply: pass needs
screenshots viewed under two themes, no tofu, accent tracking intact, and the
fastfetch row grid still aligned.

V0: nix flake check --no-build passes. Backlog only.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-14 17:40:54 +01:00
19ba4e2b12 fix(nixos): smartd self-gates on the hardware — the doctor was right, smartd wasn't
Some checks failed
Check / eval (push) Has been cancelled
BACKLOG #118. Bernardo booted the live ISO and the Waybar health icon was red,
reporting smartd. Everything downstream turned out to be working correctly,
which is the part worth recording: smartd's config is DEVICESCAN, and where no
drive answers SMART it exits 17 ("Unable to monitor any SMART enabled
devices"), systemd marks the unit failed, nomarchy-doctor faithfully reports a
failed system unit, and Waybar paints @bad. The doctor was telling the truth.
smartd was the bug.

Scope was never live-only, which is why this sat in NOW rather than as a live
nit: services.smartd.enable mkDefaults true on every machine and QEMU virtio
exposes no SMART, so every VM install has been booting to a health warning
about a daemon with nothing to do — and every V2 run had been showing it as
noise.

Fixed with the distro's own self-gate convention: an ExecCondition running
`smartctl --scan`, which prints nothing exactly when smartd would find nothing.
A failed condition leaves the unit inactive rather than failed. Deliberately
NOT SuccessExitStatus = 17: that would also swallow exit 17 from a machine that
does have drives, which is the entire reason the daemon ships.

V2. checks.smartd-gate boots the REAL distro module rather than a restatement
of it (its nixpkgs.config needs mkForce to yield to the test's pkgs) and
asserts both halves, because they pull in opposite directions: a gate that
never skips leaves the red icon, and a gate that always skips silently disables
drive-health monitoring on real hardware — the failure nobody notices until a
disk dies quietly. So the no-SMART node must go ActiveState=inactive, unfailed,
and absent from `systemctl --failed` (what the doctor actually reads); and the
gate's logic is driven against a scan that DOES find a device, since QEMU
cannot answer SMART honestly and pretending otherwise would test nothing. The
check was proved to fail by unwiring the condition. flake check, doctor,
hardware-toggles, live-baseline-apps, option-docs and state-bridges pass.

V3 pending: that smartd still RUNS where drives have SMART (dev box, real
NVMe). Queued with an explicit fail-condition — if it skips there, revert the
gate rather than tune it.

Also swept: #120's size table said the duplicate chromium was gone; #121 was
reverted, so it is back and the table says so.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-14 17:38:58 +01:00
0727ba6d44 revert(pkgs): leave the duplicate chromium — honestly, and with the numbers
All checks were successful
Check / eval (push) Successful in 3m21s
Bernardo on #121 (shipped in 0b464af): "Let's leave it honestly. Sounds too
much work for a negligible gain." Agreed, and his own reasoning is the
argument: the fix worked, but it substitutes into a buildCommand nixpkgs owns,
which couples us to wrapper internals and has a SILENT no-op failure mode —
so it also needed a permanent check, because nothing about a returning
duplicate breaks a build. That standing maintenance cost buys ~195 MiB of
download and ~19 MiB of disk. Boring beats clever at that exchange rate.

Removed the overlay and checks.chromium-single-closure. The template HM closure
is back to 9.38 GiB with the duplicate present — verified by closure, not by
reading the diff. overlays.default now carries a comment saying the duplicate
is known and deliberate, where the next person will look for it.

The ROADMAP entry stays, downgraded from ✓ to ✗ decided-against: it holds the
measurement, the approach that worked (self-contained -wv copy; repoint the
wrapper's share/* symlinks; .override composes with overrideAttrs), and the
commit to recover it from. The bug is real and worth an upstream patch — the
wrapper should take its desktop entry from the variant it wraps, which helps
every NixOS user with enableWideVine, not only us. Revisit locally if #120's
netinstall ships, since download is the one axis this moves.

What survives is worth more than the fix, and is stamped on #120: closure size
is not disk size and is not image size — measure the artifact. Removing a
687 MiB path moved the ISO by 8 KiB, because mksquashfs dedupes duplicate files
and auto-optimise-store hardlinks them. Without that caveat, #120's "drop the
pin, save 4 GiB" would have evaporated at the squashfs exactly as this did.

V0: nix flake check --no-build passes.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-14 17:32:06 +01:00
0b464af3e5 fix(pkgs): one chromium, not two — and what that is actually worth
All checks were successful
Check / eval (push) Successful in 3m32s
BACKLOG #121. nixpkgs' chromium wrapper with enableWideVine = true (what the
template ships, for DRM) runs the `-wv` copy but links its share/* from the
PLAIN unwrapped build. A symlink is a store reference, so a second 687 MiB
chromium rode along for a directory of .desktop files nothing executes.
overlays.default now points those symlinks at the copy we already run; the
template needs no change, since lib.nix, the repo pkgs and nixosModules all
apply the overlay.

Checked first, because it decided whether the item was possible at all:
whether the `-wv` copy references the plain build. It is `cp -a` of it, so it
plausibly would have — and then no wrapper change could have dropped the
duplicate. It does not. Also verified rather than assumed: `.override
{ enableWideVine = true; }` composes with the overlay's overrideAttrs (the
order usually matters), so the template's existing line picks the fix up
untouched; the wrapper still execs the -wv build with WidevineCdm present; and
share/{applications,icons,man} match stock with the .desktop byte-identical.

I claimed this was "687 MiB off every install". It is not, and measuring the
artifact instead of the closure is what corrected it:

    nominal closure          -687 MiB  (9.38 -> 8.71 GiB)
    ISO image                -8 KiB    of 8.078 GiB
    installed disk           ~-19 MiB
    cache-install download   -195 MiB

Two dedupes that closure arithmetic cannot see. mksquashfs detects duplicate
files, so the ISO had already stored the near-identical blocks once. And
auto-optimise-store (on by default here) hardlinks identical files: the two
paths share inodes — verified by stat, and du counts 639 MiB for the pair
against ~620 for one. The only thing that cannot dedupe is the wire, where the
extra path is its own 195 MiB NAR.

So this is a DOWNLOAD fix — precisely what #120's netinstall cares about — and
close to a no-op for the offline ISO that is the default today. Whether that
justifies coupling an overlay to nixpkgs wrapper internals is Bernardo's call;
it is guarded and no-ops if upstream moves, but it is his to weigh. Worth an
upstream patch regardless: the wrapper should take its desktop entry from the
variant it wraps.

The guard is the load-bearing part, because the failure mode is a SILENT no-op
that no build complains about: checks.chromium-single-closure asserts exactly
one full unwrapped chromium in the template closure, and was proved to fail by
neutering the overlay — it reports "found 2", names both paths, and warns off
the tempting "fix" of dropping enableWideVine, which silently removes DRM.

The rule this establishes, now recorded in ROADMAP and on #120 (whose entire
size table is closure arithmetic): closure size is not disk size and is not
image size. Measure the artifact.

V2: flake check, chromium-single-closure, live-baseline-apps, template-sot,
option-docs, state-bridges all pass. Three ISOs built from one tree for the
numbers above.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-14 17:26:24 +01:00
00b1e10fc3 feat(live): the live ISO gets a desktop — browser, office, editor — for +41 MiB
All checks were successful
Check / eval (push) Successful in 3m22s
BACKLOG #103. Bernardo, live ISO 2026-07-14: still no browser, no office —
"that makes a live iso useful". He is right, and it is the thing a user judges
the distro by before installing, and what they boot to rescue a machine that
won't start. It could do neither.

The live HM user now names its own set: chromium, libreoffice-fresh,
gnome-text-editor, amberol, snapshot. Firefox deliberately excluded (Bernardo's
call): chromium already owns the HTTP mime default and ships in the installed
template, so it is the browser that matches what a user gets post-install.

I asked him to choose on a "~+2 GB" premise. The premise was wrong, and
measuring rather than assuming is what corrected it: system.extraDependencies
ALREADY pins the template's HM closure into the ISO for offline installs, so
chromium/libreoffice/amberol were already in the image's store — merely absent
from the live user's profile, so nothing put them on PATH or in the launcher.
The live and template chromium resolve to the SAME store path, verified, so
reusing the template's exact `chromium.override { enableWideVine = true; }`
costs zero; a plain `chromium` would have been a second 2.5 GiB closure. Only
gnome-text-editor and snapshot are new: 9 paths, 133 MiB uncompressed. His
Firefox call still held for the right reason — it was the one item genuinely
unpinned.

V2. Measured ISO delta, both built from the same tree — the item's pass
condition: 8.038 → 8.078 GiB = +41.2 MiB (+0.50%). New
checks.live-baseline-apps asserts each app is on PATH *and* has a .desktop the
launcher can see (a binary without one is invisible, which is the failure that
matters), that HTTPS resolves to a chromium entry actually PRESENT (#94's exact
trap — a mime default naming a package nothing ships), and that firefox has not
crept back, since that is a size decision not a drive-by. The guard was proved
to fail: dropping snapshot makes it name the missing entry. flake check,
live-install-entry, option-docs, template-sot green.

V3 pending: that the apps LAUNCH needs hardware — queued on the Acer M5-481T,
from the launcher only, since that is the claim a file-level check cannot make.

Also filed, from measuring the 8 GiB rather than speculating about it:
  * #120 (NEXT, Bernardo's call) — a netinstall ISO beside the offline one.
    Starts from numbers: the offline pin is only 4.02 of 18.03 GiB uncompressed
    (~22%), so dropping it still leaves a 14 GiB desktop (~6.3 GiB compressed).
    "No pin" is not the lighter ISO; and without a binary cache for Nomarchy's
    own outputs a netinstall trades a download for a from-source install.
  * #121 — the Widevine wrapper drags a SECOND 687 MiB unwrapped chromium in
    for its share/applications alone. Pre-existing, on every installed machine.
  * #119 — text/plain names vscode, which the live ISO never ships.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-14 17:01:20 +01:00
b1c4e516c9 docs(agent): #103 confirmed on hardware — record the why, the inventory, and the size trade
All checks were successful
Check / eval (push) Successful in 3m17s
Bernardo, live ISO 2026-07-14: still no browser, no office/utility apps —
"that makes a live iso useful". Not a new item; #103 already covers it and #94
already pointed here. Recorded against #103 instead of duplicating.

Captured the rationale, which outlives the app list: a live ISO is what
someone judges the distro by before installing, and what they boot to rescue a
machine that won't start — read a wiki page, save a document off a dying disk,
check webmail. The live session can do none of that today.

Verified the inventory rather than restating the report: eval of nomarchy-live's
HM packages + systemPackages gives chromium=no firefox=no libreoffice=no
amberol=no snapshot=no text-editor=no. It does carry terminal tooling (yazi,
btop, bat, fd, ripgrep, ghostty) plus zathura and imv, so the gap is precisely
the GUI apps a non-Nix user reaches for.

Flagged the part that makes this more than a package list: the ISO-size delta
is the real trade — both browsers plus libreoffice-fresh is plausibly +2 GB on
an image people download, and squashfs can't be trimmed afterwards. Listed the
options (both browsers / Chromium only / lighter office for the live medium)
and asked for measured deltas per option, noting that nomarchy-live and the
installed profile need not carry the same set — the live medium is the one
with a size ceiling.

V0: nix flake check --no-build passes. Backlog only.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-14 16:30:38 +01:00
334354a103 docs(agent): file #118 — smartd fails with no SMART device, reddening the health icon
Some checks failed
Check / eval (push) Has been cancelled
Bernardo tested the live ISO and saw a smartd error on the Waybar doctor
module. Filed with the cause rather than the symptom: reproduced headlessly in
a scratch runNixOSTest whose node's only config is services.smartd.enable =
true — the same block default.nix:166 mkDefaults on for every machine.

  smartd: In the system's table of devices NO devices found to scan
  smartd: Unable to monitor any SMART enabled devices. Exiting.
  smartd.service: Main process exited, code=exited, status=17/n/a

Everything downstream is working correctly: systemctl --failed lists
smartd.service, so the doctor's generic failed-unit check reports it and
#custom-doctor goes @bad. The doctor is telling the truth — smartd is what
needs fixing.

Scope is wider than the live ISO, which is why it sits in NOW with the other
live findings rather than as a live-only nit: smartd mkDefaults true on
`nomarchy` too, and QEMU virtio disks expose no SMART, so a plain VM install
boots to a red health icon out of the box. Every V2 QEMU run has been showing
this.

The fix direction is the distro's own self-gate convention: `smartctl --scan`
prints nothing on such a machine, so an ExecCondition leaves the unit inactive
instead of failed and real SMART failures still surface. Recorded explicitly
that SuccessExitStatus = 17 is the wrong fix — it would also swallow smartd
dying on a machine that does have drives, which is the reason the daemon
exists.

V0: nix flake check --no-build passes. Backlog only — no fix attempted here.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-14 16:27:59 +01:00
0bb75b05a9 docs(agent): #117 is an input to #110, not work — Control Center is being retired
All checks were successful
Check / eval (push) Successful in 3m12s
Bernardo's triage: the Control Center is going away (#110), so making its
toggles rebuild would be building on a condemned surface. #117 stays filed
because the observation outlives the tool — whatever menu inherits those rows
must not reprint "requires rebuild" — but it is marked not-standalone so no
agent picks it up as work. The previous entry's "next suggestion: #117" was
wrong and the correction is appended rather than rewritten.

#110's migration matrix gets what #116 turned up, which changes its shape:
its Bluetooth and Printing rows write settings.{bluetooth,printing}.enable,
and those keys only became live bridges in a9f3a64 — so those two toggles
never worked for any user. There is therefore no behavior to preserve, but
the keys and their bridges are real now and outlive the TUI: dropping the
rows must not drop the keys, or a machine whose theme-state.json already says
bluetooth.enable = false silently flips back on at its next rebuild.
checks.state-bridges covers exactly that and fails loudly if the bridges go
with the tool. Updates (settings.updates.enable) is read in
modules/home/options.nix — the HM side, where nomarchy.settings genuinely
exists — so it needs none of this; verified, not assumed.

V0: nix flake check --no-build passes. Backlog/journal only.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-14 16:21:41 +01:00
a9f3a642ee fix(nixos): two toggles that reported success and did nothing — wire the state bridges
All checks were successful
Check / eval (push) Successful in 3m16s
BACKLOG #116. `nomarchy.settings` is declared in exactly one place —
modules/home/options.nix:412, the Home Manager side. On NixOS the attribute
does not exist, and `or <fallback>` swallows the missing-attribute error, so
four options that "defaulted from the state" had silently been their fallback
on every machine ever built.

The item said three options, and called them benign. Both halves were wrong,
and re-grepping rather than trusting the account is what found it:

  * There were four. The original enumeration read options.nix instead of
    modules/nixos/ and missed services.nix's printing.enable — the same
    mistake in miniature as the bug it was filing.
  * Two were live user-facing bugs. Control Center is shipped
    (default.nix:337) and reachable from the menu; its Bluetooth and Printing
    toggles wrote settings.{bluetooth,printing}.enable and printed "requires
    rebuild", and the rebuild changed nothing. They had never worked.

The fix is one shape, now uniform: the option declares a STATIC default, and
the implementing module reads the state via theme-state-read.nix (fails closed
on bad JSON, unlike greeter.nix's raw fromJSON — also moved onto the reader
here) and mkDefaults it behind `mkIf (state != null)`. An absent key leaves the
option default as the single source of the fallback; a hand-set system.nix
value still pins it. batteryChargeLimit gets no bridge and loses its dead read:
power.nix's oneshot already reads that key with jq at RUNTIME and prefers it
over the baked value, which is why that menu worked all along.

V2. The bug is proved real before/after on the same flipped state: BEFORE,
bluetooth stays true and printing stays false; AFTER, both flip, and a hand-set
value still outranks the state. Nothing in a build fails when a bridge dies, so
the guards are the point — checks.state-bridges asserts 11 eval cases, and
checks.printing-from-state boots a VM whose only input is the state file and
waits for a running cups.service. The guard was itself proved to fail:
re-breaking the bluetooth bridge makes it throw, naming both assertions. A
check that passes whether or not the property holds is worse than no check
(625b7e3). flake check, option-docs, template-sot, downstream-template-*,
installer-safety, hardware-toggles and battery-charge-limit all pass.

No V3: the mechanism is fully proved headlessly. Design record in ROADMAP §
NixOS-side state bridges (#116); new #117 (PROPOSED) for the control-center
toggles still leaving the rebuild to the user.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-14 16:03:12 +01:00
7353568115 docs(agent): #116 undercounted the phantom reads, and two of them are live bugs
All checks were successful
Check / eval (push) Successful in 3m2s
Re-grepped the tree instead of trusting eb38008's own account of it. Two
corrections, both in the direction of worse:

A fourth read: services.nix:80 `printing.enable`. The original enumeration
only looked at options.nix, which is the same mistake in miniature as the
bug it was filing — `or` hides a dead bridge, so the only honest check is a
grep of all of modules/nixos/, which the item's own pass condition already
said and its own list did not do.

"Benign today" was wrong about Bluetooth. The TUI control center is shipped
(default.nix:337) and reachable from the menu's Control Center row, and its
Bluetooth and Printing toggles write settings.bluetooth.enable /
settings.printing.enable and print "disabled (requires rebuild)". Nothing
reads either key, so the fallback wins and the rebuild changes nothing. Two
user-facing toggles that report success and do nothing — that is a live bug,
not a dead key. batteryChargeLimit stays benign for the stated reason: its
menu patches the baked option instead.

nomarchy.settings is declared in exactly one place, modules/home/options.nix:
412 — verified, not assumed. V0: nix flake check --no-build passes.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-14 15:45:00 +01:00
50bfdb99d0 docs(agent): reconstruct the five journal entries a crashed session owed
All checks were successful
Check / eval (push) Successful in 3m3s
A session crashed after shipping the fingerprint/idle/menu batch. The work
itself survived intact: 625b7e3, e2de906, fbbeac6, 9792976 and eb38008 are
committed and pushed, and each did its own sync sweep — BACKLOG #115/#116
filed, HARDWARE-QUEUE carrying the toggle test and the removed fprintd trap,
docs and template updated. Only the journal was owed, so the next session
would have read docking round 7 as the head and missed five iterations of
context, including two live bugs the batch exposed.

Reconstructed from the commit messages rather than from memory, and marked
as such in the file — the commits are the record, this is an index into
them. The entries keep what the messages proved and what they left pending,
notably the lesson from fbbeac6 (the lock field has never been visible; I
assumed it was and reasoned from there) and the one from 625b7e3 (a check
that passes whether or not the property holds is worse than no check).

V0: nix flake check --no-build passes. Docs-only.

Noted, not done: the journal is at 56 entries against its own ~25 rotation
threshold — it was already 51 before this, so the tail is overdue for
JOURNAL-ARCHIVE.md. That is its own commit, not this one's business.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-14 15:40:24 +01:00
eb38008ebb feat(menu): one fingerprint switch, an auto-login toggle, and the state bridge they needed
All checks were successful
Check / eval (push) Successful in 3m7s
Bernardo, post-reboot: "Use for login" was the wrong question. Whether the
finger works is one decision, not two, and whether login prompts at all is
a different decision that was never in the menu.

System › Fingerprint is now a single Fingerprint (on/off) switch, leading
the menu with enroll/list/verify/delete as the plumbing behind it. It
writes the one settings.fingerprint.pam key, and modules/home/idle.nix now
defaults idle.fingerprint from that same key — so the lock screen and
login/sudo move together instead of drifting apart the way they did until
e2de906. nomarchy-fingerprint does the two rebuilds this needs (sudo
system for PAM, home switch for hyprlock) and refuses to turn on with no
finger enrolled.

System › Auto-login is new (nomarchy-autologin), and it is what decides
whether anything is asked at boot: auto-login on means no prompt whatever
the fingerprint switch says; off means the greeter asks, for a password or
a finger. Installer-seeded ON for LUKS machines — the passphrase already
gates the disk — and off without it, where the greeter is the only thing
between power-on and the desktop.

Both had to become state-owned to be toggleable at all, which surfaced two
real bugs:

  * nomarchy.system.greeter.autoLogin defaulted from
    `config.nomarchy.settings…` — an attribute that exists ONLY on the Home
    Manager side. On NixOS it is absent and `or null` swallowed the error,
    so the default silently evaluated to null on every machine ever built.
    That is why the installer baked a Nix line: the state path never
    worked. Now read via theme-state-read.nix (the hardware.nix/timezone.nix
    pattern) and mkDefault'd, so the menu owns it and a hand-set line still
    pins it. Two more options read the same phantom bridge — BACKLOG #116.
  * `theme-sync get` printed Python's "None" for a JSON null, so every
    `case … null)` a caller writes would miss. Now prints "null", as the
    comment above it already promised for booleans.

The installer seeds the state instead of emitting the system.nix line,
because that line outranks the state and would strand the toggle.

V1 (V3 pending: HARDWARE-QUEUE). nix flake check --no-build, installer-
safety and option-docs all pass. Proved by eval/build, not assumed: a state
carrying autoLogin yields greetd initial_session {"user":"bernardo"}, the
template state (no autoLogin) yields none, and a hand-set null beats a state
that says otherwise; a state with only fingerprint.pam=true — nothing set by
hand — renders the hyprlock auth.fingerprint block; both new tools pass
bash -n and land in systemPackages (nomarchy-fingerprint only with a
reader); the patcher writes settings.greeter.autoLogin and no system.nix
line; and the get round trip prints null, so the menu reads "Auto-login
(off)" where it would have read "(on)".

The reader itself, the two rebuilds, and the reboot are hardware — queued.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-14 14:34:56 +01:00
9792976b11 feat(idle): a real lock bind, and a lock screen that speaks in sentences
All checks were successful
Check / eval (push) Successful in 3m7s
Two nits from the post-reboot hardware pass.

Super+Escape never locked anything — there was no lock bind at all, only
the power menu's Lock row and the idle timeout. (Escape is unusable here
anyway; keybinds.nix has said so since the power menu moved to Super+X,
and an unswallowed Escape just prints its sequence into the terminal.)
Lock now lives at Super+Ctrl+L: the one row in that family that is not a
menu module, because bare Super+L collides with common app binds. It
dispatches loginctl lock-session, the same path the power menu's Lock row
takes, so logind marks the session locked and hypridle's lock_cmd remains
the single place that decides what a lock looks like.

The lock prompt read as clipped fragments ("password… or scan finger" /
"scanning finger…") next to a 64px clock. Both messages are sentences
now, and the input field's placeholder is sentence-cased to match — the
two can share the screen, so they cannot read as two different voices.

V1 + V3 pending: nix flake check --no-build passes; the built home
generation carries `bind=$mod CTRL, L, exec, loginctl lock-session`, the
new ready/present messages (built with idle.fingerprint forced on, since
the in-repo config leaves it off), and a "SUPER + CTRL + L  Lock screen"
row the cheatsheet generated on its own from the shared list. The reader
itself only exists on hardware.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-14 14:18:29 +01:00
fbbeac6c79 fix(idle): put the fingerprint hint where it can actually be seen
All checks were successful
Check / eval (push) Successful in 3m0s
e2de906 fixed the invisible reader and left an invisible message. The lock
screen is deliberately just the clock: the input field fades out while empty
(hyprlock's fade_on_empty default) and only appears once you type. So
$FPRINTPROMPT in the field's placeholder rendered faithfully into a widget
nobody sees until they have already given up on the finger and started typing
— which Bernardo reported as "unlock worked, but still no message", the same
symptom as before the fix, for a completely different reason.

I had assumed the field was visible and reasoned from there. It has never been
visible; "it has always been like this" is what cracked it.

Both candidate surfaces were tested on hardware rather than picked: a label
does expand $FPRINTPROMPT (so the line is live — "password… or scan finger",
then "scanning finger…" on touch), and fade_on_empty=false would also work.
Bernardo chose the label: it keeps the clock-only look, where the always-on
field would have made opting into fingerprint mean permanently gaining a
widget the design never had.

subtext-on-base is the palette's secondary-text role and is already held to a
3.0 floor on all 21 themes by checks.theme-contrast — the guard that exists
because two themes once shipped subtext == base and made hint text invisible,
which is the same bug as this commit, one layer down. The placeholder goes
back to its exact previous string, so the non-fingerprint path is unchanged.

Verified V2: nix flake check --no-build; checks.theme-contrast and
checks.option-docs pass. V3: Bernardo confirms the line reads on the real
lock screen — the mechanism itself is already proven on his reader.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-14 13:05:50 +01:00
e2de9062d8 feat(idle): unlock by fingerprint, and say so — hyprlock never used the PAM flag
All checks were successful
Check / eval (push) Successful in 2m59s
Bernardo tested the lock screen after turning `fingerprint.pam` on: sudo took
a finger, hyprlock did nothing. The PAM stack was not the problem — grosshack
sits in /etc/pam.d/hyprlock at order 11400 exactly like sudo's. hyprlock just
never asks it: its PAM conversation runs only on submit, so a module that
wants to prompt *while* polling the reader never gets a turn. hyprlock has its
own fprintd-over-D-Bus backend instead, behind `auth:fingerprint:enabled`,
which nothing in the distro ever set. So `fingerprint.pam = true` promised
"login + sudo" and silently skipped the surface you meet most often.

The second half is the one worth having: enabling the backend alone yields a
reader that works while the field still reads "password…". $FPRINTPROMPT is
the ONLY slot hyprlock renders the ready/scanning/failed messages into, and
ours hardcoded the placeholder — proven on hardware, where a test config with
the backend on unlocked by finger and said nothing whatsoever. A feature that
works and cannot be discovered fails VISION's "discoverable without reading
the README" either way, so the placeholder now carries $FPRINTPROMPT and the
messages mirror sudo's "Enter Password or Place finger".

New `nomarchy.idle.fingerprint` (default false), mirroring
`nomarchy.hardware.fingerprint.pam` the way `nomarchy.keyboard.layout` mirrors
`services.xserver.xkb.layout`: hyprlock is configured in standalone Home
Manager, which has no `osConfig` to derive the NixOS side from. Opt-in rather
than implied, because with no reader hyprlock advertises a scan that cannot
happen. Documented in the template (SoT for opt-in comments) and README —
option-docs caught the missing row, which is the check doing its job.

Verified V2: nix flake check --no-build; checks.option-docs passes (failed
first with "undocumented option", as it should). V3 pending: Bernardo applies
it and confirms the field reads "password… or scan finger" and both factors
work — the mechanism itself is already proven on his reader via a test config.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-14 12:58:53 +01:00
625b7e38a3 docs(hardware): sudo path of parallel fingerprint passes; drop a check that cannot fail
All checks were successful
Check / eval (push) Successful in 3m4s
Bernardo, AMD dev box, gen 422, fingerprint.pam = true (parallel default):
one prompt, typed password works, sensor works, wrong-finger x3 falls back to
password. Three of the item's counts confirmed on the real reader.

The fourth — "password keeps working with fprintd stopped" — is removed as a
trap rather than checked off. fprintd is D-Bus activated, so `systemctl stop`
does not keep it down: PAM asks D-Bus, D-Bus restarts it. Bernardo ran it and
reported that *the fingerprint still worked*, which is precisely the evidence
that the daemon came back and the dead-reader path was never exercised. A
check that passes whether or not the property holds is worse than no check.

The property itself needs no hardware: password is `auth sufficient` at order
11700, independent of the fprintd rule at 11400, so no fprintd failure can
gate it — visible by eval on the rendered pam.d text, and asserted in the VM
with no reader by checks.hardware-toggles. Doing it for real would need
`systemctl mask`, and masking a working machine's auth daemon to re-prove an
evaluated invariant is not a trade worth making.

Still open on this item: hyprlock, the greeter (needs the reboot), and
`fingerprint.parallel = false` restoring sequential behavior.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-14 12:48:16 +01:00
e4404e0162 test(docking): prove the keyboard-restore path; the reload clobber is real
All checks were successful
Check / eval (push) Successful in 3m10s
The last unproven half of bec826b, and it needed a keyboard that stays behind
when the dock leaves. The dev box's by-tech hangs off the BenQ's own hub, so
it always walked away with the cable — a Logitech K400 with its receiver in
the laptop is the real case, and Bernardo happened to plug one in.

Both halves measured on hardware (dev box, 2026-07-14):

  - PRIMARY: across a real unplug + the fixed transition, the K400 kept its
    remembered `us`. Panel back, layout intact.
  - CONTROL: a bare `hyprctl reload` with no restore took the same board from
    `us` to the session's `gb`.

The control matters more than the pass. That reload clobbers runtime
`device[<name>]:kb_layout` was an inference from the mechanism when
restore_keyboards was written — reasonable, but unmeasured, and this session
has already shown what unmeasured reasoning about this watcher is worth.
It holds: without that call the undock's escalation would have silently
broken the layout of every keyboard that outlives the cable, trading a black
panel for a keyboard that types the wrong characters at the password prompt.

Verified V2 again after the comment/doc edits: nix flake check --no-build,
checks.docking-ux, shellcheck clean.

Round 8 now asks only for what is still open: relogin (the watcher calls the
transition by baked store path, so exec-once means it runs the old build
until then) and repetition — 5+ unplugs, one with the lid shut.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-14 12:35:23 +01:00
bec826baf0 fix(docking): the undock keyword is inert, not raced; escalate to reload
All checks were successful
Check / eval (push) Successful in 3m10s
Round 7 FAILED: 5-6 consecutive unplugs on the dev box, the panel never came
back. Round 6's retry could not have worked, because round 6's diagnosis was
wrong — and so was the "phantom re-add" theory I brought to this session.

With ZERO enabled outputs — panel disabled by the dock, external gone —
Hyprland 0.55.4 accepts `hyprctl keyword monitor eDP-1,preferred,auto,1`,
prints `ok`, exits 0, and never flushes it: the rule waits for a DRM event
that is not coming. It is inert, not raced. Retrying it 25x/poll for 6 polls
bought 30s of black screen and nothing else.

What made this survive two rounds is worth more than the fix: every
`transition=undock result=ok` in the round-6 journal was Bernardo plugging
the cable back in because the screen was black. His hotplug flushed the
queued rule and the next poll took the credit. Success was indistinguishable
from the user working around the failure, so the logs confirmed whichever
story we brought to them. Ten minutes of probing on hardware killed both.

Probed live 2026-07-14 (dev box, cable out): keyword inert across 4s and 5s
in two runs; `dispatch forcerendererreload` inert too; only `hyprctl reload`
escapes — 99ms and 289ms. So: issue the keyword (enough whenever another
output is still enabled, e.g. the menu's Dock mode), and escalate to reload
only when `monitors` proves it inert. After a reload, re-assert the rule so a
config that parks the panel off cannot undo the undock, and restore per-device
keyboard layouts — a reload re-reads the config and drops the runtime
`device[<name>]:kb_layout` an external board depends on. The menu's `enable`
now proves itself against `monitors` too, instead of cheering for an exit code.

Verified V3 (partial): the fixed transition driven through a real unplug —
panel on, workspaces 1-3 home, 1.8s, `keyword=inert escalate=reload` ->
`enable=via-reload` -> `result=ok`. V2: nix flake check --no-build; docking-ux
+ dock-audio; monitor-fallback.nix; shellcheck clean on display-transition.

NOT proven, and queued as HARDWARE-QUEUE round 8: the watcher-driven path
(exec-once + baked store path = relogin required, the round-5 trap) and
repetition. The keyboard-restore path is untested — the dev box's keyboard
hangs off the dock's hub, so it leaves with the cable; round 8 adds a check
with a keyboard in the laptop. The VM harness still cannot reach any of this:
QEMU aborts Hyprland if the last active output is deleted while the DRM output
is disabled, which is the same zero-output degeneracy from the other side.

New BACKLOG #114 (PROPOSED): tuigreet ignores per-device keyboard layouts —
a VT has one keymap, so the greeter cannot honour `device[]:kb_layout`.
Bernardo hit it logging out while docked; not a regression, a design gap.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-14 12:28:06 +01:00
28be6779a7 docs(hardware): round 6 settles 4 of 5 counts; headphones still untested
All checks were successful
Check / eval (push) Successful in 3m15s
Bernardo's round 6 report plus the keyboard result confirms on hardware:
wallpaper on the external, automatic dock mode, audio reaching the
monitor, and the external keyboard prompting exactly once (was four).

That leaves headphone jack-follow as the only count from rounds 4-6 never
tested at all, so it gets its own entry rather than staying buried in a
list of settled ones. Fold it into the round 7 session — the dock is
already in hand and the two checks don't interfere.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-14 11:37:52 +01:00
44ecc9094e fix(docking): retry the undock; a lost keyword left the panel black
Some checks failed
Check / eval (push) Has been cancelled
Round 6 passed on the plug side but unplugging the cable left the laptop
panel black until it was re-docked. The journal named it: across three
undocks, two logged result=enable-timeout and one result=ok — a race, not
a logic error.

Two halves, both needed:

  - `hyprctl keyword monitor eDP-1,preferred,auto,1` is accepted (exit 0)
    while Hyprland is still tearing down the departing external, then
    silently dropped. Re-issue it on every poll for 5s and keep `monitors`
    listing the panel — not hyprctl's exit code — as the only proof.

  - The watcher fired the undock once off the removal event and marked the
    departure handled regardless (`|| true` + awaiting_lid_open=1). Since
    known_outputs had already moved, the set-changed guard never reopened
    and nothing retried, so one lost keyword cost the panel until re-dock.
    The undock is now a queued invariant driven on the 1s tick, bounded at
    6 attempts so a genuinely unenableable panel can't pin the inhibitor.

The VM harness cannot reach this race: QEMU's headless backend aborts
Hyprland if the last active output is deleted while the DRM output is
disabled, so the test must undock before removing DP-1 and never collides
the two. Noted there, and V3 queued as round 7 — unplug 5+ times, since
the old failure only hit ~2 in 3.

Verified: V2 — nix flake check --no-build; monitor-fallback.nix exit 0 in
57s; shellcheck clean. The fix itself is V3 pending.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-14 11:35:12 +01:00
f625c0eaf4 feat(waybar): show the layout indicator only when the layout is ambiguous (#109)
All checks were successful
Check / eval (push) Successful in 3m27s
The indicator answers "which layout am I typing in?", so it is worth bar
space exactly when that question has more than one answer. It was gated on a
per-device override merely *existing*, which is the trap this box walked
into: the junk rows a keyboard's extra HID collections leave behind are all
remembered at the session layout, so they added no second answer and still
raised an indicator that could only ever show one value.

Gate on layouts rather than devices, and read the runtime-remembered
settings.keyboard.devices alongside the declared option — the new-keyboard
watcher writes those without a rebuild and they only graduate into
keyboard.devices later, so a bar reading the option alone was wrong for
exactly as long as the memory was fresh, which is when it matters most.

keyboard.layouts is now excluded on purpose: hyprland.nix keeps it as the
pool the picker offers first and deliberately never merges it into kb_layout,
so listing candidates cannot make the current layout ambiguous. A device
actually using one of them still does.

All four whole-swaps list the module unconditionally, so a single-layout
setup saw on a swapped bar what the generated bar hides — the parity rule was
already broken. Filter a swap through the same answer instead of asking four
hand-written files to re-derive it.

Logic is pure in modules/home/waybar-language.nix, matching monitor-rules /
dock-audio-rules; checks.waybar-language unit-tests both halves against
boreal's real swap, including a fixture-drift guard so the parity assertion
cannot pass vacuously.

Verified V2: new check passes, docking-ux and theme-wholeswap still pass,
`nix flake check` is clean, and the live config's generated bar now resolves
modules-right without hyprland/language on its single-layout default.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-14 11:10:48 +01:00
ce480f3669 fix(docking): dock automatically; repair cards parked on pro-audio
All checks were successful
Check / eval (push) Successful in 3m9s
Bernardo's retest on the AMD dev box. Two of the four reports were never
running the new code — both watchers are Hyprland `exec-once`, so a
`nomarchy-home` switch leaves the old processes alive until relogin; only
`nomarchy-dock-audio`, a user service, had picked the fix up. The journal
showed the old watcher's `event=monitoradded` next to the new audio tool's
`result=no-available-sink`, which is what gave it away. The other two were
real.

Automatic dock mode did not exist. The watcher auto-*undocked* when the last
external left, but on plug it only ever matched a display profile — and with
no profile saved and displayProfileAuto unset it did nothing at all, while
`Dock mode` was reachable only from the menu. Half the pair being automatic
is what made the dock feel broken: the session came home by itself but never
left. Mirror it, letting a matched profile win as the more specific layout.

The BenQ's audio was unreachable by any means because WirePlumber had the
GPU's DisplayPort card pinned to `pro-audio`, whose raw `pro-output-N` sinks
carry no ports, no routing and no volume — so no sink matched, nothing was
selectable, and the profile is stored per card and so survived forever. The
monitor's own USB card is input-only (every profile has sinks: 0), leaving
DisplayPort as the only path to its speakers. Repair the card when a plug
finds nothing routable, which is both the symptom and the only moment worth
touching profiles: narrow to cards carrying an *available* HDMI/DP port —
the monitor asking for audio over the cable — so the internal analog card is
never touched and a considered pro-audio setup elsewhere survives.

Verified V2: checks.docking-ux and checks.dock-audio pass with guards for
both; the repair filter picks exactly the GPU card → HiFi against the dev
box's live graph, leaving the BenQ USB, internal analog and Bluetooth cards
alone. Hotplug itself still needs hardware — HARDWARE-QUEUE round 6, which
now leads with the relogin requirement that invalidated round 5.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-14 10:56:52 +01:00
38ae6775e4 docs(backlog): settle #107 on state.json
All checks were successful
Check / eval (push) Successful in 3m14s
Bernardo's call — the rename target is decided, so drop the alternative
rather than leave whoever picks the item re-opening it.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-14 10:42:42 +01:00
e0da1ea40f fix(docking): don't lose the hotplug event; one prompt per keyboard; follow the jack
All checks were successful
Check / eval (push) Successful in 3m31s
Four failures from Bernardo's round-4 dock pass on the AMD dev box, plus a
headphone regression, tracked to three causes.

The display watcher ran socat with `-T 1`, which closes Hyprland's IPC
socket after one second of *inactivity*. A dock is normally plugged into an
idle desktop, so the socket was almost always mid-reconnect at that moment
and `monitoradded` was lost outright — and the wallpaper repaint, the
docking profile, and the dock-audio reprobe all hung off that single event.
The reconnect path recovered only the lid inhibitor, so the dock stayed
half-configured until something else poked the watcher.

Make the connection long-lived and take the periodic tick from a 1s `read`
timeout instead, then let the *output set* — not the event — decide: a new
`reconcile` diffs it and is called on both ticks and monitor events, so an
event now only makes the reaction instant rather than necessary. A newly
present external output is as unambiguous a physical plug as the event was,
so it may still override a manual sink choice. Wallpaper paints are retried
over the first few seconds because awww registers an output on its own
schedule, not the compositor's.

Hyprland calls every key-capable evdev node a "keyboard": the lid and power
buttons, a monitor's control channel, and each extra HID collection of a
real keyboard. One dock with one keyboard asked for a layout four times.
udev already draws the distinction properly, so take ID_INPUT_KEYBOARD from
it, map the names into Hyprland's spelling, and ask once per physical
keyboard — applying the answer to every node it brought with it.

Finally, a sink WirePlumber has been told to prefer is stored as the
configured default and outranks every priority rule. On UCM cards the
headphones are a sink of their own rather than a route, so once anything
pinned a sink (this feature on a dock plug, or the user in the Audio menu),
plugging headphones in could no longer take the audio back. The `watch`
loop already subscribed to exactly the right events with an empty handler;
finish it. Unplug needs no undo — the pin goes stale and WirePlumber falls
back by priority on its own, to the dock when docked and the speakers
otherwise.

Verified V2: checks.docking-ux and checks.dock-audio pass with new guards
against each regression; the keyboard filter and both sink selectors run
against the dev box's real devices and sink names. The hotplug paths need
hardware — queued as docking round 5.

Also queues #107, renaming theme.json now that it holds the system state.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-14 10:24:48 +01:00
9550d2be5e fix(live): git-init the seeded flake so doctor stops false-warning
All checks were successful
Check / eval (push) Successful in 3m26s
BACKLOG #99: the live-session seed (nomarchy-seed-flake) did a bare
`cp -r` with no `git init`, so `~/.nomarchy` was not a repo in the live
session and nomarchy-doctor's `git ls-files --error-unmatch
theme-state.json` false-alarmed "theme-state.json is NOT git-tracked"
(installed systems commit at nomarchy-install.sh:654 and were always
fine). The seed now init+add+commits, exactly like the installer.

Verified: V2 — focused serial-log VM run
(/tmp/nomarchy-v2-theme-99/): doctor prints "theme-state.json is
git-tracked" and "flake checkout is clean"; GIT_REPO_OK / TRACKED_OK
markers. A drv-equivalence test proves the seed change is
build-neutral: homeConfigurations.<user>.activationPackage is
byte-identical path-seeded vs git-seeded
(bvl30ggn...-home-manager-generation.drv). nix flake check green.

The same run exposed a separate, pre-existing failure — offline theme
switching in the live session rebuilds from source and dies offline —
filed as BACKLOG #113 (NOT caused by this change: same drv either way).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-13 20:14:04 +01:00
b645573149 docs(agent): park #94/#98/#99 with findings and in-flight state
All checks were successful
Check / eval (push) Successful in 3m13s
Session wrap-up: progress notes so the next iteration resumes without
re-deriving — #94 installed-chromium path proven at V1 (live posture is
the gap, #103 covers it), #98 ruled out palette-level causes, #99
root-caused (live seed lacks git init) with the fix drafted but held on
the open live offline theme-switch failure; a serial-log VM run is in
flight. V0 (notes only); two deliberate working-tree drafts documented
in the entries.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-13 18:37:33 +01:00
79528ff392 fix(menu): explain unsupported battery-limit and BlueZ-down states
Some checks failed
Check / eval (push) Has been cancelled
BACKLOG #96 + #97 (Acer live pass). Battery limit: the System row now
gates on battery presence (same rule as Power profile) instead of the
charge-threshold sysfs node, so firmware without the control gets the
leaf's explanation ("no charge_control_end_threshold — hardware
capability") rather than a silently missing row that reads as a broken
feature. Bluetooth: the leaf checks bluetooth.service before launching
blueman-manager and replaces the raw "Connection to BlueZ failed" with
a state-aware message (service stopped vs no adapter in
/sys/class/bluetooth, with rfkill/firmware hints).

Verified: V1 — nomarchy-menu builds, bash -n green, both paths in the
generated script; V0 flake check green. QEMU can fake neither a
threshold-less battery nor a BT adapter: #96 behavioral check queued in
HARDWARE-QUEUE (Acer + T14s); #97's no-adapter message will be
click-checked in the next VM run (V2 pending).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-13 18:36:19 +01:00
10853f662c feat(menu): direct SUPER+CTRL+I / SUPER+CTRL+T binds for System and Tools
Some checks failed
Check / eval (push) Has been cancelled
BACKLOG #107: both submenus get conflict-free direct bindings through
the canonical keybinds.nix (single source for Hyprland binds and the
SUPER+? cheatsheet) — I follows the Super+I settings muscle memory,
T is Tools. The root-menu rows now show the shortcuts via menuHint.

Verified: V2 — eval audit: 41 binds, no duplicate mods+key; both binds
present in the generated Hyprland config; built nomarchy-menu passes
bash -n with cheatsheet rows + hints rendered. In-session on the live
ISO KVM run: viewed 40-system-menu.png / 41-tools-menu.png in
/tmp/nomarchy-v2-swap-93/ — each bind opens its submenu directly.
V0 flake check green.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-13 18:33:54 +01:00
c97ecade63 fix(install): label and explain the swap-size prompt
Some checks failed
Check / eval (push) Has been cancelled
BACKLOG #93 (Acer live pass): the swap field appeared as a bare
prefilled number — gum never renders the placeholder once --value is
set, and the block had no section header, so nothing said what the
value meant. Add a visible "Swap & hibernation" section, two info lines
(default = RAM keeps hibernation possible; 0 = no swap), a labeled
--header on the input, and make the info + Review lines state the
outcome in full ("N GiB swapfile (enables hibernation)" /
"none (hibernation disabled)"). Default behavior unchanged (swap = RAM).

Verified: V2 — interactive KVM UI drive of the built ISO; viewed
10-swap-prompt.png (section + explanations + labeled input) and the
Review box ("Swap: 6 GiB swapfile (enables hibernation)") in
/tmp/nomarchy-v2-swap-93/; the wipe confirmation was aborted, nothing
touched. bash -n + nix flake check --no-build green.

Side-finding recorded on #112: QEMU's /dev/fd0 was listed first by the
disk picker (live repro evidence in the same artifacts).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-13 18:33:01 +01:00
f7246941e8 fix(power): exempt charge-limit oneshot from start rate limiting
All checks were successful
Check / eval (push) Successful in 3m9s
Round 1 of #101's V3 failed on TuringMachine (2026-07-13 18:16): a
SPACED dock/AC event storm — each run finishing before the next event —
never coalesces, so 5 successful sub-second starts inside systemd's
default 10s window tripped start-limit-hit and marked the unit failed
(doctor badge) although every run succeeded. The write is idempotent
and cheap; rate limiting it only manufactures false failures, so the
unit sets StartLimitIntervalSec=0.

checks.battery-charge-limit now asserts the exemption and runs 8
sequential starts expecting a clean, non-failed state; the original
mid-run coalescing phase is unchanged.

Verified: V2 — the check passes under KVM (exit 0). V0 flake check
green. V3 round 2 re-queued in HARDWARE-QUEUE.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-13 18:26:00 +01:00
720d9a38fb feat(template): document Nextcloud client opt-in via upstream HM service
All checks were successful
Check / eval (push) Successful in 3m15s
BACKLOG #102: a commented services.nextcloud-client example in the
downstream template (enable + startInBackground) instead of only the
bare package line — the upstream Home Manager module installs the app
AND ties it to graphical-session.target, so one block covers install,
session autostart, and clean removal. No Nomarchy wrapper added.

Verified: V1 — pinned HM 26.05 module options confirmed; eval test of
the uncommented block through lib.mkFlake shows the user unit
(ExecStart nextcloud --background, WantedBy/PartOf
graphical-session.target); nix flake check --no-build green
(template-sot, downstream-template-home included). Runtime lifecycle is
the upstream module's contract.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-13 17:57:14 +01:00
5d97cf23fe docs(agent): queue desktop and menu follow-ups
All checks were successful
Check / eval (push) Successful in 3m18s
Promote NEXT items #102-#112 for Nextcloud, live ISO apps, airplane mode, menu navigation and hierarchy, keybinding discoverability, keyboard indicator parity, Control Center retirement, recovery terminology, and installer disk-picker safety. Expand #96 for the live-ISO power policy and record #92's completed KVM UI verification.

Verified: V0 source audit and git diff --check; #92 additionally reached V2 on the exact current ISO under KVM.
2026-07-13 17:19:18 +01:00
d09c11d872 fix(install): constrain keyboard selection to XKB catalog
All checks were successful
Check / eval (push) Successful in 3m17s
Make keyboard typing filter-only with Gum strict mode, validate interactive and unattended choices against the pinned XKB catalog before any disk mutation, and show resolved human-readable choices in review.

Verified: V1 flake eval, focused installer builds, shell/Python/diff checks; current-ISO KVM boot proved the packaged new flow while the pre-destructive interaction pass continued.
2026-07-13 17:07:08 +01:00
9d0abe5422 fix(install): normalize empty keyboard variants
All checks were successful
Check / eval (push) Successful in 3m18s
Treat the picker-only (none) row as an empty XKB variant at the shared input boundary and again in the template patcher. Guard the real downstream output and console keymap, and carry the sentinel through the offline installer VM.

Verified: V0 full flake evaluation plus shell/Python/Nix/diff checks; V1 installer-keyboard, installer-safety, and template-SoT builds; V2 full KVM offline LUKS+swap install and themed first boot. No V3 required.
2026-07-13 15:01:51 +01:00
7bfe1af5b1 fix(power): coalesce charge-limit reapply events
All checks were successful
Check / eval (push) Successful in 3m16s
USB-C power event bursts could repeatedly restart and SIGTERM the settling oneshot until systemd reached its start limit. Let successful runs return inactive and use coalescing start jobs for immediate and delayed reapplication.

Verified: V0 nix flake check --no-build, module parse, and git diff --check; V2 focused battery-charge-limit KVM test with a 12-event active-run burst. V3 hardware item #101 remains queued.
2026-07-13 14:09:05 +01:00
2a34c7398b fix(docking): make workspace and lid transitions atomic
All checks were successful
Check / eval (push) Successful in 3m19s
Restore the old flake's external-only workspace handoff, protect closed-lid cable removal with a verified low-level logind inhibitor, and use monitor-added as the settled audio reprobe boundary.

Verified: V2 — nix flake check --no-build; docking-ux, option-docs, and template-sot checks; KVM tools/monitor-fallback.nix lifecycle test.

V3 pending: closed-lid BenQ round 4 in agent/HARDWARE-QUEUE.md.
2026-07-13 13:56:54 +01:00
cffe432912 docs(agent): queue live ISO hardware findings
All checks were successful
Check / eval (push) Successful in 3m20s
Record the Acer Aspire install and session feedback as separate NOW items #91-#99, with the literal (none) XKB build blocker first and acceptance criteria for each issue.

Verified: V0 — source screenshots reviewed and git diff --check passed. Implementation verification remains item-specific.
2026-07-13 12:03:13 +01:00
7ebfab4bd4 fix(docking): make clamshell hotplug safe and discoverable
All checks were successful
Check / eval (push) Successful in 3m32s
Real dock QA exposed pointer-bound menus, an absent default rescue watcher, the unsafe zero-output panel-off path, missing keyboard/audio hotplug coverage, and unpainted new outputs.

Route Rofi to the focused output; replace panel disable with safe workspace migration; always run and reconnect the display watcher and repaint hotplugged outputs; expose a default per-device keyboard picker; handle audio card availability transitions.

Verified: V1 — nix flake check --no-build; checks.docking-ux real Home Manager artifact build; option-docs/template-sot; Nix parse, py_compile, and git diff --check. V2 unavailable: no readable KVM. V3 pending in agent/HARDWARE-QUEUE.md.
2026-07-13 11:53:01 +01:00
nomarchy-bump
16846786e2 chore(lock): scheduled upstream bump
• Updated input 'home-manager':
    'github:nix-community/home-manager/af2beae5f0fae0a4310cc0e6aef2572f56090353?narHash=sha256-ESQnuNHEDChsB4IxoLRhscVahqkDWkTb%2BqdIz8euYt4%3D' (2026-07-05)
  → 'github:nix-community/home-manager/3cd22efe6471dc7365c822bd9ad73a21e55f38fb?narHash=sha256-qajyHfZY29G2oEQk%2BuHxmsJcRoBUBXP9maTpFlwP/dI%3D' (2026-07-11)
• Updated input 'nixos-hardware':
    'github:NixOS/nixos-hardware/662bd6e312d2c8b212e32cb377abaee190749320?narHash=sha256-E%2B3MIMvKuo9k%2BK%2BqLQ9YXzsBzkgHuyVLnsEbN2DFfuc%3D' (2026-07-06)
  → 'github:NixOS/nixos-hardware/8efb4337e857949f4cfac86d12ef1066f417f31f?narHash=sha256-50rvY9GdFvpYDcMLcD/4cWSi0hVxArT5wsGlVsHy8eY%3D' (2026-07-11)
• Updated input 'nixpkgs':
    'github:NixOS/nixpkgs/a50de1b7d8a586adc18d2395c19de7d6058e6030?narHash=sha256-uslt2pqShTIXDdAHRHv2QkYLsVdY8Oqwz0EA48/RSM8%3D' (2026-07-04)
  → 'github:NixOS/nixpkgs/8f0500b9660505dc3cb647775fe9a978a74b5283?narHash=sha256-O3/YajjWo001VUIgD8BwaRdSNLUFe7nZ1qV5TwhRBcw%3D' (2026-07-10)

Gate: nix flake check --no-build (eval tier — see bump.yml header).
2026-07-13 05:23:25 +00:00
b898d59295 Merge remote-tracking branch 'origin/main' into main
All checks were successful
Check / eval (push) Successful in 3m2s
Lock bump / bump (push) Successful in 6m30s
2026-07-12 20:21:23 +01:00
ae6fe1d679 docs: update agent backlog, journal, and roadmap 2026-07-12 20:20:35 +01:00
53e3c32852 removing the ISO eval from the gitea workflow
All checks were successful
Check / eval (push) Successful in 3m9s
2026-07-12 20:07:13 +01:00
67400b07dc fix(ci): memory-bounded eval — one output per nix process
Some checks failed
Check / eval (push) Failing after 7m59s
check.yml has been permanently red since the runner container was
capped at 2 GB (uncapped, it OOM'd the whole 4 GB VPS). Root cause,
measured with GNU time (eval-cache off): `nix flake check --no-build`
walks every output in ONE evaluator process and peaks at ~6.0 GB RSS
on this repo — the checks.* suite alone is ~15 runNixOSTests, each a
full NixOS eval, all accumulating in one heap.

New tools/ci-eval.sh: identical coverage (all checks.* drvPaths, both
nixosConfigurations toplevels, the HM activationPackage — enumerated
dynamically, no drift), one fresh nix process per output so the heap is
freed between outputs. Cold per-output peaks: worst check 0.99 GB
(hardware-toggles), nomarchy toplevel 0.78 GB, HM 0.60 GB; the one
outlier is nomarchy-live at 2.69 GB (live-ISO eval), which fits the
2 GB cap only via the container's swap allowance (docker's default
--memory-swap is 2x memory). check.yml and bump.yml both call the
script now; bump.yml also gains max-jobs=1/cores=2 (it had no limits,
so its V1 build gate could spawn parallel builders in the capped
container). ROADMAP's stale "no CI today" corrected: bump.yml landed
8fded63 on schedule 2026-07-06.

Verified: V1+ — ci-eval.sh green locally end-to-end (3m22s, tree peak
2.75 GB = nomarchy-live's process); per-output peaks measured
individually. The decisive proof is the next Actions run on the VPS
itself — watch the nomarchy-live step; if it OOMs there, the runner
needs swap allowed: --memory=2g --memory-swap=6g.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-12 19:39:46 +01:00
6f2fbde4f1 docs(fingerprint): parallel prompt in HARDWARE.md §5 + MIGRATION note
Some checks failed
Check / eval (push) Failing after 1m31s
The feature commit updated the README table, template, ROADMAP and
HARDWARE-QUEUE but missed the canonical user-facing page: HARDWARE.md
§5 gets a "Parallel prompt" subsection (default, opt-out, structural
lockout safety, grep to verify, known leftover-prompt quirk) and the
option-summary table row; MIGRATION.md's fingerprint bullet points at
it. Doc-only, V0.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-12 19:10:19 +01:00
7aae204014 feat(fingerprint): password OR fingerprint in parallel at one prompt
Some checks failed
Check / eval (push) Failing after 2m45s
Bernardo promoted the PROPOSED item live: with fingerprint PAM on,
sudo/login should accept whichever factor comes first instead of
pam_fprintd's wait-for-the-reader-then-password. Stock PAM cannot
express parallel factors (linux-pam#301), so this packages
pam-fprint-grosshack v0.3.0 (pkgs/, pinned from GitLab — the
field-standard fprintd fork), source-reviewed before packaging: every
failure path (no reader, no prints, fprintd absent/hung, timeout,
password typed) returns PAM_AUTHINFO_UNAVAIL and falls through; a
typed password is only ferried via PAM_AUTHTOK to the stock
`auth sufficient pam_unix.so … try_first_pass` rule — the module never
validates passwords itself, so it cannot lock out password login.

New option nomarchy.hardware.fingerprint.parallel, default TRUE (the
better UX is what opting into fingerprint PAM buys; false = stock
sequential). Wiring swaps the modulePath of stock fprintd's rule slot
(mkForce) so the sufficient-before-pam_unix ordering is inherited, not
recomputed. README + downstream template rows added.

Verified: V2 — checks.hardware-toggles extended to three nodes, green:
parallel node asserts the grosshack auth line precedes pam_unix in
/etc/pam.d/sudo and that with NO reader a correct password still
passes sudo while a wrong one fails (the lockout-safety invariant);
seqpam node gets stock pam_fprintd and no grosshack; nopam gets
neither. flake check + option-docs + template-sot green.
V3 pending (HARDWARE-QUEUE, AMD dev box): the real type-or-touch race,
fprintd-stopped fallback, hyprlock/greeter after a fingerprint win.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-12 19:03:56 +01:00
c840202018 feat(display): blackout rescue — re-enable a disabled panel on undock
Some checks failed
Check / eval (push) Failing after 1m40s
Bernardo's question: with "Laptop screen off" active, does the panel
come back when the dock is yanked? VM answer (new maintainer harness
tools/monitor-fallback.nix, softGL desktop): no — Hyprland 0.55.4
leaves the session with zero active outputs and never re-enables a
soft-disabled monitor. rescue_blackout in the display hotplug watcher
now re-enables every disabled output (immediate, retried, idempotent
keyword + toast) whenever a monitorremoved leaves nothing active;
independent of the profile auto-switch and its settings gate.

Sharp edge documented rather than hidden: the zero-output state itself
crashes Hyprland in the VM ~4/5 runs (aquamarine CBackend::dispatchIdle
ABRT within ~0.5s — faster than any userland rescue; a control run
removing the external with the panel ACTIVE survives 5/5, so the state
is the trigger, not headless removal; crash reproduced with the rescue
provably inert, so not caused by it). Rescue ships as defense-in-depth:
it recovers every surviving case, and the worst case on real DRM is
session-to-greeter on a re-lit panel instead of a black brick. Rider
fix: `grep -c .` exits 1 at count zero — the ||-guard on that pipeline
silently disabled the rescue exactly when it mattered.

Verified: hazard V2 (VM reproduces the dead state); control
discriminator 5/5; flake check green. V3 pending: real-dock
undock-while-off in HARDWARE-QUEUE (run with nothing important open;
crash on real DRM = rework the row to mirror or wait for a Hyprland
bump).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-12 18:09:17 +01:00
6439105d38 feat(audio,menu): dock audio auto-follow watcher + docked Display/Audio menu rows
Some checks failed
Check / eval (push) Failing after 1m41s
Bernardo's dock test (the #87 V3): audio stayed on EasyEffects→speakers.
Root cause, established live: WirePlumber's stored default
(default-nodes state, written by any past explicit pick) outranks the
priority.session rules, so #87 alone can never move the default on a
real machine; EasyEffects 8 follows the default device (kcfg default
true, verified by watching its output links). Fix: nomarchy-dock-audio
(modules/home/dock-audio.nix, nomarchy.dockAudio.enable, default on) —
a pactl-subscribe watcher that set-default-sinks new dock-class sinks
(regex data imported from modules/nixos/dock-audio-rules.nix, single
source) with a toast, plus a startup sweep for login-while-docked that
never overrides a manual in-dock pick. Unplug falls back by priority.

Menus: Display gains docked rows — "Laptop screen off · everything →
<ext>" (live-only disable; a persisted one could black-screen an
undocked boot), "Screen on · <name>", "Move workspace · → next
monitor", "Swap workspaces" (exactly-2 gate); Audio gains a self-gated
"Send output → <dock sink>" quick row. All self-gate on real outputs.

Verified: V2-equivalent on live hardware (no dock available) — watcher
followed a fake dock-named null sink and fell back on unload, control
run without the watcher stayed pinned (proving the stored-default
diagnosis), EE output links moved mid-playback; generated menu bash -n
green, dock-row jq exercised on real pactl JSON, dispatchers probed on
live Hyprland; flake check + option-docs + template-sot green.
V3 pending: real-dock retest ×2 in HARDWARE-QUEUE (watcher keys on
new-sink events; pre-existing nodes that only flip route availability
would need the queued diagnosis data).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-12 14:22:49 +01:00
9e37e11915 feat(lifecycle): auto-commit sweep of the machine flake before pull/rebuild/home
Some checks failed
Check / eval (push) Failing after 3m11s
With settings.autoCommit on, only menu/theme mutations were committed
(theme-sync pathspec-limits to theme-state.json by design), so hand
edits to system.nix/home.nix and lock bumps stayed forever-dirty.

New internal nomarchy-autocommit (nomarchy-lifecycle): live-reads the
same flag, commits everything dirty as "nomarchy: auto-commit before
<pull|rebuild|home switch>" with the swept file list in the body,
fallback identity, never fatal (|| true at call sites). Called at the
top of all three lifecycle commands; before the ff-only pull it also
un-dirties the tree. Exposed via passthru.autocommit + package export;
guarded by checks.lifecycle-autocommit (sandbox repos: sweep, no-op on
clean/off/non-repo). Sync sweep: README, RECOVERY, ROADMAP, rofi
toggle, control-center prompt, theme-sync docstring, JOURNAL.

Verified: V1+ — flake check --no-build green; the new check builds;
end-to-end run of the built nomarchy-rebuild (stubbed sudo/rebuild)
against a copy of a real dirty ~/.nomarchy: 4 files → one commit,
clean tree. No VM boot — no session-visible surface.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-12 12:52:05 +01:00
cc9caf802e docs(agent): #89 slice 2 done (EasyEffects icon); Signal pixmap open
Some checks failed
Check / eval (push) Failing after 4m39s
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-12 08:38:32 +01:00
f9d5e2c0af chore: untrack a stray HM temp file; ignore settings.local.json.tmp.*
Some checks failed
Check / eval (push) Has been cancelled
A settings.local.json.tmp.* atomic-write temp file slipped into the
previous commit via git add -A; untrack it and ignore the pattern. (The
prior commit message also dropped the word text to a stray backtick:
the override glyph is filled with the palette text colour.)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-12 08:32:31 +01:00
b98248fad8 feat(theme): themed monochrome override for the EasyEffects tray icon (#89)
Some checks failed
Check / eval (push) Has been cancelled
EasyEffects 8 publishes SNI IconName com.github.wwmm.easyeffects, which
Papirus renders as its full-colour blue equaliser — the one splash of
colour in an otherwise monochrome Waybar tray (nm-applet, udiskie & co
resolve to Papirus symbolics).

Fix at the shared icon-theme layer so it holds for ALL themes at once:
wrap the resolved icon set in a thin child theme, Nomarchy-icons, that
Inherits it and ships a palette-coloured monochrome easyeffects glyph
(the app's own -symbolic shape, filled with the theme's  colour —
the same hue as the bar's glyphs, so it tracks every theme/mode on
switch). The child becomes the session icon theme; a scalable override
out-resolves the parent's fixed-size icon at every requested size, while
every other icon still falls through to the parent unchanged.

Since iconTheme feeds both stylix (GTK) and rofi, wrapping it once here
covers generated and whole-swap themes with no per-theme files. Adding a
future offender is one more SVG in scalable/apps.

Mechanism validated by GTK IconTheme lookup (override wins at 16/22/24/
32px; udiskie/nm/folder icons still resolve from Papirus). Icon pkg
builds; fill=#D3DAE0, Inherits=Papirus-Dark under Boreal.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-12 08:31:31 +01:00
190e13350b docs(journal): tray menus verified fixed on the real nm-applet menu
Some checks failed
Check / eval (push) Failing after 2m0s
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-12 08:20:38 +01:00
72e53e29a1 fix(waybar): menu font rule must hit label nodes, not rely on inheritance
Some checks failed
Check / eval (push) Has been cancelled
The bar '*' matches every node directly — including menu labels — so an
inherited font from 'menu' never applies. 'menu, menu *' out-specifies
it. (Real-menu screenshot showed arrows/checks/icons fixed by 8eb672b
but labels still in the bar's mono face.)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-12 08:18:12 +01:00
8eb672bada fix(waybar): carry tray-menu counter-rules in the bar stylesheet itself
Some checks failed
Check / eval (push) Has been cancelled
The e8658fe cure (USER-priority gtk.css counter-rules) verified in a
plain GTK app but not in the real Waybar-hosted nm-applet menu: Waybar
adds its provider at GTK_STYLE_PROVIDER_PRIORITY_USER as well
(src/client.cpp), and in practice its rules win the tie against
~/.config/gtk-3.0/gtk.css on GTK 3.24 — reproduced by injecting the
bar stylesheet above USER priority: arrow, checkmark and separator all
vanish, matching the live menu exactly.

The only placement that wins at ANY provider priority is inside the
same stylesheet: after each '*' reset, restore the CSS-sized menu
nodes (menuitem arrow 16px, check/radio 14px, separator 1px line) and
give menus a text face instead of the bar's glyph font. Applied to
boreal + executive-slate waybar.css and waybar.nix generatedStyle;
stylix.nix comment corrected (its rules now cover ordinary GTK apps
only).

Ground truth checked over D-Bus first: nm-applet exports per-network
Wi-Fi icons as dbusmenu icon-data, and with gtk-menu-images=1 the
connected network's icon already renders in the live menu — the
remaining loss was arrows/checks/separators, all CSS-box nodes.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-12 08:15:08 +01:00
0416eafd71 docs(journal): record the #90 scoping regression + USER-priority correction
Some checks failed
Check / eval (push) Failing after 2m49s
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-12 08:04:12 +01:00
e8658fea5f fix(gtk): tray-menu arrows via USER-priority counter-rule; show menu icons
Some checks failed
Check / eval (push) Failing after 1m26s
Two corrections to ac7e6bd, which broke the bar it meant to spare:

1. Scoping the waybar '*' reset to 'window#waybar *' gave every reset
   property id-level specificity, so it started BEATING the later
   class rules (Boreal's pill border-radius/borders, workspace pills)
   that plain '*' correctly lost to — visibly wrecking the bar.
   Reverted all three stylesheets to the plain '*' block (bar restored
   byte-identical to pre-ac7e6bd) and instead restored the arrow where
   priority genuinely outranks waybar's stylesheet: stylix's gtk.css
   loads at USER priority (800) vs waybar's APPLICATION (600), so a
   'menu menuitem arrow { min-width/height: 16px }' there survives the
   reset in Waybar-hosted menus — the exact mechanism the neighbouring
   check/radio rule already uses for the same collapse. NB comments at
   each '*' block warn against re-scoping.

2. The 'missing icons' were never just arrows: GTK3 ships
   gtk-menu-images=false since 3.10, so image menu items (nm-applet's
   Wi-Fi list, udiskie) render iconless everywhere. Set
   gtk.gtk3.extraConfig.gtk-menu-images = 1 (merges into the
   settings.ini Stylix already writes).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-12 08:01:01 +01:00
4cdc80c9bc docs(agent): close #76 — hibernate→resume V3 PASSED on TuringMachine
Some checks failed
Check / eval (push) Failing after 1m31s
Bernardo ran the real power-cycle on the T14s (LUKS + btrfs @swap
swapfile): hibernate → power off → single LUKS unlock → session
restored. BACKLOG #76 deleted; HARDWARE-QUEUE resume entry pruned
(outcome recorded in the remaining no-swap-notify entry, JOURNAL, and
the ROADMAP ✓-entry); Decisions hibernation note marked shipped. The
no-swap Hibernate notify check remains the one open leftover.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-12 07:52:10 +01:00
ac7e6bdb4e fix(waybar): scope the universal CSS reset to the bar — un-hides tray submenu arrows
Some checks failed
Check / eval (push) Failing after 1m22s
BACKLOG #90 root-caused: the arrows were never missing from the icon
theme — Papirus-Dark resolves pan-end-symbolic through its breeze-dark
inheritance (breeze ships inside the nixpkgs papirus output). The real
culprit: Waybar loads its stylesheet screen-wide at application
priority, so the tray menus it hosts for SNI apps (nm-applet & co)
inherit the bar's '* { min-height: 0; }', which out-prioritizes
adw-gtk3's 16px min-height on the menuitem arrow node and collapses
the submenu arrow to zero height. Property bisect confirmed
min-height:0 alone reproduces; the font/border resets don't.

- boreal + executive-slate waybar.css, waybar.nix generatedStyle:
  '*' block scoped to 'window#waybar, window#waybar *'; tooltip rules
  restate the font (tooltips are separate toplevels outside the scope).
- stylix.nix: drop the go-next-symbolic remap — a workaround for the
  misdiagnosis; native pan-end-symbolic resolves fine.
- BACKLOG #90 deleted; JOURNAL entry records the method (live GTK3
  repro + grim screenshots + CSS property bisect, V3 on TuringMachine).

V3 pending: human click on the real nm-applet tray icon post-switch.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-12 07:47:37 +01:00
a9c1d8af12 fix(template): enable Widevine DRM in the shipped chromium
Some checks failed
Check / eval (push) Failing after 1m27s
Stock nixpkgs chromium has no Widevine CDM, so DRM streams (Netflix,
Spotify web player) fail out of the box. Override with
enableWideVine = true — allowUnfree is already on in the template,
and the wrapper pulls the prebuilt CDM (~no rebuild, binary-cached
chromium plus the -wv variant). Verified live on TuringMachine
(downstream flake updated the same way, CDM present in the wrapper).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-12 07:27:06 +01:00
5e1bd057d5 docs(agent): enqueue stuck nm-applet submenu arrow issue
Some checks failed
Check / eval (push) Failing after 5m41s
2026-07-11 21:09:37 +01:00
2020ab5853 fix(gtk): bypass fragile SVG recoloring with static palette assets
Some checks failed
Check / eval (push) Failing after 1m55s
GTK's -gtk-recolor proved too fragile across different contexts, failing
to substitute the foreground color even when the SVG path used #000000.
Instead, we now generate two statically colored SVGs (normal + hover)
directly from the Nix palette hex codes, bypassing GTK's SVG recoloring
engine entirely.
2026-07-11 21:07:25 +01:00
ebfd344390 fix(gtk): add fill=#000000 to bundled submenu arrow SVG
Some checks failed
Check / eval (push) Failing after 1m33s
GTK's -gtk-recolor requires the explicit string #000000 inside the SVG
to correctly substitute the foreground color. Without it, the shape defaults
to a hard un-recolorable black, which remained invisible against the dark
popover menu backgrounds. Added viewBox for robustness.
2026-07-11 20:16:11 +01:00
3057a9e239 docs(journal): 2026-07-11 interactive T14s session entry
Some checks failed
Check / eval (push) Failing after 3m12s
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-11 19:35:54 +01:00
6967d8973c fix(home): easyeffects waits for a live tray host, not waybar.service
Some checks failed
Check / eval (push) Failing after 4m10s
The tray-race guard (wwmm/easyeffects#4636) ordered the unit
After=waybar.service — inert here: Nomarchy's waybar runs from the
nomarchy-waybar supervisor (Hyprland exec-once), never as a systemd
unit, so the icon vanished whenever easyeffects won the boot race
(seen on TuringMachine 2026-07-11). Gate ExecStartPre on the SNI
watcher's IsStatusNotifierHostRegistered with a 30s timeout, non-fatal
so audio processing still starts on tray-less setups.

Also: #89 inventory narrowed by Bernardo's visual verdict — Telegram/
ZapZap fine; offenders are EasyEffects (slice 2) and Signal (slice 3).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-11 19:28:55 +01:00
dbc7741f58 docs(backlog): #89 slice-1 tray inventory done; #76 unblocked by migration
Some checks failed
Check / eval (push) Failing after 1m37s
Tray SNI inventory run live on the T14s: nm-applet, udiskie, Telegram
and EasyEffects are name-based (icon-lookup overridable); Signal and
ZapZap publish pixmaps only (slice 3). Method: watcher's
RegisteredStatusNotifierItems + busctl get-property.

#76: the dev box now runs Nomarchy (migrated 2026-07-11), so the
'not a Nomarchy machine' blocker is gone — retagged [human]; queue
step 1 (swapfile/zram/resume_offset wiring) verified live, only the
power-cycle test remains.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-11 19:21:50 +01:00
c608f58b5a fix(home): pin xdg.userDirs.setSessionVariables — HM 26.05 default flip
Some checks failed
Check / eval (push) Failing after 3m33s
home-manager 26.05 changes the default to false and warns on every
eval when home.stateVersion is older (every migrated machine). Pin the
legacy true: scripts and non-glib apps read the XDG_*_DIR session vars,
and an explicit value gives all downstreams identical behavior
regardless of stateVersion. mkDefault so a downstream can still opt out.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-11 19:13:19 +01:00
13ecfa92f0 fix(gtk): bundle the submenu arrow — Papirus has no pan-end-symbolic
Some checks failed
Check / eval (push) Failing after 1m33s
05bab55's menu fix pointed -gtk-icon-source at the icon theme, but
Papirus doesn't ship pan-end-symbolic and its inheritance chain
(breeze-dark → hicolor) has no provider installed, so GTK menu submenu
arrows rendered as nothing (nm-applet: no cue that 'VPN Connections'
opens a submenu). Bundle the triangle as a store-path SVG recolored
via -gtk-recolor(url(…)) — the same mechanism adw-gtk3 uses for its
own check-mark assets, immune to icon-pack coverage.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-11 19:06:11 +01:00
61d591f5d2 feat(waybar): explain 'Not charging' hold state in the battery tooltip
With a charge cap, AC-plugged inside the hysteresis band (power.nix:
start = cap − 10) reports 'Not charging' — indistinguishable from a
broken charger without a hint. tooltip-format-plugged now says the cap
is holding the level and how charging resumes. No cap value baked: the
menu changes it live without a rebuild.

Whole-swap parity: the same tooltip added to all four theme
waybar.jsonc files (boreal/executive-slate/summer-day/summer-night) —
the generated config is bypassed under these themes.

Verified on hardware (TuringMachine, boreal theme, cap 80, battery 71%
in the hold band): all four jsonc validate with jq; home-manager switch
deploys the boreal config with the tooltip; battery currently in the
exact Plugged state the tooltip targets.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-11 19:06:11 +01:00
b39808d847 fix(waybar): VPN tooltip names the active VPN(s)
The shield's tooltip said only 'VPN active' — with both NM VPNs and
Tailscale feeding the same indicator, a forgotten tailscaled read as
'the icon is stuck after disconnecting'. List the active NM
VPN/WireGuard connection names and/or 'Tailscale' in the tooltip.

Verified on hardware (TuringMachine): script exercised with Tailscale
up → tooltip 'VPN: Tailscale'; tailscale down → empty output
(self-hide). NM name path is the same awk the nomarchy-vpn picker uses.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-11 19:06:11 +01:00
Bernardo Magri
50a558615c feat(hyprland): SUPER+0 → workspace 10; migration doc gains autoLogin
Workspace binds were generated for 1-9 only, leaving SUPER+0 dead.
Generate 10, with the 0 key mapping to workspace 10 (and SHIFT+0 to
move); cheatsheet updated to match.

MIGRATION.md's example system.nix now includes
nomarchy.system.greeter.autoLogin — the migration path missed what
nomarchy-install sets on every LUKS machine, so migrated boxes stopped
at tuigreet asking for a password behind the LUKS prompt.

Verified: nix flake check --no-build passes; generated
homeConfigurations.nomarchy hyprland binds eval'd and contain
'$mod, 0, workspace, 10' + SHIFT variant. V3 next: rebuild on the
migrated T14s (TuringMachine) and press SUPER+0.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-11 19:06:11 +01:00
14dd663d56 Update .gitea/workflows/check.yml
Some checks failed
Check / eval (push) Failing after 1m39s
2026-07-11 18:35:13 +01:00
Bernardo Magri
919dc4afc0 docs(backlog): promote tray icon parity → NEXT #89
Some checks failed
Check / eval (push) Failing after 1h16m53s
Bernardo accepted the EasyEffects tray-icon pitch and widened it: all
SNI apps (Signal/Telegram/ZapZap flatpaks too) should read monochrome
in the bar. Three slices: bus inventory (IconName vs IconPixmap),
HM shadow-icon override layer for name-based items, per-app knobs for
pixmap ones. PROPOSED pitch folded in.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-11 17:10:31 +01:00
Bernardo Magri
482cb87c70 fix(menu): release Left from kb-move-char-back before binding it to Back
Some checks failed
Check / eval (push) Failing after 8m34s
rofi hard-fails on a duplicate keybinding and reports it in a GUI error
dialog, not stderr — so 05bab55's Left=Back killed every menu entry
point on a real session while passing all stubbed tests. Rebind
kb-move-char-back to Control+b in both rofi_menu copies (menu + vpn).

V3 on the migrated T14s: error dialog reproduced with old flags, menu
renders with fixed flags. Journal + two PROPOSED pitches (EasyEffects 8
tray icon, parallel fingerprint/password PAM).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-11 16:58:04 +01:00
Bernardo Magri
2200379ea2 docs(migration): browser recovery — manual Web Store reinstall path
Some checks failed
Check / eval (push) Failing after 4m50s
Re-declaring is optional; ids are stable so restored data reattaches
on a plain Web Store reinstall too.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-11 15:37:28 +01:00
Bernardo Magri
42542334e8 docs: backfill 05bab55 journal entry; real cause in MIGRATION browser note
Some checks failed
Check / eval (push) Has been cancelled
The migration session crashed before journaling. Follow-up diagnosis:
extensions vanished because programs.chromium.extensions wasn't carried
into the downstream flake — Chromium external-uninstall deletes the
extensions AND their Local Extension Settings. Rewrite the browser
migration note with the mechanism and the snapshot recovery path.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-11 15:35:07 +01:00
Bernardo Magri
05bab5576e fix: migration first-boot, fingerprint PAM, menus, mic LED
Some checks failed
Check / eval (push) Failing after 9m12s
Post-migration annoyances from TuringMachine:

- Force fprintAuth from fingerprint.pam (NixOS defaults it on with fprintd)
- First-boot toast waits for swaync, retries, only marks after success
- Night light status/toggle handles masked units + hyprsunset/wlsunset
- VPN menu: define rofi_menu in nomarchy-vpn (was a silent no-op)
- Left arrow = ↩ Back in list menus; GTK menu contrast via Stylix
- Mic-mute LED udev + sync after wpctl; EasyEffects starts after tray
- Migration guide: HM before GUI login, fingerprint/browser/VPN notes

Verified: flake check --no-build; checks.first-boot; checks.hardware-toggles
(pam=false node). V3 session items pending after user rebuild.
2026-07-11 15:06:21 +01:00
Bernardo Magri
f2fb4adbce fix(theme): replace neon-glass wallpaper (drop center glass box)
All checks were successful
Check / eval (push) Successful in 11m1s
New 2560×1440 cyan/purple geometric void wallpaper without the mid-screen
GLASSMORPHISM panel; preview regenerated to match.

Verified: V0 — theme-wholeswap; center pixels near-black (no grey box).
2026-07-11 11:11:59 +01:00
Bernardo Magri
435f468079 docs(vision): renumber suggested slices after #88 ship
Some checks failed
Check / eval (push) Has been cancelled
2026-07-11 11:07:35 +01:00
Bernardo Magri
13f1b2b70e feat(theme): finish neon-glass polish (#88)
Some checks failed
Check / eval (push) Has been cancelled
Rewrite whole-swap rofi.rasi to rofi-native @colors (was invalid CSS
var()); cyan glass panel matching Boreal's hex-alpha pattern. btop
titles/selection in accent cyan; ui rounding/opacity tuned; preview.png
from theme-shot. Bar stays generated from JSON (no waybar.css).

Verified: V2 — contrast 24×7; theme-wholeswap; theme-shot neon-glass
desktop+menu viewed; boreal theme-shot regression green.
2026-07-11 11:07:02 +01:00
Bernardo Magri
2cd38e168d docs(backlog): complete #88 promotion bookkeeping
Some checks failed
Check / eval (push) Has been cancelled
Drop neon-glass from PROPOSED; point VISION slices at NEXT #88 finish
path. Companion to 78a3d60.

Verified: V0 (docs only).
2026-07-11 11:00:55 +01:00
Bernardo Magri
78a3d60b02 docs(backlog): promote neon-glass polish → NEXT #88
Some checks failed
Check / eval (push) Has been cancelled
Bernardo triage: finish path (not quarantine). Whole-swap assets stay;
do not reintroduce waybar.css without @define-color parity. V2 theme-shot
required on implement.

Verified: V0 (backlog/docs only).
2026-07-11 11:00:48 +01:00
Bernardo Magri
fc63feaa9d docs(vision): mark shipped Theme A/B/C; refresh PROPOSED
Some checks failed
Check / eval (push) Has been cancelled
VISION was still listing Firmware/Fingerprint/doctor/rebuild as open
work after they shipped. Sync ✓ rows + rewrite suggested agent slices.
PROPOSED: post-install hardware hints; neon-glass finish-or-quarantine.

Verified: V0 (docs/backlog only). Queue remains #76 [blocked:hw] only.
2026-07-11 10:59:11 +01:00
Bernardo Magri
9e8433e4d6 chore(agent): QA sweep + journal rotate (queue empty)
Some checks are pending
Check / eval (push) Waiting to run
NEXT only has #76 [blocked:hw]. Re-ran option-docs, template-sot, and a
V2 suite (doctor, first-boot, what-changed, clamshell-logind, dock-audio,
windowrule-syntax, battery-notify, zram-swap, nightlight-geo) — all green.
Rotated JOURNAL.md 41→18 entries into JOURNAL-ARCHIVE.

Verified: V2 — listed checks green; flake check --no-build green.
2026-07-11 10:40:57 +01:00
Bernardo Magri
f47903a6a6 feat(audio): prefer dock/HDMI/USB sinks in WirePlumber (#87)
Some checks failed
Check / eval (push) Has been cancelled
Raise priority.session for HDMI/DisplayPort (1100) and USB (1050) ALSA
outputs so the default sink follows dock plug/unplug; built-in analog
wins again when externals disappear. Rules live in
modules/nixos/dock-audio-rules.nix; checks.dock-audio covers the contract.

Verified: V2 — flake check --no-build; checks.dock-audio green.
V3 pending: HARDWARE-QUEUE #87 real HDMI/USB-dock hotplug.
2026-07-11 10:36:07 +01:00
Bernardo Magri
27f18e4ccc feat(power): clamshell — ignore lid suspend when docked (#86)
Some checks failed
Check / eval (push) Has been cancelled
Explicit HandleLidSwitchDocked=ignore under services.logind so closing
the lid with an external display connected does not suspend. Undocked
lid path left to upstream defaults. checks.clamshell-logind asserts the
baked logind.conf and active logind; real multi-monitor lid V3 queued.

Verified: V2 — flake check --no-build; checks.clamshell-logind green.
V3 pending: HARDWARE-QUEUE #86 lid+external on a laptop.
2026-07-11 10:32:18 +01:00
Bernardo Magri
ce05eed754 docs(vision): fix HARDWARE.md section pointer after § renumber
Some checks failed
Check / eval (push) Has been cancelled
2026-07-11 10:28:56 +01:00
Bernardo Magri
523e44bbf8 docs(hardware): install-tested hall of fame (#85)
Some checks failed
Check / eval (push) Has been cancelled
HARDWARE.md §9 lists T14s Gen 4 + Latitude 5410/5310 with DMI/profile
notes and a "send your DMI line" PR invite. Split dock life #84 into
#86 clamshell + #87 dock audio (stable new IDs).

Verified: V0 (docs/backlog only).
2026-07-11 10:28:52 +01:00
Bernardo Magri
fe0b972171 feat(doctor): first-boot pre-activate fail flag (#83)
Some checks failed
Check / eval (push) Has been cancelled
If the installer left /var/log/nomarchy-hm-preactivate.log and no Home
Manager generation exists, doctor reports ✖ with the recovery one-liner
(home-manager switch --flake ~/.nomarchy -b bak). Clears once a
generation is present. checks.doctor covers both arms.

Verified: V2 — checks.doctor green; local smoke.
2026-07-11 10:28:14 +01:00
Bernardo Magri
639f553cb7 feat(lifecycle): plain-language generation diffs (#82)
Some checks failed
Check / eval (push) Has been cancelled
nomarchy-what-changed wraps nvd into "N added, M removed, K updated"
summaries. nomarchy-rebuild / nomarchy-home print full nvd and toast
the one-liner; System › What changed? offers toast + floating report.
checks.what-changed fixtures nvd via NOMARCHY_NVD.

Verified: V2 — flake check --no-build; checks.what-changed green;
local smoke on real HM generations.
2026-07-11 10:26:57 +01:00
Bernardo Magri
8f720b1078 feat(session): first-boot welcome toast (#81)
All checks were successful
Check / eval (push) Successful in 4m23s
One-shot user unit fires a dismissible "You're set" notification on the
first graphical session (SUPER+M / SUPER+T / SUPER+? + Network + Doctor),
then writes settings.firstBootShown into the flake checkout. Skips the
live ISO hostname (hosts/live.nix keeps its own toast). Toggle:
nomarchy.firstBootWelcome.enable. checks.first-boot covers the gate.

Verified: V2 — flake check --no-build; checks.first-boot green; local
shim smoke; downstream-template-home ships the unit.
2026-07-11 10:19:46 +01:00
Bernardo Magri
073adf743d test(doctor): V2 float harness for System › Doctor
Some checks failed
Check / eval (push) Has been cancelled
Headless softGL Hyprland harness (tools/doctor-float.nix): launches
nomarchy-menu doctor, asserts class/floating/center via hyprctl, dumps
before/after screenshots. Ran boreal + summer-night — both PASS
(floating centered sheet; Ghostty keeps ~800×600). MEMORY recipe.

Verified: V2 — THEME=boreal and THEME=summer-night doctor-float green;
screenshots viewed (desktop-before vs doctor-float under both themes).
2026-07-11 10:16:34 +01:00
Bernardo Magri
3d40a7e1ed feat(menu): open Doctor in a floating centered terminal
All checks were successful
Check / eval (push) Successful in 4m26s
Launch nomarchy-doctor via ghostty --class=com.nomarchy.doctor (same
pattern as the calendar popup) and match float/size/center windowrules
so System › Doctor and the Waybar doctor click no longer tile.

Verified: V1 — downstream-template-home builds; menu carries the class;
hyprland.conf has float/size/center for com.nomarchy.doctor;
windowrule-syntax green (19 rules). V2 live float confirm after
nomarchy-home (same class mechanism as calendar).
2026-07-11 10:08:50 +01:00
Bernardo Magri
71786bda6b feat(doctor): battery health readout (#80)
Some checks failed
Check / eval (push) Has been cancelled
Report-only cycle_count + retained capacity (charge_* or energy_*) as
% of design on system batteries; soft warn below 70% of design; skip
when attrs missing. NOMARCHY_POWER_SUPPLY_ROOT for the checks.doctor
fixture (test_power has no cycle/design attrs).

Verified: V2 — nix flake check --no-build; checks.doctor green (fake
BAT0 cycles/% + Device-scope ignore); local BAT0 smoke (energy_*).
2026-07-11 10:06:13 +01:00
Bernardo Magri
3132ba5ac8 docs(backlog): promote VISION-gap items → NEXT #80–#85 (human triage)
All checks were successful
Check / eval (push) Successful in 4m20s
Bernardo promoted: #80 battery health readout, #81 first-boot welcome
card, #82 generation readability, #83 doctor pre-activate fail flag,
#84 dock life polish [big — split before starting], #85 hardware hall
of fame. Wallpapers artifact split stays LATER. No implementation
started (explicit instruction).

Verification: V0 (backlog only).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-11 09:51:51 +01:00
Bernardo Magri
56793c1c27 docs(backlog): six VISION-gap PROPOSED pitches (B/C/A-residual/F)
All checks were successful
Check / eval (push) Successful in 4m18s
VISION cross-checked against the tree: firmware menu, fingerprint,
doctor GPU/hibernate, rebuild-error hints, post-install hints and the
install contracts are shipped; the genuinely open gaps are filed as
PROPOSED with one-paragraph pitches — first-boot welcome card,
generation readability, battery health readout, doctor pre-activate
fail flag, dock life polish [big], hardware hall of fame.

Verification: V0 (backlog only).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-11 09:44:56 +01:00
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
305 changed files with 31033 additions and 2280 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"
}
}

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

@@ -0,0 +1,102 @@
# 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: |
max-jobs = 1
cores = 2
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 — evaluate every output (eval tier, memory-bounded)
# Per-output processes, not one big `nix flake check --no-build`:
# the single-process walk peaks ~6 GB RSS and OOMs inside the
# runner's 2 GB container cap (details in tools/ci-eval.sh).
if: steps.update.outputs.changed == '1'
run: bash tools/ci-eval.sh
- 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

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

@@ -0,0 +1,108 @@
# 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: |
max-jobs = 1
cores = 1
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: Evaluate every output (eval tier, memory-bounded)
# Same coverage as `nix flake check --no-build` — both nixos
# configs, the home config, the checks.* derivations (instantiated,
# not run) and the downstream template through lib.mkFlake — but
# ONE output per nix process. A single process walking everything
# peaks at ~6.0 GB RSS (measured 2026-07-12): it OOM'd the 4 GB
# VPS itself, and can never fit this runner's 2 GB container cap.
# Per-output processes cap at the largest single output (~1 GB).
run: bash tools/ci-eval.sh
- 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

8
.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
result
result-*
@@ -9,5 +5,9 @@ result-*
__pycache__/
.DS_Store
# Ad-hoc hardware evidence photos dropped in the tree (not repo content)
WhatsApp Image*.jpeg
# Claude Code machine-local settings (permissions etc.)
.claude/settings.local.json
.claude/settings.local.json.tmp.*

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

21
LICENSE Normal file
View File

@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 Bernardo Magri
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

335
README.md
View File

@@ -7,11 +7,11 @@ partial.
```
┌──────────────────────────────────────────────────────────────────────────┐
theme-state.json (single source of truth) │
│ state.json (single source of truth) │
│ lives INSIDE your flake checkout, git-tracked │
└───────────────────────────────────┬──────────────────────────────────────┘
nomarchy-theme-sync apply gruvbox
nomarchy-state-sync apply gruvbox
1. merges the preset into the JSON (atomic write)
2. runs `home-manager switch` (no sudo, no system rebuild)
@@ -19,7 +19,7 @@ partial.
┌──────────────────────────────────────────────────────────────────────────┐
│ Home Manager bakes EVERYTHING into one read-only generation: │
│ Hyprland (colors/gaps/borders) Waybar (palette or whole-swap) │
Ghostty (full ANSI palette) btop (asset or generated) │
Kitty (full ANSI palette) btop (asset or generated) │
│ Stylix → GTK, Qt, cursors, fonts │
└───────────────────────────────────┬──────────────────────────────────────┘
@@ -35,11 +35,11 @@ Flat on purpose. Two module trees, one options file each, no hidden layers.
.
├── flake.nix # inputs + the downstream API (exports below)
├── lib.nix # nomarchy.lib.mkFlake — one-call downstream wrapper
├── theme-state.json # ★ THE single source of truth (git-tracked!)
├── themes/ # 21 presets: <slug>.json + optional <slug>/ assets
├── state.json # ★ THE single source of truth (git-tracked!)
├── themes/ # 28 presets: <slug>.json + optional <slug>/ assets
│ ├── nord.json # palette (required, works alone)
│ └── nord/ # assets (optional, fixed filenames)
│ ├── backgrounds/ # wallpapers (auto-picked, SUPER+SHIFT+T cycles)
│ ├── backgrounds/ # wallpapers (pinned nomarchy-wallpapers input, not this repo)
│ ├── btop.theme # hand-made config drop (else generated)
│ └── waybar.css # whole-swap: replaces the generated bar style
├── modules/
@@ -56,12 +56,13 @@ Flat on purpose. Two module trees, one options file each, no hidden layers.
│ ├── stylix.nix # GTK/Qt/cursors/fonts from the same JSON
│ ├── hyprland.nix # all JSON-driven
│ ├── waybar.nix
│ ├── ghostty.nix
│ ├── kitty.nix
│ ├── btop.nix
│ ├── rofi.nix # launcher + nomarchy-menu (calc, emoji, clip…)
│ ├── keybinds.nix # single source: Hyprland binds + SUPER+? sheet
│ ├── swaync.nix # notifications, 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
│ ├── osd.nix # swayosd volume/brightness OSD
│ ├── shell.nix # zsh + starship + bat/eza/zoxide
@@ -71,12 +72,20 @@ Flat on purpose. Two module trees, one options file each, no hidden layers.
│ ├── default/ # reference machine (thin: boot, user, hostname)
│ └── live.nix # bootable live ISO (try the distro, no install)
├── pkgs/
│ ├── nomarchy-theme-sync/ # state writer + rebuild dispatcher (Python)
── nomarchy-install/ # live-ISO installer (gum + disko + mkFlake)
├── templates/downstream/ # `nix flake init -t` starter for users
├── docs/TESTING.md # how to verify changes (incl. AI-agent rules)
├── docs/OVERRIDES.md # how downstream users override defaults
├── docs/ROADMAP.md # forward-looking plans + shipped-fixes log
│ ├── nomarchy-state-sync/ # state writer + rebuild dispatcher (Python)
── nomarchy-install/ # live-ISO installer (gum + disko + mkFlake)
│ ├── nomarchy-doctor/ # read-only health sheet, one command per failure
│ └── nomarchy-battery-notify/ # low-battery toast watcher backing battery-notify.nix
├── 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
├── import-palettes.py # converts old-distro themes → JSON + assets
├── test-live-iso.sh # build the ISO + boot it in QEMU
@@ -92,11 +101,15 @@ probably shouldn't exist.
## 2. Try it first (live ISO)
**Before you install:** check **[docs/REQUIREMENTS.md](docs/REQUIREMENTS.md)**
(UEFI x86_64, disk planning for the Nix store + BTRFS snapshots — 128GiB is
the comfortable floor).
Boot the full desktop from a USB stick or VM without installing anything:
```sh
nix build .#nixosConfigurations.nomarchy-live.config.system.build.isoImage
# → result/iso/*.iso — dd to a stick, or boot it in QEMU:
# → result/iso/nomarchy-live-….iso — dd to a stick, or boot it in QEMU:
tools/test-live-iso.sh
```
@@ -105,11 +118,19 @@ the locked inputs into the ISO store — so theme switching (including the
`home-manager switch` it triggers) works **offline**, exactly like on an
installed system. Verification checklist: [docs/TESTING.md](docs/TESTING.md).
It's a working desktop, not a demo shell: Chromium, LibreOffice, Text Editor,
Amberol and Snapshot are in the launcher alongside the terminal tooling — so
you can read these docs in a browser while you try it, or boot the stick to
rescue a machine that won't start and get a document off it. Installed
machines get their app list from the template's `home.packages` instead, which
is yours to edit.
Like what you see? **`nomarchy-install`** (in a terminal) walks you through
installing to disk: pick a disk, LUKS2 full-disk encryption **by default**
(in exchange the desktop logs in passwordless — the passphrase already
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`
snapper timeline snapshots are on) and `nixos-install` runs — **without a
network** when the ISO was built from a clean tree (the target's
@@ -128,8 +149,18 @@ mkdir my-machine && cd my-machine
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
`theme-state.json`, written by the CLI). Your `flake.nix` is set up once —
`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:
```nix
@@ -161,65 +192,139 @@ home-manager switch --flake .#me # desktop: every theme change, no
Day-to-day you'll use the shipped shortcuts instead:
```sh
sys-update # nix flake update + system rebuild (BTRFS snapshot first when available)
home-update # home-manager switch (no flake update, no sudo)
nomarchy-pull # nix flake update — refresh inputs (nomarchy, nixpkgs, …);
# 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
# (prints nvd + a "What changed" toast when the generation moved)
nomarchy-what-changed # plain-language last rebuild (system + desktop; --summary for one-liners)
```
**Order matters when pulling distro updates.** `home-update` does *not*
touch the lock — it rebuilds the desktop against the **current**
`flake.lock`. A new Nomarchy revision (new keybinds, theming, modules)
arrives only when the lock is updated, which `sys-update` does
(`nix flake update`). So to pull an update that affects the desktop layer:
run `sys-update` **first** (updates the lock + rebuilds the system), **then**
`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
skips the new home-side changes. After a home-side keybind/config change,
also `hyprctl reload` (or relogin) so the running session re-reads it.
`~/.nomarchy` is **your machine flake** (`system.nix` / `home.nix`). Newer
Nomarchy code arrives when `flake.lock` updates the `nomarchy` input
(`nomarchy-pull`), not by `git pull` of that directory (many installs have
no remote there — only local commits from auto-commit). With auto-commit
on (menu: **System Auto-commit**), all three commands above also sweep
any pending hand edits into a commit before they switch, so
`git -C ~/.nomarchy log` mirrors your generations.
| When | Run |
|---|---|
| Pull newer Nomarchy / nixpkgs / inputs | `nomarchy-pull` |
| You changed `system.nix` (or after pull) | `nomarchy-rebuild` |
| 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:
appearance (gaps/colors/fonts) changes through `nomarchy-theme-sync`,
appearance (gaps/colors/fonts) changes through `nomarchy-state-sync`,
behaviour (input/misc/monitor/chrome) is `mkDefault` so a plain `home.nix`
assignment wins, and bind/exec-once lists concatenate. Full guide with
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 |
|---|---|---|
| `nomarchy.stateFile` | — (required) | Path to your theme-state.json |
| `nomarchy.terminal` | `"ghostty"` | Terminal for keybinds and `$TERMINAL` |
| `nomarchy.stateFile` | — (required) | Path to your state.json |
| `nomarchy.terminal` | `"kitty"` | Terminal for keybinds and `$TERMINAL` (Kitty is the only shipped/themed emulator) |
| `nomarchy.kitty.enable` | `true` | Nomarchy's Kitty config (palette/font/opacity from theme-state) |
| `nomarchy.keyboard.layout` | `"us"` | XKB layout for the Hyprland session (installer writes the matching `services.xserver.xkb` in system.nix; the console + LUKS prompt follow via the distro default) |
| `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.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.keyboard.layouts` | `[]` | Optional layouts to put first in the new-keyboard picker; the watcher runs by default, offers every installed XKB layout, and remembers the choice per-device. Pick/change one anytime at System Keyboard |
| `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.waybar.enable` | `true` | Nomarchy's Waybar |
| `nomarchy.rofi.enable` | `true` | Themed rofi launcher + `nomarchy-menu` dispatcher |
| `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.dockAudio.enable` | `true` | On a fresh external-monitor plug, PipeWire/WirePlumber are reprobed after hardware settles and the highest-priority available HDMI/DP/USB sink becomes default, with a toast and journal result. A later manual speaker choice sticks until the next physical plug; unplug falls back to built-in. Manual route: System Audio |
| `nomarchy.firstBootWelcome.enable` | `true` | One dismissible “you're set” toast on the first session (SUPER+M / SUPER+T / SUPER+? + network pointer); marker is `settings.firstBootShown` in the flake checkout. Also fires at most one follow-up “Hardware tips” toast pointing at System Firmware / Fingerprint when `fwupdmgr` / `fprintd-list` are on PATH (`settings.hardwareHintsShown`) |
| `nomarchy.idle.enable` | `true` | hyprlock + hypridle (lock 5 min; display off 10 min in every dock/lid state — input wakes it compositor-side, #127; suspend 15 min battery-only via `nomarchy-suspend`) |
| `nomarchy.idle.fingerprint` | `false` | Unlock the lock screen with a fingerprint as well as the password, and say so on the input field. Set it alongside `nomarchy.hardware.fingerprint.pam` in system.nix: hyprlock is configured from Home Manager, which cannot read the NixOS option — and hyprlock does **not** take a fingerprint through PAM at all (its PAM stack runs only on submit), so it uses its own fprintd backend that this switch turns on |
| `nomarchy.yazi.enable` | `true` | yazi TUI file manager, themed + curated plugins |
| `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.ghostty.enable` | `true` | Nomarchy's Ghostty |
| `nomarchy.btop.enable` | `true` | btop with per-theme colors |
| `nomarchy.stylix.enable` | `true` | GTK/Qt/cursor theming |
| `nomarchy.fastfetch.enable` | `true` | fastfetch fronted by the themed Nomarchy logo |
| `nomarchy.keys.enable` | `true` | gpg-agent fronting SSH + pinentry-qt |
| `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.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. Profiles that disable the laptop panel use the same safe ordering as Dock mode (external on → workspace handoff → internal off). The menu's Auto-switch row (`settings.displayProfileAuto`) applies the matching profile from Hyprland hotplug events. Workspace pins (`workspaces = { "1" = "DP-3"; }`) are moved instantly and baked as 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.package` | overlay's `nomarchy-state-sync` | The theme/state tool package, overridable if you fork it |
**Greeter keyboard layout:** tuigreet runs on a kernel VT, which has exactly
one keymap — `console.useXkbConfig` follows `services.xserver.xkb.layout`
(`nomarchy.keyboard.layout`), not Hyprland's per-device `kb_layout` binding.
Per-device layouts (`nomarchy.keyboard.devices`) only take effect once the
Hyprland session starts, so at the login prompt an external keyboard types
the **system** layout, not its remembered one — if your password comes out
wrong there, type it as if on the system layout, or use the laptop's own
keyboard. A VT constraint, not a bug.
**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.stateFile` | `null` | 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.greeter.enable` | `true` | greetd/tuigreet |
| `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.greeter.autoLogin` | state | Auto-login this user into Hyprland. Owned by System Auto-login (`settings.greeter.autoLogin`; installer seeds it on LUKS machines) — set it here only to pin it against the menu |
| `nomarchy.system.audio.enable` | `true` | PipeWire stack; WirePlumber priority rules provide HDMI/USB preference/fallback, while the Home Manager dock watcher performs the fresh-display reprobe and explicit default selection |
| `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.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.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. Clamshell uses logind's `HandleLidSwitchDocked=ignore`; the desktop additionally holds a low-level lid-switch inhibitor across dock/undock, releasing only after the internal panel is restored and the lid opens. Normal future undocked lid-close suspend remains enabled |
| `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.services.tailscale.enable` | `false` | Opt-in: Tailscale mesh VPN (then `sudo tailscale up`) |
| `nomarchy.system.power.suspendThenHibernate` | `true` (state) | On battery, suspend falls through to hibernate after **1 hour** (`suspend-then-hibernate` + `HibernateDelaySec=1h`; never while on AC). Needs a hibernate resume path (`boot.resumeDevice`). Toggle: System Preferences **Suspend then hibernate** (`settings.power.suspendThenHibernate`; system rebuild for logind lid policy). Idle and the Power menu use `nomarchy-suspend` (live state). |
| `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.fingerprint.parallel` | `true` | With PAM on: password *or* fingerprint at the same prompt (whichever comes first); `false` = stock sequential pam_fprintd. Password alone always stays sufficient |
| `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.podman.enable` | `false` | Opt-in: rootless Podman (`docker` aliased to it) |
| `nomarchy.services.flatpak.enable` | `false` | Opt-in: Flatpak + the Flathub remote |
@@ -235,14 +340,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.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.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
desktop services with `lib.mkDefault` (override natively). One worth
calling out: **`services.fwupd.enable`** is on by default for firmware
desktop services with `lib.mkDefault` (override natively) and enforces
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,
so run `fwupdmgr update` to apply. Disable with `services.fwupd.enable =
false;` on machines without real firmware (VMs/headless).
so run `fwupdmgr update` to apply; disable with `services.fwupd.enable =
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
@@ -252,24 +376,56 @@ The trap with "read a mutable file from Nix" is pure evaluation: flakes
cannot read arbitrary `$HOME` paths without `--impure` (the old prototype
required it — never again). Nomarchy's convention: **the state file lives
inside the consuming flake** and is wired via
`nomarchy.stateFile = ./theme-state.json;`. Reading it is pure — it's flake
source. It must be git-tracked (`nomarchy-theme-sync` runs
`nomarchy.stateFile = ./state.json;`. Reading it is pure — it's flake
source. It must be git-tracked (`nomarchy-state-sync` runs
`git add --intent-to-add` after every write as a safety net).
### One change = one generation
`nomarchy-theme-sync apply <theme>` merges the preset into the JSON and runs
`nomarchy-state-sync apply <theme>` merges the preset into the JSON and runs
`home-manager switch` (override the command with `$NOMARCHY_REBUILD`, or pass
`--no-switch` to only write). Everything is baked: Hyprland, Waybar, Ghostty,
`--no-switch` to only write). Everything is baked: Hyprland, Waybar, Kitty,
btop, and — via Stylix, mapped onto base16 roles — GTK, Qt, cursors and
fonts. No runtime patching means no partial states, and `home-manager
generations` is also your theme history. Waybar even restyles in place: it
re-reads `style.css` when the symlink flips.
**Cleanup is automatic.** A weekly timer (`nomarchy-gen-prune`) removes
**system** and **Home Manager** generations that are older than **14 days**
*and* beyond the **three most recent past** gens — so you always keep the
current generation plus at least three rollbacks, even on a rarely rebuilt
machine. Manual: `sudo nomarchy-gen-prune --dry-run`. Full story:
[docs/RECOVERY.md §4](docs/RECOVERY.md#4-how-generations-are-kept-and-cleaned-up).
The **wallpaper** is the one runtime piece (awww — nixpkgs' swww — is
imperative; nothing in Nix consumes the path): applied at session start and
after every switch via a tiny activation hook, cycled instantly with
`bg next`.
imperative; nothing in Nix consumes the path): applied at session start,
after every switch via a tiny activation hook, and again when a monitor is
hotplugged; cycled instantly with `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` 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>/`)
@@ -278,35 +434,44 @@ per theme — a single place to look, unlike the old distro's split:
| Asset | Mechanism |
|---|---|
| `backgrounds/` | wallpapers; empty `wallpaper` in the state means "first one"; `bg next` cycles |
| `backgrounds/` | wallpapers; empty `wallpaper` in the state means "first one"; `bg next` cycles. Pinned in a separate `nomarchy-wallpapers` flake input, not this repo (ROADMAP § "Faster switches") — merged in at build time |
| `btop.theme` | baked into the generation (generated from the palette when absent) |
| `waybar.css` | **whole-swap**: replaces the generated bar style entirely (probed at eval time, self-contained) |
| `waybar.jsonc` | whole-swap for the bar *layout* (must be plain JSON) |
| `rofi.rasi` | **whole-swap**: replaces the generated launcher/menu theme entirely |
Six ported themes ship a `waybar.css` identity (catppuccin, lumon, nord,
retro-82, summer-day, summer-night). Custom user themes can live in
Eight themes ship a `waybar.css` identity: the three Nomarchy-exclusive
day/night pairs — boreal ↔ boreal-dawn, executive-slate ↔ executive-ivory,
kiln ↔ kiln-clay — plus summer-night ↔ summer-day. Custom user themes can live in
`$NOMARCHY_PATH/themes/` (preset lookup) and `nomarchy.themesDir` (eval-time
asset probe).
asset probe) — including their own `backgrounds/`, which wins over the
pinned `nomarchy-wallpapers` input (checked first); an explicit `wallpaper`
path in `state.json` works too.
## 5. Day-to-day
```sh
nomarchy-theme-sync list # 21 presets (nord, gruvbox, rose-pine, …)
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 bg next # cycle wallpapers — instant, no rebuild
nomarchy-theme-sync bg auto # back to the theme's default wallpaper
nomarchy-theme-sync get colors.accent
sys-update # update inputs + rebuild the system (snapshots first)
home-update # rebuild just the desktop layer
nomarchy-state-sync list # 28 presets (nord, gruvbox, rose-pine, …)
nomarchy-state-sync apply kanagawa # whole desktop, one generation (~a switch)
nomarchy-state-sync set ui.gapsOut 16 # tweak one knob (also a switch)
nomarchy-state-sync bg next # cycle wallpapers — instant, no rebuild
nomarchy-state-sync bg auto # back to the theme's default wallpaper
nomarchy-state-sync get colors.accent
nomarchy-pull # flake input update (no rebuild)
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 ·
`SUPER+E` file manager (yazi) · `SUPER+N` notifications · `SUPER+CTRL+V`
clipboard history · `SUPER+Q`
close · `SUPER+1..9` workspaces · `Print` region screenshot.
clipboard history · `SUPER+SHIFT+C` color picker (hyprpicker) · `SUPER+Q`
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
all; the curated set:
@@ -324,10 +489,10 @@ gd gds # diff · diff --staged
gl glg # log graph (last 20 · all branches)
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)
nfu nfc # nix flake update · check
nsearch ngc # nix search nixpkgs · nix-collect-garbage -d
nfu nfc # nix flake update · check (prefer nomarchy-pull for day-to-day)
nsearch ngc # nix search · nix-collect-garbage -d (prefer: sudo nomarchy-gen-prune)
# misc
path # print $PATH, one entry per line
@@ -338,10 +503,13 @@ reload # exec zsh (reload the shell)
- **New theme:** drop a JSON into `themes/` (schema = any existing preset),
plus an optional `themes/<slug>/` assets directory.
- **New themed value:** add the key to `theme-state.json` and consume it in
- **New themed value:** add the key to `state.json` and consume it in
the Nix modules. One place — there is no second renderer to keep in sync.
- **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
needs the user to set `nomarchy.*` options (e.g. night light, per-device
keyboard layouts, monitor layout, power management), ship a **commented**
@@ -354,9 +522,28 @@ reload # exec zsh (reload the shell)
line to slim the machine, uncomment an extra (a browser, email, full TeX
Live), or add your own. No `nomarchy.apps.*` toggles — a package list is
already its own opt-out, so the distro doesn't impose these or wrap them.
- **A newer individual app (unstable channel, #134):** Nomarchy carries a
second, newer nixpkgs channel and exposes it as `unstable` through its
overlay, so any package can be taken from it by prefixing `unstable.` in
`home.packages` — e.g. `unstable.lmstudio` — with no second flake input of
your own to manage. Three costs, stated plainly: (a) **you own this
combination** — nothing upstream tests a pinned Nomarchy plus an unstable
app together; (b) an unstable package brings its own second toolchain into
your closure (measured: `unstable.lmstudio` shares only ~690 of ~4k store
paths with the pinned set); (c) it moves whenever the flake lock moves
(`nomarchy-pull`), not on its own schedule. Home packages only — that's
the supported surface, not `system.nix` or the module set.
## Roadmap & known issues
See **[docs/ROADMAP.md](docs/ROADMAP.md)** — forward-looking plans plus the
log of shipped fixes. Kept out of the README so this stays a focused entry
point.
| Doc | Role |
|-----|------|
| **[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.
License: [MIT](LICENSE).

352
agent/BACKLOG.md Normal file
View File

@@ -0,0 +1,352 @@
# 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
### Live ISO / install hardware findings — Acer Aspire M5-481T + Dell XPS 9350
Bernardo, real installs 2026-07-1314 (photos of the install end screens and
post-boot sessions). Preserve separation: the installer bake failure and the
flake pin are different root causes even when they show up on the same machine.
(Terminal / Ghostty-on-Acer → shipped as Kitty-only, #95.)
## NEXT
### 151. `[human]` v1 launch plan — devise with Bernardo (includes the GitHub move)
Bernardo, 2026-07-17: before tagging v1, sit down and devise the launch plan.
Known ingredients, so that session starts concrete:
- **The repo moves to GitHub.** Everything baking the Gitea URL follows: the
downstream template's `inputs.nomarchy.url`, the `nomarchy-wallpapers`
input URL (baked 2026-07-17 — sweep it in the same move), README/docs
links, and CI (eval CI runs on Gitea act_runner — decide GitHub Actions
vs keeping Gitea CI on a mirror).
- The move is the natural moment for the **history-rewrite decision**, and
it is now measured (2026-07-17): the 107 MiB pack is 94.2 MB wallpaper
blobs + 9.9 MB preview generations; all 848 commits of *text* pack to
~1520 MiB. So the history is not long-heavy, it is image-heavy.
Recommendation on the table: **filter, don't reset**
`git filter-repo` stripping only `themes/*/backgrounds/` keeps every
commit's diffs/blame/`log -S` (load-bearing: it diagnosed #147) at a
~1520 MiB clone. Cost: all SHAs change, and ROADMAP/journal cite them —
mitigate by freezing the Gitea repo as a read-only archive (old SHAs
resolve there; stash filter-repo's commit-map in it) + one ROADMAP note.
Argue against a full mirror (ships 94 MB of deleted images to every
clone forever) and against fresh-start (burns the archaeology to save
~15 MiB).
- The existing v1 bar (VISION § v1.0): HARDWARE-QUEUE burn-down, install P0
re-verify before the `v1` fast-forward, visual ritual. LICENSE shipped
(MIT, 2026-07-17).
- Adjacent calls that may fold in: cachix/public binary cache (#120's
prerequisite), the netinstall decision itself, release notes/announce.
`v1` stays human-only throughout — agents prepare evidence and checklists,
never the tag or branch.
## LATER
- **Pre-built theme variants** (ROADMAP § Faster switches follow-on): if
`home-manager switch` itself is still the theme-switch bottleneck now
that the wallpapers split shipped (2026-07-17), pre-build each theme's
generation so a switch just activates a cached one. Measure first.
- **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.
## 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
### 153. Desktop widgets (clock / system stats) as an opt-in per-theme surface
Pitch (filed while shipping the three exclusive theme pairs, 2026-07-18):
themed desktop widgets — a clock, calendar, or system-stats panel living on
the wallpaper, conky-style — would deepen the identity themes (imagine
kiln's copper stats panel or executive-ivory's letterhead clock). No such
surface exists today, so it is a NEW theming surface: per CONVENTIONS it
must consume the palette from the state JSON (no side pipeline) and ship
as an opt-in `nomarchy.*` toggle with a commented template example. Engine
choice is the real decision — conky is X11-era; a Wayland layer-shell
widget system (eww or a small QML/GTK layer-shell tool) fits the stack
better. Cost: moderate (engine spike + palette bridge + 23 widget
layouts; start with the exclusive pairs only, not all 28 themes). Value:
v1+ polish, not a v1 blocker.
### 146. `[watch]` hypridle hangs instead of exiting when its compositor vanishes
**Rewritten 2026-07-16, hours after filing: the premise was disproven and almost
nothing survives.** As filed it said hypridle "dies silently ~2×/week — 19 times
in 11 days", with Jul 14 as the clean example ("`CRITICAL` at 13:08:17, then
eleven hours of silence"). Every number there was an artifact of the
measurement. **18 of the 19** `Disconnected from pollfd id 1` lines are
hypridle's noisy goodbye while systemd *deliberately stops it* — each bracketed
by `Stopped hypridle` / `Started hypridle` with a healthy replacement seconds
later. The "eleven hours of silence" came from grepping **one PID**: systemd
logged `Stopped hypridle` 13:08:17, `Started hypridle` 13:09:00, and PID 1678
ran fine all evening where the filter could not see it. Do not re-derive the old
rate; it counts normal shutdown noise.
**What is real, and it is small.** Exactly one genuine zombie has been observed
(2026-07-16 17:25:39), verified by process inspection rather than logs — `ps`
(alive 9h36m), `ss` (48 KB unread on its D-Bus socket, i.e. not reading),
`NRestarts=0` while `systemctl --user is-active` said `active (running)`. That
is upstream hyprwm/hypridle#171 ("Possible deadlock in pollThr when compositor
exits?", open since 2025-09, in neither 0.1.7 nor main) and it is a true bug:
hypridle hangs rather than exits, so `Restart=always` never fires.
**A second real one joined it with #127's close (2026-07-17):** an
inhibit-lock underflow (`BUG THIS: inhibit locks < 0: -1`, hypridle's own
words, Jul 15 10:36:29) left the fired DPMS listener's `on-resume` permanently
dead on 0.1.7 — the incident's actual mechanism (ROADMAP § "The idle brick's
CAUSE found"). Reported upstream with the full trace 2026-07-17:
https://github.com/hyprwm/hypridle/issues/208
(#74/#104/#128 are the same class, closed before 0.1.7 evidently without
killing it). Its impact path here is closed by the compositor-side wake fix
(a2151f4); same watch posture — take the upstream fix when a release carries
it.
**But its impact path is closed, which is why this is `[watch]` and not work.**
The zombie only *persists* if systemd never stops the unit — which needed the
user manager to survive logout, i.e. the stray pre-migration linger removed in
#147. Without linger, logout tears the manager down and takes any zombie with
it; the Jul 14 trace above shows the healthy path (`Stopped``Started`) doing
exactly that. A Hyprland death ends the logind session anyway (2026-07-16:
`Session 2 logged out` → greeter), so there is no known route left to a
surviving zombie.
**Do before building anything:** re-measure. Count only disconnects with **no**
`Stopped/Started hypridle` around them and **no** logind session event, and
confirm by `ps`/`ss` that the process is really wedged — not by grepping a PID.
If that count is zero over a few weeks post-linger, **close this unbuilt**. If it
is not, the upstream issue has sat ten months on vaxerski's "that will cause a
segfault no?" and a reproducer would be worth more than a local guard.
### 150. Extract the display-transition tool — idle.nix carries a lossy copy under the same name
Found by the 2026-07-17 structural audit. `modules/home/idle.nix:23-53` defines
`miniTransition`, a ~22-line `writeShellScriptBin` named
**`nomarchy-display-transition`** — the same binary name as the real ~175-line
tool in `modules/home/hyprland.nix:136-310` — implementing only
`undock|enable`, with none of the full tool's safety: no logging, no
lid-inhibitor gate, no `restore_keyboards`, no workspace moves, no
failure dump. The comment says why: "a mini transition avoids a circular
import on hyprland.nix". No PATH collision today (the mini is never installed;
it is baked by store path into hypridle's wake tool via `display-tools.nix`),
but nothing asserts the two stay behaviourally consistent, and this is the
subsystem where #100/#142/#148 all lived. Pitch: extract the full tool into
its own module file (e.g. `modules/home/display-transition.nix`) imported by
both hyprland.nix and idle.nix, delete the mini, re-run the
`tools/monitor-fallback.nix` harness + `checks.docking-ux` as the V2 gate.
Cost: a focused refactor of safety-critical code — deliberately NOT done as
hygiene drive-by; deserves its own iteration.
### 149. `[watch]` Waybar's exec-once workaround may be defending against nothing post-linger
Spun out of #147 (shipped 2026-07-17) so its loose thread survives the entry:
`modules/home/hyprland.nix` runs Waybar from `exec-once` rather than a systemd
unit because the unit "raced Hyprland's IPC on a **warm** relogin ... never
retried, so the bar vanished" — and a warm relogin is exactly what linger
created. With the stray linger removed (2026-07-16) that workaround may be
defending against nothing. Do not rip it out on this note alone: it works, and
the claim needs a real relogin without linger to test (V3 — the same relogin
that verifies #148's watcher fix would answer it).
**2026-07-18 evidence: the warm relogin is real post-linger.** On the dev
box the user manager survived a Hyprland logout (the tty1 greetd session
keeps it alive — no linger involved) and the teardown broke:
`graphical-session.target`'s stop transaction was rejected as destructive
(easyeffects had a queued start job), the target stayed active,
cliphist/swaync/portals/swayosd crash-looped against the dead Wayland
socket into `start-limit-hit`, and the relogin's plain `start` was a no-op
— doctor red for the whole session. Session bring-up now stops stale
targets + `reset-failed` before starting (`hyprland.nix`
systemd.extraCommands); relogin V3 check queued in HARDWARE-QUEUE. The
exec-once posture this entry watches is therefore still earning its keep.
### 120. A netinstall ISO, next to the fat offline one
**Deferred to PROPOSED 2026-07-16 (Bernardo): not now.** Nothing below is
stale — the measurements stand and the decision it needs is unchanged. It sits
here because it is the only genuinely large item left, and because the gotcha
at the bottom (no binary cache for our own derivations) probably makes a
cachix the real first step, not the ISO.
Bernardo 2026-07-14, after seeing the measured size: **keep the current ISO
exactly as it is** — the guaranteed offline install is the feature it buys —
and ship a **much lighter netinstall variant alongside it**. Two products, one
distro: "works on a plane" and "8 GiB is absurd to download" are both true, and
a second target settles them without compromising either.
**Measured facts (2026-07-14), so this starts from numbers, not vibes.**
*(These stand as measured: #121 would have cut ~0.67 GiB of duplicate chromium
from them, but it was **reverted** — decided against, ROADMAP § one chromium,
not two. If a netinstall ships, revisit it: the duplicate is worth ~195 MiB of
**download**, which is this item's whole currency, even though it is worth
almost nothing on disk or on the ISO.)*
> **Read this before using the numbers below.** They are **closure arithmetic**,
> and #121 proved the hard way that closure size is neither disk size nor image
> size: removing a 687 MiB path shrank the ISO by **8 KiB**, because
> **mksquashfs dedupes duplicate files** and **`auto-optimise-store` hardlinks**
> them on disk. So a change that looks like it sheds gigabytes of closure can
> shed nothing off the actual image. **Measure the artifact — build the ISO and
> `stat` it.** The corollary cuts the other way and is the good news for this
> item: what dedupe cannot help is the **wire**, so a netinstall's download is
> the one figure closure/NAR size predicts honestly (`nix path-info --store
> https://cache.nixos.org --json` gives the real `downloadSize`).
- Current ISO **8.078 GiB** compressed; **18.03 GiB** of store uncompressed
(`zstd -19`, 2.23:1 — compression is already near-max, not the lever).
- The offline pin (`system.extraDependencies`, 60 roots: a representative
installed system + the template HM closure + all flake inputs) is **4.02 GiB
uncompressed of that — only ~22%**. Dropping it entirely still leaves a
**~13.3 GiB** desktop → roughly **6 GiB** compressed at the same ratio.
**So "no pin" alone is NOT the lighter ISO** — this is the trap to avoid.
- The desktop's own top weights: libreoffice 1457 MiB, initrd 1369,
linux-firmware 770, chromium 1391 (two builds — #121, left in), llvm-lib 540,
bibata-cursors 322, mesa 264, mbrola-voices 259, nerd-fonts ~420 combined.
Note what that list implies: no single lever gets a desktop ISO under ~4 GB —
which is the case for (b) below.
**So the real decision is what a netinstall ISO IS**, and it should be settled
first (`[human]`): (a) the full try-before-install desktop minus the pin
(~6.3 GiB — barely lighter, probably not worth a second target); (b) a **TUI
installer only, no desktop** (~1 GiB, the actual "netinstall" in the Debian
sense) which drops "try before install" from that medium — the fat ISO still
offers it; (c) a middle desktop (no libreoffice/chromium — but note #103 just
put those there deliberately, and a *demo* desktop that can't browse is the
bug #103 fixed).
**The gotcha that decides feasibility:** without the pin, a netinstall target
fetches from `cache.nixos.org` for stock nixpkgs paths — but **Nomarchy's own
derivations are in no binary cache**, so they would build *from source on the
user's machine* during install. That is the same failure `tools/vm/gap-analysis.py`
exists to diagnose (and #113 is a live instance of). So this item probably
depends on a public binary cache (cachix) for the flake's own outputs, or it
trades an 8 GiB download for a 40-minute install. Establish that before
building the target.
Pass = a second, documented ISO target that is *substantially* smaller (state
the measured number, both ISOs built from one tree), installs successfully with
a network in a QEMU run, says clearly at boot that it needs one, and leaves the
offline ISO's behaviour untouched (`checks.*` for the offline path stay green).
- **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.
_(#80#83 + #85#88 shipped 2026-07-11. Theme A day-2 + neon-glass finish
shipped — VISION ✓. Dock/hibernate V3 → HARDWARE-QUEUE. Parallel
fingerprint-or-password shipped 2026-07-12 (Bernardo promoted it live;
`fingerprint.parallel`, pam-fprint-grosshack) — reader V3 →
HARDWARE-QUEUE.)_
### v1.0 pointer
See **VISION**. Open PROPOSED: NVIDIA deferred past v1; IR portal (b)/(c)
need T14s (HARDWARE-QUEUE § T14s). Standing calls: browser = Chromium;
power = PPD. (Post-install hardware hints shipped 2026-07-17.)
## 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-17)
- **#143 rofi refilter highlight** — the 07-16 "wait for upstream" ruling
was priced against carrying a patch; the maintainer's reply on
rofi#2317 pointed at the `inputchange {}` config block instead. Adopted
same day as pure config (`kb-row-first` on query edits, generated
config.rasi) — no patch, no source build, watch closed
(ROADMAP § Rofi highlight).
- **#120 netinstall** — stays deferred in PROPOSED (re-affirmed). When it
is promoted, a public binary cache (cachix) is the likely first step.
- **#134 unstable packages** — build it: Nomarchy carries the
`nixpkgs-unstable` input, `unstable.*` via the overlay, home-scope only.
Shipped the same day (ROADMAP § `unstable.<pkg>` in the downstream).
- **#114 greeter layouts** — document only: a VT has one keymap by design,
so tuigreet cannot honour per-device layouts. Shipped as the README
"Greeter keyboard layout" note + a RECOVERY.md pointer; entry deleted.
### 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.
**Shipped as #76**; V3 power-cycle PASSED on TuringMachine 2026-07-12
(ROADMAP § Hibernation + zram by default).
### 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**.

75
agent/CONVENTIONS.md Normal file
View File

@@ -0,0 +1,75 @@
# 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 `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, executive-ivory, boreal, boreal-dawn, kiln and
kiln-clay (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. The release
inputs stay within their pinned release branches; the `nixpkgs-unstable`
input (#134, feeds `unstable.*`) tracks `nixos-unstable` and bumps
together with the rest — never alone, never `nix flake update`.

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 (`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, Kitty, 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.

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

@@ -0,0 +1,626 @@
# 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).
## Suggested order (sweep 2026-07-15)
Run these first when you have a laptop session (AMD dev box unless noted):
| # | Item | Why first |
|---|------|-----------|
| 1 | **#115** suspend-then-hibernate | Just shipped; quick path is minutes (battery vs AC + `nomarchy-suspend`) |
| 2 | **#127** the brick's *cause* (reopened) | Wake path fixed + fully verified 2026-07-16; cause unknown — start at the dead Ctrl+Alt+F3, not the wake path |
| 3 | **Docking recovery round 8** + headphone jack-follow | Same dock session; relogin once, then undock ×5 + jack |
| 4 | **#104** airplane mode | Fast radio smoke after `nomarchy-home` |
| 5 | **#101** charge-limit USB-C burst | Dock plug/unplug storm; pairs with #3 |
| 6 | **Parallel fingerprint** hyprlock/greeter residual | sudo path already PASSED 2026-07-14 |
| 7 | **#95** Kitty-only on Acer | Validates terminal stack on oldest GPU |
| 8 | **#123** / **#124** install bake + flake pin | Next full reinstall window |
Everything else below stays open; order is convenience, not a gate.
## Any machine (dev box is fine)
- [ ] **Theme pairs V3 — the four new themes on real GL** — after pulling
the pair commits: `nomarchy-state-sync apply <slug>` for each of
boreal-dawn, executive-ivory, kiln, kiln-clay. Pass = all of:
(1) the bar renders true — boreal-dawn's frosted pills actually blur
what's behind them (softGL VM can't prove blur), kiln's plank shows
its copper edge, no font-fallback boxes (GeistMono / JetBrainsMono /
CaskaydiaCove); (2) wallpaper paints at native res, no banding on the
kiln ember gradient; (3) Kitty + btop under each theme — btop colors
match (the VM never started the GL terminal; btop.theme is
file-asserted only); (4) boreal-dawn app launcher (SUPER+Space) lays
out its icon grid; (5) an nm-applet/tray menu is readable under all
four (the `*`-reset counter-rules). If a VM-rendered preview looks
off in the theme-picker grid next to the hardware-captured ones,
recapture that preview on hardware into themes/<slug>/preview.png.
- [ ] **Light-theme contrast refits (2026-07-18 report)** — under any
light theme: (1) with two workspaces open, the inactive number is
readable on the bar (subtext, not the washed-out grey); (2) hover a
notification popup — background stays the theme's light card with a
faint tint, text readable (the swaync default's dark
.notification-default-action hover is now overridden); close
button matches the palette. VM-verified with a hover probe under
summer-day; this is the on-hardware confirmation of the report.
- [ ] **Auto-theme pair flip on hardware** — set
`settings.autoTheme.{day,night}` to one of the new pairs (e.g.
kiln-clay / kiln), force sunrise/sunset around the current time,
let the timer flip once each way. Pass = the whole desktop switches
in one generation, and nothing leaks from the previous theme —
fonts, rounding, terminal opacity all reset (the per-theme
appearance block), wallpaper follows.
- [ ] **#148 dock-intent enforcement without auto-profiles (dev box, docked)** —
after pulling the #148 commit: **log out and back in first** — the
watcher is `exec-once`, so `nomarchy-home` alone leaves the old one
running. Then, docked clamshell (lid closed, panel off, dockMode set
from the Display menu or a prior auto-dock), run `nomarchy-home` or a
bare `hyprctl reload`. **Pass:** eDP-1 re-lights for at most ~12 s and
goes back off by itself; `journalctl -t nomarchy-display-watch
--since -5min` shows `dock-intent=true … action=re-dock`; a "Docked —"
toast appears. Same relogin answers #149's question (does the Waybar
unit still race Hyprland IPC on a cold relogin without linger?).
**Fail:** the panel stays lit inside the shut lid (the #148 symptom).
- [ ] **#115 suspend-then-hibernate (laptop with hibernate/resume wired)** —
after system rebuild: Preferences shows **Suspend then hibernate (on)**
(hidden if `CanHibernate=no`). Confirm:
`grep -E 'HibernateDelaySec|HibernateOnACPower' /etc/systemd/sleep.conf`
`1h` / false; `grep HandleLidSwitch /etc/systemd/logind.conf`
undocked s2h, ExternalPower=suspend, Docked=ignore.
**Quick path:** on battery, `nomarchy-suspend` then
`systemctl list-jobs` / journal should show suspend-then-hibernate
(not plain suspend). On AC, same command → plain suspend.
**Full path (optional, long):** battery, lid close or idle past 15m,
leave ~1h+, open → single LUKS unlock (encrypted) or lock screen
(unencrypted), session intact. Toggle **off** + rebuild → plain
suspend only. **Pass:** battery s2h + 1h hibernate resume works;
AC never plans hibernate; no row without hibernate support.
- [ ] **#104 airplane mode radios (any laptop with Wi-Fi + BT)** —
after `nomarchy-home` + waybar restart: System **Airplane mode (off)**
(or SUPER+CTRL+R). **Pass on:** Wi-Fi and Bluetooth both drop (nm-applet /
blueman confirm); Waybar shows a plane glyph; click glyph or menu again
restores **only** the radios that were on before. If Wi-Fi was already
off, it must stay off after disengage. Glyph must **not** appear when
airplane is off.
- [ ] **#96 battery-limit row on threshold-less firmware (Acer M5-481T)** —
on the Acer (live or installed session with the commit carrying this
entry): open System (`SUPER+CTRL+I`). **Pass:** a "Battery limit" row
IS listed (battery present); picking it shows the notification
explaining the firmware exposes no charge-stop control (not a raw
error, and the row never silently disappears). On the T14s the row
must still open the working preset picker.
- [ ] **#101 charge-limit service survives USB-C power-event bursts**
(round 2) — round 1 FAILED on TuringMachine 2026-07-13 18:16: a
*spaced* storm (each run finished before the next AC event) landed 5
successful starts in 10s → `start-limit-hit`, unit marked failed,
doctor badge — although every run succeeded. Fixed by exempting the
unit from start rate limiting (`StartLimitIntervalSec=0`) + a
spaced-storm guard in `checks.battery-charge-limit` (the commit
carrying this entry). **Re-check:** after pulling/rebuilding this
commit, `sudo systemctl reset-failed nomarchy-battery-charge-limit
.service`, then plug/unplug the powered USB-C dock several times in
quick succession. **Pass:** `systemctl is-failed
nomarchy-battery-charge-limit.service` stays false, `Result=success`,
BAT0's `charge_control_end_threshold` still matches the limit, and the
journal has no new `start-limit-hit`/`Failed with result` lines.
- [ ] **Docking recovery round 8 (undock panel restore, 2026-07-14)**
round 7 **FAILED** on the AMD dev box (Bernardo, 56 consecutive
unplugs, none recovered). Its whole diagnosis was wrong, and the way it
was wrong is the lesson: round 6 read `result=enable-timeout` ×2 +
`result=ok` ×1 as an intermittent race and retried the keyword. But the
keyword is **inert, not raced** — with ZERO enabled outputs (panel
disabled by the dock, external gone) Hyprland 0.55.4 accepts
`keyword monitor` (prints `ok`, exits 0) and never flushes it until a
DRM event arrives. Retrying an inert command 25×/poll for 6 polls just
bought 30s of black screen. Every `result=ok` in that journal was
**Bernardo plugging the cable back in** — his hotplug flushed the queued
rule and the next poll took the credit. Probed live 2026-07-14: keyword
inert across 4s and 5s in two runs, `dispatch forcerendererreload` also
inert, and only `hyprctl reload` worked — 99ms and 289ms, cable out.
The transition now escalates to `reload` when the keyword proves inert,
re-asserts the rule, and restores per-device keyboard layouts (a reload
drops runtime `device[…]:kb_layout` keywords).
**Already proven on hardware** (2026-07-14, two real unplugs invoking
the fixed `nomarchy-display-transition undock eDP-1` directly with the
watcher paused): panel on and workspaces 13 home in 1.8s, journal
`keyword=inert escalate=reload``enable=via-reload``result=ok`; and
the keyboard-restore path with a Logitech K400 whose receiver is in the
laptop — it kept its remembered `us` across the undock, while a control
`hyprctl reload` with no restore dropped it to the session's `gb`
(so `restore_keyboards` is load-bearing, not insurance).
**What is NOT proven, and is this round:** the *watcher-driven* path
(the running watcher calls the transition by baked store path, so it
only picks the fix up at relogin — the round-5 trap), and repetition.
**Re-check:** relogin (mandatory — see below), dock, then unplug the
cable **at least five times**, plus once with the lid shut.
**Pass:** the panel comes back every time with workspaces intact, and
`journalctl --user
-t nomarchy-display-transition -t nomarchy-display-watch --since
'-10 min'` shows `result=ok` for each undock with **no**
`result=enable-failed`. `keyword=inert escalate=reload` on every undock
is EXPECTED — that is the fix firing, not a fault. A `result=ok`
**without** a preceding `keyword=inert` line means the keyword flushed
on its own and the reload was never needed: interesting, worth
reporting, still a pass.
- [ ] **Headphone jack-follow (last open count from rounds 46,
2026-07-14)** — rounds 46 opened five counts on the AMD dev box; four
are now **confirmed fixed on hardware** by round 6 (Bernardo): wallpaper
on the external, automatic dock mode, audio reaching the monitor, and
the external keyboard prompting **exactly once** (was four times). The
undock panel is round 7 above. This is the one count **never tested**:
plugging headphones had stopped moving audio to them, because a pinned
`default.configured.audio.sink` outranks the priority rules, which kills
jack-follow on UCM cards where the headphones are their own sink (fixed
in ce480f3's lineage, unverified). **Re-check:** docked and undocked,
plug and unplug headphones — audio must move to them and back each time,
and `journalctl --user -t nomarchy-dock-audio` must show `trigger=jack
selected=…` on each plug. Worth folding into the round 7 session: the
dock is already in hand and the two don't interfere.
**Historical detail for the four settled counts** (kept only until
round 7 closes; the fixes themselves are in the log):
— round 4 was run on the AMD dev box and **failed on four counts**
(Bernardo): no wallpaper on the external monitor, no automatic dock
mode (System Display had to be used by hand), audio staying on the
laptop speakers while docked, and one dock + one external keyboard
asking for a layout **four times**. Separately, plugging headphones had
stopped moving audio to them. Causes and fixes in the commit carrying
this entry: the display watcher's `socat -T 1` closed Hyprland's IPC
socket after a second of idle, so a dock plugged into an idle desktop
lost `monitoradded` in the reconnect gap (wallpaper + dock mode + audio
all hang off that one event); Hyprland lists every key-capable evdev
node as a "keyboard"; and a pinned `default.configured.audio.sink`
outranks the priority rules, which kills jack-follow on UCM cards where
the headphones are their own sink. **Re-run round 4 below in full**,
plus: (a) leave the desktop untouched for ~30s, then plug the dock —
wallpaper, dock mode, and audio must all follow with no manual step;
(b) the external keyboard must prompt **exactly once**, and never for
the monitor or the lid/power buttons; (c) docked and undocked, plug and
unplug headphones — audio must move to them and back each time.
`journalctl --user -t nomarchy-display-watch` should show
`outputs-changed added=…`, and `-t nomarchy-dock-audio`
`trigger=jack selected=…` on a headphone plug.
**Round 5 was itself run and failed (2026-07-14); this entry now covers
round 6.** Two of the four reports were never actually testing the fix:
both watchers are Hyprland `exec-once`, so `nomarchy-home` swaps the
config but leaves the *old* processes running — only `nomarchy-dock-audio`
(a user service) had picked up new code. **Relogin is mandatory before
testing either watcher**; confirm with `tr '\0' '\n' < /proc/$(pgrep -f
bin/nomarchy-display-profile-watch)/cmdline | sed -n 2p` and grep the
script for `outputs-changed`. The other two were real and are fixed in
the commit carrying this entry: automatic dock mode did not exist at all
(the watcher auto-*undocked* but only ever matched display profiles on
plug, and this box has none saved with `displayProfileAuto` unset, so
nothing happened — `Dock mode` was interactive-only), and the BenQ's
audio was unreachable because WirePlumber had `alsa_card.pci-0000_c3_00.1`
pinned to `pro-audio`, whose raw `pro-output-N` sinks have no ports and
no routing. The BenQ's own USB card is input-only (all profiles
`sinks: 0`), so DisplayPort is the only path to its speakers.
**Also check:** no display profile is saved, yet plugging the BenQ must
now dock by itself; and `journalctl --user -t nomarchy-dock-audio` shows
`repaired-card=alsa_card.pci-0000_c3_00.1 profile=HiFi` on the first
plug, after which audio reaches the monitor's speakers. A saved+matching
display profile must still win over auto-dock.
- [ ] **Docking recovery round 4 (#100, closed-lid BenQ sequence)** — after
updating to the commit carrying this entry, run `nomarchy-home` and
relogin. With the lid open, put visible windows on at least workspaces
1, 2, and 3 and start audio; plug the BenQ GW2790QT. **Pass on plug:**
wallpaper appears, audio toasts and moves to a BenQ/GPU HDMI sink, and
`journalctl --user -t nomarchy-dock-audio -n 10` shows
`trigger=monitoradded` plus `selected=…` (not only a card event). Open
System Display **Dock mode**. **Pass on dock:** every workspace and
window is on the BenQ, focus follows, eDP is disabled in `hyprctl
monitors all -j`, and `systemd-inhibit --list` shows Nomarchy holding
`handle-lid-switch` for “Safe dock/undock display transition”; there is
no dangling laptop workspace and no application exits. Close the lid;
audio/session must stay awake. While still docked, manually choose the
speakers and change volume/mute: it must stay on speakers. Unplug the
BenQ **with the lid still closed**, wait 10 seconds, then open it.
**Pass on undock:** no suspend/crash/hard reset, eDP is already active,
workspaces 1/2/3 and their windows are intact on it, and the inhibitor
disappears only after lid-open. `journalctl -b --since '-5 min'` must
contain no cable-removal `systemd-logind: Suspending...`. Finally close
the undocked lid normally: the laptop must suspend, proving policy was
restored. Wake it, replug the BenQ: that fresh plug must move audio back
to HDMI (manual speakers do not survive a physical replug). If any step
fails, capture `journalctl --user -t nomarchy-display-watch -t
nomarchy-display-transition -t nomarchy-dock-audio --since '-10 min'`,
`systemd-inhibit --list`, `hyprctl monitors all -j`, `hyprctl workspaces
-j`, and `pactl --format=json list sinks` before changing state again.
- [ ] **Parallel fingerprint-or-password on the real reader** (AMD dev
box, 2026-07-12) — **sudo path PASSED 2026-07-14** (Bernardo, dev box,
gen 422, `fingerprint.pam = true`, parallel default): `sudo -k true`
showed ONE prompt, the typed password worked, touching the sensor
instead worked, and wrong-finger ×3 fell back to password.
**Still open:** hyprlock and the greeter must accept both factors and
not wedge on a leftover prompt after a fingerprint win (known cosmetic
quirk of the hack — pthread_cancel'd prompt); `fingerprint.parallel =
false` must restore the old sequential behavior.
**Do NOT "check password still works with `systemctl stop fprintd`"**
that check was in this item and it is a trap that cannot fail. fprintd
is D-Bus activated, so PAM asks D-Bus and D-Bus starts it straight back
up: Bernardo ran it and *the fingerprint still authenticated*, which is
the tell that the daemon never stayed down. It proves nothing about a
dead reader. The property it was reaching for is structural anyway —
password is `auth sufficient` at order 11700, independent of the fprintd
rule at 11400, so no fprintd failure can gate it (verify by eval:
`nix eval --raw <flake>#nixosConfigurations.<host>.config.security.pam
.services.sudo.text`), and checks.hardware-toggles asserts that shape in
the VM with no reader at all. If someone genuinely wants it on hardware
it needs `systemctl mask` + stop, and masking a working machine's
authentication daemon to re-prove an evaluated invariant is a bad trade.
- [ ] **#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.
Enroll/List/Verify verified on hardware 2026-07-14 (T14s); the
remaining unknown is a reader-less machine's self-gate.
- [ ] **fingerprint + auto-login toggles on hardware** (this commit) — on the
T14s, after removing the pinning lines from `~/.nomarchy/system.nix`
(`greeter.autoLogin`, `hardware.fingerprint.pam`):
1. System Fingerprint shows **Fingerprint (on)** (state already true).
Toggle it off → terminal opens, sudo system rebuild + home switch →
toast "Fingerprint off". Expect: sudo now refuses the finger and asks
only for a password; the lock screen (SUPER+CTRL+L) shows no
"or scan your finger" line. Toggle back on → both return.
2. With fingerprint OFF and no finger enrolled, the toggle must REFUSE
with "Enroll a finger first" and write nothing.
3. System Auto-login shows **(on)**. Toggle off → sudo rebuild →
reboot → tuigreet asks, and accepts password OR finger (fingerprint
on). Toggle back on → reboot → straight to the session, no prompt.
Expected throughout: the two are independent, and auto-login on means
no boot prompt regardless of the fingerprint switch.
- [ ] **#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 a real terminal UI, 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-state-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.
- [ ] **Per-device keyboard menu + hotplug restore (re-verify after in-flake
graduation)** — update to the current `main`, run `nomarchy-home`, and
relogin with an external keyboard already connected. Open System
Keyboard: both the built-in and external boards must be listed with
their current layouts. Pick a different layout for the external board;
it must apply only there and persist in `settings.keyboard.devices`.
Unplug/replug it: the saved layout must restore without another prompt
and graduate into a `device{}` block on the next rebuild. Finally plug
a previously unseen keyboard in after login: the automatic picker must
appear once and remember that choice too.
- [ ] **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 no-swap Hibernate notify** (#76 itself is closed — the
hibernate→resume power-cycle **PASSED on TuringMachine 2026-07-12**:
Bernardo ran the full hibernate → power off → single LUKS unlock →
session-restored cycle "flawlessly"; this notify check is the one
remaining leftover) — 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.
- [ ] **#103 live-ISO baseline apps actually launch** (this commit) — the
half no headless check can reach: `checks.live-baseline-apps` proves the
binaries and `.desktop` entries are in the exact HM generation the ISO
ships, but not that a GUI app opens on real hardware (the agent may not
drive a graphical VM, and the live ISO has no SSH to script one). On the
**Acer M5-481T** (the machine that found this — its GPU is the oldest
shipped, so it is the honest test), boot the new live ISO and, from the
launcher only — no terminal, that's the point: open **Chromium**,
**LibreOffice** (Writer), **Text Editor**, **Amberol**, **Snapshot**.
**Pass** = all five are *listed in the launcher* and each opens a window.
Also: a `.html` file opens in Chromium (the HTTPS mime default now names
a browser that is present — #94's exact bug), and a `.txt` file opens in
**Text Editor** (`text/plain` falls through to
`org.gnome.TextEditor.desktop` after #119; vscode remains preferred on
template installs). Firefox is deliberately absent; its absence is
correct, not a miss.
- [ ] **#123 install bake: first boot fully themed without manual HM switch**
— re-install from a main ISO (this commit+) on **either** the Acer
M5-481T or the Dell XPS 9350. **Pass:** install ends with "Desktop
pre-activated", no `dconf-CRITICAL` in
`/var/log/nomarchy-hm-preactivate.log`, first graphical login has
Stylix GTK + nm-applet **without** hand `home-manager switch`.
- [ ] **#95 Kitty-only terminal on Acer M5-481T** — after rebuild/install
from main: SUPER+Return opens **themed Kitty**; System Doctor opens
a floating classed Kitty window. No Ghostty on PATH required.
- [ ] **#124 flake pin after main-ISO install** — installed machines
`~/.nomarchy/flake.nix` has `?ref=main` (not lagging v1). **Pass:**
`nomarchy-rebuild` does not die with
`The option 'nomarchy.hardware' does not exist`.
- [ ] **Relogin session recovery (2026-07-18 incident)** — after pulling
the session-recovery commit and rebuilding: from a full Hyprland
session (bar up, a browser open so easyeffects/tray are busy), log
out (SUPER+SHIFT+E) and log straight back in at the greeter — do
**not** reboot between. **Pass:** `nomarchy-doctor` shows *no failed
user units*; `systemctl --user status cliphist swaync
xdg-desktop-portal-hyprland swayosd` are all `active (running)`; the
bar, notifications, and clipboard history (SUPER+V) work. This is
the V3 close for the stale `graphical-session.target` /
`start-limit-hit` relogin breakage (BACKLOG #149 note).
## AMD dev box only
- [ ] **#118 smartd still runs where drives DO have SMART** (this commit) — the
half a VM cannot answer: QEMU exposes no SMART, so `checks.smartd-gate`
proves the skip but has to drive the *with-device* path through a stub.
On the dev box (real NVMe), after `nomarchy-rebuild` + reboot:
`systemctl status smartd` is **active/running**, and
`systemctl show -p ExecCondition --value smartd` names
`smartd-any-smart-device`. **Pass** = smartd is running, exactly as
before this commit — i.e. the gate skips nothing on real hardware.
**Fail** = inactive/skipped, which would mean the gate is silently
disabling drive-health monitoring: revert it, don't tune it.
Cheap bonus while you are there: `nomarchy-doctor` reports no failed
units (the red-icon symptom that started #118).
- [ ] **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.
- [ ] **#137/#145 splash on a real docked boot** — the one thing the render rig
cannot do: its fake heads exist from the start, so the canvas never
resizes, which *is* the bug. **Docked**, reboot: pass = the logo is centred
on **both** panels at boot **and** at shutdown (it was off-centre on the
external), and the LUKS prompt shows the padlock, the entry, and beneath it
a keyboard icon + your layout (`us`). Then reboot **undocked** — unchanged.
Rendering is already proven (`tools/plymouth-preview.sh`, both heads +
`ask-for-password`), so what is unproven is only a canvas that changes
under a real DRM boot. If the splash is ever blank, the passphrase prompt
is invisible but still live — type it blind, or pick the previous
generation in the boot menu, which carries the old theme.
- [ ] **#142 dock mode survives a rebuild** — **relogin first** (the watcher is
`exec-once`; `nomarchy-home` alone leaves the old one running and you
would be testing nothing — round 6's lesson). Then, docked: run
`nomarchy-home`. Pass = the laptop panel stays off, no workspace lands on
it, no menu trip; `journalctl --user -t nomarchy-display-watch` shows
`dock-intent=true panel=eDP-1 state=re-enabled action=re-dock` (a rebuild
re-lights the panel and the watcher takes it back within ~1s, so a brief
flicker is expected — the panel staying *up* is the failure).
Then the three gates that must NOT fire, since this code can disable a
panel: (a) Menu ▸ Display ▸ **Screen on** → the panel stays on (intent
cleared; if it snaps off again within a second, that is the bug);
(b) **undock** → panel returns and stays; (c) boot/relogin **undocked**
with `settings.display.dockMode` still `true` in state.json → panel stays
on (no external ⇒ never re-dock). (c) is the one that matters: it is the
#127 brick if it is wrong. Gates are unit-tested against stubs, but only
hardware proves the wiring.
- [ ] **#138 dock audio does not break a running browser** — the plug event
itself is the only thing V2 could not do (the tool was driven by hand;
Hyprland calls the same entry point). **Relogin first** — the watcher is
`exec-once`, so `nomarchy-home` alone leaves the old process running and
you would be testing nothing (round 6's lesson). Then, with Chromium
**already open** on a Meet call: dock/undock. Pass = output still follows
to the monitor, **Meet keeps its mic and speakers without restarting the
browser**, and `journalctl --user -t nomarchy-dock-audio` shows
`selected=…` with **no** `action=graph-restart-fallback`, while
`systemctl --user show -p MainPID --value pipewire.service` is unchanged
across the plug. If a `graph-restart-fallback` line does appear, rung 3
fired on a healthy plug — that is a bug, not the recovery working: file
it with the surrounding journal.
- [ ] **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.)
## Acer Aspire M5-481T only (1366×768 — the narrow-panel case)
- [ ] **#139 sheets on a small panel** — Waybar clock → calendar, System ▸
Doctor, and the Waybar updates click. Pass = each opens floating and
centred at roughly 60%×65% / 55%×70% / 45%×50% of *that* screen (they are
computed from the focused monitor now, so this is checking the fallback
path and the font, not the arithmetic), fully on-screen, none clipped by
the bar. Dev box (2560×1440) already measured exact.
- [ ] **#131 menu width on the real narrow panel** — open Menu ▸ Recovery.
Pass = every label complete (no ellipsis) and the picker visibly *not*
hogging the screen (≤65%, the cap). The geometry was already reproduced
pixel-exactly on the dev box (888px + JetBrainsMono 14 = what 65% of 1366
produces) and passed, so what is genuinely unproven here is only the
Acer's own fontconfig/DPI resolving `ch` the same way — try it under a
**JetBrainsMono 14 theme** (summer-day/night, kanagawa), the widest case.
## 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).

2225
agent/JOURNAL-ARCHIVE.md Normal file

File diff suppressed because it is too large Load Diff

1964
agent/JOURNAL.md Normal file

File diff suppressed because it is too large Load Diff

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.

219
agent/MEMORY.md Normal file
View File

@@ -0,0 +1,219 @@
# 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
- **Doctor float V2:** `THEME=<slug> nix build --impure -f tools/doctor-float.nix`
— softGL Hyprland, `nomarchy-menu doctor`, asserts
`class=com.nomarchy.doctor` + `floating` + centered midpoints +
screenshots (Kitty `--class=…`). SoftGL may still struggle with a GPU
terminal; size can ignore percent windowrules if client geometry wins.
- **theme-shot softGL may not start Kitty** — `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
event burst while the oneshot is active; clean inactive result plus an
`InvocationID` change proves coalesced AC re-apply). AC udev hooks for a
settling oneshot must use `start`, never `restart`: USB-C docks emit event
bursts and restarts SIGTERM the in-flight pass into `start-limit-hit`.
- 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 needs guest GL (`virtio-vga-gl`, `gl=on`) in interactive QEMU
or the session won't start; black screen ≈ missing GL
(docs/TESTING.md § gotchas). Kitty is the sole terminal (no Ghostty).
- 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.
- `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/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
- **Dock transitions are ordered safety operations (#100):** dock in one
`hyprctl --batch` (external on → every internal workspace moved → focus
external → internal off); undock enables internal *before* moving anything.
The Hyprland watcher, not a shell-pipeline subshell, owns a low-level
`handle-lid-switch` inhibitor until the lid is physically open; startup
cleans a validated stale process group. HDMI availability may appear only
as `change:sink`, so fresh `monitoradded` is the intent boundary for the
settled PipeWire/WirePlumber reprobe + sink pick; generic audio changes must
not override a manual in-dock speaker choice.
- **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)
- **#127 forensics/method lessons (three wrong diagnoses' worth):** drive the
real code path or measure nothing (a hand-run `dpms off` has no wake path
watching it, so "input didn't wake it" was guaranteed); do not read output
lists as lid positions — `/proc/acpi/button/lid/*/state` is the evidence
(a reload can light a panel inside a shut lid, #148); and anchor forensics
on the right boot — an *unclean boot end* (journald corruption + dirty bit
on the next boot) is itself the incident marker, and `--list-boots` comes
before any grep.
- **OVMF exposes /dev/fd0 as TYPE=disk (#112):** disk picker must exclude
`/dev/fd*` and tiny sizes; sort largest-first or blind Enter picks floppy.
- **Live ISO offline theme switch (#113):** only the *default/pinned* HM
generation is offline-safe; other presets may try to build the world —
document the contract and fail with a network-oriented message.
- **Installer HM pre-activate needs XDG_RUNTIME_DIR (+ session bus) (#123):**
`runuser … activate` inside `nixos-enter` has no user session. Without
`mkdir -p /run/user/$UID` owned by the install user and
`XDG_RUNTIME_DIR` (prefer `dbus-run-session -- activate`), dconf dies
with Permission denied and the bake aborts mid-way — first boot looks
unthemed / missing nm-applet even though the system installed.
- **ISO filename is `image.baseName` (#125):** volumeID alone does not rename
`result/iso/…`; force `image.baseName` to `nomarchy-…` or the artifact
stays `nixos-live-….iso`.
- **Install flake ref must match the ISO branch (#124):** while `v1` lags
`main`, seed `?ref=main` (and compose-lock original) from main-built ISOs;
`NOMARCHY_FLAKE_URL` must actually rewrite `inputs.nomarchy.url` (was set
but unused until #124).
- Gum `filter` returns unmatched typed text by default; catalog-only pickers
require `--strict` plus an independent exact-membership validation boundary.
- 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).
- Never gate a safety listener behind the optional feature it also serves:
the display menu existed with no profiles while its blackout rescue did
not. Rofi defaults to mouse-pointer output (`monitor=-5`), which is stale
in clamshell mode; use focused output (`-1`) for keyboard-launched UI.
- 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, Kitty, 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.

181
agent/VERIFICATION.md Normal file
View File

@@ -0,0 +1,181 @@
# 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". Since #134 the
lock carries **two channels** (the release pin and the
`nixos-unstable` pin feeding `unstable.*`) — a bump moves both, and
this checklist applies to both.
- **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.

529
docs/HARDWARE.md Normal file
View File

@@ -0,0 +1,529 @@
# Hardware support
How Nomarchy enables CPUs, GPUs, laptops, firmware, and peripherals — and
what to do when your machine is not in the happy path.
**Minimum bar** (OpenGL, RAM, disk, UEFI): [`REQUIREMENTS.md`](REQUIREMENTS.md)
— read that before calling a machine “unsupported”; this file is the
enablement stack and quirk list.
> **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 when `fwupdmgr` is on PATH.
Fingerprint / doctor tips follow the same pattern (fingerprint MOTD only
when `nomarchy.hardware.fingerprint.enable`). A one-shot toast in the
first graphical session also points at System Firmware when `fwupdmgr`
is on PATH (`settings.hardwareHintsShown`, `nomarchy-first-boot`) — for
people who never read the MOTD.
**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)
# nomarchy.hardware.fingerprint.parallel = false; # sequential prompt instead
```
### Enroll (menu or CLI)
**Shipped #55:** System Fingerprint (self-gated when `fprintd-list` is
on PATH) — a single **Fingerprint (on/off)** switch, plus Enroll / List /
Verify / Delete all (all usable while it's off — turning it on needs an
enrolled finger). The switch writes `settings.fingerprint.pam`, the one
state key behind *both* halves of "use my finger": login/sudo PAM here,
and the lock-screen unlock in `modules/home/idle.nix`. It runs
`nomarchy-fingerprint`, which sudos a system rebuild and then a home
switch, because the two halves live in different configurations.
It does **not** decide whether login prompts at all: auto-login skips the
greeter entirely, so this adds the finger to the prompts that actually
happen — sudo, the lock screen, and the greeter only when
[auto-login](#auto-login) is off.
**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. A one-shot toast in
the first graphical session also points at System Fingerprint when
`fprintd-list` is on PATH (`settings.hardwareHintsShown`,
`nomarchy-first-boot`) — for people who never read the MOTD.
```sh
# CLI still works:
fprintd-enroll
fprintd-list "$USER"
nomarchy-fingerprint toggle # or on | off | status — what the menu row runs
```
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.
### Auto-login
`settings.greeter.autoLogin` (System Auto-login, or `nomarchy-autologin
[toggle|on|off|status]`) decides whether boot goes straight to the desktop
or stops at the greeter. The installer seeds it ON for LUKS-encrypted
machines — the disk passphrase already gates the machine, so a greeter
password is a second prompt for the same thing — and leaves it off
without LUKS, where the greeter is the only thing between power-on and the
desktop. It's baked into greetd at system rebuild, so the change shows on
the next boot.
Auto-login and fingerprint are independent, and auto-login wins at boot:
with it on you are never asked for anything at startup, whatever the
fingerprint switch says. Turn auto-login off and the greeter asks — for a
password, or a password *or* finger when fingerprint is on.
Both are in-flake state rather than lines in `system.nix` on purpose: a
hand-set `nomarchy.system.greeter.autoLogin` (or
`nomarchy.hardware.fingerprint.pam`) outranks the state and pins the
setting, leaving the menu toggle unable to move it. That's the escape
hatch, not the default — leave those lines commented to use the menu.
### Parallel prompt (password *or* finger, whichever first)
With `fingerprint.pam` on, sudo/login/hyprlock show **one** prompt that
accepts either factor — type the password or touch the sensor
(2026-07-12; `pam-fprint-grosshack`, an fprintd fork, since stock PAM
can't express parallel factors). This is the default;
`fingerprint.parallel = false` restores stock pam_fprintd's sequential
wait-for-the-reader-then-password. Lockout safety is structural: the
module never validates passwords itself — a typed password is handed to
the normal `pam_unix` rule, and every failure (no reader, fprintd hung,
timeout) falls through to password, so finger auth can only ever *add* a
way in. Verify the wiring with
`grep pam_fprintd_grosshack /etc/pam.d/sudo`. Known cosmetic quirk: after
a fingerprint win a leftover password prompt may linger on the console.
### 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. Install-tested machines (hall of fame)
Models that have seen a real Nomarchy session (live ISO, install, or
day-to-day QA) — not a guarantee of every feature, just a public seed
list. **DB coverage** (hardware-db.sh) is broader; this table is the
smaller “someone actually sat at it” set.
| Model | DMI (`sys_vendor` × `product_name` / version) | Typical `hardwareProfile` | Last noted | Notes |
|-------|-----------------------------------------------|---------------------------|------------|-------|
| Lenovo ThinkPad T14s Gen 4 (AMD) | `LENOVO` × `21F8CTO1WW` / `ThinkPad T14s Gen 4` | common AMD laptop + SSD (plus `nomarchy.hardware.amd` / fingerprint / IR cam as probed) | 2026-07 | Maintainer day-to-day + HARDWARE-QUEUE (IR portal, fingerprint, NPU). Dual-sensor webcam is the motivating IR case. |
| Dell Latitude 5410 | Dell Latitude 5410 (Intel) | common Intel laptop + SSD (+ GuC when enabled) | 2026-07 | Primary Intel hardware-QA host (session, themes, polkit, bar). |
| Dell Latitude 5310 | Dell Latitude 5310 (Intel) | same family as 5410 | 2026-07 | Charge-limit / power QA sibling (see HARDWARE-QUEUE). |
### Send your DMI line
If you install Nomarchy on a machine and it works (or fails in an
interesting way), open a PR that:
1. Adds a row to the table above (model, DMI, profile, date, short notes).
2. Optionally adds a `HARDWARE_DB` line in
`pkgs/nomarchy-install/hardware-db.sh` when nixos-hardware has a
matching module (§10).
Quick DMI grab:
```sh
printf '%s | %s | %s\n' \
"$(cat /sys/class/dmi/id/sys_vendor)" \
"$(cat /sys/class/dmi/id/product_name)" \
"$(cat /sys/class/dmi/id/product_version)"
nomarchy-detect-hw # suggested MODULE / NOMARCHY lines
```
## 10. 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`.
Install-tested status goes in the hall-of-fame table (§9).
## 11. Doctor and hardware health (current vs target)
**Today** (`nomarchy-doctor`): failed units, disk space, state.json
validity/git, generation age, snapper; hardware section self-gates per
machine (NetworkManager, audio sink, GPU smoke, fprintd, fwupd, charge
limit, battery health, hibernate/zram). All read-only; each ✖ prints one
fix command.
| 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 reports it | power docs / restart charge-limit unit |
| Battery health | cycle_count and/or charge\|energy_full÷design % when sysfs exports them | report-only (warn if &lt;70% of design) |
| First-boot pre-activate | installer log present + no HM generation | `home-manager switch --flake ~/.nomarchy -b bak` |
| Hibernate / zram | disk swap + resume=; zram active | docs/MIGRATION.md → Enabling hibernation |
## 12. 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` / `.parallel` | fprintd + PAM (parallel password-or-finger prompt by default) |
| `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) |
## 13. 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 |
## 14. 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 |

539
docs/MIGRATION.md Normal file
View File

@@ -0,0 +1,539 @@
# 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)
# Auto-login is NOT set here: it lives in state.json
# (settings.greeter.autoLogin) so System Auto-login can move it — a line
# here would outrank the state and pin it. Turn it on after the first boot
# with `nomarchy-autologin on` (what nomarchy-install seeds on encrypted
# installs: the LUKS passphrase already gates access).
# 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, …
];
}
```
### `state.json`
Copy the template's `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 BEFORE the first graphical login.
# Hyprland without an HM generation shows the yellow "autogenerated
# config" banner (and no Nomarchy theming) — finish this step first.
home-manager switch --flake ~/nomarchy-migrate#bernardo -b bak
```
Log into Hyprland and sanitycheck: Waybar renders, `SUPER+M` opens the
menu, theming is coherent, `SUPER+?` shows the cheatsheet, **no** yellow
Hyprland autogenerated banner. Confirm the machinespecific things you
care about still work — suspend/hibernate, the AMD GPU (`vainfo` →
radeonsi), display brightness.
You should also get a one-shot **You're set** toast (menu / themes /
keys). If it never appears: `systemctl --user status nomarchy-first-boot`
and re-try with
`nomarchy-state-sync set settings.firstBootShown false --no-switch`
then log out/in.
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 a preset (writes
`state.json`).
- **Snapshots:** `nomarchy-menu` → System → Snapshots should see your
existing `@snapshots` subvolume.
- **Fingerprint:** `fingerprint.enable = true` only starts **fprintd**
(enrollment). Login/sudo finger auth is **`fingerprint.pam`** and is
opt-in — leave it commented until you've enrolled. NixOS defaults PAM
on whenever fprintd is enabled; Nomarchy forces PAM to follow the
`pam` flag, but only after a **system** rebuild. Verify with
`grep pam_fprintd /etc/pam.d/sudo` (should be empty when pam is off).
With pam on, the prompt accepts password *or* finger in parallel by
default (`fingerprint.parallel = false` for stock sequential) — see
HARDWARE.md §5.
- **Browser profiles:** Nomarchy does not manage Chromium/Firefox state.
Bookmarks/extensions live under `~/.config/chromium` (or
`~/.config/google-chrome` / ungoogled paths if that was your previous
browser). **If your old Home Manager config declared
`programs.chromium.extensions`, carry that block into `home.nix`
*before* first launch** — HM installs those via
`External Extensions/*.json`, and when the JSONs vanish Chromium
treats every extension as externally uninstalled and deletes it
**together with its stored data** (`Local Extension Settings` —
wallet vaults, password-manager pairings). Bookmarks survive, which
makes it look minor; it isn't. Recovery: close the browser, copy
`Default/Local Extension Settings/<id>` (plus any
`Default/IndexedDB/chrome-extension_<id>_*`) back from your Phase-0
`pre-nomarchy-home` snapshot, then reinstall each extension — from
the Web Store (ids are stable, so the data reattaches) or by
re-declaring the ids if you want them declarative again.
- **Thunderbird / Firefox — “all my email is gone” (it isnt):** Mozilla
apps find your data through **one small text file**,
`~/.thunderbird/profiles.ini` (and `~/.mozilla/firefox/profiles.ini`),
which names the profile directory to open. Lose that file and the app
does not error — it does what it does on a brand-new machine: creates
an empty profile and cheerfully opens *that*. Every account, folder and
message is still on disk, in the profile dir it stopped looking at.
This bit us on a real migration (20260716): a 30 GB profile with 19 GB
of `ImapMail` went “missing”; the fix was eight lines of `profiles.ini`.
**Before first launch,** check the file exists and names your real
profile:
```console
$ ls ~/.thunderbird/ # bernardo/ ← the fat one is your profile
$ cat ~/.thunderbird/profiles.ini
```
**If an app opens empty, do not restore a snapshot** — look first:
```console
$ du -sh ~/.thunderbird/*/ # a multi-GB dir = your data is fine
```
If a fat profile is sitting there, this is a pointer problem, not data
loss. Close the app, then write (`Path=` is the directory name, relative
to the `.thunderbird` dir):
```ini
[Profile0]
Name=default
IsRelative=1
Path=<your-profile-dir>
Default=1
[General]
StartWithLastProfile=1
Version=2
```
Keep the empty profile listed as `[Profile1]` (no `Default=`) if you
want it out of the way rather than deleted. The **restore is the risk
here**, not the bug: rolling a snapshot over a good 30 GB profile to
“recover” data that was never lost can cost you the mail that arrived
since. Same shape as the Chromium bullet above — a file HM used to
manage disappears and the app reads its own absence as “first run” —
but inverted: Chromium *deletes* quietly, Thunderbird *loses nothing*
and looks catastrophic.
- **VPN:** NetworkManager connections survive under
`/etc/NetworkManager` and your home. System VPN lists NM
`vpn`/`wireguard` profiles; import any that lived outside NM.
Tailscale is opt-in (`nomarchy.services.tailscale.enable`).
- **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`.
### `/var/lib` machine state survives migration
A migration reconciles the **flake** — packages, services, dotfiles — and
nothing else. Everything under `/var/lib` is left exactly as the previous
OS wrote it, including settings the flake can neither see nor express. That
state doesn't show up in `git diff`, doesn't get touched by `sys-rebuild`,
and can sit there for months quietly shaping how the machine behaves.
Worked example: user lingering. Run `loginctl show-user $USER -p Linger` —
on a Nomarchy machine the answer should be `no`, because the flake never
sets it. On our own QA box a leftover `Linger=yes` from a preNomarchy setup
kept the user's systemd instance alive across logouts, and it took six
months to trace a string of subtle session bugs back to that one marker.
Fix: `sudo loginctl disable-linger <user>`.
`nomarchy-doctor` now flags this specific case as a warning. More broadly:
after migrating, if the machine ever behaves in a way the flake can't
explain, suspect inherited state under `/var/lib` before you suspect the
flake.
---
## 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.
---
## State file rename (`theme-state.json` → `state.json`, #107)
The machine flake's git-tracked state file is **`state.json`** (appearance +
menu settings). Older checkouts may still have `theme-state.json`.
- **Eval:** `lib.mkFlake` and the reader accept either name (prefer
`state.json`).
- **Write:** `nomarchy-state-sync` (and the menu) always write `state.json`
and remove a leftover `theme-state.json` so you never have two sources.
- **CLI:** `nomarchy-state-sync` is the real name; `nomarchy-theme-sync`
remains a symlink for scripts and muscle memory. Drop the alias after the
next stable release notes call it out.
No action required on pull: the next theme apply or menu write migrates you.
To migrate by hand: `mv theme-state.json state.json && git add state.json`
(and `git rm theme-state.json` if it was tracked).
---
## 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) |

126
docs/OMARCHY.md Normal file
View File

@@ -0,0 +1,126 @@
# 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+CTRL+L` | Lock screen |
| `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-state-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
(`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

@@ -9,18 +9,73 @@ rule of thumb:
## 1. Appearance (gaps, colors, rounding, fonts, opacity) — use the CLI
Everything that defines the *look* flows from `theme-state.json`, the single
source of truth. Change it with `nomarchy-theme-sync`, which writes the JSON
and rebuilds — one generation, applied to Hyprland, Waybar, Ghostty, btop and
Everything that defines the *look* flows from `state.json`, the single
source of truth. Change it with `nomarchy-state-sync`, which writes the JSON
and rebuilds — one generation, applied to Hyprland, Waybar, Kitty, btop and
Stylix at once:
```sh
nomarchy-theme-sync set ui.gapsOut 16 # gaps, borders, rounding, opacity
nomarchy-theme-sync set ui.rounding 0
nomarchy-theme-sync set fonts.mono "FiraCode Nerd Font"
nomarchy-theme-sync apply gruvbox # whole palette
nomarchy-state-sync set ui.gapsOut 16 # gaps, borders, rounding, opacity
nomarchy-state-sync set ui.rounding 0
nomarchy-state-sync set fonts.mono "FiraCode Nerd Font"
nomarchy-state-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-state-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-state-sync set settings.autoTheme.day summer-day --no-switch
nomarchy-state-sync set settings.autoTheme.night summer-night --no-switch
nomarchy-state-sync set settings.autoTheme.sunset 20:00 --no-switch
nomarchy-state-sync set settings.autoTheme.enable true --no-switch
nomarchy-state-sync auto --force # one rebuild: installs the timer + applies now
```
State lives in `settings.autoTheme`:
| Field | Meaning |
|---|---|
| `enable` | on / off |
| `day` / `night` | theme slugs (see `nomarchy-state-sync list`) — the exclusive pairs are made for this: `boreal-dawn`/`boreal`, `executive-ivory`/`executive-slate`, `kiln-clay`/`kiln`, `summer-day`/`summer-night` |
| `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-state-sync auto --which`.
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`
regardless of the active theme, use `lib.mkForce` (see §4) — but then the CLI
@@ -39,17 +94,21 @@ in your `home.nix` wins — no `mkForce` needed:
input.follow_mouse = 0; # was 1
input.touchpad.natural_scroll = 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;
};
programs.ghostty.settings = {
window-padding-x = 4; # was 12
window-decoration = true;
programs.kitty.settings = {
window_padding_width = 4; # was 12
};
}
```
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
`bind`, `bindel`, `bindl`, `bindm` and `exec-once` are lists kept at normal
@@ -61,7 +120,7 @@ autostarts run *alongside* the defaults:
wayland.windowManager.hyprland.settings = {
bind = [
"$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" ];
};
@@ -138,8 +197,9 @@ value is theme-owned at normal priority — either change it via the CLI (§1) o
| You want to… | Do this |
|---|---|
| Change gaps / colors / rounding / fonts | `nomarchy-theme-sync set …` or `apply` |
| Change gaps / colors / rounding / fonts | `nomarchy-state-sync set …` or `apply` |
| 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) |
| Replace all keybinds | `bind = lib.mkForce [ … ]` |
| Hardcode an appearance value against the theme | `lib.mkForce` in `home.nix` |

42
docs/README.md Normal file
View File

@@ -0,0 +1,42 @@
# 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 |
| [REQUIREMENTS.md](REQUIREMENTS.md) | Users + agents | Minimum system requirements (GPU/OpenGL, RAM, disk, UEFI) |
| [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 § …`.

136
docs/RECOVERY.md Normal file
View File

@@ -0,0 +1,136 @@
# 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 Recovery Desktop
generation** lists the recent Home Manager generations — pick one and
it activates. System-level undo is **System Recovery System boot
generation** (boot menu) or **Files (BTRFS)** (snapper).
Or simply apply a theme you know is good: `nomarchy-state-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 — and every `nomarchy-pull`/`-rebuild`/`-home` first
sweeps pending hand edits into one, so history mirrors your generations:
`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.
- Password rejected on an external keyboard but definitely correct? The
greeter uses the system keyboard layout, not a remembered per-device one
— see the "Greeter keyboard layout" note in README.md § options.
- 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). 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`.
How long generations stick around (and how cleanup works) is §4 below.
## 4. How generations are kept (and cleaned up)
Every `nomarchy-rebuild` and `nomarchy-home` (and every theme switch) leaves
a **generation** — a full previous system or desktop you can roll back to
(§1 and §3, and **System Recovery**). Those take disk space until they
are removed.
Nomarchy prunes them **automatically once a week** with a policy designed
not to strand you without rollback:
| Rule | Meaning |
|------|---------|
| **Age** | Generations older than **14 days** are eligible for removal. |
| **Safety floor** | Always keep the **current** generation and at least **3 past** ones — even if those three are older than 14 days. |
| **Both halves** | The same rule applies independently to the **system** (boot menu) and to **Home Manager** (desktop) profiles. |
So a busy machine that rebuilds often can still free old junk after two
weeks, while a rarely rebuilt laptop never drops below four usable
system gens (current + three past) or four desktop gens.
**What you do not need to do:** run aggressive `nix-collect-garbage -d`
day to day — that can delete *all* old generations and wipe the floor.
The weekly timer already reclaims store paths after a careful prune.
**Manual control:**
```sh
sudo nomarchy-gen-prune --dry-run # list what would be deleted
sudo nomarchy-gen-prune # prune now (same rules as the timer)
systemctl status nomarchy-gen-prune.timer
```
After a system prune the boot menu is refreshed so removed generations
no longer appear as boot entries.
## 5. 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 Recovery Files (BTRFS) (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.
## 6. 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
§5. If you get this far with something Nomarchy shipped broken, please
file it.

160
docs/REQUIREMENTS.md Normal file
View File

@@ -0,0 +1,160 @@
# Minimum system requirements
What Nomarchy expects of a machine. This is a **living floor**, not a
marketing sheet — numbers come from measured installs and known software
floors (NixOS, the ISO, desktop GPU). When a real machine pushes a bound, update
this file in the same change.
Related: [HARDWARE.md](HARDWARE.md) (profiles/drivers),
[TESTING.md](TESTING.md) (ISO/VM), [README](../README.md) § install.
---
## Supported platform (hard)
| Requirement | Floor | Why |
|-------------|-------|-----|
| Architecture | **x86_64** | Only target the flake builds today |
| Firmware | **UEFI** | Installer uses systemd-boot; BIOS/legacy is LATER |
| Install path | Whole disk (disko) | No dual-boot / partial-disk path yet |
---
## Graphics (default desktop)
The sole terminal is **Kitty** (GPU-accelerated OpenGL, themed from
state.json). It runs on older integrated GPUs that reject Ghosttys OpenGL
**4.3** floor — notably **Intel HD 4000 / Ivy Bridge** (Acer Aspire M5-481T
reported OpenGL **4.2** and could not start Ghostty). That is why Nomarchy
standardized on Kitty only: one terminal to theme, one set of classed windows
(doctor / calendar), no dual-stack or GL probe at install.
| Generation (examples) | Notes |
|-----------------------|--------|
| Ivy Bridge / HD 4000 (~2012) | Supported with Kitty (verified path on Acer M5-481T) |
| Haswell+ Intel, modern AMD, discrete GPUs | Supported |
| SoftGL / virtio-gpu VMs | Hyprland needs GL for the session; Kitty is best-effort under softGL |
Other Wayland clients vary; Hyprland itself is usually fine on these GPUs.
A working GPU (or software GL for VMs) is still required for the desktop
session — only the *terminal* no longer demands OpenGL 4.3.
---
## CPU and memory
| Resource | Practical minimum | Comfortable |
|----------|-------------------|-------------|
| CPU | 64-bit x86_64, dual-core | Quad-core or better (rebuilds) |
| RAM | **8 GiB** | **16 GiB+** |
NixOS + Home Manager rebuilds and browser/Electron apps are memory-hungry.
Hibernation (default installer path) sizes a **swapfile ≈ RAM**, so low RAM
also means less disk eaten by swap — but also less headroom when building.
---
## Storage (the important one)
Nomarchy is a full NixOS desktop with:
1. **The Nix store** — every generation keeps package closures; updates add
new paths until you GC. Many packages appear once per generation when
inputs move.
2. **Home Manager generations** — desktop switches and `nomarchy-home` leave
profiles behind until cleaned.
3. **BTRFS + snapper**`@snapshots` timeline (hourly/daily) and
pre-rebuild snaps. Excellent for recovery; **they retain data** and grow
with how full the live filesystem is.
4. **Optional hibernation swapfile** — default size = **RAM** (e.g. 16 GiB
RAM → 16 GiB swapfile on disk).
Closure size is **not** the same as disk use after hardlink optimisation
and is **not** the ISO size (see ROADMAP § chromium / #121 measurements).
Still, plan for **growth**, not for a single install footprint.
### Measured / known artifacts (order of magnitude)
| Artifact | Size (approx.) | Source |
|----------|----------------|--------|
| Offline live ISO | **~8.1 GiB** | Built image, 2026-07 (#103 era) |
| Template HM closure (nominal) | **~9.4 GiB** | `nix path-info` style figures in ROADMAP |
| Desktop store weight (uncompressed, pre-dedupe order) | **tens of GiB** | ISO pin / offline pin analysis (#120) |
A **fresh** install on empty disk is smaller than a machine that has been
updated and snapshotted for months.
### Recommended free disk (whole-disk install)
These are **planning floors** for the target SSD/HDD the installer will wipe
and use entirely — not “free space beside Windows.”
| Use | Capacity | Notes |
|-----|----------|--------|
| Absolute minimum to finish install + first boot | **≥ 40 GiB** | Tight; little room for GC delay or snapshots |
| Realistic daily driver | **≥ 64 GiB** | Short GC/snap retention still required |
| Comfortable (updates + snapper + apps) | **≥ 128 GiB** | Default recommendation |
| Power user / many generations / big apps | **256 GiB+** | Dev toolchains, Steam, local datasets |
**Plus swap:** if you keep hibernation (default), add **~RAM** on top of the
table (installer creates `/swap/swapfile` sized to RAM unless you set 0).
Example: 16 GiB RAM laptop → plan **≥ 128 GiB disk**, of which ~16 GiB is
swapfile, leaving room for store + snapshots.
### Why Nix “duplicates everything”
- Each **system** and **home** generation points at a closure of store
paths. Unchanged paths are shared (same `/nix/store/…` hash); changed
inputs pull **new** paths. After several updates you hold old + new until
`nix-collect-garbage` / generation deletion.
- **Snapper** snapshots the BTRFS subvolumes. A snapshot is cheap until you
rewrite a lot of data; then it retains the old blocks. Timeline + rebuild
snaps mean “I deleted that 2 GB download” may not free space until snaps
expire.
- The live **ISO** is large because it pins an offline install closure; that
is a download/USB cost, not permanent free space on the installed machine
after install (the installed system has its own store).
**Hygiene (automatic on Nomarchy):** a weekly `nomarchy-gen-prune` removes
system and Home Manager generations that are **older than 14 days** and
**beyond the three most recent past gens** (current + ≥3 past always kept),
then reclaims the store. Details and manual commands:
[RECOVERY.md §4](RECOVERY.md#4-how-generations-are-kept-and-cleaned-up).
Also tune snapper retention for BTRFS file history (System Recovery
Files). This requirements page only states why the disk fills up.
---
## What we explicitly do **not** require
| Non-requirement | Note |
|-----------------|------|
| NVIDIA as first-class | Deferred past v1 (PROPOSED); community/docs only |
| Secure Boot enrolled out of the box | Not the current install path |
| Touch / tablet as primary input | Untested as a bar |
| &lt; 8 GiB RAM as a supported target | May boot; rebuilds and browsers will thrash |
---
## Machines that defined these bounds
| Machine | Role |
|---------|------|
| **Acer Aspire M5-481T** (~2012, Intel HD 4000) | Old-HW QA; drove Kitty-only terminal (#95) |
| **Dell XPS 9350** (Skylake) | Install bake (#123) |
| AMD dev box / Latitude / T14s | Ongoing V3 (HARDWARE-QUEUE) |
When you install on something older or smaller than the table, file what
broke in `agent/BACKLOG.md` and tighten this page.
---
## Related work
| Item | Topic |
|------|--------|
| BACKLOG **#95** | Kitty as sole terminal (shipped) |
| BACKLOG **#120** | Lighter netinstall ISO (download size ≠ installed size) |
| BACKLOG **#123** | Installer HM pre-activate (first-boot polish) |
| BACKLOG **#124** | Flake pin vs main/v1 lag after install |

File diff suppressed because it is too large Load Diff

View File

@@ -13,7 +13,8 @@ than claiming success. "All Nix files parse" is not "the bar renders."
```sh
nix flake check --no-build # full module-system evaluation, no builds
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
@@ -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),
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
```sh
@@ -35,7 +47,7 @@ nix build .#nixosConfigurations.nomarchy-live.config.system.build.isoImage
The script prefers UEFI (OVMF) with a legacy-BIOS fallback, uses KVM when
`/dev/kvm` is readable, and boots with `virtio-vga-gl` + `gl=on`
**Hyprland and Ghostty need real OpenGL in the guest**; without it the
**Hyprland (and the desktop session) need real OpenGL in the guest**; without it the
session may not start.
### What the live environment gives you
@@ -44,8 +56,11 @@ session may not start.
Hyprland via `initial_session`; logging out lands on tuigreet.
- The flake is seeded writable at `~/.nomarchy` (from the read-only
`/etc/nomarchy` copy) — `$NOMARCHY_PATH` already points there.
- Locked flake inputs are pinned into the ISO store, so
`home-manager switch` works **without a network**.
- Locked flake inputs are pinned into the ISO store, so a
`home-manager switch` for the **already-active / default theme** works
**without a network**. Switching to an arbitrary other preset can still
need downloads (that preset's HM generation is not in the pin) — the
tool should fail with an offline-oriented message (#113).
## 3. Verification checklist
@@ -53,14 +68,14 @@ Work through these in order; each one exercises a different layer.
| # | 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 |
| 3 | `SUPER+Return` opens Ghostty with Tokyo Night colors | terminal default, ANSI palette |
| 3 | `SUPER+Return` opens Kitty with the default theme colors (Boreal) | terminal default, ANSI palette |
| 4 | `btop` in the terminal is themed | per-theme asset baking |
| 5 | `nomarchy-theme-sync list` prints 21 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 |
| 7 | `SUPER+SHIFT+T` cycles wallpapers instantly (try `tokyo-night`: 4 of them) | 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 |
| 5 | `nomarchy-state-sync list` prints 24 presets | package, baked themes dir |
| 6 | `nomarchy-state-sync apply boreal` (or re-apply the **current** theme) offline → switch succeeds. Applying a *different* preset may need a network — the ISO pins the default theme's generation, not every preset (#113) | state write + offline rebuild contract |
| 7 | `SUPER+SHIFT+T` cycles wallpapers instantly (try `tokyo-night` for 4, or `boreal` for its set) | the runtime wallpaper path |
| 8 | `nomarchy-state-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 |
| 10 | `home-manager generations` lists one generation per theme change; activating an older one rolls the theme back | atomicity / rollback story |
@@ -93,6 +108,27 @@ unattended env it uses is in the script, and the same flow works
interactively from the live terminal. If the desktop comes up unthemed,
read `/var/log/nomarchy-hm-preactivate.log` on the installed system.
`tools/plymouth-preview.sh` renders the **boot splash** — including the LUKS
password dialog — into X11 windows in about ten seconds: no reboot, no root,
and no DRM. It runs plymouthd inside `unshare -rm` (a user + mount namespace),
binds a writable dir over `/run/plymouth` (which is both the daemon's socket
dir *and* its compiled-in plugin path), masks `/dev/dri` so a preview can never
mode-set a real output, and sets `PLY_CREATE_FAKE_MULTI_HEAD_SETUP` for plymouth's
built-in fake dual head. Pass it a theme store path to iterate on the script
without a rebuild. Use it before touching `modules/nixos/plymouth/`: that script
draws the passphrase box, a failure there is silent (the plugin logs
`starting boot animation` and no error), and "type your LUKS password blind" is
a bad way to find out. Its limit: the fake heads exist from the start, so it
cannot reproduce a head arriving *mid-splash* (#137).
Two full-desktop VM harnesses (softGL Hyprland, too heavy for `checks.*`)
live next to the scripts: `tools/theme-shot.nix` (themed-desktop
screenshots) and `tools/monitor-fallback.nix` (KVM display transitions:
atomic dock handoff, internal-first undock, and low-level lid-inhibitor
lifecycle). The latter restores the DRM output before deleting QEMU's final
synthetic headless output; physical cable-removal timing is a V3 check because
Hyprland's headless backend otherwise aborts in a VM-only zero-output state.
## 5. VM-specific gotchas
- **No KVM** (e.g. nested without acceleration): everything works but
@@ -102,14 +138,37 @@ read `/var/log/nomarchy-hm-preactivate.log` on the installed system.
script's qemu flags; on real hardware check `journalctl -b -u greetd`.
- **Tiny resolution**: the live config forces `monitor = ,highres,auto,1`;
if QEMU still picks 1024×768, resize the window — Hyprland follows.
- **Display menu + QEMU**: System Display mode list is sorted by **pixel
area** (highest first). After a pick, the toast shows hyprctls reported
size. In a **fixed-size** QEMU/viewer window, a higher guest mode still
*looks* smaller (more pixels in the same window) — that is viewer scaling,
not an inverted apply. Trust hyprctl / the toast numbers.
- **First theme switch is the slowest**: it evaluates the flake on a RAM
disk. Subsequent switches reuse the eval cache.
- **Menu geometry does not survive the guest**: the rofi picker draws with
icons at roughly a quarter of `ui.iconSize` and rows short enough that a
6-row root clips its last entry behind a scrollbar (`lines = 8` should
fit it) — the guest's font/icon environment, not a regression. Menu
screenshots are evidence for **which rows appear**, never for spacing,
icon size or label truncation.
**And do not "just measure" instead** — that is how #131 stayed wrong for
weeks. Measuring needs a width and a font, and both are per-theme: text
menus render through `themes/<slug>/rofi.rasi`, where boreal and
neon-glass pinned a fixed `620px` while the rest used `40%`, in fonts from
Inter 11 to JetBrainsMono 14. The item measured one combination that no
theme actually shipped, concluded "only truncates below 1920", and the
truncation was live on a 2560 panel. Since #131 the width is
`calc( 84ch min 65% )` — font-relative by design, so the arithmetic is
even less predictable from a spec sheet. **Judge it by rendering**: on
hardware, or with `rofi -dmenu -theme <the built rasi>` plus
`hyprctl layers` for the real width. `checks.rofi-text-width` guards the
invariant that makes it fit; nothing guards how it looks.
## 6. When something fails
- Session/login problems: `journalctl -b -u greetd`, then `journalctl
--user -b`.
- Theme switch failures: run `nomarchy-theme-sync apply <x>` from a
- Theme switch failures: run `nomarchy-state-sync apply <x>` from a
terminal — the home-manager output streams there. The state file is
written *before* the rebuild, so after fixing you can just re-run
`home-manager switch --flake ~/.nomarchy`.

165
docs/VISION.md Normal file
View File

@@ -0,0 +1,165 @@
# 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.
Most of this theme **shipped** mid-2026; residual is V3 hardware QA +
optional polish.
| Idea | Intent | Status |
|------|--------|--------|
| **System Firmware** ✓ | fwupd menu: refresh → list → confirm → update (never auto-flash) | ✓ shipped #43 (+ V2 #43 render) |
| **Fingerprint menu** ✓ | Enroll/list when fprintd present; optional PAM + rebuild note | ✓ shipped #55; V3 enroll HARDWARE-QUEUE |
| **Doctor hardware section** ✓ | NM, sink, GPU smoke, fprintd, fwupd, charge, battery health, hibernate | ✓ shipped #44 + #77 + #80 + #83 |
| **Machine health entry** ✓ | One System row → doctor | ✓ System Doctor + Waybar tripwire |
| **Human rebuild errors** ✓ | Failed switch → last log lines + `nomarchy-doctor` | ✓ shipped #56 |
| **HM pre-activate fail flag** ✓ | Recovery one-liner if theme bake failed | ✓ shipped #83 |
Reference: [HARDWARE.md](HARDWARE.md) §4§5, §11 (doctor).
---
## Theme B — First week experience
| Idea | Intent | Notes |
|------|--------|-------|
| **Silent first-week card** ✓ | One dismissible “youre set” (menu, themes, keys, network) — not a wizard | ✓ shipped #81`settings.firstBootShown` + notify; live ISO keeps its own toast |
| **Boreal as default** ✓ | Identity on first boot | ✓ seed `state.json` + install path use Boreal (2026-07-09) |
| **Generation readability** ✓ | “What changed last rebuild” in plain language | ✓ shipped #82 — nvd toast + System What changed? |
| **Post-install hints** ✓ | One-shot MOTD/notify for fwupd / fprintd when relevant | ✓ shipped 2026-07-17 — self-gated “Hardware tips” toast after the #81 card (`settings.hardwareHintsShown`), fwupd/fprintd lines when the tooling is on PATH |
---
## Theme C — Laptop daily driver
| Idea | Intent | Notes |
|------|--------|-------|
| **Dock life** ✓ | Lid closed + external only, wake, default audio sink on undock | ✓ #86 clamshell logind; ✓ #87 WirePlumber HDMI/USB priority (V3 hotplug QA) |
| **Hibernate/sleep confidence** ✓ | Doctor: resume device, swap size, clean suspend journal | ✓ shipped #77 (+ #76 agent V0V2; V3 power-cycle HARDWARE-QUEUE) |
| **Battery health readout** ✓ | Cycles + design capacity % in doctor where sysfs allows | ✓ shipped #80 (report-only; charge limit is a separate row) |
| **Charge-limit instant apply** ✓ | Live sysfs write without rebuild | ✓ shipped menu live-write path 2026-07-10; residual V3 Dell Adaptive / non-BAT* in HARDWARE-QUEUE |
---
## Theme D — Beauty without theme sprawl
| Idea | Intent | Notes |
|------|--------|-------|
| **Fewer themes, sharper** ✓ | Finish or quarantine neon-glass; invest in whole-swap quality | ✓ #88 finish — glass rofi (proper @colors), btop, preview; bar stays generated (no broken waybar.css) |
| **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 | ✓ shipped #85 — HARDWARE.md §9 table + DMI PR invite |
| **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)
**Still open** (as of 2026-07-17). Shipped items above are *not* open work.
1. `VISION § D` / LATER — wallpapers artifact split (decided deferred;
promote only if theme-switch latency still hurts after measurement)
2. `VISION § H` — HARDWARE-QUEUE burn-down (human-only V3; agents only
append exact steps)
3. `VISION § v1.0` — install P0 re-verify before any `v1` fast-forward
(swap=0 / unattended LUKS contracts; human ships the pointer)
Do **not** re-open Theme A firmware/fingerprint/doctor/rebuild rows —
those shipped. Dock/hibernate agent work is done; residual is V3 hardware.

59
flake.lock generated
View File

@@ -145,11 +145,11 @@
]
},
"locked": {
"lastModified": 1781063120,
"narHash": "sha256-1UIF/mDJluwJQjmmcZ2j1L2+mjYsefe82QCLj0TYSOg=",
"lastModified": 1783740085,
"narHash": "sha256-qajyHfZY29G2oEQk+uHxmsJcRoBUBXP9maTpFlwP/dI=",
"owner": "nix-community",
"repo": "home-manager",
"rev": "baa46aeb6d02e0ba13de67cd35e3d57aedfacf01",
"rev": "3cd22efe6471dc7365c822bd9ad73a21e55f38fb",
"type": "github"
},
"original": {
@@ -166,11 +166,11 @@
]
},
"locked": {
"lastModified": 1781020964,
"narHash": "sha256-fS7xTi2j2iso5Hj7RNZLv/acDlCT+fgMVkVk40A7Uco=",
"lastModified": 1783792734,
"narHash": "sha256-50rvY9GdFvpYDcMLcD/4cWSi0hVxArT5wsGlVsHy8eY=",
"owner": "NixOS",
"repo": "nixos-hardware",
"rev": "32c2cd9e46286c4eced3dc6b613c659126bf3cca",
"rev": "8efb4337e857949f4cfac86d12ef1066f417f31f",
"type": "github"
},
"original": {
@@ -182,11 +182,11 @@
},
"nixpkgs": {
"locked": {
"lastModified": 1780902259,
"narHash": "sha256-q8yYEC5f1mFlQO9RGna4LTc9QrcvWunX6FYp83munkQ=",
"lastModified": 1783703440,
"narHash": "sha256-O3/YajjWo001VUIgD8BwaRdSNLUFe7nZ1qV5TwhRBcw=",
"owner": "NixOS",
"repo": "nixpkgs",
"rev": "bd0ff2d3eac24699c3664d5966b9ef36f388e2ca",
"rev": "8f0500b9660505dc3cb647775fe9a978a74b5283",
"type": "github"
},
"original": {
@@ -196,6 +196,39 @@
"type": "github"
}
},
"nixpkgs-unstable": {
"locked": {
"lastModified": 1784120854,
"narHash": "sha256-KesHgItiZPgGX740axSiQLcIQ8D24MDqNpkKYWIek8k=",
"owner": "NixOS",
"repo": "nixpkgs",
"rev": "753cc8a3a87467296ddd1fa93f0cc3e81120ee46",
"type": "github"
},
"original": {
"owner": "NixOS",
"ref": "nixos-unstable",
"repo": "nixpkgs",
"type": "github"
}
},
"nomarchy-wallpapers": {
"flake": false,
"locked": {
"lastModified": 1784377824,
"narHash": "sha256-XPzT9w+xr0bEn3W2R+pYo4z10hKT1E10QB5TAu/UrIM=",
"ref": "main",
"rev": "d4f5fbfff2228c7725be5a1adbc9b325621d3c94",
"revCount": 4,
"type": "git",
"url": "https://git.bemagri.xyz/bernardo/Nomarchy-Wallpapers.git"
},
"original": {
"ref": "main",
"type": "git",
"url": "https://git.bemagri.xyz/bernardo/Nomarchy-Wallpapers.git"
}
},
"nur": {
"inputs": {
"flake-parts": [
@@ -226,6 +259,8 @@
"home-manager": "home-manager",
"nixos-hardware": "nixos-hardware",
"nixpkgs": "nixpkgs",
"nixpkgs-unstable": "nixpkgs-unstable",
"nomarchy-wallpapers": "nomarchy-wallpapers",
"stylix": "stylix"
}
},
@@ -249,11 +284,11 @@
"tinted-zed": "tinted-zed"
},
"locked": {
"lastModified": 1780702455,
"narHash": "sha256-+srjPGNy67nKytYwdlepycL51IG6S34sS4MKRZXK8G0=",
"lastModified": 1783359251,
"narHash": "sha256-HUiCnEVlJ4n+qJlZojiz/zv+P0cqM5zsg1dxpz2J7Mg=",
"owner": "nix-community",
"repo": "stylix",
"rev": "54fa19702f4f2c7f6a981a92850678933588af9a",
"rev": "e602ad042f00409f33c8ad2829cd8d59ba345c7e",
"type": "github"
},
"original": {

2293
flake.nix

File diff suppressed because it is too large Load Diff

View File

@@ -1,36 +1,102 @@
# 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
# exists to test the distro end-to-end on real hardware.
{ lib, pkgs, username, nomarchySrc, ... }:
# without touching the disk. The live session bundles nomarchy-install
# (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, config, username, nomarchySrc, ... }:
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
# live theme-state.json (tokyo-night by default). Shows on the isolinux
# (BIOS) boot menu; UEFI/GRUB still uses the stock theme (see roadmap).
state = builtins.fromJSON (builtins.readFile ../theme-state.json);
# live state.json (Boreal by default). The same composed image
# backs both the isolinux (BIOS) splash and the GRUB (UEFI) theme below, so
# the two boot paths match.
state = builtins.fromJSON (builtins.readFile ../state.json);
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"
{ nativeBuildInputs = [ pkgs.imagemagick pkgs.librsvg ]; } ''
rsvg-convert -h 320 ${../modules/nixos/branding/logo.svg} > logo.png
magick logo.png -fill "#${isoColor "accent" "7aa2f7"}" -colorize 100 logo-c.png
magick -size 1920x1080 xc:"#${isoColor "base" "1a1b26"}" \
magick logo.png -fill "#${accent}" -colorize 100 logo-c.png
magick -size 1920x1080 xc:"#${base}" \
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
{
networking.hostName = "nomarchy-live";
isoImage.volumeID = lib.mkForce "NOMARCHY_LIVE";
isoImage.edition = lib.mkForce "live";
isoImage.splashImage = isoSplash;
isoImage.splashImage = isoSplash; # isolinux / BIOS
isoImage.grubTheme = nomarchyGrubTheme; # GRUB / UEFI
# Filename: nixpkgs defaults image.baseName to "nixos-<edition>-…". Force
# nomarchy so result/iso/ is nomarchy-live-….iso, not nixos-live-….iso (#125).
image.baseName = lib.mkForce (
"nomarchy-${config.isoImage.edition}-${config.system.nixos.label}-${pkgs.stdenv.hostPlatform.system}"
);
# The minimal-CD profile slims the image for a CONSOLE installer; this
# ISO is the desktop, so re-enable what it strips. Above all
# fontconfig (upstream forces it off at mkOverride 500): without it
# no configured family resolves — Waybar icons render as tofu and
# Ghostty silently falls back to the wrong font (seen on the
# Latitude 5410). Normal priority (100) beats the override.
# Kitty silently falls back to the wrong font (seen on the
# Latitude 5410 with Ghostty; same fontconfig footgun). Normal priority
# (100) beats the override.
fonts.fontconfig.enable = true;
# No boot splash on the install medium: the installer ISO boots its
@@ -88,6 +154,16 @@ in
if [ ! -e "$home/.nomarchy" ]; then
cp -r ${nomarchySrc} "$home/.nomarchy"
chmod -R u+w "$home/.nomarchy"
# Committed git repo, exactly like nomarchy-install produces
# otherwise nomarchy-doctor (and its Waybar badge) false-alarms
# "state.json is NOT git-tracked" in the live session.
(
cd "$home/.nomarchy"
${pkgs.git}/bin/git init -q
${pkgs.git}/bin/git add -A
${pkgs.git}/bin/git -c user.name="Nomarchy Live" -c user.email="live@nomarchy" \
commit -qm "Nomarchy live session"
)
chown -R ${username}:users "$home/.nomarchy"
fi
'';
@@ -98,8 +174,8 @@ in
Welcome to the Nomarchy live environment.
The graphical session autologins as '${username}' (no password).
Theme switching: nomarchy-theme-sync apply <name> (or SUPER+T)
Wallpapers: nomarchy-theme-sync bg next (or SUPER+SHIFT+T)
Theme switching: nomarchy-state-sync apply <name> (or SUPER+T)
Wallpapers: nomarchy-state-sync bg next (or SUPER+SHIFT+T)
Install to disk: nomarchy-install
The flake lives at ~/.nomarchy.
'';
@@ -112,6 +188,20 @@ in
# regression). Installed systems keep idle management.
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 = {
# QEMU (and some panels) report a tiny "preferred" mode; ask for
# the highest resolution instead.
@@ -119,7 +209,7 @@ in
# Welcome toast once the session is up (concatenated onto the
# base exec-once list).
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\"'"
];
};
};

39
lib.nix
View File

@@ -1,11 +1,20 @@
# Downstream sugar: one call wires a whole machine flake.
# Users own ONLY system.nix, home.nix and theme-state.json; their flake.nix
# Users own ONLY system.nix, home.nix and state.json; their flake.nix
# is generated once (by `nix flake init -t` or the future installer) and
# never hand-edited. The raw exports (nixosModules/homeModules/overlays)
# in flake.nix remain the escape hatch for power users.
{ nixpkgs, home-manager, nixos-hardware, nomarchy }:
let
inherit (nixpkgs) lib;
# Shared pure reader (modules/state-read.nix) — re-exported so
# power users composing without mkFlake can call it too.
readThemeState = import ./modules/state-read.nix { inherit lib; };
in
{
inherit readThemeState;
mkFlake =
{ src # the downstream flake directory (./.)
, username # login name; also the homeConfigurations attr
@@ -13,8 +22,6 @@
, system ? "x86_64-linux"
}:
let
inherit (nixpkgs) lib;
# One profile or several (the installer's autodetection emits a few
# common-* modules alongside the model-specific one).
profileNames =
@@ -51,8 +58,22 @@
# and the standalone HM desktop see the same package set.
config.allowUnfree = true;
};
# Early fail-closed gate: missing/empty/non-object 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).
# #107: prefer state.json; accept legacy theme-state.json until
# the user's next menu write migrates them.
statePath =
if builtins.pathExists (src + "/state.json") then src + "/state.json"
else if builtins.pathExists (src + "/theme-state.json") then src + "/theme-state.json"
else src + "/state.json";
_themeState = readThemeState statePath;
in
{
builtins.seq _themeState {
# System layer — rebuilt rarely:
# sudo nixos-rebuild switch --flake .#default
nixosConfigurations.default = nixpkgs.lib.nixosSystem {
@@ -62,12 +83,12 @@
[
nomarchy.nixosModules.nomarchy
# The standalone HM CLI ships with the system so theme switching
# (`nomarchy-theme-sync apply` → `home-manager switch`) works
# (`nomarchy-state-sync apply` → `home-manager switch`) works
# out of the box — same pinned input as the desktop modules.
{ environment.systemPackages = [ home-manager.packages.${system}.home-manager ]; }
# System-side theme consumers (Plymouth splash background)
# read the same JSON the desktop does.
{ nomarchy.system.stateFile = src + "/theme-state.json"; }
{ nomarchy.system.stateFile = statePath; }
]
++ hardwareModules
++ [
@@ -78,16 +99,16 @@
# Desktop layer — every theme change, no sudo:
# home-manager switch --flake .#<username>
# (`nomarchy-theme-sync apply` runs this for you.)
# (`nomarchy-state-sync apply` runs this for you.)
homeConfigurations.${username} = home-manager.lib.homeManagerConfiguration {
inherit pkgs;
modules = [
nomarchy.homeModules.nomarchy
(src + "/home.nix")
{
# Written by nomarchy-theme-sync; reading it is pure — the
# Written by nomarchy-state-sync; reading it is pure — the
# file is part of the downstream flake's source.
nomarchy.stateFile = src + "/theme-state.json";
nomarchy.stateFile = statePath;
home = {
inherit username;
homeDirectory = "/home/${username}";

10
modules/home/airplane.nix Normal file
View File

@@ -0,0 +1,10 @@
# Airplane mode — runtime kill-switch for Wi-Fi + Bluetooth together.
# Implementation: pkgs/nomarchy-airplane. Session state under
# $XDG_RUNTIME_DIR; Waybar plane glyph self-hides when off.
{ config, lib, pkgs, ... }:
{
config = {
home.packages = [ pkgs.nomarchy-airplane ];
};
}

View File

@@ -0,0 +1,36 @@
# Auto time-of-day theme switch (BACKLOG #79, VISION § D). A user timer
# periodically runs `nomarchy-state-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-state-sync}/bin/nomarchy-state-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,16 +1,16 @@
# Nomarchy — Home Manager entry point.
# Consume this via homeModules.nomarchy (flake.nix), which also pulls in
# the stylix home module that stylix.nix configures.
{ config, pkgs, ... }:
{ config, lib, pkgs, ... }:
{
imports = [
./options.nix # the nomarchy.* option surface
./theme.nix # ingests theme-state.json, owns the live-sync hooks
./theme.nix # ingests state.json, owns the live-sync hooks
./stylix.nix # GTK/Qt/cursor/fonts from the same JSON
./hyprland.nix
./waybar.nix
./ghostty.nix
./kitty.nix # sole terminal (themed); doctor/calendar/--class load-bearing
./btop.nix
./rofi.nix # launcher theming + the nomarchy-menu dispatcher
./swaync.nix # notification daemon, themed from the same JSON
@@ -18,36 +18,107 @@
./yazi.nix # flagship TUI file manager, themed + plugins
./osd.nix # swayosd volume/brightness OSD, themed
./nightlight.nix # scheduled blue-light filter (hyprsunset), opt-in
./airplane.nix # runtime Wi-Fi+BT airplane mode + Waybar glyph (#104)
./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
./keys.nix # gpg-agent (fronts SSH) + pinentry-qt
./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
./dock-audio.nix # default sink follows dock/monitor audio on hotplug (#87)
./first-boot.nix # one-shot "you're set" toast on first session (#81)
./satty.nix # satty screenshot annotation tool, themed
];
# Clipboard history (wl-paste watcher); browsed via the SUPER+CTRL+V
# menu module.
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.
# Tray icon is built into EasyEffects 8 (Qt StatusNotifierItem); the
# daemon must start after a tray HOST is live or the icon is missing
# for the whole session (wwmm/easyeffects#4636). After=waybar.service
# was inert — Nomarchy's waybar runs from the nomarchy-waybar
# supervisor (Hyprland exec-once), not a systemd unit, so the race was
# a boot-order coin flip (lost on TuringMachine 2026-07-11). Gate on
# the watcher's IsStatusNotifierHostRegistered instead; the `-` prefix
# lets EasyEffects still start after the timeout on tray-less setups —
# audio processing must not hinge on an icon.
services.easyeffects.enable = true;
systemd.user.services.easyeffects = {
Unit = {
After = [ "graphical-session.target" "tray.target" ];
Wants = [ "tray.target" ];
};
Service.ExecStartPre = "-${pkgs.writeShellScript "wait-for-tray-host" ''
timeout 30 ${pkgs.bash}/bin/sh -c '
until ${pkgs.systemd}/bin/busctl --user get-property \
org.kde.StatusNotifierWatcher /StatusNotifierWatcher \
org.kde.StatusNotifierWatcher IsStatusNotifierHostRegistered \
2>/dev/null | ${pkgs.gnugrep}/bin/grep -q true; do
sleep 0.5
done'
''}";
};
# Wifi from the bar: nm-applet lives in waybar's tray (SNI flag via
# preferStatusNotifierItems — without it there is no tray icon).
services.network-manager-applet.enable = 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;
# Pinned: HM 26.05 flips the default to false (and warns on every eval
# for older stateVersions). Keep exporting XDG_*_DIR into the session —
# scripts and non-glib apps read the vars, and pinning gives every
# downstream the same behavior regardless of its stateVersion.
setSessionVariables = lib.mkDefault true;
};
home.stateVersion = lib.mkDefault "26.05";
home.packages = with pkgs; [
awww # wallpaper daemon with animated transitions (the swww fork)
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 = {
TERMINAL = config.nomarchy.terminal;
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
# disk. nomarchy-theme-sync writes its state here; rebuilds read from
# Where the Nomarchy flake (and therefore state.json) lives on
# disk. nomarchy-state-sync writes its state here; rebuilds read from
# here. Clone/symlink your flake to this path.
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;
}

View File

@@ -0,0 +1,103 @@
# Shared display recovery helpers (#127). Used by hyprland.nix (PATH +
# undock dump) and idle.nix (hypridle on-resume / after_sleep absolute paths).
#
# dump — evidence to ~/nomarchy-display-dump-*.txt without SSH
# wake — dpms on + zero-output undock rescue + dump if still dark
# Pass displayTransition so wake pins the same binary hyprland ships.
# Call once with only pkgs to get dump (transition not ready yet), then
# again with displayTransition for wake.
{ pkgs, displayTransition ? null }:
let
dump = pkgs.writeShellScriptBin "nomarchy-display-dump" ''
set -u
reason="''${1:-manual}"
stamp=$(date +%Y%m%d-%H%M%S)
out="''${HOME:-/tmp}/nomarchy-display-dump-''${stamp}.txt"
{
echo "=== nomarchy-display-dump reason=$reason ==="
echo "date: $(date -Is 2>/dev/null || date)"
echo "user: $(id -un 2>/dev/null || echo ?)"
echo "HYPRLAND_INSTANCE_SIGNATURE=''${HYPRLAND_INSTANCE_SIGNATURE:-}"
echo
echo "=== hyprctl monitors (enabled) ==="
hyprctl monitors -j 2>&1 || true
echo
echo "=== hyprctl monitors all ==="
hyprctl monitors all -j 2>&1 || true
echo
echo "=== hyprctl workspaces ==="
hyprctl workspaces -j 2>&1 || true
echo
echo "=== hyprctl devices (keyboards) ==="
hyprctl devices -j 2>/dev/null | ${pkgs.jq}/bin/jq '.keyboards // .' 2>&1 || true
echo
echo "=== systemd-inhibit --list ==="
${pkgs.systemd}/bin/systemd-inhibit --list 2>&1 || true
echo
echo "=== DRM connector status ==="
for s in /sys/class/drm/*/status; do
[ -r "$s" ] || continue
echo "$s: $(cat "$s" 2>/dev/null)"
done
echo
echo "=== journal user display/idle (last 100) ==="
journalctl --user -t nomarchy-display-watch -t nomarchy-display-transition \
-t nomarchy-display-wake -t hypridle -t hyprlock -n 100 --no-pager 2>&1 || true
echo
echo "=== journal -b suspend/sleep/dpms (last 80 matching) ==="
journalctl -b --no-pager 2>/dev/null \
| ${pkgs.gnugrep}/bin/grep -iE 'Suspending|PM: suspend|PM: hibernate|dpms|sleep\.target|systemd-sleep' \
| ${pkgs.coreutils}/bin/tail -n 80 || true
} >"$out" 2>&1
${pkgs.util-linux}/bin/logger -t nomarchy-display-dump -- "reason=$reason wrote $out"
command -v notify-send >/dev/null 2>&1 \
&& notify-send "Display dump" "$out" 2>/dev/null || true
printf '%s\n' "$out"
'';
wake =
if displayTransition == null then null
else pkgs.writeShellScriptBin "nomarchy-display-wake" ''
set -u
LOGGER=${pkgs.util-linux}/bin/logger
log() { "$LOGGER" -t nomarchy-display-wake -- "$*"; }
TRANSITION=${displayTransition}/bin/nomarchy-display-transition
DUMP=${dump}/bin/nomarchy-display-dump
hyprctl dispatch dpms on >/dev/null 2>&1 || true
enabled=$(hyprctl monitors -j 2>/dev/null | ${pkgs.jq}/bin/jq 'length' 2>/dev/null || echo 0)
case "$enabled" in *[!0-9]*|"") enabled=0 ;; esac
if [ "$enabled" -eq 0 ]; then
log "zero-enabled-outputs: attempting internal enable"
internal=$(hyprctl monitors all -j 2>/dev/null \
| ${pkgs.jq}/bin/jq -r '.[] | select(.name | test("^(eDP|LVDS|DSI)")) | .name' 2>/dev/null \
| ${pkgs.coreutils}/bin/head -n1)
if [ -n "$internal" ]; then
if "$TRANSITION" undock "$internal" 2>/dev/null; then
log "rescued via undock internal=$internal"
elif "$TRANSITION" enable "$internal" 2>/dev/null; then
log "rescued via enable internal=$internal"
else
log "rescue failed internal=$internal"
fi
else
log "no internal output in monitors all"
fi
hyprctl dispatch dpms on >/dev/null 2>&1 || true
enabled=$(hyprctl monitors -j 2>/dev/null | ${pkgs.jq}/bin/jq 'length' 2>/dev/null || echo 0)
case "$enabled" in *[!0-9]*|"") enabled=0 ;; esac
if [ "$enabled" -eq 0 ]; then
log "still zero-enabled: dumping"
"$DUMP" auto-zero-after-wake >/dev/null 2>&1 || true
fi
fi
first=$(hyprctl monitors -j 2>/dev/null \
| ${pkgs.jq}/bin/jq -r '.[0].name // empty' 2>/dev/null || true)
[ -n "$first" ] && hyprctl dispatch focusmonitor "$first" >/dev/null 2>&1 || true
log "done enabled=$enabled first=''${first:-none}"
'';
in
{
displayDumpTool = dump;
displayWakeTool = wake;
}

261
modules/home/dock-audio.nix Normal file
View File

@@ -0,0 +1,261 @@
# Automatic audio-output follow for docks / external monitors (#100).
# WirePlumber's stored default outranks priority.session, so the Hyprland
# monitor watcher calls `reprobe monitoradded` for the unambiguous physical-
# plug event and we explicitly select an available dock-class sink. Ordinary
# sink changes never select anything, so a manual speaker choice sticks until
# a fresh monitor plug.
#
# `reprobe` escalates, cheapest first, and only as far as it must (#138):
# select → repair a parked card's profile → restart the graph. The restart
# is last because it is not free — it drops every client's connection to the
# server, and clients that never reconnect (Chromium's audio service, hence
# "Meet says I have no microphone") stay broken until the app itself is
# restarted. It ran first here for one session-day because the old working
# flake used it as a recovery; nothing had shown it was *needed*. Keep it as
# the floor for the codec that only publishes its route after a re-probe, and
# leave the common path — the sink is simply there to be chosen — untouched.
{ config, lib, pkgs, ... }:
let
cfg = config.nomarchy;
rules = import ../nixos/dock-audio-rules.nix;
dockSinkRe = lib.concatStringsSep "|"
(map (m: lib.removePrefix "~" m."node.name")
(lib.concatMap (r: r.matches) rules."monitor.alsa.rules"));
# Cards driven by ALSA UCM expose speakers and headphones as separate
# sinks (HiFi__Headphones__sink), the headphone one existing only while
# the jack is occupied — so on those machines a jack plug is a sink
# appearing, not the route switch WirePlumber handles by itself.
headphoneSinkRe = "alsa_output\\..*[Hh]eadphone.*";
tool = pkgs.writeShellScriptBin "nomarchy-dock-audio" ''
set -u
PACTL=${pkgs.pulseaudio}/bin/pactl
JQ=${pkgs.jq}/bin/jq
SYSTEMCTL=${pkgs.systemd}/bin/systemctl
LOGGER=${pkgs.util-linux}/bin/logger
rt="''${XDG_RUNTIME_DIR:-/run/user/$(id -u)}"
lock="$rt/nomarchy-dock-audio-reprobe.lock"
TAB=$(printf '\t')
log() { "$LOGGER" -t nomarchy-dock-audio -- "$*"; }
wait_for_pulse() {
tries=0
while [ "$tries" -lt 40 ]; do
"$PACTL" info >/dev/null 2>&1 && return 0
tries=$((tries+1)); sleep 0.25
done
return 1
}
# Highest-priority sink matching $1 whose active port is not explicitly
# unavailable. With pro-audio profiles a sink may have no ports; its
# existence is then the only availability signal PipeWire exposes.
sinks_matching() {
"$PACTL" --format=json list sinks 2>/dev/null \
| "$JQ" -r --arg re "^($1)$" '
[ .[] as $sink
| $sink
| select(.name | test($re))
| select(
((.ports // []) | length) == 0
or .active_port == null
or ([.ports[]?
| select(.name == $sink.active_port)
| (.availability // "unknown")][0]
// "unknown") != "not available"
) ]
| sort_by(.priority // 0) | reverse[]
| "\(.name)\t\(.description // .name)"'
}
dock_sinks() { sinks_matching '${dockSinkRe}'; }
headphone_sinks() { sinks_matching '${headphoneSinkRe}'; }
default_sink() { "$PACTL" get-default-sink 2>/dev/null; }
# A dock sink appears some short time after the plug, not with it. Poll
# before concluding there is nothing to select: "not yet" and "not ever"
# look identical in one shot, and treating the first as the second is what
# would drive an ordinary plug down to the graph restart.
wait_for_dock_sink() {
tries=0
while [ "$tries" -lt 20 ]; do
[ -n "$(dock_sinks)" ] && return 0
tries=$((tries+1)); sleep 0.25
done
return 1
}
select_first() { # $1 = sink lister, $2 = observable trigger
lister="$1"; trigger="$2"
candidate=$("$lister" | ${pkgs.coreutils}/bin/head -n 1) || candidate=
if [ -z "$candidate" ]; then
log "trigger=$trigger result=no-available-sink"
return 1
fi
name=''${candidate%%"$TAB"*}
desc=''${candidate#*"$TAB"}
if "$PACTL" set-default-sink "$name" 2>/dev/null; then
log "trigger=$trigger selected=$name description=$desc"
${pkgs.libnotify}/bin/notify-send -a Nomarchy "Audio" \
"Output $desc" 2>/dev/null || true
return 0
fi
log "trigger=$trigger result=set-default-failed candidate=$name"
return 1
}
# A card parked on `pro-audio` (or `off`) publishes no sink we can route:
# pro-audio exposes raw `pro-output-N` nodes with no ports, no routing and
# no volume, so a monitor's audio is unreachable however it is chosen
# and WirePlumber stores that profile per card, so it survives forever
# once set. When a dock plug finds nothing to select, repair the cards
# that carry an available HDMI/DisplayPort output by moving them to their
# best real profile. Deliberately narrow: an internal analog card is
# never touched, and a card already on a routable profile is left alone,
# so a considered pro-audio setup on anything else survives.
repair_dock_cards() { # $1 = observable trigger
trigger="$1"
cards=$("$PACTL" --format=json list cards 2>/dev/null \
| "$JQ" -r '
.[]
| select(.active_profile == "pro-audio" or .active_profile == "off")
# An available HDMI/DP port is the monitor itself asking for
# audio over the cable that is what makes this card a dock.
| select([.ports[]? | select((.type // "") == "HDMI")
| select((.availability // "unknown") == "available")]
| length > 0)
| . as $card
| [ $card.profiles // {} | to_entries[]
| select(.key != "pro-audio" and .key != "off")
| select((.value.sinks // 0) > 0)
| select((.value.available // true) != false) ]
| sort_by(-(.value.priority // 0))
| if length == 0 then empty else "\($card.name)\t\(.[0].key)" end') || cards=
[ -n "$cards" ] || return 1
printf '%s\n' "$cards" | while IFS= read -r row; do
card=''${row%%"$TAB"*}
prof=''${row#*"$TAB"}
if "$PACTL" set-card-profile "$card" "$prof" 2>/dev/null; then
log "trigger=$trigger repaired-card=$card profile=$prof"
else
log "trigger=$trigger repair-failed=$card profile=$prof"
fi
done
}
select_first_dock() { # $1 = observable trigger
select_first dock_sinks "$1" && return 0
# Nothing routable to select is the symptom a parked card produces, so
# it is also the only moment worth touching profiles. Retry once the
# repaired card has had time to publish its sinks.
repair_dock_cards "$1" || return 1
sleep 1
select_first dock_sinks "$1"
}
case "''${1:-watch}" in
candidates)
dock_sinks ;;
select)
wait_for_pulse || { log "trigger=''${2:-manual} result=pulse-unavailable"; exit 1; }
select_first_dock "''${2:-manual}" ;;
reprobe)
trigger="''${2:-monitoradded}"
# mkdir is the debounce/lock: simultaneous monitoradded events from
# an MST dock collapse into one graph restart. Runtime-only state,
# removed on every exit path.
if ! ${pkgs.coreutils}/bin/mkdir "$lock" 2>/dev/null; then
log "trigger=$trigger result=debounced"
exit 0
fi
trap '${pkgs.coreutils}/bin/rmdir "$lock" 2>/dev/null || true' EXIT INT TERM
log "trigger=$trigger action=reprobe-start"
sleep 2
wait_for_pulse || { log "trigger=$trigger result=pulse-unavailable"; exit 1; }
# Rung 1 + 2: the sink is there to be chosen, or a parked card needs
# its profile repaired first (select_first_dock does both). This is
# the whole job on a healthy plug and it keeps every audio client's
# connection alive, which is the point.
wait_for_dock_sink || log "trigger=$trigger result=no-dock-sink-yet"
select_first_dock "$trigger" && exit 0
# Rung 3: nothing routable even after the card repair. Restart the
# graph the inherited recovery, now confined to the case that has
# actually run out of cheaper options and try once more.
log "trigger=$trigger action=graph-restart-fallback"
"$SYSTEMCTL" --user restart \
pipewire.service pipewire-pulse.service wireplumber.service \
>/dev/null 2>&1 || log "trigger=$trigger action=graph-restart-returned-error"
if wait_for_pulse; then
sleep 0.75
select_first_dock "$trigger" || true
else
log "trigger=$trigger result=pulse-did-not-return"
fi ;;
headphones)
# Follow the jack, but only ever *towards* the headphones. On unplug
# the sink disappears and any pin naming it goes stale, which makes
# WirePlumber fall back by priority on its own to the dock when
# docked, to the speakers otherwise so there is nothing to undo.
wait_for_pulse || exit 0
hp=$(headphone_sinks | ${pkgs.coreutils}/bin/head -n 1) || hp=
[ -n "$hp" ] || exit 0
name=''${hp%%"$TAB"*}
[ "$(default_sink)" = "$name" ] && exit 0
select_first headphone_sinks "''${2:-jack}" || true ;;
watch)
# Dock selection is deliberately NOT done at service startup: a
# restart or relogin while docked must not erase a manual speaker
# choice. Only the compositor's fresh monitoradded event calls
# `reprobe`.
#
# The jack is different, and is why this loop exists. A sink that
# WirePlumber has been told to prefer (by us on a dock plug, or by
# the user in the Audio menu / a mixer) is stored as the configured
# default, and that outranks every priority rule so on UCM cards,
# where the headphones are a sink of their own rather than a route,
# plugging them in could no longer steal the audio back. Watch for
# that sink appearing and select it explicitly.
wait_for_pulse || true
# A graph restart closes `pactl subscribe`, sometimes with status 0.
# Always resubscribe; log one concise closure line for diagnosis.
while :; do
if LC_ALL=C "$PACTL" subscribe 2>/dev/null \
| while IFS= read -r line; do
case "$line" in
*"'new' on sink"*|*"'change' on card"*)
# Re-entering the tool keeps the decision out of this
# read loop, which must never block on the graph.
"$0" headphones jack || true ;;
esac
done
then status=0; else status=$?; fi
log "subscription-closed status=$status; retrying"
sleep 1
wait_for_pulse || sleep 1
done ;;
*)
echo "usage: nomarchy-dock-audio [watch|candidates|select [trigger]|reprobe [trigger]|headphones [trigger]]" >&2
exit 64 ;;
esac
'';
in
{
config = lib.mkIf cfg.dockAudio.enable {
home.packages = [ tool ];
systemd.user.services.nomarchy-dock-audio = {
Unit = {
Description = "Reprobe and select dock/monitor audio on display hotplug";
After = [ "graphical-session.target" ];
PartOf = [ "graphical-session.target" ];
};
Service = {
ExecStart = "${tool}/bin/nomarchy-dock-audio watch";
Restart = "on-failure";
RestartSec = 2;
};
Install.WantedBy = [ "graphical-session.target" ];
};
};
}

View File

@@ -9,11 +9,15 @@ let
cfg = config.nomarchy;
c = cfg.theme.colors;
# #122: sextants (2×3 subcells) give ~6× the detail of half/full blocks.
# Size and raster width stepped up together so the mark still fills the
# logo column without looking sparse. Diagonals still staircase a little
# (the SVG is an angled N); that is a logo-design limit, not chafa's.
logo = pkgs.runCommand "nomarchy-fastfetch-logo"
{ nativeBuildInputs = [ pkgs.imagemagick pkgs.librsvg pkgs.chafa ]; } ''
rsvg-convert -w 220 ${../nixos/branding/logo.svg} > logo.png
rsvg-convert -w 360 ${../nixos/branding/logo.svg} > logo.png
magick logo.png -fill "${c.accent}" -colorize 100 logo-c.png
chafa --format symbols --symbols block --size 20x10 --colors full --polite on logo-c.png > $out
chafa --format symbols --symbols sextant --size 24x12 --colors full --polite on logo-c.png > $out
'';
in
{

View File

@@ -0,0 +1,47 @@
# First-session welcome toast (nomarchy.firstBootWelcome) — one dismissible
# "you're set" notification pointing at SUPER+M / SUPER+T / SUPER+? and
# Network, then writes settings.firstBootShown into the flake checkout
# (GOALS: no state outside the checkout). Live ISO keeps its own toast
# (hosts/live.nix); the script self-skips on hostname nomarchy-live.
{ config, lib, pkgs, ... }:
{
config = lib.mkIf config.nomarchy.firstBootWelcome.enable {
systemd.user.services.nomarchy-first-boot = {
Unit = {
Description = "Nomarchy first-session welcome toast";
PartOf = [ "graphical-session.target" ];
# After the session AND the notification daemon. Without
# After=swaync, first login races D-Bus Notifications and the
# toast times out (migration: journal "Timeout was reached") —
# or worse, a later success writes firstBootShown without the
# user ever seeing the toast. Wants= so we still run if swaync
# is disabled (notify-send will fail and leave the marker unset).
After = [
"graphical-session.target"
"graphical-session-pre.target"
"swaync.service"
];
Wants = [ "swaync.service" ];
};
Service = {
Type = "oneshot";
# PATH: real notify-send + theme-sync; VM check shims both via PATH.
# NOMARCHY_PATH matches home.sessionVariables so the marker lands
# in the user's flake checkout.
Environment = [
"PATH=${lib.makeBinPath [
pkgs.libnotify
pkgs.nomarchy-state-sync
pkgs.coreutils
]}"
"NOMARCHY_PATH=%h/.nomarchy"
];
# Brief settle after swaync is up; the script also retries.
ExecStartPre = "${pkgs.coreutils}/bin/sleep 1";
ExecStart = "${pkgs.nomarchy-first-boot}/bin/nomarchy-first-boot";
};
Install.WantedBy = [ "graphical-session.target" ];
};
};
}

View File

@@ -1,43 +0,0 @@
# Ghostty — Nomarchy's default terminal, themed from theme-state.json.
# Colors, fonts and the full 16-color ANSI palette are baked from the
# JSON at eval time.
{ config, lib, ... }:
let
t = config.nomarchy.theme;
c = t.colors;
in
{
programs.ghostty = lib.mkIf config.nomarchy.ghostty.enable {
enable = true;
enableBashIntegration = true;
settings = {
# ── Typography (from theme-state.json) ────────────────────────
font-family = t.fonts.mono;
font-size = t.fonts.size;
# ── Colors (from theme-state.json) ────────────────────────────
background = c.base;
foreground = c.text;
cursor-color = c.accent;
selection-background = c.overlay;
selection-foreground = c.text;
split-divider-color = c.surface;
# "N=#rrggbb" entries; Ghostty accepts repeated `palette` keys,
# which the HM module renders from this list.
palette = lib.imap0 (i: color: "${toString i}=${color}") t.ansi;
# ── Chrome ────────────────────────────────────────────────────
# background-opacity is theme-driven (normal priority — use the
# CLI); the rest are mkDefault so a plain home.nix value wins.
background-opacity = t.ui.terminalOpacity;
window-padding-x = lib.mkDefault 12;
window-padding-y = lib.mkDefault 12;
window-decoration = lib.mkDefault false;
gtk-single-instance = lib.mkDefault true;
confirm-close-surface = lib.mkDefault false;
};
};
}

File diff suppressed because it is too large Load Diff

View File

@@ -1,5 +1,5 @@
# hyprlock + hypridle — screen locking and idle management, themed from
# theme-state.json. One concern, one file: hypridle drives WHEN (idle
# state.json. One concern, one file: hypridle drives WHEN (idle
# lock, display off, suspend, lock-before-sleep), hyprlock is the
# themed lock screen itself (also behind the power menu's Lock entry).
{ config, lib, pkgs, ... }:
@@ -19,9 +19,44 @@ let
done
exit 1
'';
# Absolute store paths for hypridle (thin PATH). Wake reuses the shared
# helper; a mini transition avoids a circular import on hyprland.nix.
displayWake =
let
miniTransition = pkgs.writeShellScriptBin "nomarchy-display-transition" ''
set -u
case "''${1:-}" in
undock|enable)
internal="''${2:-}"
[ -n "$internal" ] || exit 64
hyprctl keyword monitor "$internal,preferred,auto,1" >/dev/null 2>&1 || true
if ! hyprctl monitors -j 2>/dev/null \
| ${pkgs.jq}/bin/jq -e --arg m "$internal" 'any(.[]; .name == $m)' \
>/dev/null 2>&1; then
hyprctl reload >/dev/null 2>&1 || true
sleep 0.5
fi
hyprctl keyword monitor "$internal,preferred,auto,1" >/dev/null 2>&1 || true
hyprctl dispatch dpms on >/dev/null 2>&1 || true
hyprctl monitors -j 2>/dev/null \
| ${pkgs.jq}/bin/jq -e --arg m "$internal" 'any(.[]; .name == $m)' \
>/dev/null 2>&1
;;
*) exit 64 ;;
esac
'';
in
(import ./display-tools.nix {
inherit pkgs;
displayTransition = miniTransition;
}).displayWakeTool;
in
{
config = lib.mkIf cfg.idle.enable {
# Smart suspend on PATH for manual use; hypridle uses the store path.
home.packages = [ pkgs.nomarchy-suspend ];
programs.hyprlock = {
enable = true;
settings = {
@@ -43,9 +78,21 @@ in
check_color = rgb c.warn;
fail_color = rgb c.bad;
rounding = t.ui.rounding;
placeholder_text = "<i>password</i>";
# Sentence case, matching the $FPRINTPROMPT label below: both can be
# on screen at once, so they must not read as two different voices.
placeholder_text = "<i>Password</i>";
}];
# The lock screen is deliberately just the clock: the input field
# fades out while empty (hyprlock's fade_on_empty default) and only
# appears once you type. So the fingerprint hint CANNOT live in the
# field's placeholder — $FPRINTPROMPT renders faithfully into a widget
# nobody sees until they have already given up on the reader and
# started typing. It needs a surface that is visible at rest, and a
# label is the one that keeps the clock-only look. (Confirmed on
# hardware 2026-07-14: labels do expand $FPRINTPROMPT, so the line is
# live — the ready message, then the present message on touch — and
# not static text. Both live in auth.fingerprint below.)
label = [{
monitor = "";
text = "$TIME";
@@ -55,7 +102,32 @@ in
position = "0, 120";
halign = "center";
valign = "center";
}];
}] ++ lib.optional cfg.idle.fingerprint {
monitor = "";
text = "$FPRINTPROMPT";
# subtext-on-base is the palette's secondary-text role and is held
# to a 3.0 contrast floor on every theme by checks.theme-contrast —
# the same guard that exists because two themes once shipped
# subtext == base and made hint text invisible.
color = rgb c.subtext;
font_size = 16;
font_family = t.fonts.ui;
position = "0, -160";
halign = "center";
valign = "center";
};
} // lib.optionalAttrs cfg.idle.fingerprint {
# hyprlock does NOT take a fingerprint through PAM: its PAM stack runs
# only on submit, so a parallel module never gets to poll the reader.
# This is a separate backend of its own, talking to fprintd over
# D-Bus, and `nomarchy.hardware.fingerprint.pam` does not reach it —
# which is why finger-unlock at the lock screen did nothing at all
# until this option existed, while sudo took a finger happily.
auth.fingerprint = {
enabled = true;
ready_message = "Enter password or scan your finger";
present_message = "Scanning your finger";
};
};
};
@@ -71,7 +143,10 @@ in
# can't be safely dropped after the fact — killing the locker trips
# its "go to a tty" crash failsafe). See nomarchy-lock-before-sleep
# in modules/nixos/default.nix.
after_sleep_cmd = "hyprctl dispatch dpms on";
# #127: not only dpms on — if dock mode left zero enabled
# outputs (eDP disabled + external gone/black), re-enable the
# internal. Absolute store path: hypridle's PATH is thin.
after_sleep_cmd = "${lib.getExe displayWake}";
};
listener = [
# Lock and screen-off are the same on either power source —
@@ -79,15 +154,28 @@ in
{ timeout = 300; on-timeout = "loginctl lock-session"; }
{
timeout = 600;
# Blanks in every dock/lid state — the #127 clamshell skip is
# gone: it was a cure for a cause that does not exist (proven on
# hardware 2026-07-16, see ROADMAP). The wake no longer depends
# on this daemon surviving — misc:{key_press,mouse_move}_enables_dpms
# in hyprland.nix means input wakes the screen compositor-side.
on-timeout = "hyprctl dispatch dpms off";
on-resume = "hyprctl dispatch dpms on";
# Same rescue path as after_sleep (#127): re-enables a disabled
# internal, which plain `dpms on` cannot do.
on-resume = "${lib.getExe displayWake}";
}
# Suspend only on battery, and sooner than the old fixed 30 min
# (it now only fires unplugged). Plugged in, the machine stays
# up — long builds, media, presentations aren't killed mid-idle.
# Closing the lid still suspends on AC (logind's default): that's
# an explicit "I'm done", distinct from sitting idle.
{ timeout = 900; on-timeout = "${onAc} || systemctl suspend"; }
# nomarchy-suspend (#115): on battery + hibernate wired + toggle
# on → suspend-then-hibernate (1h → disk); else plain suspend.
# Lid close is logind's job (not hypridle): undocked lid still
# suspends / s2h (HandleLidSwitch); docked/clamshell lid is ignore
# (HandleLidSwitchDocked — modules/nixos/power.nix, #86).
{
timeout = 900;
on-timeout = "${onAc} || ${lib.getExe pkgs.nomarchy-suspend}";
}
];
};
};

View File

@@ -7,62 +7,107 @@
# key — the key name
# action — the Hyprland dispatcher (+args) that the bind runs
# desc — human label, shown in the cheatsheet
# group — cheatsheet section: Window | Workspace | Menu | Media (#108)
#
# `extra` holds cheatsheet-only rows for binds generated elsewhere
# (per-workspace numbers, mouse drags) so they still show up under SUPER+?.
{
binds = [
{ 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 = "D"; action = "exec, rofi -show drun"; desc = "App launcher"; }
{ 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 = "Q"; action = "killactive"; desc = "Close window"; }
{ mods = "$mod"; key = "F"; action = "fullscreen"; desc = "Fullscreen"; }
{ mods = "$mod"; key = "V"; action = "togglefloating"; desc = "Toggle floating"; }
{ mods = "$mod SHIFT"; key = "E"; action = "exit"; desc = "Exit Hyprland"; }
{ mods = "$mod"; key = "Return"; action = "exec, $terminal"; desc = "Open terminal"; group = "Window"; }
{ mods = "$mod"; key = "Space"; action = "exec, rofi -show drun -theme launcher"; desc = "App launcher"; group = "Menu"; }
{ mods = "$mod"; key = "M"; action = "exec, nomarchy-menu"; desc = "Main menu"; group = "Menu"; }
{ mods = "$mod"; key = "E"; action = "exec, $terminal -e yazi"; desc = "File manager (yazi)"; group = "Window"; }
{ mods = "$mod"; key = "Q"; action = "killactive"; desc = "Close window"; group = "Window"; }
{ mods = "$mod"; key = "F"; action = "fullscreen"; desc = "Fullscreen"; group = "Window"; }
{ mods = "$mod"; key = "V"; action = "togglefloating"; desc = "Toggle floating"; group = "Window"; }
{ mods = "$mod SHIFT"; key = "E"; action = "exit"; desc = "Exit Hyprland"; group = "Window"; }
# Theme picker (menu dispatcher): apply writes the state and runs
# home-manager switch (progress via notify-send).
{ mods = "$mod"; key = "T"; action = "exec, nomarchy-menu theme"; desc = "Theme picker"; }
{ mods = "$mod"; key = "T"; action = "exec, nomarchy-menu theme"; desc = "Theme picker"; group = "Menu"; }
# Cycle the current theme's wallpapers (instant, no rebuild).
{ mods = "$mod SHIFT"; key = "T"; action = "exec, nomarchy-theme-sync bg next"; desc = "Next wallpaper"; }
{ mods = "$mod SHIFT"; key = "T"; action = "exec, nomarchy-state-sync bg next"; desc = "Next wallpaper"; group = "Menu"; }
# Power menu via the dispatcher. Not Escape: Super+Escape gets
# swallowed before reaching the dispatcher on some setups.
{ mods = "$mod"; key = "X"; action = "exec, nomarchy-menu power"; desc = "Power menu"; }
{ mods = "$mod"; key = "X"; action = "exec, nomarchy-menu power"; desc = "Power menu"; group = "Menu"; }
{ mods = "$mod"; key = "N"; action = "exec, swaync-client -t"; desc = "Notification centre"; }
# SUPER+? (the "question" keysym already implies Shift on most layouts).
{ mods = "$mod"; key = "question"; action = "exec, nomarchy-menu keybinds"; desc = "Keybindings cheatsheet"; }
{ mods = "$mod"; key = "N"; action = "exec, swaync-client -t"; desc = "Notification centre"; group = "Media"; }
# SUPER+? — ? is Shift+/. SHIFT stays in the modmask (you hold it), but
# 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"; group = "Menu"; }
# Menu functions — SUPER+CTRL+<mnemonic> jumps straight to a
# nomarchy-menu module (all also reachable from the SUPER+M picker).
{ mods = "$mod CTRL"; key = "V"; action = "exec, nomarchy-menu clipboard"; desc = "Clipboard history"; }
{ mods = "$mod CTRL"; key = "C"; action = "exec, nomarchy-menu calc"; desc = "Calculator"; }
{ 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 = "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 = "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 = "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 = "V"; action = "exec, nomarchy-menu clipboard"; desc = "Clipboard history"; group = "Menu"; }
{ mods = "$mod CTRL"; key = "C"; action = "exec, nomarchy-menu calc"; desc = "Calculator"; group = "Menu"; }
{ mods = "$mod CTRL"; key = "W"; action = "exec, nomarchy-menu web"; desc = "Web search"; group = "Menu"; }
{ mods = "$mod CTRL"; key = "F"; action = "exec, nomarchy-menu files"; desc = "File search"; group = "Menu"; }
{ mods = "$mod CTRL"; key = "E"; action = "exec, nomarchy-menu emoji"; desc = "Emoji picker"; group = "Menu"; }
{ mods = "$mod CTRL"; key = "N"; action = "exec, nomarchy-menu network"; desc = "Network (networkmanager_dmenu)"; group = "Menu"; }
{ mods = "$mod CTRL"; key = "B"; action = "exec, nomarchy-menu bluetooth"; desc = "Bluetooth"; group = "Menu"; }
{ mods = "$mod CTRL"; key = "R"; action = "exec, nomarchy-menu airplane"; desc = "Airplane mode (Wi-Fi + Bluetooth)"; group = "Menu"; }
{ mods = "$mod CTRL"; key = "K"; action = "exec, nomarchy-menu keyboard"; desc = "Keyboard layout"; group = "Menu"; }
{ mods = "$mod CTRL"; key = "S"; action = "exec, nomarchy-menu capture"; desc = "Screenshot / capture"; group = "Media"; }
{ mods = "$mod CTRL"; key = "P"; action = "exec, nomarchy-menu colorpicker"; desc = "Color picker ( clipboard)"; group = "Menu"; }
{ mods = "$mod CTRL"; key = "A"; action = "exec, nomarchy-menu ask"; desc = "Ask AI"; group = "Menu"; }
{ mods = "$mod CTRL"; key = "D"; action = "exec, nomarchy-menu dnd"; desc = "Do Not Disturb toggle"; group = "Media"; }
# I as in "settings" muscle memory (Super+I elsewhere); T for Tools.
{ mods = "$mod CTRL"; key = "I"; action = "exec, nomarchy-menu system"; desc = "System menu"; group = "Menu"; }
{ mods = "$mod CTRL"; key = "T"; action = "exec, nomarchy-menu tools"; desc = "Tools menu"; group = "Menu"; }
# The one SUPER+CTRL row that is not a menu module: lock earns the
# family slot because bare SUPER+L is a common app bind and Escape is
# unusable (see the power-menu note above). Same dispatcher the power
# menu's Lock row uses (rofi.nix) — logind, not `hyprlock` directly, so
# the session is marked locked and hypridle's lock_cmd stays the one
# place that decides how a lock actually looks.
{ mods = "$mod CTRL"; key = "L"; action = "exec, loginctl lock-session"; desc = "Lock screen"; group = "Menu"; }
{ mods = "$mod SHIFT"; key = "C"; action = "exec, hyprpicker -a"; desc = "Color picker ( clipboard)"; group = "Menu"; }
# #127: dump display state to ~/nomarchy-display-dump-*.txt without SSH.
# If the seat is locked, hyprlock may swallow this — use Ctrl+Alt+F3 and
# run `nomarchy-display-dump` / `nomarchy-display-wake` on the TTY instead.
{ mods = "$mod SHIFT"; key = "D"; action = "exec, nomarchy-display-dump keybind"; desc = "Dump display diagnostics (~/)"; group = "Menu"; }
# Focus
{ mods = "$mod"; key = "H"; action = "movefocus, l"; desc = "Focus left"; }
{ mods = "$mod"; key = "L"; action = "movefocus, r"; desc = "Focus right"; }
{ mods = "$mod"; key = "K"; action = "movefocus, u"; desc = "Focus up"; }
{ mods = "$mod"; key = "J"; action = "movefocus, d"; desc = "Focus down"; }
# Focus — SUPER + arrow keys.
{ mods = "$mod"; key = "left"; action = "movefocus, l"; desc = "Focus left"; group = "Window"; }
{ mods = "$mod"; key = "right"; action = "movefocus, r"; desc = "Focus right"; group = "Window"; }
{ mods = "$mod"; key = "up"; action = "movefocus, u"; desc = "Focus up"; group = "Window"; }
{ mods = "$mod"; key = "down"; action = "movefocus, d"; desc = "Focus down"; group = "Window"; }
# Screenshot region to clipboard (the menu's Capture module has more).
{ mods = ""; key = "Print"; action = "exec, grim -g \"$(slurp)\" - | wl-copy"; desc = "Screenshot region clipboard"; }
# Multi-monitor workspace movement — SUPER + ALT + arrow keys.
{ mods = "$mod ALT"; key = "left"; action = "movecurrentworkspacetomonitor, l"; desc = "Move workspace to left monitor"; group = "Workspace"; }
{ mods = "$mod ALT"; key = "right"; action = "movecurrentworkspacetomonitor, r"; desc = "Move workspace to right monitor"; group = "Workspace"; }
{ mods = "$mod ALT"; key = "up"; action = "movecurrentworkspacetomonitor, u"; desc = "Move workspace to upper monitor"; group = "Workspace"; }
{ mods = "$mod ALT"; key = "down"; action = "movecurrentworkspacetomonitor, d"; desc = "Move workspace to lower monitor"; group = "Workspace"; }
# 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"; group = "Media"; }
{ 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"; group = "Media"; }
{ 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"; group = "Media"; }
{ 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"; group = "Media"; }
];
# 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"; group = "Menu"; }
];
extra = [
{ keys = "SUPER + 1-9"; desc = "Switch 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 = "Volume / Brightness"; desc = "Hardware keys, shown via the OSD"; }
{ keys = "SUPER + 1-9, 0"; desc = "Switch to workspace 1-10 (0 = 10)"; group = "Workspace"; }
{ keys = "SUPER + SHIFT + 1-9, 0"; desc = "Move window to workspace 1-10 (0 = 10)"; group = "Workspace"; }
{ keys = "SUPER + drag"; desc = "Move (LMB) / resize (RMB) window"; group = "Window"; }
{ keys = "Volume / Brightness"; desc = "Hardware keys, shown via the OSD"; group = "Media"; }
{ keys = "Bar: 󰾪 click"; desc = "Caffeine hold the screen awake (idle inhibitor)"; group = "Media"; }
];
}

View File

@@ -8,10 +8,12 @@
# GNOME session to lean on) and themed by Stylix's Qt config, so the
# passphrase dialog tracks the palette.
#
# SSH_AUTH_SOCK is exported by the agent's shell integration (zsh, on via
# home.shell.enableZshIntegration in shell.nix). Terminal git/ssh is the
# supported path; a GUI client launched outside a shell won't inherit the
# socket — revisit with a session-level export if that's ever wanted.
# SSH_AUTH_SOCK reaches both terminal and GUI clients: the agent's zsh
# integration sets it in interactive shells (home.shell.enableZshIntegration
# in shell.nix), and a session-level home.sessionVariables export (below)
# 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
# secrets; modern gnome-keyring no longer runs an SSH agent, so there is no
@@ -38,5 +40,16 @@ in
defaultCacheTtlSsh = 1800;
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)";
};
}

60
modules/home/kitty.nix Normal file
View File

@@ -0,0 +1,60 @@
# Kitty — Nomarchy's only terminal, themed from state.json.
# Colors, fonts and the full 16-color ANSI palette are baked from the
# JSON at eval time (same contract Ghostty used to have). Always
# installed: SUPER+Return, SUPER+E, doctor, calendar, and $TERMINAL
# all depend on it.
{ config, lib, ... }:
let
t = config.nomarchy.theme;
c = t.colors;
colorSettings = lib.listToAttrs (
lib.imap0 (i: color: {
name = "color${toString i}";
value = color;
}) t.ansi
);
in
{
# Kitty is ALWAYS installed. `nomarchy.kitty.enable` gates only whether
# Nomarchy's theming/config is applied — a user can keep kitty but drop
# our config; they cannot remove kitty itself without breaking the
# desktop's load-bearing classed windows.
programs.kitty = {
enable = true;
shellIntegration.enableBashIntegration = true;
shellIntegration.enableZshIntegration = true;
font = lib.mkIf config.nomarchy.kitty.enable {
name = t.fonts.mono;
size = t.fonts.size;
};
settings = lib.mkIf config.nomarchy.kitty.enable ({
background = c.base;
foreground = c.text;
cursor = c.accent;
cursor_text_color = c.base;
selection_background = c.overlay;
selection_foreground = c.text;
url_color = c.accent;
active_border_color = c.accent;
inactive_border_color = c.surface;
background_opacity = t.ui.terminalOpacity;
window_padding_width = lib.mkDefault 12;
confirm_os_window_close = lib.mkDefault 0;
enable_audio_bell = lib.mkDefault false;
wayland_titlebar_color = lib.mkDefault "background";
# Fresh windows for doctor/calendar --class launches (not one shared instance).
single_instance = lib.mkDefault false;
# Kitty's default is to remember the last OS window's size and replay it
# into the next one (~/.cache/kitty/main.json). Harmless while tiled —
# the compositor sizes those — but every *floating* kitty then inherits
# whatever the last window happened to be, so a sheet opened after a
# maximized terminal opens maximized (#139: the Ghostty regression, which
# kept no such memory). Floats should be deterministic: each launcher
# asks for the size it wants.
remember_window_size = lib.mkDefault false;
} // colorSettings);
};
}

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

@@ -0,0 +1,68 @@
# 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";
# Prefer the template's vscode; fall through to gnome-text-editor
# (ships on the live ISO after #103, and is the only editor there).
# A singleton that names an absent .desktop is silently skipped by
# GIO and leaves no handler at all — the #94 / #119 trap. HM tries
# the next entry when the preferred one is missing.
"text/plain" = [ "code.desktop" "org.gnome.TextEditor.desktop" ];
# The system-side Thunar (nomarchy.system.fileManager).
"inode/directory" = "thunar.desktop";
# Template ships chromium (chromium-browser.desktop), and since #103
# so does the live ISO — an entry naming a package nothing installs is
# silently skipped by GIO, which is exactly how the live session ended
# up with no default browser at all (#94). Delete the package and the
# entry goes quiet again; 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,97 @@
# Night light — a scheduled blue-light filter via hyprsunset (Hyprland's own
# gamma/temperature tool). Warm at night, identity (no shift) by day;
# hyprsunset's time-based `profile` entries handle the schedule and pick the
# right state on session start. Opt-in via nomarchy.nightlight.enable.
# gamma/temperature tool). Warm at night, identity (no shift) by day; the
# schedule (temperature/sunrise/sunset) is tuned via nomarchy.nightlight.* in
# home.nix and baked into the unit's time-based `profile`.
#
# The hyprsunset HM service module is provided by home-manager; this only
# configures it. Override anything with plain services.hyprsunset.* options.
{ config, lib, ... }:
# Geo mode: when BOTH nomarchy.nightlight.latitude and .longitude are set,
# wlsunset replaces hyprsunset — it computes sunrise/sunset from the
# 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
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
}
# LoadState=loaded means a real unit (not the empty-file mask HM/packages
# leave behind for unused hyprsunset). `systemctl cat` alone succeeds on
# a masked unit and wrongly reported "installed" silent no-op start.
installed() {
[ "$(systemctl --user show -p LoadState --value "$unit" 2>/dev/null)" = loaded ]
}
active() { systemctl --user is-active --quiet "$unit" 2>/dev/null; }
start() {
systemctl --user daemon-reload 2>/dev/null || true
systemctl --user unmask "$unit" 2>/dev/null || true
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 Look & Feel.
# `active` alone is the truth for the moon; menu labels use the same.
active \
&& printf '{"text":"󰖔","tooltip":"Night light on ${if geo then "follows your location" else "warm on schedule"} (click to disable)","class":"on"}\n'
exit 0 ;;
# Plain on/off string for menus (hyprsunset OR wlsunset; not hard-coded).
is-active) if active; then echo on; else echo off; fi ;;
on)
if installed; then write_on true; start; else install_feature; start; fi ;;
off) write_on false; stop ;;
toggle)
if active; 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|is-active]" >&2; exit 64 ;;
esac
'';
in
{
config = lib.mkIf cfg.enable {
services.hyprsunset = {
config = {
# 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;
settings.profile = [
# Daytime: identity = no colour change.
@@ -21,5 +100,22 @@ in
{ 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
# configure in their home.nix. Kept small on purpose.
{ lib, pkgs, ... }:
{ config, lib, pkgs, ... }:
let
# One output's layout — turned into a Hyprland `monitor` rule in
@@ -55,17 +55,44 @@ 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
{
options.nomarchy = {
# ── Required ───────────────────────────────────────────────────
stateFile = lib.mkOption {
type = lib.types.path;
example = lib.literalExpression "./theme-state.json";
example = lib.literalExpression "./state.json";
description = ''
Path to theme-state.json, the single source of truth for all UI
Path to state.json, the single source of truth for all UI
configuration. Must live inside your flake (so evaluation stays
pure) and be git-tracked. nomarchy-theme-sync writes to the
pure) and be git-tracked. nomarchy-state-sync writes to the
on-disk copy; rebuilds bake it into the generation.
'';
};
@@ -73,13 +100,21 @@ in
# ── Preferences ────────────────────────────────────────────────
terminal = lib.mkOption {
type = lib.types.str;
default = "ghostty";
description = "Terminal emulator command, used by keybinds and $TERMINAL.";
default = config.nomarchy.settings.terminal or "kitty";
example = "kitty";
description = ''
Terminal emulator command for keybinds and `$TERMINAL`. Nomarchy
ships and themes **Kitty only** (sole supported terminal works
on older GPUs that Ghostty's OpenGL 4.3 floor rejected). Override
only if you install another emulator yourself.
'';
};
kitty.enable = lib.mkEnableOption "Nomarchy's Kitty configuration (palette/font/opacity from theme-state)" // { default = true; };
keyboard.layout = lib.mkOption {
type = lib.types.str;
default = "us";
default = config.nomarchy.settings.keyboard.layout or "us";
example = "de";
description = ''
XKB layout for the Hyprland session. The console (and the LUKS
@@ -101,14 +136,18 @@ in
default = [ ];
example = [ "de" "fr" ];
description = ''
Extra candidate layouts offered by the interactive new-keyboard
picker. When non-empty, a small watcher runs in the session: when a
keyboard connects that isn't covered by nomarchy.keyboard.devices and
hasn't been chosen before, it pops a rofi picker (these layouts plus
the primary nomarchy.keyboard.layout), applies the choice, and
remembers it per-device (~/.local/state) re-applying automatically
on later reconnects. The runtime-remember complement to the
declarative keyboard.devices; a stateful runtime piece by design.
Extra candidate layouts shown first by the interactive new-keyboard
picker (all installed XKB layouts remain searchable). A small watcher
always runs in the Hyprland session: when a keyboard connects that
isn't covered by nomarchy.keyboard.devices and hasn't been chosen
before, it pops a rofi picker, applies the choice to that keyboard only
(a per-device kb_layout, so the built-in board is left alone), and
remembers it in the git-tracked in-flake state
(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,15 +174,17 @@ in
nomarchy.keyboard.layout for that keyboard only e.g. an external
keyboard that's physically a different layout than the laptop's
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.
'';
};
package = lib.mkOption {
type = lib.types.package;
default = pkgs.nomarchy-theme-sync;
defaultText = lib.literalExpression "pkgs.nomarchy-theme-sync";
description = "The nomarchy-theme-sync package (provided by overlays.default).";
default = pkgs.nomarchy-state-sync;
defaultText = lib.literalExpression "pkgs.nomarchy-state-sync";
description = "The nomarchy-state-sync package (provided by overlays.default).";
};
themesDir = lib.mkOption {
@@ -159,8 +200,9 @@ in
nightlight = {
enable = lib.mkEnableOption ''
a scheduled blue-light filter (hyprsunset): warm at night, no shift
by day. Opt-in; tune the temperature + sunrise/sunset below'';
a scheduled blue-light filter (hyprsunset; wlsunset in geo mode):
warm at night, no shift by day. Opt-in; tune the temperature +
sunrise/sunset (or latitude/longitude) below'';
temperature = lib.mkOption {
type = lib.types.int;
@@ -173,14 +215,57 @@ in
type = lib.types.str;
default = "07:00";
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 {
type = lib.types.str;
default = "20: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,21 +290,124 @@ 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 ──────────────────────────────────────────
hyprland.enable = lib.mkEnableOption "Nomarchy's Hyprland 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; };
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; };
dockAudio.enable = lib.mkEnableOption "settled PipeWire/WirePlumber reprobe and automatic default-output switch to an available dock/monitor sink (HDMI/DisplayPort/USB) on fresh display hotplug, with a toast and journal result; a later manual choice sticks until the next plug" // { default = true; };
firstBootWelcome.enable = lib.mkEnableOption "one dismissible \"you're set\" toast on the first session (menu/themes/keys + network pointer); marker is settings.firstBootShown in the flake checkout" // { default = true; };
idle.enable = lib.mkEnableOption "hyprlock + hypridle (idle lock, display off, suspend)" // { default = true; };
idle.fingerprint = lib.mkOption {
type = lib.types.bool;
default = config.nomarchy.settings.fingerprint.pam or false;
defaultText = lib.literalExpression
"(settings.fingerprint.pam from state.json) or false";
description = ''
Unlock the lock screen with a fingerprint as well as the password, and
say so on the input field.
Reads the SAME `settings.fingerprint.pam` state key that
`nomarchy.hardware.fingerprint.pam` defaults from, so the one
System Fingerprint toggle moves the lock screen and login/sudo
together "fingerprint on" is one decision, not two that can drift.
(It has to arrive by state, not by reading the NixOS side: hyprlock is
configured here, in standalone Home Manager, which has no `osConfig`.)
The two remain separate *mechanisms*, which is why this option still
exists to be set by hand: hyprlock does NOT unlock by fingerprint
through PAM. Its PAM stack only runs on submit, so a parallel module
never gets to poll hyprlock has its own fprintd-over-D-Bus backend
instead, and this is the switch for it.
'';
};
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; };
shell.enable = lib.mkEnableOption "the zsh shell experience (starship prompt, bat/eza/zoxide)" // { default = true; };
keys.enable = lib.mkEnableOption "the SSH + GPG agent (gpg-agent fronting SSH, pinentry-qt)" // { default = true; };
fastfetch.enable = lib.mkEnableOption "fastfetch system info fronted by the themed Nomarchy logo" // { default = true; };
ghostty.enable = lib.mkEnableOption "Nomarchy's Ghostty configuration" // { 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; };
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) ───────────────────────────────────────
theme = lib.mkOption {
@@ -228,6 +416,17 @@ in
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 {
type = lib.types.attrs;
readOnly = true;

View File

@@ -2,7 +2,7 @@
# lock). The media keys drive it via swayosd-client (it performs the
# action AND shows the OSD), so changing volume or brightness gives the
# visual feedback that bare wpctl/brightnessctl didn't. Themed from
# theme-state.json. Brightness needs the backlight udev rule shipped
# state.json. Brightness needs the backlight udev rule shipped
# system-side (modules/nixos/default.nix → services.udev.packages).
{ config, lib, pkgs, ... }:

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

@@ -1,11 +1,11 @@
# Shell experience — zsh (the default login shell, set system-side) with
# autosuggestions + syntax highlighting, a themed starship prompt, and
# modern CLI tools wired in: bat (cat), eza (ls), zoxide (cd). bat uses
# the "ansi" theme so it follows the terminal palette (Ghostty is themed
# the "ansi" theme so it follows the terminal palette (Kitty is themed
# from the same JSON); starship is themed from the palette directly.
#
# home.shell.enableZshIntegration = true is the single lever that makes
# every integrating program (starship, eza, zoxide, ghostty, yazi) emit
# every integrating program (starship, eza, zoxide, kitty, yazi) emit
# its zsh hooks — no per-module flags needed.
{ config, lib, pkgs, ... }:
@@ -69,11 +69,11 @@ in
gpl = "git pull";
gf = "git fetch --all --prune";
# Nix — the flake/store verbs (system/home rebuilds already have the
# sys-update / home-update commands, so they're not re-aliased here).
# Nix — store/flake verbs. Lifecycle is nomarchy-pull /
# nomarchy-rebuild / nomarchy-home (full names on PATH, not aliases).
ns = "nix shell"; # ns nixpkgs#ripgrep
nr = "nix run"; # nr nixpkgs#cowsay
nfu = "nix flake update";
nfu = "nix flake update"; # prefer nomarchy-pull day-to-day
nfc = "nix flake check";
nsearch = "nix search nixpkgs";
ngc = "nix-collect-garbage -d"; # user generations; sudo for system roots
@@ -133,7 +133,7 @@ in
};
# bat: "ansi" follows the terminal's 16-color palette → tracks the
# active Nomarchy theme (Ghostty bakes it) with no per-theme config.
# active Nomarchy theme (Kitty bakes it) with no per-theme config.
programs.bat = {
enable = true;
config = {

View File

@@ -1,7 +1,7 @@
# Stylix — themes the long tail of applications (GTK, Qt, cursors,
# fonts) from the same theme-state.json that drives the live engine.
# fonts) from the same state.json that drives the live engine.
#
# Division of labour: the hot-reload trio (Hyprland, Waybar, Ghostty)
# Division of labour: the hot-reload trio (Hyprland, Waybar, Kitty)
# is owned by the Nomarchy engine and updates instantly; everything
# Stylix touches updates on the next home-manager switch. That is why
# autoEnable is off and the trio's Stylix targets stay disabled.
@@ -16,6 +16,31 @@ let
c = t.colors;
hex = lib.removePrefix "#";
# Accent-button chips (#130). The label is the palette's base — dark on a
# dark theme, cream on a light one — so the chip has to be pulled AWAY from
# the label until the label is legible: toward white when the label is dark,
# toward black when it is cream. Same factor both ways; only the anchor
# flips, so "solid accent button, base label" stays the design and just
# shifts tone.
#
# Why a nudge at all: on a saturated mid-tone accent NOTHING clears 4.5 —
# neither the cream base (summer-day: 2.72) nor the dark text (1.65) — which
# is why upstream adw-gtk3 ships white-on-accent at ~2.7 and why "darken the
# label" was a dead end. The chip must move.
#
# 0.70 measured across all 24 palettes (tools/check-theme-contrast.py asserts
# it): worst dark 4.86 (nord/bad), worst light 5.59 (summer-day). Today's 0.90
# fails seven themes — miasma/bad 3.43, rose-pine 2.70 — so this is not only a
# light-theme fix, which is what the item assumed.
#
# GTK's mix(a, b, f) = a + (b - a) * f (gtkcsscolorvalue.c), i.e. f is the
# weight of the SECOND colour: 0.70 = 70% accent, 30% anchor. Read it that way
# or the ladder below looks inverted.
chipAnchor = if t.mode == "light" then "black" else "white";
# Each state pulls further from the label, so contrast only ever improves —
# rest is the worst case and the one the check pins.
chip = role: f: "mix(${chipAnchor}, ${role}, ${f})";
# Map the Nomarchy palette onto base16 roles.
base16 = {
base00 = hex c.base; # default background
@@ -35,6 +60,7 @@ let
base0E = hex c.accentAlt;# magenta
base0F = hex c.bad; # brown/deprecated
};
in
{
config = lib.mkIf cfg.stylix.enable {
@@ -45,8 +71,139 @@ in
base16Scheme = base16;
targets = {
gtk.enable = true;
gtk = {
enable = true;
# nm-applet / classic GTK menus: Stylix base16 selection can
# collapse into surface/muted on dark themes (Boreal: selected
# row and submenu arrows were effectively invisible). High-
# contrast selected rows + explicit arrow colour. Appended to
# Stylix's generated gtk.css (not gtk.gtk3.extraCss — that is a
# no-op under Stylix).
#
# #98: dialog button labels. Stylix always installs theme name
# "adw-gtk3" (light sheet) and only recolors @define-color vars.
# The light sheet hardcodes dark/black button text in places; on a
# dark palette that becomes black-on-dark in GTK dialogs. We force
# adw-gtk3-dark for dark mode (below) and still pin button label
# colours here so dialogs stay legible if a light sheet slips in.
extraCss = ''
menu menuitem, .menu menuitem, .context-menu menuitem {
color: #${base16.base05};
}
menu menuitem:hover, menu menuitem:selected,
.menu menuitem:hover, .menu menuitem:selected,
.context-menu menuitem:hover, .context-menu menuitem:selected,
menubar > menuitem:hover {
background-color: #${base16.base0D};
color: #${base16.base00};
}
menu menuitem:disabled, .menu menuitem:disabled {
color: #${base16.base03};
}
/* Submenu disclosure arrows need no icon remap: pan-end-symbolic
resolves via Papirus-Dark's breeze-dark inheritance. The
"invisible arrow" bug was Waybar's process-wide stylesheet:
its `* { min-height: 0; }` reset also hits the SNI tray menus
Waybar hosts and collapses arrow/check nodes. NB: this file
CANNOT fix that Waybar adds its provider at USER priority
too and wins the tie in practice (GTK 3.24, observed), so
every waybar.css carries its own menu counter-rules. The
rules here cover menus in ordinary GTK apps only. */
menu menuitem arrow, .menu menuitem arrow, .context-menu menuitem arrow {
min-width: 16px;
min-height: 16px;
}
check, radio {
min-width: 14px;
min-height: 14px;
color: inherit;
}
/* GTK4 / popover menus (some portals). */
popover.menu contents modelbutton {
color: #${base16.base05};
}
popover.menu contents modelbutton:hover,
popover.menu contents modelbutton:selected {
background-color: #${base16.base0D};
color: #${base16.base00};
}
/* Dialog / message-box action buttons (#98). */
button, button label,
.dialog-action-area button,
.dialog-action-area button label,
messagedialog button,
messagedialog button label,
.message-dialog button,
.message-dialog button label {
color: #${base16.base05};
}
button:disabled, button:disabled label {
color: alpha(#${base16.base05}, 0.5);
}
/* Accent buttons: the palette's base as the label, on a chip
pulled away from it (#130 see `chip` above for the why and
the measured numbers).
BOTH chips are pinned, not just destructive as in #98. Two
reasons, and the second is the durable one:
1. adw-gtk3 builds the destructive background from
currentColor mix(@destructive_color,
alpha(currentColor,)) so pinning the label alone drags
the background with it and the button renders dark-on-dark
(1.03:1, invisible). That is the #98 bug.
2. A pinned label on an UNPINNED background is unknowable:
no static check can see what it renders on, which is
exactly why every check was green while #98 shipped. A
pinned pair is arithmetic, and
tools/check-theme-contrast.py now asserts it for all 24
palettes. The rule for anything added here: pin the pair,
or pin neither.
Enabled only :disabled keeps the sheet's own chrome and
already renders legibly. .default is GTK's dialog default and
adw-gtk3 styles it as an accent button, which is why it shares
the suggested chip rather than inheriting a plain one. */
button.suggested-action, button.suggested-action label,
button.destructive-action, button.destructive-action label,
button.default, button.default label {
color: #${base16.base00};
}
button.suggested-action:not(.flat):not(:disabled),
button.default:not(.flat):not(:disabled) {
background-color: ${chip "@accent_bg_color" "0.70"};
}
button.suggested-action:not(.flat):not(:disabled):hover,
button.default:not(.flat):not(:disabled):hover {
background-color: ${chip "@accent_bg_color" "0.65"};
}
button.suggested-action:not(.flat):not(:disabled):active,
button.suggested-action:not(.flat):not(:disabled):checked,
button.default:not(.flat):not(:disabled):active,
button.default:not(.flat):not(:disabled):checked {
background-color: ${chip "@accent_bg_color" "0.55"};
}
button.destructive-action:not(.flat):not(:disabled) {
background-color: ${chip "@destructive_bg_color" "0.70"};
}
button.destructive-action:not(.flat):not(:disabled):hover {
background-color: ${chip "@destructive_bg_color" "0.65"};
}
button.destructive-action:not(.flat):not(:disabled):active,
button.destructive-action:not(.flat):not(:disabled):checked {
background-color: ${chip "@destructive_bg_color" "0.55"};
}
button.suggested-action.flat, button.suggested-action.flat label {
color: #${base16.base0D};
}
button.destructive-action.flat, button.destructive-action.flat label {
color: #${base16.base08};
}
'';
};
qt.enable = true;
# No-op unless programs.zathura is on (viewers.nix enables it).
zathura.enable = true;
};
cursor = {
@@ -58,10 +215,12 @@ in
# GTK/file-manager/rofi icon theme, resolved from the JSON in
# theme.nix (per-theme `icons`, else Papirus-Dark/Light by mode).
# 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 = {
enable = true;
package = lib.mkDefault pkgs.papirus-icon-theme;
package = lib.mkDefault t.iconThemePackage;
dark = t.iconTheme;
light = t.iconTheme;
};
@@ -87,13 +246,41 @@ in
};
};
# GTK3 hides menu-item icons by default (gtk-menu-images was flipped
# off upstream in 3.10). Classic tray menus — nm-applet's Wi-Fi list,
# udiskie — are built as image menu items, so without this their icons
# silently vanish. Stylix drives the HM gtk module, so extraConfig
# merges into the same settings.ini it already writes.
gtk.gtk3.extraConfig = {
"gtk-menu-images" = 1;
# Pair with adw-gtk3-dark so the theme sheet itself is dark, not just
# the @define-color recolor (see gtk.theme.name below).
"gtk-application-prefer-dark-theme" = t.mode != "light";
};
gtk.gtk4.extraConfig."gtk-application-prefer-dark-theme" = t.mode != "light";
# #98: Stylix hardcodes gtk.theme.name = "adw-gtk3" (light CSS) for every
# polarity and only injects libadwaita colour tokens. The light adw-gtk3
# sheet assumes light chrome and hardcodes near-black button labels in
# places — on Boreal that is black text on dark dialog buttons. Force
# the matching dark/light sheet; the package still provides both names.
gtk.theme = {
package = lib.mkDefault pkgs.adw-gtk3;
name = lib.mkForce (if t.mode == "light" then "adw-gtk3" else "adw-gtk3-dark");
};
# GTK4/libadwaita and Qt6 apps decide dark vs light from the XDG
# portal's color-scheme (org.freedesktop.appearance), which
# xdg-desktop-portal-gtk sources from this gsettings key. Stylix sets
# the GTK theme and `polarity` but not this, so a light theme still
# rendered libadwaita/Qt apps dark (and vice-versa). Drive it from the
# palette mode. (programs.dconf.enable is already on system-side.)
dconf.settings."org/gnome/desktop/interface".color-scheme =
if t.mode == "light" then "prefer-light" else "prefer-dark";
dconf.settings."org/gnome/desktop/interface" = {
color-scheme =
if t.mode == "light" then "prefer-light" else "prefer-dark";
gtk-theme =
if t.mode == "light" then "adw-gtk3" else "adw-gtk3-dark";
};
};
}

View File

@@ -1,5 +1,5 @@
# swaync — notification daemon + control centre, themed from
# theme-state.json. Until this shipped, nothing rendered notify-send at
# state.json. Until this shipped, nothing rendered notify-send at
# all: the theme-switch progress toasts, the CLI's font warnings and the
# live ISO's welcome message were all invisible.
{ config, lib, ... }:
@@ -29,11 +29,14 @@ in
};
style = ''
/* Palette baked from theme-state.json */
/* Palette baked from 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 surface ${c.surface};
@define-color text ${c.text};
@define-color subtext ${c.subtext};
@define-color accent ${c.accent};
@define-color bad ${c.bad};
@@ -42,6 +45,8 @@ in
border: ${toString t.ui.borderSize}px solid alpha(@accent, 0.4);
border-radius: ${r}px;
color: @text;
font-family: "${t.fonts.ui}", "${t.fonts.mono}";
font-size: ${toString t.fonts.size}pt;
}
.notification-content .summary {
@@ -50,7 +55,7 @@ in
}
.notification-content .body {
color: @subtext;
color: @text;
}
.notification.critical {
@@ -62,21 +67,58 @@ in
border: ${toString t.ui.borderSize}px solid alpha(@accent, 0.4);
border-radius: ${r}px;
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:hover {
background: alpha(@surface, 0.6);
/* Unscoped on purpose: the floating popup wraps .notification in
a .notification-row too, and swaync's DEFAULT stylesheet (still
loaded underneath this one) paints that row dark on hover on
light themes the theme's dark @text then sat on the default's
dark hover, unreadable. The same alpha(@text) tint as
everywhere else overrides it in both surfaces. */
.notification-row:focus,
.notification-row:hover {
background: alpha(@text, 0.1);
border-radius: ${r}px;
}
/* The REAL popup-hover culprit: the notification body is one big
GTK button (.notification-default-action), and the default
sheet hovers IT to rgb(56,56,56) on light themes that put
dark @text on a dark chip (unreadable, hardware report
2026-07-18). Re-pin body + action buttons to the palette's
tint construction; hover stays a tint of @text, so it works
in both modes by construction. */
.notification-default-action,
.notification-action {
background: transparent;
color: @text;
}
.notification-default-action:hover,
.notification-action:hover {
background: alpha(@text, 0.08);
}
/* Same default-stylesheet leak: swaync ships a dark close-button
chip; re-pin both ends to the palette's tint construction. */
.close-button {
background: alpha(@text, 0.1);
color: @text;
border-radius: ${r}px;
}
.close-button:hover {
background: alpha(@text, 0.2);
color: @text;
}
.widget-title {
color: @text;
font-weight: bold;
}
.widget-title > button {
background: @surface;
background: alpha(@text, 0.1);
color: @text;
border: none;
border-radius: ${r}px;

View File

@@ -0,0 +1,52 @@
# Shared launcher for the floating terminal sheets (#139). Used by waybar.nix
# (nomarchy-calendar) and rofi.nix (the doctor sheet) — the classed Kitty
# windows hyprland.nix floats and centers.
#
# Why the size lives here and not in a window rule: Hyprland 0.55.4 silently
# ignores a *percentage* `size` rule. Measured on hardware 2026-07-16, both
# rule orders, Kitty's own memory disabled so nothing could mask it:
# `size 60% 65%` → the window keeps its requested size (no rule applied);
# `size 1536 936` → lands exactly, in either order. No `hyprctl configerrors`
# either way, which is how it survived the post-0.53 rule rewrite unnoticed.
#
# Absolute px is not a fix: "a fraction of the screen" is the whole intent, and
# one px pair cannot serve a 2560x1440 desk monitor and the 1366x768 Acer that
# is live QA (#131). A rule cannot compute it — the monitor is unknown at
# eval time — so the sheet asks for its own size instead: read the focused
# monitor here, hand Kitty the px, and let it request them. Those windows
# therefore carry float/center rules and deliberately no `size` rule.
{ pkgs }:
pkgs.writeShellScriptBin "nomarchy-term-sheet" ''
set -u
# usage: nomarchy-term-sheet <class> <width%> <height%> <cmd> [args...]
[ "$#" -ge 4 ] || { echo "usage: nomarchy-term-sheet <class> <w%> <h%> <cmd> [args...]" >&2; exit 64; }
cls="$1"; wpct="$2"; hpct="$3"; shift 3
# Hyprland reports the monitor in physical px plus the scale it renders at;
# Kitty sizes in logical px, so divide before taking the fraction. A sheet
# that opens at a clumsy size still beats no sheet, so every failure here
# falls back rather than exits: no compositor (a TTY run), no jq answer, or
# a nonsense answer all land on a size that fits the smallest panel we ship
# on.
dims=$(hyprctl monitors -j 2>/dev/null \
| ${pkgs.jq}/bin/jq -r --argjson w "$wpct" --argjson h "$hpct" '
[ .[] | select(.focused) ][0]
| select(. != null)
| (.scale // 1) as $s
| select($s > 0)
| "\((.width / $s * $w / 100) | floor) \((.height / $s * $h / 100) | floor)"' 2>/dev/null) || dims=
width=''${dims%% *}
height=''${dims##* }
case "$width$height" in
*[!0-9]*|"") width=800; height=500 ;;
esac
[ "$width" -ge 320 ] 2>/dev/null || width=800
[ "$height" -ge 240 ] 2>/dev/null || height=500
exec ${pkgs.kitty}/bin/kitty \
-o remember_window_size=no \
-o initial_window_width="$width" \
-o initial_window_height="$height" \
--class="$cls" -e "$@"
''

View File

@@ -1,31 +1,36 @@
# Nomarchy theming engine.
#
# nomarchy.stateFile (theme-state.json, inside the consuming flake) is
# nomarchy.stateFile (state.json, inside the consuming flake) is
# ingested at evaluation time — pure, because flake files are store
# paths — and exposed to every other module as `config.nomarchy.theme`.
#
# Theme changes are fully Home Manager managed: `nomarchy-theme-sync
# Theme changes are fully Home Manager managed: `nomarchy-state-sync
# apply <theme>` writes the JSON and runs `home-manager switch`, baking
# everything (Hyprland, Waybar, Ghostty, btop, Stylix) into one
# everything (Hyprland, Waybar, Kitty, btop, Stylix) into one
# read-only generation. No runtime patching, no partial states; theme
# history is generation history.
#
# The one runtime exception is the wallpaper (swww is imperative by
# nature): applied at session start, after every switch (hook below),
# and cycled instantly with `nomarchy-theme-sync bg next`.
# nature): applied at session start, after every switch (hook below), and
# after output hotplug (hyprland.nix); cycled instantly with
# `nomarchy-state-sync bg next`.
{ config, lib, pkgs, ... }:
let
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-state-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 ../state-read.nix { inherit lib; } cfg.stateFile;
# Defaults guarantee evaluation succeeds on a sparse or older state
# 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
# source even when consumed downstream.
preset = builtins.fromJSON (builtins.readFile ../../themes/tokyo-night.json);
preset = builtins.fromJSON (builtins.readFile ../../themes/boreal.json);
defaults = preset // {
fonts = {
@@ -54,16 +59,106 @@ let
# tone for kanagawa/summer-*). Each preset declares its own so a theme
# switch always replaces it (deep_merge would otherwise leave it stuck).
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).
# settings.firstBootShown: first-session welcome toast gate (#81) —
# written true after the toast fires (nomarchy-first-boot).
# 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;
firstBootShown = false;
};
};
parsed = lib.recursiveUpdate defaults themeState;
# ── Friendly eval-time validation ───────────────────────────────────
# The same schema nomarchy-state-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 state.json is invalid:
${lib.concatMapStrings (p: " ${p}\n") problems}
Fix the named field(s) in the state.json of your flake
checkout (usually ~/.nomarchy/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-state-sync
validate`. Re-applying any preset resets all appearance fields:
`nomarchy-state-sync apply boreal`.'';
# 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.
resolveColor = v: if lib.hasPrefix "#" v then v else parsed.colors.${v} or v;
# already a literal hex. Unknown roles are rejected by the field checks
# 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-state-sync validate'');
border = {
active = resolveColor parsed.border.active;
inactive = resolveColor parsed.border.inactive;
active = resolveColor checked.border.active;
inactive = resolveColor checked.border.inactive;
};
# Resolve the icon theme once and expose it on nomarchy.theme so both
@@ -73,10 +168,111 @@ let
if parsed.icons != "" then parsed.icons
else if parsed.mode == "light" then "Papirus-Light"
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 ];
};
# ── Tray-icon overrides (BACKLOG #89) ────────────────────────────────
# Some SNI apps publish an *app* IconName that the icon set renders in
# full colour (EasyEffects 8 → `com.github.wwmm.easyeffects`, Papirus'
# blue equaliser), so the icon clashes with Waybar's monochrome tray.
# We wrap the resolved set in a thin child theme that `Inherits` it and
# ships palette-coloured monochrome overrides for the offenders, then
# make that child the session icon theme: every *other* icon still
# resolves from the parent unchanged (verified — udiskie, nm-applet and
# file-manager icons all fall through). Theme-following: the fill is the
# palette `text` colour — the same hue as the bar's own glyphs — so it
# is regenerated on every switch and works under any theme/mode. A
# scalable override out-resolves the parent's fixed-size icon at every
# requested size. Adding another offender = one more SVG in scalable/apps.
overrideIconTheme = "Nomarchy-icons";
easyeffectsGlyph = pkgs.writeText "com.github.wwmm.easyeffects.svg" ''
<?xml version="1.0"?>
<svg xmlns="http://www.w3.org/2000/svg" version="1.1" viewBox="0 0 16 16">
<g style="fill:${checked.colors.text};fill-opacity:1;stroke:none">
<rect x="7.5" y="2" width="1" height="12" ry=".5"/>
<rect x="12" y="4" width="1" height="8" ry=".5"/>
<rect x="3" y="4" width="1" height="8" ry=".5"/>
<circle cx="8" cy="5" r="1.5"/>
<circle cx="12.5" cy="9" r="1.5"/>
<circle cx="3.5" cy="7.5" r="1.5"/>
</g>
</svg>
'';
overrideIndex = pkgs.writeText "index.theme" ''
[Icon Theme]
Name=${overrideIconTheme}
Comment=Nomarchy tray-icon overrides
Inherits=${iconTheme},hicolor
Directories=scalable/apps
[scalable/apps]
Context=Applications
Size=48
MinSize=8
MaxSize=512
Type=Scalable
'';
overrideIconPkg = pkgs.runCommand "nomarchy-tray-icons" { } ''
apps="$out/share/icons/${overrideIconTheme}/scalable/apps"
mkdir -p "$apps"
cp ${overrideIndex} "$out/share/icons/${overrideIconTheme}/index.theme"
cp ${easyeffectsGlyph} "$apps/com.github.wwmm.easyeffects.svg"
'';
# Child + parent joined so the session profile carries both; the child's
# index Inherits the parent by name, so lookups start in the child and
# fall through. This becomes the exposed iconTheme/-Package below.
sessionIconThemePackage = pkgs.symlinkJoin {
name = "nomarchy-session-icons";
paths = [ overrideIconPkg iconThemePackage ];
};
in
{
config = {
nomarchy.theme = parsed // { inherit iconTheme border; };
# Expose the override child as the session icon theme (it Inherits the
# resolved parent, so downstream consumers — stylix GTK, rofi — behave
# identically save for the tray overrides). `iconTheme` here is the
# child name; the parent name lives on in its index's Inherits.
nomarchy.theme = checked // {
border = border;
iconTheme = overrideIconTheme;
iconThemePackage = sessionIconThemePackage;
};
# 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 = {
# "#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 state-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" ];
};
}

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

@@ -0,0 +1,162 @@
# 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;
termSheet = import ./term-sheet.nix { inherit pkgs; };
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 ;;
upgrade-window)
# The Waybar click target. `upgrade` prompts, so it needs a terminal
# and the bar has none: it is spawned by Hyprland, whose environment has
# no $TERMINAL (that is a home.sessionVariables entry, so only login
# shells see it). A whole-swap's hand-written on-click that reached for
# $TERMINAL expanded to nothing and the click silently did nothing
# (#141). Opening our own window is the fix that cannot rot: every
# caller generated module and whole-swap alike names one env-free
# command, and a theme file stops having an opinion about terminals.
#
# A classed sheet like the calendar and doctor: a short y/N flow has no
# business taking the whole screen, and the class is what hyprland.nix
# floats it by. Smaller than either (45%x50%) this is a prompt, not a
# document.
exec ${termSheet}/bin/nomarchy-term-sheet com.nomarchy.updates 45 50 \
"$0" upgrade ;;
*) echo "usage: nomarchy-updates [check|status|upgrade|upgrade-window]" >&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

@@ -0,0 +1,51 @@
# When the keyboard-layout indicator earns a place in the bar, and how a
# whole-swap bar is held to the same answer (BACKLOG #109).
#
# Pure so checks.waybar-language can unit-test the contract without building
# a bar. Consumed by modules/home/waybar.nix.
{ lib }:
rec {
# The session's layouts are the comma-separated kb_layout.
# nomarchy.keyboard.layouts is deliberately NOT among them — hyprland.nix
# keeps it as the pool the new-keyboard picker offers first and never
# merges it into kb_layout — so listing candidates cannot by itself make
# the current layout ambiguous.
sessionLayouts = layout: lib.filter (l: l != "") (lib.splitString "," layout);
# The indicator answers "which layout am I typing in?", so it is worth bar
# space exactly when that has more than one answer.
#
# devices = declared nomarchy.keyboard.devices (submodules with .layout)
# remembered = settings.keyboard.devices from the in-flake state: what the
# new-keyboard watcher writes the moment a layout is chosen.
# These only graduate into `devices` at the next rebuild, so a
# bar reading `devices` alone stays wrong for exactly as long
# as the memory is fresh — which is when it matters most.
#
# Counting devices rather than layouts is the trap: remembering "us" for a
# device on a "us" session adds no second answer, and the rows a keyboard's
# extra HID collections leave behind are all of that kind.
show = { layout, devices ? { }, remembered ? { } }:
let
session = sessionLayouts layout;
perDevice = lib.mapAttrsToList (_: d: d.layout) devices
++ lib.attrValues remembered;
in
lib.length session > 1 || lib.any (l: ! lib.elem l session) perDevice;
# A whole-swap authors its bar in full and every one of them lists the
# language module unconditionally, so without this a single-layout setup
# showed on a swapped bar what the generated bar hides. Filter the swap
# through the one answer rather than asking four hand-written files to
# re-derive it (CONVENTIONS: the parity rule).
gate = showLanguage: bar:
if showLanguage then bar
else
lib.mapAttrs
(n: v:
if lib.hasPrefix "modules-" n && builtins.isList v
then lib.filter (m: m != "hyprland/language") v
else v)
bar;
}

View File

@@ -1,7 +1,7 @@
# Waybar — two-tier theming:
#
# 1. Default: structure, fonts, geometry AND palette baked from
# theme-state.json (colors as GTK named colors, @define-color).
# state.json (colors as GTK named colors, @define-color).
#
# 2. Whole-swap: themes with their own visual identity ship
# <themesDir>/<slug>/waybar.css (and optionally waybar.jsonc) which
@@ -11,13 +11,18 @@
let
t = config.nomarchy.theme;
termSheet = import ./term-sheet.nix { inherit pkgs; };
# Show the active-keyboard-layout indicator only when more than one layout
# is in play — multiple session layouts (comma-separated) or per-device
# overrides (nomarchy.keyboard.devices) — so single-layout bars stay clean.
showLanguage = lib.hasInfix "," config.nomarchy.keyboard.layout
|| config.nomarchy.keyboard.layouts != [ ]
|| config.nomarchy.keyboard.devices != { };
# Whether the keyboard-layout indicator is worth bar space, and the filter
# that holds a whole-swap to the same answer. Contract + rationale (and
# checks.waybar-language) live in ./waybar-language.nix.
langLib = import ./waybar-language.nix { inherit lib; };
showLanguage = langLib.show {
layout = config.nomarchy.keyboard.layout;
devices = config.nomarchy.keyboard.devices;
remembered = config.nomarchy.settings.keyboard.devices or { };
};
gateLanguage = langLib.gate showLanguage;
# Power-profile indicator (power-profiles-daemon). Both scripts self-
# gate: they exit silently unless this is a laptop (a battery is
@@ -28,8 +33,46 @@ let
# Named writeShellScriptBins (put on PATH via home.packages below) rather
# 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 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-state-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" ''
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
prof=$(powerprofilesctl get 2>/dev/null) || exit 0
case "$prof" in
@@ -51,6 +94,55 @@ let
[ -n "$next" ] && powerprofilesctl set "$next"
'';
# Opens calcurse (lightweight TUI calendar) in a floating, centered kitty
# window — Waybar clock on-click. Distinct --class → hyprland.nix float
# rules. Kitty is always installed (kitty.nix). The sheet sizes itself
# (#139: a percentage `size` rule is silently ignored) — see term-sheet.nix.
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 ${termSheet}/bin/nomarchy-term-sheet com.nomarchy.calendar 60 65 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). The tooltip names each
# active VPN (NM connection names, "Tailscale") — without it a forgotten
# tailscaled reads as "the icon is stuck". Click opens the VPN submenu.
vpnStatus = pkgs.writeShellScriptBin "nomarchy-vpn-status" ''
names=$(nmcli -t -f NAME,TYPE connection show --active 2>/dev/null \
| awk -F: '$2=="vpn"||$2=="wireguard"{print $1}')
ts=""
if command -v tailscale >/dev/null 2>&1; then
[ "$(tailscale status --json 2>/dev/null | jq -r '.BackendState // empty')" = Running ] && ts=Tailscale
fi
[ -n "$names$ts" ] || exit 0
list=$(printf '%s\n' "$names" "$ts" | grep -v '^$' | paste -sd ', ' -)
tip=$(printf 'VPN: %s\n(click to manage)' "$list" | jq -Rs 'rtrimstr("\n")')
printf '{"text":"󰦝","tooltip":%s,"class":"on"}\n' "$tip"
'';
# 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.
assetDir = config.nomarchy.themesDir + "/${t.slug}";
styleOverride = assetDir + "/waybar.css";
@@ -63,9 +155,17 @@ let
(lib.mapAttrsToList (name: value: "@define-color ${name} ${value};") t.colors);
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";
height = 34;
# 36, not 34: the active-workspace pill is inset 3px top+bottom (CSS
# below) so it can never overhang the bar's border — the extra height
# keeps the pill's text from cramping inside the inset (#152).
height = 36;
margin-top = t.ui.gapsOut;
margin-left = t.ui.gapsOut;
margin-right = t.ui.gapsOut;
@@ -75,11 +175,28 @@ let
# home-manager switch restyles the running bar without a restart.
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-right = [ "tray" "pulseaudio" "network" "cpu" "memory" "custom/powerprofile" ]
modules-right = [ "custom/recording" "idle_inhibitor" "tray" "custom/vpn" "custom/airplane" "pulseaudio" "custom/powerprofile" "custom/nightlight" ]
++ 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" = {
format = "{icon}";
@@ -92,56 +209,165 @@ let
};
clock = {
format = "{:%H:%M}";
format-alt = "{:%A %d %B %Y}";
tooltip-format = "<tt><small>{calendar}</small></tt>";
# Single module: time + short date (whole-swaps used to split these
# into clock + clock#date — one click surface for the calendar).
format = "{:%H:%M · %a %d %b}";
# Left-click → the calendar (nomarchy-calendar → calcurse in a floating
# kitty). 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
# modules-right when showLanguage (see above).
"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;
};
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-icons.default = [ "󰕿" "󰖀" "󰕾" ];
format-icons.default = [ "󰕿 " "󰖀 " "󰕾 " ];
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 = {
format-wifi = "󰤨 {essid}";
format-ethernet = "󰈀";
format-disconnected = "󰤭";
tooltip-format = "{ipaddr} via {gwaddr}";
# nm-applet sits in the tray for the GUI path; this is the
# keyboard-friendly one.
on-click = "${config.nomarchy.terminal} -e nmtui";
# Idle inhibitor (caffeine): click 󰾪 → 󰅶 to hold the screen awake —
# blocks hypridle's lock / display-off / suspend during video or a
# presentation. Waybar holds a Wayland idle-inhibit while activated;
# the state resets with the bar (deliberate — caffeine shouldn't
# survive a relogin). Summer-night's whole-swap bar had this first
# (reverse parity gap, 2026-07-04).
idle_inhibitor = {
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}%";
memory.format = "󰾆 {percentage}%";
# No network module: nm-applet lives in the tray (the GUI path), so the
# bar's wifi/ethernet indicator would just duplicate it.
battery = {
states = { warning = 25; critical = 10; };
format = "{icon} {capacity}%";
format-charging = "󰂄 {capacity}%";
format-icons = [ "󰁺" "󰁼" "󰁾" "󰂀" "󰁹" ];
# Same double-pad as pulseaudio (single space still tight on some glyphs).
format = "<span size='${toString (t.fonts.size + 2)}pt'>{icon}</span>{capacity}%";
format-charging = "<span size='${toString (t.fonts.size + 2)}pt'>󰂄 </span>{capacity}%";
format-icons = [ "󰁺 " "󰁼 " "󰁾 " "󰂀 " "󰁹 " ];
# The kernel's "Not charging" maps to waybar's Plugged state — on a
# machine with a charge cap that's the hold band doing its job
# (power.nix sets start = cap 10), which reads as "broken charger"
# without an explanation. The cap value isn't baked here: the menu
# can change it live (sysfs + state, no rebuild), so a number would
# go stale.
tooltip-format-plugged = "Plugged in, not charging held at {capacity}% by the battery charge cap\nCharging resumes ~10% below the cap · click to adjust";
# 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" = {
exec = "nomarchy-powerprofile-status";
return-type = "json";
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";
};
# Airplane mode (#104). Self-gates: plane glyph only while engaged
# (status prints nothing otherwise). Click toggles Wi-Fi+BT and
# restores prior radio state on disengage. signal 11 = instant refresh.
"custom/airplane" = {
exec = "nomarchy-airplane status";
return-type = "json";
interval = 5;
signal = 11;
on-click = "nomarchy-airplane 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 = "nomarchy-updates upgrade-window";
};
# swaync notification bell + Do-Not-Disturb state. `-swb` streams JSON
# (text/tooltip/class) on every change, so it tracks count and DND with
# 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" = {
exec = "swaync-client -swb";
return-type = "json";
@@ -152,8 +378,8 @@ let
notification = "󰂚";
dnd-none = "󰂛";
dnd-notification = "󰂛";
inhibited-none = "󰂜";
inhibited-notification = "󰂚";
inhibited-none = "󰂛";
inhibited-notification = "󰂛";
dnd-inhibited-none = "󰂛";
dnd-inhibited-notification = "󰂛";
};
@@ -167,15 +393,32 @@ let
};
generatedStyle = ''
/* Palette baked from theme-state.json */
/* Palette baked from state.json */
${colorDefs}
/* NB: this `*` reset reaches the SNI tray menus Waybar hosts too
(its stylesheet applies process-wide, at a priority user gtk.css
cannot out-rank). The menu block right below undoes the damage
keep the two in sync. Do NOT scope `*` to window#waybar instead:
the id's specificity would beat the class rules below and wreck
the bar. Whole-swap theme waybar.css files carry the same pair. */
* {
font-family: "${t.fonts.ui}", "${t.fonts.mono}";
font-size: ${toString t.fonts.size}pt;
min-height: 0;
}
/* Tray menus: undo the `*` reset same stylesheet, higher
specificity. Arrows/checks/separators are CSS-sized nodes;
min-height 0 makes them invisible. */
menu menuitem arrow { min-width: 16px; min-height: 16px; }
menu check, menu radio { min-width: 14px; min-height: 14px; }
menu separator {
min-height: 1px;
margin: 5px 0;
background: alpha(@text, 0.15);
}
window#waybar {
background: alpha(@base, 0.85);
color: @text;
@@ -183,9 +426,20 @@ let
border-radius: ${toString t.ui.rounding}px;
}
/* Dim states use the palette's @muted role on DARK themes: since
item 28b it is floor-guaranteed legible on @base (muted/base >=
2.0, gated by tools/check-theme-contrast.py). On LIGHT themes that
2.0-floor grey washes out at number size inactive workspace
numbers were unreadable on every light palette so light mode
promotes them to @subtext (>= 3.0 floor, typically 4-7:1).
Secondary-but-not-dim stays alpha(@text, 0.85). */
/* 3px vertical margin insets the active pill INSIDE the bar without
it the filled button spans the bar's full inner height and visually
overhangs the rounded border (#152). */
#workspaces button {
padding: 0 8px;
color: @muted;
margin: 3px 0;
color: ${if t.mode == "light" then "@subtext" else "@muted"};
border-radius: ${toString t.ui.rounding}px;
}
@@ -200,7 +454,7 @@ let
}
#window {
color: @subtext;
color: alpha(@text, 0.85);
padding: 0 12px;
}
@@ -209,26 +463,95 @@ let
font-weight: bold;
}
#tray, #pulseaudio, #network, #cpu, #memory, #custom-powerprofile, #language, #battery, #custom-notification {
color: @subtext;
#custom-nomarchy {
color: @accent;
font-family: Nomarchy;
font-size: ${toString (t.fonts.size + 4)}pt;
/* Wider outer padding: the bar's rounded corner curves into the
end module's box, so the edge glyphs need more room than the
uniform 10px or they look cramped (#152; power button mirrors). */
padding: 0 10px 0 14px;
}
#custom-nomarchy:hover { color: @accentAlt; }
#tray, #pulseaudio, #custom-powerprofile, #custom-nightlight, #custom-airplane, #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 { padding: 0 16px 0 10px; }
#custom-powermenu:hover { color: @bad; }
/* notifications waiting accent; Do-Not-Disturb muted bell-off */
/* 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; }
#custom-airplane.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-airplane, #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.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; }
#battery.warning { color: @warn; }
#battery.critical { color: @bad; }
#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
{
programs.waybar = lib.mkIf config.nomarchy.waybar.enable {
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-state-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
# a plain home.nix assignment. For per-theme identity, prefer the
@@ -237,7 +560,7 @@ in
# docs/OVERRIDES.md.
settings.mainBar = lib.mkDefault (
if hasConfigOverride
then builtins.fromJSON (builtins.readFile configOverride)
then gateLanguage (builtins.fromJSON (builtins.readFile configOverride))
else generatedSettings
);
@@ -248,8 +571,12 @@ in
);
};
# The power-profile helpers on PATH, so both the generated bar and the
# whole-swap themes' static waybar.jsonc can exec them by name.
# Bar helpers on PATH (whole-swap jsoncs exec them by bare 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
[ powerProfileStatus powerProfileCycle ];
[ waybarSupervisor powerProfileStatus powerProfileCycle vpnStatus doctorStatus calendarLauncher
pkgs.calcurse
pkgs.pwvucontrol
];
}

View File

@@ -1,6 +1,6 @@
# yazi — the flagship file manager: a fast, keyboard-driven TUI that
# fits the distro's identity (Ghostty's Kitty-graphics previews, rofi
# menus, everything from one JSON). Themed from theme-state.json and
# fits the distro's identity (Kitty graphics previews, rofi
# menus, everything from one JSON). Themed from state.json and
# shipped with a curated plugin set. The GUI half (Thunar, "open folder"
# handler) is nomarchy.system.fileManager on the system side.
#
@@ -92,7 +92,7 @@ in
# Theme from the palette. yazi merges this over its built-in theme,
# so only the accent-bearing UI is specified; everything else keeps
# yazi's defaults (which already follow Ghostty's ANSI colors).
# yazi's defaults (which already follow Kitty's ANSI colors).
theme = {
mgr = {
cwd = { fg = c.accent; };

View File

@@ -4,7 +4,7 @@
# nixosModules.nomarchy in flake.nix) and layer your machine specifics
# (bootloader, hostname, users, hardware) on top. Host concerns are
# deliberately NOT set here. Everything user-facing (Hyprland config,
# Waybar, Ghostty, theming) lives in modules/home.
# Waybar, Kitty, theming) lives in modules/home.
{ config, lib, pkgs, ... }:
let
@@ -20,35 +20,98 @@ let
nomarchyLogoFont = pkgs.runCommandLocal "nomarchy-logo-font" { } ''
install -Dm444 ${./branding/Nomarchy.ttf} $out/share/fonts/truetype/Nomarchy.ttf
'';
# Menu Preferences Bluetooth package writes settings.bluetooth.enable; read
# it from the state file, the only place it exists on the NixOS side (the
# hardware.nix/timezone.nix bridge). Missing/invalid JSON fails closed via
# state-read.nix rather than a raw stack. null = key absent, which
# leaves the option's own default (true) alone.
sysState =
if cfg.stateFile != null
then import ../state-read.nix { inherit lib; } cfg.stateFile
else { };
stateBluetooth =
let v = (sysState.settings or { }).bluetooth.enable or null;
in if builtins.isBool v then v else null;
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
./gen-prune.nix # #128 system+HM generation prune (14d, keep ≥3 past)
];
config = {
# The safe half of distro branding: distroName flows into
# /etc/os-release PRETTY_NAME, systemd-boot entry titles and the
# ISO boot-menu label. distroId stays "nixos" on purpose — it feeds
# DEFAULT_HOSTNAME and upstream isNixos checks (see roadmap).
# Distro branding. distroName flows into /etc/os-release PRETTY_NAME,
# systemd-boot entry titles and the ISO boot-menu label; distroId is the
# machine-readable ID (DEFAULT_HOSTNAME, lsb DISTRIB_ID, CPE name).
# 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.distroId = lib.mkDefault "nomarchy";
# No codename. Upstream hardcodes it into three fields —
# VERSION = "${release} (${codeName})", PRETTY_NAME likewise, plus
# VERSION_CODENAME (misc/version.nix) — so "Nomarchy 26.05 (Yarara)" read
# as if Yarara were *our* release name. It is nixpkgs': ours is the number,
# and the number is what tells you what you are running. Setting
# `system.nixos.codeName = ""` is the wrong lever — it renders "26.05 ()" —
# so override the assembled strings through the merge hook upstream
# provides. The empty VERSION_CODENAME is deliberate and in keeping with
# the file's own style (ANSI_COLOR="", IMAGE_ID="" …): the key cannot be
# removed through `//`, and os-release(5) makes every field optional.
# `nixos-version` still prints "(Yarara)" and is left alone on purpose —
# that command reports the *nixpkgs* release this system was built from,
# which is exactly when the codename is the honest answer.
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";
VERSION = config.system.nixos.release;
VERSION_CODENAME = "";
PRETTY_NAME = "${config.system.nixos.distroName} ${config.system.nixos.release}";
};
system.nixos.extraLSBReleaseArgs = lib.mkDefault {
LSB_VERSION = config.system.nixos.release;
DISTRIB_CODENAME = "";
DISTRIB_DESCRIPTION = "${config.system.nixos.distroName} ${config.system.nixos.release}";
};
# 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
# as a cheat sheet for the distro's own helpers.
users.motd = lib.mkDefault ''
# as a cheat sheet for the distro's own helpers. Fingerprint line only
# 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.
sys-update update inputs + rebuild the system
home-update apply home/theme changes (no sudo)
nomarchy-theme-sync apply <theme> switch the whole palette
nomarchy-pull update flake inputs (nomarchy, nixpkgs, )
nomarchy-rebuild rebuild the system (current lock)
nomarchy-home rebuild the desktop / Home Manager
nomarchy-state-sync apply <theme> switch the whole palette
nomarchy-doctor read-only health check
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
# GPU/wifi drivers, firmware, fonts, …). The custom nixpkgs-config
# type can't carry a nested mkDefault; disagree with
# `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;
environment.variables.NIXPKGS_ALLOW_UNFREE = "1";
# ── One keyboard layout everywhere, incl. the LUKS prompt ────────
# services.xserver.xkb.layout is the single source of truth for the
@@ -69,6 +132,15 @@ in
console.earlySetup = 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 ────────────────────────────────────
# Installs the binary, registers the session, wires up
# xdg-desktop-portal-hyprland. Configuration is Home Manager's job.
@@ -79,22 +151,8 @@ in
extraPortals = [ pkgs.xdg-desktop-portal-gtk ]; # file pickers, etc.
};
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}' --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;
};
};
};
# The greetd/tuigreet login screen lives in ./greeter.nix — themed
# from the state JSON (console.colors + --theme) at system rebuild.
# ── Audio: Pipewire ──────────────────────────────────────────────
security.rtkit.enable = lib.mkDefault cfg.audio.enable;
@@ -105,6 +163,10 @@ in
alsa.support32Bit = true;
pulse.enable = true;
wireplumber.enable = true;
# Prefer dock/HDMI/USB sinks when present; fall back to built-in when
# they disappear (BACKLOG #87). Rules: ./dock-audio-rules.nix.
wireplumber.extraConfig."90-nomarchy-dock-audio" =
import ./dock-audio-rules.nix;
};
# ── Desktop services ─────────────────────────────────────────────
@@ -113,6 +175,10 @@ in
services.dbus.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
# timer only — it never flashes anything on its own; applying an update
# is an explicit `fwupdmgr update`. On by default as desktop security
@@ -121,7 +187,54 @@ in
# `services.fwupd.enable = false`.
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;
};
# Self-gate on the hardware, like every other conditional bit of the
# distro. smartd's config is DEVICESCAN, and where no drive answers SMART
# it exits **17** ("Unable to monitor any SMART enabled devices") — which
# systemd records as a FAILED unit, nomarchy-doctor faithfully reports as
# a failed system unit, and Waybar renders as a red health icon. That is
# every QEMU guest (virtio exposes no SMART), most live USB sticks, and
# some eMMC — so a plain VM install greeted the user with a health warning
# about a daemon that had nothing to do (Bernardo, live ISO 2026-07-14).
#
# ExecCondition is the right lever, not SuccessExitStatus = 17: a failed
# *condition* leaves the unit **inactive** and unfailed, while exit 17
# from a machine that DOES have drives still fails loudly — which is the
# entire reason the daemon is here. `smartctl --scan` prints nothing
# exactly when smartd would find nothing, so it is the same question
# asked before the daemon can fail it.
systemd.services.smartd.serviceConfig.ExecCondition =
lib.mkIf config.services.smartd.enable
(lib.mkDefault "${pkgs.writeShellScript "smartd-any-smart-device" ''
[ -n "$(${pkgs.smartmontools}/bin/smartctl --scan)" ]
''}");
# 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;
# 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
# right for suspend (resumes from RAM, no other gate), but an encrypted
@@ -134,6 +247,13 @@ in
# system unit hooked to the sleep targets: it locks on the RAM-resume
# sleeps always, and on hibernate only when the disk is unencrypted (no
# LUKS gate to rely on). Replaces hypridle's old before_sleep_cmd.
#
# #115 suspend-then-hibernate: the first phase is RAM-resume (lock),
# then after HibernateDelaySec systemd enters pure hibernate. Without
# an unlock before that second phase, an encrypted resume would demand
# LUKS *and* still-locked hyprlock. Drop the session lock immediately
# before encrypted hibernate — LUKS is the gate; unencrypted still
# locks via the unit below on hibernate.target.
systemd.services.nomarchy-lock-before-sleep =
let
encrypted = builtins.attrNames config.boot.initrd.luks.devices != [ ];
@@ -152,6 +272,20 @@ in
};
};
systemd.services.nomarchy-unlock-before-encrypted-hibernate =
let encrypted = builtins.attrNames config.boot.initrd.luks.devices != [ ];
in lib.mkIf encrypted {
description = "Unlock session before encrypted hibernate (LUKS is the resume gate)";
before = [ "systemd-hibernate.service" ];
wantedBy = [ "hibernate.target" ];
# After lock-before-sleep on the s2h RAM phase; before the image.
after = [ "nomarchy-lock-before-sleep.service" ];
serviceConfig = {
Type = "oneshot";
ExecStart = "${config.systemd.package}/bin/loginctl unlock-sessions";
};
};
# zsh as the default login shell (the desktop's shell experience —
# starship/bat/eza/zoxide — is configured home-side in shell.nix).
# programs.zsh.enable wires /etc/zshrc, completion and /etc/shells;
@@ -163,14 +297,21 @@ in
# overrides this regardless.
users.defaultUserShell = lib.mkOverride 500 pkgs.zsh;
# The in-flake state drives the toggle; mkDefault so a hand-set
# nomarchy.system.bluetooth.enable in system.nix still pins it (the
# greeter.autoLogin shape). mkIf, not a fallback expression, so an absent
# key leaves the option default as the single source of `true`.
nomarchy.system.bluetooth.enable =
lib.mkIf (stateBluetooth != null) (lib.mkDefault stateBluetooth);
hardware.bluetooth.enable = lib.mkDefault cfg.bluetooth.enable;
services.blueman.enable = lib.mkDefault cfg.bluetooth.enable;
# ── Foreign binaries: nix-ld ─────────────────────────────────────
# An ld.so shim so dynamically-linked binaries not built for NixOS
# (downloaded tools, language servers, pip/npm-installed ELFs, the
# npx-fetched claude-code) run without manual patchelf. On by default —
# a pragmatic-desktop expectation.
# (downloaded tools, language servers, pip/npm-installed ELFs) run
# without manual patchelf. On by default — a pragmatic-desktop
# expectation.
programs.nix-ld.enable = lib.mkDefault true;
# ── Firmware ─────────────────────────────────────────────────────
@@ -231,10 +372,17 @@ 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 ────────────────────────────────────────────────────────
# 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
# (nomarchy-theme-sync warns when a configured font is missing).
# (nomarchy-state-sync warns when a configured font is missing).
fonts = {
packages = with pkgs; [
nerd-fonts.jetbrains-mono
@@ -249,6 +397,10 @@ in
# (every weight × variant) — too heavy for the ISO and closures.
nerd-fonts.mononoki
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
noto-fonts
noto-fonts-color-emoji
@@ -267,31 +419,15 @@ in
# ── Essential packages ───────────────────────────────────────────
environment.systemPackages = with pkgs; [
nomarchy-theme-sync # provided by overlays.default
nomarchy-state-sync # provided by overlays.default
nomarchy-doctor # read-only health check (System Doctor)
nomarchy-detect-hw # post-install hardware re-probe (HARDWARE.md §8)
# Friendly wrappers for the two rebuild paths (README §3). Run as
# your user: `nix flake update` must NOT run as root (libgit2
# refuses the user-owned flake repo) — sudo happens inside, only
# for the system switch.
(pkgs.writeShellScriptBin "sys-update" ''
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}" "$@"
'')
# Day-to-day lifecycle (README §3): nomarchy-pull / -rebuild / -home
# (+ legacy sys-update / sys-rebuild / home-update). Defined once in
# pkgs/nomarchy-lifecycle; also installed via HM so a home switch can
# refresh a stale system-package pull script.
pkgs.nomarchy-lifecycle
git
vim
@@ -304,6 +440,7 @@ in
wl-clipboard
grim
slurp
hyprpicker
] ++ lib.optional (cfg.snapper.enable && (config.fileSystems."/".fsType or "") == "btrfs")
# Snapshot, then rebuild — rollback material for system changes
# (theme changes don't need it; HM generations already roll back).
@@ -312,16 +449,84 @@ in
echo "This script must be run as root (use sudo)" >&2
exit 1
fi
# Prefer the path nomarchy-rebuild passes through sudo; fall back
# for hand invocations.
flake="''${NOMARCHY_PATH:-/etc/nixos}"
echo "Creating pre-rebuild snapshot..."
${pkgs.snapper}/bin/snapper -c root create \
-d "Pre-rebuild $(date +'%Y-%m-%d %H:%M:%S')" \
--cleanup-algorithm number
echo "Rebuilding..."
nixos-rebuild switch --flake /etc/nixos#default "$@"
echo "Rebuilding $flake#default ..."
nixos-rebuild switch --flake "$flake#default" "$@"
'')
# The desktop snapshot manager (browse / diff / restore / rollback over
# snapper, elevating via polkit) — what `nomarchy-menu snapshot` launches.
++ lib.optional cfg.snapper.enable pkgs.btrfs-assistant;
# snapper, elevating via polkit) — the primary `nomarchy-menu snapshot`
# 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.
boot.loader.systemd-boot.configurationLimit = lib.mkDefault 10;
@@ -331,22 +536,30 @@ in
# users without root. Harmless baseline even if the OSD is disabled.
services.udev.packages = [ pkgs.swayosd ];
# Mic-mute LED sysfs nodes (ThinkPad platform::micmute, HDA *::micmute)
# default to root-only. PipeWire mute does not drive the kernel's
# audio-micmute trigger, so nomarchy-mic-mute writes brightness itself.
# Group `video` matches the backlight udev pattern (swayosd) and the
# template login user's extraGroups.
services.udev.extraRules = lib.mkAfter ''
ACTION=="add", SUBSYSTEM=="leds", KERNEL=="*micmute*", \
RUN+="${pkgs.coreutils}/bin/chgrp video /sys/class/leds/%k/brightness /sys/class/leds/%k/trigger", \
RUN+="${pkgs.coreutils}/bin/chmod g+w /sys/class/leds/%k/brightness /sys/class/leds/%k/trigger"
'';
# ── Nix itself ───────────────────────────────────────────────────
nix = {
settings = {
experimental-features = [ "nix-command" "flakes" ];
auto-optimise-store = lib.mkDefault true;
# The downstream flake (~/.nomarchy) is meant to be a live working
# tree: nomarchy-theme-sync rewrites theme-state.json on every
# tree: nomarchy-state-sync rewrites state.json on every
# switch (and you needn't commit each tweak), so the "Git tree is
# dirty" warning fires on every rebuild and is pure noise here.
warn-dirty = lib.mkDefault false;
};
gc = {
automatic = lib.mkDefault true;
dates = lib.mkDefault "weekly";
options = lib.mkDefault "--delete-older-than 14d";
};
# Generation age+floor policy + store GC: modules/nixos/gen-prune.nix (#128).
};
};
}

View File

@@ -0,0 +1,38 @@
# WirePlumber 0.5 ALSA rules: prefer dock / external sinks over built-in
# analog when they appear (BACKLOG #87). Higher priority.session wins as
# the default node; when HDMI/USB goes away, the next-highest (usually
# the laptop speakers/headphones) becomes default again.
#
# Pure attrset so checks.dock-audio can unit-test the contract without a
# full PipeWire stack. Consumed by modules/nixos/default.nix.
#
# Priorities stay ≤1500 (WirePlumber docs: sinks default ~6001000; going
# much higher can outrank streams / BT in surprising ways).
{
"monitor.alsa.rules" = [
{
# HDMI / DisplayPort monitor audio (names vary: .hdmi-stereo,
# .hdmi-surround, HiFi__HDMI1__sink, …).
matches = [
{ "node.name" = "~alsa_output\\..*\\.hdmi-.*"; }
{ "node.name" = "~alsa_output\\..*HDMI.*"; }
{ "node.name" = "~alsa_output\\..*\\.DisplayPort.*"; }
{ "node.name" = "~alsa_output\\..*\\.dp-.*"; }
];
actions."update-props" = {
"priority.driver" = 1100;
"priority.session" = 1100;
};
}
{
# USB dock / dongle audio (Thunderbolt dock, USB-C audio, …).
matches = [
{ "node.name" = "~alsa_output\\.usb-.*"; }
];
actions."update-props" = {
"priority.driver" = 1050;
"priority.session" = 1050;
};
}
];
}

View File

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

View File

@@ -0,0 +1,41 @@
# #128 — weekly generation prune: system + Home Manager profiles.
# Policy: drop gens older than 14 days only when they are beyond the
# three most recent *past* generations (current always kept).
{ config, lib, pkgs, ... }:
{
config = {
environment.systemPackages = [ pkgs.nomarchy-gen-prune ];
# Stock nix.gc --delete-older-than has no keep-N floor and would fight
# this policy. Keep weekly store GC for dead paths only; generation
# selection is nomarchy-gen-prune's job.
nix.gc = {
automatic = lib.mkDefault true;
dates = lib.mkDefault "weekly";
# Empty options → collect unreferenced store paths only (no age-based
# profile generation wipe).
options = lib.mkDefault "";
};
systemd.services.nomarchy-gen-prune = {
description = "Prune old NixOS and Home Manager generations (14d, keep 3 past)";
serviceConfig = {
Type = "oneshot";
ExecStart = "${pkgs.nomarchy-gen-prune}/bin/nomarchy-gen-prune";
};
# After the stock store GC timer if both fire weekly.
after = [ "nix-gc.service" ];
};
systemd.timers.nomarchy-gen-prune = {
description = "Weekly Nix generation prune (Nomarchy #128)";
wantedBy = [ "timers.target" ];
timerConfig = {
OnCalendar = "weekly";
Persistent = true;
RandomizedDelaySec = "1h";
};
};
};
}

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

@@ -0,0 +1,133 @@
# Greeter — greetd/tuigreet, themed from the same 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.
#
# Auto-login is in-flake state like the rest (settings.greeter.autoLogin,
# written by System Auto-login via nomarchy-autologin below), NOT a baked
# line in system.nix: a hand-set `nomarchy.system.greeter.autoLogin` outranks
# the state default, which would leave the menu toggle flipping JSON that
# nothing reads. The installer therefore seeds the STATE on LUKS machines and
# the template keeps its example commented (templates/downstream/system.nix).
{ config, lib, pkgs, ... }:
let
cfg = config.nomarchy.system;
distroName = config.system.nixos.distroName;
sync = lib.getExe pkgs.nomarchy-state-sync;
# Menu/CLI toggle, same shape as nomarchy-autotimezone: runs as the normal
# user (it owns the flake checkout + writes the state), sudos only the
# system switch. greetd's initial_session is baked at system rebuild, so
# there is nothing to apply live — the next boot is the observable change.
nomarchy-autologin = pkgs.writeShellScriptBin "nomarchy-autologin" ''
set -e
if [ "$(id -u)" -eq 0 ]; then
echo "nomarchy-autologin: run as your normal user (it sudos the rebuild itself)" >&2
exit 1
fi
flake="''${NOMARCHY_PATH:-$HOME/.nomarchy}"
cur=$(${sync} get settings.greeter.autoLogin 2>/dev/null) || cur=null
case "''${1:-toggle}" in
on) new="\"$USER\"" ;;
off) new=null ;;
toggle) case "$cur" in null|""|None) new="\"$USER\"" ;; *) new=null ;; esac ;;
status) echo "$cur"; exit 0 ;;
*) echo "usage: nomarchy-autologin [toggle|on|off|status]" >&2; exit 64 ;;
esac
${sync} --quiet set settings.greeter.autoLogin "$new" --no-switch
notify-send "Auto-login" "Rebuilding the system" 2>/dev/null || true
sudo nixos-rebuild switch --flake "$flake#default"
if [ "$new" = null ]; then
notify-send "Auto-login off" "The greeter asks who you are on the next boot." 2>/dev/null || true
else
notify-send "Auto-login on" "Next boot goes straight to the desktop." 2>/dev/null || true
fi
'';
# Fails closed with an actionable message via state-read.nix, like
# every other stateFile consumer — a raw fromJSON here would bury a bad
# state file under a Nix stack pointing at greeter.nix.
state =
if cfg.stateFile != null
then import ../state-read.nix { inherit lib; } cfg.stateFile
else { };
# The auto-login user from the state, or null. Read here via the state file
# — NOT `config.nomarchy.settings`, which exists only on the Home Manager
# side: on NixOS that attribute is missing, and `or null` swallows the
# error, so the old default silently evaluated to null on every machine.
stateAutoLogin =
let v = (state.settings or { }).greeter.autoLogin or null;
in if builtins.isString v && v != "" then v else null;
# 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 = {
# Shipped unconditionally so the menu can turn auto-login back ON while
# it's off — the same reason nomarchy-autotimezone is unconditional.
environment.systemPackages = [ nomarchy-autologin ];
# Track the in-flake flag; mkDefault so a hand-set
# nomarchy.system.greeter.autoLogin in system.nix still wins (the
# autoTimezone pattern).
nomarchy.system.greeter.autoLogin = lib.mkDefault stateAutoLogin;
# 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;
};
};
};
};
}

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

@@ -0,0 +1,370 @@
# 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 state.json (menu toggle → next
# sys-rebuild), same bridge as autoTimezone (BACKLOG #55). Missing or
# invalid JSON fails closed (state-read.nix) instead of a raw stack.
hwState =
if config.nomarchy.system.stateFile != null
then import ../state-read.nix { inherit lib; } config.nomarchy.system.stateFile
else { };
pamFromState = (hwState.settings or { }).fingerprint.pam or false;
sync = lib.getExe pkgs.nomarchy-state-sync;
# The single fingerprint on/off switch (System Fingerprint). One state key
# for one user-facing decision — it drives login/sudo PAM here AND the
# hyprlock unlock in modules/home/idle.nix, which reads the same
# settings.fingerprint.pam. Two rebuilds, because the two live in different
# configurations: sudo the system switch (PAM), then a home switch
# (hyprlock). Same user-owns-the-flake shape as nomarchy-autotimezone.
#
# This does NOT decide whether login prompts at all — auto-login skips the
# greeter entirely, so "fingerprint on" adds the finger to whatever prompts
# actually happen (sudo, lock screen, and the greeter only when auto-login
# is off). See nomarchy-autologin in ./greeter.nix.
nomarchy-fingerprint = pkgs.writeShellScriptBin "nomarchy-fingerprint" ''
set -e
if [ "$(id -u)" -eq 0 ]; then
echo "nomarchy-fingerprint: run as your normal user (it sudos the rebuild itself)" >&2
exit 1
fi
flake="''${NOMARCHY_PATH:-$HOME/.nomarchy}"
cur=$(${sync} get settings.fingerprint.pam 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-fingerprint [toggle|on|off|status]" >&2; exit 64 ;;
esac
# Turning it ON with no enrolled finger would advertise a scan that cannot
# succeed on every prompt refuse instead, and say where to go.
if [ "$new" = true ] \
&& fprintd-list "$USER" 2>/dev/null | grep -qiE 'no fingers enrolled|No devices available'; then
notify-send "Fingerprint" "Enroll a finger first (System Fingerprint Enroll)." 2>/dev/null || true
echo "nomarchy-fingerprint: no finger enrolled run fprintd-enroll first" >&2
exit 1
fi
${sync} --quiet set settings.fingerprint.pam "$new" --no-switch
notify-send "Fingerprint" "Rebuilding" 2>/dev/null || true
sudo nixos-rebuild switch --flake "$flake#default"
home-manager switch --flake "$flake"
if [ "$new" = true ]; then
notify-send "Fingerprint on" "Password or finger at sudo, the lock screen, and the greeter." 2>/dev/null || true
else
notify-send "Fingerprint off" "Password only. Enrolled fingers are kept." 2>/dev/null || true
fi
'';
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 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 state.json `settings.fingerprint.pam` (System Fingerprint
menu) when set; otherwise false.
'';
};
parallel = lib.mkOption {
type = lib.types.bool;
default = true;
description = ''
With fingerprint PAM on, accept the password OR a fingerprint at the
same prompt (type or touch, whichever comes first) instead of stock
pam_fprintd's sequential wait-for-the-reader-then-password. Uses the
pam-fprint-grosshack module (an fprintd fork source-reviewed; every
failure path falls through to the normal password rule, so password
login can never be locked out by it). Set false for the stock
sequential behavior.
'';
};
};
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 ────────────────────────────────────────────────────
# NixOS defaults security.pam.services.*.fprintAuth to
# services.fprintd.enable — so turning on fprintd alone would enable
# finger auth for login/sudo/greetd/passwd/… even when the user (or
# the migration template) left fingerprint.pam commented off. Force
# every interactive service we care about to follow our opt-in flag.
(lib.mkIf cfg.fingerprint.enable {
services.fprintd.enable = true;
# Ships whenever a reader exists, regardless of the pam flag: the
# toggle's whole job is to turn the flag back on while it's off.
environment.systemPackages = [ nomarchy-fingerprint ];
security.pam.services = lib.genAttrs [
"login" "sudo" "su" "greetd" "hyprlock" "sshd"
"passwd" "chsh" "chfn" "chpasswd"
"polkit-1" "swaylock"
"groupadd" "groupdel" "groupmod" "groupmems"
] (_: {
fprintAuth = cfg.fingerprint.pam;
} // lib.optionalAttrs (cfg.fingerprint.pam && cfg.fingerprint.parallel) {
# Parallel mode: same rule slot as stock fprintd (so ordering —
# sufficient, before pam_unix — is inherited), different module.
# grosshack prompts for the password itself while polling the
# reader; whichever lands first wins. A typed password makes the
# rule FAIL with the token stored, and the stock
# `auth sufficient pam_unix.so … try_first_pass` right after it
# does the actual validation — password stays sufficient on its
# own, so a broken reader/fprintd can never lock login out.
rules.auth.fprintd.modulePath = lib.mkForce
"${pkgs.pam-fprint-grosshack}/lib/security/pam_fprintd_grosshack.so";
});
})
# ── 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
# with get a toggle. Everything else in the system module is set with
# lib.mkDefault, so plain NixOS options override it natively.
{ lib, ... }:
{ config, lib, ... }:
{
options.nomarchy.system = {
@@ -12,28 +12,35 @@
greeter.autoLogin = lib.mkOption {
type = lib.types.nullOr lib.types.str;
default = null;
defaultText = lib.literalExpression
"(settings.greeter.autoLogin from state.json) or null";
example = "ada";
description = ''
Log this user straight into Hyprland on boot (greetd
initial_session); logging out lands on the normal greeter.
The installer sets it on LUKS-encrypted machines the disk
passphrase already gates access, a second prompt is ceremony.
Normally you leave this alone and use System Auto-login, which
writes `settings.greeter.autoLogin` in state.json
./greeter.nix mkDefaults this option from it. The installer seeds
that state on LUKS-encrypted machines: the disk passphrase already
gates access, so a second prompt is ceremony. Setting this option by
hand pins the choice and the menu toggle can no longer move it.
'';
};
plymouth.enable = lib.mkEnableOption ''
the Nomarchy Plymouth boot splash (logo + progress + LUKS prompt),
background-tinted from theme-state.json via nomarchy.system.stateFile.
background-tinted from state.json via nomarchy.system.stateFile.
Recolors on system rebuilds theme switches don't touch the initrd'' // { default = true; };
stateFile = lib.mkOption {
type = lib.types.nullOr lib.types.path;
default = null;
example = lib.literalExpression "./theme-state.json";
example = lib.literalExpression "./state.json";
description = ''
theme-state.json for the system-side consumers (currently the
state.json for the system-side consumers (currently the
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 +51,26 @@
option'' // { default = true; };
audio.enable = lib.mkEnableOption "the Pipewire audio stack" // { default = true; };
bluetooth.enable = lib.mkEnableOption "Bluetooth support with blueman" // { default = true; };
# default stays a plain `true` here; ./default.nix mkDefaults it from
# settings.bluetooth.enable (menu Preferences Bluetooth package). Reading
# the state in the option default is the trap ROADMAP § "NixOS-side state
# bridges (#116)" documents: `config.nomarchy.settings` does not exist on
# the NixOS side, and `or true` silently swallowed that for years.
bluetooth.enable = lib.mkEnableOption "Bluetooth support with blueman" // {
default = true;
defaultText = lib.literalExpression
"(settings.bluetooth.enable from state.json) 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 ''
hourly/daily BTRFS timeline snapshots of / via snapper, plus the
@@ -92,15 +118,49 @@
batteryChargeLimit = lib.mkOption {
type = lib.types.nullOr (lib.types.ints.between 50 100);
# No state bridge at eval time, by design: ./power.nix's oneshot reads
# settings.power.batteryChargeLimit out of the live state.json
# with jq at *runtime* and prefers it over this baked value, so the
# menu applies before (and without) a rebuild. This used to read
# `config.nomarchy.settings…`, which does not exist on the NixOS side
# and so was always null — dead, but harmless precisely because the
# runtime path never depended on it (ROADMAP § state bridges, #116).
default = null;
# Dell Adaptive charge mode ignores the end threshold unless we
# also select Custom (power.nix oneshot); see Latitude 5310 QA.
example = 80;
description = ''
Stop charging at this percentage to extend battery lifespan,
where the hardware exposes a charge threshold
(/sys/class/power_supply/BAT*/charge_control_end_threshold).
null leaves charging at the firmware default. Backend-independent
(a small systemd unit writes the sysfs knob at boot), so it
works under PPD too; needs nomarchy.system.power.laptop.
where the hardware exposes charge_control_end_threshold on a
system battery (type=Battery under /sys/class/power_supply;
name-agnostic BAT0, CMB0, ).
null leaves charging at the firmware default (menu writes 100).
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.
'';
};
# #115: suspend → hibernate after 1h on battery. Default true so a
# bag-carried laptop stops draining without a menu trip; Preferences
# flips settings.power.suspendThenHibernate (state bridge). No-op
# when boot.resumeDevice is unset (no hibernate path).
suspendThenHibernate = lib.mkOption {
type = lib.types.bool;
default = true;
defaultText = lib.literalExpression
"(settings.power.suspendThenHibernate from state.json) or true";
description = ''
When true and hibernation is wired (boot.resumeDevice), idle and
undocked lid-close use systemd suspend-then-hibernate on battery:
sleep, then hibernate after HibernateDelaySec (1 hour). On AC,
plain suspend only (HibernateOnACPower=false; lid uses
HandleLidSwitchExternalPower=suspend). Toggle from System
Preferences Suspend then hibernate needs a system rebuild for
logind; nomarchy-suspend reads the live state for menu/hypridle.
'';
};
};

View File

@@ -1,10 +1,10 @@
# Plymouth boot splash — Nomarchy-branded (ported from the legacy
# iteration), background tinted from the same theme-state.json that
# iteration), background tinted from the same state.json that
# drives the desktop (nomarchy.system.stateFile, wired automatically by
# lib.mkFlake). One caveat by design: theme switches are Home
# Manager-only and never touch the initrd, so the splash follows the
# theme as of the last SYSTEM rebuild (`sys-update`), not the last
# `nomarchy-theme-sync apply`.
# `nomarchy-state-sync apply`.
{ config, lib, pkgs, ... }:
let
@@ -12,19 +12,32 @@ let
state =
if cfg.stateFile != null
then builtins.fromJSON (builtins.readFile cfg.stateFile)
then import ../state-read.nix { inherit lib; } cfg.stateFile
else { };
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
# base, light or dark: foreground glyphs → text, field/track boxes →
# surface (raised from base in both polarities), the progress fill →
# accent. The shipped art is a fixed navy that vanished on dark themes.
text = colorOf "text" "#a9b1d6";
surface = colorOf "surface" "#32344a";
accent = colorOf "accent" "#7aa2f7";
text = colorOf "text" "#D3DAE0";
surface = colorOf "surface" "#303A46";
accent = colorOf "accent" "#B79BE8";
# The keyboard hint is a footnote, not a headline: subtext, like the rest of
# the palette's secondary text (#145).
subtext = colorOf "subtext" "#97A3B2";
magick = lib.getExe' pkgs.imagemagick "magick";
# What the passphrase prompt actually types with. `services.xserver.xkb.layout`
# is this distro's single source for the layout, bridged to the console (and
# so to the initrd prompt) by console.useXkbConfig — see modules/nixos/default.nix.
# A VT loads exactly ONE keymap, so a comma list ("us,gb") means the FIRST:
# printing the raw string would lie in precisely the multi-layout case the
# label exists for.
kbdLayout = lib.head
(lib.splitString "," (config.services.xserver.xkb.layout or "us"));
# Plymouth's Window.SetBackgroundTopColor takes three floats in
# 0.01.0; the .plymouth metadata's ConsoleLogBackgroundColor takes a
# 0xRRGGBB hex. Nix has no float math: multiply, integer-divide, pad.
@@ -37,7 +50,9 @@ let
else if lib.stringLength s == 2 then "0${s}"
else s;
in "0.${padded}";
channel = off: byteToFloat (lib.fromHexString (lib.substring off 2 base));
channelOf = hex: off: byteToFloat (lib.fromHexString (lib.substring off 2 hex));
channel = channelOf base; # Window.SetBackground* (the splash base)
fgChannel = channelOf subtext; # Image.Text needs the same 0.0-1.0 floats
nomarchy-plymouth = pkgs.stdenv.mkDerivation {
pname = "nomarchy-plymouth";
@@ -52,6 +67,17 @@ let
mkdir -p "$themedir"
cp * "$themedir/"
# The keyboard glyph for the passphrase hint (#145) is plymouth's own
# literally the icon Fedora shows, since its keymap widget loads this same
# asset. Copied at build time rather than vendored: the bytes stay in
# nixpkgs' (GPL) plymouth and never enter this repo, and it cannot drift
# from the plymouth we actually run.
# chmod: store files are read-only (444) and `recolor` rewrites in place.
# unpackPhase makes the *source* writable, which is why the art below
# needs no such thing a file copied straight from the store does.
cp ${pkgs.plymouth}/share/plymouth/themes/spinner/keyboard.png "$themedir/keyboard.png"
chmod +w "$themedir/keyboard.png"
# Recolor the splash art from the palette (flat fill, alpha kept) so
# it reads on any base instead of the shipped fixed navy.
recolor() { ${magick} "$themedir/$1" -fill "#$2" -colorize 100 "$themedir/$1"; }
@@ -61,6 +87,7 @@ let
recolor entry.png ${surface} # password field box
recolor progress_box.png ${surface} # progress track
recolor progress_bar.png ${accent} # progress fill
recolor keyboard.png ${subtext} # passphrase keyboard-layout hint
# Point the .plymouth metadata into the store
sed -i "s|/usr/share/plymouth/themes/nomarchy|$themedir|g" \
@@ -72,6 +99,10 @@ let
-e 's|@BG_R@|${channel 0}|g' \
-e 's|@BG_G@|${channel 2}|g' \
-e 's|@BG_B@|${channel 4}|g' \
-e 's|@FG_R@|${fgChannel 0}|g' \
-e 's|@FG_G@|${fgChannel 2}|g' \
-e 's|@FG_B@|${fgChannel 4}|g' \
-e 's|@LAYOUT@|${kbdLayout}|g' \
"$themedir/nomarchy.script"
sed -i 's|@BG_HEX@|${base}|g' \
"$themedir/nomarchy.plymouth"

View File

@@ -3,18 +3,125 @@
Window.SetBackgroundTopColor(@BG_R@, @BG_G@, @BG_B@);
Window.SetBackgroundBottomColor(@BG_R@, @BG_G@, @BG_B@);
logo.image = Image("logo.png");
# Everything is placed by layout(), which runs on every canvas change — never
# once at parse time (#137).
#
# How the canvas works, because it is not obvious and the old code read as
# correct: the script plugin lays all heads out in ONE virtual canvas of
# max_width x max_height (the largest head), centres each display inside it
# (display->x = (max_width - width) / 2) and draws each sprite at
# (sprite.x - display.x). So Window.GetWidth() is the CANVAS width — the
# widest head, NOT head 0 — and a canvas-centred sprite lands centred on every
# head. The arithmetic below is unchanged from the original and it was always
# right. What it was not is permanent: when a head arrives or leaves the
# canvas resizes, the plugin re-centres each display, and sprites keep the
# coordinates they were given — so positions frozen at parse time end up off by
# (new_max - old_max) / 2 on EVERY head. Booting or shutting down with an
# external attached is exactly that. One monitor never resizes the canvas,
# which is why this only ever showed docked.
#
# TRAP — read before editing layout(). In plymouth script a bare assignment
# inside a function writes the GLOBAL if that name already exists globally
# (`global.foo` and a bare `foo` are the same variable; it only becomes a local
# when no global of that name exists). So a local named after the global it
# guards on silently updates that global *before* the comparison, the guard is
# then always false, and the body never runs — the whole splash renders as a
# bare background, with no error logged anywhere. Hence `cw`/`ch` below, and
# never `canvas_width = Window.GetWidth()`.
logo.original_image = Image("logo.png");
lock.image = Image("lock.png");
entry.image = Image("entry.png");
bullet.image = Image("bullet.png");
# The keyboard hint for the passphrase prompt (#145): a VT loads ONE keymap and
# knows nothing of per-device layouts, so what you type here is the console
# layout — which is worth saying out loud before three wrong tries on a disk
# nobody can read yet. Both are baked at build time by plymouth.nix.
kbd.icon_image = Image("keyboard.png");
kbd.text_image = Image.Text("@LAYOUT@", @FG_R@, @FG_G@, @FG_B@);
# Calculate scale factor to make logo ~15% of screen height
logo_scale_factor = (Window.GetHeight() * 0.15) / logo.image.GetHeight();
logo_width = logo.image.GetWidth() * logo_scale_factor;
logo_height = logo.image.GetHeight() * logo_scale_factor;
logo.image = logo.image.Scale(logo_width, logo_height);
logo.sprite = Sprite(logo.image);
logo.sprite.SetX (Window.GetWidth() / 2 - logo.image.GetWidth() / 2);
logo.sprite.SetY (Window.GetHeight() / 2 - logo.image.GetHeight() / 2);
logo.sprite = Sprite();
logo.sprite.SetOpacity (1);
entry.sprite = Sprite(entry.image);
entry.sprite.SetOpacity (0);
lock.sprite = Sprite();
lock.sprite.SetOpacity (0);
kbd.icon_sprite = Sprite();
kbd.icon_sprite.SetOpacity (0);
kbd.text_sprite = Sprite();
kbd.text_sprite.SetOpacity (0);
global.canvas_width = 0;
global.canvas_height = 0;
global.bullet_size = 7;
global.bullet_gap = 5;
fun layout ()
{
cw = Window.GetWidth();
ch = Window.GetHeight();
if (cw != global.canvas_width || ch != global.canvas_height)
{
global.canvas_width = cw;
global.canvas_height = ch;
# Logo: ~15% of canvas height, centred.
logo_scale = (ch * 0.15) / logo.original_image.GetHeight();
logo.image = logo.original_image.Scale(
logo.original_image.GetWidth() * logo_scale,
logo.original_image.GetHeight() * logo_scale);
logo.sprite.SetImage(logo.image);
logo.sprite.SetX(cw / 2 - logo.image.GetWidth() / 2);
logo.sprite.SetY(ch / 2 - logo.image.GetHeight() / 2);
# Password entry, under the logo.
entry.x = cw / 2 - entry.image.GetWidth() / 2;
entry.y = logo.sprite.GetY() + logo.image.GetHeight() + 40;
entry.sprite.SetPosition(entry.x, entry.y, 10001);
# Lock, slightly shorter than the entry, to its left.
# (source lock.png is 84x96)
lock_h = entry.image.GetHeight() * 0.8;
lock_w = 84 * (lock_h / 96);
lock.sprite.SetImage(lock.image.Scale(lock_w, lock_h));
lock.sprite.SetPosition(entry.x - lock_w - 15,
entry.y + entry.image.GetHeight() / 2 - lock_h / 2,
10001);
# Keyboard hint, centred as one icon+text group under the entry.
kbd_gap = 8;
kbd_x = cw / 2 - (kbd.icon_image.GetWidth() + kbd_gap
+ kbd.text_image.GetWidth()) / 2;
kbd_y = entry.y + entry.image.GetHeight() + 18;
kbd.icon_sprite.SetImage(kbd.icon_image);
kbd.icon_sprite.SetPosition(kbd_x, kbd_y, 10001);
kbd.text_sprite.SetImage(kbd.text_image);
kbd.text_sprite.SetPosition(
kbd_x + kbd.icon_image.GetWidth() + kbd_gap,
kbd_y + kbd.icon_image.GetHeight() / 2 - kbd.text_image.GetHeight() / 2,
10001);
# Bullets already on screen belong to the old canvas.
for (index = 0; bullet.sprites[index]; index++)
{
bullet.sprites[index].SetPosition(
entry.x + 20 + index * (global.bullet_size + global.bullet_gap),
entry.y + entry.image.GetHeight() / 2 - global.bullet_size / 2,
10002);
}
# Progress box + bar share the entry's line.
progress_box.sprite.SetPosition(
cw / 2 - progress_box.image.GetWidth() / 2,
entry.y + entry.image.GetHeight() / 2 - progress_box.image.GetHeight() / 2,
0);
progress_bar.sprite.SetPosition(
cw / 2 - progress_bar.original_image.GetWidth() / 2,
entry.y + entry.image.GetHeight() / 2
- progress_bar.original_image.GetHeight() / 2,
1);
}
}
# Use these to adjust the progress bar timing
global.fake_progress_limit = 0.7; # Target percentage for fake progress (0.0 to 1.0)
@@ -31,6 +138,10 @@ global.max_progress = 0.0; # Track the maximum progress reached to prevent back
fun refresh_callback ()
{
# Cheap: two Window.Get*() reads; layout() returns at once unless the canvas
# actually resized (a head arrived or left).
layout();
global.animation_frame++;
# Animate fake progress to limit over time with easing
@@ -91,12 +202,18 @@ fun show_password_dialog()
{
lock.sprite.SetOpacity(1);
entry.sprite.SetOpacity(1);
# The keyboard hint belongs to the prompt: it is only ever the answer to
# "what am I typing with?", so it appears and leaves with the box (#145).
kbd.icon_sprite.SetOpacity(1);
kbd.text_sprite.SetOpacity(1);
}
fun hide_password_dialog()
{
lock.sprite.SetOpacity(0);
entry.sprite.SetOpacity(0);
kbd.icon_sprite.SetOpacity(0);
kbd.text_sprite.SetOpacity(0);
for (index = 0; bullet.sprites[index]; index++)
bullet.sprites[index].SetOpacity(0);
}
@@ -121,30 +238,7 @@ fun stop_fake_progress()
#----------------------------------------- Dialogue --------------------------------
lock.image = Image("lock.png");
entry.image = Image("entry.png");
bullet.image = Image("bullet.png");
entry.sprite = Sprite(entry.image);
entry.x = Window.GetWidth()/2 - entry.image.GetWidth() / 2;
entry.y = logo.sprite.GetY() + logo.image.GetHeight() + 40;
entry.sprite.SetPosition(entry.x, entry.y, 10001);
entry.sprite.SetOpacity(0);
# Scale lock to be slightly shorter than entry field height
# Original lock is 84x96, entry height determines scale
lock_height = entry.image.GetHeight() * 0.8;
lock_scale = lock_height / 96;
lock_width = 84 * lock_scale;
scaled_lock = lock.image.Scale(lock_width, lock_height);
lock.sprite = Sprite(scaled_lock);
lock.x = entry.x - lock_width - 15;
lock.y = entry.y + entry.image.GetHeight()/2 - lock_height/2;
lock.sprite.SetPosition(lock.x, lock.y, 10001);
lock.sprite.SetOpacity(0);
# Bullet array
# Images and sprites are created at the top; every position lives in layout().
bullet.sprites = [];
fun display_normal_callback ()
@@ -206,21 +300,18 @@ Plymouth.SetDisplayPasswordFunction(display_password_callback);
progress_box.image = Image("progress_box.png");
progress_box.sprite = Sprite(progress_box.image);
progress_box.x = Window.GetWidth() / 2 - progress_box.image.GetWidth() / 2;
progress_box.y = entry.y + entry.image.GetHeight() / 2 - progress_box.image.GetHeight() / 2;
progress_box.sprite.SetPosition(progress_box.x, progress_box.y, 0);
progress_box.sprite.SetOpacity(0);
progress_bar.original_image = Image("progress_bar.png");
progress_bar.sprite = Sprite();
progress_bar.image = progress_bar.original_image.Scale(1, progress_bar.original_image.GetHeight());
progress_bar.x = Window.GetWidth() / 2 - progress_bar.original_image.GetWidth() / 2;
progress_bar.y = progress_box.y + (progress_box.image.GetHeight() - progress_bar.original_image.GetHeight()) / 2;
progress_bar.sprite.SetPosition(progress_bar.x, progress_bar.y, 1);
progress_bar.sprite.SetOpacity(0);
# First placement: everything layout() reads now exists. The refresh callback
# re-runs it, so a head arriving or leaving mid-splash moves the splash with it
# instead of stranding it against a canvas that is gone.
layout();
fun progress_callback (duration, progress)
{
global.real_progress = progress;

View File

@@ -1,17 +1,33 @@
# Active power management. One concern, one file: this is the system
# side — the power daemon (power-profiles-daemon by default, or TLP),
# thermald, and the battery charge limit. The profile *switcher* and the
# Waybar *indicator* live home-side (rofi.nix / waybar.nix); they self-
# 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
# desktops). See the roadmap in README.md.
{ config, lib, ... }:
# thermald, the battery charge limit, and suspend-then-hibernate (#115).
# The profile *switcher* and the Waybar *indicator* live home-side
# (rofi.nix / waybar.nix); they self-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 desktops). See the
# roadmap in README.md.
{ config, lib, pkgs, ... }:
let
cfg = config.nomarchy.system.power;
sysCfg = config.nomarchy.system;
ppd = cfg.backend == "ppd";
tlp = cfg.backend == "tlp";
chargeLimit = cfg.laptop && cfg.batteryChargeLimit != null;
# settings.power.suspendThenHibernate → this option (ROADMAP § state
# bridges #116). null = key absent → leave option default (true).
sysState =
if sysCfg.stateFile != null
then import ../state-read.nix { inherit lib; } sysCfg.stateFile
else { };
stateS2h =
let v = ((sysState.settings or { }).power or { }).suspendThenHibernate or null;
in if builtins.isBool v then v else null;
# Hibernate needs a resume device (installer swapfile / partition).
# Without it, offering s2h only produces a suspend that never wakes.
canHibernate = (config.boot.resumeDevice or "") != "";
s2hActive = cfg.suspendThenHibernate && canHibernate;
in
{
config = lib.mkIf cfg.enable {
@@ -32,29 +48,187 @@ in
services.power-profiles-daemon.enable = lib.mkDefault ppd;
services.tlp.enable = lib.mkDefault tlp;
# State bridge (#115 / #116): menu writes settings.power.suspendThenHibernate.
nomarchy.system.power.suspendThenHibernate =
lib.mkIf (stateS2h != null) (lib.mkDefault stateS2h);
# Smart suspend helper (hypridle + Power menu). Live state read.
environment.systemPackages = [ pkgs.nomarchy-suspend ];
# Clamshell / dock (BACKLOG #86): when the machine is "docked" in
# logind's sense (≥1 external display connected), closing the lid
# must NOT suspend — the external panel is the session. systemd's
# built-in default is already ignore; we set it explicitly so a
# downstream override or lock-bump drift is visible, and so
# checks.clamshell-logind can assert the contract.
# #115: undocked lid on battery → suspend-then-hibernate when the
# toggle is on and resume is wired; on AC → plain suspend so a
# docked-at-desk lid-close doesn't plan a hibernate. Display-profile
# "docked" layouts (eDP off) are orthogonal (nomarchy.displayProfiles).
services.logind.settings.Login = {
HandleLidSwitchDocked = lib.mkDefault "ignore";
} // lib.optionalAttrs s2hActive {
HandleLidSwitch = lib.mkDefault "suspend-then-hibernate";
HandleLidSwitchExternalPower = lib.mkDefault "suspend";
};
# 1h in suspend then hibernate; never start the countdown on AC
# (systemd ≥257 HibernateOnACPower). Only matters when something
# actually enters suspend-then-hibernate.
systemd.sleep.settings.Sleep = lib.mkIf s2hActive {
HibernateDelaySec = "1h";
HibernateOnACPower = false;
};
# thermald is Intel-only, so off unless asked (the installer enables
# it on a GenuineIntel CPU). Sits happily next to either backend.
services.thermald.enable = lib.mkDefault cfg.thermal.enable;
# 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
# the threshold directly, independent of the backend. Boot-time only:
# some firmwares reset the threshold on unplug; revisit with a udev
# hook if that bites. `-` paths that don't exist are skipped, so this
# is a clean no-op on hardware without the control.
systemd.services.nomarchy-battery-charge-limit = lib.mkIf chargeLimit {
description = "Cap battery charging at ${toString cfg.batteryChargeLimit}%";
# the threshold directly, independent of the backend. Re-applied on
# AC state changes by the udev rule below (some firmwares reset the
# threshold when the charger is unplugged).
#
# Instant menu path (user decision 2026-07-10): the threshold node is
# group-writable for `users` so nomarchy-menu can echo live without
# rebuild; this oneshot still owns boot + AC-replug re-apply. Prefer
# live 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" ];
path = [ pkgs.jq pkgs.coreutils ];
# A sustained dock/AC event storm can land SPACED starts — each run
# finishes (~1s) before the next event, so nothing coalesces and 5
# successful starts in 10s trip systemd's default start limit: the
# unit is marked failed (start-limit-hit) although every run
# succeeded (T14s, 2026-07-13; the #101 coalescing only covers
# events that arrive DURING a run). The write is idempotent and
# sub-second — exempt it from rate limiting.
unitConfig.StartLimitIntervalSec = 0;
serviceConfig = {
Type = "oneshot";
RemainAfterExit = true;
};
script = ''
for thresh in /sys/class/power_supply/BAT*/charge_control_end_threshold; do
[ -w "$thresh" ] && echo ${toString cfg.batteryChargeLimit} > "$thresh"
set -euo pipefail
# 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/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
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 start: 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 start off so udev's short RUN budget is not held. Keep the
# oneshot inactive after success and use `start`, not `restart`: a USB-C
# dock can emit a burst of Mains change events, and restart would SIGTERM
# the in-flight one-second settling pass until systemd hits its start
# limit. Concurrent starts instead coalesce safely.
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 start 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 start 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

@@ -7,12 +7,26 @@
let
cfg = config.nomarchy.services;
# Menu Preferences Printing writes settings.printing.enable; read it
# from the state file, the only place it exists on the NixOS side (the
# hardware.nix/timezone.nix bridge). Missing/invalid JSON fails closed via
# state-read.nix rather than a raw stack. null = key absent, which
# leaves the option's own default alone.
svcState =
if config.nomarchy.system.stateFile != null
then import ../state-read.nix { inherit lib; } config.nomarchy.system.stateFile
else { };
statePrinting =
let v = (svcState.settings or { }).printing.enable or null;
in if builtins.isBool v then v else null;
in
{
options.nomarchy.services = {
tailscale.enable = lib.mkEnableOption ''
Tailscale, the mesh VPN ships the daemon; authenticate once with
`sudo tailscale up`'';
Tailscale, the mesh VPN ships the daemon and makes the login user its
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 continuous file sync, running as the login user add
@@ -73,9 +87,18 @@ in
with the lmstudio/alpaca GUIs). CPU by default set
`services.ollama.acceleration` natively for GPU offload'';
# default stays a plain `false` here; the state bridge is a mkDefault
# below, from settings.printing.enable (menu Preferences Printing
# toggle) — see ROADMAP § state bridges (#116) for why the old read of
# `config.nomarchy.settings` never worked.
printing.enable = lib.mkEnableOption ''
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 = false;
defaultText = lib.literalExpression
"(settings.printing.enable from state.json) or false";
};
openrgb.enable = lib.mkEnableOption ''
the OpenRGB daemon and GUI for controlling RGB lighting on peripherals
@@ -122,8 +145,22 @@ in
};
config = lib.mkMerge [
# The in-flake state drives the toggle; mkDefault so a hand-set
# nomarchy.services.printing.enable in system.nix still pins it (the
# greeter.autoLogin shape). mkIf, not a fallback expression, so an absent
# key leaves the option default as the single source of `false`.
(lib.mkIf (statePrinting != null) {
nomarchy.services.printing.enable = lib.mkDefault statePrinting;
})
(lib.mkIf cfg.tailscale.enable {
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 {
@@ -246,6 +283,9 @@ in
nssmdns4 = 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 {

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 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 state-read.nix.
state =
if cfg.stateFile != null
then import ../state-read.nix { inherit lib; } cfg.stateFile
else { };
stateEnabled = (state.settings or { }).autoTimezone or false;
sync = lib.getExe pkgs.nomarchy-state-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;
})
];
}

94
modules/state-read.nix Normal file
View File

@@ -0,0 +1,94 @@
# Pure 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.
#
# Callers may still pass a legacy path (state.json); messages name
# whatever path they gave so a half-migrated checkout is still debuggable.
{ lib }:
path:
let
pathStr = toString path;
baseName = baseNameOf pathStr;
tip = ''
Fix:
Missing file copy the template next to your flake.nix:
templates/downstream/state.json
or regenerate from a preset:
nomarchy-state-sync apply boreal
Bad syntax / wrong shape edit ${baseName} (trailing commas
are the usual culprit) or reset with `apply` as above.
Field-level schema (colors, ui, border, ):
nomarchy-state-sync validate
Eval-time field checks live in modules/home/theme.nix.
Still on theme-state.json? Easiest is any menu write /
`nomarchy-state-sync set` it migrates and stages the rename
for you. Renaming by hand needs `git add state.json` too.
File IS on disk but eval still says missing? On a git flake only
*tracked* files exist an untracked state.json is invisible:
git add state.json'';
missingMsg = ''
Nomarchy: state file is missing:
${pathStr}
This file is required (appearance + menu settings). Add state.json to
your flake checkout git-tracked so evaluation stays pure.
${tip}'';
emptyMsg = ''
Nomarchy: state file is empty:
${pathStr}
${tip}'';
notObjectMsg = ''
Nomarchy: state file 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,124 @@
{ lib
, writeShellScriptBin
, networkmanager
, util-linux
, bluez
, coreutils
, gnugrep
, gawk
, libnotify
, procps
}:
# Runtime Wi-Fi + Bluetooth airplane mode (#104). Session-scoped state under XDG
# $XDG_RUNTIME_DIR; Waybar status self-hides when off (signal 11).
writeShellScriptBin "nomarchy-airplane" ''
set -euo pipefail
rt="''${XDG_RUNTIME_DIR:-/run/user/$(id -u)}"
state_file="$rt/nomarchy-airplane.state"
# Prefer an existing PATH prefix (test mocks, user wrappers) then the
# closed store tools so a headless check can inject fake nmcli/rfkill.
PATH=$PATH:${lib.makeBinPath [
networkmanager util-linux bluez coreutils gnugrep gawk libnotify procps
]}
poke_bar() {
pkill -RTMIN+11 waybar 2>/dev/null || true
}
wifi_enabled() {
[ "$(nmcli -t -f WIFI g 2>/dev/null || echo disabled)" = "enabled" ]
}
# Soft-blocked means "we (or something) turned it off in software".
# Hard-blocked is a laptop kill-switch we never try to fight that.
bt_soft_unblocked() {
rfkill list bluetooth 2>/dev/null | grep -q 'Soft blocked: no'
}
bt_powered() {
bluetoothctl show 2>/dev/null | grep -q 'Powered: yes'
}
# "Bluetooth was usable" = adapter present and not soft-blocked (or
# already powered). Desktops without BT leave this false.
bt_was_on() {
ls /sys/class/bluetooth/hci* >/dev/null 2>&1 || return 1
bt_powered || bt_soft_unblocked
}
engaged() { [ -f "$state_file" ]; }
engage() {
if engaged; then
notify-send -a Nomarchy "Airplane mode" "Already on." 2>/dev/null || true
exit 0
fi
wifi_prior=0
bt_prior=0
wifi_enabled && wifi_prior=1
bt_was_on && bt_prior=1
# Wi-Fi via NetworkManager so nm-applet/waybar stay consistent;
# fall back to rfkill if nmcli is missing (shouldn't happen on
# a Nomarchy desktop).
if command -v nmcli >/dev/null 2>&1; then
nmcli radio wifi off 2>/dev/null || true
else
rfkill block wlan 2>/dev/null || true
fi
# Bluetooth: soft-block the radio (covers adapters that ignore
# bluetoothctl) and ask the daemon to power off when present.
rfkill block bluetooth 2>/dev/null || true
bluetoothctl power off >/dev/null 2>&1 || true
printf 'wifi_prior=%s\nbt_prior=%s\n' "$wifi_prior" "$bt_prior" > "$state_file"
poke_bar
notify-send -a Nomarchy "Airplane mode" "On Wi-Fi and Bluetooth off." 2>/dev/null || true
}
disengage() {
if ! engaged; then
notify-send -a Nomarchy "Airplane mode" "Already off." 2>/dev/null || true
exit 0
fi
# shellcheck disable=SC1090
. "$state_file"
wifi_prior=''${wifi_prior:-0}
bt_prior=''${bt_prior:-0}
if [ "$wifi_prior" = 1 ]; then
if command -v nmcli >/dev/null 2>&1; then
nmcli radio wifi on 2>/dev/null || true
else
rfkill unblock wlan 2>/dev/null || true
fi
fi
if [ "$bt_prior" = 1 ]; then
rfkill unblock bluetooth 2>/dev/null || true
bluetoothctl power on >/dev/null 2>&1 || true
fi
rm -f "$state_file"
poke_bar
notify-send -a Nomarchy "Airplane mode" "Off prior radio state restored." 2>/dev/null || true
}
case "''${1:-toggle}" in
status)
# Waybar: plane glyph only while engaged; empty self-hide.
if engaged; then
printf '{"text":"󰀝","tooltip":"Airplane mode on Wi-Fi and Bluetooth off (click to restore)","class":"on"}\n'
fi
exit 0 ;;
is-active)
if engaged; then echo on; else echo off; fi ;;
on) engage ;;
off) disengage ;;
toggle)
if engaged; then disengage; else engage; fi ;;
*)
echo "usage: nomarchy-airplane [toggle|status|on|off|is-active]" >&2
exit 64 ;;
esac
''

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,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,452 @@
# 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 nomarchy-gen-prune (14d + keep ≥3 past system/HM gens; then reclaims store)"
else
ok "$mp has space (${use}% used, ${avail} free)"
fi
done
# ── the flake + state file ───────────────────────────────────────────
# #107: prefer state.json; theme-state.json still counts until migrated.
state_file=
if [ -f "$flake/state.json" ]; then
state_file="$flake/state.json"
elif [ -f "$flake/theme-state.json" ]; then
state_file="$flake/theme-state.json"
fi
if [ -n "$state_file" ]; then
state_base=$(basename "$state_file")
if jq empty "$state_file" 2>/dev/null; then
ok "$state_base parses"
else
bad "$state_base is not valid JSON (rebuilds will fail)" \
"nomarchy-state-sync validate (names the spot; fix it, or re-apply a theme)"
fi
if git -C "$flake" ls-files --error-unmatch "$state_base" >/dev/null 2>&1; then
ok "$state_base is git-tracked"
else
bad "$state_base is NOT git-tracked (flake evaluation can't see it)" \
"git -C $flake add $state_base"
fi
if [ "$state_base" = "theme-state.json" ]; then
warn "state file still named theme-state.json — run any menu write or" \
"nomarchy-state-sync set … to migrate to state.json"
fi
else
skip "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
# ── machine-state drift (survives migration, unseen by the flake) ────
# Worked example: systemd linger. The flake never sets it anywhere, so
# a "yes" here is leftover state from a previous OS/install, not
# something Nomarchy configured — it can cause months of divergent
# session behavior that no amount of flake auditing will explain.
linger=$(loginctl show-user "${USER:-$(id -un)}" --property=Linger --value 2>/dev/null || true)
case "$linger" in
'') skip "user linger (user unknown to logind — no session)" ;;
yes) warn "user linger is enabled but Nomarchy never sets it (machine state the flake can't see)" \
"loginctl disable-linger ${USER:-$(id -un)}" ;;
*) ok "no user linger (matches the flake)" ;;
esac
# ── 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
# ── generation prune (#128: 14d + keep ≥3 past, system + HM) ─────────
if [ -n "$(systemctl list-unit-files nomarchy-gen-prune.timer --no-legend --plain 2>/dev/null)" ]; then
if systemctl is-enabled --quiet nomarchy-gen-prune.timer 2>/dev/null; then
ok "generation prune timer is enabled (14d, keep ≥3 past)"
else
bad "nomarchy-gen-prune.timer is installed but not enabled" \
"systemctl enable --now nomarchy-gen-prune.timer"
fi
elif command -v nomarchy-gen-prune >/dev/null 2>&1; then
warn "nomarchy-gen-prune is on PATH but no timer unit" \
"rebuild the system so gen-prune.nix ships the weekly timer"
else
skip "generation prune (package not on this machine)"
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
# ── first-boot HM pre-activate (installer fail flag, BACKLOG #83) ────
# Installer writes /var/log/nomarchy-hm-preactivate.log on the target.
# If that log exists and there is still no Home Manager generation, the
# desktop never baked — print the recovery one-liner. Override log path
# with NOMARCHY_HM_PREACTIVATE_LOG for checks.doctor.
pre_log="${NOMARCHY_HM_PREACTIVATE_LOG:-/var/log/nomarchy-hm-preactivate.log}"
hm_gen_present=0
uid_name="${USER:-$(id -un 2>/dev/null || echo)}"
for d in \
"${XDG_STATE_HOME:-${HOME:-}/.local/state}/nix/profiles" \
"/nix/var/nix/profiles/per-user/${uid_name}"; do
[ -n "$d" ] || continue
if [ -e "$d/home-manager" ] \
|| ls -d "$d"/home-manager-[0-9]*-link >/dev/null 2>&1; then
hm_gen_present=1
break
fi
done
if [ ! -r "$pre_log" ]; then
skip "first-boot pre-activate (no installer log — not a failed bake)"
elif [ "$hm_gen_present" -eq 1 ]; then
ok "first-boot pre-activate: desktop generation is present"
else
bad "desktop was not pre-activated at install (no Home Manager generation)" \
"home-manager switch --flake ~/.nomarchy -b bak (details: $pre_log)"
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
# ── battery health (report-only, BACKLOG #80) ────────────────────────
# cycle_count + retained capacity (charge_* µAh or energy_* µWh). Same
# system-battery filter as charge-limit/notify. Self-gates when no
# battery or the firmware omits the attrs (desktops, bare VMs). Override
# the sysfs root with NOMARCHY_POWER_SUPPLY_ROOT for the checks.doctor
# fixture (test_power has no cycle/design attrs).
ps_root="${NOMARCHY_POWER_SUPPLY_ROOT:-/sys/class/power_supply}"
bat_health_seen=0
for bat in "$ps_root"/*/; do
[ -d "$bat" ] || continue
[ "$(cat "$bat/type" 2>/dev/null)" = Battery ] || continue
[ "$(cat "$bat/scope" 2>/dev/null || echo System)" = Device ] && continue
name=$(basename "$bat")
cycles=$(cat "$bat/cycle_count" 2>/dev/null || true)
case "$cycles" in ''|*[!0-9]*) cycles= ;; esac
# Some firmwares export 0 forever — treat as unknown, not "brand new".
if [ -n "$cycles" ] && [ "$cycles" -eq 0 ]; then cycles=; fi
full=""; design=""
if [ -r "$bat/charge_full" ] && [ -r "$bat/charge_full_design" ]; then
full=$(cat "$bat/charge_full" 2>/dev/null || true)
design=$(cat "$bat/charge_full_design" 2>/dev/null || true)
elif [ -r "$bat/energy_full" ] && [ -r "$bat/energy_full_design" ]; then
full=$(cat "$bat/energy_full" 2>/dev/null || true)
design=$(cat "$bat/energy_full_design" 2>/dev/null || true)
fi
case "$full" in ''|*[!0-9]*) full= ;; esac
case "$design" in ''|*[!0-9]*) design= ;; esac
pct=""
if [ -n "$full" ] && [ -n "$design" ] && [ "$design" -gt 0 ]; then
pct=$(( full * 100 / design ))
fi
[ -n "$cycles" ] || [ -n "$pct" ] || continue
bat_health_seen=1
detail=""
[ -n "$cycles" ] && detail="${cycles} cycles"
if [ -n "$pct" ]; then
[ -n "$detail" ] && detail="$detail, "
detail="${detail}${pct}% of design capacity"
fi
# Soft warn only — wear isn't a doctor "fix", just a heads-up.
if [ -n "$pct" ] && [ "$pct" -lt 70 ]; then
warn "$name health: $detail" \
"battery wear is normal over years — replace when runtime suffers"
else
ok "$name health: $detail"
fi
done
if [ "$bat_health_seen" -eq 0 ]; then
skip "battery health (no system battery / no cycle or design capacity attrs)"
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

@@ -0,0 +1,11 @@
# First-session "you're set" toast (nomarchy.firstBootWelcome). A package
# so checks.first-boot can exercise the gate on a minimal node — same
# pattern as nomarchy-battery-notify. libnotify + state-sync stay on PATH
# (user unit / VM shim), not runtimeInputs.
{ writeShellApplication, coreutils }:
writeShellApplication {
name = "nomarchy-first-boot";
runtimeInputs = [ coreutils ];
text = builtins.readFile ./nomarchy-first-boot.sh;
}

View File

@@ -0,0 +1,117 @@
# nomarchy-first-boot — one-shot "you're set" toast on the first session,
# plus a second self-gated toast (hardware hints, VISION § B) pointing at
# hardware-specific menu items when the matching tooling is on PATH.
# Markers are settings.firstBootShown / settings.hardwareHintsShown in the
# flake's state.json (in-checkout state; never ~/.local/state). notify-send
# and nomarchy-state-sync come from PATH so the VM check can shim them.
# Live ISO already has its own welcome (hosts/live.nix); skip there so
# users aren't double-toasted and the live seed doesn't get a sticky
# firstBootShown write every boot. This also covers the hints stage below.
# uname -n is coreutils; avoid depending on a separate hostname package.
hn=$(uname -n 2>/dev/null || true)
if [ "$hn" = nomarchy-live ]; then
exit 0
fi
# Already shown → skip straight to the hardware-hints stage below. Missing
# key / no checkout → treat as not shown (state-sync get exits non-zero
# when the key is absent).
shown=$(nomarchy-state-sync get settings.firstBootShown 2>/dev/null || true)
just_shown=
case "$shown" in
true|1|yes) : ;;
*)
# Wait for the notification daemon (swaync). On first login the unit
# can race graphical-session and get "Timeout was reached" from
# D-Bus — then either no toast, or a toast that never lands while we
# still write the marker. Retry notify-send; only persist the marker
# after a success.
body="SUPER+M menu · SUPER+T themes · SUPER+? keys
WiFi: System Network (or the bar tray)
Anything off? System Doctor"
ok=
i=0
while [ "$i" -lt 8 ]; do
if notify-send -a Nomarchy -u normal -t 0 \
"You're set" \
"$body"; then
ok=1
break
fi
i=$((i + 1))
sleep 2
done
if [ -z "$ok" ]; then
# Leave firstBootShown alone so the next login can try again. Don't
# attempt hardware hints either — they're gated on the card landing.
exit 1
fi
# Persist in the checkout so re-login is silent. --no-switch: marker
# only.
if ! nomarchy-state-sync --quiet set settings.firstBootShown true --no-switch; then
# No writable flake checkout (or tool missing) — still showed the
# toast; without a marker it may reappear next login. Don't fail
# the unit, and skip hints too (state-sync isn't writable anyway).
exit 0
fi
just_shown=1
;;
esac
# --- hardware hints (post-install hints, VISION § B) ----------------------
# At most one additional self-gated toast, once per machine, pointing at
# hardware-specific menu items when the matching tooling is on PATH.
hints_shown=$(nomarchy-state-sync get settings.hardwareHintsShown 2>/dev/null || true)
case "$hints_shown" in
true|1|yes) exit 0 ;;
esac
hint_lines=()
if command -v fwupdmgr >/dev/null 2>&1; then
hint_lines+=("SUPER+M → System Firmware to check LVFS updates")
fi
if command -v fprintd-list >/dev/null 2>&1; then
hint_lines+=("SUPER+M → System Fingerprint to enroll a finger")
fi
# No matching hardware/tooling: stay quiet and deliberately don't set the
# marker — this re-check is cheap each session, and if the tooling shows
# up later (fwupd enabled, a reader added) the hint still fires once.
if [ "${#hint_lines[@]}" -eq 0 ]; then
exit 0
fi
hints=$(printf '%s\n' "${hint_lines[@]}")
# Just showed the welcome card in this run: give swaync a moment so the
# two toasts don't collide.
if [ -n "$just_shown" ]; then
sleep 3
fi
hok=
i=0
while [ "$i" -lt 8 ]; do
if notify-send -a Nomarchy -u normal -t 0 \
"Hardware tips" \
"$hints"; then
hok=1
break
fi
i=$((i + 1))
sleep 2
done
if [ -z "$hok" ]; then
exit 1
fi
# Persist in the checkout so re-login is silent. --no-switch: marker only.
if ! nomarchy-state-sync --quiet set settings.hardwareHintsShown true --no-switch; then
# No writable flake checkout — still showed the toast; without a marker
# it may reappear next login. Don't fail the unit.
exit 0
fi

View File

@@ -0,0 +1,228 @@
{ lib
, writeShellScriptBin
, coreutils
, gnugrep
, gawk
, nix
, findutils
}:
# #128 — prune system + Home Manager generations:
# drop only if older than 14 days AND beyond the 3 most recent *past*
# gens (current + ≥3 past always kept). Store reclaim is a plain
# nix-collect-garbage after (no --delete-older-than — that would ignore
# the floor).
writeShellScriptBin "nomarchy-gen-prune" ''
set -euo pipefail
PATH=${lib.makeBinPath [ coreutils gnugrep gawk nix findutils ]}:$PATH
MAX_AGE_DAYS=''${NOMARCHY_GEN_PRUNE_MAX_AGE_DAYS:-14}
KEEP_PAST=''${NOMARCHY_GEN_PRUNE_KEEP_PAST:-3}
DRY=0
SELFTEST=0
usage() {
echo "usage: nomarchy-gen-prune [--dry-run] [--self-test]" >&2
echo " Prune NixOS system + Home Manager profile generations:" >&2
echo " delete only if older than ''${MAX_AGE_DAYS}d and beyond the" >&2
echo " ''${KEEP_PAST} most recent past gens (current always kept)." >&2
exit 64
}
for arg in "$@"; do
case "$arg" in
--dry-run) DRY=1 ;;
--self-test) SELFTEST=1 ;;
-h|--help) usage ;;
*) usage ;;
esac
done
log() { echo "nomarchy-gen-prune: $*" >&2; }
# Pure selection: stdin lines "NUM EPOCH [current]"
# stdout: generation numbers to delete (one per line).
# Always protect current + up to KEEP_PAST gens with number < current
# (highest first). Others delete only if EPOCH < now - MAX_AGE_DAYS.
select_deletions() {
local now keep_past max_age
now=$(date +%s)
keep_past=$KEEP_PAST
max_age=$MAX_AGE_DAYS
awk -v now="$now" -v keep_past="$keep_past" -v max_age="$max_age" '
NF < 2 { next }
{
num = $1 + 0
ts = $2 + 0
cur = (NF >= 3 && $3 == "current")
nums[n] = num; tss[num] = ts; if (cur) current = num; n++
}
END {
if (n == 0) exit 0
if (current == 0) {
# No marker: treat highest generation number as current.
current = nums[0]
for (i = 1; i < n; i++) if (nums[i] > current) current = nums[i]
}
# Past gens: number < current, sort desc.
pn = 0
for (i = 0; i < n; i++) {
if (nums[i] < current) { past[pn++] = nums[i] }
}
# bubble-sort past desc (small n)
for (i = 0; i < pn; i++)
for (j = i + 1; j < pn; j++)
if (past[j] > past[i]) { t = past[i]; past[i] = past[j]; past[j] = t }
protect[current] = 1
for (i = 0; i < pn && i < keep_past; i++) protect[past[i]] = 1
cutoff = now - (max_age * 86400)
for (i = 0; i < n; i++) {
g = nums[i]
if (protect[g]) continue
if (tss[g] < cutoff) print g
}
}
'
}
if [ "$SELFTEST" = 1 ]; then
# Fixture: now-fixed via epoch math in the data itself.
# current=10; past 9,8,7 protected; 6 is >14d old delete; 5 is young keep.
export KEEP_PAST=3 MAX_AGE_DAYS=14
now=$(date +%s)
old=$((now - 20 * 86400))
young=$((now - 2 * 86400))
got=$(printf '%s\n' \
"10 $young current" \
"9 $old" \
"8 $old" \
"7 $old" \
"6 $old" \
"5 $young" \
| select_deletions)
echo "$got" | grep -qx 6 || { echo "self-test: expected only 6, got: [$got]" >&2; exit 1; }
# Rarely rebuilt: only old gens delete none below floor (keep 10+9+8+7)
got=$(printf '%s\n' \
"10 $old current" \
"9 $old" \
"8 $old" \
"7 $old" \
"6 $old" \
| select_deletions)
echo "$got" | grep -qx 6 || { echo "self-test: floor failed, got: [$got]" >&2; exit 1; }
# Only 3 gens total, all old delete nothing
got=$(printf '%s\n' \
"3 $old current" \
"2 $old" \
"1 $old" \
| select_deletions)
[ -z "$got" ] || { echo "self-test: expected empty, got: [$got]" >&2; exit 1; }
log "self-test ok"
exit 0
fi
# Enumerate "NUM EPOCH [current]" for a profile symlink base.
# profile is the live link, e.g. /nix/var/nix/profiles/system
list_gens() {
local profile=$1
local dir base cur_base cur_num name num ts
[ -e "$profile" ] || [ -L "$profile" ] || return 0
dir=$(dirname -- "$profile")
base=$(basename -- "$profile")
cur_base=$(basename -- "$(readlink "$profile" 2>/dev/null || true)")
cur_num=
case "$cur_base" in
"$base"-*-link)
cur_num=''${cur_base#"$base"-}
cur_num=''${cur_num%-link}
;;
esac
# shellcheck disable=SC2035
for link in "$dir"/"$base"-*-link; do
[ -e "$link" ] || [ -L "$link" ] || continue
name=$(basename -- "$link")
case "$name" in
"$base"-*-link) ;;
*) continue ;;
esac
num=''${name#"$base"-}
num=''${num%-link}
case "$num" in *[!0-9]*|"") continue ;; esac
ts=$(stat -c %Y -- "$link" 2>/dev/null || echo 0)
if [ -n "$cur_num" ] && [ "$num" = "$cur_num" ]; then
printf '%s %s current\n' "$num" "$ts"
else
printf '%s %s\n' "$num" "$ts"
fi
done
}
prune_profile() {
local profile=$1 label=$2
local list dels
if [ ! -e "$profile" ] && [ ! -L "$profile" ]; then
log "skip $label (no profile at $profile)"
return 0
fi
list=$(list_gens "$profile" || true)
if [ -z "$list" ]; then
log "skip $label (no generation links)"
return 0
fi
dels=$(printf '%s\n' "$list" | select_deletions)
if [ -z "$dels" ]; then
log "$label: nothing to prune"
return 0
fi
# shellcheck disable=SC2086
set -- $dels
log "$label: delete generations $* (keep current + $KEEP_PAST past; age>$MAX_AGE_DAYS d)"
if [ "$DRY" = 1 ]; then
log "$label: dry-run not deleting"
return 0
fi
# nix-env needs a writeable profile; system requires root.
nix-env -p "$profile" --delete-generations "$@" \
|| log "$label: nix-env --delete-generations failed (need root for system?)"
}
prune_profile /nix/var/nix/profiles/system "system"
# Home Manager: per-user under /nix/var/nix/profiles and XDG state.
if [ -d /nix/var/nix/profiles/per-user ]; then
for u in /nix/var/nix/profiles/per-user/*; do
[ -d "$u" ] || continue
prune_profile "$u/home-manager" "home-manager($(basename "$u"))"
done
fi
# Standalone / modern HM state dir for real users' homes.
if [ -d /home ]; then
for h in /home/*; do
[ -d "$h" ] || continue
prune_profile "$h/.local/state/nix/profiles/home-manager" \
"home-manager-xdg($(basename "$h"))"
done
fi
# root's XDG state if present
prune_profile /root/.local/state/nix/profiles/home-manager "home-manager-xdg(root)"
if [ "$DRY" = 1 ]; then
log "dry-run done (no store GC)"
exit 0
fi
# Reclaim store paths freed by gen deletion no --delete-older-than
# (would re-introduce floor-free age GC on any remaining profiles).
log "nix-collect-garbage (dead store paths only)"
nix-collect-garbage || true
# Refresh systemd-boot entries so removed system gens disappear from
# the menu without waiting for the next rebuild.
if [ -x /run/current-system/bin/switch-to-configuration ]; then
log "refreshing bootloader entries"
/run/current-system/bin/switch-to-configuration boot >/dev/null 2>&1 \
|| log "bootloader refresh skipped (non-fatal)"
fi
log "done"
''

View File

@@ -13,9 +13,11 @@
, cryptsetup
, lvm2 # dmsetup
, pciutils # lspci
, usbutils # lsusb (fingerprint-reader VID probe; sysfs is the fallback)
, btrfs-progs # inspect-internal map-swapfile (hibernation offset)
, xkeyboard_config # human-readable installed layout/variant catalog
# Baked metadata — what this installer installs and where it came from.
, templateDir # templates/downstream (home.nix, theme-state.json)
, templateDir # templates/downstream (home.nix, state.json)
, nomarchyLock # the distro's flake.lock (for offline lock composition)
, hardwareModuleNames # newline-separated nixos-hardware module names
, nixpkgsPath # pinned nixpkgs source (NIX_PATH for disko's eval)
@@ -50,16 +52,21 @@ stdenvNoCC.mkDerivation {
# Empty flake registry: no network lookups for indirect refs.
echo '{"flakes":[],"version":2}' > "$share/registry.json"
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}/state.json \
"$share/template/"
install -Dm644 patch-template.py "$share/patch-template.py"
# nixos-install / nixos-generate-config / nixos-enter / nix / systemd
# tools come from the live system on purpose they must match it.
wrapProgram $out/bin/nomarchy-install \
--prefix PATH : ${lib.makeBinPath [
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_XKB_RULES ${xkeyboard_config}/share/X11/xkb/rules/base.lst \
--set NOMARCHY_NIXPKGS ${nixpkgsPath} \
--set NOMARCHY_FLAKE_URL ${lib.escapeShellArg flakeUrl} \
--set NOMARCHY_REV ${lib.escapeShellArg (toString rev)} \

View File

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

View File

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

View File

@@ -18,11 +18,129 @@
# [NOMARCHY_TIMEZONE=UTC] [NOMARCHY_LUKS_PASSPHRASE=...] \
# [NOMARCHY_LOCALE=en_US.UTF-8] [NOMARCHY_KB_LAYOUT=us] [NOMARCHY_KB_VARIANT=] \
# [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-install
# Unattended encryption is fail-closed: set a passphrase, or explicit
# NOMARCHY_NO_LUKS=1 — never silently install cleartext.
set -euo pipefail
keyboard_layout_catalog() {
local rules
rules=$(keyboard_rules_file) || return 1
awk '
/^! layout/ { in_section=1; next }
/^!/ && in_section { exit }
in_section && NF { print $1 }
' "$rules"
}
keyboard_variant_catalog() {
local layout="$1" rules
rules=$(keyboard_rules_file) || return 1
awk -v wanted_layout="$layout:" '
/^! variant/ { in_section=1; next }
/^!/ && in_section { exit }
in_section && $2 == wanted_layout { print $1 }
' "$rules"
}
keyboard_rules_file() {
local rules
for rules in "${NOMARCHY_XKB_RULES:-}" \
/run/current-system/sw/share/X11/xkb/rules/base.lst \
/usr/share/X11/xkb/rules/base.lst; do
[[ -n "$rules" && -r "$rules" ]] && { echo "$rules"; return 0; }
done
return 1
}
keyboard_layout_name() {
local layout="$1" rules
rules=$(keyboard_rules_file) || { echo "layout $layout"; return; }
awk -v wanted="$layout" '
/^! layout/ { in_section=1; next }
/^!/ && in_section { exit }
in_section && $1 == wanted {
$1=""; sub(/^[[:space:]]+/, ""); print; found=1; exit
}
END { if (!found) print "layout " wanted }
' "$rules"
}
keyboard_variant_name() {
local layout="$1" variant="$2" rules
rules=$(keyboard_rules_file) || { echo "$variant key behaviour"; return; }
awk -v wanted_layout="$layout:" -v wanted_variant="$variant" '
/^! variant/ { in_section=1; next }
/^!/ && in_section { exit }
in_section && $1 == wanted_variant && $2 == wanted_layout {
$1=""; $2=""; sub(/^[[:space:]]+/, ""); print; found=1; exit
}
END { if (!found) print wanted_variant " key behaviour" }
' "$rules"
}
keyboard_layout_choices() {
local layout
while IFS= read -r layout; do
[[ -n "$layout" ]] || continue
printf '%s\t%s\n' "$layout" "$(keyboard_layout_name "$layout")"
done < <(keyboard_layout_catalog)
}
keyboard_variant_choices() {
local layout="$1" variant
printf '(none)\tStandard keys (no special variant)\n'
while IFS= read -r variant; do
[[ -n "$variant" ]] || continue
printf '%s\t%s\n' "$variant" "$(keyboard_variant_name "$layout" "$variant")"
done < <(keyboard_variant_catalog "$layout")
}
keyboard_catalog_has() {
local wanted="$1" candidate
while IFS= read -r candidate; do
[[ "$candidate" == "$wanted" ]] && return 0
done
return 1
}
# Common trust boundary for both gum and unattended input. The picker is a
# search over these catalogs, never a text field; this second check means even
# surprising gum behaviour or a mistyped NOMARCHY_KB_* value cannot become
# generated Nix and fail much later during installation.
validate_keyboard_choice() {
local layout="$1" variant="$2" layouts variants
layouts=$(keyboard_layout_catalog) || {
echo "Could not read the installed keyboard-layout catalog." >&2
return 2
}
if [[ -z "$layouts" ]] || ! keyboard_catalog_has "$layout" <<< "$layouts"; then
echo "Unknown keyboard layout '$layout'. Choose an installed layout (NOMARCHY_KB_LAYOUT)." >&2
return 2
fi
[[ -z "$variant" ]] && return 0
variants=$(keyboard_variant_catalog "$layout") || true
if [[ -z "$variants" ]] || ! keyboard_catalog_has "$variant" <<< "$variants"; then
echo "Unknown keyboard variant '$variant' for layout '$layout'. Choose an installed variant or no variant (NOMARCHY_KB_VARIANT)." >&2
return 2
fi
}
# Small, side-effect-free interface used by the deterministic installer guard.
# It deliberately runs before root/live-ISO checks and uses the exact same
# validation function as a real install.
if [[ "${1:-}" == "--validate-keyboard" ]]; then
[[ $# -eq 3 ]] || {
echo "usage: nomarchy-install --validate-keyboard <layout> <variant>" >&2
exit 64
}
validate_keyboard_choice "$2" "$3"
exit $?
fi
# Baked in by the package wrapper:
# NOMARCHY_INSTALL_SHARE — disko-config.nix, hardware-db.sh,
# compose-lock.py, flake.lock, template/,
@@ -54,6 +172,15 @@ if [[ $EUID -ne 0 ]]; then
exec sudo --preserve-env "$0" "$@"
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."
[[ -d /sys/firmware/efi ]] \
@@ -77,7 +204,9 @@ export NIX_CONFIG="flake-registry = $SHARE/registry.json"
# substituter resolves to the TARGET store (i.e. itself), so without
# this nothing flows from the ISO and nix bootstraps gcc from source.
NIXOS_INSTALL_OPTS=()
OFFLINE=false
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."
NIX_CONFIG+=$'\nsubstituters =\nextra-substituters = daemon?trusted=1\nbuilders ='
# Must ALSO go through nixos-install as a flag: it passes its own
@@ -95,13 +224,43 @@ live_disk=""
live_src=$(findmnt -no SOURCE /iso 2>/dev/null || true)
[[ -n "$live_src" ]] && live_disk=$(lsblk -no PKNAME "$live_src" 2>/dev/null || true)
mapfile -t disks < <(lsblk -dpno NAME,SIZE,MODEL,TYPE \
| awk -v skip="/dev/${live_disk:-NONE}" \
'$NF == "disk" && $1 != skip && $1 !~ /loop|zram|sr[0-9]/ {NF--; print}')
[[ ${#disks[@]} -gt 0 ]] || fail "No installable disks found."
# Installable whole disks only (#112): drop floppy/pseudo/tiny devices that
# OVMF and some firmwares expose as TYPE=disk (e.g. /dev/fd0 first in the
# list — a blind Enter would erase nothing useful and break the install).
# Bytes via lsblk -b; 8 GiB floor catches fd0/USB crumbs; REQUIREMENTS still
# recommend ≥40 GiB for a real install.
# NOMARCHY_MIN_DISK_BYTES overrides the floor (tests / expert installs).
MIN_DISK_BYTES="${NOMARCHY_MIN_DISK_BYTES:-$((8 * 1024 * 1024 * 1024))}"
list_installable_disks() {
local skip="/dev/${live_disk:-NONE}"
local name size type model hsize
# NAME SIZE TYPE in bytes (-b); MODEL looked up separately (may contain spaces).
{
while read -r name size type; do
[[ "$type" == "disk" ]] || continue
[[ "$name" == "$skip" ]] && continue
case "$name" in
/dev/loop*|/dev/zram*|/dev/sr*|/dev/fd*|/dev/ram*|/dev/nbd*|/dev/md*)
continue ;;
esac
[[ "$size" =~ ^[0-9]+$ ]] || continue
(( size >= MIN_DISK_BYTES )) || continue
hsize=$(lsblk -dno SIZE "$name" 2>/dev/null || echo "?")
model=$(lsblk -dno MODEL "$name" 2>/dev/null | tr -s '[:space:]' ' ' | sed 's/^ //;s/ $//')
printf '%s %s %s\n' "$name" "$hsize" "${model:-}"
done < <(lsblk -dpbno NAME,SIZE,TYPE 2>/dev/null)
} | sort -k2 -h -r # largest first — never default to a tiny leftover
}
mapfile -t disks < <(list_installable_disks)
[[ ${#disks[@]} -gt 0 ]] || fail "No installable disks found (need a whole disk ≥ $(( MIN_DISK_BYTES / 1024 / 1024 / 1024 )) GiB; floppy/loop/optical excluded)."
if [[ "$UNATTENDED" == "1" ]]; then
TARGET_DISK="${NOMARCHY_DISK:?NOMARCHY_DISK required in unattended mode}"
# Unattended still rejects non-installable targets (CI footgun).
if ! printf '%s\n' "${disks[@]}" | grep -q "^${TARGET_DISK} "; then
fail "$TARGET_DISK is not an installable disk (missing, live medium, or below size floor)."
fi
else
choice=$(printf '%s\n' "${disks[@]}" \
| gum choose --header "Install Nomarchy on which disk? (EVERYTHING on it will be erased)")
@@ -110,6 +269,15 @@ fi
[[ -b "$TARGET_DISK" ]] || fail "$TARGET_DISK is not a block device."
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 ─────────────────────────────────────────────────────────
section "Disk encryption"
@@ -117,7 +285,15 @@ section "Disk encryption"
# logs you straight into the desktop (the passphrase already gates access).
LUKS_PASSPHRASE=""
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
while true; do
p1=$(gum input --password --placeholder "LUKS passphrase (min 8 chars)")
@@ -133,15 +309,20 @@ info "Encryption: $([[ $WITH_LUKS == true ]] && echo "LUKS2 (desktop auto-login)
# ─── Swap / hibernation ─────────────────────────────────────────────────
# A swapfile ≥ RAM on its own BTRFS subvolume makes hibernation possible;
# the resume offset is wired into the config below.
section "Swap & hibernation"
ram_gb=$(awk '/MemTotal/ {print int(($2 + 1048575) / 1048576)}' /proc/meminfo)
if [[ "$UNATTENDED" == "1" ]]; then
SWAP_GB="${NOMARCHY_SWAP_GB:-$ram_gb}"
else
info "A swapfile sized ≥ RAM lets this machine hibernate (suspend to disk)."
info "Default = ${ram_gb} GiB (this machine's RAM). Enter 0 for no swap (disables hibernation)."
SWAP_GB=$(gum input --value "$ram_gb" \
--header "Swap size in GiB (default ${ram_gb} = RAM, 0 = no swap)" \
--placeholder "swap size in GiB (≥ RAM enables hibernation, 0 = none)")
fi
[[ "$SWAP_GB" =~ ^[0-9]+$ ]] || fail "Swap size must be a whole number of GiB."
info "Swap: $([[ "$SWAP_GB" == "0" ]] && echo none || echo "${SWAP_GB}G swapfile (hibernation-ready)")"
info "Swap: $([[ "$SWAP_GB" == "0" ]] && echo "none (hibernation disabled)" || echo "${SWAP_GB} GiB swapfile (hibernation-ready)")"
# ─── User account ───────────────────────────────────────────────────────
section "Your account"
@@ -161,8 +342,8 @@ else
warn "Invalid username (lowercase letters, digits, - and _)."
done
while true; do
PASSWORD=$(gum input --password --placeholder "password for $USERNAME")
[[ -n "$PASSWORD" ]] || { warn "Empty password."; continue; }
PASSWORD=$(gum input --password --placeholder "password for $USERNAME (min 8 chars)")
[[ ${#PASSWORD} -ge 8 ]] || { warn "Too short (min 8 chars)."; continue; }
p2=$(gum input --password --placeholder "repeat password")
[[ "$PASSWORD" == "$p2" ]] && break
warn "Passwords don't match."
@@ -183,17 +364,51 @@ else
| sed 's/$/.UTF-8/' \
| gum filter --placeholder "language / locale (type to search)" \
|| echo en_US.UTF-8)
KB_LAYOUT=$(localectl list-x11-keymap-layouts 2>/dev/null \
| gum filter --placeholder "keyboard layout (type to search)" \
|| echo us)
KB_VARIANT=""
if [[ "$KB_LAYOUT" != "us" ]] || gum confirm --default=No "Pick a keyboard variant (intl, nodeadkeys, …)?"; then
KB_VARIANT=$( { echo "(none)"; localectl list-x11-keymap-variants "$KB_LAYOUT" 2>/dev/null; } \
| gum filter --placeholder "variant for $KB_LAYOUT (pick '(none)' for the default)" \
|| echo "(none)")
[[ "$KB_VARIANT" == "(none)" ]] && KB_VARIANT=""
keyboard_layouts=$(keyboard_layout_catalog) || \
fail "Could not read the installed keyboard-layout catalog."
[[ -n "$keyboard_layouts" ]] || \
fail "The installed keyboard-layout catalog is empty."
if gum confirm --default=yes "Use the standard US English keyboard (no special variant)?"; then
KB_LAYOUT="us"
KB_VARIANT=""
else
while true; do
if keyboard_pick=$(keyboard_layout_choices \
| gum filter --strict \
--placeholder "keyboard layout — type to search installed choices"); then
KB_LAYOUT="${keyboard_pick%%$'\t'*}"
if validate_keyboard_choice "$KB_LAYOUT" "" 2>/dev/null; then
break
fi
fi
warn "Choose one of the listed keyboard layouts; typed text is search only."
done
while true; do
if keyboard_pick=$( \
keyboard_variant_choices "$KB_LAYOUT" \
| gum filter --strict \
--placeholder "key behaviour for $KB_LAYOUT — '(none)' means the standard keys" \
); then
KB_VARIANT="${keyboard_pick%%$'\t'*}"
if [[ "$KB_VARIANT" == "(none)" ]] \
|| validate_keyboard_choice "$KB_LAYOUT" "$KB_VARIANT" 2>/dev/null; then
break
fi
fi
warn "Choose a listed key behaviour, or '(none)' for the standard keys; typed text is search only."
done
fi
fi
# `(none)` is gum's display-only sentinel, never an XKB variant. Keep the
# normalization at the common boundary so interactive and unattended installs
# cannot write it into either generated Nix file.
if [[ "$KB_VARIANT" == "(none)" ]]; then
KB_VARIANT=""
fi
keyboard_error=""
if ! keyboard_error=$(validate_keyboard_choice "$KB_LAYOUT" "$KB_VARIANT" 2>&1); then
fail "$keyboard_error"
fi
[[ "$USERNAME" =~ ^[a-z_][a-z0-9_-]*$ ]] || fail "Invalid username '$USERNAME'."
[[ -f "/usr/share/zoneinfo/$TIMEZONE" || -e "/etc/zoneinfo/$TIMEZONE" ]] \
|| timedatectl list-timezones 2>/dev/null | grep -qx "$TIMEZONE" \
@@ -201,7 +416,13 @@ fi
HASHED_PASSWORD=$(printf '%s' "$PASSWORD" | mkpasswd -m sha-512 -s)
unset PASSWORD
info "User: $USERNAME @ $HOSTNAME_ ($TIMEZONE)"
info "Locale: $LOCALE · keyboard: $KB_LAYOUT${KB_VARIANT:+ ($KB_VARIANT)}"
if [[ -n "$KB_VARIANT" ]]; then
KEYBOARD_SUMMARY="$(keyboard_layout_name "$KB_LAYOUT") [$KB_LAYOUT] — $(keyboard_variant_name "$KB_LAYOUT" "$KB_VARIANT") [$KB_VARIANT]"
else
KEYBOARD_SUMMARY="$(keyboard_layout_name "$KB_LAYOUT") [$KB_LAYOUT] — standard keys (no special variant)"
fi
info "Locale: $LOCALE"
info "Keyboard: $KEYBOARD_SUMMARY"
# ─── Hardware profile ───────────────────────────────────────────────────
section "Hardware detection"
@@ -210,6 +431,8 @@ section "Hardware detection"
source "$SHARE/hardware-db.sh"
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}"
if [[ "$hw_mode" == "none" ]]; then
info "Hardware profiles skipped."
@@ -220,8 +443,10 @@ else
if [[ -n "$detection" ]]; then
while IFS= read -r line; do
case "$line" in
MODULE\ *) HW_PROFILES+=("${line#MODULE }") ;;
DETAIL\ *) info "${line#DETAIL }" ;;
MODULE\ *) HW_PROFILES+=("${line#MODULE }") ;;
NOMARCHY-NPU\ *) NPU_VENDOR="${line#NOMARCHY-NPU }" ;;
NOMARCHY\ *) HW_NOMARCHY+=("${line#NOMARCHY }") ;;
DETAIL\ *) info "${line#DETAIL }" ;;
esac
done <<< "$detection"
fi
@@ -241,16 +466,25 @@ info "Profiles: ${HW_PROFILES[*]:-(none)}"
# ─── Review & point of no return ────────────────────────────────────────
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" \
"Disk: $TARGET_DISK (WILL BE ERASED)" \
"Encryption: $([[ $WITH_LUKS == true ]] && echo "LUKS2 + desktop auto-login" || echo none)" \
"Swap: $([[ "$SWAP_GB" == "0" ]] && echo none || echo "${SWAP_GB}G (hibernation)")" \
"Swap: $([[ "$SWAP_GB" == "0" ]] && echo "none (hibernation disabled)" || echo "${SWAP_GB} GiB swapfile (enables hibernation)")" \
"User: $USERNAME" \
"Hostname: $HOSTNAME_" \
"Timezone: $TIMEZONE" \
"Keyboard: $KEYBOARD_SUMMARY" \
"Hardware: ${HW_PROFILES[*]:-none}" \
"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
typed=$(gum input --placeholder "type the disk name ($(basename "$TARGET_DISK")) to confirm the wipe")
@@ -304,11 +538,19 @@ if [[ $WITH_LUKS == true ]]; then
unset LUKS_PASSPHRASE
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)
if ! disko --mode destroy,format,mount --yes-wipe-all-disks \
--argstr mainDrive "$TARGET_DISK" \
--arg withLuks "$WITH_LUKS" \
--argstr swapSize "${SWAP_GB}G" \
--argstr swapSize "$DISKO_SWAP_SIZE" \
"$SHARE/disko-config.nix" >"$disko_log" 2>&1; then
tail -n 30 "$disko_log"
fail "disko failed — full log: $disko_log"
@@ -317,21 +559,14 @@ rm -f "$LUKS_KEY_PATH" "$disko_log"
success "Disk partitioned and mounted at /mnt"
# Hibernation plumbing: the swapfile's physical offset goes into the
# kernel cmdline. Deactivate swap first so nixos-generate-config doesn't
# also emit a swapDevices entry (we write our own, with resume wiring).
RESUME_CONFIG=""
# kernel cmdline (patched into system.nix). Deactivate swap first so
# nixos-generate-config doesn't also emit a swapDevices entry.
resume_offset=""
root_uuid=""
if [[ "$SWAP_GB" != "0" ]]; then
swapoff -a 2>/dev/null || true
resume_offset=$(btrfs inspect-internal map-swapfile -r /mnt/swap/swapfile)
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)"
fi
@@ -345,138 +580,106 @@ nixos-generate-config --root /mnt
mv /mnt/etc/nixos/hardware-configuration.nix "$FLAKE_DIR/"
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/state.json" \
"$FLAKE_DIR/"
# home.nix is generated (not copied from the template) so the chosen
# keyboard layout reaches the Hyprland session — standalone HM cannot
# read system.nix.
cat > "$FLAKE_DIR/home.nix" <<EOF
# Your user environment. The Nomarchy desktop (Hyprland, Waybar,
# Ghostty, theming engine, Stylix) comes from homeModules.nomarchy;
# tune it via the nomarchy.* options, add your own packages and
# programs below.
{ pkgs, ... }:
# Detected hardware → flags for the patcher (safe defaults active).
has_intel=false; has_amd=false; has_fp=false
intel_guc_off=false; has_camera_ir=false
if [[ ${#HW_NOMARCHY[@]} -gt 0 ]]; then
for nm in "${HW_NOMARCHY[@]}"; do
case "$nm" in
hardware.intel.enable=true) has_intel=true ;;
hardware.intel.guc=false) intel_guc_off=true ;;
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
{
# Keyboard for the desktop session; console + LUKS prompt get the
# same layout from system.nix (xkb + console.useXkbConfig).
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=""
# JSON for patch-template.py (stdin). Hardware profiles as a JSON array.
hw_json="["
first=1
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
hw_json+="]"
cat > "$FLAKE_DIR/flake.nix" <<EOF
{
description = "$HOSTNAME_ — my Nomarchy machine";
# The only input. nixpkgs, home-manager etc. come pinned through it —
# 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
)
resume_json="null"
root_uuid_json="null"
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())))')
root_uuid_json=$(printf '%s' "$root_uuid" | python3 -c 'import json,sys; print(json.dumps(sys.stdin.read().rstrip("\n")))')
fi
# Laptop power: power-profiles-daemon ships by default; mark this a laptop
# 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, ... }:
python3 "$SHARE/patch-template.py" "$FLAKE_DIR" <<PYJSON
{
boot.loader.systemd-boot.enable = true;
boot.loader.efi.canTouchEfiVariables = true;
networking.hostName = "$HOSTNAME_";
time.timeZone = "$TIMEZONE";
i18n.defaultLocale = "$LOCALE";
# One keyboard layout everywhere: xkb is the source of truth, and the
# distro defaults (console.useXkbConfig + the systemd initrd, set in
# the nomarchy module) derive the virtual console and the LUKS
# passphrase prompt from it — so only the chosen layout is written here.
# The Hyprland session reads the same layout from nomarchy.keyboard.* in
# home.nix.
services.xserver.xkb.layout = "$KB_LAYOUT";
services.xserver.xkb.variant = "$KB_VARIANT";
# Your login user — \`username\` flows in from flake.nix automatically.
users.users.\${username} = {
isNormalUser = true;
extraGroups = [ "wheel" "networkmanager" "video" "input" ];
initialHashedPassword = "$HASHED_PASSWORD";
};
$AUTOLOGIN_CONFIG$POWER_CONFIG$RESUME_CONFIG
# Hourly/daily BTRFS timeline snapshots + nixos-rebuild-snap.
nomarchy.system.snapper.enable = true;
system.stateVersion = "26.05";
"hostname": $(printf '%s' "$HOSTNAME_" | python3 -c 'import json,sys; print(json.dumps(sys.stdin.read().rstrip("\n")))'),
"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")))'),
"locale": $(printf '%s' "$LOCALE" | python3 -c 'import json,sys; print(json.dumps(sys.stdin.read().rstrip("\n")))'),
"keyboardLayout": $(printf '%s' "$KB_LAYOUT" | python3 -c 'import json,sys; print(json.dumps(sys.stdin.read().rstrip("\n")))'),
"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")))'),
"autoLogin": $([[ $WITH_LUKS == true ]] && echo true || echo false),
"laptop": $is_laptop,
"thermald": $thermald,
"hardwareProfiles": $hw_json,
"hardware": {
"intel": $has_intel,
"intelGucOff": $intel_guc_off,
"amd": $has_amd,
"fingerprint": $has_fp,
"cameraIr": $has_camera_ir,
"nvidia": $has_nvidia,
"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)
},
"resumeOffset": $resume_json,
"rootUuid": $root_uuid_json,
"flakeUrl": $(printf '%s' "${NOMARCHY_FLAKE_URL:?NOMARCHY_FLAKE_URL unset}" | python3 -c 'import json,sys; print(json.dumps(sys.stdin.read().rstrip("\n")))')
}
EOF
PYJSON
# The flake.lock: composed offline — nomarchy is path-locked to the very
# source the ISO carries (original stays the forge URL, so a later
# `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
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."
(cd "$FLAKE_DIR" && nix --extra-experimental-features "nix-command flakes" flake lock)
fi
# A flake worktree must be git-tracked (theme-state.json especially).
# A flake worktree must be git-tracked (state.json especially).
(
cd "$FLAKE_DIR"
git init -q
@@ -485,13 +688,9 @@ fi
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
# 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"
# /etc/nixos on the installed system points at the user-owned flake.
@@ -503,7 +702,7 @@ success "Configuration written to ~$USERNAME/.nomarchy"
section "Installing (this takes a while)"
# Seed the target store with the flake source + all inputs so the first
# `nomarchy-theme-sync apply` (and the HM pre-activation below) work
# `nomarchy-state-sync apply` (and the HM pre-activation below) work
# before the machine has ever seen a network. Two steps because
# `flake archive --to` enforces signatures and locally-evaluated source
# paths have none; plain `nix copy` accepts --no-check-sigs.
@@ -543,6 +742,17 @@ fi
nixos-install --no-root-passwd "${NIXOS_INSTALL_OPTS[@]}" --flake "path:$FLAKE_DIR#default"
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
# fully themed desktop, not bare Hyprland. Best-effort: a failure here
# only costs the user one `home-manager switch` after logging in.
@@ -573,6 +783,8 @@ cat > /mnt/root/nomarchy-hm-activate.sh <<EOF
set -ex
exec > /var/log/nomarchy-hm-preactivate.log 2>&1
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
# build (default substituters — the live-side build already proved the
# no-network case) is a last-resort fallback.
@@ -590,18 +802,62 @@ nix-daemon &
daemon_pid=\$!
trap 'kill \$daemon_pid 2>/dev/null || true' EXIT
sleep 2
# Pre-activate has no graphical session: without XDG_RUNTIME_DIR, HM's
# dconfSettings step dies ("Unable to create directory /run/user/UID/dconf:
# Permission denied") and the rest of activation never runs — first boot
# then lands half-themed (#123). Create a private runtime dir the target
# user owns, and wrap activate in a session bus (dconf needs one).
uid=$USER_UID
install -d -o "$USERNAME" -g users -m 700 "/run/user/\$uid"
# BACKUP_EXT: collisions can't abort the activation (a stray
# autogenerated config gets moved aside instead).
runuser -u "$USERNAME" -- bash -lc \
"USER=$USERNAME HOME=/home/$USERNAME NIX_REMOTE=daemon HOME_MANAGER_BACKUP_EXT=bak \$out/activate"
if command -v dbus-run-session >/dev/null 2>&1; then
runuser -u "$USERNAME" -- env \
USER="$USERNAME" HOME="/home/$USERNAME" \
XDG_RUNTIME_DIR="/run/user/\$uid" \
NIX_REMOTE=daemon HOME_MANAGER_BACKUP_EXT=bak \
dbus-run-session -- "\$out/activate"
else
runuser -u "$USERNAME" -- env \
USER="$USERNAME" HOME="/home/$USERNAME" \
XDG_RUNTIME_DIR="/run/user/\$uid" \
NIX_REMOTE=daemon HOME_MANAGER_BACKUP_EXT=bak \
"\$out/activate"
fi
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"
else
warn "Desktop pre-activation failed (see /var/log/nomarchy-hm-preactivate.log"
warn "on the installed system); after first login run:"
warn " home-manager switch --flake ~/.nomarchy -b bak"
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
rm -f /mnt/root/nomarchy-hm-activate.sh

View File

@@ -0,0 +1,298 @@
#!/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 keyboard_variant(v: dict) -> str:
"""Return the XKB value, never the picker's display-only sentinel."""
variant = v.get("keyboardVariant") or ""
return "" if variant == "(none)" else variant
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",
)
# ISO build bakes the ref it was built from (main vs v1). Must match the
# option surface the installer writes into system.nix (#124).
if v.get("flakeUrl"):
text, n = re.subn(
r'inputs\.nomarchy\.url = "[^"]*";',
f'inputs.nomarchy.url = "{nix_str(v["flakeUrl"])}";',
text,
count=1,
)
if n != 1:
sys.exit("patch-template: could not patch inputs.nomarchy.url")
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(keyboard_variant(v))
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.",
]
# Auto-login is deliberately NOT emitted here — it is seeded into
# state.json instead (patch_state). A line in system.nix outranks
# the state default, which would make the System Auto-login toggle
# write JSON that nothing reads.
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(keyboard_variant(v))}";',
"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 patch_state(text: str, v: dict) -> str:
"""Seed menu-owned settings into state.json.
These live in the state rather than system.nix precisely so the menu can
change them later: a baked Nix assignment would outrank the state default
and strand the toggle. Auto-login is on when the disk is encrypted — the
LUKS passphrase already gates the machine, so a greeter password is a
second prompt for the same thing; without LUKS it stays off, where the
greeter is the only thing standing between power-on and the desktop.
"""
state = json.loads(text)
settings = state.setdefault("settings", {})
if v.get("autoLogin"):
settings.setdefault("greeter", {})["autoLogin"] = v["username"]
return json.dumps(state, indent=2) + "\n"
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,
"state.json": patch_state,
}
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,220 @@
# 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, nomarchy-what-changed }:
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}"
'';
# Opt-in sweep (settings.autoCommit — the same flag state-sync honours):
# commit EVERYTHING dirty in the machine flake before a pull/rebuild/
# home switch, so hand edits to system.nix/home.nix and lock bumps land
# in history at the moment they become live — `git log` mirrors the
# generation list. Complements nomarchy-state-sync's auto_commit, which
# is pathspec-limited to state.json on menu writes precisely so
# half-finished hand edits never ride a settings-named commit; here the
# sweep is the point, and the commit body lists what was swept. Never
# fatal — callers `|| true` so a git hiccup can't block a rebuild. Not
# in the symlinkJoin paths (internal; callers use the store path) but
# exposed as passthru.autocommit for checks.lifecycle-autocommit.
nomarchy-autocommit = writeShellScriptBin "nomarchy-autocommit" ''
${preamble "nomarchy-autocommit"}
label="''${1:-rebuild}"
[ -d "$flake/.git" ] || exit 0
command -v git >/dev/null 2>&1 || exit 0
# #107: prefer state.json; still honour a legacy theme-state.json
# until the next menu write migrates the checkout.
state="$flake/state.json"
[ -r "$state" ] || state="$flake/theme-state.json"
flag=$(${jq}/bin/jq -r '.settings.autoCommit // false' \
"$state" 2>/dev/null || true)
[ "$flag" = "true" ] || exit 0
dirty=$(git -C "$flake" status --porcelain || true)
[ -n "$dirty" ] || exit 0
g=(git -C "$flake")
if [ -z "$("''${g[@]}" config user.email 2>/dev/null || true)" ]; then
g+=(-c user.name=Nomarchy -c user.email=nomarchy@localhost)
fi
"''${g[@]}" add -A
if "''${g[@]}" commit --quiet -m "nomarchy: auto-commit before $label" \
-m "$dirty"; then
echo "nomarchy-autocommit: committed pending flake changes before $label:"
echo "$dirty" | sed 's/^/ /'
else
echo "nomarchy-autocommit: commit failed continuing without it" >&2
fi
'';
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
# With settings.autoCommit on, sweep pending hand edits into a commit
# first also keeps the ff-only pull below safe on a dirty tree.
${nomarchy-autocommit}/bin/nomarchy-autocommit pull || true
# 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
# With settings.autoCommit on, sweep pending hand edits (system.nix,
# lock bumps, ) into a commit so `git log` mirrors the generations.
${nomarchy-autocommit}/bin/nomarchy-autocommit rebuild || true
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
# One-line toast for the session (full report already on the terminal).
if command -v notify-send >/dev/null 2>&1 \
&& command -v nomarchy-what-changed >/dev/null 2>&1; then
body=$(nomarchy-what-changed --summary --diff "$before" "$after" 2>/dev/null \
| sed 's/^Diff: //' || true)
[ -n "''${body:-}" ] && notify-send -a Nomarchy "System rebuild" "$body" || true
fi
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
# With settings.autoCommit on, sweep pending hand edits (home.nix, )
# into a commit so `git log` mirrors the generations.
${nomarchy-autocommit}/bin/nomarchy-autocommit "home switch" || true
# Snapshot the active HM generation before the switch so we can nvd it.
hm_before=""
for d in \
"''${XDG_STATE_HOME:-$HOME/.local/state}/nix/profiles" \
"/nix/var/nix/profiles/per-user/$(id -un)"; do
if [ -L "$d/home-manager" ]; then
hm_before=$(readlink -f "$d/home-manager" 2>/dev/null || true)
break
fi
done
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
hm_after=""
for d in \
"''${XDG_STATE_HOME:-$HOME/.local/state}/nix/profiles" \
"/nix/var/nix/profiles/per-user/$(id -un)"; do
if [ -L "$d/home-manager" ]; then
hm_after=$(readlink -f "$d/home-manager" 2>/dev/null || true)
break
fi
done
if [ -n "$hm_before" ] && [ -n "$hm_after" ] && [ "$hm_before" != "$hm_after" ]; then
echo "nomarchy-home: what changed:"
${nvd}/bin/nvd diff "$hm_before" "$hm_after" || true
if command -v notify-send >/dev/null 2>&1 \
&& command -v nomarchy-what-changed >/dev/null 2>&1; then
body=$(nomarchy-what-changed --summary --diff "$hm_before" "$hm_after" 2>/dev/null \
| sed 's/^Diff: //' || true)
[ -n "''${body:-}" ] && notify-send -a Nomarchy "Desktop updated" "$body" || true
fi
else
echo "nomarchy-home: desktop applied."
fi
'';
# 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
nomarchy-what-changed
];
passthru.autocommit = nomarchy-autocommit;
meta = {
description = "Nomarchy machine-flake lifecycle: pull, rebuild, home";
mainProgram = "nomarchy-pull";
};
}

View File

@@ -0,0 +1,53 @@
{ lib
, stdenvNoCC
, python3
, makeWrapper
, awww
, libnotify
, git
# Shipped theme presets, baked into the package as a fallback so
# `list`/`apply` work even when $NOMARCHY_PATH has no themes/ dir.
# Already a store artifact (nomarchy-default-themes merges the repo's
# ./themes with backgrounds/ symlinked from nomarchy-wallpapers) — the
# wrapper points straight at it, no copy, so it stays in the closure
# without duplicating it into $out.
, themesDir ? null
}:
stdenvNoCC.mkDerivation {
pname = "nomarchy-state-sync";
version = "0.5.0";
src = ./.;
nativeBuildInputs = [ makeWrapper ];
buildInputs = [ python3 ];
installPhase = ''
runHook preInstall
install -Dm755 nomarchy-state-sync.py $out/bin/nomarchy-state-sync
patchShebangs $out/bin/nomarchy-state-sync
# Stdlib-only Python. home-manager is deliberately NOT wrapped in
# the rebuild must use the user's own home-manager from their PATH.
wrapProgram $out/bin/nomarchy-state-sync \
--prefix PATH : ${lib.makeBinPath [ awww libnotify git ]} \
${lib.optionalString (themesDir != null)
"--set NOMARCHY_DEFAULT_THEMES ${themesDir}"}
# #107: old CLI name kept as a symlink so muscle memory and scripts
# survive a pull (drop after the next stable release notes say so).
# After wrapProgram so it points at the wrapper, not the raw script.
ln -s nomarchy-state-sync $out/bin/nomarchy-theme-sync
runHook postInstall
'';
meta = {
description = "Nomarchy state writer + Home Manager rebuild dispatcher";
license = lib.licenses.mit;
mainProgram = "nomarchy-state-sync";
platforms = lib.platforms.linux;
};
}

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