Skip to content

Settings window redesign — sidebar + pages

Status: IMPLEMENTED on branch feat/settings-sidebar-pages (2026-10-24), unmerged and unreleased. Path: architectural. The owner delegated the open questions (§7) and the implementation; where the build departed from this document, §9 says so.

1. Problem

SettingsWindow.__init__ (src/yazses/settingsui/app.py) stacks everything in one QVBoxLayout: tooltip hint, snap banner, Hands-free button, hotkey row, Speech group (8 rows), Audio group, maintenance box, filter box, and only then the QScrollArea holding the capability toggles (well over a hundred rows, grouped by category). The scroll area receives whatever height is left. At the default 680x760 size that is roughly 70 px, so the Features list — the largest part of the product — is effectively invisible, and the window reads as an unstructured form.

2. Goals / success criteria

  • Any setting reachable in two clicks or one search, without scrolling past unrelated controls.
  • The Features list gets the full height of the window, with category navigation.
  • Pending changes are always visible and Apply is always reachable.
  • Follows the desktop theme (light and dark); keeps the WCAG AA muted-text guarantee of settingsui/theme.py.

3. Non-goals (hard constraints)

  • No behavior change. Staging, PendingChanges, Apply, restart offer, dependency install, language-profile worker, experimental confirmation, Restore defaults semantics: untouched.
  • No config-key, CLI, or IPC change (these are L3 interfaces per AGENTS.md).
  • No new dependency; PySide6 stays the optional desktop extra, imported lazily.
  • No new network access, telemetry, or anything under ADR-019.
  • The ? details dialog, tooltips, accessible names/descriptions are preserved.

4. Design

4.1 Shell

Window default 900x640, minimum 760x520. Left sidebar (QListWidget, icon-less text entries so it works on every platform without bundled assets), right QStackedWidget.

Sidebar entry Content (existing builder reused)
General hold-to-talk key (_build_hotkey_row), one-paragraph note on stage-then-Apply
Speech _build_speech_rows
Audio _build_audio_rows (level meter + silence slider get room)
Features new two-pane page, §4.2
Hands-free card with a description and an "Open hands-free controls…" button → _open_handsfree_panel
Updates attach_maintenance(self)

Pinned outside the stack: - Top: the snap-permission banner (_add_snap_banner), unchanged. - Bottom footer bar: Restore defaults (left), live status text (centre, replaces the _hint line but _hint keeps its role for _apply()/_busy() callers), Apply (right). - Sidebar entries carry a "•" marker when their page holds staged changes.

The long "Hover any capability…" sentence moves from the top of the window to the Features page header, where it is relevant.

4.2 Features page

  • Top bar: the existing filter box (same syntax: text, on:/off:, tier:), full width, with the describe_filter line beside it. Syntax help moves from a tooltip to a small popover with clickable examples.
  • Left pane (~190 px): category list. "All" first, then each category as Name · enabled/total. While a filter is active, categories show their match count and empty ones are greyed.
  • Right pane: scrolling rows for the selected category (or all, with headings, for "All"). The category blurb becomes the pane heading.
  • Rows: _build_row behavior unchanged (checkbox, ?, tooltip card, accessible description). Visual changes only: flat rows with a divider instead of nested group boxes, a small tier chip from row.tier_label, and an accent bar on rows that are staged but not applied.
  • Keyboard: Ctrl+F focuses the filter; Up/Down move through categories; sidebar entries have mnemonics.

4.3 Code shape

  • New dependency-free module settingsui/pages.py (pure): per-category enabled/total counts, per-page "has staged changes", tier-chip text, category-visibility for a query. Unit-tested directly (house pattern: pure logic beside Qt shell, cf. search.py, controller.py).
  • New Qt shell module settingsui/shell.py: sidebar, stack, footer. app.py keeps all apply / restart / install / language logic and only changes the layout assembly in __init__, so the 1,700-line file does not grow.
  • Existing attribute names that other code and tests read (_checkboxes, _info_buttons, _group_widgets, _filter_box, _filter_status, _hint, _apply_button, _reset_button, _hotkey_box, _handsfree_button, …) are kept. Filtering continues to change visibility only; a hidden row keeps its staged state.

5. Testing

  • Pure: tests/test_settingsui_pages.py for §4.3 helpers.
  • Offscreen Qt smoke (same style as tests/test_settingsui_window.py): window builds; every slug has exactly one checkbox; selecting a category / filtering hides rows without un-staging; sidebar marker follows staged state; Apply/Restore still enabled during the states they were before.
  • The existing test_settingsui_*, test_settings_* and test_tray_settings_* suites must pass unmodified except where a test asserts layout structure; any such edit is called out in the PR body.
  • tests/test_gui_job_covers_the_settingsui_family.py must be checked: new modules need to be inside the family the GUI CI job covers.
  • Full gates before any claim of done: uv run python -m pytest tests/ -v, uv run ruff check src tests scripts paper/benchmark, uv run mypy src (after uv sync --extra desktop, so Qt call sites are type-checked for real).

6. Docs surfaces

CHANGELOG.md [Unreleased]; user-facing settings docs under docs/ if they describe the window layout or include screenshots; design/architecture.md only if the module list is enumerated there. No generated file is touched.

7. Open questions for the owner

  1. Sidebar order and naming — is "General" the right home for the hotkey, or should Speech own it?
  2. Should the window remember its size and last-opened page between launches? (Would need a state file; default proposal: no, keep it stateless.)
  3. Screenshots in docs: re-take after implementation, yes/no?

8. Risks

  • Existing tests reaching into private widgets may assume the old nesting; mitigated by keeping attribute names and by reading those tests during planning before touching __init__.
  • Offscreen Qt rendering differs from a real desktop; the final check must include a real screenshot of the window on this machine (light and dark).

9. As built — departures from the draft above

  • Open questions decided (owner said to decide): the hotkey stays on General; the window is stateless (no remembered size or page); docs screenshots re-taken because docs/settings-gui.md already carried one.
  • Tier chips dropped. Each row's subtitle already begins with the tier ("recommended — ..."), so a chip said the same thing twice. pages.tier_chip was removed.
  • Disabled categories are hidden, not greyed. Greyed text was unreadable on a light theme.
  • Sidebar mnemonics became Ctrl+1..Ctrl+6. A QListWidget has no mnemonics; shortcuts on the window work from any focus.
  • Filter syntax is a visible line under the search box rather than a popover.
  • Page boxes are unframed (their title is the page heading); the capability categories keep their titled QGroupBox, which the filter and the ampersand guard rely on.
  • Category list items are measured from their wrapped text, because Qt clipped one to "…".
  • New modules: settingsui/pages.py (pure), settingsui/shell.py (Qt frame). Tests: test_settingsui_pages.py, test_settingsui_window_pages.py. The existing window tests were not edited.
  • Not verified: the look on a real desktop session. Screenshots were rendered offscreen with the Fusion style, in light and dark palettes.