Skip to content

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:

ComponentPathPatterns it demonstrates
ApplicationsTablecomponents/ApplicationsTable.tsxQuery builder, search bar, pagination, toasts, export/import menus
DevicesTablecomponents/DevicesTable.tsxSame structured-search shell as Applications
AssetDetail / UserDetailcomponents/AssetDetail.tsx, components/UserDetail.tsxSource-tab attribute panels, Tabs, KPI cards, mutations
CsvSourceDetailcomponents/CsvSourceDetail.tsxDropzone upload, parse-status polling, upload modal
SentinelOnePage / Rapid7Page / IntunePagecomponents/*Page.tsxIntegration config → sync → status polling
QueryBuildercomponents/ui/QueryBuilder.tsx + lib/query-builder/The structured query engine
DataTablecomponents/ui/DataTable.tsxSort, column picker, client search, density
Drawer / Dialogcomponents/ui/Drawer.tsx, components/ui/Primitives.tsxSide panels & centered modals
Toaster / toastcomponents/ui/Primitives.tsxGlobal toast bus
ThemeProvider / ThemeTogglecomponents/ThemeProvider.tsxLight/dark/system theme
TopBarcomponents/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:

VariantUse for
primaryThe single main action on a surface (Apply, Save & Connect, Scan)
secondaryNeutral / secondary actions, menu triggers, toolbar buttons
ghostLow-emphasis icon buttons (refresh, inline controls)
dangerDestructive confirmations
linkInline text-style actions
default / destructive / outlineLegacy 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>

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):

ValueLabelRow badge
andAll of the followingAND
orAny of the followingOR
noneNone of the followingNOR
not_allNot all of the followingNAND

Operators (OPERATOR_META, 16 total) are grouped by semantic family; the family that applies is driven by the field’s type:

FamilyOperatorsApplies to field type
stringcontains, equals, not_contains, not_equals, starts_with, not_starts_with, ends_with, not_ends_withstring
numberequals, not_equals, gt, gte, lt, ltenumber
presenceis_empty, is_not_empty (no value input)all
setin, not_instring

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)

HelperPurpose
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 query
const [qbDraft, setQbDraft] = useState<Group>(query); // in-drawer working copy
const [qbOpen, setQbOpen] = useState(false);
const queryActive = isQueryNonEmpty(query);
const openQueryBuilder = () => { setQbDraft(query); setQbOpen(true); }; // seed draft on open
const 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

InteractionBehavior
SortClick a header to cycle asc → desc → none. Numeric sortValues sort numerically; otherwise localeCompare. Nulls sort last. Seed with initialSort={{ id, dir }}.
Column pickerThe “Cols” menu toggles per-column visibility, persisted to localStorage under dt:<id>:visible. The id prop must be stable and unique per table.
SearchWhen 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 clickPass onRowClick to make rows clickable (adds pointer + hover). Inner links/buttons must e.stopPropagation().
Densitydensity="compact" (h-8 rows) vs default comfortable (h-10).
FooterShows 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:

PrimitiveSourceUse for
Drawercomponents/ui/Drawer.tsxSide panels for editing/inspecting alongside context (query builder, record editors)
Dialogcomponents/ui/Primitives.tsxCentered modals for focused confirmations / short forms
Bespoke modalinline (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 configMsg strings (✓ … / ✗ …). New code should use toast.

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