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
+1 -1
View File
@@ -119,7 +119,7 @@ shipped one, so a hand-edited `settings.json` cannot cost you a keymap.
Every `hl.bind` must pass a `description`. Hyprland reports Lua-defined binds
with dispatcher `__lua` and a bytecode offset as the argument, so a bind without
one has nothing readable beside its chord, and the Settings app drops it from the
Input & Shortcuts page rather than showing a mystery row.
Keyboard page under Input rather than showing a mystery row.
`tests/quickshell/keybinds-contract` fails if any bind lacks a description, so
this cannot regress silently.
@@ -1473,7 +1473,7 @@ Singleton {
// each chord and falls back to the shipped one, so a hand-edited file
// cannot cost you a keymap.
//
// Edited through the Input & Shortcuts page rather than as a row, hence
// Edited through the Keyboard page (under Input) rather than as a row, hence
// internal.
{
key: "keybindOverrides", type: "json", def: ({}), group: "input",
@@ -55,6 +55,6 @@ one I have".
## Changing them
Settings has a Shortcuts page listing every bind, each of which can be
reassigned. A rebind moves the shortcut and cannot change what it does, so
Settings has a Keyboard page, under Input, listing every bind, each of which
can be reassigned. A rebind moves the shortcut and cannot change what it does, so
there is no way to make a key do something unexpected by editing it.
@@ -35,7 +35,7 @@ somewhere without following it.
On a machine with more than one display, workspaces belong to whichever
screen has focus. If you would rather pin workspaces one through ten to your
main display and give the second screen its own, that is a switch on the
Desktop settings page.
Desktop & Dock settings page.
## Projects
@@ -3,7 +3,7 @@
## Start here
Run **Check System Health** from the launcher, or open Settings and go to
System Health. It reports what is actually running rather than what was
System, then the System Health tab. It reports what is actually running rather than what was
installed, and it can repair several things itself.
From a terminal, the same check is `panama doctor`.
@@ -20,8 +20,8 @@ From a terminal: `qs -d` starts the shell again.
## A shortcut stopped working
Check `Super + /` first: if the shortcut shows a different chord than you
expect, something rebound it. The Shortcuts settings page can reset any bind,
or all of them at once.
expect, something rebound it. The Keyboard page, under Input in Settings, can
reset any bind, or all of them at once.
If the whole keymap is missing from that list, the compositor config failed
to load. `Hyprland --verify-config` says why without touching your session.
@@ -47,8 +47,9 @@ harmed by being refused.
`panama migrate` applies repairs this machine has not had yet. It is safe to
run at any time and does nothing when there is nothing to do.
If an update went badly, the Snapshots settings page can roll the system back
where btrfs snapshots are configured.
If an update went badly, the Snapshots tab under System in Settings can roll
the system back. That tab is only there where btrfs snapshots are configured,
so if you cannot see it, this machine has none to roll back to.
## Reporting it
@@ -2,10 +2,16 @@
## Settings
`Super + I`. Thirty-odd pages covering displays, sound, network, appearance,
power, printers, users, sharing, storage, updates and the rest. The search
box at the top searches the settings themselves rather than just page names,
so if you know what you want to change you can type it.
`Super + I`. Fifteen categories down the side, covering appearance, displays,
sound, input, network, power, accounts and the rest. A category with more than
one subject in it opens a row of tabs above the page, which is where the
narrower topics live: Printers is a tab of Network & Sharing, Dictation a tab
of Input, and About, Software Update, Storage, Snapshots and this manual are
tabs of System.
The search box at the top searches the settings themselves rather than page
names, and each result says where it will land, so if you know what you want
to change you can type it and never touch the sidebar.
## Appearance
@@ -1,4 +1,4 @@
// Network & Devices.
// Connections — wired, Wi-Fi, and Bluetooth.
//
// Wi-Fi and Bluetooth are handled here rather than delegated. Everything goes
// through Quickshell.Networking and Quickshell.Bluetooth -- NetworkManager and
@@ -20,7 +20,7 @@ SettingsPage {
id: root
title: "Network & Devices"
title: "Connections"
lede: Connectivity.activeNetwork
? "Connected to " + Connectivity.activeNetwork.name
: "Wi-Fi, Bluetooth, and the things Fedora owns."
@@ -0,0 +1,90 @@
import QtQuick
import qs.config
import qs.services
// Dictation is an input method, so it lives under Input beside the keyboard —
// but it listens through whichever device the Sound page selects, so the
// microphone card below hands off there rather than duplicating the picker.
SettingsPage {
title: "Dictation"
lede: Dictation.ready
? "Hold Super+D, speak, and release. The words are typed where the cursor is."
: "Speech to text, on the GPU, once the one-time setup below has run."
SettingsCard {
title: "Dictation"
subtitle: Dictation.ready
? "Hold Super+D, speak, and release. The words are typed where the cursor is."
: "The one-time setup fetches a speech server and a ~490 MB model — neither ships with Panama, because both are large and want the network."
// Status of the two pieces, read from the machine rather than guessed.
TextRow {
label: "Speech server"
detail: Dictation.serverReady
? "Running"
: (Dictation.imageBuilt ? "Ready — starts on the first dictation" : "Not set up yet")
value: Dictation.imageBuilt ? "Ready" : "Missing"
}
TextRow {
label: "Speech model"
detail: Dictation.modelInstalled
? "Kept across rebuilds of the server"
: "About 490 MB, downloaded once"
value: Dictation.modelInstalled ? Math.round(Dictation.modelBytes / 1048576) + " MB" : "Missing"
}
// ONE action that actually works: panama-dictate setup pulls the
// server image and downloads the model together. This card used to
// offer a Download button wired to a command the helper does not have,
// and told you to build a "panama app whisper-vulkan" that does not
// exist -- so nothing here did anything. It does now.
ActionRow {
visible: !Dictation.ready || Dictation.downloading
label: "Set up dictation"
detail: {
if (!Dictation.downloading)
return "Fetches the speech server and the model. Runs once, keeps both.";
if (Dictation.phase === "pulling")
return "Fetching the speech server…";
if (Dictation.downloadTotalBytes > 0)
return "Downloading model — " + Math.round(Dictation.downloadFraction * 100)
+ "% of " + Math.round(Dictation.downloadTotalBytes / 1048576) + " MB";
return "Setting up…";
}
action: Dictation.downloading ? "Working…" : "Set up"
enabled: !Dictation.downloading
onTriggered: Dictation.setup()
divider: !Dictation.typingAvailable || Dictation.lastError !== ""
}
TextRow {
visible: !Dictation.typingAvailable
label: "Typing"
detail: "wtype is missing, so dictated text would go to the clipboard instead of being typed."
value: "Missing"
divider: Dictation.lastError !== ""
}
TextRow {
visible: Dictation.lastError !== ""
label: "Problem"
detail: Dictation.lastError
value: ""
divider: false
}
}
SettingsCard {
title: "Microphone"
subtitle: AudioDevices.current(false)?.description ?? "No input device"
ActionRow {
label: "Input device"
detail: "Dictation listens through the input device selected in Sound"
divider: false
action: "Open Sound"
onTriggered: ShellState.openSettings("sound")
}
}
}
@@ -4,12 +4,42 @@ The control center for everything Panama owns. Anything the system owns —
hardware, accounts, printers — is delegated to GNOME Settings and labeled as
such rather than half-reimplemented.
## Navigation
The sidebar lists fifteen **categories**, not one row per page. A category
covering several subjects — Input, Network & Sharing, Applications, Users &
Accounts, Privacy & Security, System — draws a tab strip above the page, and
each of its **leaf** pages is one tab; a category with a single subject is a
leaf itself and shows no strip. The column used to be a flat list of thirty-one
pages, which made finding Printers a scan of the whole thing. Grouped, it sits
under Network & Sharing, where somebody looking for it already expects it.
Leaf pages are ordinary `SettingsPage` files and know nothing about this.
`SettingsShell` draws the strip and hosts the page inside it, so moving a page
between categories never touches the page.
`services/SettingsRoutes.qml` is the one place the taxonomy is written down.
The sidebar, the tab strip, `ShellState`'s route validation, the breadcrumb
subtitles on search results ("System Storage"), and
`scripts/panama-settings-commands` all derive from it — so a page moves in one
edit rather than five, and none of them can disagree about where it lives.
`ShellState.settingsPage` still holds a **leaf** id, the same ids callers have
always used, so every existing deep link, IPC call, and search hit keeps
working and now lands on the exact tab. A category id is accepted too, and
resolves to that category's first available tab.
Availability gating belongs to the strip rather than the sidebar:
`SettingsRoutes.pageAvailable()` drops the Containers tab on a machine without
podman and Snapshots without a snapper configuration, but only once a scan has
proven the stack absent, so a machine that has it never sees the tab blink.
## System Health
The stable internal `services` route renders **System Health**. It is reachable
from the Settings sidebar and its live 54px footer, the degraded-only bar
indicator, and Vicinae's **Check System Health** command. Healthy scans
reserve no bar space and produce no notification.
The stable internal `services` route renders **System Health**, a tab of
**System**. It is reachable from there, from its live 54px footer, from the
degraded-only bar indicator, and from Vicinae's **Check System Health**
command. Healthy scans reserve no bar space and produce no notification.
`services/Health.qml` owns the last accepted redacted snapshot. It invokes
`scripts/panama-doctor` for scans and bounded repairs, `wl-copy` only for an
@@ -95,11 +95,39 @@ Rectangle {
color: Theme.alpha(Theme.fg, 0.06)
}
// The tab strip for the current category, rendered above the page so the
// leaf pages themselves stay untouched by the grouping. A category with a
// single subject earns no strip.
readonly property var categoryTabs: SettingsRoutes.tabsFor(ShellState.settingsPage)
Item {
id: categoryStrip
readonly property bool shown: root.categoryTabs.length > 1
anchors.left: sidebar.right
anchors.right: parent.right
anchors.top: titlebar.bottom
anchors.leftMargin: 34
anchors.rightMargin: 34
height: shown ? 48 : 0
visible: shown
SettingsTabs {
anchors.left: parent.left
anchors.right: parent.right
anchors.bottom: parent.bottom
tabs: root.categoryTabs.map(tab => ({ value: tab.page, label: tab.label }))
current: ShellState.settingsPage
onSelected: value => ShellState.openSettings(value)
}
}
Loader {
id: pageLoader
anchors.left: sidebar.right
anchors.right: parent.right
anchors.top: titlebar.bottom
anchors.top: categoryStrip.bottom
anchors.bottom: parent.bottom
sourceComponent: {
switch (ShellState.settingsPage) {
@@ -114,6 +142,7 @@ Rectangle {
case "screen-intelligence": return screenIntelligencePage;
case "shortcuts": return shortcutsPage;
case "mouse": return mousePage;
case "dictation": return dictationPage;
case "privacy": return privacyPage;
case "region": return regionPage;
case "accounts": return onlineAccountsPage;
@@ -192,6 +221,7 @@ Rectangle {
Component { id: screenIntelligencePage; ScreenIntelligencePage {} }
Component { id: shortcutsPage; ShortcutsPage {} }
Component { id: mousePage; MousePage {} }
Component { id: dictationPage; DictationPage {} }
Component { id: privacyPage; PrivacyPage {} }
Component { id: regionPage; RegionPage {} }
Component { id: onlineAccountsPage; OnlineAccountsPage {} }
@@ -16,60 +16,15 @@ Rectangle {
sidebarScroll.contentY = 0;
}
// Search results say where a hit will land ("System Storage"), because
// with tabbed categories the page name alone no longer locates it.
function pageLabel(page: string): string {
const found = root.destinations.find(item => item.page === page);
return found ? found.label : "Settings";
return SettingsRoutes.breadcrumb(page);
}
readonly property var destinations: [
{ page: "home", label: "Home", icon: "\u{F02DC}" },
{ page: "appearance", label: "Appearance", icon: "\u{F0E0D}" },
{ page: "displays", label: "Displays", icon: "\u{F0379}" },
{ page: "connectivity", label: "Network & Devices", icon: "\u{F08D4}" },
{ page: "sharing", label: "Sharing", icon: "\u{F04E6}" },
{ page: "firewall", label: "Firewall", icon: "\u{F0483}" },
{ page: "printers", label: "Printers", icon: "\u{F042A}" },
{ page: "containers", label: "Containers", icon: "\u{F0868}" },
{ page: "ssh-keys", label: "SSH Keys", icon: "\u{F0306}" },
{ page: "home-phone", label: "Home & Phone", icon: "\u{F02DC}" },
{ page: "desktop", label: "Desktop & Dock", icon: "\u{F04A4}" },
{ page: "sound", label: "Sound", icon: "\u{F057E}" },
{ page: "gaming", label: "Gaming", icon: "\u{F0297}" },
{ page: "notifications", label: "Notifications & Focus", icon: "\u{F009A}" },
{ page: "screen-intelligence", label: "Screen Intelligence", icon: "\u{F05A8}" },
{ page: "shortcuts", label: "Keyboard", icon: "\u{F030C}" },
{ page: "mouse", label: "Mouse & Touchpad", icon: "\u{F037D}" },
{ page: "privacy", label: "Privacy & Security", icon: "\u{F0483}" },
{ page: "region", label: "Region & Language", icon: "\u{F0AC2}" },
{ page: "accounts", label: "Online Accounts", icon: "\u{F0004}" },
{ page: "accessibility", label: "Accessibility", icon: "\u{F0208}" },
{ page: "power", label: "Power & Lock", icon: "\u{F0425}" },
{ page: "datetime", label: "Date & Time", icon: "\u{F0954}" },
{ page: "applications", label: "Applications", icon: "\u{F003B}" },
{ page: "updates", label: "Software Update", icon: "\u{F06B0}" },
{ page: "storage", label: "Storage", icon: "\u{F02CA}" },
{ page: "snapshots", label: "Snapshots", icon: "\u{F0954}" },
{ page: "users", label: "Users", icon: "\u{F0004}" },
{ page: "services", label: "System Health", icon: "\u{F0493}" },
{ page: "manual", label: "Manual", icon: "\u{F02D4}" },
{ page: "about", label: "About", icon: "\u{F02FD}" }
]
// Pages whose entire backing stack can be absent hide once a scan has
// proven it absent: a Containers page with no podman and a Snapshots page
// with no snapper configuration render permanently empty, which reads as
// broken rather than inapplicable. Until the scan lands they stay
// visible, so machines that have the stack never see a blink -- and both
// scans fire when Settings opens (HomePage.onCompleted).
function pageAvailable(page: string): bool {
switch (page) {
case "containers":
return !Containers.scanned || Containers.available;
case "snapshots":
return !Snapshots.scanned || Snapshots.configs.length > 0;
}
return true;
}
// One row per category. SettingsRoutes owns the taxonomy; a category with
// tabs is opened at its first available tab by ShellState's resolution.
readonly property var destinations: SettingsRoutes.categories
width: 272
color: Theme.alpha(Theme.bgDark, 0.96)
@@ -252,14 +207,16 @@ Rectangle {
Rectangle {
id: navItem
required property var modelData
visible: root.pageAvailable(modelData.page)
// A row lights up when it owns the current leaf, so
// System stays highlighted while you sit on Storage.
readonly property bool active: SettingsRoutes.categoryOf(root.selectedPage).page === modelData.page
width: parent.width
height: 40
radius: 10
color: modelData.page === root.selectedPage
color: navItem.active
? Theme.alpha(Theme.accent, 0.17)
: (navMouse.containsMouse ? Theme.alpha(Theme.fg, Theme.hoverAlpha * 0.55) : Theme.alpha(Theme.fg, 0))
border.width: modelData.page === root.selectedPage ? 1 : 0
border.width: navItem.active ? 1 : 0
border.color: Theme.alpha(Theme.accent, 0.26)
Rectangle {
@@ -268,7 +225,7 @@ Rectangle {
radius: 1
anchors.left: parent.left
anchors.verticalCenter: parent.verticalCenter
visible: navItem.modelData.page === root.selectedPage
visible: navItem.active
gradient: Gradient {
GradientStop { position: 0; color: Theme.accent }
GradientStop { position: 1; color: Theme.accentSecondary }
@@ -281,7 +238,7 @@ Rectangle {
anchors.verticalCenter: parent.verticalCenter
width: 25
text: navItem.modelData.icon
color: navItem.modelData.page === root.selectedPage ? Theme.accent : Theme.fgDim
color: navItem.active ? Theme.accent : Theme.fgDim
font.family: Theme.fontMono
font.pixelSize: 15
}
@@ -293,10 +250,10 @@ Rectangle {
anchors.rightMargin: 9
anchors.verticalCenter: parent.verticalCenter
text: navItem.modelData.label
color: navItem.modelData.page === root.selectedPage ? Theme.fg : Theme.fgDim
color: navItem.active ? Theme.fg : Theme.fgDim
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSize
font.weight: navItem.modelData.page === root.selectedPage ? Font.Medium : Font.Normal
font.weight: navItem.active ? Font.Medium : Font.Normal
elide: Text.ElideRight
}
@@ -11,6 +11,10 @@
// sat below a wallpaper grid and an entire lock screen. Tabs are not a way to
// make a short page look organised; they are for when the page is long enough
// that the order stops being a suggestion and starts being a burial.
//
// The strip scrolls horizontally when it overflows: the Settings window tiles
// down to 900px, and the System category carries nine tabs. The active tab is
// always brought into view, and edge fades say there is more to the side.
import QtQuick
import qs.config
@@ -27,55 +31,110 @@ Item {
width: parent ? parent.width : 620
implicitHeight: 40
Row {
id: strip
anchors.left: parent.left
anchors.bottom: parent.bottom
spacing: 2
Flickable {
id: flick
Repeater {
model: root.tabs
anchors.fill: parent
contentWidth: strip.implicitWidth
contentHeight: height
flickableDirection: Flickable.HorizontalFlick
boundsBehavior: Flickable.StopAtBounds
interactive: contentWidth > width
clip: true
delegate: Item {
id: tab
function reveal(item: Item): void {
if (contentWidth <= width)
return;
const target = item.x - (flick.width - item.width) / 2;
contentX = Math.max(0, Math.min(contentWidth - width, target));
}
required property var modelData
Row {
id: strip
anchors.bottom: parent.bottom
spacing: 2
readonly property bool active: String(tab.modelData.value) === root.current
Repeater {
model: root.tabs
implicitWidth: caption.implicitWidth + 30
implicitHeight: 38
delegate: Item {
id: tab
Text {
id: caption
anchors.centerIn: parent
text: String(tab.modelData.label ?? "")
color: tab.active ? Theme.fg : (hover.hovered ? Theme.fgDim : Theme.fgMuted)
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSize
font.weight: tab.active ? Font.DemiBold : Font.Medium
}
required property var modelData
Rectangle {
anchors.left: parent.left
anchors.right: parent.right
anchors.bottom: parent.bottom
height: 2
radius: 1
visible: tab.active
color: Theme.accent
}
readonly property bool active: String(tab.modelData.value) === root.current
HoverHandler {
id: hover
cursorShape: Qt.PointingHandCursor
}
implicitWidth: caption.implicitWidth + 30
implicitHeight: 38
TapHandler {
onTapped: root.selected(String(tab.modelData.value))
onActiveChanged: if (active) flick.reveal(tab)
Component.onCompleted: if (active) flick.reveal(tab)
Text {
id: caption
anchors.centerIn: parent
text: String(tab.modelData.label ?? "")
color: tab.active ? Theme.fg : (hover.hovered ? Theme.fgDim : Theme.fgMuted)
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSize
font.weight: tab.active ? Font.DemiBold : Font.Medium
}
Rectangle {
anchors.left: parent.left
anchors.right: parent.right
anchors.bottom: parent.bottom
height: 2
radius: 1
visible: tab.active
color: Theme.accent
}
HoverHandler {
id: hover
cursorShape: Qt.PointingHandCursor
}
TapHandler {
onTapped: root.selected(String(tab.modelData.value))
}
}
}
}
WheelHandler {
target: null
onWheel: event => {
const delta = event.angleDelta.x !== 0 ? event.angleDelta.x : event.angleDelta.y;
flick.contentX = Math.max(0, Math.min(flick.contentWidth - flick.width, flick.contentX - delta));
}
}
}
Rectangle {
anchors.left: parent.left
anchors.bottom: parent.bottom
width: 18
height: parent.height
visible: flick.contentX > 1
gradient: Gradient {
orientation: Gradient.Horizontal
GradientStop { position: 0; color: Theme.bg }
GradientStop { position: 1; color: Theme.alpha(Theme.bg, 0) }
}
}
Rectangle {
anchors.right: parent.right
anchors.bottom: parent.bottom
width: 18
height: parent.height
visible: flick.contentWidth > flick.width && flick.contentX < flick.contentWidth - flick.width - 1
gradient: Gradient {
orientation: Gradient.Horizontal
GradientStop { position: 0; color: Theme.alpha(Theme.bg, 0) }
GradientStop { position: 1; color: Theme.bg }
}
}
Rectangle {
@@ -1,4 +1,4 @@
// Input & Shortcuts.
// Keyboard — layout, key behavior, and every compositor shortcut.
//
// The shortcut list is generated from `hyprctl binds -j` rather than typed out
// here. The previous version was a hand-maintained array of nineteen entries
@@ -46,11 +46,11 @@ SettingsPage {
SystemSettings.commitPreference("keyboardOptions", options.join(","));
}
title: "Input & Shortcuts"
title: "Keyboard"
lede: "The Forge mental model, carried forward into native tiling."
SettingsCard {
title: "Keyboard"
title: "Layout & typing"
// These were read-only text, on the grounds that a layout change needed
// a compositor reload. It does not: setting input:kb_variant through
@@ -31,72 +31,6 @@ SettingsPage {
}
}
// Beside Input on purpose: dictation listens through whichever device that
// card selects, and putting the two together is what makes that obvious.
SettingsCard {
title: "Dictation"
subtitle: Dictation.ready
? "Hold Super+D, speak, and release. The words are typed where the cursor is."
: "Speech to text, on the GPU. Hold Super+D once it is set up. The one-time setup below fetches a speech server and a ~490 MB model — neither ships with Panama, because both are large and want the network."
// Status of the two pieces, read from the machine rather than guessed.
TextRow {
label: "Speech server"
detail: Dictation.serverReady
? "Running"
: (Dictation.imageBuilt ? "Ready — starts on the first dictation" : "Not set up yet")
value: Dictation.imageBuilt ? "Ready" : "Missing"
}
TextRow {
label: "Speech model"
detail: Dictation.modelInstalled
? "Kept across rebuilds of the server"
: "About 490 MB, downloaded once"
value: Dictation.modelInstalled ? Math.round(Dictation.modelBytes / 1048576) + " MB" : "Missing"
}
// ONE action that actually works: panama-dictate setup pulls the
// server image and downloads the model together. This card used to
// offer a Download button wired to a command the helper does not have,
// and told you to build a "panama app whisper-vulkan" that does not
// exist -- so nothing here did anything. It does now.
ActionRow {
visible: !Dictation.ready || Dictation.downloading
label: "Set up dictation"
detail: {
if (!Dictation.downloading)
return "Fetches the speech server and the model. Runs once, keeps both.";
if (Dictation.phase === "pulling")
return "Fetching the speech server…";
if (Dictation.downloadTotalBytes > 0)
return "Downloading model — " + Math.round(Dictation.downloadFraction * 100)
+ "% of " + Math.round(Dictation.downloadTotalBytes / 1048576) + " MB";
return "Setting up…";
}
action: Dictation.downloading ? "Working…" : "Set up"
enabled: !Dictation.downloading
onTriggered: Dictation.setup()
divider: !Dictation.typingAvailable || Dictation.lastError !== ""
}
TextRow {
visible: !Dictation.typingAvailable
label: "Typing"
detail: "wtype is missing, so dictated text would go to the clipboard instead of being typed."
value: "Missing"
divider: Dictation.lastError !== ""
}
TextRow {
visible: Dictation.lastError !== ""
label: "Problem"
detail: Dictation.lastError
value: ""
divider: false
}
}
SettingsCard {
title: "Applications"
subtitle: "Control each application currently playing through PipeWire."
@@ -74,6 +74,7 @@ TimeOfDayRow 1.0 TimeOfDayRow.qml
LocationPicker 1.0 LocationPicker.qml
FontPicker 1.0 FontPicker.qml
MousePage 1.0 MousePage.qml
DictationPage 1.0 DictationPage.qml
TextEntryRow 1.0 TextEntryRow.qml
PrivacyPage 1.0 PrivacyPage.qml
RegionPage 1.0 RegionPage.qml
@@ -1,8 +1,9 @@
// The first thing a new machine shows.
//
// Panama has thirty settings pages, which is the opposite of the usual
// problem: somebody arriving from GNOME, macOS or Windows cannot tell which
// four things matter. This is those four things, once, on the first start.
// Panama has fifteen settings categories and thirty-odd pages inside them,
// which is the opposite of the usual problem: somebody arriving from GNOME,
// macOS or Windows cannot tell which four things matter. This is those four
// things, once, on the first start.
//
// Deliberately not a tour. Nobody reads a tour, and a multi-step wizard on a
// desktop somebody just installed is another thing standing between them and
@@ -14,8 +15,9 @@
// have -- and so this cannot drift the way a hand-written list would.
//
// Shown once, tracked by the `welcomeSeen` preference. It stays reachable
// afterwards from the launcher and from Settings > About, because the moment
// somebody wants it again is exactly the moment a one-shot has thrown it away.
// afterwards from the launcher and from Settings > System > About, because
// the moment somebody wants it again is exactly the moment a one-shot has
// thrown it away.
import Quickshell
import Quickshell.Wayland
@@ -8,11 +8,16 @@ searching there. These commands close that gap, so typing "night light" into the
launcher opens the page that owns it.
The vocabulary is derived from the same sources the in-app search uses --
the page list in SettingsSidebar.qml, the group routing in SettingsSearch.qml,
the taxonomy in SettingsRoutes.qml, the group routing in SettingsSearch.qml,
and the labels in PreferenceSchema.qml -- so a setting that is searchable inside
Settings is searchable from the launcher without anyone maintaining a second
list.
One command per *leaf* page, which is what the sidebar's categories bottom out
in: a category with tabs contributes its tabs, a category without them is a leaf
itself. Landing the launcher on a category would mean landing on whichever tab
happened to be first, which is not what the person typing "printers" asked for.
panama-settings-commands write the commands
panama-settings-commands --check fail if what is on disk is stale
@@ -27,7 +32,7 @@ from pathlib import Path
REPO = Path(__file__).resolve().parents[4]
SHELL = REPO / "config/dot/quickshell"
SIDEBAR = SHELL / "modules/settings/SettingsSidebar.qml"
ROUTES = SHELL / "services/SettingsRoutes.qml"
SEARCH = SHELL / "services/SettingsSearch.qml"
SCHEMA = SHELL / "config/PreferenceSchema.qml"
OUTPUT_DIR = REPO / "config/local/share/vicinae/scripts"
@@ -55,13 +60,51 @@ def read(path: Path) -> str:
raise ParseError(f"could not read {path}") from error
CATEGORY = re.compile(
r'\{\s*page:\s*"([a-z-]+)",\s*label:\s*"([^"]+)",\s*icon:\s*"[^"]*",\s*tabs:\s*\[([^\]]*)\]\s*\}',
re.S)
TAB = re.compile(r'\{\s*page:\s*"([a-z-]+)",\s*label:\s*"([^"]+)"\s*\}')
def categories() -> list[tuple[str, str, list[tuple[str, str]]]]:
"""The sidebar taxonomy, in order, as (id, label, tabs)."""
source = read(ROUTES)
block = re.search(r"readonly property var categories: \[(.*?)\n \]", source, re.S)
if not block:
raise ParseError("categories not found in SettingsRoutes.qml")
found = [(page, label, TAB.findall(tabs))
for page, label, tabs in CATEGORY.findall(block.group(1))]
# A category whose literal stops matching -- a reordered field, an icon
# written as anything but a string -- would otherwise go missing without a
# word, taking its pages' commands with it. So every `page:` written in the
# array has to be accounted for by something this reader recognised.
declared = len(re.findall(r'\bpage:\s*"', block.group(1)))
parsed = sum(1 + len(tabs) for _page, _label, tabs in found)
if not found or parsed != declared:
raise ParseError(
f"read {parsed} of the {declared} pages in SettingsRoutes.qml; the "
"categories array no longer looks the way this reader expects")
return found
def pages() -> list[tuple[str, str]]:
"""The settings pages, in sidebar order, as (id, label)."""
source = read(SIDEBAR)
found = re.findall(r'\{ page: "([a-z-]+)", label: "([^"]+)"', source)
if not found:
raise ParseError("no pages found in SettingsSidebar.qml")
return [(page, label) for page, label in found if page not in SKIP_PAGES]
"""The leaf pages, in sidebar order, as (id, label).
A category with tabs is a group, not somewhere anyone lands: its tabs are
the pages, and each tab is named by its own label rather than the group's.
A category without tabs is a leaf itself.
"""
leaves: list[tuple[str, str]] = []
for page, label, tabs in categories():
leaves += tabs or [(page, label)]
ids = [page for page, _label in leaves]
duplicated = sorted({page for page in ids if ids.count(page) > 1})
if duplicated:
# Two leaves sharing an id write one file between them, so the second
# would quietly overwrite the first's title.
raise ParseError("these leaf pages are declared twice in SettingsRoutes.qml: "
+ ", ".join(duplicated))
return [(page, label) for page, label in leaves if page not in SKIP_PAGES]
def group_pages() -> dict[str, str]:
@@ -17,6 +17,10 @@ Usage:
panama-settings-docs --check exit 1 if the committed copy is stale
panama-settings-docs --stdout print without writing
Where each group of settings lives comes from SettingsSearch.qml's routing and
SettingsRoutes.qml's taxonomy, so a page renamed or moved into another category
is renamed here too rather than in a list somebody has to remember.
Parsing rather than importing: the schema is QML, there is no QML interpreter
here, and a regex reader that FAILS LOUDLY when it stops recognising the file is
better than a dependency on a shell that has to start a compositor.
@@ -32,28 +36,50 @@ import sys
ROOT = pathlib.Path(__file__).resolve().parents[4]
SCHEMA = ROOT / "config/dot/quickshell/config/PreferenceSchema.qml"
SEARCH = ROOT / "config/dot/quickshell/services/SettingsSearch.qml"
ROUTES = ROOT / "config/dot/quickshell/services/SettingsRoutes.qml"
OUTPUT = ROOT / "docs/settings.md"
# Page id -> the name a person sees in the sidebar.
PAGE_TITLES = {
"home": "Home", "appearance": "Appearance", "displays": "Displays",
"connectivity": "Network & Devices", "home-phone": "Home & Phone",
"desktop": "Desktop & Dock", "sound": "Sound",
"notifications": "Notifications & Focus",
"screen-intelligence": "Screen Intelligence", "shortcuts": "Keyboard",
"mouse": "Mouse & Touchpad", "privacy": "Privacy & Security",
"region": "Region & Language", "accounts": "Online Accounts",
"accessibility": "Accessibility", "power": "Power & Lock",
"datetime": "Date & Time", "applications": "Applications",
"services": "System Health", "about": "About",
"gaming": "Gaming",
}
CATEGORY = re.compile(
r'\{\s*page:\s*"([a-z-]+)",\s*label:\s*"([^"]+)",\s*icon:\s*"[^"]*",\s*tabs:\s*\[([^\]]*)\]\s*\}',
re.S)
TAB = re.compile(r'\{\s*page:\s*"([a-z-]+)",\s*label:\s*"([^"]+)"\s*\}')
class SchemaError(RuntimeError):
"""The schema stopped looking the way this reader expects."""
def read_titles():
"""Leaf page id -> the name a person sees, from SettingsRoutes.
The sidebar is fifteen categories of tabs rather than a flat list, so a page
is named the way a hit names it in search: "System Storage" for a tab,
"Displays" for a category that is a page by itself.
"""
text = ROUTES.read_text()
block = re.search(r"readonly property var categories: \[(.*?)\n \]", text, re.S)
if not block:
raise SchemaError("could not find the categories array in SettingsRoutes.qml")
titles = {}
read = 0
for page, label, tabs in CATEGORY.findall(block.group(1)):
found = TAB.findall(tabs)
read += 1 + len(found)
if found:
titles.update({tab: f"{label} {tab_label}" for tab, tab_label in found})
else:
titles[page] = label
# A category this reader stopped recognising would take its pages' names
# with it, and the group that routed to one would then be documented as
# living nowhere. Every `page:` in the array has to be accounted for.
declared = len(re.findall(r'\bpage:\s*"', block.group(1)))
if not titles or read != declared:
raise SchemaError(
f"read {read} of the {declared} pages in SettingsRoutes.qml; the "
"categories array no longer looks the way this reader expects")
return titles
def read_routes():
"""group -> page id, from SettingsSearch."""
text = SEARCH.read_text()
@@ -139,7 +165,7 @@ def read_entries():
return entries
def render(entries, routes):
def render(entries, routes, titles):
lines = [
"# Settings reference",
"",
@@ -163,18 +189,19 @@ def render(entries, routes):
if not visible:
continue
page = routes.get(group)
# A routed page with no title used to fall back to the raw page id, so
# A routed page with no name used to fall back to the raw page id, so
# the group "gaming" documented itself as "Found on **gaming**" while
# every other group named a real page. The staleness contract could not
# see it: regenerating reproduced the same wrong file, so the copy was
# current and wrong at once. Refusing to render is what makes the next
# page added here impossible to miss.
if page is not None and page not in PAGE_TITLES:
# current and wrong at once. Refusing to render is what makes a group
# routed at a page that no longer exists impossible to miss.
if page is not None and page not in titles:
raise SchemaError(
f"group '{group}' routes to page '{page}', which has no entry in "
"PAGE_TITLES; add one rather than letting the id be printed as a name"
f"group '{group}' routes to page '{page}', which is not a leaf page "
"in SettingsRoutes.qml; route it at one rather than letting the id "
"be printed as a name"
)
title = PAGE_TITLES.get(page, page or "—")
title = titles.get(page, page or "—")
lines += [f"## {group}", "", f"Found on **{title}**.", ""]
lines += ["| Setting | Default | What it does |", "|---|---|---|"]
for entry in visible:
@@ -210,7 +237,7 @@ def main():
args = parser.parse_args()
try:
rendered = render(read_entries(), read_routes())
rendered = render(read_entries(), read_routes(), read_titles())
except SchemaError as error:
# Loudly, and without writing. A partial reference is worse than a stale
# one: stale is caught by --check, partial reads as complete.
@@ -0,0 +1,131 @@
pragma Singleton
// The settings taxonomy: which categories the sidebar shows, and which leaf
// pages live inside each as tabs.
//
// The sidebar used to be a flat list of thirty-one pages, which made finding
// Printers a scan of the whole column. Categories group them the way macOS
// groups System Settings; the tab strip above a page switches between the
// leaves of its category. ShellState.settingsPage always holds a *leaf* id —
// the ids callers have always used — so every existing deep link, IPC call,
// and search result keeps working unchanged.
//
// This file is the only place the taxonomy is written down. The sidebar, the
// tab strip, ShellState's route validation, and two generators —
// scripts/panama-settings-commands and scripts/panama-settings-docs, which
// regex-parse the categories array below (keep its literal shape) — all
// derive from it.
import Quickshell
import QtQuick
Singleton {
id: root
// A category with no tabs is a leaf itself. A category with tabs is
// addressed by its first available tab; its own page id is accepted as an
// alias. Three category ids ("applications", "users", "privacy") double as
// the id of their first tab, which resolves to the same place either way.
readonly property var categories: [
{ page: "home", label: "Home", icon: "\u{F02DC}", tabs: [] },
{ page: "appearance", label: "Appearance", icon: "\u{F0E0D}", tabs: [] },
{ page: "desktop", label: "Desktop & Dock", icon: "\u{F04A4}", tabs: [] },
{ page: "displays", label: "Displays", icon: "\u{F0379}", tabs: [] },
{ page: "sound", label: "Sound", icon: "\u{F057E}", tabs: [] },
{ page: "notifications", label: "Notifications & Focus", icon: "\u{F009A}", tabs: [] },
{ page: "input", label: "Input", icon: "\u{F030C}", tabs: [
{ page: "shortcuts", label: "Keyboard" },
{ page: "mouse", label: "Mouse & Touchpad" },
{ page: "dictation", label: "Dictation" }
] },
{ page: "network", label: "Network & Sharing", icon: "\u{F08D4}", tabs: [
{ page: "connectivity", label: "Connections" },
{ page: "firewall", label: "Firewall" },
{ page: "sharing", label: "Sharing" },
{ page: "printers", label: "Printers" }
] },
{ page: "home-phone", label: "Home & Phone", icon: "\u{F02DC}", tabs: [] },
{ page: "applications", label: "Applications", icon: "\u{F003B}", tabs: [
{ page: "applications", label: "Applications" },
{ page: "gaming", label: "Gaming" },
{ page: "screen-intelligence", label: "Screen Intelligence" }
] },
{ page: "users", label: "Users & Accounts", icon: "\u{F0004}", tabs: [
{ page: "users", label: "Users" },
{ page: "accounts", label: "Online Accounts" }
] },
{ page: "privacy", label: "Privacy & Security", icon: "\u{F0483}", tabs: [
{ page: "privacy", label: "Privacy" },
{ page: "ssh-keys", label: "SSH Keys" }
] },
{ page: "power", label: "Power & Lock", icon: "\u{F0425}", tabs: [] },
{ page: "accessibility", label: "Accessibility", icon: "\u{F0208}", tabs: [] },
{ page: "system", label: "System", icon: "\u{F02FD}", tabs: [
{ page: "about", label: "About" },
{ page: "updates", label: "Software Update" },
{ page: "services", label: "System Health" },
{ page: "storage", label: "Storage" },
{ page: "snapshots", label: "Snapshots" },
{ page: "containers", label: "Containers" },
{ page: "datetime", label: "Date & Time" },
{ page: "region", label: "Region & Language" },
{ page: "manual", label: "Manual" }
] }
]
// Pages whose entire backing stack can be absent hide once a scan has
// proven it absent: a Containers tab with no podman and a Snapshots tab
// with no snapper configuration render permanently empty, which reads as
// broken rather than inapplicable. Until the scan lands they stay visible,
// so machines that have the stack never see a blink — both scans fire when
// Settings opens (HomePage.onCompleted).
function pageAvailable(page: string): bool {
switch (page) {
case "containers":
return !Containers.scanned || Containers.available;
case "snapshots":
return !Snapshots.scanned || Snapshots.configs.length > 0;
}
return true;
}
// The category owning a leaf. Tab membership is checked before category
// ids so the three doubled ids land on their category either way.
function categoryOf(leaf: string): var {
const byTab = root.categories.find(cat => cat.tabs.some(tab => tab.page === leaf));
if (byTab)
return byTab;
return root.categories.find(cat => cat.page === leaf) ?? root.categories[0];
}
// Any id a caller may hold — leaf, category, or garbage — to the leaf that
// should render: a leaf resolves to itself, a category to its first
// available tab, anything unknown to home.
function resolve(id: string): string {
const asLeaf = root.categories.some(cat => (cat.page === id && cat.tabs.length === 0)
|| cat.tabs.some(tab => tab.page === id));
if (asLeaf)
return id;
const category = root.categories.find(cat => cat.page === id);
if (category) {
const tab = category.tabs.find(t => root.pageAvailable(t.page));
if (tab)
return tab.page;
}
return "home";
}
// The available tabs of the category owning a leaf, for the strip above
// the page. One entry (or none) means no strip is worth rendering.
function tabsFor(leaf: string): var {
return root.categoryOf(leaf).tabs.filter(tab => root.pageAvailable(tab.page));
}
// "System Storage" for a tabbed leaf, "Displays" for a lone one. This is
// what search results show, so a hit says where it will land.
function breadcrumb(leaf: string): string {
const category = root.categoryOf(leaf);
const tab = category.tabs.find(t => t.page === leaf);
return tab ? category.label + " " + tab.label : category.label;
}
}
@@ -136,6 +136,8 @@ Singleton {
{ label: "Desktop version", detail: "Which Hyprland and Quickshell this session runs", page: "about" },
{ label: "Restore defaults", detail: "Return every Panama setting to its shipped value", page: "desktop" },
{ label: "Keyboard shortcuts", detail: "Every shortcut the compositor has bound", page: "shortcuts" },
{ label: "Dictation", detail: "Speech to text with Super+D, typed where the cursor is", page: "dictation" },
{ label: "Speech to text", detail: "Set up the local speech server and model", page: "dictation" },
{ label: "System Health", detail: "Check Panama services, integrations, tools, and recovery actions", page: "services" },
{ label: "Copy health report", detail: "Copy a redacted Panama doctor report", page: "services" },
{ label: "Lock screen background", detail: "Choose a blurred desktop, wallpaper, or solid color", page: "appearance" },
@@ -118,8 +118,10 @@ Singleton {
}
function showSettings(page: string): void {
const allowed = ["home", "appearance", "displays", "connectivity", "home-phone", "desktop", "sound", "gaming", "notifications", "screen-intelligence", "shortcuts", "mouse", "privacy", "region", "accounts", "accessibility", "power", "datetime", "applications", "updates", "storage", "snapshots", "users", "sharing", "firewall", "printers", "containers", "ssh-keys", "services", "manual", "about"];
root.settingsPage = allowed.indexOf(page) >= 0 ? page : "home";
// SettingsRoutes owns the route table: a leaf id passes through, a
// category id lands on its first available tab, and anything unknown
// redirects to home rather than rendering a blank loader.
root.settingsPage = SettingsRoutes.resolve(page);
DesktopPreferences.set("lastPage", root.settingsPage);
root.settingsOpen = true;
}
@@ -1,10 +1,10 @@
#!/usr/bin/env bash
# Generated by scripts/panama-settings-commands -- do not edit by hand.
# @vicinae.schemaVersion 1
# @vicinae.title Settings: Network & Devices
# @vicinae.title Settings: Connections
# @vicinae.mode silent
# @vicinae.icon ../../icons/hicolor/scalable/apps/panama-settings.svg
# @vicinae.description Open Network & Devices in Settings.
# @vicinae.description Open Connections in Settings.
# @vicinae.keywords ["settings", "wi-fi", "bluetooth", "printers"]
exec "$HOME/.config/quickshell/scripts/panama-action" settings-page connectivity
+10
View File
@@ -0,0 +1,10 @@
#!/usr/bin/env bash
# Generated by scripts/panama-settings-commands -- do not edit by hand.
# @vicinae.schemaVersion 1
# @vicinae.title Settings: Dictation
# @vicinae.mode silent
# @vicinae.icon ../../icons/hicolor/scalable/apps/panama-settings.svg
# @vicinae.description Open Dictation in Settings.
# @vicinae.keywords ["settings", "dictation", "speech to text"]
exec "$HOME/.config/quickshell/scripts/panama-action" settings-page dictation
@@ -1,10 +1,10 @@
#!/usr/bin/env bash
# Generated by scripts/panama-settings-commands -- do not edit by hand.
# @vicinae.schemaVersion 1
# @vicinae.title Settings: Privacy & Security
# @vicinae.title Settings: Privacy
# @vicinae.mode silent
# @vicinae.icon ../../icons/hicolor/scalable/apps/panama-settings.svg
# @vicinae.description Open Privacy & Security in Settings.
# @vicinae.description Open Privacy in Settings.
# @vicinae.keywords ["settings", "saved passwords", "camera and microphone", "screen sharing", "file history and trash", "device security"]
exec "$HOME/.config/quickshell/scripts/panama-action" settings-page privacy
+6 -6
View File
@@ -42,7 +42,7 @@ Found on **Power & Lock**.
## capture
Found on **Screen Intelligence**.
Found on **Applications Screen Intelligence**.
| Setting | Default | What it does |
|---|---|---|
@@ -127,7 +127,7 @@ Found on **Desktop & Dock**.
## gaming
Found on **Gaming**.
Found on **Applications Gaming**.
| Setting | Default | What it does |
|---|---|---|
@@ -157,7 +157,7 @@ Found on **Power & Lock**.
## input
Found on **Keyboard**.
Found on **Input Keyboard**.
| Setting | Default | What it does |
|---|---|---|
@@ -244,7 +244,7 @@ Found on **Notifications & Focus**.
## pointer
Found on **Mouse & Touchpad**.
Found on **Input Mouse & Touchpad**.
| Setting | Default | What it does |
|---|---|---|
@@ -259,7 +259,7 @@ Found on **Mouse & Touchpad**.
## search
Found on **Applications**.
Found on **Applications Applications**.
| Setting | Default | What it does |
|---|---|---|
@@ -286,7 +286,7 @@ Found on **Appearance**.
## touchpad
Found on **Mouse & Touchpad**.
Found on **Input Mouse & Touchpad**.
| Setting | Default | What it does |
|---|---|---|
@@ -0,0 +1,69 @@
# Settings navigation redesign — 31 flat pages become 15 categories
Approved 2026-08-23. The sidebar's 31 entries collapse into 15 categories. Multi-subject
categories render an Appearance-style tab strip above the page; single-subject categories
render no strip. Every old page id keeps working forever.
## The taxonomy
| Category (sidebar) | Tabs (leaf page ids) |
|---|---|
| Home | — (`home`) |
| Appearance | — (`appearance`, keeps its internal tabs) |
| Desktop & Dock | — (`desktop`) |
| Displays | — (`displays`) |
| Sound | — (`sound`, Dictation card moves out) |
| Notifications & Focus | — (`notifications`) |
| Input | Keyboard (`shortcuts`), Mouse & Touchpad (`mouse`), Dictation (`dictation`, new) |
| Network & Sharing | Connections (`connectivity`), Firewall (`firewall`), Sharing (`sharing`), Printers (`printers`) |
| Home & Phone | — (`home-phone`) |
| Applications | Applications (`applications`), Gaming (`gaming`), Screen Intelligence (`screen-intelligence`) |
| Users & Accounts | Users (`users`), Online Accounts (`accounts`) |
| Privacy & Security | Privacy (`privacy`), SSH Keys (`ssh-keys`) |
| Power & Lock | — (`power`) |
| Accessibility | — (`accessibility`) |
| System | About (`about`), Software Update (`updates`), System Health (`services`), Storage (`storage`), Snapshots (`snapshots`), Containers (`containers`), Date & Time (`datetime`), Region & Language (`region`), Manual (`manual`) |
## Architecture
`ShellState.settingsPage` keeps holding the **leaf** id, exactly as today. The page loader's
switch, the ownership contracts, HomePage's direct jumps, and every external caller are
untouched by the grouping itself. What changes:
- **`services/SettingsRoutes.qml` (new singleton)** — the one source of truth for the
taxonomy: `categories` (page, label, icon, tabs), `resolve(id)` (category id → first
available leaf; leaf → itself; unknown → home), `categoryOf(leaf)`, `tabsFor(leaf)`
(availability-filtered), `breadcrumb(leaf)`, `pageAvailable(leaf)` (the podman/snapper
gating, moved from the sidebar).
- **`SettingsSidebar.qml`** — destinations come from `SettingsRoutes.categories`; a row
highlights when it is the category owning the current leaf; clicking requests the
category id and `ShellState` resolves it.
- **`ShellState.showSettings`** — the literal allow-list becomes `SettingsRoutes.resolve()`.
- **`SettingsShell.qml`** — renders a `SettingsTabs` strip between the titlebar and the
page loader whenever the current leaf's category has more than one available tab.
Tab click is just `openSettings(leafId)`. Adds the `dictation` case.
- **`SettingsTabs.qml`** — the strip becomes horizontally scrollable when it overflows
(System has nine tabs; the window tiles down to 900px).
- **`SoundPage.qml` / `DictationPage.qml` (new)** — the Dictation card moves to its own
page under Input, with a labeled handoff noting the microphone is chosen in Sound.
- **`SettingsSearch.qml`** — dictation `extraEntries` re-route to `dictation`; the search
result subtitle becomes the `SettingsRoutes` breadcrumb ("System Storage"). Because
results carry leaf ids, search now lands on the exact tab with no further work.
## Blast radius owned by this change
- `scripts/panama-settings-commands` regex-parses the sidebar's destinations array; it
moves to parsing `SettingsRoutes.qml` and regenerates the Vicinae commands (one per
leaf; `home` still skipped; `dictation` gained). `scripts/panama-settings-docs` checked
the same way.
- Test contracts updated in the same change, not after: `settings-nav-contract`,
`settings-jump-contract`, `search-routing-contract`, `gnome-handoff-contract`,
`manual-contract`, `sound-page-contract`, plus any contract found grepping page ids.
- `modules/settings/README.md` gains the navigation model; manual chapter
`05-making-it-yours.md` updated where it describes the sidebar.
## Non-goals
- No per-tab deep links into Appearance's internal tabs from search (unchanged today).
- No persistence of last-selected tab per category (Appearance already resets; same rule).
- No renaming of leaf ids, files, or schema groups.
+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