colour -> color, behaviour -> behavior, centre -> center, favourite -> favorite, and about twenty other pairs, applied consistently across comments, docs, error/UI copy, and a handful of QML identifiers that used the British spelling as their actual name: SystemSettings' serialiseValue/serialiseTable/normaliseGradient, Displays' normaliseModes, Wallpaper's normalisePolicy, SettingsBackup's serialiseHomeState, DateTime's ntpSynchronised property, Clipboard's _normalise helper, and ShortcutCapture's cancelled signal (with its onCancelled handler in ShortcutsPage.qml). Every call site and the two tests that assert on the literal source text (settings-ownership and settings-backup-live contracts) were updated in lockstep. Left untouched: config/dot/espanso/match/packages/misspell-en/ is a vendored third-party autocorrect dictionary -- its entries are typo corrections, not our prose, and rewriting them would fight the package's own purpose (and any future re-sync from upstream). The already-American `favorites` property (Home page pinned accessories) was never actually misspelled -- only nearby comments and error strings said "favourites" -- so no data migration was needed there. Claude-Session: https://claude.ai/code/session_01E6TJUAh41HaP25MVHWkhRZ
349 lines
18 KiB
Markdown
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.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 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.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 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.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`
|
|
|
|
- [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.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.
|
|
|
|
- [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.
|