Packages
Truss UI is a small family of npm packages. They share one set of design tokens, so charts, graphs and maps retheme with the rest of your app.
| Package | What it is | Dependencies |
|---|---|---|
@ruckc/truss-ui | The core: tokens, fonts, icons and ~90 components. | peer: svelte |
@ruckc/truss-chart | Line and (stacked) area, bar, donut, histogram, scatter and sparkline charts, plus stat tiles, in plain SVG. | peer: svelte |
@ruckc/truss-graph | A node/edge editor and read-only explorer: custom node components, five edge routers, layered, force and radial layouts. | peers: svelte, @ruckc/truss-ui |
@ruckc/truss-ol | An OpenLayers map styled by the tokens. | peers: svelte, @ruckc/truss-ui, ol |
Charts · @ruckc/truss-chart
Import the palette once, then use the components. Charts are responsive (they fill their container's width), keyboard-readable, and every one has a Table toggle showing the underlying values.
import '@ruckc/truss-chart/charts.css';
import { LineChart, BarChart, DonutChart, Histogram, ScatterChart, Sparkline, StatTile, ColorScale, gradientFrom } from '@ruckc/truss-chart'; <LineChart title="Traffic" {x} series={[{ name: 'Requests', values }]} />
<BarChart categories={['Q1','Q2']} {series} stacked="percent" horizontal showValues />
<Histogram values={latencies} bins={30} bind:range />
<LineChart {x} {series} area stacked="percent" />
<ScatterChart {points} xLabel="CPU" yLabel="Latency" />
<StatTile label="Requests" value={12840} delta={0.12} spark={trend} /> <LineChart {x} {series} band={{ lo, hi, label: 'Forecast range' }} referenceLines={[{ value: 0, label: 'Break-even' }]} xFormat={(d) => `Day ${d}`} yScale="log" height="fill" />
<StatTile size="sm" label="Errors" value={12} /> - LineChart extras.
bandis one chart-level{ lo, hi, color?, label? }envelope (arrays aligned tox,nullleaves a gap) drawn behind the lines, widening the y-domain and listed in the tooltip and table.referenceLinesdraws emphasised labelled horizontal lines (also in the domain, the accessible name and the table view).zeroBaselineonly guarantees 0 is inside the y-domain; it draws no line.xFormatmirrorsyFormatfor ticks, tooltip and table.height="fill"sizes the plot to its parent via a ResizeObserver, so the parent needs a definite height.xScale/yScale="log"use base-10 axes with ticks at powers of 10; values at or below 0 are ignored (dropped on x, treated as missing on y),stackedis ignored on a log y axis, and a log x axis is ignored for dates. - StatTile size.
size="sm"uses the--tr-text-xlvalue size and tighter padding. Set--trc-stat-value-sizeon the tile or any ancestor to choose the value size directly (default 28px); no:globaloverride is needed. - Up to eight series, in a fixed colour order that never cycles. Past eight, fold the tail into “Other”.
- To colour a series yourself, set
coloron it (any CSS colour orvar(--…)). This bypasses the validated palette, so check contrast and distinguishability yourself. To retheme a palette slot for every chart inside one element instead, override--trc-sNon a wrapper element. BarChartdraws labelled reference lines withreference={{ value: 200, label: 'Target' }}(or an array of them; they extend the value axis so they are always in view) and takesdetails, one string per category, shown in the tooltip and as a Details column in the table view. For pre-binned counts, useBarChartwithcategories;Histogramis for binning raw values.- Continuous scales (heatmaps, choropleths) use the sequential and diverging ramps:
--trc-seq-1..7(one hue, lightness monotonic from low to high) and--trc-div-1..7(blue to a neutral midpoint--trc-div-4to orange, colour-blind-safe poles), both with dark-mode values.gradientFrom('sequential' | 'diverging', direction?)builds a CSSlinear-gradientfrom the tokens, and<ColorScale caption="Error rate" low="0%" high="40+%" palette="diverging" mid="20%" />draws the legend (arole="img"named "Error rate: 0% to 40+%"; passverticalfor a bottom-to-top bar). - One value axis only: never dual-axis. Two measures of different scale belong in two charts.
- Lines are 2px, marks are thin, gridlines recede, and adjacent fills are separated by a 2px gap.
Graph · @ruckc/truss-graph
import { Graph, autoLayout, forceLayout, radialLayout, routeEdge } from '@ruckc/truss-graph'; <Graph bind:nodes bind:edges edgeType="avoid" minimap nodeTypes={{ decision }} {edgeLabel} /> Custom node components
Register a snippet per node.type. It receives { node, selected, dragging, update } and draws the whole node. Mark interactive regions with the nodrag class so dragging a slider or input inside a node doesn't drag the node.
Ports
Nodes get an input on one side and an output on the other by default. Declare ports: [{ id, side, kind, label }] for any number of named ports on any side; edges pick them with fromPort / toPort.
Edge routing
edgeType | Behaviour |
|---|---|
| bezier | A smooth curve leaving and entering each port perpendicular to its side. |
| straight | A direct line. |
| step | Orthogonal segments with sharp corners. |
| smoothstep | Orthogonal segments with rounded corners. |
| avoid | Orthogonal routing that steers around other nodes (A* over a visibility grid, penalising bends), falling back to step if boxed in. |
Set it for the whole graph with edgeType, or per edge with edge.type.
Explorer mode
Set editable={false} for a read-only viewer: arrow keys move between neighbouring nodes instead of moving them. Choose a layout (layered, force or radial; the pure forceLayout and radialLayout functions are exported too), give nodes a shape (rect, circle, diamond) and show a legend whose edge kinds toggle visibility. Through bind:this the graph exposes zoomTo(id), center(), highlight(id, 'up' | 'down' | 'both'), clearHighlight(), collapse(id) and expand(id). Large graphs (hundreds to a thousand nodes) cull off-screen nodes and edges and simplify nodes when zoomed far out.
<Graph bind:this={g} {nodes} {edges} editable={false} layout="force" legend minimap />
<Button onclick={() => g.highlight('orders', 'down')}>Downstream</Button> Node callbacks
onnodeclick(node, event), onnodedblclick(node, event), onnodehover(node | null) and onnodecontextmenu(node, event) report interaction without changing it: selection, dragging, connecting and read-only traversal work as before. A click fires after the selection has updated; the click that ends a drag, and clicks on ports, collapse badges and controls inside custom nodes (inputs, buttons, links, .nodrag) are not node clicks. A double-click delivers two clicks and then onnodedblclick (the graph has no double-click action of its own). From the keyboard, Enter on a focused node reports a click and then toggles its collapse; call event.preventDefault() in the handler to keep the collapse from happening. ContextMenu or Shift+F10 reports onnodecontextmenu once, and the native menu is suppressed while the callback is set.
Colours
Give a node a category (an index from 0 into the chart categorical palette, 8 slots that wrap) or any CSS color. The colour wins over kind, and color wins over category. The palette is --trc-s1 to --trc-s8 when the charts stylesheet is loaded and built-in light and dark values otherwise, so the graph needs no charts CSS. With legend, each category in use gets a row named by the categories prop (['Table', 'View'], else "Category N"); legend={{ categories: false }} hides those rows. Nodes with a custom color are not listed.
Incremental layout
The pure layouts take pin, a list of node ids: autoLayout(nodes, edges, { pin }), forceLayout(...) and radialLayout(...) return pinned nodes exactly where they were and place every other node near the neighbours it is connected to (next column for layered, outward from its neighbour for radial, starting beside it and relaxing for force), without overlapping anything. They stay deterministic. On the component, g.layout({ pin }) does the same through the chosen layout and leaves the viewport alone; g.layout() still re-runs everything and fits. For "expand neighbours": add the nodes and edges, then call g.layout({ pin: previousIds }).
<Graph bind:this={g} bind:nodes bind:edges layout="force" legend {categories} onnodedblclick={expand} />
async function expand(n) {
const pin = nodes.map((x) => x.id);
nodes = [...nodes, ...fresh]; edges = [...edges, ...links];
await tick();
g.layout({ pin });
} Overlays inside nodes
A HoverCard or Popover (fixed) in a node body is positioned against the page, not the zoomed canvas: the graph lifts it into an overlay layer outside the transformed canvas, keeps it at its natural size, and re-places it while you pan or zoom. Give such popovers an anchor. Pointer, wheel and key events inside the lifted card do not reach the graph.
Map · @ruckc/truss-ol
OpenLayers is a peer dependency, so you control its version, and it is loaded lazily in the browser.
npm install @ruckc/truss-ol ol import { Map } from '@ruckc/truss-ol';
<Map {markers} {geojson} basemap="none" bind:selected bind:center bind:zoom height={420} /> Marker, line and polygon colours are read from the tokens at runtime and re-read when the theme changes. The none basemap draws a themed graticule and works offline; osm and custom XYZ tile URLs are dimmed and tinted in dark mode.