Skip to content

Vayu Design System

Reference for all UI tokens, component patterns, and visual conventions. Future sessions must read this before touching any UI file.


Philosophy

3-level elevation - every surface sits on one of three layers. Nothing floats outside this hierarchy.

Level Token Dark Light
Canvas (outermost) bg-background #09090b #f4f4f5
Panel (sidebar/header/toolbar) bg-panel #111113 #fafafa
Card (content surface) bg-card #1a1a1f #ffffff

--tab-active is a fourth, single-purpose surface for the active tab. It exists because the elevation model inverts between themes: in dark, background is below panel, so an active tab matching the content pane reads darker than the bar - correct. In light, background (96%) is lighter-adjacent to panel (98%), so the same rule gave only ΔL* 2.06 of separation and put the active tab on the wrong side of the convention (active tabs are normally the lightest thing in light UIs). --tab-active deepens it to ΔL* 4.82 in light and stays equal to --background in dark, where nothing needed fixing.

The active tab also carries a border-t-2 border-t-primary stripe. That is the primary signal, because it reads identically in both themes, where a surface shift does not. Inactive tabs carry border-t-2 border-t-transparent so the stripe does not displace their contents by 2px.

Paper White light mode - light surfaces use a cool near-neutral (zinc) family; higher surfaces are lighter (canvas → panel → white card).
Dark canvas - dark mode uses near-black with subtle violet undertones (zinc-950 family).


CSS Custom Properties

All tokens live in app/src/index.css as HSL channel values (no hsl() wrapper - @theme inline and Tailwind config add that). Separate light and dark values are listed where they differ.

Elevation

/* Dark */
--background: 240 10%  4%;   /* #09090b - outermost canvas */
--panel:      240  6%  7%;   /* #111113 - sidebar / panel bg */
--card:       240  6% 11%;   /* #1a1a1f - elevated surface */

/* Light */
--background: 240  6% 96%;  /* #f4f4f5 - paper-white canvas */
--panel:      240  5% 98%;  /* #fafafa - panel */
--card:         0  0% 100%; /* #ffffff - white card */

Foreground Scale

/* Dark */
--foreground:         240  5% 96%;   /* #f4f4f5 - primary text */
--muted-foreground:   240  5% 65%;   /* #a1a1aa - secondary / labels */
--subtle-foreground:  240  4% 44%;   /* de-emphasized text - faintest readable tier */

/* Light */
--foreground:         240  6% 10%;   /* #18181b - primary text */
--muted-foreground:   240  4% 42%;   /* secondary / labels (see note) */
--subtle-foreground:  240  4% 58%;   /* de-emphasized text - faintest readable tier */

subtle-foreground is the least-prominent text tier, and it is deliberately below AA - 2.69:1 light, 2.89:1 dark against the surfaces it sits on.

That is structural, not a tuning miss. For it to clear 4.5 it would have to darken to ~42% lightness in light mode, which is exactly muted-foreground. The tier cannot be both fainter than muted and AA-compliant; there is no room between them.

So it is reserved for text where a miss is acceptable and the meaning survives without it: units (the ms after a number), dashes and dash placeholders, and decorative icons. Never for a label, a count, a legend, or anything the user has to read - a dashboard sweep once found 20 such misuses ("dispatched", "4xx 0", "target 10"), all measuring 3.34:1. Those belong on muted-foreground.

Light muted-foreground is 42%, not zinc-500's 46%. It is solved against the darkest light surface it lands on - --muted/--accent at 93%, not the card. At 46% it cleared the card (4.86) and the panel (4.64) but measured 4.13 on --muted, so muted text on any tinted chip or hovered row missed 4.5. At 42% every one of those surfaces clears: card 5.62, panel 5.37, background 5.12, --muted 4.77. The darkening is not perceptible on its own but moves a whole class of text over the line.

One surface still does not clear it: --accent-active (88%), the selected-row background, where 42% measures 4.22. Muted text on a selected row is therefore slightly under AA. Darkening further to fix it would collapse the gap to --foreground, so this is a known limit rather than an oversight - prefer --foreground for text that must stay readable on a selected row.

Interactive States

/* Dark */
--accent:        240  7% 16%;   /* #26262c - hover background */
--accent-active: 240  6% 21%;   /* #323238 - selected / active background */

/* Light */
--accent:        240 5% 93%;   /* #ededef - hover background */
--accent-active: 240 5% 88%;   /* #e0e0e4 - selected / active background */

Borders

/* Dark */
--border:        0  0% 10%;   /* ≈ rgba(255,255,255,0.07) - default dividers */
--border-strong: 0  0% 18%;   /* ≈ rgba(255,255,255,0.15) - prominent borders */

/* Light */
--border:       240  6% 89%;   /* #e2e2e5 - default dividers */
--border-strong: 240 5% 82%;   /* #cfcfd5 - prominent borders */

Primary (Accent Color)

Set by the [data-color-scheme] attribute; the default is Ocean (DEFAULT_COLOR_SCHEME in constants/color-schemes.ts is the source of truth). The accent is split into two tokens to resolve a contrast bind:

  • --primary - the accent used for text, borders, rings, tints, indicators (text-primary, border-primary, bg-primary/10). Mode-adaptive: deep on the light card, brightened on the near-black dark canvas so it reads in both.
  • --primary-fill - solid button/badge backgrounds that carry a white label (bg-primary-fill). Kept deep in both modes so white text clears AA-large (a brightened dark accent would fail white-on-fill).

Rule: white-labelled solid fills use bg-primary-fill; everything else accent uses --primary. --primary-foreground (white) sits on the fill.

/* Sunset (the values below; the default scheme is Ocean) */
--primary:       24 90% 46%;   /* light - deep accent */    /* dark: 24 95% 58% (brighter) */
--primary-fill:  24 90% 46%;   /* both modes - white-safe button fill */
--primary-text:  var(--primary);   /* accent as a label - see below */
--primary-foreground: 0 0% 100%;
--ring / --variable: track --primary

--primary-text - the accent when the accent is the label. Used by the section tabs for the active trigger (text-primary-text), where the accent has to separate not from the surface but from the --muted-foreground label beside it.

It is not sufficient on its own, and the tabs pair it with a 2px underline on --primary. Colour plus weight leaves the active tab hard to find in graphite, where the accent is a neutral: the label then differs from an inactive one in lightness alone, and 12px at 600 against 500 is a difference you have to hunt for. A rule is a shape, which no accent can wash out. Note the split - the label takes --primary-text and the indicator takes --primary, because one is text and the other is an indicator.

That separation is carried almost entirely by saturation, not lightness. Measured on --card, accent text and muted text sit within a 1.01-1.56 contrast ratio of each other in every scheme - effectively the same brightness - so what the eye reads is "coloured vs grey". At 55-95% saturation against an inactive 4-5% that is plenty, which is why --primary-text defaults to --primary and seven of the eight schemes never override it.

graphite overrides it, because it is the only desaturated scheme (S=12% light, 15% dark) and so has no hue to spend: 220 12% 26% light and 220 15% 86% dark, which take its active-vs-inactive separation from 1.14 to 1.86 and 1.23 to 1.81. Retuning graphite's --primary instead is not an option - that also paints its buttons, ring and --chart-1, and would give it near-black buttons. This is the same bind --primary-fill exists to solve, answered the same way.

Adding a scheme: override --primary-text only if the accent's saturation is under about 25%. color-schemes.test.ts derives that rule from the stylesheet rather than naming graphite, so a new desaturated scheme fails until it declares one. It is deliberately not in that test's REQUIRED list - inheriting it is correct for a saturated scheme, not a forgotten block.

Note the ratios above are between two foreground colours; neither WCAG nor APCA is defined for that, so treat them as a discriminability proxy rather than a conformance figure.

Semantic Status Colors

These differ between light and dark mode.

/* Light */
--success:            142 76% 36%;   /* green */
--warning:             38 92% 50%;   /* amber */
--info:               199 89% 38%;   /* cyan */
--destructive:          0 84% 48%;   /* red */

/* Dark */
--success:            142 70% 45%;   /* lighter green */
--warning:             38 92% 50%;   /* same */
--info:               199 89% 55%;   /* brighter on a dark plot */
--destructive:          0 62.8% 30.6%;  /* darker red */

--info used to be one value in both modes, like --warning still is. It was never measured, and at 199 89% 48% it scored 2.85 on a light card against the 3.0 bar - while painting three chart series (wire, send rate, connections). Mode-consistency is deliberate for the --status-* indicators, where one value must read as the same signal on either surface; --info had no such reason, so it is split like everything else here.

Chart series tier

A chart series is a stroke or fill on a plot that sits on --card, so its job is an icon's: be distinguishable from the surface behind it. That is not the job --warning and --destructive are tuned for, and asking them to do both failed in opposite directions - --warning measured 2.14 on a light plot (amber is intrinsically light) and --destructive 1.73 on a dark one, because in dark it is a deep red chosen to carry a white button label, which all but vanishes on near-black.

/* Light */
--series-success:  142 76% 36%;   /* 3.35 on card */
--series-warning:   38 92% 36%;   /* 3.98 */
--series-danger:     0 84% 48%;   /* 4.87 */

/* Dark */
--series-success:  142 70% 45%;   /* 7.45 */
--series-warning:   38 92% 50%;   /* 8.09 */
--series-danger:     0 84% 60%;   /* 4.57 */

Split per theme rather than mode-consistent, because charts re-read their tokens on a light/dark flip (currentThemeKey), so neither end has to compromise. --warning and --destructive keep their banner and button values and are no longer painted onto a plot.

status-token-contrast.test.ts derives what it checks from ROLE_TOKEN itself, so repointing a chart role at a token that fails is caught. A first version listed the token names by hand and was decorative: pointing the roles back at the button tokens left it green, since it went on checking the --series-* values that still existed.

-text variants for legible text. The base --success / --warning / --destructive tokens are tuned as fills and indicators; as small text on a light surface they fall below AA. Use the darkened (light) / lightened (dark) -text variant when the color is the text itself - text-success-text, text-warning-text, text-destructive-text - keeping bg-* / border-* fills on the base token.

/* Light - accessible text on light surfaces */
--success-text:  142 72% 27%;   --warning-text:  38 90% 30%;   --destructive-text: 0 60% 48%;
/* Dark - accessible text on dark surfaces */
--success-text:  142 60% 55%;   --warning-text:  40 92% 60%;   --destructive-text: 0 65% 65%;

Every -text token is solved against the darkest surface it can land on - --muted/--accent (93%) in light, --card (11%) in dark - not against the card. That distinction is not academic: tuned against the card, the light green and amber measured 4.04 and 3.57 on --muted, so a status message on a tinted chip failed while the same colour on a card passed. The dashboard's "⚠ ramp off target" is what surfaced it.

The current floor across all four surfaces, both themes, is 4.63 (light amber). If you add or retune a text token, measure it against all four surfaces, not the one you happen to be looking at.

The bare token is the fill; the -text token is the foreground. This is the whole of the rule, and it is worth stating separately because the failure mode is easy to miss: a bare token used as a foreground still looks like the right colour, it is just too close in luminance to the surface behind it. Measured on bg-card in the running app (contrast ratio; bold fails the 4.5 floor for normal text, and the four values under 3.0 fail the 3.0 icon floor as well):

family light bare light -text dark bare dark -text
destructive 4.87 5.48 1.73 5.40
success 3.33 5.71 7.46 8.81
warning 2.13 5.46 8.13 9.81
status-success 2.30 5.71 7.53 8.81
status-error 3.78 5.88 4.59 5.85
status-stopped 2.79 5.73 6.23 7.40
status-running 3.64 5.99 4.77 6.75
status-warning 3.98 5.46 4.35 9.81
status-redirect 4.34 7.86 3.99 5.64
status-no-response 3.98 6.69 4.34 6.71

Which mode a family fails in is not a property of the mode. destructive fails in dark alone, the six above it in light alone, and the last three - the mode-consistent indicators added for HTTP status - in both, because a value tuned to read as a dot on either card is by construction near the middle. That spread rules out "a dark-mode bug" as the diagnosis: the single cause is the fill token standing in for the foreground one, and which mode it shows up in just depends on where that fill sits relative to the surface. destructive on bg-destructive/10 and on bg-background measures worse still (4.14 / 4.43 light, 1.69 / 1.99 dark), so a tinted error chip is the worst case, not the safe one.

status-warning used to be recorded here as an exception with no -text variant. It has one - --status-warning-text, holding the same value as --warning-text in both themes, which is why its -text column repeats that row's figures rather than stating a second number for one colour. The bare amber is the family's worst case rather than its safe one: 3.98 on the card, 3.63 on --background, 3.38 on --muted / --accent and 3.54 on its own /10 tint, so it clears the 3.0 icon floor everywhere and the 4.5 text floor nowhere. The -text variant clears both on all four (5.46 / 4.98 / 4.64 / 4.86 light). Its worst is the --muted case the light-amber floor above is quoted from, and the two figures differ in the last place - 4.64 computed from the declared triple, 4.63 measured in the browser, which quantises to 8-bit channels first. That is the size of the gap between the two methods across this whole page.

Icons count. 1.73 fails the non-text threshold as surely as the text one, so a red AlertCircle is in scope, not just the sentence next to it. So are opacity variants - text-destructive-text/50 on an error icon is a fainter version of the problem, and on an error affordance the fading is working against the point anyway. Drop the opacity rather than carrying it over.

One measured surface is worth calling out because it does not reach 4.5 even after the fix. The row-action delete button (rowActionDestructive) hovers on --accent-active, where the fill token gives 3.66 light / 1.27 dark and destructive-text gives 4.12 / 3.96. Those clear the 3.0 icon floor but not the 4.5 text floor, and the variant is icon-only by design - every call site renders a Trash2 at size="icon". Putting a text label in it would stop it passing.

app/src/components/ui/status-color-tokens.test.ts enforces this: it fails on any text-<family> in app/src (including hover:/focus: prefixes and /NN opacity forms) while allowing bg-*, border-* and *-foreground, which are correct uses of the fill token. It reads the families out of index.css - every token declaring both a bare value and a -text one in the base palette - rather than listing them, so a family added to the stylesheet arrives guarded. A hand-written list held seven while the stylesheet had ten.

Non-text contrast (WCAG 1.4.11) is a separate 3.0 bar, and it applies to things text contrast never touches: focus rings, control boundaries, and the states of a control. Measured in the running app:

what light dark verdict
--ring vs every surface 4.57–5.39 5.11–6.75 passes comfortably
--border / --input vs card 1.30 1.00 decorative only - see below

A divider inside a card needs border-border-strong. --border is tuned for the canvas - its dark value is commented "= rgba(255,255,255,0.07) on dark canvas", where it measures 1.14. Measured against the surfaces it is actually used on:

canvas panel card muted
--border 1.14 1.08 1.00 1.16
--border-strong 1.47 1.39 1.28 1.11

At 1.00 the divider is the same colour as the card and simply is not there in dark mode. --border-strong gives 1.28, which is what --border itself achieves on a card in light mode - so the strong token on dark reads the way the default token reads on light. This is not enforced by a test: whether a border sits on a card is an ancestry question a source scan cannot answer.

Ask what the box sits on, not what it is. A card's own outline is fine on border-border, because that edge faces the canvas, where the token measures 1.14 as designed. The 1.00 case is a rule inside a card. Both look like border border-border bg-card in the source, and only the ancestry tells them apart - so measure the parent's computed background before calling one a defect.

border-rule: let the surface pick the token

Because that ancestry question has to be answered correctly every single time, and was not - the same defect was found and fixed one component at a time about ten times - the answer now lives in the stylesheet instead of in this document.

A surface class sets its background and the --rule that reads on it. A divider says border-rule and inherits the right value, including through nesting: a surface-sunken slab inside a surface-card re-declares --rule, so rows in the slab get the slab's colour while the card's own dividers keep the card's.

<div className="surface-card">            {/* declares --rule */}
  <div className="border-b border-rule">  {/* resolves to the card's */}
    <div className="surface-sunken">      {/* re-declares --rule */}
      <div className="border-b border-rule" />  {/* resolves to the slab's */}

Measured in the running app, both themes:

surface light dark
canvas / panel (:root default) 1.186 1.143
surface-card 1.304 1.278
surface-sunken 1.356 1.343

The card rule is per theme on purpose. No single token serves both: --border is right in light (1.304) and invisible in dark (1.003), while --border-strong fixes dark (1.278) but overshoots light to 1.553. --rule is a variable, so each theme gets the token that lands on ~1.29 - the parity this section already asks for. Being able to do that is the point of centralising.

This does not make the mistake impossible; it makes it enumerable. A border-rule whose ancestors declare no surface falls back to the :root default and is invisible on a card again - so the guard pins the declarations (surface-rule.test.tsx), not the border-rule classes, which prove nothing on their own. Resolved colour is a computed-style question and is checked in the browser.

Definitions live in app/src/index.css under "Surfaces, and the rule colour that reads on each". Adopted by the response-viewer family, the import dialog (ImportModal.surface-rule.test.tsx guards the latter's declarations) and the command list, whose Command root declares bg-card surface-card so the input's divider, the section separators and the footer can all say border-rule (command.chrome.test.tsx); the rest of the app still uses explicit tokens and can migrate as it is touched.

A surface a component only looks like it has is the case the command list answers: it painted bg-popover, which no surface class declares. --popover and --card hold the same three numbers in both themes, and their foregrounds do too, so it declares the card rather than gaining a surface-popover that would be a second definition with nothing behind it. Check the values before copying that move - two tokens that merely look alike are two surfaces.

One trap the import dialog documents: surface-card cannot simply replace a background utility that a primitive already sets. The surface classes live in @layer components, and a utility (bg-background on DialogContent) outranks any component-layer class - while tailwind-merge does not recognise surface-card as a background class, so it will not strip the primitive's utility either. On such an element write the pair bg-card surface-card: the utility wins the cascade, the surface class contributes the --rule declaration, and both set the same colour.

On --muted there is no border to pick. It is the one surface where --border-strong is weaker than --border: --muted (L 16%) sits between them (L 10% and L 18%) in dark, so the usual escape hatch makes the edge fainter still, 1.11 against 1.16, and neither is visible. In light the pair inverts, 1.11 and 1.32, so whichever token is chosen one theme gets no edge at all. A bg-muted block has to be defined by its fill instead, which separates from a card at 1.18 light / 1.15 dark - the treatment the console log slabs and the response viewer's boxed metadata use. --accent carries the same value as --muted in both themes and behaves identically.

That fill-not-border guidance is about a block separating from its parent. An edge that is itself the point is different: the import drop zone's dashed border is a drag-target affordance, and it uses surface-sunken + border-rule - the alpha-of---foreground rule is the one edge that does work on this fill, and the strongest available in both themes (1.356 light / 1.343 dark). The border-border-strong it previously kept "for prominence" is in fact the faintest option there in dark (1.11, per the table above). Decided in issue

69; the detected-collections preview list in the same dialog got the same

treatment. | switch off track vs card | 1.55 | 1.28 | failed | | switch off thumb vs its track | 1.41 | 12.35 | failed in light | | switch on track vs card | 5.39 | 5.89 | passes |

The focus ring needed no work, which is the one that matters most for keyboard users - worth knowing so nobody "fixes" it.

--border at 1.00–1.30 is deliberate and stays: it is a seam between surfaces, not the thing identifying a control.

--input is not in that category, and the note above used to lump it in. For a field with bg-transparent - Input, Textarea and SelectTrigger all are - the border is the thing identifying the control, so 1.4.11 applies to it. In dark mode it was 240 6% 11%, byte-identical to --card: a boundary of 1.00, absent rather than faint. Light mode masked the same weakness because shadow-sm gives a light field an edge and a shadow contributes nothing on a dark ground.

It is 240 6% 26% now - 1.64 on the card, 1.79 on the panel, 1.89 on the background. A real boundary, still quiet, and still short of 3.0: reaching that needs ~42% lightness, which turns every input into a hard outline. Recorded as a known gap rather than claimed as a pass. Where a boundary is the only identifying information, it needs its own colour rather than the border token - which is exactly what the switch needed. Its off state now colours the 2px border it had already reserved, with subtle-foreground (3.17 / 3.34), the faintest tier that clears the bar. The fill stays quiet on purpose; muted-foreground would pass at 5.61 / 6.77 and make an off switch read almost as loudly as an on one.

Measure with transitions frozen. An unchecked switch first measured 13.58 in light, which was a transition-colors mid-flight reading, not a colour.

Variable Scope Colors (Categorical)

Variable scopes use a categorical palette (not semantic status): a distinct hue per scope, mode-adaptive so it reads on both light and dark surfaces. Used as text/icon/border at full strength and as tinted backgrounds via opacity.

Like the method colours, a scope is painted as text on its own 10% tint (the count badges), so the light values are solved against that wash - at green-700 / orange-700 the badge text measured 4.04 and 3.61.

/* Light */
--scope-global:      142 72% 26%;
--scope-collection:   21 90% 35%;
--scope-environment: 217 91% 45%;   /* blue-600 - already clears it */

/* Dark */
--scope-global:      142 69% 58%;   /* green-400 */
--scope-collection:   27 96% 61%;   /* orange-400 */
--scope-environment: 213 94% 68%;   /* blue-400 */

Utility classes (text-, bg-, border-, ring-, accent-): text-scope-global, bg-scope-collection/10, border-scope-environment/20, …

Scope Token Convention
Global scope-global icon/text solid; bg-scope-global/10 tint
Collection scope-collection icon/text solid; bg-scope-collection/10 tint
Environment scope-environment icon/text solid; bg-scope-environment/10 tint

Never hardcode bg-green-50 dark:bg-green-950 pairs for scopes - use the token at an opacity (/10 background, /20/30 border, full for text/icon).

Run / Status Indicator Colors

Run, connection, and test status (dots, left-bars, pills, status icons) use a cohesive --status-* set. Unlike everything else, these are mode-consistent - the same value in light and dark - because a status dot should read as the same "good / bad / busy" signal on either surface. Distinct from --success / --destructive, which are tuned for banner text and button fills respectively.

--status-success: 142 71% 45%;   /* green-500  - completed / connected / pass */
--status-error:   0 84% 60%;     /* red-500    - failed / test fail */
--status-running: 217 91% 60%;   /* blue-500   - running */
--status-stopped: 25 95% 53%;    /* orange-500 - stopped */
/* pending → text-muted-foreground / bg-muted-foreground */

/* The HTTP-status half of the same family, added with that vocabulary below.
   Amber cannot follow the -500 convention - see the note further down. */
--status-redirect: 258 60% 62%;     /* violet  - 3xx, redirect */
--status-no-response: 240 5% 52%;   /* neutral - status 0, nothing came back */
--status-warning: 38 92% 36%;       /* amber   - 4xx, client error */

A status colour has three jobs, and three tokens. The base value above is tuned as an indicator - a dot, a bar, an icon - where only 3:1 is required. Reuse it as text or as a solid chip and it fails: status-success measured 2.21:1 as 12px text on the light panel and 2.30:1 as white-on-fill.

Job Token Example
Dot, bar, icon, tint, border --status-* bg-status-success dot
The colour is the text --status-*-text text-status-error-text
Solid chip under a white label --status-*-fill bg-status-success-fill

Only -text is mode-adaptive (light needs a darker value, dark a lighter one). The base indicators and the -fill chips stay mode-consistent, so a green dot and a "200 OK" chip read identically on either theme. This is the same split --primary / --primary-fill already uses.

Utility classes (text-, bg-, border-): text-status-success-text, bg-status-error-fill, border-status-running/25, etc. Use --warning for "expiring / caution" indicators (amber) and --success for success banners.

Which family a surface belongs to is decided by what it describes, not by the colour it wants. Anything painting the lifecycle state of a run, a connection or a service - connected, running, completed, stopped, failed - takes --status-*, including when that state is drawn as text or as a glyph rather than as a dot (text-status-success-text). The Dock's connection light and its running-services chip, the Services panel dot, the mock-server glyph, the MCP server's Running / Stopped badge and the Welcome screen's recent-run labels are all that case. --success / --warning / --destructive keep everything else, and the boundary is worth naming because several of them look close: banner text, form and field errors, a schema's fetch or validation outcome (the context bar's GraphQL state, the body panel's schema badge), metric readouts such as the dashboard's "ramp off target" and ThroughputTwinCard's delta chip - a number against a target is not a lifecycle - and action affordances such as the Dock's "Restart pending", which announces a pending setting rather than a state. --success-text and --status-success-text are the same value in both themes, so on the green surfaces this distinction is not visible - it is only greppable, which is what it is for: the family is how a future reader finds every status surface, and one that answers a different name is one it will miss.

The same set also colors HTTP response severity and latency thresholds, since those map onto the same hues.

HTTP status classes have their own vocabulary, in constants/http-status.ts. Never re-derive it: httpStatusClass(code) gives the class, STATUS_CLASS_STYLE[class] gives the utility for the surface role you need (fill / text / tint / indicator). Guarded by http-status.test.ts, which fails if a component classifies a code and picks a colour inline.

Class Codes Family
success 2xx status-success
redirect 3xx, and 1xx status-redirect (violet)
client-error 4xx status-warning (amber)
server-error 5xx status-error
no-response 0, and anything not a valid code status-no-response (neutral)

This table has now been corrected twice, in opposite directions, so the reasoning is recorded rather than the conclusion alone. It previously described the response badge's mapping: 3xx on status-warning-fill, 4xx on status-stopped-fill. That ramp reads as principled - green to amber to orange to red - but it packs four classes into 38deg-0deg of hue, and measured as OKLab distance three of its ten pairs collide: 3xx/4xx at 0.073, 4xx/5xx at 0.095, and 5xx against a connection failure at 0.000, because both used status-error-fill. The set above has no pair under 0.144.

3xx is violet because that is where the wheel has room. Excluding the hues the other classes own and requiring 3.0 on both card surfaces, the best-separated free band is 262-294; violet scores 0.202 against its siblings where blue manages 0.134, and blue is already --status-running, which appears in the same History row. Hue 258 rather than 262 so it matches --chart-3, which is what the dashboard chart paints 3xx with.

A violet accent scheme (Aurora) sits 0.068 from it, which sounds disqualifying and is not: every existing status indicator already collides with some accent (--status-stopped is 0.023 from Sunset), and none of those is a defect, because a status dot and an accent button are different UI roles. The 0.10 bar is a chart-series rule, where colour is the sole encoding within one plot.

--status-warning completes a family that only ever had a -fill, which is why the history tiles used raw yellow-700. Amber cannot follow the -500 convention: amber-500 (38 92% 50%) measures 2.14 on a light card. The indicator sits at 38 92% 36%, only 3 points from its own fill - amber is squeezed from both ends, which is a property of the hue, not a mistake.

The status-code chart uses this family too, through dedicated status-* roles in uplotTheme. The other charts keep the generic success / warning / destructive roles, and must: those are a series palette wearing semantic names, and the same three also paint p50 / p95 / p99 and the error-rate area. Repointing them would recolour the latency charts, which have nothing to do with HTTP status.

That distinction was got wrong first time round. The status chart borrowed categorical for 3xx and muted for a failed connection, and this document claimed the chart therefore "taught the same violet" as the response badge. It did not: categorical is --chart-3, which also paints the p99 scatter, the HDR distribution, the latency breakdown and the throughput area. So violet meant "the categorical series in this plot", not "redirect", and a user could not learn the association from a dashboard where violet is throughput one chart higher. muted had the same problem in reverse - it made "nothing came back" read as de-emphasised rather than as an outcome of its own.

Latency uses status-runningstatus-stoppedstatus-error for normal → slow → danger (LatencyMetric.tsx).

Decorative categorical palettes (the one token exception)

A surface may use a fixed decorative palette to give items a stable identity by color rather than to signal state - the same idea as --chart-*. Such a palette may keep Tailwind hue utilities (with dark: variants) instead of tokens, because it never responds to theme and carries no semantics.

The list is currently empty. Everything - state, status, scope, semantics, and categorical identity alike - uses tokens.

Three entries were removed because they no longer describe the code. The per-section Settings accent palette is gone; there are zero pink/purple/cyan utilities left under modules/settings/. The console's Pre-request and Test script groups now use status-running-* and status-success-* tokens rather than raw blue-500 / green-500, because the raw values were theme-blind and measured 3.76 and 2.22 in light mode; the console body is bg-muted, not a fixed zinc-900 terminal. And the timing phases - DNS / connect / TLS / TTFB / download - are covered below.

The history overview tiles were on this list too, and should not have been: they encode HTTP severity, which is state. They use STATUS_CLASS_STYLE.

Timing phases

The five network phases are a categorical set, and they were the last entry here: the history breakdown tinted each tile with an explicit bg-blue-50 dark:bg-blue-950/30 pair. They are --chart-* now, declared once in components/shared/response-viewer/timing-phases.ts:

Phase Token
DNS --chart-2 (teal)
Connect --chart-4 (amber)
TLS --chart-5 (rose)
TTFB --chart-3 (violet)
Download --chart-6 (moss)

Two rules come with that table. Never --primary or --chart-1 - both follow the user's accent, so either can land on a neighbouring phase's hue; under the green scheme --primary and --success sat three lightness points apart and two of the five phases rendered as one swatch. And colour is only carried where it is the encoding - the timeline segments in the builder's timing tab and the bars in the dashboard's waterfall, where hue is how you tell the phases apart. The tile grid (TimingPhaseTiles) is deliberately neutral: each tile already has the label written in it, so a hue there was decoration paying for an exception.

The lesson worth keeping: an entry on this list is a claim about the code, and it decays. A raw palette class here is only defensible if it comes with a dark: counterpart - a single value cannot serve a white card and a near-black one, which is why every theme-blind foreground found in this tree failed in light mode and passed in dark.

palette-tokens.test.ts now guards all of modules/ and components/ (issue #1693), not just the request/response tree it was cut for. The settings restart banner was the last dark:-paired holdout, and it moved to the --warning family the "Pending" chip one card below it already used - which is the argument for widening: the token existed, the banner just predated it. Two exemptions are listed in the guard, both text-purple-500 marking a load test (RunItem's bolt, LoadTestDetail's P99 arrow). That is a kind rather than a status, so the app's one violet token - --status-redirect, which means 3xx - would be the wrong word, and both were measured where they sit (4.36/4.59 and 3.50/3.66 against the 3.0 icon bar) rather than assumed.

HTTP Method Color Tokens

Always render methods with MethodBadge (components/shared) - never a hand-rolled span or Badge with inline colours. It previously rendered seven different ways (three sizes, two weights, some tinted, two with no colour at all), and the history sidebar kept a private copy of the colour logic that omitted getMethodColor's fallback, so an unrecognised method silently lost its colour.

<MethodBadge method={request.method} />                        // tinted chip, 10px
<MethodBadge method={request.method} size="md" />              // 11px, beside body text
<MethodBadge method={request.method} variant="text" />         // colour only, dense rows
<MethodBadge method={m} variant="text" muted={!isActive} />    // secondary context

The badge variant is a fixed-width column, not a chip that grows with its letters. Every list that shows one puts the badge first and the name after it, so an intrinsic-width chip started GET names at one x, POST names at another and DELETE/OPTIONS further still - a ragged left edge down the collections tree, the history sidebar and the welcome recents at once. The chip is 5ch wide plus its own padding and border (ch, so one class serves both sizes and tracks the mono font it already uses), five being the longest label that stays whole - PATCH. The label is centred, and a longer method (the engine and a pasted curl -X both pass arbitrary strings) truncates inside the chip with the full method on the element's title, rather than widening it and re-breaking every row around it.

The three standard methods longer than the column are abbreviated. DELETE, OPTIONS and CONNECT render as DEL, OPT and CONN - the substitutions Postman, Insomnia and Bruno all use - and the full name is one hover away on the same title that reveals a truncated custom method. The abbreviation table lives beside getMethodColor in utils/helpers.ts (METHOD_ABBREVIATIONS, read by getMethodDisplayLabel), for the same reason: one value, one meaning everywhere. The column paid 7ch on every row for two verbs almost nobody has in a tree; at 5ch the collections sidebar gives the request name roughly a third of the row back.

Method Column label Title on hover
GET, POST, PUT, PATCH, HEAD as written none
DELETE DEL DELETE
OPTIONS OPT OPTIONS
CONNECT CONN CONNECT
longer custom (PROPPATCH, …) truncated full method

The width is not an opt-in prop - the primitive enforces it, which is the whole reason this component exists. The text variant keeps its intrinsic width: it sits inline in running text, where a fixed column would punch holes, and a caller that wants a column there sets its own. Two callers do: the import preview (w-10) and the collections tree row (w-[5ch], matching the badge column so DELETE still fits). The tree row uses the text variant because a bordered chip on every row was a second shape competing with the tree's own hover fill and selection ring; colour alone carries the signal there.

Method colors are design tokens, not hardcoded hex values. They are mode-adaptive - hue and saturation are identical in both themes, so a method always reads as "its" colour; only lightness shifts.

They have to be. MethodBadge paints one value three ways at once - as text, as a 10% tinted background, and as a 30% border - so the badge text sits on a wash of itself and contrast comes down entirely to lightness. As a single mode-consistent set, 10px badge text failed AA in both themes at once: PUT measured 1.97:1 in light, PATCH 2.86:1 in dark. Each value below is solved against its own tint over the worst surface of its theme, and clears 4.6:1.

/* light */                      /* dark */
--method-get:     142 76% 25%;   /* 142 76% 45% - green  */
--method-post:    217 91% 45%;   /* 217 91% 63% - blue   */
--method-put:      38 92% 28%;   /*  38 92% 45% - amber  */
--method-patch:   262 83% 45%;   /* 262 83% 71% - purple */
--method-delete:    0 84% 42%;   /*   0 84% 65% - red    */
--method-head:    199 89% 31%;   /* 199 89% 45% - cyan   */
--method-options: 240  5% 41%;   /* 240  5% 58% - gray   */

Utility classes (defined in index.css, available as Tailwind class names): - Text color: .method-get, .method-post, .method-put, .method-patch, .method-delete, .method-head, .method-options - Background: .bg-method-get, .bg-method-post, etc.

These exist but nothing in src/ currently uses them - prefer getMethodColor below, which is the one path MethodBadge, the tab strip and the method selector all take. A second way to spell the same colour is a second place for it to drift.

getMethodColor(method) in app/src/lib/method-display.ts returns var(--method-xxx) - the raw CSS variable reference. Callers construct full color values:

const c = getMethodColor(method); // e.g. "var(--method-get)"

// Solid color (text, stroke):
color: `hsl(${c})`

// Tinted background (~10% opacity):
background: `hsl(${c} / 0.1)`

// Tinted border (~30% opacity):
borderColor: `hsl(${c} / 0.3)`

Do not hand-roll that span for a method. This section used to carry the badge's markup as a pattern to copy, naming RunItem and DashboardHeader as its users; both have rendered MethodBadge for some time, and the copy here had already drifted (font-bold against the primitive's font-semibold, rounded against rounded-md, and no fixed width at all - so a copy of it would have reintroduced the ragged left edge the primitive now prevents). A hand-rolled copy of a primitive does not receive the primitive's fixes. Render MethodBadge; the three hsl() forms above are how it, the tab strip and the method selector each build a colour from getMethodColor.

MethodSelector used to carry its own METHOD_COLORS map of those utility classes - a second source of truth for the same seven colours, and the kind that quietly stops matching. It now takes the getMethodColor path above, like MethodBadge and the tab strip:

style={{ color: `hsl(${getMethodColor(request.method)})` }}

Charts

A cohesive categorical set - chart-1 tracks the active accent, then five evenly-spaced hues (teal / violet / amber / rose / moss) shared across modes and tuned only in lightness for each ground.

/* Light */
--chart-1: <accent>;         /* tracks --primary */
--chart-2: 172 66% 38%;   /* teal */
--chart-3: 258 55% 55%;   /* violet */
--chart-4:  38 88% 48%;   /* amber */
--chart-5: 340 72% 50%;   /* rose */
--chart-6: 105 58% 34%;   /* moss */

/* Dark - same hues, lifted for the dark ground */
--chart-1: <accent>;
--chart-2: 172 60% 52%;
--chart-3: 258 78% 72%;
--chart-4:  38 90% 60%;
--chart-5: 340 74% 62%;
--chart-6: 105 52% 50%;

--chart-6 was added for the response timing waterfall, which needs five series at once. With four fixed hues available, two phases had been reaching outside the set - TTFB to --primary and Download to --success - and under the green accent those two land on the same hue (142) three points of lightness apart, so two of five phases rendered as the same swatch. Moss sits in the widest gap in the ring (38 -> 172), 67 degrees from its nearest neighbour.

A series never takes --primary or --chart-1. Both move with the user's accent, so either can drift onto a neighbouring series in one scheme and not another - which is invisible when you are looking at the scheme it works in.


Color Schemes (Accent Themes)

Applied via data-color-scheme attribute on <html>. Each scheme sets --primary, --primary-fill, --primary-foreground, --ring, --variable, and --chart-1. The authoritative per-scheme values (deep fill + mode-adaptive accent) live in app/src/index.css; the table below is an approximate guide.

--primary and --primary-fill are not the same thing, and the split is what keeps labels legible. --primary-fill is the solid button background - the Button default variant, badges, tooltips and the Send button all use it - and it holds one value in both themes, so the white label on it never changes contrast. --primary is the accent as text, focus ring, --variable and --chart-1; it brightens in dark mode because those all sit on a near-black card and have to read there. Every bare bg-primary in the app is a translucent wash (/10, /15, /30), never a solid fill under white text.

Pinning --primary to its light value would look like a contrast fix and is in fact a regression: accent text on the dark card would fall from APCA Lc 44–69 to Lc 22–37.

Secondary text on the fill is a tint of --primary-foreground, never --muted-foreground. The muted token is tuned against the canvas, so on the accent fills it measures 1.04–2.27:1 - on ocean, the default, it is 1.04, which is not a de-emphasis but a disappearance. A tooltip's second line (a shortcut, a URL, the source of a value) therefore uses the TooltipHint primitive, which holds the one tint; the same argument surface-sunken makes for its --rule, on a filled surface instead of a raised one. The hint cannot out-read the label it is secondary to - white on sunset is the ceiling at 3.6:1 - so the bar is 2.5:1 on every scheme, checked in tooltip-hint-contrast.test.ts.

A value and the hint that sources it stack; they never share a flex row. TooltipContent is capped at max-w-xs, a value that wraps on break-all has a min-content width of about one character, and a hint has to keep its intrinsic width to stay readable - so a row of the two hands its whole width to the hint and leaves the value a vertical strip of letter fragments. It takes only a long environment name beside an unbroken value (issue #1195), or a note carrying the user's own data, such as a declared column list. TooltipValue holds the stacked shape and takes the hint as a prop, so a call site using it cannot express the row; the one-line alternative - min-w-0 flex-1 on the value plus a truncated hint - was rejected, because a clipped source name loses the answer to "which environment". A short label beside a short one (TooltipIconButton's shortcut hint) has neither ingredient and stays a row. → tooltip-value-layout.test.ts reads every tooltip block for the shape.

VariablePopover's source line is the same rule, one primitive over (issue

1320). It used to share the footer with the Enter and Esc keycaps - a

truncate span beside a shrink-0 chip group - so "Shopify QA - expiring tokens" clipped exactly as an unbroken tooltip value would. The source now stacks under the header, full width, with nothing beside it to hand its space away: a source name never shares a row with something that will not shrink, whether the row is a tooltip's or a popover's. Because TooltipContent cannot see inside a Popover, this one is guarded by a rendered-class check in variable-popover.test.tsx rather than by tooltip-value-layout.test.ts's block scan.

The root TooltipProvider (main.tsx) sets disableHoverableContent. Every TooltipContent in the app is read-only text - no tooltip carries a link or a button - so none needs the grace-area gap hoverable content exists for. Radix's default builds that gap as a polygon from the pointer's exit point to the content's edges and only closes once a later pointermove lands outside it; a fast flick across two adjacent triggers (the rail's icons, stacked with no gap) can exit the first with its last tracked position already over the second, and no further move ever lands outside the hull - the first tooltip stays open and the second never does, until some unrelated move happens to land outside it. disableHoverableContent closes on leave immediately instead, which is also the macOS system tooltip's own behaviour. → tooltip-delay.test.tsx

Scheme Light (--primary = --primary-fill) Dark --primary Dark --primary-fill
sunset 24 90% 46% 24 95% 58% 24 90% 46%
sky 192 95% 36% 188 90% 52% 192 95% 36%
ocean (default) 217 80% 48% 217 90% 66% 217 80% 48%
forest 142 72% 33% 142 65% 52% 142 72% 33%
aurora 262 55% 54% 258 88% 76% 262 55% 54%
coral 0 68% 54% 0 80% 68% 0 68% 54%
magenta 305 72% 45% 305 85% 70% 305 72% 45%
graphite 220 12% 46% 220 15% 72% 220 12% 46%

Every scheme also resolves --primary-text (the accent as a label). It tracks --primary for all of these except graphite, which sets 220 12% 26% light and 220 15% 86% dark - see Primary (Accent Color) above for why.

Adding a scheme. Edit constants/color-schemes.ts and index.css - nothing else. color-schemes.test.ts asserts the two agree, in both themes, because a missing block fails silently: the picker offers a swatch that inherits :root and quietly does nothing.

The value has to clear two bars. As a fill it carries a white label, so aim for the band the existing schemes occupy - APCA Lc 68–84 - and keep the fill at 3.0+ against --card so the button still reads as a control. The dark --primary is text, so it wants Lc 44–69 against the dark card. Check the new hue is more than about 0.10 OKLab ΔE from every existing scheme and from the semantic status colours, or it will read as a duplicate in the picker. magenta sits at ΔE 0.153 from aurora; graphite is distinct by being the only desaturated option.


Typography

Fonts

Role Family Weights bundled Source
UI / body default Space Grotesk 400, 500, 600, 700 @fontsource, in the app
UI / body alternate Inter 400, 500, 600, 700 @fontsource, in the app
Code / mono default JetBrains Mono 400, 500, 600, 400 italic @fontsource, in the app
Code / mono option Fira Code 400, 500 @fontsource, in the app
Code / mono option IBM Plex Mono 400, 500 @fontsource, in the app
Code / mono option Space Mono 400, 700 @fontsource, in the app
/* app/src/index.css */
@import "./fonts.css";

app/src/fonts.css holds the @fontsource @import lines for all six families - the weights bundled, the subsets, and why.

body { font-family: var(--font-sans); } /* default: "Space Grotesk", system-ui, sans-serif */
/* mono via font-mono Tailwind class, or .font-code utility */

The faces are bundled rather than fetched because a stylesheet in index.html's head is render-blocking and the window is only shown on first paint, so the fetch delayed the window appearing at all - measured at ~12.8s on a network that black-holes the request. Nothing about how the app renders changed with the move: the weights bundled are exactly the ones the old css2 URL asked for, so font-mono font-bold stays browser-synthesised for JetBrains Mono, Fira Code and IBM Plex Mono, same as before. Every subset each family ships is bundled too, which is what Google served on demand; behind their unicode-range a file is still only read when a character needs it, so the subsets beyond latin cost installer bytes and no startup work.

User-selectable UI font + scale. Settings → Appearance → Interface lets the user pick the sans/body face (Space Grotesk / Inter / System / JetBrains Mono) and an interface scale - a slider over 80% to 200% in 10% steps, which covers the 125-150% accessibility band the three fixed presets it replaced (Compact / Default / Comfortable) topped out below. Font swaps the --font-sans custom property (so body + every font-sans utility follow); scale sets the page zoom factor (Electron webFrame, CSS zoom fallback in the browser). Both live in appearance-store (source of truth constants/appearance.ts), persisted to localStorage, and applied pre-paint in index.html. Code/mono text stays JetBrains Mono regardless.

Interface density (issue #1670) is a fourth Interface control, next to Roundedness: Default and Comfortable, toggled by a data-density attribute on documentElement rather than a computed value, because the two densities are whole --spacing values declared in index.css (see Spacing Scale below) with nothing for a resolver function to pick between. The name Comfortable also named a discontinued interface-scale preset (a 1.1x zoom factor); the two are unrelated settings under different storage keys, and the scale preset has not been offered since the slider above replaced it.

The View menu's Ctrl/Cmd + - 0 drive that same setting rather than Chromium's own zoom, so a keyboard zoom persists across a restart and "Actual Size" means 100% because that is the default setting, not because it bypasses it. The code font size (Settings → Editor) stays an independent control and composes with page zoom.

Type Scale Conventions

Use Size Weight Class
Section label / eyebrow 11px semibold, uppercase, +tracking text-label font-semibold uppercase tracking-[0.06em] text-muted-foreground
Hero metric value 34px bold, tabular text-hero font-bold leading-none font-mono tabular-nums
Secondary metric value 22px bold text-metric font-bold font-mono
View title 20px semibold text-xl font-semibold
Tile metric value 18px bold text-lg font-bold
Title / small heading 15px semibold text-md font-semibold
Body / default 13px regular text-sm
Small label 12px medium text-xs font-medium
Micro / badge (mono) 10–11px mono semibold text-micro font-mono font-semibold
Micro / badge (UI face) 10–11px semibold text-micro font-semibold
URL / path 12–13px mono text-xs font-mono

No font size is written as an arbitrary value. Every step in the table above has a name, and type-scale.test.ts fails on any text-[Npx] in app/src. The last two exceptions closed in #1692: 11px and 10px - the app's two most-used sizes, at 188 and 60 call sites - are --text-label and --text-micro now, joining --text-hero (34px) and --text-metric (22px) before them, which closed the same way for the same reason: a named step arrives with its line-height, an arbitrary one does not.

A step whose name is not a size word has to be registered in cn(). text-<x> is either a font size or a text colour, and tailwind-merge tells the two apart from a list of size labels it ships - so it read text-hero as a colour and dropped it from cn("text-hero …", "text-foreground"), leaving the dashboard's largest number at body size with nothing in the source to look wrong. lib/utils.ts extends the merge's font-size group with the app's own steps; text-md never showed the defect only because "md" is already one of the labels it knows. Add a step, add it there, and cn-font-size.test.ts is where that is held.

The micro/badge step is semibold because 600 is the heaviest face the code font ships. fonts.css loads JetBrains Mono - the default --font-mono - at 400/500/600, and Fira Code and IBM Plex Mono at 400/500; only Space Mono, one of four selectable code faces, has a real 700. So font-mono font-bold on a chip renders a synthesised weight for every user who has not picked that one face - which is what this row used to specify while MethodBadge, the primitive that owns the step, shipped font-semibold (#1199).

The same step in the UI face is semibold too, and the reason is the row itself. The mono row settled one half of 10px and left the other half with no row to read, so the chips that are not font-mono picked a weight each - four different values, one pair of them inside a single primitive: VariableScopeBadge rendered font-medium compact and font-semibold full, because compact overrode Badge's base and full did not (#1222). Semibold is that base, so a chip that names no weight is already correct; a chip that names one should name this. Above 600 is refused here for a second reason on top of the synthesised face: at 10px in the UI face the extra stroke closes the counters rather than reading as emphasis.

type-scale.test.ts reads both rows and every 10-11px class string written in src - either face, quoted or a template literal - and fails on a weight above 600 in any of them, so the rows and the app cannot part again. A weight that is not written beside the size is out of its reach, whether it arrives through a cn() argument or from a cva base, so the two primitives at this step are pinned by rendering them instead (MethodBadge.test.tsx, variable-scope-badge.test.tsx).

It is a ceiling and never a floor: a chip that names a lighter weight, or none at all, passes. That is deliberate, and tightening it is not the fix - the two exceptions below live in exactly the shape a floor would have to match, so a floor would fail on a <code> printing a column name. Below the ceiling the row above is the rule and review is what applies it.

Two things at this size deliberately carry no badge weight. A chip that prints a value rather than a label - a column name, a cookie attribute - is the URL / path step and stays unweighted. And a numeric readout may take font-medium to lift one figure above its unweighted siblings, as the timing waterfall and the phase percentiles do; that is emphasis inside a row, not a badge.

The app had drifted to 182 arbitrary sizes across 11 distinct values. Half duplicated a step that already existed - text-[12px] is text-xs, text-[13px] is text-sm - and in doing so skipped the paired line-height: 34 of 36 and 13 of 16 set no leading-*, so they inherited the parent's while their text-sm siblings got 18px. Same size, two rhythms, chosen by nobody. Seven were half-pixel (text-[10.5px], text-[11.5px]), which no scale contains and which render soft on a non-retina display.

--text-md (15px/20px) was added rather than removed: six surfaces reached for text-[15px] independently - the empty and error state titles, a collection name, the response heading, the font picker and the brand mark - which is a missing step, not six mistakes. 13px is body and 16px is heavy for a small heading in a dense tool.

Use text-sm for body, not text-[13px]. Tailwind ships text-sm at 14px, which left the app running two scales a pixel apart - text-sm in ~160 places against text-[13px] in ~18. Rather than migrate every call site, --text-sm is redefined in @theme (index.css) to 13px/18px, so the utility is the documented body size. text-xs already matches the 12px label, so that was the only size that diverged. text-[13px] still works but skips the paired line-height - prefer text-sm.

The heading register stops at 15px, and a heading never names its own size. CardTitle and DialogTitle carry text-md; a card or dialog heading that writes a size is overriding the primitive rather than using it. That is how the app got here (#1202): CardTitle named no size at all, so all 51 card headings in the app named one, 45 at text-base and 6 at text-lg - a settings panel and a report tab heading at 16-18px against 13px body, which is the register Postman and VS Code cap around 14px. Input is text-sm for the same reason: stock shadcn ships text-base md:text-sm, the web workaround for iOS zooming a focused field under 16px, and a narrow desktop window is not a phone.

So text-base (16px) is written nowhere in src, and every step above the 15px title is held by file rather than by rule, because "a number in a tile" is not something a scan can recognise:

  • text-lg (18px) is the tile metric value - bold numbers in muted tiles, five files, no headings.
  • text-xl (20px) is the view title, one per view, and only the two settings views write it. A settings view stacks its title, the description under it, and cards whose CardTitle is 15px; flattening the title into that last step would lose the level, and reusing text-lg would give one step two meanings (#1409).
  • text-metric (22px) and text-hero (34px) are the dashboard's metric values. Nothing in src writes text-2xl or above: 24px was five strays reaching past the step their siblings were designed at.

type-scale.test.ts holds all of it, and holds input.tsx to carrying no responsive size variant. Each allowlisted file is asserted to still use its step, so a file that stops rendering one drops off the list instead of quietly licensing a heading there later.

The type register is a measurement, not a preference. Perceived size follows x-height, not nominal size, and the six bundled faces differ by 13% at the same font-size. Ratios are OS/2.sxHeight / head.unitsPerEm, read from the bundled @fontsource files by appearance.font-metrics.test.ts:

Face Role x-height ratio at 13px at 12px
Space Grotesk UI default 0.486 6.32px 5.83px
Inter UI alternate 0.546 7.10px 6.55px
JetBrains Mono code default 0.550 7.15px 6.60px
Fira Code code option 0.526 6.84px 6.32px
IBM Plex Mono code option 0.516 6.71px 6.19px
Space Mono code option 0.496 6.45px 5.95px
Segoe UI not bundled, the reference 0.50 6.50px 6.00px

Two decisions come out of that table. The 13px body step stays: the default UI face renders a 6.32px x-height there, below the 6.50px a system face gives at the same size, so body text was never what read large - the 16px chrome above was. And the code font default is 12px, not 13px: JetBrains Mono has the largest x-height ratio of the six, which put 13px at 7.15px where an editor shipping Menlo or Consolas at its own default sits between 6.3 and 6.6px; 12px measures 6.60px. Settings → Editor still offers 11 through 16, and the interface-scale slider still multiplies everything: these are the defaults the register is judged on, not a ceiling on the user.

Icon sizing goes on className, not lucide's size prop. Mixing the two hides icons from a scale audit and lets off-grid values (15px) creep in.

The two most common icon sizes are fixed steps, not --spacing multiples (issue #1679, superseding the #1670 decision below). Use size-icon (16px, was w-4 h-4 / size-4) and size-icon-sm (12px, was w-3 h-3) - the app's own legibility floor at both densities, per the Chrome, Target and Icon Floors table under Spacing Scale Conventions. w-5 h-5 still rides --spacing (15/20px at Default/Comfortable): it was not part of the #1679 fix pathway and remains density-scaled until a reason to fix it turns up.

h-3.5 w-3.5, w-3.5 h-3.5 and size-3.5 are banned outright (components/ui/icon-size-token.test.ts, issue #1693). They were a third icon size with no token behind them, spelled three ways across 152 call sites and doing the same job as size-icon-sm in the same rows; all of them are now size-icon-sm, a deliberate step onto the scale from 14px to 12px rather than a translation. size-icon was wrong for them - each sits beside text-sm or smaller text, which is why it was written under the default in the first place.

Superseded decision (issue #1670, kept for history): the shrink applied to icons too, with nothing pinned outside the unit - w-4 h-4 read 12px at Default and w-3 h-3 read 9px, both checked by eye against a live render. The owner's report on 0.32.0 reversed it: 12px and 9px glyphs read as small, not as dense, which is what sent size-icon/size-icon-sm back to a fixed floor.

Spacing Scale Conventions

Every p-*, m-*, gap-*, space-* and h-*/w-* utility resolves to calc(var(--spacing) * n) (Tailwind v4), so the one --spacing declaration in index.css moves every padding, margin, gap and row height in the app at once (issue #1670). Two densities, both in index.css: Default (--spacing: 0.1875rem, 3px/unit) and Comfortable ([data-density="comfortable"] { --spacing: 0.25rem }, 4px/unit - the value Tailwind defaulted to before this variable existed, so Comfortable is 0.30.0's layout exactly, not an approximation of it). Set by Settings → Appearance → Interface → Density, owned by appearance-store (applyDensity), applied pre-paint.

Use Class Default Comfortable
Control gap gap-2 6px 8px
Group gap gap-3 9px 12px
Section gap gap-4 / space-y-4 12px 16px
Card padding p-4 (CardHeader/CardContent/CardFooter) 12px 16px
Dialog padding p-5 (DialogContent) 15px 20px
Drawer row height h-8 24px 32px
Icon (menu/toolbar) w-3.5 h-3.5 10.5px 14px
Icon (panel heading) w-5 h-5 15px 20px

density.test.ts reads both --spacing declarations directly off index.css and reds if either value changes. spacing-scale.test.ts guards Card and DialogContent specifically - the two primitives this issue tightened - rather than scanning the whole tree for p-6/p-8/gap-6/gap-8: several other p-6/p-8 call sites exist outside those two primitives (page-level containers, and the EmptyState/ErrorState shared components) and were deliberately left alone as outside this issue's fix pathway. --titlebar-height stays a literal 32px: it sizes the macOS traffic lights, a fixed platform constant no density setting should move.

Chrome, Target and Icon Floors

Density scales rhythm - row heights, paddings, gaps - not chrome, interactive targets or icons (issue #1679). Three classes of thing have a floor --spacing must not carry below it: a chrome band is an anchor, not a list row; an interactive target has the WCAG 2.2 SC 2.5.8 24x24px minimum; an icon has a legibility floor. Nine named steps, outside the --spacing multiplier, generate real Tailwind utilities (h-band, size-target, and so on) for these. They live in a plain @theme block in index.css, deliberately not @theme inline: inline bakes a literal into each generated utility instead of a var() reference, which would silently disable the Comfortable override below.

Step Class prefix Default Comfortable Used by
--spacing-band h-band 32px 32px Tab strip, drawer header, response toolbar, RailButton
--spacing-band-md h-band-md 40px 40px The URL bar row (as min-h-band-md)
--spacing-band-lg h-band-lg 52px 52px Pane headers: the dashboard header, the Collection Detail header
--spacing-banner h-banner 36px 36px Update banner, recovery banner
--spacing-control h-control 28px 36px Input, Select, Button default, the URL bar's controls
--spacing-control-sm h-control-sm 24px 32px Button sm, toast action, ToggleGroup xs
--spacing-target size-target 24px 28px Icon buttons, close buttons, Switch, checkboxes, CommandSearchBar
--spacing-icon size-icon 16px 16px The app's default icon size (was w-4 h-4 / size-4)
--spacing-icon-sm size-icon-sm 12px 12px The app's small icon size (was w-3 h-3, and h-3.5 w-3.5 / size-3.5 since #1693)

band, band-md, band-lg, banner, icon and icon-sm are theme-independent, the same way --titlebar-height and --dock-height are - a chrome anchor or a glyph's legibility does not become less real at a looser density. The wider two arrived with issue #1688, which moved the last three bands off arbitrary bracketed literals; a band written that way was the one piece of chrome in the app that could follow no token at all, and the rows still breathe with the density setting because their own padding and gaps ride --spacing. header-band.test.ts fails on any element that paints a band (a bottom rule over a panel fill) and sets its height with a pixel literal. control, control-sm and target scale on their own schedule under [data-density="comfortable"], the same mechanism --spacing itself uses - just a different curve, so a control never drops below its own floor at either density. density.test.ts and chrome-floors.test.ts guard both halves of this: the former that the nine steps are declared with these values and none of them is expressed as a calc(var(--spacing) * n), the latter that the chrome bands, interactive targets and icon classes across app/src actually use them.


Geometry

--radius: 0.375rem;      /* 6px - base border radius (default) */
--dock-height: 2rem;     /* 32px - footer status strip */
--rail-width: 2.5rem;    /* 40px - ActivityRail and ContextRail */

--dock-height exists because a fixed element has to know it. The Dock is the last row of the shell column, so the layout keeps everything else clear of it automatically. The toast viewport is position: fixed and anchors to the window instead, so it has to subtract the strip's height by hand - and with a plain bottom-4 it did not, landing 16px off the window floor, inside the Dock's 32px band and covering its lower half, "Connected" and the version string included. Measured in the app: viewport bottom 704px against a Dock top of 688px.

Both sides now go through the token - h-[var(--dock-height)] on the Dock, bottom-[calc(var(--dock-height)+1rem)] on the viewport - so the height cannot change in one place only. jsdom does no layout and cannot measure the overlap, so toast-position.test.tsx guards the reference on each side instead.

--rail-width is the same rule at the window's side edges (#1615). Both ActivityRail (left) and ContextRail (right) read it, so the app can never end up with two window-edge navigation strips of different widths - a single w-[var(--rail-width)] on each is what keeps them in step, the same way one --dock-height keeps the Dock and the toast viewport from drifting apart. Unlike the Drawer and the ContextBar, a rail is not user-resizable: it holds a fixed row of icon buttons, so a token rather than a PANEL_MIN_WIDTH-style stored preference is the right amount of mechanism.

Class Value Follows the setting?
rounded-sm calc(var(--radius) - 4px) yes
rounded-md calc(var(--radius) - 2px) yes
rounded-lg var(--radius) yes
rounded-full pill / circle no - deliberately fixed
rounded-none 0 no - deliberately fixed
rounded Tailwind default no - never use it

Never use bare rounded. It resolves from Tailwind's own default rather than --radius, so it sits at 4px whatever the user picks - measured 4px at 0rem, 0.375rem and 0.75rem alike, while rounded-md moved 0 → 4 → 10. Three had drifted into the MCP settings panel, staying rounded for anyone who had chosen Square. A test (radius-token.test.tsx) now fails on any bare rounded in a class string.

Inline borderRadius escapes the setting too, and no class scan sees it. The uPlot chart tooltip carried borderRadius: "6px", so it stayed rounded on Square and stopped short of the app's own tooltip on Rounded; it is now var(--radius-md), measured 0 / 4 / 10 across the three settings. Inline radii are allowed only as a var(--radius…) reference or a percentage (a circle) - plus the Appearance panel's own roundedness swatches, which must show every option regardless of which one is active. The same test enforces this, across .ts as well as .tsx.

User-adjustable. Settings → Appearance → Interface → Roundedness sets --radius (Square 0rem / Default 0.375rem / Rounded 0.75rem), owned by appearance-store, persisted, applied pre-paint. So rounded-sm/md/lg reshape live. Always use rounded-md/rounded-lg/rounded-sm, never Tailwind's unsuffixed rounded (fixed 4px - it ignores --radius and won't follow the control). rounded-full stays a pill regardless.

Cards and panels use rounded-md. Badges/chips use rounded-sm.

rounded-full is for circles, not for chips. Status dots, spinners, circular icon wells, colour swatches, switch tracks - things whose shape is a circle or a capsule. It is not for anything rectangular that merely looked nicer with round ends: the dashboard header's LIVE / COMPLETED / STOPPED chips were rounded-full while the Badge primitive they otherwise match is rounded-md, so on Square they were the only round things left on the screen. They are rounded-md now.

No test can tell a chip from a dot, so this one is a judgement call at review time. The question to ask: if the user picks Square, should this element go square? If yes it is a chip, and rounded-full is wrong.

The same reasoning rules out rounded-full on controls - a button or dropdown trigger that keeps its pill shape becomes the one thing on screen ignoring the Roundedness setting. Interactive elements take rounded-md/rounded-sm.

A control that renders at more than one box size caps the token by proportion instead of using it bare. Checkbox (ui/checkbox.tsx) is instantiated at 12-28px depending on caller (size-icon-sm, size-icon, size-target), and --radius-md is a fixed length - 10px at Rounded, past half the width of a 12-16px box. Applied bare, a checkbox at Rounded became a circle indistinguishable from a radio button, while the same class on the 24-28px row-enable checkbox stayed an ordinary rounded square: one token, two unrelated shapes. The fix is rounded-[min(var(--radius-md),25%)]: Square (--radius: 0) is unaffected at every size, Rounded caps at a quarter of the box instead of degenerating into a circle, and the cap only ever removes roundness from a small instance - it never adds any to a large one. The next control that varies its own box size (a radio, a colour swatch) should reach for the same min(token, %) shape rather than relearning this.


Animations

Defined in both index.css and tailwind.config.js. All three vayu-* animations have Tailwind shorthand aliases (animate-vayu-spin, animate-vayu-pulse, animate-vayu-fadepulse) in addition to the verbose arbitrary form.

Name Duration Curve Tailwind class Use
vayu-spin 0.7s linear animate-vayu-spin Loading spinners
vayu-pulse 1.6s ease-in-out animate-vayu-pulse Live indicators (100→35% opacity)
vayu-fadepulse 2s ease-in-out animate-vayu-fadepulse Subtle breathe (90→50% opacity)
accordion-down/up 0.2s ease-out animate-accordion-down/up Radix accordion
collapsible-down/up 0.2s --ease-enter / --ease-exit animate-collapsible-down/up CollapsibleContent's height (from tw-animate-css)
fade-in 0.2s ease-out animate-fade-in General reveal
slide-in 0.2s ease-out animate-slide-in Dropdown/panel entry
interaction state 0.15s ease (baseline in index.css) Hover/active colour changes on interactive elements
press feedback 0.1s ease-out (baseline, [data-slot="button"]) scale: 0.98 on :active

Spinner pattern:

<span className="w-3 h-3 border-2 border-white/40 border-t-white rounded-full animate-vayu-spin inline-block" />

Live dot pattern:

<span className="w-1.5 h-1.5 rounded-full bg-green-500 animate-pulse" />

Motion vocabulary (entrances and exits)

One curve pair and one duration per tier, declared once as CSS custom properties in index.css (just above the Dialog presentation rules) so every surface that arrives or leaves reads as the same hand. Decelerate in (--ease-enter), accelerate out (--ease-exit) - a surface arriving should only ever slow down, and leaving is quicker than arriving because the decision is already made.

Token Value Tier
--ease-enter cubic-bezier(0.2, 0, 0, 1) all
--ease-exit cubic-bezier(0.4, 0, 1, 1) all
--dur-panel-in / --dur-panel-out 180ms / 130ms Dialog - the whole view dims
--dur-menu-in / --dur-menu-out 140ms / 100ms Popover, DropdownMenu, Select, ContextMenu
--dur-tooltip-in / --dur-tooltip-out 100ms / 80ms Tooltip - must read near-instant
--dur-icon-nudge / --dur-icon-play 200ms / 280ms Icon motion - a hover nudge that unwinds, a one-shot sequence (x2 for two-phase)

Dialog (.dialog-panel/.dialog-overlay) consumes these directly in hand-written @keyframes - see the comment above them for why it does not use tw-animate-css's stock utility stack (a centring hack it does not need, no overlay-sync problem the others don't have).

Popover, DropdownMenu, Select, ContextMenu and Tooltip keep tw-animate-css's animate-in/animate-out stack (fade + zoom-95 + a small directional slide from the Radix-exposed transform-origin) - there is no centring hack to cancel and no overlay to sync, so replacing it would be a hand-rolled copy of a primitive that already works. What it does not give on its own is Dialog's decelerate-in/accelerate-out asymmetry - both directions run ease at the same duration by default. It reads --tw-duration/--tw-ease ahead of its own defaults, so two small classes are enough: .motion-menu (menu tier) and .motion-tooltip (tooltip tier), each with a [data-state="closed"] variant swapping in the exit curve/duration. Add whichever one to a new anchored-chrome primitive's content class list; never hand-roll its keyframes.

Toast cannot use either mechanism as-is: the store (stores/toast-store.ts) holds a dismissed toast for exactly TIMING.TOAST_EXIT_MS (200ms) before dropping the node, and that number has to equal the exit animation's duration or the node either lingers or gets cut off mid-flight. So toast-variants.ts keeps its own pinned duration-200 (which doubles as --tw-duration, feeding the same animate-in/animate-out mechanism above) and only borrows the curve: [--tw-ease:var(--ease-enter)] data-[state=closed]:[--tw-ease:var(--ease-exit)]. Its swipe-cancel snap-back is a transition-[translate,opacity], not transition-all - see the comment in that file for why all was there and what it actually needed.

.enter-fade is for a plain conditional mount with no Radix data-state to key off - @starting-style gives the "from" opacity for the element's first frame, so it fades in instead of popping on screen. Entry only: React removes a conditionally-rendered node synchronously, so there is no exit half without JS-driven motion, which is out of scope (see Motion, below). EmptyState is one call site among several - see app/src/components/shared/EmptyState.tsx, app/src/components/ui/suggestion-list.tsx and code-editor.tsx's LeaveEditorHint for the plain-mount shape, and LabelSwap (label-swap.tsx) below for the label-swap shape built on the same class. Its duration defaults to the menu tier via --enter-fade-duration, var(--dur-menu-in) - right for the small surfaces above, but a mount that is panel-scale rather than a strip of chrome overrides it at the call site, --tw-*-style: Drawer.tsx's sidebar-page swap (key={drawerView} plus enter-fade [--enter-fade-duration:var(--dur-panel-in)] on the wrapper, so switching Collections/History/Variables/etc. fades rather than pops) is the one call site so far.

TabsContent (tabs.tsx) is the one place .enter-fade sits on an element that does carry a Radix data-state - data-[state=inactive]:hidden toggles the native hidden attribute rather than mounting/unmounting, so there is no conditional-render moment for .enter-fade to key off in the usual sense. @starting-style fires on any change of display type, none to something else included, which is exactly what a hidden toggle is - so the same class still applies, and re-fires on every switch back to an already-visited tab, not just the first. A force-mounted panel (forceMount, e.g. Collection Detail's four draft-preserving tabs) gets the same fade for the same reason: it never truly unmounts, only its hidden attribute flips.

LabelSwap (app/src/components/ui/label-swap.tsx) is for a button/badge/status label that changes text in place - "Send" → "Sending" → "Send". It solves two problems together, both of which a naive key={label} + .enter-fade on the bare text gets wrong: the text fading is not the only thing that changes (the box resizes too, since "Sending" is wider than "Send", and Apple's version never moves the control around it), and the fade needs the DOM node to actually remount for @starting-style to fire again. The fix for the first is TabLabel's (tabs.tsx) width-reservation trick generalised to more than one word: every candidate string in states sits in the same CSS grid cell, invisible and h-0, so the column is sized by the widest one while only the visible text contributes height; aria-hidden="true" on those twins keeps a screen reader from hearing every candidate read out. The fix for the second is key={label} on the live span, which remounts it - and therefore replays .enter-fade - every time label changes. states must be the finite, enumerable set the caller already knows; it is not inferred by watching label over renders (that would start too narrow and still jump the first time a wider state appears), and a data-derived label (a count, a status string from a payload) has no fixed "widest" to reserve, so LabelSwap is the wrong fit for one.

IconSwap (app/src/components/ui/icon-swap.tsx) is the same mechanism for a glyph instead of a word - Copy → Check, Eye → EyeOff, Play → Square, Folder → FolderOpen. Every icon in the icons map renders into one grid cell, the reserve twins invisible h-0 and aria-hidden (every one of them, the live state's included, so the box is the same size whichever state is showing), and key={state} on the live cell remounts it so .enter-fade replays. Two glyphs of the same nominal size are still not the same width of ink, and a pair where one carries a mr-1.5 is enough to shift the label beside it, which is what the reservation prevents. It is a different mechanism from data-icon-motion (Icon motion, below), not a variant of it: a motion animates one glyph that stays itself and is triggered by its owner's hover, a swap crossfades between two glyphs and is triggered by state. IconSwap puts nothing of its own on the nodes it is handed, so an icon can do both. Entry only, for .enter-fade's documented reason, and a state that flips back inside one fade duration reads as a flicker rather than a crossfade - the accepted cost of staying JS-free.

CollapsibleContent (app/src/components/ui/collapsible.tsx) is the one disclosure that animates its own height, and it does it with tw-animate-css's stock collapsible-down/collapsible-up keyed off Radix's data-state. Radix measures the open box into --radix-collapsible-content-height and those keyframes already read it, so there is nothing to hand-roll: the class list is overflow-hidden (what makes the height clip rather than squash) plus data-[state=open]:animate-collapsible-down data-[state=closed]:animate-collapsible-up. The curve is borrowed the Toast way, file-local, because the generated --animate-collapsible-* value reads --tw-ease ahead of its own ease-out: [--tw-ease:var(--ease-enter)] with a data-[state=closed] twin swapping in --ease-exit. The duration stays tw-animate-css's 200ms - the three tier tokens are all about chrome arriving over the view, and inline content pushing its siblings down is not that.

Two things a caller has to know. overflow-hidden is permanent while the section is open, not just while it animates, so this box does clip an outset focus ring - and it deliberately does not answer that with .panel-clip. The same Input, Switch or row-enable checkbox renders both inside a disclosure and outside one, so tucking the ring inward here would give one control two looks depending on where it sits: the clearance-over-tucking rule below, which key-value-parity.test.tsx already guards for the checkbox. A consumer whose control sits flush against the box's left, right or bottom edge adds clearance of its own - px-3 py-3, the way the load-test dialog's disclosures do it. "A collapsed section costs nothing" rests on CollapsibleContent itself, not on a caller-side guard. Radix's CollapsibleContentImpl renders isOpen && children, so closed content is unmounted - hooks stop, queries stop - without context-bar/Section.tsx or ScriptSnippets.tsx re-deriving that with their own {expanded && children}. A manual guard would unmount the instant the trigger toggles and skip the 200ms close keyframe entirely, since Radix would be animating an already-empty box. The one behavioural consequence of leaving it to Radix: a collapsed section's children stay mounted, hooks and all, for the ~200ms close animation before Radix drops them.

Press feedback (scale: 0.98 on [data-slot="button"]:active) uses the standalone scale property, not transform: scale(), so it composes with any transform the element already carries rather than clobbering it - same reasoning as the translate note in the Dialog comment. scale is listed in the same baseline transition: shorthand as the colour properties (background-color, color, border-color, opacity, scale), not a second rule on [data-slot="button"]: a transition: shorthand resets every sub-property, so a second rule at the same :where() specificity would have replaced the colour list instead of adding to it, and every Button's hover fade would have silently stopped transitioning. scale is inert on everything except [data-slot="button"]:active, the only place anything sets it. button-variants.ts deliberately does not carry Tailwind's transition-colors utility - that class lives in @layer utilities, which beats the @layer base baseline, so it would win the cascade and replace that whole list.


Focus & Interaction States

Interactive elements get a keyboard focus ring and hover transition from a baseline in app/src/index.css (@layer base) - do not add per-component focus classes for the default case.

:where(button, [role="button"], a[href], input, select, textarea, summary,
       [tabindex]:not([tabindex="-1"])):focus-visible {
  outline: 1px solid hsl(var(--ring));
  outline-offset: 2px;
}
  • :where() keeps specificity at 0, so any component utility overrides it without !important. The components/ui/* primitives already carry their own focus-visible:ring and keep their appearance.
  • :focus-visible fires only on keyboard/AT focus - mouse users never see a ring.
  • 1px, not 2px. On dense lists and toolbars a hairline reads as considered; a 2px saturated rectangle reads as a browser default.
  • outline follows the element's own border-radius. That means the roundedness setting governs the ring only on elements that already carry a rounded-* class. An element with no radius gets a square ring at every setting - so if a ring should reshape with the control, put the indicator on an element that has the radius (see below).
  • Transitions list paint properties explicitly (background-color, color, border-color, opacity) at 150ms. Never transition: all - it can animate layout properties. Reduced motion already collapses these app-wide.

Clipping panels. An element whose overflow-* would cut off an outset ring must carry .panel-clip; every focusable descendant then gets outline-offset: -1px. Currently on the TabStrip row, the Drawer content wrapper and the load-test dialog's "Recording & limits" card. Put it on the element carrying the overflow - not on the rows. For a one-off outside such a container, use the .focus-ring-inset utility.

Two limits worth knowing before reaching for it. overflow-y-auto clips horizontally too - it computes overflow-x to auto, so a box that only meant to scroll vertically still cuts a ring off its left and right edges. And .panel-clip's element list is narrower than the baseline's: it covers button, [role="button"] and [tabindex] only, so for a[href], input, select, textarea and summary it is inert - the baseline still draws the ring 2px out and the panel still cuts it off. The components/ui primitives are unaffected either way, since they set focus-visible:outline-none and paint their own ring.

DialogBody carries its own clearance for exactly this (issue #1627): a field inside a dialog needs nothing.

Which is why a primitive fixes its own clipping, with ring-inset. Neither .panel-clip nor .focus-ring-inset reaches a Tailwind ring - both move outline-offset, and a primitive has already turned its outline off. TabsTrigger is the worked example: a trigger fills its list's height exactly (measured in the running app, both boxes were 74->98) and the three scrolling strips - the response viewer, the request builder and Collection Detail - are overflow-x-auto overflow-y-hidden, so an outward ring-2 had no room at the top or bottom and rendered as two cut-off vertical strokes. focus-visible:ring-inset on the trigger fixes every strip at once, present and future; padding the three lists would have fixed it three times. Same reasoning as prefer clearance above, reached the other way round: the clipping is on the list and the ring is on the trigger, and only one of those is a single place. Guarded by tabs.test.tsx.

Prefer clearance to tucking-in for a control that also appears outside a clipping panel. Both fix the clipping; only clearance keeps one control looking like one control. The row-enable checkbox is the worked example: a plain <input type="checkbox"> in both the variables table and the request builder's key-value rows. KeyValueRow wraps its row in p-1, so the ring reads as an outset hairline with a 4px gap. The variables table's cell had no horizontal padding and sat against a p-0 scroll container, so the ring lost its left side on Collection Detail but not on the Variables screen, where the container carries p-4. The fix is px-1 on that cell - the same 4px - not .panel-clip on the container, which would have tucked this instance's ring inward and made the two checkboxes disagree. key-value-parity.test.tsx guards both halves: the two checkboxes must declare equal clearance, and neither may sit under a .panel-clip. The clearance assertion alone would pass a change that re-broke the match. That file grew into the wider parity contract between the two tables (#587) - control height, checkbox sizing and accent, the destructive row-action variant, and the shared secret-reveal control.

Composite rows - .focus-row. The baseline attaches the ring to whatever is focusable, which is only right when the focusable element is also what the user reads as the target. In a tree row it often isn't: a collection row is 220px with a rounded hover fill, but its label button is 150px with square corners, so an outline on the button indicates the wrong shape in the wrong place.

Put .focus-row on the element that paints the hover background. It then draws the indicator itself - at its own radius, so the roundedness control governs it - and adds the same accent fill hover uses, which is how native list selection reads. The inner control draws nothing.

:where(.focus-row):has(:focus-visible:not(.focus-self)) {
  outline: 2px solid hsl(var(--primary) / 0.3);
  outline-offset: -2px;
}
.focus-row :focus-visible:not(.focus-self) { outline: none; }

The indicator mirrors the disclosure chevron's own ring (ring-2 ring-primary/30) and the selected-row ring (ring-1 ring-inset ring-primary/20) so focus, selection and hover speak one language. It uses outline rather than box-shadow because Tailwind's ring utilities own box-shadow - a selected row already sets one, which would override it.

Auxiliary controls opt out with .focus-self. A control inside the row that is its own target - the chevron toggles expansion rather than opening the collection - keeps its own ring and does not light the row, so exactly one indicator ever shows.

.focus-row covers two cases: the row is itself focusable (the collection tree's roving tabindex focuses the row), or focus sits on a control inside it. :has() is descendant-only and does not cover the first, hence the :focus-visible selector alongside it.

Focus must be able to leave, and a Monaco editor is where it could not. A ring that says where focus is means nothing if Tab cannot move it on. Monaco indents with Tab - right for a code editor, and a keyboard trap for anyone who reached one by tabbing (WCAG 2.1.2). Two rules, both held in ui/code-editor.tsx rather than at the dozen mount sites:

  • A read-only editor runs with tabFocusMode on. There is no indentation to insert in text nobody can type into, so Tab simply moves focus and no trap exists to escape.
  • An editable editor advertises the way out while it holds focus. ⇧⌘M (LEAVE_EDITOR_CHORD) moves focus to the first focusable element after the editor, and a Kbd hint names it in the editor's bottom-right corner - on focus, not always, so a dozen panes do not each carry a standing badge over their content. The caps come from chordKeys, like every other chord this app puts on screen; a hand-rolled badge would be a second place a modifier is spelled.

The general rule behind both: any component that takes over a key the browser uses for navigation owes the user a documented way back, and that way back is a Chord in constants/shortcuts.ts so the Keyboard Shortcuts panel lists it without being told. Guarded by code-editor.chords.test.tsx (the chord is registered, the hint appears on focus and never on a read-only editor) and shortcuts.listed.test.ts (it reaches the panel).

Moving focus between regions is F6's job, not Tab's (#1219). The window is four bands - the title bar, the drawer, the main pane and the context bar - and reaching one from another by Tab alone meant walking every request in an expanded collection tree on the way. F6 cycles the bands; Shift+F6 goes back.

Each band carries data-app-region rather than being found by landmark tag. header, aside, main looks like it would do the same job, and it does not: the run view renders its own <header>, the breadcrumb its own <nav>, and the inbox its own <aside> - content landmarks inside main, so a tag query would cycle around inside one region forever rather than moving between the four the window actually has. The attribute says which of the shell's children are stops in the cycle, and a feature adding a landmark of its own cannot join it by accident.

A band is marked by spreading regionProps("drawer") from region-focus.ts, never by writing the attribute onto the element. A JSX attribute name has to be a literal, so bands that spelled it out left one name with five spellings, four of which a rename would leave behind; and the value they carried inline, {"drawer" satisfies AppRegion}, is a node jsx-ast-utils has no case for, so pnpm lint printed two "could not be resolved" advisories on every run, CI included (#1261). The helper takes the AppRegion union, which makes a name that is not a band an error at the call rather than on the value.

Focus lands on the region's first focusable element, never on the region container itself. A tabindex="-1" box would be a legal target and would take focus silently: the :focus-visible rule above is written [tabindex]:not([tabindex="-1"]), on purpose, so a script-focused container paints no ring at all. Landing there would move focus with nothing on screen saying where it went, which for a keyboard-only feature defeats the point of the key.

A {{variable}} inside an editor is painted from the same tokens as one outside it. EditableVariable colours its overlay tokens text-primary / text-muted-foreground / text-destructive-text for resolved, empty and undefined; Monaco draws its own text, so the same three colours (plus text-muted-foreground for a run-time token and text-warning-text for a data.* column no contract declares) are declared in index.css as five global classes its decorations can name - vayu-variable-token-*, under a .monaco-editor prefix so they outrank the theme rules Monaco injects at runtime. They are the one place a global class is the right answer rather than a utility string, for the same reason the scrollbar block is: no component stylesheet reaches what Monaco renders. → variable-token-classes.test.ts


Accessibility

Which tool holds a rule decides what breaking it looks like: a lint error on the line, a source scan naming the file, a rendered assertion, or - until #1216 - nothing at all. eslint-plugin-jsx-a11y's recommended set now runs on every .tsx under pnpm lint, underneath the behavioural guards rather than instead of them: the lint reads markup, and none of the rules below are things it can see.

Rule Enforced by
An icon-only button carries an aria-label icon-button-labels.test.tsx
outline-none is paired with a replacement indicator focus-indicator.test.ts
A control revealed on hover is revealed on focus too row-action-reveal.test.ts
An editor that takes Tab advertises the way out code-editor.chords.test.tsx
Every chord the app listens for is listed for the user shortcuts.listed.test.ts
A hand-rolled role="button" is focusable and answers Enter/Space jsx-a11y/interactive-supports-focus, jsx-a11y/click-events-have-key-events
A role="treeitem" carries aria-selected jsx-a11y/role-has-required-aria-props
A <label> names a control jsx-a11y/label-has-associated-control
Every region of the window is a stop in the F6 cycle region-focus.test.ts, region-focus.markers.test.ts
Every role="img" carries an aria-label on the same element role-img-labelled.test.ts
A jsx-a11y suppression carries a reason and is listed below a11y-suppressions.test.ts

An operation that takes seconds announces its progress. A dialog that sits on a spinner and a bar reports nothing to a screen reader: the bar's value is read on demand, never pushed. Every multi-second operation therefore puts role="status" aria-live="polite" on the visible line that says what is happening - not an off-screen copy, which is a second string to keep in step with the first. The import dialog (ImportProgressView), the export dialog and the collection tree's busy states all do.

The counterpart rule is what stays out of the region: a polite region re-announces on every text change, so a figure that moves several times a second (ImportProgressView's byte counter) is marked aria-hidden inside it and left for the eye. A counter that moves once per file is slow enough to keep.

A role="img" is a promise that the element has something to say. A decorative glyph given the role and no label announces as an unlabelled image; a labelled one beside text that already says the same thing announces twice. jsx-a11y can see neither case. The mechanical half - the label - is role-img-labelled.test.ts; the other half is a judgement made at the site, and the run row's status glyph is where it landed on aria-hidden (see Status Badges).

A tooltip is not a name. Radix supplies aria-describedby while a tooltip is open, which is a description; before it opens - and to a screen reader reading the row - the button is announced as "button". The Dock's four view switchers all had tooltips and all announced as bare buttons. title is a name, but the weaker one, since it does not surface on keyboard focus: no control here relies on it any more, and the guard pins that count at zero.

outline-none needs its replacement in the same class string. The base :focus-visible outline at the top of this page is the whole focus story for anything without its own ring, so outline-none is the single token that opts an element out of it - and the command palette's input had been opted out since it was written, unnoticed, because it is the only focusable element in its dialog. A replacement may be a ring on the element, a ring on the wrapper it fills (focus-within:, as CommandInput and VariableInput do), or the background fill Radix and cmdk paint on data-[selected] / data-[highlighted]. An element that is genuinely never a tab stop is exempt by name, with a reason, in the guard - and an exemption claiming "the wrapper paints it" has to name the class that does.

A composite widget is not itself a tab stop. A tree, a tablist or a radiogroup keeps one focusable descendant and moves that stop with the arrow keys (useRovingTreeFocus.ts); the container carries the onKeyDown and no tabIndex. jsx-a11y cannot see across that split and reports the container as unfocusable, so those sites carry a one-line disable naming the hook - the same for a row whose Enter and Space arrive through the tree rather than through its own handler.

A radio group built from ToggleGroup needs no disable at all. The variable popover's "create in" row was the hand-rolled kind - a role="radiogroup" div whose arrow handler sat on each role="radio" button, where the keydown starts, rather than on the container it would bubble to, which is what kept interactive-supports-focus quiet (issue #1380). It is the primitive now (issue

1391), so Radix owns the roles, the tab stop and the arrows, and the rule has

nothing to inspect: jsx-a11y reads JSX elements, and a component is not the bare <div role="radiogroup"> it reports on. Reach for a hand-rolled group only where ToggleGroup does not fit, put the handler on the options while they are focusable in their own right, and take the disable with the container handler when they are not. Selection is a modifier there, not a destination: it follows focus, so the arrows move both together between the segments, and a mouse click on one returns focus to the value field it qualifies, because a segment that keeps focus after a click leaves the next keystroke going nowhere.

The lint's allowlist. Three rules are configured rather than obeyed as written, in app/eslint.config.mjs, each because the pattern it flags is the correct one here:

  • no-autofocus is off. Its 14 sites are all a field inside a dialog, a rename box opened over a row, or the command palette - moving focus into what the user just opened is the WAI-ARIA dialog pattern, not the page-load autofocus the rule exists to prevent.
  • label-has-associated-control is given controlComponents: ["Switch", "Input", "SelectTrigger"]. It recognises a nested control by lower-case tag name, and each of those renders exactly one native labelable element, so the association it asks for is already there.
  • no-noninteractive-tabindex also allows role="separator". A focusable separator is the ARIA window splitter, which is what both resize handles are, arrow/Page/Home/End and all.

Everything else is suppressed at the line it happens on, with the reason and the file that provides the missing half. 21 directives across 15 files, listed here because a rule-level configuration is visible in one place and a line-level one is visible only to whoever opens that file - and because nothing otherwise stops the count growing one justified line at a time. a11y-suppressions.test.ts reads this list and the sources together: a site added, moved or removed without the list following fails, an unreasoned directive fails, and the count is a ceiling that comes down when a suppression goes rather than a budget to spend.

Paths are relative to app/src, and the count in brackets is directive lines, not rule names - two of these lines silence two rules at once.

  • components/layout/TabStrip.tsx (2) - jsx-a11y/interactive-supports-focus on the tablist, whose tab stop is the active tab; jsx-a11y/click-events-have-key-events on the close affordance, which is Delete or Backspace on the focused row and a tabIndex={-1} pointer target here.
  • components/layout/ActivityRail.tsx (1) - jsx-a11y/no-noninteractive-element-interactions on the <nav>: roving tabindex for Up/Down between the six view buttons, the same shape TabStrip's tablist uses for Left/Right, on a landmark rather than a widget role because the rail is deliberately role="toolbar"-free (#1615).
  • components/layout/Dock.tsx (3) - jsx-a11y/no-noninteractive-tabindex three times: the Radix TooltipTrigger wires focus and blur to the tooltip holding the engine error text, the same shape again on the save-error tooltip, and again on the version string's worker-count tooltip (issue #1508) - each the only keyboard path to its text.
  • components/layout/PanelResizeHandle.tsx (1) - jsx-a11y/no-noninteractive-element-interactions: the window splitter, with its arrow/Page/Home/End handling in the onKeyDown beside it.
  • components/shared/ElementList/ScriptElementForm.tsx (1) - jsx-a11y/no-noninteractive-element-interactions: the script.pre/ script.post editor's height handle, the same window-splitter shape as PanelResizeHandle above, with its pointer drag and arrow-key handling in the onPointerDown/onKeyDown beside it (issue #1605).
  • components/shared/VariableInput/index.tsx (2) - jsx-a11y/click-events-have-key-events and jsx-a11y/no-static-element-interactions on the box that widens the hit area around a native input, and no-static-element-interactions again on the pointerEvents: none overlay whose keydown delegates for the tokens inside it.
  • components/ui/disabled-hint.tsx (1) - jsx-a11y/no-noninteractive-tabindex on the wrapper span that explains a disabled control (issue #1690): the child it wraps is disabled, so it is neither focusable nor able to receive a pointer event, and the wrapper's tab stop is the only keyboard path to the reason - the same argument the three Dock.tsx tooltips above are suppressed on.
  • modules/collections/CollectionTree.tsx (1) - jsx-a11y/interactive-supports-focus, the roving tab stop seeded by useRovingTreeFocus.
  • modules/collections/CollectionItem.tsx (1) - jsx-a11y/click-events-have-key-events: Enter and Space arrive through the tree, which clicks the row's [data-tree-activate] button.
  • modules/collections/RequestItem.tsx (1) - jsx-a11y/click-events-have-key-events, the same tree path as its sibling above.
  • modules/history/sidebar/HistoryList.tsx (1) - jsx-a11y/no-noninteractive-element-interactions on the role="group" run list: roving tabindex for Up/Down/Home/End between the row activators inside (useHistoryListFocus), the same shape ActivityRail uses, on a group rather than a widget role because this flat, occasionally day-headered list has no listbox/tree selection model to claim.
  • modules/variables/sidebar/VariablesCategoryTree.tsx (2) - jsx-a11y/interactive-supports-focus on the tree and jsx-a11y/click-events-have-key-events on its rows.
  • modules/services/ServicesPanel.tsx (1) - jsx-a11y/click-events-have-key-events and jsx-a11y/no-static-element-interactions: the drawer-row hit area, which delegates only the clicks landing on the row's own padding.
  • modules/request-builder/components/LoadTestConfigDialog/ProfilePicker.tsx (1) - jsx-a11y/interactive-supports-focus: a radiogroup, where the selected role="radio" holds the stop and the onKeyDown moves selection and focus together.
  • modules/request-builder/components/RequestTabs/panels/body/graphql-explorer/SchemaExplorer.tsx (2) - jsx-a11y/interactive-supports-focus on the tree, and jsx-a11y/role-has-required-aria-props because this tree has no selection model: a row inserts into the query and nothing stays selected, so aria-selected is omitted rather than faked.

Test files are outside the count. Four directives live in two of them, each a fixture modelling one of the patterns above rather than a component the app ships.


Row Actions

Controls that appear on a row you are already hovering - , delete, remove.

Never use ghost for these. ghost hovers to bg-accent, which is exactly what the row underneath already paints, so the button looks like it has no hover state at all. Use the dedicated variants, which step up to accent-active:

Variant Use Hover
rowAction neutral (, edit, copy) bg-accent-active + text-foreground
rowActionDestructive delete / remove bg-accent-active + text-destructive

Destructive rows share the neutral shape and differ only in glyph colour on hover. No red background tint: the row already carries one fill, a second competing tint is noise, and DeleteConfirmDialog is what actually protects the user - the red glyph only needs to signal at the point of intent.

Reveal them with opacity-0 group-hover:opacity-100 group-focus-within:opacity-100. The focus-within half is not optional: without it a keyboard user lands on an invisible control.

A state toggle is not a row action, and must not be hover-revealed. The test is whether the control has an off state that means something. Delete has none - it either fires or it does not - so hiding it costs nothing. The variables table's "mark as secret" key does: hidden at rest, "this value is not secret" looked exactly like "there is no control here", so the only way to discover you could mask a value was to hover the row. It is now always visible on muted-foreground, stepping to warning-text when on.

Same rule for anything that reports state - a pin, a mute, an enable. Quiet is fine; absent is not.

A row with no action of its own keeps its controls visible too. Hover-reveal exists so the row's own job - open the request, select the run - is not crowded by buttons the user did not come for. A Trash row (modules/trash) has no such job: it opens nothing, and Restore and Delete forever are the entire reason it is on screen. Revealed on hover they would hide the surface's whole purpose and leave a list of names that reads as inert. The test is the same one the state toggle above passes - would the row still say what it is for with the control absent - and here the answer is no.

Every row that has actions draws them this way, Services included (issue

1690). The Services drawer's rows were the last holdout - two or three

always-visible TooltipIconButtons on a 32px row whose payload is a URL, so the URL truncated to make room for controls the user had not come for. They are one hover-revealed menu now, and the values those tooltips carried (the inbox URL, the mock's base URL) ride the items' hint, which reads without hovering. There is no exemption: the rows that keep visible controls are the two the rules above name - a state toggle, and a row with no job of its own.

Prefer RowActionsMenu (components/shared) over adding another inline icon button. It renders the trigger plus a DropdownMenu, so rows expose actions consistently and get focus management, Escape-to-close and arrow-key navigation for free. Used by request rows and environment rows. It opens on a pointer and on a click reporting detail === 0 - the keyboard's kind - and takes a tabIndex prop, 0 unless the row sits in a roving-tabindex tree. See Tree Navigation for why.

A disabled item says why on the item. RowAction's disabledReason draws the reason at the row's trailing edge - "Move up / Already first" - rather than in a tooltip: DisabledHint's wrapper works by taking a tab stop of its own, and inside a menu that second stop competes with Radix's own focus management, which deliberately skips a disabled item. RowActionBody draws it, so the dropdown and the right-click menu explain a gate the same way. See "A disabled control says why" under Component Patterns for the button case.

Right-click reaches the same actions through RowContextMenu (issue

1360): one list, two menus. row-actions.ts holds the one rule about the

list's shape - the first destructive action gets a separator above it - and RowActionBody holds what an item draws, so the dropdown and the context menu cannot describe a row's actions differently. Collection rows, request rows, environment rows and history rows all take it.


Full-Width List Rows

Button's listRow variant is for a summary row that spans its container and opens something on click - a stream event, a sampled exchange, a collection's last run, a recent send, a GraphQL operation, a recent-runs entry. Not row - that name already belongs to Row Actions above, a smaller control at a different scale.

It carries only the axis-level shape: w-full, justify-start in place of the default's justify-center, text-left, and h-auto overriding the default size's fixed h-9 (in compoundVariants, not the variant string itself - cva concatenates size's classes after variant's, so a height set beside w-full/justify-start would lose to the default size once both pass through the same cn() merge; button-variants.test.ts mutation-checks this). Padding, gap, text size and icon size stay with the caller's own className: the rows this variant serves span three different scales (a context-bar row is not a welcome-screen row), and a fixed opinion here would renormalize all of them. A caller whose icons are not the base's default 16px overrides with [&_svg]:size-N; a caller whose content wraps to a second line (rather than truncating) needs whitespace-normal, since the base string's whitespace-nowrap is otherwise inherited by every child.


Drawer Panel Frame

Every drawer view renders inside DrawerPanel (components/shared). It owns the header (title + trailing actions) and the scroll region; views supply only their content.

The four views had drifted into two different panel designs - Collections and History used a 16px padded container with a heading, Variables and Settings were flush with no heading at all. Switching views moved the content's vertical start and made the title appear or vanish. All four now match exactly: one 32px header band, body below it.

  • The header is the drawer's half of the second chrome row. It takes its height from --tabstrip-height and carries the same bottom rule as the tab strip on the other side of the resize handle, so the two read as one band across the window rather than as two adjacent headers. That is the only reason the height is a token and not an h-8: TabStrip.tsx and DrawerPanel.tsx cannot see each other, and titlebar-height.test.ts holds them together.

  • A group inside a view is a DrawerSection, and a count is the muted inline count. The frame gave the four views one shape; the groups inside a view had none. Services drew muted labels with a trailing plus, Variables drew a chevron, an icon, a filled Badge count and a plus, and the collection rows wrote the same fact as inline (2) text one click away from that badge - two count idioms in one drawer. DrawerSection owns the shape (the padding, the muted small type, the chevron, where the count sits, where the actions sit) and the badge idiom loses: a count beside a group name is not a status and not a control, it is the least important thing in the row, and a Badge gives it a fill, a border, a 20px floor and the weight of a chip. DrawerSectionCount is exported for a row to use the same idiom, which is how the collection tree stays in step without gaining a section header - its panel band already says "Collections", and a section of the same name inside it would be a double heading.

  • The section's ARIA stays at the call site. Variables' headers are role="treeitem" rows in a roving-tabindex tree and their "+" sits outside that row on purpose (the tree has no create key, so the button is the drawer's own tab stop). rowProps and activatorProps are spread through rather than owned, so the primitive never has to know about the tree. Guard: DrawerSection.test.tsx.
  • The frame owns header padding; the body is flush. Rows run edge to edge - the sidebar convention, and it recovers the ~32px of row width the old inset cost. Rows bring their own internal padding.
  • Full-bleed rows are square. A rounded corner meeting the panel edge reads as a clipped rectangle, not a rounded row.
  • The panel owns scrolling - vertically only. Views used to differ: some were wrapped in a ScrollArea by the Drawer, others managed their own.
  • Indent with padding inside the row, never margin around it. Margin pushes the row's background in too, so a nested row's hover and selection fill stops short of the panel edge while a top-level row's reaches it. Depth is shown by where the content sits, not where the row starts: paddingLeft: rowInsetPx(depth) (constants/layout, 8 + depth * INDENT_STEP).
  • Non-row content inside a level takes a child row's left edge. A placeholder, an inline form, a loading skeleton and an inline error all stand in for the rows that are not there, so they start where those rows would: childInsetPx(depth) in the collections tree, the named GROUP_CHILD_INSET in the two-level variables tree, and rowInsetPx(0) - a root row's own inset, there being no parent row to be a child of - in the GraphQL schema explorer, whose search results are one flat level. Each carrying its own px-* is how one expanded folder came to show up to five different left edges, with "Empty folder" left of its own parent's label (#1372). An empty level also has to say so: its group holds no treeitem, so the folder row carries aria-describedby pointing at the placeholder rather than the placeholder taking a role inside a group that owns rows.
  • A group heading is a label, not a placeholder - and it follows the rule anyway. The schema explorer's Query / Mutation / Types eyebrows were the one case #1372 left open, because an uppercase label over a flat group reads as a heading rather than as a row that escaped its level. Looked at in the running app (#1378) that held - what did not was the pane drawing three left edges at once: rows on a private base offset, the headings and both stand-in lines on their own px-2, and the rows' column matching neither the headings nor the search field above them. The headings stayed where they were and the rows came to meet them, because the pane reuses rowInsetPx now instead of a second copy of INDENT_STEP.
  • A control with an outward focus ring needs clearance from the body's top edge. The body scrolls, so it clips at its own bounds, and a ring drawn outside the border box gets its top cut off when the control sits flush. Rows are exempt - their focus outline is inset (outline-offset: -2px). Padded content blocks (the History search field) carry pt-2.
  • A row must never widen the panel. Long names ellipse; the drawer has no horizontal scrollbar - overflow-x-hidden is set explicitly, because overflow-y: auto alone computes overflow-x to auto too. truncate alone is not enough when the text sits inside a flex-1 wrapper - a flex item will not shrink below its content width, so the wrapper needs min-w-0 as well. (A truncate element that is itself the flex item is fine: overflow: hidden already gives it an automatic minimum size of 0.) Short trailing metadata - counts, badges, spinners - takes shrink-0, so the name is what yields.

Drawer Row Metric

Single-line drawer rows are h-8 (24px at the default density, 32px at Comfortable - see Spacing Scale above). State the height; do not let it fall out of the content. It previously did - a 28px chevron set the collection row, padding set the others - so the four drawer views ran 34 / 36 / 38 / 40px and the rhythm shifted every time the user switched view, one click apart in the same panel. Collection and request rows differed by 4px inside a single tree.

Applies to CollectionItem, RequestItem, SettingsCategoryTree and VariablesCategoryTree rows. Put h-8 items-center on the row and let content centre; do not re-add vertical padding, which is what caused the drift.

Section headers (e.g. "Environments") stay shorter on purpose - they are group labels, not list items, and the difference carries hierarchy.

The disclosure chevron is w-6 h-6 - 18px at the default density, 24px at Comfortable - sized to fit the row at either. That is still an adequate pointer target, and the row around it opens the collection.

h-8 items-center on the row means the activator needs self-stretch. The two rules above interact, and the interaction is a bug the eye cannot see. A composite row (see .focus-row) paints the height, the hover fill, the selection tint and cursor-pointer, while the click handler sits on a narrower activator button inside it - the row carries a menu, so it cannot itself be one button, and a plain <div onClick> is not keyboard operable. items-center then makes that button content-height: ~22px in a request row, where the MethodBadge props it open, and ~18px in a collection or environment row. The remaining 5-7px above and below took the fill and the pointer and did nothing on click. Measured in the running app at the 260px default drawer width, the share of the row that actually responded was 41% for a collection, 51% for a request and 36% for an environment - and hit-testing 3px inside the top or bottom edge landed on the container, which has no handler.

self-stretch on the activator overrides the row's centring; the activator's own items-center still centres its contents, and .focus-row is unaffected because the row paints the ring either way.

self-stretch fixes the height; the row's own box needs delegation. The indent is paddingLeft on the row - deliberately, so the fill reaches the panel edge - and the flex gaps and right padding belong to no child either. No amount of stretching reaches any of it, and on a collection row the indent cannot move onto the activator even in principle, because the chevron sits between them. So the row takes the click itself and forwards it:

const isRowSurface = (e: React.MouseEvent) => e.target === e.currentTarget;
// on the row:
onClick={(e) => isRowSurface(e) && handleClick(e)}

target === currentTarget is exactly "the pointer landed on the row's own box". It excludes the chevron and the menu without naming them - they are children, and they own their own actions - and it stops a click on the activator from firing twice as it bubbles through. Drop the check and every label click activates twice.

This is not the <div onClick> the environment row's comment warns against: the activator button stays and remains the keyboard path (useRovingTreeFocus clicks [data-tree-activate] on Enter). The row is a second, pointer-only entrance to the same handler.

Together the two changes take all three rows to 100% of their own pixels - measured by sweeping elementFromPoint across the row box in the running app. The only pixels a row does not own are the chevron, the menu and the drawer's 8px cursor-col-resize handle at the panel edge. Both halves are guarded by drawer-row-hit-area.test.tsx: the height as a className assertion (jsdom has no layout, so an offsetHeight assertion would pass while measuring nothing), the delegation behaviourally, because fireEvent.click(row) targets the row itself - exactly the pointer that used to land on dead padding.

The shared ElementList's element card header (components/shared/ElementList/, issue #1608) is the same h-8 row with two embedded controls beyond its own toggle - an enable switch and a menu - each a real interactive child rather than a row-level click delegated by target equality: with the header's own click handled by two real buttons (the chevron and the label), there is no ambiguous "click landed on the row itself" case left for isRowSurface to resolve.


Overflowing Text

User-supplied names - collections, requests, environments, URLs - are unbounded, so every surface that shows one needs a defined overflow behaviour. There are exactly two, and they are not interchangeable:

Treatment Component Where
Ellipsis + tooltip TruncatedText Rows, headers, pickers - the default.
Marquee on hover ScrollOnOverflow Tab strip only.

TruncatedText is the default. It ellipses, and reveals the full value in a native title tooltip only while the text is actually clipped. An unconditional title={name} - the obvious version - pops a tooltip on every hover, including names that are already fully readable, telling the user something they can see. The tooltip appears when the name is cut off and disappears when the drawer is widened enough to read it; useOverflowTitle re-measures on resize via ResizeObserver.

Do not hand-write title={name} alongside truncate. That is the pattern this component replaced, and it drifts - some rows get it, some do not, and the ones that do show it unconditionally.

ScrollOnOverflow marquees instead, and is limited to the tab strip, where the label is the primary target and there is no way to widen it. Rows must not animate under the cursor.

Text that wraps (break-words) is neither - it never clips, so it needs no tooltip.

A URL is the exception, and it gives way at the head. Both treatments above keep the beginning of a string, which is right for a name and wrong for a URL: the scheme and host are what every row on one host shares, so a page of local runs read http://127.0.0.1:9... five times over for five different requests. lib/truncate-url.ts (truncateUrl(url, max = 48)) shortens the head and keeps the path tail, and RunItem passes its URL-titled rows through it before the truncate class ever applies - the class stays, because a character budget is not a promise about a narrow drawer. The full value stays in the element's title and in the row's accessible name.

It is a display helper and never parses: a row can hold a value still being typed, a {{variable}} in the authority or a relative path, and new URL() throws on all three.


Tree Navigation (roving tabindex)

The collection tree follows the WAI-ARIA treeview pattern: the whole tree is one tab stop. Previously every row and every control in it was a stop - a workspace with 2 collections and 4 requests cost 17 presses to tab past.

  • Container: role="tree". Rows: role="treeitem", aria-expanded on collections, aria-selected for the open entity.
  • Rows render tabIndex={-1}; useRovingTreeFocus promotes exactly one to 0.
  • Keys: Up/Down move, Home/End jump, Right expands then steps in, Left collapses then moves to the parent, Enter/Space opens, F2 renames, Delete / Backspace deletes, Shift+F10 / Menu / Shift+Enter opens row actions, typeahead jumps to the next row whose name starts with what you type, * expands every folder at the focused row's level.
  • Right-click is the pointer equivalent of Shift+F10 / Menu / Shift+Enter (issue #1360): it opens the same row-actions list through RowContextMenu and focuses the row on the way past, so Escape returns focus there rather than dropping it to <body> - the tree still has to hold its one tab stop after the menu closes, whichever way it opened.
  • The second binding on those two is what a Mac keyboard can reach. The key labelled "delete" on a Mac reports "Backspace" ("Delete" is forward-delete, Fn+Delete), and Mac keyboards have no Menu key and default F10 to a media key - so the Delete-only and Shift+F10-only versions were dead on macOS while passing every test, since jsdom reports one platform. Both bindings are live on every platform rather than behind an isMac fork; the tests fire both keys and assert neither the host nor a stubbed platform.
  • Alt+Arrow moves the row itself, the keyboard half of drag-and-reorder: Up/Down among its siblings, Right into the folder rendered above it, Left out to after its parent. Alt because the tree owns the bare arrows and the app owns Ctrl/Cmd; every move is announced in the live region below.
  • The row menu carries the same moves with no chord at all: Move up, Move down and "Move to..." (issue #1690). A chord has to be known before it can be used, so until these existed a keyboard user who had not read the shortcut list could not reorder the tree - while the element list had carried Move up / Move down in its own menu all along, which made one action two actions depending on the list. They call the same moveByKeyboard the chords do (one move, one function) and are off at the ends carrying "Already first" / "Already last" as a disabledReason, gated off the same block the announcement is computed from. They sit above the row's destructive tail, since the separator there belongs to Delete.
  • Every control inside a row is tabIndex={-1}, so those keys are the only keyboard path to row actions - do not remove one without providing another. Both row types must render every hidden control: a folder row without data-tree-delete swallowed Delete silently for months, because the hook preventDefaults the key whether or not it finds something to click.

Those keys reach a control by clicking it, so the control has to answer a click. The hook calls .click() on the row's [data-tree-menu], and Radix's dropdown trigger opens on pointerdown and on its own keydown - neither of which a programmatic click dispatches. Every menu-only action (Duplicate, Move to, Run, Add, Export) was therefore mouse-only, on a path the tree advertised (#1212). RowActionsMenu now holds its own open state and opens on a click reporting detail === 0, which is what a click with no pointer behind it reports - the mouse path stays Radix's, since its own pointerdown has already opened the menu by the time a real click arrives. The hidden <button> controls answer a click by being plain buttons; anything richer added to a row has to declare how it answers one.

RowActionsMenu takes its tabIndex from the row. It is 0 by default - outside a tree the menu is an ordinary tab stop - and these rows pass -1, because the tree is one tab stop and the keys above are the way in. Closing the menu hands focus back to the row, not to the trigger Radix would return it to: a tabIndex={-1} control holding the tree's focus is a stop the user cannot Tab back to.

Rows declare behaviour through data attributes rather than props (data-tree-activate, data-tree-toggle, data-tree-menu, data-tree-rename, data-tree-delete, data-tree-move-up / -down / -in / -out, data-tree-label), so the hook needs nothing threaded through CollectionItem's prop list. data-tree-label is the row's name for typeahead and is not optional decoration: a request row's textContent starts with its method badge and a folder's ends with its child count, so matching the text would search a string the user never sees.

Focus is not selection. Arrows move focus without opening anything; Enter opens. Keep roving focus, aria-selected, and the open tab in tabs-store distinct - conflating them is the classic treeview bug.

The whole row is the drag handle, and that is only safe because the discriminator is movement. There is no grip icon: the row already fought dead zones to become clickable everywhere (see the hit-area rule above), and a grip would hand most of that area back. A press becomes a drag at ~4px and not before, so every click affordance survives - and the completed drag swallows the one click the browser fires after it, or the row it was just dropped on would open. Never a timer: RequestItem.test.tsx pins that opening is synchronous.

A row's pointer handlers must ignore what its own menu sends them. The ⋯ menu is a React child of the row and a portal in the DOM, and React bubbles synthetic events through the component tree - so a press on "Delete" arrives at the row's onPointerDown. Taking it captures the pointer on the row, the capture retargets the pointerup the menu item was waiting for, and every action in every row menu stops working while looking perfectly normal. closest("[data-tree-menu]") does not catch it (portalled content is not inside its trigger); a DOM containment check - currentTarget.contains(target) - does, and is the guard on every pointer handler a row spreads.

Drop indicators are classes on rows that already exist. A line between two rows is a 2px bg-primary span positioned inside the target row and indented to that row's own depth - the depth is the only thing separating "after this collapsed folder", "into it" and "after its parent". Dropping into a folder reuses the selected-row ring. Nothing new gets role="treeitem": every row in document order is the tree's row list, so an indicator node between rows would join it and change the tree's shape mid-drag - a row with no level, sitting between a folder and its children. A row that cannot take the drop is dimmed and carries data-drop-blocked - the dragged folder's own subtree, and the block the dragged row does not belong to.

A rename must hand focus back. The rename field replaces the row's label and then unmounts, so closing it from the keyboard (Enter or Escape) with nothing to catch focus drops the user to <body> and the next Tab restarts from the top of the document. Both row types refocus their own row. A blur deliberately does not - focus has already gone where the user sent it.

A delete must hand focus on, because the row it came from is gone. The confirm dialog is rendered controlled with no trigger, and a trigger is what Radix aims its close-focus at - so both outcomes dropped the user to <body> (#1218). Cancel returns focus to the row, which is still there. A delete that actually removes the row moves focus to the next row in the deleted row's own set, or to the parent when that row was the last in it: chosen while the row is still on screen, since afterwards the DOM cannot say what followed it. Focus moves through focusTreeRow, so the tree's one tab stop travels with it.

A tree that runs out of rows still has to name somewhere. The rules above answer for every row but one: a root falls back to the root before it, so it takes the whole tree emptying - the deleted row was the only one there was - for the honest answer to "which row now" to be none, which is <body> again unless the caller says otherwise. So the last resort is the caller's to name, and it is a control rather than a row: the collection tree hands focus to Add collection, the one thing left to do on an emptied tree, and the Trash view - flat, with no create control of its own - to its list container, which stays mounted holding the empty state. The variables sidebar names none, and needs none: every environment row is a level-2 child of the Environments header, so the parent rule above always answers. A last resort is not a row, so it does not travel through focusTreeRow - moving the roving tab stop onto it would take that stop off the tree altogether.

Which of the two it is, is read from the outcome and never from the confirm click. The dialog closes on a failure as much as on a success, and nothing at close time can say which happened - confirmDelete returns void, and an awaited answer arrives after Radix has already moved focus. Read as intent, a failed delete landed the user beside a row that was still sitting there (#1234). So the decision waits instead: focus goes back to the row at close, and moves to the successor only once that row actually leaves the DOM - which for a successful delete is normally a later render, when the refetch lands. A user who has moved focus on in the meantime keeps it; the move only happens out of <body>, where the removal itself left it. The waiting is useRemovalRefocus (app/src/hooks/), shared with the Trash view's permanent delete - a flat list, so its rule is the next row, or the previous one when the purged row was last. Both trees that can delete a row - the collection tree and the variables sidebar, which grew its Delete key in #1217, after the rule was written - reach it through the one useDeleteRefocus, which each tells only which attribute identifies its rows and where its last resort is (#1279).

Order comes from the DOM; hierarchy comes from aria-level. Order the DOM states plainly - [role="treeitem"] in document order is exactly the rows a user can see, since collapsed subtrees are not rendered. Parentage it does not: a row's children are a sibling of that row inside a shared wrapper, not nested within it, so closest() finds nothing and a walk up the ancestors takes whichever treeitem an ancestor holds first - a group's own first row, which is a preceding sibling of every row after it. Under that walk Left moved to the top of a list instead of out of it, and self-corrected on the next press, so it read as hesitation rather than as breakage (#1237). One function answers the question now - parentRow in tree-focus.ts, the nearest row above with a smaller level - and both the Left key and the delete refocus read it, so there is one description of this tree's shape rather than two that can drift.

A consumer of the hook therefore has to announce aria-level on every row, and all three do. It is not only for assistive tech: a tree that omits it is a tree of roots, where Left moves nowhere and * calls every row a sibling. The schema explorer is why the level and not the shape is the source - it renders every row, at every depth, as a direct child of the tree, so no DOM rule can answer for it at all.

That same shape is why the hierarchy has to be stated, not inferred. A sibling group is not a child group, so the accessibility tree read as a flat list of rows. Every row carries aria-level (1-based), aria-posinset and aria-setsize; the children wrapper is role="group" and the folder row claims it with aria-owns, which buys the ownership without moving the DOM the roving-focus order and the hit-area rules depend on. Folders and requests inside one group are one set - the requests continue the folders' numbering, or two adjacent rows both announce "1 of 1".

CollectionTree also renders one polite live region (data-tree-live). It shipped empty, ahead of anything that wrote to it, on purpose: a live region added at the same moment as its first message is not reliably announced (the same constraint ResponseAnnouncer carries). What writes to it now is a move - "Moved Get Users to position 2 of 5 in Billing", or the reason a move did not happen ("Get Users is already first in Billing"), which is the only feedback a keyboard user gets for a row that visibly went nowhere.

Currently on CollectionItem and RequestItem rows and on every row of the variables sidebar (VariablesCategoryTree, #1217), which is a roving-tabindex tree for the same reason: its Rename and Duplicate live only in the row's ⋯ menu, so a row that was not a treeitem left both unreachable without a mouse. Otherwise only needed where the control and the row genuinely differ - the history and settings trees use full-width buttons that are their own target, so they use the baseline. Before adding it, check whether the focusable element already spans the row.

The variables sidebar shows what a second consumer costs. Two things there are not the collection tree's, and both are decisions rather than omissions:

  • Its section headers are rows. Globals, Environments and Collections are the three level-1 treeitems and the two lists are their children, so one arrow key walks the whole sidebar and the headers get expand/collapse from Right/Left. The header's one button is both data-tree-toggle and data-tree-activate, because for a header those two verbs are the same one.
  • "Add environment" is still a tab stop, and sits outside the header row's treeitem. The tree owns no "create" key, so a tabIndex={-1} there would make creating an environment mouse-only - the defect the tree was adopted to fix, pointing the other way. The tree is one stop; that button is the second.

A row that does not carry a control still has to declare it: an environment row renders the hidden data-tree-rename and data-tree-delete buttons, because the hook preventDefaults F2 and Delete whether or not it finds something to click.

The other roving strip is not a tree, and does not use the hook. The {{variable}} tokens VariableInput paints over a field share one Tab stop the same way - one token at tabIndex={0}, the rest at -1, Left/Right between them and Home/End to the ends - because a URL with five variables otherwise put five stops between the URL and Send (issue #1215). It is wired in the component rather than taken from either half of the tree's machinery, for two different reasons. useRovingTreeFocus navigates [role="treeitem"] and acts by dispatching at the tree's own data-tree-* attributes, so pointed at tokens it would find nothing and silently do nothing. focusTreeRow (tree-focus.ts) is closer - it is exactly "move the stop and focus the row" - but it moves the stop by writing tabIndex onto DOM nodes, which is right for the tree, whose rows render tabIndex={-1} and do not re-render for it, and wrong here: the token strip's stop is a React prop, so the same write would sit on top of a vdom value React still believes and be undone by the next render. Arrowing does not wrap there - falling off the end leaves Tab as the way out, per Focus must be able to leave above.


Flex Items Must Be Told They May Shrink

A flex item defaults to min-width: auto / min-height: auto, which refuses to shrink below its content. flex-1 sets how an item grows; it does not grant permission to shrink. This has caused two separate bugs in this codebase and is worth checking whenever a flex child holds unbounded content.

Axis Add Symptom when missing
Horizontal min-w-0 on the wrapper truncate never engages - a long name widens the row and the panel scrolls sideways.
Vertical min-h-0 on the wrapper The child keeps its old height when the container shrinks - the parent overflows and grows a second scrollbar.

The vertical case is the more confusing one, because the visible symptom is a scrollbar, not a sizing error: a Monaco editor in a resizable pane kept its previous height when the pane was dragged smaller, so the pane overflowed and drew a native scrollbar next to the editor's own. Two scrollbars for one editor. The fix is never to hide the extra scrollbar - it is to let the child shrink, after which there is no overflow to scroll.

An element that is itself the scroller is exempt on that axis: overflow: hidden (which truncate sets) already gives a flex item an automatic minimum size of 0.

The exemption reaches exactly one box, though - the item itself. A scroller nested a few blocks below a flex or grid item does not lend it that minimum, and overflow-auto bounds the box it is on without stopping the min-content width that box contributes upward. So "I made it scroll" is not the same claim as "it cannot widen its host", and the second is the one a fixed-width surface needs.

A grid track has the same default, and a panel cannot follow it

grid gives an implicit track a sizing function of auto, whose minimum is its items' min-content - the same refusal to shrink, one box further out. On a surface whose width is capped this is worse than on the canvas, because the track can outgrow the box that paints the background: the panel stays at its max-w and every row inside it lays out at the track's width, so controls that have nothing to do with the wide content are the ones the user sees hanging over the backdrop. Issue #701 found this in the Run Collection dialog, where a seven-column data-file preview put the footer buttons 428px outside the painted panel.

grid-cols-1 - repeat(1, minmax(0, 1fr)) - is the fix for any width-capped grid surface: the 0 lets the track be narrower than its content's min-content, which is what gives the scrollers inside it room to scroll. Prefer it on the surface over min-w-0 sprinkled on the items - the items are written by every caller, and the cap is the surface's own promise.

DialogContent itself is a column flex container rather than that grid since issue #773, which needed a height cap a grid will not honour (see below), and it refuses the same widening for a different reason: min-width: auto is a main-axis rule, so on the cross axis an item stretches to the line - the panel's own content width - and a wide descendant overflows inside it instead of widening it. Measured in Chromium on the seven-column shape from #701, the footer landed 580px past the painted edge under a bare auto track and 25px inside it under either spelling of the clamp. The grid version is still the right one for a surface that is genuinely a grid.

Write it as the stock utility, not as the arbitrary value that says it more directly. Tailwind emits no rule for grid-cols-[minmax(0,1fr)] (verified against the built CSS), so that spelling is a class that reads correctly, passes a className assertion, and styles nothing.

Dialog widths: three sizes

Size Class For
Standard sm:max-w-lg (512px) A form or a decision - a confirm, a rename, a picker, a short field set.
Wide max-w-xl (576px), the primitive's default A dialog holding something with a shape of its own: a table, a diff, a preview, a dense config.
Browser sm:max-w-2xl (672px) A dialog whose job is reading that shape and picking out of it, not confirming something about it. One call site: the data-row picker beside Send.

These had drifted to five values across eleven call sites, including two one-off pixel widths, so the same kind of dialog came out a different size depending on who wrote it. dialog-width-scale.test.tsx holds the set closed; a dialog that genuinely needs another size widens the scale here and in dialog.tsx, with the reason, rather than opening a one-off.

2xl was opened for the row picker (issue #892): seven columns of ordinary CSV at 576px leaves about 60px a column, which is one truncated cell per column and nothing scannable. It is deliberately the last size on the scale - a dialog is a focus device before it is a container, so content that wants more room than this wants a pane, not a wider modal.

Prefer a cap (max-w-*) over a fixed w-[…]: a fixed width is one the panel keeps on a viewport narrower than it, where w-full under a cap gives the same stable band and still fits. And widening is never the fix for content escaping the panel - a wider panel with an auto track spills exactly the same way, just further along. Clamp the track; widen only for the reading.

Dialog height: one cap, and the band that scrolls

A dialog panel is fixed and centred by a translate, so a panel taller than the viewport is centred on a box it does not fit: clipped at the top and the bottom at once, with nothing to scroll, because a fixed box does not scroll the page. The footer is the half that goes, which makes the dialog's primary action unreachable by pointer - and only by pointer, since Tab still reaches it, which is how this survived fourteen call sites (issue #773). Measured in Chromium at a 613px viewport, an eighteen-row dialog put its Run button 227px below the screen.

The panel therefore declares max-h-[85vh], and the band between the header and the footer is a DialogBody:

<DialogContent className="sm:max-w-xl">
  <DialogHeader>…</DialogHeader>
  <DialogBody className="space-y-4 py-2">…</DialogBody>   {/* the only scroller */}
  <DialogFooter>
    <DialogCancelButton onClick={() => onOpenChange(false)} />
    <Button onClick={handleConfirm}>Run</Button>
  </DialogFooter>
</DialogContent>

The declining action is DialogCancelButton, never a Button you pick a variant for (issue #1693). The same word carried three variants across the app - outline in five dialogs, secondary in three, ghost in three, plus one hand-rolled <button> with a copied class list - so which one a user saw depended on which dialog they opened. The primitive settles it on secondary, matching DeleteConfirmDialog, the dialog this app shows most often, and it deliberately does not take a variant prop: a call site that can choose is a call site that can drift. label renames the word ("Not now", "Keep it"), size and className pass through for the inline forms outside a DialogFooter that draw the same button in a denser row. components/ui/dialog-cancel.test.ts bans a Cancel label anywhere else.

Three rules hold it together:

  • The body scrolls, never the panel. overflow-y-auto on the panel is the tempting one-liner and it is the wrong one: the corner close button is absolute inside the panel, so it scrolls away exactly when the dialog is long enough to need a visible way out. The panel keeps one anyway as a fallback for a dialog with no band; with a band present the panel never scrolls and the button stays pinned.
  • min-h-0 on the band is load-bearing, for the same reason min-w-0 is one section up: a flex item's automatic minimum on the main axis is its content, so without it the band refuses to shrink and the overflow moves straight back out to the panel.
  • flex-auto, not flex-1. Basis 0% asks a short dialog to stretch its one band over the whole cap; basis auto grows only into height that is free.

Header and footer are shrink-0 so they stay bands rather than being the first thing squashed. A dialog with no middle to scroll - a confirm, a rename - needs no body. A dialog that manages bands of its own (ImportModal) or whose content is already a self-scrolling list (CommandDialog) opts out at the call site with the reason written there; dialog-height-band.test.tsx holds that list closed, so a new dialog cannot skip the band silently.

Opting out of the band does not opt out of the shape. The command palette's keyboard hints are a CommandFooter - shrink-0, a sibling of CommandList and never a row inside it - because the rule is about what scrolls, not about which primitive names it: hints that scroll away with the results they describe are hints nobody reads. Nor does opting out excuse the cap: the list that scrolls in place of a band owns one of its own, and the palette's is min(400px, 60vh) (#1177) rather than a bare pixel value, so the input, the list and the hints together stay inside the panel's 85vh on a short window instead of being clipped by the overflow-hidden that keeps the list's scroll the only one.


Layout Structure

Shell                            flex flex-col h-full bg-background
├── row (flex-1)
│   ├── ActivityRail (nav)   w-[var(--rail-width)], bg-panel - the six view
│   │                        buttons, on the window's left edge (#1615)
│   ├── Drawer (aside)       220–480px, default 260px, bg-panel - one of the six
│   │                        views, plus its PanelResizeHandle on the right edge
│   └── content column       TabStrip, then a row of [main + ContextBar]
│                            beside ContextRail (w-[var(--rail-width)])
└── Dock                     h-[var(--dock-height)] border-t border-border -
                             ambient status only, along the bottom of the window

The panels that resize, and the one handle that resizes them

The drawer and the context bar are the two panels a user drags, and both use PanelResizeHandle (app/src/components/layout/PanelResizeHandle.tsx): one focusable role="separator" where each panel used to carry its own mouse-only copy. side sets the direction, so the drawer's right-edge handle widens on ArrowRight and the context bar's left-edge one widens on ArrowLeft; Page keys jump, Home and End take the bounds, and Enter or Space resets to the default - the keyboard equivalent of the double-click that was already there.

The width itself is a preference, not component state: drawerWidth and contextBarWidth live in layout-store, clamped to PANEL_MIN_WIDTH (220) and PANEL_MAX_WIDTH (480) from app/src/constants/layout.ts, and survive a restart. The request/response split is react-resizable-panels through components/ui/resizable.tsx, with one ratio per arrangement in the same store (requestSplitRatioBeside / requestSplitRatioBelow, #1711): the share of the width a request wants beside its response is not the share of the height it wants above it. The builder's divider resets its arrangement to an even split on double-click (ResizableHandle's onReset), the drawer handle's own gesture; stacked panes take a 160px floor rather than the 20% side-by-side ones do, because 20% of a short window is a response pane that cannot show a status line and a row of body.

An editor inside a pane is not one of them. The Body and script editors fill the pane they sit in - a flex-1 box with a min-h-40 floor and no ceiling - because a drag there resized a box inside a pane the user had already sized, and held that size in component state that Radix threw away on the next tab switch (#1323). The pane's own splitter is the one control for how tall an editor is.

ActivityRail and ContextRail

The primary navigation runs down the window's left edge, not along its bottom (#1615): the OS Dock auto-hides over the app's lowest 60-80px on macOS, so a switcher living there is the one target on screen the system can cover mid-use. ActivityRail (app/src/components/layout/ActivityRail.tsx) is a <nav aria-label="Sidebar views">, w-[var(--rail-width)], holding the six RailButtons the Dock used to render, top-aligned rather than pinned to the rail's own bottom (a bottom cluster is the same Dock problem in miniature). ContextRail (ContextRail.tsx) is the same shape on the right edge: one button per CONTEXT_BAR_SECTIONS entry the active tab has something for.

  • RailButton (RailButton.tsx) is w-full h-9 with a w-4 h-4 icon, icon-only so aria-label is the accessible name and the chord (where there is one) stays out of it - a tooltip supplies aria-describedby while open, never a name. Two variants: "edge-left"/"edge-right" paint a 2px border-*-primary bar on the rail's outer edge when active and nothing else - not a filled tile, which would read as a toolbar of independent actions rather than one mutually-exclusive choice of what the Drawer shows. "tile" is the ordinary bg-accent text-accent-foreground icon-toggle look, for ContextRail's buttons, which are a multi-select set of expanded sections rather than a single current view.
  • ActivityRail's names, marks and order read from constants/drawer-views.ts and its chords from constants/shortcuts.ts, so the palette offering the same six cannot name them differently. Deliberately not role="toolbar", the same choice the Dock nav it replaces made: arrow keys move focus (ArrowUp/ArrowDown, roving tabindex, mirroring TabStrip's handler), but claiming full toolbar semantics for six toggle buttons would overstate what six toggle buttons are.
  • Clicking the open view's button closes the drawer. The buttons call activateDrawerView, which switches the view and toggles drawerOpen only when that view is already showing. A small status-success-text dot on the Services button is the one badge the footer carried that moved here - the running-services count itself stays in the Dock too (see below).
  • ContextRail renders nothing when the context bar has nothing for the active tab (contextBarHasContent, the same predicate the Dock's toggle used to read). Clicking a section's icon opens the bar if closed, expands that section if collapsed, and scrolls it into view with scrollWithin (@/lib/scroll-within, #1612) rather than Element.scrollIntoView; clicking the icon of the section that is the only one expanded collapses the whole bar. Present in both of ContextBar's layout modes - see below.

Dock

Status in the centre, per-tab view controls on the right, and every item has a non-footer path. #1615 moved the footer's two switchers onto the rails above and left it status-only; #1711 amends that rule to admit a right-aligned cluster of controls that act on the active tab's view. Dock (app/src/components/layout/Dock.tsx) is one flex row, h-[var(--dock-height)] px-2 gap-2 border-t border-border bg-panel shrink-0, with two equal flex-1 gutters around the centre group so the connection light does not shift when the right cluster comes and goes. The height is that token rather than a bare h-8 because the toast viewport is fixed and offsets itself above this strip by the same value - the token is what keeps the two from drifting apart.

The centre holds the engine connection light (a bg-current dot plus Starting… / Connected / Disconnected, on status-success-text when connected and --muted-foreground otherwise), a running-services button and a pending-restart button that render only when there is something to report, the save status, and the version string. This strip is where the connection state lives - no sidebar footer carries a second copy.

The right cluster renders only while the active tab is a request tab; every other tab keeps the centred strip alone. Its one control is ResponsePositionButton (app/src/components/layout/ResponsePositionButton.tsx), a TooltipIconButton that moves the response pane from beside the request to below it and back, its chord in the tooltip. The icon names the destination (see Pane Toggles below): PanelBottom while the response is beside ("Response below"), PanelRight while it is below ("Response beside"), swapped with IconSwap. While the setting is Auto the button shows Auto's current pick and a click writes an explicit choice. Right-click picks instead of flipping: a ContextMenuRadioGroup over Beside / Below / Auto, the same set as the Settings row, which is what puts Auto within reach from the strip and marks which setting is in force - the destination glyph by design does not say. → Dock.response-position.test.tsx (mutation-checked: without the request-tab gate, the settings and dashboard cases fail).

The rule that lets a control sit here at all is the one both amendments kept: every item has a path that is not the footer (pending restart is the banner in Settings, save status opens the tab it names, the response position has its chord ⇧⌘B, its palette row and its Settings > Appearance row), so the system Dock covering this strip on macOS costs a glance, never a click.

Drawer

The sidebar is Drawer (app/src/components/layout/Drawer.tsx): an <aside className="relative flex shrink-0 bg-panel"> whose width is drawerWidth from layout-store and whose right edge carries its own PanelResizeHandle. It renders nothing while drawerOpen is false, and it is labelled by the view showing ("Collections sidebar") because one landmark hosts six panels - collections, history, variables, services, trash and settings - and "Complementary" alone would not say which.

A drag does not persist per frame. PanelResizeHandle paints the live width straight onto the <aside>'s inline style.width (it is the handle's own parentElement) once per animation frame, and calls setWidth - the write layout-store persists - exactly once, on pointerup. Keyboard nudges and the double-click reset are discrete key presses and clicks, not a per-frame stream, so they still call setWidth straight away.

The Drawer wraps no view in a scroll region. Each view supplies its own DrawerPanel (app/src/components/shared/DrawerPanel.tsx), which owns the header - h-[var(--tabstrip-height)], so it lines up with the TabStrip across the resize handle - and the single overflow-y-auto overflow-x-hidden body below it. That frame, not the shell, is what a new view needs: switching views changes the content and nothing else. The body is flush, so rows run edge to edge and bring their own padding.

Pane Toggles

A toggle for a docked pane sits at the edge the pane opens from, and its icon names that side. A user reaches for the side the pane will appear on; a toggle on the far edge is not where anyone looks for it, and PanelRightOpen on a control that opens a pane to the left states the wrong thing outright. The GraphQL schema explorer had both faults at once: it is the first panel of a horizontal ResizablePanelGroup, so it opens on the left, while the chip that opened it sat at the right of the Query header drawing the PanelRight* pair (#1224).

  • Icons: PanelLeftOpen / PanelLeftClose for a pane on the left, PanelRightOpen / PanelRightClose for one on the right. Only the icon and the label change with state.
  • State: aria-expanded bound to the store the pane reads, never a literal.
  • One home: the toggle stays put when the pane opens, and the pane does not grow a close button of its own. A control that changes address with the state it controls teaches a position and then abandons it, and the two copies drift into saying the same thing differently.
  • A switch between two arrangements names the destination too. The Dock's response-position button draws PanelBottom while the response is beside the request and PanelRight while it is below: the glyph is where a click takes you, not where you are, because a glyph showing the current state reads as one more status in a strip full of them. The vocabulary for that arrangement is Beside / Below / Auto everywhere - UI strings, docs, code comments - never "vertical" or "horizontal", which name opposite things in Postman and in react-resizable-panels.

Component Patterns

Empty, error and loading - the three states of a data pane

Every pane backed by a query needs all three, and they were each hand-written before: casing split two ways ("No Run Selected" vs "No collections yet") and structure ranged from icon + heading + description + action down to one bare line of muted text. Three shared primitives in components/shared/ now cover it.

State Component Notes
Nothing here yet EmptyState variant="inline" for a single muted line inside a list; default is the centred icon + title + description. Both variants take an action
It broke ErrorState Takes the raw detail and an onRetry
Still loading DetailSkeleton rows prop, default 4

An empty state that has a create path says so (issue #1693). Thirty-odd call sites had one between them before, so "No environments" and "No mock running" were dead ends whose only way forward was a tooltip icon button one row up. Twelve now carry action={<Button variant="link">…</Button>}, wired to the same handler that header button uses - never a second, parallel create path, which is what would drift. Where the surface genuinely has no create of its own the action points at the surface that does ("Browse collections" from the mock pane), and where the state is one the user caused it undoes that ("Clear the filters", "Clear the filter"). A pane that is a selection prompt ("No run selected"), a not-found, a loading step or a wait on someone else's traffic gets none: there is nothing to offer. The inline variant takes an action for this reason - the drawer's group notes are inline and are where most of the dead ends were - and renders it under its single line, so the note stays one column wide inside an inset group.

ErrorState is deliberately not a variant of EmptyState. "Nothing here yet" and "this failed" are different messages with different affordances, and folding them into one component with a flag makes it easy to show the wrong one. ErrorState's icon is not a prop, either - one symbol for all failures.

The bug underneath the inconsistency is worth knowing. useQuery destructured as { data = [] } with no throwOnError resolves to [] when the request fails, and never reaches an ErrorBoundary - so six screens told the user their workspace was empty when the query had simply errored. When adding an error pane, gate it on length === 0: TanStack keeps last-good data through a failed background refetch, and covering still-valid content with a full-pane error is its own regression.

Sentence case for titles, everywhere.

Error text has three levels, and one component each

Level What it is Component
Field One control refused one value FieldError
Block A condition about the form or the pane, which may stack with others Callout
Pane The thing you came to look at did not load ErrorState

Field-level messages were hand-written before, in text-sm, text-xs and text-[11px] - the import dialog carried all three - some with a leading glyph, some announced and most not. FieldError is one size (text-xs; the sizes were the order the code was written in, not a hierarchy) and always role="alert", because a message that appears after a keystroke is a change nobody is looking at.

text-destructive-text is not by itself an error message: it is also the right foreground for a failure count, a "not defined" chip and the Dock's "Not saved". error-presentation.test.ts draws the line where it can be drawn mechanically - the token in a literal class string on the opening tag of a text-bearing element (<p>, <span>, <div>, <small>, <label>, a list item, a table cell, a heading) - and every deliberate exception is named in that guard with what the red text is instead, rather than the rule being widened until it passes.

Tab strips: one trigger look, three band chromes

The trigger has been shared for a while; the band around it was not. Seven call sites carried seven recipes - mx-5 mt-3, nothing at all, w-full px-1, px-5 with a border-b bg-panel, w-full px-4, bg-panel px-4, and px-3 py-1.5 border-b border-rule bg-muted/30 - so the same control read as a different piece of chrome in every pane. TabsList takes a required variant, and there is no default: a new strip says which of the three it is rather than inheriting whichever call site happened to be written first.

Variant Classes For
pane border-b border-rule bg-panel px-4 The strip is the pane's chrome band - dashboard, Collection Detail, the import dialog, the unified response viewer
inset px-1 A strip inside content that is already padded; the padding only keeps the first trigger's focus ring off the edge - the request strip, the load-test detail
bare none The band belongs to a parent row that holds other things beside the tabs - the response pane's strip shares its row with the response's facts and its actions

bare is not a fourth look. It is pane, drawn by whoever owns the row; the list adds nothing so the two cannot paint two rules a pixel apart.

The fill is bg-panel and the divider is border-rule with no surface class beside it - bg-panel is the :root default surface, the one place where the fallback value of --rule is the right answer (see "border-rule: let the surface pick the token"). Guard: tabs.test.tsx, both the rendered class lists and a scan asserting every call site in app/src declares a variant.

Loading

Three shapes, one rule each, decided once here rather than per module (issue #1683/#1689) because the app had accumulated Loader2 in 35 files, ListSkeleton/DetailSkeleton in about a dozen, EmptyState's iconClassName="animate-spin", and ImportProgressView - four idioms with nothing saying which one a new screen should reach for.

  • First load of a list or pane is a skeleton. ListSkeleton or DetailSkeleton - the shape of the content that is about to appear, so the layout does not jump when it arrives.
  • An in-place action on an existing control is an inline spinner. Loader2 inside the button or row that triggered it - Send, Stop, Save, a per-row purge. The control that was clicked is what shows it is working; nothing else on the pane should move.
  • A multi-step job is a progress view. ImportProgressView and its kin - several named steps with their own state, not a single spinner standing in for all of them.

A screen reaching for a fourth idiom, or for the wrong one of these three, is the bug this rule exists to catch.

Default entity names are Title Case ("New Collection", "New Folder"), even beside sentence-case headings and button labels ("No collections yet", "Delete forever?"). The name is a proper noun for the thing until the user renames it; the surrounding UI copy is not.

Cards

<div className="bg-card border border-border rounded-md p-4">
  ...
</div>

Never use hardcoded background colors like bg-gray-50, bg-blue-50, bg-zinc-900 for card surfaces. Always bg-card.

Section Eyebrow Label

<Eyebrow className="mb-4">Section Title</Eyebrow>

The class string behind it (text-label font-semibold uppercase tracking-[0.06em] text-muted-foreground) has one home: the Eyebrow primitive (app/src/components/ui/eyebrow.tsx), which is what a section label should render - it was extracted because the class was hand-typed in about a dozen components and two of them had already drifted. Eyebrow takes size="xs" for the denser 10px tier some panes run.

eyebrow.test.ts no longer guards only a verbatim copy of that literal: since

1692 it fails on any .tsx under app/src that combines uppercase with

a tracking- utility in one class string, which is the shape every hand-rolled eyebrow had. Files that genuinely need the combination for something that is not a section label are exempted there by name, with the reason. The command palette's group headings are an Eyebrow inside the element cmdk labels the group by.

Status Badges / Pills

Live (running):

<span className="flex items-center gap-1.5 px-2 py-0.5 rounded-full text-label font-semibold tracking-wide bg-green-500/15 text-green-500 border border-green-500/25">
  <span className="w-1.5 h-1.5 rounded-full bg-green-500 animate-pulse" />
  LIVE
</span>

Completed / Stopped:

<span className="flex items-center gap-1.5 px-2 py-0.5 rounded-full text-label font-semibold tracking-wide bg-muted text-muted-foreground border border-border">
  COMPLETED
</span>

Status is never colour alone. A badge, a chip or a glyph that encodes its state only as a hue says nothing to a red/green confusion, a monochrome display or a greyscale screenshot - and every status surface here is small, where hue is hardest to judge. The rule is redundancy: shape or a word carries the state, and the colour agrees with it. The LIVE pill above carries the word; the history row carries the shape.

Run status glyph (RunItem): one lucide glyph per status, in the family's -text token (the bare token is a fill and fails AA as a small foreground - see "The bare token is the fill"). It replaced a bare coloured dot, which was five identical circles in five colours.

const STATUS_GLYPH = {
  completed: { icon: CircleCheck,  className: "text-status-success-text" },
  failed:    { icon: CircleX,      className: "text-status-error-text" },
  running:   { icon: Loader2,      className: "text-status-running-text animate-spin" },
  stopped:   { icon: CircleSlash,  className: "text-status-stopped-text" },
  pending:   { icon: Circle,       className: "text-muted-foreground" },
};

The glyph is aria-hidden: the row's own accessible name states the status in words, so a screen reader hears it once rather than twice. A glyph that has something no adjacent text says takes role="img" and an aria-label on the same element instead - role-img-labelled.test.ts holds that half.

Toasts

Transient report of an action the user just took. The queue is stores/toast-store.ts; the surface is the shadcn/Radix primitive in components/ui/toast.tsx, rendered once by components/shared/Toaster.tsx.

Four things about toasts are user-configurable (Settings -> Notifications, persisted in client-settings-store as notifications, options and defaults in constants/toast.ts):

Setting Values Default Applied
position 6: each corner plus top/bottom centre bottom-right Toaster (viewport class + swipe side)
durationScale short 0.5x, default 1x, long 2x, never default at enqueue
maxVisible 1-8 4 at enqueue
minSeverity all, warning, error, none all at enqueue

Three of the four are resolved when a toast is enqueued, not when it is drawn, so changing them does not restyle what is already on screen. That is why the panel has a Preview button.

The duration setting is a multiplier over the per-variant durations in the table below, never a replacement for them: those are tuned so a failure outlasts a confirmation, and a flat "5 seconds for everything" would throw that away. never resolves to a 24h sentinel rather than Infinity, because the primitive arms a real setTimeout and a non-finite delay there is coerced to 1.

Every position clears the chrome on its own edge, via --dock-height at the bottom and --titlebar-height plus --tabstrip-height at the top - never a round number. The top edge is two bands since the shell split the title row from the tab strip, and clearing only the first put the stack back on the chrome 32px lower. The stack is position: fixed, so it anchors to the window rather than the layout, and a plain bottom-4 once put it on top of the Dock. toast-position.test.tsx checks all six.

The icon carries the variant; the rail reinforces it. Never colour alone. The version this replaced signalled variant with a 40%-alpha border and nothing else: border-destructive/40 measured 1.16:1 against the toast surface in dark and 2.01:1 in light, while success's equivalent measured 2.21 / 1.42 - so the two were not reliably tellable apart in either theme, and error was effectively invisible in dark. All four variants now come from one token family:

Variant Icon Rail (a rule) Glyph (a foreground) Duration
info Info border-l-border text-muted-foreground 4s
success CheckCircle2 border-l-status-success text-status-success-text 4s
warning AlertTriangle border-l-status-warning text-status-warning-text 6s
error XCircle border-l-status-error text-status-error-text 10s

Measured against the popover surface, light / dark:

Variant Rail Icon
info 1.30 / 1.00 5.61 / 6.77
success 2.30 / 7.53 5.71 / 8.81
warning 4.00 / 4.34 5.46 / 9.81
error 3.78 / 4.59 5.88 / 5.85

Two of those rows look wrong and are not. info is the neutral variant and takes no accent rail on purpose - border-l-border is invisible against the toast's own fill, and absence of a rail is itself the signal. And success's rail on white is 2.30, under the 3:1 a graphic needs when it is the sole carrier of meaning. It is not the sole carrier: every icon clears 5.4:1 in both themes. That is the whole reason the icon exists.

The rail and the glyph take different tiers of the same family on purpose: a rail is a rule and takes the bare --status-*, a glyph is painted with a text- utility and takes --status-*-text, the tier tuned to be read against a background. status-color-tokens.test.ts enforces the second half repo-wide. Neither ever takes --status-*-fill, which is only correct under a white label.

The shell keeps bg-popover with a border-border edge. That edge faces the canvas, which is the case border-border is for. It is deliberately not border-rule: no surface-popover class is declared, and border-rule under no declared surface falls back to the invisible default.

The stack sits above the Dock, not on it - bottom-[calc(var(--dock-height)+1rem)], keeping the same 1rem of air it has on its right edge. See Geometry for why that is a token and not a literal. It also clears dialogs at z-50 on z-[100], which is hit-tested rather than assumed, since a dialog portals to body while the viewport lives in #root.

Durations are floors, not limits - the primitive pauses them on hover, focus and window blur. A failure gets longer than a confirmation because it often carries a cause from the engine ("database is locked") that takes longer to take in.

Queue policy lives in the store, because the primitive has no opinion on it: an identical message and variant already on screen is collapsed rather than stacked (the OAuth2 guard retries; an SSE stream can fail on every reconnect), and past four the oldest is dropped so a burst cannot run off-screen where it is unreachable and undismissable.

Everything is polite, including errors (type="background"). A toast dismisses itself on a timer and always reports something the user just asked for, so interrupting what they are reading is the wrong trade.

Inline rename

Every tree that renames a row in place does it through one hook, app/src/hooks/useInlineRename.ts - the collections tree, the variables sidebar's environments and the element list. The field is an ordinary Input at the row's height (h-6 flex-1 text-sm) replacing the row's label; the hook owns the behaviour:

  • Enter commits only through isCommitEnter (@/lib/keyboard), never a bare e.key === "Enter". An IME commits its composition buffer with an ordinary Enter keydown, and mod+Enter is the app's Send chord - a field acting on it renamed the row and sent the request from one press. commit-enter.test.ts fails on any new bare comparison outside the hand-rolled activation controls it names.
  • Escape never commits. The cancel unmounts the field and the blur that follows is the one the cancel caused, so the hook closes the editor once and that blur does nothing. A blur the user caused still commits.
  • The name is trimmed, and an empty one cancels rather than erasing a name. The one exception is a field whose entity has an optional name (an element row falls back to its kind label), which asks for commitEmpty.
  • Focus returns to the row after a keyboard close, never after a blur. Each tree is one tab stop and the field replaces the row's only focusable control, so an Escape that left focus on <body> would drop the user out of the tree. After a blur, focus has already gone where the user put it.

A surface that renames something in place uses the hook rather than a fourth copy of those four rules.

Copy feedback

A copy is acknowledged one of two ways, and which one is decided by the control, not by the surface:

  • An icon button swaps its glyph: IconSwap from Copy to Check, through useCopy({ feedback: "icon" }). No toast beside it - the swap already said it.
  • A menu item or a text button toasts: plain useCopy(), whose toast names the value that was copied, so a surface with several copy controls says which one it means.

One duration, TIMING.COPY_RESET_MS, and the hook owns the timer. This used to be three - 1500ms in the MCP settings panel, 2000ms in the two update surfaces, STATUS_RESET_MS in the response viewer and the snippet section - because each call site kept its own copied flag and its own setTimeout.

Every clipboard write goes through useCopy. navigator.clipboard.writeText rejects on a denied permission, an unfocused document, or a platform with no clipboard behind the API, and a call site that awaits it with no catch simply never reaches the line that draws the feedback: the copy fails and the user's only evidence is pasting the previous clipboard contents somewhere else. clipboard-single-writer.test.ts holds the rule to two files - the hook, and errors/ErrorBoundary.tsx, which runs when the tree below it has already failed and no hook is reachable.

A failure toasts in both modes. An icon button has no failure glyph, and a check that simply never appears is the same silence again.

A disabled control says why: DisabledHint

A gated control that gives no reason reads as a broken one, and the obvious fix does not work: Button's baseline is disabled:pointer-events-none, so a Tooltip wrapped around a disabled button never receives the pointer events its trigger listens for, and a disabled button is not focusable either. Both paths to an explanation close at the moment there is something to explain.

DisabledHint (app/src/components/ui/disabled-hint.tsx) is the wrapper that keeps them open - a span with its own pointer-events-auto and its own tab stop, holding the tooltip, with the inert control inside it:

<DisabledHint reason={captures.length === 0 && "No captures to clear"}>
  <Button disabled={captures.length === 0}>Clear</Button>
</DisabledHint>
  • reason is the switch as well as the text. Falsy renders the children alone, so one expression covers both states rather than two copies of the same button - which is how a hover class or an aria-label ends up on one of them and not the other.
  • The gate stays the child's disabled prop. This adds the explanation and changes nothing about what refuses the click.
  • The reason names the state, not the remedy in full. "No captures to clear", "Already first", "No unsaved changes" - one line, present tense. A sentence that needs more than that belongs beside the control, as the elements list's missing-field note already is.
  • A disabled menu item is the exception. RowAction's disabledReason (components/shared/row-actions.ts) draws the reason on the item itself, because a tooltip inside a menu's focus trap competes with the menu for the keyboard and Radix deliberately skips a disabled item. Both menus get it at once through RowActionBody. See Row Actions.

Destructive Actions

/* Error/warning banner */
<div className="bg-destructive/10 text-destructive rounded-md p-3">...</div>

/* Stop button */
<Button
  variant="ghost"
  className="text-destructive hover:bg-destructive/10 hover:text-destructive border border-destructive/30"
>
  Stop
</Button>

/* Delete icon button */
<Button
  variant="ghost"
  size="icon"
  className="h-6 w-6 hover:bg-destructive/10 hover:text-destructive opacity-0 group-hover:opacity-100"
>
  <Trash2 className="w-3 h-3" />
</Button>

Reversibility

Two lanes, decided once here rather than per module (issue #1683/#1689):

  • Collections and requests go to Trash, with Undo. Deleting either is recoverable for the retention window Settings shows - the undo toast handles the immediate "I clicked the wrong row", and Trash's own restore handles everything after that.
  • Everything else confirms and deletes permanently: environments, history runs, load-test examples, inbox listeners and their captures, certificates. The owner decided this deliberately (#1683) - a second Trash lane for every other entity is not coming, so a module reaching for one should reach for DeleteConfirmDialog instead.
  • A destructive action never runs from a single click without one of the two. A control that removes data either lands in Trash (no confirm needed
  • it is reversible) or opens DeleteConfirmDialog (no Trash - it is not). Wiring a mutation straight to a button's onClick for anything that destroys data is the bug this rule exists to catch (#1689's Clear captures and Empty trash were both found this way).
  • One template writes the sentence. DeleteConfirmDialog takes name and an optional scope ("default" or "cascade") and builds "Delete {name}? {name} is removed permanently." itself, with a suffix for a container that takes its contents with it. A call site passes description only for prose the template cannot say - a count, a conditional clause - never to restate what name/scope already produce.

URL Bar (Flat Style)

<div className="flex items-center gap-2 px-4 py-2 border-b border-border bg-panel shrink-0">
  <MethodSelector />   {/* w-[76px] h-[34px] bg-accent font-mono font-semibold text-label */}
  <UrlInput className="flex-1 h-[34px] bg-card border border-border rounded-md px-3 text-[13px] font-mono focus:border-primary focus:outline-none transition-colors" />

  {/* Primary action */}
  <button className="h-[34px] px-4 rounded-md bg-primary text-white text-[13px] font-semibold ...">
    {isExecuting
      ? <><span className="w-3 h-3 border-2 border-white/40 border-t-white rounded-full animate-vayu-spin inline-block" /> Sending</>
      : <>▶ Send</>
    }
  </button>

  {/* Secondary action - always token-based (text-primary/border-primary/bg-primary/10), never hardcoded purple */}
  <button className="h-[34px] px-3.5 rounded-md text-[12px] font-semibold text-primary border border-primary bg-primary/10 ...">
    <Zap className="w-3.5 h-3.5" /> Load Test
  </button>
</div>

Dashboard Header (Compact 52px)

<div className="h-[52px] flex items-center gap-3 px-5 bg-panel border-b border-border shrink-0">
  {/* Status pill - LIVE (green animated dot) or COMPLETED/STOPPED (muted) */}
  {/* Method badge - inline <span> with hsl(var(--method-xxx)) inline style */}
  {/* URL - font-mono text-[12px] flex-1 truncate */}
  {/* Config summary - text-[12px] text-muted-foreground hidden sm:block */}
  {/* Stop button - ghost variant, destructive color, Loader2 spinner while stopping */}
</div>

The header has a live elapsed timer (liveTick state) that resets to 0 at the start of each run.

SVG Sparkline

function Sparkline({ data, color }: { data: number[]; color: string }) {
  if (!data || data.length < 2) return null;
  const max = Math.max(...data), min = Math.min(...data), rng = max - min || 1;
  const w = 108, h = 26;
  const pts = data.map((v, i) =>
    `${(1 + (i / (data.length - 1)) * w).toFixed(1)},${(1 + h * (1 - (v - min) / rng)).toFixed(1)}`
  );
  const area = `M1,${h + 1} L${pts.join(" L")} L${w + 1},${h + 1}Z`;
  return (
    <svg width={110} height={28} className="block overflow-visible">
      <path d={area} fill={color} fillOpacity="0.15" />
      <polyline points={pts.join(" ")} fill="none" stroke={color} strokeWidth="1.5" strokeLinejoin="round" strokeLinecap="round" />
    </svg>
  );
}

SVG Area Chart

viewBox="0 0 600 150", padding PL=42 PR=8 PT=6 PB=20. Area fill uses fillOpacity="0.12" (slightly less than the sparkline's 0.15). Grid lines use hsl(var(--border)) with strokeDasharray="2 2". Axis labels use hsl(var(--muted-foreground)) in JetBrains Mono at fontSize="9". Requires data.length >= 2 (returns null otherwise).

Hero Metric Card

┌─────────────────────────────────────────┐
│ LABEL (11px, uppercase, muted)          │
│ 34px bold mono value   unit (xs, muted) │
│ sub-label (11px, muted)                 │
│ [sparkline 110w] (optional, below)      │
└─────────────────────────────────────────┘
<div className="bg-card border border-border rounded-md p-4 flex flex-col gap-1">
  <Eyebrow>{label}</Eyebrow>
  <div className="flex items-baseline gap-1.5 mt-0.5">
    <span
      className="text-[34px] font-bold leading-none font-mono tabular-nums"
      style={{ color: valueColor || "hsl(var(--foreground))" }}
    >
      {value}
    </span>
    {unit && <span className="text-xs text-muted-foreground">{unit}</span>}
  </div>
  {sub && <p className="text-label text-muted-foreground mt-0.5">{sub}</p>}
  {sparkData && sparkData.length > 1 && (
    <div className="mt-2">
      <Sparkline data={sparkData} color={sparkColor || "hsl(var(--primary))"} />
    </div>
  )}
</div>

Note: sparkline renders below the value row, not beside it.

Secondary Stat Card

<div className="bg-card border border-border rounded-md p-3">
  <Eyebrow className="mb-1.5">{label}</Eyebrow>
  <div className="flex items-baseline gap-1">
    <span className="text-[22px] font-bold font-mono text-foreground">{value}</span>
    {unit && <span className="text-xs text-muted-foreground">{unit}</span>}
  </div>
</div>

Latency Distribution Bar

Gradient track (green→amber→red at 18% opacity), with absolute-positioned needle markers at p50/p95/p99. Each marker consists of: - A 1px-wide, 16px-tall vertical pin: w-px h-4 mx-auto opacity-85 - A dot below it: w-2 h-2 rounded-full mx-auto -mt-1 with boxShadow: "0 0 0 2px hsl(var(--card))" (creates the ring effect without Tailwind ring classes) - Value label + percentile label below

Progress Bar

components/ui/progress.tsx, over @radix-ui/react-progress. Track h-1.5 w-full overflow-hidden rounded-full surface-sunken; fill h-full rounded-full bg-primary.

Three choices worth stating, because each has a plausible wrong answer:

  • bg-primary, not bg-primary-fill. --primary-fill exists for solids that carry a white label; a progress fill carries none, so it follows the accent rule and brightens in dark like every other accent surface.
  • surface-sunken for the track. It is the one recessed fill that reads on a card in both themes (1.356 / 1.343), and on --muted / --accent no border token works at all - see the borders section.
  • rounded-full, not a radius token. A track is a pill at every roundedness setting, which is that setting's documented fixed-radius exemption. A bare rounded would pin it to 4px and fail radius-token.test.tsx.

Determinate sets the fill's width and transitions it (transition-[width] duration-200 ease-out). Indeterminate is a w-1/3 stripe crossing the track via .progress-indeterminate - growth from the start would read as a fraction, and there is no fraction to report.


Tailwind Utility Reference

Token Tailwind class
Canvas background bg-background
Panel background bg-panel
Card background bg-card
Primary text text-foreground
Secondary text text-muted-foreground
De-emphasized text text-subtle-foreground
Primary accent text-primary, bg-primary, border-primary
Default border border-border
Strong border border-border-strong
Hover state hover:bg-accent
Selected state bg-accent-active
Success text-success, bg-success/10
Warning text-warning, bg-warning/10
Info text-info, bg-info/10
Error text-destructive, bg-destructive/10
Method text (GET) method-get (and method-post, method-put, etc.)
Method bg (GET) bg-method-get (and bg-method-post, etc.)
Mono font font-mono
Code font (utility) font-code
Thin scrollbar nothing - 6px is the global baseline (see Scrollbar)
Tab-strip scrollbar scrollbar-strip (4px, thumb on hover)
Variable color text-variable or .variable-highlight

Never use

  • bg-gray-*, bg-zinc-*, bg-slate-* - use bg-card, bg-panel, bg-background
  • bg-blue-50, bg-red-950, etc. - use bg-destructive/10, bg-info/10, etc.
  • dark:bg-* hardcoded overrides - tokens handle both modes automatically
  • text-gray-500, text-gray-400 - use text-muted-foreground
  • Hardcoded hex method colors like text-[#22c55e] - use method-get or hsl(var(--method-get))
  • ${hexColor}18 hex-alpha concatenation - use hsl(var(--method-xxx) / 0.1)
  • Hardcoded purple for Load Test / secondary actions - use text-primary/border-primary/bg-primary/10

These rules are enforced for the request/response tree by modules/request-builder/components/ResponseViewer/palette-tokens.test.ts. They were documented long before they were enforced, and the tree had drifted: seven usages of text-green-500 / text-blue-500 and friends, every one of which failed its contrast bar in light mode (1.63–3.76 against thresholds of 3.0 and 4.5) while passing in dark. That asymmetry is inherent to a raw palette class rather than bad luck - one value cannot suit a white card and a near-black one, so the light failure is unfixable without breaking dark. The per-theme -text tokens clear both.

The guard is scoped to the trees that were measured. Elsewhere the raw palette classes appear as explicit bg-blue-50 dark:bg-blue-950 pairs, which are theme-aware and so are not this defect; converting those needs new tokens (there is no purple or info -text token today) and is a design decision.


Response Body Syntax Highlighting

Planned / pending implementation. Intended colors for the JSON pretty-printer:

Token Color
Object keys #7dd3fc (sky-300)
String values #86efac (green-300)
Number values #fbbf24 (amber-400)
Boolean values #a78bfa (violet-400)
Null #94a3b8 (slate-400)

Monaco Editor Widgets

CodeEditor used to hand Monaco its own built-in theme names (vs / vs-dark), so every widget Monaco draws around the text - the suggest list, the find and replace widget, the hover card, the context menu - painted VS Code's palette next to the app's Radix popovers and tokens. lib/monaco-theme.ts now defines vayu-light and vayu-dark through monaco.editor.defineTheme: each inherits the built-in theme's syntax colours and replaces only the chrome, so a token change reaches the editor the same way it reaches every other surface. WIDGET_COLORS in that file is the mapping below; a key it does not name keeps whatever the base theme says, which is correct for anything the tokens have no opinion about (bracket-pair colours, the diff editor).

The editor canvas itself, so it reads as the app's surface rather than Monaco's own near-black default in dark mode:

Monaco colour key Token
editor.background, editorGutter.background background
editor.foreground, editorLineNumber.activeForeground, editorCursor.foreground foreground
editorLineNumber.foreground muted-foreground

Selection and find matches, painted on Monaco's own canvas rather than as DOM the app's CSS could reach:

Monaco colour key Token
editor.selectionBackground, editor.inactiveSelectionBackground, editor.selectionHighlightBackground primary
editor.findMatchBackground, editor.findMatchHighlightBackground primary

The floating surfaces - the suggest widget, the hover card, the context menu, and the generic editor widget - take the same three roles a Radix popover uses:

Monaco colour key Token
focusBorder primary
editorWidget.background, editorSuggestWidget.background, editorHoverWidget.background, menu.background popover
editorWidget.foreground, editorSuggestWidget.foreground, editorHoverWidget.foreground, menu.foreground popover-foreground
editorWidget.border, editorSuggestWidget.border, editorHoverWidget.border, menu.border border

The rows inside them - the suggest list and the context menu are both Monaco lists:

Monaco colour key Token
editorSuggestWidget.selectedBackground, list.hoverBackground, list.focusBackground, menu.selectionBackground accent
editorSuggestWidget.selectedForeground, list.hoverForeground, list.focusForeground, menu.selectionForeground accent-foreground
editorSuggestWidget.highlightForeground, editorSuggestWidget.focusHighlightForeground, list.highlightForeground primary

The find widget's input, a plain box in VS Code's palette:

Monaco colour key Token
input.background background
input.foreground foreground
input.border border
inputOption.activeBorder primary

The scrollbar slider carries the same two alphas as ::-webkit-scrollbar-thumb's rest and hover states (see Scrollbar), plus a third for the pressed state Monaco has and the native bar does not:

Monaco colour key Token
scrollbarSlider.background muted-foreground
scrollbarSlider.hoverBackground muted-foreground
scrollbarSlider.activeBackground muted-foreground

Two constraints follow from how the theme is built. Only the mode the document is currently wearing can be defined - getComputedStyle reports the live values, so the light palette is unreadable while .dark is on <html>, and the reverse. And the theme is registered during Monaco's composition, before any editor is created, and redefined from a MutationObserver on <html>'s class and data-color-scheme, never from a React effect - a child's effects run before its parent's, <Editor> is the child, and Monaco answers a theme name it does not know by silently falling back to vs and never revisiting it. Redefining the theme that is currently showing is what lets a colour-scheme change reach an open editor without a reload.

IStandaloneThemeData has no key for font or corner radius, so those two still live in index.css, scoped under .monaco-editor - and, unlike every other rule in that file, outside @layer and prefixed with html. Monaco ships unlayered CSS that declares both properties on the same two-class selectors, and an unlayered declaration beats a layered one at any specificity, so the same rule inside @layer utilities never applies at all; unlayered it merely ties, and Monaco's stylesheet arrives after this one with the lazily loaded editor chunk. Any future rule that has to win against a widget Monaco styles itself needs both halves. monaco-theme.test.ts reads them back out of the stylesheet and fails if either is dropped.

Guarded by lib/monaco-theme.test.ts (the builder, plus a source scan that no file outside the theme module passes "vs" / "vs-dark"), components/ui/code-editor.theme.test.tsx, and the sixth concern in lib/monaco-setup.contributions.test.ts.


Scrollbar

Thin scrollbars are a global baseline, not a utility. Every scroll container gets them; there is nothing to remember and nothing to apply.

/* index.css, @layer base */
@supports not selector(::-webkit-scrollbar) {
    :where(*) {
        scrollbar-width: thin;
        scrollbar-color: hsl(var(--muted-foreground) / 0.3) transparent;
    }
}
:where(.overflow-auto, .overflow-y-auto, .overflow-scroll, .overflow-y-scroll) {
    scrollbar-gutter: stable;
}
::-webkit-scrollbar {
    @apply w-1.5 h-1.5;
}

The @supports guard is load-bearing, not defensive. Since Chromium 121 a scroller that declares scrollbar-width or scrollbar-color renders the standard scrollbar and ignores every ::-webkit-scrollbar rule that applies to it - the two systems do not compose. The properties were declared globally on :where(*), so the whole webkit block below them was inert: the stylesheet said 8px and the app drew Chromium's thin, which measures 10px. That gap is the "scrollbars read too thick" report. Measured on Chromium 141: a scroller with scrollbar-width: thin and a 6px ::-webkit-scrollbar renders 10px; the same scroller with the standard properties behind the guard renders 6px.

So scrollbar-width and scrollbar-color belong nowhere outside that guard. Setting either on an element turns that element's webkit rules off, silently - the bar stays, at the wrong width, in the wrong colour.

6px, and 6px in all three systems. The app draws scrollbars three ways, and only the first is reached by a stylesheet at all:

System Where the width lives Was
Native overflow panes index.css, the block above 8px declared, 10px drawn
Radix ScrollArea scroll-area.tsx class list 10px, bg-border thumb
Monaco editors code-editor.tsx, SCROLLBAR_SIZE in px 14px vertical, 12px horizontal

They are read side by side - a body panel puts an editor, a ScrollArea and a plain scroll pane in one view - so all three carry one number. ScrollArea also repeats the baseline's muted-foreground/30 thumb: --border, shadcn's default, is the same colour as --card in dark, which is an invisible thumb on the surface that component is usually laid over. Monaco renders its bars as its own DOM inside the editor and takes a number, not a class, which is why no sweep of the stylesheet can see it drift.

scrollbar-systems.test.ts derives the two repeats from the CSS, so the three move together or the suite reddens.

Do not take a content pane below 6px: the thumb stops being a mouse target.

The baseline also reserves a 6px gutter with scrollbar-gutter: stable on scroll containers (via Tailwind utility classes), placed outside the @supports guard because this property does not trigger the standard-property opt-out that affects width and color. Per CSS Overflow 4, the gutter reserves for overflow: hidden too, so it is scoped via CSS class selectors rather than applying globally - overflow-hidden is used throughout the app for text truncation and clipping, and deserves no dead 6px strip.

Tab strips: scrollbar-strip

A TabsList that scrolls natively (request builder, response viewer, collection detail) draws the baseline bar directly under a 24px band of tabs. The scoped utility takes those strips to 4px and hides the thumb until the strip is hovered:

/* index.css, @layer utilities */
.scrollbar-strip::-webkit-scrollbar {
    @apply w-1 h-1;
}
.scrollbar-strip::-webkit-scrollbar-thumb {
    @apply bg-transparent;
}
.scrollbar-strip:hover::-webkit-scrollbar-thumb {
    @apply bg-muted-foreground/30;
}

Chromium repaints scrollbar pseudo-elements on the scroller's own :hover, so the reveal costs no JS. This is the one blessed exception to the 6px floor: a strip is scrolled by wheel or by tab focus rather than dragged.

This was a .scrollbar-thin class applied per element, and it drifted badly: 38 of the app's 44 scroll containers never got it, so chunky arrow-button scrollbars appeared mid-UI. Two traps made the class approach unfixable by discipline alone:

  • scrollbar-width is not inherited. A styled ancestor does nothing for a nested scroll container. This is exactly how the History run list ended up with a platform scrollbar inside an already-styled panel.
  • Styling ::-webkit-scrollbar at all is what removes the stepper arrows. So an unstyled container did not merely look slightly different - it grew arrow buttons, which is a different control, not a different colour.

:where() keeps specificity at zero, so an element that genuinely needs a different scrollbar can still override with a plain class.

Electron is Chromium, so the webkit rules are the ones that render here; scrollbar-width is the standards-track fallback for an engine that has no such pseudo-element, which is why it sits behind the @supports guard above rather than beside the rules it would otherwise disable.


Motion

Motion collapses for two independent reasons, and both must keep working.

/* index.css - outside @layer, so the !important declarations win */

/* 1. The in-app toggle: Settings → Appearance → Reduced motion */
html[data-reduced-motion="true"], html[data-reduced-motion="true"] * {  }

/* 2. The system preference, which a user states once for every app */
@media (prefers-reduced-motion: reduce) { *, *::before, *::after {  } }

Both collapse the same four properties - animation-duration, animation-iteration-count, transition-duration, scroll-behavior. A declaration added to one and forgotten in the other leaves the system-preference path animating something the toggle stops, which reduced-motion.test.ts guards.

The system preference was ignored until it wasn't. The toggle shipped first, and prefers-reduced-motion appeared nowhere in the stylesheet - so someone who had turned Reduce Motion on in Windows, macOS or GNOME got every animation until they found a checkbox in Vayu and said it a second time.

The two are additive, and deliberately only in one direction. The toggle forces the collapse for a system that asks for nothing. There is no way to opt back into animation against a system that asked for less; that is the one direction where guessing wrong has a cost.

Which means the switch can read "off" while nothing animates, so the Appearance panel says so when usePrefersReducedMotion() is true. Without that line the only explanation for the app's behaviour lives in another application's settings.

Nothing animates from JavaScript. No element.animate(), no requestAnimationFrame loops, and the single scrollIntoView passes no behavior, so it follows the scroll-behavior the rules above set. Keep it that way: JS-driven motion is invisible to both rules and would need its own opt-out.

Icon motion

An icon animates only as feedback for the action it is the affordance for, and the rules that do it are CSS, in the Icon motion block of app/src/index.css. There is no motion/framer-motion dependency and there will not be one: the lucide-animated registry ships a component per icon, each wrapping the glyph in a div that breaks button-variants.ts's [&_svg:not([class*='size-'])]:size-icon sizing and its [&_svg]:pointer-events-none, and JS-driven motion is invisible to both collapse rules above. A transition or a keyframe animation is stopped by them for free.

The policy, in five rules:

  1. The owner triggers, never the icon. A motion fires from the :hover / :focus-visible of the glyph's own [data-slot="button"] or the .group row that reveals it - never the SVG's own hover, which [&_svg]:pointer-events-none has already taken away. :focus-visible is not optional: a keyboard user gets the same feedback a mouse user does.
  2. A glyph conveying a state never animates - and that is a rule about placement, not about which glyph it is. The six glyphs that usually carry a state (AlertTriangle, AlertCircle, CheckCircle2, XCircle, Clock, Info) animate nowhere they are the message: a status chip, a row's status column, an inline warning, a panel heading. A state the user is being told about is not an action they can take, and a success tick that grows under the pointer invites a click on something that does nothing.

Where one of those same glyphs is the affordance for an action - inside a [data-slot="button"], a menu item, a tab trigger, a RowAction, or a registry entry a rail draws as a button (DRAWER_VIEWS) - it animates like any other action glyph. History's Clock in the Activity Rail is the case this was refined for (#1707): it means "go to History", and it earned a motion of its own rather than nothing. icon-motion-status.test.ts encodes the distinction the same way, by the owner the element sits inside. 3. A state change animates once. Loops are reserved for Loader2 and live indicators, where the loop is the message ("work is happening"). 4. Every transform of an SVG child declares transform-box: fill-box and an explicit transform-origin. The initial reference box is the view-box, so a percentage origin otherwise resolves against the whole 24x24 canvas instead of the path, and a hinge lands nowhere near the hinge. Use the standalone rotate / translate / scale properties, never transform:, so they compose with any transform the element already carries - the same reasoning as press feedback's scale. 5. Durations and eases come only from the --dur-* / --ease-* tokens, through --icon-motion-duration. That name is deliberately not --tw-duration: a .motion-menu / .motion-tooltip ancestor sets that one for tw-animate-css, and reading it here would let a menu's tier leak into the glyph inside it. No will-change - a standing compositor hint on every icon in a hovered row costs more than a 12px glyph's 200-560ms buys.

A call site spells the name once, as data-icon-motion on the icon, and takes it from ICON_MOTION in app/src/components/ui/icon-motion.ts so a name the stylesheet does not implement is a compile error rather than a dead attribute.

Name Icons Motion Duration Leaves the frame
lid Trash2 Lid bar and handle hinge up off the can (rotate: -12deg, translate: 0 -1px) about the bar's left end --dur-icon-nudge yes
hands Clock The hands polyline sweeps one full revolution about the dial centre (12,12); the dial stays --dur-icon-play x2 no
waves Radio The four arcs travel outward and fade, inner pair then outer pair; the centre dot stays --dur-icon-play x2 yes
spread Braces The two curves part by 1px each, outward, and close again --dur-icon-nudge no
tilt FolderOpen Tips -6deg with a 4% grow about the ink's bottom-left corner (2,20) - a folder opening toward you --dur-icon-nudge yes
wiggle Search -8deg, +8deg, back, once, about the lens centre (11,11) --dur-icon-play no
drop Download The arrow (shaft and head) drops 2px into its tray; the tray stays --dur-icon-nudge no
lift Upload The arrow rises 2px out of its tray; the tray stays --dur-icon-nudge yes
press Save The whole glyph goes to 92% and back, a button pressed --dur-icon-play no
tilt-pin Pin, PinOff Leans -20deg about the needle's point (12,22) and rights itself --dur-icon-play yes
ring Bell A decaying swing (+12deg, -10deg, +6deg, 0) about the point it hangs from (12,2) --dur-icon-play x2 yes
bob Info A 1.5px rise and settle, for a mark with no part to hinge and no direction of its own --dur-icon-play yes
part Code2, Code The two chevrons part by 1px each. spread's reading against a glyph lucide draws right-to-left, which is why it is not that name --dur-icon-nudge yes
tiles LayoutDashboard The four tiles step 1px away from the frame's centre and back, diagonal pairs staggered --dur-icon-play no
sweep Gauge The needle swings -70deg about its hub (12,14) and returns; the dial stays --dur-icon-play x2 no
plug-in Plug 1.5px along the axis the prongs point, which is up --dur-icon-nudge yes
pulse Network The three node rects swell 18% in turn, top node first; the connectors stay --dur-icon-play x2 no
trace Activity The trace is stroked on from its left end, over a measured path length --dur-icon-play x2 no
stack Database The top disc lifts 1.5px off the stack and settles back --dur-icon-nudge yes
spin-once RefreshCw One 360deg turn (@keyframes icon-spin-once), so pointer-out does not unwind it backwards --dur-icon-play no
spin-back RotateCcw The same turn counter-clockwise, for "put it back" rather than "do it again" --dur-icon-play no
flash Zap Dims to 40% and back with a 6% grow - a strike, not a movement --dur-icon-play no
rotate-90 Plus, X A quarter turn; both glyphs are symmetric under it, so only the movement is visible --dur-icon-nudge no
nudge-x ChevronRight, SlidersHorizontal 1px along the direction it points --dur-icon-nudge no
nudge-y ChevronDown The same, vertically --dur-icon-nudge no
scale Play The whole glyph grows 10% - the last resort for a mark that is one path with no reading of its own to act out --dur-icon-nudge no

A motion whose ink leaves the 24-unit viewBox sets overflow: visible on the svg, because an inline SVG clips to its viewBox by default and the travel is simply cut off at every call site. The column above is the list, and icon-motion.test.tsx holds it to the stylesheet - in both directions, so a visible on a motion that stays inside is caught too.

A draw-in is measured, never guessed. trace strokes its path on with stroke-dasharray and stroke-dashoffset, and the dash has to be at least the path's own length or a second dash creeps in behind the first. Measure it with getTotalLength() in a real browser, round up, and write the measurement and where it came from in the rule's comment. Both properties are set inside the keyframes, never on the element: a dasharray parked on the glyph is a permanent property that happens to look solid today.

Two glyphs that read alike may still need two names. spread (Braces) and part (Code2, Code) are the same idea - a pair of marks parting by a pixel - and cannot share a rule, because lucide draws Braces left-bracket first and both code glyphs right-chevron first. Reusing one name would have drawn the brackets closing, which looks like a considered choice in a diff. When the reading matches but the child order does not, the second name is the honest answer.

A sequence that genuinely needs longer than its tier - hands, waves, ring, sweep, pulse, trace - multiplies the token (calc(var(--dur-icon-play) * 2)) rather than introducing a literal, and says why in the rule's comment. A stagger inside a sequence is keyframe percentages, never animation-delay: the reduced-motion rules collapse a duration, not a delay, so a delayed step would survive them as a pause before an instant jump.

Lucide renders its __iconNode children in declared order with nothing prepended, which is what lets a rule address > path:nth-child(4). That is a dependency on an upstream glyph's shape, so icon-motion.test.ts pins the index against the exported __iconNode: a redrawn Trash2 fails a test instead of animating the wrong path. The same guard holds the four rules above to the stylesheet - trigger selectors, fill-box, token-only timing.


Source Files

File Purpose
app/src/index.css All CSS custom properties, keyframes, utility classes
app/tailwind.config.js Color mapping, font families, keyframes, animation aliases
app/index.html Pre-paint appearance script; no font <link> (see fonts.css)
app/src/fonts.css Bundled @fontsource imports for all six font families
app/src/components/layout/Shell.tsx Root layout - the ActivityRail, the drawer row, the content column (TabStrip, main, ContextBar, ContextRail), the Dock, and the window chords
app/src/components/layout/ActivityRail.tsx Left-edge nav - the six view buttons the Dock used to hold, with roving tabindex
app/src/components/layout/ContextRail.tsx Right-edge nav - one icon per applicable context-bar section
app/src/components/layout/RailButton.tsx The icon button shared by both rails - edge-indicator and tile variants
app/src/components/layout/Dock.tsx The bottom strip - status in the centre (engine light, version, services, save state, pending restart), per-tab view controls on the right
app/src/components/layout/ResponsePositionButton.tsx The Dock's response-position switch - click flips Beside / Below, right-click picks Beside / Below / Auto, icon names the destination, request tabs only
app/src/components/layout/Drawer.tsx The sidebar <aside> - one of six views, plus its resize handle
app/src/components/shared/DrawerPanel.tsx The frame every drawer view sits in - header plus the one scroll region
app/src/components/layout/PanelResizeHandle.tsx The drawer's and the context bar's one drag handle (a focusable window splitter)
app/src/components/ui/disabled-hint.tsx The wrapper that lets a disabled control say why it is off
app/src/hooks/useInlineRename.ts The one inline-rename editor: commit keys, the Escape that never commits, trim, focus return
app/src/lib/method-display.ts getMethodColor(method)var(--method-xxx)
app/src/modules/dashboard/components/MetricsView.tsx Sparkline, SvgAreaChart, LatencyBar, HeroCard, StatCard