Files
Panama/docs/superpowers/plans/2026-08-17-panama-cohesion.md
T
Gabriel Brown e1faaf7a76 Drop the extension, and give the test suite a front door
Phase 6, the last of the fresh-install spec.

159 scripts lose their .sh: 110 contracts, 47 Vicinae commands, 2 compositor
contracts. A shebang and the executable bit already select the interpreter. The
extension only ever added something that had to stay in sync, and the rename
proved the point twice over in the space of an hour.

The spec's stated risk was Vicinae's script discovery. One script was renamed and
reloaded on its own before the other 46 followed; it came back as
scripts:panama.capture and all 47 resolve. What the probe turned up instead is
that the extension was never only a filename: Vicinae's command IDs embed it, so
every ID changed. Nothing in this repository refers to them, so nothing breaks.
The only trace is Vicinae's metadata.json, whose visited map had two Panama
entries that are now orphaned -- two commands lost their usage ranking and will
earn it back. Worth knowing before anyone renames these again on a machine that
has a keybind pointing at one.

Rewriting the references by exact filename missed two things it structurally
could not see: a name built from a variable, settings-$page.sh, and a glob,
-name '*.sh'. Both were in the contract that counts the generated commands, which
promptly reported 47 expected and 0 found. The mechanical part of a rename is the
part that looks finished.

The three subcommands. panama doctor fronts a health check that already existed
and already ran at the end of every install but could not be reached from a
terminal. panama upgrade re-runs the installer from anywhere. panama test runs
the suite, which had no entry point at all -- 121 files that were the main safety
net in this repository and were invisible in it.

Writing that runner found three tests nothing was running.
calendar_agenda_bridge_test, home_assistant_bridge_test and kdeconnect_bridge_test
are unittest suites without the executable bit, so no contract invoked them and
the first draft of the runner skipped them silently. All three pass, and have
passed unobserved for weeks. The runner collects *_test.py as well now, because a
runner with a blind spot is worse than no runner for the same reason a dependency
checker with one is: it reports PASS.

Six worktrees pruned. Each was re-checked rather than trusted to the spec's list,
and two needed it: panama-commands is not on feat/panama-commands but on
feat/gnome-tweaks-parity, and fix/panama-displays-review reads [ahead 3] -- ahead
of its remote, not of main, with every commit patch-equivalent to landed work.
roadmap-completion stays; it has five commits that are genuinely unlanded. The
branches are left alone: pruning a worktree costs nothing, deleting a branch is a
decision.

121 contracts pass.

Claude-Session: https://claude.ai/code/session_01NvgBuSWB5sE43yWmg21ozj
2026-08-20 21:55:55 -04:00

349 lines
18 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, colors 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; customization 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 behavior 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`
- [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 serializes 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` — 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` 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`
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` and
`settings-pages-contract` 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`. 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`
- [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 behavior literals in `looks.lua` and `input.lua`
to `prefs.get("<key>", <current literal>)`, keeping every current value as
the fallback so shipped behavior 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`
- [x] **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.**
Three directions built; Gabriel chose **B, the live preview**.
- [x] Implement the row components and `SettingsPage` (the scaffold previously
copy-pasted eleven times).
- [x] Give Appearance real content, driven by a live preview.
- [x] Rewrite Appearance, Desktop & Dock, and Input & Shortcuts on the new rows;
delete the dead read-only rows that only existed because a real control was
expensive.
- [x] Make "Restore defaults" span every store Panama owns.
- [x] Run the new contracts and the existing settings contracts to green.
- [ ] Promote the remaining hardcoded `Settings.qml` values: weather
location/unit/interval, vitals interval, night-light schedule, the four
notification timing and history limits, capture directories, and recorder
arguments.
- [ ] Make the dock pin list editable (reorder, add, remove) instead of a
16-entry literal.
- [ ] Move the remaining pages (Home, Displays, Connectivity, Sound,
Notifications, Screen Intelligence, Services, About) onto `SettingsPage`.
**Exit criteria:** no shipped behavior value is reachable only by editing a file.
**Landed (first pass).** `SettingsPage` plus `ToggleRow`, `SliderRow`,
`ChoiceRow`, `ActionRow`, and `TextRow`. A row names a schema key and needs
nothing else — `ToggleRow { setting: "blurEnabled" }` pulls its label,
explanation, bounds, and unit from the schema, and writes through
`SystemSettings.commitPreference`, which routes compositor-backed keys through
apply-and-verify and local keys straight to the store. Rows never need to know
which kind they hold.
`DesktopPreview` is the direction-B centerpiece: two tiled windows drawn at the
settings actually in effect, scaled by the ratio between the preview's width and
the real monitor's, so a 10px gap on a 4500px display looks as small as it is.
Four things found by building it:
* `cursor:inactive_timeout` is reported as `float`, not `int`. `readAs` describes
what getoption answers with, not what the setting means, and getting it wrong
does not fail loudly — it makes every write to that key look rejected. The user
saw "Hyprland did not apply Hide pointer after" for a change that worked.
`tests/quickshell/schema-hypr-shape-contract` now asks the compositor for
the real shape of all 23 mapped options.
* The Settings window is a normal tiled window, so `implicitWidth: 1120` is only
a hint and rows must survive ~400px. `SliderRow` stacks its control under the
label below 520px.
* Binding an anchor to `undefined` to switch layouts does not reliably release
it. Both row layouts are positioned explicitly now.
* Refusing concurrent compositor writes was the wrong policy: the startup replay
of 23 preferences routinely overlaps a UI change, and refusing left the store
and the compositor disagreeing. Writes queue and merge, later values winning.
---
## 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`
- [x] Write a contract asserting the page's bind count matches `hyprctl binds -j`
exactly, so it can never drift again.
- [x] Implement `Keybinds.qml` reading `hyprctl binds -j`, grouped.
- [x] Backfill `description` in `keybinds.lua` for the 29 binds that lacked one.
- [x] Rebuild `ShortcutsPage` on the live data; delete the hardcoded array.
- [x] Run the contract to green.
- [ ] Add rebinding: overrides in the same JSON, applied by `keybinds.lua` after
the defaults and live via `eval`, with conflict detection against existing binds.
- [ ] Add search over the shortcut list.
**Exit criteria:** the page shows every real bind, always current, and can change them.
**Landed (read-only).** The page shows all **113** binds, grouped by what they do,
against the hand-typed **19** it had before. Descriptions come from the binds
themselves, so a new bind appears with no change to the page.
Grouping is derived from each bind's own description rather than a table here, so
adding a bind puts it in the right section automatically. Hyprland reports
Lua-defined binds with dispatcher `__lua` and a bytecode offset as the argument,
so a bind without a description has nothing readable beside its chord; the
service drops those, and `tests/quickshell/keybinds-contract` fails if any
exist so that dropping can never be silent.
Input settings are on the same page and are now real controls: keyboard repeat,
Num Lock, focus-follows-pointer, pointer speed, and the pointer hide timeout.
Rebinding is not done — that is the remaining half of this stage.
---
## 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.
---
## Stage 5 — Replace GNOME Settings for what Panama owns
Not in the original plan. Added after the settings vocabulary made new pages
cheap enough that the limiting factor stopped being effort and started being
scope. Built jointly with the codex agent, which took default applications, the
page migrations, and the remaining hardcoded values.
- [x] **Wallpaper** — thumbnail grid, applied over hyprpaper IPC.
- [x] **Power & Lock** — screen blank, lock, suspend, lock-before-sleep.
- [x] **Date & Time** — timezone and network time via `timedatectl`.
- [x] **Accessibility** — pointer size, text scale, motion, contrast.
- [x] **Search that indexes settings**, not page names.
- [x] **Editable Dock** — reorder, unpin, add.
- [x] **Settings snapshots** — save, list, restore.
- [x] **Rebindable shortcuts.**
- [ ] Displays: resolution, refresh rate, scale, rotation.
- [ ] Per-application notification rules.
- [ ] Window rules (float/tile/workspace) as a page.
**Landed.** Each of these turned up something the compositor or its tools do
differently than documented, and in every case the failure mode was silence
rather than an error:
* **hyprpaper 0.8 ignores the "all outputs" form.** `<empty>,<path>` is accepted
and does nothing, so a wallpaper set that way appears to succeed and never
changes. Its IPC is also much smaller than older versions suggest — `preload`,
`listloaded`, `unload`, and `reload` all answer "invalid hyprpaper request".
* **`cursor:inactive_timeout` is answered as `float`, not `int`.** A wrong
`readAs` does not fail loudly; it makes every write to that key look rejected,
and the user sees an error for a change that worked.
* **Snapshot filenames collided at one-second resolution.** A save followed
promptly by a restore produced the same name twice, and the restore's own
safety snapshot overwrote the file it was about to read. Found by the
contract, which restores immediately after saving.
* **Keying bind overrides by description moved every bind sharing one.**
Rebinding `SUPER+C` also dragged `XF86Calculator` onto the same chord.
Descriptions are not unique; chords are.
* **The GNOME delegation allow-list named a panel that does not exist.**
`users` is not in `gnome-control-center --list`, so that button opened
nothing.
* **A copy of the Quickshell config shares the live shell's ID.** Quickshell
derives it from content rather than path, so an "isolated" harness built by
copying the config directory can kill or drive the running desktop. This took
the live shell down twice during development. Harnesses pointing at a single
distinct `.qml` file are unaffected.
Two configurations are now generated rather than edited, because `~/.config/hypr`
is a symlink into this repository and writing there at runtime would put machine
state into a tracked file: hypridle's config, into `XDG_STATE_HOME` with a
systemd drop-in pointing at it, and the wallpaper, which is applied over IPC and
re-applied at shell start instead of being written into `hyprpaper.conf`.
The schema gained a `json` type for structured values — the dock's pinned list
and the keybind overrides — so they live in the one settings file and are
covered by the one reset, rather than each growing a preference store of its
own. `restoreDefaults()` spans every store Panama owns, including the Home
accessory arrangement, which it reaches through that service's existing public
aliases rather than an API added for the purpose.