Skip to content

refactor(settings)!: reorganise settings into fourteen routed categories - #154

Merged
Valtora merged 5 commits into
mainfrom
refactor/settings-ia-redesign
Jul 28, 2026
Merged

refactor(settings)!: reorganise settings into fourteen routed categories#154
Valtora merged 5 commits into
mainfrom
refactor/settings-ia-redesign

Conversation

@Valtora

@Valtora Valtora commented Jul 28, 2026

Copy link
Copy Markdown
Owner

Pull Request

Description

Settings was six categories holding around thirty sections, unevenly distributed: AI held eleven sections and Administration held seven heavyweight tools. The AI category also rendered a different page depending on who opened it, because five of its sections were admin-only, so the same label meant different things to different users.

Alongside that, the visual inconsistency had three separate causes rather than one:

  1. Double width constraint. SettingsPage wrapped content in mx-auto max-w-4xl, then every SettingsSection re-constrained itself with a width prop resolving to max-w-2xl, max-w-3xl, max-w-4xl or max-w-none. Across the tabs that was 8 compact, 7 regular, 8 wide and 6 full, all left-aligned in the same column, so card edges never lined up.
  2. Non-monotonic elevation. The page was dark:bg-gray-900, a section dark:bg-gray-950/90 (darker than the page), a nested field panel gray-950/80 (the same as its parent), and a nested subtle panel gray-900/70 — lighter than the card containing it. Depth carried no consistent meaning.
  3. Settings was opted out of the app's own design tokens. globals.css defines --surface-radius, --surface-padding and --workspace-max-*, plus a compact-density variant. No settings component referenced any of them, so seven distinct border radii were in use and Settings was the only area that did not respond to compact density.

What changed

Information architecture. Fourteen categories in four groups (General, Meetings, Data, About), organised by domain rather than by role. Admin-only categories live in the group their subject belongs to and are simply not shown to users who cannot see any of their content: admins see fourteen, everyone else nine.

Two naming collisions are resolved. Voice activity detection and speaker diarization move from Personal to Recording, beside the gain controls that explain the same symptoms, so "capture" no longer names two different things. The category for user accounts is Users and access, never People, which already means the contacts library.

AI splits into Your AI (routing and subscription connect, everyone) and AI providers (provider, models, credentials, admins). AiRoutingSection renders for every user and contains the Claude/ChatGPT connect flow, so a single admin-only AI category would have removed a feature non-admins need — and would have produced an eight-section page for admins, reproducing the original problem elsewhere.

Real routes per category, replacing ?tab=. Legacy values redirect: audio/companion/capture to /settings/recording, general/account/personal to /settings/profile, admin/administration to /settings/users, ai to /settings/your-ai. Six in-app links that used the old query form now point at the new routes directly.

State hoisted to settings/layout.tsx. This is required rather than tidy: useDebouncedAutosave cancels its pending timer on unmount without flushing, so a hook owned by a category page would silently discard an edit made within a second of navigating to another category. The layout survives navigation, so the debounce does too, and the session issues one GET /settings rather than one per category visited.

Settings registry (settingsRegistry.ts) as the manifest for every user-facing setting: category, access, keywords, Advanced criterion and shipped default. It backs cross-category search, the Advanced gate and the changed-count badge, replacing the per-tab keyword blobs.

Advanced gate. One collapsible block per category, orthogonal to admin — admin answers who may change a setting, advanced answers how likely anyone is to need it. Conflating them would make the gate useless on a single-user install where the owner is the admin. A setting is gated if it has a safe default and is rarely changed, can silently degrade output if set wrong, needs external credentials, or only matters on a multi-user install — with one override: never gate a page's primary purpose. A floor rule prevents a page hiding all of its content. It auto-expands when a search result lives inside it, and shows a count when anything inside differs from its default. Seven of fourteen categories carry one.

Search resolves to individual settings rather than categories, and routes straight to them. Keystroke-driven tab switching is dropped; under real routes it would push a history entry per character.

Surface model. Two container levels, never three: one card per section, divider-separated rows inside, and a full-width block slot for composite UI (live meter, users table, log console). Surface tokens are declared in globals.css beside the existing radius and padding tokens, so Settings now honours compact density. SettingsSection, SettingsField and SettingsPanel are deleted; settingsControls.ts is the shared control vocabulary. Radii in the settings tree drop from seven values to four.

Mobile navigates by drill-in below lg: /settings renders the grouped category list and each category opens as its own page. The previous six-chip horizontal strip does not scale to fourteen entries across four groups.

Two fixes from review of the rendered pages

Card padding. SettingsCard draws no padding of its own because rows and blocks own theirs, so any other content placed directly in a card body sat flush against the card edge. Direct children that are not a row or a block now inherit a fallback gutter, so a caller cannot produce edge-to-edge content by forgetting to wrap it. Leftover mx-auto max-w-* wrappers inside cards are removed — the same width-inside-width that made the old sections ragged, one level down.

System and logs froze while scrolling. Four compounding causes in the log viewer:

  • LogLine was declared inside SystemTab, making it a new component type on every render, so React discarded and rebuilt every visible line each time a log arrived. Hoisted to module scope and memoised.
  • Each websocket message called setLogs with a fresh copy of the whole array. Arrivals are now buffered in a ref and flushed every 200ms, with the buffer capped at 2000 lines.
  • filteredLogs was state written from an effect, re-rendering the whole tab a second time per change. It is derived with useMemo now.
  • Auto-scroll wrote scrollTop on every batch regardless of scroll position, so reading back through history was repeatedly yanked to the bottom. It now follows output only while the viewer is already at the bottom. The flush timer is cleared with the socket, so it no longer survives navigating away.

Notes

No new dependencies. No backend changes. Forced password change is preserved and now enforced from the layout, so no route or stale bookmark can slip past it.

Accepted risk, stated plainly: the registry declares defaults in the frontend while the backend keeps its own inline (ctx.merged_config.get("enable_vad", True)). A backend default changing without a matching registry update makes the changed-count badge wrong. Cosmetic, but real. Serving defaults from the API is the follow-up if it becomes a problem.

Type of change

  • Bug fix (non-breaking change that fixes an issue)
  • New feature (non-breaking change that adds functionality)
  • Breaking change (fix or feature that changes existing behaviour)
  • Documentation update

The breaking-change box is ticked for honesty about the user-visible surface: every settings path is renamed and the URL scheme changed. No API, database or configuration contract changed, and every previous entry point redirects.

Checks run

  • Backend tests: source .venv/bin/activate && pytest — 1122 passed
  • Python quality: Ruff lint, Ruff format check and mypy all clean (run individually rather than via scripts/check.py, which re-runs the backend suite already run above)
  • Frontend lint: cd frontend && npm run lint — clean
  • Frontend unit tests: cd frontend && npm run test — 52 files, 320 tests passed
  • Frontend build: cd frontend && npm run build — compiled successfully
  • Docs validation: python3 scripts/validate_docs.py — 30 files validated
  • Alembic validation: python3 scripts/validate_alembic.py — head e1a7c93b8d24

Also git diff --check — no whitespace errors.

Migration impact

  • No database migration in this PR.
  • Adds an Alembic migration.

Documentation impact

  • No documentation change required.
  • Updated the relevant guide(s) in the same PR.

Every user-visible settings path in the documentation pointed somewhere that no longer exists. Rewritten across ADMIN.md, USAGE.md, CALENDAR.md, TELEMETRY.md, DEPLOYMENT.md, MCP.md, CAPTURE.md, BACKUP_RESTORE.md, GETTING_STARTED.md, SECURITY.md, DEVELOPMENT.md, the CLI OAuth ADR and the README.

Several needed more than a rename: Language and Glossary moved to Transcription rather than following the rest of the old AI tab, notes structure moved to Notes and live assistance, and the calendar provider credentials joined the personal calendar connections under Integrations, which removes the hand-off the old copy had to describe.

ADMIN.md gains a Settings Structure section documenting the fourteen categories, who sees each, the routing and redirect behaviour, and how the Advanced gate works.

Security impact

  • No security-sensitive change.
  • Touches auth, tokens, encryption, capture ownership, or exposure.

The forced-password-change lockout is preserved with its behaviour unchanged; only the redirect target moved from /settings?tab=account&forcePasswordChange=1 to /settings/profile, in AuthGuard.tsx, lib/api/client.ts, the login page, the OAuth authorize page and the setup wizard. Enforcement moved from the settings page component into the settings layout, so it now covers every category route rather than a single tab, and cannot be bypassed by a direct URL or a stale bookmark. docs/SECURITY.md needed a path reference update only; no boundary changed.

Manual verification

Performed:

  • All fourteen category routes plus /settings/capture return 200 against a dev server.
  • All nine legacy ?tab= values render and resolve.
  • Registry parity tests added and passing (see below).

Still pending, and the reason this is a draft: the capture settings UI changed shape. frontend/src/lib/capture/ is untouched and the write path through useCapture().updateSettings is unchanged, but microphone selection, the gain sliders and the echo cancellation / noise suppression / browser auto gain toggles all moved — the last three from raw checkboxes to Switch components behind the Advanced gate. A wiring mistake there would degrade capture silently rather than visibly, so this needs a browser smoke pass before merge:

  • share picker behaviour
  • selected microphone behaviour, including the fail-closed path when a chosen device disappears
  • waveform and live state
  • pause/resume, stop/finalize, discard
  • unsupported-browser messaging

Also worth eyeballing before merge: the new gutters on Your AI and Privacy, and scrolling /settings/system against a busy container to confirm the log viewer now stays responsive.

New tests

settingsRegistry.test.ts (29 cases) guards the one failure mode with no visible symptom: a section imported by no page still builds, still lints and still passes every other test while rendering nowhere. It asserts that every field declared on the Settings interface is registered exactly once or explicitly listed as unsurfaced, that no category is left with nothing visible after gating, that every gated entry cites a criterion, that every category has a route, and that every legacy ?tab= value resolves.

Settings carries an index signature, so keyof Settings widens to string and no type-level assertion can enforce coverage — the test parses the interface out of types/index.ts instead, with a sanity assertion on the parsed count so a regex that matched nothing could not make the coverage checks pass vacuously.

settingsMetadata.test.ts is removed with the module it covered. settingsState.test.ts drops the three search-scoring cases, whose behaviour no longer exists; that intent is covered by the registry search tests.

Valtora added 5 commits July 28, 2026 09:22
Settings was six categories holding around thirty sections, two of which
carried most of the weight: AI held eleven sections and Administration
held seven heavyweight tools. The AI category also rendered a different
page depending on who opened it, since five of its sections were admin
only, so the same label meant different things to different users.

Replace the single tab-switching page with fourteen categories grouped
under General, Meetings, Data and About, each on its own route. Organise
by domain rather than by role: an admin-only category lives in the group
its subject belongs to and is simply not shown to users who cannot see
any of its content.

Introduce a settings registry as the manifest for every user-facing
setting, carrying its category, access, keywords, Advanced criterion and
shipped default. It backs cross-category search, the Advanced gate and
the changed-count badge, replacing the per-tab keyword blobs.

Hoist the settings object, the debounced autosave and the save status
into the route layout. This is required rather than tidy:
useDebouncedAutosave cancels its pending timer on unmount without
flushing, so a hook owned by a category page would silently discard an
edit made within a second of navigating elsewhere. It also keeps the
session to one GET /settings rather than one per category visited.

Add the surface primitives the categories will be converted onto: a
card, a divider-separated row, a full-width block slot for composite UI,
and the Advanced gate. Declare the settings surface tokens in globals.css
alongside the existing radius and padding tokens, so Settings finally
honours compact density instead of ignoring it.

Resolve two naming collisions. Voice activity detection and speaker
diarization move from Personal to Recording, beside the gain controls
that explain the same symptoms, so "capture" no longer names two
different things. The category for user accounts is Users and access,
never People, which already means the contacts library.

Preserve every pre-redesign entry point: legacy ?tab= values redirect to
their new routes, /settings/capture redirects to /settings/recording, and
a forced password change locks the whole area to Profile from the layout
so no route or stale bookmark can slip past it.

Content still renders through the previous section components; the
category pages mount them unchanged. Converting them onto the new
primitives follows in the next commits.
Rebuild Profile, Appearance, Integrations and Users on the card-and-row
model, replacing the three-level surface stack with two levels.

The Password card is the clearest case: it was one card holding three
field cards, each with its own border, background and padding ring,
carrying no information the adjacency and shared heading did not already
give. It is now one card with three divider-separated rows, and the form
still wraps all three so password managers keep treating them as one
credential change.

Split CaptureSettings in two. Device selection and levels stay visible;
the browser processing toggles and the quiet-audio reminders move to a
new CaptureProcessingSettings behind the Advanced gate, since all three
ship enabled, suit almost everyone and are touched only when something
sounds wrong. The two identical gain sliders become one component, and
the live input meter uses the block slot rather than a nested panel.

Add settingsControls.ts as the shared control vocabulary. Inputs,
selects, textareas and buttons were styled inline in every component,
which is how seven different border radii accumulated; they now come from
one definition at the design system's 8px control radius.

Give GeneralSettings a sections prop. Its four sections no longer belong
on one page: appearance, date and time and spellcheck sit under
Appearance, while the processing defaults sit under Recording with the
capture controls they explain.

Correct the Advanced primitive to group sibling cards rather than wrap
them. Wrapping produced exactly the card-inside-card nesting this work
removes, because a category's advanced content is itself made of cards.

Calendar connections, connected apps and calendar provider credentials
adopt the card and the block slot. Users and invitations are re-homed and
given the outer card only; their tables and forms keep their current
interaction design.

Retire keywords.ts, whose per-tab keyword blobs are superseded by the
registry's per-setting keywords.
Move every remaining section onto the card-and-row model and delete
SettingsSection, SettingsField and SettingsPanel.

SettingsSection carried a width prop resolving to one of four maximum
widths, applied inside a column that already constrained the content.
Across the tabs that was 8 compact, 7 regular, 8 wide and 6 full, all
left-aligned in the same column, which is why card edges never lined up.
Width is now decided once per page by the category layout, so cards
cannot disagree, and data-heavy pages declare full-bleed at page level.

SettingsField and SettingsPanel each drew a border and a background, so
every control sat in a card inside a card. Fields become rows separated
by hairlines; panels become blocks or, where they group repeated items
in the admin tables, a recessed inset that steps the same direction the
card steps from the page.

Give the admin tools their own card. Users, invitations, CLI usage,
system logs and backup previously relied on chrome supplied by the
Administration page that wrapped them, and would otherwise have rendered
with no surface at all now that each has its own route. Their tables,
consoles and wizards are otherwise untouched.

Fix the last two surfaces stepping the wrong way in dark mode: the AI
routing selection tiles and the CLI provider panel were darker than the
card containing them.

Border radii in the settings tree drop from seven values to four, all
from the shared control and surface definitions.
The redesign renames the user-visible settings paths, so every reference
in the documentation pointed somewhere that no longer exists. Rewrite
them across ADMIN, USAGE, CALENDAR, TELEMETRY, DEPLOYMENT, MCP, CAPTURE,
BACKUP_RESTORE, GETTING_STARTED, SECURITY, DEVELOPMENT, the CLI OAuth
ADR, and the README.

Several needed more than a rename. Language and Glossary moved to
Transcription rather than following the rest of the old AI tab, notes
structure moved to Notes and live assistance, and the calendar provider
credentials joined the personal calendar connections under Integrations,
which removes the hand-off the old copy had to describe.

Document the structure itself in ADMIN.md: the fourteen categories, who
sees each one, that categories are real routes with the old query links
redirecting, and how the Advanced gate behaves.

Repoint the two CLI OAuth plan documents at AiRoutingSection, since the
AISettings orchestrator they linked to was split across the new AI
routes. Delete the settings redesign plan now the work has landed.
Two problems reported from the redesigned pages.

Padding. SettingsCard draws no padding of its own because rows and
blocks own theirs, so any other content placed directly in a card body
sat flush against the card edge. Add a fallback gutter for direct
children that are not a row or a block, marked with a settings-cell
class, so a caller cannot produce edge-to-edge content by forgetting to
wrap it. Restructure the telemetry toggle, which was a bordered label
spanning the full card width, into a proper row with a switch.

Strip the leftover mx-auto max-w-* wrappers inside cards. These centred
content inside an already-constrained card, which is the same width
inside width that made the old sections ragged, one level down, and it
left uneven gutters either side.

System and logs froze while scrolling. Four compounding causes, all in
the log viewer:

LogLine was declared inside SystemTab, so it was a new component type on
every render and React discarded and rebuilt every visible line each
time a log arrived. Hoist it to module scope and memoise it.

Each websocket message called setLogs with a fresh copy of the whole
array, so a busy container cost one render and one full copy per line.
Buffer arrivals in a ref and flush every 200ms, and cap the buffer at
2000 lines so neither memory nor the DOM grows without bound.

filteredLogs was state written from an effect, which re-rendered the
whole tab a second time for every change. It is derived with useMemo
now.

Auto-scroll wrote scrollTop on every batch regardless of where the user
was, so scrolling back through history was repeatedly yanked to the
bottom. It now follows new output only while the viewer is already at
the bottom. The flush timer is also cleared with the socket, so it no
longer survives navigating away.
@Valtora
Valtora marked this pull request as ready for review July 28, 2026 11:22
@Valtora
Valtora merged commit 1e2dea8 into main Jul 28, 2026
19 of 20 checks passed
@Valtora
Valtora deleted the refactor/settings-ia-redesign branch July 28, 2026 11:22
@Valtora Valtora mentioned this pull request Jul 28, 2026
15 tasks
Valtora added a commit that referenced this pull request Jul 28, 2026
Bump docs/VERSION to 2.0.0 ahead of tagging. The release workflow fails
fast if the pushed tag does not match this value exactly, so the bump has
to land on main before v2.0.0 is created.

A major bump is correct for this range. The voiceprint fix removes
GET /speakers/voiceprints/method-status and POST
/speakers/voiceprints/rebuild outright under a BREAKING CHANGE footer,
replacing the operator trigger with scheduled maintenance, and the
settings work moves every category onto its own route. The upgrade is
also not pull and recreate alone: operators must remove the backup_temp
mounts and the second recordings bind from their Compose file, because a
stale mount reinstates the shared, permanent /tmp that this range fixes.

There are no new Alembic revisions in v1.7.0..HEAD, so the upgrade
carries no schema migration.

The full release validation set passes locally at this commit: 1142
backend tests, frontend lint, 320 frontend unit tests, the production
build, and the docs, Alembic and held-pin validators. All four release
images (api, worker, worker-io, frontend) were built from 22219f2 and
scanned with the release gate flags, meaning CRITICAL and HIGH, fixed
findings only, against .trivyignore. All four pass, so no new
.trivyignore entries are required.

Refs: #151, #154, docs/DEPLOYMENT.md
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