Files
Panama/docs/superpowers/plans/2026-08-17-panama-cohesion.md
T
Gabriel Brown bac68d2bfb Document the settings vocabulary and its traps
Records how to add a setting -- one schema entry -- and the failure
modes found building the pages. Every one of them fails silently rather
than loudly, which is why they are worth writing down: a wrong readAs
makes successful writes look rejected, hyprpaper accepts an all-outputs
wallpaper request and ignores it, and a copy of the Quickshell config
shares the live shell's ID so an "isolated" harness can kill the running
desktop.

Also documents the two generated configurations and why they are
generated: ~/.config/hypr is a symlink into this repository, so writing
there at runtime would commit machine state to a tracked file.

Claude-Session: https://claude.ai/code/session_01BRvzt4H8XXLPVH5MyYdk9L
2026-08-18 01:15:53 -04:00

18 KiB

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

  • 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.
  • Run it; confirm it fails against the current hyprctl keyword implementation.
  • Add a single applyOptions(values) boundary that serialises validated values into hl.config{} and runs them through hyprctl eval.
  • Route setAutoHdr, setVrrPolicy, and setDirectScanoutPolicy through it.
  • Treat "wrote it back and read it back equal" as the only success condition; persist to preferences only on verified success.
  • 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:

{ key: "dockHideDelayMs", type: "int", def: 250, min: 0, max: 2000, step: 25,
  group: "dock", label: "Hide delay",
  detail: "Prevents flicker when crossing icons" }
  • 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.
  • Run it; confirm it fails.
  • Author PreferenceSchema.qml covering the existing 16 user-facing keys.
  • 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.
  • Move the store to ~/.config/panama/settings.json, migrating the existing file from Quickshell.stateDir on first run if present.
  • Keep Settings.qml as the stable public read surface; existing consumers must not change.
  • 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

  • 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.
  • Run it; confirm it fails (no prefs.lua yet).
  • Implement a dependency-free JSON reader exposing prefs.get(key, fallback), pcall-wrapped, reading $XDG_CONFIG_HOME/panama/settings.json.
  • Require it first in hyprland.lua, before looks.
  • 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.
  • Extend PreferenceSchema.qml with the Hyprland-owned keys, each carrying the hl.config path it maps to.
  • Have SystemSettings derive its eval payload from that mapping, so a new Hyprland setting needs no new writer code.
  • 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. Three directions built; Gabriel chose B, the live preview.
  • Implement the row components and SettingsPage (the scaffold previously copy-pasted eleven times).
  • Give Appearance real content, driven by a live preview.
  • 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.
  • Make "Restore defaults" span every store Panama owns.
  • 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 behaviour 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 centrepiece: 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.sh 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.sh

  • Write a contract asserting the page's bind count matches hyprctl binds -j exactly, so it can never drift again.
  • Implement Keybinds.qml reading hyprctl binds -j, grouped.
  • Backfill description in keybinds.lua for the 29 binds that lacked one.
  • Rebuild ShortcutsPage on the live data; delete the hardcoded array.
  • 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.sh 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.

  • Wallpaper — thumbnail grid, applied over hyprpaper IPC.
  • Power & Lock — screen blank, lock, suspend, lock-before-sleep.
  • Date & Time — timezone and network time via timedatectl.
  • Accessibility — pointer size, text scale, motion, contrast.
  • Search that indexes settings, not page names.
  • Editable Dock — reorder, unpin, add.
  • Settings snapshots — save, list, restore.
  • 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.