Make Shell a category, the bar legible, and the dock a real dock

Desktop & Dock becomes Shell — Bar, Dock, Control Center, Tiling,
Workspaces — the home for everything Quickshell draws. The settings-
management cluster moves to System as Sync & Backup, Appearance's
Shell tab dissolves, and 24-hour time finally lives on Date & Time,
which always owned it.

The bar gets what it never had: a way to survive the wallpaper. A
second neutral text family (follow theme, or forced light or dark),
a one-layer shadow under every glyph, and a gradient scrim for
wallpapers nothing else survives — all off by default, pixel-identical
until asked. Widgets earn toggles (weather, media, clipboard, calendar
countdown), the vitals cluster stops leaving a dead pill behind, and
Control Center's sections learn to step aside.

The dock graduates from MVP: a context menu with window rows, pin,
unpin, quit and new-window; scroll an icon to cycle its windows; drag
to reorder on the dock itself; hover previews with one-shot captures;
and "Add App to Dock" in the launcher. Three real bugs died en route —
menus that slid away with the autohide, a readonly-property crash on
every menu open, and a drag that drifted half a slot per icon on side
docks. The pinned-apps editor in Settings becomes a drag strip.

166 contracts; the full suite is green except two live display and
switcher tests that cannot run behind a locked session — re-verified
on unlock.

Claude-Session: https://claude.ai/code/session_01Ms2FbjQy31TVf3CEvQhGM8
This commit is contained in:
Gabriel Brown
2026-08-24 04:28:20 -04:00
parent 4d7a194300
commit f8f5b25510
77 changed files with 2982 additions and 800 deletions
+224
View File
@@ -0,0 +1,224 @@
#!/usr/bin/env bash
# Every other surface the shell draws sits on a ground the theme chose. The bar
# sits on the wallpaper, which the theme has never seen, so it is the one place
# where a palette that is correct can still be unreadable. The Shell Bar page
# exists to fix that, and this contract pins the three halves of it that a
# refactor can quietly undo:
#
# 1. The bar has a neutral family of its own (barFg/barFgDim/barFgMuted) and
# every bar widget binds to it. A widget left on Theme.fg is invisible on
# the exact wallpaper the user turned the tone control on for.
# 2. The scrim and the shadow are PREFERENCE-DRIVEN. Both were hardcoded true
# at one point during the build, which is not a cosmetic slip: it forces a
# dark band and a whole extra layer on everyone, including the people whose
# wallpaper never needed either.
# 3. Turning a widget off removes it, and turning all of a widget's readouts
# off removes the pill rather than leaving a padded gap reporting nothing.
#
# Static checks only: no compositor, no shell, nothing read from the live
# desktop.
set -euo pipefail
repo_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
shell_dir="$repo_dir/config/dot/quickshell"
theme="$shell_dir/config/Theme.qml"
settings="$shell_dir/config/Settings.qml"
bar="$shell_dir/modules/bar/Bar.qml"
vitals="$shell_dir/modules/bar/VitalsWidget.qml"
fail() {
printf 'bar visibility contract: %s\n' "$1" >&2
exit 1
}
# The thirteen files that carry neutral bar text or glyphs. Two of them live
# outside modules/bar -- the clipboard button and the focus indicator are drawn
# into the bar by Bar.qml, so they answer to the bar's tone like the rest.
bar_widgets=(
"$shell_dir/modules/bar/ActivityIndicator.qml"
"$shell_dir/modules/bar/AgentUsageWidget.qml"
"$shell_dir/modules/bar/CalendarIndicator.qml"
"$shell_dir/modules/bar/Clock.qml"
"$shell_dir/modules/bar/MediaWidget.qml"
"$shell_dir/modules/bar/StatusCluster.qml"
"$shell_dir/modules/bar/StatusGlyph.qml"
"$shell_dir/modules/bar/VitalsField.qml"
"$shell_dir/modules/bar/WallpaperIndicator.qml"
"$shell_dir/modules/bar/WeatherWidget.qml"
"$shell_dir/modules/bar/Workspaces.qml"
"$shell_dir/modules/clipboard/ClipboardWidget.qml"
"$shell_dir/modules/focus/FocusIndicator.qml"
)
for file in "$theme" "$settings" "$bar" "$vitals" "${bar_widgets[@]}"; do
[[ -f "$file" ]] || fail "missing ${file#"$repo_dir/"}"
done
# ── The bar's neutral family ────────────────────────────────────────────────
# Left alone the family IS the fg family, by identity rather than by a copied
# literal: the moment `theme` returns a hand-picked colour instead of root.fg,
# the default bar stops following the theme and every custom palette is wrong
# in the one place the user looks at most.
python3 - "$theme" <<'PY' || fail 'the bar text tokens drifted from the fg family'
import re
import sys
text = open(sys.argv[1], encoding="utf-8").read()
if 'DesktopPreferences.get("barTextTone")' not in text:
raise SystemExit("Theme no longer reads barTextTone")
# token -> the fg role its "theme" branch must fall back to, unchanged.
expected = {
"barFg": "fg",
"barFgDim": "fgDim",
"barFgMuted": "fgMuted",
}
for token, role in expected.items():
block = re.search(
r"readonly property color " + token + r":\s*\{(?P<body>.*?)\n \}",
text,
re.S,
)
if not block:
raise SystemExit(f"Theme no longer defines {token}")
body = re.sub(r"//.*", "", block.group("body"))
for tone in ("light", "dark"):
if f'=== "{tone}"' not in body:
raise SystemExit(f"{token} does not answer the {tone} tone")
# The fallthrough is the last return in the block, and it is the whole
# promise of the default: follow theme means follow theme.
returns = re.findall(r"return\s+([^;]+);", body)
if not returns:
raise SystemExit(f"{token} returns nothing")
if returns[-1].strip() != f"root.{role}":
raise SystemExit(
f'{token} falls back to {returns[-1].strip()!r}, expected root.{role}'
)
# The forced tones are anchored on one literal each and the two dims are MIXED
# off it, so light and dark stay families rather than three unrelated colours
# somebody has to keep in step by hand.
for token in ("barFgDim", "barFgMuted"):
block = re.search(
r"readonly property color " + token + r":\s*\{(?P<body>.*?)\n \}",
text,
re.S,
)
body = block.group("body")
if body.count("root.mix(root.barFg") != 2:
raise SystemExit(f"{token} no longer derives both forced tones from barFg")
PY
# ── Settings exposes what the page writes ───────────────────────────────────
for key in barTextShadow barBackdrop showWeatherWidget showMediaWidget \
showClipboardButton showCalendarCountdown; do
rg -Fq "DesktopPreferences.get(\"$key\")" "$settings" \
|| fail "Settings does not expose $key"
done
# ── Every bar widget speaks in bar tones ────────────────────────────────────
# Theme.alpha(Theme.fg, ...) is allowed: those are hover and separator FILLS
# drawn against the widget's own pill, not text read against the wallpaper.
# Semantic tones (warn/danger/accent/ok) are allowed for the same reason -- a
# battery at 4% should be red whatever tone the neutrals were forced to.
python3 - "${bar_widgets[@]}" <<'PY' || fail 'a bar widget still paints neutral text with the fg family'
import re
import sys
from pathlib import Path
neutral = re.compile(r"Theme\.fg(?:Dim|Muted)?\b")
fill = re.compile(r"Theme\.alpha\(\s*Theme\.fg(?:Dim|Muted)?\b")
for arg in sys.argv[1:]:
path = Path(arg)
text = re.sub(r"//.*", "", path.read_text(encoding="utf-8"))
if "Theme.barFg" not in text:
raise SystemExit(f"{path.name} binds no text to the bar's own tone")
stripped = fill.sub("", text)
leftover = neutral.findall(stripped)
if leftover:
raise SystemExit(
f"{path.name} still uses {sorted(set(leftover))} for bar text; "
"use barFg/barFgDim/barFgMuted so the tone control reaches it"
)
PY
# ── The scrim and the shadow are preferences, not decisions ─────────────────
for binding in \
'visible: Settings.barBackdrop' \
'layer.enabled: Settings.barTextShadow' \
'layer.effect: MultiEffect' \
'shadowEnabled: true'; do
rg -Fq "$binding" "$bar" || fail "Bar.qml is missing \`$binding\`"
done
# The regression this catches actually happened: both landed as literal trues
# during the build, so the scrim and the extra layer shipped to everybody
# regardless of what the page said.
for hardcoded in 'visible: true' 'layer.enabled: true'; do
! rg -Fq "$hardcoded" "$bar" \
|| fail "Bar.qml hardcodes \`$hardcoded\` instead of reading the preference"
done
# One layer for the whole bar, not one per widget: the shadow is drawn under a
# flattened copy of the content, so a widget added tomorrow picks it up without
# opting in.
[[ "$(rg -c 'layer.enabled' "$bar")" == "1" ]] \
|| fail 'Bar.qml no longer flattens its content into exactly one shadow layer'
# The bar reserves its own height and nothing else. A computed zone here means
# maximised windows either overlap the bar or leave a strip of wallpaper.
rg -Fq 'exclusiveZone: Theme.barHeight' "$bar" \
|| fail 'the bar no longer reserves exactly its own height'
# Nothing in the bar animates. It already repaints once a second for the clock;
# anything that repaints continuously on top of that is a permanent GPU cost on
# a surface that is always on screen. The backdrop in particular is a static
# gradient by design.
! rg -q '\b(Behavior|NumberAnimation|ColorAnimation|SequentialAnimation|ParallelAnimation|PropertyAnimation|AnimatedImage)\b' "$bar" \
|| fail 'Bar.qml has grown an animation; the bar is always on screen and never animates'
# ── Widget gates ────────────────────────────────────────────────────────────
# Each toggle is ANDed with the widget's own state condition rather than
# replacing it, so switching one ON never conjures a pill with nothing in it.
check_gate() {
local file="$shell_dir/$1" needle="$2"
rg -Fq "$needle" "$file" || fail "${1##*/} is missing \`$needle\`"
}
check_gate modules/bar/WeatherWidget.qml \
'visible: Settings.showWeatherWidget && Weather.available'
check_gate modules/bar/MediaWidget.qml \
'visible: Settings.showMediaWidget && root.player !== null'
check_gate modules/bar/CalendarIndicator.qml \
'visible: Settings.showCalendarCountdown && CalendarAgenda.capsuleVisible'
check_gate modules/clipboard/ClipboardWidget.qml \
'visible: Settings.showClipboardButton'
# ── The vitals pill leaves when it has nothing to say ───────────────────────
# An invisible child still occupies its Row, so gating the three fields alone
# left a padded, empty pill sitting in the bar. The pill has to answer for
# itself.
rg -Fq 'visible: Settings.showCpu || Settings.showMemory || (Settings.showGpu && Vitals.gpuAvailable)' "$vitals" \
|| fail 'the vitals pill does not disappear when all three readouts are off'
for field in 'visible: Settings.showCpu' 'visible: Settings.showMemory' \
'visible: Settings.showGpu && Vitals.gpuAvailable'; do
rg -Fq "$field" "$vitals" || fail "the vitals row is missing \`$field\`"
done
# ── Right-click lands where the toggles are ─────────────────────────────────
# Both of these used to open the retired Desktop page. Whichever widget you
# right-click, you should arrive at the card holding its own switch.
rg -Fq 'ShellState.openSettings("bar")' "$vitals" \
|| fail 'the vitals pill no longer jumps to Shell Bar'
rg -Fq 'ShellState.openSettings("bar")' "$shell_dir/modules/bar/AgentUsageWidget.qml" \
|| fail 'the agent usage pill no longer jumps to Shell Bar'
printf 'bar visibility contract: PASS (%d bar widgets on bar tones)\n' "${#bar_widgets[@]}"
+1 -1
View File
@@ -158,7 +158,7 @@ qml_package() {
# Provided by the base system or the shell itself; nothing installs these
# separately, and listing them would be noise.
QML_BASELINE='^(sh|bash|rm|test|systemd-inhibit|loginctl|timedatectl|gsettings|gapplication|systemctl|busctl)$'
QML_BASELINE='^(sh|bash|rm|test|pkill|systemd-inhibit|loginctl|timedatectl|gsettings|gapplication|systemctl|busctl)$'
while IFS= read -r command_name; do
[[ -n "$command_name" ]] || continue
@@ -94,6 +94,7 @@ package_for() {
# x11 one cannot. desktop-packages declares it under that name.
espanso) printf 'espanso-wayland' ;;
rg) printf 'ripgrep' ;;
cmp) printf 'diffutils' ;;
xdg-mime|xdg-settings|xdg-open) printf 'xdg-utils' ;;
update-desktop-database|desktop-file-validate) printf 'desktop-file-utils' ;;
python3) printf 'python3' ;;
+30 -7
View File
@@ -23,6 +23,9 @@
# centred while its reveal strip spans the whole edge -- so a region that
# forgets the body's own offset lands somewhere the pointer is not, hover
# drops on the frame the dock arrives, and it hides under a still cursor.
# 8. The dock's own drag-to-reorder commits once, on release, and measures a
# slot from a real icon rather than assuming one -- a DockItem is taller
# than it is wide, so a constant is wrong on one of the two orientations.
#
# The geometry checks launch isolated shells against a temporary config. The
# real settings are read to build them and never written.
@@ -33,14 +36,14 @@ repo_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
shell_dir="$repo_dir/config/dot/quickshell"
dock="$shell_dir/modules/dock/Dock.qml"
body="$shell_dir/modules/dock/DockBody.qml"
editor="$shell_dir/modules/settings/DockPinsEditor.qml"
strip="$shell_dir/modules/settings/DockPinsStrip.qml"
fail() {
printf 'dock position contract: %s\n' "$1" >&2
exit 1
}
for path in "$dock" "$body" "$editor"; do
for path in "$dock" "$body" "$strip"; do
[[ -r "$path" ]] || fail "missing $path"
done
@@ -57,12 +60,32 @@ grep -q 'implicitWidth: root.vertical ? ' "$dock" \
grep -q 'wanted.length === 0' "$dock" \
|| fail 'an empty screen list is not treated as every screen'
grep -q 'preventStealing: true' "$editor" \
grep -q 'preventStealing: true' "$strip" \
|| fail 'the drag grip does not set preventStealing, so the page will scroll instead of reordering'
# The arrows are the keyboard-reachable path and predate the grip. A grip is not
# a replacement for them.
grep -q 'text: "↑"' "$editor" \
|| fail 'the move-up button was removed, leaving no keyboard-reachable reorder'
# A drag is not reachable from the keyboard, so the strip owes the keyboard its
# own path. The old editor spelled it as ↑/↓ buttons; the strip spells it as
# arrow keys on a focused icon. Either way it has to exist.
grep -q 'Keys.onLeftPressed' "$strip" && grep -q 'Keys.onRightPressed' "$strip" \
|| fail 'the pinned strip offers no keyboard-reachable reorder'
grep -q 'activeFocusOnTab: true' "$strip" \
|| fail 'a pinned icon cannot be reached by Tab, so the keyboard reorder is unreachable'
# ── 8. The dock's own drag ─────────────────────────────────────────────────
# Dragging an icon along the dock moves the same pin the strip does, so it has
# the same two ways to go wrong.
# One write per gesture. Committing per slot crossed rewrites settings.json a
# dozen times for one drag, and every rewrite re-evaluates the model underneath
# the gesture.
[[ "$(grep -c 'DesktopPreferences.set("dockPinned"' "$body")" -eq 1 ]] \
|| fail 'the dock commits its reorder more than once per gesture, or not through DesktopPreferences'
# A DockItem is taller than it is wide -- the running dots sit under the icon --
# so a slot down a side dock is further than a slot across a bottom one. Reading
# the pitch from the item that started the drag is what keeps both honest; a
# constant here was wrong on one of the two orientations.
grep -q 'signal dragStarted(real pitch)' "$shell_dir/modules/dock/DockItem.qml" \
|| fail 'the dock drag assumes a slot size instead of measuring the item, so a side dock steps wrong'
# ── 5. Reordering keeps every entry, exactly once ───────────────────────────
+11 -9
View File
@@ -104,12 +104,12 @@ write_settings '{
}'
run_helper generate
rg -Fq 'color = rgba(225, 226, 231, 1.0)' "$generated" || fail 'light solid mode does not match Theme.bg'
rg -Fq 'inner_color = rgba(208, 213, 227, 0.85)' "$generated" || fail 'light password field does not match the themed fallback'
rg -Fq 'inner_color = rgba(217, 218, 227, 0.85)' "$generated" || fail 'light password field does not match the themed fallback'
rg -Fq 'font_color = rgba(55, 96, 191, 1.0)' "$generated" || fail 'light foreground does not match Theme.fg'
rg -Fq 'outer_color = rgba(46, 125, 233, 0.9)' "$generated" || fail 'light focus ring does not match Theme.accent'
rg -Fq 'check_color = rgba(46, 125, 233, 1.0)' "$generated" || fail 'light success color does not match Theme.accent'
rg -Fq 'fail_color = rgba(245, 42, 101, 1.0)' "$generated" || fail 'light error color does not match Theme.red'
rg -Fq 'foreground="##6172b0"' "$generated" || fail 'light placeholder markup retained the dark muted color'
rg -Fq 'foreground="##848cb5"' "$generated" || fail 'light placeholder markup retained the dark muted color'
rg -Fq 'foreground="##f52a65"' "$generated" || fail 'light failure markup retained the dark error color'
rg -Fq 'blur_passes = 0' "$generated" || fail 'blur level zero did not disable passes'
rg -Fq 'blur_size = 1' "$generated" || fail 'blur level zero did not use the safe size'
@@ -118,17 +118,19 @@ if rg -q '^label \{' "$generated"; then
fail 'hidden clock, date, and user labels were still generated'
fi
# ── The focus ring follows the chosen accent, not just the scheme ───────────
# A pinned blue literal only ever proves the DEFAULT accent renders correctly,
# not that the helper actually reads accentName. Orchid is picked because its
# hex is nowhere close to blue's in either scheme, so a helper that quietly
# ignored accentName and kept emitting blue would be caught here.
write_settings '{"accentName":"orchid","colorScheme":"dark"}'
# ── The focus ring follows the active theme's accent, not just the scheme ──
# A pinned blue literal only ever proves the DEFAULT accent renders correctly.
# Since themes carry their accent pair, the helper reads the active profile's
# accent; a custom orchid profile is used because its hex is nowhere close to
# blue's, so a helper that quietly kept emitting the default would be caught.
# (accentName alone can no longer disagree with the profile: every commit path
# recomputes it from the active accent.)
write_settings '{"colorScheme":"dark","accentName":"orchid","themeProfileId":"custom-orchid","themeProfiles":[{"id":"custom-orchid","name":"Orchid test","scheme":"dark","accent":"#c099ff","secondary":"#fca7ea","shipped":false}]}'
run_helper generate
rg -Fq 'outer_color = rgba(192, 153, 255, 0.9)' "$generated" || fail 'dark orchid accent did not drive the focus ring'
rg -Fq 'check_color = rgba(192, 153, 255, 1.0)' "$generated" || fail 'dark orchid accent did not drive the success color'
write_settings '{"accentName":"orchid","colorScheme":"light"}'
write_settings '{"colorScheme":"light","accentName":"orchid","themeProfileId":"custom-orchid-light","themeProfiles":[{"id":"custom-orchid-light","name":"Orchid light","scheme":"light","accent":"#7847bd","secondary":"#9854f1","shipped":false}]}'
run_helper generate
rg -Fq 'outer_color = rgba(120, 71, 189, 0.9)' "$generated" || fail 'light orchid accent did not drive the focus ring'
rg -Fq 'check_color = rgba(120, 71, 189, 1.0)' "$generated" || fail 'light orchid accent did not drive the success color'
@@ -24,6 +24,7 @@ declare -A expected=(
[open-clipboard]=clipboard
[open-mission-control]=overview
[open-settings]=settings
[dock-add-app]=dock-pin
[check-system-health]=health
[toggle-dnd]=dnd
[toggle-caffeine]=caffeine
+2 -2
View File
@@ -137,8 +137,8 @@ assert_schema_and_redaction() {
and (.summary.status | IN("healthy", "warning", "error"))
and (.context.session | IN("hyprland", "other"))
and (.context.versions | type == "array")
and ([.checks[].id] | length == 29)
and ([.checks[].id] | unique | length == 29)
and ([.checks[].id] | length == 30)
and ([.checks[].id] | unique | length == 30)
and ([.checks[].status] | all(IN("ok", "warning", "error", "unconfigured")))' \
>/dev/null <<<"$snapshot" || fail "invalid schema: $snapshot"
[[ "$(jq -r '.checks[].id' <<<"$snapshot")" == "$expected_order" ]] \
+2 -2
View File
@@ -63,8 +63,8 @@ done
# contextual affordances must retain the original interaction and route to the
# setting page that owns the controls.
[[ -r "$dock_menu" ]] || fail 'dock has no contextual menu, so application actions cannot keep a final Dock settings action'
grep -qF 'ShellState.openSettings("desktop")' "$dock_menu" \
|| fail 'dock context menu does not open Desktop settings'
grep -qF 'ShellState.openSettings("dock")' "$dock_menu" \
|| fail 'dock context menu does not open Dock settings'
grep -qF 'entry.actions' "$dock_menu" \
|| fail 'dock context menu dropped application actions'
actions_line="$(grep -nF 'entry.actions' "$dock_menu" | head -1 | cut -d: -f1)"
+23 -8
View File
@@ -9,7 +9,7 @@ fail() {
exit 1
}
pages=(Home MyHome Phone Displays Connectivity Sound Dictation Notifications ScreenIntelligence Health About)
pages=(Home MyHome Phone Displays Connectivity Bar Dock ControlCenter Tiling Workspaces Sync Sound Dictation Notifications ScreenIntelligence Health About)
for page in "${pages[@]}"; do
page_file="$repo_dir/config/dot/quickshell/modules/settings/${page}Page.qml"
[[ -f "$page_file" ]] || fail "${page}Page.qml is missing"
@@ -50,12 +50,16 @@ PY
home_page="$repo_dir/config/dot/quickshell/modules/settings/HomePage.qml"
require_row "$home_page" ChoiceRow temperatureUnit
require_row "$home_page" SliderRow weatherRefreshMinutes
# vitalsIntervalMs moved to Appearance, beside the toggles it governs. It sat
# on Home while showCpu/showMemory/showGpu sat on Appearance -- one concept
# across two pages, which the ownership rule forbids and which made a search
# for it open a page that did not contain it.
appearance_page="$repo_dir/config/dot/quickshell/modules/settings/AppearancePage.qml"
require_row "$appearance_page" SliderRow vitalsIntervalMs
# vitalsIntervalMs sits beside the toggles it governs. It was on Home while
# showCpu/showMemory/showGpu were on Appearance -- one concept across two
# pages, which the ownership rule forbids and which made a search for it open
# a page that did not contain it. Both landed on Bar when Appearance's Shell
# tab dissolved: the vitals are bar content, not surface appearance.
bar_page="$repo_dir/config/dot/quickshell/modules/settings/BarPage.qml"
require_row "$bar_page" SliderRow vitalsIntervalMs
for setting in showCpu showMemory showGpu; do
require_row "$bar_page" ToggleRow "$setting"
done
notifications_page="$repo_dir/config/dot/quickshell/modules/settings/NotificationsPage.qml"
for setting in notificationTimeoutMs notificationTimeoutCriticalMs notificationHistoryLimit maxVisibleToasts; do
@@ -307,7 +311,7 @@ shell_pid="$harness_pid"
# four different categories, and the page the tab strip was introduced for.
# Routing to a tab must land on that tab, not on whatever its category opens
# first, which is the failure the SettingsRoutes resolution could introduce.
pages=(home appearance displays connectivity my-home phone desktop sound dictation notifications screen-intelligence shortcuts services manual about)
pages=(home appearance displays connectivity my-home phone bar dock control-center tiling workspaces sync sound dictation notifications screen-intelligence shortcuts services manual about)
for page in "${pages[@]}"; do
qs_for_test ipc call settings page "$page" >/dev/null
for _ in $(seq 1 20); do
@@ -323,6 +327,17 @@ done
qs_for_test ipc call settings page '__unsupported__' >/dev/null
[[ "$(qs_for_test ipc call settings status | jq -r .page)" == "home" ]] || fail 'unsupported page did not fall back to Home'
# Desktop & Dock became five Shell tabs. Old Vicinae commands, shell history,
# and muscle memory still hold the retired id, so it has to keep landing
# somewhere sensible rather than falling back to Home.
qs_for_test ipc call settings page desktop >/dev/null
for _ in $(seq 1 20); do
[[ "$(qs_for_test ipc call settings status | jq -r .page)" == "bar" ]] && break
sleep 0.1
done
[[ "$(qs_for_test ipc call settings status | jq -r .page)" == "bar" ]] \
|| fail 'the retired "desktop" id no longer resolves to the Bar tab'
/usr/sbin/hyprctl -j binds | jq -e '.[] | select(.description == "Settings" and .key == "I" and .modmask == 64)' >/dev/null \
|| fail 'Super+I is not registered as Panama Settings'
/usr/sbin/hyprctl -j binds | jq -e '.[] | select(.description == "Screen Intelligence" and .key == "S" and .modmask == 65)' >/dev/null \
+3 -1
View File
@@ -29,8 +29,10 @@ hyprctl -j clients | jq -e '.[] | select(.title == "Settings" and .floating == f
qs ipc call settings page displays >/dev/null
[[ "$(qs ipc call settings status | jq -r .page)" == "displays" ]] || fail 'Displays page did not route'
# The retired desktop id must keep routing — to bar, the Shell category's
# first tab, which is where its content went.
qs ipc call settings page desktop >/dev/null
[[ "$(qs ipc call settings status | jq -r .page)" == "desktop" ]] || fail 'Desktop page did not route'
[[ "$(qs ipc call settings status | jq -r .page)" == "bar" ]] || fail 'the retired desktop id did not route to bar'
address="$(hyprctl -j clients | jq -r '.[] | select(.title == "Settings") | .address')"
hyprctl dispatch "hl.dsp.window.close({ window = \"address:$address\" })" >/dev/null
+2 -2
View File
@@ -88,7 +88,7 @@ for reason in ("root.manuallyPaused", "root.gamePaused", "root.batteryPaused"):
# The pause reaches mpv over its JSON IPC socket rather than by killing and
# respawning the player, which would restart the video from the first frame.
if 'JSON.stringify({ command: ["set_property", "pause", root.paused] })' not in stripped:
if 'JSON.stringify({ "command": ["set_property", "pause", root.paused] })' not in stripped:
raise SystemExit("pausing no longer goes over mpv's JSON IPC")
if 'onPausedChanged: pauseSync.restart()' not in stripped:
raise SystemExit("a change of pause state does not push to the player")
@@ -122,7 +122,7 @@ rg -Fq 'mpvpaper' "$packages" \
# reasoned about per output.
rg -Fq 'playerRespawn.restart();' "$service" \
|| fail 'a crashed player is not respawned'
rg -Fq 'onOutputsChanged: if (root.active) playerRespawn.restart()' "$service" \
rg -Fq 'onOutputSignatureChanged: if (root.active) playerRespawn.restart()' "$service" \
|| fail 'a display hotplug does not respawn the players'
# ── 3. The hyprpaper handover ───────────────────────────────────────────────