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

1. a cell whose operations the declared glyph can express is drawn in it;
2. 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;
3. 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.

```yaml
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

- `label` present: the grid is `role="img"` carrying it as the accessible name
- `label` absent: the grid is `aria-hidden` and 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.kind` admits only matching edges; an intersection with no matching edge renders an EMPTY cell
- `glyph: 'quadrant'` draws the four-corner mark for a four-letter `RCUD` grant
- `glyph: 'filled'` draws a solid mark and claims nothing about the operations
- `glyph: 'ops-label'` prints the ops letters verbatim in the cell
- A declared `quadrant` degrades to `ops-label` for `RW` / `AI`, and to `filled` for an ops-less grant
- `cell.flag` marks 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 `emptyMessage` and 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 mounted
- `rows.kinds` admits exactly the declared node kinds as rows; an undeclared kind is not drawn
- `columns.kinds` admits exactly the declared node kinds as columns, including the open rung
- `rows.groupBy` bands the rows by a node field, one band per distinct value
- `columns.sortBy` + `sortDirection: desc` orders the columns most-privileged first
- The read carries the CALLER's credentials: a non-admin gets no grid, not somebody else's
- `columns.groupBy` bands the columns by a node field, one `data-matrix-column-group` per distinct value
- An operator opens the page and reads a grouped, ordered grid of their own configuration

### Schema refusals

- An empty `dataSource.system.endpoint` is refused at boot, naming the offending path
- A `cell.glyph` outside `quadrant` / `filled` / `ops-label` is refused at boot
- A DB-table `dataSource` is refused at boot rather than decoding and rendering nothing
- A `cell.flag` with a `field` and no `label` is refused at boot
- An empty `rows.kinds` list 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
