Complete the Panama theme system

This commit is contained in:
Gabriel Brown
2026-08-20 22:03:08 -04:00
parent 69ecec7ecf
commit ed428e87c4
17 changed files with 1370 additions and 41 deletions
+133
View File
@@ -0,0 +1,133 @@
#!/usr/bin/env bash
set -euo pipefail
repo_dir="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/../.." && pwd)"
settings="$repo_dir/config/dot/quickshell/modules/settings"
theme="$repo_dir/config/dot/quickshell/config/Theme.qml"
scheme="$repo_dir/config/dot/quickshell/services/ColorScheme.qml"
search="$repo_dir/config/dot/quickshell/services/SettingsSearch.qml"
fail() {
printf 'accent controls contract: %s\n' "$1" >&2
exit 1
}
for component in AccentPicker AccentEditor ThemeProfilePicker; do
[[ -f "$settings/$component.qml" ]] || fail "$component is missing"
rg -Fq "$component 1.0 $component.qml" "$settings/qmldir" \
|| fail "$component is not registered in the settings module"
done
for label in \
'Primary hue' 'Primary saturation' 'Primary value' \
'Secondary hue' 'Secondary saturation' 'Secondary value'; do
rg -Fq "$label" "$settings/AccentEditor.qml" \
|| fail "HSV control is missing the visible label $label"
done
rg -Fq 'ValueSlider {' "$settings/AccentEditor.qml" \
|| fail 'advanced accents do not reuse ValueSlider'
rg -Fq 'Accessible.role: Accessible.Slider' "$settings/AccentEditor.qml" \
|| fail 'HSV controls do not expose slider semantics'
rg -Fq 'Accessible.name: root.label' "$settings/AccentEditor.qml" \
|| fail 'HSV controls are not programmatically labelled'
rg -Fq 'Accessible.description:' "$settings/AccentEditor.qml" \
|| fail 'HSV controls do not expose their numeric value as a non-hue signal'
rg -Fq 'activeFocusOnTab: true' "$settings/AccentEditor.qml" \
|| fail 'HSV controls cannot receive keyboard focus'
rg -Fq 'Keys.onPressed:' "$settings/AccentEditor.qml" \
|| fail 'HSV controls cannot be adjusted from the keyboard'
rg -Fq 'activeFocus ? Theme.accentSecondary' "$settings/AccentEditor.qml" \
|| fail 'HSV controls have no visible keyboard focus treatment'
rg -Fq '["hyprpicker", "--format=hex", "--lowercase-hex", "--quiet", "--no-fancy"]' \
"$settings/AccentEditor.qml" \
|| fail 'screen picking does not use the validated hyprpicker hex invocation'
for target in 'Pick primary from screen' 'Pick secondary from screen'; do
rg -Fq "$target" "$settings/AccentEditor.qml" \
|| fail "screen picker action is missing $target"
done
rg -Fq 'model: Object.keys(Theme.accents)' "$settings/AccentPicker.qml" \
|| fail 'curated swatches are no longer the fast path'
rg -Fq 'ThemeProfiles.useCuratedAccent(entry.modelData)' "$settings/AccentPicker.qml" \
|| fail 'curated swatches do not select a profile-backed accent'
rg -Fq 'readonly property string current: ThemeProfiles.activeAccentName' "$settings/AccentPicker.qml" \
|| fail 'swatch selection does not follow the active profile'
rg -Fq 'Accessible.name: entry.pair.label + " accent"' "$settings/AccentPicker.qml" \
|| fail 'swatches rely on hue without a programmatic name'
rg -Fq 'activeFocusOnTab: true' "$settings/AccentPicker.qml" \
|| fail 'swatches cannot receive keyboard focus'
rg -Fq 'Keys.onReturnPressed:' "$settings/AccentPicker.qml" \
|| fail 'swatches cannot be selected from the keyboard'
rg -Fq 'model: ThemeProfiles.profiles' "$settings/ThemeProfilePicker.qml" \
|| fail 'profile picker does not show shipped and saved profiles'
rg -Fq 'ThemeProfiles.selectProfile(' "$settings/ThemeProfilePicker.qml" \
|| fail 'profile switching is not wired'
rg -Fq 'ThemeProfiles.saveProfile(' "$settings/ThemeProfilePicker.qml" \
|| fail 'profile saving is not wired'
rg -Fq 'maximumLength: 40' "$settings/ThemeProfilePicker.qml" \
|| fail 'profile names are not visibly bounded to the model limit'
rg -Fq 'ThemeProfiles.deleteProfile(' "$settings/ThemeProfilePicker.qml" \
|| fail 'custom profile deletion is not wired'
rg -Fq 'activeFocusOnTab:' "$settings/ThemeProfilePicker.qml" \
|| fail 'profile actions cannot receive keyboard focus'
rg -Fq 'Keys.onReturnPressed:' "$settings/ThemeProfilePicker.qml" \
|| fail 'profile actions cannot be triggered from the keyboard'
for component in ThemeProfilePicker AccentPicker AccentEditor; do
rg -Fq "$component {" "$settings/AppearancePage.qml" \
|| fail "Appearance does not include $component"
done
rg -Fq 'ThemeProfiles.activeProfile' "$theme" \
|| fail 'Theme roles do not react to the selected profile'
rg -Fq 'root.hyprColor(Theme.accent)' "$scheme" \
|| fail 'the focused border start does not follow Theme.accent'
rg -Fq 'root.hyprColor(Theme.accentSecondary)' "$scheme" \
|| fail 'the focused border end does not follow Theme.accentSecondary'
for term in 'Theme profiles' 'Advanced accent' 'Pick colour from screen'; do
rg -Fq "$term" "$search" || fail "Settings search is missing $term"
done
if rg -n 'NumberAnimation|ColorAnimation|SequentialAnimation|ParallelAnimation|loops:[[:space:]]*Animation\.Infinite' \
"$settings/AccentEditor.qml" "$settings/AccentPicker.qml" "$settings/ThemeProfilePicker.qml"; then
fail 'theme controls introduce continuously repainting or decorative animation'
fi
state_home="$(mktemp -d /tmp/panama-accent-controls.XXXXXX)"
harness="$repo_dir/config/dot/quickshell/accent-controls-harness.qml"
qs_for_test() {
XDG_CONFIG_HOME="$state_home/config" XDG_STATE_HOME="$state_home/state" \
QS_DISABLE_CRASH_HANDLER=1 qs -p "$harness" "$@"
}
cleanup() {
qs_for_test kill >/dev/null 2>&1 || true
rm -rf "$state_home"
}
trap cleanup EXIT
qs_for_test --daemonize >/dev/null
for _ in $(seq 1 40); do
qs_for_test ipc show 2>/dev/null | rg -q '^target accent-controls-test$' && break
sleep 0.1
done
qs_for_test ipc show 2>/dev/null | rg -q '^target accent-controls-test$' \
|| fail 'headless AccentEditor harness did not start'
before="$(qs_for_test ipc call accent-controls-test status)"
jq -e '.id == "moon" and .shipped == true' <<<"$before" >/dev/null \
|| fail 'headless editor did not begin on Moon'
after="$(qs_for_test ipc call accent-controls-test adjust primary h 0)"
jq -e '.shipped == false and .accent != "#82aaff" and .secondary == "#b172b0"' \
<<<"$after" >/dev/null \
|| fail 'an HSV adjustment did not create a custom profile with the unchanged secondary colour'
trap - EXIT
cleanup
printf 'accent controls contract: PASS\n'
+13 -13
View File
@@ -8,7 +8,8 @@
# inside Panama's own surfaces, which look correct either way.
#
# Three things have to line up, and none of them share a source:
# - config/Theme.qml carries the accent table with its `gnome` member
# - services/ThemeProfileModel.js carries the accent table with its `gnome`
# member (Theme.qml exposes it; the theme-profile system owns it)
# - scripts/panama-theme-apps maps the same names in shell
# - the portal routes Settings to a backend that can serve accent-color
#
@@ -17,7 +18,7 @@
set -uo pipefail
repo_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
theme="$repo_dir/config/dot/quickshell/config/Theme.qml"
accents="$repo_dir/config/dot/quickshell/services/ThemeProfileModel.js"
script="$repo_dir/config/dot/quickshell/scripts/panama-theme-apps"
portals="$repo_dir/config/dot/xdg-desktop-portal/hyprland-portals.conf"
@@ -26,25 +27,24 @@ fail() {
exit 1
}
for path in "$theme" "$script" "$portals"; do
for path in "$accents" "$script" "$portals"; do
[[ -r "$path" ]] || fail "missing $path"
done
# ── The accent table ─────────────────────────────────────────────────────────
mapping="$(python3 - "$theme" <<'PYTHON'
mapping="$(python3 - "$accents" <<'PYTHON'
import re, sys
source = open(sys.argv[1]).read()
table = re.search(r"readonly property var accents: \(\{(.*?)\n \}\)", source, re.S)
table = re.search(r"var CURATED = \{(.*?)\n\};", source, re.S)
if not table:
raise SystemExit("accents table not found")
for line in table.group(1).splitlines():
name = re.search(r'"([a-z]+)":', line)
if not name:
continue
member = re.search(r'gnome: "([a-z]+)"', line)
print(name.group(1) + "\t" + (member.group(1) if member else ""))
raise SystemExit("curated accent table not found")
# Each accent is a multi-line record now, so the name opens a block and the
# member may be several lines below it.
for entry in re.finditer(r"\n ([a-z]+): \{(.*?)\n \}", table.group(1), re.S):
member = re.search(r'gnome: "([a-z]+)"', entry.group(2))
print(entry.group(1) + "\t" + (member.group(1) if member else ""))
PYTHON
)" || fail 'could not read the accent table from Theme.qml'
)" || fail 'could not read the curated accent table from ThemeProfileModel.js'
[[ -n "$mapping" ]] || fail 'the accent table is empty'
+31
View File
@@ -157,6 +157,37 @@ rg -Fq 'onCommitted: value => root.brightnessRequested(value)' "$quicksettings_p
rg -Fq 'accessibleName: root.entity.name + " brightness"' "$quicksettings_path/HomeTile.qml" \
|| fail 'Home tile does not give its dimmer an accessory-specific accessible name'
# Controls added after the original contract remain service-backed rather than
# becoming optimistic local toggles. Keep these checks in the static section so
# they can run without mapping the Control Center on a daily-driver desktop.
rg -Fq 'visible: PowerProfiles.available' "$quicksettings_path/QuickSettingsPanel.qml" \
|| fail 'power profile control does not follow daemon availability'
rg -Fq 'PowerProfiles.refresh();' "$quicksettings_path/QuickSettings.qml" \
|| fail 'opening Control Center does not refresh the external power profile'
rg -Fq 'expanded: root.expandedSection === "power"' "$quicksettings_path/QuickSettingsPanel.qml" \
|| fail 'power profile choices have no detail section'
rg -Fq 'PowerProfileList {' "$quicksettings_path/QuickSettingsPanel.qml" \
|| fail 'power profile choices are not mounted'
rg -Fq 'active: NightLight.active' "$quicksettings_path/QuickSettingsPanel.qml" \
|| fail 'Night Light control does not reflect service state'
rg -Fq 'onToggled: NightLight.toggle()' "$quicksettings_path/QuickSettingsPanel.qml" \
|| fail 'Night Light primary action is not wired'
rg -Fq 'onExpanded: NightLight.automatic = !NightLight.automatic' "$quicksettings_path/QuickSettingsPanel.qml" \
|| fail 'Night Light schedule action is not wired'
rg -Fq 'sublabel: ColorScheme.dark ? "Dark" : "Light"' "$quicksettings_path/QuickSettingsPanel.qml" \
|| fail 'colour scheme control does not expose its current state'
rg -Fq 'SystemSettings.commitPreference("colorScheme",' "$quicksettings_path/QuickSettingsPanel.qml" \
|| fail 'colour scheme control bypasses the preference commit path'
if [[ "${1:-}" == "--static-only" ]]; then
trap - EXIT
cleanup
printf 'Control Center contract (static): PASS\n'
exit 0
fi
cp -a "$source_config_path" "$config_path"
: >"$helper_log"
cat >"$config_path/scripts/panama-home-assistant" <<'EOF'
+169
View File
@@ -0,0 +1,169 @@
#!/usr/bin/env bash
set -euo pipefail
repo_dir="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/../.." && pwd)"
model="$repo_dir/config/dot/quickshell/services/ThemeProfileModel.js"
node - "$model" <<'JS'
const assert = require('node:assert/strict')
const model = require(process.argv[2])
const shipped = model.shippedProfiles()
assert.deepEqual(shipped, [
{
id: 'moon',
name: 'Moon',
scheme: 'dark',
accent: '#82aaff',
secondary: '#b172b0',
shipped: true
},
{
id: 'moon-rose',
name: 'Moon Rose',
scheme: 'dark',
accent: '#ff757f',
secondary: '#c099ff',
shipped: true
},
{
id: 'day',
name: 'Day',
scheme: 'light',
accent: '#2e7de9',
secondary: '#9854f1',
shipped: true
}
])
const first = model.createCustomProfile([], {
name: ' Ocean ',
scheme: 'dark',
accent: '#86E1FC',
secondary: '#82AAFF'
})
assert.deepEqual(first.profile, {
id: 'custom-ocean',
name: 'Ocean',
scheme: 'dark',
accent: '#86e1fc',
secondary: '#82aaff',
shipped: false
})
const second = model.createCustomProfile(first.profiles, {
name: 'ocean',
scheme: 'light',
accent: '#007197',
secondary: '#2e7de9'
})
assert.equal(second.profile.name, 'ocean 2')
assert.equal(second.profile.id, 'custom-ocean-2')
const bounded = model.createCustomProfile(second.profiles, {
name: 'A theme name that is deliberately much longer than forty characters',
scheme: 'dark',
accent: '#c3e88d',
secondary: '#86e1fc'
})
assert.equal(bounded.profile.name.length, 40)
const originalMoon = shipped[0]
const edited = model.editProfile([], originalMoon, {
accent: '#ffc777',
secondary: '#ff966c'
})
assert.equal(edited.profile.shipped, false)
assert.equal(edited.profile.name, 'Moon custom')
assert.equal(edited.profiles.length, 1)
assert.deepEqual(originalMoon, shipped[0])
assert.equal(shipped[0].accent, '#82aaff')
const refusedDelete = model.deleteProfile(edited.profiles, 'moon')
assert.equal(refusedDelete.removed, false)
assert.deepEqual(refusedDelete.profiles, edited.profiles)
assert.deepEqual(model.profileCatalog([
edited.profile,
{ id: 'moon', name: 'Counterfeit', scheme: 'dark', accent: '#ffffff', secondary: '#ffffff', shipped: false },
{ id: 'custom-bad', name: 'Bad', scheme: 'sepia', accent: '#ffffff', secondary: '#ffffff', shipped: false }
]), [...shipped, edited.profile])
assert.equal(model.hsvToHex(0, 100, 100), '#ff0000')
assert.equal(model.hsvToHex(120, 100, 100), '#00ff00')
assert.equal(model.hsvToHex(240, 100, 100), '#0000ff')
assert.equal(model.hsvToHex(360, 100, 100), '#ff0000')
assert.equal(model.hsvToHex(0, 0, 50), '#808080')
assert.deepEqual(model.hexToHsv('#ff0000'), { h: 0, s: 100, v: 100 })
assert.deepEqual(model.hexToHsv('#82aaff'), { h: 221, s: 49, v: 100 })
assert.equal(model.hexToHsv('not-a-colour'), null)
assert.equal(model.curatedNameForProfile(shipped[0]), 'blue')
assert.equal(model.curatedNameForProfile(shipped[1]), 'rose')
assert.equal(model.matchingShippedProfile(
'dark', '#ff757f', '#c099ff'
).id, 'moon-rose')
assert.equal(model.matchingShippedProfile(
'light', '#2e7de9', '#9854f1'
).id, 'day')
assert.equal(model.curatedNameForProfile({
id: 'custom-unmatched', name: 'Unmatched', scheme: 'dark',
accent: '#123456', secondary: '#654321', shipped: false
}), '')
console.log('theme profiles contract: PASS')
JS
harness="$repo_dir/config/dot/quickshell/theme-profiles-harness.qml"
state_home="$(mktemp -d /tmp/panama-theme-profiles.XXXXXX)"
qs_for_test() {
XDG_CONFIG_HOME="$state_home/config" XDG_STATE_HOME="$state_home/state" \
QS_DISABLE_CRASH_HANDLER=1 qs -p "$harness" "$@"
}
cleanup() {
qs_for_test kill >/dev/null 2>&1 || true
rm -rf "$state_home"
}
trap cleanup EXIT
qs_for_test --daemonize >/dev/null
for _ in $(seq 1 40); do
qs_for_test ipc show 2>/dev/null | rg -q '^target theme-profiles-test$' && break
sleep 0.1
done
qs_for_test ipc show 2>/dev/null | rg -q '^target theme-profiles-test$' \
|| { printf 'theme profiles contract: test IPC target did not start\n' >&2; exit 1; }
status="$(qs_for_test ipc call theme-profiles-test status)"
jq -e '.active.id == "moon" and (.profiles | length) == 3' <<<"$status" >/dev/null
qs_for_test ipc call theme-profiles-test scheme light >/dev/null
jq -e '.active.id == "day" and .active.scheme == "light"' \
<<<"$(qs_for_test ipc call theme-profiles-test status)" >/dev/null
qs_for_test ipc call theme-profiles-test select day >/dev/null
jq -e '.active.id == "day" and .active.scheme == "light"' \
<<<"$(qs_for_test ipc call theme-profiles-test status)" >/dev/null
qs_for_test ipc call theme-profiles-test edit '#587539' '#007197' >/dev/null
edited="$(qs_for_test ipc call theme-profiles-test status)"
jq -e '.active.shipped == false and .active.accent == "#587539" and (.stored | length) == 1' \
<<<"$edited" >/dev/null
custom_id="$(jq -r '.active.id' <<<"$edited")"
qs_for_test ipc call theme-profiles-test save ' Forest ' >/dev/null
saved="$(qs_for_test ipc call theme-profiles-test status)"
jq -e '.active.name == "Forest" and (.stored | length) == 2' <<<"$saved" >/dev/null
[[ "$(qs_for_test ipc call theme-profiles-test remove moon)" == "false" ]]
forest_id="$(jq -r '.active.id' <<<"$saved")"
[[ "$(qs_for_test ipc call theme-profiles-test remove "$forest_id")" == "true" ]]
jq -e '.active.id == "day" and .active.shipped == true' \
<<<"$(qs_for_test ipc call theme-profiles-test status)" >/dev/null
[[ "$(qs_for_test ipc call theme-profiles-test remove "$custom_id")" == "true" ]]
trap - EXIT
cleanup
printf 'theme profiles service contract: PASS\n'