# Dashboards people arrange, from the keyboard too

> The Texuli dashboard (@texuli/data/dashboard): widgets moved and resized by pointer or keyboard, responsive breakpoints, safe persistence and agent data.

Language: en
Canonical: https://texuli.com/dashboards-page/
Markdown: https://texuli.com/dashboards-page.md (this page). Every texuli.com page has a Markdown twin at its URL plus `.md` (the home page is https://texuli.com/index.md), and any page URL answers `Accept: text/markdown` with its twin instead of HTML.
Last Updated: 2026-11-13

---

![A dashboard panel with bar KPI tiles, a line chart, a donut chart and a table](https://texuli.com/_astro/page-dashboards.YRLLJtEf.webp)

@texuli/data/dashboard · new in 0.8.0

A widget grid with pointer and keyboard moves and resizes, announcements for every step, a toolbar for people who cannot drag, breakpoints, a layout glide that respects reduced motion, and persistence that reads saved layouts as untrusted input. The dashboard is a new subpath of `@texuli/data`, on npm from `0.8.0`, a pre-1.0 release; `0.7.0` and earlier are without it.

- `data/dashboard` Dashboard and DashboardWidget
- `data/dashboard/engine` layout math, breakpoints, persistence
- `core/data-source` host data for widgets
- `drag/gesture` pointer moves and resizes
- `texuli/motion` the layout glide

## Arrange it yourself

Five widgets on a twelve-column grid, their numbers and tickets read from an in-page data source. Drag a widget by its move handle or its corner by the resize handle; or press Space or Enter on a handle, use the arrow keys, and press Space or Enter to drop (Escape puts it back). A click on a handle opens a toolbar, so nothing needs a drag. Every step is announced.

Width Measured Wide: 12 columns Medium: 6 columns Narrow: 1 column Compaction Float up Keep gaps Motion Glide to the new cell

Grid in use: **…** (forced).

Press Space or Enter to pick up. Use the arrow keys to move one column or row, Space or Enter to drop, Escape to cancel.

Press Space or Enter to resize. Left and right arrows change the width, up and down the height. Space or Enter to finish, Escape to cancel.

- ### Signups … Loading…
- ### Revenue … Loading…
- ### Uptime (locked) … Never moves.
- ### Open tickets Loading from the tickets source…
- ### Deploys … Loading…

### What a screen reader hears

- Pick a widget up with Space or Enter on its move handle.

### Saved layout

```
Not changed yet: the widgets use their own places.
```

### Host data source calls

- Waiting for the first read.

The data source answers from memory after a short delay; nothing leaves your browser. With your system set to reduce motion, changes commit at once whatever the motion switch says.

## Keyboard and announcements

Handles are buttons with instructions; the widget moves while focus stays on its handle, and a polite live region announces the pick up, every step, the drop, a cancel and every refusal (a locked widget in the way, an edge, a size limit).

Keys on a handle

| Keys | What they do |
| --- | --- |
| Tab | Reach each widget’s move and resize handles in reading order |
| Space or Enter | Pick the widget up (or start resizing); again to drop |
| Arrow keys | Move one column or row; on the resize handle, change the size |
| Escape | Cancel: the widget goes back where it was |
| Tab while picked up | Cancel and move on |
| Left and right in right-to-left text | Flip, so “left” follows the reading direction |

Why a change is refused

| `unknown-item` | the widget no longer exists |
| --- | --- |
| `locked` | a locked widget is in the way |
| `edge` | it is already at the edge |
| `size` | it is at its size limit |

Every announcement template (10), localizable through the messages prop

| Message | English template |
| --- | --- |
| `moveInstructions` | Press Space or Enter to pick up. Use the arrow keys to move one column or row, Space or Enter to drop, Escape to cancel. |
| `resizeInstructions` | Press Space or Enter to resize. Left and right arrows change the width, up and down the height. Space or Enter to finish, Escape to cancel. |
| `pickedUp` | {item} picked up. Column {column}, row {row}. |
| `moved` | {item} moved to column {column}, row {row}. |
| `dropped` | {item} dropped at column {column}, row {row}. |
| `resizeStarted` | Resizing {item}: {width} columns by {height} rows. |
| `resized` | {item} resized to {width} columns by {height} rows. |
| `resizeDone` | {item} is now {width} columns by {height} rows. |
| `cancelled` | Change cancelled. {item} is back at column {column}, row {row}, {width} columns by {height} rows. |
| `refused` | Cannot change {item}: {reason}. |

## Breakpoints

The container’s width picks the grid: the full grid from 1,024 pixels, at most six columns from 640, one stacked column below. A breakpoint without a layout of its own reflows the nearest wider one in reading order. The width control above forces each one, as a print or a preview would.

The default breakpoints of a twelve-column dashboard

| Id | From | Columns |
| --- | --- | --- |
| `lg` | 1,024 px | 12 |
| `md` | 640 px | 6 |
| `sm` | 0 px | 1 |

## Saving a layout, and reading it back

Texuli stores nothing itself: the host keeps the bound layouts and writes them with serializeDashboard. What comes back from storage, a link or another person is untrusted, so parseDashboard refuses anything that is not a dashboard, and drops or re-places what does not fit.

 Saved dashboard (untrusted text)

```
{"format":"texuli-dashboard","formatVersion":1,"layouts":{"lg":[{"id":"signups","x":0,"y":0,"w":4,"h":3},{"id":"revenue","x":4,"y":0,"w":5,"h":3},{"id":"uptime","x":9,"y":0,"w":3,"h":3,"locked":true},{"id":"tickets","x":0,"y":3,"w":8,"h":6},{"id":"deploys","x":8,"y":3,"w":4,"h":6}]}}
```

Restored as saved: 5 widgets on the wide grid, no issues.

 signups revenue uptime tickets deploys

```
{"format":"texuli-dashboard","formatVersion":1,"layouts":{"lg":[{"id":"signups","x":0,"y":0,"w":4,"h":3},{"id":"revenue","x":4,"y":0,"w":5,"h":3},{"id":"uptime","x":9,"y":0,"w":3,"h":3,"locked":true},{"id":"tickets","x":0,"y":3,"w":8,"h":6},{"id":"deploys","x":8,"y":3,"w":4,"h":6}]}}
```

## Dashboards as agent data

An agent sends a data-dashboard with widgets as ordinary catalog nodes (spec 0.8.0). Widget data comes only from a source the host registered, never a URL or code, and the layout binds to a list state so people’s arrangement persists.

### The blueprint the agent wrote

Spec `0.8.0` adds the `data-dashboard` and `data-dashboard-widget` catalog ids; they ship in the `0.8.0` release of `@texuli/spec`.

```
{
  "format": "texuli-blueprint",
  "version": 1,
  "spec": "0.8.0",
  "id": "ops-overview",
  "catalog": "0000000000000000000000000000000000000000000000000000000000000000",
  "status": "draft",
  "createdAt": "2026-10-29T09:00:00Z",
  "updatedAt": "2026-10-29T09:00:00Z",
  "generator": {
    "kind": "agent",
    "name": "Harbor assistant",
    "model": "example-model"
  },
  "tier": "untrusted",
  "profile": "web",
  "state": {
    "arrangement": {
      "type": "list",
      "maxItems": 200,
      "initial": []
    }
  },
  "tree": {
    "id": "data-dashboard",
    "props": {
      "label": "Operations overview",
      "editable": true,
      "density": "compact"
    },
    "bind": {
      "layout": {
        "$state": "arrangement"
      }
    },
    "children": [
      {
        "id": "data-dashboard-widget",
        "props": {
          "widgetId": "errors",
          "title": "Error budget",
          "place": {
            "x": 0,
            "y": 0,
            "w": 6,
            "h": 4
          }
        },
        "children": [
          {
            "id": "charts-gauge",
            "props": {
              "label": "Error budget used",
              "value": 40,
              "min": 0,
              "max": 100
            }
          }
        ]
      },
      {
        "id": "data-dashboard-widget",
        "props": {
          "widgetId": "uptime",
          "title": "Uptime",
          "place": {
            "x": 6,
            "y": 0,
            "w": 6,
            "h": 4
          }
        },
        "children": [
          {
            "id": "charts-gauge",
            "props": {
              "label": "Uptime",
              "value": 99,
              "min": 0,
              "max": 100
            }
          }
        ]
      },
      {
        "id": "data-dashboard-widget",
        "props": {
          "widgetId": "tickets",
          "title": "Open tickets",
          "place": {
            "x": 0,
            "y": 4,
            "w": 12,
            "h": 6
          }
        },
        "children": [
          {
            "id": "data-grid",
            "props": {
              "caption": "Open tickets by points",
              "columns": [
                {
                  "id": "title",
                  "header": "Title"
                },
                {
                  "id": "status",
                  "header": "Status"
                },
                {
                  "id": "points",
                  "header": "Points",
                  "kind": "number",
                  "align": "end"
                }
              ],
              "rows": {
                "$source": "tickets",
                "query": {
                  "fields": [
                    "title",
                    "status",
                    "points"
                  ],
                  "filter": {
                    "type": "group",
                    "op": "and",
                    "children": [
                      {
                        "type": "condition",
                        "field": "status",
                        "operator": "in",
                        "value": [
                          "open",
                          "doing"
                        ]
                      }
                    ]
                  },
                  "sort": [
                    {
                      "field": "points",
                      "direction": "desc"
                    }
                  ],
                  "pageSize": 25
                }
              }
            }
          }
        ]
      }
    ]
  }
}
```

### The hostile binding

```
{
  "rows": {
    "$source": "https://attacker.example.com/rows"
  }
}
```

### Painted by the player

Validated at the untrusted tier; the tickets come from the host’s source. Move a widget with its handle and the layout is saved in the player’s state.

The player’s `arrangement` state is empty until someone moves a widget.

The frame below is 1,120 pixels wide so the dashboard uses its widest grid, where a change persists to the blueprint’s state; on narrower grids the player rearranges in view only. On a small screen it scrolls sideways.

Loading the player…

## What keeps it safe

A layout is data. These hold by construction in the engine and the catalog.

- ### Bounded, deterministic layouts At most 200 widgets, 24 columns, 48 rows per widget and 512 KiB of saved state. Numbers are clamped before any packing, and normalizing the same widgets gives the same grid on every replica.
- ### Reading order is DOM order Widgets paint row by row whatever order they were written in, so keyboard and screen-reader order always follow the grid.
- ### Sources, not URLs A widget’s data is a {"$source": name} binding the host resolves through its registry, with a validated query. A URL or an unknown name is refused before anything paints.
- ### Motion that steps aside Widgets glide to their new cell only after a change; nothing plays on load, and reduced motion or motion level none commits at once.

Limits the engine holds

| Widgets in one layout | 200 |
| --- | --- |
| Columns in a grid | 24 |
| Rows one widget may span | 48 |
| Last row a widget may start on | 4,096 |
| Breakpoints | 8 |
| Saved state | 512 KiB |

### In code

```
import {
  Dashboard, DashboardWidget, parseDashboard, serializeDashboard,
} from '@texuli/data/dashboard';

// What this person arranged last time: untrusted, so it is normalized.
const stored = parseDashboard(localStorage.getItem('ops') ?? '');
let layouts = $state({ ...stored.state?.layouts });

<Dashboard label="Operations" editable bind:layouts
  onLayoutChange={() => localStorage.setItem('ops', serializeDashboard(layouts))}>
  <DashboardWidget id="signups" title="Signups" x={0} y={0} w={4} h={2}>…</DashboardWidget>
  <DashboardWidget id="uptime" title="Uptime" w={4} h={2} locked>…</DashboardWidget>
</Dashboard>
```

```
import { createSourceRegistry, defineSource } from '@texuli/core/data-source';
import { Player } from '@texuli/player';

const tickets = defineSource('tickets', ticketSchema, ticketAdapter);
const sourceRegistry = createSourceRegistry([tickets]);

<Player blueprint={agentDraft} tier="untrusted" {sourceRegistry} />
```

Planned, not built yet: a sync binding of dashboards onto the document CRDT, and adding or removing widgets from the view (the engine has both operations; the host wires them today).

## Package docs

The site does not render the package docs yet. Read them in the repository:

- Dashboard: `packages/data/docs/data-dashboard.md`
- Dashboard engine: `packages/data/docs/data-dashboard-engine.md`
- Data sources: `packages/core/docs/core-data-source.md`
- Data sources in the player: `packages/player/docs/data-sources.md`

## For agents

Dashboard widgets you move and resize by pointer or keyboard, live in the browser. The live parts run in the browser and are not in this Markdown; the operations below do the same work through the read-only MCP server at https://texuli.com/mcp (no sign-in; see https://texuli.com/auth.md).

MCP operations ([reference](https://github.com/DailybotHQ/texuli/blob/main/packages/tools/docs/ops.md#operations)):

- `catalog.search`: Search the component catalog.
- `component.get`: Read a component manifest.

Docs:

- [@texuli/data README](https://github.com/DailybotHQ/texuli/blob/main/packages/data/README.md)
