Minipages is our internal clone of claude.ai-artifacts. It is a dashboard app that serves static HTML, CSS, and JavaScript files 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
Minipageresource 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
ResourceComments. - 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:
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 withbun buildinstead. - 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
<!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 |
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.
// 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,
})fieldis a payload field, or one ofidentifier,parent_identifier,license,created_at,valid_from,source_ref,patient_id,study_id,datasource_id. An unknown field fails the request.- Operators:
eqnegtgteltlteinbetweencontainsstarts_withends_withbeforeafteris_nullis_not_null(value: nullfor the last two) andoverlaps. - 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
// 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)