Skip to content

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:

FileRole
webapp/frontend/tailwind.config.mjsTailwind theme.extend — maps utility classes to CSS variables
webapp/frontend/src/styles/tokens.cssv2 enterprise design tokens (colors, radius, fonts)
webapp/frontend/src/styles/globals.cssTailwind directives, legacy v1 (shadcn) tokens, base resets
webapp/frontend/src/lib/utils.tscn() class-merge helper
webapp/frontend/src/components/ThemeProvider.tsxlight/dark/system toggle
webapp/frontend/src/components/ui/Primitives.tsxCopyable and other support primitives
webapp/frontend/DESIGN_SYSTEM.mddesign-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.

SystemVocabularyWhere it livesUse 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.cssOriginal app under /, older componentsDo 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):

astro.config.mjs
tailwind({ applyBaseStyles: false })

Key config facts from tailwind.config.mjs:

  • darkMode: 'class' — dark mode is driven by a .dark class on <html>, not the OS media query. (The ThemeProvider toggles it.)

  • content globs 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 like bg-brand/20 work:

    const rgb = (v) => `rgb(var(${v}) / <alpha-value>)`;
    // colors.brand.DEFAULT = rgb('--v2-brand') -> bg-brand, bg-brand/20, text-brand

    Because of this, v2 token vars in tokens.css are 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 with hsl(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 classCSS varLightDarkUse
bg-surface-0--v2-surface-0#FFFFFFrgb(11 15 20)page root
bg-surface-1--v2-surface-1#F7F8FArgb(17 24 33)panel background
bg-surface-2--v2-surface-2#FFFFFFrgb(15 22 32)cards
bg-surface-3--v2-surface-3#F1F3F6rgb(24 33 45)row hover / inset
bg-surface-inverse--v2-surface-inverse#0F172Argb(5 8 12)sidebar

Text (foreground)

Tailwind classCSS varLightDarkUse
text-fg--v2-fg#0F172A#E5E7EBprimary text, values
text-fg-muted--v2-fg-muted#64748B#94A3B8labels, secondary text
text-fg-subtle--v2-fg-subtle#94A3B8rgb(100 116 139)tertiary / placeholder
text-fg-inverse--v2-fg-inverse#E5E7EB#0F172Atext on inverse surfaces

Borders

Tailwind classCSS varLightDark
border-line--v2-border#E4E7ECrgb(31 42 55)
border-line-strong--v2-border-strongrgb(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 classCSS varLightDarkUse
bg-brand / text-brand--v2-brand#0E7490#2DD4BFprimary buttons, active accent
bg-brand-hover--v2-brand-hoverrgb(11 97 122)rgb(94 234 212)hover state
text-brand-fg--v2-brand-fg#FFFFFFrgb(2 44 52)text on bg-brand
bg-brand-soft--v2-brand-softrgb(224 242 247)rgb(17 46 52)chip / badge background
text-brand-soft-fg--v2-brand-soft-fgrgb(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>.

LevelForeground classForeground (light)Soft bg class
Criticaltext-sev-critical#B91C1Cbg-sev-critical-soft
Hightext-sev-high#EA580Cbg-sev-high-soft
Mediumtext-sev-medium#CA8A04bg-sev-medium-soft
Lowtext-sev-low#2563EBbg-sev-low-soft
Infotext-sev-inforgb(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:

ClassCSS varMeaning
text-status-ok--status-oksuccess (rgb(22 163 74))
text-status-warn--status-warnwarning (rgb(202 138 4))
text-status-err--status-errerror (rgb(185 28 28))
text-status-mute--status-mutemuted/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):

ClassStackUse
font-sansInter, system-ui, -apple-system, 'Segoe UI', sans-serifall UI text
font-monoJetBrains Mono, 'SF Mono', Menlo, Consolas, monospaceIDs, hashes, CPEs, CVE strings

Type scale — the config overrides Tailwind’s defaults to a denser enterprise ramp (font-size + line-height baked in):

ClassSize / line-height
text-2xs11px / 16px
text-xs12px / 16px
text-sm13px / 18px
text-base14px / 20px

Body text defaults to 14px. Labels are typically text-xs; dense chips use text-2xs.

Radius (Tailwind borderRadius):

ClassValueVarUse
rounded-v2-sm3px--v2-radius-smchips, inputs, small controls
rounded-v26px--v2-radiuscards, buttons
rounded-v2-lg8px--v2-radius-lglarge 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).

Animationsanimate-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.

  1. ThemeProvider (src/components/ThemeProvider.tsx) is the source of truth. It reads/writes localStorage['lsk:theme'] (values 'light' | 'dark' | 'system') and resolves 'system' against prefers-color-scheme.
  2. On every mode change it mutates <html>:
    • always adds the .v2 class (opts the page into the v2 base styles), and
    • toggles .dark when the resolved theme is dark.
  3. tokens.css defines light values on :root and dark overrides under .dark. Base styles (font, body bg/color, focus ring, scrollbars) are scoped to html.v2 so 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, kbd get --font-mono.
  • *:focus-visible2px solid rgb(var(--v2-ring)), outline-offset: 2px.
  • Native form controls are forced to surface-2 bg + correct color-scheme so 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-muted for labels, text-fg for 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-mono and wrap them in Copyable.
  • 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), className last.
  • Rely on 1px border-line + surface steps for depth.

Don’t

  • Add new base colors — add a token to tokens.css first, then a Tailwind mapping.
  • Hardcode hex literals (bg-[#00a8e1], text-[#0E7490]) — that’s the v1 pattern.
  • Use box-shadow for depth (only shadow-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 DataTable with a column instead.
  • Touch v1 layout files src/components/{Sidebar,Header,AppLayout}.tsx.