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

A minipage is a folder of static files (HTML, CSS, JS) that people or agents publish
for colleagues, without git or claude.ai. The dashboard shows it to signed-in people
at `https://dashboard.fra.virdx.dev/minipages/<slug>`. Use it instead of claude.ai
artifacts or zipped HTML reports: patient data stays on our infrastructure.

- A page is a `Minipage` resource in vxData; its files sit in vxData storage.
  Republishing keeps old versions in the resource history.
- Pages hold no patient data. They load it at view time through `/api/platform/...`,
  as the person viewing the page.
- People comment on a page, anchored to its sections, charts and tables. Comments are
  `ResourceComment`s with the `VIRDX` license, so agent tokens cannot read them.

## Publish

Tell your agent: "Build a minipage in a scratch folder. Follow the instructions for
agents at https://docs.fra.virdx.dev/dashboard/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
republish. The dashboard shows `title` as the heading, `description` under it and
`author` in the list. Slugs starting with `_` are reserved (`_kit`).

## Limitations

These are deliberate, for now:

- Static files only: no server code and no build step on our side.
- Page JS runs on the dashboard origin with the viewer's session. Only publish code
  you trust; there is no sandbox or CSP on outgoing requests yet.
- Every signed-in person sees every page; there is no per-page access control.
- Any vxData token can read the published files, so files must never contain patient
  data.
- Agents cannot see rendered pages (they lack patient data access), so they cannot
  debug them visually.
- `author` is set by the publisher or the browser and is not verified.
- Comments anchor to element ids; renaming an id detaches its comments. Deleting a
  comment deletes its replies.
- No delete command and no rollback UI: older versions exist only in the vxData
  history.

## 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 (serif title) |
| `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-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, `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 |
| `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 |
| `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.

### 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/minipages/index.mdx
