Home is now Overview | My Home | Phone. Overview leads with quick-action tiles (focus, Do Not Disturb, health, snapshots, storage), keeps the findings card — updates fold in, the reclaim-space prompt is gone on purpose — and adds glance cards, the next calendar event, and weather. My Home groups every light by Home Assistant area: the helper gained an `areas` command (one REST template render, no websocket), and the rooms degrade to a flat list on setups without areas. The favorites editor and connection card moved intact. Phone gains a vitals strip — battery and cell signal read from KDE Connect's plugin D-Bus objects, where absence is data, not an error — beside ring, clipboard, send-a-file, and the BlueBubbles handoff. The retired home-phone id resolves to my-home forever via a new alias map in SettingsRoutes (with a hasOwnProperty guard so prototype names cannot leak into settingsPage). Storage no longer claims 0 B free — the old page read a field the disks helper never emitted. Contracts updated alongside; per the new workflow, the full suite runs once at the end of the redesign (see the test backlog note). Claude-Session: https://claude.ai/code/session_01Ms2FbjQy31TVf3CEvQhGM8
229 lines
11 KiB
Markdown
229 lines
11 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.
|
||
|
||
## 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
|
||
**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**, driven by the chosen `accentName` and also owned by
|
||
`ColorScheme.qml`: each accent carries a separate pair for light and dark, so
|
||
`ColorScheme.qml` restates the focused border alongside the inactive one on
|
||
every scheme change, rather than leaving a scheme flip to 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`
|
||
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.
|