Skip to content

Settings: hand-rolled sidebar chrome in place of the TabView - #199

Open
T0mSIlver wants to merge 3 commits into
mainfrom
settings-sidebar-chrome
Open

Settings: hand-rolled sidebar chrome in place of the TabView#199
T0mSIlver wants to merge 3 commits into
mainfrom
settings-sidebar-chrome

Conversation

@T0mSIlver

@T0mSIlver T0mSIlver commented Jul 27, 2026

Copy link
Copy Markdown
Owner

What

Replaces the five-tab TabView Settings window with a hand-rolled two-column
layout: a fixed 208pt sidebar of plain buttons, and a detail column that leads
with the pane's title and a one-line subtitle. Chrome only — every pane
renders exactly the content it did before, from the same call sites.

Stacked PR 1 of 2. #201 (settings-row-idiom) targets this branch and rewrites
the row/group primitives inside the panes — review this one first, and do not
delete this branch until #201 is retargeted (GitHub auto-closes children when a
base branch is deleted).

Why hand-rolled instead of NavigationSplitView / .sidebarAdaptable: both bring
a sidebar-collapse toolbar button that can only be removed with private-API
hacks, and their rows surface to accessibility as table cells. Plain AXButton
rows keep the window under our control and give the AX drills a stable element
with a stable identifier to press.

Details:

  • The Settings scene is kept, so openSettings(), Cmd-comma and
    restorationBehavior(.disabled) all keep working.
  • Width fixed at 780, height free (min 480 / ideal 560 / max ∞). Deliberately
    not .windowResizability(.contentSize): that resizes the window under the
    pointer on every tab switch. Each pane keeps its own ScrollView.
  • Transparent, full-size titlebar via a small WindowAccessor
    (SettingsWindowChrome), applied on viewDidMoveToWindow and on
    didBecomeKey — SwiftUI re-asserts its own titlebar config when the scene
    rebuilds the window.
  • Sidebar: NSVisualEffectView .sidebar material, .behindWindow, 28pt top
    inset clearing the traffic lights, 34pt rows with an r8 selection fill, 22×22
    tinted icon tiles, About pinned to the bottom over a v{version} ({build})
    footer.
  • New: Sources/localvoxtral/Settings/{SettingsSidebarView,SettingsPaneHeader,SettingsWindowChrome}.swift.
    SettingsTab gains title/subtitle/systemImage/tint and the two AX
    identifiers. Raw values are unchanged — the scripts key off them.

Script updates (mandatory, not optional polish)

Both AX drills clicked button "<tab>" of toolbar 1 of window 1, which no longer
exists. The probe moved to scripts/lib/ax-probe.swift (shared by ui-smoke.sh
and capture-readme-assets.sh) and gained:

  • --press <identifier> — find by AXIdentifier, AXUIElementPerformAction
    kAXPressAction; --title <text> is accepted as an AXTitle fallback, and the
    probe prints which route it used, so the log answers the "does
    .accessibilityIdentifier surface on a .plain Button?" question empirically.
  • --scope <identifier> — assert needles ONLY inside that subtree. This closes a
    real hole: sidebar row labels are AXStaticText and could vacuously satisfy a
    pane assert. Every assert_tab now has to match inside
    settings.pane.<tab>.
  • --window <title-substring> — both fallbacks match by shape (an AXButton
    with this title; the first AXScrollArea), so they must not wander into another
    window: the enrollment sheet alone contains two ScrollViews. Both drills pass
    Settings. A hint that matches no window searches all of them and says so, so
    a window that stops carrying its title degrades loudly rather than matching
    nothing.
  • AXIdentifier added to the element tuple and to the --dump-on-fail output.
  • ui-smoke.sh dumps the tree once before the per-tab assertions and asserts the
    pane scope is reachable at all (a settings.pane.* identifier or a scroll
    area). Losing AX identity used to be six 10s timeouts each blaming its own
    needle; it is now one named failure, and the pass line records which route the
    run used.
  • capture-readme-assets.sh: its three tab tables are indexed together, so a
    length mismatch would have written one tab's screenshot into another tab's
    file. Length guard, loud exit.

No assert was weakened; the pane asserts are strictly stronger than before.

Proof

  • Unit suite green — ./scripts/remote-build.sh
Test Suite 'localvoxtralPackageTests.xctest' passed at 2026-07-28 00:05:49.941.
	 Executed 2021 tests, with 2 tests skipped and 0 failures (0 unexpected) in 75.714 (75.862) seconds

Re-run after the review hardening commit (da5d653), focused then full:

Test Suite 'SettingsTabTests' passed at 2026-07-28 01:13:48.197.
	 Executed 6 tests, with 0 failures (0 unexpected) in 0.002 (0.002) seconds

Test Suite 'Selected tests' passed at 2026-07-28 01:15:06.946.
	 Executed 2021 tests, with 2 tests skipped and 0 failures (0 unexpected) in 72.147 (72.239) seconds

CI build-test on this branch: pass (4m7s).

Build is warning-free for the new/changed files (the only warnings in
.build/last-remote.log are the pre-existing ClaudePluginInstallService.swift:226
try? one and two in ClaudeHookPublisherTests).

New test — Tests/localvoxtralTests/SettingsTabTests.swift (6 cases, part of the
2021 above): the primary + meta sidebar arrays cover SettingsTab.allCases
exactly once with no overlap and no duplicates; every case has a non-empty
title/subtitle/symbol/identifier; subtitles are single-line sentences; and both
identifier schemes are pinned to the literal settings.tab.<rawValue> /
settings.pane.<rawValue> strings the scripts hardcode, as are the raw values
themselves.

  • AX drill: dispatched twice on this branch
    (runs 30309569624
    and 30310237279).
    Both stopped before any AX assertion ran, at the drill's pre-launch guard:
PASS: TCC preflight has Accessibility and Screen Recording grants.
PASS: Defaults domain snapshot captured and smoke run forced to external mode with onboarding completed.
FAIL: Existing app instance did not quit before smoke launch; cannot launch a fresh instance.

That is the runner's GUI session having a localvoxtral instance that would not
quit (it is the owner's personal Mac, ~00:20 local). The drill deliberately
refuses to kill it rather than force-quitting someone's running app, and I did
not weaken that guard to get a green. The AX selectors are therefore verified
by code-reading only — please re-dispatch ui-smoke.yml on this branch once the
Mac is free.
capture-assets.yml (which would have produced screenshots and
the same evidence) was deliberately NOT dispatched: it pkills the running app
and switches the Mac to dark mode, which is not something to do to an owner who
is evidently at the keyboard

  • LLM lanes: not run, and not required — this PR changes window chrome and
    two GUI drill scripts. Nothing in the diff touches prompts, model pins,
    sampling, the polish request shape, or anything else that reaches the model
    (scripts/ci/llm-lane-filter.sh matches nothing here).

UI change — what was verified, and what was not

Verified here: compiles clean under Swift 6.2 strict concurrency; the full unit
suite; the sidebar/pane data model by test; and the drill selectors by
code-reading plus a
deliberate belt-and-braces in the probe: --press falls back to the row's AXTitle
and --scope falls back to the window's first AXScrollArea, each printing which
route it took. So the drill answers "does .accessibilityIdentifier surface on a
.plain Button / on a pane container?" on its first real run without being
able to break on the answer. The fallback keeps the anti-vacuous property: the
sidebar rows and pane header are outside the pane's scroll area.

Not verified — needs the owner's eyes on a real window (./scripts/try-pr.sh <this PR>):

  1. Whether the .behindWindow sidebar material vibrates or renders flat in this
    window. If it is flat, the documented one-line fallback is .withinWindow in
    SettingsSidebarBackground.
  2. Whether the transparent/full-size titlebar sticks across close → reopen of the
    Settings window (the didBecomeKey re-apply is there for exactly that). If it
    ever fights the scene irrecoverably, the documented fallback is to keep the
    standard titlebar.
  3. That 28pt of top inset actually clears the traffic lights on this display
    scale, and that the 780×560 default is comfortable for the longest pane
    (Text Processing).

The tabbed Settings window had run out of room: five tabs of dense rows in
a 580x420 frame, with no place to say what a pane is for. This replaces the
TabView with a two-column layout — a fixed 208pt sidebar of plain buttons
and a detail column that leads with the pane's title and a one-line
subtitle. Chrome only: every pane renders exactly the content it did before.

Hand-rolled rather than NavigationSplitView/.sidebarAdaptable on purpose.
Those bring a sidebar-collapse toolbar button that can only be removed with
private-API hacks, and their rows surface to accessibility as table cells.
Plain AXButton rows keep the window under our control and give the AX
drills something stable to press.

The Settings scene is kept (openSettings(), Cmd-comma and
restorationBehavior(.disabled) all ride on it). Width is fixed at 780 and
height stays free: .windowResizability(.contentSize) would resize the
window under the pointer on every tab switch.

Both AX drills had to move with it: `button "<name>" of toolbar 1 of
window 1` no longer exists. The probe is now shared
(scripts/lib/ax-probe.swift) and gained --press <identifier> and --scope
<identifier>. Scoping closes a real hole rather than being cosmetic: pane
content asserts now have to match inside settings.pane.<tab>, so a sidebar
row's AXStaticText label can no longer satisfy them.
…ute matched

The open question is whether SwiftUI surfaces .accessibilityIdentifier on a
container in a plain-styled hierarchy. If it does not, every scoped pane
assertion in the drill would fail for a reason that has nothing to do with the
pane. The fallback is the window's first AXScrollArea, which keeps the property
the scope exists for: the sidebar rows and the pane header sit outside it, so a
navigation label still cannot satisfy a pane assertion. The success line names
the route it took, so one drill run answers the question.
… pane scope

Review follow-ups on the drill's two shape-matched fallbacks. Both of them —
an AXButton with this title for --press, the first AXScrollArea for --scope —
searched every window the app had open. The enrollment sheet alone contains two
scroll views, so a presented sheet could have answered a pane assertion, and any
window with a "General" button could have answered a tab press. `--window
<title-substring>` restricts the search; both drills pass "Settings". A hint that
matches nothing searches everything and says so, so a window that stops carrying
its title degrades loudly instead of silently matching nothing.

ui-smoke now also dumps the tree once before the per-tab assertions and asserts
that the pane scope is reachable at all — either a settings.pane.* AXIdentifier
or a scroll area. Losing AX identity was previously six 10s timeouts, each
blaming its own needle; it is now one named failure, and the pass line records
which of the two routes the run used.

capture-readme-assets.sh: the three tab tables are indexed together, so a
mismatch would have written one tab's screenshot into another tab's file. Length
guard, loud exit.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant