A dashboard panel with bar KPI tiles, a line chart, a donut chart and a table

@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.

  1. data/dashboardDashboard and DashboardWidget
  2. data/dashboard/enginelayout math, breakpoints, persistence
  3. core/data-sourcehost data for widgets
  4. drag/gesturepointer moves and resizes
  5. texuli/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.

Width
Compaction
Motion

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

  1. 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

  1. 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
KeysWhat they do
TabReach each widget’s move and resize handles in reading order
Space or EnterPick the widget up (or start resizing); again to drop
Arrow keysMove one column or row; on the resize handle, change the size
EscapeCancel: the widget goes back where it was
Tab while picked upCancel and move on
Left and right in right-to-left textFlip, so “left” follows the reading direction
Why a change is refused
unknown-itemthe widget no longer exists
lockeda locked widget is in the way
edgeit is already at the edge
sizeit is at its size limit
Every announcement template (10), localizable through the messages prop
MessageEnglish template
moveInstructionsPress Space or Enter to pick up. Use the arrow keys to move one column or row, Space or Enter to drop, Escape to cancel.
resizeInstructionsPress 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}.
resizeStartedResizing {item}: {width} columns by {height} rows.
resized{item} resized to {width} columns by {height} rows.
resizeDone{item} is now {width} columns by {height} rows.
cancelledChange cancelled. {item} is back at column {column}, row {row}, {width} columns by {height} rows.
refusedCannot 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
IdFromColumns
lg1,024 px12
md640 px6
sm0 px1

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.

Limits the engine holds
Widgets in one layout200
Columns in a grid24
Rows one widget may span48
Last row a widget may start on4,096
Breakpoints8
Saved state512 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