Compare commits

...

188 Commits

Author SHA1 Message Date
374f70c76f docs(backlog): re-measure #146 — zero genuine hypridle zombies post-fix
All checks were successful
Check / eval (push) Successful in 4m15s
Lock bump / bump (push) Successful in 1m29s
Re-ran the measurement per the entry's own recipe on the dev box (user
journal persistent to 2026-05-01, full window). Post-linger-fix
(2026-07-16 ~18:00 on) every `Disconnected from pollfd id 1` is a clean
teardown bracketed by a logind `Session N logged out` + a systemd
`Stopped hypridle` within <=11s (07-16 20:13, 07-17 17:04, 07-18 13:35,
07-19 16:46). Zero genuine zombies. Live daemon healthy (ss Recv-Q 0,
NRestarts=0, active, ExecMainPID==pid). Caveat: all four were deliberate
logouts, no compositor crash post-fix yet, so the crash path is confirmed
by argument not observation, and 4 days < the "few weeks" bar. Set an
explicit close condition (~2026-08-10 if still 0). Not closing today.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-20 19:48:00 +01:00
3536abc656 test(display): de-flake the dock harness re-dock assertion (#154)
All checks were successful
Check / eval (push) Successful in 4m16s
tools/monitor-fallback.nix flaked at the #148 reload-mid-dock step: after
a lid-closed `hyprctl reload` re-lights the disabled Virtual-1 panel, the
watcher's enforce_dock_intent re-disables it — but only on the loop's 1s
idle tick, because re-enabling an existing connector emits no
`monitoradded` and the chatty IPC events `continue` past reconcile. Under
this softGL VM's load (swaync crash-loops against the absent Vulkan
device) that idle tick is starved, so the re-dock lands ~15-20s out —
right on the old timeout=15.

An instrumented run confirmed the mechanism is correct: dockMode=true is
read, `action=re-dock` fires, the panel is re-disabled (it just missed the
bound). Fix is the bound, not the watcher: 15s → 45s, in line with the
test's own 20-30s waits. Harness now green end-to-end (~61s).

Not a product bug and independent of the #150 extraction (the built
transition/keyboard tools have byte-identical store paths across it).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-20 19:40:28 +01:00
baf2cd2e13 refactor(display): extract the shared display-transition + keyboard tools (#150)
All checks were successful
Check / eval (push) Successful in 4m29s
The full nomarchy-display-transition (dock/undock/enable — logging, the
lid-inhibitor gate, restore_keyboards, the failure dump) and the
nomarchy-keyboard-layout helper it calls move out of hyprland.nix into
leaf files (modules/home/display-transition.nix, keyboard-tool.nix),
imported by both hyprland.nix and idle.nix.

This kills the lossy ~22-line mini idle.nix carried under the same binary
name to dodge a circular import. The hypridle wake path (after_sleep /
DPMS on-resume) now runs the real undock — restore_keyboards after a
reload and the evidence dump on enable-failure — instead of a bare
keyword+reload, and the two copies can no longer drift.

Behaviour on the hyprland side is unchanged: the built
nomarchy-display-transition and nomarchy-keyboard-layout have
byte-identical store paths on HEAD vs this change (verbatim extraction).

Verify: all four files parse; home activationPackage + system toplevel
build; checks.docking-ux green (bash -n + safety-feature greps on the
extracted scripts). The tools/monitor-fallback.nix dock harness is red on
main already (the #148 reload-mid-dock assertion — identical failure on
clean HEAD; filed BACKLOG #154), so it could not gate this. The idle
wake-path behaviour change (full undock, incl. dock-intent clearing) is
V3 pending: on-hardware suspend/resume + zero-output rescue queued in
HARDWARE-QUEUE.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-20 19:17:57 +01:00
nomarchy-bump
dbe07ea429 chore(lock): scheduled upstream bump
• Updated input 'nixpkgs-unstable':
    'github:NixOS/nixpkgs/61b7c44c4073f0b827768aff0049561b5110ea5a?narHash=sha256-12KrbMiWLcf8m7pCvAtZh1ZrgF85ZXDXvfR/fWTKy84%3D' (2026-07-18)
  → 'github:NixOS/nixpkgs/241313f4e8e508cb9b13278c2b0fa25b9ca27163?narHash=sha256-vlHUuqAcbcH2RKmHbPiuQzbv1pnzzavXnI62RD0bqCU%3D' (2026-07-19)

Gate: nix flake check --no-build (eval tier — see bump.yml header).
2026-07-20 17:49:55 +00:00
fdf9790a73 chore(ci): restore bump schedule to 05:17 UTC
All checks were successful
Check / eval (push) Successful in 4m18s
Revert the temporary 17:39 test time now that the manual run has been
kicked off; keep the daily cadence.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-20 18:38:55 +01:00
5b95f6444f chore(ci): run the lock bump daily instead of weekly
Some checks failed
Check / eval (push) Has been cancelled
Move bump.yml's schedule from Mondays to every day (temporarily 17:39
UTC to trigger a run shortly). Sync the ROADMAP cadence note + shipped
log to match.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-20 18:37:27 +01:00
nomarchy-bump
757fa873b8 chore(lock): scheduled upstream bump
• Updated input 'nixpkgs':
    'github:NixOS/nixpkgs/293d6abedf0478e681a4dfcfcb35b30fc796a32f?narHash=sha256-DtoqIqM7VkR6NxAkcLpMwmi02USwWb3JdmNGLyhthc0%3D' (2026-07-17)
  → 'github:NixOS/nixpkgs/fd1462031fdee08f65fd0b4c6b64e22239a77870?narHash=sha256-n3gKTBIV4ZA5VQpUakffBe3KGu4%2BmhPoA34rrqS0GkA%3D' (2026-07-19)

Gate: nix flake check --no-build (eval tier — see bump.yml header).
2026-07-20 05:23:31 +00:00
0cd9ace669 flake bumo
All checks were successful
Check / eval (push) Successful in 4m22s
Lock bump / bump (push) Successful in 6m57s
2026-07-19 17:27:50 +01:00
3abeccf071 fix(gtk): scope the #98 button-label pin to dialogs — it was repainting the bar
All checks were successful
Check / eval (push) Successful in 4m26s
The bare `button, button label` selectors in the #98 safety net load
into every GTK3 process, including Waybar, and a rule matching the label
node outranks the color waybar.css inherits onto `#workspaces button` —
so workspace digits always rendered the gtk.css text color captured at
bar start, never the theme's @muted dots / @base-on-accent active pill
(the pair check-theme-contrast.py gates). With the bar never restarting
on switch (previous commit) the pin also went stale: all three 2026-07-18
screenshots carried kiln-clay's @text #39302A across three dark themes.
Scoped the enabled+disabled pins to the dialog selectors #98 actually
targeted; forcing adw-gtk3-dark remains the primary #98 fix, and the
sheet's own bare `label` rule sets only caret-color, so inheritance
carries the bar colors again.

Verified live on Newton (V3-grade): under the scoped gtk.css, boreal
renders the designed digits — @base #21272F on the @accent pill, @muted
#5E6A78 inactives — pixel-sampled from a bar spawned on a headless
output. V1: HM generation builds; bare selectors absent from the built
gtk.css, dialog-scoped ones present. Journal, hardware-queue (deploy
round-trip) and ROADMAP follow-up ride along.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-19 11:04:49 +01:00
76941c55b7 fix(waybar): bar pokes missed the wrapped process — switches never touched the bar
nixpkgs wraps waybar, so the running comm is `.waybar-wrapped`:
`pkill -x waybar` (state-sync restart/SIGUSR2 branches, tz-watch,
updates) matched nothing — a theme switch neither restarted nor reloaded
the bar, which then kept the gtk.css of whatever theme it started under
(the stale workspace-digit colors reported 2026-07-18; companion gtk fix
in the next commit). The unanchored pokes (recording, airplane,
theme-shot) had the opposite bug: `waybar` substring-matches the
supervisor's `nomarchy-waybar` comm too, and an unhandled RTMIN
terminates its bash — the pokes were killing the crash guard. All seven
sites now use `pkill … -x 'waybar|\.waybar-wrapped'`.
state-sync 0.5.1 → 0.5.2.

Verified live on Newton (V3-grade): new pattern restarts the bar under
the supervisor (fresh pid, supervisor alive); RTMIN+10 poke reaches the
bar, supervisor survives. V0 `nix flake check --no-build` + py_compile;
V1 HM generation + system toplevel build.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-19 11:04:08 +01:00
f658b0087e feat(autotheme): fire at the configured sunrise/sunset, not a 15-min poll
All checks were successful
Check / eval (push) Successful in 4m14s
The timer's OnCalendar now bakes the configured times (falling back
like _hhmm_to_min on a hand-edited state), so the switch lands at the
configured minute instead of up to 15 minutes late. The original
poll's robustness case is covered without it: Persistent catches
transitions missed while powered off, systemd fires elapsed calendar
timers on resume, and OnStartupSec settles the theme after login.

Baked times mean a time edit must rebuild: the menu's Sunrise/Sunset
writes now run `auto --force` when enabled (one rebuild re-bakes the
timer and lands on the right theme, same as the enable flow). Day and
night slugs stay live reads — but with no poll to catch them up,
editing the slot currently on screen applies immediately via a plain
`auto`. New eval check `auto-theme-timer` asserts the bake
state-bridges-style: configured times reach OnCalendar, garbage falls
back to 07:00/20:00, disabled installs no unit. Docs + hardware-queue
entry updated to the new semantics.

V2: checks.auto-theme (KVM VM, passed) covers the auto engine the
timer ticks; checks.auto-theme-timer (eval, passed) covers the unit;
OnCalendar strings systemd-analyze-validated; the rebuilt menu script
passes bash -n with the new branches in place. V3 pending: live timer
firing at the exact configured minute on hardware (existing
"Auto-theme pair flip" queue entry, refreshed).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-18 22:31:02 +01:00
ac24ac7c34 fix(state): auto-commit died on dead legacy pathspecs after #107
Since d8e1a13 removed theme-state.json, auto_commit's fixed pathspec
(state.json + both legacy names) made `git commit` abort — unlike
`git diff`, commit errors on a pathspec that matches nothing — so every
per-mutation settings commit was silently "skipped" and state.json
stayed dirty until a lifecycle sweep caught it. Filter the legacy names
down to those git still knows (index or HEAD — a migration's staged
`git rm` still rides along). checks.auto-theme now runs its state dir
as a git repo with autoCommit on and asserts each set/apply lands as
its own commit leaving nothing dirty — the exact post-#107 repo shape
that used to abort. state-sync 0.5.0 → 0.5.1.

V2: nix build .#checks.x86_64-linux.auto-theme (KVM VM run, passed,
including the new git assertions); the built tool was also exercised
against a clone of a real post-#107 machine flake — the set
auto-committed and the tree stayed clean.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-18 22:30:50 +01:00
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
215 changed files with 14962 additions and 1948 deletions

View File

@@ -1,11 +1,12 @@
# Nomarchy scheduled lock bump — the automated half of "rock-stable # Nomarchy scheduled lock bump — the automated half of "rock-stable
# without rotting": once a week, update flake.lock (inputs track pinned # without rotting": once a day, update flake.lock (inputs track pinned
# release branches, so this never jumps a NixOS release — that's a # 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 # deliberate hand-edited v2, see agent/GOALS.md), gate it, and land it
# on main only on green. `v1` promotion stays human, always. # on main only on green. `v1` promotion stays human, always.
# #
# Fast lane: workflow_dispatch. For a security fix upstream, run this # Fast lane: workflow_dispatch. For a security fix upstream, run this
# workflow manually from the Actions tab instead of waiting for Monday. # workflow manually from the Actions tab instead of waiting for the
# next daily run.
# #
# Gate scope: `nix flake check --no-build` (eval tier) followed by a V1 build # Gate scope: `nix flake check --no-build` (eval tier) followed by a V1 build
# (home-manager activation package + nixos toplevel) to catch compilation errors. # (home-manager activation package + nixos toplevel) to catch compilation errors.
@@ -18,7 +19,7 @@
# #
# Failure mode: a red gate fails this run visibly and pushes nothing; # Failure mode: a red gate fails this run visibly and pushes nothing;
# next schedule retries. A push race (someone landed on main mid-run) # next schedule retries. A push race (someone landed on main mid-run)
# also just fails the final push — rerun or wait a week. # also just fails the final push — rerun or wait a day.
# #
# Container recipe (nixbld users, sandbox=false, pinned Nix, plain-shell # Container recipe (nixbld users, sandbox=false, pinned Nix, plain-shell
# install) inherited from check.yml — the gotchas are documented there. # install) inherited from check.yml — the gotchas are documented there.
@@ -27,7 +28,7 @@ name: Lock bump
on: on:
schedule: schedule:
- cron: '17 5 * * 1' # Mondays 05:17 UTC - cron: '17 5 * * *' # daily 05:17 UTC
workflow_dispatch: workflow_dispatch:
jobs: jobs:
@@ -36,6 +37,8 @@ jobs:
timeout-minutes: 90 timeout-minutes: 90
env: env:
NIX_CONFIG: | NIX_CONFIG: |
max-jobs = 1
cores = 2
experimental-features = nix-command flakes experimental-features = nix-command flakes
sandbox = false sandbox = false
NIX_SSL_CERT_FILE: /etc/ssl/certs/ca-certificates.crt NIX_SSL_CERT_FILE: /etc/ssl/certs/ca-certificates.crt
@@ -68,9 +71,12 @@ jobs:
fi fi
id: update id: update
- name: Gate — nix flake check (eval only) - 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' if: steps.update.outputs.changed == '1'
run: nix flake check --no-build run: bash tools/ci-eval.sh
- name: Gate — V1 build (toplevel + home-manager) - name: Gate — V1 build (toplevel + home-manager)
if: steps.update.outputs.changed == '1' if: steps.update.outputs.changed == '1'

View File

@@ -37,6 +37,8 @@ jobs:
timeout-minutes: 60 timeout-minutes: 60
env: env:
NIX_CONFIG: | NIX_CONFIG: |
max-jobs = 1
cores = 1
experimental-features = nix-command flakes experimental-features = nix-command flakes
sandbox = false sandbox = false
NIX_SSL_CERT_FILE: /etc/ssl/certs/ca-certificates.crt NIX_SSL_CERT_FILE: /etc/ssl/certs/ca-certificates.crt
@@ -55,11 +57,15 @@ jobs:
curl -L "https://releases.nixos.org/nix/nix-${NIX_VERSION}/install" | sh -s -- --no-daemon curl -L "https://releases.nixos.org/nix/nix-${NIX_VERSION}/install" | sh -s -- --no-daemon
echo "$HOME/.nix-profile/bin" >> "$GITHUB_PATH" echo "$HOME/.nix-profile/bin" >> "$GITHUB_PATH"
- name: nix flake check (eval only) - name: Evaluate every output (eval tier, memory-bounded)
# Full module-system evaluation of every output — both nixos # Same coverage as `nix flake check --no-build` — both nixos
# configs, the home config, the checks.* derivations (instantiated, # configs, the home config, the checks.* derivations (instantiated,
# not run) and the downstream template through lib.mkFlake. # not run) and the downstream template through lib.mkFlake — but
run: nix flake check --no-build # 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) - name: Python syntax (all tracked scripts)
# Every tracked *.py — theme-sync, the installer composers # Every tracked *.py — theme-sync, the installer composers

4
.gitignore vendored
View File

@@ -5,5 +5,9 @@ result-*
__pycache__/ __pycache__/
.DS_Store .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 Code machine-local settings (permissions etc.)
.claude/settings.local.json .claude/settings.local.json
.claude/settings.local.json.tmp.*

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.

140
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 │ │ 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) 1. merges the preset into the JSON (atomic write)
2. runs `home-manager switch` (no sudo, no system rebuild) 2. runs `home-manager switch` (no sudo, no system rebuild)
@@ -19,7 +19,7 @@ partial.
┌──────────────────────────────────────────────────────────────────────────┐ ┌──────────────────────────────────────────────────────────────────────────┐
│ Home Manager bakes EVERYTHING into one read-only generation: │ │ Home Manager bakes EVERYTHING into one read-only generation: │
│ Hyprland (colors/gaps/borders) Waybar (palette or whole-swap) │ │ 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 │ │ 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) ├── flake.nix # inputs + the downstream API (exports below)
├── lib.nix # nomarchy.lib.mkFlake — one-call downstream wrapper ├── lib.nix # nomarchy.lib.mkFlake — one-call downstream wrapper
├── theme-state.json # ★ THE single source of truth (git-tracked!) ├── state.json # ★ THE single source of truth (git-tracked!)
├── themes/ # 24 presets: <slug>.json + optional <slug>/ assets ├── themes/ # 28 presets: <slug>.json + optional <slug>/ assets
│ ├── nord.json # palette (required, works alone) │ ├── nord.json # palette (required, works alone)
│ └── nord/ # assets (optional, fixed filenames) │ └── nord/ # assets (optional, fixed filenames)
│ ├── backgrounds/ # wallpapers (auto-picked, SUPER+SHIFT+T cycles) │ ├── backgrounds/ # wallpapers (pinned nomarchy-wallpapers input, not this repo)
│ ├── btop.theme # hand-made config drop (else generated) │ ├── btop.theme # hand-made config drop (else generated)
│ └── waybar.css # whole-swap: replaces the generated bar style │ └── waybar.css # whole-swap: replaces the generated bar style
├── modules/ ├── modules/
@@ -56,7 +56,7 @@ Flat on purpose. Two module trees, one options file each, no hidden layers.
│ ├── stylix.nix # GTK/Qt/cursors/fonts from the same JSON │ ├── stylix.nix # GTK/Qt/cursors/fonts from the same JSON
│ ├── hyprland.nix # all JSON-driven │ ├── hyprland.nix # all JSON-driven
│ ├── waybar.nix │ ├── waybar.nix
│ ├── ghostty.nix │ ├── kitty.nix
│ ├── btop.nix │ ├── btop.nix
│ ├── rofi.nix # launcher + nomarchy-menu (calc, emoji, clip…) │ ├── rofi.nix # launcher + nomarchy-menu (calc, emoji, clip…)
│ ├── keybinds.nix # single source: Hyprland binds + SUPER+? sheet │ ├── keybinds.nix # single source: Hyprland binds + SUPER+? sheet
@@ -72,10 +72,9 @@ Flat on purpose. Two module trees, one options file each, no hidden layers.
│ ├── default/ # reference machine (thin: boot, user, hostname) │ ├── default/ # reference machine (thin: boot, user, hostname)
│ └── live.nix # bootable live ISO (try the distro, no install) │ └── live.nix # bootable live ISO (try the distro, no install)
├── pkgs/ ├── pkgs/
│ ├── nomarchy-theme-sync/ # state writer + rebuild dispatcher (Python) │ ├── nomarchy-state-sync/ # state writer + rebuild dispatcher (Python)
│ ├── nomarchy-install/ # live-ISO installer (gum + disko + mkFlake) │ ├── nomarchy-install/ # live-ISO installer (gum + disko + mkFlake)
│ ├── nomarchy-doctor/ # read-only health sheet, one command per failure │ ├── nomarchy-doctor/ # read-only health sheet, one command per failure
│ ├── nomarchy-control-center/ # TUI settings front-end over nomarchy-theme-sync (gum)
│ └── nomarchy-battery-notify/ # low-battery toast watcher backing battery-notify.nix │ └── nomarchy-battery-notify/ # low-battery toast watcher backing battery-notify.nix
├── templates/downstream/ # machine flake SoT (`flake init` + installer copy/patch) ├── templates/downstream/ # machine flake SoT (`flake init` + installer copy/patch)
├── docs/ # human docs — map: docs/README.md ├── docs/ # human docs — map: docs/README.md
@@ -102,11 +101,15 @@ probably shouldn't exist.
## 2. Try it first (live ISO) ## 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: Boot the full desktop from a USB stick or VM without installing anything:
```sh ```sh
nix build .#nixosConfigurations.nomarchy-live.config.system.build.isoImage 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 tools/test-live-iso.sh
``` ```
@@ -115,6 +118,13 @@ the locked inputs into the ISO store — so theme switching (including the
`home-manager switch` it triggers) works **offline**, exactly like on an `home-manager switch` it triggers) works **offline**, exactly like on an
installed system. Verification checklist: [docs/TESTING.md](docs/TESTING.md). 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 Like what you see? **`nomarchy-install`** (in a terminal) walks you through
installing to disk: pick a disk, LUKS2 full-disk encryption **by default** installing to disk: pick a disk, LUKS2 full-disk encryption **by default**
(in exchange the desktop logs in passwordless — the passphrase already (in exchange the desktop logs in passwordless — the passphrase already
@@ -150,7 +160,7 @@ Nomarchy in place, reusing your existing `hardware-configuration.nix`:
**[docs/MIGRATION.md](docs/MIGRATION.md)**. **[docs/MIGRATION.md](docs/MIGRATION.md)**.
You own two files day-to-day: `system.nix` and `home.nix` (plus You own two files day-to-day: `system.nix` and `home.nix` (plus
`theme-state.json`, written by the CLI). Your `flake.nix` is set up once — `state.json`, written by the CLI). Your `flake.nix` is set up once —
later by the installer — and never hand-edited; it's a single call: later by the installer — and never hand-edited; it's a single call:
```nix ```nix
@@ -187,12 +197,17 @@ nomarchy-pull # nix flake update — refresh inputs (nomarchy, nixpkgs, …
nomarchy-rebuild # system rebuild against the CURRENT lock (sudo inside; nomarchy-rebuild # system rebuild against the CURRENT lock (sudo inside;
# BTRFS snapshot first when snapper is on; nvd diff after) # BTRFS snapshot first when snapper is on; nvd diff after)
nomarchy-home # home-manager switch — desktop only, no sudo, no lock bump 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)
``` ```
`~/.nomarchy` is **your machine flake** (`system.nix` / `home.nix`). Newer `~/.nomarchy` is **your machine flake** (`system.nix` / `home.nix`). Newer
Nomarchy code arrives when `flake.lock` updates the `nomarchy` input Nomarchy code arrives when `flake.lock` updates the `nomarchy` input
(`nomarchy-pull`), not by `git pull` of that directory (many installs have (`nomarchy-pull`), not by `git pull` of that directory (many installs have
no remote there — only local commits from auto-commit). 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 | | When | Run |
|---|---| |---|---|
@@ -213,7 +228,7 @@ Legacy aliases still work: `sys-update` → pull+rebuild, `sys-rebuild` →
`nomarchy-rebuild`, `home-update``nomarchy-home`. `nomarchy-rebuild`, `home-update``nomarchy-home`.
Override anything via the `nomarchy.*` surface or plain NixOS/HM options: Override anything via the `nomarchy.*` surface or plain NixOS/HM options:
appearance (gaps/colors/fonts) changes through `nomarchy-theme-sync`, appearance (gaps/colors/fonts) changes through `nomarchy-state-sync`,
behaviour (input/misc/monitor/chrome) is `mkDefault` so a plain `home.nix` behaviour (input/misc/monitor/chrome) is `mkDefault` so a plain `home.nix`
assignment wins, and bind/exec-once lists concatenate. Full guide with assignment wins, and bind/exec-once lists concatenate. Full guide with
examples: **[docs/OVERRIDES.md](docs/OVERRIDES.md)**. examples: **[docs/OVERRIDES.md](docs/OVERRIDES.md)**.
@@ -227,12 +242,13 @@ two tables below are split along exactly that line.
| Option | Default | Purpose | | Option | Default | Purpose |
|---|---|---| |---|---|---|
| `nomarchy.stateFile` | — (required) | Path to your theme-state.json | | `nomarchy.stateFile` | — (required) | Path to your state.json |
| `nomarchy.terminal` | `"ghostty"` | Terminal for keybinds and `$TERMINAL` | | `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.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.variant` | `""` | XKB variant for the session |
| `nomarchy.keyboard.devices` | `{}` | Per-device layout overrides (Hyprland `device` blocks keyed by `hyprctl devices` name) — e.g. an external keyboard with its own layout/variant | | `nomarchy.keyboard.devices` | `{}` | Per-device layout overrides (Hyprland `device` blocks keyed by `hyprctl devices` name) — e.g. an external keyboard with its own layout/variant |
| `nomarchy.keyboard.layouts` | `[]` | Extra candidate layouts; when set, a watcher prompts (rofi) for a layout on a newly-connected keyboard and remembers it per-device | | `nomarchy.keyboard.layouts` | `[]` | 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.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.updates.enable` | `false` | Passive update awareness: a background check (`.interval`, default daily) that flags when flake inputs (nixpkgs, the Nomarchy input, …) are behind upstream — and, with Flatpak on, when apps have updates (`.flatpak`) — via a Waybar indicator + notification. Never changes anything; click the indicator to run the upgrade flow |
| `nomarchy.hyprland.enable` | `true` | Nomarchy's Hyprland config | | `nomarchy.hyprland.enable` | `true` | Nomarchy's Hyprland config |
@@ -240,11 +256,13 @@ two tables below are split along exactly that line.
| `nomarchy.rofi.enable` | `true` | Themed rofi launcher + `nomarchy-menu` dispatcher | | `nomarchy.rofi.enable` | `true` | Themed rofi launcher + `nomarchy-menu` dispatcher |
| `nomarchy.swaync.enable` | `true` | swaync notifications, themed | | `nomarchy.swaync.enable` | `true` | swaync notifications, themed |
| `nomarchy.batteryNotify.enable` | `true` | Low-battery toasts at the bar's thresholds — 25% low, 10% critical (stays up until dismissed); silent no-op on machines without a battery | | `nomarchy.batteryNotify.enable` | `true` | Low-battery toasts at the bar's thresholds — 25% low, 10% critical (stays up until dismissed); silent no-op on machines without a battery |
| `nomarchy.idle.enable` | `true` | hyprlock + hypridle (idle lock 5 min, display off 10, suspend 15 min — battery-only) | | `nomarchy.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.yazi.enable` | `true` | yazi TUI file manager, themed + curated plugins |
| `nomarchy.osd.enable` | `true` | swayosd on-screen display for volume/brightness/mute | | `nomarchy.osd.enable` | `true` | swayosd on-screen display for volume/brightness/mute |
| `nomarchy.shell.enable` | `true` | zsh + starship prompt + bat/eza/zoxide (zsh is the default login shell) | | `nomarchy.shell.enable` | `true` | zsh + starship prompt + bat/eza/zoxide (zsh is the default login shell) |
| `nomarchy.ghostty.enable` | `true` | Nomarchy's Ghostty |
| `nomarchy.btop.enable` | `true` | btop with per-theme colors | | `nomarchy.btop.enable` | `true` | btop with per-theme colors |
| `nomarchy.stylix.enable` | `true` | GTK/Qt/cursor theming | | `nomarchy.stylix.enable` | `true` | GTK/Qt/cursor theming |
| `nomarchy.fastfetch.enable` | `true` | fastfetch fronted by the themed Nomarchy logo | | `nomarchy.fastfetch.enable` | `true` | fastfetch fronted by the themed Nomarchy logo |
@@ -253,10 +271,19 @@ two tables below are split along exactly that line.
| `nomarchy.viewers.enable` | `true` | Document/image viewers: zathura (Stylix-themed PDF) + imv | | `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.mime.enable` | `true` | Default "open with" associations (PDF/image/video/text/browser/directory); entries for apps you removed are skipped, so it degrades with the suite |
| `nomarchy.monitors` | `[]` | Declarative per-output layout → Hyprland `monitor` rules (applied on hotplug); `,preferred,auto,1` wildcard kept as fallback | | `nomarchy.monitors` | `[]` | Declarative per-output layout → Hyprland `monitor` rules (applied on hotplug); `,preferred,auto,1` wildcard kept as fallback |
| `nomarchy.displayProfiles` | `{}` | Named layouts for the same outputs (docked/undocked/…), switched from System Display Profiles: instant via hyprctl, persisted in-flake (`settings.displayProfile`), baked over `nomarchy.monitors` at the next rebuild. The menu's Auto-switch row (`settings.displayProfileAuto`) makes plugging/unplugging outputs apply the matching profile instantly (driven by a dedicated Hyprland IPC socket watcher, no polling). A profile can also pin workspaces to outputs (`workspaces = { "1" = "DP-3"; }`) moved instantly on switch, baked as Hyprland `workspace` rules | | `nomarchy.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.launchOrFocus` | `[]` | Launch-or-focus binds: `SUPER+<key>` focuses the app's window (case-insensitive class match) or launches it; entries land in the SUPER+? cheatsheet, and a bind whose app was removed notifies instead of failing silently |
| `nomarchy.themesDir` | Nomarchy's `themes/` | Where per-theme app overrides are probed | | `nomarchy.themesDir` | Nomarchy's `themes/` | Where per-theme app overrides are probed |
| `nomarchy.package` | overlay's `nomarchy-theme-sync` | The theme/state tool package, overridable if you fork it | | `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` **Always-on, no toggle by design:** `services.cliphist`, `services.udiskie`
(automount + safe-removal toasts) and `services.easyeffects` (mic noise (automount + safe-removal toasts) and `services.easyeffects` (mic noise
@@ -271,25 +298,27 @@ option, e.g. `services.easyeffects.enable = lib.mkForce false;` — see
| Option | Default | Purpose | | Option | Default | Purpose |
|---|---|---| |---|---|---|
| `nomarchy.system.plymouth.enable` | `true` | Branded boot splash, background from the theme JSON (recolors on system rebuilds) | | `nomarchy.system.plymouth.enable` | `true` | Branded boot splash, background from the theme JSON (recolors on system rebuilds) |
| `nomarchy.system.stateFile` | `null` | theme-state.json for the system-side consumers (the Plymouth tint); `lib.mkFlake` wires it for you — set it only when composing the modules by hand | | `nomarchy.system.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.fileManager.enable` | `true` | Thunar GUI + gvfs/tumbler/udisks2 (the "open folder" handler) |
| `nomarchy.system.greeter.enable` | `true` | greetd/tuigreet | | `nomarchy.system.greeter.enable` | `true` | greetd/tuigreet |
| `nomarchy.system.greeter.autoLogin` | `null` | Auto-login this user into Hyprland (installer sets it on LUKS machines) | | `nomarchy.system.greeter.autoLogin` | 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 | | `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.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.autoTimezone.enable` | `false` | Automatic timezone (geoclue + automatic-timezoned) — the clock follows your location; toggle from System Auto timezone (a menu enable rebuilds: it has to unset the static `time.timeZone`) |
| `nomarchy.system.snapper.enable` | `false` | Hourly/daily BTRFS timeline snapshots + `nixos-rebuild-snap` (installer enables it; no-op unless root is BTRFS) | | `nomarchy.system.snapper.enable` | `false` | Hourly/daily BTRFS timeline snapshots + `nixos-rebuild-snap` (installer enables it; no-op unless root is BTRFS) |
| `nomarchy.system.power.enable` | `true` | Active power management (see below) | | `nomarchy.system.power.enable` | `true` | Active power management (see below) |
| `nomarchy.system.power.backend` | `"ppd"` | `"ppd"` (power-profiles-daemon + menu/Waybar switcher) or `"tlp"` (deeper battery tuning, no switcher) — mutually exclusive | | `nomarchy.system.power.backend` | `"ppd"` | `"ppd"` (power-profiles-daemon + menu/Waybar switcher) or `"tlp"` (deeper battery tuning, no switcher) — mutually exclusive |
| `nomarchy.system.power.laptop` | `false` | Marks a laptop, gating battery-only features; the installer sets it when a battery is present | | `nomarchy.system.power.laptop` | `false` | Marks a laptop, gating battery-only features; the installer sets it when a battery is present. 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.thermal.enable` | `false` | thermald (Intel-only); the installer enables it on a GenuineIntel CPU |
| `nomarchy.system.power.batteryChargeLimit` | `null` | Stop charging at this % (e.g. `80`) where the hardware supports it; needs `power.laptop` | | `nomarchy.system.power.batteryChargeLimit` | `null` | Stop charging at this % (e.g. `80`) where the hardware supports it; needs `power.laptop` |
| `nomarchy.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.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.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.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.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.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.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.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.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.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) |
@@ -347,24 +376,31 @@ The trap with "read a mutable file from Nix" is pure evaluation: flakes
cannot read arbitrary `$HOME` paths without `--impure` (the old prototype cannot read arbitrary `$HOME` paths without `--impure` (the old prototype
required it — never again). Nomarchy's convention: **the state file lives required it — never again). Nomarchy's convention: **the state file lives
inside the consuming flake** and is wired via inside the consuming flake** and is wired via
`nomarchy.stateFile = ./theme-state.json;`. Reading it is pure — it's flake `nomarchy.stateFile = ./state.json;`. Reading it is pure — it's flake
source. It must be git-tracked (`nomarchy-theme-sync` runs source. It must be git-tracked (`nomarchy-state-sync` runs
`git add --intent-to-add` after every write as a safety net). `git add --intent-to-add` after every write as a safety net).
### One change = one generation ### 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 `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 btop, and — via Stylix, mapped onto base16 roles — GTK, Qt, cursors and
fonts. No runtime patching means no partial states, and `home-manager fonts. No runtime patching means no partial states, and `home-manager
generations` is also your theme history. Waybar even restyles in place: it generations` is also your theme history. Waybar even restyles in place: it
re-reads `style.css` when the symlink flips. 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 The **wallpaper** is the one runtime piece (awww — nixpkgs' swww — is
imperative; nothing in Nix consumes the path): applied at session start and imperative; nothing in Nix consumes the path): applied at session start,
after every switch via a tiny activation hook, cycled instantly with after every switch via a tiny activation hook, and again when a monitor is
`bg next`. hotplugged; cycled instantly with `bg next`.
### Config the menu writes (not just themes) ### Config the menu writes (not just themes)
@@ -384,7 +420,7 @@ gates the unit, the first enable rebuilds) and `settings.nightlight.on` (the
instant on/off). Expect more `nomarchy.*` toggles to migrate to this pattern. instant on/off). Expect more `nomarchy.*` toggles to migrate to this pattern.
**Auto-commit (opt-in):** System Auto-commit makes every `apply`/`set` **Auto-commit (opt-in):** System Auto-commit makes every `apply`/`set`
mutation also `git commit` theme-state.json in your flake — *only* that mutation also `git commit` state.json in your flake — *only* that
file, so unrelated dirty work is never swept up — turning your settings 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 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 Nix consumes the flag), the off-write is itself committed so history stays
@@ -398,26 +434,29 @@ per theme — a single place to look, unlike the old distro's split:
| Asset | Mechanism | | 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) | | `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.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) | | `waybar.jsonc` | whole-swap for the bar *layout* (must be plain JSON) |
| `rofi.rasi` | **whole-swap**: replaces the generated launcher/menu theme entirely | | `rofi.rasi` | **whole-swap**: replaces the generated launcher/menu theme entirely |
Four themes ship a `waybar.css` identity (summer-day, summer-night, Eight themes ship a `waybar.css` identity: the three Nomarchy-exclusive
executive-slate, boreal). Custom user themes can live in 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 `$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 ## 5. Day-to-day
```sh ```sh
nomarchy-theme-sync list # 24 presets (nord, gruvbox, rose-pine, …) nomarchy-state-sync list # 28 presets (nord, gruvbox, rose-pine, …)
nomarchy-theme-sync apply kanagawa # whole desktop, one generation (~a switch) nomarchy-state-sync apply kanagawa # whole desktop, one generation (~a switch)
nomarchy-theme-sync set ui.gapsOut 16 # tweak one knob (also a switch) nomarchy-state-sync set ui.gapsOut 16 # tweak one knob (also a switch)
nomarchy-theme-sync bg next # cycle wallpapers — instant, no rebuild nomarchy-state-sync bg next # cycle wallpapers — instant, no rebuild
nomarchy-theme-sync bg auto # back to the theme's default wallpaper nomarchy-state-sync bg auto # back to the theme's default wallpaper
nomarchy-theme-sync get colors.accent nomarchy-state-sync get colors.accent
nomarchy-pull # flake input update (no rebuild) nomarchy-pull # flake input update (no rebuild)
nomarchy-rebuild # rebuild the system, current lock nomarchy-rebuild # rebuild the system, current lock
nomarchy-home # rebuild just the desktop layer nomarchy-home # rebuild just the desktop layer
@@ -453,7 +492,7 @@ gp gpl gf # push · pull · fetch --all --prune
# nix (lifecycle: nomarchy-pull / nomarchy-rebuild / nomarchy-home — full names) # nix (lifecycle: nomarchy-pull / nomarchy-rebuild / nomarchy-home — full names)
ns nr # nix shell · nix run (e.g. ns nixpkgs#ripgrep) ns nr # nix shell · nix run (e.g. ns nixpkgs#ripgrep)
nfu nfc # nix flake update · check (prefer nomarchy-pull for day-to-day) nfu nfc # nix flake update · check (prefer nomarchy-pull for day-to-day)
nsearch ngc # nix search nixpkgs · nix-collect-garbage -d nsearch ngc # nix search · nix-collect-garbage -d (prefer: sudo nomarchy-gen-prune)
# misc # misc
path # print $PATH, one entry per line path # print $PATH, one entry per line
@@ -464,7 +503,7 @@ reload # exec zsh (reload the shell)
- **New theme:** drop a JSON into `themes/` (schema = any existing preset), - **New theme:** drop a JSON into `themes/` (schema = any existing preset),
plus an optional `themes/<slug>/` assets directory. 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. the Nix modules. One place — there is no second renderer to keep in sync.
- **Importing more old-distro palettes:** - **Importing more old-distro palettes:**
`tools/import-palettes.py <palettes-dir> themes/` (roles are first-class: `tools/import-palettes.py <palettes-dir> themes/` (roles are first-class:
@@ -483,6 +522,17 @@ reload # exec zsh (reload the shell)
line to slim the machine, uncomment an extra (a browser, email, full TeX 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 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. 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 ## Roadmap & known issues
@@ -495,3 +545,5 @@ reload # exec zsh (reload the shell)
| **[agent/LOOP.md](agent/LOOP.md)** | Autonomous iteration protocol | | **[agent/LOOP.md](agent/LOOP.md)** | Autonomous iteration protocol |
Kept out of the README body so this stays a focused entry point. Kept out of the README body so this stays a focused entry point.
License: [MIT](LICENSE).

View File

@@ -24,51 +24,59 @@ in [`docs/ROADMAP.md`](../docs/ROADMAP.md); map in
## NOW ## NOW
*(empty — NEXT's top item is the queue head)* ### 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 ## NEXT
### 76. Hibernation default — all agent work shipped `[blocked:hw]` ### 151. `[human]` v1 launch plan — devise with Bernardo (includes the GitHub move)
Hibernation + zram on by default (product intent, Bernardo 2026-07-10).
**All V0/V1/V2 slices done** (2026-07-10); only the V3 check remains — on a
machine **actually running Nomarchy** (the dev box runs Bernardo's own NixOS,
not Nomarchy, so it can't close it). Steps in HARDWARE-QUEUE. Design record in
**ROADMAP § Hibernation + zram by default**. Shipped:
- zram default (`modules/nixos/oom.nix`, zstd/50%/prio 100) — `checks.zram-swap`.
- Installer/template already default new installs to a RAM-sized encrypted
`@swap` swapfile + `resume_offset` wiring; `swapSize=0` = no-swap opt-out.
- `docs/MIGRATION.md` enable-hibernation runbook (existing machines).
- `rofi.nix` Power → Hibernate: kept unconditional, **notifies on failure**.
- `checks.hibernate` VM test (V2 PASS) — hibernate→resume→crash round-trip
(swap partition); zram (prio 100) doesn't swallow the image, disk carries it.
- `checks.hibernate-swapfile` VM test (V2 PASS) — the installer's btrfs `@swap`
**swapfile** setup: NOCOW `mkswapfile`, file-type swapon, a valid
`map-swapfile -r` offset the kernel accepts (`/sys/power/resume_offset`),
zram above it. (Bounded proof of the swapfile+offset the partition test
can't reach; a full runtime-swapfile hibernate isn't headless-reachable —
systemd `CanHibernate=no` for undeclared swap.)
**V3 remaining (narrowed):** the literal encrypted-swapfile power-cycle — Bernardo, 2026-07-17: before tagging v1, sit down and devise the launch plan.
`Hibernate → power off → unlock LUKS → resume session` on the LUKS+@swap Known ingredients, so that session starts concrete:
layout — plus the no-swap notify in a real session. QEMU can't feed a - **The repo moves to GitHub.** Everything baking the Gitea URL follows: the
runtime-computed `resume_offset` into the boot cmdline, and the dev box runs downstream template's `inputs.nomarchy.url`, the `nomarchy-wallpapers`
exactly this layout (`/proc/cmdline` verified) but isn't Nomarchy. Steps in input URL (baked 2026-07-17 — sweep it in the same move), README/docs
HARDWARE-QUEUE. Close #76 on PASS. 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 ## LATER
- **Wallpapers artifact split** (ROADMAP § Faster switches — decided, - **Pre-built theme variants** (ROADMAP § Faster switches follow-on): if
deferred): pinned `Nomarchy-wallpapers` input so a state write stops `home-manager switch` itself is still the theme-switch bottleneck now
re-copying 86 MB. Follow-on: pre-built theme variants if switches are that the wallpapers split shipped (2026-07-17), pre-build each theme's
still slow after. generation so a switch just activates a cached one. Measure first.
- **Installer round 2** (ROADMAP § Installer): multi-disk BTRFS RAID, - **Installer round 2** (ROADMAP § Installer): multi-disk BTRFS RAID,
impermanence, BIOS/legacy boot. impermanence, BIOS/legacy boot.
- **Boot-from-snapshot**: a systemd-boot equivalent of grub-btrfs. - **Boot-from-snapshot**: a systemd-boot equivalent of grub-btrfs.
- **MIPI/IPU software-ISP camera** support (no-UVC machines). - **MIPI/IPU software-ISP camera** support (no-UVC machines).
- **NixOS release bump → v2** `[human]`: deliberate, hand-edited, never - **NixOS release bump → v2** `[human]`: deliberate, hand-edited, never
automated; the previous attempt was discarded (2026-06-22) over a automated; the previous attempt was discarded (2026-06-22) over a
Hyprland OOM blocker — see MEMORY.md before retrying (NOW#3 should Hyprland OOM blocker — see MEMORY.md before retrying.
also soften that blocker class).
## FUTURE (decided deferred — not the agent queue head) ## FUTURE (decided deferred — not the agent queue head)
@@ -103,22 +111,217 @@ high-ROI, etc.) live in the journal + ROADMAP — not here.*
### Product / day-2 ### 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
**Re-measured 2026-07-20 (dev box; user journal is persistent back to 2026-05-01,
so this is the full window, not a fragment).** The linger fix landed between 17:25
and 20:13 on 2026-07-16: the last zombie-shaped disconnect is the known one
(17:25:39, PID 1698 — logout→greeter with **no** following `Stopped hypridle`), and
**every** `Disconnected from pollfd id 1` since is a clean teardown bracketed by both
a logind `Session N logged out` and a systemd `Stopped hypridle` within ≤11s
(07-16 20:13:04, 07-17 17:04:38, 07-18 13:35:25, 07-19 16:46:49). **Genuine zombies
post-fix: 0.** The live daemon is healthy too — PID 1707: `active`, `NRestarts=0`,
`ExecMainPID`==pid, `ss` Recv-Q 0 on its D-Bus socket (reading, not the 48 KB-unread
wedge), `WCHAN futex_do_wait`, logged activity <10 min before inspection. **Why this
stays open a little longer, not closed today:** all four post-fix disconnects were
*deliberate* logouts — no Hyprland *crash* (the zombie's actual trigger; the nearby
coredumps were xdg-desktop-portal/swww/swayosd during teardown, not the compositor)
has occurred since the fix, so the crash path is confirmed by argument (a crash ends
the logind session too → no-linger manager teardown reaps hypridle) but not yet by an
observed crash, and ~4 days is under the "few weeks" bar. **Close condition:** if
genuine zombies stay 0 by ~2026-08-10 (≈3 weeks post-fix), close unbuilt; one wedged
process (`ps` alive + `ss` Recv-Q high + `NRestarts=0` while `active`) reopens it as
real work. Everything below is prior context, unchanged.
**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.
### 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 - **NVIDIA first-class options** — **deferred past v1** (Bernardo
2026-07-10). Keep #59 commented install guidance; no 2026-07-10). Keep #59 commented install guidance; no
`nomarchy.hardware.nvidia.*` until a hybrid maintainer + queue. `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 ### v1.0 pointer
See **VISION**. Open PROPOSED: NVIDIA wrappers deferred past v1; IR See **VISION**. Open PROPOSED: NVIDIA deferred past v1; IR portal (b)/(c)
portal (b)/(c) need T14s hardware (HARDWARE-QUEUE § T14s). Standing need T14s (HARDWARE-QUEUE § T14s). Standing calls: browser = Chromium;
calls: browser default = Chromium; power backend = PPD. power = PPD. (Post-install hardware hints shipped 2026-07-17.)
## Decisions `[human]` ## Decisions `[human]`
Open calls only Bernardo can make; agents add options/evidence but never Open calls only Bernardo can make; agents add options/evidence but never
decide. **Resolved** entries stay for history; agents treat them as closed. 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) ### Resolved (2026-07-10)
- **Docs site vs Markdown-in-repo** — **markdown in-repo for now** - **Docs site vs Markdown-in-repo** — **markdown in-repo for now**
@@ -139,8 +342,8 @@ decide. **Resolved** entries stay for history; agents treat them as closed.
- **Hibernation** — **want by default** (product intent). Needs a - **Hibernation** — **want by default** (product intent). Needs a
disk-backed swap (file or partition) sized for resume; not zram alone. disk-backed swap (file or partition) sized for resume; not zram alone.
Implementation is a future/NEXT item when designed (installer + **Shipped as #76**; V3 power-cycle PASSED on TuringMachine 2026-07-12
resume device + encrypted-root story). (ROADMAP § Hibernation + zram by default).
### Resolved (2026-07-10, #76 design) ### Resolved (2026-07-10, #76 design)

View File

@@ -24,7 +24,7 @@ those, it probably shouldn't exist.
## Feature design ## Feature design
- **In-flake state:** any user-settable config gets a menu writer that - **In-flake state:** any user-settable config gets a menu writer that
lands it in `theme-state.json` (`settings.*`), git-tracked. No lands it in `state.json` (`settings.*`), git-tracked. No
`~/.local/state`, no side files. Instant-effect where possible `~/.local/state`, no side files. Instant-effect where possible
(`--no-switch` + flip the service; the service reads the *live* working (`--no-switch` + flip the service; the service reads the *live* working
tree at start — the night-light `ExecCondition` pattern). Rebuild-baked tree at start — the night-light `ExecCondition` pattern). Rebuild-baked
@@ -46,7 +46,8 @@ those, it probably shouldn't exist.
named `writeShellScriptBin`s on PATH (so static configs can exec them named `writeShellScriptBin`s on PATH (so static configs can exec them
by bare name), and are added to **both** the generated `waybar.nix` by bare name), and are added to **both** the generated `waybar.nix`
config **and** every `waybar.jsonc` whole-swap — summer-day, summer-night, config **and** every `waybar.jsonc` whole-swap — summer-day, summer-night,
executive-slate and boreal (the parity rule). executive-slate, executive-ivory, boreal, boreal-dawn, kiln and
kiln-clay (the parity rule).
- **Theming:** every new visual surface consumes the palette from the - **Theming:** every new visual surface consumes the palette from the
state JSON. There is no second renderer to keep in sync — add the key state JSON. There is no second renderer to keep in sync — add the key
to the JSON, consume it in the module. to the JSON, consume it in the module.
@@ -68,5 +69,7 @@ those, it probably shouldn't exist.
- Commit style: `feat|fix|test|docs|chore(scope): summary`, body with - Commit style: `feat|fix|test|docs|chore(scope): summary`, body with
what/why + verification tier + what remains. Bookkeeping (`agent/` what/why + verification tier + what remains. Bookkeeping (`agent/`
updates) rides in the same commit as the change. updates) rides in the same commit as the change.
- `flake.lock` moves only when the task *is* a lock bump, and only within - `flake.lock` moves only when the task *is* a lock bump. The release
the pinned release branches. 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`.

View File

@@ -14,7 +14,7 @@ confidence, default identity, release bar) live in
on-hardware QA. on-hardware QA.
2. **Reproducible, with zero hidden state.** The downstream flake checkout 2. **Reproducible, with zero hidden state.** The downstream flake checkout
*is* the machine. All user-settable config is menu-writable into the *is* the machine. All user-settable config is menu-writable into the
git-tracked state file (`theme-state.json` `settings.*`) — never git-tracked state file (`state.json` `settings.*`) — never
`~/.local/state`, never `~/.config` side files. Re-cloning your flake `~/.local/state`, never `~/.config` side files. Re-cloning your flake
reproduces the machine, settings and all. reproduces the machine, settings and all.
3. **Effortless to configure.** The user never has to learn Nix. Every 3. **Effortless to configure.** The user never has to learn Nix. Every
@@ -22,7 +22,7 @@ confidence, default identity, release bar) live in
ergonomic writer for the flake. Where a toggle can take effect without a ergonomic writer for the flake. Where a toggle can take effect without a
rebuild, it must (`--no-switch` + flip the running service). rebuild, it must (`--no-switch` + flip the running service).
4. **Beautiful.** One JSON themes the entire desktop coherently — Hyprland, 4. **Beautiful.** One JSON themes the entire desktop coherently — Hyprland,
Waybar, Ghostty, btop, rofi, GTK/Qt, boot splash, greeter. Every new Waybar, Kitty, btop, rofi, GTK/Qt, boot splash, greeter. Every new
surface follows the palette. Informative, self-gating Waybar modules surface follows the palette. Informative, self-gating Waybar modules
(they hide when irrelevant). No unthemed corner survives contact with (they hide when irrelevant). No unthemed corner survives contact with
the theme switcher. the theme switcher.

View File

@@ -9,13 +9,281 @@ git history is the archive. Machines: the **AMD dev box** (Ryzen AI
laptop: AMD + fingerprint + NPU), the **Latitude 5310/5410** (Intel QA), laptop: AMD + fingerprint + NPU), the **Latitude 5310/5410** (Intel QA),
the **T14s** (webcam case). 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) ## 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), set sunset (or sunrise) a few minutes ahead via
Look & Feel Auto theme (the time edit itself rebuilds — since the
exact-time revision the timer fires AT the configured minute, no
15-min poll), let the timer flip once each way. Pass = (1)
`systemctl --user list-timers nomarchy-auto-theme` shows the two
configured times as the trigger; (2) the switch lands within ~a
minute of the configured time; (3) 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; (4) with autoCommit on, `git -C
~/.nomarchy log` shows an `apply theme` commit and state.json is
not left dirty (the 0.5.1 pathspec fix).
- [ ] **#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 - [ ] **#55 fingerprint enroll on real reader** — with
`nomarchy.hardware.fingerprint.enable` and a physical reader: System `nomarchy.hardware.fingerprint.enable` and a physical reader: System
Fingerprint Enroll a finger; List shows it; Verify succeeds; optional Fingerprint Enroll a finger; List shows it; Verify succeeds.
Use for login (on) → `nomarchy-rebuild` → login/sudo accept fingerprint. Enroll/List/Verify verified on hardware 2026-07-14 (T14s); the
Menu surface is V1/V2 without a reader (self-gate + dry paths). 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 - [ ] **#60 non-BAT* battery name (if available)** — on a machine whose
system battery is **not** named `BAT*` (e.g. `CMB0`): confirm system battery is **not** named `BAT*` (e.g. `CMB0`): confirm
charge-limit oneshot writes the threshold, System Battery limit charge-limit oneshot writes the threshold, System Battery limit
@@ -24,13 +292,13 @@ the **T14s** (webcam case).
a no-op (charge-limit re-apply passed on `BAT0` — Latitude a no-op (charge-limit re-apply passed on `BAT0` — Latitude
2026-07-10). 2026-07-10).
- [ ] **btop theme fidelity (#52 residual)** — softGL theme-shot cannot - [ ] **btop theme fidelity (#52 residual)** — softGL theme-shot cannot
open Ghostty, so the hand `btop.theme` assets were only guest-file open a real terminal UI, so the hand `btop.theme` assets were only
asserted (main_bg / inactive_fg keys) + desktop/bar rendered for guest-file asserted (main_bg / inactive_fg keys) + desktop/bar
rose-pine, everforest, summer-night, vantablack, catppuccin. On a rendered for rose-pine, everforest, summer-night, vantablack,
real session: `nomarchy-theme-sync apply <slug>` then `btop` and catppuccin. On a real session: `nomarchy-state-sync apply <slug>`
confirm backgrounds/text match the theme (esp. rose-pine Dawn light then `btop` and confirm backgrounds/text match the theme (esp.
bg `#faf4ed`, vantablack near-black `#0d0d0d`, catppuccin Mocha rose-pine Dawn light bg `#faf4ed`, vantablack near-black `#0d0d0d`,
`#1E1E2E`). One pass cycling those five is enough. catppuccin Mocha `#1E1E2E`). One pass cycling those five is enough.
- [ ] **SSH_AUTH_SOCK for GUI clients** — after relogin, launch a GUI git - [ ] **SSH_AUTH_SOCK for GUI clients** — after relogin, launch a GUI git
client (or `rofi`-launched terminal-less app) and confirm it reaches client (or `rofi`-launched terminal-less app) and confirm it reaches
gpg-agent's SSH socket without an interactive shell parent. gpg-agent's SSH socket without an interactive shell parent.
@@ -71,10 +339,16 @@ the **T14s** (webcam case).
(SUPER+? / root menu Keybindings) now ends in ↩ Back and (SUPER+? / root menu Keybindings) now ends in ↩ Back and
returns to the root picker; spot a couple of submenus (Display, returns to the root picker; spot a couple of submenus (Display,
VPN Tailscale) still Back correctly after the audit pass. VPN Tailscale) still Back correctly after the audit pass.
- [ ] **Keyboard hotplug picker (re-verify after in-flake graduation)** - [ ] **Per-device keyboard menu + hotplug restore (re-verify after in-flake
plug an external keyboard post-login, pick a layout in rofi, confirm graduation)** — update to the current `main`, run `nomarchy-home`, and
it applies per-device only, persists in `settings.keyboard.devices`, relogin with an external keyboard already connected. Open System
and graduates into a `device{}` block on the next rebuild. 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 - [ ] **Snapshots restore + rollback exercise** (residual — the
GUI/polkit half passed on hardware 2026-07-04, a47aa3a): in GUI/polkit half passed on hardware 2026-07-04, a47aa3a): in
`sudo nomarchy-snapshots`, restore a single file (`undochange`) `sudo nomarchy-snapshots`, restore a single file (`undochange`)
@@ -201,28 +475,11 @@ the **T14s** (webcam case).
screenshot loaded. Check that the UI draws on the current theme palette screenshot loaded. Check that the UI draws on the current theme palette
(tools colored properly) and hitting save places the screenshot in (tools colored properly) and hitting save places the screenshot in
`~/Pictures/Screenshots/` while hitting copy places it in the clipboard. `~/Pictures/Screenshots/` while hitting copy places it in the clipboard.
- [ ] **#76 hibernate→resume on a real Nomarchy install** — the V2 VM test - [ ] **#76 no-swap Hibernate notify** (#76 itself is closed — the
proves the resume mechanism + zram coexistence, but QEMU can't feed a hibernate→resume power-cycle **PASSED on TuringMachine 2026-07-12**:
boot-time `resume_offset` for a btrfs *swapfile* nor drive the LUKS Bernardo ran the full hibernate → power off → single LUKS unlock →
initrd unlock, so this triple needs hardware. **Requires a machine session-restored cycle "flawlessly"; this notify check is the one
actually running Nomarchy** (installer default swap = RAM), NOT the dev remaining leftover) — on a **Nomarchy** machine installed
box — that box runs Bernardo's own NixOS (TuringMachine, 25.11); its
layout resumes fine (verified 2026-07-10) but that only corroborates
the *layout*, not Nomarchy's oom.nix/patch-template/rofi wiring.
Candidate: a fresh Nomarchy install on a Latitude, or the dev box
*after* migrating to Nomarchy (docs/MIGRATION.md).
1. Confirm the wiring on the Nomarchy box: `swapon --show` lists
`/swap/swapfile` (Type file) **and** `/dev/zram0` at priority 100;
`cat /proc/cmdline` shows `resume_offset=…` + `resume=` the btrfs UUID.
2. Open apps, note an unsaved in-RAM state. `nomarchy-menu → Power →
Hibernate` (or `systemctl hibernate`). Machine writes image, **powers
off**.
3. Power on: unlock LUKS **once** in the themed prompt; land back in the
**same session** — state restored, not a fresh login.
4. **Pass** = session restored after full power-off + single unlock.
Fail (fresh boot / stuck initrd) → NOW bug in BACKLOG (capture
`journalctl -b -1 | grep -i -E 'hibernat|resume|swap'`).
- [ ] **#76 no-swap Hibernate notify** — on a **Nomarchy** machine installed
with `swap = 0` (or temporarily `sudo swapoff -a` on one): `nomarchy-menu with `swap = 0` (or temporarily `sudo swapoff -a` on one): `nomarchy-menu
→ Power → Hibernate` must surface a desktop notification ("Couldn't → Power → Hibernate` must surface a desktop notification ("Couldn't
hibernate — likely no swap is configured. See docs/MIGRATION.md → hibernate — likely no swap is configured. See docs/MIGRATION.md →
@@ -238,12 +495,118 @@ the **T14s** (webcam case).
survives relogin (the ExecCondition gate on the swapped unit — survives relogin (the ExecCondition gate on the swapped unit —
the eval check proves the wiring, not the runtime gate). **Pass** = the eval check proves the wiring, not the runtime gate). **Pass** =
warm shift at the location's night + toggle/persistence intact. 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).
- [ ] **#150 hypridle wake path now uses the FULL undock** — after pulling
the #150 commit + `nomarchy-home`: the hyprland-side transition/keyboard
tools are byte-identical (proven by store-path equality), so only the
hypridle wake behaviour changed. Two checks: **(a) suspend/resume while
docked** (lid closed, external on) — suspend, resume; pass = you land
back docked on the external with the panel still off and remembered
external-keyboard layouts intact (the wake now runs the full undock,
which `restore_keyboards` after any reload; the old mini did not). **(b)
zero-output rescue** — the #127 case (dock mode, external gone/black,
eDP disabled): pass = the internal panel comes back lit, workspaces move
to it, and `~/nomarchy-display-dump-*.txt` is written iff it stays dark.
Watch for a regression: the full undock clears dock-intent, so confirm a
*transient* zero-output blip on resume while genuinely still docked does
not leave you undocked with the laptop panel on (the external re-add
should re-dock via the watcher — verify it does).
## AMD dev box only ## 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 - [ ] **AMD runtime bits** — VA-API (`vainfo` → radeonsi), amd-pstate EPP
active and PPD switching governors; opt-ins: ROCm (`rocminfo`, a GPU active and PPD switching governors; opt-ins: ROCm (`rocminfo`, a GPU
PyTorch/Ollama smoke) and the XDNA NPU driver loading. PyTorch/Ollama smoke) and the XDNA NPU driver loading.
- [ ] **Fingerprint**`fprintd-enroll` + (opt-in PAM) login/sudo. - [ ] **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, - [ ] **System ▸ Firmware menu on real LVFS hardware** (item #43,
`nomarchy-menu firmware`) — on a machine whose firmware/SSD/dock is `nomarchy-menu firmware`) — on a machine whose firmware/SSD/dock is
on LVFS: open Menu ▸ System ▸ **Firmware**; confirm the terminal runs on LVFS: open Menu ▸ System ▸ **Firmware**; confirm the terminal runs
@@ -253,6 +616,30 @@ the **T14s** (webcam case).
proves the menu row + flow renders; a real capsule write is proves the menu row + flow renders; a real capsule write is
hardware-only.) 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.
- [ ] **Theme-switch bar round-trip (2026-07-19 waybar-poke + gtk-pin fix)**
after `nomarchy-pull` + `nomarchy-home`, switch dark → light → dark
(e.g. boreal → kiln-clay → kiln). Pass = the bar visibly restarts on
each switch and the workspace digits land per theme (dark-on-accent
active pill, dimmed inactives on dark themes) with no color carried
over from the previous theme; then start/stop a screen recording and
toggle airplane mode — the supervisor must survive both pokes
(`pgrep -lx nomarchy-waybar` still lists it).
## Latitude 5310 / 5410 only ## Latitude 5310 / 5410 only
- [ ] **v1 QA batch on-hardware pass** (583708d batch was QEMU-verified) — - [ ] **v1 QA batch on-hardware pass** (583708d batch was QEMU-verified) —
general smoke before the next `main → v1` promotion (broader than general smoke before the next `main → v1` promotion (broader than

View File

@@ -6,6 +6,347 @@ JOURNAL.md grows past ~25 entries, move the tail here.
--- ---
## 2026-07-10 — #78 Omarchy migrant doc (docs/OMARCHY.md)
- **Task:** BACKLOG #78 (promoted from PROPOSED) — Omarchy→Nomarchy
one-pager (`VISION § F`).
- **Did:** New `docs/OMARCHY.md`: leads with the config-model shift
(imperative dotfiles → declarative flake + menu, rollback via
generations), then install (ISO/`nomarchy-install` or MIGRATION.md),
a familiar-bindings table, theming (`SUPER+T`/`nomarchy-theme-sync`),
packages (home.packages / `nix shell`, no AUR), update+rollback
(`nomarchy-pull`/`-rebuild`/`-home`, boot-menu generations,
`nomarchy-doctor`). Added to `docs/README.md` map + VISION § F ✓.
- **Verified:** V0 (docs). Grounded Nomarchy side in code: pulled real
binds from `keybinds.nix`; confirmed all 7 cited commands, the `nsearch`
alias, and the named slugs (gruvbox/rose-pine/nord) exist. Kept
Omarchy-side claims to known facts (Arch, dotfiles, pacman/AUR).
- **Pending:** none.
- **Next suggestion:** NEXT head #76 `[blocked:hw]`; last open PROPOSED is
auto time-of-day theme pair (`§D`, `[big]` — needs splitting).
## 2026-07-10 — #77 doctor hibernate/sleep section (V2 PASS)
- **Task:** BACKLOG #77 (promoted from PROPOSED by Bernardo) — read-only
hibernate section in nomarchy-doctor (`VISION § C`, #76 follow-on).
- **Did:** Added a hibernate/sleep row group to `nomarchy-doctor.sh`: resume
device on cmdline (+ `resume_offset` for a swapfile), disk swap ≥ RAM,
zram active, previous-boot suspend/hibernate error. Self-gates (`skip` on
swap=0); only ok/warn/skip → never fails the sheet. Added `hibernate` to
the `checks.doctor` probe list.
- **Verified:** **V2 PASS**`checks.doctor` VM test green (fresh build,
exit 0; asserts the hibernate row renders + healthy run still 0). **V1**
ran on this box's real LUKS+@swap layout: all three `ok` rows render
(resume set, swap 34G ≥ RAM, zram 16G) and the sheet reaches its verdict.
**V0** bash -n + flake check.
- **Gotcha (fixed + MEMORY):** the doctor runs `set -euo pipefail`; my first
cut aborted before the verdict because a no-match `grep` inside `$(…)` and
a `cond && action` both trip set -e. Fixed with `|| true` / `|| echo 0` and
an `if`. First VM run caught it (exit 1, no verdict) — the test earned its keep.
- **Pending:** none — fully agent-verifiable (read-only checks, no V3).
- **Next suggestion:** NEXT head is #76 `[blocked:hw]`; remaining PROPOSED
(Omarchy doc §F, auto time-of-day theme §D) await triage.
## 2026-07-10 — QA sweep (clean) + VISION-gap pass → 3 PROPOSED
- **Task:** Bernardo — QA/drift sweep, then a VISION-gap pass with proposals.
- **Did (sweep):** Fanned out two subagents (Bernardo OK'd spawning for
efficiency): nomarchy-runner ran the deterministic checks + `bash -n`/
`py_compile` (all PASS; the 4 "needs-args" scripts pass via their flake
derivations); nomarchy-scout hunted fuzzy drift (dead code, stale refs,
TODOs, changed-area contradictions, count/version) — **all clean**.
Spot-checked scout's count claim myself: 24 theme JSONs / 24 previews /
README "24 presets" — consistent. Eval sweep green. **No fixes needed.**
- **Did (VISION):** Verified candidate gaps against the tree before
proposing (killed two false gaps — nomarchy-rebuild/home already tail logs
+ point at doctor on failure = §A shipped; `nvd diff` = §B covered; System
Firmware menu already exists). Filed 3 **PROPOSED** (verified open):
doctor hibernate/sleep section (§C, top Day-2 pick, #76 follow-on),
Omarchy migrant one-pager (§F), auto time-of-day theme pair (§D, `[big]`).
- **Verified:** V0 (agent-notes/docs only). Sweep evidence above.
- **Pending:** none. Proposals await Bernardo's triage into a tier.
- **Next suggestion:** if a PROPOSED is promoted, the doctor hibernate
section is the cleanest next build; else NEXT #76 stays `[blocked:hw]`.
## 2026-07-10 — Icon overrides: user-facing docs (OVERRIDES.md)
- **Task:** Bernardo asked whether the session's features need user docs.
- **Did:** Hibernation already covered (README swapfile line, MIGRATION.md
runbook, discoverable Power-menu row) — no gap. Icons had one: the only
doc was the template comment. Added an "Icon pack" subsection to
`docs/OVERRIDES.md` § Appearance — `nomarchy-theme-sync set icons "<name>"`,
the known-packs table, the ~1 GiB/opt-in rationale, and that the choice is
sticky across `apply` (presets carry no icon field; verified `deep_merge`).
- **Verified:** command path confirmed (`cmd_set` writes any dotted key;
`icons` is a schema key). **Built all 4 packs and listed real theme dirs**
to fix the examples — caught Qogir using capital `Qogir-Dark`/`Light`
(lowercase would silently fall back to hicolor). Docs-only (V0).
- **Pending:** none.
- **Next suggestion:** NEXT #76 `[blocked:hw]`; LATER/QA-sweep next.
## 2026-07-10 — Icon themes: opt-in cross-pack overrides (no default bloat)
- **Task:** LATER "per-theme icon overrides / more icon packs". Recon found
papirus is already ~1 GiB and ships only Papirus/Dark/Light (no free
variety); real variety needs extra packs at ~hundreds of MB each. Bernardo
chose the **opt-in, no-default-bloat** shape (over shipping packs).
- **Did:** `theme.nix` resolves `nomarchy.theme.iconThemePackage` from a
small `iconPacks` registry (papirus·tela·qogir·reversal·numix, by name
prefix). Default themes name `Papirus-*` → resolves to the *plain* papirus
derivation (no symlinkJoin, identical closure); only a theme naming another
pack's set symlinkJoins that one pack in. `stylix.nix` uses it
(mkDefault). Opt-in note in `templates/downstream/home.nix`; ROADMAP §
Icon themes updated (incl. the deliberate no-default-packs decision).
- **Verified:** **V1** — HM activationPackage builds (exit 0). Eval proofs:
default `iconThemePackage` = plain `papirus-icon-theme-*` (zero delta);
with `icons="Tela-dark"``nomarchy-icon-themes` symlinkJoin (papirus+tela)
and name flows, **without building tela**. All 5 pack attrs exist in the
pinned nixpkgs. `template-sot` + `option-docs` + `flake check` green.
- **Pending:** none. No default visual change (identical papirus), so no
screenshot needed; the opt-in render is the downstream user's to see.
- **Next suggestion:** NEXT still #76 `[blocked:hw]`; LATER/QA-sweep next.
## 2026-07-10 — #76 slice 3: hibernate VM test (V2 PASS) → #76 agent-complete
- **Task:** BACKLOG #76 slice 3 — the mandatory V2 hibernate test + V3 queue.
- **Did:** Added `checks.hibernate` (flake.nix), modeled on nixpkgs'
hibernate.nix but wired to Nomarchy's #76 risk: imports oom.nix (zram
default-on), disk swap at lower priority + explicit `boot.resumeDevice`,
systemd-initrd. Test drives hibernate→power-off→resume→crash: ramfs marker
survives resume, a crash-boot loses it (real resume, not a survivor), and
asserts zram@100 + disk-swap<100 so the image lands on disk not volatile
zram. Queued the V3 laptop steps (real LUKS+@swap+offset resume + no-swap
notify) in HARDWARE-QUEUE. Collapsed #76 to a `[blocked:hw]` stub; design
record → ROADMAP § "Hibernation + zram by default".
- **Verified:** **V2 PASS**`nix build .#checks.x86_64-linux.hibernate`
built the driver fresh and exited 0 (all assertions held). `nix flake
check --no-build` green.
- **Pending:** only V3 (dev box / laptop) — #76's last check; close on PASS.
- **Next suggestion:** #76 is now blocked:hw; NEXT head falls to LATER/
PROPOSED triage or a QA sweep — pick per LOOP.md orientation next session.
## 2026-07-10 — #76 slice 2: Hibernate notify-on-failure
- **Task:** BACKLOG #76 remaining slice 2 — per the settled "keep row, notify
on failure" call.
- **Did:** `modules/home/rofi.nix` power menu Hibernate arm now runs
`systemctl hibernate 2>/dev/null || notify-send "Hibernate" "…see
docs/MIGRATION.md"`. On success the call blocks until resume, so the notify
only fires on a genuine failure (e.g. swap=0 no-swap machine). Row stays
unconditional (no hiding).
- **Verified:** **V1**`nix build …homeConfigurations.nomarchy.activationPackage`
green (rebuilds `nomarchy-menu.drv`; `writeShellScriptBin` runs bash -n at
build). Control-flow proof on the arm extracted from the built script:
failing `systemctl` → notify fires; succeeding → no notify. `nix flake
check --no-build` green.
- **Pending:** slice 3 = the V2 hibernate→resume VM test + V3 laptop
(Hibernate→power off→resume on the LUKS+@swap machine; plus confirm the
no-swap notify surfaces in a real session). Both remain open on #76.
- **Next suggestion:** slice 3 — add a `checks.*` hibernate VM test (as far
as QEMU allows) and queue the V3 laptop steps in HARDWARE-QUEUE.
## 2026-07-10 — #76 slice 1: MIGRATION.md hibernation runbook
- **Task:** BACKLOG #76 remaining slice 1 — docs runbook for enabling
hibernation on an existing machine (per the settled "docs not tool" call).
- **Did:** Added § "Enabling hibernation on an existing machine (no reinstall)"
to `docs/MIGRATION.md`: create `@swap` subvol (subvolid=5) + `mkswapfile`
(NOCOW), read `map-swapfile -r` offset, wire `fileSystems."/swap"` +
`swapDevices` + `resumeDevice` + `resume_offset` into system.nix. Flagged
the mount as required on the hand-edit path (installer gets it from
disko-gen hardware-config). Notes zram-priority reservation, no-LUKS
variant, `swap=0` opt-out. Worked example = this dev machine.
- **Verified:** **V0** — read-only commands run live against this machine's
real LUKS(`cryptroot`)+btrfs(`@`)+`/swap/swapfile` layout (RAM 31G, fsuuid,
`findmnt SOURCE`, `mkswapfile` flags, zram@100 vs swapfile@-1 confirms the
design intent). `nix flake check --no-build` green.
- **Pending:** slice 2 — Hibernate row notify-on-failure (V1/V2); then the
V2 hibernate VM test + V3 laptop.
- **Next suggestion:** slice 2 (menu notify-on-failure).
## 2026-07-10 — #76 hibernation design calls (no commit yet → this bookkeeping)
- **Task:** BACKLOG #76 design slice — Bernardo made the open calls.
- **Did:** Recon showed the hibernation half is **already built for new
installs** (installer defaults swap=RAM hibernation-ready `@swap` swapfile;
`patch-template.py` writes swapDevices/resumeDevice/resume_offset; Power
menu Hibernate row + LUKS/hyprlock handling exist). Settled 3 calls:
(1) swap sizing = **exactly RAM** (no change); (2) migration = **docs
runbook in MIGRATION.md**, not a tool; (3) no-swap Hibernate = **keep row,
notify on failure**. Folded into BACKLOG #76 (design ✓, re-sliced remaining:
MIGRATION.md runbook + notify-on-failure menu) and Decisions.
- **Verified:** **V0**`nix flake check --no-build` green (docs-only).
- **Pending:** two remaining slices below; V2 hibernate VM test + V3 laptop.
- **Next suggestion:** slice 1 — `docs/MIGRATION.md` hibernation-enable runbook
(V0), then slice 2 — Hibernate notify-on-failure (V1/V2).
## 2026-07-10 — #76 zram slice: compressed-RAM swap default
- **Task:** BACKLOG #76 (`[big]`) — split; took the low-risk zram-default slice.
- **Did:** `zramSwap` on by default in `modules/nixos/oom.nix` (zstd, 50% RAM,
priority 100) as the memory-pressure layer before earlyoom, following the
earlyoom mkDefault precedent (no toggle). High priority keeps day-to-day
paging in zram so a disk swapfile stays reserved for #76's hibernate image.
Added `checks.zram-swap` runNixOSTest (imports oom.nix). Split #76 in
BACKLOG → remainder is the hibernation half (swapfile/resume/installer).
- **Verified:** **V2**`nix build .#checks…zram-swap` PASS: /dev/zram0 active
in /proc/swaps, `[zstd]` comp_algorithm, priority 100. **V1** full distro
toplevel builds. **V0** flake check --no-build green.
- **Pending:** hibernation half of #76 still open (needs hibernate→resume VM
test + V3 laptop). No V3 for zram (under-load tuning is secondary).
- **Next suggestion:** #76 hibernation half — design default swapSize + the
resume/installer wiring; it's the boot-critical, V2-hibernate + V3 part.
## 2026-07-10 — #41 hardware-confirmed: OpenFile portal floats (V3 close)
- **Task:** BACKLOG #41 — final on-hardware confirmation (Bernardo reported PASS).
- **Did:** #41 already out of NEXT + portal rule shipped
(`hyprland.nix` xdg-desktop-portal-gtk float/center). Bernardo verified the
GTK OpenFile file-picker floats on real hardware; dropped the "OpenFile not
re-probed / optional residual" caveats in HARDWARE-QUEUE — #41 fully closed.
- **Verified:** **V3** (Bernardo, Latitude). No code change. Bookkeeping only;
QA-sweep guards run green this session: `nix flake check --no-build`,
option-docs/template-sot/theme-sync-validate builds, contrast 24×7.
- **Pending:** none for #41. NEXT head is #76 (hibernation + zram, `[big]`).
- **Next suggestion:** split #76 — take the low-risk **zram-default** slice
(V2-testable headlessly) before the boot-critical hibernate/resume work.
## 2026-07-10 — Close #41 GTK portal float rule
- **Task:** BACKLOG #41 residual (GTK file-chooser portal class).
- **Did:** Added float+center for `xdg-desktop-portal-gtk` /
`.xdg-desktop-portal-gtk-wrapped` (desktop + libexec name from package).
Polkit already PASS on Latitude. Removed #41 from NEXT; only #20 remains.
- **Verified:** **V0** windowrule-syntax check path unchanged; class from
package `xdg-desktop-portal-gtk.desktop` / community configs. SoftGL
still cannot materialize portal dialogs.
- **Pending:** optional hardware spot-check OpenFile floats (HARDWARE-QUEUE).
- **Next suggestion:** #20 is `[human]` only — or LATER/PROPOSED triage.
## 2026-07-10 — Product calls: charge live, identity, btop, NVIDIA skip
- **Task:** Bernardo decisions on remaining PROPOSED.
- **Did:** (1) Instant charge-limit: udev GROUP=users on threshold,
menu/powermgmt live sysfs + state, oneshot reads live theme-state +
AC re-apply. (2) Look & Feel already had Theme/wallpaper/night light;
added Reset wallpaper (auto). (3) NVIDIA first-class deferred past v1
(HARDWARE.md). (4) Identity retunes: white L-steps, lumon subtext,
hackerman surface+UI warn/bad, matte-black subtext, miasma bad.
(5) Hand `btop.theme` for 9 identity/whole-swap slugs from palette map.
- **Verified:** **V0** contrast 24×7; **V1** battery-charge-limit VM
(udev restart). **V3** live charge write on real laptop (HARDWARE-QUEUE).
- **Next suggestion:** only #20/#41 blocked, or Decisions.
## 2026-07-10 — Summer pair polish + status CSS + preview recapture
- **Task:** finish agent-able PROPOSED theme leftovers.
- **Did:** summer-day/night — JSON hierarchy (night subtext/muted/overlay
ladder; day overlay≠muted); waybar CSS tokens track JSON roles; font
13px both; day battery 25/10; VPN pill + full status states
(.recording/.available/.on/.activated/battery warn). Recaptured
executive-slate + neon-glass `preview.png` via theme-shot desktop
(480×270). Closed those PROPOSED lines.
- **Verified:** **V0** contrast 24×7 + wholeswap; **V2** theme-shot
summer-day/night/executive-slate/neon-glass — viewed bar crops +
previews. Identity taste retunes stay PROPOSED.
- **Next suggestion:** only blocked NEXT (#20/#41) or product Decisions.
## 2026-07-10 — #74 matrix flags + #75 V2 install (default arm)
- **Task:** NEXT #74 unattended matrix · #75 SoT V2 install after ISO.
- **Did:** `NOMARCHY_TEST_SWAP_GB` / `NOMARCHY_TEST_NO_LUKS` drive
`tools/test-install.sh` go.sh + first-boot LUKS skip; nixpkgs OVMF
fallback. Full offline install V2 (SWAP=2 LUKS) after live ISO rebuild
with starter packages — first-boot themed Boreal, no red banner.
Closed #74/#75. Other matrix arms scripted (not full-ISO re-run).
- **Verified:** **V2** — install powered off ~460s; first-boot.png
viewed (`/tmp/nomarchy-vm-75/first-boot.png`): themed wallpaper +
waybar (logo, clock, modules), doctor glyph, no autogen banner.
- **Next suggestion:** NEXT only #20 [human] + #41 [blocked:hw].
## 2026-07-10 — Loop batch land #64#73 (parallel worktrees)
- **Task:** agent-actionable NEXT #64#73 via worktree agents; land on main.
- **Did:** #64 MIGRATION snapshots · #65 real-uid chown · #66 theme-state
fail-closed reader · #67/#68 theme fidelity+ANSI · #69/#70 audit+import
tools · #71 IR portal docs · #72 template-sot check · #73 MOTD/first-boot
fingerprint+doctor tips. Agents did not push; parent cherry-picked
(stripped agent BACKLOG races). Closed #64#73.
- **Verified:** **V0/V1** — theme-contrast 24×7 pass; template-sot green;
bash -n install; flake check --no-build green.
- **Next suggestion:** #74 unattended matrix + #75 V2 install (KVM).
## 2026-07-10 — Promote agent-actionable PROPOSED → NEXT #64#75
- **Task:** Bernardo: bump non-hw/non-human PROPOSED categories 1+2.
- **Did:** **#64** MIGRATION snapshots · **#65** installer chown ·
**#66** mkFlake theme-state errors · **#67** theme fidelity nits ·
**#68** #52 residual ANSI · **#69** audit identity exemptions ·
**#70** import-pipeline hierarchy · **#71** IR portal docs (a) ·
**#72** template SoT CI · **#73** hardware hints residual · **#74**
unattended install matrix · **#75** SoT V2 install. Breadcrumbs in
PROPOSED; actionable items sit above #20/#41 in NEXT.
- **Verified:** V0 (backlog only).
- **Next suggestion:** queue head **#64** (docs) or batch #67/#68 themes.
## 2026-07-10 — PROPOSED cleanup (stale exam residue)
- **Task:** Bernardo: clean stale PROPOSED items after A/B/C + #52 ship.
- **Did:** Rewrote PROPOSED to open work only — dropped #52 shipped
high-ROI bullets, neon-glass quarantine, empty/shipped installer
audit sections, template seed drift (keys already present), soft
gaps fixed by #50, and “no action” kanagawa/boreal notes. Kept
product day-2, installer follow-ups, theme polish residuals, tooling.
- **Verified:** V0 (backlog hygiene); spot-checked template keys,
latte/mocha #52 fixes, usbutils, package exports.
- **Next suggestion:** triage the lean PROPOSED list or HARDWARE-QUEUE.
## 2026-07-10 — Pending closeout: #14, #57 V2, #41 slice
- **Task:** pending V2/CI/float leftovers after A/B/C batch.
- **Did:** **#14 closed** — first scheduled lock bump landed
`8fded63` (2026-07-06 Mon 05:20 UTC) as `nomarchy-bump` /
`chore(lock): scheduled upstream bump` (Actions token push works).
**#57 V2** — `checks.live-install-entry` asserts live HM gen has
`nomarchy-install.desktop` (Name/Terminal/Exec) + installer bin +
Tools menu gate in `nomarchy-menu`. **#41 slice** — float/center rules
for hyprpolkitagent + pinentry-qt from package app-id strings;
`tools/capture-float-classes.nix` harness (softGL empty clients);
residual GTK portal stays NEXT `#41 [blocked:hw]`. HARDWARE-QUEUE
float confirm steps added.
- **Verified:** **V2** live-install-entry green; windowrule-syntax green.
SoftGL class capture inconclusive (empty clients). **V3** float confirm
+ fingerprint/#60 still HARDWARE-QUEUE. **#20** still `[human]`.
- **Next suggestion:** Bernardo: #20 KVM runner, or HARDWARE-QUEUE pass.
## 2026-07-10 — #55 fingerprint menu + #56 human rebuild errors
- **Task:** NEXT #55 (System Fingerprint) + #56 (rebuild failure UX).
- **Did:** #55 — System row self-gated on fprintd-list; enroll/list/verify/
delete-all terminal flows; Use for login writes
`settings.fingerprint.pam` (hardware.nix default from stateFile);
HARDWARE.md §5 + HARDWARE-QUEUE V3 enroll. #56 — sys-update /
sys-rebuild / home-update tee last 40 lines + nomarchy-doctor pointer;
theme-sync run_switch notify/die text. Closed #55/#56.
- **Verified:** **V1** — HM template rebuild (menu + theme-sync);
option-docs green; failure-footer shell smoke. **V3 pending:** real
fingerprint enroll (queue). **V2 pending:** forced bad sys-rebuild in
VM (footer text pure/logic covered).
- **Next suggestion:** queue was AC; remaining NEXT is blocked items
(#14/#20/#41) or PROPOSED triage.
## 2026-07-10 — #58 nomarchy-detect-hw CLI
- **Task:** NEXT #58 — post-install re-probe package (HARDWARE.md §8).
- **Did:** `pkgs/nomarchy-detect-hw` wrapping installers hardware-db;
human report + snippets + `--raw` protocol; overlay + systemPackages;
HARDWARE.md §8 updated. Closed #58.
- **Verified:** **V1** — package builds; ran on this AMD laptop: correct
MODULE/NOMARCHY lines (amd cpu/gpu, laptop, ssd, fingerprint, IR cam,
NPU) and printable system.nix snippets.
- **Next suggestion:** #55 fingerprint menu or #56 human rebuild errors.
## 2026-07-10 — #57 live Install Nomarchy affordance
- **Task:** NEXT #57 — durable live-ISO install surface (not only toast).
- **Did:** `xdg.desktopEntries.nomarchy-install` (Terminal=true) on live
HM; Tools Install Nomarchy self-gated on `command -v nomarchy-install`;
welcome toast wording updated. Closed #57.
- **Verified:** **V1** — live config eval exposes entry name/exec;
`downstream-template-home` rebuilds with menu gate. **V2 pending:**
ISO rebuild + smoke that the .desktop appears in the session.
- **Next suggestion:** #58 `nomarchy-detect-hw` CLI.
## 2026-07-10 — #63 generated Waybar logo + powermenu
- **Task:** NEXT #63 — reverse parity: generated bar lacked whole-swap
logo → menu and power icon → power menu.
- **Did:** `custom/nomarchy` (left, Nomarchy.ttf U+F000) + `custom/powermenu`
(right, U+F011 → `nomarchy-menu power`) in generated settings/style.
Fixed Nix `\uf000` pitfall (no `\u` escapes — use literal UTF-8).
Whole-swaps already had both; no JSON/CSS edits. Closed #63.
- **Verified:** **V2**`THEME=tokyo-night` theme-shot: left monogram N,
right power glyph, accent styling; config modules-left/right correct.
Crop paths under `/tmp/theme-shot-tn2-*.png`.
- **Next suggestion:** product path #55 fingerprint menu (or #57 live install).
## 2026-07-10 — #60 name-agnostic system-battery scan ## 2026-07-10 — #60 name-agnostic system-battery scan
- **Task:** NEXT #60 — align charge-limit / menu / waybar / doctor / - **Task:** NEXT #60 — align charge-limit / menu / waybar / doctor /
installer chassis with battery-notifys type=Battery + !scope=Device installer chassis with battery-notifys type=Battery + !scope=Device

File diff suppressed because it is too large Load Diff

View File

@@ -7,7 +7,12 @@ here the moment a debugging session teaches you something a future
iteration would otherwise rediscover. iteration would otherwise rediscover.
## Testing & VM recipes ## Testing & VM recipes
- **theme-shot softGL cannot start Ghostty** — `btop.png` is best-effort - **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 (usually identical to desktop). Guest asserts on
`~/.config/btop/themes/nomarchy.theme` prove baking; the TUI look is `~/.config/btop/themes/nomarchy.theme` prove baking; the TUI look is
hardware/GL tier (HARDWARE-QUEUE). hardware/GL tier (HARDWARE-QUEUE).
@@ -70,14 +75,17 @@ iteration would otherwise rediscover.
examples to crib from: `distro-id` (boots + `switch-to-configuration examples to crib from: `distro-id` (boots + `switch-to-configuration
dry-activate`), `hardware-toggles` (kernel cmdline/PAM assertions), dry-activate`), `hardware-toggles` (kernel cmdline/PAM assertions),
`battery-charge-limit` (fake Mains adapter via `test_power`, real udev `battery-charge-limit` (fake Mains adapter via `test_power`, real udev
uevent, `InvocationID` change proves the restart). 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 - Themed-desktop screenshots work headlessly: software-GL Hyprland
(`LIBGL_ALWAYS_SOFTWARE` on virtio-gpu) + `machine.screenshot()` QMP (`LIBGL_ALWAYS_SOFTWARE` on virtio-gpu) + `machine.screenshot()` QMP
dump — prototyped 2026-06-19, kept as the fallback for theme previews dump — prototyped 2026-06-19, kept as the fallback for theme previews
(§ Visual theme picker). (§ Visual theme picker).
- Hyprland/Ghostty need guest GL (`virtio-vga-gl`, `gl=on`) in - Hyprland needs guest GL (`virtio-vga-gl`, `gl=on`) in interactive QEMU
interactive QEMU or the session won't start; black screen ≈ missing GL or the session won't start; black screen ≈ missing GL
(docs/TESTING.md § gotchas). (docs/TESTING.md § gotchas). Kitty is the sole terminal (no Ghostty).
- No KVM = slow, not broken; don't read slowness as failure. - No KVM = slow, not broken; don't read slowness as failure.
## Known-broken / watchlist ## Known-broken / watchlist
@@ -92,10 +100,10 @@ iteration would otherwise rediscover.
- **NixOS release bump is a trap:** the discarded attempt - **NixOS release bump is a trap:** the discarded attempt
(branch deleted 2026-06-22) hit a Hyprland OOM blocker; a redo is a (branch deleted 2026-06-22) hit a Hyprland OOM blocker; a redo is a
deliberate `v2`, never part of routine lock bumps. deliberate `v2`, never part of routine lock bumps.
- `theme-state.json` is git-tracked inside an 86 MB flake tree, so every - `state.json` is git-tracked inside an 86 MB flake tree, so every
state write re-copies the source before eval — the wallpapers-artifact state write re-copies the source before eval — the wallpapers-artifact
split (BACKLOG LATER) is the decided fix (§ Faster switches). split (BACKLOG LATER) is the decided fix (§ Faster switches).
- **Friendly theme-state load** (`modules/theme-state-read.nix`, #66): - **Friendly theme-state load** (`modules/state-read.nix`, #66):
`builtins.tryEval` does **not** catch `readFile`/`fromJSON` failures — `builtins.tryEval` does **not** catch `readFile`/`fromJSON` failures —
gate with `pathExists` + empty/non-object checks before `fromJSON`. gate with `pathExists` + empty/non-object checks before `fromJSON`.
Subtle JSON syntax errors still surface from nlohmann (line/col); Subtle JSON syntax errors still surface from nlohmann (line/col);
@@ -103,6 +111,15 @@ iteration would otherwise rediscover.
check onto the whole return set or lazy attr access skips it. check onto the whole return set or lazy attr access skips it.
## Design invariants ## 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): - **Waybar status is never color-only** (item 28 sweep, iteration #69):
every status module must distinguish its states by SHAPE (glyph) or every status module must distinguish its states by SHAPE (glyph) or
presence (self-hide), never color alone — good/warn/bad collapse under presence (self-hide), never color alone — good/warn/bad collapse under
@@ -120,6 +137,34 @@ iteration would otherwise rediscover.
without a hierarchy pass. without a hierarchy pass.
## Gotchas (cost a debugging session once) ## 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 - Waybar `layer: top` renders above **even real-fullscreen windows** — the
bar draws over a fullscreen video. `layer: bottom` lets the fullscreen bar draws over a fullscreen video. `layer: bottom` lets the fullscreen
surface cover it while the exclusive zone still reserves the bar's space surface cover it while the exclusive zone still reserves the bar's space
@@ -155,6 +200,10 @@ iteration would otherwise rediscover.
- Hyprland 0.55 renames `stayfocused``stay_focused` (and similar - Hyprland 0.55 renames `stayfocused``stay_focused` (and similar
underscore effects); `stayfocused 1` is `invalid field type` at parse underscore effects); `stayfocused 1` is `invalid field type` at parse
(§ polkit workspace rules, 2026-07-10 hardware). (§ 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 - Hyprland 0.53 rewrote window rules: `windowrulev2` is a hard error and
the old rule-first `float, class:^…$` no longer parses — both surface a the old rule-first `float, class:^…$` no longer parses — both surface a
red config-error banner on the default desktop. Hyprlang legacy form is red config-error banner on the default desktop. Hyprlang legacy form is

View File

@@ -20,7 +20,7 @@ consumes the palette from the state JSON, never a side file.
## Design responsibilities ## Design responsibilities
1. **Holistic design:** a theme spans Hyprland (borders, shadows, 1. **Holistic design:** a theme spans Hyprland (borders, shadows,
animations), Waybar, Ghostty, btop, fastfetch, rofi, and wallpaper — animations), Waybar, Kitty, btop, fastfetch, rofi, and wallpaper —
one coherent identity, no unthemed corner. one coherent identity, no unthemed corner.
2. **Color theory:** create or adapt advanced palettes. Draw inspiration 2. **Color theory:** create or adapt advanced palettes. Draw inspiration
from established aesthetics (Everforest, Nord, Gruvbox) or r/unixporn from established aesthetics (Everforest, Nord, Gruvbox) or r/unixporn

View File

@@ -137,7 +137,10 @@ Maintenance follows the same ladder:
- **Flake lock bumps**: treat as maximum-blast-radius changes. Build, - **Flake lock bumps**: treat as maximum-blast-radius changes. Build,
boot the VM, run the full regression checklist, and do a visual boot the VM, run the full regression checklist, and do a visual
spot-check of the session (themes can shift with upstream package spot-check of the session (themes can shift with upstream package
changes). Never merge a lock bump on "it evaluates". 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`, - **Theme imports / new themes**: import via `tools/import-palettes.py`,
then the full §3 visual protocol; additionally verify the theme-switch then the full §3 visual protocol; additionally verify the theme-switch
round trip (into the new theme and back out). round trip (into the new theme and back out).

View File

@@ -3,6 +3,10 @@
How Nomarchy enables CPUs, GPUs, laptops, firmware, and peripherals — and How Nomarchy enables CPUs, GPUs, laptops, firmware, and peripherals — and
what to do when your machine is not in the happy path. 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 > **Queue:** [`agent/BACKLOG.md`](../agent/BACKLOG.md) (PROPOSED Hardware
> product). Product framing: [`VISION.md`](VISION.md) § A. Design history: > product). Product framing: [`VISION.md`](VISION.md) § A. Design history:
> [`ROADMAP.md`](ROADMAP.md). Docs map: [`README.md`](README.md). > [`ROADMAP.md`](ROADMAP.md). Docs map: [`README.md`](README.md).
@@ -132,10 +136,12 @@ confirm → `fwupdmgr update`. It never auto-flashes — `fwupdmgr update`
confirms each device and prompts for the reboot a capsule needs; pillar 1 confirms each device and prompts for the reboot a capsule needs; pillar 1
(rock-stable) forbids silent BIOS writes. (rock-stable) forbids silent BIOS writes.
**Hints (#43 / #73):** MOTD cheat-sheet line + `nomarchy-control-center **Hints (#43 / #73):** MOTD cheat-sheet line when `fwupdmgr` is on PATH.
--first-boot` tip when `fwupdmgr` is on PATH. Fingerprint / doctor tips Fingerprint / doctor tips follow the same pattern (fingerprint MOTD only
follow the same pattern (fingerprint MOTD only when when `nomarchy.hardware.fingerprint.enable`). A one-shot toast in the
`nomarchy.hardware.fingerprint.enable`). 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 **Still queued:** a Doctor “updates available” check, and
Waybar/updates-panel integration. Waybar/updates-panel integration.
@@ -167,29 +173,79 @@ Elan, …) via `lsusb` or `/sys/bus/usb/.../idVendor`. On hit:
```nix ```nix
nomarchy.hardware.fingerprint.enable = true; # services.fprintd nomarchy.hardware.fingerprint.enable = true; # services.fprintd
# nomarchy.hardware.fingerprint.pam = true; # login + sudo (opt-in) # nomarchy.hardware.fingerprint.pam = true; # login + sudo (opt-in)
# nomarchy.hardware.fingerprint.parallel = false; # sequential prompt instead
``` ```
### Enroll (menu or CLI) ### Enroll (menu or CLI)
**Shipped #55:** System Fingerprint (self-gated when `fprintd-list` is **Shipped #55:** System Fingerprint (self-gated when `fprintd-list` is
on PATH) — Enroll / List / Verify / Delete all, plus **Use for login** on PATH) — a single **Fingerprint (on/off)** switch, plus Enroll / List /
which writes `settings.fingerprint.pam` and applies on the next Verify / Delete all (all usable while it's off — turning it on needs an
`sys-rebuild` (option default follows theme-state.json). 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 **Hints (#73):** MOTD line when `fingerprint.enable` is on; first-boot
tip when `fprintd-list` is on PATH (`SUPER+M → System Fingerprint` / tip when `fprintd-list` is on PATH (`SUPER+M → System Fingerprint` /
`fprintd-enroll`). No permanent nag without a reader. `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 ```sh
# CLI still works: # CLI still works:
fprintd-enroll fprintd-enroll
fprintd-list "$USER" fprintd-list "$USER"
# or: System Fingerprint Use for login (on) → sys-rebuild nomarchy-fingerprint toggle # or on | off | status — what the menu row runs
``` ```
PAM stays opt-in on purpose: password-only remains the cautious default 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. 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 ### Doctor
`nomarchy-doctor` reports fprintd unit + enroll status when present. `nomarchy-doctor` reports fprintd unit + enroll status when present.
@@ -346,7 +402,40 @@ nomarchy-detect-hw --raw # MODULE / NOMARCHY / DETAIL protocol lines
Prints suggested `hardwareProfile` and `system.nix` lines; **does not** Prints suggested `hardwareProfile` and `system.nix` lines; **does not**
rewrite the flake. Paste after review, then `sudo nixos-rebuild switch`. rewrite the flake. Paste after review, then `sudo nixos-rebuild switch`.
## 9. Adding your model to the distro ## 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): For contributors (and power users who will PR):
@@ -374,15 +463,15 @@ For contributors (and power users who will PR):
`nixosModules` is queued so lock bumps cannot silently break installs. `nixosModules` is queued so lock bumps cannot silently break installs.
5. Optional: note on-hardware QA steps in `agent/HARDWARE-QUEUE.md`. 5. Optional: note on-hardware QA steps in `agent/HARDWARE-QUEUE.md`.
Install-tested status goes in the hall-of-fame table (§9).
## 10. Doctor and hardware health (current vs target) ## 11. Doctor and hardware health (current vs target)
**Today** (`nomarchy-doctor`): failed units, disk space, theme-state **Today** (`nomarchy-doctor`): failed units, disk space, state.json
validity/git, generation age, snapper timer. **No** WiFi, GPU, fwupd, validity/git, generation age, snapper; hardware section self-gates per
or fingerprint checks. machine (NetworkManager, audio sink, GPU smoke, fprintd, fwupd, charge
limit, battery health, hibernate/zram). All read-only; each ✖ prints one
**Target checks** (queued, all read-only, each failure prints one fix fix command.
command — same doctor contract):
| Check | Pass condition | Suggested fix line | | Check | Pass condition | Suggested fix line |
|-------|----------------|--------------------| |-------|----------------|--------------------|
@@ -391,9 +480,12 @@ command — same doctor contract):
| GPU accel | `glxinfo`/`vainfo` smoke (if installed) | check `hardwareProfile` / drivers | | GPU accel | `glxinfo`/`vainfo` smoke (if installed) | check `hardwareProfile` / drivers |
| Fingerprint | if USB VID matched / fprintd unit | `fprintd-enroll` or enable option | | Fingerprint | if USB VID matched / fprintd unit | `fprintd-enroll` or enable option |
| fwupd | daemon active; optional “updates pending” warn | `fwupdmgr get-updates` | | fwupd | daemon active; optional “updates pending” warn | `fwupdmgr get-updates` |
| Battery threshold | if laptop + limit set, sysfs writable | power docs | | 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 |
## 11. Option quick reference ## 12. Option quick reference
Full tables: [README § options](../README.md). Hardware-shaped surface: Full tables: [README § options](../README.md). Hardware-shaped surface:
@@ -402,7 +494,7 @@ Full tables: [README § options](../README.md). Hardware-shaped surface:
| `mkFlake.hardwareProfile` | nixos-hardware name or list | | `mkFlake.hardwareProfile` | nixos-hardware name or list |
| `nomarchy.hardware.intel.enable` / `.guc` / `.computeRuntime` | Intel gap layer | | `nomarchy.hardware.intel.enable` / `.guc` / `.computeRuntime` | Intel gap layer |
| `nomarchy.hardware.amd.enable` / `.pstate` / `.vaapi` / `.rocm.*` | AMD gap layer | | `nomarchy.hardware.amd.enable` / `.pstate` / `.vaapi` / `.rocm.*` | AMD gap layer |
| `nomarchy.hardware.fingerprint.enable` / `.pam` | fprintd + PAM | | `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.npu.enable` | in-kernel NPU only |
| `nomarchy.hardware.latestKernel` | `linuxPackages_latest` | | `nomarchy.hardware.latestKernel` | `linuxPackages_latest` |
| `nomarchy.hardware.camera.hideIrSensor` / `.irMatch` | dual-sensor webcams (v4l2 only; §7) | | `nomarchy.hardware.camera.hideIrSensor` / `.irMatch` | dual-sensor webcams (v4l2 only; §7) |
@@ -411,7 +503,7 @@ Full tables: [README § options](../README.md). Hardware-shaped surface:
| `nomarchy.system.bluetooth.enable` | BT stack | | `nomarchy.system.bluetooth.enable` | BT stack |
| `services.fwupd.enable` | LVFS (native NixOS, default on) | | `services.fwupd.enable` | LVFS (native NixOS, default on) |
## 12. Ease summary ## 13. Ease summary
| Situation | Effort | | Situation | Effort |
|-----------|--------| |-----------|--------|
@@ -423,7 +515,7 @@ Full tables: [README § options](../README.md). Hardware-shaped surface:
| ROCm / NPU userspace | High — opt-in + expert | | ROCm / NPU userspace | High — opt-in + expert |
| Contribute a new DMI line | Medium for someone who can PR | | Contribute a new DMI line | Medium for someone who can PR |
## 13. Related files ## 14. Related files
| Path | Role | | Path | Role |
|------|------| |------|------|

View File

@@ -169,6 +169,12 @@ ryzenadj)**, **no Secure Boot**, and the **stateVersion override**.
nomarchy.hardware.amd.enable = true; # amd-pstate + radeonsi VA-API nomarchy.hardware.amd.enable = true; # amd-pstate + radeonsi VA-API
# nomarchy.hardware.amd.rocm.enable = true; # opt-in GPU compute (multi-GB) # 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. # CRITICAL: keep your ORIGINAL install's value — never Nomarchy's 26.05.
system.stateVersion = "24.11"; system.stateVersion = "24.11";
} }
@@ -200,9 +206,9 @@ emacs setup).
} }
``` ```
### `theme-state.json` ### `state.json`
Copy the template's `theme-state.json` (or let `nomarchy-menu theme` write Copy the template's `state.json` (or let `nomarchy-menu theme` write
it after the switch). Your old `nomarchy-state.nix` prototype (schema it after the switch). Your old `nomarchy-state.nix` prototype (schema
`nomarchy.theme = "nord"` …) is **retired** — the current distro uses this `nomarchy.theme = "nord"` …) is **retired** — the current distro uses this
JSON. `nord` is a shipped Nomarchy theme, so you lose nothing. JSON. `nord` is a shipped Nomarchy theme, so you lose nothing.
@@ -226,14 +232,23 @@ Expect to resolve a few 25.11→26.05 option renames.
# breaks, REBOOT and you are back in your old generation, untouched. # breaks, REBOOT and you are back in your old generation, untouched.
sudo nixos-rebuild test --flake ~/nomarchy-migrate#default sudo nixos-rebuild test --flake ~/nomarchy-migrate#default
# Bring the desktop (home-manager) up: # Bring the desktop (home-manager) up BEFORE the first graphical login.
home-manager switch --flake ~/nomarchy-migrate#bernardo # 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 Log into Hyprland and sanitycheck: Waybar renders, `SUPER+M` opens the
menu, theming is coherent, `SUPER+?` shows the cheatsheet. Confirm the menu, theming is coherent, `SUPER+?` shows the cheatsheet, **no** yellow
machinespecific things you care about still work — suspend/hibernate, the Hyprland autogenerated banner. Confirm the machinespecific things you
AMD GPU (`vainfo` → radeonsi), display brightness. 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 If anything is wrong: **reboot → old generation.** Nothing is committed as
default yet. default yet.
@@ -245,10 +260,80 @@ default yet.
- **Power:** verify `powerprofilesctl get` works and the Waybar battery / - **Power:** verify `powerprofilesctl get` works and the Waybar battery /
powerprofile icons open the power menu. Your ryzenadj scripts are gone; 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. if you miss a specific TDP behaviour, that's a followup, not a blocker.
- **Theme:** `nomarchy-menu theme` → pick **nord** (writes - **Theme:** `nomarchy-menu theme` → pick a preset (writes
`theme-state.json`). `state.json`).
- **Snapshots:** `nomarchy-menu` → System → Snapshots should see your - **Snapshots:** `nomarchy-menu` → System → Snapshots should see your
existing `@snapshots` subvolume. 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 - **Secrets/services:** if you relied on agenixmanaged secrets for a
service, layer `agenix` back into `system.nix` as a machinespecific service, layer `agenix` back into `system.nix` as a machinespecific
import (Nomarchy doesn't manage secrets). If you don't need them, leave import (Nomarchy doesn't manage secrets). If you don't need them, leave
@@ -299,6 +384,26 @@ lock).
prototype). prototype).
- Prune old generations: `sudo nix-collect-garbage -d`. - 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) ## Enabling hibernation on an existing machine (no reinstall)
@@ -402,6 +507,25 @@ 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 ## TuringMachine — the decisions, at a glance
| Item | Choice | | Item | Choice |

View File

@@ -67,6 +67,7 @@ live cheatsheet — you never have to memorize this table.
| `SUPER+SHIFT+T` | Next wallpaper | | `SUPER+SHIFT+T` | Next wallpaper |
| `SUPER+E` | File manager (yazi) | | `SUPER+E` | File manager (yazi) |
| `SUPER+X` | Power menu (lock / suspend / hibernate / reboot …) | | `SUPER+X` | Power menu (lock / suspend / hibernate / reboot …) |
| `SUPER+CTRL+L` | Lock screen |
| `SUPER+?` | Keybindings cheatsheet | | `SUPER+?` | Keybindings cheatsheet |
| `Print` | Screenshot region → clipboard | | `Print` | Screenshot region → clipboard |
| `SUPER+CTRL+<key>` | Jump straight to a menu module (clipboard `V`, calc `C`, emoji `E`, capture `S`, …) | | `SUPER+CTRL+<key>` | Jump straight to a menu module (clipboard `V`, calc `C`, emoji `E`, capture `S`, …) |
@@ -80,11 +81,11 @@ Both distros reskin everything from one place. In Nomarchy:
- **`SUPER+T`** (or `nomarchy-menu theme`) — pick from 24 presets with live - **`SUPER+T`** (or `nomarchy-menu theme`) — pick from 24 presets with live
previews; it rebuilds and applies to Hyprland, Waybar, the terminal, previews; it rebuilds and applies to Hyprland, Waybar, the terminal,
btop and GTK/Qt at once. btop and GTK/Qt at once.
- Or from a shell: `nomarchy-theme-sync apply <slug>` (e.g. `gruvbox`, - Or from a shell: `nomarchy-state-sync apply <slug>` (e.g. `gruvbox`,
`rose-pine`, `nord`). `rose-pine`, `nord`).
- Tweak gaps / rounding / fonts / opacity / icon pack without leaving the - Tweak gaps / rounding / fonts / opacity / icon pack without leaving the
theme system — see [OVERRIDES.md](OVERRIDES.md). One git-tracked JSON theme system — see [OVERRIDES.md](OVERRIDES.md). One git-tracked JSON
(`theme-state.json`) is the single source of truth. (`state.json`) is the single source of truth.
--- ---

View File

@@ -9,16 +9,16 @@ rule of thumb:
## 1. Appearance (gaps, colors, rounding, fonts, opacity) — use the CLI ## 1. Appearance (gaps, colors, rounding, fonts, opacity) — use the CLI
Everything that defines the *look* flows from `theme-state.json`, the single Everything that defines the *look* flows from `state.json`, the single
source of truth. Change it with `nomarchy-theme-sync`, which writes the JSON source of truth. Change it with `nomarchy-state-sync`, which writes the JSON
and rebuilds — one generation, applied to Hyprland, Waybar, Ghostty, btop and and rebuilds — one generation, applied to Hyprland, Waybar, Kitty, btop and
Stylix at once: Stylix at once:
```sh ```sh
nomarchy-theme-sync set ui.gapsOut 16 # gaps, borders, rounding, opacity nomarchy-state-sync set ui.gapsOut 16 # gaps, borders, rounding, opacity
nomarchy-theme-sync set ui.rounding 0 nomarchy-state-sync set ui.rounding 0
nomarchy-theme-sync set fonts.mono "FiraCode Nerd Font" nomarchy-state-sync set fonts.mono "FiraCode Nerd Font"
nomarchy-theme-sync apply gruvbox # whole palette nomarchy-state-sync apply gruvbox # whole palette
``` ```
### Icon pack ### Icon pack
@@ -28,7 +28,7 @@ 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: opt-in rather than a cost every install pays). To switch to another pack:
```sh ```sh
nomarchy-theme-sync set icons "Tela-dark" # or "" to return to Papirus-by-mode 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 Only the pack you name is pulled into your system (the first switch downloads
@@ -55,11 +55,11 @@ 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: day and night themes, and set the sunrise/sunset times. The same from a shell:
```sh ```sh
nomarchy-theme-sync set settings.autoTheme.day summer-day --no-switch nomarchy-state-sync set settings.autoTheme.day summer-day --no-switch
nomarchy-theme-sync set settings.autoTheme.night summer-night --no-switch nomarchy-state-sync set settings.autoTheme.night summer-night --no-switch
nomarchy-theme-sync set settings.autoTheme.sunset 20:00 --no-switch nomarchy-state-sync set settings.autoTheme.sunset 20:00 --no-switch
nomarchy-theme-sync set settings.autoTheme.enable true --no-switch nomarchy-state-sync set settings.autoTheme.enable true --no-switch
nomarchy-theme-sync auto --force # one rebuild: installs the timer + applies now nomarchy-state-sync auto --force # one rebuild: installs the timer + applies now
``` ```
State lives in `settings.autoTheme`: State lives in `settings.autoTheme`:
@@ -67,14 +67,15 @@ State lives in `settings.autoTheme`:
| Field | Meaning | | Field | Meaning |
|---|---| |---|---|
| `enable` | on / off | | `enable` | on / off |
| `day` / `night` | theme slugs — e.g. `summer-day` / `summer-night` (see `nomarchy-theme-sync list`) | | `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) | | `sunrise` / `sunset` | switch times, `"HH:MM"` (24-hour) |
A timer re-checks every ~15 minutes: the day theme applies around sunrise, the A timer fires exactly at the configured sunrise and sunset (plus once shortly
night theme around sunset — but it only rebuilds when the active theme after login, and it catches up on a transition missed while the machine was
actually needs to change, so most checks do nothing. Enabling rebuilds once off or asleep) — it only rebuilds when the active theme actually needs to
(to install the timer); disabling is instant. Preview the current decision change. The times are baked into the timer, so editing them (or enabling)
without switching with `nomarchy-theme-sync auto --which`. rebuilds once; 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 These values are deliberately kept at normal priority in the modules, so they
stay owned by the theme system. If you *insist* on pinning one in `home.nix` stay owned by the theme system. If you *insist* on pinning one in `home.nix`
@@ -98,9 +99,8 @@ in your `home.nix` wins — no `mkForce` needed:
animations.enabled = false; animations.enabled = false;
}; };
programs.ghostty.settings = { programs.kitty.settings = {
window-padding-x = 4; # was 12 window_padding_width = 4; # was 12
window-decoration = true;
}; };
} }
``` ```
@@ -198,7 +198,7 @@ value is theme-owned at normal priority — either change it via the CLI (§1) o
| You want to… | Do this | | 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` | | Change input / misc / monitor / animations / terminal chrome | plain assignment in `home.nix` |
| Arrange monitors declaratively | `nomarchy.monitors` (values via `nwg-displays`) | | Arrange monitors declaratively | `nomarchy.monitors` (values via `nwg-displays`) |
| Add keybinds / autostarts | add to the `bind` / `exec-once` list (concatenates) | | Add keybinds / autostarts | add to the `bind` / `exec-once` list (concatenates) |

View File

@@ -6,6 +6,7 @@ tree for the same facts.
| Path | Audience | Role | | Path | Audience | Role |
|------|----------|------| |------|----------|------|
| [../README.md](../README.md) | Everyone | What Nomarchy is, install, options tables | | [../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 | | [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 ✓) | | [ROADMAP.md](ROADMAP.md) | Maintainers + agents | Design/decision records + shipped log (historical ✓) |
| [HARDWARE.md](HARDWARE.md) | Users + agents | Firmware, profiles, drivers, unsupported machines | | [HARDWARE.md](HARDWARE.md) | Users + agents | Firmware, profiles, drivers, unsupported machines |

View File

@@ -21,17 +21,21 @@ home-manager generations # newest first, one per theme/HM change
/nix/store/…-home-manager-generation/activate # run the one you want /nix/store/…-home-manager-generation/activate # run the one you want
``` ```
The same picker lives in the menu: **System Rollback** lists the The same picker lives in the menu: **System Recovery Desktop
recent desktop generations — pick one and it activates. 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-theme-sync apply 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 boreal` (or any preset). If a switch failed halfway, the state file is written
*before* the rebuild — fix the cause and re-run *before* the rebuild — fix the cause and re-run
`home-manager switch --flake ~/.nomarchy` (or `nomarchy-home`). `home-manager switch --flake ~/.nomarchy` (or `nomarchy-home`).
Your flake checkout is a git repo, and with auto-commit enabled every Your flake checkout is a git repo, and with auto-commit enabled every
apply is a commit: `git -C ~/.nomarchy log` to see what changed, apply is a commit — and every `nomarchy-pull`/`-rebuild`/`-home` first
`git revert` the culprit, then `nomarchy-home`. 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 ## 2. The desktop won't start at all
@@ -45,6 +49,9 @@ journalctl --user -b # Hyprland + the user services
- Rolling back the *desktop* half is §1 (works from the TTY). - Rolling back the *desktop* half is §1 (works from the TTY).
- If greetd/tuigreet itself is broken, that's system-side → §3. - 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, - In a **VM**, a black screen is almost always missing guest OpenGL,
not your config — see docs/TESTING.md §5. not your config — see docs/TESTING.md §5.
- First boot after an install came up *unthemed*: read - First boot after an install came up *unthemed*: read
@@ -53,22 +60,59 @@ journalctl --user -b # Hyprland + the user services
## 3. A system change broke it — boot an older generation ## 3. A system change broke it — boot an older generation
Reboot and pick an older **NixOS generation** in the systemd-boot menu Reboot and pick an older **NixOS generation** in the systemd-boot menu
(hold a key during firmware handoff if the menu doesn't linger; the (hold a key during firmware handoff if the menu doesn't linger). That
last 10 generations are kept). That boots yesterday's system unchanged. boots yesterday's system unchanged.
Booting an old generation is temporary — the default entry is still the 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 broken one. Make the fix stick from the working boot: revert the change
in `~/.nomarchy` (`git -C ~/.nomarchy revert …` or edit `system.nix` in `~/.nomarchy` (`git -C ~/.nomarchy revert …` or edit `system.nix`
back), then `nomarchy-rebuild`. back), then `nomarchy-rebuild`.
## 4. Files went missing or wrong — snapshots (BTRFS installs) 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 With `nomarchy.system.snapper.enable` (the installer's default on
BTRFS), the root filesystem has hourly/daily history, and BTRFS), the root filesystem has hourly/daily history, and
`nixos-rebuild-snap` leaves a snapshot right before a rebuild: `nixos-rebuild-snap` leaves a snapshot right before a rebuild:
- **GUI:** menu System Snapshots (btrfs-assistant; expects a polkit - **GUI:** menu System Recovery Files (BTRFS) (btrfs-assistant;
password prompt). expects a polkit password prompt).
- **Terminal/SSH:** `sudo nomarchy-snapshots` — browse a snapshot's - **Terminal/SSH:** `sudo nomarchy-snapshots` — browse a snapshot's
diff, **restore changed files** (snapper `undochange`), or **roll the diff, **restore changed files** (snapper `undochange`), or **roll the
whole root back** to a snapshot and reboot. Both destructive actions whole root back** to a snapshot and reboot. Both destructive actions
@@ -77,7 +121,7 @@ BTRFS), the root filesystem has hourly/daily history, and
Snapshots are the undo for *data on disk*; the Nix config model is Snapshots are the undo for *data on disk*; the Nix config model is
undone by generations (§1/§3) — use each for its half. undone by generations (§1/§3) — use each for its half.
## 5. Last resort — from the outside ## 6. Last resort — from the outside
Boot the Nomarchy ISO (any NixOS ISO works), then: Boot the Nomarchy ISO (any NixOS ISO works), then:
@@ -88,5 +132,5 @@ sudo nixos-enter --root /mnt # chroot with nix available
From there you have the full toolbox: `nixos-rebuild boot --flake From there you have the full toolbox: `nixos-rebuild boot --flake
/home/<you>/.nomarchy#default` after fixing the flake, or snapper from /home/<you>/.nomarchy#default` after fixing the flake, or snapper from
§4. If you get this far with something Nomarchy shipped broken, please §5. If you get this far with something Nomarchy shipped broken, please
file it. 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

@@ -47,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 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` `/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. session may not start.
### What the live environment gives you ### What the live environment gives you
@@ -56,8 +56,11 @@ session may not start.
Hyprland via `initial_session`; logging out lands on tuigreet. Hyprland via `initial_session`; logging out lands on tuigreet.
- The flake is seeded writable at `~/.nomarchy` (from the read-only - The flake is seeded writable at `~/.nomarchy` (from the read-only
`/etc/nomarchy` copy) — `$NOMARCHY_PATH` already points there. `/etc/nomarchy` copy) — `$NOMARCHY_PATH` already points there.
- Locked flake inputs are pinned into the ISO store, so - Locked flake inputs are pinned into the ISO store, so a
`home-manager switch` works **without a network**. `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 ## 3. Verification checklist
@@ -67,12 +70,12 @@ Work through these in order; each one exercises a different layer.
|---|---|---| |---|---|---|
| 1 | Boots to Hyprland with the default theme wallpaper visible (Boreal) | greetd autologin, awww, session start | | 1 | Boots to Hyprland with the default theme wallpaper visible (Boreal) | greetd autologin, awww, session start |
| 2 | Waybar shows at top, themed (blue accent on dark) | HM waybar module, palette baking | | 2 | Waybar shows at top, themed (blue accent on dark) | HM waybar module, palette baking |
| 3 | `SUPER+Return` opens Ghostty with the default theme colors (Boreal) | 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 | | 4 | `btop` in the terminal is themed | per-theme asset baking |
| 5 | `nomarchy-theme-sync list` prints 24 presets | package, baked themes dir | | 5 | `nomarchy-state-sync list` prints 24 presets | package, baked themes dir |
| 6 | `nomarchy-theme-sync apply gruvbox` → state written, `home-manager switch` runs, desktop re-themes, wallpaper changes | the whole engine: state write, pure eval, HM rebuild, wallpaper hook | | 6 | `nomarchy-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 | | 7 | `SUPER+SHIFT+T` cycles wallpapers instantly (try `tokyo-night` for 4, or `boreal` for its set) | the runtime wallpaper path |
| 8 | `nomarchy-theme-sync apply summer-night` → after the switch the bar has its own identity (light bar, different styling) | whole-swap waybar.css assets | | 8 | `nomarchy-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 | | 9 | Open a GTK app — dark theme matching the palette | Stylix layer |
| 10 | `home-manager generations` lists one generation per theme change; activating an older one rolls the theme back | atomicity / rollback story | | 10 | `home-manager generations` lists one generation per theme change; activating an older one rolls the theme back | atomicity / rollback story |
@@ -105,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, interactively from the live terminal. If the desktop comes up unthemed,
read `/var/log/nomarchy-hm-preactivate.log` on the installed system. 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 ## 5. VM-specific gotchas
- **No KVM** (e.g. nested without acceleration): everything works but - **No KVM** (e.g. nested without acceleration): everything works but
@@ -114,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`. script's qemu flags; on real hardware check `journalctl -b -u greetd`.
- **Tiny resolution**: the live config forces `monitor = ,highres,auto,1`; - **Tiny resolution**: the live config forces `monitor = ,highres,auto,1`;
if QEMU still picks 1024×768, resize the window — Hyprland follows. 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 - **First theme switch is the slowest**: it evaluates the flake on a RAM
disk. Subsequent switches reuse the eval cache. 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 ## 6. When something fails
- Session/login problems: `journalctl -b -u greetd`, then `journalctl - Session/login problems: `journalctl -b -u greetd`, then `journalctl
--user -b`. --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 terminal — the home-manager output streams there. The state file is
written *before* the rebuild, so after fixing you can just re-run written *before* the rebuild, so after fixing you can just re-run
`home-manager switch --flake ~/.nomarchy`. `home-manager switch --flake ~/.nomarchy`.

View File

@@ -51,17 +51,19 @@ Steam Deck, docs website, binary cache.
## Theme A — Day-2 confidence (highest product ROI) ## Theme A — Day-2 confidence (highest product ROI)
The install is already strong. The gap is **after** first boot. 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 | Likely home | | Idea | Intent | Status |
|------|--------|-------------| |------|--------|--------|
| **System Firmware** | fwupd is on but CLI-only; menu: refresh → list → confirm → update (never auto-flash) | BACKLOG Hardware product | | **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 | BACKLOG Hardware product | | **Fingerprint menu** | Enroll/list when fprintd present; optional PAM + rebuild note | ✓ shipped #55; V3 enroll HARDWARE-QUEUE |
| **Doctor hardware section** | NM, sink, optional GPU smoke, fprintd, fwupd pending, charge threshold | BACKLOG Hardware product | | **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 (not five submenus) | Menu / doctor | | **Machine health entry** | One System row → doctor | ✓ System Doctor + Waybar tripwire |
| **Human rebuild errors** | On failed switch, point at last log lines + `nomarchy-doctor` | pkgs / menu | | **Human rebuild errors** ✓ | Failed switch last log lines + `nomarchy-doctor` | ✓ shipped #56 |
| **HM pre-activate fail flag** | Durable recovery one-liner on target if bake failed | Installer | | **HM pre-activate fail flag** ✓ | Recovery one-liner if theme bake failed | ✓ shipped #83 |
Reference: [HARDWARE.md](HARDWARE.md) §4§5, §10. Reference: [HARDWARE.md](HARDWARE.md) §4§5, §11 (doctor).
--- ---
@@ -69,10 +71,10 @@ Reference: [HARDWARE.md](HARDWARE.md) §4§5, §10.
| Idea | Intent | Notes | | Idea | Intent | Notes |
|------|--------|-------| |------|--------|-------|
| **Silent first-week card** | One dismissible “youre set” (menu, theme, wallpaper, network) — not a wizard | Prefer notify or doctor “first boot” section | | **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 | BACKLOG PROPOSED already | | **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 | Rollback exists; story is incomplete | | **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 | Avoid permanent nag | | **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 |
--- ---
@@ -80,10 +82,10 @@ Reference: [HARDWARE.md](HARDWARE.md) §4§5, §10.
| Idea | Intent | Notes | | Idea | Intent | Notes |
|------|--------|-------| |------|--------|-------|
| **Dock life** | Lid closed + external only, wake, default audio sink on undock | Display profiles exist — polish edge cases | | **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 | Trust > new power UI | | **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 / charge limit where sysfs allows | Report-only | | **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** | Already PROPOSED `[blocked:hw]` | Keep privilege tradeoff explicit | | **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 |
--- ---
@@ -91,7 +93,7 @@ Reference: [HARDWARE.md](HARDWARE.md) §4§5, §10.
| Idea | Intent | Notes | | Idea | Intent | Notes |
|------|--------|-------| |------|--------|-------|
| **Fewer themes, sharper** | Finish or quarantine neon-glass; invest in whole-swap quality | Contrast stays gated | | **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 | | **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 switch speed** | Wallpapers artifact split (LATER) if switches still feel slow | GOALS: no second pipeline |
@@ -112,7 +114,7 @@ Reference: [HARDWARE.md](HARDWARE.md) §4§5, §10.
| Idea | Intent | Notes | | Idea | Intent | Notes |
|------|--------|-------| |------|--------|-------|
| **Omarchy migrant one-pager** ✓ | Bindings/theme/install map | ✓ shipped `docs/OMARCHY.md` (#78, 2026-07-10) | | **Omarchy migrant one-pager** ✓ | Bindings/theme/install map | ✓ shipped `docs/OMARCHY.md` (#78, 2026-07-10) |
| **Hardware hall of fame** | Install-tested models; invite DMI PRs | Ties to HARDWARE.md §9 | | **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 | | **60s demo** | Live → install → theme → menu | Outside repo OK |
--- ---
@@ -150,18 +152,14 @@ From GOALS non-goals and installer audits — do not “fill the roadmap” with
## Suggested agent slices (promote via PROPOSED → NEXT) ## Suggested agent slices (promote via PROPOSED → NEXT)
Small enough for one iteration each; reference this file: **Still open** (as of 2026-07-17). Shipped items above are *not* open work.
1. `VISION § v1.0` — default theme → boreal (seed state + fallbacks) 1. `VISION § D` / LATER — wallpapers artifact split (decided deferred;
2. `VISION § A` — System Firmware (fwupd wrapper) promote only if theme-switch latency still hurts after measurement)
3. `VISION § A`doctor hardware section (read-only checks) 2. `VISION § H`HARDWARE-QUEUE burn-down (human-only V3; agents only
4. `VISION § A` — fingerprint enroll menu (self-gated) append exact steps)
5. `VISION § B`first-boot dismissible tips (one surface) 3. `VISION § v1.0`install P0 re-verify before any `v1` fast-forward
6. `VISION § A` — human-facing rebuild failure hint (swap=0 / unattended LUKS contracts; human ships the pointer)
7. `VISION § v1.0` — install P0 contracts (swap / unattended LUKS)
8. `VISION § D` — neon-glass quarantine or finish
9. `VISION § F` — Omarchy migrant doc (short)
10. `VISION § H` — HARDWARE-QUEUE session notes only Bernardo runs
Larger themes (dock life, time-of-day theme, wallpapers split) stay Do **not** re-open Theme A firmware/fingerprint/doctor/rebuild rows —
`[big]` until split. those shipped. Dock/hibernate agent work is done; residual is V3 hardware.

59
flake.lock generated
View File

@@ -145,11 +145,11 @@
] ]
}, },
"locked": { "locked": {
"lastModified": 1783221248, "lastModified": 1784350909,
"narHash": "sha256-ESQnuNHEDChsB4IxoLRhscVahqkDWkTb+qdIz8euYt4=", "narHash": "sha256-ZWyzLbS1yKUTeFJLmdVuWNnHttL333/ldJbEE+KzCrM=",
"owner": "nix-community", "owner": "nix-community",
"repo": "home-manager", "repo": "home-manager",
"rev": "af2beae5f0fae0a4310cc0e6aef2572f56090353", "rev": "4ce190229c73d44536caa7072f6308fb2d8feeb3",
"type": "github" "type": "github"
}, },
"original": { "original": {
@@ -166,11 +166,11 @@
] ]
}, },
"locked": { "locked": {
"lastModified": 1783370751, "lastModified": 1784310968,
"narHash": "sha256-E+3MIMvKuo9k+K+qLQ9YXzsBzkgHuyVLnsEbN2DFfuc=", "narHash": "sha256-rkSPTePrKqs4dg+i7ZFCq93+HrClac6oSwXX927SVjA=",
"owner": "NixOS", "owner": "NixOS",
"repo": "nixos-hardware", "repo": "nixos-hardware",
"rev": "662bd6e312d2c8b212e32cb377abaee190749320", "rev": "779c32a00155994c86cde8213a8dd4df139d4355",
"type": "github" "type": "github"
}, },
"original": { "original": {
@@ -182,11 +182,11 @@
}, },
"nixpkgs": { "nixpkgs": {
"locked": { "locked": {
"lastModified": 1783148766, "lastModified": 1784432872,
"narHash": "sha256-uslt2pqShTIXDdAHRHv2QkYLsVdY8Oqwz0EA48/RSM8=", "narHash": "sha256-n3gKTBIV4ZA5VQpUakffBe3KGu4+mhPoA34rrqS0GkA=",
"owner": "NixOS", "owner": "NixOS",
"repo": "nixpkgs", "repo": "nixpkgs",
"rev": "a50de1b7d8a586adc18d2395c19de7d6058e6030", "rev": "fd1462031fdee08f65fd0b4c6b64e22239a77870",
"type": "github" "type": "github"
}, },
"original": { "original": {
@@ -196,6 +196,39 @@
"type": "github" "type": "github"
} }
}, },
"nixpkgs-unstable": {
"locked": {
"lastModified": 1784497964,
"narHash": "sha256-vlHUuqAcbcH2RKmHbPiuQzbv1pnzzavXnI62RD0bqCU=",
"owner": "NixOS",
"repo": "nixpkgs",
"rev": "241313f4e8e508cb9b13278c2b0fa25b9ca27163",
"type": "github"
},
"original": {
"owner": "NixOS",
"ref": "nixos-unstable",
"repo": "nixpkgs",
"type": "github"
}
},
"nomarchy-wallpapers": {
"flake": false,
"locked": {
"lastModified": 1784478251,
"narHash": "sha256-sEBx2ce6g0398FlclNwfv5FuwZ1WzLYNHK6jX/2kV88=",
"ref": "main",
"rev": "5c361e17b524b6ef27fb7ba948269d0e0c6e954e",
"revCount": 5,
"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": { "nur": {
"inputs": { "inputs": {
"flake-parts": [ "flake-parts": [
@@ -226,6 +259,8 @@
"home-manager": "home-manager", "home-manager": "home-manager",
"nixos-hardware": "nixos-hardware", "nixos-hardware": "nixos-hardware",
"nixpkgs": "nixpkgs", "nixpkgs": "nixpkgs",
"nixpkgs-unstable": "nixpkgs-unstable",
"nomarchy-wallpapers": "nomarchy-wallpapers",
"stylix": "stylix" "stylix": "stylix"
} }
}, },
@@ -249,11 +284,11 @@
"tinted-zed": "tinted-zed" "tinted-zed": "tinted-zed"
}, },
"locked": { "locked": {
"lastModified": 1783359251, "lastModified": 1784060273,
"narHash": "sha256-HUiCnEVlJ4n+qJlZojiz/zv+P0cqM5zsg1dxpz2J7Mg=", "narHash": "sha256-14rIy2kTs5CufmDpgJrwkR+7IzuCAXyLYDAx6ixfRFc=",
"owner": "nix-community", "owner": "nix-community",
"repo": "stylix", "repo": "stylix",
"rev": "e602ad042f00409f33c8ad2829cd8d59ba345c7e", "rev": "2245fa9e16034149b6501834b99863a486e94725",
"type": "github" "type": "github"
}, },
"original": { "original": {

1612
flake.nix

File diff suppressed because it is too large Load Diff

View File

@@ -2,15 +2,15 @@
# without touching the disk. The live session bundles nomarchy-install # without touching the disk. The live session bundles nomarchy-install
# (gum + disko + mkFlake) for a real install; this target also lets you # (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. # test the distro end-to-end on real hardware before committing to disk.
{ lib, pkgs, username, nomarchySrc, ... }: { lib, pkgs, config, username, nomarchySrc, ... }:
let let
# ISO boot branding: the Nomarchy monogram recolored to the palette accent, # ISO boot branding: the Nomarchy monogram recolored to the palette accent,
# centred on the theme base. Built from the vendored vector logo and the # centred on the theme base. Built from the vendored vector logo and the
# live theme-state.json (Boreal by default). The same composed image # live state.json (Boreal by default). The same composed image
# backs both the isolinux (BIOS) splash and the GRUB (UEFI) theme below, so # backs both the isolinux (BIOS) splash and the GRUB (UEFI) theme below, so
# the two boot paths match. # the two boot paths match.
state = builtins.fromJSON (builtins.readFile ../theme-state.json); state = builtins.fromJSON (builtins.readFile ../state.json);
isoColor = key: fallback: lib.removePrefix "#" ((state.colors or { }).${key} or fallback); isoColor = key: fallback: lib.removePrefix "#" ((state.colors or { }).${key} or fallback);
accent = isoColor "accent" "B79BE8"; accent = isoColor "accent" "B79BE8";
base = isoColor "base" "21272F"; base = isoColor "base" "21272F";
@@ -84,13 +84,19 @@ in
isoImage.edition = lib.mkForce "live"; isoImage.edition = lib.mkForce "live";
isoImage.splashImage = isoSplash; # isolinux / BIOS isoImage.splashImage = isoSplash; # isolinux / BIOS
isoImage.grubTheme = nomarchyGrubTheme; # GRUB / UEFI 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 # The minimal-CD profile slims the image for a CONSOLE installer; this
# ISO is the desktop, so re-enable what it strips. Above all # ISO is the desktop, so re-enable what it strips. Above all
# fontconfig (upstream forces it off at mkOverride 500): without it # fontconfig (upstream forces it off at mkOverride 500): without it
# no configured family resolves — Waybar icons render as tofu and # no configured family resolves — Waybar icons render as tofu and
# Ghostty silently falls back to the wrong font (seen on the # Kitty silently falls back to the wrong font (seen on the
# Latitude 5410). Normal priority (100) beats the override. # Latitude 5410 with Ghostty; same fontconfig footgun). Normal priority
# (100) beats the override.
fonts.fontconfig.enable = true; fonts.fontconfig.enable = true;
# No boot splash on the install medium: the installer ISO boots its # No boot splash on the install medium: the installer ISO boots its
@@ -148,6 +154,16 @@ in
if [ ! -e "$home/.nomarchy" ]; then if [ ! -e "$home/.nomarchy" ]; then
cp -r ${nomarchySrc} "$home/.nomarchy" cp -r ${nomarchySrc} "$home/.nomarchy"
chmod -R u+w "$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" chown -R ${username}:users "$home/.nomarchy"
fi fi
''; '';
@@ -158,8 +174,8 @@ in
Welcome to the Nomarchy live environment. Welcome to the Nomarchy live environment.
The graphical session autologins as '${username}' (no password). The graphical session autologins as '${username}' (no password).
Theme switching: nomarchy-theme-sync apply <name> (or SUPER+T) Theme switching: nomarchy-state-sync apply <name> (or SUPER+T)
Wallpapers: nomarchy-theme-sync bg next (or SUPER+SHIFT+T) Wallpapers: nomarchy-state-sync bg next (or SUPER+SHIFT+T)
Install to disk: nomarchy-install Install to disk: nomarchy-install
The flake lives at ~/.nomarchy. The flake lives at ~/.nomarchy.
''; '';

21
lib.nix
View File

@@ -1,5 +1,5 @@
# Downstream sugar: one call wires a whole machine flake. # 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 # is generated once (by `nix flake init -t` or the future installer) and
# never hand-edited. The raw exports (nixosModules/homeModules/overlays) # never hand-edited. The raw exports (nixosModules/homeModules/overlays)
# in flake.nix remain the escape hatch for power users. # in flake.nix remain the escape hatch for power users.
@@ -8,9 +8,9 @@
let let
inherit (nixpkgs) lib; inherit (nixpkgs) lib;
# Shared pure reader (modules/theme-state-read.nix) — re-exported so # Shared pure reader (modules/state-read.nix) — re-exported so
# power users composing without mkFlake can call it too. # power users composing without mkFlake can call it too.
readThemeState = import ./modules/theme-state-read.nix { inherit lib; }; readThemeState = import ./modules/state-read.nix { inherit lib; };
in in
{ {
inherit readThemeState; inherit readThemeState;
@@ -59,13 +59,18 @@ in
config.allowUnfree = true; config.allowUnfree = true;
}; };
# Early fail-closed gate: missing/empty/non-object theme-state.json # Early fail-closed gate: missing/empty/non-object state.json
# throws here with a template + validate pointer, before module # throws here with a template + validate pointer, before module
# evaluation buries a raw readFile/fromJSON stack. Field-level # evaluation buries a raw readFile/fromJSON stack. Field-level
# schema still runs in modules/home/theme.nix after defaults merge. # schema still runs in modules/home/theme.nix after defaults merge.
# Forced via seq on the whole return set — attrNames alone must not # 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). # skip the check (Nix is lazy on unused let bindings and attr values).
statePath = src + "/theme-state.json"; # #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; _themeState = readThemeState statePath;
in in
builtins.seq _themeState { builtins.seq _themeState {
@@ -78,7 +83,7 @@ in
[ [
nomarchy.nixosModules.nomarchy nomarchy.nixosModules.nomarchy
# The standalone HM CLI ships with the system so theme switching # 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. # out of the box — same pinned input as the desktop modules.
{ environment.systemPackages = [ home-manager.packages.${system}.home-manager ]; } { environment.systemPackages = [ home-manager.packages.${system}.home-manager ]; }
# System-side theme consumers (Plymouth splash background) # System-side theme consumers (Plymouth splash background)
@@ -94,14 +99,14 @@ in
# Desktop layer — every theme change, no sudo: # Desktop layer — every theme change, no sudo:
# home-manager switch --flake .#<username> # 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 { homeConfigurations.${username} = home-manager.lib.homeManagerConfiguration {
inherit pkgs; inherit pkgs;
modules = [ modules = [
nomarchy.homeModules.nomarchy nomarchy.homeModules.nomarchy
(src + "/home.nix") (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. # file is part of the downstream flake's source.
nomarchy.stateFile = statePath; nomarchy.stateFile = statePath;
home = { home = {

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

@@ -1,18 +1,37 @@
# Auto time-of-day theme switch (BACKLOG #79, VISION § D). A user timer # Auto time-of-day theme switch (BACKLOG #79, VISION § D). A user timer
# periodically runs `nomarchy-theme-sync auto`, which reads # runs `nomarchy-state-sync auto` at the configured sunrise and sunset,
# settings.autoTheme = { enable, day, night, sunrise, sunset } from the # which reads settings.autoTheme = { enable, day, night, sunrise, sunset }
# state file and applies the day or night preset for the current clock — # from the state file and applies the day or night preset for the current
# through the SAME one engine as a manual `apply` (no second pipeline). # 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 # The trigger times are BAKED into OnCalendar at rebuild (originally this
# rebuilds when the active theme actually needs to change), so the timer # was a 15-min poll; exact times won on wasted wakeups — Bernardo
# can tick freely. Install is gated on the state flag (like nomarchy.updates # 2026-07-18), so a time edit must rebuild: the menu's Sunrise/Sunset
# gates on its enable), so a machine not using the feature carries no timer; # writes run `auto --force`, whose apply is that rebuild. Missed
# enabling it from the menu (slice 3) writes the flag + rebuilds, which is # transitions are covered without polling: Persistent=true catches ones
# what brings the timer into being. # that pass while powered off, systemd fires elapsed OnCalendar timers on
# resume from suspend, and OnStartupSec settles the theme just after
# login. The command self-gates (no-op when disabled) and is idempotent
# (it only rebuilds when the active theme actually needs to change), so
# every extra firing is cheap.
#
# 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, ... }: { config, lib, pkgs, ... }:
lib.mkIf (config.nomarchy.settings.autoTheme.enable or false) { let
at = config.nomarchy.settings.autoTheme or { };
# "HH:MM" or the fallback — mirrors _hhmm_to_min in nomarchy-state-sync,
# so a hand-edited state can't bake an OnCalendar systemd rejects.
hhmm = v: fallback:
if builtins.isString v
&& builtins.match "([01][0-9]|2[0-3]):[0-5][0-9]" v != null
then v else fallback;
in
lib.mkIf (at.enable or false) {
systemd.user.services.nomarchy-auto-theme = { systemd.user.services.nomarchy-auto-theme = {
Unit.Description = "Apply the day/night theme for the current time"; Unit.Description = "Apply the day/night theme for the current time";
Service = { Service = {
@@ -20,15 +39,18 @@ lib.mkIf (config.nomarchy.settings.autoTheme.enable or false) {
# The rebuild (`home-manager switch`) and its tools resolve from the # The rebuild (`home-manager switch`) and its tools resolve from the
# system + per-user profiles — same PATH shape nomarchy-updates uses. # 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"; Environment = "PATH=/run/current-system/sw/bin:/etc/profiles/per-user/${config.home.username}/bin";
ExecStart = "${pkgs.nomarchy-theme-sync}/bin/nomarchy-theme-sync auto"; ExecStart = "${pkgs.nomarchy-state-sync}/bin/nomarchy-state-sync auto";
}; };
}; };
systemd.user.timers.nomarchy-auto-theme = { systemd.user.timers.nomarchy-auto-theme = {
Unit.Description = "Periodic day/night theme check"; Unit.Description = "Day/night theme switch at sunrise and sunset";
Timer = { Timer = {
OnStartupSec = "1min"; # settle on the right theme shortly after login OnStartupSec = "1min"; # settle on the right theme shortly after login
OnCalendar = "*:0/15"; # re-check every 15 min; auto no-ops if unchanged OnCalendar = [
"*-*-* ${hhmm (at.sunrise or null) "07:00"}:00" # sunrise → day
"*-*-* ${hhmm (at.sunset or null) "20:00"}:00" # sunset → night
];
Persistent = true; # catch a transition missed while powered off Persistent = true; # catch a transition missed while powered off
}; };
Install.WantedBy = [ "timers.target" ]; Install.WantedBy = [ "timers.target" ];

View File

@@ -6,11 +6,11 @@
{ {
imports = [ imports = [
./options.nix # the nomarchy.* option surface ./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 ./stylix.nix # GTK/Qt/cursor/fonts from the same JSON
./hyprland.nix ./hyprland.nix
./waybar.nix ./waybar.nix
./ghostty.nix ./kitty.nix # sole terminal (themed); doctor/calendar/--class load-bearing
./btop.nix ./btop.nix
./rofi.nix # launcher theming + the nomarchy-menu dispatcher ./rofi.nix # launcher theming + the nomarchy-menu dispatcher
./swaync.nix # notification daemon, themed from the same JSON ./swaync.nix # notification daemon, themed from the same JSON
@@ -18,6 +18,7 @@
./yazi.nix # flagship TUI file manager, themed + plugins ./yazi.nix # flagship TUI file manager, themed + plugins
./osd.nix # swayosd volume/brightness OSD, themed ./osd.nix # swayosd volume/brightness OSD, themed
./nightlight.nix # scheduled blue-light filter (hyprsunset), opt-in ./nightlight.nix # scheduled blue-light filter (hyprsunset), opt-in
./airplane.nix # runtime Wi-Fi+BT airplane mode + Waybar glyph (#104)
./autotheme.nix # auto day/night theme switch (settings.autoTheme), opt-in ./autotheme.nix # auto day/night theme switch (settings.autoTheme), opt-in
./timezone.nix # keep the Waybar clock in step with auto-timezone changes ./timezone.nix # keep the Waybar clock in step with auto-timezone changes
./updates.nix # passive update-awareness indicator + notification, opt-in ./updates.nix # passive update-awareness indicator + notification, opt-in
@@ -28,6 +29,8 @@
./mime.nix # default applications (mimeapps.list), degrades with the suite ./mime.nix # default applications (mimeapps.list), degrades with the suite
./recording.nix # nomarchy-record: screen recording behind Capture + the bar ⏺ ./recording.nix # nomarchy-record: screen recording behind Capture + the bar ⏺
./battery-notify.nix # low-battery toasts at the bar's 25/10% thresholds ./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 ./satty.nix # satty screenshot annotation tool, themed
]; ];
@@ -39,7 +42,31 @@
services.udiskie.enable = true; services.udiskie.enable = true;
# Microphone noise cancellation (rnnoise) and audio EQ. # 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; 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 # Wifi from the bar: nm-applet lives in waybar's tray (SNI flag via
# preferStatusNotifierItems — without it there is no tray icon). # preferStatusNotifierItems — without it there is no tray icon).
@@ -54,6 +81,11 @@
xdg.userDirs = { xdg.userDirs = {
enable = lib.mkDefault true; enable = lib.mkDefault true;
createDirectories = 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.stateVersion = lib.mkDefault "26.05";
@@ -75,8 +107,8 @@
# (system nixpkgs.config alone does not cover those entry points). # (system nixpkgs.config alone does not cover those entry points).
NIXPKGS_ALLOW_UNFREE = "1"; NIXPKGS_ALLOW_UNFREE = "1";
# Where the Nomarchy flake (and therefore theme-state.json) lives on # Where the Nomarchy flake (and therefore state.json) lives on
# disk. nomarchy-theme-sync writes its state here; rebuilds read from # disk. nomarchy-state-sync writes its state here; rebuilds read from
# here. Clone/symlink your flake to this path. # here. Clone/symlink your flake to this path.
NOMARCHY_PATH = "$HOME/.nomarchy"; NOMARCHY_PATH = "$HOME/.nomarchy";
}; };

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

View File

@@ -0,0 +1,191 @@
# The full dock / undock / enable display-transition primitive
# (nomarchy-display-transition) — the one safety-critical copy. Extracted
# from hyprland.nix (#150) so hyprland.nix and idle.nix share it instead of
# idle.nix carrying a lossy mini that lacked logging, the lid-inhibitor
# gate, restore_keyboards, and the failure dump.
#
# One transition primitive for the interactive Dock mode, abrupt undock,
# and named profiles that disable the internal panel. Dock is one Hyprland
# batch: enable the target, move every internal workspace, focus it, then
# disable the panel. Undock deliberately enables the panel FIRST, waits
# for it to become an active target, then restores every workspace.
#
# sync — nomarchy-state-sync exe (dock-intent writes)
# keyboardTool — nomarchy-keyboard-layout (restore after reload)
# displayDumpTool — nomarchy-display-dump (evidence on enable failure)
{ pkgs, sync, keyboardTool, displayDumpTool }:
pkgs.writeShellScriptBin "nomarchy-display-transition" ''
set -u
LOGGER=${pkgs.util-linux}/bin/logger
log() { "$LOGGER" -t nomarchy-display-transition -- "$*"; }
valid_output() {
case "$1" in *[!A-Za-z0-9_.:-]*|"") return 1 ;; *) return 0 ;; esac
}
workspaces_on() {
hyprctl workspaces -j 2>/dev/null \
| jq -r --arg m "$1" '.[] | select(.monitor == $m and .id > 0) | .id'
}
all_workspaces() {
hyprctl workspaces -j 2>/dev/null | jq -r '.[] | select(.id > 0) | .id'
}
lid_inhibitor_held() {
${pkgs.systemd}/bin/systemd-inhibit --list --json=short 2>/dev/null \
| ${pkgs.jq}/bin/jq -e 'any(.[]; .what == "handle-lid-switch" and .why == "Safe dock/undock display transition" and .mode == "block")' \
>/dev/null
}
batch_moves() { # stdin workspace ids, $1 target
target="$1"; commands=
while IFS= read -r ws; do
case "$ws" in *[!0-9]*|"") continue ;; esac
commands="$commands dispatch moveworkspacetomonitor $ws $target;"
done
printf '%s' "$commands"
}
# `monitors` (enabled-only) listing the output is the ONLY proof it is
# really on: every path here issues keywords hyprctl answers "ok" to
# without acting. $1 = output, $2 = polls of 0.2s.
output_enabled() {
tries=0
while [ "$tries" -lt "$2" ]; do
hyprctl monitors -j 2>/dev/null \
| jq -e --arg m "$1" 'any(.[]; .name == $m)' >/dev/null && return 0
tries=$((tries+1)); sleep 0.2
done
return 1
}
# `hyprctl reload` re-reads the config, and every runtime keyword dies
# with it including the `device[<name>]:kb_layout` the keyboard watcher
# applies to an external board. Measured on hardware 2026-07-14: a bare
# reload took a keyboard remembered as `us` straight back to the session's
# `gb`, so without this the undock's escalation would silently break the
# layout of any keyboard that stays behind when the dock leaves (a
# wireless receiver in the laptop; only boards on the dock's own hub are
# unaffected, and only because they leave with it). The remembered
# choices live in the in-flake state, so `restore` is authoritative.
restore_keyboards() {
hyprctl devices -j 2>/dev/null | jq -r '.keyboards[].name' 2>/dev/null \
| while IFS= read -r kb; do
[ -n "$kb" ] || continue
${keyboardTool}/bin/nomarchy-keyboard-layout restore "$kb" \
>/dev/null 2>&1 || true
done
}
# Dock mode is an *intent*, and until #142 it lived only in the compositor:
# `keyword monitor <internal>, disable` and nothing written down. Any config
# reload re-applies the catch-all `monitor=,preferred,auto,1` and re-lights
# the panel which is exactly why `undock` below uses `hyprctl reload` as
# its hammer so every `nomarchy-home` silently performed an undock and the
# user re-picked Dock mode by hand.
#
# So record the intent where every other setting lives (the in-flake state,
# menu-writable), and let the watcher re-assert it. `--no-switch`: the
# watcher reads this at run time; a rebuild here would be both pointless and
# recursive. Failure to write is never fatal a session that cannot reach
# the state file must still dock.
set_dock_intent() { # $1 = true|false
${sync} --quiet set settings.display.dockMode "$1" --no-switch \
>/dev/null 2>&1 || log "dock-intent=$1 result=state-write-failed"
}
case "''${1:-}" in
dock)
internal="''${2:-}"; external="''${3:-}"
rule="''${4:-$external, preferred, auto, 1}"
valid_output "$internal" && valid_output "$external" \
|| { echo "invalid monitor name" >&2; exit 64; }
# Fail closed: disabling the panel without this lock recreates the
# exact cable-removal logind suspend race #100 is fixing.
lid_inhibitor_held \
|| { log "transition=dock internal=$internal external=$external result=no-lid-inhibitor"; exit 1; }
moves=$(workspaces_on "$internal" | batch_moves "$external")
if hyprctl --batch \
"keyword monitor $rule; $moves dispatch focusmonitor $external; keyword monitor $internal, disable" \
>/dev/null 2>&1; then
set_dock_intent true
log "transition=dock internal=$internal external=$external result=ok"
else
log "transition=dock internal=$internal external=$external result=failed"
exit 1
fi ;;
undock)
internal="''${2:-}"
valid_output "$internal" || { echo "invalid monitor name" >&2; exit 64; }
# Clear the intent *first*: this path runs when the external is already
# gone, and if anything below fails the last thing the session needs is
# a watcher that keeps trying to dock to a monitor that left.
set_dock_intent false
# Safety-critical ordering: never remove/move away from an external
# before the internal panel is active and can receive workspaces.
#
# With ZERO enabled outputs an abrupt undock, the panel already
# disabled by the dock Hyprland 0.55.4 accepts `keyword monitor`
# (prints "ok", exits 0) and then never flushes it: the rule is held
# until some DRM event arrives, and the panel stays dark. This is
# deterministic, not a race. Probed on hardware 2026-07-14: the
# keyword was inert across 4s and 5s windows in two separate runs,
# `dispatch forcerendererreload` did not flush it either, and only
# `hyprctl reload` did in 99ms and 289ms, with the cable out.
#
# Round 6 read the same symptom as a teardown race and re-issued the
# keyword 25 times a poll. Retrying an inert command cannot work: it
# bought 30s of black screen and every `result=ok` in that journal was
# really the user plugging the cable back in, whose hotplug flushed
# the queued rule and let the next poll take the credit.
#
# So issue the keyword once enough whenever another output is still
# enabled, e.g. the menu's Dock mode toggle and escalate to the
# reload hammer only when it proves inert.
hyprctl keyword monitor "$internal,preferred,auto,1" >/dev/null 2>&1
if ! output_enabled "$internal" 5; then
log "transition=undock internal=$internal keyword=inert escalate=reload"
hyprctl reload >/dev/null 2>&1
if ! output_enabled "$internal" 25; then
log "transition=undock internal=$internal result=enable-failed"
# #127: leave evidence on disk without SSH (TTY or next login).
${displayDumpTool}/bin/nomarchy-display-dump undock-enable-failed \
>/dev/null 2>&1 || true
exit 1
fi
# The config's monitor rules own the panel now. Re-assert the
# runtime rule which flushes normally, an output being enabled
# again so a config that parks the panel off cannot undo the
# undock, and give the keyboards back what the reload took.
hyprctl keyword monitor "$internal,preferred,auto,1" >/dev/null 2>&1
restore_keyboards
log "transition=undock internal=$internal enable=via-reload"
fi
moves=$(all_workspaces | batch_moves "$internal")
hyprctl --batch "$moves dispatch focusmonitor $internal" >/dev/null 2>&1 \
|| { log "transition=undock internal=$internal result=move-failed"; exit 1; }
# #127: undock after idle DPMS left the seat black even when the
# panel was re-enabled always light the chain after enable.
hyprctl dispatch dpms on >/dev/null 2>&1 || true
log "transition=undock internal=$internal result=ok" ;;
enable)
internal="''${2:-}"
valid_output "$internal" || exit 64
# Menu Display "Screen on". Turning the laptop panel back on *is*
# leaving dock mode, and saying so is what stops the watcher from
# re-docking the panel out from under the user a second later (#142).
# Only the internal clears the intent: lighting some third monitor is
# not a statement about the lid.
case "$internal" in
eDP*|LVDS*|DSI*) set_dock_intent false ;;
esac
# Reachable only from the menu, i.e. with a screen already on, so the
# keyword flushes and no reload escalation is needed but still make
# `monitors` the proof, or the menu's "back on" notification cheers
# for a keyword hyprctl merely said "ok" to.
hyprctl keyword monitor "$internal,preferred,auto,1" >/dev/null 2>&1
if output_enabled "$internal" 10; then
hyprctl dispatch dpms on >/dev/null 2>&1 || true
true
else
false
fi ;;
*)
echo "usage: nomarchy-display-transition [dock <internal> <external> [external-rule]|undock <internal>|enable <internal>]" >&2
exit 64 ;;
esac
''

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; cfg = config.nomarchy;
c = cfg.theme.colors; 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" logo = pkgs.runCommand "nomarchy-fastfetch-logo"
{ nativeBuildInputs = [ pkgs.imagemagick pkgs.librsvg pkgs.chafa ]; } '' { 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 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 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,48 +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
{
# Ghostty is ALWAYS installed — it's Nomarchy's default terminal and is
# load-bearing (SUPER+E file manager, the calendar launcher's classed
# window, etc.), so the distro enforces it. `nomarchy.ghostty.enable` now
# gates only whether Nomarchy's theming/config is applied — a user can
# keep ghostty but drop our config, they just can't remove ghostty itself.
programs.ghostty = {
enable = true;
enableBashIntegration = true;
settings = lib.mkIf config.nomarchy.ghostty.enable {
# ── 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;
};
};
}

View File

@@ -1,4 +1,4 @@
# Hyprland — fully driven by config.nomarchy.theme (theme-state.json). # Hyprland — fully driven by config.nomarchy.theme (state.json).
# Gaps, borders and colors are baked from the JSON at eval time; theme # Gaps, borders and colors are baked from the JSON at eval time; theme
# changes arrive via `home-manager switch`. # changes arrive via `home-manager switch`.
{ config, lib, pkgs, ... }: { config, lib, pkgs, ... }:
@@ -8,7 +8,7 @@ let
c = t.colors; c = t.colors;
inherit (config.nomarchy.lib) rgb rgba; inherit (config.nomarchy.lib) rgb rgba;
# nomarchy-theme-sync — the in-flake state writer the keyboard watcher uses # nomarchy-state-sync — the in-flake state writer the keyboard watcher uses
# to remember per-device layouts (same path night-light's toggle takes). # to remember per-device layouts (same path night-light's toggle takes).
sync = lib.getExe config.nomarchy.package; sync = lib.getExe config.nomarchy.package;
@@ -20,14 +20,29 @@ let
# --custom-progress is the mic level (0.0-1.0), --custom-icon the # --custom-progress is the mic level (0.0-1.0), --custom-icon the
# muted/active glyph. Pure bash builtins for parsing so the script needs no # muted/active glyph. Pure bash builtins for parsing so the script needs no
# PATH beyond the two pinned tools. # PATH beyond the two pinned tools.
#
# Hardware mic-mute LEDs (ThinkPad platform::micmute, HDA *::micmute, …)
# are driven by the kernel's audio-micmute trigger, which tracks ALSA —
# not PipeWire software mute. Sync the LED from the resulting mute state
# when the node is group-writable (udev rule in modules/nixos/default.nix).
micMute = pkgs.writeShellScript "nomarchy-mic-mute" '' micMute = pkgs.writeShellScript "nomarchy-mic-mute" ''
${pkgs.wireplumber}/bin/wpctl set-mute @DEFAULT_SOURCE@ toggle ${pkgs.wireplumber}/bin/wpctl set-mute @DEFAULT_SOURCE@ toggle
out=$(${pkgs.wireplumber}/bin/wpctl get-volume @DEFAULT_SOURCE@) out=$(${pkgs.wireplumber}/bin/wpctl get-volume @DEFAULT_SOURCE@)
set -- $out # "Volume: 0.85 [MUTED]" -> $2 is the level set -- $out # "Volume: 0.85 [MUTED]" -> $2 is the level
case "$out" in case "$out" in
*MUTED*) icon=microphone-sensitivity-muted-symbolic ;; *MUTED*) icon=microphone-sensitivity-muted-symbolic; led=1 ;;
*) icon=microphone-sensitivity-high-symbolic ;; *) icon=microphone-sensitivity-high-symbolic; led=0 ;;
esac esac
for led_dir in /sys/class/leds/*micmute* /sys/class/leds/*::micmute; do
[ -e "$led_dir/brightness" ] || continue
if [ -w "$led_dir/brightness" ]; then
# Drop the kernel trigger so a stuck audio-micmute state cannot
# override our write (verified: ALSA Capture mute alone does not
# flip platform::micmute on ThinkPad T14s).
[ -w "$led_dir/trigger" ] && echo none > "$led_dir/trigger" 2>/dev/null || true
echo "$led" > "$led_dir/brightness" 2>/dev/null || true
fi
done
${pkgs.swayosd}/bin/swayosd-client --custom-icon "$icon" --custom-progress "$2" ${pkgs.swayosd}/bin/swayosd-client --custom-icon "$icon" --custom-progress "$2"
''; '';
@@ -59,15 +74,18 @@ let
(e: "${e.mods}, ${e.key}, exec, ${focusOrLaunch}/bin/nomarchy-focus-or-launch ${e.class} ${e.command}") (e: "${e.mods}, ${e.key}, exec, ${focusOrLaunch}/bin/nomarchy-focus-or-launch ${e.class} ${e.command}")
lofEntries; lofEntries;
# SUPER+1..9 / SUPER+SHIFT+1..9 workspace binds, generated. # SUPER+1..9,0 / SUPER+SHIFT+1..9,0 workspace binds, generated
# (the 0 key is workspace 10).
workspaceBinds = builtins.concatLists (builtins.genList workspaceBinds = builtins.concatLists (builtins.genList
(i: (i:
let ws = toString (i + 1); let
ws = toString (i + 1);
key = if i == 9 then "0" else ws;
in [ in [
"$mod, ${ws}, workspace, ${ws}" "$mod, ${key}, workspace, ${ws}"
"$mod SHIFT, ${ws}, movetoworkspace, ${ws}" "$mod SHIFT, ${key}, movetoworkspace, ${ws}"
]) ])
9); 10);
# The keyboard binds — single source shared with the SUPER+? cheatsheet # The keyboard binds — single source shared with the SUPER+? cheatsheet
# (rofi.nix renders the same list). Edit them in ./keybinds.nix. # (rofi.nix renders the same list). Edit them in ./keybinds.nix.
@@ -106,6 +124,22 @@ let
profileWorkspaceRules = profileWorkspaceRules =
lib.mapAttrsToList monitorLib.workspaceRule activeProfile.workspaces; lib.mapAttrsToList monitorLib.workspaceRule activeProfile.workspaces;
# #127 dump tool first (no transition dep); wake is wired after transition.
displayDumpTool =
(import ./display-tools.nix { inherit pkgs; }).displayDumpTool;
# The full dock/undock/enable transition primitive lives in its own file
# so idle.nix shares this exact safety-critical copy (#150), not a mini.
displayTransitionTool = import ./display-transition.nix {
inherit pkgs sync keyboardTool displayDumpTool;
};
displayWakeTool =
(import ./display-tools.nix {
inherit pkgs;
displayTransition = displayTransitionTool;
}).displayWakeTool;
# Profile applier — on PATH only when profiles are declared (the menu # Profile applier — on PATH only when profiles are declared (the menu
# row self-gates on it). apply: the profile's rules live via hyprctl + # row self-gates on it). apply: the profile's rules live via hyprctl +
# the in-flake state write; base: clear the state, then hyprctl reload # the in-flake state write; base: clear the state, then hyprctl reload
@@ -121,10 +155,38 @@ ${lib.concatMapStringsSep "\n" (n: " echo ${lib.escapeShellArg n}") (lib.
case "''${2:-}" in case "''${2:-}" in
${lib.concatStringsSep "\n" (lib.mapAttrsToList ${lib.concatStringsSep "\n" (lib.mapAttrsToList
(name: p: (name: p:
let
isInternal = m: builtins.match "^(eDP|LVDS|DSI).*" m.name != null;
enabled = lib.filter (m: m.resolution != "disable") p.monitors;
disabled = lib.filter (m: m.resolution == "disable") p.monitors;
internalOff = lib.findFirst isInternal null disabled;
dockTarget = lib.findFirst (m: ! isInternal m) null enabled;
safeDock = internalOff != null && dockTarget != null;
ordinaryEnabled = if safeDock
then lib.filter (m: m.name != dockTarget.name) enabled
else enabled;
ordinaryDisabled = if safeDock
then lib.filter (m: m.name != internalOff.name) disabled
else disabled;
in
" ${lib.escapeShellArg name})\n" " ${lib.escapeShellArg name})\n"
# Outputs that will remain active always come first. If this is a
# clamshell profile, the helper performs target-enable + workspace
# handoff + internal-disable as one batch, avoiding the zero-output and
# dangling-workspace states the old per-rule loop could create.
+ lib.concatMapStringsSep "\n" + lib.concatMapStringsSep "\n"
(m: " hyprctl keyword monitor ${lib.escapeShellArg (monitorRule m)} >/dev/null 2>&1") (m: " hyprctl keyword monitor ${lib.escapeShellArg (monitorRule m)} >/dev/null 2>&1")
p.monitors ordinaryEnabled
+ lib.optionalString safeDock
("\n ${displayTransitionTool}/bin/nomarchy-display-transition dock "
+ lib.escapeShellArg internalOff.name + " "
+ lib.escapeShellArg dockTarget.name + " "
+ lib.escapeShellArg (monitorRule dockTarget)
+ " >/dev/null 2>&1 || { notify-send \"Display profile\" \"Could not safely disable the laptop panel.\"; exit 1; }")
+ lib.optionalString (ordinaryDisabled != [ ]) "\n"
+ lib.concatMapStringsSep "\n"
(m: " hyprctl keyword monitor ${lib.escapeShellArg (monitorRule m)} >/dev/null 2>&1")
ordinaryDisabled
# Workspace pins: the keyword sets the session rule, the dispatch # Workspace pins: the keyword sets the session rule, the dispatch
# moves an already-open workspace over. Pins from a previously # moves an already-open workspace over. Pins from a previously
# applied profile linger until reload/rebuild (hyprctl can only # applied profile linger until reload/rebuild (hyprctl can only
@@ -180,29 +242,140 @@ ${lib.concatStringsSep "\n" (lib.mapAttrsToList
esac esac
''; '';
# Hotplug auto-switch (opt-in via the menu's Auto-switch row → # Hyprland hotplug watcher: owns the safety-critical dock lifecycle plus
# settings.displayProfileAuto, read LIVE on every output change so the # optional profile auto-switching. A low-level logind lid-switch inhibitor
# toggle is instant, no rebuild). Polls like the keyboard watcher — # is acquired as soon as an external appears. On final external removal,
# exec-once, not a systemd unit (graphical-session-bound units raced # the internal panel is enabled and workspaces restored before the watcher
# Hyprland's IPC on relogin; see the waybar note below). Only reacts to # waits for a physically open lid and releases the inhibitor. The normal
# CHANGES after session start: the baked config already encodes the # undocked lid-close path is therefore unchanged after release.
# last choice, and login must not fight a deliberate manual pick.
# `match` picks deterministically (exact set, else unambiguous largest
# subset); applying via the tool persists the concrete profile, so the
# next rebuild bakes what auto chose.
displayProfileWatch = pkgs.writeShellScriptBin "nomarchy-display-profile-watch" '' displayProfileWatch = pkgs.writeShellScriptBin "nomarchy-display-profile-watch" ''
set -u set -u
LOGGER=${pkgs.util-linux}/bin/logger
TRANSITION=${displayTransitionTool}/bin/nomarchy-display-transition
rt="''${XDG_RUNTIME_DIR:-/run/user/$(id -u)}"
inhibitor_pid=
inhibitor_file="$rt/nomarchy-dock-lid-inhibitor.pid"
awaiting_lid_open=
undock_pending=
undock_tries=0
log() { "$LOGGER" -t nomarchy-display-watch -- "$*"; }
internal_output() {
if [ -n "''${NOMARCHY_INTERNAL_OUTPUT:-}" ]; then
printf '%s\n' "$NOMARCHY_INTERNAL_OUTPUT"; return
fi
hyprctl monitors all -j 2>/dev/null \
| jq -r '[.[].name | select(test("^(eDP|LVDS|DSI)"))][0] // empty'
}
external_outputs() {
if [ -n "''${NOMARCHY_INTERNAL_OUTPUT:-}" ]; then
hyprctl monitors all -j 2>/dev/null \
| jq -r --arg m "$NOMARCHY_INTERNAL_OUTPUT" '.[].name | select(. != $m)'
return
fi
hyprctl monitors all -j 2>/dev/null \
| jq -r '.[].name | select(test("^(eDP|LVDS|DSI)") | not)'
}
lid_open() {
saw=
if [ -n "''${NOMARCHY_LID_STATE_FILE:-}" ]; then
files="$NOMARCHY_LID_STATE_FILE"
else
files=/proc/acpi/button/lid/*/state
fi
for state in $files; do
[ -r "$state" ] || continue
saw=1
${pkgs.gnugrep}/bin/grep -qi open "$state" && return 0
done
# Desktops/VMs without a lid are safe to treat as open.
[ -z "$saw" ]
}
owned_inhibitor_pid() {
pid="''${1:-}"
case "$pid" in *[!0-9]*|"") return 1 ;; esac
[ -r "/proc/$pid/cmdline" ] || return 1
${pkgs.coreutils}/bin/tr '\0' ' ' < "/proc/$pid/cmdline" \
| ${pkgs.gnugrep}/bin/grep -qE \
'nomarchy-dock-lid-inhibitor|Safe dock/undock display transition'
}
stop_inhibitor_group() {
pid="''${1:-}"
owned_inhibitor_pid "$pid" || return 0
# The inhibitor and its sleep command share a private process group,
# so both lose the inhibitor fd even after an unclean watcher death.
kill -TERM -"$pid" 2>/dev/null || kill "$pid" 2>/dev/null || true
}
clear_stale_inhibitor() {
[ -r "$inhibitor_file" ] || return
IFS= read -r old < "$inhibitor_file" || old=
if owned_inhibitor_pid "$old"; then
stop_inhibitor_group "$old"
log "lid-inhibitor=stale-cleaned pid=$old"
fi
${pkgs.coreutils}/bin/rm -f "$inhibitor_file"
}
acquire_inhibitor() {
if [ -n "$inhibitor_pid" ] && kill -0 "$inhibitor_pid" 2>/dev/null; then return; fi
${pkgs.util-linux}/bin/setsid ${pkgs.systemd}/bin/systemd-inhibit \
--what=handle-lid-switch --mode=block --who=Nomarchy \
--why="Safe dock/undock display transition" \
${pkgs.bash}/bin/bash -c \
'while :; do ${pkgs.coreutils}/bin/sleep 86400; done' \
nomarchy-dock-lid-inhibitor &
inhibitor_pid=$!
printf '%s\n' "$inhibitor_pid" > "$inhibitor_file"
tries=0
while [ "$tries" -lt 20 ]; do
if ! kill -0 "$inhibitor_pid" 2>/dev/null; then break; fi
if ${pkgs.systemd}/bin/systemd-inhibit --list --json=short 2>/dev/null \
| ${pkgs.jq}/bin/jq -e 'any(.[]; .what == "handle-lid-switch" and .why == "Safe dock/undock display transition" and .mode == "block")' \
>/dev/null; then
log "lid-inhibitor=acquired pid=$inhibitor_pid"
return 0
fi
tries=$((tries+1)); sleep 0.1
done
log "lid-inhibitor=acquire-failed"
stop_inhibitor_group "$inhibitor_pid"
inhibitor_pid=
${pkgs.coreutils}/bin/rm -f "$inhibitor_file"
return 1
}
release_inhibitor() {
[ -n "$inhibitor_pid" ] || return
stop_inhibitor_group "$inhibitor_pid"
wait "$inhibitor_pid" 2>/dev/null || true
log "lid-inhibitor=released"
inhibitor_pid=
${pkgs.coreutils}/bin/rm -f "$inhibitor_file"
}
cleanup() { release_inhibitor; }
trap cleanup EXIT INT TERM
clear_stale_inhibitor
auto_on() { auto_on() {
v=$(${sync} get settings.displayProfileAuto 2>/dev/null) || v=false v=$(${sync} get settings.displayProfileAuto 2>/dev/null) || v=false
case "$v" in true|True) return 0 ;; *) return 1 ;; esac case "$v" in true|True) return 0 ;; *) return 1 ;; esac
} }
outputs() { hyprctl monitors all -j 2>/dev/null | jq -r '.[].name' | sort; } outputs() { hyprctl monitors all -j 2>/dev/null | jq -r '.[].name' | sort; }
in_list() { printf '%s\n' "$1" | ${pkgs.gnugrep}/bin/grep -Fxq "$2"; }
paint_outputs() {
# awww does not inherit the current image when Hyprland adds an output,
# and it only paints outputs its daemon has already registered which
# lags the compositor's own monitoradded by an unpredictable margin.
# Re-apply a few times over the first few seconds rather than betting
# on one delay. Backgrounded so profile matching never waits on paint.
for delay in 0.5 1.5 3; do
sleep "$delay"
${sync} --quiet wallpaper >/dev/null 2>&1 || true
done
}
check_monitors() { check_monitors() {
cur=$(outputs) cur=$(outputs)
[ "$cur" = "$1" ] && return
[ -n "$cur" ] || return [ -n "$cur" ] || return
auto_on || return auto_on || return
command -v nomarchy-display-profile >/dev/null 2>&1 || return
target=$(nomarchy-display-profile match $cur) || target="base" target=$(nomarchy-display-profile match $cur) || target="base"
@@ -213,34 +386,232 @@ ${lib.concatStringsSep "\n" (lib.mapAttrsToList
[ "$target" = "$(nomarchy-display-profile active)" ] && return [ "$target" = "$(nomarchy-display-profile active)" ] && return
nomarchy-display-profile apply "$target" nomarchy-display-profile apply "$target"
fi fi
echo "$cur"
} }
prev=$(outputs) # Entering dock mode is the mirror of the automatic undock below: the
# session already comes back to the panel by itself when the last
# Listen to Hyprland's IPC socket for instant hotplug reaction # external leaves, so an arriving external has to take it the other way
${pkgs.socat}/bin/socat -U - UNIX-CONNECT:$XDG_RUNTIME_DIR/hypr/$HYPRLAND_INSTANCE_SIGNATURE/.socket2.sock | while read -r line; do # or the pair is only half-automatic and the menu has to finish the job.
case "$line" in # A display profile is a deliberate, more specific layout, so it wins
monitoradded*|monitorremoved*) # the caller only reaches here when none is in effect.
res=$(check_monitors "$prev") auto_dock() {
[ -n "$res" ] && prev="$res" internal=$(internal_output)
;; target=$(external_outputs | ${pkgs.coreutils}/bin/head -n 1)
esac [ -n "$internal" ] && [ -n "$target" ] || return
# `monitors` (unlike `monitors all`) lists only enabled outputs, so the
# panel still being there is what "not docked yet" means. Re-docking
# an already-docked session would move nothing and disable nothing.
hyprctl monitors -j 2>/dev/null \
| jq -e --arg m "$internal" 'any(.[]; .name == $m)' >/dev/null || return
if "$TRANSITION" dock "$internal" "$target" "$target, preferred, auto, 1"; then
log "auto-dock internal=$internal target=$target result=ok"
${pkgs.libnotify}/bin/notify-send -a Nomarchy "Display" \
"Docked every laptop workspace moved to $target; laptop panel off." \
2>/dev/null || true
else
log "auto-dock internal=$internal target=$target result=failed"
fi
}
# Dock mode has to be re-asserted, not just entered (#142). `reconcile`
# below diffs the *connected* set (`monitors all`, which lists disabled
# outputs too), so it is blind by construction to a panel being switched
# back **on**: the connected set does not change, and every rebuild does
# exactly that Hyprland reloads the rewritten config, the catch-all
# `monitor=,preferred,auto,1` re-lights the internal, and a workspace lands
# on it. So the invariant is checked on every tick instead of on events:
# if the user's recorded intent is dock and the panel is up while an
# external is present, put it back.
#
# `auto_dock` is already idempotent it returns early unless the internal
# is enabled so this costs one `hyprctl monitors` per tick and does
# nothing in the overwhelmingly common case. `settings.display.dockMode`
# is the user's recorded intent written by the menu and by the dock
# transition itself (`set_dock_intent`, which `auto_dock` above reaches
# via `$TRANSITION dock`) and that alone is what this is gated on. It
# used to also require `auto_on` (`settings.displayProfileAuto`), but that
# toggle only controls *named-profile* auto-switching; gating the dock
# invariant on it meant enforcement never fired for anyone who had not
# separately opted into auto profiles, which is most users. A
# `home-manager` activation's `hyprctl reload` re-applies the catch-all
# monitor rule regardless, so the panel inside a shut lid came back on
# every rebuild while docked (#148). The profile-active check right below
# is the real, narrower guard this needs: a profile is a deliberate, more
# specific layout and outranks the dock heuristic.
enforce_dock_intent() {
[ "$(${sync} get settings.display.dockMode 2>/dev/null)" = true ] || return
[ "$(nomarchy-display-profile active 2>/dev/null || echo none)" = "none" ] || return
internal=$(internal_output)
[ -n "$internal" ] || return
# Only with an external actually present: intent must never be able to
# black out a laptop that has nowhere else to draw (#127's brick).
[ -n "$(external_outputs)" ] || return
hyprctl monitors -j 2>/dev/null \
| jq -e --arg m "$internal" 'any(.[]; .name == $m)' >/dev/null || return
log "dock-intent=true panel=$internal state=re-enabled action=re-dock"
acquire_inhibitor
auto_dock
}
# The set of connected outputs not the IPC event stream is the source
# of truth. Hyprland's socket can miss or drop events (see the loop
# below), and a lost monitoradded used to mean no docking profile, no
# wallpaper on the new screen and no audio follow until something else
# poked the watcher. `reconcile` diffs the output set against the last
# one it acted on, so an event only makes the reaction *instant* never
# necessary. Everything here is idempotent and safe to call on a tick.
#
# $1 (optional) = an output Hyprland just announced as removed. It may
# still be listed in `monitors all` while teardown settles, so exclude it
# and restore the internal panel before that lands.
reconcile() {
hint="''${1:-}"
cur=$(outputs)
[ -n "$cur" ] || return
internal=$(internal_output)
ext=$(external_outputs)
if [ -n "$hint" ]; then
cur=$(printf '%s\n' "$cur" | ${pkgs.gnugrep}/bin/grep -Fxv "$hint" || true)
ext=$(printf '%s\n' "$ext" | ${pkgs.gnugrep}/bin/grep -Fxv "$hint" || true)
fi
if [ "$cur" != "$known_outputs" ]; then
added_ext=
for o in $ext; do
in_list "$known_outputs" "$o" || added_ext="$added_ext $o"
done
gone=
for o in $known_outputs; do
in_list "$cur" "$o" || gone="$gone $o"
done
known_outputs=$cur
if [ -n "$added_ext" ]; then
# A new external supersedes any undock still queued for an older
# departure: the panel is about to be disabled again on purpose.
undock_pending=
# and it supersedes a *completed* one too. Bernardo 2026-07-16:
# undocked, panel stayed dark; re-docked and undocked again and it
# came back because in between he opened the lid. `awaiting_lid_open`
# is set by every successful undock and cleared only when the external
# is gone AND the lid is open, so a clamshell undock latches it; the
# next departure then hits the `[ -z "$awaiting_lid_open" ]` guard
# below, queues nothing, and the panel never returns. His journal shows
# exactly that: no `outputs-changed removed` for the first undock at
# all, then a release the moment he lifted the lid, then a clean
# undock. The latch means "this departure is dealt with" a new
# external ends that departure, so clear it here or it outlives the
# thing it describes.
awaiting_lid_open=
[ -z "$internal" ] || acquire_inhibitor
# An external output that was not there a moment ago is an
# unambiguous physical plug whether or not we saw the IPC event,
# so it is allowed to override a manual sink choice.
command -v nomarchy-dock-audio >/dev/null 2>&1 \
&& nomarchy-dock-audio reprobe monitoradded &
paint_outputs &
log "outputs-changed added=$added_ext audio-reprobe=scheduled"
fi
# awaiting_lid_open marks an undock already completed for this
# departure the guard is what keeps event + tick from doubling up.
# The attempt itself is deliberately NOT made here: it is queued and
# driven by the invariant below, so a single lost or failed attempt
# can never be what costs the panel.
if [ -n "$gone" ] && [ -z "$ext" ] && [ -n "$internal" ] \
&& [ -z "$awaiting_lid_open" ]; then
undock_pending=1
undock_tries=0
log "outputs-changed removed=$gone transition=undock-queued"
fi
check_monitors
if [ -n "$added_ext" ] \
&& [ "$(nomarchy-display-profile active 2>/dev/null || echo none)" = "none" ]; then
auto_dock
fi
fi
# Safety invariants, re-checked regardless of whether the set moved.
#
# A dark panel is the worst outcome this watcher can produce, so the
# undock is an invariant driven to completion on the tick not a
# one-shot fired off the removal event, which a dropped IPC event or a
# transient hyprctl failure would then turn into a black laptop. (The
# transition itself no longer *expects* to fail: what used to fail
# every time was an inert keyword, fixed at the source above. This
# stays as the backstop it was always meant to be.)
if [ -n "$undock_pending" ] && [ -z "$ext" ] && [ -n "$internal" ]; then
if "$TRANSITION" undock "$internal"; then
undock_pending=
awaiting_lid_open=1
log "transition=undock result=ok attempts=$((undock_tries+1))"
else
undock_tries=$((undock_tries+1))
# Bounded: a panel that cannot be enabled at all is a different
# fault, and retrying forever would pin the lid inhibitor with it.
if [ "$undock_tries" -ge 6 ]; then
undock_pending=
awaiting_lid_open=1
log "transition=undock result=gave-up attempts=$undock_tries"
fi
fi
fi
if [ -n "$internal" ] && [ -n "$ext" ]; then
if [ -z "$inhibitor_pid" ] || ! kill -0 "$inhibitor_pid" 2>/dev/null; then
acquire_inhibitor || true
fi
fi
if [ -n "$awaiting_lid_open" ] && [ -z "$ext" ] && lid_open; then
release_inhibitor
awaiting_lid_open=
fi
# Last, because it re-derives `internal` and the invariants above must
# see reconcile's own value: a queued undock outranks a stale dock
# intent, and this returns early anyway while no external is present.
enforce_dock_intent
}
# Login/relogin while already docked still needs protection. Do not
# change the layout here: the baked config/manual profile remains SoT,
# so seed known_outputs with what is already connected.
internal=$(internal_output)
[ -n "$internal" ] && [ -n "$(external_outputs)" ] && acquire_inhibitor
known_outputs=$(outputs)
# Listen to Hyprland's IPC socket for instant hotplug reaction, and
# reconnect after a compositor reload/socket hiccup. The connection is
# deliberately long-lived: `socat -T 1` used to close it after one second
# of desktop idle, and a dock plugged while idle the normal case
# landed in the reconnect gap and was lost outright. A 1s `read` timeout
# supplies the same periodic tick without ever dropping the socket.
# Process substitution is deliberate: a pipeline `... | while read` would
# run the loop in a subshell and lose inhibitor lifecycle state.
socket="$XDG_RUNTIME_DIR/hypr/$HYPRLAND_INSTANCE_SIGNATURE/.socket2.sock"
while :; do
exec 3< <(${pkgs.socat}/bin/socat -U - UNIX-CONNECT:"$socket" 2>/dev/null)
while :; do
hint=
if IFS= read -r -t 1 line <&3; then
case "$line" in
"monitorremoved>>"*) hint=''${line#monitorremoved>>} ;;
"monitoradded>>"*) : ;;
# socket2 is chatty (focus, workspaces, ); only monitor events
# are worth a reconcile, the idle tick covers everything else.
*) continue ;;
esac
else
# bash returns >128 for the read timeout (our tick); anything else
# is EOF or error, i.e. the socket died and needs a reconnect.
rc=$?
[ "$rc" -gt 128 ] || break
fi
reconcile "$hint"
done
exec 3<&-
sleep 0.25
done done
''; '';
# Candidate layouts offered by the new-keyboard picker: the session keyboardTool = import ./keyboard-tool.nix { inherit pkgs lib config; };
# layout(s) (nomarchy.keyboard.layout, comma-split for a multi-layout
# session) plus the extra nomarchy.keyboard.layouts pool. The pool is
# deliberately NOT merged into input.kb_layout — those candidates are for
# *external* keyboards and get applied per-device by the watcher's apply().
# Loading them onto the session keyboard is what let a global switch flip
# the built-in board to the wrong layout.
pickerLayouts = lib.unique (
(lib.splitString "," config.nomarchy.keyboard.layout)
++ config.nomarchy.keyboard.layouts
);
kbAutoSwitch = config.nomarchy.keyboard.layouts != [ ];
# On a keyboard that connects after login (not declared in # On a keyboard that connects after login (not declared in
# keyboard.devices, not already remembered), ask for a layout and persist it # keyboard.devices, not already remembered), ask for a layout and persist it
@@ -261,53 +632,82 @@ ${lib.concatStringsSep "\n" (lib.mapAttrsToList
# test (hotplug isn't verifiable in CI). # test (hotplug isn't verifiable in CI).
keyboardWatch = pkgs.writeShellScriptBin "nomarchy-keyboard-watch" '' keyboardWatch = pkgs.writeShellScriptBin "nomarchy-keyboard-watch" ''
set -u set -u
layouts="${lib.concatStringsSep " " pickerLayouts}"
declared="${lib.concatStringsSep " " (builtins.attrNames config.nomarchy.keyboard.devices)}" declared="${lib.concatStringsSep " " (builtins.attrNames config.nomarchy.keyboard.devices)}"
sync=${sync} tool=${keyboardTool}/bin/nomarchy-keyboard-layout
apply() { hyprctl keyword "device[$1]:kb_layout" "$2" >/dev/null 2>&1; }
keyboards() { hyprctl devices -j | jq -r '.keyboards[].name'; } keyboards() { hyprctl devices -j | jq -r '.keyboards[].name'; }
is_declared() { case " $declared " in *" $1 "*) return 0 ;; *) return 1 ;; esac; } is_declared() { case " $declared " in *" $1 "*) return 0 ;; *) return 1 ;; esac; }
in_list() { printf '%s\n' "$1" | ${pkgs.gnugrep}/bin/grep -Fxq "$2"; }
# The remembered map (device-name -> layout) from the LIVE in-flake state; # Hyprland calls every evdev node that can emit a key a "keyboard": the
# an absent key (sparse state) reads as an empty object. # lid and power buttons, a monitor's control channel, and each extra HID
saved_map() { "$sync" get settings.keyboard.devices 2>/dev/null || echo '{}'; } # collection a real keyboard exposes. One dock with one keyboard on it
saved_for() { saved_map | jq -r --arg k "$1" '.[$k] // empty'; } # therefore used to ask which layout to use four times, for devices that
# cannot type. udev already draws this distinction properly it sets
# Persist a pick INSTANTLY: merge it into the map and write the whole object # ID_INPUT_KEYBOARD only for full typing keyboards so take the answer
# back at the dot-free parent path, so a device name containing a dot can't # from there and translate the names into Hyprland's own spelling:
# corrupt the dotted set-path. --no-switch = write only, no rebuild. # spaces to '-', then lowercase (its deviceNameToInternalString).
remember() { typing_keyboards() {
map=$(saved_map | jq -c --arg k "$1" --arg v "$2" '. + {($k): $v}') || return for dev in /sys/class/input/event*; do
"$sync" --quiet set settings.keyboard.devices "$map" --no-switch [ -r "$dev/device/name" ] || continue
${pkgs.systemd}/bin/udevadm info -q property -p "$dev" 2>/dev/null \
| ${pkgs.gnugrep}/bin/grep -q '^ID_INPUT_KEYBOARD=1' || continue
IFS= read -r name < "$dev/device/name" || continue
printf '%s\n' "$name" \
| ${pkgs.coreutils}/bin/tr ' ' '-' | ${pkgs.coreutils}/bin/tr 'A-Z' 'a-z'
done | ${pkgs.coreutils}/bin/sort -u
} }
# Hyprland disambiguates same-named devices with a -1, -2, suffix, so a
# device's physical keyboard is its name minus that suffix.
base_name() { printf '%s\n' "$1" | ${pkgs.gnused}/bin/sed -E 's/-[0-9]+$//'; }
# Startup: re-apply remembered layouts, never prompt so the built-in # Startup: re-apply remembered layouts, never prompt so the built-in
# keyboard just stays on the session default. # keyboard just stays on the session default.
prev=" " prev=" "
for kb in $(keyboards); do for kb in $(keyboards); do
prev="$prev$kb " prev="$prev$kb "
is_declared "$kb" && continue is_declared "$kb" && continue
s=$(saved_for "$kb"); [ -n "$s" ] && apply "$kb" "$s" "$tool" restore "$kb" || true
done done
# Watch for keyboards that connect later. # Watch for keyboards that connect later.
while :; do while :; do
sleep 3 sleep 3
cur=" " cur=" "
new=
for kb in $(keyboards); do for kb in $(keyboards); do
cur="$cur$kb " cur="$cur$kb "
case "$prev" in *" $kb "*) continue ;; esac case "$prev" in *" $kb "*) continue ;; esac
is_declared "$kb" && continue is_declared "$kb" && continue
s=$(saved_for "$kb") new="$new $kb"
if [ -n "$s" ]; then
apply "$kb" "$s"
else
choice=$(printf '%s\n' $layouts | rofi -dmenu -p "Layout · $kb")
[ -n "$choice" ] && { remember "$kb" "$choice"; apply "$kb" "$choice"; }
fi
done done
prev="$cur" prev="$cur"
# Nothing arrived: stay off udev, this is the every-3s common path.
[ -n "$new" ] || continue
typing=$(typing_keyboards)
handled=" "
for kb in $new; do
b=$(base_name "$kb")
in_list "$typing" "$b" || continue
case "$handled" in *" $b "*) continue ;; esac
handled="$handled$b "
# Ask once for the physical keyboard, then answer for every node it
# brought with it they are one keyboard and share one layout.
group=
for k in $new; do
[ "$(base_name "$k")" = "$b" ] && group="$group $k"
done
s=$("$tool" saved "$b")
if [ -n "$s" ]; then
for k in $group; do "$tool" restore "$k" || true; done
else
choice=$("$tool" layouts \
| rofi -monitor -1 -dmenu -i -p "Keyboard layout · $b (e.g. us, gb, pt, br)")
[ -n "$choice" ] || continue
for k in $group; do "$tool" apply "$k" "$choice"; done
fi
done
done done
''; '';
in in
@@ -324,6 +724,24 @@ in
# (Hyprland then boots into emergency mode with no binds). # (Hyprland then boots into emergency mode with no binds).
configType = "hyprlang"; configType = "hyprlang";
# Session bring-up (HM renders these into the first exec-once, after
# its dbus-update-activation-environment). The default is just
# stop/start of hyprland-session.target — not enough after a broken
# logout: if the graphical-session.target *stop* transaction was
# rejected mid-teardown (seen 2026-07-18: "transaction is destructive"
# while easyeffects had a queued start job), the target stays active
# across the logout, its services crash-loop against the dead Wayland
# socket into start-limit-hit, and the next login's plain `start` is a
# no-op — cliphist/swaync/portals stay failed for the whole session
# (doctor all red). So: tear the stale targets down, clear
# failed/start-limit state, then start fresh. All three are no-ops
# after a clean logout or cold boot.
systemd.extraCommands = [
"systemctl --user stop hyprland-session.target graphical-session.target"
"systemctl --user reset-failed"
"systemctl --user start hyprland-session.target"
];
settings = { settings = {
"$mod" = "SUPER"; "$mod" = "SUPER";
"$terminal" = config.nomarchy.terminal; "$terminal" = config.nomarchy.terminal;
@@ -347,7 +765,7 @@ in
"awww-daemon" "awww-daemon"
# Paint the wallpaper as soon as the session is up (waits for # Paint the wallpaper as soon as the session is up (waits for
# the daemon internally). # the daemon internally).
"nomarchy-theme-sync wallpaper" "nomarchy-state-sync wallpaper"
# Polkit authentication agent — without one, EVERY pkexec/polkit # Polkit authentication agent — without one, EVERY pkexec/polkit
# prompt in the session fails silently (btrfs-assistant-launcher # prompt in the session fails silently (btrfs-assistant-launcher
# was the discovery case). hyprpolkitagent is Hyprland's own Qt # was the discovery case). hyprpolkitagent is Hyprland's own Qt
@@ -361,12 +779,14 @@ in
# the clean pkill a theme switch now does — respawns the bar instead # the clean pkill a theme switch now does — respawns the bar instead
# of orphaning the session bar-less. # of orphaning the session bar-less.
] ++ lib.optional config.nomarchy.waybar.enable "nomarchy-waybar" ] ++ lib.optional config.nomarchy.waybar.enable "nomarchy-waybar"
++ lib.optional kbAutoSwitch "${keyboardWatch}/bin/nomarchy-keyboard-watch" ++ [
++ lib.optional (profiles != { }) "${displayProfileWatch}/bin/nomarchy-display-profile-watch"; "${keyboardWatch}/bin/nomarchy-keyboard-watch"
"${displayProfileWatch}/bin/nomarchy-display-profile-watch"
];
# ── Theme-driven look ────────────────────────────────────────── # ── Theme-driven look ──────────────────────────────────────────
# These flow from theme-state.json and stay at NORMAL priority: # These flow from state.json and stay at NORMAL priority:
# change them with `nomarchy-theme-sync set ui.<key>` (the intended # change them with `nomarchy-state-sync set ui.<key>` (the intended
# path), or lib.mkForce in home.nix to hardcode against the theme. # path), or lib.mkForce in home.nix to hardcode against the theme.
# The non-theme knobs around them are lib.mkDefault — override # The non-theme knobs around them are lib.mkDefault — override
# those with a plain home.nix assignment. See docs/OVERRIDES.md. # those with a plain home.nix assignment. See docs/OVERRIDES.md.
@@ -394,7 +814,12 @@ in
}; };
shadow = { shadow = {
enabled = t.ui.shadow; enabled = t.ui.shadow;
range = lib.mkDefault 20; # The mantle-tinted shadow reads as a dark halo on dark themes
# but as a warm *glow* on light ones (mantle is light there) —
# kept as an identity feature, just tight (a few px) so it never
# bleeds into neighbouring windows or the bar (hardware report
# 2026-07-18); dark themes keep the soft 20px ambient shadow.
range = lib.mkDefault (if t.mode == "light" then 5 else 20);
render_power = lib.mkDefault 3; render_power = lib.mkDefault 3;
color = rgba c.mantle "aa"; color = rgba c.mantle "aa";
}; };
@@ -446,6 +871,10 @@ in
]; ];
misc = { misc = {
# Kill the water-drop splash + the yellow "autogenerated config"
# banner. Migrations that log into Hyprland before the first
# home-manager switch still see the banner (no managed conf yet) —
# see docs/MIGRATION.md. Once HM owns hyprland.conf these stay off.
disable_hyprland_logo = lib.mkDefault true; disable_hyprland_logo = lib.mkDefault true;
disable_splash_rendering = lib.mkDefault true; disable_splash_rendering = lib.mkDefault true;
force_default_wallpaper = lib.mkDefault 0; force_default_wallpaper = lib.mkDefault 0;
@@ -454,6 +883,35 @@ in
# instead of compositor black — on light themes the black flash # instead of compositor black — on light themes the black flash
# reads as a glitch (item 28c, seen in the capture harness). # reads as a glitch (item 28c, seen in the capture harness).
background_color = rgb c.base; background_color = rgb c.base;
# #127. Hyprland ships both of these OFF, so nothing in the
# compositor turned a blanked screen back on: hypridle's on-resume
# was the ONLY caller of `dpms on` anywhere in the session — an idle
# daemon as a single point of failure with the display behind it.
# Both facts verified on hardware 2026-07-16, as is the cure: with
# hypridle deliberately stopped and the screen blanked (i.e. the
# brick condition manufactured on purpose), a keypress woke it in 4s
# docked / 6s clamshell. The wake now lives in the compositor where
# no daemon can lose it; a dead hypridle costs auto-lock, not the seat.
#
# Deliberately NOT claimed: that this is what caused the original
# brick. An earlier version of this comment said so, citing a hypridle
# deadlock (hyprwm/hypridle#171) — that was wrong and is retracted:
# hypridle had no disconnect at all around the incident. Removing a
# proven single point of failure needs no such story. The cause is
# still open (BACKLOG #127); the dead Ctrl+Alt+F3 is its lead symptom,
# and neither DPMS-off nor a dead hypridle can produce that.
key_press_enables_dpms = lib.mkDefault true;
mouse_move_enables_dpms = lib.mkDefault true;
};
# First boot of every installed image was showing "Hyprland updated to
# 0.55.x!" over the themed desktop (V2 re-boot of the #94 install disk,
# 2026-07-15). That dialog is Hyprland's update-news popup, not our
# welcome toast — kill it so the first session is ours, not upstream's.
ecosystem = {
no_update_news = lib.mkDefault true;
no_donation_nag = lib.mkDefault true;
}; };
# Window rules — float + center small config/utility dialogs that # Window rules — float + center small config/utility dialogs that
@@ -484,13 +942,24 @@ in
"float 1, match:class (?i)^(\\.?blueman-(manager|adapters)(-wrapped)?|\\.?system-config-printer(-wrapped)?)$" "float 1, match:class (?i)^(\\.?blueman-(manager|adapters)(-wrapped)?|\\.?system-config-printer(-wrapped)?)$"
"center 1, match:class (?i)^(\\.?blueman-(manager|adapters)(-wrapped)?|\\.?system-config-printer(-wrapped)?)$" "center 1, match:class (?i)^(\\.?blueman-(manager|adapters)(-wrapped)?|\\.?system-config-printer(-wrapped)?)$"
# Calendar popup (item 42): the Waybar clock's on-click runs # Calendar popup (item 42): nomarchy-calendar → calcurse in a
# nomarchy-calendar → calcurse in a ghostty window tagged with a # kitty window tagged with a distinct --class, so it floats centered.
# distinct --class, so it floats centered instead of tiling. # No `size` rule on purpose: a percentage one is silently ignored here
# (#139, measured on hardware — see term-sheet.nix), and px cannot mean
# "a fraction of *this* monitor". The launcher sizes the window itself.
"float 1, match:class ^(com\\.nomarchy\\.calendar)$" "float 1, match:class ^(com\\.nomarchy\\.calendar)$"
"size 60% 65%, match:class ^(com\\.nomarchy\\.calendar)$"
"center 1, match:class ^(com\\.nomarchy\\.calendar)$" "center 1, match:class ^(com\\.nomarchy\\.calendar)$"
# Doctor sheet (System Doctor / Waybar custom/doctor click):
# nomarchy-menu doctor → nomarchy-term-sheet, sized the same way.
"float 1, match:class ^(com\\.nomarchy\\.doctor)$"
"center 1, match:class ^(com\\.nomarchy\\.doctor)$"
# Upgrade prompt (Waybar custom/updates click → nomarchy-updates
# upgrade-window): the same classed sheet, smaller — it is a y/N flow.
"float 1, match:class ^(com\\.nomarchy\\.updates)$"
"center 1, match:class ^(com\\.nomarchy\\.updates)$"
# Polkit auth (hyprpolkitagent — app id / binary name in the package) # Polkit auth (hyprpolkitagent — app id / binary name in the package)
# and pinentry-qt (keys.nix default; org.gnupg.pinentry-qt desktop). # and pinentry-qt (keys.nix default; org.gnupg.pinentry-qt desktop).
# workspace current: agent was started on ws1 at login; without this # workspace current: agent was started on ws1 at login; without this
@@ -567,11 +1036,11 @@ in
home.packages = home.packages =
lib.optionals (config.nomarchy.hyprland.enable && config.nomarchy.displays.enable) lib.optionals (config.nomarchy.hyprland.enable && config.nomarchy.displays.enable)
[ pkgs.nwg-displays ] [ pkgs.nwg-displays ]
# The new-keyboard watcher on PATH (when enabled) so it's discoverable and # Runtime hardware watchers/helpers stay on PATH for manual recovery and
# runnable by name for debugging — Hyprland still exec-once's it by store path. # debugging. The display-profile switcher itself remains gated so the
++ lib.optional (config.nomarchy.hyprland.enable && kbAutoSwitch) keyboardWatch # menu does not advertise an empty Profiles submenu.
# The display-profile switcher (the menu's Profiles row gates on it) ++ lib.optionals config.nomarchy.hyprland.enable
# + its hotplug watcher, on PATH for debugging like the keyboard one. ([ keyboardTool keyboardWatch displayTransitionTool displayProfileWatch
++ lib.optionals (config.nomarchy.hyprland.enable && profiles != { }) displayDumpTool displayWakeTool ]
[ displayProfileTool displayProfileWatch ]; ++ lib.optional (profiles != { }) displayProfileTool);
} }

View File

@@ -1,5 +1,5 @@
# hyprlock + hypridle — screen locking and idle management, themed from # 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 # lock, display off, suspend, lock-before-sleep), hyprlock is the
# themed lock screen itself (also behind the power menu's Lock entry). # themed lock screen itself (also behind the power menu's Lock entry).
{ config, lib, pkgs, ... }: { config, lib, pkgs, ... }:
@@ -19,9 +19,32 @@ let
done done
exit 1 exit 1
''; '';
# Absolute store paths for hypridle (thin PATH). Wake reuses the shared
# helper AND the full display-transition tool (#150): idle.nix used to
# carry a lossy mini copy to dodge a circular import on hyprland.nix, but
# both live in leaf files now, so the wake path gets the real undock —
# logging, the enable-failure dump, and restore_keyboards after a reload.
displayWake =
let
keyboardTool = import ./keyboard-tool.nix { inherit pkgs lib config; };
displayDumpTool =
(import ./display-tools.nix { inherit pkgs; }).displayDumpTool;
displayTransition = import ./display-transition.nix {
inherit pkgs keyboardTool displayDumpTool;
sync = lib.getExe config.nomarchy.package;
};
in
(import ./display-tools.nix {
inherit pkgs;
inherit displayTransition;
}).displayWakeTool;
in in
{ {
config = lib.mkIf cfg.idle.enable { 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 = { programs.hyprlock = {
enable = true; enable = true;
settings = { settings = {
@@ -43,9 +66,21 @@ in
check_color = rgb c.warn; check_color = rgb c.warn;
fail_color = rgb c.bad; fail_color = rgb c.bad;
rounding = t.ui.rounding; 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 = [{ label = [{
monitor = ""; monitor = "";
text = "$TIME"; text = "$TIME";
@@ -55,7 +90,32 @@ in
position = "0, 120"; position = "0, 120";
halign = "center"; halign = "center";
valign = "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 +131,10 @@ in
# can't be safely dropped after the fact — killing the locker trips # can't be safely dropped after the fact — killing the locker trips
# its "go to a tty" crash failsafe). See nomarchy-lock-before-sleep # its "go to a tty" crash failsafe). See nomarchy-lock-before-sleep
# in modules/nixos/default.nix. # 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 = [ listener = [
# Lock and screen-off are the same on either power source — # Lock and screen-off are the same on either power source —
@@ -79,15 +142,28 @@ in
{ timeout = 300; on-timeout = "loginctl lock-session"; } { timeout = 300; on-timeout = "loginctl lock-session"; }
{ {
timeout = 600; 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-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 # Suspend only on battery, and sooner than the old fixed 30 min
# (it now only fires unplugged). Plugged in, the machine stays # (it now only fires unplugged). Plugged in, the machine stays
# up — long builds, media, presentations aren't killed mid-idle. # up — long builds, media, presentations aren't killed mid-idle.
# Closing the lid still suspends on AC (logind's default): that's # nomarchy-suspend (#115): on battery + hibernate wired + toggle
# an explicit "I'm done", distinct from sitting idle. # on → suspend-then-hibernate (1h → disk); else plain suspend.
{ timeout = 900; on-timeout = "${onAc} || systemctl 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,74 +7,91 @@
# key — the key name # key — the key name
# action — the Hyprland dispatcher (+args) that the bind runs # action — the Hyprland dispatcher (+args) that the bind runs
# desc — human label, shown in the cheatsheet # desc — human label, shown in the cheatsheet
# group — cheatsheet section: Window | Workspace | Menu | Media (#108)
# #
# `extra` holds cheatsheet-only rows for binds generated elsewhere # `extra` holds cheatsheet-only rows for binds generated elsewhere
# (per-workspace numbers, mouse drags) so they still show up under SUPER+?. # (per-workspace numbers, mouse drags) so they still show up under SUPER+?.
{ {
binds = [ binds = [
{ mods = "$mod"; key = "Return"; action = "exec, $terminal"; desc = "Open terminal"; } { 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"; } { 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"; } { 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)"; } { mods = "$mod"; key = "E"; action = "exec, $terminal -e yazi"; desc = "File manager (yazi)"; group = "Window"; }
{ mods = "$mod"; key = "Q"; action = "killactive"; desc = "Close window"; } { mods = "$mod"; key = "Q"; action = "killactive"; desc = "Close window"; group = "Window"; }
{ mods = "$mod"; key = "F"; action = "fullscreen"; desc = "Fullscreen"; } { mods = "$mod"; key = "F"; action = "fullscreen"; desc = "Fullscreen"; group = "Window"; }
{ mods = "$mod"; key = "V"; action = "togglefloating"; desc = "Toggle floating"; } { mods = "$mod"; key = "V"; action = "togglefloating"; desc = "Toggle floating"; group = "Window"; }
{ mods = "$mod SHIFT"; key = "E"; action = "exit"; desc = "Exit Hyprland"; } { mods = "$mod SHIFT"; key = "E"; action = "exit"; desc = "Exit Hyprland"; group = "Window"; }
# Theme picker (menu dispatcher): apply writes the state and runs # Theme picker (menu dispatcher): apply writes the state and runs
# home-manager switch (progress via notify-send). # 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). # 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 # Power menu via the dispatcher. Not Escape: Super+Escape gets
# swallowed before reaching the dispatcher on some setups. # 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"; } { 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 # SUPER+? — ? is Shift+/. SHIFT stays in the modmask (you hold it), but
# the keysym must be the BASE key `slash`, not `question`: Hyprland # the keysym must be the BASE key `slash`, not `question`: Hyprland
# resolves the sym with Shift consumed, so `question` never matches while # resolves the sym with Shift consumed, so `question` never matches while
# Shift is down — same as the `$mod SHIFT, 1` workspace binds. (item 26 # 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.) # fixed the modmask but kept the shifted keysym → still dead; item 32.)
# The cheatsheet still renders this row as SUPER + ? (rofi.nix). # The cheatsheet still renders this row as SUPER + ? (rofi.nix).
{ mods = "$mod SHIFT"; key = "slash"; action = "exec, nomarchy-menu keybinds"; desc = "Keybindings cheatsheet"; } { mods = "$mod SHIFT"; key = "slash"; action = "exec, nomarchy-menu keybinds"; desc = "Keybindings cheatsheet"; group = "Menu"; }
# Menu functions — SUPER+CTRL+<mnemonic> jumps straight to a # Menu functions — SUPER+CTRL+<mnemonic> jumps straight to a
# nomarchy-menu module (all also reachable from the SUPER+M picker). # nomarchy-menu module (all also reachable from the SUPER+M picker).
{ mods = "$mod CTRL"; key = "V"; action = "exec, nomarchy-menu clipboard"; desc = "Clipboard history"; } { 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"; } { 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"; } { 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"; } { 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"; } { 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)"; } { 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"; } { mods = "$mod CTRL"; key = "B"; action = "exec, nomarchy-menu bluetooth"; desc = "Bluetooth"; group = "Menu"; }
{ mods = "$mod CTRL"; key = "S"; action = "exec, nomarchy-menu capture"; desc = "Screenshot / capture"; } { mods = "$mod CTRL"; key = "R"; action = "exec, nomarchy-menu airplane"; desc = "Airplane mode (Wi-Fi + Bluetooth)"; group = "Menu"; }
{ mods = "$mod CTRL"; key = "P"; action = "exec, nomarchy-menu colorpicker"; desc = "Color picker ( clipboard)"; } { mods = "$mod CTRL"; key = "K"; action = "exec, nomarchy-menu keyboard"; desc = "Keyboard layout"; group = "Menu"; }
{ mods = "$mod CTRL"; key = "A"; action = "exec, nomarchy-menu ask"; desc = "Ask Claude"; } { mods = "$mod CTRL"; key = "S"; action = "exec, nomarchy-menu capture"; desc = "Screenshot / capture"; group = "Media"; }
{ mods = "$mod CTRL"; key = "D"; action = "exec, nomarchy-menu dnd"; desc = "Do Not Disturb toggle"; } { mods = "$mod CTRL"; key = "P"; action = "exec, nomarchy-menu colorpicker"; desc = "Color picker ( clipboard)"; group = "Menu"; }
{ mods = "$mod SHIFT"; key = "C"; action = "exec, hyprpicker -a"; desc = "Color picker ( clipboard)"; } { 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 — SUPER + arrow keys. # Focus — SUPER + arrow keys.
{ mods = "$mod"; key = "left"; action = "movefocus, l"; desc = "Focus left"; } { mods = "$mod"; key = "left"; action = "movefocus, l"; desc = "Focus left"; group = "Window"; }
{ mods = "$mod"; key = "right"; action = "movefocus, r"; desc = "Focus right"; } { mods = "$mod"; key = "right"; action = "movefocus, r"; desc = "Focus right"; group = "Window"; }
{ mods = "$mod"; key = "up"; action = "movefocus, u"; desc = "Focus up"; } { mods = "$mod"; key = "up"; action = "movefocus, u"; desc = "Focus up"; group = "Window"; }
{ mods = "$mod"; key = "down"; action = "movefocus, d"; desc = "Focus down"; } { mods = "$mod"; key = "down"; action = "movefocus, d"; desc = "Focus down"; group = "Window"; }
# Multi-monitor workspace movement — SUPER + ALT + arrow keys. # Multi-monitor workspace movement — SUPER + ALT + arrow keys.
{ mods = "$mod ALT"; key = "left"; action = "movecurrentworkspacetomonitor, l"; desc = "Move workspace to left monitor"; } { 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"; } { 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"; } { 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"; } { 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). # Screenshots (the menu's Capture module has the rest: OCR, recording).
# Bare Print → region to clipboard; the two → file binds save a # Bare Print → region to clipboard; the two → file binds save a
# timestamped PNG under ~/Pictures/Screenshots and toast the path, the # timestamped PNG under ~/Pictures/Screenshots and toast the path, the
# same plumbing the Capture menu's "→ file" rows use. # same plumbing the Capture menu's "→ file" rows use.
{ mods = ""; key = "Print"; action = "exec, grim -g \"$(slurp)\" - | wl-copy && notify-send Screenshot \"Region copied to clipboard.\""; desc = "Screenshot region clipboard"; } { mods = ""; 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"; } { 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"; } { 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"; } { 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 # Rendered only when the session has >1 layout (a comma in
@@ -83,14 +100,14 @@
# step. `current` targets the focused keyboard, so a board with its # 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. # own per-device layout (a single one) is a no-op, never a leak.
multiLayoutBinds = [ multiLayoutBinds = [
{ mods = "$mod SHIFT"; key = "K"; action = "exec, hyprctl switchxkblayout current next"; desc = "Cycle keyboard layout"; } { mods = "$mod SHIFT"; key = "K"; action = "exec, hyprctl switchxkblayout current next"; desc = "Cycle keyboard layout"; group = "Menu"; }
]; ];
extra = [ extra = [
{ keys = "SUPER + 1-9"; desc = "Switch to workspace 1-9"; } { keys = "SUPER + 1-9, 0"; desc = "Switch to workspace 1-10 (0 = 10)"; group = "Workspace"; }
{ keys = "SUPER + SHIFT + 1-9"; desc = "Move window to workspace 1-9"; } { 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"; } { keys = "SUPER + drag"; desc = "Move (LMB) / resize (RMB) window"; group = "Window"; }
{ keys = "Volume / Brightness"; desc = "Hardware keys, shown via the OSD"; } { keys = "Volume / Brightness"; desc = "Hardware keys, shown via the OSD"; group = "Media"; }
{ keys = "Bar: 󰾪 click"; desc = "Caffeine hold the screen awake (idle inhibitor)"; } { keys = "Bar: 󰾪 click"; desc = "Caffeine hold the screen awake (idle inhibitor)"; group = "Media"; }
]; ];
} }

View File

@@ -0,0 +1,81 @@
# The per-device keyboard-layout helper (nomarchy-keyboard-layout).
# Extracted from hyprland.nix (#150) so it is built ONCE and shared: the
# Hyprland module puts it on PATH and drives it from the hotplug watcher,
# while the display-transition tool calls it (restore_keyboards) after a
# config reload. Pass the module's { pkgs, lib, config }.
{ pkgs, lib, config }:
let
sync = lib.getExe config.nomarchy.package;
# Candidate layouts offered by the new-keyboard picker: the session
# layout(s) (nomarchy.keyboard.layout, comma-split for a multi-layout
# session) plus the extra nomarchy.keyboard.layouts pool. The pool is
# deliberately NOT merged into input.kb_layout — those candidates are for
# *external* keyboards and get applied per-device by the watcher's apply().
# Loading them onto the session keyboard is what let a global switch flip
# the built-in board to the wrong layout.
pickerLayouts = lib.unique (
(lib.splitString "," config.nomarchy.keyboard.layout)
++ config.nomarchy.keyboard.layouts
);
in
# Per-device keyboard helper shared by the hotplug watcher and the manual
# System Keyboard menu. The configured candidates are listed first, then
# every XKB layout known to the system, so the feature works on a default
# install instead of silently disappearing until home.nix is edited.
pkgs.writeShellScriptBin "nomarchy-keyboard-layout" ''
set -u
sync=${sync}
layouts() {
printf '%s\n' ${lib.concatMapStringsSep " " lib.escapeShellArg pickerLayouts}
${pkgs.gawk}/bin/awk '
/^! layout/ { in_layouts=1; next }
/^!/ && in_layouts { exit }
in_layouts && NF { print $1 }
' ${pkgs.xkeyboard_config}/share/X11/xkb/rules/base.lst
}
saved_map() { "$sync" get settings.keyboard.devices 2>/dev/null || echo '{}'; }
saved_for() { saved_map | jq -r --arg k "$1" '.[$k] // empty'; }
apply_runtime() { hyprctl keyword "device[$1]:kb_layout" "$2" >/dev/null 2>&1; }
case "''${1:-}" in
layouts)
layouts | ${pkgs.gawk}/bin/awk 'NF && !seen[$0]++' ;;
devices)
hyprctl devices -j 2>/dev/null \
| jq -r '.keyboards[] | "\(.name)\t\(.active_keymap // \"unknown\")"' ;;
saved)
[ -n "''${2:-}" ] || exit 64
saved_for "$2" ;;
restore)
[ -n "''${2:-}" ] || exit 64
layout=$(saved_for "$2")
[ -n "$layout" ] && apply_runtime "$2" "$layout" ;;
apply)
[ -n "''${2:-}" ] && [ -n "''${3:-}" ] || exit 64
layouts | ${pkgs.gnugrep}/bin/grep -Fxq "$3" \
|| { notify-send "Keyboard" "Unknown XKB layout: $3"; exit 64; }
if apply_runtime "$2" "$3"; then
map=$(saved_map | jq -c --arg k "$2" --arg v "$3" '. + {($k): $v}') || exit 1
if "$sync" --quiet set settings.keyboard.devices "$map" --no-switch; then
notify-send "Keyboard" "$2 $3"
else
notify-send "Keyboard" "$2 $3 for this session, but the choice could not be saved."
exit 1
fi
else
notify-send "Keyboard" "Could not apply $3 to $2."
exit 1
fi ;;
forget)
[ -n "''${2:-}" ] || exit 64
map=$(saved_map | jq -c --arg k "$2" 'del(.[$k])') || exit 1
"$sync" --quiet set settings.keyboard.devices "$map" --no-switch || exit 1
apply_runtime "$2" ${lib.escapeShellArg config.nomarchy.keyboard.layout} || true
notify-send "Keyboard" "$2 now follows the session layout." ;;
*)
echo "usage: nomarchy-keyboard-layout [layouts|devices|saved <device>|restore <device>|apply <device> <layout>|forget <device>]" >&2
exit 64 ;;
esac
''

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

View File

@@ -44,15 +44,22 @@ lib.mkIf config.nomarchy.mime.enable {
"audio/x-m4a" = "io.bassi.Amberol.desktop"; "audio/x-m4a" = "io.bassi.Amberol.desktop";
"audio/aac" = "io.bassi.Amberol.desktop"; "audio/aac" = "io.bassi.Amberol.desktop";
# The template's active editor; degrades if you drop vscode. # Prefer the template's vscode; fall through to gnome-text-editor
"text/plain" = "code.desktop"; # (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). # The system-side Thunar (nomarchy.system.fileManager).
"inode/directory" = "thunar.desktop"; "inode/directory" = "thunar.desktop";
# Template ships chromium (chromium-browser.desktop). Entry is # Template ships chromium (chromium-browser.desktop), and since #103
# skipped by GIO if the package is deleted; install another browser # so does the live ISO — an entry naming a package nothing installs is
# and/or override these keys to retarget. # 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"; "text/html" = "chromium-browser.desktop";
"x-scheme-handler/http" = "chromium-browser.desktop"; "x-scheme-handler/http" = "chromium-browser.desktop";
"x-scheme-handler/https" = "chromium-browser.desktop"; "x-scheme-handler/https" = "chromium-browser.desktop";

View File

@@ -45,29 +45,42 @@ let
v=$(${sync} get settings.nightlight.on 2>/dev/null) || v=${onDefault} v=$(${sync} get settings.nightlight.on 2>/dev/null) || v=${onDefault}
case "$v" in true|True) return 0 ;; *) return 1 ;; esac case "$v" in true|True) return 0 ;; *) return 1 ;; esac
} }
installed() { systemctl --user cat "$unit" >/dev/null 2>&1; } # LoadState=loaded means a real unit (not the empty-file mask HM/packages
start() { systemctl --user start "$unit" 2>/dev/null || true; } # 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; } stop() { systemctl --user stop "$unit" 2>/dev/null || true; }
case "''${1:-toggle}" in case "''${1:-toggle}" in
should-start) is_on ;; # ExecCondition gate (login/reboot) should-start) is_on ;; # ExecCondition gate (login/reboot)
status) status)
# Waybar (polls every 3s): moon while running; print nothing otherwise so # Waybar (polls every 3s): moon while running; print nothing otherwise so
# the module self-hides enable / re-enable from the System menu. # the module self-hides enable / re-enable from Look & Feel.
systemctl --user is-active --quiet "$unit" \ # `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' && printf '{"text":"󰖔","tooltip":"Night light on ${if geo then "follows your location" else "warm on schedule"} (click to disable)","class":"on"}\n'
exit 0 ;; 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) on)
if installed; then write_on true; start; else install_feature; start; fi ;; if installed; then write_on true; start; else install_feature; start; fi ;;
off) write_on false; stop ;; off) write_on false; stop ;;
toggle) toggle)
if systemctl --user is-active --quiet "$unit"; then if active; then
write_on false; stop # on -> off (instant) write_on false; stop # on -> off (instant)
elif installed; then elif installed; then
write_on true; start # installed, off -> on (instant) write_on true; start # installed, off -> on (instant)
else else
install_feature; start # first enable (rebuilds) install_feature; start # first enable (rebuilds)
fi ;; fi ;;
*) echo "usage: nomarchy-nightlight [toggle|status|on|off|should-start]" >&2; exit 64 ;; *) echo "usage: nomarchy-nightlight [toggle|status|on|off|should-start|is-active]" >&2; exit 64 ;;
esac esac
''; '';
in in

View File

@@ -88,11 +88,11 @@ in
# ── Required ─────────────────────────────────────────────────── # ── Required ───────────────────────────────────────────────────
stateFile = lib.mkOption { stateFile = lib.mkOption {
type = lib.types.path; type = lib.types.path;
example = lib.literalExpression "./theme-state.json"; example = lib.literalExpression "./state.json";
description = '' 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 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. on-disk copy; rebuilds bake it into the generation.
''; '';
}; };
@@ -100,10 +100,18 @@ in
# ── Preferences ──────────────────────────────────────────────── # ── Preferences ────────────────────────────────────────────────
terminal = lib.mkOption { terminal = lib.mkOption {
type = lib.types.str; type = lib.types.str;
default = config.nomarchy.settings.terminal or "ghostty"; default = config.nomarchy.settings.terminal or "kitty";
description = "Terminal emulator command, used by keybinds and $TERMINAL."; 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 { keyboard.layout = lib.mkOption {
type = lib.types.str; type = lib.types.str;
default = config.nomarchy.settings.keyboard.layout or "us"; default = config.nomarchy.settings.keyboard.layout or "us";
@@ -128,13 +136,13 @@ in
default = [ ]; default = [ ];
example = [ "de" "fr" ]; example = [ "de" "fr" ];
description = '' description = ''
Extra candidate layouts offered by the interactive new-keyboard Extra candidate layouts shown first by the interactive new-keyboard
picker. When non-empty, a small watcher runs in the session: when a picker (all installed XKB layouts remain searchable). A small watcher
keyboard connects that isn't covered by nomarchy.keyboard.devices and always runs in the Hyprland session: when a keyboard connects that
hasn't been chosen before, it pops a rofi picker (these layouts plus isn't covered by nomarchy.keyboard.devices and hasn't been chosen
the primary nomarchy.keyboard.layout), applies the choice to that before, it pops a rofi picker, applies the choice to that keyboard only
keyboard only (a per-device kb_layout, so the built-in board is left (a per-device kb_layout, so the built-in board is left alone), and
alone), and remembers it in the git-tracked in-flake state remembers it in the git-tracked in-flake state
(settings.keyboard.devices, not ~/.local/state) re-applied (settings.keyboard.devices, not ~/.local/state) re-applied
automatically on later reconnects and across reboots. Each remembered automatically on later reconnects and across reboots. Each remembered
choice graduates into nomarchy.keyboard.devices on the next rebuild choice graduates into nomarchy.keyboard.devices on the next rebuild
@@ -174,9 +182,9 @@ in
package = lib.mkOption { package = lib.mkOption {
type = lib.types.package; type = lib.types.package;
default = pkgs.nomarchy-theme-sync; default = pkgs.nomarchy-state-sync;
defaultText = lib.literalExpression "pkgs.nomarchy-theme-sync"; defaultText = lib.literalExpression "pkgs.nomarchy-state-sync";
description = "The nomarchy-theme-sync package (provided by overlays.default)."; description = "The nomarchy-state-sync package (provided by overlays.default).";
}; };
themesDir = lib.mkOption { themesDir = lib.mkOption {
@@ -364,13 +372,37 @@ in
rofi.enable = lib.mkEnableOption "Nomarchy's themed rofi launcher + the nomarchy-menu dispatcher" // { default = true; }; rofi.enable = lib.mkEnableOption "Nomarchy's themed rofi launcher + the nomarchy-menu dispatcher" // { default = true; };
swaync.enable = lib.mkEnableOption "swaync notifications, themed from the state file" // { default = true; }; swaync.enable = lib.mkEnableOption "swaync notifications, themed from the state file" // { default = true; };
batteryNotify.enable = lib.mkEnableOption "low-battery notifications at the bar's thresholds (25% low, 10% critical that one stays up until dismissed); self-gating, a silent no-op on machines without a battery" // { default = true; }; 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.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; }; yazi.enable = lib.mkEnableOption "the yazi TUI file manager, themed with a curated plugin set" // { default = true; };
osd.enable = lib.mkEnableOption "swayosd on-screen display for volume/brightness/mute" // { default = true; }; osd.enable = lib.mkEnableOption "swayosd on-screen display for volume/brightness/mute" // { default = true; };
shell.enable = lib.mkEnableOption "the zsh shell experience (starship prompt, bat/eza/zoxide)" // { 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; }; 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; }; 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; }; btop.enable = lib.mkEnableOption "btop with the per-theme nomarchy theme" // { default = true; };
stylix.enable = lib.mkEnableOption "Stylix theming for the long tail of apps (GTK, Qt, cursors)" // { default = true; }; stylix.enable = lib.mkEnableOption "Stylix theming for the long tail of apps (GTK, Qt, cursors)" // { default = true; };
displays.enable = lib.mkEnableOption "the nwg-displays interactive monitor arranger (a helper to find nomarchy.monitors values; the declarative config stays the source of truth)" // { default = true; }; displays.enable = lib.mkEnableOption "the nwg-displays interactive monitor arranger (a helper to find nomarchy.monitors values; the declarative config stays the source of truth)" // { default = true; };

View File

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

View File

@@ -18,7 +18,10 @@ lib.mkIf config.nomarchy.rofi.enable {
filefile="$run/nomarchy-record.file" filefile="$run/nomarchy-record.file"
alive() { [ -f "$pidfile" ] && kill -0 "$(cat "$pidfile")" 2>/dev/null; } alive() { [ -f "$pidfile" ] && kill -0 "$(cat "$pidfile")" 2>/dev/null; }
poke_bar() { pkill -RTMIN+8 waybar 2>/dev/null || true; } # -x, both comm names: nixpkgs wraps the binary as `.waybar-wrapped`,
# and an unanchored `waybar` also matches (and kills) the
# nomarchy-waybar supervisor, whose bash dies on an unhandled RTMIN.
poke_bar() { pkill -RTMIN+8 -x 'waybar|\.waybar-wrapped' 2>/dev/null || true; }
case "''${1:-}" in case "''${1:-}" in
start) start)

File diff suppressed because it is too large Load Diff

View File

@@ -1,11 +1,11 @@
# Shell experience — zsh (the default login shell, set system-side) with # Shell experience — zsh (the default login shell, set system-side) with
# autosuggestions + syntax highlighting, a themed starship prompt, and # autosuggestions + syntax highlighting, a themed starship prompt, and
# modern CLI tools wired in: bat (cat), eza (ls), zoxide (cd). bat uses # 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. # from the same JSON); starship is themed from the palette directly.
# #
# home.shell.enableZshIntegration = true is the single lever that makes # 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. # its zsh hooks — no per-module flags needed.
{ config, lib, pkgs, ... }: { config, lib, pkgs, ... }:
@@ -133,7 +133,7 @@ in
}; };
# bat: "ansi" follows the terminal's 16-color palette → tracks the # 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 = { programs.bat = {
enable = true; enable = true;
config = { config = {

View File

@@ -1,7 +1,7 @@
# Stylix — themes the long tail of applications (GTK, Qt, cursors, # 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 # is owned by the Nomarchy engine and updates instantly; everything
# Stylix touches updates on the next home-manager switch. That is why # Stylix touches updates on the next home-manager switch. That is why
# autoEnable is off and the trio's Stylix targets stay disabled. # autoEnable is off and the trio's Stylix targets stay disabled.
@@ -16,6 +16,31 @@ let
c = t.colors; c = t.colors;
hex = lib.removePrefix "#"; 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. # Map the Nomarchy palette onto base16 roles.
base16 = { base16 = {
base00 = hex c.base; # default background base00 = hex c.base; # default background
@@ -35,6 +60,7 @@ let
base0E = hex c.accentAlt;# magenta base0E = hex c.accentAlt;# magenta
base0F = hex c.bad; # brown/deprecated base0F = hex c.bad; # brown/deprecated
}; };
in in
{ {
config = lib.mkIf cfg.stylix.enable { config = lib.mkIf cfg.stylix.enable {
@@ -45,7 +71,148 @@ in
base16Scheme = base16; base16Scheme = base16;
targets = { 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) dialog-scoped
ONLY, never a bare `button` / `button label`. This file is
loaded by every GTK3 process including Waybar, and a direct
`button label` rule beats the color waybar.css puts on
`#workspaces button` (a rule matching the label node always
outranks color inherited from the button), so the old bare
pin painted the workspace digits with whatever palette was
live when the bar process started the 2026-07-19
stale-digit bug: switch themes, digits keep the previous
gtk.css color until the bar restarts. The real #98 fix is
forcing adw-gtk3-dark (below); these pins are only the
dialog safety net for a light sheet slipping in. */
.dialog-action-area button,
.dialog-action-area button label,
messagedialog button,
messagedialog button label,
.message-dialog button,
.message-dialog button label {
color: #${base16.base05};
}
.dialog-action-area button:disabled, .dialog-action-area button:disabled label,
messagedialog button:disabled, messagedialog button:disabled label,
.message-dialog button:disabled, .message-dialog 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; qt.enable = true;
# No-op unless programs.zathura is on (viewers.nix enables it). # No-op unless programs.zathura is on (viewers.nix enables it).
zathura.enable = true; zathura.enable = true;
@@ -91,13 +258,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 # GTK4/libadwaita and Qt6 apps decide dark vs light from the XDG
# portal's color-scheme (org.freedesktop.appearance), which # portal's color-scheme (org.freedesktop.appearance), which
# xdg-desktop-portal-gtk sources from this gsettings key. Stylix sets # xdg-desktop-portal-gtk sources from this gsettings key. Stylix sets
# the GTK theme and `polarity` but not this, so a light theme still # 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 # rendered libadwaita/Qt apps dark (and vice-versa). Drive it from the
# palette mode. (programs.dconf.enable is already on system-side.) # palette mode. (programs.dconf.enable is already on system-side.)
dconf.settings."org/gnome/desktop/interface".color-scheme = dconf.settings."org/gnome/desktop/interface" = {
if t.mode == "light" then "prefer-light" else "prefer-dark"; 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 # 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 # all: the theme-switch progress toasts, the CLI's font warnings and the
# live ISO's welcome message were all invisible. # live ISO's welcome message were all invisible.
{ config, lib, ... }: { config, lib, ... }:
@@ -29,7 +29,7 @@ in
}; };
style = '' style = ''
/* Palette baked from theme-state.json. Only roles guaranteed to /* Palette baked from state.json. Only roles guaranteed to
contrast @base in EVERY palette are used: subtext/surface mean contrast @base in EVERY palette are used: subtext/surface mean
"on-surface" in some light themes (summer-day: subtext==base, "on-surface" in some light themes (summer-day: subtext==base,
surface==text body text was invisible on hardware, item 25). surface==text body text was invisible on hardware, item 25).
@@ -71,12 +71,47 @@ in
font-size: ${toString t.fonts.size}pt; font-size: ${toString t.fonts.size}pt;
} }
.control-center .notification-row:focus, /* Unscoped on purpose: the floating popup wraps .notification in
.control-center .notification-row:hover { 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); background: alpha(@text, 0.1);
border-radius: ${r}px; 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 { .widget-title {
color: @text; color: @text;
font-weight: bold; font-weight: bold;

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,28 +1,29 @@
# Nomarchy theming engine. # 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 # ingested at evaluation time — pure, because flake files are store
# paths — and exposed to every other module as `config.nomarchy.theme`. # 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 # 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 # read-only generation. No runtime patching, no partial states; theme
# history is generation history. # history is generation history.
# #
# The one runtime exception is the wallpaper (swww is imperative by # The one runtime exception is the wallpaper (swww is imperative by
# nature): applied at session start, after every switch (hook below), # nature): applied at session start, after every switch (hook below), and
# and cycled instantly with `nomarchy-theme-sync bg next`. # after output hotplug (hyprland.nix); cycled instantly with
# `nomarchy-state-sync bg next`.
{ config, lib, pkgs, ... }: { config, lib, pkgs, ... }:
let let
cfg = config.nomarchy; cfg = config.nomarchy;
# Fail-closed load: missing / empty / non-object get a short pointer at # Fail-closed load: missing / empty / non-object get a short pointer at
# the template + `nomarchy-theme-sync validate`, not a raw readFile stack # the template + `nomarchy-state-sync validate`, not a raw readFile stack
# from deep inside a consumer (nightlight, hyprland, …). Field-level # from deep inside a consumer (nightlight, hyprland, …). Field-level
# checks below still run on the merged result. # checks below still run on the merged result.
themeState = import ../theme-state-read.nix { inherit lib; } cfg.stateFile; themeState = import ../state-read.nix { inherit lib; } cfg.stateFile;
# Defaults guarantee evaluation succeeds on a sparse or older state # Defaults guarantee evaluation succeeds on a sparse or older state
# file (e.g. one written before a schema field was added). The shipped # file (e.g. one written before a schema field was added). The shipped
@@ -70,6 +71,8 @@ let
# name in hyprland.nix — the monitor twin of the keyboard graduation. # name in hyprland.nix — the monitor twin of the keyboard graduation.
# settings.displayProfile / displayProfileAuto: active named layout + # settings.displayProfile / displayProfileAuto: active named layout +
# auto-switch on plug events (hyprland.nix / Display menu). # 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 # Defaulted so a sparse/older state file still evaluates; nomarchy.settings
# exposes them. # exposes them.
settings = { settings = {
@@ -82,13 +85,14 @@ let
autoTimezone = false; autoTimezone = false;
displayProfile = ""; displayProfile = "";
displayProfileAuto = false; displayProfileAuto = false;
firstBootShown = false;
}; };
}; };
parsed = lib.recursiveUpdate defaults themeState; parsed = lib.recursiveUpdate defaults themeState;
# ── Friendly eval-time validation ─────────────────────────────────── # ── Friendly eval-time validation ───────────────────────────────────
# The same schema nomarchy-theme-sync enforces before every write. A # The same schema nomarchy-state-sync enforces before every write. A
# HAND-edited state file (the one path that bypasses the tool) must # 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 # fail with the field, the problem, and the fix — not a Nix stack
# trace deep in some consumer. Checks run on `parsed` (after the # trace deep in some consumer. Checks run on `parsed` (after the
@@ -133,14 +137,14 @@ let
if problems == [ ] then parsed if problems == [ ] then parsed
else throw '' else throw ''
Nomarchy: your theme-state.json is invalid: Nomarchy: your state.json is invalid:
${lib.concatMapStrings (p: " ${p}\n") problems} ${lib.concatMapStrings (p: " ${p}\n") problems}
Fix the named field(s) in the theme-state.json of your flake Fix the named field(s) in the state.json of your flake
checkout (usually ~/.nomarchy/theme-state.json the store copy at checkout (usually ~/.nomarchy/state.json the store copy at
${toString cfg.stateFile} is a snapshot of it). The tool prints ${toString cfg.stateFile} is a snapshot of it). The tool prints
the same report with per-field fixes: `nomarchy-theme-sync the same report with per-field fixes: `nomarchy-state-sync
validate`. Re-applying any preset resets all appearance fields: validate`. Re-applying any preset resets all appearance fields:
`nomarchy-theme-sync apply boreal`.''; `nomarchy-state-sync apply boreal`.'';
# A border value is a palette key (look it up in colors) unless it's # A border value is a palette key (look it up in colors) unless it's
# already a literal hex. Unknown roles are rejected by the field checks # already a literal hex. Unknown roles are rejected by the field checks
@@ -151,7 +155,7 @@ let
Nomarchy: border role "${v}" is not in the palette (colors.*). Nomarchy: border role "${v}" is not in the palette (colors.*).
Use one of: ${lib.concatStringsSep ", " colorRoles} Use one of: ${lib.concatStringsSep ", " colorRoles}
or a literal "#RRGGBB". Validate with: nomarchy-theme-sync validate''); or a literal "#RRGGBB". Validate with: nomarchy-state-sync validate'');
border = { border = {
active = resolveColor checked.border.active; active = resolveColor checked.border.active;
inactive = resolveColor checked.border.inactive; inactive = resolveColor checked.border.inactive;
@@ -197,10 +201,73 @@ let
name = "nomarchy-icon-themes"; name = "nomarchy-icon-themes";
paths = [ papirusPkg matchedPack ]; 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 in
{ {
config = { config = {
nomarchy.theme = checked // { inherit iconTheme border iconThemePackage; }; # 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 # Feature toggles the menu writes (settings.nightlight.enable, …), exposed
# alongside the appearance state. Feature modules mkDefault-read from here # alongside the appearance state. Feature modules mkDefault-read from here

View File

@@ -2,7 +2,7 @@
# timezone. Waybar's clock module captures the zone once at construction, so a # timezone. Waybar's clock module captures the zone once at construction, so a
# runtime timezone change (automatic-timezoned, nomarchy.system.autoTimezone) # runtime timezone change (automatic-timezoned, nomarchy.system.autoTimezone)
# would NOT show until a relogin. A tiny watcher subscribes to timedate1's # would NOT show until a relogin. A tiny watcher subscribes to timedate1's
# change signal and reloads Waybar (SIGUSR2 = the same reload theme-sync uses), # change signal and reloads Waybar (SIGUSR2 = the same reload state-sync uses),
# so the clock follows your location live. Also catches a manual # so the clock follows your location live. Also catches a manual
# `timedatectl set-timezone`. # `timedatectl set-timezone`.
# #
@@ -24,7 +24,8 @@ let
cur=$(${pkgs.systemd}/bin/timedatectl show -p Timezone --value 2>/dev/null || true) cur=$(${pkgs.systemd}/bin/timedatectl show -p Timezone --value 2>/dev/null || true)
[ "$cur" = "$last" ] && continue [ "$cur" = "$last" ] && continue
last=$cur last=$cur
${pkgs.procps}/bin/pkill -SIGUSR2 -x waybar 2>/dev/null || true # Both comm names: nixpkgs wraps the binary as `.waybar-wrapped`.
${pkgs.procps}/bin/pkill -SIGUSR2 -x 'waybar|\.waybar-wrapped' 2>/dev/null || true
done done
''; '';
in in

View File

@@ -14,6 +14,7 @@
let let
cfg = config.nomarchy.updates; cfg = config.nomarchy.updates;
termSheet = import ./term-sheet.nix { inherit pkgs; };
nomarchy-updates = pkgs.writeShellScriptBin "nomarchy-updates" '' nomarchy-updates = pkgs.writeShellScriptBin "nomarchy-updates" ''
set -u set -u
@@ -70,7 +71,8 @@ let
echo 0 echo 0
} }
refresh_bar() { ${pkgs.procps}/bin/pkill -RTMIN+9 -x waybar 2>/dev/null || true; } # Both comm names: nixpkgs wraps the binary as `.waybar-wrapped`.
refresh_bar() { ${pkgs.procps}/bin/pkill -RTMIN+9 -x 'waybar|\.waybar-wrapped' 2>/dev/null || true; }
case "''${1:-status}" in case "''${1:-status}" in
check) check)
@@ -113,7 +115,23 @@ let
fi fi
"$0" check "$0" check
echo "Done press enter."; read -r _ || true ;; echo "Done press enter."; read -r _ || true ;;
*) echo "usage: nomarchy-updates [check|status|upgrade]" >&2; exit 64 ;; 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 esac
''; '';
in in

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: # Waybar — two-tier theming:
# #
# 1. Default: structure, fonts, geometry AND palette baked from # 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 # 2. Whole-swap: themes with their own visual identity ship
# <themesDir>/<slug>/waybar.css (and optionally waybar.jsonc) which # <themesDir>/<slug>/waybar.css (and optionally waybar.jsonc) which
@@ -11,13 +11,18 @@
let let
t = config.nomarchy.theme; t = config.nomarchy.theme;
termSheet = import ./term-sheet.nix { inherit pkgs; };
# Show the active-keyboard-layout indicator only when more than one layout # Whether the keyboard-layout indicator is worth bar space, and the filter
# is in play — multiple session layouts (comma-separated) or per-device # that holds a whole-swap to the same answer. Contract + rationale (and
# overrides (nomarchy.keyboard.devices) — so single-layout bars stay clean. # checks.waybar-language) live in ./waybar-language.nix.
showLanguage = lib.hasInfix "," config.nomarchy.keyboard.layout langLib = import ./waybar-language.nix { inherit lib; };
|| config.nomarchy.keyboard.layouts != [ ] showLanguage = langLib.show {
|| config.nomarchy.keyboard.devices != { }; 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- # Power-profile indicator (power-profiles-daemon). Both scripts self-
# gate: they exit silently unless this is a laptop (a battery is # gate: they exit silently unless this is a laptop (a battery is
@@ -31,9 +36,10 @@ let
# Waybar supervisor — exec-once has no restart, so a crashed bar used to # 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 # leave the session bar-less until relogin (seen on hardware: a theme
# switch crashed waybar mid-reload). Respawns on ANY exit — a plain # switch crashed waybar mid-reload). Respawns on ANY exit — a plain
# `pkill -x waybar` is now a clean restart, which nomarchy-theme-sync # `pkill -x 'waybar|\.waybar-wrapped'` (nixpkgs wraps the binary, so
# uses instead of the crash-prone in-place SIGUSR2 reload when it sees # the comm is `.waybar-wrapped`) is now a clean restart, which
# this supervisor running. Crash-loop guard: 5 exits within 10s of # 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 # their start → give up with a critical notification instead of
# spinning. Stop the bar for real: pkill -f nomarchy-waybar (TERM is # spinning. Stop the bar for real: pkill -f nomarchy-waybar (TERM is
# trapped to take the child down too). # trapped to take the child down too).
@@ -89,33 +95,34 @@ let
[ -n "$next" ] && powerprofilesctl set "$next" [ -n "$next" ] && powerprofilesctl set "$next"
''; '';
# Opens calcurse (a lightweight TUI calendar, month view by default) in a # Opens calcurse (lightweight TUI calendar) in a floating, centered kitty
# floating, centered ghostty window — bound to the Waybar clock's on-click. # window — Waybar clock on-click. Distinct --class → hyprland.nix float
# A distinct --class gives the window a matchable app-id for the # rules. Kitty is always installed (kitty.nix). The sheet sizes itself
# float+center+size windowrule (hyprland.nix); --gtk-single-instance=false # (#139: a percentage `size` rule is silently ignored) — see term-sheet.nix.
# forces a fresh standalone window (ghostty defaults single-instance on).
# calcurse is installed with the bar (home.packages below); the gate is
# only for someone who force-removed it. ghostty is always installed
# (ghostty.nix), so it can be relied on for the --class window.
calendarLauncher = pkgs.writeShellScriptBin "nomarchy-calendar" '' calendarLauncher = pkgs.writeShellScriptBin "nomarchy-calendar" ''
if ! command -v calcurse >/dev/null 2>&1; then if ! command -v calcurse >/dev/null 2>&1; then
notify-send "Calendar" "calcurse isn't installed (removed from home.packages?)." 2>/dev/null notify-send "Calendar" "calcurse isn't installed (removed from home.packages?)." 2>/dev/null
exit 0 exit 0
fi fi
exec ghostty --class=com.nomarchy.calendar --gtk-single-instance=false -e calcurse exec ${termSheet}/bin/nomarchy-term-sheet com.nomarchy.calendar 60 65 calcurse
''; '';
# VPN indicator — shows a shield when a NetworkManager VPN/WireGuard # VPN indicator — shows a shield when a NetworkManager VPN/WireGuard
# connection is active OR Tailscale is up; prints nothing otherwise so the # connection is active OR Tailscale is up; prints nothing otherwise so the
# module self-hides (like nightlight/updates). Click opens the VPN submenu. # 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" '' vpnStatus = pkgs.writeShellScriptBin "nomarchy-vpn-status" ''
active=$(nmcli -t -f TYPE connection show --active 2>/dev/null | grep -Exm1 'vpn|wireguard') names=$(nmcli -t -f NAME,TYPE connection show --active 2>/dev/null \
| awk -F: '$2=="vpn"||$2=="wireguard"{print $1}')
ts="" ts=""
if command -v tailscale >/dev/null 2>&1; then if command -v tailscale >/dev/null 2>&1; then
[ "$(tailscale status --json 2>/dev/null | jq -r '.BackendState // empty')" = Running ] && ts=1 [ "$(tailscale status --json 2>/dev/null | jq -r '.BackendState // empty')" = Running ] && ts=Tailscale
fi fi
[ -n "$active" ] || [ -n "$ts" ] || exit 0 [ -n "$names$ts" ] || exit 0
printf '{"text":"󰦝","tooltip":"VPN active (click to manage)","class":"on"}\n' 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 # Health warning — self-gates: prints nothing while nomarchy-doctor
@@ -156,7 +163,10 @@ let
# normal tiling. Keep in sync with the whole-swap jsoncs (parity rule). # normal tiling. Keep in sync with the whole-swap jsoncs (parity rule).
layer = "bottom"; layer = "bottom";
position = "top"; 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-top = t.ui.gapsOut;
margin-left = t.ui.gapsOut; margin-left = t.ui.gapsOut;
margin-right = t.ui.gapsOut; margin-right = t.ui.gapsOut;
@@ -170,7 +180,7 @@ let
# — BACKLOG #63). Click targets are existing nomarchy-menu entry points. # — BACKLOG #63). Click targets are existing nomarchy-menu entry points.
modules-left = [ "custom/nomarchy" "hyprland/workspaces" "hyprland/window" ]; modules-left = [ "custom/nomarchy" "hyprland/workspaces" "hyprland/window" ];
modules-center = [ "clock" ]; modules-center = [ "clock" ];
modules-right = [ "custom/recording" "idle_inhibitor" "tray" "custom/vpn" "pulseaudio" "custom/powerprofile" "custom/nightlight" ] modules-right = [ "custom/recording" "idle_inhibitor" "tray" "custom/vpn" "custom/airplane" "pulseaudio" "custom/powerprofile" "custom/nightlight" ]
++ lib.optional showLanguage "hyprland/language" ++ lib.optional showLanguage "hyprland/language"
++ [ "battery" "custom/doctor" "custom/updates" "custom/notification" "custom/powermenu" ]; ++ [ "battery" "custom/doctor" "custom/updates" "custom/notification" "custom/powermenu" ];
@@ -204,7 +214,7 @@ let
# into clock + clock#date — one click surface for the calendar). # into clock + clock#date — one click surface for the calendar).
format = "{:%H:%M · %a %d %b}"; format = "{:%H:%M · %a %d %b}";
# Left-click → the calendar (nomarchy-calendar → calcurse in a floating # Left-click → the calendar (nomarchy-calendar → calcurse in a floating
# ghostty). Tooltip is plain date/zone only — embedding {calendar} # kitty). Tooltip is plain date/zone only — embedding {calendar}
# produced an empty popup on hardware (2026-07-10); month view is # produced an empty popup on hardware (2026-07-10); month view is
# one click away. # one click away.
on-click = "nomarchy-calendar"; on-click = "nomarchy-calendar";
@@ -260,6 +270,13 @@ let
format = "<span size='${toString (t.fonts.size + 2)}pt'>{icon}</span>{capacity}%"; 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-charging = "<span size='${toString (t.fonts.size + 2)}pt'>󰂄 </span>{capacity}%";
format-icons = [ "󰁺 " "󰁼 " "󰁾 " "󰂀 " "󰁹 " ]; 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 # Click either the battery or the power-profile icon → the combined
# power menu (profile + charge cap). The granular System rows stay. # power menu (profile + charge cap). The granular System rows stay.
on-click = "nomarchy-menu powermgmt"; on-click = "nomarchy-menu powermgmt";
@@ -322,6 +339,17 @@ let
on-click = "nomarchy-nightlight toggle"; 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 # Update awareness. Self-gates: hidden unless nomarchy.updates is enabled
# AND the periodic check found something (the helper prints nothing then). # AND the periodic check found something (the helper prints nothing then).
# signal 9 lets the checker refresh it instantly; click opens the upgrade # signal 9 lets the checker refresh it instantly; click opens the upgrade
@@ -331,7 +359,7 @@ let
return-type = "json"; return-type = "json";
interval = 1800; interval = 1800;
signal = 9; signal = 9;
on-click = "${config.nomarchy.terminal} -e nomarchy-updates upgrade"; on-click = "nomarchy-updates upgrade-window";
}; };
# swaync notification bell + Do-Not-Disturb state. `-swb` streams JSON # swaync notification bell + Do-Not-Disturb state. `-swb` streams JSON
@@ -366,15 +394,32 @@ let
}; };
generatedStyle = '' generatedStyle = ''
/* Palette baked from theme-state.json */ /* Palette baked from state.json */
${colorDefs} ${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-family: "${t.fonts.ui}", "${t.fonts.mono}";
font-size: ${toString t.fonts.size}pt; font-size: ${toString t.fonts.size}pt;
min-height: 0; 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 { window#waybar {
background: alpha(@base, 0.85); background: alpha(@base, 0.85);
color: @text; color: @text;
@@ -382,14 +427,20 @@ let
border-radius: ${toString t.ui.rounding}px; border-radius: ${toString t.ui.rounding}px;
} }
/* Dim states use the palette's @muted role: since item 28b it is /* Dim states use the palette's @muted role on DARK themes: since
floor-guaranteed legible on @base in every theme (muted/base >= item 28b it is floor-guaranteed legible on @base (muted/base >=
2.0, gated by tools/check-theme-contrast.py) the palettes that 2.0, gated by tools/check-theme-contrast.py). On LIGHT themes that
once made it vanish (gruvbox mutedbase, item 27) were retuned. 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). */ 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 { #workspaces button {
padding: 0 8px; padding: 0 8px;
color: @muted; margin: 3px 0;
color: ${if t.mode == "light" then "@subtext" else "@muted"};
border-radius: ${toString t.ui.rounding}px; border-radius: ${toString t.ui.rounding}px;
} }
@@ -417,14 +468,18 @@ let
color: @accent; color: @accent;
font-family: Nomarchy; font-family: Nomarchy;
font-size: ${toString (t.fonts.size + 4)}pt; font-size: ${toString (t.fonts.size + 4)}pt;
padding: 0 10px; /* 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; } #custom-nomarchy:hover { color: @accentAlt; }
#tray, #pulseaudio, #custom-powerprofile, #custom-nightlight, #custom-updates, #custom-vpn, #custom-recording, #idle_inhibitor, #language, #battery, #custom-doctor, #custom-notification, #custom-powermenu { #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); color: alpha(@text, 0.85);
padding: 0 10px; padding: 0 10px;
} }
#custom-powermenu { padding: 0 16px 0 10px; }
#custom-powermenu:hover { color: @bad; } #custom-powermenu:hover { color: @bad; }
/* Group rhythm (item 28c): a wider breath before each functional /* Group rhythm (item 28c): a wider breath before each functional
@@ -445,6 +500,7 @@ let
/* Night-light active warm tone, matching the filter it represents. */ /* Night-light active warm tone, matching the filter it represents. */
#custom-nightlight.on { color: @warn; } #custom-nightlight.on { color: @warn; }
#custom-airplane.on { color: @warn; }
/* Updates pending accent, to draw the eye. */ /* Updates pending accent, to draw the eye. */
#custom-updates.available { color: @accent; } #custom-updates.available { color: @accent; }
@@ -457,7 +513,7 @@ let
(size+2)pt Pango icon span the icon+text modules use bump their (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 / font-size to match, or these glyphs read smaller than the volume /
battery / language icons beside them. */ battery / language icons beside them. */
#custom-recording, #custom-updates, #custom-vpn, #custom-nightlight, #custom-doctor, #custom-notification, #custom-powermenu { font-size: ${toString (t.fonts.size + 2)}pt; } #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 /* The speedometer + caffeine glyphs render small in their em box
size them up a touch more so they read at a glance. */ size them up a touch more so they read at a glance. */
@@ -494,7 +550,7 @@ in
# on a warm relogin — it started before the socket was up, exited, landed # 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 # 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 # fires once Hyprland is up, dodging the race; theme switches reload the
# running bar via SIGUSR2 (nomarchy-theme-sync). No uwsm here to manage the # running bar via SIGUSR2 (nomarchy-state-sync). No uwsm here to manage the
# session target, so we don't depend on its lifecycle. # session target, so we don't depend on its lifecycle.
systemd.enable = false; systemd.enable = false;
@@ -505,7 +561,7 @@ in
# docs/OVERRIDES.md. # docs/OVERRIDES.md.
settings.mainBar = lib.mkDefault ( settings.mainBar = lib.mkDefault (
if hasConfigOverride if hasConfigOverride
then builtins.fromJSON (builtins.readFile configOverride) then gateLanguage (builtins.fromJSON (builtins.readFile configOverride))
else generatedSettings else generatedSettings
); );

View File

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

View File

@@ -4,7 +4,7 @@
# nixosModules.nomarchy in flake.nix) and layer your machine specifics # nixosModules.nomarchy in flake.nix) and layer your machine specifics
# (bootloader, hostname, users, hardware) on top. Host concerns are # (bootloader, hostname, users, hardware) on top. Host concerns are
# deliberately NOT set here. Everything user-facing (Hyprland config, # 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, ... }: { config, lib, pkgs, ... }:
let let
@@ -20,9 +20,26 @@ let
nomarchyLogoFont = pkgs.runCommandLocal "nomarchy-logo-font" { } '' nomarchyLogoFont = pkgs.runCommandLocal "nomarchy-logo-font" { } ''
install -Dm444 ${./branding/Nomarchy.ttf} $out/share/fonts/truetype/Nomarchy.ttf 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 in
{ {
imports = [ ./options.nix ./plymouth.nix ./greeter.nix ./file-manager.nix ./power.nix ./services.nix ./hardware.nix ./timezone.nix ./oom.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 = { config = {
# Distro branding. distroName flows into /etc/os-release PRETTY_NAME, # Distro branding. distroName flows into /etc/os-release PRETTY_NAME,
@@ -38,11 +55,32 @@ in
# restore them pointing at the project instead. # restore them pointing at the project instead.
system.nixos.distroName = lib.mkDefault "Nomarchy"; system.nixos.distroName = lib.mkDefault "Nomarchy";
system.nixos.distroId = lib.mkDefault "nomarchy"; system.nixos.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 { system.nixos.extraOSReleaseArgs = lib.mkDefault {
HOME_URL = "https://git.bemagri.xyz/bernardo/Nomarchy"; HOME_URL = "https://git.bemagri.xyz/bernardo/Nomarchy";
DOCUMENTATION_URL = "https://git.bemagri.xyz/bernardo/Nomarchy"; DOCUMENTATION_URL = "https://git.bemagri.xyz/bernardo/Nomarchy";
SUPPORT_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"; 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 # MOTD on TTY/SSH login (the desktop auto-logs into Hyprland, so this
@@ -57,7 +95,7 @@ in
nomarchy-pull update flake inputs (nomarchy, nixpkgs, ) nomarchy-pull update flake inputs (nomarchy, nixpkgs, )
nomarchy-rebuild rebuild the system (current lock) nomarchy-rebuild rebuild the system (current lock)
nomarchy-home rebuild the desktop / Home Manager nomarchy-home rebuild the desktop / Home Manager
nomarchy-theme-sync apply <theme> switch the whole palette nomarchy-state-sync apply <theme> switch the whole palette
nomarchy-doctor read-only health check nomarchy-doctor read-only health check
SUPER+? keybindings cheatsheet SUPER+? keybindings cheatsheet
SUPER+M System Firmware check LVFS firmware updates (fwupd) SUPER+M System Firmware check LVFS firmware updates (fwupd)
@@ -125,6 +163,10 @@ in
alsa.support32Bit = true; alsa.support32Bit = true;
pulse.enable = true; pulse.enable = true;
wireplumber.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 ───────────────────────────────────────────── # ── Desktop services ─────────────────────────────────────────────
@@ -151,6 +193,27 @@ in
notifications.x11.enable = lib.mkDefault true; notifications.x11.enable = lib.mkDefault true;
notifications.wall.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. # Core security: enable AppArmor to confine desktop apps and services.
security.apparmor.enable = true; security.apparmor.enable = true;
security.apparmor.killUnconfinedConfinables = false; security.apparmor.killUnconfinedConfinables = false;
@@ -184,6 +247,13 @@ in
# system unit hooked to the sleep targets: it locks on the RAM-resume # 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 # sleeps always, and on hibernate only when the disk is unencrypted (no
# LUKS gate to rely on). Replaces hypridle's old before_sleep_cmd. # 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 = systemd.services.nomarchy-lock-before-sleep =
let let
encrypted = builtins.attrNames config.boot.initrd.luks.devices != [ ]; encrypted = builtins.attrNames config.boot.initrd.luks.devices != [ ];
@@ -202,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 — # zsh as the default login shell (the desktop's shell experience —
# starship/bat/eza/zoxide — is configured home-side in shell.nix). # starship/bat/eza/zoxide — is configured home-side in shell.nix).
# programs.zsh.enable wires /etc/zshrc, completion and /etc/shells; # programs.zsh.enable wires /etc/zshrc, completion and /etc/shells;
@@ -213,14 +297,21 @@ in
# overrides this regardless. # overrides this regardless.
users.defaultUserShell = lib.mkOverride 500 pkgs.zsh; 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; hardware.bluetooth.enable = lib.mkDefault cfg.bluetooth.enable;
services.blueman.enable = lib.mkDefault cfg.bluetooth.enable; services.blueman.enable = lib.mkDefault cfg.bluetooth.enable;
# ── Foreign binaries: nix-ld ───────────────────────────────────── # ── Foreign binaries: nix-ld ─────────────────────────────────────
# An ld.so shim so dynamically-linked binaries not built for NixOS # An ld.so shim so dynamically-linked binaries not built for NixOS
# (downloaded tools, language servers, pip/npm-installed ELFs, the # (downloaded tools, language servers, pip/npm-installed ELFs) run
# npx-fetched claude-code) run without manual patchelf. On by default — # without manual patchelf. On by default — a pragmatic-desktop
# a pragmatic-desktop expectation. # expectation.
programs.nix-ld.enable = lib.mkDefault true; programs.nix-ld.enable = lib.mkDefault true;
# ── Firmware ───────────────────────────────────────────────────── # ── Firmware ─────────────────────────────────────────────────────
@@ -291,7 +382,7 @@ in
# ── Fonts ──────────────────────────────────────────────────────── # ── Fonts ────────────────────────────────────────────────────────
# The ten most popular Nerd Fonts ship by default, so any of them # The ten most popular Nerd Fonts ship by default, so any of them
# can be named in the theme state's fonts.mono and actually resolve # can be named in the theme state's fonts.mono and actually resolve
# (nomarchy-theme-sync warns when a configured font is missing). # (nomarchy-state-sync warns when a configured font is missing).
fonts = { fonts = {
packages = with pkgs; [ packages = with pkgs; [
nerd-fonts.jetbrains-mono nerd-fonts.jetbrains-mono
@@ -328,9 +419,8 @@ in
# ── Essential packages ─────────────────────────────────────────── # ── Essential packages ───────────────────────────────────────────
environment.systemPackages = with pkgs; [ 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-doctor # read-only health check (System Doctor)
nomarchy-control-center # TUI control center
nomarchy-detect-hw # post-install hardware re-probe (HARDWARE.md §8) nomarchy-detect-hw # post-install hardware re-probe (HARDWARE.md §8)
# Day-to-day lifecycle (README §3): nomarchy-pull / -rebuild / -home # Day-to-day lifecycle (README §3): nomarchy-pull / -rebuild / -home
@@ -446,22 +536,30 @@ in
# users without root. Harmless baseline even if the OSD is disabled. # users without root. Harmless baseline even if the OSD is disabled.
services.udev.packages = [ pkgs.swayosd ]; 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 itself ───────────────────────────────────────────────────
nix = { nix = {
settings = { settings = {
experimental-features = [ "nix-command" "flakes" ]; experimental-features = [ "nix-command" "flakes" ];
auto-optimise-store = lib.mkDefault true; auto-optimise-store = lib.mkDefault true;
# The downstream flake (~/.nomarchy) is meant to be a live working # 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 # switch (and you needn't commit each tweak), so the "Git tree is
# dirty" warning fires on every rebuild and is pure noise here. # dirty" warning fires on every rebuild and is pure noise here.
warn-dirty = lib.mkDefault false; warn-dirty = lib.mkDefault false;
}; };
gc = { # Generation age+floor policy + store GC: modules/nixos/gen-prune.nix (#128).
automatic = lib.mkDefault true;
dates = lib.mkDefault "weekly";
options = lib.mkDefault "--delete-older-than 14d";
};
}; };
}; };
} }

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

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

View File

@@ -1,4 +1,4 @@
# Greeter — greetd/tuigreet, themed from the same theme-state.json that # Greeter — greetd/tuigreet, themed from the same state.json that
# drives the desktop (nomarchy.system.stateFile; the Plymouth model: # drives the desktop (nomarchy.system.stateFile; the Plymouth model:
# baked at SYSTEM rebuild, so it follows the theme as of the last # baked at SYSTEM rebuild, so it follows the theme as of the last
# sys-update, not the last instant apply). # sys-update, not the last instant apply).
@@ -14,16 +14,70 @@
# theme's colors). ANSI "black" stays dark even in light themes — # theme's colors). ANSI "black" stays dark even in light themes —
# the greeter reads terminal-dark there, the same convention every # the greeter reads terminal-dark there, the same convention every
# terminal applies to ANSI colors. # 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, ... }: { config, lib, pkgs, ... }:
let let
cfg = config.nomarchy.system; cfg = config.nomarchy.system;
distroName = config.system.nixos.distroName; 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 = state =
if cfg.stateFile != null if cfg.stateFile != null
then builtins.fromJSON (builtins.readFile cfg.stateFile) then import ../state-read.nix { inherit lib; } cfg.stateFile
else { }; 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 # A sparse/hand-rolled state without a proper ansi block just skips the
# theming (stock tuigreet grey) — never an eval error. # theming (stock tuigreet grey) — never an eval error.
ansi = state.ansi or [ ]; ansi = state.ansi or [ ];
@@ -44,6 +98,15 @@ let
in in
{ {
config = { 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 # VT palette from the theme (RRGGBB, no #; lands as vt.default_* kernel
# params). mkDefault so a downstream console.colors wins. # params). mkDefault so a downstream console.colors wins.
console.colors = lib.mkIf themed (lib.mkDefault (map (lib.removePrefix "#") ansi)); console.colors = lib.mkIf themed (lib.mkDefault (map (lib.removePrefix "#") ansi));

View File

@@ -15,14 +15,66 @@
let let
cfg = config.nomarchy.hardware; cfg = config.nomarchy.hardware;
# Fingerprint PAM can follow theme-state.json (menu toggle → next # Fingerprint PAM can follow state.json (menu toggle → next
# sys-rebuild), same bridge as autoTimezone (BACKLOG #55). Missing or # sys-rebuild), same bridge as autoTimezone (BACKLOG #55). Missing or
# invalid JSON fails closed (theme-state-read.nix) instead of a raw stack. # invalid JSON fails closed (state-read.nix) instead of a raw stack.
hwState = hwState =
if config.nomarchy.system.stateFile != null if config.nomarchy.system.stateFile != null
then import ../theme-state-read.nix { inherit lib; } config.nomarchy.system.stateFile then import ../state-read.nix { inherit lib; } config.nomarchy.system.stateFile
else { }; else { };
pamFromState = (hwState.settings or { }).fingerprint.pam or false; 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 in
{ {
options.nomarchy.hardware = { options.nomarchy.hardware = {
@@ -104,14 +156,28 @@ in
type = lib.types.bool; type = lib.types.bool;
default = pamFromState; default = pamFromState;
defaultText = lib.literalExpression defaultText = lib.literalExpression
"(settings.fingerprint.pam from theme-state.json) or false"; "(settings.fingerprint.pam from state.json) or false";
description = '' description = ''
Use the fingerprint for login and sudo (PAM). Opt-in password-only Use the fingerprint for login and sudo (PAM). Opt-in password-only
stays the default for the cautious; enroll a finger first. Defaults stays the default for the cautious; enroll a finger first. Defaults
from theme-state.json `settings.fingerprint.pam` (System Fingerprint from state.json `settings.fingerprint.pam` (System Fingerprint
menu) when set; otherwise false. 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 '' npu.enable = lib.mkEnableOption ''
@@ -193,12 +259,35 @@ in
}) })
# ── Fingerprint ──────────────────────────────────────────────────── # ── 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 { (lib.mkIf cfg.fingerprint.enable {
services.fprintd.enable = true; services.fprintd.enable = true;
}) # Ships whenever a reader exists, regardless of the pam flag: the
(lib.mkIf (cfg.fingerprint.enable && cfg.fingerprint.pam) { # toggle's whole job is to turn the flag back on while it's off.
security.pam.services.login.fprintAuth = true; environment.systemPackages = [ nomarchy-fingerprint ];
security.pam.services.sudo.fprintAuth = true; 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) ────── # ── Newest kernel for very-new hardware (opt-in escape hatch) ──────

View File

@@ -11,27 +11,34 @@
greeter.autoLogin = lib.mkOption { greeter.autoLogin = lib.mkOption {
type = lib.types.nullOr lib.types.str; type = lib.types.nullOr lib.types.str;
default = config.nomarchy.settings.greeter.autoLogin or null; default = null;
defaultText = lib.literalExpression
"(settings.greeter.autoLogin from state.json) or null";
example = "ada"; example = "ada";
description = '' description = ''
Log this user straight into Hyprland on boot (greetd Log this user straight into Hyprland on boot (greetd
initial_session); logging out lands on the normal greeter. 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 '' plymouth.enable = lib.mkEnableOption ''
the Nomarchy Plymouth boot splash (logo + progress + LUKS prompt), 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; }; Recolors on system rebuilds theme switches don't touch the initrd'' // { default = true; };
stateFile = lib.mkOption { stateFile = lib.mkOption {
type = lib.types.nullOr lib.types.path; type = lib.types.nullOr lib.types.path;
default = null; default = null;
example = lib.literalExpression "./theme-state.json"; example = lib.literalExpression "./state.json";
description = '' 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 Plymouth splash background). lib.mkFlake wires it automatically
from your flake; null falls back to the Boreal base color. from your flake; null falls back to the Boreal base color.
''; '';
@@ -44,7 +51,16 @@
option'' // { default = true; }; option'' // { default = true; };
audio.enable = lib.mkEnableOption "the Pipewire audio stack" // { default = true; }; audio.enable = lib.mkEnableOption "the Pipewire audio stack" // { default = true; };
bluetooth.enable = lib.mkEnableOption "Bluetooth support with blueman" // { default = config.nomarchy.settings.bluetooth.enable or 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 '' autoTimezone.enable = lib.mkEnableOption ''
automatic timezone detection (geoclue + automatic-timezoned): the automatic timezone detection (geoclue + automatic-timezoned): the
@@ -102,7 +118,14 @@
batteryChargeLimit = lib.mkOption { batteryChargeLimit = lib.mkOption {
type = lib.types.nullOr (lib.types.ints.between 50 100); type = lib.types.nullOr (lib.types.ints.between 50 100);
default = config.nomarchy.settings.power.batteryChargeLimit or null; # 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 # Dell Adaptive charge mode ignores the end threshold unless we
# also select Custom (power.nix oneshot); see Latitude 5310 QA. # also select Custom (power.nix oneshot); see Latitude 5310 QA.
example = 80; example = 80;
@@ -120,6 +143,26 @@
Needs nomarchy.system.power.laptop. 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 # 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 # drives the desktop (nomarchy.system.stateFile, wired automatically by
# lib.mkFlake). One caveat by design: theme switches are Home # lib.mkFlake). One caveat by design: theme switches are Home
# Manager-only and never touch the initrd, so the splash follows the # Manager-only and never touch the initrd, so the splash follows the
# theme as of the last SYSTEM rebuild (`sys-update`), not the last # theme as of the last SYSTEM rebuild (`sys-update`), not the last
# `nomarchy-theme-sync apply`. # `nomarchy-state-sync apply`.
{ config, lib, pkgs, ... }: { config, lib, pkgs, ... }:
let let
@@ -12,7 +12,7 @@ let
state = state =
if cfg.stateFile != null if cfg.stateFile != null
then import ../theme-state-read.nix { inherit lib; } cfg.stateFile then import ../state-read.nix { inherit lib; } cfg.stateFile
else { }; else { };
colorOf = key: fallback: lib.removePrefix "#" ((state.colors or { }).${key} or fallback); colorOf = key: fallback: lib.removePrefix "#" ((state.colors or { }).${key} or fallback);
# Fallbacks match the distro default theme (Boreal) when stateFile is null. # Fallbacks match the distro default theme (Boreal) when stateFile is null.
@@ -24,8 +24,20 @@ let
text = colorOf "text" "#D3DAE0"; text = colorOf "text" "#D3DAE0";
surface = colorOf "surface" "#303A46"; surface = colorOf "surface" "#303A46";
accent = colorOf "accent" "#B79BE8"; 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"; 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 # Plymouth's Window.SetBackgroundTopColor takes three floats in
# 0.01.0; the .plymouth metadata's ConsoleLogBackgroundColor takes a # 0.01.0; the .plymouth metadata's ConsoleLogBackgroundColor takes a
# 0xRRGGBB hex. Nix has no float math: multiply, integer-divide, pad. # 0xRRGGBB hex. Nix has no float math: multiply, integer-divide, pad.
@@ -38,7 +50,9 @@ let
else if lib.stringLength s == 2 then "0${s}" else if lib.stringLength s == 2 then "0${s}"
else s; else s;
in "0.${padded}"; 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 { nomarchy-plymouth = pkgs.stdenv.mkDerivation {
pname = "nomarchy-plymouth"; pname = "nomarchy-plymouth";
@@ -53,6 +67,17 @@ let
mkdir -p "$themedir" mkdir -p "$themedir"
cp * "$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 # Recolor the splash art from the palette (flat fill, alpha kept) so
# it reads on any base instead of the shipped fixed navy. # it reads on any base instead of the shipped fixed navy.
recolor() { ${magick} "$themedir/$1" -fill "#$2" -colorize 100 "$themedir/$1"; } recolor() { ${magick} "$themedir/$1" -fill "#$2" -colorize 100 "$themedir/$1"; }
@@ -62,6 +87,7 @@ let
recolor entry.png ${surface} # password field box recolor entry.png ${surface} # password field box
recolor progress_box.png ${surface} # progress track recolor progress_box.png ${surface} # progress track
recolor progress_bar.png ${accent} # progress fill recolor progress_bar.png ${accent} # progress fill
recolor keyboard.png ${subtext} # passphrase keyboard-layout hint
# Point the .plymouth metadata into the store # Point the .plymouth metadata into the store
sed -i "s|/usr/share/plymouth/themes/nomarchy|$themedir|g" \ sed -i "s|/usr/share/plymouth/themes/nomarchy|$themedir|g" \
@@ -73,6 +99,10 @@ let
-e 's|@BG_R@|${channel 0}|g' \ -e 's|@BG_R@|${channel 0}|g' \
-e 's|@BG_G@|${channel 2}|g' \ -e 's|@BG_G@|${channel 2}|g' \
-e 's|@BG_B@|${channel 4}|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" "$themedir/nomarchy.script"
sed -i 's|@BG_HEX@|${base}|g' \ sed -i 's|@BG_HEX@|${base}|g' \
"$themedir/nomarchy.plymouth" "$themedir/nomarchy.plymouth"

View File

@@ -3,18 +3,125 @@
Window.SetBackgroundTopColor(@BG_R@, @BG_G@, @BG_B@); Window.SetBackgroundTopColor(@BG_R@, @BG_G@, @BG_B@);
Window.SetBackgroundBottomColor(@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.sprite = Sprite();
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.SetOpacity (1); 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 # Use these to adjust the progress bar timing
global.fake_progress_limit = 0.7; # Target percentage for fake progress (0.0 to 1.0) 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 () 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++; global.animation_frame++;
# Animate fake progress to limit over time with easing # Animate fake progress to limit over time with easing
@@ -91,12 +202,18 @@ fun show_password_dialog()
{ {
lock.sprite.SetOpacity(1); lock.sprite.SetOpacity(1);
entry.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() fun hide_password_dialog()
{ {
lock.sprite.SetOpacity(0); lock.sprite.SetOpacity(0);
entry.sprite.SetOpacity(0); entry.sprite.SetOpacity(0);
kbd.icon_sprite.SetOpacity(0);
kbd.text_sprite.SetOpacity(0);
for (index = 0; bullet.sprites[index]; index++) for (index = 0; bullet.sprites[index]; index++)
bullet.sprites[index].SetOpacity(0); bullet.sprites[index].SetOpacity(0);
} }
@@ -121,30 +238,7 @@ fun stop_fake_progress()
#----------------------------------------- Dialogue -------------------------------- #----------------------------------------- Dialogue --------------------------------
lock.image = Image("lock.png"); # Images and sprites are created at the top; every position lives in layout().
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
bullet.sprites = []; bullet.sprites = [];
fun display_normal_callback () fun display_normal_callback ()
@@ -206,21 +300,18 @@ Plymouth.SetDisplayPasswordFunction(display_password_callback);
progress_box.image = Image("progress_box.png"); progress_box.image = Image("progress_box.png");
progress_box.sprite = Sprite(progress_box.image); 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_box.sprite.SetOpacity(0);
progress_bar.original_image = Image("progress_bar.png"); progress_bar.original_image = Image("progress_bar.png");
progress_bar.sprite = Sprite(); progress_bar.sprite = Sprite();
progress_bar.image = progress_bar.original_image.Scale(1, progress_bar.original_image.GetHeight()); 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); 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) fun progress_callback (duration, progress)
{ {
global.real_progress = progress; global.real_progress = progress;

View File

@@ -1,16 +1,33 @@
# Active power management. One concern, one file: this is the system # Active power management. One concern, one file: this is the system
# side — the power daemon (power-profiles-daemon by default, or TLP), # side — the power daemon (power-profiles-daemon by default, or TLP),
# thermald, and the battery charge limit. The profile *switcher* and the # thermald, the battery charge limit, and suspend-then-hibernate (#115).
# Waybar *indicator* live home-side (rofi.nix / waybar.nix); they self- # The profile *switcher* and the Waybar *indicator* live home-side
# gate on powerprofilesctl being present, so there's no system→home wiring # (rofi.nix / waybar.nix); they self-gate on powerprofilesctl being
# to keep in sync (the same way the Waybar battery widget auto-hides on # present, so there's no system→home wiring to keep in sync (the same
# desktops). See the roadmap in README.md. # way the Waybar battery widget auto-hides on desktops). See the
# roadmap in README.md.
{ config, lib, pkgs, ... }: { config, lib, pkgs, ... }:
let let
cfg = config.nomarchy.system.power; cfg = config.nomarchy.system.power;
sysCfg = config.nomarchy.system;
ppd = cfg.backend == "ppd"; ppd = cfg.backend == "ppd";
tlp = cfg.backend == "tlp"; tlp = cfg.backend == "tlp";
# 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 in
{ {
config = lib.mkIf cfg.enable { config = lib.mkIf cfg.enable {
@@ -31,6 +48,38 @@ in
services.power-profiles-daemon.enable = lib.mkDefault ppd; services.power-profiles-daemon.enable = lib.mkDefault ppd;
services.tlp.enable = lib.mkDefault tlp; 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 # thermald is Intel-only, so off unless asked (the installer enables
# it on a GenuineIntel CPU). Sits happily next to either backend. # it on a GenuineIntel CPU). Sits happily next to either backend.
services.thermald.enable = lib.mkDefault cfg.thermal.enable; services.thermald.enable = lib.mkDefault cfg.thermal.enable;
@@ -44,22 +93,29 @@ in
# Instant menu path (user decision 2026-07-10): the threshold node is # Instant menu path (user decision 2026-07-10): the threshold node is
# group-writable for `users` so nomarchy-menu can echo live without # group-writable for `users` so nomarchy-menu can echo live without
# rebuild; this oneshot still owns boot + AC-replug re-apply. Prefer # rebuild; this oneshot still owns boot + AC-replug re-apply. Prefer
# live theme-state.json under /home/*/.nomarchy so a menu change # live state.json under /home/*/.nomarchy so a menu change
# survives reboot before the next sys-rebuild bakes the Nix option. # survives reboot before the next sys-rebuild bakes the Nix option.
systemd.services.nomarchy-battery-charge-limit = lib.mkIf cfg.laptop { systemd.services.nomarchy-battery-charge-limit = lib.mkIf cfg.laptop {
description = "Apply battery charge end threshold from state or config"; description = "Apply battery charge end threshold from state or config";
wantedBy = [ "multi-user.target" ]; wantedBy = [ "multi-user.target" ];
path = [ pkgs.jq pkgs.coreutils ]; 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 = { serviceConfig = {
Type = "oneshot"; Type = "oneshot";
RemainAfterExit = true;
}; };
script = '' script = ''
set -euo pipefail set -euo pipefail
# Baked generation default (empty = full charge / no cap). # Baked generation default (empty = full charge / no cap).
limit=${if cfg.batteryChargeLimit != null then toString cfg.batteryChargeLimit else ""} limit=${if cfg.batteryChargeLimit != null then toString cfg.batteryChargeLimit else ""}
# Prefer the newest live state write (menu path, no rebuild yet). # Prefer the newest live state write (menu path, no rebuild yet).
for st in /home/*/.nomarchy/theme-state.json; do for st in /home/*/.nomarchy/state.json; do
[ -r "$st" ] || continue [ -r "$st" ] || continue
v=$(jq -r '.settings.power.batteryChargeLimit // empty' "$st" 2>/dev/null || true) v=$(jq -r '.settings.power.batteryChargeLimit // empty' "$st" 2>/dev/null || true)
case "$v" in case "$v" in
@@ -139,10 +195,14 @@ in
# Writable threshold for the logged-in user (menu live apply) + AC # 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. # re-apply of the oneshot. Laptop only; no-op when the attr is absent.
# Immediate + delayed restart: Dell/Latitude firmware often stomps the # Immediate + delayed start: Dell/Latitude firmware often stomps the
# threshold to 100 right after unplug; a single immediate oneshot # threshold to 100 right after unplug; a single immediate oneshot
# loses the race (hardware: unplug→100, plug→80). systemd-run hands # loses the race (hardware: unplug→100, plug→80). systemd-run hands
# the delayed restart off so udev's short RUN budget is not held. # 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 ( services.udev.extraRules = lib.mkIf cfg.laptop (
let let
systemctl = "${config.systemd.package}/bin/systemctl"; systemctl = "${config.systemd.package}/bin/systemctl";
@@ -152,8 +212,8 @@ in
SUBSYSTEM=="power_supply", ATTR{type}=="Battery", TEST=="charge_types", 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_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}=="Battery", TEST=="charge_control_start_threshold", GROUP="users", MODE="0664"
SUBSYSTEM=="power_supply", ATTR{type}=="Mains", RUN+="${systemctl} --no-block restart nomarchy-battery-charge-limit.service" SUBSYSTEM=="power_supply", ATTR{type}=="Mains", RUN+="${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 restart nomarchy-battery-charge-limit.service" SUBSYSTEM=="power_supply", ATTR{type}=="Mains", RUN+="${systemdRun} --no-block --collect --on-active=2s --timer-property=AccuracySec=200ms ${systemctl} --no-block start nomarchy-battery-charge-limit.service"
'' ''
); );

View File

@@ -7,6 +7,19 @@
let let
cfg = config.nomarchy.services; 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 in
{ {
options.nomarchy.services = { options.nomarchy.services = {
@@ -74,10 +87,18 @@ in
with the lmstudio/alpaca GUIs). CPU by default set with the lmstudio/alpaca GUIs). CPU by default set
`services.ollama.acceleration` natively for GPU offload''; `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 '' printing.enable = lib.mkEnableOption ''
CUPS printing with Avahi/mDNS, so network printers are auto-discovered CUPS printing with Avahi/mDNS, so network printers are auto-discovered
(add vendor drivers via `services.printing.drivers`); the menu's (add vendor drivers via `services.printing.drivers`); the menu's
System Printers entry opens the system-config-printer GUI'' // { default = config.nomarchy.settings.printing.enable or false; }; 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 '' openrgb.enable = lib.mkEnableOption ''
the OpenRGB daemon and GUI for controlling RGB lighting on peripherals the OpenRGB daemon and GUI for controlling RGB lighting on peripherals
@@ -124,6 +145,14 @@ in
}; };
config = lib.mkMerge [ 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 { (lib.mkIf cfg.tailscale.enable {
services.tailscale.enable = true; services.tailscale.enable = true;
# Let the login user drive tailscale (up/down/set — and so the VPN menu's # Let the login user drive tailscale (up/down/set — and so the VPN menu's

View File

@@ -4,7 +4,7 @@
# /etc/localtime at runtime. # /etc/localtime at runtime.
# #
# In-flake state, menu-driven (the keyboard/night-light philosophy): the on/off # In-flake state, menu-driven (the keyboard/night-light philosophy): the on/off
# flag lives in the same theme-state.json under `settings.autoTimezone` # flag lives in the same state.json under `settings.autoTimezone`
# (git-tracked, reproducible), written by the System-menu toggle # (git-tracked, reproducible), written by the System-menu toggle
# (nomarchy-autotimezone). Because this is a SYSTEM service — not a user unit it # (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 # can start/stop instantly like night-light — the toggle drives a system rebuild
@@ -16,14 +16,14 @@ let
# Read the same state file the rest of the system side uses (Plymouth too), # 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; # wired by lib.mkFlake. The flag defaults off when stateFile is null;
# a set-but-missing/invalid path fails closed via theme-state-read.nix. # a set-but-missing/invalid path fails closed via state-read.nix.
state = state =
if cfg.stateFile != null if cfg.stateFile != null
then import ../theme-state-read.nix { inherit lib; } cfg.stateFile then import ../state-read.nix { inherit lib; } cfg.stateFile
else { }; else { };
stateEnabled = (state.settings or { }).autoTimezone or false; stateEnabled = (state.settings or { }).autoTimezone or false;
sync = lib.getExe pkgs.nomarchy-theme-sync; sync = lib.getExe pkgs.nomarchy-state-sync;
# Menu/CLI toggle. Runs as the normal user (it owns the flake checkout + # 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 # writes the state); sudos only the system switch, like sys-update. Writes

View File

@@ -1,4 +1,4 @@
# Pure theme-state.json loader — fail closed with a short, actionable # Pure state.json loader — fail closed with a short, actionable
# message instead of a raw `readFile` / `fromJSON` stack buried in a # message instead of a raw `readFile` / `fromJSON` stack buried in a
# consumer. Used by modules/home/theme.nix (required path), the NixOS # consumer. Used by modules/home/theme.nix (required path), the NixOS
# stateFile consumers, and lib.mkFlake (early gate). # stateFile consumers, and lib.mkFlake (early gate).
@@ -6,43 +6,53 @@
# Field-level schema checks stay in theme.nix (post-defaults). This file # Field-level schema checks stay in theme.nix (post-defaults). This file
# only gates *existence* and *JSON-shape* so the first failure the user # only gates *existence* and *JSON-shape* so the first failure the user
# sees points at the state file, not at nightlight.nix. # 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 }: { lib }:
path: path:
let let
pathStr = toString path; pathStr = toString path;
baseName = baseNameOf pathStr;
tip = '' tip = ''
Fix: Fix:
Missing file copy the template next to your flake.nix: Missing file copy the template next to your flake.nix:
templates/downstream/theme-state.json templates/downstream/state.json
or regenerate from a preset: or regenerate from a preset:
nomarchy-theme-sync apply boreal nomarchy-state-sync apply boreal
Bad syntax / wrong shape edit theme-state.json (trailing commas Bad syntax / wrong shape edit ${baseName} (trailing commas
are the usual culprit) or reset with `apply` as above. are the usual culprit) or reset with `apply` as above.
Field-level schema (colors, ui, border, ): Field-level schema (colors, ui, border, ):
nomarchy-theme-sync validate nomarchy-state-sync validate
Eval-time field checks live in modules/home/theme.nix.''; 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 = '' missingMsg = ''
Nomarchy: theme-state.json is missing: Nomarchy: state file is missing:
${pathStr} ${pathStr}
This file is required (appearance + menu settings). Add it to your This file is required (appearance + menu settings). Add state.json to
flake checkout so evaluation stays pure. your flake checkout git-tracked so evaluation stays pure.
${tip}''; ${tip}'';
emptyMsg = '' emptyMsg = ''
Nomarchy: theme-state.json is empty: Nomarchy: state file is empty:
${pathStr} ${pathStr}
${tip}''; ${tip}'';
notObjectMsg = '' notObjectMsg = ''
Nomarchy: theme-state.json must be a JSON object `{ ... }`: Nomarchy: state file must be a JSON object `{ ... }`:
${pathStr} ${pathStr}
${tip}''; ${tip}'';

View File

@@ -0,0 +1,127 @@
{ 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
]}
# -x, both comm names: nixpkgs wraps the binary as `.waybar-wrapped`,
# and an unanchored `waybar` also matches (and kills) the
# nomarchy-waybar supervisor, whose bash dies on an unhandled RTMIN.
poke_bar() {
pkill -RTMIN+11 -x 'waybar|\.waybar-wrapped' 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

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

View File

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

View File

@@ -60,28 +60,40 @@ for mp in / /boot /nix; do
avail=$(numfmt --to=iec $((availkb * 1024))) avail=$(numfmt --to=iec $((availkb * 1024)))
if [ "$use" -ge 90 ]; then if [ "$use" -ge 90 ]; then
bad "$mp is ${use}% full (${avail} free)" \ bad "$mp is ${use}% full (${avail} free)" \
"sudo nix-collect-garbage --delete-older-than 14d (then sys-rebuild to prune old boot entries)" "sudo nomarchy-gen-prune (14d + keep ≥3 past system/HM gens; then reclaims store)"
else else
ok "$mp has space (${use}% used, ${avail} free)" ok "$mp has space (${use}% used, ${avail} free)"
fi fi
done done
# ── the flake + state file ─────────────────────────────────────────── # ── the flake + state file ───────────────────────────────────────────
if [ -f "$flake/theme-state.json" ]; then # #107: prefer state.json; theme-state.json still counts until migrated.
if jq empty "$flake/theme-state.json" 2>/dev/null; then state_file=
ok "theme-state.json parses" 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 else
bad "theme-state.json is not valid JSON (rebuilds will fail)" \ bad "$state_base is not valid JSON (rebuilds will fail)" \
"nomarchy-theme-sync validate (names the spot; fix it, or re-apply a theme)" "nomarchy-state-sync validate (names the spot; fix it, or re-apply a theme)"
fi fi
if git -C "$flake" ls-files --error-unmatch theme-state.json >/dev/null 2>&1; then if git -C "$flake" ls-files --error-unmatch "$state_base" >/dev/null 2>&1; then
ok "theme-state.json is git-tracked" ok "$state_base is git-tracked"
else else
bad "theme-state.json is NOT git-tracked (flake evaluation can't see it)" \ bad "$state_base is NOT git-tracked (flake evaluation can't see it)" \
"git -C $flake add theme-state.json" "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 fi
else else
skip "theme-state.json (no flake checkout at $flake)" skip "state.json (no flake checkout at $flake)"
fi fi
if [ -d "$flake/.git" ]; then if [ -d "$flake/.git" ]; then
@@ -98,6 +110,19 @@ if [ -d "$flake/.git" ]; then
fi fi
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 ─────────────────────────────────────────────────── # ── generation age ───────────────────────────────────────────────────
# The profile SYMLINK's own mtime is the generation's creation time # The profile SYMLINK's own mtime is the generation's creation time
# (store paths themselves are all epoch-1). # (store paths themselves are all epoch-1).
@@ -115,6 +140,21 @@ else
skip "system generation age (no system profile)" skip "system generation age (no system profile)"
fi 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) ────────────────────────────────── # ── snapper timeline (when enabled) ──────────────────────────────────
if [ -n "$(systemctl list-unit-files snapper-timeline.timer --no-legend --plain 2>/dev/null)" ]; then 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 if systemctl is-active --quiet snapper-timeline.timer; then
@@ -127,6 +167,33 @@ else
skip "snapper (not enabled on this machine)" skip "snapper (not enabled on this machine)"
fi 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 ═════════════════════════════════════════════════════════ # ══ hardware ═════════════════════════════════════════════════════════
# Every check below self-gates: it skips cleanly when the tool, service, # 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 # or device isn't present, so the section shrinks to fit the machine and
@@ -263,6 +330,61 @@ if [ "$bat_seen" -eq 1 ] && [ "$bat_limited" -eq 0 ]; then
skip "battery charge limit (none set — charges to 100%)" skip "battery charge limit (none set — charges to 100%)"
fi 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) ────────────────────────────────── # ── hibernate / sleep (BACKLOG #76) ──────────────────────────────────
# Read-only: is there a working hibernate path? zram is RAM-only and can't # 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) # hold a resume image, so hibernation needs a disk swap (partition or file)

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

@@ -15,8 +15,9 @@
, pciutils # lspci , pciutils # lspci
, usbutils # lsusb (fingerprint-reader VID probe; sysfs is the fallback) , usbutils # lsusb (fingerprint-reader VID probe; sysfs is the fallback)
, btrfs-progs # inspect-internal map-swapfile (hibernation offset) , btrfs-progs # inspect-internal map-swapfile (hibernation offset)
, xkeyboard_config # human-readable installed layout/variant catalog
# Baked metadata — what this installer installs and where it came from. # Baked metadata — what this installer installs and where it came from.
, templateDir # templates/downstream (home.nix, theme-state.json) , templateDir # templates/downstream (home.nix, state.json)
, nomarchyLock # the distro's flake.lock (for offline lock composition) , nomarchyLock # the distro's flake.lock (for offline lock composition)
, hardwareModuleNames # newline-separated nixos-hardware module names , hardwareModuleNames # newline-separated nixos-hardware module names
, nixpkgsPath # pinned nixpkgs source (NIX_PATH for disko's eval) , nixpkgsPath # pinned nixpkgs source (NIX_PATH for disko's eval)
@@ -53,7 +54,7 @@ stdenvNoCC.mkDerivation {
mkdir -p "$share/template" mkdir -p "$share/template"
# Full downstream template is the SoT; install script copies + patches. # Full downstream template is the SoT; install script copies + patches.
cp ${templateDir}/flake.nix ${templateDir}/system.nix \ cp ${templateDir}/flake.nix ${templateDir}/system.nix \
${templateDir}/home.nix ${templateDir}/theme-state.json \ ${templateDir}/home.nix ${templateDir}/state.json \
"$share/template/" "$share/template/"
install -Dm644 patch-template.py "$share/patch-template.py" install -Dm644 patch-template.py "$share/patch-template.py"
@@ -65,6 +66,7 @@ stdenvNoCC.mkDerivation {
util-linux gptfdisk parted cryptsetup lvm2 pciutils usbutils btrfs-progs util-linux gptfdisk parted cryptsetup lvm2 pciutils usbutils btrfs-progs
]} \ ]} \
--set NOMARCHY_INSTALL_SHARE "$share" \ --set NOMARCHY_INSTALL_SHARE "$share" \
--set NOMARCHY_XKB_RULES ${xkeyboard_config}/share/X11/xkb/rules/base.lst \
--set NOMARCHY_NIXPKGS ${nixpkgsPath} \ --set NOMARCHY_NIXPKGS ${nixpkgsPath} \
--set NOMARCHY_FLAKE_URL ${lib.escapeShellArg flakeUrl} \ --set NOMARCHY_FLAKE_URL ${lib.escapeShellArg flakeUrl} \
--set NOMARCHY_REV ${lib.escapeShellArg (toString rev)} \ --set NOMARCHY_REV ${lib.escapeShellArg (toString rev)} \

View File

@@ -26,6 +26,121 @@
set -euo pipefail 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: # Baked in by the package wrapper:
# NOMARCHY_INSTALL_SHARE — disko-config.nix, hardware-db.sh, # NOMARCHY_INSTALL_SHARE — disko-config.nix, hardware-db.sh,
# compose-lock.py, flake.lock, template/, # compose-lock.py, flake.lock, template/,
@@ -109,13 +224,43 @@ live_disk=""
live_src=$(findmnt -no SOURCE /iso 2>/dev/null || true) live_src=$(findmnt -no SOURCE /iso 2>/dev/null || true)
[[ -n "$live_src" ]] && live_disk=$(lsblk -no PKNAME "$live_src" 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 \ # Installable whole disks only (#112): drop floppy/pseudo/tiny devices that
| awk -v skip="/dev/${live_disk:-NONE}" \ # OVMF and some firmwares expose as TYPE=disk (e.g. /dev/fd0 first in the
'$NF == "disk" && $1 != skip && $1 !~ /loop|zram|sr[0-9]/ {NF--; print}') # list — a blind Enter would erase nothing useful and break the install).
[[ ${#disks[@]} -gt 0 ]] || fail "No installable disks found." # 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 if [[ "$UNATTENDED" == "1" ]]; then
TARGET_DISK="${NOMARCHY_DISK:?NOMARCHY_DISK required in unattended mode}" 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 else
choice=$(printf '%s\n' "${disks[@]}" \ choice=$(printf '%s\n' "${disks[@]}" \
| gum choose --header "Install Nomarchy on which disk? (EVERYTHING on it will be erased)") | gum choose --header "Install Nomarchy on which disk? (EVERYTHING on it will be erased)")
@@ -164,15 +309,20 @@ info "Encryption: $([[ $WITH_LUKS == true ]] && echo "LUKS2 (desktop auto-login)
# ─── Swap / hibernation ───────────────────────────────────────────────── # ─── Swap / hibernation ─────────────────────────────────────────────────
# A swapfile ≥ RAM on its own BTRFS subvolume makes hibernation possible; # A swapfile ≥ RAM on its own BTRFS subvolume makes hibernation possible;
# the resume offset is wired into the config below. # the resume offset is wired into the config below.
section "Swap & hibernation"
ram_gb=$(awk '/MemTotal/ {print int(($2 + 1048575) / 1048576)}' /proc/meminfo) ram_gb=$(awk '/MemTotal/ {print int(($2 + 1048575) / 1048576)}' /proc/meminfo)
if [[ "$UNATTENDED" == "1" ]]; then if [[ "$UNATTENDED" == "1" ]]; then
SWAP_GB="${NOMARCHY_SWAP_GB:-$ram_gb}" SWAP_GB="${NOMARCHY_SWAP_GB:-$ram_gb}"
else 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" \ 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)") --placeholder "swap size in GiB (≥ RAM enables hibernation, 0 = none)")
fi fi
[[ "$SWAP_GB" =~ ^[0-9]+$ ]] || fail "Swap size must be a whole number of GiB." [[ "$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 ─────────────────────────────────────────────────────── # ─── User account ───────────────────────────────────────────────────────
section "Your account" section "Your account"
@@ -214,17 +364,51 @@ else
| sed 's/$/.UTF-8/' \ | sed 's/$/.UTF-8/' \
| gum filter --placeholder "language / locale (type to search)" \ | gum filter --placeholder "language / locale (type to search)" \
|| echo en_US.UTF-8) || echo en_US.UTF-8)
KB_LAYOUT=$(localectl list-x11-keymap-layouts 2>/dev/null \ keyboard_layouts=$(keyboard_layout_catalog) || \
| gum filter --placeholder "keyboard layout (type to search)" \ fail "Could not read the installed keyboard-layout catalog."
|| echo us) [[ -n "$keyboard_layouts" ]] || \
KB_VARIANT="" fail "The installed keyboard-layout catalog is empty."
if [[ "$KB_LAYOUT" != "us" ]] || gum confirm --default=No "Pick a keyboard variant (intl, nodeadkeys, …)?"; then if gum confirm --default=yes "Use the standard US English keyboard (no special variant)?"; then
KB_VARIANT=$( { echo "(none)"; localectl list-x11-keymap-variants "$KB_LAYOUT" 2>/dev/null; } \ KB_LAYOUT="us"
| gum filter --placeholder "variant for $KB_LAYOUT (pick '(none)' for the default)" \ KB_VARIANT=""
|| echo "(none)") else
[[ "$KB_VARIANT" == "(none)" ]] && KB_VARIANT="" 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
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'." [[ "$USERNAME" =~ ^[a-z_][a-z0-9_-]*$ ]] || fail "Invalid username '$USERNAME'."
[[ -f "/usr/share/zoneinfo/$TIMEZONE" || -e "/etc/zoneinfo/$TIMEZONE" ]] \ [[ -f "/usr/share/zoneinfo/$TIMEZONE" || -e "/etc/zoneinfo/$TIMEZONE" ]] \
|| timedatectl list-timezones 2>/dev/null | grep -qx "$TIMEZONE" \ || timedatectl list-timezones 2>/dev/null | grep -qx "$TIMEZONE" \
@@ -232,7 +416,13 @@ fi
HASHED_PASSWORD=$(printf '%s' "$PASSWORD" | mkpasswd -m sha-512 -s) HASHED_PASSWORD=$(printf '%s' "$PASSWORD" | mkpasswd -m sha-512 -s)
unset PASSWORD unset PASSWORD
info "User: $USERNAME @ $HOSTNAME_ ($TIMEZONE)" 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 ─────────────────────────────────────────────────── # ─── Hardware profile ───────────────────────────────────────────────────
section "Hardware detection" section "Hardware detection"
@@ -287,10 +477,11 @@ fi
gum style --border normal --padding "0 2" \ gum style --border normal --padding "0 2" \
"Disk: $TARGET_DISK (WILL BE ERASED)" \ "Disk: $TARGET_DISK (WILL BE ERASED)" \
"Encryption: $([[ $WITH_LUKS == true ]] && echo "LUKS2 + desktop auto-login" || echo none)" \ "Encryption: $([[ $WITH_LUKS == true ]] && echo "LUKS2 + desktop auto-login" || echo none)" \
"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" \ "User: $USERNAME" \
"Hostname: $HOSTNAME_" \ "Hostname: $HOSTNAME_" \
"Timezone: $TIMEZONE" \ "Timezone: $TIMEZONE" \
"Keyboard: $KEYBOARD_SUMMARY" \
"Hardware: ${HW_PROFILES[*]:-none}" \ "Hardware: ${HW_PROFILES[*]:-none}" \
"Snapshots: snapper timeline on /" \ "Snapshots: snapper timeline on /" \
"Source: nomarchy ${NOMARCHY_REV:0:12}${NOMARCHY_REV:+ }$([[ -z "${NOMARCHY_REV:-}" ]] && echo "(dirty tree) ")$SOURCE_NET" "Source: nomarchy ${NOMARCHY_REV:0:12}${NOMARCHY_REV:+ }$([[ -z "${NOMARCHY_REV:-}" ]] && echo "(dirty tree) ")$SOURCE_NET"
@@ -394,7 +585,7 @@ rm -rf /mnt/etc/nixos
cp "$SHARE/template/flake.nix" \ cp "$SHARE/template/flake.nix" \
"$SHARE/template/system.nix" \ "$SHARE/template/system.nix" \
"$SHARE/template/home.nix" \ "$SHARE/template/home.nix" \
"$SHARE/template/theme-state.json" \ "$SHARE/template/state.json" \
"$FLAKE_DIR/" "$FLAKE_DIR/"
# Detected hardware → flags for the patcher (safe defaults active). # Detected hardware → flags for the patcher (safe defaults active).
@@ -460,7 +651,8 @@ python3 "$SHARE/patch-template.py" "$FLAKE_DIR" <<PYJSON
"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) "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, "resumeOffset": $resume_json,
"rootUuid": $root_uuid_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")))')
} }
PYJSON PYJSON
@@ -487,7 +679,7 @@ if (( compose_ok != 0 )); then
(cd "$FLAKE_DIR" && nix --extra-experimental-features "nix-command flakes" flake lock) (cd "$FLAKE_DIR" && nix --extra-experimental-features "nix-command flakes" flake lock)
fi fi
# A flake worktree must be git-tracked (theme-state.json especially). # A flake worktree must be git-tracked (state.json especially).
( (
cd "$FLAKE_DIR" cd "$FLAKE_DIR"
git init -q git init -q
@@ -510,7 +702,7 @@ success "Configuration written to ~$USERNAME/.nomarchy"
section "Installing (this takes a while)" section "Installing (this takes a while)"
# Seed the target store with the flake source + all inputs so the first # 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 # before the machine has ever seen a network. Two steps because
# `flake archive --to` enforces signatures and locally-evaluated source # `flake archive --to` enforces signatures and locally-evaluated source
# paths have none; plain `nix copy` accepts --no-check-sigs. # paths have none; plain `nix copy` accepts --no-check-sigs.
@@ -610,10 +802,28 @@ nix-daemon &
daemon_pid=\$! daemon_pid=\$!
trap 'kill \$daemon_pid 2>/dev/null || true' EXIT trap 'kill \$daemon_pid 2>/dev/null || true' EXIT
sleep 2 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 # BACKUP_EXT: collisions can't abort the activation (a stray
# autogenerated config gets moved aside instead). # autogenerated config gets moved aside instead).
runuser -u "$USERNAME" -- bash -lc \ if command -v dbus-run-session >/dev/null 2>&1; then
"USER=$USERNAME HOME=/home/$USERNAME NIX_REMOTE=daemon HOME_MANAGER_BACKUP_EXT=bak \$out/activate" 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 EOF
# NOMARCHY_TEST_FORCE_HM_FAIL=1 — unattended harness only (#54 V2): take # NOMARCHY_TEST_FORCE_HM_FAIL=1 — unattended harness only (#54 V2): take
# the failure arm so the durable recovery hint is exercised without a # the failure arm so the durable recovery hint is exercised without a

View File

@@ -32,6 +32,12 @@ def nix_str(s: str) -> str:
) )
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: def replace_once(text: str, old: str, new: str, label: str) -> str:
if old not in text: if old not in text:
sys.exit(f"patch-template: missing placeholder for {label}: {old!r}") sys.exit(f"patch-template: missing placeholder for {label}: {old!r}")
@@ -51,6 +57,17 @@ def patch_flake(text: str, v: dict) -> str:
f'username = "{nix_str(v["username"])}"; # <- your login name', f'username = "{nix_str(v["username"])}"; # <- your login name',
"flake username", "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 [] profiles = v.get("hardwareProfiles") or []
if profiles: if profiles:
items = " ".join(f'"{nix_str(p)}"' for p in profiles) items = " ".join(f'"{nix_str(p)}"' for p in profiles)
@@ -75,7 +92,7 @@ def patch_flake(text: str, v: dict) -> str:
def patch_home(text: str, v: dict) -> str: def patch_home(text: str, v: dict) -> str:
layout = nix_str(v["keyboardLayout"]) layout = nix_str(v["keyboardLayout"])
variant = nix_str(v.get("keyboardVariant") or "") variant = nix_str(keyboard_variant(v))
text = replace_once( text = replace_once(
text, text,
' nomarchy.keyboard.layout = "us";', ' nomarchy.keyboard.layout = "us";',
@@ -98,12 +115,10 @@ def build_installer_region(v: dict) -> str:
" # active; heavier opt-ins stay in the commented catalog below.", " # active; heavier opt-ins stay in the commented catalog below.",
] ]
if v.get("autoLogin"): # Auto-login is deliberately NOT emitted here — it is seeded into
user = nix_str(v["username"]) # state.json instead (patch_state). A line in system.nix outranks
lines += [ # the state default, which would make the System Auto-login toggle
" # LUKS passphrase already gates this machine — skip the greeter password.", # write JSON that nothing reads.
f' nomarchy.system.greeter.autoLogin = "{user}";',
]
if v.get("laptop"): if v.get("laptop"):
lines += [ lines += [
@@ -213,7 +228,7 @@ def patch_system(text: str, v: dict) -> str:
text = replace_once( text = replace_once(
text, text,
' services.xserver.xkb.variant = "";', ' services.xserver.xkb.variant = "";',
f' services.xserver.xkb.variant = "{nix_str(v.get("keyboardVariant") or "")}";', f' services.xserver.xkb.variant = "{nix_str(keyboard_variant(v))}";',
"xkb variant", "xkb variant",
) )
@@ -244,6 +259,23 @@ def patch_system(text: str, v: dict) -> str:
return text 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: def main() -> None:
if len(sys.argv) != 2: if len(sys.argv) != 2:
sys.exit("usage: patch-template.py <flake-dir>") sys.exit("usage: patch-template.py <flake-dir>")
@@ -254,6 +286,7 @@ def main() -> None:
"flake.nix": patch_flake, "flake.nix": patch_flake,
"home.nix": patch_home, "home.nix": patch_home,
"system.nix": patch_system, "system.nix": patch_system,
"state.json": patch_state,
} }
for name, fn in mapping.items(): for name, fn in mapping.items():
path = flake_dir / name path = flake_dir / name

View File

@@ -2,7 +2,7 @@
# Installed on both NixOS (systemPackages) and Home Manager so a home # Installed on both NixOS (systemPackages) and Home Manager so a home
# switch can refresh them without waiting for a full system rebuild — # switch can refresh them without waiting for a full system rebuild —
# otherwise a broken nomarchy-pull can never update itself. # otherwise a broken nomarchy-pull can never update itself.
{ lib, writeShellScriptBin, nvd, jq, symlinkJoin }: { lib, writeShellScriptBin, nvd, jq, symlinkJoin, nomarchy-what-changed }:
let let
# Shared preamble: refuse root, resolve flake path. # Shared preamble: refuse root, resolve flake path.
@@ -15,6 +15,45 @@ let
flake="''${NOMARCHY_PATH:-$HOME/.nomarchy}" 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" '' nomarchy-pull = writeShellScriptBin "nomarchy-pull" ''
${preamble "nomarchy-pull"} ${preamble "nomarchy-pull"}
if [ ! -e "$flake/flake.nix" ]; then if [ ! -e "$flake/flake.nix" ]; then
@@ -22,6 +61,9 @@ let
echo " Set NOMARCHY_PATH or put your machine flake in ~/.nomarchy." >&2 echo " Set NOMARCHY_PATH or put your machine flake in ~/.nomarchy." >&2
exit 1 exit 1
fi 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 # Optional: pull *your* machine config only if this checkout tracks a
# remote. Distro updates come from the nomarchy flake *input* below # remote. Distro updates come from the nomarchy flake *input* below
# many installs have no upstream on ~/.nomarchy (local auto-commits). # many installs have no upstream on ~/.nomarchy (local auto-commits).
@@ -52,6 +94,9 @@ let
echo "nomarchy-rebuild: no flake.nix at $flake" >&2 echo "nomarchy-rebuild: no flake.nix at $flake" >&2
exit 1 exit 1
fi 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) before=$(readlink -f /run/current-system)
log=$(mktemp) log=$(mktemp)
trap 'rm -f "$log"' EXIT trap 'rm -f "$log"' EXIT
@@ -78,6 +123,13 @@ let
else else
echo "nomarchy-rebuild: what changed:" echo "nomarchy-rebuild: what changed:"
${nvd}/bin/nvd diff "$before" "$after" || true ${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 fi
''; '';
@@ -87,6 +139,19 @@ let
echo "nomarchy-home: no flake.nix at $flake" >&2 echo "nomarchy-home: no flake.nix at $flake" >&2
exit 1 exit 1
fi 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) log=$(mktemp)
trap 'rm -f "$log"' EXIT trap 'rm -f "$log"' EXIT
set +e set +e
@@ -102,7 +167,27 @@ let
echo "Recovery: home-manager generations (or docs/RECOVERY.md)" echo "Recovery: home-manager generations (or docs/RECOVERY.md)"
exit "$rc" exit "$rc"
fi fi
echo "nomarchy-home: desktop applied." 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 # Legacy aliases
@@ -125,7 +210,9 @@ symlinkJoin {
sys-update sys-update
sys-rebuild sys-rebuild
home-update home-update
nomarchy-what-changed
]; ];
passthru.autocommit = nomarchy-autocommit;
meta = { meta = {
description = "Nomarchy machine-flake lifecycle: pull, rebuild, home"; description = "Nomarchy machine-flake lifecycle: pull, rebuild, home";
mainProgram = "nomarchy-pull"; 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.2";
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;
};
}

View File

@@ -1,12 +1,14 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
"""nomarchy-theme-sync — state writer for Nomarchy's declarative theming. """nomarchy-state-sync — state writer for Nomarchy's declarative theming.
Single source of truth: $NOMARCHY_PATH/theme-state.json (inside the flake, Single source of truth: $NOMARCHY_PATH/state.json (inside the flake,
read purely by Home Manager via the nomarchy.stateFile option). read purely by Home Manager via the nomarchy.stateFile option).
Legacy name `theme-state.json` is still read (and migrated on write).
The old binary name `nomarchy-theme-sync` is a symlink to this tool.
Theme changes are applied by Home Manager: this tool writes the new state Theme changes are applied by Home Manager: this tool writes the new state
and runs `home-manager switch` (override with $NOMARCHY_REBUILD, or skip and runs `home-manager switch` (override with $NOMARCHY_REBUILD, or skip
with --no-switch). All app theming Hyprland, Waybar, Ghostty, btop, with --no-switch). All app theming Hyprland, Waybar, Kitty, btop,
Stylix is baked into the generation; nothing is patched at runtime. Stylix is baked into the generation; nothing is patched at runtime.
The one runtime exception is the wallpaper: swww is imperative by nature, The one runtime exception is the wallpaper: swww is imperative by nature,
@@ -39,7 +41,24 @@ from pathlib import Path
# ─── Paths ──────────────────────────────────────────────────────────────── # ─── Paths ────────────────────────────────────────────────────────────────
FLAKE_DIR = Path(os.environ.get("NOMARCHY_PATH", Path.home() / ".nomarchy")).expanduser() FLAKE_DIR = Path(os.environ.get("NOMARCHY_PATH", Path.home() / ".nomarchy")).expanduser()
STATE_FILE = FLAKE_DIR / "theme-state.json" # Canonical name after #107. Legacy theme-state.json (and a brief theme.json
# misnomer some notes used) are still accepted for existing checkouts.
STATE_NAME = "state.json"
LEGACY_STATE_NAMES = ("theme-state.json", "theme.json")
def resolve_state_file(flake_dir: Path = FLAKE_DIR) -> Path:
preferred = flake_dir / STATE_NAME
if preferred.exists():
return preferred
for name in LEGACY_STATE_NAMES:
legacy = flake_dir / name
if legacy.exists():
return legacy
return preferred
STATE_FILE = resolve_state_file()
# Preset search path: the user's flake first (their custom themes win), # Preset search path: the user's flake first (their custom themes win),
# then the presets baked into this package by the Nix build. # then the presets baked into this package by the Nix build.
@@ -51,21 +70,24 @@ WALLPAPER_EXTS = {".png", ".jpg", ".jpeg", ".webp"}
QUIET = False QUIET = False
# argv[0] may still be the compatibility name nomarchy-theme-sync.
_TOOL = Path(sys.argv[0]).name or "nomarchy-state-sync"
def log(msg: str) -> None: def log(msg: str) -> None:
if not QUIET: if not QUIET:
print(f"nomarchy-theme-sync: {msg}") print(f"{_TOOL}: {msg}")
def die(msg: str) -> "None": def die(msg: str) -> "None":
print(f"nomarchy-theme-sync: error: {msg}", file=sys.stderr) print(f"{_TOOL}: error: {msg}", file=sys.stderr)
sys.exit(1) sys.exit(1)
# All Nomarchy theme notifications share this synchronous tag, so the # All Nomarchy theme notifications share this synchronous tag, so the
# notification daemon (swaync) REPLACES the previous one in place instead # notification daemon (swaync) REPLACES the previous one in place instead
# of stacking — the "rebuilding…" toast becomes the "applied ✓" toast. # of stacking — the "rebuilding…" toast becomes the "applied ✓" toast.
NOTIFY_SYNC_TAG = "nomarchy-theme-sync" NOTIFY_SYNC_TAG = "nomarchy-state-sync"
def notify(body: str, summary: str = "Nomarchy", *, def notify(body: str, summary: str = "Nomarchy", *,
@@ -87,13 +109,14 @@ def notify(body: str, summary: str = "Nomarchy", *,
# ─── State management ───────────────────────────────────────────────────── # ─── State management ─────────────────────────────────────────────────────
def load_state(path: Path = STATE_FILE) -> dict: def load_state(path: Path | None = None) -> dict:
path = path or resolve_state_file()
try: try:
return json.loads(path.read_text()) return json.loads(path.read_text())
except FileNotFoundError: except FileNotFoundError:
die(f"state file not found: {path} (set $NOMARCHY_PATH to your flake checkout)") die(f"state file not found: {path} (set $NOMARCHY_PATH to your flake checkout)")
except json.JSONDecodeError as e: except json.JSONDecodeError as e:
die(f"theme-state.json has a JSON syntax error at line {e.lineno}, " die(f"{path.name} has a JSON syntax error at line {e.lineno}, "
f"column {e.colno}: {e.msg}\n" f"column {e.colno}: {e.msg}\n"
f" file: {path}\n" f" file: {path}\n"
f" fix: correct the syntax by hand (a trailing comma is the " f" fix: correct the syntax by hand (a trailing comma is the "
@@ -103,32 +126,48 @@ def load_state(path: Path = STATE_FILE) -> dict:
def write_state(state: dict) -> None: def write_state(state: dict) -> None:
"""Atomic write: render to a temp file in the same dir, then rename. """Atomic write: render to a temp file in the same dir, then rename.
Validates first an invalid state never reaches disk (the old file Validates first an invalid state never reaches disk (the old file
stays untouched), so a bad `set` can't brick the next rebuild.""" stays untouched), so a bad `set` can't brick the next rebuild.
Always writes the canonical state.json; removes a legacy-named sibling
so a checkout never has two competing sources of truth."""
errors, warnings = validate_state(state) errors, warnings = validate_state(state)
for w in warnings: for w in warnings:
log(f"warning: {w}") log(f"warning: {w}")
if errors: if errors:
die("refusing to write an invalid state (nothing changed):\n" die("refusing to write an invalid state (nothing changed):\n"
+ "\n".join(errors)) + "\n".join(errors))
STATE_FILE.parent.mkdir(parents=True, exist_ok=True) out = FLAKE_DIR / STATE_NAME
fd, tmp = tempfile.mkstemp(dir=STATE_FILE.parent, prefix=".theme-state.", suffix=".json") out.parent.mkdir(parents=True, exist_ok=True)
fd, tmp = tempfile.mkstemp(dir=out.parent, prefix=".state.", suffix=".json")
try: try:
with os.fdopen(fd, "w") as f: with os.fdopen(fd, "w") as f:
json.dump(state, f, indent=2) json.dump(state, f, indent=2)
f.write("\n") f.write("\n")
os.replace(tmp, STATE_FILE) os.replace(tmp, out)
except BaseException: except BaseException:
os.unlink(tmp) os.unlink(tmp)
raise raise
log(f"state written to {STATE_FILE}") for name in LEGACY_STATE_NAMES:
legacy = FLAKE_DIR / name
if legacy.exists() and legacy.resolve() != out.resolve():
try:
legacy.unlink()
log(f"migrated: removed legacy {name} (now {STATE_NAME})")
except OSError as e:
log(f"warning: could not remove legacy {name}: {e}")
log(f"state written to {out}")
# Flakes only see git-tracked files. theme-state.json ships tracked, # Flakes only see git-tracked files. state.json ships tracked,
# but make sure a fresh checkout / reset can't silently hide it. # but make sure a fresh checkout / reset can't silently hide it.
if (FLAKE_DIR / ".git").exists() and shutil.which("git"): if (FLAKE_DIR / ".git").exists() and shutil.which("git"):
subprocess.run( subprocess.run(
["git", "-C", str(FLAKE_DIR), "add", "--intent-to-add", "theme-state.json"], ["git", "-C", str(FLAKE_DIR), "add", "--intent-to-add", STATE_NAME],
capture_output=True, capture_output=True,
) )
for name in LEGACY_STATE_NAMES:
subprocess.run(
["git", "-C", str(FLAKE_DIR), "rm", "-f", "--ignore-unmatch", name],
capture_output=True,
)
def auto_commit_enabled(state: dict) -> bool: def auto_commit_enabled(state: dict) -> bool:
@@ -136,27 +175,41 @@ def auto_commit_enabled(state: dict) -> bool:
def auto_commit(message: str) -> None: def auto_commit(message: str) -> None:
"""Opt-in (settings.autoCommit): commit theme-state.json — and nothing """Opt-in (settings.autoCommit): commit state.json — and nothing
else after a mutation, so settings history is `git log`. The pathspec else after a mutation, so settings history is `git log`. The pathspec
keeps unrelated dirty files out of the commit; a missing git identity keeps unrelated dirty files out of the commit; a missing git identity
falls back to a Nomarchy one so a fresh machine never errors. Callers falls back to a Nomarchy one so a fresh machine never errors. Callers
fire this when the flag is on before OR after the write, so the fire this when the flag is on before OR after the write, so the
disable-toggle itself lands in history instead of staying dirty. disable-toggle itself lands in history instead of staying dirty.
`bg` is deliberately excluded (runtime wallpaper churn); the wallpaper `bg` is deliberately excluded (runtime wallpaper churn); the wallpaper
path rides along with the next apply/set commit.""" path rides along with the next apply/set commit. The rest of a dirty
tree (hand edits, lock bumps) is swept by nomarchy-lifecycle's
nomarchy-autocommit right before a pull/rebuild/home switch same
flag, honest message so it never rides a settings-named commit."""
if not (FLAKE_DIR / ".git").exists() or shutil.which("git") is None: if not (FLAKE_DIR / ".git").exists() or shutil.which("git") is None:
return return
git = ["git", "-C", str(FLAKE_DIR)] git = ["git", "-C", str(FLAKE_DIR)]
# Pathspec: preferred name plus any legacy file git still knows — in the
# index or in HEAD (a migration's staged `git rm` needs the pathspec to
# ride along). Names known to neither MUST be dropped: unlike `git diff`,
# `git commit` aborts on a pathspec that matches nothing, which silently
# killed every auto-commit once the #107 rename removed the legacy files.
def known(name: str) -> bool:
return subprocess.run(git + ["ls-files", "--error-unmatch", "--", name],
capture_output=True).returncode == 0 \
or subprocess.run(git + ["cat-file", "-e", f"HEAD:{name}"],
capture_output=True).returncode == 0
paths = [STATE_NAME] + [n for n in LEGACY_STATE_NAMES if known(n)]
# No-op when the file already matches HEAD (a `set` to the same value). # No-op when the file already matches HEAD (a `set` to the same value).
# On a repo with no commits yet this diff errors — then just commit. # On a repo with no commits yet this diff errors — then just commit.
if subprocess.run(git + ["diff", "--quiet", "HEAD", "--", "theme-state.json"], if subprocess.run(git + ["diff", "--quiet", "HEAD", "--"] + paths,
capture_output=True).returncode == 0: capture_output=True).returncode == 0:
return return
if not subprocess.run(git + ["config", "user.email"], if not subprocess.run(git + ["config", "user.email"],
capture_output=True, text=True).stdout.strip(): capture_output=True, text=True).stdout.strip():
git += ["-c", "user.name=Nomarchy", "-c", "user.email=nomarchy@localhost"] git += ["-c", "user.name=Nomarchy", "-c", "user.email=nomarchy@localhost"]
result = subprocess.run( result = subprocess.run(
git + ["commit", "--quiet", "-m", message, "--", "theme-state.json"], git + ["commit", "--quiet", "-m", message, "--"] + paths,
capture_output=True, text=True) capture_output=True, text=True)
if result.returncode == 0: if result.returncode == 0:
log(f"auto-committed: {message}") log(f"auto-committed: {message}")
@@ -209,7 +262,7 @@ def validate_state(state) -> tuple:
if not isinstance(state, dict): if not isinstance(state, dict):
err("top level", "must be a JSON object", err("top level", "must be a JSON object",
"re-apply a preset: nomarchy-theme-sync apply boreal") "re-apply a preset: nomarchy-state-sync apply boreal")
return errors, warnings return errors, warnings
for k in state: for k in state:
@@ -312,7 +365,7 @@ def cmd_validate(_args) -> None:
for w in warnings: for w in warnings:
print(f"{w}") print(f"{w}")
if errors: if errors:
print(f"nomarchy-theme-sync: {STATE_FILE} has " print(f"nomarchy-state-sync: {STATE_FILE} has "
f"{len(errors)} problem(s):", file=sys.stderr) f"{len(errors)} problem(s):", file=sys.stderr)
for e in errors: for e in errors:
print(f"{e}", file=sys.stderr) print(f"{e}", file=sys.stderr)
@@ -337,7 +390,7 @@ def check_fonts(state: dict) -> None:
families = [f.strip().lower() for f in result.stdout.split(",")] families = [f.strip().lower() for f in result.stdout.split(",")]
if result.returncode != 0 or name.strip().lower() not in families: if result.returncode != 0 or name.strip().lower() not in families:
fallback = result.stdout.strip() or "unknown" fallback = result.stdout.strip() or "unknown"
print(f"nomarchy-theme-sync: warning: fonts.{key} '{name}' is not " print(f"nomarchy-state-sync: warning: fonts.{key} '{name}' is not "
f"installed — fontconfig will substitute '{fallback}'", f"installed — fontconfig will substitute '{fallback}'",
file=sys.stderr) file=sys.stderr)
notify(f"Font '{name}' is not installed — using '{fallback}'") notify(f"Font '{name}' is not installed — using '{fallback}'")
@@ -367,9 +420,23 @@ def run_switch() -> None:
"Rebuild FAILED — scroll up for last lines; run nomarchy-doctor", "Rebuild FAILED — scroll up for last lines; run nomarchy-doctor",
urgency="critical", urgency="critical",
) )
# Live ISO pins only the *default* theme's HM generation. Applying
# another preset offline tries to build the world from source (#113).
offline_hint = ""
try:
import socket
socket.create_connection(("1.1.1.1", 53), timeout=1.5).close()
except OSError:
offline_hint = (
" Offline? Only the live ISO's already-pinned theme is "
"guaranteed without a network — other themes need builds "
"or a binary cache. Connect and retry, or apply the default "
"theme (boreal)."
)
die( die(
"rebuild failed (state already written; fix and re-run). " "rebuild failed (state already written; fix and re-run). "
"Diagnose: nomarchy-doctor. Recovery: docs/RECOVERY.md" "Diagnose: nomarchy-doctor. Recovery: docs/RECOVERY.md."
+ offline_hint
) )
notify("Changes applied ✓") notify("Changes applied ✓")
# Waybar runs from Hyprland's exec-once (not a systemd unit HM would # Waybar runs from Hyprland's exec-once (not a systemd unit HM would
@@ -380,14 +447,23 @@ def run_switch() -> None:
# reload_style_on_change while the symlinks flip), so prefer the # reload_style_on_change while the symlinks flip), so prefer the
# restart whenever the supervisor is there to catch it; fall back to # restart whenever the supervisor is there to catch it; fall back to
# SIGUSR2 for unsupervised/custom bars. No-ops if nothing is running. # SIGUSR2 for unsupervised/custom bars. No-ops if nothing is running.
# The pattern matches both comm names: nixpkgs wraps the binary, so
# the process is `.waybar-wrapped` — a bare `pkill -x waybar` matched
# nothing, theme switches never touched the bar, and it kept the
# gtk.css of whatever theme it was started under (the 2026-07-19
# stale-digit-color bug). Keep -x: unanchored `waybar` also matches
# the supervisor's own `nomarchy-waybar` comm and would kill it.
if shutil.which("pkill"): if shutil.which("pkill"):
supervised = subprocess.run( supervised = subprocess.run(
["pgrep", "-f", "nomarchy-waybar"], capture_output=True ["pgrep", "-f", "nomarchy-waybar"], capture_output=True
).returncode == 0 ).returncode == 0
if supervised: if supervised:
subprocess.run(["pkill", "-x", "waybar"], check=False) subprocess.run(["pkill", "-x", r"waybar|\.waybar-wrapped"], check=False)
else: else:
subprocess.run(["pkill", "--signal", "SIGUSR2", "-x", "waybar"], check=False) subprocess.run(
["pkill", "--signal", "SIGUSR2", "-x", r"waybar|\.waybar-wrapped"],
check=False,
)
# ─── Theme assets (wallpapers) ──────────────────────────────────────────── # ─── Theme assets (wallpapers) ────────────────────────────────────────────
@@ -473,7 +549,7 @@ def apply_named(theme: str, no_switch: bool = False) -> None:
candidate = Path(theme).expanduser() candidate = Path(theme).expanduser()
preset_path = candidate if candidate.is_file() else find_preset(theme) preset_path = candidate if candidate.is_file() else find_preset(theme)
if preset_path is None: if preset_path is None:
die(f"unknown theme '{theme}' (try `nomarchy-theme-sync list`)") die(f"unknown theme '{theme}' (try `nomarchy-state-sync list`)")
preset = json.loads(preset_path.read_text()) preset = json.loads(preset_path.read_text())
# Merge over current state. Presets carry a full appearance block — # Merge over current state. Presets carry a full appearance block —
@@ -586,16 +662,19 @@ def cmd_get(args) -> None:
node = node[key] node = node[key]
except (KeyError, TypeError): except (KeyError, TypeError):
die(f"no such key: {args.path}") die(f"no such key: {args.path}")
# Booleans print JSON-style (true/false, not Python's True/False) so # Booleans and null print JSON-style (true/false/null, not Python's
# shell consumers can compare against the same literal they `set`. # True/False/None) so shell consumers can compare against the same
# literal they `set`. null matters for genuinely-nullable keys like
# settings.greeter.autoLogin, where "None" would silently miss every
# `case ... null)` a caller writes.
print(json.dumps(node, indent=2) print(json.dumps(node, indent=2)
if isinstance(node, (dict, list, bool)) else node) if node is None or isinstance(node, (dict, list, bool)) else node)
else: else:
print(json.dumps(state, indent=2)) print(json.dumps(state, indent=2))
def cmd_wallpaper(_args) -> None: def cmd_wallpaper(_args) -> None:
"""Apply the current wallpaper (session start, post-switch hook).""" """Apply the current wallpaper (session start, switch, output hotplug)."""
apply_wallpaper(load_state(), wait=True) apply_wallpaper(load_state(), wait=True)
@@ -622,7 +701,7 @@ def main() -> None:
global QUIET global QUIET
parser = argparse.ArgumentParser( parser = argparse.ArgumentParser(
prog="nomarchy-theme-sync", prog="nomarchy-state-sync",
description="Nomarchy theming — state writer + Home Manager rebuild dispatcher.", description="Nomarchy theming — state writer + Home Manager rebuild dispatcher.",
) )
parser.add_argument("--quiet", action="store_true", help="suppress progress output") parser.add_argument("--quiet", action="store_true", help="suppress progress output")
@@ -651,7 +730,7 @@ def main() -> None:
p.add_argument("--no-switch", action="store_true", help="write state only, skip the rebuild") p.add_argument("--no-switch", action="store_true", help="write state only, skip the rebuild")
p.set_defaults(func=cmd_auto) p.set_defaults(func=cmd_auto)
sub.add_parser("validate", help="check theme-state.json against the schema (read-only)").set_defaults(func=cmd_validate) sub.add_parser("validate", help="check state.json against the schema (read-only)").set_defaults(func=cmd_validate)
sub.add_parser("wallpaper", help="apply the current wallpaper via swww").set_defaults(func=cmd_wallpaper) sub.add_parser("wallpaper", help="apply the current wallpaper via swww").set_defaults(func=cmd_wallpaper)

View File

@@ -0,0 +1,65 @@
{ lib
, writeShellScriptBin
, coreutils
, gnugrep
, jq
, systemd
}:
# Smart suspend (#115): on battery, with hibernate available and the
# in-flake toggle on, enter suspend-then-hibernate (1h → hibernate via
# systemd.sleep HibernateDelaySec). Otherwise plain suspend.
#
# Reads the *live* state.json (menu may have flipped before rebuild);
# logind lid policy still needs a system rebuild to match (power.nix).
writeShellScriptBin "nomarchy-suspend" ''
set -euo pipefail
PATH=${lib.makeBinPath [ coreutils gnugrep jq systemd ]}:$PATH
on_ac() {
local f
for f in /sys/class/power_supply/*/online; do
[ -r "$f" ] && [ "$(cat "$f")" = "1" ] && return 0
done
return 1
}
can_hibernate() {
# Prefer logind's live answer (swap + resume wiring).
local v
v=$(busctl get-property org.freedesktop.login1 \
/org/freedesktop/login1 org.freedesktop.login1.Manager \
CanHibernate 2>/dev/null || true)
case "$v" in
"s \"yes\"") return 0 ;;
esac
# Offline / early-boot fallback: resume= on the kernel cmdline.
grep -q '[[:space:]]resume=' /proc/cmdline 2>/dev/null \
|| grep -q '^resume=' /proc/cmdline 2>/dev/null
}
want_s2h() {
# Default on (absent key / no state file) bag-carry drains less without
# a Preferences trip; menu writes false to opt out.
local st v="" found=0
for st in \
"''${NOMARCHY_PATH:-$HOME/.nomarchy}/state.json" \
/home/*/.nomarchy/state.json
do
[ -r "$st" ] || continue
found=1
v=$(jq -r '.settings.power.suspendThenHibernate // true' "$st" 2>/dev/null || true)
break
done
[ "$found" -eq 0 ] && return 0
case "$v" in
false|False|0) return 1 ;;
*) return 0 ;;
esac
}
if want_s2h && ! on_ac && can_hibernate; then
exec systemctl suspend-then-hibernate
fi
exec systemctl suspend
''

View File

@@ -1,49 +0,0 @@
{ 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.
, themesDir ? null
}:
stdenvNoCC.mkDerivation {
pname = "nomarchy-theme-sync";
version = "0.4.0";
src = ./.;
nativeBuildInputs = [ makeWrapper ];
buildInputs = [ python3 ];
installPhase = ''
runHook preInstall
install -Dm755 nomarchy-theme-sync.py $out/bin/nomarchy-theme-sync
patchShebangs $out/bin/nomarchy-theme-sync
${lib.optionalString (themesDir != null) ''
mkdir -p $out/share/nomarchy
cp -r ${themesDir} $out/share/nomarchy/themes
''}
# 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-theme-sync \
--prefix PATH : ${lib.makeBinPath [ awww libnotify git ]} \
${lib.optionalString (themesDir != null)
"--set NOMARCHY_DEFAULT_THEMES $out/share/nomarchy/themes"}
runHook postInstall
'';
meta = {
description = "Nomarchy theming: JSON state writer + Home Manager rebuild dispatcher";
license = lib.licenses.mit;
mainProgram = "nomarchy-theme-sync";
platforms = lib.platforms.linux;
};
}

View File

@@ -0,0 +1,9 @@
# Plain-language generation diff (BACKLOG #82). Wraps nvd; PATH-resolved
# notify is left to callers. NOMARCHY_NVD lets checks inject a fixture.
{ writeShellApplication, coreutils, nvd, gnugrep, gnused }:
writeShellApplication {
name = "nomarchy-what-changed";
runtimeInputs = [ coreutils nvd gnugrep gnused ];
text = builtins.readFile ./nomarchy-what-changed.sh;
}

View File

@@ -0,0 +1,178 @@
# nomarchy-what-changed — plain-language "what changed last rebuild".
# Wraps nvd into a short summary (for toasts / menu) and optional full
# report. In-checkout state is not involved; this only reads Nix profiles.
#
# usage:
# nomarchy-what-changed # system + home (last two gens each)
# nomarchy-what-changed system|home # one layer
# nomarchy-what-changed --summary … # one line per layer (toast body)
# nomarchy-what-changed --diff PATH1 PATH2
# nomarchy-what-changed --summary --diff PATH1 PATH2
#
# NOMARCHY_NVD overrides the nvd binary (tests inject a fixture).
set -euo pipefail
nvd_bin="${NOMARCHY_NVD:-nvd}"
summary_only=0
mode="" # empty | system | home | diff
diff_a="" diff_b=""
usage() {
echo "usage: nomarchy-what-changed [--summary] [system|home]" >&2
echo " nomarchy-what-changed [--summary] --diff <before> <after>" >&2
exit 64
}
while [ $# -gt 0 ]; do
case "$1" in
--summary) summary_only=1; shift ;;
--diff)
mode="diff"
shift
[ $# -ge 2 ] || usage
diff_a=$1; diff_b=$2; shift 2
;;
system|home) mode="$1"; shift ;;
-h|--help) usage ;;
*) usage ;;
esac
done
# One-line plain-language summary of an nvd --color never report.
# Prefer package row counts ([A.]/[R.]/[C.]); fall back to closure path delta.
summarize_report() {
local label=$1 report=$2
local added removed changed
if printf '%s\n' "$report" | grep -q 'No version or selection state changes'; then
printf '%s: no package changes\n' "$label"
return
fi
# Count package rows (nvd tags: A added, R removed, C version/selection change).
added=$(printf '%s\n' "$report" | grep -c '\[A\.' || true)
removed=$(printf '%s\n' "$report" | grep -c '\[R\.' || true)
changed=$(printf '%s\n' "$report" | grep -c '\[C\.' || true)
# Strip trailing newline noise from grep -c on empty (already || true → 0).
added=${added:-0}; removed=${removed:-0}; changed=${changed:-0}
if [ "$added" -eq 0 ] && [ "$removed" -eq 0 ] && [ "$changed" -eq 0 ]; then
# Closure-only deltas (sources, etc. without package names).
local paths_a paths_r
paths_a=$(printf '%s\n' "$report" \
| sed -n 's/.*(\([0-9]*\) paths added,.*/\1/p' | head -1)
paths_r=$(printf '%s\n' "$report" \
| sed -n 's/.*paths added, \([0-9]*\) paths removed.*/\1/p' | head -1)
paths_a=${paths_a:-0}; paths_r=${paths_r:-0}
if [ "$paths_a" -eq 0 ] && [ "$paths_r" -eq 0 ]; then
printf '%s: no package changes\n' "$label"
else
printf '%s: %s paths added, %s removed\n' "$label" "$paths_a" "$paths_r"
fi
return
fi
local parts=()
[ "$added" -gt 0 ] && parts+=("$added added")
[ "$removed" -gt 0 ] && parts+=("$removed removed")
[ "$changed" -gt 0 ] && parts+=("$changed updated")
local joined
joined=$(printf '%s, ' "${parts[@]}")
joined=${joined%, }
printf '%s: %s\n' "$label" "$joined"
}
run_diff() {
local before=$1 after=$2 label=$3
if [ ! -e "$before" ] || [ ! -e "$after" ]; then
printf '%s: no previous generation to compare\n' "$label"
return 0
fi
if [ "$(readlink -f "$before" 2>/dev/null || echo "$before")" \
= "$(readlink -f "$after" 2>/dev/null || echo "$after")" ]; then
printf '%s: no package changes\n' "$label"
return 0
fi
local report
report=$("$nvd_bin" --color never diff "$before" "$after" 2>/dev/null) || {
printf '%s: could not diff (is nvd installed?)\n' "$label"
return 0
}
if [ "$summary_only" -eq 1 ]; then
summarize_report "$label" "$report"
else
printf '── %s ──\n' "$label"
printf '%s\n' "$report"
echo
summarize_report "$label" "$report"
fi
}
# Resolve last two links for a profile stem (…/system or …/home-manager).
last_two() {
local stem=$1
# shellcheck disable=SC2012
ls -d "${stem}"-[0-9]*-link 2>/dev/null | sort -V | tail -n 2
}
system_pair() {
last_two /nix/var/nix/profiles/system
}
home_pair() {
local d
d="${XDG_STATE_HOME:-$HOME/.local/state}/nix/profiles"
if ls -d "$d"/home-manager-[0-9]*-link >/dev/null 2>&1; then
last_two "$d/home-manager"
return
fi
d="/nix/var/nix/profiles/per-user/${USER:-$(id -un)}"
if ls -d "$d"/home-manager-[0-9]*-link >/dev/null 2>&1; then
last_two "$d/home-manager"
return
fi
return 0
}
pair_to_before_after() {
# stdin: 02 paths; sets before/after (before may be empty).
local -a links=()
while IFS= read -r line; do
[ -n "$line" ] && links+=("$line")
done
before=""; after=""
if [ "${#links[@]}" -ge 2 ]; then
before=$(readlink -f "${links[0]}")
after=$(readlink -f "${links[1]}")
elif [ "${#links[@]}" -eq 1 ]; then
after=$(readlink -f "${links[0]}")
fi
}
if [ "$mode" = diff ]; then
run_diff "$diff_a" "$diff_b" "Diff"
exit 0
fi
if [ -z "$mode" ] || [ "$mode" = system ]; then
before=""; after=""
pair_to_before_after < <(system_pair)
if [ -n "$after" ] && [ -n "$before" ]; then
run_diff "$before" "$after" "System"
elif [ -n "$after" ]; then
printf 'System: only one generation on disk — rebuild once more to compare\n'
else
printf 'System: no NixOS profile found\n'
fi
fi
if [ -z "$mode" ] || [ "$mode" = home ]; then
before=""; after=""
pair_to_before_after < <(home_pair)
if [ -n "$after" ] && [ -n "$before" ]; then
run_diff "$before" "$after" "Desktop"
elif [ -n "$after" ]; then
printf 'Desktop: only one generation on disk — switch once more to compare\n'
else
printf 'Desktop: no Home Manager profile found\n'
fi
fi

View File

@@ -0,0 +1,50 @@
# pam_fprintd_grosshack.so — fprintd's PAM module forked to accept the
# password OR a fingerprint at the same prompt (upstream calls itself a
# "gross hack"; it is also the field-standard answer to this PAM gap —
# stock PAM cannot express parallel factors, linux-pam#301).
#
# Source reviewed before packaging (2026-07-12, v0.3.0 = fprintd 1.94.2
# base + ~60-line delta): every failure path — no reader, no enrolled
# prints, fprintd absent, timeout, password typed — returns
# PAM_AUTHINFO_UNAVAIL, i.e. the module FAILS and PAM falls through to
# the password rule; a typed password is never validated here, only
# ferried via PAM_AUTHTOK to pam_unix (try_first_pass). Accepted
# quirks: installs a process-wide SIGUSR1 handler in the calling
# process, pthread_cancels the prompt thread on fingerprint win, and
# does not zeroize the prompt buffer. Consumed by
# modules/nixos/hardware.nix (nomarchy.hardware.fingerprint.parallel).
{ lib, stdenv, fetchFromGitLab, meson, ninja, pkg-config, gettext, python3
, glib, libfprint, polkit, pam, systemd, dbus, libpam-wrapper }:
stdenv.mkDerivation rec {
pname = "pam-fprint-grosshack";
version = "0.3.0";
src = fetchFromGitLab {
owner = "mishakmak";
repo = "pam-fprint-grosshack";
rev = "v${version}";
hash = "sha256-obczZbf/oH4xGaVvp3y3ZyDdYhZnxlCWvL0irgEYIi0=";
};
# Only the PAM module is built (the fork disables every other fprintd
# subdir), but the top-level meson still resolves the full fprintd
# dependency set — hence libfprint/polkit in buildInputs.
# pam_wrapper is only exercised by the (disabled) test suite, but the
# top-level meson marks it required whenever the pam option is on.
nativeBuildInputs = [ meson ninja pkg-config gettext python3 ];
buildInputs = [ glib libfprint polkit pam systemd dbus libpam-wrapper ];
mesonFlags = [
"-Dpam_modules_dir=${placeholder "out"}/lib/security"
"-Dsystemd=false"
"-Dman=false"
];
meta = {
description = "PAM module accepting password or fingerprint in parallel (fprintd fork)";
homepage = "https://gitlab.com/mishakmak/pam-fprint-grosshack";
license = lib.licenses.gpl2Plus;
platforms = lib.platforms.linux;
};
}

View File

@@ -15,19 +15,19 @@ keyboard, detected hardware, password hash, …). Keep commented opt-ins and
services — the login user is created from `username` automatically) and services — the login user is created from `username` automatically) and
`home.nix` (your packages). `home.nix` (your packages).
3. `git init && git add -A` — flakes only see tracked files, including 3. `git init && git add -A` — flakes only see tracked files, including
`theme-state.json`. `state.json`.
4. System: `sudo nixos-rebuild switch --flake .#default` 4. System: `sudo nixos-rebuild switch --flake .#default`
5. Desktop: `nix run home-manager -- switch --flake .#me` 5. Desktop: `nix run home-manager -- switch --flake .#me`
(afterwards just `home-manager switch --flake .#me` — it's installed) (afterwards just `home-manager switch --flake .#me` — it's installed)
6. Clone/symlink this directory to `~/.nomarchy` (or export 6. Clone/symlink this directory to `~/.nomarchy` (or export
`NOMARCHY_PATH`) so `nomarchy-theme-sync` knows where the state lives. `NOMARCHY_PATH`) so `nomarchy-state-sync` knows where the state lives.
Day-to-day: Day-to-day:
```sh ```sh
nomarchy-theme-sync list # 24 shipped presets nomarchy-state-sync list # 24 shipped presets
nomarchy-theme-sync apply gruvbox # writes state + home-manager switch nomarchy-state-sync apply gruvbox # writes state + home-manager switch
nomarchy-theme-sync bg next # cycle wallpapers (instant, no rebuild) nomarchy-state-sync bg next # cycle wallpapers (instant, no rebuild)
``` ```
The system layer only needs `nixos-rebuild` when you change `system.nix`. The system layer only needs `nixos-rebuild` when you change `system.nix`.

View File

@@ -4,9 +4,12 @@
# The only input. nixpkgs, home-manager etc. come pinned through it — # The only input. nixpkgs, home-manager etc. come pinned through it —
# tested together upstream. This file is written once (by you or the # tested together upstream. This file is written once (by you or the
# installer) and never hand-edited afterwards; your machine lives in # installer) and never hand-edited afterwards; your machine lives in
# system.nix and home.nix. v1 is the release branch. # system.nix and home.nix.
# Installer copies this template and patches username + hardwareProfile. # Development tip is `main`. Release consumers pin `?ref=v1` once that
inputs.nomarchy.url = "git+https://git.bemagri.xyz/bernardo/nomarchy.git?ref=v1"; # pointer is current (installer overwrites this URL from the ISO build).
# Installer copies this template and patches username + hardwareProfile
# + the nomarchy input URL.
inputs.nomarchy.url = "git+https://git.bemagri.xyz/bernardo/nomarchy.git?ref=main";
outputs = { nomarchy, ... }: outputs = { nomarchy, ... }:
nomarchy.lib.mkFlake { nomarchy.lib.mkFlake {

View File

@@ -1,5 +1,5 @@
# Your user environment. The Nomarchy desktop (Hyprland, Waybar, # Your user environment. The Nomarchy desktop (Hyprland, Waybar,
# Ghostty, theming engine, Stylix) comes from homeModules.nomarchy; # Kitty, theming engine, Stylix) comes from homeModules.nomarchy;
# tune it via the nomarchy.* options, add your own packages and # tune it via the nomarchy.* options, add your own packages and
# programs below. # programs below.
# #
@@ -14,16 +14,25 @@
nomarchy.keyboard.variant = ""; nomarchy.keyboard.variant = "";
# ── Overrides (the desktop is on by default; tweak or opt out) ────── # ── Overrides (the desktop is on by default; tweak or opt out) ──────
# nomarchy.terminal = "kitty"; # swap the default terminal # nomarchy.terminal = "kitty"; # default; only Kitty is shipped/themed
# nomarchy.kitty.enable = false; # keep the package, drop our config
# nomarchy.waybar.enable = false; # bring your own bar # nomarchy.waybar.enable = false; # bring your own bar
# nomarchy.stylix.enable = false; # opt out of GTK/Qt theming # nomarchy.stylix.enable = false; # opt out of GTK/Qt theming
# nomarchy.dockAudio.enable = false; # keep audio put when docking
# nomarchy.idle.fingerprint = true; # pin lock-screen finger unlock on/off. Normally
# # you want System Fingerprint instead: it moves
# # this AND login/sudo together off one state key,
# # and this line would override it. Separate options
# # only because hyprlock is configured here (it can't
# # read the NixOS side) and doesn't take a finger
# # through PAM at all — it has its own fprintd backend.
# #
# Icon pack: Papirus ships by default (auto Dark/Light by theme mode) and # Icon pack: Papirus ships by default (auto Dark/Light by theme mode) and
# is the ONLY icon pack in your closure. To use another, set the `icons` # is the ONLY icon pack in your closure. To use another, set the `icons`
# field in your theme-state.json to a theme name from a known pack — only # field in your state.json to a theme name from a known pack — only
# that pack is then added (no bloat otherwise). Known packs: Papirus, # that pack is then added (no bloat otherwise). Known packs: Papirus,
# Tela, Qogir, Reversal, Numix (see modules/home/theme.nix `iconPacks`). # Tela, Qogir, Reversal, Numix (see modules/home/theme.nix `iconPacks`).
# e.g. theme-state.json: "icons": "Tela-dark" # e.g. state.json: "icons": "Tela-dark"
# ── Opt-in features — uncomment and tweak to enable ───────────────── # ── Opt-in features — uncomment and tweak to enable ─────────────────
# nomarchy.nightlight = { # scheduled blue-light filter (hyprsunset) # nomarchy.nightlight = { # scheduled blue-light filter (hyprsunset)
@@ -72,9 +81,15 @@
# "keychron-keychron-k2" = { layout = "de"; }; # "keychron-keychron-k2" = { layout = "de"; };
# }; # };
# #
# nomarchy.keyboard.layouts = [ "de" "fr" ]; # rofi-prompt for a layout when a new # nomarchy.keyboard.layouts = [ "de" "fr" ]; # optional: put these first in the
# # keyboard connects + remember it in the # # new-keyboard picker (all installed XKB
# # flake state (graduates to .devices above) # # layouts are searchable without this)
#
# services.nextcloud-client = { # Nextcloud sync client (upstream Home Manager
# enable = true; # module — installs the client and starts it
# startInBackground = true; # with the graphical session; tray icon only,
# }; # no window). Re-comment to remove the app
# # AND its background service.
# ── Application suite ─────────────────────────────────────────────── # ── Application suite ───────────────────────────────────────────────
# A starter complete-workstation set, installed for your user — yours to # A starter complete-workstation set, installed for your user — yours to
@@ -91,7 +106,9 @@
home.packages = with pkgs; [ home.packages = with pkgs; [
libreoffice-fresh # office suite (documents, sheets, slides) libreoffice-fresh # office suite (documents, sheets, slides)
vscode # code editor (unfree; allowUnfree is on) vscode # code editor (unfree; allowUnfree is on)
chromium # web browser (mime defaults → chromium-browser.desktop) (chromium.override { enableWideVine = true; })
# web browser with Widevine DRM (Netflix, Spotify web…);
# unfree CDM — mime defaults → chromium-browser.desktop
gimp # raster image editor gimp # raster image editor
inkscape # vector graphics inkscape # vector graphics
mpv # media player mpv # media player
@@ -167,7 +184,8 @@
# mangohud # in-game overlay # mangohud # in-game overlay
# Files, sync & torrents # Files, sync & torrents
# nextcloud-client # nextcloud-client # bare app — services.nextcloud-client above
# # also autostarts it with the session
# localsend # AirDrop-like transfer # localsend # AirDrop-like transfer
# qbittorrent # qbittorrent
@@ -183,6 +201,8 @@
# Local AI (Ollama is a service toggle in system.nix) # Local AI (Ollama is a service toggle in system.nix)
# lmstudio # (unfree; large closure) # lmstudio # (unfree; large closure)
# unstable.lmstudio # newer lmstudio from the unstable channel —
# # you own this combination (see README)
# alpaca # GTK Ollama UI # alpaca # GTK Ollama UI
]; ];
} }

View File

@@ -47,6 +47,8 @@
# backend = "ppd"; # "ppd" (default) or "tlp" # backend = "ppd"; # "ppd" (default) or "tlp"
# laptop = true; # required by batteryChargeLimit # laptop = true; # required by batteryChargeLimit
# batteryChargeLimit = 80; # cap charging for longevity # batteryChargeLimit = 80; # cap charging for longevity
# # suspendThenHibernate = true; # 1h on battery → hibernate (#115);
# # menu: Preferences Suspend then hibernate
# thermal.enable = true; # thermald (Intel CPUs) # thermal.enable = true; # thermald (Intel CPUs)
# }; # };
# #
@@ -62,7 +64,10 @@
# amd.rocm.enable = true; # ROCm GPU compute (multi-GB, opt-in) # amd.rocm.enable = true; # ROCm GPU compute (multi-GB, opt-in)
# amd.rocm.gfxOverride = "11.0.0"; # HSA override for an unlisted iGPU # amd.rocm.gfxOverride = "11.0.0"; # HSA override for an unlisted iGPU
# fingerprint.enable = true; # fprintd; installer-set when a reader is detected # fingerprint.enable = true; # fprintd; installer-set when a reader is detected
# fingerprint.pam = true; # use the fingerprint for login + sudo # fingerprint.pam = true; # pin the fingerprint on/off instead of letting the
# # System Fingerprint toggle own it (this line WINS
# # over the menu — leave it commented to use the menu)
# fingerprint.parallel = false; # sequential prompt instead of password-or-finger
# npu.enable = true; # on-die NPU driver (experimental; userspace runtime BYO) # npu.enable = true; # on-die NPU driver (experimental; userspace runtime BYO)
# latestKernel = true; # newest kernel for very-new hardware (drivers not yet in the default) # latestKernel = true; # newest kernel for very-new hardware (drivers not yet in the default)
# camera.hideIrSensor = true; # dual-sensor webcam: hide the IR node so apps get the color cam # camera.hideIrSensor = true; # dual-sensor webcam: hide the IR node so apps get the color cam

60
themes/boreal-dawn.json Normal file
View File

@@ -0,0 +1,60 @@
{
"version": 1,
"name": "Boreal Dawn",
"slug": "boreal-dawn",
"mode": "light",
"wallpaper": "",
"colors": {
"base": "#EDF2F6",
"mantle": "#CBD6DF",
"surface": "#DFE7EE",
"overlay": "#A9B8C6",
"text": "#333E4E",
"subtext": "#51617A",
"muted": "#7C8B9D",
"accent": "#7458C8",
"accentAlt": "#22766E",
"good": "#48752C",
"warn": "#8F5E0A",
"bad": "#B03A47"
},
"border": {
"active": "accent",
"inactive": "overlay"
},
"fonts": {
"mono": "GeistMono Nerd Font",
"ui": "Inter",
"size": 11
},
"ui": {
"gapsIn": 6,
"gapsOut": 14,
"borderSize": 3,
"rounding": 12,
"iconSize": 44,
"activeOpacity": 1.0,
"inactiveOpacity": 0.95,
"terminalOpacity": 0.94,
"blur": true,
"shadow": true
},
"ansi": [
"#333E4E",
"#B03A47",
"#48752C",
"#8F5E0A",
"#3565BE",
"#7458C8",
"#22766E",
"#EDF2F6",
"#7C8B9D",
"#C94E5C",
"#5A8A3C",
"#A9731A",
"#4678D0",
"#8568D8",
"#2E8A80",
"#F8FAFC"
]
}

View File

@@ -0,0 +1,25 @@
theme[main_bg]="#EDF2F6"
theme[main_fg]="#333E4E"
theme[title]="#333E4E"
theme[hi_fg]="#7458C8"
theme[selected_bg]="#A9B8C6"
theme[selected_fg]="#333E4E"
theme[inactive_fg]="#7C8B9D"
theme[graph_text]="#51617A"
theme[meter_bg]="#DFE7EE"
theme[proc_misc]="#22766E"
theme[cpu_box]="#7458C8"
theme[mem_box]="#48752C"
theme[net_box]="#22766E"
theme[proc_box]="#DFE7EE"
theme[div_line]="#A9B8C6"
theme[temp_start]="#48752C"
theme[temp_mid]="#8F5E0A"
theme[temp_end]="#B03A47"
theme[cpu_start]="#48752C"
theme[cpu_mid]="#8F5E0A"
theme[cpu_end]="#B03A47"
theme[free_start]="#48752C"
theme[used_start]="#8F5E0A"
theme[download_start]="#7458C8"
theme[upload_start]="#22766E"

View File

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 80 KiB

View File

@@ -0,0 +1,122 @@
/**
* Boreal Dawn — rofi whole-swap (themes/<slug>/rofi.rasi).
* The base theme: a frosted dawn panel used by every menu (and, as a
* list, the fallback launcher). The app launcher reshapes this into a
* grid via themes/boreal-dawn/launcher.rasi, which @imports this file. The
* `configuration {}` block is omitted on purpose — it comes from
* modules/home/rofi.nix. Element structure mirrors the generated theme
* so the theme-grid picker's per-invocation -theme-str still lays out.
*/
* {
base: #EDF2F6;
glassBg: #EDF2F6E6;
surface: #DFE7EE;
overlay: #A9B8C6;
text: #333E4E;
subtext: #51617A;
muted: #7C8B9D;
accent: #7458C8;
accentAlt: #22766E;
accentSoft: #7458C83B;
frostEdge: #22766E5C;
font: "GeistMono Nerd Font 11";
background-color: transparent;
text-color: @text;
}
window {
background-color: @glassBg;
border: 1px;
border-color: @frostEdge;
border-radius: 14px;
width: calc( 84ch min 65% );
location: center;
anchor: center;
padding: 16px;
}
mainbox {
background-color: transparent;
children: [ inputbar, message, listview ];
spacing: 12px;
}
inputbar {
children: [ prompt, entry ];
background-color: @surface;
text-color: @text;
border-radius: 10px;
padding: 12px 16px;
spacing: 10px;
}
prompt { text-color: @accent; }
entry {
text-color: @text;
cursor: text;
placeholder: "Search…";
placeholder-color: @muted;
}
listview {
background-color: transparent;
columns: 1;
lines: 8;
dynamic: true;
scrollbar: true;
spacing: 6px;
padding: 4px 0px 0px 0px;
}
element {
background-color: transparent;
text-color: @text;
orientation: horizontal;
border-radius: 10px;
padding: 10px 14px;
spacing: 12px;
}
element normal.normal,
element alternate.normal {
background-color: transparent;
}
/* Selected row lifts on a soft aurora-violet wash. */
element selected.normal {
background-color: @accentSoft;
text-color: @text;
}
element-icon {
background-color: transparent;
size: 30px;
cursor: inherit;
}
element-text {
background-color: transparent;
text-color: inherit;
highlight: bold;
vertical-align: 0.5;
}
message { padding: 0px; }
textbox {
background-color: @surface;
text-color: @text;
border-radius: 10px;
padding: 10px 14px;
}
scrollbar {
width: 4px;
handle-width: 4px;
handle-color: @accent;
background-color: @surface;
border: 0px;
}

View File

@@ -0,0 +1,195 @@
/*
* Boreal Dawn — Waybar whole-swap (themes/<slug>/waybar.css).
*
* A theme waybar.css replaces the ENTIRE generated stylesheet and is read
* raw (no palette is prepended), so this file MUST define its own colors.
* Identity: a floating "frost dawn" bar — three frosted-glass pills
* (launcher+clock · workspaces · status) over the wallpaper's blur. The
* whole right side is ONE continuous pill: the system tray shares its
* glass with the status modules, no separators, no boxes. Companion:
* waybar.jsonc.
*/
@define-color base #EDF2F6;
@define-color mantle #CBD6DF;
@define-color surface #DFE7EE;
@define-color overlay #A9B8C6;
@define-color text #333E4E;
@define-color subtext #51617A;
@define-color muted #7C8B9D;
@define-color accent #7458C8;
@define-color accentAlt #22766E;
@define-color good #48752C;
@define-color warn #8F5E0A;
@define-color bad #B03A47;
/* 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 (pills) and wreck the
bar. */
* {
/* Mono Nerd face for status glyphs (tight right bearings on the
proportional Symbols face make icon+percentage collide). */
font-family: "GeistMono Nerd Font", "Symbols Nerd Font Mono";
font-size: 13px;
font-weight: 500;
border: none;
border-radius: 0;
min-height: 0;
}
/* ── Tray menus (nm-applet & co live in Waybar's process) ─────────────
Undo the `*` reset for them: same stylesheet, higher specificity.
Arrows/checks are CSS-sized nodes — min-height 0 makes them invisible;
separators die the same way; the bar's mono face reads wrong in text
menus. */
menu, menu * { font-family: "Inter", sans-serif; }
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.2);
}
/* The bar itself is invisible — the pills float over the wallpaper. */
window#waybar {
background: transparent;
color: @text;
}
/* ── The three frosted-glass pills ────────────────────────────────────
Global blur turns the translucent @base into frosted glass; a faint
frost edge lifts each pill off the wallpaper. Light frost needs more
alpha than the dark theme's to stay visible on pale wallpapers. */
.modules-left,
.modules-center,
.modules-right {
background: alpha(@base, 0.78);
border: 1px solid alpha(@accentAlt, 0.35);
border-radius: 14px;
padding: 0 6px;
margin: 0;
}
/* Every module sits ON its pill: transparent, so nothing boxes the tray
off from the status icons — the seamless-cluster requirement. */
#custom-nomarchy,
#clock,
#custom-recording,
#custom-updates,
#custom-doctor,
#idle_inhibitor,
#custom-nightlight,
#custom-airplane,
#language,
#custom-vpn,
#pulseaudio,
#battery,
#custom-powerprofile,
#tray,
#custom-notification,
#custom-powermenu {
background: transparent;
color: alpha(@text, 0.95);
padding: 0 8px;
margin: 0;
}
/* ── Launcher — the aurora-violet Nomarchy mark opens the pill ────────
The mark lives at U+F000 in the dedicated "Nomarchy" font; pin the
family or the Nerd Font's glass glyph at the same codepoint wins. */
#custom-nomarchy {
color: @accent;
font-family: Nomarchy;
font-size: 18px;
padding: 0 12px 0 14px;
}
#custom-nomarchy:hover { color: @accentAlt; }
/* ── Clock — unified time · date ─────────────────────────────────────*/
#clock {
color: @text;
font-weight: 600;
padding-left: 4px;
}
/* ── Workspaces — dots that bloom to an aurora pill when active ───────*/
#workspaces { padding: 0 2px; }
#workspaces button {
/* @subtext, not @muted: on a light pill the 2.0-floor grey washes out
at number size — inactive workspaces must stay readable (6.4:1). */
color: @subtext;
padding: 0 9px;
margin: 3px 1px;
border-radius: 9px;
transition: color 0.2s ease, background 0.2s ease;
}
#workspaces button:hover {
color: @text;
background: alpha(@overlay, 0.5);
}
#workspaces button.active {
color: @base;
background: @accent;
}
#workspaces button.urgent {
color: @base;
background: @bad;
}
/* ── Status semantics (neutral until a state fires) ──────────────────*/
#custom-recording.recording { color: @bad; }
#custom-updates.available { color: @accent; }
#custom-doctor { color: @bad; }
#idle_inhibitor.activated { color: @warn; }
#custom-nightlight.on { color: @warn; }
#custom-airplane.on { color: @warn; }
#custom-vpn.on { color: @good; }
#pulseaudio.muted { color: @muted; }
#battery.charging { color: @good; }
#battery.warning:not(.charging) { color: @warn; }
#battery.critical:not(.charging) { color: @bad; }
/* Icon-only status modules carry no text, so they don't get the 13pt
Pango icon span the icon+text modules (volume/battery/language) use —
bump their font-size to match, or these glyphs read noticeably smaller
than their neighbours. */
#custom-recording,
#custom-updates,
#custom-doctor,
#custom-nightlight,
#custom-airplane,
#custom-vpn,
#custom-notification { font-size: 17px; }
/* Speedometer + caffeine cup render small in their em box — a touch more. */
#custom-powerprofile,
#idle_inhibitor { font-size: 18px; }
#custom-notification.notification { color: @accent; }
#custom-notification.dnd-none,
#custom-notification.dnd-notification,
#custom-notification.inhibited-none,
#custom-notification.inhibited-notification { color: @muted; }
/* Tray shares the pill's glass — same transparent background, its own
modest padding so nm-applet & friends breathe with the status icons. */
#tray { padding: 0 6px; }
#tray > .passive { -gtk-icon-effect: dim; }
#tray > .needs-attention { -gtk-icon-effect: highlight; }
/* Power button closes the pill on the right; warms to alert on hover. */
#custom-powermenu { color: @subtext; padding: 0 12px 0 8px; }
#custom-powermenu:hover { color: @bad; }
/* ── Tooltips — frosted, frost edge ───────────────────────────────────*/
tooltip {
background: alpha(@base, 0.97);
border: 1px solid alpha(@accentAlt, 0.45);
border-radius: 10px;
}
tooltip label { color: @text; }

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