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