# 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("", )`, 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.