@texuli/data/dashboard · new in 0.8.0
Dashboards people arrange, from the keyboard too
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/dashboardDashboard and DashboardWidgetdata/dashboard/enginelayout math, breakpoints, persistencecore/data-sourcehost data for widgetsdrag/gesturepointer moves and resizestexuli/motionthe 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.
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.
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 | 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 |
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 |
| 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.
| 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.
Restored as saved: 5 widgets on the wide grid, no issues.
{"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.
| 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


