Four places in the entire shell could reach Settings. The bar, where a person looks first, was not one of them -- and Pill has routed right-click to a secondaryActivated signal all along, which nothing connected, so the gesture did nothing on every widget in the bar. Each widget now opens the page that owns its settings: the clock and the calendar reminder open Date & Time, weather opens Home, the vitals readout opens Appearance, the status glyphs open Network & Devices, the media readout opens Sound, and the privacy indicator opens Privacy & Security. Left-click behaviour is untouched. Two routing bugs found while picking those destinations, both of the same kind and both invisible from the code, since each page reads perfectly well on its own: weather routed to Appearance while every weather control lives on Home, so searching "temperature unit" opened a page without it. vitals routed to Appearance, but the refresh interval sat on Home while the toggles it governs sat on Appearance -- one concept split across two pages, which is exactly what the ownership rule forbids. The interval now sits beside the toggles and Home's stub card is gone. The jump contract guards the failure mode these share. openSettings() falls back to Home for an unknown page, sensibly and completely silently, so a typo or a later rename turns a right-click into "opens the wrong page" with nothing logged. It also fails a Pill-based bar widget that leaves right-click unconnected, since that is how the gesture came to be inert everywhere in the first place. A third instance of the routing bug is still open: followMouse and pointerSensitivity sit in the input group, which routes to Keyboard, while both render on Mouse. Fixing it is a two-line group change in PreferenceSchema.qml, which codex currently owns, so the contract that catches all three lands with that fix rather than red. Claude-Session: https://claude.ai/code/session_01BRvzt4H8XXLPVH5MyYdk9L
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.
System Health
The stable internal services route renders System Health. It is reachable
from the Settings sidebar and its live 54px footer, the degraded-only bar
indicator, and Vicinae's Panama: Check System Health command. Healthy scans
reserve no bar space and produce no notification.
services/Health.qml owns the last accepted redacted snapshot. It invokes
scripts/panama-doctor for scans and bounded repairs, wl-copy only for an
explicit Copy Report, and bounded notify-send only when an external repair
fails. For a concise terminal view, run:
~/.config/quickshell/scripts/panama-doctor --summary
The helper diagnoses Panama-owned desktop services, dependencies, links, and
configured integrations. It does not read secret values, clipboard or
notification contents, calendar events, SSIDs, addresses, or arbitrary command
output. Its repair interface is an authored allow-list: it never installs a
package, runs sudo, deletes user data, or repairs a service Panama does not
own. A repair remains degraded until a fresh scan observes recovery.
The final card is the ownership boundary. Network configuration and the exact Users, Sharing, Colour profiles, and Digital wellbeing handoffs open GNOME Settings because Fedora's system services own those areas.
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.
Setting ownership
Every preference has one primary page, derived from its schema group and
the route in services/SettingsSearch.qml. Search results always open that
owner. A control may appear on a second page only when the same adaptation is
part of another established mental model; otherwise use a labelled handoff to
the owner instead of duplicating it.
Intentional mirrors
| Setting | Primary page | Mirror | Why the mirror earns its place |
|---|---|---|---|
animationsEnabled |
Appearance | Accessibility | Reduced motion belongs both to visual polish and motion accessibility. |
cursorInactiveTimeout |
Mouse | Accessibility | Pointer visibility is configured with pointer behaviour but affects motor and visual access. |
cursorSize |
Accessibility | Mouse | Large cursors are an accessibility adaptation that users also look for beside pointer controls. |
inactiveOpacity |
Appearance | Accessibility | Window translucency is an appearance choice with a direct readability impact. |
lockMinutes |
Power | Privacy | Idle timing owns the mechanism; privacy owns the expectation that the unattended desktop locks. |
lockOnSleep |
Power | Privacy | Suspend owns the transition; privacy owns whether waking requires authentication. |
Mirrors must remain the same schema-backed control, never a second preference
or a copied default. Additions to this table require a concrete discoverability
reason and an update to tests/quickshell/settings-ownership-contract.sh.
Window border colour follows the same ownership rule. The inactive border is a
scheme-relative role owned by ColorScheme.qml: it changes only to retain
neutral contrast in light and dark modes. The focused Prism border is the
accent role owned by the visual theme (and, eventually, an accent picker).
ColorScheme.qml must never write the focused border, so changing schemes
cannot erase a user-selected accent.
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 content-identical Quickshell entry can share the live shell's ID.
Quickshell derives the Shell ID from config content, not path. Runtime
harnesses therefore create a distinct semantic entry file, address that exact
file with qs -p, and discover its PID from the exact Config path in
qs list --all. They terminate only that recorded PID with kill; never use
qs kill from a copied configuration.
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.