Files
Panama/config/dot/quickshell/modules/settings
Gabriel Brown cb7c09d208 Give the desktop real themes, video wallpapers, and honest titlebars
Appearance now opens on Themes: light and dark side by side, each
remembering its own choice, over galleries of ten shipped themes —
Tokyo Moon and Day joined by Moon Rose, Catppuccin, Nord, Gruvbox and
Everforest in both modes. A theme is a complete palette: the catalog
lives in themes.json, Theme.qml reads every color token from the
active record, and one render pipeline carries it to kitty, tmux,
btop, GTK, Vicinae, Firefox's chrome, and the lock screen. The Theme
editor builds new ones from four wells — wheel, hex, or eyedropper —
with derived surfaces, a saturation slider, debounced fine-tune, and
effects that save with the theme. Custom edits finally keep GNOME's
accent, kitty's border, and hyprlock in sync.

Wallpapers can be video: mpvpaper per output, hardware-decoded, muted
and looped, supervised and respawned. Panama owns the pausing — games,
battery, and a bar pill for right now — because the compositor
rebuilds full-screen blur for every frame a video wallpaper draws.
The lock screen gets a still frame.

Titlebars stop lying. GNOME apps get close-only on your chosen side,
the maximize and double-click settings are gone, the Settings window
obeys the same rules, and its titlebar can be turned off entirely.
Typography becomes five labeled dropdowns instead of a wall of
samples.

Contracts updated and written throughout (165 now); per the redesign
workflow none were executed — the full sweep runs once at the end.

Claude-Session: https://claude.ai/code/session_01Ms2FbjQy31TVf3CEvQhGM8
2026-08-23 23:39:04 -04:00
..
2026-08-18 15:00:18 -04:00
2026-08-19 13:05:13 -04:00
2026-08-19 13:05:13 -04:00
2026-08-19 15:07:17 -04:00
2026-08-19 13:05:13 -04:00
2026-08-19 13:05:13 -04:00
2026-08-19 13:05:13 -04:00

Settings

The control center for everything Panama owns. Anything the system owns — hardware, accounts, printers — is delegated to GNOME Settings and labeled as such rather than half-reimplemented.

Navigation

The sidebar lists fourteen categories, not one row per page. A category covering several subjects — Home, Input, Network & Sharing, Applications, Users & Accounts, Privacy & Security, System — draws a tab strip above the page, and each of its leaf pages is one tab; a category with a single subject is a leaf itself and shows no strip. The column used to be a flat list of thirty-one pages, which made finding Printers a scan of the whole thing. Grouped, it sits under Network & Sharing, where somebody looking for it already expects it.

Home carries three tabs: Overview (home, the quick-action tiles, findings, glance cards, Today and Weather that the shell opens by default), My Home (my-home, Home Assistant lights grouped by room, accessories, and the connection card) and Phone (phone, vitals, ring, clipboard, send file, and BlueBubbles Messages). It absorbed the former standalone Home & Phone category, whose home-phone id is now retired.

Leaf pages are ordinary SettingsPage files and know nothing about this. SettingsShell draws the strip and hosts the page inside it, so moving a page between categories never touches the page.

services/SettingsRoutes.qml is the one place the taxonomy is written down. The sidebar, the tab strip, ShellState's route validation, the breadcrumb subtitles on search results ("System Storage"), and scripts/panama-settings-commands all derive from it — so a page moves in one edit rather than five, and none of them can disagree about where it lives.

ShellState.settingsPage still holds a leaf id, the same ids callers have always used, so every existing deep link, IPC call, and search hit keeps working and now lands on the exact tab. A category id is accepted too, and resolves to that category's first available tab.

A retired id never stops resolving. When a page is split, merged, or renamed, its old id goes into SettingsRoutes.retired — a map from the dead id to the leaf that absorbed its content — rather than being deleted. Old Vicinae commands, shell history, notification handoffs, and muscle memory all hold those ids, and resolve() checks the map first, so home-phone still lands on My Home instead of falling back to Home. Retired ids are aliases only: they are not leaves, so nothing generates a command, a tab, or a doc heading for them.

Availability gating belongs to the strip rather than the sidebar: SettingsRoutes.pageAvailable() drops the Containers tab on a machine without podman and Snapshots without a snapper configuration, but only once a scan has proven the stack absent, so a machine that has it never sees the tab blink.

System Health

The stable internal services route renders System Health, a tab of System. It is reachable from there, from its live 54px footer, from the degraded-only bar indicator, and from Vicinae's 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, Color profiles, and Digital wellbeing handoffs open GNOME Settings because Fedora's system services own those areas.

Appearance

Six tabs, in the order the questions are actually asked: Themes, Theme editor, Background, Typography, Windows, Shell. Themes leads because light and dark is the control reached most often, and it used to be the third section down, under a wallpaper grid and the whole lock screen. The old theme section id still resolves to it, so deep links and IPC calls keep working.

Themes

config/themes.json is the catalog: ten shipped themes, each a full palette of nineteen tokens plus sixteen ANSI colors. services/ThemeCatalog.qml reads it with a FileView and carries Moon and Day as an embedded fallback, so the shell renders correctly for the instant before the file loads and forever on a machine where it is missing.

The tab is two galleries — dark and light — rather than a card each. Selection is per mode: themeDark and themeLight remember what you chose on each side, so flipping light and dark lands on your theme for that side rather than resetting to the shipped defaults.

services/ThemeProfiles.qml resolves the active record; Theme.qml reads every one of its nineteen color tokens from ThemeProfiles.activePalette. There are no palette ternaries left in Theme.qml, which is what makes a tenth theme a data change rather than a code change.

Theme editor

Four wells — primary, secondary, background, foreground — a saturation slider, an effects card, and the six HSV rows demoted to a fine-tune behind a disclosure. Background and foreground are never stored alone: the five surfaces and two text tints are mixed from them, so changing the ground moves the whole family instead of leaving twelve tokens pointing at the old one.

Every route that writes color — typed hex, the colour wheel, the eyedropper, the HSV sliders — ends in ThemeProfiles.commitActive, which also recomputes accentName as the nearest curated accent. That is what keeps GNOME's accent enum, kitty's border and the lock screen from going stale after a custom edit. Editing a shipped theme forks it into a named custom carrying the whole palette; saving snapshots the palette, the terminal colors, and all ten effect values into the record.

The HSV rows commit on a debounce, not per move. Writing on every move meant one drag across the hue row spent the whole gesture in apply-and-verify round trips with the desktop repainting behind the pointer.

Backgrounds, still and moving

Stills go through hyprpaper. Videos go through mpvpaper, supervised by services/VideoWallpaper.qml, and Panama owns the pause policy rather than the compositor: the video pauses whenever a game runs, on battery if the preference says so, and whenever the bar pill is clicked — over mpv's JSON IPC socket, so resuming does not restart the clip. hyprpaper's service is stopped while a video plays, because both claim the background layer and stacking within a layer is creation order. The lock screen gets a cached still frame; hyprlock cannot play motion, and pretending otherwise would show a black screen.

The honest titlebar

There is no minimize or maximize control, and no setting for one. Hyprland has no minimize — it receives the request and does nothing with it — and maximize is noise in a tiler, so DesktopStyle pushes a close-only GNOME button layout and Panama's own Settings titlebar shows one button. panamaTitlebar turns that bar off entirely, leaving the window pure Hyprland: Super+Q closes, Super+drag moves, Escape still works.

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 labeled 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 behavior 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.

Lock-screen visuals belong only to Appearance: background source, blur, clock, date, user name, and password-field presentation. Power owns when the session locks, while Privacy keeps only the established timing mirrors above. Visual controls must not be copied onto either page.

Displays is the sole owner of mode, scale, rotation, arrangement, and primary role. Those values form one safety transaction: every connected output is applied, verified, confirmed, or restored together. Other pages may link to Displays, but must never expose a second geometry control or persist a partial layout.

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.

Window border color follows the same ownership rule. The inactive border is a neutral contrast role — a scheme-relative role in the sense that it exists to stay legible on either ground — owned by ColorScheme.qml, and it is drawn from the active theme's gutter rather than from a hardcoded pair. The two literals that used to live there were correct for two of the ten shipped themes and for no custom one. The focused Prism border is the accent role, driven by the chosen accentName and also owned by ColorScheme.qml: each accent carries a separate pair for light and dark. Both roles are restated on a scheme change, an accent change, and a theme change, rather than leaving a flip to erase a user-selected accent or a theme switch to leave the border behind.

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 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 favorites and aliases
$XDG_STATE_HOME/panama/backups/ Settings snapshots
$XDG_STATE_HOME/panama/hypridle.conf Generated idle config
$XDG_STATE_HOME/panama/hyprlock.conf Generated lock-screen appearance

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.