Make Panama settings one shared source of truth

Panama had grown into three configuration surfaces that only agreed because
they had been typed to agree: looks.lua hardcoded values, DesktopPreferences
independently defaulted the same values, and SystemSettings replayed them at
startup. Nothing kept them in sync, and the Lua side read no shared state at
all. This lands the first three stages of docs/superpowers/plans/2026-08-17-panama-cohesion.md.

Fix silently failing Hyprland writes. On a Lua-configured Hyprland, hyprctl
keyword refuses the write, prints the refusal to stdout, and still exits 0, so
the HDR, VRR, and direct-scanout toggles persisted their value and reported
success while the compositor never changed. Writes now go through hyprctl eval,
which has the same hazard on syntax and runtime errors, so success is defined
as reading the value back and finding it equal. The existing contract passed
throughout the outage because it re-applied the values already in place; the
new one flips each value to something it does not hold.

Derive preferences from a schema. Every setting used to be restated four times
-- a property alias, a JSON adapter property, a change handler, and a line in
reset -- where omitting any one failed silently. PreferenceSchema.qml is now
the single source, and persistence, validation, reset, and the Hyprland mapping
all derive from it. Unknown keys on disk survive a write so a rollback does not
discard a newer build's settings, and a corrupt file falls back to shipped
defaults. The store moved to ~/.config/panama/settings.json, migrating from the
old state directory without deleting it.

Share that file with Hyprland. prefs.lua reads it at config time with every
shipped literal kept as the fallback, so the config still stands alone. The Lua
is the default, the JSON is the truth, and Settings is the editor. The
compositor-adjustable surface goes from 3 keys to 23.

Also fixes two test-hygiene bugs found by running the suite end to end for the
first time: settings-pages-contract could see the window settings-window-contract
leaves behind, and the new write contract was persisting its deliberately-wrong
values into the user's real store.

Claude-Session: https://claude.ai/code/session_01BRvzt4H8XXLPVH5MyYdk9L
This commit is contained in:
Gabriel Brown
2026-08-17 23:26:56 -04:00
parent c42794c5e2
commit 00a81edadd
26 changed files with 2108 additions and 222 deletions
@@ -0,0 +1,242 @@
# Panama Cohesion Design
## Purpose
Panama has grown from a Hyprland config into a desktop: 15 Lua/conf files, 130 QML
files, 18 services, a 12-page settings application, and 29 contract tests. Each
feature was built well on its own. What is missing is the seam between them.
This document audits the current state and defines the architecture that turns
the pieces into one product, with a single goal:
> **A user should never need a text editor to change how their desktop behaves.**
That goal is not currently met, and the reason is structural rather than a matter
of missing pages.
---
## Part 1 — Audit
### Finding 1 (critical, live bug): every Hyprland write from Settings is a no-op
`services/SystemSettings.qml` applies display policy with `hyprctl keyword`:
```qml
autoHdrWrite.exec(["hyprctl", "keyword", "render:cm_auto_hdr", enabled ? "1" : "0"]);
```
On a Lua-configured Hyprland, `hyprctl keyword` does not work:
```
$ hyprctl getoption decoration:rounding -j → "int": 18
$ hyprctl keyword decoration:rounding 4
keyword can't work with non-legacy parsers. Use eval.
$ echo $? → 0
$ hyprctl getoption decoration:rounding -j → "int": 18
```
It prints the refusal to **stdout** and exits **0**. `SystemSettings` branches on
`exitCode === 0`, so all three writers take the success path: they persist the
requested value to `panama-settings.json`, clear `lastError`, and the UI redraws
as if the change took effect. Nothing reached the compositor.
Game-aware HDR, VRR policy, and direct scanout have therefore never worked from
Settings, and the app confidently reports that they did. `applyPersistedDisplayPolicy()`
replays the same three no-ops one second after every shell start.
The correct mechanism on this build is `hyprctl eval`, which is verified working:
```
$ hyprctl eval 'hl.config({ decoration = { rounding = 4 } })' → ok
$ hyprctl getoption decoration:rounding -j → "int": 4
```
`eval` is also strictly more capable than `keyword` — it can set *any* config
value, including gradients, animation curves, and nested tables. It executes
arbitrary Lua, so the existing "never interpolate UI text into a command" rule
must extend to it: values are validated and serialised numerically, never
concatenated from user input.
### Finding 2: three disconnected sources of truth for the same three values
| Value | `looks.lua` | `DesktopPreferences.qml` | Applied by |
| --- | --- | --- | --- |
| `render:cm_auto_hdr` | `1` | `autoHdr: true` | `hyprctl keyword` (no-op) |
| `misc:vrr` | `3` | `vrrPolicy: 3` | `hyprctl keyword` (no-op) |
| `render:direct_scanout` | `2` | `directScanoutPolicy: 2` | `hyprctl keyword` (no-op) |
They agree today only because they were typed to agree. Editing the Lua does not
change what Settings displays; changing Settings does not touch the Lua. The Lua
side reads no shared state whatsoever — there is no bridge in either direction.
### Finding 3: the preference store costs four hand-edits per knob
Every key in `config/DesktopPreferences.qml` is written out four times: a
`property alias`, a `JsonAdapter` property, a `Connections` handler, and a line
in `resetDesktopDefaults()`. Seventeen keys produce 68 lines of pure bookkeeping.
The failure modes are silent. Omit the `Connections` handler and the setting
stops persisting with no error. Omit the reset line and "Restore defaults"
quietly skips it. This cost is the direct reason the settings app stalled at 16
user-facing knobs.
### Finding 4: ~40 comparable values are hardcoded one file away
`config/Settings.qml` still hardcodes, as `readonly`: weather latitude/longitude,
location label, temperature unit and refresh interval; the vitals poll interval
and the GPU sysfs path; the night-light schedule (`17.0``10.0`); all four
notification timing and history limits; the 16-entry dock pin list; the
screenshot and recording directories; and the `wf-recorder` argument string.
Every one of these is exactly the kind of thing the settings app exists for. None
is reachable from it.
### Finding 5: appearance is not adjustable at all
`Theme.qml` defines ~50 tokens, all `readonly`, none mutable. The Appearance
page's "Theme" card is two dead text rows:
```qml
SettingRow { label: "Color palette"; detail: "Tokyo Night Moon"; value: "Prism" }
SettingRow { label: "Interface type"; detail: "Adwaita Sans"; value: "System" }
```
Meanwhile `looks.lua` hardcodes gaps, border size, rounding, blur, shadow, glow,
and fourteen animation curves. The page named "Appearance" adjusts a clock format
and three vitals toggles.
### Finding 6: Shortcuts is a hand-typed copy of 19 of 113 real binds
`keybinds.lua` makes 95 `hl.bind` calls producing **113** live binds. The page
hardcodes an array of **19**. It cannot show the other 94, cannot change any, and
drifts the moment a bind is edited.
The compositor already exposes the real list, and it is 74% self-describing:
```
$ hyprctl binds -j → 113 binds, 84 carrying a human description
modmask 64 key T description "Terminal"
```
### Finding 7: "Restore defaults" is incomplete by construction
`resetDesktopDefaults()` resets `DesktopPreferences` only. Anything persisted
elsewhere survives an action that claims to restore Panama's defaults.
### Finding 8: page scaffolding is copy-pasted eleven times
Every page repeats the same `Flickable``Column``x: 34``y: 30`
title/subtitle block. Every toggle repeats a four-property inline anchor
incantation:
```qml
SettingsToggle { anchors.right: parent.right; anchors.verticalCenter: parent.verticalCenter;
checked: DesktopPreferences.showCpu; onToggled: value => DesktopPreferences.showCpu = value }
```
Across eleven pages there are 56 rows but only ~15 toggles, 14 buttons, and 3
sliders — over half the rows are static text. Pages settled for read-only text
because a real control was expensive to add. That is a tooling problem wearing a
product problem's clothes.
### What is genuinely good and must be preserved
- The `SystemSettings` allow-list discipline — UI never builds a command string.
- The Prism design language and its restraint, documented in `Theme.qml`.
- Event-driven motion; nothing repaints while idle.
- The 29 contract tests and the spec → plan → implement workflow.
- Delegation of hardware, accounts, and printers to GNOME rather than
half-reimplementing them.
---
## Part 2 — Target architecture
Four changes, in dependency order. Each is independently useful and independently
shippable.
### A. One schema, one store
Replace the hand-maintained preference object with a declarative schema —
one entry per setting carrying key, type, default, bounds or options, group,
label, and detail:
```qml
{ key: "dockHideDelayMs", type: "int", def: 250, min: 0, max: 2000, step: 25,
group: "dock", label: "Hide delay",
detail: "Prevents flicker when crossing icons" }
```
Persistence, change notification, validation, reset, and the settings UI all
derive from that one entry. Adding a knob becomes one line instead of four edits
plus a hand-built row, and reset becomes complete by construction rather than by
diligence.
The store moves from Quickshell's opaque per-shell state directory to
`~/.config/panama/settings.json`, so it is a stable path that the compositor can
also read, and one a user can back up, diff, or put in a dotfiles repo.
### B. Hyprland reads the same file
`config/dot/hypr/prefs.lua` gains a small dependency-free JSON reader and exposes
`prefs.get(key, fallback)`. `looks.lua`, `input.lua`, and `monitors.lua` read
through it, keeping their current literals as the fallback:
```lua
rounding = prefs.get("windowRounding", 18),
gaps_out = prefs.get("gapsOut", 10),
```
A missing, empty, or malformed file yields the shipped defaults. The read is
wrapped in `pcall` so a corrupt file can never take down the config.
This closes the loop:
- **Lua is the default.** It ships the curated values and works standalone.
- **The JSON is the truth.** Both sides read it.
- **Settings is the editor.** It writes the JSON *and* applies live via
`hyprctl eval`, so changes take effect immediately and survive a reboot.
### C. Generic rows, then fill the pages
Add `SettingsPage` (the repeated scaffold), plus `ToggleRow`, `SliderRow`,
`ChoiceRow`, `ActionRow`, and `TextRow`. Rewrite the eleven pages on top of them,
promote the ~40 hardcoded `Settings.qml` values into real controls, and give
Appearance genuine content: accent pair, window rounding, gaps, border size,
blur, animation speed, bar height, font scale, and wallpaper.
Appearance customisation stays inside the design language. The user picks how much
of it there is — spacing, softness, motion — not a free-form palette editor that
would let the Prism identity be dismantled by accident.
### D. Shortcuts from the compositor, then editable
Generate the Shortcuts page from `hyprctl binds -j` so it shows all 113 binds and
can never drift. Backfill descriptions for the 29 binds that lack one. Then allow
rebinding: overrides live in the same JSON, `keybinds.lua` applies them after the
defaults, and Settings applies them live with `hyprctl eval`.
---
## Constraints
- No UI-supplied string is ever interpolated into an `eval`, a shell command, or
a config value. Numbers are range-checked; choices are matched against an
allow-list; colours are validated as hex before serialisation.
- A malformed or absent `settings.json` must degrade to shipped defaults, never
to a broken compositor.
- `hyprctl keyword` is banned in this codebase. It exits 0 without acting.
- Every write path must be observably verified — read the value back rather than
trusting an exit code. Finding 1 exists because an exit code was trusted.
- Tokyo Night Moon and Prism remain the only visual identity. Customisation
adjusts its parameters, it does not replace it.
- Motion stays event-driven. No idle repaint, at any setting.
- GNOME keeps ownership of hardware, accounts, printers, and users.
## Out of scope
- Arbitrary theme/palette import.
- A global menu (previously investigated; GTK apps expose `org.gtk.Actions` but
not `org.gtk.Menus`, so coverage would be too inconsistent to ship).
- Replacing any GNOME-delegated panel.