Skip to content
VIRDX

Minipages

Static HTML reports in the dashboard, with live vxData, interactive charts and comments.

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

<!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,
})
  • 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

// 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)
Navigation

Type to search…

↑↓ navigate↵ selectEsc close