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-running → status-stopped → status-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:
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/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 whoseCardTitleis 15px; flattening the title into that last step would lose the level, and reusingtext-lgwould give one step two meanings (#1409).text-metric(22px) andtext-hero(34px) are the dashboard's metric values. Nothing insrcwritestext-2xlor 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:
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. Thecomponents/ui/*primitives already carry their ownfocus-visible:ringand keep their appearance.:focus-visiblefires 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.
outlinefollows the element's ownborder-radius. That means the roundedness setting governs the ring only on elements that already carry arounded-*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. Nevertransition: 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
tabFocusModeon. 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 aKbdhint 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 fromchordKeys, 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-autofocusis 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-controlis givencontrolComponents: ["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-tabindexalso allowsrole="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-focuson the tablist, whose tab stop is the active tab;jsx-a11y/click-events-have-key-eventson the close affordance, which is Delete or Backspace on the focused row and atabIndex={-1}pointer target here.components/layout/ActivityRail.tsx(1) -jsx-a11y/no-noninteractive-element-interactionson the<nav>: rovingtabindexfor Up/Down between the six view buttons, the same shapeTabStrip's tablist uses for Left/Right, on a landmark rather than a widget role because the rail is deliberatelyrole="toolbar"-free (#1615).components/layout/Dock.tsx(3) -jsx-a11y/no-noninteractive-tabindexthree times: the RadixTooltipTriggerwires 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 theonKeyDownbeside it.components/shared/ElementList/ScriptElementForm.tsx(1) -jsx-a11y/no-noninteractive-element-interactions: thescript.pre/script.posteditor's height handle, the same window-splitter shape asPanelResizeHandleabove, with its pointer drag and arrow-key handling in theonPointerDown/onKeyDownbeside it (issue #1605).components/shared/VariableInput/index.tsx(2) -jsx-a11y/click-events-have-key-eventsandjsx-a11y/no-static-element-interactionson the box that widens the hit area around a native input, andno-static-element-interactionsagain on thepointerEvents: noneoverlay whose keydown delegates for the tokens inside it.components/ui/disabled-hint.tsx(1) -jsx-a11y/no-noninteractive-tabindexon the wrapper span that explains a disabled control (issue #1690): the child it wraps isdisabled, 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 threeDock.tsxtooltips above are suppressed on.modules/collections/CollectionTree.tsx(1) -jsx-a11y/interactive-supports-focus, the roving tab stop seeded byuseRovingTreeFocus.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-interactionson therole="group"run list: roving tabindex for Up/Down/Home/End between the row activators inside (useHistoryListFocus), the same shapeActivityRailuses, on a group rather than a widget role because this flat, occasionally day-headered list has nolistbox/treeselection model to claim.modules/variables/sidebar/VariablesCategoryTree.tsx(2) -jsx-a11y/interactive-supports-focuson the tree andjsx-a11y/click-events-have-key-eventson its rows.modules/services/ServicesPanel.tsx(1) -jsx-a11y/click-events-have-key-eventsandjsx-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 selectedrole="radio"holds the stop and theonKeyDownmoves selection and focus together.modules/request-builder/components/RequestTabs/panels/body/graphql-explorer/SchemaExplorer.tsx(2) -jsx-a11y/interactive-supports-focuson the tree, andjsx-a11y/role-has-required-aria-propsbecause this tree has no selection model: a row inserts into the query and nothing stays selected, soaria-selectedis 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-heightand 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 anh-8:TabStrip.tsxandDrawerPanel.tsxcannot see each other, andtitlebar-height.test.tsholds 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 filledBadgecount 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.DrawerSectionowns 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 aBadgegives it a fill, a border, a 20px floor and the weight of a chip.DrawerSectionCountis 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).rowPropsandactivatorPropsare 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
ScrollAreaby 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 namedGROUP_CHILD_INSETin the two-level variables tree, androwInsetPx(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 ownpx-*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 notreeitem, so the folder row carriesaria-describedbypointing 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/Typeseyebrows 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 ownpx-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 reusesrowInsetPxnow instead of a second copy ofINDENT_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) carrypt-2. - A row must never widen the panel. Long names ellipse; the drawer has no
horizontal scrollbar -
overflow-x-hiddenis set explicitly, becauseoverflow-y: autoalone computes overflow-x toautotoo.truncatealone is not enough when the text sits inside aflex-1wrapper - a flex item will not shrink below its content width, so the wrapper needsmin-w-0as well. (Atruncateelement that is itself the flex item is fine:overflow: hiddenalready gives it an automatic minimum size of 0.) Short trailing metadata - counts, badges, spinners - takesshrink-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-expandedon collections,aria-selectedfor the open entity. - Rows render
tabIndex={-1};useRovingTreeFocuspromotes exactly one to0. - 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
RowContextMenuand 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 theDelete-only andShift+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 anisMacfork; 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
moveByKeyboardthe chords do (one move, one function) and are off at the ends carrying "Already first" / "Already last" as adisabledReason, 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 withoutdata-tree-deleteswallowed Delete silently for months, because the hookpreventDefaults 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-toggleanddata-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-autoon the panel is the tempting one-liner and it is the wrong one: the corner close button isabsoluteinside 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-0on the band is load-bearing, for the same reasonmin-w-0is 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, notflex-1. Basis0%asks a short dialog to stretch its one band over the whole cap; basisautogrows 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) isw-full h-9with aw-4 h-4icon, icon-only soaria-labelis the accessible name and the chord (where there is one) stays out of it - a tooltip suppliesaria-describedbywhile open, never a name. Two variants:"edge-left"/"edge-right"paint a 2pxborder-*-primarybar 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 ordinarybg-accent text-accent-foregroundicon-toggle look, forContextRail'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.tsand its chords fromconstants/shortcuts.ts, so the palette offering the same six cannot name them differently. Deliberately notrole="toolbar", the same choice the Dock nav it replaces made: arrow keys move focus (ArrowUp/ArrowDown, rovingtabindex, mirroringTabStrip'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 togglesdrawerOpenonly when that view is already showing. A smallstatus-success-textdot 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 withscrollWithin(@/lib/scroll-within, #1612) rather thanElement.scrollIntoView; clicking the icon of the section that is the only one expanded collapses the whole bar. Present in both ofContextBar'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/PanelLeftClosefor a pane on the left,PanelRightOpen/PanelRightClosefor one on the right. Only the icon and the label change with state. - State:
aria-expandedbound 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
PanelBottomwhile the response is beside the request andPanelRightwhile 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 inreact-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.
ListSkeletonorDetailSkeleton- 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.
Loader2inside 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.
ImportProgressViewand 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¶
Never use hardcoded background colors like bg-gray-50, bg-blue-50, bg-zinc-900 for card surfaces. Always bg-card.
Section Eyebrow Label¶
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 baree.key === "Enter". An IME commits its composition buffer with an ordinary Enter keydown, andmod+Enteris the app's Send chord - a field acting on it renamed the row and sent the request from one press.commit-enter.test.tsfails 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:
IconSwapfrom Copy to Check, throughuseCopy({ 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>
reasonis 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 anaria-labelends up on one of them and not the other.- The gate stays the child's
disabledprop. 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'sdisabledReason(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 throughRowActionBody. 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
DeleteConfirmDialoginstead. - 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'sonClickfor 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.
DeleteConfirmDialogtakesnameand an optionalscope("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 passesdescriptiononly for prose the template cannot say - a count, a conditional clause - never to restate whatname/scopealready 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, notbg-primary-fill.--primary-fillexists 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-sunkenfor the track. It is the one recessed fill that reads on a card in both themes (1.356 / 1.343), and on--muted/--accentno 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 bareroundedwould pin it to 4px and failradius-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-*- usebg-card,bg-panel,bg-backgroundbg-blue-50,bg-red-950, etc. - usebg-destructive/10,bg-info/10, etc.dark:bg-*hardcoded overrides - tokens handle both modes automaticallytext-gray-500,text-gray-400- usetext-muted-foreground- Hardcoded hex method colors like
text-[#22c55e]- usemethod-getorhsl(var(--method-get)) ${hexColor}18hex-alpha concatenation - usehsl(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-widthis 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-scrollbarat 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:
- The owner triggers, never the icon. A motion fires from the
:hover/:focus-visibleof the glyph's own[data-slot="button"]or the.grouprow that reveals it - never the SVG's own hover, which[&_svg]:pointer-events-nonehas already taken away.:focus-visibleis not optional: a keyboard user gets the same feedback a mouse user does. - 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 |