---
title: "Minipages"
description: "Static HTML reports in the dashboard, with live vxData, interactive charts and comments."
image: "https://docs.virdx.dev/img/virdx-social-card.png"
---

> Documentation Index
> Fetch the complete documentation index at: https://docs.virdx.dev/llms.txt
> Use this file to discover all available pages before exploring further.

# Minipages

*Minipages* is our internal clone of claude.ai-artifacts.
It is a [dashboard app that serves static HTML, CSS, and JavaScript files](https://dashboard.fra.virdx.dev/minipages) from a Ceph bucket.
Agents have made the creation of visual HTML reports and interactive explainers very easy, and we want to encourage their creation and usage.

Why use it over claude.ai or manually HTML files?

- Minipages are hosted on our infrastructure, protecting our IP and patient data.
- Minipages can be connected directly to vxData, so they can show live data and charts.
- Minipages can contain more advanced components such as interactive 3D viewers than claude.ai can.
- Minipages are promintently featured in a central location - the Virdx dashboard. No more searching for URLs in Slack messages.

## Concept

- A page is a `Minipage` resource in vxData with a title, a description, and a URL to a folder in our Ceph bucket. This makes it easy for us to expose them in the dashboard, version them, and provide access control if needed.
- The dashboard serves the HTML, CSS, and JavaScript as-is - there's no sandbox or security restrictions. This keeps things simple, but requires you to only publish trustworthy code. Never upload code that attempts to perform deletions or data modifications. Such operations are blocked by the access control mechanisms.
- Pages hold no patient data. Data can be loaded by the browser at view time by querying vxData at `/api/platform/...` - agents don't see any of it while creating the page.
- People can comment on a page. Comments are `ResourceComment`s.
- Every signed-in person sees every page; there is no per-page access control.

## Creating a minipage

Tell your agent: "Build a minipage in a scratch folder. Follow the instructions for
agents at https://docs.fra.virdx.dev/dashboard-frontend/minipages/index.md". Then publish the
folder with the [vx CLI](/vx/):

```sh
export VXDATA_TOKEN=...   # your vxData token
vx minipage deploy my-report ./my-report \
  --title "Synthetic grading ladder" \
  --description "What each change buys on real scans." \
  --author "Jane Doe (with Claude)"
```

Open `https://dashboard.fra.virdx.dev/minipages/my-report`. 
Run the same command to update the page. 
The dashboard shows `title` as the heading, `description` under it and `author` in the list. 
Slugs starting with `_` are reserved (`_kit`).

## Instructions for agents

A minipage is a folder of static files. It is published to the `minipages`
bucket, registered as a `Minipage` resource in vxData, and shown at
`/minipages/<slug>` in the dashboard, inside an iframe on the dashboard
origin. Only signed-in people can open it. You (the agent) can publish but
never read it back.

### Rules

- No patient data in the files. Load it at view time with `query()` from the
  kit; the request runs as the person viewing the page.
- No external URLs: no CDNs, fonts, analytics or APIs outside `/api/...`.
  Bundle npm packages with `bun build` instead.
- Use the kit below for look and charts. Add page-specific CSS in a
  `<style>` block only when no kit class fits.
- Write plain HTML + ES modules. TypeScript is fine if you bundle it.
- Keep the minipage out of git. Build it in a scratch folder.
- Give every section, figure, chart and table a stable `id`. Comments
  anchor to ids; an id that disappears on republish detaches its threads.

### Page skeleton

```html
<!doctype html>
<html lang="en">
<head>
  <meta charset="utf-8" />
  <title>My minipage</title>
  <link rel="stylesheet" href="../_kit/vx.css" />
</head>
<body>
  <main class="vx-page">
<div class="vx-eyebrow">Project · topic</div>
<h1 class="vx-title">Title</h1>
<p class="vx-byline">2026-10-12 · Author</p>
<p class="vx-lead">One-sentence answer first.</p>
<section class="vx-section" id="results"><h2>Results</h2> ...</section>
  </main>
  <script type="module">
import { syncTheme, barChart } from '../_kit/vx.js'
syncTheme()
  </script>
</body>
</html>
```

### CSS classes (`vx.css`)

| Class | Use |
| --- | --- |
| `vx-page` (`.wide`) | page container, 1040 px (1400 px) |
| `vx-eyebrow`, `vx-title`, `vx-byline`, `vx-lead` | page header; an eyebrow (`1 · Data`) also opens a section |
| `vx-col` | limit prose to a 72ch reading column |
| `vx-section` + `h2`/`h3` | sections; give each an `id` for `vx-toc` links |
| `vx-toc` | `<ul>` of in-page links |
| `vx-grid-2`, `vx-grid-3`, `vx-stack` | layout |
| `vx-multiples` > `figure.vx-card` | small multiples: same chart per model or site, auto-fitting columns |
| `vx-card` | bordered surface |
| `details.vx-fold` > `summary` (+ `span.vx-muted` hint) | collapsible block: methods, configs, long tables |
| `vx-kpis` > `vx-kpi` (`.label` `.value` `.note`) | headline numbers |
| `vx-callout` (`.warn`, `.danger`) | takeaways and warnings |
| `vx-issue` > `.label` + `h3` | open problem or failure mode |
| `vx-badge` (`.primary` `.success` `.warning` `.danger`) | inline status |
| `vx-delta` (`.up`, `.down`) | signed change, e.g. in a ladder table |
| `vx-tag` | small mono label, e.g. a run id |
| `vx-table-wrap` > `table.vx-table` (`.compact`) | tables; `td.num` right-aligns, `.best` highlights, `tr.ref` marks a reference row (oracle, baseline, total), `td.dim` mutes a context cell, `th[data-sort]` sorts |
| `figure.vx-figure` > `div` + `figcaption` | chart with caption |
| `div.vx-plate` > `img` | white plate for a PNG figure (readable in dark mode) |
| `ol.vx-steps` > `li` > `b` + `span` | pipeline / step diagram |
| `vx-muted`, `vx-small`, `vx-status` (`.error`) | secondary text, live-data status |
| `var(--chart-1..5)`, `var(--seq-0..5)`, `var(--heat-0/1)` | colours: distinct series, an ordered light-to-dark scale (`--seq-0` = none), a heat scale |
| `vx-viewer` | box for the volume viewer |

### JS helpers (`vx.js`)

| Function | Does |
| --- | --- |
| `syncTheme()` | follow the dashboard's dark mode; call once |
| `query(payloadType, body?)` | vxData rows as the viewer; see [Loading data](#loading-data-with-query) |
| `objectUrl(s3Url)` | browser-readable URL for an `s3://` object |
| `barChart(div, { categories, series: [{ name, values }], horizontal?, min?, max?, format? })` | grouped bar chart with legend + hover |
| `lineChart(div, { series: [{ name, points: [[x, y]], dashed?, line? }], x?: { label, min, max, log }, y?: { label, min, max }, diagonal?, height?, format? })` | curves (ROC, scaling, training) with hover readout; legend click hides a series; `line: false` draws points |
| `stackedBars(div, { categories, series: [{ name, values }], palette?: 'seq' \| 'cat', format? })` | one bar per category split into shares, row total at the end; `seq` for ordered series (grades) |
| `heatMatrix(div, { rows, cols, values, rowTitle?, colTitle?, scale?: 'row' \| 'max' })` | confusion matrix or counts grid, shaded by row share |
| `boxPlot(div, { groups: [{ name, values? , stats? }], y?: { label, min, max }, height?, color? })` | box plots; `stats` = `[p5, p25, p50, p75, p95, n]` |
| `tips(root?)` | tooltip for every `[data-tip]` element (charts call it themselves) |
| `heatTable(table, { min?, max? })` | colour `td.num` cells low (red) to high (green) |
| `sortableTable(table)` | enable `th[data-sort]` sorting |
| `volumeViewer(div)` → `{ show(s3Url) }` | niivue viewer for NIfTI volumes |

Chart choice: one measure across items → bars (horizontal when labels are
long); several series per item → grouped bars; signed values → bars about
zero; a measure over a continuous x (ROC, data size, epochs) → `lineChart`
(`diagonal` for ROC chance, `x.log` for sizes). Put methods, configs and long
tables in `vx-fold` blocks so the page reads top-down. Put the numbers in a table only when readers look them up. Shares of a whole per item → `stackedBars`;
true vs predicted classes → `heatMatrix`; a score's spread per group → `boxPlot`; the
same chart for several models or sites → `vx-multiples`.

Keep every number in one `const DATA = { … }` at the top of the page and render
tables and charts from it, so they always agree. Colour has meaning: `--chart-*`
for series, `--seq-*` for ordered classes, the accent only for the best value.

### Loading data with `query()`

`query(payloadType, body?)` posts to vxData's `/query` as the viewer and returns
the `results` rows. Each row has the payload fields plus `identifier`,
`parent_identifier`, `datasource_id`, `license` and `created_at`. `body` is
merged into the request; the default `limit` is 1000 and there is no paging,
so narrow with filters or raise `limit`.

```js
// Every patient (up to 1000), newest first
const patients = await query('Patient')

// One filter: { field, operator, value }. Values are case-sensitive.
const t2w = await query('Volume', {
  filters: [{ field: 'volume_type', operator: 'eq', value: 't2w' }],
})

// Several filters are AND-ed
const basel = await query('Volume', {
  filters: [
{ field: 'volume_type', operator: 'in', value: ['t2w', 'adc'] },
{ field: 'datasource_id', operator: 'eq', value: 'datasource/basel' },
  ],
})

// OR (and nesting) with a group: { operator: 'and' | 'or', conditions }
const either = await query('Patient', {
  filters: [{
operator: 'or',
conditions: [
  { field: 'default_split', operator: 'eq', value: 'test' },
  { field: 'default_split', operator: 'eq', value: 'benchmark' },
],
  }],
})

// Everything under one resource: direct children, or all descendants
const volumes = await query('Volume', {
  parent_identifier: 'patient/basel/wM42jf3',
  include_indirect_parents: true,
})

// Sort and cap
const latest = await query('BenchmarkingResult', {
  filters: [{ field: 'eval_task', operator: 'starts_with', value: 'nc:' }],
  sort_by: 'created_at',
  sort_order: 'desc',
  limit: 20,
})
```

- `field` is a payload field, or one of `identifier`, `parent_identifier`,
  `license`, `created_at`, `valid_from`, `source_ref`, `patient_id`,
  `study_id`, `datasource_id`. An unknown field fails the request.
- Operators: `eq` `ne` `gt` `gte` `lt` `lte` `in` `between` `contains`
  `starts_with` `ends_with` `before` `after` `is_null` `is_not_null`
  (`value: null` for the last two) and `overlaps`.
- Rows are filtered to what the viewer may read; the same page can show
  different rows to different people. Show the row count.
- Payload types and their fields: `GET /api/platform/schemas`, or the vxData
  docs.

### Files, charts and viewers

```js
// An s3:// path from a row (e.g. Volume.path_nii, HistoScan.url) -> fetchable URL
const url = await objectUrl('s3://vxdata/giga_3d/.../scene.json')
const scene = await (await fetch(url)).json()
img.src = await objectUrl(row.url)   // images work the same way

// Bars: categories x series
barChart(document.getElementById('auc'), {
  categories: ['Site A', 'Site B'],
  series: [{ name: 'S0', values: [0.61, 0.55] }, { name: 'S5', values: [0.76, 0.68] }],
  min: 0.5,
})

// Curves: points are [x, y]; ROC with a chance line, sizes on a log axis
lineChart(document.getElementById('roc'), {
  series: [{ name: 'Model', points: [[0, 0], [0.2, 0.6], [1, 1]] }],
  x: { label: 'False positive rate', min: 0, max: 1 },
  y: { label: 'True positive rate', min: 0, max: 1 },
  diagonal: true,
})

// Tables: sort on th[data-sort], colour td.num from low to high
sortableTable(document.getElementById('runs'))
heatTable(document.getElementById('runs'), { min: 0.5, max: 0.9 })

// NIfTI viewer in a .vx-viewer box
const viewer = await volumeViewer(document.getElementById('viewer'))
await viewer.show(t2w[0].path_nii)
```

Source: https://docs.virdx.dev/dashboard-frontend/minipages/index.mdx
