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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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.
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.
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.
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>
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>
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>
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>
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>
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>
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>
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>
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.
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.
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>
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>
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>
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>
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>
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>
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>
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.