Design system & styling
This is the authoritative reference for styling the Lockstep VM web app. It documents
the Tailwind configuration, the two coexisting token systems (legacy v1 and the
enterprise v2 system), the light/dark theming mechanism, and the className
conventions you must follow when writing components. Everything here is verified
against the real source in webapp/frontend/.
Canonical source files:
| File | Role |
|---|---|
webapp/frontend/tailwind.config.mjs | Tailwind theme.extend — maps utility classes to CSS variables |
webapp/frontend/src/styles/tokens.css | v2 enterprise design tokens (colors, radius, fonts) |
webapp/frontend/src/styles/globals.css | Tailwind directives, legacy v1 (shadcn) tokens, base resets |
webapp/frontend/src/lib/utils.ts | cn() class-merge helper |
webapp/frontend/src/components/ThemeProvider.tsx | light/dark/system toggle |
webapp/frontend/src/components/ui/Primitives.tsx | Copyable and other support primitives |
webapp/frontend/DESIGN_SYSTEM.md | design-system narrative + do/don’t rules |
Two token systems coexist
The app ships two styling vocabularies side by side. You must know which one to reach for.
| System | Vocabulary | Where it lives | Use it for |
|---|---|---|---|
| v2 (enterprise) | surface-*, fg, line, brand, sev-*, status-* — RGB-triplet CSS vars in tokens.css | /v2/* routes, everything in src/components/ui/ and src/components/v2/ | All new work. |
| v1 (legacy shadcn) | background, foreground, card, primary, muted, border, ring — HSL vars in globals.css | Original app under /, older components | Do not author new UI against these; kept only so v1 keeps rendering. |
There is also a legacy hardcoded brand cyan #00a8e1 sprinkled through original
v1 components (e.g. bg-[#00a8e1], text-[#00a8e1] in VendorsProducts.tsx). This
is the v1 brand accent. Do not use it in new code — it is an inline hex literal,
not a token. The v2 brand accent is teal (see Brand). Add a token before
introducing any new base color.
Tailwind setup
Tailwind is wired in through the Astro integration, and it does not inject base
styles (we own the reset via globals.css):
tailwind({ applyBaseStyles: false })Key config facts from tailwind.config.mjs:
-
darkMode: 'class'— dark mode is driven by a.darkclass on<html>, not the OS media query. (TheThemeProvidertoggles it.) -
contentglobs cover./src/**/*.{astro,html,js,jsx,md,mdx,svelte,ts,tsx,vue}. -
Colors bind to CSS variables. v2 colors use an
rgb(var(--x) / <alpha-value>)helper so opacity modifiers likebg-brand/20work:const rgb = (v) => `rgb(var(${v}) / <alpha-value>)`;// colors.brand.DEFAULT = rgb('--v2-brand') -> bg-brand, bg-brand/20, text-brandBecause of this, v2 token vars in
tokens.cssare stored as space-separated RGB triplets (14 116 144), not hex. v1 shadcn vars are stored as HSL channels (221.2 83.2% 53.3%) and consumed withhsl(var(--x)).
Color & design tokens (v2)
Every v2 utility class resolves to a CSS variable defined in tokens.css. Prefer the
semantic Tailwind class — never the raw variable and never a raw hex.
Surfaces
Backgrounds, layered light → dark. Depth comes from surface steps + 1px borders, not shadows.
| Tailwind class | CSS var | Light | Dark | Use |
|---|---|---|---|---|
bg-surface-0 | --v2-surface-0 | #FFFFFF | rgb(11 15 20) | page root |
bg-surface-1 | --v2-surface-1 | #F7F8FA | rgb(17 24 33) | panel background |
bg-surface-2 | --v2-surface-2 | #FFFFFF | rgb(15 22 32) | cards |
bg-surface-3 | --v2-surface-3 | #F1F3F6 | rgb(24 33 45) | row hover / inset |
bg-surface-inverse | --v2-surface-inverse | #0F172A | rgb(5 8 12) | sidebar |
Text (foreground)
| Tailwind class | CSS var | Light | Dark | Use |
|---|---|---|---|---|
text-fg | --v2-fg | #0F172A | #E5E7EB | primary text, values |
text-fg-muted | --v2-fg-muted | #64748B | #94A3B8 | labels, secondary text |
text-fg-subtle | --v2-fg-subtle | #94A3B8 | rgb(100 116 139) | tertiary / placeholder |
text-fg-inverse | --v2-fg-inverse | #E5E7EB | #0F172A | text on inverse surfaces |
Borders
| Tailwind class | CSS var | Light | Dark |
|---|---|---|---|
border-line | --v2-border | #E4E7EC | rgb(31 42 55) |
border-line-strong | --v2-border-strong | rgb(203 213 225) | rgb(51 65 85) |
The focus ring uses --v2-ring (teal #0E7490 light / #2DD4BF dark), applied
globally to :focus-visible (see Theming).
Brand
The v2 accent is teal, and it flips brighter in dark mode.
| Tailwind class | CSS var | Light | Dark | Use |
|---|---|---|---|---|
bg-brand / text-brand | --v2-brand | #0E7490 | #2DD4BF | primary buttons, active accent |
bg-brand-hover | --v2-brand-hover | rgb(11 97 122) | rgb(94 234 212) | hover state |
text-brand-fg | --v2-brand-fg | #FFFFFF | rgb(2 44 52) | text on bg-brand |
bg-brand-soft | --v2-brand-soft | rgb(224 242 247) | rgb(17 46 52) | chip / badge background |
text-brand-soft-fg | --v2-brand-soft-fg | rgb(11 97 122) | rgb(153 246 228) | text on bg-brand-soft |
Because the whole accent flows through --v2-brand, swapping that one variable
re-skins every primitive. Do not hardcode the teal hex — always use bg-brand /
text-brand.
Severity (sev-*)
Each severity has a strong foreground color and a -soft background. The soft
variants have distinct dark-mode overrides. Pattern: bg-sev-<level>-soft text-sev-<level>.
| Level | Foreground class | Foreground (light) | Soft bg class |
|---|---|---|---|
| Critical | text-sev-critical | #B91C1C | bg-sev-critical-soft |
| High | text-sev-high | #EA580C | bg-sev-high-soft |
| Medium | text-sev-medium | #CA8A04 | bg-sev-medium-soft |
| Low | text-sev-low | #2563EB | bg-sev-low-soft |
| Info | text-sev-info | rgb(100 116 139) | bg-sev-info-soft |
Do not build severity chips by hand — use the SeverityPill primitive (below), which
also handles none / unset.
Status (status-*)
Semantic state colors, foreground only:
| Class | CSS var | Meaning |
|---|---|---|
text-status-ok | --status-ok | success (rgb(22 163 74)) |
text-status-warn | --status-warn | warning (rgb(202 138 4)) |
text-status-err | --status-err | error (rgb(185 28 28)) |
text-status-mute | --status-mute | muted/neutral (rgb(100 116 139)) |
Data-viz palette
For charts, use the six --chart-1 … --chart-6 variables (defined in tokens.css).
These are not exposed as Tailwind color classes — read them via
rgb(var(--chart-1)) in chart config. Order: teal, orange, violet, green, amber, pink.
Typography & spacing scale
Fonts (Tailwind fontFamily, loaded via Google Fonts @import in tokens.css):
| Class | Stack | Use |
|---|---|---|
font-sans | Inter, system-ui, -apple-system, 'Segoe UI', sans-serif | all UI text |
font-mono | JetBrains Mono, 'SF Mono', Menlo, Consolas, monospace | IDs, hashes, CPEs, CVE strings |
Type scale — the config overrides Tailwind’s defaults to a denser enterprise ramp (font-size + line-height baked in):
| Class | Size / line-height |
|---|---|
text-2xs | 11px / 16px |
text-xs | 12px / 16px |
text-sm | 13px / 18px |
text-base | 14px / 20px |
Body text defaults to 14px. Labels are typically text-xs; dense chips use text-2xs.
Radius (Tailwind borderRadius):
| Class | Value | Var | Use |
|---|---|---|---|
rounded-v2-sm | 3px | --v2-radius-sm | chips, inputs, small controls |
rounded-v2 | 6px | --v2-radius | cards, buttons |
rounded-v2-lg | 8px | --v2-radius-lg | large panels |
(rounded-lg/md/sm still exist for v1 via --radius: 0.5rem; do not use in v2.)
Shadows — used sparingly; borders carry depth. Available: shadow-v2-sm,
shadow-v2, shadow-v2-md, shadow-v2-pop (popovers/menus).
Animations — animate-fadeIn (120ms), animate-slideDown (120ms, for menus),
animate-shimmer (skeletons).
Spacing uses Tailwind’s default numeric scale (gap-1.5, px-3, h-8, etc.). The
system leans dense: buttons are h-7/h-8, card headers are a fixed h-12 bar.
Light/dark theming
Theming is class-based, controlled by React, not the OS media query.
ThemeProvider(src/components/ThemeProvider.tsx) is the source of truth. It reads/writeslocalStorage['lsk:theme'](values'light' | 'dark' | 'system') and resolves'system'againstprefers-color-scheme.- On every mode change it mutates
<html>:- always adds the
.v2class (opts the page into the v2 base styles), and - toggles
.darkwhen the resolved theme is dark.
- always adds the
tokens.cssdefines light values on:rootand dark overrides under.dark. Base styles (font, body bg/color, focus ring, scrollbars) are scoped tohtml.v2so they never leak into v1 pages.
// applyTheme() in ThemeProvider.tsx (verified)const el = document.documentElement;el.classList.add('v2');el.classList.toggle('dark', resolved === 'dark');Consume the theme in components via the hook, and render the segmented control with
ThemeToggle:
import { useTheme, ThemeToggle } from '@/components/ThemeProvider';
function Header() { const { mode, setMode, resolved } = useTheme(); // resolved: 'light' | 'dark' return <ThemeToggle />;}Global base rules applied to html.v2 (from tokens.css / globals.css):
- Body font
--font-sans, color--v2-fg, background--v2-surface-1, antialiased. code,.font-mono,kbdget--font-mono.*:focus-visible→2px solid rgb(var(--v2-ring)),outline-offset: 2px.- Native form controls are forced to
surface-2bg + correctcolor-schemeso OS dark defaults don’t bleed through. - Thin custom scrollbars themed with
--v2-border-strong.
You never write dark: variants for token colors. bg-surface-2, text-fg, etc.
already resolve correctly in both themes because the variable changes, not the class.
Only reach for dark: when overriding something that isn’t a token.
The cn() utility
All conditional/merged class strings go through cn() — clsx for conditionals +
tailwind-merge to dedupe conflicting utilities (so a caller’s className can override
a component default).
// src/lib/utils.ts (verified)import { type ClassValue, clsx } from 'clsx';import { twMerge } from 'tailwind-merge';export function cn(...inputs: ClassValue[]) { return twMerge(clsx(inputs));}Import via the @/ alias (@/* -> ./src/*, from tsconfig.json):
import { cn } from '@/lib/utils';Component styling conventions
Every v2 primitive follows the same shape: a base string, a variant/tone lookup map,
and cn(base, map[variant], className) at the end so callers can extend or override.
Always accept and forward className last.
Variant-map pattern (from Button.tsx)
const base = 'inline-flex items-center justify-center gap-1.5 font-medium whitespace-nowrap ' + 'rounded-v2 border transition-colors select-none ' + 'disabled:opacity-50 disabled:pointer-events-none focus-visible:outline-none';
const variantMap: Record<Variant, string> = { primary: 'bg-brand text-brand-fg border-brand hover:bg-brand-hover hover:border-brand-hover', secondary: 'bg-surface-2 text-fg border-line hover:bg-surface-3', ghost: 'bg-transparent text-fg border-transparent hover:bg-surface-3', danger: 'bg-sev-critical text-white border-sev-critical hover:opacity-90', link: 'bg-transparent text-brand border-transparent hover:underline px-0 h-auto',};
const sizeMap: Record<Size, string> = { sm: 'h-7 px-2.5 text-xs', md: 'h-8 px-3 text-sm', lg: 'h-10 px-4 text-sm', icon: 'h-8 w-8 p-0',};
<button className={cn(base, variantMap[variant], sizeMap[size], className)} {...rest} />Tone-map pattern (from Badge.tsx / SeverityPill.tsx)
// SeverityPill: bg-sev-<level>-soft + text-sev-<level>const map: Record<Severity, string> = { critical: 'bg-sev-critical-soft text-sev-critical', high: 'bg-sev-high-soft text-sev-high', medium: 'bg-sev-medium-soft text-sev-medium', low: 'bg-sev-low-soft text-sev-low', info: 'bg-sev-info-soft text-sev-info', none: 'bg-surface-3 text-fg-muted', unset: 'bg-surface-3 text-fg-subtle italic',};
<span className={cn( 'inline-flex items-center h-5 px-1.5 rounded-v2-sm text-2xs font-semibold tracking-wide uppercase', map[key] ?? map.info, className,)}>{label}</span>Note the token-only opacity modifiers Badge uses for subtle borders:
bg-brand-soft text-brand-soft-fg border-brand/20.
Card composition
import { Card, CardHeader, CardBody, CardFooter } from '@/components/ui/Card';
<Card>{/* bg-surface-2 border border-line rounded-v2 shadow-v2-sm text-fg */} <CardHeader title="Asset information" description="12 attributes" actions={<Button size="sm">Edit</Button>} /> <CardBody>{/* p-4 */}…</CardBody> <CardFooter>{/* border-t border-line bg-surface-1 */}…</CardFooter></Card>Card deliberately has no overflow-hidden (it would clip menus/tooltips). Add it
via className only when you need clipping.
Mono + copy for identifiers
Put every ID, hash, CPE, and CVE string in font-mono, and make it one-click
copyable with the Copyable primitive (from @/components/ui/Primitives):
<Copyable value={cve.id}><span className="font-mono text-sm text-fg">{cve.id}</span></Copyable>Do / Don’t
Rules from DESIGN_SYSTEM.md, restated for agents:
Do
- Use semantic v2 tokens:
text-fg-mutedfor labels,text-fgfor values. - Use
rounded-v2(6px) for cards,rounded-v2-sm(3px) for chips/inputs. - Compose
Card+CardHeader+CardBody+CardFooter. - Put IDs/hashes/CPEs/CVEs in
font-monoand wrap them inCopyable. - Reach for existing primitives in
src/components/ui/(Button,Badge,SeverityPill,Input,Menu,Tabs,DataTable, …) before styling from scratch. - Merge caller overrides through
cn(..., className),classNamelast. - Rely on 1px
border-line+ surface steps for depth.
Don’t
- Add new base colors — add a token to
tokens.cssfirst, then a Tailwind mapping. - Hardcode hex literals (
bg-[#00a8e1],text-[#0E7490]) — that’s the v1 pattern. - Use
box-shadowfor depth (onlyshadow-v2*where a real elevation exists). - Write
dark:variants for token colors — the variables already flip. - Author new UI against v1 shadcn tokens (
bg-card,text-foreground,bg-primary). - Create new table components — extend
DataTablewith a column instead. - Touch v1 layout files
src/components/{Sidebar,Header,AppLayout}.tsx.
Related
- Frontend reference — component/route map.
- Architecture — where the frontend sits in the system.