Compass Settings page
Builds on: brand parity (compass-brand-parity/design.md, open PR #1915:
square corners, weights, surfaces, --cx-border-field, text-entry
primitives), UX foundation
Refs: RIG-4775 (this record); RIG-3121 (Providers, task E5 of the
gateway OAuth enrollment record);
ledger DL-431 to DL-436
Depends on: brand-parity T9 (S1); enrollment E3 (S5)
Problem / Intent
Section titled “Problem / Intent”Matt’s ask: “Settings styling and layout are bad, and we likely need many more settings. Write a full settings design (sections, which settings exist, layout), then implement.”
Today Settings is one page that edits the tracker config. Defects (from
SettingsView.tsx, the .settings-* rules in app.css, and
settings.png):
- Bespoke boxes.
.settings-input/.settings-selectand.settings-btn*copy.cx-inputand.cx-btn. - Raw sizes and bold.
14px/12pxheads at weight600,11pxlabels, a10pxgap; none is a token. - Rounded cards.
.settings-sectionhas--cx-radius-md. - Fixed widths and wrapping.
100pxand140pxstatus columns clip long names; theflex-wrapfields row breaks into ragged lines. - One page, no sections. Editor and model registry share one scroll and one route.
- The one edit does little. The store always uses the fixture seam,
which ignores its
_config;setTrackerConfigrebuilds it, but only the handle reaches the fixture queue. The kind and the status map change nothing, and the server maps tracker status itself (DL-129). Nothing shows the server, account, motion, or providers.
Brand-parity T2a and T9 fix defects 2 and 3 (they sweep every radius and
font-weight: 600 in app.css). This record fixes the rest: one view
with a section nav, a route per section, one row anatomy on the shared
primitives, and a save model per section. It deletes the tracker editor
(defect 6).
Approach
Section titled “Approach”A1 — Sections and settings
Section titled “A1 — Sections and settings”Four sections, in nav order.
- General (
general): Server URL (connection.baseUrl, the transport’s URL in both hosts); Mode (shellMode(): none → Browser,"embedded"→ Embedded server,"client"/"setup"/"reopen"→ Remote server, since setup reaches the app only by connecting and reopen never mounts it); Server version (version,api_version, andrevfromGetServerInfo; “Not connected” whenstore.daemon().liveis false); Signed in as (store.caller()handle and display name); a Keyboard shortcuts button that opens the overlay. New. - Appearance (
appearance): Reduce motion, Follow system or Always, per device in localStoragecompass.settings.reduceMotion. Always sets:root[data-reduce="on"], which the CSS honors and nothing sets. New. - Models (
models): the model registry, read-only. Exists. - Providers (
providers): enrollment E5 (A5). Needs E3 (ProviderEnrollmentService), not yet in the proto, Go, or the UI.
Not in this record: a Tracker section (DL-432: S4 deletes the editor; a
server-backed one returns with the tracker contract); a usage-data
control (DL-435: the deployer decides at build time); server URL edits
(deferred, native server URL record OQ-5); browser sign-out (no
signed-out boot path); a day theme (D2, “later”); key remap (deferred; a
Keyboard section comes with it); notifications (none exist); Secrets
(SecretsService overlaps the Providers key entry; its own design after
E5); Replay tour (a General row once the tour UI merges);
GetAgentConfigInfo (fleet member names, not a preference; a later
Fleet section); pins and window layout (set in place).
A2 — Layout
Section titled “A2 — Layout”.settings-view is a grid of two columns: the section nav and the body.
- Nav.
<nav aria-label="Settings sections" class="cx-tabs settings-nav" data-orientation="v">ofa.cx-tablinks; the current link hasdata-selected(the.cx-tabsselection rule) andaria-current="page". Links, because each section is a route. - Narrow pane. Under
@container view-panel (width < 560px)(as inapp.css),settings.csslays.settings-navout as a row above the body and restates thedata-orientation="h"border and bottom accent, since CSS cannot change the attribute; rows stack. - Body. One section: a head (
--cx-text-sm,--cx-text-bright, uppercase, letter-spaced, no weight, per brand-parity A9) and one line of help in--cx-text-dim; capped at72ch. - Row. A grid,
minmax(16ch, 1fr) minmax(0, 2fr): the label (help under it in--cx-text-dim,--cx-text-xs, viaaria-describedby), then the control; a1px solid var(--cx-border)line between rows; no card, radius, or fill. - Controls, all primitives:
input.cx-input,select.cx-select, plain text when read-only,.cx-btnactions (primarycommits,dangerdestroys). Two or three choices are.cx-btnbuttons witharia-pressed(the pressed one alsodata-selected) in arole="group"named by the row label. Row status (pending, done, error) sits under the row in arole="status", errors in--cx-error.
A3 — Route
Section titled “A3 — Route”/settings/:section. parseRoute maps bare /settings to the first
section and an unknown id to the Bridge. routePath always prints
/settings/<section>, so bare /settings is not canonical.
The catch-all RedirectHome (sends its view to /) becomes
RedirectCanonical (sends it to routePath(view.route())), so
/settings, old links, and restored layouts land on a section. A
behavior change: a path with extra segments now lands on the view it
parses to, not / (/backlog/foo → /backlog, /agent/x/extra →
/agent/x, /channel/x/topic → /channel/x). Unknown heads still land
on /.
Every opener (showSettings behind Mod+,, G S, the palette, and the
destinations list; and the sidebar link) targets store.settingsPath(),
the last section shown in this window or the first. Openers follow DL-390
(DL-436): a plain open navigates the focused view; Mod+click or
middle-click on the link opens a deduped tab. A detached window (DL-160)
opens on its section. The palette gets “Go to Settings: Label” per
section; the title is “Settings · Label”.
A4 — Save model, per section
Section titled “A4 — Save model, per section”- Immediate for a device preference (Reduce motion): applied and stored on click.
- Per action for Providers: each Connect, code, key, and Disconnect is one RPC with its own pending and error state on its row.
- None for read-only rows. No page-level Save.
A5 — Providers (E5)
Section titled “A5 — Providers (E5)”S5 implements E5 as written (policy filter, connected state, Connect and
the paste-code dialog with instructions, expiry countdown and re-begin,
API-key entry, confirmed Disconnect, E5’s store accessors, no token
rendered or stored), with two changes: “beside the tracker-config editor”
becomes one A2 row per provider in Providers, and the “draft/commit
pattern” becomes A4’s per-action model (each action is one RPC).
Alternatives considered
Section titled “Alternatives considered”- One long page with anchored headings. The hash router owns
#, so a heading cannot be linked; every section shares one scroll. - A modal dialog. DL-160 makes Settings a window-scoped view.
- Horizontal tabs only. Four sections fit today, but more are named (Keyboard, Secrets, Fleet), and a strip under the window’s tab strip (DL-390) reads as nested tabs. It is the narrow fallback.
- A switch, or a roving radiogroup. A new primitive for two rows, or
arrow-key code built for dispatcher ids;
aria-pressedneeds neither. - One Save bar. Device preferences and RPCs cannot wait for it.
Global Constraints
Section titled “Global Constraints”- After brand-parity T9. T2a and T9 rewrite the
.settings-*radii and weights and recapture every shot, so S1 starts after T9 merges. T7 only changes the.cx-inputborder; tasks use.cx-inputeither way. - Overlap with brand parity. T5 (
/agents) editsRouteMatch,parseRoute,routePath,appRoutes,route-title.ts,spine.ts,store.ts, andLeftSidebar.tsx, as S1 does. T10 leavesview-route.tsandViewHost.tsxalone but edits the/backlogand/doneentries inroutes.tsx,LeftSidebar.tsxand its test (S1), andsurfaces.md§ Backlog / Done / Settings (S4). The edits do not overlap in meaning; whichever lands second rebases. - Tokens only, the D7 stylelint guard, and the T2a and T9 rules. Each
component imports the primitive CSS it renders (brand-parity A7); no
new primitive and no
.settings-*box style (layout insettings.css). - Every entry keeps working:
Mod+,,G S, paletteview.settings, the sidebar link, and/#/settings. - Device preferences use localStorage keys
compass.settings.<name>throughsafeLocalStorage(); an invalid value reads as the default. - Copy. Sentence-case labels; help is one sentence.
- Baselines and stacking. Recapture under the pinned dev shell and
commit per DL-399; one linear line, red-first tests, lane
implement-ts. - Public repo. Cite only this repo and
docs/specs/brand/.
S1 → S2 → S3 → S4; S5 stacks on the top when enrollment E3 merges.
S1 — Shell and section route
Section titled “S1 — Shell and section route”- Gate: brand-parity T9 merged.
- Do: A2, A3, with today’s two parts as the first sections.
- Interfaces:
apps/ui/src/view-route.ts:export const SETTINGS_SECTIONS = ["tracker", "models"] as const;,export type SettingsSection = (typeof SETTINGS_SECTIONS)[number];,export const SETTINGS_SECTION_LABEL: Record<SettingsSection, string>. The settings member ofRouteMatchbecomes{ view: "settings"; section: SettingsSection }.parseRoute: noparam→SETTINGS_SECTIONS[0]; a known id → that id; another →{ view: "bridge" }.routePath→`/settings/${section}`.ViewHost.tsx:ROUTE_PATTERN.settings = "/settings/:section".routes.tsx:{ path: "/settings/:section", component: SettingsView };RedirectHome→RedirectCanonical, which callsview.navigate(routePath(view.route())).- Store:
settingsSection(): SettingsSection,setSettingsSection(section: SettingsSection): void, andsettingsPath(): string. TheLeftSidebarlink passes() => store.settingsPath()toopenLink; its plain click callsshowSettings.showSettingsrunshideShortcuts(), thennavigateTo(settingsPath())(DL-436). components/SettingsView.tsxmoves tocomponents/settings/:SettingsView.tsx(the shell: reads the section from its view scope, callssetSettingsSection, renders nav and body, importstabs.css,button.css,input.css, and./settings.css);TrackerSection.tsxandModelsSection.tsx(today’s two blocks, unchanged);SettingsRow.tsx(export function SettingsRow(props: { label: string; help?: string; for?: string; children: JSX.Element }): JSX.Element);sections.tsx(export const SECTION_VIEW: Record<SettingsSection, () => JSX.Element>);settings.css(shell, nav, fallback, rows).spine.ts: aview.settings.<id>per section, “Go to Settings: Label”, shaped likeview.settings;route-title.ts: “Settings · Label”.app.css: take.settings-viewout of the shared list rule; delete.settings-headand.settings-section*.- Tests that expect bare
/settingsmove to/settings/<id>:view-route.test.ts,window-history.test.tsx,tab-keep-alive.test.tsx, andcomponents/LeftSidebar.test.tsx(“a view link opens its view in a new tab…” expects["/", "/settings/tracker"]).settings-mapping.test.tschanges its import;SettingsView.model-registry.test.tsx→settings/ModelsSection.test.tsx.
- Test (red first):
view-route.test.ts:/settings/modelsround-trips;/settingsparses to the first section;/settings/nopeparses to the Bridge.routing.test.tsx(RedirectCanonical):mountApp("/backlog/foo")ends on/backlog;mountApp("/no-such-surface")still ends on/.settings/SettingsView.test.tsxwithmountApp:/settingsends on/settings/tracker, its link hasdata-selectedandaria-current="page"; clicking Models moves to/settings/modelsand shows the registry, not the editor.store.test.ts(showSettings): after the view moves to/settings/modelsand then to/, a call leaves one tab, its view on/settings/models.
- Baselines: recapture
settings.png; addsettings-narrow.png(480px).
S2 — General section
Section titled “S2 — General section”- Interfaces:
SETTINGS_SECTIONSgains"general"first, withsettings/GeneralSection.tsx(A1’s rows).DaemonInfogainsrev: string(probeServerreads it;STUB_DAEMON.revis"").AppStoreOptionsgainsserverUrl?: string, set bymaininindex.tsxtoconnection.baseUrl; the store exposesserverUrl(): string | undefined.GeneralSection.tsxexportsmodeLabel(mode: ShellMode | undefined): "Browser" | "Embedded server" | "Remote server", an exhaustive switch. - Test (red first):
settings/GeneralSection.test.tsxwithcreateRouterTransportservinggetServerInfo(1.2.3, an API version,abc123): the three show; offline shows “Not connected”; the caller’s handle shows; the shortcuts button opens the overlay;modeLabelmaps all five inputs. Baselines: recapturesettings.png.
S3 — Appearance section
Section titled “S3 — Appearance section”- Interfaces:
-
New
apps/ui/src/preferences.ts:export type ReduceMotion = "system" | "on";export const REDUCE_MOTION_KEY = "compass.settings.reduceMotion";export function loadReduceMotion(storage: Storage | undefined): ReduceMotion;export function saveReduceMotion(storage: Storage | undefined, value: ReduceMotion): void;export function applyReduceMotion(root: HTMLElement, value: ReduceMotion): void; -
Store:
reduceMotion(): ReduceMotionandsetReduceMotion(value: ReduceMotion): void(saves and applies);mount.tsxapplies the stored value before the first render.SETTINGS_SECTIONSgains"appearance"after"general";settings/AppearanceSection.tsx.
-
- Test (red first):
preferences.test.ts: none or junk →"system";"on"→"on"; apply"on"setsdata-reduce,"system"removes it.AppearanceSection.test.tsx: Always setsaria-pressed, the root attribute, and the stored value.
S4 — Delete the tracker editor
Section titled “S4 — Delete the tracker editor”- Interfaces:
- Delete
settings/TrackerSection.tsx,mergeFromTracker,settings-mapping.test.ts,"tracker"fromSETTINGS_SECTIONSand theview.settingskeywords, and every remaining.settings-*rule inapp.cssexcept.settings-registry*and.settings-map-arrow, which Models uses. AppStoredropssetTrackerConfigand its twostore.test.tscases;trackerConfigstays atDEFAULT_TRACKER_CONFIGfor the fixture queue.surfaces.md§ Backlog / Done / Settings: replace the status-mapping sentences (composition, empty state, flip item 2) with the A2 rows.- File a follow-up: a server-backed Tracker section with the tracker contract.
- Delete
- Test (red first): no Tracker link;
/settings/trackerends on/. Baselines: recapturesettings.png.
S5 — Providers section (E5)
Section titled “S5 — Providers section (E5)”- Gate: enrollment E3 merged. Do: A5; E5’s Interfaces and test
cycle are the contract, plus:
SETTINGS_SECTIONSgains"providers"after"models"(settings/ProvidersSection.tsx);openExternalmoves fromMarkdownText.tsxtoapps/ui/src/open-external.ts(export function openExternal(url: string): void) forauth_urlin both hosts; a test that onlyListProviders’ providers render. Baselines: addsettings-providers.png.
- S1 — Shell and route (after brand-parity T9)
- S2 — General section (after S1)
- S3 — Appearance section (after S2)
- S4 — Delete the tracker editor (after S3)
- S5 — Providers, E5 (after S1 and enrollment E3; top of the line)
Resolved Questions
Section titled “Resolved Questions”- The tracker editor: delete it (Matt, 2026-10-08, DL-432). It edits a config only the fixture seam reads, and the server maps tracker status (DL-129). A server-backed Tracker section returns with the tracker contract.
- A usage-data control: “None for now, may add later.” (Matt, 2026-10-08, DL-435). No control in the app; the deployer decides at build time.
- How a Settings opener behaves: keep DL-390 with no Settings
exception (Matt, 2026-10-08, DL-436).
Mod+,replaces the focused view (Back returns); with Settings in a background tab it makes a second Settings view.