Elite Command Design System
@kineticlogic/staresdk is the React component library, theming and design-token set behind
OpenStare and
OpenTrack. Every example on this page is the real
component from this release, running live. Use the theme button in the header to see each one in dark and light.
Overview#
The package ships the components, ThemeProvider/useTheme (dark and light), the
chromeTheme.ts token set, and optional map, graph, chart and code-editor views. Components take their
data as props. Routing, data fetching and app state stay in your app.
Getting started#
Install#
npm install @kineticlogic/staresdk react react-dom react-icons framer-motion react-markdown remark-gfm \
mdast-util-find-and-replace @dnd-kit/core @dnd-kit/sortable @dnd-kit/utilities
The optional views need their own peer dependencies. Install them only if you use those views:
| Entry point | Install |
|---|---|
@kineticlogic/staresdk/map-view | maplibre-gl |
@kineticlogic/staresdk/graph-view | @xyflow/react @dagrejs/dagre |
@kineticlogic/staresdk/chart | uplot |
@kineticlogic/staresdk/code-editor | @codemirror/* and @lezer/highlight |
Mermaid diagrams in MDText | mermaid |
Usage#
Import the stylesheet once at your application's entry point, wrap your tree in ThemeProvider
(and ToastProvider if you use Toast/useToast), then import components by name:
import '@kineticlogic/staresdk/styles.css'
import { ThemeProvider, ToastProvider, Button, Modal, useTheme } from '@kineticlogic/staresdk'
function App() {
return (
<ThemeProvider>
<ToastProvider>
<YourApp />
</ToastProvider>
</ThemeProvider>
)
}
Theming#
ThemeProvider resolves the active theme ('dark' | 'light', default
'dark') onto <html data-theme="dark"|"light"> and exposes it through
useTheme(). Every component reads its colours from the --color-*/--surface-*/--status-*
custom properties in styles.css, so once the stylesheet and provider are mounted, components theme
correctly with no further wiring.
const { theme, setTheme, commitTheme } = useTheme()
setTheme('light') // paint only: a live preview
commitTheme('light') // paint and remember the choice (localStorage 'theme')
useTheme()Design contract#
DESIGN-CONTRACT.md
says how an app built on stareSDK is laid out and which component to use for what. It is a contract, not a style
suggestion. Read it before building a page. In short:
Rules that are never broken#
- Tokens only. Every colour, font, radius, shadow and spacing value comes from a
var(--…)token (orchromeTheme.tson render paths that cannot read CSS). - Use the component that exists. One component per pattern. Don't hand-roll modals, badges, sliders, tab bars or panels.
- No unrequested controls. Never add a button, menu item, toggle or advisory copy nobody asked for.
- Icons are Tabler. Every icon comes from
react-icons/tb. - No placeholder states. Every view handles loading, empty and error explicitly, with real copy.
- Compositor-safe motion only. Animate
transform,opacityandfilter. - Tight, not oversized. Components are compact by default; let tables and panels take the width.
Page anatomy#
Every page is a full-height column: one PageHeader, a Tabs variant="bar" for primary
views, then stacked CollapsiblePanels. Detail opens in a right SideNav. The example below
is that page, built from the real components in a small frame.
PageHeader + Tabs + CollapsiblePanel + DataTable<DockProvider>
<div style={{ position: 'fixed', inset: 0, display: 'flex', flexDirection: 'column',
background: 'var(--color-bg-primary)', fontFamily: 'var(--font-sans)' }}>
<PageHeader title="OpenTrack" appName="OpenTrack" actions={…} />
<Tabs variant="bar" aria-label="Workspaces" tabs={…} value={view} onChange={setView} />
<main style={{ flex: 1, overflowY: 'auto', padding: 'var(--space-lg)',
marginRight: 'var(--assistant-width, 0px)' }}>
<div style={{ display: 'flex', flexDirection: 'column', gap: 'var(--space-md)' }}>
<CollapsiblePanel title="Sources" badge="4">…</CollapsiblePanel>
</div>
</main>
</div>
</DockProvider>
Which component, when#
| Need | Use | Not |
|---|---|---|
| Page title bar | PageHeader | a custom header div |
| Primary views of a page | Tabs variant="bar" | buttons styled as tabs |
| A section of a page | CollapsiblePanel | bordered sections, cards |
| Detail of a selected item | SideNav | a split column, a modal |
| Confirm / short form | Modal, useToast().confirm() | window.confirm |
| Transient result or error | useToast().toast() | inline banners that never clear |
| Tabular data | DataTable | hand-built <table> |
| Status, count, category | Badge | coloured text, custom pills |
| Actions | Button (one primary per view) | links styled as buttons |
| Save with dirty/saving/saved states | SaveButton | ad-hoc state text |
| Text / number field | Label + Input | bare <input> |
| Pick one of a list | FieldSelect, Select | bare <select> |
| Pick a unit or convention | UnitSelect | inline unit math |
| On/off setting | Toggle | checkboxes for settings |
| Numeric range | Slider, OpacitySlider, ZoomRangeSlider | <input type="range"> |
| Multi-step flow | Stepper | numbered headings |
| Paging a long list | Pagination | infinite scroll |
| Explaining a field | InfoTip | helper text under every field |
| Row actions menu | PopoverMenuButton / ContextMenu | |
| File input | FileDropZone | bare file input |
| JSON / text editing | CodeEditor | <textarea> |
| Points on a map | MapView | a second map library |
| Lineage or a pipeline | GraphView | hand-drawn SVG |
| Values over time | TimeSeriesChart | a second chart library |
| Rendered Markdown | MDText |
Foundations#
Chrome colours come from two places, matched to where they are read. In CSS and inline style={{}},
use var(--token-name), resolved from :root (dark) and [data-theme="light"]
in styles.css. On JS, Canvas and other non-DOM render paths, use chromeTheme.ts's
DARK_THEME / LIGHT_THEME, or themeFor(name). Never call
getComputedStyle from a render path. Each swatch below shows the live token in the current theme,
with its dark and light values. Click a token name to copy var(--…).
Canonical roles#
The eight canonical token roles. Every surface, text run and accent in the system is one of these.
Surfaces, text and brand#
Status and links#
Status colours go through Badge colours success, warning, blue
and danger. Links are always --color-link and underlined; the accent green is never a link.
Intelligence layer#
For charts, maps and overlays only, never for buttons or chrome. These are theme-invariant.
Badge fills#
BADGE_BG is one of the two deliberate raw-hex exceptions: Badge's
contrastText() parses these as WCAG luminance values, so a var() string would not work.
Severity ramp#
chromeTheme.ts's severityRamp, for topology, map and HUD-style consumers, is the other raw-hex
exception. Its hues stay distinct from the --status-* set.
Classification markings#
Domain colours, not chrome. They read the same in both themes, so a marking never changes with the reader's setting.
Typography#
Montserrat (--font-sans) for all chrome, Oswald (--font-header) for wordmarks only, and
--font-mono for data values only.
Spacing, radii and shadows#
Page padding --space-lg; gap between panels --space-md; between related controls
--space-sm. Panels use --radius-lg, inputs and buttons --radius-sm/--radius-md.
Icons#
Every icon a stareSDK component renders comes from react-icons/tb (Tabler). Use the same set for
anything you build around them. No other icon set, and no emoji or Unicode glyphs as affordances.
react-icons/tbimport { TbMap2, TbRadar2, TbSettings } from 'react-icons/tb'
<Button variant="ghost" size="xs" icon={<TbSettings />} aria-label="Settings" title="Settings" />
Components#
Each component below renders live. Interact with it, switch the theme, and copy the code underneath. Everything
is imported from @kineticlogic/staresdk unless the section names a subpath.
Button Available in 0.2.0+#
Variants primary (at most one per panel or drawer), secondary, ghost (icon-only)
and danger; sizes xs (square, icon-only) to xl. Use size="sm" in panel headers and drawers.
Icon-only buttons always carry aria-label and title.
Button<Button variant="primary" icon={<TbPlus />}>Add source</Button>
<Button variant="secondary">Cancel</Button>
<Button variant="danger" icon={<TbTrash />}>Delete</Button>
<Button variant="ghost" size="xs" icon={<TbRefresh />} aria-label="Refresh" title="Refresh" />
<Button variant="secondary" active>Selected</Button>
ButtonPalette Available in 0.2.0+#
Groups related buttons with even spacing, horizontally or vertically, on an optional glass surface. For map tool palettes and layout switchers.
ButtonPalette<ButtonPalette ariaLabel="Map tools">
<Button variant="ghost" size="xs" icon={<TbPointer />} aria-label="Select" title="Select" active />
<Button variant="ghost" size="xs" icon={<TbRuler2 />} aria-label="Measure" title="Measure" />
</ButtonPalette>
ButtonPalettePopover Available in 0.2.15+#
The flyout a palette button opens: rendered in place beside its trigger (to the right of a vertical
palette by default, left, or top/bottom of a horizontal one), flipped to the
other side when there is no room, lined up with the trigger (or, with alignTo, with the whole
palette), and kept clear of the classification banners and inside any container that clips its overflow. It wears
the palette's glass surface and closes on Escape or a press outside. A drawing tool passes
closeOnOutsideClick={false} so it stays open while the user works on the map. isolatePointer stops
pointer events at the flyout when it sits over a canvas that listens above it. Use Popover for menus hung below a control.
ButtonPalette + ButtonPalettePopover<ButtonPalette ariaLabel="Map tools" orientation="vertical">
<ButtonPalettePopover open={open} onClose={() => setOpen(false)} closeOnOutsideClick={false}
ariaLabel="Measure" title="Measure" width={200}
trigger={<Button variant="ghost" size="xs" icon={<TbRuler2 />} aria-label="Measure" title="Measure"
active={open} aria-expanded={open} onClick={() => setOpen(o => !o)} />}>
<RadioGroup ariaLabel="Measure" size="sm" value={mode} onChange={setMode} options={modes} />
</ButtonPalettePopover>
</ButtonPalette>
SaveButton Available in 0.2.0+#
A Button with built-in dirty, saving and saved states. It stays disabled until there is something to save.
SaveButton<SaveButton dirty={dirty} saving={saving} saved={saved} onSave={save} />
PopoverMenuButton Available in 0.2.0+#
A full-width, left-aligned menu item for kebab and popover menus.
PopoverMenuButton<div role="menu">
<PopoverMenuButton role="menuitem" icon={<TbPencil />} onClick={rename}>Rename</PopoverMenuButton>
<PopoverMenuButton role="menuitem" icon={<TbTrash />} onClick={remove}
style={{ color: 'var(--color-destructive)' }}>Delete</PopoverMenuButton>
</div>
Label and Input Available in 0.2.0+#
Every text or number field is a Label (uppercase, secondary) above an Input. error
turns the border destructive.
Label + Input<Label htmlFor="callsign">Callsign</Label>
<Input id="callsign" value={value} onChange={e => setValue(e.target.value)} placeholder="e.g. VIPER 21" />
<Input error value="12.3.4" aria-invalid />
Select Available in 0.2.0+#
Pick one value from a list of options, with an optional "nothing chosen" placeholder.
id, name, required, title, ref: 0.2.13+
Select<Select ariaLabel="Domain" placeholder="Any domain" value={domain} onChange={setDomain}
options={[{ value: 'air', label: 'Air' }, { value: 'sea', label: 'Sea' }, { value: 'land', label: 'Land' }]} />
FieldSelect Available in 0.2.0+#
Pick one field by name. allowNone adds a leading "None"; options can be disabled with a reason.
FieldSelect<FieldSelect ariaLabel="Label field" allowNone value={field} onChange={setField}
fields={[{ name: 'callsign' }, { name: 'mmsi' }, { name: 'geometry', disabled: true, disabledReason: 'Not a text field' }]} />
MultiSelect Available in 0.2.0+#
Choose several values from a dropdown checklist. locked options are always on.
icon trigger: 0.2.6+
MultiSelect<MultiSelect ariaLabel="Roles" value={roles} onChange={setRoles}
options={[{ value: 'viewer', label: 'Viewer', locked: true }, { value: 'operator', label: 'Operator' }, …]} />
TagInput Available in 0.2.20+#
Type values and press Enter or comma to add each as a chip; paste a comma-separated list to add them all. Backspace in
the empty input removes the last chip, and each chip has its own ×. Values are trimmed and never added twice;
normalize (e.g. lower-casing) runs first, and a value validate rejects is not added — its message
shows under the field. suggestions lists matching values as you type (free entry still works),
maxItems caps the count, and readOnly shows the chips alone.
A comma from a touch keyboard or autofill ends a value, and the placeholder shows only while there are no chips: 0.2.21+
TagInput<Label htmlFor="elnots">ELNOTs</Label>
<TagInput id="elnots" ariaLabel="ELNOTs" value={elnots} onChange={setElnots}
normalize={v => v.toUpperCase()}
validate={v => (/^[0-9A-Z]{5}$/.test(v) ? null : 'An ELNOT is five letters or digits')} />
<TagInput ariaLabel="Tags" value={tags} onChange={setTags} suggestions={['maritime', 'air', 'land']} />
UnitSelect Available in 0.2.0+#
Pick a unit, orientation convention or confidence level. The family picks the list; conversions live in units.ts.
speed (m/s, km/h, knots, mph) and time (seconds, minutes, hours, days) families: 0.2.18+
UnitSelect<UnitSelect family="distance" value={unit} onChange={setUnit} ariaLabel="Distance unit" />
distanceToMeters(12, 'nmi') // 22224
speedToMetersPerSecond(20, 'kn') // 10.29
durationToSeconds(2, 'h') // 7200
Checkbox Available in 0.2.12+#
A real checkbox with a label, the accent tint, and an indeterminate state.
Checkbox<Checkbox label="Show empty fields" checked={show} onChange={setShow} />
RadioGroup Available in 0.2.15+#
One choice from a short list the reader should see side by side (use Select when the list is long).
Real radios in a radiogroup: one tab stop, and the arrow keys move and choose. Options take a
description and disabled; orientation="horizontal" lays them in a row.
RadioGroup<RadioGroup ariaLabel="Layer source" label="Layer source" value={path} onChange={setPath}
options={[{ value: 'existing', label: 'Use an existing layer' },
{ value: 'template', label: 'Create from template' },
{ value: 'import', label: 'Import a file', disabled: true }]} />
<RadioGroup ariaLabel="Clearance" value={level} onChange={setLevel}
options={levels.map(l => ({ value: l.id, label: l.name, description: l.id }))} />
Toggle Available in 0.2.0+#
On/off settings. It renders no label of its own, so compose one beside it. onColor can mark a warning state.
Toggle<Toggle value={on} onChange={setOn} aria-label="Show labels" />
<Toggle size="sm" value={airgap} onChange={setAirgap} onColor="var(--status-warning)" aria-label="Disconnected mode" />
Slider Available in 0.2.0+#
The standard single-handle numeric slider, for any min, max and step.
Slider<Slider min={0.5} max={4} step={0.25} value={scale} onChange={setScale}
aria-label="Symbol scale" formatValue={v => `${v}×`} />
OpacitySlider Available in 0.2.0+#
The single opacity control, 0–100.
OpacitySlider<OpacitySlider value={opacity} onChange={setOpacity} aria-label="Layer opacity" />
ZoomRangeSlider Available in 0.2.0+#
A dual-handle zoom range that never crosses. With currentZoom, it can set either end from the map's zoom.
ZoomRangeSlider<ZoomRangeSlider minValue={min} maxValue={max} currentZoom={9}
onChange={(lo, hi) => { setMin(lo); setMax(hi) }} />
ColorPicker Available in 0.2.0+#
A swatch popover with a custom hex field. Its value is always a resolved hex, never a token.
ColorPicker<ColorPicker value={hex} onChange={setHex} aria-label="Track colour" />
RampPicker Available in 0.2.0+#
Pick a curated ColorBrewer ramp (graduated or categorized), with a reverse toggle.
RampPicker<RampPicker kind="sequential-diverging" rampId={ramp} onChange={setRamp}
reversed={reversed} onReverseChange={setReversed} />
TypeaheadPicker Available in 0.2.0+#
The dropdown shell for search-and-pick from many: glass panel, highlight, loading, error and empty states. It owns
no fetching. Render the input as its sibling inside a position: relative wrapper.
TypeaheadPicker<div style={{ position: 'relative' }}>
<Input value={query} onChange={…} onKeyDown={onKeyDown} />
<TypeaheadPicker open={open} items={matches} getKey={s => s.id}
renderRow={s => s.name} onSelect={pick} onDismiss={close}
highlightIndex={hi} onHighlightChange={setHi} emptyCopy="No matching sites" />
</div>
FileDropZone Available in 0.2.0+#
Drag-and-drop or browse, with extension checks on both paths and optional progress.
FileDropZone<FileDropZone inputId="layer-file" label="Layer file" accept=".geojson,.kml"
acceptedExtensions={['geojson', 'kml']} file={file} onFileChange={setFile}
onReject={message => toast({ variant: 'error', message })} hint="GeoJSON or KML, up to 50 MB" />
CodeEditor Available in 0.2.0+#
@kineticlogic/staresdk/code-editor. A CodeMirror 6 JSON, HTML or text editor, token-themed, with parse
errors, caller-supplied diagnostics and caller-supplied completions. Try breaking the JSON, or type {
in the template to pick a field.
language="html", completions: 0.2.10+
CodeEditorimport { CodeEditor } from '@kineticlogic/staresdk/code-editor'
<CodeEditor aria-label="Source config" language="json" value={json} onChange={setJson} minHeight={160}
diagnostics={[{ line: 4, message: 'Port 80 needs root on most hosts', severity: 'warning' }]} />
<CodeEditor aria-label="Popup template" language="html" value={html} onChange={setHtml}
completions={({ text, pos }) => /\{\w*$/.test(text.slice(0, pos))
? { from: text.lastIndexOf('{', pos) + 1, options: fields.map(f => ({ label: f.name, detail: f.type, apply: `${f.name}}` })) }
: null} />
Modal Available in 0.2.0+#
For decisions, not browsing: confirmations and short create or edit forms. AdminModal is the same
dialog for admin-only forms. Esc and the backdrop close it.
Modal + AdminModal{open && (
<Modal title="Rename source" onClose={close} width={420}>
<Label htmlFor="name">Name</Label>
<Input id="name" defaultValue="AIS — Channel" />
<Button size="sm" onClick={save}>Save</Button>
</Modal>
)}
ClassifyModal Available in 0.2.0+#
Picks a full classification marking (level, controls, releasability, NOFORN) with favourites and a live banner preview. The host supplies the options and the banner text.
ClassifyModal<ClassifyModal options={options} value={marking} bannerFor={bannerFor}
favorites={favorites} onToggleFavorite={toggleFavorite}
onConfirm={setMarking} onClose={close} />
DraggablePopup Available in 0.2.0+#
A floating, draggable and resizable glass popup whose position is remembered under storageKey.
DraggablePopup<DraggablePopup storageKey="popup:feature" title="Feature" getDefaultPos={() => ({ x: 120, y: 120 })}
headerRight={<Button variant="ghost" size="xs" icon={<TbX />} aria-label="Close" title="Close" onClick={close} />}>
…
</DraggablePopup>
Popover Available in 0.2.12+#
A panel hung from a button (or a point): portaled, flipped and clamped to the viewport, kept clear of the classification banners, closed by Escape, an outside press or the anchor scrolling away.
maxHeight: 0.2.13+ · Placement by rendered width: 0.2.14+
Popover<Popover open={open} onClose={close} anchorRef={button} ariaLabel="Layer options">
<Checkbox label="Show labels" checked={labels} onChange={setLabels} />
</Popover>
ContextMenu Available in 0.2.0+#
A right-click menu at the pointer, flipped to stay inside the viewport. Right-click the frame. The first item takes
focus; the arrow keys, Home and End move between enabled items, Enter or Space runs one, and Escape or Tab closes
the menu and returns focus to where it was. size="sm" is the compact menu — small text, tight rows, as
wide as its content — for a map's short menus.
Keyboard navigation: 0.2.11+ · size="sm": 0.2.16+
ContextMenu + ContextMenuItem{menu && (
<ContextMenu x={menu.x} y={menu.y} onClose={close} ariaLabel="Track actions" header="TRK-0412">
<ContextMenuItem onClick={follow}><TbFocus2 /> Follow</ContextMenuItem>
<ContextMenuItem danger onClick={drop}><TbTrash /> Drop track</ContextMenuItem>
</ContextMenu>
)}
Flyout Available in 0.2.0+#
A tool menu anchored to a trigger, with an optional filter box, shortcuts, hints, disabled reasons and favourites.
Flyout<Flyout open={open} onClose={close} anchorRef={triggerRef} ariaLabel="Tools" filterable
onSelect={run} onToggleFavourite={toggleFavourite}
items={[{ id: 'measure', label: 'Measure', icon: <TbRuler2 />, shortcut: 'M', hint: 'Distance and bearing' }, …]} />
PageHeader Available in 0.2.0+#
The glass page title bar, one per page, first in the column. It sets document.title. Optional
leading, center and actions slots take the home button, clocks, assistant and user chip.
PageHeader + HomeButton + ClockBadges + AssistantButton + Avatar<PageHeader title="Track database" appName="OpenTrack"
leading={<HomeButton onNavigate={navigate} />}
center={<ClockBadges clocks={[{ timezone: 'UTC', label: 'Zulu' }, { timezone: 'America/New_York', label: 'HQ' }]} />}
actions={<><AssistantButton open={open} onToggle={toggle} /><Avatar size={28} username="jdoe" /></>}>
<Badge size="sm" uppercase color="success">Live</Badge>
</PageHeader>
Tabs Available in 0.2.0+#
variant="bar" is the page navigation directly under the header: uppercase, no icons. The inline
variant switches views inside a panel or drawer, with optional icons, counts and notification bubbles. Both are
WAI-ARIA tabs with arrow-key navigation; pair them with TabPanel.
Tabs + TabPanel<Tabs variant="bar" aria-label="Workspaces" value={view} onChange={setView}
tabs={[{ id: 'sources', label: 'Sources' }, { id: 'tracks', label: 'Tracks' }, { id: 'schema', label: 'Schema' }]} />
<Tabs aria-label="Track detail" idPrefix="detail" value={tab} onChange={setTab}
tabs={[{ id: 'summary', label: 'Summary', icon: <TbInfoCircle /> },
{ id: 'contacts', label: 'Contacts', count: 214 },
{ id: 'reviews', label: 'Reviews', badge: 3, badgeLabel: '3 pending' }]} />
<TabPanel id={tab} idPrefix="detail">…</TabPanel>
SideNav Available in 0.2.0+#
The drawer for detail views, docked left or right, resizable, stacking with other drawers. It reserves page width
through --assistant-width / --dock-left-width and must sit inside a DockProvider.
SideNav + DockProvider<DockProvider>
<SideNav open={selected != null} side="right" ariaLabel="Track detail" storageKey="sidenav:track">
…
</SideNav>
</DockProvider>
CollapsiblePanel Available in 0.2.0+#
The titled, collapsible section every page is built from. titleActions (directly after the title) and
actions (right-aligned) hold the panel's own compact controls; clicks there never toggle it.
persistKey remembers the open state.
CollapsiblePanel<CollapsiblePanel title="Sources" badge="4" persistKey="panel:sources"
titleActions={<Input placeholder="Search" style={{ width: 180, height: 26 }} />}
actions={<Button size="sm" variant="secondary" icon={<TbPlus />}>Add</Button>}>
…
</CollapsiblePanel>
Disclosure Available in 0.2.0+#
An inline expander. Its content renders only while open.
Disclosure<Disclosure label="sensors (3)">…</Disclosure>
Stepper Available in 0.2.0+#
A compact wizard step header. Completed or failed steps can be revisited; upcoming ones cannot.
Stepper<Stepper aria-label="Onboard a source" onStepClick={setStep} steps={[
{ id: 'connect', label: 'Connect', status: 'complete' },
{ id: 'map', label: 'Map fields', status: 'current' },
{ id: 'review', label: 'Review', status: 'upcoming' },
]} />
AppCard Available in 0.2.0+#
The landing-page app tile. It renders a real link, so Ctrl/⌘-click still opens a new tab; pass
onNavigate to route a plain click through your router. HomeButton,
AssistantButton and ClockBadges appear in the PageHeader example.
AppCard<AppCard label="Track DB" icon={<TbRadar2 />} href="/tracks" description="Fused tracks and their sources" onNavigate={navigate} />
<AppCard label="Imagery" icon={<TbPhoto />} href="/imagery" disabled disabledTooltip="Ask an administrator for access" />
DataTable Available in 0.2.0+#
Sortable columns (stable, nulls last), row selection with keyboard activation, and a sticky header with
virtualisation when maxHeight is set. Header rows stay neutral: weight, uppercase and a bottom border,
no fill and no accent.
manualSort, pinFirstColumn, onColumnResize, onRowDoubleClick: 0.2.5+ · headerDividers: 0.2.7+ · onColumnReorder: 0.2.8+ · selection: 0.2.9+ · onRowContextMenu: 0.2.11+
DataTable<DataTable aria-label="Tracks" rows={tracks} rowKey={t => t.id} maxHeight={260}
selectedKey={selected} onRowClick={t => setSelected(t.id)}
defaultSort={{ key: 'updated', direction: 'desc' }} empty="No tracks yet."
columns={[
{ key: 'id', header: 'Track', mono: true, width: 110, sortValue: t => t.id },
{ key: 'callsign', header: 'Callsign', sortValue: t => t.callsign },
{ key: 'status', header: 'Status', render: t => <Badge size="sm" uppercase color={…}>{t.status}</Badge> },
{ key: 'speed', header: 'Speed (kn)', align: 'right', mono: true, sortValue: t => t.speed },
]} />
For a server-paged attribute table: manualSort leaves row order to the caller (header clicks only
report the next sort), a column with sortable: false loses its sort affordance and shows
sortDisabledReason as its tooltip, pinFirstColumn keeps the first column in place while
the table scrolls sideways, onColumnResize adds a drag handle to each header edge (reported once, on
release; widths stay controlled through each column's width), and onRowDoubleClick opens a
row. The single highlighted row is selectedKey. selection adds a checkbox column for a
caller-owned multi-row selection, onColumnReorder lets the reader drag headers into a new order,
headerDividers separates headings that run together, and onRowContextMenu opens a row menu
(right-click, Shift+F10 or the ContextMenu key).
DataTable as an attribute table<DataTable aria-label="Track attributes" rows={page} rowKey={t => t.id} maxHeight={220}
manualSort sort={sort} onSortChange={setSort} pinFirstColumn
onColumnResize={(key, width) => setWidths(w => ({ ...w, [key]: width }))}
onRowDoubleClick={t => open(t.id)} headerDividers
onColumnReorder={(key, toIndex) => move(key, toIndex)}
selection={{ isSelected, onToggle, header, onToggleAll }}
columns={fields.map(f => ({ key: f.name, header: f.name, width: widths[f.name] ?? 160,
sortable: f.sortable, sortDisabledReason: f.sortable ? undefined : 'Text fields cannot be sorted' }))} />
Pagination Available in 0.2.0+#
Offset/limit paging that matches ?offset= and ?limit=. It handles an unknown total and a backend offset ceiling.
Optionally, showEnds adds First/Last buttons, pageSizes with onLimitChange adds a page-size
select, and label="page" reads "Page X of Y · N features" (noun changes the last word).
showEnds, pageSizes, label="page": 0.2.5+
Pagination<Pagination offset={offset} limit={50} total={1284} itemCount={rows.length} onOffsetChange={setOffset} />
<Pagination offset={offset} limit={limit} total={1284} itemCount={rows.length} onOffsetChange={setOffset}
showEnds label="page" pageSizes={[50, 100, 250]} onLimitChange={l => { setLimit(l); setOffset(0) }} />
Tree Available in 0.2.0+#
A hierarchical list with expand and collapse, in none, single or multi selection modes. A node's description shows in a tooltip while its row is hovered or focused.
description: 0.2.17+
Tree<Tree nodes={nodes} selectable="multi"
checkedIds={checked} onCheckedChange={(id, on) => …}
expandedIds={expanded} onExpandedChange={(id, open) => …} />
Facets Available in 0.2.0+#
A group of filter chips with counts, for narrowing a catalogue or search results.
Facets<Facets label="Domain" icon={<TbTag />} options={[{ value: 'air', label: 'Air', count: 412 }, …]}
selected={selected} onToggle={toggle} onClear={() => setSelected([])} />
SortableList Available in 0.2.0+#
Drag-to-reorder by a grip handle (or the whole row with dragMode="row"), keyboard-accessible through dnd-kit.
SortableList<SortableList items={layers} getId={l => l.id} onReorder={setLayers} itemLabel="layer"
renderItem={l => <span>{l.name}</span>} />
VirtualList Available in 0.2.0+#
A windowed list for long, fixed-height rows. Only the visible window renders.
VirtualList<VirtualList items={contacts} rowHeight={28} maxHeight={200} ariaLabel="Contacts"
getKey={c => c.id} renderRow={c => <div>{c.id}</div>} emptyState="No contacts." />
MDText Available in 0.2.0+#
Rendered Markdown with GFM (tables, task lists, strikethrough), in the wiki, chat or
tool style. Mermaid code blocks render as diagrams when mermaid is installed.
MDText<MDText markdown={article} />
Badge Available in 0.2.0+#
Status, count or category. grey is the neutral default; brand is for brand affordances,
not status. Use size="sm" and uppercase for states. Status badges always carry text.
Badge<Badge size="sm" uppercase color="success">Live</Badge>
<Badge size="sm" uppercase color="warning" icon={<TbAlertTriangle />}>Lagging</Badge>
<Badge size="sm" outline color="blue">12 tracks</Badge>
Avatar Available in 0.2.0+#
A circular avatar. Missing or failing images fall back to an initials circle.
Avatar<Avatar size={32} username="jdoe" firstName="Jane" lastName="Doe" src={user.avatarUrl} />
Tooltip Available in 0.2.0+#
The hover label for a map feature, anchored to viewport coordinates. It sits below a Modal. Move the pointer over the frame.
id: 0.2.17+
Tooltip{hover && <Tooltip x={hover.x} y={hover.y} content={hover.feature.name} />}
InfoTip Available in 0.2.0+#
An ⓘ beside a label that explains a field, setting or heading. It shows above a Modal.
With interactive, the bubble can hold links: it stays open while the pointer moves into it, a
click pins it open, and Escape or a press outside closes it.
interactive: 0.2.19+
InfoTip<Label>Gate distance <InfoTip label="Gate distance">Contacts further than this from a track's
predicted position start a new track.</InfoTip></Label>
ItemClassificationBar Available in 0.2.0+#
The per-item classification strip, coloured by level. null renders CLASSIFICATION NOT SET.
ItemClassificationBar<ItemClassificationBar banner="UNCLASSIFIED//REL TO USA, FVEY" level="UNCLASSIFIED" />
<ItemClassificationBar banner={null} />
ClassificationBanner Available in 0.2.0+#
Fixed top and bottom strips above everything. Reserve BANNER_HEIGHT_PX through --banner-height.
Shown here inside a frame.
ClassificationBanner<ClassificationBanner enabled text="UNCLASSIFIED"
background="var(--classification-unclassified-bg)" color="var(--classification-unclassified-text)" />
Toast Available in 0.2.0+#
useToast().toast() raises a transient result. Errors never auto-dismiss.
useToast().confirm() replaces window.confirm and always names the object.
Needs a ToastProvider.
useToast()const { toast, confirm } = useToast()
toast({ variant: 'success', message: 'Source saved.' })
toast({ variant: 'error', title: 'Layer ingest failed', message: 'The file has no geometry column.' })
if (await confirm('Delete source "AIS — Channel"?', { confirmLabel: 'Delete' })) remove()
MapView Available in 0.2.0+#
@kineticlogic/staresdk/map-view. A MapLibre GL map of points and polylines on the offline basemap
style (theme-aware), with selection, fit-to-points and parent-driven fitTo. Point and line colours are
data colours and stay the same in both themes. Click a point.
MapViewimport { MapView } from '@kineticlogic/staresdk/map-view'
import 'maplibre-gl/dist/maplibre-gl.css'
<MapView aria-label="Tracks" height={320} outlines={countriesUrl}
points={tracks} lines={trails} selectedId={selected} onSelect={setSelected} fitKey="initial" />
Set MapLibre's worker URL once in your app:
setWorkerUrl(maplibreWorkerUrl) with import maplibreWorkerUrl from 'maplibre-gl/dist/maplibre-gl-worker.mjs?worker&url'.
TimeSeriesChart Available in 0.2.0+#
@kineticlogic/staresdk/chart. A compact uPlot line chart over time. Colours come from tokens and update
when the theme changes. Each series picks a tone, never a colour, and the legend shows values under the cursor.
TimeSeriesChartimport { TimeSeriesChart } from '@kineticlogic/staresdk/chart'
import 'uplot/dist/uPlot.min.css'
<TimeSeriesChart aria-label="Ingest rate" times={times} unit="/min"
series={[{ label: 'Contacts', values: contacts, tone: 'accent' }, { label: 'Rejected', values: rejected, tone: 'danger' }]} />
ProfileChart Available in 0.2.0+#
Height against distance along a path, in plain SVG with token colours. null heights break the line;
an optional dashed sight line and obstruction marker show line of sight.
ProfileChart<ProfileChart aria-label="Terrain profile" samples={samples}
sightLine={[{ distance_m: 0, height_m: 412 }, { distance_m: 18000, height_m: 260 }]}
obstruction={{ distance_m: 9400, height_m: 351 }} />
GraphView Available in 0.2.0+#
@kineticlogic/staresdk/graph-view. A directed graph for lineage, provenance and pipelines: React Flow
rendering, dagre layout and token-styled nodes. ended edges are dashed, and animated edges
move to show data flowing (drawn still under reduced motion).
GraphViewimport { GraphView } from '@kineticlogic/staresdk/graph-view'
import '@xyflow/react/dist/base.css'
<GraphView aria-label="Pipeline" height={240} layerGap={48}
nodes={[{ id: 'ais', label: 'AIS', sublabel: 'source', width: 120 }, { id: 'fuse', label: 'Fuse', tone: 'accent', width: 120 }, …]}
edges={[{ id: 'e1', source: 'ais', target: 'fuse', animated: true }, …]} />
Reference#
Hardcoded-hex exceptions#
Two places in the package keep raw hex literals instead of var() references, mirroring the same two
exceptions in OpenStare: badgeTokens.ts's BADGE_BG (parsed for WCAG contrast) and
chromeTheme.ts's severityRamp. Map feature, track, tactical and data-viz colours inside
OpenStare's layer rendering are deliberately theme-invariant and outside this package's scope.
Peer dependencies#
stareSDK declares its runtime dependencies as peerDependencies, so your app controls the installed
versions: react, react-dom, react-icons, framer-motion,
@dnd-kit/core, @dnd-kit/sortable, @dnd-kit/utilities,
react-markdown, remark-gfm, mdast-util-find-and-replace, and
mermaid (optional, loaded with a dynamic import() only when MDText renders a Mermaid block).
Lint#
The raw-colour-literal gate (eslint/colorLiteralGate.js) is exported for consuming apps, so colours stay on the tokens:
import { RAW_COLOR_LITERAL_SELECTORS } from '@kineticlogic/staresdk/eslint/colorLiteralGate.js'
export default [{ files: ['src/**/*.{ts,tsx}'], rules: { 'no-restricted-syntax': ['error', ...RAW_COLOR_LITERAL_SELECTORS] } }]
Development#
npm install --legacy-peer-deps
npm run check # typecheck, lint (including the colour-literal gate) and tests
npm run build # dist/
npm run docs # this guide, into docs/
To release, bump version in package.json and publish a GitHub release tagged
v<version>. The publish workflow then publishes the package to npm with provenance.
License#
MIT. © 2026 kineticlogic.io. For consulting or support, email admin@kineticlogic.io.