The graphical layer inherited Stylix's default font sizes, which read
oversized on this display. Step them down a point across applications,
desktop, popups, and the terminal.
home.sessionVariables (XCURSOR_THEME/XCURSOR_SIZE from home.pointerCursor)
land in hm-session-vars.sh, which only a login shell sources. UWSM starts
the graphical session without it, so Hyprland never saw the theme and drew
its built-in cursor instead of the configured Bibata-Modern-Ice.
Declare the cursor through Hyprland's own env directive, read at compositor
startup regardless of the shell profile, sourced from config.stylix.cursor
so the theme and size stay defined in one place.
Route the volume and brightness keys through swayosd-client, which raises
a transient on-screen popup that auto-hides a moment after the last press,
so the level is visible while adjusting without a permanent widget. A new
osd module runs the server for the graphical session and makes the
backlight node group-writable so it dims without root.
Supersedes the plain brightnessctl binds, whose control swayosd bundles.
Bind XF86MonBrightnessUp/Down to brightnessctl in the repeat-while-held
block, mirroring the volume keys. A new brightness module installs
brightnessctl's udev rule and adds the user to the video group so the
backlight can be dimmed without root.
Stylix's Nord Firefox Color mapping paints the selected address-bar result in
base04, a near-white grey, against base05 text — light on light, so the text
of the highlighted result is illegible. Override that one highlight to the Nord
selection grey (base03), derived from the same Stylix scheme, restoring
contrast without touching the rest of the theme.
Firefox disables profile-sideloaded extensions by default, so the Firefox
Color add-on that carries the Nord chrome theme installed but stayed disabled
and the chrome kept its default look. Set extensions.autoDisableScopes = 0 so
it enables on detection; the policy-installed functional extensions are
force-enabled by policy and unaffected.
The base Stylix Firefox target themes only fonts and the reader view, leaving
the toolbar and tabs at Firefox's default look. Enable colorTheme so Stylix
recolours the chrome Nord from the shared scheme through its managed Firefox
Color add-on, with no hand-written chrome CSS and no flake input of ours.
Scope ADR 0005's no-pinned-add-on rule to the three functional extensions and
record the signed, Stylix-managed theming add-on as a deliberate exception.
Firefox writes ~/.config/mozilla/firefox/profiles.ini itself on first launch,
pointing at an auto-created random-prefix profile. Home-manager then refuses to
clobber that file and fails the whole activation, so the declarative default
profile — search.json.mozlz4 (DuckDuckGo) and the Stylix user.js — never lands
and Firefox keeps running the stale profile with Google as default and no theme.
Set force = true on the generated profiles.ini home.file entry so home-manager
owns it, deploys the default profile, and Firefox switches to it.
Add Firefox as a single-purpose desktop Module configured entirely through
home-manager's programs.firefox, and fan it out from the desktop aggregator so
any Host with the desktop enabled brings the browser up.
Ship stock mainline Firefox, hardened and de-monetized through locked
enterprise policies (telemetry, studies, Pocket, offer-to-save-logins, the
default-browser check, Firefox accounts and sync, and the sponsored new-tab
surfaces all off). Force-install three extensions by policy, keyed by their
verified add-on ids: uBlock Origin, Proton Pass, and SponsorBlock.
Default search to DuckDuckGo over a lean list: the general-purpose commercial
engines are hidden with metaData.hidden so Firefox does not reconcile them back
in, leaving DuckDuckGo and Wikipedia. Theme the browser Nord from the shared
Stylix scheme against the one profile, and register it as the default handler
for the web-link schemes and HTML.
Record the stock-Firefox-plus-policy-extensions decision as ADR 0005.
The task 0031 commit moved the module files but a mis-staged `git add`
left the option-path rewrites out, so main declared the old namespaces
(`modules.claude-code`, `modules.gitea-axi`, `modules.pi`,
`modules.desktop.hyprlock`/`hypridle`) while CONTEXT.md and ADR 0004
already documented the new ones. Rewrite the paths in the moved and
in-place modules, the neogaia host, and the two live CLAUDE.md gotchas
so the code matches the recorded convention.
Adopt the convention that a Module's option path mirrors its directory
under modules/, with an index file naming the directory's own segment.
- Group agent Modules under modules.agents.*: claude-code (whole
directory), pi (flattened to a file), skills (renamed from
agent-skills), and gitea-axi under an agents/tools/ subgroup. The
agents/ and tools/ folders are pure namespace prefixes with no
aggregator enable.
- Nest hypridle and hyprlock under modules.desktop.hyprland.*, with
hyprland.nix as the index, and update the desktop aggregator.
- Remove the obsolete example Module.
- Record the convention in CONTEXT.md and ADR 0004, and update the
neogaia host, the two live CLAUDE.md gotchas, and the skills Module's
intentional Enable-convention exception comment.
The lt alias walks the tree with -a, pulling the noisy .git directory
into every listing. Bake -I '.git' into it so the git internals are
skipped by default; extra ignores can still be passed at call time.
Add the skills flake as an input and a module that imports its
home-manager module into every host via home-manager.sharedModules,
exposing programs.agents.skills. The global skill selection lives in
modules/agent-skills.nix and is empty for now; per-skill granularity
comes from the flake's own listOf-package option, and the placement
self-gates on the agent harness being enabled.
The ls/la/ll/lt aliases ended with a bare --icons, whose WHEN value is
optional, so a trailing path was parsed as that value: `ls .claude`
failed with "invalid value '.claude' for --icons". Bind it as
--icons=always, matching --color=always.
Add modules.desktop.portals, enabled by the desktop aggregator, pinning the
XDG desktop portal routing explicitly: the three interfaces the Hyprland
portal implements (screencast, screenshot, global shortcuts) go to Hyprland,
and GTK is the default for file dialogs and appearance.
The backend packages already arrive with the Hyprland compositor integration,
so this module owns only the routing, which was previously empty and rode on
the config file the Hyprland package happens to ship. Making it a first-class
module so in-app screen sharing does not depend on that incidental default.
Add a modules.desktop.userdirs module that declares the XDG user directories,
and route the recorder and screenshot tools through xdg-user-dir so their
output folders (Videos/Recordings, Pictures/Screenshots) follow one relocatable
source instead of a hardcoded $HOME path.
Add a wf-recorder module, enabled by the desktop aggregator. Super+Shift+R
picks a region with slurp and toggles a video-only capture (no audio), stopped
with SIGINT so the file finalises, saved to ~/Videos/Recordings. Start and
saved notifications fire via notify-send.
A Waybar custom/recording widget samples the wf-recorder process once a second
and shows a video glyph while a capture runs.
Add a screenshot module wiring grimblast (grim + slurp) through the satty
annotation editor, enabled by the desktop aggregator. Region, active-window,
and full-screen captures each open in satty and, on confirm, land in both the
clipboard and a dated file under ~/Pictures/Screenshots.
Bound to Print / Shift+Print / Ctrl+Print rather than the spec's Super+L
family, whose keys task 0021 already holds for hjkl focus and window movement.
Add a clipboard module to the desktop group, enabled through the
aggregator. Home-manager's services.cliphist runs the text and image
watchers as systemd user services bound to the graphical-session
target, and a rofi-dmenu picker recalls history on Super+Shift+V.
wl-clipboard is on PATH so the shell can pipe to and from the clipboard.
Add a session-lock screen and idle management to the Hyprland-native
subdirectory, enabled through the desktop aggregator.
hyprlock draws through the compositor session-lock protocol, themed by
Stylix, with a centered field and clock. Super+X locks directly through a
guarded launch so the key works without the idle daemon.
hypridle locks on idle and powers the displays off, with tunable timeouts,
and locks before every suspend. Lid-close routes through logind to suspend,
which the pre-sleep lock covers, so the lid lands at a locked screen.
Add a mako notification daemon module to the desktop group, enabled by
the aggregator. Toasts auto-dismiss into history after five seconds, a
do-not-disturb mode ([mode=dnd] invisible=true) suppresses display while
still recording to history, and Super+N recalls the last notification
via makoctl restore. Colors and the popup font come from Stylix's mako
target. The bar-side do-not-disturb toggle and media controls already
live in waybar.nix, so this task adds only the daemon.
Add a rofi module to the desktop group, enabled by the aggregator.
It runs the drun application launcher, bound on Super+R. A session menu
built on the same themed rofi is bound on Super+Shift+X, and every
rofi -dmenu call inherits the Stylix theme, so later utility menus
reuse it for free.
Also give layer surfaces their own fade-in (layersIn and fadeLayersIn)
a touch quicker than the window animation, so the launcher fades in
briskly rather than snapping into place.
The comment 'why not what', self-contained, sentence-per-line rules are
project-agnostic, so they belong in the global agent instructions rather
than this repo's conventions. Genericise the four Nix- and domain-specific
examples to their underlying principles on the way across.
Add a Waybar module to the desktop group, enabled through the aggregator,
showing workspaces with per-application icons plus a clock, MPRIS media
controls, audio, network, battery, and a do-not-disturb toggle.
The do-not-disturb widget drives mako's dnd mode and degrades gracefully
until the notifications module lands. Module glyphs are Nerd Font codepoints
decoded through JSON, backed by a system symbols font.
Add a PipeWire audio server so the bar's volume widget has a running sink to
read and drive.
Narrow the terminal tools excluded from Stylix to nvim alone. nvim keeps
its dedicated gbprod/nord.nvim colorscheme, which is richer than the
base16 mapping Stylix would apply.
tmux and fish are now Stylix-managed. Drop the hand-written Nord colours
from the tmux status bar so Stylix themes the status and pane styles,
keeping the minimal layout (session name plus window list) reapplied
after Stylix so it still wins.
Add Stylix as a flake input and a desktop theming module that resolves a
single Nord base16 scheme across the graphical surface: GTK, Qt, cursor,
and the system monospace font, plus a static Nord wallpaper drawn as a
Polar Night gradient.
Scope the theming to the graphical layer alone. The Stylix targets for
fish (at both system and home-manager level), tmux, and nixvim stay off
so the terminal tools keep their hand-written themes.
Flip between the dwindle and master layouts with one bind. Neither a
dispatcher nor a keyword toggles the layout on its own, so a small pinned
script reads general:layout and sets the other through hyprctl keyword.
Ghostty's GTK4 window construction made every launch sluggish on neogaia's
integrated graphics (~440 ms to map a window), which a head-to-head
comparison against Alacritty confirmed. Alacritty's lightweight OpenGL
renderer opens fast on the iGPU, so it becomes the terminal on Super+Return.
The choice stays reversible per host, so a capable host such as zeus could
adopt Ghostty later.
Two issues surfaced on the live neogaia session. Hyprland rejected two names
at load: the split toggle is a dwindle layout message reached through
layoutmsg, not a top-level dispatcher, and dwindle:pseudotile no longer exists
in this version. Bind Super+T via layoutmsg and drop the pseudotile option.
Ghostty also cold-started GTK on every launch. Configure it through
home-manager with gtk-single-instance so windows after the first open in the
existing process. Record the dispatcher/option version-drift trap in CLAUDE.md.
Introduce the modules/desktop/ group behind a single modules.desktop.enable
aggregator that hand-lists and turns on each piece at default priority, so a
host enables the whole desktop with one flag yet can override any single piece.
The Hyprland compositor lives in a subdirectory of the group, sourced from
nixpkgs with the program integration owning the session, portals, and polkit
and home-manager writing the config against that one package. greetd with the
tuigreet greeter provides a mouse-free text login that launches the session
through uwsm, and Ghostty is the terminal on Super+Return.
Port the operator's KDE/i3 bindings expressed entirely in hjkl and letters,
tune input (US layout, Caps->Escape, snappy repeat, touchpad taps and natural
scroll, flat mouse accel), and set a subtle feel with blur left as a host knob.
Enable the desktop on neogaia.
Add a `pi` module that turns on the home-manager `programs.pi-coding-agent`
integration for the primary user and freezes a single settings.json: the
default provider set to Anthropic, the default model to Opus (claude-opus-4-8,
Pi's own catalogue id), and analytics disabled. Everything else in
~/.pi/agent — the credential and all self-modification state — is left
unmanaged, so login survives rebuilds and backing Pi out is a one-line enable
flip. Enabled on neogaia alone.
The hook rang the terminal bell by walking the process ancestry for a
process named `claude`, but nix wraps the binary so its `comm` is
`.claude-wrapped`.
The match never fired: the bell never rang, and the loop fell through to
a non-zero exit that surfaced as a Stop hook error on every turn.
Remove the hook, its script, and the Stop and Notification wiring.
The tmux monitor-bell config is left in place as a general nicety.
Add gitea-axi as a flake input and a new modules.gitea-axi module that
installs the CLI through its home-manager module. That module also declares
the Claude Code context — the Agent Skill and the SessionStart dashboard hook
— when claude-code is enabled on the host, so neogaia gets both while a host
without the harness would get the CLI alone.
Drop the hand-written SessionStart hook from the claude-code module, which
named a gitea-axi binary nothing installed; the gitea-axi module owns it now.
Remove the vendored skill fork, which had drifted from upstream and would
collide with the copy the module writes.
`nixos-rebuild switch` alone leaves the flake implicit, which is not how
this system is built. Name it explicitly, taking the host from the
configuration that installs the shell so the two cannot disagree.
The reference stays relative, so `update` rebuilds the working tree the
shell is standing in rather than a fixed checkout.
The access policy was a separate fleet declaration mapping each machine to a
role and a client key, which the module looked up by hostname to derive what to
authorize. Authorizing a key needs the key text and nothing else, so the
per-machine names, the hostname lookup and the role existed only to reconstruct
a grouping that two lists already are.
The module now exposes those two lists and the set a machine admits, and a host
declares what it draws from in its own file. The fleet declaration is gone, and
with it a single-file directory at the repository root.
`authorizedKeys` defaults to the workstation keys. An option of a list type is
not mandatory the way a scalar one is: undeclared, it yields the empty list
rather than failing, and a machine admitting no key is unreachable. The default
makes the safe case the silent one.
Verified to produce the same system as the design it replaces, and against the
running machine in both directions.
The operator's SSH client key existed only as a file created by hand on one
laptop, so a reimage would destroy it and lock the operator out of the remote.
It now lives in neogaia's own secrets file, encrypted to the admin identity and
neogaia alone, and the client is pointed at the decrypted path rather than a
copy in the user's home.
Access becomes a policy over roles instead of a per-host list of keys. A new
fleet declaration names each machine's role and client public key, and every
machine derives what it authorizes from that: a workstation admits workstations
alone, a server admits both, so a compromised server reaches no machine of the
operator's own. Registering a machine is an entry in that one file.
Only neogaia exists, so the server half of the policy is built rather than
exercised. Two assertions reject a machine missing from the fleet and any entry
whose role no policy defines.
Defaulting the module on hid it from a host's config, leaving no single
place that lists what a host carries. Each host enables it explicitly
instead, at the cost of a step when adding one.
Default the module on rather than restating it per host, so a new host
commits without remembering the line. A host that should not carry a
personal identity sets enable to false.
Git identity lived only in one checkout's local configuration on one
machine, so it was invisible to every other checkout and lost on a
reimage. Declare it as a module instead, enabled on neogaia.
It is a module rather than base plumbing so a host that should not carry
a personal commit identity can decline it.
The sops secret declarations, the empty `hostKeys`, and the `HostKey`
lines were boilerplate sitting in the host, and every future host
restoring its identity would have repeated them.
`modules.ssh` takes the encrypted file and the key types, deriving the
secret names and the `HostKey` lines from one list, so the daemon and the
secrets cannot disagree about where a key lives. The resulting system
derivation is unchanged.
Also record two gotchas: reading PR review comments needs the Gitea API
rather than tea, and host keys are not user authentication keys.
Key sudo's credential cache per user rather than per terminal, holding it
for 60 minutes. An authentication made in the operator's own terminal then
covers commands issued by processes holding no terminal of their own, which
previously failed with a bare non-zero exit and no output.
No command is made passwordless. The password remains required; only the
cache holding it is shared, and any process running as the primary user can
spend that credential until it lapses.
A PreToolUse hook refuses a privileged command while the cache is cold,
naming the command that warms it, so the condition announces itself rather
than presenting as a stall. Both states were exercised against the running
system.