Build the settings vocabulary and generate the keymap

Stage 3 and 4 of docs/superpowers/plans/2026-08-17-panama-cohesion.md.

Add SettingsPage plus ToggleRow, SliderRow, ChoiceRow, ActionRow, and
TextRow. A row names a schema key and needs nothing else: label, detail,
range, and unit come from PreferenceSchema, and writes go through
SystemSettings.commitPreference, which routes compositor-backed keys
through apply-and-verify and local keys straight to the store. The page
scaffold that was copy-pasted eleven times is now one component.

Rebuild Appearance around a live preview of the real desktop, scaled by
the ratio between the preview and the actual monitor so a 10px gap on a
4500px display looks as small as it is. Rebuild Desktop & Dock and Input
& Shortcuts on the shared rows, replacing the read-only text that stood
in for controls that were merely expensive to add.

Generate the shortcut list from hyprctl binds. The page held a
hand-typed nineteen entries against a real keymap of a hundred and
thirteen; it could not show the rest and went stale whenever a bind
changed. Every bind now carries its own description -- backfilled for
the twenty-nine that lacked one -- and keybinds-contract.sh fails if any
bind lacks one, since undescribed binds are dropped from the page.

Make Restore defaults span every store Panama owns. Resetting only the
schema store left the Home accessory arrangement customised while
claiming to restore defaults, which is worse than no reset because it is
silent. Done through HomePreferences' existing public aliases rather
than a new API.

Four defects found while building:

cursor:inactive_timeout is answered by getoption as float, not int. A
wrong readAs does not fail loudly; it makes every write to that key look
rejected, and the user saw an error for a change that worked.
schema-hypr-shape-contract.sh now checks all 23 mapped options against
the running compositor.

The Settings window is tiled, so implicitWidth is only a hint and rows
must survive roughly 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.

Concurrent compositor writes are queued and merged rather than refused.
The startup replay of every compositor-backed preference routinely
overlaps a UI change, and refusing left the store and the compositor
disagreeing.

Claude-Session: https://claude.ai/code/session_01BRvzt4H8XXLPVH5MyYdk9L
This commit is contained in:
Gabriel Brown
2026-08-18 00:24:51 -04:00
parent 8a04e4f9d1
commit fe7c85e471
22 changed files with 1576 additions and 200 deletions
+80
View File
@@ -0,0 +1,80 @@
#!/usr/bin/env bash
# The Shortcuts page is generated from the compositor, so it cannot drift from
# the real keymap. Two things have to hold for that to be true:
#
# * every bind Hyprland reports is presented -- the page count matches
# `hyprctl binds -j` exactly, so adding a bind cannot silently go missing;
# * every bind carries a description. Hyprland reports Lua-defined binds with
# dispatcher "__lua" and a bytecode offset as the argument, so a bind
# without a description has nothing a person could read beside its chord.
# The service drops those rather than showing a mystery row, which means an
# undescribed bind disappears from the page -- this contract is what stops
# that from being a silent loss.
set -euo pipefail
repo_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
harness="$repo_dir/config/dot/quickshell/keybinds-harness.qml"
fail() {
printf 'keybinds contract: %s\n' "$1" >&2
exit 1
}
qs_for_harness() {
qs -p "$harness" "$@"
}
cleanup() {
qs_for_harness kill >/dev/null 2>&1 || true
}
trap cleanup EXIT
# ── Every bind in the running compositor must be describable ─────────────────
total="$(hyprctl -j binds | jq 'length')"
undescribed="$(hyprctl -j binds | jq '[.[] | select((.description // "") == "")] | length')"
[[ "$total" -gt 0 ]] || fail 'the compositor reports no binds at all'
if [[ "$undescribed" -ne 0 ]]; then
printf 'keybinds contract: %s bind(s) have no description and would be dropped from the page:\n' "$undescribed" >&2
hyprctl -j binds | jq -r '.[] | select((.description // "") == "") | " modmask=\(.modmask) key=\(.key)"' >&2
fail 'add a description to each in config/dot/hypr/keybinds.lua'
fi
# ── The page must present all of them ────────────────────────────────────────
qs_for_harness --daemonize >/dev/null
for _ in $(seq 1 40); do
qs_for_harness ipc show 2>/dev/null | rg -q '^target keybinds-test$' && break
sleep 0.1
done
qs_for_harness ipc show 2>/dev/null | rg -q '^target keybinds-test$' || fail 'test IPC target did not start'
state='{}'
for _ in $(seq 1 40); do
state="$(qs_for_harness ipc call keybinds-test status | jq -c .)"
jq -e '.loaded == true' <<<"$state" >/dev/null 2>&1 && break
sleep 0.1
done
jq -e '.loaded == true' <<<"$state" >/dev/null || fail "the keymap never loaded: $state"
presented="$(jq -r .count <<<"$state")"
[[ "$presented" == "$total" ]] \
|| fail "the page presents $presented of $total binds — the two must match exactly"
grouped_total="$(jq -r .groupedCount <<<"$state")"
[[ "$grouped_total" == "$total" ]] \
|| fail "grouping lost binds: $grouped_total grouped from $total"
# ── Chords must be rendered for people, not dumped raw ───────────────────────
jq -e '.sample | test("^Super \\+ ")' <<<"$state" >/dev/null \
|| fail "Super+T did not render as a readable chord: $(jq -r .sample <<<"$state")"
[[ "$(jq -r .emptyDescriptions <<<"$state")" == "0" ]] \
|| fail 'a presented bind has an empty description'
trap - EXIT
cleanup
printf 'keybinds contract: PASS (%s binds, all described)\n' "$total"
+57
View File
@@ -0,0 +1,57 @@
#!/usr/bin/env bash
# Every schema entry with a `hypr` block declares `readAs`: which JSON field
# `hyprctl getoption` answers with for that option. Verification compares
# against that field, so a wrong declaration does not fail loudly -- it makes
# every write to that setting look rejected, and the user sees "Hyprland did not
# apply ..." for a change that actually worked.
#
# cursor:inactive_timeout shipped as "int" and is answered as "float", which is
# exactly that failure. This contract asks the compositor for the real shape of
# every mapped option so the next one cannot reach a release.
set -euo pipefail
repo_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
schema="$repo_dir/config/dot/quickshell/config/PreferenceSchema.qml"
fail() {
printf 'schema hypr shape contract: %s\n' "$1" >&2
exit 1
}
[[ -r "$schema" ]] || fail "cannot read $schema"
mismatches=0
checked=0
while IFS='|' read -r option declared; do
[[ -n "$option" ]] || continue
checked=$((checked + 1))
answer="$(hyprctl -j getoption "$option" 2>/dev/null)" \
|| fail "hyprctl could not read $option"
# An option Hyprland does not know answers without any value field at all.
actual=""
for field in int bool float str css; do
if jq -e --arg f "$field" 'has($f)' <<<"$answer" >/dev/null 2>&1; then
actual="$field"
break
fi
done
[[ -n "$actual" ]] || fail "$option is not a known Hyprland option (answered: $answer)"
if [[ "$actual" != "$declared" ]]; then
printf 'schema hypr shape contract: %s declares readAs "%s" but answers with "%s"\n' \
"$option" "$declared" "$actual" >&2
mismatches=$((mismatches + 1))
fi
done < <(grep -oE 'option: "[^"]+", readAs: "[a-z]+"' "$schema" \
| sed -E 's/option: "([^"]+)", readAs: "([a-z]+)"/\1|\2/')
[[ "$checked" -gt 0 ]] || fail 'no hypr-mapped schema entries were found to check'
[[ "$mismatches" -eq 0 ]] || fail "$mismatches option(s) declare the wrong answer shape"
printf 'schema hypr shape contract: PASS (%d mapped options)\n' "$checked"
+117
View File
@@ -0,0 +1,117 @@
#!/usr/bin/env bash
# Two behaviours the settings rows depend on:
#
# commitPreference(key, value)
# One entry point for every row. A compositor-backed key must reach Hyprland
# and be confirmed before it is stored; a local key is written directly.
# Rows bind a schema key and call this, so they never need to know which
# kind they hold -- and a row must not be able to store a value the
# compositor rejected.
#
# restoreDefaults()
# Panama keeps user state in more than one file. Resetting only the schema
# store would leave a customised Home accessory arrangement in place while
# claiming to have restored Panama's defaults. That is worse than having no
# reset at all, because it is silent.
set -euo pipefail
repo_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
harness="$repo_dir/config/dot/quickshell/settings-system-harness.qml"
# Preferences are committed to $XDG_CONFIG_HOME, and the Home store lives under
# $XDG_STATE_HOME. Both are isolated so this contract cannot touch the real
# desktop's settings; the compositor is the live one and is restored below.
config_home="$(mktemp -d /tmp/panama-commit-config.XXXXXX)"
state_home="$(mktemp -d /tmp/panama-commit-state.XXXXXX)"
fail() {
printf 'settings commit/reset contract: %s\n' "$1" >&2
exit 1
}
qs_for_harness() {
XDG_CONFIG_HOME="$config_home" XDG_STATE_HOME="$state_home" qs -p "$harness" "$@"
}
original_rounding="$(hyprctl -j getoption decoration:rounding | jq -r .int)"
original_gaps="$(hyprctl -j getoption general:gaps_out | jq -r .css | awk '{print $1}')"
restore() {
hyprctl eval "hl.config({ decoration = { rounding = $original_rounding }, general = { gaps_out = $original_gaps } })" >/dev/null 2>&1 || true
qs_for_harness kill >/dev/null 2>&1 || true
rm -rf "$config_home" "$state_home"
}
trap restore EXIT
XDG_CONFIG_HOME="$config_home" XDG_STATE_HOME="$state_home" qs -p "$harness" --daemonize >/dev/null
for _ in $(seq 1 40); do
qs_for_harness ipc show 2>/dev/null | rg -q '^target settings-system-test$' && break
sleep 0.1
done
qs_for_harness ipc show 2>/dev/null | rg -q '^target settings-system-test$' || fail 'test IPC target did not start'
# ── A local key is stored directly ───────────────────────────────────────────
[[ "$(qs_for_harness ipc call settings-system-test commit showSeconds false)" == "true" ]] \
|| fail 'commitPreference refused a local key'
[[ "$(qs_for_harness ipc call settings-system-test stored showSeconds)" == "false" ]] \
|| fail 'a local key was not stored'
# ── A compositor key reaches Hyprland, then is stored ────────────────────────
target_rounding=$(( original_rounding == 11 ? 13 : 11 ))
[[ "$(qs_for_harness ipc call settings-system-test commit windowRounding "$target_rounding")" == "true" ]] \
|| fail 'commitPreference refused a compositor key'
for _ in $(seq 1 40); do
[[ "$(hyprctl -j getoption decoration:rounding | jq -r .int)" == "$target_rounding" ]] && break
sleep 0.1
done
[[ "$(hyprctl -j getoption decoration:rounding | jq -r .int)" == "$target_rounding" ]] \
|| fail "a compositor-backed commit did not reach Hyprland (rounding=$(hyprctl -j getoption decoration:rounding | jq -r .int))"
[[ "$(qs_for_harness ipc call settings-system-test stored windowRounding)" == "$target_rounding" ]] \
|| fail 'a verified compositor commit was not stored'
# ── A value the schema rejects is never stored ───────────────────────────────
before="$(qs_for_harness ipc call settings-system-test stored windowRounding)"
[[ "$(qs_for_harness ipc call settings-system-test commit windowRounding 9999)" == "true" ]] \
|| fail 'an out-of-range value should be clamped by the schema, not refused outright'
[[ "$(qs_for_harness ipc call settings-system-test stored windowRounding)" != "9999" ]] \
|| fail 'an out-of-range value was stored unclamped'
[[ "$(qs_for_harness ipc call settings-system-test commit __not_a_setting__ 1)" == "false" ]] \
|| fail 'commitPreference accepted a key outside the schema'
# ── Reset spans every store, not just the schema one ─────────────────────────
qs_for_harness ipc call settings-system-test seedHome >/dev/null
qs_for_harness ipc call settings-system-test commit dockHideDelayMs 900 >/dev/null
sleep 0.4
home_before="$(qs_for_harness ipc call settings-system-test homeState)"
jq -e '.count == 1 and .initialized == true' <<<"$home_before" >/dev/null \
|| fail "the Home fixture did not apply: $home_before"
[[ "$(qs_for_harness ipc call settings-system-test stored dockHideDelayMs)" == "900" ]] \
|| fail 'the dock fixture did not apply'
qs_for_harness ipc call settings-system-test restoreDefaults >/dev/null
sleep 0.6
[[ "$(qs_for_harness ipc call settings-system-test stored dockHideDelayMs)" == "250" ]] \
|| fail 'reset did not restore a schema default'
home_after="$(qs_for_harness ipc call settings-system-test homeState)"
jq -e '.count == 0 and .initialized == false' <<<"$home_after" >/dev/null \
|| fail "reset left the Home accessory store customised: $home_after"
# Resetting a stored value does not by itself tell Hyprland anything, so the
# reset must re-apply compositor-backed defaults too.
for _ in $(seq 1 40); do
[[ "$(hyprctl -j getoption decoration:rounding | jq -r .int)" == "18" ]] && break
sleep 0.1
done
[[ "$(hyprctl -j getoption decoration:rounding | jq -r .int)" == "18" ]] \
|| fail "reset did not re-apply the compositor default (rounding=$(hyprctl -j getoption decoration:rounding | jq -r .int))"
trap - EXIT
restore
printf 'settings commit/reset contract: PASS\n'