Files
Panama/tests/quickshell/bar-visibility-contract
Gabriel Brown f8f5b25510 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
2026-08-24 04:28:20 -04:00

225 lines
10 KiB
Bash
Executable File
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/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[@]}"