Skip to content

Settings Page Architecture

/settings is a React surface inside the SPA, not a server-rendered page. Every tab is a child route of one layout, so revisiting a tab is a state change rather than a request.

Layout

SettingsRoute (campuscore_app/web/src/routes/SettingsRoute.tsx) replaces the app shell rather than nesting inside it. Rendered inside the shell, the settings nav became a third column beside the chat sidebar; "‹ Back to app" returns to chat.

The route owns where content sits, and owns it alone:

<div className="mx-auto w-full max-w-settings-region px-4 py-8">
  <section className={`w-full ${COLUMN_CLASS[activeColumn]}`}>
    <Outlet />
  </section>
</div>

Two elements, because position and width are two decisions and only one of them belongs to the tab. The region is centred beside the rail and capped at --container-settings-region (72rem, which is 1440 minus the 288px rail), so the cap never bites at or below a laptop. The surface sits flush left inside it, so every tab shares one left edge whatever width its own content needs.

That split is the fix for a real defect. While each surface centred its own declared width, the content's left edge landed at 475px on a form tab, 347px on a list tab and 288px on a table tab - 187px of drift across pages meant to read as one. The page never held still as you moved between tabs. Note where the padding is: on the region, not on the surface. No column narrowed, which is what the Definition of Done asks, and two got slightly wider: the inset used to come out of the surface's own max-w-* box, so at 1440px the form column's content is 768px rather than 736px and the list column's is 1024px rather than 992px. The table column is bounded by the region rather than by its own cap, so it measures whatever sits beside the rail less the region's 32px inset: 1109px at 1440px on this stack, where the stable scrollbar gutter takes 11px of the 1152px.

A page must not declare a width, centring, or page padding of its own. Five tabs once did, nesting a second identical <section> and paying the padding twice, which left them 32px lower and 32px narrower than their neighbours. SettingsShell supplies the header and vertical rhythm; the column is the route's.

Width follows content, and a tab names the column it needs rather than setting a boolean. A boolean could not describe the knowledge tab, which holds a list and a table.

Column Width For
form (the default) 768px Label-and-input pages. Stretched wider, hint text runs long and a three-field card looks half-empty.
list 1024px A list of rows: Connectors, Workspace Connectors, Knowledge Folders, SSO, Roles, Feature Management.
table 1152px A dense table needing columns: User Management, Agent Skills, Analytics, Audit Log.

One logical surface gets one width. The knowledge folder list and detail are both list, in the settings tab and on the /knowledge route, so moving between them does not resize the page.

Below the md breakpoint there is no sidebar, so settings becomes a drill-down. /settings renders SettingsIndex, a grouped menu of the tabs that person may open, and tapping one opens that tab across the full width with a back link to the menu. That makes /settings viewport-dependent: SettingsIndex asks useIsDesktop() and, above the breakpoint, redirects to /settings/account. The hook asks for 48rem rather than 768px, so it and Tailwind's md move together when a reader changes their browser's default font size. One consequence reaches the setup wizard, which redirects to /settings when a workspace is configured: on a phone an administrator finishing setup lands on the menu rather than on Account.

The row grammar

A settings row is a name and an optional hint on the left, the control that changes it on the right, and a hairline between it and the next. Rows group under a section title rather than inside a card: a settings page has no items to draw an edge around, and boxing each group made one surface read as a stack of unrelated panels.

A list of items - a connector, a provider - is the same hairline row, not a card each. The earlier boundary put the list pages in cards on the grounds that a card draws the edge of one item, and it was wrong in practice: Connectors, Workspace Connectors and Single Sign-On all ended up reading as a different product from the tabs beside them, and the two both called Connectors sit one nav group apart. A list row is not a SettingRow call site, because an item is not a labelled control, but it wears the same hairline and the same 10px vertical rhythm.

A list row that opens its item is an OpenableRow: a named open button beside the item's own controls, inside a row that forwards a pointer click that did not start on a control. The open button is a real <button> whose accessible name is what a screen reader announces at its Tab stop. The switch or menu beside it stays a control of its own, and a pill beside it becomes readable content, rather than going silent inside a row that plays the button role. Enter or Space on the open button opens the item, and the same keys on the switch flip the switch and nothing else. A row that opens its item in the page's place, such as an SSO provider's editor, passes returnKey, so the editor's heading takes focus on open and the row gets it back on close (see focus between an opener and the view it opens). A row that opens a dialog says opensDialog instead, and cannot also take a returnKey, because the dialog returns focus to the row itself. Readable content that can be too long to share a line with the open button, such as a scope pill, goes in the status slot. Fixed-width content, such as a pill with a fixed word, a switch, a menu or a chevron, goes among the row's children. The button and the status share one wrapping group, and the button keeps at least 160px, so a status that does not fit moves under the button, right-aligned in the column of the statuses that stayed beside it. The group takes the row's own column gap, and the switch stays outside the group, so every switch keeps its column.

The Features, Single Sign-On, Workspace Connectors, action governance and personal Connectors lists all draw their rows this way, and both Connectors lists open the service in a side drawer. Every such row wears ROW_HOVER_WASH, the one hover shade, including while the pointer rests on its switch. The wash and the hairline go on the OpenableRow itself, so the area that shades is exactly the area a click opens, and an error shown under the row sits outside it. Each caller states through the required list prop which kind of list the row sits in. In a boxed list, a bordered box the row fills edge to edge, the keyboard focus ring sits 2px inside the row, since the box would clip it outside. In an open list the row reaches 8px past the column on each side, so its hairline and wash stand clear of the logo, and its focus ring sits 2px clear of its content.

What is left for SettingsPanel is a block genuinely apart from the page's flow: a whole surface's error or empty message, a sub-form that stands on its own.

Everything is in components/settings/SettingRow.tsx:

Piece For
SettingRow One setting and the single control that changes it.
SettingControlProps The aria-labelledby and aria-describedby a row hands its control, which the control spreads onto the element that takes focus.
SettingActionRow A row whose right side is a cluster of buttons, a file picker, or a Toggle.
SettingSection A titled group of rows, carrying the stack rule below.
SETTING_STACK The vertical rhythm for a stack of rows.
SettingRowGrid A two-column grid of settings whose controls share a line across each pair, taking its rows as data so the pairing cannot be half-applied.
FIELD, FIELD_LOCKED The control face: the shared height, the slate border, the brand focus.

SettingRow takes one control, never children. The row is a <label> that wraps its control, so the association is the markup and cannot rot - no caller writes an id or keeps one in sync. A <label> labels the first labelable element inside it, so a row holding a hidden <input type="file"> beside two buttons would silently make the file input the labelled control, and clicking the row's name would open the OS file picker. Three rows on General & Branding are exactly that, and they are SettingActionRows. The type is what keeps the two apart: a row that could take several controls cannot be constructed.

That association is not a nicety. Fourteen call sites once rendered <label> as a sibling of the control with no htmlFor, so clicking a name focused nothing and assistive technology reported every one of those fields as unlabelled.

The row names and describes its field itself. A wrapping <label> names its field with all of its text, so a hinted row's field would be named by its name and its hint run together, and a screen reader user would hear the hint as part of the name. So control is a function that takes SettingControlProps: aria-labelledby points at the row's name span and aria-describedby at its hint span. The field's name is then the row's label alone, and a reader announces the hint as the field's description. The ids are the row's own, from useId(), and a control spreads them onto the element that takes focus, a Select onto its trigger button. The hint stays inside the <label>, so clicking it still focuses or toggles the control, and a field in a row carries no aria-label of its own. The compiler cannot make a call site spread the props, so a form's test calls expectEverySettingRowNamed from web/src/test/settingRows.ts, which fails when a row's field is not named by its name or not described by its hint.

A control declares its own width, and FIELD declares text-sm on the control's 24px line. Width is information: a 96px box says an abbreviation goes here and a 384px one says a sentence does. That width is the side-by-side width, so a control that declares one adds FILLS_STACKED_ROW beside it and fills the row once the row has stacked. The row's width is the information at that point, and a 288px box left under a full-width name reads as a box that stopped short. The explicit text size is load-bearing, because a field with no size class inherits its parent's font through Tailwind preflight's input { font: inherit } and would change size with whatever row, dialog or filter strip holds it. The size is the one every other control and every label on the page carries - a button, a dropdown, a search box, a table cell, the name of the row - so a field reads as the same kind of thing as the controls it sits among rather than as a heavier one. The line is leading-control, a fixed 24px, so the height never depends on which step the text is, and the token stays even though text-sm's own line is already 24px: 24px of line, 8px of py-1 padding and 2px of border come to the 34px control height, and a field is never taller than the button beside it (see the design system's control scale).

A row stacks by its own container width, not the viewport's. @container/row plus @lg/row: means the same row is right on a full-width page, inside a two-column grid, and inside a 448px modal, with no caller having to say which it is in.

A save action is right-aligned at the foot of the form, and reads the same on every tab. A polite live region for "Saved.", then the failure as a role="alert" when there is one, then the button - so a save that fails is announced as the interruption it is rather than sliding into a status line, and moving between tabs never moves the button.

A feature switch is a row, not a card. FeatureSwitchRow.tsx is the switch a tab shows for the deployment-scoped feature it is a surface of. A page whose first item is boxed and whose rest is hairline rows reads as two designs meeting in the middle, and a feature flag is a setting like any other. It is a Toggle rather than a checkbox because it writes on the flip; a boolean inside a form that applies on Save is a checkbox, since a switch would claim an effect that has not happened yet.

A group of rows takes SETTING_STACK, never space-y-*. The rule has two halves and both matter: 16px between the things a group holds, and nothing between two adjacent rows. A paragraph, a definition list or a button sitting flush against the row above it reads as one run-on block; a gap between two rows leaves each hairline floating in the middle of the space instead of dividing it. Adjacency is read off the DOM through a data-setting-row attribute, so it holds whether the rows were written out, mapped over a list, or returned by a component of the caller's own. SettingSection applies it to its children, and a form or dialog that stacks rows outside a section applies it itself. One thing it cannot do: a child that is inline, such as a bare link, takes no top margin, so wrap one in a block element to put it in the rhythm.

The tabs

SETTINGS_TABS in campuscore_app/web/src/routes/settingsTabs.tsx is the single declaration of what exists. Each entry carries a slug, label, group, icon, its element, and its gates.

Group Tab Gate
User Account none
User Connectors none
Workspace General & Branding workspace admin
Workspace Sample Questions workspace admin
Workspace User Management workspace admin
Workspace Knowledge Folders workspace admin + cc_knowledge_folders
Workspace Workspace Connectors workspace admin
Workspace Single Sign-On workspace admin
Workspace Roles & Permissions workspace admin
Workspace Feature Management workspace admin
Workspace Agent Skills workspace admin
Workspace Custom Domain workspace admin
Workspace Website Widget workspace admin
Workspace Analytics workspace admin
Workspace Audit Log workspace admin

General & Branding edits the institution's name, assistant identity, logo, login background image (minimum 1920x1080, see Branding & Theming), and the two brand colours - which style only the sign-in page and the sidebar's initials badge, never app controls.

Analytics and Audit Log are deliberately not behind a feature flag: usage visibility and audit visibility are accountability surfaces and must not be silently switched off. Analytics is the only lazily imported tab, because it carries the Recharts chunk that must not ride in the bundle every chat user downloads. The skill editor under settings/skills/<id> is lazily imported for the same reason: it carries the CodeMirror chunk. Agent Skills and Website Widget are not behind their feature flags either, because each tab is where its flag is flipped; the tab explains itself when CampusCore has not made the feature available.

visibleSettingsTabs({ canManageWorkspace, features }) decides what a given user is offered. It gates on the server's can_manage_workspace answer and the server's feature list - never on a role slug spelled in the client, which is how nine tabs once became invisible to the administrator who owned them.

Where the pieces live

Settings-specific pieces live in components/settings/:

  • SettingRow.tsx - the row grammar. SettingRow, SettingActionRow, SettingSection, and the FIELD control face. See "The row grammar" above.
  • SettingsPanel.tsx - SettingsHeader (title, subtitle, optional action), SettingsPanel (the card), SettingsShell (header plus content).
  • FeatureSwitchRow.tsx - the deployment feature switch a tab shows for the feature it is a surface of, written as a settings row.
  • OpenableRow.tsx - OpenableRow and ROW_HOVER_WASH, the row a list uses when the row opens its item. See "The row grammar" above.
  • primitives.tsx - ServiceAvatar, Tag, MetaLabel, SCOPE_TONE, and ScopePill, the one scope pill of Features and action governance: "Everyone", "No one", the one role or "N roles", and "· N people", with the whole scope as a sentence in its title.

App-wide pieces live in components/ui/ and are used by the knowledge routes too:

  • Button.tsx - Button, ButtonLink, buttonClass. Four variants: primary, secondary, danger, and quiet for the secondary half of a pair. No other file may declare a brand-filled button of its own (bg-brand still tints avatars and progress fills, which are not buttons). size means visual weight only: both sizes are steps of the control scale, the same on every device, and on a touch screen each carries a tap area of at least 44px around its face (see the tap area).
  • StatusPill.tsx - tones named for meaning (success, warning, danger, neutral, info, brand, muted), not for colour.
  • TextLink.tsx - TextLink and TEXT_LINK. Navigation away, and the quiet half of a pair beside a real button. Its target is a three-arm union - an app route, an external href, or a handler - so a link that is both cannot be constructed.
  • ColorField.tsx - a colour picked from a swatch or typed as hex, used by General and Website Widget. Each input sits in a label of its own, so each has its own name and its own tap area; showLabel writes the name beside the swatch where one row holds more than one colour.
  • Toggle.tsx - the on/off switch, brand blue. Brand is CampusCore's own colour on every deployment; school branding reaches only identity surfaces, through --color-school.
  • DataTable.tsx, FilterBar.tsx, Drawer.tsx - the control bar, dense row surface and detail panel the data-heavy tabs share. A column that is context rather than identity takes hideBelow, a table width from a closed set, and the table's wrapper becomes the container that width is measured against - so a table squeezed beside an open file preview drops those columns at the width they cramp at, whatever the window is doing. Where a column's width is drawn follows from that: a <col> is matched to cells by position and a hidden cell leaves the table, so widths stay in the <colgroup> only up to the first hiding column and travel on the cells themselves after it. shrinksToWidth is the truncating column - it needs max-w-0 against a width on its <col> - so it must be declared before every hiding column, and DataTable throws rather than draw a collapsed one. A table whose rows open cannot put a hiding column first, because the first cell holds the button a keyboard opens the row with, and it cannot render a control inside that cell, because a control cannot sit inside a control; DataTable throws for both. Drawer takes leading and actions beside its title, for the subject's own mark and for a control that acts on the whole panel, and its header stays put while only the body scrolls, so a panel whose subject scrolls away cannot leave those controls unexplained. It has two modes and the caller must choose between them with the required modal prop. A modal drawer is a <dialog> in the top layer, so the page behind it is inert, the keyboard stays inside the panel, and Escape arrives as the dialog's own cancel. A non-modal one leaves the list readable and reachable behind the scrim, and its Escape listener stands down while a <dialog> is open over it: the top layer owns the key, and without the guard one press would close the dialog and the drawer under it together. The feature, role and action scope editors are modal, because the controls behind each of them - a feature row's switch beside its open button, the rows that open another role, an action's switch beside its open button and the Bulk actions menu - act on what the open form is editing. A footer stuck to the bottom edge of the body takes the exported DRAWER_FOOTER face, and the body keeps a control focused by keyboard clear of such a footer, because the browser's own scroll-into-view does not allow for a sticky element.
  • RoleChecklist.tsx - the role half of every role grant: the feature drawer, the action drawer, the connector Audience tab and the skill deployment drawer. It locks a role only when the grant does not store it yet and the save would refuse it, for being excluded from the action or held by nobody, so unchecking a saved role stays reversible until save. A stored slug the catalogue lacks is a deactivated role, listed last, muted, counted and removable; the caller mounts it only once the catalogue has loaded. A long role name wraps between its words onto more lines instead of being cut off, because a hover hint never shows on a touch screen, and the slug beside it stays whole up to 60% of the row and breaks only past that.
  • rowButton.ts - ROW_CONTROL, the one list of what counts as a control inside a row that opens, and refuseControlInsideRowButton, the check that throws when a row's open button holds one. DataTable and OpenableRow both use it, so a table row and a list row agree on what a control is.
  • Tabs.tsx - TabList and TabPanel, the tab pattern's roles with one stop in the tab order and arrow keys that move the selection. Every panel stays mounted and the inactive ones are hidden, which is what keeps a typed search or an unsaved edit alive across a trip to another tab. It is not SegmentedControl: that control is a radiogroup answering "which value" for a setting being changed, and a tab strip shows a section of what is already there. tabClass(selected) is the face one tab wears, so a strip of links to sections that live in the URL looks like a TabList (Tab strip).
  • SearchInput.tsx - the one search box, in two sizes with one magnifier ink, its input named by its own label; a hand-built type="search" input in the settings, knowledge or shared UI tree is a lint error (SPA design system).
  • StatCards.tsx, EmptyState.tsx, Modal.tsx, ConfirmDialog.tsx, Spinner.tsx, and the dropdown family Popover.tsx/Select.tsx/Menu.tsx (SPA design system).

DataTable has no sorting on purpose. Every list behind it is keyset-paginated, so ordering the loaded pages alone would present a partial view as though it were the whole one. Ordering is the server's.

The API behind it

Each tab reads a typed endpoint module under campuscore_app/apps/main_app/apis/, wrapped by the @api_endpoint decorator and reached through a hook in campuscore_app/web/src/lib/api/.

Tab Endpoint module
Account account_api.py
Connectors connectors_api.py
General & Branding workspace_branding_api.py
Sample Questions workspace_faqs_api.py
User Management workspace_users_api.py
Knowledge Folders workspace_folders_api.py
Workspace Connectors workspace_connectors_api.py
Single Sign-On workspace_sso_api.py
Roles & Permissions workspace_roles_api.py (the catalog read plus rename, review, and mapping-CRUD routes)
Feature Management workspace_features_api.py
Agent Skills workspace_skills_api.py
Website Widget workspace_widget_api.py
Custom Domain workspace_domains_api.py
Analytics analytics_api.py
Audit Log audit_log_api.py

Authorization is the decorator's, not the component's. Every workspace endpoint declares auth="workspace_admin", so a non-admin who somehow reached one of these components would see empty lists and 403s rather than data.

See spa-and-api-framework.md for the decorator, the generated TypeScript contract, and the rule that a Pydantic model change becomes a TypeScript compile error.

Knowledge folders

The knowledge folder surfaces are shared components rendered in two places, because they exist both as a personal route (/knowledge) and as a workspace admin tab.

  • components/knowledge/FolderListPanel.tsx backs both folder lists.
  • components/knowledge/FolderDetail.tsx backs both folder detail views.

The two mount points differ by a FolderSource: which endpoints to read, and how to word a deletion warning. They previously existed as two independent implementations and had already drifted on loading state and delete copy.

A workspace folder has an address of its own, /settings/knowledge-admin/<id>, and each row of the Settings folder list links to it. That view reads the folder by id through GET /api/workspace-folders/{id}/summary, so a reload or a pasted link opens the same folder, headed with its name. The endpoint answers a missing id with a 404 and a personal folder's id with a 422, and the view shows both as "This folder does not exist, or you no longer have access to it." FolderDetail takes a required folder, so neither view ever draws a folder without its name.

The personal page at /knowledge/<id> finds its folder in the person's own folder list, and waits for that list to finish loading before it decides anything. An id that is not one of their folders sends a workspace administrator who has the cc_knowledge_folders feature to /settings/knowledge-admin/<id>, because a workspace folder's home is its Settings view. Everyone else sees the not-found state.