Commit Graph

216 Commits

Author SHA1 Message Date
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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