@ai-matrx/design-system
The small, semantic UI foundation shared by AI Matrx React applications — primitives whose behavior and variants are identical across Vite and Next.js hosts.
Controls: Button, Badge, Label, Separator, the Input family, the Textarea family, Checkbox, Switch, RadioGroup, Slider, Toggle + ToggleGroup, Avatar, Progress.
Frameless fields: Field from @ai-matrx/design-system/controls and ProInput / ProTextarea from @ai-matrx/design-system/writing-field accept bare. The surrounding surface owns the border, fill, and focus treatment; the field paints none, including under the desktop look. A bare ProTextarea keeps its text inset and the space reserved for its microphone and menu controls. Existing default fields keep their normal styling.
Content transfer: moved to @ai-matrx/alchemy/react/workspace (0.69.0, ALC-16); it supplies Matrx Alchemy's inlined glyph, accessible menu, projected capture workspace, optional AI capability seam, and locally scoped SurfaceHandle provider. Its source is captured on user action; preparation keeps an immutable original, preserves manual and alternate-source sessions, fences stale async results, and routes every copy/download through Transport. Import @ai-matrx/design-system/content-transfer.css with the package structural sheets.
<MatrxCopyMenu
label="Log"
export={{
items: [
{
id: "raw",
label: "Download raw log",
build: () => ({
content: rawText,
filename: "worker.log",
extension: "txt",
mime: "text/plain",
}),
},
],
}}
/>
An explicit filename is an exact safe basename captured with its bytes. Omit it to use the label-derived default; unsafe values fail the export visibly.
Surfaces: Card, Table, Tabs, Accordion, Collapsible, ScrollArea, Popover, Tooltip, HoverCard, Sheet, Dialog, AlertDialog, Drawer, BottomSheet + TabbedBottomSheet, Alert, Resizable, Skeleton.
Menus: DropdownMenu, ContextMenu, Command / CommandDialog, Select, CreatablePicker.
Composed: EditableLabel, SegmentedControl, ArchiveFilter, ScoreRing, OverflowToolbar, ConfirmDialog + ConfirmDialogHost.
Hooks + utilities: useScrollFade, useIsMobile, usePortalContainer, useDialogContainer, useDrawerDirection, cn.
The confirm dialog lives here; the imperative confirm() lives in @ai-matrx/kit. Since 0.11.0 / kit 0.9.0 the surface is one implementation, in the package that owns AlertDialog, the motion layer and the overlay scrim token. Mount the host once near your provider root and call confirm() from anywhere — they share one registry through a globalThis slot:
// app root, once
import { ConfirmDialogHost } from "@ai-matrx/design-system";
<ConfirmDialogHost />
// anywhere — a thunk, a util, a click handler
import { confirm } from "@ai-matrx/kit/confirm-opener";
if (!(await confirm({
title: "Re-run research",
description: "This discards the 14 pages you already have (~$50 and 10 minutes) and starts over.",
variant: "destructive",
confirmLabel: "Discard and re-run",
}))) return;
Use the declarative <ConfirmDialog open … busy /> inline instead when the dialog must stay open with a spinner while async work finishes. Name the consequence: a confirmation that says only "Are you sure?" does not satisfy the platform's destructive-and-expensive-actions law.
Footer actions stay touch-sized through portals. Dialog, AlertDialog, Drawer, Sheet, and BottomSheet footers apply a 44px action floor on coarse pointers or below 1024px. Import the package stylesheet; no host subtree class is required. Fine-pointer desktop keeps its declared density. State controls and prose links retain their sizing; exceptional compact actions can use data-touch-exempt with a call-site reason.
Every list over an archivable entity uses ArchiveFilter. THE ARCHIVED-ITEMS LAW (Arman, 2026-09-09 — common-docs/policies/archived-items.md): an archive filter on every list, the default hides archived rows, and seeing them is one click. ArchiveFilter is the one control — active (default) / archived / all, in the same words the Next.js app uses.
import { ArchiveFilter, DEFAULT_ARCHIVE_FILTER, type ArchiveFilterValue } from "@ai-matrx/design-system";
const [archived, setArchived] = React.useState<ArchiveFilterValue>(DEFAULT_ARCHIVE_FILTER);
const rows = useRows({ archived }); // the SERVER applies the predicate
<ArchiveFilter value={archived} onValueChange={setArchived} counts={{ active: rows.activeCount }} />
Pass the value to the reader, never filter the already-fetched page: counts, badges and pagination must describe what the list renders. toArchiveFilter() narrows an untrusted value (URL param, stored preference) back to the tri-state, defaulting to active so nothing silently widens.
The Tooltip lives here, and only here. @ai-matrx/tap-target used to ship its own copy; since design-system 0.7.0 / tap-target 0.2.0 it consumes this one and re-exports nothing.
Where hosts had forked a primitive over DENSITY, density is a prop, not a fork: Card size="sm|md|lg", Table size="sm|md", Accordion size="sm|md", Switch/Avatar/Checkbox/Slider/RadioGroup/Toggle size, ResizableHandle size="xs".."4xl". It is declared once on the root and every item reads it from context, so a card cannot mix paddings and a radio group cannot mix control sizes.
Motion ships with the component — ALL of it. Every animated surface here
(Dialog, AlertDialog, Sheet, CommandDialog, Popover, DropdownMenu,
ContextMenu, HoverCard, Select, Tooltip, Accordion, Collapsible,
Skeleton) animates out of the box from keyframes in styles.css. You never
define keyframes, add an animate-* utility, or install tailwindcss-animate
/ tw-animate-css for anything this package renders — that plugin is loaded
from a host's CSS entry, which is why three of the four apps that already used
this package had zero animation on every overlay and no error to show for it
(0.10.0). prefers-reduced-motion is honored in the sheet.
Retiming is a token change, not an override — set --matrx-motion-duration-fast
/ -base / -slow / -sheet, --matrx-motion-ease-out / -in, or
--matrx-motion-popper-travel. For one surface that must appear instantly,
pass animated={false}; CollapsibleContent's opt-out additionally stops it
clipping its body, for the rare panel with a child that must overflow.
Mobile is the Drawer's job. Drawer owns the iOS rules every host was re-deriving — dvh never vh, pb-safe/pt-safe safe-area padding, a 44px grab-handle target, DrawerBody as the flex-1 min-h-0 scroll shard — and direction makes a top/side drawer a prop rather than a fork. Every touch control (Slider, RadioGroup, ResizableHandle) carries an invisible 44px target on coarse pointers, with desktop density untouched.
pnpm add @ai-matrx/design-system
import { Badge, Button } from "@ai-matrx/design-system";
<Button variant="subtle" size="sm">Add step</Button>
<Badge variant="success">Ready</Badge>
Host-shaped systems are injected, never imported: wrap subtrees in PortalContainerProvider to point nested Popover portals at a dialog or popout body, and hand OverflowToolbar your menu and tooltip systems through its renderOverflowMenu / renderTooltip props. @ai-matrx/kit is a real dependency (installed automatically).
web-theme.css registers Tailwind Typography for a host CSS build. Install @tailwindcss/[email protected] in any host that imports that stylesheet. It is an optional peer so component-only consumers do not install a build plugin at runtime.
Styling — the package ships its CSS, the host owns the values
The package is opinionated about the token contract and ships every rule its components need to work; it is not opinionated about your colors.
// app root, once
import "@ai-matrx/design-system/tokens.css"; // default token VALUES — override these
import "@ai-matrx/design-system/styles.css"; // structural CSS — required
/* your Tailwind/CSS entry — FIRST line, before @import "tailwindcss" */
@layer matrx-design-system-tokens, matrx-design-system;
styles.css(required) — every rule the components need to render right: the.matrx-scroll-fademask geometry behinduseScrollFade, the.matrx-mobile-sheetdensity restatementTabbedBottomSheetsets, the.matrx-glass-thin-borderchrome,.pb-safe,.shadow-input. It hardcodes no color: every color resolves from a token.tokens.css(default values) — a neutral, sensible default for every token the package reads, in both themes, so a fresh app renders correctly unthemed. This is not a brand.theme.css(Tailwind v4 hosts, optional) —@import "@ai-matrx/design-system/theme.css";from your Tailwind entry maps the semantic color utilities (bg-background,text-muted-foreground, …) onto those tokens and registers the package build as a scan source. A host that already maps this vocabulary (matrx-frontend does, as a branded superset) skips this file and keeps its own.
Overriding with your brand. Redeclare any token — --primary,
--background, the --matrx-glass-* material, the overlay scrims — anywhere
in your own CSS. The package defaults live in the
matrx-design-system-tokens layer, and the one-line @layer declaration
above puts that layer at the bottom of the cascade, so any host
declaration wins: layered or unlayered, before or after, whatever the
specificity. Declare the layer order once and the question never arises.
Tailwind CSS v4 ignores node_modules by default, so a host that does not
import theme.css still registers the package build beside its Tailwind
import:
@import "tailwindcss";
@source "../node_modules/@ai-matrx/design-system/dist";
Workspace consumers point @source at apps/shared/design-system/src.
Color roles — pick by meaning, always with the pair's own text token.
primary(blue action) ·bg-primary text-primary-foreground.secondary= quiet GRAY surface (shadcn meaning) ·bg-secondary text-secondary-foreground. Never a text, border, ring or gradient color.brand-secondary= the PURPLE brand accent ·bg-brand-secondary text-brand-secondary-foreground; as text/border/gradient:text-/border-/from-brand-secondary.muted/accent= gray rows, hovers, wells ·text-muted-foregroundfor quiet text.destructivesuccesswarninginforead as TEXT on page and card (text-destructive) and as fills with their-foreground.- A tone as text on a tint of itself (badge, chip) uses its
-inktoken:bg-success/15 text-success-ink. - Guards:
token-contrast.test.ts(every pair ≥ 4.5:1, both themes) andsecondary-role.census.test.ts(no misuse in any package).
Release gate
pnpm --filter @ai-matrx/design-system typecheck
pnpm --filter @ai-matrx/design-system test
pnpm --filter @ai-matrx/design-system check:package
The gate validates declarations and exports against the packed tarball, installs it into an empty project, and imports the public entry point.
Data tables
The browser-only table playground has two explicit presets: Capability examples is its illustrative starting configuration, while Package defaults reflects the table source's defaults. The latter uses normal density unless a host provides one, standalone zebra rows, sticky headers, view tabs, column resizing/reordering, copy, selection, and 25-row pagination; grouping, saved views, summary, spreadsheet, editing, formatting, styling, facets, detail, and validation require configuration. Its Status review and Owner review views are provided local starting examples using the same saved-view host contract as user-created views. They are editable and persist only in that browser's local storage; production hosts own durable storage, authorization, and any database-backed truth.
Desktop columns are adjustable by default. Optional summary supplies KPI
metrics above the toolbar and totals aligned to column IDs below the rows.
Callbacks receive the filtered rows before pagination, plus isFiltered and
coverage (all, loaded, or page). The table labels that scope and never
calls aggregates for an empty loading or failed read. Retained stale rows are
labeled; unavailable aggregates remain visible. Use summary.source only for
caller-provided complete source totals; missing source values are never replaced
with an aggregate of the loaded page.
summary={{
metrics: [{ id: "amount", label: "Amount", value: ({ rows }) => rows.reduce((n, row) => n + row.amount, 0) }],
totals: { amount: ({ rows }) => rows.reduce((n, row) => n + row.amount, 0) },
}}
import { MatrxDataTable } from "@ai-matrx/design-system/data-table";
import { usePaginatedData } from "@ai-matrx/data/react";
// The caller owns loadPage, source identity, and query state.
const pagination = usePaginatedData({ queryKey, initialCursor: 0, loadPage, getRowId });
<MatrxDataTable data={pagination.rows} columns={columns} getRowId={getRowId}
query={{ mode: "controlled-append", state, onStateChange, pagination,
scroll: { mode: "scroll", thresholdPx: 96, intentTimeoutMs: 1200 } }} />;
URL/source owners can control columns without rebuilding the table: pass
columnState={{ order, hidden, onChange }} and reorderableColumns. The
package then commits both the Columns dialog and header drag through onChange.
The dialog provides drag ordering and labeled arrow controls; columns marked
hideable: false remain visible.
Use application-resolved preferences for scroll; manual always retains Load
more. Numbered exact-page sources use controlled with totalItems; fully loaded
client arrays use local mode or controlled-local. Partial server results must
not claim client filtering searched the entire source. Query identity must cover
authentication, organization, filters, sort, and page size. A page loader receives
an AbortSignal and returns rows, a next cursor (null when complete), and an
optional exact total. Repeated cursors fail visibly instead of looping.
Framework-specific identity ports are optional via
@ai-matrx/design-system/data-table/host. Utilities and types have dedicated
subpaths listed in the export map. The package retains copy/subset/export, row
windows, hierarchy, selection, inline editing, and accessible manual pagination.
Editable append tables keep drafts separately for each query identity during the component lifetime. A return to that identity restores its drafts; drafts from other results offer Copy and confirmed Discard, never a save against the current rows. Applications own persistence across unmounts and must remount the table when changing authenticated users so prior-user drafts are not exposed.
Table row actions
Standard MatrxDataTable.rowActions returns icon descriptors, never React content:
rowActions={(row) => [{
id: "inspect", icon: Eye, label: `Inspect ${row.name}`,
tooltip: "Open record details", onClick: () => openRecord(row.id),
variant: "ghost", tone: "primary",
}]}
label is the accessible name and default tooltip, not visible button text. disabled and
loading retain the icon button; the table owns density and sizing. onClick receives the
button event for callers that need modifier keys. A menu, editor or other React content must
be declared in its own column: { id: "workflow", header: "Workflow", customActions: (row, controls) => <WorkflowMenu row={row} controls={controls} /> }. Custom columns carry live
record controls, never participate in sort/filter/copy/spreadsheet data, and are not draggable
(their headers may contain controls). Hidden custom columns stay hidden in mobile cards too. A table with a visible custom column draws ONE actions column: the row copy rides inline at the
start of the first visible custom column, and the built-in row-actions column is drawn only for
standard icon rowActions or the window button. Under fitToWidth a custom column is as wide as
its widest row strip or its header, never a scaled share; when it ends the row it is pinned right
like the built-in column.
Row actions declared once, with a "⋯" overflow menu
rowActionDefs declares the actions once for the whole table; rowActionsInline caps how many
show as icons (default: no cap, so a table that does not opt in is unchanged). The first N, in
declared order, are icons; the rest become one "⋯" menu at the end of the strip, each item with
its icon and label. An action that does not apply to a row is drawn DISABLED in its slot (with
unavailableTooltip), never removed, so the icons line up down the column.
rowActionsInline={3}
rowActionDefs={[
{ id: "track", icon: Radar, label: "Track", appliesTo: (p) => !p.tracked, unavailableTooltip: "Already tracked", onClick: (p) => track(p) },
{ id: "open", icon: ExternalLink, label: "Open", onClick: (p) => window.open(p.url) },
{ id: "edit", icon: Pencil, label: "Edit", onClick: (p) => edit(p) },
{ id: "delete", icon: Trash, label: "Delete", tone: "destructive", onClick: (p) => remove(p) },
]}
rowActionsInline also caps a plain rowActions list. Playground: ?scenario=row-actions-overflow.
Saved views and column defaults
A saved view (or URL-owned columnState) only decides for the columns it names in order or
hidden. A column added later, or declared hidden: true, that an older state never mentions
keeps the column's own default.
Drill-down
Use it when a person needs to ask "how many, how much, by what" of a table: group by a Dimension, pivot a second one across the top, pick the Measures, click a group to drill in, compare a window with the one before. Every state is a URL a person can share.
Rows already loaded — one prop. "Loaded" means the COMPLETE set: every row of the source is
in data. Over a partial source — server pagination (controlled / controlled-append), a
coverage or facets.totalRows larger than the rows held — the table refuses in words
("63 of 4,812,004 rows loaded: group on the server.") instead of printing counts of whatever
happens to be loaded; use the server path below.
import { MatrxDataTable } from "@ai-matrx/design-system/data-table";
<MatrxDataTable data={jobs} columns={columns} getRowId={(j) => j.id} drill={{ local: true }} />
Dimensions (few-valued text judged over every row, yes/no, dates with their grains — never an
id or a near-unique column) and Measures (count; sum, average, min, max of each number column in
its own format) are inferred from columns. The table's search and column filters narrow what
the groups count. Narrow or extend them with drill={{ local: true, dimensions: ["status", "owner"], measures: ["count", "sum_amount"], extraMeasures: [...] }}; other knobs: url (prefix, default
drill, or false), timeZone, limit (groups before Other, default 100), ceiling (default
50,000 rows — past it the table says so and names the server path; never a silent cut), source
({ total }, { paged: true } or { cap } when the table cannot tell it holds part of its source),
hide (Dimension, Measure or column keys never offered — not as a Dimension, a Measure or an
inferred attribute).
What the table reads on its own: coverage.total / matched / cap, facets.totalRows, a
controlled-local or controlled-append source's sourceProcessing.sourceTotal, and an append
source's pagination.totalItems. A list that reached coverage.cap refuses ("List capped at 200:
group on the server."); an append source with no next page and its total held is COMPLETE and
groups. While the trail narrows a complete list, the host's coverage is not restated as "Filtering
the N loaded". An inferred Measure prints in its column's format (money, duration, percent; a
blank reads "—"), so extraMeasures is only for a new label or op. A Measure may say its blanks:
nulls: "unknown" (a group with a missing input reads Unknown) or "count" (the known part, then
"N unknown"); the answer row carries unknown[measure]. In development a level, a breakout or a
named Dimension that is not a Dimension is warned once, with why the column was dropped
(near-unique, too many values, no accessor) — drillDroppedItems returns the same lines for a test.
A chart above the groups? One more option (Arman, 2026-10-25: the chart is part of the table):
<MatrxDataTable data={jobs} columns={columns} getRowId={(j) => j.id} drill={{ local: true, chart: true }} />
chart is true or { measure, stack, time, seriesLimit, height } (MatrxDataTableDrillChart).
The bars are counted from the same rows the groups count, so the table's search and filters narrow
both. A server-answered drill passes chart plus chartAnswers / chartError (the two rounds
drillChartQuestions plans). The table never imports recharts: the HOST binds the chart once
(MatrxDataTableHost.DrillChart = MatrxDrillChart from ./data-table/drill-chart, loaded lazily);
a host with none shows "Chart unavailable here" where the chart would be. Guard:
src/data-table/MatrxDataTable.drill-chart.test.tsx.
Rows on the server — the declared definition: a *.drill.ts definition in
apps/shared/records/scripts/drill-definitions/, read through @ai-matrx/records
(drillAsk) — mount DrillExplorer (matrx-frontend) or useDrill / the records-grid drill
(@ai-matrx/records-ui), which hand the table { dimensions, measures, question, onQuestionChange, answers }. Same screen; swapping local for server changes no UI.
How to declare levels
A drill-down is a table about the thing you clicked, not a list. Each Dimension may declare what a
LEVEL of it looks like — the same shape locally and on the server (MatrxDrillLevel):
| Key | Means | Example |
|---|---|---|
breakouts | Dimension references offered as one-click "By …" chips once one of its values is drilled (and first in the row menu's Break out by), in order | person → ["provider", "model", "agent", "feature", "conversation", "at:day"] |
show | The Measures shown while grouping by it (a chip, the Group menu, a drill into it) | conversation → ["cost", "calls", "tokens_in", "tokens_out", "started", "last_activity"] |
attributes | What describes ONE value: columns while grouping by it, and the drilled value's header. Server: Dimension keys each group holds one value of. Local: any column key | person → ["organization"]; model → ["provider"] |
records | The columns "See these records" shows at this level | conversation → ["created_at", "agent_id", "cost"] |
A breakout may name A SIBLING DEFINITION'S Dimension, <definition>:<dimension>
(ai_usage_executions:conversation on ai_usage's person — that rollup has no conversations). The
host offers it through drill.cross (MatrxDrillCross: label(source, ref) → the Dimension's words
or null = not offered; open(source, ref, question) switches the source, grouped by ref, carrying
the trail and window). parseBreakoutRef reads one; drillLevelProblems(dims, measures, { siblings: { ai_usage_executions: [...keys] } }) judges it. Drilling a value lands on its level's FIRST free
breakout (after a declared path), with that level's show; a question grouped by a Dimension and
naming no Measures shows its level's, an address included (?drill.by=conversation →
drillQuestionFromParams(params, fallback, prefix, dimensions), useDrillUrlState({ dimensions }),
drillSettleShow). attributes: [] says a level has none.
What the drilled screen does on its own: every header number has a visible label; a relation value
(entity) offers its record doors (open, preview, its kind's menu, via the host's
resolveEntityDoors) beside its crumb and in a group row's ⋯ menu, and "See records" lists the
value's records; two groups with one label read "label · first distinguishing fact" (an attribute,
else a moment Measure — drillDistinctLabels); an attribute column the trail filters or every group
holds the same value of is dropped, as are the records list's filtered, header-said and constant
columns; a failed attribute read (MatrxDrillAttribute.error, MatrxDrillFocusAttribute.error) is
said in the canonical ErrorBox, never as "—".
Selectors for walks: data-matrx-drill-group / data-matrx-drill-value / data-matrx-drill-level
(a group row), data-matrx-drill-into (its label), data-matrx-drill-breakout (+
-breakout-source for a sibling's), data-matrx-drill-see-records, data-matrx-drill-focus-doors,
data-matrx-drill-focus-total, data-matrx-drill-focus-attribute, data-matrx-drill-crumb,
data-matrx-drill-failed, data-matrx-drill-unknown.
Group actions, doors, names, the fixed filter and where the controls sit
| Option | On | Means |
|---|---|---|
groupActions(groups) | a Dimension | items drawn in a group row's ⋯ menu and its right-click menu ("Copy repair brief" on a lint class); selector data-matrx-drill-group-action |
doorFor(value) | a Dimension | where a named group opens: { entity, id } through the host's resolveEntityDoors, or { href } (a page). entity alone still opens a value that IS a record id |
labelFrom | a Dimension, or local labelFrom: { owner_id: "owner_name" } | the column (local) or Dimension (server, useDrill) whose value names each value; labelFor wins when given |
controls: "toolbar" | "trail" | the drill config | the trail + window/Measures/Group-by in the toolbar, or in their own row (MatrxDrillBar, data-matrx-drill-bar). Default "trail" under hideToolbar, else "toolbar". MatrxDrillControls is the three menus for a page's own row |
base: [{ label, value }] | the drill config | the host's fixed filter (one site), said first in the trail as a crumb no click removes (data-matrx-drill-base); the host applies it (useDrill({ base })) |
A blank text value and a missing one are ONE "No value" group, locally and in the server door.
Nothing declared = inferred: breakouts are every other Dimension, fewest values first, time last at
a day; show is the question's own; attributes (local mode) are the columns that hold one value
within every group of it.
Local — one prop, keyed by Dimension:
<MatrxDataTable data={jobs} columns={columns} getRowId={(j) => j.id}
drill={{ local: true, levels: {
owner: { breakouts: ["client", "project", "status", "due:month"], attributes: ["team"], show: ["count", "sum_amount"] },
client: { attributes: ["industry"] },
} }} />
Server — on the Dimension in its *.drill.ts (judged by platform.drill_definition_problems;
drill_describe hands it to the screen):
{ key: "person", label: "Person", from: "person_id", kind: "relation", relation: { token: "user", person: true },
level: { breakouts: ["provider", "model", "agent", "feature", "conversation", "at:day"], attributes: ["organization"] } },
A host that answers itself hands drill.attributes (columns while grouping) and
drill.focusAttributes (the drilled value's header) on MatrxDataTableDrillConfig, or the same
attributes to MatrxDrillAnswerTable and focusAttributes / measures / answers to
MatrxDrillTrail. Helpers: drillWithLevels, drillBreakouts, drillRegroup, drillLevelShow,
drillLevelProblems (the local validator), drillGroupAttributesLocally, drillInferAttributes,
parseBreakoutRef, drillSettleShow, drillDistinctLabels, drillDroppedItems.
Try it: open the playground at http://127.0.0.1:3026/?scenario=drill-level-owner (Ada's header
and her chips), drill-level-breakout (Ada by client), drill-level-attributes (clients with
their industry). Also http://127.0.0.1:3026/?scenario=drill-start (pnpm demo; also
drill-blocked-by-owner, drill-pivot-month, drill-chart, drill-search, drill-partial,
drill-ceiling) — any setup is a link (Copy link in its panel; demo/README.md § Links). State: common-docs/systems/data/drill-down/STATE.md.