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 two worst also fail the 3.0 floor for icons):
| 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 |
17.72 | 17.72 | 15.78 | 15.78 |
Note the inversion: destructive is the only family that fails in dark,
every other family fails in light. That 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 breaks 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 is the exception: it has no -text variant, because the bare
token already measures 17.72 / 15.78. Leave it as-is.
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.
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 and the import dialog
(ImportModal.surface-rule.test.tsx guards the latter's declarations); the rest
of the app still uses explicit tokens and can migrate as it is touched.
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 script panels' Quick Reference blocks 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 */
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.
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 few surfaces use a fixed decorative palette to give items a stable
identity by color rather than to signal state - the same idea as --chart-*.
These intentionally keep Tailwind hue utilities (with dark: variants) instead
of tokens, because they never respond to theme and don't carry semantics:
- Timing phases - DNS / connect / TLS / TTFB / download in the breakdown.
These are written as explicit
bg-blue-50 dark:bg-blue-950/30pairs, so they are theme-aware; they are categorical identity, not state.
The history overview tiles used to be listed here too, and should not have
been: they encode HTTP severity, which is state. They now use
STATUS_CLASS_STYLE.
Everything else - state, status, scope, semantics - must use tokens.
Two entries were removed from this list 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/. And 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.
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.
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
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/utils/helpers.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)`
Method badge pattern (inline <span>, not a <Badge> component - used in RunItem, DashboardHeader):
const c = `var(--method-${method.toLowerCase()})`;
<span
className="text-[10px] h-5 px-1.5 font-mono font-bold shrink-0 inline-flex items-center rounded"
style={{
color: `hsl(${c})`,
background: `hsl(${c} / 0.1)`,
border: `1px solid hsl(${c} / 0.3)`,
}}
>
{method}
</span>
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 four
evenly-spaced hues (teal / violet / amber / rose) 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 */
/* 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%;
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.
| 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 | Import |
|---|---|---|
| UI / body | Space Grotesk | Google Fonts |
| Code / mono | JetBrains Mono | Google Fonts |
<!-- app/index.html -->
<link href="https://fonts.googleapis.com/css2?family=Space+Grotesk:wght@400;500;600;700&family=JetBrains+Mono:ital,wght@0,400;0,500;0,600;1,400&display=swap" rel="stylesheet" />
body { font-family: var(--font-sans); } /* default: "Space Grotesk", system-ui, sans-serif */
/* mono via font-mono Tailwind class, or .font-code utility */
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 (Compact / Default / Comfortable). 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 are owned by useAppearance (source of truth constants/appearance.ts),
persisted to localStorage, and applied pre-paint in index.html. Code/mono
text stays JetBrains Mono regardless.
Type Scale Conventions¶
| Use | Size | Weight | Class |
|---|---|---|---|
| Section label / eyebrow | 11px | semibold, uppercase, +tracking | text-[11px] font-semibold uppercase tracking-[0.06em] text-muted-foreground |
| Hero metric value | 34px | bold, tabular | text-[34px] font-bold leading-none font-mono tabular-nums |
| Secondary metric value | 22px | bold | text-[22px] font-bold font-mono |
| 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 | 10–11px | mono bold | text-[10px] font-mono font-bold |
| URL / path | 12–13px | mono | text-xs font-mono |
Only text-[10px], text-[11px], text-[22px] and text-[34px] may be
written as arbitrary values. Everything else has a named step, and
type-scale.test.ts fails on anything outside that set.
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.
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. Use
w-3 h-3 (12), w-3.5 h-3.5 (14), w-4 h-4 (16), w-5 h-5 (20).
Geometry¶
--radius: 0.375rem; /* 6px - base border radius (default) */
--dock-height: 2rem; /* 32px - footer status strip */
--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.
| 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
useAppearance, 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.
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 |
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 |
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:
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.
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. focus-ring-clipping.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.
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.
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.
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.
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: heading at 11px from the panel top, body at 40px.
- 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: 8 + depth * INDENT_STEP(constants/layout). - 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 (32px). 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 (24px) so it fits a 32px row. That is still
an adequate pointer target, and the whole row remains clickable for opening.
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, e.g. the run URL in RunItem) is neither -
it never clips, so it needs no tooltip.
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, Delete deletes, Shift+F10 / Menu opens row actions.
- Every control inside a row is
tabIndex={-1}, so Delete and Shift+F10 are the keyboard path to row actions - do not remove them without providing another.
Rows declare behaviour through data attributes rather than props
(data-tree-activate, data-tree-toggle, data-tree-menu, data-tree-delete),
so the hook needs nothing threaded through CollectionItem's prop list.
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.
Visible order comes from the DOM ([role="treeitem"] in document order), since
collapsed subtrees are not rendered. Note a row's children are a sibling of
that row inside a shared wrapper, not nested within it, so finding a parent row
means walking up to the enclosing wrapper - not closest().
Currently on CollectionItem and RequestItem rows. Only needed where the
control and the row genuinely differ - the history, variables 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.
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.
Layout Structure¶
Shell
├── Resizable sidebar container (280–600px, default 320px - useResizable hook)
│ └── Sidebar
│ ├── ActivityBar w-11 (44px) bg-panel border-r border-border
│ └── SidebarPanel w-60 (240px) bg-panel border-r border-border (collapsible)
├── Resize handle w-1 bg-border hover:bg-primary cursor-col-resize
└── main (flex-1) routes render here
Resizable Sidebar¶
Shell uses useResizable from app/src/hooks/useResizable.ts:
const { size: sidebarWidth, isResizing, startResizing } = useResizable({
defaultSize: 320,
min: 280,
max: 600,
});
// Sidebar container:
<div style={{ width: `${sidebarWidth}px`, minWidth: "280px", maxWidth: "600px" }} className="flex-shrink-0 ...">
<Sidebar />
</div>
// Drag handle:
<div
onMouseDown={startResizing}
className={cn("w-1 bg-border hover:bg-primary cursor-col-resize transition-colors shrink-0", isResizing && "bg-primary")}
/>
useResizable API:
useResizable({ defaultSize, min, max, direction?: "horizontal" | "vertical" })
// → { size: number, isResizing: boolean, startResizing: (e: React.MouseEvent) => void }
startResizing takes a React.MouseEvent (wire directly to onMouseDown). Uses delta-based calculation - captures drag origin on mousedown, computes newSize = startSize + delta - so it works for panels that don't start at the viewport origin.
ActivityBar¶
- Width:
w-11(44px), full height,bg-panel border-r border-border - Tab buttons:
w-10 h-10 flex items-center justify-center rounded-md - Active state:
bg-primary/10 text-primary+ 2px left accent bar - Inactive hover:
hover:bg-accent hover:text-foreground - Icon size:
w-4 h-4 - Tabs (top): Collections (Folder), History (Clock), Variables (Code2)
- Tab (bottom, pinned): Settings (Settings2) - pushed down with
flex-1spacer - Collapse: clicking active tab while panel open →
setPanelOpen(false) - Tooltips:
side="right"viaTooltipContent
SidebarPanel¶
- Width:
w-60(240px) internal - the outer resizable container starts at 320px bg-panel border-r border-border overflow-hidden- Content:
ScrollAreafills available space - Footer:
ConnectionStatuspinned to bottom withborder-t border-border
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 + optional action |
| It broke | ErrorState |
Takes the raw detail and an onRetry |
| Still loading | DetailSkeleton |
rows prop, default 4 |
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.
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¶
<p className="text-[11px] font-semibold uppercase tracking-[0.06em] text-muted-foreground mb-4">
Section Title
</p>
Status Badges / Pills¶
Live (running):
<span className="flex items-center gap-1.5 px-2 py-0.5 rounded-full text-[11px] 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-[11px] font-semibold tracking-wide bg-muted text-muted-foreground border border-border">
COMPLETED
</span>
Run status left-bar (RunItem):
<div className={cn(
"absolute left-0 top-0 bottom-0 w-1",
status === "completed" && "bg-green-500",
status === "failed" && "bg-red-500",
status === "running" && "bg-blue-500",
status === "stopped" && "bg-orange-500",
status === "pending" && "bg-muted-foreground"
)} />
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 at the top - never a round number. 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.
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>
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-bold text-[11px] */}
<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">
<p className="text-[11px] font-semibold uppercase tracking-[0.06em] text-muted-foreground">{label}</p>
<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-[11px] 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">
<p className="text-[11px] font-semibold uppercase tracking-[0.06em] text-muted-foreground mb-1.5">{label}</p>
<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
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 | scrollbar-thin |
| 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) |
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 */
:where(*) {
scrollbar-width: thin;
scrollbar-color: hsl(var(--muted-foreground) / 0.3) transparent;
}
::-webkit-scrollbar {
@apply w-2 h-2;
}
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.
Chromium honours ::-webkit-scrollbar over scrollbar-width, and Electron is
Chromium, so the webkit rules are the ones that render here; scrollbar-width
is the standards-track fallback.
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.
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 |
Google Fonts preconnect + link tags |
app/src/components/layout/Shell.tsx |
Root layout - resizable sidebar + drag handle + main |
app/src/components/layout/Sidebar.tsx |
ActivityBar + SidebarPanel |
app/src/hooks/useResizable.ts |
Drag-to-resize hook (delta-based, horizontal/vertical) |
app/src/utils/helpers.ts |
getMethodColor(method) → var(--method-xxx) |
app/src/modules/dashboard/components/MetricsView.tsx |
Sparkline, SvgAreaChart, LatencyBar, HeroCard, StatCard |