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
@@ -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.