Two things, both closing gaps in work that was already reported done. GNOME's Multitasking panel, in Hyprland's terms: tiling layout, split behaviour, floating-window snapping, workspace wrap-around and back-and-forth, whether applications may take focus, and whether the pointer changes the active display. Hyprland creates and destroys workspaces as you use them, so there is no fixed count to expose, and the page says so rather than leaving a conspicuous absence. The Desktop page described the first two of these as read-only facts -- "Layout: Tiling", "Workspace movement: Dynamic" -- which was never true. Both are ordinary Hyprland options that happened to have no controls, and TextRow's own documentation says a setting the user could reasonably change does not belong in it. Schema defaults are Panama's shipped values from looks.lua rather than Hyprland's own, so restoring defaults returns the desktop to how it ships. 43 mapped options now, from 35. The launcher had no light theme. vicinae.json already selected a theme per system appearance, but both entries pointed at Moon, so choosing light mode left the most frequently opened window on the desktop dark -- a hole in the light/dark work, not a missing feature. Day is authored from the same palette as the kitty Day theme so the two cannot drift, and link-dotfiles now installs every authored theme rather than only the dark one, which is why the gap survived being noticed. Its placeholder colour is not Tokyo Night Day's own: that measures 2.54:1 against the background, below the 3:1 floor for secondary text. This is 3.25:1, the same value used for neovim's light comments. Claude-Session: https://claude.ai/code/session_01BRvzt4H8XXLPVH5MyYdk9L
Panama Settings
The control centre for everything Panama owns. Anything the system owns — hardware, accounts, printers — is delegated to GNOME Settings and labelled as such rather than half-reimplemented.
Adding a setting
One schema entry. That is the whole job.
// config/PreferenceSchema.qml
{
key: "blurSize", type: "int", def: 8, min: 1, max: 20, step: 1,
unit: "px", group: "effects",
label: "Blur radius",
detail: "Larger is softer and costs more frame time",
hypr: { path: ["decoration", "blur", "size"], option: "decoration:blur:size", readAs: "int" }
}
// the page
SliderRow { setting: "blurSize" }
Persistence, validation, clamping, reset, search indexing, and — with a hypr
block — live application to the compositor and the startup replay all derive
from that entry. There is nothing else to register.
If it is compositor-backed, add the matching prefs.get("blurSize", 8) in
hypr/looks.lua so the Hyprland config still stands alone with no settings
file.
The rows
| Component | For |
|---|---|
SettingsPage |
The page scaffold: title, lede, optional pinned header |
ToggleRow { setting } |
A boolean |
SliderRow { setting } |
A number; zeroLabel renders 0 as "Never"/"Instant"/"None" |
ChoiceRow { setting } |
An enum, as a segmented control |
ActionRow |
A button: opens a GNOME panel, runs a one-shot |
TextRow |
A genuinely read-only fact |
TextRow is for facts, not for settings that were merely expensive to wire.
Before Stage 3 more than half of all rows were static text standing in for
controls; that is the failure this vocabulary exists to prevent.
Rows write through SystemSettings.commitPreference(key, value), which routes
compositor-backed keys through apply-and-verify and local keys straight to the
store. A row never needs to know which kind it holds.
Things that will bite you
readAs describes the answer, not the setting. hyprctl getoption returns
the value in a different JSON field per type — int, bool, float, str,
and css for gaps (a four-value box). Declaring the wrong one does not fail
loudly: it makes every write to that key look rejected, and the user sees an
error for a change that worked. tests/quickshell/schema-hypr-shape-contract.sh
asks the compositor for the real shape of every mapped option.
Never trust an exit code from hyprctl. keyword refuses to work on a
Lua-configured Hyprland, prints the refusal to stdout, and exits 0. eval exits
0 on syntax and runtime errors too. The only trustworthy signal that a write
landed is reading the value back.
The Settings window is tiled. implicitWidth is a hint; the layout decides,
and it ranges from a half-screen split to the full display. SliderRow stacks
its control under the label below 520px. Test narrow.
Binding an anchor to undefined does not reliably release it. Switching
layouts that way left a slider anchored to both edges with the label squeezed
into what was left. Position explicitly instead.
Inside a SettingsCard, parent is the card's internal Column. So
parent.modelData in a nested Repeater is undefined and the rows silently
never appear — you get a card with a heading and nothing under it. Address the
outer model through an explicit id.
A TapHandler declared as a child of SettingRow lands in the trailing
slot, because that is the row's default property, so only the right-hand edge
becomes clickable. Use activatable: true with onActivated for a whole-row
target.
A copy of the Quickshell config shares the live shell's ID. Quickshell
derives the Shell ID from config content, not path, so
cp -a config/dot/quickshell $tmp && qs -p $tmp kill kills the running
desktop, and qs -p $tmp ipc call … can drive it. Harnesses that point at a
single distinct .qml file are safe; copying the whole directory is not.
Where state lives
| File | Holds |
|---|---|
~/.config/panama/settings.json |
Everything in the schema. Read by the shell and by hypr/prefs.lua |
$XDG_STATE_HOME/panama/panama-home.json |
Home accessory favourites and aliases |
$XDG_STATE_HOME/panama/backups/ |
Settings snapshots |
$XDG_STATE_HOME/panama/hypridle.conf |
Generated idle config |
SystemSettings.restoreDefaults() spans all of them. A reset that silently
skipped one would be worse than having no reset, because nothing would say so.
Not stored by Panama
Timezone and network time are read from and written to timedatectl directly.
They belong to the machine and are shared with sessions that never see Panama's
file; storing a copy would create a second answer to a question the system
already answers.