Commit Graph
100 Commits
Author SHA1 Message Date
Gabriel Brown 3d21e20041 Make every control tell the truth
The audit's first tier, in one change: every case found where the interface
asserted something the system did not do.

Twenty-one compositor-owned preferences -- the whole Mouse & Touchpad page,
plus window layout, snapping, dim-inactive and the magnifier -- had the live
half (hyprctl eval) and not the config-time half, so they quietly reverted on
every hyprctl reload. All 70 hypr-backed keys now have a prefs.get() in the
Lua, and hypr-prefs-contract pins both the presence and that the Lua fallback
equals the schema default, which is how the touchpad page misreported natural
scrolling on first boot.

The idle generator fell back from unwritten battery keys to the AC values
while the Power page displayed the schema defaults: a fresh laptop showed
"suspend at 20 minutes" and generated no suspend listener, then discharged to
zero in a bag. Unwritten keys now use the defaults the page shows
(idle-defaults-contract pins generator to schema; idle-config-contract
re-pinned to the new rule with the tradeoff recorded), and change-settings
enables managed idle on any machine with a battery -- without starting
hypridle in whatever session the installer runs under.

The per-app lock-screen notification switches wrote fields nothing read:
hyprlock cannot render notifications. Removed, with the rule model shrunk to
{enabled}, stale stored fields dropped at normalization, and the contract now
forbidding the page from growing lock-screen switches it cannot honor.

The battery warning thresholds were searchable, documented as "Found on
Power & Lock", and rendered nowhere -- and crossing the low threshold changed
only a glyph's color. Both sliders now exist where search was already sending
people, and low battery publishes a real notification at important priority.

Three handoffs opened GNOME panels that are inert in a Hyprland session. The
keyboard handoff is gone (that panel writes gsettings nothing here reads, and
the working controls sat on the same page); Connectivity gains a Wi-Fi row
that opens GNOME's actual Wi-Fi panel -- hidden SSIDs and 802.1X finally have
a road -- beside the network row that legitimately drives NetworkManager; the
universal-access handoff is gone, its few working toggles being controls this
app already owns. And the accessibility page now gives the true reason sticky
keys are missing: each Wayland compositor implements its own and Hyprland
does not yet -- not "an X11 feature with no Wayland equivalent," which sent
people to the wrong conclusion about the platform.

Claude-Session: https://claude.ai/code/session_01Epx9ZC1gwm81K3jm9x9CKh
2026-08-23 11:43:39 -04:00
Gabriel Brown dfc0c49877 Let the laptop say what it is doing: battery percentage, the lid, a fingerprint
Three surfaces the first laptop install showed were missing.

The bar's battery icon gets an optional exact number beside it -- GNOME's
"Show Battery Percentage", off by default for GNOME's reason, one color with
the icon so it reads as one indicator.

The Power page says what closing the lid does. The policy already existed
(LidPolicy holds a suspend inhibitor while an external display is connected)
but was surfaced nowhere, so the machine's most physical behavior was
undiscoverable -- and the deliberate absence of an override deserves stating
rather than leaving someone to hunt for a switch that does not exist.

And the Users page grows a Fingerprint card, because fingerprint login is two
systems that fail silently when they disagree: fprintd holds the enrolled
prints, authselect decides whether PAM ever asks the reader. This machine
arrived with a finger enrolled from its GNOME days and with-fingerprint off,
which reads as "the reader is broken". The card shows both facts, flips the
authselect feature through polkit with a stated reason, and hands enrollment
to GNOME's Users panel, which owns the only good capture dialog -- a named
exception in the handoff contract. Everything through scripts/panama-fingerprint,
pinned by a stub-driven contract.

Claude-Session: https://claude.ai/code/session_01Epx9ZC1gwm81K3jm9x9CKh
2026-08-23 11:07:37 -04:00
Gabriel Brown c2eddc2c5b Ask for the password once, and own the plugin the COPR needs
Two ways a fresh machine's unattended run could still stall. The stages call
sudo dozens of times across twenty-plus minutes, and the timestamp expires
five minutes after whichever call came last -- so any single dnf step that
outlasted it turned the next stage into a password prompt nobody was there to
answer. ./install now authenticates once, right after the interview, and a
refresher holds the timestamp for exactly as long as the script lives;
cleanup() kills it on every exit path, so nothing outlives the install with
ambient credentials.

And `dnf copr` comes from dnf5-plugins, which a machine that has used copr
carries and a fresh Workstation does not -- a missing plugin there would have
cost the entire desktop. Declared in initial-packages, where the repositories
stage can rely on it.

Claude-Session: https://claude.ai/code/session_01Epx9ZC1gwm81K3jm9x9CKh
2026-08-23 11:07:37 -04:00
Gabriel Brown 6ad2e017e8 Decide how to log out by what the session is, not what is installed
The power menu's Log Out ran `uwsm stop` whenever the uwsm binary existed --
which is always, since Panama installs it. In a plain "Hyprland" session there
is no uwsm unit to stop, so the command failed and the button silently did
nothing. Nor is `uwsm check is-active` the test: it reads
graphical-session.target, which the plain session also activates. The one
thing unique to a managed session is uwsm's own compositor unit,
wayland-wm@*.service, so that is now what chooses between stopping the unit
and asking the compositor to exit. A new contract pins the decision -- and
checks the script line rather than the file, because the comment explaining
the wrong tests has to be allowed to name them.

Claude-Session: https://claude.ai/code/session_01Epx9ZC1gwm81K3jm9x9CKh
2026-08-23 10:38:09 -04:00
Gabriel Brown 12f6b2a310 Let the contracts assert properties, not the machine they were written on
The first run of the suite on a laptop found five contracts asserting the
desktop instead of the code. settings-system pinned DP-2 at 4500x3000 in
XRGB2101010; it now asks Hyprland what is actually primary. ssh-keys hardcoded
id_ed25519; it now uses whichever key exists. switcher's live half stepped a
session with one window, which step() deliberately refuses. displays raced the
service's revert readback -- the compositor looks restored while verification
still holds busy, so an immediate apply was refused with its error already
cleared; the harness now exposes settled and the contract waits for it.

declared-dependencies gets an OPTIONAL list for docker: the aliases serve
machines that run Docker deliberately, Panama's runtime is rootless podman,
and a missing docker fails by naming the command, which is loud enough.

Claude-Session: https://claude.ai/code/session_01Epx9ZC1gwm81K3jm9x9CKh
2026-08-23 10:32:17 -04:00
Gabriel Brown 6510fdda0f Reach DDC on GPUs that are not "VGA", and stop warning about an empty dock
ddcutil's udev rule grants the seated user the GPU's i2c buses only when the
PCI class is 0x030000. An AMD iGPU that is not the primary boot display says
0x038000, so on the Framework every DDC bus stayed root-only. Ship the same
grant for the class the hardware actually reports; change-settings installs it.

And two conflations in the probe: an undocked laptop reported its normal state
as an error, and doctor collapsed every error into "No accessible DDC/CI bus".
Nothing external connected is now a clean empty -- doctor's unconfigured path
-- and a real failure surfaces the probe's own words, because an unreadable
bus and a monitor with DDC/CI off in its menu have different fixes.

Claude-Session: https://claude.ai/code/session_01Epx9ZC1gwm81K3jm9x9CKh
2026-08-23 10:32:17 -04:00
Gabriel Brown 36fdd4e076 Give the VPN a toggle, an indicator, and a way back
Turning on a WireGuard profile whose server was unreachable used to cost the
whole network stack, and the only way out was nmcli typed into a terminal.
Quickshell's Networking module has no VPN surface, so this arrives as the one
sanctioned nmcli exception: a helper that lists, raises and lowers profiles,
a service that watches NetworkManager for changes made anywhere, a quick
settings tile (left-click toggles the most recently used profile, right-click
picks among them), and a bar glyph while a tunnel is up.

The safety property is in the helper, where it cannot be skipped: activation
waits a bounded 25 seconds, and a failure is rolled back down and reported
instead of leaving a black-hole default route. The contract pins exactly that,
against a stateful stub NetworkManager.

Claude-Session: https://claude.ai/code/session_01Epx9ZC1gwm81K3jm9x9CKh
2026-08-23 10:32:17 -04:00
Gabriel Brown d865cb74a1 Never let a stale settings copy erase a saved choice
The preferences store was loaded once at shell startup, and every save wrote
the whole in-memory copy back. Anything that reached the file after startup --
a hand edit, a script, another shell instance flushing during a session
handoff -- was erased by the next unrelated save. That is how a display scale
set to 1.5 kept coming back as 2: the entry was written, then silently
clobbered, and the catch-all auto rule filled the gap.

Two halves, either alone insufficient. The store now watches its file and
adopts outside writes instead of holding a stale copy over them; and a save
merges with what is on disk rather than overwriting it -- keys this shell has
set win, keys it has never seen survive. A setting changed anywhere now
persists until it is changed again, which is the only behavior a settings
file is entitled to have.

Claude-Session: https://claude.ai/code/session_01Epx9ZC1gwm81K3jm9x9CKh
2026-08-23 10:06:38 -04:00
Gabriel Brown a95c480c6a Drop the packages Fedora 44 stopped shipping, and never let a migration wait for a prompt
Two more things the Framework's install surfaced.

gstreamer1-plugin-fmp4 and gstreamer1-plugin-mp4 no longer exist in any repo
this configuration enables — not Fedora 44, not RPM Fusion, not the Hyprland
COPR. Machines installed on older Fedora carry them as orphans, which is why
the list looked fine from one. dnf5 refuses a whole transaction over one
missing name, and desktop-packages runs above the Hyprland block, so two dead
codec entries cost the entire desktop.

The solopasha migration preferred panama-sudo, and pkexec with no polkit agent
to answer it does not fail — it waits forever, which held an unattended
./install hostage at the one stage that promised never to ask. The stated
reason is now reserved for a terminal someone is sitting at; a non-interactive
run takes plain sudo, which either succeeds quietly or fails fast enough for
panama-migrate to retry at the next login, as it promises.

Verified by running install-packages end to end on the machine that broke it:
Terra guard holds, desktop packages resolve, Hyprland 0.56.2 lands, and the
stage reaches its stepped-over report. All 22 setup contracts pass.

Claude-Session: https://claude.ai/code/session_01Epx9ZC1gwm81K3jm9x9CKh
2026-08-22 10:42:58 -04:00
Gabriel Brown 78db2d5689 Let a stepped-over step say why, and let sudo ask
soft() swallowed stderr, which hid both halves of what matters: the dnf error
explaining the failure, and the sudo password prompt on a machine that asks for
one. stdout only.
2026-08-22 09:38:41 -04:00
Gabriel Brown 669a228286 Install the desktop before anything allowed to fail, and let Terra be installed twice
Two bugs, one story: ./install on a machine that had been half set up before
produced no Hyprland at all, and said so in one line among twenty minutes of
scrollback.

Terra bootstraps itself with --repofrompath, which defines a throwaway repo id
just long enough to install terra-release. Run it again on a machine that
already has terra-release and dnf5 refuses the whole transaction -- the
throwaway id collides with the real one. That step sits above everything, so
set -e ended the stage before a single package was considered. It is skipped
now when terra-release is already installed.

The rest is the reason one failed repo cost the desktop. Hyprland was installed
near the bottom of the stage, below a codec swap, two group updates and a
GStreamer glob, any one of which can fail for reasons outside this repository.
It now installs directly after the packages it needs and before anything
optional, and everything fragile below it runs through a soft helper that logs
and continues rather than ending the run. What was stepped over is listed at
the end, because tolerating a failure only beats aborting on it if somebody is
told.

A missing Hyprland is still fatal, and now says so in words.

Also removes the leftover disabled solopasha/hyprland COPR, which would mix
with lionheartp's the moment anyone enabled it while debugging.

Fixes the usage widget reading 1500%: the endpoint reports percentages, not
0..1 fractions. Clamped as well, and the widget answers a click now -- it set
interactive:false, which disables the mouse area its own handler needed.
2026-08-22 09:36:54 -04:00
Gabriel Brown 89761a7da3 Keep the personal half of the desktop in one place, and ask before installing it
Agent instructions, skills, SSH host aliases and expansion triggers are worth
having identical on every machine one person owns, and belong in none of the
shared configuration. They live in user/ now, with a manifest saying where each
piece goes and a link-user stage that puts it there.

That stage does nothing unless the machine said yes. Somebody who clones Panama
to try the desktop keeps their own ~/.claude/CLAUDE.md exactly where it was;
the question names the destinations and defaults to no. Anything displaced goes
to config/old rather than being deleted.

~/.claude/CLAUDE.md and ~/.codex/AGENTS.md were byte-identical copies of one
file, which is the drift this exists to prevent.

Also adds the vitals toggles for the battery and Claude usage readouts, which
had preferences and no way to reach them.
2026-08-22 08:54:43 -04:00
Gabriel Brown 8b96d907a1 Show how much of the subscription is gone, without risking the session
The last of Section F, and the only thing in Panama that reads an
authentication token, so most of the design is about that rather than
about the number.

It never refreshes the token and never writes to the credentials file.
That token expires roughly hourly and Claude Code refreshes it on
demand; if this refreshed it too, two processes would be rotating one
credential, and a rotation invalidates the other holder's copy. The
failure mode is being silently signed out of Claude Code by a status
widget, which no bar indicator is worth. So it reads the token, uses it
while valid, and reports "waiting for Claude Code to refresh" when not
-- which covers the case that matters, because while you are using
Claude Code the token is fresh, and while you are not there is nothing
to watch.

The token never reaches argv either: curl takes the Authorization
header on stdin through --config, because a header passed as an
argument sits in /proc/<pid>/cmdline for the length of the request.
Same rule the password and MOK paths already follow. And it never
reaches the output: the record carries percentages and timestamps and
nothing else. Both are pinned, and both were checked by sabotaging the
collector to pass -H and watching the contract name it.

Off by default. It is a coding-tool readout, not something a
general-purpose desktop shows without being asked, and it hides unless
the collector has real numbers rather than displaying "unknown".
2026-08-22 08:33:39 -04:00
Gabriel Brown 7a5e990439 Let somebody extend this without forking it, and say when things die
Two of Section F.

Hooks are the pressure valve. "Can Panama also do X when the theme
changes" is now a five-line file in ~/.config/panama/hooks rather than
a fork, a feature request, or a patch somebody rebases forever. Each
name takes a single file and a .d directory so several things can react
without fighting over one, and a broken hook is reported and stepped
over: somebody's script must never cost a theme change, an upgrade or a
login. Wired at theme-set, post-upgrade and post-migrate. This is the
thirty-line version of the plugin host the upstream ledger defers, and
it has no API to keep stable beyond "we will run your script and tell
you what happened".

Testing it caught a real bug the reading would not have: run_one
captured the script path but never shifted it off, so every hook got
its own filename as $1 and the real arguments arrived one place late. A
hook reading $1 as the colour scheme got a path.

The crash watcher notices when a program dumps core and says so. Under
GNOME, ABRT does this; here nothing did, and applications died silently,
which is most of how "Linux is flaky" gets earned.

Once per program per session is the entire design, not a nicety. This
machine's portal backend crashes between eleven and sixty times a day,
and a notification per crash would be one every few minutes for
something nobody can act on. The first is news; the fortieth is why
people turn notifications off. The health page keeps the running count.

It waits for the notification server before reporting, because the
crash most worth hearing about is the one that took the shell with it,
and it names the executable rather than the kernel's comm field, which
truncates at fifteen characters. Verified against real segfaults.
2026-08-22 08:11:49 -04:00
Gabriel Brown 41dd91eb75 Write down where all of this came from
Section G, the closeout.

UPSTREAM-INSPIRATION gains the second and largest borrowing round: what
was taken from omarchy, end-4, caelestia, ML4W and HyDE, what was
rebuilt rather than transplanted, and -- the part that matters more --
what was declined and why. Wallpaper-derived colour is declined on
identity, because dynamic colour is how rices end up looking like each
other. Audio visualisers are declined because a continuously repainting
equaliser is the GPU-pegging animation this project avoids. Patching
application internals to theme them is declined because the maintenance
is unbounded and lands on whoever is holding the desktop that day.

DESKTOP-PARITY gains a Portable machines section, which it has never
had, because until yesterday Panama was a desktop configuration in the
code as well as on the machine. Nine rows, one of them honest about not
having run on hardware with a lid. The remaining-gaps list gains the
four things now known to be missing rather than the one.

The README stops describing a desktop from before yesterday: migrations
in the install flow, the welcome and cheatsheet and manual, and laptops
working with every surface hiding itself where the hardware is absent.

The plan records its own outcome per section, including the two places
the work departed from it: D2's template renderer deferred once D1
removed the duplication it existed to fix, and E1 inverted after using
it for an afternoon.
2026-08-22 07:48:24 -04:00
Gabriel Brown 4910bd659c Say out loud that the portal backend is crash-looping
xdg-desktop-portal-hyprland 1.4.1 segfaults in its own shutdown path:
exit() runs the CPortalManager destructor, which tears down
wlr-foreign-toplevel handles and xdg-output proxies after the display
has gone, and dies in wl_map_insert_at. systemd restarts it, so the
desktop keeps working and the only symptom is screen sharing dropping
mid-session and the occasional document-portal casualty.

Sixty crashes in one day left no trace anywhere a person would look. It
took reading coredumpctl to find, and nobody reads coredumpctl on a
desktop that appears to be working -- which is exactly the shape of
problem the health page exists for. On this machine the new check
reported twenty-eight in the last day the moment it was wired up.

Reported rather than repaired, and it says so. There is nothing to
restart that systemd has not already restarted, and no newer build in
the Hyprland COPR to install: the fix is upstream. A button that did
nothing would be worse than a sentence that explains.

Below five in a day stays green, because an upstream bug that only
bites on shutdown will occasionally bite and a health page that is
never green teaches people to ignore it.
2026-08-22 07:38:41 -04:00
Gabriel Brown 8d66247b7c Open a new one with SUPER, go to the old one with SUPER+ALT
Making the plain application keys focus an existing window was the
wrong call. It reads well in a demo and it is what macOS does, but it
made "give me another terminal" the awkward case -- and on a tiling
desktop a second terminal beside the first is the normal way to work,
not an edge case. Reaching for the launcher to open a second file
manager is not an improvement on anything.

So the plain keys do what they always did, and SUPER+ALT is the new
capability rather than a tax on the old one: go to the terminal,
editor, browser, files, calculator or mail you already have, wherever
it is, and start one only if there is none.

ALT rather than SHIFT because SUPER+SHIFT is already the
window-manipulation space -- Files, Neovim and Settings would have
collided with Focus session, Taller and Shorter, and breaking two keys
out of the eight-key resize set to make room is the worse trade.

Also fixes a real trap found while using it. The Alt-Tab overlay
commits on SUPER release, which is a compositor bind running an IPC
call; if that call ever fails to land, the overlay stayed up with no
keyboard focus, no Escape handler and nothing clickable, so the only
way out was an IPC call typed into a terminal it was covering. Clicking
outside now dismisses it, clicking a row switches to that window --
which is the obvious thing to try and did nothing -- and an abandoned
switch closes itself after ten seconds. Keyboard focus still stays with
the compositor, because taking it mid-switch is what would break
stepping.
2026-08-22 07:22:09 -04:00
Gabriel Brown 68cbf892e9 Teach the file manager to send and to shrink
Right-click a file to send it to your phone, or a video to make it
smaller. macOS has both behind the Share sheet and Quick Actions;
Windows has "Send to"; a stock Linux file manager has neither, and the
usual answer for the second one is a web uploader or an ffmpeg
incantation looked up again every time.

Neither adds machinery. Sending reuses panama-kdeconnect, the same
helper the Home & Phone page and quick settings already drive, so
there is one way to talk to a phone rather than two. The entry appears
only when a phone is actually reachable: an item that is present and
fails is worse than one that is absent, because the absence explains
itself.

The transcoder's two rules are both about not losing work. It never
writes to its input, and it never writes over an earlier output -- a
second run produces -2 rather than eating the first result. Verified
against a real encode: 1920x1080 became 854x480, with an even width
because H.264 rejects an odd one at the very end of a long encode,
which is the worst possible moment to find out.

Menus decide by mime type rather than extension, act on one file at a
time, and refuse anything that is not a local path. nautilus-python
turned out to be declared already; it now says it carries Panama's own
extensions too.
2026-08-22 06:52:04 -04:00
Gabriel Brown 05fd5346db Turn a website into an application, and notice the charger
Two of Section E's small wins.

panama-webapp gives a site its own icon, its own window with no browser
chrome, and its own launcher entry, which is most of what "installed"
means in practice and what both macOS and Windows now ship. It scrapes
the site's apple-touch-icon, falls back twice, and never fails an
install over a favicon: a web app with a generic icon still works.
Names are slugged, so "../../../../tmp/pwn" lands inside the
applications directory as tmp-pwn rather than anywhere else, and
remove refuses anything without the marker it writes -- sharing a name
with a real application must not delete that application. A browser
that cannot do app mode is told so rather than handed something that
opens an ordinary window and pretends.

The charger now announces itself through StatusEvents, which was
already the right layer and only wanted a producer. Ambient priority,
so Do Not Disturb quiets it, because a charger is exactly what DND is
for. A critically low battery is published at a priority DND does not
silence, because the one message you must not miss is the one saying
the machine is about to stop. Both join the existing silent-startup
window rather than announcing the state they found.

The keyboard-layout toast the plan also listed is deliberately not
built. Hyprland exposes the active keymap but not a change event
Quickshell already consumes, so it would need either polling or new
event plumbing, and this machine has one layout and could not test it.
2026-08-22 06:33:19 -04:00
Gabriel Brown 333ab9f6b7 Press the browser key twice, get one browser
The application keys ran their command unconditionally, so pressing the
browser key twice gave you two browsers. That is the single most common
"Linux feels wrong" moment and it is a twenty-line fix: match the
window class, focus it if it is there, launch if it is not.

Two details the obvious version gets wrong. Patterns are anchored,
because an unanchored "mail" matches gmail-notifier and the mail key
would raise somebody's notifier. And class alone is not enough here:
the terminal and the editor are both kitty, so the editor bind narrows
by title or it raises whatever terminal happened to be open. The
contract's fixture contains both traps.

Found and fixed a bug this shipped with earlier today. Hyprland 0.56
dispatches through Lua, so `hyprctl dispatch focuswindow address:0x...`
is parsed as Lua source and fails with a syntax error -- which is what
the launcher's Switch Window command had been doing since it landed.
Its own contract stubbed hyprctl and recorded the arguments, so the
call looked correct and never ran. Both now use the selector form
hl.focus actually accepts, and the contract asserts the shape rather
than only that something was dispatched.
2026-08-22 06:02:47 -04:00
Gabriel Brown e1a04d2d70 Write the eight accents down once
They were written down five times: ThemeProfileModel.js for QML,
looks.lua for the compositor, and again in panama-theme-apps and
panama-lock. The GNOME accent-name mapping was a sixth list. Adding a
ninth accent meant editing all of them, and the file most likely to be
missed was the lock screen, which fails silently -- the machine locks
in last season's colour and nothing says why. panama-theme-apps
admitted it in a comment: "there is no shared source between QML and a
shell script".

config/palette.json is that source now. looks.lua reads it through a
new prefs.readJson, which uses the same never-raise parser the settings
store uses, so an unreadable palette costs the accent colours and never
the compositor config. The two shell generators read it through
scripts/panama-palette, which also carries the hex-to-rgb conversion
hyprlock needs and the GNOME member lookup.

QML keeps its table, because a .js module imported into QML cannot read
a file. That is still a copy, so the palette contract compares the two
value by value -- every accent, every field -- and fails on any
disagreement. Verified by planting a wrong hex and watching it name the
exact field.

The adwaita contract used to check the shell's own copy of the GNOME
mapping. It now checks that the shell resolves through the palette, and
fails if that copy ever grows back.
2026-08-22 05:42:53 -04:00
Gabriel Brown 202b5b89ac Write the manual for the person using the desktop
docs/ is engineering artifacts -- design specs, plans, an upstream
ledger -- and none of it is written for the person who has to live
here. Five chapters that are: coming from another desktop, the
keyboard, windows and workspaces, when something breaks, and making it
yours.

Rendered inside Settings rather than opened in a browser, so a chapter
that says "the Displays page reverts after fifteen seconds" is one
click from the Displays page. Qt's markdown renderer does the work;
one chapter per Text, because Text has an implicit texture size limit
and a document long enough to hit it goes blank rather than
complaining.

The chapters live beside the shell in manual/ rather than at the
repository root, which departs from the plan. The reason is the path:
the shell finds them through Quickshell.shellDir, which is correct
wherever the repository is, whereas walking upward out of the shell
directory is only correct by accident. The contract fails that pattern
if it comes back.

The contract also pins the set both ways -- every chapter listed
exists, every chapter that exists is listed -- because a renamed file
shows an error card where a chapter should be, which reads as a broken
manual rather than as a moved file.
2026-08-22 01:11:46 -04:00
Gabriel Brown 9202697734 Introduce the desktop to somebody who has just met it
Thirty settings pages is the opposite of the usual problem: a person
arriving from GNOME, macOS or Windows cannot tell which few things
matter. This is those few, once, on the first start.

Not a tour. Nobody reads a tour, and a multi-step wizard on a desktop
somebody just installed is one more thing between them and using it.
One card, five keys, and a way out.

The chords come from the live keymap rather than being written here, so
a machine whose owner has already rebound something teaches what they
actually have. A welcome screen is the one surface read by somebody
with no way to tell it is wrong, which is exactly why it must not be.

Two deliberate departures from how every other surface behaves. It does
not close on a click outside, because a stray click in the first thirty
seconds would throw away the only explanation on offer. And dismissing
by any route marks it seen, Escape included, because a desktop that
reintroduces itself every login has failed to take no for an answer.
It stays reachable from the launcher afterwards, since the moment
somebody wants it again is exactly when a one-shot has thrown it away.

Also teaches the keymap to spell punctuation: slash, period, comma and
the rest were rendering as their raw keysym names, so the welcome
screen offered "Super + slash" and the cheatsheet agreed with it.
2026-08-22 01:00:18 -04:00
Gabriel Brown 9fbbdd902b Answer "what can I press" in one keypress
The Shortcuts settings page answers "how do I change this", which is
worth opening a window for. This answers the other question, the one
you have with your hands already on the keyboard, so it is an overlay
on SUPER + / and the same key closes it.

It reads Keybinds.grouped() rather than a written-down list, so a
shortcut rebound in Settings shows its new chord here with nothing kept
in sync. A cheatsheet that lies is worse than none: it gets consulted
exactly when somebody does not already know.

Three columns, balanced by how many shortcuts each category holds. The
first attempt used a Flow, which wraps into as many columns as it likes
and made 120 binds across six uneven categories unreadable; it also
sized the card from a child that filled it, which is a circular binding
and produced a card taller than the display with its contents running
off the bottom. Both were found by looking at it rather than by a test,
which is the argument for looking at it.

Fixes a real bug on the way past: luaChord and formatChord appended the
key unconditionally, so the window switcher's modifier-only release
bind became "SUPER + " with a dangling separator. That matched neither
the chord keybinds.lua binds nor the one an override is keyed by, so
that bind could never be rebound and had no category -- it was sitting
in a seventh group of its own, which is how it was noticed.
2026-08-21 23:53:50 -04:00
Gabriel Brown 6ae8265730 Say what a keybind is for, rather than guessing from its name
The Shortcuts page grouped shortcuts by matching substrings in their
descriptions, which put "Close window" and "Close the notification
list" in the same group and left anything phrased unusually in
whichever bucket matched first. The cheatsheet that comes next would
have inherited the same guesswork.

keybinds.lua says it outright now. Its sections already were the
categories, so a section sets one and the binds below inherit it: one
line per section instead of one per bind, and a new bind lands in the
category of the section somebody wrote it in without having to
remember anything.

Hyprland reports a Lua bind's dispatcher as __lua with a bytecode
offset, so nothing can be attached to a bind that survives into
`hyprctl binds`. The config writes a manifest at load instead, keyed
by the chord actually bound so the shell can join on what it sees.
Writing never raises: a read-only state directory costs the grouping,
never the keymap, and the shell keeps the old derivation as its
fallback so a machine that has not reloaded its compositor still works.

The one failure mode is a section that forgets to set a category and
silently inherits the one above. That is not hypothetical -- it
happened while writing this, because the dictation section sits in the
middle of the media binds and its category leaked onto the volume,
media and brightness keys below it. The contract walks the file for
sections with binds and no category, and spot-checks the boundaries
where inheritance is doing the work.
2026-08-21 23:30:36 -04:00
Gabriel Brown 317b7a0962 Give a reconnected display the arrangement it had
hypr/monitors.lua applies the stored per-output entries when the
compositor reads its config, and never again. A monitor plugged in an
hour later got the compositor's automatic placement instead of the
position, scale and rotation this machine was told to use, and the
only way back was to open Settings and apply it again. Docking should
not cost you your desk.

Deliberately not a confirmed transaction. applyLayout arms a fifteen
second countdown because it is about to show you something you might
not be able to undo; this restores a layout you already confirmed, on
hardware you already had, and a countdown would be asking you to
re-approve your own decision every time you sat down.

It refuses rather than guesses when the stored mode is one the
connected panel does not offer -- DP-1 on one dock is not DP-1 on
another -- and when the surviving layout would name no primary. Both
land on the compositor's automatic placement plus a toast that opens
the Displays page, which is recoverable; silence would not be. That
toast needed a new open-settings verb in StatusEvents, whose page name
goes through ShellState's existing allow-list.

The decision is split from the action as plannedRestore so it can be
tested without driving a real compositor, and the harness sets topology
and stored arrangement in one call because a real query landing between
two would replace the fixture. Both fixtures travel base64: qs ipc call
splits a JSON array of several objects into one argument per object,
so a two-monitor fixture was arriving as an extra argument.
2026-08-21 23:06:50 -04:00
Gabriel Brown 50a99a5ad0 Closing the lid at a desk is not closing it in a bag
logind handles the lid correctly except for the one case it cannot
see: an external display means a closed lid is a docked machine, not
one being put away. Its own docked test looks for an ACPI docking
station that modern hardware does not have.

Panama does not take the lid over to fix that. It holds a logind
handle-lid-switch inhibitor while an external display is connected and
releases it when the last one goes, which needs no lid watcher, no
polling, and no drop-in. The direction it fails in is the point: if the
guard dies, logind's default comes back and a docked laptop suspends,
which is annoying. A drop-in setting HandleLidSwitch=ignore plus a
watcher of our own fails the other way, leaving a lid that does nothing
at all on a machine being carried out of a building.

Locking on the way down needed no work: hypridle's before_sleep_cmd
already runs loginctl lock-session, so a lid-close suspend is a locked
suspend. The contract fails anything that duplicates it.

Not yet verified against a real lid, which is stated in the helper's
header rather than implied by silence. The decision logic, the
inhibitor's shape, and every machine that should hold none of it are
covered.
2026-08-21 22:36:36 -04:00
Gabriel Brown bc6d63b70f Let the idle timings know whether you are plugged in
An idle screen costs a screen on wall power and the rest of your
afternoon on battery, so they should not be the same number. hypridle
has no concept of a power source -- one config, one set of timeouts --
so rather than maintaining two configs and swapping them, panama-idle
builds the single config from whichever key set applies, and IdleLock
rebuilds it when the charger comes or goes. That runs through the same
400ms debounce a settings change uses, so a loose charger cannot
restart hypridle in a loop.

The battery keys fall back to their AC counterparts rather than to the
schema defaults. Without that, unplugging would silently override a
deliberately long timing with a shipped short one, which is the kind
of thing you would notice only by losing work.

A machine with no battery reads none of it and generates exactly what
it generated before. The contract pins that alongside the two obvious
directions, and was checked by sabotaging the detection to confirm it
fails rather than passing vacuously.
2026-08-21 22:18:29 -04:00
Gabriel Brown 3c359f3f7e Notice the battery, and the machine it is or is not in
Panama had no idea whether it was running on a laptop. No upower, no
battery, no lid, no AC: hypridle.conf says "This is a desktop" in its
own header, and that was true of the code as well as the machine.

panama-hw answers hardware questions one at a time, exits 0 or 1, and
prints nothing, so scripts, services and contracts all ask the same
way. The definition the rest of the laptop work hangs on is one line:
clamshell is lid-closed AND an external monitor. A machine with no
mains supply at all reports as being on wall power, because a desktop
cannot run out of it.

The battery service follows Vitals: sysfs through FileView, an
availability flag, and no subprocess on the timer. Globbing is the one
thing QML cannot do -- a battery is BAT0 or BAT1 or CMB0, mains is AC
or ADP1 or ACAD -- so panama-battery resolves the names once and the
shell reads the files directly after. Nothing falls back to a
plausible zero: a desktop shows no indicator, no card, and no charge
limit control where the firmware has no ceiling.

Also repairs two contracts that were already failing and had not been
noticed, because only the full suite runs them. The dependency
scanner treated line-initial variable assignments, case labels,
comments and heredoc bodies as commands, and `count`, `host`, `cancel`
and `import` are all real binaries on Fedora, so `command -v` could
not filter them out. It now drops comments and heredoc bodies and
requires a command to be followed by whitespace. Verified it still
catches a genuinely undeclared dependency rather than passing quietly.
The launcher command contract had not been told about the fourteen
commands added earlier today.
2026-08-21 21:43:14 -04:00
Gabriel Brown e446a1072c Give an installed machine a way to catch up
./install only ever adds. It copies over /, links dotfiles, installs
packages -- and has no way to say "remove that file", "disable that
unit", "that symlink points nowhere now". So a machine set up months
ago keeps whatever this repository has since decided was wrong, and
the only thing that ever fixes it is somebody reading a commit
message. With a curl installer in the README, that stopped being
hypothetical.

A migration is one script that performs one repair, exactly once, on
the machines that need it. Named by the commit timestamp that authored
it, so glob order is chronological without a sequence number two
branches could both pick. Marked in ~/.local/state on success and only
on success, so a repair that failed stays pending rather than being
recorded as done and hidden forever. Ordered, and stopped at the first
failure, because a later repair may assume an earlier one landed. A
fresh install marks everything without running it, the way
Migrations.qml stamps a pre-versioning settings file at its baseline.

The first real one removes the dangling ~/.config/forge symlink left
behind when the GNOME session was cut: link-dotfiles could link it but
never unlink it. Verified both ways -- a no-op on a machine that never
had it, an actual repair on one that did.

Root work goes through panama-sudo --reason so the password prompt
names the repair, and the contract fails any migration reaching for
bare sudo.
2026-08-21 21:01:31 -04:00
Gabriel Brown 3c45d63085 Plan the climb to what an operating system comes with
Seven sections, twenty-one tasks, drawn from a survey of omarchy,
end-4, caelestia, DankMaterialShell, noctalia, HyDE, ML4W and
hyprdots. Migrations come first and everything else waits on them:
Panama has a curl installer now, so it has machines that must converge
on later versions, and change-settings can only ever add.

Four findings changed the plan while writing it, and are recorded in
it. There is no existing welcome design -- the fresh-install spec is
about installer correctness. StatusEvents is already a system-toast
layer, so that task shrank to adding two producers. hypridle has no
AC concept, so per-power-source timings regenerate one config rather
than maintaining two. And display hotplug does not re-apply stored
layout, which is the actual clamshell gap rather than the one guessed.
2026-08-21 20:48:56 -04:00
Gabriel Brown 32bebc2b07 One door per name on the IPC bus
A second IpcHandler with an already-used target does not error -- it
silently shadows the first, and for the polkit target that means the
agent's authentication requests stop reaching the prompt: every
password dialog on the desktop, gone without a message. That duplicate
nearly shipped once, because the handlers live scattered through a
long shell.qml. The contract pairs every IpcHandler with its quoted
target across the shell's QML (harnesses excluded -- each is its own
root), fails on any name declared twice, and refuses to pass on an
empty scan so a declaration-format change cannot quietly blind it.
2026-08-21 20:02:25 -04:00
Gabriel Brown 12d538c4af Lock on SUPER+Backspace too
A second door to the same logind lock signal, beside CTRL+ALT+L. The
keymap tables say so too.
2026-08-21 20:01:18 -04:00
Gabriel Brown c02329ac3c Copy a password without leaving a trace of it
The launcher's Copy Password command, built alone and last as the plan
required, because every line of it is the security design: the secret
travels rbw to wl-copy through a pipe -- never argv, never a file --
and the copy carries wl-clipboard's --sensitive hint, which vicinae's
clipboard history documents it ignores. That claim was not taken on
faith: a plain probe landed in the live history database and a
sensitive one did not, before any of this was written. A transient
timer clears the clipboard after thirty seconds. An unconfigured rbw
gets a setup message; a vault that locks between list and get gets an
honest failure instead of an empty copy claiming success. rbw joins
desktop-packages, and the contract pins the whole journey with a stub
vault, including that the secret never appears on a command line.
2026-08-21 19:38:38 -04:00
Gabriel Brown 4e978bf3b7 Teach the launcher what an operating system knows
The OS-parity batch from the vicinae plan, tasks 1 through 7. The audit
came back better than the plan guessed: the calculator already links
libqalculate, the built-in file index answers in under 100ms across all
of home, quicklinks and snippets ship as built-in stores -- so zero new
packages, and `vicinae dmenu` replaces the planned compiled extension
outright. What was missing gets built: a power menu (lock, suspend, log
out through uwsm, restart, power off), reminders as transient systemd
timers with a pick-to-cancel list, a color picker over hyprpicker, and
dmenu pick-lists for window switching, force quit, kill process, SSH
hosts, and recent files -- all through one panama-pick helper. The
launcher commands contract exercises the reminder parsing and every
pick-list against stubs, including killing its own sacrificial sleep.
2026-08-21 19:32:11 -04:00
Gabriel Brown 1e430881e5 Plan the launcher's climb to OS parity 2026-08-21 19:14:20 -04:00
Gabriel Brown f42b3cfe0e Let the password prompt say why
panama-sudo is pkexec with a stated reason: the reason travels to the
shell over the existing polkit IPC target, and the prompt renders it
labeled "Stated reason (unverified)" beside polkitd's real action
message -- beside, never instead of, because any process can claim any
reason and the action text is the trust anchor. Reasons are single-shot
and expire in ten seconds, so a stale one cannot dress up an unrelated
prompt; without a reason, a running shell, or qs the wrapper is exactly
pkexec. Built for agents, so the person typing their password learns
what for. Verified live end to end -- reason shown, consumed once,
expired when stale, cleared on dismissal -- and pinned by the polkit
reason contract.
2026-08-21 18:57:26 -04:00
Gabriel Brown 51ceb19480 One command from fresh Fedora to the front door
`boot` is the script the README now leads with: curl it, and it installs
git if the machine lacks it, clones the repository to PANAMA_PATH, and
hands off to ./install -- reattaching the terminal first, because a
piped stdin would strand the interview. Deliberately dumb: a curled copy
leaves the repository the moment it runs, so nothing that can drift
lives in it. Re-running is the recovery path: an existing clone is
fast-forwarded, never re-cloned, and a refused fast-forward installs
from what is there rather than stopping mid-repair. All of it pinned by
the boot contract, against stub git and a throwaway clone.
2026-08-21 18:47:17 -04:00
Gabriel Brown 25e2328658 Your name is an answer, not a constant
The :fn/:em espanso triggers hardcoded one person's name and email in a
match file shipped to every user -- a misfire that lands inside their
own prose, where they are least likely to notice. setup-identity now
seeds match/identity.yml, per-machine and gitignored, from the same
interview answers that already configure git.
2026-08-21 17:43:33 -04:00
Gabriel Brown 9f563c8f94 Configure the Kuycon by what it is, not where it is plugged in
The panel's 4500x3000 mode, 1.5 scale, and 10-bit request were a rule
for connector DP-2 outright, which handed them to whatever monitor a
stranger's machine had on its most common DisplayPort connector. The
rule is now matched by description, the per-output prefs loop covers
every connector including DP-2, and the displays contract pins the
policy to the description rather than the port.
2026-08-21 17:43:33 -04:00
Gabriel Brown 7d633eb06e Alias only what the machine will actually have
vim aliased to vimx, which no list installed; bat invoked by its Debian
name, which Fedora does not ship; sunshine restarted by an alias when
nothing here installs sunshine. vim-X11 is now declared so vimx exists,
ff previews with bat, and the sunshine alias moved to the gitignored
env of the one machine that has it.
2026-08-21 17:43:33 -04:00
Gabriel Brown 6d1f3f3763 Keep the MOK password off the command line it was promised off of
The comment above the prompt says the password never reaches a command
line; --generate-hash="$first" put it on one, world-readable in
/proc/<pid>/cmdline while mokutil ran. It now arrives on stdin, and the
hash is taken from the last output line because mokutil prints its
prompts to stdout too.
2026-08-21 17:43:33 -04:00
Gabriel Brown de923cb4d5 Honor PANAMA_PATH in the two stages that clobbered it
install and link-dotfiles treat it as a default; install-packages and
change-settings overwrote an exported override, so a clone anywhere but
~/.local/share/Panama sourced the extras catalog from a path that does
not exist and copied config/copy from the wrong tree.
2026-08-21 17:43:33 -04:00
Gabriel Brown bd9a55c8eb Fail when failing, stop when stopped, and survive what is neither
Four installer bugs, all in the space between exit codes and intent:

- A flatpak-only extras category -- most of them -- died at the grep
  that filters out its dnf half, because grep exits 1 on zero matches
  and set -e read that as failure. sed deletes lines without editorial
  comment. The extras contract now runs a flatpak-only category under
  the installer's own strict options so this stays fixed.
- A rate-limited GitHub API call aborted the whole package stage while
  resolving the RustDesk URL, even though the empty-result fallback was
  sitting right below it. The pipeline is now guarded so the fallback
  is reachable.
- Ctrl-C did not stop the install: the INT trap ran cleanup and bash
  carried on with the remaining stages, MOK enrollment and firmware
  included. INT and TERM now exit explicitly; cleanup rides EXIT.
- change-settings and link-dotfiles ran without set -e, so a failed
  copy over / or a failed symlink fell through to guarded no-ops and
  the stage reported success. Turning strictness on immediately caught
  what it had been hiding: link-dotfiles never created ~/.config, so on
  a truly fresh HOME every symlink was failing silently.
2026-08-21 16:10:42 -04:00
Gabriel Brown cab7699711 Start the text expander that was only ever installed
espanso-wayland was installed and its config linked, and then nothing
anywhere launched it -- the RPM ships no unit, autostart.lua started
every session daemon except this one, and the seed machine only worked
because the service had once been registered by hand. change-settings
now runs `espanso service register` (upstream's own mechanism, and
unlike hypridle it conflicts with nothing GNOME runs), and autostart.lua
starts the unit so the first Hyprland login after a fresh install
expands text rather than the second.
2026-08-21 16:10:32 -04:00
Gabriel Brown 86825e7327 Judge the document portal by its mount, not by its service
No flatpak would launch. Every one of them failed in bwrap with "Can't find
source path /run/user/1000/doc/by-app/<id>", because xdg-document-portal's fuse
mount was gone -- /run/user/1000/doc was a plain empty directory. That mount is
bound into every sandbox bwrap builds, so losing it takes out all 34 flatpaks at
once, never a subset.

It had been gone for three days. The shipped unit is Type=dbus with Restart=no,
so nothing retries it on failure: after it exited 21 it came back only because a
flatpak called its bus name two seconds later, and that activation landed on the
dying instance's mountpoint and came up with no mount at all. systemctl reports
active (running) either way -- the fusermount3 helper is still sitting there as a
child, in this case for two and a half days without ever completing the mount.

Nothing running notices, which is what makes it so quiet. A sandbox needs the
mount only while it is being constructed, so everything already open keeps
working and the symptom arrives whenever you next open a flatpak you had not
opened yet. Here that was three days later, and it presented as "gearlever is
missing" -- an application that was installed, healthy, and entirely innocent.

Two changes, because there are two failures: it does not recover, and nothing
says so.

The drop-in clears the mountpoint before each start, so an activation that
follows a crash lands on clean ground. ExecStartPre rather than ExecStopPost
precisely because nothing restarts this unit -- the next start is whenever
something next wants it, and that is the moment that has to be safe. `-` because
a clean start has nothing to unmount and fusermount3 exits 1 saying so.
/etc/systemd/user rather than a per-user drop-in so it covers every account, and
change-settings reloads the user manager so it applies without a re-login.

The check asks the mount table whether $XDG_RUNTIME_DIR/doc is mounted
fuse.portal. Deliberately not a service probe, and deliberately not folded into
desktop.portals: that one asks about xdg-desktop-portal, a different service
which was up and healthy throughout. Service state is exactly the question that
lied here, so asking it again in a new place would have been no check at all.
Warning carries a restart repair, verified end to end rather than assumed.

The mount table is injectable, like every other path this script reads, so the
contract covers unmounted, wrong-filesystem-at-the-right-path, and unreadable
against written fixtures rather than against whatever /proc happens to say --
coupling the test to this machine's live flatpak state is the same mistake in
miniature. Stubbing the check to always return ok fails the contract, which was
confirmed rather than hoped.

What is not fixed is the crash itself: one occurrence, and restarting the
service to get the desktop working destroyed the evidence. The exit was 21, it
landed 21 seconds after xdg-desktop-portal restarted, and that is one sample and
not a theory. What this buys is that the next one is a two-second blip the
doctor names, rather than three silent days.

Second time for this bug. ac231ee found the same dead mount in August while
chasing "can't open Bitwarden", fixed it by hand, and recorded it as "not a
config issue, so nothing to commit there". That judgement is why it was paid for
twice, and it is the part most worth writing down.
2026-08-21 15:26:20 -04:00
Gabriel Brown 89417cd6d4 Take Claude Desktop from the repository that now carries it
It was `panama app claude-desktop`: a source build, because nothing packaged it.
Upstream publishes an RPM repository now, so the exception shrinks to adding
that repository -- and the result upgrades with `dnf upgrade` along with
everything else instead of needing a slow rebuild every time a version ships.

That moves it out of setup/apps/ and into the third-party section of
install-packages, beside Bun, Claude Code and RustDesk. It also means it is part
of ./install for the first time. The reason it was kept out was the build: slow,
wants the network throughout, and fails on an upstream that moved, which is the
failure the interview exists to prevent. A dnf install is none of those, and the
default dock pins com.anthropic.Claude, so a fresh machine was shipping a pin
for something the install had never put there.

The repository is added with upstream's own setup script rather than a .repo
file written out here. A baseurl copied into this repository is a pin by another
name; the script is the part upstream keeps correct. Both halves are skipped
when already present, so a re-run costs nothing.

The desktop entry id does not change -- it is still com.anthropic.Claude -- so
the dock pin and the notification rules carry over untouched. The new package
drops the nodejs dependency, which is what used to hide the missing-npm bug the
launcher search contract guards; its comment said "depends" in the present
tense and now says what actually happened.
2026-08-21 14:06:26 -04:00
Gabriel Brown 41b86c7a9e Pin the Claude entry that exists rather than the one that used to
The dock shipped a pin for claude-desktop, and the application it names has
since been repackaged: the entry is com.anthropic.Claude now, and only the
binary kept the old name. A pin that resolves to nothing does not announce
itself -- the dock simply has one fewer icon than the settings say it should.

Found by the dock pins contract, which exists for exactly this and had been
passing until the application updated underneath it.

Claude-Session: https://claude.ai/code/session_01Q84axqUE5inJhf5Jz9CFy1
2026-08-21 13:56:47 -04:00
Gabriel Brown 185d7edaa5 Open the windows you open together, together
Arrange the desktop, run "Save Layout as Project" from the launcher, name it.
"Open Project" lays it out again on free workspaces, so it never lands on top of
what you are already doing. Saved layouts are listed on the Desktop settings
page, which is where they are removed.

Recorded rather than written by hand, and a terminal's directory is most of why
it is worth having: without it a project opens three terminals in your home
folder and you change directory three times. This machine had two terminals in
the same project when it was written, and reopening there is the difference
between a layout and a working desktop.

Four things had to be found by running it, none of which reading would have
shown.

A terminal's directory is not the terminal's working directory -- that is where
it was launched from. The shell inside it is what has been cd'd. Reading the
wrong one looked correct for exactly as long as the terminals under test had
been started from the right place, which they had.

gtk-launch cannot place a window. It activates over D-Bus, so the process
Hyprland started exits and a [workspace N silent] rule has nothing left to apply
to; Nautilus landed on whatever workspace was in front of you. The Exec line
from the desktop entry is run directly instead. But DBusActivatable
applications do the same thing regardless, so the window is found afterwards and
moved by address -- which also means a window that never appeared is reported
rather than assumed.

/proc/PID/task/PID/children is a file of pids, not a directory. Listing it as
one always raised, so the fast path was never once taken and everything went
through pgrep.

And kitty's --directory needs an equals sign or the short -d; the
space-separated long form is accepted and silently ignored.

The contract exercises a save and open round trip against a stubbed compositor,
and reads a terminal's directory out of a real process tree rather than grepping
the source for a shell name -- an earlier version passed against a helper that
had been changed back, because the constant was still there.

Claude-Session: https://claude.ai/code/session_01Q84axqUE5inJhf5Jz9CFy1
2026-08-21 13:45:16 -04:00
Gabriel Brown 538c0a887c Save things where the rest of the software already saves them
Screenshots and recordings offered three folders to choose between, and three
guesses cannot include the folder somebody's other software already writes to --
which is the only folder that matters. This machine has had ~/Pictures/Screenshots
and ~/Videos/Screencasts since long before Panama, and Panama was writing
recordings to a Videos/Recordings it invented. Both are free text now, and the
recording default is the folder that was already there.

Wallpapers were swept from four directories at once, so the distribution's stock
images arrived mixed in with the user's own and there was no way to ask for just
one. Where wallpapers live is something somebody knows about their own machine.
It is a setting, not a search.

All three accept an absolute path as well as one relative to home, which meant
fixing Capture: it prefixed $HOME unconditionally, so naming /mnt/captures would
have written screenshots to ~/mnt/captures and left nobody able to find them.

The generator turned out to skip any entry whose comment sits inside the braces
rather than above them -- it looks for `key:` immediately after `{`. Three
settings were invisible in the reference because of it, one of them dockScreens,
which has never appeared there at all. The staleness contract could not see it
either: regenerating reproduced the same omission, so the copy was current and
incomplete at once. It now counts what was declared against what it could read
and refuses rather than quietly documenting less than exists.

Claude-Session: https://claude.ai/code/session_01Q84axqUE5inJhf5Jz9CFy1
2026-08-21 12:57:47 -04:00
Gabriel Brown 033d5b21f8 Build the launcher's extension on a machine that has only nvm
The search extension is compiled, and the stage that compiles it checked for npm
and skipped quietly when it found none. On this machine it always found one --
but only because Claude Desktop depends on nodejs and dragged a system npm in.

Nowhere else would. Node moved to nvm when the shell config turned out to have
been assuming it for months, and nvm is a shell function in a file only an
interactive shell sources; a stage is not one. So a fresh install would have set
up the launcher, printed one line about extensions not being built, and left
somebody wondering why typing in it suggested nothing.

The stage sources nvm before looking, and the contract pins that it does --
checking for npm is not the same as being able to find it.

Claude-Session: https://claude.ai/code/session_01Q84axqUE5inJhf5Jz9CFy1
2026-08-21 12:31:34 -04:00
Gabriel Brown 7cd4131327 Hold a key, speak, and the words are typed
Super+D holds the microphone open, releasing it transcribes on the GPU and
types the result wherever the cursor is. Roughly 150ms for a normal utterance
once the model is resident, measured rather than hoped for.

Getting there meant discarding two approaches. Fedora 44 cannot install any
GPU-capable Whisper for Python -- openai-whisper needs a numba that needs an
llvmlite that does not exist for 3.14, and faster-whisper needs a ctranslate2
nobody packaged. The whisper-cpp package IS built with HIP but ships libraries
with no binary and no bindings, and hand-writing ctypes for a large by-value
struct is a segfault waiting for a version bump. So a container, as suggested.

Vulkan rather than ROCm, and upstream's image rather than one built here. ROCm
is seven gigabytes and serves AMD alone; Vulkan compute runs on the AMD, Intel
and NVIDIA machines this config is used on, in a twentieth of the space. The
Vulkan tag already contains whisper-server, so there is no Containerfile to keep
working -- an earlier draft of this commit had one, and it was strictly worse.

Two bugs found by using it rather than by reading it. Whisper describes silence
as the literal text "[BLANK_AUDIO]", and the first working version pasted that
string into the clipboard; a transcription that is nothing but such markers is
now discarded. And the server answers with a line per segment, which typed into
a window is an Enter press -- sending the half-written message, submitting the
form. Whitespace is collapsed to one line.

Neither the image nor the model is installed by ./install. Together they are
over two gigabytes that want the network, and Settings offers both as one
action instead. Nothing starts at login either: whisper-server holds the model
from the moment it starts, so the first press of the key is what brings it up.

The contract pins both text bugs, that the server stays on loopback, and that it
does not start at login. Reverting the [BLANK_AUDIO] guard did not fail it at
first -- the check was still correct, it had simply stopped being called -- so
it now checks the call site too.

Claude-Session: https://claude.ai/code/session_01Q84axqUE5inJhf5Jz9CFy1
2026-08-21 12:22:19 -04:00
Gabriel Brown b280bd02d7 Let the Home Assistant contract skip a server that is not there
It already meant to skip when no token was configured, and could not: the helper
exits 2 for any catalog it cannot complete, so `set -e` aborted at the capture
before the skip was reached. The contract failed with no output at all, which
reads as a crash rather than as a skip, and the branch written to prevent that
had never once run.

So the status is taken deliberately, and there are now two skips rather than
one. No token is not a defect in Panama. Neither is a bridge that does not
answer -- off the network, VPN down, the server asleep -- which is the same
reasoning the extras contract already uses for dnf and Flathub being
unreachable.

Only "unreachable" skips, which the helper raises exclusively for a timeout or a
socket error. A bridge that answers and refuses still fails: authentication
rejected, a bad response, a catalog of the wrong shape. Those are what this
contract is for, and all seven paths were exercised against a stubbed helper to
confirm which fail and which do not.

129 contracts pass, with none of them red for a reason nobody intends to fix --
which was the point. A suite expected to be red stops being read.

Claude-Session: https://claude.ai/code/session_01Q84axqUE5inJhf5Jz9CFy1
2026-08-21 02:56:24 -04:00
Gabriel Brown 4c77bc2f61 Decide whether the other screens join in on workspaces
GNOME's Multitasking panel asked one workspace question worth reproducing, and
it is not which workspace goes on which screen. It is whether the second screen
participates at all: workspaces on the primary display only, or each screen with
its own. Ten rows of per-workspace assignment would be more powerful and worse.

Off is Hyprland's own behaviour and emits nothing. On pins workspaces 1 to 10 --
however many ALT+1..ALT+0 actually reach, read from keybinds.lua rather than
written down twice -- to whichever output is recorded as primary. With no
primary recorded, nothing is pinned: guessing one would move every workspace
onto whichever output happened to sort first, and this machine is in exactly
that state.

Applying is a reload, which is the part that shaped the design. Hyprland reads
workspace rules at config time and will not remove one afterwards -- a rule
written with an empty monitor keeps its old binding, which was checked rather
than assumed. Only a reload clears them, so the config is the only honest source
and the page cannot pretend a change has landed before one happens. Hence a
service that reads `hyprctl workspacerules` back rather than inferring success
from having written the preference, and a Reload row that exists only while the
two disagree.

Verified end to end against the live compositor and put back: off emits nothing,
on emits ten rules naming the primary, and turning it off clears them. The
settings file came back byte-identical.

Claude-Session: https://claude.ai/code/session_01Q84axqUE5inJhf5Jz9CFy1
2026-08-21 02:16:29 -04:00
Gabriel Brown 9092a80f66 Search from the launcher, and give the touchpad something to do
Four things a Hyprland desktop can do that this one was not.

Searching from the launcher needed no launcher work at all: Vicinae already
models it, so this is a script command with one percent-encoded argument. Make
it the fallback command and anything typed that matches nothing else offers to
search it. Bangs come free -- they are a property of where the query is sent,
not of the launcher -- so !yt reaches YouTube without a line of bang parsing.

Suggestions could not be a script command. They need a view that reacts as you
type, which is an extension: TypeScript, compiled, querying the same endpoint
Firefox's address bar uses. It debounces, and aborts the request in flight on
every keystroke -- typing is faster than the network, and an older answer
landing after a newer one leaves the list describing a query that is no longer
on screen. A bang skips suggestions entirely, because Google has no useful
guesses about "!yt".

The engine is now written down twice, once in each. The contract pins that they
agree, since searching from the fallback and searching from the suggestions
reaching different places is the kind of wrong that looks fine.

Gestures mirror GNOME: three fingers sideways for workspaces, up for the
overview, down to dismiss it. Open and close rather than toggle both ways --
toggling means swiping up from an open overview closes it, which is not what the
fingers meant. Hyprland reads gesture registrations at startup so they cannot be
a setting, but distance and direction can be, and are.

Window swallowing is off by default and a preference like every other misc
setting here. A terminal that vanishes when you did not ask for it is confusing
rather than broken, which is worse.

Claude-Session: https://claude.ai/code/session_01Q84axqUE5inJhf5Jz9CFy1
2026-08-21 01:49:29 -04:00
Gabriel Brown 62dce86b4e Let the prompt survive not being installed yet
oh-my-posh moved from a curl installer to a Fedora package, and the shell config
invoked it unconditionally. That is fine during a full install, where
install-packages runs before the bashrc is linked -- and not fine in every other
order: a stage re-run by hand, an install that failed partway, or the moment
between removing the old binary and installing the package.

Same guard as the nvm source above it. An unthemed prompt is a worse shell; one
printing command-not-found before every prompt is a machine that looks broken.

Claude-Session: https://claude.ai/code/session_01Q84axqUE5inJhf5Jz9CFy1
2026-08-21 00:45:15 -04:00
Gabriel Brown 725e274ef4 Install the Node this shell config has always assumed
config/bash/shell sources /etc/profile.d/nvm.sh, switches Node per project from
.nvmrc, and puts PNPM_HOME on PATH. None of it worked on a fresh machine. nvm
was never installed -- it is a Terra package, present here since before Panama
-- and the source was unconditional, so every shell on a new box opened with an
error before it got as far as failing to find nvm.

That is the second instance of the same bug. $HOME/.cargo/env was the first, and
fixing it one file at a time is why this one survived: the dependency contract
scanned setup/scripts, bin and the quickshell helpers, but never config/bash --
the one place in this repository whose entire job is to name tools and source
the files that provide them.

So it scans it now, and checks the shape rather than the instance: a literal
path sourced without testing it exists is a finding, wherever it appears. It
found the nvm line, and authselect behind the fingerprint aliases.

Node and pnpm move to nvm with it. They were declared as dnf packages while the
machine ran them from ~/.nvm, which is not a preference so much as a
contradiction -- a system Node earlier on PATH wins every `nvm use`, so the
per-project switching this shell config sets up could never have worked. nvm
install --lts, then pnpm inside it, so pnpm travels with the Node version it
belongs to instead of outliving it.

Claude-Session: https://claude.ai/code/session_01Q84axqUE5inJhf5Jz9CFy1
2026-08-21 00:30:48 -04:00
Gabriel Brown f09763ef5d Check the two facts the README states about itself
It claimed 121 contracts when there were 125, one day after the number was
written, and it documented every panama subcommand except the one added last --
so `panama apps` existed and the README did not mention it.

A number in prose is worth something as a claim somebody relies on and nothing
once it is wrong, so it is either checked or it should not be there. This checks
it, counted the way the runner collects the suite rather than by a second idea
of what a contract is, and checks that every subcommand the README documents is
one the dispatcher actually handles -- a listed command that errors reads as a
broken install rather than a stale document.

Claude-Session: https://claude.ai/code/session_01Q84axqUE5inJhf5Jz9CFy1
2026-08-21 00:11:04 -04:00
Gabriel Brown 215da285f3 Let applications be chosen a few at a time
The catalog held fourteen applications. This machine runs thirty-four flatpaks,
so most of what is actually used had no way to be installed from here at all --
Zoom, Slack, Obsidian, Spotify, LibreOffice, OBS and its sixteen plugins.

So the catalog is seeded from the machine, and `panama apps` opens it: pick a
category, tick what you want, install just those. ./install still offers the
same catalog as whole categories, because during a first install you want coarse
and fast. Both read setup/lib/extras-catalog. Two parsers would eventually
disagree about what a category contains, and the one that disagreed quietly
would be the one that runs unattended.

Two pieces of syntax earn their keep. A `| Name` suffix gives the menu something
readable, since com.obsproject.Studio is not a name anybody wants to pick from a
list. An indented line belongs to the entry above it, which is how OBS carries
its plugins as one thing to tick rather than seventeen -- they are extensions of
the flatpak, useless alone.

That is also why creative moved from dnf to Flathub: the plugins attach only to
the flatpak, so the dnf build cannot have them. The rest of the category
followed rather than leave one machine with GIMP from dnf and its neighbour from
Flathub.

The contract now reads the catalog through the same parser instead of keeping a
third idea of the format, and checks the two things this syntax can break
silently: a label leaking into an install command, and a bundle that installs
the application without its plugins. It caught a typo in the Pixelorama id on
the first run.

It also got slow enough to be worth fixing -- fifty-one names, each its own
network call. One bulk query per manager took it from minutes to four seconds.
That query needs `flatpak remote-ls --all`: without it, end-of-life applications
are hidden and read as missing, which reported yuzu as gone from Flathub when it
installs perfectly well.

Claude-Session: https://claude.ai/code/session_01Q84axqUE5inJhf5Jz9CFy1
2026-08-20 23:58:36 -04:00
Gabriel Brown 48f7c1e962 Name the Gaming page, and stop the docs inventing names
The settings reference is generated, and a contract already fails when the
committed copy is stale -- so it was current. It was also wrong: the Gaming page
was documented as "Found on **gaming**" while every other group named a real
page, because a routed page with no entry in PAGE_TITLES fell back to printing
its own id.

The staleness contract could not see it. Regenerating reproduced the same wrong
file, so the copy was current and wrong at the same time -- a check that compares
output against itself cannot catch a generator that is confidently mistaken.

So the fallback is gone. A routed page with no title now refuses to render and
says which page needs one, which is what makes the next page added here
impossible to miss.

Claude-Session: https://claude.ai/code/session_01Q84axqUE5inJhf5Jz9CFy1
2026-08-20 23:32:46 -04:00
Gabriel Brown f457c1eb9f Build the two applications nobody packages, on purpose rather than in passing
Claude Desktop and ChatGPT Desktop ship for macOS and Windows. The Linux path for
both is a community wrapper that converts the official build into an RPM -- so
what lands is still a package dnf owns and can remove, which is the part of the
dnf/flatpak rule that actually matters. What they need an exception for is the
build itself, and there is no packaged form to prefer over it.

`panama app` builds one by name, and is deliberately not part of ./install. A
source build is slow, wants the network throughout, and depends on an upstream
that moves -- twenty minutes in, an error, with nobody at the keyboard, which is
the exact failure the interview exists to prevent. Asking for one is something
you do on purpose, and it is also the rebuild path when a new version ships.

Nothing is pinned. Each build takes the current default branch and the current
upstream release, and reports a failure rather than working around it, leaving
the tree where the error can be read. sunhat pinned versions and every pin was a
404 within a release cycle.

Adding one is adding a file to setup/apps/, and the file has to say why the
exception exists -- the contract fails a definition that does not, because the
guard against this list growing by habit is having to write the reason down.
sunhat had seventy-odd installers and a reason recorded for none of them.

The contract had a bug worth recording: `while read` on the right of a pipe runs
in a subshell, so two of its three per-definition checks recorded findings into
an array that went out of scope at the end of the loop. It reported PASS on a
definition with no description and no build function. Found by standing one in
deliberately and noticing only the third check spoke up.

Also: nautilus-open-any-terminal is now declared, and Panama's copy of the
extension is gone. Fedora packages that extension AND its gsettings schema, and
Panama shipped its own fork of the .py over the same path while declaring
neither -- so a fresh machine got an extension whose schema did not exist. It
worked here only because the RPM has been installed since sunhat. The fork was
also 63 lines behind the packaged version, missing its newer Nautilus and Caja
handling.

Auditing the rest of config/copy for the same shape found nothing else: dnf.conf
is a config file its package expects to be replaced, and the GPU udev rules are
Panama's own.

125 contracts pass.

Claude-Session: https://claude.ai/code/session_01NvgBuSWB5sE43yWmg21ozj
2026-08-20 23:00:21 -04:00
Gabriel Brown d61792433c Merge the Panama roadmap completion work
Six commits, rebased onto main and integrated with where main had moved in the
sixty-two commits since the branch was cut.

What lands: a theme-profile system, so a curated accent and a saved custom theme
become the same kind of record; a dock context menu; contextual Settings routes
from the dock, notifications and the OSD; overview-thumbnail capture reliability;
and wallpaper and lock-screen hardening -- which needed the shipped wallpaper
that the fresh-install work made exist, and was the dependency that kept this
branch parked.

Where main had moved further along the same path, main won: the focused border
driven by the chosen accent, the gradient built through the shared serializer,
the multi-edge dock. The branch's work sits on top of those. The one thing that
had to be carried across by hand was the `gnome` member on each accent, which is
what recolors libadwaita applications; it moved into the curated table rather
than being lost with the table it replaced.

One behaviour change worth knowing: the OSD now accepts pointer input, where it
previously accepted none. A secondary click on a visible OSD opens its settings,
and a Wayland region cannot admit one button and refuse another. The region stays
bounded to the OSD's own card.

124 contracts pass.

Claude-Session: https://claude.ai/code/session_01NvgBuSWB5sE43yWmg21ozj
2026-08-20 22:36:41 -04:00
Gabriel Brown 6016efa436 Meet main where it has moved since this branch was written
The rebase itself is the previous five commits replayed onto main; this is what
they needed once they landed there, kept separate so the replay stays readable.

The shell did not start. A Column in this branch's notification settings menu
assigned its own implicitWidth and implicitHeight, which a Qt 6 positioner
computes and does not let you set. That took out every contract that launches a
shell -- six of the seven failures were this one line, and none of them said so
until the error was read to the bottom. A Column already measures itself from its
children, so the bindings are simply gone.

Three assertions pinned an implementation main has since replaced, and each is
updated to pin the intent rather than the mechanism:

  - The display picker now reads primaryFirstMonitors, which is monitors sorted
    with the primary first. Still populated from what is connected, which is what
    the contract is for; the sorted list is the point, so the picker opens on the
    display somebody is most likely to mean. This branch made that change and
    broke its own contract without noticing.
  - The accent swatches come from the accentName schema rather than
    Object.keys(Theme.accents). Same swatches, same order, one source shared with
    every other enum row.
  - The OSD used to take no pointer input at all. It takes some now, because this
    branch's own design calls for a secondary click on a visible OSD to open its
    settings, and a Wayland input region cannot admit one button and refuse
    another. The rule that survives is that the region stays bounded to the OSD's
    own card: it floats over other windows for a couple of seconds, and a region
    bigger than the card would swallow clicks meant for something underneath.

Theme's accent table moved to ThemeProfiles, which is this branch's point -- a
curated accent and a custom profile become the same kind of record. main had
meanwhile given each accent a `gnome` member, the nearest name in GNOME's fixed
accent-color enum, which is what makes libadwaita applications recolor instead of
staying in GNOME blue. That member moved into the curated table rather than being
dropped, and adwaita-accent-contract now reads it where it lives.

Where main had simply moved further along the same path, main won: the focused
border driven by the chosen accent rather than a hardcoded pair, the gradient
built through the shared serializer rather than a hand-rolled string, the
multi-edge dock geometry. This branch's context menu, keyboard focus and
accessibility work sit on top of those rather than beside them.

Three new contracts arrived carrying .sh and lost it, along with the references
in this branch's own plan.

124 contracts pass.

Claude-Session: https://claude.ai/code/session_01NvgBuSWB5sE43yWmg21ozj
2026-08-20 22:23:27 -04:00
Gabriel Brown ed428e87c4 Complete the Panama theme system 2026-08-20 22:03:08 -04:00
Gabriel Brown 69ecec7ecf Complete contextual desktop controls 2026-08-20 22:00:53 -04:00
Gabriel Brown a90f6eb357 Fix wallpaper scan cap exit status 2026-08-20 22:00:26 -04:00
Gabriel Brown c4642919f7 Close desktop safety gaps 2026-08-20 22:00:26 -04:00
Gabriel Brown c4733e0624 Plan Panama roadmap completion 2026-08-20 21:58:25 -04:00
Gabriel Brown e1faaf7a76 Drop the extension, and give the test suite a front door
Phase 6, the last of the fresh-install spec.

159 scripts lose their .sh: 110 contracts, 47 Vicinae commands, 2 compositor
contracts. A shebang and the executable bit already select the interpreter. The
extension only ever added something that had to stay in sync, and the rename
proved the point twice over in the space of an hour.

The spec's stated risk was Vicinae's script discovery. One script was renamed and
reloaded on its own before the other 46 followed; it came back as
scripts:panama.capture and all 47 resolve. What the probe turned up instead is
that the extension was never only a filename: Vicinae's command IDs embed it, so
every ID changed. Nothing in this repository refers to them, so nothing breaks.
The only trace is Vicinae's metadata.json, whose visited map had two Panama
entries that are now orphaned -- two commands lost their usage ranking and will
earn it back. Worth knowing before anyone renames these again on a machine that
has a keybind pointing at one.

Rewriting the references by exact filename missed two things it structurally
could not see: a name built from a variable, settings-$page.sh, and a glob,
-name '*.sh'. Both were in the contract that counts the generated commands, which
promptly reported 47 expected and 0 found. The mechanical part of a rename is the
part that looks finished.

The three subcommands. panama doctor fronts a health check that already existed
and already ran at the end of every install but could not be reached from a
terminal. panama upgrade re-runs the installer from anywhere. panama test runs
the suite, which had no entry point at all -- 121 files that were the main safety
net in this repository and were invisible in it.

Writing that runner found three tests nothing was running.
calendar_agenda_bridge_test, home_assistant_bridge_test and kdeconnect_bridge_test
are unittest suites without the executable bit, so no contract invoked them and
the first draft of the runner skipped them silently. All three pass, and have
passed unobserved for weeks. The runner collects *_test.py as well now, because a
runner with a blind spot is worse than no runner for the same reason a dependency
checker with one is: it reports PASS.

Six worktrees pruned. Each was re-checked rather than trusted to the spec's list,
and two needed it: panama-commands is not on feat/panama-commands but on
feat/gnome-tweaks-parity, and fix/panama-displays-review reads [ahead 3] -- ahead
of its remote, not of main, with every commit patch-equivalent to landed work.
roadmap-completion stays; it has five commits that are genuinely unlanded. The
branches are left alone: pruning a worktree costs nothing, deleting a branch is a
decision.

121 contracts pass.

Claude-Session: https://claude.ai/code/session_01NvgBuSWB5sE43yWmg21ozj
2026-08-20 21:55:55 -04:00
Gabriel Brown 47f29f9fa9 Stop describing a desktop this repository does not install
Phase 5. The README advertised two desktops that coexist -- GNOME with Forge,
Dash-to-Dock, Openbar and Vitals, alongside Hyprland -- and nothing in setup/
installed or configured any of the first one. Panama configures one desktop, and
now says so.

config/dot/forge is deleted along with its entry in link-dotfiles. It was the
hedge from when the GNOME session was still the fallback and Hyprland was being
built beside it; the hedge has been paid off. Nine files, six of which were
Forge's own editor backups that should never have been committed.

Searching for the rest of GNOME found nothing else to cut, which is recorded in
the spec so nobody goes looking again. change-settings never enabled an
extension. The mentions of Dash-to-Dock, Openbar and Vitals through the shell are
comments saying what a component was modelled on -- which intellihide behaviour
the dock reproduces, where a colour came from -- and DESKTOP-PARITY.md is the
table of what replaced what. That is provenance, and it is the reason those
components behave the way they do. Vitals in services/ is Panama's own bar
service and merely shares a name with the extension it replaced.

The handoff panel list was wrong in two places. It named Wacom, which nothing
hands off to, and Region, which is a subpage of System rather than a panel. The
nine real ones are read off the call sites and the allow-list that gates them.
The spec said it, the comment on gnome-control-center repeated it, and the README
would have made it three.

One test gap turned up and is closed. The assets contract caught a directory
under config/dot/ that nothing links, but not the inverse: a name left in the
dirs array with nothing behind it, which makes link-dotfiles point ~/.config/<name>
at a path that does not exist. Deleting Forge is the exact move that introduces
that, and nothing would have failed if the array entry had been left. A dangling
symlink is worse than a missing one, because everything that looks there finds
something. Verified by putting the entry back and watching it fail.

The audit of docs/settings.md this phase asked for needed nothing: it is generated
from PreferenceSchema.qml, a contract already fails when it goes stale, and it
carries no claim about GNOME or Forge. The README gained the section it was
missing instead -- the 121 contracts under tests/ were the main safety net in this
repository and went entirely unmentioned in it.

Claude-Session: https://claude.ai/code/session_01NvgBuSWB5sE43yWmg21ozj
2026-08-20 21:33:57 -04:00
Gabriel Brown 88497826ec Let a machine say what it is for, and give Firefox its face back
Phase 4: the optional application categories, and the Firefox chrome.

Everything Panama installed until now was what every machine gets, which meant a
work laptop acquired emulators and a desktop that wanted Steam had to be told
about it by hand. The interview now offers the categories in
setup/packages/extras/ as a checklist -- gaming, creative, communication,
virtualization -- and nothing is preselected, because a default here installs
applications nobody chose on a machine whose owner answered a question they
thought was about something else.

A category is one file, and a category mixes both package managers because the
applications do: Steam is in RPM Fusion, Slack publishes only a flatpak. So a
bare line is a dnf package and a flatpak: line is a Flathub ID, and one file
holds the whole answer rather than splitting each category across two. The menu
is read from the directory rather than written down, so adding a category is
adding a file. Every name in all four was resolved against the actual
repositories before being written down, and the contract re-resolves them --
the point of admitting applications one at a time is that they stay installable,
and a typo here fails on somebody else's machine, not this one.

Firefox is declared, and its chrome is Edge-Frfox, vendored into config/firefox.
sunhat carried that theme with no license and no attribution; it is MIT, and now
it says so and says whose it is.

It is the only piece of Panama's configuration that does not go to a path this
repository chooses. Firefox owns the profile directory, names it with a random
salt, and does not create one until the browser has been run -- so link-dotfiles
finds or creates a profile and links both halves into it. Both, or neither works:
chrome/ is the CSS and user.js sets the preference that makes Firefox read chrome/
at all, without which the theme is a directory of dead files.

Two assumptions there were wrong, and the contract exists for both. Firefox has
moved to the XDG directories -- the profile root is ~/.config/mozilla/firefox on
this build, not ~/.mozilla/firefox, and writing to the wrong one themes nothing
and says nothing about it. And -CreateProfile turns out to be non-interactive, so
a fresh machine gets the theme on the first install rather than the second. The
contract runs link-dotfiles for real against a throwaway home with no profile in
it and looks at what came out; it was checked by pointing the search at the
legacy path only and watching it fail.

Also: the enrolment/enrollment spellings from the last commit are corrected. This
repository is US-spelled everywhere else -- color 1131 times against colour never
-- and consistency in prose is worth as much as it is in code.

Claude-Session: https://claude.ai/code/session_01NvgBuSWB5sE43yWmg21ozj
2026-08-20 21:24:15 -04:00
Gabriel Brown b319d1a5e1 Stop handing dnf the comments that explain the package lists
Every list in setup/packages/ is annotated -- which package exists for which
settings page, why an exception was made -- and install-packages passed the whole
file to dnf, comment lines included.

dnf does not ignore an argument it cannot match. It reports "No match for
argument: #" and exits 1, and with set -euo pipefail at the top of that script
the first annotated list ends the stage. initial-packages carries four comments
and is the first list read, so a fresh machine got the repositories, the group
updates, and then nothing.

Two things hid it. On a machine that already has everything, a re-run matches
every real name and fails only on the comments, so the failure looks like noise
rather than the stage dying. And every contract that reads these lists strips
comments with sed before comparing -- the tests were reading a file the installer
was not, which is why a repository with a dependency contract, an assets contract
and a doctor still reported PASS across the board.

The fix is one filter used at all five call sites. The contract lifts that filter
out of the script and runs it, rather than describing what it should do, so
deleting or renaming it fails here instead of passing quietly. It also checks the
inverse -- that stripping comments does not strip packages -- because trading a
loud failure for a silent one would be worse than the bug.

Found while adding the extras lists for phase 4, which are annotated the same way
and would have hit the same wall.

Claude-Session: https://claude.ai/code/session_01NvgBuSWB5sE43yWmg21ozj
2026-08-20 21:11:44 -04:00
Gabriel Brown 13f3648e4d Install the driver, enrol the key, and still never ask twice
Phase 3 of the fresh-install spec: the parts of a run that depend on what the
machine actually is. NVIDIA, Secure Boot, Fedora's preinstalled extras, firmware.

Two of these looked like they would force a compromise, and neither did.

sunhat opened an editor in the middle of its run so grub could be hand-corrected,
and that single step is why walking away from an install did not work. The step
existed to delete duplicated kernel arguments -- and grubby replaces an argument
that already exists rather than appending a second copy, so the duplicates cannot
accumulate and there is nothing to correct. The editor was load-bearing for a
problem that a different tool does not have.

MOK enrolment needs a password now and the same password at the next boot's blue
screen, which reads like a prompt that has to happen mid-run. mokutil has
--generate-hash and --hash-file for exactly this: the interview asks, hashes it
on the spot, and records only the hash. The plaintext never reaches the answers
file, the environment, or a command line, and the stage runs without asking.

The stage runs last rather than fourth as the spec's table had it. The constraint
was always "late" and fourth of eight is not late: enrolment arms a prompt for the
next boot and firmware may want a reboot, so a machine that reboots out of this
stage should already be completely configured.

Every question names what was found -- the card, the packages actually installed
-- and is not asked at all on a machine it would do nothing to. sunhat's debloat
list no longer describes Fedora 44: totem became showtime and LibreOffice is not
preinstalled, so the list is curated and a package that is not installed is never
passed to dnf, which is what lets it outlive a release.

This stage cannot be verified by running it. It installs a proprietary driver and
queues a Secure Boot enrolment, and this machine is an AMD desktop. So every
privileged command is stood in on PATH and the contract asserts which answer led
to which call: that no answers means no commands, that a failed driver install is
not followed by arguments and services for a driver that is not there, that the
hash reaches mokutil through a file and never a command line, and that removal is
offered only for packages that are installed. The contract was checked by breaking
the stage three ways and confirming it caught each. It does not verify that
akmod-nvidia builds, and says so where a reader would otherwise assume it did.

The README's stage table listed three of seven stages; the interview and identity
work never reached it. Corrected rather than extended, since a table that lists
three of seven is worse than one that lists none. The Desktops section still
describes a GNOME session nothing installs -- that is phase 5.

Claude-Session: https://claude.ai/code/session_01NvgBuSWB5sE43yWmg21ozj
2026-08-20 19:36:53 -04:00
Gabriel Brown 359fb922aa Install the four applications this desktop assumed you had
Helium was already declared. Podman Desktop is on Flathub, so it joins the
flatpak list beside the podman it fronts.

RustDesk was the interesting one. panama-doctor has checked `rustdesk.service`
for as long as it has existed, and autostart.lua works around the tray that
service spawns -- so RustDesk was already part of this desktop, installed by
nothing. The flatpak cannot register a root-owned system service, so unattended
access needs the RPM.

Claude Code has no RPM and no flatpak either, so it takes the official installer
and keeps itself updated afterwards.

Neither pins a version. sunhat pinned upscayl 2.11.5, LACT 0.5.4 and a fedora-40
RPM, and every one of those was a 404 within a release cycle; the RustDesk URL is
resolved from whatever the latest release happens to be. Both are skipped when
already present, and a failure is logged and stepped over rather than aborting a
stage that has already installed the desktop.

That leaves three exceptions to the dnf-or-flatpak rule, all named in one place
with a reason each. The dependency contract now knows they are installed out of
band, so probing for them with `command -v` is not read as an undeclared
dependency -- narrowly, per command, so a genuine omission still fails.

Claude-Session: https://claude.ai/code/session_01Q84axqUE5inJhf5Jz9CFy1
2026-08-20 19:25:30 -04:00
Gabriel Brown b6448c9876 Give the revealed Dock back the pointer that revealed it
The bottom dock came up when the cursor reached the edge and hid again a
quarter-second later with the cursor still sitting there.

The input region has two shapes: a three-pixel strip along the whole edge while
hidden, and a region over the body once revealed. Teaching the dock about left
and right rewrote both, and the bottom case was folded into the branch that
serves a left dock -- x 0. That is right for a dock that hugs the left edge and
wrong for one that is centred on the bottom: the region landed on the left third
of the screen while the pointer that summoned the dock was in the middle. Hover
dropped on the very frame the dock arrived, and the hide timer did the rest.
Approaching from the far left worked, which is the only reason it looked
intermittent rather than broken.

Bottom is centred, so the region starts where the body starts.

Nothing measured the input region, which is why "bottom is unchanged" passed
while bottom was broken -- the contract read the window and the window was
fine. It now probes the mask in both states on all three edges and asserts the
point a hand actually aims at, the middle of the edge the dock lives on, is
still inside the region after the dock arrives. It fails on the old binding
with the coordinates that were wrong.

Claude-Session: https://claude.ai/code/session_01NvgBuSWB5sE43yWmg21ozj
2026-08-20 19:23:02 -04:00
Gabriel Brown 15d54b16f6 Ask everything first, then run without needing anybody
sunhat's failure mode was a question twenty minutes into a run. Walking away
from an install meant coming back to a prompt that had been waiting an hour.

So the questions move to the front. A new interview stage asks what Panama needs
to be told -- hostname, git identity, whether to sign in to GitHub, whether to
make an SSH key -- shows the answers back, and asks once to proceed. After that
nothing asks again. gum is bootstrapped before it runs, because the interview is
built on gum and gum arrives with a stage that has not run yet.

Answers reach the stages through a mktemp file that install sources and the
existing trap deletes, since a child process cannot export into its siblings.
They are not remembered between runs: there is no state file to go stale, and
one of the answers is an email address.

The interview asks only what a stage in this repository actually consumes.
Extras, hardware and debloat questions arrive with the stages that act on them
-- a prompt whose answer nothing reads is a control that lies. The new contract
pins that in both directions, and four deliberate mutations confirmed it catches
a question nobody reads, a stage reading something nobody asks, an answers file
left on disk, and a declined interview that fails to stop the run.

The run now ends with panama-doctor, because a failed-stage count says nothing
about a service that did not start. It never changes the exit code: on a fresh
machine, unconfigured is the honest answer, not a failure.

espanso and oh-my-posh stop being exceptions -- Terra packages espanso-wayland
and Fedora packages oh-my-posh, so the curl installer is gone. bun is now the
only remaining one.

Claude-Session: https://claude.ai/code/session_01Q84axqUE5inJhf5Jz9CFy1
2026-08-20 19:22:36 -04:00
Gabriel Brown 96e4085919 Install the desktop this repository already describes
The shell named a font, a pointer theme and a wallpaper that no package list
installed and no stage placed. It went unnoticed because this machine collected
all three under sunhat and never lost them; a fresh Fedora box would have come
up with tofu for every shell glyph, the default pointer, and no wallpaper --
while Wallpaper.qml called that missing file `shippedPath`.

The dependency contract reported PASS throughout, because it reads commands that
scripts invoke and none of these are one. The new assets contract covers what it
structurally cannot: fonts and pointer themes named in configs and gsettings, a
shipped wallpaper that must exist, commands launched from QML, and directories
nothing puts into service. Written against the broken tree first, where it found
ten faults.

Four of those were packages nobody had noticed were missing -- gnome-calendar,
podman, pipewire-utils and flatpak -- alongside gnome-control-center, which backs
fifteen rows of Panama's own Settings and is commented so it is not mistaken for
GNOME-session residue later.

Fonts turned out to need no install stage at all. Terra, which install-packages
already enables, packages every Nerd Font, so sunhat's wget-and-unzip is replaced
by five lines in a package list. The pointer theme had no such luck: it is
packaged nowhere, so it is vendored rather than downloaded from a URL that can
rot.

espanso stays undeclared. It is in no enabled repository, and building it from
source is the install method this repository is trying to stop using.

Claude-Session: https://claude.ai/code/session_01Q84axqUE5inJhf5Jz9CFy1
2026-08-20 18:57:47 -04:00
Gabriel Brown 6bcc351e07 Write down what a fresh install actually lacks
Panama installs a desktop it does not fully provision. The shell names fonts
nothing installs, a cursor theme nothing installs, and a wallpaper that does not
exist; five packages that settings pages depend on are declared nowhere. None of
it shows here, because this machine accumulated those pieces under sunhat and
never lost them.

The spec covers closing that gap, moving every question the installer needs to
the front of the run, and porting the parts of sunhat that still matter.

Claude-Session: https://claude.ai/code/session_01Q84axqUE5inJhf5Jz9CFy1
2026-08-20 18:38:50 -04:00
Gabriel Brown 3b01f1e020 Let the Dock choose an edge, choose its screens, and be dragged into order
Three things that were parked, and the reasons they were parked turned out to be
the useful part of doing them.

The Dock can sit on the left or the right as well as the bottom. Everything that
assumed the bottom edge is now asked which edge it is on: the anchors, the axis
that gets an implicit size, the sliver of input region that survives hiding, the
direction the body slides away in, and which side a tooltip opens towards. The
body was a Row and is a Grid, because one declaration then serves both
orientations -- Row and Column would each need their own children, and the
cross-axis anchors that centre items in a Row are the wrong axis in a Column.

Bottom is unchanged in every particular, and the settings default to it, so a
hot reload in the middle of this work left the running dock exactly where it
was.

One bug worth recording because static review would never have found it: a dock
spans the edge it lives on, which means anchoring BOTH ends of that edge. The
first side dock anchored top and left only, was free to collapse to its implicit
height, and came out one pixel tall. It parsed, it loaded, and it rendered
nothing. The contract measures the geometry rather than reading the source for
that reason, and was verified by putting the single-ended anchor back.

Per-screen is a list of names where empty means every screen, because a list is
what goes stale when a display is unplugged and "all" should not be spelled as
one. Turning off the last screen collapses to "all" rather than leaving no dock
anywhere and no obvious way back.

Pins can be dragged by a grip. The objection this file recorded for a long time
was real -- dragging inside a Flickable inside a scrolling page fails in a way
that reads as breakage -- and the answer is preventStealing on the grip, so the
page cannot claim a gesture that started there. The arrow buttons stay: they are
the keyboard-reachable path and a grip is not. The order is held locally during
the drag and written once on release, rather than rewriting settings.json for
every slot crossed.

Claude-Session: https://claude.ai/code/session_01BRvzt4H8XXLPVH5MyYdk9L
2026-08-20 14:10:22 -04:00
Gabriel Brown 23141673a2 Hang the Control Center where every other popover hangs
Five surfaces sat 12 pixels under the bar -- the date menu, the activity panel,
notification toasts, signal glass and the clipboard, all using barGap * 2. The
Control Center sat at 2, through a constant of its own, which left the widest
surface in the shell hanging ten pixels higher than the date menu beside it.

That constant arrived with the original Control Center and carried no reason,
while barGap directly above it explains itself. The clipboard even cites
QuickSettings in a comment for how it derived its own margin, and still landed
on 12. It reads as an early value nothing else converged on rather than a
decision, which is why it is going rather than being documented and kept.

The contract that guarded it pinned the literal, and that same file already
records where pinning a literal led: it once asserted the buggy margin
expression, so the code and the test agreed and a 38-pixel gap was invisible to
both. Replacing one number with another would have repeated it. It now reads the
top margin out of the Control Center and out of the date menu and requires them
to match, so drift in either direction fails -- verified by moving each one in
turn and watching it break.

Claude-Session: https://claude.ai/code/session_01BRvzt4H8XXLPVH5MyYdk9L
2026-08-20 13:41:12 -04:00
Gabriel Brown 495fb9b41b Give each online account its provider's icon
Four accounts distinguished only by a line of small grey text are four rows that
have to be read rather than recognised. GOA already knows what each one is.

It hands back a serialised GThemedIcon -- ". GThemedIcon goa-account-google
goa-account goa …" -- a preference-ordered fallback chain. The helper passes the
whole chain on rather than resolving it, because which of those names exists is
a property of the icon theme in use and not something a python script talking to
D-Bus should be deciding.

The page walks the chain and takes the first name the active theme actually has.
Both simpler readings were wrong and looked right: taking the first name blindly
assumes it resolves, and taking the last as a fallback assumes the most generic
name is the most likely to exist. On Adwaita the tails of these very chains --
"mail", "goa-symbolic" -- do not exist at all, so a miss would have drawn
nothing. Checked by asking Quickshell.iconPath directly, which returns empty for
a name the theme lacks; the fallback is avatar-default-symbolic, which is
present.

SettingsCard grew an optional icon for this. It is empty by default and the
header lays out exactly as before when unset, so no other card moves.

Last-sync is not here because there is nothing to show: GOA exposes no
sync-related property at all, on any of these accounts. Better to say so than to
invent a timestamp from when the page last refreshed.

Claude-Session: https://claude.ai/code/session_01BRvzt4H8XXLPVH5MyYdk9L
2026-08-20 13:28:05 -04:00
Gabriel Brown 3b657e0a56 Make the resize keys do what the fingers expect
Audited against the real Forge configuration rather than its defaults: the
bindings are still in dconf, and the extension is still installed, so both the
keys and the code behind them could be read directly.

The port was faithful. Forge's resize() grows the window for a positive amount
in every direction -- the edge only decides which side moves -- so
"increase" meant grow and "decrease" meant shrink, and Y/O/I/U mapped to Wider
and Taller exactly as they should have.

What did not survive is direction. Forge resized one named EDGE: Y pulled the
left edge leftward, O pushed the right edge rightward, and the window opened
from the side you asked for. Hyprland resizes along an axis and lets the layout
pick the border, so eight distinct behaviours collapse onto four and the
direction is not expressible at all in dwindle. Correct on paper, wrong under
the hands: the keys that used to pull a window open from one side now push it
from the other, which reads as the pair being swapped.

So the sizes are inverted from Forge's naming on purpose. Y and O shrink, B and
M grow, I and U shorten, N and Comma lengthen. Faithfulness to a mapping nobody
can feel is worth less than keys that behave the way their owner reaches for
them, and the reason is written where the next person will find it.

Also fixes the Home Assistant accessories dropdown, which looked glitchy for a
reason that was not performance. The resting card hid the instant the section
opened -- a Column skips invisible children, so everything above snapped up
while the expanded grid was still sliding open underneath. It now collapses on
the same curve and duration as the section replacing it. This was the only
control with a resting and an expanded form that swap, which is why it was the
only one that looked wrong.

Claude-Session: https://claude.ai/code/session_01BRvzt4H8XXLPVH5MyYdk9L
2026-08-20 13:10:17 -04:00
Gabriel Brown c148bae4ac Let Ethernet be switched off and on
Wi-Fi had a switch and wired did not, which left no way to take the cable down
without reaching for nmcli -- and this machine has two interfaces on one subnet,
so turning one off is a genuinely useful thing to be able to do.

Both halves are needed to turn it off. Disconnecting alone survives the session
but not a carrier event or a reboot, because NetworkManager brings an
autoconnecting device straight back; the switch sets the device's autoconnect
alongside it. The device property rather than the connection profile, so a
toggle here does not quietly rewrite a saved connection somebody expects to come
up at boot.

The first version built a trap door. It decided whether the switch was usable
from hasLink, which reads false while a device is merely disconnected even
though NetworkManager still reports the carrier as on -- so turning Ethernet off
made the switch disable itself, blame the cable, and offer no way back. A wired
device that exists can always be asked to come up; if there is really no cable
the attempt fails and says so, which is the honest failure. The word "off" is
used where the old text guessed "no cable", because nothing available here can
tell those apart.

Verified as a round trip against the real device, including that off stays off
through eight seconds rather than reconnecting a moment later, and that the
mechanism tested is the one the code uses -- the first test drove the connection
profile while the code drives the device, which are different things.

Claude-Session: https://claude.ai/code/session_01BRvzt4H8XXLPVH5MyYdk9L
2026-08-20 12:13:49 -04:00
Gabriel Brown 4cbab01ae2 Show what has actually been installed
Automatic updates leave no other trace. The Flatpak that sat here as "1 update
available" installed itself at 00:14 this morning and nothing on the machine
would have said so.

Both sources are asked in their own machine-readable form and merged on time, so
the answer reads as one history rather than two lists to interleave by eye.

Two parsing traps worth recording next to the code. flatpak's --json prints
timestamps as "Aug 20 08:07:46" with no year in them, so the year is inferred
and a date that would land in the future is read as last year's. And dnf5's
start_time is epoch UTC while its own history table prints that same value as
though it were local -- checked against rpm, and the local rendering here is the
correct one.

The contract asserts entries are newest first, that none is dated in the future,
and that both sources parse; it was verified to fail by breaking the year
inference so every flatpak entry landed tomorrow.

Loaded on demand rather than with the page, because it reads both full
transaction logs.

Claude-Session: https://claude.ai/code/session_01BRvzt4H8XXLPVH5MyYdk9L
2026-08-20 11:30:31 -04:00
Gabriel Brown de45f205ad Carry settings between machines by allow-list, not by stripping
panama-settings-backup already snapshots this machine so it can be put back
exactly as it was, arrangement and all. This is the other thing: an export meant
to travel, carrying the preferences that describe taste rather than hardware.

The export is an allow-list read from the preference schema rather than a
deny-list of things to remove. A key added later that happens to hold a token
cannot leak into a file somebody emails to themselves; being wrong in this
direction loses a setting, being wrong the other way publishes a secret. It
earned that immediately -- this machine's store holds an orphaned shadowOffset
from a setting that no longer exists anywhere in the source, and it was left
behind without anyone having to know about it.

Three settings stay: the display arrangement, which is keyed by output names
that mean nothing elsewhere; the last page opened, which is session noise; and
schemaVersion, which belongs to the store rather than to a person. Import is a
merge, so settings a file does not mention are left alone, and it is idempotent.

Two bugs made and caught here, in opposite directions. Validation missed 36
settings because "real" was spelled "float" and enums fell through entirely, so
an out-of-range or nonsense value would have been written straight into the
store. Correcting that then broke numeric enums -- vrrPolicy is an enum of 0..3
and the options were read with a regex that only matched quoted values, so those
settings had no known choices, were declared unverifiable and were refused:
valid settings dropped silently in transit.

The contract could not see the second one. It checked only that bad values are
refused, and when numeric enums were unreadable they never reached the bundle at
all, so every "did it arrive" assertion was satisfied by their absence. It now
requires the export to carry what it should as well as withhold what it should
not, and was verified to fail in both directions.

Claude-Session: https://claude.ai/code/session_01BRvzt4H8XXLPVH5MyYdk9L
2026-08-20 11:02:42 -04:00
Gabriel Brown 52e2a83a78 Add an SSH Keys page, and refuse the one control that would lie
The page shows which keys exist, what the agent is holding, and the hosts this
machine has met, with a two-press forget for a host whose key has changed.

Nothing here reads private key material. Fingerprints and comments come from the
.pub file, and "does this key need a passphrase" is answered by asking
ssh-keygen to derive the public half with an empty one -- it succeeds for an
unencrypted key and fails for an encrypted one, and either way the only thing it
can emit is public. The contract checks that against the payload that actually
reaches the page rather than against the source, because what the code intends
and what it ships are different claims.

Unloading a key from the agent is refused, with its reason. On this desktop
`ssh-add -d` prints "Identity removed" and the key is still offered a second
later: gnome-keyring's agent lists every key it finds in ~/.ssh, so a removed
one comes straight back off disk. That was measured rather than assumed -- a
plain ssh-agent removes durably, this one does not -- and a button reporting
success while changing nothing is worse than no button. The page says so and
names the thing that does work: move the file out of ~/.ssh.

SSH_AUTH_SOCK is not set in a normal shell here, so a naive check reports "no
agent" while one is plainly running. The helper falls back to the keyring
socket, and an agent started by hand still wins. That gap is the same one that
made reaching these servers awkward in the first place.

Generating a key is deliberately absent. A passphrase cannot reach ssh-keygen
without going somewhere it should not -- -N puts it in argv, which every process
on the machine can read -- and driving the prompt over a pty did not work.
Offering to generate an unencrypted key instead would be a downgrade dressed as
a feature, so the page does not offer to generate at all.

Claude-Session: https://claude.ai/code/session_01BRvzt4H8XXLPVH5MyYdk9L
2026-08-20 10:17:33 -04:00
Gabriel Brown 79b3d5cb85 Close the sweep's last blind spot, and stop shortcuts silently colliding
gapsIn and gapsOut were the only two compositor settings the write sweep had
never verified: Hyprland answers for them in CSS shorthand, "5 5 5 5", and the
sweep had no way to compare that. The preference behind each is a single int
that Hyprland expands to four sides, so a uniform reading compares exactly. A
non-uniform one is not something the preference can express, and is skipped
rather than collapsed to a number it never wrote. 63 of 63 verified live now,
none skipped.

Wallpaper thumbnails are cached. The report that five of them sat at "Loading…"
was a screenshot taken 1.1 seconds after the page opened -- decoding one of
these at tile size takes between 1.2 and 2.6 seconds and about ten start at
once, which the code already said. Measuring it did turn up something real
though: without a cache, scrolling back up pays that decode again for every
tile. The tradeoff is a wallpaper replaced in place showing a stale thumbnail
until restart, which is worth it for a directory of files that are added rather
than edited.

A chord already in use is now named rather than taken: "Super+Q is already
Terminal". Two actions on one chord means whichever Hyprland reads last wins,
which is not a thing to find out later by pressing it. Rebinding a shortcut to
the chord it already holds is correctly not a conflict.

Also: Open Appearance lands on the Windows tab now that the page has tabs,
Storage points at reclaimable container space, and a dock row shows its desktop
id only when two pinned applications share a name -- it is developer text, and
repeating it under fifteen recognisable names made the list harder to scan.

Written down because it cost the shell: QML has no default parameter values, and
`function openSettings(page: string, section: string = "")` fails the entire
configuration rather than the one function -- so the bar and dock went with it,
and 43 contracts failed at once pointing at the same line. qmllint --bare passes
that, which is why the usual check before touching the running shell did not
catch it. openSettingsSection exists as a separate function for that reason.

Claude-Session: https://claude.ai/code/session_01BRvzt4H8XXLPVH5MyYdk9L
2026-08-20 09:56:02 -04:00
Gabriel Brown 1b845c0126 Rebuild Home around what this desktop already knows
Home led with a diagram of the attached monitor -- DP-2, 4500 x 3000, 1.13x
scale, XRGB2101010 -- which is Displays-page data, was the largest thing on the
screen, and has never been needed there. Under it sat a permanently open search
field for a weather location that is set about once a year, and then roughly
half a page of nothing.

It now opens with the two or three things somebody would open Home to do:
starting or ending a focus session, Do Not Disturb, and what is running. The Do
Not Disturb switch is disabled and says why while a mode is holding it, so it
cannot appear to be a control that is being ignored.

Findings sit above a reassurance line, the same shape the Firewall and
Containers pages use. Each finding names the page that can resolve it, because a
home page reporting a problem it cannot help with is only an alarm. On this
machine that is one finding today -- PostgreSQL and Redis reachable from the
network -- above "27 health checks pass, 522 GB free, snapshots ran at 02:00".
"Do next" appears only when there is something in it.

Weather stays, as the greeting's second line rather than a card with a search
box open, and its picker is collapsed behind the current location.

FocusModes was missing `import qs.config`, so DesktopPreferences was undefined
and the modes list came back undefined with it -- shipped two commits ago with
nothing noticing, because until Home referenced the service no isolated shell
had ever instantiated it. Focus modes would have quietly had no modes. Pulling
these services together on one page is what surfaced it.

LocationPicker gained a picked signal, the same way DisplayModePicker did, so a
container can put the search away without reaching into it.

Claude-Session: https://claude.ai/code/session_01BRvzt4H8XXLPVH5MyYdk9L
2026-08-20 09:15:01 -04:00
Gabriel Brown f53ca16392 Make the focus exception list real, or it was a page telling a lie
The mode data model shipped with an allow list and nothing that read it. The
summary would say "2 apps may interrupt" while notification delivery never
consulted the list and no editor could set it. That is the dead row this work
has spent its time removing, introduced by the work itself.

The banner gate consults the mode in force now, and the list can be edited from
the applications that have actually sent a notification -- an exception for
something that never notifies is not a choice worth offering.

Exceptions belong to a mode. allowedApps is empty whenever no mode is active, so
a Do Not Disturb switched on by hand stays absolute and nothing can leak into
it. That scoping is asserted, not just written.

Verifying this took three attempts, and the second was a real defect in the
guard rather than in the code. The contract grep for FocusModes.allows matched
the comment that explains it, so the check passed with the enforcement deleted.
It matches the gate expression now. A guard a comment can satisfy is not a
guard, and this is the third time prose has satisfied one here.

Claude-Session: https://claude.ai/code/session_01BRvzt4H8XXLPVH5MyYdk9L
2026-08-20 02:23:30 -04:00
Gabriel Brown 6dc606b872 Give focus modes conditions rather than alarms, and let Gaming hand over
A mode is on because something is true right now: a game is running, a window is
fullscreen on a given display, a workspace is focused, the clock is inside a
window. That is asked again rather than fired once, and it is the whole reason
schedules could be included here without the usual failure modes. A machine
asleep at 23:30, rebooted at 02:00, or opened at 08:00 into a window that has
already passed all reach the right answer by being asked again; an alarm gets
all three wrong.

The midnight-crossing rule is the part worth being careful about: a window
belongs to the day it STARTS on, so a Friday-only 23:30-07:00 covers Saturday
morning and must not cover Saturday night. That arithmetic was tested as pure
logic before anything was built on it, including every malformed input failing
closed -- silencing someone because a time string was wrong is the worst way
this could fail.

This does not take over the manual timed session. FocusSession already owns
that, with its capsule, shortcut, Quick Settings entry and contracts, so modes
defer entirely while one runs. Two writers of Do Not Disturb would each restore
whatever the other happened to leave behind.

Gaming hands over rather than being duplicated. The hook was silencing
notifications itself, which would have made exactly those two owners -- and
Gaming.active only polls while its settings page is open, so a mode could not
have seen a game reliably in any case. The hook reports the game over IPC now
and the mode decides what that means, the Gaming page points at it, and
gamingSilenceNotifications is retired from the schema, since a setting nothing
reads is the dead row this work keeps removing.

Sleep ships disabled. A desktop that starts silencing someone on first boot has
overstepped, whatever the default hour.

Three contracts moved with it. gaming-contract asserted the hook uses setDnd,
which was right before and wrong now; the shell-side assertions that setDnd and
dndState exist stay, because a toggle would flip an already-silent machine back
on. The new contract is proven to fail by breaking the midnight rule and by
letting modes run alongside a manual session.

Claude-Session: https://claude.ai/code/session_01BRvzt4H8XXLPVH5MyYdk9L
2026-08-20 02:06:50 -04:00
Gabriel Brown 32fab59d24 Let a sound device be heard, and the Dock's icons be sized
Nine outputs named after their chipsets cannot be told apart by reading, so each
one gets a Test button that plays a short sample out of that device. Targeted by
node name rather than by making it the default first, because finding out which
is which should not move where everything else is playing.

That belongs in its own service rather than in AudioDevices. sound-page-contract
forbids Process, pactl and wpctl in the files that own device state, and it is
right to: shelling out there races the PipeWire service that owns those same
objects. Playback is a different thing -- pw-play opens its own stream and
mutates no device, so there is nothing to race -- but the rule's letter covered
it, and weakening a guard to fit a new case is how guards stop meaning anything.
SoundTest exists so AudioDevices stays native bindings only.

Worth recording next to the call: pw-play falls back to the default output for a
target it cannot find, rather than failing. A stale node name would play from
the wrong device and look exactly like a successful test, which is why the name
is taken straight from the live node.

The Dock's icon size was a constant in Theme. It goes through the preference
schema like everything else, so validation, search, the generated docs and the
write sweep all pick it up without being told about it separately -- and two
contracts duly failed until docs/settings.md and the per-page commands were
regenerated.

Dock position is deliberately not here. It is not a setting but a rework: the
dock is anchored bottom, and the reveal strip, tooltip placement, intellihide
and the qs-dock rule in hypr/rules.lua all assume that. Doing it properly means
changing compositor rules on a machine somebody uses daily, which is not
something to start as a side effect of adding a slider.

Claude-Session: https://claude.ai/code/session_01BRvzt4H8XXLPVH5MyYdk9L
2026-08-20 00:03:21 -04:00
Gabriel Brown f6b970da21 Turn the rows that only reported things into controls
Autostart entries showed "Enabled" or "Disabled" as plain text. The row did
toggle on click the whole time, so this is an affordance rather than a missing
capability -- but a control that reads as static text is one nobody knows they
have. It is a switch now, with removal alongside it behind a confirmation:
disabling writes Hidden=true and can be undone, deleting the file cannot.

remove-autostart is confined to files the autostart directory owns. It resolves
the path and compares the parent, so a name like "../../.bashrc" cannot escape,
and it refuses symlinks rather than following them -- deleting through one would
remove whatever it points at, which is somewhere else and not ours. Each refusal
was tested against a fixture directory, including a symlink aimed at
/etc/hostname, which survived.

Sharing says who is signed in from another machine: user, origin and since when.
An empty list on this machine proves nothing, so the parser was checked against
sample `who` output -- it picks out remote sessions and leaves out local seats
and the :0 display, which would otherwise report the person at the keyboard as a
remote login.

Media sharing was "Available" and nothing else: rygel installed, rygel.service
disabled, no way to change that from here. It is a switch now, and it says what
it does before you touch it rather than afterwards -- turning it on publishes
media folders to every device on the network with no password in front of them.

Per-application camera and microphone permissions come from the portal's
permission store, which is where an application that asked through the portal
has its answer recorded. The page states the limit plainly instead of implying a
protection that does not exist: a program installed outside the portal opens the
device directly and nothing here stands in its way. Anything that is not an
explicit "yes" is treated as withheld, because guessing generously about a
camera is the wrong way to be wrong.

The first version of the write silently did nothing -- SetPermission takes an
array of strings and was being handed one string -- and the test did not notice,
because it discarded the helper's output and only checked that state was
unchanged afterwards, which was trivially true. The contract now requires the
value to move, and was proven to fail by putting that exact bug back.

Claude-Session: https://claude.ai/code/session_01BRvzt4H8XXLPVH5MyYdk9L
2026-08-19 23:10:48 -04:00
Gabriel Brown 7e1c85b094 Group the long pages by what you are trying to do
Notifications repeated both lock-screen switch labels for every application, so
twenty apps meant sixty rows of the same two sentences and the page could not be
scanned at all. Each app is one row now, carrying what its switches add up to --
"On, lock screen shows the sender only", "On, hidden on the lock screen",
"Notifications off" -- with the switches behind it, one app open at a time. The
identifier only appears while an app is open, which is the only time it
disambiguates anything, and the content switch dims when the app cannot reach
the lock screen at all, because there it means nothing.

Shortcuts were already grouped; the problem was that "Windows" caught focus,
movement, splitting, resizing and window state alike and held 43 of the 93
binds. A section that long is a list, not a grouping. They are separated by
intent now -- Focus, Move & split, Size, Window state -- and the split was
checked against the binds this machine actually has rather than trusted from the
keywords. Order matters in two places worth naming: "Next window splits down" is
about splitting rather than focus, and "Focus session" is quiet mode bound to a
workspace rather than window focus, so both are settled before the general
checks.

Refresh rate gets its own row. That need was created by collapsing the
resolution list: the rates for a resolution were only ever reachable by opening
it, so changing nothing but the rate meant going through the mode you already
had. It appears only when the current resolution offers more than one.

Default-application rows carry a chevron, having previously opened a chooser
while looking completely inert.

The notification contract asserted the literal Notifs.appRule(app.id).enabled,
which moved when the rows collapsed. The rule is still read through a binding on
Notifs.appRule, so a rule changed elsewhere still reaches the row -- the
assertion now requires that, rather than requiring one particular spelling of
it.

The power profile rows were left alone. A three-way choice in three rows looks
wasteful until you notice each row explains what the profile does, and that page
has empty space to spare; a segmented control would trade information for space
that is not scarce.

Claude-Session: https://claude.ai/code/session_01BRvzt4H8XXLPVH5MyYdk9L
2026-08-19 22:54:51 -04:00
Gabriel Brown 0c364f38e6 Stop sending people to GNOME for pages this app already has
Panama absorbed Users, Sharing, Printers and Online Accounts one page at a time.
Each time, the row pointing at GNOME's equivalent stayed exactly where it was --
so an app whose stated purpose is to make GNOME Settings unnecessary shipped four
doors back to it, two of them inside a card headed "these areas remain owned by
Fedora and GNOME's mature system panels".

Nothing failed. Every row worked as written. They were simply no longer true, and
no test could notice, because none of them knew what Panama had come to own in
the meantime. gnome-handoff-contract reads the sidebar for the pages that exist
and the pages for the panels they hand off, and fails on any overlap -- derived
from both sides rather than a hand-kept list, so absorbing the next page cannot
leave a stale door behind. Adding an online account is allow-listed with its
reason: it genuinely requires GOA's own dialog.

health-ui-contract asserted those handoffs were present, which is how they
survived. The assertion is inverted rather than deleted, so reintroducing one
fails loudly.

The Home Assistant "Light entities" box is gone. It was a multi-line list of
comma-separated Zigbee entity IDs, and the light catalog does not come from it --
the helper discovers that live. It is a one-time migration seed for the Control
Center selection, so saving now passes the stored value back untouched: setting a
URL or a token cannot disturb it. Deleting the control naively would have written
an empty list over it.

Sharing showed two "Port" rows for RDP, same label and value, one read-only and
one editable, separated by a switch. The read-only leftover is gone. The SSH port
stays read-only because sshd's port is not ours to write.

About reported "488G free of 1.9T" where Storage said "523 GB free of 2.0 TB" --
the same drive, binary against decimal. About uses decimal now, matching how
drives are sold. Memory and swap stay in GiB, which is how RAM is sold.

Claude-Session: https://claude.ai/code/session_01BRvzt4H8XXLPVH5MyYdk9L
2026-08-19 22:36:58 -04:00
Gabriel Brown 1aa1324083 Lead Appearance with light and dark, and stop pages listing whole datasets
Appearance was six cards deep and Light/Dark was the third of them, below the
wallpaper grid and the entire lock screen -- so the control reached most often
was the last one you got to. It is five tabs now, Theme first. The mock showed
four; the page turned out to have eleven cards, so Titlebars and Windows became
Windows, and Clock and vitals became Shell, rather than pretending four would
hold them.

Region, Date & Time and Displays each rendered a complete dataset as rows: every
installed locale, the whole tz database, every mode the monitor advertises. The
chooser was never the problem -- SearchPicker already existed and worked. It was
simply rendered always-expanded, so the one line saying what is currently set sat
under hundreds that were not. PickerRow collapses each behind its current value
and closes again once something is picked.

The avatar never appeared to change because accountsservice writes every picture
to the same path, leaving the URL byte-identical while Qt served its cached
image. cache:false was already set and could not have helped: an unchanged source
is never re-read at all. avatarUrl now carries a revision fragment, bumped only
when a write actually succeeds. Pictures are cropped before they are set, in the
picture's own pixel coordinates so the result does not depend on the size it
happened to be displayed at, and written out at 512x512 through GdkPixbuf --
already a dependency here, so nothing new is required.

Snapshots listed nothing. The timeline and its Delete buttons existed the whole
time, behind a row labelled "Browse...", a word that promises a file browser. The
three most recent points are shown inline now, with the rest one press away.

qmldir-registration-contract exists because an unregistered component is not a
quiet problem: Quickshell fails the entire configuration on it, so the settings
window dies and the bar and dock go with it. That happened twice while writing
this, both times on a machine somebody was using. It is pure file inspection, so
it runs before a change ever reaches the running shell.

Claude-Session: https://claude.ai/code/session_01BRvzt4H8XXLPVH5MyYdk9L
2026-08-19 22:36:41 -04:00
Gabriel Brown ac5e6e2130 Add a Containers page, grouped by project and led by what is exposed
Every container on this machine is created by rootless podman-compose and
labelled with the project it belongs to, so the grouping is read from the
labels rather than invented. State then decides prominence within that
grouping -- running containers get rows, stopped ones collapse to a line --
which is why neither axis had to be chosen over the other.

Acting on a stack uses plain podman over the labelled set, never
`podman-compose down`, which would remove containers this shell did not
create. The compose file is the source of truth for what exists and belongs
to the repository. Nothing here needs privilege.

The findings on top are the crossing the Firewall page reports, seen from the
side that can close it: the firewall knows only that something is listening,
while this page knows which container, which compose file, and which token is
missing from it. So `bind-local` prepends a loopback address and leaves the
line byte-for-byte -- variables, quoting and style intact -- then re-parses and
rolls back unless exactly those ports moved. It refuses anything ambiguous
rather than guessing. Rewriting the mapping to the port podman reports today
would have deleted the ${POSTGRES_PORT} indirection that makes it
configurable at all.

Unused volumes are read from podman's own dangling filter. The first version
used MountCount, which is a runtime lock counter and not a usage signal: it
reads zero for a volume a running container has mounted this second, so
"remove unused volumes" offered to delete the live Command Center database.
The cross-check against `podman system df` is what exposed it. The contract
reintroduces that bug deliberately and fails if the guard does not catch it,
because a guard nobody has seen fail proves nothing.

Every mutation in the contract runs against a stubbed podman. Nothing in the
suite starts, stops or removes a real container, image or volume.

Claude-Session: https://claude.ai/code/session_01BRvzt4H8XXLPVH5MyYdk9L
2026-08-19 20:49:04 -04:00
Gabriel Brown 536958430f Fix the polkit unit's ignored rate limit
systemd logged "Unknown key 'StartLimitIntervalSec'" on every start:
rate limiting belongs in [Unit], not [Service], so both keys were
ignored and the restart limit they were meant to impose did not exist.
An agent that failed repeatedly would have flapped rather than stopping.

Found while investigating reported prompt failures, which turned out not
to be a defect: the trace showed both authentications succeeding on the
first attempt. The failures came from testing -- prompts raised by
background pkexec runs and then cancelled seconds later, while someone
was trying to type into them.

Claude-Session: https://claude.ai/code/session_01BRvzt4H8XXLPVH5MyYdk9L
2026-08-19 19:54:16 -04:00
Gabriel Brown fd99569666 Add a Firewall page, led by what is actually reachable
Listing zones and services is what firewall-cmd already does. The
question it does not answer needs both halves at once: a port is
reachable only when something is LISTENING on a network address AND the
firewall permits it.

On this machine that crossing is the whole story. The rules look
unremarkable -- one zone, three services, a port range -- and what they
mean is that PostgreSQL and Redis, published by rootless containers on
every interface, are reachable by anyone on the network. Neither half
says that alone, which is exactly how a tidy rules list coexists with an
open database. Nothing was misconfigured: Fedora's default zone met
podman's default publish behaviour.

Ephemeral client sockets are excluded. A browser's outbound UDP port is
indistinguishable from a service in ss, and listing twenty of them
buried the two rows that mattered.

Closing the port range names what it would cut off, by service, before
doing it, and removing ssh says so when someone is connected over it.
Rich rules are shown and never edited: a syntax is not a setting, but
hiding it would misrepresent the configuration.

The contract needed a recorded firewall, and the reason is worth
keeping. The rule this page exists for cannot be tested against this
machine -- its zone permits everything above 1024, so "listening" and
"listening and permitted" give identical answers, and a blocked listener
needs a port below 1024, which needs root. With the crossing deleted,
the contract passed. It now runs against a fixture where two listeners
are blocked, and catches it.

Also here: polkit response files are written 0600 rather than at the
default mask, the agent sweeps requests left by an instance that did not
exit cleanly, and the write sweep waits for its harness to be ready
instead of reporting the startup race as settings that failed.

Claude-Session: https://claude.ai/code/session_01BRvzt4H8XXLPVH5MyYdk9L
2026-08-19 19:46:16 -04:00
Gabriel Brown a412e3d894 Fix the compositor write contract properly, not by waiting longer
Widening its waits earlier treated the symptom. It still failed about
one run in three, on an idle machine, taking eighteen seconds to do so
-- which was the clue: nothing was in flight to wait for.

SystemSettings verifies each write before storing it and serializes
overlapping ones, so the restore arriving while the previous batch was
still settling was being dropped outright. Waiting longer for a write
that was never queued cannot help. It is issued up to three times now,
and the run where a retry fires visibly takes eight seconds instead of
two.

Retrying cannot hide a broken write path: with the write stubbed out the
contract still reports exactly which policy failed to reach the
compositor.

Claude-Session: https://claude.ai/code/session_01BRvzt4H8XXLPVH5MyYdk9L
2026-08-19 19:05:29 -04:00
Gabriel Brown 4cbe3b882a Add a Gaming page, and let the desktop react to games
Live first, because unlike every other page here this one has a live
dimension: card temperature, power draw, whether Game Mode actually
engaged. It polls only while it is open, since a settings page nobody is
looking at has no business waking the CPU.

The part that makes it Panama's page rather than a gamemode config
editor is the hook. gamemode runs a script when a game asks for it and
another when the game exits, so the power profile switches to
performance and notifications go quiet for exactly the duration of a
game -- and afterwards both go back to what they WERE, not to a default.
A Do Not Disturb someone set by hand survives a game; a power profile
someone chose is restored rather than replaced. Verified against real
gamemode activation, not merely by calling the hook.

Two things the page reports rather than hides. Game Mode's headline
trick is switching the CPU governor to performance, and this machine
already runs performance, so it says so instead of implying it helps.
And Proton builds are listed but never chosen: Steam picks the runtime
per game, and a control here would claim an authority this page does not
have.

The hook first called a notifications function that did not exist, and
the one that did was a TOGGLE -- the wrong primitive entirely, since
toggling at game start would unsilence notifications that were already
silent. The shell gained an explicit setter and reader.

search-routing-contract kept its own hand-written list of every page,
which made adding one fail as "not a known page" -- a sixth place to
register a page and a sixth chance to forget. It now derives the mapping
from the shell, which already knows it.

Claude-Session: https://claude.ai/code/session_01BRvzt4H8XXLPVH5MyYdk9L
2026-08-19 18:55:10 -04:00