Give the desktop real themes, video wallpapers, and honest titlebars

Appearance now opens on Themes: light and dark side by side, each
remembering its own choice, over galleries of ten shipped themes —
Tokyo Moon and Day joined by Moon Rose, Catppuccin, Nord, Gruvbox and
Everforest in both modes. A theme is a complete palette: the catalog
lives in themes.json, Theme.qml reads every color token from the
active record, and one render pipeline carries it to kitty, tmux,
btop, GTK, Vicinae, Firefox's chrome, and the lock screen. The Theme
editor builds new ones from four wells — wheel, hex, or eyedropper —
with derived surfaces, a saturation slider, debounced fine-tune, and
effects that save with the theme. Custom edits finally keep GNOME's
accent, kitty's border, and hyprlock in sync.

Wallpapers can be video: mpvpaper per output, hardware-decoded, muted
and looped, supervised and respawned. Panama owns the pausing — games,
battery, and a bar pill for right now — because the compositor
rebuilds full-screen blur for every frame a video wallpaper draws.
The lock screen gets a still frame.

Titlebars stop lying. GNOME apps get close-only on your chosen side,
the maximize and double-click settings are gone, the Settings window
obeys the same rules, and its titlebar can be turned off entirely.
Typography becomes five labeled dropdowns instead of a wall of
samples.

Contracts updated and written throughout (165 now); per the redesign
workflow none were executed — the full sweep runs once at the end.

Claude-Session: https://claude.ai/code/session_01Ms2FbjQy31TVf3CEvQhGM8
This commit is contained in:
Gabriel Brown
2026-08-23 23:39:04 -04:00
parent 7578348db1
commit cb7c09d208
68 changed files with 6115 additions and 1138 deletions
+198 -64
View File
@@ -1,10 +1,37 @@
#!/usr/bin/env bash
# The theme editor's colour controls.
#
# The editor used to be a curated swatch strip (AccentPicker), a profile
# dropdown (ThemeProfilePicker) and six HSV sliders. It is now four colour
# wells -- primary, secondary, background, foreground -- with the HSV rows
# demoted to a fine-tune behind a disclosure. Both deleted components are
# pinned as gone here, because a file left behind and still registered in the
# qmldir is a component the next person wires back up.
#
# What must hold:
#
# 1. Every colour route ends in ONE function. Typed hex, the colour wheel and
# the eyedropper all call ThemeEditorWells.apply(), so validation cannot
# differ between them -- and every write ends in ThemeProfiles.commitActive,
# which recomputes accentName. That is what stops GNOME's accent enum,
# kitty's border and the lock screen going stale after a custom edit.
# 2. Colour is never the only signal. Each well has a hex field, each button
# a spoken name, each HSV row a numeric readout, and all of them take
# keyboard focus. A hue ring alone tells someone with a colour vision
# deficiency nothing.
# 3. Writes are debounced. Committing per slider move meant a single drag
# spent the whole gesture in apply-and-verify round trips with the desktop
# repainting behind the pointer.
# 4. Nothing here animates continuously. These are settings controls on a
# high-refresh display; a shimmer costs real frame time forever.
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"
profiles="$repo_dir/config/dot/quickshell/services/ThemeProfiles.qml"
scheme="$repo_dir/config/dot/quickshell/services/ColorScheme.qml"
search="$repo_dir/config/dot/quickshell/services/SettingsSearch.qml"
@@ -13,12 +40,75 @@ fail() {
exit 1
}
for component in AccentPicker AccentEditor ThemeProfilePicker; do
# ── The components that exist, and the two that must not ────────────────────
for component in AccentEditor ColorWell ThemeEditorWells ThemeStartChips \
ThemeSaturationRow ThemeSaveRow ThemeCard ThemeGallery ThemeModeHero; 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 retired in AccentPicker ThemeProfilePicker; do
if [[ -f "$settings/$retired.qml" ]]; then
fail "$retired was deleted in the redesign but the file is back"
fi
if rg -Fq "$retired" "$settings/qmldir"; then
fail "$retired is still registered in the settings qmldir"
fi
done
# ── The four wells are the fast path ────────────────────────────────────────
for role in '"primary", role: "Primary"' '"secondary", role: "Secondary"' \
'"background", role: "Background"' '"foreground", role: "Foreground"'; do
rg -Fq "$role" "$settings/ThemeEditorWells.qml" \
|| fail "the editor is missing the $role well"
done
rg -Fq 'function apply(which: string, hex: string)' "$settings/ThemeEditorWells.qml" \
|| fail 'the wells no longer share one apply path'
rg -Fq 'ThemeProfiles.setGroundColors(' "$settings/ThemeEditorWells.qml" \
|| fail 'background and foreground do not derive the surface and text families'
rg -Fq 'ThemeProfiles.setAccentPair(' "$settings/ThemeEditorWells.qml" \
|| fail 'the accent wells do not write the accent pair'
rg -Fq 'ThemeProfiles.activePalette' "$settings/ThemeEditorWells.qml" \
|| fail 'the ground wells do not show the palette actually in effect'
# ── One eyedropper, one wheel, both aimed by a remembered target ────────────
rg -Fq '["hyprpicker", "--format=hex", "--lowercase-hex", "--quiet", "--no-fancy"]' \
"$settings/ThemeEditorWells.qml" \
|| fail 'screen picking does not use the validated hyprpicker hex invocation'
[[ "$(rg -c 'hyprpicker' "$settings/ThemeEditorWells.qml")" == "1" ]] \
|| fail 'the eyedropper is invoked from more than one place'
rg -Fq 'root.target = which' "$settings/ThemeEditorWells.qml" \
|| fail 'the eyedropper and wheel do not remember which well they answer'
rg -Fq 'onStreamFinished: root.apply(root.target, this.text)' "$settings/ThemeEditorWells.qml" \
|| fail 'the picked colour does not return through the shared apply path'
rg -Fq 'root.apply(root.target, Qt.rgba(' "$settings/ThemeEditorWells.qml" \
|| fail 'the colour wheel does not return through the shared apply path'
for target in 'Open the colour wheel for " + root.role' 'Pick " + root.role + " from the screen'; do
rg -Fq "$target" "$settings/ColorWell.qml" \
|| fail "a glyph-only button has no spoken name: $target"
done
# ── Hex validation lives in the well, and commits on intent ─────────────────
rg -Fq '/^#[0-9a-f]{6}$/.test(prefixed)' "$settings/ColorWell.qml" \
|| fail 'ColorWell does not validate typed hex'
rg -Fq 'property bool invalid' "$settings/ColorWell.qml" \
|| fail 'a rejected hex is not marked in the field'
rg -Fq 'onAccepted: root.commit()' "$settings/ColorWell.qml" \
|| fail 'a typed hex does not commit on Enter'
rg -Fq 'onActiveFocusChanged:' "$settings/ColorWell.qml" \
|| fail 'a typed hex does not commit when focus leaves'
# Per keystroke would repaint the desktop on the way to the colour you meant:
# "#82a" is a real colour.
if rg -q 'onTextChanged:.*commit|onTextEdited:.*commit' "$settings/ColorWell.qml"; then
fail 'the hex field commits per keystroke'
fi
rg -Fq 'Accessible.name: root.role + " colour, hex"' "$settings/ColorWell.qml" \
|| fail 'the hex field is not programmatically labelled'
rg -Fq 'activeFocusOnTab: true' "$settings/ColorWell.qml" \
|| fail 'the hex field cannot receive keyboard focus'
# ── The six HSV rows, still labelled, still keyboard-operable ───────────────
for label in \
'Primary hue' 'Primary saturation' 'Primary value' \
'Secondary hue' 'Secondary saturation' 'Secondary value'; do
@@ -27,10 +117,10 @@ for label in \
done
rg -Fq 'ValueSlider {' "$settings/AccentEditor.qml" \
|| fail 'advanced accents do not reuse ValueSlider'
|| fail 'the fine-tune does 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" \
rg -Fq 'Accessible.name: row.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'
@@ -38,73 +128,92 @@ 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" \
rg -Fq 'border.width: keyboardSlider.activeFocus ? 2 : 1' "$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
# ── Debounced, not per-move ─────────────────────────────────────────────────
rg -Fq 'property var pending' "$settings/AccentEditor.qml" \
|| fail 'the sliders no longer track the drag locally'
rg -Fq 'commitTimer.restart();' "$settings/AccentEditor.qml" \
|| fail 'a slider move does not restart the debounce'
python3 - "$settings/AccentEditor.qml" <<'PY' || fail 'the HSV commit is no longer debounced through a timer'
import re
import sys
# The curated swatches come from the accentName schema rather than from
# Object.keys(Theme.accents): the schema's option order is the palette's order,
# and it is the same source every other enum row reads. What matters here is
# unchanged -- the curated accents are still one click away, ahead of the
# editor.
rg -Fq 'PreferenceSchema.spec("accentName")' "$settings/AccentPicker.qml" \
|| fail 'curated swatches are no longer sourced from the accent schema'
rg -Fq 'model: root.options' "$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'
text = open(sys.argv[1], encoding="utf-8").read()
# The write must happen inside a Timer's onTriggered, never in changeChannel.
commit = re.search(r'Timer\s*\{\s*id: commitTimer(.*?)\n \}', text, re.S)
if not commit or 'ThemeProfiles.setAccentPair(' not in commit.group(1):
raise SystemExit('the commit timer does not write the accent pair')
change = re.search(r'function changeChannel\(.*?\n \}', text, re.S)
if not change or 'ThemeProfiles.' in change.group(0):
raise SystemExit('changeChannel writes directly instead of scheduling a commit')
# A refused write must snap the sliders back to what is really in effect.
if not re.search(r'Timer\s*\{\s*id: releaseTimer.*?editor\.pending = null', text, re.S):
raise SystemExit('nothing hands the sliders back to the stored pair')
PY
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'
# ── One commit path, and it keeps accentName in sync ────────────────────────
rg -Fq 'ThemeProfileModel.nearestCuratedName(profile.scheme, profile.accent)' "$profiles" \
|| fail 'commitActive does not recompute accentName from the chosen colour'
python3 - "$profiles" <<'PY' || fail 'a write path bypasses commitActive'
import re
import sys
for component in ThemeProfilePicker AccentPicker AccentEditor; do
text = re.sub(r"//.*", "", open(sys.argv[1], encoding="utf-8").read())
commit = re.search(r'function commitActive\(.*?\n \}', text, re.S)
if not commit:
raise SystemExit('commitActive is gone')
for fragment in (
'SystemSettings.commitPreference("colorScheme"',
'SystemSettings.commitPreference("themeProfileId"',
'SystemSettings.commitPreference("accentName"',
'root.applyProfileEffects(profile)',
):
if fragment not in commit.group(0):
raise SystemExit(f'commitActive no longer does: {fragment}')
# Everything that changes the active theme routes through it.
for func in ('selectProfile', 'updateActive'):
block = re.search(r'function ' + func + r'\(.*?\n \}', text, re.S)
if not block or 'root.commitActive(' not in block.group(0):
raise SystemExit(f'{func} does not end in commitActive')
PY
rg -Fq 'ThemeProfiles.activePalette' "$theme" \
|| fail 'Theme roles do not read the resolved palette'
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'
# ── The editor is on the page, under its own tab ────────────────────────────
for component in ThemeStartChips ThemeEditorWells ThemeSaturationRow \
AccentEditor ThemeSaveRow ThemeModeHero ThemeGallery; 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"
for tab in '{ value: "themes", label: "Themes" }' '{ value: "editor", label: "Theme editor" }'; do
rg -Fq "$tab" "$settings/AppearancePage.qml" \
|| fail "Appearance is missing the tab $tab"
done
# ── Findable by name ────────────────────────────────────────────────────────
for term in 'Themes' 'Theme editor' 'Theme profiles' 'Advanced accent' \
'Pick colour from screen'; do
rg -Fq "\"$term\"" "$search" || fail "Settings search is missing $term"
done
# ── No decorative or continuously repainting animation ──────────────────────
if rg -n 'NumberAnimation|ColorAnimation|SequentialAnimation|ParallelAnimation|loops:[[:space:]]*Animation\.Infinite' \
"$settings/AccentEditor.qml" "$settings/AccentPicker.qml" "$settings/ThemeProfilePicker.qml"; then
"$settings/AccentEditor.qml" "$settings/ColorWell.qml" \
"$settings/ThemeEditorWells.qml" "$settings/ThemeStartChips.qml" \
"$settings/ThemeSaturationRow.qml" "$settings/ThemeSaveRow.qml" \
"$settings/ThemeCard.qml" "$settings/ThemeGallery.qml" \
"$settings/ThemeModeHero.qml"; then
fail 'theme controls introduce continuously repainting or decorative animation'
fi
# ── Live: the editor, headless ──────────────────────────────────────────────
state_home="$(mktemp -d /tmp/panama-accent-controls.XXXXXX)"
harness="$repo_dir/config/dot/quickshell/accent-controls-harness.qml"
@@ -125,15 +234,40 @@ for _ in $(seq 1 40); do
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'
|| fail 'headless theme editor 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'
status() { qs_for_test ipc call accent-controls-test status; }
jq -e '.id == "moon" and .shipped == true and .accent == "#82aaff"
and .accentName == "blue"' <<<"$(status)" >/dev/null \
|| fail 'the headless editor did not begin on Moon'
# A slider move shows immediately and writes on a beat, not per move.
moved="$(qs_for_test ipc call accent-controls-test adjust primary h 0)"
jq -e '.pending == true and .shown.accent != "#82aaff"
and .stored.accent == "#82aaff"' <<<"$moved" >/dev/null \
|| fail 'an HSV move either did not show or wrote before the debounce elapsed'
sleep 0.8
settled="$(status)"
jq -e '.shipped == false and .accent != "#82aaff" and .secondary == "#b172b0"
and .pending == false' <<<"$settled" >/dev/null \
|| fail 'the debounced commit did not fork a custom profile with the unchanged secondary'
# The wells: a valid hex lands, and accentName follows it without being asked.
jq -e '.accent == "#86e1fc" and .accentName == "teal" and .wellError == ""' \
<<<"$(qs_for_test ipc call accent-controls-test well primary '#86e1fc')" >/dev/null \
|| fail 'a typed primary hex did not land, or accentName did not follow it'
jq -e '.accent == "#86e1fc" and (.wellError | length) > 0' \
<<<"$(qs_for_test ipc call accent-controls-test well primary 'not-a-colour')" >/dev/null \
|| fail 'an unusable hex was accepted, or was refused without saying so'
# The ground wells move the whole surface and text family, not one token.
ground="$(qs_for_test ipc call accent-controls-test well background '#101020')"
jq -e '.bg == "#101020" and .fg == "#c8d3f5" and .fgDim != "#828bb8"' \
<<<"$ground" >/dev/null \
|| fail 'changing the background did not re-derive the surface and text family'
trap - EXIT
cleanup