Skip to content
VIRDX

Minipages

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

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

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

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

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