Desktop & Dock becomes Shell — Bar, Dock, Control Center, Tiling, Workspaces — the home for everything Quickshell draws. The settings- management cluster moves to System as Sync & Backup, Appearance's Shell tab dissolves, and 24-hour time finally lives on Date & Time, which always owned it. The bar gets what it never had: a way to survive the wallpaper. A second neutral text family (follow theme, or forced light or dark), a one-layer shadow under every glyph, and a gradient scrim for wallpapers nothing else survives — all off by default, pixel-identical until asked. Widgets earn toggles (weather, media, clipboard, calendar countdown), the vitals cluster stops leaving a dead pill behind, and Control Center's sections learn to step aside. The dock graduates from MVP: a context menu with window rows, pin, unpin, quit and new-window; scroll an icon to cycle its windows; drag to reorder on the dock itself; hover previews with one-shot captures; and "Add App to Dock" in the launcher. Three real bugs died en route — menus that slid away with the autohide, a readonly-property crash on every menu open, and a drag that drifted half a slot per icon on side docks. The pinned-apps editor in Settings becomes a drag strip. 166 contracts; the full suite is green except two live display and switcher tests that cannot run behind a locked session — re-verified on unlock. Claude-Session: https://claude.ai/code/session_01Ms2FbjQy31TVf3CEvQhGM8
404 lines
21 KiB
Markdown
404 lines
21 KiB
Markdown
# 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:
|
||
|
||
```bash
|
||
~/.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
|
||
|
||
Five tabs, in the order the questions are actually asked: **Themes**, **Theme
|
||
editor**, **Background**, **Typography**, **Windows**. 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.
|
||
|
||
The sixth tab, **Shell**, is gone: its two cards moved to **Shell › Bar**, which
|
||
is where somebody who wants to change the bar now looks. Appearance answers what
|
||
the desktop looks like; Shell answers what the desktop *is*.
|
||
|
||
### 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.
|
||
|
||
## Shell
|
||
|
||
Everything Quickshell itself draws, in five tabs: **Bar**, **Dock**, **Control
|
||
Center**, **Tiling**, **Workspaces**. The category used to be **Desktop & Dock**,
|
||
a single page that held the dock alongside window layout, workspaces, and the
|
||
settings-management cluster — three unrelated subjects and a filing cabinet. Its
|
||
`desktop` id is retired to `bar`, so old deep links land on the first tab.
|
||
|
||
The split is by *surface*. If you can point at it on screen, it has a tab.
|
||
|
||
### Bar
|
||
|
||
The first settings surface the bar has ever had. Three cards.
|
||
|
||
**Visibility** exists because the bar is the one thing the shell draws that has
|
||
no ground of its own — it floats on the wallpaper, which the theme has never
|
||
seen. So a palette that is correct everywhere else can still be unreadable
|
||
exactly where the clock is. `barTextTone` (`theme`/`light`/`dark`) drives a
|
||
second neutral family in `Theme.qml` — `barFg`, `barFgDim`, `barFgMuted` — that
|
||
thirteen bar widgets bind to instead of `fg`/`fgDim`/`fgMuted`. Left on `theme`
|
||
the family *is* the fg family by identity, so nothing changes for anyone who
|
||
never asks, and a custom palette still reaches the bar. Semantic tones (warn,
|
||
danger, accent, ok) never became bar tones: a battery at 4% is red whatever the
|
||
neutrals were forced to.
|
||
|
||
`barTextShadow` and `barBackdrop` are the two escape hatches for a wallpaper no
|
||
tone wins against. The shadow is **one** `layer.effect` MultiEffect over the
|
||
whole of `Bar.qml`'s content rather than one per widget, so a widget added
|
||
tomorrow picks it up without opting in; the backdrop is a static top-down
|
||
gradient Rectangle behind the content rows. Both default off and both are read
|
||
from the preference — during the build they briefly landed as literal `true`,
|
||
which forced a dark band and an extra compositing layer on everyone.
|
||
`tests/quickshell/bar-visibility-contract` pins both bindings for that reason,
|
||
along with every widget's use of the bar tones.
|
||
|
||
**Widgets** is one toggle per thing that earns a place: weather, media,
|
||
clipboard, calendar countdown, the three vitals readouts, battery, agent usage.
|
||
Each toggle is **ANDed** with the widget's own state condition rather than
|
||
replacing it, so switching one on never conjures a pill with nothing in it.
|
||
State-driven indicators — health, activity, focus, video wallpaper — get no
|
||
toggle: they appear when they have something to say and leave when they are
|
||
done. `VitalsWidget` answers for its own pill as well as its three fields,
|
||
because an invisible child still occupies its Row and gating the fields alone
|
||
left a padded, empty pill in the bar.
|
||
|
||
**Clock** keeps `showSeconds` and `showWeekday`. `use24Hour` is *not* here: it
|
||
drives the date menu, notification timestamps, and the lock screen too, so it
|
||
moved to group `datetime` and lives on **System › Date & Time**. The card says
|
||
so rather than leaving its absence looking like an oversight.
|
||
|
||
Right-clicking the vitals or agent-usage pill opens this page.
|
||
|
||
### Dock
|
||
|
||
Behavior — position, screens, autohide, reveal and hide delays, icon size —
|
||
unchanged. **Pinned applications** is now `DockPinsStrip.qml`: the dock's own
|
||
row of icons, resolved exactly the way the dock resolves them, drag to reorder,
|
||
hover for the unpin ×, `DockAppPicker` search underneath. It replaces a sixteen-
|
||
row list of names, which was honest but was not the thing being edited — the
|
||
order of a horizontal row of icons was being decided in a vertical column of
|
||
text.
|
||
|
||
Two details make the drag safe: `preventStealing` on the grabbing MouseArea, or
|
||
the page's Flickable claims the gesture and the icon never moves; and the order
|
||
is held in the strip while the drag runs and written **once** on release, rather
|
||
than rewriting `settings.json` a dozen times for one gesture. Every icon is also
|
||
a tab stop — Left and Right move it, Delete unpins it — because a drag is not
|
||
reachable from the keyboard.
|
||
|
||
The live dock gained the matching gestures: drag to reorder on the dock itself,
|
||
a right-click menu (window rows, pin, unpin, quit, new window, and a **Dock
|
||
settings** entry that deep-links here), scroll an icon to cycle its windows, and
|
||
hover previews from `DockPreviews.qml`. `DockPickOverlay.qml` plus the `dock`
|
||
IPC target back the launcher's **Add App to Dock** command, so pinning never
|
||
requires opening Settings at all.
|
||
|
||
### Control Center
|
||
|
||
The quick settings panel's first settings surface. **Sections** is one
|
||
`ccShow*` bool per real section of `QuickSettingsPanel` — Focus, Home, Phone —
|
||
and only for sections that actually exist; a toggle for a section the panel does
|
||
not draw is a control that writes a preference nothing reads. Turning one off
|
||
hides it from the panel and stops nothing: the page behind it still works.
|
||
**Accessories** is a labeled handoff to Home › My Home rather than a second copy
|
||
of the shelf editor.
|
||
|
||
### Tiling and Workspaces
|
||
|
||
Rows moved unchanged. Tiling holds window layout (with the handoff to Appearance
|
||
› Windows for gaps, corners, and effects), master and stack, window edges, and
|
||
the Hyprland notices. Workspaces holds the workspace and focus toggles, focus
|
||
session length, and Projects.
|
||
|
||
## System › Sync & Backup
|
||
|
||
`SyncPage.qml`, the tenth tab of System: **Carry settings to another machine**
|
||
(`SettingsSync`), **Settings backups** (`SettingsBackup`), and **Reset**. It was
|
||
the settings-management cluster on the old Desktop page, which is the one thing
|
||
on that page that was never a surface. Backups is titled that way deliberately
|
||
so it stops colliding with the btrfs **Snapshots** tab one along — the two mean
|
||
different things and used to share a word. The search index's **Restore
|
||
defaults** entry points here.
|
||
|
||
## Adding a setting
|
||
|
||
One schema entry. That is the whole job.
|
||
|
||
```qml
|
||
// 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" }
|
||
}
|
||
```
|
||
|
||
```qml
|
||
// 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.
|