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[@]}"