185d7edaa5e9f9883715b0a359c74674030996e5
203
Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
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 |
||
|
|
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
|
||
|
|
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 |
||
|
|
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 |
||
|
|
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 |
||
|
|
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 |
||
|
|
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 |
||
|
|
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 |
||
|
|
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 |
||
|
|
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 |
||
|
|
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 |
||
|
|
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
|
||
|
|
ed428e87c4 | Complete the Panama theme system | ||
|
|
69ecec7ecf | Complete contextual desktop controls | ||
|
|
a90f6eb357 | Fix wallpaper scan cap exit status | ||
|
|
c4642919f7 | Close desktop safety gaps | ||
|
|
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 |
||
|
|
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 |
||
|
|
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 |
||
|
|
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 |
||
|
|
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 |
||
|
|
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 |
||
|
|
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 |
||
|
|
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 |
||
|
|
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 |
||
|
|
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 |
||
|
|
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 |
||
|
|
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 |
||
|
|
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 |
||
|
|
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 |
||
|
|
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 |
||
|
|
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 |
||
|
|
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 |
||
|
|
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 |
||
|
|
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 |
||
|
|
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 |
||
|
|
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 |
||
|
|
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
|
||
|
|
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 |
||
|
|
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 |
||
|
|
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 |
||
|
|
116510caa8 |
Draw the authentication prompt ourselves
hyprpolkitagent's dialog is compiled into its binary -- no config, no stylesheet, nothing to theme -- and it was the one window on this desktop that looked like it belonged to something else. The split between the two halves is the security design, not an implementation detail. A small agent process owns the D-Bus side: it registers with polkitd, receives the request, and hands the shell the action, the message, who may answer, and a one-time cookie. It never sees a password. The shell draws the prompt and, on submit, spawns the setuid polkit-agent-helper-1 itself and writes the password to that helper's stdin; the helper runs the PAM conversation and reports to polkitd directly. The password exists in the shell and in the helper's stdin and nowhere else -- never on a command line, never over D-Bus, never through IPC arguments. The prompt takes exclusive keyboard focus, because a password field that lets keystrokes reach the window behind it is a keylogger with extra steps. The request travels as a file created 0600 with O_EXCL inside a 0700 runtime directory: a cookie is not a password, but it is a capability, and capabilities do not belong in a process listing either. Three things cost real time. polkitd calls back on the same connection that registered, so exporting the object on the session bus while registering from the system bus failed every request as "Not authorized" with no error anywhere. XDG_SESSION_ID is absent in a systemd user unit, which runs under [email protected] and belongs to no login session, so the session comes from logind's Display property instead. And PyGObject does not accept the @ placeholder in variant format strings. hyprpolkitagent stays installed as the fallback, only one agent is started, and the comment beside the autostart says how to get the stock prompt back. Verified end to end, including a real password accepted and three cancellations refused. Claude-Session: https://claude.ai/code/session_01BRvzt4H8XXLPVH5MyYdk9L |
||
|
|
b10f8e2593 |
Stop the compositor write contract failing under load
It passed alone in two seconds and failed in a full suite run at six, which is the worst way for a test to be wrong: the code was fine and the report said otherwise. Each of its waits polls until the compositor reflects a write, and four seconds was enough on an idle machine and not enough on a busy one. They are fifteen now. A longer bound cannot mask a dead write, because a write that never lands never matches -- verified by stubbing the write path out and watching it still report exactly which policy failed. Claude-Session: https://claude.ai/code/session_01BRvzt4H8XXLPVH5MyYdk9L |
||
|
|
8f0fe23377 |
Add Software Update, across packages, applications and firmware
Three sources that fail independently, so they are counted and applied separately: a flatpak mirror being down says nothing about whether a kernel security fix is waiting. Blending them into one number would hide exactly the case that matters. Checking costs about nine seconds, which is too long to spend every time a page opens, so the page opens on the last result and says when it was taken. A first visit with nothing cached goes and finds out rather than showing a confident "up to date" it has no basis for. Installing packages takes a snapshot first, named after what is about to happen, so Snapshots shows "before 32 package updates" rather than a timestamp. Best effort: a machine without snapper still updates, because an update that refuses to run when a nicety fails would be worse than one without a restore point. Automatic updates cover applications only, through a Panama-owned user timer running daily with a randomized delay. Packages still ask, and dnf-automatic is reported as absent rather than offered, because installing software is not a settings action. Health gained a check, and that is where the bug was: it first returned status "degraded", which is not in the doctor's vocabulary of ok, warning, error and unconfigured. It was counted as nothing at all while the summary still said healthy -- the same silent no-op this codebase keeps relearning. A contract now asserts every status a check can return is one the doctor counts, and the doctor's own contract knows about the new check rather than failing on its arrival. Claude-Session: https://claude.ai/code/session_01BRvzt4H8XXLPVH5MyYdk9L |
||
|
|
6997dd535f |
Add high contrast, and make remote desktop configurable
Two of the three panels still handed to GNOME, having actually checked each rather than repeating that they were not worth owning. Universal Access turned out to be mostly ours already: the magnifier, pointer size, text scale, motion and dimming were all present. High contrast was the real gap. It reaches GTK4 applications through the desktop portal, which republishes GNOME's accessibility setting as org.freedesktop.appearance contrast -- so no high-contrast theme is involved, and none is installed here. Verified end to end: committing the preference drove gsettings and the portal reported contrast 1. Sticky, slow and bounce keys stay absent. There is no Wayland or Hyprland implementation, and the compositor would store the XKB option while nothing ever acted on it. Remote desktop gained port, view-only, and clearing stored credentials. SETTING credentials opens a terminal running grdctl, which prompts for the password itself. That is not a hand-off for lack of effort: grdctl takes the password on a terminal and core-dumps without one, and the only alternative -- passing it as an argument -- would publish it through /proc to every process on this machine. Typed into grdctl directly it never passes through Panama, and a contract now fails if it ever appears on a command line. Color stays with GNOME, and not for lack of effort either. colord runs here with seven profiles and zero devices registered, because the daemons that register displays do not run under this session, and Hyprland exposes no ICC, gamma, or color-management option at all. A Color page could import a profile, attach it to nothing, and change nothing -- the same failure refused for rollback and printer drivers. Claude-Session: https://claude.ai/code/session_01BRvzt4H8XXLPVH5MyYdk9L |
||
|
|
1f40f8e136 |
Add a snapshot manager, and start covering home
The machine already had snapper running hourly on btrfs, so the tool was never missing. What was missing is that snapper's only configuration covered / -- and /home is a separate subvolume with no configuration at all. Six hundred and forty-three snapshots existed and not one of them contained a document. Anyone reaching for file history would have found their system and none of their files. /home now has a configuration on the same hourly timeline, with deliberately conservative retention: Steam's 1.2 TB lives on that subvolume and churns on every game update, so keeping five hourly and seven daily bounds what those updates can pin. Per volume, because on this machine "one is covered and the important one is not" was the news, and a timeline opening on system snapshots would have buried it. Inside a volume the timeline is the familiar view: points in time, newest first, each openable as a folder tree to take a file out of. Restoring sets the current version aside as .before-restore-N rather than overwriting it. A restore that destroys the thing you were about to compare against is how someone loses the work they were trying to save. Rollback is deliberately absent. snapper's rollback changes the btrfs default subvolume, and this system's fstab pins subvol= explicitly, which overrides it -- so a rollback would report success and change nothing after a reboot. A recovery feature that silently does nothing is worse than not having one, and making it work means editing fstab and the bootloader, whose failure cannot be repaired from inside the desktop. Per-snapshot size is reported as not measured, because measuring it needs btrfs quotas that cost performance on every write. Free space is shown instead, which is the number that decides anything. Claude-Session: https://claude.ai/code/session_01BRvzt4H8XXLPVH5MyYdk9L |
||
|
|
e9d567aa72 |
Own printers, driverless only
The fourth panel this desktop handed to GNOME Settings, and the last one worth owning. Driverless only, deliberately. Adding a printer that describes its own capabilities over IPP is supported; choosing a PPD or fetching a vendor driver is not, and the page says so rather than pretending. That restraint is the whole design: a wrong driver produces a printer that accepts jobs, reports success, and prints nothing, which is the worst failure this page could ship because it looks like it worked. A printer old enough to need a PPD stays a job for the system printer tool. Printers and the queue are separate cards because they answer separate questions. Which printers exist is one; where a document went is the other, and it is the one that actually brings someone here -- so the queue is a single list across every printer. Device URIs are validated by scheme before reaching CUPS, whose backends run as root. file: and pipe: do not lead to a printer and are refused here rather than further down. This machine has no printer, so the page was built against a temporary CUPS queue that was created, exercised through the service, and removed; the service was confirmed to observe the removal rather than merely perform it. Discovery and the driverless add path are verified by their refusals rather than against hardware. Claude-Session: https://claude.ai/code/session_01BRvzt4H8XXLPVH5MyYdk9L |
||
|
|
a23b42841a |
Own user accounts and sharing
Two of the panels this desktop still handed to GNOME Settings. Users manages the account through accountsservice -- the same daemon GNOME's panel drives, so a name or picture set here is what the login screen and lock screen read. Name, picture, account type, password, automatic login, and adding or removing other accounts. Every change is authorized by polkit through the agent this session already runs; a dismissed prompt is a normal outcome and says so. A new password is read from the helper's stdin, hashed by openssl reading its own stdin, and handed over D-Bus from inside that process. It is never an argument: argv is world-readable through /proc, so a password passed that way is published to every process on the machine. Removing an account takes two presses and says it destroys their files; the last administrator cannot be removed or demoted, because a machine nobody can administer is not a state to offer. Sharing reports what is actually true, including "the software for this is not installed" -- the honest answer for Samba here, and the case the panel it replaces shows as a switch that does nothing. Password sign-in is reported from sshd's configuration rather than assumed: claiming "keys only" when the file is silent would state a security property that cannot be backed up. The Control Center now draws the account's real picture and name. A generic glyph sat there while a real avatar was already set, which made the desktop look like it did not know whose it was. Also here: the KDE Connect contract no longer requires a phone to be awake. kdeconnectd drops its device objects for a phone it has not seen recently while the pairing survives in its config, so demanding one failed whenever the phone was off. Claude-Session: https://claude.ai/code/session_01BRvzt4H8XXLPVH5MyYdk9L |
||
|
|
e0c0e53ae0 |
Say what the sweep helper is, when run on its own
A suite runner walking the tests directory executes it with no arguments and gets an IndexError, which reads as a failing test rather than a helper being used wrongly. Claude-Session: https://claude.ai/code/session_01BRvzt4H8XXLPVH5MyYdk9L |
||
|
|
180308135a |
Verify every setting actually does something
The audit that followed "some things in the settings app don't work". Sixty-five settings reach the compositor and five of them were checked against it. The rest were covered only by tests that read source text, which is exactly where a dead setting hides: the write no-ops, nothing fails, nothing logs, and the row simply does nothing. The sweep drives each setting through SystemSettings.commitPreference -- the entry point a settings row uses -- flips it to a value it does not hold, reads it back from the live compositor, and puts it straight back before touching the next one. Settings Panama stores itself get the same treatment against the store, since a value that fails to persist is the same dead row from the outside. Result: 61 of 63 compositor settings verified against the running compositor, and 51 stored settings round-tripped. No failures. The breakage was confined to the Applications page, which is fixed. Proven able to fail before being trusted: with commitPreference stubbed to return true without applying, 61 settings are reported; with the store stubbed to return nothing, 51 are. A one-second settle window keeps a slow read from being reported as a dead write, which it briefly was. Also here: control-center-contract asserted the literal margin expression that made the panel hang 38 pixels below the bar, so the contract and the code agreed and the bug was invisible to both. And settings-page-registry-contract is deleted -- settings-nav-contract already checked those files and more. It would have caught the Storage page omission if I had run the suite instead of a hand-picked subset. Claude-Session: https://claude.ai/code/session_01BRvzt4H8XXLPVH5MyYdk9L |