Interactions & UX patterns
This page documents the established user-interaction patterns in the Lockstep VM webapp
(webapp/frontend/src). When you build a new feature, reuse these patterns verbatim — do
not invent new table/drawer/toast mechanisms. Every primitive referenced here is exported from
@/components/ui (see UI primitives) and styled with the
design tokens documented in Design system & styling.
All examples below are taken from real components:
| Component | Path | Patterns it demonstrates |
|---|---|---|
ApplicationsTable | components/ApplicationsTable.tsx | Query builder, search bar, pagination, toasts, export/import menus |
DevicesTable | components/DevicesTable.tsx | Same structured-search shell as Applications |
AssetDetail / UserDetail | components/AssetDetail.tsx, components/UserDetail.tsx | Source-tab attribute panels, Tabs, KPI cards, mutations |
CsvSourceDetail | components/CsvSourceDetail.tsx | Dropzone upload, parse-status polling, upload modal |
SentinelOnePage / Rapid7Page / IntunePage | components/*Page.tsx | Integration config → sync → status polling |
QueryBuilder | components/ui/QueryBuilder.tsx + lib/query-builder/ | The structured query engine |
DataTable | components/ui/DataTable.tsx | Sort, column picker, client search, density |
Drawer / Dialog | components/ui/Drawer.tsx, components/ui/Primitives.tsx | Side panels & centered modals |
Toaster / toast | components/ui/Primitives.tsx | Global toast bus |
ThemeProvider / ThemeToggle | components/ThemeProvider.tsx | Light/dark/system theme |
TopBar | components/TopBar.tsx | ⌘K global-search entry point |
Buttons & actions
Use the Button primitive for every actionable control. Never hand-roll a <button> with ad-hoc
Tailwind unless it is a bespoke icon affordance inside another primitive.
Variants & sizes
Button (components/ui/Button.tsx) accepts the v2 variants plus legacy aliases:
| Variant | Use for |
|---|---|
primary | The single main action on a surface (Apply, Save & Connect, Scan) |
secondary | Neutral / secondary actions, menu triggers, toolbar buttons |
ghost | Low-emphasis icon buttons (refresh, inline controls) |
danger | Destructive confirmations |
link | Inline text-style actions |
default / destructive / outline | Legacy aliases (map to primary / danger / secondary) — do not use in new code |
Sizes: sm (h-7), md (h-8), lg (h-10), icon (h-8 w-8), default (h-9). Toolbar and
drawer-footer buttons are almost always sm or md.
Loading & icons
Button has first-class loading, leftIcon, and rightIcon props. When loading is true the
button is disabled and the left icon is replaced by a spinner — do not render your own spinner
alongside a manual disabled.
import { Button } from '@/components/ui';import { Play, ChevronDown } from 'lucide-react';
// Primary action with a leading icon<Button variant="primary" size="sm" leftIcon={<Play className="h-3.5 w-3.5" />} onClick={runScan}> Scan</Button>
// Async action — let the button own the spinner<Button size="sm" variant="secondary" leftIcon={<Play className="h-3.5 w-3.5" />} loading={scanMutation.isPending} onClick={() => scanMutation.mutate()}> {scanMutation.isPending ? 'Scanning…' : 'Run scan'}</Button>
// Menu trigger — rightIcon chevron is the convention<Button variant="secondary" size="sm" rightIcon={<ChevronDown className="h-3.5 w-3.5" />}> Export</Button>Dropdown action menus
Group related or secondary actions behind a Menu (components/ui/Menu.tsx). Menu closes on
outside-click and ESC, stops click propagation (safe inside clickable rows), and each item supports
icon, onSelect, href, danger, disabled, and separator.
import { Menu } from '@/components/ui';import { Save, Copy, RefreshCw, X } from 'lucide-react';
<Menu align="end" trigger={<Button size="sm" variant="secondary" leftIcon={<MoreHorizontal className="h-3.5 w-3.5" />} />} items={[ { label: 'Save as view', icon: <Save className="h-3.5 w-3.5" />, onSelect: () => toast.info('Saved views coming in Phase 2') }, { label: 'Copy query URL', icon: <Copy className="h-3.5 w-3.5" />, onSelect: copyQueryUrl }, { label: 'Refresh', icon: <RefreshCw className="h-3.5 w-3.5" />, onSelect: () => refetch() }, { separator: true, label: '' }, { label: 'Clear query', icon: <X className="h-3.5 w-3.5" />, danger: true, onSelect: clearQuery, disabled: !queryActive }, ]}/>Convention: unimplemented actions call toast.info('… (stub)') rather than being hidden, so the
UI stays complete while the backend catches up (see ApplicationsTable.actionMenu).
Structured query builder
The query builder is the standard way to express advanced filters against a list endpoint. It is a two-layer system:
lib/query-builder/— pure, framework-free state model + helpers (types, factories, immutable updates, serialization). Shapes match the backend Pydantic schemas 1:1 (backend/app/services/query_builder.py).components/ui/QueryBuilder.tsx— the React UI (nested groups, rule rows, builder/JSON toggle).
Data model
A query is a single root Group. A Group has a combinator and an ordered list of child Nodes,
each of which is either a Rule or another Group (infinitely nestable).
type Combinator = 'and' | 'or' | 'none' | 'not_all';
interface Rule { id: string; kind: 'rule'; field: string; operator: Operator; value: string; }interface Group { id: string; kind: 'group'; combinator: Combinator; rules: Array<Rule | Group>; }id is a client-only identifier (qb-<base36>); it is stripped before hitting the API.
Combinators (COMBINATOR_LABELS):
| Value | Label | Row badge |
|---|---|---|
and | All of the following | AND |
or | Any of the following | OR |
none | None of the following | NOR |
not_all | Not all of the following | NAND |
Operators (OPERATOR_META, 16 total) are grouped by semantic family; the family that applies is
driven by the field’s type:
| Family | Operators | Applies to field type |
|---|---|---|
| string | contains, equals, not_contains, not_equals, starts_with, not_starts_with, ends_with, not_ends_with | string |
| number | equals, not_equals, gt, gte, lt, lte | number |
| presence | is_empty, is_not_empty (no value input) | all |
| set | in, not_in | string |
boolean fields only allow equals / not_equals. When the field changes, RuleRow auto-resets the
operator if the current one is invalid for the new field type.
Field schema (FieldDef[])
Each screen declares a static FieldDef[]. id must exist in the endpoint’s backend allowlist
(e.g. APPLICATION_FIELDS). group renders <optgroup> sections in the field picker; options
turns the value input into a dropdown.
const APP_FIELDS: FieldDef[] = [ { id: 'name', label: 'Name', type: 'string', group: 'General' }, { id: 'device_count', label: 'Asset count', type: 'number', group: 'General' }, { id: 'platform', label: 'Platform', type: 'string', group: 'Microsoft Intune', options: [ { value: 'windows', label: 'Windows' }, { value: 'macos', label: 'macOS' }, ] },];Helpers (lib/query-builder/index.ts)
| Helper | Purpose |
|---|---|
emptyGroup(combinator = 'and') | Fresh root group seeded with one blank rule |
newRule(field?, value?) | Blank rule (defaults to contains) |
updateNode(group, id, patch) | Immutably replace a node by id |
removeNode(group, id) | Immutably remove a node (never returns null; falls back to emptyGroup()) |
appendChild(group, groupId, child) | Append a sibling under a group |
countRules(group) | Total rules (excludes group nodes) — used for badges & the Apply label |
isQueryNonEmpty(group) | True if any rule has a field and a value (or a presence operator) — gates whether the query is “active” |
summarizeQuery(group, fields) | One-line human summary for the active-query chip |
toServerPayload(group) | Strips client ids recursively → the exact JSON the API expects |
Builder vs JSON (Text editor) mode
<QueryBuilder showModeToggle /> renders a top-right Text editor / Query builder switch. Text
mode is a raw JSON textarea that round-trips with the builder: every keystroke is JSON.parsed and,
if valid, pushed through onChange; parse errors are shown inline without losing the builder state.
The builder is fully controlled — value in, onChange out.
Apply / clear lifecycle (draft pattern)
The builder edits a draft that is only committed on Apply. This is the canonical wiring from
ApplicationsTable (identical in DevicesTable):
const [query, setQuery] = useState<Group>(emptyGroup('and')); // committed / applied queryconst [qbDraft, setQbDraft] = useState<Group>(query); // in-drawer working copyconst [qbOpen, setQbOpen] = useState(false);const queryActive = isQueryNonEmpty(query);
const openQueryBuilder = () => { setQbDraft(query); setQbOpen(true); }; // seed draft on openconst applyQueryBuilder = () => { setQuery(qbDraft); setPage(1); setQbOpen(false); };const clearQueryBuilder = () => { setQuery(emptyGroup('and')); setPage(1); };
// Fetch keys on the *committed* query, serialized without client ids:const { data } = useQuery({ queryKey: ['applications-search', search, toServerPayload(query), skip, pageSize, sort], queryFn: () => inventoryApi.searchApplications({ query: queryActive ? toServerPayload(query) : undefined, // omit entirely when empty search: search || undefined, skip, limit: pageSize, sort: sort ?? undefined, }),});The builder itself lives in a Drawer whose footer owns Apply/Cancel, with the live rule count in the
Apply label:
<Drawer open={qbOpen} onClose={() => setQbOpen(false)} title="Edit search query" size="xl" footer={ <div className="flex items-center justify-end gap-2"> <Button variant="secondary" size="md" onClick={() => setQbOpen(false)}>Cancel</Button> <Button variant="primary" size="md" onClick={applyQueryBuilder}> Apply {countRules(qbDraft)} {countRules(qbDraft) === 1 ? 'rule' : 'rules'} </Button> </div> }> <div className="p-4"> <QueryBuilder value={qbDraft} onChange={setQbDraft} fields={APP_FIELDS} showModeToggle /> </div></Drawer>Active-query affordances
When queryActive, show: (1) a rule-count Badge on the “Query builder” trigger button, and (2) a
chip row summarizing the query with inline Edit / Clear:
{queryActive && ( <div className="px-3 py-2 border-t border-line bg-surface-2 flex items-center gap-2"> <Badge tone="brand" size="md">{queryRuleCount} {queryRuleCount === 1 ? 'rule' : 'rules'}</Badge> <span className="text-xs text-fg-muted truncate flex-1" title={querySummary}>{querySummary}</span> <button onClick={openQueryBuilder} className="text-xs text-brand hover:underline">Edit</button> <button onClick={clearQueryBuilder} className="text-xs text-sev-critical hover:underline">Clear</button> </div>)}Shareable queries: copyQueryUrl base64-encodes toServerPayload(query) into a q= URL param
alongside search/ps/p, then navigator.clipboard.writeText + toast.success.
Tables (DataTable)
DataTable<T> (components/ui/DataTable.tsx) is the standard grid: sticky header, click-to-sort,
persisted column picker, optional client-side search, loading/empty states, and a results footer.
Columns
Columns are declared as Column<T>[]. accessor renders the cell; sortValue (recommended) gives a
sortable/searchable scalar; width is a CSS grid track:
const columns: Column<any>[] = [ { id: 'name', header: 'Name', width: 'minmax(240px, 2fr)', accessor: (r) => <a href={`/application/${encodeURIComponent(r.name)}`} className="text-brand hover:underline font-medium truncate block">{r.name}</a>, sortValue: (r) => r.name?.toLowerCase() ?? '' }, { id: 'device_count', header: 'Asset Count', width: '120px', align: 'right', mono: true, accessor: (r) => Number(r.device_count ?? 0).toLocaleString(), sortValue: (r) => r.device_count ?? 0 },];Column knobs: align (left/right/center), mono (monospace cell), sticky, and
hiddenByDefault (starts unchecked in the column picker).
Interactions
| Interaction | Behavior |
|---|---|
| Sort | Click a header to cycle asc → desc → none. Numeric sortValues sort numerically; otherwise localeCompare. Nulls sort last. Seed with initialSort={{ id, dir }}. |
| Column picker | The “Cols” menu toggles per-column visibility, persisted to localStorage under dt:<id>:visible. The id prop must be stable and unique per table. |
| Search | When searchable (default true), the toolbar input filters rows client-side across all columns’ sortValue/accessor. Set searchable={false} when the parent owns search (server-side). |
| Row click | Pass onRowClick to make rows clickable (adds pointer + hover). Inner links/buttons must e.stopPropagation(). |
| Density | density="compact" (h-8 rows) vs default comfortable (h-10). |
| Footer | Shows N of M; override with resultsLabel={(visible, total) => …}. |
<DataTable id="applications" // unique — drives the localStorage column-pref key columns={columns} rows={rows} rowKey={(r, i) => r._id ?? r.name ?? `row-${i}`} onRowClick={(r) => window.location.assign(`/application/${encodeURIComponent(r.name)}`)} searchable={false} // parent owns search + query builder loading={isLoading} initialSort={{ id: 'cvss', dir: 'desc' }} empty={<EmptyState icon={<Search className="h-5 w-5" />} title="No software matches your query" />} toolbar={<Button size="sm" variant="secondary" leftIcon={<Play className="h-3.5 w-3.5" />} onClick={run}>Run scan</Button>}/>Server-side vs client-side: DataTable does not paginate. For server-driven lists
(Applications/Devices) set searchable={false}, feed it the current page’s rows, and render the
Pagination component below the table. For fully client-side lists (per-asset findings/software) let
DataTable own search + sort over the full array.
Pagination
The Pagination component in ApplicationsTable.tsx is the reference implementation: prev/next plus
first/last with ellipsis (buildPageList shows up to 7 pages before collapsing). Render it only when
total > pageSize. Pair it with a per-page Select and a “Viewing X – Y of N results” line that
shows a Skeleton while loading and a spinning RefreshCw during background refetches:
<div className="flex items-center gap-2 text-fg-muted"> {isLoading ? <Skeleton className="h-4 w-40" /> : <span>Viewing <span className="font-semibold text-fg tabular-nums">{rangeFrom} – {rangeTo}</span> of <span className="font-semibold text-fg tabular-nums">{total}</span> results</span>} {isFetching && !isLoading && <RefreshCw className="h-3 w-3 animate-spin text-fg-subtle" />}</div>React Query note: the list queries intentionally omit placeholderData — when the query changes,
briefly showing the loading state is preferred over lingering stale rows. Follow this convention.
Detail drawers & modals
There are three overlay primitives. Pick by intent:
| Primitive | Source | Use for |
|---|---|---|
Drawer | components/ui/Drawer.tsx | Side panels for editing/inspecting alongside context (query builder, record editors) |
Dialog | components/ui/Primitives.tsx | Centered modals for focused confirmations / short forms |
| Bespoke modal | inline (e.g. CsvSourceDetail.UploadModal) | Only when you need a very wide (max-w-5xl) data preview; prefer Dialog otherwise |
All three: close on ESC, close on backdrop click, lock body scroll while open, and stopPropagation
on the panel. Do not re-implement these behaviors.
Drawer
Slides from the right (or side="left"). Sizes: sm 360 / md 520 / lg 720 (default) / xl 960 /
full. Header takes title + optional subtitle + headerActions; a bottom footer is where
Cancel/Apply live.
<Drawer open={open} onClose={() => setOpen(false)} title="Edit search query" size="xl" footer={<div className="flex justify-end gap-2"> <Button variant="secondary" onClick={() => setOpen(false)}>Cancel</Button> <Button variant="primary" onClick={apply}>Apply</Button> </div>}> <div className="p-4">{/* body — scrolls independently */}</div></Drawer>Dialog
Controlled via open / onOpenChange. Sizes sm/md/lg/xl. Provide title, description, and
a footer for actions.
<Dialog open={open} onOpenChange={setOpen} title="Delete asset" description="This cannot be undone." footer={<> <Button variant="secondary" onClick={() => setOpen(false)}>Cancel</Button> <Button variant="danger" onClick={confirmDelete}>Delete</Button> </>}> <p className="text-sm text-fg-muted">The asset and its findings will be removed.</p></Dialog>For quick destructive confirms in legacy pages, window.confirm() is used (e.g.
CsvSourceDetail.purge). Prefer Dialog for new work.
Source-tab attribute panels
Detail pages that aggregate the same entity from multiple integrations (AssetDetail, UserDetail)
use a vertical source-tab + attribute table layout. Left rail = one tab per source that actually
returned data (with an attribute count badge); right pane = the selected source’s flattened
attributes.
Flattening
Raw backend objects are flattened into dot-path Attribute[] via flattenAttributes (in
AssetDetail.tsx): nested dicts become a.b.c keys, scalar arrays are joined, object arrays collapse
to "N items", ISO timestamps are pretty-printed, and keys are sorted. Each Attribute carries a
raw string for copy/search.
Building the tabs
Conditionally include a tab only when that source has data, and always keep a stable fallback when the
active selection disappears (this also preserves React hooks order — declare activeSource state
before any early return):
const [activeSource, setActiveSource] = useState('inv');
const sourceTabs: SourceTab[] = [ { id: 'inv', label: 'inventory.local', count: invAttrs.length, color: 'text-brand' }, ...(s1 ? [{ id: 's1', label: 'sentinelone.dev', count: s1Attrs.length, color: 'text-purple-500' }] : []), ...(r7 ? [{ id: 'r7', label: 'rapid7.dev', count: r7Attrs.length, color: 'text-orange-500' }] : []),];// Fall back to the first tab if the current selection no longer exists:const effectiveSource = sourceTabs.some((t) => t.id === activeSource) ? activeSource : sourceTabs[0]?.id ?? 'inv';const activeAttrs = effectiveSource === 's1' ? s1Attrs : effectiveSource === 'r7' ? r7Attrs : invAttrs;
<Card> <CardHeader title="Attributes" /> <div className="flex min-h-[420px]"> <SourceTabs tabs={sourceTabs} value={effectiveSource} onChange={setActiveSource} /> <AttributeTable attributes={activeAttrs} /> </div></Card>AttributeTable provides its own search box, per-row copy-to-clipboard (with a transient check icon),
and an empty state. SourceTabs is the vertical rail; the horizontal Tabs/TabsList/TabsTrigger
family (with icon + count) is used lower on the page for the Findings/Vulnerabilities/Software
sections. Fixed key/value panels use KeyValueGrid. See
UI primitives for full prop tables.
CSV upload + parse-status polling
CsvSourceDetail.tsx is the reference for drag-and-drop ingest with asynchronous parse feedback.
Dropzone
Uses react-dropzone. Accept CSV/XLS/XLSX, single file, and disable while an upload is in flight.
Upload is a raw fetch with FormData + credentials: 'include' (not the typed API client, because
it is multipart):
const onDrop = useCallback(async (files: File[]) => { if (!files.length) return; setUploading(true); setMsg(null); try { const fd = new FormData(); fd.append('file', files[0]); const res = await fetch(`${API}/csv-sources/${sourceKey}/upload`, { method: 'POST', credentials: 'include', body: fd }); const data = await res.json(); if (!res.ok) throw new Error(data.detail || 'Upload failed'); setMsg({ ok: true, text: `Uploaded ${data.filename}. Parsing…` }); client.invalidateQueries({ queryKey: ['csv-source', sourceKey] }); // kick off polling } catch (e: any) { setMsg({ ok: false, text: e.message }); } finally { setUploading(false); }}, [sourceKey, client]);
const { getRootProps, getInputProps, isDragActive } = useDropzone({ onDrop, accept: { 'text/csv': ['.csv'], 'application/vnd.ms-excel': ['.csv', '.xls'], 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet': ['.xlsx'], }, maxFiles: 1, disabled: uploading,});The dropzone div toggles border/background on isDragActive and dims + swaps to a spinner while
uploading. Inline msg shows success/error with a check/alert icon.
Parse-status polling
The source query polls itself while any upload row is still processing, then stops
automatically. This is the preferred lightweight polling idiom — a conditional refetchInterval that
returns false to halt:
const { data: src } = useQuery({ queryKey: ['csv-source', sourceKey], queryFn: async () => (await fetch(`${API}/csv-sources/${sourceKey}`, { credentials: 'include' })).json(), refetchInterval: (q) => (q.state.data?.uploads?.some((u: any) => u.status === 'processing') ? 2500 : false),});Per-upload status is rendered by a small UploadStatus component mapping
processed → green check, processing → spinning loader, else failed → red alert.
Upload-detail modal
Clicking an upload row opens UploadModal, which fetches that upload’s sample rows + parse metrics
(row count, imported count, detected shape, per-field fill-rate, warnings) and renders a flattened
raw-data preview table. It is a bespoke wide modal (max-w-5xl), following the same
backdrop/stopPropagation rules as Dialog.
Integration sync + status polling
Integration pages (SentinelOnePage, Rapid7Page, IntunePage, ConnectWise, Entra) share a
config → sync → poll lifecycle. Each wraps its content in its own QueryClientProvider and hits the
backend with a local apiFetch helper (credentials: 'include', JSON headers, throws on non-2xx).
The state machine
Backend reports sync_status ∈ { running, success, error }. The page derives:
const isConfigured = configData?.configured;const syncStatus = syncStatusData?.sync_status || statsData?.sync_status;const isSyncing = syncStatus === 'running';- Not configured → render only the connection form (API key/URL, Test Connection, Save & Connect).
- Configured → header shows a status pill (
Synced/Syncing…/Error), a Sync Now button, and a settings entry.
Kicking off a sync
Sync is a useMutation; on success it invalidates the status + stats queries so polling picks up the
running state:
const syncMutation = useMutation({ mutationFn: () => apiFetch('/rapid7/sync', { method: 'POST' }), onSuccess: () => { qc.invalidateQueries({ queryKey: ['r7-sync-status'] }); qc.invalidateQueries({ queryKey: ['r7-stats'] }); },});
// Button is disabled while syncing; icon spins:<button onClick={() => syncMutation.mutate()} disabled={isSyncing || syncMutation.isPending}> <RefreshCw className={`h-4 w-4 ${isSyncing ? 'animate-spin' : ''}`} /> {isSyncing ? 'Syncing...' : 'Sync Now'}</button>Status polling
Same conditional-refetchInterval idiom as CSV parsing. Poll the fast sync-status endpoint every
2s and the heavier stats endpoint every 3s, only while running:
const { data: statsData } = useQuery({ queryKey: ['r7-stats'], queryFn: () => apiFetch('/rapid7/stats'), refetchInterval: (data: any) => data?.sync_status === 'running' ? 3000 : false,});const { data: syncStatusData } = useQuery({ queryKey: ['r7-sync-status'], queryFn: () => apiFetch('/rapid7/sync-status'), refetchInterval: (data: any) => data?.sync_status === 'running' ? 2000 : false,});IntunePage uses an alternative setInterval-in-onSuccess poller that invalidates config/stats
every 2.5s and clears itself once sync_status !== 'running', then refetches the device/app lists.
Prefer the declarative refetchInterval form for new code.
Progress banner & clock-skew handling
Rapid7Page renders a live progress banner from sync_progress (phase, current/total batches,
assets processed, findings collected, percent). Elapsed time is clamped to >= 0 because the
browser and server clocks can disagree and otherwise produce a negative elapsed:
const startedAt = syncStatusData?.sync_started_at ? new Date(syncStatusData.sync_started_at) : null;// Clamp — clock skew between browser and server can otherwise show a negative elapsed:const elapsed = startedAt ? Math.max(0, Math.round((Date.now() - startedAt.getTime()) / 1000)) : 0;const pct = prog?.total_batches > 0 ? Math.round((prog.current_batch / prog.total_batches) * 100) : 0;On error, show a red banner with sync_error. On success, a green banner with last-sync time and
record counts.
Toasts, loading, empty & skeleton states
Toasts
There is one global toast mechanism: the toast bus + <Toaster /> sink in
components/ui/Primitives.tsx. Toaster is mounted once at the app shell; feature code just imports
toast. Toasts auto-dismiss after 4s and stack bottom-right.
import { toast } from '@/components/ui';
toast.success('Query URL copied');toast.error('Copy failed');toast.info('Scan queued (stub)');Wire them into async flows — mutation onSuccess/onError, clipboard results, and stubbed actions:
const scanMutation = useMutation({ mutationFn: runScan, onSuccess: () => { qc.invalidateQueries({ queryKey: ['asset-vulns'] }); toast.success('Vulnerability scan started — results will appear in the Vulnerabilities tab.'); }, onError: () => toast.error('Could not start scan'),});Note: integration pages predate the toast bus and instead show inline
configMsgstrings (✓ …/✗ …). New code should usetoast.
Skeletons
Use Skeleton for content-shaped loading placeholders (shimmer animation). Reserve full-height
skeletons for first paint of detail pages:
if (isLoading) return ( <div className="space-y-4"> <Skeleton className="h-10 w-64" /> <div className="grid grid-cols-1 lg:grid-cols-2 gap-4"><Skeleton className="h-96" /><Skeleton className="h-96" /></div> </div>);DataTable renders its own inline Loading… when loading is set, so you do not skeleton the table
body yourself.
Empty states
Use EmptyState (icon + title + description + optional action). Make the copy and the action
context-aware — e.g. offer “Clear query” only when a query is active:
<EmptyState icon={<Search className="h-5 w-5" />} title="No software matches your query" description={queryActive ? 'Try loosening a rule in the query builder or clear the current query.' : 'Try a different search term.'} action={queryActive ? <Button size="sm" variant="secondary" onClick={clearQuery}>Clear query</Button> : undefined}/>For “this entity isn’t in source X” panels, AssetDetail uses a NotInSource wrapper around
EmptyState; for “no problems found” use a positive check-mark empty state (EmptyNoIssues) rather
than a neutral one.
Theme (light / dark / brand)
Theme is owned by ThemeProvider (components/ThemeProvider.tsx), mounted once at the root. It
persists to localStorage['lsk:theme'], supports light / dark / system, and applies by toggling
.dark (and always adding .v2) on document.documentElement. system mode subscribes to
prefers-color-scheme.
import { useTheme, ThemeToggle } from '@/components/ThemeProvider';
const { mode, setMode, resolved } = useTheme(); // resolved is 'light' | 'dark'<ThemeToggle /> // the 3-segment Light/Dark/System control (used in TopBar)Never hard-code colors. Style with the semantic tokens (bg-surface-1/2/3, text-fg,
text-fg-muted, text-fg-subtle, border-line, bg-brand, text-brand, severity tokens like
text-sev-critical, bg-sev-low-soft). These resolve correctly in both themes. The brand family is
the single accent used for primary buttons, active tabs/links, and focus rings
(focus:ring-brand/30). Full token catalog: Design system & styling.
Legacy integration pages still use raw Tailwind palette classes (
bg-red-50,text-green-600, brand hex#00a8e1). Do not copy that into new components — use tokens.
Global search (⌘K)
The TopBar (components/TopBar.tsx) renders the global-search entry point: a full-width button with
a search icon and a platform-aware ⌘K / Ctrl K <kbd> hint (it sniffs navigator.platform for
Mac). The command palette itself is not yet implemented — the button is the placeholder (“global
search (⌘K later)”). List pages (ApplicationsTable, DevicesTable) also render a decorative ⌘K
<kbd> next to their search inputs.
When implementing the palette, wire a global keydown listener that intercepts (e.metaKey || e.ctrlKey) && e.key === 'k' and opens a Dialog, reusing the platform detection already in TopBar:
const [mac, setMac] = useState(false);useEffect(() => { setMac(/Mac|iPhone|iPad/.test(navigator.platform)); }, []);// <kbd> hint:<kbd className="px-1.5 h-5 inline-flex items-center text-2xs font-mono bg-surface-2 border border-line rounded-v2-sm"> {mac ? '⌘K' : 'Ctrl K'}</kbd>The per-page search bar (not the palette) is the plain controlled input pattern: submit on Enter,
inline clear “X” button, and it resets pagination to page 1 on change. See ApplicationsTable’s
search Card for the reference markup.
See also
- UI primitives — full prop tables for
Button,DataTable,Drawer,Menu,Tabs,SourceTabs,AttributeTable,KeyValueGrid,Badge,EmptyState,Skeleton,Toaster. - Feature components — how each screen composes these patterns.
- Design system & styling — semantic color tokens, spacing, radii.
- Frontend conventions — React Query setup, API client, routing.
- API reference — structured-search and integration sync endpoints.