Fold thirty-one settings pages into fifteen categories with tabs

The sidebar was a flat scan of thirty-one rows; now it reads like a
settings app. Multi-subject categories (Input, Network & Sharing,
Applications, Users & Accounts, Privacy & Security, System) carry an
Appearance-style tab strip above the page, drawn by the shell so the
leaf pages themselves are untouched. The taxonomy lives in one new
file, services/SettingsRoutes.qml; the sidebar, the strip, route
validation, search breadcrumbs, and both generators derive from it.

ShellState.settingsPage still holds leaf ids, so every deep link, IPC
call, and search result keeps working — and now lands on the exact
tab. Dictation moves out of Sound onto its own page under Input, with
a handoff back to Sound for the microphone. The strip scrolls when
System's nine tabs outgrow a tiled window. All 161 contracts pass.

Claude-Session: https://claude.ai/code/session_01Ms2FbjQy31TVf3CEvQhGM8
This commit is contained in:
Gabriel Brown
2026-08-23 20:21:31 -04:00
parent 50077a0c31
commit 5490fd285d
35 changed files with 816 additions and 288 deletions
+14 -8
View File
@@ -12,10 +12,10 @@
# longer true, and no test could notice because none of them knew what Panama
# had come to own in the meantime.
#
# This reads the sidebar for the pages that exist and the pages for the panels
# they hand off, and fails on any overlap. It is deliberately derived from both
# sides rather than from a hand-kept list, so absorbing the next page cannot
# leave a stale door behind.
# This reads SettingsRoutes for the pages that exist and the pages for the
# panels they hand off, and fails on any overlap. It is deliberately derived
# from both sides rather than from a hand-kept list, so absorbing the next page
# cannot leave a stale door behind.
#
# Read-only.
@@ -23,14 +23,14 @@ set -uo pipefail
repo_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
settings_dir="$repo_dir/config/dot/quickshell/modules/settings"
sidebar="$settings_dir/SettingsSidebar.qml"
routes="$repo_dir/config/dot/quickshell/services/SettingsRoutes.qml"
fail() {
printf 'gnome handoff contract: %s\n' "$1" >&2
exit 1
}
[[ -r "$sidebar" ]] || fail "missing $sidebar"
[[ -r "$routes" ]] || fail "missing $routes"
# GNOME panel names that correspond to a Panama page. Only entries whose panel
# genuinely duplicates a Panama page belong here: "network" stays off it because
@@ -52,8 +52,14 @@ declare -A ALLOWED=(
["UsersPage.qml:system-users"]="fingerprint enrollment requires fprintd's guided capture flow, and GNOME's Users panel carries the only good dialog for it"
)
pages="$(grep -oE '\{ *page: "[a-z-]+"' "$sidebar" | sed 's/.*"\(.*\)"/\1/' | sort -u)"
[[ -n "$pages" ]] || fail 'no pages could be read from the sidebar, so this proves nothing'
# The leaves: a tabless category is a page in its own right, and every tab is
# a page. A category that only groups tabs owns no controls itself, so it is
# not something GNOME could be handing a duplicate of.
pages="$( {
grep -oE '\{ page: "[a-z-]+", label: "[^"]*", icon: "[^"]*", tabs: \[\] \}' "$routes"
grep -oE '\{ page: "[a-z-]+", label: "[^"]*" \}' "$routes"
} | sed -E 's/\{ page: "([a-z-]+)".*/\1/' | sort -u)"
[[ -n "$pages" ]] || fail 'no pages could be read from SettingsRoutes, so this proves nothing'
has_page() {
grep -qx "$1" <<<"$pages"
+5 -2
View File
@@ -42,8 +42,11 @@ rg -Fq 'HealthCheckRow 1.0 HealthCheckRow.qml' "$settings_dir/qmldir" \
! rg -Fq 'ServicesPage 1.0 ServicesPage.qml' "$settings_dir/qmldir" \
|| fail 'retired ServicesPage remains registered in the Settings QML module'
rg -Fq 'label: "System Health"' "$settings_dir/SettingsSidebar.qml" \
|| fail 'sidebar does not label the stable services route System Health'
# The route is named in SettingsRoutes now that it is a tab of System rather
# than a sidebar row; the sidebar reads its labels from there.
rg -Fq '{ page: "services", label: "System Health" }' \
"$repo_dir/config/dot/quickshell/services/SettingsRoutes.qml" \
|| fail 'the settings taxonomy does not label the stable services route System Health'
rg -Fq 'onTapped: root.pageRequested("services")' "$settings_dir/SettingsSidebar.qml" \
|| fail 'health footer does not open the stable services route'
rg -Fq 'height: 54' "$settings_dir/SettingsSidebar.qml" \
+18 -4
View File
@@ -24,7 +24,7 @@ set -uo pipefail
repo_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
manual_dir="$repo_dir/config/dot/quickshell/manual"
page="$repo_dir/config/dot/quickshell/modules/settings/ManualPage.qml"
sidebar="$repo_dir/config/dot/quickshell/modules/settings/SettingsSidebar.qml"
routes="$repo_dir/config/dot/quickshell/services/SettingsRoutes.qml"
shell_ui="$repo_dir/config/dot/quickshell/modules/settings/SettingsShell.qml"
qmldir="$repo_dir/config/dot/quickshell/modules/settings/qmldir"
state="$repo_dir/config/dot/quickshell/services/ShellState.qml"
@@ -79,13 +79,27 @@ grep -q 'Quickshell.shellDir + "/manual/"' "$page" \
grep -q '\.\./\.\./\.\.' "$page" \
&& note 'the manual path walks upward out of the shell directory, which is only correct by accident'
# ── 4. Registered in all five places ─────────────────────────────────────────
# ── 4. Registered everywhere a settings page has to be ───────────────────────
# The manual is a tab of the System category rather than a sidebar row of its
# own -- it is reference material, not a control surface, and it belongs beside
# About for the same reason. SettingsRoutes is what makes it reachable at all:
# a leaf missing from the taxonomy cannot be opened, searched, or linked to.
grep -q '{ page: "manual"' "$sidebar" || note 'the manual has no sidebar entry'
grep -q '{ page: "manual", label: ' "$routes" || note 'the manual is not a leaf in SettingsRoutes, so nothing can navigate to it'
python3 - "$routes" <<'PY' || note 'the manual is no longer a tab of the System category, so it has drifted out of the group it belongs to'
import re, sys
text = open(sys.argv[1], encoding="utf-8").read()
block = re.search(r'\{ page: "system",.*?tabs: \[(.*?)\] \}', text, re.S)
raise SystemExit(0 if block and '{ page: "manual"' in block.group(1) else 1)
PY
grep -q 'case "manual": return manualPage;' "$shell_ui" || note 'SettingsShell does not route to the manual'
grep -q 'Component { id: manualPage; ManualPage {} }' "$shell_ui" || note 'SettingsShell never declares the manual component'
grep -q '^ManualPage 1.0 ManualPage.qml$' "$qmldir" || note 'ManualPage is not registered in the settings qmldir'
grep -q '"manual"' "$state" || note 'the manual is not in the allowed settings pages, so openSettings would redirect to Home'
# ShellState keeps no page list of its own any more; it resolves whatever it is
# handed through SettingsRoutes. That is what makes the taxonomy check above
# sufficient, so it is worth pinning that it stays that way.
grep -q 'SettingsRoutes.resolve(' "$state" || note 'ShellState does not resolve pages through SettingsRoutes, so openSettings("manual") has no defined destination'
grep -q 'page: "manual"' "$search" || note 'the manual is not searchable from the settings search box'
if (( ${#findings[@]} > 0 )); then
+14 -6
View File
@@ -38,16 +38,24 @@ declare -A expected=(
[restart-shell]=restart-shell
)
# The per-page settings commands are generated from the settings page list, so
# they are enumerated from that list rather than restated here -- a hand-written
# copy would have to be edited every time a page is added, which is exactly the
# kind of second list this generator exists to avoid.
sidebar="$repo_dir/config/dot/quickshell/modules/settings/SettingsSidebar.qml"
# The per-page settings commands are generated from the settings taxonomy, so
# they are enumerated from it rather than restated here -- a hand-written copy
# would have to be edited every time a page is added, which is exactly the kind
# of second list this generator exists to avoid.
#
# One command per leaf, not per sidebar row: a launcher entry that opened a
# category would land on whichever tab that category happens to open first,
# which is not what somebody typing "firewall" asked for. Home is skipped
# because open-settings already goes there.
routes="$repo_dir/config/dot/quickshell/services/SettingsRoutes.qml"
while read -r page; do
[[ -n "$page" ]] || continue
[[ "$page" == "home" ]] && continue
expected[settings-$page]="settings-page $page"
done < <(grep -oE '\{ page: "[a-z-]+"' "$sidebar" | sed 's/.*"\([a-z-]*\)"/\1/')
done < <( {
grep -oE '\{ page: "[a-z-]+", label: "[^"]*", icon: "[^"]*", tabs: \[\] \}' "$routes"
grep -oE '\{ page: "[a-z-]+", label: "[^"]*" \}' "$routes"
} | sed -E 's/\{ page: "([a-z-]+)".*/\1/' | sort -u)
(( ${#expected[@]} > 18 )) || fail 'no generated per-page commands were found; run scripts/panama-settings-commands'
+18 -9
View File
@@ -2,19 +2,24 @@
# Every "open the settings for this" jump must land somewhere real.
#
# ShellState.openSettings() validates its argument against an allow-list and
# falls back to Home for anything unknown. That fallback is sensible and it is
# also completely silent: a typo, or a page renamed later, turns a right-click
# into "opens Settings on the wrong page" with nothing logged and no error.
# ShellState.openSettings() hands its argument to SettingsRoutes.resolve(),
# which falls back to Home for anything it does not recognise. That fallback is
# sensible and it is also completely silent: a typo, or a page renamed later,
# turns a right-click into "opens Settings on the wrong page" with nothing
# logged and no error.
#
# Before this, exactly four places in the entire shell could reach Settings, so
# the risk was small. The bar now offers a jump on every widget, which makes the
# fallback worth guarding.
#
# A jump may name either a leaf ("firewall") or a category ("network"), since
# resolve() opens a category at its first available tab. Anything else is a
# typo, and SettingsRoutes.qml is the only place that knows which is which.
set -uo pipefail
repo_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
shell_state="$repo_dir/config/dot/quickshell/services/ShellState.qml"
routes="$repo_dir/config/dot/quickshell/services/SettingsRoutes.qml"
modules="$repo_dir/config/dot/quickshell/modules"
dock_menu="$modules/dock/DockContextMenu.qml"
notification_card="$modules/notifications/NotificationCard.qml"
@@ -25,8 +30,12 @@ fail() {
exit 1
}
allowed_line="$(grep -m1 'const allowed = \[' "$shell_state")" \
|| fail 'could not find the allow-list in ShellState'
[[ -r "$routes" ]] || fail "cannot read $routes"
# Every id SettingsRoutes recognises: the categories (which carry an icon) and
# the tabs inside them (which do not).
known="$(grep -oE '\{ page: "[a-z-]+"' "$routes" | sed 's/.*"\(.*\)"/\1/' | sort -u)"
[[ -n "$known" ]] || fail 'no pages found in SettingsRoutes -- this contract is not reading it correctly'
jumps="$(grep -rhoE 'openSettings\("[a-z-]+"\)' "$modules" 2>/dev/null \
| sed 's/openSettings("//; s/")//' | sort -u)"
@@ -35,8 +44,8 @@ jumps="$(grep -rhoE 'openSettings\("[a-z-]+"\)' "$modules" 2>/dev/null \
count=0
while read -r page; do
[[ -n "$page" ]] || continue
grep -qF "\"$page\"" <<<"$allowed_line" \
|| fail "a jump opens \"$page\", which ShellState does not allow -- openSettings falls back to Home silently, so this reads as a right-click that goes to the wrong page"
grep -qx "$page" <<<"$known" \
|| fail "a jump opens \"$page\", which is neither a leaf nor a category in SettingsRoutes -- resolve() falls back to Home silently, so this reads as a right-click that goes to the wrong page"
count=$((count + 1))
done <<<"$jumps"
+63 -18
View File
@@ -3,23 +3,32 @@
# Adding a settings page means editing four separate files, and missing one
# fails quietly rather than loudly:
#
# SettingsSidebar.qml the row you click
# SettingsShell.qml the case that maps that row to a component, AND the
# services/SettingsRoutes.qml the taxonomy: which category the page belongs
# to, and whether it is a tab inside one or a category
# of its own. This is the single source of truth -- the
# sidebar, the tab strip, ShellState's route
# resolution and the launcher command generator all
# derive from it, so a page absent here is a page that
# exists nowhere
# SettingsShell.qml the case that maps a leaf to a component, AND the
# Component declaration itself
# ShellState.qml the allow-list openSettings() checks -- a page missing
# here silently redirects to Home, so a deep link or a
# search result lands on the wrong page with no error
# modules/settings/qmldir the component registration -- without it the page
# is "not a type" and the whole settings window fails
# to load, taking every other page with it
# the .qml file itself
#
# Nothing at runtime cross-checks the four. This does, statically.
# ShellState no longer keeps its own allow-list: it asks SettingsRoutes to
# resolve whatever id it is handed. That removed a fifth place to forget, and
# this contract pins that it stays removed -- a literal list reappearing there
# would silently disagree with the taxonomy again.
#
# Nothing at runtime cross-checks any of this. This does, statically.
set -euo pipefail
repo_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
settings_dir="$repo_dir/config/dot/quickshell/modules/settings"
sidebar="$settings_dir/SettingsSidebar.qml"
routes="$repo_dir/config/dot/quickshell/services/SettingsRoutes.qml"
shell_file="$settings_dir/SettingsShell.qml"
qmldir="$settings_dir/qmldir"
shell_state="$repo_dir/config/dot/quickshell/services/ShellState.qml"
@@ -29,17 +38,49 @@ fail() {
exit 1
}
for required in "$sidebar" "$shell_file" "$qmldir" "$shell_state"; do
for required in "$routes" "$shell_file" "$qmldir" "$shell_state"; do
[[ -r "$required" ]] || fail "cannot read $required"
done
# ── Every sidebar row resolves everywhere ────────────────────────────────────
pages="$(grep -oE '\{ page: "[a-z-]+"' "$sidebar" | sed 's/.*"\(.*\)"/\1/')"
[[ -n "$pages" ]] || fail 'no pages found in the sidebar -- this contract is not reading it correctly'
# ── Reading the taxonomy ─────────────────────────────────────────────────────
# A category line carries an icon; a tab line does not. A category whose tabs
# are empty is a leaf itself, which is why the two shapes are read separately.
category_ids="$(grep -oE '\{ page: "[a-z-]+", label: "[^"]*", icon:' "$routes" \
| sed -E 's/\{ page: "([a-z-]+)".*/\1/')"
tabless_ids="$(grep -oE '\{ page: "[a-z-]+", label: "[^"]*", icon: "[^"]*", tabs: \[\] \}' "$routes" \
| sed -E 's/\{ page: "([a-z-]+)".*/\1/')"
tab_ids="$(grep -oE '\{ page: "[a-z-]+", label: "[^"]*" \}' "$routes" \
| sed -E 's/\{ page: "([a-z-]+)".*/\1/')"
allowed_line="$(grep -m1 'const allowed = \[' "$shell_state")" \
|| fail 'could not find the allow-list in ShellState'
[[ -n "$category_ids" ]] || fail 'no categories found in SettingsRoutes -- this contract is not reading it correctly'
[[ -n "$tab_ids" ]] || fail 'no tabs found in SettingsRoutes -- this contract is not reading it correctly'
# The leaves: every page a person can actually land on.
leaves="$(printf '%s\n%s\n' "$tabless_ids" "$tab_ids" | sed '/^$/d')"
# ── The taxonomy addresses each leaf exactly once ─────────────────────────────
# ShellState.settingsPage holds a leaf id, and the sidebar highlights the
# category that owns it. A leaf in two categories makes "which row lights up"
# depend on iteration order, and makes the breadcrumb a coin flip.
duplicate_tabs="$(sort <<<"$tab_ids" | uniq -d)"
[[ -z "$duplicate_tabs" ]] \
|| fail "these tab pages appear in more than one category, so the sidebar highlight and the breadcrumb become ambiguous: $(tr '\n' ' ' <<<"$duplicate_tabs")"
# A category with tabs may share its id with its first tab -- "applications",
# "users" and "privacy" do, and both readings land on the same category. A
# category *without* tabs is a leaf, so sharing an id with a tab elsewhere
# would put one page in two places.
while read -r page; do
[[ -n "$page" ]] || continue
grep -qx "$page" <<<"$tab_ids" \
&& fail "\"$page\" is a category with no tabs and also a tab of another category, so the same page id names two different places"
done <<<"$tabless_ids"
duplicate_categories="$(sort <<<"$category_ids" | uniq -d)"
[[ -z "$duplicate_categories" ]] \
|| fail "these category ids are declared twice: $(tr '\n' ' ' <<<"$duplicate_categories")"
# ── Every leaf resolves everywhere ───────────────────────────────────────────
while read -r page; do
[[ -n "$page" ]] || continue
@@ -47,12 +88,15 @@ while read -r page; do
# where an unknown page falls back to.
if [[ "$page" != "home" ]]; then
grep -qE "case \"$page\": return [a-zA-Z]+;" "$shell_file" \
|| fail "the sidebar offers \"$page\" but SettingsShell has no case for it, so clicking it shows Home"
|| fail "SettingsRoutes offers \"$page\" but SettingsShell has no case for it, so opening it shows Home"
fi
done <<<"$leaves"
grep -qF "\"$page\"" <<<"$allowed_line" \
|| fail "\"$page\" is missing from ShellState's allow-list, so openSettings(\"$page\") silently redirects to Home"
done <<<"$pages"
# ── ShellState defers to the taxonomy instead of restating it ────────────────
grep -q 'SettingsRoutes\.resolve(' "$shell_state" \
|| fail 'showSettings() does not route through SettingsRoutes.resolve, so a category id or an unknown page has no defined destination'
grep -q 'const allowed = \[' "$shell_state" \
&& fail 'ShellState has grown a literal allow-list again -- it will drift from SettingsRoutes, and a page missing from it silently redirects to Home'
# ── Every routed component is declared and registered ────────────────────────
# The case arms name a Component id; each must have a declaration, and the type
@@ -86,4 +130,5 @@ while read -r page_file; do
|| fail "$type_name.qml exists but nothing in SettingsShell instantiates it"
done < <(find "$settings_dir" -maxdepth 1 -name '*Page.qml')
printf 'settings nav contract: PASS\n'
printf 'settings nav contract: PASS (%d categories, %d leaves)\n' \
"$(grep -c . <<<"$category_ids")" "$(grep -c . <<<"$leaves")"
+6 -2
View File
@@ -9,7 +9,7 @@ fail() {
exit 1
}
pages=(Home Displays Connectivity Sound Notifications ScreenIntelligence Health About)
pages=(Home Displays Connectivity 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"
@@ -303,7 +303,11 @@ qs_for_test ipc call home-assistant fixture ready >/dev/null
shell_pid="$harness_pid"
[[ "$shell_pid" =~ ^[0-9]+$ ]] || fail 'could not identify the branch shell process'
pages=(home appearance displays connectivity home-phone desktop sound notifications screen-intelligence shortcuts services about)
# A spread of leaves rather than all of them: tabless categories, tabs from
# 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 home-phone desktop 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
+13 -5
View File
@@ -104,12 +104,20 @@ EXTRACT
)
[[ "$missing" -eq 0 ]] || fail "$missing schema label(s) are not findable by search"
# ── Results only ever route to pages the shell can open ─────────────────────
allowed="$(grep -oE 'const allowed = \[[^]]*\]' "$repo_dir/config/dot/quickshell/services/ShellState.qml" \
| grep -oE '"[a-z-]+"' | tr -d '"' | sort -u)"
for query in wallpaper blur timezone screenshot pointer lock volume gaps; do
# ── Results only ever route to leaves the shell can open ─────────────────────
# A search result carries a leaf id, never a category: it has to land on the
# exact tab holding the setting, not on whichever tab that category opens
# first. SettingsRoutes is where the leaves are declared -- a tabless category
# is one, and so is every tab.
routes="$repo_dir/config/dot/quickshell/services/SettingsRoutes.qml"
leaves="$( {
grep -oE '\{ page: "[a-z-]+", label: "[^"]*", icon: "[^"]*", tabs: \[\] \}' "$routes"
grep -oE '\{ page: "[a-z-]+", label: "[^"]*" \}' "$routes"
} | sed -E 's/\{ page: "([a-z-]+)".*/\1/' | sort -u)"
[[ -n "$leaves" ]] || fail 'no leaf pages could be read from SettingsRoutes, so this proves nothing'
for query in wallpaper blur timezone screenshot pointer lock volume gaps dictation; do
page="$(find_top "$query" | jq -r .topPage)"
grep -qx "$page" <<<"$allowed" || fail "search routed '$query' to unknown page '$page'"
grep -qx "$page" <<<"$leaves" || fail "search routed '$query' to '$page', which is not a page anyone can land on"
done
trap - EXIT
+38 -1
View File
@@ -3,11 +3,19 @@
# The Sound page is a first-class PipeWire control surface, not a launcher for
# another settings app. This contract keeps the real device plumbing shared
# with Quick Settings and verifies the controls that must remain available.
#
# It also owns the line between Sound and Dictation. Dictation used to be a
# card on this page, because it listens through the input device chosen here.
# It is an input method, so it now sits under Input with the keyboard -- and
# the thing that made the old arrangement legible, that the microphone and the
# dictation setup were visibly the same subject, has to survive the move as an
# explicit handoff rather than as a second device picker.
set -euo pipefail
repo_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
sound_page="$repo_dir/config/dot/quickshell/modules/settings/SoundPage.qml"
dictation_page="$repo_dir/config/dot/quickshell/modules/settings/DictationPage.qml"
device_list="$repo_dir/config/dot/quickshell/modules/settings/SoundDeviceList.qml"
device_row="$repo_dir/config/dot/quickshell/modules/settings/SoundDeviceRow.qml"
application_mixer="$repo_dir/config/dot/quickshell/modules/settings/ApplicationMixer.qml"
@@ -36,7 +44,7 @@ cleanup() {
}
trap cleanup EXIT
for file in "$sound_page" "$device_list" "$device_row" "$application_mixer" "$application_row" "$balance" "$audio_devices" "$sound_feedback" "$quick_devices"; do
for file in "$sound_page" "$dictation_page" "$device_list" "$device_row" "$application_mixer" "$application_row" "$balance" "$audio_devices" "$sound_feedback" "$quick_devices"; do
[[ -f "$file" ]] || fail "missing ${file#"$repo_dir/"}"
done
@@ -71,6 +79,35 @@ rg -Fq 'SoundFeedback.setEventSounds(checked)' "$sound_page" || fail 'event soun
rg -Fq 'SoundFeedback.setInputFeedback(checked)' "$sound_page" || fail 'input feedback sounds are not controllable'
rg -Fq 'org.gnome.desktop.sound' "$sound_feedback" || fail 'sound feedback does not use the desktop sound schema'
# ── Dictation lives on its own page under Input ──────────────────────────────
# One page owns the dictation controls. Two would mean two setup buttons
# driving the same one-time install, and whichever one someone found second
# would report state it did not cause.
! rg -Fq 'Dictation.' "$sound_page" \
|| fail 'the Sound page reads Dictation state again -- dictation belongs to DictationPage, and two pages showing the same setup is how one of them goes stale'
rg -Fq 'label: "Set up dictation"' "$dictation_page" \
|| fail 'DictationPage has no setup action, so the one-time install cannot be started from Settings'
rg -Fq 'onTriggered: Dictation.setup()' "$dictation_page" \
|| fail 'the dictation setup action does not call the helper that installs both the speech server and the model'
rg -Fq 'label: "Speech server"' "$dictation_page" \
|| fail 'DictationPage does not report whether the speech server is installed'
rg -Fq 'label: "Speech model"' "$dictation_page" \
|| fail 'DictationPage does not report whether the speech model is installed'
rg -Fq 'Dictation.typingAvailable' "$dictation_page" \
|| fail 'DictationPage does not say when wtype is missing, so dictated text would silently go to the clipboard'
rg -Fq 'Dictation.lastError' "$dictation_page" \
|| fail 'DictationPage never surfaces a setup failure'
# The microphone is chosen on the Sound page, and dictation listens through it.
# Saying so, with a way to get there, is what replaces the two cards having sat
# side by side.
rg -Fq 'title: "Microphone"' "$dictation_page" \
|| fail 'DictationPage does not name the input device it listens through'
rg -Fq 'ShellState.openSettings("sound")' "$dictation_page" \
|| fail 'DictationPage does not hand off to Sound, so the device it listens through is unreachable from it'
! rg -Fq 'SoundDeviceList {' "$dictation_page" \
|| fail 'DictationPage grew its own device picker -- there is one input device, and two places to change it disagree'
# Native bindings are the supported path. Shelling out would race the service
# that owns these same objects and regress Quick Settings coherence.
if rg -q '\b(Process|pactl|wpctl)\b' "$audio_devices" "$device_list" "$device_row" "$balance"; then