Files
Panama/docs/superpowers/plans/2026-08-17-panama-cohesion.md
T
Gabriel Brown 00a81edadd 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
2026-08-17 23:26:56 -04:00

241 lines
12 KiB
Markdown

# Panama Cohesion Implementation Plan
**Goal:** Make Panama one product instead of several good parts. A user changes
how their desktop looks and behaves entirely from Panama Settings; the Lua config
holds the shipped defaults; one JSON file is the truth both sides read.
**Spec:** `docs/superpowers/specs/2026-08-17-panama-cohesion-design.md`
**Tech Stack:** Quickshell 0.3.0, Qt 6 QML, Hyprland 0.56.2 (Lua config),
`hyprctl eval`, Bash contract tests.
## Global constraints
- `hyprctl keyword` is banned. It exits 0 without acting on this build.
- Verify every write by reading the value back (`hyprctl getoption`), never by
trusting an exit code.
- No UI-supplied string is interpolated into `eval`, a shell command, or a config
value. Numbers range-checked, choices allow-listed, colours hex-validated.
- A missing or malformed `settings.json` degrades to shipped defaults. The Lua
read is `pcall`-wrapped so it can never take down the compositor config.
- Tokyo Night Moon and Prism stay the only identity; customisation adjusts its
parameters, it does not replace it.
- Motion stays event-driven at every setting. No idle repaint.
- Do not restart the running Quickshell process during development; it hot-reloads.
- Keep unrelated in-flight Panama work untouched (see the
`feat/home-accessories-customization` worktree).
---
## Stage 0 — Stop the lying (ship first, standalone)
The display-policy toggles report success while doing nothing. This is a
correctness bug in shipped behaviour and does not depend on any of the
architecture below.
**Files:** Modify `config/dot/quickshell/services/SystemSettings.qml`;
Test `tests/quickshell/settings-hyprland-write-contract.sh`
- [x] Write a contract that sets a display policy through `SystemSettings`, then
asserts via `hyprctl getoption` that the compositor value actually changed —
and that a rejected write leaves `lastError` non-empty.
- [x] Run it; confirm it fails against the current `hyprctl keyword` implementation.
- [x] Add a single `applyOptions(values)` boundary that serialises validated values
into `hl.config{}` and runs them through `hyprctl eval`.
- [x] Route `setAutoHdr`, `setVrrPolicy`, and `setDirectScanoutPolicy` through it.
- [x] Treat "wrote it back and read it back equal" as the only success condition;
persist to preferences only on verified success.
- [x] Run the contract to green, and confirm live that HDR/VRR/scanout change.
**Exit criteria:** the three toggles do what they claim, and a failed write says so.
**Landed.** `hyprctl eval` also exits 0 on syntax and runtime errors — it reports
them as an `error:` line on stdout — so exit status is useless for both commands.
`applyOptions` therefore parses stdout for the error line *and* reads every
written option back with `hyprctl -j --batch getoption`, committing to
preferences only for options that read back equal. A `writableOptions` registry
holds the group/key/option path and allow-list per option, so the UI never names
an option or supplies an unchecked value. Policy is applied as one batch, so the
shell cannot come up half-configured.
New: `tests/quickshell/settings-hyprland-write-contract.sh` — flips each policy
to a value it does not hold and reads it back, so a no-op write cannot pass.
The pre-existing `settings-system-contract.sh` re-applied the values already in
place, which is why it passed throughout the outage.
---
## Stage 1 — One schema, one store
**Files:** Create `config/dot/quickshell/config/PreferenceSchema.qml`;
Modify `config/dot/quickshell/config/DesktopPreferences.qml`,
`config/dot/quickshell/config/Settings.qml`;
Test `tests/quickshell/preference-schema-contract.sh`
Schema entry shape:
```qml
{ key: "dockHideDelayMs", type: "int", def: 250, min: 0, max: 2000, step: 25,
group: "dock", label: "Hide delay",
detail: "Prevents flicker when crossing icons" }
```
- [x] Write a contract asserting: every schema key round-trips through disk;
an out-of-range value is clamped rather than stored; an unknown key in the
file is preserved rather than dropped; and `reset()` returns *every* key to
its schema default with no hand-maintained list.
- [x] Run it; confirm it fails.
- [x] Author `PreferenceSchema.qml` covering the existing 16 user-facing keys.
- [x] Rewrite `DesktopPreferences` to derive persistence, change notification,
validation, and reset from the schema — deleting the four-places-per-key
boilerplate and `resetDesktopDefaults()`'s hand-written body.
- [x] Move the store to `~/.config/panama/settings.json`, migrating the existing
file from `Quickshell.stateDir` on first run if present.
- [x] Keep `Settings.qml` as the stable public read surface; existing consumers
must not change.
- [x] Run the new contract plus `settings-preferences-contract.sh` and
`settings-pages-contract.sh` to green.
**Exit criteria:** adding a setting is one schema line; reset is complete by
construction; the store lives at a stable, user-visible path.
**Landed.** Reads go through `DesktopPreferences.get(key)` and writes through
`set(key, value)`; a `revision` counter gives function-call bindings something to
invalidate, which a bare call would not have. `Settings.qml` stays the typed
public surface — every consumer outside it was already reading through it.
`set()` returns false on an unknown key or an unrepresentable value, so a
rejection is observable instead of inferred.
Unknown keys on disk are carried through writes untouched, so rolling back to an
older Panama does not discard a newer version's settings. A corrupt file falls
back to shipped defaults rather than costing the user a working desktop. Both are
pinned by contract.
Migration reads the old `Quickshell.stateDir` file only when the new one is
absent, and never deletes the original. Verified live: the running shell adopted
all 17 values into `~/.config/panama/settings.json` with the legacy files intact.
New: `config/PreferenceSchema.qml`, `preference-schema-harness.qml`,
`tests/quickshell/preference-schema-contract.sh`. Full suite green — 6 settings
contracts, 22 other Quickshell contracts, 3 Python bridge tests.
---
## Stage 2 — Hyprland reads the same file
**Files:** Create `config/dot/hypr/prefs.lua`;
Modify `config/dot/hypr/hyprland.lua`, `looks.lua`, `input.lua`, `monitors.lua`;
Test `tests/hypr/prefs-fallback-contract.sh`
- [x] Write a contract that verifies `Hyprland --verify-config` passes with the
file absent, empty, truncated mid-object, and containing wrong-typed values —
and that each case yields the shipped default.
- [x] Run it; confirm it fails (no `prefs.lua` yet).
- [x] Implement a dependency-free JSON reader exposing `prefs.get(key, fallback)`,
`pcall`-wrapped, reading `$XDG_CONFIG_HOME/panama/settings.json`.
- [x] Require it first in `hyprland.lua`, before `looks`.
- [x] Convert the appearance and behaviour literals in `looks.lua` and `input.lua`
to `prefs.get("<key>", <current literal>)`, keeping every current value as
the fallback so shipped behaviour is byte-identical.
- [x] Extend `PreferenceSchema.qml` with the Hyprland-owned keys, each carrying
the `hl.config` path it maps to.
- [x] Have `SystemSettings` derive its `eval` payload from that mapping, so a new
Hyprland setting needs no new writer code.
- [x] Run the contract, `Hyprland --verify-config`, and a live reload to green.
**Exit criteria:** one file is the truth; the Lua is the default; Settings is the
editor; changes apply live *and* survive a reboot.
**Landed.** The compositor-adjustable surface went from 3 keys to 22.
`SystemSettings` no longer names any option: it walks `PreferenceSchema.hyprEntries()`,
builds one nested `hl.config{}` payload from each entry's table path, and verifies
against the entry's `option` path. Adding a live-adjustable Hyprland setting is now
a schema entry plus a `prefs.get` call, with no new writer code.
Verification had to learn the compositor's answer shapes: `getoption` returns the
value in a different field per type — `int`, `bool`, `float`, `str`, and `css` for
gaps, which read back as a four-value box (`"10 10 10 10"`). A verifier that only
understood `int` would have reported every other type as rejected. All five are
covered by contract.
`keyboardLayout` is the first setting whose value reaches an `hl.config` string,
so the schema gained a `pattern` field enforced in `coerce()`. The contract
includes a Lua-injection attempt through it; the value is rejected, nothing
executes, and the layout is unchanged.
Two test-hygiene bugs found and fixed along the way, both pre-existing in shape:
`settings-window-contract` leaves a Settings window that the compositor destroys
asynchronously, which made `settings-pages-contract` see a duplicate when run
straight after it — the pages contract now waits for a clean slate. And the new
write contract was persisting its deliberately-wrong values into the *real*
`~/.config/panama/settings.json`, where the next `hyprctl reload` would faithfully
apply them; it now runs against an isolated `XDG_CONFIG_HOME` while still driving
the live compositor.
Verified live: writing `gapsOut`/`windowRounding` into the shared file and running
`hyprctl reload` — the path a fresh login takes — applied both, and the compositor
and store agree on every key. Full suite green: 29 shell contracts, 3 Python
bridge tests, run sequentially.
---
## Stage 3 — Generic rows, then fill the pages
Per the visual-work rule, this stage stops for a decision before any page is
rewritten.
**Files:** Create `modules/settings/SettingsPage.qml`, `ToggleRow.qml`,
`SliderRow.qml`, `ChoiceRow.qml`, `ActionRow.qml`, `TextRow.qml`;
Modify all eleven `*Page.qml`; Test `tests/quickshell/settings-rows-contract.sh`
- [ ] **Build static mocks** of the new Appearance page and one rebuilt existing
page, serve them over HTTP, report the URL, and **stop for a decision.**
- [ ] Write a contract asserting each row type binds a schema key by name, reflects
external changes, and clamps out-of-range input.
- [ ] Implement the row components and `SettingsPage` (the scaffold currently
copy-pasted eleven times).
- [ ] Rewrite the eleven pages on top of them; delete the dead read-only rows that
only existed because a real control was expensive.
- [ ] Promote the hardcoded `Settings.qml` values into real controls: weather
location/unit/interval, vitals interval, night-light schedule, the four
notification timing and history limits, capture directories, and recorder
arguments.
- [ ] Give Appearance real content: accent pair, window rounding, gaps, border
size, blur, animation speed, bar height, font scale, wallpaper.
- [ ] Make the dock pin list editable (reorder, add, remove) instead of a
16-entry literal.
- [ ] Run the rows contract and the existing settings contracts to green.
**Exit criteria:** no shipped behaviour value is reachable only by editing a file.
---
## Stage 4 — Shortcuts from the compositor
**Files:** Create `services/Keybinds.qml`; Modify `modules/settings/ShortcutsPage.qml`,
`config/dot/hypr/keybinds.lua`; Test `tests/quickshell/keybinds-contract.sh`
- [ ] Write a contract asserting the page's bind count matches `hyprctl binds -j`
exactly, so it can never drift again.
- [ ] Run it; confirm it fails at 19 of 113.
- [ ] Implement `Keybinds.qml` reading `hyprctl binds -j`, grouped and searchable.
- [ ] Backfill `description` in `keybinds.lua` for the 29 binds that lack one.
- [ ] Rebuild `ShortcutsPage` on the live data; delete the hardcoded array.
- [ ] Add rebinding: overrides in the same JSON, applied by `keybinds.lua` after
the defaults and live via `eval`, with conflict detection against existing binds.
- [ ] Run the contract to green.
**Exit criteria:** the page shows every real bind, always current, and can change them.
---
## Sequencing note
Stage 0 is independent — ship it alone. Stages 1 and 2 are the architecture and
should land together, since Stage 2 is what makes Stage 1 worth doing. Stage 3
is the largest and is gated on a visual decision. Stage 4 is independent of 3 and
can run in parallel with it.
Nothing here is committed yet; `main` has 20+ uncommitted paths from prior work
that should get a restore point before Stage 1 begins.