Skip to content

SPA design system

The shared vocabulary of the React SPA: how big text is, how big a control is, how a settings row is written, what a pill and a link look like, what a chosen one of several peers wears, and the three components every dropdown renders through. Everything here lives in campuscore_app/web/src/components/ui/, except the type scale, which is tokens in styles/index.css, and the row grammar, which is components/settings/SettingRow.tsx.

A vocabulary only holds if it cannot be re-invented beside itself, so most sections below end with the guard that enforces them. The guards are in campuscore_app/web/eslint.config.js. Most are scoped to src/components/ui/**, src/components/settings/** and src/components/knowledge/** - the trees this vocabulary holds in - while the type-scale guard and the two tap-area guards apply to the whole SPA, and so does the ban on a raw <dialog> outside tests. The chat shell, guest and auth carry heights and faces of their own and are deliberately out of scope, so widening a guard means migrating a tree first.

Type scale

A text size comes from a step of the type scale, never from a number at the call site. Each step is Tailwind's default size times 8/7, and <body> draws every font with its lowercase at Nunito's letter height, so a step's letters draw at almost the height Tailwind's default size would draw Inter at. The steps are Tailwind's own --text-* tokens redefined in @theme in styles/index.css, so every text-* class follows the scale without its call site changing.

Class Size Line
text-3xs 11.43px the parent's line ratio
text-2xs 12.57px the parent's line ratio
text-xs 13.71px 18.29px
text-xs-plus 14.86px 20.57px
text-sm 16px 24px
text-sm-plus 17.14px the parent's line ratio
text-base, and <body> 18.29px 27.43px
text-lg 20.57px 32px
text-xl 22.86px 32px
text-2xl 27.43px 36.57px
text-3xl to text-9xl Tailwind's size times 8/7 Tailwind's ratio
text-chat 17px 25.5px
--text-reading, through .artifact-reading 17px 25.5px

A line is Tailwind's ratio for its step, so it follows the size, except text-sm's, which is 1.5 so the line is a whole 24px. The root font size is untouched: rem stays 16px, so spacing, widths and every control height stay where they are.

Why the sizes step by 8/7

Inter's lowercase is 0.546 of its size (its sxHeight is 1118 of 2048 units), and Nunito's, the letter height the product is drawn to, is 0.484. font-size-adjust: 0.484 on <body> tells the browser to scale each font until its lowercase is 0.484 of the size, which draws Inter at 0.886 of the size. Stepping every size by 8/7 gives the letters their height back: text-sm is 16px, and its letters draw within 1.3% of the height Inter draws at 14px without the adjustment. The property is inherited, so the one declaration reaches every element, portals and the website widget's frame included.

The adjustment changes the ch unit, which is measured from the font as drawn, and leaves em, which follows the computed size. The answer's 72ch measure is therefore about 684px at 17px.

font-size-adjust is used rather than a size-adjust descriptor on Inter's @font-face, because it fits every family to the same letter height, not Inter alone. With size-adjust, code in the monospace stack would draw 8/7 larger at every step; with font-size-adjust, its lowercase lands at the height of the text around it. size-adjust would also mean copying the @font-face rules that @fontsource-variable/inter ships.

The cost is in fallback glyphs, which the two engines size differently. WebKit fits each fallback font to the letter height using that font's own metrics, while Chromium scales a fallback by Inter's ratio, so an emoji inside Inter text draws smaller in Safari than in Chrome, by about 13% with Apple's emoji font. A browser without font-size-adjust (Chrome and Edge before 127, Safari before 17) ignores it and draws the letters about 13% larger at the same layout, and the SPA ships no fallback for it.

The steps between Tailwind's

text-3xs, text-2xs, text-xs-plus and text-sm-plus sit where Tailwind has no step, at 10, 11, 13 and 15 times 8/7. They are named by where they sit rather than by a role, because their uses are not one role: text-xs-plus is a setting's hint, and also a config value, a status list and a code box. text-3xs, text-2xs and text-sm-plus carry no line of their own. Inside a parent whose size comes from a text-* class they take that parent's line ratio, so text-2xs inside text-sm sits on an 18.86px line. Only a parent that sets its line as a length passes the length down. text-xs-plus carries its own 18/13 ratio, the line a hint sits on.

The chat and a document

The chat's own text is set on its own, at 17px on a 1.5 line, by text-chat: the answer (RichText), the question bubble and its edit box, the composer's text box, and the website widget's greeting. A document's reading text is set the same, by --text-reading in .artifact-reading. Both sizes were chosen by eye on their own screens rather than taken from a step, and they are two tokens so that one can move without the other. Both are above the 16px under which Safari zooms a focused field, so the composer and the edit box need no phone size of their own.

The composer's text box is 14px of padding over a 25.5px line, 39.5px, and its fit sets the box's height from scrollHeight, which is a whole number, so the box is 40px and the composer card 100px.

An answer's headings are in em (answer-headings: 1.375em, 1.1875em, 1.0625em and 1em), and a document's are in em at their ratio to 17px text (calc(28em / 17), calc(22em / 17), calc(19em / 17) and 1em), so both follow their text's size. prose and prose-sm size themselves in rem, outside the scale, so every other prose block takes a size class with a ratio line, which beats the plugin's size on the same element. A bare prose block takes text-base/[1.75], and a prose-sm block takes text-sm/[calc(24/14)], which keeps the plugin's own em spacing at the new size.

No letter spacing

The product sets no letter spacing: no tracking-* class and no letter-spacing rule, on a small uppercase label as much as on a title. The chat, which is most of what a person reads, sets none, and every other surface matches it.

A text field on a phone

Safari zooms the page when a focused field's text is under 16px. Under 640px, the base layer gives an input, a textarea and a select font-size: max(16px, 1em), so a field with no size of its own is never under 16px and never shrunk below the size it inherits. The rule is in the base layer, so it does not reach a field with its own size class. A compact field under 16px takes max-sm:text-sm itself, as the governance search, the IdP group value of a rule being edited or added, the SSO signing certificate and the skill body field do.

Guards:

  • TYPE_SCALE refuses, anywhere in the SPA:
  • a size written as a value, in any unit, as a math function or behind a type hint (text-[13px], text-[calc(13px)], text-[length:13px]);
  • a line written as a spacing step, or as a length in any unit but em, on leading-* or as a size's modifier (leading-5, text-sm/5, leading-[18px], text-sm/[20px], leading-[length:1.5rem]);
  • every Tailwind letter-spacing class, a negative one included (tracking-wide, tracking-[...], -tracking-wide);
  • an arbitrary property that sets a size or a line to a number, a length or a math function, or sets a letter spacing at all ([font-size:13px], [line-height:1.4], [letter-spacing:0]), since a class says each of them;
  • a fontSize, lineHeight or letterSpacing in a style that is a number or a string starting with one, negative or not (fontSize: 12, lineHeight: "20px", letterSpacing: -0.2).

A line written as a ratio, in em or as a percentage (leading-relaxed, text-sm/[calc(24/14)], leading-[1.2em]), a named line token (leading-control), a colour's opacity (text-slate-900/50), a value read from a variable (text-[length:var(--text-xs)], fontSize: "var(--text-xs)") and fontSizeAdjust all pass. A style value written as a template literal is computed, and no syntax selector can judge it. The guard matches one string at a time, so a class assembled from two constants gets past it, and the browser tests below are what catch a wrong computed size on the shared components. src/test/typeScaleLint.test.ts lints a sample of each form, refused and allowed, with the real config, so a pattern that fails open is caught. - styles/index.css.test.ts guards the stylesheet, which lint does not read. It checks each stepped token against Tailwind's default times 8/7, the body's font-size-adjust and size, the chat and reading tokens, the em headings and the phone floor, and that the source declares no letter-spacing and no font-size in px or rem. A size in em is allowed there, because it follows the size around it. - styles/typeScale.browser.test.tsx measures the scale in Chromium and WebKit: a step's computed size and line, that Inter itself has loaded, that Inter draws between 0.881 and 0.892 of its unadjusted width, the text controls at 34px and 28px, and the phone floor. components/chat/chatText.browser.test.tsx measures the chat, a document, the quiz and deck prose, and the widget, and components/layout/rails.browser.test.tsx measures the rails.

Control scale

A control's size comes from a step of the scale, never from a number at the call site. The scale is the same on every device:

Utility Size For
control 34px tall The default control: a button, a Select trigger, a labelled link-button, a workspace folder's role chip.
control-sm 28px tall The compact recipe: toolbar controls, size="sm" buttons.
control-segment 32px tall A member of a bordered track at the default step: a SegmentedControl segment or its opener.
control-segment-sm 26px tall A member of a bordered track at the compact step.
control-square 34px square A glyph control at the default step: Menu's ⋯ trigger, a row's remove button, a folder file's delete button, an upload's dismiss button.
control-square-sm 28px square A glyph control at the compact step.
nav-row 40px tall A row of the chat rail, the guest rail or the settings rail, which is a line in a list rather than a control in a form.

The tokens behind them (--spacing-control, --spacing-control-sm, --spacing-control-segment, --spacing-control-segment-sm, --spacing-nav-row) are constants in styles/index.css, and no media query redefines them. A bordered track lands on the step, border included, so its members are the step minus the track's 1px border on each side, which is what the two control-segment steps are. A component's size prop is visual weight only: Button's sm is control-sm, smaller text and tighter padding.

A control keeps its step's height at any text size. A text control whose height is its padding around its line sits on leading-control, a 24px line whatever its size, so its line, padding and border still add up to the step: FIELD is text-sm on that line, and 24px of line, 8px of py-1 and 2px of border make 34px. The token stays even where the step's own line is already 24px, so the height is the padding and the border and nothing else, whatever size the text is set to next. Anything off the scale whose height is its own padding and line grows with its line instead. A Select option is 40px and a Menu row 36px with a mouse, and 44px on a touch screen, where tap-row makes each its own area; a Tabs tab is 34px and a DataTable row about 45px on every device.

A text field cannot take a control utility, because an <input> draws no pseudo-element and so cannot carry a tap area. It takes its height from FIELD_HEIGHT or FIELD_HEIGHT_SM in fieldClasses.ts instead, which is the one file allowed to name the raw tokens. FIELD and COLOR_SWATCH in the same file are the faces built on them.

A checkbox is not on that scale at all. It is CHECKBOX in the same file, the 16px mark the browser draws, with accent-brand, the one property such a mark takes, block so it leaves no descender room under itself, and shrink-0 so a narrow row cannot squeeze it. A radio wears it too, because it is the same mark drawn in a circle. Its tap area is the label or the row around it, so where the mark sits at the edge of that label it needs 14px of reach past it on that side, (44 - 16) / 2. A SettingRow carries that reach in the span around its control, and a stacked row keeps the same 14px under the control on a coarse screen, 10px of the row's own padding and 4px the row's layout adds while stacked.

Guard: a checkbox or radio input whose class expression does not name CHECKBOX is a lint error in every folder, tests included, so a hand-written box cannot come back; layout such as mt-1 may be composed onto the face. QuizView.tsx is the one file exempt, because its option input is sr-only and the badge beside it is what a person sees.

A search box is that same face with room for its glyph, never a lighter one. Every search box in the settings, knowledge and shared UI trees is one component, SearchInput, and a hand-built type="search" input there is a lint error. Its default size is FIELD plus pl-9 with a 16px magnifier, and size="sm" is FIELD_SM with a 14px one, for a toolbar of 28px controls such as the one in ActionGovernance. The magnifier has one ink on both sizes, text-slate-500 dark:text-slate-400, and the input is named by its own aria-label, taken from the label prop. The caller's width, margin and flex go on the tap-area label through className, and FilterSearch is the filter strip's name for a SearchInput that grows to fill the strip. So a box that searches draws the same border, the same focus and the same placeholder as the field beside it. A box that searches is a text control like any other, and a lighter border would read as a disabled field among the controls around it.

FIELD_SM is the same face on the 28px step: Select's small padding and text-xs, and on a phone 16px text with no vertical padding, because the 16px floor in the stylesheet does not reach a field that names its own size and 16px on a 24px line with 4px of padding would be 30px, off the step. It is what a text control in a compact row takes - the rule value in RolesSettings, the search in ActionGovernance - and it stands at the height of a size="sm" Select trigger and a size="sm" Button beside it.

A date field carries two rules of its own, both in the base layer of styles/index.css. The browser draws the dd/mm/yyyy mask whether or not the field holds a value and no pseudo-class tells an empty date from a filled one, so FilterDate sets data-empty while its value is empty and the rule gives the field the placeholder's colour, which the mask inherits. The colour sits on the input rather than on ::-webkit-datetime-edit because that pseudo-element cannot be read back by getComputedStyle, so nothing could test it; being a base-layer rule, a text-* utility on the input would win over it, and FilterDate carries none. The second rule drops the 1px margin WebKit gives the value, which otherwise leaves a date filter 36px where every other text control is 34px.

Every dialog in the SPA is one of three chassis - a Modal, a ConfirmDialog or a Drawer - so the top layer, the accessible name, the close button, the scrolling body and the action row are written once rather than per dialog. A dialog that holds content rather than a single question is a Modal at md, w-[min(36rem,calc(100vw-2rem))], so a form in a dialog has the width of a form and a phone keeps its 16px margins. A footer row wraps, because a dialog that asks a question with three answers does not fit them on one line of a phone.

Each chassis draws its own heading, through OverlayTitle, from a title string, and points the dialog's aria-labelledby at it, so the title a person reads is the dialog's accessible name and the two cannot drift apart. A caller never passes heading markup or a separate label. What else sits in the header goes through named slots: mono sets the title in the mono face for an identifier, truncate cuts it to one line with the whole text as a tooltip, tag sits beside it on a wrapping row, and leading is the subject's mark before the title block, on Modal and Drawer. subtitle is drawn under the title exactly as the caller built it, spacing included, and is not part of the name; a Drawer also takes actions, the controls between the title block and Close. ConfirmDialog takes the title alone.

A Modal opens its dialog before its content exists: it calls showModal() from a layout effect and renders the body and the footer only while the dialog is open, so the browser never paints the empty panel. React implements autoFocus as a focus() when an element mounts, so only an element mounting into a dialog that is already open can take it. A field marked autoFocus inside a Modal therefore has the caret on every open, and a dialog with nothing marked opens on its close button, which is the first focusable thing in the panel. A dialog that holds a form marks the first field a person can type in, so the caret opens there: a field that is locked or fixed is skipped, and a dialog whose fields are all locked marks nothing and opens on its close button.

The body is a fresh mount on each open, so nothing typed or chosen inside a Modal survives a close, and state that has to outlive the dialog belongs above it, in the component that owns open. A form dialog holds state of its own - the fields, and the mutation behind Save - and starts fresh by being mounted per open: its owner renders it only while it is open and passes open to Modal as a constant, so every field is seeded from props as the form mounts and there is nothing to re-seed or reset. The three connector config dialogs and both folder forms are built that way. A Modal its owner takes down while it is still open closes its dialog from a layout cleanup, which React runs while the element is still in the document, so the keyboard goes back to whatever opened the dialog rather than to the page body. Escape on a dialog rendered inside another closes only the inner one: React hands a nested dialog's cancel to every dialog above it in the component tree, so Modal and ConfirmDialog each ignore a cancel that did not come from their own element.

Escape closes one thing: the innermost layer open under the reader. A surface that closes on Escape, such as the side panel, a non-modal Drawer or the phone sidebar, registers useEscapeLayer from lib/escapeLayers.ts and never listens on window itself. The layers stand down while a <dialog> is open, because the browser runs the dialog's cancel after keydown has passed window. A control that handles Escape itself, such as an edit box that cancels, calls preventDefault, so no layer runs after it. An Escape that cancels an IME composition reaches no layer, because the input method used it. A text field's own key handler returns early on event.nativeEvent.isComposing too, so an Escape or an Enter that ends a composition never cancels or saves. Popover and Menu are the only document-level Escape listeners, and they stop the key before any layer hears it.

A chassis shows its dialog again when the browser closes it while the owner still says open, through useReshowOnNativeClose, which all three call. The browser does that because a page may only prevent so many close requests in a row: a second Escape arrives as a cancel that cannot be prevented, and the browser closes the dialog itself straight afterwards, without the owner's open prop changing. The person meets that on the upload conflict prompt, which stays open while files are still waiting to be answered for. Focus goes back to whatever last held it inside the dialog, so the prompt for the next file keeps the caret on Skip; a Drawer, whose panel lands focus on Close by hand, names that button as the place to fall back to.

Guard: a JSX <dialog> anywhere outside Modal.tsx, ConfirmDialog.tsx and Drawer.tsx is a lint error, so the next dialog cannot be hand-rolled. The test blocks leave the ban out, because a test builds a <dialog> to host the control it is testing.

Tap area

A finger needs about 44px to land reliably, so on a touch screen every control answers taps in a box at least 44px each way around it, without growing its face. A form therefore looks the same on a phone as on a laptop.

The box is a ::before pseudo-element, drawn only under the coarse variant, @media screen and (pointer: coarse). A touchscreen laptop reports a fine main pointer and gets no tap areas; a printed page matches no screen query. --spacing-tap-target (2.75rem) is the one home of the 44px figure.

  • tap-target draws the area around any element, and makes the element relative. Every control step applies it, so a control cannot take a size from the scale without an area. ICON_BUTTON, the artifact controls' ARTIFACT_CONTROL, TEXT_LINK, Toggle, a Tabs tab and DismissibleWarning's close button carry it directly.
  • field-tap-target goes on a <label> wrapped around a field. The label lends the field its area, which sits behind the field, so a tap on the typed text still places the caret where the finger landed. The label isolates the area so no ancestor's background covers it, and it carries layout only: the field's sizing classes (flex-1, min-w-0, lg:w-52) move onto the label and the field becomes w-full. A field named by an sr-only label moves that name into an sr-only span inside the wrapping label. A field with visible words above it is wrapped together with them, and the words wear their type on a span of their own, because the field inherits its font from the label; the study editors' StudyField is built that way. A <textarea> is a block inside its label: an inline one sits on the text baseline and leaves a gap under itself, which makes the label taller than the field, and the composer keeps that gap as the label's own margin instead. SettingRow is already such a label and needs nothing.
  • tap-row makes a row in a list of choices 44px tall on a touch screen, so the row is its own area. Menu rows, Select options and PeoplePicker results use it.
  • A DataTable body row is at least 44px tall on a touch screen (coarse:h-tap-target, since a table row treats height as a minimum), so a link centred in a cell keeps its area inside its own row.

Neighbours. An area reaches (44 - size) / 2 past a face of that size, and it must never take a tap meant for a neighbour. Two neighbours therefore need a gap of at least 44px minus their mean size along the axis they share, and tap-gap-*, tap-gap-x-* and tap-gap-y-* set that gap on a touch screen only. tap-gap-8 is 12px between 32px icon buttons, tap-gap-nav-row is 4px between rail rows, and a 28px and a 32px control take tap-gap-7.5. Each is paired with the mouse gap it replaces, gap-1 tap-gap-8, and works only on a flex or grid container. An area may reach over empty space and over plain text in its own row, but not into another control. Controls wider than 44px need no horizontal room, so a row of text buttons usually needs nothing. Where the space between two controls is a margin or a padding rather than a gap, as in a stack of form rows, the same figure goes on a coarse: margin or padding instead. Between block siblings a bottom margin collapses into the next sibling's top margin and adds nothing, so spacing that has to add to the next row's own margin is a padding, as under a source strip and a decided action card in chat.

Where an area stops working.

  • An element that clips (overflow-*, truncate, line-clamp-*) clips its own area, so the clipping goes on an inner span.
  • A clipping ancestor cuts off the part of an area that reaches past it.
  • A scrolling ancestor grows by it instead, and can wobble sideways under a finger, so a scroll container's padding on each side is at least the reach of the areas beside that side. Where it is not, the control uses tap-row or the container gets that padding on a touch screen.
  • The compiled order decides four pairings, and the stylesheet test pins them. The tap utilities come before absolute, fixed and sticky, so a positioned control keeps its position. Each touch gap comes after the mouse gaps it can pair with and beats them: tap-gap-* comes after gap-*, and tap-gap-x-* and tap-gap-y-* come after gap-* and after the gap-x-* or gap-y-* of their own axis. gap-x-* and gap-y-* come after the two-axis tap-gap-* and beat it on their own axis. Every variant-prefixed gap comes after every unprefixed touch gap and beats it on the axes it sets, from its breakpoint up, and the gaps of one variant follow the same order among themselves. tap-row beats an unprefixed min-h-* only because it sorts after min-h- by name, and a variant-prefixed min-h-* beats it.

What cannot carry an area of its own is an element that draws no pseudo-element, which in practice is an <input>: a field borrows its area from the wrapping label, as above. A device with no pointer at all, such as a keyboard-only kiosk, matches no coarse query and gets no areas, because nothing taps it.

The touch variant is a different question and stays on hover: none: it answers whether a hover-revealed control is always drawn, not how big a target is. A control revealed on hover uses reveal-on-hover, which hides it only where something can hover, so a touch tablet as wide as a laptop still draws it. A row that lays such a control over other content from a breakpoint up stays in the flow under touch: instead, as the question's edit button does (md:absolute ... touch:static), because touch: rules come after md: ones. Chat's glyph buttons are 36px squares with tap-target at every screen width: the answer's actions, the question's edit button and the composer's attach button.

Guards:

  • A literal min-h-7, min-h-9, min-h-10, min-h-11, h-9, h-10, h-11, size-9, size-10 or size-11 is a lint error in the shared UI layer, the settings tree and the knowledge folder, and the message points at the control utilities. h-* and size-* are listed beside min-h-* because they are the forms a control height comes back as. A box that shows an image rather than taking a press is not a control, and the two that exist carry a one-line exemption saying so.
  • Any utility built on a scale token - min-h-control, size-control-sm, md:h-control, min-w-nav-row and every other spelling - is a lint error everywhere except fieldClasses.ts and tests, so a control cannot take the size without the area.
  • One class string may not pair a tap utility with a clipping utility, tap-row with a variant-prefixed min-h-*, a tap-gap-* with space-x-* or space-y-*, a two-axis tap-gap-* with an unprefixed gap-x-* or gap-y-*, or a tap-gap-* with a variant-prefixed gap whose variant lacks a tap-gap-* of its own for an axis that gap sets. Each is a pairing in which the compiled order breaks the area.
  • Lint reads one class string at a time, so a face assembled from a constant and a caller's classes gets past it, and styles/typeScale.browser.test.tsx measures what a browser actually draws. Its probe renders the shared faces and one control of each kind the app builds on them - both field faces, the small field, both dropdown sizes, the three search boxes, the filters, both button sizes and a dialog's fields - and refuses one whose computed size is off the type scale, whose height is off the control scale, or whose floor is not the height it stands at. The floor is what tells min-h-control from min-h-control-sm: a field's own line and padding can add to 34px under a 28px floor, so only that equality catches the wrong token.

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. SettingRow takes a single control, SettingActionRow takes a cluster of buttons or a file picker, and SettingSection groups rows under a title. The full rules - why the row is a <label>, why one control rather than children, and why a control declares its own width - are in Settings Page Architecture. The row names its field by its name alone and describes it by its hint, through the props it hands its control, as that section says.

SettingRowGrid is the two-column form of the same row, for a set of settings whose controls have to line up with each other: the SSO claim mappings, where up to seven fields are read as one table. It takes its rows as data, so every row in the set is laid out the same way and the pairing cannot be half-applied. Each row spans two subgrid tracks, the name and hint in one and the control in the other, so the tallest hint in a pair moves both controls down together instead of leaving one control out of line with the one beside it. The grid is two columns from 32rem of its own width, and from 66.5rem each row puts its control beside its name, which is the width at which a cell reaches the 32rem a single SettingRow needs to do the same: two cells and the 2.5rem gutter between them. The width read is the grid's own, through a @container/pair on the wrapper around it rather than on each row, because an element that becomes a container-query container no longer takes its tracks from the grid above it: a row made a container leaves the two controls of a pair 20px out of line.

The one thing worth repeating here: a row stacks by its own container width, not the viewport's. SettingRow establishes @container/row and its inner layout uses @lg/row:, so 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 Tailwind container query applies to a container's descendants and never to the container element itself, so the container and the flex row have to be two elements. A control that declares a width for the side-by-side arm adds FILLS_STACKED_ROW from SettingRow.tsx, which fills the row at both widths a row stacks at, because the row's width is what says how much belongs in the box once the name is above it rather than beside it.

StatCards and DataTable answer to their own width for the same reason: both are drawn full width and beside an open file preview, and a viewport that says "wide" says nothing about the room either one has. The strip goes two cards across from @min-[19rem], three from @min-[29rem] and four from @min-[39rem], all measured from a phone's two-card strip: at 21rem of viewport the page's 1rem gutters leave a 19rem strip and a 9rem card, which is the narrowest card the strip ever draws today. A table hides a column below the table width that column declares, and only a table with something to hide is made a container, because inline-size containment stops a box sizing to its content.

StatusPill - a state, named by meaning

StatusPill.tsx is the one pill: inline-flex ... rounded-full px-2.5 py-0.5 text-xs font-medium plus a tone. Tones are success, warning, danger, neutral, info, brand and muted - named for what they mean, so a page states "this failed" rather than picking red itself, which is how six near-identical pill vocabularies drifted apart across the settings tabs. muted is the one that is not a status: it means listed but not in force - a member of an audience who cannot act, a scope that admits nobody - and is drawn as a dashed outline so it never reads as a warning. Its ink is slate-500, or slate-400 in dark mode, on its own white or slate-900 ground, which is 4.77:1 in light and 6.79:1 in dark. The pill carries that ground because a hovered row's wash sits under it, and on the light wash the same ink with no ground falls to 4.35:1. The ink and ground are one constant, MUTED_FACE, which the people picker's dashed deactivated-person chip also wears. The outline is an outline, not a border, so a muted pill is exactly as tall as the filled one beside it in the same wrapped row. indicator draws the pill's one leading mark: "dot" for live or enabled state, "spinner" for work still in flight, such as a file that is queued, uploading or being read. It is one prop rather than two flags because the pill has one slot before its word and cannot draw both. caps is for a machine word rather than a phrase - a connector protocol, an action's side effect - because oauth2 in sentence case reads as a misspelt word and write as an unfinished verb. fit is for a pill carrying a name someone typed rather than a status word, which can be longer than its column. fit="wrap" lets the label break across lines: a role name in the users table can be longer than a phone's column, and a pill that cannot break pushes its table sideways out of the wrapper instead. fit="shrink" lets the pill narrow to its container so a truncate child ends in an ellipsis, as the scope pill does when a role name is longer than its line. It is one prop because a pill cannot both break its text and cut it.

The coloured tones use the 50 tint step on purpose: these sit inside dense tables, where a 100 fill competes with the row text for attention. neutral has no hue to carry a 50 fill, so it takes the 100 grey, which is also the grey of ROW_HOVER_WASH. On a hovered row its fill alone would disappear, so neutral also draws a hairline ring-1 ring-inset (slate-200, slate-700 in dark mode) that keeps the pill's shape on any wash. The ring is inset so it adds no height, and a neutral pill stays as tall as a muted one beside it. brand's ink is two steps darker than its fill's hue rather than the brand token itself, because the token on a 10% tint of itself is 3.27:1 and fails AA. The token is what the fill derives from and is not a pill's to change.

An inline <code> chip is not a pill, and it carries one rule of its own: a chip with punctuation straight after it takes pl-1.5 rather than px-1.5, because the trailing 6px leaves the mark standing off the last glyph, which reads as a typo rather than as spacing. The domain notice in DomainSettings.tsx is the chip that rule applies to.

Guard: a class string containing rounded-full followed by px-2 is a lint error inside the guarded paths, except in StatusPill.tsx itself.

TextLink.tsx is the one text link, and TEXT_LINK is its face for the rare caller that needs the class string alone. A button is for an action on the surface you are looking at; a link is for going elsewhere, and for the quiet half of a pair beside a real button - "Add role" next to a table, "Export CSV" next to a chart.

Where it goes is a three-arm union rather than three optional props: {to} for an app route, {href, target?, rel?, download?} for an address outside the app, or {onClick, disabled?} for a handler. A link that is both a route and an href cannot be constructed, and target, rel and disabled are only offered where they mean anything. disabled lives on the handler arm alone, because the platform has no disabled anchor: a disabled route link would render as a working link and ignore the prop.

A link inside a coloured alert block is not one of these. It inherits the block's ink on purpose, because brand blue inside a red panel reads as a second, contradictory status.

The ink is text-brand-strong in light mode and text-brand in dark, and the two tokens divide by job rather than by theme. --color-brand is what a fill, a focus ring or an active state is drawn in; measured as text on white it is 3.68:1, under the 4.5:1 that applies to text. --color-brand-strong (blue-700) measures 6.83:1 and exists only for brand-coloured text on a light ground - never for a fill, which at that darkness reads as a different brand. On the dark ground the plain brand is already the lighter of the two, so it stays.

Guard: a class string containing text-brand followed by underline is a lint error inside the guarded paths, except in TextLink.tsx itself.

Button - one filled action per surface

Button.tsx is Button, ButtonLink and buttonClass, in four variants: primary (brand fill), secondary (outlined), danger (outlined red), and quiet (no border, no fill) for the secondary half of a pair - Cancel beside Save, Remove beside Replace. ICON_BUTTON_FACE is the same quiet treatment for a control that is only a glyph, without saying how big it is, because two rails wear it at two different sizes. ICON_BUTTON is that face on a 32px square with a tap area, and every close and copy button in Modal, Drawer and CopyRow, and the panel shell's back and collapse buttons, render through it. The artifact controls wear the same face in a box of their own, ARTIFACT_CONTROL in components/panel/controlFace.tsx, which is 32px tall and at least 32px wide, because some of them carry a chevron beside the glyph.

A button whose word repeats on one screen carries an ariaLabel that starts with that word and names its subject, so a screen reader user can tell the buttons apart. The Logo, Favicon and Login background rows in GeneralSettings each have a "Replace" and a "Remove", named "Replace logo", "Remove favicon" and so on, and the connection drawer's Remove is named for its connection. Each domain card in DomainSettings names its buttons and its records disclosure for its domain, such as "Remove chat.vsu.edu" and "DNS records for chat.vsu.edu, 2 CNAME records", because two cards show the same words. The name starts with the visible word so a voice command that says the word still reaches the button. While the button reads progress, such as "Processing…" or "Removing…", it carries no ariaLabel, so its words are its name. A field whose words repeat in every row follows the same rule with an aria-label that starts with the visible words and names its row, such as "Front of card 2" in the study editors' StudyField. A field that shows no words carries an aria-label that names its row, such as "IdP group value for the advisors rule from University IdP" on a Roles row. A field in a settings row is named by the row, so it needs no aria-label. A test of a list whose rows repeat a control calls expectDistinctControlNames from web/src/test/controlNamed.ts on two or more rows, which fails when two reachable controls share a role and a name. The check counts text fields, search boxes and number fields as well as buttons, links and the other controls. A test finds one such field by its name with controlNamed, and the lint rule in spa-and-api-framework.md refuses a test selector that finds such a field by its tag.

A dialog's footer takes the default size, so its buttons stand as tall as the fields in the dialog above them, whether the footer holds Buttons or the buttonClass() face, as the link dialog in panel/editors/richText.tsx does. size="sm" is for a button among small controls - a rule row in RolesSettings, an editor toolbar - where a 34px button would stand over everything beside it.

A confirmation dialog's destructive button is the exception that stays hand-written: the primary action of a dialog whose only purpose is that action is filled, while danger is the outlined treatment for a destructive control sitting among others.

An enabled <button> shows the pointer from one base-layer rule in styles/index.css. A disabled or aria-disabled button keeps the arrow. A face that wants not-allowed says so with disabled:cursor-not-allowed, which as a utility beats the rule. A face does not add cursor-pointer to a button; PANEL_ROW is the one that carries it, because <a> rows wear the same face. A pressable that is not a button, such as an OpenableRow's container, a label around a checkbox or a <summary>, adds its own. styles/buttonCursor.browser.test.tsx pins the rule.

Guard: a heading element inside a button or Button is a lint error (HEADING_IN_BUTTON), because the button flattens the heading out of a screen reader's heading list. The heading holds the button instead, as each Features category heading holds its expand button.

Popover - the shared panel

Popover.tsx is the anchored panel every dropdown uses. It owns the one panel skin (border, radius, shadow, z-index, dark pair), so surfaces cannot drift apart, plus the behaviors every popover shares:

  • Placement and flip. placement is bottom-start | bottom-end | top-start | top-end, and the panel flips vertically before paint when the default side would cross the viewport edge and the opposite side has room. A ResizeObserver re-measures when the panel's content grows while open; a flip is latched until close, so scrolling or resizing the window cannot un-flip an open panel.
  • Sideways placement. placeHorizontally keeps the requested alignment when the panel fits inside the viewport's 4px margin, takes the other alignment when only that fits, and otherwise clamps. The fixed strategy positions with it directly. An in-tree panel measures in a layout effect of its own, on open and on every resize, and moves by a translate only when the shift is not zero, because a translate would make the panel the containing block of any fixed element inside it. So a Menu at the left edge of a phone opens aligned to its trigger's left edge rather than off screen.
  • Escape is consumed. The handler calls preventDefault() on the keydown, so dismissing a popover inside a modal <dialog> never dismisses the dialog with it.
  • Dismissal. An outside pointerdown closes without moving focus. Escape is handled at the document level, so it dismisses whether or not focus ever entered the panel; focus returns to the trigger only when it was inside.
  • A press inside keeps focus inside. A press on the panel's padding or plain text keeps focus where it was, because the panel cancels a mousedown that did not start on something focusable, so the arrow keys and Escape keep working. A press on a disabled control sends no mousedown at all, so the panel carries tabIndex={-1} and takes the focus itself. The cost is that text inside a panel cannot be selected with the mouse. Menu.browser.test.tsx and Select.browser.test.tsx pin both.
  • Role passthrough. Menu sets role="menu", Select sets role="listbox", and a caller wrapping content that declares its own role (the analytics date-range calendar's role="dialog") sets none.

An absolute panel, the default, renders in-tree: the caller owns a relative root wrapping the trigger and the panel. That means an overflow: hidden ancestor still clips it - the flip and the sideways move handle the viewport's edges, not ancestor clipping. A panel that has to escape one uses the fixed strategy, which portals it to the body, or into the nearest <dialog> when the trigger sits in one, and places it from the trigger's measured box.

Select - picking a value

Select.tsx is the value-picking dropdown: a trigger button (aria-haspopup="listbox") and a panel of role="option" buttons under roving arrow-key focus. Single mode (value: string) commits on pick and closes; multiple mode (value: string[]) toggles and keeps the panel open, with aria-multiselectable. Typing while open is type-to-jump: printable characters accumulate a case-insensitive search buffer, and typing never changes the value. A single or repeated character searches forward from just past the focused option and wraps, so repeats cycle through the options sharing that initial. A growing multi-character prefix searches from the focused option itself, so it stays put while the prefix still matches. The buffer resets after 500ms idle or on arrow/Home/End navigation; bare Space still activates the focused option, while Space typed mid-buffer joins the search. Values are strings; a call site with numeric domain values owns the conversion (see FolderScopeMenu).

Options are {value, label, meta?, disabled?, disabledReason?}, and each option row carries tap-row, so it is 44px tall on a touch screen. A disabled option is aria-disabled and inert rather than unfocusable, so keyboard and screen-reader users can still reach its reason. size is md (control, text-sm, as tall as a field in a settings row) or sm (control-sm, text-xs, the compact toolbar recipe); trigger/triggerClassName replace the default selected-label-plus-chevron trigger, and footer renders below the options (the folder picker's "Clear selection").

A filter whose empty value means "all" keeps that as a real option ("All roles", "All topics"), never a placeholder - otherwise the filter could not be cleared. placeholder is only for a value nobody returns to ("Choose a role…").

FilterSelect in FilterBar.tsx stays the filter-bar wrapper and renders a Select internally. The fixed controls in a filter bar - FilterSelect and FilterText - share one width on the wide layout and stretch to the full width in the column layout below it, so the shape of the bar does not depend on which label is selected; a label too long for the width truncates behind the trigger.

Menu.tsx is the command menu: role="menu" with role="menuitem" buttons, focus moving into the first item on open, and roving arrow-key focus.

An entry is one of three things. An action is {label, onSelect}; a link is {label, href, external?, download?}, which the browser follows on its own so a download or a print page opens the way an anchor does; a group is {label, items}, a row that opens a submenu beside it rather than doing anything itself. A note - {kind: "note", label} - says something about the level it sits in and takes no focus, so the keyboard walk steps over it; beyond its label it takes icon? and hint? and nothing else, since none of the rest would mean anything on a row that cannot be chosen. An action, a link and a group each also take destructive?, disabled?, title? (which names why a disabled item is disabled), icon? (a leading mark; a level with any icon keeps the slot on every row so the labels line up), and hint? (a second, muted line under the label).

The default trigger is the ⋯ glyph at control-square; trigger/triggerClassName make it a labelled toolbar button (the connectors "Bulk actions" dropdown), and triggerTip is the tooltip a bare-glyph trigger needs. panelClassName widens the panel when the items are longer than a verb. strategy is the popover's, and "fixed" is what a menu inside a scrolling or clipping container needs to be seen at all - the conversation rail's row menu sets it, or a menu on the last chat is cut off by the rail. onOpenChange lets a caller whose items are server state fetch them when the menu is first opened, and disabled closes the trigger itself, for a menu whose whole subject is missing rather than one whose rows are each unavailable.

Choose by meaning: picking a value is a Select, running a command is a Menu; if neither fits, the surface is probably not a dropdown (segmented controls, disclosures, and in-dialog checkbox lists stay what they are).

Guard: a JSX <select> anywhere outside Select.tsx is a lint error, so the native element cannot quietly return.

SegmentedControl - a short set of choices, all visible

SegmentedControl.tsx is a role="radiogroup" of role="radio" buttons laid side by side, generic over the option value (a string or a number) and taking {label, options, value, onChange} where an option is {value, label, icon?}. Use it where the options are a short set of values and every one of them is worth showing. Six places use it: the theme picker, the widget's bubble style and corner, the Features status filter, the connector action-kind filter and the analytics range presets. A longer list is a Select, and a two-state choice that writes on the spot is a Toggle.

It comes in two sizes, as Select, SearchInput and Button do. md is 34px as a group and sm is 28px, so a group sits level with the controls of the same size beside it; the connector action-kind filter and the range presets are sm. value is null when no segment is chosen, which the range picker needs for a custom range and for an unusable range from the URL. With nothing chosen the first segment holds the group's tab stop, an arrow forward or Home picks the first segment, and an arrow back or End picks the last. wrap lets the segments wrap onto more lines when the track is narrower than their row, and a wrapped line starts at the left edge. The range presets wrap, because five members with a cross-year label are wider than a phone, and a track without wrap keeps its width so its segments never spill past the border. className reaches the outer element, for a caller that places the group in its row.

An opener is one more member of the track, after the segments, that opens a dialog: the range picker's Custom button, which opens the calendar. A radio cannot open a dialog, because ARIA gives a radio no aria-expanded or aria-haspopup and axe flags aria-expanded on one, so the opener is a button, which a radiogroup may hold. The caller passes a typed descriptor of {label, pressed, expanded, onPress, ref}, and the component renders the button itself, so its face and its aria-haspopup="dialog" cannot drift between callers. The thumb sits under the opener while it is pressed, the arrow keys move only between segments, and the opener takes its own tab stop. A checked segment and a pressed opener together is a state the page must never show, so the component throws when a caller passes both.

Selection is carried by fill and weight, not by hue, and that is the whole reason this is a component rather than a native radio group. Which fill, and why it is one span that slides rather than a background on each segment, are the shared rule in Selected state below. A native radio says which option is chosen with the disc's colour alone, and on the dark theme the unchecked disc rendered brighter than the checked one, so the answer read backwards; the box was also 13px against a 34px control scale. Every segment is control-segment (or control-segment-sm), the same height on every device, and on a touch screen each is also at least 44px wide (coarse:min-w-tap-target) with its label centred. Segments sit flush against each other, so without that width two short labels would put their tap areas on top of each other.

Guard: a JSX role="radiogroup" anywhere outside SegmentedControl.tsx is a lint error inside the guarded paths. The role is the visible half of a keyboard contract - one tab stop, arrows that move the selection, Home and End - and a hand-rolled group tends to ship the ARIA without the behaviour. A native <input type="radio"> group is untouched, because there the platform supplies the keyboard.

Selected state - one navy fill, one thumb

A member of a set of peers says it is the chosen one with the navy fill: bg-slate-900 text-white in light, dark:bg-slate-100 dark:text-slate-900 in dark. Three surfaces wear it as a selected state, and a new set of peers takes the same fill rather than a colour of its own: SegmentedControl's selected segment or pressed opener, which carries the analytics range presets and the connector action-kind filter among its callers, the display toggles in QuizDisplayControl.tsx, and the rich-text toolbar's ToolButton in panel/editors/richText.tsx.

The brand fill is a different job. It belongs to Button's primary variant, to a Toggle that is on, and to the days a person has picked in DateRangeCalendar - one action, one switch, one value, none of them one choice among visible peers. A peer filled brand beside plain peers reads as the button of that row rather than as the one of five that is chosen.

Where the peers sit flush inside one bordered track, the fill is not a background on the member at all. It is a single span behind the track, SelectionThumb in ui/selectionThumb.tsx, moved under the chosen member by a transform, and the member itself carries only its ink. SegmentedControl renders the thumb under its checked segment or its pressed opener, and the span measures that member's box after every commit of its host and whenever the track or one of its members resizes, so it follows a member whose label changes width, that wraps onto a second line, or that the web font moves along its line. The fill moves rather than fades because a background fade to an oklch() colour snaps instead of animating, and the slate fills here are oklch. A fill that moves also tells the truth about what happened: one fill sliding says the selection moved, where two grounds cross-fading says two separate things changed. The other two surfaces paint the fill on the member itself, because the quiz toggles and the toolbar buttons are loose rows with no track for a thumb to sit behind.

Tab strip - one underline wherever it sits

A tab strip is a TabList from ui/Tabs.tsx, or a nav of links wearing tabClass when the view lives in the URL. It wears the brand underline under slate ink wherever it sits: on a page, inside a section or card, or inside a drawer. A strip inside a section is the same strip on the same 1px bottom border, and a raised white chip is not a treatment for a tab. The container draws the grey border-b, and the strip inside it wears TAB_STRIP from Tabs.tsx, which overlaps that line by 1px so the active underline sits on it. TabList builds both halves itself, and a strip of links wears TAB_STRIP on its nav inside a bordered container and adds no gap of its own, because TAB_STRIP already carries the gap.

A tab strip and a SegmentedControl answer different questions. A SegmentedControl picks a value that narrows or scopes what is shown, such as a status filter or a date range. A tab strip picks which view of what is already loaded a person is looking at.

The ink is slate rather than brand because brand ink on white measures 3.68:1, under the 4.5:1 that text needs. The underline is the brand colour, and an inactive tab carries a transparent one of the same width, so selecting a tab never moves the text.

tabClass(selected) is the face one tab wears, exported from Tabs.tsx so a strip of links looks like TabList's tabs without TabList learning about react-router. Use links when the view must live in the address bar, so a section can be shared, survives a refresh and steps back with the browser's back button. A strip of links inside a scroll box adds pt-2 to each link, because the box clips a tap area that reaches past a 34px link, and a 42px link's 44px tap area fits inside the box's coarse padding.

Three places show the rule. The analytics section links in AnalyticsSettings.tsx are the page-level strip, a nav of links wearing tabClass that scrolls sideways on a phone. The Model usage views on the same page are a TabList inside one section. The By user view stays mounted while hidden, so a search typed under it survives a trip to Overview, and the Overview renders only while it shows, because a hidden Recharts chart measures 0x0 and logs a size warning in every build. The connector drawer's Setup, Audience and Actions strip is a TabList inside a drawer.

Guard: a JSX role="tablist" anywhere outside Tabs.tsx is a lint error inside the guarded paths, as role="radiogroup" is outside SegmentedControl.tsx.

Focus between an opener and the view it opens

A view that replaces a page's content, or part of it, takes focus on its heading when a person opened it, and hands focus back to the control that opened it when it closes. The editors in Settings, the folder and artifact pages, skill Compare, the artifact editor, cloud import, the side panel and the knowledge file viewer all work this way, through one module, lib/viewFocus.ts. Focus moves only after a person used an opener, so a typed URL, a reload or a redirect moves nothing.

Which tool to use

A view in the page's place uses the module, because the opener and the view never share a component that could hold a ref between them. A dialog uses the dialog chassis instead, which hands focus back itself: the browser returns it from a closed Modal, and a Drawer names its opener in returnFocusTo. OpenableRow's onOpen hands the caller the open button, for a row whose own code returns focus to it when a drawer closes. A row that opens a dialog says opensDialog, and the type does not let it also take a returnKey.

Keys

An opener is named by a key built in returnKeys, such as returnKeys.ssoProvider(id) or returnKeys.chatSource(scope, answerId, index). Each constructor returns a template literal type, so ReturnKey is a closed union and a prefix lives in one place. The part before the first colon is the key's kind, and a view accepts only the kinds that open it. A key names one opener on screen, and two elements carrying the same key throw in tests and development builds and log in production. So two openers of the same view on one screen need two kinds: an artifact's list of sources carries sourceLink(index), and the link beside the question or card that cites the same source carries citedSource(index).

An opener carries its key in one of four ways: - OpenableRow takes returnKey, and DataTable takes rowReturnKey, so a click on a row's padding records the key as a click on its name does. - Button takes returnKey. - A plain link or button spreads returnTarget(key), which sets data-return-to. - A component whose key can change while it stays mounted calls useReturnKey(key), and entries held under the old key move to the new one. A chat card that turns from drafting to saved is the case.

installViewFocus(document), called once in main.tsx, adds a capture-phase click listener that records the key of a data-return-to element at "opening". Enter and Space on a button or link raise a click, so the keyboard records the same way. Only one opener is at "opening" at a time, and a pointer click anywhere else drops it, because the reader changed course.

Arriving

useArrivalFocus(kinds, identity?) returns { headingRef, handle }. The heading takes headingRef with tabIndex={-1} and focus:outline-none, because a focused heading is a reading position, not a control. SettingSection takes the ref as headingRef.

Every state an opener can land on takes the heading ref, including a failed load and a not-found line, or a failed open leaves the reader on the body. Such a state takes tabIndex={-1} and focus:outline-none like a heading. When a view can turn into its not-found state after its heading is on screen, the component above both holds the hook and passes the ref down, so the swap is one view changing its heading rather than one view leaving: the folder routes and SSO's provider editor do this.

When the heading attaches and the opener at "opening" is of one of its kinds, the heading takes focus and holds the key. A heading that attaches takes that opener however long ago it was used, because a route view can mount several tasks after the click. A heading that takes an opener in the same commit another view leaves also holds that view's keys, so its own close returns through to the view before. Entering Edit on the artifact page is the case: the edit heading holds the library's key too, so Done and then All artifacts still return to the title link.

identity is for one heading that shows many items, such as the side panel. An identity change counts as a new view only while the opener is fresh, which lasts until the task that used it ends, through one setTimeout(0). React commits an opener's own synchronous update in that task, so a change in a later task is one the reader did not make, such as the agent swapping a skeleton for the saved artifact, and the keys follow the new identity. LIST_VIEW is the identity of a list: a change to it on its own drops the keys, so a later close does not return to an item opened long ago.

The handle has three calls: - leave(control) is called by the view's own close control just before it closes, so the next unmount or identity change is a leave even when the view stays mounted. - rearrive() gives the opener just used to a heading that is already showing what was asked for, such as the same source opened from a second chip, because only the caller knows the view did not change. It gives only an opener used in the same task, so an older one whose view never came cannot pull focus when the agent writes the same view again. - returnTo(key) sends the return somewhere else, such as a new provider's row after a create.

Returning

useReturnFocus(containerRef, active) registers a container, such as a list, as a place a return may land. It registers from a layout effect, so a list that mounts in the commit that closes the view is there when the keys resolve. A container that appears only after a passive effect's state update commits too late and never sees the key.

When a view leaves, its keys are resolved in one microtask after the commit, newest first, and the first key whose element sits in a registered container takes focus. The view the reader came back to keeps the keys the return did not use, so its own close returns to what opened it. That is a heading that attached in the commit in the same container as the claimed element, or a view that stayed mounted and now shows an item again. A view that stayed mounted keeps its keys wherever the claimed element is, and also when no key took focus, such as when focus stayed in the composer or the chip that opened the source is gone. A view that stayed mounted keeps only the keys up to the newest one that opened the item it shows again, and none when no key opened that item. A view that changed to its list keeps none. A return moves focus only from the body, from the control that called leave, or from the heading of a view that left while staying mounted. Anywhere else is where the reader put focus, such as the composer after Escape, and it stays there.

A list that mounts with its rows still loading waits for the key's element and focuses it when it renders, while focus is still on the body and until the reader presses a key or the pointer. returnFallback(find) names where focus goes when none of the view's keys finds an element, and a view's close control calls it just before it closes. A view that can show with no opener behind it, such as cloud import's status when the dialog is reopened to a running import, names its fallback with openerOf(key), which finds the element a key names in a registered list.

Back controls call leave: SSO's "All providers" BackLink takes an onClick that receives the control, and Compare's back calls leave(null). A link-style back control does not, because the route change unmounts the view, and the unmount is the leave.

The side panel

PanelShell holds the focus for both the conversation panel and the knowledge file viewer. It takes arrivalKinds, a module constant naming the openers of its details, and detail, from which panelHeadingKey builds the identity: LIST_VIEW for the list, a source's file with its page and cited chunks, or an artifact's id or the call still writing it. Its heading slot is a function of the ref, so a caller's heading shows in its type that it must place the ref, on every heading element it can render. It registers its own aside as a return container, and the chat transcript registers too, so a chip or a card in the chat takes the return.

Back, collapse and Escape go through one close function that calls leave and names a fallback. Back falls back to the list heading, and in the conversation panel collapse falls back to the collapsed panel's expand control, which covers a chip or card replaced by the saved message while the detail was open. The file viewer names no collapse fallback, and its close returns to its opener.

A source opened while an artifact is open opens over it, and the artifact stays mounted and hidden under the source (artifacts.md). Back from the source returns focus to the link or chip that opened it, and the artifact's view keeps its own opener's key, so a second Back returns to the artifact's row or card.

One rule lives in PanelShell alone, because only this panel changes under the reader without a click. It remembers the element last focused inside the panel, and when the agent replaces the detail and that element is gone, the new heading takes focus.

Chat keys carry the chat's ChatPanelScope, and PanelShell is keyed by the scope, so switching chats takes no keys across. A chip or card key names the saved answer, not the rendering: the turn store sets answerId from the server's message_saved event, so a live chip keeps its key when the saved message replaces the live reply. Until that event arrives a key reads live, and useReturnKey moves it.

The panel scope

PanelScopeProvider takes one surface prop, a chat with its conversation id or the artifact page, and derives both the panel scope and where a source opens. usePanelActions() works on any surface: its openSource opens a chat's own panel, or the knowledge file viewer on the artifact page, which has no panel. useChatPanel() gives a chat's scope, detail and override with the actions only a chat panel has, and useChatScope() gives the scope alone, for a control that builds a key without reading the panel. Both throw on the artifact page, because a chat panel control rendered there is a wiring mistake. uiStore's per-scope detail, override and width take ChatPanelScope, so the artifact page cannot hold a panel detail nothing draws.

FileDetailPanel closes the file viewer from a layout-effect cleanup when the path changes or it unmounts, so leaving any page that opened a file closes it.

Grey ink - slate-500 on a light ground

Quiet grey text or a quiet glyph is text-slate-500 dark:text-slate-400, never a bare text-slate-400. Slate-400 measures 2.63:1 on white, which fails the 4.5:1 that text needs and the 3:1 that an unlabelled glyph needs. Slate-500 measures 4.77:1 on white and 4.55:1 on the slate-50 shell ground the chat sits on. In dark mode slate-400 is the step that reads, so the rule bans only the light-mode token: dark:text-slate-400 and placeholder:text-slate-400 pass. The dark step is always dark:text-slate-400, because dark:text-slate-500 measures 4.23:1 on the slate-950 page ground and 3.74:1 on a slate-900 card, and both fail. On a slate-100 fill, such as a section's count pill, slate-500 measures 4.35:1 and fails, so the ink there is slate-600, which measures 6.92:1. SectionHeading in components/panel/ holds that pill for the artifact panel and the history drawer. The website widget has no dark theme, so its quiet lines are a bare text-slate-500.

The three dark rails - the chat rail, the guest rail and the settings rail - are slate-900 in both themes, where slate-400 measures 6.78:1 and slate-500 fails. Their quiet text and glyphs wear text-rail-muted, a theme token for slate-400 declared in styles/index.css, so the rails keep their colour under a name that says why it is allowed there.

Guard: a bare text-slate-400 is a lint error in every shipped .ts and .tsx file, owner files included, and in the tests of the shared UI layer, the settings tree and the knowledge folder. Other tests do not get it, because a test that asserts a class name or a lint message holds the token as data. Two lines in Select.tsx keep it with an eslint-disable-next-line and a reason: an empty trigger's placeholder text, and a disabled option, which contrast rules exempt.

How the guards are written

Every guard above is a no-restricted-syntax selector, and three facts about that rule shape the config:

Each rule that matches a class string is stated twice, once for Literal and once for TemplateElement. A className built as a template literal is a TemplateElement, not a Literal, and most conditional faces in this codebase are written that way, so a single-node rule would miss them entirely. The rules that match an element or a property rather than a face - the native <select>, the hand-rolled role="radiogroup" and an inline fontSize, lineHeight or letterSpacing - are stated once, because an opening tag, an attribute name and an object property have only the one node type. One thing stays out of reach: a template literal matches one quasi at a time, so a two-token face split across an interpolation - `text-brand ${x} underline` - is past what a syntax selector can see. The single-token height, scale-token and type-scale patterns cannot split; the pill, link and tap-area pairing patterns can, and that is the boundary of what these guards claim. The pairing patterns match whole whitespace-separated tokens, so a word that only contains a class name, such as "controls", is not a match, but prose whose words are the class names themselves is, such as a sentence with the words "control" and "truncate" in it. The scale-token pattern needs one of Tailwind's spacing prefixes joined to a token, so "remote-control" passes and "top-control" does not. The type-scale pattern needs a letter-spacing class's own suffix, so the word "tracking" in prose passes. src/test/tapAreaLint.test.ts lints sample class strings for the tap-area guards with the real config, and src/test/typeScaleLint.test.ts lints sample class strings and style properties for the type-scale guard, so a pattern that fails open is caught. src/test/guardScopeLint.test.ts lints its samples under a chosen file path instead, since a file's path alone decides which vocabulary it is held to, so a block that covers the wrong folder or quietly drops a vocabulary is caught as well.

No face selector is anchored on the className attribute. This codebase names its faces - FIELD, TEXT_LINK, ICON_BUTTON, Select's TRIGGER, Menu's ROW_BOX are all module constants - so an attribute-anchored selector would see none of them, and hoisting a new inline pill to a const would be enough to walk past the guard.

An owner file is exempt from its own rule and nothing else, and the exemption re-specifies the rule with the selectors that still apply. no-restricted-syntax is one rule name carrying a variadic option list, so turning it "off" for a file disables every selector for that file at once - which is how Select.tsx, exempted for the native-<select> rule, would otherwise have escaped the control-height guard in the one file where control heights actually live. The same fact means a later block's option list replaces an earlier one's, so every block that restates the rule restates the tap-area guards and TYPE_SCALE too. The scale-token guard is dropped by one block after each folder's own block: for tests, which assert the class names, and for fieldClasses.ts, which owns them. The raw-dialog ban is dropped by those same test blocks, for the reason given with the guard above, and by the three dialog chassis, which draw the element every other file goes through them to get. Those two are all a test block drops. TYPE_SCALE is dropped by no block, tests included, so it applies to the whole SPA, and a test that asserts a size asserts a step name.