The Matrix Grid
The matrix component — two sets of graph nodes crossed into a grid whose cells are glyphs, with its accessible twin.
The one data component that draws no records. A matrix crosses two sets of graph nodes into a grid whose cells are glyphs rather than words — a mark that says "create and read, not update, not delete" in one shape you can scan a whole column of. It binds a read endpoint, never a table: where chart and kpi aggregate records, a matrix reads a graph's nodes and edges and draws the relationships between them.
Reach for it when the question is which of these reach which of those, and how strongly — permissions across roles, features across plans, coverage across teams. Reach for a table instead when the cells are values a reader needs to read word by word.
| Path | Kind | Values | Default | Description |
|---|---|---|---|---|
label |
string | Accessible name for the grid. Present: the grid is role="img" with this as its aria-label. Absent: the grid is aria-hidden and the accessible twin beneath it carries the facts alone. The twin renders either way. | ||
emptyMessage |
string | Message displayed when the bound graph resolves no cells. Not used for a DEGRADED read, where a source could not be resolved and the grid is missing its contribution rather than empty. |
dataSource
| Path | Kind | Values | Default | Description |
|---|---|---|---|---|
dataSource |
object | Graph read-endpoint binding for the matrix grid | ||
dataSource.system |
object | Graph read-endpoint binding: the nodes become the grid’s axes and the edges become its cells | ||
dataSource.system.endpoint |
string | Read endpoint path the grid fetches its graph from, server-side with the caller’s own credentials (e.g. /api/admin/organisation/graph) | ||
dataSource.system.nodesKey |
string | Key of the nodes array in the response envelope (default: 'nodes'). Nodes become the grid’s rows and columns. | ||
dataSource.system.edgesKey |
string | Key of the edges array in the response envelope (default: 'edges'). Edges become the grid’s cells. | ||
dataSource.system.query |
object | Static query params merged into every request to the endpoint |
autoSave
| Path | Kind | Values | Default | Description |
|---|---|---|---|---|
autoSave |
object | Configuration for automatic persistence of edits. Applies to table, form, kanban, and calendar components. | ||
autoSave.saveMode |
enum | auto, onBlur, manual |
Save trigger strategy: 'auto' (debounced), 'onBlur' (field blur), 'manual' (button). Default: 'manual'. | |
autoSave.autoSaveDebounceMs |
number | Debounce delay for auto-save in milliseconds (default: 500, min: 100) | ||
autoSave.showSaveIndicator |
boolean | Display a save status indicator (Saving... / Saved / Error). Default: true when saveMode is auto or onBlur. | ||
autoSave.saveIndicatorPosition |
enum | inline, toast, toolbar |
Where the save status indicator appears |
search
| Path | Kind | Values | Default | Description |
|---|---|---|---|---|
search |
object | Shared search bar configuration for data-bound components (table, kanban, calendar) | ||
search.enabled |
boolean | Enable search bar (default: true) | ||
search.placeholder |
string | Search input placeholder text | ||
search.debounceMs |
number | Debounce delay for search input in ms (default: 300) | ||
search.highlight |
boolean | Highlight matched search terms in results (default: false) |
rows
| Path | Kind | Values | Default | Description |
|---|---|---|---|---|
rows |
object | One axis of the grid: which node kinds it admits, how it groups them, and how it orders them. The SAME shape for rows and for columns. | ||
rows.kinds |
array | Node kinds admitted onto this axis, as the BOUND GRAPH spells them — open strings, never a platform vocabulary. Omitted: every node the graph returns is admitted. | ||
rows.kinds[] |
string | One node kind, as the bound graph spells it | ||
rows.groupBy |
string | Node field whose value groups this axis into labelled bands (e.g. a resource family). Names a field of the bound graph’s nodes, so it is an open string. Drawn on BOTH axes: rows band into stacked groups, columns into groups of headings. Order within a band is the axis’ own, so sortBy still applies inside a group; a node not carrying the field lands in an unnamed band. |
||
rows.sortBy |
string | Node field this axis orders by. Names a field of the bound graph’s nodes, so it is an open string; nodes that do not carry it keep their source order after those that do. | ||
rows.sortDirection |
enum | asc, desc |
Direction an axis orders in (default: 'asc'). Closed because a direction is platform vocabulary; the field it sorts on is not. |
columns
| Path | Kind | Values | Default | Description |
|---|---|---|---|---|
columns |
object | One axis of the grid: which node kinds it admits, how it groups them, and how it orders them. The SAME shape for rows and for columns. | ||
columns.kinds |
array | Node kinds admitted onto this axis, as the BOUND GRAPH spells them — open strings, never a platform vocabulary. Omitted: every node the graph returns is admitted. | ||
columns.kinds[] |
string | One node kind, as the bound graph spells it | ||
columns.groupBy |
string | Node field whose value groups this axis into labelled bands (e.g. a resource family). Names a field of the bound graph’s nodes, so it is an open string. Drawn on BOTH axes: rows band into stacked groups, columns into groups of headings. Order within a band is the axis’ own, so sortBy still applies inside a group; a node not carrying the field lands in an unnamed band. |
||
columns.sortBy |
string | Node field this axis orders by. Names a field of the bound graph’s nodes, so it is an open string; nodes that do not carry it keep their source order after those that do. | ||
columns.sortDirection |
enum | asc, desc |
Direction an axis orders in (default: 'asc'). Closed because a direction is platform vocabulary; the field it sorts on is not. |
cell
| Path | Kind | Values | Default | Description |
|---|---|---|---|---|
cell |
object | What fills the intersection of a row node and a column node: which edges count, how they are drawn, and the one flag drawn beside them | ||
cell.from |
string | Collection in the response envelope the cells are read from (default: the binding’s edgesKey). An open string: it names a key of the bound graph. |
||
cell.kind |
string | Edge kind admitted as a cell — every other edge is ignored. Names the bound graph’s own edge vocabulary, so it is an open string. Omitted: every edge between an axis pair fills its cell. | ||
cell.glyph |
enum | quadrant, filled, ops-label |
How a populated cell is drawn: quadrant (four corners, one per ops letter), filled (a solid connected mark), ops-label (the ops letters as text). Closed because the renderer draws each one. |
|
cell.opsField |
string | Edge field carrying the operation letters the glyph reads. Names a field of the bound graph’s edges, so it is an open string. Omitted: no cell has ops, and every populated cell renders filled. |
||
cell.flag |
object | One boolean edge field marked on the glyph and named in the accessible twin, with the word used for it | ||
cell.flag.field |
string | Boolean field on the edge whose truth marks this cell. Names a field of the bound graph’s edges, so it is an open string. | ||
cell.flag.label |
string | The word for a marked cell, used in the accessible twin’s flag column and as the marked cell’s title. There is no rendered legend. Required alongside field: a mark nobody can name is a mark a screen-reader user cannot read. |
Every property is optional. A matrix missing its axes renders its empty state; it does not stop your app booting.
Binding a graph
A matrix reads two collections that address each other by id, so its binding names both — unlike the single rowsKey of an ordinary system source. endpoint is required; nodesKey and edgesKey default to nodes and edges, and query merges static parameters into every request.
Nodes become the axes; edges become the cells. Because the read runs as the visitor, they only ever see the part of the graph they could have read themselves — and a matrix on a page they may not read draws nothing rather than someone else's data.
The two axes
rows and columns take the same four keys, so learning one teaches you the other. kinds admits node kinds onto the axis, as the endpoint spells them — omit it and every node is admitted. groupBy bands the axis into labelled groups, drawn on both axes. sortBy orders it, with nodes lacking the field keeping their source order after those that have it, and sortDirection is asc by default.
Neither axis is the special one. A band wraps the rows or columns it holds rather than captioning them from a distance, so the grouping survives a re-order and reads correctly to a screen reader.
Two details follow, on either axis. Ordering within a band is the axis's own order, so sortBy keeps working inside each group rather than being overridden by it, and the bands themselves come in the order their values first appear along the axis. And a node that does not carry the field lands in an unnamed band, which draws no caption — so declaring groupBy never hides a node, it only leaves one ungrouped.
kinds, groupBy and sortBy name things in your endpoint's vocabulary, not Sovrium's. Nothing here is an enum: point a matrix at a different graph and its own node kinds and fields work unchanged.
Filling a cell
cell.from is the collection the cells are read from, defaulting to edgesKey. kind admits one edge kind; omit it and every edge between an axis pair fills its cell. glyph is quadrant, filled or ops-label. opsField names the edge field carrying the operation letters the glyph reads — omit it and every filled cell is filled. flag is { field, label }, one boolean edge field marked on the cell, and both keys are required together.
An intersection with no matching edge still renders as a cell — empty, holding the grid's shape. A row whose cells are all empty is information: it is a thing nothing reaches.
When the glyph cannot express the cell
glyph is one value and a real grid usually needs more than one, because the right mark depends on the shape of the operations, not on the row. So glyph names the cell's primary rendering, and a cell it cannot express steps down rather than losing what it knows:
- a cell whose operations the declared glyph can express is drawn in it;
- a cell it cannot falls back to
ops-label— the letters are printed rather than dropped, because dropping them would make two different grants look identical; - a matching edge carrying no operations renders
filled— connected, with nothing further claimed.
One grid declaring glyph: quadrant therefore draws quadrants where the operations have four parts, letters where they do not, and solid marks where there are none. You do not configure that per row; it follows the data.
Accessibility
A grid of glyphs is not readable by itself, so a matrix always renders an accessible twin: a real table beneath the grid with one row per filled cell, naming both ends, the operations and the flag. Nothing switches it off. It is the primary artifact, not a fallback.
label decides how the drawing is announced. Set, the grid is one named figure with role="img"; omitted, the grid is hidden from assistive technology. The twin renders either way. An unnamed figure announced as a figure is noise, so a matrix with no label stays silent and lets the table speak.
flag.label is the word the twin uses for a marked cell, and the marked cell's hover text. That is why it is required alongside flag.field: the table is where a mark is actually read, and an unnamed mark leaves nothing there to read.
Rendering
A matrix is drawn on the server. The grid and its twin are in the first response — no hydration, nothing added to the browser bundle, and readable with scripting off. A page can carry one without paying for a chart library.
pages:
- name: Access
path: /access
components:
- type: matrix
dataSource:
system: { endpoint: /api/admin/organisation/graph }
rows:
kinds: [table, page, form, bucket, agent-resource]
groupBy: family
columns:
kinds: [role, team, open]
sortBy: level
sortDirection: desc
cell:
kind: grant
glyph: quadrant
opsField: ops
flag: { field: viaOpenRung, label: granted to everyone }
label: Who can do what to what
emptyMessage: No grants declared.Behaviour
Accessible twin
labelpresent: the grid isrole="img"carrying it as the accessible namelabelabsent: the grid isaria-hiddenand exposes no accessible name- The twin is a real table and renders in BOTH states, never
aria-hidden - The twin has exactly one row per POPULATED cell — no row for an empty intersection
- A flagged cell's twin row names the flag with
cell.flag.label, and an unflagged row does not - The same grid read twice — once as a named figure, once as the table alone — carries the same facts
Cell glyphs
cell.kindadmits only matching edges; an intersection with no matching edge renders an EMPTY cellglyph: 'quadrant'draws the four-corner mark for a four-letterRCUDgrantglyph: 'filled'draws a solid mark and claims nothing about the operationsglyph: 'ops-label'prints the ops letters verbatim in the cell- A declared
quadrantdegrades toops-labelforRW/AI, and tofilledfor an ops-less grant cell.flagmarks exactly the cells whose flag field is true, and no others- One grid draws quadrants, letters and filled marks side by side, and flags the open-rung cells
Empty and degraded reads
- An axis admitting nothing renders
emptyMessageand no grid - Rows present but no matching edge renders the GRID with every cell empty, not the empty state
- A degraded read names each unresolved source and does NOT render as an empty-but-healthy grid
- A read the caller is refused does not draw a grid claiming zero grants
- The four reads stay tellable apart from each other
Grid rendering
type: 'matrix'decodes, the page boots, and the grid is in the FIRST response with no island mountedrows.kindsadmits exactly the declared node kinds as rows; an undeclared kind is not drawncolumns.kindsadmits exactly the declared node kinds as columns, including the open rungrows.groupBybands the rows by a node field, one band per distinct valuecolumns.sortBy+sortDirection: descorders the columns most-privileged first- The read carries the CALLER's credentials: a non-admin gets no grid, not somebody else's
columns.groupBybands the columns by a node field, onedata-matrix-column-groupper distinct value- An operator opens the page and reads a grouped, ordered grid of their own configuration
Schema refusals
- An empty
dataSource.system.endpointis refused at boot, naming the offending path - A
cell.glyphoutsidequadrant/filled/ops-labelis refused at boot - A DB-table
dataSourceis refused at boot rather than decoding and rendering nothing - A
cell.flagwith afieldand nolabelis refused at boot - An empty
rows.kindslist is refused at boot rather than reading as "admit nothing" - Every mis-declaration is met at boot, and the valid config next to it still boots
Last updated September 23, 2026
This documentation was written with AI, so errors or outdated content are possible. Sovrium is in beta. Contributions and corrections are welcome.